mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-08 19:42:05 +00:00
Merge main into #2049
This commit is contained in:
commit
a632d63602
52 files changed
+1288
-83
No files matched your search
@@ -53,6 +53,7 @@ pub const NODE_KEYS: &[&str] = &[
|
||||
"layout",
|
||||
"leadingTrim",
|
||||
"letterSpacing",
|
||||
"lineDirections",
|
||||
"lineHeight",
|
||||
"lineIndentations",
|
||||
"lineTypes",
|
||||
@@ -96,6 +97,7 @@ pub const NODE_KEYS: &[&str] = &[
|
||||
"textDecorationSkipInk",
|
||||
"textDecorationStyle",
|
||||
"textDecorationThickness",
|
||||
"textDirection",
|
||||
"textPath",
|
||||
"textStyleRanges",
|
||||
"type",
|
||||
@@ -432,6 +434,16 @@ fn text_props(t: &TextData, o: &mut serde_json::Map<String, Value>, options: &In
|
||||
insert_prop(o, options, "listStartOffsets", || t.paragraphs.iter().map(|p| json!(p.start_offset)).collect());
|
||||
}
|
||||
}
|
||||
// The paragraphs' base direction: one for the layer when they agree, else each one's (Auto
|
||||
// throughout says nothing).
|
||||
let dirs: Vec<figcraft_doc::TextDirection> = t.paragraphs.iter().map(|p| p.direction).collect();
|
||||
if let Some(first) = dirs.first().copied().filter(|d| *d != figcraft_doc::TextDirection::Auto)
|
||||
&& dirs.iter().all(|d| *d == first)
|
||||
{
|
||||
insert_prop(o, options, "textDirection", || json!(first));
|
||||
} else if dirs.iter().any(|d| *d != figcraft_doc::TextDirection::Auto) {
|
||||
insert_prop(o, options, "lineDirections", || dirs.iter().map(|d| json!(d)).collect());
|
||||
}
|
||||
if t.list_spacing != 0.0 {
|
||||
insert_prop(o, options, "listSpacing", || json!(t.list_spacing));
|
||||
}
|
||||
|
||||
@@ -658,8 +658,28 @@ pub fn connect(net: &mut VectorNetwork, a: usize, b: usize, ta: (f64, f64), tb:
|
||||
net.segments.push(Segment { a, b, ta: finite_t(ta), tb: finite_t(tb) });
|
||||
let si = net.segments.len() - 1;
|
||||
if ends && let Some(mut lp) = path_between(net, b, a, si) {
|
||||
let unfilled = !net.regions.iter().any(|r| r.loops.iter().flatten().any(|s| lp.contains(s)));
|
||||
if unfilled {
|
||||
let filled = net.regions.iter().any(|r| r.loops.iter().flatten().any(|s| lp.contains(s)));
|
||||
if filled {
|
||||
// A loop broken open at these ends (a point of it deleted, say) closes through the new
|
||||
// segment, so the fill follows it instead of a straight line across the gap (#1951). The
|
||||
// segment goes in at the gap, in the loop's own order, so the loop keeps its direction: a
|
||||
// hole wound against its outline stays a hole whichever end was clicked first. Only a
|
||||
// loop the segment makes one continuous chain is closed; one listed out of walking order
|
||||
// is left as it was.
|
||||
let segs = &net.segments;
|
||||
let shares = |x: usize, y: usize| segs.get(x).zip(segs.get(y)).is_some_and(|(p, q)| p.a == q.a || p.a == q.b || p.b == q.a || p.b == q.b);
|
||||
for l in net.regions.iter_mut().flat_map(|r| r.loops.iter_mut()) {
|
||||
if l.len() == lp.len() && l.iter().all(|s| lp.contains(s)) {
|
||||
let gap = (1..l.len()).find(|&k| l.get(k - 1).zip(l.get(k)).is_some_and(|(&x, &y)| !shares(x, y))).unwrap_or(l.len());
|
||||
let mut closed = l.clone();
|
||||
closed.insert(gap, si);
|
||||
let n = closed.len();
|
||||
if (0..n).all(|k| closed.get(k).zip(closed.get((k + 1) % n)).is_some_and(|(&x, &y)| shares(x, y))) {
|
||||
*l = closed;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lp.insert(0, si);
|
||||
net.regions.push(Region { loops: vec![lp], winding: WindingRule::NonZero, fills: None, ..Default::default() });
|
||||
}
|
||||
@@ -997,6 +1017,49 @@ mod tests {
|
||||
assert!(connect(&mut n, 1, 99, (0.0, 0.0), (0.0, 0.0)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connecting_the_ends_of_a_hole_keeps_its_winding() {
|
||||
// A ring: the hole's loop walks the other way round from the outline's, so the non-zero
|
||||
// rule leaves it empty.
|
||||
let mut n = square();
|
||||
for (x, y) in [(25.0, 25.0), (75.0, 25.0), (75.0, 75.0), (25.0, 75.0)] {
|
||||
n.vertices.push(Vertex::new(x, y));
|
||||
}
|
||||
for (a, b) in [(4, 5), (5, 6), (6, 7), (7, 4)] {
|
||||
n.segments.push(Segment::line(a, b));
|
||||
}
|
||||
n.regions[0].loops.push(vec![7, 6, 5, 4]);
|
||||
let (hole, ring, cut) = (Point::new(40.0, 60.0), Point::new(10.0, 50.0), Point::new(70.0, 30.0));
|
||||
assert!(!n.fill_contains(hole) && n.fill_contains(ring) && !n.fill_contains(cut), "a ring to start with");
|
||||
// Delete a corner of the hole (the path opens; its fill closes across the gap, so the cut-off
|
||||
// corner is filled), then connect the two open ends again, either end first: the hole is a
|
||||
// triangle, still empty.
|
||||
let at = |m: &VectorNetwork, (x, y): (f64, f64)| m.vertices.iter().position(|v| v.x == x && v.y == y).unwrap();
|
||||
for (first, second) in [((25.0, 25.0), (75.0, 75.0)), ((75.0, 75.0), (25.0, 25.0))] {
|
||||
let mut m = n.clone();
|
||||
delete_vertices(&mut m, &[5], false);
|
||||
assert!(!m.fill_contains(hole) && m.fill_contains(cut), "opened: {:?}", m.regions);
|
||||
let (a, b) = (at(&m, first), at(&m, second));
|
||||
connect(&mut m, a, b, (0.0, 0.0), (0.0, 0.0)).unwrap();
|
||||
let loops = &m.regions[0].loops;
|
||||
assert_eq!((m.regions.len(), loops.len(), loops[1].len()), (1, 2, 3), "{first:?} first: {:?}", m.regions);
|
||||
assert!(!m.fill_contains(hole), "{first:?} first: the hole is filled, {loops:?}");
|
||||
assert!(m.fill_contains(ring) && m.fill_contains(cut), "{first:?} first: the ring is filled");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connecting_leaves_a_loop_out_of_walking_order_alone() {
|
||||
// The open chain A-B-C-D-E with its loop listed out of walking order: no place for the new
|
||||
// segment makes it one continuous chain, so the loop is left as it was, and no region is added.
|
||||
let pts = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0), Point::new(-50.0, 50.0)];
|
||||
let mut n = VectorNetwork::from_points(&pts, false);
|
||||
n.regions.push(Region { loops: vec![vec![0, 2, 1, 3]], winding: WindingRule::NonZero, fills: None, ..Default::default() });
|
||||
let s = connect(&mut n, 0, 4, (0.0, 0.0), (0.0, 0.0)).unwrap();
|
||||
assert_eq!(n.regions.len(), 1);
|
||||
assert_eq!(n.regions[0].loops, vec![vec![0, 2, 1, 3]], "left as it was, without segment {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_merges_overlapping_points_and_connects_ends() {
|
||||
let mut n = open_line();
|
||||
|
||||
@@ -597,3 +597,85 @@ fn the_pen_shows_its_ghost_anchor_on_the_segment_under_it() {
|
||||
assert_eq!(net(&s, id).vertices.len(), 5, "the press added the point");
|
||||
assert_eq!(ve(&s).anchor, None, "the click ended the pen's path");
|
||||
}
|
||||
|
||||
/// A rectangle's corner cut off in the editor (a point added on each edge next to it, the corner
|
||||
/// deleted with Shift+Delete so the two join) and the shape rounded: the fill, which the fill and
|
||||
/// the selection outline draw, traces the stroke's outline, cut at both ends of the diagonal (#1951).
|
||||
#[test]
|
||||
fn a_rounded_chamfer_cut_in_the_editor_fills_along_its_arcs() {
|
||||
for top_first in [true, false] {
|
||||
let mut s = Session::new();
|
||||
let id = rect(&mut s);
|
||||
// Splitting keeps the split edge's index, so the other edge's stays: top 0, right 1.
|
||||
let (first, second) = if top_first { (0, 1) } else { (1, 0) };
|
||||
ex(&mut s, "vector.edit.addVertex", json!({"segment": first, "t": 0.5}));
|
||||
ex(&mut s, "vector.edit.addVertex", json!({"segment": second, "t": 0.5}));
|
||||
let corner = vertex_at(&net(&s, id), 100.0, 0.0);
|
||||
ex(&mut s, "vector.edit.select", json!({"vertices": [corner]}));
|
||||
ex(&mut s, "vector.deleteHeal", json!({}));
|
||||
let n = net(&s, id);
|
||||
assert_eq!((n.vertices.len(), n.segments.len(), n.regions.len()), (5, 5, 1), "top first {top_first}: a pentagon");
|
||||
ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 43, "vertices": [0, 1, 2, 3, 4]}));
|
||||
fill_traces_stroke(&s, id, &format!("top first {top_first}"));
|
||||
// A region with a fill of its own is drawn through the region path and the planar normaliser.
|
||||
if let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(id).unwrap().kind {
|
||||
network.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]);
|
||||
}
|
||||
fill_traces_stroke(&s, id, &format!("top first {top_first}, region fill"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The chamfer made other ways: the corner deleted plainly, which opens the path between the two
|
||||
/// new points, and the two connected again; or the pentagon drawn with the pen (#1951).
|
||||
#[test]
|
||||
fn a_rounded_chamfer_made_other_ways_fills_along_its_arcs() {
|
||||
let mut s = Session::new();
|
||||
let id = rect(&mut s);
|
||||
ex(&mut s, "vector.edit.addVertex", json!({"segment": 0, "t": 0.5}));
|
||||
ex(&mut s, "vector.edit.addVertex", json!({"segment": 1, "t": 0.5}));
|
||||
let corner = vertex_at(&net(&s, id), 100.0, 0.0);
|
||||
ex(&mut s, "vector.edit.select", json!({"vertices": [corner]}));
|
||||
ex(&mut s, "vector.edit.delete", json!({}));
|
||||
let n = net(&s, id);
|
||||
assert_eq!((n.segments.len(), n.regions[0].loops[0].len()), (4, 4), "the path opened: {:?}", n.regions);
|
||||
let (top, right) = (vertex_at(&n, 50.0, 0.0), vertex_at(&n, 100.0, 50.0));
|
||||
ex(&mut s, "vector.edit.connect", json!({"a": top, "b": right}));
|
||||
assert_eq!(net(&s, id).segments.len(), 5, "connected again");
|
||||
ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 43, "vertices": [0, 1, 2, 3, 4]}));
|
||||
fill_traces_stroke(&s, id, "deleted and connected");
|
||||
// Drawn with the pen: continue from the end of an open two-point path and close on its start.
|
||||
let mut s = Session::new();
|
||||
let r = ex(&mut s, "vector.create", json!({"points": [[0, 0], [50, 0]], "closed": false}));
|
||||
let id = crate::params::id_of(&r["id"]).unwrap();
|
||||
ex(&mut s, "vector.edit.begin", json!({"id": id}));
|
||||
ex(&mut s, "vector.edit.tool", json!({"tool": "pen"}));
|
||||
click(&mut s, 50.0, 0.0);
|
||||
for (x, y) in [(100.0, 50.0), (100.0, 100.0), (0.0, 100.0), (0.0, 0.0)] {
|
||||
click(&mut s, x, y);
|
||||
}
|
||||
let n = net(&s, id);
|
||||
assert_eq!((n.vertices.len(), n.segments.len(), n.regions.len()), (5, 5, 1), "closed by the pen");
|
||||
ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 43, "vertices": [0, 1, 2, 3, 4]}));
|
||||
fill_traces_stroke(&s, id, "drawn with the pen");
|
||||
}
|
||||
|
||||
/// The rounded chamfered rectangle `id` (five points on the origin's 100×100 box, the top-right
|
||||
/// corner cut off between (50, 0) and (100, 50)): one region with every segment in its loop, and a
|
||||
/// fill that keeps off every sharp point and traces the same outline as the stroke.
|
||||
fn fill_traces_stroke(s: &Session, id: NodeId, why: &str) {
|
||||
use figcraft_geom::BezPath;
|
||||
use figcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest};
|
||||
let n = net(s, id);
|
||||
assert_eq!(n.regions.len(), 1, "{why}: one region, {:?}", n.regions);
|
||||
assert_eq!(n.regions[0].loops[0].len(), n.segments.len(), "{why}: every segment in the loop, {:?}", n.regions[0].loops);
|
||||
let doc = s.doc().unwrap();
|
||||
let (fill, _) = figcraft_render::node_outline(doc, doc.node(id).unwrap()).unwrap();
|
||||
let stroke = n.stroke_path();
|
||||
let keeps_off = |path: &BezPath, p: Point| path.segments().map(|s| s.nearest(p, 1e-9).distance_sq.sqrt()).fold(f64::INFINITY, f64::min);
|
||||
let length = |path: &BezPath| path.segments().map(|s| s.arclen(1e-9)).sum::<f64>();
|
||||
for p in [pt(50.0, 0.0), pt(100.0, 50.0), pt(0.0, 0.0), pt(100.0, 100.0), pt(0.0, 100.0)] {
|
||||
assert!(keeps_off(&stroke, p) > 2.0, "{why}: the stroke is cut at {p:?} ({:.2} from it)", keeps_off(&stroke, p));
|
||||
assert!(keeps_off(&fill, p) > 2.0, "{why}: the fill runs into {p:?} ({:.2} from it)", keeps_off(&fill, p));
|
||||
}
|
||||
assert!((length(&fill) - length(&stroke)).abs() < 1e-3, "{why}: fill {} and stroke {} trace the same outline", length(&fill), length(&stroke));
|
||||
}
|
||||
@@ -216,6 +216,11 @@ pub static MENU: &[MenuItem] = &[
|
||||
m!("Text/Case", "Uppercase", "text.caseUpper"),
|
||||
m!("Text/Case", "Lowercase", "text.caseLower"),
|
||||
m!("Text/Case", "Title case", "text.caseTitle"),
|
||||
// "Go to Text > Text direction. Select Left to right or Right to left." (Add right-to-left
|
||||
// text); Auto, the way back to detection, is FigCraft's own (in `OWN`).
|
||||
m!("Text/Text direction", "Auto", "text.directionAuto"),
|
||||
m!("Text/Text direction", "Left to right", "text.directionLtr"),
|
||||
m!("Text/Text direction", "Right to left", "text.directionRtl"),
|
||||
m!("Text", SEP, ""),
|
||||
m!("Text", "Missing fonts…", "text.missingFonts"),
|
||||
// Arrange
|
||||
@@ -292,7 +297,7 @@ pub fn items() -> impl Iterator<Item = &'static MenuItem> {
|
||||
}
|
||||
|
||||
/// Items of FigCraft's own that the parity count leaves out (as it does the `collab.` ones).
|
||||
const OWN: [&str; 5] = ["file.newFromTemplate", "file.newBoard", "help.gettingStarted", "text.missingFonts", "image.crop"];
|
||||
const OWN: [&str; 6] = ["file.newFromTemplate", "file.newBoard", "help.gettingStarted", "text.missingFonts", "image.crop", "text.directionAuto"];
|
||||
|
||||
/// Coverage: (implemented, total, missing ids). `extra` lists ids implemented by the UI layer.
|
||||
pub fn parity(extra: &[&str]) -> (usize, usize, Vec<&'static str>) {
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::params::{bool_of, boolean, get, id, num, num_or, req_index, req_strin
|
||||
use crate::{DocState, EngineError, Result, Session, bad, spec};
|
||||
|
||||
mod export;
|
||||
mod import_ids;
|
||||
mod load;
|
||||
mod open;
|
||||
mod save;
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
//! Ids a file being imported brings that the document already uses for something else.
|
||||
//!
|
||||
//! Style, variable and collection ids are numbered per file (`S:1`, `VariableID:1`,
|
||||
//! `VariableCollectionId:1`; see [`figcraft_doc::ids`]), so two files made apart use the same ones
|
||||
//! for unrelated things. Kept as they were, the imported layers would show the document's
|
||||
//! variables and styles instead of their own.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use figcraft_doc::ids::MODE_ID_INFIX;
|
||||
use figcraft_doc::proto::{Action, Expr, MAX_EXPR_DEPTH, walk_mut};
|
||||
use figcraft_doc::{Document, ModeId, NodeId, NodeKind, VariableValue, Variables};
|
||||
|
||||
/// A style, variable or collection of the file being imported.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum Asset<'a> {
|
||||
Style(&'a str),
|
||||
Variable(&'a str),
|
||||
Collection(&'a str),
|
||||
}
|
||||
|
||||
/// New ids for styles, variables and collections of a file being imported (its id → the new one).
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct Renames {
|
||||
styles: BTreeMap<String, String>,
|
||||
variables: BTreeMap<String, String>,
|
||||
collections: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl Renames {
|
||||
/// The new ids `other`'s styles, variables and collections take when appended to `doc`.
|
||||
///
|
||||
/// One whose id `doc` doesn't use keeps it. One `doc` already has is shared and keeps its id:
|
||||
/// the same content under the same id, naming only things that are shared too (the same file
|
||||
/// imported again). A collection and its variables are shared only all together, so variables
|
||||
/// of one file never join a collection of another. Library copies keep their ids and are shared
|
||||
/// with the document's copies, as before (`merge_store` records them). Every other id `doc`
|
||||
/// already uses is replaced by a new one from `doc`'s allocators, clear of `other`'s ids too.
|
||||
pub(super) fn new(doc: &mut Document, other: &Document) -> Renames {
|
||||
let (theirs, libs) = (&other.variables, &other.libraries);
|
||||
// Each with whether `doc` has the same under its id (None: nothing there), and whether
|
||||
// it's a library copy.
|
||||
let styles = other.styles.iter().map(|s| {
|
||||
let here = doc.styles.iter().find(|m| m.id == s.id);
|
||||
(Asset::Style(&s.id), here.map(|m| m == s), libs.style_import(&s.id).is_some())
|
||||
});
|
||||
let colls = theirs.collections.iter().map(|c| {
|
||||
let here = doc.variables.collection_by_id(&c.id);
|
||||
(Asset::Collection(&c.id), here.map(|m| m == c), libs.collection_import(&c.id).is_some())
|
||||
});
|
||||
let vars = theirs.variables.iter().map(|v| {
|
||||
let here = doc.variables.get_by_id(&v.id);
|
||||
(Asset::Variable(&v.id), here.map(|m| m == v), libs.variable_import(&v.id).is_some())
|
||||
});
|
||||
// `taken`: ids `doc` uses; `differs`: what `doc` doesn't have the same of.
|
||||
let (mut taken, mut copies, mut differs) = (BTreeSet::new(), BTreeSet::new(), BTreeSet::new());
|
||||
for (a, same, copy) in styles.chain(colls).chain(vars) {
|
||||
match same {
|
||||
None => {
|
||||
differs.insert(a);
|
||||
}
|
||||
Some(same) => {
|
||||
taken.insert(a);
|
||||
if copy {
|
||||
copies.insert(a);
|
||||
} else if !same {
|
||||
differs.insert(a);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// What differs makes what names it differ (library copies aside), and a collection and its
|
||||
// variables differ together.
|
||||
let mut named_by: BTreeMap<Asset, Vec<Asset>> = BTreeMap::new();
|
||||
for v in &theirs.variables {
|
||||
let (var, coll) = (Asset::Variable(&v.id), Asset::Collection(&v.collection));
|
||||
named_by.entry(coll).or_default().push(var);
|
||||
named_by.entry(var).or_default().push(coll);
|
||||
for r in v.values.values().flat_map(VariableValue::references).chain(&v.extends) {
|
||||
named_by.entry(Asset::Variable(r)).or_default().push(var);
|
||||
}
|
||||
}
|
||||
for c in &theirs.collections {
|
||||
if let Some(e) = &c.extends {
|
||||
named_by.entry(Asset::Collection(e)).or_default().push(Asset::Collection(&c.id));
|
||||
}
|
||||
}
|
||||
for s in &other.styles {
|
||||
for v in s.bound.values() {
|
||||
named_by.entry(Asset::Variable(v)).or_default().push(Asset::Style(&s.id));
|
||||
}
|
||||
}
|
||||
let mut todo: Vec<Asset> = differs.iter().copied().collect();
|
||||
while let Some(a) = todo.pop() {
|
||||
for &n in named_by.get(&a).into_iter().flatten() {
|
||||
if !copies.contains(&n) && differs.insert(n) {
|
||||
todo.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
let renamed = |a: Asset| taken.contains(&a) && differs.contains(&a);
|
||||
Renames {
|
||||
collections: fresh(
|
||||
theirs.collections.iter().map(|c| &c.id),
|
||||
|id| renamed(Asset::Collection(id)),
|
||||
|avoid| doc.new_collection_id_avoiding(avoid),
|
||||
),
|
||||
variables: fresh(theirs.variables.iter().map(|v| &v.id), |id| renamed(Asset::Variable(id)), |avoid| doc.new_variable_id_avoiding(avoid)),
|
||||
styles: fresh(other.styles.iter().map(|s| &s.id), |id| renamed(Asset::Style(id)), |avoid| doc.new_style_id_avoiding(avoid)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn is_empty(&self) -> bool {
|
||||
self.styles.is_empty() && self.variables.is_empty() && self.collections.is_empty()
|
||||
}
|
||||
|
||||
/// `other` with the renamed styles, variables and collections under their new ids, and what
|
||||
/// names them following: layers' style links (text ranges too), bindings, explicit modes and
|
||||
/// prototype actions; styles' bindings; variables' collections, aliases and calculations; what
|
||||
/// extended collections and their variables extend; and the ids of the renamed collections'
|
||||
/// modes, which name their collection (`<collection>/mode<k>`).
|
||||
pub(super) fn applied(&self, other: &Document) -> Document {
|
||||
let theirs = &other.variables;
|
||||
let mut doc = other.clone();
|
||||
let (styles, vars, colls) = (&self.styles, &self.variables, &self.collections);
|
||||
for s in &mut doc.styles {
|
||||
rename(styles, &mut s.id);
|
||||
s.bound.values_mut().for_each(|v| rename(vars, v));
|
||||
}
|
||||
for c in &mut doc.variables.collections {
|
||||
for m in &mut c.modes {
|
||||
self.mode(theirs, &c.id, &mut m.id);
|
||||
}
|
||||
for id in std::iter::once(&mut c.id).chain(&mut c.extends) {
|
||||
rename(colls, id);
|
||||
}
|
||||
}
|
||||
for v in &mut doc.variables.variables {
|
||||
let values = std::mem::take(&mut v.values).into_iter().map(|(mut mode, mut val)| {
|
||||
self.mode(theirs, &v.collection, &mut mode);
|
||||
val.references_mut().into_iter().for_each(|r| rename(vars, r));
|
||||
(mode, val)
|
||||
});
|
||||
v.values = values.collect();
|
||||
let overrides = std::mem::take(&mut v.overrides).into_iter().map(|mut mode| {
|
||||
self.mode(theirs, &v.collection, &mut mode);
|
||||
mode
|
||||
});
|
||||
v.overrides = overrides.collect();
|
||||
rename(colls, &mut v.collection);
|
||||
for id in std::iter::once(&mut v.id).chain(&mut v.extends) {
|
||||
rename(vars, id);
|
||||
}
|
||||
}
|
||||
let ids: Vec<NodeId> = doc.ids().collect();
|
||||
for id in ids {
|
||||
let Some(n) = doc.node_mut(id) else { continue };
|
||||
let links = [&mut n.styles.fill, &mut n.styles.stroke, &mut n.styles.effect, &mut n.styles.grid, &mut n.styles.text];
|
||||
for s in links.into_iter().flatten() {
|
||||
rename(styles, s);
|
||||
}
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
t.map_text_styles(|s| Some(styles.get(s).cloned().unwrap_or_else(|| s.to_string())));
|
||||
}
|
||||
n.bound.values_mut().for_each(|v| rename(vars, v));
|
||||
n.var_modes = std::mem::take(&mut n.var_modes)
|
||||
.into_iter()
|
||||
.map(|(mut c, mut mode)| {
|
||||
self.mode(theirs, &c, &mut mode);
|
||||
rename(colls, &mut c);
|
||||
(c, mode)
|
||||
})
|
||||
.collect();
|
||||
for it in &mut n.interactions {
|
||||
walk_mut(&mut it.actions, &mut |a| self.action(theirs, a));
|
||||
}
|
||||
}
|
||||
doc
|
||||
}
|
||||
|
||||
/// Rename mode `mode` of collection `of` (`theirs`: the imported file's variables as they were).
|
||||
/// A mode id names the collection it was made in: `of`, or one `of` extends (an extended
|
||||
/// collection has the modes of the one it extends). Library copies keep their library's mode
|
||||
/// ids, which may name one of the file's own collections.
|
||||
fn mode(&self, theirs: &Variables, of: &str, mode: &mut ModeId) {
|
||||
let mut at = Some(of);
|
||||
for _ in 0..=theirs.collections.len() {
|
||||
let Some(c) = at else { return };
|
||||
if let Some(k) = mode.strip_prefix(c).and_then(|rest| rest.strip_prefix(MODE_ID_INFIX)) {
|
||||
if let Some(new) = self.collections.get(c) {
|
||||
*mode = format!("{new}{MODE_ID_INFIX}{k}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
at = theirs.collection_by_id(c).and_then(|c| c.extends.as_deref());
|
||||
}
|
||||
}
|
||||
|
||||
/// Rename what one prototype action names (not the actions nested in it).
|
||||
fn action(&self, theirs: &Variables, a: &mut Action) {
|
||||
match a {
|
||||
Action::SetVariable { variable, expr, .. } => {
|
||||
rename(&self.variables, variable);
|
||||
if let Some(e) = expr {
|
||||
self.expr(theirs, e, 0);
|
||||
}
|
||||
}
|
||||
Action::SetVariableMode { collection, mode } => {
|
||||
self.mode(theirs, collection, mode);
|
||||
rename(&self.collections, collection);
|
||||
}
|
||||
Action::Conditional { blocks } => blocks.iter_mut().filter_map(|b| b.condition.as_mut()).for_each(|c| self.expr(theirs, c, 0)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn expr(&self, theirs: &Variables, e: &mut Expr, depth: usize) {
|
||||
match e {
|
||||
Expr::Value(v) => v.references_mut().into_iter().for_each(|r| rename(&self.variables, r)),
|
||||
Expr::Op { args, .. } if depth < MAX_EXPR_DEPTH => args.iter_mut().for_each(|a| self.expr(theirs, a, depth + 1)),
|
||||
Expr::Op { .. } => {}
|
||||
Expr::Mode { collection } => rename(&self.collections, collection),
|
||||
Expr::InMode { variable, mode } => {
|
||||
if let Some(v) = theirs.get_by_id(variable) {
|
||||
self.mode(theirs, &v.collection, mode);
|
||||
}
|
||||
rename(&self.variables, variable);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// New ids, from `new_id`, for those of `ids` that `pick` picks; each clear of all of `ids`.
|
||||
fn fresh<'a>(
|
||||
ids: impl Iterator<Item = &'a String> + Clone,
|
||||
pick: impl Fn(&str) -> bool,
|
||||
mut new_id: impl FnMut(&BTreeSet<String>) -> String,
|
||||
) -> BTreeMap<String, String> {
|
||||
let mut avoid: BTreeSet<String> = ids.clone().cloned().collect();
|
||||
let mut out = BTreeMap::new();
|
||||
for id in ids.filter(|id| pick(id)) {
|
||||
let new = new_id(&avoid);
|
||||
avoid.insert(new.clone());
|
||||
out.insert(id.clone(), new);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `id` as `map` renames it.
|
||||
fn rename(map: &BTreeMap<String, String>, id: &mut String) {
|
||||
if let Some(new) = map.get(id.as_str()) {
|
||||
*id = new.clone();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::import_ids::Renames;
|
||||
use super::load::{ext, file_stem, import_summary, load_path, load_with, read, rest_images, sibling_variables, variables_param};
|
||||
use super::*;
|
||||
|
||||
@@ -218,8 +219,18 @@ pub(super) fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Append `other`'s pages (with their settings and flows, its images, styles and variables) to
|
||||
/// `doc`. Ids are remapped across all pages at once, so instances keep their main, instance layers
|
||||
/// their source and interactions and flows their targets wherever those are. Styles, variables and
|
||||
/// collections whose id the document already has are kept as they are.
|
||||
/// collections the document already has (the same under the same id, as when a file comes in
|
||||
/// again) and library copies are shared; others whose id the document uses for something else get
|
||||
/// new ids, and what comes in names them by those ([`Renames`]).
|
||||
pub(super) fn append_pages(doc: &mut Document, other: &Document) -> Result<Vec<NodeId>> {
|
||||
let renames = Renames::new(doc, other);
|
||||
let renamed;
|
||||
let other = if renames.is_empty() {
|
||||
other
|
||||
} else {
|
||||
renamed = renames.applied(other);
|
||||
&renamed
|
||||
};
|
||||
for (k, v) in &other.images {
|
||||
doc.images.entry(k.clone()).or_insert_with(|| v.clone());
|
||||
}
|
||||
|
||||
@@ -983,6 +983,12 @@ fn apply_text(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Val
|
||||
t.set_para_lists(start, end, |p| p.indent = d.clamp(0.0, f64::from(MAX_LIST_INDENT)) as u8);
|
||||
}
|
||||
}
|
||||
"textDirection" | "direction" => {
|
||||
let d: figcraft_doc::TextDirection = enum_of(v).ok_or_else(|| bad(cmd, "textDirection: auto|ltr|rtl"))?;
|
||||
let t = text_mut(cmd, doc.get_mut(id)?)?;
|
||||
let (start, end) = range_of(props, t.char_count());
|
||||
t.set_para_lists(start, end, |p| p.direction = d);
|
||||
}
|
||||
"listSpacing" => text_mut(cmd, doc.get_mut(id)?)?.list_spacing = f64_of(v).filter(|s| s.is_finite()).unwrap_or(0.0).clamp(0.0, 10_000.0),
|
||||
"hangingPunctuation" | "hangingList" => {
|
||||
let on = v.as_bool().ok_or_else(|| bad(cmd, format!("`{k}` must be true or false")))?;
|
||||
@@ -1237,7 +1243,7 @@ pub fn override_group(k: &str) -> &'static str {
|
||||
"name" => "name",
|
||||
"characters" | "text" => "characters",
|
||||
"features" | "fontFeatures" | "openTypeFeatures" | "listStyle" | "indentation" | "listSpacing" | "maxLines" | "variationSettings"
|
||||
| "hangingPunctuation" | "hangingList" | "wrapStyle" | "textWrap" => "textStyle",
|
||||
| "hangingPunctuation" | "hangingList" | "wrapStyle" | "textWrap" | "textDirection" | "direction" => "textStyle",
|
||||
"cornerRadius" | "cornerSmoothing" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => "corners",
|
||||
"width" | "height" | "w" | "h" => "size",
|
||||
"sizingH" | "sizingV" | "layoutSizingHorizontal" | "layoutSizingVertical" | "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => "resizing",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Text menu and text editing commands.
|
||||
|
||||
use figcraft_doc::{
|
||||
LetterSpacing, LineHeight, ListStyle, NodeId, NodeKind, TextAlignH, TextAutoResize, TextCase, TextData, TextDecoration, TextPath, TextStyle,
|
||||
VariableId,
|
||||
LetterSpacing, LineHeight, ListStyle, NodeId, NodeKind, TextAlignH, TextAutoResize, TextCase, TextData, TextDecoration, TextDirection, TextPath,
|
||||
TextStyle, VariableId,
|
||||
};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
@@ -91,6 +91,13 @@ fn set_list(s: &mut Session, p: &Value, l: ListStyle, toggle: bool) -> Result<Va
|
||||
para_range(s, p, |t, a, b| t.set_para_lists(a, b, |pl| pl.list = l))
|
||||
}
|
||||
|
||||
/// The base direction of the targeted paragraphs: the edited ones while editing, else every
|
||||
/// paragraph of the targeted texts ("controls are available to override text direction per
|
||||
/// paragraph", Add right-to-left text, #1830).
|
||||
fn set_direction(s: &mut Session, p: &Value, d: TextDirection) -> Result<Value> {
|
||||
para_range(s, p, |t, a, b| t.set_para_lists(a, b, |pl| pl.direction = d))
|
||||
}
|
||||
|
||||
/// Whether every targeted char has `pred` (to decide toggle direction).
|
||||
fn all_have(s: &Session, p: &Value, pred: impl Fn(&TextStyle) -> bool) -> bool {
|
||||
let Ok(doc) = s.doc() else { return false };
|
||||
@@ -319,6 +326,26 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
para(s, p, |t| t.wrap_style = w)
|
||||
}
|
||||
),
|
||||
spec!(
|
||||
"text.direction",
|
||||
"Text direction",
|
||||
"{direction: auto|ltr|rtl, ids?} the base direction of the paragraphs (while editing: the selected ones): auto goes by the first letter with a direction, ltr runs them left to right and rtl right to left whatever their letters (Text ▸ Text direction)",
|
||||
true,
|
||||
|s, p| {
|
||||
let d: TextDirection =
|
||||
get(p, "direction").and_then(super::props::enum_of).ok_or_else(|| bad("text.direction", "`direction`: auto|ltr|rtl"))?;
|
||||
set_direction(s, p, d)
|
||||
}
|
||||
),
|
||||
spec!("text.directionAuto", "Text direction: auto", "{ids?} while editing: the selected paragraphs", true, |s, p| {
|
||||
set_direction(s, p, TextDirection::Auto)
|
||||
}),
|
||||
spec!("text.directionLtr", "Text direction: left to right", "{ids?} while editing: the selected paragraphs", true, |s, p| {
|
||||
set_direction(s, p, TextDirection::Ltr)
|
||||
}),
|
||||
spec!("text.directionRtl", "Text direction: right to left", "{ids?} while editing: the selected paragraphs", true, |s, p| {
|
||||
set_direction(s, p, TextDirection::Rtl)
|
||||
}),
|
||||
spec!("text.paragraphIndent", "Paragraph indent", "{indent (px), ids?}", true, |s, p| {
|
||||
let v = finite(req_num("text.paragraphIndent", p, "indent")?).clamp(0.0, 10_000.0);
|
||||
para(s, p, |t| t.paragraph_indent = v)
|
||||
|
||||
@@ -307,6 +307,19 @@ fn text_decls(doc: &Document, n: &Node, tx: &figcraft_doc::TextData, t: &mut Tok
|
||||
figcraft_doc::WrapStyle::Balance => put("text-wrap", "balance".into()),
|
||||
figcraft_doc::WrapStyle::Pretty => put("text-wrap", "pretty".into()),
|
||||
}
|
||||
// A base direction forced on every paragraph (CSS has one per element).
|
||||
let dir = tx.paragraphs.first().map(|p| p.direction).filter(|d| tx.paragraphs.iter().all(|p| p.direction == *d));
|
||||
match dir {
|
||||
Some(figcraft_doc::TextDirection::Ltr) => put("direction", "ltr".into()),
|
||||
Some(figcraft_doc::TextDirection::Rtl) => {
|
||||
put("direction", "rtl".into());
|
||||
// Left alignment is absolute on the canvas; `start` would be the right edge here.
|
||||
if tx.align_h == TextAlignH::Left {
|
||||
put("text-align", "left".into());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
match s.case {
|
||||
TextCase::Original => {}
|
||||
TextCase::Upper => put("text-transform", "uppercase".into()),
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
//! `file.import` of a file whose style, variable and collection ids the document already uses for
|
||||
//! other things: ids are numbered per file, so two files made apart share them. The imported
|
||||
//! layers keep their own variables, modes and styles, and a file imported again still shares what
|
||||
//! it brought the first time.
|
||||
|
||||
use base64::Engine as _;
|
||||
use figcraft_doc::proto::Action;
|
||||
use figcraft_doc::{Document, Node, NodeId, VariableValue};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::components::tests::ex;
|
||||
use crate::params::id_of;
|
||||
|
||||
fn idv(v: &Value) -> NodeId {
|
||||
id_of(&v["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn var_id(s: &Session, name: &str) -> String {
|
||||
s.doc().unwrap().variables.get(name).unwrap().id.clone()
|
||||
}
|
||||
|
||||
fn fill(s: &Session, id: NodeId) -> String {
|
||||
s.doc().unwrap().node(id).unwrap().fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The document's file: collection A (Light, Dark) with colours `a1`…`a4`, paint style `Host`
|
||||
/// and a frame filled with `a1`.
|
||||
fn host() -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "A", "modes": ["Light", "Dark"]}));
|
||||
for i in 1..=4 {
|
||||
ex(&mut s, "variable.create", json!({"collection": "A", "name": format!("a{i}"), "type": "color", "value": format!("#A{i}0000")}));
|
||||
}
|
||||
ex(&mut s, "style.create", json!({"type": "paint", "name": "Host", "value": "#FF0000"}));
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 50, "name": "Host frame"})));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "fill", "variable": "a1"}));
|
||||
(s, f)
|
||||
}
|
||||
|
||||
/// Another file made apart: collection B (Light, Dark) with `b1` (green in each mode), `b2` (an
|
||||
/// alias of `b1`) and `size`; paint style `Brand` bound to `b1`. Frame `Card` shows B in Dark, its
|
||||
/// corners bound to `size`; in it `Swatch` is filled with `b2` and on click sets B to Dark and
|
||||
/// `size` to `size + 1`, and `Styled` uses `Brand`.
|
||||
fn other() -> Session {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "B", "modes": ["Light", "Dark"]}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "B", "name": "b1", "type": "color", "values": {"Light": "#00B000", "Dark": "#004000"}}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "B", "name": "b2", "type": "color", "value": {"alias": "b1"}}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "B", "name": "size", "type": "number", "value": 8}));
|
||||
let brand = ex(&mut s, "style.create", json!({"type": "paint", "name": "Brand", "value": "#00B000"}))["id"].clone();
|
||||
ex(&mut s, "style.bind", json!({"style": brand, "field": "fill", "variable": "b1"}));
|
||||
let card = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})));
|
||||
ex(&mut s, "variable.setMode", json!({"ids": [card], "collection": "B", "mode": "Dark"}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [card], "field": "cornerRadius", "variable": "size"}));
|
||||
let swatch = idv(&ex(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40, "parent": card, "name": "Swatch"})));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [swatch], "field": "fill", "variable": "b2"}));
|
||||
let actions = json!([
|
||||
{"type": "setVariableMode", "collection": "B", "mode": "Dark"},
|
||||
{"type": "setVariable", "variable": "size", "expression": "size + 1"}
|
||||
]);
|
||||
ex(&mut s, "proto.interaction.add", json!({"id": swatch, "actions": actions}));
|
||||
let styled = idv(&ex(&mut s, "shape.rectangle", json!({"x": 60, "y": 10, "width": 40, "height": 40, "parent": card, "name": "Styled"})));
|
||||
ex(&mut s, "style.apply", json!({"ids": [styled], "style": "Brand"}));
|
||||
s
|
||||
}
|
||||
|
||||
fn import(s: &mut Session, file: &Session) -> Value {
|
||||
let bytes = figcraft_format::save_bytes(file.doc().unwrap(), None).unwrap();
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
ex(s, "file.import", json!({"data": data, "name": "other.figcraft"}))
|
||||
}
|
||||
|
||||
/// The layers on the pages after the first `kept` pages.
|
||||
fn layers_after(doc: &Document, kept: usize) -> Vec<&Node> {
|
||||
doc.pages[kept..].iter().flat_map(|p| doc.subtree(*p).into_iter().skip(1)).filter_map(|id| doc.node(id)).collect()
|
||||
}
|
||||
|
||||
fn layer<'a>(doc: &'a Document, kept: usize, name: &str) -> &'a Node {
|
||||
layers_after(doc, kept).into_iter().find(|n| n.name == name).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_layers_keep_their_own_variables_and_styles_when_the_ids_are_taken() {
|
||||
let (mut s, frame) = host();
|
||||
let file = other();
|
||||
assert_eq!(var_id(&file, "b1"), var_id(&s, "a1"), "the ids must collide for this test");
|
||||
assert_eq!(file.doc().unwrap().styles[0].id, s.doc().unwrap().styles[0].id);
|
||||
let before = s.doc().unwrap().clone();
|
||||
import(&mut s, &file);
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.variables.variables.len(), 4 + 3);
|
||||
assert_eq!(doc.variables.collections.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["A", "B"]);
|
||||
assert_eq!(doc.styles.iter().map(|st| st.name.as_str()).collect::<Vec<_>>(), ["Host", "Brand"]);
|
||||
// The document's own are as they were.
|
||||
assert_eq!(doc.variables.variables[..4], before.variables.variables[..]);
|
||||
assert_eq!(doc.variables.collections[0], before.variables.collections[0]);
|
||||
assert_eq!(doc.styles[0], before.styles[0]);
|
||||
// Everything imported names B's variables, B and Brand.
|
||||
let b = doc.variables.collection("B").unwrap().id.clone();
|
||||
let imported = layers_after(doc, before.pages.len());
|
||||
assert!(imported.iter().any(|n| !n.bound.is_empty()));
|
||||
for n in imported {
|
||||
for v in n.bound.values() {
|
||||
assert_eq!(doc.variables.get_by_id(v).unwrap().collection, b, "{}: {v}", n.name);
|
||||
}
|
||||
assert!(n.var_modes.keys().all(|c| *c == b), "{}: {:?}", n.name, n.var_modes);
|
||||
if let Some(st) = &n.styles.fill {
|
||||
assert_eq!(doc.styles.iter().find(|x| x.id == *st).unwrap().name, "Brand");
|
||||
}
|
||||
}
|
||||
let brand = doc.styles.iter().find(|st| st.name == "Brand").unwrap();
|
||||
assert_eq!(brand.bound.values().map(|v| doc.variables.get_by_id(v).unwrap().name.as_str()).collect::<Vec<_>>(), ["b1"]);
|
||||
// Bindings applied again leave the imported layers alone.
|
||||
let swatch = layer(doc, before.pages.len(), "Swatch").id;
|
||||
let a2 = var_id(&s, "a2");
|
||||
ex(&mut s, "variable.set", json!({"variable": a2, "value": "#123456"}));
|
||||
assert_eq!(fill(&s, swatch), "004000");
|
||||
assert_eq!(fill(&s, frame), "A10000");
|
||||
assert!(s.doc().unwrap().validate().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alias_a_frame_mode_and_prototype_actions_follow_the_renamed_collection() {
|
||||
let (mut s, _) = host();
|
||||
let file = other();
|
||||
let kept = s.doc().unwrap().pages.len();
|
||||
import(&mut s, &file);
|
||||
let doc = s.doc().unwrap();
|
||||
let vars = &doc.variables;
|
||||
let b = vars.collection("B").unwrap();
|
||||
let in_b = |name: &str| vars.variables.iter().find(|v| v.name == name && v.collection == b.id).unwrap();
|
||||
let (b1, b2, size) = (in_b("b1"), in_b("b2"), in_b("size"));
|
||||
assert!(b2.values.values().all(|v| *v == VariableValue::Alias(b1.id.clone())), "{:?}", b2.values);
|
||||
// B's mode ids name B's new id, and its variables' values are by those.
|
||||
let modes: Vec<&str> = b.modes.iter().map(|m| m.id.as_str()).collect();
|
||||
assert_eq!(modes, [format!("{}/mode1", b.id), format!("{}/mode2", b.id)]);
|
||||
assert!([b1, b2, size].iter().all(|v| v.values.keys().all(|m| modes.contains(&m.as_str()))));
|
||||
// The frame shows B in Dark: its mode is one of B's.
|
||||
let card = layer(doc, kept, "Card");
|
||||
let dark = b.find_mode("Dark").unwrap().id.clone();
|
||||
assert_eq!(card.var_modes.iter().collect::<Vec<_>>(), [(&b.id, &dark)]);
|
||||
assert_eq!(card.bound.get("cornerRadius"), Some(&size.id));
|
||||
let swatch = layer(doc, kept, "Swatch");
|
||||
assert_eq!(swatch.bound.get("fill"), Some(&b2.id));
|
||||
let shown = vars.resolve(&b2.id, &doc.modes_for(swatch.id));
|
||||
assert!(matches!(shown, Some(VariableValue::Color(c)) if c.to_hex() == "004000"), "{shown:?}");
|
||||
let actions = &swatch.interactions[0].actions;
|
||||
assert_eq!(actions[0], Action::SetVariableMode { collection: b.id.clone(), mode: dark });
|
||||
let Action::SetVariable { variable, expr: Some(e), .. } = &actions[1] else { panic!("{actions:?}") };
|
||||
assert_eq!((variable, e.variables()), (&size.id, vec![size.id.as_str()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn importing_the_same_file_again_shares_what_it_brought() {
|
||||
let file = other();
|
||||
let mut s = other();
|
||||
let before = s.doc().unwrap().clone();
|
||||
import(&mut s, &file);
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.variables, before.variables);
|
||||
assert_eq!(doc.styles, before.styles);
|
||||
let swatch = layer(doc, before.pages.len(), "Swatch");
|
||||
assert_eq!(swatch.bound.get("fill"), Some(&var_id(&s, "b2")));
|
||||
// A file imported into an empty one, then again: one copy of its variables.
|
||||
let mut t = Session::new();
|
||||
import(&mut t, &file);
|
||||
import(&mut t, &file);
|
||||
let doc = t.doc().unwrap();
|
||||
assert_eq!((doc.variables.variables.len(), doc.variables.collections.len(), doc.styles.len()), (3, 1, 1));
|
||||
assert!(layers_after(doc, 1).iter().flat_map(|n| n.bound.values()).all(|v| doc.variables.get_by_id(v).is_some()));
|
||||
}
|
||||
@@ -367,6 +367,8 @@ mod images_left_in_file_tests;
|
||||
#[cfg(test)]
|
||||
mod import_export_tests;
|
||||
#[cfg(test)]
|
||||
mod import_ids_tests;
|
||||
#[cfg(test)]
|
||||
mod import_journal_tests;
|
||||
#[cfg(test)]
|
||||
mod inspect_behaviour_tests;
|
||||
|
||||
@@ -385,6 +385,34 @@ fn library_components_keep_their_variable_bindings() {
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_bringing_older_copies_of_the_same_library_variables_shares_them() {
|
||||
let d = dir("older-copies");
|
||||
let lib_path = path_str(&d.join("kit.figcraft"));
|
||||
let mut lib = Session::new();
|
||||
theme(&mut lib);
|
||||
let lid = publish_to(&mut lib, &lib_path);
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "library.add", json!({"path": lib_path}));
|
||||
let r = idv(&ex(&mut s, "shape.rectangle", json!({"width": 10, "height": 10, "name": "Swatch"})));
|
||||
ex(&mut s, "library.bindVariable", json!({"library": lid, "variable": "primary", "field": "fill", "ids": [r]}));
|
||||
let old_path = path_str(&d.join("old.figcraft"));
|
||||
ex(&mut s, "file.saveAs", json!({"path": old_path}));
|
||||
// The copies take an update; the saved file keeps the older ones under the same ids.
|
||||
ex(&mut lib, "variable.set", json!({"variable": "primary", "mode": "Light", "value": "#444444"}));
|
||||
republish(&mut lib);
|
||||
ex(&mut s, "library.acceptUpdates", json!({}));
|
||||
let before = s.doc().unwrap().clone();
|
||||
ex(&mut s, "file.import", json!({"path": old_path}));
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!((&doc.variables, &doc.libraries.variables), (&before.variables, &before.libraries.variables), "shared by id, as before");
|
||||
let swatches: Vec<&Node> = doc.nodes().filter(|n| n.name == "Swatch").collect();
|
||||
assert_eq!(swatches.len(), 2);
|
||||
assert!(swatches.iter().all(|n| n.bound.get("fill") == Some(©_of(&s, "primary").id)));
|
||||
healthy(&s);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_variable_commands_fail_cleanly() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -371,6 +371,64 @@ fn max_lines_on_auto_height_text_hugs_that_many_lines() {
|
||||
assert_eq!(resize_mode(&s, fixed), TextAutoResize::Truncate);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_direction_is_forced_per_paragraph() {
|
||||
// Add right-to-left text: "By default, Figma automatically handles text direction based on
|
||||
// language detection. However, controls are available to override text direction per
|
||||
// paragraph" ("Go to Text > Text direction. Select Left to right or Right to left.").
|
||||
let font = test_mono_font();
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "abc def\nghi\njkl", json!({"fontFamily": font, "fontSize": 16, "lineHeight": 24}));
|
||||
let rtl = |s: &Session| -> Vec<bool> {
|
||||
let (l, _) = figcraft_text::layout_node(s.doc().unwrap().node(t).unwrap()).unwrap();
|
||||
l.lines.iter().map(|l| l.rtl).collect()
|
||||
};
|
||||
let inspect = |s: &Session| crate::inspect::node_json(s.doc().unwrap(), t, 0);
|
||||
assert_eq!(rtl(&s), [false, false, false], "Latin letters read left to right");
|
||||
assert!(inspect(&s).get("textDirection").is_none() && inspect(&s).get("lineDirections").is_none());
|
||||
// The layer's paragraphs at once, by the command or `node.set`; inspect reports the one
|
||||
// direction they share, and Dev Mode's CSS has it.
|
||||
s.execute("text.direction", &json!({"ids": [t], "direction": "rtl"})).unwrap();
|
||||
assert_eq!(rtl(&s), [true, true, true], "forced right to left whatever the letters");
|
||||
assert_eq!(inspect(&s)["textDirection"], json!("rtl"));
|
||||
let css = |s: &mut Session| s.execute("inspect.code", &json!({"id": t})).unwrap()["code"].as_str().unwrap().to_string();
|
||||
assert!(css(&mut s).contains("direction: rtl;"), "{}", css(&mut s));
|
||||
assert!(css(&mut s).contains("text-align: left;"), "Left stays the left edge, which `start` would not be: {}", css(&mut s));
|
||||
s.execute("node.set", &json!({"id": t, "props": {"textDirection": "ltr"}})).unwrap();
|
||||
assert_eq!(rtl(&s), [false, false, false]);
|
||||
assert_eq!(inspect(&s)["textDirection"], json!("ltr"));
|
||||
assert!(css(&mut s).contains("direction: ltr;"));
|
||||
s.execute("text.directionAuto", &json!({"ids": [t]})).unwrap();
|
||||
assert!(inspect(&s).get("textDirection").is_none(), "auto throughout says nothing");
|
||||
assert!(!css(&mut s).contains("direction:"));
|
||||
// While editing, the caret's paragraph alone (the menu's Right to left); inspect lists each
|
||||
// paragraph's direction when they differ, and the CSS stays quiet.
|
||||
s.execute("text.edit.begin", &json!({"id": t, "caret": 9})).unwrap();
|
||||
s.execute("text.directionRtl", &json!({})).unwrap();
|
||||
s.execute("text.edit.end", &json!({})).unwrap();
|
||||
assert_eq!(rtl(&s), [false, true, false], "the second paragraph only");
|
||||
assert_eq!(inspect(&s)["lineDirections"], json!(["auto", "rtl", "auto"]));
|
||||
assert!(!css(&mut s).contains("direction:"));
|
||||
// Enter inside a forced paragraph keeps the direction for the new one, as list settings carry.
|
||||
s.execute("text.edit.begin", &json!({"id": t, "caret": 10})).unwrap();
|
||||
s.execute("text.edit.insert", &json!({"text": "\n"})).unwrap();
|
||||
s.execute("text.edit.end", &json!({})).unwrap();
|
||||
assert_eq!(rtl(&s), [false, true, true, false]);
|
||||
// An empty last paragraph (after a final newline) keeps the direction forced on it.
|
||||
s.execute("node.set", &json!({"id": t, "props": {"characters": "abc\n", "textDirection": "auto"}})).unwrap();
|
||||
s.execute("text.edit.begin", &json!({"id": t, "caret": 4})).unwrap();
|
||||
s.execute("text.directionRtl", &json!({})).unwrap();
|
||||
s.execute("text.edit.end", &json!({})).unwrap();
|
||||
assert_eq!(rtl(&s), [false, true], "the empty last line runs right to left");
|
||||
// A direction the document has words for; junk is refused.
|
||||
s.execute("node.set", &json!({"id": t, "props": {"characters": "\u{05E9}\u{05DC}\u{05D5}\u{05DD} abc", "textDirection": "auto"}})).unwrap();
|
||||
assert_eq!(rtl(&s), [true], "Hebrew first: right to left by detection");
|
||||
s.execute("node.set", &json!({"id": t, "props": {"textDirection": "ltr"}})).unwrap();
|
||||
assert_eq!(rtl(&s), [false], "forced left to right");
|
||||
assert!(s.execute("node.set", &json!({"id": t, "props": {"textDirection": "sideways"}})).is_err());
|
||||
assert!(s.execute("text.direction", &json!({"ids": [t], "direction": "up"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn balance_and_pretty_wrap_styles_break_lines_as_documented() {
|
||||
// Explore text properties: "Auto: Figma fits as many words as possible on each line before
|
||||
|
||||
@@ -64,7 +64,7 @@ fn a_list_with_a_start_number_toggles_off() {
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "two\nthree\nfour");
|
||||
if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind {
|
||||
let n = |start_offset| ParaList { list: N, indent: 0, start_offset };
|
||||
let n = |start_offset| ParaList { list: N, indent: 0, start_offset, ..ParaList::default() };
|
||||
td.paragraphs = vec![n(1), n(0), n(0)];
|
||||
}
|
||||
let j = crate::inspect::node_json(s.doc().unwrap(), t, 0);
|
||||
@@ -157,7 +157,8 @@ fn list_keys_at_list_items() {
|
||||
for (chars, before, caret, k, shift, after, want, why) in cases {
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, chars);
|
||||
let ps: Vec<ParaList> = before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent, start_offset: 0 }).collect();
|
||||
let ps: Vec<ParaList> =
|
||||
before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent, start_offset: 0, ..ParaList::default() }).collect();
|
||||
if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind {
|
||||
td.paragraphs = ps;
|
||||
}
|
||||
@@ -181,7 +182,11 @@ fn paragraph_lists_follow_edits() {
|
||||
// A paragraph's list stays with it as text is typed, pasted and deleted: new paragraphs copy
|
||||
// the one they split from; joined paragraphs keep the first one's.
|
||||
let mut t = TextData::new("ab\ncd\nef");
|
||||
t.paragraphs = vec![ParaList { list: B, indent: 1, start_offset: 0 }, ParaList::default(), ParaList { list: N, indent: 0, start_offset: 0 }];
|
||||
t.paragraphs = vec![
|
||||
ParaList { list: B, indent: 1, start_offset: 0, ..ParaList::default() },
|
||||
ParaList::default(),
|
||||
ParaList { list: N, indent: 0, start_offset: 0, ..ParaList::default() },
|
||||
];
|
||||
let get = |t: &TextData| (0..t.paragraph_count()).map(|p| (t.para_list(p).list, t.para_list(p).indent)).collect::<Vec<_>>();
|
||||
t.replace_range(1, 1, "x\ny");
|
||||
assert_eq!(get(&t), [(B, 1), (B, 1), (P, 0), (N, 0)], "a pasted newline: a new item like it");
|
||||
@@ -196,7 +201,7 @@ fn paragraph_lists_follow_edits() {
|
||||
(seed >> 33) as usize % n.max(1)
|
||||
};
|
||||
let mut t = TextData::new("a\nb\nc\nd");
|
||||
t.paragraphs = vec![ParaList { list: B, indent: 2, start_offset: 0 }; 4];
|
||||
t.paragraphs = vec![ParaList { list: B, indent: 2, start_offset: 0, ..ParaList::default() }; 4];
|
||||
for _ in 0..500 {
|
||||
let n = t.char_count();
|
||||
let (a, b) = (rnd(n + 2), rnd(n + 2));
|
||||
|
||||
Reference in new issue
Block a user