mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 21:44:57 +00:00
Merge main into fix/warichu-rtl-gap-caret
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}))
|
||||
}
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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, ¶ms).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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())),
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)> {
|
||||
|
||||
@@ -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![];
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user