diff --git a/ROADMAP.md b/ROADMAP.md index 389d8c41d..100693e3a 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 (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-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 | 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/doc/src/lib.rs b/crates/doc/src/lib.rs index 513800e37..71c6da393 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/doc/src/tests.rs b/crates/doc/src/tests.rs index 430314388..a5ead3b7d 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"); @@ -382,6 +403,46 @@ fn custom_easings() { 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()); +} + #[test] fn text_style_empty_range_links_and_unlinks_all() { let def = TextStyle { font_style: "Bold".into(), ..Default::default() }; diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 3ddf65594..b294151bb 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`). @@ -602,7 +610,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 } diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 61b0f8b4c..2150701a9 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 664d1ec35..2c767b933 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1003,7 +1003,8 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { if find.is_empty() { return Err(bad("edit.findReplace", "`find` is empty")); } - let replace = string(p, "replace"); + // Line breaks stored as the editor stores them (CRLF, CR, U+2028 and U+2029 become LF). + let replace = string(p, "replace").map(|r| super::props::normalize_line_endings(&r)); let names = boolean(p, "layerNames").unwrap_or(false); let page = s.page()?; let doc = s.doc_mut()?; diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index 52531aa30..08e65b0b8 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -97,11 +97,13 @@ pub fn specs() -> Vec { let doc = s.doc_mut()?; let name = format!("Copy of {}", doc.get(src)?.name); let np = doc.add_page(&name); - // Each layer and its copy (also those in sections), so flows can follow their start frames. + // One copy with one id map, so navigation between the copied frames points at the copies; + // then each layer and its copy (also those in sections), so flows can follow their start frames. + let children = doc.get(src)?.children.clone(); + let copied = doc.duplicate_all_into(&children, np)?; let mut copies = Vec::new(); - for c in doc.get(src)?.children.clone() { - let copy = doc.duplicate_into(c, np, None)?; - copies.extend(doc.subtree(c).into_iter().zip(doc.subtree(copy))); + for (c, copy) in children.iter().zip(&copied) { + copies.extend(doc.subtree(*c).into_iter().zip(doc.subtree(*copy))); } // The copy keeps the page's colour, flows (pointing at the copied frames), prototype settings and guides. if let NodeKind::Page { background, flows, prototype, guides } = doc.get(src)?.kind.clone() diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index dd2daa82a..598be752f 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 a462fc0a7..34653bd5f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1562,6 +1562,8 @@ mod library_range_styles_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; @@ -1588,6 +1590,8 @@ mod open_issues_tests; #[cfg(test)] mod outline_options_tests; #[cfg(test)] +mod page_copy_navigation_tests; +#[cfg(test)] mod page_target_tests; #[cfg(test)] mod paint_reorder_tests; diff --git a/crates/engine/src/line_endings_tests.rs b/crates/engine/src/line_endings_tests.rs new file mode 100644 index 000000000..160707a6c --- /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/page_copy_navigation_tests.rs b/crates/engine/src/page_copy_navigation_tests.rs new file mode 100644 index 000000000..ddbc956a4 --- /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); +} diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index 5e5762b53..76b353a00 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -83,12 +83,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(()); } diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index 526ab3d9a..bfd0b6155 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -1552,6 +1552,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 1e3655673..66a00129f 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -1762,7 +1762,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 7b97d2153..c5381bb2e 100644 --- a/crates/format/src/rest/text.rs +++ b/crates/format/src/rest/text.rs @@ -130,6 +130,8 @@ fn type_style(cx: &mut Ctx, s: &Value, base: &TextStyle) -> TextStyle { // and clipping a URL changed it into a different, working-looking one without a word // (#459). The FIG importer already keeps it whole. (Some("URL") | None, Some(url)) if !url.is_empty() => t.link = Some(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 000000000..f47e22ae8 --- /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); +} diff --git a/crates/text/src/behaviour_tests.rs b/crates/text/src/behaviour_tests.rs index a5400b62b..b3cb4884d 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 cd9d53597..d5ff41907 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -405,6 +405,28 @@ 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; 18] = [ + "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", + "Noto Sans Arabic", + "Apple Symbols", + "Segoe UI Symbol", +]; + /// A system font file entry not yet loaded. #[derive(Clone, Debug)] struct Pending { @@ -614,24 +636,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", - "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); } } @@ -734,6 +747,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"); +} + +#[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); + } +} + #[cfg(all(unix, not(target_os = "macos")))] #[test] fn user_font_dirs_follow_xdg_data_home() { diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 836fe6a72..37092da86 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -325,8 +325,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 mut 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)); } // Colour emoji are their own, large faces (Apple Color Emoji is ~180 MB): they are read only // for a text with emoji, or with characters nothing above covers. @@ -371,6 +373,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(' '); diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index 57a5cf3ce..b78c7474c 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -311,6 +311,26 @@ fn an_animated_gif_wakes_only_the_presenter_and_only_at_its_frames() { 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); +} + #[test] fn a_hidden_window_answers_requests_that_need_no_frame() { // #269: minimised or covered, the window draws no frames, but the app's logic still runs: it diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 936c32a9b..8a1e81b8b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -330,6 +330,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; + /// With auto-save on: how long after a file last changed it is saved, and how long a failed save /// waits before trying again (seconds). pub const AUTO_SAVE_SECS: f64 = 3.0; @@ -601,8 +604,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. @@ -1066,11 +1069,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 }