From 21dbcf6307e90be1d49ac4f438aaad74dcd0f3c2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 20:16:54 -0800 Subject: [PATCH 01/97] Links: list and manage linked art in symbols, graph designs, patterns and masks by id Linked images and placed documents kept only in symbols, graph designs, pattern swatches or opacity masks now have ids of their own. links.list lists them after the layers' own, one row per stored object, with an owner naming what holds them; links.check, the missing, modified and updated files reported on open, the link watch and links.findFiles report them by id; relink (All Instances included), update, embed, placementOptions, info and image.info act on them. links.goTo on one clears the selection and returns onCanvas: false with its owner, and the Links panel says the link has no place on the canvas. - Document::visit_stored, stored_node and stored_node_mut find objects in every stored art. fix_next_id counts their ids and gives an object there whose id is 0, past the id range or already used a new one, updating Objects on Path references to renamed children. - Copies of masked objects give the mask art new ids, and symbols and patterns that placed art brings from another document take ids of the document they join. - The Links panel picks such rows in the panel itself, drops the pick when the canvas selection changes, and draws their thumbnails from a lookup made once per list refresh. Select by Link Status leaves them out, as it cannot select them. --- crates/doc/src/document.rs | 9 +- crates/doc/src/document_guides.rs | 7 + crates/doc/src/lib.rs | 2 + crates/doc/src/stored_art.rs | 376 ++++++++++++++++++ crates/engine/src/cmd/linkfiles.rs | 12 +- crates/engine/src/cmd/links.rs | 203 ++++++---- crates/engine/src/cmd/place.rs | 3 +- crates/engine/src/cmd/place/adopt.rs | 3 + crates/engine/src/cmd/place/document.rs | 8 +- crates/engine/src/tests/links.rs | 204 +++++++++- crates/engine/src/tests/linkspanel.rs | 2 +- crates/engine/src/tests/linkwatch.rs | 24 ++ crates/engine/src/tests/place.rs | 27 ++ .../ui-egui/src/addons/select/link_status.rs | 17 +- .../src/addons/select/link_status/tests.rs | 21 + crates/ui-egui/src/i18n/ca.tsv | 1 + crates/ui-egui/src/i18n/cs.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/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/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/panels/links.rs | 131 +++++- crates/ui-egui/src/panels/tests_links.rs | 46 +++ docs/mcp.md | 20 +- docs/target-app-parity.md | 1 + 41 files changed, 1031 insertions(+), 107 deletions(-) create mode 100644 crates/doc/src/stored_art.rs diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index 708d0aee03..057a9df6a6 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -286,7 +286,9 @@ impl Document { self.next_id = self.next_id.max(id); } - /// Ensure `next_id` is above every id in the tree (after deserializing foreign data). + /// Ensure `next_id` is above every id in the tree (after deserializing foreign data), then give + /// the objects outside the layer tree (opacity-mask art, symbols, graph designs and patterns) + /// whose id is missing or already taken a new one, so each stored object has an id of its own. pub fn fix_next_id(&mut self) { // Ids from [`MAX_NEXT_ID`] up (a damaged or hand-made file: #1718) are left out, as is a // counter up there: new ids start under them and never reach them, where counting on from @@ -297,13 +299,12 @@ impl Document { max = max.max(id); } }; - for l in &self.layers { - l.walk(&mut |n| see(n.id.0)); - } + self.visit_stored(|n, _| see(n.id.0)); self.slices.iter().for_each(|s| see(s.id.0)); self.assets.iter().for_each(|a| see(a.id)); let counter = Some(self.next_id).filter(|n| *n <= MAX_NEXT_ID).unwrap_or(0); self.next_id = counter.max(max.saturating_add(1)); + self.renumber_stored_duplicates(); } /// Find a node anywhere in the tree. diff --git a/crates/doc/src/document_guides.rs b/crates/doc/src/document_guides.rs index 4ec7aab1f6..14d0404bfc 100644 --- a/crates/doc/src/document_guides.rs +++ b/crates/doc/src/document_guides.rs @@ -554,6 +554,13 @@ impl Document { t.cached_baselines.clear(); t.cached_ink = None; } + // Opacity-mask art is stored art too: the copy's gets ids of its own. + if let Some(art) = n.extras().mask.as_ref().map(|m| Arc::clone(&m.art)) { + let art = Arc::new(self.reid(&art)); + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { + m.art = art; + } + } let on_path = n.extras().objects_on_path.is_some(); let mut renamed = std::collections::HashMap::new(); if let Some(ch) = n.children_mut() { diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 3c4bcc20a5..03c7da06c3 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -141,6 +141,7 @@ mod graphic_style; mod guides; mod limits; mod records; +mod stored_art; mod units; pub use artboard::{ARTBOARD_GAP, Artboard, ArtboardSide, LockedArtboards, RetiredArtboardId, VIDEO_PIXEL_ASPECT_RANGE, VIDEO_SAFE_INSETS}; @@ -153,6 +154,7 @@ pub use guides::{GridPrefs, Guide, RulerMode}; use limits::MAX_NEXT_ID; pub use limits::{DocError, FORMAT_VERSION, MAX_COORD}; pub use records::{ImageBlob, MaskEdit, NewArtIds, SavedSelection, SavedView, SvgFilter, Symbol, SymbolEdit}; +pub use stored_art::{StoredPlace, StoredRoot}; pub use units::{ColorMode, Unit, parse_number}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/doc/src/stored_art.rs b/crates/doc/src/stored_art.rs new file mode 100644 index 0000000000..e623e0ed0e --- /dev/null +++ b/crates/doc/src/stored_art.rs @@ -0,0 +1,376 @@ +//! Every object a document stores, wherever it is kept: in the layers, in opacity-mask art, and in +//! the art of symbols, graph designs and pattern swatches. [`Document::node`] finds objects in the +//! layer tree only; the lookups here find the others by their ids too, which +//! [`Document::fix_next_id`] keeps unique across all of them. + +use std::collections::HashSet; +use std::sync::Arc; + +use super::limits::MAX_NEXT_ID; +use super::{Document, Node, NodeId}; + +/// The art a stored object belongs to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StoredRoot { + /// Layer `index` of [`Document::layers`]. + Layer(usize), + /// Symbol `index` of [`Document::symbols`]. + Symbol(usize), + /// Graph design `index` of [`Document::graph_designs`]. + GraphDesign(usize), + /// Object `art` of pattern swatch `pattern` of [`Document::patterns`]. + Pattern { pattern: usize, art: usize }, +} + +/// Where a stored object is: its root and, inside opacity-mask art, the object that mask belongs +/// to (the innermost one when masks hold masks). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct StoredPlace { + pub root: StoredRoot, + pub mask_of: Option, +} + +impl StoredPlace { + /// Is the object in the layer tree itself, where [`Document::node`] finds it and the canvas + /// draws it as itself? + pub fn in_layers(self) -> bool { + matches!(self.root, StoredRoot::Layer(_)) && self.mask_of.is_none() + } +} + +/// One step down from a node: to its child at an index, or to its opacity-mask art. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Step { + Child(usize), + Mask, +} + +/// `n` and everything below it with its place, the opacity-mask art right after the object it +/// masks, then the children. +fn visit<'a>(n: &'a Arc, place: StoredPlace, f: &mut dyn FnMut(&'a Arc, StoredPlace)) { + f(n, place); + if let Some(m) = &n.extras().mask { + visit(&m.art, StoredPlace { mask_of: Some(n.id), ..place }, f); + } + for c in n.children().into_iter().flatten() { + visit(c, place, f); + } +} + +/// The steps from `n` to the first node in [`visit`] order with id `id`, and the object whose mask +/// holds it last on the way. +fn find(n: &Node, id: NodeId, steps: &mut Vec, mask_of: &mut Option) -> bool { + if n.id == id { + return true; + } + if let Some(m) = &n.extras().mask { + steps.push(Step::Mask); + let before = *mask_of; + *mask_of = Some(n.id); + if find(&m.art, id, steps, mask_of) { + return true; + } + *mask_of = before; + steps.pop(); + } + for (i, c) in n.children().into_iter().flatten().enumerate() { + steps.push(Step::Child(i)); + if find(c, id, steps, mask_of) { + return true; + } + steps.pop(); + } + false +} + +/// The node `steps` below `n`. +fn at<'a>(mut n: &'a Arc, steps: &[Step]) -> Option<&'a Arc> { + for s in steps { + n = match s { + Step::Mask => &n.extras().mask.as_ref()?.art, + Step::Child(i) => n.children()?.get(*i)?, + }; + } + Some(n) +} + +/// The node `steps` below `n` to change, the nodes on the way copied when shared. +fn at_mut<'a>(mut n: &'a mut Arc, steps: &[Step]) -> Option<&'a mut Node> { + for s in steps { + let node = Arc::make_mut(n); + n = match s { + Step::Mask => &mut node.extras_if_any()?.mask.as_mut()?.art, + Step::Child(i) => node.children_mut()?.get_mut(*i)?, + }; + } + Some(Arc::make_mut(n)) +} + +/// Gives every node from `n` down that `fresh` names a new id from `next`, counting nodes in +/// [`visit`] order from `*count`; only the nodes on the way to one are copied. Each part below is +/// renumbered through a second handle, so a new pointer marks a change. +fn renumber(n: &mut Arc, fresh: &HashSet, count: &mut usize, next: &mut u64) { + let own = fresh.contains(count); + *count += 1; + let mask = n.extras().mask.as_ref().and_then(|m| { + let mut art = Arc::clone(&m.art); + renumber(&mut art, fresh, count, next); + (!Arc::ptr_eq(&art, &m.art)).then_some(art) + }); + let mut slots = vec![]; + // Children's new ids by their old ones, for an Objects on Path group, which names its path and + // members by id. An id another child keeps still names that child. + let mut renamed = std::collections::HashMap::new(); + let mut kept = HashSet::new(); + for (i, c) in n.children().into_iter().flatten().enumerate() { + let mut new = Arc::clone(c); + renumber(&mut new, fresh, count, next); + if new.id == c.id { + kept.insert(c.id); + } else { + renamed.insert(c.id, new.id); + } + if !Arc::ptr_eq(&new, c) { + slots.push((i, new)); + } + } + renamed.retain(|old, _| !kept.contains(old)); + if !own && mask.is_none() && slots.is_empty() { + return; + } + let node = Arc::make_mut(n); + if own { + node.id = NodeId(*next); + *next += 1; + } + if let (Some(art), Some(m)) = (mask, node.extras_if_any().and_then(|e| e.mask.as_mut())) { + m.art = art; + } + if let Some(children) = node.children_mut() { + for (i, c) in slots { + if let Some(slot) = children.get_mut(i) { + *slot = c; + } + } + } + if !renamed.is_empty() + && let Some(on_path) = node.extras_if_any().and_then(|e| e.objects_on_path.as_mut()) + { + on_path.rename(&renamed); + } +} + +impl Document { + /// The roots of the art kept outside the layers, each with its [`StoredRoot`]. + fn definition_roots(&self) -> impl Iterator, StoredRoot)> { + let symbols = self.symbols.iter().enumerate().map(|(i, s)| (&s.art, StoredRoot::Symbol(i))); + let designs = self.graph_designs.iter().enumerate().map(|(i, g)| (&g.art, StoredRoot::GraphDesign(i))); + let patterns = self + .patterns + .iter() + .enumerate() + .flat_map(|(p, def)| def.art.iter().enumerate().map(move |(a, n)| (n, StoredRoot::Pattern { pattern: p, art: a }))); + symbols.chain(designs).chain(patterns) + } + + /// Every root of stored art: the layers, then the art of symbols, graph designs and patterns. + fn stored_roots(&self) -> impl Iterator, StoredRoot)> { + self.layers.iter().enumerate().map(|(i, l)| (l, StoredRoot::Layer(i))).chain(self.definition_roots()) + } + + /// The root `root` names, to change. + fn stored_root_mut(&mut self, root: StoredRoot) -> Option<&mut Arc> { + match root { + StoredRoot::Layer(i) => self.layers.get_mut(i), + StoredRoot::Symbol(i) => self.symbols.get_mut(i).map(|s| &mut s.art), + StoredRoot::GraphDesign(i) => self.graph_designs.get_mut(i).map(|g| &mut g.art), + StoredRoot::Pattern { pattern, art } => self.patterns.get_mut(pattern)?.art.get_mut(art), + } + } + + /// Visit every stored object with its place: the layer tree with the opacity-mask art of its + /// objects, then the art of symbols, graph designs and pattern swatches (their definitions, + /// once each, never the copies instances and tiles draw). + pub fn visit_stored<'a>(&'a self, mut f: impl FnMut(&'a Node, StoredPlace)) { + self.visit_stored_arcs(|n, place| f(n, place)); + } + + /// [`Self::visit_stored`] with each object's shared handle, which keeps its identity from one + /// look to the next while the object is unchanged (for caches such as thumbnails). + pub fn visit_stored_arcs<'a>(&'a self, mut f: impl FnMut(&'a Arc, StoredPlace)) { + for (root, r) in self.stored_roots() { + visit(root, StoredPlace { root: r, mask_of: None }, &mut f); + } + } + + /// Where object `id` is stored: the root, and the steps from it. + fn stored_path(&self, id: NodeId) -> Option<(StoredPlace, Vec)> { + if let Some(path) = self.index_path(id) { + let (first, rest) = path.split_first()?; + return Some((StoredPlace { root: StoredRoot::Layer(*first), mask_of: None }, rest.iter().map(|i| Step::Child(*i)).collect())); + } + // Not in the layer tree: in mask art (in the layers too) or in a definition's art. + for (root, r) in self.stored_roots() { + let (mut steps, mut mask_of) = (vec![], None); + if find(root, id, &mut steps, &mut mask_of) { + return Some((StoredPlace { root: r, mask_of }, steps)); + } + } + None + } + + /// Stored object `id` (see [`Self::visit_stored`]) with its place: one in the layer tree as + /// [`Self::node`] finds it, else the first in visiting order. + pub fn stored_node(&self, id: NodeId) -> Option<(&Arc, StoredPlace)> { + let (place, steps) = self.stored_path(id)?; + let root = self.stored_roots().find(|(_, r)| *r == place.root)?.0; + Some((at(root, &steps)?, place)) + } + + /// [`Self::stored_node`] to change; the nodes on the way are copied when shared. + pub fn stored_node_mut(&mut self, id: NodeId) -> Option<&mut Node> { + let (place, steps) = self.stored_path(id)?; + at_mut(self.stored_root_mut(place.root)?, &steps) + } + + /// Gives the objects outside the layer tree (in opacity-mask art and in the art of symbols, + /// graph designs and patterns) whose id is 0, past the id range, or already taken by an object + /// visited before them (the layer tree's own objects first) a new id, so every stored object + /// can be named by its id. Returns how many changed; the id counter then stands above them. + pub(crate) fn renumber_stored_duplicates(&mut self) -> usize { + let mut seen: HashSet = HashSet::new(); + for l in &self.layers { + l.walk(&mut |n| { + seen.insert(n.id); + }); + } + self.slices.iter().for_each(|s| { + seen.insert(s.id); + }); + // Per root, the visiting indices of the nodes that take a new id. + let mut fresh: Vec<(StoredRoot, HashSet)> = vec![]; + for (root, r) in self.stored_roots() { + let (mut i, mut here) = (0, HashSet::new()); + visit(root, StoredPlace { root: r, mask_of: None }, &mut |n, place| { + if !place.in_layers() && (n.id.0 == 0 || n.id.0 >= MAX_NEXT_ID || !seen.insert(n.id)) { + here.insert(i); + } + i += 1; + }); + if !here.is_empty() { + fresh.push((r, here)); + } + } + let total = fresh.iter().map(|(_, f)| f.len()).sum(); + if total == 0 { + return 0; + } + // No id left to give: the objects keep theirs rather than share one given out later. + if self.peek_next_id().saturating_add(total as u64) > MAX_NEXT_ID { + return 0; + } + let mut next = self.peek_next_id(); + for (r, f) in fresh { + if let Some(root) = self.stored_root_mut(r) { + renumber(root, &f, &mut 0, &mut next); + } + } + self.raise_next_id(next); + total + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{GraphDesign, OpacityMask, PatternDef, Symbol}; + use vectorcraft_geom::Rect; + + fn rect(id: u64) -> Node { + Node::path(NodeId(id), vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), crate::Appearance::default_art()) + } + + fn masked(id: u64, art: Node) -> Node { + rect(id).with_mask(Some(Box::new(OpacityMask::new(art, true)))) + } + + /// A document with object 10 in its layer, mask art 11 on it, and one object in a symbol, + /// a graph design and a pattern (ids `defs`). + fn doc(defs: [u64; 3]) -> Document { + let mut d = Document::new(100.0, 100.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, masked(10, rect(11))).unwrap(); + d.symbols.push(Symbol { name: "S".into(), art: Arc::new(rect(defs[0])), dynamic: false }); + d.graph_designs.push(GraphDesign { name: "G".into(), art: Arc::new(rect(defs[1])) }); + d.patterns.push(PatternDef::new("P", vec![Arc::new(rect(defs[2]))])); + d.raise_next_id(20); + d + } + + #[test] + fn stored_objects_are_found_in_every_art() { + let d = doc([12, 13, 14]); + let place = |id: u64| d.stored_node(NodeId(id)).map(|(n, p)| (n.id.0, p)); + assert!(place(10).unwrap().1.in_layers()); + assert_eq!(place(11).unwrap().1, StoredPlace { root: StoredRoot::Layer(0), mask_of: Some(NodeId(10)) }); + assert_eq!(place(12).unwrap().1.root, StoredRoot::Symbol(0)); + assert_eq!(place(13).unwrap().1.root, StoredRoot::GraphDesign(0)); + assert_eq!(place(14).unwrap().1.root, StoredRoot::Pattern { pattern: 0, art: 0 }); + assert!(d.node(NodeId(11)).is_none() && d.node(NodeId(12)).is_none(), "the layer lookup stays the layer tree's"); + assert!(d.stored_node(NodeId(99)).is_none()); + let mut seen = vec![]; + d.visit_stored(|n, _| seen.push(n.id.0)); + assert_eq!(seen, [d.layers[0].id.0, 10, 11, 12, 13, 14]); + } + + #[test] + fn stored_objects_change_by_id() { + let mut d = doc([12, 13, 14]); + let before = d.clone(); + d.stored_node_mut(NodeId(13)).unwrap().name = Some("changed".into()); + assert_eq!(d.graph_designs[0].art.name.as_deref(), Some("changed")); + assert!(Arc::ptr_eq(&d.symbols[0].art, &before.symbols[0].art), "other art is not copied"); + assert_eq!(before.graph_designs[0].art.name, None, "the earlier document is unchanged"); + d.stored_node_mut(NodeId(11)).unwrap().name = Some("mask".into()); + assert_eq!(d.stored_node(NodeId(11)).unwrap().0.name.as_deref(), Some("mask")); + } + + #[test] + fn fix_next_id_gives_nested_duplicates_new_ids() { + // The symbol's object repeats the layer's object, the design's has no id, and the pattern's + // repeats the mask art's. + let mut d = doc([10, 0, 11]); + d.next_id = 1; + d.fix_next_id(); + let mut ids = vec![]; + d.visit_stored(|n, _| ids.push(n.id.0)); + let unique: HashSet = ids.iter().copied().collect(); + assert_eq!(unique.len(), ids.len(), "{ids:?}"); + assert!(!ids.contains(&0)); + assert_eq!(&ids[..3], &[d.layers[0].id.0, 10, 11], "the layer tree and the first mask art keep theirs"); + assert!(ids.iter().all(|id| *id < d.peek_next_id()), "{ids:?} {}", d.peek_next_id()); + // Ids already unique are kept, and a second pass changes nothing. + let again = d.clone(); + assert_eq!(d.renumber_stored_duplicates(), 0); + assert_eq!(d, again); + } + + #[test] + fn renumbered_objects_on_path_keep_their_path_and_members() { + // A symbol's Objects on Path group whose path repeats the layer's object 10 and whose + // member has no id. + let mut d = doc([12, 13, 14]); + let (path, member) = (rect(10), rect(0)); + let rec = crate::objects_on_path::ObjectsOnPath::new(path.id, &[&member], crate::objects_on_path::CENTER, 0.0); + let mut g = Node::new(NodeId(15), crate::NodeKind::Group { children: vec![Arc::new(path), Arc::new(member)], clip: false }); + g.extras_mut().objects_on_path = Some(Box::new(rec)); + d.symbols[0].art = Arc::new(g); + d.fix_next_id(); + let art = &d.symbols[0].art; + let kids: Vec = art.children().unwrap().iter().map(|c| c.id).collect(); + assert!(kids.iter().all(|id| id.0 != 10 && id.0 != 0), "{kids:?}"); + let on_path = art.extras().objects_on_path.as_ref().unwrap(); + assert_eq!(on_path.path, kids[0]); + assert_eq!(on_path.members.iter().map(|m| m.id).collect::>(), [kids[1]]); + } +} diff --git a/crates/engine/src/cmd/linkfiles.rs b/crates/engine/src/cmd/linkfiles.rs index f2ce9d4d12..99295e5c79 100644 --- a/crates/engine/src/cmd/linkfiles.rs +++ b/crates/engine/src/cmd/linkfiles.rs @@ -13,7 +13,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::NodeKind; use super::findfiles::{self, Limits, Visitor}; -use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, bool_or, checked_ids_param, cmd}; +use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, bool_or, cmd, ids_param}; /// The most linked files one search looks for. const MAX_LINKS: usize = 1_000; @@ -143,8 +143,14 @@ fn find(s: &mut Session, p: &Value) -> Result { let ids = match p.get("ids") { None | Some(Value::Null) => None, Some(_) => { - let ids = checked_ids_param(s, p, "ids", C)?; - let linkable = |id: &NodeId| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Image(_) | NodeKind::PlacedDocument(_))); + // Images and placed documents in opacity-mask art and in the art of symbols, graph + // designs and patterns are named by their ids too (`links.list`). + let ids = ids_param(p, "ids", C)?.unwrap_or_default(); + let kind = |id: &NodeId| st.doc.stored_node(*id).map(|(n, _)| &n.kind); + if let Some(value) = ids.iter().find(|id| kind(id).is_none()).map(|id| id.0) { + return Err(bad(C, format!("no such object id {value}"))); + } + let linkable = |id: &NodeId| matches!(kind(id), Some(NodeKind::Image(_) | NodeKind::PlacedDocument(_))); if let Some(id) = ids.iter().find(|id| !linkable(id)) { return Err(bad(C, format!("object {} is not an image or a linked file", id.0))); } diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index d79320c304..3bfd4a1217 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -30,6 +30,7 @@ use serde_json::{Value, json}; use vectorcraft_doc::links::{Align, Preserve, hash_bytes}; use vectorcraft_doc::{ Appearance, DataValue, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions, + StoredPlace, StoredRoot, }; use vectorcraft_geom::{Affine, PathData, Rect, shapes}; @@ -43,7 +44,7 @@ pub fn specs() -> Vec { "Check Links", [], None, - "{ids?: [image or placed document ids] (default: every linked image and placed document)} where each linked file is and whether it changed since it was read → {links: [{name, path?, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file), refused?: why the path is not followed (another computer, a device or a URL: it counts as missing)}], missing, modified}; under automation read roots, path and found are left out when the file they name is outside the roots", + "{ids?: [image or placed document ids] (default: every linked image and placed document, in the layers and in opacity-mask art, symbols, graph designs and patterns)} where each linked file is and whether it changed since it was read → {links: [{name, path?, status: ok|modified|missing, ids, found?: path (found away from its path: relative to the document, or by name in its folder), preview: true (showing the low-resolution preview, not the file), refused?: why the path is not followed (another computer, a device or a URL: it counts as missing)}], missing, modified}; under automation read roots, path and found are left out when the file they name is outside the roots", has_doc, check ), @@ -52,7 +53,7 @@ pub fn specs() -> Vec { "Update Links", [], None, - "{ids?: [image or placed document ids] (default: the linked images whose file was modified or is showing its preview, and placed documents whose file was modified)} read their linked files again; each keeps its bounds. The images and placed documents inside symbols, graph designs, patterns and opacity masks that show the same content are read again too (by default also those only that art shows); they have no ids to list. One undo step → {updated: [ids], missing: [ids]}", + "{ids?: [image or placed document ids] (default: the linked images whose file was modified or is showing its preview, and placed documents whose file was modified)} read their linked files again; each keeps its bounds. Images and placed documents inside symbols, graph designs, patterns and opacity masks have ids of their own, as links.list gives them; those that show the same content as one read again are read again too. One undo step → {updated: [ids], missing: [ids]}", has_doc, update ), @@ -70,7 +71,7 @@ pub fn specs() -> Vec { "Relink", [], None, - "{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, scope?: selected (default: ids) | all (ids and every other object in the layers linked to the same file as one of them) | missingInFolder (with path: ids, then every other missing link whose file name is in path's folder, each to the file of its name there; names not there are in notFound; with copy, only images are looked for) | each (with paths), paths?: [{ids, path}] (with scope each, in place of ids, path and folder: each link to its own file; an id in two entries is an error), copy?: false (with path or paths, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy; to is the file name alone when the document's folder is outside the automation read roots)}", + "{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, scope?: selected (default: ids) | all (ids and every other object linked to the same file as one of them, in the layers, opacity-mask art, symbols, graph designs and patterns) | missingInFolder (with path: ids, then every other missing link whose file name is in path's folder, each to the file of its name there; names not there are in notFound; with copy, only images are looked for) | each (with paths), paths?: [{ids, path}] (with scope each, in place of ids, path and folder: each link to its own file; an id in two entries is an error), copy?: false (with path or paths, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy; to is the file name alone when the document's folder is outside the automation read roots)}", has_doc, relink ), @@ -79,7 +80,7 @@ pub fn specs() -> Vec { "Links", [], None, - "{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first} every image object and placed document in the layers, as the Links panel lists them → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels)}], missing, modified, embedded}; under automation read roots, path and found are left out when the file they name is outside the roots", + "{show?: all (default)|missing|modified|embedded, sort?: name|kind (file format)|status (missing, modified, ok, embedded); default: stacking order; top first} every image object and placed document the document stores, as the Links panel lists them: those in the layers, then those in opacity-mask art and in the art of symbols, graph designs and patterns (one row per stored object, never one per symbol instance or pattern tile) → {links: [{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?: path (found away from its path), page?, preview?: true (showing the saved preview), note?: said of an image past the decode allowance (shown at reduced size, or too large to draw), document?: true (a placed document: pageWidth and pageHeight in pt instead of pixels), owner?: {kind: layers|symbol|graphDesign|pattern, name? (of the symbol, graph design or pattern), maskOf?: id (in the opacity-mask art of that object)} (an object outside the layer tree)}], missing, modified, embedded}; under automation read roots, path and found are left out when the file they name is outside the roots", has_doc, list ), @@ -88,7 +89,7 @@ pub fn specs() -> Vec { "Go To Link", [], None, - "{id?} select image or placed document `id` (default: the first selected one); the Links panel scrolls it into view → {id, bounds: [x0, y0, x1, y1]}", + "{id?} select image or placed document `id` (default: the first selected one); the Links panel scrolls it into view → {id, bounds: [x0, y0, x1, y1], onCanvas: true}; one in opacity-mask art or in the art of a symbol, graph design or pattern has no place of its own on the canvas: the selection is cleared → {id, onCanvas: false, owner} (owner as links.list gives it)", has_doc, go_to ), @@ -156,7 +157,8 @@ impl Status { struct Group { link: LinkInfo, key: String, - /// The objects in the layers (images in symbols and patterns share the content, not an id). + /// The stored objects showing it: in the layers, in opacity-mask art and in the art of symbols, + /// graph designs and patterns ([`Document::visit_stored`]), each once. ids: Vec, /// Placed documents: what they show. document: Option, @@ -175,13 +177,16 @@ fn linked(n: &Node) -> Option<(&LinkInfo, &str, Option)> { } } -/// Every linked file of `d` with the ids of the objects showing it (for [`crate::link_watch`]). +/// Every linked file of `d` with the ids of the objects showing it, wherever they are stored (for +/// [`crate::link_watch`]). pub(crate) fn linked_groups(d: &Document) -> Vec<(String, Vec)> { groups(d, None).into_iter().filter(|g| !g.ids.is_empty()).map(|g| (g.link.path, ids_json(&g.ids))).collect() } -/// The linked images and placed documents of `d` by file and content: those in the layers (only `ids` when -/// given), then, without `ids`, the ones only symbols and patterns show. +/// The linked images and placed documents of `d` by file and content, wherever they are stored +/// (only `ids` when given), in [`Document::visit_stored`] order. An object whose id an object +/// visited before it has (art being edited on a layer and its definition) counts once, by its +/// first place; without `ids` its content is still grouped. fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec { let mut out: Vec = vec![]; let mut index: BTreeMap<(String, String, bool), usize> = BTreeMap::new(); @@ -196,24 +201,48 @@ fn groups(d: &Document, ids: Option<&[NodeId]>) -> Vec { g.ids.push(id); } }; - d.walk(|n| { - if let Some((l, key, document)) = linked(n) - && wanted.as_ref().is_none_or(|ids| ids.contains(&n.id)) - { + let mut seen = std::collections::HashSet::new(); + d.visit_stored(|n, _| { + let Some((l, key, document)) = linked(n) else { return }; + // Id 0 names no object. + let first = n.id.0 != 0 && seen.insert(n.id); + if first && wanted.as_ref().is_none_or(|ids| ids.contains(&n.id)) { add(l, key, document, Some(n.id)); + } else if ids.is_none() { + add(l, key, document, None); } }); - if ids.is_none() { - d.visit_images(|_, im| { - if let Some(l) = &im.link { - add(l, &im.key, None, None); - } - }); - d.visit_placed(|_, p| add(&p.link, &p.key, Some((p.bounding, p.page_box)), None)); - } out } +/// Stored object `id` of `d` ([`Document::stored_node`]). +fn stored(d: &Document, id: NodeId) -> Option<&Node> { + d.stored_node(id).map(|(n, _)| &**n) +} + +/// Of `ids`, the objects of `d` outside the layer tree (where [`Document::node`] finds none). +fn nested_ids(d: &Document, ids: &[NodeId]) -> std::collections::HashSet { + ids.iter().copied().filter(|id| d.node(*id).is_none()).collect() +} + +/// `links.list`'s `owner` of an object stored at `place` of `d`, when it is not in the layer tree +/// itself: `{kind: layers|symbol|graphDesign|pattern, name?, maskOf?}`. +fn owner_json(d: &Document, place: StoredPlace) -> Option { + if place.in_layers() { + return None; + } + let mut owner = match place.root { + StoredRoot::Layer(_) => json!({ "kind": "layers" }), + StoredRoot::Symbol(i) => json!({ "kind": "symbol", "name": d.symbols.get(i).map(|s| s.name.as_str()) }), + StoredRoot::GraphDesign(i) => json!({ "kind": "graphDesign", "name": d.graph_designs.get(i).map(|g| g.name.as_str()) }), + StoredRoot::Pattern { pattern, .. } => json!({ "kind": "pattern", "name": d.patterns.get(pattern).map(|p| p.name.as_str()) }), + }; + if let Some(m) = place.mask_of { + owner["maskOf"] = json!(m.0); + } + Some(owner) +} + /// A linked file where it was found. struct Found { path: String, @@ -455,8 +484,8 @@ pub(crate) fn full_linked_images<'a>(d: &'a Document, base: LinkBase) -> (Cow<'a } /// The linked files of the open document `st` that `links.findFiles` looks for, each with the -/// objects in the layers that show it: the files of objects `ids` whatever their status, else the -/// files that can't be found. +/// stored objects that show it: the files of objects `ids` whatever their status, else the files +/// that can't be found. pub(crate) fn files_to_find(st: &crate::DocState, ids: Option<&[NodeId]>) -> Vec<(LinkInfo, Vec)> { groups(&st.doc, ids) .into_iter() @@ -587,7 +616,7 @@ fn clip_shape((frame, room): Clip) -> PathData { } /// Remakes the linked objects outside the layer tree (in symbols, graph designs, patterns and -/// opacity masks, where [`Document::node`] finds no ids) that `pick` holds for. +/// opacity masks, where [`Document::node`] does not find them) that `pick` holds for. struct Nested<'a> { pick: &'a dyn Fn(&Node) -> bool, /// The object an object becomes, and the clip it takes ([`placed_xf`]). @@ -703,10 +732,10 @@ impl Nested<'_> { } } -/// Placed documents `ids` show `src`, read from the file `link` points at, each placed as its -/// Placement Options say (`force`: as that Preserve choice says, whatever they say); with `also` -/// (a file's path and the content shown), so do the placed documents outside the layer tree that -/// show that content. +/// Placed documents `ids` (in the layers or outside them) show `src`, read from the file `link` +/// points at, each placed as its Placement Options say (`force`: as that Preserve choice says, +/// whatever they say); with `also` (a file's path and the content shown), so do the placed +/// documents outside the layer tree that show that content. fn install_document( d: &mut Document, ids: &[NodeId], @@ -721,8 +750,10 @@ fn install_document( let o = PlacementOptions { preserve: force.unwrap_or(p.placement.preserve), ..p.placement }; placed_xf(p.xf, (p.width, p.height), (1.0, 1.0), (w, h), (1.0, 1.0), o) }; - if let Some(also) = also { - let pick = |n: &Node| shows(n, also, true); + let nested = nested_ids(d, ids); + if also.is_some() || !nested.is_empty() { + let pick = + |n: &Node| matches!(n.kind, NodeKind::PlacedDocument(_)) && (nested.contains(&n.id) || also.is_some_and(|also| shows(n, also, true))); let mut make = |n: &Node| { let mut n = n.clone(); let mut clip = None; @@ -843,22 +874,33 @@ pub(crate) fn set_image_from_path(d: &mut Document, id: NodeId, path: &str, doc_ install(d, &[id], read, Some(Preserve::Bounds), None) } -/// Image objects `ids` show the file read for `link` (`img`), each placed as its Placement -/// Options say (`force`: as that Preserve choice says, whatever they say); with `also` (a file's -/// path and the content shown), so do the images outside the layer tree that show that content. +/// Image objects `ids` (in the layers or outside them) show the file read for `link` (`img`), each +/// placed as its Placement Options say (`force`: as that Preserve choice says, whatever they say); +/// with `also` (a file's path and the content shown), so do the images outside the layer tree that +/// show that content. fn install(d: &mut Document, ids: &[NodeId], (link, img): FileRead, force: Option, also: Option<(&str, &str)>) -> Result<()> { let new_pt = super::place::pt_per_px(img.ppi); let mut clips = vec![]; - if let Some(also) = also { - // Every image picked shows content `also.1`, so one resolution serves them all. - let old_pt = file_pt(d, also.1); - let pick = |n: &Node| shows(n, also, false); + let nested = nested_ids(d, ids); + if also.is_some() || !nested.is_empty() { + let pick = |n: &Node| matches!(n.kind, NodeKind::Image(_)) && (nested.contains(&n.id) || also.is_some_and(|also| shows(n, also, false))); + // The resolution of each content the images picked show, looked up once each. + let mut old_pts: BTreeMap = BTreeMap::new(); + d.visit_stored(|n, _| { + if let NodeKind::Image(im) = &n.kind + && pick(n) + && !old_pts.contains_key(&im.key) + { + old_pts.insert(im.key.clone(), file_pt(d, &im.key)); + } + }); let mut make = |n: &Node| { let mut n = n.clone(); let mut clip = None; if let NodeKind::Image(im) = &mut n.kind { let o = PlacementOptions { preserve: force.unwrap_or(im.placement.preserve), ..im.placement }; let size = |w: u32, h: u32| (w as f64, h as f64); + let old_pt = old_pts.get(&im.key).copied().unwrap_or_else(|| super::place::pt_per_px(None)); let (xf, c) = placed_xf(im.xf, size(im.width, im.height), old_pt, size(img.width, img.height), new_pt, o); (im.key, im.width, im.height, im.xf, im.link) = (img.key.clone(), img.width, img.height, xf, Some(link.clone())); clip = c; @@ -1039,7 +1081,7 @@ pub fn resolve(d: &mut Document, base: LinkBase, update: bool, low_res_proxy_eps } } // The objects in symbols, graph designs, patterns and opacity masks that show the same file and - // content, which have no ids here, whatever else their links hold. + // content, whatever else their links hold, those whose id an earlier object has included. if !moved.is_empty() { let mut by_path: BTreeMap> = BTreeMap::new(); for (path, key, document, new) in moved { @@ -1074,13 +1116,25 @@ fn link_mut(n: &mut Node) -> Option<&mut LinkInfo> { } } -/// Linked image objects (or placed documents) `ids` link to `link`. +/// Linked image objects (or placed documents) `ids`, in the layers or outside them, link to `link`. fn set_links(d: &mut Document, ids: &[NodeId], link: &LinkInfo) { - for id in ids { + let nested = nested_ids(d, ids); + for id in ids.iter().filter(|id| !nested.contains(id)) { if let Some(l) = d.node_mut(*id).and_then(link_mut) { *l = link.clone(); } } + if !nested.is_empty() { + let pick = |n: &Node| nested.contains(&n.id) && linked(n).is_some(); + let mut make = |n: &Node| { + let mut out = n.clone(); + if let Some(l) = link_mut(&mut out) { + *l = link.clone(); + } + (out, None) + }; + Nested { pick: &pick, make: &mut make, next: 0 }.run(d); + } } /// Placed documents of the document saved at `path` (document `saved` of `s`) in the other open @@ -1309,7 +1363,7 @@ fn update(s: &mut Session, p: &Value) -> Result { /// Update Links in the active document of `s` for objects `ids` (default: the linked files that /// changed or show their preview), or with `path`, for the placed documents of the file at `path` /// whatever their status. The objects in symbols, graph designs, patterns and opacity masks that -/// show the same content are read again too, and have no ids to report. +/// show the same content are read again too. fn update_where(s: &mut Session, ids: Option>, path: Option<&str>) -> Result { let st = s.doc()?; let (mut reads, mut missing) = (vec![], vec![]); @@ -1341,7 +1395,7 @@ enum Scope { Each, /// The objects given or selected. Selected, - /// Every object in the layers linked to the same file as one given or selected. + /// Every stored object linked to the same file as one given or selected. All, /// The objects given or selected, then every other missing link whose file name is in the new /// file's folder. @@ -1425,7 +1479,7 @@ fn relink(s: &mut Session, p: &Value) -> Result { }; // An image or a placed document, with its link. let image = |id: &NodeId| { - st.doc.node(*id).and_then(|n| match &n.kind { + stored(&st.doc, *id).and_then(|n| match &n.kind { NodeKind::Image(im) => Some(im.link.as_ref()), NodeKind::PlacedDocument(p) => Some(Some(&p.link)), _ => None, @@ -1450,22 +1504,23 @@ fn relink(s: &mut Session, p: &Value) -> Result { if ids.is_empty() { return Err(bad(C, "select the images to relink, or pass ids")); } - // All Instances: the other objects in the layers linked to the same files. + // All Instances: the other stored objects linked to the same files, each once. if scope == Scope::All { let paths: BTreeSet<&str> = ids.iter().filter_map(|id| image(id).flatten()).map(|l| l.path.as_str()).collect(); - let given: BTreeSet = ids.iter().copied().collect(); + let mut given: BTreeSet = ids.iter().copied().collect(); let mut more = vec![]; - st.doc.walk(|n| { + st.doc.visit_stored(|n, _| { if let Some((l, _, _)) = linked(n) && paths.contains(l.path.as_str()) - && !given.contains(&n.id) + && n.id.0 != 0 + && given.insert(n.id) { more.push(n.id); } }); ids.extend(more); } - if copy_to.is_some() && ids.iter().any(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::PlacedDocument(_)))) { + if copy_to.is_some() && ids.iter().any(|id| matches!(stored(&st.doc, *id).map(|n| &n.kind), Some(NodeKind::PlacedDocument(_)))) { return Err(bad(C, "copy takes images only: relink placed documents where their files are")); } // (ids, the file) per file to read. @@ -1501,7 +1556,7 @@ fn relink(s: &mut Session, p: &Value) -> Result { let mut images = vec![]; let mut documents: Vec<((u32, bool, PageBox), Vec)> = vec![]; for id in ids { - match st.doc.node(id).map(|n| &n.kind) { + match stored(&st.doc, id).map(|n| &n.kind) { Some(NodeKind::PlacedDocument(pl)) => { let k = (pl.page(), pl.bounding, pl.page_box); match documents.iter_mut().find(|(e, _)| *e == k) { @@ -1643,9 +1698,9 @@ fn copy_into_folder(_: &str, _: &[u8], _: &Path) -> Result<(String, bool)> { // ---------- the Links panel ---------- -/// Image object `id` of `d`. +/// Image object `id` of `d`, wherever it is stored. fn image_of(d: &Document, id: NodeId) -> Option<&ImageObject> { - match &d.node(id)?.kind { + match &stored(d, id)?.kind { NodeKind::Image(im) => Some(im), _ => None, } @@ -1663,7 +1718,7 @@ fn image_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result { .ok_or_else(|| bad(cmd, "select an image, or pass id"))?; match image_of(&st.doc, id) { Some(_) => Ok(id), - None if st.doc.node(id).is_none() => Err(crate::EngineError::NoNode(id)), + None if stored(&st.doc, id).is_none() => Err(crate::EngineError::NoNode(id)), None => Err(bad(cmd, format!("object {} is not an image", id.0))), } } @@ -1677,9 +1732,9 @@ fn placement_mut(n: &mut Node) -> Option<&mut PlacementOptions> { } } -/// The Placement Options of image or placed document `id` of `d`. +/// The Placement Options of image or placed document `id` of `d`, wherever it is stored. fn placement_of(d: &Document, id: NodeId) -> Option { - match &d.node(id)?.kind { + match &stored(d, id)?.kind { NodeKind::Image(im) => Some(im.placement), NodeKind::PlacedDocument(p) => Some(p.placement), _ => None, @@ -1692,12 +1747,12 @@ fn linked_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result { let id = object_param(p, "id", cmd)?.or_else(first).ok_or_else(|| bad(cmd, "select an image, or pass id"))?; match placement_of(&st.doc, id) { Some(_) => Ok(id), - None if st.doc.node(id).is_none() => Err(crate::EngineError::NoNode(id)), + None if stored(&st.doc, id).is_none() => Err(crate::EngineError::NoNode(id)), None => Err(bad(cmd, format!("object {} is not an image or a linked file", id.0))), } } -/// Per linked image in the layers of `st`: its file's status, where it was found when away from +/// Per linked image or placed document of `st`, wherever it is stored: its file's status, where it was found when away from /// its path, and whether the image shows its preview (each file probed once). pub(crate) fn statuses(st: &crate::DocState) -> BTreeMap, bool)> { let mut out = BTreeMap::new(); @@ -1740,17 +1795,20 @@ fn list(s: &mut Session, p: &Value) -> Result { return Err(bad(C, format!("show `{show}`: all, missing, modified or embedded"))); } let statuses = statuses(st); - let mut objects = vec![]; - st.doc.walk(|n| { - if matches!(n.kind, NodeKind::Image(_) | NodeKind::PlacedDocument(_)) { - objects.push(n); + // The layer tree's objects, top first as the Layers panel lists them, then the others in the + // order they are stored (opacity-mask art, symbols, graph designs, patterns), each once. + let (mut objects, mut nested) = (vec![], vec![]); + let mut seen = std::collections::HashSet::new(); + st.doc.visit_stored(|n, place| { + if matches!(n.kind, NodeKind::Image(_) | NodeKind::PlacedDocument(_)) && n.id.0 != 0 && seen.insert(n.id) { + if place.in_layers() { objects.push((n, place)) } else { nested.push((n, place)) } } }); - // Top first, as the Layers panel lists them. objects.reverse(); + objects.extend(nested); let mut counts: BTreeMap<&str, usize> = BTreeMap::new(); let mut rows = vec![]; - for n in objects { + for (n, place) in objects { let (status, found, preview) = match statuses.get(&n.id) { Some((s, f, p)) => (s.as_str(), f.as_deref(), *p), None => ("embedded", None, false), @@ -1793,6 +1851,9 @@ fn list(s: &mut Session, p: &Value) -> Result { if preview { row["preview"] = json!(true); } + if let Some(owner) = owner_json(&st.doc, place) { + row["owner"] = owner; + } rows.push(row); } let text = |r: &Value, k: &str| r[k].as_str().unwrap_or_default().to_lowercase(); @@ -1810,15 +1871,21 @@ fn list(s: &mut Session, p: &Value) -> Result { fn go_to(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let id = linked_param(st, p, "links.goTo")?; + // An object in mask art or a definition has no place of its own on the canvas: the selection + // is cleared, and the reply names what holds it. + if let Some(owner) = st.doc.stored_node(id).and_then(|(_, place)| owner_json(&st.doc, place)) { + s.select(|_, sel| sel.set([]))?; + return Ok(json!({ "id": id.0, "onCanvas": false, "owner": owner })); + } let b = st.doc.node(id).and_then(Node::geometric_bounds).unwrap_or_default(); s.select(|_, sel| sel.set([id]))?; - Ok(json!({ "id": id.0, "bounds": [b.x0, b.y0, b.x1, b.y1] })) + Ok(json!({ "id": id.0, "bounds": [b.x0, b.y0, b.x1, b.y1], "onCanvas": true })) } fn embed(s: &mut Session, p: &Value) -> Result { const C: &str = "links.embed"; let st = s.doc()?; - let kind = |id: &NodeId| st.doc.node(*id).map(|n| &n.kind); + let kind = |id: &NodeId| stored(&st.doc, *id).map(|n| &n.kind); let linked = |id: &NodeId| match kind(id) { Some(NodeKind::Image(im)) => im.link.is_some(), Some(NodeKind::PlacedDocument(_)) => true, @@ -1864,7 +1931,7 @@ fn embed(s: &mut Session, p: &Value) -> Result { install(d, &ids, read, Some(Preserve::Bounds), None)?; } for id in &done { - if let Some(Node { kind: NodeKind::Image(im), .. }) = d.node_mut(*id) { + if let Some(Node { kind: NodeKind::Image(im), .. }) = d.stored_node_mut(*id) { im.link = None; } } @@ -1892,7 +1959,7 @@ fn unembed(s: &mut Session, p: &Value) -> Result { const C: &str = "links.unembed"; let st = s.doc()?; let id = image_param(st, p, C)?; - let n = st.doc.node(id).ok_or(crate::EngineError::NoNode(id))?; + let n = stored(&st.doc, id).ok_or(crate::EngineError::NoNode(id))?; let im = image_of(&st.doc, id).ok_or_else(|| bad(C, format!("object {} is not an image", id.0)))?; if im.link.is_some() { return Err(bad(C, format!("image {} is linked already", id.0))); @@ -1978,7 +2045,7 @@ fn scale_rotation(xf: Affine, (px, py): (f64, f64)) -> (Value, Value) { /// Link Info of placed document `id`. fn placed_info(st: &crate::DocState, id: NodeId) -> Result { - let n = st.doc.node(id).ok_or(crate::EngineError::NoNode(id))?; + let n = stored(&st.doc, id).ok_or(crate::EngineError::NoNode(id))?; let NodeKind::PlacedDocument(pl) = &n.kind else { return Err(bad("links.info", format!("object {} is not a placed document", id.0))) }; let size = n.geometric_bounds().unwrap_or_default(); let (scale, rotation) = scale_rotation(pl.xf, (1.0, 1.0)); @@ -2058,7 +2125,7 @@ fn placement_options(s: &mut Session, p: &Value) -> Result { }; s.edit("Placement Options", |d, _| { for id in &ids { - if let Some(o) = d.node_mut(*id).and_then(placement_mut) { + if let Some(o) = d.stored_node_mut(*id).and_then(placement_mut) { set(o); } } diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index 5d56a2b646..6acad1e83b 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -885,7 +885,8 @@ pub(crate) fn image_info(s: &mut Session, p: &Value) -> Result { (None, [id]) => *id, _ => return Err(bad(C, "select one image or pass id")), }; - let n = st.doc.node(id).ok_or(EngineError::NoNode(id))?; + // An image in opacity-mask art or a definition's art is found by its id too (`links.list`). + let n = st.doc.stored_node(id).map(|(n, _)| &**n).ok_or(EngineError::NoNode(id))?; let NodeKind::Image(im) = &n.kind else { return Err(bad(C, format!("object {} is not an image", id.0))) }; // Effective resolution: 72 pt per inch over the points one pixel spans along each axis. let [a, b, c, d, ..] = im.xf.as_coeffs(); diff --git a/crates/engine/src/cmd/place/adopt.rs b/crates/engine/src/cmd/place/adopt.rs index 15a39f06e5..0a0f08d3c1 100644 --- a/crates/engine/src/cmd/place/adopt.rs +++ b/crates/engine/src/cmd/place/adopt.rs @@ -272,6 +272,8 @@ pub(crate) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) { if let Some(mut def) = src.symbols.iter().find(|s| s.name == old).cloned() { def.name = new.to_string(); refs(Arc::make_mut(&mut def.art), &mut rename); + // The art takes ids of `dst`: those of `src` may name objects of `dst` already. + def.art = Arc::new(dst.reid(&def.art)); dst.symbols.push(def); } } @@ -280,6 +282,7 @@ pub(crate) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) { def.name = new.to_string(); for a in &mut def.art { refs(Arc::make_mut(a), &mut rename); + *a = Arc::new(dst.reid(a)); } dst.patterns.push(def); } diff --git a/crates/engine/src/cmd/place/document.rs b/crates/engine/src/cmd/place/document.rs index 9f58e4851d..8e4afaa295 100644 --- a/crates/engine/src/cmd/place/document.rs +++ b/crates/engine/src/cmd/place/document.rs @@ -378,8 +378,10 @@ pub(crate) fn full_documents(d: &Document) -> (Cow<'_, Document>, Vec) { /// Placed document `id` of `d` replaced by an editable copy of its art, as placing its file /// without Link gives (its resources joining `d`, renamed where `d` uses a name for something /// else), keeping the object's id, place, name and transparency: Break Link and Object › Expand. +/// A placed document in opacity-mask art or in the art of a symbol, graph design or pattern is +/// replaced where it is stored. pub(crate) fn expand(d: &mut Document, id: NodeId, cmd: &str) -> Result<()> { - let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?; + let n = d.stored_node(id).map(|(n, _)| Node::clone(n)).ok_or(EngineError::NoNode(id))?; let NodeKind::PlacedDocument(p) = &n.kind else { return Err(bad(cmd, format!("object {} is not a placed document", id.0))) }; let blob = d.images.get(&p.key).ok_or_else(|| bad(cmd, format!("object {} has lost its file's contents: relink it", id.0)))?; let bytes = vectorcraft_doc::placed_document::full_bytes(p, blob) @@ -395,6 +397,10 @@ pub(crate) fn expand(d: &mut Document, id: NodeId, cmd: &str) -> Result<()> { let art = super::transformed(Node::new(d.alloc_id(), NodeKind::Group { children, clip: a.clip.is_some() }), m); let mut out = n.clone(); out.kind = art.kind; + if d.node(id).is_none() { + *d.stored_node_mut(id).ok_or(EngineError::NoNode(id))? = out; + return Ok(()); + } let (par, index, _) = d.position(id).ok_or(EngineError::NoNode(id))?; d.remove(id)?; d.insert(par, index, out)?; diff --git a/crates/engine/src/tests/links.rs b/crates/engine/src/tests/links.rs index a3c7a72aee..1f82795399 100644 --- a/crates/engine/src/tests/links.rs +++ b/crates/engine/src/tests/links.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use serde_json::{Value, json}; -use vectorcraft_doc::{Document, ImageObject, LinkInfo, NodeKind}; +use vectorcraft_doc::{Document, ImageObject, LinkInfo, NodeKind, StoredRoot}; use vectorcraft_geom::Rect; use crate::*; @@ -446,8 +446,8 @@ fn links_outside_the_layers_find_their_files_after_the_folder_moves() { } /// With Update Links: Automatically, a file in a symbol and in the layers that changed and moved -/// with the document is read again for the layers' image and for the symbol's, and both links take -/// its new path, size and hash. +/// with the document is read again for the layers' image and for the symbol's, both are listed by +/// their ids, and both links take its new path, size and hash. #[test] fn a_symbols_link_to_a_moved_and_modified_file_takes_its_new_path() { let a = Folder::new("nested-modified-a"); @@ -458,13 +458,14 @@ fn a_symbols_link_to_a_moved_and_modified_file_takes_its_new_path() { s.execute("prefs.set", &json!({"key": "updateLinks", "value": "automatically"})).unwrap(); let id = place(&mut s, &pic); s.execute("symbol.new", &json!({"name": "Mark", "ids": [id.0]})).unwrap(); + let in_symbol = stored_image_in(&s, StoredRoot::Symbol(0)); let id = place(&mut s, &pic); save(&mut s, &a.file("poster.vectorcraft")); write(&pic, &png(30, 30, BLUE)); std::fs::remove_dir_all(&b.0).unwrap(); std::fs::rename(&a.0, &b.0).unwrap(); let r = open(&mut s, &b.file("poster.vectorcraft")); - assert_eq!((r["modifiedLinks"].clone(), r["updatedLinks"][0]["ids"].clone()), (json!([]), json!([id.0])), "{r}"); + assert_eq!((r["modifiedLinks"].clone(), r["updatedLinks"][0]["ids"].clone()), (json!([]), json!([id.0, in_symbol.0])), "{r}"); let moved = PathBuf::from(b.file("art/photo.png")); let blue = png(30, 30, BLUE); let mut images = vec![]; @@ -521,7 +522,8 @@ fn a_symbols_link_without_a_relative_path_follows_the_moved_file() { /// Update Links reads a changed file again for the images inside symbols, patterns and opacity /// masks too: those showing the same content as an image in the layers, and those no object in the -/// layers shows. Each takes the file's place as its own Placement Options say, in one undo step. +/// layers shows. Each takes the file's place as its own Placement Options say, in one undo step, +/// and the reply lists each by its id. #[test] fn update_links_reads_files_again_inside_symbols_patterns_and_masks() { let dir = Folder::new("nested-update"); @@ -533,11 +535,13 @@ fn update_links_reads_files_again_inside_symbols_patterns_and_masks() { let id = place(&mut s, &photo); s.execute("links.placementOptions", &json!({"ids": [id.0], "preserve": "fill", "clip": true})).unwrap(); s.execute("symbol.new", &json!({"name": "Mark", "ids": [id.0]})).unwrap(); + let in_symbol = stored_image_in(&s, StoredRoot::Symbol(0)); let id = place(&mut s, &photo); // logo.png only in a pattern and in an opacity mask. let p = place(&mut s, &logo); s.execute("object.pattern.make", &json!({"ids": [p.0], "edit": false})).unwrap(); s.execute("edit.clear", &json!({"ids": [p.0]})).unwrap(); + let p = stored_image_in(&s, StoredRoot::Pattern { pattern: 0, art: 0 }); let masked = super::rect(&mut s, 10.0, 10.0, 60.0, 60.0); let mask = place(&mut s, &logo); s.execute("transparency.makeOpacityMask", &json!({"ids": [masked.0, mask.0]})).unwrap(); @@ -546,7 +550,8 @@ fn update_links_reads_files_again_inside_symbols_patterns_and_masks() { write(&photo, &blue); write(&logo, &green); let u = s.execute("links.update", &json!({})).unwrap(); - assert_eq!(u, json!({"updated": [id.0], "missing": []})); + // By file: photo.png in the layers and the symbol, then logo.png in the mask and the pattern. + assert_eq!(u, json!({"updated": [id.0, in_symbol.0, mask.0, p.0], "missing": []})); // (name, size, hash, pixel size) of each image, which shows its file's pixels. let images = |s: &Session| { let d = &s.doc().unwrap().doc; @@ -576,6 +581,193 @@ fn update_links_reads_files_again_inside_symbols_patterns_and_masks() { assert!(images(&s).iter().all(|(_, _, _, size)| *size == (20, 10)), "{:?}", images(&s)); } +/// The files only art outside the layers shows: one in a symbol, a graph design, a pattern and an +/// opacity mask each, no object in the layers linked to any of them. +const NESTED: [&str; 4] = ["symbol.png", "design.png", "pattern.png", "mask.png"]; + +/// A document whose only links are [`NESTED`]'s files in `dir`. +fn nested_links(dir: &Folder) -> Session { + for (i, name) in (0u8..).zip(NESTED) { + write(&dir.file(name), &png(20, 10, [20 + 50 * i, 90, 160])); + } + let mut s = session(); + let id = place(&mut s, &dir.file("symbol.png")); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [id.0]})).unwrap(); + s.execute("edit.clear", &json!({"ids": [s.doc().unwrap().selection.objects[0].0]})).unwrap(); + let id = place(&mut s, &dir.file("design.png")); + s.execute("graph.design", &json!({"new": "Bar"})).unwrap(); + s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap(); + let id = place(&mut s, &dir.file("pattern.png")); + s.execute("object.pattern.make", &json!({"ids": [id.0], "edit": false})).unwrap(); + s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap(); + let masked = super::rect(&mut s, 10.0, 10.0, 60.0, 60.0); + let mask = place(&mut s, &dir.file("mask.png")); + s.execute("transparency.makeOpacityMask", &json!({"ids": [masked.0, mask.0]})).unwrap(); + s.execute("select.set", &json!({"ids": []})).unwrap(); + s +} + +/// The id of the `links.list` row of file `name`. +fn row_id(s: &mut Session, name: &str) -> u64 { + let list = s.execute("links.list", &json!({})).unwrap(); + let row = list["links"].as_array().unwrap().iter().find(|r| r["name"] == name).unwrap_or_else(|| panic!("{name} in {list}")); + row["id"].as_u64().unwrap() +} + +/// The link of stored object `id`. +fn stored_link(s: &Session, id: u64) -> Option { + match &s.doc().unwrap().doc.stored_node(NodeId(id)).unwrap().0.kind { + NodeKind::Image(im) => im.link.clone(), + k => panic!("not an image: {k:?}"), + } +} + +/// The id of the first image stored in the art of `root`. +fn stored_image_in(s: &Session, root: StoredRoot) -> NodeId { + let mut found = None; + s.doc().unwrap().doc.visit_stored(|n, place| { + if found.is_none() && place.root == root && matches!(n.kind, NodeKind::Image(_)) { + found = Some(n.id); + } + }); + found.unwrap_or_else(|| panic!("no image in {root:?}")) +} + +/// Images in symbols, graph designs, patterns and opacity masks are listed and checked with ids of +/// their own, one row each with what holds it, and the missing filter counts them. +#[test] +fn links_outside_the_layers_are_listed_and_checked_with_ids() { + let dir = Folder::new("nested-ids"); + let mut s = nested_links(&dir); + let list = s.execute("links.list", &json!({})).unwrap(); + let rows = list["links"].as_array().unwrap(); + let owners: Vec<(&str, &str)> = rows.iter().map(|r| (r["name"].as_str().unwrap(), r["owner"]["kind"].as_str().unwrap_or("-"))).collect(); + assert_eq!(owners, [("mask.png", "layers"), ("symbol.png", "symbol"), ("design.png", "graphDesign"), ("pattern.png", "pattern")], "{list}"); + assert_eq!(rows[1]["owner"]["name"], "Mark"); + let masked = s.doc().unwrap().doc.layers[0].children().unwrap().iter().find(|n| n.extras().mask.is_some()).unwrap().id; + assert_eq!(rows[0]["owner"]["maskOf"], json!(masked.0)); + let ids: std::collections::BTreeSet = rows.iter().map(|r| r["id"].as_u64().unwrap()).collect(); + assert_eq!(ids.len(), 4, "{list}"); + assert!(rows.iter().all(|r| r["status"] == "ok" && r["linked"] == true), "{list}"); + let check = s.execute("links.check", &json!({})).unwrap(); + let checked: Vec<(&str, &Value)> = check["links"].as_array().unwrap().iter().map(|l| (l["name"].as_str().unwrap(), &l["ids"])).collect(); + assert_eq!(checked.len(), 4, "{check}"); + for (name, ids) in checked { + assert_eq!(*ids, json!([row_id(&mut s, name)]), "{check}"); + } + std::fs::remove_file(dir.file("design.png")).unwrap(); + let check = s.execute("links.check", &json!({})).unwrap(); + assert_eq!((check["missing"].as_u64(), check["modified"].as_u64()), (Some(1), Some(0)), "{check}"); + let design = row_id(&mut s, "design.png"); + let missing = s.execute("links.list", &json!({"show": "missing"})).unwrap(); + assert_eq!((missing["missing"].as_u64(), &missing["links"][0]["id"]), (Some(1), &json!(design)), "{missing}"); + let one = s.execute("links.check", &json!({"ids": [design]})).unwrap(); + assert_eq!(one["links"][0]["status"], "missing", "{one}"); +} + +/// Every links command takes the id of an image outside the layers and acts on that object: Link +/// Info, Go To (which clears the selection and names the symbol), Placement Options, Relink, Update and +/// Embed. +#[test] +fn links_commands_act_on_images_outside_the_layers_by_id() { + let dir = Folder::new("nested-act"); + let mut s = nested_links(&dir); + let symbol = row_id(&mut s, "symbol.png"); + let info = s.execute("links.info", &json!({"id": symbol})).unwrap(); + assert_eq!((info["fileName"].as_str(), info["status"].as_str(), info["id"].as_u64()), (Some("symbol.png"), Some("ok"), Some(symbol)), "{info}"); + let other = super::rect(&mut s, 0.0, 0.0, 5.0, 5.0); + s.execute("select.set", &json!({"ids": [other.0]})).unwrap(); + let go = s.execute("links.goTo", &json!({"id": symbol})).unwrap(); + assert_eq!( + (go["onCanvas"].clone(), go["owner"]["kind"].clone(), go["owner"]["name"].clone()), + (json!(false), json!("symbol"), json!("Mark")), + "{go}" + ); + assert!(s.doc().unwrap().selection.objects.is_empty(), "nothing on the canvas is selected"); + s.execute("links.placementOptions", &json!({"ids": [symbol], "preserve": "fit"})).unwrap(); + let placement = s.execute("links.placementOptions", &json!({"ids": [symbol]})).unwrap(); + assert_eq!(placement["placement"]["preserve"], "fit"); + // Relink: the symbol's image shows the new file. + let other = dir.file("other.png"); + write(&other, &png(40, 20, BLUE)); + let r = s.execute("links.relink", &json!({"ids": [symbol], "path": other})).unwrap(); + assert_eq!(r["relinked"], json!([symbol]), "{r}"); + assert_eq!(stored_link(&s, symbol).unwrap().path, other); + // Update: a changed pattern file is read again for the pattern's image. + let pattern = row_id(&mut s, "pattern.png"); + let changed = png(30, 30, RED); + write(&dir.file("pattern.png"), &changed); + let r = s.execute("links.update", &json!({"ids": [pattern]})).unwrap(); + assert_eq!(r["updated"], json!([pattern]), "{r}"); + assert_eq!(stored_link(&s, pattern).unwrap().size, Some(changed.len() as u64)); + // Embed: the design's image keeps its pixels and no longer links. + let design = row_id(&mut s, "design.png"); + let r = s.execute("links.embed", &json!({"ids": [design]})).unwrap(); + assert_eq!(r["embedded"], json!([design]), "{r}"); + assert!(stored_link(&s, design).is_none()); + let row = s.execute("links.list", &json!({"show": "embedded"})).unwrap(); + assert_eq!(row["links"][0]["id"], json!(design), "{row}"); + // Relink All Instances takes the other stored objects linked to the same file. + let mask = row_id(&mut s, "mask.png"); + let r = s.execute("links.relink", &json!({"ids": [symbol], "path": dir.file("mask.png"), "scope": "all"})).unwrap(); + assert_eq!(r["relinked"], json!([symbol]), "{r}"); + let r = s.execute("links.relink", &json!({"ids": [mask], "path": other, "scope": "all"})).unwrap(); + let mut relinked: Vec = r["relinked"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + relinked.sort(); + let mut both = vec![symbol, mask]; + both.sort(); + assert_eq!(relinked, both, "{r}"); +} + +/// Opening a document reports missing and modified files that only art outside the layers shows, +/// by their objects' ids, and those ids stay the same after the document is saved and opened again. +#[test] +fn open_reports_links_outside_the_layers_by_id() { + let dir = Folder::new("nested-open"); + let mut s = nested_links(&dir); + let path = dir.file("poster.vectorcraft"); + save(&mut s, &path); + let ids: Vec = NESTED.iter().map(|name| row_id(&mut s, name)).collect(); + std::fs::remove_file(dir.file("symbol.png")).unwrap(); + write(&dir.file("pattern.png"), &png(30, 30, RED)); + let r = open(&mut s, &path); + let names = |k: &str| r[k].as_array().unwrap().iter().map(|l| (l["name"].as_str().unwrap().to_string(), l["ids"].clone())).collect::>(); + assert_eq!(names("missingLinks"), [("symbol.png".to_string(), json!([ids[0]]))], "{r}"); + assert_eq!(names("modifiedLinks"), [("pattern.png".to_string(), json!([ids[2]]))], "{r}"); + let again: Vec = NESTED.iter().map(|name| row_id(&mut s, name)).collect(); + assert_eq!(again, ids, "the ids are saved"); +} + +/// A document whose symbol and mask art repeat the ids of objects in the layers (or have none) +/// opens with ids of their own there, the layer tree's kept; the links commands then tell them apart. +#[test] +fn nested_objects_that_repeat_ids_open_with_ids_of_their_own() { + let dir = Folder::new("nested-repeat"); + let mut s = nested_links(&dir); + let layer_image = place(&mut s, &dir.file("symbol.png")); + let path = dir.file("poster.vectorcraft"); + save(&mut s, &path); + // The symbol's image takes the id of the image in the layers, the mask's image none. + let mut saved: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + saved["document"]["symbols"][0]["art"]["id"] = json!(layer_image.0); + let layer = &mut saved["document"]["layers"][0]["kind"]["children"]; + let masked = layer.as_array_mut().unwrap().iter_mut().find(|n| n.get("mask").is_some() || n["extras"].get("mask").is_some()).unwrap(); + let mask = if masked.get("mask").is_some() { &mut masked["mask"] } else { &mut masked["extras"]["mask"] }; + mask["art"]["id"] = json!(0); + std::fs::write(&path, serde_json::to_vec(&saved).unwrap()).unwrap(); + open(&mut s, &path); + let list = s.execute("links.list", &json!({})).unwrap(); + let ids: Vec = list["links"].as_array().unwrap().iter().map(|r| r["id"].as_u64().unwrap()).collect(); + let unique: std::collections::BTreeSet = ids.iter().copied().collect(); + assert_eq!((ids.len(), unique.len()), (5, 5), "{list}"); + assert!(!ids.contains(&0), "{list}"); + assert_eq!(list["links"][0]["id"], json!(layer_image.0), "the layer tree keeps its ids: {list}"); + let symbol = list["links"].as_array().unwrap().iter().find(|r| r["owner"]["kind"] == "symbol").unwrap()["id"].as_u64().unwrap(); + s.execute("links.embed", &json!({"ids": [symbol]})).unwrap(); + assert!(stored_link(&s, symbol).is_none()); + assert!(image(&s, layer_image).link.is_some(), "the image in the layers stays linked"); +} + #[test] fn a_missing_link_renders_its_preview_and_relinks() { let dir = Folder::new("missing"); diff --git a/crates/engine/src/tests/linkspanel.rs b/crates/engine/src/tests/linkspanel.rs index f029eccd3d..a9a3a17ec7 100644 --- a/crates/engine/src/tests/linkspanel.rs +++ b/crates/engine/src/tests/linkspanel.rs @@ -213,7 +213,7 @@ fn go_to_selects_the_image() { run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})); assert_ne!(s.doc().unwrap().selection.objects, vec![id]); let r = run(&mut s, "links.goTo", json!({"id": id.0})); - assert_eq!(r, json!({"id": id.0, "bounds": [-100.0, 0.0, 500.0, 300.0]})); + assert_eq!(r, json!({"id": id.0, "bounds": [-100.0, 0.0, 500.0, 300.0], "onCanvas": true})); assert_eq!(s.doc().unwrap().selection.objects, vec![id]); let rect = s.doc().unwrap().doc.layers[0].children().unwrap()[1].id; assert!(s.execute("links.goTo", &json!({"id": rect.0})).is_err(), "not an image"); diff --git a/crates/engine/src/tests/linkwatch.rs b/crates/engine/src/tests/linkwatch.rs index 7fc8f0a4ba..0a5b9d7dbe 100644 --- a/crates/engine/src/tests/linkwatch.rs +++ b/crates/engine/src/tests/linkwatch.rs @@ -47,6 +47,30 @@ fn update_changed_reads_changed_files_again_once() { assert_eq!(s.execute("links.check", &json!({})).unwrap()["modified"], 1); } +/// A file only a symbol's art shows is watched too, and read again by the id of the symbol's image. +#[test] +fn update_changed_reads_a_file_only_a_symbol_shows() { + let dir = Folder::new("watch-symbol"); + let path = dir.file("art.png"); + std::fs::write(&path, png(40, 40, RED)).unwrap(); + let mut s = session(); + let id = place(&mut s, &path); + s.execute("symbol.new", &json!({"name": "Mark", "ids": [id.0]})).unwrap(); + let instance = s.doc().unwrap().selection.objects[0]; + s.execute("edit.clear", &json!({"ids": [instance.0]})).unwrap(); + let mut in_symbol = None; + s.doc().unwrap().doc.symbols[0].art.walk(&mut |n| { + if matches!(n.kind, vectorcraft_doc::NodeKind::Image(_)) { + in_symbol = Some(n.id.0); + } + }); + let in_symbol = in_symbol.expect("the symbol's image"); + assert_eq!(s.execute("links.updateChanged", &json!({})).unwrap()["updated"], json!([])); + save(&path, &png(40, 40, BLUE)); + assert_eq!(s.execute("links.updateChanged", &json!({})).unwrap()["updated"], json!([in_symbol])); + assert_eq!(s.execute("links.check", &json!({})).unwrap()["modified"], 0); +} + #[test] fn a_background_look_follows_update_links() { for (mode, key) in [("automatically", "updated"), ("askWhenModified", "ask"), ("manually", "modified")] { diff --git a/crates/engine/src/tests/place.rs b/crates/engine/src/tests/place.rs index a0a8ededa0..455471dbb1 100644 --- a/crates/engine/src/tests/place.rs +++ b/crates/engine/src/tests/place.rs @@ -662,6 +662,33 @@ fn placed_art_brings_its_symbols_and_swatches_renaming_on_conflict() { assert!(linked, "still linked to the swatch it brought"); } +/// The symbol and pattern art placed art brings takes ids of the document's own: the file's ids +/// already name other objects there, and the Links panel names linked art in symbols by its id. +#[test] +fn placed_art_brings_its_symbols_and_patterns_with_ids_of_the_documents_own() { + let mut src = session(); + src.execute("shape.ellipse", &json!({"x": 50, "y": 10, "width": 20, "height": 20})).unwrap(); + src.execute("symbol.new", &json!({"name": "Star"})).unwrap(); + src.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 8, "height": 8})).unwrap(); + let tile = src.doc().unwrap().selection.objects[0]; + src.execute("object.pattern.make", &json!({"ids": [tile.0], "edit": false, "name": "Dots"})).unwrap(); + src.execute("paint.setFill", &json!({"ids": [tile.0], "swatch": "Dots"})).unwrap(); + let bytes = serialize(&mut src, "vectorcraft"); + // The document's objects take the same ids the file's do. + let mut s = session(); + for x in [0, 20, 40, 60] { + s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 5, "height": 5})).unwrap(); + } + s.execute("symbol.new", &json!({"name": "Box"})).unwrap(); + place(&mut s, "logo.vectorcraft", &bytes, json!({})); + let doc = &s.doc().unwrap().doc; + assert!(doc.symbols.iter().any(|x| x.name == "Star") && doc.pattern("Dots").is_some()); + let mut ids = vec![]; + doc.visit_stored(|n, _| ids.push(n.id.0)); + let unique: std::collections::BTreeSet = ids.iter().copied().collect(); + assert_eq!(unique.len(), ids.len(), "{ids:?}"); +} + #[test] fn placed_art_brings_its_brushes_renaming_on_conflict() { // The file: a path with its own brush, and one with a brush the defaults hold. diff --git a/crates/ui-egui/src/addons/select/link_status.rs b/crates/ui-egui/src/addons/select/link_status.rs index 0b367a7ba0..d50d650215 100644 --- a/crates/ui-egui/src/addons/select/link_status.rs +++ b/crates/ui-egui/src/addons/select/link_status.rs @@ -52,12 +52,19 @@ pub struct Present { pub has: [bool; 4], } -/// The statuses the `links.list` result `list` holds, in [`STATUSES`] order. +/// The statuses the rows of the `links.list` result `list` that can be selected hold, in +/// [`STATUSES`] order. pub(crate) fn statuses_in(list: &Value) -> [bool; 4] { - let rows = list["links"].as_array().map_or(&[][..], Vec::as_slice); + let rows = canvas_rows(list); STATUSES.map(|(status, ..)| rows.iter().any(|r| command_status(r) == status)) } +/// The rows of the `links.list` result `list` of objects in the layer tree, which can be +/// selected (a row with an `owner` is in a symbol, graph design, pattern or opacity mask). +fn canvas_rows(list: &Value) -> Vec { + list["links"].as_array().into_iter().flatten().filter(|r| r["owner"].is_null()).cloned().collect() +} + /// The status of a `links.list` row as the command names it (`ok` is `linked`). fn command_status(row: &Value) -> &str { match row["status"].as_str().unwrap_or_default() { @@ -154,11 +161,11 @@ pub(crate) fn message(statuses: &[&str], count: u64, reasons: &[&str]) -> String crate::i18n::msg_n(reasons.len() as u64, one, other, &[("selected", &selected)]) } -/// The dialog's fields to start with: every image and placed document (`__rows`, as `links.list` -/// lists them, read once), those selected chosen, All shown, Zoom to Selection on. +/// The dialog's fields to start with: every image and placed document in the layer tree (`__rows`, +/// as `links.list` lists them, read once), those selected chosen, All shown, Zoom to Selection on. pub(crate) fn fields(app: &mut VectorcraftApp) -> Value { let list = app.session.execute("links.list", &json!({})).unwrap_or_default(); - let rows: Vec = list["links"].as_array().cloned().unwrap_or_default(); + let rows = canvas_rows(&list); let selected: Vec = app.session.active().map(|st| st.selection.objects.iter().map(|id| id.0).collect()).unwrap_or_default(); let chosen: Vec = rows.iter().filter_map(|r| r["id"].as_u64()).filter(|id| selected.contains(id)).collect(); json!({ "__rows": rows, "ids": chosen, "show": "all", "zoom": true }) diff --git a/crates/ui-egui/src/addons/select/link_status/tests.rs b/crates/ui-egui/src/addons/select/link_status/tests.rs index 8ad61f6e5a..c0515e048b 100644 --- a/crates/ui-egui/src/addons/select/link_status/tests.rs +++ b/crates/ui-egui/src/addons/select/link_status/tests.rs @@ -138,6 +138,27 @@ fn menu_items_are_on_for_the_statuses_present_and_shift_adds() { assert!(on("Select Embedded")); } +/// A missing link kept only in a symbol's art can't be selected: Select Missing stays off and +/// the dialog does not list it. +#[test] +fn links_inside_a_symbol_are_left_out_of_selecting_by_status() { + let dir = Folder::new("symbol"); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let path = dir.file("inside.png", &png(30, 20)); + let id = app.run("file.place", json!({"path": path, "at": [100, 100]})).unwrap()["ids"][0].as_u64().unwrap(); + app.run("symbol.new", json!({"name": "Mark", "ids": [id]})).unwrap(); + app.run("edit.clear", json!({})).unwrap(); + std::fs::remove_file(&path).unwrap(); + let list = app.run("links.list", json!({})).unwrap(); + assert_eq!(list["missing"], 1, "{list}"); + refresh(&mut app, 0.0); + let entries = menus::menu_entries(&app); + let missing = entries.iter().find(|e| e.path == ["Object", "Plug-ins", "Selection Tools"] && e.label == "Select Missing").unwrap(); + assert!(!missing.enabled); + assert_eq!(super::fields(&mut app)["__rows"], json!([])); +} + #[test] fn the_links_panel_menu_lists_the_select_items() { let dir = Folder::new("flyout"); diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 33df72c253..3b6cfa4bc6 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5715,6 +5715,7 @@ digits Malayalam Malaiàlam @msg no library `{id}` no hi ha la biblioteca `{id}` @msg no library `{key}` (see {list}) no hi ha la biblioteca `{key}` (vegeu {list}) @msg No linked files to look for No hi ha fitxers enllaçats per buscar +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas L’enllaç és dins d’un símbol, un disseny de gràfic, un motiu o una màscara d’opacitat i no té lloc al llenç @msg no Live Paint edge near that point no hi ha cap aresta de pintura interactiva a prop d’aquell punt @msg no Live Paint face at that point no hi ha cap cara de pintura interactiva en aquell punt @msg no native format no hi ha format natiu diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index ff48f1ecfc..e0cb12f675 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5400,6 +5400,7 @@ imageTrace Outline Obrys @msg every numbered name for it is taken in the Fonts folder ve složce Fonts jsou obsazena všechna číslovaná jména pro tento soubor @msg every numbered name for {name} is taken in the document's folder ve složce dokumentu jsou obsazena všechna číslovaná jména pro {name} @msg No linked files to look for Žádné propojené soubory k vyhledání +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Propojení je uvnitř symbolu, návrhu grafu, vzorku nebo masky krytí a nemá na plátně žádné místo @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` je odkaz na krok {r}, který vyhodnocují jen `run` a `command_batch` @msg `ids` must be an array of object ids, not {v} `ids` musí být pole ID objektů, ne {v} @msg `colors` must be an array of colors, not {v} `colors` musí být pole barev, ne {v} diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 1d3b12f63a..6be2b55e6b 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5017,6 +5017,7 @@ theme Appearance Erscheinungsbild @msg every numbered name for it is taken in the Fonts folder alle nummerierten Namen dafür sind im Schriftenordner bereits vergeben @msg every numbered name for {name} is taken in the document's folder alle nummerierten Namen für {name} sind im Ordner des Dokuments bereits vergeben @msg No linked files to look for Keine verknüpften Dateien zum Suchen +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Die Verknüpfung befindet sich in einem Symbol, einem Diagrammdesign, einem Muster oder einer Deckkraftmaske und hat keinen Platz auf der Zeichenfläche # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Dienste diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 8467b8e831..3861f53fb0 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5039,6 +5039,7 @@ digits Malayalam Μαλαγιάλαμ @msg every numbered name for it is taken in the Fonts folder όλα τα αριθμημένα ονόματά του χρησιμοποιούνται ήδη στον φάκελο γραμματοσειρών @msg every numbered name for {name} is taken in the document's folder όλα τα αριθμημένα ονόματα για το {name} χρησιμοποιούνται ήδη στον φάκελο εγγράφου @msg No linked files to look for Δεν υπάρχουν συνδεδεμένα αρχεία για αναζήτηση +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Ο σύνδεσμος βρίσκεται μέσα σε σύμβολο, σχεδιασμό γραφήματος, μοτίβο ή μάσκα αδιαφάνειας και δεν έχει θέση στον καμβά Hide VectorCraft Απόκρυψη VectorCraft Bring All to Front Μεταφορά όλων στο προσκήνιο Break Link Κατάργηση σύνδεσης diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 194ba2c602..bf9adc01b6 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5092,6 +5092,7 @@ axis Both Ambos @msg every numbered name for it is taken in the Fonts folder todos los nombres numerados para él ya existen en la carpeta Fonts @msg every numbered name for {name} is taken in the document's folder todos los nombres numerados para {name} ya existen en la carpeta del documento @msg No linked files to look for No hay archivos vinculados que buscar +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas El vínculo está dentro de un símbolo, un diseño de gráfico, un motivo o una máscara de opacidad y no tiene lugar en el lienzo # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Servicios diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index e36c5e891e..3a193a980e 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5300,6 +5300,7 @@ imageTrace Outline Ääriviiva @msg every numbered name for it is taken in the Fonts folder kaikki sen numeroidut nimet ovat jo käytössä Fonts-kansiossa @msg every numbered name for {name} is taken in the document's folder kaikki kohteen {name} numeroidut nimet ovat jo käytössä asiakirjan kansiossa @msg No linked files to look for Ei etsittäviä linkitettyjä tiedostoja +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Linkki on symbolin, kaaviomallin, kuvion tai peittävyysmaskin sisällä, eikä sillä ole paikkaa piirtoalueella @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` on vaiheviittaus {r}, jonka vain `run` ja `command_batch` ratkaisevat @msg `ids` must be an array of object ids, not {v} `ids` on oltava objektitunnusten taulukko, ei {v} @msg `colors` must be an array of colors, not {v} `colors` on oltava värien taulukko, ei {v} diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 067845f493..5b7d36b7d1 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5079,6 +5079,7 @@ axis Both Les deux @msg every numbered name for it is taken in the Fonts folder tous les noms numérotés pour ce fichier sont déjà pris dans le dossier Fonts @msg every numbered name for {name} is taken in the document's folder tous les noms numérotés pour {name} sont déjà pris dans le dossier du document @msg No linked files to look for Aucun fichier lié à rechercher +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Le lien se trouve dans un symbole, une conception de graphe, un motif ou un masque d’opacité et n’a pas de place sur le canevas # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Services diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 165e04ae7d..3c39e8cf19 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -7192,6 +7192,7 @@ antiAlias Sharp शार्प @msg no library `{key}` (see {list}) कोई लाइब्रेरी `{key}` ({list} देखें) @msg No line breaks to remove हटाने के लिए कोई लाइन ब्रेक नहीं @msg No linked files to look for देखने के लिए कोई लिंक्ड फ़ाइल नहीं +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas लिंक किसी सिंबल, ग्राफ़ डिज़ाइन, पैटर्न या ओपेसिटी मास्क के अंदर है और कैनवास पर इसकी कोई जगह नहीं है @msg no Live Paint edge near that point उस बिंदु के पास कोई लाइव पेंट एज नहीं @msg no Live Paint face at that point उस बिंदु पर कोई लाइव पेंट फेस नहीं @msg no metafile format कोई मेटाफ़ाइल फ़ॉर्मैट diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 2ccf9d57bc..2420ee8319 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5139,6 +5139,7 @@ imageTrace Outline Körvonal @msg every numbered name for it is taken in the Fonts folder a Fonts mappában minden sorszámozott név foglalt hozzá @msg every numbered name for {name} is taken in the document's folder a dokumentum mappájában minden sorszámozott név foglalt: {name} @msg No linked files to look for Nincs keresendő csatolt fájl +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas A csatolás egy szimbólumban, diagrammintában, mintázatban vagy átlátszósági maszkban van, és nincs helye a vásznon @msg every search thread stopped on an internal error (please report this bug) minden keresési szál belső hibával leállt (kérjük, jelentsd ezt a hibát) @msg Expand Appearance: the selection has a basic appearance Megjelenés kibontása: a kijelölésnek alapmegjelenése van @msg Expand: no brush strokes selected Kibontás: nincs kijelölt ecsetvonás diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index be83d78b43..2ef7decb66 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5143,6 +5143,7 @@ axis Both Entrambi @msg every numbered name for it is taken in the Fonts folder tutti i nomi numerati per il file sono già usati nella cartella Fonts @msg every numbered name for {name} is taken in the document's folder tutti i nomi numerati per {name} sono già usati nella cartella del documento @msg No linked files to look for Nessun file collegato da cercare +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Il collegamento si trova in un simbolo, un design di grafico, un motivo o una maschera di opacità e non ha una posizione nell’area di disegno # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Servizi diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b27c6d2698..c46278e818 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5210,6 +5210,7 @@ swatch Bright Violet ビビッドバイオレット @msg every numbered name for it is taken in the Fonts folder Fonts フォルダーでは、このファイルの番号付きの名前がすべて使われています @msg every numbered name for {name} is taken in the document's folder ドキュメントのフォルダーでは、{name} の番号付きの名前がすべて使われています @msg No linked files to look for 検索するリンクファイルがありません +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas このリンクはシンボル、グラフデザイン、パターン、または不透明マスクの中にあり、カンバス上に位置がありません @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` はステップ参照 {r} です。ステップ参照を解決できるのは `run` と `command_batch` だけです @msg `ids` must be an array of object ids, not {v} `ids` はオブジェクト ID の配列にしてください ({v} は使えません) @msg `colors` must be an array of colors, not {v} `colors` は色の配列にしてください ({v} は使えません) diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 4ba40baab0..aee875402e 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5110,6 +5110,7 @@ font-technology Variable ცვლადი @msg the art on them differs from the page's in {_1}% of it: they come from its editing data all the same მათზე არსებული ნახატი გვერდისგან {_1}%-ით განსხვავდება: მიუხედავად ამისა, ისინი რედაქტირების მონაცემებიდან აიღება @msg every numbered name for {name} is taken in the document's folder დოკუმენტის საქაღალდეში {name}-ის ყველა დანომრილი სახელი დაკავებულია @msg No linked files to look for მოსაძებნი დაკავშირებული ფაილები არ არის +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas ბმული სიმბოლოში, დიაგრამის დიზაინში, პატერნში ან გაუმჭვირვალობის ნიღაბშია და ტილოზე ადგილი არ აქვს Ruler Origin სახაზავების სათავე Reset Ruler Origin სახაზავების სათავის ჩამოყრა Artistic მხატვრული diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index c46ce6b4fa..ad8fb62588 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5137,6 +5137,7 @@ theme Appearance 외관 @msg every numbered name for it is taken in the Fonts folder 글꼴 폴더에서 이를 위한 번호 이름이 모두 사용 중입니다 @msg every numbered name for {name} is taken in the document's folder 문서 폴더에서 {name}을(를) 위한 번호 이름이 모두 사용 중입니다 @msg No linked files to look for 찾을 링크된 파일이 없습니다 +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas 링크가 심볼, 그래프 디자인, 패턴 또는 불투명 마스크 안에 있어 캔버스에 위치가 없습니다 @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id`는 단계 참조 {r}이며, `run`과 `command_batch`만 이를 해석합니다 @msg `ids` must be an array of object ids, not {v} `ids`는 {v}이(가) 아니라 개체 id 배열이어야 합니다 @msg `colors` must be an array of colors, not {v} `colors`는 {v}이(가) 아니라 색상 배열이어야 합니다 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index a232ac4e8d..50e1dc63bc 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5226,6 +5226,7 @@ imageTrace Outline Kontur @msg every numbered name for it is taken in the Fonts folder wszystkie numerowane nazwy dla niego są zajęte w folderze czcionek @msg every numbered name for {name} is taken in the document's folder wszystkie numerowane nazwy dla {name} są zajęte w folderze dokumentu @msg No linked files to look for Brak połączonych plików do wyszukania +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Łącze znajduje się w symbolu, wzorze wykresu, wzorku lub masce krycia i nie ma miejsca na obszarze roboczym @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` to odwołanie do kroku {r}, które rozwiązują tylko `run` i `command_batch` @msg `ids` must be an array of object ids, not {v} `ids` musi być tablicą identyfikatorów obiektów, nie {v} @msg `colors` must be an array of colors, not {v} `colors` musi być tablicą kolorów, nie {v} diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 68a225625d..322ad67da4 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5328,6 +5328,7 @@ axis Both Ambos @msg area type in frames too short for a line of the font it shows in got taller frames ({_1}): their type wouldn't show otherwise o texto de área em molduras baixas demais para uma linha da fonte em que aparece recebeu molduras mais altas ({_1}): senão o texto não apareceria @msg every numbered name for {name} is taken in the document's folder todos os nomes numerados para {name} já existem na pasta do documento @msg No linked files to look for Nenhum arquivo vinculado para procurar +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas O link está dentro de um símbolo, projeto de gráfico, padrão ou máscara de opacidade e não tem lugar na tela De-Interlace Desentrelaçar Eliminate Eliminar Create New Fields by Criar Novos Campos por diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index a54d1eed2c..27b7b47e36 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5129,6 +5129,7 @@ axis Both Оба @msg every numbered name for it is taken in the Fonts folder все нумерованные имена для него уже заняты в папке Fonts @msg every numbered name for {name} is taken in the document's folder все нумерованные имена для {name} уже заняты в папке документа @msg No linked files to look for Нет связанных файлов для поиска +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Связь находится внутри символа, дизайна диаграммы, узора или непрозрачной маски и не имеет места на холсте # The macOS menu bar (native_menu): the app and Window menus' system items, Settings pages. Services Службы diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index d0648b5946..d38c35c259 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -4987,6 +4987,7 @@ axis Both Båda @msg every numbered name for it is taken in the Fonts folder alla numrerade namn för den är upptagna i mappen Typsnitt @msg every numbered name for {name} is taken in the document's folder alla numrerade namn för {name} är upptagna i dokumentets mapp @msg No linked files to look for Inga länkade filer att leta efter +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Länken finns i en symbol, en diagramdesign, ett mönster eller en opacitetsmask och har ingen plats på arbetsytan Services Tjänster Hide VectorCraft Göm VectorCraft Zoom Zoom diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 915a244b43..88f7341c35 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -4825,6 +4825,7 @@ axis Both Her İkisi @msg every numbered name for it is taken in the Fonts folder bunun için numaralandırılmış her ad Yazı Tipleri klasöründe alınır @msg every numbered name for {name} is taken in the document's folder {name} için numaralandırılmış her ad belgenin klasöründe alınır @msg No linked files to look for Aranacak bağlantılı dosya yok +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Bağlantı bir sembolün, grafik tasarımının, desenin veya opaklık maskesinin içinde ve tuvalde yeri yok Services Hizmetler Hide VectorCraft VectorCraft'ı gizle Zoom Yakınlaştır diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index e8b5445a50..3a838f8bf8 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5127,6 +5127,7 @@ axis Both Обидві @msg every numbered name for it is taken in the Fonts folder усі нумеровані імена для нього вже зайняті в папці Fonts @msg every numbered name for {name} is taken in the document's folder усі нумеровані імена для {name} уже зайняті в папці документа @msg No linked files to look for Немає пов’язаних файлів для пошуку +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Зв’язок міститься в символі, дизайні діаграми, візерунку або масці непрозорості й не має місця на полотні Services Служби Hide VectorCraft Приховати VectorCraft Zoom Масштаб diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 51681bfed5..5abcae4ee9 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4966,6 +4966,7 @@ theme Appearance Giao diện @msg no library `{id}` không có thư viện `{id}` @msg no library `{key}` (see {list}) không có thư viện `{key}` (xem {list}) @msg No linked files to look for Không có tệp liên kết để tìm kiếm +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas Liên kết nằm trong ký hiệu, thiết kế biểu đồ, họa tiết hoặc mặt nạ độ mờ và không có vị trí trên canvas @msg no Live Paint edge near that point không có cạnh tô trực tiếp gần điểm đó @msg no Live Paint face at that point không có mặt tô trực tiếp tại điểm đó @msg no native format không có định dạng gốc diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d1e4de6bc6..9a63ddd188 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -4899,6 +4899,7 @@ imageTrace Outline 轮廓 @msg every numbered name for it is taken in the Fonts folder 字体文件夹中所有带编号的名称均已被占用 @msg every numbered name for {name} is taken in the document's folder 文档所在文件夹中 {name} 的所有带编号的名称均已被占用 @msg No linked files to look for 没有要查找的链接文件 +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas 该链接位于符号、图表设计、图案或不透明度蒙版中,在画布上没有位置 @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` 是步骤引用 {r},只有 `run` 和 `command_batch` 能解析它 @msg `ids` must be an array of object ids, not {v} `ids` 必须是由对象 id 组成的数组,而不是 {v} @msg `colors` must be an array of colors, not {v} `colors` 必须是由颜色组成的数组,而不是 {v} diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 8e49ed109a..203910a663 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2668,6 +2668,7 @@ file-filter Templates 範本 @msg every numbered name for it is taken in the Fonts folder 字體資料夾中所有編號名稱都已被使用 @msg every numbered name for {name} is taken in the document's folder 文件所在資料夾中 {name} 的所有編號名稱都已被使用 @msg No linked files to look for 沒有要尋找的連結檔案 +@msg The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas 此連結位於符號、圖表設計、圖樣或不透明度遮色片中,在畫布上沒有位置 @msg `id` is the step reference {r}, which only `run` and `command_batch` resolve `id` 是步驟參照 {r},只有 `run` 和 `command_batch` 能解析它 @msg `ids` must be an array of object ids, not {v} `ids` 必須是由物件 id 組成的陣列,而非 {v} @msg `colors` must be an array of colors, not {v} `colors` 必須是由顏色組成的陣列,而非 {v} diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index 38f9848760..5fa9199f19 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -8,6 +8,10 @@ //! Modified (`addon.select.byLinkStatus`, Shift adds), Show filters, Sort, thumbnail size //! and Show in Folder. //! +//! Images and placed documents in opacity-mask art and in the art of symbols, graph designs and +//! patterns are listed after the layers' own, one row each; the canvas can't select them, so a +//! click picks such a row in the panel, and the buttons act on it as on any other. +//! //! Everything runs through the `links.*` commands (`links.editOriginal` and `links.reveal` are UI //! commands: they hand the file to the system). @@ -52,6 +56,8 @@ struct Cache { at: f64, list: Value, info: Option<(u64, Value)>, + /// The objects of the rows outside the layer tree, by id, for their thumbnails. + nested: Vec<(u64, std::sync::Arc)>, } fn options(ctx: &egui::Context) -> Options { @@ -69,13 +75,27 @@ fn set_options(ctx: &egui::Context, f: impl FnOnce(&mut Options)) { fn cached(app: &mut VectorcraftApp, ctx: &egui::Context, o: &Options) -> Option { let st = app.session.active()?; let key = Some((st.uid, st.revision, o.show, o.sort)); - let first = selected_images(app).first().copied(); + let first = selected_links(app, ctx).first().copied(); let now = ctx.input(|i| i.time); let mut c: Cache = pstate(ctx, "links-cache"); let stale = c.key != key || now - c.at > RECHECK; if stale { c.list = app.session.execute("links.list", &json!({ "show": o.show, "sort": o.sort })).ok()?; (c.key, c.at) = (key, now); + // One pass over the stored art for the rows outside the layer tree, not one per frame. + let mut want: std::collections::HashSet = + c.list["links"].as_array().into_iter().flatten().filter(|r| nested_row(r)).filter_map(|r| r["id"].as_u64()).collect(); + c.nested.clear(); + if !want.is_empty() + && let Some(st) = app.session.active() + { + st.doc.visit_stored_arcs(|n, place| { + if !place.in_layers() && want.remove(&n.id.0) { + c.nested.push((n.id.0, std::sync::Arc::clone(n))); + } + }); + c.nested.sort_by_key(|(id, _)| *id); + } } let want = first.filter(|_| o.info); if stale || c.info.as_ref().map(|(id, _)| *id) != want { @@ -96,6 +116,50 @@ fn selected_images(app: &VectorcraftApp) -> Vec { st.selection.objects.iter().filter(|id| image(id)).map(|id| id.0).collect() } +/// Is row `r` of an object outside the layer tree (in opacity-mask art or the art of a symbol, +/// graph design or pattern), which the canvas can't select? +fn nested_row(r: &Value) -> bool { + r["owner"].is_object() +} + +/// The active document's uid and the ids of its selected objects. +fn canvas_selection(app: &VectorcraftApp) -> Option<(u64, Vec)> { + app.session.active().map(|st| (st.uid, st.selection.objects.iter().map(|id| id.0).collect())) +} + +/// The rows outside the layer tree picked in the panel, with the canvas selection +/// ([`canvas_selection`]) they were picked with. +type NestedPicks = Option<((u64, Vec), Vec)>; + +/// The rows outside the layer tree picked in the panel, in the active document. A change of the +/// canvas selection, or another document, drops them (the panel looks every frame). +fn nested_picks(app: &VectorcraftApp, ctx: &egui::Context) -> Vec { + let picks: NestedPicks = pstate(ctx, "links-nested"); + let Some((at, ids)) = picks else { return vec![] }; + if Some(&at) == canvas_selection(app).as_ref() { + return ids; + } + set_pstate::(ctx, "links-nested", None); + vec![] +} + +/// Keep `ids` as the rows picked in the panel, with the canvas selection as it is now. +fn set_nested_picks(app: &VectorcraftApp, ctx: &egui::Context, ids: Vec) { + set_pstate(ctx, "links-nested", canvas_selection(app).map(|at| (at, ids))); +} + +/// The ids of the selected rows: the selected images and placed documents, then the rows outside +/// the layer tree picked in the panel. +fn selected_links(app: &VectorcraftApp, ctx: &egui::Context) -> Vec { + let mut ids = selected_images(app); + for id in nested_picks(app, ctx) { + if !ids.contains(&id) { + ids.push(id); + } + } + ids +} + /// The rows of `list` whose image is selected. fn selected_rows<'a>(list: &'a Value, selected: &[u64]) -> Vec<&'a Value> { list["links"].as_array().into_iter().flatten().filter(|r| r["id"].as_u64().is_some_and(|id| selected.contains(&id))).collect() @@ -170,9 +234,14 @@ fn relink_to_picked_folder(app: &mut VectorcraftApp, ids: Vec) { }); } -/// Select image `id` and scroll it into view. +/// Select image `id` and scroll it into view; one outside the layer tree only says that it has no +/// place on the canvas. pub(crate) fn go_to(app: &mut VectorcraftApp, id: u64) { let Ok(r) = app.run("links.goTo", json!({ "id": id })) else { return }; + if r["onCanvas"] == false { + app.status("The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas"); + return; + } let b: Vec = r["bounds"].as_array().into_iter().flatten().filter_map(Value::as_f64).collect(); if let (&[x0, y0, x1, y1], Some(v)) = (&b[..], app.view_mut()) { v.center = vectorcraft_geom::Point::new((x0 + x1) / 2.0, (y0 + y1) / 2.0); @@ -290,25 +359,46 @@ fn status_tip(r: &Value) -> String { /// Ctrl/Cmd adds or removes it, and Shift selects the images of the rows from the last row clicked /// without Shift in this document to it, or with Ctrl/Cmd adds them to the selection. A /// Shift-click while that row is not shown, or after the last click was in another document, acts -/// as a click without Shift. +/// as a click without Shift. Rows outside the layer tree are picked in the panel itself, as the +/// canvas can't select them; a click without Ctrl/Cmd replaces both kinds of selection. fn click(app: &mut VectorcraftApp, ctx: &egui::Context, rows: &[Value], id: u64, m: egui::Modifiers) { let Some(uid) = app.session.active().map(|st| st.uid) else { return }; let anchor: Option<(u64, u64)> = pstate(ctx, "links-anchor"); let at = |id: u64| rows.iter().position(|r| r["id"].as_u64() == Some(id)); + let nested = |r: &Value| nested_row(r).then(|| r["id"].as_u64()).flatten(); + let mut picks = if m.command { nested_picks(app, ctx) } else { vec![] }; if m.shift && let Some((_, a)) = anchor.filter(|(doc, _)| *doc == uid) && let (Some(i), Some(j)) = (at(a), at(id)) { - let range: Vec = rows.get(i.min(j)..=i.max(j)).unwrap_or_default().iter().filter_map(|r| r["id"].as_u64()).collect(); - app.run(if m.command { "select.add" } else { "select.set" }, json!({ "ids": range })).ok(); + let range = rows.get(i.min(j)..=i.max(j)).unwrap_or_default(); + let canvas: Vec = range.iter().filter(|r| !nested_row(r)).filter_map(|r| r["id"].as_u64()).collect(); + picks.extend(range.iter().filter_map(nested).filter(|id| !picks.contains(id)).collect::>()); + app.run(if m.command { "select.add" } else { "select.set" }, json!({ "ids": canvas })).ok(); + set_nested_picks(app, ctx, picks); return; } set_pstate(ctx, "links-anchor", Some((uid, id))); + let row = rows.iter().find(|r| r["id"].as_u64() == Some(id)); + if row.is_some_and(nested_row) { + match picks.iter().position(|p| *p == id) { + Some(i) if m.command => { + picks.remove(i); + } + _ => picks.push(id), + } + if !m.command { + app.run("select.set", json!({ "ids": [] })).ok(); + } + set_nested_picks(app, ctx, picks); + return; + } if m.command { app.run("select.toggle", json!({ "id": id })).ok(); } else { app.run("select.set", json!({ "ids": [id] })).ok(); } + set_nested_picks(app, ctx, picks); } pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { @@ -322,7 +412,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let Some(st) = app.session.active() else { return }; let doc = st.doc.clone(); let thumbs = super::layers::ThumbSource::of(app, st, &doc); - let selected = selected_images(app); + let selected = selected_links(app, ui.ctx()); let rows: &[Value] = cache.list["links"].as_array().map_or(&[], Vec::as_slice); let mut clicked: Option<(u64, egui::Modifiers, bool)> = None; widgets::list_box(ui, |ui| { @@ -354,7 +444,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if o.thumb > 0.0 { let tr = egui::Rect::from_min_size(egui::pos2(x, rect.center().y - o.thumb / 2.0), vec2(o.thumb, o.thumb)); ui.painter().rect_filled(tr, 0.0, egui::Color32::WHITE); - if let Some(n) = super::layers::node_arc(&doc, NodeId(id)) { + let node = if nested_row(r) { + cache + .nested + .binary_search_by_key(&id, |(n, _)| *n) + .ok() + .and_then(|i| cache.nested.get(i)) + .map(|(_, n)| std::sync::Arc::clone(n)) + } else { + super::layers::node_arc(&doc, NodeId(id)) + }; + if let Some(n) = node { super::layers::real_thumb(ui, &thumbs, &n, tr); } ui.painter().rect_stroke(tr, 0.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside); @@ -411,7 +511,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if widgets::icon_button_enabled(ui, "pencil", tl!("Edit Original"), false, can_edit, 24.0).clicked() { - crate::menus::invoke(app, "links.editOriginal", json!({})); + crate::menus::invoke(app, "links.editOriginal", file_row(&sel)); } if widgets::icon_button_enabled(ui, "rotate-cw", tl!("Update Link"), false, !modified.is_empty(), 24.0).clicked() { let r = app.run("links.update", json!({ "ids": modified })); @@ -452,6 +552,15 @@ fn break_link(app: &mut VectorcraftApp, ids: Vec) { }); } +/// The params of Edit Original and Show in Folder for the selected rows `sel`: the first linked +/// row whose file is there (rows outside the layer tree included), else none (the selection's). +fn file_row(sel: &[&Value]) -> Value { + match ids(sel, |r| r["linked"] == true && !status_is(r, "missing")).first() { + Some(id) => json!({ "id": id }), + None => json!({}), + } +} + /// Pick-a-file actions need a file dialog (the desktop app). fn can_pick(app: &VectorcraftApp) -> bool { app.services.pick_open.is_some() @@ -538,7 +647,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let o = options(ui.ctx()); // The rows the panel shows (the menu opens from it). let list = pstate::(ui.ctx(), "links-cache").list; - let selected = selected_images(app); + let selected = selected_links(app, ui.ctx()); let sel = selected_rows(&list, &selected); let linked = ids(&sel, |r| r["linked"] == true); let embedded: Vec<&Value> = sel.iter().copied().filter(|r| status_is(r, "embedded")).collect(); @@ -563,7 +672,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { report(app, r, |v| updated_status(v["updated"].as_array().map_or(0, Vec::len))); } if menu_item(ui, tl!("Edit Original"), sel.iter().any(|r| r["linked"] == true && !status_is(r, "missing")), false) { - crate::menus::invoke(app, "links.editOriginal", json!({})); + crate::menus::invoke(app, "links.editOriginal", file_row(&sel)); } ui.separator(); if menu_item(ui, tl!("Embed Image(s)"), !linked.is_empty(), false) { @@ -594,7 +703,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { crate::menus::invoke(app, "ui.placementOptionsDialog", json!({ "ids": ids(&sel, |_| true) })); } if menu_item(ui, tl!("Show in Folder"), sel.iter().any(|r| r["linked"] == true && !status_is(r, "missing")), false) { - crate::menus::invoke(app, "links.reveal", json!({})); + crate::menus::invoke(app, "links.reveal", file_row(&sel)); } ui.separator(); // Select Linked, Embedded, Missing and Modified: every such image in the document, not only diff --git a/crates/ui-egui/src/panels/tests_links.rs b/crates/ui-egui/src/panels/tests_links.rs index 215cf7148a..be5e17e374 100644 --- a/crates/ui-egui/src/panels/tests_links.rs +++ b/crates/ui-egui/src/panels/tests_links.rs @@ -132,6 +132,52 @@ fn shift_click_selects_a_range_of_links_and_ctrl_click_toggles_one() { assert_eq!(selected(&app), id(&[3])); } +/// A linked image kept only in a symbol's art has a row of its own: a click picks it in the panel +/// (the canvas selection is cleared), Link Info shows it, Ctrl/Cmd-click adds an image in the +/// layers, a change of the canvas selection drops the pick, and Go To clears the selection and +/// says it has no place on the canvas. +#[test] +fn a_link_inside_a_symbol_is_listed_and_picked_in_the_panel() { + let dir = Folder::new("nested"); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let inside = dir.file("inside.png", &png(30, 20)); + let id = app.run("file.place", json!({"path": inside, "at": [100, 100]})).unwrap()["ids"][0].as_u64().unwrap(); + app.run("symbol.new", json!({"name": "Mark", "ids": [id]})).unwrap(); + app.run("edit.clear", json!({"ids": selected(&app)})).unwrap(); + let outside = dir.file("layer.png", &png(20, 20)); + let layer = app.run("file.place", json!({"path": outside, "at": [300, 100]})).unwrap()["ids"][0].as_u64().unwrap(); + let list = app.run("links.list", json!({})).unwrap(); + let row = list["links"].as_array().unwrap().iter().find(|r| r["name"] == "inside.png").unwrap_or_else(|| panic!("{list}")).clone(); + assert_eq!(row["owner"]["kind"], "symbol", "{list}"); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let shown = |app: &mut VectorcraftApp, time: f64| -> Vec { + let raw = egui::RawInput { time: Some(time), ..Default::default() }; + super::tests_appearance::frame_raw(&ctx, app, raw, super::links::show).into_iter().map(|(t, _)| t).collect() + }; + assert!(shown(&mut app, 0.5).iter().any(|t| t == "20 × 20 px"), "Link Info of the selected image in the layers"); + click_row(&mut app, &ctx, "inside.png", egui::Modifiers::NONE, 1.0); + assert!(selected(&app).is_empty(), "the canvas can't select it"); + assert!(shown(&mut app, 1.5).iter().any(|t| t == "30 × 20 px"), "Link Info of the symbol's image"); + click_row(&mut app, &ctx, "layer.png", egui::Modifiers::COMMAND, 2.0); + assert_eq!(selected(&app), [layer]); + // Both rows are selected until the canvas selection changes: then the panel's pick goes too. + click_row(&mut app, &ctx, "layer.png", egui::Modifiers::COMMAND, 3.0); + assert!(selected(&app).is_empty()); + assert!(shown(&mut app, 3.5).iter().any(|t| t == "30 × 20 px"), "the symbol's image is still picked"); + app.run("select.set", json!({"ids": [layer]})).unwrap(); + assert!(shown(&mut app, 4.0).iter().any(|t| t == "20 × 20 px")); + app.run("select.set", json!({"ids": []})).unwrap(); + let text = shown(&mut app, 4.5); + assert!(!text.iter().any(|t| t == "30 × 20 px" || t == "20 × 20 px"), "nothing is picked: {text:?}"); + // Go To clears the selection and says the link has no place on the canvas. + app.run("select.set", json!({"ids": [layer]})).unwrap(); + super::links::go_to(&mut app, row["id"].as_u64().unwrap()); + assert_eq!(app.ui.status, "The link is inside a symbol, graph design, pattern or opacity mask and has no place on the canvas"); + assert!(selected(&app).is_empty(), "the image in the layers is no longer selected"); +} + #[test] fn an_empty_document_shows_how_to_add_images() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); diff --git a/docs/mcp.md b/docs/mcp.md index 2c8afc7aea..0837f48fb4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6403,8 +6403,8 @@ path isn't followed) when it isn't. The desktop app also checks the active docum comes back to the front, following the same preference. `links.check` reports every link's `status` (`ok`, `modified`, `missing`), `links.update {ids?}` reads modified files again and `links.relink {ids?, path | folder}` points images at another file (or each at the file of its name in a folder); images keep their bounds, one undo step each. -`links.relink`'s `scope` picks which links take the new file (Relink's Replace choice): `selected` (the default) relinks `ids`; `all` also relinks every other object in the layers linked to the same file as -one of them; `missingInFolder` relinks `ids` to `path`, then every other missing link to the file of its name in +`links.relink`'s `scope` picks which links take the new file (Relink's Replace choice): `selected` (the default) relinks `ids`; `all` also relinks every other stored object linked to the same file as +one of them, in the layers, opacity-mask art, symbols, graph designs and patterns; `missingInFolder` relinks `ids` to `path`, then every other missing link to the file of its name in `path`'s folder, and lists the names not there in `notFound` (with `copy`, only missing images are looked for); `each` takes `paths: [{ids, path}]` in place of `ids`, `path` and `folder` and relinks each link to its own file. An unknown scope, `paths` without `each`, `each` with `ids`, `path` or `folder`, an entry without `path` or `ids`, an id in two entries and `missingInFolder` with `folder` are @@ -6412,8 +6412,10 @@ errors, and nothing is relinked. Each call is one undo step. The Links panel's R (`ui.relinkDialog`) with a Replace menu of these four choices. A PNG, JPEG, another file (or each at the file of its name in a folder); images keep their bounds, one undo step each. `links.update`, and `document.open` when it reads a modified file, also read the file again for the images and placed -documents inside symbols, graph designs, patterns and opacity masks that show the same content (by default also for -those that only that art shows); they have no ids, so the results do not list them. Saving a document also reads it +documents inside symbols, graph designs, patterns and opacity masks that show the same content. Those objects have ids +of their own: `links.list`, `links.check`, `links.findFiles`, the `missingLinks`, `modifiedLinks` and `updatedLinks` +rows and the link watch list them, and every `links.*` command takes their ids. Opening a document gives an object +there whose id is 0 or already used by another object a new id, and the next save writes it. Saving a document also reads it again for the placed documents of it inside that art in the other open documents. A PNG, JPEG, GIF or WebP file whose pixels can't be read is not shown: `links.relink` fails with "the image's pixels can't be read", and `links.update`, or `document.open` when it reads the file, lists it as missing, its images keeping what they @@ -6653,8 +6655,11 @@ listed in `warnings`. Binary DXF and DWG files answer an error saying to save th ## Links panel `links.list {show?: all|missing|modified|embedded, sort?: name|kind|status}` lists every image in the layers, top -first, as the Links panel does: `{id, name, linked, status: ok|modified|missing|embedded, format, pixelWidth, -pixelHeight, path?, found?, page?, preview?, note?}` (`note`: the image takes more than the decode allowance, 2 GiB +first, as the Links panel does, then every image and placed document in opacity-mask art and in the art of symbols, +graph designs and patterns, one row per stored object (never one per symbol instance or pattern tile): `{id, name, +linked, status: ok|modified|missing|embedded, format, pixelWidth, pixelHeight, path?, found?, page?, preview?, note?, +owner?}` (`owner`, for an object outside the layer tree: `{kind: layers|symbol|graphDesign|pattern, name?, maskOf?}`, +`maskOf` naming the object whose opacity mask holds it; `note`: the image takes more than the decode allowance, 2 GiB or 512 MiB in the web app, as RGBA or as decoded, and it is drawn at the largest size within the allowance ("shown at reduced size"; the document keeps the whole file) or, when it can't be read that way or has more pixels than the allowance has bytes, drawn as a crossed gray box on the canvas and left out of exports ("too large to draw"); `document.open` and `file.place` say the same in their `warnings`). `links.info {id?}` is the Link Info: `image.info`'s fields plus @@ -6662,7 +6667,8 @@ allowance has bytes, drawn as a crossed gray box on the canvas and left out of e counter-clockwise), `placement` and, for a linked file, `fileName`, `location`, `fileSize`, `created`, `modified`. With automation read roots, a file outside them has no `path` or `found` in `links.list` and `links.check`, and no `link`, `path`, `location`, `fileSize` or `modified` in `links.info`; its row keeps the file's name. -`links.goTo {id}` selects an image; `links.embed {ids?}` keeps linked files' pixels in the document (an image whose +`links.goTo {id}` selects an image (`onCanvas: true`); for an object outside the layer tree it clears the selection and +returns `{id, onCanvas: false, owner}`, and the panel says the link has no place on the canvas. `links.embed {ids?}` keeps linked files' pixels in the document (an image whose file is missing stays linked: relink it first); `links.unembed {id, path}` writes an embedded image to a file (another extension converts it) and links to it, and without `path` returns `{name, dataBase64}`. `links.placementOptions {ids?, preserve?: transforms|bounds|fileDimensions|fit|fill, align?: topLeft…bottomRight, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 1ce5c5fda5..6bcf0ac446 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1629,6 +1629,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - On Windows, a document opened from a mapped network drive finds its links stored with the share's own path (`\\server\share\…`), as the same document opened from the share does; links on other computers stay missing. - On Windows, a link on a drive mapped to a network share other than the document's (`Y:\…`) is missing, with the reason that the path is on another computer, as a link stored with that share's own path is; links on this computer's drives and on the document's share are followed. - In the Links panel, Shift-click selects a range of links and Ctrl/Cmd-click adds or removes one. + - Linked images and placed documents kept only in symbols, graph designs, patterns or opacity masks are listed in the Links panel after the layers' own, one row per stored object, with their status in its Missing and Modified filters, in Check Links, in the missing and modified files reported when a document opens, in the link watch and in Find in Folder; Relink, Update, Embed, Placement Options and Link Info act on them, and Go To says they have no place on the canvas. Opening a document gives such an object whose id is 0 or repeats another object's a new id. - Linked files another app changes while their document is open follow Preferences › File Handling › Update Links, as they do when it opens: Automatically reads them again (an Update Links undo step), Ask When Modified offers to, Manually leaves them modified in the Links panel. The desktop app compares the active document’s linked files by size and modification time every two seconds and when its window comes back to the front, on a worker thread; a file still being written is tried again (`links.updateChanged`). - A look at the linked files whose worker thread ends without a result, such as after a panic, no longer stops later looks: the app clears it, stops asking for a frame every 100 ms, and the next look starts on schedule (`crates/engine/src/tests/linkwatch.rs`, `crates/ui-egui/src/tests/place.rs`). - Import and paste, 2026-10-13: a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542). Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479). Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499). From a7c82ac7f2598f1ebc26422c21fbdad335ef44c1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 21:24:12 -0800 Subject: [PATCH 02/97] Links: test a DCS image in a symbol updated and relinked by its id The symbol's image keeps following its DCS file's clipping path when it is updated by the id links.list gives it, and loses that path when it is relinked to a plain image. --- crates/engine/src/tests/dcs.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/crates/engine/src/tests/dcs.rs b/crates/engine/src/tests/dcs.rs index 2801432a0a..8a75da8208 100644 --- a/crates/engine/src/tests/dcs.rs +++ b/crates/engine/src/tests/dcs.rs @@ -326,6 +326,31 @@ fn update_link_takes_the_files_clipping_path_into_symbols_too() { assert_eq!(symbol_clip_width(&s, "Pic"), None); } +#[test] +fn update_and_relink_by_id_keep_the_clipping_path_of_a_dcs_image_in_a_symbol() { + let dir = temp_dir("symbol-clip-by-id"); + let path = write(&dir, "pic.eps", &two_plates()); + let mut s = session(); + let r = s.execute("file.place", &json!({ "path": path, "at": [100, 100] })).unwrap(); + s.execute("symbol.new", &json!({ "name": "Pic", "ids": [r["ids"][0]] })).unwrap(); + let list = s.execute("links.list", &json!({})).unwrap(); + let row = list["links"].as_array().unwrap().iter().find(|r| r["owner"]["kind"] == "symbol").cloned().expect("the symbol's image is listed"); + let id = row["id"].as_u64().unwrap(); + assert_eq!(row["format"], "DCS", "{list}"); + let before = symbol_clip_width(&s, "Pic").expect("the file's clipping path"); + // The composite now drawn through the left quarter of the page (same length). + let text = String::from_utf8(two_plates()).unwrap().replacen("10 0 lineto 10 10 lineto", " 5 0 lineto 5 10 lineto", 1); + std::fs::write(&path, text.as_bytes()).unwrap(); + assert_eq!(s.execute("links.update", &json!({ "ids": [id] })).unwrap()["updated"], json!([id])); + let after = symbol_clip_width(&s, "Pic").expect("still clipped"); + assert!((after - before / 2.0).abs() < 1e-6, "the symbol's clipping path follows the file: {before} → {after}"); + // Relinked by its id to a plain image: the DCS file's clipping path goes. + let png = dir.join("plain.png"); + image::RgbImage::from_pixel(2, 2, image::Rgb([9, 9, 9])).save(&png).unwrap(); + s.execute("links.relink", &json!({ "ids": [id], "path": png.to_string_lossy() })).unwrap(); + assert_eq!(symbol_clip_width(&s, "Pic"), None); +} + #[test] fn a_file_that_lost_its_clipping_path_unclips_the_image() { let dir = temp_dir("update-lost-clip"); From c2ed2dd3a93dc9d322f309cc132aca06b34559ed Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 21:38:46 -0800 Subject: [PATCH 03/97] Links: Find in Folder copies the files of images in symbols and patterns With Copy into the document's folder, an image picked in the Links panel by the id of an object in a symbol, graph design, pattern or opacity mask was relinked to the found file instead of a copy, because the check for an image looked only in the layers. --- crates/ui-egui/src/dialogs/find_links.rs | 5 ++-- .../ui-egui/src/dialogs/find_links/tests.rs | 29 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs/find_links.rs b/crates/ui-egui/src/dialogs/find_links.rs index 05f0e85b60..2e99f5e97e 100644 --- a/crates/ui-egui/src/dialogs/find_links.rs +++ b/crates/ui-egui/src/dialogs/find_links.rs @@ -112,11 +112,12 @@ pub(crate) fn find_in_folder_command(app: &mut VectorcraftApp, folder: Option bool { let Some(id) = id.as_u64() else { return false }; let st = app.session.active(); - st.and_then(|st| st.doc.node(vectorcraft_doc::NodeId(id))).is_some_and(|n| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_))) + st.and_then(|st| st.doc.stored_node(vectorcraft_doc::NodeId(id))).is_some_and(|(n, _)| matches!(n.kind, vectorcraft_doc::NodeKind::Image(_))) } /// Make the dialog's document the active one: it may be another tab's. diff --git a/crates/ui-egui/src/dialogs/find_links/tests.rs b/crates/ui-egui/src/dialogs/find_links/tests.rs index 4722b8b327..0b618c7e6f 100644 --- a/crates/ui-egui/src/dialogs/find_links/tests.rs +++ b/crates/ui-egui/src/dialogs/find_links/tests.rs @@ -255,6 +255,35 @@ fn copy_into_the_documents_folder_copies_before_relinking() { assert!(!frame(&mut app).contains("Copy into the document's folder")); } +/// Copy into the document's folder copies the file of an image in a symbol's art too, picked in the +/// Links panel by its id. +#[test] +fn copy_takes_an_image_in_a_symbol_into_the_documents_folder() { + let root = folder("copy-symbol"); + let mut app = app_for(&root); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let orig = at(&root, "orig/a.png"); + write(&orig, &png(3)); + let placed = app.run("file.place", json!({"path": orig})).unwrap()["ids"][0].clone(); + app.run("symbol.new", json!({"name": "Mark", "ids": [placed]})).unwrap(); + let doc = at(&root, "doc/doc.vectorcraft"); + std::fs::create_dir_all(doc.parent().unwrap()).unwrap(); + app.run("document.save", json!({"path": doc})).unwrap(); + write(&at(&root, "found/a.png"), &png(3)); + let list = app.run("links.list", json!({})).unwrap(); + let row = list["links"].as_array().unwrap().iter().find(|r| r["owner"]["kind"] == "symbol").cloned().expect("the symbol's image"); + app.run("ui.findLinksInFolder", json!({"folder": root.join("found"), "ids": [row["id"]]})).unwrap(); + searched(&mut app); + app.ui.dialog.as_mut().unwrap().fields.insert("copy".into(), json!(true)); + let r = super::super::confirm(&mut app).unwrap(); + assert_eq!((r["relinked"].as_array().map(Vec::len), &r["failed"]), (Some(1), &json!([])), "{r}"); + let copy = at(&root, "doc/a.png"); + assert_eq!(std::fs::read(©).unwrap(), png(3)); + let list = app.run("links.list", json!({})).unwrap(); + let row = list["links"].as_array().unwrap().iter().find(|r| r["owner"]["kind"] == "symbol").cloned().unwrap(); + assert_eq!(row["path"], json!(copy), "{list}"); +} + /// With Copy into the document's folder, a placed document is relinked where its file is. #[test] fn copy_leaves_placed_documents_where_they_are() { From fafd06c9594d1592edf4a6f1e537190e180cf9cb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 22:48:57 -0800 Subject: [PATCH 04/97] Recolor Artwork: the Prominent Colors bar weighs the art's colors With art selected, the Edit tab shows a Prominent Colors bar under the wheel: a part for each row in its new color, as wide as the row's share of the art, the largest first. Dragging the edge between two parts moves width from one to the other; when the drag ends, the rows' current colors move toward the new widths and the art previews the result. OK keeps the recolor as one undo step. - recolor.weigh {map, weights?, ids?} returns each row's share of the uses of the art's colors and, with weights, the rows with their current colors moved: one color at a time between rows that have a new color, while a move brings the shares nearer the weights, the color nearest in Lab to the row it goes to first. A row keeps its first color, the tints of one global swatch move together, colors that stay keep their order, and the command changes nothing. A use counts for the row recolor.apply recolors it with, and the work is bounded at 50,000,000 color-by-row comparisons. - The New Color Group menu beside the link button has Save All Colors (the new colors in row order) and Save Prominent Colors (the bar's colors in its order); each saves a color group as its own undo step. - The dialog's weights field drives the bar from ui.dialog.set. A new Colors count drops it. --- crates/color/src/recolor.rs | 157 ++++++++ crates/engine/src/cmd/plugin_call/reviewed.rs | 1 + crates/engine/src/cmd/recolor.rs | 154 +++++++- crates/engine/src/tests/recolor.rs | 121 ++++++ crates/ui-egui/src/dialogs/recolor.rs | 357 ++++++++++++++++-- crates/ui-egui/src/i18n/bg.tsv | 3 + crates/ui-egui/src/i18n/bs.tsv | 3 + crates/ui-egui/src/i18n/ca.tsv | 3 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/da.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/el.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fi.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hi.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/id.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ka.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/nl.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sv.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + docs/mcp.md | 24 ++ docs/ui-parity.md | 2 + 34 files changed, 874 insertions(+), 23 deletions(-) diff --git a/crates/color/src/recolor.rs b/crates/color/src/recolor.rs index 93c0ad0f37..05c386e405 100644 --- a/crates/color/src/recolor.rs +++ b/crates/color/src/recolor.rs @@ -567,6 +567,112 @@ pub fn theme_palette(samples: &[(Color, f32)], n: usize) -> Vec { groups.into_iter().map(|g| g.2).collect() } +/// Colors that [`weigh`] moves from one row to another as one: a color they are shown by, how +/// often the art uses them and the row they are in. A fixed unit (such as the color a row is +/// shown by) stays in its row. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct WeighUnit { + pub color: Color, + pub uses: f64, + pub row: usize, + pub fixed: bool, +} + +/// The unit-by-row comparisons one [`weigh`] makes, at most. +const WEIGH_WORK: usize = 50_000_000; + +/// The uses of `u` that count: a positive finite number, else 0. +fn unit_uses(u: &WeighUnit) -> f64 { + if u.uses.is_finite() && u.uses > 0.0 { u.uses } else { 0.0 } +} + +/// Each row's share of the uses of `units`, in percent (0 for every row when nothing is used). +/// Units whose row is past `rows`, or whose uses are not a positive finite number, count nothing. +pub fn row_shares(units: &[WeighUnit], rows: usize) -> Vec { + let mut sums = vec![0.0; rows]; + for u in units { + if let Some(s) = sums.get_mut(u.row) { + *s += unit_uses(u); + } + } + let total: f64 = sums.iter().sum(); + if total <= 0.0 { + return vec![0.0; rows]; + } + sums.iter().map(|s| s / total * 100.0).collect() +} + +/// For Recolor Artwork's Prominent Colors (our own rule): the row each of `units` goes to so that +/// each row's share of the uses comes as near its share of `weights` as whole units allow. One +/// unit moves at a time, while a move brings the rows' shares nearer their weights (the sum of +/// every row's distance from its weight falls); of those moves, the one taken is the unit nearest +/// in Lab (CIEDE2000) to the color of the row it goes to (`anchors`), compared with the row it +/// leaves; on a tie, the move that brings the shares nearest. Fixed units, units in a row past +/// `anchors` and units used no times stay where they are. Weights that are negative or not finite +/// count as 0; when no weight is positive, every unit stays. The moves stop after +/// [`WEIGH_WORK`] comparisons of a unit with a row, and when the movable units times the rows pass +/// it, every unit stays. +pub fn weigh(units: &[WeighUnit], anchors: &[Color], weights: &[f64]) -> Vec { + let mut row: Vec = units.iter().map(|u| u.row).collect(); + let n = anchors.len(); + let given = |r: usize| weights.get(r).copied().filter(|x| x.is_finite() && *x > 0.0).unwrap_or(0.0); + // Scaled by the largest weight first, so the sum stays finite. + let top = (0..n).map(given).fold(0.0, f64::max); + let w = |r: usize| if top > 0.0 { given(r) / top } else { 0.0 }; + let sum_w: f64 = (0..n).map(w).sum(); + let movable: Vec = (0..units.len()).filter(|&i| !units[i].fixed && units[i].row < n && unit_uses(&units[i]) > 0.0).collect(); + let per_pass = movable.len().saturating_mul(n); + if sum_w <= 0.0 || n < 2 || movable.is_empty() || per_pass > WEIGH_WORK { + return row; + } + let mut sums = vec![0.0; n]; + for u in units.iter().filter(|u| u.row < n) { + sums[u.row] += unit_uses(u); + } + let total: f64 = sums.iter().sum(); + let target: Vec = (0..n).map(|r| w(r) / sum_w * total).collect(); + let anchor_labs: Vec = anchors.iter().map(|c| c.to_lab()).collect(); + // Each movable unit's distance to each row's color. + let dist: Vec> = movable + .iter() + .map(|&i| { + let l = units[i].color.to_lab(); + anchor_labs.iter().map(|a| delta_e2000(*a, l)).collect() + }) + .collect(); + // A move must bring the shares nearer by more than rounding. + let eps = total * 1e-9; + for _ in 0..WEIGH_WORK / per_pass { + let mut best: Option<(f32, f64, usize, usize)> = None; + for (m, &i) in movable.iter().enumerate() { + let (a, x) = (row[i], unit_uses(&units[i])); + let before_a = (sums[a] - target[a]).abs(); + let after_a = (sums[a] - x - target[a]).abs(); + for b in (0..n).filter(|&b| b != a) { + let gain = before_a + (sums[b] - target[b]).abs() - after_a - (sums[b] + x - target[b]).abs(); + if gain <= eps { + continue; + } + let cost = dist[m][b] - dist[m][a]; + let better = match best { + None => true, + Some((c, g, ..)) => cost < c || (cost == c && gain > g), + }; + if better { + best = Some((cost, gain, m, b)); + } + } + } + let Some((_, _, m, b)) = best else { break }; + let i = movable[m]; + let x = unit_uses(&units[i]); + sums[row[i]] -= x; + sums[b] += x; + row[i] = b; + } + row +} + /// A set of colours to snap to (Limit to Library), with their Lab values cached. #[derive(Clone, Debug, Default)] pub struct Palette(Vec<(Color, Lab)>); @@ -911,4 +1017,55 @@ mod tests { assert!(matches!(Rng::new(3).saturation_brightness(Color::lab(50.0, 40.0, 20.0)), Color::Lab { .. })); assert_eq!(Rng::new(3).saturation_brightness(Color::gray(0.4)).to_hsb()[1], 0.0, "greys stay grey"); } + + fn unit(color: Color, uses: f64, row: usize, fixed: bool) -> WeighUnit { + WeighUnit { color, uses, row, fixed } + } + + #[test] + fn row_shares_are_percent_of_the_uses() { + let units = [unit(Color::rgb(1.0, 0.0, 0.0), 3.0, 0, true), unit(Color::rgb(0.0, 0.0, 1.0), 1.0, 1, true), unit(Color::WHITE, 5.0, 4, false)]; + assert_eq!(row_shares(&units, 2), vec![75.0, 25.0]); + assert_eq!(row_shares(&[], 2), vec![0.0, 0.0]); + assert_eq!(row_shares(&[unit(Color::WHITE, f64::NAN, 0, false)], 1), vec![0.0]); + } + + #[test] + fn weigh_moves_the_nearest_colors_toward_the_weights() { + let (red, blue) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)); + let units = [ + unit(red, 1.0, 0, true), + unit(Color::rgb(0.9, 0.1, 0.3), 1.0, 0, false), + unit(Color::rgb(0.5, 0.0, 0.8), 1.0, 0, false), + unit(blue, 1.0, 1, true), + ]; + // Equal weights: the reddish purple, nearer blue than the red-pink is, moves. + assert_eq!(weigh(&units, &[red, blue], &[1.0, 1.0]), vec![0, 0, 1, 1]); + // Most of the weight on blue: both movable colors go, the row's own color stays. + assert_eq!(weigh(&units, &[red, blue], &[0.0, 1.0]), vec![0, 1, 1, 1]); + // Weights already met, or none positive: nothing moves. + assert_eq!(weigh(&units, &[red, blue], &[3.0, 1.0]), vec![0, 0, 0, 1]); + assert_eq!(weigh(&units, &[red, blue], &[0.0, f64::NAN]), vec![0, 0, 0, 1]); + assert_eq!(weigh(&units, &[red, blue], &[1e308, 1e308]), vec![0, 0, 1, 1], "huge weights act as equal ones"); + assert_eq!(weigh(&units, &[red], &[1.0]), vec![0, 0, 0, 1], "a row past the anchors keeps its units"); + } + + #[test] + fn weigh_takes_whole_units_and_stops_when_no_move_helps() { + let (red, blue) = (Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)); + // A heavy unit: moving it would overshoot by more than staying misses. + let units = [unit(red, 1.0, 0, true), unit(Color::rgb(0.8, 0.0, 0.2), 10.0, 0, false), unit(blue, 10.0, 1, true)]; + assert_eq!(weigh(&units, &[red, blue], &[1.0, 1.4]), vec![0, 0, 1]); + assert_eq!(weigh(&units, &[red, blue], &[1.0, 20.0]), vec![0, 1, 1]); + // Many units: the result still follows the weights. + let many: Vec = (0..400).map(|i| unit(Color::rgb(i as f32 / 400.0, 0.2, 1.0 - i as f32 / 400.0), 1.0, 0, i == 0)).collect(); + let rows = weigh(&many, &[Color::rgb(0.0, 0.2, 1.0), Color::rgb(1.0, 0.2, 0.0)], &[1.0, 3.0]); + assert_eq!(rows.iter().filter(|r| **r == 1).count(), 300); + assert!(rows[399] == 1 && rows[1] == 0, "the reddest colors go to the red row"); + // Past the work limit (8,000 units by 7,000 rows) nothing moves. + let units: Vec = (0..8_000).map(|i| unit(Color::WHITE, 1.0, 0, i == 0)).collect(); + let anchors = vec![Color::BLACK; 7_000]; + let weights = vec![1.0; 7_000]; + assert!(weigh(&units, &anchors, &weights).iter().all(|r| *r == 0)); + } } diff --git a/crates/engine/src/cmd/plugin_call/reviewed.rs b/crates/engine/src/cmd/plugin_call/reviewed.rs index c4b21b1cb4..bf46bcee94 100644 --- a/crates/engine/src/cmd/plugin_call/reviewed.rs +++ b/crates/engine/src/cmd/plugin_call/reviewed.rs @@ -478,6 +478,7 @@ pub(super) const ALLOWED: &[&str] = &[ "recolor.pickTheme", "recolor.randomize", "recolor.reduce", + "recolor.weigh", "select.add", "select.all", "select.allOnArtboard", diff --git a/crates/engine/src/cmd/recolor.rs b/crates/engine/src/cmd/recolor.rs index 931bcce681..f36486064c 100644 --- a/crates/engine/src/cmd/recolor.rs +++ b/crates/engine/src/cmd/recolor.rs @@ -7,7 +7,9 @@ use std::collections::{BTreeSet, HashMap}; use serde_json::{Value, json}; -use vectorcraft_color::recolor::{ColorKey, GlobalAdjust, Method, Preserve, Rng, RowMap, cluster, same_ink}; +use vectorcraft_color::recolor::{ + ColorKey, GlobalAdjust, Method, Preserve, Rng, RowMap, WeighUnit, cluster, row_shares, same_ink, weigh as weigh_rows, +}; use vectorcraft_color::{Color, Paint, Swatch}; use vectorcraft_doc::swatches::{node_colors, node_paints, override_paints}; use vectorcraft_doc::{Document, Node, NodeId, NodeKind}; @@ -63,6 +65,15 @@ pub fn specs() -> Vec { has_doc, pick_theme ), + cmd!( + query "recolor.weigh", + "Prominent Colors", + [], + None, + "{map: rows [{from, to, …}] or {current: new} as recolor.apply, weights?: [numbers ≥ 0, one per row; an excluded row's is ignored, and at least one other must be above 0], ids?: as recolor.colors} → {map: the rows as given, each with its current colours (from) moved as below, shares: [one per row: its percent of the uses (recolor.colors' count) of the current colours of the rows that aren't excluded; 0 for an excluded row]} for Recolor Artwork's Prominent Colors (VectorCraft's own rule): without weights the rows stay as they are. With weights, current colours move one at a time between the rows that aren't excluded and have a new colour (to), while a move brings each row's share of the uses nearer its share of the weights, first the colour nearest in Lab to the first colour of the row it goes to compared with the row it leaves; a row's first colour stays in it, the tints of one global swatch in a row move together, and a use counts for the row recolor.apply recolors it with (rows with a new colour first, an exact colour before a hex one, the first row that lists it), else for the first other row that lists it. The colours that can move are compared with the rows at most 50,000,000 times in all, and when they times the rows that aren't excluded pass that, none move. Changes nothing", + has_doc, + weigh + ), ] } @@ -1062,6 +1073,147 @@ fn colors(s: &mut Session, p: &Value) -> Result { Ok(json!({ "colors": v })) } +/// `recolor.weigh`'s rows as given, each with `from` as a list (a `{current: new}` map gives a +/// row per entry). The rows have already been read by [`parse_rows`]. +fn given_rows(p: &Value) -> Vec { + match p.get("map") { + Some(Value::Array(rows)) => rows + .iter() + .map(|r| { + let mut r = r.clone(); + if let Some(f) = r.get("from").filter(|f| !f.is_array()).cloned() { + r["from"] = json!([f]); + } + r + }) + .collect(), + Some(Value::Object(m)) => m.iter().map(|(k, v)| json!({"from": [k], "to": v})).collect(), + _ => vec![], + } +} + +fn weigh(s: &mut Session, p: &Value) -> Result { + const C: &str = "recolor.weigh"; + let rows = parse_rows(p, C)?; + let weights = match p.get("weights") { + None | Some(Value::Null) => None, + Some(Value::Array(a)) if a.len() == rows.len() => Some( + a.iter() + .map(|v| { + v.as_f64() + .filter(|x| x.is_finite() && *x >= 0.0) + .ok_or_else(|| bad(C, format!("each weight must be a number of 0 or more (got {v})"))) + }) + .collect::>>()?, + ), + Some(v) => return Err(bad(C, format!("`weights` must be a list of {} numbers, one per row (got {v})", rows.len()))), + }; + let active: Vec = (0..rows.len()).filter(|&i| !rows[i].exclude).collect(); + if let Some(w) = &weights + && !active.is_empty() + && !active.iter().any(|&i| w[i] > 0.0) + { + return Err(bad(C, "at least one row that isn't excluded needs a weight above 0")); + } + let (ids, only) = scope_ids(s, p, C)?; + let st = s.doc()?; + let mut used: Vec> = used_colors(&st.doc, &ids, only.as_ref()).into_iter().map(Some).collect(); + let mut by_key: HashMap = HashMap::new(); + let mut by_hex: HashMap> = HashMap::new(); + for (i, u) in used.iter().enumerate().filter_map(|(i, u)| Some((i, u.as_ref()?))) { + by_key.insert(ColorKey::of(&u.color), i); + by_hex.entry(u.color.to_hex()).or_default().push(i); + } + // Each current colour's uses and global swatch. A use counts where recolor.apply recolors it: + // rows with a new colour first, exact colours before hex colours, the first row that lists it; + // then the other rows, in the same way. + let mut take = |m: &Match| -> (usize, Option) { + let hits: Vec = match m { + Match::Key(k) => by_key.get(k).copied().into_iter().collect(), + Match::Hex(h) => by_hex.get(h).cloned().unwrap_or_default(), + }; + let (mut uses, mut link) = (0, None); + for u in hits.into_iter().filter_map(|i| used[i].take()) { + uses += u.count; + link = link.or(u.swatch); + } + (uses, link) + }; + let mut found: Vec)>> = rows.iter().map(|r| vec![(0, None); r.from.len()]).collect(); + for recolors in [true, false] { + for hex in [false, true] { + for (r, row) in rows.iter().enumerate().filter(|(_, r)| r.new_color().is_some() == recolors) { + for (e, m) in row.from.iter().enumerate().filter(|(_, m)| matches!(m, Match::Hex(_)) == hex) { + found[r][e] = take(m); + } + } + } + } + // The units that move: in each row that isn't excluded, the colours linked to one global + // swatch go together, each other colour on its own, and the unit of the first colour stays. + let (mut units, mut members): (Vec, Vec<(usize, Vec)>) = (vec![], vec![]); + for (n, &r) in active.iter().enumerate() { + let mut groups: Vec<(Option<&String>, Vec, usize)> = vec![]; + for (e, (uses, link)) in found[r].iter().enumerate() { + match groups.iter_mut().find(|g| link.is_some() && g.0 == link.as_ref()) { + Some(g) => { + g.1.push(e); + g.2 += uses; + } + None => groups.push((link.as_ref(), vec![e], *uses)), + } + } + for (k, (_, entries, uses)) in groups.into_iter().enumerate() { + units.push(WeighUnit { color: rows[r].from[entries[0]].color(), uses: uses as f64, row: n, fixed: k == 0 }); + members.push((r, entries)); + } + } + if let Some(w) = &weights { + // Only rows with a new colour recolor what they hold, so only they give and take colours. + let movers: Vec = (0..active.len()).filter(|&n| rows[active[n]].new_color().is_some()).collect(); + let anchors: Vec = + movers.iter().map(|&n| rows[active[n]].from.first().map(Match::color).or(rows[active[n]].to).unwrap_or_default()).collect(); + let mut mover_of = vec![None; active.len()]; + for (m, &n) in movers.iter().enumerate() { + mover_of[n] = Some(m); + } + let at: Vec> = units.iter().map(|u| mover_of[u.row]).collect(); + let sub: Vec = units.iter().zip(&at).filter_map(|(u, m)| Some(WeighUnit { row: (*m)?, ..*u })).collect(); + let to = weigh_rows(&sub, &anchors, &movers.iter().map(|&n| w[active[n]]).collect::>()); + for ((u, _), m) in units.iter_mut().zip(&at).filter(|(_, m)| m.is_some()).zip(to) { + u.row = movers[m]; + } + } + let mut out = given_rows(p); + if weights.is_some() { + // Each row's colours: those that stayed, in their order, then those that came to it. + let old: Vec> = out.iter().map(|r| r["from"].as_array().cloned().unwrap_or_default()).collect(); + let mut stayed: Vec> = active.iter().map(|_| vec![]).collect(); + let mut came: Vec> = active.iter().map(|_| vec![]).collect(); + for (u, (r, entries)) in units.iter().zip(&members) { + if active[u.row] == *r { + stayed[u.row].extend(entries); + } else { + came[u.row].extend(entries.iter().filter_map(|&e| old[*r].get(e).cloned())); + } + } + for (n, &r) in active.iter().enumerate() { + stayed[n].sort_unstable(); + let mut from: Vec = stayed[n].iter().filter_map(|&e| old[r].get(e).cloned()).collect(); + from.append(&mut came[n]); + if let Some(row) = out.get_mut(r) { + row["from"] = Value::Array(from); + } + } + } + let shares = row_shares(&units, active.len()); + let mut all = vec![0.0; rows.len()]; + for (n, &r) in active.iter().enumerate() { + all[r] = shares[n]; + } + Ok(json!({"map": out, "shares": all})) +} + /// The `method` parameter (`default` without one, or for null). A `method` that is not a string, /// such as `5`, names no method. fn method_param(p: &Value, cmd: &str, default: Method) -> Result { diff --git a/crates/engine/src/tests/recolor.rs b/crates/engine/src/tests/recolor.rs index 4707ae98fd..a065d09c42 100644 --- a/crates/engine/src/tests/recolor.rs +++ b/crates/engine/src/tests/recolor.rs @@ -1348,3 +1348,124 @@ fn theme_picker_refuses_bad_params() { assert!(e.contains(msg), "{p}: {e}"); } } + +const RED: &str = "rgb 255 0 0"; +const PURPLE: &str = "rgb 128 0 204"; +const BLUE: &str = "rgb 0 0 255"; + +/// Two red rectangles, a purple one and a blue one, selected. +fn prominent_art(s: &mut Session) -> [NodeId; 4] { + let ids = [rect(s, json!("#ff0000")), rect(s, json!("#ff0000")), rect(s, json!("#8000cc")), rect(s, json!("#0000ff"))]; + select_all(s); + ids +} + +#[test] +fn weigh_gives_each_rows_share_of_the_uses() { + let mut s = session(); + prominent_art(&mut s); + let map = json!([{"from": [RED, PURPLE], "to": "#00ff00"}, {"from": BLUE, "to": null, "tint": 50}]); + let r = run(&mut s, "recolor.weigh", json!({"map": map})); + assert_eq!(r["shares"], json!([75.0, 25.0])); + assert_eq!( + r["map"], + json!([{"from": [RED, PURPLE], "to": "#00ff00"}, {"from": [BLUE], "to": null, "tint": 50}]), + "rows as given, from as a list" + ); + // An excluded row has no share, and the others share the uses. + let excl = json!([{"from": [RED, PURPLE], "to": null}, {"from": [BLUE], "to": null, "exclude": true}]); + assert_eq!(run(&mut s, "recolor.weigh", json!({"map": excl}))["shares"], json!([100.0, 0.0])); + // A current colour that no art uses counts nothing, and a use counts for the first row only. + let twice = json!({"map": [{"from": [RED, "#123456"]}, {"from": [RED, PURPLE, BLUE]}]}); + assert_eq!(run(&mut s, "recolor.weigh", twice)["shares"], json!([50.0, 50.0])); + // As recolor.apply matches them: an exact colour before a hex one, a row with a new colour first. + let exact = json!({"map": [{"from": ["#ff0000", PURPLE], "to": "#00ff00"}, {"from": [RED, BLUE], "to": "#ffff00"}]}); + assert_eq!(run(&mut s, "recolor.weigh", exact)["shares"], json!([25.0, 75.0])); + let recolored = json!({"map": [{"from": [RED, PURPLE]}, {"from": [RED, BLUE], "to": "#ffff00"}]}); + assert_eq!(run(&mut s, "recolor.weigh", recolored)["shares"], json!([25.0, 75.0])); +} + +#[test] +fn weigh_moves_colors_to_the_rows_weights_and_changes_nothing() { + let mut s = session(); + let [_, _, purple, blue] = prominent_art(&mut s); + let map = json!([{"from": [RED, PURPLE], "to": "#00ff00"}, {"from": [BLUE], "to": "#ffff00"}]); + let r = run(&mut s, "recolor.weigh", json!({"map": map, "weights": [1, 1]})); + assert_eq!(r["map"], json!([{"from": [RED], "to": "#00ff00"}, {"from": [BLUE, PURPLE], "to": "#ffff00"}])); + assert_eq!(r["shares"], json!([50.0, 50.0])); + assert_eq!(fill(&s, purple).to_hex(), "#8000cc", "a query leaves the art alone"); + // Recoloring with the weighed rows gives the moved colour its new row's colour. + select_all(&mut s); + run(&mut s, "recolor.apply", json!({"map": r["map"]})); + assert_eq!(fill(&s, purple).to_hex(), "#ffff00"); + assert_eq!(fill(&s, blue).to_hex(), "#ffff00"); + // A row's first colour stays, however small its weight. + let mut s = session(); + prominent_art(&mut s); + let r = run(&mut s, "recolor.weigh", json!({"map": map, "weights": [0, 1]})); + assert_eq!(r["map"][0]["from"], json!([RED])); + assert_eq!(r["shares"], json!([50.0, 50.0])); + // Excluded rows keep their colours and take none; their weights are ignored. + let three = + json!([{"from": [RED, PURPLE], "to": "#000000"}, {"from": [BLUE], "to": "#000000", "exclude": true}, {"from": ["#00ff00"], "to": "#000000"}]); + let r = run(&mut s, "recolor.weigh", json!({"map": three, "weights": [1, 50, 1]})); + assert_eq!(r["map"][1]["from"], json!([BLUE])); + assert_eq!(r["map"][2]["from"], json!(["#00ff00", PURPLE])); + // A row without a new colour recolors nothing, so it neither gives nor takes colours. + let keep = json!([{"from": [RED, PURPLE], "to": "#000000"}, {"from": [BLUE]}, {"from": ["#00ff00"], "to": "#000000"}]); + let r = run(&mut s, "recolor.weigh", json!({"map": keep, "weights": [0, 1, 1]})); + assert_eq!(r["map"][1]["from"], json!([BLUE])); + assert_eq!(r["map"][2]["from"], json!(["#00ff00", PURPLE])); +} + +#[test] +fn weigh_moves_the_tints_of_a_global_swatch_together() { + let mut s = session(); + run(&mut s, "swatch.new", json!({"name": "Brand", "color": {"c": 0, "m": 80, "y": 90, "k": 0}, "global": true})); + rect(&mut s, json!("#ff0000")); + let full = rect(&mut s, json!("#00ff00")); + let tint = rect(&mut s, json!("#00ff00")); + rect(&mut s, json!("#0000ff")); + run(&mut s, "paint.setFill", json!({"ids": [full.0], "swatch": "Brand"})); + run(&mut s, "paint.setFill", json!({"ids": [tint.0], "swatch": "Brand", "tint": 50})); + select_all(&mut s); + let brand: Vec = run(&mut s, "recolor.colors", json!({}))["colors"] + .as_array() + .unwrap() + .iter() + .filter(|c| c["swatch"] == "Brand") + .map(|c| c["key"].clone()) + .collect(); + assert_eq!(brand.len(), 2); + let map = json!([{"from": [RED, brand[0], brand[1]], "to": "#000000"}, {"from": [BLUE], "to": "#ffffff"}]); + // Even weights would take one tint alone, which would split the swatch: nothing moves. + let r = run(&mut s, "recolor.weigh", json!({"map": map, "weights": [1, 1]})); + assert_eq!(r["map"][1]["from"], json!([BLUE])); + // The swatch's two colours move to the blue row as one. + let r = run(&mut s, "recolor.weigh", json!({"map": map, "weights": [1, 3]})); + assert_eq!(r["map"][1]["from"], json!([BLUE, brand[0], brand[1]])); + assert_eq!(r["shares"], json!([25.0, 75.0])); + // Colours that stay keep their order, a swatch's colours with another between them included. + let split = json!([{"from": [brand[0], RED, brand[1]], "to": "#000000"}, {"from": [BLUE], "to": "#ffffff"}]); + let r = run(&mut s, "recolor.weigh", json!({"map": split, "weights": [3, 1]})); + assert_eq!(r["map"][0]["from"], json!([brand[0], RED, brand[1]])); +} + +#[test] +fn weigh_refuses_bad_weights() { + let mut s = session(); + prominent_art(&mut s); + let map = json!([{"from": [RED]}, {"from": [BLUE], "exclude": true}]); + for (p, msg) in [ + (json!({"map": map, "weights": [1]}), "`weights` must be a list of 2 numbers, one per row"), + (json!({"map": map, "weights": "1, 1"}), "`weights` must be a list of 2 numbers"), + (json!({"map": map, "weights": [1, -1]}), "each weight must be a number of 0 or more (got -1)"), + (json!({"map": map, "weights": [1, "2"]}), "each weight must be a number of 0 or more"), + (json!({"map": map, "weights": [0, 5]}), "at least one row that isn't excluded needs a weight above 0"), + (json!({"weights": [1, 1]}), "missing `map`"), + (json!({"map": [{"to": "#ff0000"}]}), "each row needs `from`"), + ] { + let e = s.execute("recolor.weigh", &p).unwrap_err().to_string(); + assert!(e.contains(msg), "{p}: {e}"); + } +} diff --git a/crates/ui-egui/src/dialogs/recolor.rs b/crates/ui-egui/src/dialogs/recolor.rs index 8c68237b0c..495c38f8d9 100644 --- a/crates/ui-egui/src/dialogs/recolor.rs +++ b/crates/ui-egui/src/dialogs/recolor.rs @@ -19,7 +19,13 @@ //! show the new colors on the smooth wheel, on a segmented wheel (solid patches of 24 hues by 5 //! saturations; its markers move as freely) or as color bars (one per new color, in row order: click //! one to select it for the sliders, drag one to move its row, which changes no color); the dialog -//! opens on the smooth wheel. The preset menu sets up a 1, 2 or 3 +//! opens on the smooth wheel. With art, the Prominent Colors bar under the wheel shows the new +//! colors, each part as wide as its row's share of the art's uses, the largest first; dragging the +//! edge between two parts moves width between them, and when the drag ends the rows' current +//! colors move toward those widths (`recolor.weigh`), and the art previews them. The New Color +//! Group menu beside the link button saves the new colors (Save All Colors, in row order) or the +//! bar's colors (Save Prominent Colors, in its order) as a color group right away, a step of its +//! own. The preset menu sets up a 1, 2 or 3 //! colour job or a library. Opened on a colour group (Edit or Apply Color Group), OK also rewrites //! the group with the new colours (renamed by `groupName`); without art only the group changes. //! Opened on colours alone (the Color Guide's Edit or Apply Colors without art), OK saves the new @@ -41,8 +47,9 @@ //! (`{saturation, brightness, temperature, luminosity}` in percent, sent as `recolor.apply`'s //! `globalAdjust` in every slider mode), `display` (the Edit tab's `smooth`, `segmented` or `bars`; //! unset: `smooth`), `themePicker` (the Color Theme Picker is on), `themePicks` (its picks, as -//! `recolor.pickTheme`'s `picks`; a new list recolors the rows). Changing `colors`, a preserve -//! flag or `limitTo` reduces the rows again. +//! `recolor.pickTheme`'s `picks`; a new list recolors the rows), `weights` (one per row, as +//! `recolor.weigh`'s; a new list moves the rows' current colors toward them). Changing `colors`, a +//! preserve flag or `limitTo` reduces the rows again (and drops `weights`). use egui::{Rect, Sense, Ui, pos2, vec2}; use serde_json::{Value, json}; @@ -151,7 +158,7 @@ pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result { "preserveWhite": kept("preserveWhite", true), "preserveBlack": kept("preserveBlack", true), "preserveGrays": kept("preserveGrays", false), "limitTo": limit, "group": group, "groupName": if new_group { Some(NEW_GROUP) } else { group }, "tab": "assign", "rule": Harmony::Complementary.id(), "linked": true, - "preview": true, THEME_PICKER: false, THEME_PICKS: [], "__art": art, "__assign": assign, "__newGroup": new_group, + "preview": true, THEME_PICKER: false, THEME_PICKS: [], WEIGHTS: null, "__art": art, "__assign": assign, "__newGroup": new_group, }); let mut d = Dialog::new(KIND, fields); // Without art the rows are there from the start: limit them now (art's rows snap as they reduce). @@ -214,6 +221,182 @@ fn sync(app: &mut VectorcraftApp, d: &mut Dialog) { d.fields.insert(DONE.into(), sig); // New rows take the colors picked with the Color Theme Picker again. d.fields.remove(THEME_DONE); + // Weights given for the old rows don't apply to the new ones. + d.fields.remove(WEIGHTS); + d.fields.remove(WEIGHED); +} + +/// The fields of Prominent Colors: the weights the rows are to take (`recolor.weigh`'s `weights`, +/// one per row; a new list moves the rows' current colors), the weights they last took, each row's +/// share of the art's uses (`recolor.weigh`'s `shares`) and the rows' current colors those were +/// read for; a drag of a part's edge (each part's share of the bar while it lasts); and a request +/// from the New Color Group menu (`all` or `prominent`). +const WEIGHTS: &str = "weights"; +const WEIGHED: &str = "__weighed"; +const SHARES: &str = "__shares"; +const SHARES_OF: &str = "__sharesOf"; +const BAR_DRAG: &str = "__barDrag"; +const SAVE_GROUP: &str = "__saveGroup"; +/// Height of the Prominent Colors bar, and the smallest share of it a drag leaves a part. +const PROMINENT_H: f32 = 18.0; +const MIN_PART: f64 = 0.01; +/// The ids of the bar's part `n` and of the edge after part `k`, with `n` or `k`. +const PROMINENT_PART: &str = "recolor-prominent-part"; +const PROMINENT_EDGE: &str = "recolor-prominent-edge"; + +/// Cancel the preview, so commands read the art's own colors; it runs again afterwards. +fn cancel_preview(app: &mut VectorcraftApp, d: &mut Dialog) { + if d.fields.remove(super::form::PREVIEWED).is_some() { + let _ = app.session.cancel_interaction(); + } +} + +/// With art: when `weights` changed, move the rows' current colors toward them +/// (`recolor.weigh`); when the rows' current colors changed, read each row's share of the art's +/// uses again. +fn sync_weights(app: &mut VectorcraftApp, d: &mut Dialog) { + if !d.bool("__art") { + return; + } + if let Some(w) = d.fields.get(WEIGHTS).filter(|w| !w.is_null()).cloned() + && d.fields.get(WEIGHED) != Some(&w) + { + d.fields.insert(WEIGHED.into(), w.clone()); + cancel_preview(app, d); + match app.session.execute("recolor.weigh", &json!({"map": rows(d), "weights": w})) { + Ok(r) => { + if let Some(m) = r["map"].as_array() { + d.fields.insert("rows".into(), Value::Array(m.clone())); + } + } + Err(e) => app.status(e.to_string()), + } + } + let rs = rows(d); + let of = Value::Array(rs.iter().map(|r| json!([r["from"], excluded(r)])).collect()); + if d.fields.get(SHARES_OF) == Some(&of) { + return; + } + cancel_preview(app, d); + let shares = match app.session.execute("recolor.weigh", &json!({"map": rs})) { + Ok(r) => r["shares"].clone(), + Err(e) => { + app.status(e.to_string()); + json!([]) + } + }; + d.fields.insert(SHARES.into(), shares); + d.fields.insert(SHARES_OF.into(), of); +} + +/// The parts of the Prominent Colors bar: each row that isn't excluded and has a share of the +/// art's uses, with that share in percent, the largest share first (on a tie, the first row). +fn prominent_parts(d: &Dialog, rows: &[Value]) -> Vec<(usize, f64)> { + let shares = d.fields.get(SHARES).and_then(Value::as_array).cloned().unwrap_or_default(); + let mut parts: Vec<(usize, f64)> = shares + .iter() + .enumerate() + .filter_map(|(i, s)| Some((i, s.as_f64().filter(|s| *s > 0.0)?))) + .filter(|(i, _)| rows.get(*i).is_some_and(|r| !excluded(r))) + .collect(); + parts.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0))); + parts +} + +/// The Prominent Colors bar: a part per row of [`prominent_parts`] with the row's new color, as wide +/// as its share of the art's uses. A drag of the edge between two parts moves width from one to +/// the other (each keeps at least [`MIN_PART`] of the bar); when it ends, the parts' widths become +/// the rows' `weights`. +fn prominent_bar(ui: &mut Ui, d: &mut Dialog, rows: &[Value]) { + let t = Tokens::get(ui.ctx()); + let parts = prominent_parts(d, rows); + let (bar, resp) = ui.allocate_exact_size(vec2(ui.available_width(), PROMINENT_H), Sense::hover()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Image, ui.is_enabled(), tl!("Prominent Colors"))); + if parts.is_empty() { + ui.painter().rect_stroke(bar, 1.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + return; + } + let total: f64 = parts.iter().map(|p| p.1).sum(); + let mut widths: Vec = match d.fields.get(BAR_DRAG).and_then(Value::as_array) { + Some(w) if w.len() == parts.len() => w.iter().map(|x| x.as_f64().unwrap_or(0.0)).collect(), + _ => parts.iter().map(|p| p.1 / total).collect(), + }; + let w = f64::from(bar.width()); + let edge = |k: usize, widths: &[f64]| bar.left() + (widths[..=k].iter().sum::() * w) as f32; + let mut left = bar.left(); + for (n, (i, _)) in parts.iter().enumerate() { + let right = if n + 1 == parts.len() { bar.right() } else { edge(n, &widths) }; + let r = Rect::from_min_max(pos2(left, bar.top()), pos2(right, bar.bottom())); + let c = to_color(&rows[*i]).or_else(|| from_colors(&rows[*i]).first().copied()).unwrap_or_default(); + chip(ui, r, c, false); + let share = (widths[n] * 100.0).round(); + ui.interact(r, egui::Id::unique((PROMINENT_PART, n)), Sense::hover()).on_hover_text(format!("{} {}: {share}%", tl!("Color"), i + 1)); + left = right; + } + let mut ended = false; + for k in 0..parts.len() - 1 { + let x = edge(k, &widths); + let handle = Rect::from_center_size(pos2(x, bar.center().y), vec2(8.0, PROMINENT_H)); + let h = ui.interact(handle, egui::Id::unique((PROMINENT_EDGE, k)), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal); + if h.dragged() && w > 0.0 { + // Each part keeps MIN_PART, or half of the two parts' width when they are narrower. + let pair = widths[k] + widths[k + 1]; + let least = MIN_PART.min(pair / 2.0); + let moved = (widths[k] + f64::from(h.drag_delta().x) / w).clamp(least, pair - least); + widths[k] = moved; + widths[k + 1] = pair - moved; + d.fields.insert(BAR_DRAG.into(), json!(widths)); + } + if h.hovered() || h.dragged() { + ui.painter().vline(x, bar.y_range(), egui::Stroke::new(2.0, t.accent)); + } + ended |= h.drag_stopped(); + } + if ended && d.fields.remove(BAR_DRAG).is_some() { + let mut weights = vec![0.0; rows.len()]; + for ((i, _), x) in parts.iter().zip(&widths) { + weights[*i] = (x * 10_000.0).round() / 100.0; + } + d.fields.insert(WEIGHTS.into(), json!(weights)); + } +} + +/// The colors a new color group takes from rows `rs`: each row's new color with the Global Adjust, +/// snapped to Limit to Library, as `recolor.apply` gives them to a group; a row without a new color +/// gives its first current color, which the art keeps. +fn group_colors<'a>(app: &VectorcraftApp, d: &Dialog, rs: impl Iterator) -> Vec { + let adjust = global_adjust(adjust_values(d)); + let limit = d.str("limitTo"); + let palette = Some(limit).filter(|l| !l.is_empty()).and_then(|l| swatchlib::limit_palette(&app.session, &l)); + rs.filter_map(|r| match to_color(r) { + None => from_colors(r).first().map(key), + Some(_) if adjust.is_identity() => Some(r["to"].clone()), + Some(c) => { + let c = adjust.apply(c); + Some(key(&palette.as_ref().map_or(c, |p| p.nearest(c)))) + } + }) + .collect() +} + +/// The New Color Group menu's request: save the new colors of the rows that aren't excluded (Save +/// All Colors, in row order; a row without one gives its first current color) or of the Prominent Colors bar (Save Prominent Colors, in its order) +/// as a new color group, a step of its own (`swatch.newGroup`). +fn save_group(app: &mut VectorcraftApp, d: &mut Dialog, which: &str) { + let rs = rows(d); + let order: Vec = match which { + "prominent" => prominent_parts(d, &rs).into_iter().map(|p| p.0).collect(), + _ => (0..rs.len()).filter(|&i| !excluded(&rs[i])).collect(), + }; + let colors = group_colors(app, d, order.iter().filter_map(|&i| rs.get(i))); + if colors.is_empty() { + return; + } + // The group is added to the document itself, not to the preview, which runs again afterwards. + cancel_preview(app, d); + if let Err(e) = app.run("swatch.newGroup", json!({"colors": colors, "keepModel": true})) { + app.status(e); + } } /// The fields of the Color Theme Picker: whether a click or drag on the canvas picks colors, and @@ -401,6 +584,7 @@ fn apply_params(d: &Dialog) -> Value { fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool { sync(app, d); sync_theme(app, d); + sync_weights(app, d); let t = Tokens::get(ui.ctx()); let picking = d.bool(THEME_PICKER); ui.horizontal(|ui| { @@ -444,6 +628,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, d: &mut Dialog) -> bool { } d.fields.insert("rows".into(), Value::Array(rs)); } + sync_weights(app, d); + if let Some(which) = d.fields.remove(SAVE_GROUP) { + save_group(app, d, which.as_str().unwrap_or_default()); + } if d.fields.get("group").is_some_and(Value::is_string) || d.bool("__newGroup") { ui.add_space(6.0); ui.horizontal(|ui| { @@ -479,25 +667,9 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { let mut d = d.clone(); sync(app, &mut d); sync_theme(app, &mut d); + sync_weights(app, &mut d); if d.bool("__newGroup") { - // The new colors with the Global Adjust, snapped to Limit to Library, as `recolor.apply` - // gives them to a group. - let adjust = global_adjust(adjust_values(&d)); - let limit = d.str("limitTo"); - let palette = Some(limit).filter(|l| !l.is_empty()).and_then(|l| swatchlib::limit_palette(&app.session, &l)); - let colors: Vec = rows(&d) - .into_iter() - .filter(|r| !excluded(r)) - .map(|r| { - to_color(&r).filter(|_| !adjust.is_identity()).map_or_else( - || r["to"].clone(), - |c| { - let c = adjust.apply(c); - key(&palette.as_ref().map_or(c, |p| p.nearest(c))) - }, - ) - }) - .collect(); + let colors = group_colors(app, &d, rows(&d).iter().filter(|r| !excluded(r))); return form::commit_preview(app, "swatch.newGroup", json!({"name": d.str("groupName"), "colors": colors, "keepModel": true})); } let r = form::commit_preview(app, CMD, apply_params(&d))?; @@ -1091,6 +1263,18 @@ fn edit_tab(app: &VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut [Value if widgets::icon_button(ui, if linked { "link" } else { "link-2-off" }, tip, linked, 24.0).clicked() { d.fields.insert("linked".into(), json!(!linked)); } + // New Color Group: save the wheel's colors or the Prominent Colors as a color group. + let button = widgets::icon_button(ui, "dc-folder", tl!("New Color Group"), false, 24.0); + let prominent = d.bool("__art") && !prominent_parts(d, rows).is_empty(); + let saved = egui::Popup::menu(&button).show(|ui| { + if widgets::menu_item_name(ui, tl!("Save All Colors"), rows.iter().any(|r| !excluded(r)), false) { + return Some("all"); + } + widgets::menu_item_name(ui, tl!("Save Prominent Colors"), prominent, false).then_some("prominent") + }); + if let Some(which) = saved.and_then(|r| r.inner) { + d.fields.insert(SAVE_GROUP.into(), json!(which)); + } ui.add_space(8.0); // The mode of the color sliders under the wheel: the selected new color in a color // mode, or Global Adjust for all of them. @@ -1122,6 +1306,11 @@ fn edit_tab(app: &VectorcraftApp, ui: &mut Ui, d: &mut Dialog, rows: &mut [Value _ => w = widgets::harmony_wheel(ui, "recolor-wheel", WHEEL, &colors, marked), }); }); + // Prominent Colors: the art's new colors, each as wide as its share of the art. + if d.bool("__art") { + ui.add_space(6.0); + prominent_bar(ui, d, rows); + } if let Some(k) = w.pressed { d.fields.insert("__sel".into(), json!([active[k]])); } @@ -1625,6 +1814,130 @@ mod tests { assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); } + const RED: &str = "rgb 255 0 0"; + const PURPLE: &str = "rgb 128 0 204"; + const BLUE: &str = "rgb 0 0 255"; + + /// Two red squares, a purple one and a blue one, selected; Recolor Artwork open on them on the + /// Edit tab with the Exact method and the rows set: the blue to yellow, then the red and purple + /// to green (the purple's square id last). One frame drawn. + fn prominent_setup(app: &mut VectorcraftApp, ctx: &egui::Context) -> u64 { + app.run("file.new", json!({"width": 300, "height": 100})).unwrap(); + let ids = [square(app, 10.0, "#ff0000"), square(app, 40.0, "#ff0000"), square(app, 70.0, "#8000cc"), square(app, 100.0, "#0000ff")]; + app.run("select.set", json!({"ids": ids})).unwrap(); + app.run("ui.recolorDialog", json!({})).unwrap(); + let d = app.ui.dialog.as_mut().unwrap(); + d.fields.insert("method".into(), json!(Method::Exact.id())); + d.fields.insert("tab".into(), json!("edit")); + crate::theme::install_fonts(ctx); + frame(app, ctx, 0.0, vec![], 0); + let rows = json!([{"from": [BLUE], "to": "rgb 255 255 0"}, {"from": [RED, PURPLE], "to": "rgb 0 255 0"}]); + app.ui.dialog.as_mut().unwrap().fields.insert("rows".into(), rows); + frame(app, ctx, 0.01, vec![], 0); + ids[2] + } + + fn froms(app: &VectorcraftApp) -> Vec { + super::rows(app.ui.dialog.as_ref().unwrap()).iter().map(|r| r["from"].clone()).collect() + } + + /// The Prominent Colors bar holds each row's share of the art's uses, the largest first; new + /// `weights` move the rows' current colors toward them, the art previews the result, and only + /// OK commits it, as one undo step. + #[test] + fn weights_move_the_rows_colors_and_the_art_previews_them() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let ctx = egui::Context::default(); + let purple = prominent_setup(&mut app, &ctx); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(d.fields[SHARES], json!([25.0, 75.0])); + assert_eq!(prominent_parts(d, &super::rows(d)), [(1, 75.0), (0, 25.0)], "the largest share first"); + assert_eq!(fill_hex(&app, purple), "#00ff00", "the art previews the rows"); + let undo = app.session.doc().unwrap().history.undo.len(); + app.ui.dialog.as_mut().unwrap().fields.insert(WEIGHTS.into(), json!([50, 50])); + frame(&mut app, &ctx, 0.1, vec![], 0); + assert_eq!(froms(&app), [json!([BLUE, PURPLE]), json!([RED])]); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields[SHARES], json!([50.0, 50.0])); + assert_eq!(fill_hex(&app, purple), "#ffff00", "the moved color previews its new row's color"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo, "nothing is committed before OK"); + // A new count of Colors reduces the rows again and drops the weights. + app.ui.dialog.as_mut().unwrap().fields.insert("colors".into(), json!(2)); + frame(&mut app, &ctx, 0.2, vec![], 0); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields.get(WEIGHTS), None); + app.ui + .dialog + .as_mut() + .unwrap() + .fields + .insert("rows".into(), json!([{"from": [BLUE, PURPLE], "to": "rgb 255 255 0"}, {"from": [RED], "to": "rgb 0 255 0"}])); + crate::dialogs::confirm(&mut app).unwrap(); + assert_eq!(fill_hex(&app, purple), "#ffff00"); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step"); + } + + /// Dragging the edge between two parts of the bar to the left gives the right-hand part more + /// weight; when the drag ends, the rows' current colors move. + #[test] + fn dragging_a_prominent_edge_moves_colors_between_rows() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let ctx = egui::Context::default(); + let purple = prominent_setup(&mut app, &ctx); + let edge = |ctx: &egui::Context| ctx.read_response(egui::Id::unique((PROMINENT_EDGE, 0usize))).map(|r| r.rect); + let mut last = None; + for n in 0..10 { + frame(&mut app, &ctx, 0.02 + f64::from(n) * 0.001, vec![], 0); + let r = edge(&ctx); + if r.is_some() && r == last { + break; + } + last = r; + } + let at = edge(&ctx).expect("the edge between the two parts").center(); + let bar = ctx.read_response(egui::Id::unique((PROMINENT_PART, 0usize))).unwrap().rect; + // From 75% to 50%: a quarter of the bar to the left. + let to = at - vec2((bar.width() / 0.75) * 0.25, 0.0); + let button = + |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + frame(&mut app, &ctx, 0.1, vec![egui::Event::PointerMoved(at), button(at, true)], 0); + for k in 1..=5 { + frame(&mut app, &ctx, 0.1 + f64::from(k) * 0.02, vec![egui::Event::PointerMoved(at + (to - at) * (k as f32 / 5.0))], 0); + } + assert!(app.ui.dialog.as_ref().unwrap().fields.contains_key(BAR_DRAG), "the drag is under way"); + assert_eq!(froms(&app)[0], json!([BLUE]), "the rows wait for the drag to end"); + frame(&mut app, &ctx, 0.3, vec![button(to, false)], 0); + frame(&mut app, &ctx, 0.32, vec![], 0); + let d = app.ui.dialog.as_ref().unwrap(); + let w = d.fields[WEIGHTS].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect::>(); + assert!((w[0] - 50.0).abs() < 2.0 && (w[1] - 50.0).abs() < 2.0, "{w:?}"); + assert_eq!(froms(&app), [json!([BLUE, PURPLE]), json!([RED])]); + assert_eq!(fill_hex(&app, purple), "#ffff00"); + } + + /// The New Color Group menu's Save All Colors saves the new colors in row order and Save + /// Prominent Colors the bar's colors, the largest share first, each as its own step; the + /// preview of the recolor runs on. + #[test] + fn save_all_and_prominent_colors_make_color_groups() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let ctx = egui::Context::default(); + let purple = prominent_setup(&mut app, &ctx); + let groups = |app: &VectorcraftApp| app.session.doc().unwrap().doc.swatch_groups.len(); + let last = |app: &VectorcraftApp| -> Vec { + let doc = &app.session.doc().unwrap().doc; + doc.swatch_groups.last().unwrap().swatches.iter().map(|w| w.paint.color().unwrap().to_hex()).collect() + }; + let (n, undo) = (groups(&app), app.session.doc().unwrap().history.undo.len()); + for (which, colors, t) in [("all", ["#ffff00", "#00ff00"], 0.1), ("prominent", ["#00ff00", "#ffff00"], 0.2)] { + app.ui.dialog.as_mut().unwrap().fields.insert(SAVE_GROUP.into(), json!(which)); + frame(&mut app, &ctx, t, vec![], 0); + frame(&mut app, &ctx, t + 0.01, vec![], 0); + assert_eq!(last(&app), colors, "{which}"); + assert_eq!(fill_hex(&app, purple), "#00ff00", "{which}: the art still previews the rows"); + } + assert_eq!(groups(&app), n + 2); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 2, "a step for each group"); + } + /// One frame with `modifiers` held. fn frame_with(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, events: Vec, modifiers: egui::Modifiers) { let events = std::iter::once(egui::Event::ModifiersChanged(modifiers)).chain(events).collect(); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 8edde76eb3..d3f8fe92e6 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2825,6 +2825,9 @@ digits Malayalam Малаялам Reset the rows to the artwork's colors Нулиране на редовете към цветовете на илюстрацията Color Theme Picker Избор на цветова тема Click or drag across the art to pick its colors; Shift adds more Щракнете или плъзнете през илюстрацията, за да вземете цветовете ѝ; Shift добавя още + Prominent Colors Преобладаващи цветове + Save All Colors Запази всички цветове + Save Prominent Colors Запази преобладаващите цветове Reset to Default Връщане към стандартните Reset to White, Black Нулиране към бяло, черно Reset with Mesh Нулиране с мрежа diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index c9c207eaa2..bf1a65904c 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -2849,6 +2849,9 @@ digits Malayalam Malajalam Reset the rows to the artwork's colors Vrati redove na boje crteža Color Theme Picker Birač teme boja Click or drag across the art to pick its colors; Shift adds more Kliknite ili prevucite preko crteža da uzmete njegove boje; Shift dodaje još + Prominent Colors Istaknute boje + Save All Colors Sačuvaj sve boje + Save Prominent Colors Sačuvaj istaknute boje Separate colors into rows Razdvoji boje u redove Unlink harmony colors Prekini vezu boja harmonije 1 Color Job Posao s 1 bojom diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 0399e6c04a..3e590eadb1 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -3607,6 +3607,9 @@ digits Malayalam Malaiàlam Reset the rows to the artwork's colors Restableix les files als colors de l’obra Color Theme Picker Selector de tema de colors Click or drag across the art to pick its colors; Shift adds more Feu clic o arrossegueu sobre l’obra per agafar-ne els colors; Maj n’afegeix més + Prominent Colors Colors predominants + Save All Colors Desa tots els colors + Save Prominent Colors Desa els colors predominants Reset to Default Restableix el valor predeterminat Reset to default Restableix el valor predeterminat Reset to Defaults Restableix els valors predeterminats diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index e62070329c..751d06c66a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2885,6 +2885,9 @@ digits Malayalam Malajálamské Reset the rows to the artwork's colors Obnovit řádky na barvy kresby Color Theme Picker Výběr barevného motivu Click or drag across the art to pick its colors; Shift adds more Klepnutím nebo tažením přes kresbu převezmete její barvy; Shift přidá další + Prominent Colors Převládající barvy + Save All Colors Uložit všechny barvy + Save Prominent Colors Uložit převládající barvy Separate colors into rows Rozdělit barvy do řádků Unlink harmony colors Zrušit propojení barev harmonie 1 Color Job Zakázka s 1 barvou diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 47a78ef612..df2fbc011f 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -2669,6 +2669,9 @@ digits Malayalam Malayalam Reset the rows to the artwork's colors Nulstil rækkerne til grafikkens farver Color Theme Picker Farvetemavælger Click or drag across the art to pick its colors; Shift adds more Klik eller træk hen over grafikken for at hente dens farver; Skift tilføjer flere + Prominent Colors Fremtrædende farver + Save All Colors Gem alle farver + Save Prominent Colors Gem fremtrædende farver Separate colors into rows Opdel farver i rækker Unlink harmony colors Fjern sammenkædning af harmonifarver 1 Color Job 1-farvejob diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index af4489a637..ed11eca5cf 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2871,6 +2871,9 @@ digits Malayalam Malayalam Reset the rows to the artwork's colors Zeilen auf die Farben der Grafik zurücksetzen Color Theme Picker Farbthema-Auswahl Click or drag across the art to pick its colors; Shift adds more Klicken oder ziehen Sie über die Grafik, um ihre Farben aufzunehmen; Umschalt fügt weitere hinzu + Prominent Colors Hervorstechende Farben + Save All Colors Alle Farben speichern + Save Prominent Colors Hervorstechende Farben speichern Reset to Default Auf Standard zurücksetzen Reset to White, Black Auf Weiß, Schwarz zurücksetzen Reset with Mesh Mit Gitter zurücksetzen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 6ad6ff11c0..398d51f9db 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3589,6 +3589,9 @@ digits Malayalam Μαλαγιάλαμ Reset the rows to the artwork's colors Επαναφορά γραμμών στα χρώματα του σχεδίου Color Theme Picker Επιλογέας χρωματικού θέματος Click or drag across the art to pick its colors; Shift adds more Κάντε κλικ ή σύρετε πάνω στο σχέδιο για να πάρετε τα χρώματά του· το Shift προσθέτει κι άλλα + Prominent Colors Κυρίαρχα χρώματα + Save All Colors Αποθήκευση όλων των χρωμάτων + Save Prominent Colors Αποθήκευση κυρίαρχων χρωμάτων Reset to White, Black Επαναφορά σε λευκό και μαύρο Reset with Mesh Επαναφορά με πλέγμα Reset with Mesh… Επαναφορά με πλέγμα… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 41f92e14f7..380124c709 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2904,6 +2904,9 @@ digits Malayalam Malayálam Reset the rows to the artwork's colors Restablecer las filas a los colores de la ilustración Color Theme Picker Selector de tema de color Click or drag across the art to pick its colors; Shift adds more Haga clic o arrastre sobre la ilustración para tomar sus colores; Mayús añade más + Prominent Colors Colores destacados + Save All Colors Guardar todos los colores + Save Prominent Colors Guardar colores destacados Reset to Default Restablecer valores predeterminados Reset to White, Black Restablecer a blanco, negro Reset with Mesh Restablecer con malla diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index fd38382f63..8211efc0cd 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2829,6 +2829,9 @@ digits Malayalam Malajalam Reset the rows to the artwork's colors Palauta rivit kuvituksen väreihin Color Theme Picker Väriteeman valitsin Click or drag across the art to pick its colors; Shift adds more Napsauta tai vedä kuvituksen yli poimiaksesi sen värit; Vaihto lisää useampia + Prominent Colors Hallitsevat värit + Save All Colors Tallenna kaikki värit + Save Prominent Colors Tallenna hallitsevat värit Separate colors into rows Erottele värit riveille Unlink harmony colors Poista harmoniavärien linkitys 1 Color Job 1 värin työ diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index dcbe436fc3..dc7be09b13 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2896,6 +2896,9 @@ digits Malayalam Malayalam Reset the rows to the artwork's colors Réinitialiser les lignes avec les couleurs de l’illustration Color Theme Picker Sélecteur de thème de couleurs Click or drag across the art to pick its colors; Shift adds more Cliquez ou faites glisser sur l’illustration pour prélever ses couleurs ; Maj en ajoute d’autres + Prominent Colors Couleurs dominantes + Save All Colors Enregistrer toutes les couleurs + Save Prominent Colors Enregistrer les couleurs dominantes Reset to Default Rétablir les valeurs par défaut Reset to White, Black Réinitialiser à blanc, noir Reset with Mesh Réinitialiser avec un filet diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 9b7a15bbad..dd0d8137e7 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -3948,6 +3948,9 @@ antiAlias Sharp शार्प Reset the rows to the artwork's colors आर्टवर्क के रंग के लिए पंक्तियों को रीसेट करें Color Theme Picker रंग थीम पिकर Click or drag across the art to pick its colors; Shift adds more आर्टवर्क के रंग लेने के लिए उस पर क्लिक करें या खींचें; Shift और जोड़ता है + Prominent Colors प्रमुख रंग + Save All Colors सभी रंग सेव करें + Save Prominent Colors प्रमुख रंग सेव करें Reset to default डिफ़ॉल्ट पर रीसेट करें Reset to Default डिफ़ॉल्ट पर रीसेट करें Reset to Defaults डिफ़ॉल्ट पर रीसेट करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 6832a86707..7c1c82e97e 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -3454,6 +3454,9 @@ mark-style Japanese Japán Reset the rows to the artwork's colors Sorok visszaállítása a grafika színeire Color Theme Picker Színtéma-választó Click or drag across the art to pick its colors; Shift adds more Kattintson vagy húzzon a grafikán a színei felvételéhez; a Shift továbbiakat ad hozzá + Prominent Colors Domináns színek + Save All Colors Összes szín mentése + Save Prominent Colors Domináns színek mentése Reset to default Alapérték visszaállítása Reset to Default Alapérték visszaállítása Reset to Defaults Alapértékek visszaállítása diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 45e05b9622..758903eb08 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2669,6 +2669,9 @@ digits Malayalam Malayalam Reset the rows to the artwork's colors Setel ulang baris ke warna karya seni Color Theme Picker Pemilih tema warna Click or drag across the art to pick its colors; Shift adds more Klik atau seret melintasi karya seni untuk mengambil warnanya; Shift menambahkan lagi + Prominent Colors Warna menonjol + Save All Colors Simpan semua warna + Save Prominent Colors Simpan warna menonjol Separate colors into rows Pisahkan warna ke dalam baris Unlink harmony colors Lepaskan tautan warna harmoni 1 Color Job Pekerjaan 1 warna diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 57f141f880..579098b1de 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2947,6 +2947,9 @@ digits Malayalam Malayalam Reset the rows to the artwork's colors Reimposta le righe sui colori della grafica Color Theme Picker Selettore tema colore Click or drag across the art to pick its colors; Shift adds more Fai clic o trascina sulla grafica per prelevarne i colori; Maiusc ne aggiunge altri + Prominent Colors Colori prominenti + Save All Colors Salva tutti i colori + Save Prominent Colors Salva colori prominenti Reset to Default Reimposta valori predefiniti Reset to White, Black Reimposta su bianco, nero Reset with Mesh Reimposta con trama diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 69b55c9121..6abc349cb3 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2952,6 +2952,9 @@ mark-style Japanese 日本式 Reset the rows to the artwork's colors 行をアートワークのカラーにリセット Color Theme Picker カラーテーマピッカー Click or drag across the art to pick its colors; Shift adds more アートワークをクリックまたはドラッグしてカラーを取得します。Shift キーで追加します + Prominent Colors 主要カラー + Save All Colors すべてのカラーを保存 + Save Prominent Colors 主要カラーを保存 Reset to Default 初期設定に戻す Reset to White, Black 白、黒にリセット Reset {name} {name}をリセット diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 1cbb2d1bf1..8a1605eb96 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -2585,6 +2585,9 @@ digits Malayalam მალაიალამური Reset the rows to the artwork's colors სტრიქონებისთვის ნახატის ფერების დაბრუნება Color Theme Picker ფერთა თემის ამრჩევი Click or drag across the art to pick its colors; Shift adds more დააწკაპუნეთ ან გადაატარეთ ნახატზე მისი ფერების ასაღებად; Shift ამატებს კიდევ + Prominent Colors გამოკვეთილი ფერები + Save All Colors ყველა ფერის შენახვა + Save Prominent Colors გამოკვეთილი ფერების შენახვა Separate colors into rows ფერების სტრიქონებად დაყოფა Unlink harmony colors ჰარმონიის ფერების კავშირის გაწყვეტა 1 Color Job 1-ფერიანი ბეჭდვა diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 6135f8d5ec..b1359dba7a 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2941,6 +2941,9 @@ mark-style Japanese 일본식 Reset the rows to the artwork's colors 행을 아트웍 색상으로 재설정 Color Theme Picker 색상 테마 피커 Click or drag across the art to pick its colors; Shift adds more 아트웍을 클릭하거나 드래그하여 색상을 가져옵니다. Shift 키로 더 추가합니다 + Prominent Colors 주요 색상 + Save All Colors 모든 색상 저장 + Save Prominent Colors 주요 색상 저장 Reset to Default 기본값으로 재설정 Reset to White, Black 흰색, 검정으로 재설정 Reset {name} {name} 재설정 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 327aee902c..b4fe48049b 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2683,6 +2683,9 @@ digits Malayalam Malayalam Reset the rows to the artwork's colors De rijen herstellen naar de kleuren van de artwork Color Theme Picker Kleurthemakiezer Click or drag across the art to pick its colors; Shift adds more Klik of sleep over het artwork om de kleuren ervan op te nemen; Shift voegt er meer toe + Prominent Colors Prominente kleuren + Save All Colors Alle kleuren opslaan + Save Prominent Colors Prominente kleuren opslaan Separate colors into rows Kleuren over rijen verdelen Unlink harmony colors Harmoniekleuren ontkoppelen 1 Color Job Opdracht in 1 kleur diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index f277d51d65..cd618b1912 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2794,6 +2794,9 @@ digits Malayalam Malajalam Reset the rows to the artwork's colors Przywróć w wierszach kolory kompozycji Color Theme Picker Próbnik motywu kolorów Click or drag across the art to pick its colors; Shift adds more Kliknij lub przeciągnij po kompozycji, aby pobrać jej kolory; Shift dodaje kolejne + Prominent Colors Dominujące kolory + Save All Colors Zapisz wszystkie kolory + Save Prominent Colors Zapisz dominujące kolory Separate colors into rows Rozdziel kolory na wiersze Unlink harmony colors Odłącz kolory harmonii 1 Color Job Praca 1-kolorowa diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index afd10a5fbc..38eb7122f3 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2442,6 +2442,9 @@ digits Malayalam Malaiala Reset the rows to the artwork's colors Redefinir as linhas para as cores da arte Color Theme Picker Seletor de Tema de Cores Click or drag across the art to pick its colors; Shift adds more Clique ou arraste sobre a arte para pegar suas cores; Shift adiciona mais + Prominent Colors Cores Proeminentes + Save All Colors Salvar Todas as Cores + Save Prominent Colors Salvar Cores Proeminentes Reset to Default Redefinir para o Padrão Reset to White, Black Redefinir para Branco, Preto Reset with Mesh… Redefinir com Malha… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 2dca232079..4d069732ef 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2934,6 +2934,9 @@ digits Malayalam Малаялам Reset the rows to the artwork's colors Сбросить строки к цветам рисунка Color Theme Picker Выбор цветовой темы Click or drag across the art to pick its colors; Shift adds more Щёлкните или проведите по рисунку, чтобы взять его цвета; Shift добавляет ещё + Prominent Colors Преобладающие цвета + Save All Colors Сохранить все цвета + Save Prominent Colors Сохранить преобладающие цвета Reset to Default Сбросить к значениям по умолчанию Reset to White, Black Сбросить к белому, чёрному Reset with Mesh Сбросить с сеткой diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index e47c4e8662..6311bca512 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2840,6 +2840,9 @@ digits Malayalam Malayalam Reset the rows to the artwork's colors Återställ raderna till teckningens färger Color Theme Picker Färgtemaväljare Click or drag across the art to pick its colors; Shift adds more Klicka eller dra över teckningen för att hämta dess färger; Skift lägger till fler + Prominent Colors Framträdande färger + Save All Colors Spara alla färger + Save Prominent Colors Spara framträdande färger Reset to Default Återställ till standard Reset to White, Black Återställ till vitt, svart Reset with Mesh Återställ med nät diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 5983ec4bfe..310f7e8dae 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2708,6 +2708,9 @@ digits Malayalam Malayalam Reset the rows to the artwork's colors Satırları resmin renklerine göre sıfırlayın Color Theme Picker Renk Teması Seçici Click or drag across the art to pick its colors; Shift adds more Renklerini almak için resme tıklayın veya üzerinde sürükleyin; Shift daha fazlasını ekler + Prominent Colors Belirgin Renkler + Save All Colors Tüm Renkleri Kaydet + Save Prominent Colors Belirgin Renkleri Kaydet Reset to Default Varsayılana Sıfırla Reset to White, Black Beyaz, Siyah'a Sıfırla Reset with Mesh Mesh ile Sıfırla diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 4c9326af72..6590c4bf76 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2939,6 +2939,9 @@ digits Malayalam Малаялам Reset the rows to the artwork's colors Скинути рядки до кольорів малюнка Color Theme Picker Вибір колірної теми Click or drag across the art to pick its colors; Shift adds more Клацніть або проведіть по малюнку, щоб узяти його кольори; Shift додає ще + Prominent Colors Переважні кольори + Save All Colors Зберегти всі кольори + Save Prominent Colors Зберегти переважні кольори Reset to Default Скинути до значень за замовчуванням Reset to White, Black Скинути до білого, чорного Reset with Mesh Скинути із сіткою diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index fef0b8ff90..f485a03f51 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -3227,6 +3227,9 @@ mark-style Japanese Kiểu Nhật Reset the rows to the artwork's colors Đặt lại các hàng về màu của tác phẩm Color Theme Picker Bộ chọn bộ màu Click or drag across the art to pick its colors; Shift adds more Nhấp hoặc kéo qua tác phẩm để lấy màu của nó; Shift để thêm + Prominent Colors Màu nổi bật + Save All Colors Lưu tất cả màu + Save Prominent Colors Lưu màu nổi bật Reset to Default Đặt lại về mặc định Reset to default Đặt lại về mặc định Reset to White, Black Đặt lại về Trắng, Đen diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index a0d941ada8..45e1c6d6fd 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2804,6 +2804,9 @@ digits Malayalam 马拉雅拉姆数字 Reset the rows to the artwork's colors 将各行重置为图稿的颜色 Color Theme Picker 色彩主题拾取器 Click or drag across the art to pick its colors; Shift adds more 单击或拖过图稿以拾取其颜色;按住 Shift 可添加更多 + Prominent Colors 主要颜色 + Save All Colors 存储所有颜色 + Save Prominent Colors 存储主要颜色 Reset to Default 恢复默认 Reset to White, Black 重置为白、黑 Reset with Mesh 以网格重置 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b724e57351..c6b1b68402 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -3999,6 +3999,9 @@ digits Malayalam 馬來亞拉姆數字 Reset the rows to the artwork's colors 將各列重設為圖稿的顏色 Color Theme Picker 色彩主題選取器 Click or drag across the art to pick its colors; Shift adds more 按一下或拖過圖稿以選取其顏色;按住 Shift 可加入更多 + Prominent Colors 主要顏色 + Save All Colors 儲存所有顏色 + Save Prominent Colors 儲存主要顏色 Reset to Default 重設為預設值 Reset to White, Black 重設為白色、黑色 Reset with Mesh 以網格重設 diff --git a/docs/mcp.md b/docs/mcp.md index 20e37222c6..1f370735c6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3719,6 +3719,30 @@ excluded take the palette as their new colors in row order, the colors repeating {"name":"run_command","arguments":{"command":"recolor.pickTheme","params":{"picks":[{"at":[120,80]},{"rect":[200,40,320,140]}],"colors":4}}} ``` +`recolor.weigh {map, weights?, ids?}` is the dialog's Prominent Colors bar. It takes rows as `recolor.apply` does and +returns them as `map`, each with `from` as a list, and `shares`: for each row, its percent of the uses (the `count` of +`recolor.colors`) of the current colors of the rows that are not excluded, and 0 for an excluded row. A use counts for +the row that `recolor.apply` recolors it with (rows with a new color first, an exact color before a hex color, then +the first row that lists it), and otherwise for the first other row that lists it. A current color that no art uses +counts nothing. Without `weights` the +rows come back unchanged. `weights` holds one number of 0 or more per row; an excluded row's weight is ignored, and +at least one other row needs a weight above 0. As VectorCraft's own rule, current colors then move one at a time +between the rows that are not excluded and have a new color (`to`), as long as a move brings each row's share of the uses nearer its share of the +weights. Of those moves, it takes first the color nearest in Lab to the first color of the row it goes to, compared +with the row it leaves. A row keeps its first color, and the tints of one global swatch in a row move together. The +colors that can move are compared with the rows at most 50,000,000 times in all; when those colors times the rows +that are not excluded pass that, no color moves. Colors that stay in a row keep their order, and the colors that come +to it follow them. The command changes nothing; `recolor.apply` with the returned `map` recolors the art. In the Recolor Artwork dialog the +bar sits under the Edit tab's wheel: each row with a share is a part as wide as that share, in the row's new color, +the largest first. Dragging the edge between two parts moves width between them, and when the drag ends the widths +become the `weights` field, which moves the rows' current colors and previews the result. The New Color Group menu +beside the link button runs `swatch.newGroup` right away, as its own undo step: Save All Colors saves the new colors +of the rows that are not excluded, in row order, and Save Prominent Colors saves the bar's colors in the bar's order. + +```json +{"name":"run_command","arguments":{"command":"recolor.weigh","params":{"map":[{"from":["rgb 255 0 0","rgb 128 0 204"],"to":"#00ff00"},{"from":["rgb 0 0 255"],"to":"#ffff00"}],"weights":[1,1]}}} +``` + The `recolor.*` commands refuse parameters of the wrong form. A `limitTo` or `method` that is not a string gets the error for an unknown library or method. Other wrong-form parameters get an error that names them. `includeImages`, `includePatterns`, a row's `exclude`, `order`, `saturationBrightness` and the `white`, `black` and diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5f10ea4841..8485de41ac 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -486,6 +486,8 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po - [x] Recolor Artwork › Color Theme Picker: with the button beside the tabs on, a click on the canvas takes the color palette of an object or image, a drag takes the palette of the art inside its marquee, and Shift adds more picks; the rows that aren't excluded take the palette as their new colors and the art previews them ([Recolor artwork](https://helpx.adobe.com/illustrator/desktop/manage-colors/modify-colors/recolor-artwork.html)). The number and order of the picked colors are VectorCraft's own rule (`recolor.pickTheme`). Tests: `theme_picks_give_the_rows_the_palette_of_the_art_picked` and `the_theme_picker_takes_clicks_and_shift_drags_on_the_canvas` in `crates/ui-egui/src/dialogs/recolor.rs`, `theme_picker_*` in `crates/engine/src/tests/recolor.rs`. +- [x] Recolor Artwork › Prominent Colors: with art, a bar under the Edit tab's wheel shows the new colors, each as wide as its share of the art; dragging the edge between two parts raises one color's weight and lowers the other's, and the art previews the result; the New Color Group menu has Save All Colors and Save Prominent Colors ([Recolor options overview](https://helpx.adobe.com/illustrator/desktop/manage-colors/modify-colors/recolor-options-overview.html)). How a share is measured and how a weight moves colors between rows are VectorCraft's own rules (`recolor.weigh`). Tests: `weights_move_the_rows_colors_and_the_art_previews_them`, `dragging_a_prominent_edge_moves_colors_between_rows` and `save_all_and_prominent_colors_make_color_groups` in `crates/ui-egui/src/dialogs/recolor.rs`, `weigh_*` in `crates/engine/src/tests/recolor.rs`. + - [x] Recolor Artwork Edit color chips scroll within the screen, preserving the wheel and actions (#3416); 5, 20, 200 and 400 colors, resizing and UI scaling, final-chip editing and preview/history covered by egui_kittest. - [x] Status messages and bounded readouts remain stable as values, canvas hover and background jobs change; ordinary values fit all registered languages and units, and optional groups yield in narrow windows (regressions; contributor rebuilt and confirmed the original-file status-bar scenario on 2026-10-19). Sample widths are cached per UI and refreshed only when language, document units or pixels per point changes. From 072b68583a33ee25df466ac286c182ae09fede39 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 22:50:22 -0800 Subject: [PATCH 05/97] Place: placing the same file again shares the symbols and patterns it brought Symbol and pattern art copied in by a placement takes ids of the document's own, so the next placement of the same file compared it with the file's art, found the ids different and added Star 2, Dots 2 and so on. Definitions are now compared by what their art draws, whatever its ids. A symbol, pattern or brush whose art uses an image, symbol, pattern, brush or swatch that comes in under another name is copied under a free name instead of shared. --- crates/engine/src/cmd/place/adopt.rs | 100 ++++++++++++++++++++++++--- crates/engine/src/tests/place.rs | 68 ++++++++++++++++++ 2 files changed, 160 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/place/adopt.rs b/crates/engine/src/cmd/place/adopt.rs index 0a0f08d3c1..9cd28b7e25 100644 --- a/crates/engine/src/cmd/place/adopt.rs +++ b/crates/engine/src/cmd/place/adopt.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use vectorcraft_brush::{Brush, BrushKind}; use vectorcraft_color::Paint; -use vectorcraft_doc::{AppearanceItem, Document, Node, NodeKind}; +use vectorcraft_doc::{AppearanceItem, Document, Node, NodeKind, PatternDef, Symbol}; use super::unique_name; @@ -118,11 +118,12 @@ impl Used { type Renames = BTreeMap, bool)>; /// Names in `dst` for the `used` definitions of `src` (`def`): the same name when `dst` has none -/// (copied) or an equal one (shared), else a free variant (`taken`, copied). -fn names( +/// (copied) or one that is the `same` (shared), else a free variant (`taken`, copied). +fn names( used: &[String], def: impl Fn(&str) -> Option, existing: impl Fn(&str) -> Option, + same: impl Fn(&T, &T) -> bool, taken: impl Fn(&str) -> bool, ) -> Renames { let mut out = Renames::new(); @@ -130,7 +131,7 @@ fn names( let Some(d) = def(name) else { continue }; let entry = match existing(name) { None => (Some(name.clone()), true), - Some(e) if e == d => (Some(name.clone()), false), + Some(e) if same(&e, &d) => (Some(name.clone()), false), Some(_) => { (Some(unique_name(name, |c| taken(c) || used.iter().any(|u| u == c) || out.values().any(|(n, _)| n.as_deref() == Some(c)))), true) } @@ -140,6 +141,65 @@ fn names( out } +/// `n` as `dst` would copy it in ([`Document::reid`]), with the ids a document of its own gives it: +/// art copied in once, which differs from the file's only in its ids, compares equal to the file's. +fn canon(dst: &Document, n: &Node) -> Node { + let mut own = Document::new(0.0, 0.0); + // Pixels the art carries take a key `dst` doesn't use for other pixels, as they do in `dst`. + let mut keys = vec![]; + vectorcraft_doc::swatches::walk_with_masks(n, &mut |c| { + if let NodeKind::Image(im) = &c.kind + && im.pixels.is_some() + { + keys.push(im.key.clone()); + } + }); + for (k, blob) in &dst.images { + if keys.iter().any(|key| k == key || k.strip_prefix(key.as_str()).is_some_and(|rest| rest.starts_with('-'))) { + own.images.insert(k.clone(), blob.clone()); + } + } + own.reid(n) +} + +/// Do symbols `a` and `b` draw the same, whatever ids their art has ([`canon`])? +fn same_symbol(dst: &Document, a: &Symbol, b: &Symbol) -> bool { + let own = |s: &Symbol| Symbol { art: Arc::new(canon(dst, &s.art)), ..s.clone() }; + own(a) == own(b) +} + +/// Do patterns `a` and `b` draw the same, whatever ids their art has ([`canon`])? +fn same_pattern(dst: &Document, a: &PatternDef, b: &PatternDef) -> bool { + let bare = |p: &PatternDef| PatternDef { art: vec![], ..p.clone() }; + bare(a) == bare(b) && a.art.len() == b.art.len() && a.art.iter().zip(&b.art).all(|(x, y)| canon(dst, x) == canon(dst, y)) +} + +/// Does art `a` use a resource that `m` (images, symbols, patterns, brushes, swatches) gives +/// another name, or a swatch whose link `m` drops? +fn uses_moved(a: &Node, m: [&Renames; 5]) -> bool { + let moved = |map: &Renames, k: &str| map.get(k).is_some_and(|(new, _)| new.as_deref() != Some(k)); + let (mut a, mut any) = (a.clone(), false); + refs(&mut a, &mut |r| { + any |= match r { + Ref::Image(k) => moved(m[0], k), + Ref::Symbol(k) => moved(m[1], k), + Ref::Pattern(k) => moved(m[2], k), + Ref::Brush(k) => moved(m[3], k), + Ref::Swatch(link) => link.as_deref().is_some_and(|k| moved(m[4], k)), + } + }); + any +} + +/// Copy each of `shared` in `map` under a free name, as [`names`] does for a definition `dst` +/// holds differently. +fn unshare(map: &mut Renames, shared: &[String], used: &[String], taken: impl Fn(&str) -> bool) { + for name in shared { + let free = unique_name(name, |c| taken(c) || used.iter().any(|u| u == c) || map.values().any(|(n, _)| n.as_deref() == Some(c))); + map.insert(name.clone(), (Some(free), true)); + } +} + /// The renamed resources whose definitions are copied: (name in `src`, name in `dst`). fn copies(m: &Renames) -> Vec<(&str, &str)> { m.iter().filter(|(_, (_, copy))| *copy).filter_map(|(old, (new, _))| Some((old.as_str(), new.as_deref()?))).collect() @@ -194,14 +254,17 @@ pub(crate) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) { } } - let images = names(&used.images, |k| src.images.get(k), |k| dst.images.get(k), |c| dst.images.contains_key(c)); - let symbols = names( + let images = names(&used.images, |k| src.images.get(k), |k| dst.images.get(k), |a, b| a == b, |c| dst.images.contains_key(c)); + // Symbol and pattern art copied in takes ids of `dst`, so their art is compared by what it + // draws: placing the same file again shares what the first placement brought. + let mut symbols = names( &used.symbols, |n| src.symbols.iter().find(|s| s.name == n), |n| dst.symbols.iter().find(|s| s.name == n), + |a, b| same_symbol(dst, a, b), |c| dst.symbols.iter().any(|s| s.name == c), ); - let patterns = names(&used.patterns, |n| src.pattern(n), |n| dst.pattern(n), |c| dst.pattern(c).is_some()); + let mut patterns = names(&used.patterns, |n| src.pattern(n), |n| dst.pattern(n), |a, b| same_pattern(dst, a, b), |c| dst.pattern(c).is_some()); // A swatch the document defines differently isn't renamed (that would add look-alike swatches): // the colours linked to it keep their look, unlinked. let swatches: Renames = used @@ -236,12 +299,33 @@ pub(crate) fn adopt(dst: &mut Document, src: &Document, nodes: &mut [Node]) { any }; let mut dst_brushes = vectorcraft_brush::library(dst); - let brushes = names( + let mut brushes = names( &used.brushes, |n| src_brushes.iter().find(|b| b.name == n).map(|b| (b, moves_art(b))), |n| dst_brushes.iter().find(|b| b.name == n).map(|b| (b, false)), + |a, b| a == b, |c| dst_brushes.iter().any(|b| b.name == c), ); + // Likewise a symbol, pattern or brush whose art uses a resource that moves isn't the + // document's of that name: it is copied under a free name, which can move what uses it in turn. + loop { + let maps = [&images, &symbols, &patterns, &brushes, &swatches]; + let shared = |map: &Renames| map.iter().filter(|(_, (_, copy))| !*copy).map(|(k, _)| k.clone()).collect::>(); + let sym: Vec = + shared(&symbols).into_iter().filter(|n| src.symbols.iter().any(|s| s.name == *n && uses_moved(&s.art, maps))).collect(); + let pat: Vec = + shared(&patterns).into_iter().filter(|n| src.pattern(n).is_some_and(|p| p.art.iter().any(|a| uses_moved(a, maps)))).collect(); + let bru: Vec = shared(&brushes) + .into_iter() + .filter(|n| src_brushes.iter().find(|b| b.name == *n).is_some_and(|b| brush_art(&mut b.clone()).into_iter().any(|a| uses_moved(a, maps)))) + .collect(); + if sym.is_empty() && pat.is_empty() && bru.is_empty() { + break; + } + unshare(&mut symbols, &sym, &used.symbols, |c| dst.symbols.iter().any(|s| s.name == c)); + unshare(&mut patterns, &pat, &used.patterns, |c| dst.pattern(c).is_some()); + unshare(&mut brushes, &bru, &used.brushes, |c| dst_brushes.iter().any(|b| b.name == c)); + } let mut rename = |r: Ref| { let (map, k) = match r { diff --git a/crates/engine/src/tests/place.rs b/crates/engine/src/tests/place.rs index 455471dbb1..ab80255376 100644 --- a/crates/engine/src/tests/place.rs +++ b/crates/engine/src/tests/place.rs @@ -689,6 +689,74 @@ fn placed_art_brings_its_symbols_and_patterns_with_ids_of_the_documents_own() { assert_eq!(unique.len(), ids.len(), "{ids:?}"); } +/// Placing the same file again shares the symbols and patterns the first placement brought: their +/// art differs from the file's only in its ids, so no `Star 2` or `Dots 2` joins the document. +#[test] +fn placing_the_same_art_again_shares_its_symbols_and_patterns() { + let mut src = session(); + src.execute("shape.ellipse", &json!({"x": 50, "y": 10, "width": 20, "height": 20})).unwrap(); + src.execute("symbol.new", &json!({"name": "Star"})).unwrap(); + src.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 8, "height": 8})).unwrap(); + let tile = src.doc().unwrap().selection.objects[0]; + src.execute("object.pattern.make", &json!({"ids": [tile.0], "edit": false, "name": "Dots"})).unwrap(); + src.execute("paint.setFill", &json!({"ids": [tile.0], "swatch": "Dots"})).unwrap(); + let bytes = serialize(&mut src, "vectorcraft"); + let mut s = session(); + let counts = |s: &Session| { + let doc = &s.doc().unwrap().doc; + (doc.symbols.len(), doc.patterns.len()) + }; + place(&mut s, "logo.vectorcraft", &bytes, json!({})); + let once = counts(&s); + for _ in 0..2 { + place(&mut s, "logo.vectorcraft", &bytes, json!({})); + } + assert_eq!(counts(&s), once); + let doc = &s.doc().unwrap().doc; + assert!(!doc.symbols.iter().any(|x| x.name == "Star 2") && doc.pattern("Dots 2").is_none()); +} + +/// A symbol whose art matches the document's own but uses a pattern the document defines +/// differently is not shared: it comes in under a free name, using the pattern it brings. +#[test] +fn placed_art_does_not_share_a_symbol_whose_pattern_moves() { + let mut src = session(); + src.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 8, "height": 8})).unwrap(); + let tile = src.doc().unwrap().selection.objects[0]; + src.execute("object.pattern.make", &json!({"ids": [tile.0], "edit": false, "name": "Dots"})).unwrap(); + src.execute("paint.setFill", &json!({"ids": [tile.0], "swatch": "Dots"})).unwrap(); + src.execute("symbol.new", &json!({"name": "Star", "ids": [tile.0]})).unwrap(); + let bytes = serialize(&mut src, "vectorcraft"); + let mut s = session(); + place(&mut s, "logo.vectorcraft", &bytes, json!({})); + // The document's "Dots" changes; its "Star" still matches the file's. + let st = s.doc_mut().unwrap(); + let d = Arc::make_mut(&mut st.doc); + let dots = d.pattern_mut("Dots").unwrap(); + dots.tile = Rect::new(0.0, 0.0, 50.0, 50.0); + let r = place(&mut s, "logo.vectorcraft", &bytes, json!({})); + let mut symbol = None; + node(&s, &r["ids"][0]).walk(&mut |n| { + if let NodeKind::SymbolInstance { symbol: name, .. } = &n.kind { + symbol = Some(name.clone()); + } + }); + assert_eq!(symbol.as_deref(), Some("Star 2")); + let doc = &s.doc().unwrap().doc; + let star2 = doc.symbols.iter().find(|x| x.name == "Star 2").unwrap(); + let mut fills = vec![]; + star2.art.walk(&mut |n| { + fills.extend(n.appearance.items.iter().filter_map(|i| match i { + AppearanceItem::Fill(f) => match &f.paint { + Paint::Pattern { pattern, .. } => Some(pattern.clone()), + _ => None, + }, + _ => None, + })) + }); + assert_eq!(fills, ["Dots 2"]); +} + #[test] fn placed_art_brings_its_brushes_renaming_on_conflict() { // The file: a path with its own brush, and one with a brush the defaults hold. From 64241978ec9e2814135382782e19bf9192eb1d02 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 22:50:22 -0800 Subject: [PATCH 06/97] Links: a nested row's tooltip names the symbol, graph design, pattern or mask holding it Two rows of the same file in different symbols showed the same name, thumbnail and status. The tooltip of a row outside the layer tree now adds the symbol, graph design or pattern whose art holds the image, and the object whose opacity mask holds it. --- crates/ui-egui/src/panels/links.rs | 33 ++++++++++++++++++++++-- crates/ui-egui/src/panels/tests_links.rs | 18 +++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index af85690e9d..5c95e17bc7 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -344,7 +344,31 @@ fn badge(ui: &Ui, r: egui::Rect, status: &str) { } } -fn status_tip(r: &Value) -> String { +/// The tooltip of row `r`. A row outside the layer tree also names where it is stored +/// (`links.list`'s `owner`): the symbol, graph design or pattern whose art holds it, and the object +/// whose opacity mask holds it, by `mask` (that object's name) or else by its id. +pub(super) fn status_tip(r: &Value, mask: Option) -> String { + let tip = file_tip(r); + let owner = &r["owner"]; + let name = owner["name"].as_str().unwrap_or_default(); + let art = match owner["kind"].as_str() { + Some("symbol") => Some(tl!("Symbols")), + Some("graphDesign") => Some(tl!("Graph Design")), + Some("pattern") => Some(tl!("Pattern")), + _ => None, + }; + let tip = match art { + Some(kind) => format!("{tip}\n{kind}: {name}"), + None => tip, + }; + match owner["maskOf"].as_u64() { + Some(id) => format!("{tip}\n{}: {}", tl!("Opacity Mask"), mask.unwrap_or_else(|| id.to_string())), + None => tip, + } +} + +/// The file, status and notes part of [`status_tip`]. +fn file_tip(r: &Value) -> String { let path = r["found"].as_str().or(r["path"].as_str()).unwrap_or_default(); let tip = match r["status"].as_str().unwrap_or_default() { "missing" => crate::i18n::fmt(tl!("Missing: {path}"), &[("path", path)]), @@ -495,7 +519,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { egui::FontId::proportional(12.5), t.text, ); - let resp = resp.on_hover_text(status_tip(r)); + let mask = r["owner"]["maskOf"] + .as_u64() + .and_then(|m| doc.stored_node(NodeId(m))) + .map(|(n, _)| n) + .map(|n| super::layers::shown_name(app, n, crate::i18n::current())); + let resp = resp.on_hover_text(status_tip(r, mask)); if resp.double_clicked() { clicked = Some((id, egui::Modifiers::NONE, true)); } else if resp.clicked() { diff --git a/crates/ui-egui/src/panels/tests_links.rs b/crates/ui-egui/src/panels/tests_links.rs index be5e17e374..89dfba5f43 100644 --- a/crates/ui-egui/src/panels/tests_links.rs +++ b/crates/ui-egui/src/panels/tests_links.rs @@ -178,6 +178,24 @@ fn a_link_inside_a_symbol_is_listed_and_picked_in_the_panel() { assert!(selected(&app).is_empty(), "the image in the layers is no longer selected"); } +/// The tooltip of a row outside the layer tree says where the image is stored, so two rows of the +/// same file in different symbols or masks can be told apart. +#[test] +fn a_nested_rows_tooltip_names_what_holds_it() { + let tip = |owner: serde_json::Value, mask: Option<&str>| { + let row = json!({"id": 9, "name": "a.png", "status": "ok", "path": "/x/a.png", "owner": owner}); + super::links::status_tip(&row, mask.map(str::to_string)) + }; + assert_eq!(tip(json!({"kind": "symbol", "name": "Mark"}), None), "/x/a.png\nSymbols: Mark"); + assert_eq!(tip(json!({"kind": "pattern", "name": "Dots"}), None), "/x/a.png\nPattern: Dots"); + assert_eq!(tip(json!({"kind": "graphDesign", "name": "Bar"}), None), "/x/a.png\nGraph Design: Bar"); + assert_eq!(tip(json!({"kind": "layers", "maskOf": 7}), Some("")), "/x/a.png\nOpacity Mask: "); + assert_eq!(tip(json!({"kind": "symbol", "name": "Mark", "maskOf": 7}), None), "/x/a.png\nSymbols: Mark\nOpacity Mask: 7"); + // A row in the layers says only its file. + let row = json!({"id": 9, "name": "a.png", "status": "ok", "path": "/x/a.png"}); + assert_eq!(super::links::status_tip(&row, None), "/x/a.png"); +} + #[test] fn an_empty_document_shows_how_to_add_images() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); From a367065afc0e59511c22bea468292635edfcf5c9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 23:26:25 -0800 Subject: [PATCH 07/97] Links: a mask row's tooltip finds its object without walking the stored art The panel collects the objects whose opacity masks hold a listed image in the same pass over the stored art that collects the rows' objects, once per list rebuild. Each frame then finds a row's mask object by the layer tree's index or in that cache, instead of walking every stored root for objects inside symbols, graph designs and patterns. --- crates/ui-egui/src/panels/links.rs | 40 ++++++++++++++---------- crates/ui-egui/src/panels/tests_links.rs | 33 +++++++++++++++++++ 2 files changed, 57 insertions(+), 16 deletions(-) diff --git a/crates/ui-egui/src/panels/links.rs b/crates/ui-egui/src/panels/links.rs index 5c95e17bc7..2fb130ae3e 100644 --- a/crates/ui-egui/src/panels/links.rs +++ b/crates/ui-egui/src/panels/links.rs @@ -60,10 +60,18 @@ struct Cache { at: f64, list: Value, info: Option<(u64, Value)>, - /// The objects of the rows outside the layer tree, by id, for their thumbnails. + /// The objects outside the layer tree, by id: those of the rows, for their thumbnails, and + /// those whose opacity masks hold a row's image, for the tooltip. nested: Vec<(u64, std::sync::Arc)>, } +impl Cache { + /// Object `id` outside the layer tree, as [`cached`] collected it. + fn nested_node(&self, id: u64) -> Option<&std::sync::Arc> { + self.nested.binary_search_by_key(&id, |(n, _)| *n).ok().and_then(|i| self.nested.get(i)).map(|(_, n)| n) + } +} + fn options(ctx: &egui::Context) -> Options { pstate(ctx, "links-options") } @@ -86,9 +94,16 @@ fn cached(app: &mut VectorcraftApp, ctx: &egui::Context, o: &Options) -> Option< if stale { c.list = app.session.execute("links.list", &json!({ "show": o.show, "sort": o.sort, "dcsTransparency": o.dcs })).ok()?; (c.key, c.at) = (key, now); - // One pass over the stored art for the rows outside the layer tree, not one per frame. - let mut want: std::collections::HashSet = - c.list["links"].as_array().into_iter().flatten().filter(|r| nested_row(r)).filter_map(|r| r["id"].as_u64()).collect(); + // One pass over the stored art for the rows outside the layer tree and the objects whose + // opacity masks hold them, not one per frame. + let mut want: std::collections::HashSet = c.list["links"] + .as_array() + .into_iter() + .flatten() + .filter(|r| nested_row(r)) + .flat_map(|r| [r["id"].as_u64(), r["owner"]["maskOf"].as_u64()]) + .flatten() + .collect(); c.nested.clear(); if !want.is_empty() && let Some(st) = app.session.active() @@ -477,16 +492,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if o.thumb > 0.0 { let tr = egui::Rect::from_min_size(egui::pos2(x, rect.center().y - o.thumb / 2.0), vec2(o.thumb, o.thumb)); ui.painter().rect_filled(tr, 0.0, egui::Color32::WHITE); - let node = if nested_row(r) { - cache - .nested - .binary_search_by_key(&id, |(n, _)| *n) - .ok() - .and_then(|i| cache.nested.get(i)) - .map(|(_, n)| std::sync::Arc::clone(n)) - } else { - super::layers::node_arc(&doc, NodeId(id)) - }; + let node = + if nested_row(r) { cache.nested_node(id).map(std::sync::Arc::clone) } else { super::layers::node_arc(&doc, NodeId(id)) }; if let Some(n) = node { super::layers::real_thumb(ui, &thumbs, &n, tr); } @@ -519,10 +526,11 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { egui::FontId::proportional(12.5), t.text, ); + // The mask's object by the layer tree's index or from the cache: no walk of the + // stored art each frame. let mask = r["owner"]["maskOf"] .as_u64() - .and_then(|m| doc.stored_node(NodeId(m))) - .map(|(n, _)| n) + .and_then(|m| doc.node(NodeId(m)).or_else(|| cache.nested_node(m).map(|n| &**n))) .map(|n| super::layers::shown_name(app, n, crate::i18n::current())); let resp = resp.on_hover_text(status_tip(r, mask)); if resp.double_clicked() { diff --git a/crates/ui-egui/src/panels/tests_links.rs b/crates/ui-egui/src/panels/tests_links.rs index 89dfba5f43..8055c1288c 100644 --- a/crates/ui-egui/src/panels/tests_links.rs +++ b/crates/ui-egui/src/panels/tests_links.rs @@ -196,6 +196,39 @@ fn a_nested_rows_tooltip_names_what_holds_it() { assert_eq!(super::links::status_tip(&row, None), "/x/a.png"); } +/// The tooltip of an image in the opacity mask of an object inside a symbol names that object, +/// which the panel finds among the objects it collected with the list. +#[test] +fn a_mask_rows_tooltip_names_its_object_inside_a_symbol() { + let dir = Folder::new("mask-owner"); + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + let rect = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 60, "height": 60})).unwrap()["id"].as_u64().unwrap(); + let mask = dir.file("mask.png", &png(20, 20)); + let mask = app.run("file.place", json!({"path": mask, "at": [40, 40]})).unwrap()["ids"][0].as_u64().unwrap(); + app.run("transparency.makeOpacityMask", json!({"ids": [rect, mask]})).unwrap(); + app.run("symbol.new", json!({"name": "Mark", "ids": [rect]})).unwrap(); + app.run("edit.clear", json!({"ids": selected(&app)})).unwrap(); + let list = app.run("links.list", json!({})).unwrap(); + let row = list["links"].as_array().unwrap().iter().find(|r| r["name"] == "mask.png").unwrap_or_else(|| panic!("{list}")).clone(); + assert_eq!(row["owner"]["kind"], "symbol", "{list}"); + let owner = vectorcraft_doc::NodeId(row["owner"]["maskOf"].as_u64().unwrap_or_else(|| panic!("{list}"))); + let doc = app.session.active().unwrap().doc.clone(); + assert!(doc.node(owner).is_none(), "the masked object is outside the layer tree"); + let name = super::layers::shown_name(&app, doc.stored_node(owner).unwrap().0, crate::i18n::current()); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let frame = |app: &mut VectorcraftApp, events: Vec, time: f64| { + super::tests_appearance::frame_raw(&ctx, app, egui::RawInput { events, time: Some(time), ..Default::default() }, super::links::show) + }; + let at = super::tests_appearance::text_rect(&frame(&mut app, vec![], 1.0), "mask.png").center(); + frame(&mut app, vec![egui::Event::PointerMoved(at)], 2.0); + frame(&mut app, vec![], 4.0); + let text: Vec = frame(&mut app, vec![], 4.1).into_iter().map(|(t, _)| t).collect(); + let want = format!("Opacity Mask: {name}"); + assert!(text.iter().any(|t| t.contains(&want)), "{want} in {text:?}"); +} + #[test] fn an_empty_document_shows_how_to_add_images() { let mut app = VectorcraftApp::new(Session::new(), Default::default()); From 6dcbf804ca60146f14adc789940a0fc24e6c23f9 Mon Sep 17 00:00:00 2001 From: HassanKhan Date: Fri, 13 Nov 2026 13:28:16 +0500 Subject: [PATCH 08/97] Character panel: icons for the font height references (#6304) The Set Font Height Reference dropdown shows an icon before each reference's name, in the open list and in the closed field: Em Box frames the whole square around an A, Cap Height and x-Height put a bar from the baseline to the top of a capital A or a lowercase x, and ICF Box frames a square inset in the em square. The icons are painted with egui's painter in the row's text colour and the theme accent. widgets::dropdown_icon_names is a dropdown_names whose options carry a painted icon. --- crates/ui-egui/src/panels/character.rs | 77 +++++++++++++++++++- crates/ui-egui/src/panels/character/tests.rs | 64 ++++++++++++++++ crates/ui-egui/src/widgets.rs | 4 +- crates/ui-egui/src/widgets/dropdowns.rs | 47 +++++++++++- docs/target-app-parity.md | 5 +- 5 files changed, 189 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 2ad678bf62..d1712c42d7 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -940,18 +940,89 @@ const FONT_HEIGHT_REFERENCES: [(vectorcraft_doc::FontHeightReference, &str); 4] (vectorcraft_doc::FontHeightReference::IcfBox, "icfBox"), ]; +/// The size of the icon before each name in the Set Font Height Reference dropdown. +const FONT_HEIGHT_ICON: f32 = 16.0; + /// The Set Font Height Reference dropdown, `width` wide: the height of the font that the Font Size -/// measures, blank where the characters the panel acts on differ. +/// measures, blank where the characters the panel acts on differ. Each reference shows an icon of +/// the height it measures before its name ([`paint_font_height_icon`], #6304). fn font_height_reference(app: &mut VectorcraftApp, ui: &mut Ui, width: f32) { // The labels of [`FONT_HEIGHT_REFERENCES`], in its order. let labels = [tl!("Em Box"), tl!("Cap Height"), tl!("x-Height"), tl!("ICF Box")]; let current = shared(app, |s| s.font_height_reference); - let shown = FONT_HEIGHT_REFERENCES.iter().zip(labels).find(|(r, _)| Some(r.0) == current).map_or("", |(_, l)| l); - if let Some((_, key)) = widgets::dropdown_names(ui, "ch-font-height", shown, &labels, width).and_then(|i| FONT_HEIGHT_REFERENCES.get(i)) { + let shown = FONT_HEIGHT_REFERENCES.iter().position(|(r, _)| Some(*r) == current); + let mark = Tokens::get(ui.ctx()).accent; + let icon = |ui: &Ui, rect: egui::Rect, i: usize, ink: egui::Color32| { + if let Some((r, _)) = FONT_HEIGHT_REFERENCES.get(i) { + paint_font_height_icon(ui.painter(), rect, *r, ink, mark); + } + }; + let picked = widgets::dropdown_icon_names(ui, "ch-font-height", shown, &labels, width, FONT_HEIGHT_ICON, icon); + if let Some((_, key)) = picked.and_then(|i| FONT_HEIGHT_REFERENCES.get(i)) { format(app, json!({"fontHeightReference": key})); } } +/// Paints the icon of font height reference `r` into the square `rect` (#6304): a letter drawn in +/// `ink` on a baseline, and in `mark` the height the reference measures. Em Box frames the whole +/// square around an "A"; Cap Height puts a bar from the baseline to the top of a capital "A" +/// beside it; x-Height a bar from the baseline to the top of a lowercase "x"; ICF Box frames a +/// square inset in the faint em square around an ideograph-like cross, as the ideographic +/// character face sits inside the em box. The drawings are VectorCraft's own. +fn paint_font_height_icon( + painter: &egui::Painter, + rect: egui::Rect, + r: vectorcraft_doc::FontHeightReference, + ink: egui::Color32, + mark: egui::Color32, +) { + use vectorcraft_doc::FontHeightReference as R; + // A point at (x, y) in the unit square mapped onto `rect`. + let p = |x: f32, y: f32| rect.lerp_inside(vec2(x, y)); + let glyph = egui::Stroke::new(1.3, ink); + let measure = egui::Stroke::new(1.2, mark); + let line = |a: (f32, f32), b: (f32, f32), stroke: egui::Stroke| painter.line_segment([p(a.0, a.1), p(b.0, b.1)], stroke); + let frame = |min: f32, max: f32, stroke: egui::Stroke| { + painter.rect_stroke(egui::Rect::from_min_max(p(min, min), p(max, max)), 0.0, stroke, egui::StrokeKind::Middle); + }; + // A capital A from `left` to `right`, its apex at `top` and its feet on `base`. + let capital = |left: f32, right: f32, top: f32, base: f32| { + let mid = (left + right) / 2.0; + painter.add(egui::Shape::line(vec![p(left, base), p(mid, top), p(right, base)], glyph)); + let bar = top + (base - top) * 0.62; + let inset = (right - left) * 0.5 * 0.62; + line((mid - inset, bar), (mid + inset, bar), glyph); + }; + // A vertical measure from `top` to `base` at `x`, with ticks at its ends. + let height_bar = |x: f32, top: f32, base: f32| { + line((x, top), (x, base), measure); + line((x - 0.1, top), (x + 0.1, top), measure); + line((x - 0.1, base), (x + 0.1, base), measure); + }; + let (base, cap_top, x_top) = (0.86, 0.16, 0.46); + match r { + R::EmBox => { + frame(0.06, 0.94, measure); + capital(0.26, 0.74, 0.26, 0.76); + } + R::CapHeight => { + capital(0.06, 0.6, cap_top, base); + height_bar(0.82, cap_top, base); + } + R::XHeight => { + line((0.1, x_top), (0.54, base), glyph); + line((0.54, x_top), (0.1, base), glyph); + height_bar(0.82, x_top, base); + } + R::IcfBox => { + frame(0.06, 0.94, egui::Stroke::new(1.0, ink.gamma_multiply(0.4))); + frame(0.2, 0.8, measure); + line((0.33, 0.5), (0.67, 0.5), glyph); + line((0.5, 0.31), (0.5, 0.69), glyph); + } + } +} + /// The font family menu and the font style dropdown of style `s` (Character and Properties panels, /// Control bar), with their `ids` and `widths`. The mouse wheel over the closed family field steps /// the text through the families. diff --git a/crates/ui-egui/src/panels/character/tests.rs b/crates/ui-egui/src/panels/character/tests.rs index a90286e2fb..4e403f0903 100644 --- a/crates/ui-egui/src/panels/character/tests.rs +++ b/crates/ui-egui/src/panels/character/tests.rs @@ -822,6 +822,70 @@ fn show_font_height_options_shows_the_font_height_reference() { assert!(item(&texts(&ctx, &mut app, show), "Cap Height").is_none()); } +/// The Set Font Height Reference dropdown shows an icon before each reference's name, in the +/// closed field and in the open list, and each reference's icon is its own (#6304). +#[test] +fn font_height_references_show_their_icons() { + use super::super::tests_appearance::click; + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + let id = app.run("text.create", json!({"x": 20, "y": 50, "text": "Hx", "size": 12})).unwrap()["id"].clone(); + app.run("select.set", json!({"ids": [id]})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + set_pstate(&ctx, FONT_HEIGHT_OPTIONS, true); + // Every text drawn with its rect, and the rects of every other shape drawn. + let frame = |ctx: &egui::Context, app: &mut VectorcraftApp| { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(app, ui)); + out.textures_delta.clear(); + fn collect(s: &egui::Shape, texts: &mut Vec<(String, egui::Rect)>, marks: &mut Vec) { + match s { + egui::Shape::Text(t) => texts.push((t.galley.text().to_string(), t.visual_bounding_rect())), + egui::Shape::Vec(v) => v.iter().for_each(|s| collect(s, texts, marks)), + egui::Shape::Noop => {} + s => marks.push(s.visual_bounding_rect()), + } + } + let (mut texts, mut marks) = (vec![], vec![]); + for c in &out.shapes { + collect(&c.shape, &mut texts, &mut marks); + } + (texts, marks) + }; + // The shapes in the icon's place before the text `rect`, relative to that place and rounded. + let icon = |marks: &[egui::Rect], rect: egui::Rect| { + let place = egui::Rect::from_min_max( + egui::pos2(rect.min.x - FONT_HEIGHT_ICON - 8.0, rect.center().y - 10.0), + egui::pos2(rect.min.x, rect.center().y + 10.0), + ); + let mut found: Vec<[i32; 4]> = marks + .iter() + .filter(|m| m.is_finite() && place.contains_rect(**m)) + .map(|m| [m.min.x - place.min.x, m.min.y - place.min.y, m.max.x - place.min.x, m.max.y - place.min.y].map(|v| v.round() as i32)) + .collect(); + found.sort_unstable(); + found + }; + frame(&ctx, &mut app); + let (texts, marks) = frame(&ctx, &mut app); + let field = texts.iter().find(|(t, _)| t == "Em Box").map(|(_, r)| *r).expect("the closed field shows Em Box"); + assert!(icon(&marks, field).len() >= 2, "no icon before Em Box in the closed field: {:?}", icon(&marks, field)); + click(&ctx, &mut app, field.center(), show); + frame(&ctx, &mut app); + let (texts, marks) = frame(&ctx, &mut app); + let mut icons = vec![]; + for name in ["Em Box", "Cap Height", "x-Height", "ICF Box"] { + // The list's row is the last text of that name drawn (the closed field is drawn first). + let row = texts.iter().rev().find(|(t, _)| t == name).map(|(_, r)| *r).unwrap_or_else(|| panic!("the open list shows {name}: {texts:?}")); + let found = icon(&marks, row); + assert!(found.len() >= 2, "no icon before {name} in the open list: {found:?}"); + icons.push(found); + } + for (i, a) in icons.iter().enumerate() { + assert!(icons.iter().skip(i + 1).all(|b| a != b), "two references share an icon: {icons:?}"); + } +} + /// Tate-chu-yoko is in the panel menu with the East Asian options, and choosing it marks the /// selected characters. #[test] diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 826e7e02f6..52849bc2bc 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -47,8 +47,8 @@ pub use drag::{ use dropdowns::combo_button_id; pub(crate) use dropdowns::{combo, combo_menu, combo_menu_field, 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, + dropdown, dropdown_icon_names, 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, }; #[cfg(test)] pub(crate) use dropdowns::{menu_floor, menu_room_either}; diff --git a/crates/ui-egui/src/widgets/dropdowns.rs b/crates/ui-egui/src/widgets/dropdowns.rs index 44903d6613..c8fba54aed 100644 --- a/crates/ui-egui/src/widgets/dropdowns.rs +++ b/crates/ui-egui/src/widgets/dropdowns.rs @@ -1,3 +1,5 @@ +use egui::IntoAtoms as _; + use super::drag::PanelDrag; use super::labels::{PreviousLabel, label_field, name_field, take_previous_label}; use super::text::take_armed_focus; @@ -33,6 +35,37 @@ pub fn dropdown_names_named(ui: &mut Ui, id: impl std::hash::Hash, name: Option< }) } +/// [`dropdown_names`] whose options each show an icon before their name, in the open list and in +/// the closed field, so that a name that says little on its own is shown with a picture of what it +/// means (#6304). `icon(ui, rect, index, ink)` paints option `index`'s icon into `rect`, an +/// `icon_size` square, with `ink`, the colour the row's name is drawn in. `current` is the option +/// shown; `None` shows a blank field (characters that differ). +pub fn dropdown_icon_names( + ui: &mut Ui, + id: impl std::hash::Hash, + current: Option, + options: &[&str], + width: f32, + icon_size: f32, + icon: impl Fn(&Ui, Rect, usize, egui::Color32), +) -> Option { + let icon = &icon; + let atom = move |i: usize| egui::Atom::paint(Vec2::splat(icon_size), move |ui, a| icon(ui, a.rect, i, a.fallback_text_color)); + let shown = match current.and_then(|i| options.get(i).map(|name| (i, *name))) { + Some((i, name)) => (atom(i), egui::RichText::new(name).size(12.0)).into_atoms(), + None => egui::RichText::new("").size(12.0).into_atoms(), + }; + combo_atoms_named(ui, id, None, shown, width, |ui| { + let mut chosen = None; + for (i, o) in options.iter().enumerate() { + if ui.add(egui::Button::selectable(current == Some(i), (atom(i), *o))).clicked() { + chosen = Some(i); + } + } + chosen + }) +} + /// [`dropdown`] whose options can be disabled (`enabled(index)`: greyed and not choosable). pub fn dropdown_with( ui: &mut Ui, @@ -88,6 +121,18 @@ pub(crate) fn combo_named( current: &str, width: f32, list: impl FnOnce(&mut Ui) -> Option, +) -> Option { + combo_atoms_named(ui, id, name, egui::RichText::new(current).size(12.0), width, list) +} + +/// [`combo_named`] whose closed field shows the atoms `current` (an icon and a name, say). +fn combo_atoms_named<'a, R>( + ui: &mut Ui, + id: impl std::hash::Hash, + name: Option<&str>, + current: impl egui::IntoAtoms<'a>, + width: f32, + list: impl FnOnce(&mut Ui) -> Option, ) -> Option { let t = Tokens::get(ui.ctx()); let label = take_previous_label(ui); @@ -99,7 +144,7 @@ pub(crate) fn combo_named( let button_id = combo_button_id(ui, &id); let enter_for_combo = ui.memory(|m| m.has_focus(button_id)) || craft_ui::ComboBox::is_open(ui.ctx(), button_id); let response = craft_ui::ComboBox::from_id_salt(HashOnly(&id)) - .selected_text(egui::RichText::new(current).size(12.0)) + .selected_text(current) .width(width - 4.0) .height(ui.spacing().combo_height) .show_ui(ui, list); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 086d4cfc0d..7904896324 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-13 · **Change:** minor (PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously minor: Object › Rasterize with the Grayscale or Bitmap colour model makes a grayscale image, #6969; scores and estimates unchanged; previously minor: `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; #6617: Swatches panel menu: Show Recent Colors; scores unchanged; previously minor: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; 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-13 · **Change:** minor (Character panel: the Set Font Height Reference dropdown shows an icon before each reference, #6304; scores and estimates unchanged; previously: PDF import: glyphs in a span with an empty ActualText come in as outlines, #6971; scores and estimates unchanged; previously: library panels' lists fill a taller panel, #2992; scores and estimates unchanged; previously: Swatches list fills a taller panel, #1638; scores and estimates unchanged; previously minor: Object › Rasterize with the Grayscale or Bitmap colour model makes a grayscale image, #6969; scores and estimates unchanged; previously minor: `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints, miter tips and moved strokes included, after its glyphs grown by half its strokes' weight (#6824; #6617: Swatches panel menu: Show Recent Colors; scores unchanged; previously minor: icon-strip flyouts also resize from the top edge and the canvas-side top corner, keeping the moved top in workspaces (storytold/craft-libs#1094); scores and estimates unchanged; 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). > @@ -1224,7 +1224,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Find Font: type in opacity masks and pattern swatches:** Type › Find Font…'s Document list (`text.fonts`), Find (`select.font`), Change and Change All (`text.replaceFont`), Replace Missing Fonts (`addon.cleanup.replaceFonts`), the Missing Fonts dialog (`text.missingFonts`), Document Info's Fonts and Font Details for the whole document (`document.info`), File › Package and the export warning about fonts that aren't available include the type in opacity masks (on objects and layers, disabled masks and masks inside mask, symbol or pattern art included) and in the art of pattern swatches, also a swatch that nothing paints with. A pattern's type is listed and changed once, however many objects the pattern paints. Find selects the objects whose mask holds the font, the text whose inline graphics draw a symbol whose art uses it, and the objects painted with a pattern whose art uses it by a visible fill or stroke above 0% opacity (VectorCraft's own rule; a layer's mask and a swatch that nothing paints with select nothing); Change and Change All leave the type in the mask of a hidden or locked object in its font and count it in `skipped`, and Change does the same with the type of a pattern that only hidden or locked selected objects paint with. The help page (https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-and-replace-fonts.html) says the Document list holds all the fonts the document uses. Tests: `crates/engine/src/cmd/fonts.rs`, `crates/engine/src/cmd/addons/cleanup/text/tests.rs`, `crates/engine/src/cmd/docinfo.rs`. - **Mouse wheel over the font family field (#5253):** In the Character and Properties panels and the Control bar, turning the mouse wheel over the closed font family field applies the next family to the selected type (the previous one when turned up), keeping the style when the family has it, else its closest. Each notch is one undo step; leaving the field or Escape keeps the family, and Undo takes it back. The steps use the font menu's order at the first turn and keep it until the pointer leaves the field or the text selection changes, so recent-font updates do not reshuffle later turns. That order follows the font filters every font menu shares (one chosen in the Glyphs panel's menu or a dialog's applies too) but ignores its search text, puts recent families first, and stops at the first and last family. A turn with a modifier key is left to the panel and the canvas. Behavior reference: the [Illustrator CS6 reference](https://help.adobe.com/archive/en/illustrator/cs6/illustrator_reference.pdf#page=10) describes editing hovered dropdown controls with the mouse wheel; [Find and apply fonts](https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/find-and-apply-fonts.html) names the family field's places. Applying it to the font field, the order, the ends and the undo steps are VectorCraft's own rules. Tests: `crates/ui-egui/src/tests/font_menu.rs`, `crates/ui-egui/src/font_menu/tests.rs`, `crates/ui-egui/tests/font_wheel.rs`. - **Type:** Character size, leading, baseline shift and horizontal-scale controls account for object/group transforms (#1034): exact document-point sizes, mixed-value display and keyboard increments, range edits, resettable horizontal stretch, undo/redo and native save/reopen (`tests_typescale`, Character panel tests). Point size is the transformed em-height axis, independent of glyph ink bounds; affine rotation, reflection and shear are retained. Group scaling still scales text; preserving font sizes is a separate enhancement. Behaviour reference: [Transform text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/transform-text.html) (public documentation). Text Wrap, Type on a Path effects (Rainbow/Skew/3D Ribbon/Stair Step/Gravity) and Options (Flip, Align to Path, Spacing; OK keeps the type where it is), type on a path's start, centre and end brackets (Selection and Direct Selection drag them to set where the type runs, slide it along its path and, across the path, flip it: #429), Character and Paragraph Styles (override-preserving redefine), paragraph attributes per paragraph (alignment, indents, spacing, hyphenation, hanging punctuation, tab stops, paragraph style, direction, mojikumi and leading model; the Paragraph, Tabs and Paragraph Styles panels act on the paragraphs the Type tool's selection or caret touches, Return continues the paragraph's style, threads and SVG keep them), Area Type Options (rows/columns/inset/first baseline, Text Flow by rows or by columns (#4978), vertical Align Top/Center/Bottom/Justify per row/column, reflowing non-rectangular frames and text wrap), threaded text across any closed shapes, Fit Headline, the Every-line Composer for justified and ragged text (paragraph-wide breaks for an even rag, Single-line per object from the Paragraph panel menu or `text.setFormat {composer}`). Area type resizes like Illustrator's type areas: a bounding-box handle drag (Selection tool) or Area Type Options Width/Height size the frame and the text reflows at its size (threads too); Direct Selection drags a frame corner or edge (`text.reshapeArea`), and an anchor of the path of type on a path outside perspective, the type reflowing along it (`text.reshapePath`, #3980). Point type still scales. Auto Size: the frame height follows the text after every edit, in the same undo step (rectangular frames of horizontal type in one row; columns get the least height that holds them; a height set by hand or threading turns it off), and the "Auto Size New Area Type" preference gives it to new area type. Shrink Text to Fit (beyond Illustrator): overflowing area type scales its sizes, leading and baseline shifts down by the largest factor (in 0.1 pt steps of the first run, down to a minimum percentage) that fits, at layout time so every export sees it. `text.areaOptions` and `document.inspect` report overflow and the shrink factor. The Selection tool shows the type widget beside selected type's bounding box (hollow on point type, filled on area type); double-clicking it converts between them (Type › Convert To Area Type / Convert To Point Type), keeping the text and its styles in place. Variable fonts list every named instance as a style of their family (Figtree Light to Black and their italics, Bahnschrift, Segoe UI Variable), and the instance chosen is shaped, drawn, outlined and measured at its axis settings; PDF and SVG embed it as that instance. The Character panel's Variable Font sliders set each axis (see Fonts). Beyond Illustrator: inline graphics (`text.insertInline`, Type ▸ Insert Inline Symbol) set a document symbol in a run of type as one character that flows, wraps and justifies like a glyph (InDesign-style inline objects, e.g. mana symbols in card rules text): sized to a multiple of the font size, centred on the cap height, opening up tall lines, following Redefine Symbol; a character of its run's size for Character Alignment and Top-to-Top leading, placed by the bidi reordering in right-to-left text (a neutral U+FFFC); drawn on the canvas, kept as live text plus a `` in SVG and as vector art in PDF; Backspace removes it whole. Hebrew and Arabic are laid out right to left with the Unicode bidirectional algorithm (embedded Latin and numbers keep their direction, lines put in visual order after wrapping) in point, area and path type, with caret, selection and arrow keys in visual order; each paragraph's direction comes from its first strong character unless Paragraph Direction sets it (Paragraph panel with Show Indic Options, `text.setFormat {direction}`), and new type aligns to the start of its direction. SVG export outlines right-to-left text. Arabic typefaces: builds made with a craft-fonts checkout that holds its Arabic families (Noto Sans Arabic, the Arabic fallback, plus Cairo, Tajawal, Almarai, IBM Plex Sans Arabic, Noto Kufi Arabic, Amiri, Alexandria, Fustat, Reem Kufi, Aref Ruqaa and Gulzar once craft-fonts adds them, storytold/craft-fonts#11; releases get them when their craft-fonts pin moves) list them in the font menus on desktop; the web build fetches them from beside the wasm (Noto Sans Arabic before start, so Arabic never shows as boxes, the rest in the background). Font Size and Leading are combo boxes in the Character and Properties panels (Font Size in the Control bar too, beside the Character link, font family and style): type, step with ↑/↓ or the spinner, scrub the label, or pick a preset (6–72 pt, shown in the type unit; Leading also Auto), each pick one undo step; Kerning and Tracking list -100…200; a size, leading, kerning or tracking the selected text doesn't share shows blank (#458). Type › Bold and Italic (beyond Illustrator, as page layout apps have them; Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text) switch the selected type to its family's own Bold or Italic face and back, keeping its width (a Narrow face stays Narrow); a family without that face says so (#724). The commands that make or restyle type (and the MCP `add_text` tool) say in their result when area type does not fit its frame, and `add_text` takes Auto Size and Shrink Text to Fit (#1500, fixes #1471). Cmd+Shift+C (Align center) works on Windows and Linux too (#1940). A double-click on area type's bottom middle handle turns on Auto Size (#1940). Print and Print Preview warn about the area type and type on a path that prints without the text that doesn't fit, and give their ids. A tab stop in the Tabs panel drags from where the press landed, a stop just added included (#1487, fixes #1458). Area type in a non-rectangular shape moves a word too wide for the top lines down to the first line that holds it instead of breaking it (#1617, fixes #1615). Stylistic sets (ss01–ss20), stylistic and titling alternates, proportional and lining figures, and superscript and subscript glyphs can be turned on as OpenType features of a character style (`text.setStyle {features}`), and the OpenType panel keeps them (#1611, fixes #1607). Escape while typing ends the edit, keeps the type selected and switches back to the tool that was active before type editing began (kept while clicks move editing to other text), or to the Selection tool when there was none (#1802, #1686, #1656). Select › Same matches type on its characters' fill and stroke (#1746, #1737). `text.create`, `text.createInPath` and the MCP `add_text` tool take `justify`, so point type can be centred on or end at its point (#1752, #1748). - The Character panel menu's Show Font Height Options shows a Set Font Height Reference dropdown under Font Size (`text.setFormat` or `text.setRangeStyle` `{fontHeightReference}`). With Em Box, the default, the font size is the size of the em square; with Cap Height, x-Height or ICF Box, the characters are set at the em size at which the font's OS/2 cap height, its x height or the height of its ideographic character face equals the font size. With a font that has no such value, or one outside 0.1 to 2 em, the size measures the em box. SVG, PDF and legacy `.ai` export and the CSS Properties rules write the em size the characters are drawn at, and DXF export writes 0.7 times that em size as each TEXT entity's height (group 40, the height of its capitals). Behavior reference: [Vary font height](https://web.archive.org/web/20251118174722/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/vary-font-height.html). + The Character panel menu's Show Font Height Options shows a Set Font Height Reference dropdown under Font Size (`text.setFormat` or `text.setRangeStyle` `{fontHeightReference}`). Each reference shows an icon of the height it measures before its name, in the open list and in the closed field (#6304): a frame around a letter for Em Box, a bar from the baseline to the top of a capital for Cap Height and of a lowercase x for x-Height, and a frame inset in the em square for ICF Box. With Em Box, the default, the font size is the size of the em square; with Cap Height, x-Height or ICF Box, the characters are set at the em size at which the font's OS/2 cap height, its x height or the height of its ideographic character face equals the font size. With a font that has no such value, or one outside 0.1 to 2 em, the size measures the em box. SVG, PDF and legacy `.ai` export and the CSS Properties rules write the em size the characters are drawn at, and DXF export writes 0.7 times that em size as each TEXT entity's height (group 40, the height of its capitals). Behavior reference: [Vary font height](https://web.archive.org/web/20251118174722/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/vary-font-height.html). The Every-line Composer composes a paragraph whose characters differ in line metrics (type size, font, leading or vertical scale, or an inline graphic taller than the text) in rectangular area type, rows and columns included. In area type of another shape, or with text wrap, such a paragraph is broken with the Single-line Composer. The Character panel's Small Caps, Superscript and Subscript buttons and the panel menu's items of the same names turn small caps and the superscript or subscript position on and off for the selected type, or for the text the Type tool has selected (`text.setFormat` or `text.setRangeStyle` `{smallCaps, position}`), in Document Setup's proportions. Turning Superscript on turns Subscript off, and turning Subscript on turns Superscript off. A button or item shows as on when the first selected character (at a caret, the character before it) has its attribute. The Character panel's Anti-aliasing Method dropdown, under Character Rotation, sets how the edges of the selected type objects are drawn as pixels (`text.setFormat {antiAlias}`), for the whole object, with each frame of a thread keeping its own method and graph labels keeping it when the graph is drawn again, on the canvas, in raster exports and when the type is rasterized. VectorCraft defines the methods: Sharp, the default, draws edges as before; None paints a pixel only where the type covers at least half of it; Crisp shows a pixel covered by a share c at `2c - 0.5`, and Strong shows it at `2c`, each kept between 0 and 1. SVG, PDF and EPS export are unchanged. Scores and hours unchanged. @@ -2168,6 +2168,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s | Date | Change | Summary | |---|---|---| +| 2026-11-13 | minor | Character panel: the Set Font Height Reference dropdown shows an icon of the height each reference measures before its name, in the open list and in the closed field (#6304). Scores and estimates unchanged. | | 2026-11-12 | minor | Object › Rasterize with the Grayscale colour model makes a grayscale image (one gray channel, with alpha over a transparent background), so the Properties panel says Grayscale and a fill color colorizes it; Bitmap makes a 1-bit image (a TIFF), or a black-and-white gray image with alpha over a transparent background, since 1-bit gray with alpha is no format readers take. Both were RGB images of gray pixels (#6969). Scores and estimates unchanged. | | 2026-11-12 | minor | `.ai` and EPS import: type in an envelope with Distort Appearance is also tried mapped from the box of what it paints (its strokes' miter tips, and strokes a Transform effect moves, included), after its glyphs grown by half its strokes' weight; `.ai` import evidence updated (#6824); 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. | From 3792561e1a0c01234857ad3bad22ceaaf6c779cb Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 00:51:37 -0800 Subject: [PATCH 09/97] Learn: View more opens a tutorial's practice sample, and Next Up shows the next tutorial Under a tutorial's steps, View more opens the tutorial's practice sample as a new untitled document, which becomes the active one; open documents stay as they are, and Save asks where to save the copy. Each of the 12 tutorials has its own small SVG drawing in panels/learn/samples.rs, read only when View more is pressed. Once the sample is open, Finish tutorial marks the tutorial finished. A finished tutorial shows Next Up with the next tutorial's title in the same skill, and Next Up shows that tutorial without opening its sample. After a skill's last tutorial the panel says so and shows Back to skills; Next Up never moves to another skill. Finished tutorials are kept while the app runs; the skill rings and counts do not show them yet. The help page's short video is not part of this change. - learn.openSample, learn.finish and learn.next run the three controls and are listed in the UI command catalogs; a missing, non-string or unknown tutorial id, or a step out of order, is an error and changes nothing. - New labels and messages have rows in every catalog. - Docs: docs/mcp.md, docs/target-app-parity.md, docs/ui-parity.md and the ASSETS.md line for art generated in code. --- ASSETS.md | 2 +- crates/mcp/src/ui_catalog.rs | 18 ++ crates/ui-egui/src/i18n/bg.tsv | 12 ++ crates/ui-egui/src/i18n/bs.tsv | 12 ++ crates/ui-egui/src/i18n/ca.tsv | 12 ++ crates/ui-egui/src/i18n/cs.tsv | 12 ++ crates/ui-egui/src/i18n/da.tsv | 12 ++ crates/ui-egui/src/i18n/de.tsv | 12 ++ crates/ui-egui/src/i18n/el.tsv | 12 ++ crates/ui-egui/src/i18n/es.tsv | 12 ++ crates/ui-egui/src/i18n/fi.tsv | 12 ++ crates/ui-egui/src/i18n/fr.tsv | 12 ++ crates/ui-egui/src/i18n/hi.tsv | 12 ++ crates/ui-egui/src/i18n/hu.tsv | 12 ++ crates/ui-egui/src/i18n/id.tsv | 12 ++ crates/ui-egui/src/i18n/it.tsv | 12 ++ crates/ui-egui/src/i18n/ja.tsv | 12 ++ crates/ui-egui/src/i18n/ka.tsv | 12 ++ crates/ui-egui/src/i18n/ko.tsv | 12 ++ crates/ui-egui/src/i18n/nl.tsv | 12 ++ crates/ui-egui/src/i18n/pl.tsv | 12 ++ crates/ui-egui/src/i18n/pt-br.tsv | 12 ++ crates/ui-egui/src/i18n/ru.tsv | 12 ++ crates/ui-egui/src/i18n/sv.tsv | 12 ++ crates/ui-egui/src/i18n/tr.tsv | 12 ++ crates/ui-egui/src/i18n/uk.tsv | 12 ++ crates/ui-egui/src/i18n/vi.tsv | 12 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 12 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 12 ++ crates/ui-egui/src/menus/ui_commands.rs | 18 ++ crates/ui-egui/src/panels/learn.rs | 135 ++++++++++++- crates/ui-egui/src/panels/learn/catalog.rs | 7 + crates/ui-egui/src/panels/learn/samples.rs | 103 ++++++++++ crates/ui-egui/src/panels/learn/tests.rs | 216 ++++++++++++++++++++- crates/ui-egui/src/state.rs | 5 + docs/mcp.md | 20 +- docs/target-app-parity.md | 1 + docs/ui-parity.md | 2 +- 38 files changed, 837 insertions(+), 14 deletions(-) create mode 100644 crates/ui-egui/src/panels/learn/samples.rs diff --git a/ASSETS.md b/ASSETS.md index 5658882d48..dbdd8b84d8 100644 --- a/ASSETS.md +++ b/ASSETS.md @@ -20,7 +20,7 @@ Every non-code asset in this repository (icons, images, fonts, example art, colo **Policy (mandatory):** VectorCraft contains **no Adobe iconography, images, artwork, presets, swatch/brush/symbol/pattern libraries or ICC profiles**. Every asset is either original work by VectorCraft contributors, or third-party material under an open licence (OSI open source, public domain / CC0, or Creative Commons that allows redistribution), and it is attributed below. Screenshots of Adobe software are never committed. The one exception is the ArtCraft name, wordmark and logos in `docs/brand/`: ArtCraft trademarks, not open source, usable only under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). -Generated-in-code art is original and has no file to list. This covers the default swatches, brushes, symbols, patterns, graphic styles, image-trace presets and the vector tool cursors (`crates/ui-egui/src/cursors.rs`). +Generated-in-code art is original and has no file to list. This covers the default swatches, brushes, symbols, patterns, graphic styles, image-trace presets, the vector tool cursors (`crates/ui-egui/src/cursors.rs`) and the Learn panel's practice samples (`crates/ui-egui/src/panels/learn/samples.rs`). **Font files are never committed here.** Fonts shared by the Crafting Apps live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts). The small Latin UI/document fonts below predate that rule and stay. diff --git a/crates/mcp/src/ui_catalog.rs b/crates/mcp/src/ui_catalog.rs index 342feb568c..bffe47bfa7 100644 --- a/crates/mcp/src/ui_catalog.rs +++ b/crates/mcp/src/ui_catalog.rs @@ -658,6 +658,24 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{tool: a tool id, as tool.select takes} mark where that tool is for 3 seconds without choosing it, as the link on a tool's name in a Learn tutorial step does: its toolbar button, a row of its collapsed group's flyout (which opens), its group's torn-off strip (which comes to the front), or, when no shown toolbar holds it, its row in the All Tools drawer (which opens); a Tools panel hidden with Window › Tools shows again; Escape or the next locate removes the mark; a missing, non-string or unknown id is an error and changes nothing; returns {tool, location: toolbar|flyout|strip|allTools|unavailable, toolbar: the name of the toolbar made with New Toolbar that holds it, or null}, with reason when Presentation Mode or Tab hides the toolbars", ), + ( + "learn.openSample", + "Open Tutorial Sample", + "", + "{tutorial: id (see learn.list)} open that tutorial's practice sample as a new untitled document, which becomes the active one, and move the Learn view to the tutorial, as its View more button does; each call opens another copy and open documents stay as they are; a missing, non-string or unknown id is an error and changes nothing; returns {tutorial, index, title} of the new document", + ), + ( + "learn.finish", + "Finish Tutorial", + "", + "{tutorial: id (see learn.list)} mark a tutorial whose sample learn.openSample opened as finished, as its Finish tutorial button does; finishing it again keeps one entry; a missing, non-string or unknown id, or a tutorial whose sample was not opened, is an error and changes nothing; returns {tutorial, finished: true, next: id of the next tutorial in its skill, or null after the skill's last one}", + ), + ( + "learn.next", + "Next Tutorial", + "", + "{tutorial: id (see learn.list)} show the Learn panel on the tutorial after a finished one in the same skill, as its Next Up button does, without opening a sample; a missing, non-string or unknown id, a tutorial not finished, or a skill's last tutorial is an error and changes nothing; returns {skill, tutorial, title} of the next tutorial", + ), ("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"), ( "window.workspace", diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 3828d9ba2d..0cc6e106ce 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -6744,6 +6744,18 @@ breakText Glyphs Глифи Show where this tool is Показване къде е този инструмент @msg missing `tutorial` (see learn.list) липсва `tutorial` (вижте learn.list) @msg unknown tutorial `{id}` (see learn.list) непознат урок `{id}` (вижте learn.list) + View more Вижте повече + Finish tutorial Завършване на урока + Next Up Следващ + You finished the last tutorial in this skill. Завършихте последния урок в това умение. + Back to skills Обратно към уменията + Open Tutorial Sample Отваряне на примерния файл на урока + Finish Tutorial Завършване на урок + Next Tutorial Следващ урок +@msg tutorial `{id}` has no practice sample урокът `{id}` няма примерен файл за упражнение +@msg open the practice sample of `{id}` first (see learn.openSample) първо отворете примерния файл за упражнение на `{id}` (вижте learn.openSample) +@msg finish `{id}` first (see learn.finish) първо завършете `{id}` (вижте learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` е последният урок в своето умение # Deferred plug-in effects Refresh Deferred Effects Обновяване на отложените ефекти diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 81fee3a079..2df8131afe 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -6798,6 +6798,18 @@ shape Open Otvoren Show where this tool is Prikaži gdje je ovaj alat @msg missing `tutorial` (see learn.list) nedostaje `tutorial` (vidi learn.list) @msg unknown tutorial `{id}` (see learn.list) nepoznat tutorijal `{id}` (vidi learn.list) + View more Prikaži više + Finish tutorial Završi tutorijal + Next Up Sljedeće + You finished the last tutorial in this skill. Završili ste posljednji tutorijal u ovoj vještini. + Back to skills Nazad na vještine + Open Tutorial Sample Otvori uzorak tutorijala + Finish Tutorial Završi tutorijal + Next Tutorial Sljedeći tutorijal +@msg tutorial `{id}` has no practice sample tutorijal `{id}` nema uzorak za vježbu +@msg open the practice sample of `{id}` first (see learn.openSample) prvo otvorite uzorak za vježbu tutorijala `{id}` (vidi learn.openSample) +@msg finish `{id}` first (see learn.finish) prvo završite `{id}` (vidi learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` je posljednji tutorijal u svojoj vještini # Deferred plug-in effects Refresh Deferred Effects Osvježi odgođene efekte diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 02ae0f4a09..71959a3767 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -7321,6 +7321,18 @@ breakText Glyphs Glifs Show where this tool is Mostra on és aquesta eina @msg missing `tutorial` (see learn.list) falta `tutorial` (vegeu learn.list) @msg unknown tutorial `{id}` (see learn.list) tutorial desconegut `{id}` (vegeu learn.list) + View more Mostra’n més + Finish tutorial Acaba el tutorial + Next Up A continuació + You finished the last tutorial in this skill. Heu acabat l’últim tutorial d’aquesta habilitat. + Back to skills Torna a les habilitats + Open Tutorial Sample Obre la mostra del tutorial + Finish Tutorial Acaba el tutorial + Next Tutorial Tutorial següent +@msg tutorial `{id}` has no practice sample el tutorial `{id}` no té cap mostra de pràctica +@msg open the practice sample of `{id}` first (see learn.openSample) obriu primer la mostra de pràctica de `{id}` (vegeu learn.openSample) +@msg finish `{id}` first (see learn.finish) acabeu primer `{id}` (vegeu learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` és l’últim tutorial de la seva habilitat # Deferred plug-in effects Refresh Deferred Effects Actualitza els efectes ajornats diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 7e3794e8a1..ca6c1cb875 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -7340,6 +7340,18 @@ breakText Glyphs Glyfy Show where this tool is Zobrazit, kde je tento nástroj @msg missing `tutorial` (see learn.list) chybí `tutorial` (viz learn.list) @msg unknown tutorial `{id}` (see learn.list) neznámý výukový kurz `{id}` (viz learn.list) + View more Zobrazit více + Finish tutorial Dokončit výukový kurz + Next Up Další + You finished the last tutorial in this skill. Dokončili jste poslední výukový kurz této dovednosti. + Back to skills Zpět na dovednosti + Open Tutorial Sample Otevřít ukázku výukového kurzu + Finish Tutorial Dokončit výukový kurz + Next Tutorial Další výukový kurz +@msg tutorial `{id}` has no practice sample výukový kurz `{id}` nemá cvičnou ukázku +@msg open the practice sample of `{id}` first (see learn.openSample) nejprve otevřete cvičnou ukázku kurzu `{id}` (viz learn.openSample) +@msg finish `{id}` first (see learn.finish) nejprve dokončete `{id}` (viz learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` je poslední výukový kurz své dovednosti # Deferred plug-in effects Refresh Deferred Effects Obnovit odložené efekty diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index f7011e23bf..70411cbd00 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -6760,6 +6760,18 @@ breakText Glyphs Glyffer Show where this tool is Vis, hvor dette værktøj er @msg missing `tutorial` (see learn.list) `tutorial` mangler (se learn.list) @msg unknown tutorial `{id}` (see learn.list) ukendt vejledning `{id}` (se learn.list) + View more Vis mere + Finish tutorial Afslut vejledning + Next Up Næste + You finished the last tutorial in this skill. Du har afsluttet den sidste vejledning i denne færdighed. + Back to skills Tilbage til færdigheder + Open Tutorial Sample Åbn eksempel til vejledning + Finish Tutorial Afslut vejledning + Next Tutorial Næste vejledning +@msg tutorial `{id}` has no practice sample vejledningen `{id}` har intet øvelseseksempel +@msg open the practice sample of `{id}` first (see learn.openSample) åbn først øvelseseksemplet til `{id}` (se learn.openSample) +@msg finish `{id}` first (see learn.finish) afslut først `{id}` (se learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` er den sidste vejledning i sin færdighed # Deferred plug-in effects Refresh Deferred Effects Opdatér udskudte effekter diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 4174537f87..eceefc5fb0 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -6935,6 +6935,18 @@ breakText Glyphs Glyphen Show where this tool is Zeigen, wo sich dieses Werkzeug befindet @msg missing `tutorial` (see learn.list) `tutorial` fehlt (siehe learn.list) @msg unknown tutorial `{id}` (see learn.list) unbekanntes Lernprogramm `{id}` (siehe learn.list) + View more Mehr anzeigen + Finish tutorial Lernprogramm abschließen + Next Up Als Nächstes + You finished the last tutorial in this skill. Sie haben das letzte Lernprogramm dieser Fertigkeit abgeschlossen. + Back to skills Zurück zu den Fertigkeiten + Open Tutorial Sample Übungsdatei des Lernprogramms öffnen + Finish Tutorial Lernprogramm abschließen + Next Tutorial Nächstes Lernprogramm +@msg tutorial `{id}` has no practice sample das Lernprogramm `{id}` hat keine Übungsdatei +@msg open the practice sample of `{id}` first (see learn.openSample) öffnen Sie zuerst die Übungsdatei von `{id}` (siehe learn.openSample) +@msg finish `{id}` first (see learn.finish) schließen Sie zuerst `{id}` ab (siehe learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` ist das letzte Lernprogramm seiner Fertigkeit # Deferred plug-in effects Refresh Deferred Effects Zurückgestellte Effekte aktualisieren diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index bde2fbee78..79e834ebee 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -7292,6 +7292,18 @@ breakText Glyphs Γλύφοι Show where this tool is Εμφάνιση της θέσης αυτού του εργαλείου @msg missing `tutorial` (see learn.list) λείπει το `tutorial` (δείτε learn.list) @msg unknown tutorial `{id}` (see learn.list) άγνωστος οδηγός εκμάθησης `{id}` (δείτε learn.list) + View more Προβολή περισσότερων + Finish tutorial Ολοκλήρωση οδηγού + Next Up Επόμενο + You finished the last tutorial in this skill. Ολοκληρώσατε τον τελευταίο οδηγό εκμάθησης αυτής της δεξιότητας. + Back to skills Πίσω στις δεξιότητες + Open Tutorial Sample Άνοιγμα δείγματος οδηγού εκμάθησης + Finish Tutorial Ολοκλήρωση οδηγού εκμάθησης + Next Tutorial Επόμενος οδηγός εκμάθησης +@msg tutorial `{id}` has no practice sample ο οδηγός εκμάθησης `{id}` δεν έχει δείγμα εξάσκησης +@msg open the practice sample of `{id}` first (see learn.openSample) ανοίξτε πρώτα το δείγμα εξάσκησης του `{id}` (δείτε learn.openSample) +@msg finish `{id}` first (see learn.finish) ολοκληρώστε πρώτα το `{id}` (δείτε learn.finish) +@msg `{id}` is the last tutorial in its skill το `{id}` είναι ο τελευταίος οδηγός εκμάθησης της δεξιότητάς του # Deferred plug-in effects Refresh Deferred Effects Ανανέωση εφέ σε αναμονή diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index ff28d9dfc5..905221dc68 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -7283,6 +7283,18 @@ breakText Glyphs Glifos Show where this tool is Mostrar dónde está esta herramienta @msg missing `tutorial` (see learn.list) falta `tutorial` (consulta learn.list) @msg unknown tutorial `{id}` (see learn.list) tutorial desconocido `{id}` (consulta learn.list) + View more Ver más + Finish tutorial Terminar tutorial + Next Up A continuación + You finished the last tutorial in this skill. Has terminado el último tutorial de esta habilidad. + Back to skills Volver a las habilidades + Open Tutorial Sample Abrir muestra del tutorial + Finish Tutorial Terminar tutorial + Next Tutorial Tutorial siguiente +@msg tutorial `{id}` has no practice sample el tutorial `{id}` no tiene muestra de práctica +@msg open the practice sample of `{id}` first (see learn.openSample) abre primero la muestra de práctica de `{id}` (consulta learn.openSample) +@msg finish `{id}` first (see learn.finish) termina primero `{id}` (consulta learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` es el último tutorial de su habilidad # Deferred plug-in effects Refresh Deferred Effects Actualizar efectos aplazados diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index bb74188ed8..2227a31c33 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -7375,6 +7375,18 @@ breakText Glyphs Merkit Show where this tool is Näytä, missä tämä työkalu on @msg missing `tutorial` (see learn.list) `tutorial` puuttuu (ks. learn.list) @msg unknown tutorial `{id}` (see learn.list) tuntematon opetusohjelma `{id}` (ks. learn.list) + View more Näytä lisää + Finish tutorial Viimeistele opetusohjelma + Next Up Seuraavaksi + You finished the last tutorial in this skill. Viimeistelit tämän taidon viimeisen opetusohjelman. + Back to skills Takaisin taitoihin + Open Tutorial Sample Avaa opetusohjelman harjoitustiedosto + Finish Tutorial Viimeistele opetusohjelma + Next Tutorial Seuraava opetusohjelma +@msg tutorial `{id}` has no practice sample opetusohjelmalla `{id}` ei ole harjoitustiedostoa +@msg open the practice sample of `{id}` first (see learn.openSample) avaa ensin opetusohjelman `{id}` harjoitustiedosto (ks. learn.openSample) +@msg finish `{id}` first (see learn.finish) viimeistele ensin `{id}` (ks. learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` on taitonsa viimeinen opetusohjelma # Deferred plug-in effects Refresh Deferred Effects Päivitä lykätyt tehosteet diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 09c9c35b0d..22d5e3d376 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -7335,6 +7335,18 @@ breakText Glyphs Glyphes Show where this tool is Afficher l'emplacement de cet outil @msg missing `tutorial` (see learn.list) `tutorial` manquant (voir learn.list) @msg unknown tutorial `{id}` (see learn.list) tutoriel inconnu `{id}` (voir learn.list) + View more Voir plus + Finish tutorial Terminer le tutoriel + Next Up À suivre + You finished the last tutorial in this skill. Vous avez terminé le dernier tutoriel de cette compétence. + Back to skills Retour aux compétences + Open Tutorial Sample Ouvrir le fichier d’exercice du tutoriel + Finish Tutorial Terminer le tutoriel + Next Tutorial Tutoriel suivant +@msg tutorial `{id}` has no practice sample le tutoriel `{id}` n’a pas de fichier d’exercice +@msg open the practice sample of `{id}` first (see learn.openSample) ouvrez d’abord le fichier d’exercice de `{id}` (voir learn.openSample) +@msg finish `{id}` first (see learn.finish) terminez d’abord `{id}` (voir learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` est le dernier tutoriel de sa compétence # Deferred plug-in effects Refresh Deferred Effects Actualiser les effets différés diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index 9aa4bd7b99..996e534ef0 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -8733,6 +8733,18 @@ theme Appearance अपीयरेंस Show where this tool is दिखाएँ कि यह टूल कहाँ है @msg missing `tutorial` (see learn.list) लापता `tutorial` (learn.list देखें) @msg unknown tutorial `{id}` (see learn.list) अज्ञात ट्यूटोरियल `{id}` (learn.list देखें) + View more और देखें + Finish tutorial ट्यूटोरियल पूरा करें + Next Up आगे + You finished the last tutorial in this skill. आपने इस कौशल का अंतिम ट्यूटोरियल पूरा कर लिया है। + Back to skills कौशल पर वापस जाएँ + Open Tutorial Sample ट्यूटोरियल का नमूना खोलें + Finish Tutorial ट्यूटोरियल पूरा करें + Next Tutorial अगला ट्यूटोरियल +@msg tutorial `{id}` has no practice sample ट्यूटोरियल `{id}` का कोई अभ्यास नमूना नहीं है +@msg open the practice sample of `{id}` first (see learn.openSample) पहले `{id}` का अभ्यास नमूना खोलें (learn.openSample देखें) +@msg finish `{id}` first (see learn.finish) पहले `{id}` पूरा करें (learn.finish देखें) +@msg `{id}` is the last tutorial in its skill `{id}` अपने कौशल का अंतिम ट्यूटोरियल है # Deferred plug-in effects Refresh Deferred Effects स्थगित इफ़ेक्ट्स रिफ्रेश करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index af6f586880..5c38da2fb4 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -7324,6 +7324,18 @@ breakText Glyphs Glifák Show where this tool is Megmutatja, hol van ez az eszköz @msg missing `tutorial` (see learn.list) hiányzik: `tutorial` (lásd: learn.list) @msg unknown tutorial `{id}` (see learn.list) ismeretlen oktatóanyag: `{id}` (lásd: learn.list) + View more Továbbiak + Finish tutorial Oktatóanyag befejezése + Next Up Következő + You finished the last tutorial in this skill. Befejezte a készség utolsó oktatóanyagát. + Back to skills Vissza a készségekhez + Open Tutorial Sample Oktatóanyag mintafájljának megnyitása + Finish Tutorial Oktatóanyag befejezése + Next Tutorial Következő oktatóanyag +@msg tutorial `{id}` has no practice sample nincs gyakorló mintafájl ehhez az oktatóanyaghoz: `{id}` +@msg open the practice sample of `{id}` first (see learn.openSample) előbb nyissa meg a gyakorló mintafájlt ehhez: `{id}` (lásd: learn.openSample) +@msg finish `{id}` first (see learn.finish) előbb fejezze be ezt: `{id}` (lásd: learn.finish) +@msg `{id}` is the last tutorial in its skill a készsége utolsó oktatóanyaga: `{id}` # Deferred plug-in effects Refresh Deferred Effects Elhalasztott effektusok frissítése diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index e0ba31ecd5..75b03775f3 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -6760,6 +6760,18 @@ breakText Glyphs Glif Show where this tool is Tampilkan letak alat ini @msg missing `tutorial` (see learn.list) `tutorial` tidak ada (lihat learn.list) @msg unknown tutorial `{id}` (see learn.list) tutorial `{id}` tidak dikenal (lihat learn.list) + View more Lihat selengkapnya + Finish tutorial Selesaikan tutorial + Next Up Berikutnya + You finished the last tutorial in this skill. Anda telah menyelesaikan tutorial terakhir dalam keterampilan ini. + Back to skills Kembali ke keterampilan + Open Tutorial Sample Buka contoh tutorial + Finish Tutorial Selesaikan tutorial + Next Tutorial Tutorial berikutnya +@msg tutorial `{id}` has no practice sample tutorial `{id}` tidak memiliki contoh latihan +@msg open the practice sample of `{id}` first (see learn.openSample) buka contoh latihan `{id}` terlebih dahulu (lihat learn.openSample) +@msg finish `{id}` first (see learn.finish) selesaikan `{id}` terlebih dahulu (lihat learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` adalah tutorial terakhir dalam keterampilannya # Deferred plug-in effects Refresh Deferred Effects Segarkan efek yang ditunda diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index ce0bc58ce5..1304d8cf61 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -6969,6 +6969,18 @@ breakText Glyphs Glifi Show where this tool is Mostra dove si trova questo strumento @msg missing `tutorial` (see learn.list) manca `tutorial` (vedi learn.list) @msg unknown tutorial `{id}` (see learn.list) tutorial sconosciuto `{id}` (vedi learn.list) + View more Mostra altro + Finish tutorial Termina il tutorial + Next Up Prossimo + You finished the last tutorial in this skill. Hai terminato l’ultimo tutorial di questa competenza. + Back to skills Torna alle competenze + Open Tutorial Sample Apri l’esempio del tutorial + Finish Tutorial Termina il tutorial + Next Tutorial Tutorial successivo +@msg tutorial `{id}` has no practice sample il tutorial `{id}` non ha un esempio di esercitazione +@msg open the practice sample of `{id}` first (see learn.openSample) apri prima l’esempio di esercitazione di `{id}` (vedi learn.openSample) +@msg finish `{id}` first (see learn.finish) termina prima `{id}` (vedi learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` è l’ultimo tutorial della sua competenza # Deferred plug-in effects Refresh Deferred Effects Aggiorna effetti rimandati diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 1ecce4618a..1d5d882a82 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -7034,6 +7034,18 @@ breakText Glyphs 字形 Show where this tool is このツールの場所を表示 @msg missing `tutorial` (see learn.list) `tutorial` が指定されていません (learn.list を参照) @msg unknown tutorial `{id}` (see learn.list) `{id}` は不明なチュートリアルです (learn.list を参照) + View more 詳細を見る + Finish tutorial チュートリアルを完了 + Next Up 次へ + You finished the last tutorial in this skill. このスキルの最後のチュートリアルを完了しました。 + Back to skills スキルに戻る + Open Tutorial Sample チュートリアルのサンプルを開く + Finish Tutorial チュートリアルを完了 + Next Tutorial 次のチュートリアル +@msg tutorial `{id}` has no practice sample チュートリアル `{id}` には練習用サンプルがありません +@msg open the practice sample of `{id}` first (see learn.openSample) 先に `{id}` の練習用サンプルを開いてください (learn.openSample を参照) +@msg finish `{id}` first (see learn.finish) 先に `{id}` を完了してください (learn.finish を参照) +@msg `{id}` is the last tutorial in its skill `{id}` はこのスキルの最後のチュートリアルです # Deferred plug-in effects Refresh Deferred Effects 保留中の効果を更新 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 3d6425c373..8e4de4c62d 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -7454,6 +7454,18 @@ font-script Japanese იაპონური Show where this tool is ამ ხელსაწყოს მდებარეობის ჩვენება @msg missing `tutorial` (see learn.list) აკლია `tutorial` (იხ. learn.list) @msg unknown tutorial `{id}` (see learn.list) უცნობი გაკვეთილი `{id}` (იხ. learn.list) + View more მეტის ნახვა + Finish tutorial გაკვეთილის დასრულება + Next Up შემდეგი + You finished the last tutorial in this skill. თქვენ დაასრულეთ ამ უნარის ბოლო გაკვეთილი. + Back to skills უნარებზე დაბრუნება + Open Tutorial Sample გაკვეთილის ნიმუშის გახსნა + Finish Tutorial გაკვეთილის დასრულება + Next Tutorial შემდეგი გაკვეთილი +@msg tutorial `{id}` has no practice sample გაკვეთილს `{id}` არ აქვს სავარჯიშო ნიმუში +@msg open the practice sample of `{id}` first (see learn.openSample) ჯერ გახსენით `{id}` გაკვეთილის სავარჯიშო ნიმუში (იხ. learn.openSample) +@msg finish `{id}` first (see learn.finish) ჯერ დაასრულეთ `{id}` (იხ. learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` თავისი უნარის ბოლო გაკვეთილია # Deferred plug-in effects Refresh Deferred Effects გადადებული ეფექტების განახლება diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index fb87488d64..7d8f77477b 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -6930,6 +6930,18 @@ breakText Glyphs 글리프 Show where this tool is 이 도구의 위치 표시 @msg missing `tutorial` (see learn.list) `tutorial`이 없습니다(learn.list 참조) @msg unknown tutorial `{id}` (see learn.list) 알 수 없는 튜토리얼 `{id}`(learn.list 참조) + View more 자세히 보기 + Finish tutorial 튜토리얼 완료 + Next Up 다음 + You finished the last tutorial in this skill. 이 기술의 마지막 튜토리얼을 완료했습니다. + Back to skills 기술로 돌아가기 + Open Tutorial Sample 튜토리얼 샘플 열기 + Finish Tutorial 튜토리얼 완료 + Next Tutorial 다음 튜토리얼 +@msg tutorial `{id}` has no practice sample 튜토리얼 `{id}`에 연습용 샘플이 없습니다 +@msg open the practice sample of `{id}` first (see learn.openSample) 먼저 `{id}`의 연습용 샘플을 여세요(learn.openSample 참조) +@msg finish `{id}` first (see learn.finish) 먼저 `{id}`을(를) 완료하세요(learn.finish 참조) +@msg `{id}` is the last tutorial in its skill `{id}`은(는) 해당 기술의 마지막 튜토리얼입니다 # Deferred plug-in effects Refresh Deferred Effects 보류된 효과 새로 고침 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index b3388fa82a..db793697a6 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -6811,6 +6811,18 @@ breakText Glyphs Glyphs Show where this tool is Tonen waar dit gereedschap zich bevindt @msg missing `tutorial` (see learn.list) `tutorial` ontbreekt (zie learn.list) @msg unknown tutorial `{id}` (see learn.list) onbekende tutorial `{id}` (zie learn.list) + View more Meer weergeven + Finish tutorial Tutorial voltooien + Next Up Hierna + You finished the last tutorial in this skill. Je hebt de laatste tutorial van deze vaardigheid voltooid. + Back to skills Terug naar vaardigheden + Open Tutorial Sample Oefenbestand van tutorial openen + Finish Tutorial Tutorial voltooien + Next Tutorial Volgende tutorial +@msg tutorial `{id}` has no practice sample tutorial `{id}` heeft geen oefenbestand +@msg open the practice sample of `{id}` first (see learn.openSample) open eerst het oefenbestand van `{id}` (zie learn.openSample) +@msg finish `{id}` first (see learn.finish) voltooi eerst `{id}` (zie learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` is de laatste tutorial van zijn vaardigheid # Deferred plug-in effects Refresh Deferred Effects Uitgestelde effecten vernieuwen diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 0729e35c79..372697fdef 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -7196,6 +7196,18 @@ breakText Glyphs Glify Show where this tool is Pokaż, gdzie jest to narzędzie @msg missing `tutorial` (see learn.list) brak `tutorial` (patrz learn.list) @msg unknown tutorial `{id}` (see learn.list) nieznany samouczek `{id}` (patrz learn.list) + View more Pokaż więcej + Finish tutorial Zakończ samouczek + Next Up Następny + You finished the last tutorial in this skill. Ukończono ostatni samouczek tej umiejętności. + Back to skills Powrót do umiejętności + Open Tutorial Sample Otwórz przykład samouczka + Finish Tutorial Zakończ samouczek + Next Tutorial Następny samouczek +@msg tutorial `{id}` has no practice sample samouczek `{id}` nie ma przykładu do ćwiczeń +@msg open the practice sample of `{id}` first (see learn.openSample) najpierw otwórz przykład do ćwiczeń samouczka `{id}` (patrz learn.openSample) +@msg finish `{id}` first (see learn.finish) najpierw zakończ `{id}` (patrz learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` to ostatni samouczek swojej umiejętności # Deferred plug-in effects Refresh Deferred Effects Odśwież odroczone efekty diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 36f778dbd5..728f11dafa 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -7413,6 +7413,18 @@ swatch Yellow Green Verde-amarelado Show where this tool is Mostrar onde está esta ferramenta @msg missing `tutorial` (see learn.list) falta `tutorial` (veja learn.list) @msg unknown tutorial `{id}` (see learn.list) tutorial desconhecido `{id}` (veja learn.list) + View more Ver mais + Finish tutorial Concluir tutorial + Next Up A seguir + You finished the last tutorial in this skill. Você concluiu o último tutorial desta habilidade. + Back to skills Voltar às habilidades + Open Tutorial Sample Abrir Amostra do Tutorial + Finish Tutorial Concluir Tutorial + Next Tutorial Próximo Tutorial +@msg tutorial `{id}` has no practice sample o tutorial `{id}` não tem amostra de prática +@msg open the practice sample of `{id}` first (see learn.openSample) abra primeiro a amostra de prática de `{id}` (veja learn.openSample) +@msg finish `{id}` first (see learn.finish) conclua primeiro `{id}` (veja learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` é o último tutorial da sua habilidade # Deferred plug-in effects Refresh Deferred Effects Atualizar Efeitos Adiados diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 442a9612d4..2f5708c436 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -7429,6 +7429,18 @@ shape Open Открытый Show where this tool is Показать, где находится этот инструмент @msg missing `tutorial` (see learn.list) отсутствует `tutorial` (см. learn.list) @msg unknown tutorial `{id}` (see learn.list) неизвестный урок `{id}` (см. learn.list) + View more Подробнее + Finish tutorial Завершить урок + Next Up Далее + You finished the last tutorial in this skill. Вы завершили последний урок этого навыка. + Back to skills Назад к навыкам + Open Tutorial Sample Открыть пример урока + Finish Tutorial Завершить урок + Next Tutorial Следующий урок +@msg tutorial `{id}` has no practice sample у урока `{id}` нет учебного примера +@msg open the practice sample of `{id}` first (see learn.openSample) сначала откройте учебный пример урока `{id}` (см. learn.openSample) +@msg finish `{id}` first (see learn.finish) сначала завершите `{id}` (см. learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` является последним уроком своего навыка # Deferred plug-in effects Refresh Deferred Effects Обновить отложенные эффекты diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index cc871ad8a3..a05aa652d6 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -8779,6 +8779,18 @@ digits Hindi Hindi Show where this tool is Visa var det här verktyget finns @msg missing `tutorial` (see learn.list) `tutorial` saknas (se learn.list) @msg unknown tutorial `{id}` (see learn.list) okänd självstudie `{id}` (se learn.list) + View more Visa mer + Finish tutorial Slutför självstudien + Next Up Nästa + You finished the last tutorial in this skill. Du har slutfört den sista självstudien i den här färdigheten. + Back to skills Tillbaka till färdigheter + Open Tutorial Sample Öppna övningsexempel för självstudie + Finish Tutorial Slutför självstudie + Next Tutorial Nästa självstudie +@msg tutorial `{id}` has no practice sample självstudien `{id}` har inget övningsexempel +@msg open the practice sample of `{id}` first (see learn.openSample) öppna först övningsexemplet för `{id}` (se learn.openSample) +@msg finish `{id}` first (see learn.finish) slutför först `{id}` (se learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` är den sista självstudien i sin färdighet # Deferred plug-in effects Refresh Deferred Effects Uppdatera uppskjutna effekter diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index ef8773c31b..1c310c7f6e 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -7296,6 +7296,18 @@ breakText Glyphs Glifler Show where this tool is Bu aracın nerede olduğunu göster @msg missing `tutorial` (see learn.list) `tutorial` eksik (bkz. learn.list) @msg unknown tutorial `{id}` (see learn.list) bilinmeyen öğretici `{id}` (bkz. learn.list) + View more Daha fazlasını gör + Finish tutorial Öğreticiyi bitir + Next Up Sıradaki + You finished the last tutorial in this skill. Bu becerinin son öğreticisini bitirdiniz. + Back to skills Becerilere dön + Open Tutorial Sample Öğretici Örneğini Aç + Finish Tutorial Öğreticiyi Bitir + Next Tutorial Sonraki Öğretici +@msg tutorial `{id}` has no practice sample `{id}` öğreticisinin alıştırma örneği yok +@msg open the practice sample of `{id}` first (see learn.openSample) önce `{id}` öğreticisinin alıştırma örneğini açın (bkz. learn.openSample) +@msg finish `{id}` first (see learn.finish) önce `{id}` öğreticisini bitirin (bkz. learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}`, becerisinin son öğreticisidir # Deferred plug-in effects Refresh Deferred Effects Ertelenen Efektleri Yenile diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 0bccbd8cb6..5f7de5c52f 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -6949,6 +6949,18 @@ breakText Glyphs Гліфи Show where this tool is Показати, де розташований цей інструмент @msg missing `tutorial` (see learn.list) відсутній `tutorial` (див. learn.list) @msg unknown tutorial `{id}` (see learn.list) невідомий урок `{id}` (див. learn.list) + View more Докладніше + Finish tutorial Завершити урок + Next Up Далі + You finished the last tutorial in this skill. Ви завершили останній урок цієї навички. + Back to skills Назад до навичок + Open Tutorial Sample Відкрити приклад уроку + Finish Tutorial Завершити урок + Next Tutorial Наступний урок +@msg tutorial `{id}` has no practice sample урок `{id}` не має навчального прикладу +@msg open the practice sample of `{id}` first (see learn.openSample) спочатку відкрийте навчальний приклад уроку `{id}` (див. learn.openSample) +@msg finish `{id}` first (see learn.finish) спочатку завершіть `{id}` (див. learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` є останнім уроком своєї навички # Deferred plug-in effects Refresh Deferred Effects Оновити відкладені ефекти diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 4f084029db..5fad3e2493 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -6950,6 +6950,18 @@ breakText Glyphs Glyph Show where this tool is Hiện vị trí của công cụ này @msg missing `tutorial` (see learn.list) thiếu `tutorial` (xem learn.list) @msg unknown tutorial `{id}` (see learn.list) hướng dẫn không xác định `{id}` (xem learn.list) + View more Xem thêm + Finish tutorial Hoàn tất hướng dẫn + Next Up Tiếp theo + You finished the last tutorial in this skill. Bạn đã hoàn tất hướng dẫn cuối cùng của kỹ năng này. + Back to skills Quay lại kỹ năng + Open Tutorial Sample Mở tệp mẫu của hướng dẫn + Finish Tutorial Hoàn tất hướng dẫn + Next Tutorial Hướng dẫn tiếp theo +@msg tutorial `{id}` has no practice sample hướng dẫn `{id}` không có tệp mẫu thực hành +@msg open the practice sample of `{id}` first (see learn.openSample) hãy mở tệp mẫu thực hành của `{id}` trước (xem learn.openSample) +@msg finish `{id}` first (see learn.finish) hãy hoàn tất `{id}` trước (xem learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` là hướng dẫn cuối cùng trong kỹ năng của nó # Deferred plug-in effects Refresh Deferred Effects Làm mới hiệu ứng bị hoãn diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b04e65a65b..f6e69e53c5 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -7178,6 +7178,18 @@ breakText Glyphs 字形 Show where this tool is 显示此工具的位置 @msg missing `tutorial` (see learn.list) 缺少 `tutorial` (请参阅 learn.list) @msg unknown tutorial `{id}` (see learn.list) 未知教程 `{id}` (请参阅 learn.list) + View more 查看更多 + Finish tutorial 完成教程 + Next Up 下一个 + You finished the last tutorial in this skill. 你已完成此技能的最后一个教程。 + Back to skills 返回技能 + Open Tutorial Sample 打开教程示例 + Finish Tutorial 完成教程 + Next Tutorial 下一个教程 +@msg tutorial `{id}` has no practice sample 教程 `{id}` 没有练习示例 +@msg open the practice sample of `{id}` first (see learn.openSample) 请先打开 `{id}` 的练习示例 (请参阅 learn.openSample) +@msg finish `{id}` first (see learn.finish) 请先完成 `{id}` (请参阅 learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` 是其技能的最后一个教程 # Deferred plug-in effects Refresh Deferred Effects 刷新延迟的效果 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index be62fde8a7..c9d6631fb3 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -6984,6 +6984,18 @@ breakText Glyphs 字符 Show where this tool is 顯示此工具的位置 @msg missing `tutorial` (see learn.list) 未提供 `tutorial` (請參閱 learn.list) @msg unknown tutorial `{id}` (see learn.list) 未知的教學課程 `{id}` (請參閱 learn.list) + View more 檢視更多 + Finish tutorial 完成教學課程 + Next Up 接下來 + You finished the last tutorial in this skill. 你已完成此技能的最後一個教學課程。 + Back to skills 返回技能 + Open Tutorial Sample 開啟教學課程範例 + Finish Tutorial 完成教學課程 + Next Tutorial 下一個教學課程 +@msg tutorial `{id}` has no practice sample 教學課程 `{id}` 沒有練習範例 +@msg open the practice sample of `{id}` first (see learn.openSample) 請先開啟 `{id}` 的練習範例 (請參閱 learn.openSample) +@msg finish `{id}` first (see learn.finish) 請先完成 `{id}` (請參閱 learn.finish) +@msg `{id}` is the last tutorial in its skill `{id}` 是其技能的最後一個教學課程 # Deferred plug-in effects Refresh Deferred Effects 重新整理延後的效果 diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs index 20a2c58314..8e4aaab55e 100644 --- a/crates/ui-egui/src/menus/ui_commands.rs +++ b/crates/ui-egui/src/menus/ui_commands.rs @@ -659,6 +659,24 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{tool: a tool id, as tool.select takes} mark where that tool is for 3 seconds without choosing it, as the link on a tool's name in a Learn tutorial step does: its toolbar button, a row of its collapsed group's flyout (which opens), its group's torn-off strip (which comes to the front), or, when no shown toolbar holds it, its row in the All Tools drawer (which opens); a Tools panel hidden with Window › Tools shows again; Escape or the next locate removes the mark; a missing, non-string or unknown id is an error and changes nothing; returns {tool, location: toolbar|flyout|strip|allTools|unavailable, toolbar: the name of the toolbar made with New Toolbar that holds it, or null}, with reason when Presentation Mode or Tab hides the toolbars", ), + ( + "learn.openSample", + "Open Tutorial Sample", + "", + "{tutorial: id (see learn.list)} open that tutorial's practice sample as a new untitled document, which becomes the active one, and move the Learn view to the tutorial, as its View more button does; each call opens another copy and open documents stay as they are; a missing, non-string or unknown id is an error and changes nothing; returns {tutorial, index, title} of the new document", + ), + ( + "learn.finish", + "Finish Tutorial", + "", + "{tutorial: id (see learn.list)} mark a tutorial whose sample learn.openSample opened as finished, as its Finish tutorial button does; finishing it again keeps one entry; a missing, non-string or unknown id, or a tutorial whose sample was not opened, is an error and changes nothing; returns {tutorial, finished: true, next: id of the next tutorial in its skill, or null after the skill's last one}", + ), + ( + "learn.next", + "Next Tutorial", + "", + "{tutorial: id (see learn.list)} show the Learn panel on the tutorial after a finished one in the same skill, as its Next Up button does, without opening a sample; a missing, non-string or unknown id, a tutorial not finished, or a skill's last tutorial is an error and changes nothing; returns {skill, tutorial, title} of the next tutorial", + ), ("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"), ( "window.workspace", diff --git a/crates/ui-egui/src/panels/learn.rs b/crates/ui-egui/src/panels/learn.rs index cce131c7a3..8941626a2d 100644 --- a/crates/ui-egui/src/panels/learn.rs +++ b/crates/ui-egui/src/panels/learn.rs @@ -3,11 +3,15 @@ //! section with the Quick skill guide card, which opens the Skills tab. The Skills tab lists each //! skill with an empty progress ring and its count of tutorials; a skill lists its tutorials, and a //! tutorial previews its numbered steps. A tool's name in a step is a link that shows where the tool -//! is ([`locate`]). Back goes up one level. `learn.list` lists the tutorials, `learn.show` opens one, -//! `learn.locateTool` shows where a tool is and `window.learn` shows or hides the panel. +//! is ([`locate`]). Back goes up one level. Under the steps, View more opens the tutorial's practice +//! sample as a new untitled document; Finish tutorial then marks it finished, and Next Up shows the +//! next tutorial in the same skill. `learn.list` lists the tutorials, `learn.show` opens one, +//! `learn.locateTool` shows where a tool is, `learn.openSample`, `learn.finish` and `learn.next` run +//! the three practice controls, and `window.learn` shows or hides the panel. pub mod catalog; pub mod locate; +pub mod samples; use egui::{Sense, Stroke, Ui, vec2}; use serde_json::{Value, json}; @@ -35,6 +39,14 @@ pub struct View { pub tutorial: Option<&'static str>, } +/// The tutorials whose samples View more opened, and the tutorials finished, in the order they +/// were finished. Each id is listed once. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Practice { + pub opened: Vec<&'static str>, + pub finished: Vec<&'static str>, +} + pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.horizontal(|ui| { for (tab, label) in [(Tab::ForYou, tl!("For you")), (Tab::Skills, tl!("Skills"))] { @@ -89,6 +101,7 @@ fn skills(app: &mut VectorcraftApp, ui: &mut Ui) { app.ui.learn.tutorial = None; } steps(app, ui, tutorial); + practice(app, ui, tutorial); return; } if let Some(skill) = app.ui.learn.skill.and_then(catalog::skill) { @@ -163,6 +176,49 @@ fn tool_link(ui: &mut Ui, t: &Tokens, name: &str) -> egui::Response { resp.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(tl!("Show where this tool is")) } +/// The controls under a tutorial's steps: View more; Finish tutorial once its sample is open; and +/// once it is finished, Next Up with the next tutorial's title, or for the skill's last tutorial a +/// line saying so and Back to skills. +fn practice(app: &mut VectorcraftApp, ui: &mut Ui, tutorial: &'static catalog::Tutorial) { + let t = Tokens::get(ui.ctx()); + let id = tutorial.id; + let mut run = None; + ui.add_space(6.0); + ui.horizontal_wrapped(|ui| { + if ui.button(tl!("View more")).clicked() { + run = Some("learn.openSample"); + } + let done = &app.ui.learn_practice; + if done.opened.contains(&id) && !done.finished.contains(&id) && ui.button(tl!("Finish tutorial")).clicked() { + run = Some("learn.finish"); + } + }); + if app.ui.learn_practice.finished.contains(&id) { + ui.add_space(6.0); + match catalog::next(id) { + Some(next) => { + ui.horizontal_wrapped(|ui| { + if ui.button(tl!("Next Up")).clicked() { + run = Some("learn.next"); + } + ui.add(egui::Label::new(egui::RichText::new(next.title).color(t.text)).wrap()); + }); + } + None => { + ui.add(egui::Label::new(egui::RichText::new(tl!("You finished the last tutorial in this skill.")).color(t.text_dim)).wrap()); + if ui.button(tl!("Back to skills")).clicked() { + app.ui.learn = View { tab: Tab::Skills, skill: None, tutorial: None }; + } + } + } + } + if let Some(cmd) = run + && let Err(e) = app.run(cmd, json!({ "tutorial": id })) + { + app.status(e); + } +} + /// A clickable row: `title`, and for a skill (`tutorials`) a progress ring before it and the /// count of tutorials done of all after it. No progress is kept yet, so the ring is empty. fn row(ui: &mut Ui, title: &str, tutorials: Option) -> egui::Response { @@ -191,13 +247,17 @@ fn row(ui: &mut Ui, title: &str, tutorials: Option) -> egui::Response { resp.on_hover_cursor(egui::CursorIcon::PointingHand) } -/// The Learn panel's UI commands: `window.learn`, `learn.list`, `learn.show` and `learn.locateTool`. +/// The Learn panel's UI commands: `window.learn`, `learn.list`, `learn.show`, `learn.locateTool`, +/// `learn.openSample`, `learn.finish` and `learn.next`. pub(crate) fn command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option> { Some(match id { "window.learn" => toggle(app, p), "learn.list" => Ok(list()), "learn.show" => open(app, p), "learn.locateTool" => locate::locate_tool(app, p), + "learn.openSample" => open_sample(app, p), + "learn.finish" => finish(app, p), + "learn.next" => next(app, p), _ => return None, }) } @@ -234,18 +294,73 @@ fn list() -> Value { /// `learn.show {tutorial}`: show the panel on the Skills tab with that tutorial's steps → the /// tutorial. A missing or unknown id is an error and changes nothing. fn open(app: &mut VectorcraftApp, p: &Value) -> Result { - let key = "tutorial"; - let id = match p.get(key) { - None | Some(Value::Null) => return Err("missing `tutorial` (see learn.list)".into()), - Some(Value::String(id)) => id.as_str(), - Some(_) => return Err(format!("`{key}` must be a string")), - }; - let (skill, tutorial) = catalog::tutorial(id).ok_or_else(|| format!("unknown tutorial `{id}` (see learn.list)"))?; + let (skill, tutorial) = tutorial_param(p)?; crate::panel_docking::reveal_panel(app, ID)?; app.ui.learn = View { tab: Tab::Skills, skill: Some(skill.id), tutorial: Some(tutorial.id) }; let steps: Vec = tutorial.steps.iter().map(catalog::Step::plain).collect(); Ok(json!({"skill": skill.id, "tutorial": tutorial.id, "title": tutorial.title, "steps": steps})) } +/// The tutorial `p`'s `tutorial` names, with its skill. A missing, non-string or unknown id is an +/// error. +fn tutorial_param(p: &Value) -> Result<(&'static catalog::Skill, &'static catalog::Tutorial), String> { + let key = "tutorial"; + let id = match p.get(key) { + None | Some(Value::Null) => return Err("missing `tutorial` (see learn.list)".into()), + Some(Value::String(id)) => id.as_str(), + Some(_) => return Err(format!("`{key}` must be a string")), + }; + catalog::tutorial(id).ok_or_else(|| format!("unknown tutorial `{id}` (see learn.list)")) +} + +/// `learn.openSample {tutorial}`: View more. Open that tutorial's practice sample as a new untitled +/// document, which becomes the active one, and move the Learn view to the tutorial → `{tutorial, +/// index, title}` of the new document. Each call opens another copy; open documents stay as they +/// are. An id that is not valid, or a sample that does not read, is an error and changes nothing. +fn open_sample(app: &mut VectorcraftApp, p: &Value) -> Result { + let (skill, tutorial) = tutorial_param(p)?; + let id = tutorial.id; + let svg = samples::sample(id).ok_or_else(|| format!("tutorial `{id}` has no practice sample"))?; + let name = format!("{id}.svg"); + let opened = vectorcraft_engine::cmd::fileio::open_template(&mut app.session, &name, svg.as_bytes(), None).map_err(|e| e.to_string())?; + app.sync_views(); + if !app.ui.learn_practice.opened.contains(&id) { + app.ui.learn_practice.opened.push(id); + } + app.ui.learn = View { tab: Tab::Skills, skill: Some(skill.id), tutorial: Some(id) }; + Ok(json!({"tutorial": id, "index": opened["index"], "title": opened["title"]})) +} + +/// `learn.finish {tutorial}`: Finish tutorial. Mark a tutorial whose sample was opened finished → +/// `{tutorial, finished: true, next}`, where `next` is the id of the next tutorial in its skill, or +/// null after the skill's last one. Finishing it again keeps one entry. The Learn view stays as it is. +fn finish(app: &mut VectorcraftApp, p: &Value) -> Result { + let (_, tutorial) = tutorial_param(p)?; + let id = tutorial.id; + let practice = &mut app.ui.learn_practice; + if !practice.opened.contains(&id) { + return Err(format!("open the practice sample of `{id}` first (see learn.openSample)")); + } + if !practice.finished.contains(&id) { + practice.finished.push(id); + } + Ok(json!({"tutorial": id, "finished": true, "next": catalog::next(id).map(|t| t.id)})) +} + +/// `learn.next {tutorial}`: Next Up. Show the panel on the tutorial after a finished one in the +/// same skill → `{skill, tutorial, title}` of that tutorial. It opens no sample. An unfinished +/// tutorial, or a skill's last tutorial, is an error and changes nothing. +fn next(app: &mut VectorcraftApp, p: &Value) -> Result { + let (skill, tutorial) = tutorial_param(p)?; + let id = tutorial.id; + if !app.ui.learn_practice.finished.contains(&id) { + return Err(format!("finish `{id}` first (see learn.finish)")); + } + let after = catalog::next(id).ok_or_else(|| format!("`{id}` is the last tutorial in its skill"))?; + crate::panel_docking::reveal_panel(app, ID)?; + app.ui.learn = View { tab: Tab::Skills, skill: Some(skill.id), tutorial: Some(after.id) }; + Ok(json!({"skill": skill.id, "tutorial": after.id, "title": after.title})) +} + #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/panels/learn/catalog.rs b/crates/ui-egui/src/panels/learn/catalog.rs index 98f0d6f355..57a8f30e31 100644 --- a/crates/ui-egui/src/panels/learn/catalog.rs +++ b/crates/ui-egui/src/panels/learn/catalog.rs @@ -201,3 +201,10 @@ pub fn skill(id: &str) -> Option<&'static Skill> { pub fn tutorial(id: &str) -> Option<(&'static Skill, &'static Tutorial)> { SKILLS.iter().find_map(|s| s.tutorials.iter().find(|t| t.id == id).map(|t| (s, t))) } + +/// The tutorial after the one whose id is `id` in the same skill; none for a skill's last tutorial. +pub fn next(id: &str) -> Option<&'static Tutorial> { + let (skill, _) = tutorial(id)?; + let at = skill.tutorials.iter().position(|t| t.id == id)?; + skill.tutorials.get(at + 1) +} diff --git a/crates/ui-egui/src/panels/learn/samples.rs b/crates/ui-egui/src/panels/learn/samples.rs new file mode 100644 index 0000000000..d5e2758f59 --- /dev/null +++ b/crates/ui-egui/src/panels/learn/samples.rs @@ -0,0 +1,103 @@ +//! The practice samples that a tutorial's View more opens: one small original SVG drawing for each +//! tutorial, with only the art its steps work on. Each one opens as a new untitled document. + +/// The largest sample, in bytes: the catalog test keeps every sample under it. +pub const MAX_SAMPLE_BYTES: usize = 16 * 1024; + +/// Every tutorial's sample, by tutorial id, in the catalog's order. +pub const SAMPLES: &[(&str, &str)] = &[ + ( + "draw-and-color", + r##" + + +"##, + ), + ( + "combine-shapes", + r##" + + + +"##, + ), + ( + "add-headline", + r##" + + +"##, + ), + ( + "pen-paths", + r##" + + + + + +"##, + ), + ( + "smooth-curves", + r##" + + +"##, + ), + ( + "pencil-sketch", + r##" + + +"##, + ), + ( + "fill-and-stroke", + r##" + + +"##, + ), + ( + "gradient", + r##" + + +"##, + ), + ( + "copy-colors", + r##" + + + +"##, + ), + ( + "area-type", + r##" + + +"##, + ), + ( + "type-on-path", + r##" + + +"##, + ), + ( + "outline-type", + r##" + Outline + +"##, + ), +]; + +/// The sample of the tutorial whose id is `id`. +pub fn sample(id: &str) -> Option<&'static str> { + SAMPLES.iter().find(|(tutorial, _)| *tutorial == id).map(|(_, svg)| *svg) +} diff --git a/crates/ui-egui/src/panels/learn/tests.rs b/crates/ui-egui/src/panels/learn/tests.rs index 7014785eda..f302d8f436 100644 --- a/crates/ui-egui/src/panels/learn/tests.rs +++ b/crates/ui-egui/src/panels/learn/tests.rs @@ -4,7 +4,7 @@ use egui::accesskit::Node; use serde_json::json; use vectorcraft_engine::Session; -use super::{ID, Tab, View, catalog, show}; +use super::{ID, Practice, Tab, View, catalog, samples, show}; use crate::VectorcraftApp; use crate::tests_labels::painted_text; @@ -228,6 +228,220 @@ fn learn_show_opens_a_tutorial_and_learn_list_lists_them() { assert_eq!(skills[2]["tutorials"][1], json!({"id": "gradient", "title": "Add a gradient", "steps": 3})); } +/// Every tutorial has one sample of its own, in the catalog's order, each one small and read +/// without warnings into art of its own. +#[test] +fn every_tutorial_has_its_own_readable_sample() { + let ids: Vec<&str> = catalog::SKILLS.iter().flat_map(|s| s.tutorials.iter().map(|t| t.id)).collect(); + let sampled: Vec<&str> = samples::SAMPLES.iter().map(|(id, _)| *id).collect(); + assert_eq!(sampled, ids, "one sample for each tutorial, in the catalog's order"); + let mut texts = HashSet::new(); + for (id, svg) in samples::SAMPLES { + assert!(svg.len() <= samples::MAX_SAMPLE_BYTES, "{id}: {} bytes", svg.len()); + assert!(texts.insert(*svg), "{id} reuses another tutorial's sample"); + let loaded = vectorcraft_engine::cmd::fileio::load(&format!("{id}.svg"), svg.as_bytes()).unwrap_or_else(|e| panic!("{id}: {e}")); + assert!(loaded.warnings.is_empty(), "{id}: {:?}", loaded.warnings); + let mut art = 0; + loaded.doc.walk(|n| art += usize::from(!n.is_layer())); + assert!(art > 0, "{id} has no art"); + } +} + +/// Next Up stays in the tutorial's skill: a skill's last tutorial, the catalog's last one +/// included, has no next tutorial. +#[test] +fn the_next_tutorial_is_the_next_one_in_the_same_skill() { + for skill in catalog::SKILLS { + for pair in skill.tutorials.windows(2) { + assert_eq!(catalog::next(pair[0].id).map(|t| t.id), Some(pair[1].id)); + } + let last = skill.tutorials.last().unwrap(); + assert!(catalog::next(last.id).is_none(), "{} is the last of {}", last.id, skill.id); + } + assert!(catalog::next("nope").is_none()); +} + +/// View more opens the tutorial's sample as a new untitled document beside a modified one, which +/// stays as it was; another tutorial opens another sample, and View more again opens another copy. +#[test] +fn view_more_opens_the_tutorial_s_sample_as_a_new_untitled_document() { + let mut app = app(); + let ctx = context(); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap(); + let work = app.session.documents()[0].doc.node_count(); + let (skill, tutorial) = catalog::tutorial("pen-paths").unwrap(); + app.ui.learn = View { tab: Tab::Skills, skill: Some(skill.id), tutorial: Some(tutorial.id) }; + click(&mut app, &ctx, "View more", show); + let docs = app.session.documents(); + assert_eq!(docs.len(), 2, "{}", app.ui.status); + assert_eq!(app.session.active_index(), Some(1)); + assert_eq!(docs[0].doc.node_count(), work, "the open document stays as it was"); + assert!(docs[1].path.is_none() && docs[1].title().starts_with("Untitled-"), "{}", docs[1].title()); + let pen = docs[1].doc.node_count(); + assert_eq!(app.ui.learn_practice, Practice { opened: vec!["pen-paths"], finished: vec![] }); + + let r = app.run("learn.openSample", json!({"tutorial": "gradient"})).unwrap(); + assert_eq!(r["tutorial"], "gradient"); + assert_eq!(r["index"], 2); + assert_eq!(r["title"], json!(app.session.documents()[2].title())); + assert_ne!(app.session.documents()[2].doc.node_count(), pen, "another tutorial, another sample"); + assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: Some("color"), tutorial: Some("gradient") }); + app.run("learn.openSample", json!({"tutorial": "gradient"})).unwrap(); + assert_eq!(app.session.documents().len(), 4, "View more again opens another copy"); + assert_eq!(app.ui.learn_practice.opened, ["pen-paths", "gradient"]); +} + +/// Finish tutorial shows once the sample is open; finishing shows Next Up with the next +/// tutorial's title, and Next Up shows that tutorial without opening its sample. +#[test] +fn finishing_a_tutorial_offers_next_up_in_its_skill() { + let mut app = app(); + let ctx = context(); + let (skill, tutorial) = catalog::tutorial("pen-paths").unwrap(); + app.ui.learn = View { tab: Tab::Skills, skill: Some(skill.id), tutorial: Some(tutorial.id) }; + let text = painted_text(&mut app, show); + assert!(text.contains("View more") && !text.contains("Finish tutorial") && !text.contains("Next Up"), "{text}"); + click(&mut app, &ctx, "View more", show); + let text = painted_text(&mut app, show); + assert!(text.contains("Finish tutorial") && !text.contains("Next Up"), "{text}"); + click(&mut app, &ctx, "Finish tutorial", show); + assert_eq!(app.ui.learn_practice.finished, ["pen-paths"]); + let text = painted_text(&mut app, show); + assert!(text.contains("Next Up") && text.contains("Draw smooth curves") && !text.contains("Finish tutorial"), "{text}"); + let docs = app.session.documents().len(); + click(&mut app, &ctx, "Next Up", show); + assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: Some("draw"), tutorial: Some("smooth-curves") }); + assert_eq!(app.session.documents().len(), docs, "Next Up opens no sample"); + let text = painted_text(&mut app, show); + assert!(text.contains("View more") && !text.contains("Finish tutorial") && !text.contains("Next Up"), "{text}"); +} + +/// One press and release of `key` in what `draw` draws. +fn key(app: &mut VectorcraftApp, ctx: &egui::Context, key: egui::Key, draw: fn(&mut VectorcraftApp, &mut egui::Ui)) { + let event = |pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + frame(app, ctx, vec![event(true)], draw); + frame(app, ctx, vec![event(false)], draw); +} + +/// Press Tab until the control labeled `name` has the keyboard focus. +fn tab_to(app: &mut VectorcraftApp, ctx: &egui::Context, name: &str, draw: fn(&mut VectorcraftApp, &mut egui::Ui)) { + for _ in 0..32 { + key(app, ctx, egui::Key::Tab, draw); + let at = center(&frame(app, ctx, vec![], draw), name).unwrap_or_else(|| panic!("{name} is drawn")); + if ctx.memory(|m| m.focused()).and_then(|id| ctx.read_response(id)).is_some_and(|r| r.rect.contains(at)) { + return; + } + } + panic!("Tab did not reach {name}"); +} + +/// View more, Finish tutorial and Next Up are reached with Tab and pressed with Enter. +#[test] +fn the_practice_controls_work_from_the_keyboard() { + let mut app = app(); + let ctx = context(); + let (skill, tutorial) = catalog::tutorial("pen-paths").unwrap(); + app.ui.learn = View { tab: Tab::Skills, skill: Some(skill.id), tutorial: Some(tutorial.id) }; + frame(&mut app, &ctx, vec![], show); + tab_to(&mut app, &ctx, "View more", show); + key(&mut app, &ctx, egui::Key::Enter, show); + assert_eq!(app.session.documents().len(), 1, "{}", app.ui.status); + assert_eq!(app.ui.learn_practice.opened, ["pen-paths"]); + tab_to(&mut app, &ctx, "Finish tutorial", show); + key(&mut app, &ctx, egui::Key::Enter, show); + assert_eq!(app.ui.learn_practice.finished, ["pen-paths"]); + tab_to(&mut app, &ctx, "Next Up", show); + key(&mut app, &ctx, egui::Key::Enter, show); + assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: Some("draw"), tutorial: Some("smooth-curves") }); + assert_eq!(app.session.documents().len(), 1, "Next Up opens no sample"); +} + +/// The panel 150 points wide. +fn narrow(app: &mut VectorcraftApp, ui: &mut egui::Ui) { + ui.allocate_ui(egui::vec2(150.0, 800.0), |ui| { + ui.set_max_width(150.0); + show(app, ui); + }); +} + +/// In a narrow panel the practice controls wrap inside it. +#[test] +fn the_practice_controls_fit_a_narrow_panel() { + let mut app = app(); + let ctx = context(); + app.run("learn.openSample", json!({"tutorial": "pen-paths"})).unwrap(); + frame(&mut app, &ctx, vec![], narrow); + let nodes = frame(&mut app, &ctx, vec![], narrow); + for name in ["View more", "Finish tutorial"] { + let node = nodes.iter().find(|n| n.label() == Some(name)).unwrap_or_else(|| panic!("{name} is drawn")); + let b = node.bounds().unwrap(); + assert!(b.x1 <= 151.0, "{name} ends at {}", b.x1); + } +} + +/// A skill's last tutorial, finished, says so and offers Back to skills instead of Next Up, even +/// when another skill follows; `learn.next` there is an error. +#[test] +fn the_last_tutorial_of_a_skill_offers_back_to_skills() { + let mut app = app(); + let ctx = context(); + for id in ["add-headline", "outline-type"] { + app.run("learn.openSample", json!({"tutorial": id})).unwrap(); + assert_eq!(app.run("learn.finish", json!({"tutorial": id})).unwrap(), json!({"tutorial": id, "finished": true, "next": null})); + let text = painted_text(&mut app, show); + assert!(text.contains("You finished the last tutorial in this skill.") && !text.contains("Next Up"), "{text}"); + let before = app.ui.learn.clone(); + assert_eq!(app.run("learn.next", json!({"tutorial": id})).unwrap_err(), format!("`{id}` is the last tutorial in its skill")); + assert_eq!(app.ui.learn, before); + click(&mut app, &ctx, "Back to skills", show); + assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: None, tutorial: None }); + } +} + +/// `learn.finish` needs the sample opened, and finishing again keeps one entry; `learn.next` needs +/// the tutorial finished and answers the next tutorial. +#[test] +fn learn_finish_and_learn_next_follow_the_practice() { + let mut app = app(); + let open = "open the practice sample of `gradient` first (see learn.openSample)"; + assert_eq!(app.run("learn.finish", json!({"tutorial": "gradient"})).unwrap_err(), open); + assert_eq!(app.run("learn.next", json!({"tutorial": "gradient"})).unwrap_err(), "finish `gradient` first (see learn.finish)"); + assert_eq!(app.ui.learn_practice, Practice::default()); + app.run("learn.openSample", json!({"tutorial": "gradient"})).unwrap(); + assert_eq!(app.run("learn.next", json!({"tutorial": "gradient"})).unwrap_err(), "finish `gradient` first (see learn.finish)"); + for _ in 0..2 { + let r = app.run("learn.finish", json!({"tutorial": "gradient"})).unwrap(); + assert_eq!(r, json!({"tutorial": "gradient", "finished": true, "next": "copy-colors"})); + } + assert_eq!(app.ui.learn_practice.finished, ["gradient"], "finishing again keeps one entry"); + let r = app.run("learn.next", json!({"tutorial": "gradient"})).unwrap(); + assert_eq!(r, json!({"skill": "color", "tutorial": "copy-colors", "title": "Copy colors from other art"})); + assert!(shown(&app)); + assert_eq!(app.ui.learn, View { tab: Tab::Skills, skill: Some("color"), tutorial: Some("copy-colors") }); +} + +/// The practice commands reject a missing, mistyped or unknown tutorial id and change nothing. +#[test] +fn the_practice_commands_reject_a_missing_or_unknown_tutorial() { + let mut app = app(); + app.run("file.new", json!({"width": 400, "height": 300})).unwrap(); + for cmd in ["learn.openSample", "learn.finish", "learn.next"] { + for (params, error) in [ + (json!({}), "missing `tutorial` (see learn.list)"), + (json!({"tutorial": null}), "missing `tutorial` (see learn.list)"), + (json!({"tutorial": 3}), "`tutorial` must be a string"), + (json!({"tutorial": "nope"}), "unknown tutorial `nope` (see learn.list)"), + ] { + assert_eq!(app.run(cmd, params).unwrap_err(), error, "{cmd}"); + assert_eq!(app.ui.learn, View::default()); + assert_eq!(app.ui.learn_practice, Practice::default()); + assert_eq!(app.session.documents().len(), 1); + assert!(!shown(&app)); + } + } +} + /// A missing, mistyped or unknown tutorial id is an error and leaves the panel as it was. #[test] fn learn_show_rejects_a_missing_or_unknown_tutorial() { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index f84b8f53b0..e7951f6d4f 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -424,6 +424,10 @@ pub struct UiState { /// is on screen. Not saved. #[serde(skip)] pub learn_locate: Option, + /// The tutorials whose practice samples were opened and the tutorials finished, kept while the + /// app runs. + #[serde(skip)] + pub learn_practice: crate::panels::learn::Practice, pub control_bar: bool, /// The Control bar is docked to the bottom of the window instead of the top /// (`window.control.dock`). @@ -1089,6 +1093,7 @@ impl Default for UiState { find_font_in: String::new(), learn: Default::default(), learn_locate: None, + learn_practice: Default::default(), control_bar: false, control_bar_bottom: false, control_bar_floating: false, diff --git a/docs/mcp.md b/docs/mcp.md index 1232ffb131..8e158a81a9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2685,11 +2685,29 @@ dock; in a full screen mode it makes Learn the front panel, which shows when the other value of `show` is an error and changes nothing. `learn.list {}` → `{skills: [{id, title, tutorials: [{id, title, steps}]}]}`, in the order the Skills tab lists them, where `steps` is the number of steps. `learn.show {tutorial}` shows the panel on its Skills tab with that tutorial's steps → `{skill, tutorial, title, steps: [text]}`. A missing, non-string or unknown `tutorial` is an error -and changes nothing. Behavior reference: +and changes nothing. + +Under a tutorial's steps, View more opens the tutorial's practice sample, a small drawing of VectorCraft's own with the +art its steps work on, as a new untitled document that becomes the active one; Save asks where to save it. Once the +sample is open, Finish tutorial marks the tutorial finished. A finished tutorial shows Next Up with the title of the +next tutorial in the same skill, which Next Up shows without opening its sample. A skill's last tutorial, finished, +says so and shows Back to skills instead; Next Up never moves to another skill. Which tutorials are finished is kept +while the app runs, and the skill rings and counts do not show it yet. These are VectorCraft's own rules; the help +page also plays a short video, which VectorCraft does not have. `learn.openSample {tutorial}` runs View more → +`{tutorial, index, title}` of the new document, and moves the Learn view to that tutorial; each call opens another +copy and leaves the open documents as they are. `learn.finish {tutorial}` runs Finish tutorial → +`{tutorial, finished: true, next}`, where `next` is the next tutorial's id in the skill, or null after the skill's +last one; the tutorial's sample must have been opened, and finishing it again keeps one entry. `learn.next {tutorial}` +runs Next Up on a finished tutorial and shows the panel on the next one → `{skill, tutorial, title}`; on a tutorial +not finished, or a skill's last tutorial, it is an error. For all three, a missing, non-string or unknown `tutorial` +is an error, and an error changes nothing. Behavior reference: [Quick skill guide](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html). ```json {"name":"invoke_menu","arguments":{"command":"learn.show","params":{"tutorial":"pen-paths"}}} +{"name":"invoke_menu","arguments":{"command":"learn.openSample","params":{"tutorial":"pen-paths"}}} +{"name":"invoke_menu","arguments":{"command":"learn.finish","params":{"tutorial":"pen-paths"}}} +{"name":"invoke_menu","arguments":{"command":"learn.next","params":{"tutorial":"pen-paths"}}} ``` A step that names a tool shows the tool's name as a blue link. A click on it, or Enter or Space while it has the diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 086d4cfc0d..4ac027eb78 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -864,6 +864,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Native macOS menu bar, vector tool cursors, a ⌘K command palette, middle-button panning with any tool. Native menus explicitly end tracking on command selection, outside window presses and Escape; menu updates wait until tracking ends, and a dismissing press never starts a canvas gesture (M14.8). - The Learn panel (Window › Learn, or Explore tutorials beside the search icon on the app bar) has a For you tab with a Get started section and a Quick skill guide card, and a Skills tab that lists VectorCraft's own skills with their tutorial counts and empty progress rings; a skill lists its tutorials, a tutorial shows its numbered steps, and Back goes up a level; `learn.list` and `learn.show {tutorial}` script it ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`). - A tool's name in a Learn tutorial step is a blue link that shows where the tool is without choosing it (`learn.locateTool {tool}`): its toolbar button is marked, a collapsed group's flyout opens with the tool's row marked, a torn-off strip comes to the front, and a tool no shown toolbar holds is marked in the All Tools drawer. The mark, its 3 seconds and the hidden-toolbar cases are VectorCraft's own rules ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `panels::learn::tests`, the `a_learn_link_*` tests in `toolbar::tests`). + - Under a tutorial's steps, View more opens the tutorial's own practice sample as a new untitled document, and once the tutorial is finished, Next Up moves to the next tutorial in the same skill, as the help page describes ([the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html)). The samples are VectorCraft's own drawings. A Finish tutorial button that shows once the sample is open, the untitled copy, Back to skills in place of Next Up after a skill's last tutorial, and Next Up showing the next tutorial without opening its sample are VectorCraft's own rules. `learn.openSample`, `learn.finish` and `learn.next` script them. Not done: the help page's short video, and progress kept between sessions and shown in the skill rings. Regression tests: `view_more_opens_the_tutorial_s_sample_as_a_new_untitled_document`, `finishing_a_tutorial_offers_next_up_in_its_skill`, `the_practice_controls_work_from_the_keyboard`, `the_last_tutorial_of_a_skill_offers_back_to_skills`, `learn_finish_and_learn_next_follow_the_practice`, `the_practice_commands_reject_a_missing_or_unknown_tutorial`, `every_tutorial_has_its_own_readable_sample` and `the_next_tutorial_is_the_next_one_in_the_same_skill` in `crates/ui-egui/src/panels/learn/tests.rs`. - The errors of the blend spine commands (Move Spine Point, Reshape Spine Segment, Add Spine Point and Delete Spine Point) show in the status bar in the interface language: each has a row in every catalog that translates all status and error messages and in Bulgarian (`blend_spine_param_errors_are_translated`). - The errors of the 3D effects (Extrude & Bevel, Revolve, Inflate, Rotate and their Classic forms) show in the status bar in the interface language: the art a 3D effect cannot build a solid from, invalid light, graphics and surface settings, and the notes for an object that draws no 3D faces or more wireframe edges than the limit. Each has a row in every catalog that translates all status and error messages, and the effect's name and the values in the message are translated or kept as written (`three_d_refusals_are_translated_in_every_complete_catalog`, `three_d_refusals_translate_with_their_values`). - The errors of the parameter checks that many commands share show in the status bar in the interface language: a value that is not a number where a number goes (`object.move` with `"dx": "60 mm"`, and `object.rotate`, `object.setBounds` and the other commands that call `check_numbers`), a missing number or index, `ids` that are not a list of object ids, an object id that is not a non-negative integer or names no object, and a date that is not Unix seconds. Each has a row in every catalog that translates all status and error messages, and in Bulgarian, Danish, Indonesian and Dutch (`shared_param_check_errors_are_translated`). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b2dd7d23a1..86f05d0ad8 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -380,7 +380,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po |---|---|---| | Effect > Warp Options: style previews, orientation choices and percent controls | done | #1760; focused input, mixed-style and history checks passed; original captures reviewed. No geometry or optional Properties changes. | | Command search: Up and Down move through the results, Enter runs the highlighted one | done | #1798 (#1757) | -| Learn panel: Window › Learn and Explore tutorials on the app bar open it; For you holds a Quick skill guide card, Skills lists VectorCraft's own skills with tutorial counts and empty progress rings, a tutorial shows its numbered steps, Back goes up a level; a tool's name in a step is a link that marks the tool where the toolbars show it (its button, its group's flyout, a torn-off strip, a toolbar made with New Toolbar or the All Tools drawer) without choosing it | done | [the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `learn.list`, `learn.show`, `learn.locateTool`; the mark's look and 3-second lifetime are VectorCraft's own rules; `panels::learn::tests`, `toolbar::tests` | +| Learn panel: Window › Learn and Explore tutorials on the app bar open it; For you holds a Quick skill guide card, Skills lists VectorCraft's own skills with tutorial counts and empty progress rings, a tutorial shows its numbered steps, Back goes up a level; a tool's name in a step is a link that marks the tool where the toolbars show it (its button, its group's flyout, a torn-off strip, a toolbar made with New Toolbar or the All Tools drawer) without choosing it; View more opens the tutorial's practice sample as a new untitled document, Finish tutorial then marks it finished, and Next Up shows the next tutorial in the same skill (no video) | done | [the help page](https://helpx.adobe.com/illustrator/desktop/get-started/learn-the-basics/learn-skills-quick-skill-guide.html); `learn.list`, `learn.show`, `learn.locateTool`, `learn.openSample`, `learn.finish`, `learn.next`; the mark's look and 3-second lifetime are VectorCraft's own rules; `panels::learn::tests`, `toolbar::tests` | | Pathfinder panel: a group selected alone counts its nested shapes, so an expanded stroked object keeps the Shape Modes enabled | done | #1642 (#1635) | | Selected Grid Repeat shows row, column and corner handles and spacing sliders; bounding-box handles take priority where they meet | done | #2691, `tools/src/repeat_grid.rs`; [public behavior reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html); drags preview and commit `object.repeat.options` as one undo step, Escape cancels; Radial Repeat has no handles yet | | Grid Repeat Options (Object › Repeat › Options…) has a Preview checkbox: the repeat on the canvas follows the fields while the dialog is open, Cancel puts the original back without a history step, OK keeps the previewed values as one undo step | done | #2651; [public behavior reference](https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-patterns/set-grid-repeat-options.html) lists the Preview option for the grid options only, so Radial and Mirror Repeat Options have none; `dialogs/command.rs` tests | From 6e2fe9fddf714b71f276a65f10db2445e6e65c35 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 13 Nov 2026 01:06:53 -0800 Subject: [PATCH 10/97] SVG: right-to-left text with text-anchor start or end opens at its directional edge SVG 2 defines text-anchor start for right-to-left text as placing the right side of the text at its position, and end as placing the left side there (https://www.w3.org/TR/SVG2/text.html#TextAnchoringProperties). Import placed start at the left edge and end at the right edge whatever the direction. - Point type and type on a path swap start and end when the effective direction is rtl. On a path, right-to-left text anchored by start now ends at startOffset and text anchored by end begins there. Vertical type keeps its anchoring. - Each line and each absolutely placed chunk is anchored by the text-anchor and direction of the element holding its first character, a tspan's own included. A later chunk no longer takes the first chunk's anchor. - Each line of point type starts at its own x, also when it has the same anchor as the line before it or another line holds absolutely placed characters. Such lines were drawn at the first line's x. - A test covers letter-spaced right-to-left lines: the layout keeps the tracking of every letter in such a line, so the whole advance sits on its anchor. --- crates/svg/src/import/text.rs | 78 +++++++++------ crates/svg/tests/it/import_text.rs | 152 +++++++++++++++++++++++++++++ docs/target-app-parity.md | 1 + 3 files changed, 200 insertions(+), 31 deletions(-) diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs index 51bcffa41e..f0d50a747d 100644 --- a/crates/svg/src/import/text.rs +++ b/crates/svg/src/import/text.rs @@ -1236,12 +1236,6 @@ impl Ctx { .collect(); let under = stroke_under(&mut cells, label, warnings); - let anchor_of = |i: usize| match cs.chars.get(i).and_then(|c| self.prop(c.1, "text-anchor")).as_deref() { - Some("middle") => 0.5, - Some("end") => 1.0, - _ => 0.0, - }; - let anchor = anchor_of(0); let path = cs.path.and_then(|tp| match self.text_path(xml, tp) { Some(p) => Some((tp, p)), None => { @@ -1250,6 +1244,22 @@ impl Ctx { } }); let vertical = self.prop(t, "writing-mode").is_some_and(|m| m.starts_with("tb") || m.starts_with("vertical")); + // Where the chunk starting at character `i` sits on its anchor: 0 its left (or top) edge, + // 0.5 its middle, 1 its right (or bottom) edge; on a path, its edge toward the path's + // start, its middle or its edge toward the path's end. In horizontal type `start` and + // `end` follow the `direction` of the element holding that character: right-to-left text + // starts at its right edge, and on a path at the edge toward the path's end (type on a + // path lays its glyphs out in visual order from its start). + let anchor_of = |i: usize| { + let Some(&(_, el)) = cs.chars.get(i) else { return 0.0 }; + let a = match self.prop(el, "text-anchor").as_deref() { + Some("middle") => 0.5, + Some("end") => 1.0, + _ => 0.0, + }; + if !vertical && self.prop(el, "direction").is_some_and(|d| d.trim() == "rtl") { 1.0 - a } else { a } + }; + let anchor = anchor_of(0); self.fit_lengths(&cs.spans, &mut cells, vertical, label, warnings); let mut obj = TextObject::point(Point::ZERO, "", CharStyle::default()); let mut breaks: Vec = vec![]; @@ -1268,6 +1278,8 @@ impl Ctx { let em = self.font_size(tp); let offset = tp.attribute("startOffset").and_then(|v| self.length(v, em, len)).unwrap_or(0.0) + dx[0]; let width = if anchor > 0.0 { advance(&point_text(&cells)) } else { 0.0 }; + // Text that would start before the path's beginning starts at it, with all its glyphs + // on the path (SVG does not draw the glyphs that fall off it). let start = Some((offset - anchor * width) / len).filter(|s| s.is_finite()).map_or(0.0, |s| s.clamp(0.0, 1.0)); obj.kind = TextKind::OnPath { path: PathData::from_bezpath(&bp), start, end: None }; obj.xf = Affine::IDENTITY; @@ -1293,14 +1305,14 @@ impl Ctx { obj.xf = Affine::IDENTITY; } else { let origin = Point::new(x[0].unwrap_or(0.0) + dx[0], y[0].unwrap_or(0.0) + dy[0]); - let (justify, left, starts) = lines(&mut cells, &x, &y, &dx, &dy, anchor, origin.x, &mut breaks); + let (justify, left, starts) = lines(&mut cells, &x, &y, &dx, &dy, &anchor_of, origin.x, &mut breaks); obj.para.justify = justify; obj.xf = Affine::translate((finite(left), finite(origin.y))); - // Lines anchored differently (paragraphs aligned differently): each line's paragraph - // takes its alignment, and indents that put it where it was. - if let Some(starts) = starts.filter(|st| st.iter().any(|(i, _)| anchor_of(*i) != anchor)) { - for (k, &(i, ax)) in starts.iter().enumerate() { - let a = anchor_of(i); + // Lines anchored differently or at different x (paragraphs aligned or indented + // differently): each line's paragraph takes its alignment, and indents that put it + // where it was. + if starts.iter().any(|&(_, a, ax)| a != starts[0].1 || ax != starts[0].2) { + for (k, &(_, a, ax)) in starts.iter().enumerate() { let d = finite(ax - left); let mut pa = ParaStyle { justify: justify_of(a), ..ParaStyle::default() }; match pa.justify { @@ -1412,7 +1424,7 @@ fn finite(v: f64) -> f64 { if v.is_finite() { v } else { 0.0 } } -/// The alignment of an SVG text chunk anchored at `anchor` (0 start, 0.5 middle, 1 end). +/// The alignment of an SVG text chunk anchored at `anchor` (0 left edge, 0.5 middle, 1 right edge). fn justify_of(anchor: f64) -> Justify { match anchor { a if a >= 1.0 => Justify::Right, @@ -1428,8 +1440,10 @@ fn leading_tolerance(spacing: f64) -> f64 { } /// Point type: line breaks, baseline shifts and kerning from the positioning lists. Returns the -/// justification, the x of the first line's left (or anchor) edge and, when no character is -/// placed on its own, each line's first character and anchor x. +/// justification, the x of the first line's left (or anchor) edge and each line's first +/// character, alignment (as for [`justify_of`]) and anchor x. `anchor_of` gives where the chunk +/// starting at a character sits on its anchor. When characters are placed on their own, every +/// line is left-aligned and its anchor x is its left edge. #[allow(clippy::too_many_arguments)] fn lines( cells: &mut [Cell], @@ -1437,10 +1451,10 @@ fn lines( y: &[Option], dx: &[f64], dy: &[f64], - anchor: f64, + anchor_of: &dyn Fn(usize) -> f64, x0: f64, breaks: &mut Vec, -) -> (Justify, f64, Option>) { +) -> (Justify, f64, Vec<(usize, f64, f64)>) { let n = cells.len(); let y0 = y[0].unwrap_or(0.0) + dy[0]; let (mut base, mut cur) = (y0, y0); @@ -1482,15 +1496,16 @@ fn lines( } } if targets.is_empty() { - return (justify_of(anchor), x0, Some(lines)); + let starts = lines.iter().map(|&(s, lx)| (s, anchor_of(s), lx)).collect(); + return (justify_of(anchor_of(0)), x0, starts); } // Absolutely placed characters: lay the text out left-aligned and kern each one onto its x. - // SVG anchors every absolutely placed chunk on its own, so with a middle/end anchor each - // chunk's target moves back by its share of the chunk's width. + // SVG anchors every absolutely placed chunk on its own, so a chunk not anchored at its left + // edge moves back by its share of its own width. let line_of = |i: usize| lines.partition_point(|(s, _)| *s <= i) - 1; - let mut left = x0; + let mut line_left: Vec = lines.iter().map(|l| l.1).collect(); let mut wants: Vec<(usize, f64)> = targets.clone(); - if anchor > 0.0 { + if lines.iter().chain(&targets).any(|c| anchor_of(c.0) > 0.0) { let (runs, bytes) = assemble(cells, breaks); let lay = measure(&with_runs(runs.0)); let (pens, ends) = pens(&lay, &bytes); @@ -1506,16 +1521,14 @@ fn lines( _ => 0.0, } }; - let line_left: Vec = lines.iter().map(|&(s, lx)| lx - anchor * width(s)).collect(); - left = line_left[0]; + line_left = lines.iter().map(|&(s, lx)| lx - anchor_of(s) * width(s)).collect(); for w in &mut wants { - w.1 -= anchor * width(w.0) + line_left[line_of(w.0)]; - } - } else { - for w in &mut wants { - w.1 -= lines[line_of(w.0)].1; + w.1 -= anchor_of(w.0) * width(w.0); } } + for w in &mut wants { + w.1 -= line_left[line_of(w.0)]; + } // Kerning changes shaping (pair kerning is off for manually kerned characters): repeat until // every character sits on its x. for _ in 0..4 { @@ -1538,7 +1551,8 @@ fn lines( break; } } - (Justify::Left, left, None) + let starts = lines.iter().zip(&line_left).map(|(&(s, _), &lx)| (s, 0.0, lx)).collect(); + (Justify::Left, line_left[0], starts) } /// Centred and right-aligned lines of point type `obj` where a reader draws them. `text-anchor` @@ -1546,7 +1560,9 @@ fn lines( /// export anchors lines so); VectorCraft aligns a line without that tracking /// ([`vectorcraft_text::LineInfo::trailing`]). Each such line moves back by its share of it: the /// type moves when every line moves alike, otherwise each paragraph's right indent takes the -/// tracking (a line of imported point type is a paragraph). Browsers add that letter spacing +/// tracking (a line of imported point type is a paragraph). A right-to-left paragraph's lines +/// keep all their tracking in the layout (`trailing` is 0), so they stay where they are and their +/// whole advance sits on the anchor. Browsers add that letter spacing /// today; CSS WG issue 1518 and Firefox bugs 1907305 and 1913554 may drop it at a line's end. fn keep_reader_anchors(obj: &mut TextObject) { let share = |j: Justify| match j { diff --git a/crates/svg/tests/it/import_text.rs b/crates/svg/tests/it/import_text.rs index b25dc23678..01f541d8d6 100644 --- a/crates/svg/tests/it/import_text.rs +++ b/crates/svg/tests/it/import_text.rs @@ -360,6 +360,158 @@ fn anchored_letter_spaced_lines_stay_where_a_reader_draws_them() { assert!(close(s[0], 200.0 - a[0] / 2.0, 1e-6) && close(s[1], 200.0 - a[1], 1e-6), "{s:?} {a:?}"); } +/// Document-space left and right edges of the advance of each laid-out line. +fn line_edges(t: &TextObject) -> Vec<(f64, f64)> { + let lay = layout(FontDb::global(), t); + lay.lines + .iter() + .map(|l| { + let g = &lay.glyphs[l.glyph_start..l.glyph_end]; + let left = g.iter().map(|g| (t.xf * g.origin).x).fold(f64::INFINITY, f64::min); + let right = g.iter().map(|g| (t.xf * g.origin).x + g.advance).fold(f64::NEG_INFINITY, f64::max); + (left, right) + }) + .collect() +} + +/// SVG 2 §11.10.1.1 (https://www.w3.org/TR/SVG2/text.html#TextAnchoringProperties): with +/// `direction: rtl`, `start` puts the right side of the text at its position and `end` puts the +/// left side there; `middle` centers it either way. The direction is the effective one of the element that +/// holds the chunk's first character. +#[test] +fn rtl_point_text_start_and_end_use_directional_edges() { + // (markup, share of the advance left of x = 200) + for (body, share) in [ + (r#"Wave"#, 1.0), + (r#"Wave"#, 1.0), + (r#"Wave"#, 0.5), + (r#"Wave"#, 0.0), + (r#"Wave"#, 1.0), + (r#"Wave"#, 0.0), + (r#"Wave"#, 0.0), + (r#"Wave"#, 0.0), + (r#"Wave"#, 1.0), + ] { + let d = open(body); + let (left, right) = line_edges(only_text(&d))[0]; + let want = 200.0 - share * (right - left); + assert!(close(left, want, 1e-6), "{body}: the advance spans {left}..{right}, so it does not start at {want}"); + } +} + +/// On a path, right-to-left text anchored by `start` ends at `startOffset` and one anchored by +/// `end` begins there (its glyphs run from the path's start in visual order); `middle` centers it. +#[test] +fn rtl_type_on_a_path_starts_at_its_directional_edge() { + // (anchor, direction, share of the advance before the offset) + for (anchor, direction, share) in [("start", "rtl", 1.0), ("middle", "rtl", 0.5), ("end", "rtl", 0.0), ("start", "ltr", 0.0), ("end", "ltr", 1.0)] + { + let d = open(&format!( + r##" + Wave"## + )); + let t = only_text(&d); + assert!(matches!(t.kind, TextKind::OnPath { .. }), "{:?}", t.kind); + let lay = layout(FontDb::global(), t); + let left = lay.glyphs.iter().map(|g| (t.xf * g.origin).x).fold(f64::INFINITY, f64::min); + let width: f64 = lay.glyphs.iter().map(|g| g.advance).sum(); + let want = 200.0 - share * width; + assert!(close(left, want, 0.05), "{anchor} {direction}: the text starts at {left}, not {want}"); + } +} + +/// Letter-spaced right-to-left lines: a line keeps the tracking after each of its characters, and +/// its whole advance sits on its anchor as `start`, `middle` and `end` place it. +#[test] +fn rtl_letter_spaced_lines_stay_on_their_anchor() { + for (anchor, share) in [("start", 1.0), ("middle", 0.5), ("end", 0.0)] { + let d = open(&format!(r#"WAVE"#)); + let t = only_text(&d); + let (left, right) = line_edges(t)[0]; + let want = 200.0 - share * (right - left); + assert!(close(left, want, 1e-6), "{anchor}: the advance spans {left}..{right}, so it does not start at {want}"); + let natural = { + let d = open(&format!(r#"WAVE"#)); + let (l, r) = line_edges(only_text(&d))[0]; + r - l + }; + assert!(close(right - left, natural + 4.0 * 8.0, 1e-6), "{anchor}: the advance {} holds the tracking of all 4 letters", right - left); + } + // A right-to-left line anchored by `start` beside a left-to-right `middle` line: each keeps its own anchor. + let d = open( + r#"WaveLine"#, + ); + let t = only_text(&d); + let e = line_edges(t); + assert_eq!(e.len(), 2, "{e:?}"); + assert!(close(e[0].1, 200.0, 1e-6), "the right-to-left line ends at 200: {e:?}"); + let lay = layout(FontDb::global(), t); + let a: f64 = lay.glyphs[lay.lines[1].glyph_start..lay.lines[1].glyph_end].iter().map(|g| g.advance).sum(); + assert!(close(e[1].0, 200.0 - a / 2.0, 1e-6), "the middle line is centered on 200 with its tracking: {e:?}"); +} + +/// Each line is a chunk anchored by the `text-anchor` and `direction` of its first character. +#[test] +fn rtl_lines_use_each_chunk_first_character_direction_and_anchor() { + let d = open( + r#"WaveLineLastMid"#, + ); + let e = line_edges(only_text(&d)); + assert_eq!(e.len(), 4, "{e:?}"); + assert!(close(e[0].1, 200.0, 1e-6), "a right-to-left start line ends at 200: {e:?}"); + assert!(close(e[1].0, 200.0, 1e-6), "a left-to-right start line starts at 200: {e:?}"); + assert!(close(e[2].0, 200.0, 1e-6), "a right-to-left end line starts at 200: {e:?}"); + assert!(close((e[3].0 + e[3].1) / 2.0, 200.0, 1e-6), "a middle line is centered on 200: {e:?}"); +} + +/// A line starts at its own x, also when it is anchored as the line before it is and when another +/// line holds absolutely placed characters. +#[test] +fn each_line_starts_at_its_own_x() { + let d = open(r#"WaveLine"#); + let e = line_edges(only_text(&d)); + assert!(close(e[0].0, 50.0, 1e-6) && close(e[1].0, 120.0, 1e-6), "{e:?}"); + let d = open(r#"WaveLine"#); + let e = line_edges(only_text(&d)); + assert!(close(e[0].1, 150.0, 1e-6) && close(e[1].1, 100.0, 1e-6), "{e:?}"); + let d = open(r#"abLine"#); + let t = only_text(&d); + let e = line_edges(t); + assert_eq!(e.len(), 2, "{e:?}"); + assert!(close(e[0].0, 50.0, 0.05) && close(e[1].1, 200.0, 0.05), "{e:?}"); + let b = (t.xf * layout(FontDb::global(), t).glyphs[1].origin).x; + assert!(close(b, 100.0, 0.05), "b starts at {b}"); +} + +/// Absolutely placed chunks are anchored on their own, each by the `text-anchor` and `direction` +/// of its first character, whatever the first chunk's anchor. +#[test] +fn absolutely_placed_chunks_use_their_own_rtl_anchor() { + // (markup, share of each chunk's advance left of its x) + for (body, shares) in [ + (r#"ab"#, [0.0, 1.0]), + (r#"ab"#, [0.0, 0.0]), + (r#"ab"#, [0.0, 1.0]), + (r#"ab"#, [0.5, 0.0]), + (r#"ab"#, [0.5, 1.0]), + (r#"ab"#, [0.5, 0.5]), + (r#"ab"#, [1.0, 1.0]), + ] { + let d = open(body); + let t = only_text(&d); + let lay = layout(FontDb::global(), t); + assert_eq!(lay.glyphs.len(), 2, "{body}"); + // Natural advance: the kerning that moves `b` onto its x is added after `a`. + let style = |i: usize| t.runs.iter().flat_map(|r| r.text.chars().map(move |_| &r.style)).nth(i).unwrap(); + let natural = |i: usize| lay.glyphs[i].advance - style(i).kerning.unwrap_or(0.0) / 1000.0 * style(i).size; + for (i, (x, share)) in [80.0, 200.0].into_iter().zip(shares).enumerate() { + let left = (t.xf * lay.glyphs[i].origin).x; + let want = x - share * natural(i); + assert!(close(left, want, 0.05), "{body}: chunk {i} starts at {left}, not {want}"); + } + } +} + #[test] fn a_small_line_gap_in_tiny_type_keeps_its_leading() { // 0.0085 against an auto leading of 0.006 differs by less than 0.01 but is not a whole multiple of it. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 086d4cfc0d..a202baa403 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1526,6 +1526,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur A function-based PDF shading (type 1) whose colour changes along one axis only, as a browser writes a repeating linear gradient, comes in as a linear gradient over its domain, each hard colour change two stops at one offset; one whose colour changes in two directions comes in as its average colour, with a warning. Either paints only over the shading's domain. Both came in as a flat grey that wasn't in the file (#3621). Each such shading is read once per import, and all of them within one budget of 200,000 function evaluations, so a file filling thousands of paths with long functions still opens in about a second (it took minutes); past the budget they come in as their average colour, then grey, with a warning. A gap between glyphs of two sizes is measured against the smaller one, so a word set beside a much larger one with no space glyph between them (a browser's margin between two sizes, as in `≤ 4` and a small `hours`) keeps its word space (#3623). SVG import's style cascade, used for hidden objects, non-scaling strokes and type, tests each element only against the `