From 05fd55bde1e76319d19ad82d7ac9ae52337c29d2 Mon Sep 17 00:00:00 2001 From: Drew Dibble Date: Thu, 12 Nov 2026 14:30:19 -0700 Subject: [PATCH 01/56] 3D effects: every facet's seam has round joins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each 3D facet is stroked with a narrow seam of its own paint (0.05 to 0.75 pt; Revolve's is always 0.75 pt) so neighboring facets meet without an antialiasing hairline. In Extrude & Bevel, Inflate and Rotate under a white light, and in every face of the 3D (Classic) effects (Extrude & Bevel, Rotate and Revolve (Classic); shaded, No Shading, gradient and pattern alike), that seam kept the default Miter Join with its limit of 10. Revolve's seams and the colored-light seams were already round. A facet seen almost edge-on is a sharp sliver, and its mitered seam reached up to ten times its half-width past the facet's tip: thorns of up to 3.75 pt past the solid, along the tops of type seen tilted and at every edge-on bevel facet. #2706 (multiple lights) deliberately kept white-light seam joins as they were, so that legacy white-light output stayed byte-identical to main (its comment "Preserve existing white-light paints, alpha composition and seam joins exactly", and four PNGs compared byte for byte). That kept the thorns too. This change reverses that one part on purpose: the paints and alpha composition stay exactly as #2706 left them; only the join changes. - Outputs change, the document model doesn't. Exports draw the new joins (SVG writes stroke-linejoin="round", PDF and .ai draw round joins), and Expand Appearance writes round seams into the document. But nothing a document stores selects the join, and the format is unchanged, so there is no version gate: an old file drawn now simply has no thorns. - Round joins. Projected facets rarely have right-angle corners (a square seen at an angle is a parallelogram with acute corners, and an exact 90° corner sits at a √2 limit), and round is what the seams of colored lights and Revolve already draw. The cost: where a facet has no neighbor (Rotate's plane, every silhouette corner) its convex corners are drawn rounded by up to 0.375 pt instead of sharp. Alternatives weighed and not taken: a Miter Join with a limit near √2 at every seam, the composed clip included, which would bevel slivers but chamfer most projected corners as well; and seaming only the edges facets share, or clipping facets to their union as solid_surface does, which would keep every outer corner sharp but means a new geometry pass for every 3D effect. Round is the smallest change that matches the seams already drawn. - A seam no longer reaches past its facet by more than half its width, so the art's visual bounds shrink by up to 3.375 pt where a thorn stood (10 × 0.375 less the 0.375 a round seam still reaches). White-light output is no longer byte-identical to #2706's comparison; docs/3d-lighting-performance.md says so. Every seam stroke is made by lighting::seam_stroke. The colored clip in composed_art already outlined its seam with round joins and is unchanged. Tests: every seam of Extrude & Bevel, Extrude & Bevel (Classic, each branch), Rotate (Classic), Inflate and Rotate, under white and colored light, has round joins and reaches no more than half its width past its face, at views that make a sliver; Revolve (Classic)'s seams are round on each surface; a mitered seam fails them, and so does the legacy white-light Inflate test, which now expects Round. --- crates/effects/src/lighting.rs | 20 +- crates/effects/src/revolve.rs | 10 +- crates/effects/src/solids.rs | 9 +- crates/effects/src/solids/classic.rs | 13 +- crates/effects/src/solids/tests.rs | 225 +++++++++++++++++++++- crates/effects/src/solids/tests_shadow.rs | 2 +- docs/3d-lighting-performance.md | 40 +++- docs/effects-parity.md | 7 +- docs/gaps.md | 5 +- docs/target-app-parity.md | 5 +- docs/three-d.md | 33 +++- 11 files changed, 333 insertions(+), 36 deletions(-) diff --git a/crates/effects/src/lighting.rs b/crates/effects/src/lighting.rs index 9a352189d1..8592776a95 100644 --- a/crates/effects/src/lighting.rs +++ b/crates/effects/src/lighting.rs @@ -96,6 +96,18 @@ pub(crate) fn rgb_gradient_art( composed_art(id, path, paint, Ramp::Rgb(light), 0.75) } +/// The narrow stroke of a facet's own `paint`, `width` wide, that closes the hairline seam between +/// it and its neighbors where each is antialiased alone. Its joins are round: a facet seen almost +/// edge-on is a sharp sliver, and a mitered seam would reach far past its tip as a thorn, where a +/// round one reaches no more than half its width past the facet. Revolve's meets() counts on a +/// stroked seam reaching no more than half its width; faces drawn as clipped groups (whose clip, +/// in `composed_art`, already outlines the seam with round joins) are measured by their box. +pub(crate) fn seam_stroke(paint: Paint, width: f64) -> vectorcraft_doc::StrokeLayer { + let mut edge = vectorcraft_doc::StrokeLayer::new(paint, width); + edge.join = vectorcraft_doc::LineJoin::Round; + edge +} + /// Smooth Inflate triangles keep the seam within their narrow projected footprint. pub(crate) fn facet_art( id: vectorcraft_doc::NodeId, @@ -109,8 +121,7 @@ pub(crate) fn facet_art( let light = vectorcraft_three_d::LightGradient { diffuse: r, ambient: 0.0, color: [1.0; 3] }; if let Some(color) = paint.color() { let paint = smooth_paint(light, color); - let mut edge = vectorcraft_doc::StrokeLayer::new(paint.clone(), seam); - edge.join = vectorcraft_doc::LineJoin::Round; + let edge = seam_stroke(paint.clone(), seam); return vectorcraft_doc::Node::path( id, path.clone(), @@ -131,7 +142,7 @@ pub(crate) fn solid_surface(id: vectorcraft_doc::NodeId, facets: &[vectorcraft_t use kurbo::Shape; use std::sync::Arc; use vectorcraft_doc::color::BlendMode; - use vectorcraft_doc::{Appearance, Node, NodeKind, StrokeLayer}; + use vectorcraft_doc::{Appearance, Node, NodeKind}; use vectorcraft_geom::{BezPath, PathData, Rect, shapes}; let mut boundary = BezPath::new(); @@ -163,8 +174,7 @@ pub(crate) fn solid_surface(id: vectorcraft_doc::NodeId, facets: &[vectorcraft_t } else { Paint::solid(shade(Color::WHITE, color.map(|c| c * face.illumination[channel]))) }; - let mut edge = StrokeLayer::new(paint.clone(), (face.thickness() / 2.0).clamp(0.05, 0.75)); - edge.join = vectorcraft_doc::LineJoin::Round; + let edge = seam_stroke(paint.clone(), (face.thickness() / 2.0).clamp(0.05, 0.75)); Arc::new(Node::path( id, face.path(), diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index d82e4e8818..1eadfbd2c5 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use serde_json::Value; use vectorcraft_doc::color::{Color, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, LineJoin, Node, NodeId, NodeKind, StrokeLayer}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, Node, NodeId, NodeKind}; use vectorcraft_geom::{PathData, PathEl, Point, Rect, Vec2}; use vectorcraft_three_d::{Edge, Revolve}; @@ -404,10 +404,10 @@ fn surface( .into_iter() .flat_map(|item| { let AppearanceItem::Fill(fill) = &item else { return vec![item] }; - let mut edge = StrokeLayer::new(fill.paint.clone(), SEAM_WIDTH); - // Acute face corners must stay within the overlap width. Miter joins - // amplify this seam stroke into visible spikes along the silhouette. - edge.join = LineJoin::Round; + // Acute face corners must stay within the overlap width: meets() counts on a + // stroked seam reaching no more than half its width; faces drawn as clipped + // groups are measured by their box. + let mut edge = crate::lighting::seam_stroke(fill.paint.clone(), SEAM_WIDTH); edge.opacity = fill.opacity; vec![item, AppearanceItem::Stroke(edge)] }) diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 9e7578d6d7..0905275571 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use serde_json::Value; use vectorcraft_doc::color::{BlendMode, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind, OpacityMask, StrokeLayer}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind, OpacityMask}; use vectorcraft_geom::{FillRule, PathData, Rect}; use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, Mesh, View}; @@ -475,7 +475,7 @@ fn surface(n: &Node, facets: Vec, paint: &Paint, opacity: f32, blend: Ble group.blend = blend; return group; } - // Preserve existing white-light paints, alpha composition and seam joins exactly. + // Preserve existing white-light paints and alpha composition exactly. let (opaque, mask) = if colored { crate::lighting::opaque_paint(paint) } else { (paint.clone(), None) }; let paint = &opaque; let children = facets @@ -496,10 +496,7 @@ fn surface(n: &Node, facets: Vec, paint: &Paint, opacity: f32, blend: Ble .into_iter() .flat_map(|item| { let AppearanceItem::Fill(fill) = &item else { return vec![item] }; - let mut edge = StrokeLayer::new(fill.paint.clone(), seam); - if colored { - edge.join = vectorcraft_doc::LineJoin::Round; - } + let mut edge = crate::lighting::seam_stroke(fill.paint.clone(), seam); edge.opacity = fill.opacity; vec![item, AppearanceItem::Stroke(edge)] }) diff --git a/crates/effects/src/solids/classic.rs b/crates/effects/src/solids/classic.rs index 919de831ce..18b503b5b7 100644 --- a/crates/effects/src/solids/classic.rs +++ b/crates/effects/src/solids/classic.rs @@ -218,18 +218,23 @@ pub(crate) fn surface(n: &Node, facets: Vec, source: &Paint, opacity: f32 // Unshaded (No Shading, no shading color, or a face in full light) with no // highlight: the paint itself, its color space and swatch kept. Some(_) if (tone >= 1.0 || shade.is_none()) && spec <= 0.0 => { - vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Stroke(StrokeLayer::new(paint.clone(), seam))] + vec![ + AppearanceItem::Fill(FillLayer::new(paint.clone())), + AppearanceItem::Stroke(crate::lighting::seam_stroke(paint.clone(), seam)), + ] } Some(c) => { let fill = Paint::solid(shaded(c, tone, spec, shade)); - vec![AppearanceItem::Fill(FillLayer::new(fill.clone())), AppearanceItem::Stroke(StrokeLayer::new(fill, seam))] + vec![AppearanceItem::Fill(FillLayer::new(fill.clone())), AppearanceItem::Stroke(crate::lighting::seam_stroke(fill, seam))] } // Gradients and patterns keep their paint, darkened by a layer of the shading color // and lightened by one of white. As with the other 3D effects' pattern shadow, the // layers cover the whole face, transparent pattern cells included. None => { - let mut items = - vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Stroke(StrokeLayer::new(paint.clone(), seam))]; + let mut items = vec![ + AppearanceItem::Fill(FillLayer::new(paint.clone())), + AppearanceItem::Stroke(crate::lighting::seam_stroke(paint.clone(), seam)), + ]; for (color, share) in [(shade, 1.0 - tone), (Some(Color::WHITE), spec)] { if let Some(color) = color.filter(|_| share > 0.0) { let mut layer = FillLayer::new(Paint::solid(color)); diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index 1efe826c75..2917c67618 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -1,5 +1,5 @@ use serde_json::json; -use vectorcraft_doc::{Document, NodeId}; +use vectorcraft_doc::{Document, NodeId, StrokeLayer}; use vectorcraft_geom::{Affine, BezPath, Point, Shape}; use super::*; @@ -628,6 +628,9 @@ fn inflate_reads_twist_taper_and_cap() { assert_eq!(crate::default_params(INFLATE).unwrap()["cap"], json!(true)); } +/// Inflate under the legacy white light keeps the polygon faces it had before the colored-light +/// batching (each face a path of its own, not a channel group) and its seam's 0.05–0.75 pt range; +/// its seams' joins are round, as every 3D seam's. #[test] fn legacy_white_inflate_keeps_polygon_faces_and_its_seam_range() { let mut n = square_node(); @@ -640,7 +643,9 @@ fn legacy_white_inflate_keeps_polygon_faces_and_its_seam_range() { assert!(matches!(face.kind, NodeKind::Path { .. })); let AppearanceItem::Stroke(stroke) = &face.appearance.items[1] else { panic!("seam") }; assert!((0.05..=0.75).contains(&stroke.width)); - assert_eq!(stroke.join, vectorcraft_doc::LineJoin::Miter); + // Round, as every seam is: a facet seen almost edge-on is a sliver, and a mitered seam + // reached past the solid as a thorn. + assert_eq!(stroke.join, vectorcraft_doc::LineJoin::Round); } // A tiny shape exercises the lower bound, which otherwise disappears at grazing angles. n.transform(Affine::scale(0.001), false); @@ -1032,3 +1037,219 @@ fn classic_rotate_parameters_are_checked() { assert!(err.starts_with(says), "{p}: {err}"); } } + +/// Under a white light and under colored ones, in Extrude & Bevel, Inflate, Rotate and Extrude & +/// Bevel (Classic, each of its shading branches), a face seen almost edge-on (a sharp sliver) has +/// its seam stroke rounded at the corners: every seam's joins are round, and none reaches past its +/// face more than half its width as it is outlined (a mitered seam would reach past the solid as a +/// thorn up to ten times that beyond the face's tip). Each case's view makes some face with a +/// corner a miter would spike from. +#[test] +fn a_faces_seam_stays_within_half_its_width_of_the_face() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, RED), GradientStop::new(1.0, BLUE)], + )))); + // Does `path` have a convex corner sharper than 45° that a miter with `limit` would spike from + // (one sharper than 2·asin(1/limit) is bevelled instead, so it can't thorn; a reflex corner's + // miter points inward)? An open subpath's ends are no corners, and a closing anchor repeating + // the first is one corner. + fn acute(path: &PathData, limit: f64) -> bool { + let floor = 2.0 * (1.0 / limit).asin(); + path.subpaths.iter().any(|sp| { + let mut pts: Vec = sp.anchors.iter().map(|a| a.p).collect(); + pts.dedup_by(|a, b| a.distance(*b) < 1e-9); + if sp.closed && pts.len() > 1 && pts.first().zip(pts.last()).is_some_and(|(a, b)| a.distance(*b) < 1e-9) { + pts.pop(); + } + let n = pts.len(); + let corners: Vec = if sp.closed { (0..n).collect() } else { (1..n.saturating_sub(1)).collect() }; + // Which way the subpath turns as a whole: a convex corner turns the same way. + let area: f64 = (0..n).map(|k| pts[k].to_vec2().cross(pts[(k + 1) % n].to_vec2())).sum(); + n >= 3 + && corners.into_iter().any(|k| { + let (a, b, c) = (pts[(k + n - 1) % n], pts[k], pts[(k + 1) % n]); + let (u, v) = (a - b, c - b); + let convex = !sp.closed || (b - a).cross(c - b) * area > 0.0; + let angle = (u.dot(v) / (u.hypot() * v.hypot())).clamp(-1.0, 1.0).acos(); + convex && angle > floor && angle < std::f64::consts::FRAC_PI_4 + }) + }) + } + // Views that make slivers whose corners a miter would spike from, in every case. + let views = [(-35.0, 25.0), (-85.0, 15.0), (60.0, 60.0)]; + let mut cases = vec![]; + for &(x, y) in &views { + let view = json!({"rotationX": x, "rotationY": y, "rotationZ": 0, "depth": 60}); + // Rotate (Classic) takes no depth. + let flat = json!({"rotationX": x, "rotationY": y, "rotationZ": 0}); + let with = |extra: Value| { + let mut p = view.clone(); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + p + }; + cases.push((EXTRUDE, x, y, with(json!({"taper": 47.5})), None)); + // A gradient under a white light: its paint and the shadow fill over it each have a seam. + cases.push((EXTRUDE, x, y, with(json!({"taper": 47.5})), Some(gradient.clone()))); + cases.push((INFLATE, x, y, with(json!({})), None)); + // Under colored lights: one, two, and a gradient's paint. + let one = json!({"taper": 47.5, "lights": [{"color": "#ff8080"}]}); + let two = json!({"taper": 47.5, "lights": [{"color": "#ff8080"}, {"color": "#8080ff", "rotation": -60}]}); + cases.push((EXTRUDE, x, y, with(one.clone()), None)); + cases.push((EXTRUDE, x, y, with(two.clone()), None)); + cases.push((EXTRUDE, x, y, with(one), Some(gradient.clone()))); + cases.push((INFLATE, x, y, with(json!({"lights": [{"color": "#ff8080"}, {"color": "#8080ff", "rotation": -60}]})), None)); + // Rotate turns the art flat: a thin triangle, some corner of which stays sharp enough at + // every view; under a white light and a colored one. + cases.push((ROTATE, x, y, with(json!({})), None)); + cases.push((ROTATE, x, y, with(json!({"lights": [{"color": "#ff8080"}]})), None)); + // Classic: shaded, unshaded (No Shading) and a gradient's paint kept under its shading + // layers each make their seam their own way. + let bevel = json!({"bevel": "classic", "bevelHeight": 1.5}); + cases.push((CLASSIC_EXTRUDE, x, y, with(bevel.clone()), None)); + cases.push((CLASSIC_EXTRUDE, x, y, with(json!({"bevel": "classic", "bevelHeight": 1.5, "surface": "none"})), None)); + cases.push((CLASSIC_EXTRUDE, x, y, with(bevel), Some(gradient.clone()))); + // Rotate (Classic) draws its plane through Classic's surfaces: No Shading and Diffuse. + for surface in ["none", "diffuse"] { + let mut p = flat.clone(); + p["surface"] = json!(surface); + cases.push((CLASSIC_ROTATE, x, y, p, None)); + } + } + // Classic's own paint for its fill: a swatch in CMYK, which its unshaded faces keep as it is. + let brick = Paint::Solid { color: Color::Cmyk { c: 0.0, m: 0.75, y: 0.9, k: 0.2 }, swatch: Some("Brick".into()), tint: 1.0 }; + for (id, x, y, params, paint) in cases { + let mut n = if id == ROTATE || id == CLASSIC_ROTATE { + let mut b = BezPath::new(); + b.move_to((100.0, 90.0)); + b.line_to((160.0, 110.0)); + b.line_to((100.0, 130.0)); + b.close_path(); + stroked(&b) + } else { + stroked_rect(4.0) + }; + let unshaded = params["surface"] == "none"; + for (k, item) in n.appearance.items.iter_mut().enumerate() { + match &paint { + Some(paint) => *item.paint_mut() = paint.clone(), + None if id == CLASSIC_EXTRUDE && k == 0 => *item.paint_mut() = brick.clone(), + None => {} + } + } + // Each solid on its own (the fill's, then the stroke's), so every check holds for each. + for keep in 0..n.appearance.items.len() { + let mut solo = n.clone(); + let item = solo.appearance.items.remove(keep); + solo.appearance.items = vec![item.clone()]; + let source = item.paint().clone(); + let what = format!("{id} {params} gradient {} view ({x}, {y}) solid {keep}", paint.is_some()); + solo.appearance.effects.push(fx(id, params.clone())); + let art = solid_art(&solo).unwrap(); + let mut seamed = vec![]; + art.walk(&mut |face| { + if let Some(path) = face.path_data() + && face.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(_))) + { + seamed.push((path.clone(), face.appearance.items.clone())); + } + }); + // Each Classic case reaches its own branch: an unshaded face keeps the paint itself (its + // swatch and color space too), a shaded one is the paint darkened, and a gradient's + // face keeps the gradient. + if id == CLASSIC_EXTRUDE { + let fills: Vec<&Paint> = seamed + .iter() + .filter_map(|(_, items)| match items.first() { + Some(AppearanceItem::Fill(f)) => Some(&f.paint), + _ => None, + }) + .collect(); + assert!(!fills.is_empty(), "{what}: faces"); + if paint.is_some() || unshaded { + assert!(fills.iter().all(|f| **f == source), "{what}: every face keeps its paint as it is"); + } else { + let [r, g, b] = source.color().map(|c| c.to_rgb()).unwrap_or([0.0; 3]); + let darkened = fills.iter().any(|f| match f { + Paint::Solid { color, swatch: None, .. } => { + let [fr, fg, fb] = color.to_rgb(); + fr <= r + 1e-4 && fg <= g + 1e-4 && fb <= b + 1e-4 && fr + fg + fb < r + g + b - 1e-3 + } + _ => false, + }); + assert!(darkened, "{what}: some face shaded darker than its paint"); + } + } + // Every seamed fill (the paint's, a shadow's over it) has its seams checked, and on some + // face a miter would spike from. + let (mut checked, mut sliver): (usize, Vec) = (0, vec![]); + for (path, items) in &seamed { + let outline = path.to_bezpath(); + let acute_here = items.iter().find_map(|i| match i { + AppearanceItem::Stroke(s) => Some(acute(path, s.miter_limit)), + _ => None, + }); + let mut which = 0; + for item in items { + // A seam paints something (a stroke of no width or no paint draws nothing). + let AppearanceItem::Stroke(seam) = item else { continue }; + if seam.width <= 0.0 || matches!(seam.paint, Paint::None) { + continue; + } + if sliver.len() <= which { + sliver.resize(which + 1, false); + } + sliver[which] |= acute_here.unwrap_or(false); + which += 1; + checked += 1; + assert_eq!(seam.join, vectorcraft_doc::LineJoin::Round, "{what}: a seam's joins"); + // The seam outlined as it is drawn: no point of it farther from the face than + // half its width. + let drawn = crate::stroke::line_outline(&outline, seam, seam.width, seam.width * 1e-3); + let (mut farthest, mut points): (f64, usize) = (0.0, 0); + kurbo::flatten(drawn.iter(), seam.width * 1e-4, |el| { + if let kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) = el { + points += 1; + farthest = farthest.max(path.nearest(p).map_or(f64::INFINITY, |n| n.4)); + } + }); + assert!(points > 0, "{what}: a seam outlined as nothing"); + assert!(farthest <= seam.width / 2.0 * 1.01, "{what}: a seam {} wide reaches {farthest} from its face", seam.width); + } + } + assert!(checked > 0, "{what}: seams checked"); + for (k, s) in sliver.iter().enumerate() { + assert!(s, "{what}: no face with an acute corner carries seam {k}"); + } + } + } +} + +/// Revolve (Classic) draws its faces through Classic's surfaces: every face's seam has round joins, +/// on the plastic, diffuse and unshaded surfaces. +#[test] +fn classic_revolves_seams_are_round() { + let mut b = BezPath::new(); + b.move_to((100.0, 40.0)); + b.line_to((130.0, 60.0)); + b.line_to((130.0, 140.0)); + b.line_to((100.0, 160.0)); + for surface in ["plastic", "diffuse", "none"] { + let mut n = Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(BLUE), Paint::None, 0.0)); + n.appearance.effects.push(fx(crate::CLASSIC_REVOLVE, json!({"surface": surface}))); + let art = crate::revolve_art(&n).unwrap(); + let mut seams = 0; + art.walk(&mut |face| { + for item in &face.appearance.items { + if let AppearanceItem::Stroke(s) = item + && s.width > 0.0 + { + seams += 1; + assert_eq!(s.join, vectorcraft_doc::LineJoin::Round, "{surface}: a seam's joins"); + } + } + }); + assert!(seams > 0, "{surface}: seams checked"); + } +} diff --git a/crates/effects/src/solids/tests_shadow.rs b/crates/effects/src/solids/tests_shadow.rs index 4400555d2b..7e7d2e1595 100644 --- a/crates/effects/src/solids/tests_shadow.rs +++ b/crates/effects/src/solids/tests_shadow.rs @@ -1,6 +1,6 @@ use serde_json::json; use vectorcraft_doc::color::Color; -use vectorcraft_doc::{Document, NodeId}; +use vectorcraft_doc::{Document, NodeId, StrokeLayer}; use vectorcraft_geom::{Affine, BezPath}; use super::*; diff --git a/docs/3d-lighting-performance.md b/docs/3d-lighting-performance.md index 0ce73e598b..2d40254ee6 100644 --- a/docs/3d-lighting-performance.md +++ b/docs/3d-lighting-performance.md @@ -1,6 +1,6 @@ # 3D lighting performance -Measured 2026-10-17 for PR #2706 on one macOS machine. +Measured 2026-10-17 for PR #2706 on one macOS machine (an Apple M1 Pro, below); the round seam joins on 2026-11-12 on another (an Apple M4 Pro, in their own section). ## Method @@ -34,6 +34,8 @@ vectorcraft-cli perf ## Measurements +Only the legacy white-light row and the white-light comparison below are affected by the round seam joins of 2026-11-12: they were measured on 2026-10-17, and colored-light seams were already round; see [Round seam joins](#round-seam-joins). + Milliseconds per frame, medians of three five-frame averages. `8` is the CLI default thread count on this machine; `0` selects the single-thread renderer. | Inflate scene | Before, 8 | After, 8 | Before, 0 | After, 0 | @@ -66,10 +68,42 @@ The colored fixture uses the same projected mesh before and after. The solid-fil For a smooth colored solid fill, all facets share one compound coverage clip and three isolated channel groups. Each channel has an opaque black backdrop; Screen combines the disjoint RGB channels and Multiply applies the source color. Face order is retained in each channel. Surface opacity and outer coverage apply once. The group/clip counts are independent of triangle count, and export remains vector artwork. -Source gradients retain their independently placed per-facet vector compositor. They remain a more expensive case. Patterns retain their neutral shading approximation. White-light Inflate uses the original polygon shading, paint composition and default seam joins; the seam width retains its 0.05 to 0.75 pt bounds. +Source gradients retain their independently placed per-facet vector compositor. They remain a more expensive case. Patterns retain their neutral shading approximation. White-light Inflate uses the original polygon shading and paint composition; the seam width retains its 0.05 to 0.75 pt bounds. Its seams kept the default Miter Join until 2026-11-12, when every 3D seam took round joins (a mitered seam drew thorns past facets seen almost edge-on), so white-light output is no longer identical to the comparison below. Geometry/effects tests cover legacy polygons and seam bounds, shared smooth normals, finite ramps, holes and constant group/clip counts. Engine tests cover the batched SVG round trip, vector PDF, expansion, undo, compound holes and surface opacity. ## Legacy white-light comparison -Four original synthetic scenes were exported at 1200 x 1200 from unchanged main `f7de3d6a` and the updated implementation. The PNG files match byte for byte in all four cases: the legacy white-lit ellipse above; the same ellipse with both sides, hollow cap, twist 60 degrees, taper 70%, perspective 80 degrees and view Y rotation 145 degrees; an unshaded ellipse; and the transparent source gradient above under the legacy white light. The command recipe was also checked against the measured colored fixture's image pixels. +Four original synthetic scenes were exported at 1200 x 1200 from unchanged main `f7de3d6a` and the updated implementation. The PNG files matched (2026-10-17) byte for byte in all four cases: the legacy white-lit ellipse above; the same ellipse with both sides, hollow cap, twist 60 degrees, taper 70%, perspective 80 degrees and view Y rotation 145 degrees; an unshaded ellipse; and the transparent source gradient above under the legacy white light. The command recipe was also checked against the measured colored fixture's image pixels. + +## Round seam joins + +Measured once, on 2026-11-12, on another machine (an Apple M4 Pro with 14 cores) that was not idle: its one-minute load average was 6 to 13, so these are one loaded run, not the idle measurements above. Two `vectorcraft-cli` builds, each in release (`cargo build --release -p vectorcraft-cli`): main at `6d6bbff53`, and that commit with the round seam joins, run in turn, three times each. `bench` fits the artboard to 95% of 2880 x 1800 pixels, draws one frame to warm each renderer and reports the average of the frames after it: `threads 8` with the renderer's default workers (one per core it may use, up to 8, one core left free), `threads 0` on the calling thread alone. A cold frame is a whole `run` that opens the scene, builds its 3D art and draws it once. + +White light: extruded type with a cove bevel, an Inflate, an Extrude & Bevel (Classic) star with a classic bevel and a Revolve, no strokes (6,283 nodes drawn). The median of three runs of 20 frames, on 8 threads and on one: main 8.0 ms and 18.3 ms; with the round joins 7.5 ms and 17.7 ms. Cold frames took 0.08 s of processor time on each. The differences are within noise for one loaded run. Not measured: `vectorcraft-cli perf`, the legacy white-light Inflate row above (still the 2026-10-17 figures), frames whose 3D art is rebuilt (while dragging a 3D slider, say) apart from the cold run, and SVG/PDF exports. + +``` +{"command":"file.new","params":{"width":800,"height":600}} +{"command":"text.create","params":{"x":40,"y":120,"text":"SAMPLE TYPE","size":64,"font":"Source Sans 3","color":"#3366cc"}} +{"command":"effect.apply","params":{"effect":"threeD.extrude","item":null,"params":{"depth":40,"rotationX":-35,"rotationY":25,"taper":47.5,"bevel":"cove","bevelHeight":2}}} +{"command":"path.create","params":{"d":"M100 300C100 245 145 200 200 200C255 200 300 245 300 300C300 355 255 400 200 400C145 400 100 355 100 300Z"}} +{"command":"paint.setFill","params":{"color":"#cc6633"}} +{"command":"paint.setStroke","params":{"none":true}} +{"command":"effect.apply","params":{"effect":"threeD.inflate","item":null,"params":{}}} +{"command":"path.create","params":{"d":"M400 200L440 280L520 290L460 340L480 420L400 380L320 420L340 340L280 290L360 280Z"}} +{"command":"paint.setFill","params":{"color":"#33aa66"}} +{"command":"paint.setStroke","params":{"none":true}} +{"command":"effect.apply","params":{"effect":"threeD.classicExtrude","item":null,"params":{"depth":50,"bevel":"classic","bevelHeight":4,"rotationX":80,"rotationY":10}}} +{"command":"path.create","params":{"d":"M600 200L640 200L660 300L640 400L600 400Z"}} +{"command":"paint.setFill","params":{"color":"#aa33aa"}} +{"command":"paint.setStroke","params":{"none":true}} +{"command":"effect.apply","params":{"effect":"threeD.revolve","item":null,"params":{}}} +``` + +The type is set in Source Sans 3, which a build has only with craft-fonts (`CRAFT_FONTS_DIR`, see `docs/development.md` › Fonts); without it the type falls back to another face. To reproduce, save the commands as `scene.jsonl` and, with each build: + +``` +vectorcraft-cli run --commands scene.jsonl --export scene.vectorcraft +vectorcraft-cli bench scene.vectorcraft --iters 20 +/usr/bin/time -p vectorcraft-cli run --in scene.vectorcraft --export scene.png +``` diff --git a/docs/effects-parity.md b/docs/effects-parity.md index e219ce5e1b..d834a852a9 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -1,6 +1,6 @@ # Effects parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Effect Gallery: a thumbnail for each filter and an in-dialog preview on an original drawing; the 1–2 h for those is done; Extrude & Bevel, Inflate and Rotate merge contours that cross, touch or overlap, as type often draws a glyph, before building a solid; previously: Extrude & Bevel, Inflate and Rotate apply to groups and layers, their objects turning about one shared axis (#4506); the Transform effect's copies of a path with a fill and a stroke (or several fills and strokes, an even-odd fill, or a partly transparent one) are drawn each above the one before, as Expand Appearance makes them (#5920); Expand Appearance makes each copy of a path's Transform effect an object of its own (#5612); previously: Pixelate › Color Halftone: Max Radius in pixels of the Document Raster Effects resolution, 4–127 px, with older documents converted on opening and radii written back in points for older app versions (#3260); Effect dialogs clamp bounded length inputs immediately on commit and flash rejection on invalid input (#4638); previously: Drop Shadow Darkness uses fixed RGB subtraction and CMYK black addition; scores unchanged; Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add an overprinting strip of the lighter colour where two solid colours meet; recounted with Hard Mix (#3061) and Soft Mix already in the catalogue: Pathfinder 13/13, Illustrator effects 49/50) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (Extrude & Bevel, Inflate and Rotate seams have round joins under a white light and in the 3D (Classic) effects too, no longer reaching past the solid as thorns; previously: Effect Gallery: a thumbnail for each filter and an in-dialog preview on an original drawing; the 1–2 h for those is done; Extrude & Bevel, Inflate and Rotate merge contours that cross, touch or overlap, as type often draws a glyph, before building a solid; previously: Extrude & Bevel, Inflate and Rotate apply to groups and layers, their objects turning about one shared axis (#4506); the Transform effect's copies of a path with a fill and a stroke (or several fills and strokes, an even-odd fill, or a partly transparent one) are drawn each above the one before, as Expand Appearance makes them (#5920); Expand Appearance makes each copy of a path's Transform effect an object of its own (#5612); previously: Pixelate › Color Halftone: Max Radius in pixels of the Document Raster Effects resolution, 4–127 px, with older documents converted on opening and radii written back in points for older app versions (#3260); Effect dialogs clamp bounded length inputs immediately on commit and flash rejection on invalid input (#4638); previously: Drop Shadow Darkness uses fixed RGB subtraction and CMYK black addition; scores unchanged; Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add an overprinting strip of the lighter colour where two solid colours meet; recounted with Hard Mix (#3061) and Soft Mix already in the catalogue: Pathfinder 13/13, Illustrator effects 49/50) · **Target:** Adobe Illustrator 2026 (30.x) Every entry of Illustrator's Effect menu, by its public feature name, against VectorCraft's effects catalogue (`effect_catalog()` in `crates/effects/src/lib.rs`) and the Effect menu built from it @@ -179,9 +179,9 @@ VectorCraft choices; an original approximation, never compared with another appl | Item | Ours | |---|---| | Revolve | initial: live, editable profile, rotation and light gizmos, default-on Cap with continuous curved rims, smooth vector shading and round seam joins, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) | -| Extrude & Bevel, Inflate, Rotate | initial: live, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), contours that cross, touch or overlap merged first ([three-d.md](three-d.md)), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) | +| Extrude & Bevel, Inflate, Rotate | initial: live, round seam joins, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), contours that cross, touch or overlap merged first ([three-d.md](three-d.md)), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) | | Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect, and Quick Actions › Expand as Wireframes ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; hard-edged shadows behind or below the object from the first light ([three-d.md](three-d.md#shadows)); no materials, soft shadows or ray tracing | -| 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic) initial: live, Angle, Cap, Offset from the left or right edge, Position presets and rotation gizmo, Perspective, the same four surfaces, lights, highlight, Blend Steps and Shading Color, groups and layers revolved about one shared axis (same page); Rotate (Classic) initial: live, every fill and stroke turned as one plane, Position presets and rotation gizmo, Perspective, No Shading and Diffuse surfaces with lights, Ambient Light, Blend Steps and Shading Color ([rotate objects in 3D](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/rotate-objects-in-3d.html)); Map Art: none | +| 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, round seam joins, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic) initial: live, Angle, Cap, Offset from the left or right edge, Position presets and rotation gizmo, Perspective, the same four surfaces, lights, highlight, Blend Steps and Shading Color, groups and layers revolved about one shared axis (same page); Rotate (Classic) initial: live, every fill and stroke turned as one plane, Position presets and rotation gizmo, Perspective, No Shading and Diffuse surfaces with lights, Ambient Light, Blend Steps and Shading Color ([rotate objects in 3D](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/rotate-objects-in-3d.html)); Map Art: none | | Turntable (30.0 beta) | none | | Export 3D objects (OBJ, USDA, glTF) | initial: Asset Export's Export 3D Object collects an object with Extrude & Bevel, Revolve, Inflate or Rotate as a 3D asset, and Export writes its solids as a USDA (the default), USDZ, glTF or OBJ mesh with a material in each solid's paint color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`); no textures, map art or shading ([3D export help](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)) | @@ -193,6 +193,7 @@ angular cut caps, exact visibility, the rest of 3D Classic (Map Art), Turntable) | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | The seams of Extrude & Bevel, Inflate and Rotate under a white light, and every 3D (Classic) seam, went from miter to round joins (colored-light seams were already round): no thorns on edge-on facets, and facets with no neighbor now have rounded convex corners ([three-d.md](three-d.md#seams)); scores and estimates unchanged | | 2026-11-10 | minor | Effect Gallery: each filter has a thumbnail, and the dialog has a preview of the visible stack (or the browsed filter when the stack is empty). Both are an original landscape drawing passed through the filter ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)). The canvas preview and one-undo OK are unchanged. Remaining hours for the gallery's thumbnails and in-dialog preview: 1–2 → none. Counts unchanged (57/57). | | 2026-11-08 | minor | Extrude & Bevel, Inflate and Rotate merge contours that cross, touch or overlap (a glyph's arm across its stem) before building a solid, so the solid follows the shape as it is filled; others are built as drawn ([three-d.md](three-d.md)); scores and estimates unchanged | | 2026-11-06 | minor | Extrude & Bevel, Inflate and Rotate apply to a group or layer: a solid for every path, compound path and type in it, all turned about one shared axis as the public documentation describes for groups ([three-d.md](three-d.md#groups), #4506); scores and estimates unchanged | diff --git a/docs/gaps.md b/docs/gaps.md index 826de6fe24..b4def77cd4 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (#6865: oversized editing artboard import; Glyphs panel: Alternates for Current Selection applies an OpenType feature value (`salt=2`) to the selected letter alone, so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, #5514; #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: G5: built-in Energy Beams brush library; estimate 5–15 → 4–14 h; previously minor: G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (3D: the seams of white-light Extrude & Bevel, Inflate and Rotate, and every seam of the 3D (Classic) effects, have round joins instead of miters; previously: #6865: oversized editing artboard import; Glyphs panel: Alternates for Current Selection applies an OpenType feature value (`salt=2`) to the selected letter alone, so an Arabic word stays joined, and Entire Font lists the unencoded glyphs, #5514; #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: G5: built-in Energy Beams brush library; estimate 5–15 → 4–14 h; previously minor: G5: built-in Skill Icons symbol library; estimate 6–16 → 5–15 h; previously minor: ruler double-click adds a guide at the pointer, #5239; G5: built-in Status Effects graphic style library; estimate 7–17 → 6–16 h; MCP docs catch-up for UI Frames and Map Markers; previously minor: G5: built-in Inventory Icons symbol library; estimate 8–18 → 7–17 h; previously minor: G9: `.ai` save names a variable font instance's design-space setting, #6388; previously minor: G5: built-in Game Trails brush library; estimate 9–19 → 8–18 h; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: G5: built-in Game HUD graphic style library; estimate 10–20 → 9–19 h; previously minor: G5: built-in Map Markers symbol library; estimate 11–21 → 10–20 h; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: G2: Effect Gallery thumbnails and in-dialog preview; estimate 1–2 h → 0; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: built-in UI Frames symbol library with default 9-slice; estimate 11–21 h; previously minor: G5: dynamic symbol part on-canvas takes opacity, blend, gradient and pattern; docs Overlap/auto-corners sync; estimate 12–22 h; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap, Pattern brush auto corners and Global Edit Shift-click leave-out moved from Missing to Done (code already shipped); previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -668,7 +668,7 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi ## G3 3D and Materials - **Done:** an initial live Revolve in the `three-d` crate (#846, from the contributor who offered in #605), with automatic smooth vector shading, default-on circular caps and spike-free continuous rims; - Extrude & Bevel, Inflate and Rotate with Position presets ([three-d.md](three-d.md), #1407). Colored Inflate has automatic smooth vector lighting from shared membrane normals. White-light Inflate keeps its existing appearance. All four take one to ten colored directional lights, with neutral ambient applied once, a light list and independent previewing controls. Standard, Diffuse, Top Left and Right select original rigs, shared with commands; manual settings show Custom. + Extrude & Bevel, Inflate and Rotate with Position presets ([three-d.md](three-d.md), #1407). Colored Inflate has automatic smooth vector lighting from shared membrane normals. White-light Inflate keeps its polygon shading. Every 3D facet's seam has round joins ([three-d.md](three-d.md#seams)). All four take one to ten colored directional lights, with neutral ambient applied once, a light list and independent previewing controls. Standard, Diffuse, Top Left and Right select original rigs, shared with commands; manual settings show Custom. - **Missing:** materials (base, Substance, graphics), shadows, ray-traced rendering, angular cut caps, exact visibility for intersecting and concave surfaces, the rest of 3D Classic (Map Art), Turntable, textures, map art and shading in 3D export. @@ -865,6 +865,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | 3D: The seams of Extrude & Bevel, Inflate and Rotate under a white light, and every 3D (Classic) seam, went from miter to round joins (colored-light seams were already round): no thorns on edge-on facets, and facets with no neighbour now have rounded convex corners ([three-d.md](three-d.md#seams)). Scores and estimates unchanged. | | 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | | 2026-11-09 | minor | G4 review (#6084): positional variants no supported feature draws can be applied as glyph choices in both Glyphs views; unlisted glyphs still respect name ownership. OpenType style options keep only a valid last on-value when enabling a set. Existing contextual/ligature lookup and unsupported-feature gaps remain; estimate unchanged. | | 2026-11-07 | minor | G4: the Glyphs panel's Alternates for Current Selection applies the OpenType feature that draws a variant to that letter alone (`salt=2`; the style commands take `tag=N`; runs that differ only in their features shape as one piece of text), so an Arabic word stays joined, and Show › Entire Font lists the unencoded glyphs (#5514). Estimate unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 23080e57ee..f7504a487d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (#6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (3D: the seams of Extrude & Bevel, Inflate and Rotate under a white light, and every 3D (Classic) seam, went from miter to round joins, so white-light 3D output is no longer identical to the 2026-10-17 comparison; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > @@ -1788,7 +1788,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur `xtask/src/pngsuite.rs` verifies each file from the pinned PngSuite mirror against `xtask/pngsuite.sha256` before it reaches the corpus. Seven synthetic regressions cover substitution, oversize, cache repair, failure cleanup, nonregular targets, stale staging and manifest limits. The opt-in corpus remains gitignored; no PNGs are committed. -Evidence for the shared light Color: geometry tests in `tests_lighting`, effect paint tests, command/save/undo/expand and SVG/PDF checks in `three_d_light_color`, and headless picker/reset/preview tests in `tests_revolve`. Revolve keeps smooth colored vector ramps and continuous flat-lit caps. Inflate geometry tests check spherical lighting, shared vertex/edge continuity, concave cell coverage, puffed backs at perspective and legacy single-light/list equivalence; rendered command/export regressions cover smooth shading and editable expansion. Four legacy white-light PNGs match main byte for byte; [release measurements](3d-lighting-performance.md) record the colored solid-fill batch and the remaining source-gradient cost. +Evidence for the shared light Color: geometry tests in `tests_lighting`, effect paint tests, command/save/undo/expand and SVG/PDF checks in `three_d_light_color`, and headless picker/reset/preview tests in `tests_revolve`. Revolve keeps smooth colored vector ramps and continuous flat-lit caps. Inflate geometry tests check spherical lighting, shared vertex/edge continuity, concave cell coverage, puffed backs at perspective and legacy single-light/list equivalence; rendered command/export regressions cover smooth shading and editable expansion. Four legacy white-light PNGs matched main byte for byte when this was measured (2026-10-17); since every 3D seam took round joins (2026-11-12), white-light output is expected to differ from them at facet corners ([Seams](three-d.md#seams)); the four scenes were not exported again to measure it; [release measurements](3d-lighting-performance.md) record the colored solid-fill batch and the remaining source-gradient cost. ## Eyedropper character dimensions (2026-11-11) @@ -2120,6 +2120,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-12 | minor | 3D: The seams of Extrude & Bevel, Inflate and Rotate under a white light, and every 3D (Classic) seam, went from miter to round joins (colored-light seams were already round): no thorns on edge-on facets, and facets with no neighbour now have rounded convex corners ([three-d.md](three-d.md#seams)). White-light output differs from the 2026-10-17 byte comparison at facet corners. Scores and estimates unchanged. | | 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | | 2026-11-12 | minor | Glyphs review (#6084): Entire Font uses the selection's version and variable axes for previews and alternate applicability. The default cell can reset an unlisted glyph choice when GSUB lists no alternates. Encoded Latin and other ordinary characters insert unchanged even if their glyph also has Arabic positional provenance; Arabic presentation forms retain base-letter insertion. Remove a redundant charmap scan from unlisted-glyph checks. Scores and estimates unchanged. | | 2026-11-09 | minor | Glyphs review (#6084): positional alternates with no supported feature are accepted as glyph choices in both panel views; Entire Font's unlisted-glyph and name-ownership behavior is retained. OpenType style options keep only a valid last on-value when enabling a set. Contradictory old glyph-choice evidence corrected; scores and estimates unchanged. | diff --git a/docs/three-d.md b/docs/three-d.md index 820bc1e805..57f1a91499 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -86,7 +86,7 @@ object is where the art was. | Bevel both sides | The back is bevelled too. | Holes stay holes, with walls of their own. Bevels move each corner along its bisector (mitres are limited to four -times the width), so a bevel wider than a narrow part of the shape folds over itself. +times the width), so a bevel wider than a narrow part of the shape folds over itself. Each facet has a seam ([Seams](#seams)). ## Inflate @@ -102,7 +102,33 @@ middle. | Volume | 0–100 %. The surface is a membrane under even pressure, held along the outline (and around holes): it rises vertically from the edge and rounds smoothly inside, without creases along the shape's middle. At 50 % a circle becomes a half sphere; 100 % puffs twice as high, 0 % is flat. | | Both sides | The back puffs out too; otherwise it is flat. | -The surface starts with a grid of 48 cells across the larger side, halved or quartered where it rises steeply near the outline. White-light Inflate retains the existing clipped polygon cells, shading, paint composition and seam joins. With colored lights, simple cells are triangulated with shared border samples, and lighting interpolates shared membrane normals through ordinary RGB vector gradients, including on a puffed back. Solid fills batch those triangles into three channel groups and one coverage clip for the whole surface. Source gradients retain per-facet vector composition and apply transparent coverage once. Seam widths retain the 0.05 to 0.75 pt bounds; colored facets use round joins. Shading is automatic, with no additional dialog option. +The surface starts with a grid of 48 cells across the larger side, halved or quartered where it rises steeply near the outline. White-light Inflate retains the existing clipped polygon cells, shading and paint composition. With colored lights, simple cells are triangulated with shared border samples, and lighting interpolates shared membrane normals through ordinary RGB vector gradients, including on a puffed back. Solid fills batch those triangles into three channel groups and one coverage clip for the whole surface. Source gradients retain per-facet vector composition and apply transparent coverage once. Seam widths retain the 0.05 to 0.75 pt bounds, and every facet's seam has round joins ([Seams](#seams)). Shading is automatic, with no additional dialog option. + +## Seams + +Facets side by side, each antialiased on its own, would leave hairlines between them, so 3D facets are drawn with +a seam of their own paint, with round joins. In Extrude & Bevel, Inflate, Rotate and the three 3D (Classic) effects a +facet's seam is half as wide as the facet is thick, from 0.05 to 0.75 pt; Revolve's seam is always 0.75 pt. + +How a facet carries its seam depends on the lights and the paint: + +- Extrude & Bevel, Inflate and Rotate, when no light is colored (one white light or several): a path filled with + the facet's shading and stroked with its seam. With a colored light, Extrude & Bevel and Rotate (planar shading) + are drawn the same way. +- Inflate with a colored light and shading on: a solid-color fill draws each facet's light ramps with its seam in three channel + groups clipped to the union of its facets, so no seam reaches past the surface; a gradient fill fills each facet's + ramps through a clip of the facet and its seam, outlined with round joins. +- Extrude & Bevel (Classic), Rotate (Classic) and Revolve (Classic): a path and a seam for every face; gradient and + pattern faces seam only the base paint, and the shading and highlight layers cover the face alone. The Wireframe + surface draws edges only, with no seam. +- Revolve, with shading on: when a `lights` list is given (even one of white lights), a solid-color or gradient face fills its light + ramps through a clip of the face and its 0.75 pt seam, outlined with round joins. With the single legacy light, + a face is a path and a seam; with a colored `lightColor` a gradient face fills through the same kind of clip. + +A facet seen almost edge-on is a sliver, and a seam with mitered joins would reach past the solid as a thorn; a round +one reaches no more than half its width past its facet. So where a facet has no neighbor (Rotate's plane, every +silhouette corner) its convex corners are drawn rounded by up to 0.375 pt (half the largest seam), except a +solid-color Inflate under a colored light, whose clip keeps its corners sharp. ## Fills and strokes @@ -185,7 +211,7 @@ solids are ([Groups](#groups)), turned about one shared axis. The flat art turned in 3D about the centre of its bounds. Every fill and stroke is turned with its own paint (a stroke as its outline, so it foreshortens with the art) and shaded as one plane; a plane has two sides, so its -back shows when it is turned past 90°. +back shows when it is turned past 90°. The plane has a seam ([Seams](#seams)). ## Rotation presets @@ -361,6 +387,7 @@ Release measurements and a repeatable CLI scene are in [3D lighting performance] | Date | Change | |---|---| +| 2026-11-12 | The seams of Extrude & Bevel, Inflate and Rotate under a white light, and every 3D (Classic) seam, went from miter to round joins (colored-light seams were already round): no thorns on edge-on facets, and facets with no neighbor now have rounded convex corners ([Seams](#seams)). | | 2026-11-08 | Contours that cross, touch or overlap are merged by vectorcraft-pathops before Extrude & Bevel, Inflate and Rotate build a solid, sharing its limits; others are built as drawn. | | 2026-11-06 | Extrude & Bevel, Inflate and Rotate apply to groups and layers: every path, compound path and type in them turns about one shared axis (#4506). | | 2026-10-21 | Four original lighting presets, shared by all four 3D effects and commands, with effective-setting recognition and existing preview/history behavior. | From 1032fb39799d85294a756d1659c9317eb28a046d Mon Sep 17 00:00:00 2001 From: Drew Dibble Date: Thu, 12 Nov 2026 16:28:51 -0700 Subject: [PATCH 02/56] Round seams: Classic Rotate and Revolve tests, Wireframe edge, docs - a_faces_seam_stays_within_half_its_width_of_the_face checks Rotate (Classic)'s branches too: No Shading keeps its paint, a gradient keeps the gradient, and Diffuse shades its plane darker at some view. - classic_revolves_seams_are_round checks each seam's reach as well as its join, and covers a gradient. - The Wireframe edge takes seam_stroke (the same stroke it built by hand). - Docs: which seams went from miter to round and which already were; the white-light difference from the 2026-10-17 comparison is expected, not measured; the 3D area rows record the change; the performance note says its measurement predates the Classic Rotate and Revolve paths. --- crates/effects/src/solids/classic.rs | 6 +-- crates/effects/src/solids/tests.rs | 65 ++++++++++++++++++++-------- docs/3d-lighting-performance.md | 2 +- docs/effects-parity.md | 4 +- docs/gaps.md | 2 +- docs/target-app-parity.md | 8 ++-- docs/three-d.md | 2 +- 7 files changed, 59 insertions(+), 30 deletions(-) diff --git a/crates/effects/src/solids/classic.rs b/crates/effects/src/solids/classic.rs index 18b503b5b7..23d8d0ffad 100644 --- a/crates/effects/src/solids/classic.rs +++ b/crates/effects/src/solids/classic.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use serde_json::Value; use vectorcraft_doc::color::{BlendMode, Color, Paint}; -use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, StrokeLayer}; +use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node}; use vectorcraft_geom::Rect; use vectorcraft_three_d::{Bevel, BevelShape, ClassicShading, ClassicSurface, Extrude, Facet, Light}; @@ -207,8 +207,8 @@ pub(crate) fn surface(n: &Node, facets: Vec, source: &Paint, opacity: f32 .into_iter() .map(|face| { if wire { - let mut edge = StrokeLayer::new(paint.clone(), WIRE); - edge.join = vectorcraft_doc::LineJoin::Round; + // A wire edge is drawn as a seam is: a round-joined stroke of the paint. + let edge = crate::lighting::seam_stroke(paint.clone(), WIRE); return Arc::new(Node::path(n.id, face.path(), Appearance { items: vec![AppearanceItem::Stroke(edge)], ..Default::default() })); } let (tone, spec) = shading.tone(face.normal, &lights); diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index 2917c67618..abfe7fc368 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -1038,8 +1038,9 @@ fn classic_rotate_parameters_are_checked() { } } -/// Under a white light and under colored ones, in Extrude & Bevel, Inflate, Rotate and Extrude & -/// Bevel (Classic, each of its shading branches), a face seen almost edge-on (a sharp sliver) has +/// Under a white light and under colored ones, in Extrude & Bevel, Inflate, Rotate, Extrude & +/// Bevel (Classic, each of its shading branches) and Rotate (Classic, its No Shading and Diffuse +/// surfaces and a gradient), a face seen almost edge-on (a sharp sliver) has /// its seam stroke rounded at the corners: every seam's joins are round, and none reaches past its /// face more than half its width as it is outlined (a mitered seam would reach past the solid as a /// thorn up to ten times that beyond the face's tip). Each case's view makes some face with a @@ -1116,9 +1117,15 @@ fn a_faces_seam_stays_within_half_its_width_of_the_face() { p["surface"] = json!(surface); cases.push((CLASSIC_ROTATE, x, y, p, None)); } + let mut p = flat.clone(); + p["surface"] = json!("diffuse"); + cases.push((CLASSIC_ROTATE, x, y, p, Some(gradient.clone()))); } // Classic's own paint for its fill: a swatch in CMYK, which its unshaded faces keep as it is. let brick = Paint::Solid { color: Color::Cmyk { c: 0.0, m: 0.75, y: 0.9, k: 0.2 }, swatch: Some("Brick".into()), tint: 1.0 }; + // Rotate (Classic)'s one plane is shaded at some views, not at every one (the light can leave + // it as its paint), so its Diffuse case is held to a darkened face at one view at least. + let mut rotate_shaded = false; for (id, x, y, params, paint) in cases { let mut n = if id == ROTATE || id == CLASSIC_ROTATE { let mut b = BezPath::new(); @@ -1134,7 +1141,7 @@ fn a_faces_seam_stays_within_half_its_width_of_the_face() { for (k, item) in n.appearance.items.iter_mut().enumerate() { match &paint { Some(paint) => *item.paint_mut() = paint.clone(), - None if id == CLASSIC_EXTRUDE && k == 0 => *item.paint_mut() = brick.clone(), + None if (id == CLASSIC_EXTRUDE || id == CLASSIC_ROTATE) && k == 0 => *item.paint_mut() = brick.clone(), None => {} } } @@ -1155,10 +1162,10 @@ fn a_faces_seam_stays_within_half_its_width_of_the_face() { seamed.push((path.clone(), face.appearance.items.clone())); } }); - // Each Classic case reaches its own branch: an unshaded face keeps the paint itself (its - // swatch and color space too), a shaded one is the paint darkened, and a gradient's - // face keeps the gradient. - if id == CLASSIC_EXTRUDE { + // Each Classic case (Extrude & Bevel and Rotate) reaches its own branch: an unshaded face + // keeps the paint itself (its swatch and color space too), a shaded one is the paint + // darkened, and a gradient's face keeps the gradient. + if id == CLASSIC_EXTRUDE || id == CLASSIC_ROTATE { let fills: Vec<&Paint> = seamed .iter() .filter_map(|(_, items)| match items.first() { @@ -1178,7 +1185,11 @@ fn a_faces_seam_stays_within_half_its_width_of_the_face() { } _ => false, }); - assert!(darkened, "{what}: some face shaded darker than its paint"); + if id == CLASSIC_ROTATE { + rotate_shaded |= darkened; + } else { + assert!(darkened, "{what}: some face shaded darker than its paint"); + } } } // Every seamed fill (the paint's, a shadow's over it) has its seams checked, and on some @@ -1224,32 +1235,50 @@ fn a_faces_seam_stays_within_half_its_width_of_the_face() { } } } + assert!(rotate_shaded, "Rotate (Classic), Diffuse: no view shades its plane darker than its paint"); } -/// Revolve (Classic) draws its faces through Classic's surfaces: every face's seam has round joins, -/// on the plastic, diffuse and unshaded surfaces. +/// Revolve (Classic) draws its faces through Classic's surfaces: on the plastic, diffuse and +/// unshaded surfaces, and with a gradient, every face's seam has round joins and reaches no more +/// than half its width past its face as it is outlined (the reach Revolve's overlap test counts on). #[test] fn classic_revolves_seams_are_round() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, RED), GradientStop::new(1.0, BLUE)], + )))); let mut b = BezPath::new(); b.move_to((100.0, 40.0)); b.line_to((130.0, 60.0)); b.line_to((130.0, 140.0)); b.line_to((100.0, 160.0)); - for surface in ["plastic", "diffuse", "none"] { - let mut n = Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(BLUE), Paint::None, 0.0)); + let cases = [("plastic", Paint::solid(BLUE)), ("diffuse", Paint::solid(BLUE)), ("none", Paint::solid(BLUE)), ("plastic", gradient)]; + for (surface, paint) in cases { + let what = format!("{surface} gradient {}", matches!(paint, Paint::Gradient(_))); + let mut n = Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(paint, Paint::None, 0.0)); n.appearance.effects.push(fx(crate::CLASSIC_REVOLVE, json!({"surface": surface}))); let art = crate::revolve_art(&n).unwrap(); let mut seams = 0; art.walk(&mut |face| { + let Some(path) = face.path_data() else { return }; for item in &face.appearance.items { - if let AppearanceItem::Stroke(s) = item - && s.width > 0.0 - { - seams += 1; - assert_eq!(s.join, vectorcraft_doc::LineJoin::Round, "{surface}: a seam's joins"); + let AppearanceItem::Stroke(s) = item else { continue }; + if s.width <= 0.0 { + continue; } + seams += 1; + assert_eq!(s.join, vectorcraft_doc::LineJoin::Round, "{what}: a seam's joins"); + let drawn = crate::stroke::line_outline(&path.to_bezpath(), s, s.width, s.width * 1e-3); + let mut farthest: f64 = 0.0; + kurbo::flatten(drawn.iter(), s.width * 1e-4, |el| { + if let kurbo::PathEl::MoveTo(p) | kurbo::PathEl::LineTo(p) = el { + farthest = farthest.max(path.nearest(p).map_or(f64::INFINITY, |n| n.4)); + } + }); + assert!(farthest <= s.width / 2.0 * 1.01, "{what}: a seam {} wide reaches {farthest} from its face", s.width); } }); - assert!(seams > 0, "{surface}: seams checked"); + assert!(seams > 0, "{what}: seams checked"); } } diff --git a/docs/3d-lighting-performance.md b/docs/3d-lighting-performance.md index 2d40254ee6..e7683cdf5c 100644 --- a/docs/3d-lighting-performance.md +++ b/docs/3d-lighting-performance.md @@ -78,7 +78,7 @@ Four original synthetic scenes were exported at 1200 x 1200 from unchanged main ## Round seam joins -Measured once, on 2026-11-12, on another machine (an Apple M4 Pro with 14 cores) that was not idle: its one-minute load average was 6 to 13, so these are one loaded run, not the idle measurements above. Two `vectorcraft-cli` builds, each in release (`cargo build --release -p vectorcraft-cli`): main at `6d6bbff53`, and that commit with the round seam joins, run in turn, three times each. `bench` fits the artboard to 95% of 2880 x 1800 pixels, draws one frame to warm each renderer and reports the average of the frames after it: `threads 8` with the renderer's default workers (one per core it may use, up to 8, one core left free), `threads 0` on the calling thread alone. A cold frame is a whole `run` that opens the scene, builds its 3D art and draws it once. +Measured once, on 2026-11-12, on another machine (an Apple M4 Pro with 14 cores) that was not idle: its one-minute load average was 6 to 13, so these are one loaded run, not the idle measurements above. Two `vectorcraft-cli` builds, each in release (`cargo build --release -p vectorcraft-cli`): main at `6d6bbff53`, and that commit with the round seam joins, run in turn, three times each. The measurement predates the change's rebase onto a main whose Rotate (Classic) and Revolve (Classic) draw their faces through Classic's surfaces, so it does not cover their seams. `bench` fits the artboard to 95% of 2880 x 1800 pixels, draws one frame to warm each renderer and reports the average of the frames after it: `threads 8` with the renderer's default workers (one per core it may use, up to 8, one core left free), `threads 0` on the calling thread alone. A cold frame is a whole `run` that opens the scene, builds its 3D art and draws it once. White light: extruded type with a cove bevel, an Inflate, an Extrude & Bevel (Classic) star with a classic bevel and a Revolve, no strokes (6,283 nodes drawn). The median of three runs of 20 frames, on 8 threads and on one: main 8.0 ms and 18.3 ms; with the round joins 7.5 ms and 17.7 ms. Cold frames took 0.08 s of processor time on each. The differences are within noise for one loaded run. Not measured: `vectorcraft-cli perf`, the legacy white-light Inflate row above (still the 2026-10-17 figures), frames whose 3D art is rebuilt (while dragging a 3D slider, say) apart from the cold run, and SVG/PDF exports. diff --git a/docs/effects-parity.md b/docs/effects-parity.md index d834a852a9..3465001072 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -181,7 +181,7 @@ VectorCraft choices; an original approximation, never compared with another appl | Revolve | initial: live, editable profile, rotation and light gizmos, default-on Cap with continuous curved rims, smooth vector shading and round seam joins, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) | | Extrude & Bevel, Inflate, Rotate | initial: live, round seam joins, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), contours that cross, touch or overlap merged first ([three-d.md](three-d.md)), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) | | Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect, and Quick Actions › Expand as Wireframes ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; hard-edged shadows behind or below the object from the first light ([three-d.md](three-d.md#shadows)); no materials, soft shadows or ray tracing | -| 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, round seam joins, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic) initial: live, Angle, Cap, Offset from the left or right edge, Position presets and rotation gizmo, Perspective, the same four surfaces, lights, highlight, Blend Steps and Shading Color, groups and layers revolved about one shared axis (same page); Rotate (Classic) initial: live, every fill and stroke turned as one plane, Position presets and rotation gizmo, Perspective, No Shading and Diffuse surfaces with lights, Ambient Light, Blend Steps and Shading Color ([rotate objects in 3D](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/rotate-objects-in-3d.html)); Map Art: none | +| 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, round seam joins, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic) initial: live, round seam joins, Angle, Cap, Offset from the left or right edge, Position presets and rotation gizmo, Perspective, the same four surfaces, lights, highlight, Blend Steps and Shading Color, groups and layers revolved about one shared axis (same page); Rotate (Classic) initial: live, round seam joins, every fill and stroke turned as one plane, Position presets and rotation gizmo, Perspective, No Shading and Diffuse surfaces with lights, Ambient Light, Blend Steps and Shading Color ([rotate objects in 3D](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/rotate-objects-in-3d.html)); Map Art: none | | Turntable (30.0 beta) | none | | Export 3D objects (OBJ, USDA, glTF) | initial: Asset Export's Export 3D Object collects an object with Extrude & Bevel, Revolve, Inflate or Rotate as a 3D asset, and Export writes its solids as a USDA (the default), USDZ, glTF or OBJ mesh with a material in each solid's paint color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`); no textures, map art or shading ([3D export help](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)) | @@ -193,7 +193,7 @@ angular cut caps, exact visibility, the rest of 3D Classic (Map Art), Turntable) | Date | Change | Summary | |---|---|---| -| 2026-11-12 | minor | The seams of Extrude & Bevel, Inflate and Rotate under a white light, and every 3D (Classic) seam, went from miter to round joins (colored-light seams were already round): no thorns on edge-on facets, and facets with no neighbor now have rounded convex corners ([three-d.md](three-d.md#seams)); scores and estimates unchanged | +| 2026-11-12 | minor | The seams of Extrude & Bevel, Inflate and Rotate under a white light, and of the faces of Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic), went from miter to round joins (the seams of Revolve and of colored lights, and 3D (Classic) Wireframe edges, were already round): no thorns on edge-on facets, and facets with no neighbor now have rounded convex corners ([three-d.md](three-d.md#seams)); scores and estimates unchanged | | 2026-11-10 | minor | Effect Gallery: each filter has a thumbnail, and the dialog has a preview of the visible stack (or the browsed filter when the stack is empty). Both are an original landscape drawing passed through the filter ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)). The canvas preview and one-undo OK are unchanged. Remaining hours for the gallery's thumbnails and in-dialog preview: 1–2 → none. Counts unchanged (57/57). | | 2026-11-08 | minor | Extrude & Bevel, Inflate and Rotate merge contours that cross, touch or overlap (a glyph's arm across its stem) before building a solid, so the solid follows the shape as it is filled; others are built as drawn ([three-d.md](three-d.md)); scores and estimates unchanged | | 2026-11-06 | minor | Extrude & Bevel, Inflate and Rotate apply to a group or layer: a solid for every path, compound path and type in it, all turned about one shared axis as the public documentation describes for groups ([three-d.md](three-d.md#groups), #4506); scores and estimates unchanged | diff --git a/docs/gaps.md b/docs/gaps.md index b4def77cd4..2aae655f63 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -865,7 +865,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv | Date | Change | Summary | |---|---|---| -| 2026-11-12 | minor | 3D: The seams of Extrude & Bevel, Inflate and Rotate under a white light, and every 3D (Classic) seam, went from miter to round joins (colored-light seams were already round): no thorns on edge-on facets, and facets with no neighbour now have rounded convex corners ([three-d.md](three-d.md#seams)). Scores and estimates unchanged. | +| 2026-11-12 | minor | 3D: The seams of Extrude & Bevel, Inflate and Rotate under a white light, and of the faces of Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic), went from miter to round joins (the seams of Revolve and of colored lights, and 3D (Classic) Wireframe edges, were already round): no thorns on edge-on facets, and facets with no neighbour now have rounded convex corners ([three-d.md](three-d.md#seams)). Scores and estimates unchanged. | | 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | | 2026-11-09 | minor | G4 review (#6084): positional variants no supported feature draws can be applied as glyph choices in both Glyphs views; unlisted glyphs still respect name ownership. OpenType style options keep only a valid last on-value when enabling a set. Existing contextual/ligature lookup and unsupported-feature gaps remain; estimate unchanged. | | 2026-11-07 | minor | G4: the Glyphs panel's Alternates for Current Selection applies the OpenType feature that draws a variant to that letter alone (`salt=2`; the style commands take `tag=N`; runs that differ only in their features shape as one piece of text), so an Arabic word stays joined, and Show › Entire Font lists the unencoded glyphs (#5514). Estimate unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f7504a487d..53fb7b1a02 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (3D: the seams of Extrude & Bevel, Inflate and Rotate under a white light, and every 3D (Classic) seam, went from miter to round joins, so white-light 3D output is no longer identical to the 2026-10-17 comparison; previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-12 · **Change:** minor (3D: the seams of Extrude & Bevel, Inflate and Rotate under a white light, and of the faces of Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic), went from miter to round joins, so white-light 3D output is expected to differ from the 2026-10-17 comparison at facet corners (not measured: the scenes were not exported again); previously: #6865: explicitly requested oversized editing artboard; macOS: the canvas cursor stays after the pointer moves, #6362; scores and estimates unchanged; previously minor: Type tool: kerning at a caret at the start of a line moves the character after it, #5111; scores and estimates unchanged; previously minor: Rulers: the zero point dragged out of the rulers' corner snaps as a drawn point does; previously minor: Live Paint Bucket fills faces crossed in one drag (#6373); previously: #6404: transform-aware Eyedropper character copying; scores and estimates unchanged; previously: Envelopes: with Distort Appearance, type's strokes paint along the bent glyph outlines at their own weight, as users' `.ai` files draw them, which changes how VectorCraft's own envelopes of stroked type draw (#6644; scores and estimates unchanged); previously minor: G5: Energy Beams brush library; bundled content ~44% → ~46%; previously minor: G5: Skill Icons symbol library; bundled content ~42% → ~44%; previously minor: `.ai` and EPS import: an envelope saved in Edit Contents whose mesh lies away from its drawn art by a move opens live on that art (#6570; scores and estimates unchanged); previously minor: ruler double-click adds a guide at the pointer, #5239; G5: Status Effects graphic style library; bundled content ~40% → ~42%; MCP symbol libraries list UI Frames and Map Markers; previously minor: G5: Inventory Icons built-in symbol library; bundled content ~38% → ~40%; previously minor: `.ai` and EPS import: stroked type in an envelope maps from its glyphs grown by half its stroke (#3179; scores and estimates unchanged); previously minor: `.ai` and EPS import: warps of type open as mesh envelopes on their saved grid where VectorCraft's warp doesn't draw as their drawn art (#3179; scores and estimates unchanged); G5: Game Trails brush library; bundled content ~36% → ~38%; previously minor: Edit › Edit Custom Dictionary…: its dialog adds, deletes and changes the custom dictionary's words, #4936; previously minor: `.ai` and EPS import: envelopes of type open live where they draw as their drawn art and the layers with them draw as the page (#3179; scores and estimates unchanged); G5: Game HUD graphic style library; bundled content ~34% → ~36%; previously minor: Line Arrow arrowhead (#5421; scores and estimates unchanged); G5: Map Markers built-in symbol library; bundled content ~32% → ~34%; previously minor: right-click on type: Spelling › Auto Spell Check, with its check mark, #4936; previously minor: right-click on a word Auto Spell Check underlined: its suggestions, Ignore All and Add to Dictionary, #4936; previously minor: Canvas: a 3D effect changed on type or a group redraws where its solid was; Fit to Artwork Bounds and per-artboard SVG export build each solid once for all artboards; previously minor: Effect Gallery: a thumbnail for each filter and a preview area inside the dialog, on an original drawing; Raster effects 52% → 55%, hours 1–2 → none; headline numbers unchanged; Auto Spell Check: `edit.autoSpellCheck` underlines the unknown words, #4936; previously minor: PDF editable import preserves authored ActualText once, including empty/nested spans and authored ordering (#6165); cross-line/style layout remains approximate; scores and estimates unchanged; PDF marked-text export (#6309): compatible glyphs stay on one layout line with per-glyph offsets; scores and estimates unchanged; G5: UI Frames built-in symbol library with default 9-slice; bundled content ~32%; previously minor: G5: dynamic symbol part on-canvas opacity/blend/gradient/pattern; strokes remaining no longer lists Overlap/auto corners; symbols ~88%; previously minor: interim macOS cursor delivery, #2292; native validation pending; scores and estimates unchanged; previously minor: harakat paint fallback clarified; previously minor: current Guides feature-area rows consolidated using existing ruler evidence; scores and estimates unchanged; previously minor: Control bar › Snapping includes the existing Snap to Glyph and Alignment Guides toggles (#6164); scores and estimates unchanged; previously minor: G5 docs: Art brush Overlap and Pattern brush auto corners removed from strokes remaining; symbols remaining lists dynamic on-canvas beyond solid and guides outside the art; previously minor: custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged) > > Oversized artboard import update (2026-11-12): Explicit editing-data import retains a single artboard beyond a capped 14,400 pt PDF page (#6865). > @@ -226,7 +226,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | Eyedropper copies effective character dimensions across text transforms (#6404); 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, PDF gradients mixing a spot color with RGB or Lab stops, and dithered spot gradient fills and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, which PDF export writes as images in process colors| 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode, and the Edit tab's Global Adjust sliders (Saturation, Brightness, Temperature, Luminosity, -100% to 100%) change every recolored color at once (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged | | Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (nine generated libraries including Ink and Wash, Bristle Brushes, Game Trails, Energy Beams and Dry Media, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | -| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | +| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12); every 3D seam has round joins (2026-11-12, [three-d.md](three-d.md#seams)), score, gaps and hours unchanged | | Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636); paint-only Arabic harakat keep joining with conditional GDEF-mark ownership and Insert Kashidas base-letter paint (`tests::harakat`) | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons and Skill Icons are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | @@ -234,7 +234,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, and Map Markers are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; simpler contour walk, identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments, so Paths is monotone on the code-drawn sweep) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views (scores/hours unchanged) | | Live vector effects | 5 | 85% | Outline Object, SVG Filters | 4–8 | measured count 49/50 (Pathfinder 13/13 with Trap), depth estimated; Warp Options controls verified by focused checks and original-image review | -| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility), Revolve (Classic) with Angle, Cap, Offset and the Plastic, Diffuse, No Shading and Wireframe surfaces, the Quick Actions › Expand as Wireframes command (#6354); materials, shadows, ray tracing, the rest of 3D Classic (Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12); Revolve (Classic) is covered by the `revolve::classic` tests and `dialogs::revolve::tests::classic_revolve_shows_its_options_and_applies_as_one_step`; Expand as Wireframes is covered by `crates/effects/src/solids/wireframe/tests.rs` and `crates/engine/src/cmd/wireframe/tests.rs` (score, gaps and hours unchanged) | +| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility), Revolve (Classic) with Angle, Cap, Offset and the Plastic, Diffuse, No Shading and Wireframe surfaces, the Quick Actions › Expand as Wireframes command (#6354); materials, shadows, ray tracing, the rest of 3D Classic (Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12); Revolve (Classic) is covered by the `revolve::classic` tests and `dialogs::revolve::tests::classic_revolve_shows_its_options_and_applies_as_one_step`; Expand as Wireframes is covered by `crates/effects/src/solids/wireframe/tests.rs` and `crates/engine/src/cmd/wireframe/tests.rs` (score, gaps and hours unchanged); every 3D seam has round joins (2026-11-12, [three-d.md](three-d.md#seams)), score, gaps and hours unchanged | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Raster effects (Effect Gallery) | 4 | 55% | — | — | estimated (raised from 52%: the Effect Gallery's filter thumbnails and in-dialog preview, an original landscape drawing passed through each filter, [Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html), `dialogs::effect_gallery::tests::gallery_shows_thumbnails_and_an_in_dialog_preview`; the gallery itself shipped in #2883, six categories, per-filter settings, ordered stack, live canvas preview, one undo step, `stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`); measured count 57/57 (Artistic complete with Colored Pencil, Fresco, Paint Daubs, Palette Knife, Rough Pastels and Underpainting; Sketch complete with Torn Edges and Conté Crayon, `pixel::tests_sketch::conte_crayon_*`); texture redraw distances optimized (#1257), pixels verified; the score stays under 100% because the filters are original approximations; depth estimated | @@ -2120,7 +2120,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| -| 2026-11-12 | minor | 3D: The seams of Extrude & Bevel, Inflate and Rotate under a white light, and every 3D (Classic) seam, went from miter to round joins (colored-light seams were already round): no thorns on edge-on facets, and facets with no neighbour now have rounded convex corners ([three-d.md](three-d.md#seams)). White-light output differs from the 2026-10-17 byte comparison at facet corners. Scores and estimates unchanged. | +| 2026-11-12 | minor | 3D: The seams of Extrude & Bevel, Inflate and Rotate under a white light, and of the faces of Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic), went from miter to round joins (the seams of Revolve and of colored lights, and 3D (Classic) Wireframe edges, were already round): no thorns on edge-on facets, and facets with no neighbour now have rounded convex corners ([three-d.md](three-d.md#seams)). White-light output is expected to differ from the 2026-10-17 byte comparison at facet corners; the four scenes were not exported again to measure it. Scores and estimates unchanged. | | 2026-11-12 | minor | Explicit editing-data import keeps a single artboard beyond a capped 14,400 pt PDF page (#6865), with a warning; automatic mode stays unchanged. Scores and estimates unchanged. | | 2026-11-12 | minor | Glyphs review (#6084): Entire Font uses the selection's version and variable axes for previews and alternate applicability. The default cell can reset an unlisted glyph choice when GSUB lists no alternates. Encoded Latin and other ordinary characters insert unchanged even if their glyph also has Arabic positional provenance; Arabic presentation forms retain base-letter insertion. Remove a redundant charmap scan from unlisted-glyph checks. Scores and estimates unchanged. | | 2026-11-09 | minor | Glyphs review (#6084): positional alternates with no supported feature are accepted as glyph choices in both panel views; Entire Font's unlisted-glyph and name-ownership behavior is retained. OpenType style options keep only a valid last on-value when enabling a set. Contradictory old glyph-choice evidence corrected; scores and estimates unchanged. | diff --git a/docs/three-d.md b/docs/three-d.md index 57f1a91499..6c23d31b8a 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -387,7 +387,7 @@ Release measurements and a repeatable CLI scene are in [3D lighting performance] | Date | Change | |---|---| -| 2026-11-12 | The seams of Extrude & Bevel, Inflate and Rotate under a white light, and every 3D (Classic) seam, went from miter to round joins (colored-light seams were already round): no thorns on edge-on facets, and facets with no neighbor now have rounded convex corners ([Seams](#seams)). | +| 2026-11-12 | The seams of Extrude & Bevel, Inflate and Rotate under a white light, and of the faces of Extrude & Bevel (Classic), Revolve (Classic) and Rotate (Classic), went from miter to round joins (the seams of Revolve and of colored lights, and 3D (Classic) Wireframe edges, were already round): no thorns on edge-on facets, and facets with no neighbor now have rounded convex corners ([Seams](#seams)). | | 2026-11-08 | Contours that cross, touch or overlap are merged by vectorcraft-pathops before Extrude & Bevel, Inflate and Rotate build a solid, sharing its limits; others are built as drawn. | | 2026-11-06 | Extrude & Bevel, Inflate and Rotate apply to groups and layers: every path, compound path and type in them turns about one shared axis (#4506). | | 2026-10-21 | Four original lighting presets, shared by all four 3D effects and commands, with effective-setting recognition and existing preview/history behavior. | From 0620df747c60e08445577c52da42aba87b11429f Mon Sep 17 00:00:00 2001 From: Drew Dibble Date: Thu, 12 Nov 2026 18:11:27 -0700 Subject: [PATCH 03/56] Parity: Rotate (Classic) is no longer listed as missing from 3D Classic --- docs/target-app-parity.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4aa7e8da42..9c31194ae0 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -228,7 +228,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | Eyedropper copies effective character dimensions across text transforms (#6404); 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, PDF gradients mixing a spot color with RGB or Lab stops, and dithered spot gradient fills and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, which PDF export writes as images in process colors| 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode, and the Edit tab's Global Adjust sliders (Saturation, Brightness, Temperature, Luminosity, -100% to 100%) change every recolored color at once (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged | | Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (nine generated libraries including Ink and Wash, Bristle Brushes, Game Trails, Energy Beams and Dry Media, User Defined, Other Library…, Save Brushes); Art brush Overlap and Pattern brush auto corners are done; remaining: pen pressure on macOS, Stylus Wheel, more built-in brush libraries, tilt, bearing and rotation for Art, Pattern and Bristle brushes and on native Wayland and the web | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | -| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12); every 3D seam has round joins (2026-11-12, [three-d.md](three-d.md#seams)), score, gaps and hours unchanged | +| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12); every 3D seam has round joins (2026-11-12, [three-d.md](three-d.md#seams)), score, gaps and hours unchanged | | Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636); paint-only Arabic harakat keep joining with conditional GDEF-mark ownership and Insert Kashidas base-letter paint (`tests::harakat`) | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, Map Markers, Inventory Icons and Skill Icons are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | @@ -236,7 +236,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Symbols, blends, envelopes, Repeat, perspective | 5 | 88% | remaining: dynamic symbols' `.ai` round trip, further built-in symbol libraries (Basic Shapes, UI Frames with default 9-slice, and Map Markers are done), envelope Anti-Alias/Preserve Shape, 9-slice guides outside the art; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic on-canvas fill/stroke including gradient/pattern, opacity and blend, #4938) are done | 4–7 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; simpler contour walk, identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments, so Paths is monotone on the code-drawn sweep) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views (scores/hours unchanged) | | Live vector effects | 5 | 85% | Outline Object, SVG Filters | 4–8 | measured count 49/50 (Pathfinder 13/13 with Trap), depth estimated; Warp Options controls verified by focused checks and original-image review | -| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility), Revolve (Classic) with Angle, Cap, Offset and the Plastic, Diffuse, No Shading and Wireframe surfaces, the Quick Actions › Expand as Wireframes command (#6354); materials, shadows, ray tracing, the rest of 3D Classic (Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12); Revolve (Classic) is covered by the `revolve::classic` tests and `dialogs::revolve::tests::classic_revolve_shows_its_options_and_applies_as_one_step`; Expand as Wireframes is covered by `crates/effects/src/solids/wireframe/tests.rs` and `crates/engine/src/cmd/wireframe/tests.rs` (score, gaps and hours unchanged); every 3D seam has round joins (2026-11-12, [three-d.md](three-d.md#seams)), score, gaps and hours unchanged | +| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility), Revolve (Classic) with Angle, Cap, Offset and the Plastic, Diffuse, No Shading and Wireframe surfaces, the Quick Actions › Expand as Wireframes command (#6354); materials, shadows, ray tracing, the rest of 3D Classic (Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12); Revolve (Classic) is covered by the `revolve::classic` tests and `dialogs::revolve::tests::classic_revolve_shows_its_options_and_applies_as_one_step`; Expand as Wireframes is covered by `crates/effects/src/solids/wireframe/tests.rs` and `crates/engine/src/cmd/wireframe/tests.rs` (score, gaps and hours unchanged); every 3D seam has round joins (2026-11-12, [three-d.md](three-d.md#seams)), score, gaps and hours unchanged | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check (engine, custom dictionary and Auto Spell Check with its right-click suggestions and Spelling › Auto Spell Check, and the Edit Custom Dictionary dialog done, #4936; the Check Spelling dialog remains), Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Raster effects (Effect Gallery) | 4 | 55% | — | — | estimated (raised from 52%: the Effect Gallery's filter thumbnails and in-dialog preview, an original landscape drawing passed through each filter, [Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html), `dialogs::effect_gallery::tests::gallery_shows_thumbnails_and_an_in_dialog_preview`; the gallery itself shipped in #2883, six categories, per-filter settings, ordered stack, live canvas preview, one undo step, `stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`); measured count 57/57 (Artistic complete with Colored Pencil, Fresco, Paint Daubs, Palette Knife, Rough Pastels and Underpainting; Sketch complete with Torn Edges and Conté Crayon, `pixel::tests_sketch::conte_crayon_*`); texture redraw distances optimized (#1257), pixels verified; the score stays under 100% because the filters are original approximations; depth estimated | From 6bf988a0e1938a0297e2d20369c501070b7faf43 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 02:31:04 -0800 Subject: [PATCH 04/56] Clipboard: the Flatpak on GNOME copies and pastes pictures and SVG with other apps Where arboard has neither data-control nor X11 (the Flatpak on a GNOME Wayland session), Copy and Paste used smithay-clipboard on the window's connection, which carries text only. They now use a core wl_data_device client on that connection. - One Copy offers the PNG as image/png, the SVG as image/svg+xml (with Include SVG Code on) and the text as text/plain;charset=utf-8, UTF8_STRING and text/plain. A format with no data is not offered. - Paste reads SVG, then text, then PNG. Blank text, or text that only names a picture, gives way to the PNG, and a failed or empty SVG read falls back to the types after it. The Paste menu item is enabled by the types offered, without reading any data. - The clipboard holds VectorCraft's Copy while its data source is not canceled and the offer has the Copy's types. - A read takes 64 MiB at most. Text waits 2 s, SVG and PNG 10 s off the UI thread. Another app's paste is sent on its own thread, 8 at once. smithay-clipboard is no longer a dependency of the app. --- Cargo.lock | 1 - apps/vectorcraft/Cargo.toml | 9 +- apps/vectorcraft/src/clipboard.rs | 1067 +++++++++++++++++++++++++---- docs/development.md | 2 +- docs/gaps.md | 3 +- docs/target-app-parity.md | 1 + 6 files changed, 950 insertions(+), 133 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ce5569cde9..b75d138586 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6341,7 +6341,6 @@ dependencies = [ "raw-window-handle", "rfd", "serde_json", - "smithay-clipboard", "socket2", "tempfile", "vectorcraft-engine", diff --git a/apps/vectorcraft/Cargo.toml b/apps/vectorcraft/Cargo.toml index 55b9e344e7..3802b865c3 100644 --- a/apps/vectorcraft/Cargo.toml +++ b/apps/vectorcraft/Cargo.toml @@ -96,12 +96,11 @@ winit = { version = "0.30", default-features = false, features = ["wayland-csd-a x11rb = { version = "0.13", default-features = false, features = ["xinput", "xkb"] } # Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback, src/compositor_gpu.rs), -# so Automatic draws on that one. Both are already in the build through winit. +# so Automatic draws on that one, and copies and pastes text, SVG and PNG through the compositor's +# core data-device protocol on the window's connection (src/clipboard.rs), where arboard has neither +# data-control (GNOME) nor X11 (a Flatpak on a Wayland session). Both are already in the build +# through winit. wayland-client = "0.31" -# Copy and Paste of text through the compositor's core data-device protocol on the window's -# connection (src/clipboard.rs), where arboard has neither data-control (GNOME) nor X11 (a Flatpak -# on a Wayland session). MIT, already in the build through egui-winit. -smithay-clipboard = "0.7" wayland-protocols = { version = "0.32", features = ["client", "unstable"] } # The save dialog's parent window on Wayland (src/portal_save.rs): an xdg-foreign handle (in # wayland-protocols' `unstable` set) made on the window's own connection. MIT, already in the build diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs index c5ef4d5579..87dc43be13 100644 --- a/apps/vectorcraft/src/clipboard.rs +++ b/apps/vectorcraft/src/clipboard.rs @@ -10,8 +10,9 @@ //! Linux arboard reads no other formats). On Wayland, arboard's data-control //! backend reads the compositor's clipboard (X11 through XWayland only sees what X11 apps //! copied), falling back to X11 where the compositor lacks the protocol, and where there is no -//! X11 either (a Flatpak on GNOME) to the compositor's core protocol on the window's connection, -//! text only. A clipboard that can't be opened is tried again after a wait that grows to a +//! X11 either (a Flatpak on GNOME) to the compositor's core protocol on the window's connection: +//! Copy offers SVG (`image/svg+xml`), PNG (`image/png`) and text together, and Paste reads SVG, +//! text and PNG. A clipboard that can't be opened is tried again after a wait that grows to a //! minute, not each time Paste asks. On Linux and the BSDs a read waits 2 s at most for the app //! that copied to send its data (a bitmap 10 s), and until that app answers, later reads fail at //! once; a paste then waits for that read's answer off the UI thread. @@ -24,8 +25,8 @@ use std::path::PathBuf; use vectorcraft_engine::cmd::clipboard::{BITMAP, EPS, FILE_HEAD, Flavour, PDF, PNG, SVG, TEXT, file_flavour}; use vectorcraft_ui_egui::{ClipboardRead, SystemClipboard}; -/// This platform's system clipboard. On Linux, `window`'s Wayland connection carries text where -/// arboard can't open (GNOME has no data-control protocol, a Flatpak there no X11). +/// This platform's system clipboard. On Linux, `window`'s Wayland connection carries text, SVG and +/// PNG where arboard can't open (GNOME has no data-control protocol, a Flatpak there no X11). pub fn system_clipboard(window: Option>) -> Box { #[cfg(windows)] let cb = { @@ -629,6 +630,10 @@ mod portable { enum Ours { Text(String), Image(usize, usize), + /// A Copy through the Wayland core protocol, whose data source tells whether the + /// clipboard still holds it. + #[cfg(target_os = "linux")] + Source, } /// A read of arboard's clipboard. @@ -858,18 +863,14 @@ mod portable { #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] impl Late { /// What a paste of one of `mimes` takes once the answer comes ([`Unanswered::paste`]). The - /// core protocol's text is waited for as long, until [`IMAGE_WAIT`] after its read was - /// sent, then read again, and pasted unless it is empty, what `ours` put on the clipboard, - /// or doesn't come. + /// core protocol's answer is waited for as long, until [`IMAGE_WAIT`] after its read was + /// sent; then the clipboard is read again and pasted as at once, unless it holds the Copy + /// made here ([`super::wayland::Unanswered::paste`]). fn paste(self, ours: Option<&Ours>, mimes: &[&'static str]) -> Option { match self { Self::Arboard(unanswered) => unanswered.paste(ours, mimes), #[cfg(target_os = "linux")] - Self::Wayland(unanswered) => { - let text = unanswered.text(IMAGE_WAIT).ok().filter(|t| !t.is_empty())?; - let held = matches!(ours, Some(Ours::Text(t)) if *t == text); - (!held).then(|| Flavour { mime: TEXT, data: text.into_bytes() }) - } + Self::Wayland(unanswered) => unanswered.paste(mimes, IMAGE_WAIT), } } } @@ -962,14 +963,15 @@ mod portable { /// An open clipboard connection. enum Backend { Arboard(Arboard), - /// The compositor's core data-device protocol on the window's connection (text only). + /// The compositor's core data-device protocol on the window's connection. #[cfg(target_os = "linux")] - Wayland(super::wayland::TextClipboard), + Wayland(super::wayland::DataClipboard), } - /// What a write of a picture says where the clipboard carries text only. + /// What a bitmap read or write says on the core protocol, which carries pictures as PNG data + /// only ([`Clipboard::write`], [`Clipboard::read_now_or_later`]). #[cfg(target_os = "linux")] - const TEXT_ONLY: &str = "this clipboard carries text only"; + const PNG_ONLY: &str = "this clipboard carries pictures as PNG data"; impl Backend { /// arboard (Wayland data-control, else X11), else the core protocol on `window`'s @@ -979,7 +981,10 @@ mod portable { || Arboard::open().map(Self::Arboard), || { #[cfg(target_os = "linux")] - return window.and_then(|w| super::wayland::TextClipboard::new(w.clone())).map(Self::Wayland).ok_or("not a Wayland window"); + return window + .and_then(|w| super::wayland::DataClipboard::new(w.clone())) + .map(Self::Wayland) + .ok_or("not a Wayland window with a seat and a data-device manager"); #[cfg(not(target_os = "linux"))] { let _ = window; @@ -993,7 +998,7 @@ mod portable { match self { Self::Arboard(_) => "arboard", #[cfg(target_os = "linux")] - Self::Wayland(_) => "the Wayland core protocol (text only)", + Self::Wayland(_) => "the Wayland core protocol", } } @@ -1001,7 +1006,7 @@ mod portable { match self { Self::Arboard(cb) => cb.read(Request::Text).text(), #[cfg(target_os = "linux")] - Self::Wayland(cb) => cb.get_text(), + Self::Wayland(cb) => cb.text(), } } @@ -1009,10 +1014,17 @@ mod portable { match self { Self::Arboard(cb) => cb.cb.set_text(text).map_err(|e| e.to_string()), #[cfg(target_os = "linux")] - Self::Wayland(cb) => { - cb.set_text(text); - Ok(()) - } + Self::Wayland(cb) => cb.copy(&[Flavour { mime: TEXT, data: text.as_bytes().to_vec() }]), + } + } + + /// Whether the clipboard holds the core protocol's last Copy made here (never for arboard, + /// whose writes are checked by what they wrote). + #[cfg(target_os = "linux")] + fn holds_source(&mut self) -> bool { + match self { + Self::Arboard(_) => false, + Self::Wayland(cb) => cb.holds_ours(), } } @@ -1025,7 +1037,7 @@ mod portable { #[cfg(not(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd")))] Self::Arboard(cb) => cb.read(Request::Image).image(), #[cfg(target_os = "linux")] - Self::Wayland(_) => Err(TEXT_ONLY.into()), + Self::Wayland(_) => Err(PNG_ONLY.into()), } } @@ -1041,7 +1053,7 @@ mod portable { Ok(Box::new(move || Ok(image))) } #[cfg(target_os = "linux")] - Self::Wayland(_) => Err(TEXT_ONLY.into()), + Self::Wayland(_) => Err(PNG_ONLY.into()), } } @@ -1049,7 +1061,7 @@ mod portable { match self { Self::Arboard(cb) => cb.cb.set_image(img).map_err(|e| e.to_string()), #[cfg(target_os = "linux")] - Self::Wayland(_) => Err(TEXT_ONLY.into()), + Self::Wayland(_) => Err(PNG_ONLY.into()), } } @@ -1057,15 +1069,12 @@ mod portable { match self { Self::Arboard(cb) => cb.cb.clear().map_err(|e| e.to_string()), #[cfg(target_os = "linux")] - Self::Wayland(cb) => { - cb.set_text(""); - Ok(()) - } + Self::Wayland(cb) => cb.copy(&[]), } } /// The read that got no answer in its wait, when a paste of one of `mimes` can take its - /// answer ([`Arboard::unanswered`], [`TextReads::unanswered`](super::wayland::TextReads::unanswered)). + /// answer ([`Arboard::unanswered`], [`Reads::unanswered`](super::wayland::Reads::unanswered)). #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "netbsd", target_os = "openbsd"))] fn unanswered(&mut self, mimes: &[&'static str]) -> Option { match self { @@ -1223,9 +1232,15 @@ mod portable { Some(ClipboardRead::Later(Box::new(move || unanswered.paste(ours.as_ref(), &mimes)))) } - /// Text and copied files are read here, and work that reads arboard's image comes back. + /// Text and copied files are read here, and work that reads arboard's image comes back. On + /// the Wayland core protocol the first of `mimes` the clipboard offers is read: text here, + /// SVG and PNG by the work that comes back, which waits [`IMAGE_WAIT`] at most. fn read_now_or_later(&mut self, mimes: &[&'static str]) -> Option { let cb = self.cb(Asker::User).ok()?; + #[cfg(target_os = "linux")] + if let Backend::Wayland(cb) = cb { + return cb.read(mimes, IMAGE_WAIT); + } if let Some(f) = copied_file(cb.file_list(), mimes) { return Some(ClipboardRead::Now(f)); } @@ -1247,13 +1262,23 @@ mod portable { } impl SystemClipboard for Clipboard { + /// arboard takes the text, else the PNG as a bitmap; the Wayland core protocol offers the + /// SVG, PNG and text together ([`DataClipboard::copy`](super::wayland::DataClipboard::copy)). fn write(&mut self, flavours: &[Flavour]) -> Result<(), String> { + let cb = self.cb(Asker::User)?; + #[cfg(target_os = "linux")] + if let Backend::Wayland(wl) = cb { + wl.copy(flavours)?; + // What a read got late was on the clipboard before this write. + wl.forget_unanswered(); + self.ours = wl.holds_ours().then_some(Ours::Source); + return Ok(()); + } let text = text_of(flavours); let image = match &text { Some(_) => None, None => flavours.iter().find(|f| f.mime == PNG).and_then(|f| decode_png(&f.data)), }; - let cb = self.cb(Asker::User)?; let ours = match (text, image) { (Some(t), _) => { cb.set_text(t.as_str())?; @@ -1281,6 +1306,8 @@ mod portable { match &self.ours { Some(Ours::Text(t)) => cb.get_text().is_ok_and(|now| now == *t), Some(Ours::Image(w, h)) => cb.image_now().is_ok_and(|now| (now.width, now.height) == (*w, *h)), + #[cfg(target_os = "linux")] + Some(Ours::Source) => cb.holds_source(), None => false, } } @@ -1307,7 +1334,8 @@ mod portable { read } - /// Text, and on macOS vector art or a bitmap (by the pasteboard's types). Linux has no + /// Text, on macOS vector art or a bitmap (by the pasteboard's types), and on the Wayland + /// core protocol SVG, text or PNG (by the types offered, nothing read). arboard has no /// cheap way to ask for a bitmap (reading one costs as much as pasting it): the paste keys /// take it, the menu item waits for text. fn has(&mut self, mimes: &[&'static str]) -> bool { @@ -1315,7 +1343,12 @@ mod portable { if super::mac::has(mimes) { return true; } - mimes.contains(&TEXT) && self.cb(Asker::Probe).is_ok_and(|cb| cb.get_text().is_ok_and(|t| !t.is_empty())) + let Ok(cb) = self.cb(Asker::Probe) else { return false }; + #[cfg(target_os = "linux")] + if let Backend::Wayland(wl) = cb { + return wl.has(mimes); + } + mimes.contains(&TEXT) && cb.get_text().is_ok_and(|t| !t.is_empty()) } } @@ -1646,45 +1679,58 @@ mod portable { #[cfg(target_os = "linux")] #[test] fn a_paste_after_an_unanswered_core_protocol_text_read_waits_for_its_answer() { - use super::super::wayland::TextReads; + use std::sync::{Arc, Mutex}; + + use super::super::wayland::{Reads, Seen}; use vectorcraft_engine::cmd::clipboard::PASTE_ORDER; - /// Core protocol text reads whose owner answers with `text` only once the sender + /// Core protocol reads of a clipboard offering `types`, whose owner answers each read + /// of a text type with `text` and of `image/png` with `png`, only once the sender /// returned is used or dropped. - fn slow_text(text: &'static str) -> (TextReads, std::sync::mpsc::Sender<()>) { + fn slow_owner(types: &[&str], text: &'static str, png: &'static [u8]) -> (Reads, Arc>, std::sync::mpsc::Sender<()>) { let (release, gate) = std::sync::mpsc::channel::<()>(); - let loads = Loads::spawn("test", Duration::ZERO, move |()| { + let loads = Loads::spawn("test", Duration::ZERO, move |wire: String| { let _ = gate.recv(); - Ok(text.to_string()) + Ok(if wire == PNG { png.to_vec() } else { text.as_bytes().to_vec() }) }) .unwrap(); - (TextReads::new(loads), release) + let seen = Arc::new(Mutex::new(Seen::offering(types))); + (Reads::new(loads, seen.clone()), seen, release) } - // Text was copied here; the clipboard's owner answers the check with `text` late. - let paste_after_check = |text: &'static str| { - let (mut reads, release) = slow_text(text); + // The clipboard's owner answers the check with `text` late; `copied_here` when the + // clipboard holds the Copy made here meanwhile. + let paste_after_check = |text: &'static str, copied_here: bool| { + let (mut reads, seen, release) = slow_owner(&["text/plain;charset=utf-8", PNG], text, b"\x89PNG"); // The check whether the clipboard still holds what was copied here gets no answer // in its wait (2 s on the UI thread, none here). - assert!(reads.get(Duration::ZERO).is_err()); + assert!(reads.text(Duration::ZERO).is_err()); // The paste's own read fails at once while the owner hasn't answered. - assert!(reads.get(Duration::from_secs(600)).is_err()); + assert!(reads.text(Duration::from_secs(600)).is_err()); // A paste of a bitmap alone can't take text: the read stays kept. assert!(reads.unanswered(&[BITMAP]).is_none()); let late = Late::Wayland(reads.unanswered(&PASTE_ORDER).expect("the check's read is kept for the paste")); - let ours = Ours::Text("copied here".into()); - let paste = std::thread::spawn(move || late.paste(Some(&ours), &PASTE_ORDER)); + if copied_here { + let mut seen = seen.lock().unwrap(); + seen.copy(vec!["text/plain;charset=utf-8", PNG]); + seen.selection(vec!["text/plain;charset=utf-8".into(), PNG.into()]); + } + let paste = std::thread::spawn(move || late.paste(None, &PASTE_ORDER)); drop(release); paste.join().ok().flatten() }; - let pasted = paste_after_check("copied elsewhere").expect("the other app's text is pasted"); + let pasted = paste_after_check("copied elsewhere", false).expect("the other app's text is pasted"); assert_eq!((pasted.mime, pasted.data), (TEXT, b"copied elsewhere".to_vec())); - // The text copied here, or nothing: the paste takes the internal clipboard. - assert!(paste_after_check("copied here").is_none()); - assert!(paste_after_check("").is_none()); + // Text that only names a picture goes with it: the picture is pasted, as at once. + let pasted = paste_after_check("https://example.com/art.png", false).expect("the picture is pasted"); + assert_eq!((pasted.mime, pasted.data), (PNG, b"\x89PNG".to_vec())); + // The Copy made here, by its data source: the paste takes the internal clipboard. + assert!(paste_after_check("copied elsewhere", true).is_none()); + // Empty text is not pasted: the picture beside it is. + assert_eq!(paste_after_check("", false).map(|f| f.mime), Some(PNG)); // An answer that came before the paste may be old: it is dropped, not pasted. - let (mut reads, release) = slow_text("old"); - assert!(reads.get(Duration::ZERO).is_err()); + let (mut reads, _, release) = slow_owner(&["text/plain"], "old", b""); + assert!(reads.text(Duration::ZERO).is_err()); drop(release); while reads.waiting() { std::thread::yield_now(); @@ -1695,14 +1741,14 @@ mod portable { // again and takes the new owner's text. let (release, gate) = std::sync::mpsc::channel::<()>(); let mut texts_read = 0; - let loads = Loads::spawn("test", Duration::ZERO, move |()| { + let loads = Loads::spawn("test", Duration::ZERO, move |_: String| { let _ = gate.recv(); texts_read += 1; - Ok(if texts_read == 1 { "old owner" } else { "new owner" }.to_string()) + Ok(if texts_read == 1 { "old owner" } else { "new owner" }.as_bytes().to_vec()) }) .unwrap(); - let mut reads = TextReads::new(loads); - assert!(reads.get(Duration::ZERO).is_err()); + let mut reads = Reads::new(loads, Arc::new(Mutex::new(Seen::offering(&["UTF8_STRING"])))); + assert!(reads.text(Duration::ZERO).is_err()); let late = Late::Wayland(reads.unanswered(&[TEXT]).expect("the read is kept for the paste")); let paste = std::thread::spawn(move || late.paste(None, &[TEXT])); drop(release); @@ -1727,81 +1773,311 @@ mod portable { /// The compositor's clipboard through the core data-device protocol (`wl_data_device`), on the /// window's own Wayland connection: what Copy and Paste use where the compositor lacks the /// data-control protocol arboard needs (GNOME) and there is no X11 to fall back on (the Flatpak on -/// a Wayland session). Text only (smithay-clipboard), and only while the window has focus, as the -/// protocol has it. A read waits [`LOAD_WAIT`] at most: smithay-clipboard waits for the owner's -/// data with no limit, so reads run on a worker of their own. A read that gets no answer in that -/// time is kept for a paste to wait for off the UI thread, as arboard's are. +/// a Wayland session). A Copy offers its SVG as `image/svg+xml`, its PNG as `image/png` and its +/// text under each of [`TEXT_TYPES`]; Paste reads the clipboard's SVG, text and PNG, and the paste +/// check looks at the types offered without reading any data. The protocol gives the clipboard to +/// the window with the keyboard focus only. A worker thread handles the protocol's events and +/// starts a thread for each paste of a Copy's data by another app ([`MAX_SENDS`] at once). Reads +/// run on another worker and take [`MAX_BYTES`] at most; text waits [`LOAD_WAIT`] at most, SVG and +/// PNG as long as a paste's bitmap from arboard. A read that gets no answer in its wait is kept for +/// a paste to wait for off the UI thread, as arboard's are. #[cfg(target_os = "linux")] mod wayland { - use std::sync::{Arc, mpsc}; + use std::io::{Read, Write}; + use std::os::fd::AsFd as _; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex, MutexGuard, PoisonError, mpsc}; use std::time::{Duration, Instant}; - use super::TEXT; + use vectorcraft_engine::cmd::clipboard::names_a_picture; + use vectorcraft_ui_egui::ClipboardRead; + use wayland_client::backend::ObjectData; + use wayland_client::globals::{GlobalListContents, registry_queue_init}; + use wayland_client::protocol::wl_callback::{self, WlCallback}; + use wayland_client::protocol::wl_data_device::{self, WlDataDevice}; + use wayland_client::protocol::wl_data_device_manager::{self, WlDataDeviceManager}; + use wayland_client::protocol::wl_data_offer::{self, WlDataOffer}; + use wayland_client::protocol::wl_data_source::{self, WlDataSource}; + use wayland_client::protocol::wl_keyboard::{self, WlKeyboard}; + use wayland_client::protocol::wl_pointer::{self, WlPointer}; + use wayland_client::protocol::wl_registry::{self, WlRegistry}; + use wayland_client::protocol::wl_seat::{self, WlSeat}; + use wayland_client::{Connection, Dispatch, Proxy, QueueHandle, WEnum}; + use super::loads::{Asker, GONE, LOAD_WAIT, Loads, wait_for_answer}; + use super::{BITMAP, Flavour, PNG, SVG, TEXT}; - /// What a text read gives. - type Text = Result; + /// The types text is offered under and read from, best first: `text/plain;charset=utf-8` (GTK + /// and Qt apps), `UTF8_STRING` (X11 apps through XWayland) and `text/plain`. + const TEXT_TYPES: [&str; 3] = ["text/plain;charset=utf-8", "UTF8_STRING", "text/plain"]; + /// The most bytes a read takes from the app that copied (untrusted data), VectorCraft's own + /// limit: a read of more fails. + const MAX_BYTES: usize = 64 << 20; + /// The most pastes of a Copy's data that are sent at once; a paste beyond them gets nothing. + const MAX_SENDS: usize = 8; + /// The most types of an offer that are kept (untrusted data). + const MAX_TYPES: usize = 256; - pub struct TextClipboard { - /// Writes: a store only hands the text to smithay-clipboard's thread. - cb: smithay_clipboard::Clipboard, - /// Reads, on a worker with a second clipboard on the same connection. - reads: TextReads, - /// Held as long as the clipboards: their connection is the window's. - _window: Arc, + /// What a read gives: the bytes, or why there are none. + type Got = Result, String>; + + fn lock(m: &Mutex) -> MutexGuard<'_, T> { + m.lock().unwrap_or_else(PoisonError::into_inner) } - /// Text reads on a worker, and the last one that got no answer in its wait. - pub(super) struct TextReads { - loads: Loads<(), Text>, - /// The last read that got no answer in its wait, until a paste takes it or the next read - /// is sent. + /// The types a Copy of `flavours` offers, each with its data: SVG as `image/svg+xml`, PNG as + /// `image/png` and text under each of [`TEXT_TYPES`]. Flavours with no data and other flavours + /// (PDF) are not offered. + fn copy_types(flavours: &[Flavour]) -> Vec<(&'static str, Arc<[u8]>)> { + let mut out: Vec<(&'static str, Arc<[u8]>)> = vec![]; + for f in flavours.iter().filter(|f| !f.data.is_empty()) { + let types: &[&'static str] = match f.mime { + SVG => &[SVG], + PNG => &[PNG], + TEXT => &TEXT_TYPES, + _ => &[], + }; + let data: Arc<[u8]> = f.data.as_slice().into(); + for &t in types { + if !out.iter().any(|(o, _)| *o == t) { + out.push((t, data.clone())); + } + } + } + out + } + + /// The type among `offered` that a read of `mime` asks for, in any letter case: SVG as + /// `image/svg+xml`, text as the first of [`TEXT_TYPES`] offered, a bitmap as `image/png`. + fn wire_type<'a>(mime: &str, offered: &'a [String]) -> Option<&'a str> { + let wanted: &[&str] = match mime { + SVG => &[SVG], + TEXT => &TEXT_TYPES, + BITMAP | PNG => &[PNG], + _ => &[], + }; + wanted.iter().find_map(|w| offered.iter().find(|o| o.eq_ignore_ascii_case(w))).map(String::as_str) + } + + /// The flavour a read of `mime` makes of `data`: SVG as it is, a bitmap as PNG, text with its + /// line ends as `\n`. None when `data` is empty. + fn flavour(mime: &'static str, data: Vec) -> Option { + if data.is_empty() { + return None; + } + Some(match mime { + TEXT => Flavour { mime: TEXT, data: String::from_utf8_lossy(&data).replace("\r\n", "\n").replace('\r', "\n").into_bytes() }, + BITMAP | PNG => Flavour { mime: PNG, data }, + _ => Flavour { mime, data }, + }) + } + + /// What a paste of one of `mimes` takes from a clipboard offering `offered`, `read` reading the + /// offer's type for each one tried: the first of `mimes` offered with data, except that text + /// that is blank or only names a picture gives way to the PNG when `mimes` has a bitmap + /// (`adopt_system_clipboard_or_defer` in `vectorcraft-ui-egui`). + fn pick(offered: &[String], mimes: &[&'static str], mut read: impl FnMut(&str) -> Option>) -> Option { + let mut take = |mime: &'static str| flavour(mime, read(wire_type(mime, offered)?)?); + let first = mimes.iter().find_map(|&m| take(m))?; + if first.mime == TEXT && mimes.contains(&BITMAP) { + let text = String::from_utf8_lossy(&first.data); + if text.trim().is_empty() || names_a_picture(&text) { + return take(BITMAP).or(Some(first)); + } + } + Some(first) + } + + /// `from` read to its end, `cap` bytes at most: more is an error, as is a failed read. + fn read_capped(from: impl Read, cap: usize) -> Got { + let mut data = vec![]; + from.take(cap as u64 + 1).read_to_end(&mut data).map_err(|e| format!("couldn't read the clipboard's data: {e}"))?; + if data.len() > cap { + return Err(format!("the clipboard's data is larger than {} MiB", cap >> 20)); + } + Ok(data) + } + + /// The threads that send a Copy's data to the apps that paste it, counted. + #[derive(Clone, Default)] + struct Sends(Arc); + + impl Sends { + /// `data` written to `to` on a thread of its own, which then closes `to`; an app that + /// closes its end early ends the write (the process ignores `SIGPIPE`). While + /// [`MAX_SENDS`] are being written `to` is closed at once with nothing written → false. + fn start(&self, mut to: impl Write + Send + 'static, data: Arc<[u8]>) -> bool { + if self.0.fetch_add(1, Ordering::SeqCst) >= MAX_SENDS { + self.0.fetch_sub(1, Ordering::SeqCst); + return false; + } + let count = self.0.clone(); + let send = move || { + let _ = to.write_all(&data); + drop(to); + count.fetch_sub(1, Ordering::SeqCst); + }; + if std::thread::Builder::new().name("wayland-clipboard-send".into()).spawn(send).is_err() { + self.0.fetch_sub(1, Ordering::SeqCst); + return false; + } + true + } + + /// How many are being written. + #[cfg(test)] + fn running(&self) -> usize { + self.0.load(Ordering::SeqCst) + } + } + + /// What the protocol worker has seen of the clipboard. + #[derive(Default)] + pub(super) struct Seen { + /// The types of the clipboard's offer, none when it is empty. + offered: Vec, + /// The types of this connection's last Copy, until its data source is canceled. + copied: Option>, + /// Whether the clipboard holds that Copy. + ours: bool, + } + + impl Seen { + /// A clipboard offering `types`, copied elsewhere. + #[cfg(test)] + pub(super) fn offering(types: &[&str]) -> Self { + Self { offered: types.iter().map(|t| t.to_string()).collect(), ..Self::default() } + } + + /// A Copy of `types` (none: the clipboard emptied) was given the clipboard. + pub(super) fn copy(&mut self, types: Vec<&'static str>) { + self.ours = !types.is_empty(); + self.copied = Some(types).filter(|t| !t.is_empty()); + } + + /// The clipboard's offer has `offered` now. It holds the last Copy while that Copy's data + /// source is not canceled and the offer has the Copy's types, no more and no fewer, as the + /// compositor offers a source's types. An offer made before the Copy and handled after it + /// makes the Copy not ours until the Copy's own offer comes. + pub(super) fn selection(&mut self, offered: Vec) { + self.ours = self.copied.as_ref().is_some_and(|c| c.len() == offered.len() && c.iter().all(|t| offered.iter().any(|o| o == t))); + self.offered = offered; + } + + /// The last Copy's data source was canceled: another app holds the clipboard. + pub(super) fn cancelled(&mut self) { + self.copied = None; + self.ours = false; + } + } + + /// Reads of the clipboard's offer on a worker, and the last one that got no answer in its wait. + pub(super) struct Reads { + /// Each request is the offer's type to read. + loads: Loads, + seen: Arc>, + /// The last text read that got no answer in its wait, until a paste takes it or the next + /// read is sent. unanswered: Option, } /// A text read that got no answer in its wait. pub(super) struct Unanswered { - answer: mpsc::Receiver, + answer: mpsc::Receiver, sent: Instant, - /// The worker, for the read made again once the answer comes. - reads: Asker<(), Text>, + /// The worker, for the reads made once the answer comes. + reads: Asker, + seen: Arc>, } impl Unanswered { - /// The text, read again once this read's answer comes, waiting for that answer until - /// `limit` after the read was sent, and for the new one as long, [`LOAD_WAIT`] at least. - /// The late answer may come from an app that lost the clipboard while it was awaited. - pub(super) fn text(self, limit: Duration) -> Text { + /// What a paste of one of `mimes` takes once this read's answer comes, waiting for it until + /// `limit` after the read was sent: the clipboard read again as [`pick`] reads it, each read + /// waiting as long, [`LOAD_WAIT`] at least, since the app that answers late may have lost the + /// clipboard meanwhile. None when the answer doesn't come or the clipboard holds this + /// connection's Copy. + pub(super) fn paste(self, mimes: &[&'static str], limit: Duration) -> Option { let left = |now: Instant| (self.sent + limit).saturating_duration_since(now); - let _late = wait_for_answer(&self.answer, left(Instant::now()))?; - wait_for_answer(&self.reads.ask(())?, LOAD_WAIT.max(left(Instant::now())))? + let _late = wait_for_answer(&self.answer, left(Instant::now())).ok()?; + let offered = { + let seen = lock(&self.seen); + if seen.ours { + return None; + } + seen.offered.clone() + }; + pick(&offered, mimes, |wire| wait_for_answer(&self.reads.ask(wire.to_string()).ok()?, LOAD_WAIT.max(left(Instant::now()))).ok()?.ok()) } } - impl TextReads { - pub(super) fn new(loads: Loads<(), Text>) -> Self { - Self { loads, unanswered: None } + impl Reads { + pub(super) fn new(loads: Loads, seen: Arc>) -> Self { + Self { loads, seen, unanswered: None } } - /// The text, waiting `wait` at most. A read that gets no answer in that time is kept - /// ([`Self::unanswered`]) for a paste to wait for off the UI thread; a read sent means the - /// one kept has been answered, and its answer is dropped. - pub(super) fn get(&mut self, wait: Duration) -> Text { + /// The offer's type for `mime`, when it has one. + fn wire(&self, mime: &str) -> Option { + wire_type(mime, &lock(&self.seen).offered).map(str::to_string) + } + + /// The text, waiting `wait` at most; empty when the clipboard has none. A read that gets no + /// answer in that time is kept ([`Self::unanswered`]) for a paste to wait for off the UI + /// thread; a read sent means the one kept has been answered, and its answer is dropped. + pub(super) fn text(&mut self, wait: Duration) -> Result { + let Some(wire) = self.wire(TEXT) else { return Ok(String::new()) }; let sent = Instant::now(); - let answer = self.loads.ask(())?; + let answer = self.loads.ask(wire)?; self.unanswered = None; match wait_for_answer(&answer, wait) { - Ok(text) => text, + Ok(got) => Ok(flavour(TEXT, got?).map(|f| String::from_utf8_lossy(&f.data).into_owned()).unwrap_or_default()), Err(why) => { if why != GONE { - self.unanswered = Some(Unanswered { answer, sent, reads: self.loads.asker() }); + self.unanswered = Some(Unanswered { answer, sent, reads: self.loads.asker(), seen: self.seen.clone() }); } Err(why) } } } - /// The read that got no answer in its wait, while its answer has still not come and a + /// What a paste of one of `mimes` reads: the first the clipboard offers. Text is read here, + /// [`LOAD_WAIT`] at most, and pasted unless it is empty; SVG and PNG are asked for here and + /// waited for, `wait` at most, by the work that comes back. When that read fails or is + /// empty, the work reads the rest of `mimes` as [`pick`] does, each read waiting until + /// `wait` after the first was sent, [`LOAD_WAIT`] at least. + pub(super) fn read(&mut self, mimes: &[&'static str], wait: Duration) -> Option { + mimes.iter().enumerate().find_map(|(i, &mime)| { + let wire = self.wire(mime)?; + if mime == TEXT { + let text = self.text(LOAD_WAIT).ok().filter(|t| !t.is_empty())?; + return Some(ClipboardRead::Now(Flavour { mime: TEXT, data: text.into_bytes() })); + } + let sent = Instant::now(); + let answer = self.loads.ask(wire).ok()?; + self.unanswered = None; + let (reads, seen, rest) = (self.loads.asker(), self.seen.clone(), mimes.get(i + 1..).unwrap_or_default().to_vec()); + Some(ClipboardRead::Later(Box::new(move || { + if let Some(f) = wait_for_answer(&answer, wait).ok().and_then(Result::ok).and_then(|data| flavour(mime, data)) { + return Some(f); + } + let offered = lock(&seen).offered.clone(); + let left = || LOAD_WAIT.max((sent + wait).saturating_duration_since(Instant::now())); + pick(&offered, &rest, |wire| wait_for_answer(&reads.ask(wire.to_string()).ok()?, left()).ok()?.ok()) + }))) + }) + } + + /// Whether the clipboard offers one of `mimes`; nothing is read. + pub(super) fn has(&self, mimes: &[&'static str]) -> bool { + let seen = lock(&self.seen); + mimes.iter().any(|m| wire_type(m, &seen.offered).is_some()) + } + + /// Whether the clipboard holds this connection's last Copy. + pub(super) fn holds_ours(&self) -> bool { + lock(&self.seen).ours + } + + /// The text read that got no answer in its wait, while its answer has still not come and a /// paste of one of `mimes` can take text. Once it has come the answer may be old, and it is /// dropped: a new read is made. pub(super) fn unanswered(&mut self, mimes: &[&'static str]) -> Option { @@ -1809,6 +2085,11 @@ mod wayland { self.loads.waiting().then_some(unanswered) } + /// Drops the read that got no answer in its wait: a paste no longer takes its answer. + pub(super) fn forget_unanswered(&mut self) { + self.unanswered = None; + } + /// Whether a read sent is still unanswered. #[cfg(test)] pub(super) fn waiting(&self) -> bool { @@ -1816,55 +2097,591 @@ mod wayland { } } - /// The read worker's clipboard and the window whose connection it uses, dropped in that order. - struct Loader { - cb: smithay_clipboard::Clipboard, + /// The protocol objects of a [`DataClipboard`], kept up to date by its worker. + #[derive(Default)] + struct Objects { + device: Option, + keyboard: Option, + pointer: Option, + /// The serial of the last key press, click or keyboard focus, which a Copy takes the + /// clipboard with. + serial: Option, + /// The clipboard's offer. + selection: Option, + /// The offer of a drag over the window. + drag: Option, + /// The last Copy's data source. + source: Option, + } + + /// An offer's types, as its events give them. + #[derive(Default)] + struct OfferData(Mutex>); + + /// A Copy's types with their data. + struct SourceData(Vec<(&'static str, Arc<[u8]>)>); + + /// What the protocol worker's event handlers change. + struct State { + objects: Arc>, + seen: Arc>, + sends: Sends, + } + + impl State { + /// Lets go of every object, once the clipboard is dropped. + fn close(&mut self, seat: &WlSeat) { + let mut o = lock(&self.objects); + for offer in [o.selection.take(), o.drag.take()].into_iter().flatten() { + offer.destroy(); + } + if let Some(source) = o.source.take() { + source.destroy(); + } + if let Some(device) = o.device.take().filter(|d| d.version() >= 2) { + device.release(); + } + if let Some(keyboard) = o.keyboard.take().filter(|k| k.version() >= 3) { + keyboard.release(); + } + if let Some(pointer) = o.pointer.take().filter(|p| p.version() >= 3) { + pointer.release(); + } + if seat.version() >= 5 { + seat.release(); + } + } + } + + impl Dispatch for State { + fn event(_: &mut Self, _: &WlRegistry, _: wl_registry::Event, _: &GlobalListContents, _: &Connection, _: &QueueHandle) {} + } + + impl Dispatch for State { + fn event(_: &mut Self, _: &WlCallback, _: wl_callback::Event, _: &(), _: &Connection, _: &QueueHandle) {} + } + + impl Dispatch for State { + fn event(_: &mut Self, _: &WlDataDeviceManager, _: wl_data_device_manager::Event, _: &(), _: &Connection, _: &QueueHandle) {} + } + + impl Dispatch for State { + fn event(state: &mut Self, seat: &WlSeat, event: wl_seat::Event, _: &(), _: &Connection, qh: &QueueHandle) { + let wl_seat::Event::Capabilities { capabilities: WEnum::Value(caps) } = event else { return }; + let mut o = lock(&state.objects); + if !caps.contains(wl_seat::Capability::Keyboard) { + if let Some(keyboard) = o.keyboard.take().filter(|k| k.version() >= 3) { + keyboard.release(); + } + } else if o.keyboard.is_none() { + o.keyboard = Some(seat.get_keyboard(qh, ())); + } + if !caps.contains(wl_seat::Capability::Pointer) { + if let Some(pointer) = o.pointer.take().filter(|p| p.version() >= 3) { + pointer.release(); + } + } else if o.pointer.is_none() { + o.pointer = Some(seat.get_pointer(qh, ())); + } + } + } + + /// The serial a Copy takes the clipboard with after the keyboard event `event`, `serial` before + /// it: a key or the keyboard focus coming in sets it, the focus leaving clears it, and other + /// events leave it. A modifier change's serial is not an input event's, and the release of Ctrl + /// after Ctrl+C comes before the Copy's data is ready. + fn keyboard_serial(event: &wl_keyboard::Event, serial: Option) -> Option { + match event { + wl_keyboard::Event::Enter { serial, .. } | wl_keyboard::Event::Key { serial, .. } => Some(*serial), + wl_keyboard::Event::Leave { .. } => None, + _ => serial, + } + } + + impl Dispatch for State { + fn event(state: &mut Self, _: &WlKeyboard, event: wl_keyboard::Event, _: &(), _: &Connection, _: &QueueHandle) { + let mut o = lock(&state.objects); + o.serial = keyboard_serial(&event, o.serial); + } + } + + impl Dispatch for State { + fn event(state: &mut Self, _: &WlPointer, event: wl_pointer::Event, _: &(), _: &Connection, _: &QueueHandle) { + if let wl_pointer::Event::Button { serial, .. } = event { + lock(&state.objects).serial = Some(serial); + } + } + } + + impl Dispatch for State { + fn event(state: &mut Self, _: &WlDataDevice, event: wl_data_device::Event, _: &(), _: &Connection, _: &QueueHandle) { + let mut o = lock(&state.objects); + match event { + wl_data_device::Event::Selection { id } => { + let mut offered = id.as_ref().and_then(|offer| offer.data::()).map(|d| lock(&d.0).clone()).unwrap_or_default(); + offered.dedup(); + if let Some(old) = std::mem::replace(&mut o.selection, id) { + old.destroy(); + } + lock(&state.seen).selection(offered); + } + wl_data_device::Event::Enter { id, .. } => { + if let Some(old) = std::mem::replace(&mut o.drag, id) { + old.destroy(); + } + } + wl_data_device::Event::Leave | wl_data_device::Event::Drop => { + if let Some(old) = o.drag.take() { + old.destroy(); + } + } + _ => {} + } + } + + /// The one event of a data device that makes an object is `data_offer`. + fn event_created_child(_: u16, qh: &QueueHandle) -> Arc { + qh.make_data::(OfferData::default()) + } + } + + impl Dispatch for State { + fn event(_: &mut Self, _: &WlDataOffer, event: wl_data_offer::Event, data: &OfferData, _: &Connection, _: &QueueHandle) { + if let wl_data_offer::Event::Offer { mime_type } = event { + let mut types = lock(&data.0); + if types.len() < MAX_TYPES { + types.push(mime_type); + } + } + } + } + + impl Dispatch for State { + fn event(state: &mut Self, source: &WlDataSource, event: wl_data_source::Event, data: &SourceData, _: &Connection, _: &QueueHandle) { + match event { + // A type the Copy doesn't offer gets nothing: dropping `fd` closes it. + wl_data_source::Event::Send { mime_type, fd } => { + if let Some((_, bytes)) = data.0.iter().find(|(t, _)| t.eq_ignore_ascii_case(&mime_type)) { + state.sends.start(std::fs::File::from(fd), bytes.clone()); + } + } + wl_data_source::Event::Cancelled => { + source.destroy(); + let mut o = lock(&state.objects); + if o.source.as_ref() == Some(source) { + o.source = None; + lock(&state.seen).cancelled(); + } + } + _ => {} + } + } + } + + /// Reads the clipboard's offer for the read worker. + struct Receiver { + conn: Connection, + objects: Arc>, + /// Held as long as the connection, which is the window's. _window: Arc, } - impl Loader { - fn load(&self) -> Result { - self.cb.load().map_err(|e| e.to_string()) + impl Receiver { + /// The clipboard's data of type `wire`, read until the app that copied closes the pipe, + /// [`MAX_BYTES`] at most. + fn receive(&self, wire: String) -> Got { + let offer = lock(&self.objects).selection.clone().ok_or("the clipboard is empty")?; + let (from, to) = std::io::pipe().map_err(|e| format!("couldn't open a pipe for the clipboard's data: {e}"))?; + offer.receive(wire, to.as_fd()); + // The request carries a copy of the write end: once it is sent, the app that copied + // holds the only one, and the read ends when that app closes it. + drop(to); + self.conn.flush().map_err(|e| format!("couldn't reach the compositor: {e}"))?; + read_capped(from, MAX_BYTES) } } - impl TextClipboard { - /// None when `window` isn't a Wayland window or the read worker can't start. - #[allow(unsafe_code)] + pub struct DataClipboard { + reads: Reads, + objects: Arc>, + manager: WlDataDeviceManager, + conn: Connection, + qh: QueueHandle, + /// Tells the protocol worker to stop. + stop: Arc, + /// Held as long as the connection, which is the window's. + _window: Arc, + } + + impl DataClipboard { + /// None when `window` isn't a Wayland window, the compositor has no data-device manager or + /// seat, or a worker can't start. pub fn new(window: Arc) -> Option { - use winit::raw_window_handle::{HasDisplayHandle as _, RawDisplayHandle}; - let RawDisplayHandle::Wayland(d) = window.display_handle().ok()?.as_raw() else { return None }; - let display = d.display.as_ptr(); - // SAFETY: the display is winit's open `wl_display`, which outlives `window`; each - // clipboard is held beside a clone of `window` and dropped before it. smithay-clipboard - // makes its own event queue on the display and doesn't close it. - let (cb, reader) = unsafe { (smithay_clipboard::Clipboard::new(display), smithay_clipboard::Clipboard::new(display)) }; - let loader = Loader { cb: reader, _window: window.clone() }; - // `loader.load()`, not `loader.cb.load()`: a closure moves only the fields it names, - // and the worker must hold the window as long as its clipboard. + let conn = connection(&window)?; + let (globals, mut queue) = registry_queue_init::(&conn).ok()?; + let qh = queue.handle(); + let manager: WlDataDeviceManager = globals.bind(&qh, 1..=3, ()).ok()?; + let seat: WlSeat = globals.bind(&qh, 1..=5, ()).ok()?; + let (objects, seen) = (Arc::>::default(), Arc::>::default()); + lock(&objects).device = Some(manager.get_data_device(&seat, &qh, ())); + let mut state = State { objects: objects.clone(), seen: seen.clone(), sends: Sends::default() }; + // The seat's keyboard and pointer, and the clipboard's offer when the window has focus. + queue.roundtrip(&mut state).ok()?; + let receiver = Receiver { conn: conn.clone(), objects: objects.clone(), _window: window.clone() }; // Dropping doesn't wait for an unanswered read: only arboard's X11 handles hand what the // app copied on when the last one goes. - let loads = Loads::spawn("wayland-clipboard-read", Duration::ZERO, move |()| loader.load())?; - Some(Self { cb, reads: TextReads::new(loads), _window: window }) + let loads = Loads::spawn("wayland-clipboard-read", Duration::ZERO, move |wire| receiver.receive(wire))?; + let stop = Arc::new(AtomicBool::new(false)); + let (stopped, held) = (stop.clone(), window.clone()); + let work = move || { + while !stopped.load(Ordering::SeqCst) && queue.blocking_dispatch(&mut state).is_ok() {} + state.close(&seat); + let _ = queue.flush(); + drop(held); + }; + std::thread::Builder::new().name("wayland-clipboard".into()).spawn(work).ok()?; + Some(Self { reads: Reads::new(loads, seen), objects, manager, conn, qh, stop, _window: window }) } - pub fn get_text(&mut self) -> Result { - self.reads.get(LOAD_WAIT) + /// Gives the clipboard to a Copy of `flavours` ([`copy_types`]); none empties it. Err when + /// the seat has no data device or the window has had no input to take the clipboard with. + pub fn copy(&mut self, flavours: &[Flavour]) -> Result<(), String> { + let offered = copy_types(flavours); + let mut o = lock(&self.objects); + let device = o.device.clone().ok_or("the seat has no clipboard")?; + let serial = o.serial.ok_or("the window has had no key press or click to copy with")?; + let types: Vec<&'static str> = offered.iter().map(|(t, _)| *t).collect(); + let source = (!offered.is_empty()).then(|| { + let source = self.manager.create_data_source(&self.qh, SourceData(offered)); + for t in &types { + source.offer(t.to_string()); + } + source + }); + device.set_selection(source.as_ref(), serial); + if let Some(old) = std::mem::replace(&mut o.source, source) { + old.destroy(); + } + lock(&self.reads.seen).copy(types); + drop(o); + self.conn.flush().map_err(|e| format!("couldn't reach the compositor: {e}")) } - pub fn set_text(&self, text: &str) { - self.cb.store(text); + pub fn text(&mut self) -> Result { + self.reads.text(LOAD_WAIT) + } + + /// What a paste of one of `mimes` reads ([`Reads::read`]), SVG and PNG waited for `wait` at + /// most. + pub fn read(&mut self, mimes: &[&'static str], wait: Duration) -> Option { + self.reads.read(mimes, wait) + } + + pub fn has(&self, mimes: &[&'static str]) -> bool { + self.reads.has(mimes) + } + + pub fn holds_ours(&self) -> bool { + self.reads.holds_ours() } /// The read that got no answer in its wait, when a paste of one of `mimes` can take its - /// answer ([`TextReads::unanswered`]). + /// answer ([`Reads::unanswered`]). pub(super) fn unanswered(&mut self, mimes: &[&'static str]) -> Option { self.reads.unanswered(mimes) } /// Drops the read that got no answer in its wait: a paste no longer takes its answer. pub fn forget_unanswered(&mut self) { - self.reads.unanswered = None; + self.reads.forget_unanswered(); + } + } + + impl Drop for DataClipboard { + /// Stops the protocol worker, which a sync request's answer wakes. + fn drop(&mut self) { + self.stop.store(true, Ordering::SeqCst); + let _ = self.conn.display().sync(&self.qh, ()); + let _ = self.conn.flush(); + } + } + + /// A connection on `window`'s Wayland display. + #[allow(unsafe_code)] + fn connection(window: &winit::window::Window) -> Option { + use wayland_backend::sys::client::Backend; + use winit::raw_window_handle::{HasDisplayHandle as _, RawDisplayHandle}; + let RawDisplayHandle::Wayland(d) = window.display_handle().ok()?.as_raw() else { return None }; + // SAFETY: the display is winit's open `wl_display`, which outlives `window`; every user of + // the connection holds a clone of `window`. A backend made from a foreign display does not + // close it when dropped. + let backend = unsafe { Backend::from_foreign_display(d.display.as_ptr().cast()) }; + Some(Connection::from_backend(backend)) + } + + #[cfg(test)] + mod tests { + use super::*; + + fn flavour_of(mime: &'static str, data: &[u8]) -> Flavour { + Flavour { mime, data: data.to_vec() } + } + + fn offered(types: &[&str]) -> Vec { + types.iter().map(|t| t.to_string()).collect() + } + + #[test] + fn a_copy_offers_its_svg_png_and_text_each_with_its_own_data() { + let flavours = [flavour_of(TEXT, b""), flavour_of(SVG, b""), flavour_of(super::super::PDF, b"%PDF"), flavour_of(PNG, b"png")]; + let types: Vec<(&str, Vec)> = copy_types(&flavours).into_iter().map(|(t, d)| (t, d.to_vec())).collect(); + assert_eq!( + types, + [ + ("text/plain;charset=utf-8", b"".to_vec()), + ("UTF8_STRING", b"".to_vec()), + ("text/plain", b"".to_vec()), + (SVG, b"".to_vec()), + (PNG, b"png".to_vec()), + ] + ); + // With Include SVG Code off the engine makes no SVG flavour, and none is offered; a + // flavour with no data is not offered either. + let types: Vec<&str> = copy_types(&[flavour_of(PNG, b"png"), flavour_of(TEXT, b"")]).into_iter().map(|(t, _)| t).collect(); + assert_eq!(types, [PNG]); + assert!(copy_types(&[]).is_empty()); + } + + #[test] + fn a_read_asks_for_the_offers_own_type() { + let all = offered(&["text/plain", "TEXT", "UTF8_STRING", "Image/PNG", "image/svg+xml", "application/pdf"]); + assert_eq!(wire_type(TEXT, &all), Some("UTF8_STRING")); + assert_eq!(wire_type(TEXT, &offered(&["text/plain", "text/plain;charset=UTF-8"])), Some("text/plain;charset=UTF-8")); + assert_eq!(wire_type(BITMAP, &all), Some("Image/PNG")); + assert_eq!(wire_type(SVG, &all), Some("image/svg+xml")); + // PDF, EPS and metafiles are not read here, nor types no reader knows. + assert_eq!(wire_type(super::super::PDF, &all), None); + assert_eq!(wire_type(TEXT, &offered(&["text/html", "image/jpeg"])), None); + assert_eq!(wire_type(SVG, &[]), None); + } + + /// What a paste of `mimes` takes from a clipboard holding `data` by type, and the types read. + fn pasted(data: &[(&str, &[u8])], mimes: &[&'static str]) -> (Option, Vec) { + let types: Vec = data.iter().map(|(t, _)| t.to_string()).collect(); + let mut read = vec![]; + let f = pick(&types, mimes, |wire| { + read.push(wire.to_string()); + data.iter().find(|(t, _)| *t == wire).map(|(_, d)| d.to_vec()) + }); + (f, read) + } + + #[test] + fn a_paste_takes_svg_then_text_then_png() { + use vectorcraft_engine::cmd::clipboard::PASTE_ORDER; + let svg: (&str, &[u8]) = (SVG, b""); + let text: (&str, &[u8]) = ("text/plain;charset=utf-8", b"a\r\nb"); + let png: (&str, &[u8]) = (PNG, b"\x89PNG"); + let (f, read) = pasted(&[text, png, svg], &PASTE_ORDER); + assert_eq!(f.map(|f| (f.mime, f.data)), Some((SVG, svg.1.to_vec()))); + assert_eq!(read, [SVG]); + // Text before a bitmap, its line ends made `\n`. + let (f, _) = pasted(&[png, text], &PASTE_ORDER); + assert_eq!(f.map(|f| (f.mime, f.data)), Some((TEXT, b"a\nb".to_vec()))); + // An offer of a PNG or SVG alone pastes it. + assert_eq!(pasted(&[png], &PASTE_ORDER).0.map(|f| (f.mime, f.data)), Some((PNG, png.1.to_vec()))); + assert_eq!(pasted(&[svg], &PASTE_ORDER).0.map(|f| f.mime), Some(SVG)); + // Blank text, or text that only names a picture, gives way to the picture. + for beside in [&b" \n"[..], b"https://example.com/art.png", b""] { + let (f, read) = pasted(&[("UTF8_STRING", beside), png], &PASTE_ORDER); + assert_eq!(f.map(|f| f.mime), Some(PNG)); + assert_eq!(read, ["UTF8_STRING", PNG]); + } + // With no picture, or a paste of text alone, the text is what there is. + assert_eq!(pasted(&[("text/plain", &b"https://example.com/art.png"[..])], &PASTE_ORDER).0.map(|f| f.mime), Some(TEXT)); + assert_eq!(pasted(&[("text/plain", &b" "[..]), png], &[TEXT]).0.map(|f| f.mime), Some(TEXT)); + // An empty clipboard, a type no reader knows, data that doesn't come or is empty. + assert_eq!(pasted(&[], &PASTE_ORDER).0, None); + assert_eq!(pasted(&[("application/x-other", &b"x"[..])], &PASTE_ORDER).0, None); + assert_eq!(pasted(&[(PNG, &b""[..])], &PASTE_ORDER).0, None); + let types = offered(&[PNG]); + assert_eq!(pick(&types, &PASTE_ORDER, |_| None), None); + } + + #[test] + fn the_clipboard_holds_a_copy_until_another_app_takes_it() { + let mut seen = Seen::default(); + let types = vec!["text/plain;charset=utf-8", SVG, PNG]; + seen.copy(types.clone()); + assert!(seen.ours, "a Copy holds the clipboard before its offer comes"); + // An offer made before the Copy and handled after it. + seen.selection(offered(&[PNG])); + assert!(!seen.ours); + // The Copy's own offer, its types in another order. + seen.selection(offered(&[PNG, SVG, "text/plain;charset=utf-8"])); + assert!(seen.ours); + // Another app's copy: an offer with other types, then the source canceled. + seen.selection(offered(&[PNG, SVG, "text/plain;charset=utf-8", "text/html"])); + assert!(!seen.ours); + seen.selection(offered(&[PNG, SVG, "text/plain;charset=utf-8"])); + seen.cancelled(); + assert!(!seen.ours, "a canceled source no longer holds the clipboard"); + // Another app's offer with the same types after that is not ours. + seen.selection(offered(&[PNG, SVG, "text/plain;charset=utf-8"])); + assert!(!seen.ours); + // An empty Copy empties the clipboard. + seen.copy(vec![]); + assert!(!seen.ours); + seen.selection(vec![]); + assert!(!seen.ours && seen.offered.is_empty()); + } + + #[test] + fn a_copy_takes_the_clipboard_with_a_key_press_not_a_modifier_change() { + let key = |serial| wl_keyboard::Event::Key { serial, time: 0, key: 46, state: WEnum::Value(wl_keyboard::KeyState::Pressed) }; + let modifiers = |serial| wl_keyboard::Event::Modifiers { serial, mods_depressed: 0, mods_latched: 0, mods_locked: 0, group: 0 }; + // Ctrl+C, then Ctrl released before the Copy's data is ready. + let serial = keyboard_serial(&key(7), None); + assert_eq!(keyboard_serial(&modifiers(8), serial), Some(7)); + assert_eq!(keyboard_serial(&wl_keyboard::Event::RepeatInfo { rate: 25, delay: 600 }, Some(7)), Some(7)); + assert_eq!(keyboard_serial(&modifiers(9), None), None); + } + + #[test] + fn a_read_stops_at_its_limit() { + assert_eq!(MAX_BYTES, 64 * 1024 * 1024); + assert_eq!(read_capped(&b"12345678"[..], 8), Ok(b"12345678".to_vec())); + assert_eq!(read_capped(&b""[..], 8), Ok(vec![])); + assert_eq!(read_capped(&[0u8; 9][..], 8), Err("the clipboard's data is larger than 0 MiB".to_string())); + assert_eq!(read_capped(&vec![0u8; (2 << 20) + 1][..], 2 << 20), Err("the clipboard's data is larger than 2 MiB".to_string())); + /// A pipe that breaks. + struct Broken; + impl Read for Broken { + fn read(&mut self, _: &mut [u8]) -> std::io::Result { + Err(std::io::ErrorKind::BrokenPipe.into()) + } + } + assert!(read_capped(Broken, 8).is_err_and(|e| e.starts_with("couldn't read the clipboard's data"))); + } + + #[test] + fn a_copys_data_is_sent_through_the_pipe_and_closed() { + let sends = Sends::default(); + let (from, to) = std::io::pipe().unwrap(); + let data: Arc<[u8]> = vec![7u8; 300_000].into(); + assert!(sends.start(to, data.clone())); + // More than a pipe holds: the write ends once all of it is read, then the pipe closes. + assert_eq!(read_capped(from, MAX_BYTES).as_deref(), Ok(&data[..])); + // An app that closes its end before reading ends the send. + let (from, to) = std::io::pipe().unwrap(); + drop(from); + assert!(sends.start(to, data.clone())); + while sends.running() > 0 { + std::thread::yield_now(); + } + } + + #[test] + fn pastes_that_read_nothing_hold_a_few_sends_at_most() { + let sends = Sends::default(); + let data: Arc<[u8]> = vec![1u8; 300_000].into(); + // Apps that ask for the data and never read it. + let stalled: Vec<_> = (0..MAX_SENDS) + .map(|_| { + let (from, to) = std::io::pipe().unwrap(); + assert!(sends.start(to, data.clone())); + from + }) + .collect(); + // The next paste gets its pipe closed at once, with nothing written. + let (from, to) = std::io::pipe().unwrap(); + assert!(!sends.start(to, data.clone())); + assert_eq!(read_capped(from, MAX_BYTES), Ok(vec![])); + // Once those apps close their pipes, the sends end and others are sent again. + drop(stalled); + while sends.running() > 0 { + std::thread::yield_now(); + } + let (from, to) = std::io::pipe().unwrap(); + assert!(sends.start(to, data.clone())); + assert_eq!(read_capped(from, MAX_BYTES).map(|d| d.len()), Ok(data.len())); + } + + /// What a clipboard holds, by type. + type Held = &'static [(&'static str, &'static [u8])]; + const PNG_ONLY: Held = &[(PNG, b"\x89PNG")]; + const SVG_ONLY: Held = &[(SVG, b"")]; + const TEXT_ONLY: Held = &[("text/plain;charset=utf-8", b"words")]; + const EMPTY_TEXT: Held = &[("text/plain", b"")]; + const NOTHING: Held = &[]; + const UNKNOWN: Held = &[("application/x-other", b"x")]; + + /// Reads of a clipboard holding `data`, answered at once. + fn reads_of(data: Held) -> Reads { + let loads = Loads::spawn("test", Duration::ZERO, move |wire: String| { + data.iter().find(|(t, _)| *t == wire).map(|(_, d)| d.to_vec()).ok_or_else(|| "not offered".to_string()) + }) + .unwrap(); + let types: Vec<&str> = data.iter().map(|(t, _)| *t).collect(); + Reads::new(loads, Arc::new(Mutex::new(Seen::offering(&types)))) + } + + #[test] + fn a_picture_or_svg_alone_can_be_pasted_and_is() { + use vectorcraft_engine::cmd::clipboard::PASTE_ORDER; + let wait = Duration::from_secs(600); + for (data, mime) in [(PNG_ONLY, PNG), (SVG_ONLY, SVG)] { + let mut reads = reads_of(data); + // The paste check enables Paste from the types alone. + assert!(reads.has(&PASTE_ORDER)); + let Some(ClipboardRead::Later(work)) = reads.read(&PASTE_ORDER, wait) else { panic!("{mime} is read off the UI thread") }; + assert_eq!(work().map(|f| (f.mime, f.data)), Some((mime, data[0].1.to_vec()))); + } + let mut reads = reads_of(TEXT_ONLY); + assert!(reads.has(&[TEXT]) && !reads.has(&[BITMAP, SVG])); + let Some(ClipboardRead::Now(f)) = reads.read(&PASTE_ORDER, wait) else { panic!("text is read at once") }; + assert_eq!((f.mime, f.data), (TEXT, b"words".to_vec())); + // An empty clipboard, or one with no type a paste reads, enables nothing and pastes nothing. + for data in [NOTHING, UNKNOWN] { + let mut reads = reads_of(data); + assert!(!reads.has(&PASTE_ORDER)); + assert!(reads.read(&PASTE_ORDER, wait).is_none()); + assert_eq!(reads.text(wait), Ok(String::new())); + } + // Empty text is not pasted. + assert!(reads_of(EMPTY_TEXT).read(&[TEXT], wait).is_none()); + } + + #[test] + fn an_svg_that_sends_nothing_gives_way_to_the_text_or_png_beside_it() { + use vectorcraft_engine::cmd::clipboard::PASTE_ORDER; + const EMPTY_SVG_TEXT: Held = &[(SVG, b""), ("text/plain", b"words"), (PNG, b"\x89PNG")]; + const EMPTY_SVG_PNG: Held = &[(SVG, b""), (PNG, b"\x89PNG")]; + const EMPTY_SVG_URL: Held = &[(SVG, b""), ("text/plain", b"https://example.com/art.png"), (PNG, b"\x89PNG")]; + let wait = Duration::from_secs(600); + const EMPTY_PNG: Held = &[(PNG, b"")]; + for (data, mime, bytes) in + [(EMPTY_SVG_TEXT, TEXT, &b"words"[..]), (EMPTY_SVG_PNG, PNG, &b"\x89PNG"[..]), (EMPTY_SVG_URL, PNG, &b"\x89PNG"[..])] + { + // Held while the work runs, as the clipboard is. + let mut reads = reads_of(data); + let Some(ClipboardRead::Later(work)) = reads.read(&PASTE_ORDER, wait) else { panic!("the SVG is asked for") }; + assert_eq!(work().map(|f| (f.mime, f.data)), Some((mime, bytes.to_vec()))); + } + // A failed PNG read, last of all, pastes nothing. + let mut reads = reads_of(EMPTY_PNG); + let Some(ClipboardRead::Later(work)) = reads.read(&PASTE_ORDER, wait) else { panic!("the PNG is asked for") }; + assert!(work().is_none()); + } + + #[test] + fn a_read_that_fails_pastes_nothing() { + let loads = Loads::spawn("test", Duration::ZERO, |_: String| Err::, _>("the clipboard's data is larger than 64 MiB".to_string())) + .unwrap(); + let mut reads = Reads::new(loads, Arc::new(Mutex::new(Seen::offering(&[PNG, "text/plain"])))); + assert_eq!(reads.text(Duration::from_secs(600)), Err("the clipboard's data is larger than 64 MiB".to_string())); + let Some(ClipboardRead::Later(work)) = reads.read(&[BITMAP], Duration::from_secs(600)) else { panic!("the PNG is asked for") }; + assert!(work().is_none()); } } } diff --git a/docs/development.md b/docs/development.md index 37f2b770ac..178b620549 100644 --- a/docs/development.md +++ b/docs/development.md @@ -125,7 +125,7 @@ On GNOME/Wayland, enable **Preferences › User Interface › System Title Bar** The window runs natively on Wayland (eframe's `wayland` feature) and on X11. On machines with two GPUs it renders on the one the desktop runs on unless Preferences say otherwise; see [Desktop graphics processor](#desktop-graphics-processor) (#502). The system clipboard (`apps/vectorcraft/src/clipboard.rs`) is arboard with its `wayland-data-control` feature: -- **Clipboard:** under Wayland arboard talks to the compositor through the data-control protocol (wlroots compositors such as Sway and Hyprland, KDE Plasma), so bitmaps (`image/png`), SVG markup and text copied in any app paste (#398). Where the compositor lacks the protocol (GNOME's Mutter, [arboard#223](https://github.com/1Password/arboard/issues/223)), arboard falls back to the X11 clipboard through XWayland, which only holds what X11 apps copied. Where there is no X11 either (the Flatpak on a Wayland session: `--socket=fallback-x11` gives it none), Copy and Paste use the compositor's core data-device protocol on the window's own connection through smithay-clipboard: text only (a copy's text or its SVG markup), and only while the window has focus. smithay-clipboard waits for the owner's data with no limit, so its reads run on a worker thread and the UI thread waits 2 s at most: an owner that never sends its data costs one read 2 s, and later reads return at once until it answers. When a read gets no answer in its 2 s, such as a paste's check whether the clipboard still holds what VectorCraft copied, the paste waits on its background thread for that read's answer, up to 10 s after the read was sent, and pastes the text unless it is empty or what VectorCraft copied. A clipboard neither backend opens is tried again after 1 s, doubling to a minute (a Copy or Paste of the user's at most once a second), and logged once when it goes and once when it comes back: before, the background Paste check reconnected and logged a warning every 250 ms. egui's own text paste into fields goes through smithay-clipboard and works either way. +- **Clipboard:** under Wayland arboard talks to the compositor through the data-control protocol (wlroots compositors such as Sway and Hyprland, KDE Plasma), so bitmaps (`image/png`), SVG markup and text copied in any app paste (#398). Where the compositor lacks the protocol (GNOME's Mutter, [arboard#223](https://github.com/1Password/arboard/issues/223)), arboard falls back to the X11 clipboard through XWayland, which only holds what X11 apps copied. Where there is no X11 either (the Flatpak on a Wayland session: `--socket=fallback-x11` gives it none), Copy and Paste use the compositor's core data-device protocol (`wl_data_device`) on the window's own connection, and only while the window has focus. One Copy offers the PNG as `image/png`, the SVG as `image/svg+xml` (with Include SVG Code on) and the text as `text/plain;charset=utf-8`, `UTF8_STRING` and `text/plain`; a format with no data is not offered. Paste reads `image/svg+xml`, then text, then `image/png`, and blank text or text that only names a picture gives way to the PNG; the Paste menu item is enabled by the types offered, without reading any data. Whether the clipboard still holds VectorCraft's Copy is told by its data source, which the compositor cancels when another app copies. A read takes 64 MiB at most and fails beyond that. Reads run on a worker thread: text waits 2 s at most on the UI thread, SVG and PNG 10 s on the paste's background thread. An owner that never sends its data costs one read its wait, and later reads return at once until it answers. When a text read gets no answer in its 2 s, the paste waits on its background thread for that read's answer, up to 10 s after the read was sent, then reads the clipboard again unless it holds VectorCraft's Copy. Another app's paste of a Copy is sent on a thread of its own, 8 at once at most; a paste beyond them gets nothing. A clipboard neither backend opens is tried again after 1 s, doubling to a minute (a Copy or Paste of the user's at most once a second), and logged once when it goes and once when it comes back: before, the background Paste check reconnected and logged a warning every 250 ms. egui's own text paste into fields goes through smithay-clipboard and works either way. - **Clipboard owners that never answer (#510):** reading the clipboard waits on the app that owns it: arboard gives up on an X11 owner after 4 s for each format it tries, and a Wayland read waits for the owner to send its data. So on Linux the check that enables the Paste menu items runs on a background thread with a second clipboard handle (`crates/ui-egui/src/clipboard_probe.rs`, installed as `Services::clipboard_probe`), only while nothing is copied in the app and a document is open, and the window keeps drawing. Every arboard read on Linux and the BSDs, including the UI thread's (a paste, and a Copy or Cut checking what the clipboard held), runs on a worker thread with a handle of its own and waits 2 s at most: an owner that never sends its data costs one read that wait, and later reads fail at once until it answers. A paste's bitmap read waits up to 10 s on the paste's background thread, so the UI thread does not wait for it. When one of a paste's reads gets no answer in its 2 s, or a paste follows a read that has not been answered yet, such as the paste's own check whether the clipboard still holds what VectorCraft copied, the paste waits on that thread for that read's answer, up to 10 s after the read was sent, and pastes what it holds. When the answer is what VectorCraft copied, holds nothing to paste, or does not come, the paste takes the internal clipboard. On X11, quitting within 25 s of a read that got no answer waits for that read to end, so the app's own handle is the last one and hands what it copied to the clipboard manager. Windows and macOS keep the check on the UI thread: Windows only asks which formats the clipboard holds, macOS asks the pasteboard server. - **Copied files:** files copied in a file manager (`text/uri-list`; `CF_HDROP` on Windows, file URLs on macOS) paste as the first one that is art (SVG, PDF, EPS, EMF/WMF or a bitmap; a `.ai` or `.ait` file from the editing data it holds, else as the PDF or PostScript it holds), before the clipboard's other formats (which include the files' paths as text). - **Dropping files on the window does not work under Wayland:** winit 0.30, which eframe 0.37 runs on, has no Wayland drag and drop ([winit#1881](https://github.com/rust-windowing/winit/issues/1881), added in winit 0.31; [egui#1563](https://github.com/emilk/egui/issues/1563)), so no `dropped_files` arrive. Until eframe moves to winit 0.31: copy the files in the file manager and paste them, use File › Place or Open, or open the window through XWayland, where drops work: set **Preferences › Performance › Linux Display Server** to **X11 (XWayland)** and restart (#1151), or start the app with `WAYLAND_DISPLAY= vectorcraft`. The preference (`linuxDisplayServer`, read at startup in `apps/vectorcraft/src/main.rs`, shown on Linux only) has winit open the window on X11 (`EventLoopBuilderExtX11::with_x11`) when the session is Wayland, `DISPLAY` is set and the X11 libraries winit loads are installed (`apps/vectorcraft/src/xwayland.rs` reads `ldconfig -p` and the usual library directories); otherwise the window opens on Wayland and the log says why. Unlike clearing `WAYLAND_DISPLAY`, it leaves the clipboard on the compositor's own. diff --git a/docs/gaps.md b/docs/gaps.md index d34956d4e0..bfefdcfd7c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -243,7 +243,8 @@ macOS cursor delivery (#2292) is narrowed to a native host adapter using the exi - **Done (2026-11-04):** a first frame the app is slow to build no longer costs the window its graphics adapter (#5160). The first-frame watchdog gave the frame 20 s from the app's start and then started again without the adapter, so a slow machine (antivirus reading every file, a large font library) ended with no adapter left and no window. A frame counts once its UI pass ends, before it is painted, so the 20 s now run from there; a frame still being built is waited for, with a note in the log (`a_first_frame_that_never_reaches_the_screen_hangs_its_adapter`; checked on Linux with the first frame held 35 s: the window opened with no restart). Estimate unchanged. - **Done (2026-11-04):** a large font library no longer keeps the window from opening (#5483). The menu bar's Type › Font, drawn in the first frame, waited for the scan of every installed font; when that took longer than the 20 s first-frame watchdog (thousands of fonts on a slow disk, or with each file checked by antivirus) the app took the graphics adapter for hung and started again without it until none was left. The menu now lists the fonts already loaded until the scan ends, then all of them (`the_menu_family_list_now_does_not_wait_for_the_scan`; checked on Linux with 3,923 extra font files, each open delayed 8 ms: the window opened in 1.5 s, where it never did before). Estimate unchanged. - **Done (2026-10-29):** the Linux Flatpak on GNOME no longer reconnects to the compositor and logs a warning every 250 ms when arboard has neither data-control nor X11 (267k warnings in 18 h filled the 16 MB log in 3 h): a clipboard that can't be opened is retried with a wait growing to a minute and logged once. Copy and Paste of text with other apps work there through the core Wayland protocol. -- **Missing:** pictures (`image/png`) and SVG as `image/svg+xml` on the clipboard of the Flatpak on GNOME, where only text travels (smithay-clipboard 0.7 carries text only); the app not starting or staying in the background on some Windows 11 machines (#858, #964 fixed +- **Unconfirmed (2026-11-13):** pictures and SVG on the clipboard of the Flatpak on GNOME. Copy there offers the PNG as `image/png`, the SVG as `image/svg+xml` (with Include SVG Code on) and the text, all from one Copy; Paste takes SVG, then text, then the PNG (blank text, or text that only names a picture, gives way to the PNG), and the Paste menu item is enabled by the types offered. A read takes 64 MiB at most (`clipboard::wayland` tests; not yet run in the Flatpak on a GNOME session). +- **Missing:** the app not starting or staying in the background on some Windows 11 machines (#858, #964 fixed 2026-10-10, #620, #818), slow pointer on a Linux AppImage (#575), macOS paste from other apps (#954), a web-free crash/hang reporter, a field record. - **Evidence:** open issues; the never-crash lints and `guard` stop panics, not driver, windowing or packaging diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f04ee3c910..240f70a5a3 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1642,6 +1642,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - A photo whose EXIF orientation turns or flips it (a camera held upright or upside down) is placed and opened upright, as it was taken; the image is stored as it is seen (#4284). A quarter turn swaps the resolution's axes, so the image keeps its physical size. TIFFs read without the image decoder (grayscale with extra channels, CCITT Group 3, CMYK with one plane per ink) follow their Orientation tag too, and a turned CMYK JPEG or TIFF keeps its inks, 8 bits each, as a CMYK TIFF. - File › Place and a file dropped on the canvas refuse a PNG, JPEG, GIF or WebP file whose pixels can't be read, such as one cut short by a download or a sync still running, and the status bar shows "Couldn't place : the image's pixels can't be read". Paste and Relink refuse such an image too, and Update Links, or Open when it reads the file again, counts it as missing. A JPEG cut short still places when its pixels can be decoded (`file.place`, `crates/engine/src/tests_place.rs`). - On the desktop, Copy and Cut make the system clipboard's formats (text, SVG, PDF, PNG) from a copy of the clipboard on the thread that writes background saves and exports, and write them when they are ready; while that thread has a save, an export, a print or a recovery copy to finish, they are made and written at once. Until they are written, Paste in VectorCraft takes the internal clipboard, and while a text field or the Type tool has the keyboard the app waits for them. Another Copy or Cut, or a copy of text in VectorCraft, drops them. + - In the Linux Flatpak on GNOME, where the clipboard goes through the compositor's core Wayland protocol, one Copy offers the PNG (`image/png`), the SVG (`image/svg+xml`, with Include SVG Code on) and the text, and Paste takes another app's SVG, text or PNG in that order, a picture over blank text or text that only names it; the Paste menu item is enabled by the types offered (`clipboard::wayland` tests in `apps/vectorcraft/src/clipboard.rs`; not yet run in the Flatpak on a GNOME session). - On the desktop with Enable Background Export on (Preferences › File Handling), File › Package… collects the linked files and fonts and writes the package from the document as it was at OK on the thread that writes background saves and exports, with "Packaging" in the status bar and the busy spinner until it is written; a save of unsaved changes that Package starts is written first. `file.package` over MCP and scripts replies as before. - On the desktop with Enable Background Export on, Export for Screens and the Asset Export panel's Export encode and write their files (into the folder, or one ZIP in it) from the document as it was at OK on the thread that writes background saves and exports, with "Exporting" in the status bar and the export progress dialog, which counts the files and whose Cancel stops before the next file. `document.exportForScreens` and `assets.export` over MCP and scripts reply as before. - With Enable Background Export on, an Export for Screens with no folder of its own also writes in the background into the folder its `exportPreset` keeps, with the same progress dialog and Cancel, and the status bar names that folder. When that folder is outside an MCP client's read roots, the reply, its errors and the status bar name it by its own name, as for an export made at once (`dialogs::tests_screens::an_export_into_a_presets_folder_writes_in_the_background`, `fileio::tests_screens::a_prepared_export_into_a_presets_folder_outside_the_read_root_leaves_it_out`). Behavior reference: the help page, https://helpx.adobe.com/illustrator/using/exporting-artwork.html (exports run in the background by default). From cf10a77594f5c09cde23b0115c7b587d8b1b374f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 04:17:59 -0800 Subject: [PATCH 05/56] Actions: Begin Recording adds steps to the selected action, and a drag moves a step With an action selected, Begin Recording records into it: Stop puts the recorded commands after the selected step, or after the action's last step, and the steps after them keep whether they are excluded. With no action selected it still adds a new Action N. - ui.action.record takes {set?, action?, after?}; action needs set. The place stays between the same steps while a step is inserted, deleted or moved in that action during the recording, and the selected step and an open Record Stop dialog follow their step when Stop adds the recorded steps before them. - New ui.action.moveStep {set, action, step, toSet?, toAction?, before} moves a step within its action or into another action, also an empty one or one in another set. The step keeps its params and exclusion, the selected step and an open Record Stop dialog follow their step, and a move past 10,000 steps or 8 MiB is an error that changes nothing. - In the Actions panel a step row drags: a line shows where the step goes, the release moves it once, and a release off the actions' rows leaves it where it was. The list scrolls near its edges. Help page: https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/add-commands-to-actions.html Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/mcp/src/ui_catalog.rs | 10 +- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/bs.tsv | 1 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hi.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/menus/run.rs | 1 + crates/ui-egui/src/menus/ui_commands.rs | 10 +- crates/ui-egui/src/panels/actions.rs | 601 +++++++++++++++++++++++- crates/ui-egui/src/state.rs | 12 +- docs/mcp.md | 31 +- docs/target-app-parity.md | 1 + 34 files changed, 658 insertions(+), 35 deletions(-) diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index 342feb568c..77aa3d1129 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -1431,13 +1431,13 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.action.record", "Begin Recording", "", - "{set?: index} begin recording an automatically named Action N in the Actions panel's set at set (0 is the top set), by default the current set or the first set; with no sets, make Set 1. The set becomes current, and ui.action.stop adds the recorded action after that set's existing actions. An explicit invalid set, an existing recording, or a set where the new action would give it more than 1,000 actions or an action set file larger than 8 MiB is an error and changes nothing. While an action records, a recorded command whose step would give the action more than 10,000 steps or its set's action set file more than 8 MiB is an error and does not run, and the recording goes on. A recorded command whose step passes a limit only as it runs, from values it adds to its step, keeps its edit and result; the status bar names the limit, later recorded commands are an error and do not run, and ui.action.stop leaves its step out → {set, name}", + "{set?: index, action?: index, after?: index} begin recording an automatically named Action N in the Actions panel's set at set (0 is the top set), by default the current set or the first set; with no sets, make Set 1. The set becomes current, and ui.action.stop adds the recorded action after that set's existing actions. With action, record into the action at action in set instead: ui.action.stop puts the recorded steps after step after of that action, else after its last step, and the steps after them keep whether they are excluded. The panel's Begin Recording button passes the selected action and its selected step. action without set, after without action, an index past the last set, action or step, an explicit invalid set, an existing recording, or a set where the new action would give it more than 1,000 actions or an action set file larger than 8 MiB is an error and changes nothing. While an action records, a recorded command whose step would give the action more than 10,000 steps or its set's action set file more than 8 MiB is an error and does not run, and the recording goes on. A recorded command whose step passes a limit only as it runs, from values it adds to its step, keeps its edit and result; the status bar names the limit, later recorded commands are an error and do not run, and ui.action.stop leaves its step out → {set, name}, or with action {set, action, name}", ), ( "ui.action.stop", "Stop Playing/Recording", "", - "{} end the Actions panel's recording and keep its steps, in the action ui.action.new made for it, else in a new action after the last action of its set, as the panel's Stop Playing/Recording button does. It also ends an action playing step by step or with pauses, or one waiting at a stop, whose message box closes: the steps played stay and the rest do not run. Recorded steps still past 10,000 in the action, or past 8 MiB in its set's action set file, are left out, and the status bar names the limit. With nothing recording or playing it changes nothing → {stopped}", + "{} end the Actions panel's recording and keep its steps, in the action ui.action.new made for it or the action ui.action.record named, at the place it named, else in a new action after the last action of its set, as the panel's Stop Playing/Recording button does. It also ends an action playing step by step or with pauses, or one waiting at a stop, whose message box closes: the steps played stay and the rest do not run. Recorded steps still past 10,000 in the action, or past 8 MiB in its set's action set file, are left out, and the status bar names the limit. With nothing recording or playing it changes nothing → {stopped}", ), ( "ui.action.insertStop", @@ -1475,6 +1475,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{set: index, action: index, step: index} delete one step, a stop or a command, of the Actions panel's action at action in set (0 is the top set, action or step). The steps after it keep whether they are excluded, and no step of that action stays selected. A Record Stop dialog open on that step closes, and one open on a later step of the action follows it up. A missing or invalid index is an error and changes nothing. Undo does not bring the step back. The panel's Delete Selection runs this for the selected step → {set, action, step}", ), + ( + "ui.action.moveStep", + "Move Action Step", + "", + "{set: index, action: index, step: index, toSet?: index, toAction?: index, before: index} move one step, a stop or a command, of the Actions panel's action at action in set (0 is the top set, action or step) into the action at toAction in toSet (default: the same set and action), before the step at index before there, or after its last step when before is its number of steps; before counts the steps as they are before the move. The step keeps its params and whether it is excluded, and the other steps keep theirs. A selected step stays selected, and a moved selected step makes its new action the selected one and its set the current one. A Record Stop dialog open on a step follows the step, and the steps that the recording in progress adds to either action stay after the steps that were before them. A step put back just before or just after itself does not move. A missing or invalid index, or a move that would give the action more than 10,000 steps or its set's file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an error and changes nothing. Undo does not move the step back. Dragging a step row in the panel runs this → {set, action, step, moved}", + ), ( "ui.batchDialog", "Batch…", diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 3828d9ba2d..0b23a2959a 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5989,6 +5989,7 @@ axis Y Axis Y ос Continue Продължаване Continue Action Продължаване на действието Delete Action Step Изтриване на стъпка от действието + Move Action Step Преместване на стъпка от действието Press Play Current Selection in the Actions panel to go on. За да продължите, натиснете „Изпълнение на текущия избор“ в панела „Действия“. Stop: {message} Спиране: {message} @msg no action is waiting at a stop няма действие, което чака при спиране diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 81fee3a079..789c3db469 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6031,6 +6031,7 @@ axis Y Axis Y osa Continue Nastavi Continue Action Nastavi akciju Delete Action Step Izbriši korak akcije + Move Action Step Premjesti korak akcije Press Play Current Selection in the Actions panel to go on. Za nastavak pritisnite „Reprodukuj trenutni odabir“ na panelu Akcije. Stop: {message} Zaustavljanje: {message} @msg no action is waiting at a stop nijedna akcija ne čeka na zaustavljanju diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 02ae0f4a09..27a9de6073 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -4043,6 +4043,7 @@ digits Malayalam Malaiàlam Continue Continua Continue Action Continua l’acció Delete Action Step Suprimeix el pas de l’acció + Move Action Step Mou el pas de l’acció Press Play Current Selection in the Actions panel to go on. Per continuar, premeu «Reprodueix la selecció actual» al tauler Accions. Stop: {message} Aturada: {message} @msg no action is waiting at a stop cap acció no espera en una aturada diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 7e3794e8a1..fd6cd6efd9 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6524,6 +6524,7 @@ axis Y Axis osa Y Continue Pokračovat Continue Action Pokračovat v akci Delete Action Step Odstranit krok akce + Move Action Step Přesunout krok akce Press Play Current Selection in the Actions panel to go on. Chcete-li pokračovat, stiskněte na panelu Akce tlačítko Přehrát aktuální výběr. Stop: {message} Zastavení: {message} @msg no action is waiting at a stop žádná akce nečeká na zastavení diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index f7011e23bf..7482a45ae2 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6006,6 +6006,7 @@ axis Y Axis Y-akse Continue Fortsæt Continue Action Fortsæt handling Delete Action Step Slet handlingstrin + Move Action Step Flyt handlingstrin Press Play Current Selection in the Actions panel to go on. Tryk på Afspil aktuel markering i panelet Handlinger for at fortsætte. Stop: {message} Stop: {message} @msg no action is waiting at a stop ingen handling venter ved et stop diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 4174537f87..4057dd11f3 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -6115,6 +6115,7 @@ axis Y Axis Y-Achse Continue Fortsetzen Continue Action Aktion fortsetzen Delete Action Step Aktionsschritt löschen + Move Action Step Aktionsschritt verschieben Press Play Current Selection in the Actions panel to go on. Klicken Sie im Bedienfeld „Aktionen“ auf „Aktuelle Auswahl abspielen“, um fortzufahren. Stop: {message} Stopp: {message} @msg no action is waiting at a stop keine Aktion wartet an einem Stopp diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index bde2fbee78..60b4882536 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6475,6 +6475,7 @@ axis Y Axis Άξονας Y Continue Συνέχεια Continue Action Συνέχεια ενέργειας Delete Action Step Διαγραφή βήματος ενέργειας + Move Action Step Μετακίνηση βήματος ενέργειας Press Play Current Selection in the Actions panel to go on. Για να συνεχίσετε, πατήστε «Αναπαραγωγή τρέχουσας επιλογής» στο πάνελ Ενέργειες. Stop: {message} Διακοπή: {message} @msg no action is waiting at a stop καμία ενέργεια δεν περιμένει σε διακοπή diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index ff28d9dfc5..7dbec4a486 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6466,6 +6466,7 @@ axis Y Axis Eje Y Continue Continuar Continue Action Continuar acción Delete Action Step Eliminar paso de la acción + Move Action Step Mover paso de la acción Press Play Current Selection in the Actions panel to go on. Para continuar, pulse Reproducir la selección actual en el panel Acciones. Stop: {message} Parada: {message} @msg no action is waiting at a stop ninguna acción está esperando en una parada diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index bb74188ed8..7c369e8bb1 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6554,6 +6554,7 @@ axis Y Axis Y-akseli Continue Jatka Continue Action Jatka toimintoa Delete Action Step Poista toiminnon vaihe + Move Action Step Siirrä toiminnon vaihe Press Play Current Selection in the Actions panel to go on. Jatka valitsemalla Toiminnot-paneelista Toista nykyinen valinta. Stop: {message} Pysäytys: {message} @msg no action is waiting at a stop mikään toiminto ei odota pysäytyksessä diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 09c9c35b0d..1a0e50ec2b 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6519,6 +6519,7 @@ axis Y Axis Axe Y Continue Continuer Continue Action Continuer le script Delete Action Step Supprimer l’étape du script + Move Action Step Déplacer l’étape du script Press Play Current Selection in the Actions panel to go on. Pour continuer, cliquez sur Exécuter la sélection actuelle dans le panneau Scripts. Stop: {message} Arrêt : {message} @msg no action is waiting at a stop aucun script n’attend à un arrêt diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 9aa4bd7b99..470e3a4a79 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3472,6 +3472,7 @@ antiAlias Sharp शार्प Continue जारी रखें Continue Action एक्शन जारी रखें Delete Action Step एक्शन का चरण हटाएं + Move Action Step एक्शन का चरण खिसकाएं Press Play Current Selection in the Actions panel to go on. आगे बढ़ने के लिए एक्शन्स पैनल में वर्तमान सिलेक्शन प्ले करें दबाएँ। Stop: {message} स्टॉप: {message} @msg no action is waiting at a stop कोई एक्शन स्टॉप पर प्रतीक्षा नहीं कर रहा है diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index af6f586880..10df15679a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6507,6 +6507,7 @@ axis Y Axis Y tengely Continue Folytatás Continue Action Művelet folytatása Delete Action Step Műveletlépés törlése + Move Action Step Műveletlépés áthelyezése Press Play Current Selection in the Actions panel to go on. A folytatáshoz nyomja meg a Műveletek panelen a Kijelölt művelet lejátszása gombot. Stop: {message} Megállás: {message} @msg no action is waiting at a stop egyetlen művelet sem várakozik megállásnál diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index e0ba31ecd5..9955c11132 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6006,6 +6006,7 @@ axis Y Axis Sumbu Y Continue Lanjutkan Continue Action Lanjutkan tindakan Delete Action Step Hapus langkah tindakan + Move Action Step Pindahkan langkah tindakan Press Play Current Selection in the Actions panel to go on. Untuk melanjutkan, tekan Putar seleksi saat ini di panel Tindakan. Stop: {message} Henti: {message} @msg no action is waiting at a stop tidak ada tindakan yang menunggu di titik henti diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index ce0bc58ce5..00a6cc2f30 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -6149,6 +6149,7 @@ axis Y Axis Asse Y Continue Continua Continue Action Continua azione Delete Action Step Elimina passaggio dell’azione + Move Action Step Sposta passaggio dell’azione Press Play Current Selection in the Actions panel to go on. Per continuare, premi Esegui selezione corrente nel pannello Azioni. Stop: {message} Interruzione: {message} @msg no action is waiting at a stop nessuna azione è in attesa a un'interruzione diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1ecce4618a..791842b703 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6214,6 +6214,7 @@ axis Y Axis Y 軸 Continue 続行 Continue Action アクションを続行 Delete Action Step アクションステップを削除 + Move Action Step アクションステップを移動 Press Play Current Selection in the Actions panel to go on. 続行するには、アクションパネルの「選択項目を再生」を押します。 Stop: {message} 停止: {message} @msg no action is waiting at a stop 停止で待機中のアクションはありません diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 3d6425c373..214c6deef9 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6475,6 +6475,7 @@ axis Y Axis Y ღერძი Continue გაგრძელება Continue Action მოქმედების გაგრძელება Delete Action Step მოქმედების ნაბიჯის წაშლა + Move Action Step მოქმედების ნაბიჯის გადატანა Press Play Current Selection in the Actions panel to go on. გასაგრძელებლად მოქმედებების პანელზე დააჭირეთ „მიმდინარე მონიშნულის შესრულებას“. Stop: {message} გაჩერება: {message} @msg no action is waiting at a stop გაჩერებაზე არცერთი მოქმედება არ ელოდება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index fb87488d64..734479ba0d 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -6110,6 +6110,7 @@ axis Y Axis Y축 Continue 계속 Continue Action 액션 계속 Delete Action Step 액션 단계 삭제 + Move Action Step 액션 단계 이동 Press Play Current Selection in the Actions panel to go on. 계속하려면 액션 패널에서 현재 선택 항목 재생을 누르세요. Stop: {message} 중지: {message} @msg no action is waiting at a stop 중지에서 대기 중인 액션이 없습니다 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index b3388fa82a..ab497a4ddd 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6057,6 +6057,7 @@ axis Y Axis Y-as Continue Doorgaan Continue Action Actie voortzetten Delete Action Step Actiestap verwijderen + Move Action Step Actiestap verplaatsen Press Play Current Selection in the Actions panel to go on. Druk in het paneel Acties op Huidige selectie afspelen om door te gaan. Stop: {message} Stop: {message} @msg no action is waiting at a stop geen actie wacht bij een stop diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 0729e35c79..e7e07faf00 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6378,6 +6378,7 @@ axis Y Axis Oś Y Continue Kontynuuj Continue Action Kontynuuj operację Delete Action Step Usuń krok operacji + Move Action Step Przenieś krok operacji Press Play Current Selection in the Actions panel to go on. Aby kontynuować, naciśnij Odtwórz bieżące zaznaczenie w panelu Operacje. Stop: {message} Zatrzymanie: {message} @msg no action is waiting at a stop żadna operacja nie czeka na zatrzymaniu diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 36f778dbd5..500d079586 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6439,6 +6439,7 @@ axis Y Axis Eixo Y Continue Continuar Continue Action Continuar Ação Delete Action Step Excluir Etapa da Ação + Move Action Step Mover Etapa da Ação Press Play Current Selection in the Actions panel to go on. Para continuar, pressione Reproduzir Seleção Atual no painel Ações. Stop: {message} Parada: {message} @msg no action is waiting at a stop nenhuma ação está aguardando em uma parada diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 442a9612d4..12707d0219 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6135,6 +6135,7 @@ axis Y Axis Ось Y Continue Продолжить Continue Action Продолжить действие Delete Action Step Удалить шаг действия + Move Action Step Переместить шаг действия Press Play Current Selection in the Actions panel to go on. Чтобы продолжить, нажмите «Воспроизвести текущее выделение» на панели «Действия». Stop: {message} Остановка: {message} @msg no action is waiting at a stop ни одно действие не ожидает на остановке diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 8a8f8453b6..f25f4f19da 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6505,6 +6505,7 @@ axis Y Axis Y-axel Continue Fortsätt Continue Action Fortsätt åtgärd Delete Action Step Radera åtgärdssteg + Move Action Step Flytta åtgärdssteg Press Play Current Selection in the Actions panel to go on. Fortsätt genom att trycka på Spela upp aktuell markering i panelen Åtgärder. Stop: {message} Stopp: {message} @msg no action is waiting at a stop ingen åtgärd väntar vid ett stopp diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index ef8773c31b..0e93f26549 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6480,6 +6480,7 @@ axis Y Axis Y ekseni Continue Devam Et Continue Action Eyleme Devam Et Delete Action Step Eylem Adımını Sil + Move Action Step Eylem Adımını Taşı Press Play Current Selection in the Actions panel to go on. Devam etmek için Eylemler panelinde Geçerli Seçimi Oynat'a basın. Stop: {message} Durak: {message} @msg no action is waiting at a stop durakta bekleyen eylem yok diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0bccbd8cb6..c8bf43bfef 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6129,6 +6129,7 @@ axis Y Axis Вісь Y Continue Продовжити Continue Action Продовжити дію Delete Action Step Видалити крок дії + Move Action Step Перемістити крок дії Press Play Current Selection in the Actions panel to go on. Щоб продовжити, натисніть «Відтворити поточне виділення» на панелі «Дії». Stop: {message} Зупинка: {message} @msg no action is waiting at a stop жодна дія не очікує на зупинці diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 4f084029db..e1e79b8eb0 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6130,6 +6130,7 @@ axis Y Axis Trục Y Continue Tiếp tục Continue Action Tiếp tục hành động Delete Action Step Xóa bước hành động + Move Action Step Di chuyển bước hành động Press Play Current Selection in the Actions panel to go on. Để tiếp tục, nhấn Phát vùng chọn hiện tại trong bảng Hành động. Stop: {message} Điểm dừng: {message} @msg no action is waiting at a stop không có hành động nào đang chờ ở điểm dừng diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b04e65a65b..464a97c565 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6362,6 +6362,7 @@ axis Y Axis Y 轴 Continue 继续 Continue Action 继续动作 Delete Action Step 删除动作步骤 + Move Action Step 移动动作步骤 Press Play Current Selection in the Actions panel to go on. 要继续,请按“动作”面板中的“播放当前选择项目”。 Stop: {message} 停止:{message} @msg no action is waiting at a stop 没有动作在停止处等待 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index be62fde8a7..9efc886c80 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -6167,6 +6167,7 @@ axis Y Axis Y 軸 Continue 繼續 Continue Action 繼續動作 Delete Action Step 刪除動作步驟 + Move Action Step 移動動作步驟 Press Play Current Selection in the Actions panel to go on. 若要繼續,請按「動作」面板中的「播放目前選取項目」。 Stop: {message} 停止:{message} @msg no action is waiting at a stop 沒有動作在停止處等待 diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs index efa4e95ac4..fa6b17a61b 100644 --- a/crates/ui-egui/src/menus/run.rs +++ b/crates/ui-egui/src/menus/run.rs @@ -989,6 +989,7 @@ fn run_ui_part11(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option crate::panels::actions::insert_select_object(app, p), crate::panels::actions::CONTINUE_PLAYBACK => crate::panels::actions::continue_playback(app, p), crate::panels::actions::DELETE_STEP => crate::panels::actions::delete_step(app, p), + crate::panels::actions::MOVE_STEP => crate::panels::actions::move_step(app, p), "ui.batchDialog" => { let index = |k: &str| opt_index(p, k).map(|i| i.unwrap_or(0)); index("set").and_then(|set| Ok((set, index("action")?))).and_then(|(set, action)| crate::dialogs::action_batch::open(app, set, action)) diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 20a2c58314..d252f187e4 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -1432,13 +1432,13 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ crate::panels::actions::START_RECORDING, "Begin Recording", "", - "{set?: index} begin recording an automatically named Action N in the Actions panel's set at set (0 is the top set), by default the current set or the first set; with no sets, make Set 1. The set becomes current, and ui.action.stop adds the recorded action after that set's existing actions. An explicit invalid set, an existing recording, or a set where the new action would give it more than 1,000 actions or an action set file larger than 8 MiB is an error and changes nothing. While an action records, a recorded command whose step would give the action more than 10,000 steps or its set's action set file more than 8 MiB is an error and does not run, and the recording goes on. A recorded command whose step passes a limit only as it runs, from values it adds to its step, keeps its edit and result; the status bar names the limit, later recorded commands are an error and do not run, and ui.action.stop leaves its step out → {set, name}", + "{set?: index, action?: index, after?: index} begin recording an automatically named Action N in the Actions panel's set at set (0 is the top set), by default the current set or the first set; with no sets, make Set 1. The set becomes current, and ui.action.stop adds the recorded action after that set's existing actions. With action, record into the action at action in set instead: ui.action.stop puts the recorded steps after step after of that action, else after its last step, and the steps after them keep whether they are excluded. The panel's Begin Recording button passes the selected action and its selected step. action without set, after without action, an index past the last set, action or step, an explicit invalid set, an existing recording, or a set where the new action would give it more than 1,000 actions or an action set file larger than 8 MiB is an error and changes nothing. While an action records, a recorded command whose step would give the action more than 10,000 steps or its set's action set file more than 8 MiB is an error and does not run, and the recording goes on. A recorded command whose step passes a limit only as it runs, from values it adds to its step, keeps its edit and result; the status bar names the limit, later recorded commands are an error and do not run, and ui.action.stop leaves its step out → {set, name}, or with action {set, action, name}", ), ( crate::panels::actions::STOP_RECORDING, "Stop Playing/Recording", "", - "{} end the Actions panel's recording and keep its steps, in the action ui.action.new made for it, else in a new action after the last action of its set, as the panel's Stop Playing/Recording button does. It also ends an action playing step by step or with pauses, or one waiting at a stop, whose message box closes: the steps played stay and the rest do not run. Recorded steps still past 10,000 in the action, or past 8 MiB in its set's action set file, are left out, and the status bar names the limit. With nothing recording or playing it changes nothing → {stopped}", + "{} end the Actions panel's recording and keep its steps, in the action ui.action.new made for it or the action ui.action.record named, at the place it named, else in a new action after the last action of its set, as the panel's Stop Playing/Recording button does. It also ends an action playing step by step or with pauses, or one waiting at a stop, whose message box closes: the steps played stay and the rest do not run. Recorded steps still past 10,000 in the action, or past 8 MiB in its set's action set file, are left out, and the status bar names the limit. With nothing recording or playing it changes nothing → {stopped}", ), ( crate::panels::actions::INSERT_STOP, @@ -1476,6 +1476,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{set: index, action: index, step: index} delete one step, a stop or a command, of the Actions panel's action at action in set (0 is the top set, action or step). The steps after it keep whether they are excluded, and no step of that action stays selected. A Record Stop dialog open on that step closes, and one open on a later step of the action follows it up. A missing or invalid index is an error and changes nothing. Undo does not bring the step back. The panel's Delete Selection runs this for the selected step → {set, action, step}", ), + ( + crate::panels::actions::MOVE_STEP, + "Move Action Step", + "", + "{set: index, action: index, step: index, toSet?: index, toAction?: index, before: index} move one step, a stop or a command, of the Actions panel's action at action in set (0 is the top set, action or step) into the action at toAction in toSet (default: the same set and action), before the step at index before there, or after its last step when before is its number of steps; before counts the steps as they are before the move. The step keeps its params and whether it is excluded, and the other steps keep theirs. A selected step stays selected, and a moved selected step makes its new action the selected one and its set the current one. A Record Stop dialog open on a step follows the step, and the steps that the recording in progress adds to either action stay after the steps that were before them. A step put back just before or just after itself does not move. A missing or invalid index, or a move that would give the action more than 10,000 steps or its set's file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an error and changes nothing. Undo does not move the step back. Dragging a step row in the panel runs this → {set, action, step, moved}", + ), ( "ui.batchDialog", "Batch…", diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 8196ad27c2..361f7c42ba 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -847,16 +847,27 @@ pub const STOP_RECORDING: &str = "ui.action.stop"; /// The command that begins a plain recording ([`record`]). pub const START_RECORDING: &str = "ui.action.record"; -/// `ui.action.record {set?}`: begins recording an automatically named Action N in `set`, or in -/// the current set (the first if none is current). With no sets, makes Set 1. Stop adds the action -/// after the set's existing actions. An explicit invalid set, an existing recording, or a set whose -/// new action would pass a limit of Load Actions… ([`edit_keeps_set_loadable`]: 1,000 actions, -/// 8 MiB) is an error and leaves the panel unchanged. While it records, a command whose step does not fit in the action -/// ([`recording_room`]) does not run. → {set, name} +/// `ui.action.record {set?, action?, after?}`: begins recording an automatically named Action N in +/// `set`, or in the current set (the first if none is current). With no sets, makes Set 1. Stop +/// adds the action after the set's existing actions. With `action`, it records into the action at +/// that index of set `set` instead: Stop puts the recorded steps after step `after` of that action, +/// else after its last step, and the steps after them keep whether they are excluded. The Begin +/// Recording button passes the selected action and step. `action` without `set`, `after` without +/// `action`, an index past the last set, action or step, an explicit invalid set, an existing +/// recording, or a set whose new action would pass a limit of Load Actions… +/// ([`edit_keeps_set_loadable`]: 1,000 actions, 8 MiB) is an error and leaves the panel unchanged. +/// While it records, a command whose step does not fit in the action ([`recording_room`]) does not +/// run. → {set, name}, and with `action` {set, action, name} pub fn record(app: &mut VectorcraftApp, p: &Value) -> Result { if app.ui.recording.is_some() { return Err("an action is already recording".into()); } + if p.get("action").is_some() { + return record_into(app, p); + } + if p.get("after").is_some() { + return Err("no such action".into()); + } let set = match p.get("set") { Some(v) => v.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|&i| i < app.ui.action_sets.len()).ok_or("no such action set")?, _ => { @@ -884,8 +895,32 @@ pub fn record(app: &mut VectorcraftApp, p: &Value) -> Result { Ok(json!({ "set": set, "name": name })) } +/// [`record`] with `action`: records into the action at index `action` of set `set`, after step +/// `after` or after its last step. The set becomes current and the only selected set, and the +/// action stays selected. → {set, action, name} +fn record_into(app: &mut VectorcraftApp, p: &Value) -> Result { + let index = |k: &str| p.get(k).and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()); + let (set, at) = + index("set").zip(index("action")).filter(|&(s, a)| app.ui.action_sets.get(s).is_some_and(|s| a < s.actions.len())).ok_or("no such action")?; + let action = &app.ui.action_sets[set].actions[at]; + let slot = match p.get("after") { + None => None, + Some(_) => Some(index("after").filter(|&s| s < action.steps.len()).ok_or("no such action step")? + 1), + }; + let (id, name) = (action.id, action.name.clone()); + app.ui.action_set = Some(set); + app.ui.action_sets_selected = vec![set]; + start_recording(app, set, name.clone(), Some(id)); + app.ui.recording_at = slot; + if let Some(ctx) = app.ui_ctx.clone() { + set_pstate::>(&ctx, "actions-selected", Some((set, at))); + } + Ok(json!({ "set": set, "action": at, "name": name })) +} + /// `ui.action.stop {}`: ends the recording in progress and keeps its steps, in the action New -/// Action made for it, else in a new action after the last action of its set, as the bottom bar's +/// Action made for it or the action [`record`] named, at the place it named, else in a new action +/// after the last action of its set, as the bottom bar's /// Stop Playing/Recording does. It also ends a playback at Step by Step or Pause For, or one that /// waits at a stop, whose message box closes: the steps already played stay, and the rest do not /// run. With nothing recording or playing it changes nothing. Recorded steps past the limits of an @@ -904,6 +939,10 @@ pub const INSERT_STOP: &str = "ui.action.insertStop"; /// The panel state that holds the selected step as (the action's [`Action::id`], the step's index). const SELECTED_STEP: &str = "actions-selected-step"; +/// The panel state that holds the step being dragged as (the action's [`Action::id`], the step's +/// index). +const DRAGGED_STEP: &str = "actions-dragged-step"; + /// `ui.action.insertStop {set?, action?, step?, message?, allowContinue?}`: inserts a stop. With `set` /// and `action`, it goes into that action, after step `step`, else after its last step. Without /// them, it goes into the recording in progress, after the steps recorded so far. `message` is @@ -1030,9 +1069,9 @@ fn recording_start(app: &VectorcraftApp) -> Option { } /// Where Stop Playing/Recording puts the steps of the recording in progress: the index of the set, -/// and of the action New Action made for it, or None for the new action Stop adds after the set's -/// last action. None when there is no recording, no set to put them in, or the action New Action -/// made for it was deleted, so that Stop keeps none of them. +/// and of the action it records into ([`crate::UiState::recording_into`]), or None for the new +/// action Stop adds after the set's last action. None when there is no recording, no set to put +/// them in, or the action it records into was deleted, so that Stop keeps none of them. fn recording_target(app: &VectorcraftApp) -> Option<(usize, Option)> { let (set, ..) = app.ui.recording.as_ref()?; let Some(id) = app.ui.recording_into else { @@ -1054,7 +1093,7 @@ fn set_with_recorded(app: &VectorcraftApp, steps: Vec<(String, Value)>) -> Optio s.actions.len() - 1 } }; - extend_recorded(s.actions.get_mut(at)?, steps); + put_recorded(s.actions.get_mut(at)?, steps, app.ui.recording_at); Some((s, at)) } @@ -1091,7 +1130,7 @@ fn set_sizes(app: &VectorcraftApp, at: Option, set: &ActionSet) -> [usize let mut s = set.clone(); let steps = app.session.journal_for_action(start); match app.ui.recording_into.and_then(|id| s.actions.iter().position(|a| a.id == id)) { - Some(i) => extend_recorded(&mut s.actions[i], steps), + Some(i) => put_recorded(&mut s.actions[i], steps, app.ui.recording_at), None => s.actions.push(Action::new(name, steps)), } with = s; @@ -1286,12 +1325,17 @@ fn insert(app: &mut VectorcraftApp, p: &Value, step: (String, Value)) -> Result< let excluded = action.excluded_steps.clone(); action.insert_step(after, step); let id = action.id; + // The recording's steps stay after the steps that were before them. + let slot = match (&recorded, app.ui.recording_at) { + (Some(r), Some(slot)) if r.into == Some(at) && after <= slot => Some(slot + 1), + (_, slot) => slot, + }; let file_len = match recorded { None => files::write_set(s).len(), Some(Recorded { into, name, steps }) => { let mut kept = s.clone(); match into { - Some(into) => kept.actions[into].steps.extend(steps), + Some(into) => put_recorded(&mut kept.actions[into], steps, slot), None => kept.actions.push(Action::new(name, steps)), } files::write_set(&kept).len() @@ -1303,6 +1347,7 @@ fn insert(app: &mut VectorcraftApp, p: &Value, step: (String, Value)) -> Result< action.excluded_steps = excluded; return Err("the action set would be larger than 8 MiB".into()); } + app.ui.recording_at = slot; app.ui.action_set = Some(set); app.ui.action_sets_selected = vec![set]; if let Some(ctx) = app.ui_ctx.clone() { @@ -1342,6 +1387,12 @@ pub fn delete_step(app: &mut VectorcraftApp, p: &Value) -> Result let step = index("step").filter(|&s| s < action.steps.len()).ok_or("no such action step")?; action.remove_step(step); let id = action.id; + // The recording's steps stay after the steps that were before them. + if app.ui.recording_into == Some(id) + && let Some(slot) = app.ui.recording_at.filter(|&slot| step < slot) + { + app.ui.recording_at = Some(slot - 1); + } if let Some(ctx) = app.ui_ctx.clone() && pstate::>(&ctx, SELECTED_STEP).is_some_and(|(selected, _)| selected == id) { @@ -1364,6 +1415,115 @@ pub fn delete_step(app: &mut VectorcraftApp, p: &Value) -> Result Ok(json!({"set": set, "action": at, "step": step})) } +/// The command that moves one step of an action ([`move_step`]). +pub const MOVE_STEP: &str = "ui.action.moveStep"; + +/// `ui.action.moveStep {set, action, step, toSet?, toAction?, before}`: moves step `step`, a stop or +/// a command, of the action at index `action` of set `set` into the action at index `toAction` of +/// set `toSet` (default: the same set and action), before the step at index `before` there, or +/// after its last step when `before` is its number of steps. `before` counts the steps as they are +/// before the move. The step keeps its params and whether it is excluded, and the other steps keep +/// theirs. A selected step stays selected: a moved selected step makes its new action the selected +/// one and its set the current one. A Record Stop, Insert Menu Item or Set Selection dialog open on +/// a step follows the step, and the steps a recording adds to either action stay after the steps +/// that were before them. A step put back just before or just after itself does not move. A missing +/// or invalid index, or a move that would give a set a file that Load Actions… refuses +/// ([`edit_keeps_set_loadable`]: 10,000 steps in an action, 8 MiB), is an error, and then nothing +/// changes. Undo does not move the step back. A drag of a step row in the panel runs this. +/// → {set, action, step, moved}: where the step is now +pub fn move_step(app: &mut VectorcraftApp, p: &Value) -> Result { + let number = |v: &Value| v.as_u64().and_then(|i| usize::try_from(i).ok()); + let index = |k: &str| p.get(k).and_then(number); + let (set, at) = index("set").zip(index("action")).ok_or("no such action")?; + let source = app.ui.action_sets.get(set).and_then(|s| s.actions.get(at)).ok_or("no such action")?; + let step = index("step").filter(|&s| s < source.steps.len()).ok_or("no such action step")?; + let source_id = source.id; + let to_set = match p.get("toSet") { + None => set, + Some(v) => number(v).filter(|&s| s < app.ui.action_sets.len()).ok_or("no such action set")?, + }; + let to_action = match p.get("toAction") { + None => at, + Some(v) => number(v).ok_or("no such action")?, + }; + let dest = app.ui.action_sets[to_set].actions.get(to_action).ok_or("no such action")?; + let dest_id = dest.id; + let before = index("before").filter(|&b| b <= dest.steps.len()).ok_or("no such action step")?; + let same = (set, at) == (to_set, to_action); + if same && (before == step || before == step + 1) { + return Ok(json!({"set": set, "action": at, "step": step, "moved": false})); + } + // The step's index once it is in place, counted without it in its own action. + let to = if same && before > step { before - 1 } else { before }; + let mut from_edit = app.ui.action_sets[set].clone(); + let moved = from_edit.actions[at].steps[step].clone(); + let excluded = from_edit.actions[at].excluded_steps.contains(&step); + from_edit.actions[at].remove_step(step); + let put = |a: &mut Action| { + a.insert_step(to, moved); + if excluded { + a.excluded_steps.insert(to); + } + }; + let to_edit = if to_set == set { + put(&mut from_edit.actions[to_action]); + None + } else { + let mut edit = app.ui.action_sets[to_set].clone(); + put(&mut edit.actions[to_action]); + Some(edit) + }; + edit_keeps_set_loadable(app, Some(set), &from_edit)?; + if let Some(edit) = &to_edit { + edit_keeps_set_loadable(app, Some(to_set), edit)?; + } + app.ui.action_sets[set] = from_edit; + if let Some(edit) = to_edit { + app.ui.action_sets[to_set] = edit; + } + // A step index in `source_id` or `dest_id` as (the action's id, the step) after the move. + let follow = |(id, s): (u64, usize)| -> (u64, usize) { + if (id, s) == (source_id, step) { + return (dest_id, to); + } + let s = if id == source_id && s > step { s - 1 } else { s }; + (id, if id == dest_id && s >= to { s + 1 } else { s }) + }; + // The recording's steps stay after the steps that were before them. + if let Some(slot) = app.ui.recording_at { + let slot = if app.ui.recording_into == Some(source_id) && step < slot { slot - 1 } else { slot }; + app.ui.recording_at = Some(if app.ui.recording_into == Some(dest_id) && to <= slot { slot + 1 } else { slot }); + } + if let Some(ctx) = app.ui_ctx.clone() + && let Some(selected) = pstate::>(&ctx, SELECTED_STEP) + { + set_pstate::>(&ctx, SELECTED_STEP, Some(follow(selected))); + if selected == (source_id, step) { + set_pstate::>(&ctx, "actions-selected", Some((to_set, to_action))); + let mut expanded: BTreeSet = pstate(&ctx, "actions-expanded"); + expanded.insert(dest_id); + set_pstate(&ctx, "actions-expanded", expanded); + app.ui.action_set = Some(to_set); + app.ui.action_sets_selected = vec![to_set]; + } + } + // A Record Stop, Insert Menu Item or Set Selection dialog open on a step of either action follows it. + let either = |s: usize, a: usize| (s, a) == (set, at) || (s, a) == (to_set, to_action); + if let Some(d) = app.ui.dialog.as_mut().filter(|d| holds_step(d)) + && let (Some(ds), Some(da), Some(ds_step)) = + (d.fields.get("set").and_then(number), d.fields.get("action").and_then(number), d.fields.get("step").and_then(number)) + && either(ds, da) + { + let id = if (ds, da) == (set, at) { source_id } else { dest_id }; + let (id, s) = follow((id, ds_step)); + let (ds, da) = if id == dest_id { (to_set, to_action) } else { (set, at) }; + d.fields.insert("set".into(), json!(ds)); + d.fields.insert("action".into(), json!(da)); + d.fields.insert("step".into(), json!(s)); + } + Ok(json!({"set": to_set, "action": to_action, "step": to, "moved": true})) +} + /// The command that `chord` runs under `km`, pressed once or pressed twice. #[cfg(test)] fn command_on(km: &craft_keymap::Keymap, chord: &craft_keymap::Chord) -> Option { @@ -1398,6 +1558,7 @@ fn start_recording(app: &mut VectorcraftApp, set: usize, name: String, into: Opt app.session.journal.hold(Some(start)); app.ui.recording = Some((set, name, start)); app.ui.recording_into = into; + app.ui.recording_at = None; app.ui.recording_base = None; app.ui.recording_tail = None; } @@ -1424,12 +1585,15 @@ fn steps_that_fit(app: &VectorcraftApp, steps: &[(String, Value)]) -> (usize, Op (keep, (keep < steps.len()).then_some("the action already has 10,000 steps")) } -/// Adds the recorded `steps` after the steps of `action`, included in playback. An exclusion past -/// the action's last step, which saved preferences can hold, goes. -fn extend_recorded(action: &mut Action, steps: Vec<(String, Value)>) { +/// Puts the recorded `steps` into `action` before its step at index `at`, or after its last step +/// when `at` is None or past it, included in playback. The steps after them keep whether they are +/// excluded. An exclusion past the action's last step, which saved preferences can hold, goes. +fn put_recorded(action: &mut Action, steps: Vec<(String, Value)>, at: Option) { let len = action.steps.len(); - action.excluded_steps.retain(|&i| i < len); - action.steps.extend(steps); + let at = at.map_or(len, |at| at.min(len)); + let added = steps.len(); + action.excluded_steps = action.excluded_steps.iter().filter(|&&i| i < len).map(|&i| if i >= at { i + added } else { i }).collect(); + action.steps.splice(at..at, steps); } /// Stop Playing/Recording: ends the recording and keeps its steps (see [`start_recording`]). An @@ -1447,10 +1611,16 @@ fn stop_recording(app: &mut VectorcraftApp) { app.ui.recording_base = None; app.ui.recording_tail = None; app.session.journal.hold(None); + let at = app.ui.recording_at.take(); match app.ui.recording_into.take() { Some(id) => { - if let Some(a) = app.ui.action_sets.iter_mut().flat_map(|s| s.actions.iter_mut()).find(|a| a.id == id) { - extend_recorded(a, steps); + let found = app.ui.action_sets.iter().enumerate().find_map(|(s, set)| Some((s, set.actions.iter().position(|a| a.id == id)?))); + if let Some((s, a)) = found { + let action = &mut app.ui.action_sets[s].actions[a]; + let len = action.steps.len(); + let (from, added) = (at.map_or(len, |at| at.min(len)), steps.len()); + put_recorded(action, steps, at); + follow_recorded(app, (s, a, id), from, added); } } None => { @@ -1461,6 +1631,28 @@ fn stop_recording(app: &mut VectorcraftApp) { } } +/// After Stop Playing/Recording put `added` steps into the action at `place` (set, action, id) +/// before its step at index `from`: the selected step and a Record Stop, Insert Menu Item or Set +/// Selection dialog open on a later step of the action follow their step down. +fn follow_recorded(app: &mut VectorcraftApp, (set, at, id): (usize, usize, u64), from: usize, added: usize) { + if added == 0 { + return; + } + if let Some(ctx) = app.ui_ctx.clone() + && let Some((step_id, step)) = pstate::>(&ctx, SELECTED_STEP).filter(|&(step_id, step)| step_id == id && step >= from) + { + set_pstate::>(&ctx, SELECTED_STEP, Some((step_id, step + added))); + } + let number = |v: &Value| v.as_u64().and_then(|i| usize::try_from(i).ok()); + if let Some(d) = app.ui.dialog.as_mut().filter(|d| holds_step(d)) + && d.fields.get("set").and_then(number) == Some(set) + && d.fields.get("action").and_then(number) == Some(at) + && let Some(step) = d.fields.get("step").and_then(number).filter(|&step| step >= from) + { + d.fields.insert("step".into(), json!(step + added)); + } +} + crate::shortcut_editor::ui_mirror!(fn function_key_chords() -> Vec); /// Mirrors the actions' function keys for code without app access (cheap when unchanged). @@ -1598,6 +1790,7 @@ pub fn delete_sets(app: &mut VectorcraftApp, p: &Value) -> Result if sets.contains(rec) { app.ui.recording = None; app.ui.recording_into = None; + app.ui.recording_at = None; app.session.journal.hold(None); } else { *rec -= sets.iter().filter(|&&s| s < *rec).count(); @@ -1851,6 +2044,34 @@ fn inclusion_box( (response.on_hover_text(if included { tl!("Exclude from playback") } else { tl!("Include in playback") }).clicked(), hit) } +/// While a step is dragged over the list: where it goes as (set, action, `before` of [`MOVE_STEP`]), +/// shown by a line in `color` on the row under the pointer, or None off every action's rows +/// (`slots`, as [`show`] lists them). Near the list's top or bottom edge, the list scrolls. +fn drop_target(ui: &mut egui::Ui, slots: &[(egui::Rect, usize, usize, usize, usize)], color: egui::Color32) -> Option<(usize, usize, usize)> { + let p = ui.ctx().pointer_interact_pos()?; + let clip = ui.clip_rect(); + if !clip.contains(p) { + return None; + } + let edge = 12.0; + let scroll = if p.y < clip.top() + edge { + 6.0 + } else if p.y > clip.bottom() - edge { + -6.0 + } else { + 0.0 + }; + if scroll != 0.0 && ui.input(|i| i.pointer.any_down()) { + ui.scroll_with_delta(egui::vec2(0.0, scroll)); + ui.ctx().request_repaint(); + } + let half_gap = ui.spacing().item_spacing.y / 2.0; + let &(r, si, ai, upper, lower) = slots.iter().find(|(r, ..)| (r.top() - half_gap..r.bottom() + half_gap).contains(&p.y))?; + let (before, y) = if upper != lower && p.y < r.center().y { (upper, r.top()) } else { (lower, r.bottom()) }; + ui.painter().line_segment([egui::pos2(r.left() + 42.0, y), egui::pos2(r.right(), y)], egui::Stroke::new(2.0, color)); + Some((si, ai, before)) +} + enum InclusionChange { Set(usize), Action(usize, usize), @@ -1879,6 +2100,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { let mut expanded: BTreeSet = pstate(ui.ctx(), "actions-expanded"); let mut inclusion_changes = Vec::new(); let mut play_req = None; + // The step being dragged, as (its action's id, the step), from the frame its drag started. + let dragging: Option<(u64, usize)> = pstate(ui.ctx(), DRAGGED_STEP); + let mut drag_started = None; + let mut drag_stopped = false; + // The rows a dragged step can be dropped on: (row, set, action, where it goes on the row's upper + // half, where on its lower half), as `before` of [`MOVE_STEP`]. + let mut slots: Vec<(egui::Rect, usize, usize, usize, usize)> = Vec::new(); + let mut drop = None; let list_height = widgets::panel_list_height(ui, 260.0, 0.0).min(260.0); egui::ScrollArea::vertical().max_height(list_height).min_scrolled_height(list_height.min(64.0)).show(ui, |ui| { for (si, set) in app.ui.action_sets.iter().enumerate() { @@ -1964,9 +2193,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { if resp.double_clicked() && !resp.interact_pointer_pos().is_some_and(|p| check_rect.contains(p) || disclosure.contains(p)) { play_req = Some((si, ai)); } + // A step dropped on an action's row goes first when the action shows its steps, else + // after its last step. + let first = if open && !a.steps.is_empty() { 0 } else { a.steps.len() }; + slots.push((r, si, ai, first, first)); if open { for (step, (command, params)) in a.steps.iter().enumerate() { - let (r, resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 20.0), egui::Sense::click()); + let (r, resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 20.0), egui::Sense::click_and_drag()); + slots.push((r, si, ai, step, step + 1)); + if resp.drag_started() { + drag_started = Some((a.id, step)); + } + drag_stopped |= resp.drag_stopped(); if step_selected == Some((a.id, step)) { ui.painter().rect_filled(r, 0.0, t.row_selected); } else if resp.hovered() { @@ -2010,7 +2248,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { } } } + if dragging.is_some() { + drop = drop_target(ui, &slots, t.accent); + } }); + // A released drag moves the step once, to where the line showed. + let move_req = drag_stopped.then_some(dragging.zip(drop)).flatten().and_then(|((id, step), (si, ai, before))| { + let (fs, fa) = app.ui.action_sets.iter().enumerate().find_map(|(fs, s)| Some((fs, s.actions.iter().position(|a| a.id == id)?)))?; + Some(json!({"set": fs, "action": fa, "step": step, "toSet": si, "toAction": ai, "before": before})) + }); + let still_dragging = !drag_stopped && ui.input(|i| i.pointer.any_down()); + set_pstate(ui.ctx(), DRAGGED_STEP, drag_started.or(dragging.filter(|_| still_dragging))); if waiting_at_stop(app) { dim_label(ui, tl!("Press Play Current Selection in the Actions panel to go on.")); } @@ -2039,6 +2287,19 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { set_pstate(ui.ctx(), SELECTED_STEP, step_selected); app.ui.action_set = current; app.ui.action_sets_selected = sets_selected; + if let Some(params) = move_req + && let Err(e) = app.run(MOVE_STEP, params) + { + app.status(e); + } + // Begin Recording records into the selected action, after its selected step. + let record_params = match selected.and_then(|(s, a)| Some((s, a, app.ui.action_sets.get(s)?.actions.get(a)?.id))) { + Some((s, a, id)) => match step_selected.filter(|&(step_id, _)| step_id == id) { + Some((_, step)) => json!({"set": s, "action": a, "after": step}), + None => json!({"set": s, "action": a}), + }, + None => json!({}), + }; if app.ui.action_sets.is_empty() { dim_label(ui, tl!("No actions. Press ● to record.")); } @@ -2054,7 +2315,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { ui.painter().circle_filled(r.center(), 6.0, if recording { egui::Color32::from_rgb(0xe0, 0x30, 0x30) } else { t.icon }); if resp.on_hover_text(tl!("Begin Recording")).clicked() && !recording - && let Err(e) = app.run(START_RECORDING, json!({})) + && let Err(e) = app.run(START_RECORDING, record_params.clone()) { app.status(e); } @@ -5384,6 +5645,302 @@ mod tests { assert_eq!(open_kind(&app), None, "deleting its step closes it"); } + /// The steps of action `action` in set `set`: each command, with its `dx` when it has one. + fn step_names(app: &VectorcraftApp, set: usize, action: usize) -> Vec { + app.ui.action_sets[set].actions[action] + .steps + .iter() + .map(|(c, p)| match p.get("dx") { + Some(dx) => format!("{c} {dx}"), + None => c.clone(), + }) + .collect() + } + + /// The bottom bar's Stop and Begin Recording buttons, as drawn last. + fn stop_and_record(ctx: &egui::Context, app: &mut VectorcraftApp) -> (egui::Pos2, egui::Pos2) { + super::super::tests_appearance::frame_events(ctx, app, vec![], show); + let mut bar = crate::toolbar::tests::widget_rects(ctx, egui::vec2(24.0, 24.0)); + bar.sort_by(|a, b| a.left().total_cmp(&b.left())); + let [stop, record, ..] = bar[..] else { panic!("the bottom bar: {bar:?}") }; + (stop.center(), record.center()) + } + + #[test] + fn begin_recording_selected_action_adds_steps_without_new_action() { + use super::super::tests_appearance::{click, frame_events, text_rect}; + let (mut app, _) = app_with_three_moves(); + app.ui.action_set = Some(0); + let ctx = egui::Context::default(); + app.ui_ctx = Some(ctx.clone()); + let texts = frame_events(&ctx, &mut app, vec![], show); + click(&ctx, &mut app, text_rect(&texts, "Three Moves").center(), show); + let (stop, record) = stop_and_record(&ctx, &mut app); + click(&ctx, &mut app, record, show); + app.run("object.move", json!({"dx": 8, "dy": 0})).unwrap(); + click(&ctx, &mut app, stop, show); + assert_eq!(app.ui.action_sets[0].actions.len(), 1, "no new action"); + assert_eq!(step_names(&app, 0, 0), ["object.move 1", "object.move 2", "object.move 4", "object.move 8"], "added at the end"); + + // With a step selected, the recorded steps go after it. + click_first_step(&ctx, &mut app); + // The steps shown move the bottom bar down. + let (stop, record) = stop_and_record(&ctx, &mut app); + click(&ctx, &mut app, record, show); + app.run("object.move", json!({"dx": 16, "dy": 0})).unwrap(); + click(&ctx, &mut app, stop, show); + assert_eq!(step_names(&app, 0, 0), ["object.move 1", "object.move 16", "object.move 2", "object.move 4", "object.move 8"]); + + // With no action selected, Begin Recording still adds a new action. + let texts = frame_events(&ctx, &mut app, vec![], show); + click(&ctx, &mut app, text_rect(&texts, "▾ Moves").center(), show); + let (stop, record) = stop_and_record(&ctx, &mut app); + click(&ctx, &mut app, record, show); + app.run("object.move", json!({"dx": 32, "dy": 0})).unwrap(); + click(&ctx, &mut app, stop, show); + let names: Vec<&str> = app.ui.action_sets[0].actions.iter().map(|a| a.name.as_str()).collect(); + assert_eq!(names, ["Three Moves", "Action 2"]); + assert_eq!(step_names(&app, 0, 1), ["object.move 32"]); + } + + #[test] + fn begin_recording_after_selected_step_preserves_order_and_exclusions() { + let (mut app, _) = app_with_three_moves(); + app.ui.action_sets[0].actions[0].excluded_steps = BTreeSet::from([0, 2]); + for (params, want) in [ + (json!({"action": 0}), "no such action"), + (json!({"set": 0, "after": 0}), "no such action"), + (json!({"set": 1, "action": 0}), "no such action"), + (json!({"set": 0, "action": 1}), "no such action"), + (json!({"set": 0, "action": "0"}), "no such action"), + (json!({"set": 0, "action": 0, "after": 3}), "no such action step"), + (json!({"set": 0, "action": 0, "after": null}), "no such action step"), + (json!({"set": 0, "action": 0, "after": -1}), "no such action step"), + ] { + assert_eq!(app.run(START_RECORDING, params.clone()).unwrap_err(), want, "{params}"); + assert!(app.ui.recording.is_none(), "{params}"); + } + assert_eq!( + app.run(START_RECORDING, json!({"set": 0, "action": 0, "after": 0})).unwrap(), + json!({"set": 0, "action": 0, "name": "Three Moves"}) + ); + app.run("object.move", json!({"dx": 8, "dy": 0})).unwrap(); + app.run("object.move", json!({"dx": 16, "dy": 0})).unwrap(); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(app.ui.action_sets[0].actions.len(), 1, "no new action"); + assert_eq!(step_names(&app, 0, 0), ["object.move 1", "object.move 8", "object.move 16", "object.move 2", "object.move 4"]); + assert_eq!(app.ui.action_sets[0].actions[0].excluded_steps, BTreeSet::from([0, 4]), "the steps keep whether they are excluded"); + } + + #[test] + fn steps_recorded_into_the_middle_of_an_action_keep_the_selected_step_and_dialog_on_their_steps() { + let (mut app, _) = app_with_three_moves(); + let ctx = egui::Context::default(); + app.ui_ctx = Some(ctx.clone()); + let id = app.ui.action_sets[0].actions[0].id; + app.run(START_RECORDING, json!({"set": 0, "action": 0, "after": 0})).unwrap(); + app.run("object.move", json!({"dx": 8, "dy": 0})).unwrap(); + app.run("object.move", json!({"dx": 16, "dy": 0})).unwrap(); + set_pstate::>(&ctx, SELECTED_STEP, Some((id, 2))); + crate::dialogs::action_stop::open_record(&mut app, Some((0, 0)), Some(1)); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(step_names(&app, 0, 0), ["object.move 1", "object.move 8", "object.move 16", "object.move 2", "object.move 4"]); + assert_eq!(pstate::>(&ctx, SELECTED_STEP), Some((id, 4)), "the move by 4 stays selected"); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["step"], json!(3), "Record Stop stays after the move by 2"); + } + + #[test] + fn step_edits_while_recording_into_an_action_keep_the_recording_in_place() { + let (mut app, _) = app_with_three_moves(); + // The recording goes between the moves by 2 and 4. + app.run(START_RECORDING, json!({"set": 0, "action": 0, "after": 1})).unwrap(); + app.run("object.move", json!({"dx": 8, "dy": 0})).unwrap(); + app.run(DELETE_STEP, json!({"set": 0, "action": 0, "step": 0})).unwrap(); + app.run(INSERT_STOP, json!({"set": 0, "action": 0, "step": 0})).unwrap(); + app.run(MOVE_STEP, json!({"set": 0, "action": 0, "step": 2, "before": 0})).unwrap(); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(step_names(&app, 0, 0), ["object.move 4", "object.move 2", STOP_STEP, "object.move 8"]); + + let (mut app, _) = app_with_three_moves(); + app.run(START_RECORDING, json!({"set": 0, "action": 0, "after": 1})).unwrap(); + app.run("object.move", json!({"dx": 8, "dy": 0})).unwrap(); + app.run(DELETE_STEP, json!({"set": 0, "action": 0, "step": 0})).unwrap(); + app.run(STOP_RECORDING, json!({})).unwrap(); + assert_eq!(step_names(&app, 0, 0), ["object.move 2", "object.move 8", "object.move 4"], "a step deleted before the place"); + } + + #[test] + fn move_step_within_action_updates_order_exclusions_and_selection() { + let (mut app, _) = app_with_three_moves(); + app.ui.action_sets[0].actions[0].steps.push(("object.move".into(), json!({"dx": 8, "dy": 0}))); + app.ui.action_sets[0].actions[0].excluded_steps = BTreeSet::from([1]); + let ctx = egui::Context::default(); + app.ui_ctx = Some(ctx.clone()); + let id = app.ui.action_sets[0].actions[0].id; + set_pstate::>(&ctx, SELECTED_STEP, Some((id, 3))); + let selected_step = |ctx: &egui::Context| pstate::>(ctx, SELECTED_STEP); + assert_eq!( + app.run(MOVE_STEP, json!({"set": 0, "action": 0, "step": 1, "before": 4})).unwrap(), + json!({"set": 0, "action": 0, "step": 3, "moved": true}) + ); + assert_eq!(step_names(&app, 0, 0), ["object.move 1", "object.move 4", "object.move 8", "object.move 2"]); + assert_eq!(app.ui.action_sets[0].actions[0].excluded_steps, BTreeSet::from([3]), "the moved step stays excluded"); + assert_eq!(selected_step(&ctx), Some((id, 2)), "the selected step stays selected"); + assert_eq!( + app.run(MOVE_STEP, json!({"set": 0, "action": 0, "step": 2, "before": 0})).unwrap(), + json!({"set": 0, "action": 0, "step": 0, "moved": true}) + ); + assert_eq!(step_names(&app, 0, 0), ["object.move 8", "object.move 1", "object.move 4", "object.move 2"]); + assert_eq!(app.ui.action_sets[0].actions[0].excluded_steps, BTreeSet::from([3])); + assert_eq!(selected_step(&ctx), Some((id, 0)), "the moved selected step is selected where it went"); + for before in [1, 2] { + assert_eq!( + app.run(MOVE_STEP, json!({"set": 0, "action": 0, "step": 1, "before": before})).unwrap(), + json!({"set": 0, "action": 0, "step": 1, "moved": false}) + ); + } + assert_eq!(step_names(&app, 0, 0), ["object.move 8", "object.move 1", "object.move 4", "object.move 2"], "a drop on itself moves nothing"); + crate::dialogs::action_stop::open_record(&mut app, Some((0, 0)), Some(3)); + app.run(MOVE_STEP, json!({"set": 0, "action": 0, "step": 3, "before": 0})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["step"], json!(0), "an open Record Stop follows its step"); + } + + #[test] + fn move_step_between_actions_and_sets_keeps_file_roundtrip() { + let (mut app, _) = app_with_three_moves(); + app.ui.action_sets[0].actions[0].excluded_steps = BTreeSet::from([2]); + let rotate = ("object.rotate".to_string(), json!({"angle": 5})); + app.ui.action_sets.push(ActionSet::new("Other", vec![Action::new("Empty", vec![]), Action::new("One", vec![rotate])])); + app.ui.action_set = Some(0); + let ctx = egui::Context::default(); + app.ui_ctx = Some(ctx.clone()); + let (id, empty) = (app.ui.action_sets[0].actions[0].id, app.ui.action_sets[1].actions[0].id); + set_pstate::>(&ctx, "actions-selected", Some((0, 0))); + set_pstate::>(&ctx, SELECTED_STEP, Some((id, 2))); + assert_eq!( + app.run(MOVE_STEP, json!({"set": 0, "action": 0, "step": 2, "toSet": 1, "toAction": 0, "before": 0})).unwrap(), + json!({"set": 1, "action": 0, "step": 0, "moved": true}) + ); + assert_eq!(step_names(&app, 0, 0), ["object.move 1", "object.move 2"]); + assert!(app.ui.action_sets[0].actions[0].excluded_steps.is_empty()); + assert_eq!(step_names(&app, 1, 0), ["object.move 4"], "an empty action takes a step"); + assert_eq!(app.ui.action_sets[1].actions[0].excluded_steps, BTreeSet::from([0]), "the moved step stays excluded"); + assert_eq!(pstate::>(&ctx, SELECTED_STEP), Some((empty, 0))); + assert_eq!(pstate::>(&ctx, "actions-selected"), Some((1, 0))); + assert_eq!((app.ui.action_set, app.ui.action_sets_selected.as_slice()), (Some(1), &[1][..])); + assert!(pstate::>(&ctx, "actions-expanded").contains(&empty), "the step's new action shows its steps"); + app.run(MOVE_STEP, json!({"set": 0, "action": 0, "step": 0, "toSet": 1, "toAction": 1, "before": 0})).unwrap(); + assert_eq!(step_names(&app, 1, 1), ["object.move 1", "object.rotate"]); + assert_eq!(pstate::>(&ctx, SELECTED_STEP), Some((empty, 0)), "an unmoved selected step stays"); + let loaded = files::read_set(&files::write_set(&app.ui.action_sets[1]), "Other").unwrap(); + for (a, b) in loaded.actions.iter().zip(&app.ui.action_sets[1].actions) { + assert_eq!((&a.name, &a.steps, &a.excluded_steps), (&b.name, &b.steps, &b.excluded_steps)); + } + } + + #[test] + fn move_step_rejects_invalid_and_oversize_destinations_without_changes() { + let (mut app, _) = app_with_three_moves(); + let full = Action::new("Full", vec![("object.move".to_string(), json!({"dx": 0})); files::MAX_ACTION_STEPS]); + let big = |text: String| ActionSet::new("Big", vec![Action::new("Big", vec![("document.note".to_string(), json!({ "s": text }))])]); + let room = files::MAX_SET_FILE - files::write_set(&big(String::new())).len() - 4; + app.ui.action_sets.push(ActionSet::new("Full", vec![full])); + app.ui.action_sets.push(big("x".repeat(room))); + assert!(files::write_set(&app.ui.action_sets[2]).len() <= files::MAX_SET_FILE); + let before: Vec = app.ui.action_sets.iter().map(files::write_set).collect(); + for (params, want) in [ + (json!({}), "no such action"), + (json!({"set": 0, "action": 1, "step": 0, "before": 0}), "no such action"), + (json!({"set": 3, "action": 0, "step": 0, "before": 0}), "no such action"), + (json!({"set": 0, "action": 0, "step": 3, "before": 0}), "no such action step"), + (json!({"set": 0, "action": 0, "before": 0}), "no such action step"), + (json!({"set": 0, "action": 0, "step": 0}), "no such action step"), + (json!({"set": 0, "action": 0, "step": 0, "before": 4}), "no such action step"), + (json!({"set": 0, "action": 0, "step": 0, "before": "1"}), "no such action step"), + (json!({"set": 0, "action": 0, "step": 0, "toSet": 3, "before": 0}), "no such action set"), + (json!({"set": 0, "action": 0, "step": 0, "toSet": null, "before": 0}), "no such action set"), + (json!({"set": 0, "action": 0, "step": 0, "toSet": 1, "toAction": 1, "before": 0}), "no such action"), + (json!({"set": 0, "action": 0, "step": 0, "toSet": 1, "toAction": 0, "before": 0}), "the action already has 10,000 steps"), + (json!({"set": 0, "action": 0, "step": 0, "toSet": 2, "toAction": 0, "before": 1}), "the action set would be larger than 8 MiB"), + ] { + assert_eq!(app.run(MOVE_STEP, params.clone()).unwrap_err(), want, "{params}"); + assert_eq!(app.ui.action_sets.iter().map(files::write_set).collect::>(), before, "{params}"); + } + } + + #[test] + fn drag_step_shows_insertion_line_and_moves_once() { + use egui::{Event, PointerButton}; + let (mut app, _) = app_with_three_moves(); + app.ui.action_set = Some(0); + let ctx = egui::Context::default(); + app.ui_ctx = Some(ctx.clone()); + set_pstate(&ctx, "actions-expanded", BTreeSet::from([app.ui.action_sets[0].actions[0].id])); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], show); + let mut rows: Vec = texts.iter().filter(|(s, _)| s == "object.move").map(|(_, r)| *r).collect(); + rows.sort_by(|a, b| a.top().total_cmp(&b.top())); + let (from, to) = (rows[0].center(), egui::pos2(rows[0].center().x, rows[2].center().y + 4.0)); + let accent = Tokens::get(&ctx).accent; + // Whether a frame with `events` draws the insertion line. + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + fn line(s: &egui::Shape, accent: egui::Color32) -> bool { + match s { + egui::Shape::LineSegment { stroke, .. } => stroke.width == 2.0 && stroke.color == accent, + egui::Shape::Vec(v) => v.iter().any(|s| line(s, accent)), + _ => false, + } + } + out.shapes.iter().any(|c| line(&c.shape, accent)) + }; + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + assert!(!frame(&mut app, vec![Event::PointerMoved(from)]), "no line before a drag"); + frame(&mut app, vec![button(from, true)]); + frame(&mut app, vec![Event::PointerMoved(from + egui::vec2(0.0, 12.0))]); + assert!(frame(&mut app, vec![Event::PointerMoved(to)]), "the line shows where the step goes"); + assert_eq!(step_names(&app, 0, 0), ["object.move 1", "object.move 2", "object.move 4"], "nothing moves while dragging"); + frame(&mut app, vec![button(to, false)]); + assert_eq!(step_names(&app, 0, 0), ["object.move 2", "object.move 4", "object.move 1"], "the release moves the step"); + for _ in 0..3 { + assert!(!frame(&mut app, vec![]), "no line after the drop"); + } + assert_eq!(step_names(&app, 0, 0), ["object.move 2", "object.move 4", "object.move 1"], "the step moves once"); + } + + #[test] + fn drag_step_released_off_the_rows_leaves_it_in_place_without_an_undo_step() { + use egui::{Event, PointerButton}; + let (mut app, _) = app_with_three_moves(); + app.ui.action_set = Some(0); + let ctx = egui::Context::default(); + app.ui_ctx = Some(ctx.clone()); + set_pstate(&ctx, "actions-expanded", BTreeSet::from([app.ui.action_sets[0].actions[0].id])); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], show); + let mut rows: Vec = texts.iter().filter(|(s, _)| s == "object.move").map(|(_, r)| *r).collect(); + rows.sort_by(|a, b| a.top().total_cmp(&b.top())); + let from = rows[0].center(); + let over = egui::pos2(from.x, rows[2].center().y + 4.0); + let off = egui::pos2(from.x, rows[2].bottom() + 2_000.0); + let undo = undo_steps(&app); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, vec![button(from, true)]); + frame(&mut app, vec![Event::PointerMoved(from + egui::vec2(0.0, 12.0))]); + frame(&mut app, vec![Event::PointerMoved(over)]); + frame(&mut app, vec![Event::PointerMoved(off)]); + frame(&mut app, vec![button(off, false)]); + for _ in 0..3 { + frame(&mut app, vec![]); + } + assert_eq!(step_names(&app, 0, 0), ["object.move 1", "object.move 2", "object.move 4"], "the step stays where it was"); + assert_eq!(undo_steps(&app), undo, "no undo step"); + assert_eq!(pstate::>(&ctx, DRAGGED_STEP), None, "the drag ends"); + } + #[test] fn step_and_set_edits_keep_an_open_insert_menu_item_on_its_place() { let (mut app, _) = app_with_three_moves(); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index f84b8f53b0..28f068fc06 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -617,11 +617,16 @@ pub struct UiState { /// Recording in progress: (set index, action name, journal length when recording started). #[serde(skip)] pub recording: Option<(usize, String, usize)>, - /// The id of the action New Action made for the recording in progress - /// ([`crate::panels::actions::Action::id`]): Stop gives it the recorded steps. None when Stop - /// adds a new action, as after Begin Recording. + /// The id of the action the recording in progress records into, the one New Action made or the + /// one selected when Begin Recording started it ([`crate::panels::actions::Action::id`]): Stop + /// gives it the recorded steps. None when Stop adds a new action, as after Begin Recording with + /// no action selected. #[serde(skip)] pub recording_into: Option, + /// Where in the action [`UiState::recording_into`] names Stop puts the recorded steps: before + /// the step at this index, or None after its last step. + #[serde(skip)] + pub recording_at: Option, /// The length of the action set file that Stop Playing/Recording would leave without the steps /// recorded so far, once measured; None until then, and again after an app command, which may /// change the sets. @@ -1150,6 +1155,7 @@ impl Default for UiState { action_sets_selected: vec![], recording: None, recording_into: None, + recording_at: None, recording_base: None, recording_tail: None, action_playback: Default::default(), diff --git a/docs/mcp.md b/docs/mcp.md index 274db05a34..112befc778 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3053,10 +3053,15 @@ already walks Image Trace. Short agent entry points: would give the set more than 1,000 actions (`the action set file holds more than 1,000 actions`) or a file larger than 8 MiB (`the action set would be larger than 8 MiB`), and then no recording starts. The panel's Create New Action button and menu's New Action… open the `actionNew` dialog, whose Record runs this. -- Actions: `ui.action.record {set?}` starts recording a new action in the chosen set, or the current set when `set` - is left out, and returns `{set, name}`. With no sets, it creates Set 1. The action gets the next available - `Action N` name in that set. The Actions panel's Begin Recording button runs this command. An invalid set or an - existing recording is an error and leaves the sets and recording unchanged, and so is a set where the new action +- Actions: `ui.action.record {set?, action?, after?}` starts recording a new action in the chosen set, or the current + set when `set` is left out, and returns `{set, name}`. With no sets, it creates Set 1. The action gets the next + available `Action N` name in that set. With `action`, it records into the action at `action` in set `set` instead + and returns `{set, action, name}`: `ui.action.stop` puts the recorded steps after step `after` of that action, or + after its last step when `after` is left out, and the steps after them keep whether they are excluded. The Actions + panel's Begin Recording button runs this command with the selected action and its selected step, and with neither + when no action is selected. `action` without `set`, `after` without `action`, an index past the last set, action or + step, an invalid set or an existing recording is an error and leaves the sets and recording unchanged, and so is a + set where the new action would give it more than 1,000 actions or an action set file larger than 8 MiB. While a recording (this one or `ui.action.new`'s) is in progress, a recorded command whose step would give the action more than 10,000 steps, or its set's action set file more than 8 MiB, is an error with the reason (`the action already has 10,000 steps`, `the @@ -3169,6 +3174,20 @@ already walks Image Trace. Short agent entry points: they are excluded, and no step of that action stays selected. A Record Stop dialog open on that step closes, and one open on a later step of the action follows it up. A missing or invalid index is an error, and then nothing changes. Undo does not restore a deleted step. The panel's Delete Selection runs this for the selected step. +- Actions: `ui.action.moveStep {set, action, step, toSet?, toAction?, before}` moves one step, a stop or a command, of + the action at `action` in set `set` into the action at `toAction` in set `toSet` (by default the same set and + action), before the step at index `before` there, or after its last step when `before` is its number of steps. + `before` counts the steps as they are before the move. It returns `{set, action, step, moved}` with the step's new + place. The step keeps its params and whether it is excluded, and the other steps keep theirs. A selected step stays + selected; a moved selected step makes its new action the selected one and its set the current one. A Record Stop + dialog open on a step follows the step, and the steps that the recording in progress adds to either action stay + after the steps that were before them. A step put back just before or just after itself does not move, and + `moved` is `false`. A missing or invalid index, or a move that would give the action more than 10,000 steps or its + set's action set file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an + error, and then nothing changes. Undo does not move the step back. In the Actions panel, dragging a step row moves + the step: a line shows where it goes, before or after the row under the pointer, or first in an action whose name + row it is over (after the last step when the action's steps are hidden), and releasing the button off those rows + leaves the step where it was. - Actions: `ui.action.playbackOptions {speed?, seconds?}` sets how the Actions panel plays actions and returns `{speed, seconds}`. `speed` is `accelerated` (the default: every step at once), `step` (one step at a time, with the window drawn after each step before the next runs) or `pause` (a pause of `seconds` between steps). `seconds` is a @@ -3177,8 +3196,8 @@ already walks Image Trace. Short agent entry points: preferences. The panel menu's Playback Options… opens the `actionPlayback` dialog (`speed`, `seconds`), whose OK runs this. Batch… (`action.batch`) plays every step at once whatever the setting. - Actions: `ui.action.stop {}` ends the recording in progress and keeps its steps, in the action `ui.action.new` made - for it, else in a new action after the last action of its set, and returns `{stopped}`. It also ends an action - playing at Step by Step or Pause For, or one waiting at a stop, whose message box closes: the steps already played + for it or the action `ui.action.record` named, at the place it named, else in a new action after the last action of + its set, and returns `{stopped}`. It also ends an action playing at Step by Step or Pause For, or one waiting at a stop, whose message box closes: the steps already played stay, and the rest do not play. With nothing recording or playing it changes nothing. The panel's Stop Playing/Recording button runs this. - Action sets: `ui.actionSet.delete {sets?: [index], ids?: [id]}` deletes the Actions panel's sets at these indexes diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f04ee3c910..053d952be1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -741,6 +741,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Actions panel menu: Load Actions… also reads action sets saved as `.aia` files ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html); `actionSet.read`, `ui.actionSet.load`). VectorCraft's own rules: the set comes in with all its actions. A step with a label and no recorded values comes in as the one menu command with the step's label, when that command is directly in a top menu, is what its menu bar item runs with no values and opens no dialog. A step that is off in the file stays excluded from playback. The other steps, recorded menu items included, are left out, and the status bar says how many. - Actions panel: the panel menu's Action Options… renames the selected action and keeps its steps ([Actions panel overview](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/actions-panel-overview.html): the menu item opens a dialog that renames the action or gives it a function key and color; `ui.action.options`). It also sets the action's function key and color. - Actions panel: Create New Action (bottom bar) and the panel menu's New Action… ask for the new action's name, set, function key and color, and Record adds the action and starts recording into it ([Create new actions](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/record-actions.html): the New Action dialog takes the action's name and the set it goes in; `ui.action.new`, `ui.action.play`). The function key (F1 to F15, with Ctrl or Command and Shift) plays the action. An action's function key plays the action even when a command has the same shortcut, and the command keeps it. VectorCraft's own rules, which the page does not cover: when several actions have the same key the first in panel order plays; a copied action has no key; New Action does not open while an action is recording. The color is kept with the action for Button Mode, which VectorCraft does not have yet. + - Actions panel: with an action selected, Begin Recording adds the commands recorded to that action, after its selected step or at its end, and a step dragged within its action or to another action goes where a highlighted line shows when the button is released ([the help page](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/add-commands-to-actions.html); `ui.action.record`, `ui.action.moveStep`). VectorCraft's own rules, which the page does not cover: with no action selected Begin Recording adds a new action; a step can go into an empty action or an action in another set; a release off the actions' rows leaves the step where it was; a moved step keeps whether it is excluded; Undo does not move it back. - Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command. - Actions panel: the current set is kept with the UI preferences, so after a restart a recording goes into the set that was current, not into the first set. With no set current, Begin Recording records into the first set and makes it current. - Actions panel: expand an action to show its recorded steps, then clear a step's checkmark to keep it in the action but skip it during playback. The action and set checkmarks include or exclude all their steps; a mixed checkmark includes all. Alt/Option-clicking a step checkmark keeps that step as it was and applies the opposite inclusion state to the other steps in its action. This follows the public [Exclude commands from an action](https://helpx.adobe.com/illustrator/desktop/automate-visualize-data/automate-actions/exclude-commands-from-actions.html) guide; the scope of the mixed-state and Alt-click behavior is VectorCraft's design. The exclusion survives UI restart, action duplication, and `.vcactions` save/load. Existing version 1 action set files load with every step included. From cf4d27c38eee4dc1917c75f492422a6dd835a9ee Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 02:15:55 -0800 Subject: [PATCH 06/56] Graphs: stroke, gradient and recolor commands update a series' stored paint A stroke weight set with stroke.set or stroke.weightUp/weightDown, a gradient made, placed or edited with paint.editGradient, paint.setGradientGeom or the freeform gradient edits, and colors changed by paint.invert, paint.complement, the Edit Colors recolor and Blend commands, recolor.apply, addon.color.filter, addon.color.applyNearestSwatch or the ink limit of addon.color.printFixes changed a series mark but not the paint the graph stores, so the mark went back to the old paint when the graph was drawn again. appearance::edit_items now stores what its edit changes on the topmost visible fill and stroke of series marks, as the Appearance commands do, and the recolor pass does the same for the series marks it reaches, including those inside a series group or a graph it is given. When one command changes several marks of a series, the frontmost one sets the stored paint. A gradient is stored with its placement. These commands do not store the colors they give a graph's axes and labels. docs/mcp.md, the params text and the Graphs line in docs/target-app-parity.md say what is stored. --- crates/engine/src/cmd/addons/color.rs | 6 +- .../src/cmd/addons/color/print_fixes.rs | 4 +- crates/engine/src/cmd/appearance.rs | 31 ++- crates/engine/src/cmd/colorcmds.rs | 29 ++- crates/engine/src/cmd/freeform.rs | 2 +- crates/engine/src/cmd/gradient.rs | 4 +- crates/engine/src/cmd/graph.rs | 2 +- crates/engine/src/cmd/graph/series.rs | 19 ++ crates/engine/src/cmd/graph/tests.rs | 202 ++++++++++++++++++ crates/engine/src/cmd/paint.rs | 4 +- crates/engine/src/cmd/recolor.rs | 6 +- crates/engine/src/cmd/stroke.rs | 4 +- docs/mcp.md | 19 +- docs/target-app-parity.md | 2 +- 14 files changed, 301 insertions(+), 33 deletions(-) diff --git a/crates/engine/src/cmd/addons/color.rs b/crates/engine/src/cmd/addons/color.rs index cd30d29d15..ced10c9912 100644 --- a/crates/engine/src/cmd/addons/color.rs +++ b/crates/engine/src/cmd/addons/color.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "Apply as Filter", ["Object", "Plug-ins", "Color Effects"], None, - "{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate, addon.color.duotone or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step; their effects stay. Duotone links a colour to its one tone's swatch as the live effect does → {changed}", + "{effect: a colour adjustment's effect id (adjust.brightnessContrast, adjust.curves, adjust.hueSaturation, adjust.levels, adjust.shiftToColor, adjust.temperatureTint, addon.color.exposure, addon.color.desaturate, addon.color.duotone or addon.color.invert), params?: {…} (as effect.apply; missing keys take the dialog defaults), fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?: [id…] (default: the selection)} recolour the objects and everything inside them as the live effect would draw them, but in their colours, as edit.colors.invert does (fills, strokes, type, gradient meshes, embedded images and pattern tiles), as one undo step; their effects stay. Duotone links a colour to its one tone's swatch as the live effect does. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed}", has_selection, filter ), @@ -52,7 +52,7 @@ pub fn specs() -> Vec { "Print Ink Fixes", ["Object", "Plug-ins", "Color Effects"], None, - "{scope?: \"selection\" (the selection, or `ids`; by default, the document while nothing is selected) | \"document\" (every layer), inkLimit?: total ink % 1..400 (0 or left out: off), excludePureBlack?: true (black-only and grey colours keep their ink), removeWhiteOverprint?: false (white fills and strokes stop overprinting), overprintBlack?: black % 1..100 (black-only and grey fills and strokes this dark or darker overprint, as edit.colors.overprintBlack; 0 or left out: off), textOnly?: false (Overprint Black on type only), fill?: true, stroke?: true, ids?: [id…]} prepare a CMYK document's inks for print as one undo step: CMYK colours (solid, gradient stops, gradient meshes) with more total ink than inkLimit are scaled down to it (colours of spot swatches and Registration stay), white overprint is removed and black overprint set. Refused in RGB documents (File › Document Color Mode › CMYK Color first) → {changed, inkLimited: paints and meshes scaled, whiteOverprintRemoved: objects, blackOverprinted: objects}", + "{scope?: \"selection\" (the selection, or `ids`; by default, the document while nothing is selected) | \"document\" (every layer), inkLimit?: total ink % 1..400 (0 or left out: off), excludePureBlack?: true (black-only and grey colours keep their ink), removeWhiteOverprint?: false (white fills and strokes stop overprinting), overprintBlack?: black % 1..100 (black-only and grey fills and strokes this dark or darker overprint, as edit.colors.overprintBlack; 0 or left out: off), textOnly?: false (Overprint Black on type only), fill?: true, stroke?: true, ids?: [id…]} prepare a CMYK document's inks for print as one undo step: CMYK colours (solid, gradient stops, gradient meshes) with more total ink than inkLimit are scaled down to it (colours of spot swatches and Registration stay), white overprint is removed and black overprint set. Refused in RGB documents (File › Document Color Mode › CMYK Color first). On a path directly in a graph series, the graph also stores a paint the ink limit changes on the topmost visible fill or stroke (docs/mcp.md) → {changed, inkLimited: paints and meshes scaled, whiteOverprintRemoved: objects, blackOverprinted: objects}", has_doc, print_fixes::run ), @@ -70,7 +70,7 @@ pub fn specs() -> Vec { "Apply Nearest Swatch", ["Object", "Plug-ins", "Color Effects"], None, - "{library?: as addon.color.nearestSwatch (default: \"document\"), fill?: true, stroke?: true, ids?: [id…] (default: the selection)} give each solid colour and gradient stop of the selected objects' fills and strokes (their type's and those of the objects inside them; gradient meshes too) the colour of its nearest swatch in the library, as addon.color.nearestSwatch finds it, as one undo step: a global or spot swatch's colour links to it. A library's swatches the colours take are added to the document's first, as swatch.library.add adds them (a spot colour stays spot); images and patterns keep their colours → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}", + "{library?: as addon.color.nearestSwatch (default: \"document\"), fill?: true, stroke?: true, ids?: [id…] (default: the selection)} give each solid colour and gradient stop of the selected objects' fills and strokes (their type's and those of the objects inside them; gradient meshes too) the colour of its nearest swatch in the library, as addon.color.nearestSwatch finds it, as one undo step: a global or spot swatch's colour links to it. A library's swatches the colours take are added to the document's first, as swatch.library.add adds them (a spot colour stays spot); images and patterns keep their colours. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed: paints changed, swatches: [the document swatches the colours took], added: [swatches added to the document]}", has_selection, nearest::apply ), diff --git a/crates/engine/src/cmd/addons/color/print_fixes.rs b/crates/engine/src/cmd/addons/color/print_fixes.rs index a0dd332fec..c5f430eb76 100644 --- a/crates/engine/src/cmd/addons/color/print_fixes.rs +++ b/crates/engine/src/cmd/addons/color/print_fixes.rs @@ -11,7 +11,7 @@ use vectorcraft_color::swatch::REGISTRATION; use vectorcraft_doc::overprint::{OverprintBlack, is_white}; use vectorcraft_doc::{ColorMode, Node, NodeKind}; -use super::super::super::colorcmds::{Recolor, Scope}; +use super::super::super::colorcmds::{Recolor, Scope, keeping_series_paint}; use super::super::super::overprint::edit_counted; use super::super::super::{bad, checked_ids_or_selection, number_or, switch_or, text_param}; use crate::{Result, Session}; @@ -149,7 +149,7 @@ pub(super) fn run(s: &mut Session, p: &Value) -> Result { true }; let scope = Scope { fill: fixes.fill, stroke: fixes.stroke, images: false, patterns: false }; - ink = Recolor::with_links(scope, &|c| fixes.limit(c), &links).run(d, &ids); + ink = keeping_series_paint(d, &ids, |d| Ok(Recolor::with_links(scope, &|c| fixes.limit(c), &links).run(d, &ids)))?; } if !fixes.remove_white_overprint && fixes.overprint_black <= 0.0 { return Ok(ink); diff --git a/crates/engine/src/cmd/appearance.rs b/crates/engine/src/cmd/appearance.rs index fb86fa7852..fe24e75ac4 100644 --- a/crates/engine/src/cmd/appearance.rs +++ b/crates/engine/src/cmd/appearance.rs @@ -427,8 +427,9 @@ fn require_targets(s: &Session, p: &Value, cmd: &str) -> Result> { /// One undo step (`label`) running `f` on each of `ids` with the item a fill (`fill`) or stroke /// edit aimed at `item` changes there (`None`: the topmost one; text then edits its characters). -/// Nothing targeted is not an edit, and when `f` leaves every target as it was, the document and -/// its undo history stay as they are. +/// What the edit changes on the topmost visible fill and stroke of graph series marks is stored on +/// their graphs in the same step ([`capture_series_changes`]). Nothing targeted is not an edit, and +/// when `f` leaves every target as it was, the document and its undo history stay as they are. pub(crate) fn edit_items( s: &mut Session, ids: &[NodeId], @@ -438,12 +439,13 @@ pub(crate) fn edit_items( fill: bool, f: impl FnMut(&mut Node, Option) -> Result<()>, ) -> Result<()> { - edit_items_then(s, ids, item, cmd, label, fill, f, |_, _| Ok(false)) + edit_items_then(s, ids, item, cmd, label, fill, f, |before, d, edited| Ok(capture_series_changes(before, d, edited))) } -/// [`edit_items`], then `after` on the ids `f` ran on, inside the same undo step. `after` returns -/// whether it changed the document; when it did not and `f` left every target as it was, the -/// document and its undo history stay as they are. +/// [`edit_items`], then `after` on the document as it was before the edit, the edited document and +/// the ids `f` ran on, inside the same undo step. `after` returns whether it changed the document; +/// when it did not and `f` left every target as it was, the document and its undo history stay as +/// they are. #[allow(clippy::too_many_arguments)] pub(crate) fn edit_items_then( s: &mut Session, @@ -453,7 +455,7 @@ pub(crate) fn edit_items_then( label: &str, fill: bool, mut f: impl FnMut(&mut Node, Option) -> Result<()>, - mut after: impl FnMut(&mut vectorcraft_doc::Document, &[NodeId]) -> Result, + mut after: impl FnMut(&vectorcraft_doc::Document, &mut vectorcraft_doc::Document, &[NodeId]) -> Result, ) -> Result<()> { if ids.is_empty() { return Ok(()); @@ -469,7 +471,7 @@ pub(crate) fn edit_items_then( f(n, index)?; edited.push(*id); } - let more = after(d, &edited)?; + let more = after(&before, d, &edited)?; Ok(count_changed(d, &before, &edited) + usize::from(more)) })?; Ok(()) @@ -710,6 +712,19 @@ pub(super) fn capture_series_writes( kept || painted || stored } +/// Store on the graph what an edit of `ids` (from `before` to `d`) changed on the series marks among them: the paint, +/// weight, opacity or blend mode of the topmost visible fill and stroke, and the object opacity and blend mode. Rows +/// and objects the edit left as they were keep what the graph stores. When the edit changes several marks of one series, +/// the frontmost one sets what the graph stores, whatever order `ids` are in. Returns whether the stored paint changed. +pub(crate) fn capture_series_changes(before: &vectorcraft_doc::Document, d: &mut vectorcraft_doc::Document, ids: &[NodeId]) -> bool { + // Only objects directly in a series group can be marks: other edits skip the search of the document for graphs. + let in_series = |id: &NodeId| d.parent_of(*id).and_then(|p| d.node(p)).is_some_and(|g| g.extras().series_index.is_some()); + let mut marks: Vec = ids.iter().copied().filter(in_series).collect(); + // A later mark's capture replaces an earlier one's: take them in paint order. + marks.sort_by_cached_key(|id| d.index_path(*id)); + !marks.is_empty() && capture_series_writes(before, d, &marks, (false, false), (false, false), false, false) +} + /// The kind of the row at `at` (fill `true`, stroke `false`) when it is the topmost visible row of that kind in `ap`. fn top_row_kind(ap: &Appearance, at: usize) -> Option { let fill = ap.items.get(at)?.is_fill(); diff --git a/crates/engine/src/cmd/colorcmds.rs b/crates/engine/src/cmd/colorcmds.rs index bbc56dbc15..1238ba5978 100644 --- a/crates/engine/src/cmd/colorcmds.rs +++ b/crates/engine/src/cmd/colorcmds.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { "Invert Colors", ["Edit", "Edit Colors"], None, - "{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step → {changed, notChanged: {locked, hidden} (when the selection is everything select.all takes: the locked and hidden objects it left out whose colors would have changed; an object both hidden and locked counts as hidden), warning?: set when notChanged counts any object}", + "{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed, notChanged: {locked, hidden} (when the selection is everything select.all takes: the locked and hidden objects it left out whose colors would have changed; an object both hidden and locked counts as hidden), warning?: set when notChanged counts any object}", has_selection, |s, p| recolor(s, p, "edit.colors.invert", "Invert Colors", &invert) ), @@ -89,7 +89,7 @@ pub fn specs() -> Vec { "Blend Front to Back", ["Edit", "Edit Colors"], None, - "{} ≥3 filled objects (solid fills or gradient meshes, which keep their shading): intermediate fills graded between the frontmost and backmost fill → {changed}", + "{} ≥3 filled objects (solid fills or gradient meshes, which keep their shading): intermediate fills graded between the frontmost and backmost fill. On a path directly in a graph series, the graph also stores the new fill, as for the other Blend commands (docs/mcp.md) → {changed}", has_selection, |s, _| blend(s, BlendOrder::Stack) ), @@ -446,8 +446,11 @@ fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) - let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); let (n, not_changed) = s.edit(label, |d, _| { let not_changed = NotChanged::of(pass.again().would_change(d, &left).into_iter().map(|(_, hidden)| hidden)); - let n = pass.run(d, &ids); - super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(cmd, e))?; + let n = keeping_series_paint(d, &ids, |d| { + let n = pass.run(d, &ids); + super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(cmd, e))?; + Ok(n) + })?; Ok((n, not_changed)) })?; let mut out = json!({ "changed": n }); @@ -455,6 +458,19 @@ fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) - Ok(out) } +/// Run `recolor` on `d` (it returns how many objects it changed) over `roots`, then store on their graphs what it changed +/// on the graph series marks among `roots` and inside them, so the marks keep their new paint when the graph is drawn +/// again. +pub(crate) fn keeping_series_paint(d: &mut Document, roots: &[NodeId], recolor: impl FnOnce(&mut Document) -> Result) -> Result { + let marks = super::graph::series_marks_in(d, roots); + let before = (!marks.is_empty()).then(|| d.clone()); + let n = recolor(d)?; + if let Some(before) = before.filter(|_| n > 0) { + super::appearance::capture_series_changes(&before, d, &marks); + } + Ok(n) +} + /// The objects a recolor of `roots` leaves out of the art the user meant to recolor, each with /// whether it is hidden (otherwise it is locked). When `roots` are exactly the objects Select All /// takes (in isolation mode, those of the isolated group), these are the objects beside them that @@ -679,7 +695,7 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result { }; let fills = Scope { fill: true, stroke: false, images: false, patterns: false }; let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok(); - let changed = s.edit(label, |d, _| { + let run = |d: &mut Document| -> Result { let mut changed = 0; for (i, id) in ids.iter().enumerate().take(n - 1).skip(1) { let c = lerp_color(&first, &last, i as f32 / (n - 1) as f32).in_model(model); @@ -698,7 +714,8 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result { } super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(label, e))?; Ok(changed) - })?; + }; + let changed = s.edit(label, |d, _| keeping_series_paint(d, &ids, run))?; Ok(json!({ "changed": changed })) } diff --git a/crates/engine/src/cmd/freeform.rs b/crates/engine/src/cmd/freeform.rs index 0f72b066ab..f4ec784af3 100644 --- a/crates/engine/src/cmd/freeform.rs +++ b/crates/engine/src/cmd/freeform.rs @@ -19,7 +19,7 @@ pub fn specs() -> Vec { "Add Freeform Point", [], None, - "{at: [x,y] (document coordinates), color? (default: the gradient's colour there; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it → {index}", + "{at: [x,y] (document coordinates), color? (default: the gradient's colour there; #rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), opacity?: 0..1 (or 0..100), spread?: 0..1 (or 0..100 %; the radius of pure colour around the point, a fraction of half the object's larger side; default 0), line?: true (join it to the selected point: extends the line ending there, else starts one), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\"} add a point to the freeform gradient (an unplaced one keeps its automatic points first) and select it. On a path directly in a graph series, the graph also stores the edited gradient when it is on the topmost visible fill or stroke, as for the other paint.freeform edits (docs/mcp.md) → {index}", has_doc, add_point ), diff --git a/crates/engine/src/cmd/gradient.rs b/crates/engine/src/cmd/gradient.rs index 090a7cb46c..fd850bb741 100644 --- a/crates/engine/src/cmd/gradient.rs +++ b/crates/engine/src/cmd/gradient.rs @@ -24,7 +24,7 @@ pub fn specs() -> Vec { "Gradient", ["Window", "Gradient"], None, - "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool, dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient", + "{stroke?: bool (default: the targeted item's kind, else the active proxy), kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical, sweeps the stops once around the center, clockwise from the vector, with a seam where the last stop meets the first; diamond runs the stops from the center out to a square with a corner at the end of the vector; freeform places points on each object, coloured along the stops), mode?: points|lines (freeform: how the Gradient tool adds points), method?: classic|perceptual (how the colours between the stops mix: in display RGB, or perceptually (OKLab) so the steps look even; SVG exports draw a Perceptual gradient as an image, and PDF exports do too unless it is linear, radial, conic or diamond, has spot color stops, every other stop is paper white or written in CMYK or gray (an export that converts colors to a CMYK destination profile writes RGB and Lab stops in CMYK, and so does a CMYK document unless the export converts colors to an RGB destination profile), it is not a dithered fill and its curve does not have too many stops: then PDF writes tints of one spot ink as a Separation shading in that ink, and spot inks mixed with other spot, CMYK or gray inks as a DeviceN shading; freeform gradients mix their own way), spread?: pad|repeat|reflect (linear, radial, conic and diamond: what the gradient paints past the ends of its stops: pad carries the end colors on; repeat and reflect run the stops `repeats` times along the vector (around the circle, for a conic) and on past both ends, reflect mirroring every other run; an error on a freeform gradient), repeats?: 1..100 (a whole number: how many times repeat and reflect run the stops along the vector; VectorCraft's own limit; pad keeps it unused), stops?: [{offset 0..1, color? (needed without swatch; #rrggbbaa and #rgba are refused), opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: colour swatch name (a global or spot colour or tint swatch links the stop, so swatch edits recolour it and a spot stop prints on its plate; a process colour just gives its colour), tint?: 0..100 (% of the linked swatch; default 100, or a tint swatch's own)}] (at least 2; a freeform gradient's points are recoloured along them), keepModel?: bool (as paint.setFill; a stop color the gradient already has keeps its model), angle?: deg counter-clockwise (0: the gradient runs left to right; radial, conic and diamond: the vector turns about the center), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep and is an error for the other kinds), reverse?: bool, dither?: bool (fills only: a linear, radial, conic or diamond gradient fill is drawn with fine noise that hides banding, and as pixels in SVG and PDF exports), item?: fill/stroke item index|null (omitted: the Appearance panel's active item when it is of the edited kind), ids?|id?, strokeMode?: within|along|across (strokes only: the gradient lies on the page and shows through the stroke, runs from the start of each subpath to its end, or runs from the stroke's left edge to its right all along it; with nothing selected, for the next object drawn; type characters' own strokes always paint within), target?: \"object\"|\"contents\"} edit the gradient in place (keeps its placement); solid/none paints become the default gradient. On a path directly in a graph series, the graph also stores the gradient when it is on the topmost visible fill or stroke (docs/mcp.md)", has_doc, edit_gradient ), @@ -33,7 +33,7 @@ pub fn specs() -> Vec { "Gradient Vector", [], None, - "{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space)", + "{start?: [x,y], end?: [x,y] (document coordinates; both or neither: omitted, the vector stays), aspect?: % (radial, conic and diamond: the extent's height / width across the vector, a negative one mirroring a conic's sweep and an error for the other kinds; default: kept), focal?: [x,y] (document coordinates) | null (radial: the focal point, where the first stop sits, pulled inside the extent ellipse; null centres it; default: it keeps its place in the ellipse; an error on a conic or diamond gradient), ids?|id?, stroke?: bool (default: the targeted item's kind, else the active proxy), item?: fill/stroke item index|null (alias: index; omitted: the Appearance panel's active item when it is of the edited kind), target?: \"object\"|\"contents\", index?: appearance item index (as item)} set the gradient vector, aspect ratio and focal point (solid paints become the default gradient; type objects set it on their runs, in text space). On a path directly in a graph series, the graph also stores the gradient with its placement when it is on the topmost visible fill or stroke (docs/mcp.md)", has_doc, set_gradient_geom ), diff --git a/crates/engine/src/cmd/graph.rs b/crates/engine/src/cmd/graph.rs index 2c42494160..e2bb2b312c 100644 --- a/crates/engine/src/cmd/graph.rs +++ b/crates/engine/src/cmd/graph.rs @@ -46,7 +46,7 @@ use generate::generate; use series::default_series_color; pub(crate) use series::{ capture_label_anti_alias, capture_label_paragraphs, capture_label_styles, capture_series_object_opacity, capture_series_opacity, - capture_series_paints, capture_series_top_paints, paint_targets, series_object_opacity_differs, shown_paint, + capture_series_paints, capture_series_top_paints, paint_targets, series_marks_in, series_object_opacity_differs, shown_paint, }; #[cfg(test)] diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index 229a4d4b42..a417a74015 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -98,6 +98,25 @@ fn series_members(doc: &Document) -> HashMap { out } +/// The objects directly in a series group among `roots` and inside them, for an edit that reaches graph series marks +/// without naming them. They may include objects of groups that only look like series groups; the capture functions +/// check each one. +pub(crate) fn series_marks_in(doc: &Document, roots: &[NodeId]) -> Vec { + let mut out = Vec::new(); + for id in roots { + if doc.parent_of(*id).and_then(|p| doc.node(p)).is_some_and(|g| g.extras().series_index.is_some()) { + out.push(*id); + } + let Some(root) = doc.node(*id) else { continue }; + root.walk(&mut |n| { + if n.extras().series_index.is_some() { + out.extend(n.children().into_iter().flatten().map(|c| c.id)); + } + }); + } + out +} + /// Group Selection on a graph series targets its constituent marks and legend swatch, not a /// group-level appearance which the generated art does not inherit. Other ids pass through. pub(crate) fn paint_targets(doc: &Document, ids: &[NodeId]) -> Vec { diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index aef1918a6d..fcb798c45e 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -1733,6 +1733,208 @@ fn swapping_onto_a_hidden_top_fill_leaves_the_stored_fill() { assert_eq!(spec(&s, id).series_paints[0].fill, red); } +/// Run `case` on each of `cases`, and fail naming every case that failed, not only the first one. +fn every_case(cases: impl IntoIterator, case: impl Fn(&T)) { + let failed: Vec = cases.into_iter().filter(|c| std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| case(c))).is_err()).collect(); + assert!(failed.is_empty(), "failed cases: {failed:?}"); +} + +/// The top fill paint of `mark`. +fn fill_of(s: &Session, mark: NodeId) -> Paint { + s.doc().unwrap().doc.node(mark).unwrap().appearance.fill_paint() +} + +/// A stroke weight set with `stroke.set`, or stepped with `stroke.weightUp` or `stroke.weightDown`, on a series mark is +/// stored on the graph with the mark's stroke paint, and undo takes it back with its edit. +#[test] +fn a_stroke_weight_set_on_a_series_mark_is_kept() { + let cases = [("stroke.set", json!({"weight": 7}), 7.0), ("stroke.weightUp", json!({}), 3.0), ("stroke.weightDown", json!({}), 1.0)]; + every_case(cases, |(cmd, params, weight)| { + let (mut s, id, marks) = stored_red_stroke(); + let was = spec(&s, id).series_paints; + let mut p = params.clone(); + p["ids"] = json!([marks[1].0]); + s.execute(cmd, &p).unwrap(); + let mark = s.doc().unwrap().doc.node(marks[1]).unwrap().clone(); + let (fill, stroke) = (mark.appearance.fill_paint(), mark.appearance.stroke_paint()); + let kept = spec(&s, id).series_paints[0].clone(); + assert_eq!((kept.stroke.clone(), kept.stroke_width), (Some(stroke.clone()), Some(*weight)), "{cmd}"); + series_0_is(&mut s, id, (&fill, 0.4, &stroke, *weight), &was); + }); +} + +/// A gradient made on a series mark's fill with `paint.editGradient` is stored on the graph, every mark of the series has +/// that fill after the graph is drawn again, and undo takes it back with its edit. +#[test] +fn a_gradient_edit_on_a_series_mark_is_kept() { + let (mut s, id, marks) = stored_red_stroke(); + let was = spec(&s, id).series_paints; + s.execute("paint.editGradient", &json!({"ids": [marks[0].0], "kind": "linear"})).unwrap(); + let fill = fill_of(&s, marks[0]); + assert!(matches!(fill, Paint::Gradient(_)), "{fill:?}"); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone())); + let red = was[0].stroke.clone().unwrap(); + series_0_is(&mut s, id, (&fill, 0.4, &red, 2.0), &was); +} + +/// A gradient placed on a series mark's fill with `paint.setGradientGeom` is stored on the graph as it is placed, and +/// every mark of the series has that fill, placement included, after the graph is drawn again. +#[test] +fn a_gradient_placed_on_a_series_mark_is_kept_with_its_placement() { + let (mut s, id, _, marks) = column_marks(); + // `paint.setFill` stores the gradient before it is placed. + s.execute("paint.setFill", &json!({"ids": [marks[0].0], "gradient": {"kind": "linear"}})).unwrap(); + let fill = fill_of(&s, marks[0]); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone())); + s.execute("paint.setGradientGeom", &json!({"ids": [marks[0].0], "start": [0, 0], "end": [10, 40]})).unwrap(); + let placed = fill_of(&s, marks[0]); + assert_ne!(placed, fill, "the command places the gradient"); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(placed.clone())); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == placed), "{:?}", marks_of(&s, id, 0)); +} + +/// A point recolored with `paint.freeform.setPoint` in a freeform gradient on a series mark's fill is stored on the graph. +#[test] +fn a_freeform_gradient_edit_on_a_series_mark_is_kept() { + let (mut s, id, _, marks) = column_marks(); + let points = json!([{"at": [20, 20], "color": "#53aadf"}, {"at": [50, 50], "color": "#ff8000"}]); + // `paint.setFill` stores the gradient before its point is recolored. + s.execute("paint.setFill", &json!({"ids": [marks[0].0], "gradient": {"kind": "freeform", "freeform": {"points": points}}})).unwrap(); + let fill = fill_of(&s, marks[0]); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone())); + s.execute("paint.freeform.setPoint", &json!({"ids": [marks[0].0], "index": 1, "color": "#00ff00"})).unwrap(); + let edited = fill_of(&s, marks[0]); + assert_ne!(edited, fill, "the command recolors the point"); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(edited.clone())); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == edited), "{:?}", marks_of(&s, id, 0)); +} + +/// A series mark's fill recolored with Invert or Complement (the proxy, or one appearance row), an Edit Colors command, +/// a color filter or Recolor Artwork is stored on the graph, and undo takes it back with its edit. +#[test] +fn a_recolored_series_mark_is_kept() { + let cases = [ + ("paint.invert", json!({}), false), + ("paint.complement", json!({}), false), + ("paint.invert", json!({}), true), + ("paint.complement", json!({}), true), + ("edit.colors.invert", json!({}), false), + ("edit.colors.toGrayscale", json!({}), false), + ("addon.color.filter", json!({"effect": "addon.color.invert"}), false), + ("recolor.apply", json!({"map": {"#3366cc": "#00ff00"}}), false), + ]; + every_case(cases, |(cmd, params, item)| { + let (mut s, id, marks) = stored_red_stroke(); + // Not the gray the series starts in: Complement leaves a gray as it is. + s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "color": "#3366cc"})).unwrap(); + let was = spec(&s, id).series_paints; + let mut p = params.clone(); + if *cmd == "recolor.apply" { + // Recolor Artwork works on the selection. + s.execute("select.set", &json!({"ids": [marks[0].0]})).unwrap(); + } else { + p["ids"] = json!([marks[0].0]); + } + if *item { + p["item"] = json!(top_row(&s, marks[0], true)); + } + let was_fill = fill_of(&s, marks[0]); + s.execute(cmd, &p).unwrap(); + let fill = fill_of(&s, marks[0]); + assert_ne!(fill, was_fill, "{cmd} recolors the fill"); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone()), "{cmd} item {item}"); + let red = was[0].stroke.clone().unwrap(); + series_0_is(&mut s, id, (&fill, 0.4, &red, 2.0), &was); + }); +} + +/// The ink limit of Print Ink Fixes on a series mark's fill is stored on the graph. +#[test] +fn an_ink_limit_on_a_series_mark_is_kept() { + let mut s = Session::new(); + s.execute("file.new", &json!({"colorMode": "cmyk"})).unwrap(); + let id = graph(&mut s, "column"); + let mark = series(&s.doc().unwrap().doc.node(id).unwrap().clone(), 0).children().unwrap()[0].id; + // `paint.setFill` stores the heavy color before the limit lowers it. + s.execute("paint.setFill", &json!({"ids": [mark.0], "color": {"c": 100, "m": 100, "y": 100, "k": 100}})).unwrap(); + let heavy = fill_of(&s, mark); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(heavy.clone())); + s.execute("addon.color.printFixes", &json!({"ids": [mark.0], "inkLimit": 300})).unwrap(); + let fill = fill_of(&s, mark); + assert_ne!(fill, heavy, "the limit lowers the ink"); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill.clone())); + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == fill), "{:?}", marks_of(&s, id, 0)); +} + +/// A recolor of a whole graph, or of one series group, stores the new fill of each series it reaches, and the marks have +/// it after the graph is drawn again. +#[test] +fn recoloring_a_graph_or_a_series_group_stores_its_marks_fills() { + let cases = [("edit.colors.invert", true), ("edit.colors.invert", false), ("paint.invert", true), ("paint.invert", false)]; + every_case(cases, |(cmd, whole)| { + let (mut s, id, gray, _) = column_marks(); + let n = s.doc().unwrap().doc.node(id).unwrap().clone(); + let (group, other) = (series(&n, 0).id, series(&n, 1).children().unwrap()[0].clone()); + let target = if *whole { id } else { group }; + s.execute(cmd, &json!({"ids": [target.0]})).unwrap(); + let fill = marks_of(&s, id, 0).last().unwrap().0.clone(); + assert_ne!(fill, gray, "{cmd} recolors the series"); + let kept = spec(&s, id).series_paints; + assert_eq!(kept[0].fill, Some(fill.clone()), "{cmd} whole {whole}"); + let other_now = fill_of(&s, other.id); + if *whole { + assert_ne!(other_now, other.appearance.fill_paint()); + assert_eq!(kept[1].fill, Some(other_now.clone())); + } else { + assert_eq!(other_now, other.appearance.fill_paint(), "the other series is left as it was"); + assert!(kept.get(1).is_none_or(|p| p.fill.is_none()), "{kept:?}"); + } + s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap(); + assert!(marks_of(&s, id, 0).iter().all(|m| m.0 == fill), "{:?}", marks_of(&s, id, 0)); + assert!(marks_of(&s, id, 1).iter().all(|m| m.0 == other_now), "{:?}", marks_of(&s, id, 1)); + }); +} + +/// A recolor that changes several marks of one series to different fills stores the frontmost mark's fill, whatever +/// order the ids are given in. +#[test] +fn recoloring_several_marks_of_a_series_stores_the_frontmost_ones_fill() { + let cases = + [("edit.colors.invert", false), ("paint.invert", true)].into_iter().flat_map(|c| [[0, 1, 2], [2, 1, 0], [1, 2, 0]].map(|o| (c.0, c.1, o))); + every_case(cases, |(cmd, item, order)| { + let (mut s, id, _, marks) = column_marks(); + for (mark, hex) in [(marks[0], "#ff0000"), (marks[1], "#00ff00"), (marks[2], "#0000ff")] { + s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, true), "color": hex})).unwrap(); + } + let mut p = json!({"ids": order.map(|i| marks[i].0)}); + if *item { + p["item"] = json!(top_row(&s, marks[0], true)); + } + s.execute(cmd, &p).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(fill_of(&s, marks[2])), "{cmd} {order:?}"); + }); +} + +/// Blend Horizontally across a series' three marks stores the fill it gives the middle one on the graph. +#[test] +fn blending_a_series_marks_fills_is_kept() { + let (mut s, id, marks) = stored_red_stroke(); + for (mark, hex) in [(marks[0], "#ff0000"), (marks[2], "#0000ff")] { + s.execute("appearance.setItem", &json!({"ids": [mark.0], "index": top_row(&s, mark, true), "color": hex})).unwrap(); + } + let was = spec(&s, id).series_paints; + s.execute("select.set", &json!({"ids": [marks[0].0, marks[1].0, marks[2].0]})).unwrap(); + s.execute("edit.colors.blendHorizontally", &json!({})).unwrap(); + let mid = s.doc().unwrap().doc.node(marks[1]).unwrap().appearance.fill_paint(); + assert_ne!(Some(&mid), was[0].fill.as_ref()); + assert_eq!(spec(&s, id).series_paints[0].fill, Some(mid.clone())); + let red = was[0].stroke.clone().unwrap(); + series_0_is(&mut s, id, (&mid, 0.4, &red, 2.0), &was); +} + /// A kept opacity read from a file outside 0..1 is held to that range, and one that is not a number is left out. #[test] fn a_kept_series_opacity_from_a_file_is_held_to_its_range() { diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index eaf1597423..266422fc02 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -88,7 +88,7 @@ pub fn proxy_specs() -> Vec { "Invert", [], None, - "{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default → {changed}", + "{stroke?: bool (default: the active proxy), ids?|id?, item?: appearance item index|null, target?: \"object\"|\"contents\"} invert the active proxy's colours of the selection (or ids; groups recolour their contents) keeping each colour's model, as edit.colors.invert does; the Appearance panel's active fill/stroke item (appearance.setActiveItem) is inverted instead when it is of that kind and no ids are given; with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's solid color changes for this instance only, and a gradient or pattern, or type whose characters differ in color, is refused; with nothing selected, invert the default. On a path directly in a graph series, the graph also stores the new color of the topmost visible fill or stroke (docs/mcp.md) → {changed}", has_doc, |s, p| proxy_recolor(s, p, "paint.invert", "Invert", &super::colorcmds::invert) ), @@ -583,7 +583,7 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) } Ok(()) }, - |d, changed| { + |_, d, changed| { let series = super::graph::capture_series_paints(d, changed, fill); let labels = super::graph::capture_label_styles(d, changed, &|st| { if fill { diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index 931bcce681..e4b276a0f8 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -13,7 +13,7 @@ use vectorcraft_doc::swatches::{node_colors, node_paints, override_paints}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; use vectorcraft_geom::{Point, Rect}; -use super::colorcmds::{NotChanged, Recolor, Scope, left_out}; +use super::colorcmds::{NotChanged, Recolor, Scope, keeping_series_paint, left_out}; use super::{CommandSpec, Result, Session, always, bad, checked_ids_param, cmd, color_value, has_doc, index, str_param, switch_or, text_param}; pub fn specs() -> Vec { @@ -32,7 +32,7 @@ pub fn specs() -> Vec { "Recolor Artwork", ["Edit", "Edit Colors"], None, - "{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null (in from, to and groupColors, #rrggbbaa and #rgba are refused), exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true, globalAdjust?: {saturation?: 0, brightness?: 0, temperature?: 0, luminosity?: 0} (Global Adjust: percent from -100 to 100, 0 changes nothing; every colour a row that isn't excluded recolours, and the group's new colours, take the same change after the row's new colour: saturation scales HSB saturation by 1 + saturation/100, brightness moves HSB brightness that share of the way to full or to black, temperature adds 0.2 × temperature/100 to red and takes it from blue, luminosity moves Lab lightness that share of the way to 100 or 0 keeping a and b; with limitTo each row's new colour takes the change before it snaps to the library)} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model), notChanged: {locked, hidden} (as edit.colors.invert), warning?: set when notChanged counts any object}", + "{map: rows [{from: [colour keys (recolor.colors) or \"#rrggbb\" (every colour shown as that hex)], to: colour (hex, key, {c,m,y,k} or {l,a,b}) | null (in from, to and groupColors, #rrggbbaa and #rgba are refused), exclude?: false (the row's colours are kept), tint?: 100 (percent; with limitTo, art linked to the global or spot swatch the new color is takes this share of its tint)}] | {\"key or #rrggbb\": colour, …} (one row per colour), method?: \"exact\" (default)|\"preserveTints\"|\"scaleTints\"|\"tintsShades\"|\"hueShift\" (how a row's colours take its new colour, relative to the row's darkest, average or most saturated colour), limitTo?: swatch library id or name; or \"document\" (the document's swatches): new colours snap to its nearest colour, group?: colour group to rewrite, groupColors?: [colour] (what `group` is rewritten with; default: the rows' new colours, in order, excluded rows left out), rename?: the group's new name, includeImages?: true, includePatterns?: true, globalAdjust?: {saturation?: 0, brightness?: 0, temperature?: 0, luminosity?: 0} (Global Adjust: percent from -100 to 100, 0 changes nothing; every colour a row that isn't excluded recolours, and the group's new colours, take the same change after the row's new colour: saturation scales HSB saturation by 1 + saturation/100, brightness moves HSB brightness that share of the way to full or to black, temperature adds 0.2 × temperature/100 to red and takes it from blue, luminosity moves Lab lightness that share of the way to 100 or 0 keeping a and b; with limitTo each row's new colour takes the change before it snaps to the library)} recolour the selection (gradients, text, meshes, image pixels and pattern tiles included; each new colour keeps the model of the one it replaces (model?: \"keep\" default | \"new\" (the new colour's own model, so a CMYK colour replacing RGB art gives CMYK art) | \"document\" (the document's colour mode)), except a library swatch's with limitTo: art takes the swatch itself, linked to a global or spot colour at its tints, and the swatch joins the document's swatches; art linked to a global swatch of `group` that is rewritten with its row's new color stays linked to it, at its tint, unless the row converts the color into the art's model; other recolored art, and art in a row linked to a global swatch of `group` that is rewritten with another color, keeps its row's new color and loses its swatch link) and the group, as one undo step. On a path directly in a graph series, the graph also stores the new paint of the topmost visible fill and stroke (docs/mcp.md) → {changed, swatches: [names added to the document's swatches], relinked?: with group, the other paints linked to its global swatches that took their new colors, note?: set when a colour kept a model other than its new colour's (see model), notChanged: {locked, hidden} (as edit.colors.invert), warning?: set when notChanged counts any object}", has_doc, apply ), @@ -1521,7 +1521,7 @@ fn apply(s: &mut Session, p: &Value) -> Result { .filter_map(|(id, hidden)| Some((id, shown_colors(d, d.node(id)?), hidden))) .collect(); map.converted.set(false); - let changed = Recolor::with_links(scope, &f, &lf).run(d, &ids); + let changed = keeping_series_paint(d, &ids, |d| Ok(Recolor::with_links(scope, &f, &lf).run(d, &ids)))?; let edit = group.map(|g| super::swatch::rewrite_group(d, g, &new, rename, C)).transpose()?; super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?; // Rewriting the group's swatches can change the colors of art linked to them, left out or diff --git a/crates/engine/src/cmd/stroke.rs b/crates/engine/src/cmd/stroke.rs index fe846ad8af..14c86c06ed 100644 --- a/crates/engine/src/cmd/stroke.rs +++ b/crates/engine/src/cmd/stroke.rs @@ -22,7 +22,7 @@ pub fn specs() -> Vec { "Stroke Options", ["Window", "Stroke"], None, - "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}", + "{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does). On a path directly in a graph series, the graph also stores a weight it changes on the topmost visible stroke (docs/mcp.md) → {objects: how many objects took the stroke options}", has_doc, stroke_set ), @@ -31,7 +31,7 @@ pub fn specs() -> Vec { "Stroke Weight Up", [], Some("Cmd+Alt+."), - "{ids?|id?} raise each selected stroke's weight by one step of Units › Stroke (a quarter unit below one unit, then whole units; up to 1000 pt), each from its own weight: the selected objects' active or top stroke, type's stroked characters; objects without a stroke are left alone. With nothing selected (and no ids), the weight of the next object drawn → {objects}", + "{ids?|id?} raise each selected stroke's weight by one step of Units › Stroke (a quarter unit below one unit, then whole units; up to 1000 pt), each from its own weight: the selected objects' active or top stroke, type's stroked characters; objects without a stroke are left alone. With nothing selected (and no ids), the weight of the next object drawn. On a path directly in a graph series, the graph also stores the new weight of the topmost visible stroke (docs/mcp.md) → {objects}", has_doc, |s, p| stroke_step(s, p, true) ), diff --git a/docs/mcp.md b/docs/mcp.md index 274db05a34..f2945064e1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -757,11 +757,26 @@ outline, so these commands do not store a stroke paint or weight for a series th designs keep their own paint. A row that had no paint before the command and has none after it does not change the paint the graph stores. `paint.setFill`, `paint.setStroke`, `paint.none` and the commands that apply one paint kind to the other store the topmost row of the kind -even when it is hidden, and store a stroke set on a band. `stroke.set`, `paint.editGradient` and the recolor commands do -not store a series' paint. An `appearance.*` command or `graphicStyle.apply` given a series group's id, and +even when it is hidden, and store a stroke set on a band. +An `appearance.*` command or `graphicStyle.apply` given a series group's id, and `graphicStyle.redefine` on a series group linked to the style, edit the group's own rows, and the graph does not store them. +The graph also stores a paint or stroke weight that one of these commands changes on the topmost visible fill or stroke +of a path directly in a series: a weight set with `stroke.set`, `stroke.weightUp` or `stroke.weightDown`; a gradient +made, placed or edited with `paint.editGradient`, `paint.setGradientGeom`, `paint.freeform.addPoint`, +`paint.freeform.setPoint`, `paint.freeform.deletePoint`, `paint.freeform.addLine` or `paint.freeform.splitLine`; and a +color changed by `paint.invert`, `paint.complement`, `edit.colors.invert`, `edit.colors.adjustBalance`, +`edit.colors.saturate`, `edit.colors.toCMYK`, `edit.colors.toGrayscale`, `edit.colors.toRGB`, +`edit.colors.blendFrontToBack`, `edit.colors.blendHorizontally`, `edit.colors.blendVertically`, `recolor.apply`, +`addon.color.filter`, `addon.color.applyNearestSwatch` or the ink limit of `addon.color.printFixes`. The recolor +commands store it too when they are given a series group, the graph or a group that holds the graph. A row a command +leaves as it was keeps what the graph stores. When one command changes several paths of a series, the frontmost of +them sets what the graph stores. A gradient is stored with its placement, and every path of the series takes that +placement when the graph is drawn again. The graph does not store a stroke's dashes, caps, joins, arrowheads, width +profile or brush. These commands do not store the colors they give a graph's axes and labels, which are drawn again +with the paint they had. + `text.setFormat` without `start` and `end`, on a graph or on its labels, stores the character and paragraph attributes it sets for each kind of label it reaches (value axis numbers, category labels, legend labels), and every label of that kind takes them when the graph is drawn again. The graph saves the paragraph attributes as `valuesPara`, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 086d4cfc0d..23d3c5c070 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1056,7 +1056,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Blend spine Anchor Point tool (#2544):** on a selected blend, dragging a spine corner makes symmetric Smooth handles; the tool displays and picks existing spine handles when switched to, drags one independently or retracts it on click, and clicking a point with handles retracts both. The spine is edited live without moving its key objects. Windows native canvas checks and screenshots reviewed: symmetric and independent handles, corner/retraction, Shift constraints and perpendicular reshaping, tool switching, key preservation, Undo/Redo, save/reopen, hidden handles and overlapping Path/Blend priority (PR #2643). Behaviour reference: [public path-editing documentation](https://helpx.adobe.com/illustrator/using/adjust-path-segments.html). - **Blend steps of brushed strokes:** the steps interpolate the pen pressure, tilt, bearing and rotation recorded with the Paintbrush, so a Calligraphic, Scatter or Art brush that varies with the pen changes gradually from one key to the next. A key without a recording counts as half pressure, half tilt and 0°, as brushes read it, and angles turn the shorter way round. When the blend starts a key's path at another anchor (an open path run from its end, a closed one turned to another start), the key's recordings, width profile and arrowheads turn with the path. Different brushes still switch halfway (VectorCraft's own rule; strokes blend their options, as [Blended objects overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/apply-transparency-and-blending/blended-objects-overview.html) describes; `crates/doc/src/blend.rs`, `pen_pressure_tilt_and_angles_interpolate_for_brushes`, `stroke_profiles_turn_with_a_key_path_started_elsewhere`). - **Path editing (#494):** the handles shown are the handles that drag: Direct Selection drags those of a path selected as a whole (as the Pen leaves it), the Anchor Point tool and Pen+Alt no longer grab handles that Show handles when multiple anchors are selected hides, a handle shorter than the tolerance leaves its anchor to be picked, and the anchor tools pick within Selection & Anchor Display › Tolerance. A dragged segment keeps a smooth anchor at either end smooth. With Direct Selection, the Pen, the Curvature and the anchor tools, a path selected as a whole shows all its anchors selected, and the Control bar and the Properties panel offer Convert (corner, smooth) for them too; Remove and Cut wait for anchors direct-selected (#504). A handle drag gathers its snap targets once per drag (it took 21 ms per pointer move on a 20,000-path document, now 1 ms; `vectorcraft-cli perf` measures handle and segment drags on a 5,000-anchor path). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533); Object › Path › Average & Join… (`path.averageJoin`, Cmd+Alt+Shift+J, the "Create corner or smooth join" shortcut of [Default keyboard shortcuts](https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html)) moves two open path ends to their midpoint and joins them there, a corner or smooth point as its dialog's Points choice asks, in one undo step. - - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command, the Eyedropper, a graphic style, Default Fill and Stroke or Swap Fill and Stroke, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). + - **Graphs:** Column, Stacked Column, Bar, Stacked Bar, Line, Area, Scatter, Pie and Radar graph tools with Graph Data and Graph Type. A series starts in greyscale; Group Selection's fill and stroke, the paint, weight, opacity and blend mode of a mark's top visible fill or stroke row after an Appearance command, the Eyedropper, a graphic style, Default Fill and Stroke or Swap Fill and Stroke, the paint or stroke weight a stroke weight, gradient or recolor command changes there, and a mark's object opacity and blend mode, are kept separately on the graph and reapplied when its data, type or size changes, and through save, open and undo. Graph Type's Override Calculated Values (both `axisMin` and `axisMax`) uses exactly that value-axis range, split into the tick count (5 when automatic). Line graphs take Edge-to-Edge Lines (off by default: the points sit at the centres of their categories; graphs saved before the option keep their edge-to-edge lines), and radar graphs follow Mark Data Points and Connect Data Points. Blank cells (an empty CSV cell or `null`) are values left out, not zeros: columns, bars and stacked segments skip them, lines and scatter lines break around them and the value axis ignores them, while area, pie and radar draw them as 0; a number in straight quotes is a label, and the data window writes it back quoted. Graph types combine: Graph Type on series picked with Group Selection (or `series` indexes) draws them as their own type, with column, stacked column, line and area mixing on the vertical value axis and bar and stacked bar on the horizontal one; stacked columns take one slot of the cluster, lines draw in front of columns and areas behind. The value axis goes on the left, the right or both sides (column, stacked column, line, area and scatter graphs; bar graphs keep theirs along the bottom), and Separate Scales gives the series assigned to the right axis a scale of their own with its own tick values. Each value axis takes tick marks of no, short or full (across the plot) length and a count per division, and a prefix and suffix for its numbers; the category axis takes tick marks too (none by default), at or between its labels. Graph designs: Object › Graph › Design lists the designs with a preview of the selected one, and New Design (the selected art), Rename, Delete Design, Paste Design (a copy back to edit) and Select Unused, and Marker draws the data points and legend swatches of line, scatter and radar series with a design, scaled so its backmost object fills the default marker's square. Graph Data transposes rows and columns and switches a scatter graph's x and y, and a `|` in a category or series label starts a new line. Pie graphs take Legends in Wedges, Position (Even, Ratio sized by area, Stacked on one centre) and Sort (None, All, First). Graph Type takes column and cluster widths up to 1000% (overlapping past 100%) with First Row and First Column in Front (First Row orders each series' categories; across series the series order decides, so series stay one group each for Group Selection), draws filled lines, adds drop shadows behind columns, bars, lines and wedges, and puts the legend in rows across the top. Text formatted with the Character, Paragraph or Appearance controls, on the whole graph or on labels picked with Group Selection, is kept per kind of label (value axis numbers, category labels, legend labels) through the graph being drawn again. Object › Graph › Data opens a worksheet: cells picked with a click and edited above the sheet (Tab moves right, Enter down, the arrows between cells), Import Data from a tab-separated text file, Transpose, Switch x/y, Cell Style (decimals shown, column width), Revert and Apply. `graph.setData` returns and takes the data as CSV with each value written exactly, not rounded to two decimals, so applying the returned CSV changes nothing (#1485). - **Graph column designs:** Object › Graph › Column (`graph.column`) draws the columns, bars or stacked segments and the legend swatch of the series picked with Group Selection with a graph design: Vertically Scaled stretches it to each column's length, Uniformly Scaled scales it both ways and centers it, Repeating stacks a copy for each Each Design Represents value and chops or scales the last one (For Fractions), and Sliding keeps the part above the design's guide line in proportion and stretches the part below. The dialog lists the designs with a preview; the choice is saved with the graph and follows the design's rename and delete. Type in a column design that holds a `%` followed by two digits shows each column's value in place of the `%` and the digits: the first digit is the least number of places before the decimal point (zeros are added in front; 0 gives as many as the value needs), and the second digit sets the places after it, rounded. Unless it is inside a group that clips or has its own opacity or appearance, or inside a blend, compound shape, envelope, repeat or intertwined objects, the type keeps its size and is drawn once per column in a repeating design. The legend swatch leaves it out. - **Other:** Eyedropper, Gradient annotator, Artboard (Alt-drag duplicates an artboard, and its art with Move/Copy Artwork with Artboard on; Copy, Cut and Paste take the artboard with its art, Paste right of the last artboard, Paste in Place where it was, into this or another document, #446; Object › Artboards › Rearrange All Artboards lays them out in a grid by row or by column, in one row or one column, left to right or right to left, from the menu, the Artboards panel or Properties' Rearrange All, #681; Scale Artwork with Artboard, in the Control bar and Properties beside Move Artwork with Artboard, resizes the artboard proportionally with the art fully inside it and its guides, strokes and corners per the Scale preferences, #602), Measure, Type, Hand, Zoom, Rotate View. From 54b4addcebb32b8ba569e56b587dfd98baa7e8e2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 05:12:09 -0800 Subject: [PATCH 07/56] Actions: a command that changes a dragged step's action ends the drag A drag of a step row kept the action's id and the step's index. A command that ran while the pointer was held could put another step at that index, and the release then moved that step. The drag now keeps the action's steps and excluded steps as they were when it started. It ends without a move when a command changes either, or deletes the action. A command on another action leaves the drag as it is. --- crates/ui-egui/src/panels/actions.rs | 71 +++++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 361f7c42ba..80f3397696 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -939,10 +939,13 @@ pub const INSERT_STOP: &str = "ui.action.insertStop"; /// The panel state that holds the selected step as (the action's [`Action::id`], the step's index). const SELECTED_STEP: &str = "actions-selected-step"; -/// The panel state that holds the step being dragged as (the action's [`Action::id`], the step's -/// index). +/// The panel state that holds the step being dragged as a [`DraggedStep`]. const DRAGGED_STEP: &str = "actions-dragged-step"; +/// A step being dragged: the action's [`Action::id`], the step's index, and the action's steps and +/// excluded steps as they were when the drag started. +type DraggedStep = (u64, usize, std::sync::Arc<(Vec<(String, Value)>, BTreeSet)>); + /// `ui.action.insertStop {set?, action?, step?, message?, allowContinue?}`: inserts a stop. With `set` /// and `action`, it goes into that action, after step `step`, else after its last step. Without /// them, it goes into the recording in progress, after the steps recorded so far. `message` is @@ -2100,8 +2103,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { let mut expanded: BTreeSet = pstate(ui.ctx(), "actions-expanded"); let mut inclusion_changes = Vec::new(); let mut play_req = None; - // The step being dragged, as (its action's id, the step), from the frame its drag started. - let dragging: Option<(u64, usize)> = pstate(ui.ctx(), DRAGGED_STEP); + // The step being dragged, from the frame its drag started. The drag ends without a move when a + // command since then changed its action's steps or which of them are excluded, or deleted the + // action. + let dragging = pstate::>(ui.ctx(), DRAGGED_STEP).filter(|(id, _, was)| { + let action = app.ui.action_sets.iter().flat_map(|s| &s.actions).find(|a| a.id == *id); + action.is_some_and(|a| (&a.steps, &a.excluded_steps) == (&was.0, &was.1)) + }); let mut drag_started = None; let mut drag_stopped = false; // The rows a dragged step can be dropped on: (row, set, action, where it goes on the row's upper @@ -2202,7 +2210,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { let (r, resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 20.0), egui::Sense::click_and_drag()); slots.push((r, si, ai, step, step + 1)); if resp.drag_started() { - drag_started = Some((a.id, step)); + drag_started = Some((a.id, step, std::sync::Arc::new((a.steps.clone(), a.excluded_steps.clone())))); } drag_stopped |= resp.drag_stopped(); if step_selected == Some((a.id, step)) { @@ -2253,7 +2261,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) { } }); // A released drag moves the step once, to where the line showed. - let move_req = drag_stopped.then_some(dragging.zip(drop)).flatten().and_then(|((id, step), (si, ai, before))| { + let dragged = dragging.as_ref().map(|(id, step, _)| (*id, *step)); + let move_req = drag_stopped.then_some(dragged.zip(drop)).flatten().and_then(|((id, step), (si, ai, before))| { let (fs, fa) = app.ui.action_sets.iter().enumerate().find_map(|(fs, s)| Some((fs, s.actions.iter().position(|a| a.id == id)?)))?; Some(json!({"set": fs, "action": fa, "step": step, "toSet": si, "toAction": ai, "before": before})) }); @@ -5938,7 +5947,55 @@ mod tests { } assert_eq!(step_names(&app, 0, 0), ["object.move 1", "object.move 2", "object.move 4"], "the step stays where it was"); assert_eq!(undo_steps(&app), undo, "no undo step"); - assert_eq!(pstate::>(&ctx, DRAGGED_STEP), None, "the drag ends"); + assert_eq!(pstate::>(&ctx, DRAGGED_STEP), None, "the drag ends"); + } + + #[test] + fn drag_step_ends_without_a_move_when_a_command_changes_its_action() { + use egui::{Event, PointerButton}; + let (mut app, _) = app_with_three_moves(); + let rotate = |angle: u32| ("object.rotate".to_string(), json!({"angle": angle})); + app.ui.action_sets[0].actions.push(Action::new("Turns", vec![rotate(5), rotate(6)])); + app.ui.action_set = Some(0); + let ctx = egui::Context::default(); + app.ui_ctx = Some(ctx.clone()); + set_pstate(&ctx, "actions-expanded", BTreeSet::from([app.ui.action_sets[0].actions[0].id])); + let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], show); + let mut rows: Vec = texts.iter().filter(|(s, _)| s == "object.move").map(|(_, r)| *r).collect(); + rows.sort_by(|a, b| a.top().total_cmp(&b.top())); + let (from, to) = (rows[0].center(), egui::pos2(rows[0].center().x, rows[1].center().y + 4.0)); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui)); + out.textures_delta.clear(); + }; + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + let start_drag = |app: &mut VectorcraftApp| { + frame(app, vec![button(from, true)]); + frame(app, vec![Event::PointerMoved(from + egui::vec2(0.0, 12.0))]); + }; + // A command on another action leaves the drag as it is. + start_drag(&mut app); + app.run(DELETE_STEP, json!({"set": 0, "action": 1, "step": 0})).unwrap(); + frame(&mut app, vec![Event::PointerMoved(to)]); + frame(&mut app, vec![button(to, false)]); + assert_eq!(step_names(&app, 0, 0), ["object.move 2", "object.move 1", "object.move 4"], "the dragged step moves"); + // A command that deletes the dragged step's action's first step puts another step at the + // dragged index. + start_drag(&mut app); + app.run(DELETE_STEP, json!({"set": 0, "action": 0, "step": 0})).unwrap(); + frame(&mut app, vec![Event::PointerMoved(to)]); + assert_eq!(pstate::>(&ctx, DRAGGED_STEP), None, "the drag ends"); + frame(&mut app, vec![button(to, false)]); + assert_eq!(step_names(&app, 0, 0), ["object.move 1", "object.move 4"], "no other step moves"); + // Two equal steps, one excluded, that change places put the other one at the dragged index. + let same = ("object.move".to_string(), json!({"dx": 1, "dy": 0})); + app.ui.action_sets[0].actions[0].steps = vec![same.clone(), same]; + app.ui.action_sets[0].actions[0].excluded_steps = BTreeSet::from([1]); + start_drag(&mut app); + app.run(MOVE_STEP, json!({"set": 0, "action": 0, "step": 1, "before": 0})).unwrap(); + frame(&mut app, vec![Event::PointerMoved(to)]); + frame(&mut app, vec![button(to, false)]); + assert_eq!(app.ui.action_sets[0].actions[0].excluded_steps, BTreeSet::from([0]), "the excluded step stays first"); } #[test] From 4c93c04213b4bbd3b5f4d429bebd63f9913fde92 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 05:27:22 -0800 Subject: [PATCH 08/56] Brushes: built-in library panels show brush names in the UI language A built-in brush library's panel now shows each brush's name in the UI language in its list rows and tooltips, and Find matches either the shown name or the English name. User Defined and Other Library... libraries, and the document's Brushes panel, keep names as stored. A click, a drag, Add to Brushes and brush.addFromLibrary still use the English name, so the brush comes into the document under that name. The shared library panel gets a label hook for this, and its rows take the library id. The 16 built-in brush names that had no catalog row (Notched Ribbon, Double Line, Wave, Teardrop, Striped Ribbon, Fine Nib, Broad Nib, Chisel, Round Pen, Pressure Pen, Beads, Dash Dot, Scallops, Stars, Triangles, Sparkles) get a row in each of the 27 catalogs, and a test requires a row for every built-in brush name. --- crates/ui-egui/src/i18n/bg.tsv | 16 +++++++ crates/ui-egui/src/i18n/bs.tsv | 16 +++++++ crates/ui-egui/src/i18n/ca.tsv | 16 +++++++ crates/ui-egui/src/i18n/cs.tsv | 16 +++++++ crates/ui-egui/src/i18n/da.tsv | 16 +++++++ crates/ui-egui/src/i18n/de.tsv | 16 +++++++ crates/ui-egui/src/i18n/el.tsv | 16 +++++++ crates/ui-egui/src/i18n/es.tsv | 16 +++++++ crates/ui-egui/src/i18n/fi.tsv | 16 +++++++ crates/ui-egui/src/i18n/fr.tsv | 16 +++++++ crates/ui-egui/src/i18n/hi.tsv | 16 +++++++ crates/ui-egui/src/i18n/hu.tsv | 16 +++++++ crates/ui-egui/src/i18n/id.tsv | 16 +++++++ crates/ui-egui/src/i18n/it.tsv | 16 +++++++ crates/ui-egui/src/i18n/ja.tsv | 16 +++++++ crates/ui-egui/src/i18n/ka.tsv | 16 +++++++ crates/ui-egui/src/i18n/ko.tsv | 16 +++++++ crates/ui-egui/src/i18n/nl.tsv | 16 +++++++ crates/ui-egui/src/i18n/pl.tsv | 16 +++++++ crates/ui-egui/src/i18n/pt-br.tsv | 16 +++++++ crates/ui-egui/src/i18n/ru.tsv | 16 +++++++ crates/ui-egui/src/i18n/sv.tsv | 16 +++++++ crates/ui-egui/src/i18n/tests.rs | 24 ++++++++++ crates/ui-egui/src/i18n/tr.tsv | 16 +++++++ crates/ui-egui/src/i18n/uk.tsv | 16 +++++++ crates/ui-egui/src/i18n/vi.tsv | 16 +++++++ crates/ui-egui/src/i18n/zh-hans.tsv | 16 +++++++ crates/ui-egui/src/i18n/zh-hant.tsv | 16 +++++++ crates/ui-egui/src/panels/brushes.rs | 10 +++- crates/ui-egui/src/panels/graphic_styles.rs | 2 +- crates/ui-egui/src/panels/library_panel.rs | 18 +++++--- .../ui-egui/src/panels/library_panel/tests.rs | 2 +- .../ui-egui/src/panels/swatches/libraries.rs | 2 +- crates/ui-egui/src/panels/symbols.rs | 2 +- .../src/panels/tests_brush_libraries.rs | 46 ++++++++++++++++++- .../src/panels/tests_style_libraries.rs | 4 +- .../src/panels/tests_symbol_libraries.rs | 2 +- docs/target-app-parity.md | 1 + 38 files changed, 528 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 3828d9ba2d..c26330b619 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6148,6 +6148,22 @@ axis Y Axis Y ос Graphite Pencil Графитен молив Wax Crayon Восъчен пастел Charcoal Dust Въглен прах + Notched Ribbon Назъбена лента + Double Line Двойна линия + Wave Вълна + Teardrop Сълза + Striped Ribbon Раирана лента + Fine Nib Тънък писец + Broad Nib Широк писец + Chisel Длето + Round Pen Кръгло перо + Pressure Pen Перо с натиск + Beads Мъниста + Dash Dot Тире-точка + Scallops Фестони + Stars Звезди + Triangles Триъгълници + Sparkles Искри Save Brush Library… Запази библиотека с четки… @msg no empty artboards to delete няма празни работни области за изтриване Scale Presets Пресети за мащаб diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 81fee3a079..89d900ea5b 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6201,6 +6201,22 @@ axis Y Axis Y osa Graphite Pencil Grafitna olovka Wax Crayon Voštana bojica Charcoal Dust Ugljena prašina + Notched Ribbon Zarezana traka + Double Line Dvostruka linija + Wave Talas + Teardrop Suza + Striped Ribbon Prugasta traka + Fine Nib Tanki vrh + Broad Nib Široki vrh + Chisel Dlijeto + Round Pen Okruglo pero + Pressure Pen Pero na pritisak + Beads Perle + Dash Dot Crta-tačka + Scallops Festoni + Stars Zvijezde + Triangles Trouglovi + Sparkles Iskrice Save Brush Library… Sačuvaj biblioteku četkica… @msg `{_1}` has no brushes to apply `{_1}` nema četkica za primjenu @msg missing `library` (see brush.libraries) nedostaje `library` (pogledajte brush.libraries) diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 02ae0f4a09..359b2bb46f 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3917,6 +3917,22 @@ digits Malayalam Malaiàlam Graphite Pencil Llapis de grafit Wax Crayon Cera de colors Charcoal Dust Pols de carbó + Notched Ribbon Cinta dentada + Double Line Línia doble + Wave Ona + Teardrop Llàgrima + Striped Ribbon Cinta ratllada + Fine Nib Plomí fi + Broad Nib Plomí ample + Chisel Escarpra + Round Pen Ploma rodona + Pressure Pen Ploma de pressió + Beads Perles + Dash Dot Ratlla i punt + Scallops Festons + Stars Estrelles + Triangles Triangles + Sparkles Espurnes Scatter: Dispersió: Scissors Tisores Scissors Tool Eina de tisores diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 7e3794e8a1..0eea65f2d9 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -6694,6 +6694,22 @@ axis Y Axis osa Y Graphite Pencil Grafitová tužka Wax Crayon Vosková pastelka Charcoal Dust Uhlový prach + Notched Ribbon Vroubkovaná stuha + Double Line Dvojitá čára + Wave Vlna + Teardrop Slza + Striped Ribbon Pruhovaná stuha + Fine Nib Jemný hrot + Broad Nib Široký hrot + Chisel Dláto + Round Pen Kulaté pero + Pressure Pen Tlakové pero + Beads Korálky + Dash Dot Čárka-tečka + Scallops Feston + Stars Hvězdy + Triangles Trojúhelníky + Sparkles Jiskry Save Brush Library… Uložit knihovnu štětců… @msg `{_1}` has no brushes to apply `{_1}` nemá žádné štětce k použití @msg missing `library` (see brush.libraries) chybí `library` (viz brush.libraries) diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index f7011e23bf..987c765dbd 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6165,6 +6165,22 @@ axis Y Axis Y-akse Graphite Pencil Grafitblyant Wax Crayon Vokskridt Charcoal Dust Kulstøv + Notched Ribbon Takket bånd + Double Line Dobbelt linje + Wave Bølge + Teardrop Dråbe + Striped Ribbon Stribet bånd + Fine Nib Fin pen + Broad Nib Bred pen + Chisel Mejsel + Round Pen Rund pen + Pressure Pen Trykpen + Beads Perler + Dash Dot Streg-prik + Scallops Bølgekant + Stars Stjerner + Triangles Trekanter + Sparkles Gnister Save Brush Library… Gem penselbibliotek… @msg no empty artboards to delete ingen tomme tegneflader at slette Scale Presets Skaleringsforudindstillinger diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 4174537f87..c95a4e9742 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -6285,6 +6285,22 @@ axis Y Axis Y-Achse Graphite Pencil Graphitstift Wax Crayon Wachsmalstift Charcoal Dust Kohlestaub + Notched Ribbon Gekerbtes Band + Double Line Doppellinie + Wave Welle + Teardrop Tropfen + Striped Ribbon Gestreiftes Band + Fine Nib Feine Feder + Broad Nib Breite Feder + Chisel Meißel + Round Pen Runde Feder + Pressure Pen Druckfeder + Beads Perlen + Dash Dot Strich-Punkt + Scallops Bogenkante + Stars Sterne + Triangles Dreiecke + Sparkles Funkeln Save Brush Library… Pinsel-Bibliothek speichern… @msg `{_1}` has no brushes to apply `{_1}` hat keine Pinsel zum Anwenden @msg missing `library` (see brush.libraries) `library` fehlt (siehe brush.libraries) diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index bde2fbee78..0e78ec9ed6 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -6645,6 +6645,22 @@ axis Y Axis Άξονας Y Graphite Pencil Μολύβι γραφίτη Wax Crayon Κηρομπογιά Charcoal Dust Σκόνη κάρβουνου + Notched Ribbon Οδοντωτή κορδέλα + Double Line Διπλή γραμμή + Wave Κύμα + Teardrop Δάκρυ + Striped Ribbon Ριγέ κορδέλα + Fine Nib Λεπτή πένα + Broad Nib Φαρδιά πένα + Chisel Σμίλη + Round Pen Στρογγυλή πένα + Pressure Pen Πένα πίεσης + Beads Χάντρες + Dash Dot Παύλα-τελεία + Scallops Φεστόνια + Stars Αστέρια + Triangles Τρίγωνα + Sparkles Σπίθες Save Brush Library… Αποθήκευση βιβλιοθήκης πινέλων… @msg `{_1}` has no brushes to apply Η `{_1}` δεν έχει πινέλα για εφαρμογή @msg missing `library` (see brush.libraries) λείπει το `library` (δείτε brush.libraries) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index ff28d9dfc5..e4a9897be5 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -6636,6 +6636,22 @@ axis Y Axis Eje Y Graphite Pencil Lápiz de grafito Wax Crayon Cera de colores Charcoal Dust Polvo de carboncillo + Notched Ribbon Cinta dentada + Double Line Línea doble + Wave Onda + Teardrop Lágrima + Striped Ribbon Cinta a rayas + Fine Nib Plumilla fina + Broad Nib Plumilla ancha + Chisel Cincel + Round Pen Pluma redonda + Pressure Pen Pluma de presión + Beads Cuentas + Dash Dot Raya y punto + Scallops Festones + Stars Estrellas + Triangles Triángulos + Sparkles Destellos Save Brush Library… Guardar biblioteca de pinceles… @msg `{_1}` has no brushes to apply `{_1}` no tiene pinceles para aplicar @msg missing `library` (see brush.libraries) falta `library` (consulte brush.libraries) diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index bb74188ed8..ba201a9e75 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -6724,6 +6724,22 @@ axis Y Axis Y-akseli Graphite Pencil Grafiittikynä Wax Crayon Vahaliitu Charcoal Dust Hiilipöly + Notched Ribbon Lovettu nauha + Double Line Kaksoisviiva + Wave Aalto + Teardrop Pisara + Striped Ribbon Raidallinen nauha + Fine Nib Ohut kärki + Broad Nib Leveä kärki + Chisel Taltta + Round Pen Pyöreä kynä + Pressure Pen Painekynä + Beads Helmet + Dash Dot Viiva-piste + Scallops Kaarireunus + Stars Tähdet + Triangles Kolmiot + Sparkles Kipinät Save Brush Library… Tallenna sivellinkirjasto… @msg `{_1}` has no brushes to apply Kohteessa `{_1}` ei ole käytettäviä siveltimiä @msg missing `library` (see brush.libraries) `library` puuttuu (katso brush.libraries) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 09c9c35b0d..ed33a6f10e 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -6689,6 +6689,22 @@ axis Y Axis Axe Y Graphite Pencil Crayon graphite Wax Crayon Craie grasse Charcoal Dust Poussière de fusain + Notched Ribbon Ruban cranté + Double Line Double ligne + Wave Vague + Teardrop Goutte + Striped Ribbon Ruban rayé + Fine Nib Plume fine + Broad Nib Plume large + Chisel Ciseau + Round Pen Plume ronde + Pressure Pen Plume à pression + Beads Perles + Dash Dot Tiret-point + Scallops Festons + Stars Étoiles + Triangles Triangles + Sparkles Étincelles Save Brush Library… Enregistrer la bibliothèque de formes… @msg `{_1}` has no brushes to apply `{_1}` n'a aucune forme à appliquer @msg missing `library` (see brush.libraries) `library` manquant (voir brush.libraries) diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 9aa4bd7b99..05c6d1e574 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -444,6 +444,22 @@ antiAlias Sharp शार्प Graphite Pencil ग्रेफाइट पेंसिल Wax Crayon मोम क्रेयॉन Charcoal Dust चारकोल धूल + Notched Ribbon नोकदार रिबन + Double Line दोहरी लाइन + Wave लहर + Teardrop आंसू की बूंद + Striped Ribbon धारीदार रिबन + Fine Nib बारीक निब + Broad Nib चौड़ी निब + Chisel छेनी + Round Pen गोल पेन + Pressure Pen प्रेशर पेन + Beads मोती + Dash Dot डैश डॉट + Scallops स्कैलप + Stars तारे + Triangles त्रिभुज + Sparkles चमक Art using a deleted global swatch keeps its colour. हटाए गए ग्लोबल स्वॉच वाला आर्टवर्क अपना रंग बनाए रखता है। Artboard आर्टबोर्ड Artboard {n} of {total} {total} में से आर्टबोर्ड {n} diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index af6f586880..d2387475df 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -6677,6 +6677,22 @@ axis Y Axis Y tengely Graphite Pencil Grafitceruza Wax Crayon Zsírkréta Charcoal Dust Szénpor + Notched Ribbon Csipkézett szalag + Double Line Dupla vonal + Wave Hullám + Teardrop Csepp + Striped Ribbon Csíkos szalag + Fine Nib Finom tollhegy + Broad Nib Széles tollhegy + Chisel Véső + Round Pen Kerek toll + Pressure Pen Nyomásérzékeny toll + Beads Gyöngyök + Dash Dot Pont-vonal + Scallops Hullámos szegély + Stars Csillagok + Triangles Háromszögek + Sparkles Szikrák Save Brush Library… Ecsetkönyvtár mentése… @msg `{_1}` has no brushes to apply A(z) `{_1}` nem tartalmaz alkalmazható ecsetet @msg missing `library` (see brush.libraries) hiányzik a `library` (lásd: brush.libraries) diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index e0ba31ecd5..a9e3010861 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6165,6 +6165,22 @@ axis Y Axis Sumbu Y Graphite Pencil Pensil grafit Wax Crayon Krayon lilin Charcoal Dust Debu arang + Notched Ribbon Pita bertakik + Double Line Garis ganda + Wave Gelombang + Teardrop Tetesan + Striped Ribbon Pita bergaris + Fine Nib Mata pena halus + Broad Nib Mata pena lebar + Chisel Pahat + Round Pen Pena bulat + Pressure Pen Pena tekanan + Beads Manik-manik + Dash Dot Garis titik + Scallops Lekuk kerang + Stars Bintang + Triangles Segitiga + Sparkles Kilauan Save Brush Library… Simpan pustaka kuas… @msg no empty artboards to delete tidak ada artboard kosong untuk dihapus Scale Presets Preset skala diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index ce0bc58ce5..64271b143a 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -6319,6 +6319,22 @@ axis Y Axis Asse Y Graphite Pencil Matita di grafite Wax Crayon Pastello a cera Charcoal Dust Polvere di carboncino + Notched Ribbon Nastro dentellato + Double Line Doppia linea + Wave Onda + Teardrop Goccia + Striped Ribbon Nastro a righe + Fine Nib Pennino fine + Broad Nib Pennino largo + Chisel Scalpello + Round Pen Penna tonda + Pressure Pen Penna a pressione + Beads Perline + Dash Dot Tratto-punto + Scallops Smerlo + Stars Stelle + Triangles Triangoli + Sparkles Scintille Save Brush Library… Salva libreria pennelli… @msg `{_1}` has no brushes to apply `{_1}` non ha pennelli da applicare @msg missing `library` (see brush.libraries) manca `library` (vedi brush.libraries) diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1ecce4618a..89c345e3ad 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -6384,6 +6384,22 @@ axis Y Axis Y 軸 Graphite Pencil 黒鉛鉛筆 Wax Crayon ワックスクレヨン Charcoal Dust 木炭の粉 + Notched Ribbon ノッチリボン + Double Line 二重線 + Wave 波 + Teardrop しずく + Striped Ribbon ストライプリボン + Fine Nib 細いペン先 + Broad Nib 太いペン先 + Chisel 平ノミ + Round Pen 丸ペン + Pressure Pen 筆圧ペン + Beads ビーズ + Dash Dot 一点鎖線 + Scallops スカラップ + Stars 星 + Triangles 三角形 + Sparkles きらめき Save Brush Library… ブラシライブラリを保存… @msg `{_1}` has no brushes to apply `{_1}` に適用できるブラシがありません @msg missing `library` (see brush.libraries) `library` がありません (brush.libraries を参照) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 3d6425c373..ac208e54f9 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -6645,6 +6645,22 @@ axis Y Axis Y ღერძი Graphite Pencil გრაფიტის ფანქარი Wax Crayon ცვილის ფანქარი Charcoal Dust ნახშირის მტვერი + Notched Ribbon დაკბილული ლენტი + Double Line ორმაგი ხაზი + Wave ტალღა + Teardrop წვეთი + Striped Ribbon ზოლიანი ლენტი + Fine Nib წვრილი წვერი + Broad Nib განიერი წვერი + Chisel საჭრეთელი + Round Pen მრგვალი კალამი + Pressure Pen დაწოლის კალამი + Beads მძივები + Dash Dot ტირე-წერტილი + Scallops ფესტონები + Stars ვარსკვლავები + Triangles სამკუთხედები + Sparkles ნაპერწკლები Save Brush Library… ფუნჯების ბიბლიოთეკის შენახვა… @msg `{_1}` has no brushes to apply `{_1}`-ში გამოსაყენებელი ფუნჯები არ არის @msg missing `library` (see brush.libraries) `library` აკლია (იხ. brush.libraries) diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index fb87488d64..f8e452c03e 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -6280,6 +6280,22 @@ axis Y Axis Y축 Graphite Pencil 흑연 연필 Wax Crayon 왁스 크레용 Charcoal Dust 목탄 가루 + Notched Ribbon 노치 리본 + Double Line 이중선 + Wave 물결 + Teardrop 눈물방울 + Striped Ribbon 줄무늬 리본 + Fine Nib 가는 펜촉 + Broad Nib 넓은 펜촉 + Chisel 끌 + Round Pen 둥근 펜 + Pressure Pen 필압 펜 + Beads 구슬 + Dash Dot 일점쇄선 + Scallops 스캘럽 + Stars 별 + Triangles 삼각형 + Sparkles 반짝임 Save Brush Library… 브러시 라이브러리 저장… @msg `{_1}` has no brushes to apply `{_1}`에 적용할 브러시가 없습니다 @msg missing `library` (see brush.libraries) `library`가 없습니다(brush.libraries 참조) diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index b3388fa82a..6d42141cdf 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6216,6 +6216,22 @@ axis Y Axis Y-as Graphite Pencil Grafietpotlood Wax Crayon Waskrijt Charcoal Dust Houtskoolstof + Notched Ribbon Gekerfd lint + Double Line Dubbele lijn + Wave Golf + Teardrop Traan + Striped Ribbon Gestreept lint + Fine Nib Fijne penpunt + Broad Nib Brede penpunt + Chisel Beitel + Round Pen Ronde pen + Pressure Pen Drukpen + Beads Kralen + Dash Dot Streep-punt + Scallops Schulprand + Stars Sterren + Triangles Driehoeken + Sparkles Fonkelingen Save Brush Library… Penseelbibliotheek opslaan… @msg no empty artboards to delete geen lege tekengebieden om te verwijderen Scale Presets Schaalvoorinstellingen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 0729e35c79..1bab8829d6 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -6548,6 +6548,22 @@ axis Y Axis Oś Y Graphite Pencil Ołówek grafitowy Wax Crayon Kredka woskowa Charcoal Dust Pył węglowy + Notched Ribbon Wstęga z nacięciami + Double Line Podwójna linia + Wave Fala + Teardrop Łza + Striped Ribbon Wstęga w paski + Fine Nib Cienka stalówka + Broad Nib Szeroka stalówka + Chisel Dłuto + Round Pen Okrągłe pióro + Pressure Pen Pióro z naciskiem + Beads Koraliki + Dash Dot Kreska-kropka + Scallops Feston + Stars Gwiazdy + Triangles Trójkąty + Sparkles Iskry Save Brush Library… Zapisz bibliotekę pędzli… @msg `{_1}` has no brushes to apply `{_1}` nie ma pędzli do zastosowania @msg missing `library` (see brush.libraries) brak `library` (zobacz brush.libraries) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 36f778dbd5..1b91e4601c 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -6609,6 +6609,22 @@ axis Y Axis Eixo Y Graphite Pencil Lápis de Grafite Wax Crayon Giz de Cera Charcoal Dust Pó de Carvão + Notched Ribbon Fita Entalhada + Double Line Linha Dupla + Wave Onda + Teardrop Lágrima + Striped Ribbon Fita Listrada + Fine Nib Pena Fina + Broad Nib Pena Larga + Chisel Cinzel + Round Pen Pena Redonda + Pressure Pen Pena de Pressão + Beads Contas + Dash Dot Traço-Ponto + Scallops Festões + Stars Estrelas + Triangles Triângulos + Sparkles Brilhos Save Brush Library… Salvar Biblioteca de Pincéis… @msg `{_1}` has no brushes to apply `{_1}` não tem pincéis para aplicar @msg missing `library` (see brush.libraries) falta `library` (consulte brush.libraries) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 442a9612d4..5bb9f3345c 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -6305,6 +6305,22 @@ axis Y Axis Ось Y Graphite Pencil Графитный карандаш Wax Crayon Восковой мелок Charcoal Dust Угольная пыль + Notched Ribbon Лента с насечками + Double Line Двойная линия + Wave Волна + Teardrop Капля + Striped Ribbon Полосатая лента + Fine Nib Тонкое перо + Broad Nib Широкое перо + Chisel Стамеска + Round Pen Круглое перо + Pressure Pen Перо с нажимом + Beads Бусины + Dash Dot Штрихпунктир + Scallops Фестоны + Stars Звёзды + Triangles Треугольники + Sparkles Искры Save Brush Library… Сохранить библиотеку кистей… @msg `{_1}` has no brushes to apply В `{_1}` нет кистей для применения @msg missing `library` (see brush.libraries) не указан `library` (см. brush.libraries) diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 8a8f8453b6..97c9f99ccd 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -6675,6 +6675,22 @@ axis Y Axis Y-axel Graphite Pencil Grafitpenna Wax Crayon Vaxkrita Charcoal Dust Koldamm + Notched Ribbon Hackat band + Double Line Dubbellinje + Wave Våg + Teardrop Droppe + Striped Ribbon Randigt band + Fine Nib Fin spets + Broad Nib Bred spets + Chisel Mejsel + Round Pen Rund penna + Pressure Pen Tryckpenna + Beads Pärlor + Dash Dot Streck-punkt + Scallops Bågkant + Stars Stjärnor + Triangles Trianglar + Sparkles Gnistor Save Brush Library… Spara penselbibliotek… @msg `{_1}` has no brushes to apply `{_1}` har inga penslar att använda @msg missing `library` (see brush.libraries) `library` saknas (se brush.libraries) diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 66caa1c3a4..fa2c034d09 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -198,6 +198,30 @@ fn built_in_brush_library_names_are_in_complete_catalogs() { assert!(bad.is_empty(), "{}", bad.join("\n")); } +/// A built-in brush library's panel lists its brushes in the UI language, so every brush of every +/// built-in library has a row in each complete catalog. +#[test] +fn built_in_library_brush_names_are_in_complete_catalogs() { + use vectorcraft_engine::cmd::brushlib; + let session = vectorcraft_engine::Session::new(); + let mut names: Vec = brushlib::BUILTIN + .iter() + .filter_map(|b| brushlib::library(&session, b.id)) + .flat_map(|(_, lib)| lib.brushes.iter().map(|b| b.name.clone()).collect::>()) + .collect(); + names.sort(); + names.dedup(); + assert_eq!(names.len(), 43, "{names:?}"); + let mut bad = vec![]; + for lang in LANGUAGES.iter().filter(|lang| lang.complete_menus) { + let missing: Vec<&String> = names.iter().filter(|n| lang.catalog().plain(n).is_none()).collect(); + if !missing.is_empty() { + bad.push(format!("{} is missing {missing:?}", lang.code)); + } + } + assert!(bad.is_empty(), "{}", bad.join("\n")); +} + /// Preference choices and the marquee's E hint share the same translated labels. #[test] fn marquee_selection_labels_are_in_complete_catalogs() { diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index ef8773c31b..84902b0658 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -6650,6 +6650,22 @@ axis Y Axis Y ekseni Graphite Pencil Grafit Kalem Wax Crayon Mum Boya Charcoal Dust Kömür Tozu + Notched Ribbon Çentikli Şerit + Double Line Çift Çizgi + Wave Dalga + Teardrop Gözyaşı + Striped Ribbon Çizgili Şerit + Fine Nib İnce Uç + Broad Nib Geniş Uç + Chisel Keski + Round Pen Yuvarlak Kalem + Pressure Pen Basınçlı Kalem + Beads Boncuklar + Dash Dot Çizgi Nokta + Scallops Fistolar + Stars Yıldızlar + Triangles Üçgenler + Sparkles Parıltılar Save Brush Library… Fırça Kitaplığını Kaydet… @msg `{_1}` has no brushes to apply `{_1}` içinde uygulanacak fırça yok @msg missing `library` (see brush.libraries) `library` eksik (bkz. brush.libraries) diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0bccbd8cb6..c27838be3a 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6299,6 +6299,22 @@ axis Y Axis Вісь Y Graphite Pencil Графітний олівець Wax Crayon Восковий олівець Charcoal Dust Вугільний пил + Notched Ribbon Стрічка з насічками + Double Line Подвійна лінія + Wave Хвиля + Teardrop Крапля + Striped Ribbon Смугаста стрічка + Fine Nib Тонке перо + Broad Nib Широке перо + Chisel Стамеска + Round Pen Кругле перо + Pressure Pen Перо з натиском + Beads Намистини + Dash Dot Штрихпунктир + Scallops Фестони + Stars Зірки + Triangles Трикутники + Sparkles Іскри Save Brush Library… Зберегти бібліотеку пензлів… @msg `{_1}` has no brushes to apply У `{_1}` немає пензлів для застосування @msg missing `library` (see brush.libraries) не вказано `library` (див. brush.libraries) diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 4f084029db..d3ca07a4ac 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6300,6 +6300,22 @@ axis Y Axis Trục Y Graphite Pencil Bút chì than chì Wax Crayon Bút sáp màu Charcoal Dust Bụi than + Notched Ribbon Dải băng khía + Double Line Đường đôi + Wave Sóng + Teardrop Giọt lệ + Striped Ribbon Dải băng sọc + Fine Nib Ngòi mảnh + Broad Nib Ngòi rộng + Chisel Đục + Round Pen Bút tròn + Pressure Pen Bút áp lực + Beads Hạt cườm + Dash Dot Gạch chấm + Scallops Viền vỏ sò + Stars Ngôi sao + Triangles Tam giác + Sparkles Lấp lánh Save Brush Library… Lưu thư viện cọ… @msg `{_1}` has no brushes to apply `{_1}` không có cọ nào để áp dụng @msg missing `library` (see brush.libraries) thiếu `library` (xem brush.libraries) diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b04e65a65b..f98d150c61 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -6532,6 +6532,22 @@ axis Y Axis Y 轴 Graphite Pencil 石墨铅笔 Wax Crayon 蜡笔 Charcoal Dust 炭粉 + Notched Ribbon 凹口丝带 + Double Line 双线 + Wave 波浪 + Teardrop 泪滴 + Striped Ribbon 条纹丝带 + Fine Nib 细笔尖 + Broad Nib 宽笔尖 + Chisel 凿形 + Round Pen 圆头笔 + Pressure Pen 压感笔 + Beads 珠串 + Dash Dot 点划线 + Scallops 扇贝边 + Stars 星形 + Triangles 三角形 + Sparkles 闪光 Save Brush Library… 保存画笔库… @msg `{_1}` has no brushes to apply `{_1}` 中没有可应用的画笔 @msg missing `library` (see brush.libraries) 缺少 `library`(请参阅 brush.libraries) diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index be62fde8a7..b8bcc68ab2 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -6337,6 +6337,22 @@ axis Y Axis Y 軸 Graphite Pencil 石墨鉛筆 Wax Crayon 蠟筆 Charcoal Dust 炭粉 + Notched Ribbon 凹口緞帶 + Double Line 雙線 + Wave 波浪 + Teardrop 淚滴 + Striped Ribbon 條紋緞帶 + Fine Nib 細筆尖 + Broad Nib 寬筆尖 + Chisel 鑿形 + Round Pen 圓頭筆 + Pressure Pen 壓感筆 + Beads 珠串 + Dash Dot 點劃線 + Scallops 扇貝邊 + Stars 星形 + Triangles 三角形 + Sparkles 閃光 Save Brush Library… 儲存筆刷資料庫… @msg `{_1}` has no brushes to apply `{_1}` 中沒有可套用的筆刷 @msg missing `library` (see brush.libraries) 未提供 `library`(請參閱 brush.libraries) diff --git a/crates/ui-egui/src/panels/brushes.rs b/crates/ui-egui/src/panels/brushes.rs index 8f29c70475..82991005ab 100644 --- a/crates/ui-egui/src/panels/brushes.rs +++ b/crates/ui-egui/src/panels/brushes.rs @@ -637,10 +637,16 @@ impl LibraryKind for BrushLibraries { fn path(app: &VectorcraftApp, id: &str) -> Option { app.session.brush_libraries.path(id).map(str::to_string) } - fn rows<'a>(lib: &'a Self::Lib, query: &str) -> Vec> { - let found = |n: &str| query.is_empty() || n.to_lowercase().contains(query); + /// Find matches a brush's name as shown and as stored. + fn rows<'a>(lib: &'a Self::Lib, id: &str, query: &str) -> Vec> { + let found = |n: &str| query.is_empty() || n.to_lowercase().contains(query) || Self::label(id, n).to_lowercase().contains(query); lib.iter().filter(|b| found(b.name())).map(|b| Row { name: b.name(), item: Some(b) }).collect() } + /// A built-in library's brush in the UI language; a User Defined or loaded library's brush as + /// it is named. + fn label<'a>(id: &str, name: &'a str) -> &'a str { + super::label_or_name(name, brushlib::BUILTIN.iter().any(|b| b.id == id)) + } /// The brush's stroke preview, drawn with the library's resources. fn draw(_: &VectorcraftApp, ui: &Ui, r: egui::Rect, b: &LibBrush, selected: bool, hovered: bool) { chip(ui, r, &b.lib.doc, &b.shown.def, b.shown.key); diff --git a/crates/ui-egui/src/panels/graphic_styles.rs b/crates/ui-egui/src/panels/graphic_styles.rs index fe38851b44..b87f291e67 100644 --- a/crates/ui-egui/src/panels/graphic_styles.rs +++ b/crates/ui-egui/src/panels/graphic_styles.rs @@ -726,7 +726,7 @@ impl LibraryKind for GraphicStyleLibraries { fn path(app: &VectorcraftApp, id: &str) -> Option { app.session.style_libraries.path(id).map(str::to_string) } - fn rows<'a>(lib: &'a Self::Lib, query: &str) -> Vec> { + fn rows<'a>(lib: &'a Self::Lib, _: &str, query: &str) -> Vec> { let found = |n: &str| query.is_empty() || n.to_lowercase().contains(query); lib.iter().filter(|s| found(&s.style().name)).map(|s| Row { name: &s.style().name, item: Some(s) }).collect() } diff --git a/crates/ui-egui/src/panels/library_panel.rs b/crates/ui-egui/src/panels/library_panel.rs index 948651ef80..76455a63f5 100644 --- a/crates/ui-egui/src/panels/library_panel.rs +++ b/crates/ui-egui/src/panels/library_panel.rs @@ -104,8 +104,13 @@ pub(crate) trait LibraryKind { fn find(app: &VectorcraftApp, key: &str) -> Found; /// The file library `id` was read from, if any. fn path(app: &VectorcraftApp, id: &str) -> Option; - /// The rows matching the find field's `query` (lower case; empty: all). - fn rows<'a>(lib: &'a Self::Lib, query: &str) -> Vec>; + /// The rows of library `id` matching the find field's `query` (lower case; empty: all). + fn rows<'a>(lib: &'a Self::Lib, id: &str, query: &str) -> Vec>; + /// Row `name` of library `id` as its list row and tooltips show it (by default as it is). The + /// row keeps `name` for its tile, selection, clicks, drags and the commands they run. + fn label<'a>(_id: &str, name: &'a str) -> &'a str { + name + } /// An item's thumbnail in `r`. fn draw(app: &VectorcraftApp, ui: &Ui, r: Rect, item: &Self::Item, selected: bool, hovered: bool); /// The icons at the right of list row `r`. @@ -626,7 +631,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { let (view, hide_find) = settings(&app.ui, K::KIND, id).map_or((K::VIEW, false), |s| (s.view.unwrap_or(K::VIEW), s.hide_find)); let query = if hide_find { String::new() } else { widgets::search_field(ui, egui::Id::unique(key(&panel, "find")), tl!("Find")) }; ui.add_space(4.0); - let rows = K::rows(&lib, &query.trim().to_lowercase()); + let rows = K::rows(&lib, id, &query.trim().to_lowercase()); let selected = selection(ui.ctx(), &panel); let is_sel = |n: &str| selected.iter().any(|s| s == n); let mut clicked: Option<(String, bool, egui::Modifiers)> = None; @@ -659,9 +664,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { } None => super::swatches::draw_folder(ui, chip), } - list_row_name(ui, r, chip, row.name); + let label = K::label(id, row.name); + list_row_name(ui, r, chip, label); drag_out::(ui, &resp, id, row); - let tip = || row.item.map_or_else(|| row.name.to_string(), |i| format!("{} ({})", row.name, K::describe(i))); + let tip = || row.item.map_or_else(|| label.to_string(), |i| format!("{label} ({})", K::describe(i))); click(ui, resp, row, &tip); } } else { @@ -672,7 +678,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) { None => folder_tile(ui, cell, is_sel(row.name)), } drag_out::(ui, &resp, id, row); - click(ui, resp, row, &|| row.name.to_string()); + click(ui, resp, row, &|| K::label(id, row.name).to_string()); } } }); diff --git a/crates/ui-egui/src/panels/library_panel/tests.rs b/crates/ui-egui/src/panels/library_panel/tests.rs index 034a90e5b0..5d6fef049b 100644 --- a/crates/ui-egui/src/panels/library_panel/tests.rs +++ b/crates/ui-egui/src/panels/library_panel/tests.rs @@ -127,7 +127,7 @@ fn a_plain_click_sets_where_a_shift_click_range_starts() { let ctx = context(); app.run("window.swatchLibrary", json!({"library": "pastels"})).unwrap(); let (_, lib) = SwatchLibraries::get(&app, "pastels").unwrap(); - let order: Vec = SwatchLibraries::rows(&lib, "").iter().map(|r| r.name.to_string()).collect(); + let order: Vec = SwatchLibraries::rows(&lib, "pastels", "").iter().map(|r| r.name.to_string()).collect(); click(&mut app, &ctx, &order[1], Modifiers::NONE, 0.0); click(&mut app, &ctx, &order[4], Modifiers::SHIFT, 2.0); assert_eq!(selection(&ctx, "library:swatches:pastels"), order[1..=4], "the range starts at the plain click"); diff --git a/crates/ui-egui/src/panels/swatches/libraries.rs b/crates/ui-egui/src/panels/swatches/libraries.rs index 772bc8e28d..864f6ae7e0 100644 --- a/crates/ui-egui/src/panels/swatches/libraries.rs +++ b/crates/ui-egui/src/panels/swatches/libraries.rs @@ -83,7 +83,7 @@ impl LibraryKind for SwatchLibraries { } /// Ungrouped swatches, then each colour group's folder and swatches (a group whose name /// matches shows all of them), as in the Swatches panel. - fn rows<'a>(lib: &'a Self::Lib, query: &str) -> Vec> { + fn rows<'a>(lib: &'a Self::Lib, _: &str, query: &str) -> Vec> { let found = |n: &str| query.is_empty() || n.to_lowercase().contains(query); let row = |w: &'a Swatch| Row { name: &w.name, item: Some(w) }; let mut out: Vec> = lib.swatches.iter().filter(|w| found(&w.name)).map(row).collect(); diff --git a/crates/ui-egui/src/panels/symbols.rs b/crates/ui-egui/src/panels/symbols.rs index 04f99c3c1f..ad081ddfcd 100644 --- a/crates/ui-egui/src/panels/symbols.rs +++ b/crates/ui-egui/src/panels/symbols.rs @@ -496,7 +496,7 @@ impl LibraryKind for SymbolLibraries { fn path(app: &VectorcraftApp, id: &str) -> Option { app.session.symbol_libraries.path(id).map(str::to_string) } - fn rows<'a>(lib: &'a Self::Lib, query: &str) -> Vec> { + fn rows<'a>(lib: &'a Self::Lib, _: &str, query: &str) -> Vec> { let found = |n: &str| query.is_empty() || n.to_lowercase().contains(query); lib.iter().filter(|s| found(s.name())).map(|s| Row { name: s.name(), item: Some(s) }).collect() } diff --git a/crates/ui-egui/src/panels/tests_brush_libraries.rs b/crates/ui-egui/src/panels/tests_brush_libraries.rs index 263342741a..6da376c7ca 100644 --- a/crates/ui-egui/src/panels/tests_brush_libraries.rs +++ b/crates/ui-egui/src/panels/tests_brush_libraries.rs @@ -103,7 +103,7 @@ fn clicking_a_library_brush_adds_it_to_the_documents_brushes_in_one_step() { assert!(has(&app, "Teardrop") && has(&app, "Double Line")); // Find filters by name; list rows give the type. let lib = BrushLibraries::get(&app, "art-ribbons").unwrap().1; - let rows = BrushLibraries::rows(&lib, "tear"); + let rows = BrushLibraries::rows(&lib, "art-ribbons", "tear"); assert_eq!(rows.iter().map(|r| r.name).collect::>(), ["Teardrop"]); assert_eq!(BrushLibraries::describe(rows[0].item.unwrap()), "Art"); // Dragging a brush out of the panel carries it. @@ -127,7 +127,7 @@ fn the_ink_and_wash_library_opens_and_a_click_adds_and_applies_its_brush() { let r = app.run("window.brushLibrary", json!({"library": "ink-and-wash"})).unwrap(); assert_eq!((r["open"].as_str(), r["count"].as_u64()), (Some("ink-and-wash"), Some(4))); let lib = BrushLibraries::get(&app, "ink-and-wash").unwrap().1; - let rows = BrushLibraries::rows(&lib, ""); + let rows = BrushLibraries::rows(&lib, "ink-and-wash", ""); assert_eq!(rows.iter().map(|r| r.name).collect::>(), ["Tapered Ink", "Brush Pen", "Soft Wash", "Pooling Wash"]); let path = app.run("shape.line", json!({"x1": 10, "y1": 10, "x2": 150, "y2": 60})).unwrap()["id"].as_u64().unwrap(); let (undo, brushes) = (undo_len(&app), count(&app)); @@ -140,6 +140,48 @@ fn the_ink_and_wash_library_opens_and_a_click_adds_and_applies_its_brush() { assert_eq!(app.run("brush.list", json!({})).unwrap()["current"], "Brush Pen"); } +/// A built-in library lists its brushes in the UI language, and Find matches the name shown and +/// the brush's own name. The brush keeps its own name for its tile, a click and the document's +/// Brushes. A library loaded from a document lists its brushes as they are named. +#[test] +fn built_in_library_brushes_are_listed_in_the_ui_language() { + let dir = std::env::temp_dir().join(format!("vc-brush-names-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut app = app(); + let ctx = context(); + crate::i18n::set_current(crate::i18n::Lang::from_code("fr").expect("fr registered")); + let list = |app: &mut VectorcraftApp, t: f64| { + let panel = last_open(&app.ui).map(LibraryPanel::panel).unwrap_or_default(); + app.run("ui.libraryPanel", json!({"panel": panel, "view": "smallList"})).unwrap(); + frame(&ctx, vec![], t, |ui| library_panel::show_window(app, ui.ctx())); + frame(&ctx, vec![], t + 0.25, |ui| library_panel::show_window(app, ui.ctx())) + }; + app.run("window.brushLibrary", json!({"library": "ink-and-wash"})).unwrap(); + let texts = list(&mut app, 0.0); + assert!(texts.iter().any(|t| t == "Encre effilée"), "{texts:?}"); + assert!(!texts.iter().any(|t| t == "Tapered Ink"), "{texts:?}"); + let lib = BrushLibraries::get(&app, "ink-and-wash").unwrap().1; + for query in ["effil", "tapered"] { + let rows = BrushLibraries::rows(&lib, "ink-and-wash", query); + assert_eq!(rows.iter().map(|r| r.name).collect::>(), ["Tapered Ink"], "{query}"); + } + assert!(BrushLibraries::rows(&lib, "loaded/1", "effil").is_empty(), "another library's brush is matched by its own name only"); + click(&mut app, &ctx, "Tapered Ink", Modifiers::NONE, 1.0); + assert!(has(&app, "Tapered Ink")); + assert_eq!(app.run("brush.list", json!({})).unwrap()["current"], "Tapered Ink"); + // The document's brushes, opened as a library, keep their names. + let doc = dir.join("Inks.vectorcraft"); + let b64 = app.run("document.serialize", json!({})).unwrap()["dataBase64"].as_str().unwrap().to_string(); + std::fs::write(&doc, vectorcraft_format::base64_decode(&b64).unwrap()).unwrap(); + app.run("window.brushLibrary.other", json!({"path": doc.to_string_lossy()})).unwrap(); + let texts = list(&mut app, 2.0); + assert!(texts.iter().any(|t| t == "Tapered Ink"), "{texts:?}"); + assert!(!texts.iter().any(|t| t == "Encre effilée"), "{texts:?}"); + crate::i18n::set_current(crate::i18n::Lang::EN); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn the_window_menu_and_the_brushes_panel_open_brush_libraries() { let mut app = app(); diff --git a/crates/ui-egui/src/panels/tests_style_libraries.rs b/crates/ui-egui/src/panels/tests_style_libraries.rs index 46c274dee6..2adeda9812 100644 --- a/crates/ui-egui/src/panels/tests_style_libraries.rs +++ b/crates/ui-egui/src/panels/tests_style_libraries.rs @@ -92,7 +92,7 @@ fn clicking_a_library_style_adds_and_applies_it_in_one_step() { assert!(d.graphic_style("Feathered").is_some() && d.graphic_style("Haze").is_some()); // Find filters by name; list rows describe the styles; looks are computed once per library. let lib = GraphicStyleLibraries::get(&app, "shadows-glows").unwrap().1; - let rows = GraphicStyleLibraries::rows(&lib, "ember"); + let rows = GraphicStyleLibraries::rows(&lib, "shadows-glows", "ember"); assert_eq!(rows.iter().map(|r| r.name).collect::>(), ["Ember"]); assert_eq!(GraphicStyleLibraries::describe(rows[0].item.unwrap()), "1 fill, Inner Glow, Outer Glow"); assert!(std::sync::Arc::ptr_eq(&lib, &GraphicStyleLibraries::get(&app, "shadows-glows").unwrap().1)); @@ -121,7 +121,7 @@ fn painted_and_sketched_opens_from_the_window_menu_and_a_click_applies_its_style let node = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(rect)).unwrap().appearance.effects.clone(); assert_eq!(node.iter().map(|e| e.id.as_str()).collect::>(), ["sketch.charcoal"]); let lib = GraphicStyleLibraries::get(&app, "painted-sketched").unwrap().1; - let rows = GraphicStyleLibraries::rows(&lib, "ink"); + let rows = GraphicStyleLibraries::rows(&lib, "painted-sketched", "ink"); assert_eq!(rows.iter().map(|r| r.name).collect::>(), ["Ink Contour", "Wet Ink"]); } diff --git a/crates/ui-egui/src/panels/tests_symbol_libraries.rs b/crates/ui-egui/src/panels/tests_symbol_libraries.rs index 1e71583a4b..d61c03eaec 100644 --- a/crates/ui-egui/src/panels/tests_symbol_libraries.rs +++ b/crates/ui-egui/src/panels/tests_symbol_libraries.rs @@ -89,7 +89,7 @@ fn clicking_a_library_symbol_adds_it_to_the_documents_symbols_in_one_step() { assert_eq!(symbols(&app), ["Star", "Hexagon", "Arrow"]); // Find filters by name; list rows give the natural size. let lib = SymbolLibraries::get(&app, "basic-shapes").unwrap().1; - let rows = SymbolLibraries::rows(&lib, "arr"); + let rows = SymbolLibraries::rows(&lib, "basic-shapes", "arr"); assert_eq!(rows.iter().map(|r| r.name).collect::>(), ["Arrow"]); assert_eq!(SymbolLibraries::describe(rows[0].item.unwrap()), "48 × 32 pt"); // Dragging a symbol out of the panel carries it to the canvas. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f04ee3c910..4981b8643a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -895,6 +895,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Brush libraries** ([public source](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/create-new-brush-libraries.html), [import](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/import-brushes-from-brush-libraries-or-other-files.html), [Brushes panel](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/brushes-panel-overview.html)): Window › Brush Libraries, the Brushes panel's Brush Libraries Menu and its menu's Open Brush Library open each library in a library panel of its own, as swatch, graphic style and symbol libraries do. The built-in libraries (Art Ribbons, Ink and Wash, Calligraphic Nibs, Bristle Brushes, Pattern Borders, Scatter Shapes, Game Trails, Energy Beams, Dry Media) are original brushes generated in code (`crates/engine/src/cmd/brushlib.rs`); Striped Ribbon's art is filled with the library's own pattern and Sparkles scatters the library's own symbol; Ink and Wash adds ink and wash brushes (the [help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html) lists an Ink brush library of strokes with varied thickness and texture; the names, shapes and opacities are VectorCraft's own): Tapered Ink, an art brush broad near its start that thins to a fine tip; Brush Pen, a calligraphic brush whose size follows the pen pressure; and Soft Wash and Pooling Wash, art brushes of translucent gray layers that take the stroke color as tints; Game Trails adds motion art brushes (Speed Line, Dash Trail, Burst Ray, Zigzag); Energy Beams adds laser and plasma art brushes (Laser Bolt, Plasma Arc, Charge Wave, Spark Trail); Dry Media adds grainy art brushes (Chalk Grain, Graphite Pencil, Wax Crayon, Charcoal Dust). A click on a brush adds it to the document's Brushes as one undo step with every resource its art uses (patterns, symbols, swatches, graphic styles, images), makes it the current brush and, with paths selected, applies it to them in the same step; a brush the document already has is not added again and a different brush of the same name comes in numbered ("Wave 2"). Shift/Cmd-clicks select brushes for Add to Brushes; a brush dragged onto a path is added and applied to it, and one dragged onto the Brushes panel is added. Save Brushes… / the Brushes panel's Save Brush Library… write the document's brushes (or the selected ones) as a `.vectorcraft` library into the `Brushes` folder next to the preferences, listed under User Defined, or to a file; Other Library… reads the brushes of any document VectorCraft opens. Thumbnails, in the library panels, the Brushes panel and Brush Options, render brush art with the patterns and symbols it uses. Copying brushed art now carries the patterns and symbols its brush's art uses too. Tests: `crates/engine/src/tests/brushlib.rs`, `crates/ui-egui/src/panels/tests_brush_libraries.rs`, `panels::brushes::tests::library_brush_previews_show_the_patterns_their_art_uses`, `canvas::tests::a_library_brush_dropped_on_a_path_is_added_and_applied_in_one_step`. - **Bristle Brushes library** ([What's new](https://helpx.adobe.com/illustrator/desktop/new-features/whats-new.html), [About brushes](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html)): Window › Brush Libraries › Bristle Brushes holds ten bristle brushes, one for each tip shape (Fine Liner, Pointed Flat, Stubby Round, Dry Scrub, Soft Dome, Filbert, Angled Round, Angled Flat, Fan Blender, Grass Fan). The brushes, their names and their values are VectorCraft's own, and every value lies inside the range the bristle brush keeps (`brush.libraries`, `brush.addFromLibrary`; `the_bristle_brushes_library_covers_every_tip_shape_within_the_bristle_ranges`). - **Dry Media library** ([help page](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/apply-and-edit-strokes/about-brushes.html)): Window › Brush Libraries › Dry Media holds four art brushes with textured strokes like dry drawing materials, each 100 pt long: Chalk Grain (about 8 pt high), Graphite Pencil (about 2.3 pt), Wax Crayon (about 5.5 pt) and Charcoal Dust (about 7 pt). The brushes, their names, sizes and art are VectorCraft's own: each is filled vector paths from a fixed seed, at most 80 paths and 500 anchors, stretched along the path with Tints colorization, and the chalk and crayon art has even-odd gaps where the paper shows (`brush.libraries`, `brush.addFromLibrary`; `the_dry_media_library_holds_grainy_art_brushes_with_bounded_art`). + - **Brush names in library panels**: a built-in brush library's panel shows its brushes' names in the UI language, in its list rows and tooltips, and its Find field matches the name shown or the English name. This is VectorCraft's own rule. A User Defined or Other Library… library's brushes, and the document's Brushes panel, show names as they are stored. A click, a drag, Add to Brushes and `brush.addFromLibrary` still use the English name, so the brush comes into the document under that name (`built_in_library_brushes_are_listed_in_the_ui_language`, `built_in_library_brush_names_are_in_complete_catalogs`). - A canvas context menu: right-click selects the object under the pointer and lists what applies to the selection (Undo/Redo, clipboard, group, isolation, join, Remove Anchor Points when anchors are selected, masks, compound paths, guides, Transform, Arrange, Select, Export Selection, and on type Bullets and Numbering and Spelling › Auto Spell Check, checked while it is on), or the view commands on empty canvas. - A right-click over locked objects adds an Unlock submenu to the canvas context menu. The submenu lists up to 20 locked objects under the pointer, topmost first, by the name the Layers panel shows, and choosing one unlocks it (`layer.setProps`). A locked group is listed as one item. Objects on hidden, locked or template layers are not listed. - Native macOS save sheets offer a localised Format menu (#866), built from the request's existing format filters. It selects the suggested filename's format and updates the suffix when changed, including legacy `.drawcraft` names. `apps/vectorcraft/src/mac_save_format.rs` has filename regressions; an actual native SVG save, selection of PDF and cancellation without changing the saved document were verified. From f7ba1d045ae9a39efb7e76a45bdb09847e45a306 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 05:50:00 -0800 Subject: [PATCH 09/56] Contextual Task Bar: the Type tools show font family, style, size and Fill While a Type tool (Type, Area Type, Type on a Path and their vertical versions) adds or edits type, the task bar shows the font family and style menus, the Font Size combo and the Fill chip. Type selected with a selection tool shows the same font controls beside its Create Outlines, conversion and Type on a Path items. The controls change what the Character panel's would: the selected characters, else every selected type object. The Touch Type tool still shows no bar. While a Type tool edits type, the Fill chip shows the selected characters' fill, with "?" where their fills differ. On a canvas too narrow for one row, the font controls go on a second row and narrow to fit. Screen readers name each control. --- crates/ui-egui/src/canvas.rs | 4 +- crates/ui-egui/src/canvas/task_bar.rs | 143 ++++++++++--- crates/ui-egui/src/font_menu.rs | 20 +- crates/ui-egui/src/panels/character.rs | 45 +++- crates/ui-egui/src/tests/taskbar.rs | 260 +++++++++++++++++++++++- crates/ui-egui/src/widgets.rs | 2 +- crates/ui-egui/src/widgets/dropdowns.rs | 14 +- docs/target-app-parity.md | 3 +- 8 files changed, 452 insertions(+), 39 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d75eae537f..85412cb53d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -67,8 +67,10 @@ pub(crate) use panes::{consolidate, pane, tile, tiled_area}; use raster::{changed_pixels, pixel_region, upload}; pub(crate) use raster::{color_image, color_image_owned, render_job}; pub use selection_marks::SelectionMarks; +#[cfg(test)] +pub(crate) use task_bar::chip_paints; use task_bar::task_bar; -pub(crate) use task_bar::{has_colors, task_bar_rect}; +pub(crate) use task_bar::{has_colors, task_bar_rect, task_bar_shows_type}; #[cfg(test)] use tool_overlays::{DIAL_RADIUS, HiddenMarks}; pub use tool_overlays::{HiddenChars, SpellingMarks}; diff --git a/crates/ui-egui/src/canvas/task_bar.rs b/crates/ui-egui/src/canvas/task_bar.rs index 5372cc083d..c0422e0347 100644 --- a/crates/ui-egui/src/canvas/task_bar.rs +++ b/crates/ui-egui/src/canvas/task_bar.rs @@ -30,21 +30,59 @@ fn picked_artboards_bounds(app: &VectorcraftApp) -> Option { picked.iter().filter_map(|i| st.doc.artboards.get(*i)).map(|a| a.rect).reduce(|a, b| a.union(b)) } +/// The Type tools whose Contextual Task Bar shows the text formatting controls. The Touch Type +/// tool has no bar: its own handles take the place of one. +const TYPE_TOOLS: [&str; 6] = ["type", "areaType", "typeOnPath", "verticalType", "verticalAreaType", "verticalTypeOnPath"]; + +/// Whether every selected object is a type object, with no anchors picked out. +fn only_type_selected(app: &VectorcraftApp) -> bool { + app.session.active().is_some_and(|st| { + !st.selection.is_empty() + && st.selection.anchors.is_empty() + && st.selection.objects.iter().all(|id| st.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))) + }) +} + +/// Whether a selection tool is in use with something selected. +fn selection_tool_with_selection(app: &VectorcraftApp) -> bool { + matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection") + && app.session.active().is_some_and(|st| !st.selection.is_empty()) +} + +/// Whether a Type tool edits type, or has type selected: its bar then shows only the text +/// formatting controls. +fn type_tool_on_type(app: &VectorcraftApp) -> bool { + TYPE_TOOLS.contains(&app.session.tool_id()) && (crate::panels::character::text_editing(app).is_some() || only_type_selected(app)) +} + +/// Whether the Contextual Task Bar shows the font family, font style and Font Size controls: a +/// Type tool edits or has selected type, or a selection tool has only type selected. +pub(crate) fn task_bar_shows_type(app: &VectorcraftApp) -> bool { + type_tool_on_type(app) || (selection_tool_with_selection(app) && only_type_selected(app)) +} + /// The Contextual Task Bar: a floating pill under the selection with the most likely next actions. /// With the Artboard tool it sits under the selected artboards and offers Resize (Artboard /// Options), Duplicate, Rearrange All (with two or more artboards) and Export (Export for Screens). /// A basic shape selected with the Selection tool shows a Shape Properties button in place of Lock. +/// Selected type also shows the font family, font style and Font Size controls before its Fill. +/// While a Type tool edits type, or has type selected, the bar shows only those controls and the +/// Fill. The font controls take a second row when one row would be wider than the canvas, and +/// narrow there to fit it. /// Its handle drags it anywhere on the canvas; unpinned it keeps that offset as it follows the /// selection, pinned it stays put ([`crate::state::TaskBarPlace`]). pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { let t = Tokens::get(ui.ctx()); let on_artboards = app.session.tool_id() == "artboard"; - if !on_artboards - && (app.session.active().is_none_or(|st| st.selection.is_empty()) - || !matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection")) - { + // Text added with a Type tool shows its formatting controls, as the help page describes + // (). + let typing = type_tool_on_type(app); + if !(on_artboards || typing || selection_tool_with_selection(app)) { return; } + // Selected type has the font controls too, as the help page describes + // (). + let fonts = task_bar_shows_type(app); let Some(b) = (if on_artboards { picked_artboards_bounds(app) } else { app.selection_bounds() }) else { return }; let Some(st) = app.session.active() else { return }; let n = st.selection.len(); @@ -93,7 +131,9 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { items.push((tl!("Rearrange All"), "dc-rearrange", TASK_BAR_REARRANGE_ARTBOARDS, none())); } items.push((tl!("Export"), "share-2", "file.exportForScreens", none())); - } else { + } else if !typing { + // The selection tools' object actions. A Type tool's bar leaves them out, Create Outlines + // and the type conversions included. // Edit Path: the Direct Selection tool, to edit the path's anchors. let edit_path = || (tl!("Edit Path"), "dc-direct", "tool.select", json!({"tool": "directSelection"})); // As in the reference app, an image offers Image Trace, Crop Image and Remove Background, and @@ -185,22 +225,30 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { } items.push((tl!("Duplicate"), "copy", "edit.duplicate", none())); } - // The paints the Fill and Stroke proxies show, "?" when the selected objects' paints differ. - let (fill, stroke) = crate::panels::current_paints(app); - let (fill_mixed, stroke_mixed) = crate::panels::mixed_paints(app, ui.ctx()); + let (fill, stroke, fill_mixed, stroke_mixed) = chip_paints(app, ui.ctx()); let colored = has_colors(&st.doc, &st.selection.objects); let doc = st.uid; // Under the selection's lowest corner on screen, centered on the selection (the view may be // rotated). let lowest = xf.quad(b).iter().map(|c| c.y).fold(f32::NEG_INFINITY, f32::max); let anchor = pos2(xf.to_screen(b.center()).x, lowest); - let est_w = 150.0 + items.iter().map(|(l, _, _, _)| l.len() as f32 * 7.2 + 44.0).sum::(); + let bounds = xf.rect.shrink(8.0); + // On a canvas too narrow for the type controls at their narrowest, the bar leaves them out. + let fonts = fonts && bounds.width() - 18.0 >= crate::panels::character::task_bar_min_room(); + // The width of the bar's frame (margins and stroke), its handle, Fill and Stroke chips, + // Recolor Artwork, Lock or Shape Properties, and More Options, each after a 6-point gap. + let chips: f32 = + [(!on_artboards, 26.0), (!on_artboards && !typing, 26.0), (!on_artboards && !typing && colored, 30.0), (!on_artboards && !typing, 30.0)] + .iter() + .filter(|(shown, _)| *shown) + .map(|(_, w)| w + 6.0) + .sum(); + let (font_row, est) = size_estimate(&items, fonts, 18.0 + 4.0 + chips + 36.0, bounds.width()); // Its place under the selection, then where the handle moved it, kept on the canvas (with the // bar's size last frame, the estimate before it first shows). - let under = pos2(anchor.x - est_w / 2.0, anchor.y + 28.0); + let under = pos2(anchor.x - est.x / 2.0, anchor.y + 28.0); let id = task_bar_id(); - let size = egui::AreaState::load(ui.ctx(), id).and_then(|s| s.size).unwrap_or(vec2(est_w, 44.0)); - let bounds = xf.rect.shrink(8.0); + let size = egui::AreaState::load(ui.ctx(), id).and_then(|s| s.size).unwrap_or(est); let keep_in = |p: Pos2| p.clamp(bounds.min, (bounds.max - size).max(bounds.min)); let place = &mut app.ui.task_bar_place; // Just unpinned: follow the selection from the pinned spot. @@ -255,23 +303,21 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { run = Some((*cmd, params.clone())); } } + // Type's font family, font style and Font Size, which act as the Character + // panel's do: on the characters a Type tool selects, else on the selected type. + if fonts && !font_row { + crate::panels::character::task_bar(app, ui, f32::INFINITY); + } // The selected objects' Fill, Stroke, Recolor Artwork and Lock (a basic shape's - // properties instead), left out with the Artboard tool. - if !on_artboards { - let (r, resp) = ui.allocate_exact_size(vec2(26.0, 30.0), Sense::click()); - let chip = egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)); - widgets::proxy_chip(ui, chip, &fill, fill_mixed, None); - ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.button_border), StrokeKind::Outside); - if resp.on_hover_text(tl!("Fill")).clicked() { - app.session.fill_active = true; - show_swatches = true; - } + // properties instead), left out with the Artboard tool. A Type tool's bar has + // the Fill alone. + if !on_artboards && paint_chip(ui, &t, &fill, fill_mixed, None, tl!("Fill")) { + app.session.fill_active = true; + show_swatches = true; + } + if !on_artboards && !typing { // The Stroke chip, drawn as a ring as the Control bar's is. - let (r, resp) = ui.allocate_exact_size(vec2(26.0, 30.0), Sense::click()); - let chip = egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)); - widgets::proxy_chip(ui, chip, &stroke, stroke_mixed, Some(4.0)); - ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.button_border), StrokeKind::Outside); - if resp.on_hover_text(tl!("Stroke")).clicked() { + if paint_chip(ui, &t, &stroke, stroke_mixed, Some(4.0), tl!("Stroke")) { app.session.fill_active = false; show_swatches = true; } @@ -316,6 +362,13 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { } }); }); + if font_row { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + // The canvas's width less the frame's margins and stroke. + crate::panels::character::task_bar(app, ui, bounds.width() - 18.0); + }); + } }); }); if moved != Vec2::ZERO { @@ -353,6 +406,42 @@ pub(super) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) { } } +/// Whether the type controls go on a second row, and the bar's estimated size before it first +/// shows: `items` and, when `fonts`, the font family, font style and Font Size controls, which take +/// a second row when one row, with the bar's other controls `fixed` points wide, would be wider than +/// `room` (and narrow to fit it there). +fn size_estimate(items: &[(&str, &str, &str, serde_json::Value)], fonts: bool, fixed: f32, room: f32) -> (bool, Vec2) { + let font_w: f32 = crate::panels::character::TASK_BAR_WIDTHS.iter().filter(|_| fonts).map(|w| w + 6.0).sum(); + let items_w = if fonts { fixed } else { 150.0 } + items.iter().map(|(l, _, _, _)| l.len() as f32 * 7.2 + 44.0).sum::(); + if fonts && items_w + font_w > room { (true, vec2(items_w.max((font_w + 16.0).min(room)), 80.0)) } else { (false, vec2(items_w + font_w, 44.0)) } +} + +/// The paints the Fill and Stroke chips show, and whether each is mixed ("?"): the proxies', or +/// while a Type tool edits type, the edited characters' fill, which a swatch picked then paints. +pub(crate) fn chip_paints(app: &VectorcraftApp, ctx: &egui::Context) -> (vectorcraft_color::Paint, vectorcraft_color::Paint, bool, bool) { + let (fill, stroke) = crate::panels::current_paints(app); + let (fill_mixed, stroke_mixed) = crate::panels::mixed_paints(app, ctx); + if crate::panels::character::text_editing(app).is_some() + && let Some((chars, _)) = crate::panels::character::text_style(app) + { + let mixed = crate::panels::character::shared(app, |s| s.fill.clone()).is_none(); + return (chars.fill, stroke, mixed, stroke_mixed); + } + (fill, stroke, fill_mixed, stroke_mixed) +} + +/// One of the Contextual Task Bar's Fill and Stroke chips, showing `paint` ("?" when `mixed`, a +/// ring of `ring` points for the stroke), named `tip` for screen readers. Returns whether it was +/// clicked. +fn paint_chip(ui: &mut Ui, t: &Tokens, paint: &vectorcraft_color::Paint, mixed: bool, ring: Option, tip: &str) -> bool { + let (r, resp) = ui.allocate_exact_size(vec2(26.0, 30.0), Sense::click()); + let chip = egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)); + widgets::proxy_chip(ui, chip, paint, mixed, ring); + ui.painter().rect_stroke(chip, 0.0, Stroke::new(1.0, t.button_border), StrokeKind::Outside); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tip)); + resp.on_hover_text(tip).clicked() +} + /// The Contextual Task Bar's Center on Top and Center on Bottom, as in the Type menu, for type on a /// closed path. fn center_on_path_items(app: &VectorcraftApp, items: &mut Vec<(&'static str, &'static str, &'static str, serde_json::Value)>) { diff --git a/crates/ui-egui/src/font_menu.rs b/crates/ui-egui/src/font_menu.rs index 4fbb7a7ffc..01e4d9e524 100644 --- a/crates/ui-egui/src/font_menu.rs +++ b/crates/ui-egui/src/font_menu.rs @@ -55,6 +55,8 @@ pub enum WheelOwner { Control, /// The Character popover attached to this Control bar link. ControlPopover(egui::Id), + /// The Contextual Task Bar's type controls. + TaskBar, } impl WheelOwner { @@ -68,6 +70,7 @@ impl WheelOwner { Self::ControlPopover(anchor) => { return app.ui.control_bar && app.ui.screen_mode < 2 && widgets::popover_is_open(ctx, anchor); } + Self::TaskBar => return crate::canvas::task_bar_shows_type(app) && crate::canvas::task_bar_rect(ctx).is_some(), Self::Character => "character", Self::Properties => { let selected = app.session.active().and_then(|document| document.selection.objects.first().and_then(|id| document.doc.node(*id))); @@ -106,12 +109,24 @@ pub struct MenuLook<'a> { pub wheel: bool, /// Its owner, so a field hidden before the next layout cannot capture wheel input. pub wheel_owner: Option, + /// The name screen readers give the closed field when no label stands beside it. + pub name: Option<&'a str>, } impl Default for MenuLook<'static> { /// Samples on in the theme's colors, English names, medium rows, no favorites, no wheel. fn default() -> Self { - Self { samples: true, on_white: false, english_names: true, row: ROW, favorites: &[], recent: &[], wheel: false, wheel_owner: None } + Self { + samples: true, + on_white: false, + english_names: true, + row: ROW, + favorites: &[], + recent: &[], + wheel: false, + wheel_owner: None, + name: None, + } } } @@ -131,6 +146,7 @@ impl<'a> MenuLook<'a> { recent: app.recent_fonts(), wheel: false, wheel_owner: None, + name: None, } } } @@ -378,7 +394,7 @@ pub fn font_menu( // The search field and filters stay in place over the rows, which scroll. The rows are all // one height (a family's styles too), so only those in view are laid out: there are thousands // of families. - let (field, shown) = widgets::combo_menu_field(ui, &id, current, width, |menu| { + let (field, shown) = widgets::combo_menu_field_named(ui, &id, look.name, current, width, |menu| { menu.height(height).show_rows_with_header( |ui| { // Wider than a narrow field, so the filters keep their rows and long names show. diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 2ad678bf62..571cae66a3 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -922,8 +922,13 @@ fn set_anti_alias(app: &mut VectorcraftApp, m: TypeAntiAlias) { /// The Font Size combo (Character and Properties panels, Control bar): typed, stepped, scrubbed or /// picked from the presets, in the type unit, blank where the selected text's sizes differ. pub(crate) fn size_field(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, width: f32) { + size_field_named(app, ui, id, None, width); +} + +/// [`size_field`] named `name` for screen readers, where no label stands beside it. +fn size_field_named(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, name: Option<&str>, width: f32) { let size = shared_effective(app, |s| s.size); - if let Some(v) = widgets::spin_field(ui, id, size, app.session.type_unit(), width, 1.0, 0.1, &SIZE_PRESETS) { + if let Some(v) = widgets::spin_field_named(ui, id, name, size, app.session.type_unit(), width, 1.0, 0.1, &SIZE_PRESETS) { style(app, json!({"size": v})); } } @@ -962,7 +967,7 @@ pub(crate) fn font_pickers(app: &mut VectorcraftApp, ui: &mut Ui, s: &CharStyle, "cb-font" => Some(crate::font_menu::WheelOwner::Control), _ => None, }; - font_pickers_owned(app, ui, s, ids, widths, wheel_owner); + font_pickers_owned(app, ui, s, ids, widths, wheel_owner, (None, None)); } fn font_pickers_owned( @@ -972,16 +977,17 @@ fn font_pickers_owned( ids: (&str, &str), widths: (f32, f32), wheel_owner: Option, + a11y: (Option<&str>, Option<&str>), ) { crate::font_menu::sync_wheel_target(app, ui.ctx(), ui.ctx().viewport_id()); let sample = crate::font_menu::sample_text(app); - let look = crate::font_menu::MenuLook { wheel: true, wheel_owner, ..crate::font_menu::MenuLook::of(app) }; + let look = crate::font_menu::MenuLook { wheel: true, wheel_owner, name: a11y.0, ..crate::font_menu::MenuLook::of(app) }; if let Some(pick) = crate::font_menu::font_menu(ui, ids.0, &s.font_family, widths.0, sample.as_deref(), look) { crate::font_menu::apply(app, ui.ctx(), pick); } let styles = vectorcraft_text::FontDb::global().styles(&s.font_family); let names: Vec<&str> = styles.iter().map(String::as_str).collect(); - if let Some(name) = widgets::dropdown_names(ui, ids.1, &s.font_style, &names, widths.1).and_then(|i| names.get(i)) { + if let Some(name) = widgets::dropdown_names_named(ui, ids.1, a11y.1, &s.font_style, &names, widths.1).and_then(|i| names.get(i)) { style(app, json!({"style": name})); } } @@ -1076,6 +1082,35 @@ pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) { ui.separator(); } +/// The Contextual Task Bar's widths of the font family menu, the font style dropdown and the +/// Font Size combo. +pub(crate) const TASK_BAR_WIDTHS: [f32; 3] = [140.0, 90.0, 76.0]; + +/// The smallest share of [`TASK_BAR_WIDTHS`] the Contextual Task Bar's type controls narrow to. +const TASK_BAR_MIN_SCALE: f32 = 0.4; + +/// The gaps between the Contextual Task Bar's type controls. +fn task_bar_gaps() -> f32 { + 6.0 * (TASK_BAR_WIDTHS.len() - 1) as f32 +} + +/// The narrowest room the Contextual Task Bar's type controls fit in, with their spacing. +pub(crate) fn task_bar_min_room() -> f32 { + TASK_BAR_MIN_SCALE * TASK_BAR_WIDTHS.iter().sum::() + task_bar_gaps() +} + +/// The Contextual Task Bar's type controls while type is selected or edited: the font family and +/// style menus and the Font Size combo, as the Control bar has them, named for screen readers. +/// They narrow, down to 40% of their widths, to fit in `room` points with their spacing. +pub(crate) fn task_bar(app: &mut VectorcraftApp, ui: &mut Ui, room: f32) { + let Some((s, _)) = text_style(app) else { return }; + let k = ((room - task_bar_gaps()) / TASK_BAR_WIDTHS.iter().sum::()).clamp(TASK_BAR_MIN_SCALE, 1.0); + let [family, style, size] = TASK_BAR_WIDTHS.map(|w| w * k); + let owner = Some(crate::font_menu::WheelOwner::TaskBar); + font_pickers_owned(app, ui, &s, ("tb-font", "tb-font-style"), (family, style), owner, (Some(tl!("Font Family")), Some(tl!("Style")))); + size_field_named(app, ui, "tb-font-size", Some(tl!("Font Size")), size); +} + /// The panel state key of the panel menu's Show Snap to Glyph Options. const SNAP_TO_GLYPH_OPTIONS: &str = "ch-snap-to-glyph-options"; /// Set when Show Snap to Glyph Options is chosen: the next frame scrolls the panel to the options, @@ -1146,7 +1181,7 @@ fn attributes(app: &mut VectorcraftApp, ui: &mut Ui, owner: crate::font_menu::Wh if let Some(f) = app.character_focus { widgets::arm_field_focus(ui.ctx(), f.salt()); } - font_pickers_owned(app, ui, &s, ("ch-font", "ch-style"), (w - 4.0, w - 4.0), Some(owner)); + font_pickers_owned(app, ui, &s, ("ch-font", "ch-style"), (w - 4.0, w - 4.0), Some(owner), (None, None)); variable_font(app, ui, &s, w - 4.0); ui.add_space(2.0); let hidden = pstate::(ui.ctx(), "ch-hide-options"); diff --git a/crates/ui-egui/src/tests/taskbar.rs b/crates/ui-egui/src/tests/taskbar.rs index 4cece18050..202d6eb4c1 100644 --- a/crates/ui-egui/src/tests/taskbar.rs +++ b/crates/ui-egui/src/tests/taskbar.rs @@ -1,7 +1,8 @@ //! The Contextual Task Bar's actions for each kind of selection: open paths offer Join and Edit //! Path, compound paths and clipping masks their Release, images selected with vector art Make //! Clipping Mask, type converts between point and area type, type on a path opens its options, and -//! a basic shape shows its properties in place of Lock. +//! a basic shape shows its properties in place of Lock. Type, and the Type tools while they edit it, +//! show the font family, font style and Font Size controls. use egui::{Event, PointerButton, Pos2, Rect, vec2}; use serde_json::json; @@ -537,6 +538,263 @@ fn the_task_bar_follows_the_active_document() { assert!(!locked(&app, rect), "the button doesn't lock the shape"); } +/// The Type tools whose bar shows the font and Fill controls. +const TYPE_TOOLS: [&str; 6] = ["type", "areaType", "typeOnPath", "verticalType", "verticalAreaType", "verticalTypeOnPath"]; + +/// The labels drawn inside the task bar (none while it isn't shown), with the context. +fn bar_texts(app: &mut VectorcraftApp) -> (egui::Context, Vec<(String, Rect)>) { + let (ctx, texts) = bar(app); + let Some(r) = canvas::task_bar_rect(&ctx) else { return (ctx, vec![]) }; + (ctx, texts.into_iter().filter(|(_, t)| r.contains(t.center())).collect()) +} + +/// Point type "Hello" at 20 pt, made with `tool` and edited by it as after a click with it. +fn edited_type(app: &mut VectorcraftApp, tool: &str) -> u64 { + app.run("tool.select", json!({ "tool": tool })).unwrap(); + let t = id(app.run("text.create", json!({"x": 60, "y": 80, "text": "Hello", "size": 20})).unwrap()); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + assert!(crate::panels::character::text_editing(app).is_some(), "{tool} edits the type"); + t +} + +/// The font family and style of type `t`'s first character. +fn font_of(app: &VectorcraftApp, t: u64) -> (String, String) { + match kind(app, t) { + NodeKind::Text(t) => (t.first_style().font_family, t.first_style().font_style), + k => panic!("{k:?}"), + } +} + +/// The size of each run of type `t`, with its text. +fn sizes(app: &VectorcraftApp, t: u64) -> Vec<(String, f64)> { + match kind(app, t) { + NodeKind::Text(t) => t.runs.iter().map(|r| (r.text.clone(), r.style.size)).collect(), + k => panic!("{k:?}"), + } +} + +/// Each Type tool that adds or edits type shows the font family, font style and Font Size controls +/// and the Fill, without the selection tools' object actions, the Stroke chip or Lock. +#[test] +fn the_type_tools_show_font_and_fill_controls() { + for tool in TYPE_TOOLS { + let mut app = app(); + let t = edited_type(&mut app, tool); + let (family, style) = font_of(&app, t); + let (_, texts) = bar_texts(&mut app); + for label in [family.as_str(), style.as_str(), "20 pt"] { + assert!(has(&texts, label), "{tool}: {label} in {texts:?}"); + } + for label in ["Create Outlines", "Convert To Area Type", "Dynamic Text", "Duplicate"] { + assert!(!has(&texts, label), "{tool}: {label} in {texts:?}"); + } + let tree = crate::widgets::accessibility_tests::tree(|ui| canvas::show(&mut app, ui)); + let named = |label: &str| tree.iter().any(|n| n.label().is_some_and(|l| l.starts_with(label))); + assert!(named("Fill"), "{tool}: the Fill shows"); + for label in ["Stroke", "Recolor Artwork", "Lock"] { + assert!(!named(label), "{tool}: {label} is left out"); + } + } +} + +/// A Type tool shows no bar before it adds type, nor with other art selected; the Touch Type tool +/// shows none with type selected. +#[test] +fn the_type_tools_show_no_bar_without_type() { + let mut app = app(); + app.run("tool.select", json!({"tool": "type"})).unwrap(); + let (ctx, _) = bar(&mut app); + assert!(canvas::task_bar_rect(&ctx).is_none(), "nothing to format"); + let rect = id(app.run("shape.rectangle", json!({"x": 40, "y": 40, "width": 80, "height": 60})).unwrap()); + select(&mut app, &[rect]); + app.run("tool.select", json!({"tool": "type"})).unwrap(); + let (ctx, _) = bar(&mut app); + assert!(canvas::task_bar_rect(&ctx).is_none(), "a rectangle is not type"); + let t = id(app.run("text.create", json!({"x": 60, "y": 180, "text": "Hello", "size": 20})).unwrap()); + app.run("tool.select", json!({"tool": "touchType"})).unwrap(); + select(&mut app, &[t]); + let (ctx, _) = bar(&mut app); + assert!(canvas::task_bar_rect(&ctx).is_none(), "Touch Type has its own handles"); +} + +/// Selected type keeps its actions and gains the font controls, for one type object or several; +/// type selected with other art does not. +#[test] +fn selected_type_shows_font_controls_beside_its_actions() { + let mut app = app(); + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + let a = id(app.run("text.create", json!({"x": 60, "y": 80, "text": "Hello", "size": 20})).unwrap()); + let b = id(app.run("text.create", json!({"x": 60, "y": 160, "text": "World", "size": 20})).unwrap()); + let rect = id(app.run("shape.rectangle", json!({"x": 200, "y": 40, "width": 80, "height": 60})).unwrap()); + let (family, _) = font_of(&app, a); + select(&mut app, &[a]); + let (_, texts) = bar_texts(&mut app); + for label in ["Create Outlines", "Convert To Area Type", "Duplicate", family.as_str(), "20 pt"] { + assert!(has(&texts, label), "{label} in {texts:?}"); + } + select(&mut app, &[a, b]); + let (_, texts) = bar_texts(&mut app); + assert!(has(&texts, family.as_str()) && has(&texts, "20 pt"), "{texts:?}"); + select(&mut app, &[a, rect]); + let (_, texts) = bar_texts(&mut app); + assert!(has(&texts, "Group") && !has(&texts, family.as_str()) && !has(&texts, "20 pt"), "{texts:?}"); + select(&mut app, &[rect]); + let (_, texts) = bar_texts(&mut app); + assert!(!has(&texts, family.as_str()) && !has(&texts, "20 pt"), "{texts:?}"); +} + +/// The bar's Font Size sets the characters a Type tool selects, one undo step; with type selected +/// it sets every selected type object, as the Character panel's does. +#[test] +fn the_task_bars_font_size_sets_what_the_character_panel_would() { + let mut app = app(); + let t = edited_type(&mut app, "type"); + app.session.set_tool_option("select", &json!({"start": 0, "end": 2})); + let (ctx, texts) = bar_texts(&mut app); + type_into(&mut app, &ctx, &texts, "20 pt", "30"); + assert_eq!(sizes(&app, t), [("He".to_string(), 30.0), ("llo".to_string(), 20.0)]); + // Mixed sizes leave the field blank, as in the Character panel. + app.session.set_tool_option("select", &json!({"start": 0, "end": 5})); + let (_, texts) = bar_texts(&mut app); + assert!(!has(&texts, "20 pt") && !has(&texts, "30 pt"), "{texts:?}"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(sizes(&app, t), [("Hello".to_string(), 20.0)]); + + let mut app = self::app(); + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + let a = id(app.run("text.create", json!({"x": 60, "y": 80, "text": "Hello", "size": 20})).unwrap()); + let b = id(app.run("text.create", json!({"x": 60, "y": 160, "text": "World", "size": 20})).unwrap()); + select(&mut app, &[a, b]); + let (ctx, texts) = bar_texts(&mut app); + type_into(&mut app, &ctx, &texts, "20 pt", "14"); + assert_eq!(sizes(&app, a), [("Hello".to_string(), 14.0)]); + assert_eq!(sizes(&app, b), [("World".to_string(), 14.0)]); +} + +/// At a caret, with no characters selected, the bar's Font Size sets the edited type as the +/// Character panel's does, and the characters typed next take it. +#[test] +fn the_task_bars_font_size_at_a_caret_sets_what_is_typed_next() { + let mut app = app(); + let t = edited_type(&mut app, "type"); + app.session.set_tool_option("select", &json!({"start": 5, "end": 5})); + assert_eq!(crate::panels::character::text_editing(&app), Some((NodeId(t), 5, 5)), "a caret after the type"); + let (ctx, texts) = bar_texts(&mut app); + type_into(&mut app, &ctx, &texts, "20 pt", "30"); + app.session.tool_typed_text("!", app.view_info()).unwrap(); + assert_eq!(sizes(&app, t), [("Hello!".to_string(), 30.0)]); +} + +/// The Type tool's Fill chip makes the Fill active and brings up the Swatches panel. +#[test] +fn the_type_tools_fill_chip_activates_the_fill() { + let mut app = app(); + edited_type(&mut app, "type"); + app.session.fill_active = false; + let (ctx, _) = bar_texts(&mut app); + let r = canvas::task_bar_rect(&ctx).expect("the task bar shows"); + // Right to left: the frame's margin, More Options, then the Fill chip. + let p = Pos2::new(r.right() - 8.0 - 30.0 - 6.0 - 13.0, r.center().y); + let press = |pressed| Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, vec![Event::PointerMoved(p)]); + frame(&mut app, &ctx, vec![press(true)]); + frame(&mut app, &ctx, vec![press(false)]); + assert!(app.session.fill_active, "the Fill is active"); + assert!(crate::panels::character::text_editing(&app).is_some(), "the type is still edited"); +} + +/// Screen readers name the Type tool bar's controls, and the bar stays on the canvas for type at +/// its lower right corner. +#[test] +fn the_type_tools_bar_is_named_and_stays_on_the_canvas() { + use crate::widgets::accessibility_tests::{named, tree}; + let mut app = app(); + edited_type(&mut app, "type"); + let tree = tree(|ui| canvas::show(&mut app, ui)); + for label in ["Font Family", "Style", "Font Size", "Fill"] { + named(&tree, label); + } + let mut app = self::app(); + app.run("tool.select", json!({"tool": "type"})).unwrap(); + app.run("text.create", json!({"x": 380, "y": 290, "text": "Corner", "size": 20})).unwrap(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + // The type near the canvas's lower edge, so the bar's place under it is off the canvas. + let view = app.view_mut().unwrap(); + (view.center, view.zoom) = (vectorcraft_geom::Point::new(200.0, 20.0), 1.0); + let (ctx, _) = bar(&mut app); + let r = canvas::task_bar_rect(&ctx).expect("the task bar shows"); + let canvas = app.canvas_rect.expect("the canvas is laid out"); + assert!(canvas.contains_rect(r), "{r:?} in {canvas:?}"); +} + +/// The task bar's rect after a few frames on a `width` by 700 screen, and the canvas's rect. +fn bar_on_screen(app: &mut VectorcraftApp, width: f32) -> (Rect, Rect) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let screen = Rect::from_min_size(Pos2::ZERO, vec2(width, 700.0)); + for _ in 0..3 { + let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| canvas::show(app, ui)); + out.textures_delta.clear(); + } + (canvas::task_bar_rect(&ctx).expect("the task bar shows"), app.canvas_rect.expect("the canvas is laid out")) +} + +/// On a canvas narrower than one row of the bar, the font controls go on a second row, narrowed to +/// fit, and the bar stays whole on the canvas: selected type's actions, and a Type tool's Fill. +#[test] +fn selected_types_font_controls_take_a_second_row_on_a_narrow_canvas() { + let mut app = app(); + app.run("tool.select", json!({"tool": "selection"})).unwrap(); + let t = id(app.run("text.create", json!({"x": 20, "y": 60, "text": "Hello", "size": 20})).unwrap()); + select(&mut app, &[t]); + let (r, canvas) = bar_on_screen(&mut app, 760.0); + assert!(canvas.contains_rect(r), "{r:?} in {canvas:?}"); + assert!(r.height() > 60.0, "the font controls take a second row: {r:?}"); + + let mut app = self::app(); + app.run("tool.select", json!({"tool": "type"})).unwrap(); + app.run("text.create", json!({"x": 20, "y": 60, "text": "Hello", "size": 20})).unwrap(); + app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap(); + // Narrower than the font controls at their full widths too. + let (r, canvas) = bar_on_screen(&mut app, 300.0); + assert!(canvas.contains_rect(r), "{r:?} in {canvas:?}"); + assert!(r.height() > 60.0, "the font controls take a second row: {r:?}"); + let (r, _) = bar_on_screen(&mut app, 1000.0); + assert!(r.height() < 60.0, "one row where it fits: {r:?}"); + // The Type tool's bar has only the Fill and More Options beside the controls: one row fits. + let (r, canvas) = bar_on_screen(&mut app, 460.0); + assert!(canvas.contains_rect(r) && r.height() < 60.0, "one row on a 460 px screen: {r:?} in {canvas:?}"); + // Too narrow for the controls at their narrowest: the bar leaves them out. + let (r, canvas) = bar_on_screen(&mut app, 160.0); + assert!(canvas.contains_rect(r) && r.height() < 60.0, "no font controls on a 160 px screen: {r:?} in {canvas:?}"); +} + +/// While a Type tool edits type, the Fill chip shows the selected characters' fill, and "?" where +/// their fills differ. +#[test] +fn the_type_tools_fill_chip_shows_the_edited_characters_fill() { + let mut app = app(); + let t = edited_type(&mut app, "type"); + app.session.set_tool_option("select", &json!({"start": 2, "end": 5})); + app.run("paint.setFill", json!({"color": "#ff0000"})).unwrap(); + let fills: Vec<_> = match kind(&app, t) { + NodeKind::Text(t) => t.runs.iter().map(|r| (r.text.clone(), r.style.fill.clone())).collect(), + k => panic!("{k:?}"), + }; + let [(he, he_fill), (llo, llo_fill)] = &fills[..] else { panic!("{fills:?}") }; + assert_eq!((he.as_str(), llo.as_str()), ("He", "llo")); + assert_ne!(he_fill, llo_fill); + let ctx = egui::Context::default(); + let (fill, _, mixed, _) = canvas::chip_paints(&app, &ctx); + assert!(fill == *llo_fill && !mixed, "{fill:?} {mixed}"); + app.session.set_tool_option("select", &json!({"start": 0, "end": 2})); + let (fill, _, mixed, _) = canvas::chip_paints(&app, &ctx); + assert!(fill == *he_fill && !mixed, "{fill:?} {mixed}"); + app.session.set_tool_option("select", &json!({"start": 0, "end": 5})); + let (_, _, mixed, _) = canvas::chip_paints(&app, &ctx); + assert!(mixed, "the fills differ"); +} + /// The task bar shows the selection as it is after an edit and after Undo: Join leaves once the /// open path is closed and comes back with Undo, and Shape Properties shows a rectangle's new /// corner radius and then its old one. diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 826e7e02f6..efb14d7ab6 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -45,7 +45,7 @@ pub use drag::{ }; #[cfg(test)] use dropdowns::combo_button_id; -pub(crate) use dropdowns::{combo, combo_menu, combo_menu_field, combo_named, field_focused_in, popover_is_open}; +pub(crate) use dropdowns::{combo, combo_menu, combo_menu_field_named, combo_named, field_focused_in, popover_is_open}; pub use dropdowns::{ dropdown, dropdown_named, dropdown_names, dropdown_names_named, dropdown_with, dropdown_with_named, list_scroll_style, menu_room, menu_scroll, panel_scroll, popover, set_menu_floor, strip_scroll, diff --git a/crates/ui-egui/src/widgets/dropdowns.rs b/crates/ui-egui/src/widgets/dropdowns.rs index 44903d6613..cc0988c480 100644 --- a/crates/ui-egui/src/widgets/dropdowns.rs +++ b/crates/ui-egui/src/widgets/dropdowns.rs @@ -146,6 +146,18 @@ pub(crate) fn combo_menu_field( current: &str, width: f32, menu: impl FnOnce(craft_ui::combo::Menu<'_>) -> Option, +) -> (Response, Option) { + combo_menu_field_named(ui, id, None, current, width, menu) +} + +/// [`combo_menu_field`] for a field whose visible label is not an adjacent label widget. +pub(crate) fn combo_menu_field_named( + ui: &mut Ui, + id: impl std::hash::Hash, + name: Option<&str>, + current: &str, + width: f32, + menu: impl FnOnce(craft_ui::combo::Menu<'_>) -> Option, ) -> (Response, Option) { let t = Tokens::get(ui.ctx()); let label = take_previous_label(ui); @@ -155,7 +167,7 @@ pub(crate) fn combo_menu_field( .corner_radius(CornerRadius::same(3)) .show(ui, |ui| { let armed = take_armed_focus(ui, &id); - let resp = combo_field(ui, id_with(ui.scope_id(), id), label, None, current, width - 4.0); + let resp = combo_field(ui, id_with(ui.scope_id(), id), label, name, current, width - 4.0); if armed { egui::Popup::open_id(ui.ctx(), egui::Popup::default_response_id(&resp)); ui.memory_mut(|m| m.stop_text_input()); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f04ee3c910..df42147214 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -817,11 +817,12 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Window › SVG Interactivity attaches JavaScript to the selected objects' events (`onclick`, `onmouseover` and 17 more) and links JavaScript files to the document (Link JavaScript Files…); SVG export writes the events as event attributes and the files as `