Merge main into fix/warichu-rtl-gap-caret

This commit is contained in:
dotCooCoo
2026-11-13 22:42:56 -08:00
52 changed files with 4881 additions and 214 deletions
+68
View File
@@ -3,6 +3,8 @@
//! square to it: scaling by a side handle stretches it along its own width or height. Path
//! geometry is still baked; Object ▸ Transform ▸ Reset Bounding Box sets the angle back to 0.
use std::sync::Arc;
use vectorcraft_geom::{Affine, Point, Rect, Vec2, normalize_deg, union_opt};
use crate::{Document, Node, NodeId, NodeKind, SymbolBoxes};
@@ -84,7 +86,35 @@ pub fn transformed_angle(angle: f64, a: Affine) -> f64 {
}
}
/// The turn (counter-clockwise degrees) from the boxes of art placed by a symbol instance's
/// transform `xf` to the instance's own box at `angle`: 0 while the box turns with the transform,
/// otherwise the change Reset Bounding Box made to it. Under a mirroring `xf` the instance's box can
/// stand 180° from the one `xf` gives in one step ([`transformed_angle`] keeps the angle nearer the
/// one before at each step); the turn is then 180°, so the art's boxes match the instance's.
pub fn placed_turn(angle: f64, xf: Affine) -> f64 {
let turn = normalize_deg(angle - transformed_angle(0.0, xf));
if turn.is_finite() && turn.abs() > EPS_DEG { turn } else { 0.0 }
}
impl Node {
/// Turns the box of this object, and of every object that turns with it ([`Node::transform`]:
/// its children and the art of a linked opacity mask), by `by` degrees counter-clockwise.
/// Layers have no box.
pub fn turn_boxes(&mut self, by: f64) {
if !self.is_layer() {
let a = normalize_deg(self.bbox_angle + by);
self.bbox_angle = if a.abs() <= EPS_DEG { 0.0 } else { a };
}
for c in self.children_mut().into_iter().flatten() {
Arc::make_mut(c).turn_boxes(by);
}
if let Some(m) = self.extras_if_any().and_then(|e| e.mask.as_mut())
&& m.linked
{
Arc::make_mut(&mut m.art).turn_boxes(by);
}
}
/// Bounds of this object as `a` would place it (geometric, or `visual` with its strokes),
/// without changing it, symbol instances measured by their art from `symbols`
/// ([`Node::geometric_bounds_with`]).
@@ -185,6 +215,44 @@ mod tests {
assert_eq!(transformed_angle(30.0, Affine::scale(0.0)), 30.0);
}
#[test]
fn placed_turn_is_what_reset_bounding_box_took_off_the_instance() {
let xf = Affine::translate((40.0, 10.0)) * rot(20.0) * Affine::scale_non_uniform(2.0, 0.5);
// The box turns with the transform: no turn.
assert_eq!(placed_turn(transformed_angle(0.0, xf), xf), 0.0);
assert_eq!(placed_turn(0.0, Affine::scale(3.0)), 0.0);
// Reset to 0: the placed art turns back by the 20°.
assert!(close(placed_turn(0.0, xf), -20.0));
// Mirrored: turned 170° and then flipped, the instance's box stands 180° from the box the
// whole transform gives in one step, and the art turns the half turn to match it.
let flip = Affine::scale_non_uniform(1.0, -1.0);
let flipped = flip * rot(170.0);
let stepwise = transformed_angle(transformed_angle(0.0, rot(170.0)), flip);
assert!(close(stepwise, -170.0) && close(transformed_angle(0.0, flipped), 10.0));
assert!(close(placed_turn(stepwise, flipped).abs(), 180.0));
assert!(close(normalize_deg(placed_turn(0.0, flipped) + transformed_angle(0.0, flipped)), 0.0));
// Degenerate and non-finite transforms turn nothing.
assert_eq!(placed_turn(0.0, Affine::scale(0.0)), 0.0);
assert_eq!(placed_turn(f64::NAN, xf), 0.0);
}
#[test]
fn turn_boxes_turns_children_and_linked_mask_art_but_not_layers() {
let mut d = Document::new(500.0, 500.0);
let a = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
let mut g = Node::group(d.alloc_id(), vec![Arc::new(a.clone())]);
g.bbox_angle = 170.0;
g.extras_mut().mask = Some(Box::new(crate::OpacityMask::new(a.clone(), true)));
let mut layer = (*d.layers[0]).clone();
layer.children_mut().unwrap().push(Arc::new(g));
layer.turn_boxes(20.0);
assert_eq!(layer.bbox_angle, 0.0);
let g = &layer.children().unwrap()[0];
assert!(close(g.bbox_angle, -170.0), "wraps to (-180, 180]");
assert!(close(g.children().unwrap()[0].bbox_angle, 20.0));
assert!(close(g.extras().mask.as_ref().unwrap().art.bbox_angle, 20.0));
}
#[test]
fn oriented_bounds_hug_a_rotated_rectangle() {
let mut d = Document::new(500.0, 500.0);
+7 -1
View File
@@ -614,6 +614,8 @@ impl NineSliceMap {
/// `n`, whose box at the natural size is `b`, drawn in each cell it reaches: a clip group of
/// the cell around a copy placed by the cell's map, all in a group with `n`'s id. `n` placed
/// by `center` when it draws symbol art ([`draws_symbol_art`]), and in [`Self::place_unclipped`].
/// The clip groups and clip paths take the angle of the cell map that places them. The group
/// and each copy keep `n`'s box angle, turned by the map that places them.
///
/// The drop shadows, outer glows and blurs ([`PAINTED_EFFECTS`]) at the end of `n`'s own
/// effects, after its last other visible effect, go on that group with its opacity, blending
@@ -647,11 +649,15 @@ impl NineSliceMap {
area.transform(local);
let mut clip = Node::new(NodeId(0), NodeKind::Path { path: area, rule: FillRule::NonZero, live: None, clipping: true, guide: false });
clip.appearance = Appearance::default().into();
clip.bbox_angle = crate::orient::transformed_angle(0.0, local);
let copy = transformed(n, local * self.natural);
cells.push(Arc::new(Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(copy)], clip: true })));
let mut cell = Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(copy)], clip: true });
cell.bbox_angle = crate::orient::transformed_angle(0.0, local);
cells.push(Arc::new(cell));
}
}
let mut out = Node::group(n.id, cells);
out.bbox_angle = crate::orient::transformed_angle(n.bbox_angle, center);
out.visible = n.visible;
out.locked = n.locked;
out.name = n.name.clone();
+10 -3
View File
@@ -23,15 +23,22 @@ pub fn text_outliner() -> Outliner<'static> {
/// Type outlined, or a symbol instance as its symbol's art (from `doc`) placed by the instance,
/// through the symbol's 9-slice guides when it has them ([`Document::place_symbol_art`], `None`
/// past its object limit). As an [`OutlineHook`](crate::OutlineHook), it gives compound shapes and
/// Pathfinder operations the symbol instances among their members.
/// Pathfinder operations the symbol instances among their members. The group around the art takes
/// the instance's box angle, and the art's boxes turn with it, as Break Link turns them.
pub fn outline_object(doc: Option<&Document>, n: &Node) -> Option<Node> {
match &n.kind {
NodeKind::SymbolInstance { symbol, xf } => {
let doc = doc?;
let sym = doc.symbols.iter().find(|s| s.name == *symbol)?;
let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n);
let art = doc.place_symbol_art(symbol, &art, *xf)?;
Some(Node::group(n.id, vec![Arc::new(art)]))
let mut art = doc.place_symbol_art(symbol, &art, *xf)?;
let turn = vectorcraft_doc::orient::placed_turn(n.bbox_angle, *xf);
if turn != 0.0 {
art.turn_boxes(turn);
}
let mut out = Node::group(n.id, vec![Arc::new(art)]);
out.bbox_angle = n.bbox_angle;
Some(out)
}
_ => outline_text(n),
}
+21
View File
@@ -1570,6 +1570,27 @@ fn outline_object_is_a_geometry_effect_that_keeps_the_path() {
assert_eq!(apply_geometry(&[fx("path.outlineObject", json!({})), offset.clone()], &square(), b), apply_geometry(&[offset], &square(), b));
}
/// A symbol instance given to Pathfinder and compound shapes as its art: the group takes the
/// instance's box angle and the art turns with it, turned with the instance or reset to 0.
#[test]
fn outline_object_turns_an_instances_art_with_its_box() {
use std::sync::Arc;
use vectorcraft_doc::orient::transformed_angle;
use vectorcraft_doc::{Appearance, Document, NodeId, Symbol};
let mut d = Document::new(500.0, 500.0);
d.symbols.push(Symbol { dynamic: false, name: "s".into(), art: Arc::new(Node::path(NodeId(0), square(), Appearance::default())) });
let xf = Affine::translate((200.0, 100.0)) * Affine::rotate(0.35);
for box_angle in [transformed_angle(0.0, xf), 0.0] {
let mut inst = Node::new(NodeId(3), NodeKind::SymbolInstance { symbol: "s".into(), xf });
inst.bbox_angle = box_angle;
let out = outline_object(Some(&d), &inst).unwrap();
let mut angles = vec![];
out.walk(&mut |c| angles.push(c.bbox_angle));
assert!(angles.len() == 2 && angles.iter().all(|a| close(*a, box_angle, 1e-9)), "{box_angle}: {angles:?}");
}
}
#[test]
fn convert_to_shape_sizes() {
let r = run("convertToShape.rectangle", json!({"extraW": 20, "extraH": 10}), &square()).bounds().unwrap();
+1 -1
View File
@@ -226,7 +226,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Break Link to Symbol",
[],
None,
"{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol), placed through the symbol's 9-slice guides when it has them, with the instance's opacity mask; a hidden or locked instance's art stays hidden or locked and is not selected → {ids}",
"{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol), placed through the symbol's 9-slice guides when it has them, with the instance's opacity mask and its own bounding box (square after Reset Bounding Box); a hidden or locked instance's art stays hidden or locked and is not selected → {ids}",
has_selection,
symbol_break_link
),
+11 -3
View File
@@ -393,8 +393,10 @@ pub(super) fn symbol_place(s: &mut Session, p: &Value) -> Result<Value> {
/// The plain art of instance `inst` in document space (fresh ids), with the instance's own
/// appearance changes to a dynamic symbol's art when `own`, placed through its symbol's 9-slice
/// guides when it has them. The instance's opacity mask goes on the art, or on a group around it
/// when the art has a mask of its own.
/// guides when it has them. The art's bounding boxes turn with the instance's own box, so after
/// Reset Bounding Box they stand as the instance's box does. The instance's opacity mask goes on
/// the art, or on a group around it when the art has a mask of its own; a group added around the
/// art takes the instance's box angle.
fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option<Node> {
let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None };
let sym = d.symbols.iter().find(|s| &s.name == symbol)?;
@@ -402,12 +404,17 @@ fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option<Node> {
if let Some((color, amount)) = brush::stain(inst) {
brush::tint_node(&mut art, &color, amount);
}
let art = d.place_symbol_art(symbol, &art, *xf)?;
let mut art = d.place_symbol_art(symbol, &art, *xf)?;
let turn = vectorcraft_doc::orient::placed_turn(inst.bbox_angle, *xf);
if turn != 0.0 {
art.turn_boxes(turn);
}
let mut art = d.reid(&art);
if inst.opacity < 1.0 || inst.blend != vectorcraft_color::BlendMode::Normal {
if !matches!(art.kind, NodeKind::Group { .. }) {
let id = d.alloc_id();
art = Node::group(id, vec![Arc::new(art)]);
art.bbox_angle = inst.bbox_angle;
}
art.opacity *= inst.opacity;
art.blend = inst.blend;
@@ -416,6 +423,7 @@ fn expanded_instance(d: &mut Document, inst: &Node, own: bool) -> Option<Node> {
if art.extras().mask.is_some() {
let id = d.alloc_id();
art = Node::group(id, vec![Arc::new(art)]);
art.bbox_angle = inst.bbox_angle;
}
art.extras_mut().mask = Some(mask.clone());
}
+17 -4
View File
@@ -11,7 +11,9 @@ use super::{
// ---------- Pencil / Paintbrush ----------
/// Fit a freehand polyline: drop jitter, build a spline through the samples (sharp turns stay
/// corners) and simplify it to `tol`.
/// corners) and simplify it to `tol`. A closed stroke is fitted as the open one is and closed by a
/// straight segment from its end back to its start: fitting round the gap would curve that segment
/// on from both ends, past them and back.
fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath {
let min = (tol * 0.3).max(vectorcraft_tools::draw2::MIN_FIT_TOLERANCE * 0.3);
let (Some(&p0), Some(&pn)) = (pts.first(), pts.last()) else { return SubPath::new(vec![], closed) };
@@ -41,15 +43,26 @@ fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath {
(kept[i], corner)
})
.collect();
let sp = vectorcraft_tools::draw2::catmull_rom(&marked, closed);
let sp = vectorcraft_tools::draw2::catmull_rom(&marked, false);
if sp.anchors.len() < 3 {
return sp;
// Two anchors closed would run back over the line itself.
return SubPath::new(sp.anchors, closed && n > 2);
}
let fitted = po::simplify_with(
&PathData::single(sp.clone()),
&SimplifyOptions { tolerance: tol, corner_angle_deg: 60.0, straight_lines: false, ..Default::default() },
);
fitted.subpaths.into_iter().next().filter(|s| s.anchors.len() >= 2).unwrap_or(sp)
let mut anchors = fitted.subpaths.into_iter().next().filter(|s| s.anchors.len() >= 2).unwrap_or(sp).anchors;
if closed {
// No handle on either side of the closing segment.
if let Some(l) = anchors.last_mut() {
l.h_out = l.p;
}
if let Some(f) = anchors.first_mut() {
f.h_in = f.p;
}
}
SubPath::new(anchors, closed)
}
/// The path of a freehand stroke: fitted at `tol` ([`fit_freehand`]), or with no `tol` the points
+1 -1
View File
@@ -1724,7 +1724,7 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
let mut out = json!({ "changed": changed, "swatches": added });
not_changed.report(&mut out);
if let Some(e) = edit {
e.defaults(s);
e.defaults(s)?;
out["relinked"] = json!(e.relinked());
}
if converted {
+40 -16
View File
@@ -322,7 +322,7 @@ fn swatch_delete(s: &mut Session, p: &Value) -> Result<Value> {
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?;
Ok(unlinked)
})?;
map_default_paints(s, &mut unlink);
map_default_paints(s, &mut unlink)?;
Ok(json!({"deleted": names, "unlinked": unlinked}))
}
@@ -660,20 +660,41 @@ impl Relink {
renamed || recolored
}
fn defaults(&self, s: &mut Session) {
map_default_paints(s, &mut |c, l, t| self.apply(c, l, t));
fn defaults(&self, s: &mut Session) -> Result<()> {
map_default_paints(s, &mut |c, l, t| self.apply(c, l, t))
}
}
/// Apply a swatch-link rewrite to the default fill and stroke for new art, except during a live
/// preview (Cancel rolls the document back, not the session).
pub(super) fn map_default_paints(s: &mut Session, f: &mut LinkFn) {
if s.in_interaction() {
return;
/// Apply a swatch-link rewrite to the fill and stroke new art takes and to the paints of its
/// template appearance, except during a live preview (Cancel rolls the document back, not the
/// session). A `command.batch` step rewrites them as the command run on its own does: a failed
/// batch, and a canceled batch preview, put back the paints the batch started with. A change is
/// recorded with the step just made, so Undo and Redo put these paints back with the document.
pub(super) fn map_default_paints(s: &mut Session, f: &mut LinkFn) -> Result<()> {
if s.in_interaction() && s.batch_stash.is_none() {
return Ok(());
}
for p in [&mut s.paint.fill, &mut s.paint.stroke] {
p.map_links(f);
let start = super::colormgmt::new_art_colors_before(s)?;
let pd = &mut s.paint;
let template = pd.appearance.iter_mut().flat_map(|a| a.items.iter_mut()).map(|it| match it {
vectorcraft_doc::AppearanceItem::Fill(f) => &mut f.paint,
vectorcraft_doc::AppearanceItem::Stroke(s) => &mut s.paint,
});
let mut changed = false;
for p in [&mut pd.fill, &mut pd.stroke].into_iter().chain(template) {
changed |= p.map_links(f);
}
if changed {
super::colormgmt::record_new_art_colors(s, start)?;
// The step takes the paints the command finally leaves, after the selection's appearance
// is inherited.
let st = s.doc()?;
if st.interaction.is_none() {
let uid = st.uid;
s.batch_paint_docs.push(uid);
}
}
Ok(())
}
/// Apply a swatch-link rewrite to the document ([`Document::map_solid_paints`]) and to the art of
@@ -888,7 +909,7 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?;
Ok(relinked)
})?;
relink.defaults(s);
relink.defaults(s)?;
Ok(json!({"name": to, "relinked": relinked}))
}
@@ -1084,7 +1105,7 @@ fn swatch_merge(s: &mut Session, p: &Value) -> Result<Value> {
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(C, e))?;
Ok(relinked)
})?;
map_default_paints(s, &mut relink);
map_default_paints(s, &mut relink)?;
Ok(json!({"name": keep, "merged": merged, "relinked": relinked}))
}
@@ -1141,7 +1162,7 @@ fn swatch_spot_options(s: &mut Session, p: &Value) -> Result<Value> {
let relinked = map_swatch_links(d, &mut follow_bases(&bases));
Ok((relinked, bases))
})?;
map_default_paints(s, &mut follow_bases(&bases));
map_default_paints(s, &mut follow_bases(&bases))?;
Ok(json!({"useLab": use_lab, "relinked": relinked}))
}
@@ -1165,8 +1186,11 @@ pub(super) struct GroupEdit {
impl GroupEdit {
/// Apply the link changes to the default fill and stroke for new art.
pub(super) fn defaults(&self, s: &mut Session) {
self.relinks.iter().for_each(|r| r.defaults(s));
pub(super) fn defaults(&self, s: &mut Session) -> Result<()> {
if self.relinks.is_empty() {
return Ok(());
}
map_default_paints(s, &mut |c, l, t| self.relinks.iter().any(|r| r.apply(c, l, t)))
}
/// How many paints linked to the group's swatches the rewrite changed.
@@ -1242,6 +1266,6 @@ fn swatch_edit_group(s: &mut Session, p: &Value) -> Result<Value> {
colors.into_iter().enumerate().map(|(i, c)| if old.get(i) == Some(&Some(c)) { c } else { super::paint::new_color(s, p, c) }).collect();
let rename = str_param(p, "rename");
let e = s.edit("Edit Color Group", |d, _| rewrite_group(d, group, &colors, rename, C))?;
e.defaults(s);
e.defaults(s)?;
Ok(json!({"name": e.name, "swatches": e.swatches, "relinked": e.relinked}))
}
+1 -1
View File
@@ -980,7 +980,7 @@ fn reset_defaults(s: &mut Session, p: &Value) -> Result<Value> {
Ok(added)
})?;
if replace {
map_default_paints(s, &mut |_, l, _| unlink_dead(&live, l));
map_default_paints(s, &mut |_, l, _| unlink_dead(&live, l))?;
}
Ok(json!({ "added": added }))
}
+3 -4
View File
@@ -1688,10 +1688,9 @@ fn warichu_is_a_character_attribute_set_saved_and_exported() {
assert!(["warichu", "warichuLines", "warichuGap", "warichuScale", "warichuAlign"].iter().all(|k| saved.get(*k).is_none()), "{saved}");
let plain_svg = vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default());
let plain_pdf = vectorcraft_pdf::export(&s.doc().unwrap().doc, &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap();
let text_ops = |pdf: &[u8]| {
let s = String::from_utf8_lossy(pdf);
s.contains("Tj") || s.contains("TJ")
};
// Only text objects count: the file ID in the trailer and the XMP is base64 that changes each
// second and now and then spells `Tj` or `TJ`.
let text_ops = |pdf: &[u8]| String::from_utf8_lossy(pdf).lines().any(|l| l.starts_with("BT ") && (l.contains("Tj") || l.contains("TJ")));
assert!(plain_svg.contains("<text") && text_ops(&plain_pdf));
// text.setFormat with the values the characters already have changes nothing and records no
+5 -4
View File
@@ -369,12 +369,13 @@ impl DocState {
/// menu asks on every frame it is open, and measuring builds the solids of 3D effects, so the
/// answer is kept for this version of the document and those artboards, and a change of the
/// selection or the view keeps it. It is measured again when what the measure reads outside the
/// document changes: the installed plug-ins, whose effects draw art, and the installed fonts,
/// which set the bounds of all type.
/// document changes: the installed plug-ins, whose effects draw art, and the installed fonts and
/// the hyphenation exceptions, which set the bounds of all type.
pub fn any_art_to_fit(&self, indices: &[usize]) -> bool {
self.any_art_to_fit_with(indices, (vectorcraft_plugins::registry::revision(), vectorcraft_text::FontDb::global().generation()))
self.any_art_to_fit_with(indices, (vectorcraft_plugins::registry::revision(), vectorcraft_text::layout_inputs()))
}
/// [`Self::any_art_to_fit`], with `outside` the generations of the plug-ins and the fonts.
/// [`Self::any_art_to_fit`], with `outside` the generations of the plug-ins and the layout inputs
/// ([`vectorcraft_text::layout_inputs`]).
pub(crate) fn any_art_to_fit_with(&self, indices: &[usize], outside: (u64, u64)) -> bool {
// Measuring runs renderer and plug-in code; should it ever ask again while measuring, it
// is measured without being kept rather than borrowed twice.
+4 -2
View File
@@ -25,9 +25,11 @@ impl Session {
// Clicks find an image's own fills and strokes where their effects draw them.
vectorcraft_doc::hit::set_image_paint(vectorcraft_render::effects::image_paint);
// Clicks find a blend's steps and an envelope's distorted art where the canvas draws them,
// evaluated again when the fonts or the plug-ins change.
// evaluated again when the fonts, the hyphenation exceptions or the plug-ins change.
vectorcraft_doc::hit::set_live_art(vectorcraft_render::effects::expand_live, || {
vectorcraft_text::FontDb::global().generation().rotate_left(32) ^ vectorcraft_render::effects::plugin_revision()
let mut h = std::hash::DefaultHasher::new();
std::hash::Hash::hash(&(vectorcraft_text::layout_inputs(), vectorcraft_render::effects::plugin_revision()), &mut h);
std::hash::Hasher::finish(&h)
});
// Envelopes map type from the bounds of its glyph outlines, underlines and strikethroughs.
vectorcraft_text::register_envelope_ink();
+119
View File
@@ -560,6 +560,125 @@ fn reset_bounding_box_then_release_intertwine() {
reset_then_release("object.intertwine.make", "object.intertwine.release");
}
/// A symbol of two rectangles, the first turned `inner` degrees before the symbol is made, placed
/// as an instance that is then turned 20°.
fn turned_instance(inner: f64) -> (Session, NodeId) {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0);
let b = rect(&mut s, 150.0, 220.0, 100.0, 50.0);
if inner != 0.0 {
sel(&mut s, &[a]);
s.execute("object.rotate", &json!({"angle": inner})).unwrap();
}
sel(&mut s, &[a, b]);
let inst = id_of(&s.execute("symbol.new", &json!({"name": "Pair"})).unwrap());
sel(&mut s, &[inst]);
s.execute("object.rotate", &json!({"angle": 20})).unwrap();
assert!(near(angle(&s, inst), 20.0));
(s, inst)
}
/// Break Link and Expand with the parameters that turn instance `inst` into its art.
fn expand_instance(s: &mut Session, command: &str, inst: NodeId) {
sel(s, &[inst]);
let params = if command == "symbol.breakLink" { json!({"ids": [inst.0]}) } else { json!({"object": true, "fill": false, "stroke": false}) };
s.execute(command, &params).unwrap();
assert!(s.doc().unwrap().doc.node(inst).is_none_or(|n| !matches!(n.kind, vectorcraft_doc::NodeKind::SymbolInstance { .. })), "{command}");
}
/// The angles of the objects inside the document's only top-level object, that object included.
fn art_angles(s: &Session) -> Vec<f64> {
let d = &s.doc().unwrap().doc;
let layer = d.layers.first().unwrap();
let top = layer.children().unwrap();
assert_eq!(top.len(), 1);
let mut out = vec![];
subtree_angles(&top[0], &mut out);
out
}
#[test]
fn reset_bounding_box_then_expand_symbol_instance() {
for command in ["symbol.breakLink", "object.expand"] {
let (mut s, inst) = turned_instance(0.0);
assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 1);
assert_eq!(angle(&s, inst), 0.0);
expand_instance(&mut s, command, inst);
let angles = art_angles(&s);
assert!(angles.len() >= 2 && angles.iter().all(|a| near(*a, 0.0)), "{command}: {angles:?}");
s.execute("select.all", &json!({})).unwrap();
assert_eq!(selection_box(&s).angle, 0.0, "{command}");
}
}
#[test]
fn expanding_a_turned_symbol_instance_keeps_its_angle() {
for command in ["symbol.breakLink", "object.expand"] {
let (mut s, inst) = turned_instance(0.0);
expand_instance(&mut s, command, inst);
let angles = art_angles(&s);
assert!(angles.len() >= 2 && angles.iter().all(|a| near(*a, 20.0)), "{command}: {angles:?}");
}
}
#[test]
fn reset_bounding_box_then_expand_keeps_the_angles_inside_the_symbol() {
for command in ["symbol.breakLink", "object.expand"] {
let (mut s, inst) = turned_instance(30.0);
s.execute("object.resetBoundingBox", &json!({})).unwrap();
expand_instance(&mut s, command, inst);
// The turned rectangle keeps the 30° it had in the symbol; the rest stand square.
let mut angles = art_angles(&s);
angles.sort_by(f64::total_cmp);
assert!(near(*angles.last().unwrap(), 30.0) && angles[..angles.len() - 1].iter().all(|a| near(*a, 0.0)), "{command}: {angles:?}");
}
}
/// The group that an instance's opacity adds around the art of a one-object symbol stands as the
/// instance's box does, turned or reset.
#[test]
fn expanding_a_faded_symbol_instance_gives_its_group_the_instance_box() {
for command in ["symbol.breakLink", "object.expand"] {
for reset in [false, true] {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0);
sel(&mut s, &[a]);
let inst = id_of(&s.execute("symbol.new", &json!({"name": "One"})).unwrap());
sel(&mut s, &[inst]);
s.execute("object.rotate", &json!({"angle": 20})).unwrap();
s.execute("object.setProps", &json!({"ids": [inst.0], "opacity": 50})).unwrap();
if reset {
s.execute("object.resetBoundingBox", &json!({})).unwrap();
}
expand_instance(&mut s, command, inst);
let want = if reset { 0.0 } else { 20.0 };
let angles = art_angles(&s);
assert!(angles.len() == 2 && angles.iter().all(|a| near(*a, want)), "{command}, reset {reset}: {angles:?}");
}
}
}
/// A mirrored instance: turned 170° and then reflected, its box stands 180° from the box its
/// transform gives in one step. The art takes the instance's box, and stands square after a reset.
#[test]
fn expanding_a_reflected_symbol_instance_keeps_its_box() {
for command in ["symbol.breakLink", "object.expand"] {
for reset in [false, true] {
let (mut s, inst) = turned_instance(0.0);
s.execute("object.rotate", &json!({"angle": 150})).unwrap();
s.execute("object.reflect", &json!({"axis": "horizontal"})).unwrap();
assert!(near(angle(&s, inst), -170.0), "{}", angle(&s, inst));
if reset {
s.execute("object.resetBoundingBox", &json!({})).unwrap();
}
let want = angle(&s, inst);
expand_instance(&mut s, command, inst);
let angles = art_angles(&s);
assert!(angles.len() >= 2 && angles.iter().all(|a| near(*a, want)), "{command}, reset {reset}: {angles:?}");
}
}
}
#[test]
fn reset_bounding_box_then_release_linked_opacity_mask() {
let mut s = session();
+19
View File
@@ -184,6 +184,25 @@ fn pencil_alt_closes_path() {
assert!(ps[0].1.is_closed());
}
/// A Pencil stroke drawn as a dome and closed with Alt at the release closes with a straight segment
/// across the bottom, from where the stroke ends to where it starts: the path stays within where the
/// pen went, rather than curving on past both ends and back like a hat's brim.
#[test]
fn pencil_alt_closes_a_stroke_straight_back_to_its_start() {
let mut s = session();
let pts: Vec<(f64, f64)> =
(0..=60).map(|i| f64::from(i) * std::f64::consts::PI / 60.0).map(|a| (300.0 - 150.0 * a.cos(), 400.0 - 200.0 * a.sin())).collect();
drag(&mut s, "pencil", &pts, Mods { alt: true, ..Default::default() });
let ps = paths(&s);
let sp = &ps[0].1.subpaths[0];
assert!(sp.closed);
let b = ps[0].1.bounds().unwrap();
assert!(b.x0 > 149.5 && b.x1 < 450.5 && b.y1 < 400.5, "within the stroke: {b:?}");
let (first, last) = (sp.anchors.first().unwrap(), sp.anchors.last().unwrap());
assert!(first.p.distance(Point::new(150.0, 400.0)) < 1e-6 && last.p.distance(Point::new(450.0, 400.0)) < 1e-6, "{first:?} {last:?}");
assert_eq!((last.h_out, first.h_in), (last.p, first.p), "a straight closing segment");
}
/// #3799: with Shift held, a wavy Pencil drag draws one straight segment at 45° steps from the
/// Constrain Angle (here 30°: a drag at about 60° comes out at 75°).
#[test]
+140
View File
@@ -1075,6 +1075,146 @@ fn deleting_a_global_swatch_unlinks_art_that_keeps_its_colour() {
assert_eq!(fill_of(&s, a), linked("#2a6fb0", "Brand"));
}
/// A session whose default fill and stroke for new art are linked to the global swatch `Brand`
/// (`#2a6fb0`), in color group `G`, beside the global swatch `Other` (`#ff8800`).
fn brand_defaults_session() -> Session {
let mut s = session();
run(&mut s, "swatch.new", json!({"name": "Brand", "color": "#2a6fb0", "global": true}));
run(&mut s, "swatch.newGroup", json!({"name": "G", "swatches": ["Brand"]}));
run(&mut s, "swatch.new", json!({"name": "Other", "color": "#ff8800", "global": true}));
run(&mut s, "select.set", json!({"ids": []}));
run(&mut s, "paint.setFill", json!({"swatch": "Brand"}));
run(&mut s, "paint.setStroke", json!({"swatch": "Brand"}));
assert_eq!(s.paint.fill, linked("#2a6fb0", "Brand"));
s
}
fn fill_and_stroke(s: &Session, id: NodeId) -> (Paint, Paint) {
let a = &doc(s).node(id).unwrap().appearance;
(a.fill_paint(), a.stroke_paint())
}
/// Run `steps` and then draw a rectangle, once one command at a time and once as one
/// `command.batch`, and check that both leave the same default paints and give the rectangle drawn
/// in the batch, and one drawn after it, the same fill and stroke. Returns the batch's rectangle's
/// fill and stroke.
fn batch_matches_steps(steps: &[(&str, Value)]) -> (Paint, Paint) {
let shape = ("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}));
let mut one_by_one = brand_defaults_session();
for (id, p) in steps {
run(&mut one_by_one, id, p.clone());
}
let single = rect(&mut one_by_one);
let mut batched = brand_defaults_session();
let commands: Vec<Value> = steps.iter().chain([&shape]).map(|(id, p)| json!({"command": id, "params": p})).collect();
let r = run(&mut batched, "command.batch", json!({ "commands": commands }));
let in_batch = NodeId(r["results"][steps.len()]["id"].as_u64().unwrap());
assert_eq!(batched.paint.fill, one_by_one.paint.fill, "the default fill after {steps:?}");
assert_eq!(batched.paint.stroke, one_by_one.paint.stroke, "the default stroke after {steps:?}");
let expected = fill_and_stroke(&one_by_one, single);
assert_eq!(fill_and_stroke(&batched, in_batch), expected, "art drawn in the batch after {steps:?}");
let later = rect(&mut batched);
assert_eq!(fill_and_stroke(&batched, later), expected, "art drawn after the batch after {steps:?}");
expected
}
#[test]
fn swatch_steps_in_a_batch_change_the_new_art_paints_as_commands_do() {
let blue = Paint::solid(Color::from_hex("#2a6fb0").unwrap());
assert_eq!(batch_matches_steps(&[("swatch.delete", json!({"name": "Brand"}))]), (blue.clone(), blue));
let link = linked("#2a6fb0", "Brand");
assert_eq!(batch_matches_steps(&[("swatch.delete", json!({"name": "Brand", "unlink": false}))]), (link.clone(), link));
let signal = linked("#00ff00", "Signal");
let edit = ("swatch.edit", json!({"name": "Brand", "newName": "Signal", "color": "#00ff00"}));
assert_eq!(batch_matches_steps(&[edit]), (signal.clone(), signal));
let other = linked("#ff8800", "Other");
assert_eq!(batch_matches_steps(&[("swatch.merge", json!({"names": ["Other", "Brand"]}))]), (other.clone(), other));
let gray = linked("#ababab", "Brand");
assert_eq!(batch_matches_steps(&[("swatch.editGroup", json!({"group": "G", "colors": ["#ababab"]}))]), (gray.clone(), gray));
}
#[test]
fn undo_and_redo_of_a_swatch_edit_put_back_the_new_art_paints() {
let edit = json!({"name": "Brand", "newName": "Signal", "color": "#00ff00"});
let delete = json!({"name": "Brand"});
for (id, p, fill) in
[("swatch.edit", &edit, linked("#00ff00", "Signal")), ("swatch.delete", &delete, Paint::solid(Color::from_hex("#2a6fb0").unwrap()))]
{
for batch in [false, true] {
let mut s = brand_defaults_session();
let before = s.paint.clone();
if batch {
run(&mut s, "command.batch", json!({"commands": [{"command": id, "params": p}]}));
} else {
run(&mut s, id, p.clone());
}
assert_eq!(s.paint.fill, fill, "{id}, batch: {batch}");
let after = s.paint.clone();
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.paint, before, "Undo of {id}, batch: {batch}");
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.paint, after, "Redo of {id}, batch: {batch}");
}
}
}
#[test]
fn swatch_edits_reach_the_paints_of_the_new_art_template() {
let mut s = brand_defaults_session();
s.paint.appearance = Some(vectorcraft_doc::Appearance::basic(linked("#2a6fb0", "Brand"), linked("#2a6fb0", "Brand"), 1.0));
run(&mut s, "swatch.edit", json!({"name": "Brand", "newName": "Signal", "color": "#00ff00"}));
let template = |s: &Session| -> Vec<Paint> {
s.paint
.appearance
.iter()
.flat_map(|a| a.items.iter())
.map(|it| match it {
vectorcraft_doc::AppearanceItem::Fill(f) => f.paint.clone(),
vectorcraft_doc::AppearanceItem::Stroke(k) => k.paint.clone(),
})
.collect()
};
assert_eq!(template(&s), [linked("#00ff00", "Signal"), linked("#00ff00", "Signal")]);
run(&mut s, "swatch.delete", json!({"name": "Signal"}));
let green = Paint::solid(Color::from_hex("#00ff00").unwrap());
assert_eq!(template(&s), [green.clone(), green]);
}
#[test]
fn a_failed_batch_puts_back_the_new_art_paints_a_swatch_step_changed() {
let mut s = brand_defaults_session();
let before = s.paint.clone();
let steps = json!([
{"command": "swatch.edit", "params": {"name": "Brand", "newName": "Signal", "color": "#00ff00"}},
{"command": "no.such.command", "params": {}},
]);
assert!(s.execute("command.batch", &json!({ "commands": steps })).is_err());
assert_eq!(s.paint, before);
assert_eq!(doc(&s).swatch("Brand").unwrap().paint.color(), Some(Color::from_hex("#2a6fb0").unwrap()));
}
#[test]
fn canceling_a_swatch_options_preview_keeps_the_new_art_paints() {
let mut s = brand_defaults_session();
let before = s.paint.clone();
s.begin_interaction("Swatch Options").unwrap();
s.preview("swatch.edit", &json!({"name": "Brand", "newName": "Signal", "color": "#00ff00"})).unwrap();
s.cancel_interaction().unwrap();
assert_eq!(s.paint, before);
assert!(doc(&s).swatch("Signal").is_none());
// A previewed batch that is canceled puts them back too, and one that is accepted keeps them.
let steps = json!([{"command": "swatch.edit", "params": {"name": "Brand", "newName": "Signal", "color": "#00ff00"}}]);
s.begin_interaction("Batch Preview").unwrap();
s.preview("command.batch", &json!({ "commands": steps })).unwrap();
s.cancel_interaction().unwrap();
assert_eq!(s.paint, before);
s.begin_interaction("Batch Preview").unwrap();
s.preview("command.batch", &json!({ "commands": steps })).unwrap();
s.commit_interaction().unwrap();
assert_eq!(s.paint.fill, linked("#00ff00", "Signal"));
assert_eq!(s.paint.stroke, linked("#00ff00", "Signal"));
}
#[test]
fn deleting_several_swatches_and_groups() {
let mut s = session();
@@ -20,9 +20,18 @@ fn close(a: Rect, b: Rect) -> bool {
/// A 100 × 100 frame with a 10 × 10 square in its top-left corner and one in its center, made
/// into symbol "Panel" with 9-slice guides on, its instance selected at its natural size.
fn panel_symbol() -> (Session, NodeId) {
panel_symbol_filled(None)
}
/// [`panel_symbol`] with the frame filled with `fill` when there is one.
fn panel_symbol_filled(fill: Option<vectorcraft_color::Paint>) -> (Session, NodeId) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
let ids = [rect(&mut s, 50.0, 50.0, 100.0, 100.0), rect(&mut s, 50.0, 50.0, 10.0, 10.0), rect(&mut s, 95.0, 95.0, 10.0, 10.0)];
if let Some(fill) = fill {
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
doc.node_mut(ids[0]).unwrap().appearance = vectorcraft_doc::Appearance::basic(fill, vectorcraft_color::Paint::None, 0.0).into();
}
s.select(|_, sel| sel.set(ids)).unwrap();
let r = s.execute("symbol.new", &json!({"name": "Panel", "nineSlice": true})).unwrap();
assert_eq!(r["nineSlice"], json!([0.25, 0.25, 0.25, 0.25]));
@@ -350,3 +359,39 @@ fn guides_outside_the_art_survive_saving_and_reopening_a_native_file() {
assert!(back.symbol_slice_map(symbol, *xf).is_none(), "{bad}: scaled as a whole");
}
}
/// [`panel_symbol`] with a gradient on the frame, so that the frame is drawn in a clip group for
/// each cell, its instance resized to 200 × 150 and turned 20°.
fn turned_gradient_panel() -> (Session, NodeId) {
let gradient = vectorcraft_color::Paint::Gradient(Box::new(vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default())));
let (mut s, id) = panel_symbol_filled(Some(gradient));
s.execute("object.setBounds", &json!({"x": 50, "y": 50, "width": 200, "height": 150, "reference": 0})).unwrap();
s.execute("object.rotate", &json!({"angle": 20})).unwrap();
(s, id)
}
/// The bounding box angles of the objects under the art that replaced an instance, and whether
/// that art holds a clip group.
fn expanded_angles(s: &Session, art: NodeId) -> (Vec<f64>, bool) {
let (mut angles, mut clips) = (vec![], false);
s.doc().unwrap().doc.node(art).unwrap().walk(&mut |c| {
angles.push(c.bbox_angle);
clips |= matches!(c.kind, NodeKind::Group { clip: true, .. });
});
(angles, clips)
}
#[test]
fn break_link_turns_the_cells_of_a_sliced_instance_with_its_box() {
for reset in [false, true] {
let (mut s, _) = turned_gradient_panel();
if reset {
assert_eq!(s.execute("object.resetBoundingBox", &json!({})).unwrap()["changed"], 1);
}
let r = s.execute("symbol.breakLink", &json!({})).unwrap();
let (angles, clips) = expanded_angles(&s, NodeId(r["ids"][0].as_u64().unwrap()));
assert!(clips, "the frame is drawn in clip groups");
let want = if reset { 0.0 } else { 20.0 };
assert!(angles.iter().all(|a| (a - want).abs() < 1e-6), "reset {reset}: {angles:?}");
}
}
+2 -2
View File
@@ -110,8 +110,8 @@ pub(crate) struct PlacedEntry {
pub(crate) art: std::sync::Weak<Node>,
/// The art placed, or `None` for art placed again in every frame ([`depends_on_document`]).
pub(crate) placed: Option<Arc<Node>>,
/// The font generation ([`vectorcraft_text::FontDb::generation`]) the art was placed in, when
/// it holds type: the art is placed again when the fonts change.
/// The layout inputs ([`vectorcraft_text::layout_inputs`]) the art was placed with, when it
/// holds type: the art is placed again when the fonts or the hyphenation exceptions change.
pub(crate) fonts: Option<u64>,
/// The 9-slice map the art was placed through ([`Renderer::sliced_art`]), or `None` for art
/// placed by the transform alone ([`Renderer::placed_art`]). An entry is used only by the
+2 -2
View File
@@ -412,7 +412,7 @@ impl Renderer {
/// brush library it reads; for images and placed documents their pixels and files, the
/// layer's Dim Images and the options they are drawn with (with
/// [`crate::RenderOptions::kept_images_only`], also which pixels and undecodable notes the
/// image store holds); for type the fonts, whether overprinting shows
/// image store holds); for type the fonts and the hyphenation exceptions, whether overprinting shows
/// ([`crate::proof::overprints`]) and Document Setup's substitution highlights; and the
/// plug-ins installed, with the names of the document's swatches and patterns and its pattern
/// definitions (a plug-in's painted pieces can name them), when an object in it has a plug-in
@@ -457,7 +457,7 @@ impl Renderer {
f.doc.setup.highlight_substituted_glyphs,
o.highlight_alternate_glyphs,
));
(vectorcraft_text::FontDb::global().generation(), crate::proof::overprints(f.opts), highlights).hash(&mut h);
(vectorcraft_text::layout_inputs(), crate::proof::overprints(f.opts), highlights).hash(&mut h);
}
if reads.plugins || (doc.plugins && (reads.symbols || reads.patterns)) {
(effects::plugin_revision(), doc.names, doc.patterns).hash(&mut h);
+2 -2
View File
@@ -65,8 +65,8 @@ pub struct Renderer {
/// The latest cached glyph geometry for each text object id. A move changes the node's Arc
/// but not its glyphs in text space, so the old geometry can still be used after comparison.
text_ids: PtrMap<NodeId, usize>,
/// The [`vectorcraft_text::FontDb::generation`] `texts` was laid out with: type lays out
/// again when fonts are added or rescanned.
/// The [`vectorcraft_text::layout_inputs`] `texts` was laid out with: type lays out again
/// when fonts are added or rescanned and when the hyphenation exceptions change.
text_fonts: u64,
/// Clip regions per clipping path (see [`Self::clip_of`]).
clips: FrameCache<usize, ClipEntry>,
+4 -3
View File
@@ -127,8 +127,9 @@ struct Walked {
}
impl Walked {
/// The [`resources_key`] of the node walked: the walk's hash, the font generation when it found
/// type or a placed file, the plug-in revision and the hash of the document's swatch and pattern
/// The [`resources_key`] of the node walked: the walk's hash, the layout inputs (fonts and
/// hyphenation exceptions, [`vectorcraft_text::layout_inputs`]) when it found type or a placed
/// file, the plug-in revision and the hash of the document's swatch and pattern
/// names (`names`) when it found a plug-in effect, and the color settings `cms`. Whether a
/// plug-in paints its pieces depends on the plug-ins installed, so the names count for every
/// plug-in effect.
@@ -136,7 +137,7 @@ impl Walked {
let mut h = DefaultHasher::new();
self.hash.hash(&mut h);
if self.text {
vectorcraft_text::FontDb::global().generation().hash(&mut h);
vectorcraft_text::layout_inputs().hash(&mut h);
}
if self.plugins {
crate::effects::plugin_revision().hash(&mut h);
+4 -3
View File
@@ -39,7 +39,8 @@ type TileKey = (String, u64, Ink, bool);
/// What a tile was drawn from besides its pattern's own art, for art that shows more than itself:
/// the art of every pattern and symbol and the pixels of every image in the document, the
/// document's other data (which holds its brushes), and the fonts. Editing a symbol leaves the art
/// document's other data (which holds its brushes), and the fonts and hyphenation exceptions
/// ([`vectorcraft_text::layout_inputs`]). Editing a symbol leaves the art
/// of a pattern that shows it as it was, so the art alone kept the tile from before (#1723). Held
/// weakly: a weak reference keeps the allocation, so no other value gets one of these addresses
/// while the tile is kept.
@@ -66,13 +67,13 @@ impl Deps {
art: Self::art(doc).map(Arc::downgrade).collect(),
pixels: Self::pixels(doc).map(Arc::downgrade).collect(),
unknown: vectorcraft_doc::Shared::downgrade(&doc.unknown),
fonts: vectorcraft_text::FontDb::global().generation(),
fonts: vectorcraft_text::layout_inputs(),
}
}
/// Does `doc` hold what the tile was drawn from?
fn current(&self, doc: &Document) -> bool {
self.fonts == vectorcraft_text::FontDb::global().generation()
self.fonts == vectorcraft_text::layout_inputs()
&& std::ptr::eq(self.unknown.as_ptr(), &*doc.unknown)
&& self.art.iter().map(Weak::as_ptr).eq(Self::art(doc).map(Arc::as_ptr))
&& self.pixels.iter().map(Weak::as_ptr).eq(Self::pixels(doc).map(Arc::as_ptr))
+5 -5
View File
@@ -55,7 +55,7 @@ impl Renderer {
/// objects) hit for them. Other art is placed again in every frame, and so is art that holds
/// symbol instances, type with inline graphics, images, placed documents or raster effects
/// ([`depends_on_document`]), and art that the cache has no room for. Art that holds type is
/// placed again when the fonts change.
/// placed again when the fonts or the hyphenation exceptions change.
pub(super) fn placed_art(&mut self, art: &Arc<Node>, xf: Affine, scale_strokes: bool, inst: Option<&Node>) -> (Arc<Node>, bool) {
let kept_kind = matches!(art.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) || vectorcraft_doc::live::is_live(art);
// An instance with appearance changes of its own isn't kept: the key doesn't tell them apart.
@@ -67,7 +67,7 @@ impl Renderer {
let key = (Arc::as_ptr(art) as usize, xf.as_coeffs().map(f64::to_bits), scale_strokes, stain);
if let Some(e) = self.placed_arts.get(&key, self.stamp)
&& std::ptr::eq(e.art.as_ptr(), Arc::as_ptr(art))
&& e.fonts.is_none_or(|g| g == vectorcraft_text::FontDb::global().generation())
&& e.fonts.is_none_or(|g| g == vectorcraft_text::layout_inputs())
&& e.slice.is_none()
{
return match &e.placed {
@@ -106,7 +106,7 @@ impl Renderer {
if keeps
&& let Some(e) = self.placed_arts.get(&key, self.stamp)
&& std::ptr::eq(e.art.as_ptr(), Arc::as_ptr(art))
&& e.fonts.is_none_or(|g| g == vectorcraft_text::FontDb::global().generation())
&& e.fonts.is_none_or(|g| g == vectorcraft_text::layout_inputs())
&& e.slice.as_ref() == Some(map)
{
match e.placed.clone() {
@@ -134,7 +134,7 @@ impl Renderer {
fn keep_placed(&mut self, key: PlacedKey, art: &Arc<Node>, placed: &Arc<Node>, slice: Option<vectorcraft_doc::NineSliceMap>) -> bool {
let kept = (!depends_on_document(art)).then(|| placed.clone());
let keeps = kept.is_some();
let fonts = (keeps && holds_type(art)).then(|| vectorcraft_text::FontDb::global().generation());
let fonts = (keeps && holds_type(art)).then(vectorcraft_text::layout_inputs);
// The bytes count the key and the entry, the art's allocation that the weak reference
// keeps, and the kept art.
let bytes = size_of::<(PlacedKey, PlacedEntry, Node)>() + kept.as_deref().map_or(0, lru::node_bytes);
@@ -150,7 +150,7 @@ impl Renderer {
if !matches!(&a.kind, NodeKind::Text(own) if std::ptr::eq(&**own, t)) {
return (Arc::new(text_geom(t)), false);
}
let fonts = vectorcraft_text::FontDb::global().generation();
let fonts = vectorcraft_text::layout_inputs();
if fonts != self.text_fonts {
self.texts.clear();
self.text_ids.clear();
+78 -11
View File
@@ -3,16 +3,16 @@
use std::sync::{PoisonError, RwLock, RwLockReadGuard, RwLockWriteGuard};
use vectorcraft_doc::text::{CharStyle, TextObject};
use vectorcraft_doc::text::{CharStyle, TextKind, TextObject};
use vectorcraft_doc::{Node, NodeKind};
use vectorcraft_geom::Point;
use vectorcraft_geom::{PathData, Point};
use crate::*;
/// Taken to add fonts, or to look up an installed one by name (`FontDb::face` loads it, which
/// changes the fonts too). Renderers lay type out again when the fonts change, so tests of what
/// they keep between frames hold it shared ([`fonts_steady`]) and the tests that change the fonts
/// hold it alone ([`adding_fonts`]).
/// Taken to add fonts, to look up an installed one by name (`FontDb::face` loads it, which
/// changes the fonts too) or to change the hyphenation exceptions. Renderers lay type out again
/// when the fonts or the exceptions change, so tests of what they keep between frames hold it
/// shared ([`fonts_steady`]) and the tests that change them hold it alone ([`adding_fonts`]).
static FONTS: RwLock<()> = RwLock::new(());
/// No fonts are added by these tests until the guard is dropped.
@@ -29,14 +29,12 @@ pub(crate) fn adding_fonts() -> RwLockWriteGuard<'static, ()> {
/// What `step` returns from a run in which the fonts stayed the same. The fonts can change while
/// [`fonts_steady`] is held: the first lookup by family name catalogs the installed fonts, and
/// resolving a cataloged family, or a character no loaded font has, loads a face. `step` runs up
/// to 100 times, until [`vectorcraft_text::FontDb::generation`] is the same after a run as before
/// it.
/// to 100 times, until [`vectorcraft_text::layout_inputs`] is the same after a run as before it.
pub(crate) fn with_fonts_unchanged<T>(mut step: impl FnMut() -> T) -> T {
let db = vectorcraft_text::FontDb::global();
for _ in 0..100 {
let before = db.generation();
let before = vectorcraft_text::layout_inputs();
let out = step();
if db.generation() == before {
if vectorcraft_text::layout_inputs() == before {
return out;
}
}
@@ -466,6 +464,75 @@ fn enveloped_type_under_effects_redraws_when_its_font_becomes_available() {
}
}
/// Hyphenated area type redraws with its new line breaks when Preferences › Hyphenation ›
/// Exceptions change: its glyph geometry, the pixels of its raster effects, the art drawn for
/// geometry effects, the art kept for a symbol instance and an envelope's evaluation are made
/// again.
#[test]
fn hyphenated_type_redraws_when_the_exceptions_change() {
let _adding = adding_fonts();
struct Clear;
impl Drop for Clear {
fn drop(&mut self) {
vectorcraft_text::set_hyphenation_exceptions("");
}
}
let _clear = Clear;
use vectorcraft_doc::Effect;
let moved = || Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 0.0}), visible: true };
const WORDS: &str = "Extraordinarily comprehensive typographical considerations notwithstanding everything.";
const NEVER: &str = "extraordinarily, comprehensive, typographical, considerations, notwithstanding";
let cases = ["the area type", "the type with a drop shadow", "the group with a Transform effect", "the symbol instance", "the envelope"];
for (i, what) in cases.into_iter().enumerate() {
vectorcraft_text::set_hyphenation_exceptions("");
let mut d = Document::new(300.0, 150.0);
let mut t = TextObject::point(Point::ZERO, WORDS, CharStyle { size: 14.0, ..Default::default() });
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(10.0, 10.0, 130.0, 145.0).to_path(0.1)) };
t.xf = Affine::IDENTITY;
t.para.hyphenate = true;
let mut object = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
match i {
0 => {}
1 => {
let params = serde_json::json!({"x": 4, "y": 4, "blur": 1, "opacity": 100});
object.appearance.effects.push(Effect { id: "stylize.dropShadow".into(), params, visible: true });
}
2 => {
object = Node::group(d.alloc_id(), vec![Arc::new(object)]);
object.appearance.effects.push(moved());
}
3 => {
object.appearance.effects.push(moved());
let art = Node::group(d.alloc_id(), vec![Arc::new(object)]);
d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Words".into(), art: Arc::new(art) });
object = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Words".into(), xf: Affine::IDENTITY });
}
_ => {
let kind = vectorcraft_doc::EnvelopeKind::Warp { style: "arc".into(), bend: 30.0, h: 0.0, v: 0.0, horizontal: true, version: 0 };
let envelope = NodeKind::Envelope {
content: vec![Arc::new(object)],
kind: Box::new(kind),
fidelity: 50.0,
editing: false,
options: Default::default(),
frame: Box::new(Affine::IDENTITY),
};
object = Node::new(d.alloc_id(), envelope);
}
}
let l = d.layers[0].id;
d.insert(Some(l), 0, object).unwrap();
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
let mut r = Renderer::new();
let before = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
vectorcraft_text::set_hyphenation_exceptions(NEVER);
let after = r.render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
let fresh = Renderer::new().render(&d, 300, 150, Affine::IDENTITY, &opts).pixels;
assert_ne!(before, fresh, "{what}: the words in the list are not broken");
assert!(after == fresh, "{what}: drawn as a new renderer draws it");
}
}
/// Without an engine session, a renderer maps an envelope of type from its glyph outline bounds,
/// and measures the type again when its font becomes available.
#[test]
+4 -4
View File
@@ -320,13 +320,13 @@ fn kept_glyphs(r: &mut Renderer, d: &Document) -> Vec<Option<Arc<TextGeom>>> {
.collect()
}
/// `f`'s result from a run during which the fonts did not change: other tests add fonts, and the
/// renderer lays all type out again when the fonts change.
/// `f`'s result from a run during which the fonts and the hyphenation exceptions did not change:
/// other tests change them, and the renderer lays all type out again when they change.
fn fonts_unchanged<T>(mut f: impl FnMut() -> T) -> T {
loop {
let fonts = vectorcraft_text::FontDb::global().generation();
let inputs = vectorcraft_text::layout_inputs();
let t = f();
if vectorcraft_text::FontDb::global().generation() == fonts {
if vectorcraft_text::layout_inputs() == inputs {
return t;
}
}
+1 -1
View File
@@ -859,7 +859,7 @@ fn type_outside_its_frame_is_drawn() {
fn a_pan_draws_the_type_from_the_kept_pixels() {
let _fonts = crate::tests_fontchange::fonts_steady();
let opts = RenderOptions::default();
let fonts = || vectorcraft_text::FontDb::global().generation();
let fonts = vectorcraft_text::layout_inputs;
// How many type objects a renderer drew offscreen for `b` in `second` after drawing `a` in
// `first`. The second frame's pixels are a new renderer's, each channel within `near`. The
// frames are drawn again while the fonts change between them (other tests load fonts, and the
+3 -9
View File
@@ -491,16 +491,10 @@ pub(crate) fn text_within<'a>(bytes: &'a [u8], unpacked: &std::sync::atomic::Ato
match craft_svg::svgz::inflate(bytes, most) {
Ok(raw) => {
unpacked.fetch_add(raw.len() as u64, Ordering::Relaxed);
craft_svg::svgz::decode(&raw)
.map(|t| std::borrow::Cow::Owned(t.into_owned()))
.map_err(|_| not_utf8())
}
Err(craft_svg::svgz::Error::TooLarge(_)) => {
Err(SvgError::TooBig(format!("the SVGZ file unpacks to more than {} MB", most >> 20)))
}
Err(craft_svg::svgz::Error::Corrupt(e)) => {
Err(SvgError::Parse(format!("not a valid SVGZ file: {e}")))
craft_svg::svgz::decode(&raw).map(|t| std::borrow::Cow::Owned(t.into_owned())).map_err(|_| not_utf8())
}
Err(craft_svg::svgz::Error::TooLarge(_)) => Err(SvgError::TooBig(format!("the SVGZ file unpacks to more than {} MB", most >> 20))),
Err(craft_svg::svgz::Error::Corrupt(e)) => Err(SvgError::Parse(format!("not a valid SVGZ file: {e}"))),
Err(craft_svg::svgz::Error::Encoding) => Err(not_utf8()),
Err(e) => Err(SvgError::Parse(e.to_string())),
}
+5 -5
View File
@@ -502,16 +502,16 @@ mod tests {
}
/// Process-wide store used by `prefs.set` / restore. Each change makes envelopes measure type
/// again.
/// again, and caches of layouts lay type out again.
#[test]
fn set_hyphenation_exceptions_round_trips_the_source_string() {
let inputs = crate::layout::envelope_ink_inputs();
let inputs = crate::layout_inputs();
set_hyphenation_exceptions("typography, hap-pen");
assert_eq!(hyphenation_exceptions(), "typography, hap-pen");
let changed = crate::layout::envelope_ink_inputs();
assert_ne!(changed, inputs, "the envelope inputs change with the exceptions");
let changed = crate::layout_inputs();
assert_ne!(changed, inputs, "the layout inputs change with the exceptions");
set_hyphenation_exceptions("");
assert!(hyphenation_exceptions().is_empty());
assert_ne!(crate::layout::envelope_ink_inputs(), changed, "and again when they are cleared");
assert_ne!(crate::layout_inputs(), changed, "and again when they are cleared");
}
}
+1 -3
View File
@@ -46,9 +46,7 @@ pub(crate) use driver::{fit_passes, layout_faces_per_call};
pub(crate) use on_path::{layout_trying_each_start, path_arrangements};
use vertical::{TcyStyle, stands_upright, tate_chu_yoko, upright_cell, upright_centre, upright_extent, whole_character_widths};
#[cfg(test)]
pub(crate) use driver::envelope_ink_inputs;
pub use driver::{envelope_ink, register_envelope_ink};
pub use driver::{envelope_ink, layout_inputs, register_envelope_ink};
const EPS: f64 = 1e-6;
+5 -4
View File
@@ -191,12 +191,13 @@ pub fn envelope_ink(t: &TextObject, m: Affine, projection: Option<(vectorcraft_g
/// Make envelopes map type from [`envelope_ink`], measured again when the fonts or the hyphenation
/// exceptions change (the first registration stays, see `vectorcraft_doc::live::set_type_ink`).
pub fn register_envelope_ink() {
vectorcraft_doc::live::set_type_ink(envelope_ink, envelope_ink_inputs);
vectorcraft_doc::live::set_type_ink(envelope_ink, layout_inputs);
}
/// A number that changes whenever what [`envelope_ink`] reads besides the type changes: the
/// installed fonts and the hyphenation exceptions.
pub(crate) fn envelope_ink_inputs() -> u64 {
/// A number that changes when the fonts ([`FontDb::generation`]) or the hyphenation exceptions
/// (Preferences › Hyphenation › Exceptions) change. Caches of layouts made with the global font
/// database, and of art made from them, key on it.
pub fn layout_inputs() -> u64 {
FontDb::global().generation().rotate_left(32) ^ crate::hyphen::exceptions_generation()
}
+1 -1
View File
@@ -47,7 +47,7 @@ pub use fontdb::{
user_font_dirs,
};
pub use hyphen::{hyphenation_exceptions, set_hyphenation_exceptions};
pub use layout::{envelope_ink, register_envelope_ink};
pub use layout::{envelope_ink, layout_inputs, register_envelope_ink};
use std::sync::Arc;
pub use svg_glyph::{SvgGlyphReader, set_svg_glyph_reader};
+57 -16
View File
@@ -4,7 +4,9 @@
//! dragging, ←/→ its second value or corner radius. A click selects a parametric shape and shows
//! its handles (centre, size, rotation, corner radius and one of the kind's own), whose drags run
//! `addon.pathtools.shape.set`; a double-click on the canvas places one with the last settings.
//! Each gesture is one undo step.
//! Each gesture is one undo step. A new shape's center (from a press or a double-click) and the
//! point dragged out from it snap as a drawing tool's points do ([`DrawSnap`]), hovering before
//! the press too.
//!
//! A parametric shape is an ordinary path that keeps its numbers in one entry of its object data
//! ([`KEY`]) with a fingerprint of the outline they drew. Moved, turned or scaled by any tool it
@@ -17,6 +19,7 @@ use vectorcraft_geom::{Affine, BezPath, PathData, Point, Vec2};
use vectorcraft_pathops::addons::pathtools::shapes::fingerprint;
pub use vectorcraft_pathops::addons::pathtools::shapes::{MAX_COUNT, MAX_SIZE, MIN_COUNT, MIN_SIZE, ParametricShape, ShapeKind};
use crate::guides::DrawSnap;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
/// The tool's id.
@@ -209,7 +212,7 @@ fn angle(c: Point, p: Point, shift: bool) -> f64 {
#[derive(Clone, Debug, PartialEq)]
enum Drag {
/// Drawing a shape out from its centre.
Create { center: Point },
Create { center: Point, from: Point },
/// Dragging a handle of shape `id`, which was `shape` when the drag began at `from`.
Edit { id: NodeId, shape: ParametricShape, grip: Grip, from: Point },
}
@@ -221,11 +224,18 @@ pub struct ParametricShapeTool {
drag: Option<Drag>,
/// Where the pointer last was during the drag, and the keys held.
last: (Point, Mods),
/// Smart Guides for a new shape's center and the point dragged out from it.
snap: DrawSnap,
}
impl Default for ParametricShapeTool {
fn default() -> Self {
Self { next: ParametricShape::new(ShapeKind::Polygon, Point::ZERO, 100.0, 100.0), drag: None, last: (Point::ZERO, Mods::default()) }
Self {
next: ParametricShape::new(ShapeKind::Polygon, Point::ZERO, 100.0, 100.0),
drag: None,
last: (Point::ZERO, Mods::default()),
snap: DrawSnap::default(),
}
}
}
@@ -247,7 +257,7 @@ impl ParametricShapeTool {
fn shaped(&self, drag: &Drag, p: Point, mods: Mods) -> (&'static str, ParametricShape, Option<NodeId>) {
let fit = |v: f64| v.clamp(MIN_SIZE, MAX_SIZE);
match *drag {
Drag::Create { center } => {
Drag::Create { center, .. } => {
let r = center.distance(p);
let aspect = self.next.width / self.next.height;
let s = ParametricShape {
@@ -288,6 +298,18 @@ impl ParametricShapeTool {
}
Some(Action::Preview(cmd.into(), p))
}
/// Show the Smart Guides of a new shape's center at `p`, the pointer hovering; none over a
/// handle of the selected shape or over a parametric shape, where a press does not draw.
fn hover(&mut self, cx: &ToolContext, p: Point) {
let tol = cx.point_tol();
let on_grip = selected(cx).is_some_and(|(_, s)| grips(cx, &s).iter().any(|(_, at)| at.distance(p) <= tol));
if on_grip || shape_at(cx, p).is_some() {
self.snap.clear();
} else {
self.snap.hover(cx, p, &[], None);
}
}
}
impl Tool for ParametricShapeTool {
@@ -300,6 +322,9 @@ impl Tool for ParametricShapeTool {
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
if ev.kind == PointerKind::Move && self.drag.is_none() {
self.hover(cx, ev.pos);
}
match ev.kind {
PointerKind::Move => vec![],
PointerKind::Down => {
@@ -309,15 +334,17 @@ impl Tool for ParametricShapeTool {
near.min_by(|a, b| a.1.distance(ev.pos).total_cmp(&b.1.distance(ev.pos))).map(|(g, _)| (id, s, g))
});
if let Some((id, shape, grip)) = grip {
self.snap.clear();
self.drag = Some(Drag::Edit { id, shape, grip, from: ev.pos });
self.last = (ev.pos, ev.mods);
return vec![Action::Begin(EDIT.into())];
}
if let Some(id) = shape_at(cx, ev.pos) {
self.snap.clear();
return if cx.selection.objects == [id] { vec![] } else { vec![Action::Exec("select.set".into(), json!({ "ids": [id.0] }))] };
}
let (center, _) = crate::guides::snap_draw(cx, ev.pos, &[]);
self.drag = Some(Drag::Create { center });
let center = self.snap.press(cx, ev.pos, &[], None);
self.drag = Some(Drag::Create { center, from: ev.pos });
self.last = (center, ev.mods);
vec![Action::Begin(CREATE.into())]
}
@@ -325,15 +352,19 @@ impl Tool for ParametricShapeTool {
if self.drag.is_none() {
return vec![];
}
self.last = (ev.pos, ev.mods);
// The point a new shape is dragged out to snaps; a handle's drag does not.
let pos = if matches!(self.drag, Some(Drag::Create { .. })) { self.snap.drag(cx, ev.pos, None) } else { ev.pos };
self.last = (pos, ev.mods);
let Some(preview) = self.preview() else { return vec![] };
if ev.kind == PointerKind::Drag {
return vec![preview];
}
self.snap.clear();
let Some(drag) = self.drag.take() else { return vec![] };
match drag {
// A click draws nothing (a double-click places a shape).
Drag::Create { center } if center.distance(ev.pos) <= cx.tol(2.0) => vec![Action::Cancel],
// A click draws nothing (a double-click places a shape), even when its center
// snapped a few pixels from where it was pressed.
Drag::Create { from, .. } if from.distance(ev.pos) <= cx.tol(2.0) => vec![Action::Cancel],
Drag::Create { .. } => {
if let Action::Preview(_, p) = &preview
&& let Ok(s) = apply(self.next, p)
@@ -350,7 +381,8 @@ impl Tool for ParametricShapeTool {
if self.drag.is_some() || shape_at(cx, ev.pos).is_some() {
return vec![];
}
let s = ParametricShape { center: ev.pos, ..self.next };
let s = ParametricShape { center: self.snap.press(cx, ev.pos, &[], None), ..self.next };
self.snap.clear();
vec![Action::Exec("addon.pathtools.shape.create".into(), to_json(&s))]
}
}
@@ -358,6 +390,7 @@ impl Tool for ParametricShapeTool {
fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
if key == ToolKey::Escape && self.drag.take().is_some() {
self.snap.clear();
return vec![Action::Cancel];
}
if !matches!(self.drag, Some(Drag::Create { .. })) {
@@ -385,13 +418,16 @@ impl Tool for ParametricShapeTool {
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
if let Some(Drag::Create { center }) = &self.drag {
let (_, s, _) = self.shaped(&Drag::Create { center: *center }, self.last.0, self.last.1);
if let Some(drag @ Drag::Create { .. }) = &self.drag {
let (_, s, _) = self.shaped(drag, self.last.0, self.last.1);
let text = format!("{}\nAngle: {:.1}°", cx.size_label(s.width, s.height), s.rotation);
return vec![Overlay::Measure { p: self.last.0 + Vec2::new(cx.tol(12.0), cx.tol(12.0)), text }];
let mut out = self.snap.guides().to_vec();
out.push(Overlay::Measure { p: self.last.0 + Vec2::new(cx.tol(12.0), cx.tol(12.0)), text });
return out;
}
let Some((_, s)) = selected(cx) else { return vec![] };
// The shape's box, then its handles.
// The hovering pointer's Smart Guides, then the selected shape's box and handles.
let mut out = self.snap.guides().to_vec();
let Some((_, s)) = selected(cx) else { return out };
let (rx, ry) = (s.width / 2.0, s.height / 2.0);
let mut bp = BezPath::new();
let f: Affine = s.frame();
@@ -400,7 +436,7 @@ impl Tool for ParametricShapeTool {
if i == 0 { bp.move_to(*p) } else { bp.line_to(*p) }
}
bp.close_path();
let mut out = vec![Overlay::Path { path: bp, color: BLUE, width: 1.0, dashed: true }];
out.push(Overlay::Path { path: bp, color: BLUE, width: 1.0, dashed: true });
for (grip, p) in grips(cx, &s) {
if grip == Grip::Rotate {
out.push(Overlay::Line { a: f * Point::new(0.0, -ry), b: p, color: BLUE, dashed: false });
@@ -418,6 +454,11 @@ impl Tool for ParametricShapeTool {
Cursor::ParametricShape
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
self.snap.clear();
vec![]
}
/// The next shape's numbers (its size and rotation are a double-click's).
fn options(&self) -> Value {
let mut v = to_json(&self.next);
+75
View File
@@ -714,6 +714,81 @@ fn pen_click_near_a_glyph_anchor_lands_on_it() {
assert!(q.distance(anchor) > 1e-3, "{q:?} landed on the glyph anchor with Smart Guides off");
}
/// Snap to Glyph: the Parametric Shape tool's center (a drag and a double-click) and the point
/// dragged out from it, and the Slice tool's corners, land on a glyph's anchor point as a Pen
/// click does, with the guides shown while hovering, and not with Snap to Glyph off.
#[test]
fn parametric_shape_and_slice_points_land_on_a_glyph_anchor() {
use crate::addons::parametric_shape::ParametricShapeTool;
use crate::slice::SliceTool;
use crate::{Action, PointerEvent, PointerKind, Tool};
let (d, t) = glyph_doc("H", 0.0);
let lay = layout_text(FontDb::global(), &t);
let g = lay.glyphs.iter().find(|g| !g.outline.elements().is_empty()).expect("a glyph with an outline");
// An anchor inside the glyph's bounds, which no glyph bounds line passes through.
let bb = g.outline.bounding_box();
let inside = |p: Point| p.x > bb.x0 + 1.0 && p.x < bb.x1 - 1.0 && p.y > bb.y0 + 1.0 && p.y < bb.y1 - 1.0;
let anchor = t.xf * g.outline.segments().map(|s| s.start()).find(|p| inside(*p)).expect("an anchor inside the glyph");
let (s, p) = (Selection::default(), paint());
let click = anchor + Vec2::new(1.5, -1.0);
let ev = |kind, q: Point| PointerEvent::new(kind, q.x, q.y);
let center = |v: &serde_json::Value| Point::new(v["center"][0].as_f64().unwrap(), v["center"][1].as_f64().unwrap());
// A press away from the type, for drags that end at the click.
let away = anchor + Vec2::new(-83.3, -61.7);
for glyphs in [true, false] {
let c = ToolContext { snap_to_glyph: glyphs, ..cx(&d, &s, &p) };
let lands = |q: Point| q.distance(anchor) < 1e-9;
// A Parametric Shape drawn out from the click.
let mut tool = ParametricShapeTool::default();
tool.pointer(&c, &ev(PointerKind::Down, click));
let acts = tool.pointer(&c, &ev(PointerKind::Drag, click + Vec2::new(80.0, 60.0)));
let Some(Action::Preview(_, v)) = acts.first() else { panic!("{acts:?}") };
assert_eq!(lands(center(v)), glyphs, "drag, Snap to Glyph {glyphs}: {:?} vs {anchor:?}", center(v));
// A Parametric Shape dragged out to the click is sized and turned as one dragged out to
// the anchor itself.
let drawn_to = |q: Point| {
let mut tool = ParametricShapeTool::default();
tool.pointer(&c, &ev(PointerKind::Down, away));
tool.pointer(&c, &ev(PointerKind::Drag, q))
};
assert_eq!(drawn_to(click) == drawn_to(anchor), glyphs, "dragged point, Snap to Glyph {glyphs}");
// A click 3 px from the anchor draws nothing, though its center snaps onto the anchor.
let near = anchor + Vec2::new(2.5, -1.5);
let mut tool = ParametricShapeTool::default();
tool.pointer(&c, &ev(PointerKind::Down, near));
let acts = tool.pointer(&c, &ev(PointerKind::Drag, near));
let Some(Action::Preview(_, v)) = acts.first() else { panic!("{acts:?}") };
assert_eq!(lands(center(v)), glyphs, "click, Snap to Glyph {glyphs}: {:?} vs {anchor:?}", center(v));
assert_eq!(tool.pointer(&c, &ev(PointerKind::Up, near)), vec![Action::Cancel], "click, Snap to Glyph {glyphs}");
// One placed with a double-click.
let mut tool = ParametricShapeTool::default();
let acts = tool.pointer(&c, &ev(PointerKind::DoubleClick, click));
let Some(Action::Exec(_, v)) = acts.first() else { panic!("{acts:?}") };
assert_eq!(lands(center(v)), glyphs, "double-click, Snap to Glyph {glyphs}: {:?} vs {anchor:?}", center(v));
// The pointer hovering at the click shows the guides of the anchor it would land on.
let mut tool = ParametricShapeTool::default();
tool.pointer(&c, &ev(PointerKind::Move, click));
assert_eq!(!tool.overlays(&c).is_empty(), glyphs, "hover, Snap to Glyph {glyphs}");
// A slice dragged from the click down and to the right starts at its top-left corner.
let mut tool = SliceTool::default();
tool.pointer(&c, &ev(PointerKind::Move, click));
assert_eq!(!tool.overlays(&c).is_empty(), glyphs, "slice hover, Snap to Glyph {glyphs}");
tool.pointer(&c, &ev(PointerKind::Down, click));
let acts = tool.pointer(&c, &ev(PointerKind::Drag, click + Vec2::new(80.0, 60.0)));
let Some(Action::Preview(_, v)) = acts.get(1) else { panic!("{acts:?}") };
let corner = Point::new(v["x"].as_f64().unwrap(), v["y"].as_f64().unwrap());
assert_eq!(lands(corner), glyphs, "slice, Snap to Glyph {glyphs}: {corner:?} vs {anchor:?}");
// One dragged from up and to the left ends at its bottom-right corner.
let mut tool = SliceTool::default();
tool.pointer(&c, &ev(PointerKind::Down, away));
let acts = tool.pointer(&c, &ev(PointerKind::Drag, click));
let Some(Action::Preview(_, v)) = acts.get(1) else { panic!("{acts:?}") };
let f = |k: &str| v[k].as_f64().unwrap();
let end = Point::new(f("x") + f("width"), f("y") + f("height"));
assert_eq!(end.distance(anchor) < 1e-6, glyphs, "slice end, Snap to Glyph {glyphs}: {end:?} vs {anchor:?}");
}
}
/// Snap to Glyph: a single line of type has an x-height guide; a paragraph shows glyph bounds only.
#[test]
fn drawn_points_line_up_with_the_x_height_of_a_single_line() {
+44 -13
View File
@@ -14,7 +14,7 @@ use vectorcraft_doc::{NodeId, OrientedBox, SliceSource};
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
use crate::bbox::{Handle, hit_handle, move_delta, scale_for_drag};
use crate::guides::{PointSnap, Targets};
use crate::guides::{DrawSnap, PointSnap, Targets};
use crate::shape::drag_rect;
use crate::xform::{BLUE, polygon, rect_corners};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, json_ids};
@@ -37,9 +37,26 @@ fn has_area(r: Rect) -> bool {
r.width() > 1e-6 && r.height() > 1e-6
}
/// Snap `p` as the drawing tools do, then to the nearest ruler guide within reach (guides win).
fn snap(cx: &ToolContext, p: Point) -> (Point, Vec<Overlay>) {
let (mut q, ov) = crate::guides::snap_draw(cx, p, &[]);
/// When the Slice tool snaps a point.
#[derive(Clone, Copy, PartialEq)]
enum Phase {
/// The pointer hovers before a press.
Hover,
/// The first corner is pressed: the targets are gathered afresh.
Press,
/// The other corner is dragged: the press's targets are kept.
Drag,
}
/// Snap `p` as the drawing tools do ([`DrawSnap`]), then to the nearest ruler guide within reach
/// (guides win, and the Smart Guides are then not shown).
fn snap(draw: &mut DrawSnap, cx: &ToolContext, p: Point, phase: Phase) -> (Point, Vec<Overlay>) {
let mut q = match phase {
Phase::Hover => draw.hover(cx, p, &[], None),
Phase::Press => draw.press(cx, p, &[], None),
Phase::Drag => draw.drag(cx, p, None),
};
let mut ov = draw.guides().to_vec();
let tol = cx.tol(5.0);
let guides = cx.doc.ruler_guides();
let nearest = |vertical: bool, v: f64| {
@@ -50,9 +67,11 @@ fn snap(cx: &ToolContext, p: Point) -> (Point, Vec<Overlay>) {
};
if let Some(g) = nearest(true, p.x) {
q.x = g.pos;
ov.clear();
}
if let Some(g) = nearest(false, p.y) {
q.y = g.pos;
ov.clear();
}
(q, ov)
}
@@ -64,6 +83,8 @@ pub struct SliceTool {
last: Point,
began: bool,
guides: Vec<Overlay>,
/// Smart Guides for the slice's corners, and for the pointer hovering before a press.
snap: DrawSnap,
}
impl Tool for SliceTool {
@@ -77,14 +98,20 @@ impl Tool for SliceTool {
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
match ev.kind {
PointerKind::Move => {
if self.start.is_none() {
self.guides = snap(&mut self.snap, cx, ev.pos, Phase::Hover).1;
}
vec![]
}
PointerKind::Down => {
let (p, _) = snap(cx, ev.pos);
(self.start, self.last, self.began) = (Some(p), p, false);
let (p, guides) = snap(&mut self.snap, cx, ev.pos, Phase::Press);
(self.start, self.last, self.began, self.guides) = (Some(p), p, false, guides);
vec![]
}
PointerKind::Drag => {
let Some(start) = self.start else { return vec![] };
let (p, guides) = snap(cx, ev.pos);
let (p, guides) = snap(&mut self.snap, cx, ev.pos, Phase::Drag);
(self.last, self.guides) = (p, guides);
let mut out = vec![];
if !self.began {
@@ -102,6 +129,7 @@ impl Tool for SliceTool {
}
PointerKind::Up => {
self.guides.clear();
self.snap.clear();
let began = std::mem::take(&mut self.began);
if self.start.take().is_some() && began { vec![Action::Commit] } else { vec![] }
}
@@ -123,6 +151,8 @@ impl Tool for SliceTool {
}
o
}
// The hovering pointer's Smart Guides.
None => self.guides.clone(),
_ => vec![],
}
}
@@ -134,6 +164,7 @@ impl Tool for SliceTool {
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
self.start = None;
self.guides.clear();
self.snap.clear();
if std::mem::take(&mut self.began) { vec![Action::Cancel] } else { vec![] }
}
}
@@ -152,8 +183,8 @@ impl Drag {
}
}
/// What a user slice's handle snaps to, gathered when it is pressed: what the Slice tool's drag
/// snaps to ([`snap`]), the art through [`PointSnap`] and the ruler guides on their own.
/// What a user slice's handle snaps to, gathered when it is pressed: the art's bounds through
/// [`PointSnap`], as a bounding-box handle snaps, and the ruler guides on their own.
struct ResizeSnap {
art: PointSnap,
rulers: Targets,
@@ -164,10 +195,10 @@ impl ResizeSnap {
Self { art: PointSnap::scaling(cx, || Targets::collect(cx.doc, &[], None)), rulers: Targets::rulers(cx) }
}
/// Snap the resize `a` ([`scale_for_drag`]) of the slice `rect` by `handle` as [`snap`] snaps
/// a drawn slice's corner: the handle snaps as a bounding-box handle does
/// ([`PointSnap::snap_scale`]), then a ruler guide within 5 px of where that leaves it takes
/// what the handle moves on its axis. Returns the scale and the guides showing why.
/// Snap the resize `a` ([`scale_for_drag`]) of the slice `rect` by `handle`: the handle snaps
/// as a bounding-box handle does ([`PointSnap::snap_scale`]), then a ruler guide within 5 px of
/// where that leaves it takes what the handle moves on its axis, as [`snap`] does for a drawn
/// slice's corner. Returns the scale and the guides showing why.
fn scale(&self, cx: &ToolContext, rect: Rect, handle: Handle, a: Affine, m: Mods) -> (Affine, Vec<Overlay>) {
let bx = OrientedBox::aligned(rect);
let (a, ov) = self.art.snap_scale(cx, &bx, handle, a, m.shift, m.alt);
+3 -3
View File
@@ -134,7 +134,7 @@ fn text_hash(s: &str) -> u64 {
h.finish()
}
/// The edited text's layout, keyed by the font database's generation and the text.
/// The edited text's layout, keyed by [`vectorcraft_text::layout_inputs`] and the text.
type CachedLayout = ((u64, TextObject), Arc<TextLayout>);
/// Process-global Type-tool edit-session counter: the IME target's replacement epoch.
@@ -281,9 +281,9 @@ impl TypeTool {
}
/// Layout of `t`, cached by content (overlays run every frame) and by the fonts available (the
/// web build adds fonts after startup).
/// web build adds fonts after startup) and the hyphenation exceptions.
fn layout(&self, t: &TextObject) -> Arc<TextLayout> {
let fonts = FontDb::global().generation();
let fonts = vectorcraft_text::layout_inputs();
let mut c = self.cache.lock().unwrap_or_else(|e| e.into_inner());
if let Some(((g, k), l)) = c.as_ref()
&& *g == fonts
+3 -2
View File
@@ -167,7 +167,8 @@ pub(crate) fn port_near(cx: &ToolContext, p: Point) -> Option<(NodeId, Port, f64
}
/// Which selected text overflows, for [`thread_ports`]: each text object's layout verdict kept
/// with the object and the fonts it was made from, as overlays run every frame.
/// with the object and the layout inputs it was made with (fonts and hyphenation exceptions,
/// [`vectorcraft_text::layout_inputs`]), as overlays run every frame.
#[derive(Default)]
pub struct PortMarks {
seen: std::collections::HashMap<NodeId, ((u64, TextObject), bool)>,
@@ -178,7 +179,7 @@ pub struct PortMarks {
/// port whether text is hidden by the size of its frame, whatever tool is active. None when more
/// than [`PORTS_MAX`] of them are selected.
pub fn thread_ports(marks: &mut PortMarks, cx: &ToolContext) -> Vec<Overlay> {
let fonts = FontDb::global().generation();
let fonts = vectorcraft_text::layout_inputs();
let mut o = Vec::new();
let mut kept = std::collections::HashMap::new();
if !ports_shown(cx.doc, &cx.selection.objects) {
+30
View File
@@ -1538,6 +1538,36 @@ fn selected_type_shows_its_ports_in_each_state() {
assert!(thread_ports(&mut marks, &cx(&d, &sel, &p)).is_empty(), "only selected type shows them");
}
/// The out port shows whether the text fits as the hyphenation exceptions lay it out now: when
/// Preferences › Hyphenation › Exceptions change, the selected type is laid out again.
#[test]
fn the_out_port_follows_the_hyphenation_exceptions() {
struct Clear;
impl Drop for Clear {
fn drop(&mut self) {
vectorcraft_text::set_hyphenation_exceptions("");
}
}
let _clear = Clear;
const WORDS: &str = "Extraordinarily comprehensive typographical considerations notwithstanding everything.";
const NEVER: &str = "extraordinarily, comprehensive, typographical, considerations, notwithstanding";
let (mut d, id) = crate::testutil::doc_with_area_type();
// A 70 by 170 pt frame holds the hyphenated text, and the text without those breaks overflows
// it.
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("not text") };
t.runs = vec![TextRun::new(WORDS, CharStyle { size: 14.0, ..Default::default() })];
t.para.hyphenate = true;
t.kind = TextKind::Area { frame: vectorcraft_geom::PathData::from_bezpath(&Rect::new(0.0, 0.0, 70.0, 170.0).to_path(0.1)) };
let (mut sel, p) = (Selection::default(), paint());
sel.set([id]);
let mut marks = PortMarks::default();
let red = |ov: &[Overlay]| ov.iter().filter(|o| matches!(o, Overlay::Path { color: [230, 30, 30], .. })).count();
vectorcraft_text::set_hyphenation_exceptions("");
assert_eq!(red(&thread_ports(&mut marks, &cx(&d, &sel, &p))), 0, "the hyphenated text fits");
vectorcraft_text::set_hyphenation_exceptions(NEVER);
assert_eq!(red(&thread_ports(&mut marks, &cx(&d, &sel, &p))), 1, "the text without those breaks does not");
}
/// Past [`PORTS_MAX`] selected frames (select all of an opened PDF) no ports are drawn or clicked:
/// they would bury the canvas in boxes; up to it every frame has its two.
#[test]
+4 -3
View File
@@ -99,7 +99,7 @@ struct Drag {
began: bool,
}
/// The type object's layout, keyed by the font database's generation and the object.
/// The type object's layout, keyed by [`vectorcraft_text::layout_inputs`] and the object.
type CachedLayout = ((u64, TextObject), Arc<TextLayout>);
#[derive(Default)]
@@ -196,9 +196,10 @@ fn char_box(t: &TextObject, lay: &TextLayout, gi: usize) -> Option<CharBox> {
}
impl TouchTypeTool {
/// Layout of `t`, cached by content (overlays run every frame) and by the fonts available.
/// Layout of `t`, cached by content (overlays run every frame), by the fonts available and by
/// the hyphenation exceptions.
fn layout(&self, t: &TextObject) -> Arc<TextLayout> {
let fonts = FontDb::global().generation();
let fonts = vectorcraft_text::layout_inputs();
let mut c = self.cache.lock().unwrap_or_else(|e| e.into_inner());
if let Some(((g, k), l)) = c.as_ref()
&& *g == fonts
+6 -2
View File
@@ -1691,7 +1691,7 @@ pub(crate) fn end_stale_preview(app: &mut VectorcraftApp, ctx: &egui::Context) {
}
/// What the font menus preview on and apply to: the Type tool's selected range while it edits
/// text (the whole text when nothing is selected), else the selected text objects.
/// text (the whole text when nothing is selected), else every frame of each selected story.
pub(crate) fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, pick: FontPick) {
let previewing = crate::panels::pstate::<bool>(ctx, PREVIEWING);
match pick {
@@ -1791,7 +1791,11 @@ fn font_command(app: &VectorcraftApp, family: &str, style: Option<&str>) -> (&'s
}
("text.setRangeStyle", p)
}
None => ("text.setStyle", p),
None => {
// With the Selection tool: every frame of each selected story.
crate::panels::character::target_selected_stories(app, &mut p);
("text.setStyle", p)
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+75 -21
View File
@@ -2,6 +2,7 @@
//! scale, baseline shift, character rotation, the anti-aliasing method and the All Caps, Small Caps,
//! Superscript, Subscript, Underline and Strikethrough toggles and the Language menu.
use std::collections::HashSet;
use std::sync::{Mutex, OnceLock};
use egui::{Key, Ui, vec2};
@@ -99,8 +100,8 @@ pub(crate) fn shared_text_flag(app: &VectorcraftApp, f: impl Fn(&vectorcraft_doc
}
/// `f` of the character styles the panels act on: the runs the Type tool's selection covers (the
/// caret's style at a caret), else every run of the selected type, groups' included. `None` (a
/// blank field) where they differ, or with no type.
/// caret's style at a caret), else every run of every frame of the selected stories
/// ([`selected_stories`]). `None` (a blank field) where they differ, or with no type.
pub(crate) fn shared<T: PartialEq>(app: &VectorcraftApp, f: impl Fn(&CharStyle) -> T) -> Option<T> {
shared_styles(app, |_, _, style, _| Some(f(style)))
}
@@ -162,12 +163,8 @@ fn shared_styles<T: PartialEq>(
None => each_paragraph_run(t, Some((a, b)), |para, style, inline| acc.add(f(t, para, style, inline))),
}
} else {
for n in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
n.walk(&mut |c| {
if let NodeKind::Text(t) = &c.kind {
each_paragraph_run(t, None, |para, style, inline| acc.add(f(t, para, style, inline)));
}
});
for t in selected_story_texts(app) {
each_paragraph_run(t, None, |para, style, inline| acc.add(f(t, para, style, inline)));
}
}
acc.value().flatten()
@@ -243,9 +240,12 @@ pub(crate) fn format(app: &mut VectorcraftApp, p: Value) {
/// [`format`] that returns the command's result: `text.setRangeStyle` on the range the Type tool
/// has selected (the whole text when nothing is selected) while it edits text, else
/// `text.setFormat` on the selected text objects.
/// `text.setFormat` on the selected stories ([`selected_stories`]).
pub(crate) fn run_format(app: &mut VectorcraftApp, mut p: Value) -> Result<Value, String> {
let Some((id, a, b)) = text_editing(app) else { return app.run("text.setFormat", p) };
let Some((id, a, b)) = text_editing(app) else {
target_selected_stories(app, &mut p);
return app.run("text.setFormat", p);
};
if let Some(first) = text_story(app).and_then(|story| story.first().copied()) {
return story_style(app, first, p);
}
@@ -283,9 +283,11 @@ pub(crate) fn shared_warichu(app: &VectorcraftApp) -> SharedWarichu {
}
/// Apply character attributes: to the selected range while the Type tool edits text (the whole
/// object when nothing is selected), else to the selected text objects.
fn char_cmd(app: &mut VectorcraftApp, cmd: &str, p: Value) {
/// object when nothing is selected), else to every frame of the selected stories
/// ([`selected_stories`]).
fn char_cmd(app: &mut VectorcraftApp, cmd: &str, mut p: Value) {
if text_editing(app).is_none() {
target_selected_stories(app, &mut p);
app.run(cmd, p).ok();
return;
}
@@ -546,6 +548,55 @@ pub(crate) fn text_story(app: &VectorcraftApp) -> Option<Vec<NodeId>> {
(ids.len() >= 2).then_some(ids)
}
/// The type objects the panels act on with the Selection tool: the selected type objects and those
/// in selected groups, each with every frame of its threaded story (locked and hidden frames
/// included), each once, the frames of a story in thread order.
pub(crate) fn selected_stories(app: &VectorcraftApp) -> Vec<NodeId> {
let Some(st) = app.session.active() else { return Vec::new() };
let mut frames = Vec::new();
let mut taken = HashSet::new();
for selected in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
selected.walk(&mut |child| {
if matches!(child.kind, NodeKind::Text(_)) && !taken.contains(&child.id) {
for id in st.doc.story_of(child.id) {
if taken.insert(id) {
frames.push(id);
}
}
}
});
}
frames
}
/// The type objects of [`selected_stories`].
pub(crate) fn selected_story_texts(app: &VectorcraftApp) -> Vec<&vectorcraft_doc::TextObject> {
let Some(st) = app.session.active() else { return Vec::new() };
selected_stories(app)
.into_iter()
.filter_map(|id| match st.doc.node(id).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => Some(t.as_ref()),
_ => None,
})
.collect()
}
/// Give the command parameters `p` the `ids` of [`selected_stories`], so a command run with the
/// Selection tool sets every frame of each selected story. `p` stays as it is when it names its
/// targets, with no type selected, and while an object of a symbol instance's art is selected,
/// which the commands refuse.
pub(crate) fn target_selected_stories(app: &VectorcraftApp, p: &mut Value) {
if p.get("ids").is_some() || p.get("id").is_some() || app.session.active().is_none_or(|st| st.selection.symbol_part.is_some()) {
return;
}
let ids = selected_stories(app);
if let Some(params) = p.as_object_mut()
&& !ids.is_empty()
{
params.insert("ids".into(), json!(ids.iter().map(|id| id.0).collect::<Vec<_>>()));
}
}
/// `text.setRangeStyle` with `p` on all the text of every frame of the story that `first` is a
/// frame of, as one undo step.
fn story_style(app: &mut VectorcraftApp, first: NodeId, mut p: Value) -> Result<Value, String> {
@@ -910,7 +961,8 @@ fn anti_alias_method(app: &mut VectorcraftApp, ui: &mut Ui, width: f32) {
}
/// The anti-aliasing method of the type objects the panel acts on: the one the Type tool edits,
/// else the selected type (groups' included). `None` where they differ, or with no type.
/// else every frame of the selected stories ([`selected_stories`]). `None` where they differ, or
/// with no type.
fn shared_anti_alias(app: &VectorcraftApp) -> Option<TypeAntiAlias> {
let st = app.session.active()?;
let mut acc = Shared::None;
@@ -921,12 +973,8 @@ fn shared_anti_alias(app: &VectorcraftApp) -> Option<TypeAntiAlias> {
}
}
None => {
for n in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
n.walk(&mut |c| {
if let NodeKind::Text(t) = &c.kind {
acc.add(t.anti_alias);
}
});
for t in selected_story_texts(app) {
acc.add(t.anti_alias);
}
}
}
@@ -934,12 +982,14 @@ fn shared_anti_alias(app: &VectorcraftApp) -> Option<TypeAntiAlias> {
}
/// Set the anti-aliasing method `m` of the frame the Type tool edits, whatever part of its text
/// is selected, else the selected type objects (`text.setFormat {antiAlias}`).
/// is selected, else every frame of the selected stories (`text.setFormat {antiAlias}`).
fn set_anti_alias(app: &mut VectorcraftApp, m: TypeAntiAlias) {
let mut p = json!({"antiAlias": m.id()});
if let Some((id, _, _)) = text_editing(app) {
end_typing(app);
p["ids"] = json!([id.0]);
} else {
target_selected_stories(app, &mut p);
}
app.run("text.setFormat", p).ok();
}
@@ -1160,7 +1210,11 @@ fn axes_cmd(app: &mut VectorcraftApp, axes: Value, phase: widgets::Live) {
}
super::live_run(app, "Character", "text.setRangeStyle", q, phase);
}
None => super::live_run(app, "Character", "text.setFormat", json!({ "fontAxes": axes }), phase),
None => {
let mut p = json!({ "fontAxes": axes });
target_selected_stories(app, &mut p);
super::live_run(app, "Character", "text.setFormat", p, phase);
}
}
}
@@ -1741,3 +1741,141 @@ fn story_changes_take_in_locked_and_hidden_frames() {
assert_eq!(story_sizes(&app, id).iter().filter(|s| (**s - 9.0).abs() > 1e-9).count(), 0, "frame {id} takes the size");
}
}
/// [`three_frame_story`] with its second frame locked and its third hidden, a type object of its own
/// below it, and the first frame selected with the Selection tool → the app, the frames and the
/// other type object.
fn story_selected_in_its_first_frame() -> (VectorcraftApp, [u64; 3], u64) {
let (mut app, a, b, c) = three_frame_story(60);
let other = app.run("text.create", json!({"x": 10, "y": 250, "text": "Other", "size": 12})).unwrap()["id"].as_u64().unwrap();
app.run("select.set", json!({"ids": [b]})).unwrap();
app.run("object.lock", json!({})).unwrap();
app.run("select.set", json!({"ids": [c]})).unwrap();
app.run("object.hide", json!({})).unwrap();
app.run("select.set", json!({"ids": [a]})).unwrap();
assert_eq!(text_editing(&app), None);
(app, [a, b, c], other)
}
fn undo_steps(app: &VectorcraftApp) -> usize {
app.session.active().unwrap().history.undo.len()
}
/// With the Selection tool, a selected frame of a threaded story stands for the whole story: the
/// panel's changes reach its locked and hidden frames, each in one undo step, and leave other type
/// alone.
#[test]
fn selection_tool_changes_reach_every_frame_of_the_story() {
let (mut app, frames, other) = story_selected_in_its_first_frame();
assert_eq!(selected_stories(&app), frames.map(NodeId).to_vec());
let steps = undo_steps(&app);
style(&mut app, json!({"size": 9}));
assert_eq!(undo_steps(&app), steps + 1, "one undo step");
for id in frames {
assert!(story_sizes(&app, id).iter().all(|s| (*s - 9.0).abs() < 1e-9), "frame {id} takes the size");
}
assert!(story_sizes(&app, other).iter().all(|s| (*s - 12.0).abs() < 1e-9), "other type keeps its size");
app.run("edit.undo", json!({})).unwrap();
for id in frames {
assert!(story_sizes(&app, id).iter().all(|s| (*s - 12.0).abs() < 1e-9), "undo gives frame {id} its size back");
}
format(&mut app, json!({"underline": true}));
for id in frames {
assert!(story_frame(&app, id).runs.iter().all(|r| r.style.underline), "frame {id} is underlined");
}
assert!(story_frame(&app, other).runs.iter().all(|r| !r.style.underline));
// Tate-chu-yoko Settings and Warichu Settings set their values through `run_format`.
run_format(&mut app, json!({"tcyUpDown": 2})).unwrap();
for id in frames {
assert!(story_frame(&app, id).runs.iter().all(|r| (r.style.tcy_up_down - 2.0).abs() < 1e-9), "frame {id} takes Up/Down");
}
assert!(story_frame(&app, other).runs.iter().all(|r| r.style.tcy_up_down.abs() < 1e-9));
}
/// With the Selection tool the anti-aliasing method goes to every frame of the selected story.
#[test]
fn selection_tool_anti_aliasing_reaches_every_frame_of_the_story() {
let (mut app, frames, other) = story_selected_in_its_first_frame();
set_anti_alias(&mut app, TypeAntiAlias::Strong);
for id in frames {
assert_eq!(story_frame(&app, id).anti_alias, TypeAntiAlias::Strong, "frame {id}");
}
assert_eq!(story_frame(&app, other).anti_alias, TypeAntiAlias::Sharp);
assert_eq!(shared_anti_alias(&app), Some(TypeAntiAlias::Strong));
}
/// With the Selection tool a Variable Font slider sets the axis on every frame of the selected
/// story, in one undo step.
#[test]
fn selection_tool_font_axes_reach_every_frame_of_the_story() {
use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font};
vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap());
let (mut app, frames, _) = story_selected_in_its_first_frame();
app.run("text.setStyle", json!({"ids": frames, "font": TWO_AXIS_FAMILY})).unwrap();
app.run("select.set", json!({"ids": [frames[0]]})).unwrap();
let steps = undo_steps(&app);
axes_cmd(&mut app, json!({"wght": 700}), widgets::Live::Released);
assert_eq!(undo_steps(&app), steps + 1, "one undo step");
for id in frames {
let t = story_frame(&app, id);
assert!(t.runs.iter().all(|r| r.style.font_axes.get("wght") == Some(&700.0)), "frame {id}: {:?}", t.runs[0].style.font_axes);
}
}
/// With the Selection tool a font family picked in the font menu, and its preview, go to every
/// frame of the selected story, in one undo step.
#[test]
fn selection_tool_font_family_reaches_every_frame_of_the_story() {
let (mut app, frames, other) = story_selected_in_its_first_frame();
let family = |app: &VectorcraftApp, id: u64| story_frame(app, id).runs.iter().map(|r| r.style.font_family.clone()).collect::<Vec<_>>();
let before = family(&app, other);
let ctx = egui::Context::default();
crate::font_menu::apply(&mut app, &ctx, crate::font_menu::FontPick::Preview("Source Serif 4".into(), None));
for id in frames {
assert!(family(&app, id).iter().all(|f| f == "Source Serif 4"), "the preview shows on frame {id}");
}
let steps = undo_steps(&app);
crate::font_menu::apply(&mut app, &ctx, crate::font_menu::FontPick::Chosen("Source Serif 4".into(), None));
assert_eq!(undo_steps(&app), steps + 1, "one undo step");
for id in frames {
assert!(family(&app, id).iter().all(|f| f == "Source Serif 4"), "frame {id} takes the family");
}
assert_eq!(family(&app, other), before, "other type keeps its family");
}
/// The fields show a value only when every frame of the selected story has it: a hidden frame that
/// differs leaves them blank.
#[test]
fn selection_tool_fields_read_every_frame_of_the_story() {
let (mut app, [a, b, c], _) = story_selected_in_its_first_frame();
assert_eq!(shared(&app, |s| s.size), Some(12.0));
assert_eq!(shared_anti_alias(&app), Some(TypeAntiAlias::Sharp));
app.run("text.setStyle", json!({"ids": [c], "size": 20})).unwrap();
app.run("text.setFormat", json!({"ids": [b], "antiAlias": TypeAntiAlias::Crisp.id()})).unwrap();
app.run("select.set", json!({"ids": [a]})).unwrap();
assert_eq!(shared(&app, |s| s.size), None, "the hidden frame's size differs");
assert_eq!(shared_anti_alias(&app), None, "the locked frame's anti-aliasing differs");
}
/// Two frames of one story, or a frame in a selected group, count once; an unthreaded type
/// object is a story of its own.
#[test]
fn selected_stories_list_each_frame_once() {
let (mut app, [a, b, c], other) = story_selected_in_its_first_frame();
app.run("object.unlockAll", json!({})).unwrap();
app.run("select.set", json!({"ids": [b, a]})).unwrap();
assert_eq!(selected_stories(&app), [a, b, c].map(NodeId).to_vec(), "thread order, each once");
app.run("select.set", json!({"ids": [other, a]})).unwrap();
let group = app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap();
app.run("select.set", json!({"ids": [group]})).unwrap();
let mut listed = selected_stories(&app);
listed.sort_by_key(|id| id.0);
let mut expected = [a, b, c, other].map(NodeId).to_vec();
expected.sort_by_key(|id| id.0);
assert_eq!(listed, expected);
app.run("select.set", json!({"ids": []})).unwrap();
assert!(selected_stories(&app).is_empty());
}
+24 -37
View File
@@ -28,8 +28,9 @@ pub const ALIGNMENTS: [(Justify, &str, &str, &str); 7] = [
(Justify::JustifyAll, "dc-para-justify-all", "Justify all lines", "justifyAll"),
];
/// Paragraph attributes apply to the selected text objects' paragraphs or, while the Type tool
/// edits text, to the paragraphs its selection (or caret) touches (ending its typing session).
/// Paragraph attributes apply to the paragraphs of every frame of the selected stories or, while
/// the Type tool edits text, to the paragraphs its selection (or caret) touches (ending its typing
/// session).
fn format(app: &mut VectorcraftApp, p: Value) {
para_cmd(app, "text.setFormat", p);
}
@@ -75,8 +76,16 @@ fn para_cmd(app: &mut VectorcraftApp, cmd: &str, p: Value) {
run_on_paragraphs(app, cmd, p).ok();
}
/// Run `cmd` on the paragraphs the Paragraph panel acts on ([`format`]) and return its result.
pub(crate) fn run_on_paragraphs(app: &mut VectorcraftApp, cmd: &str, mut p: Value) -> Result<Value, String> {
/// Run `cmd` on the paragraphs the Paragraph panel acts on ([`format`]) and return its result. With
/// the Selection tool these are the paragraphs of every frame of the selected stories
/// ([`super::character::selected_stories`]).
pub(crate) fn run_on_paragraphs(app: &mut VectorcraftApp, cmd: &str, p: Value) -> Result<Value, String> {
run_on(app, cmd, p, true)
}
/// [`run_on_paragraphs`], with the Selection tool on the selected type objects alone unless
/// `stories`.
fn run_on(app: &mut VectorcraftApp, cmd: &str, mut p: Value, stories: bool) -> Result<Value, String> {
if let Some((id, a, b)) = super::character::text_editing(app) {
super::character::end_typing(app);
match super::character::text_story(app) {
@@ -88,6 +97,8 @@ pub(crate) fn run_on_paragraphs(app: &mut VectorcraftApp, cmd: &str, mut p: Valu
p["end"] = json!(b);
}
}
} else if stories {
super::character::target_selected_stories(app, &mut p);
}
app.run(cmd, p)
}
@@ -208,14 +219,10 @@ fn visit_paragraphs(app: &VectorcraftApp, mut f: impl FnMut(&ParaStyle)) {
}
return;
}
for node in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
node.walk(&mut |c| {
if let NodeKind::Text(t) = &c.kind {
for i in 0..t.paragraph_count() {
f(t.para_at(i));
}
}
});
for t in super::character::selected_story_texts(app) {
for i in 0..t.paragraph_count() {
f(t.para_at(i));
}
}
}
@@ -328,18 +335,8 @@ fn selected_paragraph_direction(app: &VectorcraftApp, fallback: &ParaStyle) -> O
{
return if collect_text_direction(text, Some((a, b)), &mut shared) { shared.or(default) } else { None };
}
fn collect(node: &vectorcraft_doc::Node, shared: &mut Option<bool>) -> bool {
if let NodeKind::Text(text) = &node.kind {
return collect_text_direction(text, None, shared);
}
node.children().is_none_or(|children| children.iter().all(|child| collect(child, shared)))
}
for node in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
if !collect(node, &mut shared) {
return None;
}
}
shared.or(default)
let texts = super::character::selected_story_texts(app);
if texts.into_iter().all(|t| collect_text_direction(t, None, &mut shared)) { shared.or(default) } else { None }
}
/// A hyphenation option shared by every paragraph in scope, or `None` when they differ.
@@ -365,18 +362,8 @@ fn selected_alignment(app: &VectorcraftApp, fallback: &ParaStyle) -> Option<Just
{
return if collect_text_alignment(text, Some((a, b)), &mut shared) { shared.or(default) } else { None };
}
fn collect(node: &vectorcraft_doc::Node, shared: &mut Option<Justify>) -> bool {
if let NodeKind::Text(text) = &node.kind {
return collect_text_alignment(text, None, shared);
}
node.children().is_none_or(|children| children.iter().all(|child| collect(child, shared)))
}
for node in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
if !collect(node, &mut shared) {
return None;
}
}
shared.or(default)
let texts = super::character::selected_story_texts(app);
if texts.into_iter().all(|t| collect_text_alignment(t, None, &mut shared)) { shared.or(default) } else { None }
}
/// The seven alignment buttons shared by Paragraph and Properties; no button is selected
@@ -492,7 +479,7 @@ pub(crate) fn number_sample(style: vectorcraft_doc::text::NumberStyle, n: u32) -
/// the Type tool's anchor and caret past the characters inserted at or before them).
pub(crate) fn convert_list_to_text(app: &mut VectorcraftApp) -> Result<Value, String> {
text_style(app).ok_or("select some type first")?;
run_on_paragraphs(app, "text.convertListToText", json!({}))
run_on(app, "text.convertListToText", json!({}), false)
}
/// The Bullets button and the arrow beside it (Paragraph panel). The button makes the paragraphs
@@ -1035,3 +1035,58 @@ fn reset_panel_leaves_an_undo_group_open_in_another_document() {
app.run("edit.undoGroup.end", json!({})).unwrap();
assert_eq!(steps(app.session.active().unwrap()), a_steps, "the later edit is part of the Batch step");
}
/// With the Selection tool a selected frame of a threaded story stands for the whole story: the
/// Paragraph panel's changes and Reset Panel reach its locked and hidden frames, each in one undo
/// step, other type keeps its paragraphs, and the alignment buttons read every frame.
#[test]
fn selection_tool_paragraph_changes_reach_every_frame_of_the_story() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 900, "height": 300})).unwrap();
let story = "The quick brown fox jumps over the lazy dog.\n".repeat(12);
let area = json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 60}});
let a = app.run("text.create", area).unwrap()["id"].as_u64().unwrap();
let b = app.run("text.thread.add", json!({"from": a, "rect": [200, 10, 150, 60]})).unwrap()["id"].as_u64().unwrap();
let c = app.run("text.thread.add", json!({"from": b, "rect": [400, 10, 150, 200]})).unwrap()["id"].as_u64().unwrap();
let other = app.run("text.create", json!({"x": 10, "y": 250, "text": "Other", "size": 12})).unwrap()["id"].as_u64().unwrap();
app.run("select.set", json!({"ids": [b]})).unwrap();
app.run("object.lock", json!({})).unwrap();
app.run("select.set", json!({"ids": [c]})).unwrap();
app.run("object.hide", json!({})).unwrap();
app.run("select.set", json!({"ids": [a]})).unwrap();
let paragraphs = |app: &VectorcraftApp, id: u64| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => (0..t.paragraph_count()).map(|i| t.para_at(i).clone()).collect::<Vec<_>>(),
_ => panic!("not text"),
};
let undo_steps = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len();
for id in [a, b, c] {
assert!(!paragraphs(&app, id).is_empty(), "frame {id} holds text");
}
let steps = undo_steps(&app);
format(&mut app, json!({"spaceBefore": 4}));
assert_eq!(undo_steps(&app), steps + 1, "one undo step");
for id in [a, b, c] {
assert!(paragraphs(&app, id).iter().all(|p| (p.space_before - 4.0).abs() < 1e-9), "frame {id} takes the space");
}
assert!(paragraphs(&app, other).iter().all(|p| p.space_before.abs() < 1e-9), "other type keeps its paragraphs");
assert_eq!(shared_para_field(&app, |p| p.space_before), Some(4.0));
align(&mut app, "center").unwrap();
for id in [a, b, c] {
assert!(paragraphs(&app, id).iter().all(|p| p.justify == Justify::Center), "frame {id} is centered");
}
assert!(paragraphs(&app, other).iter().all(|p| p.justify != Justify::Center));
assert_eq!(selected_alignment(&app, &ParaStyle::default()), Some(Justify::Center));
// A hidden frame aligned otherwise leaves no alignment button on.
app.run("text.setStyle", json!({"ids": [c], "justify": "right"})).unwrap();
app.run("select.set", json!({"ids": [a]})).unwrap();
assert_eq!(selected_alignment(&app, &ParaStyle::default()), None);
let steps = undo_steps(&app);
reset_panel(&mut app);
assert_eq!(undo_steps(&app), steps + 1, "Reset Panel is one undo step");
for id in [a, b, c] {
assert!(paragraphs(&app, id).iter().all(|p| p.space_before.abs() < 1e-9 && p.justify == Justify::Auto), "frame {id} is reset");
}
}
+21 -3
View File
@@ -419,7 +419,9 @@ fn stops_json(stops: &[TabStop]) -> Value {
)
}
/// Run `text.tabs.set` with `stops` on the text the panel acts on. Returns false when it fails.
/// Run `text.tabs.set` with `stops` on the text the panel acts on: with the Selection tool, every
/// frame of the selected stories ([`super::character::selected_stories`]). Returns false when it
/// fails.
fn apply(app: &mut VectorcraftApp, stops: &[TabStop]) -> bool {
if let Some((id, a, b)) = super::character::text_editing(app) {
super::character::end_typing(app);
@@ -430,7 +432,9 @@ fn apply(app: &mut VectorcraftApp, stops: &[TabStop]) -> bool {
};
app.run("text.tabs.set", p).is_ok()
} else {
app.run("text.tabs.set", json!({"stops": stops_json(stops)})).is_ok()
let mut p = json!({"stops": stops_json(stops)});
super::character::target_selected_stories(app, &mut p);
app.run("text.tabs.set", p).is_ok()
}
}
@@ -727,6 +731,20 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
resp.on_hover_text(tl!("Click to add a tab stop, drag to move it, drag it off the ruler to delete it"));
}
/// Whether a paragraph of the whole story Clear All Tabs acts on has tab stops: after Select All in
/// threaded text, every frame of the edited story; with the Selection tool, every frame of the
/// selected stories ([`super::character::selected_stories`]).
fn story_has_tabs(app: &VectorcraftApp) -> bool {
let has_tabs = |t: &TextObject| (0..t.paragraph_count()).any(|i| !t.para_at(i).tabs.is_empty());
match (super::character::text_editing(app), super::character::text_story(app), app.session.active()) {
(Some(_), Some(story), Some(st)) => {
story.iter().any(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Text(t)) if has_tabs(t)))
}
(Some(_), _, _) => false,
(None, _, _) => super::character::selected_story_texts(app).into_iter().any(has_tabs),
}
}
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
let Some((_, para, _)) = current_para(app) else {
ui.add_enabled(false, egui::Button::new(tl!("Select text")).frame(false));
@@ -742,7 +760,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
if sel >= stops.len() {
sel = stops.len().saturating_sub(1);
}
if menu_item(ui, tl!("Clear All Tabs"), !stops.is_empty(), false)
if menu_item(ui, tl!("Clear All Tabs"), !stops.is_empty() || story_has_tabs(app), false)
&& let Err(e) = super::paragraph::run_on_paragraphs(app, "text.tabs.clear", json!({}))
{
app.status(e);
+37
View File
@@ -642,6 +642,43 @@ fn text_of(app: &VectorcraftApp, id: u64) -> vectorcraft_doc::TextObject {
}
}
/// With the Selection tool, tab stops set in the panel go to every paragraph of every frame of a
/// selected threaded story, its locked and hidden frames included.
#[test]
fn selection_tool_tab_stops_reach_every_frame_of_the_story() {
let (mut app, frames) = split_story_app();
app.run("select.set", json!({"ids": [frames[1]]})).unwrap();
app.run("object.lock", json!({})).unwrap();
app.run("select.set", json!({"ids": [frames[2]]})).unwrap();
app.run("object.hide", json!({})).unwrap();
app.run("select.set", json!({"ids": [frames[0]]})).unwrap();
let stop = TabStop { position: 36.0, align: Default::default(), leader: String::new(), align_on: '.' };
assert!(apply(&mut app, std::slice::from_ref(&stop)));
for id in frames {
let t = text_of(&app, id);
assert!((0..t.paragraph_count()).all(|i| t.para_at(i).tabs == [stop.clone()]), "frame {id} takes the stop");
}
}
/// With the Selection tool, Clear All Tabs is on when only another frame of the selected story has
/// tab stops, and clears them there.
#[test]
fn clear_all_tabs_is_on_when_another_frame_of_the_story_has_stops() {
let (mut app, [a, b, c]) = split_story_app();
app.run("text.tabs.set", json!({"ids": [c], "stops": [{"position": 36}]})).unwrap();
app.run("select.set", json!({"ids": [c]})).unwrap();
app.run("object.hide", json!({})).unwrap();
app.run("select.set", json!({"ids": [a]})).unwrap();
assert!(current(&app).unwrap().1.is_empty(), "the selected frame has no stops");
assert!(story_has_tabs(&app));
super::super::paragraph::run_on_paragraphs(&mut app, "text.tabs.clear", json!({})).unwrap();
for id in [a, b, c] {
let t = text_of(&app, id);
assert!((0..t.paragraph_count()).all(|i| t.para_at(i).tabs.is_empty()), "frame {id} has no stops");
}
assert!(!story_has_tabs(&app));
}
/// Each paragraph of the story of `frames` by its first word, with its left indent and first-line
/// position, counted once; each piece of a paragraph split between frames has the same values.
fn story_paragraphs(app: &VectorcraftApp, frames: &[u64]) -> Vec<(String, f64, f64)> {
+16
View File
@@ -1040,6 +1040,22 @@ fn open_menu_with(id: &str, left: f32, width: f32, screen: egui::Vec2, look: Men
assert!(frames < 1000, "the menu's samples are still rendering after {frames} frames");
shapes = frame(vec![]);
}
// egui places the menu by the size it had the frame before. When a name measured late (a face
// added for its script, a font found in the background) widens it on that frame, the menu sits
// where the narrower one fit until the next: draw until it stays put.
let filter_at = |shapes: &[egui::epaint::ClippedShape]| {
shapes.iter().find_map(|s| match &s.shape {
egui::Shape::Text(t) if t.galley.text() == crate::i18n::t("Any Kind") => Some(t.pos),
_ => None,
})
};
for _ in 0..100 {
let before = filter_at(&shapes);
shapes = frame(vec![]);
if filter_at(&shapes) == before && !crate::font_menu::wait_for_samples(&ctx) {
break;
}
}
let mut texts = vec![];
let mut samples = vec![];
let mut tints = vec![];
+2 -2
View File
@@ -49,7 +49,7 @@ plus review. Native-speaker review is human work and isn't in the hours.
| Language | Code | Translated | Status |
|---|---|---|---|
| Traditional Chinese (Taiwan) | `zh-hant` | 4,892 rows, 790 of them `@msg` (counted 2026-10-21): menus, `tl!` literals and messages complete when counted (80% overall) | partial |
| Traditional Chinese (Taiwan) | `zh-hant` | 8,820 rows, 3,438 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 40 missing messages for it; menus, `tl!` literals and messages were complete when counted on 2026-10-21 (80% overall) | partial |
| Italian | `it` | 8,757 rows, 3,422 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 40 missing messages for it | full |
| Dutch | `nl` | 7,299 rows, 1,609 of them `@msg` and 47 `@msgplural` (counted 2026-11-13 on `main` at `9a1a158a3`): in `COMPLETE_MESSAGES`, and the untranslated report lists the same missing messages for it as for German; one/other plurals | full |
| Danish | `da` | 5,279 rows, 750 of them `@msg` (counted 2026-10-21): menus and `tl!` literals complete when counted; its messages are partial; one/other plurals | full |
@@ -58,7 +58,7 @@ plus review. Native-speaker review is human work and isn't in the hours.
| Hungarian | `hu` | 5,253 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one plural form | full |
| Polish | `pl` | 9,044 rows, 3,439 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 46 missing messages for it, and 47 menu strings, 26 `tl!` literals, 11 undo labels and 45 swatch names; one/few/many plurals | full; native review pending |
| Finnish | `fi` | 5,318 rows (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one/other plurals, 797 `@msg` rows | full; AI-assisted, native proofreading pending |
| Czech | `cs` | 5,240 catalog entries, including 795 `@msg` rows (counted 2026-10-21); menus, literals and messages complete when counted | catalog complete; render-site gaps reduced; native review pending |
| Czech | `cs` | 9,155 rows, 3,445 of them `@msg` and 48 `@msgplural` (counted 2026-11-13): the untranslated report lists 40 missing messages for it | catalog complete; render-site gaps reduced; native review pending |
| Bosnian | `bs` | 5,841 rows (counted 2026-10-30): menus, `tl!` literals and messages complete when counted; one/few/other plurals | full; AI-assisted, native review pending |
| Swedish | `sv` | 5,251 rows, 795 of them `@msg` (counted 2026-10-21): menus, `tl!` literals and messages complete when counted; one/other plurals | full; native review welcome |
| Bulgarian | `bg` | 5,245 rows, 763 of them `@msg` (counted 2026-10-21): menus and `tl!` literals complete when counted; its messages are partial | full; AI-drafted, native review pending |
+9 -5
View File
@@ -2991,7 +2991,8 @@ already walks Image Trace. Short agent entry points:
a symbol with guides keeps its corners at their size, and `symbol.breakLink` gives its art as it draws; dragging a
guide on the canvas in symbol editing mode sends `nineSliceGuides` in one undo step; `type` is
Symbol Type, `static` by default for the command and `dynamic` by default in the Symbol Options dialog),
`symbol.place {name?, x?, y?}`, `symbol.breakLink {ids?}` (the art keeps the instance's opacity mask; a hidden or locked instance's
`symbol.place {name?, x?, y?}`, `symbol.breakLink {ids?}` (the art keeps the instance's opacity mask and its bounding box, square after
`object.resetBoundingBox`; a hidden or locked instance's
art stays hidden or locked and is not selected), `symbol.replace`,
`symbol.edit {id?, ids?, name?, x?, y?, artboard?, viewCenter?}` (symbol editing mode: the symbol's art on a temporary
isolated layer, where the instance `id` shows it or centered on (x, y), by default on the center of artboard
@@ -7379,8 +7380,8 @@ restore the selection that was cleared. While the slices are locked or hidden, a
```
The Slice tool (`slice`, Shift+K) and the Slice Selection tool (`sliceSelection`) reduce to commands:
`object.slice.create {x, y, width, height}` (the Slice tool's drag; it snaps to ruler guides), `object.slice.select
{slices?, toggle?}`, `object.slice.move {slices?, dx, dy}` (an object slice moves its object),
`object.slice.create {x, y, width, height}` (the Slice tool's drag; it snaps as drawn points do, then to ruler guides),
`object.slice.select {slices?, toggle?}`, `object.slice.move {slices?, dx, dy}` (an object slice moves its object),
`object.slice.setRect {id, x, y, width, height}` (user slices) and `object.slice.delete {slices?}`. Slice ids are user
slice ids and the ids of objects with an object slice. `object.slice.select` with `slices` left out or null deselects
the slices.
@@ -8129,7 +8130,9 @@ square to the page. `object.resetBoundingBox` squares the box again without movi
object inside the selected ones: the children of groups, compound paths, clip groups, blends, envelopes, repeats and
intertwines, and the art of linked opacity masks (an unlinked mask's art keeps its angle). Ungroup, Release Clipping
Mask, Release Compound Path, Blend Expand, Release Envelope, Release Repeat, Release Intertwine and Release Opacity Mask
then bring back boxes square to the page.
then bring back boxes square to the page. `symbol.breakLink` and `object.expand` give a symbol instance's art the
instance's own box: after a reset the art stands square to the page, and an object turned inside the symbol keeps its
angle relative to the instance's box.
```json
{"name":"run_command","arguments":{"command":"object.rotate","params":{"angle":45,"absolute":true}}}
@@ -10723,7 +10726,8 @@ Tools:
centre (move), the box's lower right corner (size; Shift keeps the proportions), a handle above the top (rotation;
Shift: 45° steps), a dot on the top edge (corner radius) and one of the kind's own (a star's or ring's inner radius, a
gear's tooth height, an arrow's head, a cross's bar, a heart's notch, a bubble's tail tip); each drag runs
`addon.pathtools.shape.set`. A double-click on the canvas places a shape with the last settings. Options
`addon.pathtools.shape.set`. A double-click on the canvas places a shape with the last settings. A new shape's
center and the point dragged out from it snap as drawn points do. Options
(`tool.setOption`, or the panel with no shape selected) are the next shape's numbers, as the commands take them.
- **Corner Tool** (`cornerTool`, or the UI command `addon.pathtools.cornerTool`, which also shows the Anchor Inspector
panel with its Corners section): edits the app's Live Corners on any path, selected or not. Over an anchor where two
File diff suppressed because one or more lines are too long
+17 -9
View File
@@ -204,18 +204,25 @@ fn cmd_layers() -> Result<(), String> {
}
/// Workspace packages that must build for wasm32: all L0–L5 crates, the egui
/// shell, and the web app (its wasm-only code is compiled by nothing else).
fn wasm_set() -> Result<Vec<String>, String> {
let crates = layers::from_metadata(&metadata()?)?;
Ok(crates
/// shell, and the web app (its wasm-only code is compiled by nothing else). Each comes with its
/// package id, which `-p` takes where the name would be ambiguous: craft-libs builds against this
/// repo's geom and pathops by git, so the graph can hold a second, older copy of a workspace crate.
fn wasm_set() -> Result<Vec<(String, String)>, String> {
let meta = metadata()?;
let ids: std::collections::HashMap<&str, &str> =
meta["packages"].as_array().into_iter().flatten().filter_map(|p| Some((p["name"].as_str()?, p["id"].as_str()?))).collect();
layers::from_metadata(&meta)?
.into_iter()
.filter(|c| match layers::classify(&c.name) {
Some(layers::Class::Layer(l)) => l <= 5 || layers::short_name(&c.name) == "ui-egui",
Some(layers::Class::Standalone) => true,
_ => layers::short_name(&c.name) == "web",
})
.map(|c| c.name)
.collect())
.map(|c| {
let id = ids.get(c.name.as_str()).ok_or_else(|| format!("cargo metadata: no package id for {}", c.name))?.to_string();
Ok((c.name, id))
})
.collect()
}
/// One `cargo check` over the whole wasm set: the crates share one build, and `--keep-going`
@@ -224,10 +231,11 @@ fn cmd_wasm() -> Result<(), String> {
let set = wasm_set()?;
let mut c = cargo();
c.args(["check", "--target", "wasm32-unknown-unknown", "--keep-going"]);
for pkg in &set {
c.args(["-p", pkg]);
for (_, id) in &set {
c.args(["-p", id]);
}
run(c, &format!("cargo check --target wasm32-unknown-unknown --keep-going ({} crates: {})", set.len(), set.join(", ")))
let names: Vec<&str> = set.iter().map(|(name, _)| name.as_str()).collect();
run(c, &format!("cargo check --target wasm32-unknown-unknown --keep-going ({} crates: {})", set.len(), names.join(", ")))
}
/// Whether `cargo nextest` is installed.