mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-11 04:39:38 -06:00
Merge main into #2010
This commit is contained in:
@@ -361,6 +361,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-06 | patch | M8, #1906: under Wayland a minimised window answers control-channel and attached-MCP requests from its logic passes once it has drawn nothing for 0.2 s while a request waits (about half a second each; every request timed out after 60 s before). Test: `idle_tests::a_window_that_stopped_drawing_answers_from_its_logic_passes`. Numbers unchanged |
|
||||
| 2026-11-06 | patch | M3: a glass's light angle bound to a number past ±180 turns round to the same direction (270 is -90), as the angle drawn from it does, instead of stopping at 180. Tests: `effects_tests::a_bound_light_angle_turns_round_instead_of_stopping`. Numbers unchanged |
|
||||
| 2026-11-06 | patch | M3: a layer's effect binding (`variable.bind`, `effects/<i>/<field>`) takes only a field its effect has, as an effect style's does: `effects/0/refraction` on a drop shadow, or a binding on an effect the layer doesn't have, is refused instead of being kept and doing nothing (a binding an older file has there still does nothing, and unbinds). Tests: `binding_lists_tests::a_layer_binds_only_fields_its_effects_have`. Numbers unchanged |
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
|
||||
@@ -28,8 +28,9 @@ pub fn shape_path(n: &Node) -> Option<BezPath> {
|
||||
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<BezPath> {
|
||||
/// The open/closed path strokes follow (vector networks stroke every segment, filled or not).
|
||||
pub fn stroke_geometry(n: &Node) -> Option<BezPath> {
|
||||
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);
|
||||
@@ -96,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1230,7 +1230,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();
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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<Paint> },
|
||||
Text { style: TextStyle },
|
||||
Effect { effects: Vec<Effect> },
|
||||
Grid { grids: Vec<LayoutGrid> },
|
||||
Paint {
|
||||
paints: Vec<Paint>,
|
||||
},
|
||||
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<ParagraphStyle>,
|
||||
},
|
||||
Effect {
|
||||
effects: Vec<Effect>,
|
||||
},
|
||||
Grid {
|
||||
grids: Vec<LayoutGrid>,
|
||||
},
|
||||
}
|
||||
|
||||
impl StyleKind {
|
||||
|
||||
@@ -679,6 +679,155 @@ 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]
|
||||
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));
|
||||
}
|
||||
|
||||
/// A rectangle whose top-right corner was cut off in the vector editor (a diagonal between a point
|
||||
|
||||
@@ -324,6 +324,23 @@ pub struct TextStyle {
|
||||
pub style_id: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String, bool>,
|
||||
}
|
||||
|
||||
/// 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") {
|
||||
@@ -852,6 +869,11 @@ impl TextData {
|
||||
|
||||
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);
|
||||
@@ -880,6 +902,61 @@ 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) {
|
||||
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) {
|
||||
@@ -1056,3 +1133,22 @@ mod title_case_tests {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
+76
-25
@@ -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<CornerCurve>,
|
||||
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<VectorNetwork> {
|
||||
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<VectorNetwork> {
|
||||
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<(usize, bool)>> = 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<Corner> = (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<Corner> = (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<usize, usize> = std::collections::BTreeMap::new();
|
||||
// Each rounded point's curves in order from `s1`'s side, by point.
|
||||
let mut by_vertex: std::collections::BTreeMap<usize, (usize, Vec<usize>)> = 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<Corner> {
|
||||
/// 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<Corner> {
|
||||
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),
|
||||
})
|
||||
|
||||
@@ -121,7 +121,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),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -214,7 +214,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,8 +134,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 })
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -777,8 +777,11 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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() {
|
||||
@@ -787,7 +790,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} 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),
|
||||
@@ -797,7 +800,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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);
|
||||
@@ -885,8 +888,10 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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);
|
||||
@@ -906,7 +911,7 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 });
|
||||
|
||||
@@ -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<usize>) -> Option<TextStyle> {
|
||||
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<ParagraphStyle> {
|
||||
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<ParagraphStyle> {
|
||||
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<Value> {
|
||||
let cmd = "style.detach";
|
||||
let which = req_string(cmd, p, "which")?;
|
||||
@@ -295,7 +331,7 @@ fn kind_from_node(t: &str, n: &Node) -> Option<StyleKind> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
};
|
||||
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<Value> {
|
||||
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<TextTarget> =
|
||||
@@ -430,6 +472,8 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
/// 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<NodeId> = 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,28 @@ 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)| {
|
||||
// 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 {
|
||||
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));
|
||||
|
||||
@@ -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<NodeId> {
|
||||
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(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -54,7 +54,7 @@ pub fn missing(doc: &Document) -> Vec<Missing> {
|
||||
}
|
||||
}
|
||||
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") {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
@@ -410,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()));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -225,7 +225,7 @@ fn same_after_round_trip(doc: &Document, what: &str) -> Vec<String> {
|
||||
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()] }),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -270,7 +270,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,7 +322,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),
|
||||
};
|
||||
|
||||
@@ -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<Vec<u8>> {
|
||||
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<f64> = 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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<StyleKind> {
|
||||
}
|
||||
}
|
||||
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] };
|
||||
@@ -5954,7 +5954,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 }
|
||||
@@ -6010,10 +6010,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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -929,7 +929,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());
|
||||
@@ -972,7 +976,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,
|
||||
|
||||
@@ -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")));
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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<String, Value>) {
|
||||
@@ -80,10 +83,16 @@ pub(crate) fn node(cx: &mut Cx, l: &Value, node: &mut JsonMap<String, Value>) {
|
||||
// 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() {
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
+117
-51
@@ -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<SmoothCorner> {
|
||||
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<Vec<kurbo::CubicBez>> {
|
||||
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() {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<NodeId>) {
|
||||
let mut d = Document::new("sample");
|
||||
|
||||
@@ -121,7 +121,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)))
|
||||
});
|
||||
|
||||
@@ -274,7 +274,9 @@ fn cached_stroke_outline(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath>
|
||||
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) {
|
||||
|
||||
@@ -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 { "" };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+54
-11
@@ -315,6 +315,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 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<bool> {
|
||||
let gap_after = |k: usize| after.get(k).copied().unwrap_or(false);
|
||||
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);
|
||||
}
|
||||
order.get(2 * i + 1).is_some_and(|&g| g % 2 == 1 && g / 2 < trailing && gap_after(g / 2))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 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<usize> {
|
||||
@@ -1290,8 +1321,20 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
|
||||
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;
|
||||
// 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| {
|
||||
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();
|
||||
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)
|
||||
@@ -1373,20 +1416,20 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
|
||||
// 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<usize> = if bidi.is_some() {
|
||||
let para_level = u8::from(rtl_line);
|
||||
let levels: Vec<u8> = 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()
|
||||
};
|
||||
for it in order.iter().filter_map(|k| line_items.get(*k)) {
|
||||
let para_level = u8::from(rtl_line);
|
||||
let levels: Vec<u8> = 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<bool> = (0..line_items.len()).map(|k| widens(*s + k)).collect();
|
||||
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) {
|
||||
continue;
|
||||
}
|
||||
out.glyphs.extend(glyph_at(&styles, it, pen, baseline));
|
||||
pen += it.adv;
|
||||
if it.stretches() {
|
||||
if gaps_at.get(i).copied().unwrap_or(false) {
|
||||
pen += justify_extra;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -618,6 +618,61 @@ fn lower_case_writes_final_sigma() {
|
||||
assert_eq!(gids(&t).iter().map(|g| g.0).collect::<Vec<_>>(), want.iter().map(|g| g.0).collect::<Vec<_>>(), "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.
|
||||
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);
|
||||
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_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) {
|
||||
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.
|
||||
|
||||
@@ -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" = "تحديث الإضافة"
|
||||
|
||||
@@ -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" = "আপডেট"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1421,7 +1421,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."
|
||||
@@ -1474,8 +1473,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"
|
||||
|
||||
@@ -1579,7 +1579,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."
|
||||
@@ -1641,8 +1640,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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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ä"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" = "メインから更新"
|
||||
|
||||
@@ -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" = "განახლება"
|
||||
|
||||
@@ -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" = "메인에서 업데이트"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" = "Обновить"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" = "อัปเดต"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" = "从主文件更新"
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -596,6 +596,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<windows::TabClose>,
|
||||
/// 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<u64>,
|
||||
@@ -719,6 +721,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,
|
||||
}
|
||||
|
||||
@@ -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!({}));
|
||||
|
||||
@@ -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")));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Event>) -> Vec<ViewportCommand> {
|
||||
fn frame(&mut self, mut events: Vec<Event>) -> Vec<ViewportCommand> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<Item = &'a Style>) -> 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<String> = 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::<Vec<_>>().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);
|
||||
|
||||
@@ -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) {
|
||||
@@ -317,7 +317,7 @@ fn commit_text_field(def: &TextStyle, field: &str, typed: &str) -> Option<TextSt
|
||||
id: "S".into(),
|
||||
name: "S".into(),
|
||||
description: String::new(),
|
||||
kind: StyleKind::Text { style: def.clone() },
|
||||
kind: StyleKind::Text { style: def.clone(), paragraph: Default::default() },
|
||||
bound: Default::default(),
|
||||
};
|
||||
let mut out = ctx.run_ui(input, |ui| text_properties(ui, &doc, &style, def, &mut f));
|
||||
@@ -463,7 +463,7 @@ fn text_style_editor_binds_variables_from_its_fields() {
|
||||
let size_row = text_at(&texts, "Size").y;
|
||||
let spacing = {
|
||||
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") };
|
||||
def.letter_spacing.label()
|
||||
};
|
||||
let in_editor: Vec<_> = texts.iter().filter(|(_, p)| p.x > left && p.x < left + W).cloned().collect();
|
||||
@@ -496,7 +496,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);
|
||||
}
|
||||
|
||||
@@ -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<CloseAnswer> {
|
||||
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<usize> {
|
||||
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<String> = 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}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Components, variants, styles and variables parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (a layer's effect binding takes only a field its effect has; Go to main component on an instance of a library component opens the library file in a tab and selects the main there; glass effects take variables; 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-06 · **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); a layer's effect binding takes only a field its effect has; Go to main component on an instance of a library component opens the library file in a tab and selects the main there; glass effects take variables; 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
|
||||
@@ -60,7 +60,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 |
|
||||
@@ -79,6 +79,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-06 | patch | M3: a glass's light angle bound to a number past ±180 turns round to the same direction (270 is -90), as the angle drawn from it does, instead of stopping at 180. Tests: `effects_tests::a_bound_light_angle_turns_round_instead_of_stopping`. Grade unchanged. |
|
||||
| 2026-11-06 | patch | M3: a layer's effect binding (`variable.bind`, `effects/<i>/<field>`) takes only a field its effect has, as an effect style's does: `effects/0/refraction` on a drop shadow, or a binding on an effect the layer doesn't have, is refused instead of being kept and doing nothing (a binding an older file has there still does nothing, and unbinds). Tests: `binding_lists_tests::a_layer_binds_only_fields_its_effects_have`. Grade unchanged. |
|
||||
| 2026-11-06 | minor | M3: Go to main component (context menu, Design panel, Ctrl+Alt+Cmd+K) on an instance of a library component opens the library file, as the help center describes for mains kept in another file ("Edit main components"); it used to refuse. The file opens in a tab, or an open tab comes to the front, on the main's page with the main selected and zoomed to: a nested instance goes to its own main, a variant to that variant. A main deleted from the library file since it was published leaves the file open with a note; a library file that moved, a team library on a server and a path off this computer are refused with a message. Test: `library::tests::going_to_a_library_main_opens_the_library_file`. Numbers unchanged |
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+9
-3
@@ -313,10 +313,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
|
||||
|
||||
File diff suppressed because one or more lines are too long
+3
-2
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user