From 000d39b0421225e3382b3b6b30b52196990caf24 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 02:33:38 -0700 Subject: [PATCH 01/18] M4: closed vectors take corner smoothing A vector's corner smoothing was stored, imported and exported, but its rounded points were drawn as plain circular arcs, and the Design panel hid the Corner smoothing control for vectors. A vector with a closed region now draws its rounded points with the layer's corner smoothing, on the canvas, in hit testing, in PNG, SVG and PDF exports, and in the fill and stroke geometry of .fig exports. Each point keeps its own radius; a point without one stays sharp. The corner uses the profile of smoothed polygon corners, and smoothing is reduced before the radius when a corner does not fit in half of each segment. FigCraft's own rules for cases the help page does not describe: only a point between two straight segments that both bound a closed region is smoothed; points on open branches and points next to a curved segment keep the plain arc; concave corners are smoothed like convex ones. The Design panel shows Corner smoothing and its iOS (60%) button for vectors with a closed region, and not for open paths. --- crates/doc/src/geometry.rs | 21 ++- crates/doc/src/node.rs | 8 +- crates/doc/src/tests.rs | 122 +++++++++++++ crates/doc/src/vector.rs | 101 ++++++++--- crates/engine/src/canvas/shapebuilder.rs | 4 +- .../engine/src/canvas/vectoredit/regions.rs | 14 +- crates/engine/src/shapes_behaviour_tests.rs | 51 +++++- crates/format/src/fig/export/common.rs | 4 +- crates/format/src/fig/export/shape.rs | 4 +- crates/format/src/fig/export_tests.rs | 43 +++++ crates/geom/src/lib.rs | 168 ++++++++++++------ crates/geom/src/tests.rs | 47 +++++ crates/pdf/src/draw.rs | 4 +- crates/pdf/src/tests.rs | 21 +++ crates/render/src/draw.rs | 14 +- crates/svg/src/lib.rs | 4 +- crates/ui-egui/src/corner_individual_tests.rs | 33 ++++ crates/ui-egui/src/panels/right/paints.rs | 2 +- docs/gaps.md | 4 +- docs/target-app-parity.md | 2 +- 20 files changed, 559 insertions(+), 112 deletions(-) diff --git a/crates/doc/src/geometry.rs b/crates/doc/src/geometry.rs index 0e06d2941..3dbbce6d4 100644 --- a/crates/doc/src/geometry.rs +++ b/crates/doc/src/geometry.rs @@ -28,8 +28,9 @@ pub fn shape_path(n: &Node) -> Option { p } NodeKind::Vector { network } => { - let f = network.fill_path(); - if f.elements().is_empty() { network.stroke_path() } else { f } + let net = drawn_network(network, c.smoothing); + let f = net.fill_path(); + if f.elements().is_empty() { net.stroke_path() } else { f } } NodeKind::Text(_) => figcraft_geom::rounded_rect(w, h, [0.0; 4], 0.0), NodeKind::Page { .. } | NodeKind::Group | NodeKind::BooleanOp { .. } => return None, @@ -39,11 +40,21 @@ pub fn shape_path(n: &Node) -> Option { /// The open/closed path strokes follow (vector networks stroke every segment, filled or not). pub fn stroke_geometry(n: &Node) -> Option { match &n.kind { - NodeKind::Vector { network } => Some(network.stroke_path()), + NodeKind::Vector { network } => Some(drawn_network(network, n.corners.smoothing).stroke_path()), _ => shape_path(n), } } +/// A vector's `network` as the layer draws it: rounded points drawn in with the layer's corner +/// `smoothing` (see [`VectorNetwork::rounded_smoothed`](crate::VectorNetwork::rounded_smoothed)), +/// or the network itself when no point is rounded. +pub fn drawn_network(network: &crate::VectorNetwork, smoothing: f64) -> std::borrow::Cow<'_, crate::VectorNetwork> { + match network.rounded_smoothed(smoothing) { + Some(r) => std::borrow::Cow::Owned(r), + None => std::borrow::Cow::Borrowed(network), + } +} + /// Whether the shape's path is closed (fills apply). pub fn is_closed(n: &Node) -> bool { match &n.kind { @@ -75,8 +86,8 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, o n.local_rect().inflate(tol_l, tol_l).contains(lp) } NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some()) => { - let rounded = network.rounded(); - let net = rounded.as_ref().unwrap_or(network); + let drawn = drawn_network(network, n.corners.smoothing); + let net = drawn.as_ref(); if !outline { for (i, region) in net.regions.iter().enumerate().rev() { let paints = region.fills.as_deref().unwrap_or(&n.fills); diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 6ec7e8245..21241cf0f 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -416,9 +416,13 @@ impl NodeKind { self.has_corner_radius() && !matches!(self, NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Vector { .. }) } - /// Kinds whose rounded corners are drawn with corner smoothing (a vector's rounded points are drawn without it). + /// Kinds whose rounded corners are drawn with corner smoothing: a vector only when its network + /// has a closed region (an open path's rounded points are drawn without it). pub fn has_corner_smoothing(&self) -> bool { - self.has_corner_radius() && !matches!(self, NodeKind::Vector { .. }) + match self { + NodeKind::Vector { network } => !network.regions.is_empty(), + _ => self.has_corner_radius(), + } } pub fn text(&self) -> Option<&TextData> { diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index a0e3e17b0..2eb912f45 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -665,6 +665,128 @@ fn two_segment_closed_loop_rounded_corners() { assert_eq!(r.regions[0].loops[0].len(), 4, "both arcs are in the loop"); let path = net.fill_path(); assert!(!path.elements().is_empty()); + // Both points sit next to the curved segment, so smoothing keeps their plain arcs. + assert_eq!(net.rounded_smoothed(0.6), net.rounded(), "a corner next to a curve keeps the plain arc"); +} + +/// A closed 100 × 100 square whose corners (top left, top right, bottom right, bottom left) have +/// radii `radii`. +fn rounded_square(radii: [f64; 4]) -> VectorNetwork { + 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)]; + let mut net = VectorNetwork::from_points(&pts, true); + for (v, r) in net.vertices.iter_mut().zip(radii) { + v.radius = r; + } + net +} + +/// The area a closed path encloses, whichever way it runs. +fn area_of(p: &figcraft_geom::BezPath) -> f64 { + use figcraft_geom::Shape; + p.area().abs() +} + +#[test] +fn closed_vectors_smooth_each_rounded_point_with_its_own_radius() { + use figcraft_geom::Shape; + use figcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest}; + // (point radii, smoothing, derivation): a closed square draws the smoothed rectangle's corners, + // each point keeping its own radius and a point without one staying sharp. + let rows: Vec<([f64; 4], f64, &str)> = vec![ + ([20.0; 4], 0.6, "one radius on every point"), + ([30.0, 0.0, 10.0, 0.0], 0.6, "each point keeps its own radius; points without one stay sharp"), + ([12.0; 4], 1.0, "full smoothing"), + ([40.0; 4], 0.6, "a corner too large for its edges loses smoothing first"), + ]; + for (radii, s, why) in rows { + let net = rounded_square(radii); + let smoothed = net.rounded_smoothed(s).unwrap().fill_path(); + // The rectangle's corners take [top left, top right, bottom right, bottom left] radii. + let want = area_of(&figcraft_geom::rounded_rect(100.0, 100.0, radii, s)); + let got = area_of(&smoothed); + assert!((got - want).abs() < 0.1, "{why}: {got} vs the smoothed rectangle's {want}"); + assert!((got - area_of(&net.fill_path())).abs() > 1.0, "{why}: smoothing changes the outline"); + let b = smoothed.bounding_box(); + assert!(b.x0 >= -1e-6 && b.y0 >= -1e-6 && b.x1 <= 100.0 + 1e-6 && b.y1 <= 100.0 + 1e-6, "{why}: {b:?}"); + } + // The sharp points stay on the outline, and the outline has no kinks at the rounded points. + let smoothed = rounded_square([30.0, 0.0, 10.0, 0.0]).rounded_smoothed(0.6).unwrap(); + let path = smoothed.fill_path(); + for p in [Point::new(100.0, 0.0), Point::new(0.0, 100.0)] { + let d = path.segments().map(|seg| seg.nearest(p, 1e-9).distance_sq).fold(f64::INFINITY, f64::min); + assert!(d < 1e-12, "the sharp point {p:?} stays a corner"); + } + let segs: Vec<_> = path.segments().filter(|seg| seg.arclen(1e-6) > 1e-6).collect(); + let mut kinks = 0; + for (i, seg) in segs.iter().enumerate() { + let next = segs[(i + 1) % segs.len()]; + let out = (seg.eval(1.0) - seg.eval(1.0 - 1e-6)).normalize(); + let into = (next.eval(1e-6) - next.eval(0.0)).normalize(); + if out.dot(into) < 0.999 { + kinks += 1; + } + } + assert_eq!(kinks, 2, "only the two sharp points turn without a curve"); + assert_eq!(smoothed.regions[0].loops[0].len(), smoothed.segments.len(), "the loop walks every curve once"); +} + +#[test] +fn corner_smoothing_leaves_open_paths_and_zero_alone() { + let square = rounded_square([20.0; 4]); + assert_eq!(square.rounded_smoothed(0.0), square.rounded(), "no smoothing draws the plain rounded points"); + assert_eq!(square.rounded_smoothed(f64::NAN), square.rounded(), "an unusable smoothing draws no smoothing"); + // An open path (no closed region) keeps its plain arcs. + let pts = [Point::new(200.0, 0.0), Point::new(250.0, 0.0), Point::new(250.0, 50.0)]; + let mut open = VectorNetwork::from_points(&pts, false); + for v in &mut open.vertices { + v.radius = 20.0; + } + assert!(open.regions.is_empty()); + assert_eq!(open.rounded_smoothed(0.6), open.rounded(), "an open path takes no smoothing"); + // In a network with a closed square and a separate open branch, only the square smooths. + let mut mixed = square.clone(); + let base = mixed.vertices.len(); + mixed.vertices.extend(open.vertices.iter().copied()); + mixed.segments.extend(open.segments.iter().map(|s| Segment { a: s.a + base, b: s.b + base, ..*s })); + let runs = mixed.rounded_smoothed(0.6).unwrap().stroke_runs(); + let open_run = runs.iter().find(|r| !r.closed).expect("the open branch"); + assert_eq!(open_run.path, open.rounded().unwrap().stroke_runs()[0].path, "the open branch keeps its plain arc"); + let closed_run = runs.iter().find(|r| r.closed).expect("the square"); + assert_eq!(closed_run.path, square.rounded_smoothed(0.6).unwrap().stroke_runs()[0].path, "the square smooths"); + assert_ne!(closed_run.path, square.rounded().unwrap().stroke_runs()[0].path, "the square's smoothed corners differ from its arcs"); +} + +#[test] +fn smoothed_vector_corners_stay_finite_on_awkward_networks() { + use figcraft_geom::Shape; + // A concave L, a square with a square hole, points with unusable radii and a loop that names a + // missing segment. + let l = [(0.0, 0.0), (100.0, 0.0), (100.0, 40.0), (40.0, 40.0), (40.0, 100.0), (0.0, 100.0)]; + let mut concave = VectorNetwork::from_points(&l.map(|(x, y)| Point::new(x, y)), true); + for v in &mut concave.vertices { + v.radius = 1000.0; + } + let mut holed = rounded_square([10.0; 4]); + let hole = rounded_square([5.0; 4]); + let (base, seg_base) = (holed.vertices.len(), holed.segments.len()); + holed.vertices.extend(hole.vertices.iter().map(|v| Vertex { x: 25.0 + v.x / 2.0, y: 25.0 + v.y / 2.0, ..*v })); + holed.segments.extend(hole.segments.iter().map(|s| Segment { a: s.a + base, b: s.b + base, ..*s })); + holed.regions[0].loops.push((seg_base..seg_base + hole.segments.len()).collect()); + holed.regions[0].winding = WindingRule::EvenOdd; + let mut odd = rounded_square([f64::INFINITY, 10.0, f64::NAN, 10.0]); + odd.regions[0].loops[0].push(99); + for (net, s, why) in [(&concave, 1.0, "concave"), (&holed, 0.6, "holed"), (&odd, 0.6, "odd radii")] { + let drawn = net.rounded_smoothed(s).unwrap(); + let b = drawn.fill_path().bounding_box(); + assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()), "{why}: {b:?}"); + assert!(b.x0 >= -1e-6 && b.y0 >= -1e-6 && b.x1 <= 100.0 + 1e-6 && b.y1 <= 100.0 + 1e-6, "{why}: {b:?}"); + assert!(!drawn.stroke_path().elements().is_empty(), "{why}"); + } + let concave_path = concave.rounded_smoothed(1.0).unwrap().fill_path(); + assert_ne!(concave_path, concave.fill_path(), "the concave L smooths"); + assert!(concave_path.winding(Point::new(20.0, 60.0)) != 0, "the L stays filled"); + let holed_net = holed.rounded_smoothed(0.6).unwrap(); + assert!(!holed_net.fill_contains(Point::new(50.0, 50.0)) && holed_net.fill_contains(Point::new(12.0, 50.0)), "the hole stays a hole"); } #[test] diff --git a/crates/doc/src/vector.rs b/crates/doc/src/vector.rs index 5ae01a089..f53ad4408 100644 --- a/crates/doc/src/vector.rs +++ b/crates/doc/src/vector.rs @@ -129,9 +129,10 @@ impl Region { } } -/// A rounded point as drawn: point `v` moves to `a` (on segment `s1`), segment `s2` starts again -/// at `b`, and an arc with handles `ta`/`tb` joins them. `cut1`/`cut2`: where each segment is cut, -/// in its own parameter (curved segments keep the rest of their curve). +/// A rounded point as drawn: point `v` moves to `a` (on segment `s1`), and the curves of `chain` +/// lead from there to where segment `s2` starts again (one arc, or a smoothed corner's easing +/// curves and arc). `cut1`/`cut2`: where each segment is cut, in its own parameter (curved +/// segments keep the rest of their curve). struct Corner { v: usize, s1: usize, @@ -139,13 +140,19 @@ struct Corner { s2: usize, s2_at_a: bool, a: Point, - b: Point, - ta: (f64, f64), - tb: (f64, f64), + chain: Vec, cut1: f64, cut2: f64, } +/// One curve of a rounded point: its handles at its start (`ta`) and its end (`tb`), relative to +/// those ends, and the point it ends at. +struct CornerCurve { + ta: (f64, f64), + tb: (f64, f64), + end: Point, +} + /// Accuracy of arc lengths along curved segments (local units). const ARCLEN_ACC: f64 = 1e-6; @@ -382,9 +389,19 @@ impl VectorNetwork { /// the rest of its curve, the arc leaving along its direction there. `None` when no point is /// rounded. pub fn rounded(&self) -> Option { + self.rounded_smoothed(0.0) + } + + /// [`Self::rounded`] with the layer's corner smoothing (0..=1): a rounded point between two + /// straight segments that both belong to a region's loops gets a smoothed corner, drawn with + /// the profile of a smoothed polygon's corners (`figcraft_geom::smoothed_corner`). Other + /// rounded points (on open branches, or next to a curved segment) keep the plain arc, and a + /// point without a radius stays sharp. Without smoothing this is [`Self::rounded`]. + pub fn rounded_smoothed(&self, smoothing: f64) -> Option { if !self.vertices.iter().any(|v| v.radius > 0.0) { return None; } + let smoothing = if smoothing.is_finite() { smoothing.clamp(0.0, 1.0) } else { 0.0 }; let mut incident: Vec> = vec![Vec::new(); self.vertices.len()]; for (i, s) in self.segments.iter().enumerate() { for (v, at_a) in [(s.a, true), (s.b, false)] { @@ -393,7 +410,14 @@ impl VectorNetwork { } } } - let corners: Vec = (0..self.vertices.len()).filter_map(|v| self.corner(v, incident.get(v)?)).collect(); + // The segments that bound a closed region, where corners take smoothing. + let mut on_loop = vec![false; if smoothing > 0.0 { self.segments.len() } else { 0 }]; + for &i in self.regions.iter().flat_map(|r| r.loops.iter().flatten()) { + if let Some(x) = on_loop.get_mut(i) { + *x = true; + } + } + let corners: Vec = (0..self.vertices.len()).filter_map(|v| self.corner(v, incident.get(v)?, smoothing, &on_loop)).collect(); let mut out = self.clone(); for v in &mut out.vertices { v.radius = 0.0; @@ -420,26 +444,33 @@ impl VectorNetwork { s.ta = (q.p1.x - q.p0.x, q.p1.y - q.p0.y); s.tb = (q.p2.x - q.p3.x, q.p2.y - q.p3.y); } - let mut by_vertex: std::collections::BTreeMap = std::collections::BTreeMap::new(); + // Each rounded point's curves in order from `s1`'s side, by point. + let mut by_vertex: std::collections::BTreeMap)> = std::collections::BTreeMap::new(); for c in corners { if let Some(v) = out.vertices.get_mut(c.v) { (v.x, v.y) = (c.a.x, c.a.y); } - out.vertices.push(Vertex::new(c.b.x, c.b.y)); - let nb = out.vertices.len() - 1; + let mut from = c.v; + let mut arcs = Vec::with_capacity(c.chain.len()); + for CornerCurve { ta, tb, end } in c.chain { + out.vertices.push(Vertex::new(end.x, end.y)); + let to = out.vertices.len() - 1; + out.segments.push(Segment { a: from, b: to, ta, tb }); + arcs.push(out.segments.len() - 1); + from = to; + } if let Some(s) = out.segments.get_mut(c.s2) { if c.s2_at_a { - s.a = nb; + s.a = from; } else { - s.b = nb; + s.b = from; } } - out.segments.push(Segment { a: c.v, b: nb, ta: c.ta, tb: c.tb }); - let arc = out.segments.len() - 1; - by_vertex.insert(c.v, arc); + by_vertex.insert(c.v, (c.s1, arcs)); } - // The arc goes between the two segments wherever a loop walks through the point. Each loop - // is rebuilt in one pass, so a polygon with many rounded corners isn't quadratic. + // The curves go between the two segments wherever a loop walks through the point, in the + // order the loop meets them. Each loop is rebuilt in one pass, so a polygon with many + // rounded corners isn't quadratic. for lp in out.regions.iter_mut().flat_map(|r| r.loops.iter_mut()) { let Some(&first_idx) = lp.first() else { continue }; let start_v = match (self.segments.get(first_idx), lp.get(1).and_then(|&i| self.segments.get(i))) { @@ -453,8 +484,12 @@ impl VectorNetwork { rebuilt.push(x); let Some(s) = self.segments.get(x) else { continue }; cur = if s.a == cur { s.b } else { s.a }; - if let Some(&arc) = by_vertex.get(&cur) { - rebuilt.push(arc); + if let Some((s1, arcs)) = by_vertex.get(&cur) { + if x == *s1 { + rebuilt.extend(arcs.iter().copied()); + } else { + rebuilt.extend(arcs.iter().rev().copied()); + } } } *lp = rebuilt; @@ -463,8 +498,9 @@ impl VectorNetwork { } /// How point `v` (its `incident` segments) is rounded, if it is: not where the two segments - /// run on smoothly or fold back. - fn corner(&self, v: usize, incident: &[(usize, bool)]) -> Option { + /// run on smoothly or fold back. With `smoothing`, a point between two straight segments that + /// are both `on_loop` gets a smoothed corner. + fn corner(&self, v: usize, incident: &[(usize, bool)], smoothing: f64, on_loop: &[bool]) -> Option { let r = self.vertices.get(v)?.radius; let [(s1, a1), (s2, a2)] = *incident else { return None }; if !(r > 0.0 && r.is_finite()) || s1 == s2 { @@ -480,6 +516,24 @@ impl VectorNetwork { if !(l1 > 1e-9 && l2 > 1e-9) { return None; } + let own = |at_a: bool, t: f64| if at_a { t } else { 1.0 - t }; + let closed = |s: usize| on_loop.get(s).copied().unwrap_or(false); + if smoothing > 0.0 + && is_line(&c1) + && is_line(&c2) + && closed(s1) + && closed(s2) + && let Some(curves) = figcraft_geom::smoothed_corner(c1.p0, u1, u2, l1.min(l2) / 2.0, r, smoothing) + && let (Some(first), Some(last)) = (curves.first(), curves.last()) + { + let (a, b) = (first.p0, last.p3); + let chain = curves + .iter() + .map(|q| CornerCurve { ta: (q.p1.x - q.p0.x, q.p1.y - q.p0.y), tb: (q.p2.x - q.p3.x, q.p2.y - q.p3.y), end: q.p3 }) + .collect(); + let (cut1, cut2) = ((a - c1.p0).hypot() / l1, (b - c2.p0).hypot() / l2); + return Some(Corner { v, s1, s1_at_a: a1, s2, s2_at_a: a2, a, chain, cut1: own(a1, cut1), cut2: own(a2, cut2) }); + } let t = (r / (angle / 2.0).tan()).min(l1.min(l2) / 2.0); let ((a, d1, k1), (b, d2, k2)) = (trim(&c1, t, l1), trim(&c2, t, l2)); // A cubic arc: handles of 4/3·tan(φ/4)·ρ along each end's direction, φ the turn between @@ -490,7 +544,6 @@ impl VectorNetwork { if !(k.is_finite() && a.x.is_finite() && a.y.is_finite() && b.x.is_finite() && b.y.is_finite()) { return None; } - let own = |at_a: bool, t: f64| if at_a { t } else { 1.0 - t }; Some(Corner { v, s1, @@ -498,9 +551,7 @@ impl VectorNetwork { s2, s2_at_a: a2, a, - b, - ta: (d1.x * k, d1.y * k), - tb: (d2.x * k, d2.y * k), + chain: vec![CornerCurve { ta: (d1.x * k, d1.y * k), tb: (d2.x * k, d2.y * k), end: b }], cut1: own(a1, k1), cut2: own(a2, k2), }) diff --git a/crates/engine/src/canvas/shapebuilder.rs b/crates/engine/src/canvas/shapebuilder.rs index b68f1e71d..4e6981f14 100644 --- a/crates/engine/src/canvas/shapebuilder.rs +++ b/crates/engine/src/canvas/shapebuilder.rs @@ -132,8 +132,8 @@ fn world_area(doc: &Document, part: Part) -> Option<(BezPath, bool)> { } match (&n.kind, part.region) { (NodeKind::Vector { network }, Some(i)) => { - let rounded = network.rounded(); - let net = rounded.as_ref().unwrap_or(network); + let drawn = figcraft_doc::geometry::drawn_network(network, n.corners.smoothing); + let net = drawn.as_ref(); let region = net.regions.get(i)?; let path = net.region_path(i)?; (path, if region.winding == WindingRule::EvenOdd { figcraft_render::FillRule::EvenOdd } else { figcraft_render::FillRule::Winding }) diff --git a/crates/engine/src/canvas/vectoredit/regions.rs b/crates/engine/src/canvas/vectoredit/regions.rs index 91763d3aa..6351ce824 100644 --- a/crates/engine/src/canvas/vectoredit/regions.rs +++ b/crates/engine/src/canvas/vectoredit/regions.rs @@ -56,12 +56,10 @@ fn region_filled(n: &figcraft_doc::Node, net: &VectorNetwork, i: usize) -> bool net.regions.get(i).is_some_and(|r| r.fills.as_deref().unwrap_or(&n.fills).iter().any(|f| f.visible)) } -/// The network as its fill draws it: rounded points drawn in (region indexes are kept). -fn drawn(net: &VectorNetwork) -> std::borrow::Cow<'_, VectorNetwork> { - match net.rounded() { - Some(r) => std::borrow::Cow::Owned(r), - None => std::borrow::Cow::Borrowed(net), - } +/// The network as its fill draws it: rounded points drawn in with the layer's corner `smoothing` +/// (region indexes are kept). +fn drawn(net: &VectorNetwork, smoothing: f64) -> std::borrow::Cow<'_, VectorNetwork> { + figcraft_doc::geometry::drawn_network(net, smoothing) } /// The region of the active layer under page point `p` at `zoom`: none when a point is within @@ -90,7 +88,7 @@ pub fn region_hit(doc: &Document, ve: &VectorEdit, p: Point, zoom: f64) -> Optio if (0..net.segments.len()).filter_map(|i| net.cubic(i)).any(|c| c.nearest(lp, 0.01).distance_sq.sqrt() <= sc) { return None; } - let drawn = drawn(net); + let drawn = drawn(net, node.corners.smoothing); (0..drawn.regions.len()).rev().find(|i| region_filled(node, &drawn, *i) && drawn.region_contains(*i, lp)) } @@ -221,7 +219,7 @@ pub fn region_hatch(doc: &Document, ve: &VectorEdit, regions: &[usize], view: Af let zoom = view.determinant().abs().sqrt(); let Some(spacing) = hatch_spacing(zoom).filter(|_| !regions.is_empty()) else { return Vec::new() }; let Some(net) = network(doc, ve.id) else { return Vec::new() }; - let net = drawn(net); + let net = drawn(net, doc.node(ve.id).map_or(0.0, |n| n.corners.smoothing)); let m = view * doc.world_transform(ve.id); let anchor = m * Point::ZERO; let mut out = Vec::new(); diff --git a/crates/engine/src/shapes_behaviour_tests.rs b/crates/engine/src/shapes_behaviour_tests.rs index e628e1683..ca2d16390 100644 --- a/crates/engine/src/shapes_behaviour_tests.rs +++ b/crates/engine/src/shapes_behaviour_tests.rs @@ -233,7 +233,14 @@ fn corner_smoothing_follows_the_documented_rules() { (NodeKind::Frame, true, "frames"), (NodeKind::Polygon { count: 5 }, true, "polygons"), (NodeKind::Star { count: 5, inner_ratio: 0.4 }, true, "stars"), - (NodeKind::Vector { network: Default::default() }, false, "documented for closed vectors; their points round without it here"), + ( + NodeKind::Vector { + network: figcraft_doc::VectorNetwork::from_points(&[Point::ZERO, Point::new(10.0, 0.0), Point::new(10.0, 10.0)], true), + }, + true, + "vector networks that are closed shapes", + ), + (NodeKind::Vector { network: Default::default() }, false, "an open vector has no closed shape to smooth"), (NodeKind::Line, false, "lines take no radius, so no smoothing"), ]; for (kind, smoothing, why) in doc_rows { @@ -241,6 +248,48 @@ fn corner_smoothing_follows_the_documented_rules() { } } +#[test] +fn closed_vectors_take_corner_smoothing() { + // "Vector networks that are closed shapes" take corner smoothing, which applies to the entire + // shape; a single open stroke does not. + let mut s = Session::new(); + let closed = create(&mut s, "vector.create", json!({"points": [[0, 0], [100, 0], [100, 100], [0, 100]], "closed": true})); + let open = create(&mut s, "vector.create", json!({"points": [[200, 0], [300, 0], [300, 100]], "closed": false})); + ex(&mut s, "node.set", json!({"ids": [closed, open], "props": {"cornerRadius": 20}})); + let outline = |s: &Session, id: NodeId| figcraft_doc::geometry::shape_path(&node(s, id)).unwrap(); + let svg = |s: &Session, id: NodeId| figcraft_svg::export_svg(s.doc().unwrap(), &[id], &figcraft_svg::SvgOptions::default()).unwrap(); + let render = |s: &Session, id: NodeId| figcraft_render::node_outline(s.doc().unwrap(), &node(s, id)).unwrap().0; + let (round, open_round, svg_round, drawn_round) = (outline(&s, closed), outline(&s, open), svg(&s, closed), render(&s, closed)); + ex(&mut s, "node.set", json!({"ids": [closed, open], "props": {"cornerSmoothing": 0.6}})); + let smooth = outline(&s, closed); + // The smoothed corner reaches 1.6 radii along each edge, as a smoothed rectangle's does. + let want = figcraft_geom::rounded_rect(100.0, 100.0, [20.0; 4], 0.6).area().abs(); + assert!((smooth.area().abs() - want).abs() < 0.1, "the closed vector smooths: {} vs {want}", smooth.area().abs()); + assert!((smooth.area().abs() - round.area().abs()).abs() > 1.0, "smoothing changes the closed vector's outline"); + assert_ne!(svg(&s, closed), svg_round, "SVG export draws the smoothed corners"); + assert_ne!(render(&s, closed), drawn_round, "the canvas draws the smoothed corners"); + assert_eq!(outline(&s, open), open_round, "an open vector keeps its plain rounded points"); + let stroke = figcraft_doc::geometry::stroke_geometry(&node(&s, closed)).unwrap(); + assert!((stroke.area().abs() - want).abs() < 0.1, "the stroke follows the smoothed outline"); + // Smoothing needs a radius, and undo restores the plain corners in one step. + s.undo().unwrap(); + assert_eq!(outline(&s, closed), round, "one undo step"); + ex(&mut s, "node.set", json!({"ids": [closed], "props": {"cornerRadius": 0}})); + let sharp = outline(&s, closed); + ex(&mut s, "node.set", json!({"ids": [closed], "props": {"cornerSmoothing": 0.6}})); + assert_eq!(outline(&s, closed), sharp, "smoothing needs a radius"); + // A vector whose region has paints of its own draws the smoothed corners too, on the canvas + // and in SVG. + ex(&mut s, "node.set", json!({"ids": [closed], "props": {"cornerRadius": 20}})); + if let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(closed).unwrap().kind { + network.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]); + } + let (region_svg, region_path) = (svg(&s, closed), render(&s, closed)); + assert!((region_path.area().abs() - want).abs() < 0.5, "region paints follow the smoothed outline: {}", region_path.area().abs()); + ex(&mut s, "node.set", json!({"ids": [closed], "props": {"cornerSmoothing": 0}})); + assert_ne!(svg(&s, closed), region_svg, "SVG export draws the region's smoothed corners"); +} + #[test] fn rounded_vector_points_are_drawn() { // A converted 100 × 100 square: a point's radius rounds that corner only (it wasn't drawn). diff --git a/crates/format/src/fig/export/common.rs b/crates/format/src/fig/export/common.rs index 4a97eecb9..ee7293dc9 100644 --- a/crates/format/src/fig/export/common.rs +++ b/crates/format/src/fig/export/common.rs @@ -34,10 +34,10 @@ pub(super) fn outlined(n: &Node) -> Option<(Node, String)> { } let path = figcraft_doc::geometry::stroke_geometry(n)?; let net = match &n.kind { - NodeKind::Vector { network } => Some(network), + NodeKind::Vector { network } => Some(figcraft_doc::geometry::drawn_network(network, n.corners.smoothing)), _ => None, }; - let area = figcraft_pathops::stroke_outline_of(&path, sp, figcraft_doc::geometry::is_closed(n), net); + let area = figcraft_pathops::stroke_outline_of(&path, sp, figcraft_doc::geometry::is_closed(n), net.as_deref()); if area.elements().is_empty() || area.elements().len() > MAX_NETWORK / 4 { return None; } diff --git a/crates/format/src/fig/export/shape.rs b/crates/format/src/fig/export/shape.rs index 03c82cfb1..6627935ca 100644 --- a/crates/format/src/fig/export/shape.rs +++ b/crates/format/src/fig/export/shape.rs @@ -257,7 +257,7 @@ impl<'d> Exporter<'d> { if net.regions.iter().any(|r| r.winding != winding) { self.warn("vectors whose filled areas use different winding rules use the first one's in their outline"); } - self.geometry("fillGeometry", &net.fill_path(), winding, f); + self.geometry("fillGeometry", &figcraft_doc::geometry::drawn_network(net, n.corners.smoothing).fill_path(), winding, f); } } @@ -309,7 +309,7 @@ impl<'d> Exporter<'d> { self.geometry("strokeGeometry", &ring, WindingRule::EvenOdd, f); } else if sp.align == StrokeAlign::Center { let path = match &n.kind { - NodeKind::Vector { network } => Some(network.stroke_path()), + NodeKind::Vector { .. } => figcraft_doc::geometry::stroke_geometry(n), NodeKind::Text(_) => None, _ => figcraft_doc::geometry::shape_path(n), }; diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index 5b4bcb991..45f6866f3 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -912,6 +912,49 @@ fn written_files_decode_and_list_parents_first() { } } +#[test] +fn closed_vector_geometry_is_written_with_its_corner_smoothing() { + // A closed vector with rounded points: its written fill and stroke outlines take the layer's + // corner smoothing, and the smoothing and each point's radius come back on import. + let mut doc = Document::new("smoothing"); + let page = doc.first_page().unwrap(); + let mut network = VectorNetwork::from_points(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)].map(|(x, y)| Point::new(x, y)), true); + for (v, r) in network.vertices.iter_mut().zip([20.0, 0.0, 10.0, 0.0]) { + v.radius = r; + } + let id = doc.create(page, NodeKind::Vector { network }, "V", 0.0, 0.0, 100.0, 100.0).unwrap(); + doc.node_mut(id).unwrap().strokes = vec![Paint::solid(Color::BLACK)]; + // The vector's written fill and stroke outlines. + let outlines = |doc: &Document| -> Vec> { + let (bytes, _) = export(doc, None).unwrap(); + let archive = Archive::open(&bytes).unwrap(); + let (schema, message) = decode_canvas(&archive.canvas).unwrap(); + let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap(); + let blobs = msg.get("blobs").unwrap().list(); + let changes = msg.get("nodeChanges").unwrap().list(); + let nc = changes.iter().find(|nc| nc.get("type").and_then(Kv::str) == Some("VECTOR")).unwrap(); + ["fillGeometry", "strokeGeometry"] + .iter() + .map(|key| { + let paths = nc.get(key).map(Kv::list).unwrap_or_default(); + let blob = paths.first().unwrap_or_else(|| panic!("{key} is written")).get("commandsBlob").unwrap().f64().unwrap() as usize; + blobs[blob].get("bytes").and_then(Kv::bytes).unwrap().to_vec() + }) + .collect() + }; + let plain = outlines(&doc); + doc.node_mut(id).unwrap().corners.smoothing = 0.6; + let smoothed = outlines(&doc); + assert_ne!(smoothed[0], plain[0], "the fill outline takes the smoothing"); + assert_ne!(smoothed[1], plain[1], "the stroke outline takes the smoothing"); + let (back, _) = round_trip(&doc, "smoothed vector"); + let v = back.nodes().find(|n| matches!(n.kind, NodeKind::Vector { .. })).unwrap(); + assert!((v.corners.smoothing - 0.6).abs() < 1e-6, "smoothing comes back: {}", v.corners.smoothing); + let NodeKind::Vector { network } = &v.kind else { panic!("vector") }; + let radii: Vec = network.vertices.iter().map(|x| x.radius).collect(); + assert_eq!(radii, vec![20.0, 0.0, 10.0, 0.0], "each point keeps its own radius"); +} + #[test] fn the_written_schema_parses_and_round_trips() { let schema = Schema::parse(export::SCHEMA).unwrap(); diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 012fca429..d63d84397 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -249,67 +249,133 @@ pub fn smoothed_polygon(points: &[Point], radius: f64, smoothing: f64) -> BezPat }; let (v1, v2) = (*prev - *cur, *next - *cur); let (l1, l2) = (v1.hypot(), v2.hypot()); - let angle = if l1 < 1e-9 || l2 < 1e-9 { 0.0 } else { (v1 / l1).dot(v2 / l2).clamp(-1.0, 1.0).acos() }; - if angle < 1e-6 || (PI - angle) < 1e-6 { + let corner = if l1 < 1e-9 || l2 < 1e-9 { None } else { smooth_corner(*cur, v1 / l1, v2 / l2, l1.min(l2) / 2.0, radius, s) }; + let Some(k) = corner else { line_or_move(&mut path, *cur); continue; + }; + line_or_move(&mut path, k.start); + match k.ease_in { + Some((h1, h2)) => path.curve_to(h1, h2, k.p3), + None => path.line_to(k.p3), } - let (u1, u2) = (v1 / l1, v2 / l2); - // The plain corner's length along each edge, then the fit: smoothing first, then the radius. - let max_p = l1.min(l2) / 2.0; - let mut r = radius; - let mut t = r / (angle / 2.0).tan(); - let mut sm = s; - if (1.0 + sm) * t > max_p { - sm = (max_p / t - 1.0).clamp(0.0, sm); + if k.sweep.abs() > 1e-9 { + kurbo::Arc::new(k.center, (k.r, k.r), k.a0, k.sweep, 0.0).to_cubic_beziers(0.01, |p1, p2, q| path.curve_to(p1, p2, q)); } - if t > max_p { - r *= max_p / t; - t = max_p; - } - let p = ((1.0 + sm) * t).min(max_p); - // Each easing curve covers `beta` of the turn and ends on the arc at `back` along the edge - // and `side` across it; its last handle points along the arc there. - let beta = (PI - angle) * sm / 2.0; - let back = t - r * beta.sin(); - let side = r * (1.0 - beta.cos()); - let c = r * (beta / 2.0).tan() * beta.cos(); - let b = ((p - back - c) / 3.0).max(0.0); - let a = 2.0 * b; - let bisector = (u1 + u2).normalize(); - let center = *cur + bisector * (r / (angle / 2.0).sin()); - // A point `along` an edge from the vertex and `across` it toward the arc's center. - let at = |u: Vec2, along: f64, across: f64| *cur + u * along + (bisector - u * bisector.dot(u)).normalize() * across; - line_or_move(&mut path, at(u1, p, 0.0)); - let p3 = at(u1, back, side); - let p4 = at(u2, back, side); - if a + b + c > 1e-9 { - path.curve_to(at(u1, p - a, 0.0), at(u1, p - a - b, 0.0), p3); - } else { - path.line_to(p3); - } - let a0 = (p3 - center).atan2(); - let mut sweep = (p4 - center).atan2() - a0; - while sweep <= -PI { - sweep += TAU; - } - while sweep > PI { - sweep -= TAU; - } - if sweep.abs() > 1e-9 { - kurbo::Arc::new(center, (r, r), a0, sweep, 0.0).to_cubic_beziers(0.01, |p1, p2, q| path.curve_to(p1, p2, q)); - } - let end = at(u2, p, 0.0); - if a + b + c > 1e-9 { - path.curve_to(at(u2, p - a - b, 0.0), at(u2, p - a, 0.0), end); - } else { - path.line_to(end); + match k.ease_out { + Some((h1, h2)) => path.curve_to(h1, h2, k.end), + None => path.line_to(k.end), } } path.close_path(); path } +/// One smoothed corner as [`smoothed_polygon`] draws it: it starts at `start` on the first edge, +/// eases into a circular arc at `p3` (through handles `ease_in`, or straight when `None`), follows +/// the arc of radius `r` about `center` from angle `a0` through `sweep` to `p4`, and eases out to +/// `end` on the second edge. +struct SmoothCorner { + start: Point, + ease_in: Option<(Point, Point)>, + p3: Point, + center: Point, + r: f64, + a0: f64, + sweep: f64, + p4: Point, + ease_out: Option<(Point, Point)>, + end: Point, +} + +/// The smoothed corner at `cur` between edges that leave it along unit directions `u1` and `u2`, +/// with `max_p` of room along each: `None` where the edges run straight on or fold back. +fn smooth_corner(cur: Point, u1: Vec2, u2: Vec2, max_p: f64, radius: f64, s: f64) -> Option { + let angle = u1.dot(u2).clamp(-1.0, 1.0).acos(); + if angle < 1e-6 || (PI - angle) < 1e-6 { + return None; + } + // The plain corner's length along each edge, then the fit: smoothing first, then the radius. + let mut r = radius; + let mut t = r / (angle / 2.0).tan(); + let mut sm = s; + if (1.0 + sm) * t > max_p { + sm = (max_p / t - 1.0).clamp(0.0, sm); + } + if t > max_p { + r *= max_p / t; + t = max_p; + } + let p = ((1.0 + sm) * t).min(max_p); + // Each easing curve covers `beta` of the turn and ends on the arc at `back` along the edge + // and `side` across it; its last handle points along the arc there. + let beta = (PI - angle) * sm / 2.0; + let back = t - r * beta.sin(); + let side = r * (1.0 - beta.cos()); + let c = r * (beta / 2.0).tan() * beta.cos(); + let b = ((p - back - c) / 3.0).max(0.0); + let a = 2.0 * b; + let bisector = (u1 + u2).normalize(); + let center = cur + bisector * (r / (angle / 2.0).sin()); + // A point `along` an edge from the vertex and `across` it toward the arc's center. + let at = |u: Vec2, along: f64, across: f64| cur + u * along + (bisector - u * bisector.dot(u)).normalize() * across; + let p3 = at(u1, back, side); + let p4 = at(u2, back, side); + let eased = a + b + c > 1e-9; + let a0 = (p3 - center).atan2(); + let mut sweep = (p4 - center).atan2() - a0; + while sweep <= -PI { + sweep += TAU; + } + while sweep > PI { + sweep -= TAU; + } + Some(SmoothCorner { + start: at(u1, p, 0.0), + ease_in: eased.then(|| (at(u1, p - a, 0.0), at(u1, p - a - b, 0.0))), + p3, + center, + r, + a0, + sweep, + p4, + ease_out: eased.then(|| (at(u2, p - a - b, 0.0), at(u2, p - a, 0.0))), + end: at(u2, p, 0.0), + }) +} + +/// The curves of one corner rounded by `radius` with corner smoothing (0..=1) at `corner`, between +/// two straight edges that leave it along unit directions `u1` and `u2` with `max_p` of room along +/// each. The curves follow the profile of [`smoothed_polygon`], with the circular part as one +/// cubic, and run from the point on the `u1` edge to the point on the `u2` edge; a straight piece +/// has its handles on its ends. As in the polygon, smoothing is reduced first when the corner does +/// not fit, then the radius. `None` for a corner that is not rounded, or where the edges run +/// straight on or fold back. +pub fn smoothed_corner(corner: Point, u1: Vec2, u2: Vec2, max_p: f64, radius: f64, smoothing: f64) -> Option> { + let (s, radius, max_p) = (finite(smoothing).clamp(0.0, 1.0), finite(radius).max(0.0), finite(max_p).max(0.0)); + let usable = |u: Vec2| u.x.is_finite() && u.y.is_finite() && u.hypot() > 1e-9; + if radius <= 0.0 || max_p <= 0.0 || !usable(u1) || !usable(u2) || !(corner.x.is_finite() && corner.y.is_finite()) { + return None; + } + let k = smooth_corner(corner, u1 / u1.hypot(), u2 / u2.hypot(), max_p, radius, s)?; + let piece = |p0: Point, handles: Option<(Point, Point)>, p3: Point| { + let (h1, h2) = handles.unwrap_or((p0, p3)); + kurbo::CubicBez::new(p0, h1, h2, p3) + }; + let mut out = vec![piece(k.start, k.ease_in, k.p3)]; + if k.sweep.abs() > 1e-9 { + // The arc as one cubic: handles of 4/3·tan(θ/4)·r along the circle's tangents at its ends. + let h = 4.0 / 3.0 * (k.sweep.abs() / 4.0).tan() * k.r * k.sweep.signum(); + let tangent = |a: f64| Vec2::new(-a.sin(), a.cos()); + out.push(kurbo::CubicBez::new(k.p3, k.p3 + tangent(k.a0) * h, k.p4 - tangent(k.a0 + k.sweep) * h, k.p4)); + } + out.push(piece(k.p4, k.ease_out, k.end)); + out.retain(|q| (q.p3 - q.p0).hypot() > 1e-9 || (q.p1 - q.p0).hypot() > 1e-9 || (q.p2 - q.p3).hypot() > 1e-9); + let finite_point = |p: &Point| p.x.is_finite() && p.y.is_finite(); + let ok = out.iter().all(|q| [q.p0, q.p1, q.p2, q.p3].iter().all(finite_point)); + (ok && !out.is_empty()).then_some(out) +} + /// Starts `path` at `p`, or continues it there with a straight segment. fn line_or_move(path: &mut BezPath, p: Point) { if path.elements().is_empty() { diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index 37e6ccab7..1a2391a35 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -52,6 +52,53 @@ fn degenerate_inputs_dont_panic() { let _ = smoothed_polygon(&polygon_points(10.0, 10.0, 5), f64::INFINITY, f64::NAN); let _ = smoothed_polygon(&polygon_points(10.0, 10.0, 5), 3.0, f64::INFINITY); let _ = ellipse(f64::NAN, 10.0, f64::NAN, 100.0, 2.0); + // (corner, first edge, second edge, room, radius, smoothing): none of these is a corner to round. + let x = Vec2::new(1.0, 0.0); + let rows = [ + (Point::ZERO, x, Vec2::new(0.0, 1.0), 10.0, 0.0, 0.6), + (Point::ZERO, x, Vec2::new(0.0, 1.0), 0.0, 5.0, 0.6), + (Point::ZERO, x, x, 10.0, 5.0, 0.6), + (Point::ZERO, x, -x, 10.0, 5.0, 0.6), + (Point::ZERO, Vec2::ZERO, x, 10.0, 5.0, 0.6), + (Point::ZERO, Vec2::new(f64::NAN, 0.0), x, 10.0, 5.0, 0.6), + (Point::new(f64::INFINITY, 0.0), x, Vec2::new(0.0, 1.0), 10.0, 5.0, 0.6), + ]; + for (corner, u1, u2, room, r, s) in rows { + assert!(smoothed_corner(corner, u1, u2, room, r, s).is_none(), "{corner:?} {u1:?} {u2:?} {room} {r} {s}"); + } +} + +#[test] +fn smoothed_corner_draws_the_polygon_corner() { + use kurbo::ParamCurve; + // The corner of a square drawn by itself follows the smoothed polygon's corner and joins its + // edges and its own pieces without a kink. + for s in [0.0, 0.6, 1.0] { + let curves = smoothed_corner(Point::ZERO, Vec2::new(0.0, 1.0), Vec2::new(1.0, 0.0), 50.0, 20.0, s).unwrap(); + let mut path = BezPath::new(); + path.move_to(curves[0].p0); + for q in &curves { + path.curve_to(q.p1, q.p2, q.p3); + } + path.line_to((100.0, 0.0)); + path.line_to((100.0, 100.0)); + path.line_to((0.0, 100.0)); + path.close_path(); + let want = area(&rounded_rect(100.0, 100.0, [20.0, 0.0, 0.0, 0.0], s)); + assert!((area(&path) - want).abs() < 0.05, "s={s}: {} vs {want}", area(&path)); + assert!((curves[0].p0 - Point::new(0.0, 20.0 * (1.0 + s))).hypot() < 1e-9, "s={s}: it starts 1 + smoothing radii along the edge"); + let last = curves[curves.len() - 1]; + assert!((last.p3 - Point::new(20.0 * (1.0 + s), 0.0)).hypot() < 1e-9, "s={s}: and ends as far along the other"); + let dir = |c: &kurbo::CubicBez, t: f64| { + let d = if t < 0.5 { c.eval(1e-6) - c.eval(0.0) } else { c.eval(1.0) - c.eval(1.0 - 1e-6) }; + d.normalize() + }; + assert!(dir(&curves[0], 0.0).dot(Vec2::new(0.0, -1.0)) > 0.999, "s={s}: it leaves along the first edge"); + assert!(dir(&last, 1.0).dot(Vec2::new(1.0, 0.0)) > 0.999, "s={s}: it arrives along the second edge"); + for pair in curves.windows(2) { + assert!(dir(&pair[0], 1.0).dot(dir(&pair[1], 0.0)) > 0.999, "s={s}: its pieces join smoothly"); + } + } } #[test] diff --git a/crates/pdf/src/draw.rs b/crates/pdf/src/draw.rs index eb1b8aaf1..2ae6e366c 100644 --- a/crates/pdf/src/draw.rs +++ b/crates/pdf/src/draw.rs @@ -338,8 +338,8 @@ impl<'a> Exporter<'a> { if let NodeKind::Vector { network } = &n.kind && network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some()) { - let rounded = network.rounded(); - let net = rounded.as_ref().unwrap_or(network); + let drawn = figcraft_doc::geometry::drawn_network(network, n.corners.smoothing); + let net = drawn.as_ref(); for (i, region) in net.regions.iter().enumerate() { let paints = region.fills.as_deref().unwrap_or(&n.fills); if let Some(mut region_path) = net.region_path(i) { diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index cdd85d6bd..110bfa736 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -82,6 +82,27 @@ fn pdf_vector_region_paints_emit_independent_colors() { assert!(content.contains("1 0 0 rg") && content.contains("0 0 1 rg"), "{content}"); } +#[test] +fn pdf_vector_region_paints_draw_smoothed_corners() { + // A closed vector with rounded points and paints on its region: corner smoothing changes the + // exported outline, as on the canvas. + let corners = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)].map(|(x, y)| figcraft_geom::Point::new(x, y)); + let mut network = VectorNetwork::from_points(&corners, true); + for v in &mut network.vertices { + v.radius = 20.0; + } + network.regions[0].fills = Some(vec![Paint::solid(Color::from_hex("FF0000").unwrap())]); + let mut d = Document::new("smoothing"); + let p = d.first_page().unwrap(); + let id = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 100.0, 100.0).unwrap(); + let content = |d: &Document| inflated(&export_pdf(d, &[id], &PdfOptions::default()).unwrap()); + let plain = content(&d); + d.get_mut(id).unwrap().corners.smoothing = 0.6; + let smoothed = content(&d); + assert!(smoothed.contains("1 0 0 rg"), "{smoothed}"); + assert_ne!(smoothed, plain, "the region's outline takes the smoothing"); +} + /// A page with two frames: shapes, gradients, an image, strokes, a mask, a boolean, effects, text. pub(crate) fn sample_doc() -> (Document, Vec) { let mut d = Document::new("sample"); diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index f48899aea..74512ef4f 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -274,7 +274,9 @@ fn cached_stroke_outline(n: &Node, path: &BezPath, closed: bool) -> Arc if let Some(p) = locked(stroke_cache()).get(key) { return p; } - let out = Arc::new(figcraft_pathops::stroke_outline_of(path, props, closed, net)); + // Points with styles of their own are stroked on the network as drawn (smoothed corners in). + let drawn = net.map(|network| figcraft_doc::geometry::drawn_network(network, n.corners.smoothing)); + let out = Arc::new(figcraft_pathops::stroke_outline_of(path, props, closed, drawn.as_deref())); locked(stroke_cache()).insert(key, out.clone()); out } @@ -334,9 +336,9 @@ fn node_outline_at(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, F Some((p, FillRule::Winding)) } NodeKind::Vector { network } => { + let drawn = figcraft_doc::geometry::drawn_network(network, n.corners.smoothing); + let net = drawn.as_ref(); if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some()) { - let rounded = network.rounded(); - let net = rounded.as_ref().unwrap_or(network); let mut areas = BezPath::new(); for (i, region) in net.regions.iter().enumerate() { let paints = region.fills.as_deref().unwrap_or(&n.fills); @@ -357,7 +359,7 @@ fn node_outline_at(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, F } let rule = if network.regions.iter().any(|r| r.winding == figcraft_doc::WindingRule::EvenOdd) { FillRule::EvenOdd } else { FillRule::Winding }; - Some((network.fill_path(), rule)) + Some((net.fill_path(), rule)) } _ => figcraft_doc::geometry::shape_path(n).map(|p| (p, FillRule::Winding)), } @@ -849,8 +851,8 @@ fn region_painted(n: &Node) -> Option<&figcraft_doc::VectorNetwork> { /// Each region of a vector with region paints, filled with its own paints (or the layer's) in its /// own paint frame. fn fill_vector_regions(ctx: &Ctx, pix: &mut Pixmap, n: &Node, network: &figcraft_doc::VectorNetwork, xf: Affine) { - let rounded = network.rounded(); - let net = rounded.as_ref().unwrap_or(network); + let drawn = figcraft_doc::geometry::drawn_network(network, n.corners.smoothing); + let net = drawn.as_ref(); for (i, region) in net.regions.iter().enumerate() { let paints = region.fills.as_deref().unwrap_or(&n.fills); if let Some(mut path) = net.region_path(i) { diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index de98795d3..b5047cec4 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -537,8 +537,8 @@ impl W<'_> { if let NodeKind::Vector { network } = &n.kind && network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some()) { - let rounded = network.rounded(); - let net = rounded.as_ref().unwrap_or(network); + let drawn = figcraft_doc::geometry::drawn_network(network, n.corners.smoothing); + let net = drawn.as_ref(); for (i, region) in net.regions.iter().enumerate() { let paints = region.fills.as_deref().unwrap_or(&n.fills); let fr = if region.winding == figcraft_doc::WindingRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" }; diff --git a/crates/ui-egui/src/corner_individual_tests.rs b/crates/ui-egui/src/corner_individual_tests.rs index e76f2f57e..281ab6f2a 100644 --- a/crates/ui-egui/src/corner_individual_tests.rs +++ b/crates/ui-egui/src/corner_individual_tests.rs @@ -184,3 +184,36 @@ fn polygons_and_stars_take_corner_smoothing() { assert_eq!(u.app.session.undo_depth().0, history + 1, "{cmd}: one undo step"); } } + +/// A closed vector shows the same Corner smoothing control, and its iOS button sets 60% in one +/// undo step; an open vector, which has no closed shape to smooth, shows no smoothing control. +#[test] +fn closed_vectors_take_corner_smoothing() { + let rows = [(true, json!([[0, 0], [120, 0], [120, 120], [0, 120]])), (false, json!([[0, 0], [120, 0], [120, 120]]))]; + for (closed, points) in rows { + let mut s = Session::new(); + let id = NodeId(s.execute("vector.create", &json!({"points": points, "closed": closed})).unwrap()["id"].as_u64().unwrap()); + s.execute("node.set", &json!({"ids": [id], "props": {"cornerRadius": 8}})).unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let mut u = Ui::new(s); + u.ctx.enable_accesskit(); + u.frame(vec![]); + let out = u.frame_output(vec![]); + let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes; + let toggle = nodes.iter().any(|(_, n)| n.role() == Role::Button && n.label() == Some("Corner smoothing")); + assert_eq!(toggle, closed, "closed {closed}: the Corner smoothing button shows only for a closed vector"); + if !closed { + continue; + } + let t = named_button(&mut u, "Corner smoothing"); + click(&mut u, t); + assert!((0..4).all(|k| u.ctx.read_response(Id::unique(("corner", k)).with("field")).is_none()), "no field per corner"); + assert!(u.ctx.read_response(Id::unique("smoothing").with("field")).is_some(), "the smoothing field shows"); + let history = u.app.session.undo_depth().0; + let ios = named_button(&mut u, "iOS"); + click(&mut u, ios); + let n = u.app.session.doc().unwrap().node(id).unwrap().clone(); + assert!((n.corners.smoothing - 0.6).abs() < 1e-9, "iOS sets 60%: {}", n.corners.smoothing); + assert_eq!(u.app.session.undo_depth().0, history + 1, "one undo step"); + } +} diff --git a/crates/ui-egui/src/panels/right/paints.rs b/crates/ui-egui/src/panels/right/paints.rs index 58423d252..10301a85e 100644 --- a/crates/ui-egui/src/panels/right/paints.rs +++ b/crates/ui-egui/src/panels/right/paints.rs @@ -53,7 +53,7 @@ pub(super) fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[N let radius_ok = crate::selection_cache::every(doc, sel, |n| n.kind.has_corner_radius()); // Only rectangles and frames round each corner on its own. let indep_ok = radius_ok && crate::selection_cache::every(doc, sel, |n| n.kind.has_independent_corners()); - // For polygons and stars the same panel holds only the Corner smoothing row. + // For polygons, stars and closed vectors the same panel holds only the Corner smoothing row. let smooth_ok = radius_ok && crate::selection_cache::every(doc, sel, |n| n.kind.has_corner_smoothing()); let r = common(doc, sel, |n| n.corners.uniform().map(|r| (r * 100.0).round() / 100.0)); widgets::sublabels(ui, tr("Opacity"), radius_ok.then_some(tr("Corner radius"))); diff --git a/docs/gaps.md b/docs/gaps.md index 5437c6766..b3685c912 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -384,8 +384,8 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe - **Landed (2026-10-12):** several Bezier handles selected with Shift-click and dragged together, sharp points beside curved segments rounded, a stroke cap and join per point (canvas, PNG, SVG, PDF, outline stroke), the Pen outside edit mode connecting to its own points and continuing a - selected open path. Left: corner smoothing on vectors, corner radius and smoothing on boolean - groups that contain rectangles, per-point caps and joins on dashed, variable-width and brush + selected open path. Left: corner radius and smoothing on boolean groups that contain + rectangles, per-point caps and joins on dashed, variable-width and brush strokes. - **Landed (2026-10-11):** boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 489c0e7da..2775c41d2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -99,7 +99,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 1 | Canvas & navigation | 6 | 98% | 74% | Display P3 on screen, QA at extreme zooms (behaviour suite for navigation, rulers, guides, layout grids and outline mode landed: guide selection, Auto grid counts, outline-mode clicks; outline mode's Include hidden layers and Include object bounds landed) | 1–4 | | 2 | Selection & transform | 8 | 97% | 72% | Handles, measuring and snapping checked tool by tool against Figma and real files (snap distance, handle reach, cursors); children turned off a right angle scale without skew, approximately (behaviour suite from the documentation, rotated groups and mixed selections, equal spacing snapping, Cmd/Ctrl to ignore constraints and marquees inside frames landed) | 3–5 | | 3 | Layers panel | 5 | 99% | 85% | QA with long real files (behaviour suite from the documentation, Rename layers dialog, sweeping eyes and padlocks, page dragging and dividers, several layers dropped together, keyboard box selection, F2 rename, Shift+F10 context menu and 10k-layer timings landed) | 1–2 | -| 4 | Shapes & vector | 8 | 99% | 73% | Corner smoothing on vectors, corner radius and smoothing on boolean groups that contain rectangles; per-point caps and joins on dashed, variable-width and brush strokes; vector QA with real files (behaviour suites from the documentation, corner radius and star ratio handles, corner smoothing on polygons and stars, rounded vector points drawn, also beside curved segments, a cap and join per point, several handles dragged together, cut and paint bucket drags, delete and heal along the curve, a boolean group's look by operation and the miter angle landed) | 1–3 | +| 4 | Shapes & vector | 8 | 99% | 73% | Corner radius and smoothing on boolean groups that contain rectangles; per-point caps and joins on dashed, variable-width and brush strokes; vector QA with real files (behaviour suites from the documentation, corner radius and star ratio handles, corner smoothing on polygons, stars and closed vectors, rounded vector points drawn, also beside curved segments, a cap and join per point, several handles dragged together, cut and paint bucket drags, delete and heal along the curve, a boolean group's look by operation and the miter angle landed) | 1–3 | | 5 | Paint | 7 | 90% | 71% | Video fills, wide-gamut canvas, HEIC images; plus darker in PDF (behaviour suite from the documentation, pass-through and isolating groups, clipping frames that don't isolate, plus darker, animated GIF fills, TIFF images and embedded image colour profiles landed) | 2–5 | | 6 | Strokes | 3 | 95% | 75% | Stroke QA with real files (colour styles on strokes, stroke weight and per-side weight variables, brush strokes landed, see 20) | 1–2 | | 7 | Effects | 4 | 100% | 75% | Glass and progressive blur tuned side by side (behaviour suite from the documentation, inner shadows and background blur on groups, shadows hidden behind translucent layers on the canvas and in every export landed) | 2–5 | From f56e30e7e3bf992d747881b7d0b0d21ed8e16efd Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 02:42:39 -0700 Subject: [PATCH 02/18] Hit testing: clicks on a closed vector follow its smoothed corners A vector without region paints tested a click against its plain rounded fill while the canvas drew the smoothed corners, so a click between the two could select a layer where no fill is drawn or miss fill that is drawn. The inside test now uses the network as drawn, keeping the even-odd rule. --- crates/doc/src/geometry.rs | 5 +++-- crates/doc/src/tests.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/doc/src/geometry.rs b/crates/doc/src/geometry.rs index 3dbbce6d4..189e06192 100644 --- a/crates/doc/src/geometry.rs +++ b/crates/doc/src/geometry.rs @@ -107,10 +107,11 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, o } /// Whether the fill of `n`, drawn as `path`, covers `lp` (vectors follow the rule of -/// [`VectorNetwork::fill_contains`](crate::VectorNetwork::fill_contains): even-odd when any region asks for it). +/// [`VectorNetwork::fill_contains`](crate::VectorNetwork::fill_contains) on the network as drawn: even-odd when any +/// region asks for it). fn fills_point(n: &Node, path: &BezPath, lp: Point) -> bool { match &n.kind { - NodeKind::Vector { network } => network.fill_contains(lp), + NodeKind::Vector { network } => drawn_network(network, n.corners.smoothing).fill_contains(lp), _ => path.winding(lp) != 0, } } diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 2eb912f45..7dbc8cc94 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -789,6 +789,33 @@ fn smoothed_vector_corners_stay_finite_on_awkward_networks() { assert!(!holed_net.fill_contains(Point::new(50.0, 50.0)) && holed_net.fill_contains(Point::new(12.0, 50.0)), "the hole stays a hole"); } +#[test] +fn clicks_on_a_smoothed_vector_corner_follow_the_drawn_fill() { + // A closed square with rounded points and smoothing: a click between the plain arc and the + // smoothed corner selects the layer only where the smoothed fill is drawn. + let pts = [Point::new(0.0, 0.0), Point::new(1000.0, 0.0), Point::new(1000.0, 1000.0), Point::new(0.0, 1000.0)]; + let mut net = VectorNetwork::from_points(&pts, true); + for v in &mut net.vertices { + v.radius = 200.0; + } + let smoothed = net.rounded_smoothed(1.0).unwrap(); + let mut d = Document::new("x"); + let p = page(&d); + let v = d.create(p, NodeKind::Vector { network: net.clone() }, "V", 0.0, 0.0, 1000.0, 1000.0).unwrap(); + let n = d.node_mut(v).unwrap(); + n.corners.smoothing = 1.0; + n.strokes.clear(); + // Walk the row 20 units below the top edge inward to where each outline starts covering it. + let first_inside = |inside: &dyn Fn(Point) -> bool| (0..5_000).map(|i| f64::from(i) * 0.1).find(|&x| inside(Point::new(x, 20.0))); + let plain = first_inside(&|pt| net.fill_contains(pt)).unwrap(); + let drawn = first_inside(&|pt| smoothed.fill_contains(pt)).unwrap(); + assert!((plain - drawn).abs() > 5.0, "smoothing moves the corner: plain {plain}, smoothed {drawn}"); + let pt = Point::new((plain + drawn) / 2.0, 20.0); + assert_ne!(net.fill_contains(pt), smoothed.fill_contains(pt)); + assert_eq!(d.hit_test(p, pt, 0.0, None).is_some(), smoothed.fill_contains(pt), "the click follows the smoothed corner at {pt:?}"); + assert_eq!(d.hit_test(p, Point::new(500.0, 500.0), 0.0, None), Some(v)); +} + #[test] fn stored_variable_ids_resolve_only_their_exact_collection() { let mut vars = crate::proto_expr_tests::vars(); From 099cc0375455a63803b49739183583163ef36bf9 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 31 Oct 2026 03:21:43 -0700 Subject: [PATCH 03/18] Vector: simplify, offset and Penpot export keep a closed vector's smoothed corners vector.simplify and vector.offset rebuild a closed vector from its rounded outline with the layer's corner smoothing, as the canvas draws it. A vector whose regions have paints of their own was rebuilt from its sharp points before; it now keeps its rounded corners. The Penpot export writes a stroked vector's path with its smoothed corners. --- crates/engine/src/cmd/structure.rs | 13 ++++--- crates/engine/src/vector_behaviour_tests.rs | 39 +++++++++++++++++++++ crates/format/src/penpot/export.rs | 4 ++- crates/format/src/penpot/export_tests.rs | 26 ++++++++++++++ 4 files changed, 77 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index 52b4a415d..5a79d0a24 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -751,8 +751,11 @@ fn simplify(s: &mut Session, p: &Value) -> Result { for i in req_targets("vector.simplify", s, p)? { let doc = s.doc_mut()?; let n = doc.get_mut(i)?; + let smoothing = n.corners.smoothing; if let NodeKind::Vector { network } = &mut n.kind { let source = network.clone(); + // The regions as the layer draws them: rounded points with the layer's corner smoothing. + let drawn = figcraft_doc::geometry::drawn_network(&source, smoothing); let mut net = VectorNetwork::default(); let legacy = source.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none()); if legacy && !source.regions.is_empty() { @@ -761,7 +764,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result { } else { WindingRule::NonZero }; - let path = source.fill_path(); + let path = drawn.fill_path(); let path = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default()); let (path, output_winding) = match figcraft_pathops::simplify_with_rule(&path, winding == WindingRule::EvenOdd) { Ok(path) => (path, WindingRule::EvenOdd), @@ -771,7 +774,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result { append_region_path(&mut net, &path, &combined, output_winding); } else { for (index, region) in source.regions.iter().enumerate() { - let Some(path) = source.region_path(index) else { continue }; + let Some(path) = drawn.region_path(index) else { continue }; let path = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default()); let path = figcraft_pathops::simplify_with_rule(&path, region.winding == WindingRule::EvenOdd).unwrap_or(path); append_region_path(&mut net, &path, region, region.winding); @@ -859,8 +862,10 @@ fn offset_path(s: &mut Session, p: &Value) -> Result { nm.height = bb.height(); continue; } + // The regions as the layer draws them: rounded points with the layer's corner smoothing. + let drawn = figcraft_doc::geometry::drawn_network(source, n.corners.smoothing); if legacy { - let path = source.fill_path(); + let path = drawn.fill_path(); let even_odd = source.regions.iter().any(|region| region.winding == WindingRule::EvenOdd); let path = figcraft_pathops::simplify_with_rule(&path, even_odd).map_err(|error| bad("vector.offset", error.to_string()))?; let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props); @@ -880,7 +885,7 @@ fn offset_path(s: &mut Session, p: &Value) -> Result { append_network(&mut net, part); } else { for (index, region) in source.regions.iter().enumerate() { - let Some(path) = source.region_path(index) else { continue }; + let Some(path) = drawn.region_path(index) else { continue }; let mut paint_region = region.clone(); let (paint_width, paint_height, paint_transform) = region.paint_frame(n.width, n.height); paint_region.paint_space = Some(RegionPaintSpace { width: paint_width, height: paint_height, transform: paint_transform }); diff --git a/crates/engine/src/vector_behaviour_tests.rs b/crates/engine/src/vector_behaviour_tests.rs index 83e585ea0..c990ad348 100644 --- a/crates/engine/src/vector_behaviour_tests.rs +++ b/crates/engine/src/vector_behaviour_tests.rs @@ -278,6 +278,45 @@ fn legacy_evenodd_holes_survive_bucket_offset_and_flatten() { assert!(!net(&s, flattened).fill_contains(Point::new(100.0, 100.0)), "flatten retains the even-odd hole"); } +#[test] +fn simplify_and_offset_keep_a_closed_vectors_smoothed_corners() { + // A closed 100 × 100 vector with radius 20 on every point and corner smoothing 0.6 draws a + // smoothed rectangle's outline; simplify and offset rebuild it from that outline. + let drawn = figcraft_geom::rounded_rect(100.0, 100.0, [20.0; 4], 0.6); + let plain = figcraft_geom::rounded_rect(100.0, 100.0, [20.0; 4], 0.0).area().abs(); + let (area, d) = (drawn.area().abs(), 2.0); + // Offsetting a convex outline by d adds its perimeter times d and a circle of radius d. + let grown = area + drawn.perimeter(1e-6) * d + std::f64::consts::PI * d * d; + assert!((area - plain).abs() > 10.0, "smoothing changes the outline: {area} vs {plain}"); + // (command, params, region paints of its own, expected area, tolerance: the offset ring is + // flattened before the union) + let rows = [ + ("vector.simplify", json!({"tolerance": 0.01}), false, area, 1.0), + ("vector.simplify", json!({"tolerance": 0.01}), true, area, 1.0), + ("vector.offset", json!({"offset": d}), false, grown, 4.0), + ("vector.offset", json!({"offset": d}), true, grown, 4.0), + ]; + let mut wrong = Vec::new(); + for (cmd, mut params, painted, expected, tolerance) in rows { + let mut s = Session::new(); + let id = id_of(&ex(&mut s, "vector.create", json!({"points": [[0, 0], [100, 0], [100, 100], [0, 100]], "closed": true}))["id"]).unwrap(); + ex(&mut s, "node.set", json!({"ids": [id], "props": {"cornerRadius": 20}})); + ex(&mut s, "node.set", json!({"ids": [id], "props": {"cornerSmoothing": 0.6}})); + if painted { + let doc = s.doc_mut().unwrap(); + let NodeKind::Vector { network } = &mut doc.get_mut(id).unwrap().kind else { panic!("expected vector fixture") }; + network.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]); + } + params["ids"] = json!([id]); + ex(&mut s, cmd, params); + let got = net(&s, id).fill_path().area().abs(); + if (got - expected).abs() >= tolerance { + wrong.push(format!("{cmd} (region paints: {painted}): {got} vs {expected}")); + } + } + assert!(wrong.is_empty(), "simplify and offset keep the smoothed corners: {wrong:?}"); +} + /// Give vector `id` a painted region, converting a plain shape to a vector first. fn ex_to_vector(doc: &mut figcraft_doc::Document, id: NodeId) { let n = doc.get(id).unwrap(); diff --git a/crates/format/src/penpot/export.rs b/crates/format/src/penpot/export.rs index cacafd0d9..d0b9d3046 100644 --- a/crates/format/src/penpot/export.rs +++ b/crates/format/src/penpot/export.rs @@ -713,7 +713,9 @@ impl<'a> Exporter<'a> { } } } - NodeKind::Vector { network } if node.fills.is_empty() || !node.strokes.is_empty() => self.doc.world_transform(id) * network.stroke_path(), + NodeKind::Vector { network } if node.fills.is_empty() || !node.strokes.is_empty() => { + self.doc.world_transform(id) * figcraft_doc::geometry::drawn_network(network, node.corners.smoothing).stroke_path() + } _ => match figcraft_doc::geometry::shape_path(node) { Some(p) => self.doc.world_transform(id) * p, None => BezPath::new(), diff --git a/crates/format/src/penpot/export_tests.rs b/crates/format/src/penpot/export_tests.rs index 4dacd4eae..1e9e95638 100644 --- a/crates/format/src/penpot/export_tests.rs +++ b/crates/format/src/penpot/export_tests.rs @@ -174,6 +174,32 @@ fn rectangles_ellipses_and_paths_keep_their_geometry() { assert!(matches!(back.node(named(&back, "Circle")).unwrap().kind, NodeKind::Ellipse { .. })); } +#[test] +fn stroked_closed_vectors_are_written_with_their_corner_smoothing() { + // A stroked closed 100 × 100 vector with radius 20 on every point and corner smoothing 0.6 is + // written as the smoothed outline it draws. + let mut doc = Document::new("Smoothed vector"); + let p = page(&doc); + let corners = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)]; + let mut network = VectorNetwork::from_points(&corners, true); + for v in &mut network.vertices { + v.radius = 20.0; + } + let id = add(&mut doc, p, NodeKind::Vector { network }, "Smoothed", 0.0, 0.0, 100.0, 100.0); + { + let n = doc.node_mut(id).unwrap(); + n.fills = vec![Paint::solid(Color::BLACK)]; + n.strokes = vec![Paint::solid(Color::BLACK)]; + n.corners.smoothing = 0.6; + } + let want = figcraft_doc::geom::rounded_rect(100.0, 100.0, [20.0; 4], 0.6).area().abs(); + let plain = figcraft_doc::geom::rounded_rect(100.0, 100.0, [20.0; 4], 0.0).area().abs(); + assert!((want - plain).abs() > 10.0, "smoothing changes the outline: {want} vs {plain}"); + let (back, _, _) = roundtrip(&doc); + let written = figcraft_doc::geometry::shape_path(back.node(named(&back, "Smoothed")).unwrap()).unwrap().area().abs(); + assert!((written - want).abs() < 0.5, "the written path keeps the smoothed corners: {written} vs {want}"); +} + #[test] fn fills_and_strokes_keep_their_order_and_styles() { let mut doc = Document::new("Paints"); From 01214c6e12aff34a2ac56503b1f7dbe8cb9a60c8 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sat, 31 Oct 2026 11:26:45 -0400 Subject: [PATCH 04/18] M8: CSS colours take none and every hue unit, refuse nan and inf, and to_css keeps the 8-bit alpha Found while testing crates/color/src/lib.rs (no existing issue), each with a test that fails before and passes after: rgb() accepted nan/inf/infinity identifiers (Rust float syntax, not CSS numbers); hsl() refused the turn, grad and rad hue units lch() and oklch() already take, and accepted repeated suffixes; none was refused in rgb() and hsl() and read as opaque in a lch()/oklch() alpha, where CSS Color 4 section 4.4 says a missing component is zero; to_css could print an alpha that read back as a different 8-bit value, and printed NaN or negative alphas from a field-built colour. One css_f32 number reader (sign, digits, point, exponent) and a css_fraction helper (number, percentage or none) now serve every CSS number path. Co-Authored-By: Claude Fable 5.1 --- crates/color/src/lch_tests.rs | 5 ++- crates/color/src/lib.rs | 79 ++++++++++++++++++++++++----------- crates/color/src/tests.rs | 76 +++++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 25 deletions(-) diff --git a/crates/color/src/lch_tests.rs b/crates/color/src/lch_tests.rs index 0bf249b07..d3998261d 100644 --- a/crates/color/src/lch_tests.rs +++ b/crates/color/src/lch_tests.rs @@ -73,7 +73,10 @@ fn css_hue_units_and_none() { assert_eq!(Color::parse_css(&format!("oklch(0.6 0.2 {h})")).unwrap().to_hex(), blue.to_hex(), "{h}"); } assert_eq!(Color::parse_css("lch(50 none none)").unwrap().to_hex(), Color::parse_css("lch(50 0 0)").unwrap().to_hex()); - assert_eq!(Color::parse_css("oklch(none 0 0 / none)").unwrap().to_rgba8(), [0, 0, 0, 255]); + // A missing alpha is zero like any other missing component (CSS Color 4 §4.4), so it is + // transparent; only an *omitted* alpha defaults to opaque. + assert_eq!(Color::parse_css("oklch(none 0 0 / none)").unwrap().to_rgba8(), [0, 0, 0, 0]); + assert_eq!(Color::parse_css("oklch(none 0 0)").unwrap().to_rgba8(), [0, 0, 0, 255]); } #[test] diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 190fdbb9c..764069742 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -158,14 +158,9 @@ impl Color { } if let Some(inner) = t.strip_prefix("rgba(").or_else(|| t.strip_prefix("rgb(")).and_then(|r| r.strip_suffix(')')) { let parts = css_args(inner)?; - let num = |i: usize| -> Option { - let p = parts.get(i)?; - if let Some(pc) = p.strip_suffix('%') { - pc.trim().parse::().ok().map(|v| v / 100.0) - } else { - p.parse::().ok().map(|v| v / 255.0) - } - }; + // A channel is a number of 255, a percentage or `none`; out-of-range values (a number + // too big for f32 included) clamp in `Color::new`, as CSS Color 4 §5.1 asks. + let num = |i: usize| css_fraction(parts.get(i)?, 255.0); let alpha = match parts.get(3) { Some(a) => parse_alpha(a)?, None => 1.0, @@ -177,10 +172,12 @@ impl Color { } if let Some(inner) = t.strip_prefix("hsla(").or_else(|| t.strip_prefix("hsl(")).and_then(|r| r.strip_suffix(')')) { let parts = css_args(inner)?; - let h = parts.first()?.trim_end_matches("deg").parse::().ok()?; - let s = parts.get(1)?.trim_end_matches('%').parse::().ok()? / 100.0; - let l = parts.get(2)?.trim_end_matches('%').parse::().ok()? / 100.0; - if !(h.is_finite() && s.is_finite() && l.is_finite()) { + // The hue takes every CSS angle unit; saturation and lightness are percentages, numbers + // read as percentages (CSS Color 4 §7) or `none`. + let h = css_hue(parts.first()?)?; + let s = css_fraction(parts.get(1)?, 100.0)?; + let l = css_fraction(parts.get(2)?, 100.0)?; + if !(s.is_finite() && l.is_finite()) { return None; } let alpha = match parts.get(3) { @@ -209,14 +206,23 @@ impl Color { /// CSS `rgba(...)` / hex text for copy-as-CSS. pub fn to_css(self) -> String { - let [r, g, b, _] = self.to_rgba8(); - if self.a >= 0.999 { + let [r, g, b, a8] = self.to_rgba8(); + let a = clamp01(self.a); + if a >= 0.999 { return self.to_css_hex(); } - // Two decimals when they keep the 8-bit alpha, three otherwise (near-transparent colours). - let a8 = |a: f32| (a.clamp(0.0, 1.0) * 255.0).round() as u8; - let short = format!("{:.2}", self.a); - let alpha = if short.parse::().is_ok_and(|v| a8(v) == a8(self.a)) { short } else { format!("{:.3}", self.a) }; + // The fewest decimals that read back as the same 8-bit alpha: two, else three, else three + // of the 8-bit value itself (within half a step of it, so it always reads back). + let keeps = |s: &str| s.parse::().is_ok_and(|v| (v * 255.0).round() as u8 == a8); + let two = format!("{a:.2}"); + let three = format!("{a:.3}"); + let alpha = if keeps(&two) { + two + } else if keeps(&three) { + three + } else { + format!("{:.3}", f32::from(a8) / 255.0) + }; format!("rgba({r}, {g}, {b}, {alpha})") } @@ -268,19 +274,42 @@ fn css_polar(inner: &str, l_full: f32, c_full: f32) -> Option<([f32; 3], f32)> { let l = css_number(parts.first()?, l_full)?; let c = css_number(parts.get(1)?, c_full)?; let h = css_hue(parts.get(2)?)?; - let alpha = match parts.get(3).map(String::as_str) { - None | Some("none") => 1.0, + let alpha = match parts.get(3) { + None => 1.0, Some(a) => parse_alpha(a)?, }; Some(([l, c, h], alpha)) } +/// A CSS `` as f32: a sign, digits, a decimal point and an exponent only. Rust's float +/// parser also reads `inf`, `infinity` and `nan`, which are identifiers in CSS, not numbers. A +/// value too big for f32 comes back as an infinity for the caller to clamp or refuse. +fn css_f32(s: &str) -> Option { + if !s.bytes().all(|b| b.is_ascii_digit() || matches!(b, b'+' | b'-' | b'.' | b'e' | b'E')) { + return None; + } + s.parse::().ok() +} + +/// A CSS number, percentage or `none` as a fraction of one: a percentage is of 100, a bare number +/// of `number_full` (255 for an rgb() channel, 100 for hsl() saturation and lightness, 1 for +/// alpha) and `none` is 0 (CSS Color 4 §4.4). +fn css_fraction(p: &str, number_full: f32) -> Option { + if p == "none" { + return Some(0.0); + } + match p.strip_suffix('%') { + Some(pc) => Some(css_f32(pc)? / 100.0), + None => Some(css_f32(p)? / number_full), + } +} + /// A CSS number, a percentage of `full` or `none` (0); finite only. fn css_number(p: &str, full: f32) -> Option { let v = match p.strip_suffix('%') { _ if p == "none" => 0.0, - Some(pc) => pc.trim().parse::().ok()? / 100.0 * full, - None => p.parse::().ok()?, + Some(pc) => css_f32(pc)? / 100.0 * full, + None => css_f32(p)?, }; v.is_finite().then_some(v) } @@ -294,12 +323,14 @@ fn css_hue(p: &str) -> Option { .into_iter() .find_map(|(unit, k)| p.strip_suffix(unit).map(|n| (n, k))) .unwrap_or((p, 1.0)); - let v = num.parse::().ok()? / per_degree; + let v = css_f32(num)? / per_degree; v.is_finite().then_some(v) } +/// A CSS ``: a number, a percentage or `none` (0: a missing alpha is transparent, CSS +/// Color 4 §4.4); finite only. An *omitted* alpha is the caller's default of 1. fn parse_alpha(a: &str) -> Option { - let v = if let Some(pc) = a.strip_suffix('%') { pc.parse::().ok().map(|v| v / 100.0) } else { a.parse::().ok() }?; + let v = css_fraction(a, 1.0)?; v.is_finite().then_some(v) } diff --git a/crates/color/src/tests.rs b/crates/color/src/tests.rs index a41d0dd2e..6100a89ad 100644 --- a/crates/color/src/tests.rs +++ b/crates/color/src/tests.rs @@ -157,3 +157,79 @@ fn css_rejects_empty_arguments() { assert!(Color::parse_css("rgb(255 0 0 / 50%)").is_some()); assert!(Color::parse_css("hsl(120deg 50% 50%)").is_some()); } + +#[test] +fn css_rgb_rejects_non_numeric_channels() { + // `nan`, `inf` and `infinity` are identifiers, not CSS numbers; the hsl(), lch() and oklch() + // branches and the alpha already refuse them, and so must rgb() channels (which previously read + // `rgb(nan, 0, 0)` as black and `rgb(inf, 0, 0)` as red). + for s in ["rgb(nan, 0, 0)", "rgb(inf, 0, 0)", "rgb(0, 0, infinity)", "rgb(-inf, 0, 0)", "rgb(nan%, 0, 0)", "rgb(NaN 0 0)", "rgba(0, 0, 0, inf)"] { + assert_eq!(Color::parse_css(s), None, "{s}"); + } + // A genuine number that overflows f32 is still a number and clamps (CSS Color 4 §5.1). + assert_eq!(Color::parse_css("rgb(1e40, -1e40, 0)").unwrap().to_rgba8(), [255, 0, 0, 255]); + assert_eq!(Color::parse_css("rgb(+255, .5%, 1E2)").unwrap().to_rgba8(), [255, 1, 100, 255]); +} + +#[test] +fn css_hsl_takes_every_hue_unit() { + // CSS Color 4 §4.3: ` = | `, so turn, grad and rad are as valid as deg. + let cyan = Color::parse_css("hsl(180, 100%, 50%)").unwrap(); + assert_eq!(cyan.to_hex(), "00FFFF"); + let units = + ["hsl(0.5turn, 100%, 50%)", "hsl(200grad 100% 50%)", "hsl(3.1415927rad 100% 50%)", "hsl(180deg 100% 50%)", "hsla(-0.5turn, 100%, 50%, 1)"]; + for s in units { + assert_eq!(Color::parse_css(s).map(|c| c.to_hex()), Some(cyan.to_hex()), "{s}"); + } + // One unit only: a repeated or stray unit is not a dimension. + for s in ["hsl(120degdeg, 100%, 50%)", "hsl(120, 100%%, 50%)", "hsl(0.5turndeg 100% 50%)", "hsl(deg 100% 50%)"] { + assert_eq!(Color::parse_css(s), None, "{s}"); + } +} + +#[test] +fn css_none_is_zero_in_every_function() { + // CSS Color 4 §4.4: every modern-syntax component may be `none`, and outside interpolation a + // missing component "behaves as a zero value" -- including a missing alpha, which renders + // fully transparent. + assert_eq!(Color::parse_css("rgb(none 0 0)").unwrap().to_rgba8(), [0, 0, 0, 255]); + assert_eq!(Color::parse_css("rgb(255 none none)").unwrap().to_rgba8(), [255, 0, 0, 255]); + assert_eq!(Color::parse_css("rgb(255 0 0 / none)").unwrap().to_rgba8(), [255, 0, 0, 0]); + assert_eq!(Color::parse_css("hsl(none 100% 50%)").unwrap().to_hex(), "FF0000"); + assert_eq!(Color::parse_css("hsl(120 none 50%)").unwrap().to_hex(), "808080"); + assert_eq!(Color::parse_css("hsl(120 100% none)").unwrap().to_hex(), "000000"); + assert_eq!(Color::parse_css("hsl(120 100% 50% / none)").unwrap().to_rgba8(), [0, 255, 0, 0]); + assert_eq!(Color::parse_css("oklch(0.5 0.1 20 / none)").unwrap().a, 0.0); + assert_eq!(Color::parse_css("lch(50 50 50 / none)").unwrap().a, 0.0); + // An omitted alpha still defaults to opaque. + assert_eq!(Color::parse_css("rgb(none none none)").unwrap().a, 1.0); +} + +#[test] +fn to_css_keeps_the_8_bit_alpha_for_any_alpha() { + // Three decimals of the alpha can still round across an 8-bit step (0.0055 -> "0.006" reads + // back as 2/255, not 1/255); the text must always read back to the same 8-bit alpha. + for i in 0..=10_000u32 { + let a = i as f32 / 10_000.0; + let c = Color::new(0.1, 0.2, 0.3, a); + let css = c.to_css(); + assert_eq!(Color::parse_css(&css).map(|b| b.to_rgba8()), Some(c.to_rgba8()), "alpha {a}: {css}"); + } + let c = Color::new(0.1, 0.2, 0.3, 0.0055); + assert_eq!(Color::parse_css(&c.to_css()).unwrap().to_rgba8()[3], 1, "{}", c.to_css()); + // Still the short forms where they are exact. + assert_eq!(Color::new(1.0, 0.0, 0.0, 0.5).to_css(), "rgba(255, 0, 0, 0.50)"); + assert_eq!(Color::from_rgba8(255, 0, 0, 85).to_css(), "rgba(255, 0, 0, 0.333)"); +} + +#[test] +fn to_css_sanitizes_an_unclamped_alpha() { + // The RGB channels go through `to_rgba8` and are sanitized; the alpha must be too, or a colour + // built field-by-field prints `rgba(0, 0, 0, NaN)` / `rgba(0, 0, 0, -0.50)`, which is not CSS. + for a in [f32::NAN, -0.5, f32::NEG_INFINITY] { + let c = Color { r: 0.0, g: 0.0, b: 0.0, a }; + let css = c.to_css(); + assert_eq!(Color::parse_css(&css).map(|b| b.to_rgba8()), Some([0, 0, 0, 0]), "alpha {a}: {css}"); + } + assert_eq!(Color { r: 1.0, g: 0.0, b: 0.0, a: f32::INFINITY }.to_css(), "#FF0000"); +} From 467607762616424b9345c8f1cefe09198e6e2d8e Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 1 Nov 2026 00:16:56 -0400 Subject: [PATCH 05/18] SVG color-mix(): a missing alpha is taken from the other colour before the mix Review follow-up on #1773: with a none alpha now zero when a colour is rendered on its own (CSS Color 4 section 4.4), color-mix() needs the carry-forward rule of CSS Color 5 section 4.1, else color-mix(in srgb, rgb(255 0 0 / none), blue) came out half transparent instead of opaque purple. The operand reports a missing alpha and mix() fills it from the other colour; both missing stays zero. Co-Authored-By: Claude Fable 5.1 --- crates/svg/src/import/css_color.rs | 33 ++++++++++++++++++------ crates/svg/src/import/css_color_tests.rs | 6 +++++ 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/crates/svg/src/import/css_color.rs b/crates/svg/src/import/css_color.rs index 40bf0ab40..e330959fe 100644 --- a/crates/svg/src/import/css_color.rs +++ b/crates/svg/src/import/css_color.rs @@ -219,9 +219,16 @@ fn mix(args: &str, profile: ColorProfile, depth: usize) -> Eval { ["in", ..] => (Space::Srgb, Some("only srgb, oklab and oklch mixes are read: it was mixed in sRGB")), _ => return Err("color-mix() starts with `in` and a colour space"), }; - let (a, pa, why_a) = operand(first, profile, depth)?; - let (b, pb, why_b) = operand(second, profile, depth)?; + let (a, pa, why_a, a_none) = operand(first, profile, depth)?; + let (b, pb, why_b, b_none) = operand(second, profile, depth)?; why = why.or(why_a).or(why_b); + // A missing alpha (`/ none`) is carried over from the other colour before the mix (CSS Color 5 + // section 4.1); rendered on its own the same colour is transparent. + let (a, b) = match (a_none, b_none) { + (true, false) => (a.with_alpha(b.a), b), + (false, true) => (a, b.with_alpha(a.a)), + _ => (a, b), + }; if [pa, pb].into_iter().flatten().any(|p| !(0.0..=100.0).contains(&p)) { return Err("mix percentages are between 0% and 100%"); } @@ -239,8 +246,11 @@ fn mix(args: &str, profile: ColorProfile, depth: usize) -> Eval { Ok((mixed.with_alpha(mixed.a * (sum / 100.0).min(1.0)), why)) } -/// One colour of a mix and its percentage (before or after it). -fn operand(s: &str, profile: ColorProfile, depth: usize) -> Result<(Color, Option, Option<&'static str>), &'static str> { +/// One colour of a mix: the colour, its percentage (before or after it), a note, and whether its +/// alpha is missing (`rgb(255 0 0 / none)`), which [`mix`] fills from the other colour. +type Operand = (Color, Option, Option<&'static str>, bool); + +fn operand(s: &str, profile: ColorProfile, depth: usize) -> Result { let words = split_top(s, char::is_whitespace); let pct = |w: &str| w.strip_suffix('%').and_then(|p| p.parse::().ok()).filter(|p| p.is_finite()); let (color, p) = match words.as_slice() { @@ -249,12 +259,19 @@ fn operand(s: &str, profile: ColorProfile, depth: usize) -> Result<(Color, Optio [p, c] if pct(p).is_some() => (*c, pct(p)), _ => return Err("a mix colour is a colour and an optional percentage"), }; - let (c, why) = if color.starts_with("color(") || color.starts_with("color-mix(") { - eval(color, profile, depth + 1)? + let (c, why, alpha_missing) = if color.starts_with("color(") || color.starts_with("color-mix(") { + let (c, why) = eval(color, profile, depth + 1)?; + (c, why, false) } else { - (Color::parse_css(color).ok_or("a mix colour isn't read")?, None) + (Color::parse_css(color).ok_or("a mix colour isn't read")?, None, alpha_is_none(color)) }; - Ok((c, p, why)) + Ok((c, p, why, alpha_missing)) +} + +/// Whether a colour function's text (already lower case) gives its alpha as `none`: what follows +/// its last `/`. +fn alpha_is_none(color: &str) -> bool { + color.rsplit_once('/').is_some_and(|(_, rest)| rest.trim().trim_end_matches(')').trim_end() == "none") } /// `a` and `b` mixed `t` of the way to `b`, premultiplied by their alpha. diff --git a/crates/svg/src/import/css_color_tests.rs b/crates/svg/src/import/css_color_tests.rs index dbf3856a0..1c7177e1c 100644 --- a/crates/svg/src/import/css_color_tests.rs +++ b/crates/svg/src/import/css_color_tests.rs @@ -73,6 +73,12 @@ fn color_mix_in_srgb_oklab_and_oklch() { // Percentages under 100% lower the alpha; alphas mix premultiplied. assert_eq!(rgba8(fill("color-mix(in srgb, red 20%, blue 20%)").0), Some([128, 0, 128, 102])); assert_eq!(rgba8(fill("color-mix(in srgb, red, transparent)").0), Some([255, 0, 0, 128])); + // A missing alpha (`/ none`) takes the other colour's before the mix (CSS Color 5 section 4.1); + // on its own the same colour renders transparent. + assert_eq!(rgba8(fill("color-mix(in srgb, rgb(255 0 0 / none), blue)").0), Some([128, 0, 128, 255])); + assert_eq!(rgba8(fill("color-mix(in srgb, red, rgb(0 0 255 / none) 50%)").0), Some([128, 0, 128, 255])); + assert_eq!(rgba8(fill("color-mix(in srgb, rgb(255 0 0 / none), rgb(0 0 255 / 50%))").0), Some([128, 0, 128, 128])); + assert_eq!(rgba8(fill("color-mix(in srgb, rgb(255 0 0 / none), rgb(0 0 255 / none))").0), Some([128, 0, 128, 0]), "both missing: zero"); // Nested, with color() inside. assert_eq!(rgba8(fill("color-mix(in srgb, color-mix(in srgb, red, blue), color(srgb 0 1 0) 0%)").0), Some([128, 0, 128, 255])); } From 8b6c92796eb3492968ae8fdfaadb207496892101 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 1 Nov 2026 00:39:38 -0400 Subject: [PATCH 06/18] SVG color-mix(): every missing component is carried over, through nested mixes too Review follow-up on #1773: the carry-forward now covers red, green and blue in an sRGB mix (converted first in another space, so zero there, per CSS Color 5 section 4.1 on analogous components) as well as the alpha in every mix, and a component missing in both colours stays missing in the result, so an enclosing mix carries it from its other colour. Co-Authored-By: Claude Fable 5.1 --- crates/svg/src/import/css_color.rs | 87 ++++++++++++++++++------ crates/svg/src/import/css_color_tests.rs | 7 ++ 2 files changed, 72 insertions(+), 22 deletions(-) diff --git a/crates/svg/src/import/css_color.rs b/crates/svg/src/import/css_color.rs index e330959fe..9301275ed 100644 --- a/crates/svg/src/import/css_color.rs +++ b/crates/svg/src/import/css_color.rs @@ -27,8 +27,50 @@ const GREY_CHROMA: f32 = 0.000_2; /// The attributes that can hold a colour (with `style`, which can hold any property). const COLOR_ATTRS: [&str; 8] = ["fill", "stroke", "stop-color", "flood-color", "lighting-color", "color", "solid-color", "style"]; -/// A colour read from a call, with why it is only an approximation; or why it can't be read. -type Eval = Result<(Color, Option<&'static str>), &'static str>; +/// A colour read from a call, with why it is only an approximation (or why it can't be read) and +/// which of its components a mix should treat as missing. +type Eval = Result<(Color, Option<&'static str>, Missing), &'static str>; + +/// Which components a colour gave as `none`: red, green, blue and alpha. A mix takes a missing +/// component from its other colour before interpolating (CSS Color 5 section 4.1): red, green and +/// blue only in an sRGB mix (in another space they are converted first, and a missing one is 0), +/// the alpha in every mix. Missing in both colours, a component stays missing (and 0). +#[derive(Clone, Copy)] +struct Missing { + r: bool, + g: bool, + b: bool, + a: bool, +} + +impl Missing { + const NONE: Self = Self { r: false, g: false, b: false, a: false }; + + /// What a colour function's text (lower case) gives as `none`: the four values of `rgb()` or + /// `rgba()`, else only an alpha after a `/`. + fn of(color: &str) -> Self { + let rgb = color.strip_prefix("rgba(").or_else(|| color.strip_prefix("rgb(")).and_then(|r| r.strip_suffix(')')); + match rgb { + Some(inner) => { + let parts = split_top(inner, |c| c == ',' || c == '/' || c.is_whitespace()); + let at = |i: usize| parts.get(i).is_some_and(|p| *p == "none"); + Self { r: at(0), g: at(1), b: at(2), a: at(3) } + } + None => Self { a: alpha_is_none(color), ..Self::NONE }, + } + } +} + +/// Fill a component missing on one side from the other; true when it is missing on both. +fn carry(a: &mut f32, b: &mut f32, a_missing: bool, b_missing: bool) -> bool { + match (a_missing, b_missing) { + (true, false) => *a = *b, + (false, true) => *b = *a, + (true, true) => return true, + (false, false) => {} + } + false +} /// `data` with its `color()` and `color-mix()` calls replaced by colours usvg reads, and notes on /// those it read only approximately or not at all; `None` when there are none (or `data` isn't @@ -90,8 +132,8 @@ fn rewrite_calls(text: &str, range: Range, profile: ColorProfile, edits: }; let call = s.get(start..end).unwrap_or_default(); let color = match eval(call, profile, 0) { - Ok((c, None)) => c, - Ok((c, Some(why))) => { + Ok((c, None, _)) => c, + Ok((c, Some(why), _)) => { notes.push(note(call, why)); c } @@ -181,7 +223,7 @@ fn color_fn(args: &str, profile: ColorProfile) -> Eval { "display-p3" => (ColorProfile::DisplayP3, None), _ => (ColorProfile::Srgb, Some("only the srgb and display-p3 spaces are read: its values were taken as sRGB")), }; - Ok((from.conversion_to(profile).apply(c), why)) + Ok((from.conversion_to(profile).apply(c), why, Missing::NONE)) } /// A CSS number, a percentage (100% is 1) or `none` (0); finite only. @@ -219,15 +261,17 @@ fn mix(args: &str, profile: ColorProfile, depth: usize) -> Eval { ["in", ..] => (Space::Srgb, Some("only srgb, oklab and oklch mixes are read: it was mixed in sRGB")), _ => return Err("color-mix() starts with `in` and a colour space"), }; - let (a, pa, why_a, a_none) = operand(first, profile, depth)?; - let (b, pb, why_b, b_none) = operand(second, profile, depth)?; + let (mut a, pa, why_a, ma) = operand(first, profile, depth)?; + let (mut b, pb, why_b, mb) = operand(second, profile, depth)?; why = why.or(why_a).or(why_b); - // A missing alpha (`/ none`) is carried over from the other colour before the mix (CSS Color 5 - // section 4.1); rendered on its own the same colour is transparent. - let (a, b) = match (a_none, b_none) { - (true, false) => (a.with_alpha(b.a), b), - (false, true) => (a, b.with_alpha(a.a)), - _ => (a, b), + // Missing components are carried over from the other colour before the mix (see `Missing`); + // rendered on its own, the same colour has them at 0. + let srgb = matches!(space, Space::Srgb); + let missing = Missing { + r: srgb && carry(&mut a.r, &mut b.r, ma.r, mb.r), + g: srgb && carry(&mut a.g, &mut b.g, ma.g, mb.g), + b: srgb && carry(&mut a.b, &mut b.b, ma.b, mb.b), + a: carry(&mut a.a, &mut b.a, ma.a, mb.a), }; if [pa, pb].into_iter().flatten().any(|p| !(0.0..=100.0).contains(&p)) { return Err("mix percentages are between 0% and 100%"); @@ -243,12 +287,12 @@ fn mix(args: &str, profile: ColorProfile, depth: usize) -> Eval { return Err("mix percentages add up to 0%"); } let mixed = interpolate(space, a, b, pb / sum); - Ok((mixed.with_alpha(mixed.a * (sum / 100.0).min(1.0)), why)) + Ok((mixed.with_alpha(mixed.a * (sum / 100.0).min(1.0)), why, missing)) } -/// One colour of a mix: the colour, its percentage (before or after it), a note, and whether its -/// alpha is missing (`rgb(255 0 0 / none)`), which [`mix`] fills from the other colour. -type Operand = (Color, Option, Option<&'static str>, bool); +/// One colour of a mix: the colour, its percentage (before or after it), a note, and the +/// components it gave as `none` (see [`Missing`]). +type Operand = (Color, Option, Option<&'static str>, Missing); fn operand(s: &str, profile: ColorProfile, depth: usize) -> Result { let words = split_top(s, char::is_whitespace); @@ -259,13 +303,12 @@ fn operand(s: &str, profile: ColorProfile, depth: usize) -> Result (*c, pct(p)), _ => return Err("a mix colour is a colour and an optional percentage"), }; - let (c, why, alpha_missing) = if color.starts_with("color(") || color.starts_with("color-mix(") { - let (c, why) = eval(color, profile, depth + 1)?; - (c, why, false) + let (c, why, missing) = if color.starts_with("color(") || color.starts_with("color-mix(") { + eval(color, profile, depth + 1)? } else { - (Color::parse_css(color).ok_or("a mix colour isn't read")?, None, alpha_is_none(color)) + (Color::parse_css(color).ok_or("a mix colour isn't read")?, None, Missing::of(color)) }; - Ok((c, p, why, alpha_missing)) + Ok((c, p, why, missing)) } /// Whether a colour function's text (already lower case) gives its alpha as `none`: what follows diff --git a/crates/svg/src/import/css_color_tests.rs b/crates/svg/src/import/css_color_tests.rs index 1c7177e1c..ee66b42da 100644 --- a/crates/svg/src/import/css_color_tests.rs +++ b/crates/svg/src/import/css_color_tests.rs @@ -79,6 +79,13 @@ fn color_mix_in_srgb_oklab_and_oklch() { assert_eq!(rgba8(fill("color-mix(in srgb, red, rgb(0 0 255 / none) 50%)").0), Some([128, 0, 128, 255])); assert_eq!(rgba8(fill("color-mix(in srgb, rgb(255 0 0 / none), rgb(0 0 255 / 50%))").0), Some([128, 0, 128, 128])); assert_eq!(rgba8(fill("color-mix(in srgb, rgb(255 0 0 / none), rgb(0 0 255 / none))").0), Some([128, 0, 128, 0]), "both missing: zero"); + // A missing red, green or blue is carried over as well in an sRGB mix; in another space the + // converted channels are plain zeros (only analogous components carry, CSS Color 5 section 4.1). + assert_eq!(rgba8(fill("color-mix(in srgb, rgb(none 0 0), red)").0), Some([255, 0, 0, 255])); + assert_eq!(rgba8(fill("color-mix(in srgb, rgb(none 0 0), rgb(255 none 0))").0), Some([255, 0, 0, 255])); + assert_eq!(rgba8(fill("color-mix(in oklab, rgb(none 0 0), red)").0), rgba8(fill("color-mix(in oklab, black, red)").0)); + // Missing in both stays missing through a nested mix and is carried in the outer one. + assert_eq!(rgba8(fill("color-mix(in srgb, color-mix(in srgb, rgb(0 255 0 / none), rgb(0 255 0 / none)), red)").0), Some([128, 128, 0, 255])); // Nested, with color() inside. assert_eq!(rgba8(fill("color-mix(in srgb, color-mix(in srgb, red, blue), color(srgb 0 1 0) 0%)").0), Some([128, 0, 128, 255])); } From d72f0961867826c6bf9983b40734ada6e1dab70e Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 1 Nov 2026 11:54:35 -0500 Subject: [PATCH 07/18] M8: color-mix(): a none in color() is a missing component too Co-Authored-By: Claude Fable 5.1 --- crates/svg/src/import/css_color.rs | 5 ++++- crates/svg/src/import/css_color_tests.rs | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/svg/src/import/css_color.rs b/crates/svg/src/import/css_color.rs index 9301275ed..715883de8 100644 --- a/crates/svg/src/import/css_color.rs +++ b/crates/svg/src/import/css_color.rs @@ -218,12 +218,15 @@ fn color_fn(args: &str, profile: ColorProfile) -> Eval { None => 1.0, }; let c = Color::new(value(r)?, value(g)?, value(b)?, a); + // A `none` is 0 here and missing in a mix: reds, greens and blues are analogous across the RGB + // spaces (CSS Color 4 section 12.2), so a missing channel stays missing through the conversion. + let missing = Missing { r: *r == "none", g: *g == "none", b: *b == "none", a: alpha == Some("none") }; let (from, why) = match *space { "srgb" => (ColorProfile::Srgb, None), "display-p3" => (ColorProfile::DisplayP3, None), _ => (ColorProfile::Srgb, Some("only the srgb and display-p3 spaces are read: its values were taken as sRGB")), }; - Ok((from.conversion_to(profile).apply(c), why, Missing::NONE)) + Ok((from.conversion_to(profile).apply(c), why, missing)) } /// A CSS number, a percentage (100% is 1) or `none` (0); finite only. diff --git a/crates/svg/src/import/css_color_tests.rs b/crates/svg/src/import/css_color_tests.rs index ee66b42da..c327fee01 100644 --- a/crates/svg/src/import/css_color_tests.rs +++ b/crates/svg/src/import/css_color_tests.rs @@ -86,6 +86,15 @@ fn color_mix_in_srgb_oklab_and_oklch() { assert_eq!(rgba8(fill("color-mix(in oklab, rgb(none 0 0), red)").0), rgba8(fill("color-mix(in oklab, black, red)").0)); // Missing in both stays missing through a nested mix and is carried in the outer one. assert_eq!(rgba8(fill("color-mix(in srgb, color-mix(in srgb, rgb(0 255 0 / none), rgb(0 255 0 / none)), red)").0), Some([128, 128, 0, 255])); + // `color()` gives its `none` values the same way, in either of its spaces (reds, greens and + // blues are analogous across RGB spaces, CSS Color 4 section 12.2). + assert_eq!(rgba8(fill("color-mix(in srgb, color(srgb 1 0 0 / none), blue)").0), Some([128, 0, 128, 255])); + assert_eq!(rgba8(fill("color-mix(in srgb, color(srgb none 0 0), red)").0), Some([255, 0, 0, 255])); + assert_eq!(rgba8(fill("color-mix(in srgb, color(display-p3 none 0 0), red)").0), Some([255, 0, 0, 255])); + assert_eq!( + rgba8(fill("color-mix(in srgb, color-mix(in srgb, color(srgb 0 1 0 / none), color(srgb 0 1 0 / none)), red)").0), + Some([128, 128, 0, 255]) + ); // Nested, with color() inside. assert_eq!(rgba8(fill("color-mix(in srgb, color-mix(in srgb, red, blue), color(srgb 0 1 0) 0%)").0), Some([128, 0, 128, 255])); } From 695ca0752874c94138bd5e362136bf28850a3c42 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Mon, 2 Nov 2026 06:43:02 -0500 Subject: [PATCH 08/18] M3: text styles carry paragraph spacing and indent, the list style and the OpenType features (#1829) ParagraphStyle beside TextStyle in StyleKind::Text: style.create takes it from the layer or a value, style.apply sets it (the list on the paragraphs touched for a range), style.update moves it, editing it unlinks, .fig files keep it with the style; tests in styles_behaviour_tests and fig_export_assets_tests. Co-Authored-By: Claude Fable 5.1 --- crates/doc/src/colors.rs | 2 +- crates/doc/src/lib.rs | 2 +- crates/doc/src/styles.rs | 22 ++++- crates/doc/src/text.rs | 68 ++++++++++++++ crates/engine/src/assets/style_vars.rs | 4 +- crates/engine/src/cmd/file/load.rs | 6 +- crates/engine/src/cmd/styles.rs | 94 ++++++++++++++++--- crates/engine/src/content/synthetic_ds.rs | 4 +- crates/engine/src/fig_export_assets_tests.rs | 21 +++++ crates/engine/src/fonts.rs | 2 +- crates/engine/src/fonts/tests.rs | 5 +- crates/engine/src/sketch_import_tests.rs | 2 +- crates/engine/src/styles_behaviour_tests.rs | 65 ++++++++++++- crates/engine/src/text_style_picker_tests.rs | 4 +- crates/format/src/fig/assets_tests.rs | 4 +- crates/format/src/fig/export/assets.rs | 7 +- crates/format/src/fig/text_skip_ink_tests.rs | 6 +- crates/format/src/fig/text_style_tests.rs | 2 +- crates/format/src/penpot.rs | 12 +-- crates/format/src/rest.rs | 10 +- crates/format/src/rest/tests.rs | 2 +- crates/format/src/sketch/shared_tests.rs | 2 +- crates/ui-egui/src/widgets/style_picker.rs | 41 +++++++- .../ui-egui/src/widgets/style_picker/tests.rs | 8 +- docs/components-parity.md | 5 +- docs/mcp.md | 12 ++- 26 files changed, 346 insertions(+), 66 deletions(-) diff --git a/crates/doc/src/colors.rs b/crates/doc/src/colors.rs index f4681601b..bd4dfdbab 100644 --- a/crates/doc/src/colors.rs +++ b/crates/doc/src/colors.rs @@ -79,7 +79,7 @@ impl Style { pub fn for_each_color_mut(&mut self, f: &mut ColorVisitor) { match &mut self.kind { StyleKind::Paint { paints } => paints.iter_mut().for_each(|p| p.for_each_color_mut(f)), - StyleKind::Text { style } => style.for_each_color_mut(f), + StyleKind::Text { style, .. } => style.for_each_color_mut(f), StyleKind::Effect { effects } => effects.iter_mut().for_each(|e| e.for_each_color_mut(f)), StyleKind::Grid { grids } => grids.iter_mut().for_each(|g| f(&mut g.color)), } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 89eb97269..eecb64af2 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -1206,7 +1206,7 @@ impl Document { .styles .iter() .filter_map(|s| match &s.kind { - StyleKind::Text { style } => Some((s.id.clone(), style.clone())), + StyleKind::Text { style, .. } => Some((s.id.clone(), style.clone())), _ => None, }) .collect(); diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index f3893077e..b10806e33 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -1,7 +1,7 @@ //! Shared styles and variables. use crate::props::{Effect, LayoutGrid}; -use crate::text::TextStyle; +use crate::text::{ParagraphStyle, TextStyle}; use figcraft_color::{Color, Paint}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; @@ -14,10 +14,22 @@ pub type ModeId = String; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum StyleKind { - Paint { paints: Vec }, - Text { style: TextStyle }, - Effect { effects: Vec }, - Grid { grids: Vec }, + Paint { + paints: Vec, + }, + Text { + style: TextStyle, + /// What the style sets at the paragraph and layer level (#1829); boxed to keep the enum + /// small. + #[serde(default, skip_serializing_if = "crate::node::is_default")] + paragraph: Box, + }, + Effect { + effects: Vec, + }, + Grid { + grids: Vec, + }, } impl StyleKind { diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 0c41d3c9a..76771c80c 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -291,6 +291,23 @@ pub struct TextStyle { pub style_id: Option, } +/// What a text style sets at the paragraph and layer level, beside its character style +/// ([`TextStyle`]): paragraph spacing and indent, the list style and the OpenType features +/// ("Supported in text styles": "Paragraph spacing and indentation", "Lists", "OpenType +/// features", Create and apply text styles). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct ParagraphStyle { + #[serde(skip_serializing_if = "crate::node::is_default")] + pub paragraph_spacing: f64, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub paragraph_indent: f64, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub list: ListStyle, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub features: std::collections::BTreeMap, +} + /// The font new text starts in. A build can name another (`FIGCRAFT_DEFAULT_FONT="Noto Sans Thai"`, /// for a distribution whose users mostly write a script Inter lacks); empty means Inter. pub const DEFAULT_FONT: &str = match option_env!("FIGCRAFT_DEFAULT_FONT") { @@ -843,6 +860,57 @@ impl TextData { (a..=b).all(|p| self.para_list(p).list == first.list && self.para_list(p).indent == first.indent).then_some(first) } + /// What a text style made from this text sets at the paragraph and layer level: the paragraph + /// spacing and indent, the list style (the one every paragraph shares; the base one when they + /// differ) and the OpenType features. + pub fn paragraph_style(&self) -> ParagraphStyle { + let list = match self.paragraphs.first() { + Some(first) if self.paragraphs.iter().all(|p| p.list == first.list) => first.list, + _ => self.list, + }; + ParagraphStyle { paragraph_spacing: self.paragraph_spacing, paragraph_indent: self.paragraph_indent, list, features: self.features.clone() } + } + + /// Take what text style `ps` sets at the paragraph and layer level: the spacing, indent and + /// features on the layer, the list style on every paragraph. + pub fn take_paragraph_style(&mut self, ps: &ParagraphStyle) { + let len = self.char_count(); + self.take_paragraph_style_in(0, len, ps); + } + + /// As [`take_paragraph_style`](Self::take_paragraph_style), with the list style going to the + /// paragraphs chars `start..end` touch only; the spacing, indent and features are the + /// layer's, as in Figma. + pub fn take_paragraph_style_in(&mut self, start: usize, end: usize, ps: &ParagraphStyle) { + self.paragraph_spacing = ps.paragraph_spacing; + self.paragraph_indent = ps.paragraph_indent; + self.features.clone_from(&ps.features); + self.set_para_lists(start, end, |pl| pl.list = ps.list); + } + + /// Follow text style `id`'s paragraph-level definition wherever chars link to it. + pub fn update_paragraph_style(&mut self, id: &str, ps: &ParagraphStyle) { + let linked: Vec<(usize, usize)> = + self.spans().into_iter().filter(|(_, _, s)| s.style_id.as_deref() == Some(id)).map(|(a, b, _)| (a, b)).collect(); + for (a, b) in linked { + self.take_paragraph_style_in(a, b, ps); + } + } + + /// Whether chars `start..end` show what text style `ps` sets at the paragraph and layer + /// level: the layer's spacing, indent and features, and their paragraphs' list style. + pub fn shows_paragraph_style_in(&self, start: usize, end: usize, ps: &ParagraphStyle) -> bool { + self.paragraph_spacing == ps.paragraph_spacing + && self.paragraph_indent == ps.paragraph_indent + && self.features == ps.features + && self.common_para_list(start, end).is_some_and(|pl| pl.list == ps.list) + } + + /// Whether the whole text shows what text style `ps` sets at the paragraph and layer level. + pub fn shows_paragraph_style(&self, ps: &ParagraphStyle) -> bool { + self.shows_paragraph_style_in(0, self.char_count(), ps) + } + /// Link chars `start..end` (all of them when the range is empty or the whole text) to text /// style `id`, defined as `def`. pub fn link_text_style(&mut self, start: usize, end: usize, def: &TextStyle, id: &str) { diff --git a/crates/engine/src/assets/style_vars.rs b/crates/engine/src/assets/style_vars.rs index 7fb30fd06..12db4eaa8 100644 --- a/crates/engine/src/assets/style_vars.rs +++ b/crates/engine/src/assets/style_vars.rs @@ -113,7 +113,7 @@ fn resolved(vars: &Variables, st: &Style, modes: &Modes) -> StyleKind { (StyleKind::Grid { grids }, Some((BoundList::Grids, i, field)), VariableValue::Number(x)) => { crate::cmd::lists::set_grid_field(grids, i, field, *x); } - (StyleKind::Text { style }, None, _) => set_text_field(style, key, &val), + (StyleKind::Text { style, .. }, None, _) => set_text_field(style, key, &val), _ => {} } } @@ -206,7 +206,7 @@ pub fn refresh(doc: &mut Document, before: Option<&Document>, only: Option<&BTre && was.is_none_or(|w| w.kind == n.kind) { for st in doc.styles.iter().filter(|s| !s.bound.is_empty()) { - if let StyleKind::Text { style } = kind_for(doc, st, id).as_ref() { + if let StyleKind::Text { style, .. } = kind_for(doc, st, id).as_ref() { t.update_text_style(&st.id, style); } } diff --git a/crates/engine/src/cmd/file/load.rs b/crates/engine/src/cmd/file/load.rs index 5033a057e..3e917d5dd 100644 --- a/crates/engine/src/cmd/file/load.rs +++ b/crates/engine/src/cmd/file/load.rs @@ -250,7 +250,7 @@ fn match_font_families(doc: &mut Document) { } // Shared text styles too, so they stay like the layers linked to them. for st in &mut doc.styles { - if let StyleKind::Text { style } = &mut st.kind { + if let StyleKind::Text { style, .. } = &mut st.kind { fix(style); } } @@ -294,7 +294,7 @@ fn platform_tracking(doc: &mut Document) { } } for st in &mut doc.styles { - if let StyleKind::Text { style } = &mut st.kind { + if let StyleKind::Text { style, .. } = &mut st.kind { track(style); } } @@ -365,7 +365,7 @@ fn sketch_line_heights(doc: &mut Document) { } } for st in &mut doc.styles { - if let StyleKind::Text { style } = &mut st.kind { + if let StyleKind::Text { style, .. } = &mut st.kind { set(style); } } diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index 99d4521e4..313062a68 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -3,6 +3,7 @@ use std::collections::BTreeMap; use figcraft_color::{Color, Paint, PaintKind}; +use figcraft_doc::ParagraphStyle; use figcraft_doc::{ Collection, CollectionId, Document, Effect, EffectKind, Mode, ModeId, Node, NodeId, NodeKind, Style, StyleId, StyleKind, TextData, TextStyle, Variable, VariableId, VariableType, VariableValue, Variables, trim_num, @@ -261,6 +262,41 @@ fn text_style_of(n: &Node, at: Option) -> Option { Some(at.map_or(&t.style, |a| t.style_at(a)).as_text_style()) } +/// What a text style definition from JSON sets at the paragraph level, in the `node.set` +/// spellings (`paragraphSpacing`, `paragraphIndent`, `listStyle`: none|bulleted|numbered, +/// `features` or `openTypeFeatures`: `{tag: bool}`); keys left out are the defaults. +fn paragraph_style_json(cmd: &str, v: &Value) -> Result { + let Value::Object(m) = v else { return Ok(ParagraphStyle::default()) }; + let mut ps = ParagraphStyle::default(); + let length = + |k: &str, x: &Value| f64_of(x).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 10_000.0)).ok_or_else(|| bad(cmd, format!("{k}: a number"))); + for (k, x) in m { + match k.as_str() { + "paragraphSpacing" | "paragraph_spacing" => ps.paragraph_spacing = length(k, x)?, + "paragraphIndent" | "paragraph_indent" => ps.paragraph_indent = length(k, x)?, + "listStyle" | "list" => ps.list = super::props::enum_of(x).ok_or_else(|| bad(cmd, "listStyle: none|bulleted|numbered"))?, + "features" | "openTypeFeatures" | "fontFeatures" | "textFeatures" => { + ps.features = super::props::features_of(cmd, x)?.into_iter().filter_map(|(tag, on)| Some((tag, on?))).collect(); + } + _ => {} + } + } + Ok(ps) +} + +/// What a layer's text sets at the paragraph level, the list style being that of the paragraphs +/// chars `range` touch when given (the layer's when they differ). +fn paragraph_style_of(n: &Node, range: Option<(usize, usize)>) -> Option { + let t = n.kind.text()?; + let mut ps = t.paragraph_style(); + if let Some((a, b)) = range + && let Some(pl) = t.common_para_list(a, b) + { + ps.list = pl.list; + } + Some(ps) +} + fn detach(s: &mut Session, p: &Value) -> Result { let cmd = "style.detach"; let which = req_string(cmd, p, "which")?; @@ -295,7 +331,7 @@ fn kind_from_node(t: &str, n: &Node) -> Option { Some(match t { "paint" | "color" | "fill" => StyleKind::Paint { paints: n.fills.clone() }, "stroke" => StyleKind::Paint { paints: n.strokes.clone() }, - "text" => StyleKind::Text { style: text_style_of(n, None)? }, + "text" => StyleKind::Text { style: text_style_of(n, None)?, paragraph: Box::new(paragraph_style_of(n, None)?) }, "effect" => StyleKind::Effect { effects: n.effects.clone() }, "grid" => StyleKind::Grid { grids: n.grids.clone() }, _ => return None, @@ -317,13 +353,17 @@ fn create(s: &mut Session, p: &Value) -> Result { let kind = if let Some(v) = get(p, "value") { match t.as_str() { "paint" | "color" | "fill" => StyleKind::Paint { paints: paints_of(v).ok_or_else(|| bad(cmd, "value: paints"))? }, - "text" => StyleKind::Text { style: text_style_json(cmd, v)? }, + "text" => StyleKind::Text { style: text_style_json(cmd, v)?, paragraph: Box::new(paragraph_style_json(cmd, v)?) }, "effect" => StyleKind::Effect { effects: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? }, "grid" => StyleKind::Grid { grids: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? }, _ => return Err(bad(cmd, "type: paint|text|effect|grid")), } - } else if let Some((tid, a, _)) = range { - StyleKind::Text { style: doc.node(tid).and_then(|n| text_style_of(n, Some(a))).ok_or_else(|| bad(cmd, "not a text layer"))? } + } else if let Some((tid, a, b)) = range { + let n = doc.node(tid).ok_or_else(|| bad(cmd, "not a text layer"))?; + StyleKind::Text { + style: text_style_of(n, Some(a)).ok_or_else(|| bad(cmd, "not a text layer"))?, + paragraph: Box::new(paragraph_style_of(n, Some((a, b))).ok_or_else(|| bad(cmd, "not a text layer"))?), + } } else { let n = from.and_then(|f| doc.node(f)).ok_or_else(|| bad(cmd, "pass `value` or select a layer to take it from"))?; kind_from_node(&t, n).ok_or_else(|| bad(cmd, "that layer has no such property"))? @@ -343,7 +383,7 @@ fn create(s: &mut Session, p: &Value) -> Result { }; doc.styles.push(Style { id: sid.clone(), name, description: string(p, "description").unwrap_or_default(), kind: kind.clone(), bound }); // The source layer (or the selected chars) becomes linked to the new style. - if let (Some((tid, a, b)), StyleKind::Text { style }) = (range, &kind) { + if let (Some((tid, a, b)), StyleKind::Text { style, .. }) = (range, &kind) { if let NodeKind::Text(td) = &mut doc.get_mut(tid)?.kind { td.link_text_style(a, b, style, &sid); } @@ -387,11 +427,13 @@ pub fn apply_style_ref(doc: &mut Document, id: NodeId, field: &str, sid: Option< n.grids = grids.clone(); n.styles.grid = Some(id.clone()); } - ("textStyle", Some(Style { kind: StyleKind::Text { style }, id, .. })) => { - // Every char takes the style's typography (their fills and links stay). + ("textStyle", Some(Style { kind: StyleKind::Text { style, paragraph }, id, .. })) => { + // Every char takes the style's typography (their fills and links stay), and the layer + // what the style sets at the paragraph level (#1829). if let NodeKind::Text(t) = &mut n.kind { let len = t.char_count(); t.link_text_style(0, len, style, id); + t.take_paragraph_style(paragraph); } n.styles.text = Some(id.clone()); } @@ -416,7 +458,7 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { let st = req_string(cmd, p, "style")?; let style = s.doc()?.style(&st).cloned().ok_or_else(|| bad(cmd, format!("no style `{st}`")))?; // Text styles go on chars: a range, the selected chars of the edited text, or whole layers. - if let StyleKind::Text { style: def } = &style.kind { + if let StyleKind::Text { style: def, paragraph } = &style.kind { // A text style is no property of other layers: they're left alone. let doc = s.doc()?; let targets: Vec = @@ -430,6 +472,8 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { Some((a, b)) => { if let NodeKind::Text(t) = &mut doc.get_mut(i)?.kind { t.link_text_style(a, b, def, &style.id); + // The list goes to the paragraphs touched; the rest is the layer's. + t.take_paragraph_style_in(a, b, paragraph); } } None => apply_style_ref(doc, i, "textStyle", Some(&style.id))?, @@ -506,7 +550,7 @@ fn update(s: &mut Session, p: &Value) -> Result { let cur = doc.styles.get(idx).map(|x| x.kind.type_name()).unwrap_or("paint"); Some(match cur { "paint" => StyleKind::Paint { paints: paints_of(v).ok_or_else(|| bad(cmd, "value: paints"))? }, - "text" => StyleKind::Text { style: text_style_json(cmd, v)? }, + "text" => StyleKind::Text { style: text_style_json(cmd, v)?, paragraph: Box::new(paragraph_style_json(cmd, v)?) }, "effect" => StyleKind::Effect { effects: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? }, _ => StyleKind::Grid { grids: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? }, }) @@ -651,22 +695,23 @@ fn rename_folder(s: &mut Session, p: &Value) -> Result { /// Re-apply style `sid` to every node (and text chars) linked to it. pub fn propagate_style(doc: &mut Document, sid: &str) { let text_def = match doc.style(sid).map(|s| &s.kind) { - Some(StyleKind::Text { style }) => Some(style.clone()), + Some(StyleKind::Text { style, paragraph }) => Some((style.clone(), paragraph.clone())), _ => None, }; let ids: Vec = doc.ids().collect(); for i in ids { let Some(n) = doc.node(i) else { continue }; - if let Some(def) = &text_def { + if let Some((def, paragraph)) = &text_def { if n.kind.text().is_some_and(|t| t.links_text_style(sid)) { let def = match doc.style(sid).map(|st| crate::style_vars::kind_for(doc, st, i).into_owned()) { - Some(StyleKind::Text { style }) => style, + Some(StyleKind::Text { style, .. }) => style, _ => def.clone(), }; if let Ok(n) = doc.get_mut(i) && let NodeKind::Text(t) = &mut n.kind { t.update_text_style(sid, &def); + t.update_paragraph_style(sid, paragraph); } } continue; @@ -695,7 +740,16 @@ pub fn sync_text_styles(doc: &mut Document, ids: &[NodeId]) { .styles .iter() .filter_map(|s| match &s.kind { - StyleKind::Text { style } => Some((s.id.clone(), style.clone())), + StyleKind::Text { style, .. } => Some((s.id.clone(), style.clone())), + _ => None, + }) + .collect(); + // What each text style sets at the paragraph level (#1829); no variable binds those. + let paras: BTreeMap<&str, &ParagraphStyle> = doc + .styles + .iter() + .filter_map(|s| match &s.kind { + StyleKind::Text { paragraph, .. } => Some((s.id.as_str(), paragraph.as_ref())), _ => None, }) .collect(); @@ -707,7 +761,7 @@ pub fn sync_text_styles(doc: &mut Document, ids: &[NodeId]) { // Styles with bound fields read as this layer sees them. let mut defs = std::borrow::Cow::Borrowed(&defs); for st in &varying { - if let StyleKind::Text { style } = crate::style_vars::kind_for(doc, st, id).into_owned() { + if let StyleKind::Text { style, .. } = crate::style_vars::kind_for(doc, st, id).into_owned() { defs.to_mut().insert(st.id.clone(), style); } } @@ -717,11 +771,23 @@ pub fn sync_text_styles(doc: &mut Document, ids: &[NodeId]) { && t2.text_style_link() == Some(None) && let Some(def) = defs.get(sid) && t2.spans().iter().all(|(_, _, s)| s.shows_text_style(def)) + && paras.get(sid.as_str()).is_some_and(|ps| t2.shows_paragraph_style(ps)) { let len = t2.char_count(); t2.restyle(0, len, |s| s.style_id = Some(sid.clone())); } t2.check_text_styles(|sid| defs.get(sid)); + // Chars whose paragraphs left their style's list, and every char of a layer whose + // paragraph spacing, indent or features were edited, unlink too (#1829). + let gone: Vec<(usize, usize)> = t2 + .spans() + .iter() + .filter(|(a, b, s)| s.style_id.as_deref().is_some_and(|sid| paras.get(sid).is_none_or(|ps| !t2.shows_paragraph_style_in(*a, *b, ps)))) + .map(|(a, b, _)| (*a, *b)) + .collect(); + for (a, b) in gone { + t2.unlink_text_style(a, b); + } let link = t2.text_style_link().flatten().map(str::to_string); if t2 != *t || link != n.styles.text { updates.push((id, link, t2)); diff --git a/crates/engine/src/content/synthetic_ds.rs b/crates/engine/src/content/synthetic_ds.rs index 74558567f..be5268355 100644 --- a/crates/engine/src/content/synthetic_ds.rs +++ b/crates/engine/src/content/synthetic_ds.rs @@ -154,7 +154,7 @@ fn auto_layout(doc: &mut Document, id: NodeId, mode: LayoutMode, gap: f64, pad: fn styled_text(doc: &mut Document, parent: NodeId, chars: &str, name: &str, style: &str) -> DocResult { let id = doc.create(parent, text(chars), name, 0.0, 0.0, 60.0, 16.0)?; let def = doc.styles.iter().find(|s| s.id == style).and_then(|s| match &s.kind { - StyleKind::Text { style } => Some(style.clone()), + StyleKind::Text { style, .. } => Some(style.clone()), _ => None, }); set_node(doc, id, |n| { @@ -242,7 +242,7 @@ fn foundations(doc: &mut Document) -> (CollectionId, ModeId, ModeId) { id: format!("ds:text:{name}"), name: name.into(), description: String::new(), - kind: StyleKind::Text { style }, + kind: StyleKind::Text { style, paragraph: Default::default() }, bound: Default::default(), }); } diff --git a/crates/engine/src/fig_export_assets_tests.rs b/crates/engine/src/fig_export_assets_tests.rs index f5b9f7758..ef88bc680 100644 --- a/crates/engine/src/fig_export_assets_tests.rs +++ b/crates/engine/src/fig_export_assets_tests.rs @@ -122,6 +122,27 @@ fn variables_styles_and_instance_sizes_come_back() { assert!(after.iter().any(|l| l.contains("\"In the dark\"") && l.contains("FFFFFF")), "{after:#?}"); } +/// #1829: a text style's paragraph spacing and indent and its list style go through a `.fig` +/// file with it (the style's sample layer carries them, as a Figma file's does). +#[test] +fn a_text_styles_paragraph_values_come_back_from_a_fig_file() { + let mut s = Session::new(); + let t = id(&run(&mut s, "text.create", json!({"text": "One\nTwo", "x": 0, "y": 0}))); + run(&mut s, "node.set", json!({"ids": [t], "paragraphSpacing": 12, "paragraphIndent": 8, "listStyle": "bulleted"})); + run(&mut s, "style.create", json!({"type": "text", "name": "Audit", "from": t})); + let path = std::env::temp_dir().join(format!("figcraft-fig-text-style-{}.fig", std::process::id())); + run(&mut s, "file.export", json!({"format": "fig", "path": path.to_string_lossy()})); + run(&mut s, "file.open", json!({"path": path.to_string_lossy()})); + let _ = std::fs::remove_file(&path); + let doc = s.doc().unwrap(); + let st = doc.styles.iter().find(|x| x.name == "Audit").unwrap(); + let figcraft_doc::StyleKind::Text { paragraph, .. } = &st.kind else { panic!("{:?}", st.kind) }; + assert_eq!((paragraph.paragraph_spacing, paragraph.paragraph_indent, paragraph.list), (12.0, 8.0, figcraft_doc::ListStyle::Bulleted)); + // The layer the style was made from is linked to it again, showing the same values. + let layer = doc.nodes().find(|n| n.kind.text().is_some()).unwrap(); + assert_eq!(layer.styles.text.as_deref(), Some(st.id.as_str())); +} + #[test] fn every_example_keeps_its_variables_and_styles() { let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples"); diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs index 484e7b88e..e0538db67 100644 --- a/crates/engine/src/fonts.rs +++ b/crates/engine/src/fonts.rs @@ -54,7 +54,7 @@ pub fn missing(doc: &Document) -> Vec { } } for st in &doc.styles { - if let StyleKind::Text { style } = &st.kind { + if let StyleKind::Text { style, .. } = &st.kind { add(&style.font_family, false); } if let Some(var) = st.bound.get("fontFamily") { diff --git a/crates/engine/src/fonts/tests.rs b/crates/engine/src/fonts/tests.rs index 5df9a8540..fc9713535 100644 --- a/crates/engine/src/fonts/tests.rs +++ b/crates/engine/src/fonts/tests.rs @@ -37,7 +37,10 @@ fn lists_families_of_layers_ranges_styles_and_variables_once_each() { id: "S:1".into(), name: "Heading".into(), description: String::new(), - kind: StyleKind::Text { style: TextStyle { font_family: "FigCraft Missing C".into(), ..TextStyle::default() } }, + kind: StyleKind::Text { + style: TextStyle { font_family: "FigCraft Missing C".into(), ..TextStyle::default() }, + paragraph: Default::default(), + }, bound: Default::default(), }); s.execute("variable.createCollection", &json!({"name": "Type", "modes": ["Light", "Dark"]})).unwrap(); diff --git a/crates/engine/src/sketch_import_tests.rs b/crates/engine/src/sketch_import_tests.rs index e05dade6c..b5ddab8b0 100644 --- a/crates/engine/src/sketch_import_tests.rs +++ b/crates/engine/src/sketch_import_tests.rs @@ -520,7 +520,7 @@ fn sketch_shared_text_styles_match_their_layers() { .styles .iter() .find_map(|st| match &st.kind { - figcraft_doc::StyleKind::Text { style } if st.name == "Body" => Some(style.clone()), + figcraft_doc::StyleKind::Text { style, .. } if st.name == "Body" => Some(style.clone()), _ => None, }) .unwrap(); diff --git a/crates/engine/src/styles_behaviour_tests.rs b/crates/engine/src/styles_behaviour_tests.rs index de9da5a5d..f1ebd96de 100644 --- a/crates/engine/src/styles_behaviour_tests.rs +++ b/crates/engine/src/styles_behaviour_tests.rs @@ -5,10 +5,11 @@ //! line; "docs silent" marks behaviour the documentation doesn't describe (pinned as it is). use serde_json::{Value, json}; +use std::collections::BTreeMap; use crate::Session; use crate::params::id_of; -use figcraft_doc::{Node, NodeId}; +use figcraft_doc::{ListStyle, Node, NodeId, ParagraphStyle, StyleKind, TextData}; fn id(v: Value) -> NodeId { id_of(&v["id"]).unwrap() @@ -355,6 +356,68 @@ fn text_styles_go_on_text_layers_and_chars() { assert_eq!(tx.styles.text, None, "mixed: the layer isn't linked as a whole"); } +#[test] +fn text_styles_carry_paragraph_spacing_indent_lists_and_features() { + // "Supported in text styles": "Paragraph spacing and indentation", "Lists" and "OpenType + // features", beside the character properties (Create and apply text styles). A style made + // from a text takes them, applying it sets them, editing one on a styled text unlinks it. + let mut s = Session::new(); + let td = |s: &Session, n: NodeId| -> TextData { node(s, n).kind.text().unwrap().clone() }; + let t = text(&mut s, 0.0); + let props = json!({"characters": "One\nTwo", "paragraphSpacing": 12, "paragraphIndent": 8, "listStyle": "bulleted", "features": {"tnum": true, "liga": false}}); + run(&mut s, "node.set", json!({"id": t, "props": props})); + let st = sid(run(&mut s, "style.create", json!({"type": "text", "name": "Audit", "from": t}))); + let listed = run(&mut s, "style.list", json!({"type": "text"})); + let audit = listed.as_array().unwrap().iter().find(|x| x["id"] == json!(st)).unwrap(); + assert_eq!( + audit["paragraph"], + json!({"paragraph_spacing": 12.0, "paragraph_indent": 8.0, "list": "bulleted", "features": {"liga": false, "tnum": true}}) + ); + // Applying it sets them on a plain text, every paragraph in the list; the layer links. + let t2 = text(&mut s, 300.0); + run(&mut s, "node.set", json!({"id": t2, "props": {"characters": "A\nB\nC"}})); + run(&mut s, "style.apply", json!({"ids": [t2], "style": st})); + let d = td(&s, t2); + assert_eq!((d.paragraph_spacing, d.paragraph_indent), (12.0, 8.0)); + assert!((0..3).all(|p| d.para_list(p).list == ListStyle::Bulleted), "{:?}", d.paragraphs); + assert_eq!(d.features, BTreeMap::from([("liga".to_string(), false), ("tnum".to_string(), true)])); + assert_eq!(node(&s, t2).styles.text.as_deref(), Some(st.as_str())); + // Redefining the style moves its layers' paragraph values with the rest. + let value = json!({"fontSize": 22, "paragraphSpacing": 20, "listStyle": "numbered", "openTypeFeatures": {"tnum": true}}); + run(&mut s, "style.update", json!({"style": st, "value": value})); + let d = td(&s, t2); + assert_eq!((d.paragraph_spacing, d.paragraph_indent, d.para_list(1).list), (20.0, 0.0, ListStyle::Numbered)); + assert_eq!(d.features, BTreeMap::from([("tnum".to_string(), true)])); + assert_eq!(font_size(&s, t2), 22.0); + assert_eq!(node(&s, t2).styles.text.as_deref(), Some(st.as_str()), "still linked after the update"); + // Editing a paragraph value the style sets unlinks the text, as editing its font does. + run(&mut s, "node.set", json!({"id": t2, "props": {"paragraphSpacing": 5}})); + assert_eq!(node(&s, t2).styles.text, None, "paragraph spacing edited: unlinked"); + assert!(td(&s, t2).spans().iter().all(|(_, _, x)| x.style_id.is_none())); + // On a range, the list goes to the paragraphs touched; the spacing is the layer's. Taking + // the paragraph out of the style's list unlinks its chars. + let t3 = text(&mut s, 600.0); + run(&mut s, "node.set", json!({"id": t3, "props": {"characters": "A\nB\nC"}})); + run(&mut s, "style.apply", json!({"ids": [t3], "style": st, "start": 0, "end": 1})); + let d = td(&s, t3); + assert_eq!((d.para_list(0).list, d.para_list(1).list, d.paragraph_spacing), (ListStyle::Numbered, ListStyle::None, 20.0)); + assert_eq!(d.style_at(0).style_id.as_deref(), Some(st.as_str())); + run(&mut s, "node.set", json!({"id": t3, "props": {"start": 0, "end": 1, "listStyle": "none"}})); + assert_eq!(td(&s, t3).style_at(0).style_id, None, "its paragraph left the style's list"); + // A definition given as a value takes the node.set spellings; junk is refused. + let value = json!({"fontSize": 11, "paragraphSpacing": 4, "listStyle": "bulleted", "features": {"liga": false}}); + let st2 = sid(run(&mut s, "style.create", json!({"type": "text", "name": "Note", "value": value}))); + let want = ParagraphStyle { + paragraph_spacing: 4.0, + list: ListStyle::Bulleted, + features: BTreeMap::from([("liga".to_string(), false)]), + ..Default::default() + }; + assert!(matches!(&s.doc().unwrap().style(&st2).unwrap().kind, StyleKind::Text { paragraph, .. } if **paragraph == want)); + assert!(s.execute("style.create", &json!({"type": "text", "name": "Bad", "value": {"listStyle": "dotted"}})).is_err()); + assert!(s.execute("style.create", &json!({"type": "text", "name": "Bad", "value": {"paragraphSpacing": "wide"}})).is_err()); +} + #[test] fn styles_are_organised_by_slash_names_order_and_duplicates() { // Documented: "/" in a name groups styles in folders; moving a style to another folder renames diff --git a/crates/engine/src/text_style_picker_tests.rs b/crates/engine/src/text_style_picker_tests.rs index 6ee8cc2c3..733845a52 100644 --- a/crates/engine/src/text_style_picker_tests.rs +++ b/crates/engine/src/text_style_picker_tests.rs @@ -86,7 +86,7 @@ fn editing_text_applies_to_the_selected_chars() { s.execute("text.style", &json!({"id": t, "start": 6, "end": 11, "fontSize": 20})).unwrap(); let c = s.execute("style.create", &json!({"type": "text", "name": "Type/Caption"})).unwrap()["id"].as_str().unwrap().to_string(); let style = s.doc().unwrap().style(&c).cloned().unwrap(); - let StyleKind::Text { style: def } = style.kind else { panic!("not a text style") }; + let StyleKind::Text { style: def, .. } = style.kind else { panic!("not a text style") }; assert_eq!(def.font_size, 20.0); assert_eq!(def.style_id, None, "definitions link to nothing"); assert_eq!(links(&s, t)[8].as_deref(), Some(c.as_str())); @@ -117,7 +117,7 @@ fn edits_unlink_and_style_edits_follow() { assert_eq!(layer_link(&s, a).as_deref(), Some(h.as_str())); // Redefining the style moves linked chars only. let mut def = match &s.doc().unwrap().style(&h).unwrap().kind { - StyleKind::Text { style } => style.clone(), + StyleKind::Text { style, .. } => style.clone(), _ => panic!("not a text style"), }; def.font_size = 40.0; diff --git a/crates/format/src/fig/assets_tests.rs b/crates/format/src/fig/assets_tests.rs index ce4192008..36c70a687 100644 --- a/crates/format/src/fig/assets_tests.rs +++ b/crates/format/src/fig/assets_tests.rs @@ -225,7 +225,7 @@ fn same_after_round_trip(doc: &Document, what: &str) -> Vec { normalize(&mut n, &|i| i); *paints = n.fills; } - StyleKind::Text { style } => { + StyleKind::Text { style, .. } => { if let NodeKind::Text(td) = &mut n.kind { td.style = style.clone(); } @@ -444,7 +444,7 @@ fn styled() -> Document { bound: Default::default(), }; doc.styles = vec![ - style("s-text", "Heading", StyleKind::Text { style: heading.clone() }), + style("s-text", "Heading", StyleKind::Text { style: heading.clone(), paragraph: Default::default() }), style("s-brand", "Brand/Primary", StyleKind::Paint { paints: vec![solid(0.25, 0.5, 1.0)] }), style("s-unused", "Brand/Unused", StyleKind::Paint { paints: vec![solid(1.0, 0.0, 0.0), solid(0.0, 1.0, 0.0)] }), style("s-shadow", "Elevation/1", StyleKind::Effect { effects: vec![shadow.clone()] }), diff --git a/crates/format/src/fig/export/assets.rs b/crates/format/src/fig/export/assets.rs index 3874b073c..f1df1874d 100644 --- a/crates/format/src/fig/export/assets.rs +++ b/crates/format/src/fig/export/assets.rs @@ -159,7 +159,7 @@ impl<'d> Exporter<'d> { Bound::default() } else { let mut sample = Node::new(NodeId(0), NodeKind::Rectangle, "", 0.0, 0.0); - if let StyleKind::Text { style } = &st.kind { + if let StyleKind::Text { style, .. } = &st.kind { sample.kind = NodeKind::Text(TextData { style: style.as_text_style(), ..TextData::new("Ag") }); } sample.bound.clone_from(&st.bound); @@ -179,8 +179,9 @@ impl<'d> Exporter<'d> { let list = grids.iter().enumerate().map(|(i, g)| layout_grid(g, i, &none)).collect(); f.extend([("fillPaints", arr(Vec::new())), ("layoutGrids", arr(list))]); } - StyleKind::Text { style } => { - let td = TextData { style: style.as_text_style(), ..TextData::new("Ag") }; + StyleKind::Text { style, paragraph } => { + let mut td = TextData { style: style.as_text_style(), ..TextData::new("Ag") }; + td.take_paragraph_style(paragraph); let fills = [Paint::solid(Color::BLACK)]; f.push(("fillPaints", self.paints(style.fills.as_deref().unwrap_or(&fills), true, &none))); self.text(&td, &fills, &mut f); diff --git a/crates/format/src/fig/text_skip_ink_tests.rs b/crates/format/src/fig/text_skip_ink_tests.rs index 485663847..b5285e1b6 100644 --- a/crates/format/src/fig/text_skip_ink_tests.rs +++ b/crates/format/src/fig/text_skip_ink_tests.rs @@ -113,7 +113,7 @@ fn local_skip_ink_override_preserves_shared_style_and_omitted_value_inherits() { let node = doc.nodes().find(|n| n.name == "Label").unwrap(); assert_eq!(node.kind.text().unwrap().style.look().skip_ink, expected); let linked = doc.style(node.styles.text.as_deref().unwrap()).unwrap(); - let StyleKind::Text { style } = &linked.kind else { panic!("linked definition must remain text") }; + let StyleKind::Text { style, .. } = &linked.kind else { panic!("linked definition must remain text") }; assert!(style.look().skip_ink, "local value must not alter definition"); } } @@ -223,7 +223,7 @@ fn export_writes_one_actual_node_value_and_complete_run_values_without_skip_only id: "body".into(), name: "Body".into(), description: String::new(), - kind: StyleKind::Text { style: shared }, + kind: StyleKind::Text { style: shared, paragraph: Default::default() }, bound: Default::default(), }); let id = linked_doc @@ -237,7 +237,7 @@ fn export_writes_one_actual_node_value_and_complete_run_values_without_skip_only let node = back.nodes().find(|n| n.name == "Label").unwrap(); assert!(!node.kind.text().unwrap().style.look().skip_ink); let linked = back.style(node.styles.text.as_deref().unwrap()).unwrap(); - let StyleKind::Text { style } = &linked.kind else { panic!("shared style link must remain text") }; + let StyleKind::Text { style, .. } = &linked.kind else { panic!("shared style link must remain text") }; assert!(style.look().skip_ink, "local false must not alter the shared definition"); } diff --git a/crates/format/src/fig/text_style_tests.rs b/crates/format/src/fig/text_style_tests.rs index 0f08459e6..dadd052c1 100644 --- a/crates/format/src/fig/text_style_tests.rs +++ b/crates/format/src/fig/text_style_tests.rs @@ -85,7 +85,7 @@ fn seen(doc: &Document) -> Seen { assert_eq!((text.style.font_family.as_str(), text.style.font_size), ("Inter", 20.0)); let linked = doc.style(label.styles.text.as_deref().unwrap()).unwrap(); assert_eq!(linked.name, "Shared"); - let StyleKind::Text { style } = &linked.kind else { panic!("the linked definition must stay a text style") }; + let StyleKind::Text { style, .. } = &linked.kind else { panic!("the linked definition must stay a text style") }; Seen { case: text.style.case, decoration: text.style.decoration, diff --git a/crates/format/src/penpot.rs b/crates/format/src/penpot.rs index 6cd8fe53c..a0e50a067 100644 --- a/crates/format/src/penpot.rs +++ b/crates/format/src/penpot.rs @@ -1531,7 +1531,7 @@ fn import_typography_styles( id: format!("penpot:text:{base}:{id}"), name: name.chars().take(256).collect(), description: "Imported from Penpot typography library".into(), - kind: StyleKind::Text { style }, + kind: StyleKind::Text { style, paragraph: Default::default() }, bound: BTreeMap::new(), }); } @@ -1596,7 +1596,7 @@ fn composite_token_style(token: &Value) -> Option { } } let parsed = text_style(&Value::Object(fields), &style); - Some(StyleKind::Text { style: parsed }) + Some(StyleKind::Text { style: parsed, paragraph: Default::default() }) } "boxShadow" | "shadow" => { let parts: Vec<&Value> = if let Some(items) = value.as_array() { items.iter().take(32).collect() } else { vec![value] }; @@ -5927,7 +5927,7 @@ mod tests { let (doc, warnings) = import(&archive_with_design_system(tokens, None, None, None)).unwrap(); assert_eq!(doc.styles.len(), 2); assert!(doc.styles.iter().any(|s| s.name == "Penpot tokens/Core/title" - && matches!(&s.kind, StyleKind::Text { style } + && matches!(&s.kind, StyleKind::Text { style, .. } if style.font_family == "Spicy Rice" && style.font_size == 24.0 && style.line_height == LineHeight::Pixels(32.0)))); assert!(doc.styles.iter().any(|s| s.name == "Penpot tokens/Core/elevation" && matches!(&s.kind, StyleKind::Effect { effects } @@ -5983,10 +5983,8 @@ mod tests { let (doc, warnings) = import(&archive_with_design_system(serde_json::json!({"sets":{}}), Some(typ), Some(color), None)).unwrap(); assert!(warnings.is_empty(), "{warnings:?}"); assert_eq!(doc.styles.len(), 2); - assert!( - doc.styles.iter().any(|s| s.name == "Brand/Heading" - && matches!(&s.kind, StyleKind::Text { style } if style.font_family == "Inter" && style.font_size == 24.0)) - ); + assert!(doc.styles.iter().any(|s| s.name == "Brand/Heading" + && matches!(&s.kind, StyleKind::Text { style, .. } if style.font_family == "Inter" && style.font_size == 24.0))); assert!(doc.styles.iter().any(|s| s.name == "Brand/Primary" && matches!(s.kind, StyleKind::Paint { .. }))); assert_eq!(doc.document_colors.len(), 1); } diff --git a/crates/format/src/rest.rs b/crates/format/src/rest.rs index 4604b73a6..eb9a10112 100644 --- a/crates/format/src/rest.rs +++ b/crates/format/src/rest.rs @@ -913,7 +913,11 @@ impl<'a> Ctx<'a> { Some("FILL") => StyleKind::Paint { paints: props::paints(self, node, "fills").unwrap_or_default() }, Some("EFFECT") => StyleKind::Effect { effects: props::effects(self, node) }, Some("GRID") => StyleKind::Grid { grids: props::layout_grids(node) }, - Some("TEXT") => StyleKind::Text { style: text::text_data(self, node).style.as_text_style() }, + Some("TEXT") => { + // The sample layer carries the paragraph-level values too (#1829). + let td = text::text_data(self, node); + StyleKind::Text { style: td.style.as_text_style(), paragraph: Box::new(td.paragraph_style()) } + } _ => continue, }; let name = text(d, "name").filter(|s| !s.is_empty()).map_or_else(|| format!("Style {id}"), |s| clip_str(s).to_string()); @@ -955,7 +959,9 @@ impl<'a> Ctx<'a> { "effect" | "effects" => (StyleKind::Effect { effects: n.effects.clone() }, &mut n.styles.effect), "grid" | "grids" => (StyleKind::Grid { grids: n.grids.clone() }, &mut n.styles.grid), "text" => match &n.kind { - NodeKind::Text(t) => (StyleKind::Text { style: t.style.as_text_style() }, &mut n.styles.text), + NodeKind::Text(t) => { + (StyleKind::Text { style: t.style.as_text_style(), paragraph: Box::new(t.paragraph_style()) }, &mut n.styles.text) + } _ => continue, }, _ => continue, diff --git a/crates/format/src/rest/tests.rs b/crates/format/src/rest/tests.rs index 050dcbaa6..43c9db6f2 100644 --- a/crates/format/src/rest/tests.rs +++ b/crates/format/src/rest/tests.rs @@ -530,7 +530,7 @@ fn shared_styles_link_with_metadata_names() { let fill = doc.style("S:1").unwrap(); assert_eq!((fill.name.as_str(), fill.description.as_str()), ("Brand/Primary", "Main")); assert!(matches!(&fill.kind, StyleKind::Paint { paints } if paints.len() == 1)); - assert!(matches!(&doc.style("S:2").unwrap().kind, StyleKind::Text { style } if style.font_size == 32.0)); + assert!(matches!(&doc.style("S:2").unwrap().kind, StyleKind::Text { style, .. } if style.font_size == 32.0)); assert!(matches!(&doc.style("Soft").unwrap().kind, StyleKind::Effect { effects } if effects.len() == 1)); let r = named(&doc, "R"); assert_eq!((r.styles.fill.as_deref(), r.styles.effect.as_deref()), (Some("S:1"), Some("S:4"))); diff --git a/crates/format/src/sketch/shared_tests.rs b/crates/format/src/sketch/shared_tests.rs index 240fdb7ea..4636c652e 100644 --- a/crates/format/src/sketch/shared_tests.rs +++ b/crates/format/src/sketch/shared_tests.rs @@ -91,7 +91,7 @@ fn text_style_links() { let sid = n.styles.text.clone().expect("text style linked"); let s = style(&doc, &sid); assert_eq!(s.name, "Heading/H1"); - let StyleKind::Text { style: ts } = &s.kind else { panic!("not a text style") }; + let StyleKind::Text { style: ts, .. } = &s.kind else { panic!("not a text style") }; assert_eq!((ts.font_family.as_str(), ts.font_style.as_str(), ts.font_size), ("Helvetica", "Bold", 32.0)); assert!(n.fills.iter().any(|p| matches!(p.kind, PaintKind::Solid { color } if color.r == 1.0 && color.g == 0.0))); } diff --git a/crates/ui-egui/src/widgets/style_picker.rs b/crates/ui-egui/src/widgets/style_picker.rs index eb2a2b44b..f616ddec7 100644 --- a/crates/ui-egui/src/widgets/style_picker.rs +++ b/crates/ui-egui/src/widgets/style_picker.rs @@ -10,7 +10,9 @@ use egui::{Align2, Color32, CornerRadius, Id, Rect, Response, Sense, Stroke, Ui, pos2, vec2}; use figcraft_color::{Paint, PaintKind}; -use figcraft_doc::{Document, GridKind, Node, NodeId, PublishedLibrary, Style, StyleKind, TextStyle, VariableType, trim_num}; +use figcraft_doc::{ + Document, GridKind, ListStyle, Node, NodeId, ParagraphStyle, PublishedLibrary, Style, StyleKind, TextStyle, VariableType, trim_num, +}; use figcraft_engine::library; use serde_json::{Value, json}; @@ -354,7 +356,7 @@ fn by_folder<'a>(styles: impl Iterator) -> Vec<(&'a str, Vec<& /// A text style's definition, if `s` is one. fn text_def(s: &Style) -> Option<&TextStyle> { match &s.kind { - StyleKind::Text { style } => Some(style), + StyleKind::Text { style, .. } => Some(style), _ => None, } } @@ -974,6 +976,39 @@ fn text_properties(ui: &mut Ui, doc: &Document, style: &Style, def: &TextStyle, if let Some(sr) = style_resp { font_style_menu(id, def, &sr, out); } + if let StyleKind::Text { paragraph, .. } = &style.kind { + paragraph_rows(ui, paragraph); + } +} + +/// What a text style sets at the paragraph level (#1829), when it sets anything: the spacing, +/// indent, list style and OpenType features (as `+tnum -liga`). A layer's Type settings edit +/// them; Update from selection takes them into the style. +fn paragraph_rows(ui: &mut Ui, ps: &ParagraphStyle) { + let mut rows: Vec = Vec::new(); + if ps.paragraph_spacing != 0.0 { + let label = tr("Paragraph spacing"); + rows.push(format!("{label}: {}", trim_num(ps.paragraph_spacing))); + } + if ps.paragraph_indent != 0.0 { + let label = tr("Paragraph indent"); + rows.push(format!("{label}: {}", trim_num(ps.paragraph_indent))); + } + match ps.list { + ListStyle::None => {} + ListStyle::Bulleted => rows.push(tr("Bulleted list").into()), + ListStyle::Numbered => rows.push(tr("Numbered list").into()), + } + if !ps.features.is_empty() { + rows.push(ps.features.iter().map(|(tag, on)| format!("{}{tag}", if *on { '+' } else { '-' })).collect::>().join(" ")); + } + if rows.is_empty() { + return; + } + ui.add_space(4.0); + for row in rows { + caption(ui, &row); + } } /// Editing one style: name, description, its values (paints open the colour picker; text styles @@ -1038,7 +1073,7 @@ fn editor(ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut Frame) { binding_cell(ui, doc, &style, &format!("layoutGrids/{i}/size"), figcraft_doc::VariableType::Number, out); } } - StyleKind::Text { style: def } => text_properties(ui, doc, &style, def, out), + StyleKind::Text { style: def, .. } => text_properties(ui, doc, &style, def, out), } }); ui.add_space(8.0); diff --git a/crates/ui-egui/src/widgets/style_picker/tests.rs b/crates/ui-egui/src/widgets/style_picker/tests.rs index fa996374c..0f3392e48 100644 --- a/crates/ui-egui/src/widgets/style_picker/tests.rs +++ b/crates/ui-egui/src/widgets/style_picker/tests.rs @@ -29,7 +29,7 @@ fn names_split_into_folders() { for s in [StyleSlot::Fill, StyleSlot::Stroke, StyleSlot::Effect, StyleSlot::Grid, StyleSlot::Text] { assert_eq!(StyleSlot::parse(s.key()), Some(s)); } - assert!(StyleSlot::Text.holds(&StyleKind::Text { style: Default::default() }) && StyleSlot::Text.has_grid()); + assert!(StyleSlot::Text.holds(&StyleKind::Text { style: Default::default(), paragraph: Default::default() }) && StyleSlot::Text.has_grid()); } fn frame(ctx: &egui::Context, app: &mut FigApp, t: &mut f64) { @@ -255,7 +255,7 @@ fn commit_text_field(def: &TextStyle, field: &str, typed: &str) -> Option = texts.iter().filter(|(_, p)| p.x > left && p.x < left + W).cloned().collect(); @@ -434,7 +434,7 @@ fn text_style_editor_binds_variables_from_its_fields() { assert_eq!(bound_to(&u, field).as_deref(), Some(name)); } let doc = u.app.session.doc().unwrap(); - let Some(StyleKind::Text { style: def }) = doc.style("Body").map(|s| &s.kind) else { panic!("a text style") }; + let Some(StyleKind::Text { style: def, .. }) = doc.style("Body").map(|s| &s.kind) else { panic!("a text style") }; assert_eq!((def.font_size, def.font_style.as_str()), (18.0, "Bold"), "the style takes the variables' values"); assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); } diff --git a/docs/components-parity.md b/docs/components-parity.md index 0e3248248..7fce63d14 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -1,6 +1,6 @@ # Components, variants, styles and variables parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (text styles carry paragraph spacing and indent, the list style and the OpenType features, as the Supported in text styles table of Create and apply text styles says (#1829); Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300 @@ -59,7 +59,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Feature | Status | Notes | |---|---|---| -| Paint, text, effect, grid styles; style picker; text styles on character ranges | done | behaviour suite: `styles_behaviour_tests.rs` | +| Paint, text, effect, grid styles; style picker; text styles on character ranges | done | behaviour suite: `styles_behaviour_tests.rs`; a text style carries paragraph spacing and indent, the list style and the OpenType features beside the character properties (`paragraph` next to `style` in `style.list`): applied with it, moved by `style.update`, unlinking when edited, kept by `.fig` files (#1829) | | Editing a style updates its layers; detach and delete keep values; editing a styled property unlinks that style | done | an edited fill/stroke/effect/grid list or text chars unlink; applying a style replaces variables bound to what it sets; text styles on text layers only | | Organising styles: folders by `/` names, order, duplicate, rename folder, Local styles list | done | `style.move` (into a folder renames), `style.duplicate` (right below), `style.renameFolder`; the page's Local styles list by type and folder (drag to reorder, right-click) | | Variables inside styles (paint, effect, grid and text styles; each user resolves them in its own modes) | done | `style.bind`, `style_vars_tests`; the editor binds paint colours, shadow colours, blur radii, grid sizes and a text style's font, font style, size, line height and letter spacing, the rest through the command | @@ -78,6 +78,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | M3, #1829: text styles carry paragraph spacing and indent, the list style and the OpenType features ("Supported in text styles": "Paragraph spacing and indentation", "Lists", "OpenType features", Create and apply text styles) beside the character properties: `style.create` takes them from the layer (or the selected paragraphs' list) or from a `value` (`paragraphSpacing`, `paragraphIndent`, `listStyle`, `features`), `style.apply` sets them (on a range, the list goes to the paragraphs touched; the rest is the layer's, as in Figma), `style.update` moves them on every linked layer, editing one on a styled text unlinks it as editing its font does, the style editor lists them, and a `.fig` file keeps them with the style (its sample layer). Grade unchanged. | | 2026-11-01 | patch | M3: Delete on a layer inside an instance hides it (an override) instead of being refused ("You can't delete a layer or object from an instance. If you try, Figma will only toggle the layer's visibility instead of removing it", Guide to auto layout); `edit.delete` reports those ids under `hidden`, the structure and the main component stay as they were, one undo step; deleting the instance itself still removes it. Found by a documentation audit of the auto layout page. Test: `components_behaviour_tests::deleting_a_layer_inside_an_instance_hides_it_instead` (the refusal row for it is gone from `what_instances_cannot_override_is_refused`). Numbers unchanged | | 2026-10-31 | minor | M3, gap 10: library analytics cover styles and variables. A file reports how many of its layers use each of a library's styles and variables, and Show usage (`library.usage`) lists them beside the components, by a Type choice, with their layers and files. Layers inside instances count for their component. Insertions, detachments and modes aren't counted for them yet. Numbers unchanged. Tests: `library_assets_tests`. | | 2026-10-31 | minor | M6: as the help center describes code syntax, Dev Mode's SwiftUI and Compose code now writes a bound variable's iOS or Android code syntax in place of its value, as CSS already wrote the Web one: fills, strokes and their weight, sizes, corner radius, gaps, padding, opacity, the font family and size and the characters. A variable with no code syntax for the platform keeps the value, since a name made up here wouldn't exist in the code it lands in. ArkUI and Flutter have no code syntax platform and are unchanged. Test: `variables_behaviour_tests::swiftui_and_compose_name_variables_by_their_code_syntax`. Numbers unchanged. Grade unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index b5e49e749..8344f4972 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -312,10 +312,16 @@ Or in a project's `.mcp.json`: `style.detach {which: "text", start?, end?}` unlinks them (values stay). A layer's `styles.text` is the style all its chars share (none when they differ); `get_node` lists the linked chars as `textStyleRanges` (`[start, end, style id]`). + A text style also carries paragraph spacing and indent, the list style and the OpenType features + (`paragraph` beside `style` in `style.list`): applying it sets them (on a range, the list goes to + the paragraphs touched; the rest is the layer's), `style.update` moves them too, and a `.fig` + file keeps them with the style. Changing what a text style sets (font, size, line height, letter spacing, decoration, case, axis - values) on linked chars unlinks them; `style.update` moves every linked char. `style.create - {type: "text", name}` takes the selected chars while editing; a `value` may use the `node.set` - spellings (`{"fontSize": 32, "lineHeight": "120%", "letterSpacing": "2%"}`). + values; at the paragraph level the spacing, indent, list and features) on linked chars unlinks + them; `style.update` moves every linked char. `style.create {type: "text", name}` takes the + selected chars while editing; a `value` may use the `node.set` spellings (`{"fontSize": 32, + "lineHeight": "120%", "letterSpacing": "2%", "paragraphSpacing": 12, "listStyle": "bulleted", + "features": {"liga": false}}`). - **Styles stay true to their layers**: editing a styled fill, stroke, effect or layout grid list (`node.set`, `fill.set`, `fill.add`, a bound variable…) unlinks that style (`styles.fill` etc. go `null`), so a later `style.update` doesn't overwrite the edit; `style.apply` replaces variables From 5bbab0fa181329d01973c489bc6fb01b4f863ab4 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 04:23:05 +0000 Subject: [PATCH 09/18] fix(pdf): drop an oversized hyperlink before it is copied once per line Every linked line copied the destination string, and its length was only checked when the annotation was written, so one multi-megabyte link on text wrapping onto many lines was copied that many times first. The length is checked where the link areas are collected. --- crates/pdf/src/export_tests.rs | 17 +++++++++++++++++ crates/pdf/src/text.rs | 4 +++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/pdf/src/export_tests.rs b/crates/pdf/src/export_tests.rs index 259e58327..d41f11379 100644 --- a/crates/pdf/src/export_tests.rs +++ b/crates/pdf/src/export_tests.rs @@ -284,3 +284,20 @@ fn depth_samples() { std::fs::write(format!("{stem}-{name}.png"), figcraft_render::encode_png(&pix).unwrap()).unwrap(); } } + +#[test] +fn an_oversized_link_is_dropped_before_it_is_copied_per_line() { + // One link far past MAX_URL on text that wraps onto many lines: no annotation, and the + // destination is never copied once per line on the way to being refused. + let mut d = Document::new("long link"); + let p = d.first_page().unwrap_or(NodeId(0)); + let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 300.0, 4000.0).unwrap(); + let words = "word ".repeat(2_000); + let mut td = TextData::new(words.trim_end()); + td.style.font_size = 20.0; + td.style.link = Some("https://example.com/".to_string() + &"a".repeat(1 << 20)); + let t = d.create(f, NodeKind::Text(td), "T", 10.0, 10.0, 280.0, 3980.0).unwrap(); + d.get_mut(t).unwrap().fills = vec![Paint::solid(Color::BLACK)]; + let pdf = export_pdf(&d, &[f], &PdfOptions::default()).unwrap(); + assert_eq!(text(&pdf).matches("/Subtype/Link").count(), 0); +} diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index abbb65b34..3b3917689 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -111,7 +111,9 @@ fn record_links(ex: &mut Exporter, layout: &TextLayout, gxf: Affine) { let glyphs = layout.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]); for g in glyphs.iter().map(Some).chain([None]) { let link = g.and_then(|g| { - let url = layout.styles.get(g.style)?.link.as_deref().filter(|u| !u.trim().is_empty())?; + // Checked here, not when the annotation is written: every linked line copies + // the destination, so one oversized link must not be copied at all. + let url = layout.styles.get(g.style)?.link.as_deref().map(str::trim).filter(|u| !u.is_empty() && u.len() <= crate::MAX_URL)?; let (a, b) = (g.x, g.x + g.advance); (a.is_finite() && b.is_finite()).then(|| (url, a.min(b), a.max(b))) }); From 453e0b9af88196d90efcc38ee48d15948d9f51d6 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Tue, 3 Nov 2026 23:48:36 -0500 Subject: [PATCH 10/18] M8: a whole-turn hue is 0, a negative hue counts back, and calc() is refused (tests) Review asks on #1773: `css_hue_wraps_a_whole_turn_and_calc_is_not_a_number` checks that 360, 1turn, 400grad, 2 pi rad and -1turn all read as hue 0, that -90deg, -0.25turn, -100grad and 630 all read as 270, the same in oklch() and lch(), and that `calc(NaN)`, `calc(infinity)`, `calc(1)` and `calc(1turn)` are refused as non-numbers rather than read as 0 or clamped. Co-Authored-By: Claude Fable 5.1 --- crates/color/src/tests.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/crates/color/src/tests.rs b/crates/color/src/tests.rs index 41e7acaf7..d4bb69676 100644 --- a/crates/color/src/tests.rs +++ b/crates/color/src/tests.rs @@ -271,3 +271,26 @@ fn to_css_sanitizes_an_unclamped_alpha() { } assert_eq!(Color { r: 1.0, g: 0.0, b: 0.0, a: f32::INFINITY }.to_css(), "#FF0000"); } + +#[test] +fn css_hue_wraps_a_whole_turn_and_calc_is_not_a_number() { + // A hue is an angle (CSS Color 4 §4.3): a whole turn (360, 1turn, 400grad, 2 pi rad) is 0 and a + // negative hue counts back from it, in hsl() and in the polar spaces alike. `calc()` is not + // read, so `calc(NaN)` and `calc(infinity)` are refused like any other non-number rather than + // read as 0 or clamped. + let red = Color::parse_css("hsl(0 100% 50%)").unwrap().to_rgba8(); + for s in ["hsl(360 100% 50%)", "hsl(1turn 100% 50%)", "hsl(400grad 100% 50%)", "hsl(6.2831855rad 100% 50%)", "hsl(-1turn 100% 50%)"] { + assert_eq!(Color::parse_css(s).map(|c| c.to_rgba8()), Some(red), "{s}"); + } + let violet = Color::parse_css("hsl(270 100% 50%)").unwrap().to_rgba8(); + for s in ["hsl(-90deg 100% 50%)", "hsl(-0.25turn 100% 50%)", "hsl(-100grad 100% 50%)", "hsl(630 100% 50%)"] { + assert_eq!(Color::parse_css(s).map(|c| c.to_rgba8()), Some(violet), "{s}"); + } + assert_eq!(Color::parse_css("oklch(0.5 0.1 1turn)").map(|c| c.to_rgba8()), Color::parse_css("oklch(0.5 0.1 0)").map(|c| c.to_rgba8())); + assert_eq!(Color::parse_css("lch(50 50 400grad)").map(|c| c.to_rgba8()), Color::parse_css("lch(50 50 0)").map(|c| c.to_rgba8())); + assert_eq!(Color::parse_css("oklch(0.5 0.1 -90deg)").map(|c| c.to_rgba8()), Color::parse_css("oklch(0.5 0.1 270)").map(|c| c.to_rgba8())); + for s in ["hsl(calc(NaN) 100% 50%)", "rgb(calc(infinity) 0 0)", "rgb(0 0 0 / calc(1))", "oklch(0.5 0.1 calc(20deg))", "hsl(calc(1turn) 100% 50%)"] + { + assert_eq!(Color::parse_css(s), None, "{s}"); + } +} From deee3d8acf47e971b023ebbff8a8abc73ccac821 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 4 Nov 2026 04:50:11 +0000 Subject: [PATCH 11/18] fix(format): bound the characters Sketch text runs write in all --- crates/format/src/sketch/text.rs | 9 +++++++++ crates/format/src/sketch/text_tests.rs | 10 ++++++++++ 2 files changed, 19 insertions(+) diff --git a/crates/format/src/sketch/text.rs b/crates/format/src/sketch/text.rs index 74cfa21c9..4a7fe18ad 100644 --- a/crates/format/src/sketch/text.rs +++ b/crates/format/src/sketch/text.rs @@ -14,6 +14,9 @@ use super::tree::{Cx, color}; const MAX_CHARS: usize = 1 << 20; /// Most runs read from one text layer. const MAX_RUNS: usize = 10_000; +/// Most characters that styled runs may write in all, overlaps counted again (a run's range is +/// rewritten in full, even where later runs overwrite it). +const MAX_RUN_WORK: usize = 16 * MAX_CHARS; /// Fill `node` (a TEXT node) from text layer `l`. pub(crate) fn node(cx: &mut Cx, l: &Value, node: &mut JsonMap) { @@ -80,10 +83,16 @@ pub(crate) fn node(cx: &mut Cx, l: &Value, node: &mut JsonMap) { // Runs that differ from the first: a style id per UTF-16 unit. let mut ids = vec![0u64; units]; let mut table = JsonMap::new(); + let mut work = 0usize; for (start, end, s) in &styles { if *s == base { continue; } + work = work.saturating_add(end.saturating_sub(*start)); + if work > MAX_RUN_WORK { + cx.warn("text whose styled runs overlap a great deal keeps the first runs' styles"); + break; + } let id = table.len() as u64 + 1; table.insert(id.to_string(), s.clone()); for slot in ids.get_mut(*start..*end).into_iter().flatten() { diff --git a/crates/format/src/sketch/text_tests.rs b/crates/format/src/sketch/text_tests.rs index 0e8a4a279..f50bf74df 100644 --- a/crates/format/src/sketch/text_tests.rs +++ b/crates/format/src/sketch/text_tests.rs @@ -271,3 +271,13 @@ fn later_runs_dont_inherit_the_first_runs_settings() { assert_eq!(second.line_height, figcraft_doc::LineHeight::Auto, "{second:?}"); assert!(second.variations.is_empty(), "{second:?}"); } + +#[test] +fn runs_that_overlap_a_great_deal_stop_being_applied() { + // 17 runs over the whole of a text of 2^20 characters: 17 × 2^20 writes without the limit. + let n = 1 << 20; + let mut runs = vec![(0, 1, "Helvetica", 10.0, BLACK)]; + runs.extend((0..17).map(|i| (0, n, "Helvetica", 11.0 + f64::from(i), BLACK))); + let (_, warnings) = open(vec![text("Long", 0.0, 0.0, 200.0, 20.0, &"a".repeat(n), &runs)]); + assert!(warnings.iter().any(|w| w.contains("overlap a great deal")), "{warnings:?}"); +} From 95d71a6825477e13a74deddc1f4401ef0a2c0f03 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Wed, 4 Nov 2026 03:59:46 -0500 Subject: [PATCH 12/18] M4: justified Chinese, Japanese and Korean text spreads the room between its characters (#1970) Cause: justified lines only widened their spaces, so a line of ideographs, written without any, kept its natural width and its right edge never reached the box's. Fix: the room on a line (but a paragraph's last) also goes into the gaps next to an ideograph, kana, Hangul or full-width form wherever a line could break there (`breaks::justify_between`, beside `break_between`): each is a word of its own, as for line breaking, and no gap opens before a closing mark or after an opening one. Spaces widen as before; mixed text widens both. Co-Authored-By: Claude Fable 5.1 --- crates/text/src/breaks.rs | 9 ++++++++ crates/text/src/layout.rs | 14 ++++++++---- crates/text/src/tests.rs | 45 +++++++++++++++++++++++++++++++++++++++ docs/target-app-parity.md | 5 +++-- 4 files changed, 67 insertions(+), 6 deletions(-) diff --git a/crates/text/src/breaks.rs b/crates/text/src/breaks.rs index 6d094e9dc..f0352f852 100644 --- a/crates/text/src/breaks.rs +++ b/crates/text/src/breaks.rs @@ -79,6 +79,15 @@ pub(crate) fn is_space(c: char) -> bool { class(c) == Class::Space } +/// Whether justified text may widen the gap between `a` and the `b` after it (a space widens on +/// its own): next to an ideograph, kana, Hangul or full-width form, which are written without +/// spaces, so each is a word of its own, as for line breaking, wherever a line may break there (no +/// gap before a closing mark or after an opening one). +pub(crate) fn justify_between(a: char, b: char) -> bool { + let (ca, cb) = (class(a), class(b)); + (ca == Class::Ideo || cb == Class::Ideo) && ca != Class::Space && break_between(a, b) +} + /// Whether `c` is in a script written without spaces between words that the line segmenter has /// word data for (UAX #14 class SA): Thai, Lao, Burmese (with the Myanmar extensions) and Khmer. fn writes_no_spaces(c: char) -> bool { diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index dd728916b..1cb5170fa 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -1259,8 +1259,13 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) let hang_r = items.get(vis_end.wrapping_sub(1)).filter(|it| hang_end && !rtl_line && vis_end > *s + 1 && closes(it.ch)).map_or(0.0, |it| it.adv); let gstart = out.glyphs.len(); - // Justify: extra space per gap. - let gaps = items.get(*s..vis_end).unwrap_or(&[]).iter().filter(|it| it.stretches()).count(); + // Justify: extra space per gap (a space, or between ideographs, whose text has no spaces). + let widens = |k: usize| { + items.get(k).is_some_and(|it| { + it.stretches() || (k + 1 < vis_end && items.get(k + 1).is_some_and(|next| crate::breaks::justify_between(it.ch, next.ch))) + }) + }; + let gaps = (*s..vis_end).filter(|k| widens(*k)).count(); let justify_extra = match (text.align_h, wrap) { (TextAlignH::Justified, Some(maxw)) if !para_end && !line_break_after(&items, *e) && gaps > 0 => { ((maxw - line_indent + hang_l + hang_r - width) / gaps as f64).max(0.0) @@ -1349,13 +1354,14 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) } else { (0..line_items.len()).collect() }; - for it in order.iter().filter_map(|k| line_items.get(*k)) { + for k in order.iter().copied() { + let Some(it) = line_items.get(k) else { continue }; if is_line_break(it.ch) { continue; } out.glyphs.extend(glyph_at(&styles, it, pen, baseline)); pen += it.adv; - if it.stretches() { + if widens(*s + k) { pen += justify_extra; } } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 6d341419c..e59438c83 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -590,6 +590,51 @@ fn lower_case_writes_final_sigma() { assert_eq!(gids(&t).iter().map(|g| g.0).collect::>(), want.iter().map(|g| g.0).collect::>(), "split runs"); } +/// Chinese is written without spaces: justified, the room goes between its characters (each a word +/// of its own, as for line breaking), so the last one reaches the right edge too, and none opens +/// where a line couldn't break (before a closing mark, after an opening one). "Justify aligns the +/// first word in each line of text to the left-most edge, and the last to the right-most edge." +#[test] +fn justified_ideographs_spread_the_room_between_characters() { + if !have_fonts() { + return; + } + let lay_out = |s: &str, align, w| { + let mut t = TextData::new(s); + t.style.font_size = 20.0; + t.align_h = align; + t.auto_resize = figcraft_doc::TextAutoResize::Height; + layout(&t, w, None) + }; + // Box width cut inside char `fit`, so the first line is chars 0..fit; how many gaps lie before + // each of its chars. + for (s, fit, gaps_before, why) in [ + ("一二三四五六七八九十", 6, [0, 1, 2, 3, 4, 5], "one gap between each pair of ideographs"), + ("一二三。四五六七八九", 6, [0, 1, 2, 2, 3, 4], "none before the full stop, which never starts a line"), + ("一二三「四五六七八九", 6, [0, 1, 2, 3, 3, 4], "none after the opening bracket, which never ends a line"), + ] { + let natural = lay_out(s, TextAlignH::Left, None); + let (fits, next) = (natural.caret(fit).0, natural.caret(fit + 1).0); + assert!(next > fits, "{why}: chars need advances"); + let w = fits + (next - fits) / 4.0; + let left = lay_out(s, TextAlignH::Left, Some(w)); + let just = lay_out(s, TextAlignH::Justified, Some(w)); + assert!(just.lines.len() >= 2 && just.lines[0].char_end == fit, "{why}: {:?}", just.lines); + let extra = (w - left.lines[0].width) / f64::from(gaps_before[fit - 1]); + assert!(extra > 0.0, "{why}"); + assert_eq!(left.glyphs.len(), just.glyphs.len()); + for (l, j) in left.glyphs.iter().zip(&just.glyphs).filter(|(g, _)| g.char_end <= fit) { + let want = f64::from(gaps_before[l.char_start]) * extra; + assert!((j.x - l.x - want).abs() < 1e-6, "{why}: char {} moved {} (want {want})", l.char_start, j.x - l.x); + } + let last = just.glyphs.iter().rfind(|g| g.char_end <= fit).unwrap(); + assert!((last.x + last.advance - w).abs() < 1e-6, "{why}: the last char ends at the right edge, not {}", last.x + last.advance); + // The last line is left-aligned, as always. + let (l2, j2) = (left.glyphs.iter().find(|g| g.char_start == fit).unwrap(), just.glyphs.iter().find(|g| g.char_start == fit).unwrap()); + assert!((j2.x - l2.x).abs() < 1e-6, "{why}: the last line is not justified"); + } +} + /// Thai is written without spaces between words; U+200B ZERO WIDTH SPACE marks where a line may /// break. ภาษา|ไทย|เขียน|ติดกัน ("Thai is written joined up") ไม่มี|ช่องว่าง|ระหว่าง|คำ ("with no /// spaces between words"), with one real space between the two phrases. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 75a426231..572bb7ffa 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (bulleted lists use the same bullet at every level of indentation, as the help center says, instead of a white bullet and a square at the deeper levels; typing `1)` and a space at the start of a paragraph starts a numbered list, as `1.` does, both being the help center's creation characters; a link on text is underlined by default, as the help center says, and Underline takes the underline off while the link stays; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Thai, Lao, Khmer and Burmese lines break between words (#1219); MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719)) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** patch (justified Chinese, Japanese and Korean text, written without spaces, spreads each line's room between its characters so its right edge aligns (#1970); bulleted lists use the same bullet at every level of indentation, as the help center says, instead of a white bullet and a square at the deeper levels; typing `1)` and a space at the start of a paragraph starts a numbered list, as `1.` does, both being the help center's creation characters; a link on text is underlined by default, as the help center says, and Underline takes the underline off while the link stays; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Thai, Lao, Khmer and Burmese lines break between words (#1219); MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719)) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -297,7 +297,7 @@ Hours in this narrative are the builders' estimates; the tables above supersede break inside Latin words, kinsoku to JIS X 4051 for closing and opening punctuation, small kana (halfwidth too), the prolonged sound mark, the iteration marks, the (semi-)voiced sound marks and the halfwidth parentheses); Thai, Lao, Khmer and Burmese lines break between words (ICU4X's word - dictionaries, its LSTM models in the browser), never after a leading vowel or before a mark; a style's metrics from its own font, not a fallback; a letter and + dictionaries, its LSTM models in the browser), never after a leading vowel or before a mark; justified text widens each line's spaces but the last line's, and in Chinese, Japanese and Korean text, which has none, the gaps between its characters wherever a line could break (#1970); a style's metrics from its own font, not a fallback; a letter and its combining marks (Vietnamese typed decomposed, say) from one font that has them all; carets, Delete and Backspace by grapheme cluster; double-click and word moves by script run; IME composition with CJK; vertical trim (cap height to baseline). The tail suite (`crates/text/src/tail_tests.rs`, @@ -648,6 +648,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-11-04 | patch | M4, #1970: justified Chinese, Japanese and Korean text, written without spaces between words, aligns its right edge: the room on each line but a paragraph's last goes between its characters, each a word of its own as for line breaking ("Justify aligns the first word in each line of text to the left-most edge, and the last to the right-most edge"), wherever a line could break there, so none opens before a full stop or closing bracket, nor after an opening one. Latin text widens its spaces as before; mixed text widens both. `figcraft_text::breaks::justify_between`, beside `break_between`, says where. Evidence: `tests::justified_ideographs_spread_the_room_between_characters` (fails on main). | | 2026-11-01 | patch | M4: bulleted lists draw the same bullet at every level of indentation ("Figma bullets will keep the same styling at all levels of indentation", Create bulleted and numbered lists); the deeper levels drew a white bullet and a small square, pinned as silent before. Numbered levels (1., a., i.) are unchanged. Found by a documentation audit of the text rules. Test: `tail_tests::list_numbering_by_level` (the bullets row). Numbers unchanged | | 2026-11-01 | patch | M4: typing `1)` followed by a space at the start of a plain paragraph starts a numbered list, as `1.` already did ("Enter one of the creation characters below followed by a Space: 1. 1)", Create bulleted and numbered lists). Found by a documentation audit of the text rules. Test: `text_tail_tests::typing_a_creation_character_and_a_space_starts_a_list`. Numbers unchanged | | 2026-11-01 | patch | M4: a link made with `text.link` (Cmd/Ctrl+K, Shift+Cmd/Ctrl+U) underlines the linked text unless it has a decoration of its own ("Links are styled with an underline by default. Use the keyboard shortcut to remove the underline", Add links to text); Underline (Cmd/Ctrl+U) takes the underline off and the link stays. Found by a documentation audit of the text rules. Test: `text_behaviour_tests::a_link_is_underlined_by_default_and_underline_takes_it_off`. Numbers unchanged | From 44f9eccb71bbd337e29faf0047ced5ff9adbb30f Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Wed, 4 Nov 2026 04:33:28 -0500 Subject: [PATCH 13/18] M4: a justify gap stays out of grapheme clusters and opens beside a right-to-left run (#1970) Review: a combining mark shaped as a glyph of its own (U+20DD on an ideograph, say) got a gap between it and its base; a gap after the last logical glyph of a right-to-left run opened after that glyph's visual place, inside the word, instead of between the run and the ideograph after it. A gap now needs the next item to start a grapheme cluster, and `visual_gaps` opens each logical gap at its visual boundary: between logical neighbours where they are, between two runs where they meet. Two more rows in the test. Co-Authored-By: Claude Fable 5.1 --- crates/text/src/layout.rs | 60 ++++++++++++++++++++++++++++++++++++--- crates/text/src/tests.rs | 14 +++++---- 2 files changed, 65 insertions(+), 9 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 1cb5170fa..59e0d5877 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -297,6 +297,48 @@ pub(crate) fn is_rtl_char(c: char) -> bool { matches!(c as u32, 0x0590..=0x08FF | 0xFB1D..=0xFDFF | 0xFE70..=0xFEFF | 0x10800..=0x10FFF | 0x1E800..=0x1EFFF | 0x200F | 0x202B | 0x202E | 0x2067) } +/// Which visual positions of `order` (the logical index at each) a justify gap follows. A gap +/// after logical item k (`after[k]`) opens between logically neighbouring glyphs where they are, +/// or where two runs meet (a right-to-left run is laid out reversed) between the runs, so a gap +/// beside a Hebrew word in Chinese text opens beside the word, not inside it; each opens once. +/// From item `trailing` on (a line's trailing spaces) a gap opens after the item itself. +fn visual_gaps(order: &[usize], after: &[bool], trailing: usize) -> Vec { + let run_min = visual_run_starts(order); + let mut applied = vec![false; after.len()]; + let gap_after = |k: usize| after.get(k).copied().unwrap_or(false); + order + .iter() + .enumerate() + .map(|(i, &k)| { + if k >= trailing { + return gap_after(k); + } + let at = match order.get(i + 1) { + Some(&n) if n == k + 1 || k == n + 1 => Some(k.min(n)), + Some(_) => run_min.get(i).copied().max(run_min.get(i + 1).copied()).and_then(|m| m.checked_sub(1)), + None => None, + }; + at.is_some_and(|g| gap_after(g) && applied.get_mut(g).is_some_and(|a| !std::mem::replace(a, true))) + }) + .collect() +} + +/// For each position of `order` (the logical index at each visual position), the smallest +/// logical index of its run: the visually consecutive glyphs whose logical indices step one way. +fn visual_run_starts(order: &[usize]) -> Vec { + let mut out = Vec::with_capacity(order.len()); + let mut start = 0; + for i in 1..=order.len() { + let joined = i < order.len() && order.get(i - 1).zip(order.get(i)).is_some_and(|(&p, &k)| p + 1 == k || k + 1 == p); + if !joined { + let m = order.get(start..i).and_then(|run| run.iter().copied().min()).unwrap_or(0); + out.extend(std::iter::repeat_n(m, i - start)); + start = i; + } + } + out +} + /// Visual order of a line's items from their levels (rule L2: from the highest level down to the /// lowest odd one, reverse every run at that level or above). pub(crate) fn visual_order(levels: &[u8]) -> Vec { @@ -1259,10 +1301,17 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) let hang_r = items.get(vis_end.wrapping_sub(1)).filter(|it| hang_end && !rtl_line && vis_end > *s + 1 && closes(it.ch)).map_or(0.0, |it| it.adv); let gstart = out.glyphs.len(); - // Justify: extra space per gap (a space, or between ideographs, whose text has no spaces). + // Justify: extra space per gap (a space, or between ideographs, whose text has no spaces; + // never inside a grapheme cluster, so a mark stays on its base). let widens = |k: usize| { items.get(k).is_some_and(|it| { - it.stretches() || (k + 1 < vis_end && items.get(k + 1).is_some_and(|next| crate::breaks::justify_between(it.ch, next.ch))) + it.stretches() + || (k + 1 < vis_end + && items.get(k + 1).is_some_and(|next| { + next.char_start > it.char_start + && cluster_start.get(next.char_start).copied().unwrap_or(true) + && crate::breaks::justify_between(it.ch, next.ch) + })) }) }; let gaps = (*s..vis_end).filter(|k| widens(*k)).count(); @@ -1354,14 +1403,17 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) } else { (0..line_items.len()).collect() }; - for k in order.iter().copied() { + // Each gap opens at its visual boundary; trailing spaces widen where they are. + let after: Vec = (0..line_items.len()).map(|k| widens(*s + k)).collect(); + let gaps_at = visual_gaps(&order, &after, vis_end - *s); + for (i, &k) in order.iter().enumerate() { let Some(it) = line_items.get(k) else { continue }; if is_line_break(it.ch) { continue; } out.glyphs.extend(glyph_at(&styles, it, pen, baseline)); pen += it.adv; - if widens(*s + k) { + if gaps_at.get(i).copied().unwrap_or(false) { pen += justify_extra; } } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index e59438c83..8082ffed3 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -608,11 +608,14 @@ fn justified_ideographs_spread_the_room_between_characters() { }; // Box width cut inside char `fit`, so the first line is chars 0..fit; how many gaps lie before // each of its chars. - for (s, fit, gaps_before, why) in [ - ("一二三四五六七八九十", 6, [0, 1, 2, 3, 4, 5], "one gap between each pair of ideographs"), - ("一二三。四五六七八九", 6, [0, 1, 2, 2, 3, 4], "none before the full stop, which never starts a line"), - ("一二三「四五六七八九", 6, [0, 1, 2, 3, 3, 4], "none after the opening bracket, which never ends a line"), - ] { + let rows: [(&str, usize, &[i32], &str); 5] = [ + ("一二三四五六七八九十", 6, &[0, 1, 2, 3, 4, 5], "one gap between each pair of ideographs"), + ("一二三。四五六七八九", 6, &[0, 1, 2, 2, 3, 4], "none before the full stop, which never starts a line"), + ("一二三「四五六七八九", 6, &[0, 1, 2, 3, 3, 4], "none after the opening bracket, which never ends a line"), + ("一二三\u{20DD}四五六七八九", 6, &[0, 1, 2, 2, 3, 4], "none inside a grapheme cluster: a combining mark stays on its base"), + ("漢אב字一二三", 4, &[0, 1, 1, 2], "beside a right-to-left word (laid out ב then א), not inside it"), + ]; + for (s, fit, gaps_before, why) in rows { let natural = lay_out(s, TextAlignH::Left, None); let (fits, next) = (natural.caret(fit).0, natural.caret(fit + 1).0); assert!(next > fits, "{why}: chars need advances"); @@ -621,6 +624,7 @@ fn justified_ideographs_spread_the_room_between_characters() { let just = lay_out(s, TextAlignH::Justified, Some(w)); assert!(just.lines.len() >= 2 && just.lines[0].char_end == fit, "{why}: {:?}", just.lines); let extra = (w - left.lines[0].width) / f64::from(gaps_before[fit - 1]); + assert_eq!(left.lines[0].char_end, fit, "{why}: {:?}", left.lines); assert!(extra > 0.0, "{why}"); assert_eq!(left.glyphs.len(), just.glyphs.len()); for (l, j) in left.glyphs.iter().zip(&just.glyphs).filter(|(g, _)| g.char_end <= fit) { From 4cd01f0a045965a885d064c7e2ef17713c6b0f0e Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Wed, 4 Nov 2026 06:01:39 -0500 Subject: [PATCH 14/18] M4: a justify gap is reordered with the line's runs, so a nested embedding gets its gaps at its edges (#1970) Cause: `visual_gaps` mapped a gap at a run boundary to the gap before the later run's first item, which for a run nested inside another (a Chinese word in a Hebrew phrase in Chinese text, made with embedding marks) is a gap inside the embedding: one boundary took it, another got none, and the line came up a gap short of the box's edge. Fix: each gap is reordered with the line as a glyph of no width at the lower of its two neighbours' levels (rule N1 for a space between them), through the same `visual_order`: items and gaps keep alternating and each gap lands once at the boundary it belongs to. Test row with explicit embedding marks (red before: "char 8 moved 2 (want 1)"). Co-Authored-By: Claude Fable 5.1 --- crates/text/src/layout.rs | 69 +++++++++++++++------------------------ crates/text/src/tests.rs | 8 ++++- docs/target-app-parity.md | 4 +-- 3 files changed, 36 insertions(+), 45 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 59e0d5877..e28682d74 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -297,48 +297,37 @@ pub(crate) fn is_rtl_char(c: char) -> bool { matches!(c as u32, 0x0590..=0x08FF | 0xFB1D..=0xFDFF | 0xFE70..=0xFEFF | 0x10800..=0x10FFF | 0x1E800..=0x1EFFF | 0x200F | 0x202B | 0x202E | 0x2067) } -/// Which visual positions of `order` (the logical index at each) a justify gap follows. A gap -/// after logical item k (`after[k]`) opens between logically neighbouring glyphs where they are, -/// or where two runs meet (a right-to-left run is laid out reversed) between the runs, so a gap -/// beside a Hebrew word in Chinese text opens beside the word, not inside it; each opens once. -/// From item `trailing` on (a line's trailing spaces) a gap opens after the item itself. -fn visual_gaps(order: &[usize], after: &[bool], trailing: usize) -> Vec { - let run_min = visual_run_starts(order); - let mut applied = vec![false; after.len()]; +/// Which visual positions of a line's items (in `visual_order(levels)`) a justify gap follows. The +/// gap after logical item k (`after[k]`) is reordered like a glyph of no width at the lower of its +/// two neighbours' levels, as rule N1 resolves a space between them: between logically neighbouring +/// glyphs it opens where they are, and where runs meet it opens beside the run it belongs to, so a +/// gap beside a Hebrew word in Chinese text opens beside the word, not inside it, and so does one +/// beside a Chinese word within that Hebrew. From item `trailing` on (a line's trailing spaces) a +/// gap opens after the item itself. +fn visual_gaps(levels: &[u8], after: &[bool], trailing: usize) -> Vec { let gap_after = |k: usize| after.get(k).copied().unwrap_or(false); - order - .iter() - .enumerate() - .map(|(i, &k)| { + let level = |k: usize| levels.get(k).copied().unwrap_or(0); + // Items at the even indices, the gap after item k at 2k + 1 (a gap's level never exceeds its + // neighbours', so every run reversed starts and ends on an item and the two keep alternating). + let mut both = Vec::with_capacity(after.len() * 2); + for k in 0..after.len() { + if k > 0 { + both.push(level(k).min(level(k - 1))); + } + both.push(level(k)); + } + let order = visual_order(&both); + (0..after.len()) + .map(|i| { + let k = order.get(2 * i).copied().unwrap_or(0) / 2; if k >= trailing { return gap_after(k); } - let at = match order.get(i + 1) { - Some(&n) if n == k + 1 || k == n + 1 => Some(k.min(n)), - Some(_) => run_min.get(i).copied().max(run_min.get(i + 1).copied()).and_then(|m| m.checked_sub(1)), - None => None, - }; - at.is_some_and(|g| gap_after(g) && applied.get_mut(g).is_some_and(|a| !std::mem::replace(a, true))) + order.get(2 * i + 1).is_some_and(|&g| g % 2 == 1 && g / 2 < trailing && gap_after(g / 2)) }) .collect() } -/// For each position of `order` (the logical index at each visual position), the smallest -/// logical index of its run: the visually consecutive glyphs whose logical indices step one way. -fn visual_run_starts(order: &[usize]) -> Vec { - let mut out = Vec::with_capacity(order.len()); - let mut start = 0; - for i in 1..=order.len() { - let joined = i < order.len() && order.get(i - 1).zip(order.get(i)).is_some_and(|(&p, &k)| p + 1 == k || k + 1 == p); - if !joined { - let m = order.get(start..i).and_then(|run| run.iter().copied().min()).unwrap_or(0); - out.extend(std::iter::repeat_n(m, i - start)); - start = i; - } - } - out -} - /// Visual order of a line's items from their levels (rule L2: from the highest level down to the /// lowest odd one, reverse every run at that level or above). pub(crate) fn visual_order(levels: &[u8]) -> Vec { @@ -1396,16 +1385,12 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) // Right-to-left runs are placed in visual order; trailing spaces take the paragraph's // direction (rule L1). let line_items = items.get(*s..*e).unwrap_or(&[]); - let order: Vec = if bidi.is_some() { - let para_level = u8::from(rtl_line); - let levels: Vec = line_items.iter().enumerate().map(|(k, it)| if *s + k >= vis_end { para_level } else { it.level }).collect(); - visual_order(&levels) - } else { - (0..line_items.len()).collect() - }; + let para_level = u8::from(rtl_line); + let levels: Vec = line_items.iter().enumerate().map(|(k, it)| if *s + k >= vis_end { para_level } else { it.level }).collect(); + let order = visual_order(&levels); // Each gap opens at its visual boundary; trailing spaces widen where they are. let after: Vec = (0..line_items.len()).map(|k| widens(*s + k)).collect(); - let gaps_at = visual_gaps(&order, &after, vis_end - *s); + let gaps_at = visual_gaps(&levels, &after, vis_end - *s); for (i, &k) in order.iter().enumerate() { let Some(it) = line_items.get(k) else { continue }; if is_line_break(it.ch) { diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 8082ffed3..cbd1e9d4c 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -608,12 +608,18 @@ fn justified_ideographs_spread_the_room_between_characters() { }; // Box width cut inside char `fit`, so the first line is chars 0..fit; how many gaps lie before // each of its chars. - let rows: [(&str, usize, &[i32], &str); 5] = [ + let rows: [(&str, usize, &[i32], &str); 6] = [ ("一二三四五六七八九十", 6, &[0, 1, 2, 3, 4, 5], "one gap between each pair of ideographs"), ("一二三。四五六七八九", 6, &[0, 1, 2, 2, 3, 4], "none before the full stop, which never starts a line"), ("一二三「四五六七八九", 6, &[0, 1, 2, 3, 3, 4], "none after the opening bracket, which never ends a line"), ("一二三\u{20DD}四五六七八九", 6, &[0, 1, 2, 2, 3, 4], "none inside a grapheme cluster: a combining mark stays on its base"), ("漢אב字一二三", 4, &[0, 1, 1, 2], "beside a right-to-left word (laid out ב then א), not inside it"), + ( + "漢\u{202B}אב字一גד\u{202C}文一二三", + 10, + &[0, 1, 4, 4, 2, 3, 1, 1, 1, 5], + "beside a Chinese word embedded in a Hebrew phrase embedded in Chinese (laid out 漢 ד ג 字 一 ב א 文, the embedding marks of no width), and beside the phrase", + ), ]; for (s, fit, gaps_before, why) in rows { let natural = lay_out(s, TextAlignH::Left, None); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 572bb7ffa..c7cef88e1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -297,7 +297,7 @@ Hours in this narrative are the builders' estimates; the tables above supersede break inside Latin words, kinsoku to JIS X 4051 for closing and opening punctuation, small kana (halfwidth too), the prolonged sound mark, the iteration marks, the (semi-)voiced sound marks and the halfwidth parentheses); Thai, Lao, Khmer and Burmese lines break between words (ICU4X's word - dictionaries, its LSTM models in the browser), never after a leading vowel or before a mark; justified text widens each line's spaces but the last line's, and in Chinese, Japanese and Korean text, which has none, the gaps between its characters wherever a line could break (#1970); a style's metrics from its own font, not a fallback; a letter and + dictionaries, its LSTM models in the browser), never after a leading vowel or before a mark; justified text widens each line's spaces but the last line's, and in Chinese, Japanese and Korean text, which has none, the gaps between its characters wherever a line could break, a gap beside a right-to-left word opening beside the word (#1970); a style's metrics from its own font, not a fallback; a letter and its combining marks (Vietnamese typed decomposed, say) from one font that has them all; carets, Delete and Backspace by grapheme cluster; double-click and word moves by script run; IME composition with CJK; vertical trim (cap height to baseline). The tail suite (`crates/text/src/tail_tests.rs`, @@ -648,7 +648,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| -| 2026-11-04 | patch | M4, #1970: justified Chinese, Japanese and Korean text, written without spaces between words, aligns its right edge: the room on each line but a paragraph's last goes between its characters, each a word of its own as for line breaking ("Justify aligns the first word in each line of text to the left-most edge, and the last to the right-most edge"), wherever a line could break there, so none opens before a full stop or closing bracket, nor after an opening one. Latin text widens its spaces as before; mixed text widens both. `figcraft_text::breaks::justify_between`, beside `break_between`, says where. Evidence: `tests::justified_ideographs_spread_the_room_between_characters` (fails on main). | +| 2026-11-04 | patch | M4, #1970: justified Chinese, Japanese and Korean text, written without spaces between words, aligns its right edge: the room on each line but a paragraph's last goes between its characters, each a word of its own as for line breaking ("Justify aligns the first word in each line of text to the left-most edge, and the last to the right-most edge"), wherever a line could break there, so none opens before a full stop or closing bracket, nor after an opening one. Latin text widens its spaces as before; mixed text widens both. A gap is reordered with the line's runs as a space of no width would be, so one beside a Hebrew word in Chinese text, or beside a Chinese word within that Hebrew, opens beside the word, not inside it. `figcraft_text::breaks::justify_between`, beside `break_between`, says where. Evidence: `tests::justified_ideographs_spread_the_room_between_characters` (fails on main). | | 2026-11-01 | patch | M4: bulleted lists draw the same bullet at every level of indentation ("Figma bullets will keep the same styling at all levels of indentation", Create bulleted and numbered lists); the deeper levels drew a white bullet and a small square, pinned as silent before. Numbered levels (1., a., i.) are unchanged. Found by a documentation audit of the text rules. Test: `tail_tests::list_numbering_by_level` (the bullets row). Numbers unchanged | | 2026-11-01 | patch | M4: typing `1)` followed by a space at the start of a plain paragraph starts a numbered list, as `1.` already did ("Enter one of the creation characters below followed by a Space: 1. 1)", Create bulleted and numbered lists). Found by a documentation audit of the text rules. Test: `text_tail_tests::typing_a_creation_character_and_a_space_starts_a_list`. Numbers unchanged | | 2026-11-01 | patch | M4: a link made with `text.link` (Cmd/Ctrl+K, Shift+Cmd/Ctrl+U) underlines the linked text unless it has a decoration of its own ("Links are styled with an underline by default. Use the keyboard shortcut to remove the underline", Add links to text); Underline (Cmd/Ctrl+U) takes the underline off and the link stays. Found by a documentation audit of the text rules. Test: `text_behaviour_tests::a_link_is_underlined_by_default_and_underline_takes_it_off`. Numbers unchanged | From 0d1801db937678756d09b2f2e72990e47b7fcb3e Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Thu, 5 Nov 2026 17:21:18 -0500 Subject: [PATCH 15/18] text styles: applying one to the whole layer reaches a trailing empty paragraph take_paragraph_style went through para_span, which leaves out the empty paragraph after a final newline, so its list style stayed as it was and the layer did not fold back to one list. The whole-layer path now addresses every paragraph by index; para_span's selection semantics are unchanged. Co-Authored-By: Claude Fable 5.1 --- crates/doc/src/text.rs | 2246 ++++++++++++++++++++-------------------- 1 file changed, 1137 insertions(+), 1109 deletions(-) diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index d674adb6d..f4d087589 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -1,1109 +1,1137 @@ -//! Text node data: characters plus a base style and styled runs. - -use figcraft_color::{Color, Paint}; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -#[serde(tag = "unit", content = "value", rename_all = "camelCase")] -#[derive(Default)] -pub enum LineHeight { - #[default] - Auto, - Pixels(f64), - Percent(f64), -} - -impl LineHeight { - /// Resolved line height in px for `font_size` (`auto` uses the font's natural ratio). - pub fn resolve(self, font_size: f64, natural_ratio: f64) -> f64 { - match self { - LineHeight::Auto => font_size * natural_ratio, - LineHeight::Pixels(v) => v.max(0.0), - LineHeight::Percent(p) => font_size * p.max(0.0) / 100.0, - } - } - pub fn label(self) -> String { - match self { - LineHeight::Auto => "Auto".into(), - LineHeight::Pixels(v) => trim_num(v), - LineHeight::Percent(p) => format!("{}%", trim_num(p)), - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -#[serde(tag = "unit", content = "value", rename_all = "camelCase")] -pub enum LetterSpacing { - Pixels(f64), - Percent(f64), -} - -impl Default for LetterSpacing { - fn default() -> Self { - LetterSpacing::Percent(0.0) - } -} - -impl LetterSpacing { - pub fn resolve(self, font_size: f64) -> f64 { - match self { - LetterSpacing::Pixels(v) => v, - LetterSpacing::Percent(p) => font_size * p / 100.0, - } - } - pub fn label(self) -> String { - match self { - LetterSpacing::Pixels(v) => trim_num(v), - LetterSpacing::Percent(p) => format!("{}%", trim_num(p)), - } - } -} - -pub fn trim_num(v: f64) -> String { - if v.abs() >= 9.0e18 { - return format!("{v}"); - } - let r = (v * 100.0).round() / 100.0; - if r.fract().abs() < 1e-9 { format!("{}", r as i64) } else { format!("{r}") } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum TextDecoration { - #[default] - None, - Underline, - Strikethrough, -} - -/// How an underline or strikethrough is drawn. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum DecorationStyle { - #[default] - Solid, - Dotted, - Wavy, -} - -/// A decoration length: `Auto` (the font's own value), pixels, or a percentage of the font size. -pub type DecorationLength = LineHeight; - -/// How a decoration line looks; the default is the font's own solid line in the text's colour. -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)] -#[serde(default)] -pub struct DecorationLook { - pub style: DecorationStyle, - pub thickness: DecorationLength, - /// Underlines: from the baseline down to the line's top edge (negative: above the baseline). - pub offset: DecorationLength, - /// Underlines break where descenders cross them. - pub skip_ink: bool, - /// The line's own colour (None: the text's fills). - pub color: Option, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum TextCase { - #[default] - Original, - Upper, - Lower, - Title, - SmallCaps, -} - -impl TextCase { - pub fn apply(self, s: &str) -> String { - self.apply_after(s, None) - } - - /// Apply the case to `s`, which follows `prev` in the text (so title case only capitalises - /// the first letter of each word even when a word spans several styled runs). An apostrophe - /// as `prev` is taken to continue a word. - pub fn apply_after(self, s: &str, prev: Option) -> String { - self.apply_impl(s, prev, prev.is_some_and(is_word_char)) - } - - /// Like [`apply_after`](Self::apply_after), also given the char `before` `prev`: an apostrophe - /// only continues a word when a letter or digit precedes it. - pub fn apply_between(self, s: &str, before: Option, prev: Option) -> String { - let continues = prev.is_some_and(|c| is_word_char(c) && (c.is_alphanumeric() || before.is_some_and(char::is_alphanumeric))); - self.apply_impl(s, prev, continues) - } - - /// `in_word` says whether `prev` continues a word (rather than a new word starting here). - fn apply_impl(self, s: &str, prev: Option, in_word: bool) -> String { - match self { - TextCase::Original | TextCase::SmallCaps => s.to_string(), - TextCase::Upper => s.to_uppercase(), - TextCase::Lower => s.to_lowercase(), - TextCase::Title => { - let mut out = String::with_capacity(s.len()); - let mut start = !in_word; - let mut last = prev; - for ch in s.chars() { - if start && ch.is_alphanumeric() { - out.extend(ch.to_uppercase()); - start = false; - } else { - out.push(ch); - // An apostrophe only continues a word right after a letter or digit. - let opening_apostrophe = - matches!(ch, '\'' | '\u{2019}') && !last.is_some_and(|c| c.is_alphanumeric() || is_combining_mark(c)); - // A combining mark attaches to the char before it and never changes word state. - if !is_combining_mark(ch) { - start = !is_word_char(ch) || opening_apostrophe; - } - } - last = Some(ch); - } - out - } - } - } - - /// Whether the char at a position preceded by `prev` starts a word (for title case). - pub fn starts_word(prev: Option) -> bool { - prev.is_none_or(|c| !is_word_char(c)) - } -} - -/// Letters, digits, combining marks and apostrophes continue a word (so "don't" stays "Don't" -/// and decomposed accents stay inside their word). -fn is_word_char(c: char) -> bool { - c.is_alphanumeric() || is_combining_mark(c) || matches!(c, '\'' | '\u{2019}') -} - -/// Combining marks such as the U+0301 accent in decomposed text: non-alphanumeric chars that -/// extend the grapheme of the char before them. -fn is_combining_mark(c: char) -> bool { - use unicode_segmentation::UnicodeSegmentation; - if c.is_ascii() || c.is_alphanumeric() { - return false; - } - let mut buf = [0u8; 8]; - buf[0] = b'a'; - let end = 1 + c.encode_utf8(&mut buf[1..]).len(); - std::str::from_utf8(&buf[..end]).is_ok_and(|s| s.graphemes(true).count() == 1) -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum TextAlignH { - #[default] - Left, - Center, - Right, - Justified, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum TextAlignV { - #[default] - Top, - Middle, - Bottom, -} - -/// How a text box resizes as its content changes. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum TextAutoResize { - /// Auto width: one line per paragraph, box grows both ways. - #[default] - WidthAndHeight, - /// Fixed width, height grows. - Height, - /// Fixed size. - None, - /// Fixed size, truncate with an ellipsis. - Truncate, -} - -/// Vertical trim: what bounds a text box vertically. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum LeadingTrim { - /// The full line height of the first and last lines. - #[default] - None, - /// From the first line's cap height to the last line's baseline. - CapHeight, -} - -/// Where lines break when text wraps to more than one line ("Wrap style controls where lines -/// break when text wraps to more than one line", Explore text properties). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum WrapStyle { - /// "Figma fits as many words as possible on each line before wrapping to the next." - #[default] - Auto, - /// "Figma distributes text as evenly as possible across every line, so each line is close to - /// the same length." - Balance, - /// "Figma keeps each line full, but adjusts the last four lines to avoid an orphan -- a single - /// word stranded alone on the last line." - Pretty, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum ListStyle { - #[default] - None, - Bulleted, - Numbered, -} - -/// Deepest list nesting (levels 0..=MAX_LIST_INDENT). -pub const MAX_LIST_INDENT: u8 = 8; - -/// One paragraph's list settings. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(default)] -pub struct ParaList { - pub list: ListStyle, - /// Nesting level (0 = outermost). A plain paragraph is 0, except one that left its list by - /// Backspace or Delete at its start ([`TextData::unlist_keeping_levels`]): it keeps the level - /// it had and starts where that level's item text does ("delete the counter, but keep the - /// same level of indentation"). - pub indent: u8, - /// A numbered list that starts counting at this paragraph counts from `start_offset + 1` - /// (items that go on counting ignore it; other paragraphs are always 0). - #[serde(skip_serializing_if = "crate::node::is_default")] - pub start_offset: u32, -} - -/// Character style. In runs, every field is an override; `None` means "inherit the base". -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct TextStyle { - pub font_family: String, - /// The style name, e.g. "Regular", "Bold", "Semi Bold Italic". - pub font_style: String, - pub font_size: f64, - pub line_height: LineHeight, - pub letter_spacing: LetterSpacing, - pub decoration: TextDecoration, - /// How the decoration looks (None: the default look; boxed to keep styles small). - #[serde(skip_serializing_if = "Option::is_none")] - pub decoration_look: Option>, - pub case: TextCase, - /// Run fills (None = node fills). - pub fills: Option>, - /// Hyperlink. - pub link: Option, - /// Variable font axis values in user space, keyed by 4-char OpenType axis tag (e.g. `wght`: - /// 550, `wdth`: 85). Empty = what the style name implies; axes the face lacks are ignored. - #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")] - pub variations: std::collections::BTreeMap, - /// The text style (shared style id) these chars are linked to. A layer's chars can link to - /// different styles; editing what a style sets unlinks the chars edited. - #[serde(skip_serializing_if = "Option::is_none")] - pub style_id: Option, -} - -/// What a text style sets at the paragraph and layer level, beside its character style -/// ([`TextStyle`]): paragraph spacing and indent, the list style and the OpenType features -/// ("Supported in text styles": "Paragraph spacing and indentation", "Lists", "OpenType -/// features", Create and apply text styles). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] -#[serde(default)] -pub struct ParagraphStyle { - #[serde(skip_serializing_if = "crate::node::is_default")] - pub paragraph_spacing: f64, - #[serde(skip_serializing_if = "crate::node::is_default")] - pub paragraph_indent: f64, - #[serde(skip_serializing_if = "crate::node::is_default")] - pub list: ListStyle, - #[serde(skip_serializing_if = "crate::node::is_default")] - pub features: std::collections::BTreeMap, -} - -/// The font new text starts in. A build can name another (`FIGCRAFT_DEFAULT_FONT="Noto Sans Thai"`, -/// for a distribution whose users mostly write a script Inter lacks); empty means Inter. -pub const DEFAULT_FONT: &str = match option_env!("FIGCRAFT_DEFAULT_FONT") { - Some(family) if !family.is_empty() => family, - _ => "Inter", -}; - -impl Default for TextStyle { - fn default() -> Self { - Self { - font_family: DEFAULT_FONT.into(), - font_style: "Regular".into(), - font_size: 12.0, - line_height: LineHeight::Auto, - letter_spacing: LetterSpacing::default(), - decoration: TextDecoration::None, - decoration_look: None, - case: TextCase::Original, - fills: None, - link: None, - variations: std::collections::BTreeMap::new(), - style_id: None, - } - } -} - -/// Axes a style name implies (weight, italic, slant): picking a named style clears their custom values. -pub const STYLE_AXES: [&str; 3] = ["wght", "ital", "slnt"]; - -impl TextStyle { - /// Numeric weight: a custom `wght` axis value, else the one implied by the style name. - pub fn weight(&self) -> u16 { - match self.variations.get("wght") { - Some(w) if w.is_finite() => w.round().clamp(1.0, 1000.0) as u16, - _ => style_weight(&self.font_style), - } - } - pub fn italic(&self) -> bool { - self.font_style.to_ascii_lowercase().contains("italic") - || self.variations.get("ital").is_some_and(|v| *v >= 0.5) - || self.variations.get("slnt").is_some_and(|v| *v != 0.0) - } - - /// Take what text style `def` sets (font, size, line height, letter spacing, decoration, case - /// and axis values) and link to it as `id`; fills and hyperlinks stay. - pub fn take_text_style(&mut self, def: &TextStyle, id: &str) { - *self = TextStyle { fills: self.fills.take(), link: self.link.take(), style_id: Some(id.to_string()), ..def.clone() }; - } - - /// Whether these chars show what text style `def` sets. - pub fn shows_text_style(&self, def: &TextStyle) -> bool { - self.font_family == def.font_family - && self.font_style == def.font_style - && self.font_size == def.font_size - && self.line_height == def.line_height - && self.letter_spacing == def.letter_spacing - && self.decoration == def.decoration - && self.decoration_look == def.decoration_look - && self.case == def.case - && self.variations == def.variations - } - - /// This style as a text style's definition (linked to nothing). - pub fn as_text_style(&self) -> TextStyle { - TextStyle { style_id: None, link: None, ..self.clone() } - } - - /// How the decoration looks. - pub fn look(&self) -> DecorationLook { - self.decoration_look.as_deref().copied().unwrap_or_default() - } - - /// Change how the decoration looks (the default look is stored as None). - pub fn set_look(&mut self, f: impl FnOnce(&mut DecorationLook)) { - let mut look = self.look(); - f(&mut look); - self.decoration_look = (look != DecorationLook::default()).then(|| Box::new(look)); - } - - /// Switch to the named style, dropping custom values of the axes the name implies. - pub fn set_font_style(&mut self, name: impl Into) { - self.font_style = name.into(); - for tag in STYLE_AXES { - self.variations.remove(tag); - } - } -} - -/// Weight from a style name ("Semi Bold Italic" → 600). -pub fn style_weight(style: &str) -> u16 { - let s = style.to_ascii_lowercase().replace([' ', '-'], ""); - // A weight number ("600", "600 Italic"), as some apps name styles. - let digits: String = s.chars().take_while(char::is_ascii_digit).collect(); - if let Ok(n) = digits.parse::() - && (1..=1000).contains(&n) - { - return n; - } - let table: [(&str, u16); 10] = [ - ("thin", 100), - ("extralight", 200), - ("ultralight", 200), - ("light", 300), - ("medium", 500), - ("semibold", 600), - ("demibold", 600), - ("extrabold", 800), - ("ultrabold", 800), - ("black", 900), - ]; - for (k, w) in table { - if s.contains(k) { - return w; - } - } - if s.contains("bold") { - return 700; - } - if s.contains("heavy") { - return 900; - } - 400 -} - -/// A styled range of characters (char indices, end exclusive). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct StyleRun { - pub start: usize, - pub end: usize, - pub style: TextStyle, -} - -/// Text node content. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] -#[serde(default)] -pub struct TextData { - pub characters: String, - pub style: TextStyle, - /// Ranges whose style differs from `style`; full styles (already merged), non-overlapping, sorted. - #[serde(skip_serializing_if = "crate::node::is_default")] - pub runs: Vec, - #[serde(skip_serializing_if = "crate::node::is_default")] - pub auto_resize: TextAutoResize, - #[serde(skip_serializing_if = "crate::node::is_default")] - pub align_h: TextAlignH, - #[serde(skip_serializing_if = "crate::node::is_default")] - pub align_v: TextAlignV, - #[serde(skip_serializing_if = "crate::node::is_default")] - pub paragraph_spacing: f64, - #[serde(skip_serializing_if = "crate::node::is_default")] - pub paragraph_indent: f64, - /// The list style of paragraphs `paragraphs` doesn't cover (all of them when it's empty). - #[serde(skip_serializing_if = "crate::node::is_default")] - pub list: ListStyle, - /// Per-paragraph list settings, by paragraph index; empty when every paragraph is `list` at - /// level 0. Kept in step with the characters by [`TextData::replace_range`]. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub paragraphs: Vec, - /// Space between two items of a list (px), in place of the paragraph spacing. - #[serde(skip_serializing_if = "crate::node::is_default")] - pub list_spacing: f64, - /// Opening and closing punctuation at a line's aligned edge sits outside the box. - #[serde(skip_serializing_if = "crate::node::is_default")] - pub hanging_punctuation: bool, - /// List bullets and numbers sit outside the box; item text lines up with plain paragraphs. - #[serde(skip_serializing_if = "crate::node::is_default")] - pub hanging_list: bool, - /// Vertical trim (`capHeight`: the box runs from the first line's cap height to the last - /// line's baseline). - #[serde(skip_serializing_if = "crate::node::is_default")] - pub leading_trim: LeadingTrim, - /// Max lines when truncating, or before an auto-height text ends in an ellipsis (0 = as many - /// as fit). - #[serde(skip_serializing_if = "crate::node::is_default")] - pub max_lines: u32, - /// Where lines break when the text wraps: greedily (Auto), as evenly as the words allow - /// (Balance), or full with no one word alone on the last line (Pretty). - #[serde(skip_serializing_if = "crate::node::is_default")] - pub wrap_style: WrapStyle, - /// OpenType feature toggles (e.g. "liga": false). - #[serde(skip_serializing_if = "crate::node::is_default")] - pub features: std::collections::BTreeMap, - /// The path the text follows (text on a path); `None`: lines in the box. Boxed to keep text - /// data small. - #[serde(skip_serializing_if = "Option::is_none")] - pub path: Option>, - /// Where the file this text came from wrapped its lines (an imported `.fig`'s saved layout). - /// Layout follows them only while the box width and everything that moves a line break are - /// as they were saved, so an edit that could change the wrap drops them. - #[serde(skip_serializing_if = "Option::is_none")] - pub saved_breaks: Option>, -} - -/// Line breaks a file saved with a text: the chars that start a wrapped line (not those after a -/// hard break), for the box width and the content ([`TextData::layout_key`]) they were made for. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] -#[serde(default)] -pub struct SavedBreaks { - pub starts: Vec, - pub width: f64, - pub for_text: u64, -} - -/// Text on a path: the baseline follows `d`. The text starts at `start` (aligned there by the -/// horizontal alignment: left starts, centre centres, right ends at it); glyphs past an open -/// path's ends are hidden, a closed path wraps them around. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] -#[serde(default)] -pub struct TextPath { - /// SVG path data in the layer's own space. - pub d: String, - /// Where the text starts along the path, 0..1 by length. - pub start: f64, - /// On the other side of the path, running the other way. - #[serde(skip_serializing_if = "std::ops::Not::not")] - pub flip: bool, -} - -impl TextPath { - /// A text path following `path` (layer space). - pub fn new(path: &figcraft_geom::BezPath) -> Self { - Self { d: path.to_svg(), start: 0.0, flip: false } - } - - /// The path (empty when `d` doesn't parse). - pub fn path(&self) -> figcraft_geom::BezPath { - figcraft_geom::BezPath::from_svg(&self.d).unwrap_or_default() - } - - /// The start offset clamped into 0..1 (NaN: 0). - pub fn start(&self) -> f64 { - if self.start.is_finite() { self.start.clamp(0.0, 1.0) } else { 0.0 } - } - - /// Scale the path by `sx`, `sy` (the layer was resized). - pub fn scale(&mut self, sx: f64, sy: f64) { - if sx.is_finite() && sy.is_finite() { - let mut p = self.path(); - p.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy)); - self.d = p.to_svg(); - } - } -} - -/// Whether `tag` is a valid OpenType feature tag (four printable ASCII chars). -pub fn valid_feature_tag(tag: &str) -> bool { - tag.len() == 4 && tag.bytes().all(|b| (0x20..0x7f).contains(&b)) -} - -/// Whether `tag` is a valid OpenType variation axis tag (same shape as a feature tag). -pub fn valid_axis_tag(tag: &str) -> bool { - valid_feature_tag(tag) -} - -impl TextData { - pub fn new(characters: impl Into) -> Self { - Self { characters: characters.into(), ..Default::default() } - } - - /// A stable hash of what decides where lines break: everything but paints, links, style links, - /// decorations, vertical settings and the saved breaks themselves (FNV-1a over the JSON, so it - /// stays the same across builds and can be saved). - pub fn layout_key(&self) -> u64 { - let clear = |s: &mut TextStyle| { - (s.fills, s.link, s.style_id, s.decoration, s.decoration_look) = (None, None, None, TextDecoration::None, None); - }; - let mut t = TextData { - align_v: TextAlignV::default(), - paragraph_spacing: 0.0, - list_spacing: 0.0, - leading_trim: LeadingTrim::default(), - max_lines: 0, - saved_breaks: None, - ..self.clone() - }; - clear(&mut t.style); - t.runs.iter_mut().for_each(|r| clear(&mut r.style)); - t.normalize(); - serde_json::to_vec(&t).unwrap_or_default().iter().fold(0xcbf2_9ce4_8422_2325, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3)) - } - - /// Keep `starts` (chars that start a wrapped line) as this text's saved line breaks in a box - /// `width` wide. - pub fn save_breaks(&mut self, starts: Vec, width: f64) { - self.saved_breaks = Some(Box::new(SavedBreaks { starts, width, for_text: self.layout_key() })); - } - - /// The saved line starts (empty: no wrapped line) while they still hold: the same box width and - /// layout content, and starts increasing inside the text. - pub fn saved_breaks_for(&self, width: f64) -> Option<&[usize]> { - let b = self.saved_breaks.as_deref()?; - let inside = b.starts.first().is_none_or(|s| *s > 0) && b.starts.last().is_none_or(|s| *s < self.char_count()); - let same_box = (b.width - width).abs() <= 1e-6 * width.abs().max(1.0); - (inside && same_box && b.starts.is_sorted_by(|a, b| a < b) && b.for_text == self.layout_key()).then_some(b.starts.as_slice()) - } - - /// How far text on a path paints past its path's box (glyphs stand on the path, lines after - /// the first run beside it): about one and a half of the largest size per line; 0 off a path. - pub fn path_reach(&self) -> f64 { - if self.path.is_none() { - return 0.0; - } - let lines = self.characters.lines().count().clamp(1, 10_000) as f64; - self.max_size() * 1.5 * lines - } - - /// How far vertically trimmed text paints past its box (descenders below the last baseline, - /// accents above the first cap height): half the largest size; 0 untrimmed. - pub fn trim_reach(&self) -> f64 { - if self.leading_trim == LeadingTrim::None { 0.0 } else { self.max_size() * 0.5 } - } - - /// The largest font size used (capped like layout caps it). - fn max_size(&self) -> f64 { - self.spans().iter().map(|(_, _, s)| s.font_size).filter(|v| v.is_finite()).fold(0.0, f64::max).min(5000.0) - } - - pub fn char_count(&self) -> usize { - self.characters.chars().count() - } - - /// Whether text that runs out of room ends in an ellipsis: a truncating box, or auto height - /// or auto width with a line limit (the box hugs up to `max_lines` lines: "Max lines is - /// available only if truncate text is enabled, and: Text resizing is set to auto height or - /// auto width"). - pub fn truncates(&self) -> bool { - match self.auto_resize { - TextAutoResize::Truncate => true, - TextAutoResize::Height | TextAutoResize::WidthAndHeight => self.max_lines > 0, - TextAutoResize::None => false, - } - } - - /// The resize mode once the box is given an explicit `width` and/or `height`: an explicit - /// height fixes the box, an explicit width turns auto width into auto height, and truncating - /// text keeps truncating. - pub fn fix_size(&mut self, width: bool, height: bool) { - self.auto_resize = match self.auto_resize { - _ if height && self.truncates() => TextAutoResize::Truncate, - _ if height => TextAutoResize::None, - TextAutoResize::WidthAndHeight if width => TextAutoResize::Height, - mode => mode, - }; - } - - /// The style at char index `i`. - pub fn style_at(&self, i: usize) -> &TextStyle { - self.runs.iter().find(|r| r.start <= i && i < r.end).map(|r| &r.style).unwrap_or(&self.style) - } - - /// Spans of uniform style covering the whole text: (start, end, style). - pub fn spans(&self) -> Vec<(usize, usize, &TextStyle)> { - let n = self.char_count(); - let mut out = Vec::new(); - let mut pos = 0; - let mut runs: Vec<&StyleRun> = self.runs.iter().filter(|r| r.start < r.end && r.start < n).collect(); - runs.sort_by_key(|r| r.start); - for r in runs { - let start = r.start.max(pos); - let end = r.end.min(n); - if start >= end { - continue; - } - if start > pos { - out.push((pos, start, &self.style)); - } - out.push((start, end, &r.style)); - pos = end; - } - if pos < n || out.is_empty() { - out.push((pos, n, &self.style)); - } - out - } - - /// Apply `f` to the style of chars `start..end` (all text when the range is the whole text or - /// empty text), splitting runs as needed and merging equal neighbours. - pub fn restyle(&mut self, start: usize, end: usize, f: impl Fn(&mut TextStyle)) { - let n = self.char_count(); - let (start, end) = (start.min(n), end.min(n)); - if n == 0 || (start == 0 && end >= n) { - f(&mut self.style); - for r in &mut self.runs { - f(&mut r.style); - } - self.normalize(); - return; - } - if start >= end { - return; - } - // Expand to per-char styles, modify, re-compress. - let mut per: Vec = (0..n).map(|i| self.style_at(i).clone()).collect(); - for s in per.iter_mut().take(end).skip(start) { - f(s); - } - self.runs.clear(); - let mut i = 0; - while i < n { - let Some(s) = per.get(i) else { break }; - let mut j = i + 1; - while per.get(j).is_some_and(|t| t == s) { - j += 1; - } - if *s != self.style { - self.runs.push(StyleRun { start: i, end: j, style: s.clone() }); - } - i = j; - } - } - - /// Drop empty/out-of-range runs and runs equal to the base style, and merge touching equal runs. - pub fn normalize(&mut self) { - let n = self.char_count(); - let base = self.style.clone(); - self.runs.retain(|r| r.start < r.end && r.start < n && r.style != base); - for r in &mut self.runs { - r.end = r.end.min(n); - } - self.runs.sort_by_key(|r| r.start); - let mut merged: Vec = Vec::with_capacity(self.runs.len()); - for mut r in std::mem::take(&mut self.runs) { - // Runs never overlap: a later run starts where the previous one ends at the earliest. - if let Some(prev) = merged.last() { - r.start = r.start.max(prev.end); - if r.start >= r.end { - continue; - } - } - match merged.last_mut() { - Some(prev) if prev.end == r.start && prev.style == r.style => prev.end = r.end, - _ => merged.push(r), - } - } - self.runs = merged; - } - - /// Replace chars `start..end` with `text`, shifting runs (inserted text takes the style at `start`). - pub fn replace_range(&mut self, start: usize, end: usize, text: &str) { - let n = self.char_count(); - let (start, end) = (start.min(n), end.min(n).max(start.min(n))); - let byte = |i: usize| self.characters.char_indices().nth(i).map(|(b, _)| b).unwrap_or(self.characters.len()); - let (bs, be) = (byte(start), byte(end)); - if !self.paragraphs.is_empty() { - // Paragraphs merged by removed newlines take the first one's list; new ones copy it. - let p = self.para_of(start); - let removed = self.characters.get(bs..be).unwrap_or("").matches('\n').count(); - let added = text.matches('\n').count(); - self.fill_paragraphs(); - let at = self.para_list(p); - let tail = (p + 1).min(self.paragraphs.len()); - self.paragraphs.drain(tail..(p + 1 + removed).min(self.paragraphs.len())); - self.paragraphs.splice(tail..tail, std::iter::repeat_n(at, added)); - } - let mut s = String::with_capacity(self.characters.len() + text.len()); - s.push_str(self.characters.get(..bs).unwrap_or("")); - s.push_str(text); - s.push_str(self.characters.get(be..).unwrap_or("")); - self.characters = s; - let inserted = text.chars().count(); - let removed = end - start; - for r in &mut self.runs { - let shift = |p: usize| -> usize { - if p <= start { - p - } else if p >= end { - // Runs of a file that reach past the text (nothing clamps them on load) - // would otherwise overflow here; `normalize` below drops what is left. - p.saturating_sub(removed).saturating_add(inserted) - } else { - start + inserted - } - }; - // A run touching the insertion point at its end grows with the typed text. - let new_start = shift(r.start); - let new_end = if r.end == start && r.start < start { start + inserted } else { shift(r.end) }; - r.start = new_start; - r.end = new_end; - } - self.normalize(); - } - - pub fn paragraph_count(&self) -> usize { - self.characters.matches('\n').count() + 1 - } - - /// The paragraph char index `i` is in. - pub fn para_of(&self, i: usize) -> usize { - self.characters.chars().take(i).filter(|c| *c == '\n').count() - } - - /// Paragraph `p`'s list settings. - pub fn para_list(&self, p: usize) -> ParaList { - self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, ..ParaList::default() }) - } - - /// Spell out every paragraph's list settings in `paragraphs` (and drop extra entries). - fn fill_paragraphs(&mut self) { - let n = self.paragraph_count(); - let fill = ParaList { list: self.list, ..ParaList::default() }; - self.paragraphs.resize(n, fill); - } - - /// The paragraphs chars `start..end` touch (a caret: its paragraph; a selection ending just - /// after a newline leaves the next paragraph out). - pub fn para_span(&self, start: usize, end: usize) -> (usize, usize) { - let (s, e) = (start.min(end), start.max(end)); - let (a, b) = (self.para_of(s), self.para_of(e)); - let ends_at_start = e > s && b > a && self.characters.chars().nth(e - 1) == Some('\n'); - (a, if ends_at_start { b - 1 } else { b }) - } - - /// Apply `f` to the list settings of the paragraphs chars `start..end` touch (plain - /// paragraphs stay at level 0, only numbered ones keep a start, levels stop at - /// [`MAX_LIST_INDENT`]); one style for all with no nesting or start folds back into `list`. - pub fn set_para_lists(&mut self, start: usize, end: usize, f: impl Fn(&mut ParaList)) { - self.set_para_lists_with(start, end, false, f); - } - - /// Take the list items among the paragraphs chars `start..end` touch out of their list at the - /// level they had, as Backspace or Delete at an item's start does: "delete the counter, but - /// keep the same level of indentation" (Create bulleted and numbered lists). - pub fn unlist_keeping_levels(&mut self, start: usize, end: usize) { - self.set_para_lists_with(start, end, true, |pl| pl.list = ListStyle::None); - } - - fn set_para_lists_with(&mut self, start: usize, end: usize, keep_levels: bool, f: impl Fn(&mut ParaList)) { - let (a, b) = self.para_span(start, end); - self.fill_paragraphs(); - for pl in self.paragraphs.get_mut(a..=b).unwrap_or(&mut []) { - f(pl); - if pl.list == ListStyle::None && !keep_levels { - pl.indent = 0; - } - if pl.list != ListStyle::Numbered { - pl.start_offset = 0; - } - pl.indent = pl.indent.min(MAX_LIST_INDENT); - } - if let Some(first) = self.paragraphs.first().copied() - && first == (ParaList { list: first.list, ..ParaList::default() }) - && self.paragraphs.iter().all(|p| *p == first) - { - self.list = first.list; - self.paragraphs.clear(); - } - } - - /// The list style and level shared by the paragraphs chars `start..end` touch (the first - /// one's settings), or None when they differ. Start numbers don't count. - pub fn common_para_list(&self, start: usize, end: usize) -> Option { - let (a, b) = self.para_span(start, end); - let first = self.para_list(a); - (a..=b).all(|p| self.para_list(p).list == first.list && self.para_list(p).indent == first.indent).then_some(first) - } - - /// What a text style made from this text sets at the paragraph and layer level: the paragraph - /// spacing and indent, the list style (the one every paragraph shares; the base one when they - /// differ) and the OpenType features. - pub fn paragraph_style(&self) -> ParagraphStyle { - let list = match self.paragraphs.first() { - Some(first) if self.paragraphs.iter().all(|p| p.list == first.list) => first.list, - _ => self.list, - }; - ParagraphStyle { paragraph_spacing: self.paragraph_spacing, paragraph_indent: self.paragraph_indent, list, features: self.features.clone() } - } - - /// Take what text style `ps` sets at the paragraph and layer level: the spacing, indent and - /// features on the layer, the list style on every paragraph. - pub fn take_paragraph_style(&mut self, ps: &ParagraphStyle) { - let len = self.char_count(); - self.take_paragraph_style_in(0, len, ps); - } - - /// As [`take_paragraph_style`](Self::take_paragraph_style), with the list style going to the - /// paragraphs chars `start..end` touch only; the spacing, indent and features are the - /// layer's, as in Figma. - pub fn take_paragraph_style_in(&mut self, start: usize, end: usize, ps: &ParagraphStyle) { - self.paragraph_spacing = ps.paragraph_spacing; - self.paragraph_indent = ps.paragraph_indent; - self.features.clone_from(&ps.features); - self.set_para_lists(start, end, |pl| pl.list = ps.list); - } - - /// Follow text style `id`'s paragraph-level definition wherever chars link to it. - pub fn update_paragraph_style(&mut self, id: &str, ps: &ParagraphStyle) { - let linked: Vec<(usize, usize)> = - self.spans().into_iter().filter(|(_, _, s)| s.style_id.as_deref() == Some(id)).map(|(a, b, _)| (a, b)).collect(); - for (a, b) in linked { - self.take_paragraph_style_in(a, b, ps); - } - } - - /// Whether chars `start..end` show what text style `ps` sets at the paragraph and layer - /// level: the layer's spacing, indent and features, and their paragraphs' list style. - pub fn shows_paragraph_style_in(&self, start: usize, end: usize, ps: &ParagraphStyle) -> bool { - self.paragraph_spacing == ps.paragraph_spacing - && self.paragraph_indent == ps.paragraph_indent - && self.features == ps.features - && self.common_para_list(start, end).is_some_and(|pl| pl.list == ps.list) - } - - /// Whether the whole text shows what text style `ps` sets at the paragraph and layer level. - pub fn shows_paragraph_style(&self, ps: &ParagraphStyle) -> bool { - self.shows_paragraph_style_in(0, self.char_count(), ps) - } - - /// Link chars `start..end` (all of them when the range is empty or the whole text) to text - /// style `id`, defined as `def`. - pub fn link_text_style(&mut self, start: usize, end: usize, def: &TextStyle, id: &str) { - let (start, end) = if start == end { (0, self.char_count()) } else { (start, end) }; - self.restyle(start, end, |s| s.take_text_style(def, id)); - } - - /// Unlink chars `start..end` (all when empty or whole) from their text styles; values stay. - pub fn unlink_text_style(&mut self, start: usize, end: usize) { - let (start, end) = if start == end { (0, self.char_count()) } else { (start, end) }; - self.restyle(start, end, |s| s.style_id = None); - } - - /// The text style every char is linked to: `Some(None)` when none is, None when they differ. - pub fn text_style_link(&self) -> Option> { - let mut spans = self.spans().into_iter().map(|(_, _, s)| s.style_id.as_deref()); - let first = spans.next()?; - spans.all(|s| s == first).then_some(first) - } - - /// Apply `f` to every distinct style (the base and each run) and merge runs that became equal. - pub(crate) fn each_style(&mut self, mut f: impl FnMut(&mut TextStyle)) { - f(&mut self.style); - for r in &mut self.runs { - f(&mut r.style); - } - self.normalize(); - } - - /// Whether any char links to text style `id`. - pub fn links_text_style(&self, id: &str) -> bool { - std::iter::once(&self.style).chain(self.runs.iter().map(|r| &r.style)).any(|s| s.style_id.as_deref() == Some(id)) - } - - /// Follow text style `id`'s new definition wherever chars link to it. - pub fn update_text_style(&mut self, id: &str, def: &TextStyle) { - self.each_style(|s| { - if s.style_id.as_deref() == Some(id) { - s.take_text_style(def, id); - } - }); - } - - /// Point text style links elsewhere: `f(id)` gives the new id, None unlinks (values stay). - pub fn map_text_styles(&mut self, f: impl Fn(&str) -> Option) { - self.each_style(|s| s.style_id = s.style_id.as_deref().and_then(&f)); - } - - /// Unlink chars whose style is gone (`def` gives None) or that no longer show it (edited since). - pub fn check_text_styles<'a>(&mut self, def: impl Fn(&str) -> Option<&'a TextStyle>) { - self.each_style(|s| { - if s.style_id.as_deref().is_some_and(|id| !def(id).is_some_and(|d| s.shows_text_style(d))) { - s.style_id = None; - } - }); - } - - /// Whether every char shares one style (the panel shows "Mixed" otherwise). - pub fn is_uniform(&self) -> bool { - self.runs.is_empty() - } -} - -#[cfg(test)] -mod case_tests { - use super::*; - - #[test] - fn trim_num_prints_huge_whole_numbers() { - assert_eq!(trim_num(1e19), "10000000000000000000"); - assert_eq!(trim_num(-1e19), "-10000000000000000000"); - assert_eq!(trim_num(12.0), "12"); - let v = 12345678901234567890.0_f64; - assert_eq!(trim_num(v).parse::().unwrap(), v); - } - - #[test] - fn title_case_respects_context() { - assert_eq!(TextCase::Title.apply("hello wORLD don't 3d x-ray"), "Hello WORLD Don't 3d X-Ray"); - // Continuing a word from a previous run: no capital. - assert_eq!(TextCase::Title.apply_after("lo world", Some('l')), "lo World"); - assert_eq!(TextCase::Title.apply_after("lo", Some(' ')), "Lo"); - assert_eq!(TextCase::Upper.apply_after("ß", Some('a')), "SS"); - assert!(valid_feature_tag("liga") && valid_feature_tag("ss01")); - assert!(!valid_feature_tag("lig") && !valid_feature_tag("ligatures") && !valid_feature_tag("li\u{e9}")); - } - - #[test] - fn new_text_starts_in_the_builds_default_font() { - // Inter, unless the build names another with `FIGCRAFT_DEFAULT_FONT` (empty means Inter). - let named = option_env!("FIGCRAFT_DEFAULT_FONT").filter(|f| !f.is_empty()); - assert_eq!(DEFAULT_FONT, named.unwrap_or("Inter")); - let st = TextStyle::default(); - assert_eq!(st.font_family, DEFAULT_FONT); - // The family is always written, so a saved file keeps it in a build with another default. - let saved = serde_json::to_value(&st).unwrap(); - assert_eq!(saved["font_family"], DEFAULT_FONT); - } - - #[test] - fn variations_are_backward_compatible_and_imply_weight() { - // Files written before variable fonts load with no custom axes, and stay byte-identical. - let old = r#"{"font_family":"Inter","font_style":"Bold","font_size":12.0}"#; - let st: TextStyle = serde_json::from_str(old).unwrap(); - assert!(st.variations.is_empty()); - assert!(!serde_json::to_string(&st).unwrap().contains("variations")); - let mut st = TextStyle { font_style: "Regular".into(), ..Default::default() }; - st.variations.insert("wght".into(), 640.0); - st.variations.insert("wdth".into(), 80.0); - assert_eq!(st.weight(), 640); - assert!(!st.italic()); - st.variations.insert("slnt".into(), -8.0); - assert!(st.italic()); - let round: TextStyle = serde_json::from_str(&serde_json::to_string(&st).unwrap()).unwrap(); - assert_eq!(round, st); - // Picking a named style drops the axes its name implies, keeps the others. - st.set_font_style("Bold"); - assert_eq!(st.weight(), 700); - assert_eq!(st.variations.keys().collect::>(), ["wdth"]); - assert!(valid_axis_tag("wght") && !valid_axis_tag("weight")); - } - - #[test] - fn list_starts_load_from_old_files_and_follow_edits() { - use ListStyle::{Bulleted as B, None as P, Numbered as N}; - // Files written before start numbers load with none, and write the same bytes. - let old = r#"{"list":"numbered","indent":1}"#; - let pl: ParaList = serde_json::from_str(old).unwrap(); - assert_eq!(pl, ParaList { list: N, indent: 1, start_offset: 0 }); - assert_eq!(serde_json::to_string(&pl).unwrap(), old); - // One numbered paragraph with a start keeps it (it doesn't fold into `list`). - let mut t = TextData { characters: "Next".into(), ..Default::default() }; - t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start_offset: 1 }); - assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start_offset: 1 }); - // Enter copies it to the new item; different starts don't make the list mixed. - t.replace_range(4, 4, "\nMore"); - assert_eq!(t.para_list(1).start_offset, 1); - t.set_para_lists(5, 5, |p| p.start_offset = 0); - assert!(t.common_para_list(0, t.char_count()).is_some_and(|p| p.list == N)); - // Bullets have no number, so no start. - t.set_para_lists(0, 0, |p| *p = ParaList { list: B, indent: 1, start_offset: 4 }); - assert_eq!(t.para_list(0), ParaList { list: B, indent: 1, start_offset: 0 }); - // A plain paragraph has none. - t.set_para_lists(0, 0, |p| p.list = P); - assert_eq!(t.para_list(0), ParaList::default()); - } -} - -#[cfg(test)] -mod title_case_tests { - use super::TextCase; - - #[test] - fn title_case_after_opening_apostrophe() { - assert_eq!(TextCase::Title.apply("'hello world'"), "'Hello World'"); - assert_eq!(TextCase::Title.apply("say 'hi'"), "Say 'Hi'"); - assert_eq!(TextCase::Title.apply("don't"), "Don't"); - assert_eq!(TextCase::Title.apply("\u{2019}hello"), "\u{2019}Hello"); - } - - #[test] - fn title_case_between_runs_sees_char_before_apostrophe() { - assert_eq!(TextCase::Title.apply_after("t", Some('\'')), "t"); - assert_eq!(TextCase::Title.apply_between("hello", None, Some('\'')), "Hello"); - assert_eq!(TextCase::Title.apply_between("t", Some('n'), Some('\'')), "t"); - assert_eq!(TextCase::Title.apply_between("t", Some(' '), Some('\u{2019}')), "T"); - } - - #[test] - fn title_case_combining_marks_do_not_break_words() { - assert_eq!(TextCase::Title.apply("re\u{301}sume\u{301}"), "Re\u{301}sume\u{301}"); - assert_eq!(TextCase::Title.apply("re\u{301}sume\u{301} bon"), "Re\u{301}sume\u{301} Bon"); - assert_eq!(TextCase::Title.apply("a \u{301}b"), "A \u{301}B"); - assert_eq!(TextCase::Title.apply("cafe\u{301}'s"), "Cafe\u{301}'s"); - } -} +//! Text node data: characters plus a base style and styled runs. + +use figcraft_color::{Color, Paint}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "unit", content = "value", rename_all = "camelCase")] +#[derive(Default)] +pub enum LineHeight { + #[default] + Auto, + Pixels(f64), + Percent(f64), +} + +impl LineHeight { + /// Resolved line height in px for `font_size` (`auto` uses the font's natural ratio). + pub fn resolve(self, font_size: f64, natural_ratio: f64) -> f64 { + match self { + LineHeight::Auto => font_size * natural_ratio, + LineHeight::Pixels(v) => v.max(0.0), + LineHeight::Percent(p) => font_size * p.max(0.0) / 100.0, + } + } + pub fn label(self) -> String { + match self { + LineHeight::Auto => "Auto".into(), + LineHeight::Pixels(v) => trim_num(v), + LineHeight::Percent(p) => format!("{}%", trim_num(p)), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "unit", content = "value", rename_all = "camelCase")] +pub enum LetterSpacing { + Pixels(f64), + Percent(f64), +} + +impl Default for LetterSpacing { + fn default() -> Self { + LetterSpacing::Percent(0.0) + } +} + +impl LetterSpacing { + pub fn resolve(self, font_size: f64) -> f64 { + match self { + LetterSpacing::Pixels(v) => v, + LetterSpacing::Percent(p) => font_size * p / 100.0, + } + } + pub fn label(self) -> String { + match self { + LetterSpacing::Pixels(v) => trim_num(v), + LetterSpacing::Percent(p) => format!("{}%", trim_num(p)), + } + } +} + +pub fn trim_num(v: f64) -> String { + if v.abs() >= 9.0e18 { + return format!("{v}"); + } + let r = (v * 100.0).round() / 100.0; + if r.fract().abs() < 1e-9 { format!("{}", r as i64) } else { format!("{r}") } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum TextDecoration { + #[default] + None, + Underline, + Strikethrough, +} + +/// How an underline or strikethrough is drawn. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum DecorationStyle { + #[default] + Solid, + Dotted, + Wavy, +} + +/// A decoration length: `Auto` (the font's own value), pixels, or a percentage of the font size. +pub type DecorationLength = LineHeight; + +/// How a decoration line looks; the default is the font's own solid line in the text's colour. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct DecorationLook { + pub style: DecorationStyle, + pub thickness: DecorationLength, + /// Underlines: from the baseline down to the line's top edge (negative: above the baseline). + pub offset: DecorationLength, + /// Underlines break where descenders cross them. + pub skip_ink: bool, + /// The line's own colour (None: the text's fills). + pub color: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum TextCase { + #[default] + Original, + Upper, + Lower, + Title, + SmallCaps, +} + +impl TextCase { + pub fn apply(self, s: &str) -> String { + self.apply_after(s, None) + } + + /// Apply the case to `s`, which follows `prev` in the text (so title case only capitalises + /// the first letter of each word even when a word spans several styled runs). An apostrophe + /// as `prev` is taken to continue a word. + pub fn apply_after(self, s: &str, prev: Option) -> String { + self.apply_impl(s, prev, prev.is_some_and(is_word_char)) + } + + /// Like [`apply_after`](Self::apply_after), also given the char `before` `prev`: an apostrophe + /// only continues a word when a letter or digit precedes it. + pub fn apply_between(self, s: &str, before: Option, prev: Option) -> String { + let continues = prev.is_some_and(|c| is_word_char(c) && (c.is_alphanumeric() || before.is_some_and(char::is_alphanumeric))); + self.apply_impl(s, prev, continues) + } + + /// `in_word` says whether `prev` continues a word (rather than a new word starting here). + fn apply_impl(self, s: &str, prev: Option, in_word: bool) -> String { + match self { + TextCase::Original | TextCase::SmallCaps => s.to_string(), + TextCase::Upper => s.to_uppercase(), + TextCase::Lower => s.to_lowercase(), + TextCase::Title => { + let mut out = String::with_capacity(s.len()); + let mut start = !in_word; + let mut last = prev; + for ch in s.chars() { + if start && ch.is_alphanumeric() { + out.extend(ch.to_uppercase()); + start = false; + } else { + out.push(ch); + // An apostrophe only continues a word right after a letter or digit. + let opening_apostrophe = + matches!(ch, '\'' | '\u{2019}') && !last.is_some_and(|c| c.is_alphanumeric() || is_combining_mark(c)); + // A combining mark attaches to the char before it and never changes word state. + if !is_combining_mark(ch) { + start = !is_word_char(ch) || opening_apostrophe; + } + } + last = Some(ch); + } + out + } + } + } + + /// Whether the char at a position preceded by `prev` starts a word (for title case). + pub fn starts_word(prev: Option) -> bool { + prev.is_none_or(|c| !is_word_char(c)) + } +} + +/// Letters, digits, combining marks and apostrophes continue a word (so "don't" stays "Don't" +/// and decomposed accents stay inside their word). +fn is_word_char(c: char) -> bool { + c.is_alphanumeric() || is_combining_mark(c) || matches!(c, '\'' | '\u{2019}') +} + +/// Combining marks such as the U+0301 accent in decomposed text: non-alphanumeric chars that +/// extend the grapheme of the char before them. +fn is_combining_mark(c: char) -> bool { + use unicode_segmentation::UnicodeSegmentation; + if c.is_ascii() || c.is_alphanumeric() { + return false; + } + let mut buf = [0u8; 8]; + buf[0] = b'a'; + let end = 1 + c.encode_utf8(&mut buf[1..]).len(); + std::str::from_utf8(&buf[..end]).is_ok_and(|s| s.graphemes(true).count() == 1) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum TextAlignH { + #[default] + Left, + Center, + Right, + Justified, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum TextAlignV { + #[default] + Top, + Middle, + Bottom, +} + +/// How a text box resizes as its content changes. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum TextAutoResize { + /// Auto width: one line per paragraph, box grows both ways. + #[default] + WidthAndHeight, + /// Fixed width, height grows. + Height, + /// Fixed size. + None, + /// Fixed size, truncate with an ellipsis. + Truncate, +} + +/// Vertical trim: what bounds a text box vertically. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum LeadingTrim { + /// The full line height of the first and last lines. + #[default] + None, + /// From the first line's cap height to the last line's baseline. + CapHeight, +} + +/// Where lines break when text wraps to more than one line ("Wrap style controls where lines +/// break when text wraps to more than one line", Explore text properties). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum WrapStyle { + /// "Figma fits as many words as possible on each line before wrapping to the next." + #[default] + Auto, + /// "Figma distributes text as evenly as possible across every line, so each line is close to + /// the same length." + Balance, + /// "Figma keeps each line full, but adjusts the last four lines to avoid an orphan -- a single + /// word stranded alone on the last line." + Pretty, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum ListStyle { + #[default] + None, + Bulleted, + Numbered, +} + +/// Deepest list nesting (levels 0..=MAX_LIST_INDENT). +pub const MAX_LIST_INDENT: u8 = 8; + +/// One paragraph's list settings. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct ParaList { + pub list: ListStyle, + /// Nesting level (0 = outermost). A plain paragraph is 0, except one that left its list by + /// Backspace or Delete at its start ([`TextData::unlist_keeping_levels`]): it keeps the level + /// it had and starts where that level's item text does ("delete the counter, but keep the + /// same level of indentation"). + pub indent: u8, + /// A numbered list that starts counting at this paragraph counts from `start_offset + 1` + /// (items that go on counting ignore it; other paragraphs are always 0). + #[serde(skip_serializing_if = "crate::node::is_default")] + pub start_offset: u32, +} + +/// Character style. In runs, every field is an override; `None` means "inherit the base". +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct TextStyle { + pub font_family: String, + /// The style name, e.g. "Regular", "Bold", "Semi Bold Italic". + pub font_style: String, + pub font_size: f64, + pub line_height: LineHeight, + pub letter_spacing: LetterSpacing, + pub decoration: TextDecoration, + /// How the decoration looks (None: the default look; boxed to keep styles small). + #[serde(skip_serializing_if = "Option::is_none")] + pub decoration_look: Option>, + pub case: TextCase, + /// Run fills (None = node fills). + pub fills: Option>, + /// Hyperlink. + pub link: Option, + /// Variable font axis values in user space, keyed by 4-char OpenType axis tag (e.g. `wght`: + /// 550, `wdth`: 85). Empty = what the style name implies; axes the face lacks are ignored. + #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub variations: std::collections::BTreeMap, + /// The text style (shared style id) these chars are linked to. A layer's chars can link to + /// different styles; editing what a style sets unlinks the chars edited. + #[serde(skip_serializing_if = "Option::is_none")] + pub style_id: Option, +} + +/// What a text style sets at the paragraph and layer level, beside its character style +/// ([`TextStyle`]): paragraph spacing and indent, the list style and the OpenType features +/// ("Supported in text styles": "Paragraph spacing and indentation", "Lists", "OpenType +/// features", Create and apply text styles). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct ParagraphStyle { + #[serde(skip_serializing_if = "crate::node::is_default")] + pub paragraph_spacing: f64, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub paragraph_indent: f64, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub list: ListStyle, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub features: std::collections::BTreeMap, +} + +/// The font new text starts in. A build can name another (`FIGCRAFT_DEFAULT_FONT="Noto Sans Thai"`, +/// for a distribution whose users mostly write a script Inter lacks); empty means Inter. +pub const DEFAULT_FONT: &str = match option_env!("FIGCRAFT_DEFAULT_FONT") { + Some(family) if !family.is_empty() => family, + _ => "Inter", +}; + +impl Default for TextStyle { + fn default() -> Self { + Self { + font_family: DEFAULT_FONT.into(), + font_style: "Regular".into(), + font_size: 12.0, + line_height: LineHeight::Auto, + letter_spacing: LetterSpacing::default(), + decoration: TextDecoration::None, + decoration_look: None, + case: TextCase::Original, + fills: None, + link: None, + variations: std::collections::BTreeMap::new(), + style_id: None, + } + } +} + +/// Axes a style name implies (weight, italic, slant): picking a named style clears their custom values. +pub const STYLE_AXES: [&str; 3] = ["wght", "ital", "slnt"]; + +impl TextStyle { + /// Numeric weight: a custom `wght` axis value, else the one implied by the style name. + pub fn weight(&self) -> u16 { + match self.variations.get("wght") { + Some(w) if w.is_finite() => w.round().clamp(1.0, 1000.0) as u16, + _ => style_weight(&self.font_style), + } + } + pub fn italic(&self) -> bool { + self.font_style.to_ascii_lowercase().contains("italic") + || self.variations.get("ital").is_some_and(|v| *v >= 0.5) + || self.variations.get("slnt").is_some_and(|v| *v != 0.0) + } + + /// Take what text style `def` sets (font, size, line height, letter spacing, decoration, case + /// and axis values) and link to it as `id`; fills and hyperlinks stay. + pub fn take_text_style(&mut self, def: &TextStyle, id: &str) { + *self = TextStyle { fills: self.fills.take(), link: self.link.take(), style_id: Some(id.to_string()), ..def.clone() }; + } + + /// Whether these chars show what text style `def` sets. + pub fn shows_text_style(&self, def: &TextStyle) -> bool { + self.font_family == def.font_family + && self.font_style == def.font_style + && self.font_size == def.font_size + && self.line_height == def.line_height + && self.letter_spacing == def.letter_spacing + && self.decoration == def.decoration + && self.decoration_look == def.decoration_look + && self.case == def.case + && self.variations == def.variations + } + + /// This style as a text style's definition (linked to nothing). + pub fn as_text_style(&self) -> TextStyle { + TextStyle { style_id: None, link: None, ..self.clone() } + } + + /// How the decoration looks. + pub fn look(&self) -> DecorationLook { + self.decoration_look.as_deref().copied().unwrap_or_default() + } + + /// Change how the decoration looks (the default look is stored as None). + pub fn set_look(&mut self, f: impl FnOnce(&mut DecorationLook)) { + let mut look = self.look(); + f(&mut look); + self.decoration_look = (look != DecorationLook::default()).then(|| Box::new(look)); + } + + /// Switch to the named style, dropping custom values of the axes the name implies. + pub fn set_font_style(&mut self, name: impl Into) { + self.font_style = name.into(); + for tag in STYLE_AXES { + self.variations.remove(tag); + } + } +} + +/// Weight from a style name ("Semi Bold Italic" → 600). +pub fn style_weight(style: &str) -> u16 { + let s = style.to_ascii_lowercase().replace([' ', '-'], ""); + // A weight number ("600", "600 Italic"), as some apps name styles. + let digits: String = s.chars().take_while(char::is_ascii_digit).collect(); + if let Ok(n) = digits.parse::() + && (1..=1000).contains(&n) + { + return n; + } + let table: [(&str, u16); 10] = [ + ("thin", 100), + ("extralight", 200), + ("ultralight", 200), + ("light", 300), + ("medium", 500), + ("semibold", 600), + ("demibold", 600), + ("extrabold", 800), + ("ultrabold", 800), + ("black", 900), + ]; + for (k, w) in table { + if s.contains(k) { + return w; + } + } + if s.contains("bold") { + return 700; + } + if s.contains("heavy") { + return 900; + } + 400 +} + +/// A styled range of characters (char indices, end exclusive). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct StyleRun { + pub start: usize, + pub end: usize, + pub style: TextStyle, +} + +/// Text node content. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct TextData { + pub characters: String, + pub style: TextStyle, + /// Ranges whose style differs from `style`; full styles (already merged), non-overlapping, sorted. + #[serde(skip_serializing_if = "crate::node::is_default")] + pub runs: Vec, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub auto_resize: TextAutoResize, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub align_h: TextAlignH, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub align_v: TextAlignV, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub paragraph_spacing: f64, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub paragraph_indent: f64, + /// The list style of paragraphs `paragraphs` doesn't cover (all of them when it's empty). + #[serde(skip_serializing_if = "crate::node::is_default")] + pub list: ListStyle, + /// Per-paragraph list settings, by paragraph index; empty when every paragraph is `list` at + /// level 0. Kept in step with the characters by [`TextData::replace_range`]. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub paragraphs: Vec, + /// Space between two items of a list (px), in place of the paragraph spacing. + #[serde(skip_serializing_if = "crate::node::is_default")] + pub list_spacing: f64, + /// Opening and closing punctuation at a line's aligned edge sits outside the box. + #[serde(skip_serializing_if = "crate::node::is_default")] + pub hanging_punctuation: bool, + /// List bullets and numbers sit outside the box; item text lines up with plain paragraphs. + #[serde(skip_serializing_if = "crate::node::is_default")] + pub hanging_list: bool, + /// Vertical trim (`capHeight`: the box runs from the first line's cap height to the last + /// line's baseline). + #[serde(skip_serializing_if = "crate::node::is_default")] + pub leading_trim: LeadingTrim, + /// Max lines when truncating, or before an auto-height text ends in an ellipsis (0 = as many + /// as fit). + #[serde(skip_serializing_if = "crate::node::is_default")] + pub max_lines: u32, + /// Where lines break when the text wraps: greedily (Auto), as evenly as the words allow + /// (Balance), or full with no one word alone on the last line (Pretty). + #[serde(skip_serializing_if = "crate::node::is_default")] + pub wrap_style: WrapStyle, + /// OpenType feature toggles (e.g. "liga": false). + #[serde(skip_serializing_if = "crate::node::is_default")] + pub features: std::collections::BTreeMap, + /// The path the text follows (text on a path); `None`: lines in the box. Boxed to keep text + /// data small. + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option>, + /// Where the file this text came from wrapped its lines (an imported `.fig`'s saved layout). + /// Layout follows them only while the box width and everything that moves a line break are + /// as they were saved, so an edit that could change the wrap drops them. + #[serde(skip_serializing_if = "Option::is_none")] + pub saved_breaks: Option>, +} + +/// Line breaks a file saved with a text: the chars that start a wrapped line (not those after a +/// hard break), for the box width and the content ([`TextData::layout_key`]) they were made for. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct SavedBreaks { + pub starts: Vec, + pub width: f64, + pub for_text: u64, +} + +/// Text on a path: the baseline follows `d`. The text starts at `start` (aligned there by the +/// horizontal alignment: left starts, centre centres, right ends at it); glyphs past an open +/// path's ends are hidden, a closed path wraps them around. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct TextPath { + /// SVG path data in the layer's own space. + pub d: String, + /// Where the text starts along the path, 0..1 by length. + pub start: f64, + /// On the other side of the path, running the other way. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub flip: bool, +} + +impl TextPath { + /// A text path following `path` (layer space). + pub fn new(path: &figcraft_geom::BezPath) -> Self { + Self { d: path.to_svg(), start: 0.0, flip: false } + } + + /// The path (empty when `d` doesn't parse). + pub fn path(&self) -> figcraft_geom::BezPath { + figcraft_geom::BezPath::from_svg(&self.d).unwrap_or_default() + } + + /// The start offset clamped into 0..1 (NaN: 0). + pub fn start(&self) -> f64 { + if self.start.is_finite() { self.start.clamp(0.0, 1.0) } else { 0.0 } + } + + /// Scale the path by `sx`, `sy` (the layer was resized). + pub fn scale(&mut self, sx: f64, sy: f64) { + if sx.is_finite() && sy.is_finite() { + let mut p = self.path(); + p.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy)); + self.d = p.to_svg(); + } + } +} + +/// Whether `tag` is a valid OpenType feature tag (four printable ASCII chars). +pub fn valid_feature_tag(tag: &str) -> bool { + tag.len() == 4 && tag.bytes().all(|b| (0x20..0x7f).contains(&b)) +} + +/// Whether `tag` is a valid OpenType variation axis tag (same shape as a feature tag). +pub fn valid_axis_tag(tag: &str) -> bool { + valid_feature_tag(tag) +} + +impl TextData { + pub fn new(characters: impl Into) -> Self { + Self { characters: characters.into(), ..Default::default() } + } + + /// A stable hash of what decides where lines break: everything but paints, links, style links, + /// decorations, vertical settings and the saved breaks themselves (FNV-1a over the JSON, so it + /// stays the same across builds and can be saved). + pub fn layout_key(&self) -> u64 { + let clear = |s: &mut TextStyle| { + (s.fills, s.link, s.style_id, s.decoration, s.decoration_look) = (None, None, None, TextDecoration::None, None); + }; + let mut t = TextData { + align_v: TextAlignV::default(), + paragraph_spacing: 0.0, + list_spacing: 0.0, + leading_trim: LeadingTrim::default(), + max_lines: 0, + saved_breaks: None, + ..self.clone() + }; + clear(&mut t.style); + t.runs.iter_mut().for_each(|r| clear(&mut r.style)); + t.normalize(); + serde_json::to_vec(&t).unwrap_or_default().iter().fold(0xcbf2_9ce4_8422_2325, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3)) + } + + /// Keep `starts` (chars that start a wrapped line) as this text's saved line breaks in a box + /// `width` wide. + pub fn save_breaks(&mut self, starts: Vec, width: f64) { + self.saved_breaks = Some(Box::new(SavedBreaks { starts, width, for_text: self.layout_key() })); + } + + /// The saved line starts (empty: no wrapped line) while they still hold: the same box width and + /// layout content, and starts increasing inside the text. + pub fn saved_breaks_for(&self, width: f64) -> Option<&[usize]> { + let b = self.saved_breaks.as_deref()?; + let inside = b.starts.first().is_none_or(|s| *s > 0) && b.starts.last().is_none_or(|s| *s < self.char_count()); + let same_box = (b.width - width).abs() <= 1e-6 * width.abs().max(1.0); + (inside && same_box && b.starts.is_sorted_by(|a, b| a < b) && b.for_text == self.layout_key()).then_some(b.starts.as_slice()) + } + + /// How far text on a path paints past its path's box (glyphs stand on the path, lines after + /// the first run beside it): about one and a half of the largest size per line; 0 off a path. + pub fn path_reach(&self) -> f64 { + if self.path.is_none() { + return 0.0; + } + let lines = self.characters.lines().count().clamp(1, 10_000) as f64; + self.max_size() * 1.5 * lines + } + + /// How far vertically trimmed text paints past its box (descenders below the last baseline, + /// accents above the first cap height): half the largest size; 0 untrimmed. + pub fn trim_reach(&self) -> f64 { + if self.leading_trim == LeadingTrim::None { 0.0 } else { self.max_size() * 0.5 } + } + + /// The largest font size used (capped like layout caps it). + fn max_size(&self) -> f64 { + self.spans().iter().map(|(_, _, s)| s.font_size).filter(|v| v.is_finite()).fold(0.0, f64::max).min(5000.0) + } + + pub fn char_count(&self) -> usize { + self.characters.chars().count() + } + + /// Whether text that runs out of room ends in an ellipsis: a truncating box, or auto height + /// or auto width with a line limit (the box hugs up to `max_lines` lines: "Max lines is + /// available only if truncate text is enabled, and: Text resizing is set to auto height or + /// auto width"). + pub fn truncates(&self) -> bool { + match self.auto_resize { + TextAutoResize::Truncate => true, + TextAutoResize::Height | TextAutoResize::WidthAndHeight => self.max_lines > 0, + TextAutoResize::None => false, + } + } + + /// The resize mode once the box is given an explicit `width` and/or `height`: an explicit + /// height fixes the box, an explicit width turns auto width into auto height, and truncating + /// text keeps truncating. + pub fn fix_size(&mut self, width: bool, height: bool) { + self.auto_resize = match self.auto_resize { + _ if height && self.truncates() => TextAutoResize::Truncate, + _ if height => TextAutoResize::None, + TextAutoResize::WidthAndHeight if width => TextAutoResize::Height, + mode => mode, + }; + } + + /// The style at char index `i`. + pub fn style_at(&self, i: usize) -> &TextStyle { + self.runs.iter().find(|r| r.start <= i && i < r.end).map(|r| &r.style).unwrap_or(&self.style) + } + + /// Spans of uniform style covering the whole text: (start, end, style). + pub fn spans(&self) -> Vec<(usize, usize, &TextStyle)> { + let n = self.char_count(); + let mut out = Vec::new(); + let mut pos = 0; + let mut runs: Vec<&StyleRun> = self.runs.iter().filter(|r| r.start < r.end && r.start < n).collect(); + runs.sort_by_key(|r| r.start); + for r in runs { + let start = r.start.max(pos); + let end = r.end.min(n); + if start >= end { + continue; + } + if start > pos { + out.push((pos, start, &self.style)); + } + out.push((start, end, &r.style)); + pos = end; + } + if pos < n || out.is_empty() { + out.push((pos, n, &self.style)); + } + out + } + + /// Apply `f` to the style of chars `start..end` (all text when the range is the whole text or + /// empty text), splitting runs as needed and merging equal neighbours. + pub fn restyle(&mut self, start: usize, end: usize, f: impl Fn(&mut TextStyle)) { + let n = self.char_count(); + let (start, end) = (start.min(n), end.min(n)); + if n == 0 || (start == 0 && end >= n) { + f(&mut self.style); + for r in &mut self.runs { + f(&mut r.style); + } + self.normalize(); + return; + } + if start >= end { + return; + } + // Expand to per-char styles, modify, re-compress. + let mut per: Vec = (0..n).map(|i| self.style_at(i).clone()).collect(); + for s in per.iter_mut().take(end).skip(start) { + f(s); + } + self.runs.clear(); + let mut i = 0; + while i < n { + let Some(s) = per.get(i) else { break }; + let mut j = i + 1; + while per.get(j).is_some_and(|t| t == s) { + j += 1; + } + if *s != self.style { + self.runs.push(StyleRun { start: i, end: j, style: s.clone() }); + } + i = j; + } + } + + /// Drop empty/out-of-range runs and runs equal to the base style, and merge touching equal runs. + pub fn normalize(&mut self) { + let n = self.char_count(); + let base = self.style.clone(); + self.runs.retain(|r| r.start < r.end && r.start < n && r.style != base); + for r in &mut self.runs { + r.end = r.end.min(n); + } + self.runs.sort_by_key(|r| r.start); + let mut merged: Vec = Vec::with_capacity(self.runs.len()); + for mut r in std::mem::take(&mut self.runs) { + // Runs never overlap: a later run starts where the previous one ends at the earliest. + if let Some(prev) = merged.last() { + r.start = r.start.max(prev.end); + if r.start >= r.end { + continue; + } + } + match merged.last_mut() { + Some(prev) if prev.end == r.start && prev.style == r.style => prev.end = r.end, + _ => merged.push(r), + } + } + self.runs = merged; + } + + /// Replace chars `start..end` with `text`, shifting runs (inserted text takes the style at `start`). + pub fn replace_range(&mut self, start: usize, end: usize, text: &str) { + let n = self.char_count(); + let (start, end) = (start.min(n), end.min(n).max(start.min(n))); + let byte = |i: usize| self.characters.char_indices().nth(i).map(|(b, _)| b).unwrap_or(self.characters.len()); + let (bs, be) = (byte(start), byte(end)); + if !self.paragraphs.is_empty() { + // Paragraphs merged by removed newlines take the first one's list; new ones copy it. + let p = self.para_of(start); + let removed = self.characters.get(bs..be).unwrap_or("").matches('\n').count(); + let added = text.matches('\n').count(); + self.fill_paragraphs(); + let at = self.para_list(p); + let tail = (p + 1).min(self.paragraphs.len()); + self.paragraphs.drain(tail..(p + 1 + removed).min(self.paragraphs.len())); + self.paragraphs.splice(tail..tail, std::iter::repeat_n(at, added)); + } + let mut s = String::with_capacity(self.characters.len() + text.len()); + s.push_str(self.characters.get(..bs).unwrap_or("")); + s.push_str(text); + s.push_str(self.characters.get(be..).unwrap_or("")); + self.characters = s; + let inserted = text.chars().count(); + let removed = end - start; + for r in &mut self.runs { + let shift = |p: usize| -> usize { + if p <= start { + p + } else if p >= end { + // Runs of a file that reach past the text (nothing clamps them on load) + // would otherwise overflow here; `normalize` below drops what is left. + p.saturating_sub(removed).saturating_add(inserted) + } else { + start + inserted + } + }; + // A run touching the insertion point at its end grows with the typed text. + let new_start = shift(r.start); + let new_end = if r.end == start && r.start < start { start + inserted } else { shift(r.end) }; + r.start = new_start; + r.end = new_end; + } + self.normalize(); + } + + pub fn paragraph_count(&self) -> usize { + self.characters.matches('\n').count() + 1 + } + + /// The paragraph char index `i` is in. + pub fn para_of(&self, i: usize) -> usize { + self.characters.chars().take(i).filter(|c| *c == '\n').count() + } + + /// Paragraph `p`'s list settings. + pub fn para_list(&self, p: usize) -> ParaList { + self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, ..ParaList::default() }) + } + + /// Spell out every paragraph's list settings in `paragraphs` (and drop extra entries). + fn fill_paragraphs(&mut self) { + let n = self.paragraph_count(); + let fill = ParaList { list: self.list, ..ParaList::default() }; + self.paragraphs.resize(n, fill); + } + + /// The paragraphs chars `start..end` touch (a caret: its paragraph; a selection ending just + /// after a newline leaves the next paragraph out). + pub fn para_span(&self, start: usize, end: usize) -> (usize, usize) { + let (s, e) = (start.min(end), start.max(end)); + let (a, b) = (self.para_of(s), self.para_of(e)); + let ends_at_start = e > s && b > a && self.characters.chars().nth(e - 1) == Some('\n'); + (a, if ends_at_start { b - 1 } else { b }) + } + + /// Apply `f` to the list settings of the paragraphs chars `start..end` touch (plain + /// paragraphs stay at level 0, only numbered ones keep a start, levels stop at + /// [`MAX_LIST_INDENT`]); one style for all with no nesting or start folds back into `list`. + pub fn set_para_lists(&mut self, start: usize, end: usize, f: impl Fn(&mut ParaList)) { + self.set_para_lists_with(start, end, false, f); + } + + /// Take the list items among the paragraphs chars `start..end` touch out of their list at the + /// level they had, as Backspace or Delete at an item's start does: "delete the counter, but + /// keep the same level of indentation" (Create bulleted and numbered lists). + pub fn unlist_keeping_levels(&mut self, start: usize, end: usize) { + self.set_para_lists_with(start, end, true, |pl| pl.list = ListStyle::None); + } + + fn set_para_lists_with(&mut self, start: usize, end: usize, keep_levels: bool, f: impl Fn(&mut ParaList)) { + let (a, b) = self.para_span(start, end); + self.set_para_lists_between(a, b, keep_levels, f); + } + + /// As [`set_para_lists`](Self::set_para_lists), for paragraphs `a..=b` addressed directly. + fn set_para_lists_between(&mut self, a: usize, b: usize, keep_levels: bool, f: impl Fn(&mut ParaList)) { + self.fill_paragraphs(); + for pl in self.paragraphs.get_mut(a..=b).unwrap_or(&mut []) { + f(pl); + if pl.list == ListStyle::None && !keep_levels { + pl.indent = 0; + } + if pl.list != ListStyle::Numbered { + pl.start_offset = 0; + } + pl.indent = pl.indent.min(MAX_LIST_INDENT); + } + if let Some(first) = self.paragraphs.first().copied() + && first == (ParaList { list: first.list, ..ParaList::default() }) + && self.paragraphs.iter().all(|p| *p == first) + { + self.list = first.list; + self.paragraphs.clear(); + } + } + + /// The list style and level shared by the paragraphs chars `start..end` touch (the first + /// one's settings), or None when they differ. Start numbers don't count. + pub fn common_para_list(&self, start: usize, end: usize) -> Option { + let (a, b) = self.para_span(start, end); + let first = self.para_list(a); + (a..=b).all(|p| self.para_list(p).list == first.list && self.para_list(p).indent == first.indent).then_some(first) + } + + /// What a text style made from this text sets at the paragraph and layer level: the paragraph + /// spacing and indent, the list style (the one every paragraph shares; the base one when they + /// differ) and the OpenType features. + pub fn paragraph_style(&self) -> ParagraphStyle { + let list = match self.paragraphs.first() { + Some(first) if self.paragraphs.iter().all(|p| p.list == first.list) => first.list, + _ => self.list, + }; + ParagraphStyle { paragraph_spacing: self.paragraph_spacing, paragraph_indent: self.paragraph_indent, list, features: self.features.clone() } + } + + /// Take what text style `ps` sets at the paragraph and layer level: the spacing, indent and + /// features on the layer, the list style on every paragraph. + pub fn take_paragraph_style(&mut self, ps: &ParagraphStyle) { + self.paragraph_spacing = ps.paragraph_spacing; + self.paragraph_indent = ps.paragraph_indent; + self.features.clone_from(&ps.features); + // Every paragraph by index: `para_span` leaves out an empty one after a final newline. + let last = self.paragraph_count() - 1; + self.set_para_lists_between(0, last, false, |pl| pl.list = ps.list); + } + + /// As [`take_paragraph_style`](Self::take_paragraph_style), with the list style going to the + /// paragraphs chars `start..end` touch only; the spacing, indent and features are the + /// layer's, as in Figma. + pub fn take_paragraph_style_in(&mut self, start: usize, end: usize, ps: &ParagraphStyle) { + self.paragraph_spacing = ps.paragraph_spacing; + self.paragraph_indent = ps.paragraph_indent; + self.features.clone_from(&ps.features); + self.set_para_lists(start, end, |pl| pl.list = ps.list); + } + + /// Follow text style `id`'s paragraph-level definition wherever chars link to it. + pub fn update_paragraph_style(&mut self, id: &str, ps: &ParagraphStyle) { + let linked: Vec<(usize, usize)> = + self.spans().into_iter().filter(|(_, _, s)| s.style_id.as_deref() == Some(id)).map(|(a, b, _)| (a, b)).collect(); + for (a, b) in linked { + self.take_paragraph_style_in(a, b, ps); + } + } + + /// Whether chars `start..end` show what text style `ps` sets at the paragraph and layer + /// level: the layer's spacing, indent and features, and their paragraphs' list style. + pub fn shows_paragraph_style_in(&self, start: usize, end: usize, ps: &ParagraphStyle) -> bool { + self.paragraph_spacing == ps.paragraph_spacing + && self.paragraph_indent == ps.paragraph_indent + && self.features == ps.features + && self.common_para_list(start, end).is_some_and(|pl| pl.list == ps.list) + } + + /// Whether the whole text shows what text style `ps` sets at the paragraph and layer level. + pub fn shows_paragraph_style(&self, ps: &ParagraphStyle) -> bool { + self.shows_paragraph_style_in(0, self.char_count(), ps) + } + + /// Link chars `start..end` (all of them when the range is empty or the whole text) to text + /// style `id`, defined as `def`. + pub fn link_text_style(&mut self, start: usize, end: usize, def: &TextStyle, id: &str) { + let (start, end) = if start == end { (0, self.char_count()) } else { (start, end) }; + self.restyle(start, end, |s| s.take_text_style(def, id)); + } + + /// Unlink chars `start..end` (all when empty or whole) from their text styles; values stay. + pub fn unlink_text_style(&mut self, start: usize, end: usize) { + let (start, end) = if start == end { (0, self.char_count()) } else { (start, end) }; + self.restyle(start, end, |s| s.style_id = None); + } + + /// The text style every char is linked to: `Some(None)` when none is, None when they differ. + pub fn text_style_link(&self) -> Option> { + let mut spans = self.spans().into_iter().map(|(_, _, s)| s.style_id.as_deref()); + let first = spans.next()?; + spans.all(|s| s == first).then_some(first) + } + + /// Apply `f` to every distinct style (the base and each run) and merge runs that became equal. + pub(crate) fn each_style(&mut self, mut f: impl FnMut(&mut TextStyle)) { + f(&mut self.style); + for r in &mut self.runs { + f(&mut r.style); + } + self.normalize(); + } + + /// Whether any char links to text style `id`. + pub fn links_text_style(&self, id: &str) -> bool { + std::iter::once(&self.style).chain(self.runs.iter().map(|r| &r.style)).any(|s| s.style_id.as_deref() == Some(id)) + } + + /// Follow text style `id`'s new definition wherever chars link to it. + pub fn update_text_style(&mut self, id: &str, def: &TextStyle) { + self.each_style(|s| { + if s.style_id.as_deref() == Some(id) { + s.take_text_style(def, id); + } + }); + } + + /// Point text style links elsewhere: `f(id)` gives the new id, None unlinks (values stay). + pub fn map_text_styles(&mut self, f: impl Fn(&str) -> Option) { + self.each_style(|s| s.style_id = s.style_id.as_deref().and_then(&f)); + } + + /// Unlink chars whose style is gone (`def` gives None) or that no longer show it (edited since). + pub fn check_text_styles<'a>(&mut self, def: impl Fn(&str) -> Option<&'a TextStyle>) { + self.each_style(|s| { + if s.style_id.as_deref().is_some_and(|id| !def(id).is_some_and(|d| s.shows_text_style(d))) { + s.style_id = None; + } + }); + } + + /// Whether every char shares one style (the panel shows "Mixed" otherwise). + pub fn is_uniform(&self) -> bool { + self.runs.is_empty() + } +} + +#[cfg(test)] +mod case_tests { + use super::*; + + #[test] + fn trim_num_prints_huge_whole_numbers() { + assert_eq!(trim_num(1e19), "10000000000000000000"); + assert_eq!(trim_num(-1e19), "-10000000000000000000"); + assert_eq!(trim_num(12.0), "12"); + let v = 12345678901234567890.0_f64; + assert_eq!(trim_num(v).parse::().unwrap(), v); + } + + #[test] + fn title_case_respects_context() { + assert_eq!(TextCase::Title.apply("hello wORLD don't 3d x-ray"), "Hello WORLD Don't 3d X-Ray"); + // Continuing a word from a previous run: no capital. + assert_eq!(TextCase::Title.apply_after("lo world", Some('l')), "lo World"); + assert_eq!(TextCase::Title.apply_after("lo", Some(' ')), "Lo"); + assert_eq!(TextCase::Upper.apply_after("ß", Some('a')), "SS"); + assert!(valid_feature_tag("liga") && valid_feature_tag("ss01")); + assert!(!valid_feature_tag("lig") && !valid_feature_tag("ligatures") && !valid_feature_tag("li\u{e9}")); + } + + #[test] + fn new_text_starts_in_the_builds_default_font() { + // Inter, unless the build names another with `FIGCRAFT_DEFAULT_FONT` (empty means Inter). + let named = option_env!("FIGCRAFT_DEFAULT_FONT").filter(|f| !f.is_empty()); + assert_eq!(DEFAULT_FONT, named.unwrap_or("Inter")); + let st = TextStyle::default(); + assert_eq!(st.font_family, DEFAULT_FONT); + // The family is always written, so a saved file keeps it in a build with another default. + let saved = serde_json::to_value(&st).unwrap(); + assert_eq!(saved["font_family"], DEFAULT_FONT); + } + + #[test] + fn variations_are_backward_compatible_and_imply_weight() { + // Files written before variable fonts load with no custom axes, and stay byte-identical. + let old = r#"{"font_family":"Inter","font_style":"Bold","font_size":12.0}"#; + let st: TextStyle = serde_json::from_str(old).unwrap(); + assert!(st.variations.is_empty()); + assert!(!serde_json::to_string(&st).unwrap().contains("variations")); + let mut st = TextStyle { font_style: "Regular".into(), ..Default::default() }; + st.variations.insert("wght".into(), 640.0); + st.variations.insert("wdth".into(), 80.0); + assert_eq!(st.weight(), 640); + assert!(!st.italic()); + st.variations.insert("slnt".into(), -8.0); + assert!(st.italic()); + let round: TextStyle = serde_json::from_str(&serde_json::to_string(&st).unwrap()).unwrap(); + assert_eq!(round, st); + // Picking a named style drops the axes its name implies, keeps the others. + st.set_font_style("Bold"); + assert_eq!(st.weight(), 700); + assert_eq!(st.variations.keys().collect::>(), ["wdth"]); + assert!(valid_axis_tag("wght") && !valid_axis_tag("weight")); + } + + #[test] + fn list_starts_load_from_old_files_and_follow_edits() { + use ListStyle::{Bulleted as B, None as P, Numbered as N}; + // Files written before start numbers load with none, and write the same bytes. + let old = r#"{"list":"numbered","indent":1}"#; + let pl: ParaList = serde_json::from_str(old).unwrap(); + assert_eq!(pl, ParaList { list: N, indent: 1, start_offset: 0 }); + assert_eq!(serde_json::to_string(&pl).unwrap(), old); + // One numbered paragraph with a start keeps it (it doesn't fold into `list`). + let mut t = TextData { characters: "Next".into(), ..Default::default() }; + t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start_offset: 1 }); + assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start_offset: 1 }); + // Enter copies it to the new item; different starts don't make the list mixed. + t.replace_range(4, 4, "\nMore"); + assert_eq!(t.para_list(1).start_offset, 1); + t.set_para_lists(5, 5, |p| p.start_offset = 0); + assert!(t.common_para_list(0, t.char_count()).is_some_and(|p| p.list == N)); + // Bullets have no number, so no start. + t.set_para_lists(0, 0, |p| *p = ParaList { list: B, indent: 1, start_offset: 4 }); + assert_eq!(t.para_list(0), ParaList { list: B, indent: 1, start_offset: 0 }); + // A plain paragraph has none. + t.set_para_lists(0, 0, |p| p.list = P); + assert_eq!(t.para_list(0), ParaList::default()); + } +} + +#[cfg(test)] +mod title_case_tests { + use super::TextCase; + + #[test] + fn title_case_after_opening_apostrophe() { + assert_eq!(TextCase::Title.apply("'hello world'"), "'Hello World'"); + assert_eq!(TextCase::Title.apply("say 'hi'"), "Say 'Hi'"); + assert_eq!(TextCase::Title.apply("don't"), "Don't"); + assert_eq!(TextCase::Title.apply("\u{2019}hello"), "\u{2019}Hello"); + } + + #[test] + fn title_case_between_runs_sees_char_before_apostrophe() { + assert_eq!(TextCase::Title.apply_after("t", Some('\'')), "t"); + assert_eq!(TextCase::Title.apply_between("hello", None, Some('\'')), "Hello"); + assert_eq!(TextCase::Title.apply_between("t", Some('n'), Some('\'')), "t"); + assert_eq!(TextCase::Title.apply_between("t", Some(' '), Some('\u{2019}')), "T"); + } + + #[test] + fn title_case_combining_marks_do_not_break_words() { + assert_eq!(TextCase::Title.apply("re\u{301}sume\u{301}"), "Re\u{301}sume\u{301}"); + assert_eq!(TextCase::Title.apply("re\u{301}sume\u{301} bon"), "Re\u{301}sume\u{301} Bon"); + assert_eq!(TextCase::Title.apply("a \u{301}b"), "A \u{301}B"); + assert_eq!(TextCase::Title.apply("cafe\u{301}'s"), "Cafe\u{301}'s"); + } +} + +#[cfg(test)] +mod whole_text_paragraph_style_tests { + use super::*; + + #[test] + fn a_text_style_on_the_whole_layer_reaches_a_trailing_empty_paragraph() { + let ps = ParagraphStyle { list: ListStyle::Bulleted, ..Default::default() }; + let mut t = TextData::new("a\nb\n"); + t.take_paragraph_style(&ps); + assert_eq!(t.paragraph_count(), 3); + assert_eq!(t.para_list(2).list, ListStyle::Bulleted); + assert_eq!(t.list, ListStyle::Bulleted); + assert!(t.paragraphs.is_empty()); + let mut plain = TextData::new("a\nb"); + plain.take_paragraph_style(&ps); + assert_eq!((plain.list, plain.paragraphs.len()), (t.list, t.paragraphs.len())); + } +} From 9e356096e382abc60f0f26e1d518c9affa4f3cd1 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Thu, 5 Nov 2026 17:22:53 -0500 Subject: [PATCH 16/18] text.rs keeps its LF line endings The previous commit saved the file with CRLF endings; only the 30 changed lines are a real change. Co-Authored-By: Claude Fable 5.1 --- crates/doc/src/text.rs | 2274 ++++++++++++++++++++-------------------- 1 file changed, 1137 insertions(+), 1137 deletions(-) diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index f4d087589..7183efbf6 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -1,1137 +1,1137 @@ -//! Text node data: characters plus a base style and styled runs. - -use figcraft_color::{Color, Paint}; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -#[serde(tag = "unit", content = "value", rename_all = "camelCase")] -#[derive(Default)] -pub enum LineHeight { - #[default] - Auto, - Pixels(f64), - Percent(f64), -} - -impl LineHeight { - /// Resolved line height in px for `font_size` (`auto` uses the font's natural ratio). - pub fn resolve(self, font_size: f64, natural_ratio: f64) -> f64 { - match self { - LineHeight::Auto => font_size * natural_ratio, - LineHeight::Pixels(v) => v.max(0.0), - LineHeight::Percent(p) => font_size * p.max(0.0) / 100.0, - } - } - pub fn label(self) -> String { - match self { - LineHeight::Auto => "Auto".into(), - LineHeight::Pixels(v) => trim_num(v), - LineHeight::Percent(p) => format!("{}%", trim_num(p)), - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -#[serde(tag = "unit", content = "value", rename_all = "camelCase")] -pub enum LetterSpacing { - Pixels(f64), - Percent(f64), -} - -impl Default for LetterSpacing { - fn default() -> Self { - LetterSpacing::Percent(0.0) - } -} - -impl LetterSpacing { - pub fn resolve(self, font_size: f64) -> f64 { - match self { - LetterSpacing::Pixels(v) => v, - LetterSpacing::Percent(p) => font_size * p / 100.0, - } - } - pub fn label(self) -> String { - match self { - LetterSpacing::Pixels(v) => trim_num(v), - LetterSpacing::Percent(p) => format!("{}%", trim_num(p)), - } - } -} - -pub fn trim_num(v: f64) -> String { - if v.abs() >= 9.0e18 { - return format!("{v}"); - } - let r = (v * 100.0).round() / 100.0; - if r.fract().abs() < 1e-9 { format!("{}", r as i64) } else { format!("{r}") } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum TextDecoration { - #[default] - None, - Underline, - Strikethrough, -} - -/// How an underline or strikethrough is drawn. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum DecorationStyle { - #[default] - Solid, - Dotted, - Wavy, -} - -/// A decoration length: `Auto` (the font's own value), pixels, or a percentage of the font size. -pub type DecorationLength = LineHeight; - -/// How a decoration line looks; the default is the font's own solid line in the text's colour. -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)] -#[serde(default)] -pub struct DecorationLook { - pub style: DecorationStyle, - pub thickness: DecorationLength, - /// Underlines: from the baseline down to the line's top edge (negative: above the baseline). - pub offset: DecorationLength, - /// Underlines break where descenders cross them. - pub skip_ink: bool, - /// The line's own colour (None: the text's fills). - pub color: Option, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum TextCase { - #[default] - Original, - Upper, - Lower, - Title, - SmallCaps, -} - -impl TextCase { - pub fn apply(self, s: &str) -> String { - self.apply_after(s, None) - } - - /// Apply the case to `s`, which follows `prev` in the text (so title case only capitalises - /// the first letter of each word even when a word spans several styled runs). An apostrophe - /// as `prev` is taken to continue a word. - pub fn apply_after(self, s: &str, prev: Option) -> String { - self.apply_impl(s, prev, prev.is_some_and(is_word_char)) - } - - /// Like [`apply_after`](Self::apply_after), also given the char `before` `prev`: an apostrophe - /// only continues a word when a letter or digit precedes it. - pub fn apply_between(self, s: &str, before: Option, prev: Option) -> String { - let continues = prev.is_some_and(|c| is_word_char(c) && (c.is_alphanumeric() || before.is_some_and(char::is_alphanumeric))); - self.apply_impl(s, prev, continues) - } - - /// `in_word` says whether `prev` continues a word (rather than a new word starting here). - fn apply_impl(self, s: &str, prev: Option, in_word: bool) -> String { - match self { - TextCase::Original | TextCase::SmallCaps => s.to_string(), - TextCase::Upper => s.to_uppercase(), - TextCase::Lower => s.to_lowercase(), - TextCase::Title => { - let mut out = String::with_capacity(s.len()); - let mut start = !in_word; - let mut last = prev; - for ch in s.chars() { - if start && ch.is_alphanumeric() { - out.extend(ch.to_uppercase()); - start = false; - } else { - out.push(ch); - // An apostrophe only continues a word right after a letter or digit. - let opening_apostrophe = - matches!(ch, '\'' | '\u{2019}') && !last.is_some_and(|c| c.is_alphanumeric() || is_combining_mark(c)); - // A combining mark attaches to the char before it and never changes word state. - if !is_combining_mark(ch) { - start = !is_word_char(ch) || opening_apostrophe; - } - } - last = Some(ch); - } - out - } - } - } - - /// Whether the char at a position preceded by `prev` starts a word (for title case). - pub fn starts_word(prev: Option) -> bool { - prev.is_none_or(|c| !is_word_char(c)) - } -} - -/// Letters, digits, combining marks and apostrophes continue a word (so "don't" stays "Don't" -/// and decomposed accents stay inside their word). -fn is_word_char(c: char) -> bool { - c.is_alphanumeric() || is_combining_mark(c) || matches!(c, '\'' | '\u{2019}') -} - -/// Combining marks such as the U+0301 accent in decomposed text: non-alphanumeric chars that -/// extend the grapheme of the char before them. -fn is_combining_mark(c: char) -> bool { - use unicode_segmentation::UnicodeSegmentation; - if c.is_ascii() || c.is_alphanumeric() { - return false; - } - let mut buf = [0u8; 8]; - buf[0] = b'a'; - let end = 1 + c.encode_utf8(&mut buf[1..]).len(); - std::str::from_utf8(&buf[..end]).is_ok_and(|s| s.graphemes(true).count() == 1) -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum TextAlignH { - #[default] - Left, - Center, - Right, - Justified, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum TextAlignV { - #[default] - Top, - Middle, - Bottom, -} - -/// How a text box resizes as its content changes. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum TextAutoResize { - /// Auto width: one line per paragraph, box grows both ways. - #[default] - WidthAndHeight, - /// Fixed width, height grows. - Height, - /// Fixed size. - None, - /// Fixed size, truncate with an ellipsis. - Truncate, -} - -/// Vertical trim: what bounds a text box vertically. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum LeadingTrim { - /// The full line height of the first and last lines. - #[default] - None, - /// From the first line's cap height to the last line's baseline. - CapHeight, -} - -/// Where lines break when text wraps to more than one line ("Wrap style controls where lines -/// break when text wraps to more than one line", Explore text properties). -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum WrapStyle { - /// "Figma fits as many words as possible on each line before wrapping to the next." - #[default] - Auto, - /// "Figma distributes text as evenly as possible across every line, so each line is close to - /// the same length." - Balance, - /// "Figma keeps each line full, but adjusts the last four lines to avoid an orphan -- a single - /// word stranded alone on the last line." - Pretty, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -pub enum ListStyle { - #[default] - None, - Bulleted, - Numbered, -} - -/// Deepest list nesting (levels 0..=MAX_LIST_INDENT). -pub const MAX_LIST_INDENT: u8 = 8; - -/// One paragraph's list settings. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(default)] -pub struct ParaList { - pub list: ListStyle, - /// Nesting level (0 = outermost). A plain paragraph is 0, except one that left its list by - /// Backspace or Delete at its start ([`TextData::unlist_keeping_levels`]): it keeps the level - /// it had and starts where that level's item text does ("delete the counter, but keep the - /// same level of indentation"). - pub indent: u8, - /// A numbered list that starts counting at this paragraph counts from `start_offset + 1` - /// (items that go on counting ignore it; other paragraphs are always 0). - #[serde(skip_serializing_if = "crate::node::is_default")] - pub start_offset: u32, -} - -/// Character style. In runs, every field is an override; `None` means "inherit the base". -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -#[serde(default)] -pub struct TextStyle { - pub font_family: String, - /// The style name, e.g. "Regular", "Bold", "Semi Bold Italic". - pub font_style: String, - pub font_size: f64, - pub line_height: LineHeight, - pub letter_spacing: LetterSpacing, - pub decoration: TextDecoration, - /// How the decoration looks (None: the default look; boxed to keep styles small). - #[serde(skip_serializing_if = "Option::is_none")] - pub decoration_look: Option>, - pub case: TextCase, - /// Run fills (None = node fills). - pub fills: Option>, - /// Hyperlink. - pub link: Option, - /// Variable font axis values in user space, keyed by 4-char OpenType axis tag (e.g. `wght`: - /// 550, `wdth`: 85). Empty = what the style name implies; axes the face lacks are ignored. - #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")] - pub variations: std::collections::BTreeMap, - /// The text style (shared style id) these chars are linked to. A layer's chars can link to - /// different styles; editing what a style sets unlinks the chars edited. - #[serde(skip_serializing_if = "Option::is_none")] - pub style_id: Option, -} - -/// What a text style sets at the paragraph and layer level, beside its character style -/// ([`TextStyle`]): paragraph spacing and indent, the list style and the OpenType features -/// ("Supported in text styles": "Paragraph spacing and indentation", "Lists", "OpenType -/// features", Create and apply text styles). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] -#[serde(default)] -pub struct ParagraphStyle { - #[serde(skip_serializing_if = "crate::node::is_default")] - pub paragraph_spacing: f64, - #[serde(skip_serializing_if = "crate::node::is_default")] - pub paragraph_indent: f64, - #[serde(skip_serializing_if = "crate::node::is_default")] - pub list: ListStyle, - #[serde(skip_serializing_if = "crate::node::is_default")] - pub features: std::collections::BTreeMap, -} - -/// The font new text starts in. A build can name another (`FIGCRAFT_DEFAULT_FONT="Noto Sans Thai"`, -/// for a distribution whose users mostly write a script Inter lacks); empty means Inter. -pub const DEFAULT_FONT: &str = match option_env!("FIGCRAFT_DEFAULT_FONT") { - Some(family) if !family.is_empty() => family, - _ => "Inter", -}; - -impl Default for TextStyle { - fn default() -> Self { - Self { - font_family: DEFAULT_FONT.into(), - font_style: "Regular".into(), - font_size: 12.0, - line_height: LineHeight::Auto, - letter_spacing: LetterSpacing::default(), - decoration: TextDecoration::None, - decoration_look: None, - case: TextCase::Original, - fills: None, - link: None, - variations: std::collections::BTreeMap::new(), - style_id: None, - } - } -} - -/// Axes a style name implies (weight, italic, slant): picking a named style clears their custom values. -pub const STYLE_AXES: [&str; 3] = ["wght", "ital", "slnt"]; - -impl TextStyle { - /// Numeric weight: a custom `wght` axis value, else the one implied by the style name. - pub fn weight(&self) -> u16 { - match self.variations.get("wght") { - Some(w) if w.is_finite() => w.round().clamp(1.0, 1000.0) as u16, - _ => style_weight(&self.font_style), - } - } - pub fn italic(&self) -> bool { - self.font_style.to_ascii_lowercase().contains("italic") - || self.variations.get("ital").is_some_and(|v| *v >= 0.5) - || self.variations.get("slnt").is_some_and(|v| *v != 0.0) - } - - /// Take what text style `def` sets (font, size, line height, letter spacing, decoration, case - /// and axis values) and link to it as `id`; fills and hyperlinks stay. - pub fn take_text_style(&mut self, def: &TextStyle, id: &str) { - *self = TextStyle { fills: self.fills.take(), link: self.link.take(), style_id: Some(id.to_string()), ..def.clone() }; - } - - /// Whether these chars show what text style `def` sets. - pub fn shows_text_style(&self, def: &TextStyle) -> bool { - self.font_family == def.font_family - && self.font_style == def.font_style - && self.font_size == def.font_size - && self.line_height == def.line_height - && self.letter_spacing == def.letter_spacing - && self.decoration == def.decoration - && self.decoration_look == def.decoration_look - && self.case == def.case - && self.variations == def.variations - } - - /// This style as a text style's definition (linked to nothing). - pub fn as_text_style(&self) -> TextStyle { - TextStyle { style_id: None, link: None, ..self.clone() } - } - - /// How the decoration looks. - pub fn look(&self) -> DecorationLook { - self.decoration_look.as_deref().copied().unwrap_or_default() - } - - /// Change how the decoration looks (the default look is stored as None). - pub fn set_look(&mut self, f: impl FnOnce(&mut DecorationLook)) { - let mut look = self.look(); - f(&mut look); - self.decoration_look = (look != DecorationLook::default()).then(|| Box::new(look)); - } - - /// Switch to the named style, dropping custom values of the axes the name implies. - pub fn set_font_style(&mut self, name: impl Into) { - self.font_style = name.into(); - for tag in STYLE_AXES { - self.variations.remove(tag); - } - } -} - -/// Weight from a style name ("Semi Bold Italic" → 600). -pub fn style_weight(style: &str) -> u16 { - let s = style.to_ascii_lowercase().replace([' ', '-'], ""); - // A weight number ("600", "600 Italic"), as some apps name styles. - let digits: String = s.chars().take_while(char::is_ascii_digit).collect(); - if let Ok(n) = digits.parse::() - && (1..=1000).contains(&n) - { - return n; - } - let table: [(&str, u16); 10] = [ - ("thin", 100), - ("extralight", 200), - ("ultralight", 200), - ("light", 300), - ("medium", 500), - ("semibold", 600), - ("demibold", 600), - ("extrabold", 800), - ("ultrabold", 800), - ("black", 900), - ]; - for (k, w) in table { - if s.contains(k) { - return w; - } - } - if s.contains("bold") { - return 700; - } - if s.contains("heavy") { - return 900; - } - 400 -} - -/// A styled range of characters (char indices, end exclusive). -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct StyleRun { - pub start: usize, - pub end: usize, - pub style: TextStyle, -} - -/// Text node content. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] -#[serde(default)] -pub struct TextData { - pub characters: String, - pub style: TextStyle, - /// Ranges whose style differs from `style`; full styles (already merged), non-overlapping, sorted. - #[serde(skip_serializing_if = "crate::node::is_default")] - pub runs: Vec, - #[serde(skip_serializing_if = "crate::node::is_default")] - pub auto_resize: TextAutoResize, - #[serde(skip_serializing_if = "crate::node::is_default")] - pub align_h: TextAlignH, - #[serde(skip_serializing_if = "crate::node::is_default")] - pub align_v: TextAlignV, - #[serde(skip_serializing_if = "crate::node::is_default")] - pub paragraph_spacing: f64, - #[serde(skip_serializing_if = "crate::node::is_default")] - pub paragraph_indent: f64, - /// The list style of paragraphs `paragraphs` doesn't cover (all of them when it's empty). - #[serde(skip_serializing_if = "crate::node::is_default")] - pub list: ListStyle, - /// Per-paragraph list settings, by paragraph index; empty when every paragraph is `list` at - /// level 0. Kept in step with the characters by [`TextData::replace_range`]. - #[serde(skip_serializing_if = "Vec::is_empty")] - pub paragraphs: Vec, - /// Space between two items of a list (px), in place of the paragraph spacing. - #[serde(skip_serializing_if = "crate::node::is_default")] - pub list_spacing: f64, - /// Opening and closing punctuation at a line's aligned edge sits outside the box. - #[serde(skip_serializing_if = "crate::node::is_default")] - pub hanging_punctuation: bool, - /// List bullets and numbers sit outside the box; item text lines up with plain paragraphs. - #[serde(skip_serializing_if = "crate::node::is_default")] - pub hanging_list: bool, - /// Vertical trim (`capHeight`: the box runs from the first line's cap height to the last - /// line's baseline). - #[serde(skip_serializing_if = "crate::node::is_default")] - pub leading_trim: LeadingTrim, - /// Max lines when truncating, or before an auto-height text ends in an ellipsis (0 = as many - /// as fit). - #[serde(skip_serializing_if = "crate::node::is_default")] - pub max_lines: u32, - /// Where lines break when the text wraps: greedily (Auto), as evenly as the words allow - /// (Balance), or full with no one word alone on the last line (Pretty). - #[serde(skip_serializing_if = "crate::node::is_default")] - pub wrap_style: WrapStyle, - /// OpenType feature toggles (e.g. "liga": false). - #[serde(skip_serializing_if = "crate::node::is_default")] - pub features: std::collections::BTreeMap, - /// The path the text follows (text on a path); `None`: lines in the box. Boxed to keep text - /// data small. - #[serde(skip_serializing_if = "Option::is_none")] - pub path: Option>, - /// Where the file this text came from wrapped its lines (an imported `.fig`'s saved layout). - /// Layout follows them only while the box width and everything that moves a line break are - /// as they were saved, so an edit that could change the wrap drops them. - #[serde(skip_serializing_if = "Option::is_none")] - pub saved_breaks: Option>, -} - -/// Line breaks a file saved with a text: the chars that start a wrapped line (not those after a -/// hard break), for the box width and the content ([`TextData::layout_key`]) they were made for. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] -#[serde(default)] -pub struct SavedBreaks { - pub starts: Vec, - pub width: f64, - pub for_text: u64, -} - -/// Text on a path: the baseline follows `d`. The text starts at `start` (aligned there by the -/// horizontal alignment: left starts, centre centres, right ends at it); glyphs past an open -/// path's ends are hidden, a closed path wraps them around. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] -#[serde(default)] -pub struct TextPath { - /// SVG path data in the layer's own space. - pub d: String, - /// Where the text starts along the path, 0..1 by length. - pub start: f64, - /// On the other side of the path, running the other way. - #[serde(skip_serializing_if = "std::ops::Not::not")] - pub flip: bool, -} - -impl TextPath { - /// A text path following `path` (layer space). - pub fn new(path: &figcraft_geom::BezPath) -> Self { - Self { d: path.to_svg(), start: 0.0, flip: false } - } - - /// The path (empty when `d` doesn't parse). - pub fn path(&self) -> figcraft_geom::BezPath { - figcraft_geom::BezPath::from_svg(&self.d).unwrap_or_default() - } - - /// The start offset clamped into 0..1 (NaN: 0). - pub fn start(&self) -> f64 { - if self.start.is_finite() { self.start.clamp(0.0, 1.0) } else { 0.0 } - } - - /// Scale the path by `sx`, `sy` (the layer was resized). - pub fn scale(&mut self, sx: f64, sy: f64) { - if sx.is_finite() && sy.is_finite() { - let mut p = self.path(); - p.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy)); - self.d = p.to_svg(); - } - } -} - -/// Whether `tag` is a valid OpenType feature tag (four printable ASCII chars). -pub fn valid_feature_tag(tag: &str) -> bool { - tag.len() == 4 && tag.bytes().all(|b| (0x20..0x7f).contains(&b)) -} - -/// Whether `tag` is a valid OpenType variation axis tag (same shape as a feature tag). -pub fn valid_axis_tag(tag: &str) -> bool { - valid_feature_tag(tag) -} - -impl TextData { - pub fn new(characters: impl Into) -> Self { - Self { characters: characters.into(), ..Default::default() } - } - - /// A stable hash of what decides where lines break: everything but paints, links, style links, - /// decorations, vertical settings and the saved breaks themselves (FNV-1a over the JSON, so it - /// stays the same across builds and can be saved). - pub fn layout_key(&self) -> u64 { - let clear = |s: &mut TextStyle| { - (s.fills, s.link, s.style_id, s.decoration, s.decoration_look) = (None, None, None, TextDecoration::None, None); - }; - let mut t = TextData { - align_v: TextAlignV::default(), - paragraph_spacing: 0.0, - list_spacing: 0.0, - leading_trim: LeadingTrim::default(), - max_lines: 0, - saved_breaks: None, - ..self.clone() - }; - clear(&mut t.style); - t.runs.iter_mut().for_each(|r| clear(&mut r.style)); - t.normalize(); - serde_json::to_vec(&t).unwrap_or_default().iter().fold(0xcbf2_9ce4_8422_2325, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3)) - } - - /// Keep `starts` (chars that start a wrapped line) as this text's saved line breaks in a box - /// `width` wide. - pub fn save_breaks(&mut self, starts: Vec, width: f64) { - self.saved_breaks = Some(Box::new(SavedBreaks { starts, width, for_text: self.layout_key() })); - } - - /// The saved line starts (empty: no wrapped line) while they still hold: the same box width and - /// layout content, and starts increasing inside the text. - pub fn saved_breaks_for(&self, width: f64) -> Option<&[usize]> { - let b = self.saved_breaks.as_deref()?; - let inside = b.starts.first().is_none_or(|s| *s > 0) && b.starts.last().is_none_or(|s| *s < self.char_count()); - let same_box = (b.width - width).abs() <= 1e-6 * width.abs().max(1.0); - (inside && same_box && b.starts.is_sorted_by(|a, b| a < b) && b.for_text == self.layout_key()).then_some(b.starts.as_slice()) - } - - /// How far text on a path paints past its path's box (glyphs stand on the path, lines after - /// the first run beside it): about one and a half of the largest size per line; 0 off a path. - pub fn path_reach(&self) -> f64 { - if self.path.is_none() { - return 0.0; - } - let lines = self.characters.lines().count().clamp(1, 10_000) as f64; - self.max_size() * 1.5 * lines - } - - /// How far vertically trimmed text paints past its box (descenders below the last baseline, - /// accents above the first cap height): half the largest size; 0 untrimmed. - pub fn trim_reach(&self) -> f64 { - if self.leading_trim == LeadingTrim::None { 0.0 } else { self.max_size() * 0.5 } - } - - /// The largest font size used (capped like layout caps it). - fn max_size(&self) -> f64 { - self.spans().iter().map(|(_, _, s)| s.font_size).filter(|v| v.is_finite()).fold(0.0, f64::max).min(5000.0) - } - - pub fn char_count(&self) -> usize { - self.characters.chars().count() - } - - /// Whether text that runs out of room ends in an ellipsis: a truncating box, or auto height - /// or auto width with a line limit (the box hugs up to `max_lines` lines: "Max lines is - /// available only if truncate text is enabled, and: Text resizing is set to auto height or - /// auto width"). - pub fn truncates(&self) -> bool { - match self.auto_resize { - TextAutoResize::Truncate => true, - TextAutoResize::Height | TextAutoResize::WidthAndHeight => self.max_lines > 0, - TextAutoResize::None => false, - } - } - - /// The resize mode once the box is given an explicit `width` and/or `height`: an explicit - /// height fixes the box, an explicit width turns auto width into auto height, and truncating - /// text keeps truncating. - pub fn fix_size(&mut self, width: bool, height: bool) { - self.auto_resize = match self.auto_resize { - _ if height && self.truncates() => TextAutoResize::Truncate, - _ if height => TextAutoResize::None, - TextAutoResize::WidthAndHeight if width => TextAutoResize::Height, - mode => mode, - }; - } - - /// The style at char index `i`. - pub fn style_at(&self, i: usize) -> &TextStyle { - self.runs.iter().find(|r| r.start <= i && i < r.end).map(|r| &r.style).unwrap_or(&self.style) - } - - /// Spans of uniform style covering the whole text: (start, end, style). - pub fn spans(&self) -> Vec<(usize, usize, &TextStyle)> { - let n = self.char_count(); - let mut out = Vec::new(); - let mut pos = 0; - let mut runs: Vec<&StyleRun> = self.runs.iter().filter(|r| r.start < r.end && r.start < n).collect(); - runs.sort_by_key(|r| r.start); - for r in runs { - let start = r.start.max(pos); - let end = r.end.min(n); - if start >= end { - continue; - } - if start > pos { - out.push((pos, start, &self.style)); - } - out.push((start, end, &r.style)); - pos = end; - } - if pos < n || out.is_empty() { - out.push((pos, n, &self.style)); - } - out - } - - /// Apply `f` to the style of chars `start..end` (all text when the range is the whole text or - /// empty text), splitting runs as needed and merging equal neighbours. - pub fn restyle(&mut self, start: usize, end: usize, f: impl Fn(&mut TextStyle)) { - let n = self.char_count(); - let (start, end) = (start.min(n), end.min(n)); - if n == 0 || (start == 0 && end >= n) { - f(&mut self.style); - for r in &mut self.runs { - f(&mut r.style); - } - self.normalize(); - return; - } - if start >= end { - return; - } - // Expand to per-char styles, modify, re-compress. - let mut per: Vec = (0..n).map(|i| self.style_at(i).clone()).collect(); - for s in per.iter_mut().take(end).skip(start) { - f(s); - } - self.runs.clear(); - let mut i = 0; - while i < n { - let Some(s) = per.get(i) else { break }; - let mut j = i + 1; - while per.get(j).is_some_and(|t| t == s) { - j += 1; - } - if *s != self.style { - self.runs.push(StyleRun { start: i, end: j, style: s.clone() }); - } - i = j; - } - } - - /// Drop empty/out-of-range runs and runs equal to the base style, and merge touching equal runs. - pub fn normalize(&mut self) { - let n = self.char_count(); - let base = self.style.clone(); - self.runs.retain(|r| r.start < r.end && r.start < n && r.style != base); - for r in &mut self.runs { - r.end = r.end.min(n); - } - self.runs.sort_by_key(|r| r.start); - let mut merged: Vec = Vec::with_capacity(self.runs.len()); - for mut r in std::mem::take(&mut self.runs) { - // Runs never overlap: a later run starts where the previous one ends at the earliest. - if let Some(prev) = merged.last() { - r.start = r.start.max(prev.end); - if r.start >= r.end { - continue; - } - } - match merged.last_mut() { - Some(prev) if prev.end == r.start && prev.style == r.style => prev.end = r.end, - _ => merged.push(r), - } - } - self.runs = merged; - } - - /// Replace chars `start..end` with `text`, shifting runs (inserted text takes the style at `start`). - pub fn replace_range(&mut self, start: usize, end: usize, text: &str) { - let n = self.char_count(); - let (start, end) = (start.min(n), end.min(n).max(start.min(n))); - let byte = |i: usize| self.characters.char_indices().nth(i).map(|(b, _)| b).unwrap_or(self.characters.len()); - let (bs, be) = (byte(start), byte(end)); - if !self.paragraphs.is_empty() { - // Paragraphs merged by removed newlines take the first one's list; new ones copy it. - let p = self.para_of(start); - let removed = self.characters.get(bs..be).unwrap_or("").matches('\n').count(); - let added = text.matches('\n').count(); - self.fill_paragraphs(); - let at = self.para_list(p); - let tail = (p + 1).min(self.paragraphs.len()); - self.paragraphs.drain(tail..(p + 1 + removed).min(self.paragraphs.len())); - self.paragraphs.splice(tail..tail, std::iter::repeat_n(at, added)); - } - let mut s = String::with_capacity(self.characters.len() + text.len()); - s.push_str(self.characters.get(..bs).unwrap_or("")); - s.push_str(text); - s.push_str(self.characters.get(be..).unwrap_or("")); - self.characters = s; - let inserted = text.chars().count(); - let removed = end - start; - for r in &mut self.runs { - let shift = |p: usize| -> usize { - if p <= start { - p - } else if p >= end { - // Runs of a file that reach past the text (nothing clamps them on load) - // would otherwise overflow here; `normalize` below drops what is left. - p.saturating_sub(removed).saturating_add(inserted) - } else { - start + inserted - } - }; - // A run touching the insertion point at its end grows with the typed text. - let new_start = shift(r.start); - let new_end = if r.end == start && r.start < start { start + inserted } else { shift(r.end) }; - r.start = new_start; - r.end = new_end; - } - self.normalize(); - } - - pub fn paragraph_count(&self) -> usize { - self.characters.matches('\n').count() + 1 - } - - /// The paragraph char index `i` is in. - pub fn para_of(&self, i: usize) -> usize { - self.characters.chars().take(i).filter(|c| *c == '\n').count() - } - - /// Paragraph `p`'s list settings. - pub fn para_list(&self, p: usize) -> ParaList { - self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, ..ParaList::default() }) - } - - /// Spell out every paragraph's list settings in `paragraphs` (and drop extra entries). - fn fill_paragraphs(&mut self) { - let n = self.paragraph_count(); - let fill = ParaList { list: self.list, ..ParaList::default() }; - self.paragraphs.resize(n, fill); - } - - /// The paragraphs chars `start..end` touch (a caret: its paragraph; a selection ending just - /// after a newline leaves the next paragraph out). - pub fn para_span(&self, start: usize, end: usize) -> (usize, usize) { - let (s, e) = (start.min(end), start.max(end)); - let (a, b) = (self.para_of(s), self.para_of(e)); - let ends_at_start = e > s && b > a && self.characters.chars().nth(e - 1) == Some('\n'); - (a, if ends_at_start { b - 1 } else { b }) - } - - /// Apply `f` to the list settings of the paragraphs chars `start..end` touch (plain - /// paragraphs stay at level 0, only numbered ones keep a start, levels stop at - /// [`MAX_LIST_INDENT`]); one style for all with no nesting or start folds back into `list`. - pub fn set_para_lists(&mut self, start: usize, end: usize, f: impl Fn(&mut ParaList)) { - self.set_para_lists_with(start, end, false, f); - } - - /// Take the list items among the paragraphs chars `start..end` touch out of their list at the - /// level they had, as Backspace or Delete at an item's start does: "delete the counter, but - /// keep the same level of indentation" (Create bulleted and numbered lists). - pub fn unlist_keeping_levels(&mut self, start: usize, end: usize) { - self.set_para_lists_with(start, end, true, |pl| pl.list = ListStyle::None); - } - - fn set_para_lists_with(&mut self, start: usize, end: usize, keep_levels: bool, f: impl Fn(&mut ParaList)) { - let (a, b) = self.para_span(start, end); - self.set_para_lists_between(a, b, keep_levels, f); - } - - /// As [`set_para_lists`](Self::set_para_lists), for paragraphs `a..=b` addressed directly. - fn set_para_lists_between(&mut self, a: usize, b: usize, keep_levels: bool, f: impl Fn(&mut ParaList)) { - self.fill_paragraphs(); - for pl in self.paragraphs.get_mut(a..=b).unwrap_or(&mut []) { - f(pl); - if pl.list == ListStyle::None && !keep_levels { - pl.indent = 0; - } - if pl.list != ListStyle::Numbered { - pl.start_offset = 0; - } - pl.indent = pl.indent.min(MAX_LIST_INDENT); - } - if let Some(first) = self.paragraphs.first().copied() - && first == (ParaList { list: first.list, ..ParaList::default() }) - && self.paragraphs.iter().all(|p| *p == first) - { - self.list = first.list; - self.paragraphs.clear(); - } - } - - /// The list style and level shared by the paragraphs chars `start..end` touch (the first - /// one's settings), or None when they differ. Start numbers don't count. - pub fn common_para_list(&self, start: usize, end: usize) -> Option { - let (a, b) = self.para_span(start, end); - let first = self.para_list(a); - (a..=b).all(|p| self.para_list(p).list == first.list && self.para_list(p).indent == first.indent).then_some(first) - } - - /// What a text style made from this text sets at the paragraph and layer level: the paragraph - /// spacing and indent, the list style (the one every paragraph shares; the base one when they - /// differ) and the OpenType features. - pub fn paragraph_style(&self) -> ParagraphStyle { - let list = match self.paragraphs.first() { - Some(first) if self.paragraphs.iter().all(|p| p.list == first.list) => first.list, - _ => self.list, - }; - ParagraphStyle { paragraph_spacing: self.paragraph_spacing, paragraph_indent: self.paragraph_indent, list, features: self.features.clone() } - } - - /// Take what text style `ps` sets at the paragraph and layer level: the spacing, indent and - /// features on the layer, the list style on every paragraph. - pub fn take_paragraph_style(&mut self, ps: &ParagraphStyle) { - self.paragraph_spacing = ps.paragraph_spacing; - self.paragraph_indent = ps.paragraph_indent; - self.features.clone_from(&ps.features); - // Every paragraph by index: `para_span` leaves out an empty one after a final newline. - let last = self.paragraph_count() - 1; - self.set_para_lists_between(0, last, false, |pl| pl.list = ps.list); - } - - /// As [`take_paragraph_style`](Self::take_paragraph_style), with the list style going to the - /// paragraphs chars `start..end` touch only; the spacing, indent and features are the - /// layer's, as in Figma. - pub fn take_paragraph_style_in(&mut self, start: usize, end: usize, ps: &ParagraphStyle) { - self.paragraph_spacing = ps.paragraph_spacing; - self.paragraph_indent = ps.paragraph_indent; - self.features.clone_from(&ps.features); - self.set_para_lists(start, end, |pl| pl.list = ps.list); - } - - /// Follow text style `id`'s paragraph-level definition wherever chars link to it. - pub fn update_paragraph_style(&mut self, id: &str, ps: &ParagraphStyle) { - let linked: Vec<(usize, usize)> = - self.spans().into_iter().filter(|(_, _, s)| s.style_id.as_deref() == Some(id)).map(|(a, b, _)| (a, b)).collect(); - for (a, b) in linked { - self.take_paragraph_style_in(a, b, ps); - } - } - - /// Whether chars `start..end` show what text style `ps` sets at the paragraph and layer - /// level: the layer's spacing, indent and features, and their paragraphs' list style. - pub fn shows_paragraph_style_in(&self, start: usize, end: usize, ps: &ParagraphStyle) -> bool { - self.paragraph_spacing == ps.paragraph_spacing - && self.paragraph_indent == ps.paragraph_indent - && self.features == ps.features - && self.common_para_list(start, end).is_some_and(|pl| pl.list == ps.list) - } - - /// Whether the whole text shows what text style `ps` sets at the paragraph and layer level. - pub fn shows_paragraph_style(&self, ps: &ParagraphStyle) -> bool { - self.shows_paragraph_style_in(0, self.char_count(), ps) - } - - /// Link chars `start..end` (all of them when the range is empty or the whole text) to text - /// style `id`, defined as `def`. - pub fn link_text_style(&mut self, start: usize, end: usize, def: &TextStyle, id: &str) { - let (start, end) = if start == end { (0, self.char_count()) } else { (start, end) }; - self.restyle(start, end, |s| s.take_text_style(def, id)); - } - - /// Unlink chars `start..end` (all when empty or whole) from their text styles; values stay. - pub fn unlink_text_style(&mut self, start: usize, end: usize) { - let (start, end) = if start == end { (0, self.char_count()) } else { (start, end) }; - self.restyle(start, end, |s| s.style_id = None); - } - - /// The text style every char is linked to: `Some(None)` when none is, None when they differ. - pub fn text_style_link(&self) -> Option> { - let mut spans = self.spans().into_iter().map(|(_, _, s)| s.style_id.as_deref()); - let first = spans.next()?; - spans.all(|s| s == first).then_some(first) - } - - /// Apply `f` to every distinct style (the base and each run) and merge runs that became equal. - pub(crate) fn each_style(&mut self, mut f: impl FnMut(&mut TextStyle)) { - f(&mut self.style); - for r in &mut self.runs { - f(&mut r.style); - } - self.normalize(); - } - - /// Whether any char links to text style `id`. - pub fn links_text_style(&self, id: &str) -> bool { - std::iter::once(&self.style).chain(self.runs.iter().map(|r| &r.style)).any(|s| s.style_id.as_deref() == Some(id)) - } - - /// Follow text style `id`'s new definition wherever chars link to it. - pub fn update_text_style(&mut self, id: &str, def: &TextStyle) { - self.each_style(|s| { - if s.style_id.as_deref() == Some(id) { - s.take_text_style(def, id); - } - }); - } - - /// Point text style links elsewhere: `f(id)` gives the new id, None unlinks (values stay). - pub fn map_text_styles(&mut self, f: impl Fn(&str) -> Option) { - self.each_style(|s| s.style_id = s.style_id.as_deref().and_then(&f)); - } - - /// Unlink chars whose style is gone (`def` gives None) or that no longer show it (edited since). - pub fn check_text_styles<'a>(&mut self, def: impl Fn(&str) -> Option<&'a TextStyle>) { - self.each_style(|s| { - if s.style_id.as_deref().is_some_and(|id| !def(id).is_some_and(|d| s.shows_text_style(d))) { - s.style_id = None; - } - }); - } - - /// Whether every char shares one style (the panel shows "Mixed" otherwise). - pub fn is_uniform(&self) -> bool { - self.runs.is_empty() - } -} - -#[cfg(test)] -mod case_tests { - use super::*; - - #[test] - fn trim_num_prints_huge_whole_numbers() { - assert_eq!(trim_num(1e19), "10000000000000000000"); - assert_eq!(trim_num(-1e19), "-10000000000000000000"); - assert_eq!(trim_num(12.0), "12"); - let v = 12345678901234567890.0_f64; - assert_eq!(trim_num(v).parse::().unwrap(), v); - } - - #[test] - fn title_case_respects_context() { - assert_eq!(TextCase::Title.apply("hello wORLD don't 3d x-ray"), "Hello WORLD Don't 3d X-Ray"); - // Continuing a word from a previous run: no capital. - assert_eq!(TextCase::Title.apply_after("lo world", Some('l')), "lo World"); - assert_eq!(TextCase::Title.apply_after("lo", Some(' ')), "Lo"); - assert_eq!(TextCase::Upper.apply_after("ß", Some('a')), "SS"); - assert!(valid_feature_tag("liga") && valid_feature_tag("ss01")); - assert!(!valid_feature_tag("lig") && !valid_feature_tag("ligatures") && !valid_feature_tag("li\u{e9}")); - } - - #[test] - fn new_text_starts_in_the_builds_default_font() { - // Inter, unless the build names another with `FIGCRAFT_DEFAULT_FONT` (empty means Inter). - let named = option_env!("FIGCRAFT_DEFAULT_FONT").filter(|f| !f.is_empty()); - assert_eq!(DEFAULT_FONT, named.unwrap_or("Inter")); - let st = TextStyle::default(); - assert_eq!(st.font_family, DEFAULT_FONT); - // The family is always written, so a saved file keeps it in a build with another default. - let saved = serde_json::to_value(&st).unwrap(); - assert_eq!(saved["font_family"], DEFAULT_FONT); - } - - #[test] - fn variations_are_backward_compatible_and_imply_weight() { - // Files written before variable fonts load with no custom axes, and stay byte-identical. - let old = r#"{"font_family":"Inter","font_style":"Bold","font_size":12.0}"#; - let st: TextStyle = serde_json::from_str(old).unwrap(); - assert!(st.variations.is_empty()); - assert!(!serde_json::to_string(&st).unwrap().contains("variations")); - let mut st = TextStyle { font_style: "Regular".into(), ..Default::default() }; - st.variations.insert("wght".into(), 640.0); - st.variations.insert("wdth".into(), 80.0); - assert_eq!(st.weight(), 640); - assert!(!st.italic()); - st.variations.insert("slnt".into(), -8.0); - assert!(st.italic()); - let round: TextStyle = serde_json::from_str(&serde_json::to_string(&st).unwrap()).unwrap(); - assert_eq!(round, st); - // Picking a named style drops the axes its name implies, keeps the others. - st.set_font_style("Bold"); - assert_eq!(st.weight(), 700); - assert_eq!(st.variations.keys().collect::>(), ["wdth"]); - assert!(valid_axis_tag("wght") && !valid_axis_tag("weight")); - } - - #[test] - fn list_starts_load_from_old_files_and_follow_edits() { - use ListStyle::{Bulleted as B, None as P, Numbered as N}; - // Files written before start numbers load with none, and write the same bytes. - let old = r#"{"list":"numbered","indent":1}"#; - let pl: ParaList = serde_json::from_str(old).unwrap(); - assert_eq!(pl, ParaList { list: N, indent: 1, start_offset: 0 }); - assert_eq!(serde_json::to_string(&pl).unwrap(), old); - // One numbered paragraph with a start keeps it (it doesn't fold into `list`). - let mut t = TextData { characters: "Next".into(), ..Default::default() }; - t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start_offset: 1 }); - assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start_offset: 1 }); - // Enter copies it to the new item; different starts don't make the list mixed. - t.replace_range(4, 4, "\nMore"); - assert_eq!(t.para_list(1).start_offset, 1); - t.set_para_lists(5, 5, |p| p.start_offset = 0); - assert!(t.common_para_list(0, t.char_count()).is_some_and(|p| p.list == N)); - // Bullets have no number, so no start. - t.set_para_lists(0, 0, |p| *p = ParaList { list: B, indent: 1, start_offset: 4 }); - assert_eq!(t.para_list(0), ParaList { list: B, indent: 1, start_offset: 0 }); - // A plain paragraph has none. - t.set_para_lists(0, 0, |p| p.list = P); - assert_eq!(t.para_list(0), ParaList::default()); - } -} - -#[cfg(test)] -mod title_case_tests { - use super::TextCase; - - #[test] - fn title_case_after_opening_apostrophe() { - assert_eq!(TextCase::Title.apply("'hello world'"), "'Hello World'"); - assert_eq!(TextCase::Title.apply("say 'hi'"), "Say 'Hi'"); - assert_eq!(TextCase::Title.apply("don't"), "Don't"); - assert_eq!(TextCase::Title.apply("\u{2019}hello"), "\u{2019}Hello"); - } - - #[test] - fn title_case_between_runs_sees_char_before_apostrophe() { - assert_eq!(TextCase::Title.apply_after("t", Some('\'')), "t"); - assert_eq!(TextCase::Title.apply_between("hello", None, Some('\'')), "Hello"); - assert_eq!(TextCase::Title.apply_between("t", Some('n'), Some('\'')), "t"); - assert_eq!(TextCase::Title.apply_between("t", Some(' '), Some('\u{2019}')), "T"); - } - - #[test] - fn title_case_combining_marks_do_not_break_words() { - assert_eq!(TextCase::Title.apply("re\u{301}sume\u{301}"), "Re\u{301}sume\u{301}"); - assert_eq!(TextCase::Title.apply("re\u{301}sume\u{301} bon"), "Re\u{301}sume\u{301} Bon"); - assert_eq!(TextCase::Title.apply("a \u{301}b"), "A \u{301}B"); - assert_eq!(TextCase::Title.apply("cafe\u{301}'s"), "Cafe\u{301}'s"); - } -} - -#[cfg(test)] -mod whole_text_paragraph_style_tests { - use super::*; - - #[test] - fn a_text_style_on_the_whole_layer_reaches_a_trailing_empty_paragraph() { - let ps = ParagraphStyle { list: ListStyle::Bulleted, ..Default::default() }; - let mut t = TextData::new("a\nb\n"); - t.take_paragraph_style(&ps); - assert_eq!(t.paragraph_count(), 3); - assert_eq!(t.para_list(2).list, ListStyle::Bulleted); - assert_eq!(t.list, ListStyle::Bulleted); - assert!(t.paragraphs.is_empty()); - let mut plain = TextData::new("a\nb"); - plain.take_paragraph_style(&ps); - assert_eq!((plain.list, plain.paragraphs.len()), (t.list, t.paragraphs.len())); - } -} +//! Text node data: characters plus a base style and styled runs. + +use figcraft_color::{Color, Paint}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "unit", content = "value", rename_all = "camelCase")] +#[derive(Default)] +pub enum LineHeight { + #[default] + Auto, + Pixels(f64), + Percent(f64), +} + +impl LineHeight { + /// Resolved line height in px for `font_size` (`auto` uses the font's natural ratio). + pub fn resolve(self, font_size: f64, natural_ratio: f64) -> f64 { + match self { + LineHeight::Auto => font_size * natural_ratio, + LineHeight::Pixels(v) => v.max(0.0), + LineHeight::Percent(p) => font_size * p.max(0.0) / 100.0, + } + } + pub fn label(self) -> String { + match self { + LineHeight::Auto => "Auto".into(), + LineHeight::Pixels(v) => trim_num(v), + LineHeight::Percent(p) => format!("{}%", trim_num(p)), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "unit", content = "value", rename_all = "camelCase")] +pub enum LetterSpacing { + Pixels(f64), + Percent(f64), +} + +impl Default for LetterSpacing { + fn default() -> Self { + LetterSpacing::Percent(0.0) + } +} + +impl LetterSpacing { + pub fn resolve(self, font_size: f64) -> f64 { + match self { + LetterSpacing::Pixels(v) => v, + LetterSpacing::Percent(p) => font_size * p / 100.0, + } + } + pub fn label(self) -> String { + match self { + LetterSpacing::Pixels(v) => trim_num(v), + LetterSpacing::Percent(p) => format!("{}%", trim_num(p)), + } + } +} + +pub fn trim_num(v: f64) -> String { + if v.abs() >= 9.0e18 { + return format!("{v}"); + } + let r = (v * 100.0).round() / 100.0; + if r.fract().abs() < 1e-9 { format!("{}", r as i64) } else { format!("{r}") } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum TextDecoration { + #[default] + None, + Underline, + Strikethrough, +} + +/// How an underline or strikethrough is drawn. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum DecorationStyle { + #[default] + Solid, + Dotted, + Wavy, +} + +/// A decoration length: `Auto` (the font's own value), pixels, or a percentage of the font size. +pub type DecorationLength = LineHeight; + +/// How a decoration line looks; the default is the font's own solid line in the text's colour. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct DecorationLook { + pub style: DecorationStyle, + pub thickness: DecorationLength, + /// Underlines: from the baseline down to the line's top edge (negative: above the baseline). + pub offset: DecorationLength, + /// Underlines break where descenders cross them. + pub skip_ink: bool, + /// The line's own colour (None: the text's fills). + pub color: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum TextCase { + #[default] + Original, + Upper, + Lower, + Title, + SmallCaps, +} + +impl TextCase { + pub fn apply(self, s: &str) -> String { + self.apply_after(s, None) + } + + /// Apply the case to `s`, which follows `prev` in the text (so title case only capitalises + /// the first letter of each word even when a word spans several styled runs). An apostrophe + /// as `prev` is taken to continue a word. + pub fn apply_after(self, s: &str, prev: Option) -> String { + self.apply_impl(s, prev, prev.is_some_and(is_word_char)) + } + + /// Like [`apply_after`](Self::apply_after), also given the char `before` `prev`: an apostrophe + /// only continues a word when a letter or digit precedes it. + pub fn apply_between(self, s: &str, before: Option, prev: Option) -> String { + let continues = prev.is_some_and(|c| is_word_char(c) && (c.is_alphanumeric() || before.is_some_and(char::is_alphanumeric))); + self.apply_impl(s, prev, continues) + } + + /// `in_word` says whether `prev` continues a word (rather than a new word starting here). + fn apply_impl(self, s: &str, prev: Option, in_word: bool) -> String { + match self { + TextCase::Original | TextCase::SmallCaps => s.to_string(), + TextCase::Upper => s.to_uppercase(), + TextCase::Lower => s.to_lowercase(), + TextCase::Title => { + let mut out = String::with_capacity(s.len()); + let mut start = !in_word; + let mut last = prev; + for ch in s.chars() { + if start && ch.is_alphanumeric() { + out.extend(ch.to_uppercase()); + start = false; + } else { + out.push(ch); + // An apostrophe only continues a word right after a letter or digit. + let opening_apostrophe = + matches!(ch, '\'' | '\u{2019}') && !last.is_some_and(|c| c.is_alphanumeric() || is_combining_mark(c)); + // A combining mark attaches to the char before it and never changes word state. + if !is_combining_mark(ch) { + start = !is_word_char(ch) || opening_apostrophe; + } + } + last = Some(ch); + } + out + } + } + } + + /// Whether the char at a position preceded by `prev` starts a word (for title case). + pub fn starts_word(prev: Option) -> bool { + prev.is_none_or(|c| !is_word_char(c)) + } +} + +/// Letters, digits, combining marks and apostrophes continue a word (so "don't" stays "Don't" +/// and decomposed accents stay inside their word). +fn is_word_char(c: char) -> bool { + c.is_alphanumeric() || is_combining_mark(c) || matches!(c, '\'' | '\u{2019}') +} + +/// Combining marks such as the U+0301 accent in decomposed text: non-alphanumeric chars that +/// extend the grapheme of the char before them. +fn is_combining_mark(c: char) -> bool { + use unicode_segmentation::UnicodeSegmentation; + if c.is_ascii() || c.is_alphanumeric() { + return false; + } + let mut buf = [0u8; 8]; + buf[0] = b'a'; + let end = 1 + c.encode_utf8(&mut buf[1..]).len(); + std::str::from_utf8(&buf[..end]).is_ok_and(|s| s.graphemes(true).count() == 1) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum TextAlignH { + #[default] + Left, + Center, + Right, + Justified, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum TextAlignV { + #[default] + Top, + Middle, + Bottom, +} + +/// How a text box resizes as its content changes. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum TextAutoResize { + /// Auto width: one line per paragraph, box grows both ways. + #[default] + WidthAndHeight, + /// Fixed width, height grows. + Height, + /// Fixed size. + None, + /// Fixed size, truncate with an ellipsis. + Truncate, +} + +/// Vertical trim: what bounds a text box vertically. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum LeadingTrim { + /// The full line height of the first and last lines. + #[default] + None, + /// From the first line's cap height to the last line's baseline. + CapHeight, +} + +/// Where lines break when text wraps to more than one line ("Wrap style controls where lines +/// break when text wraps to more than one line", Explore text properties). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum WrapStyle { + /// "Figma fits as many words as possible on each line before wrapping to the next." + #[default] + Auto, + /// "Figma distributes text as evenly as possible across every line, so each line is close to + /// the same length." + Balance, + /// "Figma keeps each line full, but adjusts the last four lines to avoid an orphan -- a single + /// word stranded alone on the last line." + Pretty, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum ListStyle { + #[default] + None, + Bulleted, + Numbered, +} + +/// Deepest list nesting (levels 0..=MAX_LIST_INDENT). +pub const MAX_LIST_INDENT: u8 = 8; + +/// One paragraph's list settings. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct ParaList { + pub list: ListStyle, + /// Nesting level (0 = outermost). A plain paragraph is 0, except one that left its list by + /// Backspace or Delete at its start ([`TextData::unlist_keeping_levels`]): it keeps the level + /// it had and starts where that level's item text does ("delete the counter, but keep the + /// same level of indentation"). + pub indent: u8, + /// A numbered list that starts counting at this paragraph counts from `start_offset + 1` + /// (items that go on counting ignore it; other paragraphs are always 0). + #[serde(skip_serializing_if = "crate::node::is_default")] + pub start_offset: u32, +} + +/// Character style. In runs, every field is an override; `None` means "inherit the base". +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct TextStyle { + pub font_family: String, + /// The style name, e.g. "Regular", "Bold", "Semi Bold Italic". + pub font_style: String, + pub font_size: f64, + pub line_height: LineHeight, + pub letter_spacing: LetterSpacing, + pub decoration: TextDecoration, + /// How the decoration looks (None: the default look; boxed to keep styles small). + #[serde(skip_serializing_if = "Option::is_none")] + pub decoration_look: Option>, + pub case: TextCase, + /// Run fills (None = node fills). + pub fills: Option>, + /// Hyperlink. + pub link: Option, + /// Variable font axis values in user space, keyed by 4-char OpenType axis tag (e.g. `wght`: + /// 550, `wdth`: 85). Empty = what the style name implies; axes the face lacks are ignored. + #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub variations: std::collections::BTreeMap, + /// The text style (shared style id) these chars are linked to. A layer's chars can link to + /// different styles; editing what a style sets unlinks the chars edited. + #[serde(skip_serializing_if = "Option::is_none")] + pub style_id: Option, +} + +/// What a text style sets at the paragraph and layer level, beside its character style +/// ([`TextStyle`]): paragraph spacing and indent, the list style and the OpenType features +/// ("Supported in text styles": "Paragraph spacing and indentation", "Lists", "OpenType +/// features", Create and apply text styles). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct ParagraphStyle { + #[serde(skip_serializing_if = "crate::node::is_default")] + pub paragraph_spacing: f64, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub paragraph_indent: f64, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub list: ListStyle, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub features: std::collections::BTreeMap, +} + +/// The font new text starts in. A build can name another (`FIGCRAFT_DEFAULT_FONT="Noto Sans Thai"`, +/// for a distribution whose users mostly write a script Inter lacks); empty means Inter. +pub const DEFAULT_FONT: &str = match option_env!("FIGCRAFT_DEFAULT_FONT") { + Some(family) if !family.is_empty() => family, + _ => "Inter", +}; + +impl Default for TextStyle { + fn default() -> Self { + Self { + font_family: DEFAULT_FONT.into(), + font_style: "Regular".into(), + font_size: 12.0, + line_height: LineHeight::Auto, + letter_spacing: LetterSpacing::default(), + decoration: TextDecoration::None, + decoration_look: None, + case: TextCase::Original, + fills: None, + link: None, + variations: std::collections::BTreeMap::new(), + style_id: None, + } + } +} + +/// Axes a style name implies (weight, italic, slant): picking a named style clears their custom values. +pub const STYLE_AXES: [&str; 3] = ["wght", "ital", "slnt"]; + +impl TextStyle { + /// Numeric weight: a custom `wght` axis value, else the one implied by the style name. + pub fn weight(&self) -> u16 { + match self.variations.get("wght") { + Some(w) if w.is_finite() => w.round().clamp(1.0, 1000.0) as u16, + _ => style_weight(&self.font_style), + } + } + pub fn italic(&self) -> bool { + self.font_style.to_ascii_lowercase().contains("italic") + || self.variations.get("ital").is_some_and(|v| *v >= 0.5) + || self.variations.get("slnt").is_some_and(|v| *v != 0.0) + } + + /// Take what text style `def` sets (font, size, line height, letter spacing, decoration, case + /// and axis values) and link to it as `id`; fills and hyperlinks stay. + pub fn take_text_style(&mut self, def: &TextStyle, id: &str) { + *self = TextStyle { fills: self.fills.take(), link: self.link.take(), style_id: Some(id.to_string()), ..def.clone() }; + } + + /// Whether these chars show what text style `def` sets. + pub fn shows_text_style(&self, def: &TextStyle) -> bool { + self.font_family == def.font_family + && self.font_style == def.font_style + && self.font_size == def.font_size + && self.line_height == def.line_height + && self.letter_spacing == def.letter_spacing + && self.decoration == def.decoration + && self.decoration_look == def.decoration_look + && self.case == def.case + && self.variations == def.variations + } + + /// This style as a text style's definition (linked to nothing). + pub fn as_text_style(&self) -> TextStyle { + TextStyle { style_id: None, link: None, ..self.clone() } + } + + /// How the decoration looks. + pub fn look(&self) -> DecorationLook { + self.decoration_look.as_deref().copied().unwrap_or_default() + } + + /// Change how the decoration looks (the default look is stored as None). + pub fn set_look(&mut self, f: impl FnOnce(&mut DecorationLook)) { + let mut look = self.look(); + f(&mut look); + self.decoration_look = (look != DecorationLook::default()).then(|| Box::new(look)); + } + + /// Switch to the named style, dropping custom values of the axes the name implies. + pub fn set_font_style(&mut self, name: impl Into) { + self.font_style = name.into(); + for tag in STYLE_AXES { + self.variations.remove(tag); + } + } +} + +/// Weight from a style name ("Semi Bold Italic" → 600). +pub fn style_weight(style: &str) -> u16 { + let s = style.to_ascii_lowercase().replace([' ', '-'], ""); + // A weight number ("600", "600 Italic"), as some apps name styles. + let digits: String = s.chars().take_while(char::is_ascii_digit).collect(); + if let Ok(n) = digits.parse::() + && (1..=1000).contains(&n) + { + return n; + } + let table: [(&str, u16); 10] = [ + ("thin", 100), + ("extralight", 200), + ("ultralight", 200), + ("light", 300), + ("medium", 500), + ("semibold", 600), + ("demibold", 600), + ("extrabold", 800), + ("ultrabold", 800), + ("black", 900), + ]; + for (k, w) in table { + if s.contains(k) { + return w; + } + } + if s.contains("bold") { + return 700; + } + if s.contains("heavy") { + return 900; + } + 400 +} + +/// A styled range of characters (char indices, end exclusive). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct StyleRun { + pub start: usize, + pub end: usize, + pub style: TextStyle, +} + +/// Text node content. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct TextData { + pub characters: String, + pub style: TextStyle, + /// Ranges whose style differs from `style`; full styles (already merged), non-overlapping, sorted. + #[serde(skip_serializing_if = "crate::node::is_default")] + pub runs: Vec, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub auto_resize: TextAutoResize, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub align_h: TextAlignH, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub align_v: TextAlignV, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub paragraph_spacing: f64, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub paragraph_indent: f64, + /// The list style of paragraphs `paragraphs` doesn't cover (all of them when it's empty). + #[serde(skip_serializing_if = "crate::node::is_default")] + pub list: ListStyle, + /// Per-paragraph list settings, by paragraph index; empty when every paragraph is `list` at + /// level 0. Kept in step with the characters by [`TextData::replace_range`]. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub paragraphs: Vec, + /// Space between two items of a list (px), in place of the paragraph spacing. + #[serde(skip_serializing_if = "crate::node::is_default")] + pub list_spacing: f64, + /// Opening and closing punctuation at a line's aligned edge sits outside the box. + #[serde(skip_serializing_if = "crate::node::is_default")] + pub hanging_punctuation: bool, + /// List bullets and numbers sit outside the box; item text lines up with plain paragraphs. + #[serde(skip_serializing_if = "crate::node::is_default")] + pub hanging_list: bool, + /// Vertical trim (`capHeight`: the box runs from the first line's cap height to the last + /// line's baseline). + #[serde(skip_serializing_if = "crate::node::is_default")] + pub leading_trim: LeadingTrim, + /// Max lines when truncating, or before an auto-height text ends in an ellipsis (0 = as many + /// as fit). + #[serde(skip_serializing_if = "crate::node::is_default")] + pub max_lines: u32, + /// Where lines break when the text wraps: greedily (Auto), as evenly as the words allow + /// (Balance), or full with no one word alone on the last line (Pretty). + #[serde(skip_serializing_if = "crate::node::is_default")] + pub wrap_style: WrapStyle, + /// OpenType feature toggles (e.g. "liga": false). + #[serde(skip_serializing_if = "crate::node::is_default")] + pub features: std::collections::BTreeMap, + /// The path the text follows (text on a path); `None`: lines in the box. Boxed to keep text + /// data small. + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option>, + /// Where the file this text came from wrapped its lines (an imported `.fig`'s saved layout). + /// Layout follows them only while the box width and everything that moves a line break are + /// as they were saved, so an edit that could change the wrap drops them. + #[serde(skip_serializing_if = "Option::is_none")] + pub saved_breaks: Option>, +} + +/// Line breaks a file saved with a text: the chars that start a wrapped line (not those after a +/// hard break), for the box width and the content ([`TextData::layout_key`]) they were made for. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct SavedBreaks { + pub starts: Vec, + pub width: f64, + pub for_text: u64, +} + +/// Text on a path: the baseline follows `d`. The text starts at `start` (aligned there by the +/// horizontal alignment: left starts, centre centres, right ends at it); glyphs past an open +/// path's ends are hidden, a closed path wraps them around. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct TextPath { + /// SVG path data in the layer's own space. + pub d: String, + /// Where the text starts along the path, 0..1 by length. + pub start: f64, + /// On the other side of the path, running the other way. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub flip: bool, +} + +impl TextPath { + /// A text path following `path` (layer space). + pub fn new(path: &figcraft_geom::BezPath) -> Self { + Self { d: path.to_svg(), start: 0.0, flip: false } + } + + /// The path (empty when `d` doesn't parse). + pub fn path(&self) -> figcraft_geom::BezPath { + figcraft_geom::BezPath::from_svg(&self.d).unwrap_or_default() + } + + /// The start offset clamped into 0..1 (NaN: 0). + pub fn start(&self) -> f64 { + if self.start.is_finite() { self.start.clamp(0.0, 1.0) } else { 0.0 } + } + + /// Scale the path by `sx`, `sy` (the layer was resized). + pub fn scale(&mut self, sx: f64, sy: f64) { + if sx.is_finite() && sy.is_finite() { + let mut p = self.path(); + p.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy)); + self.d = p.to_svg(); + } + } +} + +/// Whether `tag` is a valid OpenType feature tag (four printable ASCII chars). +pub fn valid_feature_tag(tag: &str) -> bool { + tag.len() == 4 && tag.bytes().all(|b| (0x20..0x7f).contains(&b)) +} + +/// Whether `tag` is a valid OpenType variation axis tag (same shape as a feature tag). +pub fn valid_axis_tag(tag: &str) -> bool { + valid_feature_tag(tag) +} + +impl TextData { + pub fn new(characters: impl Into) -> Self { + Self { characters: characters.into(), ..Default::default() } + } + + /// A stable hash of what decides where lines break: everything but paints, links, style links, + /// decorations, vertical settings and the saved breaks themselves (FNV-1a over the JSON, so it + /// stays the same across builds and can be saved). + pub fn layout_key(&self) -> u64 { + let clear = |s: &mut TextStyle| { + (s.fills, s.link, s.style_id, s.decoration, s.decoration_look) = (None, None, None, TextDecoration::None, None); + }; + let mut t = TextData { + align_v: TextAlignV::default(), + paragraph_spacing: 0.0, + list_spacing: 0.0, + leading_trim: LeadingTrim::default(), + max_lines: 0, + saved_breaks: None, + ..self.clone() + }; + clear(&mut t.style); + t.runs.iter_mut().for_each(|r| clear(&mut r.style)); + t.normalize(); + serde_json::to_vec(&t).unwrap_or_default().iter().fold(0xcbf2_9ce4_8422_2325, |h, b| (h ^ u64::from(*b)).wrapping_mul(0x0100_0000_01b3)) + } + + /// Keep `starts` (chars that start a wrapped line) as this text's saved line breaks in a box + /// `width` wide. + pub fn save_breaks(&mut self, starts: Vec, width: f64) { + self.saved_breaks = Some(Box::new(SavedBreaks { starts, width, for_text: self.layout_key() })); + } + + /// The saved line starts (empty: no wrapped line) while they still hold: the same box width and + /// layout content, and starts increasing inside the text. + pub fn saved_breaks_for(&self, width: f64) -> Option<&[usize]> { + let b = self.saved_breaks.as_deref()?; + let inside = b.starts.first().is_none_or(|s| *s > 0) && b.starts.last().is_none_or(|s| *s < self.char_count()); + let same_box = (b.width - width).abs() <= 1e-6 * width.abs().max(1.0); + (inside && same_box && b.starts.is_sorted_by(|a, b| a < b) && b.for_text == self.layout_key()).then_some(b.starts.as_slice()) + } + + /// How far text on a path paints past its path's box (glyphs stand on the path, lines after + /// the first run beside it): about one and a half of the largest size per line; 0 off a path. + pub fn path_reach(&self) -> f64 { + if self.path.is_none() { + return 0.0; + } + let lines = self.characters.lines().count().clamp(1, 10_000) as f64; + self.max_size() * 1.5 * lines + } + + /// How far vertically trimmed text paints past its box (descenders below the last baseline, + /// accents above the first cap height): half the largest size; 0 untrimmed. + pub fn trim_reach(&self) -> f64 { + if self.leading_trim == LeadingTrim::None { 0.0 } else { self.max_size() * 0.5 } + } + + /// The largest font size used (capped like layout caps it). + fn max_size(&self) -> f64 { + self.spans().iter().map(|(_, _, s)| s.font_size).filter(|v| v.is_finite()).fold(0.0, f64::max).min(5000.0) + } + + pub fn char_count(&self) -> usize { + self.characters.chars().count() + } + + /// Whether text that runs out of room ends in an ellipsis: a truncating box, or auto height + /// or auto width with a line limit (the box hugs up to `max_lines` lines: "Max lines is + /// available only if truncate text is enabled, and: Text resizing is set to auto height or + /// auto width"). + pub fn truncates(&self) -> bool { + match self.auto_resize { + TextAutoResize::Truncate => true, + TextAutoResize::Height | TextAutoResize::WidthAndHeight => self.max_lines > 0, + TextAutoResize::None => false, + } + } + + /// The resize mode once the box is given an explicit `width` and/or `height`: an explicit + /// height fixes the box, an explicit width turns auto width into auto height, and truncating + /// text keeps truncating. + pub fn fix_size(&mut self, width: bool, height: bool) { + self.auto_resize = match self.auto_resize { + _ if height && self.truncates() => TextAutoResize::Truncate, + _ if height => TextAutoResize::None, + TextAutoResize::WidthAndHeight if width => TextAutoResize::Height, + mode => mode, + }; + } + + /// The style at char index `i`. + pub fn style_at(&self, i: usize) -> &TextStyle { + self.runs.iter().find(|r| r.start <= i && i < r.end).map(|r| &r.style).unwrap_or(&self.style) + } + + /// Spans of uniform style covering the whole text: (start, end, style). + pub fn spans(&self) -> Vec<(usize, usize, &TextStyle)> { + let n = self.char_count(); + let mut out = Vec::new(); + let mut pos = 0; + let mut runs: Vec<&StyleRun> = self.runs.iter().filter(|r| r.start < r.end && r.start < n).collect(); + runs.sort_by_key(|r| r.start); + for r in runs { + let start = r.start.max(pos); + let end = r.end.min(n); + if start >= end { + continue; + } + if start > pos { + out.push((pos, start, &self.style)); + } + out.push((start, end, &r.style)); + pos = end; + } + if pos < n || out.is_empty() { + out.push((pos, n, &self.style)); + } + out + } + + /// Apply `f` to the style of chars `start..end` (all text when the range is the whole text or + /// empty text), splitting runs as needed and merging equal neighbours. + pub fn restyle(&mut self, start: usize, end: usize, f: impl Fn(&mut TextStyle)) { + let n = self.char_count(); + let (start, end) = (start.min(n), end.min(n)); + if n == 0 || (start == 0 && end >= n) { + f(&mut self.style); + for r in &mut self.runs { + f(&mut r.style); + } + self.normalize(); + return; + } + if start >= end { + return; + } + // Expand to per-char styles, modify, re-compress. + let mut per: Vec = (0..n).map(|i| self.style_at(i).clone()).collect(); + for s in per.iter_mut().take(end).skip(start) { + f(s); + } + self.runs.clear(); + let mut i = 0; + while i < n { + let Some(s) = per.get(i) else { break }; + let mut j = i + 1; + while per.get(j).is_some_and(|t| t == s) { + j += 1; + } + if *s != self.style { + self.runs.push(StyleRun { start: i, end: j, style: s.clone() }); + } + i = j; + } + } + + /// Drop empty/out-of-range runs and runs equal to the base style, and merge touching equal runs. + pub fn normalize(&mut self) { + let n = self.char_count(); + let base = self.style.clone(); + self.runs.retain(|r| r.start < r.end && r.start < n && r.style != base); + for r in &mut self.runs { + r.end = r.end.min(n); + } + self.runs.sort_by_key(|r| r.start); + let mut merged: Vec = Vec::with_capacity(self.runs.len()); + for mut r in std::mem::take(&mut self.runs) { + // Runs never overlap: a later run starts where the previous one ends at the earliest. + if let Some(prev) = merged.last() { + r.start = r.start.max(prev.end); + if r.start >= r.end { + continue; + } + } + match merged.last_mut() { + Some(prev) if prev.end == r.start && prev.style == r.style => prev.end = r.end, + _ => merged.push(r), + } + } + self.runs = merged; + } + + /// Replace chars `start..end` with `text`, shifting runs (inserted text takes the style at `start`). + pub fn replace_range(&mut self, start: usize, end: usize, text: &str) { + let n = self.char_count(); + let (start, end) = (start.min(n), end.min(n).max(start.min(n))); + let byte = |i: usize| self.characters.char_indices().nth(i).map(|(b, _)| b).unwrap_or(self.characters.len()); + let (bs, be) = (byte(start), byte(end)); + if !self.paragraphs.is_empty() { + // Paragraphs merged by removed newlines take the first one's list; new ones copy it. + let p = self.para_of(start); + let removed = self.characters.get(bs..be).unwrap_or("").matches('\n').count(); + let added = text.matches('\n').count(); + self.fill_paragraphs(); + let at = self.para_list(p); + let tail = (p + 1).min(self.paragraphs.len()); + self.paragraphs.drain(tail..(p + 1 + removed).min(self.paragraphs.len())); + self.paragraphs.splice(tail..tail, std::iter::repeat_n(at, added)); + } + let mut s = String::with_capacity(self.characters.len() + text.len()); + s.push_str(self.characters.get(..bs).unwrap_or("")); + s.push_str(text); + s.push_str(self.characters.get(be..).unwrap_or("")); + self.characters = s; + let inserted = text.chars().count(); + let removed = end - start; + for r in &mut self.runs { + let shift = |p: usize| -> usize { + if p <= start { + p + } else if p >= end { + // Runs of a file that reach past the text (nothing clamps them on load) + // would otherwise overflow here; `normalize` below drops what is left. + p.saturating_sub(removed).saturating_add(inserted) + } else { + start + inserted + } + }; + // A run touching the insertion point at its end grows with the typed text. + let new_start = shift(r.start); + let new_end = if r.end == start && r.start < start { start + inserted } else { shift(r.end) }; + r.start = new_start; + r.end = new_end; + } + self.normalize(); + } + + pub fn paragraph_count(&self) -> usize { + self.characters.matches('\n').count() + 1 + } + + /// The paragraph char index `i` is in. + pub fn para_of(&self, i: usize) -> usize { + self.characters.chars().take(i).filter(|c| *c == '\n').count() + } + + /// Paragraph `p`'s list settings. + pub fn para_list(&self, p: usize) -> ParaList { + self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, ..ParaList::default() }) + } + + /// Spell out every paragraph's list settings in `paragraphs` (and drop extra entries). + fn fill_paragraphs(&mut self) { + let n = self.paragraph_count(); + let fill = ParaList { list: self.list, ..ParaList::default() }; + self.paragraphs.resize(n, fill); + } + + /// The paragraphs chars `start..end` touch (a caret: its paragraph; a selection ending just + /// after a newline leaves the next paragraph out). + pub fn para_span(&self, start: usize, end: usize) -> (usize, usize) { + let (s, e) = (start.min(end), start.max(end)); + let (a, b) = (self.para_of(s), self.para_of(e)); + let ends_at_start = e > s && b > a && self.characters.chars().nth(e - 1) == Some('\n'); + (a, if ends_at_start { b - 1 } else { b }) + } + + /// Apply `f` to the list settings of the paragraphs chars `start..end` touch (plain + /// paragraphs stay at level 0, only numbered ones keep a start, levels stop at + /// [`MAX_LIST_INDENT`]); one style for all with no nesting or start folds back into `list`. + pub fn set_para_lists(&mut self, start: usize, end: usize, f: impl Fn(&mut ParaList)) { + self.set_para_lists_with(start, end, false, f); + } + + /// Take the list items among the paragraphs chars `start..end` touch out of their list at the + /// level they had, as Backspace or Delete at an item's start does: "delete the counter, but + /// keep the same level of indentation" (Create bulleted and numbered lists). + pub fn unlist_keeping_levels(&mut self, start: usize, end: usize) { + self.set_para_lists_with(start, end, true, |pl| pl.list = ListStyle::None); + } + + fn set_para_lists_with(&mut self, start: usize, end: usize, keep_levels: bool, f: impl Fn(&mut ParaList)) { + let (a, b) = self.para_span(start, end); + self.set_para_lists_between(a, b, keep_levels, f); + } + + /// As [`set_para_lists`](Self::set_para_lists), for paragraphs `a..=b` addressed directly. + fn set_para_lists_between(&mut self, a: usize, b: usize, keep_levels: bool, f: impl Fn(&mut ParaList)) { + self.fill_paragraphs(); + for pl in self.paragraphs.get_mut(a..=b).unwrap_or(&mut []) { + f(pl); + if pl.list == ListStyle::None && !keep_levels { + pl.indent = 0; + } + if pl.list != ListStyle::Numbered { + pl.start_offset = 0; + } + pl.indent = pl.indent.min(MAX_LIST_INDENT); + } + if let Some(first) = self.paragraphs.first().copied() + && first == (ParaList { list: first.list, ..ParaList::default() }) + && self.paragraphs.iter().all(|p| *p == first) + { + self.list = first.list; + self.paragraphs.clear(); + } + } + + /// The list style and level shared by the paragraphs chars `start..end` touch (the first + /// one's settings), or None when they differ. Start numbers don't count. + pub fn common_para_list(&self, start: usize, end: usize) -> Option { + let (a, b) = self.para_span(start, end); + let first = self.para_list(a); + (a..=b).all(|p| self.para_list(p).list == first.list && self.para_list(p).indent == first.indent).then_some(first) + } + + /// What a text style made from this text sets at the paragraph and layer level: the paragraph + /// spacing and indent, the list style (the one every paragraph shares; the base one when they + /// differ) and the OpenType features. + pub fn paragraph_style(&self) -> ParagraphStyle { + let list = match self.paragraphs.first() { + Some(first) if self.paragraphs.iter().all(|p| p.list == first.list) => first.list, + _ => self.list, + }; + ParagraphStyle { paragraph_spacing: self.paragraph_spacing, paragraph_indent: self.paragraph_indent, list, features: self.features.clone() } + } + + /// Take what text style `ps` sets at the paragraph and layer level: the spacing, indent and + /// features on the layer, the list style on every paragraph. + pub fn take_paragraph_style(&mut self, ps: &ParagraphStyle) { + self.paragraph_spacing = ps.paragraph_spacing; + self.paragraph_indent = ps.paragraph_indent; + self.features.clone_from(&ps.features); + // Every paragraph by index: `para_span` leaves out an empty one after a final newline. + let last = self.paragraph_count() - 1; + self.set_para_lists_between(0, last, false, |pl| pl.list = ps.list); + } + + /// As [`take_paragraph_style`](Self::take_paragraph_style), with the list style going to the + /// paragraphs chars `start..end` touch only; the spacing, indent and features are the + /// layer's, as in Figma. + pub fn take_paragraph_style_in(&mut self, start: usize, end: usize, ps: &ParagraphStyle) { + self.paragraph_spacing = ps.paragraph_spacing; + self.paragraph_indent = ps.paragraph_indent; + self.features.clone_from(&ps.features); + self.set_para_lists(start, end, |pl| pl.list = ps.list); + } + + /// Follow text style `id`'s paragraph-level definition wherever chars link to it. + pub fn update_paragraph_style(&mut self, id: &str, ps: &ParagraphStyle) { + let linked: Vec<(usize, usize)> = + self.spans().into_iter().filter(|(_, _, s)| s.style_id.as_deref() == Some(id)).map(|(a, b, _)| (a, b)).collect(); + for (a, b) in linked { + self.take_paragraph_style_in(a, b, ps); + } + } + + /// Whether chars `start..end` show what text style `ps` sets at the paragraph and layer + /// level: the layer's spacing, indent and features, and their paragraphs' list style. + pub fn shows_paragraph_style_in(&self, start: usize, end: usize, ps: &ParagraphStyle) -> bool { + self.paragraph_spacing == ps.paragraph_spacing + && self.paragraph_indent == ps.paragraph_indent + && self.features == ps.features + && self.common_para_list(start, end).is_some_and(|pl| pl.list == ps.list) + } + + /// Whether the whole text shows what text style `ps` sets at the paragraph and layer level. + pub fn shows_paragraph_style(&self, ps: &ParagraphStyle) -> bool { + self.shows_paragraph_style_in(0, self.char_count(), ps) + } + + /// Link chars `start..end` (all of them when the range is empty or the whole text) to text + /// style `id`, defined as `def`. + pub fn link_text_style(&mut self, start: usize, end: usize, def: &TextStyle, id: &str) { + let (start, end) = if start == end { (0, self.char_count()) } else { (start, end) }; + self.restyle(start, end, |s| s.take_text_style(def, id)); + } + + /// Unlink chars `start..end` (all when empty or whole) from their text styles; values stay. + pub fn unlink_text_style(&mut self, start: usize, end: usize) { + let (start, end) = if start == end { (0, self.char_count()) } else { (start, end) }; + self.restyle(start, end, |s| s.style_id = None); + } + + /// The text style every char is linked to: `Some(None)` when none is, None when they differ. + pub fn text_style_link(&self) -> Option> { + let mut spans = self.spans().into_iter().map(|(_, _, s)| s.style_id.as_deref()); + let first = spans.next()?; + spans.all(|s| s == first).then_some(first) + } + + /// Apply `f` to every distinct style (the base and each run) and merge runs that became equal. + pub(crate) fn each_style(&mut self, mut f: impl FnMut(&mut TextStyle)) { + f(&mut self.style); + for r in &mut self.runs { + f(&mut r.style); + } + self.normalize(); + } + + /// Whether any char links to text style `id`. + pub fn links_text_style(&self, id: &str) -> bool { + std::iter::once(&self.style).chain(self.runs.iter().map(|r| &r.style)).any(|s| s.style_id.as_deref() == Some(id)) + } + + /// Follow text style `id`'s new definition wherever chars link to it. + pub fn update_text_style(&mut self, id: &str, def: &TextStyle) { + self.each_style(|s| { + if s.style_id.as_deref() == Some(id) { + s.take_text_style(def, id); + } + }); + } + + /// Point text style links elsewhere: `f(id)` gives the new id, None unlinks (values stay). + pub fn map_text_styles(&mut self, f: impl Fn(&str) -> Option) { + self.each_style(|s| s.style_id = s.style_id.as_deref().and_then(&f)); + } + + /// Unlink chars whose style is gone (`def` gives None) or that no longer show it (edited since). + pub fn check_text_styles<'a>(&mut self, def: impl Fn(&str) -> Option<&'a TextStyle>) { + self.each_style(|s| { + if s.style_id.as_deref().is_some_and(|id| !def(id).is_some_and(|d| s.shows_text_style(d))) { + s.style_id = None; + } + }); + } + + /// Whether every char shares one style (the panel shows "Mixed" otherwise). + pub fn is_uniform(&self) -> bool { + self.runs.is_empty() + } +} + +#[cfg(test)] +mod case_tests { + use super::*; + + #[test] + fn trim_num_prints_huge_whole_numbers() { + assert_eq!(trim_num(1e19), "10000000000000000000"); + assert_eq!(trim_num(-1e19), "-10000000000000000000"); + assert_eq!(trim_num(12.0), "12"); + let v = 12345678901234567890.0_f64; + assert_eq!(trim_num(v).parse::().unwrap(), v); + } + + #[test] + fn title_case_respects_context() { + assert_eq!(TextCase::Title.apply("hello wORLD don't 3d x-ray"), "Hello WORLD Don't 3d X-Ray"); + // Continuing a word from a previous run: no capital. + assert_eq!(TextCase::Title.apply_after("lo world", Some('l')), "lo World"); + assert_eq!(TextCase::Title.apply_after("lo", Some(' ')), "Lo"); + assert_eq!(TextCase::Upper.apply_after("ß", Some('a')), "SS"); + assert!(valid_feature_tag("liga") && valid_feature_tag("ss01")); + assert!(!valid_feature_tag("lig") && !valid_feature_tag("ligatures") && !valid_feature_tag("li\u{e9}")); + } + + #[test] + fn new_text_starts_in_the_builds_default_font() { + // Inter, unless the build names another with `FIGCRAFT_DEFAULT_FONT` (empty means Inter). + let named = option_env!("FIGCRAFT_DEFAULT_FONT").filter(|f| !f.is_empty()); + assert_eq!(DEFAULT_FONT, named.unwrap_or("Inter")); + let st = TextStyle::default(); + assert_eq!(st.font_family, DEFAULT_FONT); + // The family is always written, so a saved file keeps it in a build with another default. + let saved = serde_json::to_value(&st).unwrap(); + assert_eq!(saved["font_family"], DEFAULT_FONT); + } + + #[test] + fn variations_are_backward_compatible_and_imply_weight() { + // Files written before variable fonts load with no custom axes, and stay byte-identical. + let old = r#"{"font_family":"Inter","font_style":"Bold","font_size":12.0}"#; + let st: TextStyle = serde_json::from_str(old).unwrap(); + assert!(st.variations.is_empty()); + assert!(!serde_json::to_string(&st).unwrap().contains("variations")); + let mut st = TextStyle { font_style: "Regular".into(), ..Default::default() }; + st.variations.insert("wght".into(), 640.0); + st.variations.insert("wdth".into(), 80.0); + assert_eq!(st.weight(), 640); + assert!(!st.italic()); + st.variations.insert("slnt".into(), -8.0); + assert!(st.italic()); + let round: TextStyle = serde_json::from_str(&serde_json::to_string(&st).unwrap()).unwrap(); + assert_eq!(round, st); + // Picking a named style drops the axes its name implies, keeps the others. + st.set_font_style("Bold"); + assert_eq!(st.weight(), 700); + assert_eq!(st.variations.keys().collect::>(), ["wdth"]); + assert!(valid_axis_tag("wght") && !valid_axis_tag("weight")); + } + + #[test] + fn list_starts_load_from_old_files_and_follow_edits() { + use ListStyle::{Bulleted as B, None as P, Numbered as N}; + // Files written before start numbers load with none, and write the same bytes. + let old = r#"{"list":"numbered","indent":1}"#; + let pl: ParaList = serde_json::from_str(old).unwrap(); + assert_eq!(pl, ParaList { list: N, indent: 1, start_offset: 0 }); + assert_eq!(serde_json::to_string(&pl).unwrap(), old); + // One numbered paragraph with a start keeps it (it doesn't fold into `list`). + let mut t = TextData { characters: "Next".into(), ..Default::default() }; + t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start_offset: 1 }); + assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start_offset: 1 }); + // Enter copies it to the new item; different starts don't make the list mixed. + t.replace_range(4, 4, "\nMore"); + assert_eq!(t.para_list(1).start_offset, 1); + t.set_para_lists(5, 5, |p| p.start_offset = 0); + assert!(t.common_para_list(0, t.char_count()).is_some_and(|p| p.list == N)); + // Bullets have no number, so no start. + t.set_para_lists(0, 0, |p| *p = ParaList { list: B, indent: 1, start_offset: 4 }); + assert_eq!(t.para_list(0), ParaList { list: B, indent: 1, start_offset: 0 }); + // A plain paragraph has none. + t.set_para_lists(0, 0, |p| p.list = P); + assert_eq!(t.para_list(0), ParaList::default()); + } +} + +#[cfg(test)] +mod title_case_tests { + use super::TextCase; + + #[test] + fn title_case_after_opening_apostrophe() { + assert_eq!(TextCase::Title.apply("'hello world'"), "'Hello World'"); + assert_eq!(TextCase::Title.apply("say 'hi'"), "Say 'Hi'"); + assert_eq!(TextCase::Title.apply("don't"), "Don't"); + assert_eq!(TextCase::Title.apply("\u{2019}hello"), "\u{2019}Hello"); + } + + #[test] + fn title_case_between_runs_sees_char_before_apostrophe() { + assert_eq!(TextCase::Title.apply_after("t", Some('\'')), "t"); + assert_eq!(TextCase::Title.apply_between("hello", None, Some('\'')), "Hello"); + assert_eq!(TextCase::Title.apply_between("t", Some('n'), Some('\'')), "t"); + assert_eq!(TextCase::Title.apply_between("t", Some(' '), Some('\u{2019}')), "T"); + } + + #[test] + fn title_case_combining_marks_do_not_break_words() { + assert_eq!(TextCase::Title.apply("re\u{301}sume\u{301}"), "Re\u{301}sume\u{301}"); + assert_eq!(TextCase::Title.apply("re\u{301}sume\u{301} bon"), "Re\u{301}sume\u{301} Bon"); + assert_eq!(TextCase::Title.apply("a \u{301}b"), "A \u{301}B"); + assert_eq!(TextCase::Title.apply("cafe\u{301}'s"), "Cafe\u{301}'s"); + } +} + +#[cfg(test)] +mod whole_text_paragraph_style_tests { + use super::*; + + #[test] + fn a_text_style_on_the_whole_layer_reaches_a_trailing_empty_paragraph() { + let ps = ParagraphStyle { list: ListStyle::Bulleted, ..Default::default() }; + let mut t = TextData::new("a\nb\n"); + t.take_paragraph_style(&ps); + assert_eq!(t.paragraph_count(), 3); + assert_eq!(t.para_list(2).list, ListStyle::Bulleted); + assert_eq!(t.list, ListStyle::Bulleted); + assert!(t.paragraphs.is_empty()); + let mut plain = TextData::new("a\nb"); + plain.take_paragraph_style(&ps); + assert_eq!((plain.list, plain.paragraphs.len()), (t.list, t.paragraphs.len())); + } +} From 98710acb801b7c4a8b08ea7b670ffc46d4cc36bb Mon Sep 17 00:00:00 2001 From: Filip Strajnar Date: Fri, 6 Nov 2026 12:39:39 +0100 Subject: [PATCH 17/18] M7: closing a tab with unsaved changes asks Don't Save / Cancel / Save (#2032) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing a tab whose file has unsaved changes, by its X, a middle click, File ▸ Close tab or its shortcut, only showed a toast ("save first, or close again with Alt to discard"; from the menu "close the tab with Alt-click") and left the tab open, so closing a tab seemed not to work. A `file.close` refused for unsaved changes asked the UI with `dialog.unsaved`, which toasted too. Closing such a tab now asks the unsaved-changes question the window asks when it closes (`windows::unsaved_question`, shared): Don't Save closes the tab and drops the changes, Cancel or Escape keeps it, Save saves the file next frame and then closes the tab. A file without a path, or saved as .fig, asks where with Save as first; the tab closes once that saved it and stays open if it is cancelled or fails. `UiState::close_tab` (`windows::TabClose`: Ask, Save, AfterSaveAs) keeps where it is; `ask_close_tab` starts it for the tab strip, the menu and `dialog.unsaved`. Alt+close still drops the changes without asking. The three toasts are gone from the code and the catalogs. Reordering tabs by dragging, the issue's other half, already works on main (landed 2026-10-19, `dragging_a_tab_along_the_bar_reorders_it_without_detaching`). Tests: `titlebar_tests::closing_a_tab_with_unsaved_changes_asks` (the X asks, Escape keeps the tab, Don't Save closes it, Alt+click closes without asking, File ▸ Close tab asks), `titlebar_tests::save_in_the_tab_question_saves_then_closes` (Enter saves then closes; no path: the tab stays). The title bar test harness can hold modifier keys. Docs: ui-parity.md, ROADMAP.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LvG6E2LxvJ6MuFmiLA6Zqm --- ROADMAP.md | 3 +- crates/ui-egui/locales/ar.toml | 3 - crates/ui-egui/locales/bn.toml | 3 - crates/ui-egui/locales/bs.toml | 3 - crates/ui-egui/locales/cs.toml | 3 - crates/ui-egui/locales/de.toml | 3 - crates/ui-egui/locales/en.toml | 3 - crates/ui-egui/locales/es.toml | 3 - crates/ui-egui/locales/fi.toml | 3 - crates/ui-egui/locales/fr.toml | 3 - crates/ui-egui/locales/id.toml | 3 - crates/ui-egui/locales/it.toml | 3 - crates/ui-egui/locales/ja.toml | 3 - crates/ui-egui/locales/ka.toml | 3 - crates/ui-egui/locales/ko.toml | 3 - crates/ui-egui/locales/nl.toml | 3 - crates/ui-egui/locales/pl.toml | 3 - crates/ui-egui/locales/pt-BR.toml | 3 - crates/ui-egui/locales/ru.toml | 3 - crates/ui-egui/locales/sv.toml | 3 - crates/ui-egui/locales/th.toml | 3 - crates/ui-egui/locales/tr.toml | 3 - crates/ui-egui/locales/vi.toml | 3 - crates/ui-egui/locales/zh-Hans.toml | 3 - crates/ui-egui/src/app/commands.rs | 12 ++- crates/ui-egui/src/dialogs.rs | 6 ++ crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/menus.rs | 6 +- crates/ui-egui/src/tabs.rs | 13 ++- crates/ui-egui/src/titlebar_tests.rs | 111 ++++++++++++++++++++++++- crates/ui-egui/src/windows.rs | 119 ++++++++++++++++++++++++++- docs/ui-parity.md | 5 +- 32 files changed, 255 insertions(+), 92 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 60f5962d2..917f0fb24 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (glass bends towards its core, and rounded rectangles work it out in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** patch (closing a tab whose file has unsaved changes (its X, a middle click, File ▸ Close tab) asks Don't Save / Cancel / Save as closing the window does, instead of a toast that left it open (#2032); glass bends towards its core, and rounded rectangles work it out in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -358,6 +358,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-11-06 | patch | M7, #2032: closing a tab whose file has unsaved changes asks Don't Save / Cancel / Save, as closing the window does, instead of a toast that left the tab open; Alt+close still discards at once. Tests: `titlebar_tests::closing_a_tab_with_unsaved_changes_asks`, `titlebar_tests::save_in_the_tab_question_saves_then_closes`. Numbers unchanged | | 2026-11-02 | patch | M8, #1885: a glass whose shape is too big to keep whole at the zoom (past `MAX_SHAPE_PX` with its core's margin) bends along its edge normals instead of working out a core, so its fields never cover more pixels than a kept shape's, and the core's feature transform holds 16 bytes a pixel. The edge normals are box-blurred twice again and the bend directions once, as documented. Test: `glass::tests::a_shape_too_big_to_keep_bends_along_its_normals`. Numbers unchanged | | 2026-11-02 | minor | M3, #1885: glass bends the backdrop towards its core, the shape shrunk by `depth` and the bevel, as the reference tool's: round a sharp corner the bend turns smoothly instead of meeting along the diagonal, and a part thinner than twice that (a capsule, a letter's stroke) bends across itself towards its own middle (pixels about as deep as any within 2 px). Rectangles, frames, components, instances and sections whose corners aren't smoothed work their glass out in closed form, with nothing to rasterise, transform or keep: a zoom frame draws in 40 ms instead of 54 and a resize frame in 46 instead of 67 (`glass_render_timings`, release, 2×). Other shapes keep their fields, with the bend directions for the last two depths asked for beside them, so dragging the Depth slider works out only those again. Tests: `glass::tests::a_sharp_corner_bends_round_towards_the_core`, `glass::tests::a_thin_part_bends_across_itself`, `glass::tests::neighbouring_strokes_bend_towards_their_own_middles`, `glass::tests::a_rounded_rectangle_in_closed_form_matches_its_distance_transform`, `glass_cut_tests::what_the_shape_depends_on_works_it_out_again_and_nothing_else_does`. Numbers unchanged | | 2026-11-02 | minor | M3, #1885: glass's light is a line on its 1-unit bevel added to the backdrop, as the reference tool's: fully where the edge faces the light, none below 0.35 of facing it and 0.6 as much on the opposite side, with a faint gradient across the curve, 5% darker facing the light and lighter opposite (it was a glow mixed towards white, as deep as `splay` made it). `splay` turns the bend from along the edge's normal towards the way away from the layer's centre, as the reference tool's does, and no longer widens the light: files with a splay above 0 look different. The direction to the centre is worked out in the shape's own frame, so cut renders match. Tests: `glass::tests::the_light_lies_on_the_bevel_and_the_gradient_across_the_curve`, `backdrop_tests::splay_turns_the_bend_towards_the_centre`, `glass_cut_tests::the_light_is_a_line_on_the_bevel`. Numbers unchanged | diff --git a/crates/ui-egui/locales/ar.toml b/crates/ui-egui/locales/ar.toml index a3dcf0844..da4474e79 100644 --- a/crates/ui-egui/locales/ar.toml +++ b/crates/ui-egui/locales/ar.toml @@ -1520,7 +1520,6 @@ status = "draft — needs native review" "This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "تحتفظ هذه الصفحة برمز المالك ما دامت مفتوحة فقط. انسخه لإدارة الملف لاحقًا؛ فهو كلمة مرور لهذا الملف ولن يُعرض مجددًا." "This condition is edited as an expression" = "يُحرر هذا الشرط كتعبير" "This file" = "هذا الملف" -"This file has unsaved changes" = "يحتوي هذا الملف على تغييرات غير محفوظة" "This file is too large to join here (up to {mb} MB)" = "هذا الملف أكبر من أن يمكن الانضمام إليه هنا (الحد {mb} ميغابايت)" "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "يستخدم هذا الملف خطوطًا غير مثبتة على هذا الحاسوب. يُعرض نصها بخط بديل حتى تثبتها." "This file is on {server}, open only to you. Share it to let others in with a new code." = "هذا الملف على {server} ومتاح لك وحدك. شاركه ليتمكن الآخرون من الانضمام برمز جديد." @@ -1580,8 +1579,6 @@ status = "draft — needs native review" "Unresolve" = "إعادة فتح" "Unsaved changes" = "تغييرات غير محفوظة" "Unsaved changes in {names}. Save them before closing?" = "تغييرات غير محفوظة في {names}. هل تريد حفظها قبل الإغلاق؟" -"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "تغييرات غير محفوظة: احفظ أولًا ({save})، أو أغلق علامة التبويب بالنقر مع {alt} لتجاهلها" -"Unsaved changes: save first, or close again with {key} to discard" = "تغييرات غير محفوظة: احفظ أولًا، أو أغلق مجددًا مع {key} لتجاهلها" "Up" = "لأعلى" "Update check is not supported in the web browser." = "التحقق من التحديثات غير مدعوم في المتصفح." "Update plugin" = "تحديث الإضافة" diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index 32d66721f..5d01928bf 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -1476,7 +1476,6 @@ status = "draft — needs native review" "This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "এই পেজটি খোলা থাকা পর্যন্তই ওনার টোকেন রাখে। পরে ফাইলটি পরিচালনা করতে এটি কপি করুন: এটি এই ফাইলের জন্য একটি পাসওয়ার্ড এবং আর কখনো দেখানো হবে না।" "This condition is edited as an expression" = "এই শর্তটি এক্সপ্রেশন হিসেবে এডিট করা হয়েছে" "This file" = "এই ফাইল" -"This file has unsaved changes" = "এই ফাইলে সেভ করা হয়নি এমন পরিবর্তন রয়েছে" "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "এই ফাইলে এমন ফন্ট ব্যবহার করা হয়েছে যা এই কম্পিউটারে ইনস্টল করা নেই। আপনি ইনস্টল না করা পর্যন্ত সেগুলোর টেক্সট একটি ফলব্যাক ফন্টে দেখানো হয়।" "This file is on {server}, open only to you. Share it to let others in with a new code." = "এই ফাইলটি {server}-এ আছে, শুধুমাত্র আপনার জন্য খোলা। নতুন কোড দিয়ে অন্যদের প্রবেশ করাতে এটি শেয়ার করুন।" "This frame is new on the branch." = "এই ফ্রেমটি ব্রাঞ্চে নতুন।" @@ -1531,8 +1530,6 @@ status = "draft — needs native review" "Unresolve" = "অমীমাংসিত করুন" "Unsaved changes" = "সংরক্ষিত হয়নি এমন পরিবর্তন" "Unsaved changes in {names}. Save them before closing?" = "{names}-এ সংরক্ষিত হয়নি এমন পরিবর্তন রয়েছে। বন্ধ করার আগে সেগুলো সংরক্ষণ করবেন?" -"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "সংরক্ষিত হয়নি এমন পরিবর্তন: প্রথমে সেভ করুন ({save}), অথবা বাতিল করতে {alt}-ক্লিক করে ট্যাব বন্ধ করুন" -"Unsaved changes: save first, or close again with {key} to discard" = "সংরক্ষিত হয়নি এমন পরিবর্তন: প্রথমে সেভ করুন, অথবা বাতিল করতে {key} দিয়ে আবার বন্ধ করুন" "Up" = "উপরে" "Update plugin" = "প্লাগইন আপডেট করুন" "Update" = "আপডেট" diff --git a/crates/ui-egui/locales/bs.toml b/crates/ui-egui/locales/bs.toml index 72f0268bd..36d71664c 100644 --- a/crates/ui-egui/locales/bs.toml +++ b/crates/ui-egui/locales/bs.toml @@ -1533,7 +1533,6 @@ status = "draft — needs native review" "This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Ova stranica čuva token vlasnika samo dok je otvorena. Kopirajte ga da kasnije upravljate datotekom: to je lozinka za ovu datoteku i neće se ponovo prikazati." "This condition is edited as an expression" = "Ovaj uslov se uređuje kao izraz" "This file" = "Ova datoteka" -"This file has unsaved changes" = "Ova datoteka ima nesačuvane promjene" "This file is too large to join here (up to {mb} MB)" = "Ova datoteka je prevelika za pridruživanje ovdje (do {mb} MB)" "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Ova datoteka koristi fontove koji nisu instalirani na ovom računaru. Njihov tekst se iscrtava rezervnim fontom dok ih ne instalirate." "This file is on {server}, open only to you. Share it to let others in with a new code." = "Ova datoteka je na serveru {server}, otvorena samo Vama. Podijelite je da drugi uđu s novim kodom." @@ -1594,8 +1593,6 @@ status = "draft — needs native review" "Unresolve" = "Poništi rješenje" "Unsaved changes" = "Nesačuvane promjene" "Unsaved changes in {names}. Save them before closing?" = "Nesačuvane promjene u: {names}. Sačuvati ih prije zatvaranja?" -"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Nesačuvane promjene: prvo sačuvajte ({save}) ili zatvorite karticu uz {alt}-klik da ih odbacite" -"Unsaved changes: save first, or close again with {key} to discard" = "Nesačuvane promjene: prvo sačuvajte ili ponovo zatvorite s {key} da ih odbacite" "Up" = "Gore" "Update check is not supported in the web browser." = "Provjera ažuriranja nije podržana u web pregledniku." "Update plugin" = "Ažuriraj dodatak" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 16de8065a..d401654ac 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -1448,7 +1448,6 @@ status = "draft — needs native review" "This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Tato stránka si token vlastníka pamatuje, jen dokud je otevřená. Zkopírujte ho pro pozdější správu souboru: je to heslo k tomuto souboru a znovu se nezobrazí." "This condition is edited as an expression" = "Tato podmínka se upravuje jako výraz" "This file" = "Tento soubor" -"This file has unsaved changes" = "Tento soubor obsahuje neuložené změny" "This file is on {server}, open only to you. Share it to let others in with a new code." = "Tento soubor je na serveru {server}, přístupný pouze vám. Sdílením umožníte ostatním připojení pomocí nového kódu." "This frame is new on the branch." = "Tento rámec je ve větvi nový." "This frame isn't on the branch." = "Tento rámec není ve větvi." @@ -1501,8 +1500,6 @@ status = "draft — needs native review" "Unresolve" = "Znovu otevřít" "Unsaved changes" = "Neuložené změny" "Unsaved changes in {names}. Save them before closing?" = "Neuložené změny v {names}. Uložit je před zavřením?" -"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Neuložené změny: nejprve uložte ({save}) nebo zavřete kartu kliknutím s {alt} a zahoďte je" -"Unsaved changes: save first, or close again with {key} to discard" = "Neuložené změny: nejprve uložte nebo zavřete znovu s {key} a zahoďte je" "Up" = "Nahoru" "Update plugin" = "Aktualizovat plugin" "Update" = "Aktualizovat" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 344e70912..b6f55f821 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -1416,7 +1416,6 @@ status = "draft — needs native review" "This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Diese Seite behält das Eigentümer-Token nur, solange sie geöffnet ist. Kopieren Sie es, um die Datei später zu verwalten: Es ist ein Passwort für diese Datei und wird nicht noch einmal angezeigt." "This condition is edited as an expression" = "Diese Bedingung wird als Ausdruck bearbeitet" "This file" = "Diese Datei" -"This file has unsaved changes" = "Diese Datei hat nicht gespeicherte Änderungen" "This file is on {server}, open only to you. Share it to let others in with a new code." = "Diese Datei liegt auf {server} und ist nur für Sie zugänglich. Teilen Sie sie, um andere mit einem neuen Code hereinzulassen." "This frame is new on the branch." = "Dieser Rahmen ist im Branch neu." "This frame isn't on the branch." = "Dieser Rahmen ist nicht im Branch." @@ -1469,8 +1468,6 @@ status = "draft — needs native review" "Unresolve" = "Wieder öffnen" "Unsaved changes" = "Nicht gespeicherte Änderungen" "Unsaved changes in {names}. Save them before closing?" = "Nicht gespeicherte Änderungen in {names}. Vor dem Schließen speichern?" -"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Nicht gespeicherte Änderungen: Speichern Sie zuerst ({save}) oder schließen Sie den Tab mit {alt}+Klick, um sie zu verwerfen" -"Unsaved changes: save first, or close again with {key} to discard" = "Nicht gespeicherte Änderungen: Speichern Sie zuerst oder schließen Sie erneut mit {key}, um sie zu verwerfen" "Up" = "Nach oben" "Update" = "Aktualisieren" "Update from main" = "Aus Hauptdatei aktualisieren" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 11f0bb4ac..fbce00adf 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1573,7 +1573,6 @@ name = "English" "This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." "This condition is edited as an expression" = "This condition is edited as an expression" "This file" = "This file" -"This file has unsaved changes" = "This file has unsaved changes" "This file is too large to join here (up to {mb} MB)" = "This file is too large to join here (up to {mb} MB)" "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." "This file is on {server}, open only to you. Share it to let others in with a new code." = "This file is on {server}, open only to you. Share it to let others in with a new code." @@ -1635,8 +1634,6 @@ name = "English" "Unresolve" = "Unresolve" "Unsaved changes" = "Unsaved changes" "Unsaved changes in {names}. Save them before closing?" = "Unsaved changes in {names}. Save them before closing?" -"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" -"Unsaved changes: save first, or close again with {key} to discard" = "Unsaved changes: save first, or close again with {key} to discard" "Up" = "Up" "Update check is not supported in the web browser." = "Update check is not supported in the web browser." "Update plugin" = "Update plugin" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index b9e004dd0..e6e8ecd7f 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1515,7 +1515,6 @@ status = "draft — needs native review" "This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Esta página guarda el token de propietario solo mientras está abierta. Cópialo para gestionar el archivo más tarde: es una contraseña para este archivo y no se volverá a mostrar." "This condition is edited as an expression" = "Esta condición se edita como expresión" "This file" = "Este archivo" -"This file has unsaved changes" = "Este archivo tiene cambios sin guardar" "This file is too large to join here (up to {mb} MB)" = "Este archivo es demasiado grande para unirte aquí (hasta {mb} MB)" "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Este archivo usa fuentes que no están instaladas en este equipo. Su texto se dibuja con una fuente de reserva hasta que las instales." "This file is on {server}, open only to you. Share it to let others in with a new code." = "Este archivo está en {server}, abierto solo para ti. Compártelo para dejar entrar a otros con un código nuevo." @@ -1573,8 +1572,6 @@ status = "draft — needs native review" "Unresolve" = "Reabrir" "Unsaved changes" = "Cambios sin guardar" "Unsaved changes in {names}. Save them before closing?" = "Hay cambios sin guardar en {names}. ¿Guardarlos antes de cerrar?" -"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Cambios sin guardar: guarda primero ({save}) o cierra la pestaña con {alt}+clic para descartarlos" -"Unsaved changes: save first, or close again with {key} to discard" = "Cambios sin guardar: guarda primero o vuelve a cerrar con {key} para descartarlos" "Up" = "Arriba" "Update plugin" = "Actualizar plugin" "Update" = "Actualizar" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 0c74f9de7..b3f8cee10 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -1546,7 +1546,6 @@ status = "draft — needs native review" "This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Tämä sivu säilyttää omistajan tunnisteen vain niin kauan kuin se on auki. Kopioi tunniste hallitaksesi tiedostoa myöhemmin. Se on tämän tiedoston salasana, eikä sitä näytetä uudelleen." "This condition is edited as an expression" = "Tätä ehtoa muokataan lausekkeena" "This file" = "Tämä tiedosto" -"This file has unsaved changes" = "Tässä tiedostossa on tallentamattomia muutoksia" "This file is too large to join here (up to {mb} MB)" = "Tiedosto on liian suuri liitettäväksi tähän (enintään {mb} Mt)" "This file is on {server}, open only to you. Share it to let others in with a new code." = "Tämä tiedosto on palvelimella {server} vain sinun käytössäsi. Jaa se, jotta muut voivat liittyä uudella koodilla." "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Tämä tiedosto käyttää fontteja, joita ei ole asennettu tälle tietokoneelle. Teksti piirretään korvaavalla fontilla, kunnes asennat puuttuvat fontit." @@ -1608,8 +1607,6 @@ status = "draft — needs native review" "Unresolve" = "Merkitse ratkaisemattomaksi" "Unsaved changes" = "Tallentamattomat muutokset" "Unsaved changes in {names}. Save them before closing?" = "Tallentamattomia muutoksia kohteissa {names}. Tallennetaanko ne ennen sulkemista?" -"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Tallentamattomia muutoksia: tallenna ensin ({save}) tai hylkää sulkemalla välilehti {alt}-napsautuksella" -"Unsaved changes: save first, or close again with {key} to discard" = "Tallentamattomia muutoksia: tallenna ensin tai hylkää sulkemalla uudelleen näppäimellä {key}" "Up" = "Ylös" "Update check is not supported in the web browser." = "Päivitysten tarkistusta ei tueta selaimessa." "Update" = "Päivitä" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index b30abe731..d47ad9597 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -1481,7 +1481,6 @@ status = "draft — needs native review" "This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Cette page ne garde le jeton de propriétaire que tant qu’elle est ouverte. Copiez-le pour gérer le fichier plus tard : c’est un mot de passe pour ce fichier et il ne sera plus affiché." "This condition is edited as an expression" = "Cette condition se modifie en tant qu’expression" "This file" = "Ce fichier" -"This file has unsaved changes" = "Ce fichier contient des modifications non enregistrées" "This file is on {server}, open only to you. Share it to let others in with a new code." = "Ce fichier est sur {server}, accessible à vous seul. Partagez-le pour laisser entrer d’autres personnes avec un nouveau code." "This frame is new on the branch." = "Ce cadre est nouveau dans la branche." "This frame isn't on the branch." = "Ce cadre n’est pas dans la branche." @@ -1536,8 +1535,6 @@ status = "draft — needs native review" "Unresolve" = "Rouvrir" "Unsaved changes" = "Modifications non enregistrées" "Unsaved changes in {names}. Save them before closing?" = "Modifications non enregistrées dans {names}. Les enregistrer avant de fermer ?" -"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Modifications non enregistrées : enregistrez d’abord ({save}) ou fermez l’onglet avec {alt}+clic pour les abandonner" -"Unsaved changes: save first, or close again with {key} to discard" = "Modifications non enregistrées : enregistrez d’abord ou fermez à nouveau avec {key} pour les abandonner" "Up" = "Haut" "Update" = "Mettre à jour" "Update from main" = "Mettre à jour depuis le fichier principal" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index da08f159d..cbebaf1c4 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -1454,7 +1454,6 @@ status = "draft — needs native review" "This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Halaman ini hanya menyimpan token pemilik selama halaman terbuka. Salin token untuk mengelola file nanti: token ini adalah kata sandi untuk file ini dan tidak akan ditampilkan lagi." "This condition is edited as an expression" = "Kondisi ini diedit sebagai ekspresi" "This file" = "File ini" -"This file has unsaved changes" = "File ini memiliki perubahan yang belum disimpan" "This file is on {server}, open only to you. Share it to let others in with a new code." = "File ini ada di {server}, hanya dapat dibuka oleh Anda. Bagikan untuk mengizinkan orang lain masuk dengan kode baru." "This frame is new on the branch." = "Frame ini baru di branch." "This frame isn't on the branch." = "Frame ini tidak ada di branch." @@ -1506,8 +1505,6 @@ status = "draft — needs native review" "Unresolve" = "Buka kembali" "Unsaved changes" = "Perubahan belum disimpan" "Unsaved changes in {names}. Save them before closing?" = "Perubahan belum disimpan di {names}. Simpan sebelum menutup?" -"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Perubahan belum disimpan: simpan dulu ({save}), atau tutup tab dengan {alt}-klik untuk membuangnya" -"Unsaved changes: save first, or close again with {key} to discard" = "Perubahan belum disimpan: simpan dulu, atau tutup lagi dengan {key} untuk membuangnya" "Up" = "Atas" "Update plugin" = "Perbarui plugin" "Update" = "Perbarui" diff --git a/crates/ui-egui/locales/it.toml b/crates/ui-egui/locales/it.toml index 3d2877e7e..364aeedf8 100644 --- a/crates/ui-egui/locales/it.toml +++ b/crates/ui-egui/locales/it.toml @@ -1413,7 +1413,6 @@ status = "draft — needs native review" "This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Questa pagina conserva il token del proprietario solo finché resta aperta. Copialo per gestire il file in seguito: è una password per questo file e non verrà mostrato di nuovo." "This condition is edited as an expression" = "Questa condizione viene modificata come espressione" "This file" = "Questo file" -"This file has unsaved changes" = "Questo file ha modifiche non salvate" "This file is too large to join here (up to {mb} MB)" = "Questo file è troppo grande per parteciparvi da qui (fino a {mb} MB)" "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Questo file usa font che non sono installati su questo computer. Il loro testo è disegnato con un font di riserva finché non li installi." "This file is on {server}, open only to you. Share it to let others in with a new code." = "Questo file è su {server}, aperto solo a te. Condividilo per far entrare altri con un nuovo codice." @@ -1464,8 +1463,6 @@ status = "draft — needs native review" "Unread" = "Non letto" "Unresolve" = "Riapri" "Unsaved changes" = "Modifiche non salvate" -"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Modifiche non salvate: salva prima ({save}), oppure chiudi la scheda con {alt}+clic per scartarle" -"Unsaved changes: save first, or close again with {key} to discard" = "Modifiche non salvate: salva prima, oppure chiudi di nuovo con {key} per scartarle" "Up" = "Su" "Update plugin" = "Aggiorna plugin" "Update" = "Aggiorna" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 8cf2da2d5..5a8918a20 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -1464,7 +1464,6 @@ status = "draft — needs native review" "This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "このページは開いている間だけ所有者トークンを保持する。後でファイルを管理するにはコピーしておくこと:このファイルのパスワードにあたり、再表示されない。" "This condition is edited as an expression" = "この条件は式として編集されている" "This file" = "このファイル" -"This file has unsaved changes" = "このファイルには未保存の変更がある" "This file is on {server}, open only to you. Share it to let others in with a new code." = "このファイルは {server} にあり、開けるのはあなただけ。共有すると、新しいコードで他の人が参加可能。" "This frame is new on the branch." = "このフレームはブランチで新規作成。" "This frame isn't on the branch." = "このフレームはブランチにない。" @@ -1519,8 +1518,6 @@ status = "draft — needs native review" "Unresolve" = "未解決に戻す" "Unsaved changes" = "未保存の変更" "Unsaved changes in {names}. Save them before closing?" = "{names} に未保存の変更がある。閉じる前に保存する?" -"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "未保存の変更:先に保存する({save})か、{alt}-クリックでタブを閉じて破棄" -"Unsaved changes: save first, or close again with {key} to discard" = "未保存の変更:先に保存するか、もう一度 {key} で閉じて破棄" "Up" = "上" "Update" = "更新" "Update from main" = "メインから更新" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index f634a2e7e..ae514a829 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -1495,7 +1495,6 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "ეს გვერდი მფლობელის ტოკენს მხოლოდ მანამ ინახავს, სანამ ღიაა. ფაილის მოგვიანებით სამართავად დააკოპირეთ ის: ეს ამ ფაილის პაროლია და აღარ გამოჩნდება." "This condition is edited as an expression" = "ეს პირობა ექსპრესიად რედაქტირდება" "This file" = "ეს ფაილი" -"This file has unsaved changes" = "ამ ფაილს შეუნახავი ცვლილებები აქვს" "This file is too large to join here (up to {mb} MB)" = "ეს ფაილი აქ შესაერთებლად ძალიან დიდია (მაქსიმუმ {mb} მბ)" "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "ეს ფაილი იყენებს შრიფტებს, რომლებიც ამ კომპიუტერზე დაინსტალირებული არ არის. მათი ტექსტი სათადარიგო შრიფტით გამოისახება, სანამ მათ არ დააინსტალირებთ." "This file is on {server}, open only to you. Share it to let others in with a new code." = "ეს ფაილი {server}-ზეა და მხოლოდ თქვენთვისაა ღია. გააზიარეთ, რომ სხვები ახალი კოდით შემოუშვათ." @@ -1554,8 +1553,6 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Unresolve" = "ხელახლა გახსნა" "Unsaved changes" = "შეუნახავი ცვლილებები" "Unsaved changes in {names}. Save them before closing?" = "შეუნახავი ცვლილებები: {names}. შევინახოთ დახურვამდე?" -"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "შეუნახავი ცვლილებები: ჯერ შეინახეთ ({save}) ან გასაუქმებლად დახურეთ ჩანართი {alt}+დაწკაპუნებით" -"Unsaved changes: save first, or close again with {key} to discard" = "შეუნახავი ცვლილებები: ჯერ შეინახეთ ან გასაუქმებლად ხელახლა დახურეთ {key}-ით" "Up" = "ზემოთ" "Update plugin" = "პლაგინის განახლება" "Update" = "განახლება" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index d52310997..ebea678d1 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -1399,7 +1399,6 @@ status = "draft — needs native review" "This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "이 페이지는 열려 있는 동안에만 소유자 토큰을 보관함. 나중에 파일을 관리하려면 복사해 두세요: 이 파일의 비밀번호와 같으며 다시 표시되지 않음." "This condition is edited as an expression" = "이 조건은 수식으로 편집됨" "This file" = "이 파일" -"This file has unsaved changes" = "이 파일에 저장되지 않은 변경 사항이 있음" "This file is on {server}, open only to you. Share it to let others in with a new code." = "이 파일은 {server}에 있으며 나만 열 수 있음. 공유하면 다른 사람들이 새 코드로 참여할 수 있음." "This frame is new on the branch." = "이 프레임은 브랜치에서 새로 만들어졌음." "This frame isn't on the branch." = "이 프레임은 브랜치에 없음." @@ -1452,8 +1451,6 @@ status = "draft — needs native review" "Unresolve" = "미해결로 되돌리기" "Unsaved changes" = "저장되지 않은 변경 사항" "Unsaved changes in {names}. Save them before closing?" = "{names}에 저장되지 않은 변경 사항이 있음. 닫기 전에 저장할까요?" -"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "저장되지 않은 변경 사항: 먼저 저장({save})하거나 {alt}-클릭으로 탭을 닫아 버리세요" -"Unsaved changes: save first, or close again with {key} to discard" = "저장되지 않은 변경 사항: 먼저 저장하거나 {key}로 다시 닫아 버리세요" "Up" = "위" "Update" = "업데이트" "Update from main" = "메인에서 업데이트" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 070ca4bb4..e43d40b5e 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -1481,7 +1481,6 @@ status = "draft — needs native review" "This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Deze pagina bewaart het eigenaartoken alleen zolang ze open is. Kopieer het om het bestand later te beheren: het is het wachtwoord voor dit bestand en wordt niet opnieuw weergegeven." "This condition is edited as an expression" = "Deze voorwaarde wordt als expressie bewerkt" "This file" = "Dit bestand" -"This file has unsaved changes" = "Dit bestand heeft niet-opgeslagen wijzigingen" "This file is on {server}, open only to you. Share it to let others in with a new code." = "Dit bestand staat op {server}, alleen toegankelijk voor jou. Deel het om anderen binnen te laten met een nieuwe code." "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Dit bestand gebruikt lettertypen die niet op deze computer zijn geïnstalleerd. Hun tekst wordt in een vervangend lettertype getoond tot je ze installeert." "This frame is new on the branch." = "Dit frame is nieuw op de branch." @@ -1536,8 +1535,6 @@ status = "draft — needs native review" "Unresolve" = "Oplossing ongedaan maken" "Unsaved changes" = "Niet-opgeslagen wijzigingen" "Unsaved changes in {names}. Save them before closing?" = "Niet-opgeslagen wijzigingen in {names}. Opslaan voor het sluiten?" -"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Niet-opgeslagen wijzigingen: sla eerst op ({save}), of sluit het tabblad met {alt}-klik om ze te verwerpen" -"Unsaved changes: save first, or close again with {key} to discard" = "Niet-opgeslagen wijzigingen: sla eerst op, of sluit nogmaals met {key} om te verwerpen" "Up" = "Omhoog" "Update" = "Bijwerken" "Update from main" = "Bijwerken vanaf main" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 05d9b60f8..68fe136d7 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -1488,7 +1488,6 @@ name = "Polski" "This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Ta strona przechowuje token właściciela tylko, dopóki jest otwarta. Skopiuj go, aby zarządzać plikiem później: jest to hasło do tego pliku i nie zostanie ponownie wyświetlone." "This condition is edited as an expression" = "Ten warunek jest edytowany jako wyrażenie" "This file" = "Ten plik" -"This file has unsaved changes" = "Ten plik ma niezapisane zmiany" "This file is too large to join here (up to {mb} MB)" = "Ten plik jest zbyt duży, aby do niego dołączyć (maksymalnie {mb} MB)" "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Ten plik używa czcionek, które nie są zainstalowane na tym komputerze. Do czasu ich zainstalowania tekst jest wyświetlany czcionką zastępczą." "This file is on {server}, open only to you. Share it to let others in with a new code." = "Ten plik znajduje się na {server}, otwarty tylko dla Ciebie. Udostępnij go, aby wpuścić innych nowym kodem." @@ -1545,8 +1544,6 @@ name = "Polski" "Unresolve" = "Cofnij rozwiązanie" "Unsaved changes" = "Niezapisane zmiany" "Unsaved changes in {names}. Save them before closing?" = "Niezapisane zmiany w: {names}. Zapisać je przed zamknięciem?" -"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Niezapisane zmiany: najpierw zapisz ({save}) lub zamknij kartę z {alt}-kliknięciem, aby odrzucić" -"Unsaved changes: save first, or close again with {key} to discard" = "Niezapisane zmiany: najpierw zapisz lub naciśnij ponownie {key}, aby odrzucić" "Up" = "W górę" "Update plugin" = "Zaktualizuj wtyczkę" "Update" = "Aktualizuj" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 1b00ce28c..c2ccba835 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -1397,7 +1397,6 @@ status = "draft — needs native review" "This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Esta página guarda o token de proprietário só enquanto estiver aberta. Copie-o para gerenciar o arquivo depois: ele é uma senha para este arquivo e não será mostrado de novo." "This condition is edited as an expression" = "Esta condição é editada como expressão" "This file" = "Este arquivo" -"This file has unsaved changes" = "Este arquivo tem alterações não salvas" "This file is on {server}, open only to you. Share it to let others in with a new code." = "Este arquivo está em {server}, aberto somente para você. Compartilhe-o para deixar outras pessoas entrarem com um novo código." "This frame is new on the branch." = "Este quadro é novo na branch." "This frame isn't on the branch." = "Este quadro não está na branch." @@ -1450,8 +1449,6 @@ status = "draft — needs native review" "Unresolve" = "Reabrir" "Unsaved changes" = "Alterações não salvas" "Unsaved changes in {names}. Save them before closing?" = "Há alterações não salvas em {names}. Salvá-las antes de fechar?" -"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Alterações não salvas: salve primeiro ({save}) ou feche a aba com {alt}+clique para descartá-las" -"Unsaved changes: save first, or close again with {key} to discard" = "Alterações não salvas: salve primeiro ou feche de novo com {key} para descartá-las" "Up" = "Para cima" "Update" = "Atualizar" "Update from main" = "Atualizar a partir do principal" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index a9a155818..01e1d86ac 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -1488,7 +1488,6 @@ name = "Русский" "This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Эта страница хранит токен владельца, только пока она открыта. Скопируйте токен, чтобы управлять файлом позже: это пароль к файлу, он больше не будет показан." "This condition is edited as an expression" = "Это условие редактируется как выражение" "This file" = "Этот файл" -"This file has unsaved changes" = "В файле есть несохранённые изменения" "This file is on {server}, open only to you. Share it to let others in with a new code." = "Файл находится на {server} и доступен только вам. Включите общий доступ, чтобы пригласить других по новому коду." "This file is too large to join here (up to {mb} MB)" = "Этот файл слишком большой, чтобы подключиться здесь (до {mb} МБ)" "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "В этом файле используются шрифты, не установленные на этом компьютере. Их текст отрисовывается запасным шрифтом, пока вы их не установите." @@ -1545,8 +1544,6 @@ name = "Русский" "Unresolve" = "Снова открыть" "Unsaved changes" = "Несохранённые изменения" "Unsaved changes in {names}. Save them before closing?" = "Несохранённые изменения в {names}. Сохранить их перед закрытием?" -"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Есть несохранённые изменения: сохраните ({save}) или закройте вкладку нажатием с {alt}, чтобы отбросить их" -"Unsaved changes: save first, or close again with {key} to discard" = "Есть несохранённые изменения: сохраните или закройте ещё раз с {key}, чтобы отбросить их" "Up" = "Вверх" "Update plugin" = "Обновить плагин" "Update" = "Обновить" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 9384ba152..a94feb2a9 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -1561,7 +1561,6 @@ name = "Svenska" "This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Den här sidan behåller ägartoken bara så länge den är öppen. Kopiera den om du vill hantera filen senare: den fungerar som ett lösenord för filen och visas inte igen." "This condition is edited as an expression" = "Det här villkoret redigeras som ett uttryck" "This file" = "Den här filen" -"This file has unsaved changes" = "Filen har osparade ändringar" "This file is too large to join here (up to {mb} MB)" = "Den här filen är för stor för att ansluta till här (högst {mb} MB)" "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Den här filen använder typsnitt som inte är installerade på den här datorn. Deras text ritas med ett reservtypsnitt tills du installerar dem." "This file is on {server}, open only to you. Share it to let others in with a new code." = "Filen ligger på {server} och är bara öppen för dig. Dela den för att släppa in andra med en ny kod." @@ -1623,8 +1622,6 @@ name = "Svenska" "Unresolve" = "Markera som olöst" "Unsaved changes" = "Osparade ändringar" "Unsaved changes in {names}. Save them before closing?" = "Osparade ändringar i {names}. Vill du spara dem innan du stänger?" -"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Osparade ändringar: spara först ({save}), eller stäng fliken med {alt}-klick för att slänga dem" -"Unsaved changes: save first, or close again with {key} to discard" = "Osparade ändringar: spara först, eller stäng igen med {key} för att slänga dem" "Up" = "Upp" "Update check is not supported in the web browser." = "Sökning efter uppdateringar stöds inte i webbläsaren." "Update plugin" = "Uppdatera plugin" diff --git a/crates/ui-egui/locales/th.toml b/crates/ui-egui/locales/th.toml index 45de7b858..81b581ce0 100644 --- a/crates/ui-egui/locales/th.toml +++ b/crates/ui-egui/locales/th.toml @@ -1470,7 +1470,6 @@ status = "draft — needs native review" "This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "หน้านี้เก็บโทเคนเจ้าของไว้เฉพาะตอนที่เปิดอยู่ คัดลอกไว้เพื่อจัดการไฟล์ภายหลัง: โทเคนนี้เป็นรหัสผ่านของไฟล์นี้และจะไม่แสดงอีก" "This condition is edited as an expression" = "เงื่อนไขนี้แก้ไขเป็นนิพจน์" "This file" = "ไฟล์นี้" -"This file has unsaved changes" = "ไฟล์นี้มีการเปลี่ยนแปลงที่ยังไม่ได้บันทึก" "This file is too large to join here (up to {mb} MB)" = "ไฟล์นี้ใหญ่เกินกว่าจะเข้าร่วมที่นี่ (สูงสุด {mb} MB)" "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "ไฟล์นี้ใช้ฟอนต์ที่ยังไม่ได้ติดตั้งในคอมพิวเตอร์เครื่องนี้ ข้อความจะแสดงด้วยฟอนต์สำรองจนกว่าคุณจะติดตั้ง" "This file is on {server}, open only to you. Share it to let others in with a new code." = "ไฟล์นี้อยู่บน {server} เปิดได้เฉพาะคุณ แชร์เพื่อให้คนอื่นเข้าได้ด้วยรหัสใหม่" @@ -1527,8 +1526,6 @@ status = "draft — needs native review" "Unresolve" = "ยกเลิกสถานะแก้ไขแล้ว" "Unsaved changes" = "การเปลี่ยนแปลงที่ยังไม่ได้บันทึก" "Unsaved changes in {names}. Save them before closing?" = "{names} มีการเปลี่ยนแปลงที่ยังไม่ได้บันทึก บันทึกก่อนปิดหรือไม่" -"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "มีการเปลี่ยนแปลงที่ยังไม่ได้บันทึก: บันทึกก่อน ({save}) หรือปิดแท็บด้วย {alt}+คลิกเพื่อทิ้งการเปลี่ยนแปลง" -"Unsaved changes: save first, or close again with {key} to discard" = "มีการเปลี่ยนแปลงที่ยังไม่ได้บันทึก: บันทึกก่อน หรือปิดอีกครั้งด้วย {key} เพื่อทิ้งการเปลี่ยนแปลง" "Up" = "ขึ้น" "Update plugin" = "อัปเดตปลั๊กอิน" "Update" = "อัปเดต" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index db05d1c93..b07d25462 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -1452,7 +1452,6 @@ name = "Türkçe" "This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Bu sayfa sahip belirtecini yalnızca açık kaldığı sürece tutar. Dosyayı daha sonra yönetmek için kopyalayın: bu, dosyanın parolasıdır ve bir daha gösterilmez." "This condition is edited as an expression" = "Bu koşul bir ifade olarak düzenleniyor" "This file" = "Bu dosya" -"This file has unsaved changes" = "Bu dosyada kaydedilmemiş değişiklikler var" "This file is on {server}, open only to you. Share it to let others in with a new code." = "Bu dosya {server} üzerinde ve yalnızca size açık. Başkalarını yeni bir kodla almak için paylaşın." "This frame is new on the branch." = "Bu çerçeve dalda yeni." "This frame isn't on the branch." = "Bu çerçeve dalda yok." @@ -1505,8 +1504,6 @@ name = "Türkçe" "Unresolve" = "Çözülmedi yap" "Unsaved changes" = "Kaydedilmemiş değişiklikler" "Unsaved changes in {names}. Save them before closing?" = "{names} içinde kaydedilmemiş değişiklikler var. Kapatmadan önce kaydedilsin mi?" -"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Kaydedilmemiş değişiklikler: önce kaydedin ({save}) veya atmak için sekmeyi {alt}-tıklamayla kapatın" -"Unsaved changes: save first, or close again with {key} to discard" = "Kaydedilmemiş değişiklikler: önce kaydedin veya atmak için {key} ile yeniden kapatın" "Up" = "Yukarı" "Update plugin" = "Eklentiyi güncelle" "Update" = "Güncelle" diff --git a/crates/ui-egui/locales/vi.toml b/crates/ui-egui/locales/vi.toml index 8b58fc67e..9a4d600fe 100644 --- a/crates/ui-egui/locales/vi.toml +++ b/crates/ui-egui/locales/vi.toml @@ -1414,7 +1414,6 @@ status = "draft — needs native review" "This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Trang này chỉ giữ mã thông báo chủ sở hữu khi còn mở. Hãy sao chép mã này để quản lý tệp sau: đây là mật khẩu của tệp và sẽ không hiển thị lại." "This condition is edited as an expression" = "Điều kiện này được chỉnh sửa dưới dạng biểu thức" "This file" = "Tệp này" -"This file has unsaved changes" = "Tệp này có thay đổi chưa lưu" "This file is on {server}, open only to you. Share it to let others in with a new code." = "Tệp này đang ở trên {server} và chỉ mình bạn mở được. Hãy chia sẻ để người khác tham gia bằng mã mới." "This file is too large to join here (up to {mb} MB)" = "Tệp này quá lớn để tham gia tại đây (tối đa {mb} MB)" "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Tệp này sử dụng phông chữ chưa được cài đặt trên máy tính này. Văn bản sẽ hiển thị bằng phông chữ dự phòng cho đến khi bạn cài đặt chúng." @@ -1465,8 +1464,6 @@ status = "draft — needs native review" "Unread" = "Chưa đọc" "Unresolve" = "Mở lại bình luận" "Unsaved changes" = "Thay đổi chưa lưu" -"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Có thay đổi chưa lưu: hãy lưu trước ({save}), hoặc giữ {alt} khi nhấp đóng thẻ để hủy bỏ" -"Unsaved changes: save first, or close again with {key} to discard" = "Có thay đổi chưa lưu: hãy lưu trước, hoặc đóng lại lần nữa bằng {key} để hủy bỏ" "Up" = "Lên" "Update" = "Cập nhật" "Update from main" = "Cập nhật từ nhánh chính" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index a6ecb8ab3..b48345acc 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -1407,7 +1407,6 @@ status = "draft — needs native review" "This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "此页面仅在打开期间保留所有者令牌。如需稍后管理此文件,请复制它:它相当于此文件的密码,且不会再次显示。" "This condition is edited as an expression" = "此条件以表达式编辑" "This file" = "此文件" -"This file has unsaved changes" = "此文件有未保存的更改" "This file is on {server}, open only to you. Share it to let others in with a new code." = "此文件在 {server} 上,只有你能打开。分享后,其他人可以用新代码加入。" "This frame is new on the branch." = "此画框是分支上新增的。" "This frame isn't on the branch." = "此画框不在分支上。" @@ -1460,8 +1459,6 @@ status = "draft — needs native review" "Unresolve" = "取消解决" "Unsaved changes" = "未保存的更改" "Unsaved changes in {names}. Save them before closing?" = "{names} 中有未保存的更改。关闭前要保存吗?" -"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "有未保存的更改:请先保存({save}),或按住 {alt} 点击关闭标签页以丢弃" -"Unsaved changes: save first, or close again with {key} to discard" = "有未保存的更改:请先保存,或再次按 {key} 关闭以丢弃" "Up" = "向上" "Update" = "更新" "Update from main" = "从主文件更新" diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 93762e579..061084f9b 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -1,6 +1,6 @@ use super::{ - Done, FigApp, LeftTab, MobilePanel, NodeId, PANEL_DEFAULT, RightTab, Value, comments, dev_compare, dialogs, home, i18n, import_report, json, - layers, live, menus, missing_fonts, plugins_ui, present, tabs, updates_ui, versions, + Done, FigApp, LeftTab, MobilePanel, NodeId, PANEL_DEFAULT, RightTab, Value, comments, dev_compare, dialogs, home, import_report, json, layers, + live, menus, missing_fonts, plugins_ui, present, tabs, updates_ui, versions, }; impl FigApp { @@ -270,7 +270,13 @@ impl FigApp { let y = r.data.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0); comments::start_draft(self, x, y); } - "dialog.unsaved" => self.toast(i18n::tr("This file has unsaved changes")), + "dialog.unsaved" => { + // `file.close` refused a file with unsaved changes: ask about it. + let i = r.data.get("index").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).unwrap_or(self.session.active); + if let Some(d) = self.session.docs.get(i) { + self.ui.close_tab = Some(crate::windows::TabClose::Ask(d.uid)); + } + } "dialog.plugins" => self.ui.plugins_window = true, "dialog.pluginSave" => plugins_ui::save_file(self, &r.data), "dialog.cursorChat" => live::open_chat(self), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index a1df8e750..52170af44 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -178,6 +178,7 @@ pub fn keys(app: &mut FigApp, ctx: &Context) { || u.import_report.is_some() || u.missing_fonts.is_some() || u.confirm_close + || matches!(u.close_tab, Some(crate::windows::TabClose::Ask(_))) || u.export_preview || u.shortcuts_panel || u.preferences @@ -194,6 +195,10 @@ pub fn keys(app: &mut FigApp, ctx: &Context) { { return; } + if matches!(u.close_tab, Some(crate::windows::TabClose::Ask(_))) { + u.close_tab = None; + return; + } for flag in [&mut u.confirm_close, &mut u.export_preview, &mut u.shortcuts_panel, &mut u.preferences, &mut u.about] { if std::mem::take(flag) { return; @@ -405,6 +410,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) { crate::live::publish_dialog(app, ctx); crate::online::dialogs(app, ctx); crate::windows::confirm_close(app, ctx); + crate::windows::confirm_close_tab(app, ctx); } /// Preferences ▸ Interface size: a size relative to the system's display scale (100 % is the diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a7397cee4..a2d1c34f3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -592,6 +592,8 @@ pub struct UiState { pub online: online::OnlineUi, /// The window was asked to close with unsaved changes: the question shows. pub confirm_close: bool, + /// A tab was asked to close with unsaved changes: the question for it, then its save. + pub close_tab: Option, /// The file a Save as started from the unsaved-changes question is saving: the window closes /// once it is saved, and stays open if the dialog is cancelled or the save fails. pub close_after_save_as: Option, @@ -715,6 +717,7 @@ impl Default for UiState { live: live::LiveUi::default(), online: online::OnlineUi::default(), confirm_close: false, + close_tab: None, close_after_save_as: None, save_on_close: false, } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 299d2a20f..98fd881d1 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -212,11 +212,7 @@ pub fn invoke(app: &mut FigApp, id: &str) { } "edit.paste" => paste(app, None, None), "file.close" if app.session.ds().is_ok_and(|d| d.is_dirty() && d.branch_of.is_none()) => { - let save = pretty_shortcut(crate::shortcuts::shortcut("file.save").unwrap_or("")); - app.toast(trf( - "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard", - &[("save", &save), ("alt", &pretty_shortcut("Alt"))], - )); + crate::windows::ask_close_tab(app, app.session.active); } _ => { app.run(id, json!({})); diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index e59552d12..558792d08 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -303,18 +303,15 @@ fn drag_free_space(ui: &mut Ui, rect: Rect, from_x: f32, to_x: f32) { } } -/// Closes tab `i`; unsaved changes need a second close with Alt held. +/// Closes tab `i`: with unsaved changes it asks first (Don't Save / Cancel / Save), and Alt held +/// drops them without asking. fn close_tab(app: &mut FigApp, ui: &Ui, i: usize) { // A branch tab's edits live on in its main file: closing it loses nothing. let dirty = app.session.docs.get(i).is_some_and(|d| d.is_dirty() && d.branch_of.is_none()); - if dirty { - app.ui.find = None; - app.toast(trf("Unsaved changes: save first, or close again with {key} to discard", &[("key", &crate::toolbar::pretty_shortcut("Alt"))])); - if ui.input(|inp| inp.modifiers.alt) { - app.run("file.close", json!({"index": i, "force": true})); - } + if dirty && ui.input(|inp| inp.modifiers.alt) { + app.run("file.close", json!({"index": i, "force": true})); } else { - app.run("file.close", json!({"index": i})); + crate::windows::ask_close_tab(app, i); } } diff --git a/crates/ui-egui/src/titlebar_tests.rs b/crates/ui-egui/src/titlebar_tests.rs index e329c7417..c36a05c23 100644 --- a/crates/ui-egui/src/titlebar_tests.rs +++ b/crates/ui-egui/src/titlebar_tests.rs @@ -16,13 +16,15 @@ struct Ui { time: f64, /// What the system reports about the window. maximized: bool, + /// Modifier keys held during the next frames. + modifiers: egui::Modifiers, } impl Ui { fn new(title_bar: TitleBar) -> Self { let mut app = FigApp::new(Session::new(), Box::new(NoServices)); app.ui.title_bar = title_bar; - let mut u = Self { ctx: egui::Context::default(), app, time: 0.0, maximized: false }; + let mut u = Self { ctx: egui::Context::default(), app, time: 0.0, maximized: false, modifiers: egui::Modifiers::NONE }; for _ in 0..3 { u.frame(vec![]); } @@ -30,8 +32,9 @@ impl Ui { } /// Run one frame; returns the window commands it sent. - fn frame(&mut self, events: Vec) -> Vec { + fn frame(&mut self, mut events: Vec) -> Vec { self.time += 1.0 / 60.0; + events.insert(0, Event::ModifiersChanged(self.modifiers)); let mut input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, SIZE)), time: Some(self.time), events, ..Default::default() }; input.viewports.entry(ViewportId::ROOT).or_default().maximized = Some(self.maximized); let app = &mut self.app; @@ -242,3 +245,107 @@ fn a_system_title_bar_leaves_the_window_alone() { assert!(u.drag(EMPTY, vec2(40.0, 10.0)).is_empty()); assert!(u.drag(pos2(SIZE.x - 2.0, SIZE.y - 2.0), vec2(-30.0, -30.0)).iter().all(|c| !matches!(c, ViewportCommand::BeginResize(_)))); } + +/// The main window's first tab closing: (its file's uid, the close button of the first tab). +fn first_tab(u: &Ui) -> (u64, Pos2) { + (u.app.session.docs[0].uid, pos2(156.0, 20.0)) +} + +/// Whether file `uid` is still open in a tab. +fn open(u: &Ui, uid: u64) -> bool { + u.app.session.docs.iter().any(|d| d.uid == uid) +} + +/// Whether the unsaved-changes question is on screen. +fn asking(u: &Ui) -> bool { + u.ctx.memory(|memory| memory.area_rect(egui::Id::unique(Some(crate::i18n::tr("Unsaved changes")))).is_some()) +} + +/// #2032: closing a tab whose file has unsaved changes asks Don't Save / Cancel / Save, as +/// closing the window does, instead of a toast that left the tab open. Escape (or Cancel) keeps the +/// tab, Don't Save closes it, Alt+click closes it without asking, and File ▸ Close tab asks too. +#[test] +fn closing_a_tab_with_unsaved_changes_asks() { + use crate::windows::{CloseAnswer, TabClose, answer_close_tab}; + let mut u = Ui::new(TitleBar::Custom); + u.app.session.execute("file.new", &json!({})).unwrap(); + u.app.session.execute("doc.switch", &json!({"index": 0})).unwrap(); + u.app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let (uid, close) = first_tab(&u); + u.frame(vec![Event::PointerMoved(close)]); + u.click(close); + u.frame(vec![]); + assert!(open(&u, uid), "the tab waits for an answer"); + assert_eq!(u.app.ui.close_tab, Some(TabClose::Ask(uid))); + assert!(asking(&u), "the question is on screen"); + // Escape is Cancel: the tab stays, with its changes. + u.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]); + u.frame(vec![]); + assert!(open(&u, uid), "Escape keeps the tab"); + assert!(u.app.ui.close_tab.is_none(), "and ends the question"); + // Don't Save closes it. + u.click(close); + assert_eq!(u.app.ui.close_tab, Some(TabClose::Ask(uid))); + let ctx = u.ctx.clone(); + answer_close_tab(&mut u.app, &ctx, CloseAnswer::DontSave); + assert!(!open(&u, uid), "Don't Save closes the tab"); + // Alt+click drops the changes without asking. + u.app.session.execute("doc.switch", &json!({"index": 0})).unwrap(); + u.app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + u.app.session.execute("file.new", &json!({})).unwrap(); + let (uid, close) = first_tab(&u); + u.frame(vec![Event::PointerMoved(close)]); + u.modifiers = egui::Modifiers::ALT; + u.click(close); + u.modifiers = egui::Modifiers::NONE; + assert!(!open(&u, uid), "Alt+click closes without asking"); + assert!(u.app.ui.close_tab.is_none()); + // File ▸ Close tab (and its shortcut) asks about the file shown. + u.app.session.execute("doc.switch", &json!({"index": 0})).unwrap(); + u.app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let uid = u.app.session.docs[0].uid; + crate::menus::invoke(&mut u.app, "file.close"); + assert_eq!(u.app.ui.close_tab, Some(TabClose::Ask(uid))); + assert!(open(&u, uid)); +} + +/// #2032: Save in the question saves the file, then closes its tab; a file without a path asks +/// where with Save as first and the tab stays open until it is saved. +#[test] +fn save_in_the_tab_question_saves_then_closes() { + let dir = std::env::temp_dir().join(format!("figcraft-tab-close-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("kept.figcraft"); + let mut u = Ui::new(TitleBar::Custom); + u.app.session.execute("file.new", &json!({})).unwrap(); + u.app.session.execute("doc.switch", &json!({"index": 0})).unwrap(); + u.app.session.execute("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); + let saved = std::fs::metadata(&path).unwrap().len(); + u.app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 40})).unwrap(); + let (uid, close) = first_tab(&u); + u.frame(vec![Event::PointerMoved(close)]); + u.click(close); + u.frame(vec![]); + assert!(asking(&u)); + // Enter answers Save. + u.frame(vec![Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]); + for _ in 0..3 { + u.frame(vec![]); + } + assert!(!open(&u, uid), "saved, then closed"); + assert!(std::fs::metadata(&path).unwrap().len() > saved, "the rectangle was saved"); + // No path: Save as starts (headless: no dialog can open), and the tab stays open. + u.app.session.execute("doc.switch", &json!({"index": 0})).unwrap(); + u.app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 40})).unwrap(); + let (uid, close) = first_tab(&u); + u.frame(vec![Event::PointerMoved(close)]); + u.click(close); + let ctx = u.ctx.clone(); + crate::windows::answer_close_tab(&mut u.app, &ctx, crate::windows::CloseAnswer::Save); + for _ in 0..3 { + u.frame(vec![]); + } + assert!(open(&u, uid), "nothing saved it: the tab stays"); + assert!(u.app.ui.close_tab.is_none()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index f9f6067d7..3397e00de 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -445,6 +445,15 @@ pub(crate) fn confirm_close(app: &mut FigApp, ctx: &Context) { app.ui.confirm_close = false; return; } + if let Some(a) = unsaved_question(ctx, &names) { + answer_close(app, ctx, a); + } +} + +/// The unsaved-changes question about files `names` (Don't Save / Cancel / Save, as the other +/// Craft apps ask it; Enter answers Save, the dialog's close button Cancel): `None` while it is +/// unanswered. +fn unsaved_question(ctx: &Context, names: &[String]) -> Option { let t = crate::theme::Tokens::get(ctx); let mut open = true; let mut answer = None; @@ -471,8 +480,114 @@ pub(crate) fn confirm_close(app: &mut FigApp, ctx: &Context) { if !open { answer = Some(CloseAnswer::Cancel); } - if let Some(a) = answer { - answer_close(app, ctx, a); + answer +} + +/// Where closing a tab whose file has unsaved changes is (each step names the file's uid). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TabClose { + /// The unsaved-changes question shows for it. + Ask(u64), + /// Save was answered: it saves next frame, once a panel field the click took the keyboard + /// from has committed what was typed, then the tab closes. + Save(u64), + /// Its Save as dialog (a file without a path, or a .fig) is open: the tab closes once the file + /// is saved, and stays open if the dialog is cancelled or the save fails. + AfterSaveAs(u64), +} + +/// Close tab `i` (its X, a middle click, File ▸ Close tab): at once when its file has no unsaved +/// changes (a branch tab's edits are in its main file already), else after the unsaved-changes +/// question ([`confirm_close_tab`]). +pub(crate) fn ask_close_tab(app: &mut FigApp, i: usize) { + let Some(d) = app.session.docs.get(i) else { return }; + if d.is_dirty() && d.branch_of.is_none() { + app.ui.find = None; + app.ui.close_tab = Some(TabClose::Ask(d.uid)); + } else { + app.run("file.close", json!({"index": i})); + } +} + +/// Act on the unsaved-changes question's answer for the tab it asks about: Cancel keeps the tab, +/// Don't Save closes it and drops the changes, Save saves the file next frame, then closes it. +pub(crate) fn answer_close_tab(app: &mut FigApp, ctx: &Context, answer: CloseAnswer) { + let Some(TabClose::Ask(uid)) = app.ui.close_tab else { return }; + app.ui.close_tab = None; + match answer { + CloseAnswer::Cancel => {} + CloseAnswer::DontSave => { + if let Some(i) = tab_of(app, uid) { + app.run("file.close", json!({"index": i, "force": true})); + } + } + CloseAnswer::Save => { + app.ui.close_tab = Some(TabClose::Save(uid)); + ctx.request_repaint(); + } + } +} + +/// Tab `i` of the session whose file has uid `uid`, if it is still open. +fn tab_of(app: &FigApp, uid: u64) -> Option { + app.session.docs.iter().position(|d| d.uid == uid) +} + +/// The unsaved-changes question for a closing tab and what its answer starts: Don't Save closes +/// the tab and drops the changes, Cancel keeps it, Save saves the file (Save as for one without a +/// path or saved as .fig) and closes the tab once it is saved. +pub(crate) fn confirm_close_tab(app: &mut FigApp, ctx: &Context) { + let Some(step) = app.ui.close_tab else { return }; + let (TabClose::Ask(uid) | TabClose::Save(uid) | TabClose::AfterSaveAs(uid)) = step; + let Some(i) = tab_of(app, uid) else { + app.ui.close_tab = None; + return; + }; + let dirty = app.session.docs.get(i).is_some_and(DocState::is_dirty); + match step { + TabClose::Ask(_) if !dirty => { + // Saved meanwhile: nothing is lost by closing. + app.ui.close_tab = None; + app.run("file.close", json!({"index": i})); + } + TabClose::Ask(_) => { + let names: Vec = app.session.docs.get(i).map(|d| d.doc.name.clone()).into_iter().collect(); + if let Some(a) = unsaved_question(ctx, &names) { + answer_close_tab(app, ctx, a); + } + } + TabClose::Save(_) => { + app.ui.close_tab = None; + // Saving acts on the file shown. + app.session.active = i; + let native = app.session.docs.get(i).and_then(|d| d.path.as_deref()).is_some_and(|p| { + !std::path::Path::new(p).extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("fig")) + }); + if !native { + app.ui.close_tab = Some(TabClose::AfterSaveAs(uid)); + app.save_as_dialog(); + if app.pending_dialog.is_none() { + // No dialog could start: the tab stays open. + app.ui.close_tab = None; + } + ctx.request_repaint(); + return; + } + if app.run("file.save", json!({})).is_some() + && let Some(i) = tab_of(app, uid) + { + app.run("file.close", json!({"index": i})); + } + } + TabClose::AfterSaveAs(_) => { + if app.pending_dialog.is_some() { + return; + } + app.ui.close_tab = None; + if !dirty { + app.run("file.close", json!({"index": i})); + } + } } } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index d98a6ed73..611c3f400 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** patch (a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** patch (closing a tab whose file has unsaved changes (its X, a middle click, File ▸ Close tab) asks Don't Save / Cancel / Save as closing the window does, instead of a toast that left it open (#2032); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -15,7 +15,7 @@ interaction feel not yet compared. | Item | Status | Notes | |---|---|---| -| UI3 layout: tab strip as title bar, icon rail, file header, pages + layers, floating bottom toolbar with flyouts, Design / Prototype / Inspect panels | done | frameless window on Windows/Linux with drawn buttons; on a Wayland compositor that offers no server-side decorations (GNOME) the window gets winit's Adwaita controls instead (#592; checked by its author on GNOME, KDE not run) | +| UI3 layout: tab strip as title bar, icon rail, file header, pages + layers, floating bottom toolbar with flyouts, Design / Prototype / Inspect panels | done | frameless window on Windows/Linux with drawn buttons; tabs reorder by dragging along the strip, and closing one whose file has unsaved changes asks Don't Save / Cancel / Save (Alt+close drops the changes at once, #2032); on a Wayland compositor that offers no server-side decorations (GNOME) the window gets winit's Adwaita controls instead (#592; checked by its author on GNOME, KDE not run) | | Light and dark themes | done | colours from `theme::Tokens`; a file or a page made while the dark theme shows gets a `#1E1E1E` page (`#F5F5F5` in the light theme), its own from then on: changing the theme repaints no page and opened files keep theirs; the file the app opens with takes the theme once it is known; the CLI, MCP and exports have no theme and keep `#F5F5F5`; text drawn with the Text tool is white on a dark page and black on a light one (so is text a command makes in the app without naming a fill; headless sessions keep black) (`theme_pages_tests`, `dark_page_text_tests`) | | Minimize UI (Shift+Cmd/Ctrl+\\, the Minimize button by the file name) and Show/Hide UI (Cmd/Ctrl+\\) | done | Minimize collapses the navigation bar and both sidebars and keeps the toolbar and the panel widths; "If you select an object while the panels are minimized, the left sidebar remains minimized while the right sidebar expands [...] Once all objects are deselected, the right panel minimizes again" (Explore the navigation bar and left sidebar); a small Expand button at the top left of the canvas puts them back (FigCraft's own, the article names no control for it). Hide UI takes the toolbar too (`panel_width_tests`, `shortcuts_tests`; #1835) | | Resizable panels | done | each panel's edge drags its width (left 200–480, right 240–480, 240 by default; a double-click on the edge or View ▸ Panels ▸ Reset panel widths puts the default back); the widths are each window's own, the main window's are kept in the preferences when a drag ends and come back at the next start, a new window opens with those of the window it comes from; Minimize UI hides the panels and brings them back as they were; the view keeps its centre and zoom while a panel is resized; in a window too small for them the panels give way so the canvas keeps 160 px (the left one first), down to the smallest window (800×500) (`panel_width_tests`). Not compared with Figma's widths; no snap points | @@ -178,6 +178,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-11-06 | patch | M7, #2032: closing a tab whose file has unsaved changes, by its X, a middle click, File ▸ Close tab or its shortcut, only showed a toast ("save first, or close again with Alt to discard") and left the tab open, so closing seemed not to work. It now asks the unsaved-changes question the window asks (`windows::TabClose`): Don't Save closes the tab, Cancel or Escape keeps it, Save saves the file and then closes it (a file without a path, or saved as .fig, asks where with Save as first and stays open if that is cancelled); Alt+close still drops the changes at once, and `file.close`'s `dialog.unsaved` request asks too. The three toasts left the catalogs. Tests: `titlebar_tests::closing_a_tab_with_unsaved_changes_asks`, `titlebar_tests::save_in_the_tab_question_saves_then_closes`. Reordering tabs by dragging, the issue's other half, landed on 2026-10-19 (`dragging_a_tab_along_the_bar_reorders_it_without_detaching`) | | 2026-11-04 | patch | M1, #1852: a press on one of the open gradient's handles or stops on the canvas is the colour picker's own, not a press outside it (Figma keeps the picker and the handles up while a handle or stop is dragged; the stops are made to be dragged: "You can click and drag a stop to change its placement on the gradient", Use gradients as a fill or stroke). The canvas took the press and began the drag, then the picker, drawn later in the same frame, saw a press outside its card and closed, committing the drag's interaction at once; the handles vanished with it and the drag went on without an undo step of its own. Now the picker stays while a gradient handle or stop drags, and the drag is one undo step; a press elsewhere on the canvas still closes it. A handle drag whose button goes up off the canvas (over a panel or the picker) ends there too, instead of waiting, uncommitted, to move the handle to the next press on the canvas. Tests: `colorpicker_selection_tests::dragging_a_gradient_handle_on_the_canvas_keeps_the_picker_open`, `colorpicker_selection_tests::a_gradient_drag_released_off_the_canvas_ends_there`. | | 2026-11-02 | patch | M8, #1875: a click with the Bend tool on a sharp point between two segments gives it handles along the line between its neighbours, a third of the way to each ("Click on a point or path where you want to add a curve", Edit vector layers); a click on a curved point still makes it sharp, a drag still sets the handles by hand, an endpoint is left alone. Between a handle's end and a point both within reach of the pointer, the nearer one on the page is hit (the handle on a tie), so a short path's point (its handles a few pixels long) can be clicked again. Before, only a drag curved a point, and a short one left two tiny handles in its direction. Tests: `vectoredit::tests::short_handles_leave_their_point_clickable`, `vectoredit::tests::a_bend_click_curves_a_sharp_point_between_its_neighbours`, `bend_tool_smooths_and_sharpens_points`. Numbers unchanged. | | 2026-11-02 | minor | M4, #1827: wrap style on a text layer (`WrapStyle`: Auto, Balance, Pretty): Balance re-breaks each paragraph at the narrowest width that keeps its line count (bisection over the greedy breaker, as browsers balance), Pretty narrows one of the last four lines just enough so the last line isn't one word; `text.wrapStyle`, `node.set wrapStyle` (`textWrap` too), inspect `wrapStyle`, Dev Mode CSS `text-wrap` (Tailwind `text-balance` / `text-pretty`), the Type settings control under Vertical trim. Per-paragraph settings, text-style support and the `.fig` import/export mapping stay open. Test: `text_behaviour_tests::balance_and_pretty_wrap_styles_break_lines_as_documented`. | From fb33f78269c06fad868f0d9ebb61347ff63dc358 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 7 Nov 2026 08:17:37 +0700 Subject: [PATCH 18/18] Text styles: a style with no paragraph values keeps a bulleted text linked --- crates/engine/src/cmd/styles.rs | 7 ++++++- crates/engine/src/styles_behaviour_tests.rs | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index ff7838108..61f2c265f 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -782,7 +782,12 @@ pub fn sync_text_styles(doc: &mut Document, ids: &[NodeId]) { let gone: Vec<(usize, usize)> = t2 .spans() .iter() - .filter(|(a, b, s)| s.style_id.as_deref().is_some_and(|sid| paras.get(sid).is_none_or(|ps| !t2.shows_paragraph_style_in(*a, *b, ps)))) + .filter(|(a, b, s)| { + // A style with no paragraph values (every style in older files) asks nothing of them. + s.style_id + .as_deref() + .is_some_and(|sid| paras.get(sid).is_none_or(|ps| **ps != ParagraphStyle::default() && !t2.shows_paragraph_style_in(*a, *b, ps))) + }) .map(|(a, b, _)| (*a, *b)) .collect(); for (a, b) in gone { diff --git a/crates/engine/src/styles_behaviour_tests.rs b/crates/engine/src/styles_behaviour_tests.rs index f1ebd96de..73fe1edef 100644 --- a/crates/engine/src/styles_behaviour_tests.rs +++ b/crates/engine/src/styles_behaviour_tests.rs @@ -473,3 +473,21 @@ fn descriptions_and_library_styles() { run(&mut s, "style.update", json!({"style": st, "description": "Body text"})); assert_eq!(s.doc().unwrap().style(&st).unwrap().description, "Body text"); } + +#[test] +fn a_style_without_paragraph_values_keeps_a_bulleted_text_linked() { + // Styles from older files set no paragraph values, so a list or spacing on their text isn't + // an edit of the style: it stays linked (docs silent, pinned to how such files worked). + let mut s = Session::new(); + let t = text(&mut s, 0.0); + run( + &mut s, + "node.set", + json!({"id": t, "props": {"characters": "A +B"}}), + ); + let st = sid(run(&mut s, "style.create", json!({"type": "text", "name": "Plain", "value": {"fontSize": 11}}))); + run(&mut s, "style.apply", json!({"ids": [t], "style": st})); + run(&mut s, "node.set", json!({"id": t, "props": {"listStyle": "bulleted", "paragraphSpacing": 6}})); + assert_eq!(node(&s, t).styles.text.as_deref(), Some(st.as_str())); +}