mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 03:46:50 -06:00
M4: truncated text fills its last line, keeps truncating when resized, and auto height takes max lines (#1375, #1376, #1377)
- 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 <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
63d926876f
commit
b6d3e002df
+24
-1
@@ -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)
|
||||
|
||||
@@ -562,7 +562,11 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
n.child_layout.sizing_v = Sizing::Fixed;
|
||||
}
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
t.auto_resize = if num(p, "height").is_some() { TextAutoResize::None } else { TextAutoResize::Height };
|
||||
if num(p, "height").is_some() {
|
||||
t.fix_size(num(p, "width").is_some(), true);
|
||||
} else if t.auto_resize != TextAutoResize::Truncate {
|
||||
t.auto_resize = TextAutoResize::Height;
|
||||
}
|
||||
}
|
||||
super::props::restore_worlds(doc, &kept)?;
|
||||
}
|
||||
|
||||
@@ -461,11 +461,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
resize_node(doc, id, nw, nh)?;
|
||||
// Explicit size makes text fixed in that dimension.
|
||||
if let Some(t) = doc.get_mut(id)?.kind.text_mut() {
|
||||
t.auto_resize = if is_w {
|
||||
if t.auto_resize == TextAutoResize::WidthAndHeight { TextAutoResize::Height } else { t.auto_resize }
|
||||
} else {
|
||||
TextAutoResize::None
|
||||
};
|
||||
t.fix_size(is_w, !is_w);
|
||||
}
|
||||
fix_axis(doc, id, is_w)?;
|
||||
}
|
||||
@@ -1220,6 +1216,7 @@ fn set_sizing(cmd: &str, doc: &mut Document, id: NodeId, s: Sizing, horizontal:
|
||||
t.auto_resize = match (n.child_layout.sizing_h, n.child_layout.sizing_v) {
|
||||
(Sizing::Hug, _) => TextAutoResize::WidthAndHeight,
|
||||
(_, Sizing::Hug) => TextAutoResize::Height,
|
||||
_ if t.truncates() => TextAutoResize::Truncate,
|
||||
_ => TextAutoResize::None,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -291,15 +291,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let v = finite(req_num("text.paragraphSpacing", p, "spacing")?).clamp(0.0, 10_000.0);
|
||||
para(s, p, |t| t.paragraph_spacing = v)
|
||||
}),
|
||||
spec!("text.maxLines", "Max lines", "{lines (0 = no limit; truncates), ids?}", true, |s, p| {
|
||||
let v = finite(req_num("text.maxLines", p, "lines")?).clamp(0.0, 10_000.0) as u32;
|
||||
para(s, p, |t| {
|
||||
t.max_lines = v;
|
||||
if v > 0 {
|
||||
t.auto_resize = TextAutoResize::Truncate;
|
||||
}
|
||||
})
|
||||
}),
|
||||
spec!(
|
||||
"text.maxLines",
|
||||
"Max lines",
|
||||
"{lines (0 = no limit; truncates: auto-height text hugs up to that many lines, other text becomes a truncating box), ids?}",
|
||||
true,
|
||||
|s, p| {
|
||||
let v = finite(req_num("text.maxLines", p, "lines")?).clamp(0.0, 10_000.0) as u32;
|
||||
para(s, p, |t| {
|
||||
t.max_lines = v;
|
||||
if v > 0 && t.auto_resize != TextAutoResize::Height {
|
||||
t.auto_resize = TextAutoResize::Truncate;
|
||||
}
|
||||
})
|
||||
}
|
||||
),
|
||||
spec!(
|
||||
"text.setFeatures",
|
||||
"OpenType features",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
/// paragraph of its own after a hard break, which no size can fix).
|
||||
fn measure(t: &TextData, k: f64, width: f64) -> (usize, f64, bool) {
|
||||
let mut t = t.clone();
|
||||
// Every line counts, not only those the layer's own line limit shows.
|
||||
t.max_lines = 0;
|
||||
let n = t.char_count();
|
||||
t.restyle(0, n, |s| s.font_size = (s.font_size * k).clamp(1.0, 5000.0));
|
||||
let l = figcraft_text::layout(&t, Some(width), None);
|
||||
|
||||
@@ -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(());
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
+46
-18
@@ -532,7 +532,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
|
||||
let mut out = TextLayout { styles: styles.clone(), ..Default::default() };
|
||||
let mut y = 0.0;
|
||||
let mut para_start_line = true;
|
||||
let max_lines = if text.max_lines > 0 && matches!(text.auto_resize, TextAutoResize::Truncate) { text.max_lines as usize } else { usize::MAX };
|
||||
let max_lines = if text.max_lines > 0 && text.truncates() { text.max_lines as usize } else { usize::MAX };
|
||||
// A style's vertical metrics (ascent, descent, line height, cap height) come from its own
|
||||
// font, never a fallback face, so lines mixing scripts keep the spacing of lines that don't.
|
||||
let metrics = |st_idx: usize| -> [f64; 4] {
|
||||
@@ -546,6 +546,25 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
|
||||
None => [size * 0.8, size * 0.2, whole_px(st.line_height.resolve(size, 1.2)), size * 0.7],
|
||||
}
|
||||
};
|
||||
// An item's glyph with its pen position at `pen` on the line at `baseline` (none for a space
|
||||
// the font has no glyph for).
|
||||
let glyph_at = |it: &Item, pen: f64, baseline: f64| {
|
||||
let size = styles.get(it.style).map(|st| sanitize_size(st.font_size)).unwrap_or(12.0) * it.scale;
|
||||
(it.gid != 0 || !it.is_space()).then(|| Glyph {
|
||||
face: it.face.clone(),
|
||||
coords: it.coords.clone(),
|
||||
gid: it.gid,
|
||||
x: pen + it.dx,
|
||||
y: baseline - it.dy,
|
||||
size,
|
||||
advance: it.adv,
|
||||
char_start: it.char_start,
|
||||
char_end: it.char_end,
|
||||
style: it.style,
|
||||
rtl: it.level % 2 == 1,
|
||||
warp: Affine::IDENTITY,
|
||||
})
|
||||
};
|
||||
let mut first_cap = 0.0;
|
||||
for (li, (s, e, para_end, para)) in lines.iter().enumerate() {
|
||||
if li >= max_lines {
|
||||
@@ -685,23 +704,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
|
||||
if is_line_break(it.ch) {
|
||||
continue;
|
||||
}
|
||||
let size = styles.get(it.style).map(|st| sanitize_size(st.font_size)).unwrap_or(12.0) * it.scale;
|
||||
if it.gid != 0 || !it.is_space() {
|
||||
out.glyphs.push(Glyph {
|
||||
face: it.face.clone(),
|
||||
coords: it.coords.clone(),
|
||||
gid: it.gid,
|
||||
x: pen + it.dx,
|
||||
y: baseline - it.dy,
|
||||
size,
|
||||
advance: it.adv,
|
||||
char_start: it.char_start,
|
||||
char_end: it.char_end,
|
||||
style: it.style,
|
||||
rtl: it.level % 2 == 1,
|
||||
warp: Affine::IDENTITY,
|
||||
});
|
||||
}
|
||||
out.glyphs.extend(glyph_at(it, pen, baseline));
|
||||
pen += it.adv;
|
||||
if it.stretches() {
|
||||
pen += justify_extra;
|
||||
@@ -757,6 +760,31 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
|
||||
}
|
||||
}
|
||||
if out.truncated {
|
||||
// The last line shown runs on into the line cut after it, so the ellipsis fills the line
|
||||
// ("Lorem consec…") instead of following the last whole word ("Lorem…").
|
||||
let last = out.lines.len().checked_sub(1);
|
||||
let wrapped_on = last.and_then(|li| Some((out.lines.get(li)?, lines.get(li)?, lines.get(li + 1)?)));
|
||||
if let (Some(maxw), Some((line, &(_, e, para_end, _), &(ns, ne, _, _)))) = (wrap, wrapped_on)
|
||||
&& !para_end
|
||||
&& !break_after(e)
|
||||
&& !line.rtl
|
||||
{
|
||||
let baseline = line.baseline;
|
||||
let mut pen = out.glyphs.get(line.glyph_start..line.glyph_end).and_then(<[Glyph]>::last).map_or(line.width, |g| g.x + g.advance);
|
||||
for it in items.get(ns..ne).unwrap_or(&[]) {
|
||||
if pen > maxw {
|
||||
break;
|
||||
}
|
||||
if !is_line_break(it.ch) {
|
||||
out.glyphs.extend(glyph_at(it, pen, baseline));
|
||||
pen += it.adv;
|
||||
}
|
||||
}
|
||||
let gend = out.glyphs.len();
|
||||
if let Some(l) = out.lines.last_mut() {
|
||||
l.glyph_end = gend;
|
||||
}
|
||||
}
|
||||
add_ellipsis(&mut out, wrap);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+2
-1
@@ -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 |
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user