Merge upstream/main into fix/scrub-speed

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Trevor BrownandClaude Fable 5.1 committed 2026-11-02 06:02:47 -05:00
commit e87b46b4e3
58 files changed
+1146 -130

No files matched your search

+60
View File
@@ -72,6 +72,52 @@ fn fill_on_a_child_turns_the_hugging_parent_fixed() {
assert_eq!(sizing(&s, f), (Sizing::Hug, Sizing::Hug));
}
#[test]
fn a_child_resized_to_the_full_available_space_becomes_fill() {
// Documented: "Child objects of an auto layout frame will also be set to Fill container if
// they are manually resized to the full available space of the parent frame."
let mut s = Session::new();
let f = al_frame(&mut s, json!({"padding": 10, "gap": 10})); // 200 × 100, fixed
let a = rect(&mut s, f, 50.0, 20.0, "#FF0000");
// Along the flow, alone in the frame: its width less the padding (200 - 20).
s.execute("transform.resize", &json!({"ids": [a], "width": 180})).unwrap();
assert_eq!(sizing(&s, a), (Sizing::Fill, Sizing::Fixed));
assert_eq!(size(&s, a), (180.0, 20.0));
s.undo().unwrap();
assert_eq!(sizing(&s, a), (Sizing::Fixed, Sizing::Fixed), "undone as part of the edit");
// Across the flow, beside a sibling too: the frame's height less its padding.
let b = rect(&mut s, f, 50.0, 20.0, "#0000FF");
set(&mut s, b, json!({"height": 80}));
assert_eq!(sizing(&s, b), (Sizing::Fixed, Sizing::Fill));
assert_eq!(size(&s, b), (50.0, 80.0));
// Short of it: fixed, as before.
set(&mut s, b, json!({"height": 70}));
assert_eq!(sizing(&s, b), (Sizing::Fixed, Sizing::Fixed));
// Along the flow beside a sibling, what is left is not "the full available space": a width
// that happens to match it (180 - 10 - 50) stays fixed.
s.execute("transform.resize", &json!({"ids": [b], "width": 120})).unwrap();
assert_eq!(sizing(&s, b), (Sizing::Fixed, Sizing::Fixed));
// A hugging frame has no full space to offer: the child stays fixed.
let g = al_frame(&mut s, json!({"sizingH": "hug", "sizingV": "hug", "padding": 10}));
let c = rect(&mut s, g, 50.0, 20.0, "#00FF00");
set(&mut s, c, json!({"width": 180}));
assert_eq!(sizing(&s, c), (Sizing::Fixed, Sizing::Fixed));
// An inside stroke the layout includes takes room like the padding: the full space is what
// layout leaves the child (200 - 2 × 10 - 2 × 4), not the frame less its padding alone.
let h = al_frame(&mut s, json!({"padding": 10, "strokesIncluded": true, "stroke": "#000000", "strokeWeight": 4, "strokeAlign": "inside"}));
let d = rect(&mut s, h, 50.0, 20.0, "#FF0000");
set(&mut s, d, json!({"width": 180}));
assert_eq!(sizing(&s, d), (Sizing::Fixed, Sizing::Fixed), "wider than the room the stroke leaves");
assert_eq!(size(&s, d), (180.0, 20.0), "and kept as typed");
set(&mut s, d, json!({"width": 172}));
assert_eq!(sizing(&s, d), (Sizing::Fill, Sizing::Fixed));
assert_eq!(size(&s, d), (172.0, 20.0));
// Excluded from the layout, the stroke takes no room: the frame less its padding again.
set(&mut s, h, json!({"strokesIncluded": false}));
set(&mut s, d, json!({"width": 172}));
assert_eq!(sizing(&s, d), (Sizing::Fixed, Sizing::Fixed));
}
#[test]
fn hug_and_fill_only_where_they_apply() {
// Documented: Hug applies to auto layout frames (and text); Fill to children of auto layout,
@@ -232,6 +278,20 @@ fn a_size_given_with_a_new_path_fixes_that_axis() {
set(&mut s, v, json!({"path": "M0 0 L20 5", "width": 30}));
assert_eq!(sizing(&s, v).0, Sizing::Fixed);
assert!((size(&s, v).0 - 30.0).abs() < 1e-6, "{:?}", size(&s, v));
// The axis is judged by the size the path is given, not the one before it: the frame's full
// height (100) given with a path fills; a smaller height given with the next path is fixed,
// though the layer filled the frame until then.
set(&mut s, v, json!({"path": "M0 0 L20 5", "height": 100}));
assert_eq!(sizing(&s, v).1, Sizing::Fill);
assert!((size(&s, v).1 - 100.0).abs() < 1e-6, "{:?}", size(&s, v));
set(&mut s, v, json!({"height": 40, "path": "M0 0 L20 5"}));
assert_eq!(sizing(&s, v).1, Sizing::Fixed);
assert!((size(&s, v).1 - 40.0).abs() < 1e-6, "{:?}", size(&s, v));
// Rotated, the layer's local size isn't the room layout gives it: a height matching the
// frame's stays fixed rather than have Fill squash the path.
set(&mut s, v, json!({"rotation": 90}));
set(&mut s, v, json!({"path": "M0 0 L20 5", "height": 100}));
assert_eq!(sizing(&s, v).1, Sizing::Fixed);
}
/// What one call's auto layout keys give, whichever way they are spelled: the mode (a string, or
+31 -14
View File
@@ -303,9 +303,21 @@ fn insert_model(s: &mut Session, text: &str, ime_commit: bool) -> Result<ImeInse
Ok(outcome)
}
/// List keys: Tab nests the selected list items a level deeper; Shift+Tab, Backspace at an item's
/// start and Enter on an empty item move it a level out (at the top: out of the list). Returns
/// whether the key was taken.
/// What a list key does to the selected items.
#[derive(Clone, Copy, PartialEq, Eq)]
enum ListMove {
/// A level deeper (Tab).
Deeper,
/// A level out; at the top, out of the list (Shift+Tab, Enter on an empty item).
Out,
/// Out of the list at the level it had: "Use Backspace or Delete at the beginning of a list
/// item to delete the counter, but keep the same level of indentation."
Unlist,
}
/// List keys: Tab nests the selected list items a level deeper; Shift+Tab and Enter on an empty
/// item move it a level out (at the top: out of the list); Backspace and Delete at an item's start
/// take its marker and keep its level. Returns whether the key was taken.
fn list_key(s: &mut Session, key: &str, shift: bool) -> Result<bool> {
let Some(te) = s.ds()?.text_edit.clone() else { return Ok(false) };
let t = text_of(s, te.id)?;
@@ -315,19 +327,24 @@ fn list_key(s: &mut Session, key: &str, shift: bool) -> Result<bool> {
let item = t.para_list(t.para_of(a)).list != ListStyle::None;
let (pa, pb) = t.para_span(a, b);
let any_item = (pa..=pb).any(|p| t.para_list(p).list != ListStyle::None);
let deeper = match key {
"Tab" if any_item => !shift,
"Backspace" if item && at_start => false,
"Enter" if item && at_start && chars.get(a).is_none_or(|c| *c == '\n') => false,
let mv = match key {
"Tab" if any_item && !shift => ListMove::Deeper,
"Tab" if any_item => ListMove::Out,
"Backspace" | "Delete" if item && at_start => ListMove::Unlist,
"Enter" if item && at_start && chars.get(a).is_none_or(|c| *c == '\n') => ListMove::Out,
_ => return Ok(false),
};
edit(s, |t, _| {
t.set_para_lists(a, b, |pl| match pl.list {
ListStyle::None => {}
_ if deeper => pl.indent = pl.indent.saturating_add(1),
_ if pl.indent > 0 => pl.indent -= 1,
_ => pl.list = ListStyle::None,
})
if mv == ListMove::Unlist {
t.unlist_keeping_levels(a, b);
} else {
t.set_para_lists(a, b, |pl| match (pl.list, mv) {
(ListStyle::None, _) | (_, ListMove::Unlist) => {}
(_, ListMove::Deeper) => pl.indent = pl.indent.saturating_add(1),
(_, ListMove::Out) if pl.indent > 0 => pl.indent -= 1,
(_, ListMove::Out) => pl.list = ListStyle::None,
});
}
})?;
Ok(true)
}
@@ -411,7 +428,7 @@ pub fn key(s: &mut Session, key: &str, shift: bool, word: bool, line: bool) -> R
let t = text_of(s, te.id)?;
let chars: Vec<char> = t.characters.chars().collect();
let n = chars.len();
if matches!(key, "Tab" | "Backspace" | "Enter") && !word && !line && list_key(s, key, shift)? {
if matches!(key, "Tab" | "Backspace" | "Delete" | "Enter") && !word && !line && list_key(s, key, shift)? {
return Ok(());
}
match key {
+1 -1
View File
@@ -242,7 +242,7 @@ fn paragraph_and_feature_commands_on_nodes() {
s.execute("text.listStyle", &json!({"ids": ids, "style": "bulleted"})).unwrap();
let t = data(&s, id);
assert_eq!((t.paragraph_indent, t.paragraph_spacing, t.max_lines), (16.0, 8.0, 2));
assert_eq!(t.auto_resize, TextAutoResize::Truncate);
assert_eq!(t.auto_resize, TextAutoResize::WidthAndHeight, "max lines keep an auto-width text auto width");
assert_eq!(t.style.decoration, TextDecoration::Strikethrough);
assert_eq!(t.style.case, TextCase::SmallCaps);
assert_eq!(t.list, ListStyle::Bulleted);
+31
View File
@@ -22,6 +22,29 @@ impl Session {
}
}
/// A double-click on an edge handle of the one selected layer auto layout sizes: Hug contents
/// on that axis for an auto layout frame, Fill container with Alt for a child of one (a
/// vertical edge sizes the width, a horizontal one the height). Whether it applied: on any
/// other layer, or off the edges, the double-click goes on as before.
fn edge_double_click(&mut self, doc: &Document, sel: &[NodeId], ev: &PointerEvent) -> Result<bool> {
let [id] = sel[..] else { return Ok(false) };
let Some(super::Handle::Edge(edge)) = super::handles::handle_at(doc, sel, ev.p(), ev.zoom) else { return Ok(false) };
let Some(n) = doc.node(id) else { return Ok(false) };
let applies = if ev.alt {
!n.child_layout.absolute && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some())
} else {
n.layout.is_some()
};
if !applies {
return Ok(false);
}
// Edges run top, right, bottom, left: the odd ones are vertical and size the width.
let mut props = serde_json::Map::new();
props.insert(if edge % 2 == 1 { "sizingH" } else { "sizingV" }.to_string(), json!(if ev.alt { "fill" } else { "hug" }));
self.execute("node.set", &json!({"id": id, "props": props}))?;
Ok(true)
}
pub(super) fn double_click(&mut self, ev: &PointerEvent) -> Result<()> {
let ds = self.ds()?;
let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page);
@@ -61,6 +84,14 @@ impl Session {
self.tool.overlay.marquee = None;
self.cancel_interaction()?;
}
// An edge of an auto layout object's bounding box: Hug contents on that axis, with Alt
// Fill container ("Tip: Double-click the vertical or horizontal edge of an auto layout
// object's bounding box to set it to Hug contents." and "Hold ⌥ Option or Alt and
// double-click the vertical or horizontal edge of an auto layout object's bounding box to
// set it to Fill container.", Guide to auto layout).
if edits && matches!(self.tool.tool, ToolId::Move | ToolId::Scale) && !(ev.shift || ev.cmd) && self.edge_double_click(&doc, &sel, ev)? {
return Ok(());
}
let hit_here = self.hit(&doc, page, ev.p(), ev.tol() * 2.0);
// A double-click anywhere on the one selected shape edits its points, like Enter: also
// inside a hollow (unfilled) closed shape, where the hit test finds nothing under the
+7 -6
View File
@@ -569,12 +569,6 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let n = doc.get_mut(i)?;
let after = n.transform * Point::new(w * ax, h * ay);
n.transform = Affine::translate(before - after) * n.transform;
if num(p, "width").is_some() {
n.child_layout.sizing_h = Sizing::Fixed;
}
if num(p, "height").is_some() {
n.child_layout.sizing_v = Sizing::Fixed;
}
if let NodeKind::Text(t) = &mut n.kind {
if num(p, "height").is_some() {
t.fix_size(num(p, "width").is_some(), true);
@@ -582,6 +576,13 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
t.auto_resize = TextAutoResize::Height;
}
}
// A side given is fixed, or fills its auto layout parent when it is the full space there.
if num(p, "width").is_some() {
super::props::fix_axis(doc, i, true)?;
}
if num(p, "height").is_some() {
super::props::fix_axis(doc, i, false)?;
}
super::props::restore_worlds(doc, &kept)?;
}
super::styles::detach_edited(doc, &before);
+53 -8
View File
@@ -437,17 +437,55 @@ fn layout_keys_beside_none(cmd: &str, props: &Map<String, Value>) -> Result<()>
}
/// An explicit width (`is_w`) or height makes hugging or filling on that axis fixed, like typing
/// into W or H.
fn fix_axis(doc: &mut Document, id: NodeId, is_w: bool) -> Result<()> {
/// into W or H; sized to the full space its auto layout parent has for it on that axis, the layer
/// fills instead: "Child objects of an auto layout frame will also be set to Fill container if
/// they are manually resized to the full available space of the parent frame" (Guide to auto
/// layout).
pub(crate) fn fix_axis(doc: &mut Document, id: NodeId, is_w: bool) -> Result<()> {
let sizing = if fills_available_space(doc, id, is_w) { Sizing::Fill } else { Sizing::Fixed };
let n = doc.get_mut(id)?;
if is_w {
n.child_layout.sizing_h = Sizing::Fixed;
n.child_layout.sizing_h = sizing;
} else {
n.child_layout.sizing_v = Sizing::Fixed;
n.child_layout.sizing_v = sizing;
}
Ok(())
}
/// Whether `id`'s width (`is_w`) or height is the full space its auto layout parent has for it on
/// that axis: the frame's size less its padding and an inside stroke included in the layout (the
/// insets layout itself takes). Across the flow that is every child's room;
/// along the flow only a lone child has it (beside siblings what is left depends on the gaps, the
/// alignment and overflow, so a size that happens to match it is not "the full available
/// space"). Never in a hugging or wrapping frame or a grid, or for a layer that ignores the layout.
fn fills_available_space(doc: &Document, id: NodeId, is_w: bool) -> bool {
let Some(n) = doc.node(id) else { return false };
let Some(parent) = n.parent.and_then(|p| doc.node(p)) else { return false };
let Some(l) = &parent.layout else { return false };
if n.child_layout.absolute || l.wrap || l.mode == LayoutMode::Grid {
return false;
}
// A rotated layer's local size isn't the size layout gives it room for (its rotated bounds
// are): it stays Fixed rather than have Fill squash its geometry.
let [_, b, c, ..] = n.transform.as_coeffs();
if b.abs() > 1e-9 || c.abs() > 1e-9 {
return false;
}
let hugs = if is_w { parent.child_layout.sizing_h } else { parent.child_layout.sizing_v } == Sizing::Hug;
if hugs {
return false;
}
if (l.mode == LayoutMode::Horizontal) == is_w
&& parent.children.iter().filter(|c| **c != id).filter_map(|c| doc.node(*c)).any(|c| c.visible && !c.child_layout.absolute)
{
return false;
}
let (pt, pr, pb, pl) = figcraft_layout::insets(parent, l);
let available = if is_w { parent.width - pl - pr } else { parent.height - pt - pb };
let size = if is_w { n.width } else { n.height };
available > 0.0 && (size - available).abs() < 0.01
}
/// The `geometry` properties of `apply`; `false` when `k` is not one of them.
fn apply_geometry(
cmd: &str,
@@ -459,10 +497,17 @@ fn apply_geometry(
path_offset: &mut (f64, f64),
) -> Result<bool> {
match k {
"path" => *path_offset = set_path(cmd, doc, id, v, props)?,
// A new path takes the size asked for as its scale (`set_path`); the size still fixes
// its auto layout axis.
"width" | "height" | "w" | "h" if props.contains_key("path") => fix_axis(doc, id, k.starts_with('w'))?,
"path" => {
*path_offset = set_path(cmd, doc, id, v, props)?;
// A size given beside the path is its scale (`set_path`), and still fixes or fills
// its auto layout axis, judged once the path has given the layer that size.
for (long, short, is_w) in [("width", "w", true), ("height", "h", false)] {
if props.contains_key(long) || props.contains_key(short) {
fix_axis(doc, id, is_w)?;
}
}
}
"width" | "height" | "w" | "h" if props.contains_key("path") => {}
"x" | "y" => {
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?;
let val = val + if k == "x" { path_offset.0 } else { path_offset.1 };
+28 -2
View File
@@ -319,13 +319,13 @@ pub fn specs() -> Vec<CommandSpec> {
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?}",
"{lines (0 = no limit; truncates: auto-height and auto-width text hug up to that many lines, fixed-size 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;
let r = para(s, p, |t| {
t.max_lines = v;
if v > 0 && t.auto_resize != TextAutoResize::Height {
if v > 0 && !matches!(t.auto_resize, TextAutoResize::Height | TextAutoResize::WidthAndHeight) {
t.auto_resize = TextAutoResize::Truncate;
}
})?;
@@ -521,6 +521,32 @@ pub fn specs() -> Vec<CommandSpec> {
textedit::end(s)?;
ok()
}),
spec!(
"text.openLink",
"Open link",
"{} while editing: the link at the caret (the selection's start) goes to the UI to open, as Alt+Enter does (\"press Option Return / Alt Enter to activate a focused link\"); only a web or mail link opens → {url} ({url: null}: no link there)",
false,
|s, _| {
let Some(te) = s.ds()?.text_edit.clone() else { return Err(bad("text.openLink", "no text is being edited")) };
let doc = s.doc()?;
let t = doc.node(te.id).and_then(|n| n.kind.text()).ok_or_else(|| bad("text.openLink", format!("{} is not text", te.id)))?;
let n = t.char_count();
let at = te.caret.min(te.anchor);
// The caret sits between two chars: the link of the one after it, else before it.
let link = (at < n)
.then(|| t.style_at(at).link.clone())
.flatten()
.or_else(|| (at > 0 && at <= n).then(|| t.style_at(at - 1).link.clone()).flatten());
match link {
Some(url) if crate::doc_urls::openable(&url) => {
s.request_ui("open.url", json!({"url": url}));
Ok(json!({"url": url}))
}
Some(url) => Err(bad("text.openLink", format!("`{url}` is not a web or mail link"))),
None => Ok(json!({"url": Value::Null})),
}
}
),
spec!(
"text.fonts",
"List fonts",
+7
View File
@@ -321,6 +321,13 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("view.snapObjects", "Snap to objects", "{on?}", false, |s, p| toggle(s, p, "view.snapObjects")),
spec!("view.snapPixel", "Snap to pixel grid", "{on?}", false, |s, p| toggle(s, p, "view.snapPixel")),
spec!("view.toggleUi", "Show/Hide UI", "{on?}", false, |s, p| flag(s, p, |v| &mut v.ui_hidden)),
spec!(
"view.minimize",
"Minimize the interface",
"{on?} collapses the navigation bar and both sidebars and keeps the toolbar (Shift+Cmd/Ctrl+\\, the Minimize button by the file name); the right sidebar shows while something is selected",
false,
|s, p| flag(s, p, |v| &mut v.ui_minimized)
),
spec!("view.fullscreen", "Full screen", "{on?} the window fills the screen (the desktop app; a no-op headless)", false, |s, p| {
s.request_ui("window.fullscreen", json!({"on": boolean(p, "on")}));
ok()
+74
View File
@@ -128,3 +128,77 @@ fn a_press_drags_only_past_the_threshold_in_screen_pixels() {
assert_eq!(origin(&s, r).2 != 200.0, resizes, "resize by {by}");
}
}
/// A double-click with Alt held throughout, as the canvas sends it.
fn alt_double_click(s: &mut Session, x: f64, y: f64, zoom: f64) {
for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::DoubleClick] {
let mut ev = PointerEvent::at(kind, x, y, zoom);
ev.alt = true;
s.pointer(&ev).unwrap();
}
}
/// "Tip: Double-click the vertical or horizontal edge of an auto layout object's bounding box to
/// set it to Hug contents." and "Hold ⌥ Option or Alt and double-click the vertical or horizontal
/// edge of an auto layout object's bounding box to set it to Fill container." (Guide to auto
/// layout): a vertical edge sizes the width, a horizontal one the height; a layer auto layout
/// doesn't size keeps the double-click's old meaning.
#[test]
fn a_double_click_on_an_auto_layout_edge_hugs_and_with_alt_fills() {
use figcraft_doc::Sizing;
let mut s = Session::new();
let f = id_of(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 100}))["id"]).unwrap();
ex(&mut s, "node.set", json!({"id": f, "props": {"layout": "horizontal", "padding": 0, "gap": 0}}));
let c =
id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 40, "parent": f, "props": {"fill": "#FF0000"}}))["id"])
.unwrap();
let sizing = |s: &Session, id: NodeId| {
let cl = &s.doc().unwrap().node(id).unwrap().child_layout;
(cl.sizing_h, cl.sizing_v)
};
let size = |s: &Session, id: NodeId| {
let n = s.doc().unwrap().node(id).unwrap();
(n.width, n.height)
};
ex(&mut s, "select.set", json!({"ids": [f]}));
assert_eq!((sizing(&s, f), size(&s, f)), ((Sizing::Fixed, Sizing::Fixed), (400.0, 100.0)));
// The frame's right edge: Hug contents horizontally, and the frame shrinks onto its child.
double_click(&mut s, 400.0, 50.0, 0.0, 1.0);
assert_eq!(sizing(&s, f), (Sizing::Hug, Sizing::Fixed));
assert_eq!(size(&s, f), (100.0, 100.0));
assert_eq!(s.selection(), [f], "still selected, not entered");
s.undo().unwrap();
assert_eq!((sizing(&s, f), size(&s, f)), ((Sizing::Fixed, Sizing::Fixed), (400.0, 100.0)), "one undo step");
// Its bottom edge: Hug contents vertically.
double_click(&mut s, 200.0, 100.0, 0.0, 1.0);
assert_eq!(sizing(&s, f), (Sizing::Fixed, Sizing::Hug));
assert_eq!(size(&s, f), (400.0, 40.0));
// Alt on the child's right edge: Fill container horizontally, and the child takes the width.
ex(&mut s, "select.set", json!({"ids": [c]}));
alt_double_click(&mut s, 100.0, 20.0, 1.0);
assert_eq!(sizing(&s, c), (Sizing::Fill, Sizing::Fixed));
assert_eq!(size(&s, c), (400.0, 40.0));
// Without Alt the child, no auto layout frame itself, keeps the old meaning (a frame's
// double-click: nothing to enter here), and a plain rectangle's edge still edits its points.
double_click(&mut s, 400.0, 20.0, 0.0, 1.0);
assert_eq!(sizing(&s, c), (Sizing::Fill, Sizing::Fixed));
// A text child of the frame: Alt+double-click on its edge sets Fill like any child's, and a
// plain double-click on its edge still enters the text.
let x = id_of(&ex(&mut s, "text.create", json!({"text": "Hi", "x": 0, "y": 0, "parent": f, "props": {"fontSize": 16}}))["id"]).unwrap();
ex(&mut s, "node.set", json!({"id": x, "props": {"textAutoResize": "none", "width": 60, "height": 20}}));
ex(&mut s, "select.set", json!({"ids": [x]}));
let b = s.doc().unwrap().world_bounds(x).unwrap();
alt_double_click(&mut s, b.x1, (b.y0 + b.y1) / 2.0, 1.0);
assert_eq!(sizing(&s, x), (Sizing::Fill, Sizing::Fixed), "a text child fills too");
let b = s.doc().unwrap().world_bounds(x).unwrap();
// On the edge handle, a pixel in (the hit test's reach): the text, not the frame edge it shares.
double_click(&mut s, b.x1 - 1.0, (b.y0 + b.y1) / 2.0, 0.0, 1.0);
assert!(s.ds().unwrap().text_edit.as_ref().is_some_and(|te| te.id == x), "a text's edge still enters it without Alt");
assert_eq!(sizing(&s, x), (Sizing::Fill, Sizing::Fixed));
crate::textedit::end(&mut s).unwrap();
let r = id_of(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 300, "width": 100, "height": 100}))["id"]).unwrap();
ex(&mut s, "select.set", json!({"ids": [r]}));
double_click(&mut s, 99.0, 350.0, 0.0, 1.0);
assert_eq!(sizing(&s, r), (Sizing::Fixed, Sizing::Fixed));
assert!(s.ds().unwrap().vector_edit.is_some(), "a shape's double-click still edits its points");
}
+4 -1
View File
@@ -225,7 +225,7 @@ pub static KEYS: &[Binding] = &[
k(View, "view.prototype", &["Alt+9"]).label("Prototype panel"),
k(View, "view.designPrototype", &["Shift+E"]),
k(View, "view.devMode", &["Shift+D"]).label("Dev Mode on/off"),
k(View, "view.leftSidebar", &["Shift+Cmd+\\"]),
k(View, "view.minimize", &["Shift+Cmd+\\"]),
k(View, "view.nextRegion", &["F6"]).win(&["F6", "Ctrl+F6"]).label("Focus the next region (toolbar, layers, canvas, properties)").both(),
k(View, "view.previousRegion", &["Shift+F6"]).label("Focus the previous region").both(),
k(View, "view.contextMenu", &["Shift+F10", "Menu"]).label("Context menu of the selection (the focused layer row or the canvas)"),
@@ -247,6 +247,7 @@ pub static KEYS: &[Binding] = &[
// Text (on selected text layers, and on the selected characters while editing)
k(Text, "text.bold", &["Cmd+B"]).both(),
k(Text, "text.italic", &["Cmd+I"]).both(),
k(Text, "text.openLink", &["Alt+Enter"]).both(),
k(Text, "text.underline", &["Cmd+U"]).both(),
k(Text, "text.strikethrough", &["Shift+Cmd+X"]).both(),
k(Text, "text.link", &["Shift+Cmd+U", "Cmd+K"]).text(),
@@ -370,6 +371,8 @@ pub static GESTURES: &[(Group, &str, &str, &str)] = &[
(Transform, "Opacity 10%…90%, 0: 100%", "1…9, 0", "1…9, 0"),
(Transform, "Exact opacity (4 then 5: 45%)", "Two digits", "Two digits"),
(Transform, "Suspend snapping while dragging", "Hold ⌃ or ⌘", "Hold Ctrl"),
(Arrange, "Set hug contents", "Double-click an edge of an auto layout frame", "Double-click an edge of an auto layout frame"),
(Arrange, "Set fill container", "⌥ double-click an edge of an auto layout child", "Alt+double-click an edge of an auto layout child"),
(Prototype, "Presenting: next frame", "→, Space or N", "→, Space or N"),
(Prototype, "Presenting: previous frame", "←", "←"),
(Prototype, "Presenting: restart", "R", "R"),
+2
View File
@@ -565,6 +565,8 @@ mod text_behaviour_tests;
#[cfg(test)]
mod text_fill_tests;
#[cfg(test)]
mod text_link_tests;
#[cfg(test)]
mod text_metrics_local_tests;
#[cfg(test)]
mod text_motion_tests;
+3 -2
View File
@@ -145,7 +145,7 @@ static ROWS: &[Row] = &[
doc(View, "Outline mode", &["Shift+Cmd+O"], &[], Bound("view.outlines"), ""),
doc(View, "Pixel preview", &["Ctrl+P"], &["Ctrl+Alt+P"], Bound("view.pixelPreview"), ""),
doc(View, "Others' cursors in a live session", &["Alt+Cmd+\\"], &["Ctrl+Alt+\\"], Bound("view.multiplayerCursors"), "new command"),
doc(View, "Hide the side panels (minimize the interface)", &["Shift+Cmd+\\"], &[], Bound("view.leftSidebar"), ""),
doc(View, "Minimize the interface: the navigation bar and both sidebars", &["Shift+Cmd+\\"], &[], Bound("view.minimize"), "the toolbar stays; the right sidebar shows while something is selected"),
doc(View, "Layers panel", &["Alt+1"], &["Alt+1", "Ctrl+1"], Bound("view.layers"), "one article writes Ctrl+1 for Windows, another Alt+1; both bound there"),
doc(View, "Interface scale: make larger", &["Alt+Shift+Cmd+="], &[], Bound("pref.uiScaleUp"), "Accessibility at Figma, Adjust the scale of the Figma UI"),
doc(View, "Interface scale: make smaller", &["Alt+Shift+Cmd+-"], &[], Bound("pref.uiScaleDown"), "Accessibility at Figma"),
@@ -181,6 +181,7 @@ static ROWS: &[Row] = &[
pin(Zoom, "view.nextFrame", &["N", "End"], &[]),
// Text
doc(Text, "Bold", &["Cmd+B"], &[], Bound("text.bold"), ""),
doc(Text, "Activate a focused link", &["Alt+Enter"], &[], Bound("text.openLink"), "while editing: the link at the caret; web and mail links only"),
doc(Text, "Italic", &["Cmd+I"], &[], Bound("text.italic"), ""),
doc(Text, "Underline", &["Cmd+U"], &[], Bound("text.underline"), ""),
doc(Text, "Strikethrough", &["Shift+Cmd+X"], &[], Bound("text.strikethrough"), ""),
@@ -412,7 +413,7 @@ fn counts_match_the_docs() {
let reserved = documented.iter().filter(|r| !r.reserved.is_empty()).count();
let pinned = ROWS.len() - documented.len();
// docs/ui-parity.md quotes these.
assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (133, 132, 1, 0, 2, 57));
assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (134, 133, 1, 0, 2, 57));
}
#[test]
+69 -1
View File
@@ -365,12 +365,80 @@ fn max_lines_on_auto_height_text_hugs_that_many_lines() {
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.
// Fixed-size 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);
}
#[test]
fn max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines() {
// "Max lines is available only if truncate text is enabled, and: Text resizing is set to
// auto height or auto width" (Explore text properties): an auto-width text with max lines
// stays auto width, growing with its longest line and hugging up to that many lines.
use figcraft_doc::TextAutoResize;
// The synthetic font: every character 8 px wide at 16 px, so the widths are the same
// everywhere and never depend on what the system has installed.
let font = test_mono_font();
let mut s = Session::new();
let t = text(&mut s, "one\ntwo\nthree", json!({"fontFamily": font, "fontSize": 16, "lineHeight": 24}));
assert_eq!(resize_mode(&s, t), TextAutoResize::WidthAndHeight);
assert_eq!(size(&s, t), (40.0, 72.0), "three lines tall, as wide as 'three'");
s.execute("text.maxLines", &json!({"ids": [t], "lines": 2})).unwrap();
assert_eq!(resize_mode(&s, t), TextAutoResize::WidthAndHeight, "max lines keep auto width");
assert_eq!(size(&s, t), (32.0, 48.0), "two lines tall, as wide as the widest line it shows with its ellipsis (two…), not the hidden third");
assert_eq!(crate::inspect::node_json(s.doc().unwrap(), t, 0)["maxLines"], json!(2));
// Still auto width: a longer line widens the box.
s.execute("text.edit.begin", &json!({"id": t, "caret": 3})).unwrap();
s.execute("text.edit.insert", &json!({"text": " and a longer line"})).unwrap();
crate::textedit::end(&mut s).unwrap();
assert_eq!(size(&s, t), (168.0, 48.0), "the box grew with its longest line (21 characters)");
assert_eq!(resize_mode(&s, t), TextAutoResize::WidthAndHeight);
// No limit: all of its lines again.
s.execute("node.set", &json!({"id": t, "props": {"maxLines": 0}})).unwrap();
assert_eq!(size(&s, t).1, 72.0);
}
#[test]
fn superscript_and_subscript_are_synthesised_without_glyphs_for_them() {
// "If it isn't provided, Figma draws a synthesized version by using the font's standard
// version of the character, shrinking it, and positioning it accordingly" (Explore text
// properties). The synthetic font has no sups or subs feature and no OS/2 metrics, so the
// usual 0.65 em at 0.35 em up (superscript) or 0.14 em down (subscript) apply.
let font = test_mono_font();
let mut s = Session::new();
let t = text(&mut s, "x2", json!({"fontFamily": font, "fontSize": 40}));
let glyphs = |s: &Session| -> Vec<(f64, f64, f64)> {
let (l, _) = figcraft_text::layout_node(s.doc().unwrap().node(t).unwrap()).unwrap();
l.glyphs.iter().map(|g| (g.size, g.y, g.advance)).collect()
};
let plain = glyphs(&s);
assert_eq!(plain.len(), 2);
assert_eq!((plain[0].0, plain[0].2), (40.0, 20.0));
assert_eq!(size(&s, t).0, 40.0);
s.execute("text.setFeatures", &json!({"ids": [t], "features": {"sups": true}})).unwrap();
let sup = glyphs(&s);
for (g, p) in sup.iter().zip(&plain) {
assert!((g.0 - 26.0).abs() < 1e-6 && (g.2 - 13.0).abs() < 1e-6, "shrunk to 0.65 em, the advance with it: {g:?}");
assert!((p.1 - g.1 - 14.0).abs() < 1e-6, "raised 0.35 em: {g:?} against {p:?}");
}
assert!((size(&s, t).0 - 26.0).abs() < 1e-6, "the auto-width box hugs the smaller glyphs: {:?}", size(&s, t));
s.execute("text.setFeatures", &json!({"ids": [t], "features": {"sups": null, "subs": true}})).unwrap();
let sub = glyphs(&s);
for (g, p) in sub.iter().zip(&plain) {
assert!((g.0 - 26.0).abs() < 1e-6 && (g.1 - p.1 - 5.6).abs() < 1e-6, "shrunk, and lowered 0.14 em: {g:?} against {p:?}");
}
// Capitalised, the same: the synthesis follows the characters shaping saw.
s.execute("node.set", &json!({"id": t, "props": {"textCase": "upper"}})).unwrap();
s.execute("text.setFeatures", &json!({"ids": [t], "features": {"subs": null, "sups": true}})).unwrap();
for (g, p) in glyphs(&s).iter().zip(&plain) {
assert!((g.0 - 26.0).abs() < 1e-6 && (p.1 - g.1 - 14.0).abs() < 1e-6, "upper case, superscript: {g:?} against {p:?}");
}
s.execute("node.set", &json!({"id": t, "props": {"textCase": "original"}})).unwrap();
s.execute("text.setFeatures", &json!({"ids": [t], "features": {"sups": null, "subs": false}})).unwrap();
assert_eq!(glyphs(&s), plain, "off again");
}
/// A synthetic monospaced TrueType font (outline-less glyphs, every printable ASCII character 500 of
/// 1000 units wide), loaded into the font database so text measures the same on every machine.
fn test_mono_font() -> &'static str {
+42
View File
@@ -0,0 +1,42 @@
//! Alt+Enter while editing text activates the link at the caret ("When editing text, press
//! ⌥Option Return/Alt Enter to activate a focused link", Accessibility at Figma): `text.openLink`
//! hands a web or mail link at the caret to the UI, refuses other schemes, and does nothing where
//! there is no link.
use serde_json::json;
use crate::Session;
use crate::params::id_of;
#[test]
fn the_link_at_the_caret_goes_to_the_ui_and_only_a_web_or_mail_link() {
let mut s = Session::new();
let t = id_of(&s.execute("text.create", &json!({"text": "See the docs now", "x": 0, "y": 0})).unwrap()["id"]).unwrap();
s.execute("text.style", &json!({"id": t, "start": 4, "end": 12, "hyperlink": "https://example.com/docs"})).unwrap();
assert!(s.execute("text.openLink", &json!({})).is_err(), "nothing to activate while no text is edited");
s.execute("text.edit.begin", &json!({"id": t, "caret": 6})).unwrap();
let r = s.execute("text.openLink", &json!({})).unwrap();
assert_eq!(r["url"], "https://example.com/docs");
let req = s.ui_requests.iter().find(|r| r.kind == "open.url").expect("a UI request to open it");
assert_eq!(req.data["url"], "https://example.com/docs");
assert!(s.ds().unwrap().text_edit.is_some(), "still editing");
// Right after the link's last char the caret still counts as on it; before its start it doesn't.
s.execute("text.edit.caret", &json!({"index": 12})).unwrap();
assert_eq!(s.execute("text.openLink", &json!({})).unwrap()["url"], "https://example.com/docs");
s.execute("text.edit.caret", &json!({"index": 2})).unwrap();
assert!(s.execute("text.openLink", &json!({})).unwrap()["url"].is_null(), "no link there");
// A selection: its start decides.
s.execute("text.edit.caret", &json!({"index": 14})).unwrap();
s.execute("text.edit.caret", &json!({"index": 8, "extend": true})).unwrap();
assert_eq!(s.execute("text.openLink", &json!({})).unwrap()["url"], "https://example.com/docs");
// Mail links open; anything that isn't a web or mail link is refused and nothing is requested.
s.execute("text.style", &json!({"id": t, "start": 4, "end": 12, "hyperlink": "mailto:hello@example.com"})).unwrap();
s.execute("text.edit.caret", &json!({"index": 6})).unwrap();
assert_eq!(s.execute("text.openLink", &json!({})).unwrap()["url"], "mailto:hello@example.com");
let before = s.ui_requests.len();
for bad in ["javascript:alert(1)", "file:///etc/passwd", "example.com/docs"] {
s.execute("text.style", &json!({"id": t, "start": 4, "end": 12, "hyperlink": bad})).unwrap();
assert!(s.execute("text.openLink", &json!({})).is_err(), "{bad} is refused");
}
assert_eq!(s.ui_requests.len(), before, "nothing requested for a refused link");
}
+22
View File
@@ -129,6 +129,28 @@ fn list_keys_at_list_items() {
"silent: Backspace at an item's start removes its bullet",
),
("a\nbc", vec![(B, 0), (B, 0)], 4, "Backspace", false, "a\nb", vec![(B, 0), (B, 0)], "Backspace inside an item deletes text"),
(
"a\nb",
vec![(B, 0), (B, 1)],
2,
"Backspace",
false,
"a\nb",
vec![(B, 0), (P, 1)],
"Backspace at a nested item's start removes its bullet and keeps its level",
),
("a\nb", vec![(N, 0), (N, 2)], 2, "Delete", false, "a\nb", vec![(N, 0), (P, 2)], "Delete at an item's start does the same"),
("a\nbc", vec![(B, 0), (B, 1)], 3, "Delete", false, "a\nb", vec![(B, 0), (B, 1)], "Delete inside an item deletes text"),
(
"a\nb",
vec![(B, 0), (P, 1)],
2,
"Tab",
false,
"a\n\tb",
vec![(B, 0), (P, 1)],
"a plain paragraph at a level isn't an item: Tab types a tab",
),
("a\nb", vec![(P, 0), (P, 0)], 3, "Tab", false, "a\nb\t", vec![(P, 0), (P, 0)], "outside a list Tab types a tab"),
("a", vec![(B, 8)], 1, "Tab", false, "a", vec![(B, 8)], "silent: nesting stops at 8 levels"),
];
+4
View File
@@ -38,6 +38,9 @@ pub struct ViewState {
pub snap_objects: bool,
pub snap_pixel: bool,
pub ui_hidden: bool,
/// Minimized interface (Shift+Cmd/Ctrl+\): the navigation bar and both sidebars are collapsed
/// and the toolbar stays; the right sidebar shows while something is selected.
pub ui_minimized: bool,
}
impl Default for ViewState {
@@ -67,6 +70,7 @@ impl Default for ViewState {
snap_objects: true,
snap_pixel: true,
ui_hidden: false,
ui_minimized: false,
}
}
}