From 695ca0752874c94138bd5e362136bf28850a3c42 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Mon, 2 Nov 2026 06:43:02 -0500 Subject: [PATCH] M3: text styles carry paragraph spacing and indent, the list style and the OpenType features (#1829) ParagraphStyle beside TextStyle in StyleKind::Text: style.create takes it from the layer or a value, style.apply sets it (the list on the paragraphs touched for a range), style.update moves it, editing it unlinks, .fig files keep it with the style; tests in styles_behaviour_tests and fig_export_assets_tests. Co-Authored-By: Claude Fable 5.1 --- crates/doc/src/colors.rs | 2 +- crates/doc/src/lib.rs | 2 +- crates/doc/src/styles.rs | 22 ++++- crates/doc/src/text.rs | 68 ++++++++++++++ crates/engine/src/assets/style_vars.rs | 4 +- crates/engine/src/cmd/file/load.rs | 6 +- crates/engine/src/cmd/styles.rs | 94 ++++++++++++++++--- crates/engine/src/content/synthetic_ds.rs | 4 +- crates/engine/src/fig_export_assets_tests.rs | 21 +++++ crates/engine/src/fonts.rs | 2 +- crates/engine/src/fonts/tests.rs | 5 +- crates/engine/src/sketch_import_tests.rs | 2 +- crates/engine/src/styles_behaviour_tests.rs | 65 ++++++++++++- crates/engine/src/text_style_picker_tests.rs | 4 +- crates/format/src/fig/assets_tests.rs | 4 +- crates/format/src/fig/export/assets.rs | 7 +- crates/format/src/fig/text_skip_ink_tests.rs | 6 +- crates/format/src/fig/text_style_tests.rs | 2 +- crates/format/src/penpot.rs | 12 +-- crates/format/src/rest.rs | 10 +- crates/format/src/rest/tests.rs | 2 +- crates/format/src/sketch/shared_tests.rs | 2 +- crates/ui-egui/src/widgets/style_picker.rs | 41 +++++++- .../ui-egui/src/widgets/style_picker/tests.rs | 8 +- docs/components-parity.md | 5 +- docs/mcp.md | 12 ++- 26 files changed, 346 insertions(+), 66 deletions(-) diff --git a/crates/doc/src/colors.rs b/crates/doc/src/colors.rs index f4681601b..bd4dfdbab 100644 --- a/crates/doc/src/colors.rs +++ b/crates/doc/src/colors.rs @@ -79,7 +79,7 @@ impl Style { pub fn for_each_color_mut(&mut self, f: &mut ColorVisitor) { match &mut self.kind { StyleKind::Paint { paints } => paints.iter_mut().for_each(|p| p.for_each_color_mut(f)), - StyleKind::Text { style } => style.for_each_color_mut(f), + StyleKind::Text { style, .. } => style.for_each_color_mut(f), StyleKind::Effect { effects } => effects.iter_mut().for_each(|e| e.for_each_color_mut(f)), StyleKind::Grid { grids } => grids.iter_mut().for_each(|g| f(&mut g.color)), } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 89eb97269..eecb64af2 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -1206,7 +1206,7 @@ impl Document { .styles .iter() .filter_map(|s| match &s.kind { - StyleKind::Text { style } => Some((s.id.clone(), style.clone())), + StyleKind::Text { style, .. } => Some((s.id.clone(), style.clone())), _ => None, }) .collect(); diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index f3893077e..b10806e33 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -1,7 +1,7 @@ //! Shared styles and variables. use crate::props::{Effect, LayoutGrid}; -use crate::text::TextStyle; +use crate::text::{ParagraphStyle, TextStyle}; use figcraft_color::{Color, Paint}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; @@ -14,10 +14,22 @@ pub type ModeId = String; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum StyleKind { - Paint { paints: Vec }, - Text { style: TextStyle }, - Effect { effects: Vec }, - Grid { grids: Vec }, + Paint { + paints: Vec, + }, + Text { + style: TextStyle, + /// What the style sets at the paragraph and layer level (#1829); boxed to keep the enum + /// small. + #[serde(default, skip_serializing_if = "crate::node::is_default")] + paragraph: Box, + }, + Effect { + effects: Vec, + }, + Grid { + grids: Vec, + }, } impl StyleKind { diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 0c41d3c9a..76771c80c 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -291,6 +291,23 @@ pub struct TextStyle { pub style_id: Option, } +/// What a text style sets at the paragraph and layer level, beside its character style +/// ([`TextStyle`]): paragraph spacing and indent, the list style and the OpenType features +/// ("Supported in text styles": "Paragraph spacing and indentation", "Lists", "OpenType +/// features", Create and apply text styles). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct ParagraphStyle { + #[serde(skip_serializing_if = "crate::node::is_default")] + pub paragraph_spacing: f64, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub paragraph_indent: f64, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub list: ListStyle, + #[serde(skip_serializing_if = "crate::node::is_default")] + pub features: std::collections::BTreeMap, +} + /// The font new text starts in. A build can name another (`FIGCRAFT_DEFAULT_FONT="Noto Sans Thai"`, /// for a distribution whose users mostly write a script Inter lacks); empty means Inter. pub const DEFAULT_FONT: &str = match option_env!("FIGCRAFT_DEFAULT_FONT") { @@ -843,6 +860,57 @@ impl TextData { (a..=b).all(|p| self.para_list(p).list == first.list && self.para_list(p).indent == first.indent).then_some(first) } + /// What a text style made from this text sets at the paragraph and layer level: the paragraph + /// spacing and indent, the list style (the one every paragraph shares; the base one when they + /// differ) and the OpenType features. + pub fn paragraph_style(&self) -> ParagraphStyle { + let list = match self.paragraphs.first() { + Some(first) if self.paragraphs.iter().all(|p| p.list == first.list) => first.list, + _ => self.list, + }; + ParagraphStyle { paragraph_spacing: self.paragraph_spacing, paragraph_indent: self.paragraph_indent, list, features: self.features.clone() } + } + + /// Take what text style `ps` sets at the paragraph and layer level: the spacing, indent and + /// features on the layer, the list style on every paragraph. + pub fn take_paragraph_style(&mut self, ps: &ParagraphStyle) { + let len = self.char_count(); + self.take_paragraph_style_in(0, len, ps); + } + + /// As [`take_paragraph_style`](Self::take_paragraph_style), with the list style going to the + /// paragraphs chars `start..end` touch only; the spacing, indent and features are the + /// layer's, as in Figma. + pub fn take_paragraph_style_in(&mut self, start: usize, end: usize, ps: &ParagraphStyle) { + self.paragraph_spacing = ps.paragraph_spacing; + self.paragraph_indent = ps.paragraph_indent; + self.features.clone_from(&ps.features); + self.set_para_lists(start, end, |pl| pl.list = ps.list); + } + + /// Follow text style `id`'s paragraph-level definition wherever chars link to it. + pub fn update_paragraph_style(&mut self, id: &str, ps: &ParagraphStyle) { + let linked: Vec<(usize, usize)> = + self.spans().into_iter().filter(|(_, _, s)| s.style_id.as_deref() == Some(id)).map(|(a, b, _)| (a, b)).collect(); + for (a, b) in linked { + self.take_paragraph_style_in(a, b, ps); + } + } + + /// Whether chars `start..end` show what text style `ps` sets at the paragraph and layer + /// level: the layer's spacing, indent and features, and their paragraphs' list style. + pub fn shows_paragraph_style_in(&self, start: usize, end: usize, ps: &ParagraphStyle) -> bool { + self.paragraph_spacing == ps.paragraph_spacing + && self.paragraph_indent == ps.paragraph_indent + && self.features == ps.features + && self.common_para_list(start, end).is_some_and(|pl| pl.list == ps.list) + } + + /// Whether the whole text shows what text style `ps` sets at the paragraph and layer level. + pub fn shows_paragraph_style(&self, ps: &ParagraphStyle) -> bool { + self.shows_paragraph_style_in(0, self.char_count(), ps) + } + /// Link chars `start..end` (all of them when the range is empty or the whole text) to text /// style `id`, defined as `def`. pub fn link_text_style(&mut self, start: usize, end: usize, def: &TextStyle, id: &str) { diff --git a/crates/engine/src/assets/style_vars.rs b/crates/engine/src/assets/style_vars.rs index 7fb30fd06..12db4eaa8 100644 --- a/crates/engine/src/assets/style_vars.rs +++ b/crates/engine/src/assets/style_vars.rs @@ -113,7 +113,7 @@ fn resolved(vars: &Variables, st: &Style, modes: &Modes) -> StyleKind { (StyleKind::Grid { grids }, Some((BoundList::Grids, i, field)), VariableValue::Number(x)) => { crate::cmd::lists::set_grid_field(grids, i, field, *x); } - (StyleKind::Text { style }, None, _) => set_text_field(style, key, &val), + (StyleKind::Text { style, .. }, None, _) => set_text_field(style, key, &val), _ => {} } } @@ -206,7 +206,7 @@ pub fn refresh(doc: &mut Document, before: Option<&Document>, only: Option<&BTre && was.is_none_or(|w| w.kind == n.kind) { for st in doc.styles.iter().filter(|s| !s.bound.is_empty()) { - if let StyleKind::Text { style } = kind_for(doc, st, id).as_ref() { + if let StyleKind::Text { style, .. } = kind_for(doc, st, id).as_ref() { t.update_text_style(&st.id, style); } } diff --git a/crates/engine/src/cmd/file/load.rs b/crates/engine/src/cmd/file/load.rs index 5033a057e..3e917d5dd 100644 --- a/crates/engine/src/cmd/file/load.rs +++ b/crates/engine/src/cmd/file/load.rs @@ -250,7 +250,7 @@ fn match_font_families(doc: &mut Document) { } // Shared text styles too, so they stay like the layers linked to them. for st in &mut doc.styles { - if let StyleKind::Text { style } = &mut st.kind { + if let StyleKind::Text { style, .. } = &mut st.kind { fix(style); } } @@ -294,7 +294,7 @@ fn platform_tracking(doc: &mut Document) { } } for st in &mut doc.styles { - if let StyleKind::Text { style } = &mut st.kind { + if let StyleKind::Text { style, .. } = &mut st.kind { track(style); } } @@ -365,7 +365,7 @@ fn sketch_line_heights(doc: &mut Document) { } } for st in &mut doc.styles { - if let StyleKind::Text { style } = &mut st.kind { + if let StyleKind::Text { style, .. } = &mut st.kind { set(style); } } diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index 99d4521e4..313062a68 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -3,6 +3,7 @@ use std::collections::BTreeMap; use figcraft_color::{Color, Paint, PaintKind}; +use figcraft_doc::ParagraphStyle; use figcraft_doc::{ Collection, CollectionId, Document, Effect, EffectKind, Mode, ModeId, Node, NodeId, NodeKind, Style, StyleId, StyleKind, TextData, TextStyle, Variable, VariableId, VariableType, VariableValue, Variables, trim_num, @@ -261,6 +262,41 @@ fn text_style_of(n: &Node, at: Option) -> Option { Some(at.map_or(&t.style, |a| t.style_at(a)).as_text_style()) } +/// What a text style definition from JSON sets at the paragraph level, in the `node.set` +/// spellings (`paragraphSpacing`, `paragraphIndent`, `listStyle`: none|bulleted|numbered, +/// `features` or `openTypeFeatures`: `{tag: bool}`); keys left out are the defaults. +fn paragraph_style_json(cmd: &str, v: &Value) -> Result { + let Value::Object(m) = v else { return Ok(ParagraphStyle::default()) }; + let mut ps = ParagraphStyle::default(); + let length = + |k: &str, x: &Value| f64_of(x).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 10_000.0)).ok_or_else(|| bad(cmd, format!("{k}: a number"))); + for (k, x) in m { + match k.as_str() { + "paragraphSpacing" | "paragraph_spacing" => ps.paragraph_spacing = length(k, x)?, + "paragraphIndent" | "paragraph_indent" => ps.paragraph_indent = length(k, x)?, + "listStyle" | "list" => ps.list = super::props::enum_of(x).ok_or_else(|| bad(cmd, "listStyle: none|bulleted|numbered"))?, + "features" | "openTypeFeatures" | "fontFeatures" | "textFeatures" => { + ps.features = super::props::features_of(cmd, x)?.into_iter().filter_map(|(tag, on)| Some((tag, on?))).collect(); + } + _ => {} + } + } + Ok(ps) +} + +/// What a layer's text sets at the paragraph level, the list style being that of the paragraphs +/// chars `range` touch when given (the layer's when they differ). +fn paragraph_style_of(n: &Node, range: Option<(usize, usize)>) -> Option { + let t = n.kind.text()?; + let mut ps = t.paragraph_style(); + if let Some((a, b)) = range + && let Some(pl) = t.common_para_list(a, b) + { + ps.list = pl.list; + } + Some(ps) +} + fn detach(s: &mut Session, p: &Value) -> Result { let cmd = "style.detach"; let which = req_string(cmd, p, "which")?; @@ -295,7 +331,7 @@ fn kind_from_node(t: &str, n: &Node) -> Option { Some(match t { "paint" | "color" | "fill" => StyleKind::Paint { paints: n.fills.clone() }, "stroke" => StyleKind::Paint { paints: n.strokes.clone() }, - "text" => StyleKind::Text { style: text_style_of(n, None)? }, + "text" => StyleKind::Text { style: text_style_of(n, None)?, paragraph: Box::new(paragraph_style_of(n, None)?) }, "effect" => StyleKind::Effect { effects: n.effects.clone() }, "grid" => StyleKind::Grid { grids: n.grids.clone() }, _ => return None, @@ -317,13 +353,17 @@ fn create(s: &mut Session, p: &Value) -> Result { let kind = if let Some(v) = get(p, "value") { match t.as_str() { "paint" | "color" | "fill" => StyleKind::Paint { paints: paints_of(v).ok_or_else(|| bad(cmd, "value: paints"))? }, - "text" => StyleKind::Text { style: text_style_json(cmd, v)? }, + "text" => StyleKind::Text { style: text_style_json(cmd, v)?, paragraph: Box::new(paragraph_style_json(cmd, v)?) }, "effect" => StyleKind::Effect { effects: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? }, "grid" => StyleKind::Grid { grids: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? }, _ => return Err(bad(cmd, "type: paint|text|effect|grid")), } - } else if let Some((tid, a, _)) = range { - StyleKind::Text { style: doc.node(tid).and_then(|n| text_style_of(n, Some(a))).ok_or_else(|| bad(cmd, "not a text layer"))? } + } else if let Some((tid, a, b)) = range { + let n = doc.node(tid).ok_or_else(|| bad(cmd, "not a text layer"))?; + StyleKind::Text { + style: text_style_of(n, Some(a)).ok_or_else(|| bad(cmd, "not a text layer"))?, + paragraph: Box::new(paragraph_style_of(n, Some((a, b))).ok_or_else(|| bad(cmd, "not a text layer"))?), + } } else { let n = from.and_then(|f| doc.node(f)).ok_or_else(|| bad(cmd, "pass `value` or select a layer to take it from"))?; kind_from_node(&t, n).ok_or_else(|| bad(cmd, "that layer has no such property"))? @@ -343,7 +383,7 @@ fn create(s: &mut Session, p: &Value) -> Result { }; doc.styles.push(Style { id: sid.clone(), name, description: string(p, "description").unwrap_or_default(), kind: kind.clone(), bound }); // The source layer (or the selected chars) becomes linked to the new style. - if let (Some((tid, a, b)), StyleKind::Text { style }) = (range, &kind) { + if let (Some((tid, a, b)), StyleKind::Text { style, .. }) = (range, &kind) { if let NodeKind::Text(td) = &mut doc.get_mut(tid)?.kind { td.link_text_style(a, b, style, &sid); } @@ -387,11 +427,13 @@ pub fn apply_style_ref(doc: &mut Document, id: NodeId, field: &str, sid: Option< n.grids = grids.clone(); n.styles.grid = Some(id.clone()); } - ("textStyle", Some(Style { kind: StyleKind::Text { style }, id, .. })) => { - // Every char takes the style's typography (their fills and links stay). + ("textStyle", Some(Style { kind: StyleKind::Text { style, paragraph }, id, .. })) => { + // Every char takes the style's typography (their fills and links stay), and the layer + // what the style sets at the paragraph level (#1829). if let NodeKind::Text(t) = &mut n.kind { let len = t.char_count(); t.link_text_style(0, len, style, id); + t.take_paragraph_style(paragraph); } n.styles.text = Some(id.clone()); } @@ -416,7 +458,7 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { let st = req_string(cmd, p, "style")?; let style = s.doc()?.style(&st).cloned().ok_or_else(|| bad(cmd, format!("no style `{st}`")))?; // Text styles go on chars: a range, the selected chars of the edited text, or whole layers. - if let StyleKind::Text { style: def } = &style.kind { + if let StyleKind::Text { style: def, paragraph } = &style.kind { // A text style is no property of other layers: they're left alone. let doc = s.doc()?; let targets: Vec = @@ -430,6 +472,8 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result { Some((a, b)) => { if let NodeKind::Text(t) = &mut doc.get_mut(i)?.kind { t.link_text_style(a, b, def, &style.id); + // The list goes to the paragraphs touched; the rest is the layer's. + t.take_paragraph_style_in(a, b, paragraph); } } None => apply_style_ref(doc, i, "textStyle", Some(&style.id))?, @@ -506,7 +550,7 @@ fn update(s: &mut Session, p: &Value) -> Result { let cur = doc.styles.get(idx).map(|x| x.kind.type_name()).unwrap_or("paint"); Some(match cur { "paint" => StyleKind::Paint { paints: paints_of(v).ok_or_else(|| bad(cmd, "value: paints"))? }, - "text" => StyleKind::Text { style: text_style_json(cmd, v)? }, + "text" => StyleKind::Text { style: text_style_json(cmd, v)?, paragraph: Box::new(paragraph_style_json(cmd, v)?) }, "effect" => StyleKind::Effect { effects: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? }, _ => StyleKind::Grid { grids: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? }, }) @@ -651,22 +695,23 @@ fn rename_folder(s: &mut Session, p: &Value) -> Result { /// Re-apply style `sid` to every node (and text chars) linked to it. pub fn propagate_style(doc: &mut Document, sid: &str) { let text_def = match doc.style(sid).map(|s| &s.kind) { - Some(StyleKind::Text { style }) => Some(style.clone()), + Some(StyleKind::Text { style, paragraph }) => Some((style.clone(), paragraph.clone())), _ => None, }; let ids: Vec = doc.ids().collect(); for i in ids { let Some(n) = doc.node(i) else { continue }; - if let Some(def) = &text_def { + if let Some((def, paragraph)) = &text_def { if n.kind.text().is_some_and(|t| t.links_text_style(sid)) { let def = match doc.style(sid).map(|st| crate::style_vars::kind_for(doc, st, i).into_owned()) { - Some(StyleKind::Text { style }) => style, + Some(StyleKind::Text { style, .. }) => style, _ => def.clone(), }; if let Ok(n) = doc.get_mut(i) && let NodeKind::Text(t) = &mut n.kind { t.update_text_style(sid, &def); + t.update_paragraph_style(sid, paragraph); } } continue; @@ -695,7 +740,16 @@ pub fn sync_text_styles(doc: &mut Document, ids: &[NodeId]) { .styles .iter() .filter_map(|s| match &s.kind { - StyleKind::Text { style } => Some((s.id.clone(), style.clone())), + StyleKind::Text { style, .. } => Some((s.id.clone(), style.clone())), + _ => None, + }) + .collect(); + // What each text style sets at the paragraph level (#1829); no variable binds those. + let paras: BTreeMap<&str, &ParagraphStyle> = doc + .styles + .iter() + .filter_map(|s| match &s.kind { + StyleKind::Text { paragraph, .. } => Some((s.id.as_str(), paragraph.as_ref())), _ => None, }) .collect(); @@ -707,7 +761,7 @@ pub fn sync_text_styles(doc: &mut Document, ids: &[NodeId]) { // Styles with bound fields read as this layer sees them. let mut defs = std::borrow::Cow::Borrowed(&defs); for st in &varying { - if let StyleKind::Text { style } = crate::style_vars::kind_for(doc, st, id).into_owned() { + if let StyleKind::Text { style, .. } = crate::style_vars::kind_for(doc, st, id).into_owned() { defs.to_mut().insert(st.id.clone(), style); } } @@ -717,11 +771,23 @@ pub fn sync_text_styles(doc: &mut Document, ids: &[NodeId]) { && t2.text_style_link() == Some(None) && let Some(def) = defs.get(sid) && t2.spans().iter().all(|(_, _, s)| s.shows_text_style(def)) + && paras.get(sid.as_str()).is_some_and(|ps| t2.shows_paragraph_style(ps)) { let len = t2.char_count(); t2.restyle(0, len, |s| s.style_id = Some(sid.clone())); } t2.check_text_styles(|sid| defs.get(sid)); + // Chars whose paragraphs left their style's list, and every char of a layer whose + // paragraph spacing, indent or features were edited, unlink too (#1829). + let gone: Vec<(usize, usize)> = t2 + .spans() + .iter() + .filter(|(a, b, s)| s.style_id.as_deref().is_some_and(|sid| paras.get(sid).is_none_or(|ps| !t2.shows_paragraph_style_in(*a, *b, ps)))) + .map(|(a, b, _)| (*a, *b)) + .collect(); + for (a, b) in gone { + t2.unlink_text_style(a, b); + } let link = t2.text_style_link().flatten().map(str::to_string); if t2 != *t || link != n.styles.text { updates.push((id, link, t2)); diff --git a/crates/engine/src/content/synthetic_ds.rs b/crates/engine/src/content/synthetic_ds.rs index 74558567f..be5268355 100644 --- a/crates/engine/src/content/synthetic_ds.rs +++ b/crates/engine/src/content/synthetic_ds.rs @@ -154,7 +154,7 @@ fn auto_layout(doc: &mut Document, id: NodeId, mode: LayoutMode, gap: f64, pad: fn styled_text(doc: &mut Document, parent: NodeId, chars: &str, name: &str, style: &str) -> DocResult { let id = doc.create(parent, text(chars), name, 0.0, 0.0, 60.0, 16.0)?; let def = doc.styles.iter().find(|s| s.id == style).and_then(|s| match &s.kind { - StyleKind::Text { style } => Some(style.clone()), + StyleKind::Text { style, .. } => Some(style.clone()), _ => None, }); set_node(doc, id, |n| { @@ -242,7 +242,7 @@ fn foundations(doc: &mut Document) -> (CollectionId, ModeId, ModeId) { id: format!("ds:text:{name}"), name: name.into(), description: String::new(), - kind: StyleKind::Text { style }, + kind: StyleKind::Text { style, paragraph: Default::default() }, bound: Default::default(), }); } diff --git a/crates/engine/src/fig_export_assets_tests.rs b/crates/engine/src/fig_export_assets_tests.rs index f5b9f7758..ef88bc680 100644 --- a/crates/engine/src/fig_export_assets_tests.rs +++ b/crates/engine/src/fig_export_assets_tests.rs @@ -122,6 +122,27 @@ fn variables_styles_and_instance_sizes_come_back() { assert!(after.iter().any(|l| l.contains("\"In the dark\"") && l.contains("FFFFFF")), "{after:#?}"); } +/// #1829: a text style's paragraph spacing and indent and its list style go through a `.fig` +/// file with it (the style's sample layer carries them, as a Figma file's does). +#[test] +fn a_text_styles_paragraph_values_come_back_from_a_fig_file() { + let mut s = Session::new(); + let t = id(&run(&mut s, "text.create", json!({"text": "One\nTwo", "x": 0, "y": 0}))); + run(&mut s, "node.set", json!({"ids": [t], "paragraphSpacing": 12, "paragraphIndent": 8, "listStyle": "bulleted"})); + run(&mut s, "style.create", json!({"type": "text", "name": "Audit", "from": t})); + let path = std::env::temp_dir().join(format!("figcraft-fig-text-style-{}.fig", std::process::id())); + run(&mut s, "file.export", json!({"format": "fig", "path": path.to_string_lossy()})); + run(&mut s, "file.open", json!({"path": path.to_string_lossy()})); + let _ = std::fs::remove_file(&path); + let doc = s.doc().unwrap(); + let st = doc.styles.iter().find(|x| x.name == "Audit").unwrap(); + let figcraft_doc::StyleKind::Text { paragraph, .. } = &st.kind else { panic!("{:?}", st.kind) }; + assert_eq!((paragraph.paragraph_spacing, paragraph.paragraph_indent, paragraph.list), (12.0, 8.0, figcraft_doc::ListStyle::Bulleted)); + // The layer the style was made from is linked to it again, showing the same values. + let layer = doc.nodes().find(|n| n.kind.text().is_some()).unwrap(); + assert_eq!(layer.styles.text.as_deref(), Some(st.id.as_str())); +} + #[test] fn every_example_keeps_its_variables_and_styles() { let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples"); diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs index 484e7b88e..e0538db67 100644 --- a/crates/engine/src/fonts.rs +++ b/crates/engine/src/fonts.rs @@ -54,7 +54,7 @@ pub fn missing(doc: &Document) -> Vec { } } for st in &doc.styles { - if let StyleKind::Text { style } = &st.kind { + if let StyleKind::Text { style, .. } = &st.kind { add(&style.font_family, false); } if let Some(var) = st.bound.get("fontFamily") { diff --git a/crates/engine/src/fonts/tests.rs b/crates/engine/src/fonts/tests.rs index 5df9a8540..fc9713535 100644 --- a/crates/engine/src/fonts/tests.rs +++ b/crates/engine/src/fonts/tests.rs @@ -37,7 +37,10 @@ fn lists_families_of_layers_ranges_styles_and_variables_once_each() { id: "S:1".into(), name: "Heading".into(), description: String::new(), - kind: StyleKind::Text { style: TextStyle { font_family: "FigCraft Missing C".into(), ..TextStyle::default() } }, + kind: StyleKind::Text { + style: TextStyle { font_family: "FigCraft Missing C".into(), ..TextStyle::default() }, + paragraph: Default::default(), + }, bound: Default::default(), }); s.execute("variable.createCollection", &json!({"name": "Type", "modes": ["Light", "Dark"]})).unwrap(); diff --git a/crates/engine/src/sketch_import_tests.rs b/crates/engine/src/sketch_import_tests.rs index e05dade6c..b5ddab8b0 100644 --- a/crates/engine/src/sketch_import_tests.rs +++ b/crates/engine/src/sketch_import_tests.rs @@ -520,7 +520,7 @@ fn sketch_shared_text_styles_match_their_layers() { .styles .iter() .find_map(|st| match &st.kind { - figcraft_doc::StyleKind::Text { style } if st.name == "Body" => Some(style.clone()), + figcraft_doc::StyleKind::Text { style, .. } if st.name == "Body" => Some(style.clone()), _ => None, }) .unwrap(); diff --git a/crates/engine/src/styles_behaviour_tests.rs b/crates/engine/src/styles_behaviour_tests.rs index de9da5a5d..f1ebd96de 100644 --- a/crates/engine/src/styles_behaviour_tests.rs +++ b/crates/engine/src/styles_behaviour_tests.rs @@ -5,10 +5,11 @@ //! line; "docs silent" marks behaviour the documentation doesn't describe (pinned as it is). use serde_json::{Value, json}; +use std::collections::BTreeMap; use crate::Session; use crate::params::id_of; -use figcraft_doc::{Node, NodeId}; +use figcraft_doc::{ListStyle, Node, NodeId, ParagraphStyle, StyleKind, TextData}; fn id(v: Value) -> NodeId { id_of(&v["id"]).unwrap() @@ -355,6 +356,68 @@ fn text_styles_go_on_text_layers_and_chars() { assert_eq!(tx.styles.text, None, "mixed: the layer isn't linked as a whole"); } +#[test] +fn text_styles_carry_paragraph_spacing_indent_lists_and_features() { + // "Supported in text styles": "Paragraph spacing and indentation", "Lists" and "OpenType + // features", beside the character properties (Create and apply text styles). A style made + // from a text takes them, applying it sets them, editing one on a styled text unlinks it. + let mut s = Session::new(); + let td = |s: &Session, n: NodeId| -> TextData { node(s, n).kind.text().unwrap().clone() }; + let t = text(&mut s, 0.0); + let props = json!({"characters": "One\nTwo", "paragraphSpacing": 12, "paragraphIndent": 8, "listStyle": "bulleted", "features": {"tnum": true, "liga": false}}); + run(&mut s, "node.set", json!({"id": t, "props": props})); + let st = sid(run(&mut s, "style.create", json!({"type": "text", "name": "Audit", "from": t}))); + let listed = run(&mut s, "style.list", json!({"type": "text"})); + let audit = listed.as_array().unwrap().iter().find(|x| x["id"] == json!(st)).unwrap(); + assert_eq!( + audit["paragraph"], + json!({"paragraph_spacing": 12.0, "paragraph_indent": 8.0, "list": "bulleted", "features": {"liga": false, "tnum": true}}) + ); + // Applying it sets them on a plain text, every paragraph in the list; the layer links. + let t2 = text(&mut s, 300.0); + run(&mut s, "node.set", json!({"id": t2, "props": {"characters": "A\nB\nC"}})); + run(&mut s, "style.apply", json!({"ids": [t2], "style": st})); + let d = td(&s, t2); + assert_eq!((d.paragraph_spacing, d.paragraph_indent), (12.0, 8.0)); + assert!((0..3).all(|p| d.para_list(p).list == ListStyle::Bulleted), "{:?}", d.paragraphs); + assert_eq!(d.features, BTreeMap::from([("liga".to_string(), false), ("tnum".to_string(), true)])); + assert_eq!(node(&s, t2).styles.text.as_deref(), Some(st.as_str())); + // Redefining the style moves its layers' paragraph values with the rest. + let value = json!({"fontSize": 22, "paragraphSpacing": 20, "listStyle": "numbered", "openTypeFeatures": {"tnum": true}}); + run(&mut s, "style.update", json!({"style": st, "value": value})); + let d = td(&s, t2); + assert_eq!((d.paragraph_spacing, d.paragraph_indent, d.para_list(1).list), (20.0, 0.0, ListStyle::Numbered)); + assert_eq!(d.features, BTreeMap::from([("tnum".to_string(), true)])); + assert_eq!(font_size(&s, t2), 22.0); + assert_eq!(node(&s, t2).styles.text.as_deref(), Some(st.as_str()), "still linked after the update"); + // Editing a paragraph value the style sets unlinks the text, as editing its font does. + run(&mut s, "node.set", json!({"id": t2, "props": {"paragraphSpacing": 5}})); + assert_eq!(node(&s, t2).styles.text, None, "paragraph spacing edited: unlinked"); + assert!(td(&s, t2).spans().iter().all(|(_, _, x)| x.style_id.is_none())); + // On a range, the list goes to the paragraphs touched; the spacing is the layer's. Taking + // the paragraph out of the style's list unlinks its chars. + let t3 = text(&mut s, 600.0); + run(&mut s, "node.set", json!({"id": t3, "props": {"characters": "A\nB\nC"}})); + run(&mut s, "style.apply", json!({"ids": [t3], "style": st, "start": 0, "end": 1})); + let d = td(&s, t3); + assert_eq!((d.para_list(0).list, d.para_list(1).list, d.paragraph_spacing), (ListStyle::Numbered, ListStyle::None, 20.0)); + assert_eq!(d.style_at(0).style_id.as_deref(), Some(st.as_str())); + run(&mut s, "node.set", json!({"id": t3, "props": {"start": 0, "end": 1, "listStyle": "none"}})); + assert_eq!(td(&s, t3).style_at(0).style_id, None, "its paragraph left the style's list"); + // A definition given as a value takes the node.set spellings; junk is refused. + let value = json!({"fontSize": 11, "paragraphSpacing": 4, "listStyle": "bulleted", "features": {"liga": false}}); + let st2 = sid(run(&mut s, "style.create", json!({"type": "text", "name": "Note", "value": value}))); + let want = ParagraphStyle { + paragraph_spacing: 4.0, + list: ListStyle::Bulleted, + features: BTreeMap::from([("liga".to_string(), false)]), + ..Default::default() + }; + assert!(matches!(&s.doc().unwrap().style(&st2).unwrap().kind, StyleKind::Text { paragraph, .. } if **paragraph == want)); + assert!(s.execute("style.create", &json!({"type": "text", "name": "Bad", "value": {"listStyle": "dotted"}})).is_err()); + assert!(s.execute("style.create", &json!({"type": "text", "name": "Bad", "value": {"paragraphSpacing": "wide"}})).is_err()); +} + #[test] fn styles_are_organised_by_slash_names_order_and_duplicates() { // Documented: "/" in a name groups styles in folders; moving a style to another folder renames diff --git a/crates/engine/src/text_style_picker_tests.rs b/crates/engine/src/text_style_picker_tests.rs index 6ee8cc2c3..733845a52 100644 --- a/crates/engine/src/text_style_picker_tests.rs +++ b/crates/engine/src/text_style_picker_tests.rs @@ -86,7 +86,7 @@ fn editing_text_applies_to_the_selected_chars() { s.execute("text.style", &json!({"id": t, "start": 6, "end": 11, "fontSize": 20})).unwrap(); let c = s.execute("style.create", &json!({"type": "text", "name": "Type/Caption"})).unwrap()["id"].as_str().unwrap().to_string(); let style = s.doc().unwrap().style(&c).cloned().unwrap(); - let StyleKind::Text { style: def } = style.kind else { panic!("not a text style") }; + let StyleKind::Text { style: def, .. } = style.kind else { panic!("not a text style") }; assert_eq!(def.font_size, 20.0); assert_eq!(def.style_id, None, "definitions link to nothing"); assert_eq!(links(&s, t)[8].as_deref(), Some(c.as_str())); @@ -117,7 +117,7 @@ fn edits_unlink_and_style_edits_follow() { assert_eq!(layer_link(&s, a).as_deref(), Some(h.as_str())); // Redefining the style moves linked chars only. let mut def = match &s.doc().unwrap().style(&h).unwrap().kind { - StyleKind::Text { style } => style.clone(), + StyleKind::Text { style, .. } => style.clone(), _ => panic!("not a text style"), }; def.font_size = 40.0; diff --git a/crates/format/src/fig/assets_tests.rs b/crates/format/src/fig/assets_tests.rs index ce4192008..36c70a687 100644 --- a/crates/format/src/fig/assets_tests.rs +++ b/crates/format/src/fig/assets_tests.rs @@ -225,7 +225,7 @@ fn same_after_round_trip(doc: &Document, what: &str) -> Vec { normalize(&mut n, &|i| i); *paints = n.fills; } - StyleKind::Text { style } => { + StyleKind::Text { style, .. } => { if let NodeKind::Text(td) = &mut n.kind { td.style = style.clone(); } @@ -444,7 +444,7 @@ fn styled() -> Document { bound: Default::default(), }; doc.styles = vec![ - style("s-text", "Heading", StyleKind::Text { style: heading.clone() }), + style("s-text", "Heading", StyleKind::Text { style: heading.clone(), paragraph: Default::default() }), style("s-brand", "Brand/Primary", StyleKind::Paint { paints: vec![solid(0.25, 0.5, 1.0)] }), style("s-unused", "Brand/Unused", StyleKind::Paint { paints: vec![solid(1.0, 0.0, 0.0), solid(0.0, 1.0, 0.0)] }), style("s-shadow", "Elevation/1", StyleKind::Effect { effects: vec![shadow.clone()] }), diff --git a/crates/format/src/fig/export/assets.rs b/crates/format/src/fig/export/assets.rs index 3874b073c..f1df1874d 100644 --- a/crates/format/src/fig/export/assets.rs +++ b/crates/format/src/fig/export/assets.rs @@ -159,7 +159,7 @@ impl<'d> Exporter<'d> { Bound::default() } else { let mut sample = Node::new(NodeId(0), NodeKind::Rectangle, "", 0.0, 0.0); - if let StyleKind::Text { style } = &st.kind { + if let StyleKind::Text { style, .. } = &st.kind { sample.kind = NodeKind::Text(TextData { style: style.as_text_style(), ..TextData::new("Ag") }); } sample.bound.clone_from(&st.bound); @@ -179,8 +179,9 @@ impl<'d> Exporter<'d> { let list = grids.iter().enumerate().map(|(i, g)| layout_grid(g, i, &none)).collect(); f.extend([("fillPaints", arr(Vec::new())), ("layoutGrids", arr(list))]); } - StyleKind::Text { style } => { - let td = TextData { style: style.as_text_style(), ..TextData::new("Ag") }; + StyleKind::Text { style, paragraph } => { + let mut td = TextData { style: style.as_text_style(), ..TextData::new("Ag") }; + td.take_paragraph_style(paragraph); let fills = [Paint::solid(Color::BLACK)]; f.push(("fillPaints", self.paints(style.fills.as_deref().unwrap_or(&fills), true, &none))); self.text(&td, &fills, &mut f); diff --git a/crates/format/src/fig/text_skip_ink_tests.rs b/crates/format/src/fig/text_skip_ink_tests.rs index 485663847..b5285e1b6 100644 --- a/crates/format/src/fig/text_skip_ink_tests.rs +++ b/crates/format/src/fig/text_skip_ink_tests.rs @@ -113,7 +113,7 @@ fn local_skip_ink_override_preserves_shared_style_and_omitted_value_inherits() { let node = doc.nodes().find(|n| n.name == "Label").unwrap(); assert_eq!(node.kind.text().unwrap().style.look().skip_ink, expected); let linked = doc.style(node.styles.text.as_deref().unwrap()).unwrap(); - let StyleKind::Text { style } = &linked.kind else { panic!("linked definition must remain text") }; + let StyleKind::Text { style, .. } = &linked.kind else { panic!("linked definition must remain text") }; assert!(style.look().skip_ink, "local value must not alter definition"); } } @@ -223,7 +223,7 @@ fn export_writes_one_actual_node_value_and_complete_run_values_without_skip_only id: "body".into(), name: "Body".into(), description: String::new(), - kind: StyleKind::Text { style: shared }, + kind: StyleKind::Text { style: shared, paragraph: Default::default() }, bound: Default::default(), }); let id = linked_doc @@ -237,7 +237,7 @@ fn export_writes_one_actual_node_value_and_complete_run_values_without_skip_only let node = back.nodes().find(|n| n.name == "Label").unwrap(); assert!(!node.kind.text().unwrap().style.look().skip_ink); let linked = back.style(node.styles.text.as_deref().unwrap()).unwrap(); - let StyleKind::Text { style } = &linked.kind else { panic!("shared style link must remain text") }; + let StyleKind::Text { style, .. } = &linked.kind else { panic!("shared style link must remain text") }; assert!(style.look().skip_ink, "local false must not alter the shared definition"); } diff --git a/crates/format/src/fig/text_style_tests.rs b/crates/format/src/fig/text_style_tests.rs index 0f08459e6..dadd052c1 100644 --- a/crates/format/src/fig/text_style_tests.rs +++ b/crates/format/src/fig/text_style_tests.rs @@ -85,7 +85,7 @@ fn seen(doc: &Document) -> Seen { assert_eq!((text.style.font_family.as_str(), text.style.font_size), ("Inter", 20.0)); let linked = doc.style(label.styles.text.as_deref().unwrap()).unwrap(); assert_eq!(linked.name, "Shared"); - let StyleKind::Text { style } = &linked.kind else { panic!("the linked definition must stay a text style") }; + let StyleKind::Text { style, .. } = &linked.kind else { panic!("the linked definition must stay a text style") }; Seen { case: text.style.case, decoration: text.style.decoration, diff --git a/crates/format/src/penpot.rs b/crates/format/src/penpot.rs index 6cd8fe53c..a0e50a067 100644 --- a/crates/format/src/penpot.rs +++ b/crates/format/src/penpot.rs @@ -1531,7 +1531,7 @@ fn import_typography_styles( id: format!("penpot:text:{base}:{id}"), name: name.chars().take(256).collect(), description: "Imported from Penpot typography library".into(), - kind: StyleKind::Text { style }, + kind: StyleKind::Text { style, paragraph: Default::default() }, bound: BTreeMap::new(), }); } @@ -1596,7 +1596,7 @@ fn composite_token_style(token: &Value) -> Option { } } let parsed = text_style(&Value::Object(fields), &style); - Some(StyleKind::Text { style: parsed }) + Some(StyleKind::Text { style: parsed, paragraph: Default::default() }) } "boxShadow" | "shadow" => { let parts: Vec<&Value> = if let Some(items) = value.as_array() { items.iter().take(32).collect() } else { vec![value] }; @@ -5927,7 +5927,7 @@ mod tests { let (doc, warnings) = import(&archive_with_design_system(tokens, None, None, None)).unwrap(); assert_eq!(doc.styles.len(), 2); assert!(doc.styles.iter().any(|s| s.name == "Penpot tokens/Core/title" - && matches!(&s.kind, StyleKind::Text { style } + && matches!(&s.kind, StyleKind::Text { style, .. } if style.font_family == "Spicy Rice" && style.font_size == 24.0 && style.line_height == LineHeight::Pixels(32.0)))); assert!(doc.styles.iter().any(|s| s.name == "Penpot tokens/Core/elevation" && matches!(&s.kind, StyleKind::Effect { effects } @@ -5983,10 +5983,8 @@ mod tests { let (doc, warnings) = import(&archive_with_design_system(serde_json::json!({"sets":{}}), Some(typ), Some(color), None)).unwrap(); assert!(warnings.is_empty(), "{warnings:?}"); assert_eq!(doc.styles.len(), 2); - assert!( - doc.styles.iter().any(|s| s.name == "Brand/Heading" - && matches!(&s.kind, StyleKind::Text { style } if style.font_family == "Inter" && style.font_size == 24.0)) - ); + assert!(doc.styles.iter().any(|s| s.name == "Brand/Heading" + && matches!(&s.kind, StyleKind::Text { style, .. } if style.font_family == "Inter" && style.font_size == 24.0))); assert!(doc.styles.iter().any(|s| s.name == "Brand/Primary" && matches!(s.kind, StyleKind::Paint { .. }))); assert_eq!(doc.document_colors.len(), 1); } diff --git a/crates/format/src/rest.rs b/crates/format/src/rest.rs index 4604b73a6..eb9a10112 100644 --- a/crates/format/src/rest.rs +++ b/crates/format/src/rest.rs @@ -913,7 +913,11 @@ impl<'a> Ctx<'a> { Some("FILL") => StyleKind::Paint { paints: props::paints(self, node, "fills").unwrap_or_default() }, Some("EFFECT") => StyleKind::Effect { effects: props::effects(self, node) }, Some("GRID") => StyleKind::Grid { grids: props::layout_grids(node) }, - Some("TEXT") => StyleKind::Text { style: text::text_data(self, node).style.as_text_style() }, + Some("TEXT") => { + // The sample layer carries the paragraph-level values too (#1829). + let td = text::text_data(self, node); + StyleKind::Text { style: td.style.as_text_style(), paragraph: Box::new(td.paragraph_style()) } + } _ => continue, }; let name = text(d, "name").filter(|s| !s.is_empty()).map_or_else(|| format!("Style {id}"), |s| clip_str(s).to_string()); @@ -955,7 +959,9 @@ impl<'a> Ctx<'a> { "effect" | "effects" => (StyleKind::Effect { effects: n.effects.clone() }, &mut n.styles.effect), "grid" | "grids" => (StyleKind::Grid { grids: n.grids.clone() }, &mut n.styles.grid), "text" => match &n.kind { - NodeKind::Text(t) => (StyleKind::Text { style: t.style.as_text_style() }, &mut n.styles.text), + NodeKind::Text(t) => { + (StyleKind::Text { style: t.style.as_text_style(), paragraph: Box::new(t.paragraph_style()) }, &mut n.styles.text) + } _ => continue, }, _ => continue, diff --git a/crates/format/src/rest/tests.rs b/crates/format/src/rest/tests.rs index 050dcbaa6..43c9db6f2 100644 --- a/crates/format/src/rest/tests.rs +++ b/crates/format/src/rest/tests.rs @@ -530,7 +530,7 @@ fn shared_styles_link_with_metadata_names() { let fill = doc.style("S:1").unwrap(); assert_eq!((fill.name.as_str(), fill.description.as_str()), ("Brand/Primary", "Main")); assert!(matches!(&fill.kind, StyleKind::Paint { paints } if paints.len() == 1)); - assert!(matches!(&doc.style("S:2").unwrap().kind, StyleKind::Text { style } if style.font_size == 32.0)); + assert!(matches!(&doc.style("S:2").unwrap().kind, StyleKind::Text { style, .. } if style.font_size == 32.0)); assert!(matches!(&doc.style("Soft").unwrap().kind, StyleKind::Effect { effects } if effects.len() == 1)); let r = named(&doc, "R"); assert_eq!((r.styles.fill.as_deref(), r.styles.effect.as_deref()), (Some("S:1"), Some("S:4"))); diff --git a/crates/format/src/sketch/shared_tests.rs b/crates/format/src/sketch/shared_tests.rs index 240fdb7ea..4636c652e 100644 --- a/crates/format/src/sketch/shared_tests.rs +++ b/crates/format/src/sketch/shared_tests.rs @@ -91,7 +91,7 @@ fn text_style_links() { let sid = n.styles.text.clone().expect("text style linked"); let s = style(&doc, &sid); assert_eq!(s.name, "Heading/H1"); - let StyleKind::Text { style: ts } = &s.kind else { panic!("not a text style") }; + let StyleKind::Text { style: ts, .. } = &s.kind else { panic!("not a text style") }; assert_eq!((ts.font_family.as_str(), ts.font_style.as_str(), ts.font_size), ("Helvetica", "Bold", 32.0)); assert!(n.fills.iter().any(|p| matches!(p.kind, PaintKind::Solid { color } if color.r == 1.0 && color.g == 0.0))); } diff --git a/crates/ui-egui/src/widgets/style_picker.rs b/crates/ui-egui/src/widgets/style_picker.rs index eb2a2b44b..f616ddec7 100644 --- a/crates/ui-egui/src/widgets/style_picker.rs +++ b/crates/ui-egui/src/widgets/style_picker.rs @@ -10,7 +10,9 @@ use egui::{Align2, Color32, CornerRadius, Id, Rect, Response, Sense, Stroke, Ui, pos2, vec2}; use figcraft_color::{Paint, PaintKind}; -use figcraft_doc::{Document, GridKind, Node, NodeId, PublishedLibrary, Style, StyleKind, TextStyle, VariableType, trim_num}; +use figcraft_doc::{ + Document, GridKind, ListStyle, Node, NodeId, ParagraphStyle, PublishedLibrary, Style, StyleKind, TextStyle, VariableType, trim_num, +}; use figcraft_engine::library; use serde_json::{Value, json}; @@ -354,7 +356,7 @@ fn by_folder<'a>(styles: impl Iterator) -> Vec<(&'a str, Vec<& /// A text style's definition, if `s` is one. fn text_def(s: &Style) -> Option<&TextStyle> { match &s.kind { - StyleKind::Text { style } => Some(style), + StyleKind::Text { style, .. } => Some(style), _ => None, } } @@ -974,6 +976,39 @@ fn text_properties(ui: &mut Ui, doc: &Document, style: &Style, def: &TextStyle, if let Some(sr) = style_resp { font_style_menu(id, def, &sr, out); } + if let StyleKind::Text { paragraph, .. } = &style.kind { + paragraph_rows(ui, paragraph); + } +} + +/// What a text style sets at the paragraph level (#1829), when it sets anything: the spacing, +/// indent, list style and OpenType features (as `+tnum -liga`). A layer's Type settings edit +/// them; Update from selection takes them into the style. +fn paragraph_rows(ui: &mut Ui, ps: &ParagraphStyle) { + let mut rows: Vec = Vec::new(); + if ps.paragraph_spacing != 0.0 { + let label = tr("Paragraph spacing"); + rows.push(format!("{label}: {}", trim_num(ps.paragraph_spacing))); + } + if ps.paragraph_indent != 0.0 { + let label = tr("Paragraph indent"); + rows.push(format!("{label}: {}", trim_num(ps.paragraph_indent))); + } + match ps.list { + ListStyle::None => {} + ListStyle::Bulleted => rows.push(tr("Bulleted list").into()), + ListStyle::Numbered => rows.push(tr("Numbered list").into()), + } + if !ps.features.is_empty() { + rows.push(ps.features.iter().map(|(tag, on)| format!("{}{tag}", if *on { '+' } else { '-' })).collect::>().join(" ")); + } + if rows.is_empty() { + return; + } + ui.add_space(4.0); + for row in rows { + caption(ui, &row); + } } /// Editing one style: name, description, its values (paints open the colour picker; text styles @@ -1038,7 +1073,7 @@ fn editor(ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut Frame) { binding_cell(ui, doc, &style, &format!("layoutGrids/{i}/size"), figcraft_doc::VariableType::Number, out); } } - StyleKind::Text { style: def } => text_properties(ui, doc, &style, def, out), + StyleKind::Text { style: def, .. } => text_properties(ui, doc, &style, def, out), } }); ui.add_space(8.0); diff --git a/crates/ui-egui/src/widgets/style_picker/tests.rs b/crates/ui-egui/src/widgets/style_picker/tests.rs index fa996374c..0f3392e48 100644 --- a/crates/ui-egui/src/widgets/style_picker/tests.rs +++ b/crates/ui-egui/src/widgets/style_picker/tests.rs @@ -29,7 +29,7 @@ fn names_split_into_folders() { for s in [StyleSlot::Fill, StyleSlot::Stroke, StyleSlot::Effect, StyleSlot::Grid, StyleSlot::Text] { assert_eq!(StyleSlot::parse(s.key()), Some(s)); } - assert!(StyleSlot::Text.holds(&StyleKind::Text { style: Default::default() }) && StyleSlot::Text.has_grid()); + assert!(StyleSlot::Text.holds(&StyleKind::Text { style: Default::default(), paragraph: Default::default() }) && StyleSlot::Text.has_grid()); } fn frame(ctx: &egui::Context, app: &mut FigApp, t: &mut f64) { @@ -255,7 +255,7 @@ fn commit_text_field(def: &TextStyle, field: &str, typed: &str) -> Option = texts.iter().filter(|(_, p)| p.x > left && p.x < left + W).cloned().collect(); @@ -434,7 +434,7 @@ fn text_style_editor_binds_variables_from_its_fields() { assert_eq!(bound_to(&u, field).as_deref(), Some(name)); } let doc = u.app.session.doc().unwrap(); - let Some(StyleKind::Text { style: def }) = doc.style("Body").map(|s| &s.kind) else { panic!("a text style") }; + let Some(StyleKind::Text { style: def, .. }) = doc.style("Body").map(|s| &s.kind) else { panic!("a text style") }; assert_eq!((def.font_size, def.font_style.as_str()), (18.0, "Bold"), "the style takes the variables' values"); assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); } diff --git a/docs/components-parity.md b/docs/components-parity.md index 0e3248248..7fce63d14 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -1,6 +1,6 @@ # Components, variants, styles and variables parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (text styles carry paragraph spacing and indent, the list style and the OpenType features, as the Supported in text styles table of Create and apply text styles says (#1829); Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300 @@ -59,7 +59,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Feature | Status | Notes | |---|---|---| -| Paint, text, effect, grid styles; style picker; text styles on character ranges | done | behaviour suite: `styles_behaviour_tests.rs` | +| Paint, text, effect, grid styles; style picker; text styles on character ranges | done | behaviour suite: `styles_behaviour_tests.rs`; a text style carries paragraph spacing and indent, the list style and the OpenType features beside the character properties (`paragraph` next to `style` in `style.list`): applied with it, moved by `style.update`, unlinking when edited, kept by `.fig` files (#1829) | | Editing a style updates its layers; detach and delete keep values; editing a styled property unlinks that style | done | an edited fill/stroke/effect/grid list or text chars unlink; applying a style replaces variables bound to what it sets; text styles on text layers only | | Organising styles: folders by `/` names, order, duplicate, rename folder, Local styles list | done | `style.move` (into a folder renames), `style.duplicate` (right below), `style.renameFolder`; the page's Local styles list by type and folder (drag to reorder, right-click) | | Variables inside styles (paint, effect, grid and text styles; each user resolves them in its own modes) | done | `style.bind`, `style_vars_tests`; the editor binds paint colours, shadow colours, blur radii, grid sizes and a text style's font, font style, size, line height and letter spacing, the rest through the command | @@ -78,6 +78,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | M3, #1829: text styles carry paragraph spacing and indent, the list style and the OpenType features ("Supported in text styles": "Paragraph spacing and indentation", "Lists", "OpenType features", Create and apply text styles) beside the character properties: `style.create` takes them from the layer (or the selected paragraphs' list) or from a `value` (`paragraphSpacing`, `paragraphIndent`, `listStyle`, `features`), `style.apply` sets them (on a range, the list goes to the paragraphs touched; the rest is the layer's, as in Figma), `style.update` moves them on every linked layer, editing one on a styled text unlinks it as editing its font does, the style editor lists them, and a `.fig` file keeps them with the style (its sample layer). Grade unchanged. | | 2026-11-01 | patch | M3: Delete on a layer inside an instance hides it (an override) instead of being refused ("You can't delete a layer or object from an instance. If you try, Figma will only toggle the layer's visibility instead of removing it", Guide to auto layout); `edit.delete` reports those ids under `hidden`, the structure and the main component stay as they were, one undo step; deleting the instance itself still removes it. Found by a documentation audit of the auto layout page. Test: `components_behaviour_tests::deleting_a_layer_inside_an_instance_hides_it_instead` (the refusal row for it is gone from `what_instances_cannot_override_is_refused`). Numbers unchanged | | 2026-10-31 | minor | M3, gap 10: library analytics cover styles and variables. A file reports how many of its layers use each of a library's styles and variables, and Show usage (`library.usage`) lists them beside the components, by a Type choice, with their layers and files. Layers inside instances count for their component. Insertions, detachments and modes aren't counted for them yet. Numbers unchanged. Tests: `library_assets_tests`. | | 2026-10-31 | minor | M6: as the help center describes code syntax, Dev Mode's SwiftUI and Compose code now writes a bound variable's iOS or Android code syntax in place of its value, as CSS already wrote the Web one: fills, strokes and their weight, sizes, corner radius, gaps, padding, opacity, the font family and size and the characters. A variable with no code syntax for the platform keeps the value, since a name made up here wouldn't exist in the code it lands in. ArkUI and Flutter have no code syntax platform and are unchanged. Test: `variables_behaviour_tests::swiftui_and_compose_name_variables_by_their_code_syntax`. Numbers unchanged. Grade unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index b5e49e749..8344f4972 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -312,10 +312,16 @@ Or in a project's `.mcp.json`: `style.detach {which: "text", start?, end?}` unlinks them (values stay). A layer's `styles.text` is the style all its chars share (none when they differ); `get_node` lists the linked chars as `textStyleRanges` (`[start, end, style id]`). + A text style also carries paragraph spacing and indent, the list style and the OpenType features + (`paragraph` beside `style` in `style.list`): applying it sets them (on a range, the list goes to + the paragraphs touched; the rest is the layer's), `style.update` moves them too, and a `.fig` + file keeps them with the style. Changing what a text style sets (font, size, line height, letter spacing, decoration, case, axis - values) on linked chars unlinks them; `style.update` moves every linked char. `style.create - {type: "text", name}` takes the selected chars while editing; a `value` may use the `node.set` - spellings (`{"fontSize": 32, "lineHeight": "120%", "letterSpacing": "2%"}`). + values; at the paragraph level the spacing, indent, list and features) on linked chars unlinks + them; `style.update` moves every linked char. `style.create {type: "text", name}` takes the + selected chars while editing; a `value` may use the `node.set` spellings (`{"fontSize": 32, + "lineHeight": "120%", "letterSpacing": "2%", "paragraphSpacing": 12, "listStyle": "bulleted", + "features": {"liga": false}}`). - **Styles stay true to their layers**: editing a styled fill, stroke, effect or layout grid list (`node.set`, `fill.set`, `fill.add`, a bound variable…) unlinks that style (`styles.fill` etc. go `null`), so a later `style.update` doesn't overwrite the edit; `style.apply` replaces variables