Merge main into #840

# Conflicts:
#	docs/roadmap.md
This commit is contained in:
bflatastic
2026-10-27 00:12:27 +07:00
22 changed files with 598 additions and 61 deletions
+24 -1
View File
@@ -417,7 +417,8 @@ pub struct TextData {
/// line's baseline).
#[serde(skip_serializing_if = "crate::node::is_default")]
pub leading_trim: LeadingTrim,
/// Max lines when truncating (0 = as many as fit).
/// Max lines when truncating, or before an auto-height text ends in an ellipsis (0 = as many
/// as fit).
#[serde(skip_serializing_if = "crate::node::is_default")]
pub max_lines: u32,
/// OpenType feature toggles (e.g. "liga": false).
@@ -510,6 +511,28 @@ impl TextData {
self.characters.chars().count()
}
/// Whether text that runs out of room ends in an ellipsis: a truncating box, or auto height
/// with a line limit (the box hugs up to `max_lines` lines).
pub fn truncates(&self) -> bool {
match self.auto_resize {
TextAutoResize::Truncate => true,
TextAutoResize::Height => self.max_lines > 0,
TextAutoResize::WidthAndHeight | TextAutoResize::None => false,
}
}
/// The resize mode once the box is given an explicit `width` and/or `height`: an explicit
/// height fixes the box, an explicit width turns auto width into auto height, and truncating
/// text keeps truncating.
pub fn fix_size(&mut self, width: bool, height: bool) {
self.auto_resize = match self.auto_resize {
_ if height && self.truncates() => TextAutoResize::Truncate,
_ if height => TextAutoResize::None,
TextAutoResize::WidthAndHeight if width => TextAutoResize::Height,
mode => mode,
};
}
/// The style at char index `i`.
pub fn style_at(&self, i: usize) -> &TextStyle {
self.runs.iter().find(|r| r.start <= i && i < r.end).map(|r| &r.style).unwrap_or(&self.style)
+5 -1
View File
@@ -562,7 +562,11 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
n.child_layout.sizing_v = Sizing::Fixed;
}
if let NodeKind::Text(t) = &mut n.kind {
t.auto_resize = if num(p, "height").is_some() { TextAutoResize::None } else { TextAutoResize::Height };
if num(p, "height").is_some() {
t.fix_size(num(p, "width").is_some(), true);
} else if t.auto_resize != TextAutoResize::Truncate {
t.auto_resize = TextAutoResize::Height;
}
}
super::props::restore_worlds(doc, &kept)?;
}
+2 -5
View File
@@ -461,11 +461,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
resize_node(doc, id, nw, nh)?;
// Explicit size makes text fixed in that dimension.
if let Some(t) = doc.get_mut(id)?.kind.text_mut() {
t.auto_resize = if is_w {
if t.auto_resize == TextAutoResize::WidthAndHeight { TextAutoResize::Height } else { t.auto_resize }
} else {
TextAutoResize::None
};
t.fix_size(is_w, !is_w);
}
fix_axis(doc, id, is_w)?;
}
@@ -1220,6 +1216,7 @@ fn set_sizing(cmd: &str, doc: &mut Document, id: NodeId, s: Sizing, horizontal:
t.auto_resize = match (n.child_layout.sizing_h, n.child_layout.sizing_v) {
(Sizing::Hug, _) => TextAutoResize::WidthAndHeight,
(_, Sizing::Hug) => TextAutoResize::Height,
_ if t.truncates() => TextAutoResize::Truncate,
_ => TextAutoResize::None,
};
}
+15 -9
View File
@@ -291,15 +291,21 @@ pub fn specs() -> Vec<CommandSpec> {
let v = finite(req_num("text.paragraphSpacing", p, "spacing")?).clamp(0.0, 10_000.0);
para(s, p, |t| t.paragraph_spacing = v)
}),
spec!("text.maxLines", "Max lines", "{lines (0 = no limit; truncates), ids?}", true, |s, p| {
let v = finite(req_num("text.maxLines", p, "lines")?).clamp(0.0, 10_000.0) as u32;
para(s, p, |t| {
t.max_lines = v;
if v > 0 {
t.auto_resize = TextAutoResize::Truncate;
}
})
}),
spec!(
"text.maxLines",
"Max lines",
"{lines (0 = no limit; truncates: auto-height text hugs up to that many lines, other text becomes a truncating box), ids?}",
true,
|s, p| {
let v = finite(req_num("text.maxLines", p, "lines")?).clamp(0.0, 10_000.0) as u32;
para(s, p, |t| {
t.max_lines = v;
if v > 0 && t.auto_resize != TextAutoResize::Height {
t.auto_resize = TextAutoResize::Truncate;
}
})
}
),
spec!(
"text.setFeatures",
"OpenType features",
+3
View File
@@ -188,6 +188,9 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
o.insert("letterSpacing".into(), json!(t.style.letter_spacing.label()));
o.insert("textAlign".into(), json!(t.align_h));
o.insert("textAutoResize".into(), json!(t.auto_resize));
if t.max_lines > 0 {
o.insert("maxLines".into(), json!(t.max_lines));
}
if let Some(tp) = &t.path {
o.insert("textPath".into(), json!(tp));
}
+60
View File
@@ -310,3 +310,63 @@ fn new_text_starts_in_the_builds_default_font() {
assert_eq!(serde_json::to_value(td).unwrap()["style"]["font_family"], figcraft_doc::DEFAULT_FONT);
}
}
fn resize_mode(s: &Session, t: NodeId) -> figcraft_doc::TextAutoResize {
s.doc().unwrap().node(t).unwrap().kind.text().unwrap().auto_resize
}
#[test]
fn a_truncated_text_keeps_truncating_when_its_box_is_resized() {
// #1376: a truncating text is already fixed both ways, so an explicit size changes its box only.
use figcraft_doc::TextAutoResize;
let mut s = Session::new();
let t = text(&mut s, "Lorem consectetur", json!({"textAutoResize": "truncate", "width": 100, "height": 24}));
assert_eq!(resize_mode(&s, t), TextAutoResize::Truncate);
for (cmd, p, why) in [
("node.set", json!({"id": t, "props": {"height": 24}}), "node.set height (the value it has)"),
("node.set", json!({"id": t, "props": {"width": 120, "height": 30}}), "node.set width and height"),
("transform.resize", json!({"ids": [t], "height": 40}), "transform.resize height"),
("transform.resize", json!({"ids": [t], "width": 90}), "transform.resize width"),
("node.set", json!({"id": t, "props": {"sizingV": "fixed"}}), "fixed vertical sizing"),
] {
s.execute(cmd, &p).unwrap();
assert_eq!(resize_mode(&s, t), TextAutoResize::Truncate, "{why}");
}
// Text that doesn't truncate still turns fixed size.
let plain = text(&mut s, "Lorem consectetur", json!({"textAutoResize": "height", "width": 100}));
s.execute("node.set", &json!({"id": plain, "props": {"height": 24}})).unwrap();
assert_eq!(resize_mode(&s, plain), TextAutoResize::None);
}
#[test]
fn max_lines_on_auto_height_text_hugs_that_many_lines() {
// #1377: an auto-height text with max lines grows up to that many lines and ends in an
// ellipsis; the layer reports its max lines.
use figcraft_doc::TextAutoResize;
figcraft_text::scan_system_fonts();
let mut s = Session::new();
let t = text(&mut s, "Lorem ipsum dolor sit amet consectetur", json!({"fontSize": 16, "lineHeight": 24}));
s.execute("node.set", &json!({"id": t, "props": {"textAutoResize": "height", "width": 100}})).unwrap();
assert!(crate::inspect::node_json(s.doc().unwrap(), t, 0).get("maxLines").is_none(), "no limit: not reported");
// Wrapping needs a font (CI may have none).
if size(&s, t).1 < 48.0 {
return;
}
s.execute("text.maxLines", &json!({"ids": [t], "lines": 1})).unwrap();
assert_eq!(resize_mode(&s, t), TextAutoResize::Height, "max lines keep auto height");
assert_eq!(size(&s, t), (100.0, 24.0), "one line tall");
assert_eq!(crate::inspect::node_json(s.doc().unwrap(), t, 0)["maxLines"], json!(1));
s.execute("node.set", &json!({"id": t, "props": {"maxLines": 2}})).unwrap();
assert_eq!(size(&s, t).1, 48.0, "two lines tall");
// No limit: it grows to all of its lines again.
s.execute("node.set", &json!({"id": t, "props": {"maxLines": 0}})).unwrap();
assert!(size(&s, t).1 > 48.0);
// An explicit height on a text that truncates makes a truncating box.
s.execute("node.set", &json!({"id": t, "props": {"maxLines": 1}})).unwrap();
s.execute("node.set", &json!({"id": t, "props": {"height": 30}})).unwrap();
assert_eq!((resize_mode(&s, t), size(&s, t).1), (TextAutoResize::Truncate, 30.0));
// Other text still becomes a truncating box with max lines.
let fixed = text(&mut s, "Lorem", json!({"textAutoResize": "none", "width": 100, "height": 100}));
s.execute("text.maxLines", &json!({"ids": [fixed], "lines": 2})).unwrap();
assert_eq!(resize_mode(&s, fixed), TextAutoResize::Truncate);
}
+2
View File
@@ -29,6 +29,8 @@ pub fn specs() -> Vec<CommandSpec> {
/// paragraph of its own after a hard break, which no size can fix).
fn measure(t: &TextData, k: f64, width: f64) -> (usize, f64, bool) {
let mut t = t.clone();
// Every line counts, not only those the layer's own line limit shows.
t.max_lines = 0;
let n = t.char_count();
t.restyle(0, n, |s| s.font_size = (s.font_size * k).clamp(1.0, 5000.0));
let l = figcraft_text::layout(&t, Some(width), None);
+5 -4
View File
@@ -1821,10 +1821,11 @@ impl Session {
n.child_layout.sizing_v = Sizing::Fixed;
}
if let NodeKind::Text(t) = &mut n.kind {
t.auto_resize = match handle {
Handle::Edge(1) | Handle::Edge(3) => TextAutoResize::Height,
_ => TextAutoResize::None,
};
match handle {
Handle::Edge(1) | Handle::Edge(3) if t.auto_resize == TextAutoResize::Truncate => {}
Handle::Edge(1) | Handle::Edge(3) => t.auto_resize = TextAutoResize::Height,
_ => t.fix_size(true, true),
}
}
return Ok(());
}
+1 -1
View File
@@ -1558,7 +1558,7 @@ impl<'d> Exporter<'d> {
("textTracking", fl(0.0)),
("emojiImageSet", e("APPLE")),
]);
if td.auto_resize == TextAutoResize::Truncate {
if td.truncates() {
f.push(("textTruncation", e("ENDING")));
}
if td.max_lines > 0 {
+212 -2
View File
@@ -254,14 +254,193 @@ fn image_index<S: Serializer>(images: &&BTreeMap<String, ImageData>, s: S) -> Re
s.collect_seq(images.iter().map(|(hash, i)| Entry { hash, width: i.width, height: i.height }))
}
/// A serializer adapter that writes `0` in place of every NaN or infinite float. JSON has no such
/// numbers (`serde_json` writes `null`, which the reader rejects), so one bad value must not stop
/// Save, autosave or recovery copies of the whole document. Everything else passes through.
struct Finite<S>(S);
/// Serializes a value through a [`Finite`] adapter around the serializer it is written with.
struct FiniteValue<'a, T: ?Sized>(&'a T);
impl<T: ?Sized + Serialize> Serialize for FiniteValue<'_, T> {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
self.0.serialize(Finite(s))
}
}
macro_rules! finite_pass {
($($name:ident($ty:ty)),* $(,)?) => {
$(fn $name(self, v: $ty) -> Result<S::Ok, S::Error> { self.0.$name(v) })*
};
}
impl<S: Serializer> Serializer for Finite<S> {
type Ok = S::Ok;
type Error = S::Error;
type SerializeSeq = Finite<S::SerializeSeq>;
type SerializeTuple = Finite<S::SerializeTuple>;
type SerializeTupleStruct = Finite<S::SerializeTupleStruct>;
type SerializeTupleVariant = Finite<S::SerializeTupleVariant>;
type SerializeMap = Finite<S::SerializeMap>;
type SerializeStruct = Finite<S::SerializeStruct>;
type SerializeStructVariant = Finite<S::SerializeStructVariant>;
finite_pass!(
serialize_bool(bool),
serialize_i8(i8),
serialize_i16(i16),
serialize_i32(i32),
serialize_i64(i64),
serialize_u8(u8),
serialize_u16(u16),
serialize_u32(u32),
serialize_u64(u64),
serialize_i128(i128),
serialize_u128(u128),
serialize_char(char),
serialize_str(&str),
serialize_bytes(&[u8])
);
fn serialize_f32(self, v: f32) -> Result<S::Ok, S::Error> {
self.0.serialize_f32(if v.is_finite() { v } else { 0.0 })
}
fn serialize_f64(self, v: f64) -> Result<S::Ok, S::Error> {
self.0.serialize_f64(if v.is_finite() { v } else { 0.0 })
}
fn serialize_none(self) -> Result<S::Ok, S::Error> {
self.0.serialize_none()
}
fn serialize_some<T: ?Sized + Serialize>(self, v: &T) -> Result<S::Ok, S::Error> {
self.0.serialize_some(&FiniteValue(v))
}
fn serialize_unit(self) -> Result<S::Ok, S::Error> {
self.0.serialize_unit()
}
fn serialize_unit_struct(self, name: &'static str) -> Result<S::Ok, S::Error> {
self.0.serialize_unit_struct(name)
}
fn serialize_unit_variant(self, name: &'static str, idx: u32, variant: &'static str) -> Result<S::Ok, S::Error> {
self.0.serialize_unit_variant(name, idx, variant)
}
fn serialize_newtype_struct<T: ?Sized + Serialize>(self, name: &'static str, v: &T) -> Result<S::Ok, S::Error> {
self.0.serialize_newtype_struct(name, &FiniteValue(v))
}
fn serialize_newtype_variant<T: ?Sized + Serialize>(self, name: &'static str, idx: u32, variant: &'static str, v: &T) -> Result<S::Ok, S::Error> {
self.0.serialize_newtype_variant(name, idx, variant, &FiniteValue(v))
}
fn serialize_seq(self, len: Option<usize>) -> Result<Self::SerializeSeq, S::Error> {
self.0.serialize_seq(len).map(Finite)
}
fn serialize_tuple(self, len: usize) -> Result<Self::SerializeTuple, S::Error> {
self.0.serialize_tuple(len).map(Finite)
}
fn serialize_tuple_struct(self, name: &'static str, len: usize) -> Result<Self::SerializeTupleStruct, S::Error> {
self.0.serialize_tuple_struct(name, len).map(Finite)
}
fn serialize_tuple_variant(
self,
name: &'static str,
idx: u32,
variant: &'static str,
len: usize,
) -> Result<Self::SerializeTupleVariant, S::Error> {
self.0.serialize_tuple_variant(name, idx, variant, len).map(Finite)
}
fn serialize_map(self, len: Option<usize>) -> Result<Self::SerializeMap, S::Error> {
self.0.serialize_map(len).map(Finite)
}
fn serialize_struct(self, name: &'static str, len: usize) -> Result<Self::SerializeStruct, S::Error> {
self.0.serialize_struct(name, len).map(Finite)
}
fn serialize_struct_variant(
self,
name: &'static str,
idx: u32,
variant: &'static str,
len: usize,
) -> Result<Self::SerializeStructVariant, S::Error> {
self.0.serialize_struct_variant(name, idx, variant, len).map(Finite)
}
fn is_human_readable(&self) -> bool {
self.0.is_human_readable()
}
}
macro_rules! finite_elements {
($($trait:ident::$method:ident),* $(,)?) => {
$(impl<S: serde::ser::$trait> serde::ser::$trait for Finite<S> {
type Ok = S::Ok;
type Error = S::Error;
fn $method<T: ?Sized + Serialize>(&mut self, v: &T) -> Result<(), S::Error> {
self.0.$method(&FiniteValue(v))
}
fn end(self) -> Result<S::Ok, S::Error> {
self.0.end()
}
})*
};
}
finite_elements!(
SerializeSeq::serialize_element,
SerializeTuple::serialize_element,
SerializeTupleStruct::serialize_field,
SerializeTupleVariant::serialize_field
);
impl<S: serde::ser::SerializeMap> serde::ser::SerializeMap for Finite<S> {
type Ok = S::Ok;
type Error = S::Error;
fn serialize_key<T: ?Sized + Serialize>(&mut self, k: &T) -> Result<(), S::Error> {
self.0.serialize_key(&FiniteValue(k))
}
fn serialize_value<T: ?Sized + Serialize>(&mut self, v: &T) -> Result<(), S::Error> {
self.0.serialize_value(&FiniteValue(v))
}
fn end(self) -> Result<S::Ok, S::Error> {
self.0.end()
}
}
impl<S: serde::ser::SerializeStruct> serde::ser::SerializeStruct for Finite<S> {
type Ok = S::Ok;
type Error = S::Error;
fn serialize_field<T: ?Sized + Serialize>(&mut self, key: &'static str, v: &T) -> Result<(), S::Error> {
self.0.serialize_field(key, &FiniteValue(v))
}
fn skip_field(&mut self, key: &'static str) -> Result<(), S::Error> {
self.0.skip_field(key)
}
fn end(self) -> Result<S::Ok, S::Error> {
self.0.end()
}
}
impl<S: serde::ser::SerializeStructVariant> serde::ser::SerializeStructVariant for Finite<S> {
type Ok = S::Ok;
type Error = S::Error;
fn serialize_field<T: ?Sized + Serialize>(&mut self, key: &'static str, v: &T) -> Result<(), S::Error> {
self.0.serialize_field(key, &FiniteValue(v))
}
fn skip_field(&mut self, key: &'static str) -> Result<(), S::Error> {
self.0.skip_field(key)
}
fn end(self) -> Result<S::Ok, S::Error> {
self.0.end()
}
}
/// Serialize the document (without images) to the JSON value stored in `document.json`.
/// NaN and infinite numbers are written as `0`.
pub fn to_json(doc: &Document) -> Result<Value, FormatError> {
serde_json::to_value(DocumentJson::new(doc)).map_err(io_err)
DocumentJson::new(doc).serialize(Finite(serde_json::value::Serializer)).map_err(io_err)
}
/// Write the document (without images) as `document.json` content to `w`.
/// NaN and infinite numbers are written as `0`.
pub fn write_json(doc: &Document, w: impl Write) -> Result<(), FormatError> {
serde_json::to_writer(w, &DocumentJson::new(doc)).map_err(io_err)
DocumentJson::new(doc).serialize(Finite(&mut serde_json::Serializer::new(w))).map_err(io_err)
}
/// Parse `document.json` content.
@@ -840,6 +1019,37 @@ mod tests {
assert!(load_bytes(&json).is_ok());
}
#[test]
fn non_finite_numbers_are_saved_as_zero() {
let mut d = Document::new("NaN");
let p = d.first_page().unwrap();
let r = d.create(p, NodeKind::Rectangle, "R", 1.0, 2.0, 3.0, 4.0).unwrap();
assert!(save_bytes(&d, None).is_ok());
d.get_mut(r).unwrap().width = f64::NAN;
d.get_mut(r).unwrap().opacity = f32::INFINITY;
// Saving never fails; non-finite numbers are written as 0 and the file reopens.
let bytes = save_bytes(&d, None).unwrap();
let (back, _) = load_bytes(&bytes).unwrap();
assert_eq!(back.node_count(), d.node_count());
let n = back.get(r).unwrap();
assert_eq!(n.width, 0.0);
assert_eq!(n.opacity, 0.0);
assert_eq!(n.height, 4.0);
let mut out = Vec::new();
write_json(&d, &mut out).unwrap();
let (back, _) = from_json(std::str::from_utf8(&out).unwrap()).unwrap();
let n = back.get(r).unwrap();
assert_eq!(n.width, 0.0);
assert_eq!(n.opacity, 0.0);
assert_eq!(n.height, 4.0);
let v = to_json(&d).unwrap();
let (back, _) = from_json(&v.to_string()).unwrap();
let n = back.get(r).unwrap();
assert_eq!(n.width, 0.0);
assert_eq!(n.opacity, 0.0);
assert_eq!(n.height, 4.0);
}
#[test]
fn garbage_is_an_error() {
assert!(load_bytes(b"not a zip").is_err());
+11
View File
@@ -365,6 +365,17 @@ fn text_styles_and_runs() {
assert!(!named(&doc, "NoFills").fills.is_empty(), "text keeps its default fill");
}
#[test]
fn max_lines_apply_only_while_truncating() {
let (doc, _) = one(json!([
{"id": "1", "name": "Auto", "type": "TEXT", "relativeTransform": at(0.0, 0.0), "size": {"x": 100, "y": 20}, "characters": "a",
"style": {"textAutoResize": "HEIGHT", "textTruncation": "DISABLED", "maxLines": 2}}
]));
let NodeKind::Text(td) = &named(&doc, "Auto").kind else { panic!() };
assert_eq!((td.auto_resize, td.max_lines), (TextAutoResize::Height, 0));
assert!(!td.truncates());
}
#[test]
fn auto_layout_constraints_and_grids() {
let (doc, _) = one(json!([
+5 -2
View File
@@ -69,10 +69,13 @@ fn paragraph(s: &Value, td: &mut TextData) {
// Missing means a fixed box.
_ => TextAutoResize::None,
};
if text(s, "textTruncation") == Some("ENDING") {
// A line limit applies only while truncation is on; a leftover `maxLines` is ignored.
let ending = text(s, "textTruncation") == Some("ENDING");
td.max_lines = num(s, "maxLines").filter(|n| ending && *n >= 1.0).map_or(0, |n| n.min(10_000.0) as u32);
// Auto height with a line limit keeps hugging its lines up to the limit.
if ending && !td.truncates() {
td.auto_resize = TextAutoResize::Truncate;
}
td.max_lines = num(s, "maxLines").filter(|n| *n >= 1.0).map_or(0, |n| n.min(10_000.0) as u32);
td.paragraph_spacing = len(s, "paragraphSpacing").unwrap_or(0.0);
td.paragraph_indent = len(s, "paragraphIndent").unwrap_or(0.0);
td.list_spacing = len(s, "listSpacing").unwrap_or(0.0).min(10_000.0);
+38
View File
@@ -162,6 +162,44 @@ fn resizing_auto_width_auto_height_fixed_and_truncate() {
assert!(two.truncated && two.lines.len() == 2);
}
#[test]
fn truncation_fills_the_last_line_before_the_ellipsis() {
// #1375: as with CSS `text-overflow: ellipsis`, the last line shown takes as much of the word
// wrapped after it as fits before the ellipsis ("Lorem consec…", not "Lorem…").
if face().is_none() {
return;
}
let s = "Lorem consectetur";
let full = layout(&text(s, TextAutoResize::WidthAndHeight), None, None);
let lorem = layout(&text("Lorem", TextAutoResize::WidthAndHeight), None, None).width;
// Room for "Lorem" and most of the next word, not all of it.
let w = lorem + (full.width - lorem) * 0.75;
let line_h = full.lines[0].height;
let mut max_one = text(s, TextAutoResize::Truncate);
max_one.max_lines = 1;
let mut hug_one = text(s, TextAutoResize::Height);
hug_one.max_lines = 1;
for (l, why) in [
(layout(&text(s, TextAutoResize::Truncate), Some(w), Some(line_h + 1.0)), "cut by the box height"),
(layout(&max_one, Some(w), Some(1000.0)), "cut by max lines"),
(layout(&hug_one, Some(w), None), "auto height with max lines"),
] {
assert!(l.truncated && l.lines.len() == 1, "{why}");
let last = l.glyphs.last().unwrap();
assert_eq!(last.char_start, last.char_end, "{why}: the ellipsis ends the line");
assert!(l.glyphs.iter().any(|g| g.char_start == 6), "{why}: the wrapped word starts on the line");
assert!(l.glyphs.iter().all(|g| g.x + g.advance <= w + EPS), "{why}: within the box");
assert!((l.height - line_h).abs() < EPS, "{why}: one line tall");
let g7 = l.glyphs.iter().find(|g| g.char_start == 7).unwrap();
assert_eq!(l.caret(7), (g7.x, 0), "{why}: a caret reaches the chars taken from the next line");
}
// A line that ends its paragraph keeps its words; the ellipsis follows them.
let mut para = text("Lorem\nconsectetur", TextAutoResize::Truncate);
para.max_lines = 1;
let l = layout(&para, Some(w), Some(1000.0));
assert!(l.truncated && l.glyphs.iter().all(|g| g.char_start < 5 || g.char_start == g.char_end));
}
#[test]
fn vertical_trim_cap_height_to_baseline() {
let Some(f) = face() else { return };
+54 -18
View File
@@ -532,7 +532,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
let mut out = TextLayout { styles: styles.clone(), ..Default::default() };
let mut y = 0.0;
let mut para_start_line = true;
let max_lines = if text.max_lines > 0 && matches!(text.auto_resize, TextAutoResize::Truncate) { text.max_lines as usize } else { usize::MAX };
let max_lines = if text.max_lines > 0 && text.truncates() { text.max_lines as usize } else { usize::MAX };
// A style's vertical metrics (ascent, descent, line height, cap height) come from its own
// font, never a fallback face, so lines mixing scripts keep the spacing of lines that don't.
let metrics = |st_idx: usize| -> [f64; 4] {
@@ -546,6 +546,25 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
None => [size * 0.8, size * 0.2, whole_px(st.line_height.resolve(size, 1.2)), size * 0.7],
}
};
// An item's glyph with its pen position at `pen` on the line at `baseline` (none for a space
// the font has no glyph for).
let glyph_at = |it: &Item, pen: f64, baseline: f64| {
let size = styles.get(it.style).map(|st| sanitize_size(st.font_size)).unwrap_or(12.0) * it.scale;
(it.gid != 0 || !it.is_space()).then(|| Glyph {
face: it.face.clone(),
coords: it.coords.clone(),
gid: it.gid,
x: pen + it.dx,
y: baseline - it.dy,
size,
advance: it.adv,
char_start: it.char_start,
char_end: it.char_end,
style: it.style,
rtl: it.level % 2 == 1,
warp: Affine::IDENTITY,
})
};
let mut first_cap = 0.0;
for (li, (s, e, para_end, para)) in lines.iter().enumerate() {
if li >= max_lines {
@@ -685,23 +704,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
if is_line_break(it.ch) {
continue;
}
let size = styles.get(it.style).map(|st| sanitize_size(st.font_size)).unwrap_or(12.0) * it.scale;
if it.gid != 0 || !it.is_space() {
out.glyphs.push(Glyph {
face: it.face.clone(),
coords: it.coords.clone(),
gid: it.gid,
x: pen + it.dx,
y: baseline - it.dy,
size,
advance: it.adv,
char_start: it.char_start,
char_end: it.char_end,
style: it.style,
rtl: it.level % 2 == 1,
warp: Affine::IDENTITY,
});
}
out.glyphs.extend(glyph_at(it, pen, baseline));
pen += it.adv;
if it.stretches() {
pen += justify_extra;
@@ -757,7 +760,40 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
}
}
if out.truncated {
// The last line shown runs on into the line cut after it, so the ellipsis fills the line
// ("Lorem consec…") instead of following the last whole word ("Lorem…").
let last = out.lines.len().checked_sub(1);
let wrapped_on = last.and_then(|li| Some((out.lines.get(li)?, lines.get(li)?, lines.get(li + 1)?)));
if let (Some(maxw), Some((line, &(_, e, para_end, _), &(ns, ne, _, _)))) = (wrap, wrapped_on)
&& !para_end
&& !break_after(e)
&& !line.rtl
{
let baseline = line.baseline;
let mut pen = out.glyphs.get(line.glyph_start..line.glyph_end).and_then(<[Glyph]>::last).map_or(line.width, |g| g.x + g.advance);
for it in items.get(ns..ne).unwrap_or(&[]) {
if pen > maxw {
break;
}
if !is_line_break(it.ch) {
out.glyphs.extend(glyph_at(it, pen, baseline));
pen += it.adv;
}
}
let gend = out.glyphs.len();
if let Some(l) = out.lines.last_mut() {
l.glyph_end = gend;
}
}
add_ellipsis(&mut out, wrap);
// Carets and selections reach every char the line shows, borrowed ones included.
if let Some(l) = out.lines.last() {
let shown =
out.glyphs.get(l.glyph_start..l.glyph_end).unwrap_or(&[]).iter().filter(|g| g.char_end > g.char_start).map(|g| g.char_end).max();
if let (Some(shown), Some(l)) = (shown, out.lines.last_mut()) {
l.char_end = l.char_end.max(shown);
}
}
}
// Horizontal alignment within the box.
+3 -1
View File
@@ -232,7 +232,9 @@ fn truncate_max_lines_with_ellipsis() {
assert_eq!(l.lines.len(), 2);
let last = l.glyphs.last().unwrap();
assert_eq!(last.gid, face.glyph_for('…'));
// max_lines is ignored unless truncating.
// Auto height truncates at max_lines too; a fixed box shows every line.
t.auto_resize = figcraft_doc::TextAutoResize::Height;
assert_eq!(layout(&t, Some(300.0), None).lines.len(), 2);
t.auto_resize = figcraft_doc::TextAutoResize::None;
assert_eq!(layout(&t, Some(300.0), Some(1000.0)).lines.len(), 3);
}
+13 -6
View File
@@ -338,13 +338,20 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) {
PropDef::InstanceSwap { default, .. } => {
let label = doc.node(*default).map(|n| n.name.clone()).unwrap_or_else(|| tr("Missing").into());
let resp = widgets::dropdown(ui, &label, 84.0, None);
menu_popup(&resp).show(|ui| {
for (c, name) in comps.iter() {
if menu_item(ui, name, "") {
app.run("component.editProperty", json!({"id": owner, "name": k, "default": c.0}));
ui.close();
crate::a11y::combo(&resp, k, &label, crate::a11y::menu_open(&resp));
// A file with many components (an icon library) scrolls instead of running off the screen.
menu_popup(&resp).width(200.0).show(|ui| {
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
for (c, name) in comps.iter() {
let current = c == default;
if menu_item(ui, &format!("{}{name}", if current { "✓ " } else { "" }), "") {
if !current {
app.run("component.editProperty", json!({"id": owner, "name": k, "default": c.0}));
}
ui.close();
}
}
}
});
});
}
PropDef::Variant { .. } | PropDef::Slot { .. } => {}
@@ -0,0 +1,118 @@
//! The default of a main component's instance-swap property, driven through egui headless: with
//! more components than fit on the screen (an icon library), its menu scrolls, so the last one can
//! be picked (#1338).
use egui::accesskit::{Node, Role};
use egui::{Event, MouseWheelUnit, Pos2, TouchPhase, pos2, vec2};
use figcraft_doc::{NodeId, NodeKind, PropDef};
use figcraft_engine::Session;
use serde_json::json;
use crate::layers_drag_tests::Ui;
fn id(v: serde_json::Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
/// A selected "Button" component whose instance-swap property "Icon" defaults to "Heart", then 80
/// more components, the last "Zebra". Returns (ui, button, zebra).
fn setup() -> (Ui, NodeId, NodeId) {
let mut s = Session::new();
let mut icon = |name: &str, k: usize| {
let f =
id(s.execute("frame.create", &json!({"x": (k % 20) * 40, "y": 200 + (k / 20) * 40, "width": 24, "height": 24, "name": name})).unwrap());
id(s.execute("component.create", &json!({"ids": [f]})).unwrap())
};
let heart = icon("Heart", 0);
let mut zebra = heart;
for k in 1..=80 {
zebra = icon(&if k == 80 { "Zebra".to_string() } else { format!("Glyph {k}") }, k);
}
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 120, "height": 40, "name": "Button"})).unwrap());
let nested = id(s.execute("instance.create", &json!({"component": heart, "x": 8, "y": 8, "parent": f})).unwrap());
let button = id(s.execute("component.create", &json!({"ids": [f]})).unwrap());
s.execute("component.addProperty", &json!({"id": button, "name": "Icon", "type": "instanceSwap", "default": heart, "layer": nested})).unwrap();
s.execute("select.set", &json!({"ids": [button]})).unwrap();
let u = Ui::new(s);
u.ctx.enable_accesskit();
(u, button, zebra)
}
fn tree(u: &mut Ui) -> Vec<Node> {
u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes.into_iter().map(|(_, n)| n).collect()
}
fn centre(n: &Node) -> Pos2 {
let b = n.bounds().unwrap();
pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32)
}
fn click(u: &mut Ui, at: Pos2) {
u.button(at, true);
u.button(at, false);
}
/// Turn the mouse wheel down by `dy` points over `at`, and let the scroll settle.
fn wheel(u: &mut Ui, at: Pos2, dy: f32) {
let wheel = Event::MouseWheel {
unit: MouseWheelUnit::Point,
source: egui::MouseWheelSource::Unknown,
delta: vec2(0.0, -dy),
phase: TouchPhase::Move,
modifiers: Default::default(),
};
u.frame(vec![Event::PointerMoved(at), wheel]);
for _ in 0..30 {
u.frame(vec![]);
}
}
fn swap_default(u: &Ui, button: NodeId) -> Option<NodeId> {
match u.app.session.ds().ok()?.doc.node(button).map(|n| &n.kind) {
Some(NodeKind::Component { props, .. }) => match props.get("Icon") {
Some(PropDef::InstanceSwap { default, .. }) => Some(*default),
_ => None,
},
_ => None,
}
}
/// The default's dropdown on the "Icon" property row, scrolled into view in the Design panel.
fn default_combo(u: &mut Ui) -> Option<Node> {
for _ in 0..20 {
let combo = tree(u).into_iter().find(|n| n.role() == Role::ComboBox && n.label() == Some("Icon"))?;
if centre(&combo).y < 860.0 {
return Some(combo);
}
wheel(u, pos2(1300.0, 500.0), 100.0);
}
None
}
#[test]
fn the_default_menu_scrolls_to_its_last_component() {
let (mut u, button, zebra) = setup();
let combo = default_combo(&mut u).expect("the Icon property has a dropdown for its default");
assert_eq!(combo.value(), Some("Heart"));
// Layer rows share the names; the menu's items are the ones that weren't there before it opened.
let before: Vec<Pos2> = tree(&mut u).iter().filter(|n| n.label().is_some_and(|l| l == "Zebra" || l == "Glyph 1")).map(centre).collect();
click(&mut u, centre(&combo));
let item = |u: &mut Ui, name: &str| tree(u).into_iter().find(|n| n.label() == Some(name) && !before.contains(&centre(n)));
let top = centre(&item(&mut u, "Glyph 1").expect("the menu lists the components"));
let bottom = centre(&item(&mut u, "Zebra").expect("the menu lists the last component"));
assert!(bottom.y > 900.0, "80 components don't fit on the screen: Zebra at {bottom:?}");
// The menu is capped and scrolls: wheel over it to the end of the list, and pick Zebra.
let mut at = bottom;
for _ in 0..40 {
wheel(&mut u, top, 200.0);
let next = centre(&item(&mut u, "Zebra").expect("the menu stays open while it scrolls"));
if next == at {
break;
}
at = next;
}
assert!(at.y < 880.0, "the menu scrolls Zebra onto the screen: at {at:?}");
assert!(at.y - top.y < 400.0, "Zebra ends up inside the capped menu, near its top row: {top:?} → {at:?}");
click(&mut u, at);
assert_eq!(swap_default(&u, button), Some(zebra), "picking the last component makes it the default");
}
+2
View File
@@ -106,6 +106,8 @@ mod comments_tests;
#[cfg(test)]
mod component_props_reorder_tests;
#[cfg(test)]
mod component_swap_default_tests;
#[cfg(test)]
mod context_menu_tests;
#[cfg(test)]
mod corner_individual_tests;
+16 -6
View File
@@ -527,17 +527,27 @@ fn type_settings(
app.set_props(ids, json!({"leadingTrim": if i == 1 { "capHeight" } else { "none" }}));
}
let resize = common(doc, ids, |n| n.kind.text().map(|t| t.auto_resize)).flatten();
// Auto-height text with a line limit truncates too.
let truncating = common(doc, ids, |n| n.kind.text().map(TextData::truncates)).flatten();
caption(ui, tr("Truncate text"));
let active = resize.map(|r| usize::from(r == TextAutoResize::Truncate));
match text_seg(ui, "trunc", &[(tr("Off"), tr_noop("Don't truncate")), ("…", tr_noop("Truncate with an ellipsis"))], active, w) {
Some(1) => app.set_props(ids, json!({"textAutoResize": "truncate"})),
Some(_) if resize != Some(TextAutoResize::Height) && resize != Some(TextAutoResize::WidthAndHeight) => {
app.set_props(ids, json!({"textAutoResize": "none"}))
match text_seg(
ui,
"trunc",
&[(tr("Off"), tr_noop("Don't truncate")), ("…", tr_noop("Truncate with an ellipsis"))],
truncating.map(usize::from),
w,
) {
Some(1) if truncating != Some(true) => app.set_props(ids, json!({"textAutoResize": "truncate"})),
Some(0) if resize == Some(TextAutoResize::Height) => {
if truncating == Some(true) {
app.set_props(ids, json!({"maxLines": 0}));
}
}
Some(0) if resize != Some(TextAutoResize::WidthAndHeight) => app.set_props(ids, json!({"textAutoResize": "none"})),
_ => {}
}
// Max lines only matter while truncating; no limit shows "Auto".
if resize == Some(TextAutoResize::Truncate) {
if truncating == Some(true) {
let max = common(doc, ids, |n| n.kind.text().map(|t| t.max_lines)).flatten();
ui.add_space(4.0);
let shown = max.map(f64::from).filter(|m| *m > 0.0);
+3 -2
View File
@@ -1,6 +1,6 @@
# Components, variants, styles and variables parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (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-10-26 · **Change:** minor (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
@@ -21,7 +21,7 @@ accounts).
| Switching variants keeps overrides where names match and both variants had the same value; text layers keep theirs | done | |
| Default variant: the top-left one (new instances, Assets thumbnail, generated code) | done | |
| Variant property order (names rewritten), duplicate combinations reported | done | drag a property in the Properties section to reorder it, one undo step (#679), or `component.editProperty {index}` |
| Properties: boolean, text, instance swap (preferred values), variant | done | |
| Properties: boolean, text, instance swap (preferred values), variant | done | every component menu scrolls past 320 pt, the default of an instance-swap property's too (#1338); no search in them yet |
| Slots (preferred components, fill menu, kept across updates) | done | Figma 2025 feature |
| Exposed nested instances | done | hidden ones hide their properties |
| Descriptions | done | |
@@ -77,6 +77,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Date | Change | Summary |
|---|---|---|
| 2026-10-26 | minor | An instance-swap property's default menu scrolls (#1338): on the main component's Properties row the menu listed every component in the file at full height, so in a file with many (an icon library) the bottom ones ran off the screen and couldn't be picked. It now scrolls past 320 pt like the instance's swap menu, is at least 200 pt wide, ticks the current default and is named for assistive technology (`component_swap_default_tests`). Left: searching these menus. Numbers unchanged. |
| 2026-10-24 | minor | Variables on a text style's fields from the style editor: the font and font style menus start with Apply variable (string variables), and the size, line height and letter spacing fields show a variable button on hover (number variables). A bound field shows its variable in place of the value; a click on it picks another variable and its detach button unbinds it (`style_picker_tests`). Left: the other effect and grid fields (commands only), library variables in the picker. Numbers unchanged. |
| 2026-10-23 | minor | Instance-swap properties in the component playground: the Inspect tab's Playground shows a dropdown for each, with the same menu as the Design panel's (the property's preferred components first, then every component in the file). The component picked changes the preview and the code only, never the file. Left: slots in the playground. Numbers unchanged. |
| 2026-10-20 | minor | A main component's copy within its file is an instance of it (#892): Duplicate (`edit.duplicate`) and Alt-drag on the canvas made a second main; they now place an instance where the copy went (same offset, the instance's default name, selected), as Copy then Paste already did. A variant duplicated in its set stays a new variant. |
+2 -1
View File
@@ -1,6 +1,6 @@
# FigCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Android Vector Drawable export; M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Android Vector Drawable export; text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
@@ -75,6 +75,7 @@ blockers.
| Date | Change | Summary |
|---|---|---|
| 2026-10-26 | minor | Text truncation (M4, #1375, #1376, #1377): the last line shown takes as much of the next word as fits before the ellipsis ("Lorem consec…", not "Lorem…"); an explicit height, `transform.resize`, a resize handle or fixed sizing on a truncated text keeps it truncating instead of turning it fixed size; Max lines on auto-height text hugs up to that many lines and ends in an ellipsis (`text.maxLines`, `node.set {maxLines}`, Type settings, `.fig` export and import), and `node.get` / `get_node` report `maxLines`. Regression tests in the text and engine behaviour suites. Numbers unchanged |
| 2026-10-24 | minor | Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged |
| 2026-10-24 | minor | M12: a shared board timer and voting sessions; 30–60 h → 26–54 h |
| 2026-10-24 | minor | M11: plugin widgets take text input (editable fields, `widget.setText`) and a property menu (`widget.menu`), plugin API 4; 8–26 h → 6–22 h |
File diff suppressed because one or more lines are too long