mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 07:39:20 +00:00
M3: component properties keep their order on rename; Make slot asks for the name (#1536, #1537) (#1565)
Properties were listed by name, so a rename moved one. Components now keep prop_order (the names as they were made; a rename keeps the place) and every listing follows it. Make slot opens a name field under the frame's Slot row; Enter makes the slot, Escape cancels, a taken name keeps the field open.
This commit is contained in:
1 parent
3c1718bf86
commit
7d76b0c673
56 files changed
+589
-135
No files matched your search
+2
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers paneLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (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); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe FontsLine truncated
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
@@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | M3, #1536 and #1537: component properties are listed in the order they were made in, and renaming one keeps its place (they were sorted by name, so a rename moved the property); that holds for every kind, on every variant of a set and on instances, in `component.properties`, the Design panel and `.fig` export. Files from before keep their properties by name until one is added or renamed. Make slot (the Slot menu of a frame inside a component) asks for the slot's name first: a field under the Slot row with the frame's name selected, Enter or Make slot to make it, Escape to cancel; a name taken keeps the field open. Renaming stays in the component's Properties list. Tests: `engine/src/prop_order_tests.rs`, `ui-egui/src/make_slot_naming_tests.rs`. Left: dragging the shared properties to reorder them, and renaming a slot with only its frame selected. Numbers unchanged |
|
||||
| 2026-10-30 | minor | M9, #917: the browser build publishes to a `figcraft-server`. Share ▸ Online (the server in Preferences) and Share ▸ Publish to server… upload the open file over the page's own WebSocket, make the links and join codes others join with, and keep the tab in sync with the new file, as the desktop app does; asking a server about its hosting, stopping a share and sharing again go out the same way. The Share popover offers only online sharing there (a page can't listen, so the local network stays the desktop app's); without a server in Preferences it says so and offers joining. A failed publish says why and leaves the file as it was. The browser keeps only your name across reloads, so the owner token of a file it put on an open server lasts as long as the page (`crates/collab-net/src/exchange.rs`, run by the browser client's driver; the requests and what their answers mean are shared with the desktop transport in `requests.rs`). Tests: `exchange_tests` (scripted server: with and without the key, sharing or not, refusals, an old server without join codes, time-outs, leaving while publishing, the background requests), `live_join_tests`; checked in a browser against a local server. Percentages and hours unchanged |
|
||||
| 2026-10-30 | minor | M4, #1051: fonts installed on this computer in the browser build. Where the browser lists them (Chromium's Local Font Access, `window.queryLocalFonts`), the Design panel's font menu offers "Use fonts installed on this computer…"; the browser asks for permission, and the fonts join the font list by name, with their styles. A font's bytes are read only when text uses it, one style at a time (its preview in the menu waits until then), and auto-sizing text fits it once it is in; one that can't be read is dropped and its text keeps the fallback. A family the build embeds keeps its faces; the computer's styles it lacks join it. Other browsers show no row (`figcraft_text::add_local_fonts`, `apps/figcraft-web/src/local_fonts.rs`). Tests: `fontdb::local_tests`, `local_fonts_tests`; checked in a browser. Percentages and hours unchanged |
|
||||
| 2026-10-30 | minor | M8, #1550: SVG import reads CSS colour functions its SVG parser doesn't (it painted them black, or left strokes out, without a word): `color(srgb …)` and `color(display-p3 …)`, with alpha, converted to the document's profile, and `color-mix(in srgb|oklab|oklch, …)`, evaluated at import (percentages, alpha and nested mixes as CSS Color 5 has them), in colour attributes, `style` attributes and `<style>` sheets. Other colour spaces are taken as sRGB values and other mixes mixed in sRGB, each with an import note; a call that can't be read becomes a mid grey with a note. Bounded: calls over 2,048 characters, mixes nested more than 8 deep and more than 100,000 calls are not read. Tests: `css_color_tests` (svg). Numbers unchanged |
|
||||
|
||||
@@ -102,7 +102,7 @@ fn pages_add_reorder_and_delete() {
|
||||
fn kind_changes_and_transforms_round_trip() {
|
||||
let (a, _, frame, kids) = setup();
|
||||
let mut b = a.document().clone();
|
||||
b.get_mut(frame).unwrap().kind = NodeKind::Component { description: "A card".into(), props: BTreeMap::new() };
|
||||
b.get_mut(frame).unwrap().kind = NodeKind::Component { description: "A card".into(), props: BTreeMap::new(), prop_order: Vec::new() };
|
||||
b.get_mut(kids[0]).unwrap().kind = NodeKind::Ellipse { start: 0.0, sweep: 3.0, inner: 0.25 };
|
||||
b.get_mut(kids[0]).unwrap().transform = figcraft_geom::Affine::new([0.5, 0.1, -0.1, 2.0, 1.0 / 3.0, -7.25]);
|
||||
round_trip(&a, &b);
|
||||
|
||||
@@ -65,6 +65,18 @@ pub enum PropDef {
|
||||
},
|
||||
}
|
||||
|
||||
/// A component's properties in the order they show: the names in `order` first (as they were
|
||||
/// made; renaming keeps the place), then any others by name (files from before the order was kept).
|
||||
pub fn ordered_props<'a>(props: &'a BTreeMap<String, PropDef>, order: &[String]) -> Vec<(&'a String, &'a PropDef)> {
|
||||
let mut out: Vec<(&String, &PropDef)> = Vec::with_capacity(props.len());
|
||||
for (k, d) in order.iter().filter_map(|k| props.get_key_value(k)).chain(props.iter()) {
|
||||
if !out.iter().any(|(o, _)| *o == k) {
|
||||
out.push((k, d));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// What a node is, with per-type data.
|
||||
// Text sets the size; the lint counts the gap to the next largest variant, which grew only because
|
||||
// Page's guides moved to the node (#1476).
|
||||
@@ -115,6 +127,9 @@ pub enum NodeKind {
|
||||
description: String,
|
||||
#[serde(default)]
|
||||
props: BTreeMap<String, PropDef>,
|
||||
/// The order `props` show in, as they were made: see [`ordered_props`].
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
prop_order: Vec<String>,
|
||||
},
|
||||
ComponentSet {
|
||||
#[serde(default)]
|
||||
|
||||
@@ -284,6 +284,16 @@ pub fn default_props(comp: &Node) -> BTreeMap<String, Value> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Component `c`'s property definitions, in the order they show ([`figcraft_doc::ordered_props`]).
|
||||
pub fn prop_list(doc: &Document, c: NodeId) -> Vec<(String, PropDef)> {
|
||||
match doc.node(c).map(|n| &n.kind) {
|
||||
Some(NodeKind::Component { props, prop_order, .. }) => {
|
||||
figcraft_doc::ordered_props(props, prop_order).into_iter().map(|(k, d)| (k.clone(), d.clone())).collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether instances store a value for the property (every kind but slots).
|
||||
pub fn takes_value(d: &PropDef) -> bool {
|
||||
!matches!(d, PropDef::Slot { .. })
|
||||
|
||||
@@ -7,7 +7,7 @@ fn add(doc: &mut Document, parent: NodeId, kind: NodeKind, name: &str) -> NodeId
|
||||
}
|
||||
|
||||
fn component(doc: &mut Document, parent: NodeId, name: &str) -> NodeId {
|
||||
add(doc, parent, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, name)
|
||||
add(doc, parent, NodeKind::Component { description: String::new(), props: BTreeMap::new(), prop_order: Vec::new() }, name)
|
||||
}
|
||||
|
||||
fn mirror(doc: &mut Document, source: NodeId, parent: NodeId) -> NodeId {
|
||||
|
||||
@@ -288,7 +288,7 @@ fn kind_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>)
|
||||
o.insert("componentProperties".into(), json!(props));
|
||||
}
|
||||
}
|
||||
NodeKind::Component { description, props } => {
|
||||
NodeKind::Component { description, props, .. } => {
|
||||
if !description.is_empty() {
|
||||
o.insert("description".into(), json!(description));
|
||||
}
|
||||
|
||||
@@ -342,7 +342,7 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc = s.doc_mut()?;
|
||||
let n = doc.get_mut(target)?;
|
||||
let was_group = matches!(n.kind, NodeKind::Group);
|
||||
n.kind = NodeKind::Component { description: String::new(), props: Default::default() };
|
||||
n.kind = NodeKind::Component { description: String::new(), props: Default::default(), prop_order: Vec::new() };
|
||||
if was_group {
|
||||
n.fills.clear();
|
||||
n.clip = false;
|
||||
|
||||
@@ -331,7 +331,7 @@ pub(super) fn restore_main(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let Some(mut n) = doc.node(*d).cloned() else { continue };
|
||||
if *d == i {
|
||||
let defs = restore_prop_defs(doc, &props);
|
||||
n.kind = NodeKind::Component { description: String::new(), props: defs };
|
||||
n.kind = NodeKind::Component { description: String::new(), props: defs, prop_order: Vec::new() };
|
||||
n.source = None;
|
||||
n.overrides.clear();
|
||||
n.prop_refs.clear();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use figcraft_doc::ordered_props;
|
||||
|
||||
use super::instance::preferred_arg;
|
||||
use super::variants::{fit_set, wrap_in_set};
|
||||
use super::{
|
||||
@@ -6,6 +8,7 @@ use super::{
|
||||
req_string, req_targets, set_boolean_default, slot_edited, slots_of, string, takes_value, targets, variant_conflicts, variant_name,
|
||||
variant_options, variant_props, variant_set,
|
||||
};
|
||||
use crate::components::prop_list;
|
||||
|
||||
/// Whether instance `id` has a component property `name` that a `kind` variable can drive (a
|
||||
/// boolean property takes a boolean variable, a text or variant property a string one).
|
||||
@@ -195,9 +198,22 @@ pub(super) fn prop_defs(doc: &Document, c: NodeId) -> BTreeMap<String, PropDef>
|
||||
}
|
||||
}
|
||||
|
||||
fn set_prop_defs(doc: &mut Document, c: NodeId, f: impl FnOnce(&mut BTreeMap<String, PropDef>)) -> Result<()> {
|
||||
if let NodeKind::Component { props, .. } = &mut doc.get_mut(c)?.kind {
|
||||
/// Change component `c`'s property definitions with `f`, keeping their order: the property
|
||||
/// `rename` names (old, new) keeps its place, new ones go last.
|
||||
fn set_prop_defs(doc: &mut Document, c: NodeId, rename: Option<(&str, &str)>, f: impl FnOnce(&mut BTreeMap<String, PropDef>)) -> Result<()> {
|
||||
if let NodeKind::Component { props, prop_order, .. } = &mut doc.get_mut(c)?.kind {
|
||||
let mut order: Vec<String> = ordered_props(props, prop_order)
|
||||
.into_iter()
|
||||
.map(|(k, _)| match rename {
|
||||
Some((old, new)) if k == old => new.to_string(),
|
||||
_ => k.clone(),
|
||||
})
|
||||
.collect();
|
||||
f(props);
|
||||
order.retain(|k| props.contains_key(k));
|
||||
let added: Vec<String> = props.keys().filter(|k| !order.contains(k)).cloned().collect();
|
||||
order.extend(added);
|
||||
*prop_order = order;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -280,7 +296,7 @@ pub(super) fn add_prop_for(cmd: &str, s: &mut Session, p: &Value) -> Result<Valu
|
||||
_ => return Err(bad(cmd, "type: boolean|text|instanceSwap|variant|slot")),
|
||||
};
|
||||
for c in &comps {
|
||||
set_prop_defs(doc, *c, |m| {
|
||||
set_prop_defs(doc, *c, None, |m| {
|
||||
m.insert(name.clone(), def.clone());
|
||||
})?;
|
||||
// Existing instances get the default.
|
||||
@@ -508,10 +524,9 @@ pub(super) fn edit_prop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let key = new_name.clone().unwrap_or(name.clone());
|
||||
let nd = new_def.clone();
|
||||
let old = name.clone();
|
||||
set_prop_defs(doc, *c, move |m| {
|
||||
m.remove(&old);
|
||||
m.insert(key, nd);
|
||||
set_prop_defs(doc, *c, Some((&name, &key)), |m| {
|
||||
m.remove(&name);
|
||||
m.insert(key.clone(), nd);
|
||||
})?;
|
||||
if let Some(nn) = &new_name {
|
||||
rename_prop_refs(doc, *c, &name, Some(nn))?;
|
||||
@@ -547,7 +562,7 @@ pub(super) fn delete_prop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for c in &comps {
|
||||
if prop_defs(doc, *c).contains_key(&name) {
|
||||
found = true;
|
||||
set_prop_defs(doc, *c, |m| {
|
||||
set_prop_defs(doc, *c, None, |m| {
|
||||
m.remove(&name);
|
||||
})?;
|
||||
rename_prop_refs(doc, *c, &name, None)?;
|
||||
@@ -591,7 +606,7 @@ pub(crate) fn describe_props(doc: &Document, id: NodeId) -> Value {
|
||||
}
|
||||
// Slots: the frames (on a main) or this instance's copy, with whether it holds its own content.
|
||||
let slots = slots_of(doc, if values.is_some() { id } else { variant_set(doc, comp).unwrap_or(comp) });
|
||||
for (k, d) in prop_defs(doc, comp) {
|
||||
for (k, d) in prop_list(doc, comp) {
|
||||
let mut v = def_json(&k, &d);
|
||||
if !takes_value(&d) {
|
||||
let layers: Vec<NodeId> = slots.iter().filter(|(_, n)| *n == k).map(|(l, _)| *l).collect();
|
||||
|
||||
@@ -173,7 +173,7 @@ pub fn kind_for(t: &str) -> Option<(NodeKind, &'static str)> {
|
||||
"text" => (NodeKind::Text(TextData::default()), "Text"),
|
||||
"frame" => (NodeKind::Frame, "Frame"),
|
||||
"section" => (NodeKind::Section { contents_hidden: false }, "Section"),
|
||||
"component" => (NodeKind::Component { description: String::new(), props: Default::default() }, "Component"),
|
||||
"component" => (NodeKind::Component { description: String::new(), props: Default::default(), prop_order: Vec::new() }, "Component"),
|
||||
"group" => (NodeKind::Group, "Group"),
|
||||
"slice" => (NodeKind::Slice, "Slice"),
|
||||
_ => return None,
|
||||
|
||||
@@ -31,7 +31,15 @@ fn cached_import() -> Document {
|
||||
let page = doc.first_page().unwrap();
|
||||
let store = doc.ensure_library_store();
|
||||
let main = doc
|
||||
.create(store, NodeKind::Component { description: String::new(), props: Default::default() }, "Cached main", 0.0, 0.0, 20.0, 20.0)
|
||||
.create(
|
||||
store,
|
||||
NodeKind::Component { description: String::new(), props: Default::default(), prop_order: Vec::new() },
|
||||
"Cached main",
|
||||
0.0,
|
||||
0.0,
|
||||
20.0,
|
||||
20.0,
|
||||
)
|
||||
.unwrap();
|
||||
let body = doc.create(main, NodeKind::Rectangle, "Body", 0.0, 0.0, 20.0, 20.0).unwrap();
|
||||
let inst = doc.create(page, NodeKind::Instance { component: main, props: Default::default() }, "Cached instance", 0.0, 0.0, 20.0, 20.0).unwrap();
|
||||
|
||||
@@ -66,7 +66,8 @@ fn invalid_flags_and_non_frames_keep_the_default_thumbnail() {
|
||||
choose(&mut s, wide);
|
||||
s.doc_mut().unwrap().get_mut(wide).unwrap().kind = NodeKind::Rectangle;
|
||||
assert_eq!(thumbnail(s.doc().unwrap()).unwrap(), default, "only frames can be thumbnails");
|
||||
s.doc_mut().unwrap().get_mut(wide).unwrap().kind = NodeKind::Component { description: String::new(), props: Default::default() };
|
||||
s.doc_mut().unwrap().get_mut(wide).unwrap().kind =
|
||||
NodeKind::Component { description: String::new(), props: Default::default(), prop_order: Vec::new() };
|
||||
assert_ne!(thumbnail(s.doc().unwrap()).unwrap(), default, "a component counts as a frame");
|
||||
}
|
||||
|
||||
|
||||
@@ -82,8 +82,15 @@ pub fn large_document(layers: usize) -> DocResult<(Document, NodeId)> {
|
||||
let page = doc.first_page().ok_or(figcraft_doc::DocError::Invalid("new document has no page".into()))?;
|
||||
let layers = layers.clamp(10, 2_000_000);
|
||||
// The shared button component (3 layers), above the screens.
|
||||
let button =
|
||||
doc.create(page, NodeKind::Component { description: String::new(), props: Default::default() }, "Button", 0.0, -120.0, 96.0, 32.0)?;
|
||||
let button = doc.create(
|
||||
page,
|
||||
NodeKind::Component { description: String::new(), props: Default::default(), prop_order: Vec::new() },
|
||||
"Button",
|
||||
0.0,
|
||||
-120.0,
|
||||
96.0,
|
||||
32.0,
|
||||
)?;
|
||||
let bg = doc.create(button, NodeKind::Rectangle, "Background", 0.0, 0.0, 96.0, 32.0)?;
|
||||
fill(&mut doc, bg, Color::from_rgb8_const(0x0D, 0x99, 0xFF));
|
||||
if let Some(n) = doc.node_mut(bg) {
|
||||
|
||||
@@ -118,7 +118,7 @@ fn var_id(name: &str) -> String {
|
||||
}
|
||||
|
||||
fn component() -> NodeKind {
|
||||
NodeKind::Component { description: String::new(), props: BTreeMap::new() }
|
||||
NodeKind::Component { description: String::new(), props: BTreeMap::new(), prop_order: Vec::new() }
|
||||
}
|
||||
|
||||
fn text(chars: &str) -> NodeKind {
|
||||
|
||||
@@ -427,6 +427,8 @@ pub(crate) mod plugins_tests;
|
||||
#[cfg(test)]
|
||||
mod progressive_blur_tests;
|
||||
#[cfg(test)]
|
||||
mod prop_order_tests;
|
||||
#[cfg(test)]
|
||||
mod property_copy_tests;
|
||||
#[cfg(test)]
|
||||
mod proto_behaviour_tests;
|
||||
|
||||
@@ -83,7 +83,8 @@ fn node_find_other_page_requires_root_and_does_not_switch_pages() {
|
||||
fn node_find_instances_match_main_name_and_visit_actual_children_only() {
|
||||
let mut s = Session::new();
|
||||
let page = s.page().unwrap();
|
||||
let main = add(&mut s, page, NodeKind::Component { description: String::new(), props: Default::default() }, "CAFÉ [Button]");
|
||||
let main =
|
||||
add(&mut s, page, NodeKind::Component { description: String::new(), props: Default::default(), prop_order: Vec::new() }, "CAFÉ [Button]");
|
||||
let original = add(&mut s, main, NodeKind::Text(TextData::new("Original")), "Original");
|
||||
let inst = idv(&ex(&mut s, "instance.create", json!({"component": main})));
|
||||
let child = s.doc().unwrap().get(inst).unwrap().children[0];
|
||||
@@ -116,7 +117,7 @@ fn node_find_accepts_every_canonical_type() {
|
||||
NodeKind::Vector { network: Default::default() },
|
||||
NodeKind::Text(TextData::new("Text")),
|
||||
NodeKind::BooleanOp { op: BoolOp::Union },
|
||||
NodeKind::Component { description: String::new(), props: Default::default() },
|
||||
NodeKind::Component { description: String::new(), props: Default::default(), prop_order: Vec::new() },
|
||||
NodeKind::ComponentSet { description: String::new() },
|
||||
NodeKind::Instance { component: NodeId(u64::MAX), props: Default::default() },
|
||||
NodeKind::Slice,
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
//! Component properties keep the order they were made in: renaming one never moves it (#1536),
|
||||
//! and Make slot takes the name it is given (#1537).
|
||||
|
||||
use figcraft_doc::{NodeId, NodeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn idv(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// The property names `component.properties` lists for `id`, in order.
|
||||
fn names(s: &mut Session, id: NodeId) -> Vec<String> {
|
||||
ex(s, "component.properties", json!({"id": id}))["properties"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|p| p["name"].as_str().unwrap().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn rename(s: &mut Session, id: NodeId, name: &str, new_name: &str) {
|
||||
ex(s, "component.editProperty", json!({"id": id, "name": name, "newName": new_name}));
|
||||
}
|
||||
|
||||
/// A "Card" component with five frames made slots one by one ("Slot 1" … "Slot 5"). Returns
|
||||
/// (card, frames).
|
||||
fn card_with_slots(s: &mut Session) -> (NodeId, Vec<NodeId>) {
|
||||
let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 500, "height": 100, "name": "Card"})));
|
||||
let frames: Vec<NodeId> = (1..=5)
|
||||
.map(|k| {
|
||||
idv(&ex(s, "frame.create", json!({"x": (k - 1) * 100, "y": 0, "width": 90, "height": 90, "parent": f, "name": format!("Slot {k}")})))
|
||||
})
|
||||
.collect();
|
||||
let card = idv(&ex(s, "component.create", json!({"ids": [f]})));
|
||||
for fr in &frames {
|
||||
ex(s, "component.makeSlot", json!({"id": fr}));
|
||||
}
|
||||
(card, frames)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_a_slot_keeps_its_place() {
|
||||
let mut s = Session::new();
|
||||
let (card, _) = card_with_slots(&mut s);
|
||||
assert_eq!(names(&mut s, card), ["Slot 1", "Slot 2", "Slot 3", "Slot 4", "Slot 5"]);
|
||||
rename(&mut s, card, "Slot 1", "Icon 1");
|
||||
rename(&mut s, card, "Slot 2", "Icon 2");
|
||||
rename(&mut s, card, "Icon 1", "Icone 1");
|
||||
assert_eq!(names(&mut s, card), ["Icone 1", "Icon 2", "Slot 3", "Slot 4", "Slot 5"], "the issue's steps");
|
||||
// Renamed to sort first by name, the last one still stays last.
|
||||
rename(&mut s, card, "Slot 5", "A");
|
||||
assert_eq!(names(&mut s, card), ["Icone 1", "Icon 2", "Slot 3", "Slot 4", "A"]);
|
||||
// Undo puts the old name back in its place; a deleted property leaves the others in order.
|
||||
s.undo().unwrap();
|
||||
assert_eq!(names(&mut s, card), ["Icone 1", "Icon 2", "Slot 3", "Slot 4", "Slot 5"]);
|
||||
ex(&mut s, "component.deleteProperty", json!({"id": card, "name": "Icon 2"}));
|
||||
assert_eq!(names(&mut s, card), ["Icone 1", "Slot 3", "Slot 4", "Slot 5"]);
|
||||
// The order is saved with the file.
|
||||
let saved = serde_json::to_string(s.doc().unwrap()).unwrap();
|
||||
let back: figcraft_doc::Document = serde_json::from_str(&saved).unwrap();
|
||||
assert_eq!(back.node(card).map(|n| &n.kind), s.doc().unwrap().node(card).map(|n| &n.kind));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_kind_of_property_keeps_its_place_on_rename() {
|
||||
let mut s = Session::new();
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Size=Small"})));
|
||||
let label = idv(&ex(&mut s, "text.create", json!({"text": "Go", "x": 10, "y": 10, "parent": f})));
|
||||
let body = idv(&ex(&mut s, "frame.create", json!({"x": 100, "y": 0, "width": 90, "height": 90, "parent": f, "name": "Body"})));
|
||||
let icon_f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 20, "height": 20, "name": "Icon"})));
|
||||
let icon = idv(&ex(&mut s, "component.create", json!({"ids": [icon_f]})));
|
||||
let nested = idv(&ex(&mut s, "instance.create", json!({"component": icon, "x": 60, "y": 10, "parent": f})));
|
||||
let small = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
// Made in an order that isn't by name: Zoom (boolean), Label (text), Glyph (instance swap), Body (slot).
|
||||
ex(&mut s, "component.addProperty", json!({"id": small, "name": "Zoom", "type": "boolean"}));
|
||||
ex(&mut s, "component.addProperty", json!({"id": small, "name": "Label", "type": "text", "layer": label}));
|
||||
ex(&mut s, "component.addProperty", json!({"id": small, "name": "Glyph", "type": "instanceSwap", "layer": nested}));
|
||||
ex(&mut s, "component.makeSlot", json!({"id": body}));
|
||||
assert_eq!(names(&mut s, small), ["Zoom", "Label", "Glyph", "Body"]);
|
||||
for (old, new) in [("Zoom", "A zoom"), ("Label", "B label"), ("Glyph", "C glyph"), ("Body", "D body")] {
|
||||
rename(&mut s, small, old, new);
|
||||
}
|
||||
assert_eq!(names(&mut s, small), ["A zoom", "B label", "C glyph", "D body"]);
|
||||
rename(&mut s, small, "A zoom", "Zz");
|
||||
assert_eq!(names(&mut s, small), ["Zz", "B label", "C glyph", "D body"]);
|
||||
// In a set the shared properties keep their order on every variant, after the variant ones
|
||||
// (which keep theirs when renamed too).
|
||||
ex(&mut s, "component.addVariant", json!({"id": small, "property": "Size", "value": "Large"}));
|
||||
let set = s.doc().unwrap().node(small).and_then(|n| n.parent).unwrap();
|
||||
ex(&mut s, "component.addProperty", json!({"id": set, "name": "State", "type": "variant", "default": "Rest"}));
|
||||
assert_eq!(names(&mut s, set), ["Size", "State", "Zz", "B label", "C glyph", "D body"]);
|
||||
rename(&mut s, set, "Size", "Width");
|
||||
rename(&mut s, set, "C glyph", "Alpha");
|
||||
assert_eq!(names(&mut s, set), ["Width", "State", "Zz", "B label", "Alpha", "D body"]);
|
||||
let variants: Vec<NodeId> = s.doc().unwrap().node(set).unwrap().children.clone();
|
||||
for v in variants {
|
||||
assert_eq!(names(&mut s, v), ["Width", "State", "Zz", "B label", "Alpha", "D body"]);
|
||||
}
|
||||
// An instance lists them in the same order.
|
||||
let inst = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 600})));
|
||||
assert_eq!(names(&mut s, inst), ["Width", "State", "Zz", "B label", "Alpha", "D body"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_slot_takes_the_name_typed() {
|
||||
let mut s = Session::new();
|
||||
let (card, frames) = card_with_slots(&mut s);
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 40, "parent": card, "name": "Frame 12"})));
|
||||
let r = ex(&mut s, "component.makeSlot", json!({"id": f, "name": " Avatar "}));
|
||||
assert_eq!(r["name"], "Avatar");
|
||||
assert_eq!(crate::components::slot_name(s.doc().unwrap(), f).as_deref(), Some("Avatar"));
|
||||
assert_eq!(names(&mut s, card).last().map(String::as_str), Some("Avatar"), "a new slot goes last");
|
||||
// A name taken, or one a property can't have, is refused.
|
||||
for bad in ["Slot 1", "a=b", ""] {
|
||||
let other = frames[4];
|
||||
ex(&mut s, "component.removeSlot", json!({"id": other}));
|
||||
assert!(s.execute("component.makeSlot", &json!({"id": other, "name": bad})).is_err(), "{bad:?}");
|
||||
ex(&mut s, "component.makeSlot", json!({"id": other, "name": "Slot 5"}));
|
||||
}
|
||||
// Files from before the order was kept list their properties by name.
|
||||
let doc = s.doc_mut().unwrap();
|
||||
if let Some(NodeKind::Component { prop_order, .. }) = doc.node_mut(card).map(|n| &mut n.kind) {
|
||||
prop_order.clear();
|
||||
}
|
||||
let mut by_name = names(&mut s, card);
|
||||
let listed = by_name.clone();
|
||||
by_name.sort();
|
||||
assert_eq!(listed, by_name);
|
||||
}
|
||||
@@ -526,8 +526,15 @@ fn text_styles_on_part_of_a_text_are_written_as_values() {
|
||||
fn sized() -> (Document, [NodeId; 3]) {
|
||||
let mut doc = Document::new("Sized");
|
||||
let page = doc.first_page().unwrap();
|
||||
let main =
|
||||
add(&mut doc, page, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, "Card", (0.0, 0.0), (100.0, 60.0), &|_| {});
|
||||
let main = add(
|
||||
&mut doc,
|
||||
page,
|
||||
NodeKind::Component { description: String::new(), props: BTreeMap::new(), prop_order: Vec::new() },
|
||||
"Card",
|
||||
(0.0, 0.0),
|
||||
(100.0, 60.0),
|
||||
&|_| {},
|
||||
);
|
||||
add(&mut doc, main, NodeKind::Rectangle, "Box", (10.0, 10.0), (40.0, 40.0), &|n| {
|
||||
n.constraints = Constraints { horizontal: HConstraint::Scale, vertical: VConstraint::Scale };
|
||||
});
|
||||
@@ -623,10 +630,17 @@ fn an_instance_named_after_its_set_has_no_name_of_its_own() {
|
||||
fn layers_bound_like_their_mains_are_no_overrides_in_another_mode() {
|
||||
let mut doc = themed();
|
||||
let page = doc.first_page().unwrap();
|
||||
let main =
|
||||
add(&mut doc, page, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, "Chip", (0.0, 300.0), (80.0, 30.0), &|n| {
|
||||
let main = add(
|
||||
&mut doc,
|
||||
page,
|
||||
NodeKind::Component { description: String::new(), props: BTreeMap::new(), prop_order: Vec::new() },
|
||||
"Chip",
|
||||
(0.0, 300.0),
|
||||
(80.0, 30.0),
|
||||
&|n| {
|
||||
n.bound.insert("fill".into(), "v-bg".into());
|
||||
});
|
||||
},
|
||||
);
|
||||
add(&mut doc, main, NodeKind::Text(TextData::new("Chip")), "Chip label", (8.0, 6.0), (40.0, 16.0), &|n| {
|
||||
n.fills = vec![solid(0.0, 0.0, 0.0)];
|
||||
n.bound.insert("fill".into(), "v-fg".into());
|
||||
@@ -662,15 +676,22 @@ fn layers_of_a_swapped_nested_instance_keep_what_they_change() {
|
||||
let other = add(
|
||||
&mut doc,
|
||||
page,
|
||||
NodeKind::Component { description: String::new(), props: BTreeMap::new() },
|
||||
NodeKind::Component { description: String::new(), props: BTreeMap::new(), prop_order: Vec::new() },
|
||||
"Other",
|
||||
(0.0, 400.0),
|
||||
(100.0, 60.0),
|
||||
&|_| {},
|
||||
);
|
||||
add(&mut doc, other, NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "Dot", (10.0, 10.0), (40.0, 40.0), &|_| {});
|
||||
let row =
|
||||
add(&mut doc, page, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, "Row", (0.0, 500.0), (300.0, 80.0), &|_| {});
|
||||
let row = add(
|
||||
&mut doc,
|
||||
page,
|
||||
NodeKind::Component { description: String::new(), props: BTreeMap::new(), prop_order: Vec::new() },
|
||||
"Row",
|
||||
(0.0, 500.0),
|
||||
(300.0, 80.0),
|
||||
&|_| {},
|
||||
);
|
||||
let mut g = Gen { r: Lcg(3), doc, image: String::new(), frames: Vec::new(), layers: Vec::new() };
|
||||
let thumb = g.instance(card, row, BTreeMap::new());
|
||||
g.doc.node_mut(thumb).unwrap().name = "Thumb".into();
|
||||
|
||||
@@ -76,8 +76,8 @@ impl<'d> Exporter<'d> {
|
||||
// A name used with two types is two properties (their definitions have their own ids).
|
||||
let mut seen: BTreeMap<(String, &'static str), PropDef> = BTreeMap::new();
|
||||
for v in &variants {
|
||||
let NodeKind::Component { props, .. } = &v.kind else { continue };
|
||||
for (name, d) in props {
|
||||
let NodeKind::Component { props, prop_order, .. } = &v.kind else { continue };
|
||||
for (name, d) in figcraft_doc::ordered_props(props, prop_order) {
|
||||
let Some(ty) = prop_type(d) else { continue };
|
||||
match seen.get(&(name.clone(), ty)) {
|
||||
Some(first) if first != d => self.warn("variants with different defaults for one property were written with the first variant's"),
|
||||
|
||||
@@ -109,7 +109,7 @@ impl<'d> Exporter<'d> {
|
||||
self.warn("boolean groups whose layers combine in different ways use the group's operation");
|
||||
}
|
||||
}
|
||||
NodeKind::Component { description, props } if !nested => {
|
||||
NodeKind::Component { description, props, prop_order } if !nested => {
|
||||
if let Some(set) = self.set_of(n.id) {
|
||||
// A variant: its set holds the definitions; the variant has its own copy of
|
||||
// each (pointing at the set's), and its values of the variant properties.
|
||||
@@ -136,7 +136,11 @@ impl<'d> Exporter<'d> {
|
||||
f.push(("variantPropSpecs", arr(specs)));
|
||||
}
|
||||
} else {
|
||||
let defs: Vec<Kv> = props.iter().enumerate().filter_map(|(i, (name, d))| self.prop_def(n.id, name, d, i)).collect();
|
||||
let defs: Vec<Kv> = figcraft_doc::ordered_props(props, prop_order)
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, (name, d))| self.prop_def(n.id, name, d, i))
|
||||
.collect();
|
||||
if !defs.is_empty() {
|
||||
f.push(("componentPropDefs", arr(defs)));
|
||||
}
|
||||
|
||||
@@ -410,7 +410,7 @@ impl Gen {
|
||||
/// A component with a boolean and a text property, a component set, and instances of both
|
||||
/// (with property values and overrides), and one nested two levels deep.
|
||||
fn components(&mut self, page: NodeId) {
|
||||
let comp = self.node(page, NodeKind::Component { description: "A button".into(), props: BTreeMap::new() }, "Button");
|
||||
let comp = self.node(page, NodeKind::Component { description: "A button".into(), props: BTreeMap::new(), prop_order: Vec::new() }, "Button");
|
||||
let icon = self.node(comp, NodeKind::Rectangle, "Icon");
|
||||
let label = self.node(comp, NodeKind::Text(TextData::new("Label")), "Label");
|
||||
{
|
||||
@@ -449,7 +449,7 @@ impl Gen {
|
||||
}
|
||||
// A card holding an instance of the button, and an instance of the card overriding a layer of
|
||||
// the nested button.
|
||||
let card = self.node(page, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, "Card");
|
||||
let card = self.node(page, NodeKind::Component { description: String::new(), props: BTreeMap::new(), prop_order: Vec::new() }, "Card");
|
||||
// A grid whose second cell holds the nested button.
|
||||
self.doc.node_mut(card).unwrap().layout = Some(AutoLayout { mode: LayoutMode::Grid, grid_columns: 2, grid_rows: 1, ..AutoLayout::default() });
|
||||
let nested = self.instance(comp, card, BTreeMap::new());
|
||||
@@ -465,7 +465,7 @@ impl Gen {
|
||||
let set = self.node(page, NodeKind::ComponentSet { description: "States".into() }, "Toggle");
|
||||
let mut variants = Vec::new();
|
||||
for name in ["State=Off", "State=On"] {
|
||||
let v = self.node(set, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, name);
|
||||
let v = self.node(set, NodeKind::Component { description: String::new(), props: BTreeMap::new(), prop_order: Vec::new() }, name);
|
||||
self.node(v, NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "Knob");
|
||||
variants.push(v);
|
||||
}
|
||||
@@ -1120,7 +1120,17 @@ fn grid_track_sizes_round_trip() {
|
||||
fn instance_grid_tracks_clear_the_mains_sizes() {
|
||||
let mut doc = Document::new("File");
|
||||
let page = doc.first_page().unwrap();
|
||||
let main = doc.create(page, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, "Main", 0.0, 0.0, 400.0, 200.0).unwrap();
|
||||
let main = doc
|
||||
.create(
|
||||
page,
|
||||
NodeKind::Component { description: String::new(), props: BTreeMap::new(), prop_order: Vec::new() },
|
||||
"Main",
|
||||
0.0,
|
||||
0.0,
|
||||
400.0,
|
||||
200.0,
|
||||
)
|
||||
.unwrap();
|
||||
let grid = doc.create(main, NodeKind::Frame, "Grid", 0.0, 0.0, 400.0, 200.0).unwrap();
|
||||
let layout = AutoLayout {
|
||||
mode: LayoutMode::Grid,
|
||||
@@ -1327,8 +1337,17 @@ fn set_with_properties() -> (Document, NodeId) {
|
||||
}
|
||||
let mut doc = Document::new("File");
|
||||
let page = doc.first_page().unwrap();
|
||||
let glyph =
|
||||
doc.create(page, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, "Glyph", 400.0, 0.0, 16.0, 16.0).unwrap();
|
||||
let glyph = doc
|
||||
.create(
|
||||
page,
|
||||
NodeKind::Component { description: String::new(), props: BTreeMap::new(), prop_order: Vec::new() },
|
||||
"Glyph",
|
||||
400.0,
|
||||
0.0,
|
||||
16.0,
|
||||
16.0,
|
||||
)
|
||||
.unwrap();
|
||||
doc.create(glyph, NodeKind::Rectangle, "Shape", 0.0, 0.0, 16.0, 16.0).unwrap();
|
||||
let set = doc.create(page, NodeKind::ComponentSet { description: String::new() }, "Button", 0.0, 0.0, 300.0, 120.0).unwrap();
|
||||
let mut variants = Vec::new();
|
||||
@@ -1337,7 +1356,9 @@ fn set_with_properties() -> (Document, NodeId) {
|
||||
("Label".to_string(), PropDef::Text { default: "Button".into() }),
|
||||
("Show icon".to_string(), PropDef::Boolean { default: true }),
|
||||
]);
|
||||
let v = doc.create(set, NodeKind::Component { description: String::new(), props }, name, 0.0, 60.0 * i as f64, 120.0, 40.0).unwrap();
|
||||
let v = doc
|
||||
.create(set, NodeKind::Component { description: String::new(), props, prop_order: Vec::new() }, name, 0.0, 60.0 * i as f64, 120.0, 40.0)
|
||||
.unwrap();
|
||||
let label = doc.create(v, NodeKind::Text(TextData::new("Button")), "Label", 0.0, 0.0, 60.0, 20.0).unwrap();
|
||||
doc.node_mut(label).unwrap().prop_refs.insert("characters".into(), "Label".into());
|
||||
// The icon is an instance placed in the variant: like the editor's, its `source` is its
|
||||
@@ -1466,7 +1487,17 @@ fn set_properties_round_trip() {
|
||||
fn an_instance_without_its_mains_maximum_size_overrides_it_with_infinity() {
|
||||
let mut doc = Document::new("File");
|
||||
let page = doc.first_page().unwrap();
|
||||
let main = doc.create(page, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, "Box", 0.0, 0.0, 100.0, 40.0).unwrap();
|
||||
let main = doc
|
||||
.create(
|
||||
page,
|
||||
NodeKind::Component { description: String::new(), props: BTreeMap::new(), prop_order: Vec::new() },
|
||||
"Box",
|
||||
0.0,
|
||||
0.0,
|
||||
100.0,
|
||||
40.0,
|
||||
)
|
||||
.unwrap();
|
||||
doc.node_mut(main).unwrap().child_layout.max_w = Some(300.0);
|
||||
doc.create(page, NodeKind::Instance { component: main, props: BTreeMap::new() }, "Box", 0.0, 100.0, 100.0, 40.0).unwrap();
|
||||
let (bytes, _) = export(&doc, None).unwrap();
|
||||
|
||||
@@ -15,8 +15,17 @@ use super::*;
|
||||
fn large(screens: usize) -> Document {
|
||||
let mut d = Document::new("Large");
|
||||
let page = d.first_page().unwrap();
|
||||
let button =
|
||||
d.create(page, NodeKind::Component { description: "A button".into(), props: Default::default() }, "Button", 0.0, -80.0, 96.0, 32.0).unwrap();
|
||||
let button = d
|
||||
.create(
|
||||
page,
|
||||
NodeKind::Component { description: "A button".into(), props: Default::default(), prop_order: Vec::new() },
|
||||
"Button",
|
||||
0.0,
|
||||
-80.0,
|
||||
96.0,
|
||||
32.0,
|
||||
)
|
||||
.unwrap();
|
||||
let label = d.create(button, NodeKind::Text(TextData::new("OK")), "Label", 8.0, 8.0, 40.0, 16.0).unwrap();
|
||||
d.get_mut(label).unwrap().plugin_data.insert("agent".into(), "note".into());
|
||||
for s in 0..screens {
|
||||
|
||||
@@ -19,7 +19,17 @@ fn png() -> Vec<u8> {
|
||||
fn published() -> (Document, figcraft_doc::NodeId, String) {
|
||||
let mut d = Document::new("Kit");
|
||||
let p = d.first_page().unwrap();
|
||||
let tile = d.create(p, NodeKind::Component { description: String::new(), props: Default::default() }, "Tile", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
let tile = d
|
||||
.create(
|
||||
p,
|
||||
NodeKind::Component { description: String::new(), props: Default::default(), prop_order: Vec::new() },
|
||||
"Tile",
|
||||
0.0,
|
||||
0.0,
|
||||
10.0,
|
||||
10.0,
|
||||
)
|
||||
.unwrap();
|
||||
let (hash, _, _) = add_image(&mut d, png()).unwrap();
|
||||
d.get_mut(tile).unwrap().fills = vec![Paint::image(hash.clone(), ImageScaleMode::Fill)];
|
||||
let snap = d.clone();
|
||||
|
||||
@@ -2970,7 +2970,7 @@ fn layer_kind(
|
||||
let mut path_box = None;
|
||||
let mut vector_source = None;
|
||||
let kind = match typ {
|
||||
"frame" if is_master => NodeKind::Component { description: String::new(), props: BTreeMap::new() },
|
||||
"frame" if is_master => NodeKind::Component { description: String::new(), props: BTreeMap::new(), prop_order: Vec::new() },
|
||||
"frame" if v.get("isVariantContainer").and_then(Value::as_bool) == Some(true) => NodeKind::ComponentSet { description: String::new() },
|
||||
"frame" => NodeKind::Frame,
|
||||
"group" => NodeKind::Group,
|
||||
|
||||
@@ -451,7 +451,16 @@ fn unchanged_penpot_text_and_file_header_are_written_back() {
|
||||
fn components_instances_and_variants_round_trip() {
|
||||
let mut doc = Document::new("Library");
|
||||
let p = page(&doc);
|
||||
let main = add(&mut doc, p, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, "Button", 0.0, 0.0, 120.0, 40.0);
|
||||
let main = add(
|
||||
&mut doc,
|
||||
p,
|
||||
NodeKind::Component { description: String::new(), props: BTreeMap::new(), prop_order: Vec::new() },
|
||||
"Button",
|
||||
0.0,
|
||||
0.0,
|
||||
120.0,
|
||||
40.0,
|
||||
);
|
||||
let label = add(&mut doc, main, NodeKind::Rectangle, "Fill", 0.0, 0.0, 120.0, 40.0);
|
||||
doc.node_mut(label).unwrap().fills = vec![Paint::solid(Color::BLACK)];
|
||||
let copy = add(&mut doc, p, NodeKind::Instance { component: main, props: BTreeMap::new() }, "Button copy", 0.0, 100.0, 120.0, 40.0);
|
||||
@@ -465,7 +474,16 @@ fn components_instances_and_variants_round_trip() {
|
||||
doc.node_mut(copy).unwrap().source = Some(main);
|
||||
let set = add(&mut doc, p, NodeKind::ComponentSet { description: String::new() }, "Size", 300.0, 0.0, 200.0, 100.0);
|
||||
for (name, y) in [("size=small", 0.0), ("size=large", 50.0)] {
|
||||
add(&mut doc, set, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, name, 0.0, y, 100.0, 40.0);
|
||||
add(
|
||||
&mut doc,
|
||||
set,
|
||||
NodeKind::Component { description: String::new(), props: BTreeMap::new(), prop_order: Vec::new() },
|
||||
name,
|
||||
0.0,
|
||||
y,
|
||||
100.0,
|
||||
40.0,
|
||||
);
|
||||
}
|
||||
|
||||
let (back, e, _) = roundtrip(&doc);
|
||||
|
||||
@@ -613,7 +613,7 @@ impl<'a> Ctx<'a> {
|
||||
},
|
||||
},
|
||||
"TEXT" => NodeKind::Text(text::text_data(self, j)),
|
||||
"COMPONENT" => NodeKind::Component { description: description(&self.component_meta, j), props: BTreeMap::new() },
|
||||
"COMPONENT" => NodeKind::Component { description: description(&self.component_meta, j), props: BTreeMap::new(), prop_order: Vec::new() },
|
||||
"COMPONENT_SET" => NodeKind::ComponentSet { description: description(&self.set_meta, j) },
|
||||
"SLICE" => NodeKind::Slice,
|
||||
_ => return None,
|
||||
|
||||
@@ -469,7 +469,7 @@ fn components_sets_instances_and_properties() {
|
||||
assert_eq!(set.stroke.dashes, vec![10.0, 5.0]);
|
||||
let def = named(&doc, "State=Default");
|
||||
let icon = named(&doc, "Icon");
|
||||
let NodeKind::Component { description, props } = &def.kind else { panic!() };
|
||||
let NodeKind::Component { description, props, .. } = &def.kind else { panic!() };
|
||||
assert_eq!(description, "Primary button");
|
||||
assert_eq!(props.get("Label"), Some(&PropDef::Text { default: "OK".into() }), "{props:?}");
|
||||
assert_eq!(props.get("Icon"), Some(&PropDef::InstanceSwap { default: icon.id, preferred: Vec::new() }));
|
||||
@@ -477,7 +477,7 @@ fn components_sets_instances_and_properties() {
|
||||
let NodeKind::Component { props: hover_props, .. } = &named(&doc, "State=Hover").kind else { panic!() };
|
||||
assert!(hover_props.contains_key("Label"), "variants share the set's definitions");
|
||||
assert!(
|
||||
matches!(&icon.kind, NodeKind::Component { description, props } if description == "An icon" && props.get("Show") == Some(&PropDef::Boolean { default: true }))
|
||||
matches!(&icon.kind, NodeKind::Component { description, props, .. } if description == "An icon" && props.get("Show") == Some(&PropDef::Boolean { default: true }))
|
||||
);
|
||||
let label = doc.node(def.children[0]).unwrap();
|
||||
assert_eq!(label.prop_refs.get("characters").map(String::as_str), Some("Label"));
|
||||
|
||||
@@ -808,6 +808,7 @@ status = "draft — needs native review"
|
||||
"Main menu" = "القائمة الرئيسية"
|
||||
"Make a component" = "إنشاء مكوّن"
|
||||
"Make slot" = "إنشاء خانة محتوى"
|
||||
"Slot name" = "اسم خانة المحتوى"
|
||||
"Mar" = "مارس"
|
||||
"Margin" = "الهامش"
|
||||
"Mark all as read" = "تحديد الكل كمقروء"
|
||||
|
||||
@@ -776,6 +776,7 @@ status = "draft — needs native review"
|
||||
"Main menu" = "প্রধান মেনু"
|
||||
"Make a component" = "একটি কম্পোনেন্ট তৈরি করুন"
|
||||
"Make slot" = "স্লট তৈরি করুন"
|
||||
"Slot name" = "স্লটের নাম"
|
||||
"Mar" = "মার্চ"
|
||||
"Margin" = "মার্জিন"
|
||||
"Mark all as read" = "সব পঠিত হিসেবে চিহ্নিত করুন"
|
||||
|
||||
@@ -761,6 +761,7 @@ status = "draft — needs native review"
|
||||
"Main menu" = "Hlavní nabídka"
|
||||
"Make a component" = "Vytvořit komponentu"
|
||||
"Make slot" = "Vytvořit slot"
|
||||
"Slot name" = "Název slotu"
|
||||
"Mar" = "března"
|
||||
"Margin" = "Vnější okraj"
|
||||
"Mark all as read" = "Označit vše jako přečtené"
|
||||
|
||||
@@ -740,6 +740,7 @@ status = "draft — needs native review"
|
||||
"Main component deleted" = "Hauptkomponente gelöscht"
|
||||
"Main menu" = "Hauptmenü"
|
||||
"Make slot" = "Slot erstellen"
|
||||
"Slot name" = "Slot-Name"
|
||||
"Margin" = "Rand"
|
||||
"Mark all as read" = "Alle als gelesen markieren"
|
||||
"Mark as resolved" = "Als erledigt markieren"
|
||||
|
||||
@@ -813,6 +813,7 @@ name = "English"
|
||||
"Main menu" = "Main menu"
|
||||
"Make a component" = "Make a component"
|
||||
"Make slot" = "Make slot"
|
||||
"Slot name" = "Slot name"
|
||||
"Mar" = "Mar"
|
||||
"Margin" = "Margin"
|
||||
"Mark all as read" = "Mark all as read"
|
||||
|
||||
@@ -801,6 +801,7 @@ status = "draft — needs native review"
|
||||
"Main menu" = "Menú principal"
|
||||
"Make a component" = "Crea un componente"
|
||||
"Make slot" = "Convertir en ranura"
|
||||
"Slot name" = "Nombre de la ranura"
|
||||
"Mar" = "mar"
|
||||
"Margin" = "Margen"
|
||||
"Mark all as read" = "Marcar todo como leído"
|
||||
|
||||
@@ -778,6 +778,7 @@ status = "draft — needs native review"
|
||||
"Main menu" = "Päävalikko"
|
||||
"Make a component" = "Luo komponentti"
|
||||
"Make slot" = "Luo sisältöpaikka"
|
||||
"Slot name" = "Sisältöpaikan nimi"
|
||||
"Mar" = "maalisk."
|
||||
"Margin" = "Marginaali"
|
||||
"Mark all as read" = "Merkitse kaikki luetuiksi"
|
||||
|
||||
@@ -778,6 +778,7 @@ status = "draft — needs native review"
|
||||
"Main menu" = "Menu principal"
|
||||
"Make a component" = "Créer un composant"
|
||||
"Make slot" = "Créer un emplacement"
|
||||
"Slot name" = "Nom de l'emplacement"
|
||||
"Mar" = "mars"
|
||||
"Margin" = "Marge"
|
||||
"Mark all as read" = "Tout marquer comme lu"
|
||||
|
||||
@@ -768,6 +768,7 @@ status = "draft — needs native review"
|
||||
"Main menu" = "Menu utama"
|
||||
"Make a component" = "Buat component"
|
||||
"Make slot" = "Jadikan slot"
|
||||
"Slot name" = "Nama slot"
|
||||
"Mar" = "Mar"
|
||||
"Margin" = "Margin"
|
||||
"Mark all as read" = "Tandai semua sudah dibaca"
|
||||
|
||||
@@ -751,6 +751,7 @@ status = "draft — needs native review"
|
||||
"Main menu" = "Menu principale"
|
||||
"Make a component" = "Crea un componente"
|
||||
"Make slot" = "Crea slot"
|
||||
"Slot name" = "Nome dello slot"
|
||||
"Mar" = "mar"
|
||||
"Margin" = "Margine"
|
||||
"Mark all as read" = "Segna tutto come letto"
|
||||
|
||||
@@ -772,6 +772,7 @@ status = "draft — needs native review"
|
||||
"Main menu" = "メインメニュー"
|
||||
"Make a component" = "コンポーネントを作成"
|
||||
"Make slot" = "スロットにする"
|
||||
"Slot name" = "スロット名"
|
||||
"Mar" = "3月"
|
||||
"Margin" = "マージン"
|
||||
"Mark all as read" = "すべて既読にする"
|
||||
|
||||
@@ -785,6 +785,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Main menu" = "მთავარი მენიუ"
|
||||
"Make a component" = "შექმენით კომპონენტი"
|
||||
"Make slot" = "სლოტად გადაქცევა"
|
||||
"Slot name" = "სლოტის სახელი"
|
||||
"Mar" = "მარ"
|
||||
"Margin" = "ველი"
|
||||
"Mark all as read" = "ყველას წაკითხულად მარკირება"
|
||||
|
||||
@@ -736,6 +736,7 @@ status = "draft — needs native review"
|
||||
"Main component deleted" = "메인 컴포넌트가 삭제됨"
|
||||
"Main menu" = "메인 메뉴"
|
||||
"Make slot" = "슬롯으로 만들기"
|
||||
"Slot name" = "슬롯 이름"
|
||||
"Margin" = "여백"
|
||||
"Mark all as read" = "모두 읽음으로 표시"
|
||||
"Mark as resolved" = "해결됨으로 표시"
|
||||
|
||||
@@ -778,6 +778,7 @@ status = "draft — needs native review"
|
||||
"Main menu" = "Hoofdmenu"
|
||||
"Make a component" = "Maak een component"
|
||||
"Make slot" = "Slot maken"
|
||||
"Slot name" = "Slotnaam"
|
||||
"Mar" = "mrt."
|
||||
"Margin" = "Marge"
|
||||
"Mark all as read" = "Markeer alles als gelezen"
|
||||
|
||||
@@ -780,6 +780,7 @@ name = "Polski"
|
||||
"Main menu" = "Menu główne"
|
||||
"Make a component" = "Utwórz komponent"
|
||||
"Make slot" = "Utwórz gniazdo (slot)"
|
||||
"Slot name" = "Nazwa gniazda"
|
||||
"Mar" = "Mar"
|
||||
"Margin" = "Margines"
|
||||
"Mark all as read" = "Oznacz wszystkie jako przeczytane"
|
||||
|
||||
@@ -735,6 +735,7 @@ status = "draft — needs native review"
|
||||
"Main component deleted" = "Componente principal excluído"
|
||||
"Main menu" = "Menu principal"
|
||||
"Make slot" = "Transformar em slot"
|
||||
"Slot name" = "Nome do slot"
|
||||
"Margin" = "Margem"
|
||||
"Mark all as read" = "Marcar tudo como lido"
|
||||
"Mark as resolved" = "Marcar como resolvido"
|
||||
|
||||
@@ -780,6 +780,7 @@ name = "Русский"
|
||||
"Main menu" = "Главное меню"
|
||||
"Make a component" = "Создать компонент"
|
||||
"Make slot" = "Сделать слотом"
|
||||
"Slot name" = "Имя слота"
|
||||
"Mar" = "мар."
|
||||
"Margin" = "Внешний отступ"
|
||||
"Mark all as read" = "Отметить всё как прочитанное"
|
||||
|
||||
@@ -802,6 +802,7 @@ name = "Svenska"
|
||||
"Main menu" = "Huvudmeny"
|
||||
"Make a component" = "Skapa en komponent"
|
||||
"Make slot" = "Gör till plats"
|
||||
"Slot name" = "Platsens namn"
|
||||
"Mar" = "Mar"
|
||||
"Margin" = "Marginal"
|
||||
"Mark all as read" = "Märk alla som lästa"
|
||||
|
||||
@@ -774,6 +774,7 @@ status = "draft — needs native review"
|
||||
"Main menu" = "เมนูหลัก"
|
||||
"Make a component" = "สร้างคอมโพเนนต์"
|
||||
"Make slot" = "สร้างสล็อต"
|
||||
"Slot name" = "ชื่อสล็อต"
|
||||
"Mar" = "มี.ค."
|
||||
"Margin" = "ระยะขอบนอก"
|
||||
"Mark all as read" = "ทำเครื่องหมายว่าอ่านทั้งหมดแล้ว"
|
||||
|
||||
@@ -765,6 +765,7 @@ name = "Türkçe"
|
||||
"Main menu" = "Ana menü"
|
||||
"Make a component" = "Bileşen yap"
|
||||
"Make slot" = "Yuva yap"
|
||||
"Slot name" = "Yuva adı"
|
||||
"Mar" = "Mar"
|
||||
"Margin" = "Kenar boşluğu"
|
||||
"Mark all as read" = "Tümünü okundu olarak işaretle"
|
||||
|
||||
@@ -752,6 +752,7 @@ status = "draft — needs native review"
|
||||
"Main menu" = "Trình đơn chính"
|
||||
"Make a component" = "Tạo thành phần"
|
||||
"Make slot" = "Tạo ô chứa"
|
||||
"Slot name" = "Tên ô chứa"
|
||||
"Mar" = "Thg 3"
|
||||
"Margin" = "Lề"
|
||||
"Mark all as read" = "Đánh dấu tất cả là đã đọc"
|
||||
|
||||
@@ -741,6 +741,7 @@ status = "draft — needs native review"
|
||||
"Main component deleted" = "主组件已删除"
|
||||
"Main menu" = "主菜单"
|
||||
"Make slot" = "设为插槽"
|
||||
"Slot name" = "插槽名称"
|
||||
"Margin" = "边距"
|
||||
"Mark all as read" = "全部标为已读"
|
||||
"Mark as resolved" = "标为已解决"
|
||||
|
||||
@@ -137,6 +137,8 @@ mod live_text_tests;
|
||||
#[cfg(test)]
|
||||
mod local_fonts_tests;
|
||||
#[cfg(test)]
|
||||
mod make_slot_naming_tests;
|
||||
#[cfg(test)]
|
||||
mod measure_nudge_tests;
|
||||
#[cfg(test)]
|
||||
mod menu_aim_tests;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
//! Make slot asks for the slot's name first, driven through egui headless (#1537): picking it in
|
||||
//! the Slot menu of a frame inside a component shows a name field with the frame's name selected;
|
||||
//! what is typed names the slot on Enter, and Escape makes nothing.
|
||||
|
||||
use egui::Event;
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_engine::components::slot_name;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::component_swap_default_tests::{centre, click, tree};
|
||||
use crate::layers_drag_tests::Ui;
|
||||
|
||||
/// A "Card" component holding a selected frame "Frame 12". Returns (ui, frame).
|
||||
fn setup() -> (Ui, NodeId) {
|
||||
let mut s = Session::new();
|
||||
let id = |v: serde_json::Value| NodeId(v["id"].as_u64().unwrap());
|
||||
let card = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 200, "name": "Card"})).unwrap());
|
||||
let frame = id(s.execute("frame.create", &json!({"x": 10, "y": 50, "width": 200, "height": 100, "parent": card, "name": "Frame 12"})).unwrap());
|
||||
s.execute("component.create", &json!({"ids": [card]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [frame]})).unwrap();
|
||||
let u = Ui::new(s);
|
||||
u.ctx.enable_accesskit();
|
||||
(u, frame)
|
||||
}
|
||||
|
||||
fn key(key: egui::Key) -> Event {
|
||||
Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }
|
||||
}
|
||||
|
||||
/// Open the frame's Slot menu and pick Make slot; returns the name field's value then.
|
||||
fn pick_make_slot(u: &mut Ui) -> Option<String> {
|
||||
let combo = tree(u).into_iter().find(|n| n.label() == Some("Slot") && n.value() == Some("Not bound")).expect("the frame has a Slot row");
|
||||
click(u, centre(&combo));
|
||||
let item = tree(u).into_iter().find(|n| n.label() == Some("Make slot")).expect("the Slot menu offers Make slot");
|
||||
click(u, centre(&item));
|
||||
u.frame(vec![]);
|
||||
u.frame(vec![]);
|
||||
tree(u).into_iter().find(|n| n.label() == Some("Slot name")).and_then(|n| n.value().map(str::to_string))
|
||||
}
|
||||
|
||||
fn slot_of(u: &Ui, frame: NodeId) -> Option<String> {
|
||||
slot_name(u.app.session.doc().unwrap(), frame)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn make_slot_names_the_slot_with_what_is_typed() {
|
||||
let (mut u, frame) = setup();
|
||||
assert_eq!(pick_make_slot(&mut u).as_deref(), Some("Frame 12"), "the field suggests the frame's name");
|
||||
assert_eq!(slot_of(&u, frame), None, "nothing is made before the name is confirmed");
|
||||
// The suggestion is selected: typing replaces it.
|
||||
u.frame(vec![Event::Text("Avatar".into())]);
|
||||
u.frame(vec![key(egui::Key::Enter)]);
|
||||
u.frame(vec![]);
|
||||
assert_eq!(slot_of(&u, frame).as_deref(), Some("Avatar"));
|
||||
assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error);
|
||||
assert!(tree(&mut u).iter().all(|n| n.label() != Some("Slot name")), "the naming row closes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_cancels_make_slot() {
|
||||
let (mut u, frame) = setup();
|
||||
assert!(pick_make_slot(&mut u).is_some());
|
||||
u.frame(vec![key(egui::Key::Escape)]);
|
||||
u.frame(vec![]);
|
||||
assert_eq!(slot_of(&u, frame), None);
|
||||
assert!(tree(&mut u).iter().all(|n| n.label() != Some("Slot name")));
|
||||
assert_eq!(u.app.session.ds().unwrap().selection, [frame], "the Escape only cancels the naming");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_taken_keeps_the_field_open() {
|
||||
let (mut u, frame) = setup();
|
||||
let card = u.app.session.doc().unwrap().node(frame).and_then(|n| n.parent).unwrap();
|
||||
u.app.session.execute("component.addProperty", &json!({"id": card, "name": "Taken", "type": "boolean"})).unwrap();
|
||||
assert!(pick_make_slot(&mut u).is_some());
|
||||
u.frame(vec![Event::Text("Taken".into())]);
|
||||
u.frame(vec![key(egui::Key::Enter)]);
|
||||
u.frame(vec![]);
|
||||
assert_eq!(slot_of(&u, frame), None);
|
||||
let field = tree(&mut u).into_iter().find(|n| n.label() == Some("Slot name")).expect("the field stays to fix the name");
|
||||
assert_eq!(field.value(), Some("Taken"));
|
||||
}
|
||||
@@ -14,7 +14,7 @@ use egui::{Id, Sense, Ui, vec2};
|
||||
use figcraft_doc::{Document, Node, NodeId, NodeKind, PropDef, VariableType};
|
||||
use figcraft_engine::cmd::component::{can_add_variant, can_combine_variants};
|
||||
use figcraft_engine::components::{
|
||||
PROP_BINDING, default_variant, inside_instance, owning_component, owning_instance, slot_def, slot_edited, slots_of, variant_conflicts,
|
||||
PROP_BINDING, default_variant, inside_instance, owning_component, owning_instance, prop_list, slot_def, slot_edited, slots_of, variant_conflicts,
|
||||
variant_options, variant_props, variant_set,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
@@ -207,13 +207,6 @@ fn slot_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, slot: NodeId, prefer
|
||||
});
|
||||
}
|
||||
|
||||
fn prop_defs(doc: &Document, c: NodeId) -> std::collections::BTreeMap<String, PropDef> {
|
||||
match doc.node(c).map(|n| &n.kind) {
|
||||
Some(NodeKind::Component { props, .. }) => props.clone(),
|
||||
_ => Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Properties of a component or component set.
|
||||
fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
@@ -224,11 +217,11 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) {
|
||||
} else {
|
||||
Some(owner)
|
||||
};
|
||||
let defs = first.map(|c| prop_defs(doc, c)).unwrap_or_default();
|
||||
let defs = first.map(|c| prop_list(doc, c)).unwrap_or_default();
|
||||
let variants = if is_set { variant_options(doc, owner) } else { Vec::new() };
|
||||
let comps = local_components(app, ui, doc);
|
||||
widgets::separator(ui);
|
||||
let existing: Vec<String> = defs.keys().cloned().chain(variants.iter().map(|(k, _)| k.clone())).collect();
|
||||
let existing: Vec<String> = defs.iter().map(|(k, _)| k.clone()).chain(variants.iter().map(|(k, _)| k.clone())).collect();
|
||||
let fresh = (1..).map(|k| format!("Property {k}")).find(|n| !existing.contains(n)).unwrap_or_else(|| "Property".into());
|
||||
let plus = header_with_plus(ui, tr("Properties"), tr("Create component property"));
|
||||
menu_popup(&plus).width(180.0).show(|ui| {
|
||||
@@ -396,9 +389,24 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) {
|
||||
fn bind_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Some(comp) = owning_component(doc, n.id) else { return };
|
||||
let defs = prop_defs(doc, comp);
|
||||
// The English label also names a new property (document content); it shows translated.
|
||||
let mut fields: Vec<(&str, &str, &str)> = vec![("visible", tr_noop("Visibility"), "boolean")];
|
||||
let defs = prop_list(doc, comp);
|
||||
widgets::separator(ui);
|
||||
row(ui, 36.0, |ui| {
|
||||
ui.label(egui::RichText::new(tr("Component property")).font(semibold(11.0)).color(t.text));
|
||||
});
|
||||
for field in bind_fields(n) {
|
||||
bind_row(app, ui, n, &defs, field);
|
||||
}
|
||||
slot_name_row(app, ui, n);
|
||||
}
|
||||
|
||||
/// A field of a layer a component property can control: (field, label, property type). The
|
||||
/// English label also names a new property (document content); it shows translated.
|
||||
type BindField = (&'static str, &'static str, &'static str);
|
||||
|
||||
/// The fields of layer `n` that component properties can control.
|
||||
fn bind_fields(n: &Node) -> Vec<BindField> {
|
||||
let mut fields: Vec<BindField> = vec![("visible", tr_noop("Visibility"), "boolean")];
|
||||
if n.kind.text().is_some() {
|
||||
fields.push(("characters", tr_noop("Text"), "text"));
|
||||
}
|
||||
@@ -408,71 +416,118 @@ fn bind_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) {
|
||||
if matches!(n.kind, NodeKind::Frame) {
|
||||
fields.push(("slot", tr_noop("Slot"), "slot"));
|
||||
}
|
||||
widgets::separator(ui);
|
||||
row(ui, 36.0, |ui| {
|
||||
ui.label(egui::RichText::new(tr("Component property")).font(semibold(11.0)).color(t.text));
|
||||
fields
|
||||
}
|
||||
|
||||
/// `base`, or `base 2`, `base 3`…: the first name no property in `defs` has.
|
||||
fn fresh_name(base: &str, defs: &[(String, PropDef)]) -> String {
|
||||
(1..).map(|k| if k == 1 { base.to_string() } else { format!("{base} {k}") }).find(|x| !defs.iter().any(|(k, _)| k == x)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The row of one field of layer `n`: its icon, its label and a menu that binds it to a property
|
||||
/// of type `ty` (or makes one).
|
||||
fn bind_row(app: &mut FigApp, ui: &mut Ui, n: &Node, defs: &[(String, PropDef)], field: BindField) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (key, label, _) = field;
|
||||
let bound = n.prop_refs.get(key).cloned();
|
||||
row(ui, 32.0, |ui| {
|
||||
let (ir, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());
|
||||
let icon = match key {
|
||||
"visible" => Icon::Eye,
|
||||
"characters" => Icon::Text,
|
||||
"slot" => Icon::Slot,
|
||||
_ => Icon::Instance,
|
||||
};
|
||||
icons::paint(ui.painter(), ir, icon, if bound.is_some() { COMPONENT } else { t.icon_secondary });
|
||||
widgets::label(ui, tr(label), t.text_secondary);
|
||||
let shown = bound.clone().unwrap_or_else(|| tr("Not bound").into());
|
||||
let resp = widgets::dropdown(ui, &shown, 120.0, None).on_hover_text(tr("Apply a component property"));
|
||||
crate::a11y::combo(&resp, tr(label), &shown, crate::a11y::menu_open(&resp));
|
||||
menu_popup(&resp).width(180.0).show(|ui| bind_menu(app, ui, n, defs, field, bound.is_some()));
|
||||
});
|
||||
for (field, label, ty) in fields {
|
||||
let matching: Vec<&String> = defs
|
||||
.iter()
|
||||
.filter(|(_, d)| {
|
||||
matches!(
|
||||
(ty, d),
|
||||
("boolean", PropDef::Boolean { .. })
|
||||
| ("text", PropDef::Text { .. })
|
||||
| ("instanceSwap", PropDef::InstanceSwap { .. })
|
||||
| ("slot", PropDef::Slot { .. })
|
||||
)
|
||||
})
|
||||
.map(|(k, _)| k)
|
||||
.collect();
|
||||
let bound = n.prop_refs.get(field).cloned();
|
||||
row(ui, 32.0, |ui| {
|
||||
let (ir, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());
|
||||
let icon = match field {
|
||||
"visible" => Icon::Eye,
|
||||
"characters" => Icon::Text,
|
||||
"slot" => Icon::Slot,
|
||||
_ => Icon::Instance,
|
||||
};
|
||||
icons::paint(ui.painter(), ir, icon, if bound.is_some() { COMPONENT } else { t.icon_secondary });
|
||||
widgets::label(ui, tr(label), t.text_secondary);
|
||||
let shown = bound.clone().unwrap_or_else(|| tr("Not bound").into());
|
||||
let resp = widgets::dropdown(ui, &shown, 120.0, None).on_hover_text(tr("Apply a component property"));
|
||||
menu_popup(&resp).width(180.0).show(|ui| {
|
||||
for k in &matching {
|
||||
if menu_item(ui, k, "") {
|
||||
app.run("component.bindProperty", json!({"layer": n.id, "property": k, "field": field}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
if ty == "slot" {
|
||||
if bound.is_none() && menu_item(ui, tr("Make slot"), "") {
|
||||
// Named after the frame.
|
||||
app.run("component.makeSlot", json!({"id": n.id}));
|
||||
ui.close();
|
||||
}
|
||||
} else if menu_item(ui, tr("Create property…"), "") {
|
||||
let existing: Vec<String> = defs.keys().cloned().collect();
|
||||
let name = (1..)
|
||||
.map(|k| if k == 1 { label.to_string() } else { format!("{label} {k}") })
|
||||
.find(|x| !existing.contains(x))
|
||||
.unwrap_or_default();
|
||||
app.run("component.addProperty", json!({"id": n.id, "name": name, "type": ty}));
|
||||
ui.close();
|
||||
}
|
||||
if ty == "slot" {
|
||||
if bound.is_some() && menu_item(ui, tr("Remove slot"), "") {
|
||||
app.run("component.removeSlot", json!({"id": n.id}));
|
||||
ui.close();
|
||||
}
|
||||
} else if bound.is_some() && menu_item(ui, tr("Detach property"), "") {
|
||||
app.run("component.bindProperty", json!({"layer": n.id, "property": null, "field": field}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// The menu of a field's row ([`bind_row`]): the properties of its type, then making one (a slot
|
||||
/// is named first, in [`slot_name_row`]) and unbinding (removing the slot).
|
||||
fn bind_menu(app: &mut FigApp, ui: &mut Ui, n: &Node, defs: &[(String, PropDef)], (key, label, ty): BindField, bound: bool) {
|
||||
let fits = |d: &PropDef| {
|
||||
matches!(
|
||||
(ty, d),
|
||||
("boolean", PropDef::Boolean { .. })
|
||||
| ("text", PropDef::Text { .. })
|
||||
| ("instanceSwap", PropDef::InstanceSwap { .. })
|
||||
| ("slot", PropDef::Slot { .. })
|
||||
)
|
||||
};
|
||||
for (k, _) in defs.iter().filter(|(_, d)| fits(d)) {
|
||||
if menu_item(ui, k, "") {
|
||||
app.run("component.bindProperty", json!({"layer": n.id, "property": k, "field": key}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
let slot = ty == "slot";
|
||||
if slot && !bound && menu_item(ui, tr("Make slot"), "") {
|
||||
let base = if n.name.trim().is_empty() { label } else { n.name.as_str() };
|
||||
ui.data_mut(|d| d.insert_temp(slot_naming_id(n.id), (fresh_name(base, defs), true)));
|
||||
ui.close();
|
||||
}
|
||||
if !slot && menu_item(ui, tr("Create property…"), "") {
|
||||
app.run("component.addProperty", json!({"id": n.id, "name": fresh_name(label, defs), "type": ty}));
|
||||
ui.close();
|
||||
}
|
||||
if slot && bound && menu_item(ui, tr("Remove slot"), "") {
|
||||
app.run("component.removeSlot", json!({"id": n.id}));
|
||||
ui.close();
|
||||
}
|
||||
if !slot && bound && menu_item(ui, tr("Detach property"), "") {
|
||||
app.run("component.bindProperty", json!({"layer": n.id, "property": null, "field": key}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Where Make slot keeps the name being typed for frame `id` (and whether its field takes the
|
||||
/// keyboard next frame).
|
||||
fn slot_naming_id(id: NodeId) -> Id {
|
||||
Id::unique(("make_slot_name", id))
|
||||
}
|
||||
|
||||
/// Make slot's naming step, once it was picked for frame `n`: a name field that takes the keyboard
|
||||
/// with the suggested name selected, and a Make slot button. Enter or the button makes the slot
|
||||
/// with that name; Escape cancels.
|
||||
fn slot_name_row(app: &mut FigApp, ui: &mut Ui, n: &Node) {
|
||||
let id = slot_naming_id(n.id);
|
||||
let Some((mut name, mut focus)) = ui.data(|d| d.get_temp::<(String, bool)>(id)) else { return };
|
||||
let mut open = !n.prop_refs.contains_key("slot");
|
||||
row(ui, 32.0, |ui| {
|
||||
ui.add_space(22.0);
|
||||
let w = (ui.available_width() - 96.0).max(60.0);
|
||||
let field = id.with("field");
|
||||
// Asked for until it has it: the click that picked Make slot takes the focus away again.
|
||||
if focus && !crate::a11y::field_focused(ui.ctx()) {
|
||||
ui.memory_mut(|m| m.request_focus(field));
|
||||
widgets::select_all_next(ui, field, &name);
|
||||
}
|
||||
let r = widgets::text_input(ui, field, &mut name, w, tr("Slot name"));
|
||||
crate::a11y::text(&r, tr("Slot name"), &name);
|
||||
focus &= !r.has_focus();
|
||||
let ok = !name.trim().is_empty();
|
||||
let enter = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
let make = widgets::button(ui, tr("Make slot"), true, ok).clicked() || (enter && ok);
|
||||
// A refused name (one taken) keeps the field open to fix it.
|
||||
if make && app.run("component.makeSlot", json!({"id": n.id, "name": name.trim()})).is_some() {
|
||||
open = false;
|
||||
}
|
||||
if (r.has_focus() || r.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
open = false;
|
||||
}
|
||||
});
|
||||
ui.data_mut(|d| {
|
||||
if open {
|
||||
d.insert_temp(id, (name, focus));
|
||||
} else {
|
||||
d.remove::<(String, bool)>(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Overridden property groups of a layer inside an instance, each with a reset button.
|
||||
@@ -667,7 +722,7 @@ fn instance_props(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node, comps
|
||||
}
|
||||
}
|
||||
// Boolean, text and instance-swap properties.
|
||||
for (k, d) in prop_defs(doc, *component) {
|
||||
for (k, d) in prop_list(doc, *component) {
|
||||
let v = props.get(&k).cloned().unwrap_or_else(|| figcraft_engine::components::prop_default_value(&d));
|
||||
row(ui, 32.0, |ui| {
|
||||
prop_label(ui, &k, type_icon(&d));
|
||||
|
||||
@@ -704,7 +704,7 @@ pub fn select_all_on_focus(ui: &Ui, r: &Response, text: &str) {
|
||||
/// with the editor already focused, which then gains no focus). Call it before adding the editor:
|
||||
/// the text is selected at once, so a key typed in the frame the edit starts (the frame after a
|
||||
/// double-click or F2 is often the one with the first key) replaces it.
|
||||
fn select_all_next(ui: &Ui, te_id: Id, text: &str) {
|
||||
pub(crate) fn select_all_next(ui: &Ui, te_id: Id, text: &str) {
|
||||
ui.data_mut(|d| d.insert_temp(select_all_id(te_id), true));
|
||||
select_all(ui, te_id, text);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Components, variants, styles and variables parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (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-10-30 · **Change:** minor (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
|
||||
@@ -22,8 +22,8 @@ accounts).
|
||||
| Switching variants keeps overrides where names match and both variants had the same value; text layers keep theirs | done | |
|
||||
| Default variant: the top-left one (new instances, Assets thumbnail, generated code) | done | |
|
||||
| Variant property order (names rewritten), duplicate combinations reported | done | drag a property in the Properties section to reorder it, one undo step (#679), or `component.editProperty {index}` |
|
||||
| Properties: boolean, text, instance swap (preferred values), variant | done | every component menu scrolls past 320 pt, the default of an instance-swap property's too (#1338); the instance-swap menus (an instance's property and swap menus, a property's default, the playground) open with a search field that filters by name as you type, with Up, Down and Enter to pick and Escape to clear then close; the slot and preferred components menus have no search yet |
|
||||
| Slots (preferred components, fill menu, kept across updates) | done | Figma 2025 feature |
|
||||
| Properties: boolean, text, instance swap (preferred values), variant | done | listed in the order they were made, a rename keeping the place (#1536; the shared ones don't reorder by drag yet); every component menu scrolls past 320 pt, the default of an instance-swap property's too (#1338); the instance-swap menus (an instance's property and swap menus, a property's default, the playground) open with a search field that filters by name as you type, with Up, Down and Enter to pick and Escape to clear then close; the slot and preferred components menus have no search yet |
|
||||
| Slots (preferred components, fill menu, kept across updates) | done | Figma 2025 feature; Make slot asks for the name first (a field under the frame's Slot row), and slots are renamed in the component's Properties list (#1537); no rename with only the slot's frame selected yet |
|
||||
| Exposed nested instances | done | hidden ones hide their properties |
|
||||
| Descriptions | done | |
|
||||
| Instance swap: panel, Alt-drop from Assets | done | the panel keeps overrides on same-named layers, an Alt-drop from Assets only text layers' |
|
||||
@@ -78,6 +78,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | M3, #1536 and #1537: component properties are listed in the order they were made in, and renaming one keeps its place (they were sorted by name, so a rename moved the property); that holds for every kind, on every variant of a set and on instances, in `component.properties`, the Design panel and `.fig` export. Files from before keep their properties by name until one is added or renamed. Make slot (the Slot menu of a frame inside a component) asks for the slot's name first: a field under the Slot row with the frame's name selected, Enter or Make slot to make it, Escape to cancel; a name taken keeps the field open. Renaming stays in the component's Properties list. Tests: `engine/src/prop_order_tests.rs`, `ui-egui/src/make_slot_naming_tests.rs`. Left: dragging the shared properties to reorder them, and renaming a slot with only its frame selected. Numbers unchanged |
|
||||
| 2026-10-29 | minor | M3, #1338: the instance-swap property's menu (on an instance, its main component's default and the component playground) and the instance's swap menu open with a search field at the top that has the keyboard; typing filters the components by name (any case, headings without matches drop out), an empty result reads "No matching components", Up and Down step through the matches and Enter picks one (the first without a step), Escape clears the search and then closes the menu. Only the list scrolls; the menu grows back when the search is cleared. Tests: `component_swap_search_tests`. Numbers unchanged |
|
||||
| 2026-10-26 | minor | An instance-swap property's default menu scrolls (#1338): on the main component's Properties row the menu listed every component in the file at full height, so in a file with many (an icon library) the bottom ones ran off the screen and couldn't be picked. It now scrolls past 320 pt like the instance's swap menu, is at least 200 pt wide, ticks the current default and is named for assistive technology (`component_swap_default_tests`). Left: searching these menus. Numbers unchanged. |
|
||||
| 2026-10-24 | minor | Variables on a text style's fields from the style editor: the font and font style menus start with Apply variable (string variables), and the size, line height and letter spacing fields show a variable button on hover (number variables). A bound field shows its variable in place of the value; a click on it picks another variable and its detach button unbinds it (`style_picker_tests`). Left: the other effect and grid fields (commands only), library variables in the picker. Numbers unchanged. |
|
||||
|
||||
+3
-1
@@ -958,7 +958,9 @@ remove and reorder layers there, unlike the rest of an instance). It is a compon
|
||||
`value: {layer, edited, layers}`).
|
||||
|
||||
- **Define** (on the main): `component.makeSlot {id (a frame inside a main component), name?,
|
||||
preferred? [component or set ids]}` (the property is named after the frame by default);
|
||||
preferred? [component or set ids]}` (the property is named after the frame by default; a new
|
||||
property goes last, as `component.properties` lists them in the order they were made, a rename
|
||||
keeping the place);
|
||||
`component.editProperty {name, preferred}`; `component.removeSlot {id}` (instances lose their own
|
||||
content in it). `component.addProperty {type: "slot", layer}` and `component.bindProperty {field:
|
||||
"slot"}` work too (one frame per component for a slot).
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
# FigCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (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); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
|
||||
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
|
||||
@@ -75,6 +75,7 @@ blockers.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-30 | minor | M3, #1536 and #1537: component properties are listed in the order they were made in, and renaming one keeps its place (they were sorted by name, so a rename moved the property); that holds for every kind, on every variant of a set and on instances, in `component.properties`, the Design panel and `.fig` export. Files from before keep their properties by name until one is added or renamed. Make slot (the Slot menu of a frame inside a component) asks for the slot's name first: a field under the Slot row with the frame's name selected, Enter or Make slot to make it, Escape to cancel; a name taken keeps the field open. Renaming stays in the component's Properties list. Tests: `engine/src/prop_order_tests.rs`, `ui-egui/src/make_slot_naming_tests.rs`. Left: dragging the shared properties to reorder them, and renaming a slot with only its frame selected. Numbers unchanged |
|
||||
| 2026-10-30 | minor | M9, #917: the browser build publishes to a `figcraft-server`. Share ▸ Online (the server in Preferences) and Share ▸ Publish to server… upload the open file over the page's own WebSocket, make the links and join codes others join with, and keep the tab in sync with the new file, as the desktop app does; asking a server about its hosting, stopping a share and sharing again go out the same way. The Share popover offers only online sharing there (a page can't listen, so the local network stays the desktop app's); without a server in Preferences it says so and offers joining. A failed publish says why and leaves the file as it was. The browser keeps only your name across reloads, so the owner token of a file it put on an open server lasts as long as the page (`crates/collab-net/src/exchange.rs`, run by the browser client's driver; the requests and what their answers mean are shared with the desktop transport in `requests.rs`). Tests: `exchange_tests` (scripted server: with and without the key, sharing or not, refusals, an old server without join codes, time-outs, leaving while publishing, the background requests), `live_join_tests`; checked in a browser against a local server. Percentages and hours unchanged |
|
||||
| 2026-10-30 | minor | M4, #1051: fonts installed on this computer in the browser build. Where the browser lists them (Chromium's Local Font Access, `window.queryLocalFonts`), the Design panel's font menu offers "Use fonts installed on this computer…"; the browser asks for permission, and the fonts join the font list by name, with their styles. A font's bytes are read only when text uses it, one style at a time (its preview in the menu waits until then), and auto-sizing text fits it once it is in; one that can't be read is dropped and its text keeps the fallback. A family the build embeds keeps its faces; the computer's styles it lacks join it. Other browsers show no row (`figcraft_text::add_local_fonts`, `apps/figcraft-web/src/local_fonts.rs`). Tests: `fontdb::local_tests`, `local_fonts_tests`; checked in a browser. Percentages and hours unchanged |
|
||||
| 2026-10-30 | minor | M8, #1550: SVG import reads CSS colour functions its SVG parser doesn't (it painted them black, or left strokes out, without a word): `color(srgb …)` and `color(display-p3 …)`, with alpha, converted to the document's profile, and `color-mix(in srgb|oklab|oklch, …)`, evaluated at import (percentages, alpha and nested mixes as CSS Color 5 has them), in colour attributes, `style` attributes and `<style>` sheets. Other colour spaces are taken as sRGB values and other mixes mixed in sRGB, each with an import note; a call that can't be read becomes a mid grey with a note. Bounded: calls over 2,048 characters, mixes nested more than 8 deep and more than 100,000 calls are not read. Tests: `css_color_tests` (svg). Numbers unchanged |
|
||||
|
||||
Reference in new issue
Block a user