From 503b6d606d9825e5c7bfa5ec937d35de7e4cb0dd Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Wed, 21 Oct 2026 01:27:12 +0200 Subject: [PATCH 1/7] Refuse to save documents containing NaN or infinite numbers Found while testing crates/format/src/lib.rs:256 (to_json/write_json/save_bytes) and :323 (parse_json) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/format/src/lib.rs | 186 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 9f7e51bc..441b6861 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -252,13 +252,184 @@ fn image_index(images: &&BTreeMap, s: S) -> Re s.collect_seq(images.iter().map(|(hash, i)| Entry { hash, width: i.width, height: i.height })) } +/// A serializer that writes nothing and fails on the first NaN or infinite float. JSON has no +/// such numbers (`serde_json` writes `null`, which the reader rejects), so a document holding +/// one is refused instead of saved as an unreadable file. +struct FiniteCheck; + +impl serde::ser::Error for FiniteCheckError { + fn custom(msg: T) -> Self { + Self(msg.to_string()) + } +} + +#[derive(Debug)] +struct FiniteCheckError(String); + +impl std::fmt::Display for FiniteCheckError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for FiniteCheckError {} + +macro_rules! check_skip { + ($($name:ident($ty:ty)),* $(,)?) => { + $(fn $name(self, _: $ty) -> Result<(), FiniteCheckError> { Ok(()) })* + }; +} + +impl Serializer for FiniteCheck { + type Ok = (); + type Error = FiniteCheckError; + type SerializeSeq = FiniteCheck; + type SerializeTuple = FiniteCheck; + type SerializeTupleStruct = FiniteCheck; + type SerializeTupleVariant = FiniteCheck; + type SerializeMap = FiniteCheck; + type SerializeStruct = FiniteCheck; + type SerializeStructVariant = FiniteCheck; + + check_skip!( + 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<(), FiniteCheckError> { + if v.is_finite() { Ok(()) } else { Err(FiniteCheckError(format!("cannot save a non-finite number ({v})"))) } + } + fn serialize_f64(self, v: f64) -> Result<(), FiniteCheckError> { + if v.is_finite() { Ok(()) } else { Err(FiniteCheckError(format!("cannot save a non-finite number ({v})"))) } + } + fn serialize_none(self) -> Result<(), FiniteCheckError> { + Ok(()) + } + fn serialize_some(self, v: &T) -> Result<(), FiniteCheckError> { + v.serialize(self) + } + fn serialize_unit(self) -> Result<(), FiniteCheckError> { + Ok(()) + } + fn serialize_unit_struct(self, _: &'static str) -> Result<(), FiniteCheckError> { + Ok(()) + } + fn serialize_unit_variant(self, _: &'static str, _: u32, _: &'static str) -> Result<(), FiniteCheckError> { + Ok(()) + } + fn serialize_newtype_struct(self, _: &'static str, v: &T) -> Result<(), FiniteCheckError> { + v.serialize(self) + } + fn serialize_newtype_variant(self, _: &'static str, _: u32, _: &'static str, v: &T) -> Result<(), FiniteCheckError> { + v.serialize(self) + } + fn serialize_seq(self, _: Option) -> Result { + Ok(FiniteCheck) + } + fn serialize_tuple(self, _: usize) -> Result { + Ok(FiniteCheck) + } + fn serialize_tuple_struct(self, _: &'static str, _: usize) -> Result { + Ok(FiniteCheck) + } + fn serialize_tuple_variant(self, _: &'static str, _: u32, _: &'static str, _: usize) -> Result { + Ok(FiniteCheck) + } + fn serialize_map(self, _: Option) -> Result { + Ok(FiniteCheck) + } + fn serialize_struct(self, _: &'static str, _: usize) -> Result { + Ok(FiniteCheck) + } + fn serialize_struct_variant(self, _: &'static str, _: u32, _: &'static str, _: usize) -> Result { + Ok(FiniteCheck) + } +} + +macro_rules! check_elements { + ($($trait:ident::$method:ident),* $(,)?) => { + $(impl serde::ser::$trait for FiniteCheck { + type Ok = (); + type Error = FiniteCheckError; + fn $method(&mut self, v: &T) -> Result<(), FiniteCheckError> { + v.serialize(FiniteCheck) + } + fn end(self) -> Result<(), FiniteCheckError> { + Ok(()) + } + })* + }; +} + +check_elements!( + SerializeSeq::serialize_element, + SerializeTuple::serialize_element, + SerializeTupleStruct::serialize_field, + SerializeTupleVariant::serialize_field +); + +impl serde::ser::SerializeMap for FiniteCheck { + type Ok = (); + type Error = FiniteCheckError; + fn serialize_key(&mut self, k: &T) -> Result<(), FiniteCheckError> { + k.serialize(FiniteCheck) + } + fn serialize_value(&mut self, v: &T) -> Result<(), FiniteCheckError> { + v.serialize(FiniteCheck) + } + fn end(self) -> Result<(), FiniteCheckError> { + Ok(()) + } +} + +impl serde::ser::SerializeStruct for FiniteCheck { + type Ok = (); + type Error = FiniteCheckError; + fn serialize_field(&mut self, _: &'static str, v: &T) -> Result<(), FiniteCheckError> { + v.serialize(FiniteCheck) + } + fn end(self) -> Result<(), FiniteCheckError> { + Ok(()) + } +} + +impl serde::ser::SerializeStructVariant for FiniteCheck { + type Ok = (); + type Error = FiniteCheckError; + fn serialize_field(&mut self, _: &'static str, v: &T) -> Result<(), FiniteCheckError> { + v.serialize(FiniteCheck) + } + fn end(self) -> Result<(), FiniteCheckError> { + Ok(()) + } +} + +/// Fail when the document holds a number JSON cannot represent. +fn ensure_finite(doc: &Document) -> Result<(), FormatError> { + DocumentJson::new(doc).serialize(FiniteCheck).map_err(io_err) +} + /// Serialize the document (without images) to the JSON value stored in `document.json`. pub fn to_json(doc: &Document) -> Result { + ensure_finite(doc)?; serde_json::to_value(DocumentJson::new(doc)).map_err(io_err) } /// Write the document (without images) as `document.json` content to `w`. pub fn write_json(doc: &Document, w: impl Write) -> Result<(), FormatError> { + ensure_finite(doc)?; serde_json::to_writer(w, &DocumentJson::new(doc)).map_err(io_err) } @@ -808,6 +979,21 @@ mod tests { assert!(load_bytes(&json).is_ok()); } + #[test] + fn non_finite_numbers_are_refused_at_save() { + 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; + let e = save_bytes(&d, None).unwrap_err(); + assert!(e.to_string().contains("non-finite"), "{e}"); + d.get_mut(r).unwrap().width = 3.0; + d.get_mut(r).unwrap().opacity = f32::INFINITY; + let e = save_bytes(&d, None).unwrap_err(); + assert!(e.to_string().contains("non-finite"), "{e}"); + } + #[test] fn garbage_is_an_error() { assert!(load_bytes(b"not a zip").is_err()); From 8c08035b8942ab7dfb2890a8f8bf4012ae2343f5 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 05:08:57 +0200 Subject: [PATCH 2/7] fix: write non-finite numbers as 0 instead of refusing to save Co-Authored-By: Claude Sonnet 5.5 --- crates/format/src/lib.rs | 242 +++++++++++++++++++++------------------ 1 file changed, 133 insertions(+), 109 deletions(-) diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 441b6861..ca32eaaa 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -252,46 +252,38 @@ fn image_index(images: &&BTreeMap, s: S) -> Re s.collect_seq(images.iter().map(|(hash, i)| Entry { hash, width: i.width, height: i.height })) } -/// A serializer that writes nothing and fails on the first NaN or infinite float. JSON has no -/// such numbers (`serde_json` writes `null`, which the reader rejects), so a document holding -/// one is refused instead of saved as an unreadable file. -struct FiniteCheck; +/// 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); -impl serde::ser::Error for FiniteCheckError { - fn custom(msg: T) -> Self { - Self(msg.to_string()) +/// Serializes a value through a [`Finite`] adapter around the serializer it is written with. +struct FiniteValue<'a, T: ?Sized>(&'a T); + +impl Serialize for FiniteValue<'_, T> { + fn serialize(&self, s: S) -> Result { + self.0.serialize(Finite(s)) } } -#[derive(Debug)] -struct FiniteCheckError(String); - -impl std::fmt::Display for FiniteCheckError { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - f.write_str(&self.0) - } -} - -impl std::error::Error for FiniteCheckError {} - -macro_rules! check_skip { +macro_rules! finite_pass { ($($name:ident($ty:ty)),* $(,)?) => { - $(fn $name(self, _: $ty) -> Result<(), FiniteCheckError> { Ok(()) })* + $(fn $name(self, v: $ty) -> Result { self.0.$name(v) })* }; } -impl Serializer for FiniteCheck { - type Ok = (); - type Error = FiniteCheckError; - type SerializeSeq = FiniteCheck; - type SerializeTuple = FiniteCheck; - type SerializeTupleStruct = FiniteCheck; - type SerializeTupleVariant = FiniteCheck; - type SerializeMap = FiniteCheck; - type SerializeStruct = FiniteCheck; - type SerializeStructVariant = FiniteCheck; +impl Serializer for Finite { + type Ok = S::Ok; + type Error = S::Error; + type SerializeSeq = Finite; + type SerializeTuple = Finite; + type SerializeTupleStruct = Finite; + type SerializeTupleVariant = Finite; + type SerializeMap = Finite; + type SerializeStruct = Finite; + type SerializeStructVariant = Finite; - check_skip!( + finite_pass!( serialize_bool(bool), serialize_i8(i8), serialize_i16(i16), @@ -308,129 +300,145 @@ impl Serializer for FiniteCheck { serialize_bytes(&[u8]) ); - fn serialize_f32(self, v: f32) -> Result<(), FiniteCheckError> { - if v.is_finite() { Ok(()) } else { Err(FiniteCheckError(format!("cannot save a non-finite number ({v})"))) } + fn serialize_f32(self, v: f32) -> Result { + self.0.serialize_f32(if v.is_finite() { v } else { 0.0 }) } - fn serialize_f64(self, v: f64) -> Result<(), FiniteCheckError> { - if v.is_finite() { Ok(()) } else { Err(FiniteCheckError(format!("cannot save a non-finite number ({v})"))) } + fn serialize_f64(self, v: f64) -> Result { + self.0.serialize_f64(if v.is_finite() { v } else { 0.0 }) } - fn serialize_none(self) -> Result<(), FiniteCheckError> { - Ok(()) + fn serialize_none(self) -> Result { + self.0.serialize_none() } - fn serialize_some(self, v: &T) -> Result<(), FiniteCheckError> { - v.serialize(self) + fn serialize_some(self, v: &T) -> Result { + self.0.serialize_some(&FiniteValue(v)) } - fn serialize_unit(self) -> Result<(), FiniteCheckError> { - Ok(()) + fn serialize_unit(self) -> Result { + self.0.serialize_unit() } - fn serialize_unit_struct(self, _: &'static str) -> Result<(), FiniteCheckError> { - Ok(()) + fn serialize_unit_struct(self, name: &'static str) -> Result { + self.0.serialize_unit_struct(name) } - fn serialize_unit_variant(self, _: &'static str, _: u32, _: &'static str) -> Result<(), FiniteCheckError> { - Ok(()) + fn serialize_unit_variant(self, name: &'static str, idx: u32, variant: &'static str) -> Result { + self.0.serialize_unit_variant(name, idx, variant) } - fn serialize_newtype_struct(self, _: &'static str, v: &T) -> Result<(), FiniteCheckError> { - v.serialize(self) + fn serialize_newtype_struct(self, name: &'static str, v: &T) -> Result { + self.0.serialize_newtype_struct(name, &FiniteValue(v)) } - fn serialize_newtype_variant(self, _: &'static str, _: u32, _: &'static str, v: &T) -> Result<(), FiniteCheckError> { - v.serialize(self) + fn serialize_newtype_variant(self, name: &'static str, idx: u32, variant: &'static str, v: &T) -> Result { + self.0.serialize_newtype_variant(name, idx, variant, &FiniteValue(v)) } - fn serialize_seq(self, _: Option) -> Result { - Ok(FiniteCheck) + fn serialize_seq(self, len: Option) -> Result { + self.0.serialize_seq(len).map(Finite) } - fn serialize_tuple(self, _: usize) -> Result { - Ok(FiniteCheck) + fn serialize_tuple(self, len: usize) -> Result { + self.0.serialize_tuple(len).map(Finite) } - fn serialize_tuple_struct(self, _: &'static str, _: usize) -> Result { - Ok(FiniteCheck) + fn serialize_tuple_struct(self, name: &'static str, len: usize) -> Result { + self.0.serialize_tuple_struct(name, len).map(Finite) } - fn serialize_tuple_variant(self, _: &'static str, _: u32, _: &'static str, _: usize) -> Result { - Ok(FiniteCheck) + fn serialize_tuple_variant( + self, + name: &'static str, + idx: u32, + variant: &'static str, + len: usize, + ) -> Result { + self.0.serialize_tuple_variant(name, idx, variant, len).map(Finite) } - fn serialize_map(self, _: Option) -> Result { - Ok(FiniteCheck) + fn serialize_map(self, len: Option) -> Result { + self.0.serialize_map(len).map(Finite) } - fn serialize_struct(self, _: &'static str, _: usize) -> Result { - Ok(FiniteCheck) + fn serialize_struct(self, name: &'static str, len: usize) -> Result { + self.0.serialize_struct(name, len).map(Finite) } - fn serialize_struct_variant(self, _: &'static str, _: u32, _: &'static str, _: usize) -> Result { - Ok(FiniteCheck) + fn serialize_struct_variant( + self, + name: &'static str, + idx: u32, + variant: &'static str, + len: usize, + ) -> Result { + self.0.serialize_struct_variant(name, idx, variant, len).map(Finite) + } + fn is_human_readable(&self) -> bool { + self.0.is_human_readable() } } -macro_rules! check_elements { +macro_rules! finite_elements { ($($trait:ident::$method:ident),* $(,)?) => { - $(impl serde::ser::$trait for FiniteCheck { - type Ok = (); - type Error = FiniteCheckError; - fn $method(&mut self, v: &T) -> Result<(), FiniteCheckError> { - v.serialize(FiniteCheck) + $(impl serde::ser::$trait for Finite { + type Ok = S::Ok; + type Error = S::Error; + fn $method(&mut self, v: &T) -> Result<(), S::Error> { + self.0.$method(&FiniteValue(v)) } - fn end(self) -> Result<(), FiniteCheckError> { - Ok(()) + fn end(self) -> Result { + self.0.end() } })* }; } -check_elements!( +finite_elements!( SerializeSeq::serialize_element, SerializeTuple::serialize_element, SerializeTupleStruct::serialize_field, SerializeTupleVariant::serialize_field ); -impl serde::ser::SerializeMap for FiniteCheck { - type Ok = (); - type Error = FiniteCheckError; - fn serialize_key(&mut self, k: &T) -> Result<(), FiniteCheckError> { - k.serialize(FiniteCheck) +impl serde::ser::SerializeMap for Finite { + type Ok = S::Ok; + type Error = S::Error; + fn serialize_key(&mut self, k: &T) -> Result<(), S::Error> { + self.0.serialize_key(&FiniteValue(k)) } - fn serialize_value(&mut self, v: &T) -> Result<(), FiniteCheckError> { - v.serialize(FiniteCheck) + fn serialize_value(&mut self, v: &T) -> Result<(), S::Error> { + self.0.serialize_value(&FiniteValue(v)) } - fn end(self) -> Result<(), FiniteCheckError> { - Ok(()) + fn end(self) -> Result { + self.0.end() } } -impl serde::ser::SerializeStruct for FiniteCheck { - type Ok = (); - type Error = FiniteCheckError; - fn serialize_field(&mut self, _: &'static str, v: &T) -> Result<(), FiniteCheckError> { - v.serialize(FiniteCheck) +impl serde::ser::SerializeStruct for Finite { + type Ok = S::Ok; + type Error = S::Error; + fn serialize_field(&mut self, key: &'static str, v: &T) -> Result<(), S::Error> { + self.0.serialize_field(key, &FiniteValue(v)) } - fn end(self) -> Result<(), FiniteCheckError> { - Ok(()) + fn skip_field(&mut self, key: &'static str) -> Result<(), S::Error> { + self.0.skip_field(key) + } + fn end(self) -> Result { + self.0.end() } } -impl serde::ser::SerializeStructVariant for FiniteCheck { - type Ok = (); - type Error = FiniteCheckError; - fn serialize_field(&mut self, _: &'static str, v: &T) -> Result<(), FiniteCheckError> { - v.serialize(FiniteCheck) +impl serde::ser::SerializeStructVariant for Finite { + type Ok = S::Ok; + type Error = S::Error; + fn serialize_field(&mut self, key: &'static str, v: &T) -> Result<(), S::Error> { + self.0.serialize_field(key, &FiniteValue(v)) } - fn end(self) -> Result<(), FiniteCheckError> { - Ok(()) + fn skip_field(&mut self, key: &'static str) -> Result<(), S::Error> { + self.0.skip_field(key) + } + fn end(self) -> Result { + self.0.end() } -} - -/// Fail when the document holds a number JSON cannot represent. -fn ensure_finite(doc: &Document) -> Result<(), FormatError> { - DocumentJson::new(doc).serialize(FiniteCheck).map_err(io_err) } /// 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 { - ensure_finite(doc)?; - 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> { - ensure_finite(doc)?; - 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. @@ -986,12 +994,28 @@ mod tests { 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; - let e = save_bytes(&d, None).unwrap_err(); - assert!(e.to_string().contains("non-finite"), "{e}"); - d.get_mut(r).unwrap().width = 3.0; d.get_mut(r).unwrap().opacity = f32::INFINITY; - let e = save_bytes(&d, None).unwrap_err(); - assert!(e.to_string().contains("non-finite"), "{e}"); + // 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] From acdc71453794424a213d359782cc2a2d270dfe46 Mon Sep 17 00:00:00 2001 From: Ridzzz0Alam Date: Mon, 26 Oct 2026 10:26:27 +0100 Subject: [PATCH 3/7] M3: an instance-swap property's default menu scrolls (#1338) On a main component's Properties row, the default of an instance-swap property opened a menu of every component in the file at full height, so in a file with many components (an icon library) the bottom ones ran off the screen and couldn't be picked. The menu 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. --- crates/ui-egui/src/component_panel.rs | 19 ++- .../src/component_swap_default_tests.rs | 118 ++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 + docs/components-parity.md | 5 +- 4 files changed, 136 insertions(+), 8 deletions(-) create mode 100644 crates/ui-egui/src/component_swap_default_tests.rs diff --git a/crates/ui-egui/src/component_panel.rs b/crates/ui-egui/src/component_panel.rs index 06a0385c..54bc0974 100644 --- a/crates/ui-egui/src/component_panel.rs +++ b/crates/ui-egui/src/component_panel.rs @@ -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 { .. } => {} diff --git a/crates/ui-egui/src/component_swap_default_tests.rs b/crates/ui-egui/src/component_swap_default_tests.rs new file mode 100644 index 00000000..50b06b8b --- /dev/null +++ b/crates/ui-egui/src/component_swap_default_tests.rs @@ -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 { + 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 { + 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 { + 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 = 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(¢re(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"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c11bf985..e5bc32a0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/docs/components-parity.md b/docs/components-parity.md index 9489d440..7b92b061 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -1,6 +1,6 @@ # Components, variants, styles and variables parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-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. | From b6d3e002df3fd9f125c44637bee1a32ce04d04c2 Mon Sep 17 00:00:00 2001 From: Muamer Mustafic Date: Mon, 26 Oct 2026 13:14:51 +0100 Subject: [PATCH 4/7] M4: truncated text fills its last line, keeps truncating when resized, and auto height takes max lines (#1375, #1376, #1377) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The last line shown runs on into the word wrapped after it, so the ellipsis fills the line ("Lorem consec…") instead of following the last whole word. - An explicit height (node.set, transform.resize, a resize handle, fixed sizing) on truncating text keeps it truncating (TextData::fix_size). - Max lines on auto-height text hugs up to that many lines and ends in an ellipsis; text.maxLines keeps auto height, Type settings show it as truncating, .fig export writes textTruncation and import keeps auto height. - node.get reports maxLines; text.fit measures every line. Co-authored-by: Cursor --- crates/doc/src/text.rs | 25 ++++++++- crates/engine/src/cmd/arrange.rs | 6 ++- crates/engine/src/cmd/props.rs | 7 +-- crates/engine/src/cmd/text.rs | 24 +++++---- crates/engine/src/inspect.rs | 3 ++ crates/engine/src/text_behaviour_tests.rs | 60 +++++++++++++++++++++ crates/engine/src/textfit.rs | 2 + crates/engine/src/tools.rs | 9 ++-- crates/format/src/fig/export.rs | 2 +- crates/format/src/rest/text.rs | 5 +- crates/text/src/behaviour_tests.rs | 36 +++++++++++++ crates/text/src/layout.rs | 64 ++++++++++++++++------- crates/text/src/tests.rs | 4 +- crates/ui-egui/src/typography.rs | 22 +++++--- docs/roadmap.md | 3 +- docs/target-app-parity.md | 6 ++- 16 files changed, 227 insertions(+), 51 deletions(-) diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 24d7e088..8c739920 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -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) diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index 0180504c..8f8e40d2 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -562,7 +562,11 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result { 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)?; } diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 9ee499fc..30a10393 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -461,11 +461,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map TextAutoResize::WidthAndHeight, (_, Sizing::Hug) => TextAutoResize::Height, + _ if t.truncates() => TextAutoResize::Truncate, _ => TextAutoResize::None, }; } diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 813a6036..3822c5c5 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -291,15 +291,21 @@ pub fn specs() -> Vec { 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", diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 33162314..7dccd706 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -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)); } diff --git a/crates/engine/src/text_behaviour_tests.rs b/crates/engine/src/text_behaviour_tests.rs index 92a43306..8b33e303 100644 --- a/crates/engine/src/text_behaviour_tests.rs +++ b/crates/engine/src/text_behaviour_tests.rs @@ -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); +} diff --git a/crates/engine/src/textfit.rs b/crates/engine/src/textfit.rs index 4ab6c50e..f9d549e4 100644 --- a/crates/engine/src/textfit.rs +++ b/crates/engine/src/textfit.rs @@ -29,6 +29,8 @@ pub fn specs() -> Vec { /// 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); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 629c9a06..165638ff 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -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(()); } diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index dad09d95..801aeed9 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -1555,7 +1555,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 { diff --git a/crates/format/src/rest/text.rs b/crates/format/src/rest/text.rs index 99ceb500..46dc7486 100644 --- a/crates/format/src/rest/text.rs +++ b/crates/format/src/rest/text.rs @@ -69,10 +69,11 @@ fn paragraph(s: &Value, td: &mut TextData) { // Missing means a fixed box. _ => TextAutoResize::None, }; - if text(s, "textTruncation") == Some("ENDING") { + td.max_lines = num(s, "maxLines").filter(|n| *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 text(s, "textTruncation") == Some("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); diff --git a/crates/text/src/behaviour_tests.rs b/crates/text/src/behaviour_tests.rs index eea8e93f..4f5b0774 100644 --- a/crates/text/src/behaviour_tests.rs +++ b/crates/text/src/behaviour_tests.rs @@ -162,6 +162,42 @@ 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"); + } + // 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(¶, 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 }; diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 3c64d4ab..56439719 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -532,7 +532,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) 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, box_height: Option) 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, box_height: Option) 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,6 +760,31 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) } } 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); } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index d36c1589..09accbd4 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -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); } diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index f61aa183..13b5785f 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -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); diff --git a/docs/roadmap.md b/docs/roadmap.md index c22f88f8..5d6bf925 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (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 (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 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5c8dcc01..8a236656 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -285,7 +285,9 @@ Hours in this narrative are the builders' estimates; the tables above supersede unlinks the chars edited). Live previews of a hovered named instance or font style and of a dragged axis slider (one undo step on release, Escape cancels). Visual QA: list markers keep the paragraph's font, renames start with the name selected and centred, Max lines shows while - truncating. Right-to-left paragraphs mirror lists and indents: bullets and numbers (".1") on + truncating. Truncation fills the last line shown before the ellipsis ("Lorem consec…", not + "Lorem…"), a truncated text resized keeps truncating, and auto-height text with Max lines hugs + up to that many lines. Right-to-left paragraphs mirror lists and indents: bullets and numbers (".1") on the right, wrapped lines hanging from the right, the paragraph indent on the right, trailing spaces hanging outside the line (canvas, PNG, PDF and SVG). A behaviour suite from the public documentation (`crates/text/src/behaviour_tests.rs`, `crates/engine/src/text_behaviour_tests.rs`): From 398c5404f5c10ce21788180baa7c2518f0924e88 Mon Sep 17 00:00:00 2001 From: Muamer Mustafic Date: Mon, 26 Oct 2026 13:27:25 +0100 Subject: [PATCH 5/7] M4: a truncated last line's char range covers the chars it takes from the next line (#1375) Co-authored-by: Cursor --- crates/text/src/behaviour_tests.rs | 2 ++ crates/text/src/layout.rs | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/crates/text/src/behaviour_tests.rs b/crates/text/src/behaviour_tests.rs index 4f5b0774..2b19b1d6 100644 --- a/crates/text/src/behaviour_tests.rs +++ b/crates/text/src/behaviour_tests.rs @@ -190,6 +190,8 @@ fn truncation_fills_the_last_line_before_the_ellipsis() { 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); diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 56439719..77d42804 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -786,6 +786,14 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) } } 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. From d3db02de9ae83a38ca57e96a38553d49c8568d97 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 26 Oct 2026 23:04:38 +0700 Subject: [PATCH 6/7] #1378: max lines only while truncation is ENDING, with a REST test for auto height with truncation off --- crates/format/src/rest/tests.rs | 11 +++++++++++ crates/format/src/rest/text.rs | 6 ++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/format/src/rest/tests.rs b/crates/format/src/rest/tests.rs index f9acf2ea..5ea5264f 100644 --- a/crates/format/src/rest/tests.rs +++ b/crates/format/src/rest/tests.rs @@ -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!([ diff --git a/crates/format/src/rest/text.rs b/crates/format/src/rest/text.rs index 46dc7486..18e5015e 100644 --- a/crates/format/src/rest/text.rs +++ b/crates/format/src/rest/text.rs @@ -69,9 +69,11 @@ fn paragraph(s: &Value, td: &mut TextData) { // Missing means a fixed box. _ => TextAutoResize::None, }; - td.max_lines = num(s, "maxLines").filter(|n| *n >= 1.0).map_or(0, |n| n.min(10_000.0) as u32); + // 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 text(s, "textTruncation") == Some("ENDING") && !td.truncates() { + if ending && !td.truncates() { td.auto_resize = TextAutoResize::Truncate; } td.paragraph_spacing = len(s, "paragraphSpacing").unwrap_or(0.0); From 4c101fa54930711a48d34326383b24eda69b18ae Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 26 Oct 2026 23:05:53 +0700 Subject: [PATCH 7/7] #959: the test is named for what it checks: non-finite numbers are saved as zero --- crates/format/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 31c2d21c..30f6e548 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -1020,7 +1020,7 @@ mod tests { } #[test] - fn non_finite_numbers_are_refused_at_save() { + 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();