mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-09 02:34:12 +00:00
Merge upstream main into fix/control-arrows-pan
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Generated
+22
-22
@@ -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",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -314,14 +314,22 @@ stand on its own.
|
||||
|
||||
| | App | What it's for | Code | Learn more |
|
||||
|:-:|---|---|---|---|
|
||||
| <img src="assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | **Interface design, components and prototypes · you are here** | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | Image editing | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **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) |
|
||||
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+11
-6
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -277,14 +277,15 @@ fn insert_model(s: &mut Session, text: &str, ime_commit: bool) -> Result<ImeInse
|
||||
let text = normalized;
|
||||
edit(s, |t, te| {
|
||||
let (a, b) = range(te);
|
||||
// "- ", "* " or "1. " typed at the start of a plain paragraph starts a list.
|
||||
// "- ", "* ", "1. " or "1) " typed at the start of a plain paragraph starts a list (the
|
||||
// creation characters of Create bulleted and numbered lists).
|
||||
if text == " " && a == b && t.para_list(t.para_of(a)).list == ListStyle::None {
|
||||
let chars: Vec<char> = 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 {
|
||||
|
||||
@@ -544,6 +544,15 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
}
|
||||
// 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
|
||||
|
||||
@@ -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<String, Val
|
||||
*if k == "hangingList" { &mut t.hanging_list } else { &mut t.hanging_punctuation } = on;
|
||||
}
|
||||
"leadingTrim" => 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)?)?;
|
||||
|
||||
@@ -137,7 +137,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
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()
|
||||
|
||||
@@ -83,6 +83,32 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
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| {
|
||||
|
||||
@@ -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<Value> {
|
||||
@@ -217,10 +219,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
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!(
|
||||
|
||||
@@ -194,6 +194,22 @@ fn region(s: &mut Session, step: i64) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
let slot = f(&mut s.prefs);
|
||||
*slot = boolean(p, "on").unwrap_or(!*slot);
|
||||
@@ -395,6 +411,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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| {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<f64> {
|
||||
scale.is_finite().then(|| scale.clamp(UI_SCALE_MIN, UI_SCALE_MAX))
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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)]));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<NodeKind> =
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<NodeId> = 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));
|
||||
}
|
||||
|
||||
@@ -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<Value> = 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
|
||||
|
||||
@@ -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() },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -77,6 +77,7 @@ fn api_from_number_or_string<'de, D: serde::Deserializer<'de>>(d: D) -> Result<u
|
||||
.trim()
|
||||
.split('.')
|
||||
.next()
|
||||
.filter(|major| !major.is_empty() && major.bytes().all(|b| b.is_ascii_digit()))
|
||||
.and_then(|major| major.parse().ok())
|
||||
.ok_or_else(|| serde::de::Error::custom(format!("invalid api version `{s}`"))),
|
||||
}
|
||||
|
||||
@@ -472,3 +472,10 @@ fn api_may_be_a_version_string() {
|
||||
assert!(manifest(json!({"api": format!("{}.0.0", crate::API_VERSION + 1)})).is_err());
|
||||
assert!(manifest(json!({"api": "x"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_version_string_rejects_signs() {
|
||||
for bad in ["+2.0.0", "-1.0.0", "+2", ""] {
|
||||
assert!(manifest(json!({"api": bad})).is_err(), "accepted {bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +217,7 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath> {
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<EffectKind>| {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("<filter ").unwrap()..];
|
||||
let attr = |name: &str| -> 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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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##"<image id="pic" x="10" y="20" width="40" height="40" preserveAspectRatio="none" href="data:image/png;base64,{PNG}"/>"##));
|
||||
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##"<image id="pic" x="10" y="20" width="40" height="40" href="data:image/png;base64,{PNG}"/>"##));
|
||||
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");
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1165,9 +1165,12 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
|
||||
|
||||
/// 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<String> {
|
||||
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<String> {
|
||||
(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()
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<Value, String> {
|
||||
// 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())
|
||||
}
|
||||
_ => {
|
||||
|
||||
@@ -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"),
|
||||
("-", ""),
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -16,12 +16,14 @@ use super::{
|
||||
pub(super) fn player(p: &mut Presenter, ui: &mut Ui, rect: Rect, now: f64, chrome: Chrome) -> Option<BarAction> {
|
||||
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<Device>, 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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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!({}));
|
||||
}
|
||||
|
||||
@@ -484,6 +484,21 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option<Key>, 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}));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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`,
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -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
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
File diff suppressed because one or more lines are too long
+7
-4
@@ -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`. |
|
||||
|
||||
Reference in New Issue
Block a user