From 9eadb37622025f9eaf7d9f1ccdcfbbd5992d76ff Mon Sep 17 00:00:00 2001 From: Andrii Volkov <60794004+volkovskey@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:14:53 +0300 Subject: [PATCH 01/18] perf(ui): look for CJK names in edits, not on every pan Each frame checks the active document's names for CJK scripts, to install faces for them. The check was keyed on the revision, which pans and zooms bump too, so every frame of a pan read every layer name. In a 900 000-layer file that was ~70 ms a frame: panning ran at ~14 fps whatever the canvas cost. It now keeps the document version it read: the same version (a pan, a zoom) is skipped, and a new one reads only the layers that changed since (`Document::changed_ids`, sized by the edit), or every name past 10 000 changes. On that file the UI frame while panning went from ~71 ms to ~3 ms. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki --- crates/ui-egui/src/idle_tests.rs | 20 ++++++++++++++++++++ crates/ui-egui/src/lib.rs | 31 ++++++++++++++++++++++++------- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index 2d4e5757..3c71146d 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -288,3 +288,23 @@ fn an_animated_gif_wakes_only_the_presenter_and_only_at_its_frames() { h.app.ui.present = None; h.assert_idle("the presenter closed"); } + +#[test] +fn cjk_names_are_looked_for_in_edits_not_on_every_pan() { + let mut s = Session::new(); + let a = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); + let b = s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); + s.execute("node.set", &json!({"id": a, "name": "中文"})).unwrap(); + let mut h = Harness::new(s); + let ctx = h.ctx.clone(); + assert_eq!(h.app.cjk_needed(&ctx, 0), crate::cjk::HAN, "the first look reads every name"); + // A pan or zoom changes the revision but not the document: its hundreds of thousands of + // names aren't read again (that took 70 ms a frame in a 900 000-layer file). + let revision = h.app.session.ds().unwrap().revision; + h.run("view.set", json!({"zoom": 2, "cx": 300, "cy": 200})); + assert_ne!(h.app.session.ds().unwrap().revision, revision); + assert_eq!(h.app.cjk_needed(&ctx, 0), 0); + // An edit is read: the layers it changed. + h.run("node.set", json!({"id": b, "name": "한국어"})); + assert_eq!(h.app.cjk_needed(&ctx, 0), crate::cjk::HANGUL); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613f18f9..059ca364 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -156,6 +156,7 @@ mod windows_tests; use std::collections::BTreeSet; use std::path::PathBuf; +use std::sync::Arc; use figcraft_doc::NodeId; use figcraft_engine::Session; @@ -241,6 +242,9 @@ pub enum TitleBar { /// How often open documents get an autosave checkpoint (seconds). pub const AUTOSAVE_SECS: f64 = 30.0 * 60.0; +/// Most changed layers whose names are checked for CJK scripts one by one (beyond: all of them). +const CJK_CHANGES: usize = 10_000; + /// How long a toast shows (seconds). const TOAST_SECS: f64 = 3.0; @@ -483,8 +487,8 @@ pub struct FigApp { /// System faces for scripts the built-in fonts lack were added (after the font scan), with /// faces for these CJK scripts ([`cjk`]) in this UI language's order. script_fonts: Option<(u8, &'static str)>, - /// The tab (uid) and revision whose names were last checked for CJK scripts. - cjk_checked: Option<(u64, u64)>, + /// The tab (uid) and document version whose names were last checked for CJK scripts. + cjk_checked: Option<(u64, Arc)>, /// The language preference the UI strings follow (applied when it changes). language: Option, /// A field had the keyboard when the last frame ended. @@ -856,11 +860,24 @@ impl FigApp { }) }); let dragging = ctx.input(|i| i.pointer.any_down()); - if let Some(d) = self.session.docs.get(self.session.active).filter(|d| !dragging && self.cjk_checked != Some((d.uid, d.revision))) { - self.cjk_checked = Some((d.uid, d.revision)); - let doc = &d.doc; - let comments = doc.comments.iter().flat_map(|c| std::iter::once(&c.first).chain(&c.replies)).flat_map(|m| [&m.author, &m.text]); - need |= std::iter::once(&doc.name).chain(doc.nodes().map(|n| &n.name)).chain(comments).fold(0, |m, s| m | cjk::scripts(s)); + // Pans and zooms change the revision too: only a new version of the document is looked + // at, and only the layers it changed (a large file has hundreds of thousands of names). + let current = self.session.docs.get(self.session.active).filter(|_| !dragging).map(|d| (d.uid, d.doc.clone())); + if let Some((uid, doc)) = current { + let before = self.cjk_checked.take().filter(|(u, _)| *u == uid).map(|(_, b)| b); + if !before.as_ref().is_some_and(|b| Arc::ptr_eq(b, &doc)) { + let mut scan = |s: &str| need |= cjk::scripts(s); + scan(&doc.name); + match before.as_ref().and_then(|b| doc.changed_ids(b, CJK_CHANGES)) { + Some(ids) => ids.into_iter().filter_map(|id| doc.node(id)).for_each(|n| scan(&n.name)), + None => doc.nodes().for_each(|n| scan(&n.name)), + } + doc.comments.iter().flat_map(|c| std::iter::once(&c.first).chain(&c.replies)).for_each(|m| { + scan(&m.author); + scan(&m.text); + }); + } + self.cjk_checked = Some((uid, doc)); } need } From 4f9cbf702fa3035e496327f603c57d963fb04641 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:23:44 +0200 Subject: [PATCH 02/18] Merge text runs made identical by style updates Fixes #452 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/tests.rs | 40 ++++++++++++++++++++++++++++++++++++++++ crates/doc/src/text.rs | 10 +++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index ef2f37b9..96349f48 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -371,3 +371,43 @@ fn custom_easings() { assert_eq!(back, Easing::CustomSpring { mass: 2.0, stiffness: 50.0, damping: 5.0 }); assert_eq!(serde_json::from_value::(serde_json::json!("easeInOut")).unwrap(), Easing::EaseInOut); } + +#[test] +fn text_style_updates_merge_equal_runs() { + let def = TextStyle { font_size: 14.0, ..Default::default() }; + let mut t = TextData::new("abcd"); + t.link_text_style(0, 2, &def, "S"); + t.link_text_style(2, 4, &def, "S"); + t.restyle(2, 4, |s| s.font_size = 24.0); + assert_eq!(t.runs.len(), 2); + t.update_text_style("S", &TextStyle { font_size: 24.0, ..def.clone() }); + let spans = t.spans(); + assert_eq!(spans.len(), 1); + assert_eq!((spans[0].0, spans[0].1), (0, 4)); + assert_eq!(spans[0].2.font_size, 24.0); + assert_eq!(spans[0].2.style_id.as_deref(), Some("S")); + + // Remapping two links to one id merges the runs. + let mut t = TextData::new("ab"); + t.link_text_style(0, 1, &def, "A"); + t.link_text_style(1, 2, &def, "B"); + t.map_text_styles(|_| Some("C".into())); + assert_eq!(t.spans().len(), 1); + assert_eq!(t.style_at(1).style_id.as_deref(), Some("C")); + + // Different values stay separate. + let mut t = TextData::new("ab"); + t.link_text_style(0, 1, &def, "A"); + t.link_text_style(1, 2, &TextStyle { font_size: 30.0, ..Default::default() }, "B"); + t.map_text_styles(|_| Some("C".into())); + let spans = t.spans(); + assert_eq!(spans.len(), 2); + assert_eq!((spans[0].2.font_size, spans[1].2.font_size), (14.0, 30.0)); + + // Unlinking a run equal to the base drops it. + let mut t = TextData::new("ab"); + let base = t.style.clone(); + t.link_text_style(0, 1, &base, "S"); + t.map_text_styles(|_| None); + assert!(t.runs.is_empty()); +} diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 8018609d..2daf2701 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -562,7 +562,7 @@ impl TextData { } } - /// Drop empty/out-of-range runs and runs equal to the base style. + /// Drop empty/out-of-range runs and runs equal to the base style, and merge touching equal runs. pub fn normalize(&mut self) { let n = self.char_count(); let base = self.style.clone(); @@ -571,6 +571,14 @@ impl TextData { r.end = r.end.min(n); } self.runs.sort_by_key(|r| r.start); + let mut merged: Vec = Vec::with_capacity(self.runs.len()); + for r in std::mem::take(&mut self.runs) { + match merged.last_mut() { + Some(prev) if prev.end == r.start && prev.style == r.style => prev.end = r.end, + _ => merged.push(r), + } + } + self.runs = merged; } /// Replace chars `start..end` with `text`, shifting runs (inserted text takes the style at `start`). From 6ea957ec5dcc2b54420e22eb238e1199f1fbbd09 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:36:06 +0300 Subject: [PATCH 03/18] doc: runs of a file that reach past the text can't overflow a text edit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StyleRun start/end are usize taken straight from a .figcraft document.json (nothing clamps them on load; only the REST and SVG importers normalize), so TextData::replace_range's p - removed + inserted panics in a debug or test build and wraps to zero in release, where normalize then drops the run and the layer silently loses its formatting. Shift the runs with saturating arithmetic; normalize drops whatever is left. Regression test: text_runs_past_the_text_never_overflow — panics at crates/doc/src/text.rs:605 on main, passes with the clamp. --- crates/doc/src/tests.rs | 21 +++++++++++++++++++++ crates/doc/src/text.rs | 4 +++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index d54bb29b..bb03a440 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -168,6 +168,27 @@ fn text_runs_restyle_and_replace() { assert_eq!(t.characters, ""); } +/// A run that reaches past the text (a file can carry one: nothing clamps `runs` on load) +/// must not overflow the arithmetic that shifts the runs along, and must not leave the +/// text unwritable. +#[test] +fn text_runs_past_the_text_never_overflow() { + let style = || TextStyle { font_style: "Bold".into(), ..Default::default() }; + for end in [usize::MAX, usize::MAX - 1, 1_000_000] { + let mut t = TextData::new("hi"); + t.runs = vec![StyleRun { start: 0, end, style: style() }]; + // Typing at the caret, and replacing a selection: both shift `end`. + t.replace_range(0, 0, "x"); + assert_eq!(t.characters, "xhi", "end {end}"); + t.replace_range(0, 3, "yo"); + assert_eq!(t.characters, "yo", "end {end}"); + // The text is still writable and the runs are back inside it. + t.replace_range(0, 0, "!"); + assert_eq!(t.characters, "!yo", "end {end}"); + assert!(t.runs.iter().all(|r| r.start < r.end && r.end <= t.char_count()), "end {end}: {:?}", t.runs); + } +} + #[test] fn text_case() { assert_eq!(TextCase::Title.apply("hello big world"), "Hello Big World"); diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 8018609d..b20512f7 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -602,7 +602,9 @@ impl TextData { if p <= start { p } else if p >= end { - p - removed + inserted + // Runs of a file that reach past the text (nothing clamps them on load) + // would otherwise overflow here; `normalize` below drops what is left. + p.saturating_sub(removed).saturating_add(inserted) } else { start + inserted } From 3c28f605d192d776984242b5c2a8fe5489a07367 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 14:55:48 +0000 Subject: [PATCH 04/18] Keep cleared range hyperlinks through FIG export and import Fixes #458 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/format/src/fig/export.rs | 4 ++ crates/format/src/fig/map.rs | 3 +- crates/format/src/rest/text.rs | 2 + .../tests/range_hyperlink_round_trip.rs | 38 +++++++++++++++++++ 4 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 crates/format/tests/range_hyperlink_round_trip.rs diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index 22243355..9bcf36f0 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -1551,6 +1551,10 @@ impl<'d> Exporter<'d> { .map(|(st, id)| { let mut ef: Fields = vec![("styleID", u(id))]; style_fields(st, &mut ef); + // An empty link clears the text's own (a missing one would inherit it). + if st.link.is_none() && td.style.link.is_some() { + ef.push(("hyperlink", o(vec![("url", s(""))]))); + } if let Some(p) = st.fills.as_deref().or(td.style.fills.as_ref().map(|_| fills)) { ef.push(("fillPaints", self.paints(p, true, &Bound::default()))); } diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 787ca6d4..40d2f0bc 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1576,7 +1576,8 @@ fn text_style(v: &View, size: Option) -> Obj { } } } - if let Some(u) = v.get("hyperlink").and_then(|h| h.get("url")).and_then(Kv::str).filter(|u| !u.is_empty()) { + // An empty URL is kept: on an override entry it clears the text's own link. + if let Some(u) = v.get("hyperlink").and_then(|h| h.get("url")).and_then(Kv::str) { s.insert("hyperlink".into(), json!({"type": "URL", "url": u})); } s diff --git a/crates/format/src/rest/text.rs b/crates/format/src/rest/text.rs index 5fbb368d..e8e53b1f 100644 --- a/crates/format/src/rest/text.rs +++ b/crates/format/src/rest/text.rs @@ -127,6 +127,8 @@ fn type_style(cx: &mut Ctx, s: &Value, base: &TextStyle) -> TextStyle { if let Some(h) = s.get("hyperlink") { match (text(h, "type"), text(h, "url")) { (Some("URL") | None, Some(url)) if !url.is_empty() => t.link = Some(super::props::clip_str(url).to_string()), + // An empty URL clears the link the base style has. + (Some("URL") | None, Some(_)) => t.link = None, _ => {} } } diff --git a/crates/format/tests/range_hyperlink_round_trip.rs b/crates/format/tests/range_hyperlink_round_trip.rs new file mode 100644 index 00000000..f47e22ae --- /dev/null +++ b/crates/format/tests/range_hyperlink_round_trip.rs @@ -0,0 +1,38 @@ +//! A hyperlink cleared on part of a text layer stays cleared through FIG export and import. + +use figcraft_doc::{Document, NodeKind, TextData}; +use figcraft_format::fig; + +fn links(doc: &Document, name: &str) -> Vec> { + let Some(NodeKind::Text(text)) = doc.nodes().find(|n| n.name == name).map(|n| &n.kind) else { return Vec::new() }; + text.spans().into_iter().flat_map(|(start, end, style)| std::iter::repeat_n(style.link.clone(), end - start)).collect() +} + +#[test] +fn cleared_range_hyperlink_round_trip() { + let mut document = Document::new("File"); + let page = document.first_page().unwrap(); + let url = "https://example.com/".to_string(); + + let mut text = TextData { characters: "AB".into(), ..Default::default() }; + text.style.link = Some(url.clone()); + text.restyle(1, 2, |style| style.link = None); + document.create(page, NodeKind::Text(text), "Cleared", 0.0, 0.0, 100.0, 30.0).unwrap(); + + let mut control = TextData { characters: "AB".into(), ..Default::default() }; + control.restyle(1, 2, |style| style.link = Some(url.clone())); + document.create(page, NodeKind::Text(control), "Explicit", 0.0, 40.0, 100.0, 30.0).unwrap(); + + let expected = vec![Some(url.clone()), None]; + assert_eq!(links(&document, "Cleared"), expected); + let native_bytes = figcraft_format::save_bytes(&document, None).unwrap(); + let native = figcraft_format::load_bytes(&native_bytes).unwrap().0; + assert_eq!(links(&native, "Cleared"), expected); + + let (bytes, export_warnings) = fig::export(&document, None).unwrap(); + let (loaded, import_warnings) = fig::import(&bytes, "File").unwrap(); + assert!(export_warnings.is_empty(), "{export_warnings:?}"); + assert!(import_warnings.is_empty(), "{import_warnings:?}"); + assert_eq!(links(&loaded, "Explicit"), vec![None, Some(url)]); + assert_eq!(links(&loaded, "Cleared"), expected); +} From a4f4869443fea70a705a411c4706c01afa5662f3 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:10:00 +0200 Subject: [PATCH 05/18] Point duplicated page navigation at the copied frames Fixes #419 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/lib.rs | 17 +++++++ crates/engine/src/cmd/page.rs | 5 +- crates/engine/src/lib.rs | 2 + .../engine/src/page_copy_navigation_tests.rs | 48 +++++++++++++++++++ 4 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 crates/engine/src/page_copy_navigation_tests.rs diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 513800e3..71c6da39 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -781,6 +781,23 @@ impl Document { self.paste_nodes(&src_nodes, &map, id, parent, index) } + /// Deep-copy each of `ids`' subtrees to the end of `parent`, in order, returning the new root + /// ids. One id map covers every copy, so references between the copied subtrees (a navigation + /// from one copied frame to another) point at the copies. + pub fn duplicate_all_into(&mut self, ids: &[NodeId], parent: NodeId) -> DocResult> { + let mut map = BTreeMap::new(); + let mut trees = Vec::with_capacity(ids.len()); + for &id in ids { + let src_nodes: Vec = self.subtree(id).into_iter().filter_map(|i| self.node(i).cloned()).collect(); + for n in &src_nodes { + let nid = self.alloc_id(); + map.insert(n.id, nid); + } + trees.push((id, src_nodes)); + } + trees.into_iter().map(|(id, src_nodes)| self.paste_nodes(&src_nodes, &map, id, parent, None)).collect() + } + /// Insert copies of `nodes` (a subtree rooted at `root`, from this or another document) with ids /// remapped by `map`. Interactions, instance references and pattern sources pointing inside the /// subtree are remapped too. diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index 8fccd607..e6c1876f 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -92,9 +92,8 @@ pub fn specs() -> Vec { { *b = background; } - for c in doc.get(src)?.children.clone() { - doc.duplicate_into(c, np, None)?; - } + let children = doc.get(src)?.children.clone(); + doc.duplicate_all_into(&children, np)?; // Keep the new page right after the source. if let Some(si) = doc.pages.iter().position(|x| *x == src) { doc.pages.retain(|x| *x != np); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 42423dbc..c10dd04f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1528,6 +1528,8 @@ mod open_issues_tests; #[cfg(test)] mod outline_options_tests; #[cfg(test)] +mod page_copy_navigation_tests; +#[cfg(test)] mod paint_reorder_tests; #[cfg(test)] mod paint_tests; diff --git a/crates/engine/src/page_copy_navigation_tests.rs b/crates/engine/src/page_copy_navigation_tests.rs new file mode 100644 index 00000000..ddbc956a --- /dev/null +++ b/crates/engine/src/page_copy_navigation_tests.rs @@ -0,0 +1,48 @@ +//! Duplicating a page copies its prototype connections: navigation between two frames that are both +//! on the page leads between the copies, while a destination outside the copied contents is kept. + +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn frame(s: &mut Session, x: f64) -> NodeId { + id_of(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 100, "height": 100}))["id"]).unwrap() +} + +fn dest(s: &Session, id: NodeId) -> NodeId { + s.doc().unwrap().node(id).unwrap().interactions[0].actions[0].dest().unwrap() +} + +#[test] +fn page_copy_navigation_uses_the_copied_destination() { + let mut s = Session::new(); + let first = frame(&mut s, 0.0); + let second = frame(&mut s, 200.0); + ex(&mut s, "proto.interaction.add", json!({"id": first, "action": {"type": "navigate", "dest": second}})); + let copy = id_of(&ex(&mut s, "page.duplicate", json!({}))["id"]).unwrap(); + let children = s.doc().unwrap().node(copy).unwrap().children.clone(); + assert_eq!(children.len(), 2); + assert_eq!(dest(&s, children[0]), children[1]); + let visited = Default::default(); + assert_eq!(crate::proto_run::navigate_target(s.doc().unwrap(), dest(&s, children[0]), &visited), Some(children[1])); + assert_eq!(dest(&s, first), second, "the original keeps its destination"); +} + +#[test] +fn frame_copy_keeps_an_outside_destination() { + let mut s = Session::new(); + let first = frame(&mut s, 0.0); + let second = frame(&mut s, 200.0); + ex(&mut s, "proto.interaction.add", json!({"id": first, "action": {"type": "navigate", "dest": second}})); + ex(&mut s, "select.set", json!({"ids": [first]})); + ex(&mut s, "edit.duplicate", json!({})); + let copy = s.selection()[0]; + assert_ne!(copy, first); + assert_eq!(dest(&s, copy), second); +} From 843b00b22f8df10a5d05d1c8c92ac5f055e5aae7 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:18:17 +0200 Subject: [PATCH 06/18] text: scripted text stores CRLF, CR, U+2028 and U+2029 as LF like the editor (#427) --- crates/engine/src/cmd/create.rs | 2 +- crates/engine/src/cmd/edit.rs | 1 + crates/engine/src/cmd/props.rs | 7 +++- crates/engine/src/components.rs | 4 +- crates/engine/src/lib.rs | 2 + crates/engine/src/line_endings_tests.rs | 56 +++++++++++++++++++++++++ crates/engine/src/textedit.rs | 7 +--- 7 files changed, 69 insertions(+), 10 deletions(-) create mode 100644 crates/engine/src/line_endings_tests.rs diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 61b0f8b4..2150701a 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -216,7 +216,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result { } } if let NodeKind::Text(td) = &mut kind { - td.characters = string(p, "text").or_else(|| string(p, "characters")).unwrap_or_default(); + td.characters = super::props::normalize_line_endings(&string(p, "text").or_else(|| string(p, "characters")).unwrap_or_default()); if w > 0.0 { td.auto_resize = TextAutoResize::Height; } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 3a0342cd..9687e06a 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1016,6 +1016,7 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { // Replace occurrence by occurrence so styles shift correctly. // The search resumes after each inserted replacement, so a replacement that // contains `find` neither loops forever nor stops the other occurrences. + let r = &super::props::normalize_line_endings(r); let mut guard = 0; let mut cursor = 0; let len = find.chars().count(); diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index dd2daa82..598be752 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -30,6 +30,11 @@ fontVariations / variationSettings ({axis: number}, e.g. {\"wght\": 550, \"wdth\ arcInner, booleanOperation (a boolean group's operation, or an operand's own; null: the group's), operandOperation (a layer in a boolean \ group: how it combines with the operands below it; null: the group's), constrainProportions, description, interactions, overflow, pluginData {k: v}"; +/// CRLF, bare CR, U+2028 and U+2029 become LF, the only line ending text layout understands (editor insertion does the same). +pub(crate) fn normalize_line_endings(s: &str) -> String { + s.replace("\r\n", "\n").replace(['\r', '\u{2028}', '\u{2029}'], "\n") +} + /// camelCase object keys → snake_case (accept the inspect/camel forms for serde-backed values). pub fn snake(v: &Value) -> Value { match v { @@ -599,7 +604,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map { - if let (NodeKind::Text(t), Some(sv)) = (&mut n.kind, val.as_str()) + if let (NodeKind::Text(t), Some(sv)) = (&mut n.kind, val.as_str().map(crate::cmd::props::normalize_line_endings)) && t.characters != sv { let len = t.char_count(); - t.replace_range(0, len, sv); + t.replace_range(0, len, &sv); } } "mainComponent" => { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 42423dbc..a80cc4c9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1502,6 +1502,8 @@ mod keymap_tests; #[cfg(test)] mod library_variables_tests; #[cfg(test)] +mod line_endings_tests; +#[cfg(test)] mod live_access_tests; #[cfg(test)] mod live_docs_tests; diff --git a/crates/engine/src/line_endings_tests.rs b/crates/engine/src/line_endings_tests.rs new file mode 100644 index 00000000..160707a6 --- /dev/null +++ b/crates/engine/src/line_endings_tests.rs @@ -0,0 +1,56 @@ +//! Text set by a command stores every line ending as LF, like editor insertion does, so layout +//! sees two lines and no stray glyph for CRLF, bare CR, U+2028 or U+2029. + +use serde_json::json; + +use crate::Session; +use crate::components::tests::{card, chars, copy_of, ex, idv}; +use crate::params::id_of; + +const INPUTS: [&str; 5] = ["a\nb", "a\r\nb", "a\rb", "a\u{2028}b", "a\u{2029}b"]; + +#[test] +fn text_set_normalizes_line_endings() { + let mut s = Session::new(); + let created = s.execute("text.create", &json!({"text": "", "x": 0, "y": 0})).unwrap(); + let id = id_of(&created["id"]).unwrap(); + for input in INPUTS { + s.execute("text.set", &json!({"id": id, "characters": input})).unwrap(); + let t = s.doc().unwrap().node(id).unwrap().kind.text().unwrap().clone(); + assert_eq!(t.characters, "a\nb", "text.set {input:?}"); + } +} + +#[test] +fn text_create_normalizes_line_endings() { + let mut s = Session::new(); + for input in INPUTS { + let created = s.execute("text.create", &json!({"text": input, "x": 0, "y": 0})).unwrap(); + let id = id_of(&created["id"]).unwrap(); + let t = s.doc().unwrap().node(id).unwrap().kind.text().unwrap().clone(); + assert_eq!(t.characters, "a\nb", "text.create {input:?}"); + } +} + +#[test] +fn find_replace_normalizes_line_endings() { + for input in INPUTS { + let mut s = Session::new(); + let id = idv(&ex(&mut s, "text.create", json!({"text": "x", "x": 0, "y": 0}))); + ex(&mut s, "edit.findReplace", json!({"find": "x", "replace": input})); + assert_eq!(chars(&s, id), "a\nb", "edit.findReplace {input:?}"); + } +} + +#[test] +fn text_property_values_normalize_line_endings() { + let mut s = Session::new(); + let (c, _bg, title, _dot) = card(&mut s); + ex(&mut s, "component.addProperty", json!({"id": c, "name": "Title", "type": "text", "layer": title})); + let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0}))); + let copy = copy_of(&s, i, title); + for input in INPUTS { + ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": input}})); + assert_eq!(chars(&s, copy), "a\nb", "instance.setProperties {input:?}"); + } +} diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index 6ba94217..af9cb52c 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -82,12 +82,7 @@ fn edit(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit)) -> Result /// Type (or paste) `text` replacing the selection. Line endings are normalised and other control /// characters dropped, so pasted plain text from any platform lands cleanly. pub fn insert(s: &mut Session, text: &str) -> Result<()> { - let text: String = text - .replace("\r\n", "\n") - .replace(['\r', '\u{2028}', '\u{2029}'], "\n") - .chars() - .filter(|c| !c.is_control() || matches!(c, '\n' | '\t')) - .collect(); + let text: String = crate::cmd::props::normalize_line_endings(text).chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).collect(); if text.is_empty() { return Ok(()); } From 5454bdc2449b1fc8fa9fc826ebc8a4d3f758e2f7 Mon Sep 17 00:00:00 2001 From: IanHollow <72767437+IanHollow@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:29:11 -0700 Subject: [PATCH 07/18] M7: skip special files when scanning system fonts Require resolved font entries to be regular files before indexing them. Preserve normal font symlinks and cover named pipes in a bounded child-process regression. Assisted-by: OpenAI Codex (GPT-6) Signed-off-by: IanHollow <72767437+IanHollow@users.noreply.github.com> --- ROADMAP.md | 3 +- crates/text/src/fontdb.rs | 5 +++ crates/text/src/fontdb_tests.rs | 73 ++++++++++++++++++++++++++++++++- 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index ace7702e..b03bc8f8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-14 · **Change:** minor (system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) 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 @@ -313,6 +313,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. | | 2026-10-14 | minor | System theme on first run (M7): preferences without a theme default to System; the first frame reads the OS appearance directly, including when returning from a manual theme. Saved Light/Dark choices stay explicit; unavailable OS appearance falls back to Light. Numbers unchanged. | | 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 2c584ebc..8f91d83f 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -695,6 +695,11 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap status, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + eprintln!("skipping named-pipe fixture: POSIX mkfifo is unavailable"); + return; + } + Err(error) => panic!("cannot create named-pipe fixture: {error}"), + }; + assert!(status.success(), "mkfifo failed"); + std::os::unix::fs::symlink("pipe.ttf", dir.0.join("linked.otf")).unwrap(); + std::os::unix::fs::symlink("missing.ttf", dir.0.join("broken.ttf")).unwrap(); + + // A regression must fail without leaving a blocked test thread behind. Run only this + // test in a child, then kill and reap it if opening the pipe blocks the scanner. + struct Child(std::process::Child); + impl Drop for Child { + fn drop(&mut self) { + let _ = self.0.kill(); + let _ = self.0.wait(); + } + } + let module = module_path!().split_once("::").unwrap().1; + let mut child = Child( + Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &format!("{module}::the_scan_skips_named_pipes"), "--nocapture"]) + .env(INPUT, &dir.0) + .stdout(Stdio::null()) + .spawn() + .unwrap(), + ); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if let Some(status) = child.0.try_wait().unwrap() { + assert!(status.success(), "font-scan child failed: {status}"); + break; + } + assert!(Instant::now() < deadline, "font scan blocked on a named pipe"); + // std::process has no portable timed wait; a bounded poll keeps the regression finite. + std::thread::sleep(Duration::from_millis(10)); + } + // An exact-filter typo otherwise makes libtest report success after running zero tests. + assert_eq!(std::fs::read(dir.0.join("complete")).unwrap(), b"scanned"); +} From e9e2d874051918c5c6207d882013a06b4c9eac8c Mon Sep 17 00:00:00 2001 From: bkim <165993880+perskim@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:23:06 +0900 Subject: [PATCH 08/18] M1: Hangul falls back to a Korean system font, at the run's weight Text in the default Inter (or any Latin font) drew the missing-glyph box for every Hangul syllable on macOS and Windows: no Korean family was on the fallback list, so the Korean fonts those systems ship were never tried, and there is no default-font setting to work around it. Apple SD Gothic Neo (macOS), Malgun Gothic (Windows) and Noto Sans CJK KR / Noto Sans KR (Linux) now come after the Chinese and Japanese entries, so Han characters resolve as before. Fallback faces also follow the run's style: each fallback family gives its face nearest the run's style instead of always its Regular, so Hangul (or kana) in a bold run comes out bold. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015MAPjgJo17KasfbzMTbV65 --- crates/text/src/behaviour_tests.rs | 28 +++++++++++++++++ crates/text/src/fontdb.rs | 49 +++++++++++++++++++----------- crates/text/src/fontdb_tests.rs | 15 +++++++++ crates/text/src/layout.rs | 3 ++ 4 files changed, 77 insertions(+), 18 deletions(-) diff --git a/crates/text/src/behaviour_tests.rs b/crates/text/src/behaviour_tests.rs index a5400b62..b3cb4884 100644 --- a/crates/text/src/behaviour_tests.rs +++ b/crates/text/src/behaviour_tests.rs @@ -237,6 +237,34 @@ fn fallback_fonts_dont_move_the_baseline() { } } +#[test] +fn hangul_comes_from_a_korean_font_when_one_is_installed() { + // The default (Latin) face has no Hangul. With a Korean system font (Apple SD Gothic Neo on + // macOS, Malgun Gothic on Windows, Noto Sans CJK KR on Linux) the fallback draws it instead + // of the missing-glyph box, at the run's weight. Skips on a machine without one. + let Some(f) = face() else { return }; + if f.has_char('\u{D55C}') { + return; + } + let korean = ["Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans CJK KR", "Noto Sans KR"]; + if !korean.iter().any(|k| find_face(k, "Regular").is_some_and(|k| k.has_char('\u{D55C}'))) { + return; + } + for style in ["Regular", "Bold"] { + let mut t = text("Ab \u{D55C}\u{AD6D}\u{C5B4}", TextAutoResize::WidthAndHeight); + t.style.font_style = style.into(); + let l = layout(&t, None, None); + let hangul: Vec<_> = l.glyphs.iter().filter(|g| g.char_start >= 3).collect(); + assert!(!hangul.is_empty()); + for g in &hangul { + assert!(g.face.has_char('\u{D55C}'), "{style}: drawn by {} {}, which has no Hangul", g.face.family, g.face.style); + } + let used = &hangul[0].face; + let nearest = find_face(&used.family, style).map_or(used.weight, |n| n.weight); + assert_eq!(used.weight, nearest, "{style}: the fallback face is the one nearest the run's style"); + } +} + #[test] fn break_opportunities() { // (before, after, may break between, derivation) diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 2c584ebc..72192eb3 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -391,6 +391,29 @@ fn names(f: &skrifa::FontRef) -> (Option, Option) { (family, style) } +/// Families tried, in order, for characters a text's own font lacks. +const FALLBACK_FAMILIES: [&str; 19] = [ + "Inter", + "Helvetica Neue", + "Arial", + "Segoe UI", + "DejaVu Sans", + "Noto Sans", + "BIZ UDPGothic", + "Hiragino Sans", + "PingFang SC", + "Noto Sans CJK SC", + "Microsoft YaHei", + "Apple SD Gothic Neo", + "Malgun Gothic", + "Noto Sans CJK KR", + "Noto Sans KR", + "Apple Color Emoji", + "Noto Sans Arabic", + "Apple Symbols", + "Segoe UI Symbol", +]; + /// A system font file entry not yet loaded. #[derive(Clone, Debug)] struct Pending { @@ -600,25 +623,15 @@ impl FontDb { /// Faces to try for characters the primary face lacks. pub fn fallbacks(&mut self) -> Vec> { + self.fallbacks_for("Regular") + } + + /// [`fallbacks`](Self::fallbacks), each family's face nearest `style`, so the characters a + /// bold run takes from a fallback font (Hangul in Inter Bold) come out bold too. + pub fn fallbacks_for(&mut self, style: &str) -> Vec> { let mut out = Vec::new(); - for fam in [ - "Inter", - "Helvetica Neue", - "Arial", - "Segoe UI", - "DejaVu Sans", - "Noto Sans", - "BIZ UDPGothic", - "Hiragino Sans", - "PingFang SC", - "Noto Sans CJK SC", - "Microsoft YaHei", - "Apple Color Emoji", - "Noto Sans Arabic", - "Apple Symbols", - "Segoe UI Symbol", - ] { - if let Some(f) = self.find(fam, "Regular") { + for fam in FALLBACK_FAMILIES { + if let Some(f) = self.find(fam, style) { out.push(f); } } diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs index b9add9c2..00bdd038 100644 --- a/crates/text/src/fontdb_tests.rs +++ b/crates/text/src/fontdb_tests.rs @@ -67,3 +67,18 @@ fn the_scan_reads_names_like_a_whole_file_read() { assert_eq!(faces_in(&index_bytes(&p, whole.len()).unwrap()), faces_in(&whole), "{}", p.display()); } } + +#[test] +fn fallback_faces_follow_the_runs_style() { + // A bold run's missing characters come from each fallback family's face nearest Bold, not its + // Regular, so Hangul or kana in a bold Latin run come out bold too. Same families, same order. + scan_system_fonts(); + let mut w = db().write().unwrap(); + let regular = w.fallbacks(); + let bold = w.fallbacks_for("Bold"); + let families = |v: &[Arc]| v.iter().map(|f| f.family.clone()).collect::>(); + assert_eq!(families(®ular), families(&bold)); + for (r, b) in regular.iter().zip(&bold) { + assert!(b.weight >= r.weight, "{}: {} is lighter than {}", b.family, b.style, r.style); + } +} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index b5b617d0..9bf1f49b 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -301,8 +301,10 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) let mut dbw = db().write().unwrap_or_else(std::sync::PoisonError::into_inner); let fallbacks = dbw.fallbacks(); let mut faces: Vec>> = Vec::with_capacity(styles.len()); + let mut style_fallbacks: Vec>> = Vec::with_capacity(styles.len()); for s in &styles { faces.push(dbw.find(&s.font_family, &s.font_style).or_else(|| dbw.default_face())); + style_fallbacks.push(dbw.fallbacks_for(&s.font_style)); } drop(dbw); @@ -334,6 +336,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) if c == '\n' || primary.has_char(c) { return primary.clone(); } + let fallbacks = style_fallbacks.get(st_idx).unwrap_or(&fallbacks); fallbacks.iter().find(|f| f.has_char(c)).cloned().unwrap_or_else(|| primary.clone()) }; let first = chars.get(seg_start).copied().unwrap_or(' '); From f8458b70a8d7d1d59d2c39a53206ae254e3f873a Mon Sep 17 00:00:00 2001 From: Yarila Date: Fri, 16 Oct 2026 00:27:57 +0500 Subject: [PATCH 09/18] Reduce blank canvas strips during panning with bounded overscan --- ROADMAP.md | 3 +- crates/ui-egui/src/canvas.rs | 77 +++++++++++++++++++++++++++++++++--- docs/gaps.md | 6 ++- 3 files changed, 78 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 389d8c41..46ca36f2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-15 · **Change:** minor (Typography spacing icons support scrubbing (#373); system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) 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 @@ -313,6 +313,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Canvas retains a bounded margin while panning so newly exposed strips can reuse the previous render. Numbers unchanged. | | 2026-10-15 | minor | Typography spacing icons scrub with the shared number-field gesture, modifier rates and one Undo step per drag (#373). Units and selected-character targeting are preserved. Numbers unchanged. | | 2026-10-14 | minor | System theme on first run (M7): preferences without a theme default to System; the first frame reads the OS appearance directly, including when returning from a manual theme. Saved Light/Dark choices stay explicit; unavailable OS appearance falls back to Light. Numbers unchanged. | | 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ad94b596..d182fbf8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -186,7 +186,9 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { crate::guides::end_released(&ctx); crate::live::track_pointer(app, &ctx); app.sync_preedit(); - request_render(app, worker, ppp); + // Retained rendering snaps the view outward and adds one pixel on each axis. + let max_texture_side = (ctx.input(|i| i.max_texture_side).min(16_384) as u32).saturating_sub(1).max(1); + request_render(app, worker, ppp, max_texture_side); // Every finished render is shown, even when a newer one is already on its way: renders finish // in order, and an edit every frame (a drag) would otherwise never show until it stopped. let shown = app.session.ds().map(|ds| (ds.uid, ds.page)).ok(); @@ -322,11 +324,11 @@ fn covers(outer: &figcraft_render::View, inner: &figcraft_render::View) -> bool o.x0 <= i.x0 + eps && o.y0 <= i.y0 + eps && o.x1 >= i.x1 - eps && o.y1 >= i.y1 - eps } -fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) { +fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32, max_texture_side: u32) { let Ok(ds) = app.session.ds() else { return }; let rect = app.canvas.rect; let (mut ox, mut oy) = ds.view.origin(); - let (scale, w, h) = if let Some(k) = ds.view.pixel_preview_scale() { + let (scale, visible_w, visible_h) = if let Some(k) = ds.view.pixel_preview_scale() { // Pixel preview: render `k` pixels per page unit from a whole-pixel origin, shown magnified // without smoothing. (ox, oy) = ((ox * k).floor() / k, (oy * k).floor() / k); @@ -340,12 +342,20 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) { let outlined = if ds.view.outlines_selection { ds.selection.clone() } else { vec![] }; let proof = ds.view.proof_srgb && !doc.color_profile.is_srgb(); let outline_extras = figcraft_render::Outline { hidden: ds.view.outline_hidden, bounds: ds.view.outline_bounds, light: false }; + // Render beyond the visible viewport. During a pan the previous texture then still covers + // the screen while the worker renders newly exposed content. Request a new buffer before the + // visible area reaches its edge, rather than exposing an empty strip each pointer move. + let margin = overscan_margin(visible_w, visible_h, max_texture_side); + let origin = (ox, oy); + let visible = (visible_w, visible_h); + let requested = (ox - f64::from(margin) / scale, oy - f64::from(margin) / scale); + let size = (visible_w.saturating_add(margin * 2).min(max_texture_side), visible_h.saturating_add(margin * 2).min(max_texture_side)); let key = JobKey { doc: Arc::as_ptr(&doc) as usize, page: ds.page, - origin: ((ox * 1000.0) as i64, (oy * 1000.0) as i64), + origin: ((requested.0 * 1000.0) as i64, (requested.1 * 1000.0) as i64), scale: (scale * 10000.0) as i64, - size: (w, h), + size, outline: ds.view.outlines, outline_extras, outlined: outlined.clone(), @@ -354,13 +364,21 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) { fonts: figcraft_text::layout_generation(), proof, }; + if let Some(previous) = &app.canvas.submitted { + let previous_origin = (previous.origin.0 as f64 / 1000.0, previous.origin.1 as f64 / 1000.0); + let same_content = JobKey { origin: previous.origin, size: previous.size, ..key.clone() } == *previous; + let covers = cached_render_covers(previous_origin, previous.size, origin, visible, scale); + if same_content && covers { + return; + } + } if app.canvas.submitted.as_ref() == Some(&key) { return; } let job = Job { doc, page: ds.page, - view: figcraft_render::View { origin: (ox, oy), scale, width: w.min(16384), height: h.min(16384) }, + view: figcraft_render::View { origin: requested, scale, width: size.0, height: size.1 }, outline: ds.view.outlines, outline_extras, outlined, @@ -376,6 +394,53 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) { app.canvas.submitted = Some(key); } +/// Leave a 96-pixel reserve so the next render starts before a pan reaches a cached edge. +fn cached_render_covers(buffer_origin: (f64, f64), buffer_size: (u32, u32), view_origin: (f64, f64), view_size: (u32, u32), scale: f64) -> bool { + if !scale.is_finite() || scale <= 0.0 { + return false; + } + let guard_x = 96.0_f64.min(f64::from(buffer_size.0.saturating_sub(view_size.0)) / 4.0) / scale; + let guard_y = 96.0_f64.min(f64::from(buffer_size.1.saturating_sub(view_size.1)) / 4.0) / scale; + view_origin.0 >= buffer_origin.0 + guard_x + && view_origin.1 >= buffer_origin.1 + guard_y + && view_origin.0 + f64::from(view_size.0) / scale <= buffer_origin.0 + f64::from(buffer_size.0) / scale - guard_x + && view_origin.1 + f64::from(view_size.1) / scale <= buffer_origin.1 + f64::from(buffer_size.1) / scale - guard_y +} + +/// Bound the extra rendered area to about 1.6 times the visible area. A fixed large margin can +/// multiply the pixel count several-fold on a narrow or small viewport. +fn overscan_margin(width: u32, height: u32, max_texture_side: u32) -> u32 { + width.min(height).saturating_div(8).min(128).min(max_texture_side.saturating_sub(width.max(height)) / 2) +} + +#[cfg(test)] +#[test] +fn pan_uses_cached_pixels_until_the_refill_boundary() { + let margin = overscan_margin(1000, 800, 16_383); + assert_eq!(margin, 100); + let buffer = (-f64::from(margin), -f64::from(margin)); + let size = (1000 + 2 * margin, 800 + 2 * margin); + assert!(cached_render_covers(buffer, size, (0.0, 0.0), (1000, 800), 1.0)); + assert!(cached_render_covers(buffer, size, (50.0, 0.0), (1000, 800), 1.0)); + assert!(!cached_render_covers(buffer, size, (80.0, 0.0), (1000, 800), 1.0)); + assert!(!cached_render_covers(buffer, size, (-80.0, 0.0), (1000, 800), 1.0)); + assert!(cached_render_covers((-24.0, -24.0), (1048, 848), (10.0, 0.0), (1000, 800), 1.0)); + assert!(!cached_render_covers((-24.0, -24.0), (1048, 848), (19.0, 0.0), (1000, 800), 1.0)); +} + +#[cfg(test)] +#[test] +fn overscan_stays_within_texture_and_pixel_budget() { + for (width, height, limit) in [(1000, 800, 16_383), (390, 844, 16_383), (2560, 1440, 16_383), (1000, 800, 1060)] { + let margin = overscan_margin(width, height, limit); + assert!(width + 2 * margin <= limit || width > limit); + assert!(height + 2 * margin <= limit || height > limit); + let rendered = u64::from(width + 2 * margin) * u64::from(height + 2 * margin); + let visible = u64::from(width) * u64::from(height); + assert!(rendered * 5 <= visible * 8); // At most 1.6× the visible pixels. + } +} + /// The zoom tool while Z is held (Alt: zoom out): a click zooms a step about the pointer, a drag /// zooms to the dragged area. Takes the pointer from the other tools while it's active. fn zoom_tool(app: &mut FigApp, ui: &Ui, resp: &egui::Response, pos: Pos2, mods: egui::Modifiers, typing: bool) -> bool { diff --git a/docs/gaps.md b/docs/gaps.md index 07192dfd..e8b111a8 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -197,6 +197,9 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe parallel, retained) and shows the image through wgpu. A new zoom can't reuse the retained image; when renders from scratch take a frame or more, the whole view is previewed with a sixteenth of the pixels first, then refined. +- **Landed (2026-10-15):** a bounded render margin is reused during panning and refilled before + the visible view reaches its edge. The extra rendered area is capped at 1.6× visible pixels; + this reduces blank strips without changing the CPU renderer. - **Evidence (measured, `cargo xtask bench`):** a 50k-layer page ~8 ms zoomed out with levels of detail, single-layer edits at 10k layers ~0.2 ms; full 1440×900 renders 7–15 ms at 10k layers. Heavy effects (glass, background blur, noise) and 4K/5K canvases at 2x are where CPU time grows. @@ -397,6 +400,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Gap 7: bounded canvas overscan for smoother panning. Estimates and ranking unchanged. | | 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | | 2026-10-13 | minor | Gap 1: the experimental `.fig` export writes instance sizes (resized, filling an auto layout, scaled), variables with modes and bindings, shared styles and variable-width strokes, and the import reads them back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Hours and the ranking unchanged | | 2026-10-12 | minor | Gap 2: your online files landed (the app records the files it put on a server with their owner tokens, Home ▸ Online lists them and opens them again as their owner, with sharing, rename, removal and forget from the list; one request per server tells which are still there; tabs mark files in a live session). What is left is unchanged: accounts, teams and per-person permissions, shared comments and versions. Hours unchanged | From 4d9cde8feedfdc428a41d66e4d742bac3ea79114 Mon Sep 17 00:00:00 2001 From: Losminek <301412321+Losminek@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:28:51 +0200 Subject: [PATCH 10/18] i18n: Czech one/few/other plurals Czech counts take three integer forms: one (1), few (2-4) and other (0 and 5+). Since #257 catalogs can supply `few`, so `plural()` gets a Czech rule and the 38 plural entries in cs.toml now read naturally ("1 vrstva", "3 vrstvy", "5 vrstev") instead of the count-neutral "Vrstvy: {n}" that #469 used for everything but 1. Test czech_integer_plurals covers the rule (1; 2-4; 0, 5, 11, 12, 21, 22, 100, 1 000 000), a real key through counted(), and that every Czech plural entry has one and few. Docs no longer say Czech has only one/other. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/cs.toml | 76 ++++++++++++++++---------------- crates/ui-egui/src/i18n.rs | 5 ++- crates/ui-egui/src/i18n_tests.rs | 20 +++++++++ docs/localization-parity.md | 9 ++-- docs/translating.md | 6 +-- 5 files changed, 70 insertions(+), 46 deletions(-) diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index aba0bec7..268ff527 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -7,7 +7,7 @@ status = "draft — needs native review" [ui] "% of all commits" = "% všech commitů" "(no longer published)" = "(již nepublikováno)" -"({n} warnings: some entries were skipped)" = { one = "({n} varování: některé položky byly přeskočeny)", other = "(Varování: {n}; některé položky byly přeskočeny)" } +"({n} warnings: some entries were skipped)" = { one = "({n} varování: některé položky byly přeskočeny)", few = "({n} varování: některé položky byly přeskočeny)", other = "({n} varování: některé položky byly přeskočeny)" } "+ Create variable" = "+ Vytvořit proměnnou" ".fig (experimental)" = ".fig (experimentální)" "1…9, 0" = "1…9, 0" @@ -455,11 +455,11 @@ status = "draft — needs native review" "Export settings" = "Nastavení exportu" "Export variables" = "Exportovat proměnné" "Export {name}" = "Exportovat {name}" -"Export {n} layers" = { one = "Exportovat {n} vrstvu", other = "Exportovat vrstvy: {n}" } +"Export {n} layers" = { one = "Exportovat {n} vrstvu", few = "Exportovat {n} vrstvy", other = "Exportovat {n} vrstev" } "Exported {file}" = "Exportován soubor {file}" "Exported {name}" = "Exportováno: {name}" -"Exported {name}; {n} kinds of content were left out" = { one = "Exportováno: {name}; vynechán {n} druh obsahu", other = "Exportováno: {name}; vynechané druhy obsahu: {n}" } -"Exported {n} files" = { one = "Exportován {n} soubor", other = "Exportované soubory: {n}" } +"Exported {name}; {n} kinds of content were left out" = { one = "Exportováno: {name}; vynechán {n} druh obsahu", few = "Exportováno: {name}; vynechány {n} druhy obsahu", other = "Exportováno: {name}; vynecháno {n} druhů obsahu" } +"Exported {n} files" = { one = "Exportován {n} soubor", few = "Exportovány {n} soubory", other = "Exportováno {n} souborů" } "Export…" = "Exportovat…" "Expose properties to instances" = "Zpřístupnit vlastnosti instancím" "Exposure" = "Expozice" @@ -477,7 +477,7 @@ status = "draft — needs native review" "File not found" = "Soubor nenalezen" "File published" = "Soubor publikován" "Files aren't end-to-end encrypted: the server's operator can read them." = "Soubory nemají koncové šifrování: provozovatel serveru je může číst." -"Files nobody opens for {n} days are deleted." = { one = "Soubory, které nikdo neotevře déle než {n} den, se smažou.", other = "Soubory, které nikdo neotevře déle než {n} d, se smažou." } +"Files nobody opens for {n} days are deleted." = { one = "Soubory, které nikdo neotevře déle než {n} den, se smažou.", few = "Soubory, které nikdo neotevře déle než {n} dny, se smažou.", other = "Soubory, které nikdo neotevře déle než {n} dní, se smažou." } "Fill" = "Výplň" "Fill (fr)" = "Vyplnit (fr)" "Fill container" = "Vyplnit kontejner" @@ -601,7 +601,7 @@ status = "draft — needs native review" "Import plugin from manifest…" = "Importovat plugin z manifestu…" "Import variables" = "Importovat proměnné" "Imported {name}" = "Importováno: {name}" -"Imported {n} new variables, updated {updated}" = { one = "Importována {n} nová proměnná, aktualizováno: {updated}", other = "Nově importované proměnné: {n}, aktualizované: {updated}" } +"Imported {n} new variables, updated {updated}" = { one = "Importována {n} nová proměnná, aktualizováno: {updated}", few = "Importovány {n} nové proměnné, aktualizováno: {updated}", other = "Importováno {n} nových proměnných, aktualizováno: {updated}" } "Import…" = "Importovat…" "In an instance, this comes from the main component" = "V instanci se toto přebírá z hlavní komponenty" "In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)." = "V prohlížeči se můžete připojit k relacím hostovaným desktopovou aplikací a k souborům na serveru FigCraft. Stránka otevřená přes https může přistupovat k jiným počítačům pouze přes odkazy wss:// (server za TLS)." @@ -1013,7 +1013,7 @@ status = "draft — needs native review" "Recent" = "Nedávné" "Recent files" = "Nedávné soubory" "Recents" = "Nedávné" -"Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them" = { one = "Po neočekávaném ukončení aplikace obnoven {n} neuložený soubor: uložte ho pro zachování", other = "Po neočekávaném ukončení aplikace obnoveny neuložené soubory (počet: {n}): uložte je pro zachování" } +"Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them" = { one = "Po neočekávaném ukončení aplikace obnoven {n} neuložený soubor: uložte ho pro zachování", few = "Po neočekávaném ukončení aplikace obnoveny {n} neuložené soubory: uložte je pro zachování", other = "Po neočekávaném ukončení aplikace obnoveno {n} neuložených souborů: uložte je pro zachování" } "Rectangle" = "Obdélník" "Rectangles" = "Obdélníky" "Red" = "Červená" @@ -1287,8 +1287,8 @@ status = "draft — needs native review" "Swap to {name}" = "Vyměnit za {name}" "Swap width and height" = "Prohodit šířku a výšku" "Swapped instance" = "Instance vyměněna" -"Swapped {n} assets" = { one = "Vyměněn {n} zdroj", other = "Vyměněné zdroje: {n}" } -"Swapped {n} assets; {missed} had no match and still use the old library" = { one = "Vyměněn {n} zdroj; bez shody: {missed}, nadále používají původní knihovnu", other = "Vyměněné zdroje: {n}; bez shody: {missed}, nadále používají původní knihovnu" } +"Swapped {n} assets" = { one = "Vyměněn {n} zdroj", few = "Vyměněny {n} zdroje", other = "Vyměněno {n} zdrojů" } +"Swapped {n} assets; {missed} had no match and still use the old library" = { one = "Vyměněn {n} zdroj; bez shody: {missed}, nadále používají původní knihovnu", few = "Vyměněny {n} zdroje; bez shody: {missed}, nadále používají původní knihovnu", other = "Vyměněno {n} zdrojů; bez shody: {missed}, nadále používají původní knihovnu" } "Sweep" = "Rozsah" "System" = "Systém" "Tab / Shift+Tab" = "Tab / Shift+Tab" @@ -1479,7 +1479,7 @@ status = "draft — needs native review" "Zoom to selection" = "Přiblížit na výběr" "Zoom tool: click to zoom in, drag an area" = "Nástroj přiblížení: kliknutím přiblížíte, tažením vyberete oblast" "Zoom tool: zoom out" = "Nástroj přiblížení: oddálit" -"and {n} more" = { one = "a {n} další", other = "a další (počet: {n})" } +"and {n} more" = { one = "a {n} další", few = "a {n} další", other = "a {n} dalších" } "bleed marks" = "značky spadávky" "by {author}" = "autor: {author}" "call any command its access allows" = "volat libovolný příkaz povolený jeho oprávněními" @@ -1531,35 +1531,35 @@ status = "draft — needs native review" "{name}, in a live session" = "{name}, v živé relaci" "{name}, online, open only to you" = "{name}, online, přístupný pouze vám" "{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, vybráno" -"{n} added" = { one = "Přidáno: {n}", other = "Přidáno: {n}" } -"{n} autosave versions" = { one = "{n} automaticky uložená verze", other = "Automaticky uložené verze: {n}" } -"{n} changed" = { one = "Změněno: {n}", other = "Změněno: {n}" } -"{n} columns" = { one = "{n} sloupec", other = "Sloupce: {n}" } -"{n} components" = { one = "{n} komponenta", other = "Komponenty: {n}" } -"{n} components and styles have newer versions" = { one = "{n} komponenta nebo styl má novější verzi", other = "Komponenty a styly s novější verzí: {n}" } -"{n} conflicts to resolve" = { one = "{n} konflikt k vyřešení", other = "Konflikty k vyřešení: {n}" } -"{n} days ago" = { one = "Před {n} dnem", other = "Před {n} dny" } -"{n} files" = { one = "{n} soubor", other = "Soubory: {n}" } -"{n} hours ago" = { one = "Před {n} hodinou", other = "Před {n} hodinami" } -"{n} instances" = { one = "{n} instance", other = "Instance: {n}" } -"{n} layers" = { one = "{n} vrstva", other = "Vrstvy: {n}" } -"{n} layers as one file" = { one = "{n} vrstva jako jeden soubor", other = "Vrstvy jako jeden soubor: {n}" } -"{n} layers selected" = { one = "Vybrána {n} vrstva", other = "Vybrané vrstvy: {n}" } -"{n} layers · {parts}" = { one = "{n} vrstva · {parts}", other = "Vrstvy: {n} · {parts}" } -"{n} matches" = { one = "{n} shoda", other = "Shody: {n}" } -"{n} minutes ago" = { one = "Před {n} minutou", other = "Před {n} minutami" } -"{n} months ago" = { one = "Před {n} měsícem", other = "Před {n} měsíci" } +"{n} added" = { one = "Přidáno: {n}", few = "Přidáno: {n}", other = "Přidáno: {n}" } +"{n} autosave versions" = { one = "{n} automaticky uložená verze", few = "{n} automaticky uložené verze", other = "{n} automaticky uložených verzí" } +"{n} changed" = { one = "Změněno: {n}", few = "Změněno: {n}", other = "Změněno: {n}" } +"{n} columns" = { one = "{n} sloupec", few = "{n} sloupce", other = "{n} sloupců" } +"{n} components" = { one = "{n} komponenta", few = "{n} komponenty", other = "{n} komponent" } +"{n} components and styles have newer versions" = { one = "{n} komponenta nebo styl má novější verzi", few = "{n} komponenty nebo styly mají novější verzi", other = "{n} komponent nebo stylů má novější verzi" } +"{n} conflicts to resolve" = { one = "{n} konflikt k vyřešení", few = "{n} konflikty k vyřešení", other = "{n} konfliktů k vyřešení" } +"{n} days ago" = { one = "Před {n} dnem", few = "Před {n} dny", other = "Před {n} dny" } +"{n} files" = { one = "{n} soubor", few = "{n} soubory", other = "{n} souborů" } +"{n} hours ago" = { one = "Před {n} hodinou", few = "Před {n} hodinami", other = "Před {n} hodinami" } +"{n} instances" = { one = "{n} instance", few = "{n} instance", other = "{n} instancí" } +"{n} layers" = { one = "{n} vrstva", few = "{n} vrstvy", other = "{n} vrstev" } +"{n} layers as one file" = { one = "{n} vrstva jako jeden soubor", few = "{n} vrstvy jako jeden soubor", other = "{n} vrstev jako jeden soubor" } +"{n} layers selected" = { one = "Vybrána {n} vrstva", few = "Vybrány {n} vrstvy", other = "Vybráno {n} vrstev" } +"{n} layers · {parts}" = { one = "{n} vrstva · {parts}", few = "{n} vrstvy · {parts}", other = "{n} vrstev · {parts}" } +"{n} matches" = { one = "{n} shoda", few = "{n} shody", other = "{n} shod" } +"{n} minutes ago" = { one = "Před {n} minutou", few = "Před {n} minutami", other = "Před {n} minutami" } +"{n} months ago" = { one = "Před {n} měsícem", few = "Před {n} měsíci", other = "Před {n} měsíci" } "{n} of {total}" = "{n} z {total}" -"{n} people joined" = { one = "Připojena {n} osoba", other = "Připojené osoby: {n}" } -"{n} preferred components" = { one = "{n} preferovaná komponenta", other = "Preferované komponenty: {n}" } -"{n} ready" = { one = "Připraveno: {n}", other = "Připraveno: {n}" } -"{n} removed" = { one = "Odebráno: {n}", other = "Odebráno: {n}" } -"{n} replies" = { one = "{n} odpověď", other = "Odpovědi: {n}" } -"{n} rows" = { one = "{n} řádek", other = "Řádky: {n}" } -"{n} styles" = { one = "{n} styl", other = "Styly: {n}" } -"{n} variables" = { one = "{n} proměnná", other = "Proměnné: {n}" } -"{n} weeks ago" = { one = "Před {n} týdnem", other = "Před {n} týdny" } -"{n} years ago" = { one = "Před {n} rokem", other = "Před {n} lety" } +"{n} people joined" = { one = "Připojila se {n} osoba", few = "Připojily se {n} osoby", other = "Připojilo se {n} osob" } +"{n} preferred components" = { one = "{n} preferovaná komponenta", few = "{n} preferované komponenty", other = "{n} preferovaných komponent" } +"{n} ready" = { one = "Připraveno: {n}", few = "Připraveno: {n}", other = "Připraveno: {n}" } +"{n} removed" = { one = "Odebráno: {n}", few = "Odebráno: {n}", other = "Odebráno: {n}" } +"{n} replies" = { one = "{n} odpověď", few = "{n} odpovědi", other = "{n} odpovědí" } +"{n} rows" = { one = "{n} řádek", few = "{n} řádky", other = "{n} řádků" } +"{n} styles" = { one = "{n} styl", few = "{n} styly", other = "{n} stylů" } +"{n} variables" = { one = "{n} proměnná", few = "{n} proměnné", other = "{n} proměnných" } +"{n} weeks ago" = { one = "Před {n} týdnem", few = "Před {n} týdny", other = "Před {n} týdny" } +"{n} years ago" = { one = "Před {n} rokem", few = "Před {n} lety", other = "Před {n} lety" } "{n}d" = "{n} d" "{n}h" = "{n} h" "{n}m" = "{n} min" diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 46ccd909..d30130fb 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -163,6 +163,9 @@ enum Plural { /// CLDR integer rules. Existing one/other catalogs may omit the extra forms. fn plural(code: &str, n: usize) -> Plural { match code { + "cs" if n == 1 => Plural::One, + "cs" if (2..=4).contains(&n) => Plural::Few, + "cs" => Plural::Other, "ru" if n % 10 == 1 && n % 100 != 11 => Plural::One, "ru" if (2..=4).contains(&(n % 10)) && !(12..=14).contains(&(n % 100)) => Plural::Few, "ru" => Plural::Many, @@ -174,7 +177,7 @@ fn plural(code: &str, n: usize) -> Plural { } // CLDR's million-count "many" in French, Spanish and Portuguese still reads their existing -// "other" wording. Only Russian currently supplies separate few/many forms. +// "other" wording. Czech supplies separate few forms; Russian supplies few/many forms. /// Whether count `n` takes the "one" form: 0 and 1 in French and Brazilian Portuguese, never in /// Japanese, Korean and Chinese (no plural forms: always "other"), 1 elsewhere (CLDR's integer rules). diff --git a/crates/ui-egui/src/i18n_tests.rs b/crates/ui-egui/src/i18n_tests.rs index 4a03ae69..07f32037 100644 --- a/crates/ui-egui/src/i18n_tests.rs +++ b/crates/ui-egui/src/i18n_tests.rs @@ -419,6 +419,26 @@ fn the_key_scanner_reads_rust_literals() { assert_eq!(literals(src), vec!["A \"b\"", "C {n}", "DE"]); } +#[test] +fn czech_integer_plurals() { + let cs = LANGS.iter().position(|l| l.code == "cs").unwrap(); + assert_eq!(plural("cs", 1), Plural::One); + assert_eq!(fill(counted(cs, "{n} layers", 1), &[("n", &1)]), "1 vrstva"); + for n in [2, 3, 4] { + assert_eq!(plural("cs", n), Plural::Few, "{n}"); + assert_eq!(fill(counted(cs, "{n} layers", n), &[("n", &n)]), format!("{n} vrstvy")); + } + for n in [0, 5, 11, 12, 21, 22, 100, 1_000_000] { + assert_eq!(plural("cs", n), Plural::Other, "{n}"); + assert_eq!(fill(counted(cs, "{n} layers", n), &[("n", &n)]), format!("{n} vrstev")); + } + for (key, text) in LANGS[cs].ui { + if let Text::Plural { one, few, .. } = text { + assert!(one.is_some() && few.is_some(), "{key}: missing Czech plural forms"); + } + } +} + #[test] fn russian_language_tags_and_integer_plurals() { let ru = LANGS.iter().position(|l| l.code == "ru").unwrap(); diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 1fbf705f..45a7a925 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -21,7 +21,7 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v - **Layer:** one TOML catalog per language in `crates/ui-egui/locales/`, baked into the binary by the UI crate's build script (desktop and web; nothing read at run time). Keys are the English text - (`tr("Fill")`, `trf` for `{named}` placeholders, `trn` for counts with one/other plural forms), so + (`tr("Fill")`, `trf` for `{named}` placeholders, `trn` for counts with one/few/many/other plural forms), so a missing translation shows English, never a raw key. One English text that needs two translations takes a context (`"Fill|sizing"`). - **Strings:** 1,417 UI keys in `en.toml` (36 with plural forms; ~110 are labels from engine and @@ -55,9 +55,10 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v until someone translates it. Every draft translates every string as of 2026-10-12 (checked by adding each to `COMPLETE` locally and running the check). - **Not yet localized:** messages the engine writes (errors, a few toasts, Dev Mode property values - such as "inside"), number formatting (decimal points stay `.`), and plural forms beyond one/other - (French and Brazilian Portuguese take the singular for 0 as well; Japanese, Korean and Chinese - have one form; CLDR's "many" for millions reads as the plural). + such as "inside"), number formatting (decimal points stay `.`), and decimal plural rules + (`trn` takes integers: Czech uses one/few/other and Russian one/few/many; French and Brazilian + Portuguese take the singular for 0 as well; Japanese, Korean and Chinese have one form; + CLDR's "many" for millions in French, Spanish and Portuguese reads as the plural). - **Script support in documents:** Unicode bidi with Arabic shaping and mirrored brackets in text layers; right-to-left list markers and indents; right-to-left names in the layers panel, tabs and canvas labels (visual order, joined Arabic); inline IME composition with the candidate window diff --git a/docs/translating.md b/docs/translating.md index bdb3a913..d93c0255 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -80,9 +80,9 @@ language also needs its script in `cjk::language`. `few` for 2–4, 22–24… except 12–14; and `many` for the other integers, including 0. Keep `{n}` in all Russian forms. Add new rules to `plural` in [`crates/ui-egui/src/i18n.rs`](../crates/ui-egui/src/i18n.rs). A missing form reads `other`; - a missing key uses the fallback catalog's own plural rule. Czech has no rule of its own yet, so it - picks `one` for 1 and `other` for everything else: its `other` forms are count-neutral for 0, - 2–4 and 5+ (e.g. `Vrstvy: {n}`). + a missing key uses the fallback catalog's own plural rule. Czech uses `one` for 1, `few` for + 2–4 and `other` for 0 and 5+ (e.g. `{n} vrstva`, `{n} vrstvy`, `{n} vrstev`). Keep `{n}` in all + three forms. Czech's CLDR `many` applies only to decimals; `trn` takes integer counts. 4. To require full coverage, add the code to `COMPLETE` in [`crates/ui-egui/src/i18n_tests.rs`](../crates/ui-egui/src/i18n_tests.rs). Every later PR that adds a string then has to translate it into each complete language, so only Spanish is there: From b640069961f7a3a3fbd17297bdc8252d3a6a1cb9 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Thu, 15 Oct 2026 21:48:25 +0200 Subject: [PATCH 11/18] M7: drop files anywhere to open them - Files dragged over the window show a "Drop to open" (home screen) or "Drop to import" (canvas) overlay. - On the home screen each dropped file the app opens becomes a document of its own; any other file is turned away with a toast. - On the canvas dropped files import as before, at the drop point. - A small card ("Opening as a new file", "Adding to {document}") is on screen before a large file blocks the window; the files then open a frame later. - A canvas import stays with the document and page that received the drop, keeps its placement, restores the page and selection chosen meanwhile, and is cancelled with a message if that document or page is gone. - ui.inspect reports pendingOpens; every new string, including the cancel messages, goes through tr() with en, de and es entries. --- ROADMAP.md | 3 +- crates/ui-egui/locales/de.toml | 9 + crates/ui-egui/locales/en.toml | 9 + crates/ui-egui/locales/es.toml | 9 + crates/ui-egui/src/canvas.rs | 34 ++-- crates/ui-egui/src/dialogs.rs | 2 + crates/ui-egui/src/home.rs | 29 +++ crates/ui-egui/src/home_tests.rs | 241 ++++++++++++++++++++++++ crates/ui-egui/src/layers_drag_tests.rs | 11 +- crates/ui-egui/src/lib.rs | 59 +++++- crates/ui-egui/src/pending_open.rs | 131 +++++++++++++ 11 files changed, 510 insertions(+), 27 deletions(-) create mode 100644 crates/ui-egui/src/pending_open.rs diff --git a/ROADMAP.md b/ROADMAP.md index 389d8c41..7a11c7fd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-15 · **Change:** minor (Typography spacing icons support scrubbing (#373); system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (files dropped anywhere on the window open from the home screen or import onto the canvas; Typography spacing icons support scrubbing (#373); system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) 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 @@ -313,6 +313,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. | | 2026-10-15 | minor | Typography spacing icons scrub with the shared number-field gesture, modifier rates and one Undo step per drag (#373). Units and selected-character targeting are preserved. Numbers unchanged. | | 2026-10-14 | minor | System theme on first run (M7): preferences without a theme default to System; the first frame reads the OS appearance directly, including when returning from a manual theme. Saved Light/Dark choices stay explicit; unavailable OS appearance falls back to Light. Numbers unchanged. | | 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 36305e84..cd1e3482 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -64,6 +64,7 @@ status = "draft — needs native review" "Add variant with a new value" = "Variante mit neuem Wert hinzufügen" "Added" = "Hinzugefügt" "Added the library \"{name}\"" = "Bibliothek „{name}“ hinzugefügt" +"Adding to {name}" = "Hinzufügen zu {name}" "Adjust" = "Anpassen" "Adjustment presets" = "Vorgaben" "Advanced" = "Erweitert" @@ -392,6 +393,9 @@ status = "draft — needs native review" "Drag the stroke to add a width point" = "Ziehen Sie an der Kontur, um einen Breitenpunkt hinzuzufügen" "Draw" = "Zeichnen" "Drawing tools" = "Zeichenwerkzeuge" +"Drop a .figcraft, .fig, .json, .svg or image file." = "Legen Sie eine .figcraft-, .fig-, .json-, .svg- oder Bilddatei ab." +"Drop to import" = "Zum Importieren ablegen" +"Drop to open" = "Zum Öffnen ablegen" "Drop shadow" = "Schlagschatten" "Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Doppelt lizenziert unter MIT OR Apache-2.0. Entwickelt vom ArtCraft-Team und der Community." "Duo" = "Duo" @@ -857,6 +861,8 @@ status = "draft — needs native review" "Open prototype preview" = "Prototyp-Vorschau öffnen" "Open variables ({count})" = "Variablen öffnen ({count})" "Opened “{name}” in a new tab" = "„{name}“ in neuem Tab geöffnet" +"Opening the file…" = "Datei wird geöffnet…" +"Opening {n} files as new files" = { one = "Wird als neue Datei geöffnet", other = "{n} Dateien werden als neue Dateien geöffnet" } "Open|comments" = "Offen" "Options" = "Optionen" "Orange" = "Orange" @@ -1256,6 +1262,9 @@ status = "draft — needs native review" "Texts" = "Texte" "Texture" = "Textur" "The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "Die Arbeitsfläche zeigt Farben in sRGB an; exportierte PNG-, JPG- und WebP-Dateien werden als Display P3 gekennzeichnet." +"The destination document is unavailable. Nothing was imported." = "Das Zieldokument ist nicht verfügbar. Es wurde nichts importiert." +"The destination document was closed. Nothing was imported." = "Das Zieldokument wurde geschlossen. Es wurde nichts importiert." +"The destination page was removed. Nothing was imported." = "Die Zielseite wurde entfernt. Es wurde nichts importiert." "The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "Die Datei wird vom Server gelöscht: Niemand kann sie von dort wieder öffnen oder ihr beitreten, und das lässt sich nicht rückgängig machen. Geöffnete Kopien bleiben bei denen, die sie haben." "The file now holds another library" = "Die Datei enthält jetzt eine andere Bibliothek" "The file now holds another library (\"{name}\")" = "Die Datei enthält jetzt eine andere Bibliothek („{name}“)" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index d313c57e..3c127fc6 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -66,6 +66,7 @@ name = "English" "Add variant with a new value" = "Add variant with a new value" "Added" = "Added" "Added the library \"{name}\"" = "Added the library \"{name}\"" +"Adding to {name}" = "Adding to {name}" "Adjust" = "Adjust" "Adjustment presets" = "Adjustment presets" "Advanced" = "Advanced" @@ -407,6 +408,9 @@ name = "English" "Draw" = "Draw" "Draw a frame" = "Draw a frame" "Drawing tools" = "Drawing tools" +"Drop a .figcraft, .fig, .json, .svg or image file." = "Drop a .figcraft, .fig, .json, .svg or image file." +"Drop to import" = "Drop to import" +"Drop to open" = "Drop to open" "Drop shadow" = "Drop shadow" "Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." "Duo" = "Duo" @@ -900,6 +904,8 @@ name = "English" "Open prototype preview" = "Open prototype preview" "Open variables ({count})" = "Open variables ({count})" "Opened “{name}” in a new tab" = "Opened “{name}” in a new tab" +"Opening the file…" = "Opening the file…" +"Opening {n} files as new files" = { one = "Opening as a new file", other = "Opening {n} files as new files" } "Open|comments" = "Open" "Options" = "Options" "Orange" = "Orange" @@ -1320,6 +1326,9 @@ name = "English" "Texts" = "Texts" "Texture" = "Texture" "The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." +"The destination document is unavailable. Nothing was imported." = "The destination document is unavailable. Nothing was imported." +"The destination document was closed. Nothing was imported." = "The destination document was closed. Nothing was imported." +"The destination page was removed. Nothing was imported." = "The destination page was removed. Nothing was imported." "The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." "The file now holds another library" = "The file now holds another library" "The file now holds another library (\"{name}\")" = "The file now holds another library (\"{name}\")" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index c9b27846..901283f1 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -68,6 +68,7 @@ status = "draft — needs native review" "Add variant with a new value" = "Añadir variante con un valor nuevo" "Added" = "Añadido" "Added the library \"{name}\"" = "Biblioteca \"{name}\" añadida" +"Adding to {name}" = "Añadiendo a {name}" "Adjust" = "Ajustar" "Adjustment presets" = "Preajustes" "Advanced" = "Avanzado" @@ -408,6 +409,9 @@ status = "draft — needs native review" "Draw" = "Dibujar" "Draw a frame" = "Dibuja un marco" "Drawing tools" = "Herramientas de dibujo" +"Drop a .figcraft, .fig, .json, .svg or image file." = "Suelta un archivo .figcraft, .fig, .json, .svg o de imagen." +"Drop to import" = "Suelta para importar" +"Drop to open" = "Suelta para abrir" "Drop shadow" = "Sombra paralela" "Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Con doble licencia MIT OR Apache-2.0. Hecho por el equipo y la comunidad de ArtCraft." "Duo" = "Duo" @@ -901,6 +905,8 @@ status = "draft — needs native review" "Open prototype preview" = "Abrir vista previa del prototipo" "Open variables ({count})" = "Abrir variables ({count})" "Opened “{name}” in a new tab" = "“{name}” abierta en una pestaña nueva" +"Opening the file…" = "Abriendo el archivo…" +"Opening {n} files as new files" = { one = "Abriendo como archivo nuevo", other = "Abriendo {n} archivos como archivos nuevos" } "Open|comments" = "Abiertos" "Options" = "Opciones" "Orange" = "Naranja" @@ -1321,6 +1327,9 @@ status = "draft — needs native review" "Texts" = "Textos" "Texture" = "Textura" "The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "El lienzo muestra los colores en sRGB; los archivos PNG, JPG y WebP exportados se etiquetan como Display P3." +"The destination document is unavailable. Nothing was imported." = "El documento de destino no está disponible. No se importó nada." +"The destination document was closed. Nothing was imported." = "Se cerró el documento de destino. No se importó nada." +"The destination page was removed. Nothing was imported." = "Se eliminó la página de destino. No se importó nada." "The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "El archivo se elimina del servidor: nadie podrá volver a abrirlo ni unirse a él desde allí, y no se puede deshacer. Las copias abiertas se quedan con quien las tenga." "The file now holds another library" = "El archivo ahora contiene otra biblioteca" "The file now holds another library (\"{name}\")" = "El archivo ahora contiene otra biblioteca (\"{name}\")" diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ad94b596..d4aad233 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -183,6 +183,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { } } let own_cursor = input(app, ui, &resp); + crate::drop_overlay(&ctx, ctx.content_rect(), tr("Drop to import")); crate::guides::end_released(&ctx); crate::live::track_pointer(app, &ctx); app.sync_preedit(); @@ -414,19 +415,27 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { let editing_text = app.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false); let typing_elsewhere = crate::a11y::field_focused(&ctx); app.canvas.space_pan = space && !editing_text && !typing_elsewhere; - // Dropped files import onto the canvas. + // Dropped files import onto the canvas, behind a card while they do ([`crate::pending_open`]). let dropped = ctx.input(|i| i.raw.dropped_files.clone()); for f in dropped { let path = f.path().to_path_buf(); let pt = hover_pos.map(|h| app.to_page(h)).unwrap_or_default(); - if path.exists() { - app.run("file.import", json!({"path": path.to_string_lossy(), "x": pt.x, "y": pt.y})); - } else if let Some(bytes) = dropped_bytes(&f) { + let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped.png".into()); + let params = if path.exists() { + json!({"path": path.to_string_lossy(), "x": pt.x, "y": pt.y}) + } else if let Some(bytes) = crate::dropped_bytes(&f) { use base64::Engine as _; let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); - let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped.png".into()); - app.run("file.import", json!({"data": b64, "name": name, "x": pt.x, "y": pt.y})); - } + json!({"data": b64, "name": name, "x": pt.x, "y": pt.y}) + } else { + continue; + }; + let Some(target) = + app.session.ds().ok().map(|doc| crate::pending_open::ImportTarget { uid: doc.uid, page: doc.page, name: doc.doc.name.clone() }) + else { + continue; + }; + app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.import", target: Some(target) }); } let Some(pos) = hover_pos.filter(|p| app.canvas.rect.contains(*p)) else { // A drag released off the canvas, or off the window, ends where the pointer was last seen. @@ -585,17 +594,6 @@ pub fn open_context_menu(app: &mut FigApp, pos: Pos2, deep: bool) { app.ui.context_menu = Some((pos, app.session.selection())); } -#[cfg(not(target_arch = "wasm32"))] -fn dropped_bytes(f: &std::sync::Arc) -> Option> { - f.bytes().ok() -} - -#[cfg(target_arch = "wasm32")] -fn dropped_bytes(_f: &std::sync::Arc) -> Option> { - // Browser drops arrive asynchronously; not supported yet. - None -} - fn stroke(c: Color32, w: f32) -> Stroke { Stroke::new(w, c) } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7f4991dd..236f8e0d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -158,6 +158,8 @@ pub fn keys(app: &mut FigApp, ctx: &Context) { } pub fn show(app: &mut FigApp, ctx: &Context) { + // Dropped files waiting to open: their card, then (once it has shown) the files. + crate::pending_open::pending_opens(app, ctx); let t = Tokens::get(ctx); if app.ui.about { let mut open = true; diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index e6c78947..4a3741b5 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -137,6 +137,7 @@ fn listed(ui: &mut Ui, t: &Tokens, id: Id, height: f32, row: &FileRow) -> bool { pub fn show(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); + dropped(app, ui.ctx()); egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.panel)).show(ui, |ui| { let rect = ui.max_rect(); let left = Rect::from_min_size(rect.min, vec2(SIDEBAR, rect.height())); @@ -162,6 +163,34 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { }); }); }); + // The whole window is the drop target, like the incumbent. + crate::drop_overlay(ui.ctx(), ui.ctx().content_rect(), tr("Drop to open")); +} + +/// Files dropped on the home screen: each one the app opens becomes a document of its own (the +/// editor then shows it), behind an "Opening as a new file" card ([`crate::pending_open`]); anything +/// else is turned away with a toast. A file that fails to open keeps the engine's own error. +fn dropped(app: &mut FigApp, ctx: &egui::Context) { + let files = ctx.input(|i| i.raw.dropped_files.clone()); + for f in files { + let path = f.path().to_path_buf(); + if !crate::opens(&path) { + app.toast(tr("Drop a .figcraft, .fig, .json, .svg or image file.")); + continue; + } + // `opens` saw an extension, so there is a file name to tell the format by. + let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + let params = if path.exists() { + json!({"path": path.to_string_lossy()}) + } else if let Some(bytes) = crate::dropped_bytes(&f) { + use base64::Engine as _; + let data = base64::engine::general_purpose::STANDARD.encode(&bytes); + json!({"data": data, "name": name}) + } else { + continue; + }; + app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.open", target: None }); + } } fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) { diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs index af651992..87363656 100644 --- a/crates/ui-egui/src/home_tests.rs +++ b/crates/ui-egui/src/home_tests.rs @@ -327,3 +327,244 @@ fn tab_walks_the_sidebar_in_order_before_a_sections_controls() { let at = |name: &str| names.iter().position(|n| n == name).unwrap_or_else(|| panic!("Tab never reached {name:?}: {names:?}")); assert!(at("Recents") < at("Online") && at("Online") < at("Templates") && at("Templates") < at("Open file"), "{names:?}"); } + +/// A temporary directory of its own for a test's files, removed with them when dropped. +struct Temp(std::path::PathBuf); + +impl Temp { + fn new() -> Self { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let path = std::env::temp_dir().join(format!("figcraft-home-drop-{}-{stamp}-{n}", std::process::id())); + std::fs::create_dir(&path).unwrap(); + Self(path) + } + + fn path(&self, name: &str) -> std::path::PathBuf { + self.0.join(name) + } +} + +impl Drop for Temp { + fn drop(&mut self) { + if let Ok(files) = std::fs::read_dir(&self.0) { + for file in files.flatten() { + let _ = std::fs::remove_file(file.path()); + } + } + let _ = std::fs::remove_dir(&self.0); + } +} + +/// A file dropped on the window, as the windowing backend hands it over (read from its path). +#[derive(Debug)] +struct Dropped(std::path::PathBuf); + +impl egui::DroppedFile for Dropped { + fn path(&self) -> &std::path::Path { + &self.0 + } + + fn bytes(&self) -> Result, String> { + std::fs::read(&self.0).map_err(|e| e.to_string()) + } +} + +/// Drag `path` over the window (one frame: what it shows), then drop it (one frame). +fn drop_file(u: &mut Ui, path: &std::path::Path) -> Vec { + let hovered = egui::HoveredFile { path: Some(path.to_path_buf()), mime: String::new() }; + let hovering = flat(&u.frame_raw(egui::RawInput { hovered_files: vec![hovered], ..Default::default() }).shapes); + let file: egui::DroppedFileHandle = std::sync::Arc::new(Dropped(path.to_path_buf())); + u.frame_raw(egui::RawInput { dropped_files: vec![file], ..Default::default() }); + hovering +} + +#[test] +fn dropping_a_native_file_on_the_home_screen_opens_it() { + let dir = Temp::new(); + let path = dir.path("Dropped.figcraft"); + let mut saved = Session::new(); + saved.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + assert!(path.exists()); + let mut u = first_run(); + u.app.ui.home = true; + let s = drop_file(&mut u, &path); + assert!(has_text(&s, "Drop to open"), "dragging over the home screen says what a drop does"); + // The file waits behind its card, which is on screen before it opens. + assert_eq!(u.app.ui.pending_opens.len(), 1); + assert!(u.app.session.docs.is_empty(), "nothing opens in the frame of the drop"); + let s = flat(&u.frame_output(vec![]).shapes); + assert!(has_text(&s, "Opening as a new file") && has_text(&s, "Opening the file…") && has_text(&s, "Dropped.figcraft"), "the card shows"); + assert_eq!(u.app.ui.pending_opens.len(), 1); + assert!(u.app.session.docs.is_empty(), "the card is drawn first"); + assert_eq!(u.app.inspect()["pendingOpens"], 1); + u.frame(vec![]); + assert!(u.app.ui.pending_opens.is_empty()); + assert_eq!(u.app.session.docs.len(), 1); + assert_eq!(u.app.session.docs[0].path.as_deref(), Some(path.to_string_lossy().as_ref())); + assert!(!u.app.ui.home, "and the editor shows it"); + assert!(u.app.ui.toast.as_ref().is_none_or(|t| !t.0.starts_with("Drop a")), "no turning away"); +} + +#[test] +fn dropping_a_native_file_on_canvas_keeps_its_receiving_document_after_tab_switch() { + let dir = Temp::new(); + let path = dir.path("Canvas import.figcraft"); + let mut source = Session::new(); + source.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + + let mut session = Session::new(); + let target_uid = session.ds().unwrap().uid; + let target_page = session.page().unwrap(); + let target_name = session.doc().unwrap().name.clone(); + session.execute("file.new", &json!({"name": "Other tab"})).unwrap(); + let other_uid = session.ds().unwrap().uid; + session.active = 0; + let mut u = Ui::new(session); + u.app.ui.home = false; + u.frame(vec![]); + let pos = u.app.canvas.rect.center(); + let file: egui::DroppedFileHandle = std::sync::Arc::new(Dropped(path.clone())); + u.frame_raw(egui::RawInput { events: vec![Event::PointerMoved(pos)], dropped_files: vec![file], ..Default::default() }); + let pending = u.app.ui.pending_opens.first().unwrap(); + assert_eq!(pending.target.as_ref().map(|target| (target.uid, target.page)), Some((target_uid, target_page))); + assert_eq!(pending.target.as_ref().map(|target| target.name.as_str()), Some(target_name.as_str())); + + u.frame(vec![]); + u.app.session.active = 1; + assert_eq!(u.app.session.ds().unwrap().uid, other_uid, "the UI switched tabs while the card was displayed"); + assert_eq!(u.app.ui.pending_opens.len(), 1, "the switch happens before dispatch"); + u.frame(vec![]); + + assert_eq!(u.app.session.ds().unwrap().uid, other_uid, "the user's active tab stays in front"); + assert_eq!(u.app.session.docs.iter().map(|doc| doc.doc.pages.len()).collect::>(), [2, 1]); +} + +#[test] +fn canvas_import_uses_queued_page_and_restores_the_page_selected_while_waiting() { + let dir = Temp::new(); + let path = dir.path("Queued.svg"); + std::fs::write(&path, r##""##) + .unwrap(); + + let mut session = Session::new(); + let target_uid = session.ds().unwrap().uid; + let first_page = session.page().unwrap(); + session.execute("page.add", &json!({"name": "Second page"})).unwrap(); + let second_page = session.page().unwrap(); + let second_page_shape = session.execute("node.create", &json!({"type": "rectangle", "x": 30, "y": 40, "width": 12, "height": 14})).unwrap()["id"] + .as_u64() + .map(figcraft_doc::NodeId) + .unwrap(); + session.execute("page.select", &json!({"id": first_page})).unwrap(); + session.execute("file.new", &json!({"name": "Other tab"})).unwrap(); + let other_uid = session.ds().unwrap().uid; + session.active = 0; + let mut u = Ui::new(session); + u.app.ui.home = false; + u.frame(vec![]); + let pos = u.app.canvas.rect.center(); + let file: egui::DroppedFileHandle = std::sync::Arc::new(Dropped(path)); + u.frame_raw(egui::RawInput { events: vec![Event::PointerMoved(pos)], dropped_files: vec![file], ..Default::default() }); + let queued_params = u.app.ui.pending_opens[0].params.clone(); + assert_eq!(u.app.ui.pending_opens[0].target.as_ref().map(|target| (target.uid, target.page)), Some((target_uid, first_page))); + + u.frame(vec![]); + u.app.session.execute("page.select", &json!({"id": second_page})).unwrap(); + u.app.session.execute("select.set", &json!({"ids": [second_page_shape]})).unwrap(); + u.app.session.active = 1; + u.frame(vec![]); + let target_index = u.app.session.docs.iter().position(|doc| doc.uid == target_uid).unwrap(); + let imported = u.app.session.docs[target_index].doc.node(first_page).unwrap().children[0]; + let doc = &u.app.session.docs[target_index].doc; + assert_eq!(doc.page_of(imported), Some(first_page)); + let bounds = doc.world_bounds(imported).unwrap(); + assert!((bounds.x0 - queued_params["x"].as_f64().unwrap()).abs() < 1.0); + assert!((bounds.y0 - queued_params["y"].as_f64().unwrap()).abs() < 1.0); + assert_eq!(doc.node(second_page).unwrap().children, [second_page_shape]); + assert_eq!(u.app.session.ds().unwrap().uid, other_uid); + assert_eq!(u.app.session.docs[target_index].page, second_page, "the page selected during the wait is restored"); + assert_eq!(u.app.session.docs[target_index].selection, [second_page_shape], "the restored page keeps its selection"); + + u.app.session.active = target_index; + u.app.session.execute("edit.delete", &json!({})).unwrap(); + let doc = &u.app.session.docs[target_index].doc; + assert!(doc.node(second_page).unwrap().children.is_empty(), "the next edit acts on the restored page selection"); + assert_eq!(doc.node(first_page).unwrap().children.len(), 1, "the imported object on the queued page remains"); +} + +#[test] +fn queued_canvas_import_is_cancelled_if_its_page_is_deleted() { + let dir = Temp::new(); + let path = dir.path("Removed target.svg"); + std::fs::write(&path, r#""#).unwrap(); + + let mut session = Session::new(); + let target_uid = session.ds().unwrap().uid; + session.execute("page.add", &json!({"name": "Keep this page"})).unwrap(); + let target_page = session.page().unwrap(); + session.execute("file.new", &json!({"name": "Other tab"})).unwrap(); + let other_uid = session.ds().unwrap().uid; + session.active = 0; + let mut u = Ui::new(session); + u.app.ui.home = false; + u.frame(vec![]); + let pos = u.app.canvas.rect.center(); + let file: egui::DroppedFileHandle = std::sync::Arc::new(Dropped(path)); + u.frame_raw(egui::RawInput { events: vec![Event::PointerMoved(pos)], dropped_files: vec![file], ..Default::default() }); + u.app.session.execute("page.delete", &json!({"id": target_page})).unwrap(); + let other_before = u.app.session.docs.iter().find(|doc| doc.uid == other_uid).unwrap().doc.clone(); + u.app.ui.pending_open_frames = 2; + crate::pending_open::pending_opens(&mut u.app, &u.ctx); + assert_eq!(u.app.session.docs.iter().find(|doc| doc.uid == other_uid).unwrap().doc, other_before); + let target = u.app.session.docs.iter().find(|doc| doc.uid == target_uid).unwrap(); + assert_eq!(target.doc.pages.len(), 1); + assert!(!target.doc.nodes().any(|node| node.name == "Removed target")); + assert!(u.app.ui.last_error.as_deref().is_some_and(|error| error.contains("destination page was removed"))); +} + +#[test] +fn queued_canvas_import_is_cancelled_if_its_document_is_closed() { + let dir = Temp::new(); + let path = dir.path("Closed target.svg"); + std::fs::write(&path, r#""#).unwrap(); + + let mut session = Session::new(); + let target_uid = session.ds().unwrap().uid; + session.execute("file.new", &json!({"name": "Other tab"})).unwrap(); + let other_uid = session.ds().unwrap().uid; + session.active = 0; + let mut u = Ui::new(session); + u.app.ui.home = false; + u.frame(vec![]); + let pos = u.app.canvas.rect.center(); + let file: egui::DroppedFileHandle = std::sync::Arc::new(Dropped(path)); + u.frame_raw(egui::RawInput { events: vec![Event::PointerMoved(pos)], dropped_files: vec![file], ..Default::default() }); + u.frame(vec![]); + let other_before = u.app.session.docs.iter().find(|doc| doc.uid == other_uid).unwrap().doc.clone(); + let target_index = u.app.session.docs.iter().position(|doc| doc.uid == target_uid).unwrap(); + u.app.session.docs.remove(target_index); + u.app.session.active = 0; + + while !u.app.ui.pending_opens.is_empty() { + u.frame(vec![]); + } + assert_eq!(u.app.session.docs.len(), 1); + assert_eq!(u.app.session.ds().unwrap().uid, other_uid); + assert_eq!(u.app.session.doc().unwrap(), other_before.as_ref()); + assert!(u.app.ui.last_error.as_deref().is_some_and(|error| error.contains("destination document was closed"))); +} + +#[test] +fn dropping_an_unsupported_file_on_the_home_screen_opens_nothing() { + let dir = Temp::new(); + let path = dir.path("unsupported.txt"); + std::fs::write(&path, "not a design").unwrap(); + let mut u = first_run(); + u.app.ui.home = true; + drop_file(&mut u, &path); + assert!(u.app.session.docs.is_empty()); + assert!(u.app.ui.home); + assert_eq!(u.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Drop a .figcraft, .fig, .json, .svg or image file.")); +} diff --git a/crates/ui-egui/src/layers_drag_tests.rs b/crates/ui-egui/src/layers_drag_tests.rs index 10900680..37002f9f 100644 --- a/crates/ui-egui/src/layers_drag_tests.rs +++ b/crates/ui-egui/src/layers_drag_tests.rs @@ -35,10 +35,15 @@ impl Ui { /// [`Self::frame_output`] with `modifiers` held. pub(crate) fn frame_with(&mut self, mut events: Vec, modifiers: egui::Modifiers) -> egui::FullOutput { - self.time += 1.0 / 60.0; events.insert(0, Event::ModifiersChanged(modifiers)); - let input = - RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), events, ..Default::default() }; + self.frame_raw(RawInput { events, ..Default::default() }) + } + + /// One frame of `raw` input (files dropped on or hovered over the window, say), on the test + /// screen and clock. + pub(crate) fn frame_raw(&mut self, raw: RawInput) -> egui::FullOutput { + self.time += 1.0 / 60.0; + let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), ..raw }; let app = &mut self.app; let mut out = self.ctx.run_ui(input, |ui| app.ui(ui)); out.textures_delta.clear(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 936c32a9..fa406571 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -40,6 +40,7 @@ pub mod live_text; pub mod measure_overlay; pub mod menus; pub mod online; +pub mod pending_open; pub mod plugins_ui; pub mod present; pub mod proto_canvas; @@ -187,7 +188,7 @@ mod windows_tests; use std::collections::BTreeSet; use std::future::Future; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::pin::Pin; use std::sync::Arc; use std::task::{Context as TaskContext, Poll, Wake, Waker}; @@ -446,6 +447,10 @@ pub struct UiState { pub follow_system: bool, pub tool_flyout: Option<&'static str>, pub last_error: Option, + /// Files dropped on the home screen or the canvas, opened once their card has been on screen + /// ([`pending_open::pending_opens`]), and the frames it has been drawn. + pub pending_opens: Vec, + pub pending_open_frames: u8, pub screenshot_requests: Vec<(u64, String)>, pub frame_ms: f64, pub render_ms: f64, @@ -552,6 +557,8 @@ impl Default for UiState { follow_system: false, tool_flyout: None, last_error: None, + pending_opens: Vec::new(), + pending_open_frames: 0, screenshot_requests: Vec::new(), frame_ms: 0.0, render_ms: 0.0, @@ -948,10 +955,9 @@ impl FigApp { } pub fn open_dialog(&mut self) { - let exts = ["figcraft", "fig", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"]; // Opening needs no file in front: it goes ahead even if that file closed meanwhile. self.start_dialog( - |services| services.pick_open(i18n::tr("Open"), &exts), + |services| services.pick_open(i18n::tr("Open"), OPEN_EXTENSIONS), false, |app, p| { let ps = p.to_string_lossy().to_string(); @@ -1444,7 +1450,7 @@ impl FigApp { /// UI state as JSON (control channel `ui.inspect`). pub fn inspect(&self) -> Value { - json!({ + let mut state = json!({ "rightTab": self.ui.right_tab, "leftTab": self.ui.left_tab, "home": self.ui.home, @@ -1503,9 +1509,52 @@ impl FigApp { "panels": {"left": self.ui.left_width, "right": self.ui.right_width, "leftCollapsed": self.ui.left_collapsed}, "status": self.session.status(), "lastError": self.ui.last_error, - }) + }); + state["pendingOpens"] = json!(self.ui.pending_opens.len()); + state } } +/// The file types File ▸ Open and a drop on the home screen open. +pub(crate) const OPEN_EXTENSIONS: &[&str] = &["figcraft", "fig", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"]; + +/// Whether `path` names a file type that opens as a document (the extension, in any case). +pub(crate) fn opens(path: &Path) -> bool { + path.extension().and_then(|e| e.to_str()).is_some_and(|e| OPEN_EXTENSIONS.iter().any(|x| x.eq_ignore_ascii_case(e))) +} + +/// The contents of a dropped file that has no file on disk to read by path. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle) -> Option> { + f.bytes().ok() +} + +/// The contents of a dropped file that has no file on disk to read by path. +#[cfg(target_arch = "wasm32")] +pub(crate) fn dropped_bytes(_f: &egui::DroppedFileHandle) -> Option> { + // Browser drops arrive asynchronously; not supported yet. + None +} + +/// While files are dragged over the window: a tinted, outlined `rect` with `label` in its middle, +/// over everything else, saying what a drop does there. +pub(crate) fn drop_overlay(ctx: &egui::Context, rect: egui::Rect, label: &str) { + if ctx.input(|i| i.raw.hovered_files.is_empty()) { + return; + } + let t = theme::Tokens::get(ctx); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("drop_overlay"))); + let area = rect.shrink(4.0); + painter.rect_filled(area, egui::CornerRadius::same(8), t.accent.gamma_multiply(0.12)); + painter.rect_stroke(area, egui::CornerRadius::same(8), egui::Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); + let galley = painter.layout_no_wrap(label.to_owned(), theme::semibold(14.0), t.text); + let pill = egui::Rect::from_center_size(area.center(), galley.size() + egui::vec2(32.0, 16.0)); + painter.rect_filled(pill, egui::CornerRadius::same(8), t.panel); + painter.rect_stroke(pill, egui::CornerRadius::same(8), egui::Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + painter.galley(pill.center() - galley.size() / 2.0, galley, t.text); + // Hovering sends no pointer events on every platform: keep the overlay current. + ctx.request_repaint(); +} + #[cfg(test)] mod typography_scrub_tests; diff --git a/crates/ui-egui/src/pending_open.rs b/crates/ui-egui/src/pending_open.rs new file mode 100644 index 00000000..63c213ff --- /dev/null +++ b/crates/ui-egui/src/pending_open.rs @@ -0,0 +1,131 @@ +//! Files dropped on the home screen or the canvas open a frame later, behind a card that says what +//! is happening: opening a large `.fig` blocks the UI thread for a while, and the card is on screen +//! before it does (rather than a window that freezes without a word). + +use figcraft_doc::NodeId; +use serde_json::Value; + +use crate::FigApp; +use crate::i18n::{tr, trf, trn}; +use crate::theme::{Tokens, semibold}; + +/// A dropped file waiting to be opened (`file.open`, a document of its own) or imported +/// (`file.import`, into the current document). +pub struct PendingOpen { + /// What the card shows for it (the file name). + pub label: String, + pub params: Value, + pub command: &'static str, + /// Canvas imports stay with the document and page that received the drop. + pub target: Option, +} + +pub struct ImportTarget { + pub uid: u64, + pub page: NodeId, + pub name: String, +} + +fn run_pending(app: &mut FigApp, pending: PendingOpen) -> Option { + let target = match pending.target { + Some(target) => target, + None if pending.command != "file.import" => return app.run(pending.command, pending.params), + None => { + cancel(app, tr("The destination document is unavailable. Nothing was imported.").to_string()); + return None; + } + }; + let Some(index) = app.session.docs.iter().position(|doc| doc.uid == target.uid) else { + cancel(app, tr("The destination document was closed. Nothing was imported.").to_string()); + return None; + }; + if !app.session.docs[index].doc.pages.contains(&target.page) { + cancel(app, tr("The destination page was removed. Nothing was imported.").to_string()); + return None; + } + + let previous = app.session.ds().ok().map(|doc| doc.uid); + let previous_page = app.session.docs[index].page; + let previous_selection = app.session.docs[index].selection.clone(); + app.session.active = index; + app.session.docs[index].page = target.page; + let result = app.run(pending.command, pending.params); + if previous_page != target.page + && let Some(doc) = app.session.docs.iter_mut().find(|doc| doc.uid == target.uid) + && doc.doc.pages.contains(&previous_page) + { + doc.page = previous_page; + doc.selection = previous_selection; + doc.clean_selection(); + } + if let Some(index) = previous.and_then(|uid| app.session.docs.iter().position(|doc| doc.uid == uid)) { + app.session.active = index; + } + result +} + +/// An import that has no destination left: nothing is imported, and the user is told why. +fn cancel(app: &mut FigApp, message: String) { + app.ui.last_error = Some(message.clone()); + app.toast(message); +} + +/// Frames the card is drawn before the files open: egui lays a new card out invisibly on its first +/// frame, so the second one is the first the window shows. +const FRAMES_BEFORE_OPENING: u8 = 2; + +/// The card while files wait, then (once it has been on screen) open them. Called before the import +/// report, so a report for these files shows after them. +pub fn pending_opens(app: &mut FigApp, ctx: &egui::Context) { + if app.ui.pending_opens.is_empty() { + app.ui.pending_open_frames = 0; + return; + } + ctx.request_repaint(); + if app.ui.pending_open_frames >= FRAMES_BEFORE_OPENING { + app.ui.pending_open_frames = 0; + for pending in std::mem::take(&mut app.ui.pending_opens) { + let command = pending.command; + if run_pending(app, pending).is_some() && command == "file.open" { + app.ui.home = false; + } + } + return; + } + app.ui.pending_open_frames += 1; + let title = match app.ui.pending_opens.first().map(|p| p.command) { + Some("file.import") => { + let name = app + .ui + .pending_opens + .first() + .and_then(|pending| pending.target.as_ref().map(|target| target.name.clone())) + .or_else(|| app.session.doc().ok().map(|d| d.name.clone())) + .unwrap_or_default(); + trf("Adding to {name}", &[("name", &name)]) + } + _ => trn("Opening {n} files as new files", app.ui.pending_opens.len(), &[]), + }; + let t = Tokens::get(ctx); + let pending = &app.ui.pending_opens; + // Shown at once: the frame after this one may block the window while the file opens. + crate::dialogs::window("", ctx).id(egui::Id::new("pending_open")).title_bar(false).movable(false).interactable(false).fade_in(false).show( + ctx, + |ui| { + ui.set_width(480.0); + ui.label(egui::RichText::new(title).font(semibold(13.0))); + ui.add_space(12.0); + for p in pending { + ui.horizontal(|ui| { + ui.add(egui::Spinner::new().size(16.0)); + ui.label(p.label.as_str()); + }); + } + ui.add_space(16.0); + ui.horizontal(|ui| { + ui.add(egui::Spinner::new().size(12.0)); + ui.label(egui::RichText::new(tr("Opening the file…")).color(t.text_secondary)); + }); + }, + ); +} From d27ae11d150fd4c159c574f675fa818e28f9180f Mon Sep 17 00:00:00 2001 From: Barzabel Date: Thu, 15 Oct 2026 23:06:31 +0300 Subject: [PATCH 12/18] M1: a duplicate's fill stays its own (#586) Regression test for #586: with the colour picker open on a red rectangle or pen path, duplicating it (edit.duplicate, or copy and paste) and recolouring the copy leaves the original red. The cause was the picker staying bound to the original after the selection moved to the copy, fixed by #523; without that fix this test fails. The duplicated paint is already independent data. --- crates/ui-egui/src/duplicate_paint_tests.rs | 71 +++++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 + 2 files changed, 73 insertions(+) create mode 100644 crates/ui-egui/src/duplicate_paint_tests.rs diff --git a/crates/ui-egui/src/duplicate_paint_tests.rs b/crates/ui-egui/src/duplicate_paint_tests.rs new file mode 100644 index 00000000..b8ca81c6 --- /dev/null +++ b/crates/ui-egui/src/duplicate_paint_tests.rs @@ -0,0 +1,71 @@ +//! A duplicate's fill is its own: recolouring it, with the picker that was open on the original, +//! leaves the original as it was (#586). + +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::colorpicker::PickerTarget; +use crate::layers_drag_tests::Ui; + +fn id(v: &serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().or_else(|| v["ids"][0].as_u64()).unwrap()) +} + +fn fill(u: &Ui, id: NodeId) -> String { + u.app.session.doc().unwrap().node(id).unwrap().fills[0].solid_color().unwrap().to_css_hex() +} + +/// A red rectangle or pen path, selected, with the colour picker of its fill open. +fn red(vector: bool) -> (Ui, NodeId) { + let mut s = Session::new(); + let a = if vector { + id(&s.execute("vector.create", &json!({"points": [[0, 0], [60, 0], [30, 50]], "closed": true})).unwrap()) + } else { + id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 40})).unwrap()) + }; + s.execute("fill.set", &json!({"ids": [a], "color": "#FF0000"})).unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let mut u = Ui::new(s); + u.app.ui.picker = Some(PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }); + u.frame(vec![]); + (u, a) +} + +/// Duplicate the selection the way `how` names, moved clear of the original; the copy's id. +fn duplicate(u: &mut Ui, how: &str) -> NodeId { + let copy = match how { + "duplicate" => id(&u.app.session.execute("edit.duplicate", &json!({"dx": 100})).unwrap()), + _ => { + u.app.session.execute("edit.copy", &json!({})).unwrap(); + id(&u.app.session.execute("edit.paste", &json!({"x": 100, "y": 0})).unwrap()) + } + }; + u.frame(vec![]); + u.frame(vec![]); + copy +} + +#[test] +fn recolouring_a_duplicate_leaves_the_original() { + for vector in [false, true] { + for how in ["duplicate", "paste"] { + let (mut u, a) = red(vector); + let b = duplicate(&mut u, how); + assert_ne!(a, b); + assert_eq!(u.app.session.selection(), vec![b], "{how} selects the copy"); + // The picker opened for the original doesn't follow it onto the copy's edits. + let picker = u.app.ui.picker.clone(); + assert!( + !matches!(&picker, Some(PickerTarget::Paint { ids, .. }) if ids.contains(&a)), + "vector {vector}, {how}: the picker still edits the original: {picker:?}" + ); + // Recolour the selection as the fill row does. + u.app.ui.picker = Some(PickerTarget::Paint { fills: true, index: 0, ids: u.app.session.selection() }); + u.app.session.execute("fill.set", &json!({"ids": u.app.session.selection(), "color": "#0000FF"})).unwrap(); + u.frame(vec![]); + assert_eq!(fill(&u, b), "#0000FF", "vector {vector}, {how}"); + assert_eq!(fill(&u, a), "#FF0000", "vector {vector}, {how}: the original keeps its fill"); + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 936c32a9..d5d6bdfd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -87,6 +87,8 @@ mod cursors_tests; #[cfg(test)] mod dev_mode_tests; #[cfg(test)] +mod duplicate_paint_tests; +#[cfg(test)] mod export_preview_tests; #[cfg(test)] mod field_tests; From 65f5771a6e82913cf6c01541b8082749467e89ba Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 05:04:00 +0700 Subject: [PATCH 13/18] Home: describe the drop target without naming other software --- crates/ui-egui/src/home.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 4a3741b5..abbd0440 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -163,7 +163,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { }); }); }); - // The whole window is the drop target, like the incumbent. + // The whole window is the drop target, not only the canvas. crate::drop_overlay(ui.ctx(), ui.ctx().content_rect(), tr("Drop to open")); } From 90d75218400ed4281216d6f7bcd6b293793c99a5 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:19:49 +0200 Subject: [PATCH 14/18] Reject negative or fractional index in file.close Found while testing crates/engine/src/cmd/file.rs:1283 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/file.rs | 2 +- crates/engine/src/open_issues_tests.rs | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 54348372..b8d47277 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -1280,7 +1280,7 @@ fn append_pages(doc: &mut Document, other: &Document) -> Result> { } fn close(s: &mut Session, p: &Value) -> Result { - let i = num(p, "index").map(|x| x as usize).unwrap_or(s.active); + let i = if get(p, "index").is_none() { s.active } else { req_index("file.close", p, "index")? }; let Some(ds) = s.docs.get(i) else { return Err(bad("file.close", "no such document")) }; // A branch tab's edits are in its main file already (which then has unsaved changes). if ds.branch_of.is_none() && ds.is_dirty() && !boolean(p, "force").unwrap_or(false) { diff --git a/crates/engine/src/open_issues_tests.rs b/crates/engine/src/open_issues_tests.rs index d7f23720..2bee799b 100644 --- a/crates/engine/src/open_issues_tests.rs +++ b/crates/engine/src/open_issues_tests.rs @@ -49,3 +49,16 @@ fn an_intact_file_opens_without_a_message() { assert!(s.message.is_none()); let _ = std::fs::remove_dir_all(path.parent().unwrap()); } + +#[test] +fn file_close_rejects_a_bad_index_instead_of_closing_another_document() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("file.new", &json!({})).unwrap(); + let docs = s.docs.len(); + for index in [json!(-1), json!(1.5), json!(99)] { + let r = s.execute("file.close", &json!({"index": index, "force": true})); + assert!(r.is_err(), "index {index} must be rejected, got {r:?}"); + assert_eq!(s.docs.len(), docs, "index {index} closed a document"); + } +} From 1f6010e07c20bbff499bc2694f7e847a2b2a1ce3 Mon Sep 17 00:00:00 2001 From: Skyfuller1303 Date: Thu, 15 Oct 2026 23:48:12 +0200 Subject: [PATCH 15/18] M7: open dropped files that have no file on disk A home-screen drop that carries only bytes queued file.open with data and name, which file.open rejected because it requires a path. file.open now also takes base64 data with a name and opens it as a new document without a save path. --- crates/engine/src/cmd/file.rs | 43 ++++++++++++++++++++++++++++++-- crates/ui-egui/src/home_tests.rs | 37 +++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 54348372..195d7308 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -30,7 +30,7 @@ pub fn specs() -> Vec { spec!( "file.open", "Open", - "{path, variables? (REST: the local variables JSON as an object, JSON text or a path; default `.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .svg, .png, .jpg …); REST and .fig imports return `warnings`", + "{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .svg, .png, .jpg …); REST and .fig imports return `warnings`", false, open ), @@ -653,8 +653,13 @@ fn open_recent(s: &mut Session, p: &Value) -> Result { } fn open(s: &mut Session, p: &Value) -> Result { - let path = req_string("file.open", p, "path")?; let variables = variables_param("file.open", p)?; + if string(p, "path").is_none() + && let Some(d) = string(p, "data") + { + return open_data(s, &d, p, variables.as_ref()); + } + let path = req_string("file.open", p, "path")?; // Already open: switch to it. if let Some(i) = s.docs.iter().position(|d| d.path.as_deref() == Some(path.as_str())) { s.active = i; @@ -679,6 +684,21 @@ fn open(s: &mut Session, p: &Value) -> Result { Ok(out) } +/// [`open`] for a file given as base64 `data` (a file dropped without a path on disk): `name` tells +/// the format. It opens as a new document with no path, so the first save asks where to put it. +fn open_data(s: &mut Session, data: &str, p: &Value, variables: Option<&Value>) -> Result { + let name = req_string("file.open", p, "name")?; + let data = data.split_once(',').map_or(data, |(_, b)| b); + let bytes = base64::engine::general_purpose::STANDARD.decode(data.trim()).map_err(|e| bad("file.open", e.to_string()))?; + let (loaded, rest) = load_with(&bytes, &name, variables, &figcraft_format::rest::no_images)?; + let warnings = rest || ext(&name) == "svg"; + s.docs.push(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches)); + s.active = s.docs.len() - 1; + let mut out = json!({"index": s.active}); + out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues); + Ok(out) +} + /// Thumbnail PNG of the first page's content (for file browsers / the home screen). pub fn thumbnail(doc: &Document) -> Option> { let page = doc.first_page()?; @@ -1351,6 +1371,25 @@ mod rest_import_tests { assert!(thumbnail(&doc).is_some()); } + #[test] + fn open_takes_data_and_a_name_as_a_new_unsaved_document() { + let mut saved = Session::new(); + saved.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let bytes = file_bytes(saved.ds().unwrap()).unwrap(); + let data = base64::engine::general_purpose::STANDARD.encode(&bytes); + let mut s = Session::new(); + let recent = s.prefs.recent_files.clone(); + let r = s.execute("file.open", &json!({"data": data, "name": "Dropped.figcraft"})).unwrap(); + let open = s.docs.len(); + assert_eq!(r["index"], open - 1, "a document of its own, made active"); + assert_eq!(s.ds().unwrap().path, None, "no path to save over"); + assert!(s.doc().unwrap().nodes().any(|n| matches!(n.kind, NodeKind::Rectangle))); + assert_eq!(s.prefs.recent_files, recent, "nothing on disk to remember"); + assert!(s.execute("file.open", &json!({"data": data})).is_err(), "the name tells the format"); + assert!(s.execute("file.open", &json!({"data": "not base64!", "name": "x.svg"})).is_err()); + assert_eq!(s.docs.len(), open, "a refused open adds nothing"); + } + #[test] fn open_and_import_rest_json() { let dir = std::env::temp_dir().join(format!("figcraft-rest-{}", std::process::id())); diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs index 87363656..a975bec2 100644 --- a/crates/ui-egui/src/home_tests.rs +++ b/crates/ui-egui/src/home_tests.rs @@ -380,6 +380,43 @@ fn drop_file(u: &mut Ui, path: &std::path::Path) -> Vec { hovering } +/// A file dropped with its contents but no file on disk (a virtual file from another app). +#[derive(Debug)] +struct DroppedBytes(std::path::PathBuf, Vec); + +impl egui::DroppedFile for DroppedBytes { + fn path(&self) -> &std::path::Path { + &self.0 + } + + fn bytes(&self) -> Result, String> { + Ok(self.1.clone()) + } +} + +#[test] +fn dropping_a_file_without_one_on_disk_opens_it_as_a_new_unsaved_document() { + let dir = Temp::new(); + let path = dir.path("virtual.svg"); + assert!(!path.exists()); + let svg = br#""#.to_vec(); + let mut u = first_run(); + u.app.ui.home = true; + let file: egui::DroppedFileHandle = std::sync::Arc::new(DroppedBytes(path.clone(), svg)); + u.frame_raw(egui::RawInput { dropped_files: vec![file], ..Default::default() }); + assert_eq!(u.app.ui.pending_opens.len(), 1); + for _ in 0..3 { + u.frame(vec![]); + } + assert!(u.app.ui.pending_opens.is_empty()); + assert_eq!(u.app.ui.last_error, None); + assert_eq!(u.app.session.docs.len(), 1, "the dropped contents open"); + assert_eq!(u.app.session.docs[0].path, None, "with no save path made up for them"); + assert!(u.app.session.docs[0].doc.nodes().any(|n| matches!(n.kind, figcraft_doc::NodeKind::Rectangle)), "the SVG's content"); + assert!(!u.app.ui.home, "and the editor shows it"); + assert!(!path.exists(), "nothing is written"); +} + #[test] fn dropping_a_native_file_on_the_home_screen_opens_it() { let dir = Temp::new(); From f40c600e67e12b55de12eb10ad22aad79ad22a20 Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 16 Oct 2026 07:55:52 +0700 Subject: [PATCH 16/18] Docs: roadmap rows for the community fixes from #478 up (#591, #598, #602) and #589 (#605) --- ROADMAP.md | 3 ++- docs/roadmap.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index bff59ddd..c3c12085 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-15 · **Change:** minor (bounded canvas pan overscan; files dropped anywhere on the window open from the home screen or import onto the canvas; Typography spacing icons support scrubbing (#373); system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) 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 @@ -313,6 +313,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | | 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. | | 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. | | 2026-10-15 | minor | Canvas retains a bounded margin while panning so newly exposed strips can reuse the previous render. Numbers unchanged. | diff --git a/docs/roadmap.md b/docs/roadmap.md index 12f2a5fb..9e9eaec9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **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 @@ -73,6 +73,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | | 2026-10-14 | minor | M7: the Small caps button in Type settings showed a box where the UI font has no small-capital `ɢ` (builds without Inter use egui's fonts); its sample is now a capital and a smaller capital, drawn from ordinary letters every UI font has. Numbers unchanged | | 2026-10-14 | minor | M7: Home screen recent files as cards: each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. | | 2026-10-14 | minor | Lost images reported on loading (#207): a `.figcraft` file whose image member is damaged, too large or missing no longer loads in silence; each lost image (up to 20, then a count) is listed in `repairs` on `file.open` with its hash and the reason, and the app shows it as a toast. Saving still writes the file without the lost images. Numbers unchanged | From 3489e01a226da10eee785d424fdfc638fa807754 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 15 Oct 2026 19:53:22 -0400 Subject: [PATCH 17/18] ci: delegate release, freebsd, windows-arm64, packaging-lint to craft-common-actions and add nix check --- .github/workflows/freebsd.yml | 53 +-- .github/workflows/nix.yml | 33 ++ .github/workflows/packaging-lint.yml | 57 +-- .github/workflows/release.yml | 565 ++------------------------- .github/workflows/windows-arm64.yml | 79 +--- 5 files changed, 76 insertions(+), 711 deletions(-) create mode 100644 .github/workflows/nix.yml diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index dcd0192f..04f1f8a2 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -1,18 +1,15 @@ # Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner -# (GitHub has no FreeBSD runners). -# It then runs packaging/freebsd/package.sh, the script the release job runs, and checks the -# tarball, so a broken FreeBSD package fails here before it can fail a release. Nothing is -# uploaded and no secrets are used. +# (GitHub has no FreeBSD runners). It then runs packaging/freebsd/package.sh, the script +# the release job runs, and checks the tarball, so a broken FreeBSD package fails here +# before it can fail a release. Nothing is uploaded and no secrets are used. name: FreeBSD -# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push -# or dependency change: a drifted dependency still shows up within a day, and always before a -# release ships. PRs that change the FreeBSD packaging itself still run it. +# The VM build takes one to several hours, so it runs nightly on main rather than on every push +# or dependency change: a drifted dependency still shows up within a day. PRs that change the +# FreeBSD packaging itself still run it. on: schedule: - cron: "17 2 * * *" - push: - branches: [release] pull_request: paths: - .github/workflows/freebsd.yml @@ -29,36 +26,8 @@ permissions: jobs: freebsd: - name: FreeBSD 14 (build + test + package) - runs-on: ubuntu-latest - timeout-minutes: 300 - steps: - - uses: actions/checkout@v4 - - name: Build, test and package in a FreeBSD VM - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1 - with: - release: "14.3" - usesh: true - cpu: 4 - mem: 12288 - # Nothing to bring back; copying the target dir out of the VM fails on large builds. - copyback: false - # dejavu: the text tests need one real font, as the Ubuntu runner has. - prepare: | - pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 dejavu - curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal - run: | - set -e - . "$HOME/.cargo/env" - # The checkout is synced in under another owner; let tests that call git read it. - git config --global --add safe.directory "*" - rustc --version - CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked - CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked - # The release package, as release.yml builds it. Free the debug build's disk first. - rm -rf target/debug - CARGO_BUILD_JOBS=4 bash packaging/freebsd/package.sh - tar -tzf dist/release/figcraft-*-freebsd-x86_64.tar.gz >"$HOME/freebsd-contents.txt" - for f in '/bin/figcraft$' '/share/applications/ai\.storyteller\.figcraft\.desktop$' '/share/doc/figcraft/LICENSE-MIT$'; do - grep -E "$f" "$HOME/freebsd-contents.txt" - done + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main + with: + freebsd-release: "14.3" + craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 00000000..18392587 --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,33 @@ +# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation +# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice. +name: Nix + +on: + push: + branches: [main] + paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"] + pull_request: + paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: nix-${{ github.ref }} + cancel-in-progress: true + +jobs: + eval: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 + - name: nix flake check --no-build + run: | + if [ -f flake.nix ]; then + nix flake check --no-build --all-systems + else + echo "No flake.nix present in this repository; skipping check." + fi diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 26fad875..f54b0538 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -19,58 +19,5 @@ concurrency: jobs: lint: - timeout-minutes: 15 - runs-on: ubuntu-latest - env: - ACTIONLINT_VERSION: 1.7.12 - steps: - - uses: actions/checkout@v4 - - name: Install tools - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml - curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ - | tar -xz -C "$RUNNER_TEMP" actionlint - - name: actionlint - run: | - "$RUNNER_TEMP/actionlint" -color - - name: shellcheck - run: shellcheck packaging/*.sh packaging/*/*.sh packaging/linux/AppRun - - name: Server install script (Caddyfile handling) - run: bash packaging/server/install-test.sh - - name: AppImage AppRun desktop integration - run: bash packaging/linux/apprun-test.sh - - name: PowerShell - shell: pwsh - run: | - foreach ($f in Get-ChildItem packaging/windows/*.ps1) { - $errors = $null - [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null - if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 } - Write-Output "ok $($f.Name)" - } - ./packaging/windows/check-icons.ps1 - - name: FreeBSD package layout (dry run, no build) - run: bash packaging/freebsd/package.sh --dry-run - - name: No template leftovers from other apps in package metadata - run: | - if grep -rniE 'photoshop|psd|photocraft|vectorcraft|illustrator|adobe|figma' packaging/ .github/ --exclude=packaging-lint.yml; then - echo "::error::package metadata mentions another product (see above)" - exit 1 - fi - - name: XML, desktop entry, AppStream, Flatpak manifest - run: | - xmllint --noout packaging/windows/figcraft.wxs packaging/windows/figcraft.en-us.wxl packaging/macos/*.plist* packaging/linux/*.xml* - desktop-file-validate packaging/linux/ai.storyteller.figcraft.desktop - sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.figcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.figcraft.metainfo.xml" - appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.figcraft.metainfo.xml" - python3 - <<'EOF' - import yaml - src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.figcraft.yml")) - bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.figcraft.bundle.yml")) - for m in (src, bundle): - assert m["id"] == "ai.storyteller.figcraft" and m["command"] == "figcraft", m["id"] - for key in ("runtime", "runtime-version", "sdk", "finish-args"): - assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}" - print("Flatpak manifests ok") - EOF + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ddf622d7..75f7a5fa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,11 +1,3 @@ -# Release pipeline: every push to `release` builds signed installers for every platform and -# creates or updates the draft GitHub Release "FigCraft v". See docs/releasing.md. -# -# Only the jobs that read secrets (macOS and Windows signing, and the draft release) run in the -# `release` environment (restricted to the `release` branch), which holds the signing secrets; the -# rest build on any branch, so `gh workflow run release.yml --ref ` is a real dry run. -# Every secret is optional: missing ones produce unsigned artifacts and a warning, never a failed -# build. Playbook: storytold/craftrules release/playbook.md. name: Release on: @@ -19,543 +11,34 @@ on: default: "" permissions: - contents: read + contents: write concurrency: group: release-${{ github.ref }} - # Never cancel a run mid-notarization or mid-upload; a newer push waits its turn. cancel-in-progress: false -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - RUST_BACKTRACE: 1 - FIGCRAFT_BUILD_SHA: ${{ github.sha }} - INPUT_VERSION: ${{ inputs.version }} - # craft-fonts (https://github.com/storytold/craft-fonts): shared font assets every release - # embeds (Inter and the Latin presentation fonts from fonts/latin-manifest.txt; the web build - # embeds Inter only, see crates/text/build.rs). An optional build input for everyone else. - # crates/text/build.rs needs fonts/latin-manifest.txt, so the pin must include it. Bump - # deliberately; every build job checks out this commit. - CRAFT_FONTS_REF: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 - jobs: - version: - timeout-minutes: 10 - name: version - runs-on: ubuntu-latest - # No `environment: release`: this job reads no secrets, so a branch dry run can run it. - outputs: - version: ${{ steps.v.outputs.version }} - date: ${{ steps.v.outputs.date }} - prerelease: ${{ steps.v.outputs.prerelease }} - steps: - - uses: actions/checkout@v4 - - id: v - name: Resolve version - run: | - . packaging/env.sh - v="${INPUT_VERSION#v}" - v="${v:-$VERSION}" - if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then - echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1" - exit 1 - fi - case "$v" in *-*) pre=true ;; *) pre=false ;; esac - { - echo "version=$v" - echo "date=$(date -u +%Y-%m-%d)" - echo "prerelease=$pre" - } >>"$GITHUB_OUTPUT" - echo "### FigCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY" - - macos: - timeout-minutes: 90 - name: macOS universal - needs: version - runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend) - environment: release - env: - FIGCRAFT_VERSION: ${{ needs.version.outputs.version }} - FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@v4 - - name: Check out craft-fonts (embedded fonts) - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - repository: storytold/craft-fonts - ref: ${{ env.CRAFT_FONTS_REF }} - path: craft-fonts - - uses: dtolnay/rust-toolchain@stable - with: - targets: aarch64-apple-darwin, x86_64-apple-darwin - - uses: Swatinem/rust-cache@v2 - with: - key: release-macos-universal - - name: Apply version override - if: inputs.version != '' - run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION" - - name: Import signing certificate - env: - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} - run: packaging/macos/import-cert.sh - - name: Build, sign, notarize - env: - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: packaging/macos/package.sh --arch universal - # Unpacks the CLI zip and checks the binary users get: Developer ID, hardened runtime, - # timestamp, and Gatekeeper's notarization lookup (codesign --verify, spctl). Also the DMG's - # stapled ticket. Strict when the signing secrets are present, warnings otherwise. - - name: Verify signatures and notarization - env: - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - run: packaging/macos/verify.sh --arch universal - - uses: actions/upload-artifact@v4 - with: - name: release-macos - path: dist/release/* - if-no-files-found: error - - name: Remove temporary keychain - if: always() - run: | - if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi - - windows: - timeout-minutes: 90 - name: Windows ${{ matrix.arch }} - needs: version - runs-on: windows-latest - environment: release - strategy: - fail-fast: false - matrix: - include: - - { arch: x64, target: x86_64-pc-windows-msvc } - - { arch: x86, target: i686-pc-windows-msvc } - # Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above. - # .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware. - - { arch: arm64, target: aarch64-pc-windows-msvc } - env: - FIGCRAFT_VERSION: ${{ needs.version.outputs.version }} - FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@v4 - - name: Check out craft-fonts (embedded fonts) - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - repository: storytold/craft-fonts - ref: ${{ env.CRAFT_FONTS_REF }} - path: craft-fonts - - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.target }} - - uses: Swatinem/rust-cache@v2 - with: - key: release-windows-${{ matrix.arch }} - - name: Apply version override - if: inputs.version != '' - shell: bash - run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION" - - name: Install WiX - shell: pwsh - run: | - dotnet tool install --global wix --version 5.0.2 - wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2 - - name: Build, sign, package - shell: pwsh - env: - WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} - WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} - AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} - AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} - - uses: actions/upload-artifact@v4 - with: - name: release-windows-${{ matrix.arch }} - path: dist/release/* - if-no-files-found: error - - linux: - timeout-minutes: 90 - name: Linux ${{ matrix.arch }} - needs: version - # The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+, - # Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md. - runs-on: ${{ matrix.runner }} - # No `environment: release`: this job reads no secrets, so a branch dry run can run it. - strategy: - fail-fast: false - matrix: - include: - - { arch: x86_64, runner: ubuntu-22.04, deb: amd64 } - - { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 } - env: - FIGCRAFT_VERSION: ${{ needs.version.outputs.version }} - FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - NFPM_VERSION: 2.47.0 - steps: - - uses: actions/checkout@v4 - - name: Check out craft-fonts (embedded fonts) - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - repository: storytold/craft-fonts - ref: ${{ env.CRAFT_FONTS_REF }} - path: craft-fonts - - name: Install build and packaging tools - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \ - desktop-file-utils appstream python3-yaml zsync - curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \ - "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb" - sudo dpkg -i "$RUNNER_TEMP/nfpm.deb" - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - with: - key: release-linux-${{ matrix.arch }} - - name: Apply version override - if: inputs.version != '' - run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION" - - name: Build and package (AppImage, deb, rpm, tar.gz) - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: packaging/linux/package.sh - - name: Check the packages - run: | - dpkg-deb --info dist/release/*.deb - dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/figcraft$|metainfo|applications' - ldd target/release/figcraft - python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.figcraft.yml')); assert m['id'] == 'ai.storyteller.figcraft'" - for appimage in dist/release/*.AppImage; do - chmod +x "$appimage" - APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version - # Update information is embedded, and the .zsync for delta updates sits beside it. - "$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|' - test -s "$appimage.zsync" - done - - uses: actions/upload-artifact@v4 - with: - name: release-linux-${{ matrix.arch }} - path: dist/release/* - if-no-files-found: error - - server: - timeout-minutes: 90 - name: Server Linux ${{ matrix.arch }} - needs: version - # The headless collaboration server (docs/server.md): figcraft-server--linux-.tar.gz - # with the binary, licences, docs/server.md and the deployment examples. Same runners as the - # app, so the same glibc floor; no craft-fonts (the server draws no text). - # shortcut: Linux only (where servers run); add macOS/Windows archives, signed like figcraft-cli, - # when people host there. - runs-on: ${{ matrix.runner }} - # No `environment: release`: this job reads no secrets, so a branch dry run can run it. - strategy: - fail-fast: false - matrix: - include: - - { arch: x86_64, runner: ubuntu-22.04 } - - { arch: aarch64, runner: ubuntu-22.04-arm } - env: - FIGCRAFT_VERSION: ${{ needs.version.outputs.version }} - FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - with: - key: release-server-${{ matrix.arch }} - - name: Apply version override - if: inputs.version != '' - run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION" - - name: Build and package (tar.gz) - run: packaging/server/package.sh - - name: Check the archive - run: | - tar -tzf "dist/release/figcraft-server-$FIGCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" | tee "$RUNNER_TEMP/server-contents.txt" - for f in '/figcraft-server$' '/figcraft-server\.service$' '/Caddyfile$' '/nginx\.conf$' '/install\.sh$' '/figcraft-backup\.timer$' '/server\.md$' '/LICENSE-MIT$'; do - grep -E "$f" "$RUNNER_TEMP/server-contents.txt" - done - ldd target/release/figcraft-server - - uses: actions/upload-artifact@v4 - with: - name: release-server-linux-${{ matrix.arch }} - path: dist/release/*.tar.gz - if-no-files-found: error - - flatpak: - timeout-minutes: 90 - name: Flatpak ${{ matrix.arch }} - needs: [version, linux] - # Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust - # build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships. - runs-on: ${{ matrix.runner }} - # No `environment: release`: this job signs nothing, so it gets no release secrets. - strategy: - fail-fast: false - matrix: - include: - - { arch: x86_64, runner: ubuntu-24.04 } - - { arch: aarch64, runner: ubuntu-24.04-arm } - env: - FIGCRAFT_VERSION: ${{ needs.version.outputs.version }} - FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 - with: - name: release-linux-${{ matrix.arch }} - path: dist/linux - - name: Install flatpak-builder - run: | - sudo apt-get update - # librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's - # `appstreamcli compose`, which renders the scalable icon and fails without it. - sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common - # Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs. - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - - name: Build the Flatpak bundle - run: packaging/linux/flatpak-bundle.sh "dist/linux/figcraft-$FIGCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" - - uses: actions/upload-artifact@v4 - with: - name: release-flatpak-${{ matrix.arch }} - path: dist/release/*.flatpak - if-no-files-found: error - - freebsd: - name: FreeBSD x86_64 - needs: version - # GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM, the same image and packages as - # .github/workflows/freebsd.yml. A private repo's ubuntu-latest has 2 vCPUs and 7 GB, too small - # for this VM (QEMU "cannot set up guest memory"), so it runs on the org's 16-core larger runner. - runs-on: github-ubuntu-16core - timeout-minutes: 180 - # No `environment: release`: this job signs nothing, so it gets no release secrets. - env: - FIGCRAFT_VERSION: ${{ needs.version.outputs.version }} - FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@v4 - - name: Check out craft-fonts (embedded fonts) - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - repository: storytold/craft-fonts - ref: ${{ env.CRAFT_FONTS_REF }} - path: craft-fonts - - name: Build and package in a FreeBSD VM (tar.gz) - # vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build). - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc - with: - release: "14.3" - usesh: true - cpu: 4 - mem: 12288 - # Passed into the VM by name; never templated into the script below. - envs: "FIGCRAFT_VERSION FIGCRAFT_BUILD_DATE FIGCRAFT_BUILD_SHA INPUT_VERSION" - # The FreeBSD CI packages, plus bash for the packaging script. - prepare: | - pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable - run: | - set -e - . "$HOME/.cargo/env" - git config --global --add safe.directory "$PWD" - # Build outside the checkout so only dist/ is copied back out of the VM (copying the - # target dir back fails on large builds). 4 jobs: more runs the VM out of memory. - export CARGO_TARGET_DIR=/var/tmp/figcraft-target CARGO_BUILD_JOBS=4 - if [ -n "$INPUT_VERSION" ]; then cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION"; fi - # craft-fonts was checked out next to the sources and synced into the VM with them. - export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1 - bash packaging/freebsd/package.sh - tar -tzf dist/release/figcraft-*-freebsd-x86_64.tar.gz >"$HOME/freebsd-contents.txt" - for f in '/bin/figcraft$' '/share/applications/ai\.storyteller\.figcraft\.desktop$' '/share/doc/figcraft/LICENSE-MIT$'; do - grep -E "$f" "$HOME/freebsd-contents.txt" - done - - uses: actions/upload-artifact@v4 - with: - name: release-freebsd-x86_64 - path: dist/release/* - if-no-files-found: error - - web: - timeout-minutes: 90 - name: Web (wasm) - needs: version - runs-on: ubuntu-latest - # No `environment: release`: this job reads no secrets, so a branch dry run can run it. - env: - FIGCRAFT_VERSION: ${{ needs.version.outputs.version }} - FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - TRUNK_VERSION: 0.21.14 - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@v2 - with: - key: release-web - - name: Install trunk - run: | - curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \ - | tar -xz -C "$HOME/.cargo/bin" trunk - trunk --version - - name: Apply version override - if: inputs.version != '' - run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION" - # The wasm32 build embeds only Inter from craft-fonts (crates/text/build.rs): browsers have - # no system fonts to fall back on, and the rest would push the wasm towards - # packaging/web/package.sh's 24 MiB gate. - - name: Check out craft-fonts (embedded fonts) - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - repository: storytold/craft-fonts - ref: ${{ env.CRAFT_FONTS_REF }} - path: craft-fonts - - name: Build and zip - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: packaging/web/package.sh - - uses: actions/upload-artifact@v4 - with: - name: release-web - path: dist/release/* - if-no-files-found: error - - linux-riscv64: - timeout-minutes: 90 - name: Linux riscv64 - needs: version - # Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release - # from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's - # riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the - # CLI is smoke-tested under QEMU. - runs-on: ubuntu-24.04 - # No `environment: release`: this job reads no secrets, so a branch dry run can run it. - env: - FIGCRAFT_VERSION: ${{ needs.version.outputs.version }} - FIGCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@v4 - - name: Check out craft-fonts (embedded fonts) - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - with: - repository: storytold/craft-fonts - ref: ${{ env.CRAFT_FONTS_REF }} - path: craft-fonts - - uses: dtolnay/rust-toolchain@stable - with: - targets: riscv64gc-unknown-linux-gnu - - uses: Swatinem/rust-cache@v2 - with: - key: release-linux-riscv64 - - name: Apply version override - if: inputs.version != '' - run: cargo run --locked -q -p xtask -- version set "$FIGCRAFT_VERSION" - - name: Install cross-compilation dependencies - run: | - . /etc/os-release - sudo dpkg --add-architecture riscv64 - # Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports. - if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then - sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources - fi - if [ -f /etc/apt/sources.list ]; then - sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list - sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list - fi - for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do - echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse" - done | sudo tee /etc/apt/sources.list.d/riscv64.list - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \ - libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \ - libxi-dev:riscv64 libgl1-mesa-dev:riscv64 - - name: Build and package (tar.gz) - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc - PKG_CONFIG_SYSROOT_DIR: / - PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig - PKG_CONFIG_ALLOW_CROSS: "1" - CROSS_ARCH: riscv64 - CROSS_TARGET: riscv64gc-unknown-linux-gnu - CROSS_COMPILE: riscv64-linux-gnu- - EMULATOR: qemu-riscv64 - QEMU_LD_PREFIX: /usr/riscv64-linux-gnu - run: packaging/linux/package.sh --formats tar - - uses: actions/upload-artifact@v4 - with: - name: release-linux-riscv64 - path: dist/release/*.tar.gz - if-no-files-found: error - release: - timeout-minutes: 10 - name: Draft GitHub Release - needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web, server] - runs-on: ubuntu-latest - environment: release - permissions: - contents: write - env: - VERSION: ${{ needs.version.outputs.version }} - PRERELEASE: ${{ needs.version.outputs.prerelease }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_REPO: ${{ github.repository }} - steps: - - uses: actions/download-artifact@v4 - with: - pattern: release-* - path: artifacts - merge-multiple: true - - name: Checksums - working-directory: artifacts - run: | - sha256sum -- * | tee SHA256SUMS.txt - - name: Create or update the draft release - working-directory: artifacts - run: | - tag="v$VERSION" - title="FigCraft v$VERSION" - pre=() - if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi - if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then - if [ "$draft" != "true" ]; then - echo "::error::Release $tag is already published. Bump the version (cargo run -p xtask -- version set X.Y.Z) before pushing to release." - exit 1 - fi - echo "Updating draft $tag: replacing its assets" - gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do - gh release delete-asset "$tag" "$asset" --yes - done - gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE" - gh release upload "$tag" --clobber -- * - else - echo "Creating draft $tag" - gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- * - fi - { - echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:" - echo - sed 's/^/ /' SHA256SUMS.txt - } >>"$GITHUB_STEP_SUMMARY" + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/release.yml@main + with: + app-name: figcraft + app-title: FigCraft + craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 + has-server: true + version: ${{ inputs.version }} + secrets: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index a289df72..4cceb922 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -24,77 +24,10 @@ concurrency: permissions: contents: read -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - jobs: - package: - name: package (cross-compiled on x64) - runs-on: windows-latest - timeout-minutes: 90 - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable - with: - targets: aarch64-pc-windows-msvc - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - with: - key: windows-arm64-package - - name: Install WiX - shell: pwsh - run: | - dotnet tool install --global wix --version 5.0.2 - wix extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2 - - name: Build and package (unsigned) - shell: pwsh - env: - DIST: ${{ github.workspace }}\dist\arm64 - run: ./packaging/windows/package.ps1 -Arch arm64 - - uses: actions/upload-artifact@v4 - with: - name: figcraft-windows-arm64 - path: dist/arm64/* - if-no-files-found: error - retention-days: 7 - - install: - name: install and run on ARM64 - needs: package - runs-on: windows-11-arm - timeout-minutes: 30 - steps: - - uses: actions/download-artifact@v4 - with: - name: figcraft-windows-arm64 - path: dist - - name: Install the MSI, check it, run it, uninstall it - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName - $p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru - if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" } - $dir = Join-Path $env:ProgramFiles 'FigCraft' - # figcraft-cli.exe ships once apps/figcraft-cli exists; check it when installed. - foreach ($exe in 'figcraft.exe', 'figcraft-cli.exe') { - $path = Join-Path $dir $exe - if (-not (Test-Path $path)) { - if ($exe -eq 'figcraft-cli.exe') { Write-Output "skip $exe (not in this build)"; continue } - throw "$exe not installed in $dir" - } - $bytes = [System.IO.File]::ReadAllBytes($path) - $machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4) - if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" } - Write-Output "ok $path (ARM64)" - } - # The installed binary runs natively on ARM64 (the CLI when present, else the app's --version). - $cli = Join-Path $dir 'figcraft-cli.exe' - if (-not (Test-Path $cli)) { $cli = Join-Path $dir 'figcraft.exe' } - $out = & $cli --version 2>&1 | Out-String - if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "$cli --version exited $LASTEXITCODE" } - Write-Output "ok $(Split-Path -Leaf $cli) --version: $($out.Trim())" - $p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru - if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" } - if (Test-Path (Join-Path $dir 'figcraft.exe')) { throw 'uninstall left figcraft.exe behind' } - Write-Output 'ok uninstalled' + windows-arm64: + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main + with: + app-name: figcraft + app-dir: FigCraft From d9584410af9f93cb0325ed84fbfdf173ac4f037d Mon Sep 17 00:00:00 2001 From: skylartaylor <26588250+skylartaylor@users.noreply.github.com> Date: Fri, 16 Oct 2026 05:24:23 +0200 Subject: [PATCH 18/18] ci: retain FigCraft package and metadata checks --- .github/workflows/freebsd.yml | 3 +++ .github/workflows/packaging-lint.yml | 2 ++ packaging/check-app-specific.sh | 24 ++++++++++++++++++++++++ 3 files changed, 29 insertions(+) create mode 100644 packaging/check-app-specific.sh diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 04f1f8a2..ca0cd49f 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -10,6 +10,8 @@ name: FreeBSD on: schedule: - cron: "17 2 * * *" + push: + branches: [release] pull_request: paths: - .github/workflows/freebsd.yml @@ -30,4 +32,5 @@ jobs: uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main with: freebsd-release: "14.3" + run-package-check: true craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index f54b0538..38698902 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -21,3 +21,5 @@ jobs: lint: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main + with: + extra-checks-script: packaging/check-app-specific.sh diff --git a/packaging/check-app-specific.sh b/packaging/check-app-specific.sh new file mode 100644 index 00000000..ebf23c0b --- /dev/null +++ b/packaging/check-app-specific.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# App-specific checks retained alongside shared packaging lint. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +bash packaging/server/install-test.sh +if grep -rniE 'photoshop|psd|photocraft|vectorcraft|illustrator|adobe|figma' packaging/ .github/ \ + --exclude=packaging-lint.yml --exclude=check-app-specific.sh; then + echo "::error::package metadata mentions another product (see above)" + exit 1 +fi + +python3 - <<'PYEOF' +import yaml +manifests = [] +for name in ("ai.storyteller.figcraft.yml", "ai.storyteller.figcraft.bundle.yml"): + with open("packaging/linux/flatpak/" + name) as source: + manifest = yaml.safe_load(source) + assert manifest["id"] == "ai.storyteller.figcraft" and manifest["command"] == "figcraft", name + manifests.append(manifest) +for key in ("runtime", "runtime-version", "sdk", "finish-args"): + assert manifests[0][key] == manifests[1][key], f"Flatpak manifests disagree on {key}" +print("FigCraft package identity ok") +PYEOF