M4: a wrap style per paragraph and in text styles, overriding the layer's (#1827)

"You can apply a wrap style at three levels: Text layer, Paragraph, Text style". The layer level was done in #1871; this adds the other two, following the per-paragraph text direction (#1830) and the list style in text styles (#1876).

ParaList::wrap (None follows the layer) with para_wrap_style, set_wrap_style (the layer level, clears overrides), set_para_wrap_styles and common_wrap_style; restyle_breaks takes a style per paragraph; ParagraphStyle::wrap_style; text.wrapStyle and node.set wrapStyle with start/end; inspect lineWrapStyles; CSS text-wrap only when the paragraphs agree; the Wrap style control follows the paragraph; a read-only row in the style editor. The .fig mapping is left for a later change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Model-Effort: Opus 5.5 drove; Fable 5.1 briefed; Sonnet 5.5 wrote at medium
This commit is contained in:
Trevor BrownandClaude Sonnet 5.5 committed 2026-11-07 21:13:39 -05:00
1 parent 3635c23070
commit 136ca4cda7
15 files changed
+234 -29

No files matched your search

+65 -1
View File
@@ -293,6 +293,10 @@ pub struct ParaList {
/// The paragraph's base direction (Text ▸ Text direction); `Auto` follows its letters.
#[serde(skip_serializing_if = "crate::node::is_default")]
pub direction: TextDirection,
/// The paragraph's own wrap style ("You can apply a wrap style at three levels: Text layer,
/// Paragraph, Text style"); `None` follows the layer's [`TextData::wrap_style`].
#[serde(skip_serializing_if = "Option::is_none")]
pub wrap: Option<WrapStyle>,
}
/// Character style. In runs, every field is an override; `None` means "inherit the base".
@@ -337,6 +341,9 @@ pub struct ParagraphStyle {
pub paragraph_indent: f64,
#[serde(skip_serializing_if = "crate::node::is_default")]
pub list: ListStyle,
/// The wrap style (Auto asks nothing).
#[serde(skip_serializing_if = "crate::node::is_default")]
pub wrap_style: WrapStyle,
#[serde(skip_serializing_if = "crate::node::is_default")]
pub features: std::collections::BTreeMap<String, bool>,
}
@@ -838,6 +845,33 @@ impl TextData {
self.paragraphs.get(p).copied().unwrap_or(ParaList { list: self.list, ..ParaList::default() })
}
/// Paragraph `p`'s wrap style: its own, else the layer's.
pub fn para_wrap_style(&self, p: usize) -> WrapStyle {
self.paragraphs.get(p).and_then(|pl| pl.wrap).unwrap_or(self.wrap_style)
}
/// The layer level: every paragraph takes wrap style `w` (their own overrides go).
pub fn set_wrap_style(&mut self, w: WrapStyle) {
self.wrap_style = w;
// 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.wrap = None);
}
/// The paragraph level: the paragraphs chars `start..end` touch take wrap style `w`; one equal
/// to the layer's is no override.
pub fn set_para_wrap_styles(&mut self, start: usize, end: usize, w: WrapStyle) {
let layer = self.wrap_style;
self.set_para_lists(start, end, |pl| pl.wrap = (w != layer).then_some(w));
}
/// The effective wrap style the paragraphs chars `start..end` touch share, or None when they differ.
pub fn common_wrap_style(&self, start: usize, end: usize) -> Option<WrapStyle> {
let (a, b) = self.para_span(start, end);
let first = self.para_wrap_style(a);
(a..=b).all(|p| self.para_wrap_style(p) == first).then_some(first)
}
/// Spell out every paragraph's list settings in `paragraphs` (and drop extra entries).
fn fill_paragraphs(&mut self) {
let n = self.paragraph_count();
@@ -911,7 +945,14 @@ impl TextData {
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() }
let wrap_style = self.common_wrap_style(0, self.char_count()).unwrap_or(self.wrap_style);
ParagraphStyle {
paragraph_spacing: self.paragraph_spacing,
paragraph_indent: self.paragraph_indent,
list,
wrap_style,
features: self.features.clone(),
}
}
/// Take what text style `ps` sets at the paragraph and layer level: the spacing, indent and
@@ -923,6 +964,7 @@ impl TextData {
// 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);
self.set_wrap_style(ps.wrap_style);
}
/// As [`take_paragraph_style`](Self::take_paragraph_style), with the list style going to the
@@ -933,6 +975,7 @@ impl TextData {
self.paragraph_indent = ps.paragraph_indent;
self.features.clone_from(&ps.features);
self.set_para_lists(start, end, |pl| pl.list = ps.list);
self.set_para_wrap_styles(start, end, ps.wrap_style);
}
/// Follow text style `id`'s paragraph-level definition wherever chars link to it.
@@ -951,6 +994,7 @@ impl TextData {
&& self.paragraph_indent == ps.paragraph_indent
&& self.features == ps.features
&& self.common_para_list(start, end).is_some_and(|pl| pl.list == ps.list)
&& self.common_wrap_style(start, end) == Some(ps.wrap_style)
}
/// Whether the whole text shows what text style `ps` sets at the paragraph and layer level.
@@ -1104,6 +1148,26 @@ mod case_tests {
t.set_para_lists(0, 0, |p| p.list = P);
assert_eq!(t.para_list(0), ParaList::default());
}
#[test]
fn wrap_style_per_paragraph_loads_from_old_files_and_folds() {
use WrapStyle::{Auto, Balance, Pretty};
let old = r#"{"list":"numbered","indent":1}"#;
assert_eq!(serde_json::from_str::<ParaList>(old).unwrap().wrap, None);
let mut t = TextData::new("a\nb");
let key = t.layout_key();
t.set_para_wrap_styles(0, 0, Balance);
assert_eq!((t.para_wrap_style(0), t.para_wrap_style(1)), (Balance, Auto));
assert_ne!(t.layout_key(), key);
let back: TextData = serde_json::from_str(&serde_json::to_string(&t).unwrap()).unwrap();
assert_eq!(back, t);
t.set_para_wrap_styles(0, 0, Auto);
assert!(t.paragraphs.is_empty());
t.set_para_wrap_styles(0, 0, Balance);
t.set_wrap_style(Pretty);
assert!(t.paragraphs.is_empty());
assert_eq!((t.para_wrap_style(0), t.para_wrap_style(1)), (Pretty, Pretty));
}
}
#[cfg(test)]
+9 -2
View File
@@ -57,6 +57,7 @@ pub const NODE_KEYS: &[&str] = &[
"lineHeight",
"lineIndentations",
"lineTypes",
"lineWrapStyles",
"listSpacing",
"listStartOffsets",
"listStyle",
@@ -455,8 +456,14 @@ fn text_props(t: &TextData, o: &mut serde_json::Map<String, Value>, options: &In
if t.leading_trim != figcraft_doc::LeadingTrim::None {
insert_prop(o, options, "leadingTrim", || json!(t.leading_trim));
}
if t.wrap_style != figcraft_doc::WrapStyle::Auto {
insert_prop(o, options, "wrapStyle", || json!(t.wrap_style));
// The paragraphs' effective wrap styles: one for the layer when they agree, else each one's.
let wraps: Vec<figcraft_doc::WrapStyle> = (0..t.paragraph_count()).map(|p| t.para_wrap_style(p)).collect();
if let Some(first) = wraps.first().copied().filter(|w| *w != figcraft_doc::WrapStyle::Auto)
&& wraps.iter().all(|w| *w == first)
{
insert_prop(o, options, "wrapStyle", || json!(first));
} else if wraps.iter().any(|w| *w != figcraft_doc::WrapStyle::Auto) {
insert_prop(o, options, "lineWrapStyles", || wraps.iter().map(|w| json!(w)).collect());
}
if !t.runs.is_empty() {
insert_prop(o, options, "styledRuns", || serde_json::to_value(&t.runs).unwrap_or(Value::Null));
+9 -2
View File
@@ -29,7 +29,7 @@ fontSize, lineHeight (auto|px|\"150%\"), letterSpacing (px|\"2%\"), textAlign (l
(auto|px|\"10%\" of the font size; the offset runs from the baseline down to an underline's top edge), textDecorationSkipInk (underlines break around descenders), \
textDecorationColor (colour, or auto/null: the text's), textCase (original|upper|lower|title|smallCaps), paragraphSpacing, paragraphIndent, \
listStyle (none|bulleted|numbered; with start/end: the paragraphs those chars touch), indentation (list nesting level 0-8, same range rule), \
listSpacing (px between list items, in place of paragraphSpacing), hangingPunctuation, hangingList (markers outside the box), leadingTrim (vertical trim: none|capHeight), maxLines, features ({tag: bool}, e.g. {\"liga\": false, \"tnum\": true}), \
listSpacing (px between list items, in place of paragraphSpacing), wrapStyle (auto|balance|pretty; with start/end: the paragraphs those chars touch), hangingPunctuation, hangingList (markers outside the box), leadingTrim (vertical trim: none|capHeight), maxLines, features ({tag: bool}, e.g. {\"liga\": false, \"tnum\": true}), \
fontVariations / variationSettings ({axis: number}, e.g. {\"wght\": 550, \"wdth\": 85}; null = none), \
path (a vector's SVG path data: replaces its geometry, keeping the layer, its paints, styles and bindings; width/height in the same call scale it as \
node.create does; its own box goes at the layer's x/y, or with x/y given, at x + its left edge and y + its top as node.create places it), pointCount, innerRadius, arcStart, arcSweep, \
@@ -1008,7 +1008,14 @@ fn apply_text(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Val
}
"leadingTrim" => text_mut(cmd, doc.get_mut(id)?)?.leading_trim = enum_of(v).ok_or_else(|| bad(cmd, "leadingTrim: none|capHeight"))?,
"wrapStyle" | "textWrap" => {
text_mut(cmd, doc.get_mut(id)?)?.wrap_style = enum_of(v).ok_or_else(|| bad(cmd, "wrapStyle: auto|balance|pretty"))?
let w: figcraft_doc::WrapStyle = enum_of(v).ok_or_else(|| bad(cmd, "wrapStyle: auto|balance|pretty"))?;
let t = text_mut(cmd, doc.get_mut(id)?)?;
if props.contains_key("start") || props.contains_key("end") {
let (start, end) = range_of(props, t.char_count());
t.set_para_wrap_styles(start, end, w);
} else {
t.set_wrap_style(w);
}
}
"maxLines" => {
let lines = f64_of(v).unwrap_or(0.0).clamp(0.0, 10_000.0) as u32;
+8 -4
View File
@@ -275,6 +275,9 @@ fn paragraph_style_json(cmd: &str, v: &Value) -> Result<ParagraphStyle> {
"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"))?,
"wrapStyle" | "textWrap" | "wrap_style" => {
ps.wrap_style = super::props::enum_of(x).ok_or_else(|| bad(cmd, "wrapStyle: auto|balance|pretty"))?;
}
"features" | "openTypeFeatures" | "fontFeatures" | "textFeatures" => {
ps.features = super::props::features_of(cmd, x)?.into_iter().filter_map(|(tag, on)| Some((tag, on?))).collect();
}
@@ -289,10 +292,11 @@ fn paragraph_style_json(cmd: &str, v: &Value) -> Result<ParagraphStyle> {
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;
if let Some((a, b)) = range {
if let Some(pl) = t.common_para_list(a, b) {
ps.list = pl.list;
}
ps.wrap_style = t.common_wrap_style(a, b).unwrap_or(t.wrap_style);
}
Some(ps)
}
+11 -2
View File
@@ -98,6 +98,15 @@ fn set_direction(s: &mut Session, p: &Value, d: TextDirection) -> Result<Value>
para_range(s, p, |t, a, b| t.set_para_lists(a, b, |pl| pl.direction = d))
}
/// The wrap style of the targeted texts: the edited paragraphs while editing (the paragraph
/// level), else the whole layer (the layer level: every paragraph, overrides included).
fn set_wrap_style(s: &mut Session, p: &Value, w: figcraft_doc::WrapStyle) -> Result<Value> {
match textedit::selected_range(s) {
Some((_, a, b)) => para(s, p, |t| t.set_para_wrap_styles(a, b, w)),
None => para(s, p, |t| t.set_wrap_style(w)),
}
}
/// Whether every targeted char has `pred` (to decide toggle direction).
fn all_have(s: &Session, p: &Value, pred: impl Fn(&TextStyle) -> bool) -> bool {
let Ok(doc) = s.doc() else { return false };
@@ -445,12 +454,12 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"text.wrapStyle",
"Wrap style",
"{style: auto|balance|pretty, ids?} where lines break when the text wraps: Auto fits as many words as fit on each line, Balance makes the lines close to the same length, Pretty keeps lines full but leaves no one word alone on the last",
"{style: auto|balance|pretty, ids?} where lines break when the text wraps (while editing: the selected paragraphs; else the whole layer): Auto fits as many words as fit on each line, Balance makes the lines close to the same length, Pretty keeps lines full but leaves no one word alone on the last",
true,
|s, p| {
let w: figcraft_doc::WrapStyle =
get(p, "style").and_then(super::props::enum_of).ok_or_else(|| bad("text.wrapStyle", "`style`: auto|balance|pretty"))?;
para(s, p, |t| t.wrap_style = w)
set_wrap_style(s, p, w)
}
),
spec!(
+6 -4
View File
@@ -302,10 +302,12 @@ fn text_decls(doc: &Document, n: &Node, tx: &figcraft_doc::TextData, t: &mut Tok
TextAlignH::Right => put("text-align", "right".into()),
TextAlignH::Justified => put("text-align", "justify".into()),
}
match tx.wrap_style {
figcraft_doc::WrapStyle::Auto => {}
figcraft_doc::WrapStyle::Balance => put("text-wrap", "balance".into()),
figcraft_doc::WrapStyle::Pretty => put("text-wrap", "pretty".into()),
// CSS has one `text-wrap` per element: written when every paragraph has the same one.
let wrap = (0..tx.paragraph_count()).map(|p| tx.para_wrap_style(p)).collect::<Vec<_>>();
match wrap.first().copied().filter(|w| wrap.iter().all(|x| x == w)) {
Some(figcraft_doc::WrapStyle::Balance) => put("text-wrap", "balance".into()),
Some(figcraft_doc::WrapStyle::Pretty) => put("text-wrap", "pretty".into()),
_ => {}
}
// A base direction forced on every paragraph (CSS has one per element).
let dir = tx.paragraphs.first().map(|p| p.direction).filter(|d| tx.paragraphs.iter().all(|p| p.direction == *d));
@@ -418,6 +418,51 @@ fn text_styles_carry_paragraph_spacing_indent_lists_and_features() {
assert!(s.execute("style.create", &json!({"type": "text", "name": "Bad", "value": {"paragraphSpacing": "wide"}})).is_err());
}
#[test]
fn text_styles_carry_the_wrap_style() {
// Explore text properties: "You can apply a wrap style at three levels: Text layer,
// Paragraph, Text style." A style made from a text takes its wrap style, applying it sets it,
// editing it on a styled text unlinks it.
use figcraft_doc::WrapStyle::{Auto, Balance, Pretty};
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);
run(&mut s, "node.set", json!({"id": t, "props": {"characters": "One\nTwo", "wrapStyle": "balance"}}));
let st = sid(run(&mut s, "style.create", json!({"type": "text", "name": "Even", "from": t})));
let listed = run(&mut s, "style.list", json!({"type": "text"}));
let even = listed.as_array().unwrap().iter().find(|x| x["id"] == json!(st)).unwrap();
assert_eq!(even["paragraph"]["wrap_style"], json!("balance"), "{even}");
// Applying it sets the layer's wrap style, with no paragraph overrides, and links the layer.
let t2 = text(&mut s, 300.0);
run(&mut s, "node.set", json!({"id": t2, "props": {"characters": "A\nB"}}));
run(&mut s, "style.apply", json!({"ids": [t2], "style": st}));
let d = td(&s, t2);
assert_eq!(d.wrap_style, Balance);
assert!(d.paragraphs.iter().all(|p| p.wrap.is_none()), "{:?}", d.paragraphs);
assert_eq!(node(&s, t2).styles.text.as_deref(), Some(st.as_str()));
// Redefining the style moves the layer and keeps the link.
run(&mut s, "style.update", json!({"style": st, "value": {"wrapStyle": "pretty"}}));
let d = td(&s, t2);
assert_eq!((d.para_wrap_style(0), d.para_wrap_style(1)), (Pretty, Pretty));
assert_eq!(node(&s, t2).styles.text.as_deref(), Some(st.as_str()), "still linked after the update");
// Editing the wrap style on the styled text unlinks it.
run(&mut s, "node.set", json!({"id": t2, "props": {"wrapStyle": "auto"}}));
assert_eq!(td(&s, t2).wrap_style, Auto);
assert_eq!(node(&s, t2).styles.text, None, "wrap style edited: unlinked");
// On a range, the paragraphs touched take it as their own and the layer keeps its style.
let t3 = text(&mut s, 600.0);
run(&mut s, "node.set", json!({"id": t3, "props": {"characters": "A\nB"}}));
run(&mut s, "style.apply", json!({"ids": [t3], "style": st, "start": 0, "end": 1}));
let d = td(&s, t3);
assert_eq!((d.wrap_style, d.para_wrap_style(0), d.para_wrap_style(1)), (Auto, Pretty, Auto));
// A definition given as a value takes the node.set spellings; junk is refused.
let st2 = sid(run(&mut s, "style.create", json!({"type": "text", "name": "Note", "value": {"wrapStyle": "balance"}})));
let want = ParagraphStyle { wrap_style: Balance, ..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": {"wrapStyle": "zigzag"}})).is_err());
assert!(s.execute("style.create", &json!({"type": "text", "name": "Bad", "value": {"textWrap": 3}})).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
+46
View File
@@ -495,6 +495,52 @@ fn balance_and_pretty_wrap_styles_break_lines_as_documented() {
assert_eq!(lines(&s), [(0, 9), (9, 16), (16, 21)]);
}
#[test]
fn wrap_style_per_paragraph_overrides_the_layers() {
// Explore text properties: "You can apply a wrap style at three levels: Text layer,
// Paragraph, Text style." A paragraph's own wrap style beats the layer's; setting the layer
// sets every paragraph again.
let font = test_mono_font();
let mut s = Session::new();
let t = text(&mut s, "aaa bbb ccc ddd eee\naaa bbb ccc ddd eee", json!({"fontFamily": font, "fontSize": 16, "lineHeight": 24}));
s.execute("node.set", &json!({"id": t, "props": {"textAutoResize": "height", "width": 136}})).unwrap();
let lines = |s: &Session| -> Vec<(usize, usize)> {
let (l, _) = figcraft_text::layout_node(s.doc().unwrap().node(t).unwrap()).unwrap();
l.lines.iter().map(|l| (l.char_start, l.char_end)).collect()
};
let inspect = |s: &Session| crate::inspect::node_json(s.doc().unwrap(), t, 0);
let css = |s: &mut Session| s.execute("inspect.code", &json!({"id": t})).unwrap()["code"].as_str().unwrap().to_string();
let paragraphs = |s: &Session| s.doc().unwrap().node(t).unwrap().kind.text().unwrap().paragraphs.clone();
assert_eq!(lines(&s), [(0, 16), (16, 20), (20, 36), (36, 39)], "Auto: four words and one in each paragraph");
// The second paragraph alone, by a range on node.set.
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "balance", "start": 20, "end": 20}})).unwrap();
assert_eq!(lines(&s), [(0, 16), (16, 20), (20, 32), (32, 39)]);
assert_eq!(inspect(&s)["lineWrapStyles"], json!(["auto", "balance"]));
assert!(inspect(&s).get("wrapStyle").is_none());
assert!(!css(&mut s).contains("text-wrap"), "{}", css(&mut s));
// While editing, the caret's paragraph (the Wrap style control).
s.execute("text.edit.begin", &json!({"id": t, "caret": 0})).unwrap();
s.execute("text.wrapStyle", &json!({"style": "pretty"})).unwrap();
s.execute("text.edit.end", &json!({})).unwrap();
assert_eq!(lines(&s), [(0, 12), (12, 20), (20, 32), (32, 39)]);
assert_eq!(inspect(&s)["lineWrapStyles"], json!(["pretty", "balance"]));
// Undo restores the paragraph's override.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(lines(&s), [(0, 16), (16, 20), (20, 32), (32, 39)]);
assert_eq!(inspect(&s)["lineWrapStyles"], json!(["auto", "balance"]));
// Enter inside the balanced paragraph carries the style to the new paragraph.
s.execute("text.edit.begin", &json!({"id": t, "caret": 22})).unwrap();
s.execute("text.edit.insert", &json!({"text": "\n"})).unwrap();
s.execute("text.edit.end", &json!({})).unwrap();
assert_eq!(inspect(&s)["lineWrapStyles"], json!(["auto", "balance", "balance"]));
// The layer level sets every paragraph and folds the overrides away.
s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "balance"}})).unwrap();
assert!(paragraphs(&s).iter().all(|p| p.wrap.is_none()), "{:?}", paragraphs(&s));
assert_eq!(inspect(&s)["wrapStyle"], json!("balance"));
assert!(css(&mut s).contains("text-wrap: balance;"), "{}", css(&mut s));
assert!(s.execute("node.set", &json!({"id": t, "props": {"wrapStyle": "zigzag", "start": 0, "end": 0}})).is_err());
}
#[test]
fn max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines() {
// "Max lines is available only if truncate text is enabled, and: Text resizing is set to
+1
View File
@@ -278,6 +278,7 @@ fn lists(v: &Value, td: &mut TextData) {
Some("RTL") => TextDirection::Rtl,
_ => TextDirection::Auto,
},
wrap: None,
})
.collect();
// Clamp the levels and fold one style for all back into the layer's list style.
+4 -4
View File
@@ -869,7 +869,7 @@ fn line_width(items: &[Item], (start, end, _, _): LineRange) -> f64 {
/// to give a word to the lines after it ("keeps each line full, but adjusts the last four lines to
/// avoid an orphan"). `rebreak` lays the text out again with a width per paragraph and line.
fn restyle_breaks(
style: WrapStyle,
style_of: &dyn Fn(usize) -> WrapStyle,
w: f64,
lines: Vec<LineRange>,
items: &[Item],
@@ -905,7 +905,7 @@ fn restyle_breaks(
}
// Words wider than the box break where they must; the re-breaking adds no such break.
let base = emergency_breaks(items, word_breaks, &para);
match style {
match style_of(p) {
WrapStyle::Auto => {}
WrapStyle::Balance => {
// The narrowest width that still gives `n` lines, to half a pixel.
@@ -992,8 +992,8 @@ fn wrapped_lines(
let lines = from_saved.or_else(|| breaks(&|_, _| wrap, None)).unwrap_or_default();
// Balance and Pretty re-break the paragraphs at narrower widths (never saved breaks).
match wrap {
Some(w) if !followed && text.wrap_style != WrapStyle::Auto => {
restyle_breaks(text.wrap_style, w, lines, items, word_breaks, line_indent_of, hung_of, &|wrap_of| {
Some(w) if !followed && (0..text.paragraph_count()).any(|p| text.para_wrap_style(p) != WrapStyle::Auto) => {
restyle_breaks(&|p| text.para_wrap_style(p), w, lines, items, word_breaks, line_indent_of, hung_of, &|wrap_of| {
breaks(wrap_of, None).unwrap_or_default()
})
}
@@ -388,7 +388,14 @@ fn type_settings(
}
// Where lines break when the text wraps (Explore text properties).
caption(ui, tr("Wrap style"));
let wrap = common(doc, ids, |n| n.kind.text().map(|t| t.wrap_style as usize)).flatten();
// Per paragraph, as the text direction: while editing, the selected paragraphs.
let wrap = common(doc, ids, |n| {
n.kind.text().and_then(|t| {
let (a, b) = range.filter(|r| r.0 == n.id).map_or((0, t.char_count()), |(_, a, b)| (a, b));
t.common_wrap_style(a, b).map(|w| w as usize)
})
})
.flatten();
let items = [
(tr("Auto"), tr_noop("Auto: as many words as fit on each line")),
(tr("Balance"), tr_noop("Balance: every line close to the same length")),
@@ -396,7 +403,7 @@ fn type_settings(
];
if let Some(i) = text_seg(ui, "wrap", &items, wrap, w) {
let style = ["auto", "balance", "pretty"].get(i).copied().unwrap_or("auto");
app.set_props(ids, json!({"wrapStyle": style}));
app.run("text.wrapStyle", json!({"ids": ids, "style": style}));
}
// The paragraphs' base direction, once a right-to-left script is in the text or a direction
// is forced ("controls are available to override text direction per paragraph", Add
+6 -1
View File
@@ -11,7 +11,7 @@
use egui::{Align2, Color32, CornerRadius, Id, Rect, Response, Sense, Stroke, Ui, pos2, vec2};
use figcraft_color::{Paint, PaintKind};
use figcraft_doc::{
Document, GridKind, ListStyle, Node, NodeId, ParagraphStyle, PublishedLibrary, Style, StyleKind, TextStyle, VariableType, trim_num,
Document, GridKind, ListStyle, Node, NodeId, ParagraphStyle, PublishedLibrary, Style, StyleKind, TextStyle, VariableType, WrapStyle, trim_num,
};
use figcraft_engine::library;
use serde_json::{Value, json};
@@ -999,6 +999,11 @@ fn paragraph_rows(ui: &mut Ui, ps: &ParagraphStyle) {
ListStyle::Bulleted => rows.push(tr("Bulleted list").into()),
ListStyle::Numbered => rows.push(tr("Numbered list").into()),
}
match ps.wrap_style {
WrapStyle::Auto => {}
WrapStyle::Balance => rows.push(format!("{}: {}", tr("Wrap style"), tr("Balance"))),
WrapStyle::Pretty => rows.push(format!("{}: {}", tr("Wrap style"), tr("Pretty"))),
}
if !ps.features.is_empty() {
rows.push(ps.features.iter().map(|(tag, on)| format!("{}{tag}", if *on { '+' } else { '-' })).collect::<Vec<_>>().join(" "));
}
+2 -1
View File
@@ -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`; 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) |
| 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, the wrap 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 |
@@ -81,6 +81,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|---|---|---|
| 2026-11-05 | minor | M3, #2002: the Local variables window selects several variables, as the Layers panel selects layers. A click selects one row, Shift+click the range from the anchor through the rows shown, Cmd/Ctrl+click toggles a row, Escape or a click on the empty space below the rows clears; selected rows use the sidebar's selected-row colour. Right-clicking a row that is part of a selection of several offers Delete, Move to group and Scope (all supported properties, hidden from pickers, or only one scope) for the selected local variables, each sent as one `command.batch`, so one undo step; no engine command changed. Renaming and value edits stay single-row. Tests: `variables_select_tests` (headless UI). Grade unchanged |
| 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-07 | minor | M4, #1827: text styles carry the wrap style (`wrapStyle` in a style's `paragraph`): applied with the style (on a range, to the paragraphs touched), moved by `style.update`, unlinking when edited; shown read-only in the style editor |
| 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 |
+10 -4
View File
@@ -298,6 +298,11 @@ Or in a project's `.mcp.json`:
`textDecorationOffset` (`auto`, px, or `"10%"` of the font size; the offset runs from the baseline
down to an underline's top edge), `textDecorationSkipInk` (underlines break around descenders) and
`textDecorationColor` (a colour, or `auto` for the text's) are character props too.
- **Wrap style** (`auto`, `balance`, `pretty`) works at three levels: `wrapStyle` on `node.set`
sets the layer (every paragraph); with `start`/`end` it sets the paragraphs those chars touch,
and a paragraph's own style beats the layer's. `text.wrapStyle` sets the selected paragraphs while
text is edited. `get_node` reports one `wrapStyle`, or `lineWrapStyles` per paragraph when they
differ.
- **Lists** are per paragraph: `listStyle` (`none`, `bulleted`, `numbered`) and `indentation` (the
nesting level, 0–8) apply to the paragraphs chars `start`..`end` touch (all of them without a
range); `get_node` reports one `listStyle` for the layer, or `lineTypes` and `lineIndentations`
@@ -313,10 +318,11 @@ 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.
A text style also carries paragraph spacing and indent, the list style, the wrap style and the
OpenType features (`paragraph` beside `style` in `style.list`; a `value` takes `wrapStyle`):
applying it sets them (on a range, the list and wrap style go to the paragraphs touched; the rest
is the layer's), `style.update` moves them too, and a `.fig` file keeps them with the style
(not the wrap style yet).
Changing what a text style sets (font, size, line height, letter spacing, decoration, case, axis
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
+3 -2
View File
@@ -1,6 +1,6 @@
# UI and interaction parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged;Line truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; wrap style per paragraph and in text styles (#1827); Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fracLine truncated
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
@@ -96,7 +96,7 @@ interaction feel not yet compared.
| Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | |
| Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | a group past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold) draws without its result, with a warning, and Flatten refuses it (#1104); the Design panel's Boolean groups menu leads Union, Subtract, Intersect and Exclude with their own icons, two offset squares with the result filled at reduced alpha (#2003) |
| Text: point, auto height, fixed; caret, words, lines, IME | done | Backspace or Delete at the start of a list item takes its marker and keeps its level, the paragraph starting where the item's text did ("delete the counter, but keep the same level of indentation", Create bulleted and numbered lists; `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests`); max lines on auto-width text keep it auto width, hugging up to that many lines and growing with its longest line ("Text resizing is set to auto height or auto width", Explore text properties; `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines`); Alt+Enter while editing opens the link at the caret, web and mail links only (`text.openLink`; "press ⌥Option Return/Alt Enter to activate a focused link", Accessibility at Figma; `text_link_tests`); the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check |
| Text: wrap style (Auto, Balance, Pretty) | done | per text layer (`wrapStyle` on `node.set`, `text.wrapStyle`, the Type settings control under Vertical trim, inspect, CSS `text-wrap: balance` or `pretty`): Auto breaks greedily; Balance narrows each paragraph as far as it goes without another line, so its lines come out close to the same length (what browsers do for `text-wrap: balance`); Pretty keeps lines full and, when the last line is one word, narrows one of the last four lines just enough to give a word to the lines after it (Explore text properties: "Auto: Figma fits as many words as possible on each line before wrapping to the next", "Balance: Figma distributes text as evenly as possible across every line, so each line is close to the same length", "Pretty: Figma keeps each line full, but adjusts the last four lines to avoid an orphan"). Saved line breaks from a file still win. Not yet included: per-paragraph settings, text-style support and the `.fig` import/export mapping (#1827). Test: `text_behaviour_tests::balance_and_pretty_wrap_styles_break_lines_as_documented` |
| Text: wrap style (Auto, Balance, Pretty) | done | per text layer (`wrapStyle` on `node.set`, `text.wrapStyle`, the Type settings control under Vertical trim, inspect, CSS `text-wrap: balance` or `pretty`): Auto breaks greedily; Balance narrows each paragraph as far as it goes without another line, so its lines come out close to the same length (what browsers do for `text-wrap: balance`); Pretty keeps lines full and, when the last line is one word, narrows one of the last four lines just enough to give a word to the lines after it (Explore text properties: "Auto: Figma fits as many words as possible on each line before wrapping to the next", "Balance: Figma distributes text as evenly as possible across every line, so each line is close to the same length", "Pretty: Figma keeps each line full, but adjusts the last four lines to avoid an orphan"). Saved line breaks from a file still win. At three levels, as "You can apply a wrap style at three levels: Text layer, Paragraph, Text style" says: the paragraph level is the caret's or the selection's paragraphs while editing (`text.wrapStyle`; the control follows them), or `start`/`end` on `node.set wrapStyle` (a paragraph's own style beats the layer's; setting the layer sets every paragraph again; Enter carries it to the new paragraph; inspect reports `lineWrapStyles` when paragraphs differ and CSS `text-wrap` only when they agree); the text-style level is a style's `wrapStyle` (shown read-only in the style editor, applied with the style, moved by `style.update`, unlinking when edited). The `.fig` import/export mapping stays open (#1827). Test: `text_behaviour_tests::wrap_style_per_paragraph_overrides_the_layers`, `styles_behaviour_tests::text_styles_carry_the_wrap_style`, `text_behaviour_tests::balance_and_pretty_wrap_styles_break_lines_as_documented` |
| Text: direction override (Text ▸ Text direction) | done | per paragraph (`ParaList::direction`: auto, ltr, rtl): Text ▸ Text direction ▸ Auto / Left to right / Right to left and `text.direction {direction, ids?}` set the edited paragraphs while editing, else every paragraph of the selected texts; `node.set textDirection` the layer's (or the chars `start`/`end` name); the Type settings control appears once a right-to-left script is in the text or a direction is forced; inspect reports `textDirection` when the paragraphs agree and `lineDirections` otherwise; Dev Mode's CSS writes `direction: ltr` or `rtl` when every paragraph is forced the same way (with `text-align: left` beside `rtl`, as Left is absolute on the canvas); `.fig` import and export carry each line's `sourceDirectionality`, read up to the paragraph count. Layout gives the paragraph its base level, so a forced right-to-left paragraph of Latin letters starts at the right, mirrors its list gutter and indent and orders its runs right to left, and a forced left-to-right paragraph of Hebrew the other way ("By default, Figma automatically handles text direction based on language detection. However, controls are available to override text direction per paragraph", "Go to Text > Text direction. Select Left to right or Right to left", Add right-to-left text). Not included: a direction in text styles (the article names none). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests` |
| Text: superscript and subscript | done | `sups` / `subs` use the font's own glyphs where it has the feature and every letter or digit of the run gets one; otherwise the standard glyphs are drawn shrunk and raised or lowered by the font's OS/2 superscript and subscript metrics (0.65 em at 0.35 em up or 0.14 em down without them), and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says ("Figma draws a synthesized version by using the font's standard version of the character, shrinking it, and positioning it accordingly"; mixed glyphs "all update to the synthesized form for consistency"), #1828; tests `text::tests::superscript_native_or_synthesised_and_never_mixed`, `text_behaviour_tests::superscript_and_subscript_are_synthesised_without_glyphs_for_them` |
| Eyedropper (I) | done | picks the colour seen under the pointer (opaque) into the selection's fill and the recent colours, one undo step (`fx_behaviour_tests`); with Cmd/Ctrl+Shift held it makes a colour style or variable of it instead, as the reference app's eyedropper does: a Create from color window asks which, its name (the next free "Color n") and a variable's collection (the file's first, or a new one made with it in the same undo step), and leaves the selection's fills alone (`eyedropper_create_tests`, `sampled_color_tests`) |
@@ -206,6 +206,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
| 2026-11-04 | patch | M1, #1852: a press on one of the open gradient's handles or stops on the canvas is the colour picker's own, not a press outside it (Figma keeps the picker and the handles up while a handle or stop is dragged; the stops are made to be dragged: "You can click and drag a stop to change its placement on the gradient", Use gradients as a fill or stroke). The canvas took the press and began the drag, then the picker, drawn later in the same frame, saw a press outside its card and closed, committing the drag's interaction at once; the handles vanished with it and the drag went on without an undo step of its own. Now the picker stays while a gradient handle or stop drags, and the drag is one undo step; a press elsewhere on the canvas still closes it. A handle drag whose button goes up off the canvas (over a panel or the picker) ends there too, instead of waiting, uncommitted, to move the handle to the next press on the canvas. Tests: `colorpicker_selection_tests::dragging_a_gradient_handle_on_the_canvas_keeps_the_picker_open`, `colorpicker_selection_tests::a_gradient_drag_released_off_the_canvas_ends_there`. |
| 2026-11-02 | patch | M8, #1875: a click with the Bend tool on a sharp point between two segments gives it handles along the line between its neighbours, a third of the way to each ("Click on a point or path where you want to add a curve", Edit vector layers); a click on a curved point still makes it sharp, a drag still sets the handles by hand, an endpoint is left alone. Between a handle's end and a point both within reach of the pointer, the nearer one on the page is hit (the handle on a tie), so a short path's point (its handles a few pixels long) can be clicked again. Before, only a drag curved a point, and a short one left two tiny handles in its direction. Tests: `vectoredit::tests::short_handles_leave_their_point_clickable`, `vectoredit::tests::a_bend_click_curves_a_sharp_point_between_its_neighbours`, `bend_tool_smooths_and_sharpens_points`. Numbers unchanged. |
| 2026-11-02 | minor | M4, #1827: wrap style on a text layer (`WrapStyle`: Auto, Balance, Pretty): Balance re-breaks each paragraph at the narrowest width that keeps its line count (bisection over the greedy breaker, as browsers balance), Pretty narrows one of the last four lines just enough so the last line isn't one word; `text.wrapStyle`, `node.set wrapStyle` (`textWrap` too), inspect `wrapStyle`, Dev Mode CSS `text-wrap` (Tailwind `text-balance` / `text-pretty`), the Type settings control under Vertical trim. Per-paragraph settings, text-style support and the `.fig` import/export mapping stay open. Test: `text_behaviour_tests::balance_and_pretty_wrap_styles_break_lines_as_documented`. |
| 2026-11-07 | minor | M4, #1827: wrap style per paragraph and in text styles, overriding the layer's: `ParaList::wrap` (None follows the layer; Enter carries it, an override equal to the layer's is none), `text.wrapStyle` while editing and `node.set wrapStyle` with `start`/`end` set the paragraphs, without them the layer sets every paragraph; `restyle_breaks` takes a style per paragraph; inspect `lineWrapStyles`, CSS `text-wrap` when the paragraphs agree; `ParagraphStyle::wrap_style` (text styles apply, update and unlink on it); the Wrap style control follows the paragraph; the `.fig` mapping stays open |
| 2026-11-02 | patch | M4: Backspace or Delete at the start of a list item takes its marker and keeps its level ("Use Backspace or Delete at the beginning of a list item to delete the counter, but keep the same level of indentation", Create bulleted and numbered lists): a plain paragraph may now carry a list level, and the text layout starts it where that level's item text starts (the gutters stay even when no list is left). Backspace used to drop the level too, and Delete at an item's start deleted the next character. Tab on such a paragraph types a tab, as outside a list. Tests: `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests::a_plain_paragraph_at_a_list_level_starts_where_the_items_text_does` |
| 2026-11-02 | patch | M4: max lines on an auto-width text keep it auto width: the box hugs up to that many lines and still grows with its longest line ("Max lines is available only if truncate text is enabled, and: Text resizing is set to auto height or auto width", Explore text properties). `text.maxLines` turned such a text into a fixed truncating box with a stale height, so later typing no longer widened it; fixed-size text still becomes a truncating box, auto-height text hugs as before. Test: `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines` |
| 2026-11-02 | patch | M4, #1828: superscript and subscript synthesised where the font has no `sups` / `subs` glyphs (the standard glyph shrunk and shifted by the OS/2 metrics, the usual 0.65 em and 0.35 / 0.14 em offsets without them), and a run mixing available and unavailable glyphs synthesised whole, as Explore text properties says. Tests: `text::tests::superscript_native_or_synthesised_and_never_mixed`, `text_behaviour_tests::superscript_and_subscript_are_synthesised_without_glyphs_for_them`. |