diff --git a/Cargo.lock b/Cargo.lock index 797f6498d..888365316 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1763,7 +1763,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] name = "figcraft" -version = "0.13.1" +version = "0.14.0" dependencies = [ "arboard", "craft-menu", @@ -1795,7 +1795,7 @@ dependencies = [ [[package]] name = "figcraft-cli" -version = "0.13.1" +version = "0.14.0" dependencies = [ "clap", "clap_complete", @@ -1813,7 +1813,7 @@ dependencies = [ [[package]] name = "figcraft-collab" -version = "0.13.1" +version = "0.14.0" dependencies = [ "base64 0.22.1", "chrono", @@ -1831,7 +1831,7 @@ dependencies = [ [[package]] name = "figcraft-collab-net" -version = "0.13.1" +version = "0.14.0" dependencies = [ "base64 0.22.1", "figcraft-collab", @@ -1854,7 +1854,7 @@ dependencies = [ [[package]] name = "figcraft-color" -version = "0.13.1" +version = "0.14.0" dependencies = [ "kurbo", "serde", @@ -1863,7 +1863,7 @@ dependencies = [ [[package]] name = "figcraft-doc" -version = "0.13.1" +version = "0.14.0" dependencies = [ "figcraft-color", "figcraft-geom", @@ -1876,7 +1876,7 @@ dependencies = [ [[package]] name = "figcraft-engine" -version = "0.13.1" +version = "0.14.0" dependencies = [ "base64 0.22.1", "craft-docpath", @@ -1910,7 +1910,7 @@ dependencies = [ [[package]] name = "figcraft-format" -version = "0.13.1" +version = "0.14.0" dependencies = [ "figcraft-doc", "figcraft-layout", @@ -1927,7 +1927,7 @@ dependencies = [ [[package]] name = "figcraft-geom" -version = "0.13.1" +version = "0.14.0" dependencies = [ "kurbo", "serde", @@ -1935,7 +1935,7 @@ dependencies = [ [[package]] name = "figcraft-layout" -version = "0.13.1" +version = "0.14.0" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1943,7 +1943,7 @@ dependencies = [ [[package]] name = "figcraft-mcp" -version = "0.13.1" +version = "0.14.0" dependencies = [ "base64 0.22.1", "figcraft-collab-net", @@ -1959,7 +1959,7 @@ dependencies = [ [[package]] name = "figcraft-pathops" -version = "0.13.1" +version = "0.14.0" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1968,7 +1968,7 @@ dependencies = [ [[package]] name = "figcraft-pdf" -version = "0.13.1" +version = "0.14.0" dependencies = [ "figcraft-color", "figcraft-doc", @@ -1985,7 +1985,7 @@ dependencies = [ [[package]] name = "figcraft-plugin" -version = "0.13.1" +version = "0.14.0" dependencies = [ "figcraft-color", "serde", @@ -1998,7 +1998,7 @@ dependencies = [ [[package]] name = "figcraft-render" -version = "0.13.1" +version = "0.14.0" dependencies = [ "crc32fast", "figcraft-color", @@ -2017,7 +2017,7 @@ dependencies = [ [[package]] name = "figcraft-server" -version = "0.13.1" +version = "0.14.0" dependencies = [ "chrono", "crc32fast", @@ -2042,7 +2042,7 @@ dependencies = [ [[package]] name = "figcraft-svg" -version = "0.13.1" +version = "0.14.0" dependencies = [ "base64 0.22.1", "figcraft-color", @@ -2058,7 +2058,7 @@ dependencies = [ [[package]] name = "figcraft-text" -version = "0.13.1" +version = "0.14.0" dependencies = [ "core-text", "craft-font-catalog", @@ -2076,7 +2076,7 @@ dependencies = [ [[package]] name = "figcraft-ui-egui" -version = "0.13.1" +version = "0.14.0" dependencies = [ "base64 0.22.1", "chrono", @@ -2104,7 +2104,7 @@ dependencies = [ [[package]] name = "figcraft-vectordrawable" -version = "0.13.1" +version = "0.14.0" dependencies = [ "figcraft-color", "figcraft-doc", @@ -2118,7 +2118,7 @@ dependencies = [ [[package]] name = "figcraft-web" -version = "0.13.1" +version = "0.14.0" dependencies = [ "base64 0.22.1", "eframe", @@ -6739,7 +6739,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.13.1" +version = "0.14.0" dependencies = [ "figcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index a560971a8..c7a86dd21 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ exclude = ["plugins"] # default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"] [workspace.package] -version = "0.13.1" +version = "0.14.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" diff --git a/README.md b/README.md index 63c8fb3d1..012ca857a 100644 --- a/README.md +++ b/README.md @@ -314,14 +314,22 @@ stand on its own. | | App | What it's for | Code | Learn more | |:-:|---|---|---|---| -| | **FigCraft** | **Interface design, components and prototypes · you are here** | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) | -| | **PhotoCraft** | Image editing | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) | +| | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) | | | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) | | | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) | | | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) | | | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) | | | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) | | | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) | +| | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) | +| | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) | +| | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) | +| | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) | +| | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) | +| | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) | +| | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) | +| | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) | +| | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | | And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control. diff --git a/apps/figcraft-cli/examples/man-and-completions.rs b/apps/figcraft-cli/examples/man-and-completions.rs index c67827477..2b119d6a8 100644 --- a/apps/figcraft-cli/examples/man-and-completions.rs +++ b/apps/figcraft-cli/examples/man-and-completions.rs @@ -6,7 +6,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #[path = "../src/args.rs"] -#[expect(dead_code, reason = "Generation uses clap's command metadata, not the parsed fields or runtime parser.")] +#[cfg_attr(not(test), expect(dead_code, reason = "Generation uses clap's command metadata, not the parsed fields or runtime parser."))] mod args; use std::path::Path; diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 6ec7e8245..3ac81afdb 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -942,9 +942,9 @@ impl Node { matches!(&self.kind, NodeKind::Vector { network } if network.vertices.iter().any(|v| v.cap.is_some_and(StrokeCap::is_arrow))); let caps = if self.stroke.cap_start.is_arrow() || self.stroke.cap_end.is_arrow() || point_arrows { w.max(self.stroke.weight) * 3.0 } else { w }; - // Right-angled or smooth outlines (boxes, ellipses) end their mitres on the grown box. - let boxy = matches!(self.kind, NodeKind::Rectangle | NodeKind::Ellipse { .. } | NodeKind::Section { .. }) || self.kind.is_frame_like(); - let miter = if boxy { 1.0 } else { self.stroke.miter_limit.clamp(1.0, 4.0) }; + // Right-angled or smooth outlines (boxes, whole ellipses) end their mitres on the grown + // box; an arc's corners don't. + let miter = if self.grows_by_exactly_its_spread() { 1.0 } else { self.stroke.miter_limit.clamp(1.0, 4.0) }; base.max(if matches!(self.kind, NodeKind::Line | NodeKind::Vector { .. }) { caps } else { 0.0 }) * miter } else { 0.0 @@ -960,14 +960,19 @@ impl Node { !(matches!(self.kind, NodeKind::Group) || (matches!(self.kind, NodeKind::Text(_)) && self.has_visible_stroke())) } - /// Whether a spread grows this node's outline by exactly the spread (no sharp corners). + /// Whether a spread grows this node's outline by exactly the spread (no sharp corners). An arc + /// (an ellipse that doesn't go all the way round) has corners where its ends meet its sides. fn grows_by_exactly_its_spread(&self) -> bool { - matches!(self.kind, NodeKind::Rectangle | NodeKind::Ellipse { .. } | NodeKind::Section { .. }) || self.kind.is_frame_like() + match self.kind { + NodeKind::Rectangle | NodeKind::Section { .. } => true, + NodeKind::Ellipse { sweep, .. } => figcraft_geom::full_sweep(sweep), + _ => self.kind.is_frame_like(), + } } /// How much further than its spread a drop shadow can reach at the outline's sharp corners: /// spread moves the edges out in parallel and they meet in a mitre (see - /// [`SPREAD_MITER_LIMIT`]). Rectangles, frames and ellipses grow by exactly the spread. + /// [`SPREAD_MITER_LIMIT`]). Rectangles, frames and whole ellipses grow by exactly the spread. fn spread_miter_reach(&self) -> f64 { if self.grows_by_exactly_its_spread() { return 0.0; diff --git a/crates/engine/src/al_nesting_tests.rs b/crates/engine/src/al_nesting_tests.rs index 6ebcc295b..f5d607ecf 100644 --- a/crates/engine/src/al_nesting_tests.rs +++ b/crates/engine/src/al_nesting_tests.rs @@ -318,3 +318,30 @@ fn fill_text_in_a_hugging_card_sets_the_height_in_one_step() { // Whatever the fonts, the card hugs the wrapped text exactly. assert!((cb.height() - (tb.height() + 20.0)).abs() < 1e-6, "card {cb:?} text {tb:?}"); } + +/// "To remove auto layout from a frame and all of its nested objects at once, right-click on the +/// frame and go to More layout options > Remove all auto layout." (Toggle on auto layout in designs) +#[test] +fn remove_all_auto_layout_strips_the_frame_and_every_frame_nested_in_it() { + let mut s = Session::new(); + let outer = frame(&mut s, None, 0.0, 0.0, 300.0, 100.0); + let inner = frame(&mut s, Some(outer), 0.0, 0.0, 100.0, 50.0); + let deep = frame(&mut s, Some(inner), 0.0, 0.0, 40.0, 20.0); + rect(&mut s, Some(deep), 0.0, 0.0, 10.0, 10.0); + for (f, dir) in [(outer, "horizontal"), (inner, "vertical"), (deep, "horizontal")] { + s.execute("node.set", &json!({"id": f, "props": {"layout": dir}})).unwrap(); + } + let laid_out = |s: &Session| [outer, inner, deep].map(|n| s.doc().unwrap().node(n).unwrap().layout.is_some()); + // Remove auto layout takes it off the frame alone. + s.execute("layout.remove", &json!({"ids": [outer]})).unwrap(); + assert_eq!(laid_out(&s), [false, true, true]); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(laid_out(&s), [true, true, true]); + let steps = s.ds().unwrap().history.undo.len(); + let r = s.execute("layout.removeAll", &json!({"ids": [outer]})).unwrap(); + assert_eq!(laid_out(&s), [false, false, false], "every nested frame too"); + assert_eq!(r["removed"].as_array().map(Vec::len), Some(3)); + assert_eq!(s.ds().unwrap().history.undo.len(), steps + 1, "one undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(laid_out(&s), [true, true, true]); +} diff --git a/crates/engine/src/assets/components/tests.rs b/crates/engine/src/assets/components/tests.rs index 82aabc63f..ffaac176f 100644 --- a/crates/engine/src/assets/components/tests.rs +++ b/crates/engine/src/assets/components/tests.rs @@ -144,7 +144,6 @@ fn instances_refuse_structure_edits() { let before = s.doc().unwrap().clone(); let undo = s.ds().unwrap().history.undo.len(); let refused = [ - ("edit.delete", json!({"ids": [ibg]})), ("shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5, "parent": i})), ("node.reorder", json!({"id": idot, "index": 0})), ("object.group", json!({"ids": [ibg, idot]})), @@ -158,6 +157,11 @@ fn instances_refuse_structure_edits() { assert_eq!(*s.doc().unwrap(), before, "{cmd} rolled back"); } assert_eq!(s.ds().unwrap().history.undo.len(), undo); + // Delete on a layer inside hides it instead (an override), as the reference tool does. + ex(&mut s, "edit.delete", json!({"ids": [ibg]})); + assert!(s.doc().unwrap().node(ibg).is_some_and(|n| !n.visible), "hidden, not removed"); + assert_eq!(s.doc().unwrap().node(i).unwrap().children, before.node(i).unwrap().children, "the structure is intact"); + healthy(&s); // Deleting the whole instance is fine. ex(&mut s, "edit.delete", json!({"ids": [i]})); healthy(&s); diff --git a/crates/engine/src/canvas/textedit.rs b/crates/engine/src/canvas/textedit.rs index 91e411e54..d7a70e5c7 100644 --- a/crates/engine/src/canvas/textedit.rs +++ b/crates/engine/src/canvas/textedit.rs @@ -277,14 +277,15 @@ fn insert_model(s: &mut Session, text: &str, ime_commit: bool) -> Result = t.characters.chars().collect(); let start = line_start(&chars, a); let typed: String = chars.get(start..a).unwrap_or(&[]).iter().collect(); let list = match typed.as_str() { "-" | "*" => ListStyle::Bulleted, - "1." => ListStyle::Numbered, + "1." | "1)" => ListStyle::Numbered, _ => ListStyle::None, }; if list != ListStyle::None { diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 3f72e429e..700fa7414 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -544,6 +544,15 @@ fn delete(s: &mut Session, p: &Value) -> Result { let ids = req_targets("edit.delete", s, p)?; let doc = s.doc_mut()?; let ids = roots(doc, &ids); + // A layer inside an instance can't be removed: Delete hides it instead, an override ("Figma + // will only toggle the layer's visibility instead of removing it", Guide to auto layout). + let (hidden, ids): (Vec<_>, Vec<_>) = ids.into_iter().partition(|i| crate::assets::components::inside_instance(doc, *i)); + for i in &hidden { + doc.get_mut(*i)?.visible = false; + } + if ids.is_empty() { + return Ok(json!({"deleted": [], "hidden": hidden})); + } let mut parents = Vec::new(); for i in &ids { if doc.node(*i).is_some_and(Node::is_page) { @@ -556,7 +565,7 @@ fn delete(s: &mut Session, p: &Value) -> Result { } // Select the parent frame like Figma? Figma clears the selection. s.set_selection(vec![]); - Ok(json!({"deleted": ids})) + Ok(json!({"deleted": ids, "hidden": hidden})) } /// Layers duplicated last (Duplicate or Alt-drag) and their copies, in the same order. While the diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index fe7fdcd74..fbf80fc1b 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -759,6 +759,14 @@ fn apply_sizing(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) - other => Some(f64_of(other).ok_or_else(|| bad(cmd, format!("`{k}` must be a number or null")))?.clamp(0.0, 1e7)), }; set_limit(&mut doc.get_mut(id)?.child_layout, k, val); + // A text has a max height or a number of max lines, not both: a max height sets the + // lines back to Auto (Guide to auto layout). + if k == "maxHeight" + && val.is_some() + && let Some(t) = doc.get_mut(id)?.kind.text_mut() + { + t.max_lines = 0; + } } "columnTracks" | "gridColumnTracks" | "rowTracks" | "gridRowTracks" => { let a = v.as_array().ok_or_else(|| bad(cmd, format!("`{k}` must be an array of tracks")))?; @@ -900,7 +908,16 @@ fn apply_text(cmd: &str, doc: &mut Document, id: NodeId, props: &Map text_mut(cmd, doc.get_mut(id)?)?.leading_trim = enum_of(v).ok_or_else(|| bad(cmd, "leadingTrim: none|capHeight"))?, - "maxLines" => text_mut(cmd, doc.get_mut(id)?)?.max_lines = f64_of(v).unwrap_or(0.0).clamp(0.0, 10_000.0) as u32, + "maxLines" => { + let lines = f64_of(v).unwrap_or(0.0).clamp(0.0, 10_000.0) as u32; + let n = doc.get_mut(id)?; + text_mut(cmd, n)?.max_lines = lines; + // A text has a max height or a number of max lines, not both: a number of lines takes + // the max height away (Guide to auto layout). + if lines > 0 { + n.child_layout.max_h = None; + } + } "features" | "textFeatures" | "fontFeatures" | "openTypeFeatures" => { let f = features_of(cmd, v)?; let t = text_mut(cmd, doc.get_mut(id)?)?; diff --git a/crates/engine/src/cmd/proto.rs b/crates/engine/src/cmd/proto.rs index df80764a3..dc30e933b 100644 --- a/crates/engine/src/cmd/proto.rs +++ b/crates/engine/src/cmd/proto.rs @@ -137,7 +137,7 @@ pub fn specs() -> Vec { spec!( "proto.presentOptions", "Presentation options", - "{scale? fit|fitWidth|fill|actual, deviceFrame? bool, hotspotHints? bool, inspect? bool} how the presenter sizes the prototype (Fit scales down to fit, Fit width fills the width, Fill covers the window, Actual shows it at 100%), whether it draws the device, whether a click where nothing reacts flashes the hotspots (on by default) and whether the Inspect panel shows the properties of the layer under the pointer; kept for later presentations", + "{scale? fit|fitWidth|fill|actual, deviceFrame? bool, hotspotHints? bool, inspect? bool, hideUi? bool} how the presenter sizes the prototype (Fit scales down to fit, Fit width fills the width, Fill covers the window, Actual shows it at 100%), whether it draws the device, whether a click where nothing reacts flashes the hotspots (on by default), whether the Inspect panel shows the properties of the layer under the pointer, and whether the bars are hidden (Hide UI: the prototype alone; Cmd/Ctrl+\\ shows them again); kept for later presentations", false, |s, p| { let cmd = "proto.presentOptions"; @@ -146,13 +146,13 @@ pub fn specs() -> Vec { let scale = v.as_str().filter(|x| PRESENT_SCALES.contains(x)).ok_or_else(|| bad(cmd, "scale: fit|fitWidth|fill|actual"))?; data.insert("scale".into(), json!(scale)); } - for key in ["deviceFrame", "hotspotHints", "inspect"] { + for key in ["deviceFrame", "hotspotHints", "inspect", "hideUi"] { if let Some(v) = p.get(key).filter(|v| !v.is_null()) { data.insert(key.into(), json!(v.as_bool().ok_or_else(|| bad(cmd, format!("{key}: true|false")))?)); } } if data.is_empty() { - return Err(bad(cmd, "nothing to set: scale, deviceFrame, hotspotHints or inspect")); + return Err(bad(cmd, "nothing to set: scale, deviceFrame, hotspotHints, inspect or hideUi")); } s.request_ui("present.options", Value::Object(data)); ok() diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index 52b4a415d..693465d81 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -83,6 +83,32 @@ pub fn specs() -> Vec { } ok() }), + spec!( + "layout.removeAll", + "Remove all auto layout", + "{ids?} takes the auto layout off the frames and every frame nested in them at once (More layout options ▸ Remove all auto layout); instances and the layers inside them keep theirs", + true, + |s, p| { + let ids = req_targets("layout.removeAll", s, p)?; + let doc = s.doc_mut()?; + let mut removed = Vec::new(); + for root in ids { + for i in doc.subtree(root) { + let strip = doc.node(i).is_some_and(|n| n.layout.is_some() && !matches!(n.kind, figcraft_doc::NodeKind::Instance { .. })) + && !crate::assets::components::inside_instance(doc, i); + if !strip { + continue; + } + let n = doc.get_mut(i)?; + n.layout = None; + super::props::drop_layout_bindings(n); + figcraft_layout::coherent_sizing(doc, i); + removed.push(i); + } + } + Ok(json!({"removed": removed})) + } + ), spec!("layout.suggest", "Suggest auto layout", "{ids?, direction? (horizontal|vertical|grid)}", true, layout_add), spec!("object.moveToPage", "Move to page", "{ids?, page}", true, move_to_page), spec!("object.setThumbnail", "Set as thumbnail", "{id?}", true, |s, p| { diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index ec219db36..b507afd6b 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -101,7 +101,9 @@ fn all_have(s: &Session, p: &Value, pred: impl Fn(&TextStyle) -> bool) -> bool { } return false; } - targets(s, p).iter().all(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Text(t)) if t.spans().iter().all(|(_, _, st)| pred(st)))) + // The texts `style` restyles: the targets and the texts inside them. + let texts: Vec<&TextData> = targets(s, p).iter().flat_map(|i| doc.subtree(*i)).filter_map(|d| doc.node(d).and_then(|n| n.kind.text())).collect(); + !texts.is_empty() && texts.iter().all(|t| t.spans().iter().all(|(_, _, st)| pred(st))) } fn toggle_bold(s: &mut Session, p: &Value) -> Result { @@ -217,10 +219,24 @@ pub fn specs() -> Vec { let on = all_have(s, p, |st| st.decoration == TextDecoration::Strikethrough); style(s, p, |st| st.decoration = if on { TextDecoration::None } else { TextDecoration::Strikethrough }) }), - spec!("text.link", "Create link", "{url, ids?}", true, |s, p| { - let url = req_string("text.link", p, "url")?; - style(s, p, |st| st.link = Some(url.clone())) - }), + spec!( + "text.link", + "Create link", + "{url, ids?} linked text is underlined unless it has a decoration of its own (Underline takes it off again)", + true, + |s, p| { + let url = req_string("text.link", p, "url")?; + style(s, p, |st| { + // "Links are styled with an underline by default" (Add links to text); Underline + // (Cmd/Ctrl+U) removes it, as documented there, and changing the URL of a link + // leaves its decoration as it is. + if st.link.is_none() && st.decoration == TextDecoration::None { + st.decoration = TextDecoration::Underline; + } + st.link = Some(url.clone()); + }) + } + ), spec!("text.bulletList", "Bulleted list", "{ids?} while editing: the selected paragraphs", true, |s, p| set_list( s, p, @@ -307,12 +323,30 @@ pub fn specs() -> Vec { true, |s, p| { let v = finite(req_num("text.maxLines", p, "lines")?).clamp(0.0, 10_000.0) as u32; - para(s, p, |t| { + let r = para(s, p, |t| { t.max_lines = v; if v > 0 && t.auto_resize != TextAutoResize::Height { t.auto_resize = TextAutoResize::Truncate; } - }) + })?; + // A text has a max height or a number of max lines, not both (Guide to auto layout). + if v > 0 { + let ids = match textedit::selected_range(s) { + Some((id, _, _)) => vec![id], + None => targets(s, p), + }; + let doc = s.doc_mut()?; + for i in ids { + for d in doc.subtree(i) { + if let Some(n) = doc.node_mut(d) + && n.kind.text().is_some() + { + n.child_layout.max_h = None; + } + } + } + } + Ok(r) } ), spec!( diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index c7fea9f92..1fc2ef22a 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -194,6 +194,22 @@ fn region(s: &mut Session, step: i64) -> Result { ok() } +/// Step the interface size through the Preferences sizes: `dir` 1 up, -1 down, 0 back to the +/// system's size (1). At an end it stays. Kept at once, as a size picked in Preferences is. +fn ui_scale_step(s: &mut Session, dir: i32) -> Result { + let now = s.prefs.ui_scale; + let next = match dir { + 0 => 1.0, + 1 => crate::UI_SCALE_SIZES.iter().copied().find(|v| *v > now + 1e-9).unwrap_or(now), + _ => crate::UI_SCALE_SIZES.iter().rev().copied().find(|v| *v < now - 1e-9).unwrap_or(now), + }; + if (next - now).abs() > 1e-9 { + s.prefs.ui_scale = next; + s.prefs_unsaved = true; + } + Ok(json!({"scale": s.prefs.ui_scale})) +} + fn pref(s: &mut Session, p: &Value, f: fn(&mut crate::Prefs) -> &mut bool) -> Result { let slot = f(&mut s.prefs); *slot = boolean(p, "on").unwrap_or(!*slot); @@ -395,6 +411,21 @@ pub fn specs() -> Vec { Ok(json!({"scale": s.prefs.ui_scale})) } ), + spec!( + "pref.uiScaleUp", + "Larger interface", + "{} the next of the Preferences sizes above the current one (the interface size keys of Accessibility at Figma); at the largest it stays → {scale}", + false, + |s, _| ui_scale_step(s, 1) + ), + spec!( + "pref.uiScaleDown", + "Smaller interface", + "{} the next of the Preferences sizes below the current one; at the smallest it stays → {scale}", + false, + |s, _| ui_scale_step(s, -1) + ), + spec!("pref.uiScaleReset", "Default interface size", "{} back to the system's own size (1) → {scale}", false, |s, _| ui_scale_step(s, 0)), spec!("pref.autoSave", "Save files automatically", "{on?}", false, |s, p| pref(s, p, |x| &mut x.auto_save)), spec!("pref.startOnHome", "Start on the Home screen", "{on?}", false, |s, p| pref(s, p, |x| &mut x.start_on_home)), spec!("pref.nudge", "Nudge amount", "{small?, big?}", false, |s, p| { diff --git a/crates/engine/src/components_behaviour_tests.rs b/crates/engine/src/components_behaviour_tests.rs index a95e1268c..6d5a7ebcb 100644 --- a/crates/engine/src/components_behaviour_tests.rs +++ b/crates/engine/src/components_behaviour_tests.rs @@ -512,7 +512,6 @@ fn what_instances_cannot_override_is_refused() { ), ("remove auto layout", "layout.remove", json!({"ids": ["$"]}), Layer::Row, "structure of the instance"), ("reorder layers", "node.reorder", json!({"id": "$", "index": 3}), Layer::Bg, "\"the order, or z-index, of any layers within the instance\""), - ("delete a layer", "edit.delete", json!({"ids": ["$"]}), Layer::Bg, "layers can only be hidden"), ( "add a layer", "shape.rectangle", @@ -969,3 +968,26 @@ fn a_new_component_set_is_named_after_its_first_variant() { assert_eq!(node(&s, c).name, "State=Default"); healthy(&s); } + +/// "You can't delete a layer or object from an instance. If you try, Figma will only toggle the +/// layer's visibility instead of removing it." (Guide to auto layout; the flows article says the +/// same). Delete hides the layer, an override; the main component is untouched. +#[test] +fn deleting_a_layer_inside_an_instance_hides_it_instead() { + let mut s = Session::new(); + let k = kit(&mut s); + let target = k.copy(&s, Layer::Bg); + let steps = undo_len(&s); + let r = s.execute("edit.delete", &json!({"ids": [target]})).unwrap(); + assert_eq!((r["hidden"].clone(), r["deleted"].clone()), (json!([target]), json!([]))); + let doc = s.doc().unwrap(); + assert!(doc.node(target).is_some_and(|n| !n.visible), "hidden, not removed"); + assert!(doc.node(k.bg).is_some_and(|n| n.visible), "the main is untouched"); + assert_eq!(doc.node(k.inst).unwrap().children.len(), doc.node(k.card).unwrap().children.len(), "the structure is intact"); + assert_eq!(undo_len(&s), steps + 1, "one undo step"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().node(target).is_some_and(|n| n.visible)); + // The instance itself still goes away. + s.execute("edit.delete", &json!({"ids": [k.inst]})).unwrap(); + assert!(s.doc().unwrap().node(k.inst).is_none()); +} diff --git a/crates/engine/src/keymap.rs b/crates/engine/src/keymap.rs index baa3885c5..a0f0bc4b6 100644 --- a/crates/engine/src/keymap.rs +++ b/crates/engine/src/keymap.rs @@ -212,7 +212,13 @@ pub static KEYS: &[Binding] = &[ k(View, "view.comments", &["Shift+C"]), k(View, "view.multiplayerCursors", &["Alt+Cmd+\\"]), k(View, "view.snapPixel", &["Shift+Cmd+'"]), - k(View, "view.layers", &["Alt+1"]).label("Layers panel"), + // Windows: one help-center article writes Ctrl+1, another Alt+1; both open it there. + k(View, "view.layers", &["Alt+1"]).win(&["Alt+1", "Ctrl+1"]).label("Layers panel"), + // The interface size (Accessibility at Figma): Cmd/Ctrl+Shift+Alt with =, - and 0, while + // editing text too. + k(View, "pref.uiScaleUp", &["Alt+Shift+Cmd+="]).both(), + k(View, "pref.uiScaleDown", &["Alt+Shift+Cmd+-"]).both(), + k(View, "pref.uiScaleReset", &["Alt+Shift+Cmd+0"]).both(), k(View, "view.assets", &["Alt+2"]).label("Assets panel"), k(View, "libraries.open", &["Alt+3"]), k(View, "view.design", &["Alt+8"]).label("Design panel"), @@ -345,6 +351,7 @@ pub static KEYS: &[Binding] = &[ pub static GESTURES: &[(Group, &str, &str, &str)] = &[ (Tools, "Pan the canvas", "Space drag", "Space+drag"), (Tools, "Pan with nothing selected (Shift: farther)", "← ↑ → ↓", "← ↑ → ↓"), + (Transform, "Rotate a degree (Shift: 15)", "⌥⌘ ← ↑ → ↓", "Ctrl+Alt+← ↑ → ↓"), (Tools, "Place a shape or frame (its tool chosen)", "↵", "Enter"), (Zoom, "Zoom tool: click to zoom in, drag an area", "Hold Z", "Hold Z"), (Zoom, "Zoom tool: zoom out", "Hold ⌥Z", "Hold Alt+Z"), diff --git a/crates/engine/src/keymap_tests.rs b/crates/engine/src/keymap_tests.rs index bdf57c9da..67c5bf258 100644 --- a/crates/engine/src/keymap_tests.rs +++ b/crates/engine/src/keymap_tests.rs @@ -305,3 +305,56 @@ fn ctrl_tab_cycles_documents() { assert_eq!(lookup(Platform::Other, Context::Canvas, "Tab", mods(false, false, true, true)).map(|b| b.id), Some("doc.nextTab")); assert_eq!(lookup(Platform::Mac, Context::Canvas, "Tab", mods(true, false, false, true)).map(|b| b.id), Some("doc.previousTab")); } + +/// "Adjust the scale of the Figma UI [...] Windows: Reset to Default: Control ⇧Shift Alt 0; Make +/// Larger: Control ⇧Shift Alt =; Make Smaller: Control ⇧Shift Alt -" (Accessibility at Figma; ⌘ on +/// a Mac). The steps go through the sizes Preferences offers, stop at the ends, and are kept at once. +#[test] +fn the_interface_size_keys_step_through_the_preferences_sizes() { + let m = mods(true, true, true, false); + for p in PLATFORMS { + for c in [Context::Canvas, Context::Text] { + assert_eq!(id_for(p, c, "=", m), Some("pref.uiScaleUp"), "{p:?} {c:?}"); + assert_eq!(id_for(p, c, "-", m), Some("pref.uiScaleDown"), "{p:?} {c:?}"); + assert_eq!(id_for(p, c, "0", m), Some("pref.uiScaleReset"), "{p:?} {c:?}"); + } + } + let mut s = Session::new(); + let scale = |s: &Session| s.prefs.ui_scale; + ex(&mut s, "pref.uiScale", json!({"scale": 1})); + s.prefs_unsaved = false; + ex(&mut s, "pref.uiScaleUp", json!({})); + assert_eq!(scale(&s), 1.1); + assert!(s.prefs_unsaved, "kept at once"); + ex(&mut s, "pref.uiScaleUp", json!({})); + assert_eq!(scale(&s), 1.25); + ex(&mut s, "pref.uiScaleDown", json!({})); + assert_eq!(scale(&s), 1.1); + assert_eq!(ex(&mut s, "pref.uiScaleReset", json!({}))["scale"], json!(1.0)); + assert_eq!(scale(&s), 1.0); + // Between two sizes a step lands on the next one; at an end it stays. + ex(&mut s, "pref.uiScale", json!({"scale": 1.3})); + ex(&mut s, "pref.uiScaleUp", json!({})); + assert_eq!(scale(&s), 1.5); + ex(&mut s, "pref.uiScale", json!({"scale": 1.3})); + ex(&mut s, "pref.uiScaleDown", json!({})); + assert_eq!(scale(&s), 1.25); + ex(&mut s, "pref.uiScale", json!({"scale": 2})); + ex(&mut s, "pref.uiScaleUp", json!({})); + assert_eq!(scale(&s), 2.0); + ex(&mut s, "pref.uiScale", json!({"scale": 0.75})); + ex(&mut s, "pref.uiScaleDown", json!({})); + assert_eq!(scale(&s), 0.75); +} + +/// The Layers panel's Windows key is Ctrl+1 in one help-center article ("Mac: Option 1 Windows: +/// Ctrl 1", Select layers and objects) and Alt+1 in another (Explore the navigation bar and left +/// sidebar): both open it there. macOS keeps Option+1 alone; Cmd+1 is not documented. +#[test] +fn the_layers_panel_answers_both_of_its_documented_windows_keys() { + let (alt, ctrl, cmd) = (mods(false, true, false, false), mods(false, false, false, true), mods(false, false, true, false)); + assert_eq!(id_for(Platform::Other, Context::Canvas, "1", alt), Some("view.layers")); + assert_eq!(id_for(Platform::Other, Context::Canvas, "1", ctrl), Some("view.layers")); + assert_eq!(id_for(Platform::Mac, Context::Canvas, "1", alt), Some("view.layers")); + assert_eq!(id_for(Platform::Mac, Context::Canvas, "1", cmd), None); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1c0b7e39a..0da532b25 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -110,7 +110,7 @@ mod view_state; pub use derived::{changed_nodes, normalize}; pub use doc_state::{DocState, History, HistoryEntry, Interaction, PREVIEW_READ_ONLY, Preedit, TextEdit, VersionPreview}; pub use error::{EngineError, Result, bad}; -pub use prefs::{Clipboard, MAX_RECENT_ACTIONS, Prefs, UI_SCALE_MAX, UI_SCALE_MIN, UiRequest, clamp_ui_scale}; +pub use prefs::{Clipboard, MAX_RECENT_ACTIONS, Prefs, UI_SCALE_MAX, UI_SCALE_MIN, UI_SCALE_SIZES, UiRequest, clamp_ui_scale}; pub use session::Forward; pub use view_state::ViewState; diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 709482218..5c455aa35 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -7,6 +7,10 @@ pub const UI_SCALE_MIN: f64 = 0.75; pub const UI_SCALE_MAX: f64 = 3.0; /// `scale` kept between [`UI_SCALE_MIN`] and [`UI_SCALE_MAX`]; not a finite number: `None`. +/// The interface sizes Preferences offers, relative to the system's scale (the keys step through +/// them; `pref.uiScale` takes any size up to [`UI_SCALE_MAX`]). +pub const UI_SCALE_SIZES: [f64; 8] = [0.75, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0]; + pub fn clamp_ui_scale(scale: f64) -> Option { scale.is_finite().then(|| scale.clamp(UI_SCALE_MIN, UI_SCALE_MAX)) } diff --git a/crates/engine/src/shortcut_parity_tests.rs b/crates/engine/src/shortcut_parity_tests.rs index 65e269982..0b9b573af 100644 --- a/crates/engine/src/shortcut_parity_tests.rs +++ b/crates/engine/src/shortcut_parity_tests.rs @@ -146,7 +146,10 @@ static ROWS: &[Row] = &[ 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, "Layers panel", &["Alt+1"], &[], Bound("view.layers"), "one article writes Ctrl+1 for Windows, another Alt+1; Alt+1 bound"), + 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"), + doc(View, "Interface scale: reset to default", &["Alt+Shift+Cmd+0"], &[], Bound("pref.uiScaleReset"), "Accessibility at Figma"), doc(View, "Assets panel", &["Alt+2"], &[], Bound("view.assets"), ""), doc(View, "Libraries", &["Alt+3"], &[], Bound("libraries.open"), ""), doc(View, "Dev Mode on or off", &["Shift+D"], &[], Bound("view.devMode"), ""), @@ -255,6 +258,8 @@ static ROWS: &[Row] = &[ doc(Transform, "Use as mask (again: release)", &["Ctrl+Cmd+M"], &["Ctrl+Alt+M"], Bound("object.mask"), ""), doc(Transform, "Resize a frame to fit its contents", &["Alt+Shift+Cmd+R"], &["Ctrl+Alt+Shift+R"], Bound("object.resizeToFit"), ""), doc(Transform, "Nudge (Shift: big nudge)", &["ArrowRight"], &[], Gesture("Nudge (Shift: big nudge)"), ""), + doc(Transform, "Rotate a degree (Shift: 15 degrees)", &["Alt+Cmd+ArrowDown"], &[], Gesture("Rotate a degree (Shift: 15)"), + "Accessibility at Figma; down and right turn clockwise, up and left back (silent on the direction)"), doc(Transform, "Suspend snapping while dragging", &["Ctrl"], &[], Gesture("Suspend snapping while dragging"), "Control on macOS (Cmd keeps working); Ctrl elsewhere"), doc(Transform, "Show the rotation origin to move it", &["Alt+R"], &[], Bound("transform.rotationOrigin"), @@ -407,7 +412,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), (129, 128, 1, 0, 2, 57)); + assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (133, 132, 1, 0, 2, 57)); } #[test] diff --git a/crates/engine/src/text_behaviour_tests.rs b/crates/engine/src/text_behaviour_tests.rs index b263211ee..9ae7dd62c 100644 --- a/crates/engine/src/text_behaviour_tests.rs +++ b/crates/engine/src/text_behaviour_tests.rs @@ -550,3 +550,67 @@ fn css_font_family_escapes_backslash_and_newline() { let css = crate::codegen::css_decls(s.doc().unwrap(), s.doc().unwrap().node(t).unwrap(), &mut Default::default(), (true, true)); assert!(css.contains(r#"font-family: "Foo\"\3C /style>";"#), "{css}"); } + +/// "Text layers cannot have both a max height and a set number of max lines. Adding a max height +/// will set max lines to Auto. Setting max lines to a number will remove the layer's max height +/// setting." (Guide to auto layout) +#[test] +fn a_text_has_a_max_height_or_max_lines_not_both() { + let mut s = Session::new(); + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 100})).unwrap()); + s.execute("node.set", &json!({"id": f, "props": {"layout": "vertical"}})).unwrap(); + let t = id(s.execute("text.create", &json!({"text": "Hello there world", "x": 0, "y": 0, "parent": f, "width": 60})).unwrap()); + let state = |s: &Session| { + let n = s.doc().unwrap().node(t).unwrap().clone(); + (n.kind.text().unwrap().max_lines, n.child_layout.max_h) + }; + s.execute("node.set", &json!({"id": t, "props": {"maxLines": 2}})).unwrap(); + assert_eq!(state(&s), (2, None)); + s.execute("node.set", &json!({"id": t, "props": {"maxHeight": 40}})).unwrap(); + assert_eq!(state(&s), (0, Some(40.0)), "a max height sets max lines to Auto"); + s.execute("node.set", &json!({"id": t, "props": {"maxLines": 3}})).unwrap(); + assert_eq!(state(&s), (3, None), "max lines take the max height away"); + s.execute("node.set", &json!({"id": t, "props": {"maxHeight": 50}})).unwrap(); + s.execute("text.maxLines", &json!({"ids": [t], "lines": 1})).unwrap(); + assert_eq!(state(&s), (1, None), "the Max lines command too"); + // Auto (0) leaves a max height alone, and a frame's max height has no lines to clear. + s.execute("node.set", &json!({"id": t, "props": {"maxHeight": 50}})).unwrap(); + s.execute("node.set", &json!({"id": t, "props": {"maxLines": 0}})).unwrap(); + assert_eq!(state(&s), (0, Some(50.0))); + s.execute("node.set", &json!({"id": f, "props": {"maxHeight": 80}})).unwrap(); + assert_eq!(s.doc().unwrap().node(f).unwrap().child_layout.max_h, Some(80.0)); +} + +/// "Links are styled with an underline by default. Use the keyboard shortcut to remove the +/// underline: Mac: Command U, Windows: Control U" (Add links to text). +#[test] +fn a_link_is_underlined_by_default_and_underline_takes_it_off() { + use figcraft_doc::TextDecoration; + let mut s = Session::new(); + let t = text(&mut s, "docs", json!({})); + let style = |s: &Session| s.doc().unwrap().node(t).unwrap().kind.text().unwrap().style.clone(); + s.execute("text.link", &json!({"ids": [t], "url": "https://example.com"})).unwrap(); + assert_eq!((style(&s).link.as_deref(), style(&s).decoration), (Some("https://example.com"), TextDecoration::Underline)); + s.execute("text.underline", &json!({"ids": [t]})).unwrap(); + assert_eq!( + (style(&s).link.as_deref(), style(&s).decoration), + (Some("https://example.com"), TextDecoration::None), + "the link stays, the underline goes" + ); + // Changing the URL of a link whose underline was taken off keeps it off. + s.execute("text.link", &json!({"ids": [t], "url": "https://example.org"})).unwrap(); + assert_eq!((style(&s).link.as_deref(), style(&s).decoration), (Some("https://example.org"), TextDecoration::None)); + // A decoration of the text's own is kept. + let u = text(&mut s, "struck", json!({"textDecoration": "strikethrough"})); + s.execute("text.link", &json!({"ids": [u], "url": "https://example.com"})).unwrap(); + assert_eq!(s.doc().unwrap().node(u).unwrap().kind.text().unwrap().style.decoration, TextDecoration::Strikethrough); + // On a frame, the link reaches the text inside, and Underline reads that text: it takes the + // underline off rather than putting it on again. + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 100, "width": 200, "height": 100})).unwrap()); + let inner = id(s.execute("text.create", &json!({"text": "inside", "x": 0, "y": 100, "parent": f})).unwrap()); + let inner_style = |s: &Session| s.doc().unwrap().node(inner).unwrap().kind.text().unwrap().style.clone(); + s.execute("text.link", &json!({"ids": [f], "url": "https://example.com"})).unwrap(); + assert_eq!((inner_style(&s).link.as_deref(), inner_style(&s).decoration), (Some("https://example.com"), TextDecoration::Underline)); + s.execute("text.underline", &json!({"ids": [f]})).unwrap(); + assert_eq!(inner_style(&s).decoration, TextDecoration::None, "Underline on the frame reads the text inside it"); +} diff --git a/crates/engine/src/text_tail_tests.rs b/crates/engine/src/text_tail_tests.rs index 81d05ed23..a94852093 100644 --- a/crates/engine/src/text_tail_tests.rs +++ b/crates/engine/src/text_tail_tests.rs @@ -318,3 +318,30 @@ fn rest_import_lists_and_decorations() { (DecorationStyle::Wavy, DecorationLength::Pixels(2.0), DecorationLength::Auto, true, Some(Color::new(0.0, 0.0, 1.0, 1.0))) ); } + +/// "From within the text layer, there are a couple of ways to start a numbered list: Enter one of +/// the creation characters below followed by a Space: 1. 1)" (Create bulleted and numbered lists); +/// "-" and "*" start a bulleted one the same way. +#[test] +fn typing_a_creation_character_and_a_space_starts_a_list() { + for (start, want) in [("1.", N), ("1)", N), ("-", B), ("*", B)] { + let mut s = Session::new(); + let t = text(&mut s, "x"); + s.execute("node.set", &json!({"id": t, "props": {"characters": ""}})).unwrap(); + begin(&mut s, t, Some(0), false).unwrap(); + for c in start.chars() { + insert(&mut s, &c.to_string()).unwrap(); + } + insert(&mut s, " ").unwrap(); + assert_eq!((data(&s, t).characters.as_str(), lists(&s, t)), ("", vec![(want, 0)]), "{start}"); + } + // "2)" is not a creation character: it stays text. + let mut s = Session::new(); + let t = text(&mut s, "x"); + s.execute("node.set", &json!({"id": t, "props": {"characters": ""}})).unwrap(); + begin(&mut s, t, Some(0), false).unwrap(); + for c in ["2", ")", " "] { + insert(&mut s, c).unwrap(); + } + assert_eq!((data(&s, t).characters.as_str(), lists(&s, t)), ("2) ", vec![(P, 0)])); +} diff --git a/crates/format/src/fig/connector_label_tests.rs b/crates/format/src/fig/connector_label_tests.rs index de0c4f2fb..ca76c0b29 100644 --- a/crates/format/src/fig/connector_label_tests.rs +++ b/crates/format/src/fig/connector_label_tests.rs @@ -218,6 +218,64 @@ fn connector_mut<'a, 's>(message: &'a mut Kv<'s>, local_id: u64) -> Option<&'a m }) } +fn set_connector_field(message: &mut Kv<'_>, key: &'static str, value: Kv<'static>) { + let connector = connector_mut(message, 123).expect("synthetic connector"); + let Kv::Object(fields) = connector else { panic!("connector object") }; + if let Some((_, current)) = fields.iter_mut().find(|(name, _)| *name == key) { + *current = value; + } else { + fields.push((key, value)); + } +} + +/// A connector without a saved snapshot (a curved or elbowed one is routed here) still draws its +/// line as the generated record says: the paint, weight, cap and dashes live there, not on the +/// connector (#1807). +#[test] +fn a_routed_connector_takes_its_line_style_from_the_generated_record() { + let mut message = synthetic_connector(1.0, false, 0.0); + set_connector_field(&mut message, "connectorLineStyle", Kv::Enum("CURVED")); + set_connector_field(&mut message, "connectorStart", obj(vec![("position", point(10.0, 10.0))])); + set_connector_field(&mut message, "connectorEnd", obj(vec![("position", point(110.0, 60.0))])); + set_override_field(&mut message, "nodeGenerationData", 0, "strokeWeight", scalar(4.0)); + set_override_field(&mut message, "nodeGenerationData", 0, "strokeCap", Kv::Enum("ROUND")); + set_override_field(&mut message, "nodeGenerationData", 0, "dashPattern", Kv::Array(vec![scalar(16.0), scalar(10.0)])); + let (rest, warnings, _) = map::to_rest(&message, "synthetic", crate::rest::MAX_NODES); + let connector = &rest["document"]["children"][0]["children"][0]; + assert_eq!(connector["type"], "VECTOR"); + assert!(connector.get("vectorNetwork").is_some(), "routed here: {connector}"); + assert_eq!(connector["strokes"].as_array().map(Vec::len), Some(1), "{connector}"); + assert!((connector["strokes"][0]["color"]["b"].as_f64().unwrap() - 0.8).abs() < 1e-6); + assert_eq!(connector["strokeWeight"], 4.0); + assert_eq!(connector["strokeDashes"], serde_json::json!([16.0, 10.0])); + assert_eq!(connector["strokeCapEnd"], "ARROW"); + assert!(warnings.iter().any(|warning| warning.contains("follow FigCraft's own routing")), "{warnings:?}"); + + let (native, _) = crate::rest::import_fig(&rest, &|_| None).unwrap(); + let node = find_node(&native, "synthetic connector"); + assert_eq!((node.strokes.len(), node.stroke.weight, node.stroke.dashes.clone()), (1, 4.0, vec![16.0, 10.0])); +} + +/// A routed connector that writes its own empty paints and dash pattern is meant to have no stroke +/// and a solid line: the generated record's paint and dashes don't replace them. +#[test] +fn a_routed_connector_keeps_its_own_empty_paints_and_dashes() { + let mut message = synthetic_connector(1.0, false, 0.0); + set_connector_field(&mut message, "connectorLineStyle", Kv::Enum("CURVED")); + set_connector_field(&mut message, "connectorStart", obj(vec![("position", point(10.0, 10.0))])); + set_connector_field(&mut message, "connectorEnd", obj(vec![("position", point(110.0, 60.0))])); + set_connector_field(&mut message, "strokePaints", Kv::Array(Vec::new())); + set_connector_field(&mut message, "dashPattern", Kv::Array(Vec::new())); + set_override_field(&mut message, "nodeGenerationData", 0, "strokeWeight", scalar(4.0)); + set_override_field(&mut message, "nodeGenerationData", 0, "dashPattern", Kv::Array(vec![scalar(16.0), scalar(10.0)])); + let (rest, _, _) = map::to_rest(&message, "synthetic", crate::rest::MAX_NODES); + let connector = &rest["document"]["children"][0]["children"][0]; + assert!(connector.get("vectorNetwork").is_some(), "routed here: {connector}"); + assert!(connector["strokes"].as_array().is_none_or(Vec::is_empty), "{connector}"); + assert!(connector.get("strokeDashes").is_none(), "{connector}"); + assert_eq!(connector["strokeWeight"], 4.0); +} + fn set_override_field(message: &mut Kv<'_>, field: &str, local_id: u64, key: &'static str, value: Kv<'static>) { let connector = connector_mut(message, 123).expect("synthetic connector"); let section = field_mut(connector, field).expect("connector override section"); diff --git a/crates/format/src/fig/export/shape.rs b/crates/format/src/fig/export/shape.rs index 03c82cfb1..e4e2cae6b 100644 --- a/crates/format/src/fig/export/shape.rs +++ b/crates/format/src/fig/export/shape.rs @@ -282,7 +282,7 @@ impl<'d> Exporter<'d> { if !(plain && weight.is_finite() && weight > 0.0 && w.is_finite() && h.is_finite()) { return; } - let whole = matches!(n.kind, NodeKind::Ellipse { sweep, inner, .. } if inner == 0.0 && sweep.abs() >= std::f64::consts::TAU - 1e-9); + let whole = matches!(n.kind, NodeKind::Ellipse { sweep, inner, .. } if inner == 0.0 && figcraft_doc::geom::full_sweep(sweep)); if boxy || whole { let (out, inset) = match sp.align { StrokeAlign::Inside => (0.0, weight), diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index 5b4bcb991..b1b31262a 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -1592,3 +1592,22 @@ fn a_name_used_with_two_types_in_a_set_writes_both_definitions() { } } } + +/// A whole ellipse that starts anywhere round comes back whole: the file holds its start and end +/// angles as 32-bit floats, and their difference falls a hair short of a turn as often as not. +#[test] +fn a_whole_ellipse_starting_anywhere_comes_back_whole() { + let tau = std::f64::consts::TAU; + for start in [0.3, 2.0, -std::f64::consts::FRAC_PI_2, std::f64::consts::PI, 100.25] { + for sweep in [tau, -tau] { + let mut doc = Document::new("whole"); + let page = doc.first_page().unwrap(); + doc.create(page, NodeKind::Ellipse { start, sweep, inner: 0.0 }, "Ellipse", 0.0, 0.0, 100.0, 100.0).unwrap(); + let (bytes, _) = export(&doc, None).unwrap(); + let (back, _) = import(&bytes, "x").unwrap(); + let kinds: Vec = + back.ids().filter_map(|id| back.node(id)).map(|n| n.kind.clone()).filter(|k| matches!(k, NodeKind::Ellipse { .. })).collect(); + assert!(matches!(kinds.as_slice(), [NodeKind::Ellipse { sweep: s, .. }] if *s == sweep), "start {start}, sweep {sweep}: {kinds:?}"); + } + } +} diff --git a/crates/format/src/fig/interop_tests.rs b/crates/format/src/fig/interop_tests.rs index b51760c55..fa95d792b 100644 --- a/crates/format/src/fig/interop_tests.rs +++ b/crates/format/src/fig/interop_tests.rs @@ -449,3 +449,34 @@ fn local_samples_agree_with_the_written_schema() { } } } + +/// An ellipse drawn the other way round (a sweep of −2π) is whole: its inside stroke is written +/// as the same ring a forward one's is, path and winding rule alike. +#[test] +fn a_whole_ellipse_drawn_backwards_carries_its_stroke_ring() { + let ring = |sweep: f64| { + let mut doc = sample(); + let ids: Vec = doc.ids().collect(); + for id in ids { + if let Some(n) = doc.node_mut(id) + && let NodeKind::Ellipse { sweep: s, .. } = &mut n.kind + { + *s = sweep; + } + } + let out = std::cell::RefCell::new(None); + with_changes(&doc, |of, msg| { + let blobs = msg.get("blobs").unwrap().list(); + let geometry = of("ELLIPSE").get("strokeGeometry").map(|g| { + let p = g.list().first().unwrap().clone(); + let bytes = blobs.get(num(&p, "commandsBlob") as usize).and_then(|b| b.get("bytes")).and_then(Kv::bytes).unwrap(); + (name(&p, "windingRule").to_string(), bytes.to_vec()) + }); + *out.borrow_mut() = geometry; + }); + out.into_inner() + }; + let forwards = ring(std::f64::consts::TAU).expect("a forward ellipse writes its stroke ring"); + assert_eq!(forwards.0, "ODD"); + assert_eq!(ring(-std::f64::consts::TAU), Some(forwards)); +} diff --git a/crates/format/src/fig/map/connector.rs b/crates/format/src/fig/map/connector.rs index 7da7458b9..de2ff501c 100644 --- a/crates/format/src/fig/map/connector.rs +++ b/crates/format/src/fig/map/connector.rs @@ -1,4 +1,5 @@ use super::layer::{cap_name, own_text, svg_path, valid_size}; +use super::paint::{paint, stroke}; use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, generated_record, guid, json, matrix, network, num, rows}; use figcraft_doc::geom::kurbo::{BezPath, PathEl, Rect, Shape}; use figcraft_doc::{RouteEnd, Routing, Side, route_points}; @@ -29,6 +30,7 @@ impl<'a> Mapper<'a> { return Vec::new(); } let mid = self.connector(o, v, space); + self.connector_line_style(o, v); if shape.label { return self.connector_label(o, v, rest_id, mid); } @@ -122,6 +124,30 @@ impl<'a> Mapper<'a> { middle(&path).map(|m| m - b.origin().to_vec2()) } + /// The line's paint, weight, cap and dashes, which a connector keeps in its generated line + /// record rather than on itself: without a saved snapshot the routed line took none of them and + /// drew nothing (#1807). The connector's own values, when it has any, win. + fn connector_line_style(&mut self, o: &mut Obj, v: &View<'a>) { + let Some(line_style) = generated_record(v, "nodeGenerationData", 0) else { return }; + let style = View::of(line_style); + // A connector that writes its own (even empty) paints or dash pattern keeps them: an empty + // list is no stroke, or a solid line, not a missing value. + if v.get("strokePaints").is_none() && o.get("strokes").and_then(Value::as_array).is_none_or(Vec::is_empty) { + let strokes: Vec = style.list("strokePaints").iter().filter_map(|p| paint(p, &mut |w| self.warn(w))).collect(); + if !strokes.is_empty() { + o.insert("strokes".into(), Value::Array(strokes)); + } + } + let mut line = Obj::new(); + stroke(&mut line, &style); + if v.get("dashPattern").is_some() { + line.remove("strokeDashes"); + } + for (key, value) in line { + o.entry(key).or_insert(value); + } + } + /// A connector with a label but no saved snapshot of it, as the frame a saved one imports as: /// `o` keeps the connector's place and size and gives its line to a vector child, and the /// label is a text child centred on `mid` (the line's middle; the frame's centre when the line diff --git a/crates/format/src/rest.rs b/crates/format/src/rest.rs index 26ab3586a..4604b73a6 100644 --- a/crates/format/src/rest.rs +++ b/crates/format/src/rest.rs @@ -629,7 +629,12 @@ impl<'a> Ctx<'a> { let start = arc("startingAngle").unwrap_or(0.0).clamp(-1e3, 1e3); let end = arc("endingAngle").map_or(start + std::f64::consts::TAU, |e| e.clamp(-1e3, 1e3)); let tau = std::f64::consts::TAU; - NodeKind::Ellipse { start, sweep: (end - start).clamp(-tau, tau), inner: arc("innerRadius").unwrap_or(0.0).clamp(0.0, 1.0) } + // The angles are 32-bit floats: a whole turn from anywhere but zero comes out a + // hair short of 2π as often as not. Within their precision of a turn, it is one. + let precision = (start.abs() + end.abs() + tau) * f64::from(f32::EPSILON); + let sweep = end - start; + let sweep = if sweep.abs() >= tau - precision { tau.copysign(sweep) } else { sweep }; + NodeKind::Ellipse { start, sweep: sweep.clamp(-tau, tau), inner: arc("innerRadius").unwrap_or(0.0).clamp(0.0, 1.0) } } // Point counts aren't part of the documented JSON: the outline is exact when present. "REGULAR_POLYGON" | "STAR" if point_count().is_none() && has_geometry => NodeKind::Vector { network: VectorNetwork::default() }, diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 012fca429..696a02e92 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -319,6 +319,12 @@ fn line_or_move(path: &mut BezPath, p: Point) { } } +/// Whether an ellipse of this `sweep` (radians) goes all the way round: a whole ellipse or ring, +/// without the sharp corners where an arc's ends meet its sides. +pub fn full_sweep(sweep: f64) -> bool { + finite(sweep).abs() >= TAU - 1e-9 +} + /// An ellipse in the `width`×`height` box; with arc data, a pie/arc/ring: `start` and `sweep` in /// radians (clockwise from 3 o'clock, y-down) and `inner` radius ratio 0..1. pub fn ellipse(width: f64, height: f64, start: f64, sweep: f64, inner: f64) -> BezPath { @@ -327,7 +333,7 @@ pub fn ellipse(width: f64, height: f64, start: f64, sweep: f64, inner: f64) -> B let sweep = finite(sweep).clamp(-TAU, TAU); let start = finite(start); let inner = finite(inner).clamp(0.0, 1.0); - let full = sweep.abs() >= TAU - 1e-9; + let full = full_sweep(sweep); let mut path = BezPath::new(); if full && inner <= 0.0 { return kurbo::Ellipse::new(c, (rx, ry), 0.0).to_path(0.01); diff --git a/crates/plugin/src/manifest.rs b/crates/plugin/src/manifest.rs index 6bca9aef2..269174e0f 100644 --- a/crates/plugin/src/manifest.rs +++ b/crates/plugin/src/manifest.rs @@ -77,6 +77,7 @@ fn api_from_number_or_string<'de, D: serde::Deserializer<'de>>(d: D) -> Result Arc { p.apply_affine(Affine::translate((x0, y0))); Some(p) } - NodeKind::Ellipse { sweep, inner, .. } if sweep.abs() >= std::f64::consts::TAU - 1e-9 && inner == 0.0 => { + NodeKind::Ellipse { sweep, inner, .. } if figcraft_geom::full_sweep(sweep) && inner == 0.0 => { Some(figcraft_geom::kurbo::Ellipse::from_rect(Rect::new(x0, y0, x0 + ww, y0 + hh)).to_path(0.05)) } _ => None, diff --git a/crates/render/src/join_tests.rs b/crates/render/src/join_tests.rs index 9fbb4f870..42c931997 100644 --- a/crates/render/src/join_tests.rs +++ b/crates/render/src/join_tests.rs @@ -44,3 +44,23 @@ fn outside_bevel_join_cuts_outer_corner() { fn outside_miter_join_keeps_square_corner() { assert_ne!(covered(StrokeJoin::Miter), 0); } + +/// An ellipse drawn the other way round (a sweep of −2π) is whole: its inside stroke is the same +/// ring of two outlines as a forward one's. +#[test] +fn a_whole_ellipse_drawn_backwards_strokes_as_a_ring() { + let ring = |sweep: f64| { + let mut d = Document::new("ring"); + let p = d.first_page().unwrap(); + let id = d.create(p, NodeKind::Ellipse { start: 0.0, sweep, inner: 0.0 }, "E", 0.0, 0.0, 60.0, 40.0).unwrap(); + let n = d.get_mut(id).unwrap(); + n.strokes = vec![Paint::solid(Color::BLACK)]; + (n.stroke.weight, n.stroke.align) = (6.0, StrokeAlign::Inside); + let outline: BezPath = figcraft_geom::kurbo::Ellipse::from_rect(figcraft_geom::kurbo::Rect::new(0.0, 0.0, 60.0, 40.0)).to_path(0.1); + (*crate::draw::stroke_area(d.get(id).unwrap(), &outline, true)).clone() + }; + let forwards = ring(std::f64::consts::TAU); + let moves = forwards.elements().iter().filter(|e| matches!(e, figcraft_geom::kurbo::PathEl::MoveTo(_))).count(); + assert_eq!(moves, 2, "two outlines: {forwards:?}"); + assert_eq!(ring(-std::f64::consts::TAU), forwards); +} diff --git a/crates/render/src/spread_tests.rs b/crates/render/src/spread_tests.rs index 00fbb6333..6b30766fc 100644 --- a/crates/render/src/spread_tests.rs +++ b/crates/render/src/spread_tests.rs @@ -142,3 +142,53 @@ fn spread_bands_are_cached_and_hostile_values_are_safe() { let _ = render(&d, n); } } + +/// An arc (a pie slice, or a ring cut short) has sharp corners where its ends meet its sides, and +/// a spread mitres them out past the spread: its layer makes room for that, as a star's does. A +/// clear shadow spread four times as far gives the same layer room to the full mitre: the shadow +/// it casts is the same. (Both layers end at the view here, so they line up pixel for pixel; +/// layers starting at different pixels can differ by a few levels at their edges.) +#[test] +fn arc_spread_mitres_are_not_cut_off() { + for kind in [ + NodeKind::Ellipse { start: 1.0, sweep: 0.3, inner: 0.0 }, + NodeKind::Ellipse { start: 0.0, sweep: 3.0, inner: 0.0 }, + NodeKind::Ellipse { start: 1.0, sweep: 2.0, inner: 0.8 }, + ] { + for spread in [25.0, 40.0] { + let (d, n) = shape(kind.clone(), vec![EffectKind::DropShadow(shadow(spread))]); + let clear = Shadow { color: Color::new(0.0, 0.0, 0.0, 0.0), ..shadow(spread * 4.0) }; + let (roomy, m) = shape(kind.clone(), vec![EffectKind::DropShadow(shadow(spread)), EffectKind::DropShadow(clear)]); + let (a, b) = (render(&d, n), render(&roomy, m)); + let worst = a.data().chunks(4).zip(b.data().chunks(4)).map(|(p, q)| p[3].abs_diff(q[3])).max().unwrap_or(0); + assert!(worst <= 2, "{kind:?}, a spread of {spread}: {worst} levels cut off"); + } + } +} + +/// A mitred stroke on an arc reaches out past its width at the arc's corners too, and a spread +/// grows from there: a thin slice with both keeps its whole shadow. +#[test] +fn arc_stroke_mitres_are_not_cut_off() { + use figcraft_doc::{StrokeAlign, StrokeJoin}; + let kind = NodeKind::Ellipse { start: std::f64::consts::FRAC_PI_2 - 0.015, sweep: 0.03, inner: 0.0 }; + for align in [StrokeAlign::Center, StrokeAlign::Outside] { + let make = |effects: Vec| { + let mut d = Document::new("arc"); + let p = d.first_page().unwrap(); + let n = d.create(p, kind.clone(), "S", 0.0, 190.0, 400.0, 20.0).unwrap(); + let node = d.get_mut(n).unwrap(); + node.fills = vec![Paint::solid(Color::WHITE)]; + node.strokes = vec![Paint::solid(Color::WHITE)]; + (node.stroke.weight, node.stroke.align, node.stroke.join, node.stroke.miter_limit) = (20.0, align, StrokeJoin::Miter, 4.0); + node.effects = effects.into_iter().map(|kind| Effect { kind, visible: true }).collect(); + (d, n) + }; + let (d, n) = make(vec![EffectKind::DropShadow(shadow(10.0))]); + let clear = Shadow { color: Color::new(0.0, 0.0, 0.0, 0.0), ..shadow(80.0) }; + let (roomy, m) = make(vec![EffectKind::DropShadow(shadow(10.0)), EffectKind::DropShadow(clear)]); + let (a, b) = (render(&d, n), render(&roomy, m)); + let worst = a.data().chunks(4).zip(b.data().chunks(4)).map(|(p, q)| p[3].abs_diff(q[3])).max().unwrap_or(0); + assert!(worst <= 2, "{align:?}: {worst} levels cut off"); + } +} diff --git a/crates/svg/src/effects_tests.rs b/crates/svg/src/effects_tests.rs index 5177e992b..f81d8e04c 100644 --- a/crates/svg/src/effects_tests.rs +++ b/crates/svg/src/effects_tests.rs @@ -83,3 +83,45 @@ fn hostile_shadow_values_still_export() { assert!(export_svg(&d, &[n], &SvgOptions::default()).is_some()); } } + +/// The filter a spread shadow is blurred in makes room for the spread as the node's transform +/// draws it: on a star scaled three times, the spread's mitres reach three times as far, and on a +/// skewed one further across than down. +#[test] +fn a_scaled_spread_shadow_keeps_its_filter_room() { + use figcraft_doc::SPREAD_MITER_LIMIT; + use figcraft_geom::{Affine, kurbo}; + let star = NodeKind::Star { count: 5, inner_ratio: 0.4 }; + let s = + Shadow { color: Color::from_hex("000000").unwrap(), offset: (6.0, -9.0), radius: 4.0, spread: 10.0, show_behind: true, ..Default::default() }; + for (why, xf) in [ + ("scaled 3", Affine::scale(3.0)), + ("scaled (3, 1)", Affine::scale_non_uniform(3.0, 1.0)), + ("skewed", Affine::new([1.0, 0.0, 2.0, 1.0, 0.0, 0.0])), + ("turned and stretched", Affine::rotate(0.5) * Affine::scale_non_uniform(3.0, 1.0)), + ("as drawn", Affine::IDENTITY), + ] { + let (mut d, n) = with_effects(star.clone(), vec![EffectKind::DropShadow(s.clone())]); + let t = d.get(n).unwrap().transform; + d.get_mut(n).unwrap().transform = t * xf; + let svg = export(&d, n); + let tag = &svg[svg.find(" f64 { + let at = tag.find(&format!(" {name}=\"")).unwrap() + name.len() + 3; + tag[at..at + tag[at..].find('"').unwrap()].parse().unwrap() + }; + let region = kurbo::Rect::new(attr("x"), attr("y"), attr("x") + attr("width"), attr("y") + attr("height")); + // What the shadow paints, as the renderer draws it: the outline stroked twice the spread + // wide with mitred joins, through the node's transform, moved by the offset (in the + // export's frame, which starts at the node's export bounds) and blurred. + let bounds = figcraft_render::export_bounds(&d, n).unwrap(); + let at = Affine::translate((s.offset.0 - bounds.x0, s.offset.1 - bounds.y0)) * d.world_transform(n); + let shape = figcraft_render::node_outline(&d, d.get(n).unwrap()).unwrap(); + let stroke = kurbo::Stroke::new(s.spread * 2.0).with_join(kurbo::Join::Miter).with_miter_limit(SPREAD_MITER_LIMIT); + let mut grown = kurbo::stroke(shape.0.iter(), &stroke, &Default::default(), 0.01); + grown.apply_affine(at); + let blur = s.radius * 1.5; + let painted = figcraft_geom::Shape::bounding_box(&grown).inflate(blur, blur); + assert!(region.union(painted) == region, "{why}: the filter {region:?} cuts the shadow at {painted:?}"); + } +} diff --git a/crates/svg/src/import.rs b/crates/svg/src/import.rs index 71c6c3b05..e3d9d8523 100644 --- a/crates/svg/src/import.rs +++ b/crates/svg/src/import.rs @@ -608,7 +608,17 @@ fn image(cx: &mut Ctx, img: &usvg::Image, id: &str, parent: NodeId, base: Affine let hash = add_image(cx.doc, bytes, iw, ih); let mut n = Node::new(cx.doc.alloc_id(), NodeKind::Rectangle, layer_name(id, "Image"), w, h); n.transform = transform; - n.fills = vec![Paint::image(hash, ImageScaleMode::Fill)]; + let mut fill = Paint::image(hash, ImageScaleMode::Fill); + // usvg sizes the box to the picture (`meet` and `slice` are in the transform), so a box of + // another shape is `preserveAspectRatio="none"`: the picture stretched edge to edge. A Fill + // would keep its shape and crop it; a crop of the whole picture stretches it. + if (w * ih - h * iw).abs() > 1e-6 * (w * ih).max(h * iw) + && let figcraft_color::PaintKind::Image(ip) = &mut fill.kind + { + ip.scale_mode = ImageScaleMode::Crop; + ip.crop = Some(Affine::IDENTITY.as_coeffs()); + } + n.fills = vec![fill]; cx.add(n, parent); } diff --git a/crates/svg/src/import/structure_tests.rs b/crates/svg/src/import/structure_tests.rs index adeedb005..f16abb66a 100644 --- a/crates/svg/src/import/structure_tests.rs +++ b/crates/svg/src/import/structure_tests.rs @@ -129,6 +129,28 @@ fn strokes_scale_with_their_layer() { assert_eq!((l.width, l.stroke.weight, l.stroke.dashes.clone()), (30.0, 6.0, vec![12.0, 6.0])); } +/// `preserveAspectRatio="none"` stretches a picture into its box edge to edge. usvg sizes the +/// box to the picture for `meet` and `slice` (the fit is in the transform), so a box of another +/// shape is a stretch: a Fill, which keeps the picture's shape and crops it, would show the +/// middle of the picture instead. +#[test] +fn an_image_stretched_into_its_box_stays_stretched() { + use figcraft_color::{ImageScaleMode, PaintKind}; + let (d, f) = + import(&format!(r##""##)); + let n = node(&d, f, "pic"); + assert_eq!((n.width, n.height), (40.0, 40.0)); + let Some(PaintKind::Image(ip)) = n.fills.first().map(|p| &p.kind) else { panic!("{:?}", n.fills) }; + // The 4 × 2 picture's pixels map onto the whole 40 × 40 box: 10 across, 20 down. + let m = figcraft_render::paint::image_transform(ip, 4.0, 2.0, n.width, n.height); + assert_eq!((m.sx, m.ky, m.kx, m.sy, m.tx, m.ty), (10.0, 0.0, 0.0, 20.0, 0.0, 0.0), "{ip:?}"); + // With the aspect kept (the default) the box is the picture's own shape and a Fill is exact. + let (d, f) = import(&format!(r##""##)); + let n = node(&d, f, "pic"); + assert_eq!((n.width, n.height), (40.0, 20.0)); + assert!(matches!(n.fills.first().map(|p| &p.kind), Some(PaintKind::Image(i)) if i.scale_mode == ImageScaleMode::Fill), "{:?}", n.fills); +} + #[test] fn linked_files_are_never_read() { let dir = std::env::temp_dir().join("figcraft-svg-linked"); diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index de98795d3..3158d1c0d 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -802,7 +802,7 @@ impl W<'_> { return None; } match n.kind { - NodeKind::Ellipse { sweep, inner, .. } if inner == 0.0 && sweep >= std::f64::consts::TAU - 1e-9 => { + NodeKind::Ellipse { sweep, inner, .. } if inner == 0.0 && figcraft_geom::full_sweep(sweep) => { figcraft_geom::kurbo::Shape::to_path(&figcraft_geom::kurbo::Ellipse::from_rect(r), 0.1) } NodeKind::Rectangle | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. } if n.corners.smoothing == 0.0 => { @@ -1052,10 +1052,14 @@ impl W<'_> { else { continue; }; - let reach = s.spread.abs() * SPREAD_MITER_LIMIT + s.radius.abs() * 1.5 + 2.0; + // The spread grows the outline in its own units (mitres up to the limit), drawn through + // `xf`: as far across and down as a local step that long goes. The blur is in page units. + let spread = if s.spread.is_finite() { s.spread.abs() * SPREAD_MITER_LIMIT } else { 0.0 }; + let [a, b_, c, d, _, _] = xf.as_coeffs(); + let blur = s.radius.abs() * 1.5 + 2.0; + let region = b.inflate(spread * a.hypot(c) + blur, spread * b_.hypot(d) + blur); let cover = if s.show_behind { None } else { self.cover_input(n, xf) }; - let filter = - self.blur_filter(s.radius, b.inflate(reach, reach), cover.as_deref()).map(|f| format!(" filter=\"url(#{f})\"")).unwrap_or_default(); + let filter = self.blur_filter(s.radius, region, cover.as_deref()).map(|f| format!(" filter=\"url(#{f})\"")).unwrap_or_default(); let op = f64::from(s.color.a * n.opacity.clamp(0.0, 1.0)); let blend = if matches!(s.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) { String::new() diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index c3fb75f8e..ade0e3f30 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -429,3 +429,24 @@ fn an_exported_dashed_line_opens_as_the_same_line() { d.subtree(frame).iter().filter_map(|i| d.node(*i)).filter(|n| !n.stroke.dashes.is_empty()).map(|n| n.stroke.dashes.clone()).collect(); assert!(matches!(dashes.as_slice(), [d] if d.len() == 2 && d.iter().all(|v| (v - 10.7).abs() < 1e-4)), "{dashes:?}"); } + +/// An ellipse drawn the other way round (a sweep of −2π) is as whole as one drawn forwards: its +/// stroke inside or outside exports as a stroke, the same as the forward one's. +#[test] +fn a_whole_ellipse_drawn_backwards_keeps_its_simple_stroke() { + let export = |sweep: f64, align: figcraft_doc::StrokeAlign| { + let mut d = Document::new("backwards"); + let p = d.first_page().unwrap(); + let n = d.create(p, NodeKind::Ellipse { start: 0.0, sweep, inner: 0.0 }, "Ellipse", 0.0, 0.0, 100.0, 60.0).unwrap(); + let node = d.get_mut(n).unwrap(); + node.fills.clear(); + node.strokes = vec![Paint::solid(Color::from_hex("000000").unwrap())]; + (node.stroke.weight, node.stroke.align) = (8.0, align); + export_svg(&d, &[n], &SvgOptions { simplify_stroke: true, ..SvgOptions::default() }).unwrap() + }; + for align in [figcraft_doc::StrokeAlign::Inside, figcraft_doc::StrokeAlign::Outside] { + let (forwards, backwards) = (export(std::f64::consts::TAU, align), export(-std::f64::consts::TAU, align)); + assert!(forwards.contains("stroke-width=\"8\""), "{align:?}: {forwards}"); + assert_eq!(backwards, forwards, "{align:?}"); + } +} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 3ae4997ab..7f5872fdf 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -1165,9 +1165,12 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) /// The markers of a text's paragraphs ("" for plain ones). Numbers count the items of one list at /// one level, restarting after a plain paragraph, a shallower item or the other kind of list, -/// from the first item's start number; levels go 1. a. i. and • ◦ ▪, then round again. +/// from the first item's start number; levels go 1. a. i., then round again; bullets are • at +/// every level. pub(crate) fn list_markers(lists: &[ParaList]) -> Vec { - const BULLETS: [&str; 3] = ["\u{2022}", "\u{25E6}", "\u{25AA}"]; + // "Figma bullets will keep the same styling at all levels of indentation." (Create bulleted + // and numbered lists) + const BULLET: &str = "\u{2022}"; let mut count = [(ListStyle::None, 0usize); figcraft_doc::MAX_LIST_INDENT as usize + 1]; lists .iter() @@ -1187,7 +1190,7 @@ pub(crate) fn list_markers(lists: &[ParaList]) -> Vec { (ListStyle::Numbered, 1) => format!("{}.", alpha(c.1)), (ListStyle::Numbered, 2) => format!("{}.", roman(c.1)), (ListStyle::Numbered, _) => format!("{}.", c.1), - _ => BULLETS.get(d % 3).copied().unwrap_or("\u{2022}").to_string(), + _ => BULLET.to_string(), } }) .collect() diff --git a/crates/text/src/tail_tests.rs b/crates/text/src/tail_tests.rs index 712ff6aa8..805f744d8 100644 --- a/crates/text/src/tail_tests.rs +++ b/crates/text/src/tail_tests.rs @@ -90,8 +90,8 @@ fn list_numbering_by_level() { ), ( vec![pl(B, 0), pl(B, 1), pl(B, 2), pl(B, 3)], - vec!["\u{2022}", "\u{25E6}", "\u{25AA}", "\u{2022}"], - "silent: bullets change shape per level, round again", + vec!["\u{2022}", "\u{2022}", "\u{2022}", "\u{2022}"], + "bullets keep the same styling at all levels of indentation", ), (vec![pl(N, 0), pl(N, 0), pl(P, 0), pl(N, 0)], vec!["1.", "2.", "", "1."], "silent: a plain paragraph ends a list, the next starts at 1"), (vec![pl(N, 0), pl(B, 0), pl(N, 0)], vec!["1.", "\u{2022}", "1."], "silent: the other kind of list at the same level starts the count again"), diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index ba567dd81..c44635a2a 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -656,6 +656,7 @@ name = "English" "Help page" = "Help page" "Here: {names}" = "Here: {names}" "Hidden" = "Hidden" +"Hide UI" = "Hide UI" "Hide contents" = "Hide contents" "Hide from publishing" = "Hide from publishing" "Hide section contents" = "Hide section contents" @@ -993,6 +994,9 @@ name = "English" "Nov" = "Nov" "Now" = "Now" "Nudge (Shift: big nudge)" = "Nudge (Shift: big nudge)" +"Rotate a degree (Shift: 15)" = "Rotate a degree (Shift: 15)" +"⌥⌘ ← ↑ → ↓" = "⌥⌘ ← ↑ → ↓" +"Ctrl+Alt+← ↑ → ↓" = "Ctrl+Alt+← ↑ → ↓" "Nudge amount" = "Nudge amount" "Number" = "Number" "Number ↑" = "Number ↑" @@ -1187,6 +1191,7 @@ name = "English" "Remove" = "Remove" "Remove all" = "Remove all" "Remove annotation" = "Remove annotation" +"Remove all auto layout" = "Remove all auto layout" "Remove auto layout" = "Remove auto layout" "Remove column" = "Remove column" "Remove dev resource" = "Remove dev resource" @@ -1384,6 +1389,7 @@ name = "English" "Show device frame" = "Show device frame" "Show dimensions on objects" = "Show dimensions on objects" "Show hotspot hints" = "Show hotspot hints" +"The bars are hidden. {shortcut} shows them again" = "The bars are hidden. {shortcut} shows them again" "Show in Explorer" = "Show in Explorer" "Show in Finder" = "Show in Finder" "Show in folder" = "Show in folder" diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index d130daaea..c625f342a 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -117,6 +117,11 @@ impl FigApp { if ctx.viewport_id() == egui::ViewportId::ROOT { ctx.set_zoom_factor(plan.zoom); } + // A size that just changed (a key, Preferences, a command) draws at the new size from + // the next frame: ask for it rather than wait for the next input. + if plan != self.ui.ui_scale { + ctx.request_repaint(); + } self.ui.ui_scale = plan; } let dark = match self.session.prefs.theme.as_str() { @@ -177,6 +182,9 @@ impl FigApp { self.frame(ui); windows::settle(self, &ctx, files); if self.session.prefs != prefs { + // This window too: a preference set during the frame (the interface size, the theme) + // is applied as the next frame begins, so ask for it now rather than wait for input. + ctx.request_repaint(); windows::repaint_others(self, &ctx); } windows::show(self, &ctx); diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index 69b60c5ed..0e34f437a 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -210,10 +210,14 @@ impl FigApp { if let Some(i) = r.data.get("inspect").and_then(|i| i.as_bool()) { v.inspect = i; } + if let Some(h) = r.data.get("hideUi").and_then(|h| h.as_bool()) { + v.hide_ui = h; + } if let Some(p) = self.ui.present.as_mut() { p.view.device_frame = v.device_frame; p.view.hotspot_hints = v.hotspot_hints; p.view.inspect = v.inspect; + p.view.hide_ui = v.hide_ui; p.set_scale(v.scale); } } diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index 10baf76e8..72435673c 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -53,7 +53,7 @@ impl FigApp { "reduceMotion": self.reduce_motion(), "presenting": self.ui.present.is_some(), "presenter": tabs::shown_or_paused(self).map(present::Presenter::inspect), - "presentOptions": {"scale": self.ui.present_view.scale.id(), "deviceFrame": self.ui.present_view.device_frame, "comments": self.ui.present_view.comments, "hotspotHints": self.ui.present_view.hotspot_hints, "inspect": self.ui.present_view.inspect}, + "presentOptions": {"scale": self.ui.present_view.scale.id(), "deviceFrame": self.ui.present_view.device_frame, "comments": self.ui.present_view.comments, "hotspotHints": self.ui.present_view.hotspot_hints, "inspect": self.ui.present_view.inspect, "hideUi": self.ui.present_view.hide_ui}, "preview": self.ui.preview.as_ref().map(|p| p.player.inspect()), "mainMenu": self.ui.main_menu, "palette": self.ui.palette.is_some(), diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index aa79bec78..cad8267d8 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -545,6 +545,7 @@ pub fn background(app: &mut FigApp, ctx: &Context) { } // Outside a frame, so outside the frame's guard: within the window's swap, a panic still // ends in recovery copies and leaves every window its own state. + let prefs = app.session.prefs.clone(); let res = crate::windows::as_window(app, target, |app| { let mut res = Err("internal error".to_string()); // Every control-channel gesture is fenced from the user's system clipboard. @@ -554,6 +555,13 @@ pub fn background(app: &mut FigApp, ctx: &Context) { app.control_synthetic = before; res }); + // A preference the command changed (the interface size, the theme) applies as each + // window's next frame begins; no frame's end-of-frame check ran here, so ask every window. + if app.session.prefs != prefs { + for id in std::iter::once(0).chain(app.windows.list.iter().map(|w| w.id)) { + ctx.request_repaint_of(crate::windows::viewport(id)); + } + } let _ = r.reply.send(ControlReply { id: r.id, result: res.unwrap_or_else(|| Err(format!("no window {target}"))) }); } request_paint(app, ctx); @@ -999,11 +1007,24 @@ fn ui_key(app: &mut FigApp, ctx: &Context, p: &Value) -> Result { // With nothing selected the arrows pan the view, as the window's own key path does. let (cmd, params) = if app.session.selection().is_empty() { ("view.pan", json!({"dx": dx, "dy": dy, "big": m.shift})) + } else if (m.cmd || m.ctrl) && m.alt { + // Rotate a degree, 15 with Shift: down and right clockwise, up and left back, about + // the selection's rotation origin; nothing while points are being edited. + if app.session.ds().is_ok_and(|d| d.vector_edit.is_some()) { + return Ok(json!({})); + } + let step = if m.shift { 15.0 } else { 1.0 }; + let origin = app.session.ds().ok().and_then(|d| d.origin_point()); + ("transform.rotate", json!({"degrees": if dx + dy < 0 { -step } else { step }, "cx": origin.map(|o| o.x), "cy": origin.map(|o| o.y)})) } else if m.cmd || m.ctrl { ("transform.nudgeSize", json!({"dw": dx, "dh": dy, "big": m.shift})) } else { ("transform.nudge", json!({"dx": dx, "dy": dy, "big": m.shift})) }; + // Dev Mode only inspects, as the window's key path says. + if crate::menus::dev_mode_refuses(app, cmd) { + return Err("Dev Mode only inspects: switch to Design to edit".into()); + } app.session.execute(cmd, ¶ms).map_err(|e| e.to_string()) } _ => { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 76f220f4f..299d2a20f 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -502,6 +502,7 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { (tr("Use as mask"), "object.mask"), ("-", ""), (tr("Add auto layout"), "layout.add"), + (tr("Remove all auto layout"), "layout.removeAll"), (tr("Create component"), "component.create"), (tr("Detach instance"), "component.detach"), ("-", ""), diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index 11c4466df..8f23069f2 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -216,11 +216,14 @@ pub struct PresentView { pub hotspot_hints: bool, /// A panel beside the prototype shows the Inspect properties of the layer under the pointer. pub inspect: bool, + /// Hide UI: the bar above the prototype and the navigation bar under it are gone, so the + /// prototype shows alone (Cmd/Ctrl+\, the editor's Show/Hide UI key, brings them back). + pub hide_ui: bool, } impl Default for PresentView { fn default() -> Self { - Self { scale: PresentScale::Fit, device_frame: true, comments: false, hotspot_hints: true, inspect: false } + Self { scale: PresentScale::Fit, device_frame: true, comments: false, hotspot_hints: true, inspect: false, hide_ui: false } } } diff --git a/crates/ui-egui/src/present/chrome.rs b/crates/ui-egui/src/present/chrome.rs index 4b477ec9f..4fc57ff0f 100644 --- a/crates/ui-egui/src/present/chrome.rs +++ b/crates/ui-egui/src/present/chrome.rs @@ -21,6 +21,8 @@ pub(super) enum BarAction { Comments, /// Show or hide the Inspect panel. Inspect, + /// Hide UI: hide or show the bars (the Options menu, Cmd/Ctrl+\). + HideUi, } pub struct PresentTab { @@ -53,6 +55,10 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { if !typing && ctx.input_mut(|i| i.consume_key(egui::Modifiers::SHIFT, egui::Key::C)) { action = Some(BarAction::Comments); } + // Cmd/Ctrl+\ hides and shows the bars, as it hides and shows the editor's UI. + if !typing && ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::Backslash)) { + action = Some(BarAction::HideUi); + } if pres.view.inspect { let t = Tokens::get(&ctx); egui::Panel::right("present_inspect") @@ -64,7 +70,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| { action = player(&mut pres, ui, ui.max_rect(), now, Chrome { keys: !typing, ..FULL }).or(action); }); - let picked_device = navigation_bar(&mut pres, &ctx, now); + let picked_device = if pres.view.hide_ui { None } else { navigation_bar(&mut pres, &ctx, now) }; // Keys the prototype left alone: C shows the comments, F fills the screen. if !typing { if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::C)) { @@ -78,6 +84,14 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { match action { Some(BarAction::Comments) => pres.view.comments = !pres.view.comments, Some(BarAction::Inspect) => pres.view.inspect = !pres.view.inspect, + Some(BarAction::HideUi) => { + pres.view.hide_ui = !pres.view.hide_ui; + // As the reference tool does, say how the bars come back. + if pres.view.hide_ui { + let shortcut = crate::toolbar::pretty_shortcut("Cmd+\\"); + app.toast(crate::i18n::trf("The bars are hidden. {shortcut} shows them again", &[("shortcut", &shortcut)])); + } + } _ => {} } if live diff --git a/crates/ui-egui/src/present/navigate.rs b/crates/ui-egui/src/present/navigate.rs index 34e76ea80..b5ae19cec 100644 --- a/crates/ui-egui/src/present/navigate.rs +++ b/crates/ui-egui/src/present/navigate.rs @@ -130,6 +130,7 @@ impl Presenter { "waiting": self.waiting.len(), "inspect": self.view.inspect, "inspected": self.inspected, + "hideUi": self.view.hide_ui, "deviceColor": doc.and_then(|d| page_settings(d, self.current).device_color()).map(|c| c.name), "responsive": self.fit.map(|f| [f.0, f.1]), "frameSize": doc.and_then(|d| d.node(self.current)).map(|n| [n.width, n.height]), diff --git a/crates/ui-egui/src/present/player.rs b/crates/ui-egui/src/present/player.rs index 006d95214..8bcbea09d 100644 --- a/crates/ui-egui/src/present/player.rs +++ b/crates/ui-egui/src/present/player.rs @@ -16,12 +16,14 @@ use super::{ pub(super) fn player(p: &mut Presenter, ui: &mut Ui, rect: Rect, now: f64, chrome: Chrome) -> Option { let ctx = ui.ctx().clone(); p.clock = now; - let bar = Rect::from_min_size(rect.min, vec2(rect.width(), chrome.bar_h)); + // Hide UI (the full-window presenter only): no bar, the prototype takes the whole window. + let hidden = p.view.hide_ui && !chrome.pane; + let bar = Rect::from_min_size(rect.min, vec2(rect.width(), if hidden { 0.0 } else { chrome.bar_h })); let area = Rect::from_min_max(pos2(rect.min.x, bar.max.y), rect.max); let bg = crate::theme::doc_c32(ui.ctx(), p.doc.as_ref().map(|d| page_settings(d, p.current)).unwrap_or_default().background); let r = chrome.round; ui.painter().rect_filled(area, CornerRadius { nw: 0, ne: 0, sw: r, se: r }, bg); - let action = top_bar(p, ui, bar, now, chrome); + let action = if hidden { None } else { top_bar(p, ui, bar, now, chrome) }; if action == Some(BarAction::Restart) { p.restart(p.start, now); } @@ -280,6 +282,12 @@ fn options_menu(p: &mut Presenter, ui: &mut Ui, device: Option, responsi p.view.hotspot_hints = !p.view.hotspot_hints; ui.close(); } + // Hide UI, as the reference tool's Options menu has it (#1820): the bars go away, the key + // (the editor's Show/Hide UI key) brings them back. + if menu_check(ui, p.view.hide_ui, tr("Hide UI"), &crate::toolbar::pretty_shortcut("Cmd+\\")) { + action = Some(BarAction::HideUi); + ui.close(); + } if menu_check(ui, false, tr("Restart"), "R") { p.restart(p.start, now); ui.close(); diff --git a/crates/ui-egui/src/proto_editor_tests.rs b/crates/ui-egui/src/proto_editor_tests.rs index 63c152e14..b16894425 100644 --- a/crates/ui-egui/src/proto_editor_tests.rs +++ b/crates/ui-egui/src/proto_editor_tests.rs @@ -1,10 +1,11 @@ //! The interaction editor's conditionals and the presenter's hotspot hints, driven headless //! through egui (the harness is `present_tests`'). -use egui::{Pos2, Rect, pos2}; +use egui::{Event, Key, Modifiers, Pos2, Rect, pos2}; use figcraft_doc::proto::{Action, Expr, ExprOp}; use figcraft_doc::{NodeId, VariableValue}; use figcraft_engine::Session; +use figcraft_engine::keymap::Platform; use serde_json::{Value, json}; use crate::present_tests::{Ui, id}; @@ -42,6 +43,48 @@ fn click(u: &mut Ui, p: Pos2) { u.button(p, false); } +fn press(u: &mut Ui, key: Key, modifiers: Modifiers) { + let ev = |pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }; + u.frame(vec![ev(true), ev(false)]); +} + +/// Cmd on macOS, Ctrl elsewhere, as egui reports it. +fn cmd() -> Modifiers { + if crate::shortcuts::platform() == Platform::Mac { Modifiers::MAC_CMD | Modifiers::COMMAND } else { Modifiers::CTRL | Modifiers::COMMAND } +} + +/// Hide UI (the Options menu, or Cmd/Ctrl+\ as in the editor) hides the bar above the prototype and +/// the navigation bar under it, so the prototype alone shows and takes the bar's room; the key shows +/// them again; the option carries over like the others, and the prototype still reacts meanwhile. +#[test] +fn hide_ui_hides_the_bars_and_the_key_shows_them_again() { + let (s, a, b, _) = setup(); + let mut u = Ui::new(s); + u.run("proto.present", json!({"start": a})); + u.frame(vec![]); + assert_eq!(presenter(&u)["hideUi"], json!(false), "shown by default"); + let top = |u: &Ui| rect_of(&presenter(u)["hotspots"][0]["rect"]).min.y; + let with_bars = top(&u); + u.run("proto.presentOptions", json!({"hideUi": true})); + u.frame(vec![]); + assert_eq!(presenter(&u)["hideUi"], json!(true)); + assert_eq!(u.app.inspect()["presentOptions"]["hideUi"], json!(true), "kept for the next presentation"); + let without = top(&u); + assert!(without < with_bars, "the prototype moves up into the bar's room: {with_bars} then {without}"); + press(&mut u, Key::Backslash, cmd()); + u.frame(vec![]); + assert_eq!(presenter(&u)["hideUi"], json!(false), "the key shows the bars again"); + assert_eq!(top(&u), with_bars); + press(&mut u, Key::Backslash, cmd()); + u.frame(vec![]); + assert_eq!(presenter(&u)["hideUi"], json!(true), "and hides them once more"); + assert!(u.app.ui.present_view.hide_ui, "kept for the next presentation"); + // The prototype keeps reacting with the bars hidden. + let at = rect_of(&presenter(&u)["hotspots"][0]["rect"]).center(); + click(&mut u, at); + assert_eq!(presenter(&u)["current"], json!(b)); +} + #[test] fn a_click_where_nothing_reacts_flashes_the_hotspots() { let (s, a, b, btn) = setup(); diff --git a/crates/ui-egui/src/shortcut_keys_tests.rs b/crates/ui-egui/src/shortcut_keys_tests.rs index 42413a4fb..6907cac5a 100644 --- a/crates/ui-egui/src/shortcut_keys_tests.rs +++ b/crates/ui-egui/src/shortcut_keys_tests.rs @@ -228,3 +228,52 @@ fn cmd_plus_and_minus_zoom_the_canvas_not_the_interface() { assert!(view(&u).2 < 1.0, "Cmd/Ctrl+- zooms the canvas out: {}", view(&u).2); assert_eq!(u.ctx.zoom_factor(), 1.0); } + +/// "Press ⌘Command ⌥Option ↑/ ↓ on Mac or Control Alt ↑/↓ on Windows to rotate an object one +/// degree at a time" and with Shift "15 degrees at a time" (Accessibility at Figma). The keys +/// turned the selection's size before; Cmd/Ctrl+arrows without Alt still resize. +#[test] +fn cmd_alt_arrows_rotate_a_degree_and_fifteen_with_shift() { + let mut s = Session::new(); + let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 40})).unwrap()); + let mut u = Ui::new(s); + u.app.run("select.set", json!({"ids": [r]})); + let rot = |u: &Ui| figcraft_geom::rotation_degrees(u.app.session.doc().unwrap().node(r).unwrap().transform); + let size = |u: &Ui| { + let n = u.app.session.doc().unwrap().node(r).unwrap().clone(); + (n.width, n.height) + }; + press(&mut u, Key::ArrowDown, cmd() | Modifiers::ALT); + assert!((rot(&u) - 1.0).abs() < 1e-6, "one degree: {}", rot(&u)); + press(&mut u, Key::ArrowDown, cmd() | Modifiers::ALT | Modifiers::SHIFT); + assert!((rot(&u) - 16.0).abs() < 1e-6, "fifteen more: {}", rot(&u)); + press(&mut u, Key::ArrowUp, cmd() | Modifiers::ALT); + assert!((rot(&u) - 15.0).abs() < 1e-6, "one back: {}", rot(&u)); + assert_eq!(size(&u), (40.0, 40.0), "no resize"); + press(&mut u, Key::ArrowDown, cmd()); + assert_eq!(size(&u), (40.0, 41.0), "Cmd/Ctrl+arrows alone still resize"); + // Two layers turned together: the turns go about the rotated selection box's centre, so a + // degree down and a degree up leave both where they were. + let c = id(u.app.session.execute("shape.rectangle", &json!({"x": 200, "y": 0, "width": 40, "height": 40})).unwrap()); + let d = id(u.app.session.execute("shape.rectangle", &json!({"x": 300, "y": 60, "width": 30, "height": 20})).unwrap()); + u.app.run("select.set", json!({"ids": [c, d]})); + u.app.run("transform.rotate", json!({"degrees": 30})); + let bounds = |u: &Ui| [c, d].map(|n| u.app.session.doc().unwrap().world_bounds(n).unwrap()); + let before = bounds(&u); + press(&mut u, Key::ArrowDown, cmd() | Modifiers::ALT); + press(&mut u, Key::ArrowUp, cmd() | Modifiers::ALT); + let after = bounds(&u); + for (x, y) in before.iter().zip(&after) { + assert!( + (x.x0 - y.x0).abs() < 1e-6 && (x.y0 - y.y0).abs() < 1e-6 && (x.x1 - y.x1).abs() < 1e-6 && (x.y1 - y.y1).abs() < 1e-6, + "{x:?} then {y:?}" + ); + } + // While points are being edited the chord does nothing to the layer. + u.app.run("select.set", json!({"ids": [r]})); + let rot_before = rot(&u); + u.app.run("vector.edit.begin", json!({"id": r})); + press(&mut u, Key::ArrowDown, cmd() | Modifiers::ALT); + assert!((rot(&u) - rot_before).abs() < 1e-6, "no turn while editing points"); + u.app.run("vector.edit.end", json!({})); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index a6006976d..f44adb68e 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -484,6 +484,21 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option, m: Modifiers, re app.run("view.pan", json!({"dx": dx, "dy": dy, "big": m.shift})); } else if crate::menus::dev_mode_refuses(app, "transform.nudge") { // Dev Mode: said so, nothing moves. + } else if m.command && m.alt { + // Cmd/Ctrl+Alt+arrows rotate a degree at a time, 15 with Shift (Accessibility at + // Figma); down and right turn clockwise, up and left back (the documentation doesn't + // say which way). Not while points are being edited: the layer must not turn under + // the editor, and no key rotates points. About the selection's rotation origin (the + // moved one, else the centre of the rotated selection box), so repeated turns stay put. + if app.session.ds().is_ok_and(|d| d.vector_edit.is_some()) { + return true; + } + let step = if m.shift { 15.0 } else { 1.0 }; + let origin = app.session.ds().ok().and_then(|d| d.origin_point()); + app.run( + "transform.rotate", + json!({"degrees": if dx + dy < 0.0 { -step } else { step }, "cx": origin.map(|o| o.x), "cy": origin.map(|o| o.y)}), + ); } else if m.command { // Cmd/Ctrl+arrows resize: right/down grow, left/up shrink. app.run("transform.nudgeSize", json!({"dw": dx, "dh": dy, "big": m.shift})); diff --git a/crates/ui-egui/src/ui_scale.rs b/crates/ui-egui/src/ui_scale.rs index 6b275a078..3a407dcf8 100644 --- a/crates/ui-egui/src/ui_scale.rs +++ b/crates/ui-egui/src/ui_scale.rs @@ -47,8 +47,9 @@ pub fn percent(scale: f32) -> u32 { (scale * 100.0).round().clamp(0.0, 100_000.0) as u32 } -/// The sizes Preferences offers, relative to the system's scale (`pref.uiScale` takes up to 3). -pub const SIZES: [f64; 8] = [0.75, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0]; +/// The sizes Preferences offers, relative to the system's scale (`pref.uiScale` takes up to 3); +/// the interface size keys step through the same list. +pub const SIZES: [f64; 8] = figcraft_engine::UI_SCALE_SIZES; #[cfg(test)] mod tests { diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index b9e7e997d..f9f6067d7 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -718,6 +718,8 @@ fn draw(app: &mut FigApp, id: u64, ui: &mut egui::Ui) { adopt(app, &e); settle(app, &ctx, files_before); if app.session.prefs != prefs { + // This window too (see the root's frame): the next frame applies the preference. + ctx.request_repaint(); repaint_others(app, &ctx); } let (outer, inner) = ctx.input(|i| (i.viewport().outer_rect, i.viewport().inner_rect)); diff --git a/docs/auto-layout-parity.md b/docs/auto-layout-parity.md index b38829368..f481fc7ab 100644 --- a/docs/auto-layout-parity.md +++ b/docs/auto-layout-parity.md @@ -1,6 +1,6 @@ # Auto layout and constraints parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-30 · **Change:** minor (grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); Layout sidebar: Add auto layout (+) is available for groups and converts a single group to a frame in place, with mask order, flow-only inference and Fill sizing preserved; adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** minor (a text keeps a max height or a number of max lines, not both, as the Guide to auto layout says: a max height sets max lines to Auto and max lines remove the max height; Remove all auto layout, as the help center's More layout options has it: `layout.removeAll` takes the auto layout off a frame and every frame nested in it in one undo step; grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); Layout sidebar: Add auto layout (+) is available for groups and converts a single group to a frame in place, with mask order, flow-only inference and Fill sizing preserved; adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7) Auto layout is the most used layout feature in Figma and the one where small differences (a hugging frame one pixel off, text wrapping at another width) break real files. **Breadth ~98%, @@ -42,6 +42,8 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver | Hug only on auto layout frames (and text); Fill only in an auto layout flow, never on a top-level frame | pass | `al_behaviour_tests::hug_and_fill_only_where_they_apply` | | Min/max width and height bound Hug, Fill and Fixed sizes, on frames and children | pass | `sizing_rules`; a fill child at its max leaves the rest to the other fill children (silent on redistribution: CSS flexbox) | | A hugging text wider than its max width wraps at the max | pass | `sizing_rules` | +| A text has a max height or a number of max lines, not both: a max height sets max lines to Auto, a number of max lines removes the max height | **fixed** | both coexisted; `node.set {maxHeight}` and `{maxLines}` and `text.maxLines` keep one of the two (a frame's max height is untouched); `text_behaviour_tests::a_text_has_a_max_height_or_max_lines_not_both` | +| Remove all auto layout: a frame and all of its nested objects lose their auto layout at once (More layout options) | **added** | `layout.removeAll {ids?}`, one undo step, in the canvas context menu beside Add auto layout; instances and the layers inside them keep theirs; `al_nesting_tests::remove_all_auto_layout_strips_the_frame_and_every_frame_nested_in_it` | | Text filling a width wraps and its hugging frame grows with it | pass | `sizing_rules` | | A hugging frame follows a child that grows, through nested levels, in one edit and one undo step | pass | `al_behaviour_tests::a_hugging_frame_follows_its_children_through_nested_levels` | | Padding per side (uniform, vertical/horizontal, individual) | pass | `padding_and_stroke_rules` | @@ -95,6 +97,8 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver | Date | Change | Summary | |---|---|---| +| 2026-11-01 | patch | M2: a text layer keeps a max height or a number of max lines, not both ("Text layers cannot have both a max height and a set number of max lines", Guide to auto layout): `node.set {maxHeight}` on a text sets its max lines to Auto, and `node.set {maxLines}` or `text.maxLines` with a number removes its max height. Found by a documentation audit of this page. Test: `text_behaviour_tests::a_text_has_a_max_height_or_max_lines_not_both`. Numbers unchanged | +| 2026-11-01 | minor | M2: Remove all auto layout ("To remove auto layout from a frame and all of its nested objects at once, right-click on the frame and go to More layout options > Remove all auto layout", Toggle on auto layout in designs): `layout.removeAll {ids?}` strips the frames and every frame nested in them in one undo step, leaving instances and their layers alone; in the canvas context menu beside Add auto layout and in the Actions palette. Found by a documentation audit of this page. Test: `al_nesting_tests::remove_all_auto_layout_strips_the_frame_and_every_frame_nested_in_it`. Numbers unchanged | | 2026-10-30 | minor | M8, #1083: grid placement finds free cells on a bitmap, a word at a time, with the same placements; crafted grids of wide children no longer stall a layout pass. Numbers unchanged. | | 2026-10-29 | minor | M2 review fixes: conversion preserves mask runs and Fill sizing per axis; layout inference uses visible, non-absolute flow children; the sidebar hides Add for mixed selections containing existing auto layout. Regression coverage includes rendered mask pixels, visible-child positions, parent layout stability and the sidebar accessibility tree. Numbers unchanged | | 2026-10-29 | minor | M2: the Layout sidebar exposes Add auto layout (+) for groups. `layout.add` converts one selected group into a frame in place rather than wrapping it: same id, name and direct children; the layout arranges those children, and one undo restores the group. Sidebar, menu, shortcut and API calls all use the corrected command. This follows the contributor’s observed Figma behavior and the workflow familiar to users of similar design tools. Tests cover direct-child reflow, stable hierarchy, undo/redo and wrapping a multi-group selection. Numbers unchanged | diff --git a/docs/components-parity.md b/docs/components-parity.md index 051730215..0e3248248 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-31 · **Change:** minor (library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); 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 @@ -15,7 +15,7 @@ accounts). | Create component, instances, detach, reset, go to main, restore main, push changes | done | push from a nested layer goes to the outer main | | Per-layer overrides with reset; kept across main edits, nested swaps, copy/paste | done | main edits match a full rebuild exactly at 2,000 instances | | What an instance overrides: fills, strokes, effects, visibility, layer names, text content and every text property (resizing included), auto layout padding, gap and alignment, layout grids, export settings, prototype interactions, nested swaps and property values; the size and resizing (hug, fill, fixed, min and max) of layers inside it | done | each kept when the main changes that property, reset per group; opacity and corner radius too (documentation silent); size and resizing are kept through relayout, undo, save, `.fig` and REST import and detach, and stay with the instance on push | -| What it doesn't: position, rotation, constraints and absolute or grid placement of layers inside it, its auto layout direction and other settings, its structure | done | refused with a message, the fields greyed out; the instance itself resizes and its layers follow the main's constraints | +| What it doesn't: position, rotation, constraints and absolute or grid placement of layers inside it, its auto layout direction and other settings, its structure | done | refused with a message, the fields greyed out (Delete on a layer inside an instance hides it instead of removing it, as the help center says it does); the instance itself resizes and its layers follow the main's constraints | | Component sets / variants, add variant, switch | done | Combine as variants is in the Object menu, and the canvas and layers context menu and the Design panel offer it when two or more main components are selected; Add variant on a main component not in a set yet makes it a set of itself and the new variant, one undo step (#903); components that are variants already aren't combined again (it used to put a set inside a set) | | A main's copy within its file is an instance of it: Duplicate, Alt-drag, Copy then Paste | done | a variant duplicated in its set stays a new variant and a whole set copies as a new set; only the copied roots become instances (a main inside a copied frame is copied as a main); Cut then Paste and pastes into other files keep the component (#892) | | A component can't contain its own instance, directly or through other components | done | refused with the loop named (creating, placing, swapping, switching variants, moving, pasting, an instance-swap value); each variant counts as its own component, so a variant may hold a sibling variant's instance unless that one leads back to it; a dropped instance lands on the page; a file holding a loop opens with one instance detached and a note, any other file unchanged; a loop merged in a live session stops expanding where an instance would show itself again (#1084) | @@ -78,6 +78,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 2026-11-01 | patch | M3: Delete on a layer inside an instance hides it (an override) instead of being refused ("You can't delete a layer or object from an instance. If you try, Figma will only toggle the layer's visibility instead of removing it", Guide to auto layout); `edit.delete` reports those ids under `hidden`, the structure and the main component stay as they were, one undo step; deleting the instance itself still removes it. Found by a documentation audit of the auto layout page. Test: `components_behaviour_tests::deleting_a_layer_inside_an_instance_hides_it_instead` (the refusal row for it is gone from `what_instances_cannot_override_is_refused`). Numbers unchanged | | 2026-10-31 | minor | M3, gap 10: library analytics cover styles and variables. A file reports how many of its layers use each of a library's styles and variables, and Show usage (`library.usage`) lists them beside the components, by a Type choice, with their layers and files. Layers inside instances count for their component. Insertions, detachments and modes aren't counted for them yet. Numbers unchanged. Tests: `library_assets_tests`. | | 2026-10-31 | minor | M6: as the help center describes code syntax, Dev Mode's SwiftUI and Compose code now writes a bound variable's iOS or Android code syntax in place of its value, as CSS already wrote the Web one: fills, strokes and their weight, sizes, corner radius, gaps, padding, opacity, the font family and size and the characters. A variable with no code syntax for the platform keeps the value, since a name made up here wouldn't exist in the code it lands in. ArkUI and Flutter have no code syntax platform and are unchanged. Test: `variables_behaviour_tests::swiftui_and_compose_name_variables_by_their_code_syntax`. Numbers unchanged. Grade unchanged | | 2026-10-31 | minor | M3, gap 10: library analytics over time. A file counts the instances of library components it inserts (placed, created, pasted or duplicated, Alt-dragged copies too; not those nested in another instance) and reports them like its detaches (`ReportInserted`). The server counts what a file's insertion and detach totals grow past their highest in the current week, a Monday at 00:00 UTC, so an undo and a redo count once, and keeps a year of weeks in `actions.json` (`Actions`). `library.usage {days?}` adds `insertions`, `detachments` and `weeks`, and per component `insertions` and `detachments`; Show usage has a 30 days, 90 days or 1 year choice, the period's totals, and each component's counts on a line of their own. The new variants are appended, so older messages keep their bytes; an older server refuses them and Show usage says it doesn't count insertions. A file's first report after this counts its total in that week. Tests: `library_actions_tests`, `inserting_library_instances_counts_them`. Grade unchanged | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b2a455e0f..82ba08bd1 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -250,9 +250,9 @@ far the view is panned (`pan`, `panRange`), whether a scroll is still coasting ( active screen's `hotspots` (`[{id, rect: [x0, y0, x1, y1]}]` in window points, bottom-most first), whether a click where nothing reacts flashes them (`hotspotHints`) and they are flashing now (`hotspotFlash`), and whether the Inspect panel shows (`inspect`) with the layer it shows -(`inspected`), and the link an Open link notice waits to open (`linkNotice`, else `null`) (`null` +(`inspected`), whether its bars are hidden (`hideUi`), and the link an Open link notice waits to open (`linkNotice`, else `null`) (`null` when nothing plays). `presentOptions` holds the options the next presentation -starts with (`scale`, `deviceFrame`, `comments`, `hotspotHints`, `inspect`). +starts with (`scale`, `deviceFrame`, `comments`, `hotspotHints`, `inspect`, `hideUi`). `textPreview` is true while a live text preview shows on the canvas (a hovered text style or named instance, a dragged axis slider; see `text.preview` in [mcp.md](mcp.md)). `focus` is keyboard navigation's state: the `region` with the keyboard (`toolbar`, `layers`, diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 50805f39e..56cc701a2 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.fig` export writes colour calculations as the colour they show in each mode and leaves out, with a warning, one that resolves to nothing (#1280); `.fig` import budgets four decoded values per compressed byte instead of one, still within 20M–128M, so dense files within that budget open instead of being refused as having too many values (#574); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (a FigJam connector without a saved snapshot took no stroke and was invisible; its line's paint, weight, cap and dashes now come from the file's generated line record (#1807); a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.fig` export writes colour calculations as the colour they show in each mode and leaves out, with a warning, one that resolves to nothing (#1280); `.fig` import budgets four decoded values per compressed byte instead of one, still within 20M–128M, so dense files within that budget open instead of being refused as having too many values (#574); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -17,7 +17,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| -| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks with region-specific fill paints (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes and shapes with text keep their generated backgrounds, styled text, text paints, saved child bounds and sticky author lines as editable frame children, including saved vector networks without geometry paths, outer fill/stroke outlines when generated geometry is damaged, and outer body text when its generated child is unusable even if an author survives (#1341, #1397); connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message; since 2026-10-29, style links that name a library style by asset key and version (the file's own copy of it), with override rows above the linking one still winning, and override rows restating a nested level's overrides applied at that level, below the instance's own (#1234), and the line breaks the file saved with a text, followed until an edit or a resize could move one, or where a line would come out more than 3% wider than its box in the fonts at hand (#1236). Budgets come from the file's own size (four decoded values per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows the file's size too), so a 15.4 MB synthetic file with 300,000 layers opens whole; ZIP image reads have a separate total budget of 4 GiB (1 GiB on 32-bit targets) of decompressed ZIP-member bytes containing encoded images, with the 256 MiB per-image cap kept: a member that cannot fit is skipped before its output buffer is allocated, and the import report names the budget and counts skipped images (#1076). Failed reads and invalid image contents still consume the bytes read; a reference used by several layers is read once. This is not a limit on decoded pixels or total import memory; images carried inside the message are unchanged. The file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Elbowed and curved connectors without a saved path are routed as FigCraft's board connectors are (right angles, or one curve, from the sides of the layers they join). A connector's label without a saved snapshot is kept as text centred on its line, without its background. Not read: table cells beyond their own layers, live style and variable links for vector region fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed), `crates/engine/src/shapebuilder_region_paints_tests.rs`, and by reading back what the export writes; the FigJam report sample from #1341 checked locally only; no representative real-world corpus yet | read tail 5–10, write: checks against real files 5–15 | +| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks with region-specific fill paints (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes and shapes with text keep their generated backgrounds, styled text, text paints, saved child bounds and sticky author lines as editable frame children, including saved vector networks without geometry paths, outer fill/stroke outlines when generated geometry is damaged, and outer body text when its generated child is unusable even if an author survives (#1341, #1397); connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message; since 2026-10-29, style links that name a library style by asset key and version (the file's own copy of it), with override rows above the linking one still winning, and override rows restating a nested level's overrides applied at that level, below the instance's own (#1234), and the line breaks the file saved with a text, followed until an edit or a resize could move one, or where a line would come out more than 3% wider than its box in the fonts at hand (#1236). Budgets come from the file's own size (four decoded values per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows the file's size too), so a 15.4 MB synthetic file with 300,000 layers opens whole; ZIP image reads have a separate total budget of 4 GiB (1 GiB on 32-bit targets) of decompressed ZIP-member bytes containing encoded images, with the 256 MiB per-image cap kept: a member that cannot fit is skipped before its output buffer is allocated, and the import report names the budget and counts skipped images (#1076). Failed reads and invalid image contents still consume the bytes read; a reference used by several layers is read once. This is not a limit on decoded pixels or total import memory; images carried inside the message are unchanged. The file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Elbowed and curved connectors without a saved path are routed as FigCraft's board connectors are (right angles, or one curve, from the sides of the layers they join), and a connector routed here takes its line's paint, weight, cap and dashes from the file's generated line record, as one with a saved snapshot does (#1807). A connector's label without a saved snapshot is kept as text centred on its line, without its background. Not read: table cells beyond their own layers, live style and variable links for vector region fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed), `crates/engine/src/shapebuilder_region_paints_tests.rs`, and by reading back what the export writes; the FigJam report sample from #1341 checked locally only; no representative real-world corpus yet | read tail 5–10, write: checks against real files 5–15 | | `.penpot` (Penpot 2.x ZIP export) | — | **yes, offline** (open, import, drop, CLI, MCP) | **yes, offline** (export, CLI convert) | Editable layers, supported flex/grid layout, components, tokens and prototypes using the existing model. Unsupported layouts retain saved positions and bounded metadata; toggle-overlay and embedded font binaries are reported. Font references remain editable; no downloads. Synthetic regression coverage; live Penpot and cross-platform QA remain open. See [Penpot reference](penpot.md). | further live Penpot and cross-platform QA | | `.sketch` (Sketch 48 and newer) | import (file browser ▸ Import or drop, desktop app too; converted to a Figma file, symbols as components; per the help center) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP; Open With from Finder, Explorer and Linux file managers once installed; recent files show its `previews/preview.png`) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, stretching filling every item across, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides; from Sketch 64, before which a group's fill draws nothing), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); operands' own borders ignored as Sketch does; open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (their image under their style's fills; colour adjustments as image filters with Sketch's tone curve, contrast and then brightness added), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, gradients mixed in OKLab or OKLCH as sRGB stops along the same colours, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer, motion and background blurs (Sketch's radius is the standard deviation), opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does, and its opacity fades it and what it clips together; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, also on shapes' image fills, layer and text styles, nested paths), clipping only where the master clips (`clippingBehavior` 1), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only when the master includes them in both instances and exports, as on Sketch's canvas and renders; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open, an auto-width one growing from its aligned edge. Warnings: noise fills, OKLab and OKLCH gradients, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 68 and 83 (70,000 layers open in ~3 s, debug build; its edition saved by Sketch 47 is refused, as it should be); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 69 1.1% on average and at most 4.4% (text in IBM Plex, which the file embeds and FigCraft doesn't use yet), a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%); the rules for master backgrounds, symbol clipping, mask opacity, bitmap fills, image overrides, stretching stacks, blur radii, colour adjustments, gradient interpolation and text growth checked against Sketch's renders of files built for each case (black-box, synthetic) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths; fonts embedded in the file once `.figcraft` documents can carry fonts: until then they would only last the session). Known limitations: a plain group (not a frame) that fills a stack keeps its layers' size where Sketch stretches them; a font Sketch can't find draws in the installed family's own weight (Sketch falls back to a regular face); text in the system SF Pro comes out a few percent narrower than Sketch's; macOS's reserved system fonts (PingFang) aren't found; brightness past about +0.4 is approximated (image filters can't add); OKLab gradients become sRGB stops (FigCraft gradients mix in sRGB) | | `.jam` (whiteboards) | native to FigJam | **yes, best effort** (`file.open`, `file.import`, File ▸ Open, drop, CLI, MCP) | no | the same archive as a `.fig` (its canvas header reads `fig-jam.`) through the same reader (#1342): it opens as a board (the file kind; a `.fig` stays a design file), with generated sticky and shape backgrounds and text kept (#1341) as ordinary layers rather than native board objects (see [boards-parity](boards-parity.md)); tables, stamps, code blocks and the like come in as frames with their text and connectors as vectors, with an import note; a layer type the reader doesn't know is left out with a note naming it; writing `.jam` is not supported | — | @@ -187,6 +187,7 @@ source paint coordinates. | Date | Change | Summary | |---|---|---| +| 2026-11-01 | patch | `.fig` and `.jam` import: a connector without a saved snapshot (elbowed and curved ones are routed here) took no stroke, so it was invisible (#1807); its line's paint, weight, cap and dashes now come from the file's generated line record, as a saved snapshot's do, and the connector's own values, when it has any, win. Test: `fig::connector_label_tests::a_routed_connector_takes_its_line_style_from_the_generated_record`. Numbers unchanged | | 2026-10-31 | patch | M4: Dash and Custom strokes have a Dash cap (None, Round, Square) in the advanced stroke settings and `node.set {dashCap}`, on both ends of every dash on the canvas and in PNG, PDF and SVG. Unset, dashes keep taking the end point caps, so older files look the same; the saved file writes it only when set. Numbers unchanged | | 2026-10-30 | minor | `.fig` import: a connector's label without a saved snapshot was skipped; now the connector imports as a frame holding its line and the label, as a connector with a saved snapshot does, the label centred on the line's middle by length (in a 200-wide box, laid out again with the fonts at hand), in the connector's fill paints or black. Its background isn't known without the snapshot and is left out. Not checked on real files. Test: `fig::tests::tail::a_labelled_connector_without_a_snapshot_keeps_its_label` | | 2026-10-30 | minor | Pasted content row: an image the clipboard doesn't carry keeps its name and shows a placeholder that reads as missing; the paste names those layers in its message and opens the import report with its notes (#1646). Numbers unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index 0e9f4f497..b5e49e749 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -859,7 +859,7 @@ in the file) the presented frame is instead resized to the device's screen, or w the presenter's window at 100%, and its constraints and auto layout respond as when it is resized in the editor (an axis it hugs keeps hugging its content); the file's frames don't change. `proto.present {start?}` plays the prototype full-window and `proto.preview {start?}` in a resizable pane over the canvas that follows edits live; `proto.closePreview` closes -it. `proto.presentOptions {scale?, deviceFrame?, hotspotHints?, inspect?}` sets how the +it. `proto.presentOptions {scale?, deviceFrame?, hotspotHints?, inspect?, hideUi?}` sets how the full-window presenter sizes the prototype (`fit` scales it down to fit, `fitWidth` fills the width, `fill` covers the window, `actual` shows it at 100%; what overflows pans), whether it draws the device, whether a click where nothing reacts briefly flashes the screen's hotspots (the layers with diff --git a/docs/prototyping-parity.md b/docs/prototyping-parity.md index 345d08753..826b6780d 100644 --- a/docs/prototyping-parity.md +++ b/docs/prototyping-parity.md @@ -1,6 +1,6 @@ # Prototyping parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (the Controller menu names each button as PlayStation and Switch controllers do; "Open in new tab" on Open link: off, the web build's own tab goes to the link; a manual overlay's position can be dragged on the canvas (#405); Open link notices now cover mail links, and skipping them covers only links the viewer fired (#1096); overlay positions relative to the layer that opens them; the Triggers row no longer lists delays on mouse triggers as missing; top-level frames bigger than the device's screen scroll in the player; Change to, set variable and set variable mode lay out what they change in the player, as the canvas does; presentation tabs, one per page of a file; a Present tab with device and scale controls (#537); core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** minor (Hide UI in the presenter's Options menu, and Cmd/Ctrl+\ as in the editor: the bar above the prototype and the navigation bar under it go away, a toast names the key that brings them back, `proto.presentOptions {hideUi}` (#1820); the Controller menu names each button as PlayStation and Switch controllers do; "Open in new tab" on Open link: off, the web build's own tab goes to the link; a manual overlay's position can be dragged on the canvas (#405); Open link notices now cover mail links, and skipping them covers only links the viewer fired (#1096); overlay positions relative to the layer that opens them; the Triggers row no longer lists delays on mouse triggers as missing; top-level frames bigger than the device's screen scroll in the player; Change to, set variable and set variable mode lay out what they change in the player, as the canvas does; presentation tabs, one per page of a file; a Present tab with device and scale controls (#537); core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7) **Breadth ~92%, ready ~74% (estimated).** Every Figma trigger except media, every action except media control, every transition and easing exist; key/gamepad triggers take controller buttons, @@ -44,6 +44,7 @@ tool; opt-in), **silent** (the documentation doesn't say; the case pins the curr | Nested layers with the same trigger: the top-most layer's interaction wins; a layer without that trigger lets it through to the one under it | pass | `nested_hotspots_and_click_through`, `hotspots_priority_and_click_through` | | A layer without interactions over a hotspot | silent | it doesn't block it: clicks reach the hotspot under it | | Hidden layers aren't hotspots; clipping frames cut their hotspots | pass | same | +| Hide UI (Options menu): "Hide the toolbar and footer on your current view" and "Remind you how to restore the toolbar and footer" | **added** | the Options menu's Hide UI and Cmd/Ctrl+\ (the editor's Show/Hide UI key, as the reference tool's) hide the bar above the prototype and the navigation bar under it, and a toast names the key that shows them again; `hide_ui_hides_the_bars_and_the_key_shows_them_again` (#1820) | | Navigate to: replaces the screen; from an overlay it closes the overlays; preserve scroll position keeps the offset (as far as the destination scrolls) | pass | `overlays_stack_swap_close_and_go_with_navigation`, `navigating_resets_scroll_unless_it_preserves_it` | | Back: closes the top overlay first, else returns to the previous screen playing its animation backwards; restart empties the history | pass | `back_plays_the_way_in_backwards_and_restart_starts_over`, `back_and_reverting_play_the_animation_the_other_way` | | Scroll to: every frame around the layer scrolls to bring it in, animated with the interaction's animation | pass | `scroll_to_moves_every_scrolling_frame_around_the_layer` | @@ -92,6 +93,7 @@ tool; opt-in), **silent** (the documentation doesn't say; the case pins the curr | Drag-driven animations that follow the pointer, in their direction | done | | | Presenter: device frames and colours, rotation, custom sizes, fit/fill/100%, responsive frames | done | device bodies drawn in code. Presenting opens a tab for the page, named "file - page", which keeps its screen and history while you go back to the design, with a thumbnail of the current frame, previous / next, device presets and scale choices; the frame is laid out again for the device's screen on the prototype's copy (constraints and auto layout; the file is untouched); on phones and tablets overflowing content drags while the device stays put: a top-level frame bigger than the screen always scrolls (as in Figma, with no overflow setting), content past the frame's own edges as its overflow setting allows ("No scrolling" clips it); closing the window asks about unsaved changes above the presentation (#537). Devices not compared with Figma's | | Hotspot hints, inspect panel in the presenter, comments in the presenter | done | | +| Hide UI in the presenter (Options menu, Cmd/Ctrl+\) | done | the bar above the prototype and the navigation bar under it go away and the prototype takes their room; a toast names the key that brings them back; `proto.presentOptions {hideUi}`, kept for later presentations (#1820) | | Preview pane following edits live | done | | | Edit interactions on several layers at once | done | | | Open link: a notice before a link opens, with a checkbox to skip it on this device | done | every openable link — `http`, `https` and `mailto` — and whatever fired it, a click, a delay, a key (a further link that fires while the notice shows is dropped); skipping it opens at once only a link the viewer's own click, press, drag or key fired, one per frame and never while a notice shows — a delay, the pointer passing over a layer or a timed mouse trigger still shows the notice, without the checkbox; a press under way when the notice appears ends there, so its release doesn't reach the prototype behind it; Open link opens it, Cancel or Escape drops it; the desktop app saves the checkbox with its preferences, and the web build keeps it until the page reloads; `proto.linkNotice {show}` turns it on or off (`present_links_tests`) | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 489c0e7da..75a426231 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-31 · **Change:** minor (a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Thai, Lao, Khmer and Burmese lines break between words (#1219); MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; 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)) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (bulleted lists use the same bullet at every level of indentation, as the help center says, instead of a white bullet and a square at the deeper levels; typing `1)` and a space at the start of a paragraph starts a numbered list, as `1.` does, both being the help center's creation characters; a link on text is underlined by default, as the help center says, and Underline takes the underline off while the link stays; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Thai, Lao, Khmer and Burmese lines break between words (#1219); MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; 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)) · **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 @@ -105,7 +105,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 7 | Effects | 4 | 100% | 75% | Glass and progressive blur tuned side by side (behaviour suite from the documentation, inner shadows and background blur on groups, shadows hidden behind translucent layers on the canvas and in every export landed) | 2–5 | | 8 | Frames, groups, sections, masks | 5 | 97% | 80% | Visual QA with real files (a one-step frame/group convert, and a behaviour suite from the documentation landed: masked-out areas don't take clicks, mask group bounds and exports follow the mask, sections kept out of frames and groups, frames and sections drawn around layers take them, section resizing leaves contents in place, X/Y measured in the frame) | 1–3 | | 9 | Auto layout & constraints | 9 | 98% | 74% | Behaviour against real Figma files (behaviour suite from the documentation, `fr` tracks like CSS, vertical wrap, auto gaps evenly/around, cell alignment and wrapped-line alignment landed) ([auto-layout-parity](auto-layout-parity.md)) | 3–6 | -| 10 | Text | 8 | 90% | 70% | Spell check, text metrics on real files and in other fonts (text box sizes measured against documents re-saved by other software: all 230 self-sizing boxes in 7 documents come out to the pixel, one font family; behaviour suite with CJK line breaking, Thai, Lao, Khmer and Burmese breaks between words, vertical trim and carets by grapheme cluster landed; lists per paragraph with nesting and list spacing, hanging punctuation and lists, decoration style, thickness, offset, skip ink and colour landed) | 3–7 | +| 10 | Text | 8 | 90% | 70% | Spell check, text metrics on real files and in other fonts (text box sizes measured against documents re-saved by other software: all 230 self-sizing boxes in 7 documents come out to the pixel, one font family; behaviour suite with CJK line breaking, Thai, Lao, Khmer and Burmese breaks between words, vertical trim and carets by grapheme cluster landed; lists per paragraph with nesting and list spacing (typing `-`, `*`, `1.` or `1)` and a space starts one), hanging punctuation and lists, decoration style, thickness, offset, skip ink and colour landed) | 3–7 | | 11 | Components & variants | 9 | 96% | 72% | Real (not synthetic) libraries, `.fig` libraries (a component playground for variant, boolean, text and instance-swap properties, a behaviour suite from the documentation, overrides of padding, gap, grids, exports and interactions, refused geometry overrides, variant-switch and Assets-swap override rules, top-left default variant landed) ([components-parity](components-parity.md)) | 4–9 | | 12 | Styles & variables | 5 | 95% | 76% | Real-library QA (variables inside styles, behaviour suite from the documentation, style folders and order, mode order, code syntax, extended collections with their tail (new library variables as updates, chains through libraries) and variables import/export landed) ([components-parity](components-parity.md)) | 2–4 | | 13 | Prototyping | 6 | 92% | 74% | Video triggers and media actions, controllers on macOS, Linux and FreeBSD, real prototype QA (behaviour suite from the documentation, delays on mouse triggers, manual overlay offsets, key combinations, controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes, interactive components playing their variants, drag-driven animations, physical springs landed) ([prototyping-parity](prototyping-parity.md)) | 2–5 | @@ -648,6 +648,9 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-11-01 | patch | M4: bulleted lists draw the same bullet at every level of indentation ("Figma bullets will keep the same styling at all levels of indentation", Create bulleted and numbered lists); the deeper levels drew a white bullet and a small square, pinned as silent before. Numbered levels (1., a., i.) are unchanged. Found by a documentation audit of the text rules. Test: `tail_tests::list_numbering_by_level` (the bullets row). Numbers unchanged | +| 2026-11-01 | patch | M4: typing `1)` followed by a space at the start of a plain paragraph starts a numbered list, as `1.` already did ("Enter one of the creation characters below followed by a Space: 1. 1)", Create bulleted and numbered lists). Found by a documentation audit of the text rules. Test: `text_tail_tests::typing_a_creation_character_and_a_space_starts_a_list`. Numbers unchanged | +| 2026-11-01 | patch | M4: a link made with `text.link` (Cmd/Ctrl+K, Shift+Cmd/Ctrl+U) underlines the linked text unless it has a decoration of its own ("Links are styled with an underline by default. Use the keyboard shortcut to remove the underline", Add links to text); Underline (Cmd/Ctrl+U) takes the underline off and the link stays. Found by a documentation audit of the text rules. Test: `text_behaviour_tests::a_link_is_underlined_by_default_and_underline_takes_it_off`. Numbers unchanged | | 2026-10-31 | patch | M4: Dash and Custom strokes have a Dash cap (None, Round, Square) in the advanced stroke settings and `node.set {dashCap}`, on both ends of every dash on the canvas and in PNG, PDF and SVG. Unset, dashes keep taking the end point caps, so older files look the same; the saved file writes it only when set. Numbers unchanged | | 2026-10-31 | minor | M11, gap 6: widgets draw lines and SVG pictures, as a Figma widget's `Line` and `SVG` do (plugin API 6; older plugins unchanged). A line is 100 long, black and 1 thick unless told otherwise, and turns around its top-left corner. An SVG comes in as locked layers, scaled to fit a size, within the widget's 256 layers. The Counter example uses both. Numbers unchanged. Tests: `widget_shapes_tests`, `widget_tests`. | | 2026-10-30 | minor | M11, gap 6: a widget can open its plugin's panel, as a Figma widget's click handler shows a plugin window (plugin API 5). A widget callback's `ui_show` (click, field edit or menu item) opens the plugin's one panel and keeps the module loaded; each panel callback gets the widget's id and current state as well as the panel's event and values, and its `widget_render` redraws the widget in the same undo step. A widget removed meanwhile isn't redrawn (the callback rolls back, the panel stays); an API 3 or 4 plugin's `ui_show` from a widget still opens nothing; a presented prototype opens no panel. The Counter example (API 5) gets Set count… in its menu: a panel whose Set button redraws it with the count entered. Tests: `widget_panel_tests` (engine), the Counter's unit test. Numbers unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 0286fd37d..3963e4624 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -128,8 +128,8 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | | Count | Notes | |---|---|---| -| Documented shortcuts (rows) | 129 | each with macOS and Windows/Linux keys | -| Bound as documented | 128 (110 before this suite) | 108 keymap bindings, 14 held keys, gestures and presentation keys, 6 vector edit tool keys | +| Documented shortcuts (rows) | 133 | each with macOS and Windows/Linux keys | +| Bound as documented | 132 (110 before this suite) | 111 keymap bindings, 15 held keys, gestures and presentation keys, 6 vector edit tool keys | | Bound on other keys (documented conflict) | 1 | Run last plugin: the documentation also gives Ctrl+Alt+P to pixel preview on Windows; the plugin takes Ctrl+Alt+Shift+P there | | Missing | 0 | moving the rotation origin (Alt+R, `transform.rotationOrigin`) and placing an object with Enter after choosing its tool (`tool.place`) were the last two | | Documented keys a platform keeps | 2 | Ctrl+Space (Windows/Linux keyboard box selection) switches some CJK input methods; F6 goes to a browser's address bar in the web build | @@ -150,7 +150,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look - **Where the documentation disagrees with itself** (noted per row): pixel grid (Shift+' or Cmd+'), letter spacing (Alt or Cmd with < >; Cmd would be the font size keys, Alt bound), Windows remove fill (Alt+/ or Alt+,; Alt+, is letter spacing), the Layers panel on Windows (Alt+1 - or Ctrl+1), send to back / bring to front (one table shows only the bracket). + or Ctrl+1; both bound there, though in the browser Ctrl+1 is the browser's own tab key and only Alt+1 reaches the app), send to back / bring to front (one table shows only the bracket). - **Platform keys:** macOS rows use Cmd and Option, Windows/Linux rows Ctrl and Alt (tested: no macOS glyph on Windows/Linux keycaps, no "Ctrl"/"Alt" words on macOS); no default binding uses a chord the system keeps (Cmd+H/M/Q/Tab/Space, Ctrl+Space and the screenshot keys on macOS; @@ -176,6 +176,9 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-11-01 | patch | Shortcuts: Cmd/Ctrl+Shift+Alt+= and - make the interface larger and smaller through the sizes Preferences offers, and Cmd/Ctrl+Shift+Alt+0 puts it back to the system's size ("Adjust the scale of the Figma UI [...] Reset to Default: Control ⇧Shift Alt 0; Make Larger: Control ⇧Shift Alt =; Make Smaller: Control ⇧Shift Alt -", Accessibility at Figma); the size is kept at once, as one picked in Preferences is. `pref.uiScaleUp`, `pref.uiScaleDown`, `pref.uiScaleReset`; the sizes list now lives in the engine (`UI_SCALE_SIZES`) and Preferences reads it from there. Found by a documentation audit of the shortcuts. Test: `keymap_tests::the_interface_size_keys_step_through_the_preferences_sizes`. Documented rows 130 → 133, bound 129 → 132 | +| 2026-11-01 | patch | Shortcuts: Cmd/Ctrl+Alt+↑/↓ (and ←/→) rotate the selection one degree at a time, 15 with Shift ("Press ⌘Command ⌥Option ↑/ ↓ on Mac or Control Alt ↑/↓ on Windows to rotate an object one degree at a time", Accessibility at Figma); they resized by the nudge before, which Cmd/Ctrl+arrows without Alt still do. Down and right turn clockwise, up and left back (the documentation is silent on the direction). In the shortcuts panel's Transform tab, over the control channel's `ui.key` too. Found by a documentation audit of the shortcuts. Test: `shortcut_keys_tests::cmd_alt_arrows_rotate_a_degree_and_fifteen_with_shift`. Documented rows 129 → 130, bound 128 → 129 | +| 2026-11-01 | patch | Shortcuts: the Layers panel opens with Ctrl+1 as well as Alt+1 on Windows and Linux ("Mac: Option 1 Windows: Ctrl 1" in Select layers and objects; "Windows: Alt 1" in Explore the navigation bar and left sidebar). macOS keeps Option+1. Found by a documentation audit of the shortcuts (every chord driven over the control channel). Tests: `keymap_tests::the_layers_panel_answers_both_of_its_documented_windows_keys`, the parity row. Counts unchanged | | 2026-10-31 | minor | M1, #1682: as the help center describes the colour picker, choosing Image no longer opens a file dialog at once: the tab shows a checkered placeholder and Upload from computer until there is a file. An image paint shows its asset preview with the same button. A file dropped on the open picker replaces the image and keeps the fill mode, crop, position and adjustments; on the placeholder it becomes the paint's image, filling the layer. While a file is dragged the picker says it takes it instead of the window saying Drop to import. New command `image.replace {ids?, target?, index?, data | path}`, through the same size cap and decoder as `image.place`; one undo step. Dropped files in the browser arrive as `data`. A native window or a browser doesn't say where a dragged file is, so a drop with no known position goes to the open picker, and one known to be over the canvas imports as before. Left: the Fill and Stroke swatches in the Design panel don't take a drop, since their place can't be told during a drag; pasting into the picker isn't documented and isn't added. Tests: `colorpicker_image_tests`, `image_crop_tests::replacing_an_image_keeps_its_fill_mode_crop_and_adjustments`. Numbers unchanged | | 2026-10-30 | minor | Gap 4: the keyboard's Menu key opens the context menu, as Shift+F10 does (at the focused layer row or below the canvas selection, with the keyboard in the menu). egui already reported the key; FigCraft's keymap had no name or binding for it. `view.contextMenu` now takes `Menu` too, and the Context menus row is done (`layers_keyboard_tests`). Stale rows corrected: smart-selection spacing has had reorder rings since the "M1: smart-selection drag reordering" landing (the row is still partial: irregular grids and several marked layers aren't covered), and gap 4 and the parity summary quoted shortcut counts from before the last bindings (now 128 bound, none missing, 57 undocumented pinned, as `shortcut_parity_tests` counts). | | 2026-10-30 | minor | M4, gap 4: the eyedropper with Cmd/Ctrl+Shift held makes a colour style or variable of the colour it picks, as the reference app's eyedropper does, instead of filling the selection. It asks the UI for a Create from color window (`dialog.createFromColor`; the control channel never opens it). The window shows the colour and offers Style or Variable, a name (the next free "Color n") and, for a variable, a collection: the file's first, or a new one made with it in the same undo step. Create runs `style.create` or `variable.create`. Tests: `eyedropper_create_tests`, `sampled_color_tests`. |