From 47f1315ec2824f16c3449e23002f5b029eca4f3d Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:02:49 +0000 Subject: [PATCH 01/67] test: scan source directories recursively --- crates/ui-egui/src/i18n_tests.rs | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/i18n_tests.rs b/crates/ui-egui/src/i18n_tests.rs index 5b4da85e..d1fedef8 100644 --- a/crates/ui-egui/src/i18n_tests.rs +++ b/crates/ui-egui/src/i18n_tests.rs @@ -157,10 +157,9 @@ fn menu_worded_differently() -> BTreeSet<&'static str> { fn used_keys() -> BTreeSet { let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); let mut keys = BTreeSet::new(); - for entry in std::fs::read_dir(dir).unwrap().flatten() { - let path = entry.path(); + for path in sources(&dir) { let name = path.file_name().unwrap().to_string_lossy().into_owned(); - if !name.ends_with(".rs") || name.ends_with("_tests.rs") || name == "i18n.rs" { + if name.ends_with("_tests.rs") || name == "i18n.rs" { continue; } keys.extend(literals(&std::fs::read_to_string(&path).unwrap(), LOOKUPS)); @@ -168,6 +167,24 @@ fn used_keys() -> BTreeSet { keys } +/// Every `.rs` file under `dir`, in subdirectories too, leaving out test code: `tests.rs` and +/// anything under a `tests` directory. +fn sources(dir: &std::path::Path) -> Vec { + let mut out = Vec::new(); + for entry in std::fs::read_dir(dir).unwrap().flatten() { + let path = entry.path(); + let name = path.file_name().unwrap().to_string_lossy().into_owned(); + if path.is_dir() { + if name != "tests" { + out.extend(sources(&path)); + } + } else if name.ends_with(".rs") && name != "tests.rs" { + out.push(path); + } + } + out +} + /// The importers' fixed notes: the string literals passed to `warn` in the `.fig` and REST import /// sources. The import report shows a note translated when the catalog has it, so each one is a key /// `en.toml` (and every complete language) must have, and an entry for a note the importers no @@ -176,11 +193,12 @@ fn importer_notes() -> BTreeSet { let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../format/src"); let mut keys = BTreeSet::new(); for dir in ["fig", "rest"] { - for entry in std::fs::read_dir(src.join(dir)).unwrap().flatten() { - let path = entry.path(); + // The module root sits beside its directory (`fig.rs` + `fig/`) or inside it (`fig/mod.rs`). + let root = src.join(format!("{dir}.rs")); + for path in sources(&src.join(dir)).into_iter().chain(root.exists().then_some(root)) { let name = path.file_name().unwrap().to_string_lossy().into_owned(); - // export.rs warns about what a `.fig` export leaves out, not about an import. - if !name.ends_with(".rs") || name.contains("tests") || name == "export.rs" { + // export.rs and export/ warn about what a `.fig` export leaves out, not about an import. + if name.contains("tests") || name == "export.rs" || path.starts_with(src.join("fig/export")) { continue; } keys.extend(literals(&std::fs::read_to_string(&path).unwrap(), &["warn("])); From d853eee82039b9f21edd0867f8b3b25eae4ad16d Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:08 +0000 Subject: [PATCH 02/67] refactor(engine): move crates/engine/src/cmd/mod.rs to crates/engine/src/cmd.rs --- crates/engine/src/{cmd/mod.rs => cmd.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/engine/src/{cmd/mod.rs => cmd.rs} (100%) diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd.rs similarity index 100% rename from crates/engine/src/cmd/mod.rs rename to crates/engine/src/cmd.rs From 71524803d11d24dc81358558685de8cc2dffb771 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:08 +0000 Subject: [PATCH 03/67] refactor(engine): move crates/engine/src/templates/mod.rs to crates/engine/src/templates.rs --- crates/engine/src/{templates/mod.rs => templates.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/engine/src/{templates/mod.rs => templates.rs} (100%) diff --git a/crates/engine/src/templates/mod.rs b/crates/engine/src/templates.rs similarity index 100% rename from crates/engine/src/templates/mod.rs rename to crates/engine/src/templates.rs From a99248b2965556ba178b831643342c74391704d2 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:08 +0000 Subject: [PATCH 04/67] refactor(svg): move crates/svg/src/import/mod.rs to crates/svg/src/import.rs --- crates/svg/src/{import/mod.rs => import.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/svg/src/{import/mod.rs => import.rs} (100%) diff --git a/crates/svg/src/import/mod.rs b/crates/svg/src/import.rs similarity index 100% rename from crates/svg/src/import/mod.rs rename to crates/svg/src/import.rs From 47ee566f3d61156c86b13ba5c7df2694a220d03a Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:09 +0000 Subject: [PATCH 05/67] refactor(format): move crates/format/src/fig/mod.rs to crates/format/src/fig.rs --- crates/format/src/{fig/mod.rs => fig.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/format/src/{fig/mod.rs => fig.rs} (100%) diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig.rs similarity index 100% rename from crates/format/src/fig/mod.rs rename to crates/format/src/fig.rs From 8d4a4497699533d815c88050fef72e7bcab0c0e1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:09 +0000 Subject: [PATCH 06/67] refactor(format): move crates/format/src/rest/mod.rs to crates/format/src/rest.rs --- crates/format/src/{rest/mod.rs => rest.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/format/src/{rest/mod.rs => rest.rs} (100%) diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest.rs similarity index 100% rename from crates/format/src/rest/mod.rs rename to crates/format/src/rest.rs From e9fb6a533e6be86a2f356f96f3cd3f705901e5db Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:09 +0000 Subject: [PATCH 07/67] refactor(format): move crates/format/src/sketch/mod.rs to crates/format/src/sketch.rs --- crates/format/src/{sketch/mod.rs => sketch.rs} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename crates/format/src/{sketch/mod.rs => sketch.rs} (100%) diff --git a/crates/format/src/sketch/mod.rs b/crates/format/src/sketch.rs similarity index 100% rename from crates/format/src/sketch/mod.rs rename to crates/format/src/sketch.rs From a3179236f33895a3c11495678fcde124360f3eb5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:14 +0000 Subject: [PATCH 08/67] refactor(engine): move textedit.rs inline tests to crates/engine/src/textedit/tests.rs --- crates/engine/src/textedit.rs | 574 +--------------------------- crates/engine/src/textedit/tests.rs | 571 +++++++++++++++++++++++++++ 2 files changed, 572 insertions(+), 573 deletions(-) create mode 100644 crates/engine/src/textedit/tests.rs diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index 2cd9bf97..91e411e5 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -796,576 +796,4 @@ pub fn selected_range(s: &Session) -> Option<(NodeId, usize, usize)> { } #[cfg(test)] -mod tests { - use super::*; - use crate::tools::{PointerEvent, PointerKind}; - use figcraft_doc::{ListStyle, TextAutoResize, TextCase, TextDecoration}; - use serde_json::json; - - fn setup(text: &str) -> (Session, NodeId) { - let mut s = Session::new(); - let r = s.execute("text.create", &json!({"text": text, "x": 0, "y": 0, "props": {"fontSize": 24}})).unwrap(); - let id = crate::params::id_of(&r["id"]).unwrap(); - (s, id) - } - - fn data(s: &Session, id: NodeId) -> TextData { - s.doc().unwrap().node(id).unwrap().kind.text().unwrap().clone() - } - - fn sel(s: &Session) -> (usize, usize) { - let te = s.ds().unwrap().text_edit.clone().unwrap(); - (te.anchor, te.caret) - } - - #[test] - fn whole_editing_session_is_one_undo_step() { - let (mut s, id) = setup("Hello"); - let undo_before = s.ds().unwrap().history.undo.len(); - begin(&mut s, id, Some(5), false).unwrap(); - insert(&mut s, " big").unwrap(); - insert(&mut s, " world").unwrap(); - key(&mut s, "Backspace", false, false, false).unwrap(); - // Styling a range mid-session joins the same step. - set_caret(&mut s, 0, false).unwrap(); - set_caret(&mut s, 5, true).unwrap(); - s.execute("text.bold", &json!({})).unwrap(); - s.execute("text.alignCenter", &json!({})).unwrap(); - end(&mut s).unwrap(); - let t = data(&s, id); - assert_eq!(t.characters, "Hello big worl"); - assert_eq!(t.style_at(0).font_style, "Bold"); - assert_eq!(t.style_at(6).font_style, "Regular"); - assert_eq!(s.ds().unwrap().history.undo.len(), undo_before + 1); - s.undo().unwrap(); - let t = data(&s, id); - assert_eq!(t.characters, "Hello"); - assert!(t.runs.is_empty()); - assert_eq!(t.align_h, figcraft_doc::TextAlignH::Left); - } - - #[test] - fn typing_replaces_selection_and_paste_is_plain() { - let (mut s, id) = setup("Hello world"); - begin(&mut s, id, None, false).unwrap(); - assert_eq!(sel(&s), (0, 11), "begin without caret selects all"); - select_word(&mut s, 7).unwrap(); - assert_eq!(sel(&s), (6, 11)); - insert(&mut s, "there").unwrap(); - assert_eq!(data(&s, id).characters, "Hello there"); - assert_eq!(sel(&s), (11, 11)); - // Pasted text: CRLF / CR / Unicode separators become newlines, other controls are dropped. - insert(&mut s, "\r\na\rb\u{2029}c\u{0}\u{7}\td").unwrap(); - assert_eq!(data(&s, id).characters, "Hello there\na\nb\nc\td"); - insert(&mut s, "\u{1}").unwrap(); - assert_eq!(data(&s, id).characters, "Hello there\na\nb\nc\td"); - // Select the line (paragraph) then type over it. - select_line(&mut s, 0).unwrap(); - assert_eq!(sel(&s), (0, 11)); - insert(&mut s, "Hi").unwrap(); - assert_eq!(data(&s, id).characters, "Hi\na\nb\nc\td"); - assert_eq!(selected_text(&s).as_deref(), Some("")); - end(&mut s).unwrap(); - } - - #[test] - fn word_selection_classes() { - let (mut s, id) = setup("foo, bar_baz!\nnext"); - begin(&mut s, id, Some(0), false).unwrap(); - select_word(&mut s, 1).unwrap(); - assert_eq!(sel(&s), (0, 3)); - // Right after a word's last char selects the word. - select_word(&mut s, 13).unwrap(); - assert_eq!(sel(&s), (6, 13)); - // A run of spaces. - select_word(&mut s, 4).unwrap(); - assert_eq!(sel(&s), (4, 6)); - select_line(&mut s, 16).unwrap(); - assert_eq!(sel(&s), (15, 19)); - select_line(&mut s, 999).unwrap(); - assert_eq!(sel(&s), (15, 19)); - select_word(&mut s, 999).unwrap(); - assert_eq!(sel(&s), (15, 19)); - set_caret(&mut s, 999, false).unwrap(); - assert_eq!(sel(&s), (19, 19), "carets clamp to the text"); - } - - #[test] - fn keyboard_navigation_and_extension() { - let (mut s, id) = setup("one two\nthree"); - begin(&mut s, id, Some(0), false).unwrap(); - key(&mut s, "Right", false, true, false).unwrap(); // Alt+Right: end of word - assert_eq!(sel(&s), (3, 3)); - key(&mut s, "Right", true, true, false).unwrap(); // Shift+Alt+Right extends - assert_eq!(sel(&s), (3, 7)); - key(&mut s, "Left", false, false, false).unwrap(); // collapses to the start - assert_eq!(sel(&s), (3, 3)); - key(&mut s, "Right", false, false, true).unwrap(); // Cmd+Right: line end - assert_eq!(sel(&s), (7, 7)); - key(&mut s, "Down", true, false, true).unwrap(); // Shift+Cmd+Down: to the end - assert_eq!(sel(&s), (7, 13)); - key(&mut s, "Left", false, false, true).unwrap(); // Cmd+Left from the collapsed start - assert_eq!(sel(&s), (0, 0)); - key(&mut s, "Delete", false, true, false).unwrap(); // Alt+Delete: word forward - assert_eq!(data(&s, id).characters, " two\nthree"); - key(&mut s, "SelectAll", false, false, false).unwrap(); - assert_eq!(sel(&s), (0, 10)); - end(&mut s).unwrap(); - } - - #[test] - fn backspace_removes_only_the_final_thai_mark() { - let (mut s, id) = setup("น้ำ"); - begin(&mut s, id, Some(3), false).unwrap(); - - key(&mut s, "Backspace", false, false, false).unwrap(); - - assert_eq!(data(&s, id).characters, "น้"); - } - - fn ev(kind: PointerKind, x: f64, y: f64, shift: bool) -> PointerEvent { - let mut e = PointerEvent::at(kind, x, y, 1.0); - e.shift = shift; - e - } - - /// Asserts double-clicking a text layer selects all text, a second double-click selects the - /// word without changing the editing session, and drag and Shift+click extend the - /// selection. - #[test] - fn pointer_double_click_drag_and_shift_click() { - let (mut s, id) = setup("Hello world"); - let Some((l, _)) = figcraft_text::layout_node(s.doc().unwrap().node(id).unwrap()) else { return }; - if l.glyphs.is_empty() { - return; // no fonts - } - let y = l.lines[0].top + l.lines[0].height / 2.0; - let x_of = |i: usize| l.caret(i).0; - // Double-click inside "world" edits with all the text selected; another one there - // selects the word. - let mid = (x_of(7) + x_of(8)) / 2.0; - s.pointer(&ev(PointerKind::Down, mid, y, false)).unwrap(); - s.pointer(&ev(PointerKind::Up, mid, y, false)).unwrap(); - s.pointer(&ev(PointerKind::DoubleClick, mid, y, false)).unwrap(); - assert_eq!(sel(&s), (0, 11)); - let epoch = s.ds().unwrap().text_edit.as_ref().unwrap().editing_session; - s.pointer(&ev(PointerKind::Down, mid, y, false)).unwrap(); - s.pointer(&ev(PointerKind::DoubleClick, mid, y, false)).unwrap(); - s.pointer(&ev(PointerKind::Up, mid, y, false)).unwrap(); - assert_eq!(sel(&s), (6, 11)); - assert_eq!( - s.ds().unwrap().text_edit.as_ref().unwrap().editing_session, - epoch, - "a double-click inside the edit is an ordinary edit: same IME replacement epoch" - ); - // Drag-select from the start of "Hello" to the middle. - s.pointer(&ev(PointerKind::Down, x_of(0) + 0.5, y, false)).unwrap(); - assert_eq!(sel(&s), (0, 0)); - s.pointer(&ev(PointerKind::Move, (x_of(3) + x_of(4)) / 2.0 - 1.0, y, false)).unwrap(); - s.pointer(&ev(PointerKind::Up, (x_of(3) + x_of(4)) / 2.0 - 1.0, y, false)).unwrap(); - assert_eq!(sel(&s), (0, 3)); - // Shift+click extends. - s.pointer(&ev(PointerKind::Down, x_of(9) + 0.5, y, true)).unwrap(); - s.pointer(&ev(PointerKind::Up, x_of(9) + 0.5, y, true)).unwrap(); - assert_eq!(sel(&s), (0, 9)); - insert(&mut s, "J").unwrap(); - assert_eq!(data(&s, id).characters, "Jld"); - } - - #[test] - fn text_commands_apply_to_edited_range() { - let (mut s, id) = setup("Hello world"); - begin(&mut s, id, Some(0), false).unwrap(); - set_caret(&mut s, 6, true).unwrap(); // "Hello " - for (cmd, p) in [ - ("text.underline", json!({})), - ("text.italic", json!({})), - ("text.sizeUp", json!({})), - ("text.trackingUp", json!({})), - ("text.lineHeightUp", json!({})), - ("text.caseSmallCaps", json!({})), - ("text.link", json!({"url": "https://example.com"})), - ] { - s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")); - } - let t = data(&s, id); - let (a, b) = (t.style_at(0), t.style_at(8)); - assert_eq!(a.decoration, TextDecoration::Underline); - assert!(a.italic() && !b.italic()); - assert_eq!(a.font_size, 25.0); - assert_eq!(b.font_size, 24.0); - assert_eq!(a.case, TextCase::SmallCaps); - assert_eq!(a.link.as_deref(), Some("https://example.com")); - assert!(b.link.is_none()); - // text.style with an explicit range (what the panel sends). - s.execute("text.style", &json!({"id": id, "start": 6, "end": 11, "fontSize": 40, "textCase": "upper"})).unwrap(); - let t = data(&s, id); - assert_eq!(t.style_at(8).font_size, 40.0); - assert_eq!(t.style_at(8).case, TextCase::Upper); - // Paragraph commands hit the edited node. - s.execute("text.listStyle", &json!({"style": "numbered"})).unwrap(); - assert_eq!(data(&s, id).list, ListStyle::Numbered); - // Every Text-menu command runs while editing. - for item in crate::catalog::MENU.iter().filter(|i| i.id.starts_with("text.")) { - let p = if item.id == "text.link" { json!({"url": "u"}) } else { json!({}) }; - if crate::find_command(item.id).is_some() { - s.execute(item.id, &p).unwrap_or_else(|e| panic!("{}: {e}", item.id)); - } - } - end(&mut s).unwrap(); - } - - #[test] - fn paragraph_and_feature_commands_on_nodes() { - let (mut s, id) = setup("Hello"); - let ids = json!([id]); - s.execute("text.setFeatures", &json!({"ids": ids, "features": {"liga": false, "tnum": true}})).unwrap(); - assert_eq!(data(&s, id).features.get("liga"), Some(&false)); - s.execute("text.setFeatures", &json!({"ids": ids, "features": {"liga": null, "ss01": 1}})).unwrap(); - let f = data(&s, id).features; - assert_eq!(f.get("liga"), None); - assert_eq!(f.get("ss01"), Some(&true)); - assert_eq!(f.get("tnum"), Some(&true)); - s.execute("text.setFeatures", &json!({"ids": ids, "features": {"zero": true}, "replace": true})).unwrap(); - assert_eq!(data(&s, id).features.len(), 1); - assert!(s.execute("text.setFeatures", &json!({"ids": ids, "features": {"ligatures": true}})).is_err()); - assert!(s.execute("text.setFeatures", &json!({"ids": ids, "features": {"liga": "yes"}})).is_err()); - assert!(s.execute("text.setFeatures", &json!({"ids": ids})).is_err()); - s.execute("node.set", &json!({"id": id, "props": {"features": {"calt": false}}})).unwrap(); - assert_eq!(data(&s, id).features.len(), 1); - s.execute("text.paragraphIndent", &json!({"ids": ids, "indent": 16})).unwrap(); - s.execute("text.paragraphSpacing", &json!({"ids": ids, "spacing": 8})).unwrap(); - s.execute("text.maxLines", &json!({"ids": ids, "lines": 2})).unwrap(); - s.execute("text.decoration", &json!({"ids": ids, "decoration": "strikethrough"})).unwrap(); - s.execute("text.case", &json!({"ids": ids, "case": "smallCaps"})).unwrap(); - s.execute("text.listStyle", &json!({"ids": ids, "style": "bulleted"})).unwrap(); - let t = data(&s, id); - assert_eq!((t.paragraph_indent, t.paragraph_spacing, t.max_lines), (16.0, 8.0, 2)); - assert_eq!(t.auto_resize, TextAutoResize::Truncate); - assert_eq!(t.style.decoration, TextDecoration::Strikethrough); - assert_eq!(t.style.case, TextCase::SmallCaps); - assert_eq!(t.list, ListStyle::Bulleted); - assert!(s.execute("text.listStyle", &json!({"ids": ids, "style": "zigzag"})).is_err()); - assert!(s.execute("text.paragraphIndent", &json!({"ids": ids, "indent": "NaN"})).is_err()); - s.execute("node.set", &json!({"id": id, "props": {"textCase": "smallCaps", "paragraphIndent": 4, "maxLines": 0}})).unwrap(); - assert_eq!(data(&s, id).paragraph_indent, 4.0); - } - - fn preedit_state(s: &Session) -> Option { - s.ds().unwrap().text_edit.clone().unwrap().preedit - } - - #[test] - fn ime_composition_is_shown_but_not_in_the_document_until_committed() { - let (mut s, id) = setup("Hello world"); - let undo_before = s.ds().unwrap().history.undo.len(); - begin(&mut s, id, Some(5), false).unwrap(); - let doc_before = s.ds().unwrap().doc.clone(); - s.execute("text.edit.preedit", &json!({"text": "にほ", "cursor": 1})).unwrap(); - assert_eq!(preedit_state(&s), Some(Preedit { text: "にほ".into(), cursor: 1 })); - assert!(std::sync::Arc::ptr_eq(&doc_before, &s.ds().unwrap().doc), "composing leaves the document alone"); - let (shown, range, cursor) = preedit_doc(&s).unwrap(); - assert_eq!(shown.node(id).unwrap().kind.text().unwrap().characters, "Helloにほ world"); - assert_eq!((range, cursor), ((5, 7), 6)); - let info = s.execute("text.edit.selection", &json!({})).unwrap(); - assert_eq!(info["preedit"]["text"], "にほ"); - // Updating the composition replaces it; an empty one cancels it. - preedit(&mut s, "にほん", None).unwrap(); - assert_eq!(preedit_doc(&s).unwrap().2, 8); - preedit(&mut s, "", None).unwrap(); - assert!(preedit_state(&s).is_none() && preedit_doc(&s).is_none()); - assert_eq!(data(&s, id).characters, "Hello world"); - // Commit puts the final text at the caret. - preedit(&mut s, "にほん", None).unwrap(); - s.execute("text.edit.insert", &json!({"text": "日本"})).unwrap(); - assert!(preedit_state(&s).is_none()); - assert_eq!(data(&s, id).characters, "Hello日本 world"); - assert_eq!(sel(&s), (7, 7)); - // Composing over a selection replaces it; moving the caret abandons the composition. - set_caret(&mut s, 7, false).unwrap(); - set_caret(&mut s, 13, true).unwrap(); - preedit(&mut s, "x", None).unwrap(); - assert_eq!(data(&s, id).characters, "Hello日本"); - set_caret(&mut s, 0, false).unwrap(); - assert!(preedit_state(&s).is_none()); - assert_eq!(data(&s, id).characters, "Hello日本"); - // Ending the edit drops an uncommitted composition; the session stays one undo step. - preedit(&mut s, "zz", None).unwrap(); - end(&mut s).unwrap(); - assert_eq!(data(&s, id).characters, "Hello日本"); - assert_eq!(s.ds().unwrap().history.undo.len(), undo_before + 1); - // Not editing: an error, not a crash. - assert!(preedit(&mut s, "a", None).is_err()); - } - - #[test] - fn variable_font_axes_as_commands_and_props() { - let (mut s, id) = setup("Hello world"); - let ids = json!([id]); - s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wght": 550, "wdth": 85}})).unwrap(); - let st = data(&s, id).style; - assert_eq!((st.variations.get("wght"), st.variations.get("wdth")), (Some(&550.0), Some(&85.0))); - assert_eq!(st.weight(), 550); - // null removes one axis; replace clears the rest. - s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wdth": null}})).unwrap(); - assert_eq!(data(&s, id).style.variations.len(), 1); - s.execute("text.setVariations", &json!({"ids": ids, "variations": {"opsz": 14}, "replace": true})).unwrap(); - assert_eq!(data(&s, id).style.variations.keys().collect::>(), ["opsz"]); - // Undo restores the previous values. - s.undo().unwrap(); - assert_eq!(data(&s, id).style.variations.keys().collect::>(), ["wght"]); - // Junk is rejected. - assert!(s.execute("text.setVariations", &json!({"ids": ids, "variations": {"weight": 500}})).is_err()); - assert!(s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wght": "bold"}})).is_err()); - assert!(s.execute("text.setVariations", &json!({"ids": ids})).is_err()); - // node.set props (whole text or a range) replace the custom values. - s.execute("node.set", &json!({"id": id, "props": {"fontVariations": {"wght": 300}, "start": 6, "end": 11}})).unwrap(); - let t = data(&s, id); - assert_eq!(t.style_at(0).variations.get("wght"), Some(&550.0)); - assert_eq!(t.style_at(7).variations.get("wght"), Some(&300.0)); - s.execute("node.set", &json!({"id": id, "props": {"variationSettings": null}})).unwrap(); - assert!(data(&s, id).runs.is_empty() && data(&s, id).style.variations.is_empty()); - // Picking a named style (Bold, weight steps) drops the custom weight but keeps width. - s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wght": 650, "wdth": 90}})).unwrap(); - s.execute("text.bold", &json!({"ids": ids})).unwrap(); - let st = data(&s, id).style; - assert_eq!(st.font_style, "Bold", "650 is lighter than bold, so Bold applies"); - assert!(!st.variations.contains_key("wght")); - assert_eq!(st.variations.get("wdth"), Some(&90.0)); - // While editing, the command styles the selected range only. - begin(&mut s, id, Some(0), false).unwrap(); - set_caret(&mut s, 5, true).unwrap(); - s.execute("text.setVariations", &json!({"variations": {"wdth": 75}})).unwrap(); - end(&mut s).unwrap(); - let t = data(&s, id); - assert_eq!(t.style_at(0).variations.get("wdth"), Some(&75.0)); - assert_eq!(t.style_at(8).variations.get("wdth"), Some(&90.0)); - // The axes query answers without fonts too. - let r = s.execute("text.fonts", &json!({"family": "Inter", "axes": true})).unwrap(); - assert!(r.is_null() || r["axes"].is_array()); - } - - /// No text command panics on junk parameters while a text is being edited. - #[test] - fn text_commands_survive_junk_while_editing() { - let junk = [ - json!({}), - json!(null), - json!({"index": -5, "features": {"": 1}}), - json!({"index": 1e300, "style": 3, "case": null}), - json!([1]), - json!({"variations": {"wght": "heavy", "toolong": 1}, "text": "\u{0}\u{7}", "cursor": 1e300}), - json!({"variations": {"wght": 1e300, "opsz": -1e300}, "replace": "yes", "text": "日本語", "cursor": -1}), - ]; - // `text.rescanFonts` takes no params and walks the font folders: one walk per junk is slow. - for spec in crate::cmd::all_commands().iter().filter(|c| c.id.starts_with("text.") && c.id != "text.rescanFonts") { - for j in &junk { - let (mut s, id) = setup("Hi there\nyou"); - begin(&mut s, id, Some(2), false).unwrap(); - set_caret(&mut s, 5, true).unwrap(); - if let Err(EngineError::Internal { cmd, msg }) = s.execute(spec.id, j) { - panic!("{cmd} panicked on {j}: {msg}"); - } - } - } - } - - /// Asserts surrounding text is logical UTF-8 text with byte offsets and keeps a reversed - /// selection reversed. - #[test] - fn ime_surrounding_uses_logical_utf8_bytes_and_keeps_selection_direction() { - let (mut s, id) = setup("café😀e\u{301}x"); - begin(&mut s, id, Some(8), false).unwrap(); - set_caret(&mut s, 8, false).unwrap(); - set_caret(&mut s, 5, true).unwrap(); // anchor after the emoji, caret before it - let ime = ime_surrounding(&s).unwrap(); - assert_eq!(ime.text, "café😀e\u{301}x"); - assert_eq!((ime.cursor, ime.anchor), (9, 13), "char 5 and char 8 as UTF-8 bytes"); - assert!(ime.cursor < ime.anchor, "the reversed selection stays reversed"); - assert_eq!(ime.owner.node, id); - assert_eq!(ime.owner.document, s.ds().unwrap().uid); - } - - /// Asserts an active composition is excluded from surrounding text and an empty text layer - /// reports an available, empty payload. - #[test] - fn ime_surrounding_excludes_the_composition_and_empty_text_is_available() { - let (mut s, id) = setup("Hi"); - begin(&mut s, id, Some(2), false).unwrap(); - preedit(&mut s, "にほ", Some(1)).unwrap(); - let ime = ime_surrounding(&s).unwrap(); - assert_eq!(ime.text, "Hi", "the composition is not committed text"); - assert_eq!((ime.cursor, ime.anchor), (2, 2)); - end(&mut s).unwrap(); - - let (mut s, id) = setup(""); - begin(&mut s, id, Some(0), false).unwrap(); - let ime = ime_surrounding(&s).unwrap(); - assert_eq!((ime.text.as_str(), ime.cursor, ime.anchor), ("", 0, 0), "an empty layer is available, not unavailable"); - } - - /// Asserts surrounding text is capped at `MAX_IME_SURROUNDING_BYTES`, an exactly budget-sized - /// selection is describable, and one byte more is `SelectionTooWide`. - #[test] - fn ime_surrounding_is_bounded_and_a_too_wide_selection_is_explicitly_unavailable() { - let long = "a".repeat(5000); - let (mut s, id) = setup(&long); - begin(&mut s, id, Some(5000), false).unwrap(); - let ime = ime_surrounding(&s).unwrap(); - assert_eq!(ime.text.len(), MAX_IME_SURROUNDING_BYTES); - assert_eq!((ime.cursor, ime.anchor), (MAX_IME_SURROUNDING_BYTES, MAX_IME_SURROUNDING_BYTES)); - - // Exactly the budget is still describable; one more byte is not. - set_caret(&mut s, 1000, false).unwrap(); - set_caret(&mut s, 5000, true).unwrap(); - let ime = ime_surrounding(&s).unwrap(); - assert_eq!(ime.text.len(), 4000); - assert_eq!((ime.cursor, ime.anchor), (4000, 0)); - set_caret(&mut s, 999, false).unwrap(); - set_caret(&mut s, 5000, true).unwrap(); - assert_eq!(ime_surrounding(&s), Err(ImeSurroundingUnavailable::SelectionTooWide)); - } - - /// Asserts edits and caret moves keep the same IME owner, while ending and beginning a new - /// edit session changes the generation. - #[test] - fn ime_owner_is_stable_through_edits_and_the_session_changes_on_begin() { - let (mut s, id) = setup("Hello"); - begin(&mut s, id, Some(0), false).unwrap(); - let first = ime_surrounding(&s).unwrap().owner; - insert(&mut s, "x").unwrap(); - set_caret(&mut s, 2, false).unwrap(); - assert_eq!(ime_surrounding(&s).unwrap().owner, first, "edits and caret moves keep the owner"); - end(&mut s).unwrap(); - begin(&mut s, id, Some(0), false).unwrap(); - let replaced = ime_surrounding(&s).unwrap().owner; - assert_eq!(replaced.node, id); - assert_ne!(replaced.editing_session, first.editing_session, "a new edit session is a replacement epoch"); - } - - /// Asserts byte deletion around a reversed selection removes whole characters on both - /// sides, keeps the selected text and direction, and a final no-op leaves the text alone. - #[test] - fn ime_delete_surrounding_bytes_keeps_the_reversed_selection_and_deletes_around_it() { - let (mut s, id) = setup("ab你好cd"); - begin(&mut s, id, Some(4), false).unwrap(); - set_caret(&mut s, 4, false).unwrap(); - set_caret(&mut s, 2, true).unwrap(); // reversed: caret before 你, anchor after 好 - assert!(delete_surrounding_bytes(&mut s, 1, 1).unwrap()); - // "b" (whole character before the selection) and "c" (whole character after it) go; the - // selected "你好" stays for the paired commit, and the reversed direction is preserved. - assert_eq!(data(&s, id).characters, "a你好d"); - assert_eq!(sel(&s), (3, 1)); // (anchor, caret), still reversed - assert!(delete_surrounding_bytes(&mut s, 1, 1).unwrap()); - assert_eq!(data(&s, id).characters, "你好"); - assert_eq!(sel(&s), (2, 0)); // (anchor, caret), still reversed - assert!(!delete_surrounding_bytes(&mut s, 1, 1).unwrap(), "nothing is left around the selection"); - assert_eq!(data(&s, id).characters, "你好", "a no-op delete does not touch the text"); - } - - /// Asserts character deletion keeps and shifts the selection, and a no-op delete leaves an - /// active composition and the revision untouched. - #[test] - fn ime_delete_surrounding_chars_keeps_the_selection_and_ignores_a_no_op() { - let (mut s, id) = setup("ab你好cd"); - begin(&mut s, id, Some(2), false).unwrap(); - set_caret(&mut s, 4, true).unwrap(); // "你好" - assert!(delete_surrounding_chars(&mut s, 1, 1).unwrap()); - assert_eq!(data(&s, id).characters, "a你好d"); - assert_eq!(sel(&s), (1, 3), "the selection is kept, shifted by the deleted prefix"); - set_caret(&mut s, 0, false).unwrap(); - // A composition in progress survives a delete that finds nothing to remove. - preedit(&mut s, "x", None).unwrap(); - let revision = s.ds().unwrap().revision; - assert!(!delete_surrounding_chars(&mut s, 1, 0).unwrap()); - assert_eq!(s.ds().unwrap().text_edit.as_ref().and_then(|te| te.preedit.as_ref()).map(|p| p.text.as_str()), Some("x")); - assert_eq!(s.ds().unwrap().revision, revision, "a no-op does not bump the revision"); - assert!(!delete_surrounding_bytes(&mut s, 1, 0).unwrap()); - assert_eq!(s.ds().unwrap().text_edit.as_ref().and_then(|te| te.preedit.as_ref()).map(|p| p.text.as_str()), Some("x")); - assert_eq!(s.ds().unwrap().revision, revision); - } - - /// Asserts a text layer in another document has a different IME owner. - #[test] - fn ime_owner_identity_changes_with_the_document() { - let (mut s, id) = setup("Hi"); - begin(&mut s, id, Some(2), false).unwrap(); - let first = ime_surrounding(&s).unwrap().owner; - end(&mut s).unwrap(); - s.execute("file.new", &json!({"name": "Second"})).unwrap(); - let r = s.execute("text.create", &json!({"text": "There", "x": 0, "y": 0})).unwrap(); - let other = crate::params::id_of(&r["id"]).unwrap(); - begin(&mut s, other, Some(5), false).unwrap(); - let second = ime_surrounding(&s).unwrap().owner; - assert_ne!(second.document, first.document, "another document is another IME owner"); - assert_eq!(second.node, other); - } - - /// Asserts a space committed after a line-start `-` becomes a bulleted list and reports - /// `Transformed`; a plain `"x"` commit reports `Plain`, and an empty commit reports `Nothing`. - #[test] - fn ime_commit_reports_list_autoformat_as_an_application_transformation() { - let (mut s, id) = setup("-"); - begin(&mut s, id, Some(1), false).unwrap(); - assert_eq!(insert_ime_commit(&mut s, " ").unwrap(), ImeInsertion::Transformed); - assert_eq!(data(&s, id).characters, ""); - assert_eq!(data(&s, id).para_list(0).list, ListStyle::Bulleted); - assert_eq!(insert_ime_commit(&mut s, "x").unwrap(), ImeInsertion::Plain); - assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); - } - - /// Asserts CRLF normalization reports `Transformed`, control-only input reports `Nothing`, - /// and bare newline/CR commits are ignored while a live composition remains. - #[test] - fn ime_commit_normalisation_is_a_transformation_and_a_bare_newline_is_ignored() { - let (mut s, id) = setup("Hi"); - begin(&mut s, id, Some(2), false).unwrap(); - // CRLF is inserted as LF, but the editor changed the commit: application-origin. - assert_eq!(insert_ime_commit(&mut s, "a\r\nb").unwrap(), ImeInsertion::Transformed); - assert_eq!(data(&s, id).characters, "Hia\nb"); - // Control characters are dropped; the commit itself is a no-op. - assert_eq!(insert_ime_commit(&mut s, "\u{0}\u{7}").unwrap(), ImeInsertion::Nothing); - assert_eq!(data(&s, id).characters, "Hia\nb"); - // Stock `TextEdit` ignores a bare newline/CR commit; typing Enter still inserts one. - // (It also leaves a live composition alone, unlike the non-ignored commit below.) - preedit(&mut s, "x", None).unwrap(); - assert_eq!(insert_ime_commit(&mut s, "\n").unwrap(), ImeInsertion::Nothing); - assert_eq!(insert_ime_commit(&mut s, "\r").unwrap(), ImeInsertion::Nothing); - assert_eq!(preedit_state(&s).map(|p| p.text), Some("x".into())); - preedit(&mut s, "", None).unwrap(); - assert_eq!(data(&s, id).characters, "Hia\nb"); - insert(&mut s, "\n").unwrap(); - assert_eq!(data(&s, id).characters, "Hia\nb\n"); - } - - /// Asserts an empty or control-only IME commit ends a live composition (bumping the revision - /// once), inserts nothing, and is a harmless no-op when no composition is active. - #[test] - fn an_empty_ime_commit_ends_the_composition_without_inserting_text() { - let (mut s, id) = setup("Hello"); - begin(&mut s, id, Some(5), false).unwrap(); - preedit(&mut s, "にほ", None).unwrap(); - assert_eq!(preedit_state(&s).map(|p| p.text), Some("にほ".into())); - let revision = s.ds().unwrap().revision; - assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); - assert_eq!(s.ds().unwrap().revision, revision + 1, "ending a live composition bumps the revision"); - assert!(preedit_state(&s).is_none(), "an empty commit ends the composition"); - assert!(preedit_doc(&s).is_none()); - assert_eq!(data(&s, id).characters, "Hello", "and inserts nothing"); - - // A control-only commit normalises to empty and ends the composition too. - preedit(&mut s, "x", None).unwrap(); - let revision = s.ds().unwrap().revision; - assert_eq!(insert_ime_commit(&mut s, "\u{0}\u{7}").unwrap(), ImeInsertion::Nothing); - assert_eq!(s.ds().unwrap().revision, revision + 1, "clearing a live composition bumps the revision"); - assert!(preedit_state(&s).is_none()); - assert_eq!(data(&s, id).characters, "Hello"); - - // With no composition (and not even while editing) an empty commit stays a harmless no-op. - let revision = s.ds().unwrap().revision; - assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); - assert_eq!(s.ds().unwrap().revision, revision); - end(&mut s).unwrap(); - assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); - } -} +mod tests; diff --git a/crates/engine/src/textedit/tests.rs b/crates/engine/src/textedit/tests.rs new file mode 100644 index 00000000..fecca580 --- /dev/null +++ b/crates/engine/src/textedit/tests.rs @@ -0,0 +1,571 @@ +use super::*; +use crate::tools::{PointerEvent, PointerKind}; +use figcraft_doc::{ListStyle, TextAutoResize, TextCase, TextDecoration}; +use serde_json::json; + +fn setup(text: &str) -> (Session, NodeId) { + let mut s = Session::new(); + let r = s.execute("text.create", &json!({"text": text, "x": 0, "y": 0, "props": {"fontSize": 24}})).unwrap(); + let id = crate::params::id_of(&r["id"]).unwrap(); + (s, id) +} + +fn data(s: &Session, id: NodeId) -> TextData { + s.doc().unwrap().node(id).unwrap().kind.text().unwrap().clone() +} + +fn sel(s: &Session) -> (usize, usize) { + let te = s.ds().unwrap().text_edit.clone().unwrap(); + (te.anchor, te.caret) +} + +#[test] +fn whole_editing_session_is_one_undo_step() { + let (mut s, id) = setup("Hello"); + let undo_before = s.ds().unwrap().history.undo.len(); + begin(&mut s, id, Some(5), false).unwrap(); + insert(&mut s, " big").unwrap(); + insert(&mut s, " world").unwrap(); + key(&mut s, "Backspace", false, false, false).unwrap(); + // Styling a range mid-session joins the same step. + set_caret(&mut s, 0, false).unwrap(); + set_caret(&mut s, 5, true).unwrap(); + s.execute("text.bold", &json!({})).unwrap(); + s.execute("text.alignCenter", &json!({})).unwrap(); + end(&mut s).unwrap(); + let t = data(&s, id); + assert_eq!(t.characters, "Hello big worl"); + assert_eq!(t.style_at(0).font_style, "Bold"); + assert_eq!(t.style_at(6).font_style, "Regular"); + assert_eq!(s.ds().unwrap().history.undo.len(), undo_before + 1); + s.undo().unwrap(); + let t = data(&s, id); + assert_eq!(t.characters, "Hello"); + assert!(t.runs.is_empty()); + assert_eq!(t.align_h, figcraft_doc::TextAlignH::Left); +} + +#[test] +fn typing_replaces_selection_and_paste_is_plain() { + let (mut s, id) = setup("Hello world"); + begin(&mut s, id, None, false).unwrap(); + assert_eq!(sel(&s), (0, 11), "begin without caret selects all"); + select_word(&mut s, 7).unwrap(); + assert_eq!(sel(&s), (6, 11)); + insert(&mut s, "there").unwrap(); + assert_eq!(data(&s, id).characters, "Hello there"); + assert_eq!(sel(&s), (11, 11)); + // Pasted text: CRLF / CR / Unicode separators become newlines, other controls are dropped. + insert(&mut s, "\r\na\rb\u{2029}c\u{0}\u{7}\td").unwrap(); + assert_eq!(data(&s, id).characters, "Hello there\na\nb\nc\td"); + insert(&mut s, "\u{1}").unwrap(); + assert_eq!(data(&s, id).characters, "Hello there\na\nb\nc\td"); + // Select the line (paragraph) then type over it. + select_line(&mut s, 0).unwrap(); + assert_eq!(sel(&s), (0, 11)); + insert(&mut s, "Hi").unwrap(); + assert_eq!(data(&s, id).characters, "Hi\na\nb\nc\td"); + assert_eq!(selected_text(&s).as_deref(), Some("")); + end(&mut s).unwrap(); +} + +#[test] +fn word_selection_classes() { + let (mut s, id) = setup("foo, bar_baz!\nnext"); + begin(&mut s, id, Some(0), false).unwrap(); + select_word(&mut s, 1).unwrap(); + assert_eq!(sel(&s), (0, 3)); + // Right after a word's last char selects the word. + select_word(&mut s, 13).unwrap(); + assert_eq!(sel(&s), (6, 13)); + // A run of spaces. + select_word(&mut s, 4).unwrap(); + assert_eq!(sel(&s), (4, 6)); + select_line(&mut s, 16).unwrap(); + assert_eq!(sel(&s), (15, 19)); + select_line(&mut s, 999).unwrap(); + assert_eq!(sel(&s), (15, 19)); + select_word(&mut s, 999).unwrap(); + assert_eq!(sel(&s), (15, 19)); + set_caret(&mut s, 999, false).unwrap(); + assert_eq!(sel(&s), (19, 19), "carets clamp to the text"); +} + +#[test] +fn keyboard_navigation_and_extension() { + let (mut s, id) = setup("one two\nthree"); + begin(&mut s, id, Some(0), false).unwrap(); + key(&mut s, "Right", false, true, false).unwrap(); // Alt+Right: end of word + assert_eq!(sel(&s), (3, 3)); + key(&mut s, "Right", true, true, false).unwrap(); // Shift+Alt+Right extends + assert_eq!(sel(&s), (3, 7)); + key(&mut s, "Left", false, false, false).unwrap(); // collapses to the start + assert_eq!(sel(&s), (3, 3)); + key(&mut s, "Right", false, false, true).unwrap(); // Cmd+Right: line end + assert_eq!(sel(&s), (7, 7)); + key(&mut s, "Down", true, false, true).unwrap(); // Shift+Cmd+Down: to the end + assert_eq!(sel(&s), (7, 13)); + key(&mut s, "Left", false, false, true).unwrap(); // Cmd+Left from the collapsed start + assert_eq!(sel(&s), (0, 0)); + key(&mut s, "Delete", false, true, false).unwrap(); // Alt+Delete: word forward + assert_eq!(data(&s, id).characters, " two\nthree"); + key(&mut s, "SelectAll", false, false, false).unwrap(); + assert_eq!(sel(&s), (0, 10)); + end(&mut s).unwrap(); +} + +#[test] +fn backspace_removes_only_the_final_thai_mark() { + let (mut s, id) = setup("น้ำ"); + begin(&mut s, id, Some(3), false).unwrap(); + + key(&mut s, "Backspace", false, false, false).unwrap(); + + assert_eq!(data(&s, id).characters, "น้"); +} + +fn ev(kind: PointerKind, x: f64, y: f64, shift: bool) -> PointerEvent { + let mut e = PointerEvent::at(kind, x, y, 1.0); + e.shift = shift; + e +} + +/// Asserts double-clicking a text layer selects all text, a second double-click selects the +/// word without changing the editing session, and drag and Shift+click extend the +/// selection. +#[test] +fn pointer_double_click_drag_and_shift_click() { + let (mut s, id) = setup("Hello world"); + let Some((l, _)) = figcraft_text::layout_node(s.doc().unwrap().node(id).unwrap()) else { return }; + if l.glyphs.is_empty() { + return; // no fonts + } + let y = l.lines[0].top + l.lines[0].height / 2.0; + let x_of = |i: usize| l.caret(i).0; + // Double-click inside "world" edits with all the text selected; another one there + // selects the word. + let mid = (x_of(7) + x_of(8)) / 2.0; + s.pointer(&ev(PointerKind::Down, mid, y, false)).unwrap(); + s.pointer(&ev(PointerKind::Up, mid, y, false)).unwrap(); + s.pointer(&ev(PointerKind::DoubleClick, mid, y, false)).unwrap(); + assert_eq!(sel(&s), (0, 11)); + let epoch = s.ds().unwrap().text_edit.as_ref().unwrap().editing_session; + s.pointer(&ev(PointerKind::Down, mid, y, false)).unwrap(); + s.pointer(&ev(PointerKind::DoubleClick, mid, y, false)).unwrap(); + s.pointer(&ev(PointerKind::Up, mid, y, false)).unwrap(); + assert_eq!(sel(&s), (6, 11)); + assert_eq!( + s.ds().unwrap().text_edit.as_ref().unwrap().editing_session, + epoch, + "a double-click inside the edit is an ordinary edit: same IME replacement epoch" + ); + // Drag-select from the start of "Hello" to the middle. + s.pointer(&ev(PointerKind::Down, x_of(0) + 0.5, y, false)).unwrap(); + assert_eq!(sel(&s), (0, 0)); + s.pointer(&ev(PointerKind::Move, (x_of(3) + x_of(4)) / 2.0 - 1.0, y, false)).unwrap(); + s.pointer(&ev(PointerKind::Up, (x_of(3) + x_of(4)) / 2.0 - 1.0, y, false)).unwrap(); + assert_eq!(sel(&s), (0, 3)); + // Shift+click extends. + s.pointer(&ev(PointerKind::Down, x_of(9) + 0.5, y, true)).unwrap(); + s.pointer(&ev(PointerKind::Up, x_of(9) + 0.5, y, true)).unwrap(); + assert_eq!(sel(&s), (0, 9)); + insert(&mut s, "J").unwrap(); + assert_eq!(data(&s, id).characters, "Jld"); +} + +#[test] +fn text_commands_apply_to_edited_range() { + let (mut s, id) = setup("Hello world"); + begin(&mut s, id, Some(0), false).unwrap(); + set_caret(&mut s, 6, true).unwrap(); // "Hello " + for (cmd, p) in [ + ("text.underline", json!({})), + ("text.italic", json!({})), + ("text.sizeUp", json!({})), + ("text.trackingUp", json!({})), + ("text.lineHeightUp", json!({})), + ("text.caseSmallCaps", json!({})), + ("text.link", json!({"url": "https://example.com"})), + ] { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")); + } + let t = data(&s, id); + let (a, b) = (t.style_at(0), t.style_at(8)); + assert_eq!(a.decoration, TextDecoration::Underline); + assert!(a.italic() && !b.italic()); + assert_eq!(a.font_size, 25.0); + assert_eq!(b.font_size, 24.0); + assert_eq!(a.case, TextCase::SmallCaps); + assert_eq!(a.link.as_deref(), Some("https://example.com")); + assert!(b.link.is_none()); + // text.style with an explicit range (what the panel sends). + s.execute("text.style", &json!({"id": id, "start": 6, "end": 11, "fontSize": 40, "textCase": "upper"})).unwrap(); + let t = data(&s, id); + assert_eq!(t.style_at(8).font_size, 40.0); + assert_eq!(t.style_at(8).case, TextCase::Upper); + // Paragraph commands hit the edited node. + s.execute("text.listStyle", &json!({"style": "numbered"})).unwrap(); + assert_eq!(data(&s, id).list, ListStyle::Numbered); + // Every Text-menu command runs while editing. + for item in crate::catalog::MENU.iter().filter(|i| i.id.starts_with("text.")) { + let p = if item.id == "text.link" { json!({"url": "u"}) } else { json!({}) }; + if crate::find_command(item.id).is_some() { + s.execute(item.id, &p).unwrap_or_else(|e| panic!("{}: {e}", item.id)); + } + } + end(&mut s).unwrap(); +} + +#[test] +fn paragraph_and_feature_commands_on_nodes() { + let (mut s, id) = setup("Hello"); + let ids = json!([id]); + s.execute("text.setFeatures", &json!({"ids": ids, "features": {"liga": false, "tnum": true}})).unwrap(); + assert_eq!(data(&s, id).features.get("liga"), Some(&false)); + s.execute("text.setFeatures", &json!({"ids": ids, "features": {"liga": null, "ss01": 1}})).unwrap(); + let f = data(&s, id).features; + assert_eq!(f.get("liga"), None); + assert_eq!(f.get("ss01"), Some(&true)); + assert_eq!(f.get("tnum"), Some(&true)); + s.execute("text.setFeatures", &json!({"ids": ids, "features": {"zero": true}, "replace": true})).unwrap(); + assert_eq!(data(&s, id).features.len(), 1); + assert!(s.execute("text.setFeatures", &json!({"ids": ids, "features": {"ligatures": true}})).is_err()); + assert!(s.execute("text.setFeatures", &json!({"ids": ids, "features": {"liga": "yes"}})).is_err()); + assert!(s.execute("text.setFeatures", &json!({"ids": ids})).is_err()); + s.execute("node.set", &json!({"id": id, "props": {"features": {"calt": false}}})).unwrap(); + assert_eq!(data(&s, id).features.len(), 1); + s.execute("text.paragraphIndent", &json!({"ids": ids, "indent": 16})).unwrap(); + s.execute("text.paragraphSpacing", &json!({"ids": ids, "spacing": 8})).unwrap(); + s.execute("text.maxLines", &json!({"ids": ids, "lines": 2})).unwrap(); + s.execute("text.decoration", &json!({"ids": ids, "decoration": "strikethrough"})).unwrap(); + s.execute("text.case", &json!({"ids": ids, "case": "smallCaps"})).unwrap(); + s.execute("text.listStyle", &json!({"ids": ids, "style": "bulleted"})).unwrap(); + let t = data(&s, id); + assert_eq!((t.paragraph_indent, t.paragraph_spacing, t.max_lines), (16.0, 8.0, 2)); + assert_eq!(t.auto_resize, TextAutoResize::Truncate); + assert_eq!(t.style.decoration, TextDecoration::Strikethrough); + assert_eq!(t.style.case, TextCase::SmallCaps); + assert_eq!(t.list, ListStyle::Bulleted); + assert!(s.execute("text.listStyle", &json!({"ids": ids, "style": "zigzag"})).is_err()); + assert!(s.execute("text.paragraphIndent", &json!({"ids": ids, "indent": "NaN"})).is_err()); + s.execute("node.set", &json!({"id": id, "props": {"textCase": "smallCaps", "paragraphIndent": 4, "maxLines": 0}})).unwrap(); + assert_eq!(data(&s, id).paragraph_indent, 4.0); +} + +fn preedit_state(s: &Session) -> Option { + s.ds().unwrap().text_edit.clone().unwrap().preedit +} + +#[test] +fn ime_composition_is_shown_but_not_in_the_document_until_committed() { + let (mut s, id) = setup("Hello world"); + let undo_before = s.ds().unwrap().history.undo.len(); + begin(&mut s, id, Some(5), false).unwrap(); + let doc_before = s.ds().unwrap().doc.clone(); + s.execute("text.edit.preedit", &json!({"text": "にほ", "cursor": 1})).unwrap(); + assert_eq!(preedit_state(&s), Some(Preedit { text: "にほ".into(), cursor: 1 })); + assert!(std::sync::Arc::ptr_eq(&doc_before, &s.ds().unwrap().doc), "composing leaves the document alone"); + let (shown, range, cursor) = preedit_doc(&s).unwrap(); + assert_eq!(shown.node(id).unwrap().kind.text().unwrap().characters, "Helloにほ world"); + assert_eq!((range, cursor), ((5, 7), 6)); + let info = s.execute("text.edit.selection", &json!({})).unwrap(); + assert_eq!(info["preedit"]["text"], "にほ"); + // Updating the composition replaces it; an empty one cancels it. + preedit(&mut s, "にほん", None).unwrap(); + assert_eq!(preedit_doc(&s).unwrap().2, 8); + preedit(&mut s, "", None).unwrap(); + assert!(preedit_state(&s).is_none() && preedit_doc(&s).is_none()); + assert_eq!(data(&s, id).characters, "Hello world"); + // Commit puts the final text at the caret. + preedit(&mut s, "にほん", None).unwrap(); + s.execute("text.edit.insert", &json!({"text": "日本"})).unwrap(); + assert!(preedit_state(&s).is_none()); + assert_eq!(data(&s, id).characters, "Hello日本 world"); + assert_eq!(sel(&s), (7, 7)); + // Composing over a selection replaces it; moving the caret abandons the composition. + set_caret(&mut s, 7, false).unwrap(); + set_caret(&mut s, 13, true).unwrap(); + preedit(&mut s, "x", None).unwrap(); + assert_eq!(data(&s, id).characters, "Hello日本"); + set_caret(&mut s, 0, false).unwrap(); + assert!(preedit_state(&s).is_none()); + assert_eq!(data(&s, id).characters, "Hello日本"); + // Ending the edit drops an uncommitted composition; the session stays one undo step. + preedit(&mut s, "zz", None).unwrap(); + end(&mut s).unwrap(); + assert_eq!(data(&s, id).characters, "Hello日本"); + assert_eq!(s.ds().unwrap().history.undo.len(), undo_before + 1); + // Not editing: an error, not a crash. + assert!(preedit(&mut s, "a", None).is_err()); +} + +#[test] +fn variable_font_axes_as_commands_and_props() { + let (mut s, id) = setup("Hello world"); + let ids = json!([id]); + s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wght": 550, "wdth": 85}})).unwrap(); + let st = data(&s, id).style; + assert_eq!((st.variations.get("wght"), st.variations.get("wdth")), (Some(&550.0), Some(&85.0))); + assert_eq!(st.weight(), 550); + // null removes one axis; replace clears the rest. + s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wdth": null}})).unwrap(); + assert_eq!(data(&s, id).style.variations.len(), 1); + s.execute("text.setVariations", &json!({"ids": ids, "variations": {"opsz": 14}, "replace": true})).unwrap(); + assert_eq!(data(&s, id).style.variations.keys().collect::>(), ["opsz"]); + // Undo restores the previous values. + s.undo().unwrap(); + assert_eq!(data(&s, id).style.variations.keys().collect::>(), ["wght"]); + // Junk is rejected. + assert!(s.execute("text.setVariations", &json!({"ids": ids, "variations": {"weight": 500}})).is_err()); + assert!(s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wght": "bold"}})).is_err()); + assert!(s.execute("text.setVariations", &json!({"ids": ids})).is_err()); + // node.set props (whole text or a range) replace the custom values. + s.execute("node.set", &json!({"id": id, "props": {"fontVariations": {"wght": 300}, "start": 6, "end": 11}})).unwrap(); + let t = data(&s, id); + assert_eq!(t.style_at(0).variations.get("wght"), Some(&550.0)); + assert_eq!(t.style_at(7).variations.get("wght"), Some(&300.0)); + s.execute("node.set", &json!({"id": id, "props": {"variationSettings": null}})).unwrap(); + assert!(data(&s, id).runs.is_empty() && data(&s, id).style.variations.is_empty()); + // Picking a named style (Bold, weight steps) drops the custom weight but keeps width. + s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wght": 650, "wdth": 90}})).unwrap(); + s.execute("text.bold", &json!({"ids": ids})).unwrap(); + let st = data(&s, id).style; + assert_eq!(st.font_style, "Bold", "650 is lighter than bold, so Bold applies"); + assert!(!st.variations.contains_key("wght")); + assert_eq!(st.variations.get("wdth"), Some(&90.0)); + // While editing, the command styles the selected range only. + begin(&mut s, id, Some(0), false).unwrap(); + set_caret(&mut s, 5, true).unwrap(); + s.execute("text.setVariations", &json!({"variations": {"wdth": 75}})).unwrap(); + end(&mut s).unwrap(); + let t = data(&s, id); + assert_eq!(t.style_at(0).variations.get("wdth"), Some(&75.0)); + assert_eq!(t.style_at(8).variations.get("wdth"), Some(&90.0)); + // The axes query answers without fonts too. + let r = s.execute("text.fonts", &json!({"family": "Inter", "axes": true})).unwrap(); + assert!(r.is_null() || r["axes"].is_array()); +} + +/// No text command panics on junk parameters while a text is being edited. +#[test] +fn text_commands_survive_junk_while_editing() { + let junk = [ + json!({}), + json!(null), + json!({"index": -5, "features": {"": 1}}), + json!({"index": 1e300, "style": 3, "case": null}), + json!([1]), + json!({"variations": {"wght": "heavy", "toolong": 1}, "text": "\u{0}\u{7}", "cursor": 1e300}), + json!({"variations": {"wght": 1e300, "opsz": -1e300}, "replace": "yes", "text": "日本語", "cursor": -1}), + ]; + // `text.rescanFonts` takes no params and walks the font folders: one walk per junk is slow. + for spec in crate::cmd::all_commands().iter().filter(|c| c.id.starts_with("text.") && c.id != "text.rescanFonts") { + for j in &junk { + let (mut s, id) = setup("Hi there\nyou"); + begin(&mut s, id, Some(2), false).unwrap(); + set_caret(&mut s, 5, true).unwrap(); + if let Err(EngineError::Internal { cmd, msg }) = s.execute(spec.id, j) { + panic!("{cmd} panicked on {j}: {msg}"); + } + } + } +} + +/// Asserts surrounding text is logical UTF-8 text with byte offsets and keeps a reversed +/// selection reversed. +#[test] +fn ime_surrounding_uses_logical_utf8_bytes_and_keeps_selection_direction() { + let (mut s, id) = setup("café😀e\u{301}x"); + begin(&mut s, id, Some(8), false).unwrap(); + set_caret(&mut s, 8, false).unwrap(); + set_caret(&mut s, 5, true).unwrap(); // anchor after the emoji, caret before it + let ime = ime_surrounding(&s).unwrap(); + assert_eq!(ime.text, "café😀e\u{301}x"); + assert_eq!((ime.cursor, ime.anchor), (9, 13), "char 5 and char 8 as UTF-8 bytes"); + assert!(ime.cursor < ime.anchor, "the reversed selection stays reversed"); + assert_eq!(ime.owner.node, id); + assert_eq!(ime.owner.document, s.ds().unwrap().uid); +} + +/// Asserts an active composition is excluded from surrounding text and an empty text layer +/// reports an available, empty payload. +#[test] +fn ime_surrounding_excludes_the_composition_and_empty_text_is_available() { + let (mut s, id) = setup("Hi"); + begin(&mut s, id, Some(2), false).unwrap(); + preedit(&mut s, "にほ", Some(1)).unwrap(); + let ime = ime_surrounding(&s).unwrap(); + assert_eq!(ime.text, "Hi", "the composition is not committed text"); + assert_eq!((ime.cursor, ime.anchor), (2, 2)); + end(&mut s).unwrap(); + + let (mut s, id) = setup(""); + begin(&mut s, id, Some(0), false).unwrap(); + let ime = ime_surrounding(&s).unwrap(); + assert_eq!((ime.text.as_str(), ime.cursor, ime.anchor), ("", 0, 0), "an empty layer is available, not unavailable"); +} + +/// Asserts surrounding text is capped at `MAX_IME_SURROUNDING_BYTES`, an exactly budget-sized +/// selection is describable, and one byte more is `SelectionTooWide`. +#[test] +fn ime_surrounding_is_bounded_and_a_too_wide_selection_is_explicitly_unavailable() { + let long = "a".repeat(5000); + let (mut s, id) = setup(&long); + begin(&mut s, id, Some(5000), false).unwrap(); + let ime = ime_surrounding(&s).unwrap(); + assert_eq!(ime.text.len(), MAX_IME_SURROUNDING_BYTES); + assert_eq!((ime.cursor, ime.anchor), (MAX_IME_SURROUNDING_BYTES, MAX_IME_SURROUNDING_BYTES)); + + // Exactly the budget is still describable; one more byte is not. + set_caret(&mut s, 1000, false).unwrap(); + set_caret(&mut s, 5000, true).unwrap(); + let ime = ime_surrounding(&s).unwrap(); + assert_eq!(ime.text.len(), 4000); + assert_eq!((ime.cursor, ime.anchor), (4000, 0)); + set_caret(&mut s, 999, false).unwrap(); + set_caret(&mut s, 5000, true).unwrap(); + assert_eq!(ime_surrounding(&s), Err(ImeSurroundingUnavailable::SelectionTooWide)); +} + +/// Asserts edits and caret moves keep the same IME owner, while ending and beginning a new +/// edit session changes the generation. +#[test] +fn ime_owner_is_stable_through_edits_and_the_session_changes_on_begin() { + let (mut s, id) = setup("Hello"); + begin(&mut s, id, Some(0), false).unwrap(); + let first = ime_surrounding(&s).unwrap().owner; + insert(&mut s, "x").unwrap(); + set_caret(&mut s, 2, false).unwrap(); + assert_eq!(ime_surrounding(&s).unwrap().owner, first, "edits and caret moves keep the owner"); + end(&mut s).unwrap(); + begin(&mut s, id, Some(0), false).unwrap(); + let replaced = ime_surrounding(&s).unwrap().owner; + assert_eq!(replaced.node, id); + assert_ne!(replaced.editing_session, first.editing_session, "a new edit session is a replacement epoch"); +} + +/// Asserts byte deletion around a reversed selection removes whole characters on both +/// sides, keeps the selected text and direction, and a final no-op leaves the text alone. +#[test] +fn ime_delete_surrounding_bytes_keeps_the_reversed_selection_and_deletes_around_it() { + let (mut s, id) = setup("ab你好cd"); + begin(&mut s, id, Some(4), false).unwrap(); + set_caret(&mut s, 4, false).unwrap(); + set_caret(&mut s, 2, true).unwrap(); // reversed: caret before 你, anchor after 好 + assert!(delete_surrounding_bytes(&mut s, 1, 1).unwrap()); + // "b" (whole character before the selection) and "c" (whole character after it) go; the + // selected "你好" stays for the paired commit, and the reversed direction is preserved. + assert_eq!(data(&s, id).characters, "a你好d"); + assert_eq!(sel(&s), (3, 1)); // (anchor, caret), still reversed + assert!(delete_surrounding_bytes(&mut s, 1, 1).unwrap()); + assert_eq!(data(&s, id).characters, "你好"); + assert_eq!(sel(&s), (2, 0)); // (anchor, caret), still reversed + assert!(!delete_surrounding_bytes(&mut s, 1, 1).unwrap(), "nothing is left around the selection"); + assert_eq!(data(&s, id).characters, "你好", "a no-op delete does not touch the text"); +} + +/// Asserts character deletion keeps and shifts the selection, and a no-op delete leaves an +/// active composition and the revision untouched. +#[test] +fn ime_delete_surrounding_chars_keeps_the_selection_and_ignores_a_no_op() { + let (mut s, id) = setup("ab你好cd"); + begin(&mut s, id, Some(2), false).unwrap(); + set_caret(&mut s, 4, true).unwrap(); // "你好" + assert!(delete_surrounding_chars(&mut s, 1, 1).unwrap()); + assert_eq!(data(&s, id).characters, "a你好d"); + assert_eq!(sel(&s), (1, 3), "the selection is kept, shifted by the deleted prefix"); + set_caret(&mut s, 0, false).unwrap(); + // A composition in progress survives a delete that finds nothing to remove. + preedit(&mut s, "x", None).unwrap(); + let revision = s.ds().unwrap().revision; + assert!(!delete_surrounding_chars(&mut s, 1, 0).unwrap()); + assert_eq!(s.ds().unwrap().text_edit.as_ref().and_then(|te| te.preedit.as_ref()).map(|p| p.text.as_str()), Some("x")); + assert_eq!(s.ds().unwrap().revision, revision, "a no-op does not bump the revision"); + assert!(!delete_surrounding_bytes(&mut s, 1, 0).unwrap()); + assert_eq!(s.ds().unwrap().text_edit.as_ref().and_then(|te| te.preedit.as_ref()).map(|p| p.text.as_str()), Some("x")); + assert_eq!(s.ds().unwrap().revision, revision); +} + +/// Asserts a text layer in another document has a different IME owner. +#[test] +fn ime_owner_identity_changes_with_the_document() { + let (mut s, id) = setup("Hi"); + begin(&mut s, id, Some(2), false).unwrap(); + let first = ime_surrounding(&s).unwrap().owner; + end(&mut s).unwrap(); + s.execute("file.new", &json!({"name": "Second"})).unwrap(); + let r = s.execute("text.create", &json!({"text": "There", "x": 0, "y": 0})).unwrap(); + let other = crate::params::id_of(&r["id"]).unwrap(); + begin(&mut s, other, Some(5), false).unwrap(); + let second = ime_surrounding(&s).unwrap().owner; + assert_ne!(second.document, first.document, "another document is another IME owner"); + assert_eq!(second.node, other); +} + +/// Asserts a space committed after a line-start `-` becomes a bulleted list and reports +/// `Transformed`; a plain `"x"` commit reports `Plain`, and an empty commit reports `Nothing`. +#[test] +fn ime_commit_reports_list_autoformat_as_an_application_transformation() { + let (mut s, id) = setup("-"); + begin(&mut s, id, Some(1), false).unwrap(); + assert_eq!(insert_ime_commit(&mut s, " ").unwrap(), ImeInsertion::Transformed); + assert_eq!(data(&s, id).characters, ""); + assert_eq!(data(&s, id).para_list(0).list, ListStyle::Bulleted); + assert_eq!(insert_ime_commit(&mut s, "x").unwrap(), ImeInsertion::Plain); + assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); +} + +/// Asserts CRLF normalization reports `Transformed`, control-only input reports `Nothing`, +/// and bare newline/CR commits are ignored while a live composition remains. +#[test] +fn ime_commit_normalisation_is_a_transformation_and_a_bare_newline_is_ignored() { + let (mut s, id) = setup("Hi"); + begin(&mut s, id, Some(2), false).unwrap(); + // CRLF is inserted as LF, but the editor changed the commit: application-origin. + assert_eq!(insert_ime_commit(&mut s, "a\r\nb").unwrap(), ImeInsertion::Transformed); + assert_eq!(data(&s, id).characters, "Hia\nb"); + // Control characters are dropped; the commit itself is a no-op. + assert_eq!(insert_ime_commit(&mut s, "\u{0}\u{7}").unwrap(), ImeInsertion::Nothing); + assert_eq!(data(&s, id).characters, "Hia\nb"); + // Stock `TextEdit` ignores a bare newline/CR commit; typing Enter still inserts one. + // (It also leaves a live composition alone, unlike the non-ignored commit below.) + preedit(&mut s, "x", None).unwrap(); + assert_eq!(insert_ime_commit(&mut s, "\n").unwrap(), ImeInsertion::Nothing); + assert_eq!(insert_ime_commit(&mut s, "\r").unwrap(), ImeInsertion::Nothing); + assert_eq!(preedit_state(&s).map(|p| p.text), Some("x".into())); + preedit(&mut s, "", None).unwrap(); + assert_eq!(data(&s, id).characters, "Hia\nb"); + insert(&mut s, "\n").unwrap(); + assert_eq!(data(&s, id).characters, "Hia\nb\n"); +} + +/// Asserts an empty or control-only IME commit ends a live composition (bumping the revision +/// once), inserts nothing, and is a harmless no-op when no composition is active. +#[test] +fn an_empty_ime_commit_ends_the_composition_without_inserting_text() { + let (mut s, id) = setup("Hello"); + begin(&mut s, id, Some(5), false).unwrap(); + preedit(&mut s, "にほ", None).unwrap(); + assert_eq!(preedit_state(&s).map(|p| p.text), Some("にほ".into())); + let revision = s.ds().unwrap().revision; + assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); + assert_eq!(s.ds().unwrap().revision, revision + 1, "ending a live composition bumps the revision"); + assert!(preedit_state(&s).is_none(), "an empty commit ends the composition"); + assert!(preedit_doc(&s).is_none()); + assert_eq!(data(&s, id).characters, "Hello", "and inserts nothing"); + + // A control-only commit normalises to empty and ends the composition too. + preedit(&mut s, "x", None).unwrap(); + let revision = s.ds().unwrap().revision; + assert_eq!(insert_ime_commit(&mut s, "\u{0}\u{7}").unwrap(), ImeInsertion::Nothing); + assert_eq!(s.ds().unwrap().revision, revision + 1, "clearing a live composition bumps the revision"); + assert!(preedit_state(&s).is_none()); + assert_eq!(data(&s, id).characters, "Hello"); + + // With no composition (and not even while editing) an empty commit stays a harmless no-op. + let revision = s.ds().unwrap().revision; + assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); + assert_eq!(s.ds().unwrap().revision, revision); + end(&mut s).unwrap(); + assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); +} From 096b96c699ff2e6f3fbf8617623900146675d905 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:15:15 +0000 Subject: [PATCH 09/67] refactor(ui-egui): move present.rs inline tests to crates/ui-egui/src/present/tests.rs --- crates/ui-egui/src/present.rs | 442 +--------------------------- crates/ui-egui/src/present/tests.rs | 438 +++++++++++++++++++++++++++ 2 files changed, 439 insertions(+), 441 deletions(-) create mode 100644 crates/ui-egui/src/present/tests.rs diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index 17825fbf..b5469e6e 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -2979,444 +2979,4 @@ mod delay_tests; mod overlay_anchor_tests; #[cfg(test)] -mod tests { - use super::*; - use figcraft_doc::NodeKind; - - use crate::present_tests::{Ui, id}; - - #[test] - fn device_picker_reflows_without_resizing_the_saved_frame_and_can_revert() { - let mut session = figcraft_engine::Session::new(); - let frame = id(&session.execute("node.create", &json!({"type": "frame", "width": 894, "height": 844})).unwrap()); - let right = id(&session - .execute("node.create", &json!({"type": "rectangle", "parent": frame, "x": 804, "y": 20, "width": 80, "height": 40})) - .unwrap()); - session.execute("node.set", &json!({"id": right, "props": {"constraints": {"horizontal": "right", "vertical": "top"}}})).unwrap(); - let mut ui = Ui::new(session); - ui.run("proto.present", json!({"start": frame})); - - let mut presenter = ui.app.ui.present.take().unwrap(); - presenter.pan = Some((100.0, 200.0)); - presenter.scroll.insert(frame, (75.0, 50.0)); - choose_device(&mut ui.app, &mut presenter, "iPhone 17"); - assert!(presenter.pan.is_none() && presenter.scroll.is_empty()); - ui.app.ui.present = Some(presenter); - ui.frame(vec![]); - - let shown = &ui.app.ui.present.as_ref().unwrap().doc.as_ref().unwrap(); - assert_eq!((shown.node(frame).unwrap().width, shown.node(frame).unwrap().height), (402.0, 874.0)); - assert_eq!(shown.world_bounds(right).unwrap().x0, 312.0, "right constraint follows the phone edge"); - assert_eq!(ui.app.inspect()["presenter"]["viewport"], json!([402.0, 874.0])); - assert_eq!(ui.app.inspect()["presenter"]["scrollRange"], json!([0.0, 0.0])); - assert_eq!(ui.app.session.doc().unwrap().node(frame).unwrap().width, 894.0, "the saved design keeps its geometry"); - - let area = Rect::from_min_max(pos2(0.0, 40.0), pos2(1440.0, 900.0)); - let screen_before = layout(ui.app.ui.present.as_mut().unwrap(), area, FULL).unwrap().screen; - ui.button(pos2(720.0, 440.0), true); - ui.frame(vec![egui::Event::PointerMoved(pos2(300.0, 300.0))]); - ui.button(pos2(300.0, 300.0), false); - assert_eq!(ui.app.inspect()["presenter"]["scroll"], json!([0.0, 0.0]), "fitted content does not move"); - assert_eq!(layout(ui.app.ui.present.as_mut().unwrap(), area, FULL).unwrap().screen, screen_before); - - let mut presenter = ui.app.ui.present.take().unwrap(); - choose_device(&mut ui.app, &mut presenter, "None"); - ui.app.ui.present = Some(presenter); - ui.frame(vec![]); - assert_eq!(ui.app.inspect()["presenter"]["frameSize"], json!([894.0, 844.0])); - assert_eq!(ui.app.inspect()["presenter"]["responsive"], Value::Null); - } - - #[test] - fn device_presets_show_their_screen_at_the_real_resolution_in_their_body() { - let mut session = figcraft_engine::Session::new(); - let frame = id(&session.execute("node.create", &json!({"type": "frame", "width": 894, "height": 844})).unwrap()); - let mut ui = Ui::new(session); - ui.run("proto.present", json!({"start": frame})); - let area = Rect::from_min_max(pos2(0.0, 40.0), pos2(1440.0, 860.0)); - - for (name, width, height) in - [("iPad mini", 744.0, 1133.0), ("MacBook Air", 1280.0, 832.0), ("Desktop", 1440.0, 1024.0), ("iPhone 17", 402.0, 874.0)] - { - let mut presenter = ui.app.ui.present.take().unwrap(); - choose_device(&mut ui.app, &mut presenter, name); - ui.app.ui.present = Some(presenter); - ui.frame(vec![]); - - let presenter = ui.app.ui.present.as_mut().unwrap(); - let laid = layout(presenter, area, FULL).unwrap(); - assert!(laid.color.is_some(), "{name}: drawn in its body"); - assert!((f64::from(laid.screen.width()) / laid.scale - width).abs() < 0.1, "{name}: width"); - assert!((f64::from(laid.screen.height()) / laid.scale - height).abs() < 0.1, "{name}: height"); - assert_eq!(ui.app.session.doc().unwrap().node(frame).unwrap().width, 894.0); - } - } - - /// A phone shows a frame's content past its screen only as far as the frame's overflow lets it - /// scroll: with "No scrolling" it's clipped, like on every other device. - #[test] - fn a_phone_scrolls_only_what_the_frames_overflow_allows() { - let on_phone = |overflow: &str| { - let mut session = figcraft_engine::Session::new(); - let frame = id(&session.execute("node.create", &json!({"type": "frame", "width": 894, "height": 844})).unwrap()); - session.execute("node.create", &json!({"type": "rectangle", "parent": frame, "x": 650, "y": 1050, "width": 80, "height": 80})).unwrap(); - session.execute("node.set", &json!({"id": frame, "props": {"overflow": overflow}})).unwrap(); - let mut ui = Ui::new(session); - ui.run("proto.present", json!({"start": frame})); - let mut presenter = ui.app.ui.present.take().unwrap(); - choose_device(&mut ui.app, &mut presenter, "iPhone 17"); - ui.app.ui.present = Some(presenter); - ui.frame(vec![]); - (ui, frame) - }; - let start = pos2(720.0, 440.0); - let drag = |ui: &mut Ui, to: Pos2| { - ui.button(start, true); - ui.frame(vec![egui::Event::PointerMoved(to)]); - ui.button(to, false); - ui.app.inspect()["presenter"].clone() - }; - - let (mut ui, frame) = on_phone("none"); - assert_eq!(ui.app.inspect()["presenter"]["scrollRange"], json!([0.0, 0.0])); - assert_eq!(drag(&mut ui, start - vec2(100.0, 120.0))["scroll"], json!([0.0, 0.0]), "no scrolling stays clipped"); - assert_eq!(ui.app.session.doc().unwrap().node(frame).unwrap().width, 894.0); - - let (mut ui, frame) = on_phone("both"); - let range = &ui.app.inspect()["presenter"]["scrollRange"]; - assert!(range[0].as_f64().unwrap() > 0.0 && range[1].as_f64().unwrap() > 0.0, "{range}"); - let scroll = drag(&mut ui, start - vec2(100.0, 120.0))["scroll"].clone(); - assert!(scroll[0].as_f64().unwrap() > 0.0 && scroll[1].as_f64().unwrap() > 0.0, "{scroll}"); - assert_eq!(ui.app.session.doc().unwrap().node(frame).unwrap().width, 894.0); - - // The content keeps following a drag after the pointer leaves the screen, while the - // device itself stays fixed on the stage. - ui.frames(60, Vec::new); - let area = Rect::from_min_max(pos2(0.0, 40.0), pos2(1440.0, 900.0)); - let screen_before = layout(ui.app.ui.present.as_mut().unwrap(), area, FULL).unwrap().screen; - ui.button(start, true); - ui.frame(vec![egui::Event::PointerMoved(pos2(300.0, 300.0))]); - let during = ui.app.inspect()["presenter"].clone(); - assert!(during["scroll"][0].as_f64().unwrap() > 0.0, "{during}"); - assert_eq!(layout(ui.app.ui.present.as_mut().unwrap(), area, FULL).unwrap().screen, screen_before); - ui.button(pos2(300.0, 300.0), false); - } - - fn doc_with_two_frames() -> (Document, NodeId, NodeId) { - let mut d = Document::new("t"); - let page = d.add_page("Page 1"); - let a = d.create(page, NodeKind::Frame, "A", 0.0, 0.0, 200.0, 400.0).unwrap(); - let b = d.create(page, NodeKind::Frame, "B", 300.0, 0.0, 200.0, 400.0).unwrap(); - (d, a, b) - } - - #[test] - fn closed_overlays_animate_out_unless_instant() { - let (_, a, b) = doc_with_two_frames(); - let mut p = Presenter::new(a); - let ov = |transition, opened| OverlayState { - id: b, - position: OverlayPosition::Center, - close_on_outside: true, - background: None, - anchor: None, - offset: None, - anchor_to: OverlayAnchor::Screen, - opened, - transition, - dur: 0.3, - easing: Easing::EaseOut, - }; - p.overlays.push(ov(Transition::Dissolve, 0.0)); - assert!(p.close_overlay(10.0)); - assert!(p.overlays.is_empty()); - assert_eq!(p.closing.len(), 1); - assert_eq!(p.closing[0].0, 10.0, "fully open: the reverse starts now"); - // Closed half way through opening: it reverses from there. - p.overlays.push(ov(Transition::Dissolve, 20.0)); - p.close_overlay(20.15); - assert!((p.closing[1].0 - 20.0).abs() < 1e-9); - // Instant overlays just go. - p.overlays.push(ov(Transition::Instant, 30.0)); - p.close_overlay(31.0); - assert_eq!(p.closing.len(), 2); - assert!(!p.close_overlay(32.0), "nothing left to close"); - } - - #[test] - fn matching_by_name_path() { - let (mut d, a, b) = doc_with_two_frames(); - let ga = d.create(a, NodeKind::Group, "Card", 0.0, 0.0, 10.0, 10.0).unwrap(); - let ra = d.create(ga, NodeKind::Rectangle, "Box", 0.0, 0.0, 10.0, 10.0).unwrap(); - let ra2 = d.create(ga, NodeKind::Rectangle, "Box", 0.0, 0.0, 10.0, 10.0).unwrap(); - let only_a = d.create(a, NodeKind::Rectangle, "Gone", 0.0, 0.0, 10.0, 10.0).unwrap(); - let gb = d.create(b, NodeKind::Group, "Card", 0.0, 0.0, 10.0, 10.0).unwrap(); - let rb = d.create(gb, NodeKind::Rectangle, "Box", 0.0, 0.0, 10.0, 10.0).unwrap(); - let rb2 = d.create(gb, NodeKind::Rectangle, "Box", 0.0, 0.0, 10.0, 10.0).unwrap(); - // Same name, different parent: no match. - let _other = d.create(b, NodeKind::Rectangle, "Box", 0.0, 0.0, 10.0, 10.0).unwrap(); - let m = match_layers(&d, a, &d, b); - assert_eq!(m, vec![(ga, gb), (ra, rb), (ra2, rb2)]); - assert!(!m.iter().any(|(f, _)| *f == only_a)); - assert_eq!(name_path(&d, a, ra).as_deref(), Some("Card\u{1f}Box")); - assert_eq!(name_path(&d, b, ra), None); - } - - #[test] - fn smart_interpolate_tweens_matched_and_fades_the_rest() { - let (mut d, a, b) = doc_with_two_frames(); - let ra = d.create(a, NodeKind::Rectangle, "Box", 0.0, 0.0, 20.0, 20.0).unwrap(); - let rb = d.create(b, NodeKind::Rectangle, "Box", 100.0, 200.0, 60.0, 40.0).unwrap(); - { - let n = d.get_mut(ra).unwrap(); - n.fills = vec![Paint::solid(Color::new(1.0, 0.0, 0.0, 1.0))]; - n.corners.radii = [0.0; 4]; - n.opacity = 1.0; - } - { - let n = d.get_mut(rb).unwrap(); - n.fills = vec![Paint::solid(Color::new(0.0, 0.0, 1.0, 1.0))]; - n.corners.radii = [20.0; 4]; - n.opacity = 0.5; - // Rotate 90° about its centre. - let c = (130.0, 220.0); - n.transform = Affine::translate(c) * Affine::rotate(std::f64::consts::FRAC_PI_2) * Affine::translate((-30.0, -20.0)); - } - let only_b = d.create(b, NodeKind::Rectangle, "New", 0.0, 0.0, 5.0, 5.0); - let only_a = d.create(a, NodeKind::Rectangle, "Old", 0.0, 0.0, 5.0, 5.0).unwrap(); - let mid = smart_interpolate(&d, a, &d, b, 0.5); - let n = mid.node(rb).unwrap(); - assert!((n.width - 40.0).abs() < 1e-9 && (n.height - 30.0).abs() < 1e-9); - assert!((n.opacity - 0.75).abs() < 1e-6); - assert!((n.corners.radii[0] - 10.0).abs() < 1e-9); - let c = n.fills[0].solid_color().unwrap(); - assert!((c.r - 0.5).abs() < 0.02 && (c.b - 0.5).abs() < 0.02, "{c:?}"); - // Centre halfway between (10, 10) and (130, 220) in frame space; rotated 45°. - let ctr = mid.world_transform(b).inverse() * mid.world_transform(rb) * Point::new(n.width / 2.0, n.height / 2.0); - assert!((ctr.x - 70.0).abs() < 1e-6 && (ctr.y - 115.0).abs() < 1e-6, "{ctr:?}"); - let [aa, bb, ..] = n.transform.as_coeffs(); - assert!((bb.atan2(aa) - std::f64::consts::FRAC_PI_4).abs() < 1e-6); - if let Ok(ob) = only_b { - assert!((mid.node(ob).unwrap().opacity - 0.5).abs() < 1e-6); - } - // The source-only layer is copied into the destination frame, half faded. - let copies: Vec<_> = mid.node(b).unwrap().children.iter().filter_map(|c| mid.node(*c)).filter(|n| n.name == "Old").collect(); - assert_eq!(copies.len(), 1); - assert!((copies[0].opacity - 0.5).abs() < 1e-6); - assert_ne!(copies[0].id, only_a); - // At the end it is exactly the destination. - let end = smart_interpolate(&d, a, &d, b, 1.0); - assert_eq!(end.node(rb), d.node(rb)); - assert_eq!(end.node(b).unwrap().children, d.node(b).unwrap().children); - // At the start the matched layer sits where the source was. - let start = smart_interpolate(&d, a, &d, b, 0.0); - let s = start.world_bounds(rb).unwrap(); - let f = start.world_bounds(b).unwrap(); - assert!((s.x0 - f.x0).abs() < 1e-6 && (s.y0 - f.y0).abs() < 1e-6 && (s.width() - 20.0).abs() < 1e-6); - } - - #[test] - fn scroll_offsets_fixed_and_sticky() { - let (mut d, a, _) = doc_with_two_frames(); - d.get_mut(a).unwrap().overflow = Overflow::Vertical; - let long = d.create(a, NodeKind::Rectangle, "Long", 0.0, 0.0, 200.0, 1000.0).unwrap(); - let fixed = d.create(a, NodeKind::Rectangle, "Bar", 0.0, 350.0, 200.0, 50.0).unwrap(); - let sticky = d.create(a, NodeKind::Rectangle, "Header", 0.0, 100.0, 200.0, 40.0).unwrap(); - d.get_mut(fixed).unwrap().scroll_position = ScrollPosition::Fixed; - d.get_mut(sticky).unwrap().scroll_position = ScrollPosition::Sticky; - assert_eq!(scroll_range(&d, a), (0.0, 600.0)); - d.get_mut(a).unwrap().overflow = Overflow::Horizontal; - assert_eq!(scroll_range(&d, a), (0.0, 0.0)); - d.get_mut(a).unwrap().overflow = Overflow::Vertical; - let mut s = HashMap::new(); - s.insert(a, (0.0, 50.0)); - let out = apply_scroll(&d, &s); - let y = |doc: &Document, id| doc.world_bounds(id).unwrap().y0; - assert_eq!(y(&out, long), -50.0); - assert_eq!(y(&out, fixed), 350.0); - assert_eq!(y(&out, sticky), 50.0); - s.insert(a, (0.0, 300.0)); - let out = apply_scroll(&d, &s); - assert_eq!(y(&out, long), -300.0); - assert_eq!(y(&out, sticky), 0.0); - } - - /// A page presented on an iPhone 16 (393×852): a tall screen A that scrolls vertically, with a - /// tab bar fixed to its bottom, a nested scrolling list and a layer at its end; screen B. - fn device_doc() -> (Document, NodeId, NodeId, NodeId, NodeId, NodeId) { - let mut d = Document::new("t"); - let page = d.add_page("Page 1"); - if let NodeKind::Page { prototype, .. } = &mut d.get_mut(page).unwrap().kind { - prototype.device = "iPhone 16".into(); - } - let a = d.create(page, NodeKind::Frame, "A", 0.0, 0.0, 393.0, 2000.0).unwrap(); - let b = d.create(page, NodeKind::Frame, "B", 500.0, 0.0, 393.0, 1500.0).unwrap(); - for f in [a, b] { - d.get_mut(f).unwrap().overflow = Overflow::Vertical; - } - let bar = d.create(a, NodeKind::Rectangle, "Tab bar", 0.0, 1940.0, 393.0, 60.0).unwrap(); - let n = d.get_mut(bar).unwrap(); - n.scroll_position = ScrollPosition::Fixed; - n.constraints.vertical = VConstraint::Bottom; - let list = d.create(a, NodeKind::Frame, "List", 0.0, 1200.0, 393.0, 300.0).unwrap(); - d.get_mut(list).unwrap().overflow = Overflow::Vertical; - let item = d.create(list, NodeKind::Rectangle, "Item", 0.0, 700.0, 393.0, 100.0).unwrap(); - (d, a, b, bar, list, item) - } - - fn player_on(d: Document, start: NodeId) -> Presenter { - let mut p = Presenter::new(start); - p.sync(&Arc::new(d), 0.0); - p - } - - #[test] - fn device_screen_clips_the_frame_and_it_scrolls_through_its_height() { - let (mut d, a, _, bar, list, _) = device_doc(); - assert_eq!(viewport(&d, a), Some((393.0, 852.0))); - // On the device the whole frame scrolls; at its own size nothing overflows it. - assert_eq!(scroll_range_in(&d, a, viewport(&d, a)), (0.0, 1148.0)); - assert_eq!(scroll_range(&d, a), (0.0, 0.0)); - // Nested frames scroll within themselves. - assert_eq!(scroll_range(&d, list), (0.0, 500.0)); - // The display copy is sized to the screen; the fixed tab bar moves to the screen's bottom. - let mut p = player_on(d.clone(), a); - let disp = p.display().unwrap(); - assert_eq!((disp.node(a).unwrap().width, disp.node(a).unwrap().height), (393.0, 852.0)); - assert_eq!(disp.world_bounds(bar).unwrap().y0, 792.0); - // Scrolled, it stays there while the content moves. - p.scroll_to(0.0, 400.0); - let disp = p.display().unwrap(); - assert_eq!(disp.world_bounds(bar).unwrap().y0, 792.0); - assert_eq!(disp.world_bounds(list).unwrap().y0, 800.0); - p.scroll_to(0.0, 99_999.0); - assert_eq!(p.scroll[&a], (0.0, 1148.0), "clamped to the range"); - // A top-level frame taller than the screen scrolls without an overflow setting (its own - // range still clips nested content); without a device the frame shows whole. - d.get_mut(a).unwrap().overflow = Overflow::None; - assert_eq!(scroll_range_in(&d, a, viewport(&d, a)), (0.0, 1148.0)); - assert_eq!(scroll_range(&d, a), (0.0, 0.0)); - if let Some(pg) = d.page_of(a) - && let NodeKind::Page { prototype, .. } = &mut d.get_mut(pg).unwrap().kind - { - prototype.device = "None".into(); - } - assert_eq!(viewport(&d, a), None); - // A frame wider than the screen scrolls sideways when it scrolls both ways. - let (mut d, a, ..) = device_doc(); - let n = d.get_mut(a).unwrap(); - n.width = 800.0; - n.overflow = Overflow::Both; - assert_eq!(scroll_range_in(&d, a, viewport(&d, a)), (407.0, 1148.0)); - } - - #[test] - fn scroll_to_moves_every_scrolling_frame_around_the_layer() { - let (d, a, _, _, list, item) = device_doc(); - let mut p = player_on(d.clone(), a); - assert!(p.perform(&Action::ScrollTo { dest: item, offset: [0.0, 0.0] }, item, Transition::Instant, 0.0, Easing::Linear, None, 1.0)); - // The list brings the item to its top (as far as it scrolls), the screen brings the list. - assert_eq!(p.scroll[&list], (0.0, 500.0)); - assert_eq!(p.scroll[&a], (0.0, 1148.0)); - // Animated, it gets there over the duration. - let mut p = player_on(d, a); - assert!(p.perform(&Action::ScrollTo { dest: list, offset: [0.0, 0.0] }, list, Transition::Dissolve, 0.3, Easing::Linear, None, 1.0)); - let anim = p.scroll_anim.as_ref().unwrap(); - assert_eq!(anim.frames, vec![(a, (0.0, 0.0), (0.0, 1148.0))]); - } - - #[test] - fn navigating_resets_scroll_unless_it_preserves_it() { - let (d, a, b, ..) = device_doc(); - let mut p = player_on(d, a); - p.scroll_to(0.0, 600.0); - assert!(p.perform(&Action::Navigate { dest: b, preserve_scroll: false }, a, Transition::Instant, 0.0, Easing::Linear, None, 1.0)); - assert_eq!(p.scroll.get(&b), None, "starts at the top"); - p.restart(a, 2.0); - p.scroll_to(0.0, 1000.0); - assert!(p.perform(&Action::Navigate { dest: b, preserve_scroll: true }, a, Transition::Instant, 0.0, Easing::Linear, None, 3.0)); - assert_eq!(p.scroll[&b], (0.0, 648.0), "kept, as far as B scrolls"); - // Back starts at the top again. - assert!(p.perform(&Action::Back, b, Transition::Instant, 0.0, Easing::Linear, None, 4.0)); - assert_eq!((p.current, p.scroll.get(&a)), (a, None)); - } - - #[test] - fn sync_follows_edits_and_keeps_the_screen() { - let (mut d, a, b, _, list, _) = device_doc(); - let mut p = player_on(d.clone(), a); - p.go(b, Transition::Instant, 0.0, Easing::Linear, 1.0, true, false); - p.scroll_to(0.0, 600.0); - // The same document again changes nothing. - let same = Arc::new(d.clone()); - p.sync(&same, 2.0); - let rev = p.rev; - p.sync(&same, 2.0); - assert_eq!(p.rev, rev); - // An edit is copied; the screen and its scroll stay (clamped to the new range). - d.get_mut(b).unwrap().height = 1000.0; - d.get_mut(b).unwrap().name = "B2".into(); - p.sync(&Arc::new(d.clone()), 3.0); - assert_eq!(p.current, b); - assert_eq!(p.inspect()["name"], "B2"); - assert_eq!(p.scroll[&b], (0.0, 148.0)); - assert_eq!(p.history.len(), 1); - // The screen deleted: back to the start. - d.remove(b).unwrap(); - p.sync(&Arc::new(d.clone()), 4.0); - assert_eq!(p.current, a); - assert!(p.history.is_empty() && p.scroll.is_empty()); - // Junk scroll offsets never panic and stay in range. - p.scroll.insert(list, (f64::NAN, f64::INFINITY)); - p.sync(&Arc::new(d), 5.0); - assert_eq!(p.scroll[&list], (0.0, 0.0)); - } - - #[test] - fn transitions_move_the_right_way() { - let left = transition_frames(Transition::MoveIn { direction: Direction::Left }, 0.0); - assert_eq!(left.to_off, (1.0, 0.0)); - assert_eq!(transition_frames(Transition::MoveIn { direction: Direction::Left }, 1.0).to_off, (0.0, 0.0)); - let push = transition_frames(Transition::Push { direction: Direction::Top }, 0.5); - assert_eq!(push.from_off, (0.0, -0.5)); - assert_eq!(push.to_off, (0.0, 0.5)); - let out = transition_frames(Transition::MoveOut { direction: Direction::Right }, 0.25); - assert!(out.from_on_top); - assert_eq!(out.from_off, (0.25, 0.0)); - assert_eq!(transition_frames(Transition::Dissolve, 0.3).to_alpha, 0.3); - assert_eq!(transition_frames(Transition::Instant, 0.0).from_alpha, 0.0); - let r = Transition::SlideIn { direction: Direction::Bottom }.reversed(); - assert_eq!(r, Transition::SlideOut { direction: Direction::Top }); - } - - #[test] - fn overlay_positions() { - let base = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 800.0)); - let size = vec2(200.0, 100.0); - assert_eq!(overlay_rect(OverlayPosition::Center, base, size, None, None).min, pos2(100.0, 350.0)); - assert_eq!(overlay_rect(OverlayPosition::BottomCenter, base, size, None, None).min, pos2(100.0, 700.0)); - assert_eq!(overlay_rect(OverlayPosition::TopRight, base, size, None, None).min, pos2(200.0, 0.0)); - let anchor = Rect::from_min_size(pos2(30.0, 40.0), vec2(50.0, 20.0)); - assert_eq!(overlay_rect(OverlayPosition::Manual, base, size, Some(anchor), None).min, pos2(30.0, 60.0)); - } - - /// Smart animate renders a temporary document per animation frame; keep that cheap. - #[test] - fn smart_animate_frame_cost() { - let (mut d, a, b) = doc_with_two_frames(); - for (f, dx) in [(a, 0.0), (b, 40.0)] { - for i in 0..40 { - let r = d.create(f, NodeKind::Rectangle, &format!("Row {i}"), dx + 10.0, 10.0 + f64::from(i) * 9.0, 120.0, 8.0).unwrap(); - d.get_mut(r).unwrap().corners.radii = [4.0; 4]; - } - } - let t0 = std::time::Instant::now(); - let n = 10; - for k in 0..n { - let tmp = smart_interpolate(&d, a, &d, b, f64::from(k) / f64::from(n)); - assert!(figcraft_render::render_node(&tmp, b, 2.0).is_some()); - } - let per = t0.elapsed().as_secs_f64() * 1000.0 / f64::from(n); - eprintln!("smart animate: {per:.2} ms per frame (80 layers, 400x800 px)"); - } -} +mod tests; diff --git a/crates/ui-egui/src/present/tests.rs b/crates/ui-egui/src/present/tests.rs new file mode 100644 index 00000000..bba99878 --- /dev/null +++ b/crates/ui-egui/src/present/tests.rs @@ -0,0 +1,438 @@ +use super::*; +use figcraft_doc::NodeKind; + +use crate::present_tests::{Ui, id}; + +#[test] +fn device_picker_reflows_without_resizing_the_saved_frame_and_can_revert() { + let mut session = figcraft_engine::Session::new(); + let frame = id(&session.execute("node.create", &json!({"type": "frame", "width": 894, "height": 844})).unwrap()); + let right = + id(&session.execute("node.create", &json!({"type": "rectangle", "parent": frame, "x": 804, "y": 20, "width": 80, "height": 40})).unwrap()); + session.execute("node.set", &json!({"id": right, "props": {"constraints": {"horizontal": "right", "vertical": "top"}}})).unwrap(); + let mut ui = Ui::new(session); + ui.run("proto.present", json!({"start": frame})); + + let mut presenter = ui.app.ui.present.take().unwrap(); + presenter.pan = Some((100.0, 200.0)); + presenter.scroll.insert(frame, (75.0, 50.0)); + choose_device(&mut ui.app, &mut presenter, "iPhone 17"); + assert!(presenter.pan.is_none() && presenter.scroll.is_empty()); + ui.app.ui.present = Some(presenter); + ui.frame(vec![]); + + let shown = &ui.app.ui.present.as_ref().unwrap().doc.as_ref().unwrap(); + assert_eq!((shown.node(frame).unwrap().width, shown.node(frame).unwrap().height), (402.0, 874.0)); + assert_eq!(shown.world_bounds(right).unwrap().x0, 312.0, "right constraint follows the phone edge"); + assert_eq!(ui.app.inspect()["presenter"]["viewport"], json!([402.0, 874.0])); + assert_eq!(ui.app.inspect()["presenter"]["scrollRange"], json!([0.0, 0.0])); + assert_eq!(ui.app.session.doc().unwrap().node(frame).unwrap().width, 894.0, "the saved design keeps its geometry"); + + let area = Rect::from_min_max(pos2(0.0, 40.0), pos2(1440.0, 900.0)); + let screen_before = layout(ui.app.ui.present.as_mut().unwrap(), area, FULL).unwrap().screen; + ui.button(pos2(720.0, 440.0), true); + ui.frame(vec![egui::Event::PointerMoved(pos2(300.0, 300.0))]); + ui.button(pos2(300.0, 300.0), false); + assert_eq!(ui.app.inspect()["presenter"]["scroll"], json!([0.0, 0.0]), "fitted content does not move"); + assert_eq!(layout(ui.app.ui.present.as_mut().unwrap(), area, FULL).unwrap().screen, screen_before); + + let mut presenter = ui.app.ui.present.take().unwrap(); + choose_device(&mut ui.app, &mut presenter, "None"); + ui.app.ui.present = Some(presenter); + ui.frame(vec![]); + assert_eq!(ui.app.inspect()["presenter"]["frameSize"], json!([894.0, 844.0])); + assert_eq!(ui.app.inspect()["presenter"]["responsive"], Value::Null); +} + +#[test] +fn device_presets_show_their_screen_at_the_real_resolution_in_their_body() { + let mut session = figcraft_engine::Session::new(); + let frame = id(&session.execute("node.create", &json!({"type": "frame", "width": 894, "height": 844})).unwrap()); + let mut ui = Ui::new(session); + ui.run("proto.present", json!({"start": frame})); + let area = Rect::from_min_max(pos2(0.0, 40.0), pos2(1440.0, 860.0)); + + for (name, width, height) in + [("iPad mini", 744.0, 1133.0), ("MacBook Air", 1280.0, 832.0), ("Desktop", 1440.0, 1024.0), ("iPhone 17", 402.0, 874.0)] + { + let mut presenter = ui.app.ui.present.take().unwrap(); + choose_device(&mut ui.app, &mut presenter, name); + ui.app.ui.present = Some(presenter); + ui.frame(vec![]); + + let presenter = ui.app.ui.present.as_mut().unwrap(); + let laid = layout(presenter, area, FULL).unwrap(); + assert!(laid.color.is_some(), "{name}: drawn in its body"); + assert!((f64::from(laid.screen.width()) / laid.scale - width).abs() < 0.1, "{name}: width"); + assert!((f64::from(laid.screen.height()) / laid.scale - height).abs() < 0.1, "{name}: height"); + assert_eq!(ui.app.session.doc().unwrap().node(frame).unwrap().width, 894.0); + } +} + +/// A phone shows a frame's content past its screen only as far as the frame's overflow lets it +/// scroll: with "No scrolling" it's clipped, like on every other device. +#[test] +fn a_phone_scrolls_only_what_the_frames_overflow_allows() { + let on_phone = |overflow: &str| { + let mut session = figcraft_engine::Session::new(); + let frame = id(&session.execute("node.create", &json!({"type": "frame", "width": 894, "height": 844})).unwrap()); + session.execute("node.create", &json!({"type": "rectangle", "parent": frame, "x": 650, "y": 1050, "width": 80, "height": 80})).unwrap(); + session.execute("node.set", &json!({"id": frame, "props": {"overflow": overflow}})).unwrap(); + let mut ui = Ui::new(session); + ui.run("proto.present", json!({"start": frame})); + let mut presenter = ui.app.ui.present.take().unwrap(); + choose_device(&mut ui.app, &mut presenter, "iPhone 17"); + ui.app.ui.present = Some(presenter); + ui.frame(vec![]); + (ui, frame) + }; + let start = pos2(720.0, 440.0); + let drag = |ui: &mut Ui, to: Pos2| { + ui.button(start, true); + ui.frame(vec![egui::Event::PointerMoved(to)]); + ui.button(to, false); + ui.app.inspect()["presenter"].clone() + }; + + let (mut ui, frame) = on_phone("none"); + assert_eq!(ui.app.inspect()["presenter"]["scrollRange"], json!([0.0, 0.0])); + assert_eq!(drag(&mut ui, start - vec2(100.0, 120.0))["scroll"], json!([0.0, 0.0]), "no scrolling stays clipped"); + assert_eq!(ui.app.session.doc().unwrap().node(frame).unwrap().width, 894.0); + + let (mut ui, frame) = on_phone("both"); + let range = &ui.app.inspect()["presenter"]["scrollRange"]; + assert!(range[0].as_f64().unwrap() > 0.0 && range[1].as_f64().unwrap() > 0.0, "{range}"); + let scroll = drag(&mut ui, start - vec2(100.0, 120.0))["scroll"].clone(); + assert!(scroll[0].as_f64().unwrap() > 0.0 && scroll[1].as_f64().unwrap() > 0.0, "{scroll}"); + assert_eq!(ui.app.session.doc().unwrap().node(frame).unwrap().width, 894.0); + + // The content keeps following a drag after the pointer leaves the screen, while the + // device itself stays fixed on the stage. + ui.frames(60, Vec::new); + let area = Rect::from_min_max(pos2(0.0, 40.0), pos2(1440.0, 900.0)); + let screen_before = layout(ui.app.ui.present.as_mut().unwrap(), area, FULL).unwrap().screen; + ui.button(start, true); + ui.frame(vec![egui::Event::PointerMoved(pos2(300.0, 300.0))]); + let during = ui.app.inspect()["presenter"].clone(); + assert!(during["scroll"][0].as_f64().unwrap() > 0.0, "{during}"); + assert_eq!(layout(ui.app.ui.present.as_mut().unwrap(), area, FULL).unwrap().screen, screen_before); + ui.button(pos2(300.0, 300.0), false); +} + +fn doc_with_two_frames() -> (Document, NodeId, NodeId) { + let mut d = Document::new("t"); + let page = d.add_page("Page 1"); + let a = d.create(page, NodeKind::Frame, "A", 0.0, 0.0, 200.0, 400.0).unwrap(); + let b = d.create(page, NodeKind::Frame, "B", 300.0, 0.0, 200.0, 400.0).unwrap(); + (d, a, b) +} + +#[test] +fn closed_overlays_animate_out_unless_instant() { + let (_, a, b) = doc_with_two_frames(); + let mut p = Presenter::new(a); + let ov = |transition, opened| OverlayState { + id: b, + position: OverlayPosition::Center, + close_on_outside: true, + background: None, + anchor: None, + offset: None, + anchor_to: OverlayAnchor::Screen, + opened, + transition, + dur: 0.3, + easing: Easing::EaseOut, + }; + p.overlays.push(ov(Transition::Dissolve, 0.0)); + assert!(p.close_overlay(10.0)); + assert!(p.overlays.is_empty()); + assert_eq!(p.closing.len(), 1); + assert_eq!(p.closing[0].0, 10.0, "fully open: the reverse starts now"); + // Closed half way through opening: it reverses from there. + p.overlays.push(ov(Transition::Dissolve, 20.0)); + p.close_overlay(20.15); + assert!((p.closing[1].0 - 20.0).abs() < 1e-9); + // Instant overlays just go. + p.overlays.push(ov(Transition::Instant, 30.0)); + p.close_overlay(31.0); + assert_eq!(p.closing.len(), 2); + assert!(!p.close_overlay(32.0), "nothing left to close"); +} + +#[test] +fn matching_by_name_path() { + let (mut d, a, b) = doc_with_two_frames(); + let ga = d.create(a, NodeKind::Group, "Card", 0.0, 0.0, 10.0, 10.0).unwrap(); + let ra = d.create(ga, NodeKind::Rectangle, "Box", 0.0, 0.0, 10.0, 10.0).unwrap(); + let ra2 = d.create(ga, NodeKind::Rectangle, "Box", 0.0, 0.0, 10.0, 10.0).unwrap(); + let only_a = d.create(a, NodeKind::Rectangle, "Gone", 0.0, 0.0, 10.0, 10.0).unwrap(); + let gb = d.create(b, NodeKind::Group, "Card", 0.0, 0.0, 10.0, 10.0).unwrap(); + let rb = d.create(gb, NodeKind::Rectangle, "Box", 0.0, 0.0, 10.0, 10.0).unwrap(); + let rb2 = d.create(gb, NodeKind::Rectangle, "Box", 0.0, 0.0, 10.0, 10.0).unwrap(); + // Same name, different parent: no match. + let _other = d.create(b, NodeKind::Rectangle, "Box", 0.0, 0.0, 10.0, 10.0).unwrap(); + let m = match_layers(&d, a, &d, b); + assert_eq!(m, vec![(ga, gb), (ra, rb), (ra2, rb2)]); + assert!(!m.iter().any(|(f, _)| *f == only_a)); + assert_eq!(name_path(&d, a, ra).as_deref(), Some("Card\u{1f}Box")); + assert_eq!(name_path(&d, b, ra), None); +} + +#[test] +fn smart_interpolate_tweens_matched_and_fades_the_rest() { + let (mut d, a, b) = doc_with_two_frames(); + let ra = d.create(a, NodeKind::Rectangle, "Box", 0.0, 0.0, 20.0, 20.0).unwrap(); + let rb = d.create(b, NodeKind::Rectangle, "Box", 100.0, 200.0, 60.0, 40.0).unwrap(); + { + let n = d.get_mut(ra).unwrap(); + n.fills = vec![Paint::solid(Color::new(1.0, 0.0, 0.0, 1.0))]; + n.corners.radii = [0.0; 4]; + n.opacity = 1.0; + } + { + let n = d.get_mut(rb).unwrap(); + n.fills = vec![Paint::solid(Color::new(0.0, 0.0, 1.0, 1.0))]; + n.corners.radii = [20.0; 4]; + n.opacity = 0.5; + // Rotate 90° about its centre. + let c = (130.0, 220.0); + n.transform = Affine::translate(c) * Affine::rotate(std::f64::consts::FRAC_PI_2) * Affine::translate((-30.0, -20.0)); + } + let only_b = d.create(b, NodeKind::Rectangle, "New", 0.0, 0.0, 5.0, 5.0); + let only_a = d.create(a, NodeKind::Rectangle, "Old", 0.0, 0.0, 5.0, 5.0).unwrap(); + let mid = smart_interpolate(&d, a, &d, b, 0.5); + let n = mid.node(rb).unwrap(); + assert!((n.width - 40.0).abs() < 1e-9 && (n.height - 30.0).abs() < 1e-9); + assert!((n.opacity - 0.75).abs() < 1e-6); + assert!((n.corners.radii[0] - 10.0).abs() < 1e-9); + let c = n.fills[0].solid_color().unwrap(); + assert!((c.r - 0.5).abs() < 0.02 && (c.b - 0.5).abs() < 0.02, "{c:?}"); + // Centre halfway between (10, 10) and (130, 220) in frame space; rotated 45°. + let ctr = mid.world_transform(b).inverse() * mid.world_transform(rb) * Point::new(n.width / 2.0, n.height / 2.0); + assert!((ctr.x - 70.0).abs() < 1e-6 && (ctr.y - 115.0).abs() < 1e-6, "{ctr:?}"); + let [aa, bb, ..] = n.transform.as_coeffs(); + assert!((bb.atan2(aa) - std::f64::consts::FRAC_PI_4).abs() < 1e-6); + if let Ok(ob) = only_b { + assert!((mid.node(ob).unwrap().opacity - 0.5).abs() < 1e-6); + } + // The source-only layer is copied into the destination frame, half faded. + let copies: Vec<_> = mid.node(b).unwrap().children.iter().filter_map(|c| mid.node(*c)).filter(|n| n.name == "Old").collect(); + assert_eq!(copies.len(), 1); + assert!((copies[0].opacity - 0.5).abs() < 1e-6); + assert_ne!(copies[0].id, only_a); + // At the end it is exactly the destination. + let end = smart_interpolate(&d, a, &d, b, 1.0); + assert_eq!(end.node(rb), d.node(rb)); + assert_eq!(end.node(b).unwrap().children, d.node(b).unwrap().children); + // At the start the matched layer sits where the source was. + let start = smart_interpolate(&d, a, &d, b, 0.0); + let s = start.world_bounds(rb).unwrap(); + let f = start.world_bounds(b).unwrap(); + assert!((s.x0 - f.x0).abs() < 1e-6 && (s.y0 - f.y0).abs() < 1e-6 && (s.width() - 20.0).abs() < 1e-6); +} + +#[test] +fn scroll_offsets_fixed_and_sticky() { + let (mut d, a, _) = doc_with_two_frames(); + d.get_mut(a).unwrap().overflow = Overflow::Vertical; + let long = d.create(a, NodeKind::Rectangle, "Long", 0.0, 0.0, 200.0, 1000.0).unwrap(); + let fixed = d.create(a, NodeKind::Rectangle, "Bar", 0.0, 350.0, 200.0, 50.0).unwrap(); + let sticky = d.create(a, NodeKind::Rectangle, "Header", 0.0, 100.0, 200.0, 40.0).unwrap(); + d.get_mut(fixed).unwrap().scroll_position = ScrollPosition::Fixed; + d.get_mut(sticky).unwrap().scroll_position = ScrollPosition::Sticky; + assert_eq!(scroll_range(&d, a), (0.0, 600.0)); + d.get_mut(a).unwrap().overflow = Overflow::Horizontal; + assert_eq!(scroll_range(&d, a), (0.0, 0.0)); + d.get_mut(a).unwrap().overflow = Overflow::Vertical; + let mut s = HashMap::new(); + s.insert(a, (0.0, 50.0)); + let out = apply_scroll(&d, &s); + let y = |doc: &Document, id| doc.world_bounds(id).unwrap().y0; + assert_eq!(y(&out, long), -50.0); + assert_eq!(y(&out, fixed), 350.0); + assert_eq!(y(&out, sticky), 50.0); + s.insert(a, (0.0, 300.0)); + let out = apply_scroll(&d, &s); + assert_eq!(y(&out, long), -300.0); + assert_eq!(y(&out, sticky), 0.0); +} + +/// A page presented on an iPhone 16 (393×852): a tall screen A that scrolls vertically, with a +/// tab bar fixed to its bottom, a nested scrolling list and a layer at its end; screen B. +fn device_doc() -> (Document, NodeId, NodeId, NodeId, NodeId, NodeId) { + let mut d = Document::new("t"); + let page = d.add_page("Page 1"); + if let NodeKind::Page { prototype, .. } = &mut d.get_mut(page).unwrap().kind { + prototype.device = "iPhone 16".into(); + } + let a = d.create(page, NodeKind::Frame, "A", 0.0, 0.0, 393.0, 2000.0).unwrap(); + let b = d.create(page, NodeKind::Frame, "B", 500.0, 0.0, 393.0, 1500.0).unwrap(); + for f in [a, b] { + d.get_mut(f).unwrap().overflow = Overflow::Vertical; + } + let bar = d.create(a, NodeKind::Rectangle, "Tab bar", 0.0, 1940.0, 393.0, 60.0).unwrap(); + let n = d.get_mut(bar).unwrap(); + n.scroll_position = ScrollPosition::Fixed; + n.constraints.vertical = VConstraint::Bottom; + let list = d.create(a, NodeKind::Frame, "List", 0.0, 1200.0, 393.0, 300.0).unwrap(); + d.get_mut(list).unwrap().overflow = Overflow::Vertical; + let item = d.create(list, NodeKind::Rectangle, "Item", 0.0, 700.0, 393.0, 100.0).unwrap(); + (d, a, b, bar, list, item) +} + +fn player_on(d: Document, start: NodeId) -> Presenter { + let mut p = Presenter::new(start); + p.sync(&Arc::new(d), 0.0); + p +} + +#[test] +fn device_screen_clips_the_frame_and_it_scrolls_through_its_height() { + let (mut d, a, _, bar, list, _) = device_doc(); + assert_eq!(viewport(&d, a), Some((393.0, 852.0))); + // On the device the whole frame scrolls; at its own size nothing overflows it. + assert_eq!(scroll_range_in(&d, a, viewport(&d, a)), (0.0, 1148.0)); + assert_eq!(scroll_range(&d, a), (0.0, 0.0)); + // Nested frames scroll within themselves. + assert_eq!(scroll_range(&d, list), (0.0, 500.0)); + // The display copy is sized to the screen; the fixed tab bar moves to the screen's bottom. + let mut p = player_on(d.clone(), a); + let disp = p.display().unwrap(); + assert_eq!((disp.node(a).unwrap().width, disp.node(a).unwrap().height), (393.0, 852.0)); + assert_eq!(disp.world_bounds(bar).unwrap().y0, 792.0); + // Scrolled, it stays there while the content moves. + p.scroll_to(0.0, 400.0); + let disp = p.display().unwrap(); + assert_eq!(disp.world_bounds(bar).unwrap().y0, 792.0); + assert_eq!(disp.world_bounds(list).unwrap().y0, 800.0); + p.scroll_to(0.0, 99_999.0); + assert_eq!(p.scroll[&a], (0.0, 1148.0), "clamped to the range"); + // A top-level frame taller than the screen scrolls without an overflow setting (its own + // range still clips nested content); without a device the frame shows whole. + d.get_mut(a).unwrap().overflow = Overflow::None; + assert_eq!(scroll_range_in(&d, a, viewport(&d, a)), (0.0, 1148.0)); + assert_eq!(scroll_range(&d, a), (0.0, 0.0)); + if let Some(pg) = d.page_of(a) + && let NodeKind::Page { prototype, .. } = &mut d.get_mut(pg).unwrap().kind + { + prototype.device = "None".into(); + } + assert_eq!(viewport(&d, a), None); + // A frame wider than the screen scrolls sideways when it scrolls both ways. + let (mut d, a, ..) = device_doc(); + let n = d.get_mut(a).unwrap(); + n.width = 800.0; + n.overflow = Overflow::Both; + assert_eq!(scroll_range_in(&d, a, viewport(&d, a)), (407.0, 1148.0)); +} + +#[test] +fn scroll_to_moves_every_scrolling_frame_around_the_layer() { + let (d, a, _, _, list, item) = device_doc(); + let mut p = player_on(d.clone(), a); + assert!(p.perform(&Action::ScrollTo { dest: item, offset: [0.0, 0.0] }, item, Transition::Instant, 0.0, Easing::Linear, None, 1.0)); + // The list brings the item to its top (as far as it scrolls), the screen brings the list. + assert_eq!(p.scroll[&list], (0.0, 500.0)); + assert_eq!(p.scroll[&a], (0.0, 1148.0)); + // Animated, it gets there over the duration. + let mut p = player_on(d, a); + assert!(p.perform(&Action::ScrollTo { dest: list, offset: [0.0, 0.0] }, list, Transition::Dissolve, 0.3, Easing::Linear, None, 1.0)); + let anim = p.scroll_anim.as_ref().unwrap(); + assert_eq!(anim.frames, vec![(a, (0.0, 0.0), (0.0, 1148.0))]); +} + +#[test] +fn navigating_resets_scroll_unless_it_preserves_it() { + let (d, a, b, ..) = device_doc(); + let mut p = player_on(d, a); + p.scroll_to(0.0, 600.0); + assert!(p.perform(&Action::Navigate { dest: b, preserve_scroll: false }, a, Transition::Instant, 0.0, Easing::Linear, None, 1.0)); + assert_eq!(p.scroll.get(&b), None, "starts at the top"); + p.restart(a, 2.0); + p.scroll_to(0.0, 1000.0); + assert!(p.perform(&Action::Navigate { dest: b, preserve_scroll: true }, a, Transition::Instant, 0.0, Easing::Linear, None, 3.0)); + assert_eq!(p.scroll[&b], (0.0, 648.0), "kept, as far as B scrolls"); + // Back starts at the top again. + assert!(p.perform(&Action::Back, b, Transition::Instant, 0.0, Easing::Linear, None, 4.0)); + assert_eq!((p.current, p.scroll.get(&a)), (a, None)); +} + +#[test] +fn sync_follows_edits_and_keeps_the_screen() { + let (mut d, a, b, _, list, _) = device_doc(); + let mut p = player_on(d.clone(), a); + p.go(b, Transition::Instant, 0.0, Easing::Linear, 1.0, true, false); + p.scroll_to(0.0, 600.0); + // The same document again changes nothing. + let same = Arc::new(d.clone()); + p.sync(&same, 2.0); + let rev = p.rev; + p.sync(&same, 2.0); + assert_eq!(p.rev, rev); + // An edit is copied; the screen and its scroll stay (clamped to the new range). + d.get_mut(b).unwrap().height = 1000.0; + d.get_mut(b).unwrap().name = "B2".into(); + p.sync(&Arc::new(d.clone()), 3.0); + assert_eq!(p.current, b); + assert_eq!(p.inspect()["name"], "B2"); + assert_eq!(p.scroll[&b], (0.0, 148.0)); + assert_eq!(p.history.len(), 1); + // The screen deleted: back to the start. + d.remove(b).unwrap(); + p.sync(&Arc::new(d.clone()), 4.0); + assert_eq!(p.current, a); + assert!(p.history.is_empty() && p.scroll.is_empty()); + // Junk scroll offsets never panic and stay in range. + p.scroll.insert(list, (f64::NAN, f64::INFINITY)); + p.sync(&Arc::new(d), 5.0); + assert_eq!(p.scroll[&list], (0.0, 0.0)); +} + +#[test] +fn transitions_move_the_right_way() { + let left = transition_frames(Transition::MoveIn { direction: Direction::Left }, 0.0); + assert_eq!(left.to_off, (1.0, 0.0)); + assert_eq!(transition_frames(Transition::MoveIn { direction: Direction::Left }, 1.0).to_off, (0.0, 0.0)); + let push = transition_frames(Transition::Push { direction: Direction::Top }, 0.5); + assert_eq!(push.from_off, (0.0, -0.5)); + assert_eq!(push.to_off, (0.0, 0.5)); + let out = transition_frames(Transition::MoveOut { direction: Direction::Right }, 0.25); + assert!(out.from_on_top); + assert_eq!(out.from_off, (0.25, 0.0)); + assert_eq!(transition_frames(Transition::Dissolve, 0.3).to_alpha, 0.3); + assert_eq!(transition_frames(Transition::Instant, 0.0).from_alpha, 0.0); + let r = Transition::SlideIn { direction: Direction::Bottom }.reversed(); + assert_eq!(r, Transition::SlideOut { direction: Direction::Top }); +} + +#[test] +fn overlay_positions() { + let base = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 800.0)); + let size = vec2(200.0, 100.0); + assert_eq!(overlay_rect(OverlayPosition::Center, base, size, None, None).min, pos2(100.0, 350.0)); + assert_eq!(overlay_rect(OverlayPosition::BottomCenter, base, size, None, None).min, pos2(100.0, 700.0)); + assert_eq!(overlay_rect(OverlayPosition::TopRight, base, size, None, None).min, pos2(200.0, 0.0)); + let anchor = Rect::from_min_size(pos2(30.0, 40.0), vec2(50.0, 20.0)); + assert_eq!(overlay_rect(OverlayPosition::Manual, base, size, Some(anchor), None).min, pos2(30.0, 60.0)); +} + +/// Smart animate renders a temporary document per animation frame; keep that cheap. +#[test] +fn smart_animate_frame_cost() { + let (mut d, a, b) = doc_with_two_frames(); + for (f, dx) in [(a, 0.0), (b, 40.0)] { + for i in 0..40 { + let r = d.create(f, NodeKind::Rectangle, &format!("Row {i}"), dx + 10.0, 10.0 + f64::from(i) * 9.0, 120.0, 8.0).unwrap(); + d.get_mut(r).unwrap().corners.radii = [4.0; 4]; + } + } + let t0 = std::time::Instant::now(); + let n = 10; + for k in 0..n { + let tmp = smart_interpolate(&d, a, &d, b, f64::from(k) / f64::from(n)); + assert!(figcraft_render::render_node(&tmp, b, 2.0).is_some()); + } + let per = t0.elapsed().as_secs_f64() * 1000.0 / f64::from(n); + eprintln!("smart animate: {per:.2} ms per frame (80 layers, 400x800 px)"); +} From 5d1b826f7a0a409da54bc4135f7a723ac8e89035 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:08:39 +0000 Subject: [PATCH 10/67] refactor(engine): move board, board_table, board_templates to boards/ --- crates/engine/src/boards.rs | 3 +++ crates/engine/src/{ => boards}/board.rs | 0 crates/engine/src/{ => boards}/board_table.rs | 0 crates/engine/src/{ => boards}/board_templates.rs | 0 crates/engine/src/lib.rs | 7 ++++--- 5 files changed, 7 insertions(+), 3 deletions(-) create mode 100644 crates/engine/src/boards.rs rename crates/engine/src/{ => boards}/board.rs (100%) rename crates/engine/src/{ => boards}/board_table.rs (100%) rename crates/engine/src/{ => boards}/board_templates.rs (100%) diff --git a/crates/engine/src/boards.rs b/crates/engine/src/boards.rs new file mode 100644 index 00000000..685c1d70 --- /dev/null +++ b/crates/engine/src/boards.rs @@ -0,0 +1,3 @@ +pub mod board; +pub mod board_table; +pub mod board_templates; diff --git a/crates/engine/src/board.rs b/crates/engine/src/boards/board.rs similarity index 100% rename from crates/engine/src/board.rs rename to crates/engine/src/boards/board.rs diff --git a/crates/engine/src/board_table.rs b/crates/engine/src/boards/board_table.rs similarity index 100% rename from crates/engine/src/board_table.rs rename to crates/engine/src/boards/board_table.rs diff --git a/crates/engine/src/board_templates.rs b/crates/engine/src/boards/board_templates.rs similarity index 100% rename from crates/engine/src/board_templates.rs rename to crates/engine/src/boards/board_templates.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 462c184f..bc735299 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -14,9 +14,7 @@ pub mod annotations; pub mod arc_handles; pub mod background; pub mod batch; -pub mod board; -pub mod board_table; -pub mod board_templates; +mod boards; pub mod booleans; pub mod branches; pub mod catalog; @@ -72,6 +70,9 @@ pub mod token_roles; pub mod tools; pub mod vectoredit; pub mod widgets; +pub use boards::board; +pub use boards::board_table; +pub use boards::board_templates; pub use batch::{BatchScope, BatchStep}; pub use cmd::{CommandSpec, all_commands, find_command}; From 094b01e501c0d65a66cb4d4c65e4ec60cc424e97 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:08:39 +0000 Subject: [PATCH 11/67] refactor(engine): move tools, snap, distance, guides, drop, arc_handles, shape_handles, rotation_origin, al_keys, booleans, shapebuilder, textedit, textpreview, vectoredit to canvas/ --- crates/engine/src/canvas.rs | 14 +++++++++ crates/engine/src/{ => canvas}/al_keys.rs | 0 crates/engine/src/{ => canvas}/arc_handles.rs | 0 crates/engine/src/{ => canvas}/booleans.rs | 0 crates/engine/src/{ => canvas}/distance.rs | 0 crates/engine/src/{ => canvas}/drop.rs | 0 crates/engine/src/{ => canvas}/guides.rs | 0 .../src/{ => canvas}/rotation_origin.rs | 0 .../engine/src/{ => canvas}/shape_handles.rs | 0 .../engine/src/{ => canvas}/shapebuilder.rs | 0 crates/engine/src/{ => canvas}/snap.rs | 0 crates/engine/src/{ => canvas}/textedit.rs | 0 .../engine/src/{ => canvas}/textedit/tests.rs | 0 crates/engine/src/{ => canvas}/textpreview.rs | 0 crates/engine/src/{ => canvas}/tools.rs | 0 crates/engine/src/{ => canvas}/vectoredit.rs | 0 .../engine/src/{ => canvas}/vectoredit/ops.rs | 0 .../src/{ => canvas}/vectoredit/point_snap.rs | 0 .../src/{ => canvas}/vectoredit/pointer.rs | 0 .../src/{ => canvas}/vectoredit/regions.rs | 0 .../{ => canvas}/vectoredit/regions_tests.rs | 0 .../src/{ => canvas}/vectoredit/tests.rs | 0 .../src/{ => canvas}/vectoredit/transform.rs | 0 .../vectoredit/transform_tests.rs | 0 .../src/{ => canvas}/vectoredit/width.rs | 0 .../vectoredit/width_sides_tests.rs | 0 .../{ => canvas}/vectoredit/width_tests.rs | 0 crates/engine/src/lib.rs | 29 ++++++++++--------- 28 files changed, 29 insertions(+), 14 deletions(-) create mode 100644 crates/engine/src/canvas.rs rename crates/engine/src/{ => canvas}/al_keys.rs (100%) rename crates/engine/src/{ => canvas}/arc_handles.rs (100%) rename crates/engine/src/{ => canvas}/booleans.rs (100%) rename crates/engine/src/{ => canvas}/distance.rs (100%) rename crates/engine/src/{ => canvas}/drop.rs (100%) rename crates/engine/src/{ => canvas}/guides.rs (100%) rename crates/engine/src/{ => canvas}/rotation_origin.rs (100%) rename crates/engine/src/{ => canvas}/shape_handles.rs (100%) rename crates/engine/src/{ => canvas}/shapebuilder.rs (100%) rename crates/engine/src/{ => canvas}/snap.rs (100%) rename crates/engine/src/{ => canvas}/textedit.rs (100%) rename crates/engine/src/{ => canvas}/textedit/tests.rs (100%) rename crates/engine/src/{ => canvas}/textpreview.rs (100%) rename crates/engine/src/{ => canvas}/tools.rs (100%) rename crates/engine/src/{ => canvas}/vectoredit.rs (100%) rename crates/engine/src/{ => canvas}/vectoredit/ops.rs (100%) rename crates/engine/src/{ => canvas}/vectoredit/point_snap.rs (100%) rename crates/engine/src/{ => canvas}/vectoredit/pointer.rs (100%) rename crates/engine/src/{ => canvas}/vectoredit/regions.rs (100%) rename crates/engine/src/{ => canvas}/vectoredit/regions_tests.rs (100%) rename crates/engine/src/{ => canvas}/vectoredit/tests.rs (100%) rename crates/engine/src/{ => canvas}/vectoredit/transform.rs (100%) rename crates/engine/src/{ => canvas}/vectoredit/transform_tests.rs (100%) rename crates/engine/src/{ => canvas}/vectoredit/width.rs (100%) rename crates/engine/src/{ => canvas}/vectoredit/width_sides_tests.rs (100%) rename crates/engine/src/{ => canvas}/vectoredit/width_tests.rs (100%) diff --git a/crates/engine/src/canvas.rs b/crates/engine/src/canvas.rs new file mode 100644 index 00000000..b513ffbd --- /dev/null +++ b/crates/engine/src/canvas.rs @@ -0,0 +1,14 @@ +pub mod al_keys; +pub mod arc_handles; +pub mod booleans; +pub mod distance; +pub mod drop; +pub mod guides; +pub mod rotation_origin; +pub mod shape_handles; +pub mod shapebuilder; +pub mod snap; +pub mod textedit; +pub mod textpreview; +pub mod tools; +pub mod vectoredit; diff --git a/crates/engine/src/al_keys.rs b/crates/engine/src/canvas/al_keys.rs similarity index 100% rename from crates/engine/src/al_keys.rs rename to crates/engine/src/canvas/al_keys.rs diff --git a/crates/engine/src/arc_handles.rs b/crates/engine/src/canvas/arc_handles.rs similarity index 100% rename from crates/engine/src/arc_handles.rs rename to crates/engine/src/canvas/arc_handles.rs diff --git a/crates/engine/src/booleans.rs b/crates/engine/src/canvas/booleans.rs similarity index 100% rename from crates/engine/src/booleans.rs rename to crates/engine/src/canvas/booleans.rs diff --git a/crates/engine/src/distance.rs b/crates/engine/src/canvas/distance.rs similarity index 100% rename from crates/engine/src/distance.rs rename to crates/engine/src/canvas/distance.rs diff --git a/crates/engine/src/drop.rs b/crates/engine/src/canvas/drop.rs similarity index 100% rename from crates/engine/src/drop.rs rename to crates/engine/src/canvas/drop.rs diff --git a/crates/engine/src/guides.rs b/crates/engine/src/canvas/guides.rs similarity index 100% rename from crates/engine/src/guides.rs rename to crates/engine/src/canvas/guides.rs diff --git a/crates/engine/src/rotation_origin.rs b/crates/engine/src/canvas/rotation_origin.rs similarity index 100% rename from crates/engine/src/rotation_origin.rs rename to crates/engine/src/canvas/rotation_origin.rs diff --git a/crates/engine/src/shape_handles.rs b/crates/engine/src/canvas/shape_handles.rs similarity index 100% rename from crates/engine/src/shape_handles.rs rename to crates/engine/src/canvas/shape_handles.rs diff --git a/crates/engine/src/shapebuilder.rs b/crates/engine/src/canvas/shapebuilder.rs similarity index 100% rename from crates/engine/src/shapebuilder.rs rename to crates/engine/src/canvas/shapebuilder.rs diff --git a/crates/engine/src/snap.rs b/crates/engine/src/canvas/snap.rs similarity index 100% rename from crates/engine/src/snap.rs rename to crates/engine/src/canvas/snap.rs diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/canvas/textedit.rs similarity index 100% rename from crates/engine/src/textedit.rs rename to crates/engine/src/canvas/textedit.rs diff --git a/crates/engine/src/textedit/tests.rs b/crates/engine/src/canvas/textedit/tests.rs similarity index 100% rename from crates/engine/src/textedit/tests.rs rename to crates/engine/src/canvas/textedit/tests.rs diff --git a/crates/engine/src/textpreview.rs b/crates/engine/src/canvas/textpreview.rs similarity index 100% rename from crates/engine/src/textpreview.rs rename to crates/engine/src/canvas/textpreview.rs diff --git a/crates/engine/src/tools.rs b/crates/engine/src/canvas/tools.rs similarity index 100% rename from crates/engine/src/tools.rs rename to crates/engine/src/canvas/tools.rs diff --git a/crates/engine/src/vectoredit.rs b/crates/engine/src/canvas/vectoredit.rs similarity index 100% rename from crates/engine/src/vectoredit.rs rename to crates/engine/src/canvas/vectoredit.rs diff --git a/crates/engine/src/vectoredit/ops.rs b/crates/engine/src/canvas/vectoredit/ops.rs similarity index 100% rename from crates/engine/src/vectoredit/ops.rs rename to crates/engine/src/canvas/vectoredit/ops.rs diff --git a/crates/engine/src/vectoredit/point_snap.rs b/crates/engine/src/canvas/vectoredit/point_snap.rs similarity index 100% rename from crates/engine/src/vectoredit/point_snap.rs rename to crates/engine/src/canvas/vectoredit/point_snap.rs diff --git a/crates/engine/src/vectoredit/pointer.rs b/crates/engine/src/canvas/vectoredit/pointer.rs similarity index 100% rename from crates/engine/src/vectoredit/pointer.rs rename to crates/engine/src/canvas/vectoredit/pointer.rs diff --git a/crates/engine/src/vectoredit/regions.rs b/crates/engine/src/canvas/vectoredit/regions.rs similarity index 100% rename from crates/engine/src/vectoredit/regions.rs rename to crates/engine/src/canvas/vectoredit/regions.rs diff --git a/crates/engine/src/vectoredit/regions_tests.rs b/crates/engine/src/canvas/vectoredit/regions_tests.rs similarity index 100% rename from crates/engine/src/vectoredit/regions_tests.rs rename to crates/engine/src/canvas/vectoredit/regions_tests.rs diff --git a/crates/engine/src/vectoredit/tests.rs b/crates/engine/src/canvas/vectoredit/tests.rs similarity index 100% rename from crates/engine/src/vectoredit/tests.rs rename to crates/engine/src/canvas/vectoredit/tests.rs diff --git a/crates/engine/src/vectoredit/transform.rs b/crates/engine/src/canvas/vectoredit/transform.rs similarity index 100% rename from crates/engine/src/vectoredit/transform.rs rename to crates/engine/src/canvas/vectoredit/transform.rs diff --git a/crates/engine/src/vectoredit/transform_tests.rs b/crates/engine/src/canvas/vectoredit/transform_tests.rs similarity index 100% rename from crates/engine/src/vectoredit/transform_tests.rs rename to crates/engine/src/canvas/vectoredit/transform_tests.rs diff --git a/crates/engine/src/vectoredit/width.rs b/crates/engine/src/canvas/vectoredit/width.rs similarity index 100% rename from crates/engine/src/vectoredit/width.rs rename to crates/engine/src/canvas/vectoredit/width.rs diff --git a/crates/engine/src/vectoredit/width_sides_tests.rs b/crates/engine/src/canvas/vectoredit/width_sides_tests.rs similarity index 100% rename from crates/engine/src/vectoredit/width_sides_tests.rs rename to crates/engine/src/canvas/vectoredit/width_sides_tests.rs diff --git a/crates/engine/src/vectoredit/width_tests.rs b/crates/engine/src/canvas/vectoredit/width_tests.rs similarity index 100% rename from crates/engine/src/vectoredit/width_tests.rs rename to crates/engine/src/canvas/vectoredit/width_tests.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index bc735299..c158fd2d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -9,14 +9,12 @@ // Tests make and inspect files directly; shipped code goes through `file_access` (clippy.toml). #![cfg_attr(test, allow(clippy::disallowed_methods))] -pub mod al_keys; pub mod annotations; -pub mod arc_handles; pub mod background; pub mod batch; mod boards; -pub mod booleans; pub mod branches; +mod canvas; pub mod catalog; pub mod cmd; pub mod code_connect; @@ -29,13 +27,10 @@ pub mod dev_measurements; pub mod dev_resources; pub mod devcompare; pub mod devinspect; -pub mod distance; pub mod doc_urls; -pub mod drop; pub mod file_access; pub mod fonts; pub mod guard; -pub mod guides; pub mod inspect; pub mod invite_links; pub mod keymap; @@ -51,28 +46,34 @@ pub mod plugin_directory; pub mod plugins; pub mod proto_run; pub mod rename; -pub mod rotation_origin; pub mod selection_colors; pub mod selection_reorder; pub mod selection_spacing; -pub mod shape_handles; -pub mod shapebuilder; -pub mod snap; pub mod style_vars; pub mod synthetic; pub mod synthetic_ds; pub mod tailwind; pub mod templates; -pub mod textedit; pub mod textfit; -pub mod textpreview; pub mod token_roles; -pub mod tools; -pub mod vectoredit; pub mod widgets; pub use boards::board; pub use boards::board_table; pub use boards::board_templates; +pub use canvas::al_keys; +pub use canvas::arc_handles; +pub use canvas::booleans; +pub use canvas::distance; +pub use canvas::drop; +pub use canvas::guides; +pub use canvas::rotation_origin; +pub use canvas::shape_handles; +pub use canvas::shapebuilder; +pub use canvas::snap; +pub use canvas::textedit; +pub use canvas::textpreview; +pub use canvas::tools; +pub use canvas::vectoredit; pub use batch::{BatchScope, BatchStep}; pub use cmd::{CommandSpec, all_commands, find_command}; From a587d964c9610be409e3dff946cc81729e2b9ad1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:08:40 +0000 Subject: [PATCH 12/67] refactor(engine): move selection_colors, selection_reorder, selection_spacing, rename to selection/ --- crates/engine/src/lib.rs | 9 +++++---- crates/engine/src/selection.rs | 4 ++++ crates/engine/src/{ => selection}/rename.rs | 0 crates/engine/src/{ => selection}/selection_colors.rs | 0 crates/engine/src/{ => selection}/selection_reorder.rs | 0 crates/engine/src/{ => selection}/selection_spacing.rs | 0 6 files changed, 9 insertions(+), 4 deletions(-) create mode 100644 crates/engine/src/selection.rs rename crates/engine/src/{ => selection}/rename.rs (100%) rename crates/engine/src/{ => selection}/selection_colors.rs (100%) rename crates/engine/src/{ => selection}/selection_reorder.rs (100%) rename crates/engine/src/{ => selection}/selection_spacing.rs (100%) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c158fd2d..4b61ad3f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -45,10 +45,7 @@ pub mod playground; pub mod plugin_directory; pub mod plugins; pub mod proto_run; -pub mod rename; -pub mod selection_colors; -pub mod selection_reorder; -pub mod selection_spacing; +mod selection; pub mod style_vars; pub mod synthetic; pub mod synthetic_ds; @@ -74,6 +71,10 @@ pub use canvas::textedit; pub use canvas::textpreview; pub use canvas::tools; pub use canvas::vectoredit; +pub use selection::rename; +pub use selection::selection_colors; +pub use selection::selection_reorder; +pub use selection::selection_spacing; pub use batch::{BatchScope, BatchStep}; pub use cmd::{CommandSpec, all_commands, find_command}; diff --git a/crates/engine/src/selection.rs b/crates/engine/src/selection.rs new file mode 100644 index 00000000..08ab6456 --- /dev/null +++ b/crates/engine/src/selection.rs @@ -0,0 +1,4 @@ +pub mod rename; +pub mod selection_colors; +pub mod selection_reorder; +pub mod selection_spacing; diff --git a/crates/engine/src/rename.rs b/crates/engine/src/selection/rename.rs similarity index 100% rename from crates/engine/src/rename.rs rename to crates/engine/src/selection/rename.rs diff --git a/crates/engine/src/selection_colors.rs b/crates/engine/src/selection/selection_colors.rs similarity index 100% rename from crates/engine/src/selection_colors.rs rename to crates/engine/src/selection/selection_colors.rs diff --git a/crates/engine/src/selection_reorder.rs b/crates/engine/src/selection/selection_reorder.rs similarity index 100% rename from crates/engine/src/selection_reorder.rs rename to crates/engine/src/selection/selection_reorder.rs diff --git a/crates/engine/src/selection_spacing.rs b/crates/engine/src/selection/selection_spacing.rs similarity index 100% rename from crates/engine/src/selection_spacing.rs rename to crates/engine/src/selection/selection_spacing.rs From e466f4a03a6e0c0ee5c85beb30489468c7afaba6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:08:40 +0000 Subject: [PATCH 13/67] refactor(engine): move components, library, library_server, library_swap, style_vars, playground, token_roles to assets/ --- crates/engine/src/assets.rs | 7 +++++++ crates/engine/src/{ => assets}/components.rs | 0 .../components_rebase_source_tests.rs | 0 .../engine/src/{ => assets}/components_tests.rs | 0 crates/engine/src/{ => assets}/library.rs | 0 crates/engine/src/{ => assets}/library_server.rs | 0 .../src/{ => assets}/library_server_tests.rs | 0 crates/engine/src/{ => assets}/library_swap.rs | 0 .../engine/src/{ => assets}/library_swap_tests.rs | 0 crates/engine/src/{ => assets}/library_tests.rs | 0 crates/engine/src/{ => assets}/playground.rs | 0 crates/engine/src/{ => assets}/style_vars.rs | 0 crates/engine/src/{ => assets}/token_roles.rs | 0 crates/engine/src/lib.rs | 15 ++++++++------- 14 files changed, 15 insertions(+), 7 deletions(-) create mode 100644 crates/engine/src/assets.rs rename crates/engine/src/{ => assets}/components.rs (100%) rename crates/engine/src/{ => assets}/components_rebase_source_tests.rs (100%) rename crates/engine/src/{ => assets}/components_tests.rs (100%) rename crates/engine/src/{ => assets}/library.rs (100%) rename crates/engine/src/{ => assets}/library_server.rs (100%) rename crates/engine/src/{ => assets}/library_server_tests.rs (100%) rename crates/engine/src/{ => assets}/library_swap.rs (100%) rename crates/engine/src/{ => assets}/library_swap_tests.rs (100%) rename crates/engine/src/{ => assets}/library_tests.rs (100%) rename crates/engine/src/{ => assets}/playground.rs (100%) rename crates/engine/src/{ => assets}/style_vars.rs (100%) rename crates/engine/src/{ => assets}/token_roles.rs (100%) diff --git a/crates/engine/src/assets.rs b/crates/engine/src/assets.rs new file mode 100644 index 00000000..e648ad3b --- /dev/null +++ b/crates/engine/src/assets.rs @@ -0,0 +1,7 @@ +pub mod components; +pub mod library; +pub mod library_server; +pub mod library_swap; +pub mod playground; +pub mod style_vars; +pub mod token_roles; diff --git a/crates/engine/src/components.rs b/crates/engine/src/assets/components.rs similarity index 100% rename from crates/engine/src/components.rs rename to crates/engine/src/assets/components.rs diff --git a/crates/engine/src/components_rebase_source_tests.rs b/crates/engine/src/assets/components_rebase_source_tests.rs similarity index 100% rename from crates/engine/src/components_rebase_source_tests.rs rename to crates/engine/src/assets/components_rebase_source_tests.rs diff --git a/crates/engine/src/components_tests.rs b/crates/engine/src/assets/components_tests.rs similarity index 100% rename from crates/engine/src/components_tests.rs rename to crates/engine/src/assets/components_tests.rs diff --git a/crates/engine/src/library.rs b/crates/engine/src/assets/library.rs similarity index 100% rename from crates/engine/src/library.rs rename to crates/engine/src/assets/library.rs diff --git a/crates/engine/src/library_server.rs b/crates/engine/src/assets/library_server.rs similarity index 100% rename from crates/engine/src/library_server.rs rename to crates/engine/src/assets/library_server.rs diff --git a/crates/engine/src/library_server_tests.rs b/crates/engine/src/assets/library_server_tests.rs similarity index 100% rename from crates/engine/src/library_server_tests.rs rename to crates/engine/src/assets/library_server_tests.rs diff --git a/crates/engine/src/library_swap.rs b/crates/engine/src/assets/library_swap.rs similarity index 100% rename from crates/engine/src/library_swap.rs rename to crates/engine/src/assets/library_swap.rs diff --git a/crates/engine/src/library_swap_tests.rs b/crates/engine/src/assets/library_swap_tests.rs similarity index 100% rename from crates/engine/src/library_swap_tests.rs rename to crates/engine/src/assets/library_swap_tests.rs diff --git a/crates/engine/src/library_tests.rs b/crates/engine/src/assets/library_tests.rs similarity index 100% rename from crates/engine/src/library_tests.rs rename to crates/engine/src/assets/library_tests.rs diff --git a/crates/engine/src/playground.rs b/crates/engine/src/assets/playground.rs similarity index 100% rename from crates/engine/src/playground.rs rename to crates/engine/src/assets/playground.rs diff --git a/crates/engine/src/style_vars.rs b/crates/engine/src/assets/style_vars.rs similarity index 100% rename from crates/engine/src/style_vars.rs rename to crates/engine/src/assets/style_vars.rs diff --git a/crates/engine/src/token_roles.rs b/crates/engine/src/assets/token_roles.rs similarity index 100% rename from crates/engine/src/token_roles.rs rename to crates/engine/src/assets/token_roles.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4b61ad3f..59718b0f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -10,6 +10,7 @@ #![cfg_attr(test, allow(clippy::disallowed_methods))] pub mod annotations; +mod assets; pub mod background; pub mod batch; mod boards; @@ -21,7 +22,6 @@ pub mod code_connect; pub mod codegen; pub mod collab; pub mod component_codegen; -pub mod components; pub mod datafill; pub mod dev_measurements; pub mod dev_resources; @@ -34,26 +34,27 @@ pub mod guard; pub mod inspect; pub mod invite_links; pub mod keymap; -pub mod library; -pub mod library_server; -pub mod library_swap; pub mod live; pub mod measure; pub mod online; pub mod params; -pub mod playground; pub mod plugin_directory; pub mod plugins; pub mod proto_run; mod selection; -pub mod style_vars; pub mod synthetic; pub mod synthetic_ds; pub mod tailwind; pub mod templates; pub mod textfit; -pub mod token_roles; pub mod widgets; +pub use assets::components; +pub use assets::library; +pub use assets::library_server; +pub use assets::library_swap; +pub use assets::playground; +pub use assets::style_vars; +pub use assets::token_roles; pub use boards::board; pub use boards::board_table; pub use boards::board_templates; From 9452ec1adade521b3c5ead487c45f9281c691235 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:08:40 +0000 Subject: [PATCH 14/67] refactor(engine): move annotations, code_connect, codegen, component_codegen, tailwind, devcompare, devinspect, dev_measurements, dev_resources to dev/ --- crates/engine/src/dev.rs | 9 +++++++++ crates/engine/src/{ => dev}/annotations.rs | 0 crates/engine/src/{ => dev}/code_connect.rs | 0 crates/engine/src/{ => dev}/codegen.rs | 0 .../engine/src/{ => dev}/component_codegen.rs | 0 .../engine/src/{ => dev}/dev_measurements.rs | 0 crates/engine/src/{ => dev}/dev_resources.rs | 0 crates/engine/src/{ => dev}/devcompare.rs | 0 crates/engine/src/{ => dev}/devinspect.rs | 0 crates/engine/src/{ => dev}/tailwind.rs | 0 crates/engine/src/lib.rs | 19 ++++++++++--------- 11 files changed, 19 insertions(+), 9 deletions(-) create mode 100644 crates/engine/src/dev.rs rename crates/engine/src/{ => dev}/annotations.rs (100%) rename crates/engine/src/{ => dev}/code_connect.rs (100%) rename crates/engine/src/{ => dev}/codegen.rs (100%) rename crates/engine/src/{ => dev}/component_codegen.rs (100%) rename crates/engine/src/{ => dev}/dev_measurements.rs (100%) rename crates/engine/src/{ => dev}/dev_resources.rs (100%) rename crates/engine/src/{ => dev}/devcompare.rs (100%) rename crates/engine/src/{ => dev}/devinspect.rs (100%) rename crates/engine/src/{ => dev}/tailwind.rs (100%) diff --git a/crates/engine/src/dev.rs b/crates/engine/src/dev.rs new file mode 100644 index 00000000..bc46aae9 --- /dev/null +++ b/crates/engine/src/dev.rs @@ -0,0 +1,9 @@ +pub mod annotations; +pub mod code_connect; +pub mod codegen; +pub mod component_codegen; +pub mod dev_measurements; +pub mod dev_resources; +pub mod devcompare; +pub mod devinspect; +pub mod tailwind; diff --git a/crates/engine/src/annotations.rs b/crates/engine/src/dev/annotations.rs similarity index 100% rename from crates/engine/src/annotations.rs rename to crates/engine/src/dev/annotations.rs diff --git a/crates/engine/src/code_connect.rs b/crates/engine/src/dev/code_connect.rs similarity index 100% rename from crates/engine/src/code_connect.rs rename to crates/engine/src/dev/code_connect.rs diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/dev/codegen.rs similarity index 100% rename from crates/engine/src/codegen.rs rename to crates/engine/src/dev/codegen.rs diff --git a/crates/engine/src/component_codegen.rs b/crates/engine/src/dev/component_codegen.rs similarity index 100% rename from crates/engine/src/component_codegen.rs rename to crates/engine/src/dev/component_codegen.rs diff --git a/crates/engine/src/dev_measurements.rs b/crates/engine/src/dev/dev_measurements.rs similarity index 100% rename from crates/engine/src/dev_measurements.rs rename to crates/engine/src/dev/dev_measurements.rs diff --git a/crates/engine/src/dev_resources.rs b/crates/engine/src/dev/dev_resources.rs similarity index 100% rename from crates/engine/src/dev_resources.rs rename to crates/engine/src/dev/dev_resources.rs diff --git a/crates/engine/src/devcompare.rs b/crates/engine/src/dev/devcompare.rs similarity index 100% rename from crates/engine/src/devcompare.rs rename to crates/engine/src/dev/devcompare.rs diff --git a/crates/engine/src/devinspect.rs b/crates/engine/src/dev/devinspect.rs similarity index 100% rename from crates/engine/src/devinspect.rs rename to crates/engine/src/dev/devinspect.rs diff --git a/crates/engine/src/tailwind.rs b/crates/engine/src/dev/tailwind.rs similarity index 100% rename from crates/engine/src/tailwind.rs rename to crates/engine/src/dev/tailwind.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 59718b0f..1ba0c805 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -9,7 +9,6 @@ // Tests make and inspect files directly; shipped code goes through `file_access` (clippy.toml). #![cfg_attr(test, allow(clippy::disallowed_methods))] -pub mod annotations; mod assets; pub mod background; pub mod batch; @@ -18,15 +17,9 @@ pub mod branches; mod canvas; pub mod catalog; pub mod cmd; -pub mod code_connect; -pub mod codegen; pub mod collab; -pub mod component_codegen; pub mod datafill; -pub mod dev_measurements; -pub mod dev_resources; -pub mod devcompare; -pub mod devinspect; +mod dev; pub mod doc_urls; pub mod file_access; pub mod fonts; @@ -44,7 +37,6 @@ pub mod proto_run; mod selection; pub mod synthetic; pub mod synthetic_ds; -pub mod tailwind; pub mod templates; pub mod textfit; pub mod widgets; @@ -72,6 +64,15 @@ pub use canvas::textedit; pub use canvas::textpreview; pub use canvas::tools; pub use canvas::vectoredit; +pub use dev::annotations; +pub use dev::code_connect; +pub use dev::codegen; +pub use dev::component_codegen; +pub use dev::dev_measurements; +pub use dev::dev_resources; +pub use dev::devcompare; +pub use dev::devinspect; +pub use dev::tailwind; pub use selection::rename; pub use selection::selection_colors; pub use selection::selection_reorder; From fa8e917ede4b7ae68d9020421e988095f4864d63 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:08:40 +0000 Subject: [PATCH 15/67] refactor(engine): move collab, live, online, branches, invite_links, background to sharing/ --- crates/engine/src/lib.rs | 13 +++++++------ crates/engine/src/sharing.rs | 6 ++++++ crates/engine/src/{ => sharing}/background.rs | 0 crates/engine/src/{ => sharing}/background_tests.rs | 0 crates/engine/src/{ => sharing}/branches.rs | 0 crates/engine/src/{ => sharing}/collab.rs | 0 crates/engine/src/{ => sharing}/invite_links.rs | 0 crates/engine/src/{ => sharing}/live.rs | 0 crates/engine/src/{ => sharing}/online.rs | 0 9 files changed, 13 insertions(+), 6 deletions(-) create mode 100644 crates/engine/src/sharing.rs rename crates/engine/src/{ => sharing}/background.rs (100%) rename crates/engine/src/{ => sharing}/background_tests.rs (100%) rename crates/engine/src/{ => sharing}/branches.rs (100%) rename crates/engine/src/{ => sharing}/collab.rs (100%) rename crates/engine/src/{ => sharing}/invite_links.rs (100%) rename crates/engine/src/{ => sharing}/live.rs (100%) rename crates/engine/src/{ => sharing}/online.rs (100%) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1ba0c805..a6d847d6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -10,14 +10,11 @@ #![cfg_attr(test, allow(clippy::disallowed_methods))] mod assets; -pub mod background; pub mod batch; mod boards; -pub mod branches; mod canvas; pub mod catalog; pub mod cmd; -pub mod collab; pub mod datafill; mod dev; pub mod doc_urls; @@ -25,16 +22,14 @@ pub mod file_access; pub mod fonts; pub mod guard; pub mod inspect; -pub mod invite_links; pub mod keymap; -pub mod live; pub mod measure; -pub mod online; pub mod params; pub mod plugin_directory; pub mod plugins; pub mod proto_run; mod selection; +mod sharing; pub mod synthetic; pub mod synthetic_ds; pub mod templates; @@ -77,6 +72,12 @@ pub use selection::rename; pub use selection::selection_colors; pub use selection::selection_reorder; pub use selection::selection_spacing; +pub use sharing::background; +pub use sharing::branches; +pub use sharing::collab; +pub use sharing::invite_links; +pub use sharing::live; +pub use sharing::online; pub use batch::{BatchScope, BatchStep}; pub use cmd::{CommandSpec, all_commands, find_command}; diff --git a/crates/engine/src/sharing.rs b/crates/engine/src/sharing.rs new file mode 100644 index 00000000..27d1e560 --- /dev/null +++ b/crates/engine/src/sharing.rs @@ -0,0 +1,6 @@ +pub mod background; +pub mod branches; +pub mod collab; +pub mod invite_links; +pub mod live; +pub mod online; diff --git a/crates/engine/src/background.rs b/crates/engine/src/sharing/background.rs similarity index 100% rename from crates/engine/src/background.rs rename to crates/engine/src/sharing/background.rs diff --git a/crates/engine/src/background_tests.rs b/crates/engine/src/sharing/background_tests.rs similarity index 100% rename from crates/engine/src/background_tests.rs rename to crates/engine/src/sharing/background_tests.rs diff --git a/crates/engine/src/branches.rs b/crates/engine/src/sharing/branches.rs similarity index 100% rename from crates/engine/src/branches.rs rename to crates/engine/src/sharing/branches.rs diff --git a/crates/engine/src/collab.rs b/crates/engine/src/sharing/collab.rs similarity index 100% rename from crates/engine/src/collab.rs rename to crates/engine/src/sharing/collab.rs diff --git a/crates/engine/src/invite_links.rs b/crates/engine/src/sharing/invite_links.rs similarity index 100% rename from crates/engine/src/invite_links.rs rename to crates/engine/src/sharing/invite_links.rs diff --git a/crates/engine/src/live.rs b/crates/engine/src/sharing/live.rs similarity index 100% rename from crates/engine/src/live.rs rename to crates/engine/src/sharing/live.rs diff --git a/crates/engine/src/online.rs b/crates/engine/src/sharing/online.rs similarity index 100% rename from crates/engine/src/online.rs rename to crates/engine/src/sharing/online.rs From 53b04cc7844eb7f701886f268dd6e2714ef3f4f0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:08:40 +0000 Subject: [PATCH 16/67] refactor(engine): move plugins, plugin_directory, widgets to extensions/ --- crates/engine/src/extensions.rs | 3 +++ crates/engine/src/{ => extensions}/plugin_directory.rs | 0 crates/engine/src/{ => extensions}/plugins.rs | 0 crates/engine/src/{ => extensions}/widgets.rs | 0 crates/engine/src/lib.rs | 7 ++++--- 5 files changed, 7 insertions(+), 3 deletions(-) create mode 100644 crates/engine/src/extensions.rs rename crates/engine/src/{ => extensions}/plugin_directory.rs (100%) rename crates/engine/src/{ => extensions}/plugins.rs (100%) rename crates/engine/src/{ => extensions}/widgets.rs (100%) diff --git a/crates/engine/src/extensions.rs b/crates/engine/src/extensions.rs new file mode 100644 index 00000000..aeac5e34 --- /dev/null +++ b/crates/engine/src/extensions.rs @@ -0,0 +1,3 @@ +pub mod plugin_directory; +pub mod plugins; +pub mod widgets; diff --git a/crates/engine/src/plugin_directory.rs b/crates/engine/src/extensions/plugin_directory.rs similarity index 100% rename from crates/engine/src/plugin_directory.rs rename to crates/engine/src/extensions/plugin_directory.rs diff --git a/crates/engine/src/plugins.rs b/crates/engine/src/extensions/plugins.rs similarity index 100% rename from crates/engine/src/plugins.rs rename to crates/engine/src/extensions/plugins.rs diff --git a/crates/engine/src/widgets.rs b/crates/engine/src/extensions/widgets.rs similarity index 100% rename from crates/engine/src/widgets.rs rename to crates/engine/src/extensions/widgets.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a6d847d6..6775f807 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -18,6 +18,7 @@ pub mod cmd; pub mod datafill; mod dev; pub mod doc_urls; +mod extensions; pub mod file_access; pub mod fonts; pub mod guard; @@ -25,8 +26,6 @@ pub mod inspect; pub mod keymap; pub mod measure; pub mod params; -pub mod plugin_directory; -pub mod plugins; pub mod proto_run; mod selection; mod sharing; @@ -34,7 +33,6 @@ pub mod synthetic; pub mod synthetic_ds; pub mod templates; pub mod textfit; -pub mod widgets; pub use assets::components; pub use assets::library; pub use assets::library_server; @@ -68,6 +66,9 @@ pub use dev::dev_resources; pub use dev::devcompare; pub use dev::devinspect; pub use dev::tailwind; +pub use extensions::plugin_directory; +pub use extensions::plugins; +pub use extensions::widgets; pub use selection::rename; pub use selection::selection_colors; pub use selection::selection_reorder; From 2cf15106e1e9295ffe56748ce24dea65093bb49e Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:08:40 +0000 Subject: [PATCH 17/67] refactor(engine): move datafill, textfit, templates, synthetic, synthetic_ds to content/ --- crates/engine/src/content.rs | 5 +++++ crates/engine/src/{ => content}/datafill.rs | 0 crates/engine/src/{ => content}/synthetic.rs | 0 crates/engine/src/{ => content}/synthetic_ds.rs | 0 crates/engine/src/{ => content}/templates.rs | 0 .../engine/src/{ => content}/templates/dashboard.rs | 0 .../src/{ => content}/templates/design_system.rs | 0 crates/engine/src/{ => content}/templates/glyphs.rs | 0 crates/engine/src/{ => content}/templates/icon_set.rs | 0 crates/engine/src/{ => content}/templates/kit.rs | 0 crates/engine/src/{ => content}/templates/landing.rs | 0 crates/engine/src/{ => content}/templates/mobile.rs | 0 crates/engine/src/{ => content}/templates/slides.rs | 0 crates/engine/src/{ => content}/templates/social.rs | 0 .../engine/src/{ => content}/templates/wireframe.rs | 0 crates/engine/src/{ => content}/textfit.rs | 0 crates/engine/src/lib.rs | 11 ++++++----- 17 files changed, 11 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/content.rs rename crates/engine/src/{ => content}/datafill.rs (100%) rename crates/engine/src/{ => content}/synthetic.rs (100%) rename crates/engine/src/{ => content}/synthetic_ds.rs (100%) rename crates/engine/src/{ => content}/templates.rs (100%) rename crates/engine/src/{ => content}/templates/dashboard.rs (100%) rename crates/engine/src/{ => content}/templates/design_system.rs (100%) rename crates/engine/src/{ => content}/templates/glyphs.rs (100%) rename crates/engine/src/{ => content}/templates/icon_set.rs (100%) rename crates/engine/src/{ => content}/templates/kit.rs (100%) rename crates/engine/src/{ => content}/templates/landing.rs (100%) rename crates/engine/src/{ => content}/templates/mobile.rs (100%) rename crates/engine/src/{ => content}/templates/slides.rs (100%) rename crates/engine/src/{ => content}/templates/social.rs (100%) rename crates/engine/src/{ => content}/templates/wireframe.rs (100%) rename crates/engine/src/{ => content}/textfit.rs (100%) diff --git a/crates/engine/src/content.rs b/crates/engine/src/content.rs new file mode 100644 index 00000000..cf32c3eb --- /dev/null +++ b/crates/engine/src/content.rs @@ -0,0 +1,5 @@ +pub mod datafill; +pub mod synthetic; +pub mod synthetic_ds; +pub mod templates; +pub mod textfit; diff --git a/crates/engine/src/datafill.rs b/crates/engine/src/content/datafill.rs similarity index 100% rename from crates/engine/src/datafill.rs rename to crates/engine/src/content/datafill.rs diff --git a/crates/engine/src/synthetic.rs b/crates/engine/src/content/synthetic.rs similarity index 100% rename from crates/engine/src/synthetic.rs rename to crates/engine/src/content/synthetic.rs diff --git a/crates/engine/src/synthetic_ds.rs b/crates/engine/src/content/synthetic_ds.rs similarity index 100% rename from crates/engine/src/synthetic_ds.rs rename to crates/engine/src/content/synthetic_ds.rs diff --git a/crates/engine/src/templates.rs b/crates/engine/src/content/templates.rs similarity index 100% rename from crates/engine/src/templates.rs rename to crates/engine/src/content/templates.rs diff --git a/crates/engine/src/templates/dashboard.rs b/crates/engine/src/content/templates/dashboard.rs similarity index 100% rename from crates/engine/src/templates/dashboard.rs rename to crates/engine/src/content/templates/dashboard.rs diff --git a/crates/engine/src/templates/design_system.rs b/crates/engine/src/content/templates/design_system.rs similarity index 100% rename from crates/engine/src/templates/design_system.rs rename to crates/engine/src/content/templates/design_system.rs diff --git a/crates/engine/src/templates/glyphs.rs b/crates/engine/src/content/templates/glyphs.rs similarity index 100% rename from crates/engine/src/templates/glyphs.rs rename to crates/engine/src/content/templates/glyphs.rs diff --git a/crates/engine/src/templates/icon_set.rs b/crates/engine/src/content/templates/icon_set.rs similarity index 100% rename from crates/engine/src/templates/icon_set.rs rename to crates/engine/src/content/templates/icon_set.rs diff --git a/crates/engine/src/templates/kit.rs b/crates/engine/src/content/templates/kit.rs similarity index 100% rename from crates/engine/src/templates/kit.rs rename to crates/engine/src/content/templates/kit.rs diff --git a/crates/engine/src/templates/landing.rs b/crates/engine/src/content/templates/landing.rs similarity index 100% rename from crates/engine/src/templates/landing.rs rename to crates/engine/src/content/templates/landing.rs diff --git a/crates/engine/src/templates/mobile.rs b/crates/engine/src/content/templates/mobile.rs similarity index 100% rename from crates/engine/src/templates/mobile.rs rename to crates/engine/src/content/templates/mobile.rs diff --git a/crates/engine/src/templates/slides.rs b/crates/engine/src/content/templates/slides.rs similarity index 100% rename from crates/engine/src/templates/slides.rs rename to crates/engine/src/content/templates/slides.rs diff --git a/crates/engine/src/templates/social.rs b/crates/engine/src/content/templates/social.rs similarity index 100% rename from crates/engine/src/templates/social.rs rename to crates/engine/src/content/templates/social.rs diff --git a/crates/engine/src/templates/wireframe.rs b/crates/engine/src/content/templates/wireframe.rs similarity index 100% rename from crates/engine/src/templates/wireframe.rs rename to crates/engine/src/content/templates/wireframe.rs diff --git a/crates/engine/src/textfit.rs b/crates/engine/src/content/textfit.rs similarity index 100% rename from crates/engine/src/textfit.rs rename to crates/engine/src/content/textfit.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6775f807..4a4894b3 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -15,7 +15,7 @@ mod boards; mod canvas; pub mod catalog; pub mod cmd; -pub mod datafill; +mod content; mod dev; pub mod doc_urls; mod extensions; @@ -29,10 +29,6 @@ pub mod params; pub mod proto_run; mod selection; mod sharing; -pub mod synthetic; -pub mod synthetic_ds; -pub mod templates; -pub mod textfit; pub use assets::components; pub use assets::library; pub use assets::library_server; @@ -57,6 +53,11 @@ pub use canvas::textedit; pub use canvas::textpreview; pub use canvas::tools; pub use canvas::vectoredit; +pub use content::datafill; +pub use content::synthetic; +pub use content::synthetic_ds; +pub use content::templates; +pub use content::textfit; pub use dev::annotations; pub use dev::code_connect; pub use dev::codegen; From e1b0ea2c1df0505ce73259d893bc2a8dbdc4aab6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:08:41 +0000 Subject: [PATCH 18/67] refactor(engine): move batch, params, inspect, file_access, doc_urls to automation/ --- crates/engine/src/automation.rs | 5 +++++ crates/engine/src/{ => automation}/batch.rs | 0 crates/engine/src/{ => automation}/doc_urls.rs | 0 crates/engine/src/{ => automation}/file_access.rs | 0 .../engine/src/{ => automation}/file_access_tests.rs | 0 crates/engine/src/{ => automation}/inspect.rs | 0 crates/engine/src/{ => automation}/params.rs | 0 crates/engine/src/lib.rs | 11 ++++++----- 8 files changed, 11 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/automation.rs rename crates/engine/src/{ => automation}/batch.rs (100%) rename crates/engine/src/{ => automation}/doc_urls.rs (100%) rename crates/engine/src/{ => automation}/file_access.rs (100%) rename crates/engine/src/{ => automation}/file_access_tests.rs (100%) rename crates/engine/src/{ => automation}/inspect.rs (100%) rename crates/engine/src/{ => automation}/params.rs (100%) diff --git a/crates/engine/src/automation.rs b/crates/engine/src/automation.rs new file mode 100644 index 00000000..acf11e5c --- /dev/null +++ b/crates/engine/src/automation.rs @@ -0,0 +1,5 @@ +pub mod batch; +pub mod doc_urls; +pub mod file_access; +pub mod inspect; +pub mod params; diff --git a/crates/engine/src/batch.rs b/crates/engine/src/automation/batch.rs similarity index 100% rename from crates/engine/src/batch.rs rename to crates/engine/src/automation/batch.rs diff --git a/crates/engine/src/doc_urls.rs b/crates/engine/src/automation/doc_urls.rs similarity index 100% rename from crates/engine/src/doc_urls.rs rename to crates/engine/src/automation/doc_urls.rs diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/automation/file_access.rs similarity index 100% rename from crates/engine/src/file_access.rs rename to crates/engine/src/automation/file_access.rs diff --git a/crates/engine/src/file_access_tests.rs b/crates/engine/src/automation/file_access_tests.rs similarity index 100% rename from crates/engine/src/file_access_tests.rs rename to crates/engine/src/automation/file_access_tests.rs diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/automation/inspect.rs similarity index 100% rename from crates/engine/src/inspect.rs rename to crates/engine/src/automation/inspect.rs diff --git a/crates/engine/src/params.rs b/crates/engine/src/automation/params.rs similarity index 100% rename from crates/engine/src/params.rs rename to crates/engine/src/automation/params.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4a4894b3..9910e027 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -10,22 +10,18 @@ #![cfg_attr(test, allow(clippy::disallowed_methods))] mod assets; -pub mod batch; +mod automation; mod boards; mod canvas; pub mod catalog; pub mod cmd; mod content; mod dev; -pub mod doc_urls; mod extensions; -pub mod file_access; pub mod fonts; pub mod guard; -pub mod inspect; pub mod keymap; pub mod measure; -pub mod params; pub mod proto_run; mod selection; mod sharing; @@ -36,6 +32,11 @@ pub use assets::library_swap; pub use assets::playground; pub use assets::style_vars; pub use assets::token_roles; +pub use automation::batch; +pub use automation::doc_urls; +pub use automation::file_access; +pub use automation::inspect; +pub use automation::params; pub use boards::board; pub use boards::board_table; pub use boards::board_templates; From 3e23a0f884043fcd5bdb2a22fce25e55160140d1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:08:41 +0000 Subject: [PATCH 19/67] refactor(ui-egui): move cursors, frame_labels, frame_stats, guides, handles, measure_overlay, proto_canvas, render_worker, selection_spacing, live_text, comments, board_bar, board_facilitate, widget_bar to canvas/ --- crates/ui-egui/src/canvas.rs | 14 ++++++++++ crates/ui-egui/src/{ => canvas}/board_bar.rs | 0 .../src/{ => canvas}/board_facilitate.rs | 0 crates/ui-egui/src/{ => canvas}/comments.rs | 0 crates/ui-egui/src/{ => canvas}/cursors.rs | 0 .../ui-egui/src/{ => canvas}/frame_labels.rs | 0 .../ui-egui/src/{ => canvas}/frame_stats.rs | 0 crates/ui-egui/src/{ => canvas}/guides.rs | 0 crates/ui-egui/src/{ => canvas}/handles.rs | 0 crates/ui-egui/src/{ => canvas}/live_text.rs | 0 .../src/{ => canvas}/measure_overlay.rs | 0 .../ui-egui/src/{ => canvas}/proto_canvas.rs | 0 .../ui-egui/src/{ => canvas}/render_worker.rs | 0 .../src/{ => canvas}/selection_spacing.rs | 0 crates/ui-egui/src/{ => canvas}/widget_bar.rs | 0 crates/ui-egui/src/lib.rs | 28 +++++++++---------- 16 files changed, 28 insertions(+), 14 deletions(-) rename crates/ui-egui/src/{ => canvas}/board_bar.rs (100%) rename crates/ui-egui/src/{ => canvas}/board_facilitate.rs (100%) rename crates/ui-egui/src/{ => canvas}/comments.rs (100%) rename crates/ui-egui/src/{ => canvas}/cursors.rs (100%) rename crates/ui-egui/src/{ => canvas}/frame_labels.rs (100%) rename crates/ui-egui/src/{ => canvas}/frame_stats.rs (100%) rename crates/ui-egui/src/{ => canvas}/guides.rs (100%) rename crates/ui-egui/src/{ => canvas}/handles.rs (100%) rename crates/ui-egui/src/{ => canvas}/live_text.rs (100%) rename crates/ui-egui/src/{ => canvas}/measure_overlay.rs (100%) rename crates/ui-egui/src/{ => canvas}/proto_canvas.rs (100%) rename crates/ui-egui/src/{ => canvas}/render_worker.rs (100%) rename crates/ui-egui/src/{ => canvas}/selection_spacing.rs (100%) rename crates/ui-egui/src/{ => canvas}/widget_bar.rs (100%) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index d227940c..724b441e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -13,6 +13,20 @@ use crate::FigApp; use crate::i18n::{tr, trf, trn}; use crate::render_worker::{Done, Job, Worker}; use crate::theme::{COMPONENT, Tokens, doc_c32, regular, semibold}; +pub mod board_bar; +pub mod board_facilitate; +pub mod comments; +pub mod cursors; +pub mod frame_labels; +pub mod frame_stats; +pub mod guides; +pub mod handles; +pub mod live_text; +pub mod measure_overlay; +pub mod proto_canvas; +pub mod render_worker; +pub(crate) mod selection_spacing; +pub mod widget_bar; pub struct CanvasState { pub rect: Rect, diff --git a/crates/ui-egui/src/board_bar.rs b/crates/ui-egui/src/canvas/board_bar.rs similarity index 100% rename from crates/ui-egui/src/board_bar.rs rename to crates/ui-egui/src/canvas/board_bar.rs diff --git a/crates/ui-egui/src/board_facilitate.rs b/crates/ui-egui/src/canvas/board_facilitate.rs similarity index 100% rename from crates/ui-egui/src/board_facilitate.rs rename to crates/ui-egui/src/canvas/board_facilitate.rs diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/canvas/comments.rs similarity index 100% rename from crates/ui-egui/src/comments.rs rename to crates/ui-egui/src/canvas/comments.rs diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/canvas/cursors.rs similarity index 100% rename from crates/ui-egui/src/cursors.rs rename to crates/ui-egui/src/canvas/cursors.rs diff --git a/crates/ui-egui/src/frame_labels.rs b/crates/ui-egui/src/canvas/frame_labels.rs similarity index 100% rename from crates/ui-egui/src/frame_labels.rs rename to crates/ui-egui/src/canvas/frame_labels.rs diff --git a/crates/ui-egui/src/frame_stats.rs b/crates/ui-egui/src/canvas/frame_stats.rs similarity index 100% rename from crates/ui-egui/src/frame_stats.rs rename to crates/ui-egui/src/canvas/frame_stats.rs diff --git a/crates/ui-egui/src/guides.rs b/crates/ui-egui/src/canvas/guides.rs similarity index 100% rename from crates/ui-egui/src/guides.rs rename to crates/ui-egui/src/canvas/guides.rs diff --git a/crates/ui-egui/src/handles.rs b/crates/ui-egui/src/canvas/handles.rs similarity index 100% rename from crates/ui-egui/src/handles.rs rename to crates/ui-egui/src/canvas/handles.rs diff --git a/crates/ui-egui/src/live_text.rs b/crates/ui-egui/src/canvas/live_text.rs similarity index 100% rename from crates/ui-egui/src/live_text.rs rename to crates/ui-egui/src/canvas/live_text.rs diff --git a/crates/ui-egui/src/measure_overlay.rs b/crates/ui-egui/src/canvas/measure_overlay.rs similarity index 100% rename from crates/ui-egui/src/measure_overlay.rs rename to crates/ui-egui/src/canvas/measure_overlay.rs diff --git a/crates/ui-egui/src/proto_canvas.rs b/crates/ui-egui/src/canvas/proto_canvas.rs similarity index 100% rename from crates/ui-egui/src/proto_canvas.rs rename to crates/ui-egui/src/canvas/proto_canvas.rs diff --git a/crates/ui-egui/src/render_worker.rs b/crates/ui-egui/src/canvas/render_worker.rs similarity index 100% rename from crates/ui-egui/src/render_worker.rs rename to crates/ui-egui/src/canvas/render_worker.rs diff --git a/crates/ui-egui/src/selection_spacing.rs b/crates/ui-egui/src/canvas/selection_spacing.rs similarity index 100% rename from crates/ui-egui/src/selection_spacing.rs rename to crates/ui-egui/src/canvas/selection_spacing.rs diff --git a/crates/ui-egui/src/widget_bar.rs b/crates/ui-egui/src/canvas/widget_bar.rs similarity index 100% rename from crates/ui-egui/src/widget_bar.rs rename to crates/ui-egui/src/canvas/widget_bar.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e5bc32a0..8f70ea85 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -8,29 +8,21 @@ pub mod a11y; pub mod assets_panel; pub mod bidi; -pub mod board_bar; -pub mod board_facilitate; mod board_picker; pub mod branch_ui; pub mod canvas; pub mod cjk; pub mod colorpicker; -pub mod comments; pub mod component_panel; pub mod control; pub mod credits; -pub mod cursors; pub mod dev_compare; pub mod dev_mode; pub mod dialogs; pub mod export_panel; pub mod field_math; pub mod font_preview; -pub mod frame_labels; pub mod frame_presets; -pub mod frame_stats; -pub mod guides; -pub mod handles; pub mod home; pub mod home_recents; pub mod i18n; @@ -40,23 +32,18 @@ pub mod layers; pub mod layout_panel; pub mod list_drag; pub mod live; -pub mod live_text; -pub mod measure_overlay; pub mod menus; pub mod missing_fonts; pub mod online; pub mod pending_open; pub mod plugins_ui; pub mod present; -pub mod proto_canvas; pub mod proto_editor; pub mod proto_panel; -pub mod render_worker; pub mod right; pub mod scale_panel; pub mod section_panel; pub mod selection_colors; -mod selection_spacing; pub mod shortcuts; pub mod shortcuts_panel; pub mod style_picker; @@ -70,7 +57,6 @@ pub mod variable_ui; pub mod variables_panel; pub mod vector_panel; pub mod versions; -pub mod widget_bar; pub mod widgets; pub mod windows; @@ -259,6 +245,20 @@ mod web_save_tests; mod windows_qa_tests; #[cfg(test)] mod windows_tests; +pub use canvas::board_bar; +pub use canvas::board_facilitate; +pub use canvas::comments; +pub use canvas::cursors; +pub use canvas::frame_labels; +pub use canvas::frame_stats; +pub use canvas::guides; +pub use canvas::handles; +pub use canvas::live_text; +pub use canvas::measure_overlay; +pub use canvas::proto_canvas; +pub use canvas::render_worker; +use canvas::selection_spacing; +pub use canvas::widget_bar; use std::collections::BTreeSet; use std::ffi::OsStr; From a0e33576d750ae89950bb15a16be50727843e397 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:08:41 +0000 Subject: [PATCH 20/67] refactor(ui-egui): move layers, assets_panel, versions, right, component_panel, export_panel, layout_panel, section_panel, selection_colors, typography, variable_ui, vector_panel, scale_panel, frame_presets, list_drag, proto_panel, proto_editor, dev_mode, dev_compare, shortcuts_panel to panels/ --- crates/ui-egui/src/lib.rs | 41 ++++++++++--------- crates/ui-egui/src/panels.rs | 20 +++++++++ .../ui-egui/src/{ => panels}/assets_panel.rs | 0 .../src/{ => panels}/component_panel.rs | 0 .../ui-egui/src/{ => panels}/dev_compare.rs | 0 crates/ui-egui/src/{ => panels}/dev_mode.rs | 0 .../ui-egui/src/{ => panels}/export_panel.rs | 0 .../ui-egui/src/{ => panels}/frame_presets.rs | 0 crates/ui-egui/src/{ => panels}/layers.rs | 0 .../ui-egui/src/{ => panels}/layout_panel.rs | 0 crates/ui-egui/src/{ => panels}/list_drag.rs | 0 .../ui-egui/src/{ => panels}/proto_editor.rs | 0 .../ui-egui/src/{ => panels}/proto_panel.rs | 0 crates/ui-egui/src/{ => panels}/right.rs | 0 .../ui-egui/src/{ => panels}/scale_panel.rs | 0 .../ui-egui/src/{ => panels}/section_panel.rs | 0 .../src/{ => panels}/selection_colors.rs | 0 .../src/{ => panels}/shortcuts_panel.rs | 0 crates/ui-egui/src/{ => panels}/typography.rs | 0 .../ui-egui/src/{ => panels}/variable_ui.rs | 0 .../ui-egui/src/{ => panels}/vector_panel.rs | 0 crates/ui-egui/src/{ => panels}/versions.rs | 0 22 files changed, 41 insertions(+), 20 deletions(-) create mode 100644 crates/ui-egui/src/panels.rs rename crates/ui-egui/src/{ => panels}/assets_panel.rs (100%) rename crates/ui-egui/src/{ => panels}/component_panel.rs (100%) rename crates/ui-egui/src/{ => panels}/dev_compare.rs (100%) rename crates/ui-egui/src/{ => panels}/dev_mode.rs (100%) rename crates/ui-egui/src/{ => panels}/export_panel.rs (100%) rename crates/ui-egui/src/{ => panels}/frame_presets.rs (100%) rename crates/ui-egui/src/{ => panels}/layers.rs (100%) rename crates/ui-egui/src/{ => panels}/layout_panel.rs (100%) rename crates/ui-egui/src/{ => panels}/list_drag.rs (100%) rename crates/ui-egui/src/{ => panels}/proto_editor.rs (100%) rename crates/ui-egui/src/{ => panels}/proto_panel.rs (100%) rename crates/ui-egui/src/{ => panels}/right.rs (100%) rename crates/ui-egui/src/{ => panels}/scale_panel.rs (100%) rename crates/ui-egui/src/{ => panels}/section_panel.rs (100%) rename crates/ui-egui/src/{ => panels}/selection_colors.rs (100%) rename crates/ui-egui/src/{ => panels}/shortcuts_panel.rs (100%) rename crates/ui-egui/src/{ => panels}/typography.rs (100%) rename crates/ui-egui/src/{ => panels}/variable_ui.rs (100%) rename crates/ui-egui/src/{ => panels}/vector_panel.rs (100%) rename crates/ui-egui/src/{ => panels}/versions.rs (100%) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8f70ea85..a5b1ad70 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -6,31 +6,22 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod a11y; -pub mod assets_panel; pub mod bidi; mod board_picker; pub mod branch_ui; pub mod canvas; pub mod cjk; pub mod colorpicker; -pub mod component_panel; pub mod control; pub mod credits; -pub mod dev_compare; -pub mod dev_mode; pub mod dialogs; -pub mod export_panel; pub mod field_math; pub mod font_preview; -pub mod frame_presets; pub mod home; pub mod home_recents; pub mod i18n; pub mod icons; pub mod import_report; -pub mod layers; -pub mod layout_panel; -pub mod list_drag; pub mod live; pub mod menus; pub mod missing_fonts; @@ -38,25 +29,14 @@ pub mod online; pub mod pending_open; pub mod plugins_ui; pub mod present; -pub mod proto_editor; -pub mod proto_panel; -pub mod right; -pub mod scale_panel; -pub mod section_panel; -pub mod selection_colors; pub mod shortcuts; -pub mod shortcuts_panel; pub mod style_picker; pub mod tabs; pub mod theme; pub mod toolbar; -pub mod typography; pub mod ui_scale; pub mod updates_ui; -pub mod variable_ui; pub mod variables_panel; -pub mod vector_panel; -pub mod versions; pub mod widgets; pub mod windows; @@ -177,6 +157,7 @@ mod panel_remove_tests; mod panel_reorder_tests; #[cfg(test)] mod panel_width_tests; +mod panels; #[cfg(test)] mod paste_image_tests; #[cfg(test)] @@ -259,6 +240,26 @@ pub use canvas::proto_canvas; pub use canvas::render_worker; use canvas::selection_spacing; pub use canvas::widget_bar; +pub use panels::assets_panel; +pub use panels::component_panel; +pub use panels::dev_compare; +pub use panels::dev_mode; +pub use panels::export_panel; +pub use panels::frame_presets; +pub use panels::layers; +pub use panels::layout_panel; +pub use panels::list_drag; +pub use panels::proto_editor; +pub use panels::proto_panel; +pub use panels::right; +pub use panels::scale_panel; +pub use panels::section_panel; +pub use panels::selection_colors; +pub use panels::shortcuts_panel; +pub use panels::typography; +pub use panels::variable_ui; +pub use panels::vector_panel; +pub use panels::versions; use std::collections::BTreeSet; use std::ffi::OsStr; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs new file mode 100644 index 00000000..8dd0df07 --- /dev/null +++ b/crates/ui-egui/src/panels.rs @@ -0,0 +1,20 @@ +pub mod assets_panel; +pub mod component_panel; +pub mod dev_compare; +pub mod dev_mode; +pub mod export_panel; +pub mod frame_presets; +pub mod layers; +pub mod layout_panel; +pub mod list_drag; +pub mod proto_editor; +pub mod proto_panel; +pub mod right; +pub mod scale_panel; +pub mod section_panel; +pub mod selection_colors; +pub mod shortcuts_panel; +pub mod typography; +pub mod variable_ui; +pub mod vector_panel; +pub mod versions; diff --git a/crates/ui-egui/src/assets_panel.rs b/crates/ui-egui/src/panels/assets_panel.rs similarity index 100% rename from crates/ui-egui/src/assets_panel.rs rename to crates/ui-egui/src/panels/assets_panel.rs diff --git a/crates/ui-egui/src/component_panel.rs b/crates/ui-egui/src/panels/component_panel.rs similarity index 100% rename from crates/ui-egui/src/component_panel.rs rename to crates/ui-egui/src/panels/component_panel.rs diff --git a/crates/ui-egui/src/dev_compare.rs b/crates/ui-egui/src/panels/dev_compare.rs similarity index 100% rename from crates/ui-egui/src/dev_compare.rs rename to crates/ui-egui/src/panels/dev_compare.rs diff --git a/crates/ui-egui/src/dev_mode.rs b/crates/ui-egui/src/panels/dev_mode.rs similarity index 100% rename from crates/ui-egui/src/dev_mode.rs rename to crates/ui-egui/src/panels/dev_mode.rs diff --git a/crates/ui-egui/src/export_panel.rs b/crates/ui-egui/src/panels/export_panel.rs similarity index 100% rename from crates/ui-egui/src/export_panel.rs rename to crates/ui-egui/src/panels/export_panel.rs diff --git a/crates/ui-egui/src/frame_presets.rs b/crates/ui-egui/src/panels/frame_presets.rs similarity index 100% rename from crates/ui-egui/src/frame_presets.rs rename to crates/ui-egui/src/panels/frame_presets.rs diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/panels/layers.rs similarity index 100% rename from crates/ui-egui/src/layers.rs rename to crates/ui-egui/src/panels/layers.rs diff --git a/crates/ui-egui/src/layout_panel.rs b/crates/ui-egui/src/panels/layout_panel.rs similarity index 100% rename from crates/ui-egui/src/layout_panel.rs rename to crates/ui-egui/src/panels/layout_panel.rs diff --git a/crates/ui-egui/src/list_drag.rs b/crates/ui-egui/src/panels/list_drag.rs similarity index 100% rename from crates/ui-egui/src/list_drag.rs rename to crates/ui-egui/src/panels/list_drag.rs diff --git a/crates/ui-egui/src/proto_editor.rs b/crates/ui-egui/src/panels/proto_editor.rs similarity index 100% rename from crates/ui-egui/src/proto_editor.rs rename to crates/ui-egui/src/panels/proto_editor.rs diff --git a/crates/ui-egui/src/proto_panel.rs b/crates/ui-egui/src/panels/proto_panel.rs similarity index 100% rename from crates/ui-egui/src/proto_panel.rs rename to crates/ui-egui/src/panels/proto_panel.rs diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/panels/right.rs similarity index 100% rename from crates/ui-egui/src/right.rs rename to crates/ui-egui/src/panels/right.rs diff --git a/crates/ui-egui/src/scale_panel.rs b/crates/ui-egui/src/panels/scale_panel.rs similarity index 100% rename from crates/ui-egui/src/scale_panel.rs rename to crates/ui-egui/src/panels/scale_panel.rs diff --git a/crates/ui-egui/src/section_panel.rs b/crates/ui-egui/src/panels/section_panel.rs similarity index 100% rename from crates/ui-egui/src/section_panel.rs rename to crates/ui-egui/src/panels/section_panel.rs diff --git a/crates/ui-egui/src/selection_colors.rs b/crates/ui-egui/src/panels/selection_colors.rs similarity index 100% rename from crates/ui-egui/src/selection_colors.rs rename to crates/ui-egui/src/panels/selection_colors.rs diff --git a/crates/ui-egui/src/shortcuts_panel.rs b/crates/ui-egui/src/panels/shortcuts_panel.rs similarity index 100% rename from crates/ui-egui/src/shortcuts_panel.rs rename to crates/ui-egui/src/panels/shortcuts_panel.rs diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/panels/typography.rs similarity index 100% rename from crates/ui-egui/src/typography.rs rename to crates/ui-egui/src/panels/typography.rs diff --git a/crates/ui-egui/src/variable_ui.rs b/crates/ui-egui/src/panels/variable_ui.rs similarity index 100% rename from crates/ui-egui/src/variable_ui.rs rename to crates/ui-egui/src/panels/variable_ui.rs diff --git a/crates/ui-egui/src/vector_panel.rs b/crates/ui-egui/src/panels/vector_panel.rs similarity index 100% rename from crates/ui-egui/src/vector_panel.rs rename to crates/ui-egui/src/panels/vector_panel.rs diff --git a/crates/ui-egui/src/versions.rs b/crates/ui-egui/src/panels/versions.rs similarity index 100% rename from crates/ui-egui/src/versions.rs rename to crates/ui-egui/src/panels/versions.rs From 525d5cb6a160a7c265cb6938da9102fb66ae93a0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:08:41 +0000 Subject: [PATCH 21/67] refactor(ui-egui): move credits, missing_fonts, import_report, updates_ui, variables_panel, branch_ui, plugins_ui to dialogs/ --- crates/ui-egui/src/dialogs.rs | 7 +++++++ crates/ui-egui/src/{ => dialogs}/branch_ui.rs | 0 crates/ui-egui/src/{ => dialogs}/credits.rs | 0 crates/ui-egui/src/{ => dialogs}/credits_tests.rs | 0 crates/ui-egui/src/{ => dialogs}/import_report.rs | 0 crates/ui-egui/src/{ => dialogs}/missing_fonts.rs | 0 crates/ui-egui/src/{ => dialogs}/plugins_ui.rs | 0 crates/ui-egui/src/{ => dialogs}/updates_ui.rs | 0 .../ui-egui/src/{ => dialogs}/variables_panel.rs | 0 crates/ui-egui/src/lib.rs | 14 +++++++------- 10 files changed, 14 insertions(+), 7 deletions(-) rename crates/ui-egui/src/{ => dialogs}/branch_ui.rs (100%) rename crates/ui-egui/src/{ => dialogs}/credits.rs (100%) rename crates/ui-egui/src/{ => dialogs}/credits_tests.rs (100%) rename crates/ui-egui/src/{ => dialogs}/import_report.rs (100%) rename crates/ui-egui/src/{ => dialogs}/missing_fonts.rs (100%) rename crates/ui-egui/src/{ => dialogs}/plugins_ui.rs (100%) rename crates/ui-egui/src/{ => dialogs}/updates_ui.rs (100%) rename crates/ui-egui/src/{ => dialogs}/variables_panel.rs (100%) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8ada24e8..f10e008a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -13,6 +13,13 @@ use crate::FigApp; use crate::export_panel::{export_layers, save_one, suggested_name}; use crate::i18n::{self, tr, tr_noop, trf, trn}; use crate::theme::{Tokens, regular, semibold}; +pub mod branch_ui; +pub mod credits; +pub mod import_report; +pub mod missing_fonts; +pub mod plugins_ui; +pub mod updates_ui; +pub mod variables_panel; pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/crates/ui-egui/src/branch_ui.rs b/crates/ui-egui/src/dialogs/branch_ui.rs similarity index 100% rename from crates/ui-egui/src/branch_ui.rs rename to crates/ui-egui/src/dialogs/branch_ui.rs diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/dialogs/credits.rs similarity index 100% rename from crates/ui-egui/src/credits.rs rename to crates/ui-egui/src/dialogs/credits.rs diff --git a/crates/ui-egui/src/credits_tests.rs b/crates/ui-egui/src/dialogs/credits_tests.rs similarity index 100% rename from crates/ui-egui/src/credits_tests.rs rename to crates/ui-egui/src/dialogs/credits_tests.rs diff --git a/crates/ui-egui/src/import_report.rs b/crates/ui-egui/src/dialogs/import_report.rs similarity index 100% rename from crates/ui-egui/src/import_report.rs rename to crates/ui-egui/src/dialogs/import_report.rs diff --git a/crates/ui-egui/src/missing_fonts.rs b/crates/ui-egui/src/dialogs/missing_fonts.rs similarity index 100% rename from crates/ui-egui/src/missing_fonts.rs rename to crates/ui-egui/src/dialogs/missing_fonts.rs diff --git a/crates/ui-egui/src/plugins_ui.rs b/crates/ui-egui/src/dialogs/plugins_ui.rs similarity index 100% rename from crates/ui-egui/src/plugins_ui.rs rename to crates/ui-egui/src/dialogs/plugins_ui.rs diff --git a/crates/ui-egui/src/updates_ui.rs b/crates/ui-egui/src/dialogs/updates_ui.rs similarity index 100% rename from crates/ui-egui/src/updates_ui.rs rename to crates/ui-egui/src/dialogs/updates_ui.rs diff --git a/crates/ui-egui/src/variables_panel.rs b/crates/ui-egui/src/dialogs/variables_panel.rs similarity index 100% rename from crates/ui-egui/src/variables_panel.rs rename to crates/ui-egui/src/dialogs/variables_panel.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a5b1ad70..7e64906d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -8,12 +8,10 @@ pub mod a11y; pub mod bidi; mod board_picker; -pub mod branch_ui; pub mod canvas; pub mod cjk; pub mod colorpicker; pub mod control; -pub mod credits; pub mod dialogs; pub mod field_math; pub mod font_preview; @@ -21,13 +19,10 @@ pub mod home; pub mod home_recents; pub mod i18n; pub mod icons; -pub mod import_report; pub mod live; pub mod menus; -pub mod missing_fonts; pub mod online; pub mod pending_open; -pub mod plugins_ui; pub mod present; pub mod shortcuts; pub mod style_picker; @@ -35,8 +30,6 @@ pub mod tabs; pub mod theme; pub mod toolbar; pub mod ui_scale; -pub mod updates_ui; -pub mod variables_panel; pub mod widgets; pub mod windows; @@ -240,6 +233,13 @@ pub use canvas::proto_canvas; pub use canvas::render_worker; use canvas::selection_spacing; pub use canvas::widget_bar; +pub use dialogs::branch_ui; +pub use dialogs::credits; +pub use dialogs::import_report; +pub use dialogs::missing_fonts; +pub use dialogs::plugins_ui; +pub use dialogs::updates_ui; +pub use dialogs::variables_panel; pub use panels::assets_panel; pub use panels::component_panel; pub use panels::dev_compare; From 49ca56c3a42e73bfdb53071533831c730cb7577f Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:08:41 +0000 Subject: [PATCH 22/67] refactor(ui-egui): move colorpicker, style_picker, field_math, font_preview, board_picker to widgets/ --- crates/ui-egui/src/lib.rs | 10 +++++----- crates/ui-egui/src/widgets.rs | 5 +++++ crates/ui-egui/src/{ => widgets}/board_picker.rs | 0 crates/ui-egui/src/{ => widgets}/colorpicker.rs | 0 crates/ui-egui/src/{ => widgets}/field_math.rs | 0 crates/ui-egui/src/{ => widgets}/font_preview.rs | 0 crates/ui-egui/src/{ => widgets}/style_picker.rs | 0 crates/ui-egui/src/{ => widgets}/style_picker_tests.rs | 0 8 files changed, 10 insertions(+), 5 deletions(-) rename crates/ui-egui/src/{ => widgets}/board_picker.rs (100%) rename crates/ui-egui/src/{ => widgets}/colorpicker.rs (100%) rename crates/ui-egui/src/{ => widgets}/field_math.rs (100%) rename crates/ui-egui/src/{ => widgets}/font_preview.rs (100%) rename crates/ui-egui/src/{ => widgets}/style_picker.rs (100%) rename crates/ui-egui/src/{ => widgets}/style_picker_tests.rs (100%) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7e64906d..f9c2e623 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -7,14 +7,10 @@ pub mod a11y; pub mod bidi; -mod board_picker; pub mod canvas; pub mod cjk; -pub mod colorpicker; pub mod control; pub mod dialogs; -pub mod field_math; -pub mod font_preview; pub mod home; pub mod home_recents; pub mod i18n; @@ -25,7 +21,6 @@ pub mod online; pub mod pending_open; pub mod present; pub mod shortcuts; -pub mod style_picker; pub mod tabs; pub mod theme; pub mod toolbar; @@ -260,6 +255,11 @@ pub use panels::typography; pub use panels::variable_ui; pub use panels::vector_panel; pub use panels::versions; +use widgets::board_picker; +pub use widgets::colorpicker; +pub use widgets::field_math; +pub use widgets::font_preview; +pub use widgets::style_picker; use std::collections::BTreeSet; use std::ffi::OsStr; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 5f08a998..0a39604e 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -8,6 +8,11 @@ pub use crate::field_math::{Rel, Typed, eval, parse, stepped}; use crate::i18n::tr; use crate::icons::{self, Icon}; use crate::theme::{Tokens, regular, semibold}; +pub(crate) mod board_picker; +pub mod colorpicker; +pub mod field_math; +pub mod font_preview; +pub mod style_picker; pub const ROW_H: f32 = 32.0; pub const FIELD_H: f32 = 24.0; diff --git a/crates/ui-egui/src/board_picker.rs b/crates/ui-egui/src/widgets/board_picker.rs similarity index 100% rename from crates/ui-egui/src/board_picker.rs rename to crates/ui-egui/src/widgets/board_picker.rs diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/widgets/colorpicker.rs similarity index 100% rename from crates/ui-egui/src/colorpicker.rs rename to crates/ui-egui/src/widgets/colorpicker.rs diff --git a/crates/ui-egui/src/field_math.rs b/crates/ui-egui/src/widgets/field_math.rs similarity index 100% rename from crates/ui-egui/src/field_math.rs rename to crates/ui-egui/src/widgets/field_math.rs diff --git a/crates/ui-egui/src/font_preview.rs b/crates/ui-egui/src/widgets/font_preview.rs similarity index 100% rename from crates/ui-egui/src/font_preview.rs rename to crates/ui-egui/src/widgets/font_preview.rs diff --git a/crates/ui-egui/src/style_picker.rs b/crates/ui-egui/src/widgets/style_picker.rs similarity index 100% rename from crates/ui-egui/src/style_picker.rs rename to crates/ui-egui/src/widgets/style_picker.rs diff --git a/crates/ui-egui/src/style_picker_tests.rs b/crates/ui-egui/src/widgets/style_picker_tests.rs similarity index 100% rename from crates/ui-egui/src/style_picker_tests.rs rename to crates/ui-egui/src/widgets/style_picker_tests.rs From 7d3978bedf3f47d0da75e706ab67d0d32ba39e51 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:08:42 +0000 Subject: [PATCH 23/67] refactor(ui-egui): move home_recents, online to home/ --- crates/ui-egui/src/home.rs | 2 ++ crates/ui-egui/src/{ => home}/home_recents.rs | 0 crates/ui-egui/src/{ => home}/online.rs | 0 crates/ui-egui/src/lib.rs | 4 ++-- 4 files changed, 4 insertions(+), 2 deletions(-) rename crates/ui-egui/src/{ => home}/home_recents.rs (100%) rename crates/ui-egui/src/{ => home}/online.rs (100%) diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 922c0fda..05c8fcda 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -15,6 +15,8 @@ use crate::i18n::{tr, tr_noop}; use crate::icons::{self, Icon}; use crate::theme::{ACCENT, Tokens, regular, semibold}; use crate::widgets; +pub mod home_recents; +pub mod online; /// The section the home screen shows. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] diff --git a/crates/ui-egui/src/home_recents.rs b/crates/ui-egui/src/home/home_recents.rs similarity index 100% rename from crates/ui-egui/src/home_recents.rs rename to crates/ui-egui/src/home/home_recents.rs diff --git a/crates/ui-egui/src/online.rs b/crates/ui-egui/src/home/online.rs similarity index 100% rename from crates/ui-egui/src/online.rs rename to crates/ui-egui/src/home/online.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f9c2e623..1e89a3e7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -12,12 +12,10 @@ pub mod cjk; pub mod control; pub mod dialogs; pub mod home; -pub mod home_recents; pub mod i18n; pub mod icons; pub mod live; pub mod menus; -pub mod online; pub mod pending_open; pub mod present; pub mod shortcuts; @@ -235,6 +233,8 @@ pub use dialogs::missing_fonts; pub use dialogs::plugins_ui; pub use dialogs::updates_ui; pub use dialogs::variables_panel; +pub use home::home_recents; +pub use home::online; pub use panels::assets_panel; pub use panels::component_panel; pub use panels::dev_compare; From 2e7790b58baea5896e20514730b75d5b9cb2f08b Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:20:45 +0000 Subject: [PATCH 24/67] refactor(engine): split canvas/tools.rs into tools/ --- crates/engine/src/canvas/tools.rs | 2335 +-------------------- crates/engine/src/canvas/tools/click.rs | 344 +++ crates/engine/src/canvas/tools/drag.rs | 605 ++++++ crates/engine/src/canvas/tools/draw.rs | 229 ++ crates/engine/src/canvas/tools/handles.rs | 426 ++++ crates/engine/src/canvas/tools/keys.rs | 174 ++ crates/engine/src/canvas/tools/pen.rs | 276 +++ crates/engine/src/canvas/tools/pointer.rs | 292 +++ 8 files changed, 2365 insertions(+), 2316 deletions(-) create mode 100644 crates/engine/src/canvas/tools/click.rs create mode 100644 crates/engine/src/canvas/tools/drag.rs create mode 100644 crates/engine/src/canvas/tools/draw.rs create mode 100644 crates/engine/src/canvas/tools/handles.rs create mode 100644 crates/engine/src/canvas/tools/keys.rs create mode 100644 crates/engine/src/canvas/tools/pen.rs create mode 100644 crates/engine/src/canvas/tools/pointer.rs diff --git a/crates/engine/src/canvas/tools.rs b/crates/engine/src/canvas/tools.rs index 424694b3..fce14f7c 100644 --- a/crates/engine/src/canvas/tools.rs +++ b/crates/engine/src/canvas/tools.rs @@ -19,6 +19,25 @@ use crate::shape_handles::{ShapeHandle, ShapeHint}; use crate::snap::{Guide, Targets}; use crate::{Result, Session, textedit}; +mod click; +mod drag; +mod draw; +mod handles; +mod keys; +mod pen; +mod pointer; + +use handles::DRAG_SLOP_PX; +#[cfg(test)] +pub(crate) use handles::scale_factor; +pub use handles::{ + PENCIL_STROKE, SMALL_BOX_PX, anchor_name, anchor_of, box_sides_px, handle_at, handle_at_corners, label_at, locked_in, scale_anchor_point, + selection_box, selection_frame, +}; +pub(crate) use handles::{SNAP_PX, handle_cursor, readable_text_fills, resized_box}; +#[cfg(test)] +pub(crate) use pen::pressure_profile; + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Default)] pub enum ToolId { #[default] @@ -552,2324 +571,8 @@ pub struct ToolState { pub picked: Option, } -/// Pen pressure along drawn samples as width profile points `[t, scale]`: `t` by length, at most -/// 32 points, medium pressure (0.5) at the stroke weight, from a tenth to twice it. `None` when no -/// sample has pressure. -pub(crate) fn pressure_profile(points: &[Point], pressure: &[Option]) -> Option> { - if pressure.iter().all(Option::is_none) { - return None; - } - let mut lengths = vec![0.0]; - for w in points.windows(2) { - let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue }; - lengths.push(lengths.last().copied().unwrap_or(0.0) + (*b - *a).hypot()); - } - let total = lengths.last().copied().unwrap_or(0.0); - if !(total.is_finite() && total > 0.0) { - return None; - } - let at = |i: usize| f64::from(pressure.get(i).copied().flatten().filter(|p| p.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0)); - let n = points.len().clamp(2, 32); - let profile = (0..n).map(|k| { - let t = k as f64 / (n - 1) as f64; - let s = t * total; - let i = lengths.partition_point(|l| *l < s).min(lengths.len().saturating_sub(1)); - let p = match (i.checked_sub(1).and_then(|j| lengths.get(j)), lengths.get(i)) { - (Some(a), Some(b)) if b > a => at(i - 1) + (at(i) - at(i - 1)) * ((s - a) / (b - a)), - _ => at(i), - }; - [t, (p / 0.5).clamp(0.1, 2.0)] - }); - Some(profile.collect()) -} - -/// A frame or section drawn around layers takes those of its siblings that lie fully inside it -/// (visible and unlocked ones, in their order; a frame never takes a section). Not in auto layout, -/// where the new frame is one more item of the flow. -fn take_enclosed(doc: &mut Document, drawn: NodeId) -> Result<()> { - let Some(n) = doc.node(drawn) else { return Ok(()) }; - let section = matches!(n.kind, NodeKind::Section { .. }); - let (Some(parent), Some(area)) = (n.parent.and_then(|p| doc.node(p)), doc.world_bounds(drawn)) else { return Ok(()) }; - if parent.layout.is_some() { - return Ok(()); - } - let inside: Vec = parent - .children - .iter() - .copied() - .filter(|c| *c != drawn && doc.node(*c).is_some_and(|k| k.visible && !k.locked && (section || !matches!(k.kind, NodeKind::Section { .. })))) - .filter(|c| doc.world_bounds(*c).is_some_and(|b| area.contains(b.origin()) && area.contains(Point::new(b.x1, b.y1)))) - .collect(); - for c in inside { - doc.reparent(c, drawn, None)?; - } - Ok(()) -} - -/// The frame whose name label (see [`ToolState::labels`]) is under `p` on `page`: the topmost -/// one drawn, if it can be selected (still there, on the page, visible and unlocked). -pub fn label_at(doc: &Document, page: NodeId, labels: &[(NodeId, [Point; 4])], p: Point) -> Option { - labels - .iter() - .rev() - .find(|(id, q)| in_quad(q, p) && doc.page_of(*id) == Some(page) && doc.node(*id).is_some_and(|n| n.visible && !n.locked)) - .map(|(id, _)| *id) -} - -/// Whether `p` is inside the convex quadrilateral `q` (either winding; edges included). -fn in_quad(q: &[Point; 4], p: Point) -> bool { - let (mut left, mut right) = (false, false); - for (a, b) in q.iter().zip(q.iter().cycle().skip(1)) { - let side = (*b - *a).cross(p - *a); - if !side.is_finite() { - return false; - } - left |= side < 0.0; - right |= side > 0.0; - } - // On one side of every edge (a point can't be on all four edges of a quad with an area). - left != right -} - -/// An anchor name (`tl`, `t`, `tr`, `l`, `c`, `r`, `bl`, `b`, `br`) as column and row (0 start, -/// 1 middle, 2 end). -pub fn anchor_of(name: &str) -> Option<(u8, u8)> { - ANCHORS.iter().position(|a| a.eq_ignore_ascii_case(name.trim())).map(|i| ((i % 3) as u8, (i / 3) as u8)) -} - -/// The name of anchor `(column, row)`. -pub fn anchor_name(a: (u8, u8)) -> &'static str { - ANCHORS.get(usize::from(a.1.min(2)) * 3 + usize::from(a.0.min(2))).copied().unwrap_or("tl") -} - -/// The fill new text takes to be readable where it is drawn, in `parent`: white on a dark -/// background (None: the model's default, black). The background is the nearest layer from `parent` -/// out whose top visible fill is a mostly opaque solid colour, else the page (#362). For text made -/// in the app: by the Text tool, or by a command that names no fill while the interface is up. -pub(crate) fn readable_text_fills(doc: &Document, parent: NodeId) -> Option> { - let dark = std::iter::once(parent).chain(doc.ancestors(parent)).filter_map(|id| doc.node(id)).find_map(|n| match &n.kind { - NodeKind::Page { background, .. } => Some(background.is_dark()), - _ => n.fills.iter().rev().find(|p| p.visible).and_then(|p| p.solid_color()).filter(|c| c.a >= 0.5).map(|c| c.is_dark()), - }); - dark.unwrap_or(false).then(|| vec![figcraft_color::Paint::solid(figcraft_color::Color::WHITE)]) -} - -const ANCHORS: [&str; 9] = ["tl", "t", "tr", "l", "c", "r", "bl", "b", "br"]; - impl ToolState { pub fn busy(&self) -> bool { !matches!(self.gesture, Gesture::None) } } - -const HANDLE_PX: f64 = 5.0; -const ROTATE_PX: f64 = 18.0; -/// Snap distance in screen pixels. -pub(crate) const SNAP_PX: f64 = 5.0; -/// Screen pixels the pointer travels after a press before a move or resize starts: a click or a -/// double-click made with an unsteady hand doesn't drag what it lands on. -const DRAG_SLOP_PX: f64 = 3.0; -/// Screen pixels from a path within which the Text on a path tool takes it. -const PATH_REACH_PX: f64 = 6.0; - -/// The visible layers under `id` whose box, grown by `reach` (page units), holds `p`, front-most -/// first: what the Text on a path tool measures its distance to. Content a clipping frame or a -/// mask hides at `p` and the operands of boolean groups are left out. -fn path_candidates(doc: &Document, id: NodeId, p: Point, reach: f64, depth: usize, out: &mut Vec) { - let Some(n) = doc.node(id) else { return }; - if !n.visible || depth > 512 || matches!(n.kind, NodeKind::BooleanOp { .. }) { - return; - } - if n.clip && n.kind.is_frame_like() && !n.local_rect().contains(doc.world_transform(id).inverse() * p) { - return; - } - for c in n.paint_order().rev() { - let before = out.len(); - path_candidates(doc, c, p, reach, depth + 1, out); - if out.len() > before && figcraft_doc::geometry::masked_out(doc, n, c, p) { - out.truncate(before); - } - } - if n.children.is_empty() && !n.is_mask && doc.world_bounds(id).is_some_and(|b| b.inflate(reach, reach).contains(p)) { - out.push(id); - } -} - -/// Corners of the selection's handle box in page coordinates (tl, tr, br, bl). -pub fn selection_box(doc: &Document, sel: &[NodeId]) -> Option<[Point; 4]> { - let (f, w, h) = selection_frame(doc, sel)?; - Some([f * Point::ZERO, f * Point::new(w, 0.0), f * Point::new(w, h), f * Point::new(0.0, h)]) -} - -/// The selection's handle box: the page transform of its top-left corner (axes along the box) and -/// its size. One layer: its own box; several sharing a rotation: a box turned with them; -/// otherwise the page-aligned bounds. -pub fn selection_frame(doc: &Document, sel: &[NodeId]) -> Option<(Affine, f64, f64)> { - let ids = roots(doc, sel); - if let [id] = ids.as_slice() { - let n = doc.node(*id)?; - return Some((doc.world_transform(*id), n.width, n.height)); - } - let Some(angle) = shared_rotation(doc, &ids) else { - let b = doc.bounds_of(&ids)?; - return Some((Affine::translate((b.x0, b.y0)), b.width(), b.height())); - }; - let rot = Affine::rotate(angle); - let inv = rot.inverse(); - let b = ids - .iter() - .filter_map(|i| Some(figcraft_geom::transform_rect_bbox(inv * doc.world_transform(*i), doc.node(*i)?.local_rect()))) - .reduce(|a, b| a.union(b))?; - Some((rot * Affine::translate((b.x0, b.y0)), b.width(), b.height())) -} - -/// The rotation (radians) every layer in `ids` shares, unless it's a multiple of 90 degrees (the -/// page-aligned box is then the same box). Flipped layers count by their axis, not its direction. -fn shared_rotation(doc: &Document, ids: &[NodeId]) -> Option { - use std::f64::consts::{FRAC_PI_2, PI}; - const EPS: f64 = 1e-4; - let mut angles = ids.iter().map(|i| { - let c = doc.world_transform(*i).as_coeffs(); - c[1].atan2(c[0]) - }); - let a = angles.next()?; - let same = |b: f64| { - let d = (b - a).rem_euclid(PI); - d.min(PI - d) < EPS - }; - let axis_aligned = (a - (a / FRAC_PI_2).round() * FRAC_PI_2).abs() < EPS; - (a.is_finite() && !axis_aligned && angles.all(same)).then_some(a) -} - -/// Whether layer `id` is locked or inside a locked layer: it can be selected in the layers panel -/// but not moved or resized on the canvas. -pub fn locked_in(doc: &Document, id: NodeId) -> bool { - std::iter::once(id).chain(doc.ancestors(id)).any(|a| doc.node(a).is_some_and(|n| n.locked)) -} - -/// Whether layer `t` paints below layer `s`: it holds `s`, or it is behind `s` in the stack of the -/// nearest layer holding both. -fn paints_below(doc: &Document, t: NodeId, s: NodeId) -> bool { - let chain = |id: NodeId| { - let mut c: Vec = std::iter::once(id).chain(doc.ancestors(id)).collect(); - c.reverse(); - c - }; - let (ct, cs) = (chain(t), chain(s)); - let shared = ct.iter().zip(&cs).take_while(|(a, b)| a == b).count(); - match (ct.get(shared), cs.get(shared)) { - (None, Some(_)) => true, - (Some(a), Some(b)) => shared.checked_sub(1).and_then(|i| ct.get(i)).and_then(|p| doc.node(*p)).is_some_and(|p| { - let order: Vec = p.paint_order().collect(); - order.iter().position(|c| c == a) < order.iter().position(|c| c == b) - }), - _ => false, - } -} - -/// Which handle (if any) is under `p`. A selection with a locked layer has none. -pub fn handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option { - if sel.is_empty() || sel.iter().any(|i| locked_in(doc, *i)) { - return None; - } - // A connector's handles are its ends (see `board::end_handle_at`): no box to resize or turn. - if crate::board::selected_connector_ends(doc, sel).is_some() { - return None; - } - let corners = selection_box(doc, sel)?; - // Lines (zero height) only get end handles. - let is_line = sel.len() == 1 && sel.first().and_then(|i| doc.node(*i)).is_some_and(|n| matches!(n.kind, NodeKind::Line)); - handle_at_corners(&corners, p, zoom, is_line) -} - -/// Half the side of the square the canvas draws on each corner of the handle box (screen pixels, -/// upright whatever the box's rotation). -const CORNER_SQUARE_PX: f64 = 4.0; - -/// Whether `p` is on a corner square of the selection's handle box as drawn: there the corner -/// resizes, although a corner-radius handle 10 px in reaches that far too. -fn on_corner_square(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> bool { - let reach = CORNER_SQUARE_PX / zoom.max(1e-6); - matches!(handle_at(doc, sel, p, zoom), Some(Handle::Corner(_))) - && selection_box(doc, sel).is_some_and(|c| c.iter().any(|c| (c.x - p.x).abs() <= reach && (c.y - p.y).abs() <= reach)) -} - -/// A box whose sides are both shorter than this on screen (pixels) would be covered by its -/// handles: the canvas draws just its outline (the handles still answer just outside it). -pub const SMALL_BOX_PX: f64 = 16.0; - -/// The stroke the Pencil draws: its colour and weight (the canvas previews it the same). -pub const PENCIL_STROKE: (figcraft_color::Color, f64) = (figcraft_color::Color::BLACK, 2.0); - -/// The shorter and longer sides, in screen pixels, of the box with `corners` (tl, tr, br, bl). -pub fn box_sides_px(corners: &[Point; 4], zoom: f64) -> (f64, f64) { - let [a, b, _, d] = corners; - let (w, h) = ((*b - *a).hypot() * zoom, (*d - *a).hypot() * zoom); - (w.min(h), w.max(h)) -} - -/// Which handle of the box with `corners` (tl, tr, br, bl in page coordinates) is under `p`; -/// `is_line` keeps only the two end handles. -pub fn handle_at_corners(corners: &[Point; 4], p: Point, zoom: f64, is_line: bool) -> Option { - let tol = HANDLE_PX / zoom.max(1e-6); - // Inside the box, handles reach in at most a quarter of its shorter side, so that a press in - // the middle of a small layer moves it; outside it they reach as far as ever. - let inner = if !is_line && in_quad(corners, p) { box_sides_px(corners, zoom).0 / 4.0 / zoom.max(1e-6) } else { f64::INFINITY }; - let (corner_reach, edge_reach) = ((tol * 1.6).min(inner), tol.min(inner)); - // The nearest corner, then the nearest edge, in reach (a small box's reaches overlap). - let nearest = |hits: &mut dyn Iterator| hits.min_by(|a, b| a.0.total_cmp(&b.0)).map(|(_, h)| h); - let corner = nearest( - &mut corners - .iter() - .enumerate() - .filter(|(i, _)| !(is_line && *i >= 2)) - .map(|(i, c)| ((*c - p).hypot(), Handle::Corner(i as u8))) - .filter(|(d, _)| *d <= corner_reach), - ); - if corner.is_some() { - return corner; - } - if !is_line { - use figcraft_geom::kurbo::ParamCurveNearest; - let edge = nearest(&mut (0..4).filter_map(|i| { - let (a, b) = (corners.get(i)?, corners.get((i + 1) % 4)?); - let near = figcraft_geom::kurbo::Line::new(*a, *b).nearest(p, 0.001); - let d = near.distance_sq.sqrt(); - (d <= edge_reach && near.t > 0.05 && near.t < 0.95).then_some((d, Handle::Edge(i as u8))) - })); - if edge.is_some() { - return edge; - } - } - // Rotation zones: just outside each corner. - let center = Point::new(corners.iter().map(|c| c.x).sum::() / 4.0, corners.iter().map(|c| c.y).sum::() / 4.0); - let rot = ROTATE_PX / zoom.max(1e-6); - for (i, c) in corners.iter().enumerate() { - let d = (*c - p).hypot(); - let outward = (p - *c).dot(*c - center) > 0.0; - if d <= rot && d > tol && outward { - return Some(Handle::Rotate(i as u8)); - } - } - None -} - -fn resize_cursor(doc: &Document, sel: &[NodeId], h: Handle) -> Cursor { - selection_box(doc, sel).map_or(Cursor::Default, |c| handle_cursor(&c, h)) -} - -/// The cursor over handle `h` of the box with corners `c`. -pub(crate) fn handle_cursor(c: &[Point; 4], h: Handle) -> Cursor { - let angle = |a: Point, b: Point| (b - a).atan2().to_degrees(); - match h { - Handle::Corner(i) => { - let a = c.get(i as usize).copied().unwrap_or_default(); - let b = c.get((i as usize + 2) % 4).copied().unwrap_or_default(); - // Along the box's own axes at 45°, not the diagonal, so wide or tall boxes still get a - // diagonal cursor. A box with no width or height falls back to the diagonal. - let unit = |v: figcraft_geom::kurbo::Vec2| { - let len = v.hypot(); - (len.is_finite() && len > 1e-9).then(|| v / len) - }; - let next = c.get((i as usize + 1) % 4).copied().unwrap_or_default(); - let prev = c.get((i as usize + 3) % 4).copied().unwrap_or_default(); - match (unit(next - a), unit(prev - a)) { - (Some(u), Some(v)) => Cursor::Resize((u + v).atan2().to_degrees()), - _ => Cursor::Resize(angle(a, b)), - } - } - Handle::Edge(i) => { - let a = c.get(i as usize).copied().unwrap_or_default(); - let b = c.get((i as usize + 1) % 4).copied().unwrap_or_default(); - Cursor::Resize(angle(a, b) + 90.0) - } - Handle::Rotate(i) => { - let centre = Point::new(c.iter().map(|p| p.x).sum::() / 4.0, c.iter().map(|p| p.y).sum::() / 4.0); - Cursor::Rotate(angle(centre, c.get(i as usize).copied().unwrap_or_default())) - } - } -} - -/// The box (x0, y0, x1, y1) a `w0`×`h0` handle box becomes when `handle` is dragged to `q`, all in -/// the box's own axes: `keep` constrains proportions, `from_center` resizes about the centre (the -/// opposite side mirrors the dragged one). Edges that cross over come back reversed (x1 < x0 or -/// y1 < y0: a flip). -pub(crate) fn resized_box(handle: Handle, q: Point, w0: f64, h0: f64, keep: bool, from_center: bool) -> (f64, f64, f64, f64) { - let (mut x0, mut y0, mut x1, mut y1) = (0.0, 0.0, w0, h0); - // The dragged sides: x0 or x1 (`Some(false)` / `Some(true)`), y0 or y1. - let (sx, sy) = match handle { - Handle::Corner(0) => (Some(false), Some(false)), - Handle::Corner(1) => (Some(true), Some(false)), - Handle::Corner(2) => (Some(true), Some(true)), - Handle::Corner(_) => (Some(false), Some(true)), - Handle::Edge(0) => (None, Some(false)), - Handle::Edge(1) => (Some(true), None), - Handle::Edge(2) => (None, Some(true)), - Handle::Edge(_) => (Some(false), None), - Handle::Rotate(_) => (None, None), - }; - match sx { - Some(false) => (x0, x1) = (q.x, if from_center { w0 - q.x } else { w0 }), - Some(true) => (x0, x1) = (if from_center { w0 - q.x } else { 0.0 }, q.x), - None => {} - } - match sy { - Some(false) => (y0, y1) = (q.y, if from_center { h0 - q.y } else { h0 }), - Some(true) => (y0, y1) = (if from_center { h0 - q.y } else { 0.0 }, q.y), - None => {} - } - if keep && w0 > 1e-9 && h0 > 1e-9 && !matches!(handle, Handle::Rotate(_)) { - // One factor for both axes: the dragged axis's (a corner: the one that moved the most), - // about the point that stays put (the opposite handle, or the centre). - let (kx, ky) = ((x1 - x0) / w0, (y1 - y0) / h0); - let k = match (sx, sy) { - (None, _) => ky, - (_, None) => kx, - _ if kx.abs() > ky.abs() => kx, - _ => ky, - }; - let pivot = if from_center { Point::new(w0 / 2.0, h0 / 2.0) } else { handle_point(opposite(handle), w0, h0) }; - (x0, x1) = (pivot.x * (1.0 - k), pivot.x + (w0 - pivot.x) * k); - (y0, y1) = (pivot.y * (1.0 - k), pivot.y + (h0 - pivot.y) * k); - } - (x0, y0, x1, y1) -} - -/// A handle's point in its box's axes (`w0`×`h0`): a corner or an edge's middle. -fn handle_point(h: Handle, w0: f64, h0: f64) -> Point { - match h { - Handle::Corner(0) | Handle::Rotate(0) => Point::new(0.0, 0.0), - Handle::Corner(1) | Handle::Rotate(1) => Point::new(w0, 0.0), - Handle::Corner(2) | Handle::Rotate(2) => Point::new(w0, h0), - Handle::Corner(_) | Handle::Rotate(_) => Point::new(0.0, h0), - Handle::Edge(0) => Point::new(w0 / 2.0, 0.0), - Handle::Edge(1) => Point::new(w0, h0 / 2.0), - Handle::Edge(2) => Point::new(w0 / 2.0, h0), - Handle::Edge(_) => Point::new(0.0, h0 / 2.0), - } -} - -/// Anchor `(column, row)` of a `w`×`h` box, in its axes. -fn anchor_in_box(anchor: (u8, u8), w: f64, h: f64) -> Point { - Point::new(w * f64::from(anchor.0.min(2)) / 2.0, h * f64::from(anchor.1.min(2)) / 2.0) -} - -/// The Scale tool's anchor point on the selection's handle box, in page coordinates. -pub fn scale_anchor_point(doc: &Document, sel: &[NodeId], anchor: (u8, u8)) -> Option { - let (f, w, h) = selection_frame(doc, sel)?; - Some(f * anchor_in_box(anchor, w, h)) -} - -/// The handle across the box from `h`. -fn opposite(h: Handle) -> Handle { - match h { - Handle::Corner(i) => Handle::Corner((i + 2) % 4), - Handle::Edge(i) => Handle::Edge((i + 2) % 4), - Handle::Rotate(i) => Handle::Rotate((i + 2) % 4), - } -} - -/// The Scale tool's factor and pivot (in the box's axes) for dragging `handle` of a `w0`×`h0` box -/// to `q`. The pivot is the anchor point (`anchor`: column, row), or the opposite handle when the -/// anchor lies on the side being dragged, or the centre (`from_center`). Corners follow the axis -/// that moved the most; a negative factor flips. None when the box has no size to scale by. -pub(crate) fn scale_factor(handle: Handle, q: Point, w0: f64, h0: f64, anchor: (u8, u8), from_center: bool) -> Option<(f64, Point)> { - const EPS: f64 = 1e-9; - if !(q.x.is_finite() && q.y.is_finite() && w0.is_finite() && h0.is_finite()) || matches!(handle, Handle::Rotate(_)) { - return None; - } - let h = handle_point(handle, w0, h0); - let anchor_pt = anchor_in_box(anchor, w0, h0); - // The axes the handle moves along. - let (ax_x, ax_y) = match handle { - Handle::Edge(0) | Handle::Edge(2) => (false, true), - Handle::Edge(_) => (true, false), - _ => (true, true), - }; - let on_side = (ax_x && (anchor_pt.x - h.x).abs() < EPS) || (ax_y && (anchor_pt.y - h.y).abs() < EPS); - let pivot = if from_center { - Point::new(w0 / 2.0, h0 / 2.0) - } else if on_side { - handle_point(opposite(handle), w0, h0) - } else { - anchor_pt - }; - let ratio = |on: bool, to: f64, from: f64, p: f64| (on && (from - p).abs() > EPS).then(|| (to - p) / (from - p)); - let kx = ratio(ax_x, q.x, h.x, pivot.x); - let ky = ratio(ax_y, q.y, h.y, pivot.y); - let k = match (kx, ky) { - (Some(a), Some(b)) => { - if a.abs() > b.abs() { - a - } else { - b - } - } - (Some(a), None) | (None, Some(a)) => a, - (None, None) => return None, - }; - if !k.is_finite() { - return None; - } - // Never collapse to nothing (or blow up past a usable size); 0 counts as positive. - Some((k.signum() * k.abs().clamp(0.01, 1000.0), pivot)) -} - -impl Session { - /// The pointer left the canvas: what hovering there showed goes (the distances Alt or Dev Mode - /// measured and the box measured to, a vector region's highlight), unless a gesture holds it. - pub fn pointer_left(&mut self) { - if !self.tool.busy() { - self.tool.overlay.guides.clear(); - self.tool.overlay.measure_box = None; - } - self.vector_pointer_left(); - } - - /// What a marquee starting at page point `p` selects inside: the top-level frame it starts in - /// (its children), or the focus view's frame. - pub(crate) fn marquee_within(&self, p: Point) -> Result> { - let (doc, page) = (self.doc()?, self.page()?); - let within = frame_at(doc, page, p, &[]).filter(|f| doc.node(*f).is_some_and(|n| n.parent == Some(page))); - let within = within.filter(|f| doc.world_bounds(*f).is_some_and(|b| b.contains(p))); - Ok(self.ds()?.focused().or(within)) - } - - /// The selection a marquee over page rect `r` makes from `base` (what was selected before): - /// the layers it touches (`deep`: nested ones too); `additive` (Shift) toggles them. - pub(crate) fn marquee_selection(&self, r: Rect, within: Option, deep: bool, base: &[NodeId], additive: bool) -> Result> { - let (doc, page) = (self.doc()?, self.page()?); - let mut hits = - if deep { figcraft_doc::geometry::marquee_deep(doc, page, r) } else { figcraft_doc::geometry::marquee_select(doc, page, r, within) }; - if let Some(f) = self.ds()?.focused() { - hits.retain(|h| *h != f && doc.is_ancestor(f, *h)); - } - // A marquee over a frame from outside selects the frame only when it covers it fully. - if within.is_none() && !deep { - hits.retain(|h| { - let full = doc.world_bounds(*h).is_some_and(|b| r.contains(b.origin()) && r.contains(Point::new(b.x1, b.y1))); - full || !doc.node(*h).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })) - }); - } - // Shift toggles: what was selected and is touched again comes out. - let mut sel: Vec = base.iter().copied().filter(|b| !(additive && hits.contains(b))).collect(); - for h in hits { - if !base.contains(&h) && !sel.contains(&h) { - sel.push(h); - } - } - Ok(sel) - } - - pub fn set_tool(&mut self, t: ToolId) { - if self.tool.tool == ToolId::Pen && t != ToolId::Pen { - let _ = self.pen_finish(); - } - if !matches!(t, ToolId::Move | ToolId::Scale | ToolId::Hand) && self.ds().is_ok_and(|d| d.vector_edit.is_some()) { - let _ = crate::vectoredit::end(self); - } - self.tool.tool = t; - self.tool.gesture = Gesture::None; - self.tool.pick_only = false; - // The tool's cursor shows before the pointer moves. - self.tool.overlay = - Overlay { builder_remove: self.tool.builder_remove && t == ToolId::ShapeBuilder, cursor: t.cursor(), ..Overlay::default() }; - self.tool.shape_builder = None; - if t == ToolId::ShapeBuilder { - // Regions show straight away, before the pointer moves. - let sel = self.selection(); - let _ = crate::shapebuilder::arrangement(self, &sel); - } - if let Ok(ds) = self.ds_mut() { - if !matches!(t, ToolId::Text | ToolId::TextOnPath | ToolId::Move) { - ds.text_edit = None; - } - ds.revision += 1; - } - } - - /// The tool pointer events go to: in Dev Mode every tool but the hand and comments selects. - fn acting_tool(&self) -> ToolId { - match self.tool.tool { - t if self.tool.dev_mode && !matches!(t, ToolId::Hand | ToolId::Comment) => ToolId::Move, - t => t, - } - } - - /// Drop the gesture under way (a drag, a resize, a shape being drawn), restoring the document - /// as Escape does; the UI calls it when Dev Mode starts, where nothing may move. - pub fn drop_gesture(&mut self) -> Result<()> { - self.tool.gesture = Gesture::None; - self.cancel_interaction() - } - - /// Feed one pointer event to the current tool. - pub fn pointer(&mut self, ev: &PointerEvent) -> Result<()> { - // A held button sends the same move every frame: running the gesture again would only - // rebuild the same preview (a new document, and a canvas render, every frame). - if ev.kind != PointerKind::Move { - self.tool.last_move = None; - } else if self.tool.busy() { - if self.tool.last_move.as_ref() == Some(ev) { - return Ok(()); - } - self.tool.last_move = Some(ev.clone()); - } - let tool = self.acting_tool(); - let edits = matches!(tool, ToolId::Move | ToolId::Scale) && !self.tool.dev_mode; - if edits && self.origin_pointer(ev)? { - return Ok(()); - } - if edits && self.ds().is_ok_and(|d| d.vector_edit.is_some()) && self.vector_pointer(ev)? { - return Ok(()); - } - if tool == ToolId::ShapeBuilder { - return self.shape_builder_pointer(ev); - } - match ev.kind { - PointerKind::Hover => self.hover(ev), - PointerKind::Down => match tool { - ToolId::Move | ToolId::Scale => { - // While a board's vote runs, a click on an object votes. - if tool == ToolId::Move && !self.tool.dev_mode && self.vote_down(ev)? { - return Ok(()); - } - // A click on the canvas deselects a ruler guide (the UI selects guides itself). - self.ds_mut()?.guide = None; - self.move_down(ev) - } - ToolId::Hand => { - let v = &self.ds()?.view; - self.tool.gesture = Gesture::Pan { sx: ev.sx, sy: ev.sy, cx: v.cx, cy: v.cy }; - self.tool.overlay.cursor = Cursor::Grabbing; - Ok(()) - } - ToolId::Text => self.text_down(ev), - ToolId::TextOnPath => self.text_on_path_down(ev), - ToolId::Pen => self.pen_down(ev), - ToolId::Pencil => { - self.tool.gesture = Gesture::Pencil { points: vec![ev.p()], pressure: vec![ev.pressure] }; - Ok(()) - } - ToolId::Comment => { - self.request_ui("comment.new", json!({"x": ev.x, "y": ev.y})); - Ok(()) - } - ToolId::Eyedropper => self.eyedrop(ev), - t if t.on_boards() => self.board_down(ev), - t if t.draws_shape() => self.draw_down(ev), - _ => Ok(()), - }, - PointerKind::Move => self.drag(ev), - PointerKind::Up => self.up(ev), - PointerKind::DoubleClick => self.double_click(ev), - } - } - - /// The rotation origin handle while it shows: a press drags it, a double-click puts it back - /// at the centre. True when the event was the handle's. - fn origin_pointer(&mut self, ev: &PointerEvent) -> Result { - let dragging = matches!(self.tool.gesture, Gesture::Origin); - if !dragging && !self.ds()?.on_origin_handle(ev.p(), ev.zoom) { - return Ok(false); - } - match ev.kind { - PointerKind::Down if !dragging => self.tool.gesture = Gesture::Origin, - PointerKind::Move if dragging => self.drag_origin_to(ev.p(), ev.zoom)?, - PointerKind::Up if dragging => self.tool.gesture = Gesture::None, - PointerKind::DoubleClick => self.set_rotation_origin(None)?, - PointerKind::Hover => {} - _ => return Ok(false), - } - self.tool.overlay.cursor = Cursor::Move; - Ok(true) - } - - fn hover(&mut self, ev: &PointerEvent) -> Result<()> { - let tool = self.acting_tool(); - let ds = self.ds()?; - let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); - let mut cursor = tool.cursor(); - // The text being edited, and with the Text tool any text, takes the I-beam: a click there - // puts the caret. - let on_edited = ds - .text_edit - .as_ref() - .is_some_and(|te| doc.world_bounds(te.id).is_some_and(|b| b.inflate(ev.tol() * 4.0, ev.tol() * 4.0).contains(ev.p()))); - let on_text = - |s: &Self| s.hit(&doc, page, ev.p(), ev.tol() * 2.0).and_then(|l| doc.node(l)).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))); - if tool == ToolId::Text && (on_edited || on_text(self)) { - cursor = Cursor::Text; - } - if tool == ToolId::TextOnPath && (on_edited || self.path_at(&doc, page, ev).is_some()) { - cursor = Cursor::Text; - } - let mut hover = None; - let mut guides = Vec::new(); - let mut measure_box = None; - let arc_hover = if self.arc_handles_shown() { crate::arc_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h) } else { None }; - self.tool.overlay.arc_hover = arc_hover; - let shape_hover = if self.arc_handles_shown() && !on_corner_square(&doc, &sel, ev.p(), ev.zoom) { - crate::shape_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h) - } else { - None - }; - self.tool.overlay.shape_hover = shape_hover; - if matches!(tool, ToolId::Move | ToolId::Scale) && on_edited { - cursor = Cursor::Text; - } else if matches!(tool, ToolId::Move | ToolId::Scale) && arc_hover.is_none() && shape_hover.is_none() { - if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom).filter(|_| !self.tool.dev_mode) { - cursor = resize_cursor(&doc, &sel, h); - } else { - let leaf = self.hit(&doc, page, ev.p(), ev.tol() * 2.0); - if leaf.and_then(|l| crate::widgets::target_at(&doc, l, ev.p())).is_some() { - cursor = Cursor::Pointer; - } - hover = label_at(&doc, page, &self.tool.labels, ev.p()).or_else(|| leaf.map(|leaf| select_target(&doc, leaf, &sel, ev.cmd))); - // Alt over a selected layer: a drag from here duplicates it. - if ev.alt && !self.tool.dev_mode && tool == ToolId::Move && hover.is_some_and(|h| sel.contains(&h) && !locked_in(&doc, h)) { - cursor = Cursor::Copy; - } - if ev.alt || self.tool.dev_mode { - use crate::distance::{Hover, guide_at, hover_lines, padding_frame, parent_of}; - // Shift+Alt over an auto layout frame: its padding and gaps. Otherwise from the - // selection to a ruler guide, the hovered layer or (over empty canvas) the parent. - let around: Vec = sel.iter().copied().chain(hover).collect(); - let what = hover.filter(|_| ev.alt && ev.shift).and_then(|t| padding_frame(&doc, t)).map(Hover::Padding).or_else(|| { - if sel.is_empty() { - return None; - } - let guide = guide_at(&doc, page, &around, ev.p(), ev.tol() * 4.0).map(|(vertical, at)| Hover::Guide { vertical, at }); - guide.or_else(|| hover.or_else(|| parent_of(&doc, page, &sel)).map(Hover::Layer)) - }); - if let Some(what) = what { - let (g, b) = hover_lines(&doc, page, &sel, what); - guides = g; - measure_box = b.map(|b| [b.x0, b.y0, b.x1, b.y1]); - } - } - } - } - if tool == ToolId::Move && !self.tool.dev_mode && crate::board::end_handle_at(&doc, &sel, ev.p(), ev.zoom).is_some() { - cursor = Cursor::Grab; - } - self.tool.overlay.measure_box = measure_box; - self.tool.overlay.cursor = cursor; - if tool == ToolId::Pen { - // Where a click would land (snapped), with its guides. - let (to, g) = if ev.shift { (ev.p(), Vec::new()) } else { self.snap_pen_tool(ev)? }; - guides = g; - self.tool.overlay.cursor = Cursor::Pen(self.pen_cursor(&doc, &sel, to, ev.zoom)); - if let Some((last, _, out)) = self.tool.pen.as_ref().and_then(|pen| pen.points.last()) { - // With Shift the band ends where a click puts the point (#970). - let to = if ev.shift { at_45_degrees(*last, to) } else { to }; - self.tool.overlay.rubber = Some([last.x, last.y, to.x, to.y]); - self.tool.overlay.rubber_handle = (out.hypot() > 0.0).then_some([last.x + out.x, last.y + out.y]); - } - } - self.tool.overlay.guides = guides; - let ds = self.ds_mut()?; - if ds.hover != hover { - ds.hover = hover; - ds.revision += 1; - } - Ok(()) - } - - /// What a click with the Pen tool at `p` (where it snaps) would do: close the path on its first - /// point, connect to another of its points, go on from a selected open path's end, or add a - /// new point (the same tests as [`Self::pen_down`]). - fn pen_cursor(&self, doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> PenCursor { - let near = |q: &Point| (*q - p).hypot() * zoom < 8.0; - let on_point = match &self.tool.pen { - None => selected_end_at(doc, sel, p, 8.0 / zoom.max(1e-6)).is_some(), - Some(pen) => { - let n = pen.points.len(); - (n > 2 && pen.points.first().is_some_and(|(q, _, _)| near(q))) - || (1..n.saturating_sub(1)).any(|k| pen.points.get(k).is_some_and(|(q, _, _)| near(q))) - } - }; - if on_point { PenCursor::Close } else { PenCursor::New } - } - - /// Whether what the gesture did to `ids` so far is an edit only a main component can make - /// (`moved`: their place among the layers counts too): it is undone when the gesture ends. - fn refused(&self, ids: &[NodeId], moved: bool) -> bool { - let Ok(ds) = self.ds() else { return false }; - ds.interaction.as_ref().is_some_and(|it| { - ids.iter().any(|m| { - (moved && !crate::components::move_allowed(&it.doc, &ds.doc, *m)) - || crate::components::refused_override(&it.doc, &ds.doc, *m).is_some() - }) - }) - } - - fn move_down(&mut self, ev: &PointerEvent) -> Result<()> { - let ds = self.ds()?; - let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); - // Clicking outside the text being edited ends editing. - if let Some(te) = ds.text_edit.clone() { - let inside = doc.world_bounds(te.id).is_some_and(|b| b.inflate(ev.tol() * 4.0, ev.tol() * 4.0).contains(ev.p())); - if inside { - return self.text_click(te.id, ev, ev.shift); - } - textedit::end(self)?; - } - // Handles first: a selected connector's ends, a selected ellipse's arc handles (drawn over - // the box), then the box's. - let edits = !self.tool.dev_mode; - if edits && let Some((id, from)) = crate::board::end_handle_at(&doc, &sel, ev.p(), ev.zoom) { - self.tool.gesture = Gesture::Reattach { id, from, start: ev.p() }; - return Ok(()); - } - if self.arc_handles_shown() - && let Some((id, handle)) = crate::arc_handles::hit(&doc, &sel, ev.p(), ev.zoom) - && let Some(arc) = crate::arc_handles::arc_of(&doc, id) - { - self.begin_interaction("Arc")?; - self.tool.gesture = Gesture::Arc { id, handle, arc }; - self.tool.overlay.arc = - crate::arc_handles::handle_at(&doc, id, handle, arc, ev.zoom).map(|at| ArcHint { handle, label: arc.label(handle), at }); - return Ok(()); - } - if self.arc_handles_shown() - && !on_corner_square(&doc, &sel, ev.p(), ev.zoom) - && let Some((id, handle)) = crate::shape_handles::hit(&doc, &sel, ev.p(), ev.zoom) - { - self.begin_interaction("Corner radius")?; - self.tool.gesture = Gesture::Shape { id, handle }; - self.tool.overlay.shape = shape_hint(&doc, id, handle, ev.zoom); - return Ok(()); - } - if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom).filter(|_| edits) { - let ids = roots(&doc, &sel); - if let Handle::Rotate(_) = h { - let Some(b) = selection_box(&doc, &ids) else { return Ok(()) }; - let center = self - .ds()? - .moved_origin() - .unwrap_or_else(|| Point::new(b.iter().map(|c| c.x).sum::() / 4.0, b.iter().map(|c| c.y).sum::() / 4.0)); - self.begin_interaction("Rotate")?; - self.tool.gesture = Gesture::Rotate { center, start_angle: (ev.p() - center).atan2(), ids }; - return Ok(()); - } - let nodes: Vec<(NodeId, Affine, f64, f64)> = - ids.iter().filter_map(|i| doc.node(*i).map(|n| (*i, doc.world_transform(*i), n.width, n.height))).collect(); - let Some(frame) = selection_frame(&doc, &ids) else { return Ok(()) }; - let single = ids.len() == 1; - self.begin_interaction(if self.tool.tool == ToolId::Scale { "Scale" } else { "Resize" })?; - self.tool.gesture = Gesture::Resize { - start: ev.p(), - r: Box::new(ResizeStart { nodes, frame, single, handle: h }), - scale_tool: self.tool.tool == ToolId::Scale, - moved: false, - }; - return Ok(()); - } - // A frame's name label picks the frame itself. The background of a top-level frame with - // layers in it is like the canvas (a press starts a marquee inside it) unless the frame is - // selected; an empty frame (or a widget, or a board object such as a sticky) is picked and - // dragged. - let board_background = |leaf: NodeId| { - !sel.contains(&leaf) - && doc.node(leaf).is_some_and(|n| { - n.parent == Some(page) && n.kind.is_frame_like() && !n.children.is_empty() && !crate::widgets::is_widget(n) && !n.is_board_atom() - }) - }; - let label = label_at(&doc, page, &self.tool.labels, ev.p()); - let target = label.or_else(|| { - self.hit(&doc, page, ev.p(), ev.tol() * 2.0) - .filter(|leaf| ev.cmd || !board_background(*leaf)) - .map(|leaf| select_target(&doc, leaf, &sel, ev.cmd)) - }); - // A press inside the selection's box that reaches nothing above it (an unfilled shape's - // inside, the frame or canvas behind it, or a layer stacked below the selected layer the - // press is in) drags the selection. In a gap between selected layers, what's there is hit. - let in_own_box = |s: NodeId| selection_box(&doc, &[s]).is_some_and(|q| in_quad(&q, ev.p())); - let behind_sel = |t: Option| { - t.is_none_or(|t| sel.iter().any(|s| *s != t && (doc.is_ancestor(t, *s) || (in_own_box(*s) && paints_below(&doc, t, *s))))) - }; - let target = match sel.iter().copied().find(|s| !locked_in(&doc, *s)) { - Some(first) if label.is_none() && !ev.shift && behind_sel(target) && selection_box(&doc, &sel).is_some_and(|q| in_quad(&q, ev.p())) => { - Some(first) - } - _ => target, - }; - match target { - Some(target) => { - // Clicking inside the current selection's box keeps it (drag them all). - let in_sel_box = sel.len() > 1 && doc.bounds_of(&sel).is_some_and(|b| b.contains(ev.p())) && !ev.shift; - // Shift on a selected layer takes it out on release if the press doesn't become a - // drag; a drag moves the selection with it. Where no drag can start, it goes now. - let deselect = (ev.shift && sel.contains(&target)).then_some(target); - let without_target = || sel.iter().copied().filter(|x| *x != target).collect::>(); - let new_sel = if in_sel_box && (sel.contains(&target) || sel.iter().any(|s| doc.is_ancestor(*s, target))) { - sel.clone() - } else if ev.shift && deselect.is_none() { - let mut s2 = sel.clone(); - s2.push(target); - s2 - } else if sel.contains(&target) { - sel.clone() - } else { - vec![target] - }; - self.set_selection(new_sel.clone()); - // Dev Mode only selects (Shift-click still deselects): nothing moves. - if !edits { - if deselect.is_some() { - self.set_selection(without_target()); - } - return Ok(()); - } - // Locked layers selected in the layers panel stay put. - let ids: Vec = roots(&doc, &new_sel).into_iter().filter(|i| !locked_in(&doc, *i)).collect(); - if new_sel.is_empty() || doc.node(target).is_some_and(|n| n.locked) || ids.is_empty() { - if deselect.is_some() { - self.set_selection(without_target()); - } - return Ok(()); - } - let mut targets = Targets::gather(&doc, page, &ids); - // A copy dragged out with Alt snaps to the layers it came from too (they stay put). - if ev.alt - && let Some(b) = targets.moving - { - targets.add_box(b); - } - if let Err(e) = self.begin_interaction(if ev.alt { "Duplicate" } else { "Move" }) { - if deselect.is_some() { - self.set_selection(without_target()); - } - return Err(e); - } - self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false, deselect }; - self.tool.overlay.cursor = if ev.alt { Cursor::Copy } else { Cursor::Default }; - } - None => { - let within = self.marquee_within(ev.p())?; - if !ev.shift { - self.set_selection(vec![]); - } - self.tool.gesture = Gesture::Marquee { start: ev.p(), additive: ev.shift, base: if ev.shift { sel } else { vec![] }, within }; - } - } - Ok(()) - } - - fn drag(&mut self, ev: &PointerEvent) -> Result<()> { - let g = std::mem::take(&mut self.tool.gesture); - let res = match &g { - Gesture::None => return self.hover(ev), - Gesture::Marquee { start, additive, base, within } => { - let r = Rect::from_points(*start, ev.p()); - self.tool.overlay.marquee = Some([r.x0, r.y0, r.x1, r.y1]); - // Cmd/Ctrl: the nested layers it touches. - let sel = self.marquee_selection(r, *within, ev.cmd, base, *additive)?; - self.set_selection(sel); - Ok(()) - } - Gesture::Move { start, moved: false, .. } | Gesture::Resize { start, moved: false, .. } if within_slop(ev, *start) => Ok(()), - Gesture::Move { start, ids, targets, duplicate, .. } => { - let mut d = ev.p() - *start; - if ev.shift { - if d.x.abs() > d.y.abs() { - d.y = 0.0; - } else { - d.x = 0.0; - } - } - // Cmd/Ctrl (and Control on macOS) held while dragging suspends snapping. - let snap_on = self.ds().map(|ds| ds.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl; - let mut guides = Vec::new(); - if snap_on { - let (c, g2) = targets.snap_move(d, SNAP_PX / ev.zoom.max(1e-6)); - d += c; - guides = g2; - } - if self.ds().map(|ds| ds.view.snap_pixel).unwrap_or(true) { - // Keep the box's origin on whole pixels (a rotated parent frame's, inside one). - d = targets.round_move(d); - } - let ids = ids.clone(); - let dup = *duplicate; - let page = self.page()?; - let mut drop_cell = None; - let mut drop_target = None; - let mut in_flow = false; - // Placed in a row or column: (is it a column, its gap) for the insertion line. - let mut slot_axis: Option<(bool, f64)> = None; - self.preview(|doc, sel| { - let mut moving = ids.clone(); - if dup { - let mut copies = Vec::new(); - for i in &ids { - let parent = doc.get(*i)?.parent.unwrap_or(page); - let idx = doc.index_in_parent(*i).map(|x| x + 1); - copies.push(crate::components::copy_in_file(doc, *i, parent, idx)?); - } - // Connectors copied with what they join join the copies. - let map = crate::board::copy_map(doc, &ids, &copies); - crate::board::rejoin(doc, &crate::board::copied_items(doc, &map), &map, false); - moving = copies; - *sel = moving.clone(); - } - let p = ev.p(); - let home = moving.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent); - // Absolute children of an auto layout frame stay in it, and so do the layers of - // groups, boolean groups and instances ([`crate::drop::keeps_layers`]); the - // rest may change parent. - let flow = moving.iter().all(|i| doc.node(*i).is_some_and(|n| !n.child_layout.absolute)) - && !home.is_some_and(|h| crate::drop::keeps_layers(doc, h)); - let target = if flow { crate::drop::drag_parent(doc, page, p, &moving) } else { home.unwrap_or(page) }; - let mode = doc.node(target).and_then(|n| n.layout.as_ref()).map(|l| l.mode); - if let (true, Some(mode)) = (flow, mode) { - // Into (or within) an auto layout frame: the layers take the slot (a grid: the - // cell) under the pointer, as the frame shows it now. A frame other than the - // one they come from is outlined too. - let cell = if mode == LayoutMode::Grid { crate::drop::grid_cell_at(doc, target, p) } else { None }; - let idx = - if mode == LayoutMode::Grid { 0 } else { crate::drop::auto_layout_slot(doc, target, p, &moving).map_or(0, |(i, _)| i) }; - for m in &moving { - if doc.get(*m)?.parent != Some(target) { - doc.reparent(*m, target, None)?; - } - } - if let (Some(first), Some((col, row, rect))) = (moving.first().copied(), cell) { - let cl = &mut doc.get_mut(first)?.child_layout; - cl.grid_column = Some(col); - cl.grid_row = Some(row); - drop_cell = Some(rect); - } else if mode != LayoutMode::Grid { - let mut kids: Vec = doc.get(target)?.children.iter().copied().filter(|c| !moving.contains(c)).collect(); - for (k, m) in moving.iter().enumerate() { - kids.insert((idx + k).min(kids.len()), *m); - } - let gap = doc.node(target).and_then(|n| n.layout.as_ref()).map_or(0.0, |l| l.gap); - doc.get_mut(target)?.children = kids; - slot_axis = Some((mode == LayoutMode::Vertical, gap)); - } - drop_target = (Some(target) != home).then_some(target); - in_flow = true; - return Ok(()); - } - // A free move. Layers dragged out of an auto layout flow leave it (onto the page - // while dragging; the drop puts them in the frame under the pointer). - for m in &moving { - let in_al = doc.node(*m).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()); - if flow && in_al { - doc.reparent(*m, page, None)?; - } - move_world(doc, *m, d.x, d.y)?; - } - drop_target = (flow && target != page).then_some(target); - Ok(()) - })?; - let placed = if dup { self.selection() } else { ids }; - // Snapping guides mean nothing while the layout places the layers; with Alt held, - // live distances to the neighbours replace them. - self.tool.overlay.guides = if in_flow { - Vec::new() - } else if ev.alt { - let doc = self.doc()?; - doc.bounds_of(&placed).map(|b| crate::distance::neighbours(doc, page, &placed, b)).unwrap_or_default() - } else { - guides - }; - // The preview already shows the layers in their slot: the line marks it there, in - // the middle of the gap before them (clear of the selection box). - self.tool.overlay.insert_line = slot_axis.and_then(|(vertical, gap)| { - let off = if gap.is_finite() { (gap / 2.0).clamp(3.0, 24.0) } else { 3.0 }; - self.doc().ok()?.bounds_of(&placed).map(|b| crate::drop::line_before(b, vertical, off)) - }); - self.tool.overlay.drop_cell = drop_cell; - self.tool.overlay.drop_target = drop_target; - self.tool.overlay.cursor = if self.refused(&placed, true) { - Cursor::NotAllowed - } else if dup { - Cursor::Copy - } else { - Cursor::Default - }; - self.tool.gesture = g.clone(); - if let Gesture::Move { moved, .. } = &mut self.tool.gesture { - *moved = true; - } - return Ok(()); - } - Gesture::Resize { start, r, scale_tool, .. } => { - let res = self.resize_drag(ev, *start, r, *scale_tool); - self.tool.gesture = Gesture::Resize { start: *start, r: r.clone(), scale_tool: *scale_tool, moved: true }; - if self.refused(&self.selection(), false) { - self.tool.overlay.cursor = Cursor::NotAllowed; - } - return res; - } - Gesture::Rotate { center, start_angle, ids } => { - let mut a = ((ev.p() - *center).atan2() - start_angle).to_degrees(); - if ev.shift { - a = (a / 15.0).round() * 15.0; - } - let (c, ids) = (*center, ids.clone()); - self.preview(|doc, _| { - for i in &ids { - crate::cmd::arrange::rotate_world(doc, *i, c, -a)?; - } - Ok(()) - })?; - // The cursor turns with the pointer about the centre. - self.tool.overlay.cursor = - if self.refused(&ids, false) { Cursor::NotAllowed } else { Cursor::Rotate((ev.p() - c).atan2().to_degrees()) }; - Ok(()) - } - Gesture::Draw { start, id, parent, targets } => { - let (start, id, parent, targets) = (*start, *id, *parent, targets.clone()); - self.tool.gesture = g; - return self.draw_drag(ev, start, id, parent, &targets); - } - Gesture::TextBox { id, start, line } => { - let (id, start, line) = (*id, *start, *line); - self.tool.gesture = g; - return self.text_box_drag(ev, id, start, line); - } - Gesture::Arc { id, handle, arc } => { - let (id, handle, arc) = (*id, *handle, *arc); - self.tool.gesture = g; - return self.arc_drag(ev, id, handle, arc); - } - Gesture::Shape { id, handle } => { - let (id, handle) = (*id, *handle); - let doc = self.doc()?.clone(); - if let Some(props) = crate::shape_handles::drag(&doc, id, handle, ev.p(), ev.alt) { - self.preview(|doc, _| crate::cmd::props::apply("node.set", doc, id, &props))?; - } - self.tool.overlay.shape = shape_hint(self.doc()?, id, handle, ev.zoom); - self.tool.overlay.shape_hover = Some(handle); - Ok(()) - } - Gesture::Pan { sx, sy, cx, cy } => { - let ds = self.ds_mut()?; - let z = ds.view.zoom.max(1e-6); - ds.view.cx = cx - (ev.sx - sx) / z; - ds.view.cy = cy - (ev.sy - sy) / z; - ds.revision += 1; - Ok(()) - } - Gesture::Pencil { points, pressure } => { - let (mut pts, mut pressure) = (points.clone(), pressure.clone()); - if pts.last().is_none_or(|l| (*l - ev.p()).hypot() > 0.5 / ev.zoom.max(1e-6)) { - pts.push(ev.p()); - pressure.push(ev.pressure); - } - self.tool.overlay.pen = pts.iter().map(|p| [p.x, p.y]).collect(); - self.tool.gesture = Gesture::Pencil { points: pts, pressure }; - if let Ok(ds) = self.ds_mut() { - ds.revision += 1; - } - return Ok(()); - } - Gesture::TextSelect { id } => { - let id = *id; - let idx = self.text_index_at(id, ev.p())?; - textedit::set_caret(self, idx, true)?; - Ok(()) - } - Gesture::Vector(_) | Gesture::ShapeBuild { .. } | Gesture::Origin => Ok(()), - Gesture::Connect { from, start } => { - let (from, start) = (*from, *start); - self.connect_drag(ev, from, start); - return Ok(()); - } - Gesture::Erase { gone } => { - self.tool.gesture = Gesture::Erase { gone: gone.clone() }; - return self.erase_at(ev); - } - Gesture::Reattach { id, from, start } => { - let (id, from, start) = (*id, *from, *start); - self.reattach_drag(ev, id, from, start); - return Ok(()); - } - Gesture::PenDrag { anchor } => { - // A click's wobble pulls out no handle. - let out = if within_slop(ev, *anchor) { Vec2::ZERO } else { ev.p() - *anchor }; - if let Some(pen) = self.tool.pen.as_mut() - && let Some(last) = pen.points.last_mut() - { - last.2 = out; - last.1 = -out; - } - self.pen_update()?; - Ok(()) - } - }; - self.tool.gesture = g; - res - } - - /// An arc handle drag: the new arc, written through `node.set`'s keys. - fn arc_drag(&mut self, ev: &PointerEvent, id: NodeId, handle: ArcHandle, arc: crate::arc_handles::Arc) -> Result<()> { - let doc = self.doc()?.clone(); - let Some(next) = crate::arc_handles::drag(&doc, id, handle, arc, ev.p(), ev.shift, ev.zoom) else { return Ok(()) }; - let props = crate::arc_handles::props(next); - self.preview(|doc, _| crate::cmd::props::apply("node.set", doc, id, &props))?; - self.tool.gesture = Gesture::Arc { id, handle, arc: next }; - let at = crate::arc_handles::handle_at(self.doc()?, id, handle, next, ev.zoom); - self.tool.overlay.arc = at.map(|at| ArcHint { handle, label: next.label(handle), at }); - self.tool.overlay.arc_hover = Some(handle); - Ok(()) - } - - fn resize_drag(&mut self, ev: &PointerEvent, start: Point, r: &ResizeStart, scale_tool: bool) -> Result<()> { - let page = self.page()?; - let snap = self.ds().map(|d| d.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl; - let ids: Vec = r.nodes.iter().map(|n| n.0).collect(); - let targets = if snap { Some(Targets::gather(self.doc()?, page, &ids)) } else { None }; - let mut p = ev.p(); - let mut guides = Vec::new(); - if let Some(t) = &targets { - let (dx, dy, g) = t.snap_point(p.x, p.y, SNAP_PX / ev.zoom.max(1e-6)); - p.x += dx; - p.y += dy; - guides = g; - } - let _ = start; - // In the handle box's own axes (one layer: its local space). - let (frame, w0, h0) = r.frame; - // The pixel grid: an upright box's dragged sides land on whole pixels (the Scale tool - // scales by the factor as dragged). - if !scale_tool && self.ds().map(|d| d.view.snap_pixel).unwrap_or(true) && crate::guides::axis_aligned(frame) { - p = Point::new(p.x.round(), p.y.round()); - } - let q = frame.inverse() * p; - let is_line = r.single && ids.first().and_then(|i| self.doc().ok()?.node(*i).map(|n| matches!(n.kind, NodeKind::Line))).unwrap_or(false); - if scale_tool { - return self.scale_drag(ev, r, q, guides); - } - if is_line { - // Line end handles: move the end point freely (length + angle). - let (a, b) = match r.handle { - Handle::Corner(0) => (p, frame * Point::new(w0, 0.0)), - _ => (frame * Point::new(0.0, 0.0), p), - }; - let id = ids.first().copied().unwrap_or(NodeId(0)); - self.preview(|doc, _| { - let mut v = b - a; - if ev.shift { - let ang = (v.atan2() / (std::f64::consts::PI / 4.0)).round() * std::f64::consts::PI / 4.0; - v = Vec2::from_angle(ang) * v.hypot(); - } - let len = v.hypot(); - let world = Affine::translate(a.to_vec2()) * Affine::rotate(v.atan2()); - doc.set_world_transform(id, world)?; - figcraft_layout::set_size(doc, id, len, 0.0); - Ok(()) - })?; - self.tool.overlay.guides = guides; - return Ok(()); - } - // Constrain proportions (Shift, or the node's lock). - let keep = ev.shift || r.single && ids.first().and_then(|i| self.doc().ok()?.node(*i).map(|n| n.constrain_proportions)).unwrap_or(false); - let (x0, y0, x1, y1) = resized_box(r.handle, q, w0, h0, keep, ev.alt); - let (flip_x, flip_y) = (x1 < x0, y1 < y0); - let (nx0, ny0) = (x0.min(x1), y0.min(y1)); - let (nw, nh) = ((x1 - x0).abs(), (y1 - y0).abs()); - let nodes = r.nodes.clone(); - let single = r.single; - let handle = r.handle; - // What's in a section stays where it is on the page, and so does what's in a frame resized - // with Cmd/Ctrl held (its constraints are ignored). - let keeps = |doc: &Document, id: NodeId| { - doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }) || (ev.cmd && single && n.kind.is_frame_like())) - }; - self.preview(|doc, _| { - if single { - let Some((id, world, _, _)) = nodes.first() else { return Ok(()) }; - let kept = if keeps(doc, *id) { crate::cmd::props::children_worlds(doc, *id) } else { Vec::new() }; - let mut t = *world * Affine::translate((nx0, ny0)); - if flip_x { - t *= Affine::new([-1.0, 0.0, 0.0, 1.0, nw, 0.0]); - } - if flip_y { - t *= Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, nh]); - } - // Text reflow and auto layout can recompute this box after the preview (a size bound - // to a variable lets go of it: the resize is an explicit edit). - let n = doc.get(*id)?; - let crop_images = ev.cmd - && !matches!(n.kind, NodeKind::Text(_)) - && !std::iter::once(*id).chain(doc.ancestors(*id)).any(|a| doc.node(a).is_some_and(|n| n.layout.is_some())); - if crop_images { - // Crop the layer's image fills without moving their pixels on the page. - // Work from the interaction snapshot, just like the geometry on every move. - let (w, h) = (n.width.max(1e-6), n.height.max(1e-6)); - let to_old = - Affine::scale_non_uniform(1.0 / w, 1.0 / h) * world.inverse() * t * Affine::scale_non_uniform(nw.max(0.01), nh.max(0.01)); - let mut fills = n.fills.clone(); - for paint in &mut fills { - let PaintKind::Image(img) = &mut paint.kind else { continue }; - // A repeating image cannot be represented by a single Crop placement. - if img.scale_mode == ImageScaleMode::Tile { - continue; - } - let Some(data) = doc.images.get(&img.image) else { continue }; - let (iw, ih) = (f64::from(data.width), f64::from(data.height)); - let crop = if img.scale_mode == ImageScaleMode::Crop { - img.effective_crop(iw, ih, w, h) - } else { - let (rw, rh) = img.rotated_size(iw.max(1.0), ih.max(1.0)); - let s = if img.scale_mode == ImageScaleMode::Fit { (w / rw).min(h / rh) } else { (w / rw).max(h / rh) }; - [w / (rw * s), 0.0, 0.0, h / (rh * s), (rw * s - w) / (2.0 * rw * s), (rh * s - h) / (2.0 * rh * s)] - }; - // A degenerate (zero-scale) layer has no inverse: its image keeps its placement. - let crop = (Affine::new(crop) * to_old).as_coeffs(); - if !crop.iter().all(|c| c.is_finite()) { - continue; - } - img.crop = Some(crop); - img.scale_mode = ImageScaleMode::Crop; - } - doc.get_mut(*id)?.fills = fills; - } - doc.set_world_transform(*id, t)?; - if kept.is_empty() { - crate::cmd::props::resize_node(doc, *id, nw.max(0.01), nh.max(0.01))?; - } else { - figcraft_layout::set_size(doc, *id, nw.max(0.01), nh.max(0.01)); - crate::cmd::props::restore_worlds(doc, &kept)?; - } - let n = doc.get_mut(*id)?; - if matches!(handle, Handle::Corner(_) | Handle::Edge(1) | Handle::Edge(3)) { - n.child_layout.sizing_h = Sizing::Fixed; - } - if matches!(handle, Handle::Corner(_) | Handle::Edge(0) | Handle::Edge(2)) { - n.child_layout.sizing_v = Sizing::Fixed; - } - if let NodeKind::Text(t) = &mut n.kind { - match handle { - Handle::Edge(1) | Handle::Edge(3) if t.auto_resize == TextAutoResize::Truncate => {} - Handle::Edge(1) | Handle::Edge(3) => t.auto_resize = TextAutoResize::Height, - _ => t.fix_size(true, true), - } - } - return Ok(()); - } - // Several layers: map the old box onto the new one, in the box's axes. Each layer's - // centre follows the box (mirrored when it flips); each keeps its rotation and scales - // along its own axes (see `axis_factors`), turning over with the box. - let (sx, sy) = (if w0 > 1e-9 { (x1 - x0) / w0 } else { 1.0 }, if h0 > 1e-9 { (y1 - y0) / h0 } else { 1.0 }); - let mirror = Affine::new([sx.signum(), 0.0, 0.0, sy.signum(), 0.0, 0.0]); - let inv = frame.inverse(); - for (id, world, w, h) in &nodes { - let local = inv * *world; - let center = local * Point::new(w / 2.0, h / 2.0); - let (kw, kh) = figcraft_layout::axis_factors(local, sx, sy); - let (w2, h2) = (w * kw, h * kh); - let lin = (mirror * local).with_translation(Vec2::ZERO); - let origin = Point::new(x0 + center.x * sx, y0 + center.y * sy) - lin * Point::new(w2 / 2.0, h2 / 2.0); - let kept = if keeps(doc, *id) { crate::cmd::props::children_worlds(doc, *id) } else { Vec::new() }; - doc.set_world_transform(*id, frame * lin.with_translation(origin))?; - crate::cmd::props::resize_node(doc, *id, w2, h2)?; - crate::cmd::props::restore_worlds(doc, &kept)?; - } - Ok(()) - })?; - self.tool.overlay.guides = guides; - Ok(()) - } - - /// Scale tool handle drag (`q`: the snapped pointer in the handle box's axes): everything - /// scales by one factor about the anchor point (Alt: the centre), strokes, effects, radii and - /// text included, children by the same factor whatever their constraints. - fn scale_drag(&mut self, ev: &PointerEvent, r: &ResizeStart, q: Point, guides: Vec) -> Result<()> { - let (frame, w0, h0) = r.frame; - let Some((k, pivot)) = scale_factor(r.handle, q, w0, h0, self.tool.scale_anchor, ev.alt) else { return Ok(()) }; - let nodes = r.nodes.clone(); - let inv = frame.inverse(); - // A layer's place in the box scales about the pivot; a negative factor turns it over. - let about = frame * Affine::translate(pivot.to_vec2() * (1.0 - k)); - let sign = k.signum(); - self.preview(|doc, _| { - for (id, world, _, _) in &nodes { - let c = (inv * *world).as_coeffs(); - doc.set_world_transform(*id, about * Affine::new([sign * c[0], sign * c[1], sign * c[2], sign * c[3], k * c[4], k * c[5]]))?; - crate::cmd::arrange::scale_node(doc, *id, k.abs())?; - } - Ok(()) - })?; - let p = frame * pivot; - self.tool.overlay.scale = Some(ScaleHint { factor: k, pivot: [p.x, p.y] }); - self.tool.overlay.guides = guides; - Ok(()) - } - - fn draw_down(&mut self, ev: &PointerEvent) -> Result<()> { - let ds = self.ds()?; - let doc = ds.doc.clone(); - let page = ds.page; - if ds.text_edit.is_some() { - textedit::end(self)?; - } - // Frames drawn inside frames nest; sections only at the top or in sections. - let parent = match self.tool.tool { - ToolId::Section => { - frame_at(&doc, page, ev.p(), &[]).filter(|f| doc.node(*f).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }))).unwrap_or(page) - } - _ => frame_at(&doc, page, ev.p(), &[]).unwrap_or(page), - }; - let targets = Targets::gather(&doc, page, &[]); - self.begin_interaction(self.tool.tool.label())?; - self.tool.gesture = Gesture::Draw { start: ev.p(), id: None, parent, targets }; - Ok(()) - } - - fn draw_drag(&mut self, ev: &PointerEvent, start: Point, id: Option, parent: NodeId, targets: &Targets) -> Result<()> { - let tool = self.tool.tool; - let mut p = ev.p(); - let mut guides = Vec::new(); - if self.ds().map(|d| d.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl { - let (dx, dy, g) = targets.snap_point(p.x, p.y, SNAP_PX / ev.zoom.max(1e-6)); - p.x += dx; - p.y += dy; - guides = g; - } - let pixel = self.ds().map(|d| d.view.snap_pixel).unwrap_or(true); - let mut created = id; - let page = self.page()?; - self.preview(|doc, sel| { - let (mut a, mut b) = (start, p); - if pixel { - a = Point::new(a.x.round(), a.y.round()); - b = Point::new(b.x.round(), b.y.round()); - } - let pw = doc.world_transform(parent); - let (kind, base) = match tool { - ToolId::Frame => (NodeKind::Frame, "Frame"), - ToolId::Section => (NodeKind::Section { contents_hidden: false }, "Section"), - ToolId::Slice => (NodeKind::Slice, "Slice"), - ToolId::Rectangle => (NodeKind::Rectangle, "Rectangle"), - ToolId::Ellipse => (NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "Ellipse"), - ToolId::Polygon => (NodeKind::Polygon { count: 3 }, "Polygon"), - ToolId::Star => (NodeKind::Star { count: 5, inner_ratio: 0.38 }, "Star"), - ToolId::Line => (NodeKind::Line, "Line"), - ToolId::Arrow => (NodeKind::Line, "Arrow"), - _ => return Ok(()), - }; - let name = match tool { - ToolId::Frame | ToolId::Section | ToolId::Slice => doc.next_name(base), - _ => base.to_string(), - }; - let nid = doc.alloc_id(); - let mut n = if tool == ToolId::Section { Node::new_section(nid, name, 0.0, 0.0) } else { Node::new(nid, kind, name, 0.0, 0.0) }; - if tool == ToolId::Arrow { - n.stroke.cap_end = StrokeCap::LineArrow; - } - if matches!(tool, ToolId::Line | ToolId::Arrow) { - let mut v = b - a; - if ev.shift { - let ang = (v.atan2() / (std::f64::consts::PI / 4.0)).round() * std::f64::consts::PI / 4.0; - v = Vec2::from_angle(ang) * v.hypot(); - } - // Alt draws from the centre, as for the other shapes. - let (a, v) = if ev.alt { (a - v, v * 2.0) } else { (a, v) }; - n.width = v.hypot(); - n.transform = pw.inverse() * Affine::translate(a.to_vec2()) * Affine::rotate(v.atan2()); - } else { - let mut d = b - a; - if ev.shift { - let m = d.x.abs().max(d.y.abs()); - d = Vec2::new(m * d.x.signum(), m * d.y.signum()); - } - let (x0, y0, x1, y1) = if ev.alt { (a.x - d.x, a.y - d.y, a.x + d.x, a.y + d.y) } else { (a.x, a.y, a.x + d.x, a.y + d.y) }; - let r = Rect::new(x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)); - n.width = r.width(); - n.height = r.height(); - n.transform = pw.inverse() * Affine::translate((r.x0, r.y0)); - } - doc.insert(n, parent, None)?; - *sel = vec![nid]; - created = Some(nid); - let _ = page; - Ok(()) - })?; - self.tool.overlay.guides = guides; - self.tool.gesture = Gesture::Draw { start, id: created, parent, targets: targets.clone() }; - Ok(()) - } - - fn up(&mut self, ev: &PointerEvent) -> Result<()> { - let g = std::mem::take(&mut self.tool.gesture); - self.tool.overlay.marquee = None; - self.tool.overlay.guides.clear(); - self.tool.overlay.scale = None; - self.tool.overlay.arc = None; - self.tool.overlay.shape = None; - self.tool.overlay.measure_box = None; - self.tool.overlay.insert_line = None; - self.tool.overlay.drop_cell = None; - let drop_target = self.tool.overlay.drop_target.take(); - match g { - Gesture::Move { ids, moved, duplicate, start, deselect, .. } => { - if moved { - // A free move lands in the frame under the pointer (or the page) where it was - // dropped; layers in an auto layout frame already sit in their slot. - let page = self.page()?; - let sel = self.selection(); - let sources = if duplicate { ids.clone() } else { Vec::new() }; - let moving = if duplicate { sel } else { ids }; - let doc = self.doc()?.clone(); - let in_al = - moving.iter().any(|m| doc.node(*m).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some())); - let target = drop_target.unwrap_or(page); - let current = moving.first().and_then(|m| doc.node(*m)).and_then(|n| n.parent); - if !in_al && current != Some(target) { - let ds = self.ds_mut()?; - let before = ds.doc.clone(); - let d = ds.doc_mut(); - for m in &moving { - // Group, boolean group and instance layers stay put: they only move. - let from = d.node(*m).and_then(|n| n.parent); - if from.is_some_and(|f| crate::drop::keeps_layers(d, f)) || crate::components::structure_locked(d, target) { - continue; - } - // A layer can't go into itself; it stays where it is. - let _ = d.reparent(*m, target, None); - } - crate::normalize(ds.doc_mut(), &before); - } - // Reordering or dragging layers out of an instance (outside its slots), or - // moving layers inside one, are edits only its main can make: the gesture is - // undone. - if self.refused(&moving, true) { - self.cancel_interaction()?; - } else { - self.commit_interaction()?; - // Duplicate repeats a free Alt-drag's step (an auto layout places its own). - if duplicate && !in_al && moving.len() == sources.len() { - self.ds_mut()?.last_duplicate = Some(crate::cmd::edit::Duplicated { sources, copies: moving }); - } - } - } else { - self.cancel_interaction()?; - if let Some(d) = deselect { - let sel = self.selection().into_iter().filter(|x| *x != d).collect(); - self.set_selection(sel); - } - if deselect.is_none() && !(ev.shift || ev.cmd || duplicate) { - self.widget_click(start, ev.tol() * 2.0)?; - } - } - } - Gesture::Resize { .. } | Gesture::Rotate { .. } | Gesture::Arc { .. } | Gesture::Shape { .. } => { - // Layers inside an instance keep their main's size and rotation. - if self.refused(&self.selection(), false) { - self.cancel_interaction()?; - } else { - self.commit_interaction()?; - } - } - Gesture::Draw { start, id, parent, .. } => { - let tiny = (ev.p() - start).hypot() * ev.zoom < 3.0; - if tiny || id.is_none() { - // Click without drag: Figma makes a 100×100 shape (frames 100×100 too). - self.cancel_interaction()?; - self.begin_interaction(self.tool.tool.label())?; - let (w, h) = self.tool.tool.click_size(); - let target = start + Vec2::new(w, h); - let mut fake = ev.clone(); - fake.x = target.x; - fake.y = target.y; - fake.shift = false; - fake.alt = false; - self.tool.gesture = Gesture::None; - let targets = Targets::default(); - self.draw_drag(&fake, start, None, parent, &targets)?; - self.tool.gesture = Gesture::None; - } - if matches!(self.tool.tool, ToolId::Frame | ToolId::Section) - && let Some(drawn) = self.selection().first().copied() - { - let ds = self.ds_mut()?; - let before = ds.doc.clone(); - take_enclosed(ds.doc_mut(), drawn)?; - crate::normalize(ds.doc_mut(), &before); - } - self.commit_interaction()?; - if !self.prefs.keep_tool { - self.set_tool(ToolId::Move); - } - } - Gesture::Pan { .. } => { - self.tool.overlay.cursor = Cursor::Grab; - if let Some(t) = self.tool.temp_from.take() { - self.tool.tool = t; - } - } - Gesture::Pencil { mut points, mut pressure } => { - self.tool.overlay.pen.clear(); - // The stroke ends where the button came up, even without a move to get there. - if points.last().is_none_or(|l| (*l - ev.p()).hypot() > 0.5 / ev.zoom.max(1e-6)) { - points.push(ev.p()); - pressure.push(ev.pressure); - } - self.pencil_finish(&points, &pressure)?; - } - Gesture::Connect { from, start } => self.connect_up(ev, from, start)?, - Gesture::Erase { .. } => self.erase_up()?, - Gesture::Reattach { id, from, start } => self.reattach_up(ev, id, from, start)?, - Gesture::TextSelect { .. } | Gesture::TextBox { .. } | Gesture::Marquee { .. } | Gesture::None => {} - Gesture::PenDrag { .. } | Gesture::ShapeBuild { .. } | Gesture::Origin => {} - Gesture::Vector(_) => self.commit_interaction()?, - } - if let Ok(ds) = self.ds_mut() { - ds.revision += 1; - } - Ok(()) - } - - /// Handle a double-click gesture: finish a pen path, rename or click labels/widgets, - /// cancel an in-progress move/resize/draw/marquee, handle board double-clicks, enter text - /// editing (select-all on entry, word-select inside the running edit), enter vector editing - /// for selected shapes, and step groups/frames one level deeper. - /// A double-click on `leaf` that a board object or a widget's editable field takes (typing into - /// a sticky or a shape on a board; editing the field like any text, its plugin told when editing - /// ends). Whether it was taken. - fn object_double_click(&mut self, doc: &Document, leaf: NodeId, sel: &[NodeId], p: Point, edits: bool) -> Result { - if matches!(self.tool.tool, ToolId::Move | ToolId::Scale) && self.board_double_click(doc, leaf, sel)? { - return Ok(true); - } - match crate::widgets::field_at(doc, leaf, p).filter(|_| edits) { - Some(field) => textedit::begin(self, field, None, false).map(|()| true), - None => Ok(false), - } - } - - fn double_click(&mut self, ev: &PointerEvent) -> Result<()> { - let ds = self.ds()?; - let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); - if self.tool.tool == ToolId::Pen { - return self.pen_finish(); - } - let edits = !self.tool.dev_mode; - // A frame's or section's name label: rename it there (the canvas puts a field over it). - // Labels come before widgets, as on a press; the second press began a move: undo it. - if edits - && matches!(self.tool.tool, ToolId::Move | ToolId::Scale) - && !(ev.shift || ev.cmd || ev.alt) - && let Some(id) = label_at(&doc, page, &self.tool.labels, ev.p()) - { - self.tool.gesture = Gesture::None; - self.cancel_interaction()?; - if sel != [id] { - self.set_selection(vec![id]); - } - self.request_ui("canvas.renameLabel", serde_json::json!({"id": id})); - return Ok(()); - } - // Quick clicks on a widget's clickable part are clicks, one by one. - if edits - && matches!(self.tool.tool, ToolId::Move | ToolId::Scale) - && !(ev.shift || ev.cmd || ev.alt) - && self.hit(&doc, page, ev.p(), ev.tol() * 2.0).and_then(|hit| crate::widgets::target_at(&doc, hit, ev.p())).is_some() - { - self.tool.gesture = Gesture::None; - self.cancel_interaction()?; - self.widget_click(ev.p(), ev.tol() * 2.0)?; - return Ok(()); - } - // The second press began a move, a resize, a shape or a marquee: a double-click isn't one. - if matches!(self.tool.gesture, Gesture::Move { .. } | Gesture::Resize { .. } | Gesture::Draw { .. } | Gesture::Marquee { .. }) { - self.tool.gesture = Gesture::None; - self.tool.overlay.marquee = None; - self.cancel_interaction()?; - } - let hit_here = self.hit(&doc, page, ev.p(), ev.tol() * 2.0); - // A double-click anywhere on the one selected shape edits its points, like Enter: also - // inside a hollow (unfilled) closed shape, where the hit test finds nothing under the - // pointer (an open path or a line has no inside: only its stroke counts). - if edits - && let [id] = sel[..] - && doc.node(id).is_some_and(|n| n.kind.is_shape() && figcraft_doc::geometry::is_closed(n)) - && doc.world_bounds(id).is_some_and(|b| b.contains(ev.p())) - && hit_here.is_none_or(|h| h == id || doc.is_ancestor(id, h)) - { - return crate::vectoredit::begin_many(self, &[id]); - } - let Some(leaf) = hit_here else { return Ok(()) }; - if self.object_double_click(&doc, leaf, &sel, ev.p(), edits)? { - return Ok(()); - } - let target = select_target(&doc, leaf, &sel, true); - // Text: edit at the click. - if edits - && doc.node(target).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) - && (sel.contains(&target) || select_target(&doc, leaf, &sel, false) == target) - { - // Entering a text selects all of it; in the text being edited, the word. A - // double-click inside the running edit is an ordinary edit: starting a new session - // here would replace the IME owner's epoch and drop a composition on a plain gesture. - let editing = self.ds()?.text_edit.as_ref().is_some_and(|te| te.id == target); - let idx = self.text_index_at(target, ev.p())?; - if editing { - textedit::select_word(self, idx)?; - } else { - textedit::begin(self, target, Some(idx), false)?; - textedit::key(self, "SelectAll", false, false, false)?; - } - return Ok(()); - } - // Shapes and vectors: enter vector edit mode (every selected shape, the clicked one active). - // A shape clicked on its own edits even when the click that selects it and the double-click - // arrived in one frame (a press and a release the canvas took together), so no selection was - // made yet; another layer's shapes are reached one level at a time, as below. - if edits && doc.node(target).is_some_and(|n| n.kind.is_shape()) && (sel.contains(&target) || select_target(&doc, leaf, &sel, false) == target) - { - let mut ids = if sel.contains(&target) { crate::vectoredit::enter_targets(&doc, &sel).unwrap_or_default() } else { vec![target] }; - ids.retain(|i| *i != target); - ids.insert(0, target); - return crate::vectoredit::begin_many(self, &ids); - } - // Groups, frames and boolean groups: select one level deeper (the layer under the pointer). - let deeper = figcraft_doc::geometry::enter_target(&doc, leaf, &sel); - if deeper != target || !sel.contains(&deeper) { - self.set_selection(vec![deeper]); - } - Ok(()) - } - - /// A plain click at `p` on a clickable part of a widget calls its plugin back (a failure is - /// shown as a message). - fn widget_click(&mut self, p: Point, tol: f64) -> Result<()> { - let (doc, page) = (self.doc()?.clone(), self.page()?); - let Some((id, target)) = self.hit(&doc, page, p, tol).and_then(|hit| crate::widgets::target_at(&doc, hit, p)) else { return Ok(()) }; - if let Err(e) = self.execute("widget.click", &serde_json::json!({"id": id, "target": target})) { - self.message = Some(e.to_string()); - } - Ok(()) - } - - /// The char index in text node `id` at page point `p`. - pub fn text_index_at(&self, id: NodeId, p: Point) -> Result { - let doc = self.doc()?; - let n = doc.get(id)?; - let local = doc.world_transform(id).inverse() * p; - let Some((layout, dy)) = figcraft_text::layout_node(n) else { return Ok(0) }; - Ok(layout.hit(local.x, local.y - dy)) - } - - fn text_click(&mut self, id: NodeId, ev: &PointerEvent, extend: bool) -> Result<()> { - let idx = self.text_index_at(id, ev.p())?; - textedit::set_caret(self, idx, extend)?; - self.tool.gesture = Gesture::TextSelect { id }; - Ok(()) - } - - fn text_down(&mut self, ev: &PointerEvent) -> Result<()> { - let ds = self.ds()?; - let (doc, page) = (ds.doc.clone(), ds.page); - if let Some(te) = ds.text_edit.clone() { - if doc.world_bounds(te.id).is_some_and(|b| b.contains(ev.p())) { - return self.text_click(te.id, ev, ev.shift); - } - textedit::end(self)?; - } - // Clicking existing text edits it. - if let Some(leaf) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0) - && doc.node(leaf).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) - { - let idx = self.text_index_at(leaf, ev.p())?; - textedit::begin(self, leaf, Some(idx), false)?; - self.tool.gesture = Gesture::TextSelect { id: leaf }; - return Ok(()); - } - // New text at the click (Figma puts the baseline-ish top-left there). - let parent = frame_at(&doc, page, ev.p(), &[]).unwrap_or(page); - let ds = self.ds_mut()?; - let d = ds.doc_mut(); - let id = d.alloc_id(); - let mut n = Node::new(id, NodeKind::Text(TextData::default()), "Text", 1.0, 14.0); - let pw = d.world_transform(parent); - let at = Point::new(ev.x.round(), (ev.y - 7.0).round()); - n.transform = pw.inverse() * Affine::translate(at.to_vec2()); - n.name = String::new(); - if let Some(fills) = readable_text_fills(d, parent) { - n.fills = fills; - } - if let Some((w, h)) = figcraft_text::auto_size(&n) { - n.width = w.max(1.0); - n.height = h; - } - let line = n.height; - d.insert(n, parent, None)?; - ds.selection = vec![id]; - textedit::begin(self, id, Some(0), true)?; - self.tool.gesture = Gesture::TextBox { id, start: ev.p(), line }; - if !self.prefs.keep_tool { - self.tool.tool = ToolId::Move; - } - Ok(()) - } - - /// The layer whose path is under `ev` for the Text on a path tool and where along it the press - /// is (0..1 by length): the front-most shape or vector layer within a few screen pixels of its - /// first subpath (the one text follows), not locked and not inside an instance. Layers in front - /// whose path is further away don't hide it. - pub(crate) fn path_at(&self, doc: &Document, page: NodeId, ev: &PointerEvent) -> Option<(NodeId, f64)> { - let reach = ev.tol() * PATH_REACH_PX; - let near = |id: NodeId| -> Option<(NodeId, f64)> { - let n = doc.node(id)?; - let shape = matches!( - n.kind, - NodeKind::Rectangle - | NodeKind::Ellipse { .. } - | NodeKind::Polygon { .. } - | NodeKind::Star { .. } - | NodeKind::Line - | NodeKind::Vector { .. } - ); - if !shape || locked_in(doc, id) || crate::components::inside_instance(doc, id) { - return None; - } - let geom = figcraft_doc::geometry::stroke_geometry(n)?; - let line = figcraft_geom::measure::polylines(&geom)?.into_iter().next()?; - // Measured on the page (a layer scaled more one way than the other is still reached - // where it shows), the start by length along the layer's own path. - let world = doc.world_transform(id); - let p = ev.p(); - let mut best: Option<(f64, f64)> = None; - for (i, w) in line.points.windows(2).enumerate() { - let (a, b) = (world * w[0], world * w[1]); - let d = b - a; - let l2 = d.hypot2(); - let u = if l2 > 1e-18 { ((p - a).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 }; - let dist = (a.lerp(b, u) - p).hypot(); - if best.is_none_or(|(_, bd)| dist < bd) { - let (la, lb) = (*line.lengths.get(i)?, *line.lengths.get(i + 1)?); - best = Some((la + u * (lb - la), dist)); - } - } - let (along, dist) = best?; - let len = line.length(); - (len > 1e-9 && dist <= reach).then_some((id, along / len)) - }; - let mut candidates = Vec::new(); - path_candidates(doc, page, ev.p(), reach, 0, &mut candidates); - candidates.into_iter().find_map(near) - } - - /// A press with the Text on a path tool: on a path, a new empty text layer goes onto it, - /// starting where it was pressed, and typing starts. The text takes the path layer's place, - /// geometry, transform, constraints and effects, and its fills when one of them shows; the path - /// layer is removed. Ending the edit with nothing typed puts the path back without an undo step - /// ([`textedit::end`]). Elsewhere a press does nothing. - fn text_on_path_down(&mut self, ev: &PointerEvent) -> Result<()> { - if let Some(te) = self.ds()?.text_edit.clone() { - if self.ds()?.doc.world_bounds(te.id).is_some_and(|b| b.contains(ev.p())) { - return self.text_click(te.id, ev, ev.shift); - } - textedit::end(self)?; - } - let ds = self.ds()?; - let (doc, page) = (ds.doc.clone(), ds.page); - let Some((path, start)) = self.path_at(&doc, page, ev) else { return Ok(()) }; - let Some(pn) = doc.node(path) else { return Ok(()) }; - let (Some(parent), Some(geom)) = (pn.parent, figcraft_doc::geometry::stroke_geometry(pn)) else { return Ok(()) }; - let index = doc.index_in_parent(path); - self.begin_interaction("Text on a path")?; - let ds = self.ds_mut()?; - let d = ds.doc_mut(); - let id = d.alloc_id(); - let mut n = Node::new(id, NodeKind::Text(TextData::default()), "Text", pn.width, pn.height); - n.name = String::new(); - n.transform = pn.transform; - n.constraints = pn.constraints; - n.child_layout = pn.child_layout.clone(); - let effects = !pn.effects.is_empty(); - if effects { - n.effects = pn.effects.clone(); - n.styles.effect = pn.styles.effect.clone(); - } - // A path drawn with a stroke only has no fill to give: the text gets the usual one. - let filled = pn.fills.iter().any(|f| f.visible); - if filled { - n.fills = pn.fills.clone(); - n.styles.fill = pn.styles.fill.clone(); - } else if let Some(fills) = readable_text_fills(d, parent) { - n.fills = fills; - } - // As `text.onPath`: a binding goes with the list it binds into (`fill`, `fills`, `fills/N/…`). - let carried = |k: &str| match crate::cmd::lists::list_binding(k) { - Some((crate::cmd::lists::BoundList::Fills, ..)) => filled, - Some((crate::cmd::lists::BoundList::Effects, ..)) => effects, - _ => false, - }; - n.bound = pn.bound.iter().filter(|(k, _)| carried(k)).map(|(k, v)| (k.clone(), v.clone())).collect(); - if let NodeKind::Text(t) = &mut n.kind { - let mut tp = TextPath::new(&geom); - tp.start = start; - t.path = Some(Box::new(tp)); - } - d.insert(n, parent, index)?; - d.remove(path)?; - ds.selection = vec![id]; - textedit::begin(self, id, Some(0), true)?; - if !self.prefs.keep_tool { - self.tool.tool = ToolId::Move; - } - Ok(()) - } - - /// Drag out a new text box: past the drag slop it has the drag's size (at least one line - /// tall) and its top-left, and that size is fixed. - fn text_box_drag(&mut self, ev: &PointerEvent, id: NodeId, start: Point, line: f64) -> Result<()> { - if within_slop(ev, start) { - return Ok(()); - } - let r = Rect::from_points(start, ev.p()); - let d = self.ds_mut()?.doc_mut(); - let pw = d.node(id).and_then(|n| n.parent).map(|p| d.world_transform(p)).unwrap_or_default(); - let n = d.get_mut(id)?; - n.transform = pw.inverse() * Affine::translate((r.x0.round(), r.y0.round())); - n.width = r.width().round().max(1.0); - n.height = r.height().round().max(line); - n.child_layout.sizing_h = Sizing::Fixed; - n.child_layout.sizing_v = Sizing::Fixed; - if let NodeKind::Text(t) = &mut n.kind { - t.auto_resize = TextAutoResize::None; - } - self.ds_mut()?.revision += 1; - Ok(()) - } - - fn eyedrop(&mut self, ev: &PointerEvent) -> Result<()> { - let ds = self.ds()?; - let doc = ds.doc.clone(); - let view = figcraft_render::View { origin: (ev.x, ev.y), scale: ev.zoom.max(0.01), width: 1, height: 1 }; - let Some(pix) = figcraft_render::render_view(&doc, ds.page, &view, &Default::default()) else { return Ok(()) }; - let rgba = figcraft_render::to_rgba8(&pix); - let c = figcraft_color::Color::from_rgba8( - rgba.first().copied().unwrap_or(0), - rgba.get(1).copied().unwrap_or(0), - rgba.get(2).copied().unwrap_or(0), - 255, - ); - let sel = ds.selection.clone(); - if self.tool.pick_only { - self.tool.picked = Some(c); - } else if !sel.is_empty() { - self.execute("node.set", &json!({"ids": sel, "props": {"fill": c.to_css_hex()}}))?; - } - self.recent_colors.retain(|x| *x != c); - self.recent_colors.insert(0, c); - self.recent_colors.truncate(16); - self.message = Some(format!("Picked #{}", c.to_hex())); - self.set_tool(ToolId::Move); - Ok(()) - } - - /// The pen tool's snapped position for `ev`: its path's vector (or, before it has one, its first - /// point) counts among the targets. - fn snap_pen_tool(&mut self, ev: &PointerEvent) -> Result<(Point, Vec)> { - let (id, extra) = match &self.tool.pen { - Some(pen) if pen.id.is_some() => (pen.id, Vec::new()), - Some(pen) => (None, pen.points.iter().map(|(p, _, _)| *p).collect()), - None => (None, Vec::new()), - }; - self.snap_pen(ev, id, &extra) - } - - fn pen_down(&mut self, ev: &PointerEvent) -> Result<()> { - self.tool.overlay.guides.clear(); - // The segment being placed shows as itself while the press lasts. - self.tool.overlay.rubber = None; - self.tool.overlay.rubber_handle = None; - let p = if ev.shift { ev.p() } else { self.snap_pen_tool(ev)?.0 }; - let (doc, page) = { - let ds = self.ds()?; - (ds.doc.clone(), ds.page) - }; - // With an open path selected, a click on one of its end points continues it in edit mode. - if self.tool.pen.is_none() - && let Some((id, v)) = selected_end_at(&doc, &self.selection(), ev.p(), 8.0 / ev.zoom.max(1e-6)) - { - return self.pen_edit(id, Some(v)); - } - let pen = self.tool.pen.get_or_insert_with(PenPath::default); - // Clicking the first anchor closes the path. - if pen.points.len() > 2 - && let Some((first, _, _)) = pen.points.first() - && (*first - p).hypot() * ev.zoom < 8.0 - { - pen.closed = true; - self.pen_update()?; - return self.pen_finish(); - } - // Clicking another of its own anchors (not the last) connects there and ends the path. - let near = |q: &Point| (*q - p).hypot() * ev.zoom < 8.0; - if let Some(k) = (1..pen.points.len().saturating_sub(1)).find(|k| pen.points.get(*k).is_some_and(|(q, _, _)| near(q))) { - pen.join = Some(k); - self.pen_update()?; - return self.pen_finish(); - } - if pen.parent.is_none() { - pen.parent = Some(frame_at(&doc, page, p, &[]).unwrap_or(page)); - } - let mut p = p; - if ev.shift - && let Some((last, _, _)) = pen.points.last() - { - p = at_45_degrees(*last, p); - } - pen.points.push((p, Vec2::ZERO, Vec2::ZERO)); - self.tool.gesture = Gesture::PenDrag { anchor: p }; - self.pen_update() - } - - /// Rebuild the pen's vector node from its anchors. - fn pen_update(&mut self) -> Result<()> { - let Some(pen) = self.tool.pen.clone() else { return Ok(()) }; - self.tool.overlay.pen = pen.points.iter().map(|(p, _, _)| [p.x, p.y]).collect(); - self.tool.overlay.pen_handles = pen_handles(&pen.points); - if pen.points.len() < 2 { - if let Ok(ds) = self.ds_mut() { - ds.revision += 1; - } - return Ok(()); - } - let page = self.page()?; - let parent = pen.parent.unwrap_or(page); - if pen.id.is_none() { - self.begin_interaction("Pen")?; - } - let ds = self.ds_mut()?; - let doc = ds.doc_mut(); - let pw = doc.world_transform(parent); - let inv = pw.inverse(); - // Anchors and handles into the parent's space. - let pts: Vec<(Point, Point, Point)> = pen.points.iter().map(|(p, i, o)| (inv * *p, inv * (*p + *i), inv * (*p + *o))).collect(); - let mut path = figcraft_geom::BezPath::new(); - for (k, (p, _, _)) in pts.iter().enumerate() { - if k == 0 { - path.move_to(*p); - } else if let (Some(prev), Some(cur)) = (pts.get(k - 1), pts.get(k)) { - path.curve_to(prev.2, cur.1, *p); - } - } - if pen.closed - && let (Some(last), Some(first)) = (pts.last(), pts.first()) - { - path.curve_to(last.2, first.1, first.0); - path.close_path(); - } - use figcraft_geom::Shape; - let bb = path.bounding_box(); - path.apply_affine(Affine::translate((-bb.x0, -bb.y0))); - let mut net = VectorNetwork::from_path(&path); - // Straight segments stay straight. - for s in &mut net.segments { - if (s.ta.0.abs() + s.ta.1.abs()) < 1e-9 && (s.tb.0.abs() + s.tb.1.abs()) < 1e-9 { - s.ta = (0.0, 0.0); - s.tb = (0.0, 0.0); - } - } - if !pen.closed { - net.regions.clear(); - } - // Anchor k is vertex k of the open path. - if let (Some(k), Some((last, _, out))) = (pen.join, pts.last()) { - let from = pts.len() - 1; - crate::vectoredit::ops::connect(&mut net, from, k, (out.x - last.x, out.y - last.y), (0.0, 0.0)); - } - let id = match pen.id { - Some(id) if doc.contains(id) => id, - _ => { - let id = doc.alloc_id(); - let mut n = Node::new(id, NodeKind::Vector { network: VectorNetwork::default() }, "Vector", 0.0, 0.0); - n.fills.clear(); - n.strokes = vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]; - n.stroke.align = StrokeAlign::Center; - doc.insert(n, parent, None)?; - id - } - }; - let n = doc.get_mut(id)?; - n.kind = NodeKind::Vector { network: net }; - n.width = bb.width(); - n.height = bb.height(); - n.transform = Affine::translate((bb.x0, bb.y0)); - if pen.closed && n.fills.is_empty() { - n.fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::DEFAULT_SHAPE)]; - } - ds.selection = vec![id]; - ds.revision += 1; - if let Some(p) = self.tool.pen.as_mut() { - p.id = Some(id); - } - Ok(()) - } - - /// Edit `id` as a vector with the pen, going on from point `anchor` (or, without one, starting - /// a new path in the same layer at the next click). - fn pen_edit(&mut self, id: NodeId, anchor: Option) -> Result<()> { - self.set_tool(ToolId::Move); - crate::vectoredit::begin(self, id)?; - crate::vectoredit::set_tool(self, crate::vectoredit::VTool::Pen)?; - crate::vectoredit::set_state(self, |e| { - e.anchor = anchor; - e.vertices = anchor.into_iter().collect(); - }) - } - - /// Enter/Escape while the pen draws: the path ends but its layer stays in edit mode with the - /// pen, so the next path joins the same vector (another Escape leaves edit mode). - pub fn pen_end(&mut self) -> Result<()> { - let id = self.tool.pen.as_ref().and_then(|p| p.id); - self.pen_finish()?; - match id { - Some(id) if self.doc()?.contains(id) => self.pen_edit(id, None), - _ => Ok(()), - } - } - - /// Finish the pen path (and keep the pen tool: double-click, Delete, a tool switch). - pub fn pen_finish(&mut self) -> Result<()> { - let pen = self.tool.pen.take(); - self.tool.overlay.pen.clear(); - self.tool.overlay.pen_handles.clear(); - self.tool.overlay.rubber = None; - self.tool.overlay.rubber_handle = None; - self.tool.gesture = Gesture::None; - if pen.as_ref().is_some_and(|p| p.id.is_some()) { - self.commit_interaction()?; - } - Ok(()) - } - - fn pencil_finish(&mut self, points: &[Point], pressure: &[Option]) -> Result<()> { - if points.len() < 2 { - return Ok(()); - } - // Smooth: fit a curve through the samples. - let mut poly = figcraft_geom::BezPath::new(); - for (k, p) in points.iter().enumerate() { - if k == 0 { - poly.move_to(*p); - } else { - poly.line_to(*p); - } - } - let fitted = figcraft_geom::kurbo::simplify::simplify_bezpath(poly.iter(), 0.75, &Default::default()); - let d = fitted.to_svg(); - if matches!(self.tool.tool, ToolId::Marker | ToolId::Highlighter) { - return self.ink_finish(&d); - } - let page = self.page()?; - let parent = frame_at(self.doc()?, page, points.first().copied().unwrap_or_default(), &[]).unwrap_or(page); - let pw = self.doc()?.world_transform(parent); - let mut local = fitted.clone(); - local.apply_affine(pw.inverse()); - let _ = d; - let (color, weight) = PENCIL_STROKE; - let mut props = json!({"fills": [], "strokes": [color.to_css_hex()], "strokeWeight": weight, "strokeCap": "round", "strokeJoin": "round", "strokeAlign": "center"}); - // Pen pressure becomes the stroke's width profile (brushes follow it too). - if let Some(points) = pressure_profile(points, pressure) { - props["widthProfile"] = json!(points); - } - if let Some(b) = &self.tool.pencil_brush { - props["brush"] = serde_json::to_value(b).unwrap_or_default(); - } - self.execute( - "node.create", - &json!({"type": "vector", "path": local.to_svg(), "closed": false, "parent": parent, "name": "Vector", "props": props}), - )?; - Ok(()) - } - - /// Keys the current mode handles before menu shortcuts (vector edit mode's tools and point - /// keys, Enter to finish the shape builder). Returns whether the key was used. - pub fn tool_key(&mut self, name: &str, shift: bool, alt: bool, cmd: bool) -> Result { - // Dev Mode edits nothing: keys go to the commands (which it refuses in the app). - if self.tool.dev_mode { - return Ok(false); - } - if self.ds().is_ok_and(|d| d.vector_edit.is_some()) { - return crate::vectoredit::key(self, name, shift, alt, cmd); - } - if !alt && !cmd && self.guide_key(name, shift)? { - return Ok(true); - } - crate::shapebuilder::key(self, name) - } - - /// Keys for a selected ruler guide: Delete removes it, arrows across it nudge it (Shift: the big - /// nudge), Escape deselects it. - fn guide_key(&mut self, name: &str, shift: bool) -> Result { - let Some((owner, index)) = self.ds()?.selected_guide() else { return Ok(false) }; - // Backspace is Delete, `Left` is `ArrowLeft`, and so on. - let name = crate::keymap::normalize(name).map_or(name, |(k, _)| k); - let page = self.page()?; - let at = |mut p: serde_json::Value| { - if owner != page { - p["frame"] = json!(owner); - } - p - }; - let Some(g) = self.doc()?.node(owner).and_then(|n| n.guides().get(index).cloned()) else { return Ok(false) }; - let step = if shift { self.prefs.nudge_big } else { self.prefs.nudge_small }; - let d = match (name, g.vertical) { - ("Delete", _) => { - self.execute("guide.remove", &at(json!({"index": index})))?; - return Ok(true); - } - ("Escape", _) => { - self.execute("guide.select", &json!({}))?; - return Ok(true); - } - ("ArrowLeft", true) | ("ArrowUp", false) => -step, - ("ArrowRight", true) | ("ArrowDown", false) => step, - _ => return Ok(false), - }; - self.execute("guide.move", &at(json!({"index": index, "offset": g.offset + d})))?; - Ok(true) - } - - /// The layer under `p` for a click: the topmost on `page`, or in the focus view the focused - /// frame's. Boolean groups are hit by their results ([`crate::booleans::result_outline`]); in - /// outline mode shapes only on their outlines. - pub fn hit(&self, doc: &Document, page: NodeId, p: Point, tol: f64) -> Option { - let sel = self.selection(); - let result = crate::booleans::result_outline(doc, &sel); - let focus = self.ds().ok().and_then(crate::DocState::focused); - // Outline mode draws no fills: shapes are clicked on their outlines, reaching what's behind. - if let Some(v) = self.ds().ok().map(|d| &d.view).filter(|v| v.outlines) { - // What it draws besides the shapes can be clicked too. - let also = figcraft_doc::geometry::OutlineHits { hidden: v.outline_hidden, bounds: v.outline_bounds }; - return figcraft_doc::geometry::hit_outlines(doc, focus.unwrap_or(page), p, tol, Some(&result), also); - } - match focus { - Some(f) => figcraft_doc::geometry::hit_in(doc, f, p, tol, Some(&result)), - None => doc.hit_test(page, p, tol, Some(&result)), - } - } - - /// Enter with a shape or frame tool (`tool.place`): what a click with it makes, centred on `at` - /// (default the middle of the view) and selected. - pub fn place(&mut self, at: Option) -> Result<()> { - let tool = self.tool.tool; - let refuse = |why: &str| Err(crate::EngineError::Disabled("tool.place".into(), why.into())); - if !tool.draws_shape() { - return refuse("choose a shape or frame tool first"); - } - if self.tool.busy() { - return refuse("finish the drag first"); - } - let v = &self.ds()?.view; - let (c, zoom) = (at.unwrap_or(Point::new(v.cx, v.cy)), v.zoom); - let (w, h) = tool.click_size(); - let ev = |kind| PointerEvent::at(kind, c.x - w / 2.0, c.y - h / 2.0, zoom); - let r = self.pointer(&ev(PointerKind::Down)).and_then(|()| self.pointer(&ev(PointerKind::Up))); - if r.is_err() { - self.tool.gesture = Gesture::None; - let _ = self.cancel_interaction(); - } - r - } - - /// Keyboard Escape: cancel gestures, finish pens, leave text editing, then deselect (in the - /// focus view, once the focused frame or nothing is selected, it ends the focus view). - pub fn escape(&mut self) -> Result<()> { - // Following someone in a live session: Escape stops that first. - if self.live_unfollow() { - return Ok(()); - } - if self.tool.pen.is_some() { - return self.pen_end(); - } - if self.tool.busy() { - self.tool.gesture = Gesture::None; - self.tool.overlay = Overlay::default(); - return self.cancel_interaction(); - } - if self.end_origin_edit() { - return Ok(()); - } - if self.ds()?.text_edit.is_some() { - return textedit::end(self); - } - if self.ds()?.vector_edit.is_some() { - return crate::vectoredit::escape(self); - } - if self.tool.tool != ToolId::Move { - self.set_tool(ToolId::Move); - return Ok(()); - } - let sel = self.selection(); - if let Some(f) = self.ds()?.focused() - && (sel.is_empty() || sel == [f]) - { - self.ds_mut()?.focus = None; - return Ok(()); - } - // Escape selects the parent, then nothing. - let doc = self.doc()?; - let parents: Vec = - sel.iter().filter_map(|i| doc.node(*i).and_then(|n| n.parent)).filter(|p| !doc.node(*p).is_some_and(Node::is_page)).collect(); - if !parents.is_empty() { - let mut uniq = Vec::new(); - for p in parents { - if !uniq.contains(&p) { - uniq.push(p); - } - } - self.set_selection(uniq); - } else { - self.set_selection(vec![]); - } - Ok(()) - } - - /// Whether the Move tool shows arc and shape handles now (not while another gesture runs, nor - /// in vector or text edit mode). - fn arc_handles_shown(&self) -> bool { - let arc_drag = matches!(self.tool.gesture, Gesture::Arc { .. } | Gesture::Shape { .. }); - self.tool.tool == ToolId::Move - && !self.tool.dev_mode - && (arc_drag || !self.tool.busy()) - && self.ds().is_ok_and(|ds| ds.vector_edit.is_none() && ds.text_edit.is_none()) - } - - /// The selected ellipse's arc handles as the canvas shows them at the current zoom. - pub fn arc_handles(&self) -> Option { - let ds = self.ds().ok()?; - self.arc_handles_shown().then(|| crate::arc_handles::handles(&ds.doc, &ds.selection, ds.view.zoom)).flatten() - } - - /// The selected shape's corner radius and star ratio handles as the canvas shows them now. - pub fn shape_handles(&self) -> Vec<(ShapeHandle, [f64; 2])> { - let Ok(ds) = self.ds() else { return vec![] }; - if self.arc_handles_shown() { crate::shape_handles::handles(&ds.doc, &ds.selection, ds.view.zoom) } else { vec![] } - } - - /// Snapshot of tool state for the UI / inspect. - pub fn tool_info(&self) -> serde_json::Value { - let shapes: Vec = self.shape_handles().into_iter().map(|(h, at)| json!({"handle": h, "at": at})).collect(); - json!({"tool": self.tool.tool.id(), "busy": self.tool.busy(), "overlay": self.tool.overlay, "arcHandles": self.arc_handles(), "shapeHandles": shapes}) - } -} - -/// The readout of a shape handle drag on `id`. -fn shape_hint(doc: &Document, id: NodeId, handle: ShapeHandle, zoom: f64) -> Option { - Some(ShapeHint { handle, value: crate::shape_handles::value(doc, id, handle)?, at: crate::shape_handles::handle_at(doc, id, handle, zoom)? }) -} - -#[allow(dead_code)] -fn unused(_: BTreeMap) {} - -/// An end point of a selected open vector within `tol` of page point `p`: (layer, point). -fn selected_end_at(doc: &Document, sel: &[NodeId], p: Point, tol: f64) -> Option<(NodeId, usize)> { - sel.iter().find_map(|id| { - let net = crate::vectoredit::network(doc, *id)?; - let w = doc.world_transform(*id); - let near = |v: &usize| net.vertices.get(*v).is_some_and(|x| (w * x.point() - p).hypot() <= tol); - (0..net.vertices.len()).filter(near).find(|v| net.incident(*v).len() == 1).map(|v| (*id, v)) - }) -} diff --git a/crates/engine/src/canvas/tools/click.rs b/crates/engine/src/canvas/tools/click.rs new file mode 100644 index 00000000..8f21aaca --- /dev/null +++ b/crates/engine/src/canvas/tools/click.rs @@ -0,0 +1,344 @@ +use super::handles::{PATH_REACH_PX, label_at, locked_in, path_candidates, readable_text_fills}; +use super::*; + +impl Session { + /// Handle a double-click gesture: finish a pen path, rename or click labels/widgets, + /// cancel an in-progress move/resize/draw/marquee, handle board double-clicks, enter text + /// editing (select-all on entry, word-select inside the running edit), enter vector editing + /// for selected shapes, and step groups/frames one level deeper. + /// A double-click on `leaf` that a board object or a widget's editable field takes (typing into + /// a sticky or a shape on a board; editing the field like any text, its plugin told when editing + /// ends). Whether it was taken. + fn object_double_click(&mut self, doc: &Document, leaf: NodeId, sel: &[NodeId], p: Point, edits: bool) -> Result { + if matches!(self.tool.tool, ToolId::Move | ToolId::Scale) && self.board_double_click(doc, leaf, sel)? { + return Ok(true); + } + match crate::widgets::field_at(doc, leaf, p).filter(|_| edits) { + Some(field) => textedit::begin(self, field, None, false).map(|()| true), + None => Ok(false), + } + } + + pub(super) fn double_click(&mut self, ev: &PointerEvent) -> Result<()> { + let ds = self.ds()?; + let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); + if self.tool.tool == ToolId::Pen { + return self.pen_finish(); + } + let edits = !self.tool.dev_mode; + // A frame's or section's name label: rename it there (the canvas puts a field over it). + // Labels come before widgets, as on a press; the second press began a move: undo it. + if edits + && matches!(self.tool.tool, ToolId::Move | ToolId::Scale) + && !(ev.shift || ev.cmd || ev.alt) + && let Some(id) = label_at(&doc, page, &self.tool.labels, ev.p()) + { + self.tool.gesture = Gesture::None; + self.cancel_interaction()?; + if sel != [id] { + self.set_selection(vec![id]); + } + self.request_ui("canvas.renameLabel", serde_json::json!({"id": id})); + return Ok(()); + } + // Quick clicks on a widget's clickable part are clicks, one by one. + if edits + && matches!(self.tool.tool, ToolId::Move | ToolId::Scale) + && !(ev.shift || ev.cmd || ev.alt) + && self.hit(&doc, page, ev.p(), ev.tol() * 2.0).and_then(|hit| crate::widgets::target_at(&doc, hit, ev.p())).is_some() + { + self.tool.gesture = Gesture::None; + self.cancel_interaction()?; + self.widget_click(ev.p(), ev.tol() * 2.0)?; + return Ok(()); + } + // The second press began a move, a resize, a shape or a marquee: a double-click isn't one. + if matches!(self.tool.gesture, Gesture::Move { .. } | Gesture::Resize { .. } | Gesture::Draw { .. } | Gesture::Marquee { .. }) { + self.tool.gesture = Gesture::None; + self.tool.overlay.marquee = None; + self.cancel_interaction()?; + } + let hit_here = self.hit(&doc, page, ev.p(), ev.tol() * 2.0); + // A double-click anywhere on the one selected shape edits its points, like Enter: also + // inside a hollow (unfilled) closed shape, where the hit test finds nothing under the + // pointer (an open path or a line has no inside: only its stroke counts). + if edits + && let [id] = sel[..] + && doc.node(id).is_some_and(|n| n.kind.is_shape() && figcraft_doc::geometry::is_closed(n)) + && doc.world_bounds(id).is_some_and(|b| b.contains(ev.p())) + && hit_here.is_none_or(|h| h == id || doc.is_ancestor(id, h)) + { + return crate::vectoredit::begin_many(self, &[id]); + } + let Some(leaf) = hit_here else { return Ok(()) }; + if self.object_double_click(&doc, leaf, &sel, ev.p(), edits)? { + return Ok(()); + } + let target = select_target(&doc, leaf, &sel, true); + // Text: edit at the click. + if edits + && doc.node(target).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) + && (sel.contains(&target) || select_target(&doc, leaf, &sel, false) == target) + { + // Entering a text selects all of it; in the text being edited, the word. A + // double-click inside the running edit is an ordinary edit: starting a new session + // here would replace the IME owner's epoch and drop a composition on a plain gesture. + let editing = self.ds()?.text_edit.as_ref().is_some_and(|te| te.id == target); + let idx = self.text_index_at(target, ev.p())?; + if editing { + textedit::select_word(self, idx)?; + } else { + textedit::begin(self, target, Some(idx), false)?; + textedit::key(self, "SelectAll", false, false, false)?; + } + return Ok(()); + } + // Shapes and vectors: enter vector edit mode (every selected shape, the clicked one active). + // A shape clicked on its own edits even when the click that selects it and the double-click + // arrived in one frame (a press and a release the canvas took together), so no selection was + // made yet; another layer's shapes are reached one level at a time, as below. + if edits && doc.node(target).is_some_and(|n| n.kind.is_shape()) && (sel.contains(&target) || select_target(&doc, leaf, &sel, false) == target) + { + let mut ids = if sel.contains(&target) { crate::vectoredit::enter_targets(&doc, &sel).unwrap_or_default() } else { vec![target] }; + ids.retain(|i| *i != target); + ids.insert(0, target); + return crate::vectoredit::begin_many(self, &ids); + } + // Groups, frames and boolean groups: select one level deeper (the layer under the pointer). + let deeper = figcraft_doc::geometry::enter_target(&doc, leaf, &sel); + if deeper != target || !sel.contains(&deeper) { + self.set_selection(vec![deeper]); + } + Ok(()) + } + + /// A plain click at `p` on a clickable part of a widget calls its plugin back (a failure is + /// shown as a message). + pub(super) fn widget_click(&mut self, p: Point, tol: f64) -> Result<()> { + let (doc, page) = (self.doc()?.clone(), self.page()?); + let Some((id, target)) = self.hit(&doc, page, p, tol).and_then(|hit| crate::widgets::target_at(&doc, hit, p)) else { return Ok(()) }; + if let Err(e) = self.execute("widget.click", &serde_json::json!({"id": id, "target": target})) { + self.message = Some(e.to_string()); + } + Ok(()) + } + + /// The char index in text node `id` at page point `p`. + pub fn text_index_at(&self, id: NodeId, p: Point) -> Result { + let doc = self.doc()?; + let n = doc.get(id)?; + let local = doc.world_transform(id).inverse() * p; + let Some((layout, dy)) = figcraft_text::layout_node(n) else { return Ok(0) }; + Ok(layout.hit(local.x, local.y - dy)) + } + + pub(super) fn text_click(&mut self, id: NodeId, ev: &PointerEvent, extend: bool) -> Result<()> { + let idx = self.text_index_at(id, ev.p())?; + textedit::set_caret(self, idx, extend)?; + self.tool.gesture = Gesture::TextSelect { id }; + Ok(()) + } + + pub(super) fn text_down(&mut self, ev: &PointerEvent) -> Result<()> { + let ds = self.ds()?; + let (doc, page) = (ds.doc.clone(), ds.page); + if let Some(te) = ds.text_edit.clone() { + if doc.world_bounds(te.id).is_some_and(|b| b.contains(ev.p())) { + return self.text_click(te.id, ev, ev.shift); + } + textedit::end(self)?; + } + // Clicking existing text edits it. + if let Some(leaf) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0) + && doc.node(leaf).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) + { + let idx = self.text_index_at(leaf, ev.p())?; + textedit::begin(self, leaf, Some(idx), false)?; + self.tool.gesture = Gesture::TextSelect { id: leaf }; + return Ok(()); + } + // New text at the click (Figma puts the baseline-ish top-left there). + let parent = frame_at(&doc, page, ev.p(), &[]).unwrap_or(page); + let ds = self.ds_mut()?; + let d = ds.doc_mut(); + let id = d.alloc_id(); + let mut n = Node::new(id, NodeKind::Text(TextData::default()), "Text", 1.0, 14.0); + let pw = d.world_transform(parent); + let at = Point::new(ev.x.round(), (ev.y - 7.0).round()); + n.transform = pw.inverse() * Affine::translate(at.to_vec2()); + n.name = String::new(); + if let Some(fills) = readable_text_fills(d, parent) { + n.fills = fills; + } + if let Some((w, h)) = figcraft_text::auto_size(&n) { + n.width = w.max(1.0); + n.height = h; + } + let line = n.height; + d.insert(n, parent, None)?; + ds.selection = vec![id]; + textedit::begin(self, id, Some(0), true)?; + self.tool.gesture = Gesture::TextBox { id, start: ev.p(), line }; + if !self.prefs.keep_tool { + self.tool.tool = ToolId::Move; + } + Ok(()) + } + + /// The layer whose path is under `ev` for the Text on a path tool and where along it the press + /// is (0..1 by length): the front-most shape or vector layer within a few screen pixels of its + /// first subpath (the one text follows), not locked and not inside an instance. Layers in front + /// whose path is further away don't hide it. + pub(crate) fn path_at(&self, doc: &Document, page: NodeId, ev: &PointerEvent) -> Option<(NodeId, f64)> { + let reach = ev.tol() * PATH_REACH_PX; + let near = |id: NodeId| -> Option<(NodeId, f64)> { + let n = doc.node(id)?; + let shape = matches!( + n.kind, + NodeKind::Rectangle + | NodeKind::Ellipse { .. } + | NodeKind::Polygon { .. } + | NodeKind::Star { .. } + | NodeKind::Line + | NodeKind::Vector { .. } + ); + if !shape || locked_in(doc, id) || crate::components::inside_instance(doc, id) { + return None; + } + let geom = figcraft_doc::geometry::stroke_geometry(n)?; + let line = figcraft_geom::measure::polylines(&geom)?.into_iter().next()?; + // Measured on the page (a layer scaled more one way than the other is still reached + // where it shows), the start by length along the layer's own path. + let world = doc.world_transform(id); + let p = ev.p(); + let mut best: Option<(f64, f64)> = None; + for (i, w) in line.points.windows(2).enumerate() { + let (a, b) = (world * w[0], world * w[1]); + let d = b - a; + let l2 = d.hypot2(); + let u = if l2 > 1e-18 { ((p - a).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 }; + let dist = (a.lerp(b, u) - p).hypot(); + if best.is_none_or(|(_, bd)| dist < bd) { + let (la, lb) = (*line.lengths.get(i)?, *line.lengths.get(i + 1)?); + best = Some((la + u * (lb - la), dist)); + } + } + let (along, dist) = best?; + let len = line.length(); + (len > 1e-9 && dist <= reach).then_some((id, along / len)) + }; + let mut candidates = Vec::new(); + path_candidates(doc, page, ev.p(), reach, 0, &mut candidates); + candidates.into_iter().find_map(near) + } + + /// A press with the Text on a path tool: on a path, a new empty text layer goes onto it, + /// starting where it was pressed, and typing starts. The text takes the path layer's place, + /// geometry, transform, constraints and effects, and its fills when one of them shows; the path + /// layer is removed. Ending the edit with nothing typed puts the path back without an undo step + /// ([`textedit::end`]). Elsewhere a press does nothing. + pub(super) fn text_on_path_down(&mut self, ev: &PointerEvent) -> Result<()> { + if let Some(te) = self.ds()?.text_edit.clone() { + if self.ds()?.doc.world_bounds(te.id).is_some_and(|b| b.contains(ev.p())) { + return self.text_click(te.id, ev, ev.shift); + } + textedit::end(self)?; + } + let ds = self.ds()?; + let (doc, page) = (ds.doc.clone(), ds.page); + let Some((path, start)) = self.path_at(&doc, page, ev) else { return Ok(()) }; + let Some(pn) = doc.node(path) else { return Ok(()) }; + let (Some(parent), Some(geom)) = (pn.parent, figcraft_doc::geometry::stroke_geometry(pn)) else { return Ok(()) }; + let index = doc.index_in_parent(path); + self.begin_interaction("Text on a path")?; + let ds = self.ds_mut()?; + let d = ds.doc_mut(); + let id = d.alloc_id(); + let mut n = Node::new(id, NodeKind::Text(TextData::default()), "Text", pn.width, pn.height); + n.name = String::new(); + n.transform = pn.transform; + n.constraints = pn.constraints; + n.child_layout = pn.child_layout.clone(); + let effects = !pn.effects.is_empty(); + if effects { + n.effects = pn.effects.clone(); + n.styles.effect = pn.styles.effect.clone(); + } + // A path drawn with a stroke only has no fill to give: the text gets the usual one. + let filled = pn.fills.iter().any(|f| f.visible); + if filled { + n.fills = pn.fills.clone(); + n.styles.fill = pn.styles.fill.clone(); + } else if let Some(fills) = readable_text_fills(d, parent) { + n.fills = fills; + } + // As `text.onPath`: a binding goes with the list it binds into (`fill`, `fills`, `fills/N/…`). + let carried = |k: &str| match crate::cmd::lists::list_binding(k) { + Some((crate::cmd::lists::BoundList::Fills, ..)) => filled, + Some((crate::cmd::lists::BoundList::Effects, ..)) => effects, + _ => false, + }; + n.bound = pn.bound.iter().filter(|(k, _)| carried(k)).map(|(k, v)| (k.clone(), v.clone())).collect(); + if let NodeKind::Text(t) = &mut n.kind { + let mut tp = TextPath::new(&geom); + tp.start = start; + t.path = Some(Box::new(tp)); + } + d.insert(n, parent, index)?; + d.remove(path)?; + ds.selection = vec![id]; + textedit::begin(self, id, Some(0), true)?; + if !self.prefs.keep_tool { + self.tool.tool = ToolId::Move; + } + Ok(()) + } + + /// Drag out a new text box: past the drag slop it has the drag's size (at least one line + /// tall) and its top-left, and that size is fixed. + pub(super) fn text_box_drag(&mut self, ev: &PointerEvent, id: NodeId, start: Point, line: f64) -> Result<()> { + if within_slop(ev, start) { + return Ok(()); + } + let r = Rect::from_points(start, ev.p()); + let d = self.ds_mut()?.doc_mut(); + let pw = d.node(id).and_then(|n| n.parent).map(|p| d.world_transform(p)).unwrap_or_default(); + let n = d.get_mut(id)?; + n.transform = pw.inverse() * Affine::translate((r.x0.round(), r.y0.round())); + n.width = r.width().round().max(1.0); + n.height = r.height().round().max(line); + n.child_layout.sizing_h = Sizing::Fixed; + n.child_layout.sizing_v = Sizing::Fixed; + if let NodeKind::Text(t) = &mut n.kind { + t.auto_resize = TextAutoResize::None; + } + self.ds_mut()?.revision += 1; + Ok(()) + } + + pub(super) fn eyedrop(&mut self, ev: &PointerEvent) -> Result<()> { + let ds = self.ds()?; + let doc = ds.doc.clone(); + let view = figcraft_render::View { origin: (ev.x, ev.y), scale: ev.zoom.max(0.01), width: 1, height: 1 }; + let Some(pix) = figcraft_render::render_view(&doc, ds.page, &view, &Default::default()) else { return Ok(()) }; + let rgba = figcraft_render::to_rgba8(&pix); + let c = figcraft_color::Color::from_rgba8( + rgba.first().copied().unwrap_or(0), + rgba.get(1).copied().unwrap_or(0), + rgba.get(2).copied().unwrap_or(0), + 255, + ); + let sel = ds.selection.clone(); + if self.tool.pick_only { + self.tool.picked = Some(c); + } else if !sel.is_empty() { + self.execute("node.set", &json!({"ids": sel, "props": {"fill": c.to_css_hex()}}))?; + } + self.recent_colors.retain(|x| *x != c); + self.recent_colors.insert(0, c); + self.recent_colors.truncate(16); + self.message = Some(format!("Picked #{}", c.to_hex())); + self.set_tool(ToolId::Move); + Ok(()) + } +} diff --git a/crates/engine/src/canvas/tools/drag.rs b/crates/engine/src/canvas/tools/drag.rs new file mode 100644 index 00000000..0d406673 --- /dev/null +++ b/crates/engine/src/canvas/tools/drag.rs @@ -0,0 +1,605 @@ +use super::handles::{ + SNAP_PX, handle_at, in_quad, label_at, locked_in, on_corner_square, paints_below, resized_box, scale_factor, selection_box, selection_frame, + shape_hint, +}; +use super::*; + +impl Session { + pub(super) fn move_down(&mut self, ev: &PointerEvent) -> Result<()> { + let ds = self.ds()?; + let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); + // Clicking outside the text being edited ends editing. + if let Some(te) = ds.text_edit.clone() { + let inside = doc.world_bounds(te.id).is_some_and(|b| b.inflate(ev.tol() * 4.0, ev.tol() * 4.0).contains(ev.p())); + if inside { + return self.text_click(te.id, ev, ev.shift); + } + textedit::end(self)?; + } + // Handles first: a selected connector's ends, a selected ellipse's arc handles (drawn over + // the box), then the box's. + let edits = !self.tool.dev_mode; + if edits && let Some((id, from)) = crate::board::end_handle_at(&doc, &sel, ev.p(), ev.zoom) { + self.tool.gesture = Gesture::Reattach { id, from, start: ev.p() }; + return Ok(()); + } + if self.arc_handles_shown() + && let Some((id, handle)) = crate::arc_handles::hit(&doc, &sel, ev.p(), ev.zoom) + && let Some(arc) = crate::arc_handles::arc_of(&doc, id) + { + self.begin_interaction("Arc")?; + self.tool.gesture = Gesture::Arc { id, handle, arc }; + self.tool.overlay.arc = + crate::arc_handles::handle_at(&doc, id, handle, arc, ev.zoom).map(|at| ArcHint { handle, label: arc.label(handle), at }); + return Ok(()); + } + if self.arc_handles_shown() + && !on_corner_square(&doc, &sel, ev.p(), ev.zoom) + && let Some((id, handle)) = crate::shape_handles::hit(&doc, &sel, ev.p(), ev.zoom) + { + self.begin_interaction("Corner radius")?; + self.tool.gesture = Gesture::Shape { id, handle }; + self.tool.overlay.shape = shape_hint(&doc, id, handle, ev.zoom); + return Ok(()); + } + if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom).filter(|_| edits) { + let ids = roots(&doc, &sel); + if let Handle::Rotate(_) = h { + let Some(b) = selection_box(&doc, &ids) else { return Ok(()) }; + let center = self + .ds()? + .moved_origin() + .unwrap_or_else(|| Point::new(b.iter().map(|c| c.x).sum::() / 4.0, b.iter().map(|c| c.y).sum::() / 4.0)); + self.begin_interaction("Rotate")?; + self.tool.gesture = Gesture::Rotate { center, start_angle: (ev.p() - center).atan2(), ids }; + return Ok(()); + } + let nodes: Vec<(NodeId, Affine, f64, f64)> = + ids.iter().filter_map(|i| doc.node(*i).map(|n| (*i, doc.world_transform(*i), n.width, n.height))).collect(); + let Some(frame) = selection_frame(&doc, &ids) else { return Ok(()) }; + let single = ids.len() == 1; + self.begin_interaction(if self.tool.tool == ToolId::Scale { "Scale" } else { "Resize" })?; + self.tool.gesture = Gesture::Resize { + start: ev.p(), + r: Box::new(ResizeStart { nodes, frame, single, handle: h }), + scale_tool: self.tool.tool == ToolId::Scale, + moved: false, + }; + return Ok(()); + } + // A frame's name label picks the frame itself. The background of a top-level frame with + // layers in it is like the canvas (a press starts a marquee inside it) unless the frame is + // selected; an empty frame (or a widget, or a board object such as a sticky) is picked and + // dragged. + let board_background = |leaf: NodeId| { + !sel.contains(&leaf) + && doc.node(leaf).is_some_and(|n| { + n.parent == Some(page) && n.kind.is_frame_like() && !n.children.is_empty() && !crate::widgets::is_widget(n) && !n.is_board_atom() + }) + }; + let label = label_at(&doc, page, &self.tool.labels, ev.p()); + let target = label.or_else(|| { + self.hit(&doc, page, ev.p(), ev.tol() * 2.0) + .filter(|leaf| ev.cmd || !board_background(*leaf)) + .map(|leaf| select_target(&doc, leaf, &sel, ev.cmd)) + }); + // A press inside the selection's box that reaches nothing above it (an unfilled shape's + // inside, the frame or canvas behind it, or a layer stacked below the selected layer the + // press is in) drags the selection. In a gap between selected layers, what's there is hit. + let in_own_box = |s: NodeId| selection_box(&doc, &[s]).is_some_and(|q| in_quad(&q, ev.p())); + let behind_sel = |t: Option| { + t.is_none_or(|t| sel.iter().any(|s| *s != t && (doc.is_ancestor(t, *s) || (in_own_box(*s) && paints_below(&doc, t, *s))))) + }; + let target = match sel.iter().copied().find(|s| !locked_in(&doc, *s)) { + Some(first) if label.is_none() && !ev.shift && behind_sel(target) && selection_box(&doc, &sel).is_some_and(|q| in_quad(&q, ev.p())) => { + Some(first) + } + _ => target, + }; + match target { + Some(target) => { + // Clicking inside the current selection's box keeps it (drag them all). + let in_sel_box = sel.len() > 1 && doc.bounds_of(&sel).is_some_and(|b| b.contains(ev.p())) && !ev.shift; + // Shift on a selected layer takes it out on release if the press doesn't become a + // drag; a drag moves the selection with it. Where no drag can start, it goes now. + let deselect = (ev.shift && sel.contains(&target)).then_some(target); + let without_target = || sel.iter().copied().filter(|x| *x != target).collect::>(); + let new_sel = if in_sel_box && (sel.contains(&target) || sel.iter().any(|s| doc.is_ancestor(*s, target))) { + sel.clone() + } else if ev.shift && deselect.is_none() { + let mut s2 = sel.clone(); + s2.push(target); + s2 + } else if sel.contains(&target) { + sel.clone() + } else { + vec![target] + }; + self.set_selection(new_sel.clone()); + // Dev Mode only selects (Shift-click still deselects): nothing moves. + if !edits { + if deselect.is_some() { + self.set_selection(without_target()); + } + return Ok(()); + } + // Locked layers selected in the layers panel stay put. + let ids: Vec = roots(&doc, &new_sel).into_iter().filter(|i| !locked_in(&doc, *i)).collect(); + if new_sel.is_empty() || doc.node(target).is_some_and(|n| n.locked) || ids.is_empty() { + if deselect.is_some() { + self.set_selection(without_target()); + } + return Ok(()); + } + let mut targets = Targets::gather(&doc, page, &ids); + // A copy dragged out with Alt snaps to the layers it came from too (they stay put). + if ev.alt + && let Some(b) = targets.moving + { + targets.add_box(b); + } + if let Err(e) = self.begin_interaction(if ev.alt { "Duplicate" } else { "Move" }) { + if deselect.is_some() { + self.set_selection(without_target()); + } + return Err(e); + } + self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false, deselect }; + self.tool.overlay.cursor = if ev.alt { Cursor::Copy } else { Cursor::Default }; + } + None => { + let within = self.marquee_within(ev.p())?; + if !ev.shift { + self.set_selection(vec![]); + } + self.tool.gesture = Gesture::Marquee { start: ev.p(), additive: ev.shift, base: if ev.shift { sel } else { vec![] }, within }; + } + } + Ok(()) + } + + pub(super) fn drag(&mut self, ev: &PointerEvent) -> Result<()> { + let g = std::mem::take(&mut self.tool.gesture); + let res = match &g { + Gesture::None => return self.hover(ev), + Gesture::Marquee { start, additive, base, within } => { + let r = Rect::from_points(*start, ev.p()); + self.tool.overlay.marquee = Some([r.x0, r.y0, r.x1, r.y1]); + // Cmd/Ctrl: the nested layers it touches. + let sel = self.marquee_selection(r, *within, ev.cmd, base, *additive)?; + self.set_selection(sel); + Ok(()) + } + Gesture::Move { start, moved: false, .. } | Gesture::Resize { start, moved: false, .. } if within_slop(ev, *start) => Ok(()), + Gesture::Move { start, ids, targets, duplicate, .. } => { + let mut d = ev.p() - *start; + if ev.shift { + if d.x.abs() > d.y.abs() { + d.y = 0.0; + } else { + d.x = 0.0; + } + } + // Cmd/Ctrl (and Control on macOS) held while dragging suspends snapping. + let snap_on = self.ds().map(|ds| ds.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl; + let mut guides = Vec::new(); + if snap_on { + let (c, g2) = targets.snap_move(d, SNAP_PX / ev.zoom.max(1e-6)); + d += c; + guides = g2; + } + if self.ds().map(|ds| ds.view.snap_pixel).unwrap_or(true) { + // Keep the box's origin on whole pixels (a rotated parent frame's, inside one). + d = targets.round_move(d); + } + let ids = ids.clone(); + let dup = *duplicate; + let page = self.page()?; + let mut drop_cell = None; + let mut drop_target = None; + let mut in_flow = false; + // Placed in a row or column: (is it a column, its gap) for the insertion line. + let mut slot_axis: Option<(bool, f64)> = None; + self.preview(|doc, sel| { + let mut moving = ids.clone(); + if dup { + let mut copies = Vec::new(); + for i in &ids { + let parent = doc.get(*i)?.parent.unwrap_or(page); + let idx = doc.index_in_parent(*i).map(|x| x + 1); + copies.push(crate::components::copy_in_file(doc, *i, parent, idx)?); + } + // Connectors copied with what they join join the copies. + let map = crate::board::copy_map(doc, &ids, &copies); + crate::board::rejoin(doc, &crate::board::copied_items(doc, &map), &map, false); + moving = copies; + *sel = moving.clone(); + } + let p = ev.p(); + let home = moving.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent); + // Absolute children of an auto layout frame stay in it, and so do the layers of + // groups, boolean groups and instances ([`crate::drop::keeps_layers`]); the + // rest may change parent. + let flow = moving.iter().all(|i| doc.node(*i).is_some_and(|n| !n.child_layout.absolute)) + && !home.is_some_and(|h| crate::drop::keeps_layers(doc, h)); + let target = if flow { crate::drop::drag_parent(doc, page, p, &moving) } else { home.unwrap_or(page) }; + let mode = doc.node(target).and_then(|n| n.layout.as_ref()).map(|l| l.mode); + if let (true, Some(mode)) = (flow, mode) { + // Into (or within) an auto layout frame: the layers take the slot (a grid: the + // cell) under the pointer, as the frame shows it now. A frame other than the + // one they come from is outlined too. + let cell = if mode == LayoutMode::Grid { crate::drop::grid_cell_at(doc, target, p) } else { None }; + let idx = + if mode == LayoutMode::Grid { 0 } else { crate::drop::auto_layout_slot(doc, target, p, &moving).map_or(0, |(i, _)| i) }; + for m in &moving { + if doc.get(*m)?.parent != Some(target) { + doc.reparent(*m, target, None)?; + } + } + if let (Some(first), Some((col, row, rect))) = (moving.first().copied(), cell) { + let cl = &mut doc.get_mut(first)?.child_layout; + cl.grid_column = Some(col); + cl.grid_row = Some(row); + drop_cell = Some(rect); + } else if mode != LayoutMode::Grid { + let mut kids: Vec = doc.get(target)?.children.iter().copied().filter(|c| !moving.contains(c)).collect(); + for (k, m) in moving.iter().enumerate() { + kids.insert((idx + k).min(kids.len()), *m); + } + let gap = doc.node(target).and_then(|n| n.layout.as_ref()).map_or(0.0, |l| l.gap); + doc.get_mut(target)?.children = kids; + slot_axis = Some((mode == LayoutMode::Vertical, gap)); + } + drop_target = (Some(target) != home).then_some(target); + in_flow = true; + return Ok(()); + } + // A free move. Layers dragged out of an auto layout flow leave it (onto the page + // while dragging; the drop puts them in the frame under the pointer). + for m in &moving { + let in_al = doc.node(*m).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()); + if flow && in_al { + doc.reparent(*m, page, None)?; + } + move_world(doc, *m, d.x, d.y)?; + } + drop_target = (flow && target != page).then_some(target); + Ok(()) + })?; + let placed = if dup { self.selection() } else { ids }; + // Snapping guides mean nothing while the layout places the layers; with Alt held, + // live distances to the neighbours replace them. + self.tool.overlay.guides = if in_flow { + Vec::new() + } else if ev.alt { + let doc = self.doc()?; + doc.bounds_of(&placed).map(|b| crate::distance::neighbours(doc, page, &placed, b)).unwrap_or_default() + } else { + guides + }; + // The preview already shows the layers in their slot: the line marks it there, in + // the middle of the gap before them (clear of the selection box). + self.tool.overlay.insert_line = slot_axis.and_then(|(vertical, gap)| { + let off = if gap.is_finite() { (gap / 2.0).clamp(3.0, 24.0) } else { 3.0 }; + self.doc().ok()?.bounds_of(&placed).map(|b| crate::drop::line_before(b, vertical, off)) + }); + self.tool.overlay.drop_cell = drop_cell; + self.tool.overlay.drop_target = drop_target; + self.tool.overlay.cursor = if self.refused(&placed, true) { + Cursor::NotAllowed + } else if dup { + Cursor::Copy + } else { + Cursor::Default + }; + self.tool.gesture = g.clone(); + if let Gesture::Move { moved, .. } = &mut self.tool.gesture { + *moved = true; + } + return Ok(()); + } + Gesture::Resize { start, r, scale_tool, .. } => { + let res = self.resize_drag(ev, *start, r, *scale_tool); + self.tool.gesture = Gesture::Resize { start: *start, r: r.clone(), scale_tool: *scale_tool, moved: true }; + if self.refused(&self.selection(), false) { + self.tool.overlay.cursor = Cursor::NotAllowed; + } + return res; + } + Gesture::Rotate { center, start_angle, ids } => { + let mut a = ((ev.p() - *center).atan2() - start_angle).to_degrees(); + if ev.shift { + a = (a / 15.0).round() * 15.0; + } + let (c, ids) = (*center, ids.clone()); + self.preview(|doc, _| { + for i in &ids { + crate::cmd::arrange::rotate_world(doc, *i, c, -a)?; + } + Ok(()) + })?; + // The cursor turns with the pointer about the centre. + self.tool.overlay.cursor = + if self.refused(&ids, false) { Cursor::NotAllowed } else { Cursor::Rotate((ev.p() - c).atan2().to_degrees()) }; + Ok(()) + } + Gesture::Draw { start, id, parent, targets } => { + let (start, id, parent, targets) = (*start, *id, *parent, targets.clone()); + self.tool.gesture = g; + return self.draw_drag(ev, start, id, parent, &targets); + } + Gesture::TextBox { id, start, line } => { + let (id, start, line) = (*id, *start, *line); + self.tool.gesture = g; + return self.text_box_drag(ev, id, start, line); + } + Gesture::Arc { id, handle, arc } => { + let (id, handle, arc) = (*id, *handle, *arc); + self.tool.gesture = g; + return self.arc_drag(ev, id, handle, arc); + } + Gesture::Shape { id, handle } => { + let (id, handle) = (*id, *handle); + let doc = self.doc()?.clone(); + if let Some(props) = crate::shape_handles::drag(&doc, id, handle, ev.p(), ev.alt) { + self.preview(|doc, _| crate::cmd::props::apply("node.set", doc, id, &props))?; + } + self.tool.overlay.shape = shape_hint(self.doc()?, id, handle, ev.zoom); + self.tool.overlay.shape_hover = Some(handle); + Ok(()) + } + Gesture::Pan { sx, sy, cx, cy } => { + let ds = self.ds_mut()?; + let z = ds.view.zoom.max(1e-6); + ds.view.cx = cx - (ev.sx - sx) / z; + ds.view.cy = cy - (ev.sy - sy) / z; + ds.revision += 1; + Ok(()) + } + Gesture::Pencil { points, pressure } => { + let (mut pts, mut pressure) = (points.clone(), pressure.clone()); + if pts.last().is_none_or(|l| (*l - ev.p()).hypot() > 0.5 / ev.zoom.max(1e-6)) { + pts.push(ev.p()); + pressure.push(ev.pressure); + } + self.tool.overlay.pen = pts.iter().map(|p| [p.x, p.y]).collect(); + self.tool.gesture = Gesture::Pencil { points: pts, pressure }; + if let Ok(ds) = self.ds_mut() { + ds.revision += 1; + } + return Ok(()); + } + Gesture::TextSelect { id } => { + let id = *id; + let idx = self.text_index_at(id, ev.p())?; + textedit::set_caret(self, idx, true)?; + Ok(()) + } + Gesture::Vector(_) | Gesture::ShapeBuild { .. } | Gesture::Origin => Ok(()), + Gesture::Connect { from, start } => { + let (from, start) = (*from, *start); + self.connect_drag(ev, from, start); + return Ok(()); + } + Gesture::Erase { gone } => { + self.tool.gesture = Gesture::Erase { gone: gone.clone() }; + return self.erase_at(ev); + } + Gesture::Reattach { id, from, start } => { + let (id, from, start) = (*id, *from, *start); + self.reattach_drag(ev, id, from, start); + return Ok(()); + } + Gesture::PenDrag { anchor } => { + // A click's wobble pulls out no handle. + let out = if within_slop(ev, *anchor) { Vec2::ZERO } else { ev.p() - *anchor }; + if let Some(pen) = self.tool.pen.as_mut() + && let Some(last) = pen.points.last_mut() + { + last.2 = out; + last.1 = -out; + } + self.pen_update()?; + Ok(()) + } + }; + self.tool.gesture = g; + res + } + + /// An arc handle drag: the new arc, written through `node.set`'s keys. + fn arc_drag(&mut self, ev: &PointerEvent, id: NodeId, handle: ArcHandle, arc: crate::arc_handles::Arc) -> Result<()> { + let doc = self.doc()?.clone(); + let Some(next) = crate::arc_handles::drag(&doc, id, handle, arc, ev.p(), ev.shift, ev.zoom) else { return Ok(()) }; + let props = crate::arc_handles::props(next); + self.preview(|doc, _| crate::cmd::props::apply("node.set", doc, id, &props))?; + self.tool.gesture = Gesture::Arc { id, handle, arc: next }; + let at = crate::arc_handles::handle_at(self.doc()?, id, handle, next, ev.zoom); + self.tool.overlay.arc = at.map(|at| ArcHint { handle, label: next.label(handle), at }); + self.tool.overlay.arc_hover = Some(handle); + Ok(()) + } + + fn resize_drag(&mut self, ev: &PointerEvent, start: Point, r: &ResizeStart, scale_tool: bool) -> Result<()> { + let page = self.page()?; + let snap = self.ds().map(|d| d.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl; + let ids: Vec = r.nodes.iter().map(|n| n.0).collect(); + let targets = if snap { Some(Targets::gather(self.doc()?, page, &ids)) } else { None }; + let mut p = ev.p(); + let mut guides = Vec::new(); + if let Some(t) = &targets { + let (dx, dy, g) = t.snap_point(p.x, p.y, SNAP_PX / ev.zoom.max(1e-6)); + p.x += dx; + p.y += dy; + guides = g; + } + let _ = start; + // In the handle box's own axes (one layer: its local space). + let (frame, w0, h0) = r.frame; + // The pixel grid: an upright box's dragged sides land on whole pixels (the Scale tool + // scales by the factor as dragged). + if !scale_tool && self.ds().map(|d| d.view.snap_pixel).unwrap_or(true) && crate::guides::axis_aligned(frame) { + p = Point::new(p.x.round(), p.y.round()); + } + let q = frame.inverse() * p; + let is_line = r.single && ids.first().and_then(|i| self.doc().ok()?.node(*i).map(|n| matches!(n.kind, NodeKind::Line))).unwrap_or(false); + if scale_tool { + return self.scale_drag(ev, r, q, guides); + } + if is_line { + // Line end handles: move the end point freely (length + angle). + let (a, b) = match r.handle { + Handle::Corner(0) => (p, frame * Point::new(w0, 0.0)), + _ => (frame * Point::new(0.0, 0.0), p), + }; + let id = ids.first().copied().unwrap_or(NodeId(0)); + self.preview(|doc, _| { + let mut v = b - a; + if ev.shift { + let ang = (v.atan2() / (std::f64::consts::PI / 4.0)).round() * std::f64::consts::PI / 4.0; + v = Vec2::from_angle(ang) * v.hypot(); + } + let len = v.hypot(); + let world = Affine::translate(a.to_vec2()) * Affine::rotate(v.atan2()); + doc.set_world_transform(id, world)?; + figcraft_layout::set_size(doc, id, len, 0.0); + Ok(()) + })?; + self.tool.overlay.guides = guides; + return Ok(()); + } + // Constrain proportions (Shift, or the node's lock). + let keep = ev.shift || r.single && ids.first().and_then(|i| self.doc().ok()?.node(*i).map(|n| n.constrain_proportions)).unwrap_or(false); + let (x0, y0, x1, y1) = resized_box(r.handle, q, w0, h0, keep, ev.alt); + let (flip_x, flip_y) = (x1 < x0, y1 < y0); + let (nx0, ny0) = (x0.min(x1), y0.min(y1)); + let (nw, nh) = ((x1 - x0).abs(), (y1 - y0).abs()); + let nodes = r.nodes.clone(); + let single = r.single; + let handle = r.handle; + // What's in a section stays where it is on the page, and so does what's in a frame resized + // with Cmd/Ctrl held (its constraints are ignored). + let keeps = |doc: &Document, id: NodeId| { + doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }) || (ev.cmd && single && n.kind.is_frame_like())) + }; + self.preview(|doc, _| { + if single { + let Some((id, world, _, _)) = nodes.first() else { return Ok(()) }; + let kept = if keeps(doc, *id) { crate::cmd::props::children_worlds(doc, *id) } else { Vec::new() }; + let mut t = *world * Affine::translate((nx0, ny0)); + if flip_x { + t *= Affine::new([-1.0, 0.0, 0.0, 1.0, nw, 0.0]); + } + if flip_y { + t *= Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, nh]); + } + // Text reflow and auto layout can recompute this box after the preview (a size bound + // to a variable lets go of it: the resize is an explicit edit). + let n = doc.get(*id)?; + let crop_images = ev.cmd + && !matches!(n.kind, NodeKind::Text(_)) + && !std::iter::once(*id).chain(doc.ancestors(*id)).any(|a| doc.node(a).is_some_and(|n| n.layout.is_some())); + if crop_images { + // Crop the layer's image fills without moving their pixels on the page. + // Work from the interaction snapshot, just like the geometry on every move. + let (w, h) = (n.width.max(1e-6), n.height.max(1e-6)); + let to_old = + Affine::scale_non_uniform(1.0 / w, 1.0 / h) * world.inverse() * t * Affine::scale_non_uniform(nw.max(0.01), nh.max(0.01)); + let mut fills = n.fills.clone(); + for paint in &mut fills { + let PaintKind::Image(img) = &mut paint.kind else { continue }; + // A repeating image cannot be represented by a single Crop placement. + if img.scale_mode == ImageScaleMode::Tile { + continue; + } + let Some(data) = doc.images.get(&img.image) else { continue }; + let (iw, ih) = (f64::from(data.width), f64::from(data.height)); + let crop = if img.scale_mode == ImageScaleMode::Crop { + img.effective_crop(iw, ih, w, h) + } else { + let (rw, rh) = img.rotated_size(iw.max(1.0), ih.max(1.0)); + let s = if img.scale_mode == ImageScaleMode::Fit { (w / rw).min(h / rh) } else { (w / rw).max(h / rh) }; + [w / (rw * s), 0.0, 0.0, h / (rh * s), (rw * s - w) / (2.0 * rw * s), (rh * s - h) / (2.0 * rh * s)] + }; + // A degenerate (zero-scale) layer has no inverse: its image keeps its placement. + let crop = (Affine::new(crop) * to_old).as_coeffs(); + if !crop.iter().all(|c| c.is_finite()) { + continue; + } + img.crop = Some(crop); + img.scale_mode = ImageScaleMode::Crop; + } + doc.get_mut(*id)?.fills = fills; + } + doc.set_world_transform(*id, t)?; + if kept.is_empty() { + crate::cmd::props::resize_node(doc, *id, nw.max(0.01), nh.max(0.01))?; + } else { + figcraft_layout::set_size(doc, *id, nw.max(0.01), nh.max(0.01)); + crate::cmd::props::restore_worlds(doc, &kept)?; + } + let n = doc.get_mut(*id)?; + if matches!(handle, Handle::Corner(_) | Handle::Edge(1) | Handle::Edge(3)) { + n.child_layout.sizing_h = Sizing::Fixed; + } + if matches!(handle, Handle::Corner(_) | Handle::Edge(0) | Handle::Edge(2)) { + n.child_layout.sizing_v = Sizing::Fixed; + } + if let NodeKind::Text(t) = &mut n.kind { + match handle { + Handle::Edge(1) | Handle::Edge(3) if t.auto_resize == TextAutoResize::Truncate => {} + Handle::Edge(1) | Handle::Edge(3) => t.auto_resize = TextAutoResize::Height, + _ => t.fix_size(true, true), + } + } + return Ok(()); + } + // Several layers: map the old box onto the new one, in the box's axes. Each layer's + // centre follows the box (mirrored when it flips); each keeps its rotation and scales + // along its own axes (see `axis_factors`), turning over with the box. + let (sx, sy) = (if w0 > 1e-9 { (x1 - x0) / w0 } else { 1.0 }, if h0 > 1e-9 { (y1 - y0) / h0 } else { 1.0 }); + let mirror = Affine::new([sx.signum(), 0.0, 0.0, sy.signum(), 0.0, 0.0]); + let inv = frame.inverse(); + for (id, world, w, h) in &nodes { + let local = inv * *world; + let center = local * Point::new(w / 2.0, h / 2.0); + let (kw, kh) = figcraft_layout::axis_factors(local, sx, sy); + let (w2, h2) = (w * kw, h * kh); + let lin = (mirror * local).with_translation(Vec2::ZERO); + let origin = Point::new(x0 + center.x * sx, y0 + center.y * sy) - lin * Point::new(w2 / 2.0, h2 / 2.0); + let kept = if keeps(doc, *id) { crate::cmd::props::children_worlds(doc, *id) } else { Vec::new() }; + doc.set_world_transform(*id, frame * lin.with_translation(origin))?; + crate::cmd::props::resize_node(doc, *id, w2, h2)?; + crate::cmd::props::restore_worlds(doc, &kept)?; + } + Ok(()) + })?; + self.tool.overlay.guides = guides; + Ok(()) + } + + /// Scale tool handle drag (`q`: the snapped pointer in the handle box's axes): everything + /// scales by one factor about the anchor point (Alt: the centre), strokes, effects, radii and + /// text included, children by the same factor whatever their constraints. + fn scale_drag(&mut self, ev: &PointerEvent, r: &ResizeStart, q: Point, guides: Vec) -> Result<()> { + let (frame, w0, h0) = r.frame; + let Some((k, pivot)) = scale_factor(r.handle, q, w0, h0, self.tool.scale_anchor, ev.alt) else { return Ok(()) }; + let nodes = r.nodes.clone(); + let inv = frame.inverse(); + // A layer's place in the box scales about the pivot; a negative factor turns it over. + let about = frame * Affine::translate(pivot.to_vec2() * (1.0 - k)); + let sign = k.signum(); + self.preview(|doc, _| { + for (id, world, _, _) in &nodes { + let c = (inv * *world).as_coeffs(); + doc.set_world_transform(*id, about * Affine::new([sign * c[0], sign * c[1], sign * c[2], sign * c[3], k * c[4], k * c[5]]))?; + crate::cmd::arrange::scale_node(doc, *id, k.abs())?; + } + Ok(()) + })?; + let p = frame * pivot; + self.tool.overlay.scale = Some(ScaleHint { factor: k, pivot: [p.x, p.y] }); + self.tool.overlay.guides = guides; + Ok(()) + } +} diff --git a/crates/engine/src/canvas/tools/draw.rs b/crates/engine/src/canvas/tools/draw.rs new file mode 100644 index 00000000..b594ae27 --- /dev/null +++ b/crates/engine/src/canvas/tools/draw.rs @@ -0,0 +1,229 @@ +use super::handles::SNAP_PX; +use super::pen::take_enclosed; +use super::*; + +impl Session { + pub(super) fn draw_down(&mut self, ev: &PointerEvent) -> Result<()> { + let ds = self.ds()?; + let doc = ds.doc.clone(); + let page = ds.page; + if ds.text_edit.is_some() { + textedit::end(self)?; + } + // Frames drawn inside frames nest; sections only at the top or in sections. + let parent = match self.tool.tool { + ToolId::Section => { + frame_at(&doc, page, ev.p(), &[]).filter(|f| doc.node(*f).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }))).unwrap_or(page) + } + _ => frame_at(&doc, page, ev.p(), &[]).unwrap_or(page), + }; + let targets = Targets::gather(&doc, page, &[]); + self.begin_interaction(self.tool.tool.label())?; + self.tool.gesture = Gesture::Draw { start: ev.p(), id: None, parent, targets }; + Ok(()) + } + + pub(super) fn draw_drag(&mut self, ev: &PointerEvent, start: Point, id: Option, parent: NodeId, targets: &Targets) -> Result<()> { + let tool = self.tool.tool; + let mut p = ev.p(); + let mut guides = Vec::new(); + if self.ds().map(|d| d.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl { + let (dx, dy, g) = targets.snap_point(p.x, p.y, SNAP_PX / ev.zoom.max(1e-6)); + p.x += dx; + p.y += dy; + guides = g; + } + let pixel = self.ds().map(|d| d.view.snap_pixel).unwrap_or(true); + let mut created = id; + let page = self.page()?; + self.preview(|doc, sel| { + let (mut a, mut b) = (start, p); + if pixel { + a = Point::new(a.x.round(), a.y.round()); + b = Point::new(b.x.round(), b.y.round()); + } + let pw = doc.world_transform(parent); + let (kind, base) = match tool { + ToolId::Frame => (NodeKind::Frame, "Frame"), + ToolId::Section => (NodeKind::Section { contents_hidden: false }, "Section"), + ToolId::Slice => (NodeKind::Slice, "Slice"), + ToolId::Rectangle => (NodeKind::Rectangle, "Rectangle"), + ToolId::Ellipse => (NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "Ellipse"), + ToolId::Polygon => (NodeKind::Polygon { count: 3 }, "Polygon"), + ToolId::Star => (NodeKind::Star { count: 5, inner_ratio: 0.38 }, "Star"), + ToolId::Line => (NodeKind::Line, "Line"), + ToolId::Arrow => (NodeKind::Line, "Arrow"), + _ => return Ok(()), + }; + let name = match tool { + ToolId::Frame | ToolId::Section | ToolId::Slice => doc.next_name(base), + _ => base.to_string(), + }; + let nid = doc.alloc_id(); + let mut n = if tool == ToolId::Section { Node::new_section(nid, name, 0.0, 0.0) } else { Node::new(nid, kind, name, 0.0, 0.0) }; + if tool == ToolId::Arrow { + n.stroke.cap_end = StrokeCap::LineArrow; + } + if matches!(tool, ToolId::Line | ToolId::Arrow) { + let mut v = b - a; + if ev.shift { + let ang = (v.atan2() / (std::f64::consts::PI / 4.0)).round() * std::f64::consts::PI / 4.0; + v = Vec2::from_angle(ang) * v.hypot(); + } + // Alt draws from the centre, as for the other shapes. + let (a, v) = if ev.alt { (a - v, v * 2.0) } else { (a, v) }; + n.width = v.hypot(); + n.transform = pw.inverse() * Affine::translate(a.to_vec2()) * Affine::rotate(v.atan2()); + } else { + let mut d = b - a; + if ev.shift { + let m = d.x.abs().max(d.y.abs()); + d = Vec2::new(m * d.x.signum(), m * d.y.signum()); + } + let (x0, y0, x1, y1) = if ev.alt { (a.x - d.x, a.y - d.y, a.x + d.x, a.y + d.y) } else { (a.x, a.y, a.x + d.x, a.y + d.y) }; + let r = Rect::new(x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)); + n.width = r.width(); + n.height = r.height(); + n.transform = pw.inverse() * Affine::translate((r.x0, r.y0)); + } + doc.insert(n, parent, None)?; + *sel = vec![nid]; + created = Some(nid); + let _ = page; + Ok(()) + })?; + self.tool.overlay.guides = guides; + self.tool.gesture = Gesture::Draw { start, id: created, parent, targets: targets.clone() }; + Ok(()) + } + + pub(super) fn up(&mut self, ev: &PointerEvent) -> Result<()> { + let g = std::mem::take(&mut self.tool.gesture); + self.tool.overlay.marquee = None; + self.tool.overlay.guides.clear(); + self.tool.overlay.scale = None; + self.tool.overlay.arc = None; + self.tool.overlay.shape = None; + self.tool.overlay.measure_box = None; + self.tool.overlay.insert_line = None; + self.tool.overlay.drop_cell = None; + let drop_target = self.tool.overlay.drop_target.take(); + match g { + Gesture::Move { ids, moved, duplicate, start, deselect, .. } => { + if moved { + // A free move lands in the frame under the pointer (or the page) where it was + // dropped; layers in an auto layout frame already sit in their slot. + let page = self.page()?; + let sel = self.selection(); + let sources = if duplicate { ids.clone() } else { Vec::new() }; + let moving = if duplicate { sel } else { ids }; + let doc = self.doc()?.clone(); + let in_al = + moving.iter().any(|m| doc.node(*m).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some())); + let target = drop_target.unwrap_or(page); + let current = moving.first().and_then(|m| doc.node(*m)).and_then(|n| n.parent); + if !in_al && current != Some(target) { + let ds = self.ds_mut()?; + let before = ds.doc.clone(); + let d = ds.doc_mut(); + for m in &moving { + // Group, boolean group and instance layers stay put: they only move. + let from = d.node(*m).and_then(|n| n.parent); + if from.is_some_and(|f| crate::drop::keeps_layers(d, f)) || crate::components::structure_locked(d, target) { + continue; + } + // A layer can't go into itself; it stays where it is. + let _ = d.reparent(*m, target, None); + } + crate::normalize(ds.doc_mut(), &before); + } + // Reordering or dragging layers out of an instance (outside its slots), or + // moving layers inside one, are edits only its main can make: the gesture is + // undone. + if self.refused(&moving, true) { + self.cancel_interaction()?; + } else { + self.commit_interaction()?; + // Duplicate repeats a free Alt-drag's step (an auto layout places its own). + if duplicate && !in_al && moving.len() == sources.len() { + self.ds_mut()?.last_duplicate = Some(crate::cmd::edit::Duplicated { sources, copies: moving }); + } + } + } else { + self.cancel_interaction()?; + if let Some(d) = deselect { + let sel = self.selection().into_iter().filter(|x| *x != d).collect(); + self.set_selection(sel); + } + if deselect.is_none() && !(ev.shift || ev.cmd || duplicate) { + self.widget_click(start, ev.tol() * 2.0)?; + } + } + } + Gesture::Resize { .. } | Gesture::Rotate { .. } | Gesture::Arc { .. } | Gesture::Shape { .. } => { + // Layers inside an instance keep their main's size and rotation. + if self.refused(&self.selection(), false) { + self.cancel_interaction()?; + } else { + self.commit_interaction()?; + } + } + Gesture::Draw { start, id, parent, .. } => { + let tiny = (ev.p() - start).hypot() * ev.zoom < 3.0; + if tiny || id.is_none() { + // Click without drag: Figma makes a 100×100 shape (frames 100×100 too). + self.cancel_interaction()?; + self.begin_interaction(self.tool.tool.label())?; + let (w, h) = self.tool.tool.click_size(); + let target = start + Vec2::new(w, h); + let mut fake = ev.clone(); + fake.x = target.x; + fake.y = target.y; + fake.shift = false; + fake.alt = false; + self.tool.gesture = Gesture::None; + let targets = Targets::default(); + self.draw_drag(&fake, start, None, parent, &targets)?; + self.tool.gesture = Gesture::None; + } + if matches!(self.tool.tool, ToolId::Frame | ToolId::Section) + && let Some(drawn) = self.selection().first().copied() + { + let ds = self.ds_mut()?; + let before = ds.doc.clone(); + take_enclosed(ds.doc_mut(), drawn)?; + crate::normalize(ds.doc_mut(), &before); + } + self.commit_interaction()?; + if !self.prefs.keep_tool { + self.set_tool(ToolId::Move); + } + } + Gesture::Pan { .. } => { + self.tool.overlay.cursor = Cursor::Grab; + if let Some(t) = self.tool.temp_from.take() { + self.tool.tool = t; + } + } + Gesture::Pencil { mut points, mut pressure } => { + self.tool.overlay.pen.clear(); + // The stroke ends where the button came up, even without a move to get there. + if points.last().is_none_or(|l| (*l - ev.p()).hypot() > 0.5 / ev.zoom.max(1e-6)) { + points.push(ev.p()); + pressure.push(ev.pressure); + } + self.pencil_finish(&points, &pressure)?; + } + Gesture::Connect { from, start } => self.connect_up(ev, from, start)?, + Gesture::Erase { .. } => self.erase_up()?, + Gesture::Reattach { id, from, start } => self.reattach_up(ev, id, from, start)?, + Gesture::TextSelect { .. } | Gesture::TextBox { .. } | Gesture::Marquee { .. } | Gesture::None => {} + Gesture::PenDrag { .. } | Gesture::ShapeBuild { .. } | Gesture::Origin => {} + Gesture::Vector(_) => self.commit_interaction()?, + } + if let Ok(ds) = self.ds_mut() { + ds.revision += 1; + } + Ok(()) + } +} diff --git a/crates/engine/src/canvas/tools/handles.rs b/crates/engine/src/canvas/tools/handles.rs new file mode 100644 index 00000000..fd765f66 --- /dev/null +++ b/crates/engine/src/canvas/tools/handles.rs @@ -0,0 +1,426 @@ +use super::*; + +/// The frame whose name label (see [`ToolState::labels`]) is under `p` on `page`: the topmost +/// one drawn, if it can be selected (still there, on the page, visible and unlocked). +pub fn label_at(doc: &Document, page: NodeId, labels: &[(NodeId, [Point; 4])], p: Point) -> Option { + labels + .iter() + .rev() + .find(|(id, q)| in_quad(q, p) && doc.page_of(*id) == Some(page) && doc.node(*id).is_some_and(|n| n.visible && !n.locked)) + .map(|(id, _)| *id) +} + +/// Whether `p` is inside the convex quadrilateral `q` (either winding; edges included). +pub(super) fn in_quad(q: &[Point; 4], p: Point) -> bool { + let (mut left, mut right) = (false, false); + for (a, b) in q.iter().zip(q.iter().cycle().skip(1)) { + let side = (*b - *a).cross(p - *a); + if !side.is_finite() { + return false; + } + left |= side < 0.0; + right |= side > 0.0; + } + // On one side of every edge (a point can't be on all four edges of a quad with an area). + left != right +} + +/// An anchor name (`tl`, `t`, `tr`, `l`, `c`, `r`, `bl`, `b`, `br`) as column and row (0 start, +/// 1 middle, 2 end). +pub fn anchor_of(name: &str) -> Option<(u8, u8)> { + ANCHORS.iter().position(|a| a.eq_ignore_ascii_case(name.trim())).map(|i| ((i % 3) as u8, (i / 3) as u8)) +} + +/// The name of anchor `(column, row)`. +pub fn anchor_name(a: (u8, u8)) -> &'static str { + ANCHORS.get(usize::from(a.1.min(2)) * 3 + usize::from(a.0.min(2))).copied().unwrap_or("tl") +} + +/// The fill new text takes to be readable where it is drawn, in `parent`: white on a dark +/// background (None: the model's default, black). The background is the nearest layer from `parent` +/// out whose top visible fill is a mostly opaque solid colour, else the page (#362). For text made +/// in the app: by the Text tool, or by a command that names no fill while the interface is up. +pub(crate) fn readable_text_fills(doc: &Document, parent: NodeId) -> Option> { + let dark = std::iter::once(parent).chain(doc.ancestors(parent)).filter_map(|id| doc.node(id)).find_map(|n| match &n.kind { + NodeKind::Page { background, .. } => Some(background.is_dark()), + _ => n.fills.iter().rev().find(|p| p.visible).and_then(|p| p.solid_color()).filter(|c| c.a >= 0.5).map(|c| c.is_dark()), + }); + dark.unwrap_or(false).then(|| vec![figcraft_color::Paint::solid(figcraft_color::Color::WHITE)]) +} + +const ANCHORS: [&str; 9] = ["tl", "t", "tr", "l", "c", "r", "bl", "b", "br"]; + +const HANDLE_PX: f64 = 5.0; + +const ROTATE_PX: f64 = 18.0; + +/// Snap distance in screen pixels. +pub(crate) const SNAP_PX: f64 = 5.0; + +/// Screen pixels the pointer travels after a press before a move or resize starts: a click or a +/// double-click made with an unsteady hand doesn't drag what it lands on. +pub(super) const DRAG_SLOP_PX: f64 = 3.0; + +/// Screen pixels from a path within which the Text on a path tool takes it. +pub(super) const PATH_REACH_PX: f64 = 6.0; + +/// The visible layers under `id` whose box, grown by `reach` (page units), holds `p`, front-most +/// first: what the Text on a path tool measures its distance to. Content a clipping frame or a +/// mask hides at `p` and the operands of boolean groups are left out. +pub(super) fn path_candidates(doc: &Document, id: NodeId, p: Point, reach: f64, depth: usize, out: &mut Vec) { + let Some(n) = doc.node(id) else { return }; + if !n.visible || depth > 512 || matches!(n.kind, NodeKind::BooleanOp { .. }) { + return; + } + if n.clip && n.kind.is_frame_like() && !n.local_rect().contains(doc.world_transform(id).inverse() * p) { + return; + } + for c in n.paint_order().rev() { + let before = out.len(); + path_candidates(doc, c, p, reach, depth + 1, out); + if out.len() > before && figcraft_doc::geometry::masked_out(doc, n, c, p) { + out.truncate(before); + } + } + if n.children.is_empty() && !n.is_mask && doc.world_bounds(id).is_some_and(|b| b.inflate(reach, reach).contains(p)) { + out.push(id); + } +} + +/// Corners of the selection's handle box in page coordinates (tl, tr, br, bl). +pub fn selection_box(doc: &Document, sel: &[NodeId]) -> Option<[Point; 4]> { + let (f, w, h) = selection_frame(doc, sel)?; + Some([f * Point::ZERO, f * Point::new(w, 0.0), f * Point::new(w, h), f * Point::new(0.0, h)]) +} + +/// The selection's handle box: the page transform of its top-left corner (axes along the box) and +/// its size. One layer: its own box; several sharing a rotation: a box turned with them; +/// otherwise the page-aligned bounds. +pub fn selection_frame(doc: &Document, sel: &[NodeId]) -> Option<(Affine, f64, f64)> { + let ids = roots(doc, sel); + if let [id] = ids.as_slice() { + let n = doc.node(*id)?; + return Some((doc.world_transform(*id), n.width, n.height)); + } + let Some(angle) = shared_rotation(doc, &ids) else { + let b = doc.bounds_of(&ids)?; + return Some((Affine::translate((b.x0, b.y0)), b.width(), b.height())); + }; + let rot = Affine::rotate(angle); + let inv = rot.inverse(); + let b = ids + .iter() + .filter_map(|i| Some(figcraft_geom::transform_rect_bbox(inv * doc.world_transform(*i), doc.node(*i)?.local_rect()))) + .reduce(|a, b| a.union(b))?; + Some((rot * Affine::translate((b.x0, b.y0)), b.width(), b.height())) +} + +/// The rotation (radians) every layer in `ids` shares, unless it's a multiple of 90 degrees (the +/// page-aligned box is then the same box). Flipped layers count by their axis, not its direction. +fn shared_rotation(doc: &Document, ids: &[NodeId]) -> Option { + use std::f64::consts::{FRAC_PI_2, PI}; + const EPS: f64 = 1e-4; + let mut angles = ids.iter().map(|i| { + let c = doc.world_transform(*i).as_coeffs(); + c[1].atan2(c[0]) + }); + let a = angles.next()?; + let same = |b: f64| { + let d = (b - a).rem_euclid(PI); + d.min(PI - d) < EPS + }; + let axis_aligned = (a - (a / FRAC_PI_2).round() * FRAC_PI_2).abs() < EPS; + (a.is_finite() && !axis_aligned && angles.all(same)).then_some(a) +} + +/// Whether layer `id` is locked or inside a locked layer: it can be selected in the layers panel +/// but not moved or resized on the canvas. +pub fn locked_in(doc: &Document, id: NodeId) -> bool { + std::iter::once(id).chain(doc.ancestors(id)).any(|a| doc.node(a).is_some_and(|n| n.locked)) +} + +/// Whether layer `t` paints below layer `s`: it holds `s`, or it is behind `s` in the stack of the +/// nearest layer holding both. +pub(super) fn paints_below(doc: &Document, t: NodeId, s: NodeId) -> bool { + let chain = |id: NodeId| { + let mut c: Vec = std::iter::once(id).chain(doc.ancestors(id)).collect(); + c.reverse(); + c + }; + let (ct, cs) = (chain(t), chain(s)); + let shared = ct.iter().zip(&cs).take_while(|(a, b)| a == b).count(); + match (ct.get(shared), cs.get(shared)) { + (None, Some(_)) => true, + (Some(a), Some(b)) => shared.checked_sub(1).and_then(|i| ct.get(i)).and_then(|p| doc.node(*p)).is_some_and(|p| { + let order: Vec = p.paint_order().collect(); + order.iter().position(|c| c == a) < order.iter().position(|c| c == b) + }), + _ => false, + } +} + +/// Which handle (if any) is under `p`. A selection with a locked layer has none. +pub fn handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option { + if sel.is_empty() || sel.iter().any(|i| locked_in(doc, *i)) { + return None; + } + // A connector's handles are its ends (see `board::end_handle_at`): no box to resize or turn. + if crate::board::selected_connector_ends(doc, sel).is_some() { + return None; + } + let corners = selection_box(doc, sel)?; + // Lines (zero height) only get end handles. + let is_line = sel.len() == 1 && sel.first().and_then(|i| doc.node(*i)).is_some_and(|n| matches!(n.kind, NodeKind::Line)); + handle_at_corners(&corners, p, zoom, is_line) +} + +/// Half the side of the square the canvas draws on each corner of the handle box (screen pixels, +/// upright whatever the box's rotation). +const CORNER_SQUARE_PX: f64 = 4.0; + +/// Whether `p` is on a corner square of the selection's handle box as drawn: there the corner +/// resizes, although a corner-radius handle 10 px in reaches that far too. +pub(super) fn on_corner_square(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> bool { + let reach = CORNER_SQUARE_PX / zoom.max(1e-6); + matches!(handle_at(doc, sel, p, zoom), Some(Handle::Corner(_))) + && selection_box(doc, sel).is_some_and(|c| c.iter().any(|c| (c.x - p.x).abs() <= reach && (c.y - p.y).abs() <= reach)) +} + +/// A box whose sides are both shorter than this on screen (pixels) would be covered by its +/// handles: the canvas draws just its outline (the handles still answer just outside it). +pub const SMALL_BOX_PX: f64 = 16.0; + +/// The stroke the Pencil draws: its colour and weight (the canvas previews it the same). +pub const PENCIL_STROKE: (figcraft_color::Color, f64) = (figcraft_color::Color::BLACK, 2.0); + +/// The shorter and longer sides, in screen pixels, of the box with `corners` (tl, tr, br, bl). +pub fn box_sides_px(corners: &[Point; 4], zoom: f64) -> (f64, f64) { + let [a, b, _, d] = corners; + let (w, h) = ((*b - *a).hypot() * zoom, (*d - *a).hypot() * zoom); + (w.min(h), w.max(h)) +} + +/// Which handle of the box with `corners` (tl, tr, br, bl in page coordinates) is under `p`; +/// `is_line` keeps only the two end handles. +pub fn handle_at_corners(corners: &[Point; 4], p: Point, zoom: f64, is_line: bool) -> Option { + let tol = HANDLE_PX / zoom.max(1e-6); + // Inside the box, handles reach in at most a quarter of its shorter side, so that a press in + // the middle of a small layer moves it; outside it they reach as far as ever. + let inner = if !is_line && in_quad(corners, p) { box_sides_px(corners, zoom).0 / 4.0 / zoom.max(1e-6) } else { f64::INFINITY }; + let (corner_reach, edge_reach) = ((tol * 1.6).min(inner), tol.min(inner)); + // The nearest corner, then the nearest edge, in reach (a small box's reaches overlap). + let nearest = |hits: &mut dyn Iterator| hits.min_by(|a, b| a.0.total_cmp(&b.0)).map(|(_, h)| h); + let corner = nearest( + &mut corners + .iter() + .enumerate() + .filter(|(i, _)| !(is_line && *i >= 2)) + .map(|(i, c)| ((*c - p).hypot(), Handle::Corner(i as u8))) + .filter(|(d, _)| *d <= corner_reach), + ); + if corner.is_some() { + return corner; + } + if !is_line { + use figcraft_geom::kurbo::ParamCurveNearest; + let edge = nearest(&mut (0..4).filter_map(|i| { + let (a, b) = (corners.get(i)?, corners.get((i + 1) % 4)?); + let near = figcraft_geom::kurbo::Line::new(*a, *b).nearest(p, 0.001); + let d = near.distance_sq.sqrt(); + (d <= edge_reach && near.t > 0.05 && near.t < 0.95).then_some((d, Handle::Edge(i as u8))) + })); + if edge.is_some() { + return edge; + } + } + // Rotation zones: just outside each corner. + let center = Point::new(corners.iter().map(|c| c.x).sum::() / 4.0, corners.iter().map(|c| c.y).sum::() / 4.0); + let rot = ROTATE_PX / zoom.max(1e-6); + for (i, c) in corners.iter().enumerate() { + let d = (*c - p).hypot(); + let outward = (p - *c).dot(*c - center) > 0.0; + if d <= rot && d > tol && outward { + return Some(Handle::Rotate(i as u8)); + } + } + None +} + +pub(super) fn resize_cursor(doc: &Document, sel: &[NodeId], h: Handle) -> Cursor { + selection_box(doc, sel).map_or(Cursor::Default, |c| handle_cursor(&c, h)) +} + +/// The cursor over handle `h` of the box with corners `c`. +pub(crate) fn handle_cursor(c: &[Point; 4], h: Handle) -> Cursor { + let angle = |a: Point, b: Point| (b - a).atan2().to_degrees(); + match h { + Handle::Corner(i) => { + let a = c.get(i as usize).copied().unwrap_or_default(); + let b = c.get((i as usize + 2) % 4).copied().unwrap_or_default(); + // Along the box's own axes at 45°, not the diagonal, so wide or tall boxes still get a + // diagonal cursor. A box with no width or height falls back to the diagonal. + let unit = |v: figcraft_geom::kurbo::Vec2| { + let len = v.hypot(); + (len.is_finite() && len > 1e-9).then(|| v / len) + }; + let next = c.get((i as usize + 1) % 4).copied().unwrap_or_default(); + let prev = c.get((i as usize + 3) % 4).copied().unwrap_or_default(); + match (unit(next - a), unit(prev - a)) { + (Some(u), Some(v)) => Cursor::Resize((u + v).atan2().to_degrees()), + _ => Cursor::Resize(angle(a, b)), + } + } + Handle::Edge(i) => { + let a = c.get(i as usize).copied().unwrap_or_default(); + let b = c.get((i as usize + 1) % 4).copied().unwrap_or_default(); + Cursor::Resize(angle(a, b) + 90.0) + } + Handle::Rotate(i) => { + let centre = Point::new(c.iter().map(|p| p.x).sum::() / 4.0, c.iter().map(|p| p.y).sum::() / 4.0); + Cursor::Rotate(angle(centre, c.get(i as usize).copied().unwrap_or_default())) + } + } +} + +/// The box (x0, y0, x1, y1) a `w0`×`h0` handle box becomes when `handle` is dragged to `q`, all in +/// the box's own axes: `keep` constrains proportions, `from_center` resizes about the centre (the +/// opposite side mirrors the dragged one). Edges that cross over come back reversed (x1 < x0 or +/// y1 < y0: a flip). +pub(crate) fn resized_box(handle: Handle, q: Point, w0: f64, h0: f64, keep: bool, from_center: bool) -> (f64, f64, f64, f64) { + let (mut x0, mut y0, mut x1, mut y1) = (0.0, 0.0, w0, h0); + // The dragged sides: x0 or x1 (`Some(false)` / `Some(true)`), y0 or y1. + let (sx, sy) = match handle { + Handle::Corner(0) => (Some(false), Some(false)), + Handle::Corner(1) => (Some(true), Some(false)), + Handle::Corner(2) => (Some(true), Some(true)), + Handle::Corner(_) => (Some(false), Some(true)), + Handle::Edge(0) => (None, Some(false)), + Handle::Edge(1) => (Some(true), None), + Handle::Edge(2) => (None, Some(true)), + Handle::Edge(_) => (Some(false), None), + Handle::Rotate(_) => (None, None), + }; + match sx { + Some(false) => (x0, x1) = (q.x, if from_center { w0 - q.x } else { w0 }), + Some(true) => (x0, x1) = (if from_center { w0 - q.x } else { 0.0 }, q.x), + None => {} + } + match sy { + Some(false) => (y0, y1) = (q.y, if from_center { h0 - q.y } else { h0 }), + Some(true) => (y0, y1) = (if from_center { h0 - q.y } else { 0.0 }, q.y), + None => {} + } + if keep && w0 > 1e-9 && h0 > 1e-9 && !matches!(handle, Handle::Rotate(_)) { + // One factor for both axes: the dragged axis's (a corner: the one that moved the most), + // about the point that stays put (the opposite handle, or the centre). + let (kx, ky) = ((x1 - x0) / w0, (y1 - y0) / h0); + let k = match (sx, sy) { + (None, _) => ky, + (_, None) => kx, + _ if kx.abs() > ky.abs() => kx, + _ => ky, + }; + let pivot = if from_center { Point::new(w0 / 2.0, h0 / 2.0) } else { handle_point(opposite(handle), w0, h0) }; + (x0, x1) = (pivot.x * (1.0 - k), pivot.x + (w0 - pivot.x) * k); + (y0, y1) = (pivot.y * (1.0 - k), pivot.y + (h0 - pivot.y) * k); + } + (x0, y0, x1, y1) +} + +/// A handle's point in its box's axes (`w0`×`h0`): a corner or an edge's middle. +fn handle_point(h: Handle, w0: f64, h0: f64) -> Point { + match h { + Handle::Corner(0) | Handle::Rotate(0) => Point::new(0.0, 0.0), + Handle::Corner(1) | Handle::Rotate(1) => Point::new(w0, 0.0), + Handle::Corner(2) | Handle::Rotate(2) => Point::new(w0, h0), + Handle::Corner(_) | Handle::Rotate(_) => Point::new(0.0, h0), + Handle::Edge(0) => Point::new(w0 / 2.0, 0.0), + Handle::Edge(1) => Point::new(w0, h0 / 2.0), + Handle::Edge(2) => Point::new(w0 / 2.0, h0), + Handle::Edge(_) => Point::new(0.0, h0 / 2.0), + } +} + +/// Anchor `(column, row)` of a `w`×`h` box, in its axes. +fn anchor_in_box(anchor: (u8, u8), w: f64, h: f64) -> Point { + Point::new(w * f64::from(anchor.0.min(2)) / 2.0, h * f64::from(anchor.1.min(2)) / 2.0) +} + +/// The Scale tool's anchor point on the selection's handle box, in page coordinates. +pub fn scale_anchor_point(doc: &Document, sel: &[NodeId], anchor: (u8, u8)) -> Option { + let (f, w, h) = selection_frame(doc, sel)?; + Some(f * anchor_in_box(anchor, w, h)) +} + +/// The handle across the box from `h`. +fn opposite(h: Handle) -> Handle { + match h { + Handle::Corner(i) => Handle::Corner((i + 2) % 4), + Handle::Edge(i) => Handle::Edge((i + 2) % 4), + Handle::Rotate(i) => Handle::Rotate((i + 2) % 4), + } +} + +/// The Scale tool's factor and pivot (in the box's axes) for dragging `handle` of a `w0`×`h0` box +/// to `q`. The pivot is the anchor point (`anchor`: column, row), or the opposite handle when the +/// anchor lies on the side being dragged, or the centre (`from_center`). Corners follow the axis +/// that moved the most; a negative factor flips. None when the box has no size to scale by. +pub(crate) fn scale_factor(handle: Handle, q: Point, w0: f64, h0: f64, anchor: (u8, u8), from_center: bool) -> Option<(f64, Point)> { + const EPS: f64 = 1e-9; + if !(q.x.is_finite() && q.y.is_finite() && w0.is_finite() && h0.is_finite()) || matches!(handle, Handle::Rotate(_)) { + return None; + } + let h = handle_point(handle, w0, h0); + let anchor_pt = anchor_in_box(anchor, w0, h0); + // The axes the handle moves along. + let (ax_x, ax_y) = match handle { + Handle::Edge(0) | Handle::Edge(2) => (false, true), + Handle::Edge(_) => (true, false), + _ => (true, true), + }; + let on_side = (ax_x && (anchor_pt.x - h.x).abs() < EPS) || (ax_y && (anchor_pt.y - h.y).abs() < EPS); + let pivot = if from_center { + Point::new(w0 / 2.0, h0 / 2.0) + } else if on_side { + handle_point(opposite(handle), w0, h0) + } else { + anchor_pt + }; + let ratio = |on: bool, to: f64, from: f64, p: f64| (on && (from - p).abs() > EPS).then(|| (to - p) / (from - p)); + let kx = ratio(ax_x, q.x, h.x, pivot.x); + let ky = ratio(ax_y, q.y, h.y, pivot.y); + let k = match (kx, ky) { + (Some(a), Some(b)) => { + if a.abs() > b.abs() { + a + } else { + b + } + } + (Some(a), None) | (None, Some(a)) => a, + (None, None) => return None, + }; + if !k.is_finite() { + return None; + } + // Never collapse to nothing (or blow up past a usable size); 0 counts as positive. + Some((k.signum() * k.abs().clamp(0.01, 1000.0), pivot)) +} + +/// The readout of a shape handle drag on `id`. +pub(super) fn shape_hint(doc: &Document, id: NodeId, handle: ShapeHandle, zoom: f64) -> Option { + Some(ShapeHint { handle, value: crate::shape_handles::value(doc, id, handle)?, at: crate::shape_handles::handle_at(doc, id, handle, zoom)? }) +} + +#[allow(dead_code)] +fn unused(_: BTreeMap) {} + +/// An end point of a selected open vector within `tol` of page point `p`: (layer, point). +pub(super) fn selected_end_at(doc: &Document, sel: &[NodeId], p: Point, tol: f64) -> Option<(NodeId, usize)> { + sel.iter().find_map(|id| { + let net = crate::vectoredit::network(doc, *id)?; + let w = doc.world_transform(*id); + let near = |v: &usize| net.vertices.get(*v).is_some_and(|x| (w * x.point() - p).hypot() <= tol); + (0..net.vertices.len()).filter(near).find(|v| net.incident(*v).len() == 1).map(|v| (*id, v)) + }) +} diff --git a/crates/engine/src/canvas/tools/keys.rs b/crates/engine/src/canvas/tools/keys.rs new file mode 100644 index 00000000..591f1479 --- /dev/null +++ b/crates/engine/src/canvas/tools/keys.rs @@ -0,0 +1,174 @@ +use super::*; + +impl Session { + /// Keys the current mode handles before menu shortcuts (vector edit mode's tools and point + /// keys, Enter to finish the shape builder). Returns whether the key was used. + pub fn tool_key(&mut self, name: &str, shift: bool, alt: bool, cmd: bool) -> Result { + // Dev Mode edits nothing: keys go to the commands (which it refuses in the app). + if self.tool.dev_mode { + return Ok(false); + } + if self.ds().is_ok_and(|d| d.vector_edit.is_some()) { + return crate::vectoredit::key(self, name, shift, alt, cmd); + } + if !alt && !cmd && self.guide_key(name, shift)? { + return Ok(true); + } + crate::shapebuilder::key(self, name) + } + + /// Keys for a selected ruler guide: Delete removes it, arrows across it nudge it (Shift: the big + /// nudge), Escape deselects it. + fn guide_key(&mut self, name: &str, shift: bool) -> Result { + let Some((owner, index)) = self.ds()?.selected_guide() else { return Ok(false) }; + // Backspace is Delete, `Left` is `ArrowLeft`, and so on. + let name = crate::keymap::normalize(name).map_or(name, |(k, _)| k); + let page = self.page()?; + let at = |mut p: serde_json::Value| { + if owner != page { + p["frame"] = json!(owner); + } + p + }; + let Some(g) = self.doc()?.node(owner).and_then(|n| n.guides().get(index).cloned()) else { return Ok(false) }; + let step = if shift { self.prefs.nudge_big } else { self.prefs.nudge_small }; + let d = match (name, g.vertical) { + ("Delete", _) => { + self.execute("guide.remove", &at(json!({"index": index})))?; + return Ok(true); + } + ("Escape", _) => { + self.execute("guide.select", &json!({}))?; + return Ok(true); + } + ("ArrowLeft", true) | ("ArrowUp", false) => -step, + ("ArrowRight", true) | ("ArrowDown", false) => step, + _ => return Ok(false), + }; + self.execute("guide.move", &at(json!({"index": index, "offset": g.offset + d})))?; + Ok(true) + } + + /// The layer under `p` for a click: the topmost on `page`, or in the focus view the focused + /// frame's. Boolean groups are hit by their results ([`crate::booleans::result_outline`]); in + /// outline mode shapes only on their outlines. + pub fn hit(&self, doc: &Document, page: NodeId, p: Point, tol: f64) -> Option { + let sel = self.selection(); + let result = crate::booleans::result_outline(doc, &sel); + let focus = self.ds().ok().and_then(crate::DocState::focused); + // Outline mode draws no fills: shapes are clicked on their outlines, reaching what's behind. + if let Some(v) = self.ds().ok().map(|d| &d.view).filter(|v| v.outlines) { + // What it draws besides the shapes can be clicked too. + let also = figcraft_doc::geometry::OutlineHits { hidden: v.outline_hidden, bounds: v.outline_bounds }; + return figcraft_doc::geometry::hit_outlines(doc, focus.unwrap_or(page), p, tol, Some(&result), also); + } + match focus { + Some(f) => figcraft_doc::geometry::hit_in(doc, f, p, tol, Some(&result)), + None => doc.hit_test(page, p, tol, Some(&result)), + } + } + + /// Enter with a shape or frame tool (`tool.place`): what a click with it makes, centred on `at` + /// (default the middle of the view) and selected. + pub fn place(&mut self, at: Option) -> Result<()> { + let tool = self.tool.tool; + let refuse = |why: &str| Err(crate::EngineError::Disabled("tool.place".into(), why.into())); + if !tool.draws_shape() { + return refuse("choose a shape or frame tool first"); + } + if self.tool.busy() { + return refuse("finish the drag first"); + } + let v = &self.ds()?.view; + let (c, zoom) = (at.unwrap_or(Point::new(v.cx, v.cy)), v.zoom); + let (w, h) = tool.click_size(); + let ev = |kind| PointerEvent::at(kind, c.x - w / 2.0, c.y - h / 2.0, zoom); + let r = self.pointer(&ev(PointerKind::Down)).and_then(|()| self.pointer(&ev(PointerKind::Up))); + if r.is_err() { + self.tool.gesture = Gesture::None; + let _ = self.cancel_interaction(); + } + r + } + + /// Keyboard Escape: cancel gestures, finish pens, leave text editing, then deselect (in the + /// focus view, once the focused frame or nothing is selected, it ends the focus view). + pub fn escape(&mut self) -> Result<()> { + // Following someone in a live session: Escape stops that first. + if self.live_unfollow() { + return Ok(()); + } + if self.tool.pen.is_some() { + return self.pen_end(); + } + if self.tool.busy() { + self.tool.gesture = Gesture::None; + self.tool.overlay = Overlay::default(); + return self.cancel_interaction(); + } + if self.end_origin_edit() { + return Ok(()); + } + if self.ds()?.text_edit.is_some() { + return textedit::end(self); + } + if self.ds()?.vector_edit.is_some() { + return crate::vectoredit::escape(self); + } + if self.tool.tool != ToolId::Move { + self.set_tool(ToolId::Move); + return Ok(()); + } + let sel = self.selection(); + if let Some(f) = self.ds()?.focused() + && (sel.is_empty() || sel == [f]) + { + self.ds_mut()?.focus = None; + return Ok(()); + } + // Escape selects the parent, then nothing. + let doc = self.doc()?; + let parents: Vec = + sel.iter().filter_map(|i| doc.node(*i).and_then(|n| n.parent)).filter(|p| !doc.node(*p).is_some_and(Node::is_page)).collect(); + if !parents.is_empty() { + let mut uniq = Vec::new(); + for p in parents { + if !uniq.contains(&p) { + uniq.push(p); + } + } + self.set_selection(uniq); + } else { + self.set_selection(vec![]); + } + Ok(()) + } + + /// Whether the Move tool shows arc and shape handles now (not while another gesture runs, nor + /// in vector or text edit mode). + pub(super) fn arc_handles_shown(&self) -> bool { + let arc_drag = matches!(self.tool.gesture, Gesture::Arc { .. } | Gesture::Shape { .. }); + self.tool.tool == ToolId::Move + && !self.tool.dev_mode + && (arc_drag || !self.tool.busy()) + && self.ds().is_ok_and(|ds| ds.vector_edit.is_none() && ds.text_edit.is_none()) + } + + /// The selected ellipse's arc handles as the canvas shows them at the current zoom. + pub fn arc_handles(&self) -> Option { + let ds = self.ds().ok()?; + self.arc_handles_shown().then(|| crate::arc_handles::handles(&ds.doc, &ds.selection, ds.view.zoom)).flatten() + } + + /// The selected shape's corner radius and star ratio handles as the canvas shows them now. + pub fn shape_handles(&self) -> Vec<(ShapeHandle, [f64; 2])> { + let Ok(ds) = self.ds() else { return vec![] }; + if self.arc_handles_shown() { crate::shape_handles::handles(&ds.doc, &ds.selection, ds.view.zoom) } else { vec![] } + } + + /// Snapshot of tool state for the UI / inspect. + pub fn tool_info(&self) -> serde_json::Value { + let shapes: Vec = self.shape_handles().into_iter().map(|(h, at)| json!({"handle": h, "at": at})).collect(); + json!({"tool": self.tool.tool.id(), "busy": self.tool.busy(), "overlay": self.tool.overlay, "arcHandles": self.arc_handles(), "shapeHandles": shapes}) + } +} diff --git a/crates/engine/src/canvas/tools/pen.rs b/crates/engine/src/canvas/tools/pen.rs new file mode 100644 index 00000000..319c11b7 --- /dev/null +++ b/crates/engine/src/canvas/tools/pen.rs @@ -0,0 +1,276 @@ +use super::handles::{PENCIL_STROKE, selected_end_at}; +use super::*; + +/// Pen pressure along drawn samples as width profile points `[t, scale]`: `t` by length, at most +/// 32 points, medium pressure (0.5) at the stroke weight, from a tenth to twice it. `None` when no +/// sample has pressure. +pub(crate) fn pressure_profile(points: &[Point], pressure: &[Option]) -> Option> { + if pressure.iter().all(Option::is_none) { + return None; + } + let mut lengths = vec![0.0]; + for w in points.windows(2) { + let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue }; + lengths.push(lengths.last().copied().unwrap_or(0.0) + (*b - *a).hypot()); + } + let total = lengths.last().copied().unwrap_or(0.0); + if !(total.is_finite() && total > 0.0) { + return None; + } + let at = |i: usize| f64::from(pressure.get(i).copied().flatten().filter(|p| p.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0)); + let n = points.len().clamp(2, 32); + let profile = (0..n).map(|k| { + let t = k as f64 / (n - 1) as f64; + let s = t * total; + let i = lengths.partition_point(|l| *l < s).min(lengths.len().saturating_sub(1)); + let p = match (i.checked_sub(1).and_then(|j| lengths.get(j)), lengths.get(i)) { + (Some(a), Some(b)) if b > a => at(i - 1) + (at(i) - at(i - 1)) * ((s - a) / (b - a)), + _ => at(i), + }; + [t, (p / 0.5).clamp(0.1, 2.0)] + }); + Some(profile.collect()) +} + +/// A frame or section drawn around layers takes those of its siblings that lie fully inside it +/// (visible and unlocked ones, in their order; a frame never takes a section). Not in auto layout, +/// where the new frame is one more item of the flow. +pub(super) fn take_enclosed(doc: &mut Document, drawn: NodeId) -> Result<()> { + let Some(n) = doc.node(drawn) else { return Ok(()) }; + let section = matches!(n.kind, NodeKind::Section { .. }); + let (Some(parent), Some(area)) = (n.parent.and_then(|p| doc.node(p)), doc.world_bounds(drawn)) else { return Ok(()) }; + if parent.layout.is_some() { + return Ok(()); + } + let inside: Vec = parent + .children + .iter() + .copied() + .filter(|c| *c != drawn && doc.node(*c).is_some_and(|k| k.visible && !k.locked && (section || !matches!(k.kind, NodeKind::Section { .. })))) + .filter(|c| doc.world_bounds(*c).is_some_and(|b| area.contains(b.origin()) && area.contains(Point::new(b.x1, b.y1)))) + .collect(); + for c in inside { + doc.reparent(c, drawn, None)?; + } + Ok(()) +} + +impl Session { + /// The pen tool's snapped position for `ev`: its path's vector (or, before it has one, its first + /// point) counts among the targets. + pub(super) fn snap_pen_tool(&mut self, ev: &PointerEvent) -> Result<(Point, Vec)> { + let (id, extra) = match &self.tool.pen { + Some(pen) if pen.id.is_some() => (pen.id, Vec::new()), + Some(pen) => (None, pen.points.iter().map(|(p, _, _)| *p).collect()), + None => (None, Vec::new()), + }; + self.snap_pen(ev, id, &extra) + } + + pub(super) fn pen_down(&mut self, ev: &PointerEvent) -> Result<()> { + self.tool.overlay.guides.clear(); + // The segment being placed shows as itself while the press lasts. + self.tool.overlay.rubber = None; + self.tool.overlay.rubber_handle = None; + let p = if ev.shift { ev.p() } else { self.snap_pen_tool(ev)?.0 }; + let (doc, page) = { + let ds = self.ds()?; + (ds.doc.clone(), ds.page) + }; + // With an open path selected, a click on one of its end points continues it in edit mode. + if self.tool.pen.is_none() + && let Some((id, v)) = selected_end_at(&doc, &self.selection(), ev.p(), 8.0 / ev.zoom.max(1e-6)) + { + return self.pen_edit(id, Some(v)); + } + let pen = self.tool.pen.get_or_insert_with(PenPath::default); + // Clicking the first anchor closes the path. + if pen.points.len() > 2 + && let Some((first, _, _)) = pen.points.first() + && (*first - p).hypot() * ev.zoom < 8.0 + { + pen.closed = true; + self.pen_update()?; + return self.pen_finish(); + } + // Clicking another of its own anchors (not the last) connects there and ends the path. + let near = |q: &Point| (*q - p).hypot() * ev.zoom < 8.0; + if let Some(k) = (1..pen.points.len().saturating_sub(1)).find(|k| pen.points.get(*k).is_some_and(|(q, _, _)| near(q))) { + pen.join = Some(k); + self.pen_update()?; + return self.pen_finish(); + } + if pen.parent.is_none() { + pen.parent = Some(frame_at(&doc, page, p, &[]).unwrap_or(page)); + } + let mut p = p; + if ev.shift + && let Some((last, _, _)) = pen.points.last() + { + p = at_45_degrees(*last, p); + } + pen.points.push((p, Vec2::ZERO, Vec2::ZERO)); + self.tool.gesture = Gesture::PenDrag { anchor: p }; + self.pen_update() + } + + /// Rebuild the pen's vector node from its anchors. + pub(super) fn pen_update(&mut self) -> Result<()> { + let Some(pen) = self.tool.pen.clone() else { return Ok(()) }; + self.tool.overlay.pen = pen.points.iter().map(|(p, _, _)| [p.x, p.y]).collect(); + self.tool.overlay.pen_handles = pen_handles(&pen.points); + if pen.points.len() < 2 { + if let Ok(ds) = self.ds_mut() { + ds.revision += 1; + } + return Ok(()); + } + let page = self.page()?; + let parent = pen.parent.unwrap_or(page); + if pen.id.is_none() { + self.begin_interaction("Pen")?; + } + let ds = self.ds_mut()?; + let doc = ds.doc_mut(); + let pw = doc.world_transform(parent); + let inv = pw.inverse(); + // Anchors and handles into the parent's space. + let pts: Vec<(Point, Point, Point)> = pen.points.iter().map(|(p, i, o)| (inv * *p, inv * (*p + *i), inv * (*p + *o))).collect(); + let mut path = figcraft_geom::BezPath::new(); + for (k, (p, _, _)) in pts.iter().enumerate() { + if k == 0 { + path.move_to(*p); + } else if let (Some(prev), Some(cur)) = (pts.get(k - 1), pts.get(k)) { + path.curve_to(prev.2, cur.1, *p); + } + } + if pen.closed + && let (Some(last), Some(first)) = (pts.last(), pts.first()) + { + path.curve_to(last.2, first.1, first.0); + path.close_path(); + } + use figcraft_geom::Shape; + let bb = path.bounding_box(); + path.apply_affine(Affine::translate((-bb.x0, -bb.y0))); + let mut net = VectorNetwork::from_path(&path); + // Straight segments stay straight. + for s in &mut net.segments { + if (s.ta.0.abs() + s.ta.1.abs()) < 1e-9 && (s.tb.0.abs() + s.tb.1.abs()) < 1e-9 { + s.ta = (0.0, 0.0); + s.tb = (0.0, 0.0); + } + } + if !pen.closed { + net.regions.clear(); + } + // Anchor k is vertex k of the open path. + if let (Some(k), Some((last, _, out))) = (pen.join, pts.last()) { + let from = pts.len() - 1; + crate::vectoredit::ops::connect(&mut net, from, k, (out.x - last.x, out.y - last.y), (0.0, 0.0)); + } + let id = match pen.id { + Some(id) if doc.contains(id) => id, + _ => { + let id = doc.alloc_id(); + let mut n = Node::new(id, NodeKind::Vector { network: VectorNetwork::default() }, "Vector", 0.0, 0.0); + n.fills.clear(); + n.strokes = vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]; + n.stroke.align = StrokeAlign::Center; + doc.insert(n, parent, None)?; + id + } + }; + let n = doc.get_mut(id)?; + n.kind = NodeKind::Vector { network: net }; + n.width = bb.width(); + n.height = bb.height(); + n.transform = Affine::translate((bb.x0, bb.y0)); + if pen.closed && n.fills.is_empty() { + n.fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::DEFAULT_SHAPE)]; + } + ds.selection = vec![id]; + ds.revision += 1; + if let Some(p) = self.tool.pen.as_mut() { + p.id = Some(id); + } + Ok(()) + } + + /// Edit `id` as a vector with the pen, going on from point `anchor` (or, without one, starting + /// a new path in the same layer at the next click). + fn pen_edit(&mut self, id: NodeId, anchor: Option) -> Result<()> { + self.set_tool(ToolId::Move); + crate::vectoredit::begin(self, id)?; + crate::vectoredit::set_tool(self, crate::vectoredit::VTool::Pen)?; + crate::vectoredit::set_state(self, |e| { + e.anchor = anchor; + e.vertices = anchor.into_iter().collect(); + }) + } + + /// Enter/Escape while the pen draws: the path ends but its layer stays in edit mode with the + /// pen, so the next path joins the same vector (another Escape leaves edit mode). + pub fn pen_end(&mut self) -> Result<()> { + let id = self.tool.pen.as_ref().and_then(|p| p.id); + self.pen_finish()?; + match id { + Some(id) if self.doc()?.contains(id) => self.pen_edit(id, None), + _ => Ok(()), + } + } + + /// Finish the pen path (and keep the pen tool: double-click, Delete, a tool switch). + pub fn pen_finish(&mut self) -> Result<()> { + let pen = self.tool.pen.take(); + self.tool.overlay.pen.clear(); + self.tool.overlay.pen_handles.clear(); + self.tool.overlay.rubber = None; + self.tool.overlay.rubber_handle = None; + self.tool.gesture = Gesture::None; + if pen.as_ref().is_some_and(|p| p.id.is_some()) { + self.commit_interaction()?; + } + Ok(()) + } + + pub(super) fn pencil_finish(&mut self, points: &[Point], pressure: &[Option]) -> Result<()> { + if points.len() < 2 { + return Ok(()); + } + // Smooth: fit a curve through the samples. + let mut poly = figcraft_geom::BezPath::new(); + for (k, p) in points.iter().enumerate() { + if k == 0 { + poly.move_to(*p); + } else { + poly.line_to(*p); + } + } + let fitted = figcraft_geom::kurbo::simplify::simplify_bezpath(poly.iter(), 0.75, &Default::default()); + let d = fitted.to_svg(); + if matches!(self.tool.tool, ToolId::Marker | ToolId::Highlighter) { + return self.ink_finish(&d); + } + let page = self.page()?; + let parent = frame_at(self.doc()?, page, points.first().copied().unwrap_or_default(), &[]).unwrap_or(page); + let pw = self.doc()?.world_transform(parent); + let mut local = fitted.clone(); + local.apply_affine(pw.inverse()); + let _ = d; + let (color, weight) = PENCIL_STROKE; + let mut props = json!({"fills": [], "strokes": [color.to_css_hex()], "strokeWeight": weight, "strokeCap": "round", "strokeJoin": "round", "strokeAlign": "center"}); + // Pen pressure becomes the stroke's width profile (brushes follow it too). + if let Some(points) = pressure_profile(points, pressure) { + props["widthProfile"] = json!(points); + } + if let Some(b) = &self.tool.pencil_brush { + props["brush"] = serde_json::to_value(b).unwrap_or_default(); + } + self.execute( + "node.create", + &json!({"type": "vector", "path": local.to_svg(), "closed": false, "parent": parent, "name": "Vector", "props": props}), + )?; + Ok(()) + } +} diff --git a/crates/engine/src/canvas/tools/pointer.rs b/crates/engine/src/canvas/tools/pointer.rs new file mode 100644 index 00000000..985e3326 --- /dev/null +++ b/crates/engine/src/canvas/tools/pointer.rs @@ -0,0 +1,292 @@ +use super::handles::{handle_at, label_at, locked_in, on_corner_square, resize_cursor, selected_end_at}; +use super::*; + +impl Session { + /// The pointer left the canvas: what hovering there showed goes (the distances Alt or Dev Mode + /// measured and the box measured to, a vector region's highlight), unless a gesture holds it. + pub fn pointer_left(&mut self) { + if !self.tool.busy() { + self.tool.overlay.guides.clear(); + self.tool.overlay.measure_box = None; + } + self.vector_pointer_left(); + } + + /// What a marquee starting at page point `p` selects inside: the top-level frame it starts in + /// (its children), or the focus view's frame. + pub(crate) fn marquee_within(&self, p: Point) -> Result> { + let (doc, page) = (self.doc()?, self.page()?); + let within = frame_at(doc, page, p, &[]).filter(|f| doc.node(*f).is_some_and(|n| n.parent == Some(page))); + let within = within.filter(|f| doc.world_bounds(*f).is_some_and(|b| b.contains(p))); + Ok(self.ds()?.focused().or(within)) + } + + /// The selection a marquee over page rect `r` makes from `base` (what was selected before): + /// the layers it touches (`deep`: nested ones too); `additive` (Shift) toggles them. + pub(crate) fn marquee_selection(&self, r: Rect, within: Option, deep: bool, base: &[NodeId], additive: bool) -> Result> { + let (doc, page) = (self.doc()?, self.page()?); + let mut hits = + if deep { figcraft_doc::geometry::marquee_deep(doc, page, r) } else { figcraft_doc::geometry::marquee_select(doc, page, r, within) }; + if let Some(f) = self.ds()?.focused() { + hits.retain(|h| *h != f && doc.is_ancestor(f, *h)); + } + // A marquee over a frame from outside selects the frame only when it covers it fully. + if within.is_none() && !deep { + hits.retain(|h| { + let full = doc.world_bounds(*h).is_some_and(|b| r.contains(b.origin()) && r.contains(Point::new(b.x1, b.y1))); + full || !doc.node(*h).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })) + }); + } + // Shift toggles: what was selected and is touched again comes out. + let mut sel: Vec = base.iter().copied().filter(|b| !(additive && hits.contains(b))).collect(); + for h in hits { + if !base.contains(&h) && !sel.contains(&h) { + sel.push(h); + } + } + Ok(sel) + } + + pub fn set_tool(&mut self, t: ToolId) { + if self.tool.tool == ToolId::Pen && t != ToolId::Pen { + let _ = self.pen_finish(); + } + if !matches!(t, ToolId::Move | ToolId::Scale | ToolId::Hand) && self.ds().is_ok_and(|d| d.vector_edit.is_some()) { + let _ = crate::vectoredit::end(self); + } + self.tool.tool = t; + self.tool.gesture = Gesture::None; + self.tool.pick_only = false; + // The tool's cursor shows before the pointer moves. + self.tool.overlay = + Overlay { builder_remove: self.tool.builder_remove && t == ToolId::ShapeBuilder, cursor: t.cursor(), ..Overlay::default() }; + self.tool.shape_builder = None; + if t == ToolId::ShapeBuilder { + // Regions show straight away, before the pointer moves. + let sel = self.selection(); + let _ = crate::shapebuilder::arrangement(self, &sel); + } + if let Ok(ds) = self.ds_mut() { + if !matches!(t, ToolId::Text | ToolId::TextOnPath | ToolId::Move) { + ds.text_edit = None; + } + ds.revision += 1; + } + } + + /// The tool pointer events go to: in Dev Mode every tool but the hand and comments selects. + fn acting_tool(&self) -> ToolId { + match self.tool.tool { + t if self.tool.dev_mode && !matches!(t, ToolId::Hand | ToolId::Comment) => ToolId::Move, + t => t, + } + } + + /// Drop the gesture under way (a drag, a resize, a shape being drawn), restoring the document + /// as Escape does; the UI calls it when Dev Mode starts, where nothing may move. + pub fn drop_gesture(&mut self) -> Result<()> { + self.tool.gesture = Gesture::None; + self.cancel_interaction() + } + + /// Feed one pointer event to the current tool. + pub fn pointer(&mut self, ev: &PointerEvent) -> Result<()> { + // A held button sends the same move every frame: running the gesture again would only + // rebuild the same preview (a new document, and a canvas render, every frame). + if ev.kind != PointerKind::Move { + self.tool.last_move = None; + } else if self.tool.busy() { + if self.tool.last_move.as_ref() == Some(ev) { + return Ok(()); + } + self.tool.last_move = Some(ev.clone()); + } + let tool = self.acting_tool(); + let edits = matches!(tool, ToolId::Move | ToolId::Scale) && !self.tool.dev_mode; + if edits && self.origin_pointer(ev)? { + return Ok(()); + } + if edits && self.ds().is_ok_and(|d| d.vector_edit.is_some()) && self.vector_pointer(ev)? { + return Ok(()); + } + if tool == ToolId::ShapeBuilder { + return self.shape_builder_pointer(ev); + } + match ev.kind { + PointerKind::Hover => self.hover(ev), + PointerKind::Down => match tool { + ToolId::Move | ToolId::Scale => { + // While a board's vote runs, a click on an object votes. + if tool == ToolId::Move && !self.tool.dev_mode && self.vote_down(ev)? { + return Ok(()); + } + // A click on the canvas deselects a ruler guide (the UI selects guides itself). + self.ds_mut()?.guide = None; + self.move_down(ev) + } + ToolId::Hand => { + let v = &self.ds()?.view; + self.tool.gesture = Gesture::Pan { sx: ev.sx, sy: ev.sy, cx: v.cx, cy: v.cy }; + self.tool.overlay.cursor = Cursor::Grabbing; + Ok(()) + } + ToolId::Text => self.text_down(ev), + ToolId::TextOnPath => self.text_on_path_down(ev), + ToolId::Pen => self.pen_down(ev), + ToolId::Pencil => { + self.tool.gesture = Gesture::Pencil { points: vec![ev.p()], pressure: vec![ev.pressure] }; + Ok(()) + } + ToolId::Comment => { + self.request_ui("comment.new", json!({"x": ev.x, "y": ev.y})); + Ok(()) + } + ToolId::Eyedropper => self.eyedrop(ev), + t if t.on_boards() => self.board_down(ev), + t if t.draws_shape() => self.draw_down(ev), + _ => Ok(()), + }, + PointerKind::Move => self.drag(ev), + PointerKind::Up => self.up(ev), + PointerKind::DoubleClick => self.double_click(ev), + } + } + + /// The rotation origin handle while it shows: a press drags it, a double-click puts it back + /// at the centre. True when the event was the handle's. + fn origin_pointer(&mut self, ev: &PointerEvent) -> Result { + let dragging = matches!(self.tool.gesture, Gesture::Origin); + if !dragging && !self.ds()?.on_origin_handle(ev.p(), ev.zoom) { + return Ok(false); + } + match ev.kind { + PointerKind::Down if !dragging => self.tool.gesture = Gesture::Origin, + PointerKind::Move if dragging => self.drag_origin_to(ev.p(), ev.zoom)?, + PointerKind::Up if dragging => self.tool.gesture = Gesture::None, + PointerKind::DoubleClick => self.set_rotation_origin(None)?, + PointerKind::Hover => {} + _ => return Ok(false), + } + self.tool.overlay.cursor = Cursor::Move; + Ok(true) + } + + pub(super) fn hover(&mut self, ev: &PointerEvent) -> Result<()> { + let tool = self.acting_tool(); + let ds = self.ds()?; + let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); + let mut cursor = tool.cursor(); + // The text being edited, and with the Text tool any text, takes the I-beam: a click there + // puts the caret. + let on_edited = ds + .text_edit + .as_ref() + .is_some_and(|te| doc.world_bounds(te.id).is_some_and(|b| b.inflate(ev.tol() * 4.0, ev.tol() * 4.0).contains(ev.p()))); + let on_text = + |s: &Self| s.hit(&doc, page, ev.p(), ev.tol() * 2.0).and_then(|l| doc.node(l)).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))); + if tool == ToolId::Text && (on_edited || on_text(self)) { + cursor = Cursor::Text; + } + if tool == ToolId::TextOnPath && (on_edited || self.path_at(&doc, page, ev).is_some()) { + cursor = Cursor::Text; + } + let mut hover = None; + let mut guides = Vec::new(); + let mut measure_box = None; + let arc_hover = if self.arc_handles_shown() { crate::arc_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h) } else { None }; + self.tool.overlay.arc_hover = arc_hover; + let shape_hover = if self.arc_handles_shown() && !on_corner_square(&doc, &sel, ev.p(), ev.zoom) { + crate::shape_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h) + } else { + None + }; + self.tool.overlay.shape_hover = shape_hover; + if matches!(tool, ToolId::Move | ToolId::Scale) && on_edited { + cursor = Cursor::Text; + } else if matches!(tool, ToolId::Move | ToolId::Scale) && arc_hover.is_none() && shape_hover.is_none() { + if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom).filter(|_| !self.tool.dev_mode) { + cursor = resize_cursor(&doc, &sel, h); + } else { + let leaf = self.hit(&doc, page, ev.p(), ev.tol() * 2.0); + if leaf.and_then(|l| crate::widgets::target_at(&doc, l, ev.p())).is_some() { + cursor = Cursor::Pointer; + } + hover = label_at(&doc, page, &self.tool.labels, ev.p()).or_else(|| leaf.map(|leaf| select_target(&doc, leaf, &sel, ev.cmd))); + // Alt over a selected layer: a drag from here duplicates it. + if ev.alt && !self.tool.dev_mode && tool == ToolId::Move && hover.is_some_and(|h| sel.contains(&h) && !locked_in(&doc, h)) { + cursor = Cursor::Copy; + } + if ev.alt || self.tool.dev_mode { + use crate::distance::{Hover, guide_at, hover_lines, padding_frame, parent_of}; + // Shift+Alt over an auto layout frame: its padding and gaps. Otherwise from the + // selection to a ruler guide, the hovered layer or (over empty canvas) the parent. + let around: Vec = sel.iter().copied().chain(hover).collect(); + let what = hover.filter(|_| ev.alt && ev.shift).and_then(|t| padding_frame(&doc, t)).map(Hover::Padding).or_else(|| { + if sel.is_empty() { + return None; + } + let guide = guide_at(&doc, page, &around, ev.p(), ev.tol() * 4.0).map(|(vertical, at)| Hover::Guide { vertical, at }); + guide.or_else(|| hover.or_else(|| parent_of(&doc, page, &sel)).map(Hover::Layer)) + }); + if let Some(what) = what { + let (g, b) = hover_lines(&doc, page, &sel, what); + guides = g; + measure_box = b.map(|b| [b.x0, b.y0, b.x1, b.y1]); + } + } + } + } + if tool == ToolId::Move && !self.tool.dev_mode && crate::board::end_handle_at(&doc, &sel, ev.p(), ev.zoom).is_some() { + cursor = Cursor::Grab; + } + self.tool.overlay.measure_box = measure_box; + self.tool.overlay.cursor = cursor; + if tool == ToolId::Pen { + // Where a click would land (snapped), with its guides. + let (to, g) = if ev.shift { (ev.p(), Vec::new()) } else { self.snap_pen_tool(ev)? }; + guides = g; + self.tool.overlay.cursor = Cursor::Pen(self.pen_cursor(&doc, &sel, to, ev.zoom)); + if let Some((last, _, out)) = self.tool.pen.as_ref().and_then(|pen| pen.points.last()) { + // With Shift the band ends where a click puts the point (#970). + let to = if ev.shift { at_45_degrees(*last, to) } else { to }; + self.tool.overlay.rubber = Some([last.x, last.y, to.x, to.y]); + self.tool.overlay.rubber_handle = (out.hypot() > 0.0).then_some([last.x + out.x, last.y + out.y]); + } + } + self.tool.overlay.guides = guides; + let ds = self.ds_mut()?; + if ds.hover != hover { + ds.hover = hover; + ds.revision += 1; + } + Ok(()) + } + + /// What a click with the Pen tool at `p` (where it snaps) would do: close the path on its first + /// point, connect to another of its points, go on from a selected open path's end, or add a + /// new point (the same tests as [`Self::pen_down`]). + fn pen_cursor(&self, doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> PenCursor { + let near = |q: &Point| (*q - p).hypot() * zoom < 8.0; + let on_point = match &self.tool.pen { + None => selected_end_at(doc, sel, p, 8.0 / zoom.max(1e-6)).is_some(), + Some(pen) => { + let n = pen.points.len(); + (n > 2 && pen.points.first().is_some_and(|(q, _, _)| near(q))) + || (1..n.saturating_sub(1)).any(|k| pen.points.get(k).is_some_and(|(q, _, _)| near(q))) + } + }; + if on_point { PenCursor::Close } else { PenCursor::New } + } + + /// Whether what the gesture did to `ids` so far is an edit only a main component can make + /// (`moved`: their place among the layers counts too): it is undone when the gesture ends. + pub(super) fn refused(&self, ids: &[NodeId], moved: bool) -> bool { + let Ok(ds) = self.ds() else { return false }; + ds.interaction.as_ref().is_some_and(|it| { + ids.iter().any(|m| { + (moved && !crate::components::move_allowed(&it.doc, &ds.doc, *m)) + || crate::components::refused_override(&it.doc, &ds.doc, *m).is_some() + }) + }) + } +} From 0bc49705bdd0ba17f4e60bdf5c3dc731ceb5cd4e Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:20:45 +0000 Subject: [PATCH 25/67] refactor(engine): split assets/components.rs into components/ --- crates/engine/src/assets/components.rs | 2078 +---------------- crates/engine/src/assets/components/diff.rs | 218 ++ crates/engine/src/assets/components/guard.rs | 135 ++ .../engine/src/assets/components/instances.rs | 342 +++ crates/engine/src/assets/components/patch.rs | 528 +++++ crates/engine/src/assets/components/rebase.rs | 249 ++ crates/engine/src/assets/components/sync.rs | 459 ++++ .../engine/src/assets/components/variants.rs | 147 ++ 8 files changed, 2099 insertions(+), 2057 deletions(-) create mode 100644 crates/engine/src/assets/components/diff.rs create mode 100644 crates/engine/src/assets/components/guard.rs create mode 100644 crates/engine/src/assets/components/instances.rs create mode 100644 crates/engine/src/assets/components/patch.rs create mode 100644 crates/engine/src/assets/components/rebase.rs create mode 100644 crates/engine/src/assets/components/sync.rs create mode 100644 crates/engine/src/assets/components/variants.rs diff --git a/crates/engine/src/assets/components.rs b/crates/engine/src/assets/components.rs index c85c6b8b..07389419 100644 --- a/crates/engine/src/assets/components.rs +++ b/crates/engine/src/assets/components.rs @@ -37,6 +37,27 @@ use figcraft_doc::*; use figcraft_geom::Affine; use serde_json::Value; +mod diff; +mod guard; +mod instances; +mod patch; +mod rebase; +mod sync; +mod variants; + +pub use diff::{diff_groups, property_owners, set_boolean_default, variable_groups}; +pub use guard::{check_override_edit, check_structure_edit, refused_override, unlink_layers}; +pub(crate) use instances::copy_group; +pub use instances::{PROP_BINDING, copy_in_file, create_instance, create_instance_at, sync_instance}; +pub use patch::sync_after_edit; +#[cfg(test)] +pub(crate) use patch::{FULL_REBUILD, PATCHED}; +#[cfg(test)] +use rebase::RebaseSources; +pub use rebase::rebase_instance; +pub use sync::keep_text_overrides; +pub use variants::{default_variant, exposed_copies, find_variant, mirror_issues, variant_conflicts, variant_name, variant_options, variant_props}; + /// Nesting limit for instances inside instances. const MAX_DEPTH: usize = 16; /// The override group (and `prop_refs` field) of slots. @@ -273,2067 +294,10 @@ pub fn prop_default_value(d: &PropDef) -> Value { } } -/// Create an instance of `component` under `parent` at `(x, y)` (parent space). -pub fn create_instance(doc: &mut Document, component: NodeId, parent: NodeId, x: f64, y: f64) -> Result { - create_instance_at(doc, component, parent, x, y, None) -} - -/// [`create_instance`] at child `index` of `parent` (None: on top). -pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId, x: f64, y: f64, index: Option) -> Result { - let comp = doc.get(component)?.clone(); - if !matches!(comp.kind, NodeKind::Component { .. }) { - return Err(DocError::Invalid(format!("{component} is not a component"))); - } - if doc.is_ancestor(component, parent) { - return Err(DocError::Invalid("an instance can't be placed inside its own main component".into())); - } - let id = doc.alloc_id(); - let mut root = comp.clone(); - root.id = id; - root.name = default_instance_name(doc, component); - root.kind = NodeKind::Instance { component, props: default_props(&comp) }; - root.children.clear(); - root.parent = None; - root.source = Some(component); - root.overrides.clear(); - root.transform = Affine::translate((x, y)); - root.prop_refs.clear(); - root.annotations.clear(); - root.code_links.clear(); - // The main's dev resources show on its instances without being copied. - root.dev_resources.clear(); - // Measurements belong to the layer they were placed on. - root.measurements.clear(); - // The main's interactions play on its instances in prototypes; the instance's own start empty. - root.interactions.clear(); - // So do the variable modes the main sets (see `Document::inherited_modes`). - root.var_modes.clear(); - doc.insert(root, parent, index)?; - sync_instance(doc, id); - Ok(id) -} - -/// Copy `id` within its own file under `parent` (at `index`), keeping its local transform. A file -/// has one main per component, so a main component's copy is an instance of it; a variant copied -/// into its set stays a new variant, and every other layer is deep-copied. -pub fn copy_in_file(doc: &mut Document, id: NodeId, parent: NodeId, index: Option) -> Result { - let n = doc.get(id)?; - let in_set = doc.node(parent).is_some_and(|p| matches!(p.kind, NodeKind::ComponentSet { .. })); - if !matches!(n.kind, NodeKind::Component { .. }) || in_set { - return doc.duplicate_into(id, parent, index); - } - let transform = n.transform; - let instance = create_instance_at(doc, id, parent, 0.0, 0.0, index)?; - doc.get_mut(instance)?.transform = transform; - Ok(instance) -} - -/// Which part of the carried overrides to apply. -#[derive(Clone, Copy, PartialEq)] -enum Carry { - /// Paint, text, visibility, … (everything but geometry). - Values, - /// Values plus size (instance roots: their size decides how children follow constraints). - Root, - /// Size and position (children, after constraints ran). - Geometry, -} - -/// Copy the overridden property groups of `from` onto `to`. -fn carry_overrides(from: &Node, to: &mut Node, what: Carry) { - for g in &from.overrides { - let geo = matches!(g.as_str(), "size" | "position"); - match what { - Carry::Values if geo => continue, - Carry::Root if g == "position" => continue, - Carry::Geometry if !geo => continue, - _ => {} - } - copy_group(from, to, g); - } - if what != Carry::Geometry { - to.overrides = from.overrides.clone(); - } -} - -/// An instance switched from variant `was` to `new`: its resizing (sizing and min / max per axis) -/// that is still `was`'s follows `new`; what the instance set itself stays in `own`. -fn follow_variant_resizing(own: &mut ChildLayout, was: &ChildLayout, new: &ChildLayout) { - if own.sizing_h == was.sizing_h { - own.sizing_h = new.sizing_h; - } - if own.sizing_v == was.sizing_v { - own.sizing_v = new.sizing_v; - } - for (o, w, n) in [ - (&mut own.min_w, was.min_w, new.min_w), - (&mut own.max_w, was.max_w, new.max_w), - (&mut own.min_h, was.min_h, new.min_h), - (&mut own.max_h, was.max_h, new.max_h), - ] { - if *o == w { - *o = n; - } - } -} - -/// Copy the values of property group `g` of `from` onto `to`, with the variables bound to them. -pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) { - if from.bound.iter().chain(to.bound.iter()).any(|(k, _)| binding_group(k) == Some(g)) { - to.bound.retain(|k, _| binding_group(k) != Some(g)); - to.bound.extend(bindings_of(from, g).map(|(k, v)| (k.clone(), v.clone()))); - } - match g { - "fills" => { - to.fills = from.fills.clone(); - to.styles.fill = from.styles.fill.clone(); - } - "strokes" => { - to.strokes = from.strokes.clone(); - to.stroke = from.stroke.clone(); - to.styles.stroke = from.styles.stroke.clone(); - } - "effects" => { - to.effects = from.effects.clone(); - to.styles.effect = from.styles.effect.clone(); - } - "opacity" => { - to.opacity = from.opacity; - to.blend = from.blend; - } - "visible" => to.visible = from.visible, - "name" => to.name = from.name.clone(), - "corners" => to.corners = from.corners, - "characters" => { - if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) { - b.characters = a.characters.clone(); - b.runs = a.runs.clone(); - b.paragraphs = a.paragraphs.clone(); - } - } - "textStyle" => { - if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) { - b.style = a.style.clone(); - b.runs = a.runs.clone(); - b.auto_resize = a.auto_resize; - b.align_h = a.align_h; - b.align_v = a.align_v; - b.paragraph_spacing = a.paragraph_spacing; - b.paragraph_indent = a.paragraph_indent; - b.list = a.list; - b.paragraphs = a.paragraphs.clone(); - b.list_spacing = a.list_spacing; - b.hanging_punctuation = a.hanging_punctuation; - b.hanging_list = a.hanging_list; - b.max_lines = a.max_lines; - b.features = a.features.clone(); - } - to.styles.text = from.styles.text.clone(); - } - "padding" | "gap" | "alignment" => { - if let (Some(a), Some(b)) = (&from.layout, &mut to.layout) { - match g { - "padding" => b.padding = a.padding, - "gap" => (b.gap, b.cross_gap) = (a.gap, a.cross_gap), - _ => (b.main_align, b.cross_align, b.content_align) = (a.main_align, a.cross_align, a.content_align), - } - } - } - "grids" => { - to.grids = from.grids.clone(); - to.styles.grid = from.styles.grid.clone(); - } - "exports" => to.exports = from.exports.clone(), - "interactions" => to.interactions = from.interactions.clone(), - "size" => { - to.width = from.width; - to.height = from.height; - if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) { - b.auto_resize = a.auto_resize; - } - } - "resizing" => { - let (a, b) = (&from.child_layout, &mut to.child_layout); - (b.sizing_h, b.sizing_v, b.min_w, b.max_w, b.min_h, b.max_h) = (a.sizing_h, a.sizing_v, a.min_w, a.max_w, a.min_h, a.max_h); - } - "position" => to.transform = from.transform, - "modes" => to.var_modes = from.var_modes.clone(), - "component" => { - if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&from.kind, &mut to.kind) { - *b = *a; - } - } - "props" => { - if let (NodeKind::Instance { props: a, .. }, NodeKind::Instance { props: b, .. }) = (&from.kind, &mut to.kind) { - *b = a.clone(); - } - carry_prop_bindings(from, to); - } - _ => {} - } -} - -/// Layer name paths below `root` ("Frame/Label", with `#n` for same-named siblings). With -/// `src_names` (an instance's layers), layers are named after the main layer they mirror (renames -/// don't break matching), and the content of slots holding the instance's own is left out. -fn path_map(doc: &Document, root: NodeId, src_names: bool) -> BTreeMap { - let mut out = BTreeMap::new(); - let mut stack = vec![(root, String::new())]; - while let Some((id, prefix)) = stack.pop() { - let Some(n) = doc.node(id) else { continue }; - let mut seen: BTreeMap<&str, usize> = BTreeMap::new(); - for c in &n.children { - let Some(cn) = doc.node(*c) else { continue }; - // A nested instance linked only to its own component so far (an imported one) mirrors no - // layer yet: it goes by its own name, like the main's layer it will be matched with. - let unplaced = matches!(&cn.kind, NodeKind::Instance { component, .. } if cn.source == Some(*component)); - let mirrored = cn.source.filter(|_| src_names && !unplaced).and_then(|s| doc.node(s)); - let name = mirrored.map_or(cn.name.as_str(), |s| s.name.as_str()); - let k = seen.entry(name).or_insert(0); - let seg = if *k == 0 { name.to_string() } else { format!("{name}#{k}") }; - *k += 1; - let path = if prefix.is_empty() { seg } else { format!("{prefix}/{seg}") }; - out.insert(*c, path.clone()); - if out.len() > 1_000_000 { - return out; - } - if !(src_names && holds_content(cn)) { - stack.push((*c, path)); - } - } - } - out -} - -/// The binding key prefix of an instance's component property value -/// (`componentProperties/`): a boolean property takes a boolean variable, a text property a -/// string variable. -pub const PROP_BINDING: &str = "componentProperties/"; - -/// Give `to` the component property bindings of instance `from` (they go with its property -/// values, which an instance keeps across rebuilds). -fn carry_prop_bindings(from: &Node, to: &mut Node) { - to.bound.retain(|k, _| !k.starts_with(PROP_BINDING)); - to.bound.extend(from.bound.iter().filter(|(k, _)| k.starts_with(PROP_BINDING)).map(|(k, v)| (k.clone(), v.clone()))); -} - -/// The override group a property-bound field belongs to. -fn field_group(field: &str) -> &'static str { - match field { - "visible" => "visible", - "characters" => "characters", - "mainComponent" => "component", - _ => "", - } -} - -/// Apply one component-property value to a layer. -fn apply_prop(doc: &Document, n: &mut Node, field: &str, val: &Value) { - match field { - "visible" => { - if let Some(b) = val.as_bool() { - n.visible = b; - } - } - "characters" => { - 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); - } - } - "mainComponent" => { - if let (NodeKind::Instance { component: c, .. }, Some(v)) = (&mut n.kind, crate::params::id_of(val)) - && doc.node(v).is_some_and(|m| matches!(m.kind, NodeKind::Component { .. })) - { - *c = v; - } - } - _ => {} - } -} - -/// Rebuild instance `inst` from its main component, keeping ids, overrides and the content of its -/// slots. An instance whose main component was deleted keeps its layers as they are. -pub fn sync_instance(doc: &mut Document, inst: NodeId) { - // Layers the rebuild leaves as they were keep their shared snapshot (and aren't edits). - let old: Vec<(std::sync::Arc, bool)> = - doc.subtree(inst).into_iter().filter_map(|i| doc.node_arc(i).map(|a| (a.clone(), doc.dirty().contains(&i)))).collect(); - let kept = slot_content(doc, inst); - sync_instance_depth(doc, inst, 0, BTreeMap::new()); - restore_slot_content(doc, kept); - for (a, was_dirty) in old { - doc.keep_if_unchanged(a, was_dirty); - } -} - -/// The slots in instance `inst` (nested instances included) holding the instance's own content: -/// each with its content (the subtrees of its children, pre-order). -fn slot_content(doc: &Document, inst: NodeId) -> Vec<(NodeId, Vec)> { - let mut out = Vec::new(); - let mut stack: Vec = doc.node(inst).map(|n| n.children.clone()).unwrap_or_default(); - while let Some(c) = stack.pop() { - let Some(n) = doc.node(c) else { continue }; - if holds_content(n) { - let nodes = n.children.iter().flat_map(|k| doc.subtree(*k)).filter_map(|d| doc.node(d).cloned()).collect(); - out.push((c, nodes)); - } else { - stack.extend(n.children.iter().copied()); - } - } - out -} - -/// Put slot content back after a rebuild, into the slots that still hold the instance's own -/// content (a slot the main no longer has, or no longer marks as a slot, loses it). Constraints -/// and geometry overrides ran before, so the content keeps its place. -fn restore_slot_content(doc: &mut Document, kept: Vec<(NodeId, Vec)>) { - for (slot, nodes) in kept { - let keep = slot_edited(doc, slot) && is_slot(doc, slot) && nodes.iter().all(|n| !doc.contains(n.id)); - if !keep { - continue; - } - let ids: Vec = nodes.iter().map(|n| n.id).collect(); - for n in nodes { - // Pre-order: every parent is back before its children. - let _ = insert_linked(doc, n); - } - // Never leave links to a layer that couldn't come back. - for id in ids.into_iter().chain([slot]) { - let dangling = doc.node(id).is_some_and(|n| n.children.iter().any(|c| !doc.contains(*c))); - if dangling { - let live: Vec = doc.node(id).map(|n| n.children.iter().copied().filter(|c| doc.contains(*c)).collect()).unwrap_or_default(); - if let Some(n) = doc.node_mut(id) { - n.children = live; - } - } - } - } -} - -/// `spare`: layers the outer rebuild didn't reuse that were below `inst` (a swapped nested -/// instance): its layers the outer main doesn't have, matched here so they keep ids and overrides. -fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BTreeMap>) { - if depth > MAX_DEPTH { - return; - } - let Some(mut root) = doc.node(inst).cloned() else { return }; - let NodeKind::Instance { component, props: root_props } = &root.kind else { return }; - let component = *component; - let Some(comp) = doc.node(component).cloned() else { return }; - if !matches!(comp.kind, NodeKind::Component { .. }) || doc.is_ancestor(inst, component) || doc.is_ancestor(component, inst) { - // A missing main, or an instance inside its own main (would recurse forever). - return; - } - let mut eff_props = default_props(&comp); - for (k, v) in root_props { - if eff_props.contains_key(k) { - eff_props.insert(k.clone(), v.clone()); - } - } - // Old layers (kept alive for their overrides), by (parent, source), by source alone (when only - // one layer mirrors it) and, only when those don't match, by name path. - let mut old_ids = old_layers(doc, inst); - old_ids.extend(spare.keys().copied()); - old_ids.sort(); - old_ids.dedup(); - let mut old: BTreeMap> = spare; - let mut by_source: BTreeMap<(NodeId, NodeId), NodeId> = BTreeMap::new(); - let mut by_src: BTreeMap> = BTreeMap::new(); - for i in &old_ids { - let Some(n) = doc.node_arc(*i).or_else(|| old.get(i)).cloned() else { continue }; - if let Some(s) = n.source { - if let Some(p) = n.parent { - by_source.entry((p, s)).or_insert(*i); - } - by_src.entry(s).and_modify(|e| *e = None).or_insert(Some(*i)); - } - old.insert(*i, n); - } - let mut by_path: Option> = None; - let mut main_paths: Option> = None; - // Ids for the copies: reuse the matching old layer's id (a layer the main moved to another - // parent keeps its id and overrides). - let src: Vec = doc.subtree(component).into_iter().filter(|i| *i != component).collect(); - let mut map: BTreeMap = BTreeMap::new(); - map.insert(component, inst); - let mut used: BTreeSet = BTreeSet::new(); - // Slots holding the instance's own content (new ids), and main layers not copied: the - // default content of those slots. - let mut edited: BTreeSet = BTreeSet::new(); - let mut skipped: BTreeSet = BTreeSet::new(); - let mut fresh_ids = Vec::new(); - for s in &src { - let Some(sn) = doc.node(*s) else { continue }; - if sn.parent.is_some_and(|p| skipped.contains(&p) || map.get(&p).is_some_and(|pn| edited.contains(pn))) { - skipped.insert(*s); - continue; - } - let parent_new = sn.parent.and_then(|p| map.get(&p).copied()); - let mut found = parent_new.and_then(|pn| by_source.get(&(pn, *s)).copied()).or_else(|| by_src.get(s).copied().flatten()); - if found.is_none() { - let paths = by_path.get_or_insert_with(|| path_map(doc, inst, true).into_iter().map(|(k, v)| (v, k)).collect()); - let mains = main_paths.get_or_insert_with(|| path_map(doc, component, false)); - found = mains.get(s).and_then(|p| paths.get(p)).copied(); - } - let nid = match found { - Some(o) if used.insert(o) => o, - _ => { - fresh_ids.push(*s); - continue; - } - }; - if let Some(m) = old.get(&nid).and_then(|o| variant_kept(doc, o, *s)) { - old.insert(nid, std::sync::Arc::new(m)); - } - if old.get(&nid).is_some_and(|o| holds_content(o)) && is_slot(doc, *s) { - edited.insert(nid); - } - map.insert(*s, nid); - } - for c in root.children.clone() { - let _ = doc.remove(c); - } - for s in fresh_ids { - let nid = doc.alloc_id(); - map.insert(s, nid); - } - // Build the copies (pre-order: parents first). - let mut enclosing: BTreeMap> = BTreeMap::new(); - enclosing.insert(inst, vec![inst]); - let mut inst_props: BTreeMap> = BTreeMap::new(); - inst_props.insert(inst, eff_props); - let mut nested = Vec::new(); - let mut new_nodes = Vec::new(); - // Text layers showing other text or style than their main layer (to re-measure). - let mut remeasure = Vec::new(); - for s in &src { - let Some(orig) = doc.node(*s) else { continue }; - let Some(&nid) = map.get(s) else { continue }; - let Some(parent) = orig.parent.and_then(|p| map.get(&p).copied()) else { continue }; - let mut n = orig.clone(); - n.id = nid; - n.parent = Some(parent); - n.children = orig.children.iter().filter_map(|c| map.get(c).copied()).collect(); - n.source = Some(*s); - n.overrides.clear(); - n.prop_refs.clear(); - let prev = old.get(&nid); - // Annotations belong to the layer they were added to, not to its copies. - n.annotations = prev.map(|o| o.annotations.clone()).unwrap_or_default(); - n.measurements = prev.map(|o| o.measurements.clone()).unwrap_or_default(); - if let Some(o) = prev { - carry_overrides(o, &mut n, Carry::Values); - keep_laid_out(doc, orig, o, &mut n); - } - if !edited.contains(&nid) { - // No longer a slot in the main: it mirrors the main again. - n.overrides.remove(SLOT); - } - // Component properties: the main layer's bindings use this instance's values; layers of - // nested instances use the nested instance's values (one level per source hop). - let enc = enclosing.get(&parent).cloned().unwrap_or_default(); - let mut chain: Vec<&Node> = vec![orig]; - while chain.len() < enc.len() { - let Some(next) = chain.last().and_then(|c| c.source).and_then(|x| doc.node(x)) else { break }; - chain.push(next); - } - for (j, level) in chain.iter().enumerate().rev() { - let Some(props) = enc.get(j).and_then(|e| inst_props.get(e)) else { continue }; - for (field, prop) in &level.prop_refs { - let g = field_group(field); - if n.overrides.contains(g) || (j > 0 && orig.overrides.contains(g)) { - continue; - } - if let Some(val) = props.get(prop) { - apply_prop(doc, &mut n, field, val); - } - } - } - if let NodeKind::Instance { component: c, props } = &n.kind { - inst_props.insert(nid, props.clone()); - let mut e = enc; - e.push(nid); - enclosing.insert(nid, e); - if !matches!(orig.kind, NodeKind::Instance { component: oc, .. } if oc == *c) { - nested.push(nid); - } - } else { - enclosing.insert(nid, enc); - } - if let (NodeKind::Text(a), NodeKind::Text(b)) = (&orig.kind, &n.kind) - && a != b - { - remeasure.push(nid); - } - new_nodes.push(n); - } - for n in new_nodes { - let (id, parent) = (n.id, n.parent); - if insert_linked(doc, n).is_err() - && let Some(p) = parent.and_then(|p| doc.node_mut(p)) - { - p.children.retain(|c| *c != id); - } - } - // Root: follows the main except its own placement, name, size and overrides. - let order: Vec = comp.children.iter().filter_map(|c| map.get(c).copied()).filter(|c| doc.contains(*c)).collect(); - let mut fresh = comp.clone(); - fresh.id = inst; - fresh.parent = root.parent; - fresh.children = order; - fresh.transform = root.transform; - fresh.kind = root.kind.clone(); - carry_prop_bindings(&root, &mut fresh); - // A nested instance keeps mirroring its layer of the outer main. - let nested_copy = inside_instance(doc, inst); - fresh.source = if nested_copy { root.source } else { Some(component) }; - fresh.constraints = root.constraints; - fresh.child_layout = root.child_layout.clone(); - fresh.interactions = root.interactions.clone(); - fresh.exports = root.exports.clone(); - fresh.prop_refs = root.prop_refs.clone(); - fresh.plugin_data = root.plugin_data.clone(); - fresh.annotations = root.annotations.clone(); - // Code links belong to the main component; instances read them from it. - fresh.code_links.clear(); - // An instance's own dev resources (its main's are listed with them, not copied). - fresh.dev_resources = root.dev_resources.clone(); - fresh.measurements = root.measurements.clone(); - fresh.locked = root.locked; - fresh.visible = root.visible; - fresh.var_modes = root.var_modes.clone(); - fresh.is_mask = root.is_mask; - fresh.exposed = root.exposed; - fresh.guides = root.guides.clone(); - fresh.dev_status = root.dev_status; - fresh.dev_changed = root.dev_changed; - fresh.dev_note = root.dev_note.clone(); - fresh.constrain_proportions = root.constrain_proportions; - // How the instance combines in a boolean group it sits in is its own. - fresh.bool_op = root.bool_op; - // A swapped nested instance keeps the name its layer was given in the outer main. - let renamed = nested_copy - && root.source.and_then(|s| doc.node(s)).is_some_and(|s| match &s.kind { - NodeKind::Instance { component: c, .. } => s.name != default_instance_name(doc, *c), - _ => false, - }); - fresh.name = if renamed { root.name.clone() } else { default_instance_name(doc, component) }; - // Switched to another variant: its own overrides stay where both variants agreed. - if let Some(prev) = root.source.and_then(|p| doc.node(p)).filter(|p| sibling_variants(doc, p.id, component)) { - let differs = diff_groups(prev, &comp); - root.overrides.retain(|g| g == "name" || !differs.contains(&g.as_str())); - follow_variant_resizing(&mut fresh.child_layout, &prev.child_layout, &comp.child_layout); - } - carry_overrides(&root, &mut fresh, Carry::Root); - if let Some(r) = doc.node_mut(inst) { - *r = fresh; - } - // A resized instance: children follow their constraints (auto layout re-flows later). - let (iw, ih) = doc.node(inst).map(|n| (n.width, n.height)).unwrap_or((comp.width, comp.height)); - if (iw - comp.width).abs() > 1e-9 || (ih - comp.height).abs() > 1e-9 { - figcraft_layout::apply_constraints(doc, inst, comp.width, comp.height); - remeasure = map.values().copied().collect(); - } - // Geometry overrides of children win over constraints. - for s in &src { - let Some(nid) = map.get(s).copied() else { continue }; - let Some(o) = old.get(&nid) else { continue }; - // A frame that kept the size its own resizing gave it: its layers (copied at their main - // layers' places) follow their constraints from its main layer's size. - if o.overrides.contains("resizing") - && !o.overrides.contains("size") - && doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none()) - && let Some((mw, mh)) = doc.node(*s).map(|m| (m.width, m.height)) - { - figcraft_layout::apply_constraints(doc, nid, mw, mh); - } - if !o.overrides.contains("size") && !o.overrides.contains("position") { - continue; - } - remeasure.push(nid); - let Some((ow, oh)) = doc.node(nid).map(|n| (n.width, n.height)) else { continue }; - if o.overrides.contains("size") { - // Through set_size so vector networks scale with the box. - figcraft_layout::set_size(doc, nid, o.width, o.height); - if doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none()) { - figcraft_layout::apply_constraints(doc, nid, ow, oh); - } - } - if let Some(n) = doc.node_mut(nid) { - carry_overrides(o, n, Carry::Geometry); - } - } - // Text whose characters or style differ from the main re-measures. - for nid in remeasure { - if let Some(n) = doc.node(nid) - && let Some((w, h)) = figcraft_text::auto_size(n) - && ((w - n.width).abs() > 1e-6 || (h - n.height).abs() > 1e-6) - { - figcraft_layout::set_size(doc, nid, w, h); - } - } - // Nested instances showing another main than the main's copy (swapped): rebuild from it, - // with the old layers below them this rebuild didn't reuse. - let unused: BTreeMap> = - old.iter().filter(|(i, _)| !used.contains(*i) && !doc.contains(**i)).map(|(i, x)| (*i, x.clone())).collect(); - for n in nested { - let mut below: BTreeMap> = unused - .iter() - .filter(|(_, x)| { - let mut cur = x.parent; - for _ in 0..=MAX_DEPTH * 4 { - match cur { - Some(p) if p == n => return true, - Some(p) => cur = unused.get(&p).and_then(|a| a.parent), - None => return false, - } - } - false - }) - .map(|(i, x)| (*i, x.clone())) - .collect(); - take_placing_overrides(doc, n, &old, &mut below); - sync_instance_depth(doc, n, depth + 1, below); - } -} - -/// Before swapped nested instance `n` is rebuilt from its new main: each of its old layers (the -/// copies just made of the outer main's, and `below`) takes the overrides held along the source -/// chain it had before this rebuild (`before`), through layers inside instances in the mains that -/// place `n`. Matched by name, they carry onto the new main's layers as the instance's own, so a -/// swap takes what the outer main set once: later rebuilds find layers mirroring the new main. -/// Geometry and slot content follow the new main; groups the layer overrides itself keep its value. -fn take_placing_overrides( - doc: &mut Document, - n: NodeId, - before: &BTreeMap>, - below: &mut BTreeMap>, -) { - let placing = placing_mains(doc, n); - for id in doc.subtree(n).into_iter().filter(|i| *i != n) { - let held = before.get(&id).map(|b| held_along(doc, b, &placing)).unwrap_or_default(); - if let Some(layer) = doc.node_mut(id) { - take(layer, &held); - } - } - for layer in below.values_mut() { - let held = held_along(doc, layer, &placing); - if !held.is_empty() { - take(std::sync::Arc::make_mut(layer), &held); - } - } -} - -/// The mains placing nested instance `n`: those of the layers it mirrors, outward. -fn placing_mains(doc: &Document, n: NodeId) -> BTreeSet { - let mut out = BTreeSet::new(); - let mut cur = doc.node(n).and_then(|x| x.source); - for _ in 0..=MAX_DEPTH { - let Some(s) = cur.and_then(|c| doc.node(c)).filter(|s| !matches!(s.kind, NodeKind::Component { .. })) else { break }; - out.extend(owning_component(doc, s.id)); - cur = s.source; - } - out -} - -/// The overrides but geometry and slot content held along `layer`'s source chain while it runs -/// through layers inside instances in `placing` mains, each with the nearest layer holding it. -fn held_along(doc: &Document, layer: &Node, placing: &BTreeSet) -> BTreeMap> { - let mut out = BTreeMap::new(); - let mut cur = layer.source; - for _ in 0..=MAX_DEPTH { - let Some(s) = cur.and_then(|c| doc.node_arc(c)) else { break }; - if !inside_instance(doc, s.id) || !owning_component(doc, s.id).is_some_and(|m| placing.contains(&m)) { - break; - } - for g in s.overrides.iter().filter(|g| !matches!(g.as_str(), "size" | "position" | SLOT)) { - out.entry(g.clone()).or_insert_with(|| s.clone()); - } - cur = s.source; - } - out -} - -/// Give `layer` the `held` groups it has no override of its own for. -fn take(layer: &mut Node, held: &BTreeMap>) { - for (g, from) in held { - if layer.overrides.insert(g.clone()) { - copy_group(from, layer, g); - } - } -} - -/// Whether main components `a` and `b` are two variants of one set. -fn sibling_variants(doc: &Document, a: NodeId, b: NodeId) -> bool { - a != b && variant_set(doc, a).is_some_and(|s| variant_set(doc, b) == Some(s)) -} - -/// Old copy `o` matched by name to main layer `s` of another variant (the instance switched -/// variants): `o` without the overrides of properties the two variants' layers differ in (`None` -/// when it keeps them all). Text layers keep every override. -fn variant_kept(doc: &Document, o: &Node, s: NodeId) -> Option { - let from = o.source.filter(|f| *f != s).and_then(|f| doc.node(f))?; - if o.overrides.is_empty() || matches!(o.kind, NodeKind::Text(_)) { - return None; - } - if !sibling_variants(doc, owning_component(doc, from.id)?, owning_component(doc, s)?) { - return None; - } - let differs = diff_groups(from, doc.node(s)?); - let mut m = o.clone(); - m.overrides.retain(|g| !differs.contains(&g.as_str())); - (m.overrides.len() != o.overrides.len()).then_some(m) -} - -/// Drop the overrides of instance `inst` but its text layers' (and its own size and the content of -/// its slots): what swapping it from the Assets panel keeps. -pub fn keep_text_overrides(doc: &mut Document, inst: NodeId) { - for d in old_layers(doc, inst).into_iter().chain([inst]) { - let Some(n) = doc.node(d) else { continue }; - let text = matches!(n.kind, NodeKind::Text(_)); - let keep = |g: &String| text || g == SLOT || (d == inst && g == "size"); - if !n.overrides.iter().all(keep) - && let Some(m) = doc.node_mut(d) - { - m.overrides.retain(keep); - } - } -} - -/// Where auto layout places and sizes a rebuilt copy `n` of main layer `orig`, keep the old -/// copy's `o` values (an instance's auto layout re-flows from its own content, so they usually -/// differ from the main's): unchanged layers then stay unchanged, and auto layout re-flows wherever -/// something that sizes or places layers did change. -fn keep_laid_out(doc: &Document, orig: &Node, o: &Node, n: &mut Node) { - if o.parent != n.parent || o.overrides.contains("size") || o.overrides.contains("position") { - return; - } - let flows = !n.child_layout.absolute && orig.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()); - if flows { - // The main's rotation and skew, the old place. - let [a, b, c, d, _, _] = n.transform.as_coeffs(); - let [.., e, f] = o.transform.as_coeffs(); - n.transform = Affine::new([a, b, c, d, e, f]); - } - // Only layers whose shape is their box (a vector's points scale with it). - let boxed = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Text(_) | NodeKind::Rectangle | NodeKind::Ellipse { .. }); - let derived = |s: Sizing| boxed && ((s == Sizing::Hug && n.layout.is_some()) || (s == Sizing::Fill && flows)); - if derived(n.child_layout.sizing_h) { - n.width = o.width; - } - if derived(n.child_layout.sizing_v) { - n.height = o.height; - } -} - -/// The layers of instance `inst` a rebuild matches against (every layer below it but the content -/// of slots holding its own), ascending. -fn old_layers(doc: &Document, inst: NodeId) -> Vec { - let mut out = Vec::new(); - let mut stack = vec![inst]; - while let Some(id) = stack.pop() { - let Some(n) = doc.node(id) else { continue }; - for c in &n.children { - out.push(*c); - if out.len() > 1_000_000 { - break; - } - if doc.node(*c).is_some_and(|cn| !holds_content(cn)) { - stack.push(*c); - } - } - } - out.sort(); - out.dedup(); - out -} - -/// Insert a node whose parent/children links are already set (used when rebuilding instances). -fn insert_linked(doc: &mut Document, n: Node) -> Result<(), DocError> { - let parent = n.parent.ok_or_else(|| DocError::Invalid("no parent".into()))?; - let id = n.id; - let mut n = n; - let kids = std::mem::take(&mut n.children); - doc.insert(n, parent, None)?; - if let Some(m) = doc.node_mut(id) { - m.children = kids; - } - // The parent may already list us (its children were copied with ids): keep the first. - if let Some(p) = doc.node_mut(parent) { - let mut seen = BTreeSet::new(); - p.children.retain(|c| seen.insert(*c)); - } - Ok(()) -} - -/// The property groups whose value layer `n` takes from the same variables as its main layer -/// `src` (they differ only where the two resolve in different variable modes). -pub fn variable_groups(n: &Node, src: &Node) -> Vec<&'static str> { - let mut out = Vec::new(); - for (k, v) in &n.bound { - let Some(g) = binding_group(k) else { continue }; - if src.bound.get(k) == Some(v) && !out.contains(&g) { - out.push(g); - } - } - out -} - -/// The override group a variable binding key feeds (`None` for component property and layout -/// grid bindings, which follow other rules). -fn binding_group(key: &str) -> Option<&'static str> { - use crate::cmd::lists::{BoundList, list_binding}; - if key.starts_with(PROP_BINDING) { - return None; - } - match list_binding(key).map(|(l, _, _)| l) { - Some(BoundList::Fills) => Some("fills"), - Some(BoundList::Strokes) => Some("strokes"), - Some(BoundList::Effects) => Some("effects"), - Some(BoundList::Grids) => None, - None => Some(crate::cmd::props::override_group(key)).filter(|g| *g != "other"), - } -} - -/// The variable bindings of `n` that feed group `g`. -fn bindings_of<'a>(n: &'a Node, g: &'a str) -> impl Iterator + 'a { - n.bound.iter().filter(move |(k, _)| binding_group(k) == Some(g)) -} - -/// Property groups that differ between two versions of a node. -pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> { - let mut out = Vec::new(); - if a.fills != b.fills { - out.push("fills"); - } - if a.strokes != b.strokes || a.stroke != b.stroke { - out.push("strokes"); - } - if a.effects != b.effects { - out.push("effects"); - } - if a.opacity != b.opacity || a.blend != b.blend { - out.push("opacity"); - } - if a.visible != b.visible { - out.push("visible"); - } - if a.name != b.name { - out.push("name"); - } - if a.corners != b.corners { - out.push("corners"); - } - if (a.width - b.width).abs() > 1e-6 || (a.height - b.height).abs() > 1e-6 { - out.push("size"); - } - if resizing(a) != resizing(b) { - out.push("resizing"); - } - if a.transform != b.transform { - out.push("position"); - } - if let (NodeKind::Text(x), NodeKind::Text(y)) = (&a.kind, &b.kind) { - if x.characters != y.characters { - out.push("characters"); - } - if text_style_differs(x, y) { - out.push("textStyle"); - } - } - if let (Some(x), Some(y)) = (&a.layout, &b.layout) { - if x.padding != y.padding { - out.push("padding"); - } - if x.gap != y.gap || x.cross_gap != y.cross_gap { - out.push("gap"); - } - if x.main_align != y.main_align || x.cross_align != y.cross_align || x.content_align != y.content_align { - out.push("alignment"); - } - } - if a.grids != b.grids || a.styles.grid != b.styles.grid { - out.push("grids"); - } - if a.exports != b.exports { - out.push("exports"); - } - if a.interactions != b.interactions { - out.push("interactions"); - } - if a.var_modes != b.var_modes { - out.push("modes"); - } - if let (NodeKind::Instance { component: x, props: px }, NodeKind::Instance { component: y, props: py }) = (&a.kind, &b.kind) { - if x != y { - out.push("component"); - } - if px != py { - out.push("props"); - } - } - out -} - -/// The `resizing` group of a layer: how it sizes on each axis, and its min and max sizes. -fn resizing(n: &Node) -> (Sizing, Sizing, [Option; 4]) { - let c = &n.child_layout; - (c.sizing_h, c.sizing_v, [c.min_w, c.max_w, c.min_h, c.max_h]) -} - -/// Whether two texts differ in anything but their characters (the `textStyle` group). -fn text_style_differs(x: &TextData, y: &TextData) -> bool { - x.style != y.style - || x.runs != y.runs - || x.auto_resize != y.auto_resize - || x.align_h != y.align_h - || x.align_v != y.align_v - || x.paragraph_spacing != y.paragraph_spacing - || x.paragraph_indent != y.paragraph_indent - || x.list != y.list - || x.paragraphs != y.paragraphs - || x.list_spacing != y.list_spacing - || x.hanging_punctuation != y.hanging_punctuation - || x.hanging_list != y.hanging_list - || x.max_lines != y.max_lines - || x.features != y.features -} - -/// Whether a geometry change of `id` comes from layout rather than the user: an ancestor was -/// resized in the same edit (constraints), the parent is an auto layout frame, or auto-sized text. -fn geometry_derived(doc: &Document, before: &Document, id: NodeId, group: &str) -> bool { - for a in doc.ancestors(id) { - let (Some(x), Some(y)) = (doc.node(a), before.node(a)) else { continue }; - if (x.width - y.width).abs() > 1e-6 || (x.height - y.height).abs() > 1e-6 { - return true; - } - } - let Some(n) = doc.node(id) else { return true }; - if group == "position" && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()) && !n.child_layout.absolute { - return true; - } - if group == "size" - && let NodeKind::Text(t) = &n.kind - { - let wchanged = before.node(id).is_some_and(|b| (b.width - n.width).abs() > 1e-6); - return match t.auto_resize { - TextAutoResize::WidthAndHeight => true, - TextAutoResize::Height => !wchanged, - _ => false, - }; - } - false -} - -/// The components sharing the properties of component `c`: the variants of its set, or `c` alone. -pub fn property_owners(doc: &Document, c: NodeId) -> Vec { - match variant_set(doc, c).and_then(|s| doc.node(s)) { - Some(set) => set.children.iter().copied().filter(|v| doc.node(*v).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))).collect(), - None => vec![c], - } -} - -/// Make `value` the default of boolean property `name` on components `comps`, showing or hiding -/// their layers bound to it: on a main component, a boolean property's value is the visibility of -/// its layers. Returns the components and layers it changed. -pub fn set_boolean_default(doc: &mut Document, comps: &[NodeId], name: &str, value: bool) -> Vec { - let mut out = Vec::new(); - for &c in comps { - let Some(NodeKind::Component { props, .. }) = doc.node(c).map(|n| &n.kind) else { continue }; - let Some(PropDef::Boolean { default }) = props.get(name) else { continue }; - if *default != value - && let Some(NodeKind::Component { props, .. }) = doc.node_mut(c).map(|n| &mut n.kind) - { - props.insert(name.to_string(), PropDef::Boolean { default: value }); - out.push(c); - } - // The main's own layers (not those of instances nested in it, which follow their mains). - let layers: Vec = doc - .subtree(c) - .into_iter() - .filter(|d| *d != c && !inside_instance(doc, *d)) - .filter(|d| doc.node(*d).is_some_and(|n| n.visible != value && n.prop_refs.get("visible").map(String::as_str) == Some(name))) - .collect(); - for d in layers { - if let Some(n) = doc.node_mut(d) { - n.visible = value; - out.push(d); - } - } - } - out -} - -/// A main component's layer bound to a boolean property was shown or hidden: that is the -/// property's new default, which its other bound layers (in every variant) take too. Returns the -/// components and layers that changed as a result. -fn follow_boolean_defaults(doc: &mut Document, before: &Document, changed: &[NodeId]) -> Vec { - let mut out = Vec::new(); - for &id in changed { - let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue }; - if n.visible == b.visible || inside_instance(doc, id) { - continue; - } - let Some(name) = n.prop_refs.get("visible").cloned() else { continue }; - let visible = n.visible; - let Some(c) = owning_component(doc, id).filter(|c| *c != id) else { continue }; - out.extend(set_boolean_default(doc, &property_owners(doc, c), &name, visible)); - } - out -} - -/// After an edit: record overrides inside instances, then rebuild instances whose main changed, -/// whose properties or main were switched, or whose overrides were reset. -pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId], touched: &BTreeSet) { - let mut changed = changed.to_vec(); - for id in follow_boolean_defaults(doc, before, &changed) { - if !changed.contains(&id) { - changed.push(id); - } - } - let changed = changed.as_slice(); - // 0. A slot whose layers were added, removed or reordered holds the instance's own content - // from now on; copies of instance layers placed elsewhere stop mirroring their main. - for &id in changed { - let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue }; - if n.children == b.children || owning_instance(doc, id).is_none() { - continue; - } - if let Some(slot) = slot_scope(doc, id) - && !slot_edited(doc, slot) - { - own_slot_content(doc, slot); - } - } - for &id in changed { - if !before.contains(id) { - relink_copy(doc, id); - } - } - let mut resync: BTreeSet = BTreeSet::new(); - // 1. Overrides for layers edited inside instances. - for &id in changed { - let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue }; - let Some(inst) = owning_instance(doc, id) else { continue }; - let top_root = inst == id && !inside_instance(doc, id); - if top_root - && let (NodeKind::Instance { component: c1, props: p1 }, NodeKind::Instance { component: c2, props: p2 }) = (&n.kind, &b.kind) - && (c1 != c2 || p1 != p2) - { - // Switching the main or a property value of an instance isn't an override. - resync.insert(id); - } - let raw = diff_groups(b, n); - let mut groups = raw.clone(); - // An instance's own place, resizing, export settings, interactions and variable modes are - // its own, not overrides. - if top_root { - groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions" | "modes")); - } - if groups.iter().any(|g| matches!(*g, "size" | "position")) { - groups.retain(|g| !matches!(*g, "size" | "position") || !geometry_derived(doc, before, id, g)); - } - // A value equal to the main's isn't an override (setting it back clears the override). - let mut cleared = Vec::new(); - if let Some(src) = n.source.and_then(|s| doc.node(s)) { - let mut differs = diff_groups(src, n); - if inst == id { - differs.retain(|g| *g != "name"); - if n.name != default_instance_name(doc, src.id) { - differs.push("name"); - } - } - // A value from the same variable as the main's (resolved in this layer's modes) isn't an - // override either. - let from_variables = variable_groups(n, src); - groups.retain(|g| differs.contains(g) && !from_variables.contains(g)); - // Binding another variable (or none) where the main binds one is an override. - for (k, _) in b.bound.iter().chain(n.bound.iter()) { - let Some(g) = binding_group(k) else { continue }; - let rebound = b.bound.get(k) != n.bound.get(k) && !bindings_of(n, g).eq(bindings_of(src, g)); - if rebound && !groups.contains(&g) { - groups.push(g); - } - } - cleared = - raw.iter().filter(|g| !matches!(**g, "size" | "position") && !differs.contains(g) && n.overrides.contains(**g)).copied().collect(); - if top_root { - cleared.retain(|g| !matches!(*g, "component" | "props")); - } - } - let lost = b.overrides.iter().any(|g| !n.overrides.contains(g)); - let nested_switch = !top_root && raw.iter().any(|g| matches!(*g, "component" | "props")); - if (!groups.is_empty() || !cleared.is_empty()) - && let Some(m) = doc.node_mut(id) - { - for g in groups { - m.overrides.insert(g.to_string()); - } - for g in cleared { - m.overrides.remove(g); - } - } - if (lost || nested_switch) - && let Some(r) = root_instance(doc, id) - { - resync.insert(r); - } - } - // 2. Components whose layers changed, were added or removed. Value-only edits (paint, text, - // visibility, …) are carried to the copies directly; anything else rebuilds the instances. - let mut comps: BTreeSet = BTreeSet::new(); - let mut patches: Vec<(NodeId, Vec<&'static str>)> = Vec::new(); - // Main layers that only gained new layers or lost some: their copies take the same change. - let mut restructured: Vec = Vec::new(); - let mut covered: BTreeSet = BTreeSet::new(); - for &id in changed { - if let (Some(b), Some(n)) = (before.node(id), doc.node(id)) - && b.children != n.children - && owning_instance(doc, id).is_none() - && owning_component(doc, id).is_some() - && let Some(layers) = restructurable(doc, before, b, n) - { - restructured.push(id); - covered.extend(layers); - } - } - for &id in changed { - if covered.contains(&id) || restructured.contains(&id) { - continue; - } - let Some(c) = owning_component(doc, id) else { continue }; - let (Some(b), Some(n)) = (before.node(id), doc.node(id)) else { - comps.insert(c); - continue; - }; - if b == n { - continue; - } - match patchable(doc, b, n) { - Some(groups) => patches.push((id, groups)), - None => { - comps.insert(c); - } - } - } - for &id in touched { - if !doc.contains(id) - && !covered.contains(&id) - && let Some(c) = owning_component(before, id) - && doc.contains(c) - { - comps.insert(c); - } - } - for r in resync { - if doc.contains(r) { - sync_instance(doc, r); - if let Some(c) = owning_component(doc, r) { - comps.insert(c); - } - } - } - // A copy the patch can't follow exactly rebuilds instead (as does any other change). - let patched = comps.is_empty() - && !full_rebuild() - && (restructured.is_empty() || patch_structure(doc, before, &restructured)) - && (patches.is_empty() || patch_copies(doc, &patches)); - #[cfg(test)] - if patched && (!restructured.is_empty() || !patches.is_empty()) { - PATCHED.with(|c| c.set(c.get() + 1)); - } - if !patched { - comps.extend(patches.iter().map(|(id, _)| *id).chain(restructured.iter().copied()).filter_map(|id| owning_component(doc, id))); - } - // 3. Instances of changed components follow; an instance inside another component makes that - // one change too. - rebuild_instances(doc, &comps); -} - -#[cfg(test)] -thread_local! { - /// Tests: rebuild instances even for edits that could be patched (to compare both ways). - pub(crate) static FULL_REBUILD: std::cell::Cell = const { std::cell::Cell::new(false) }; - /// Tests: edits carried to the copies without rebuilding instances. - pub(crate) static PATCHED: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - -#[cfg(test)] -fn full_rebuild() -> bool { - FULL_REBUILD.with(std::cell::Cell::get) -} - -#[cfg(not(test))] -fn full_rebuild() -> bool { - false -} - -/// The property groups of a main-component layer edited from `b` to `n` when the edit changed -/// values only (paint, text, visibility, size, position, …) that copies take as they are: carried to them without -/// rebuilding the instances ([`patch_copies`]). `None` when anything else changed. -fn patchable(doc: &Document, b: &Node, n: &Node) -> Option> { - const VALUES: [&str; 17] = [ - "fills", - "strokes", - "effects", - "opacity", - "visible", - "name", - "corners", - "characters", - "textStyle", - "padding", - "gap", - "alignment", - "grids", - "exports", - "interactions", - "size", - "position", - ]; - if b.children != n.children || b.parent != n.parent || b.kind.type_name() != n.kind.type_name() { - return None; - } - let groups = diff_groups(b, n); - if groups.is_empty() || groups.iter().any(|g| !VALUES.contains(g)) { - return None; - } - // Exactly those groups (and the layer's own overrides) changed. - let mut probe = n.clone(); - for g in &groups { - copy_group(b, &mut probe, g); - } - probe.overrides = b.overrides.clone(); - if probe != *b { - return None; - } - // Values a component property sets (on this layer or a main it mirrors) come from instances. - let mut cur = Some(n); - for _ in 0..=MAX_DEPTH { - let Some(x) = cur else { break }; - if x.prop_refs.keys().any(|f| groups.contains(&field_group(f))) { - return None; - } - cur = x.source.and_then(|s| doc.node(s)); - } - Some(groups) -} - -/// Copies by the layer they mirror; nested instances swapped to a main also follow it. -fn copies_index(doc: &Document) -> BTreeMap> { - let mut copies: BTreeMap> = BTreeMap::new(); - for n in doc.nodes() { - let Some(s) = n.source else { continue }; - copies.entry(s).or_default().push(n.id); - if let (NodeKind::Instance { component, .. }, Some(NodeKind::Instance { component: other, .. })) = (&n.kind, doc.node(s).map(|x| &x.kind)) - && component != other - { - copies.entry(*component).or_default().push(n.id); - } - } - copies -} - -/// When main layer `b` → `n` only gained new plain layers (created in this edit: no instances, -/// components or property bindings in them) or lost layers that were deleted: those layers, which -/// [`patch_structure`] carries to the copies. `None` for anything else. -fn restructurable(doc: &Document, before: &Document, b: &Node, n: &Node) -> Option> { - let mut probe = n.clone(); - probe.children = b.children.clone(); - probe.overrides = b.overrides.clone(); - if probe != *b { - return None; - } - let mut layers = Vec::new(); - for c in n.children.iter().filter(|c| !b.children.contains(c)) { - for d in doc.subtree(*c) { - let x = doc.node(d)?; - let plain = !matches!(x.kind, NodeKind::Instance { .. } | NodeKind::Component { .. } | NodeKind::ComponentSet { .. }); - if before.contains(d) || !plain || !x.prop_refs.is_empty() { - return None; - } - layers.push(d); - } - } - for c in b.children.iter().filter(|c| !n.children.contains(c)) { - if doc.contains(*c) { - return None; - } - layers.extend(before.subtree(*c)); - } - Some(layers) -} - -/// Carry the new and deleted layers of main layers `seeds` ([`restructurable`]) to every copy, -/// through copies of copies (slots holding an instance's own content keep it). Returns false -/// (leaving a partial patch for the caller to rebuild over) when a copy can't follow exactly. -fn patch_structure(doc: &mut Document, before: &Document, seeds: &[NodeId]) -> bool { - let copies = copies_index(doc); - let mut work: Vec = seeds.to_vec(); - let mut steps = 0usize; - while let Some(p) = work.pop() { - steps += 1; - if steps > 10_000_000 { - return false; - } - let Some(pn) = doc.node(p).cloned() else { continue }; - for c in copies.get(&p).cloned().unwrap_or_default() { - let Some(cn) = doc.node(c).cloned() else { continue }; - if holds_content(&cn) { - continue; - } - if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&pn.kind, &cn.kind) - && a != b - { - continue; - } - // The copy's layers by the layer of `p` they mirror. - let mut mirrors: BTreeMap = BTreeMap::new(); - for k in &cn.children { - let Some(src) = doc.node(*k).and_then(|x| x.source) else { return false }; - if mirrors.insert(src, *k).is_some() { - return false; - } - } - let mut order = Vec::with_capacity(pn.children.len()); - for pc in &pn.children { - if let Some(k) = mirrors.remove(pc) { - order.push(k); - continue; - } - // A layer the copy lacks must be new in this edit, placed as in the main. - let absolute = doc.node(*pc).is_some_and(|x| x.child_layout.absolute); - if before.contains(*pc) || !child_follows_main(doc, c, absolute) { - return false; - } - match copy_layers(doc, *pc, c) { - Some(k) => order.push(k), - None => return false, - } - } - // Copies of layers the main lost. - for (src, k) in mirrors { - if doc.contains(src) || doc.remove(k).is_err() { - return false; - } - } - if doc.node(c).is_some_and(|x| x.children != order) - && let Some(m) = doc.node_mut(c) - { - m.children = order; - } - work.push(c); - } - } - true -} - -/// Whether a layer placed in copy `parent` (absolutely positioned or not) takes its main layer's -/// place and size: nothing on the way up applies constraints ([`follows_main_geometry`]). -fn child_follows_main(doc: &Document, parent: NodeId, absolute: bool) -> bool { - let Some(p) = doc.node(parent) else { return false }; - let constrains = p.layout.is_none() || absolute; - let resized = sized_own(p) - || match &p.kind { - NodeKind::Instance { component, .. } => { - doc.node(*component).is_none_or(|m| (m.width - p.width).abs() > 1e-9 || (m.height - p.height).abs() > 1e-9) - } - _ => false, - }; - !(constrains && resized) && follows_main_geometry(doc, parent) -} - -/// Copy main layer `src` (with its layers) into copy `parent` as layers mirroring them; returns the -/// new copy (the caller places it among `parent`'s layers). -fn copy_layers(doc: &mut Document, src: NodeId, parent: NodeId) -> Option { - let sub = doc.subtree(src); - let map: BTreeMap = sub.iter().map(|d| (*d, doc.alloc_id())).collect(); - for d in &sub { - let mut n = doc.node(*d)?.clone(); - n.id = *map.get(d)?; - n.parent = if *d == src { Some(parent) } else { n.parent.and_then(|x| map.get(&x).copied()) }; - n.children = n.children.iter().filter_map(|k| map.get(k).copied()).collect(); - n.source = Some(*d); - n.overrides.clear(); - n.prop_refs.clear(); - n.annotations.clear(); - n.measurements.clear(); - insert_linked(doc, n).ok()?; - } - map.get(&src).copied() -} - -/// Carry value-only edits of main-component layers (`seeds`: layer, groups) to every copy, through -/// copies of copies, except where a copy overrides the group. Returns false (leaving a partial -/// patch for the caller to rebuild over) when a copy can't follow exactly. -fn patch_copies(doc: &mut Document, seeds: &[(NodeId, Vec<&'static str>)]) -> bool { - let copies = copies_index(doc); - let text = |g: &str| matches!(g, "characters" | "textStyle"); - let mut work: Vec<(NodeId, Vec<&'static str>)> = seeds.to_vec(); - let mut steps = 0usize; - while let Some((p, groups)) = work.pop() { - steps += 1; - if steps > 10_000_000 { - return false; - } - let Some(pn) = doc.node(p).cloned() else { continue }; - for c in copies.get(&p).cloned().unwrap_or_default() { - let Some(cn) = doc.node(c) else { continue }; - // A swapped nested instance shows its own main, not the layer it stands in for. - if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&pn.kind, &cn.kind) - && a != b - { - continue; - } - // An instance keeps its own visibility, place, export settings and interactions; its - // name follows the main's unless renamed; a resized one lays its layers out against - // the main's size. - let root = matches!(pn.kind, NodeKind::Component { .. }); - if root && ((groups.contains(&"name") && !cn.overrides.contains("name")) || (groups.contains(&"size") && cn.overrides.contains("size"))) { - return false; - } - let gs: Vec<&'static str> = groups - .iter() - .copied() - .filter(|g| !(cn.overrides.contains(*g) || root && matches!(*g, "visible" | "name" | "position" | "exports" | "interactions"))) - .collect(); - if gs.is_empty() { - continue; - } - let geometry = gs.iter().any(|g| matches!(*g, "size" | "position")); - if geometry && !follows_main_geometry(doc, c) { - return false; - } - // Text content and style share their runs: a copy overriding one keeps its runs when the - // other follows the main (as a rebuild carries them). - let runs = match &cn.kind { - NodeKind::Text(t) if gs.iter().any(|g| text(g)) && cn.overrides.iter().any(|g| text(g)) => Some(t.runs.clone()), - _ => None, - }; - if let Some(m) = doc.node_mut(c) { - for g in &gs { - copy_group(&pn, m, g); - } - if let (Some(r), NodeKind::Text(t)) = (runs, &mut m.kind) { - t.runs = r; - } - } - if (geometry || gs.iter().any(|g| text(g))) - && let Some((w, h)) = doc.node(c).and_then(figcraft_text::auto_size) - && doc.node(c).is_some_and(|n| (n.width - w).abs() > 1e-6 || (n.height - h).abs() > 1e-6) - { - figcraft_layout::set_size(doc, c, w, h); - } - work.push((c, gs)); - } - } - true -} - -/// Whether a copy's size and place are its main layer's: no layer around it inside its instances -/// applies constraints on the way down to it (a resized instance or frame without auto layout, or -/// a resized auto layout instance holding it absolutely positioned). Auto layout re-flows the rest. -fn follows_main_geometry(doc: &Document, id: NodeId) -> bool { - let chain = scope(doc, id); - chain.windows(2).all(|w| { - let (Some(below), Some(a)) = (w.first().and_then(|i| doc.node(*i)), w.get(1).and_then(|i| doc.node(*i))) else { return false }; - let constrains = a.layout.is_none() || below.child_layout.absolute; - if sized_own(a) && constrains { - return false; - } - match &a.kind { - NodeKind::Instance { component, .. } => { - !constrains || doc.node(*component).is_some_and(|m| (m.width - a.width).abs() <= 1e-9 && (m.height - a.height).abs() <= 1e-9) - } - _ => true, - } - }) -} - -/// Whether a layer inside an instance overrides its size or resizing: it may not have its main -/// layer's size, so the layers in it can't take their main layers' places as they are. -fn sized_own(n: &Node) -> bool { - n.overrides.contains("size") || n.overrides.contains("resizing") -} - -/// Rebuild every instance of `comps` (and of the components that hold those instances, and so on -/// outwards), each outermost instance once, inner components first. -fn rebuild_instances(doc: &mut Document, comps: &BTreeSet) { - if comps.is_empty() { - return; - } - // Outermost instances by the components their layers instantiate. - let mut roots_of: BTreeMap> = BTreeMap::new(); - for n in doc.nodes() { - if let NodeKind::Instance { component, .. } = &n.kind { - roots_of.entry(*component).or_default().insert(root_instance(doc, n.id).unwrap_or(n.id)); - } - } - // Affected components, with how deep their dependencies go (an instance inside component X of - // component Y: X follows Y). - let mut level: BTreeMap = comps.iter().map(|c| (*c, 0)).collect(); - let mut outer: BTreeMap> = BTreeMap::new(); - let mut frontier: Vec = comps.iter().copied().collect(); - let mut rounds = 0; - while !frontier.is_empty() && rounds < 64 { - rounds += 1; - let mut next = Vec::new(); - for c in frontier { - let lc = level.get(&c).copied().unwrap_or(0); - for r in roots_of.get(&c).into_iter().flatten() { - let o = *outer.entry(*r).or_insert_with(|| owning_component(doc, *r)); - if let Some(x) = o - && x != c - { - let lx = level.entry(x).or_insert(0); - if *lx < lc + 1 { - *lx = lc + 1; - next.push(x); - } - } - } - } - frontier = next; - } - // Instances inside components by level, then the rest (each once). - let mut order: Vec<(usize, NodeId)> = outer.iter().map(|(r, o)| (o.and_then(|x| level.get(&x).copied()).unwrap_or(usize::MAX), *r)).collect(); - order.sort(); - for (_, r) in order { - if doc.contains(r) { - sync_instance(doc, r); - } - } -} - -/// Slot `slot` (inside an instance) takes the instance's own content: the layers it shows (the -/// main's default content until now) stop mirroring the main, and instances among them stand -/// alone, linked to their own mains. -fn own_slot_content(doc: &mut Document, slot: NodeId) { - if let Some(n) = doc.node_mut(slot) { - n.overrides.insert(SLOT.into()); - } - for nested in unlink_layers(doc, slot, None) { - rebase_instance(doc, nested); - } -} - -/// Unlink the layers below `top` that are layers of `owner` (an instance, or `None` for the -/// content of a slot): plain layers forget the main layer they mirrored and their property -/// bindings. The instances among them are returned, to re-link to their own mains (with -/// [`rebase_instance`]) once the caller is done. -pub fn unlink_layers(doc: &mut Document, top: NodeId, owner: Option) -> Vec { - let picked: Vec<(NodeId, bool)> = - doc.subtree(top).into_iter().filter(|d| *d != top && enclosing_instance(doc, *d) == owner).map(|d| (d, is_instance(doc, d))).collect(); - let mut nested = Vec::new(); - for (d, instance) in picked { - let Some(n) = doc.node_mut(d) else { continue }; - n.prop_refs.clear(); - if instance { - nested.push(d); - } else { - n.source = None; - n.overrides.clear(); - } - } - nested -} - -/// A new layer copied from inside an instance (pasted, duplicated) to where no instance owns it -/// stops mirroring the main; a copied nested instance is re-linked to its own main (its look kept). -fn relink_copy(doc: &mut Document, id: NodeId) { - let Some(n) = doc.node(id) else { return }; - match &n.kind { - NodeKind::Instance { component, .. } => { - let main_ok = doc.node(*component).is_some_and(|m| matches!(m.kind, NodeKind::Component { .. })); - if main_ok && !inside_instance(doc, id) && (n.source != Some(*component) || !instance_issues(doc, id).is_empty()) { - rebase_instance(doc, id); - } - } - _ => { - if (n.source.is_some() || !n.overrides.is_empty()) - && owning_instance(doc, id).is_none() - && let Some(m) = doc.node_mut(id) - { - m.source = None; - m.overrides.clear(); - } - } - } -} - -/// Refuse edits that add, remove or reorder layers inside an instance (only its main can), outside -/// its slots. -pub fn check_structure_edit(before: &Document, after: &Document) -> Result<(), String> { - for &id in after.dirty() { - let (Some(n), Some(b)) = (after.node(id), before.node(id)) else { continue }; - // Library copies change only through library updates (and are read-only otherwise). - if n.children == b.children || after.is_remote(id) { - continue; - } - if structure_locked(before, id) && structure_locked(after, id) { - let name = owning_instance(after, id).and_then(|i| after.node(i)).map(|i| i.name.clone()).unwrap_or_default(); - return Err(format!( - "layers can't be added, removed or reordered inside the instance \"{name}\": edit its main component, or detach the instance first" - )); - } - } - Ok(()) -} - -/// Refuse edits an instance doesn't take as overrides ([`refused_override`]); the edit is rolled -/// back. -pub fn check_override_edit(before: &Document, after: &Document) -> Result<(), String> { - match after.dirty().iter().find_map(|id| refused_override(before, after, *id)) { - Some(what) => Err(format!("{what} comes from the main component in an instance: edit the main component, or detach the instance")), - None => Ok(()), - } -} - -/// What about layer `id` an edit from `before` to `after` changed that only its main can: the -/// auto layout of an instance or a layer in one but its padding, gap and alignment, or the -/// position (rotation too), constraints or place in auto layout (absolute, grid cell) of a layer -/// inside an instance (its size and resizing are overrides). `None` when the edit is fine, also -/// when the main changed the same way in this edit (a library update). -pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Option<&'static str> { - let (n, b) = (after.node(id)?, before.node(id)?); - if after.is_remote(id) || n.source != b.source || owning_instance(before, id).is_none() || owning_instance(after, id).is_none() { - return None; - } - let (mb, ma) = (before.node(n.source?)?, after.node(n.source?)?); - let mode = |x: &Node| x.layout.as_ref().map(|l| l.mode); - // Auto layout settings other than the overridable ones. - let settings = |x: &Node| { - x.layout.clone().map(|mut l| { - (l.gap, l.cross_gap, l.padding, l.main_align, l.cross_align) = (0.0, 0.0, [0.0; 4], MainAlign::Min, CrossAlign::Min); - l.content_align = ContentAlign::Auto; - l - }) - }; - // Child layout settings other than the resizing ones. - let placing = |x: &Node| { - let mut c = x.child_layout.clone(); - (c.sizing_h, c.sizing_v, c.min_w, c.max_w, c.min_h, c.max_h) = (Sizing::Fixed, Sizing::Fixed, None, None, None, None); - c - }; - if mode(n) != mode(b) && mode(ma) == mode(mb) { - return Some("The auto layout direction"); - } - if settings(n) != settings(b) && settings(ma) == settings(mb) { - return Some("This auto layout setting"); - } - if !inside_instance(after, id) || !inside_instance(before, id) { - return None; - } - if n.transform != b.transform && ma.transform == mb.transform && !geometry_derived(after, before, id, "position") { - return Some("A layer's position"); - } - if n.constraints != b.constraints && ma.constraints == mb.constraints { - return Some("A layer's constraints"); - } - if placing(n) != placing(b) && placing(ma) == placing(mb) { - return Some("A layer's place in auto layout"); - } - None -} - -/// Bounded source and parent lookups while a rebase reads an unchanged document. -struct RebaseSources<'a> { - doc: &'a Document, - chains: BTreeMap>>, - parents: BTreeMap<(NodeId, NodeId), Option>, - main_changes: BTreeMap, -} - -impl<'a> RebaseSources<'a> { - const PARENTS: usize = 512; - const CACHE: usize = 8192; - - fn new(doc: &'a Document) -> Self { - Self { doc, chains: BTreeMap::new(), parents: BTreeMap::new(), main_changes: BTreeMap::new() } - } - - fn chain(&mut self, source: NodeId) -> Option> { - if let Some(chain) = self.chains.get(&source) { - return chain.clone(); - } - let read = || { - let mut chain = Vec::new(); - let mut seen = BTreeSet::new(); - let mut current = Some(source); - for _ in 0..MAX_DEPTH { - let Some(id) = current else { return Some(chain) }; - if !seen.insert(id) { - return None; - } - let n = self.doc.node(id)?; - chain.push(id); - current = n.source; - } - current.is_none().then_some(chain) - }; - let chain = read(); - if self.chains.len() < Self::CACHE { - self.chains.insert(source, chain.clone()); - } - chain - } - - /// Validate the complete parent chain, even if `main` was found before a cycle. - fn in_main(&mut self, id: NodeId, main: NodeId) -> Option { - if let Some(found) = self.parents.get(&(id, main)) { - return *found; - } - let read = || { - let mut current = Some(id); - let mut seen = BTreeSet::new(); - let mut found = false; - for _ in 0..Self::PARENTS { - let Some(id) = current else { return Some(found) }; - if !seen.insert(id) { - return None; - } - found |= id == main; - current = self.doc.node(id)?.parent; - } - current.is_none().then_some(found) - }; - let found = read(); - if self.parents.len() < Self::CACHE { - self.parents.insert((id, main), found); - } - found - } - - fn member(&mut self, source: NodeId, main: NodeId, target: NodeId) -> Option { - let chain = self.chain(source)?; - if chain.contains(&target) { - return None; - } - let mut member = None; - for id in chain { - if self.in_main(id, main)? && id != main && member.is_none() { - member = Some(id); - } - } - member - } - - /// Nearest enclosing instances and their current mains. Slots with own content end scope. - fn owners(&self, id: NodeId) -> Option> { - let mut owners = Vec::new(); - let mut current = self.doc.node(id)?.parent; - let mut seen = BTreeSet::from([id]); - for _ in 0..Self::PARENTS { - let Some(id) = current else { return Some(owners) }; - if !seen.insert(id) { - return None; - } - let n = self.doc.node(id)?; - if holds_content(n) { - return Some(owners); - } - if let NodeKind::Instance { component, .. } = &n.kind { - owners.push((id, *component)); - } - current = n.parent; - } - current.is_none().then_some(owners) - } - - fn contextual(&mut self, source: NodeId, owners: &[(NodeId, NodeId)]) -> bool { - owners.iter().any(|(_, main)| self.in_main(source, *main) == Some(true) && source != *main) - } - - /// A contextual child belongs to the same placed slot as its enclosing instance. - fn valid(&mut self, source: NodeId, target: NodeId, owners: &[(NodeId, NodeId)]) -> bool { - let Some((owner, main)) = owners.first() else { return false }; - if self.member(source, *main, target).is_none() { - return false; - } - if self.in_main(source, *main) == Some(true) { - return true; - } - let Some(source_owner) = self.doc.node(*owner).and_then(|n| n.source) else { return false }; - matches!(self.doc.node(source_owner).map(|n| &n.kind), Some(NodeKind::Instance { component, .. }) if component == main) - && self.owners(source).and_then(|owners| owners.first().copied()).is_some_and(|(id, _)| id == source_owner) - } - - fn placed(&mut self, id: NodeId, owners: &[(NodeId, NodeId)]) -> Option { - let source = self.doc.node(id)?.source?; - if !matches!(self.doc.node(source)?.kind, NodeKind::Instance { .. } | NodeKind::Frame) - || !self.valid(source, id, owners) - || !self.contextual(source, owners) - { - return None; - } - Some(source) - } - - fn beneath_swap(&mut self, owners: &[(NodeId, NodeId)], stop: NodeId) -> bool { - for (index, (id, main)) in owners.iter().enumerate() { - if *id == stop { - break; - } - let changed = if let Some(changed) = self.main_changes.get(id) { - *changed - } else { - let changed = self.placed(*id, &owners[index + 1..]).is_some_and(|source| match self.doc.node(source).map(|n| &n.kind) { - Some(NodeKind::Instance { component, .. }) => component != main, - Some(NodeKind::Frame) => true, - _ => false, - }); - if self.main_changes.len() < Self::CACHE { - self.main_changes.insert(*id, changed); - } - changed - }; - if changed { - return true; - } - } - false - } -} - -/// Re-derive `source` links and overrides of instance `inst` against its own main by comparing -/// values (after detaching an outer instance, restoring a main, or copying a nested instance out): -/// the look is kept exactly. Values a component property sets aren't overrides. -pub fn rebase_instance(doc: &mut Document, inst: NodeId) { - let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else { return }; - let mine = path_map(doc, inst, true); - let theirs: BTreeMap = - if doc.contains(component) { path_map(doc, component, false).into_iter().map(|(k, v)| (v, k)).collect() } else { BTreeMap::new() }; - let value_groups = |a: &Node, b: &Node| -> Vec { - diff_groups(a, b).into_iter().filter(|g| !matches!(*g, "size" | "position")).map(str::to_string).collect() - }; - let mut updates = Vec::new(); - let mut sources = RebaseSources::new(doc); - let root_owners = sources.owners(inst).unwrap_or_default(); - let placed_source = sources.placed(inst, &root_owners); - let mut contexts = vec![(inst, component)]; - contexts.extend_from_slice(&root_owners); - for (id, path) in &mine { - let Some(n) = doc.node(*id) else { continue }; - let owners = sources.owners(*id).unwrap_or_default(); - let nearest_main = owners.first().map(|(_, main)| *main); - let valid_source = n.source.filter(|source| sources.valid(*source, *id, &owners)); - let mut main = valid_source.and_then(|source| sources.member(source, component, *id)); - // A swapped or Frame-to-Instance subtree is compared to its current main by the inner - // rebase. An outer name path must not rebind those children to the old expanded copies. - if main.is_none() && valid_source.is_some() && sources.beneath_swap(&owners, inst) { - updates.push((*id, valid_source, n.overrides.clone())); - continue; - } - main = main - .or_else(|| theirs.get(path).copied().filter(|source| nearest_main.is_some_and(|scope| sources.member(*source, scope, *id).is_some()))); - let source = valid_source.filter(|source| sources.contextual(*source, &contexts)).filter(|_| main.is_some()).or(main); - let mut overrides: BTreeSet = n.overrides.iter().filter(|g| !matches!(g.as_str(), "component" | "props")).cloned().collect(); - if let Some(m) = source.and_then(|source| doc.node(source)) { - let mut bound: Vec<&str> = m.prop_refs.keys().map(|f| field_group(f)).collect(); - bound.extend(variable_groups(n, m)); - overrides.extend(value_groups(m, n).into_iter().filter(|g| !bound.contains(&g.as_str()))); - if matches!((&m.kind, &n.kind), (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) if a != b) { - overrides.insert("component".into()); - } - } - updates.push((*id, source, overrides)); - } - // A placed nested root compares to its actual slot. A detached root compares to its own main. - let root_base = placed_source.or_else(|| { - let chain = sources.chain(component)?; - (!chain.contains(&inst) && sources.in_main(component, inst) == Some(false)).then_some(component) - }); - let root_groups = match (root_base.and_then(|source| doc.node(source)), doc.node(inst)) { - (Some(m), Some(n)) => { - let from_variables = variable_groups(n, m); - let mut groups: Vec = value_groups(m, n) - .into_iter() - .filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str())) - .filter(|g| placed_source.is_some() || !matches!(g.as_str(), "resizing" | "modes")) - .collect(); - let named = if placed_source.is_some() { n.name != m.name } else { n.name != default_instance_name(doc, component) }; - if named { - groups.push("name".into()); - } - if placed_source.is_some() - && matches!((&m.kind, &n.kind), (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) if a != b) - { - groups.push("component".into()); - } - groups - } - _ => Vec::new(), - }; - drop(sources); - for (id, main, overrides) in updates { - if let Some(n) = doc.node_mut(id) { - n.source = main; - n.overrides = if main.is_some() { overrides } else { BTreeSet::new() }; - } - } - if let Some(n) = doc.node_mut(inst) { - n.source = root_base; - // A top-level instance's resizing and modes are its own (a nested one's override its main - // layer's). - n.overrides.retain(|g| { - !matches!(g.as_str(), "component" | "props" | "position") && (placed_source.is_some() || !matches!(g.as_str(), "resizing" | "modes")) - }); - n.overrides.extend(root_groups); - } -} - #[cfg(test)] #[path = "components_rebase_source_tests.rs"] mod rebase_source_tests; -/// A set's default variant: the one in its top-left corner (nearest to it, then the top-most, then -/// the left-most). -pub fn default_variant(doc: &Document, set: NodeId) -> Option { - let corner = |c: &NodeId| { - doc.node(*c).filter(|n| matches!(n.kind, NodeKind::Component { .. })).map(|n| n.transform.as_coeffs()).map(|[.., x, y]| (x + y, y, x)) - }; - let kids = &doc.node(set)?.children; - kids.iter() - .copied() - .filter_map(|c| corner(&c).map(|k| (k, c))) - .min_by(|(a, _), (b, _)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) - .map(|(_, c)| c) -} - -/// Variants of a set sharing one combination of property values (each group two or more, in -/// layer order): a conflict to resolve by giving each a unique combination. -pub fn variant_conflicts(doc: &Document, set: NodeId) -> Vec> { - let mut by: BTreeMap, Vec> = BTreeMap::new(); - for c in doc.node(set).map(|s| s.children.clone()).unwrap_or_default() { - if let Some(n) = doc.node(c).filter(|n| matches!(n.kind, NodeKind::Component { .. })) { - by.entry(variant_props(&n.name)).or_default().push(c); - } - } - by.into_values().filter(|v| v.len() > 1).collect() -} - -/// Parse a variant name "Size=Large, State=Hover" into pairs. -pub fn variant_props(name: &str) -> BTreeMap { - name.split(',') - .filter_map(|kv| { - let (k, v) = kv.split_once('=')?; - Some((k.trim().to_string(), v.trim().to_string())) - }) - .collect() -} - -/// Format variant pairs as a layer name, in the given property order (others after, sorted). -pub fn variant_name(pairs: &BTreeMap, order: &[String]) -> String { - let mut keys: Vec<&String> = order.iter().filter(|k| pairs.contains_key(*k)).collect(); - keys.extend(pairs.keys().filter(|k| !order.contains(k))); - keys.iter().filter_map(|k| pairs.get(*k).map(|v| format!("{k}={v}"))).collect::>().join(", ") -} - -/// Variant properties of a set: property → values, in first-seen order. -pub fn variant_options(doc: &Document, set: NodeId) -> Vec<(String, Vec)> { - let mut out: Vec<(String, Vec)> = Vec::new(); - for c in doc.node(set).map(|s| s.children.clone()).unwrap_or_default() { - let Some(cn) = doc.node(c) else { continue }; - for kv in cn.name.split(',') { - let Some((k, v)) = kv.split_once('=') else { continue }; - let (k, v) = (k.trim().to_string(), v.trim().to_string()); - match out.iter_mut().find(|(p, _)| *p == k) { - Some((_, vals)) => { - if !vals.contains(&v) { - vals.push(v); - } - } - None => out.push((k, vec![v])), - } - } - } - out -} - -/// The variant in `set` matching `wanted` best: all given properties equal, or else the most. -pub fn find_variant(doc: &Document, set: NodeId, wanted: &BTreeMap) -> Option { - let s = doc.node(set)?; - let score = |c: &NodeId| doc.node(*c).map(|n| variant_props(&n.name)).map(|p| wanted.iter().filter(|(k, v)| p.get(*k) == Some(*v)).count()); - if let Some(exact) = s.children.iter().copied().find(|c| score(c) == Some(wanted.len())) { - return Some(exact); - } - s.children.iter().copied().filter(|c| score(c).is_some()).max_by_key(|c| score(c).unwrap_or(0)) -} - -/// Check that every instance mirrors its main's layer structure (tests and diagnostics): each -/// layer's `source` is the main layer at the same place, swapped nested instances mirror their -/// own main, slots holding the instance's own content aren't compared. Returns the problems found. -pub fn mirror_issues(doc: &Document) -> Vec { - doc.nodes() - .filter(|n| matches!(n.kind, NodeKind::Instance { .. }) && !inside_instance(doc, n.id)) - .flat_map(|n| instance_issues(doc, n.id)) - .collect() -} - -/// [`mirror_issues`] for one instance. -fn instance_issues(doc: &Document, id: NodeId) -> Vec { - fn walk(doc: &Document, inst: &Node, main: &Node, depth: usize, out: &mut Vec) { - if depth > MAX_DEPTH || holds_content(inst) { - return; - } - if inst.children.len() != main.children.len() { - out.push(format!("{} has {} layers, its main {} has {}", inst.id, inst.children.len(), main.id, main.children.len())); - return; - } - for (a, b) in inst.children.iter().zip(&main.children) { - let (Some(an), Some(bn)) = (doc.node(*a), doc.node(*b)) else { continue }; - if an.source != Some(bn.id) { - out.push(format!("{} mirrors {:?}, expected {}", an.id, an.source, bn.id)); - continue; - } - match (&an.kind, &bn.kind) { - (NodeKind::Instance { component: x, .. }, NodeKind::Instance { component: y, .. }) if x != y => { - if let Some(m) = doc.node(*x) { - walk(doc, an, m, depth + 1, out); - } - } - _ => walk(doc, an, bn, depth + 1, out), - } - } - } - let mut out = Vec::new(); - if let Some(n) = doc.node(id) - && let NodeKind::Instance { component, .. } = &n.kind - && let Some(m) = doc.node(*component) - { - walk(doc, n, m, 0, &mut out); - } - out -} - #[cfg(test)] #[path = "components_tests.rs"] pub(crate) mod tests; - -/// The nested instance copies inside instance `id` whose main-component counterpart is exposed -/// (top-most first; an exposed instance's own exposed nested instances follow it). -pub fn exposed_copies(doc: &Document, id: NodeId) -> Vec { - let mut out = Vec::new(); - let mut stack: Vec = doc.node(id).map(|n| n.children.iter().rev().copied().collect()).unwrap_or_default(); - let mut seen = 0usize; - while let Some(c) = stack.pop() { - seen += 1; - if seen > 100_000 { - break; - } - let Some(n) = doc.node(c) else { continue }; - // A hidden exposed instance hides its properties too. - let exposed = - n.visible && matches!(n.kind, NodeKind::Instance { .. }) && n.source.and_then(|src| doc.node(src)).is_some_and(|src| src.exposed); - if exposed { - out.push(c); - } - // Only exposed nested instances pass their own exposed instances up. - if !matches!(n.kind, NodeKind::Instance { .. }) || exposed { - stack.extend(n.children.iter().rev().copied()); - } - } - out -} diff --git a/crates/engine/src/assets/components/diff.rs b/crates/engine/src/assets/components/diff.rs new file mode 100644 index 00000000..bf742fed --- /dev/null +++ b/crates/engine/src/assets/components/diff.rs @@ -0,0 +1,218 @@ +use super::instances::PROP_BINDING; +use super::*; + +/// The property groups whose value layer `n` takes from the same variables as its main layer +/// `src` (they differ only where the two resolve in different variable modes). +pub fn variable_groups(n: &Node, src: &Node) -> Vec<&'static str> { + let mut out = Vec::new(); + for (k, v) in &n.bound { + let Some(g) = binding_group(k) else { continue }; + if src.bound.get(k) == Some(v) && !out.contains(&g) { + out.push(g); + } + } + out +} + +/// The override group a variable binding key feeds (`None` for component property and layout +/// grid bindings, which follow other rules). +pub(super) fn binding_group(key: &str) -> Option<&'static str> { + use crate::cmd::lists::{BoundList, list_binding}; + if key.starts_with(PROP_BINDING) { + return None; + } + match list_binding(key).map(|(l, _, _)| l) { + Some(BoundList::Fills) => Some("fills"), + Some(BoundList::Strokes) => Some("strokes"), + Some(BoundList::Effects) => Some("effects"), + Some(BoundList::Grids) => None, + None => Some(crate::cmd::props::override_group(key)).filter(|g| *g != "other"), + } +} + +/// The variable bindings of `n` that feed group `g`. +pub(super) fn bindings_of<'a>(n: &'a Node, g: &'a str) -> impl Iterator + 'a { + n.bound.iter().filter(move |(k, _)| binding_group(k) == Some(g)) +} + +/// Property groups that differ between two versions of a node. +pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> { + let mut out = Vec::new(); + if a.fills != b.fills { + out.push("fills"); + } + if a.strokes != b.strokes || a.stroke != b.stroke { + out.push("strokes"); + } + if a.effects != b.effects { + out.push("effects"); + } + if a.opacity != b.opacity || a.blend != b.blend { + out.push("opacity"); + } + if a.visible != b.visible { + out.push("visible"); + } + if a.name != b.name { + out.push("name"); + } + if a.corners != b.corners { + out.push("corners"); + } + if (a.width - b.width).abs() > 1e-6 || (a.height - b.height).abs() > 1e-6 { + out.push("size"); + } + if resizing(a) != resizing(b) { + out.push("resizing"); + } + if a.transform != b.transform { + out.push("position"); + } + if let (NodeKind::Text(x), NodeKind::Text(y)) = (&a.kind, &b.kind) { + if x.characters != y.characters { + out.push("characters"); + } + if text_style_differs(x, y) { + out.push("textStyle"); + } + } + if let (Some(x), Some(y)) = (&a.layout, &b.layout) { + if x.padding != y.padding { + out.push("padding"); + } + if x.gap != y.gap || x.cross_gap != y.cross_gap { + out.push("gap"); + } + if x.main_align != y.main_align || x.cross_align != y.cross_align || x.content_align != y.content_align { + out.push("alignment"); + } + } + if a.grids != b.grids || a.styles.grid != b.styles.grid { + out.push("grids"); + } + if a.exports != b.exports { + out.push("exports"); + } + if a.interactions != b.interactions { + out.push("interactions"); + } + if a.var_modes != b.var_modes { + out.push("modes"); + } + if let (NodeKind::Instance { component: x, props: px }, NodeKind::Instance { component: y, props: py }) = (&a.kind, &b.kind) { + if x != y { + out.push("component"); + } + if px != py { + out.push("props"); + } + } + out +} + +/// The `resizing` group of a layer: how it sizes on each axis, and its min and max sizes. +fn resizing(n: &Node) -> (Sizing, Sizing, [Option; 4]) { + let c = &n.child_layout; + (c.sizing_h, c.sizing_v, [c.min_w, c.max_w, c.min_h, c.max_h]) +} + +/// Whether two texts differ in anything but their characters (the `textStyle` group). +fn text_style_differs(x: &TextData, y: &TextData) -> bool { + x.style != y.style + || x.runs != y.runs + || x.auto_resize != y.auto_resize + || x.align_h != y.align_h + || x.align_v != y.align_v + || x.paragraph_spacing != y.paragraph_spacing + || x.paragraph_indent != y.paragraph_indent + || x.list != y.list + || x.paragraphs != y.paragraphs + || x.list_spacing != y.list_spacing + || x.hanging_punctuation != y.hanging_punctuation + || x.hanging_list != y.hanging_list + || x.max_lines != y.max_lines + || x.features != y.features +} + +/// Whether a geometry change of `id` comes from layout rather than the user: an ancestor was +/// resized in the same edit (constraints), the parent is an auto layout frame, or auto-sized text. +pub(super) fn geometry_derived(doc: &Document, before: &Document, id: NodeId, group: &str) -> bool { + for a in doc.ancestors(id) { + let (Some(x), Some(y)) = (doc.node(a), before.node(a)) else { continue }; + if (x.width - y.width).abs() > 1e-6 || (x.height - y.height).abs() > 1e-6 { + return true; + } + } + let Some(n) = doc.node(id) else { return true }; + if group == "position" && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()) && !n.child_layout.absolute { + return true; + } + if group == "size" + && let NodeKind::Text(t) = &n.kind + { + let wchanged = before.node(id).is_some_and(|b| (b.width - n.width).abs() > 1e-6); + return match t.auto_resize { + TextAutoResize::WidthAndHeight => true, + TextAutoResize::Height => !wchanged, + _ => false, + }; + } + false +} + +/// The components sharing the properties of component `c`: the variants of its set, or `c` alone. +pub fn property_owners(doc: &Document, c: NodeId) -> Vec { + match variant_set(doc, c).and_then(|s| doc.node(s)) { + Some(set) => set.children.iter().copied().filter(|v| doc.node(*v).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))).collect(), + None => vec![c], + } +} + +/// Make `value` the default of boolean property `name` on components `comps`, showing or hiding +/// their layers bound to it: on a main component, a boolean property's value is the visibility of +/// its layers. Returns the components and layers it changed. +pub fn set_boolean_default(doc: &mut Document, comps: &[NodeId], name: &str, value: bool) -> Vec { + let mut out = Vec::new(); + for &c in comps { + let Some(NodeKind::Component { props, .. }) = doc.node(c).map(|n| &n.kind) else { continue }; + let Some(PropDef::Boolean { default }) = props.get(name) else { continue }; + if *default != value + && let Some(NodeKind::Component { props, .. }) = doc.node_mut(c).map(|n| &mut n.kind) + { + props.insert(name.to_string(), PropDef::Boolean { default: value }); + out.push(c); + } + // The main's own layers (not those of instances nested in it, which follow their mains). + let layers: Vec = doc + .subtree(c) + .into_iter() + .filter(|d| *d != c && !inside_instance(doc, *d)) + .filter(|d| doc.node(*d).is_some_and(|n| n.visible != value && n.prop_refs.get("visible").map(String::as_str) == Some(name))) + .collect(); + for d in layers { + if let Some(n) = doc.node_mut(d) { + n.visible = value; + out.push(d); + } + } + } + out +} + +/// A main component's layer bound to a boolean property was shown or hidden: that is the +/// property's new default, which its other bound layers (in every variant) take too. Returns the +/// components and layers that changed as a result. +pub(super) fn follow_boolean_defaults(doc: &mut Document, before: &Document, changed: &[NodeId]) -> Vec { + let mut out = Vec::new(); + for &id in changed { + let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue }; + if n.visible == b.visible || inside_instance(doc, id) { + continue; + } + let Some(name) = n.prop_refs.get("visible").cloned() else { continue }; + let visible = n.visible; + let Some(c) = owning_component(doc, id).filter(|c| *c != id) else { continue }; + out.extend(set_boolean_default(doc, &property_owners(doc, c), &name, visible)); + } + out +} diff --git a/crates/engine/src/assets/components/guard.rs b/crates/engine/src/assets/components/guard.rs new file mode 100644 index 00000000..b320b7c4 --- /dev/null +++ b/crates/engine/src/assets/components/guard.rs @@ -0,0 +1,135 @@ +use super::diff::geometry_derived; +use super::rebase::rebase_instance; +use super::variants::instance_issues; +use super::*; + +/// Slot `slot` (inside an instance) takes the instance's own content: the layers it shows (the +/// main's default content until now) stop mirroring the main, and instances among them stand +/// alone, linked to their own mains. +pub(super) fn own_slot_content(doc: &mut Document, slot: NodeId) { + if let Some(n) = doc.node_mut(slot) { + n.overrides.insert(SLOT.into()); + } + for nested in unlink_layers(doc, slot, None) { + rebase_instance(doc, nested); + } +} + +/// Unlink the layers below `top` that are layers of `owner` (an instance, or `None` for the +/// content of a slot): plain layers forget the main layer they mirrored and their property +/// bindings. The instances among them are returned, to re-link to their own mains (with +/// [`rebase_instance`]) once the caller is done. +pub fn unlink_layers(doc: &mut Document, top: NodeId, owner: Option) -> Vec { + let picked: Vec<(NodeId, bool)> = + doc.subtree(top).into_iter().filter(|d| *d != top && enclosing_instance(doc, *d) == owner).map(|d| (d, is_instance(doc, d))).collect(); + let mut nested = Vec::new(); + for (d, instance) in picked { + let Some(n) = doc.node_mut(d) else { continue }; + n.prop_refs.clear(); + if instance { + nested.push(d); + } else { + n.source = None; + n.overrides.clear(); + } + } + nested +} + +/// A new layer copied from inside an instance (pasted, duplicated) to where no instance owns it +/// stops mirroring the main; a copied nested instance is re-linked to its own main (its look kept). +pub(super) fn relink_copy(doc: &mut Document, id: NodeId) { + let Some(n) = doc.node(id) else { return }; + match &n.kind { + NodeKind::Instance { component, .. } => { + let main_ok = doc.node(*component).is_some_and(|m| matches!(m.kind, NodeKind::Component { .. })); + if main_ok && !inside_instance(doc, id) && (n.source != Some(*component) || !instance_issues(doc, id).is_empty()) { + rebase_instance(doc, id); + } + } + _ => { + if (n.source.is_some() || !n.overrides.is_empty()) + && owning_instance(doc, id).is_none() + && let Some(m) = doc.node_mut(id) + { + m.source = None; + m.overrides.clear(); + } + } + } +} + +/// Refuse edits that add, remove or reorder layers inside an instance (only its main can), outside +/// its slots. +pub fn check_structure_edit(before: &Document, after: &Document) -> Result<(), String> { + for &id in after.dirty() { + let (Some(n), Some(b)) = (after.node(id), before.node(id)) else { continue }; + // Library copies change only through library updates (and are read-only otherwise). + if n.children == b.children || after.is_remote(id) { + continue; + } + if structure_locked(before, id) && structure_locked(after, id) { + let name = owning_instance(after, id).and_then(|i| after.node(i)).map(|i| i.name.clone()).unwrap_or_default(); + return Err(format!( + "layers can't be added, removed or reordered inside the instance \"{name}\": edit its main component, or detach the instance first" + )); + } + } + Ok(()) +} + +/// Refuse edits an instance doesn't take as overrides ([`refused_override`]); the edit is rolled +/// back. +pub fn check_override_edit(before: &Document, after: &Document) -> Result<(), String> { + match after.dirty().iter().find_map(|id| refused_override(before, after, *id)) { + Some(what) => Err(format!("{what} comes from the main component in an instance: edit the main component, or detach the instance")), + None => Ok(()), + } +} + +/// What about layer `id` an edit from `before` to `after` changed that only its main can: the +/// auto layout of an instance or a layer in one but its padding, gap and alignment, or the +/// position (rotation too), constraints or place in auto layout (absolute, grid cell) of a layer +/// inside an instance (its size and resizing are overrides). `None` when the edit is fine, also +/// when the main changed the same way in this edit (a library update). +pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Option<&'static str> { + let (n, b) = (after.node(id)?, before.node(id)?); + if after.is_remote(id) || n.source != b.source || owning_instance(before, id).is_none() || owning_instance(after, id).is_none() { + return None; + } + let (mb, ma) = (before.node(n.source?)?, after.node(n.source?)?); + let mode = |x: &Node| x.layout.as_ref().map(|l| l.mode); + // Auto layout settings other than the overridable ones. + let settings = |x: &Node| { + x.layout.clone().map(|mut l| { + (l.gap, l.cross_gap, l.padding, l.main_align, l.cross_align) = (0.0, 0.0, [0.0; 4], MainAlign::Min, CrossAlign::Min); + l.content_align = ContentAlign::Auto; + l + }) + }; + // Child layout settings other than the resizing ones. + let placing = |x: &Node| { + let mut c = x.child_layout.clone(); + (c.sizing_h, c.sizing_v, c.min_w, c.max_w, c.min_h, c.max_h) = (Sizing::Fixed, Sizing::Fixed, None, None, None, None); + c + }; + if mode(n) != mode(b) && mode(ma) == mode(mb) { + return Some("The auto layout direction"); + } + if settings(n) != settings(b) && settings(ma) == settings(mb) { + return Some("This auto layout setting"); + } + if !inside_instance(after, id) || !inside_instance(before, id) { + return None; + } + if n.transform != b.transform && ma.transform == mb.transform && !geometry_derived(after, before, id, "position") { + return Some("A layer's position"); + } + if n.constraints != b.constraints && ma.constraints == mb.constraints { + return Some("A layer's constraints"); + } + if placing(n) != placing(b) && placing(ma) == placing(mb) { + return Some("A layer's place in auto layout"); + } + None +} diff --git a/crates/engine/src/assets/components/instances.rs b/crates/engine/src/assets/components/instances.rs new file mode 100644 index 00000000..9fd4e2c2 --- /dev/null +++ b/crates/engine/src/assets/components/instances.rs @@ -0,0 +1,342 @@ +use super::diff::{binding_group, bindings_of}; +use super::sync::{insert_linked, sync_instance_depth}; +use super::*; + +/// Create an instance of `component` under `parent` at `(x, y)` (parent space). +pub fn create_instance(doc: &mut Document, component: NodeId, parent: NodeId, x: f64, y: f64) -> Result { + create_instance_at(doc, component, parent, x, y, None) +} + +/// [`create_instance`] at child `index` of `parent` (None: on top). +pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId, x: f64, y: f64, index: Option) -> Result { + let comp = doc.get(component)?.clone(); + if !matches!(comp.kind, NodeKind::Component { .. }) { + return Err(DocError::Invalid(format!("{component} is not a component"))); + } + if doc.is_ancestor(component, parent) { + return Err(DocError::Invalid("an instance can't be placed inside its own main component".into())); + } + let id = doc.alloc_id(); + let mut root = comp.clone(); + root.id = id; + root.name = default_instance_name(doc, component); + root.kind = NodeKind::Instance { component, props: default_props(&comp) }; + root.children.clear(); + root.parent = None; + root.source = Some(component); + root.overrides.clear(); + root.transform = Affine::translate((x, y)); + root.prop_refs.clear(); + root.annotations.clear(); + root.code_links.clear(); + // The main's dev resources show on its instances without being copied. + root.dev_resources.clear(); + // Measurements belong to the layer they were placed on. + root.measurements.clear(); + // The main's interactions play on its instances in prototypes; the instance's own start empty. + root.interactions.clear(); + // So do the variable modes the main sets (see `Document::inherited_modes`). + root.var_modes.clear(); + doc.insert(root, parent, index)?; + sync_instance(doc, id); + Ok(id) +} + +/// Copy `id` within its own file under `parent` (at `index`), keeping its local transform. A file +/// has one main per component, so a main component's copy is an instance of it; a variant copied +/// into its set stays a new variant, and every other layer is deep-copied. +pub fn copy_in_file(doc: &mut Document, id: NodeId, parent: NodeId, index: Option) -> Result { + let n = doc.get(id)?; + let in_set = doc.node(parent).is_some_and(|p| matches!(p.kind, NodeKind::ComponentSet { .. })); + if !matches!(n.kind, NodeKind::Component { .. }) || in_set { + return doc.duplicate_into(id, parent, index); + } + let transform = n.transform; + let instance = create_instance_at(doc, id, parent, 0.0, 0.0, index)?; + doc.get_mut(instance)?.transform = transform; + Ok(instance) +} + +/// Which part of the carried overrides to apply. +#[derive(Clone, Copy, PartialEq)] +pub(super) enum Carry { + /// Paint, text, visibility, … (everything but geometry). + Values, + /// Values plus size (instance roots: their size decides how children follow constraints). + Root, + /// Size and position (children, after constraints ran). + Geometry, +} + +/// Copy the overridden property groups of `from` onto `to`. +pub(super) fn carry_overrides(from: &Node, to: &mut Node, what: Carry) { + for g in &from.overrides { + let geo = matches!(g.as_str(), "size" | "position"); + match what { + Carry::Values if geo => continue, + Carry::Root if g == "position" => continue, + Carry::Geometry if !geo => continue, + _ => {} + } + copy_group(from, to, g); + } + if what != Carry::Geometry { + to.overrides = from.overrides.clone(); + } +} + +/// An instance switched from variant `was` to `new`: its resizing (sizing and min / max per axis) +/// that is still `was`'s follows `new`; what the instance set itself stays in `own`. +pub(super) fn follow_variant_resizing(own: &mut ChildLayout, was: &ChildLayout, new: &ChildLayout) { + if own.sizing_h == was.sizing_h { + own.sizing_h = new.sizing_h; + } + if own.sizing_v == was.sizing_v { + own.sizing_v = new.sizing_v; + } + for (o, w, n) in [ + (&mut own.min_w, was.min_w, new.min_w), + (&mut own.max_w, was.max_w, new.max_w), + (&mut own.min_h, was.min_h, new.min_h), + (&mut own.max_h, was.max_h, new.max_h), + ] { + if *o == w { + *o = n; + } + } +} + +/// Copy the values of property group `g` of `from` onto `to`, with the variables bound to them. +pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) { + if from.bound.iter().chain(to.bound.iter()).any(|(k, _)| binding_group(k) == Some(g)) { + to.bound.retain(|k, _| binding_group(k) != Some(g)); + to.bound.extend(bindings_of(from, g).map(|(k, v)| (k.clone(), v.clone()))); + } + match g { + "fills" => { + to.fills = from.fills.clone(); + to.styles.fill = from.styles.fill.clone(); + } + "strokes" => { + to.strokes = from.strokes.clone(); + to.stroke = from.stroke.clone(); + to.styles.stroke = from.styles.stroke.clone(); + } + "effects" => { + to.effects = from.effects.clone(); + to.styles.effect = from.styles.effect.clone(); + } + "opacity" => { + to.opacity = from.opacity; + to.blend = from.blend; + } + "visible" => to.visible = from.visible, + "name" => to.name = from.name.clone(), + "corners" => to.corners = from.corners, + "characters" => { + if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) { + b.characters = a.characters.clone(); + b.runs = a.runs.clone(); + b.paragraphs = a.paragraphs.clone(); + } + } + "textStyle" => { + if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) { + b.style = a.style.clone(); + b.runs = a.runs.clone(); + b.auto_resize = a.auto_resize; + b.align_h = a.align_h; + b.align_v = a.align_v; + b.paragraph_spacing = a.paragraph_spacing; + b.paragraph_indent = a.paragraph_indent; + b.list = a.list; + b.paragraphs = a.paragraphs.clone(); + b.list_spacing = a.list_spacing; + b.hanging_punctuation = a.hanging_punctuation; + b.hanging_list = a.hanging_list; + b.max_lines = a.max_lines; + b.features = a.features.clone(); + } + to.styles.text = from.styles.text.clone(); + } + "padding" | "gap" | "alignment" => { + if let (Some(a), Some(b)) = (&from.layout, &mut to.layout) { + match g { + "padding" => b.padding = a.padding, + "gap" => (b.gap, b.cross_gap) = (a.gap, a.cross_gap), + _ => (b.main_align, b.cross_align, b.content_align) = (a.main_align, a.cross_align, a.content_align), + } + } + } + "grids" => { + to.grids = from.grids.clone(); + to.styles.grid = from.styles.grid.clone(); + } + "exports" => to.exports = from.exports.clone(), + "interactions" => to.interactions = from.interactions.clone(), + "size" => { + to.width = from.width; + to.height = from.height; + if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) { + b.auto_resize = a.auto_resize; + } + } + "resizing" => { + let (a, b) = (&from.child_layout, &mut to.child_layout); + (b.sizing_h, b.sizing_v, b.min_w, b.max_w, b.min_h, b.max_h) = (a.sizing_h, a.sizing_v, a.min_w, a.max_w, a.min_h, a.max_h); + } + "position" => to.transform = from.transform, + "modes" => to.var_modes = from.var_modes.clone(), + "component" => { + if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&from.kind, &mut to.kind) { + *b = *a; + } + } + "props" => { + if let (NodeKind::Instance { props: a, .. }, NodeKind::Instance { props: b, .. }) = (&from.kind, &mut to.kind) { + *b = a.clone(); + } + carry_prop_bindings(from, to); + } + _ => {} + } +} + +/// Layer name paths below `root` ("Frame/Label", with `#n` for same-named siblings). With +/// `src_names` (an instance's layers), layers are named after the main layer they mirror (renames +/// don't break matching), and the content of slots holding the instance's own is left out. +pub(super) fn path_map(doc: &Document, root: NodeId, src_names: bool) -> BTreeMap { + let mut out = BTreeMap::new(); + let mut stack = vec![(root, String::new())]; + while let Some((id, prefix)) = stack.pop() { + let Some(n) = doc.node(id) else { continue }; + let mut seen: BTreeMap<&str, usize> = BTreeMap::new(); + for c in &n.children { + let Some(cn) = doc.node(*c) else { continue }; + // A nested instance linked only to its own component so far (an imported one) mirrors no + // layer yet: it goes by its own name, like the main's layer it will be matched with. + let unplaced = matches!(&cn.kind, NodeKind::Instance { component, .. } if cn.source == Some(*component)); + let mirrored = cn.source.filter(|_| src_names && !unplaced).and_then(|s| doc.node(s)); + let name = mirrored.map_or(cn.name.as_str(), |s| s.name.as_str()); + let k = seen.entry(name).or_insert(0); + let seg = if *k == 0 { name.to_string() } else { format!("{name}#{k}") }; + *k += 1; + let path = if prefix.is_empty() { seg } else { format!("{prefix}/{seg}") }; + out.insert(*c, path.clone()); + if out.len() > 1_000_000 { + return out; + } + if !(src_names && holds_content(cn)) { + stack.push((*c, path)); + } + } + } + out +} + +/// The binding key prefix of an instance's component property value +/// (`componentProperties/`): a boolean property takes a boolean variable, a text property a +/// string variable. +pub const PROP_BINDING: &str = "componentProperties/"; + +/// Give `to` the component property bindings of instance `from` (they go with its property +/// values, which an instance keeps across rebuilds). +pub(super) fn carry_prop_bindings(from: &Node, to: &mut Node) { + to.bound.retain(|k, _| !k.starts_with(PROP_BINDING)); + to.bound.extend(from.bound.iter().filter(|(k, _)| k.starts_with(PROP_BINDING)).map(|(k, v)| (k.clone(), v.clone()))); +} + +/// The override group a property-bound field belongs to. +pub(super) fn field_group(field: &str) -> &'static str { + match field { + "visible" => "visible", + "characters" => "characters", + "mainComponent" => "component", + _ => "", + } +} + +/// Apply one component-property value to a layer. +pub(super) fn apply_prop(doc: &Document, n: &mut Node, field: &str, val: &Value) { + match field { + "visible" => { + if let Some(b) = val.as_bool() { + n.visible = b; + } + } + "characters" => { + 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); + } + } + "mainComponent" => { + if let (NodeKind::Instance { component: c, .. }, Some(v)) = (&mut n.kind, crate::params::id_of(val)) + && doc.node(v).is_some_and(|m| matches!(m.kind, NodeKind::Component { .. })) + { + *c = v; + } + } + _ => {} + } +} + +/// Rebuild instance `inst` from its main component, keeping ids, overrides and the content of its +/// slots. An instance whose main component was deleted keeps its layers as they are. +pub fn sync_instance(doc: &mut Document, inst: NodeId) { + // Layers the rebuild leaves as they were keep their shared snapshot (and aren't edits). + let old: Vec<(std::sync::Arc, bool)> = + doc.subtree(inst).into_iter().filter_map(|i| doc.node_arc(i).map(|a| (a.clone(), doc.dirty().contains(&i)))).collect(); + let kept = slot_content(doc, inst); + sync_instance_depth(doc, inst, 0, BTreeMap::new()); + restore_slot_content(doc, kept); + for (a, was_dirty) in old { + doc.keep_if_unchanged(a, was_dirty); + } +} + +/// The slots in instance `inst` (nested instances included) holding the instance's own content: +/// each with its content (the subtrees of its children, pre-order). +fn slot_content(doc: &Document, inst: NodeId) -> Vec<(NodeId, Vec)> { + let mut out = Vec::new(); + let mut stack: Vec = doc.node(inst).map(|n| n.children.clone()).unwrap_or_default(); + while let Some(c) = stack.pop() { + let Some(n) = doc.node(c) else { continue }; + if holds_content(n) { + let nodes = n.children.iter().flat_map(|k| doc.subtree(*k)).filter_map(|d| doc.node(d).cloned()).collect(); + out.push((c, nodes)); + } else { + stack.extend(n.children.iter().copied()); + } + } + out +} + +/// Put slot content back after a rebuild, into the slots that still hold the instance's own +/// content (a slot the main no longer has, or no longer marks as a slot, loses it). Constraints +/// and geometry overrides ran before, so the content keeps its place. +fn restore_slot_content(doc: &mut Document, kept: Vec<(NodeId, Vec)>) { + for (slot, nodes) in kept { + let keep = slot_edited(doc, slot) && is_slot(doc, slot) && nodes.iter().all(|n| !doc.contains(n.id)); + if !keep { + continue; + } + let ids: Vec = nodes.iter().map(|n| n.id).collect(); + for n in nodes { + // Pre-order: every parent is back before its children. + let _ = insert_linked(doc, n); + } + // Never leave links to a layer that couldn't come back. + for id in ids.into_iter().chain([slot]) { + let dangling = doc.node(id).is_some_and(|n| n.children.iter().any(|c| !doc.contains(*c))); + if dangling { + let live: Vec = doc.node(id).map(|n| n.children.iter().copied().filter(|c| doc.contains(*c)).collect()).unwrap_or_default(); + if let Some(n) = doc.node_mut(id) { + n.children = live; + } + } + } + } +} diff --git a/crates/engine/src/assets/components/patch.rs b/crates/engine/src/assets/components/patch.rs new file mode 100644 index 00000000..8c61f0b8 --- /dev/null +++ b/crates/engine/src/assets/components/patch.rs @@ -0,0 +1,528 @@ +use super::diff::{binding_group, bindings_of, diff_groups, follow_boolean_defaults, geometry_derived, variable_groups}; +use super::guard::{own_slot_content, relink_copy}; +use super::instances::{copy_group, field_group, sync_instance}; +use super::sync::insert_linked; +use super::*; + +/// After an edit: record overrides inside instances, then rebuild instances whose main changed, +/// whose properties or main were switched, or whose overrides were reset. +pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId], touched: &BTreeSet) { + let mut changed = changed.to_vec(); + for id in follow_boolean_defaults(doc, before, &changed) { + if !changed.contains(&id) { + changed.push(id); + } + } + let changed = changed.as_slice(); + // 0. A slot whose layers were added, removed or reordered holds the instance's own content + // from now on; copies of instance layers placed elsewhere stop mirroring their main. + for &id in changed { + let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue }; + if n.children == b.children || owning_instance(doc, id).is_none() { + continue; + } + if let Some(slot) = slot_scope(doc, id) + && !slot_edited(doc, slot) + { + own_slot_content(doc, slot); + } + } + for &id in changed { + if !before.contains(id) { + relink_copy(doc, id); + } + } + let mut resync: BTreeSet = BTreeSet::new(); + // 1. Overrides for layers edited inside instances. + for &id in changed { + let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue }; + let Some(inst) = owning_instance(doc, id) else { continue }; + let top_root = inst == id && !inside_instance(doc, id); + if top_root + && let (NodeKind::Instance { component: c1, props: p1 }, NodeKind::Instance { component: c2, props: p2 }) = (&n.kind, &b.kind) + && (c1 != c2 || p1 != p2) + { + // Switching the main or a property value of an instance isn't an override. + resync.insert(id); + } + let raw = diff_groups(b, n); + let mut groups = raw.clone(); + // An instance's own place, resizing, export settings, interactions and variable modes are + // its own, not overrides. + if top_root { + groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions" | "modes")); + } + if groups.iter().any(|g| matches!(*g, "size" | "position")) { + groups.retain(|g| !matches!(*g, "size" | "position") || !geometry_derived(doc, before, id, g)); + } + // A value equal to the main's isn't an override (setting it back clears the override). + let mut cleared = Vec::new(); + if let Some(src) = n.source.and_then(|s| doc.node(s)) { + let mut differs = diff_groups(src, n); + if inst == id { + differs.retain(|g| *g != "name"); + if n.name != default_instance_name(doc, src.id) { + differs.push("name"); + } + } + // A value from the same variable as the main's (resolved in this layer's modes) isn't an + // override either. + let from_variables = variable_groups(n, src); + groups.retain(|g| differs.contains(g) && !from_variables.contains(g)); + // Binding another variable (or none) where the main binds one is an override. + for (k, _) in b.bound.iter().chain(n.bound.iter()) { + let Some(g) = binding_group(k) else { continue }; + let rebound = b.bound.get(k) != n.bound.get(k) && !bindings_of(n, g).eq(bindings_of(src, g)); + if rebound && !groups.contains(&g) { + groups.push(g); + } + } + cleared = + raw.iter().filter(|g| !matches!(**g, "size" | "position") && !differs.contains(g) && n.overrides.contains(**g)).copied().collect(); + if top_root { + cleared.retain(|g| !matches!(*g, "component" | "props")); + } + } + let lost = b.overrides.iter().any(|g| !n.overrides.contains(g)); + let nested_switch = !top_root && raw.iter().any(|g| matches!(*g, "component" | "props")); + if (!groups.is_empty() || !cleared.is_empty()) + && let Some(m) = doc.node_mut(id) + { + for g in groups { + m.overrides.insert(g.to_string()); + } + for g in cleared { + m.overrides.remove(g); + } + } + if (lost || nested_switch) + && let Some(r) = root_instance(doc, id) + { + resync.insert(r); + } + } + // 2. Components whose layers changed, were added or removed. Value-only edits (paint, text, + // visibility, …) are carried to the copies directly; anything else rebuilds the instances. + let mut comps: BTreeSet = BTreeSet::new(); + let mut patches: Vec<(NodeId, Vec<&'static str>)> = Vec::new(); + // Main layers that only gained new layers or lost some: their copies take the same change. + let mut restructured: Vec = Vec::new(); + let mut covered: BTreeSet = BTreeSet::new(); + for &id in changed { + if let (Some(b), Some(n)) = (before.node(id), doc.node(id)) + && b.children != n.children + && owning_instance(doc, id).is_none() + && owning_component(doc, id).is_some() + && let Some(layers) = restructurable(doc, before, b, n) + { + restructured.push(id); + covered.extend(layers); + } + } + for &id in changed { + if covered.contains(&id) || restructured.contains(&id) { + continue; + } + let Some(c) = owning_component(doc, id) else { continue }; + let (Some(b), Some(n)) = (before.node(id), doc.node(id)) else { + comps.insert(c); + continue; + }; + if b == n { + continue; + } + match patchable(doc, b, n) { + Some(groups) => patches.push((id, groups)), + None => { + comps.insert(c); + } + } + } + for &id in touched { + if !doc.contains(id) + && !covered.contains(&id) + && let Some(c) = owning_component(before, id) + && doc.contains(c) + { + comps.insert(c); + } + } + for r in resync { + if doc.contains(r) { + sync_instance(doc, r); + if let Some(c) = owning_component(doc, r) { + comps.insert(c); + } + } + } + // A copy the patch can't follow exactly rebuilds instead (as does any other change). + let patched = comps.is_empty() + && !full_rebuild() + && (restructured.is_empty() || patch_structure(doc, before, &restructured)) + && (patches.is_empty() || patch_copies(doc, &patches)); + #[cfg(test)] + if patched && (!restructured.is_empty() || !patches.is_empty()) { + PATCHED.with(|c| c.set(c.get() + 1)); + } + if !patched { + comps.extend(patches.iter().map(|(id, _)| *id).chain(restructured.iter().copied()).filter_map(|id| owning_component(doc, id))); + } + // 3. Instances of changed components follow; an instance inside another component makes that + // one change too. + rebuild_instances(doc, &comps); +} + +#[cfg(test)] +thread_local! { + /// Tests: rebuild instances even for edits that could be patched (to compare both ways). + pub(crate) static FULL_REBUILD: std::cell::Cell = const { std::cell::Cell::new(false) }; + /// Tests: edits carried to the copies without rebuilding instances. + pub(crate) static PATCHED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[cfg(test)] +fn full_rebuild() -> bool { + FULL_REBUILD.with(std::cell::Cell::get) +} + +#[cfg(not(test))] +fn full_rebuild() -> bool { + false +} + +/// The property groups of a main-component layer edited from `b` to `n` when the edit changed +/// values only (paint, text, visibility, size, position, …) that copies take as they are: carried to them without +/// rebuilding the instances ([`patch_copies`]). `None` when anything else changed. +fn patchable(doc: &Document, b: &Node, n: &Node) -> Option> { + const VALUES: [&str; 17] = [ + "fills", + "strokes", + "effects", + "opacity", + "visible", + "name", + "corners", + "characters", + "textStyle", + "padding", + "gap", + "alignment", + "grids", + "exports", + "interactions", + "size", + "position", + ]; + if b.children != n.children || b.parent != n.parent || b.kind.type_name() != n.kind.type_name() { + return None; + } + let groups = diff_groups(b, n); + if groups.is_empty() || groups.iter().any(|g| !VALUES.contains(g)) { + return None; + } + // Exactly those groups (and the layer's own overrides) changed. + let mut probe = n.clone(); + for g in &groups { + copy_group(b, &mut probe, g); + } + probe.overrides = b.overrides.clone(); + if probe != *b { + return None; + } + // Values a component property sets (on this layer or a main it mirrors) come from instances. + let mut cur = Some(n); + for _ in 0..=MAX_DEPTH { + let Some(x) = cur else { break }; + if x.prop_refs.keys().any(|f| groups.contains(&field_group(f))) { + return None; + } + cur = x.source.and_then(|s| doc.node(s)); + } + Some(groups) +} + +/// Copies by the layer they mirror; nested instances swapped to a main also follow it. +fn copies_index(doc: &Document) -> BTreeMap> { + let mut copies: BTreeMap> = BTreeMap::new(); + for n in doc.nodes() { + let Some(s) = n.source else { continue }; + copies.entry(s).or_default().push(n.id); + if let (NodeKind::Instance { component, .. }, Some(NodeKind::Instance { component: other, .. })) = (&n.kind, doc.node(s).map(|x| &x.kind)) + && component != other + { + copies.entry(*component).or_default().push(n.id); + } + } + copies +} + +/// When main layer `b` → `n` only gained new plain layers (created in this edit: no instances, +/// components or property bindings in them) or lost layers that were deleted: those layers, which +/// [`patch_structure`] carries to the copies. `None` for anything else. +fn restructurable(doc: &Document, before: &Document, b: &Node, n: &Node) -> Option> { + let mut probe = n.clone(); + probe.children = b.children.clone(); + probe.overrides = b.overrides.clone(); + if probe != *b { + return None; + } + let mut layers = Vec::new(); + for c in n.children.iter().filter(|c| !b.children.contains(c)) { + for d in doc.subtree(*c) { + let x = doc.node(d)?; + let plain = !matches!(x.kind, NodeKind::Instance { .. } | NodeKind::Component { .. } | NodeKind::ComponentSet { .. }); + if before.contains(d) || !plain || !x.prop_refs.is_empty() { + return None; + } + layers.push(d); + } + } + for c in b.children.iter().filter(|c| !n.children.contains(c)) { + if doc.contains(*c) { + return None; + } + layers.extend(before.subtree(*c)); + } + Some(layers) +} + +/// Carry the new and deleted layers of main layers `seeds` ([`restructurable`]) to every copy, +/// through copies of copies (slots holding an instance's own content keep it). Returns false +/// (leaving a partial patch for the caller to rebuild over) when a copy can't follow exactly. +fn patch_structure(doc: &mut Document, before: &Document, seeds: &[NodeId]) -> bool { + let copies = copies_index(doc); + let mut work: Vec = seeds.to_vec(); + let mut steps = 0usize; + while let Some(p) = work.pop() { + steps += 1; + if steps > 10_000_000 { + return false; + } + let Some(pn) = doc.node(p).cloned() else { continue }; + for c in copies.get(&p).cloned().unwrap_or_default() { + let Some(cn) = doc.node(c).cloned() else { continue }; + if holds_content(&cn) { + continue; + } + if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&pn.kind, &cn.kind) + && a != b + { + continue; + } + // The copy's layers by the layer of `p` they mirror. + let mut mirrors: BTreeMap = BTreeMap::new(); + for k in &cn.children { + let Some(src) = doc.node(*k).and_then(|x| x.source) else { return false }; + if mirrors.insert(src, *k).is_some() { + return false; + } + } + let mut order = Vec::with_capacity(pn.children.len()); + for pc in &pn.children { + if let Some(k) = mirrors.remove(pc) { + order.push(k); + continue; + } + // A layer the copy lacks must be new in this edit, placed as in the main. + let absolute = doc.node(*pc).is_some_and(|x| x.child_layout.absolute); + if before.contains(*pc) || !child_follows_main(doc, c, absolute) { + return false; + } + match copy_layers(doc, *pc, c) { + Some(k) => order.push(k), + None => return false, + } + } + // Copies of layers the main lost. + for (src, k) in mirrors { + if doc.contains(src) || doc.remove(k).is_err() { + return false; + } + } + if doc.node(c).is_some_and(|x| x.children != order) + && let Some(m) = doc.node_mut(c) + { + m.children = order; + } + work.push(c); + } + } + true +} + +/// Whether a layer placed in copy `parent` (absolutely positioned or not) takes its main layer's +/// place and size: nothing on the way up applies constraints ([`follows_main_geometry`]). +fn child_follows_main(doc: &Document, parent: NodeId, absolute: bool) -> bool { + let Some(p) = doc.node(parent) else { return false }; + let constrains = p.layout.is_none() || absolute; + let resized = sized_own(p) + || match &p.kind { + NodeKind::Instance { component, .. } => { + doc.node(*component).is_none_or(|m| (m.width - p.width).abs() > 1e-9 || (m.height - p.height).abs() > 1e-9) + } + _ => false, + }; + !(constrains && resized) && follows_main_geometry(doc, parent) +} + +/// Copy main layer `src` (with its layers) into copy `parent` as layers mirroring them; returns the +/// new copy (the caller places it among `parent`'s layers). +fn copy_layers(doc: &mut Document, src: NodeId, parent: NodeId) -> Option { + let sub = doc.subtree(src); + let map: BTreeMap = sub.iter().map(|d| (*d, doc.alloc_id())).collect(); + for d in &sub { + let mut n = doc.node(*d)?.clone(); + n.id = *map.get(d)?; + n.parent = if *d == src { Some(parent) } else { n.parent.and_then(|x| map.get(&x).copied()) }; + n.children = n.children.iter().filter_map(|k| map.get(k).copied()).collect(); + n.source = Some(*d); + n.overrides.clear(); + n.prop_refs.clear(); + n.annotations.clear(); + n.measurements.clear(); + insert_linked(doc, n).ok()?; + } + map.get(&src).copied() +} + +/// Carry value-only edits of main-component layers (`seeds`: layer, groups) to every copy, through +/// copies of copies, except where a copy overrides the group. Returns false (leaving a partial +/// patch for the caller to rebuild over) when a copy can't follow exactly. +fn patch_copies(doc: &mut Document, seeds: &[(NodeId, Vec<&'static str>)]) -> bool { + let copies = copies_index(doc); + let text = |g: &str| matches!(g, "characters" | "textStyle"); + let mut work: Vec<(NodeId, Vec<&'static str>)> = seeds.to_vec(); + let mut steps = 0usize; + while let Some((p, groups)) = work.pop() { + steps += 1; + if steps > 10_000_000 { + return false; + } + let Some(pn) = doc.node(p).cloned() else { continue }; + for c in copies.get(&p).cloned().unwrap_or_default() { + let Some(cn) = doc.node(c) else { continue }; + // A swapped nested instance shows its own main, not the layer it stands in for. + if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&pn.kind, &cn.kind) + && a != b + { + continue; + } + // An instance keeps its own visibility, place, export settings and interactions; its + // name follows the main's unless renamed; a resized one lays its layers out against + // the main's size. + let root = matches!(pn.kind, NodeKind::Component { .. }); + if root && ((groups.contains(&"name") && !cn.overrides.contains("name")) || (groups.contains(&"size") && cn.overrides.contains("size"))) { + return false; + } + let gs: Vec<&'static str> = groups + .iter() + .copied() + .filter(|g| !(cn.overrides.contains(*g) || root && matches!(*g, "visible" | "name" | "position" | "exports" | "interactions"))) + .collect(); + if gs.is_empty() { + continue; + } + let geometry = gs.iter().any(|g| matches!(*g, "size" | "position")); + if geometry && !follows_main_geometry(doc, c) { + return false; + } + // Text content and style share their runs: a copy overriding one keeps its runs when the + // other follows the main (as a rebuild carries them). + let runs = match &cn.kind { + NodeKind::Text(t) if gs.iter().any(|g| text(g)) && cn.overrides.iter().any(|g| text(g)) => Some(t.runs.clone()), + _ => None, + }; + if let Some(m) = doc.node_mut(c) { + for g in &gs { + copy_group(&pn, m, g); + } + if let (Some(r), NodeKind::Text(t)) = (runs, &mut m.kind) { + t.runs = r; + } + } + if (geometry || gs.iter().any(|g| text(g))) + && let Some((w, h)) = doc.node(c).and_then(figcraft_text::auto_size) + && doc.node(c).is_some_and(|n| (n.width - w).abs() > 1e-6 || (n.height - h).abs() > 1e-6) + { + figcraft_layout::set_size(doc, c, w, h); + } + work.push((c, gs)); + } + } + true +} + +/// Whether a copy's size and place are its main layer's: no layer around it inside its instances +/// applies constraints on the way down to it (a resized instance or frame without auto layout, or +/// a resized auto layout instance holding it absolutely positioned). Auto layout re-flows the rest. +fn follows_main_geometry(doc: &Document, id: NodeId) -> bool { + let chain = scope(doc, id); + chain.windows(2).all(|w| { + let (Some(below), Some(a)) = (w.first().and_then(|i| doc.node(*i)), w.get(1).and_then(|i| doc.node(*i))) else { return false }; + let constrains = a.layout.is_none() || below.child_layout.absolute; + if sized_own(a) && constrains { + return false; + } + match &a.kind { + NodeKind::Instance { component, .. } => { + !constrains || doc.node(*component).is_some_and(|m| (m.width - a.width).abs() <= 1e-9 && (m.height - a.height).abs() <= 1e-9) + } + _ => true, + } + }) +} + +/// Whether a layer inside an instance overrides its size or resizing: it may not have its main +/// layer's size, so the layers in it can't take their main layers' places as they are. +fn sized_own(n: &Node) -> bool { + n.overrides.contains("size") || n.overrides.contains("resizing") +} + +/// Rebuild every instance of `comps` (and of the components that hold those instances, and so on +/// outwards), each outermost instance once, inner components first. +fn rebuild_instances(doc: &mut Document, comps: &BTreeSet) { + if comps.is_empty() { + return; + } + // Outermost instances by the components their layers instantiate. + let mut roots_of: BTreeMap> = BTreeMap::new(); + for n in doc.nodes() { + if let NodeKind::Instance { component, .. } = &n.kind { + roots_of.entry(*component).or_default().insert(root_instance(doc, n.id).unwrap_or(n.id)); + } + } + // Affected components, with how deep their dependencies go (an instance inside component X of + // component Y: X follows Y). + let mut level: BTreeMap = comps.iter().map(|c| (*c, 0)).collect(); + let mut outer: BTreeMap> = BTreeMap::new(); + let mut frontier: Vec = comps.iter().copied().collect(); + let mut rounds = 0; + while !frontier.is_empty() && rounds < 64 { + rounds += 1; + let mut next = Vec::new(); + for c in frontier { + let lc = level.get(&c).copied().unwrap_or(0); + for r in roots_of.get(&c).into_iter().flatten() { + let o = *outer.entry(*r).or_insert_with(|| owning_component(doc, *r)); + if let Some(x) = o + && x != c + { + let lx = level.entry(x).or_insert(0); + if *lx < lc + 1 { + *lx = lc + 1; + next.push(x); + } + } + } + } + frontier = next; + } + // Instances inside components by level, then the rest (each once). + let mut order: Vec<(usize, NodeId)> = outer.iter().map(|(r, o)| (o.and_then(|x| level.get(&x).copied()).unwrap_or(usize::MAX), *r)).collect(); + order.sort(); + for (_, r) in order { + if doc.contains(r) { + sync_instance(doc, r); + } + } +} diff --git a/crates/engine/src/assets/components/rebase.rs b/crates/engine/src/assets/components/rebase.rs new file mode 100644 index 00000000..748a99d3 --- /dev/null +++ b/crates/engine/src/assets/components/rebase.rs @@ -0,0 +1,249 @@ +use super::diff::{diff_groups, variable_groups}; +use super::instances::{field_group, path_map}; +use super::*; + +/// Bounded source and parent lookups while a rebase reads an unchanged document. +pub(super) struct RebaseSources<'a> { + doc: &'a Document, + chains: BTreeMap>>, + parents: BTreeMap<(NodeId, NodeId), Option>, + main_changes: BTreeMap, +} + +impl<'a> RebaseSources<'a> { + const PARENTS: usize = 512; + const CACHE: usize = 8192; + + pub(super) fn new(doc: &'a Document) -> Self { + Self { doc, chains: BTreeMap::new(), parents: BTreeMap::new(), main_changes: BTreeMap::new() } + } + + fn chain(&mut self, source: NodeId) -> Option> { + if let Some(chain) = self.chains.get(&source) { + return chain.clone(); + } + let read = || { + let mut chain = Vec::new(); + let mut seen = BTreeSet::new(); + let mut current = Some(source); + for _ in 0..MAX_DEPTH { + let Some(id) = current else { return Some(chain) }; + if !seen.insert(id) { + return None; + } + let n = self.doc.node(id)?; + chain.push(id); + current = n.source; + } + current.is_none().then_some(chain) + }; + let chain = read(); + if self.chains.len() < Self::CACHE { + self.chains.insert(source, chain.clone()); + } + chain + } + + /// Validate the complete parent chain, even if `main` was found before a cycle. + fn in_main(&mut self, id: NodeId, main: NodeId) -> Option { + if let Some(found) = self.parents.get(&(id, main)) { + return *found; + } + let read = || { + let mut current = Some(id); + let mut seen = BTreeSet::new(); + let mut found = false; + for _ in 0..Self::PARENTS { + let Some(id) = current else { return Some(found) }; + if !seen.insert(id) { + return None; + } + found |= id == main; + current = self.doc.node(id)?.parent; + } + current.is_none().then_some(found) + }; + let found = read(); + if self.parents.len() < Self::CACHE { + self.parents.insert((id, main), found); + } + found + } + + fn member(&mut self, source: NodeId, main: NodeId, target: NodeId) -> Option { + let chain = self.chain(source)?; + if chain.contains(&target) { + return None; + } + let mut member = None; + for id in chain { + if self.in_main(id, main)? && id != main && member.is_none() { + member = Some(id); + } + } + member + } + + /// Nearest enclosing instances and their current mains. Slots with own content end scope. + pub(super) fn owners(&self, id: NodeId) -> Option> { + let mut owners = Vec::new(); + let mut current = self.doc.node(id)?.parent; + let mut seen = BTreeSet::from([id]); + for _ in 0..Self::PARENTS { + let Some(id) = current else { return Some(owners) }; + if !seen.insert(id) { + return None; + } + let n = self.doc.node(id)?; + if holds_content(n) { + return Some(owners); + } + if let NodeKind::Instance { component, .. } = &n.kind { + owners.push((id, *component)); + } + current = n.parent; + } + current.is_none().then_some(owners) + } + + fn contextual(&mut self, source: NodeId, owners: &[(NodeId, NodeId)]) -> bool { + owners.iter().any(|(_, main)| self.in_main(source, *main) == Some(true) && source != *main) + } + + /// A contextual child belongs to the same placed slot as its enclosing instance. + fn valid(&mut self, source: NodeId, target: NodeId, owners: &[(NodeId, NodeId)]) -> bool { + let Some((owner, main)) = owners.first() else { return false }; + if self.member(source, *main, target).is_none() { + return false; + } + if self.in_main(source, *main) == Some(true) { + return true; + } + let Some(source_owner) = self.doc.node(*owner).and_then(|n| n.source) else { return false }; + matches!(self.doc.node(source_owner).map(|n| &n.kind), Some(NodeKind::Instance { component, .. }) if component == main) + && self.owners(source).and_then(|owners| owners.first().copied()).is_some_and(|(id, _)| id == source_owner) + } + + fn placed(&mut self, id: NodeId, owners: &[(NodeId, NodeId)]) -> Option { + let source = self.doc.node(id)?.source?; + if !matches!(self.doc.node(source)?.kind, NodeKind::Instance { .. } | NodeKind::Frame) + || !self.valid(source, id, owners) + || !self.contextual(source, owners) + { + return None; + } + Some(source) + } + + fn beneath_swap(&mut self, owners: &[(NodeId, NodeId)], stop: NodeId) -> bool { + for (index, (id, main)) in owners.iter().enumerate() { + if *id == stop { + break; + } + let changed = if let Some(changed) = self.main_changes.get(id) { + *changed + } else { + let changed = self.placed(*id, &owners[index + 1..]).is_some_and(|source| match self.doc.node(source).map(|n| &n.kind) { + Some(NodeKind::Instance { component, .. }) => component != main, + Some(NodeKind::Frame) => true, + _ => false, + }); + if self.main_changes.len() < Self::CACHE { + self.main_changes.insert(*id, changed); + } + changed + }; + if changed { + return true; + } + } + false + } +} + +/// Re-derive `source` links and overrides of instance `inst` against its own main by comparing +/// values (after detaching an outer instance, restoring a main, or copying a nested instance out): +/// the look is kept exactly. Values a component property sets aren't overrides. +pub fn rebase_instance(doc: &mut Document, inst: NodeId) { + let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else { return }; + let mine = path_map(doc, inst, true); + let theirs: BTreeMap = + if doc.contains(component) { path_map(doc, component, false).into_iter().map(|(k, v)| (v, k)).collect() } else { BTreeMap::new() }; + let value_groups = |a: &Node, b: &Node| -> Vec { + diff_groups(a, b).into_iter().filter(|g| !matches!(*g, "size" | "position")).map(str::to_string).collect() + }; + let mut updates = Vec::new(); + let mut sources = RebaseSources::new(doc); + let root_owners = sources.owners(inst).unwrap_or_default(); + let placed_source = sources.placed(inst, &root_owners); + let mut contexts = vec![(inst, component)]; + contexts.extend_from_slice(&root_owners); + for (id, path) in &mine { + let Some(n) = doc.node(*id) else { continue }; + let owners = sources.owners(*id).unwrap_or_default(); + let nearest_main = owners.first().map(|(_, main)| *main); + let valid_source = n.source.filter(|source| sources.valid(*source, *id, &owners)); + let mut main = valid_source.and_then(|source| sources.member(source, component, *id)); + // A swapped or Frame-to-Instance subtree is compared to its current main by the inner + // rebase. An outer name path must not rebind those children to the old expanded copies. + if main.is_none() && valid_source.is_some() && sources.beneath_swap(&owners, inst) { + updates.push((*id, valid_source, n.overrides.clone())); + continue; + } + main = main + .or_else(|| theirs.get(path).copied().filter(|source| nearest_main.is_some_and(|scope| sources.member(*source, scope, *id).is_some()))); + let source = valid_source.filter(|source| sources.contextual(*source, &contexts)).filter(|_| main.is_some()).or(main); + let mut overrides: BTreeSet = n.overrides.iter().filter(|g| !matches!(g.as_str(), "component" | "props")).cloned().collect(); + if let Some(m) = source.and_then(|source| doc.node(source)) { + let mut bound: Vec<&str> = m.prop_refs.keys().map(|f| field_group(f)).collect(); + bound.extend(variable_groups(n, m)); + overrides.extend(value_groups(m, n).into_iter().filter(|g| !bound.contains(&g.as_str()))); + if matches!((&m.kind, &n.kind), (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) if a != b) { + overrides.insert("component".into()); + } + } + updates.push((*id, source, overrides)); + } + // A placed nested root compares to its actual slot. A detached root compares to its own main. + let root_base = placed_source.or_else(|| { + let chain = sources.chain(component)?; + (!chain.contains(&inst) && sources.in_main(component, inst) == Some(false)).then_some(component) + }); + let root_groups = match (root_base.and_then(|source| doc.node(source)), doc.node(inst)) { + (Some(m), Some(n)) => { + let from_variables = variable_groups(n, m); + let mut groups: Vec = value_groups(m, n) + .into_iter() + .filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str())) + .filter(|g| placed_source.is_some() || !matches!(g.as_str(), "resizing" | "modes")) + .collect(); + let named = if placed_source.is_some() { n.name != m.name } else { n.name != default_instance_name(doc, component) }; + if named { + groups.push("name".into()); + } + if placed_source.is_some() + && matches!((&m.kind, &n.kind), (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) if a != b) + { + groups.push("component".into()); + } + groups + } + _ => Vec::new(), + }; + drop(sources); + for (id, main, overrides) in updates { + if let Some(n) = doc.node_mut(id) { + n.source = main; + n.overrides = if main.is_some() { overrides } else { BTreeSet::new() }; + } + } + if let Some(n) = doc.node_mut(inst) { + n.source = root_base; + // A top-level instance's resizing and modes are its own (a nested one's override its main + // layer's). + n.overrides.retain(|g| { + !matches!(g.as_str(), "component" | "props" | "position") && (placed_source.is_some() || !matches!(g.as_str(), "resizing" | "modes")) + }); + n.overrides.extend(root_groups); + } +} diff --git a/crates/engine/src/assets/components/sync.rs b/crates/engine/src/assets/components/sync.rs new file mode 100644 index 00000000..c502322d --- /dev/null +++ b/crates/engine/src/assets/components/sync.rs @@ -0,0 +1,459 @@ +use super::diff::diff_groups; +use super::instances::{Carry, apply_prop, carry_overrides, carry_prop_bindings, copy_group, field_group, follow_variant_resizing, path_map}; +use super::*; + +/// `spare`: layers the outer rebuild didn't reuse that were below `inst` (a swapped nested +/// instance): its layers the outer main doesn't have, matched here so they keep ids and overrides. +pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BTreeMap>) { + if depth > MAX_DEPTH { + return; + } + let Some(mut root) = doc.node(inst).cloned() else { return }; + let NodeKind::Instance { component, props: root_props } = &root.kind else { return }; + let component = *component; + let Some(comp) = doc.node(component).cloned() else { return }; + if !matches!(comp.kind, NodeKind::Component { .. }) || doc.is_ancestor(inst, component) || doc.is_ancestor(component, inst) { + // A missing main, or an instance inside its own main (would recurse forever). + return; + } + let mut eff_props = default_props(&comp); + for (k, v) in root_props { + if eff_props.contains_key(k) { + eff_props.insert(k.clone(), v.clone()); + } + } + // Old layers (kept alive for their overrides), by (parent, source), by source alone (when only + // one layer mirrors it) and, only when those don't match, by name path. + let mut old_ids = old_layers(doc, inst); + old_ids.extend(spare.keys().copied()); + old_ids.sort(); + old_ids.dedup(); + let mut old: BTreeMap> = spare; + let mut by_source: BTreeMap<(NodeId, NodeId), NodeId> = BTreeMap::new(); + let mut by_src: BTreeMap> = BTreeMap::new(); + for i in &old_ids { + let Some(n) = doc.node_arc(*i).or_else(|| old.get(i)).cloned() else { continue }; + if let Some(s) = n.source { + if let Some(p) = n.parent { + by_source.entry((p, s)).or_insert(*i); + } + by_src.entry(s).and_modify(|e| *e = None).or_insert(Some(*i)); + } + old.insert(*i, n); + } + let mut by_path: Option> = None; + let mut main_paths: Option> = None; + // Ids for the copies: reuse the matching old layer's id (a layer the main moved to another + // parent keeps its id and overrides). + let src: Vec = doc.subtree(component).into_iter().filter(|i| *i != component).collect(); + let mut map: BTreeMap = BTreeMap::new(); + map.insert(component, inst); + let mut used: BTreeSet = BTreeSet::new(); + // Slots holding the instance's own content (new ids), and main layers not copied: the + // default content of those slots. + let mut edited: BTreeSet = BTreeSet::new(); + let mut skipped: BTreeSet = BTreeSet::new(); + let mut fresh_ids = Vec::new(); + for s in &src { + let Some(sn) = doc.node(*s) else { continue }; + if sn.parent.is_some_and(|p| skipped.contains(&p) || map.get(&p).is_some_and(|pn| edited.contains(pn))) { + skipped.insert(*s); + continue; + } + let parent_new = sn.parent.and_then(|p| map.get(&p).copied()); + let mut found = parent_new.and_then(|pn| by_source.get(&(pn, *s)).copied()).or_else(|| by_src.get(s).copied().flatten()); + if found.is_none() { + let paths = by_path.get_or_insert_with(|| path_map(doc, inst, true).into_iter().map(|(k, v)| (v, k)).collect()); + let mains = main_paths.get_or_insert_with(|| path_map(doc, component, false)); + found = mains.get(s).and_then(|p| paths.get(p)).copied(); + } + let nid = match found { + Some(o) if used.insert(o) => o, + _ => { + fresh_ids.push(*s); + continue; + } + }; + if let Some(m) = old.get(&nid).and_then(|o| variant_kept(doc, o, *s)) { + old.insert(nid, std::sync::Arc::new(m)); + } + if old.get(&nid).is_some_and(|o| holds_content(o)) && is_slot(doc, *s) { + edited.insert(nid); + } + map.insert(*s, nid); + } + for c in root.children.clone() { + let _ = doc.remove(c); + } + for s in fresh_ids { + let nid = doc.alloc_id(); + map.insert(s, nid); + } + // Build the copies (pre-order: parents first). + let mut enclosing: BTreeMap> = BTreeMap::new(); + enclosing.insert(inst, vec![inst]); + let mut inst_props: BTreeMap> = BTreeMap::new(); + inst_props.insert(inst, eff_props); + let mut nested = Vec::new(); + let mut new_nodes = Vec::new(); + // Text layers showing other text or style than their main layer (to re-measure). + let mut remeasure = Vec::new(); + for s in &src { + let Some(orig) = doc.node(*s) else { continue }; + let Some(&nid) = map.get(s) else { continue }; + let Some(parent) = orig.parent.and_then(|p| map.get(&p).copied()) else { continue }; + let mut n = orig.clone(); + n.id = nid; + n.parent = Some(parent); + n.children = orig.children.iter().filter_map(|c| map.get(c).copied()).collect(); + n.source = Some(*s); + n.overrides.clear(); + n.prop_refs.clear(); + let prev = old.get(&nid); + // Annotations belong to the layer they were added to, not to its copies. + n.annotations = prev.map(|o| o.annotations.clone()).unwrap_or_default(); + n.measurements = prev.map(|o| o.measurements.clone()).unwrap_or_default(); + if let Some(o) = prev { + carry_overrides(o, &mut n, Carry::Values); + keep_laid_out(doc, orig, o, &mut n); + } + if !edited.contains(&nid) { + // No longer a slot in the main: it mirrors the main again. + n.overrides.remove(SLOT); + } + // Component properties: the main layer's bindings use this instance's values; layers of + // nested instances use the nested instance's values (one level per source hop). + let enc = enclosing.get(&parent).cloned().unwrap_or_default(); + let mut chain: Vec<&Node> = vec![orig]; + while chain.len() < enc.len() { + let Some(next) = chain.last().and_then(|c| c.source).and_then(|x| doc.node(x)) else { break }; + chain.push(next); + } + for (j, level) in chain.iter().enumerate().rev() { + let Some(props) = enc.get(j).and_then(|e| inst_props.get(e)) else { continue }; + for (field, prop) in &level.prop_refs { + let g = field_group(field); + if n.overrides.contains(g) || (j > 0 && orig.overrides.contains(g)) { + continue; + } + if let Some(val) = props.get(prop) { + apply_prop(doc, &mut n, field, val); + } + } + } + if let NodeKind::Instance { component: c, props } = &n.kind { + inst_props.insert(nid, props.clone()); + let mut e = enc; + e.push(nid); + enclosing.insert(nid, e); + if !matches!(orig.kind, NodeKind::Instance { component: oc, .. } if oc == *c) { + nested.push(nid); + } + } else { + enclosing.insert(nid, enc); + } + if let (NodeKind::Text(a), NodeKind::Text(b)) = (&orig.kind, &n.kind) + && a != b + { + remeasure.push(nid); + } + new_nodes.push(n); + } + for n in new_nodes { + let (id, parent) = (n.id, n.parent); + if insert_linked(doc, n).is_err() + && let Some(p) = parent.and_then(|p| doc.node_mut(p)) + { + p.children.retain(|c| *c != id); + } + } + // Root: follows the main except its own placement, name, size and overrides. + let order: Vec = comp.children.iter().filter_map(|c| map.get(c).copied()).filter(|c| doc.contains(*c)).collect(); + let mut fresh = comp.clone(); + fresh.id = inst; + fresh.parent = root.parent; + fresh.children = order; + fresh.transform = root.transform; + fresh.kind = root.kind.clone(); + carry_prop_bindings(&root, &mut fresh); + // A nested instance keeps mirroring its layer of the outer main. + let nested_copy = inside_instance(doc, inst); + fresh.source = if nested_copy { root.source } else { Some(component) }; + fresh.constraints = root.constraints; + fresh.child_layout = root.child_layout.clone(); + fresh.interactions = root.interactions.clone(); + fresh.exports = root.exports.clone(); + fresh.prop_refs = root.prop_refs.clone(); + fresh.plugin_data = root.plugin_data.clone(); + fresh.annotations = root.annotations.clone(); + // Code links belong to the main component; instances read them from it. + fresh.code_links.clear(); + // An instance's own dev resources (its main's are listed with them, not copied). + fresh.dev_resources = root.dev_resources.clone(); + fresh.measurements = root.measurements.clone(); + fresh.locked = root.locked; + fresh.visible = root.visible; + fresh.var_modes = root.var_modes.clone(); + fresh.is_mask = root.is_mask; + fresh.exposed = root.exposed; + fresh.guides = root.guides.clone(); + fresh.dev_status = root.dev_status; + fresh.dev_changed = root.dev_changed; + fresh.dev_note = root.dev_note.clone(); + fresh.constrain_proportions = root.constrain_proportions; + // How the instance combines in a boolean group it sits in is its own. + fresh.bool_op = root.bool_op; + // A swapped nested instance keeps the name its layer was given in the outer main. + let renamed = nested_copy + && root.source.and_then(|s| doc.node(s)).is_some_and(|s| match &s.kind { + NodeKind::Instance { component: c, .. } => s.name != default_instance_name(doc, *c), + _ => false, + }); + fresh.name = if renamed { root.name.clone() } else { default_instance_name(doc, component) }; + // Switched to another variant: its own overrides stay where both variants agreed. + if let Some(prev) = root.source.and_then(|p| doc.node(p)).filter(|p| sibling_variants(doc, p.id, component)) { + let differs = diff_groups(prev, &comp); + root.overrides.retain(|g| g == "name" || !differs.contains(&g.as_str())); + follow_variant_resizing(&mut fresh.child_layout, &prev.child_layout, &comp.child_layout); + } + carry_overrides(&root, &mut fresh, Carry::Root); + if let Some(r) = doc.node_mut(inst) { + *r = fresh; + } + // A resized instance: children follow their constraints (auto layout re-flows later). + let (iw, ih) = doc.node(inst).map(|n| (n.width, n.height)).unwrap_or((comp.width, comp.height)); + if (iw - comp.width).abs() > 1e-9 || (ih - comp.height).abs() > 1e-9 { + figcraft_layout::apply_constraints(doc, inst, comp.width, comp.height); + remeasure = map.values().copied().collect(); + } + // Geometry overrides of children win over constraints. + for s in &src { + let Some(nid) = map.get(s).copied() else { continue }; + let Some(o) = old.get(&nid) else { continue }; + // A frame that kept the size its own resizing gave it: its layers (copied at their main + // layers' places) follow their constraints from its main layer's size. + if o.overrides.contains("resizing") + && !o.overrides.contains("size") + && doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none()) + && let Some((mw, mh)) = doc.node(*s).map(|m| (m.width, m.height)) + { + figcraft_layout::apply_constraints(doc, nid, mw, mh); + } + if !o.overrides.contains("size") && !o.overrides.contains("position") { + continue; + } + remeasure.push(nid); + let Some((ow, oh)) = doc.node(nid).map(|n| (n.width, n.height)) else { continue }; + if o.overrides.contains("size") { + // Through set_size so vector networks scale with the box. + figcraft_layout::set_size(doc, nid, o.width, o.height); + if doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none()) { + figcraft_layout::apply_constraints(doc, nid, ow, oh); + } + } + if let Some(n) = doc.node_mut(nid) { + carry_overrides(o, n, Carry::Geometry); + } + } + // Text whose characters or style differ from the main re-measures. + for nid in remeasure { + if let Some(n) = doc.node(nid) + && let Some((w, h)) = figcraft_text::auto_size(n) + && ((w - n.width).abs() > 1e-6 || (h - n.height).abs() > 1e-6) + { + figcraft_layout::set_size(doc, nid, w, h); + } + } + // Nested instances showing another main than the main's copy (swapped): rebuild from it, + // with the old layers below them this rebuild didn't reuse. + let unused: BTreeMap> = + old.iter().filter(|(i, _)| !used.contains(*i) && !doc.contains(**i)).map(|(i, x)| (*i, x.clone())).collect(); + for n in nested { + let mut below: BTreeMap> = unused + .iter() + .filter(|(_, x)| { + let mut cur = x.parent; + for _ in 0..=MAX_DEPTH * 4 { + match cur { + Some(p) if p == n => return true, + Some(p) => cur = unused.get(&p).and_then(|a| a.parent), + None => return false, + } + } + false + }) + .map(|(i, x)| (*i, x.clone())) + .collect(); + take_placing_overrides(doc, n, &old, &mut below); + sync_instance_depth(doc, n, depth + 1, below); + } +} + +/// Before swapped nested instance `n` is rebuilt from its new main: each of its old layers (the +/// copies just made of the outer main's, and `below`) takes the overrides held along the source +/// chain it had before this rebuild (`before`), through layers inside instances in the mains that +/// place `n`. Matched by name, they carry onto the new main's layers as the instance's own, so a +/// swap takes what the outer main set once: later rebuilds find layers mirroring the new main. +/// Geometry and slot content follow the new main; groups the layer overrides itself keep its value. +fn take_placing_overrides( + doc: &mut Document, + n: NodeId, + before: &BTreeMap>, + below: &mut BTreeMap>, +) { + let placing = placing_mains(doc, n); + for id in doc.subtree(n).into_iter().filter(|i| *i != n) { + let held = before.get(&id).map(|b| held_along(doc, b, &placing)).unwrap_or_default(); + if let Some(layer) = doc.node_mut(id) { + take(layer, &held); + } + } + for layer in below.values_mut() { + let held = held_along(doc, layer, &placing); + if !held.is_empty() { + take(std::sync::Arc::make_mut(layer), &held); + } + } +} + +/// The mains placing nested instance `n`: those of the layers it mirrors, outward. +fn placing_mains(doc: &Document, n: NodeId) -> BTreeSet { + let mut out = BTreeSet::new(); + let mut cur = doc.node(n).and_then(|x| x.source); + for _ in 0..=MAX_DEPTH { + let Some(s) = cur.and_then(|c| doc.node(c)).filter(|s| !matches!(s.kind, NodeKind::Component { .. })) else { break }; + out.extend(owning_component(doc, s.id)); + cur = s.source; + } + out +} + +/// The overrides but geometry and slot content held along `layer`'s source chain while it runs +/// through layers inside instances in `placing` mains, each with the nearest layer holding it. +fn held_along(doc: &Document, layer: &Node, placing: &BTreeSet) -> BTreeMap> { + let mut out = BTreeMap::new(); + let mut cur = layer.source; + for _ in 0..=MAX_DEPTH { + let Some(s) = cur.and_then(|c| doc.node_arc(c)) else { break }; + if !inside_instance(doc, s.id) || !owning_component(doc, s.id).is_some_and(|m| placing.contains(&m)) { + break; + } + for g in s.overrides.iter().filter(|g| !matches!(g.as_str(), "size" | "position" | SLOT)) { + out.entry(g.clone()).or_insert_with(|| s.clone()); + } + cur = s.source; + } + out +} + +/// Give `layer` the `held` groups it has no override of its own for. +fn take(layer: &mut Node, held: &BTreeMap>) { + for (g, from) in held { + if layer.overrides.insert(g.clone()) { + copy_group(from, layer, g); + } + } +} + +/// Whether main components `a` and `b` are two variants of one set. +fn sibling_variants(doc: &Document, a: NodeId, b: NodeId) -> bool { + a != b && variant_set(doc, a).is_some_and(|s| variant_set(doc, b) == Some(s)) +} + +/// Old copy `o` matched by name to main layer `s` of another variant (the instance switched +/// variants): `o` without the overrides of properties the two variants' layers differ in (`None` +/// when it keeps them all). Text layers keep every override. +fn variant_kept(doc: &Document, o: &Node, s: NodeId) -> Option { + let from = o.source.filter(|f| *f != s).and_then(|f| doc.node(f))?; + if o.overrides.is_empty() || matches!(o.kind, NodeKind::Text(_)) { + return None; + } + if !sibling_variants(doc, owning_component(doc, from.id)?, owning_component(doc, s)?) { + return None; + } + let differs = diff_groups(from, doc.node(s)?); + let mut m = o.clone(); + m.overrides.retain(|g| !differs.contains(&g.as_str())); + (m.overrides.len() != o.overrides.len()).then_some(m) +} + +/// Drop the overrides of instance `inst` but its text layers' (and its own size and the content of +/// its slots): what swapping it from the Assets panel keeps. +pub fn keep_text_overrides(doc: &mut Document, inst: NodeId) { + for d in old_layers(doc, inst).into_iter().chain([inst]) { + let Some(n) = doc.node(d) else { continue }; + let text = matches!(n.kind, NodeKind::Text(_)); + let keep = |g: &String| text || g == SLOT || (d == inst && g == "size"); + if !n.overrides.iter().all(keep) + && let Some(m) = doc.node_mut(d) + { + m.overrides.retain(keep); + } + } +} + +/// Where auto layout places and sizes a rebuilt copy `n` of main layer `orig`, keep the old +/// copy's `o` values (an instance's auto layout re-flows from its own content, so they usually +/// differ from the main's): unchanged layers then stay unchanged, and auto layout re-flows wherever +/// something that sizes or places layers did change. +fn keep_laid_out(doc: &Document, orig: &Node, o: &Node, n: &mut Node) { + if o.parent != n.parent || o.overrides.contains("size") || o.overrides.contains("position") { + return; + } + let flows = !n.child_layout.absolute && orig.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()); + if flows { + // The main's rotation and skew, the old place. + let [a, b, c, d, _, _] = n.transform.as_coeffs(); + let [.., e, f] = o.transform.as_coeffs(); + n.transform = Affine::new([a, b, c, d, e, f]); + } + // Only layers whose shape is their box (a vector's points scale with it). + let boxed = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Text(_) | NodeKind::Rectangle | NodeKind::Ellipse { .. }); + let derived = |s: Sizing| boxed && ((s == Sizing::Hug && n.layout.is_some()) || (s == Sizing::Fill && flows)); + if derived(n.child_layout.sizing_h) { + n.width = o.width; + } + if derived(n.child_layout.sizing_v) { + n.height = o.height; + } +} + +/// The layers of instance `inst` a rebuild matches against (every layer below it but the content +/// of slots holding its own), ascending. +fn old_layers(doc: &Document, inst: NodeId) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![inst]; + while let Some(id) = stack.pop() { + let Some(n) = doc.node(id) else { continue }; + for c in &n.children { + out.push(*c); + if out.len() > 1_000_000 { + break; + } + if doc.node(*c).is_some_and(|cn| !holds_content(cn)) { + stack.push(*c); + } + } + } + out.sort(); + out.dedup(); + out +} + +/// Insert a node whose parent/children links are already set (used when rebuilding instances). +pub(super) fn insert_linked(doc: &mut Document, n: Node) -> Result<(), DocError> { + let parent = n.parent.ok_or_else(|| DocError::Invalid("no parent".into()))?; + let id = n.id; + let mut n = n; + let kids = std::mem::take(&mut n.children); + doc.insert(n, parent, None)?; + if let Some(m) = doc.node_mut(id) { + m.children = kids; + } + // The parent may already list us (its children were copied with ids): keep the first. + if let Some(p) = doc.node_mut(parent) { + let mut seen = BTreeSet::new(); + p.children.retain(|c| seen.insert(*c)); + } + Ok(()) +} diff --git a/crates/engine/src/assets/components/variants.rs b/crates/engine/src/assets/components/variants.rs new file mode 100644 index 00000000..40ac23ea --- /dev/null +++ b/crates/engine/src/assets/components/variants.rs @@ -0,0 +1,147 @@ +use super::*; + +/// A set's default variant: the one in its top-left corner (nearest to it, then the top-most, then +/// the left-most). +pub fn default_variant(doc: &Document, set: NodeId) -> Option { + let corner = |c: &NodeId| { + doc.node(*c).filter(|n| matches!(n.kind, NodeKind::Component { .. })).map(|n| n.transform.as_coeffs()).map(|[.., x, y]| (x + y, y, x)) + }; + let kids = &doc.node(set)?.children; + kids.iter() + .copied() + .filter_map(|c| corner(&c).map(|k| (k, c))) + .min_by(|(a, _), (b, _)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) + .map(|(_, c)| c) +} + +/// Variants of a set sharing one combination of property values (each group two or more, in +/// layer order): a conflict to resolve by giving each a unique combination. +pub fn variant_conflicts(doc: &Document, set: NodeId) -> Vec> { + let mut by: BTreeMap, Vec> = BTreeMap::new(); + for c in doc.node(set).map(|s| s.children.clone()).unwrap_or_default() { + if let Some(n) = doc.node(c).filter(|n| matches!(n.kind, NodeKind::Component { .. })) { + by.entry(variant_props(&n.name)).or_default().push(c); + } + } + by.into_values().filter(|v| v.len() > 1).collect() +} + +/// Parse a variant name "Size=Large, State=Hover" into pairs. +pub fn variant_props(name: &str) -> BTreeMap { + name.split(',') + .filter_map(|kv| { + let (k, v) = kv.split_once('=')?; + Some((k.trim().to_string(), v.trim().to_string())) + }) + .collect() +} + +/// Format variant pairs as a layer name, in the given property order (others after, sorted). +pub fn variant_name(pairs: &BTreeMap, order: &[String]) -> String { + let mut keys: Vec<&String> = order.iter().filter(|k| pairs.contains_key(*k)).collect(); + keys.extend(pairs.keys().filter(|k| !order.contains(k))); + keys.iter().filter_map(|k| pairs.get(*k).map(|v| format!("{k}={v}"))).collect::>().join(", ") +} + +/// Variant properties of a set: property → values, in first-seen order. +pub fn variant_options(doc: &Document, set: NodeId) -> Vec<(String, Vec)> { + let mut out: Vec<(String, Vec)> = Vec::new(); + for c in doc.node(set).map(|s| s.children.clone()).unwrap_or_default() { + let Some(cn) = doc.node(c) else { continue }; + for kv in cn.name.split(',') { + let Some((k, v)) = kv.split_once('=') else { continue }; + let (k, v) = (k.trim().to_string(), v.trim().to_string()); + match out.iter_mut().find(|(p, _)| *p == k) { + Some((_, vals)) => { + if !vals.contains(&v) { + vals.push(v); + } + } + None => out.push((k, vec![v])), + } + } + } + out +} + +/// The variant in `set` matching `wanted` best: all given properties equal, or else the most. +pub fn find_variant(doc: &Document, set: NodeId, wanted: &BTreeMap) -> Option { + let s = doc.node(set)?; + let score = |c: &NodeId| doc.node(*c).map(|n| variant_props(&n.name)).map(|p| wanted.iter().filter(|(k, v)| p.get(*k) == Some(*v)).count()); + if let Some(exact) = s.children.iter().copied().find(|c| score(c) == Some(wanted.len())) { + return Some(exact); + } + s.children.iter().copied().filter(|c| score(c).is_some()).max_by_key(|c| score(c).unwrap_or(0)) +} + +/// Check that every instance mirrors its main's layer structure (tests and diagnostics): each +/// layer's `source` is the main layer at the same place, swapped nested instances mirror their +/// own main, slots holding the instance's own content aren't compared. Returns the problems found. +pub fn mirror_issues(doc: &Document) -> Vec { + doc.nodes() + .filter(|n| matches!(n.kind, NodeKind::Instance { .. }) && !inside_instance(doc, n.id)) + .flat_map(|n| instance_issues(doc, n.id)) + .collect() +} + +/// [`mirror_issues`] for one instance. +pub(super) fn instance_issues(doc: &Document, id: NodeId) -> Vec { + fn walk(doc: &Document, inst: &Node, main: &Node, depth: usize, out: &mut Vec) { + if depth > MAX_DEPTH || holds_content(inst) { + return; + } + if inst.children.len() != main.children.len() { + out.push(format!("{} has {} layers, its main {} has {}", inst.id, inst.children.len(), main.id, main.children.len())); + return; + } + for (a, b) in inst.children.iter().zip(&main.children) { + let (Some(an), Some(bn)) = (doc.node(*a), doc.node(*b)) else { continue }; + if an.source != Some(bn.id) { + out.push(format!("{} mirrors {:?}, expected {}", an.id, an.source, bn.id)); + continue; + } + match (&an.kind, &bn.kind) { + (NodeKind::Instance { component: x, .. }, NodeKind::Instance { component: y, .. }) if x != y => { + if let Some(m) = doc.node(*x) { + walk(doc, an, m, depth + 1, out); + } + } + _ => walk(doc, an, bn, depth + 1, out), + } + } + } + let mut out = Vec::new(); + if let Some(n) = doc.node(id) + && let NodeKind::Instance { component, .. } = &n.kind + && let Some(m) = doc.node(*component) + { + walk(doc, n, m, 0, &mut out); + } + out +} + +/// The nested instance copies inside instance `id` whose main-component counterpart is exposed +/// (top-most first; an exposed instance's own exposed nested instances follow it). +pub fn exposed_copies(doc: &Document, id: NodeId) -> Vec { + let mut out = Vec::new(); + let mut stack: Vec = doc.node(id).map(|n| n.children.iter().rev().copied().collect()).unwrap_or_default(); + let mut seen = 0usize; + while let Some(c) = stack.pop() { + seen += 1; + if seen > 100_000 { + break; + } + let Some(n) = doc.node(c) else { continue }; + // A hidden exposed instance hides its properties too. + let exposed = + n.visible && matches!(n.kind, NodeKind::Instance { .. }) && n.source.and_then(|src| doc.node(src)).is_some_and(|src| src.exposed); + if exposed { + out.push(c); + } + // Only exposed nested instances pass their own exposed instances up. + if !matches!(n.kind, NodeKind::Instance { .. }) || exposed { + stack.extend(n.children.iter().rev().copied()); + } + } + out +} From c631450b0cd13fcc333398079b9a2da9ac90a6ea Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:20:45 +0000 Subject: [PATCH 26/67] refactor(engine): split sharing/live.rs into live/ --- crates/engine/src/sharing/live.rs | 1758 +--------------------- crates/engine/src/sharing/live/events.rs | 408 +++++ crates/engine/src/sharing/live/host.rs | 272 ++++ crates/engine/src/sharing/live/join.rs | 259 ++++ crates/engine/src/sharing/live/sync.rs | 347 +++++ crates/engine/src/sharing/live/types.rs | 482 ++++++ 6 files changed, 1783 insertions(+), 1743 deletions(-) create mode 100644 crates/engine/src/sharing/live/events.rs create mode 100644 crates/engine/src/sharing/live/host.rs create mode 100644 crates/engine/src/sharing/live/join.rs create mode 100644 crates/engine/src/sharing/live/sync.rs create mode 100644 crates/engine/src/sharing/live/types.rs diff --git a/crates/engine/src/sharing/live.rs b/crates/engine/src/sharing/live.rs index f33abd1c..a469b28e 100644 --- a/crates/engine/src/sharing/live.rs +++ b/crates/engine/src/sharing/live.rs @@ -59,485 +59,23 @@ use crate::params::{get, num, req_string, string}; use crate::tools::ToolId; use crate::{DocState, EngineError, Result, Session, TextEdit, ViewState, bad, spec}; -/// The server files are shared through online unless Preferences name another: the only place -/// its address is written. -/// -/// A build can ship another one, or none: set `FIGCRAFT_DEFAULT_SERVER` when compiling to a -/// server's address (`wss://host/`), or to nothing (`FIGCRAFT_DEFAULT_SERVER=`) for a build whose -/// online sharing stays off until the user enters a server in Preferences. -pub const DEFAULT_SERVER: &str = match option_env!("FIGCRAFT_DEFAULT_SERVER") { - Some(server) => server, - None => "wss://collab.craftapps.dev/", +mod events; +mod host; +mod join; +mod sync; +mod types; + +use host::unavailable; +pub use host::{MAX_REPORT, hosting_json}; +pub(crate) use host::{clean_shared, share_answer}; +pub(crate) use join::start_join; +pub use join::{can_remove_people, specs}; +pub use types::{ + Bind, DEFAULT_SERVER, Event, HostOptions, LiveLink, MAX_NAME, MAX_SELECTION, NO_HOSTING, NO_SESSION, NetReply, NetRequest, ONLINE_OFF, PEOPLE, + PageInfo, PublishOptions, Published, Role, SessionId, SessionInfo, Share, Shared, SharedLink, Status, TOO_LARGE_TO_JOIN, Via, join_target, + parse_color, }; -/// Most selected ids one presence update carries (the rest aren't shown to others). -pub const MAX_SELECTION: usize = 10_000; -/// Longest name shown to others (characters). -pub const MAX_NAME: usize = 64; - -/// The colours people get in a session, in the order the host hands them out (`#RRGGBB`, white -/// text reads on each). -pub const PEOPLE: [&str; 8] = ["#0D99FF", "#9747FF", "#14AE5C", "#F24822", "#D9730D", "#E83E8C", "#00A3A3", "#665CE0"]; - -/// `#RRGGBB` → (r, g, b); `None` for anything else. -pub fn parse_color(s: &str) -> Option<(u8, u8, u8)> { - let hex = s.strip_prefix('#').filter(|h| h.len() == 6 && h.bytes().all(|b| b.is_ascii_hexdigit()))?; - let byte = |i: usize| hex.get(i..i + 2).and_then(|b| u8::from_str_radix(b, 16).ok()); - Some((byte(0)?, byte(2)?, byte(4)?)) -} - -/// Why a join failed when a message from the host is over this app's size limit (`{mb}`: the -/// limit in MB). The UI translates it. -pub const TOO_LARGE_TO_JOIN: &str = "This file is too large to join here (up to {mb} MB)"; - -/// The connection to a live session. -#[derive(Clone, Debug, Default, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", tag = "state")] -pub enum Status { - /// Not in a session. - #[default] - Off, - /// Joining (connecting to the host, waiting for its welcome). - Connecting, - /// In the session. - Live, - /// The connection dropped; trying again (`attempt` from 1). - Reconnecting { attempt: u32 }, - /// Couldn't join, or the connection was lost for good. - Failed { reason: String }, -} - -impl Status { - pub fn id(&self) -> &'static str { - match self { - Status::Off => "off", - Status::Connecting => "connecting", - Status::Live => "live", - Status::Reconnecting { .. } => "reconnecting", - Status::Failed { .. } => "failed", - } - } - - /// In a session or on the way into one. - pub fn active(&self) -> bool { - matches!(self, Status::Connecting | Status::Live | Status::Reconnecting { .. }) - } - - /// What the Share popover says about it. - pub fn label(&self) -> String { - match self { - Status::Off => "Not in a live session".into(), - Status::Connecting => "Joining…".into(), - Status::Live => "Live".into(), - Status::Reconnecting { attempt } => format!("Connection lost, reconnecting (attempt {attempt})…"), - Status::Failed { reason } => reason.clone(), - } - } -} - -/// Your part in the session. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum Role { - Host, - Guest, -} - -/// A page of the shared file. -#[derive(Clone, Debug, PartialEq, Serialize)] -pub struct PageInfo { - pub id: u64, - pub name: String, -} - -/// The session you're in. -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct SessionInfo { - pub role: Role, - /// Your client id, name and colour as the others see them. - pub me: u32, - pub name: String, - pub color: String, - /// The shared file's name and pages (the host's). - pub file_name: String, - pub pages: Vec, - /// The invite link (`figcraft://join?host=…&file=…&token=…`) and the same as a plain - /// WebSocket URL. Both carry the session's secret token: share them only with people you - /// invite. - pub link: String, - pub url: String, - /// `ip:port` the session listens on (host) or connects to (guest); a server's URL when it - /// isn't plain `ws://` at `/`. - pub address: String, - /// Reachable from other machines (the host listens on a network address, not loopback). - pub network: bool, - /// What you may do: edit, or only view (a server's view link). - pub access: Access, -} - -/// A file you published to a server: where, and its first share links (they carry their tokens: -/// share them like passwords; the server keeps only hashes). -#[derive(Clone, Debug, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct Published { - /// The server's address. - pub server: String, - /// The file's id and name on the server. - pub file: String, - pub name: String, - /// The link that lets people edit, and the one that lets them only view. - pub edit: String, - pub view: String, - /// Published without the server's key: the owner token that revokes links, renames or removes - /// the file (shown this once; the server keeps only its hash). - pub owner: Option, - /// When the server deletes the file if nobody opens it (seconds since 1970). - pub expires: Option, -} - -/// How a file is shared with a join code. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub enum Via { - /// Through a server: the file is kept there and the server hands out the codes. - Online, - /// From this app, to people on the same network. - Local, -} - -/// One way into a shared file: an invite link, and the join code that stands for it. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, serde::Deserialize)] -#[serde(default)] -pub struct SharedLink { - /// The link's id on its server (what revokes it; empty for a session this app hosts). - pub id: String, - /// The invite link (it carries its secret). - pub link: String, - /// Its join code's symbols. `None`: there is none (a server from before join codes, a host - /// without an IPv4 address): the link is shared instead. - pub code: Option, -} - -/// What others join a shared file with: to edit it and, on a server, to only view it. -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, serde::Deserialize)] -#[serde(default)] -pub struct Shared { - pub edit: SharedLink, - pub view: Option, -} - -/// A file shared with a join code (`collab.share`). Online, the file is its owner's on the -/// server: sharing gives others links (and codes) of their own, and stopping takes those away -/// while the owner stays in the file. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Share { - pub via: Via, - /// Online: the server's address as it was given (it may carry the server's key: never shown). - pub server: String, - /// What the code shown lets people do. - pub access: Access, - /// What others join with now. `None`: on its way, or (online) not shared at the moment: the - /// file is on the server, open only to its owner. - pub links: Option, -} - -impl Share { - /// The way in to show: the one for [`access`](Self::access). - fn shown(&self) -> Option<&SharedLink> { - let links = self.links.as_ref()?; - if self.access == Access::View { links.view.as_ref() } else { Some(&links.edit) } - } - - /// The code to show, grouped (`K7M-2QX-9TD`). - pub fn code(&self) -> Option { - self.shown()?.code.as_deref().map(code::grouped) - } - - /// The invite link to give with (or in place of) the code. - pub fn link(&self) -> Option<&str> { - self.shown().map(|l| l.link.as_str()) - } -} - -/// What someone typed to join, as what the link joins: an invite link as it is, a local network -/// code as the link it stands for, an online code with `server` to ask about it. -pub fn join_target(server: &str, typed: &str) -> std::result::Result { - let typed = typed.trim(); - if typed.contains("://") || typed.contains('?') { - return Ok(typed.to_string()); - } - match code::parse(typed)? { - Code::Local(local) => Ok(local.link()), - Code::Online(_) if server.trim().is_empty() => Err(format!("that is an online join code, and {ONLINE_OFF}")), - Code::Online(code) => Ok(format!("figcraft://join?server={}&code={code}", server.trim())), - } -} - -/// Why nothing can be shared or joined online. -pub const ONLINE_OFF: &str = "online sharing is off: choose a server in Preferences (or share on the local network)"; - -/// A session's number on the link: the engine picks it (from 1) when it starts the session. -pub type SessionId = u64; -/// The [`SessionId`] of events that aren't about a session (answers to background requests). -pub const NO_SESSION: SessionId = 0; - -/// What happened in a session since the last [`LiveLink::poll`]. -#[derive(Clone, Debug, PartialEq)] -pub enum Event { - Status(Status), - /// Joined, or joined again after a reconnect: who you are and who else is here. - Welcome { - info: SessionInfo, - peers: Vec, - }, - PeerJoined(Peer), - PeerLeft(u32), - Presence(u32, Presence), - /// Someone's cursor chat or spotlight, or (about you) the host's [`Signal::READY`]. - Signal(u32, Signal), - /// For the document sync client: the welcome (with the whole document), or a batch of - /// changes the server ordered, or the rejection of one of yours. - Sync(ServerMsg), - /// The host ended the session. - Ended { - reason: String, - }, - /// The document is on the server (`collab.publish`); joining it follows. - Published(Published), - /// The published file is shared (`collab.share`): the links and codes others join with, or - /// why the server made none. - Shared(std::result::Result), - /// What the server says about its hosting (operator, terms, this file's expiry). - Hosting(HostInfo), - /// The server's answer to `collab.report`: kept, or why not. - Reported(Option), - /// The answer to a team library request sent in the background - /// ([`LiveLink::library_background`]) about the file behind share link `link`. - Library { - link: String, - reply: std::result::Result, - }, - /// The answer to request `job` sent in the background ([`LiveLink::background`]). - Net { - job: u64, - reply: Box, - }, -} - -/// A request to a server outside a live session: its plugin directory, a team library, or keeping -/// a file there. -#[derive(Clone, Debug)] -pub enum NetRequest { - /// To the plugin directory of the server at `server` (`ws://host:port/`, with `?key=…` to - /// publish). - Plugins { server: String, req: PluginReq }, - /// About the team library of the file behind share link `link`. - Library { link: String, req: LibraryReq }, - /// Keep a file on a server without joining it ([`LiveLink::upload`]). - Upload(PublishOptions), - /// Change who else gets into `file` on the server at `server` (its address, with `?key=…`, or - /// the file's `owner` token): revoke the links with these ids, then, to `create`, make a new - /// edit link and view link with their join codes ([`LiveLink::share`]). - Share { server: String, file: String, owner: String, revoke: Vec, create: bool }, - /// Ask the server at `server` about its hosting and, with a file's owner token, manage that - /// file ([`LiveLink::hosting`]). - Hosting { server: String, file: String, owner: String, req: Option }, -} - -impl NetRequest { - /// What a session without the network says. - pub(crate) fn unavailable(&self) -> &'static str { - match self { - NetRequest::Plugins { .. } => crate::plugin_directory::NO_DIRECTORY, - NetRequest::Library { .. } | NetRequest::Upload(_) => NO_TEAM_LIBRARIES, - NetRequest::Hosting { .. } | NetRequest::Share { .. } => NO_HOSTING, - } - } - - /// How to answer that this request failed (and why). - pub fn failure(&self) -> fn(String) -> NetReply { - match self { - NetRequest::Plugins { .. } => |why| NetReply::Plugins(Err(why)), - NetRequest::Library { .. } => |why| NetReply::Library(Err(why)), - NetRequest::Upload(_) => |why| NetReply::Upload(Err(why)), - NetRequest::Hosting { .. } => |why| NetReply::Hosting(Err(why)), - NetRequest::Share { .. } => |why| NetReply::Share(Err(why)), - } - } - - /// The answer to this request stands for `other` when a command runs again: the same kind of - /// request to the same place (what it carries may differ: a usage report, a fresh timestamp). - pub(crate) fn stands_for(&self, other: &NetRequest) -> bool { - use std::mem::discriminant; - match (self, other) { - (NetRequest::Plugins { server: a, req: x }, NetRequest::Plugins { server: b, req: y }) => a == b && discriminant(x) == discriminant(y), - (NetRequest::Library { link: a, req: x }, NetRequest::Library { link: b, req: y }) => a == b && discriminant(x) == discriminant(y), - (NetRequest::Upload(a), NetRequest::Upload(b)) => a.server == b.server, - (NetRequest::Hosting { server: a, file: f, req: x, .. }, NetRequest::Hosting { server: b, file: g, req: y, .. }) => { - a == b && f == g && x.as_ref().map(discriminant) == y.as_ref().map(discriminant) - } - (NetRequest::Share { server: a, file: f, create: x, .. }, NetRequest::Share { server: b, file: g, create: y, .. }) => { - a == b && f == g && x == y - } - _ => false, - } - } -} - -/// The answer to a [`NetRequest`] (an error: the server couldn't be reached or refused). -#[derive(Clone, Debug, PartialEq)] -pub enum NetReply { - Plugins(std::result::Result), - Library(std::result::Result), - /// The new links, when some were asked for. - Share(std::result::Result, String>), - Upload(std::result::Result), - /// What the server says about its hosting, and its answer to the request, if one was sent. - Hosting(std::result::Result<(HostInfo, Option), String>), -} - -/// What a session without the network says about servers' hosting. -pub const NO_HOSTING: &str = "publishing to a server needs the desktop app or the MCP server"; - -/// Which network interface a hosted session listens on. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Bind { - /// This machine only (127.0.0.1): the default. - Local, - /// The machine's address on the local network (only when the user chooses to share there). - Lan, - /// A given interface address (e.g. a VPN's). - Addr(IpAddr), -} - -/// What [`LiveLink::host`] needs to start a session for the open file. -#[derive(Clone, Debug)] -pub struct HostOptions { - /// `None`: the default port, or any free one when it's taken. - pub port: Option, - pub bind: Bind, - /// Your name (empty: the host picks one). - pub name: String, - /// The shared file (the session's authoritative copy starts from it). - pub doc: Arc, - /// Shared with a join code (`collab.share`): the session's secret is short enough to type. - pub short: bool, -} - -/// What [`LiveLink::publish`] needs to put the open file on a server. -#[derive(Clone, Debug)] -pub struct PublishOptions { - /// The server's address (`ws://host:port/`, `wss://host/path/`), which may carry its key - /// (`?key=…`). - pub server: String, - /// The server's key, when the address doesn't carry it. Without one the file is published - /// as anyone may, on a server open to the public (with an owner token). - pub key: Option, - /// The file's name on the server. - pub name: String, - /// Your name in the session (empty: the server picks one). - pub me: String, - pub doc: Arc, - /// Share the file once it is there: links and join codes for others ([`Event::Shared`]; - /// `collab.share`). - pub share: bool, -} - -/// The networking behind live sessions, installed by the app ([`Lives::install`]). It carries -/// any number of sessions at once, each under the [`SessionId`] it was started with. Calls return -/// at once; what happens later arrives through [`poll`](Self::poll). -pub trait LiveLink: Send { - /// Sessions can be hosted here (the browser build can only join). - fn can_host(&self) -> bool { - true - } - /// Start hosting session `session` for `opts.doc`. - fn host(&mut self, session: SessionId, opts: HostOptions) -> std::result::Result; - /// Start joining the session behind an invite link, as session `session`. Errors are for - /// links that can't be used; they never echo the link (it holds the token). - fn join(&mut self, session: SessionId, link: &str, name: &str) -> std::result::Result<(), String>; - /// [`join`](Self::join) as the client `resume` names, when the server still knows it (an app - /// that starts over after a crash keeps its id, so what it made before is its own to send). - /// A link that can't resume joins as someone new. - fn join_resuming(&mut self, session: SessionId, link: &str, name: &str, _resume: Option) -> std::result::Result<(), String> { - self.join(session, link, name) - } - /// Start uploading `opts.doc` to a server as a new file, then join it with its edit link, as - /// session `session`: [`Event::Published`] with its links, then the welcome. Errors are for - /// addresses that can't be used; they never echo the key. - fn publish(&mut self, _session: SessionId, _opts: PublishOptions) -> std::result::Result<(), String> { - Err("publishing to a server needs the desktop app or the MCP server".into()) - } - /// Keep `opts.doc` on a server as a new file without joining it: where, and its links - /// (`library.publishToServer` with the server's key). Waits for the server. Errors never echo - /// the key. - fn upload(&mut self, _opts: PublishOptions) -> std::result::Result { - Err(NO_TEAM_LIBRARIES.into()) - } - /// One request about the team library of the file behind share link `link`; waits for the - /// server's answer (a few seconds at most when it can't be reached). Errors never echo the - /// link. - fn library(&mut self, _link: &str, _req: LibraryReq) -> std::result::Result { - Err(NO_TEAM_LIBRARIES.into()) - } - /// Send library requests `(link, request)` in the background, in order; each answer arrives as - /// [`Event::Library`]. Ignored while an earlier call's requests are still out. - fn library_background(&mut self, _jobs: Vec<(String, LibraryReq)>) {} - /// One request to the plugin directory of the server at `server` (`ws://host:port/`, with - /// `?key=…` to publish); waits for the answer (a few seconds at most when it can't be - /// reached). Errors never echo the key. - fn plugins(&mut self, _server: &str, _req: PluginReq) -> std::result::Result { - Err(crate::plugin_directory::NO_DIRECTORY.into()) - } - /// What the server at `server` says about its hosting, and with a file's owner token - /// (`file` and `owner`) its answer to `req` about that file; waits for the server. Errors - /// never echo the token. - fn hosting( - &mut self, - _server: &str, - _file: &str, - _owner: &str, - _req: Option, - ) -> std::result::Result<(HostInfo, Option), String> { - Err(NO_HOSTING.into()) - } - /// See [`NetRequest::Share`]; waits for the server. Errors never echo the key or the token. - fn share(&mut self, _server: &str, _file: &str, _owner: &str, _revoke: &[String], _create: bool) -> std::result::Result, String> { - Err(NO_HOSTING.into()) - } - /// Send `req` and wait for the answer (through the methods above). - fn request(&mut self, req: NetRequest) -> NetReply { - match req { - NetRequest::Share { server, file, owner, revoke, create } => NetReply::Share(self.share(&server, &file, &owner, &revoke, create)), - NetRequest::Plugins { server, req } => NetReply::Plugins(self.plugins(&server, req)), - NetRequest::Library { link, req } => NetReply::Library(self.library(&link, req)), - NetRequest::Upload(o) => NetReply::Upload(self.upload(o)), - NetRequest::Hosting { server, file, owner, req } => NetReply::Hosting(self.hosting(&server, &file, &owner, req)), - } - } - /// Send `req` on a thread of its own and return at once: the answer arrives as [`Event::Net`] - /// with `job` (a failure to send too). False when this link can't, and nothing was sent. - fn background(&mut self, _job: u64, _req: NetRequest) -> bool { - false - } - /// Remove client `client` from session `session`, which this link hosts: they are - /// disconnected and can't join again from their address while it runs. Returns at once. - fn remove(&mut self, _session: SessionId, _client: u32) -> std::result::Result<(), String> { - Err("removing people needs the desktop app or the MCP server".into()) - } - /// End session `session` (stop hosting or leave); the others go on. Nothing for a session - /// that is over already. - fn leave(&mut self, session: SessionId); - /// Your latest presence in `session` (the link throttles and coalesces). - fn presence(&mut self, session: SessionId, p: &Presence); - /// A message for `session`'s server (document changes), sent in order. - fn send(&mut self, session: SessionId, msg: ClientMsg); - /// Events since the last call, each with its session ([`NO_SESSION`] for answers to - /// background requests); never blocks. - fn poll(&mut self) -> Vec<(SessionId, Event)>; -} - /// The view a follow last applied: any other view means the user moved. #[derive(Clone, Copy, Debug, PartialEq)] struct FollowedView { @@ -849,1269 +387,3 @@ pub const PUBLISHING: &str = "The file is being published: you can edit it again fn busy(ds: &DocState) -> bool { ds.interaction.is_some() || ds.preview.is_some() || ds.text_preview.is_some() } - -impl Session { - /// The active document (`None`: no document is open). - fn tab(&self) -> Option { - self.ds().ok().map(|d| d.uid) - } - - /// The active document's live session (status off when it isn't in one). Everything the - /// window shows and the `collab.*` commands act on is this one. - pub fn live(&self) -> &Live { - self.live.on(self.tab()) - } - - /// The active document's live session, when it has one (running, on its way or failed). - pub fn live_mut(&mut self) -> Option<&mut Live> { - let id = self.live.shown(self.tab())?; - self.live.sessions.get_mut(&id) - } - - /// Document `uid`'s live session (status off when it isn't in one). - pub fn live_of(&self, uid: u64) -> &Live { - self.live.on(Some(uid)) - } - - /// The active document is its session's synced document: page and layer ids in others' - /// presence refer to it. - pub fn live_shared(&self) -> bool { - self.tab().is_some_and(|uid| self.live_doc(uid)) - } - - /// Install a document that came from the session (others' edits): the selection, page and view - /// stay where they can, and the undo history isn't touched. - pub fn apply_remote(&mut self, uid: u64, doc: Arc) { - let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) else { return }; - ds.doc = doc; - if !ds.doc.pages.contains(&ds.page) { - ds.page = ds.doc.first_page().unwrap_or(ds.page); - } - if ds.text_edit.as_ref().is_some_and(|t| !ds.doc.contains(t.id)) { - ds.text_edit = None; - } - if ds.vector_edit.as_ref().is_some_and(|v| !ds.doc.contains(v.id)) { - ds.vector_edit = None; - } - ds.clean_selection(); - ds.revision += 1; - } - - /// Your presence in the active document's session now: its page, selection, viewport and - /// edited text, and the pointer. - pub fn live_presence(&self) -> Presence { - self.presence_in(self.live()) - } - - /// Your presence in `live` (nothing while another document than its own is in front). - fn presence_in(&self, live: &Live) -> Presence { - let Some(ds) = self.ds().ok().filter(|ds| live.tab == Some(ds.uid)) else { return Presence::default() }; - let (x0, y0) = ds.view.origin(); - let z = ds.view.zoom.max(1e-9); - sanitize(Presence { - cursor: live.cursor.map(|(x, y)| (ds.page, x, y)), - selection: Some(ds.selection.clone()), - viewport: Some((ds.page, x0, y0, ds.view.vw / z, ds.view.vh / z)), - editing_text: ds.text_edit.as_ref().map(|t| Box::new(editing_of(t))), - }) - } - - /// The others typing in text layer `id` of the active document, in its session. - pub fn live_text_editors(&self, id: NodeId) -> Vec<&Peer> { - if !self.live_shared() { - return Vec::new(); - } - let live = self.live(); - live.presence.iter().filter(|(_, p)| p.editing_text.as_ref().is_some_and(|e| e.node == id)).filter_map(|(c, _)| live.peers.get(c)).collect() - } - - /// You started typing in text layer `id`: say so when someone else is typing in it too (both - /// edits sync when they finish; the one finished last wins). - pub(crate) fn live_text_notice(&mut self, id: NodeId) { - let names: Vec<&str> = self.live_text_editors(id).iter().map(|p| p.name.as_str()).collect(); - if let Some(hint) = editing_hint(&names) { - self.message = Some(format!("{hint} too: the version finished last is kept")); - } - } - - /// Take in what happened in every session (the UI calls it every frame; the network wakes the - /// UI when something arrives). Returns whether anything changed. - pub fn live_poll(&mut self) -> bool { - let Some(link) = self.live.link.as_mut() else { return false }; - let events = link.poll(); - if events.is_empty() { - return false; - } - for (id, e) in events { - self.live_event(id, e); - } - self.live_closed(); - for id in self.live.sessions.keys().copied().collect::>() { - self.live_deferred(id); - self.live_follow_apply(id); - } - self.online_recoveries(); - true - } - - fn live_event(&mut self, id: SessionId, e: Event) { - let e = match e { - Event::Library { link, reply } => return self.library_answer(&link, reply), - Event::Net { job, reply } => return self.background_answer(job, *reply), - e => e, - }; - // A session left meanwhile: what was still on its way is dropped. - let front = self.tab(); - let Some(live) = self.live.sessions.get_mut(&id) else { return }; - // What happens in a file that isn't in front says which file (by its tab's name). - let open = self.docs.iter().find(|d| Some(d.uid) == live.doc_uid).map(|d| d.doc.name.as_str()); - let behind = open.or(live.file_name()).filter(|_| live.tab != front).map(|n| format!(" {n}")).unwrap_or_default(); - match e { - Event::Status(Status::Failed { reason }) => self.live_fail(id, reason), - Event::Status(Status::Off) => { - self.live.end(id); - } - Event::Status(st) => { - // Who's here comes again with the next welcome; edits wait for it. - if let Status::Reconnecting { .. } = st { - live.peers.clear(); - live.presence.clear(); - live.sent = None; - // Until the host says it reads them again (a spotlight of yours goes out - // again then). - live.signals = false; - live.chats.clear(); - live.chat.clear(); - live.spotlight = None; - if let Some(c) = live.client.as_mut() { - c.disconnect(); - } - } - live.status = st; - } - Event::Welcome { mut info, peers } => { - // The shared file's name and pages come with the document (the sync welcome). - if let Some(old) = live.info.as_ref().filter(|_| info.file_name.is_empty()) { - info.file_name = old.file_name.clone(); - info.pages = old.pages.clone(); - } - live.status = Status::Live; - live.info = Some(info); - live.peers = peers.into_iter().map(|p| (p.client_id, p)).collect(); - live.presence.retain(|id, _| live.peers.contains_key(id)); - if live.follow.is_some_and(|f| !live.peers.contains_key(&f)) { - live.unfollow(); - } - // Others need your presence again. - live.sent = None; - } - Event::PeerJoined(p) => { - self.message = Some(format!("{} joined{behind}", p.name)); - live.peers.insert(p.client_id, p); - } - Event::PeerLeft(peer) => { - live.presence.remove(&peer); - live.chats.remove(&peer); - if live.spotlight == Some(peer) { - live.spotlight = None; - } - if let Some(p) = live.peers.remove(&peer) { - self.message = Some(format!("{} left{behind}", p.name)); - } - if live.follow == Some(peer) { - live.unfollow(); - } - } - Event::Presence(peer, p) => { - if live.peers.contains_key(&peer) { - live.presence.insert(peer, p); - } - } - Event::Signal(peer, sig) => self.live_signal_in(id, peer, sig), - Event::Sync(welcome @ ServerMsg::Welcome { .. }) if live.client.is_none() => self.live_welcome(id, welcome), - // Batches, and the welcome back after a dropped connection: the client catches up in - // order. - Event::Sync(msg) => live.deferred.push(msg), - Event::Ended { reason } => { - self.message = Some(if behind.is_empty() { reason } else { format!("{}: {reason}", behind.trim_start()) }); - self.live.end(id); - } - Event::Published(p) => { - // Shared with a code: the popover shows it, nothing more to say. - if live.share.is_none() { - self.message = Some(format!("Published {} to the server", p.name)); - } - let server = live.server.clone(); - live.published = Some(p.clone()); - self.online_published(&server, &p); - } - Event::Shared(Ok(links)) => { - let links = clean_shared(links); - match live.server_file() { - Some((server, file)) => self.online_shared(&server, &file, Some(links)), - None => { - if let Some(share) = live.share.as_mut() { - share.links = Some(links); - } - } - } - } - Event::Shared(Err(why)) => self.message = Some(format!("The file is on the server, but couldn't be shared: {why}")), - Event::Hosting(info) => live.hosting = Some(clean_hosting(info)), - Event::Reported(error) => { - self.message = - Some(error.map_or_else(|| "Your report was sent to the server's operator".into(), |e| format!("The report wasn't sent: {e}"))); - } - // Answered above. - Event::Library { .. } | Event::Net { .. } => {} - } - } - - /// Someone's cursor chat or spotlight in session `id` (checked again: the host is untrusted - /// too), or the host saying it reads them. - fn live_signal_in(&mut self, id: SessionId, peer: u32, sig: Signal) { - let Some(sig) = sig.clean() else { return }; - let Some(live) = self.live.sessions.get_mut(&id) else { return }; - let name = live.peers.get(&peer).map(|p| p.name.clone()); - match (sig.kind.as_str(), name) { - (Signal::READY, _) if live.info.as_ref().is_some_and(|i| i.me == peer) => { - live.signals = true; - if live.spotlighting { - self.live.send(id, [ClientMsg::Signal(Signal::spotlight(true))]); - } - } - (Signal::CHAT, Some(_)) if sig.text.is_empty() => { - live.chats.remove(&peer); - } - (Signal::CHAT, Some(_)) => { - live.chats.insert(peer, sig.text); - } - (Signal::SPOTLIGHT, Some(name)) if sig.on() => { - // Theirs takes over from yours or anyone else's. - live.spotlight = Some(peer); - live.spotlighting = false; - live.follow = Some(peer); - live.followed = None; - self.message = Some(format!("{name} is spotlighting: you follow their view (move it to stop)")); - } - (Signal::SPOTLIGHT, Some(name)) if live.spotlight == Some(peer) => { - live.spotlight = None; - if live.follow == Some(peer) { - live.unfollow(); - } - self.message = Some(format!("{name} stopped spotlighting")); - } - _ => {} - } - } - - /// Why cursor chat or a spotlight can't go out now, if it can't. - fn signal_blocker(&self) -> Option<&'static str> { - let live = self.live(); - if live.status != Status::Live { - Some("cursor chat and spotlight need a live session") - } else if !live.signals { - Some("the session's host runs an older version without cursor chat and spotlight") - } else { - None - } - } - - /// Show `text` next to your pointer for everyone (empty closes it); returns it as sent - /// (control characters out, at most [`Signal::MAX_CHAT`] characters). - pub fn live_chat(&mut self, text: &str) -> Result { - let sig = Signal::chat(text).clean().unwrap_or_default(); - if sig.text.is_empty() && self.live().chat.is_empty() { - return Ok(String::new()); - } - if let Some(why) = self.signal_blocker() { - return Err(EngineError::Disabled("collab.cursorChat".into(), why.into())); - } - let Some(live) = self.live_mut() else { return Ok(String::new()) }; - if sig.text != live.chat { - live.chat.clone_from(&sig.text); - let id = live.id; - self.live.send(id, [ClientMsg::Signal(sig.clone())]); - } - Ok(sig.text) - } - - /// Start (`on`) or stop asking everyone to follow your view. - pub fn live_spotlight(&mut self, on: bool) -> Result<()> { - if on == self.live().spotlighting { - return Ok(()); - } - if on && let Some(why) = self.signal_blocker() { - return Err(EngineError::Disabled("collab.spotlight".into(), why.into())); - } - let Some(live) = self.live_mut() else { return Ok(()) }; - if on { - live.unfollow(); - live.spotlight = None; - } - live.spotlighting = on; - let id = live.id; - self.live.send(id, [ClientMsg::Signal(Signal::spotlight(on))]); - Ok(()) - } - - /// Session `id`'s welcome: start the sync client; the host keeps editing the document it - /// shared, a guest opens the host's file as a new tab (or gets it again in that tab after a - /// reconnect). - fn live_welcome(&mut self, id: SessionId, welcome: ServerMsg) { - let client = match Client::join(welcome) { - Ok(c) => c, - Err(e) => return self.live_fail(id, format!("Couldn't open the shared file: {e}")), - }; - let Some(live) = self.live.sessions.get(&id) else { return }; - let existing = live.doc_uid.filter(|u| self.docs.iter().any(|d| d.uid == *u)); - let (uid, synced) = match (live.role(), existing) { - (Some(Role::Host), Some(uid)) => { - if let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) { - let was_dirty = ds.is_dirty(); - // Undo is per user from now on: earlier whole-document steps would revert - // others' edits. - ds.history.undo.clear(); - ds.history.redo.clear(); - ds.doc_mut().set_client_id(client.id()); - if !was_dirty { - ds.mark_saved(); - } - } - // Edits made since hosting started go out with the first sync. - (uid, None) - } - (Some(Role::Guest), Some(uid)) => { - // Joining again in this tab, or the file you published arriving from the server. - let doc = Arc::new(client.document().clone()); - let was_dirty = self.docs.iter().find(|d| d.uid == uid).is_some_and(DocState::is_dirty); - self.apply_remote(uid, doc.clone()); - if let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) { - // Undo is per user from now on: earlier whole-document steps would revert - // others' edits. - ds.history.undo.clear(); - ds.history.redo.clear(); - if !was_dirty { - ds.mark_saved(); - } - } - (uid, Some(doc)) - } - (Some(Role::Guest), None) => { - // The session's file gets a tab of its own, in front. - let ds = DocState::new(client.document().clone(), None); - self.message = Some(format!("Joined {}", ds.doc.name)); - let opened = (ds.uid, Some(ds.doc.clone())); - self.active = self.docs.len(); - self.docs.push(ds); - opened - } - _ => return, - }; - // A view link: the editing tools are off. - if client.access() == Access::View && self.tab() == Some(uid) && !matches!(self.tool.tool, ToolId::Move | ToolId::Hand) { - self.set_tool(ToolId::Move); - } - let Some(live) = self.live.sessions.get_mut(&id) else { return }; - let doc = client.document(); - if let Some(info) = live.info.as_mut() { - info.file_name = doc.name.clone(); - info.pages = doc.pages.iter().map(|p| PageInfo { id: p.0, name: doc.node(*p).map(|n| n.name.clone()).unwrap_or_default() }).collect(); - } - live.doc_uid = Some(uid); - live.tab = Some(uid); - live.synced = synced; - live.client = Some(client); - live.deferred.clear(); - } - - /// Session `id` is over with a message: it couldn't be joined, the connection was lost for - /// good or sync broke. Its tab shows why. - fn live_fail(&mut self, id: SessionId, reason: String) { - log::warn!("live session: {reason}"); - self.live.leave(id); - if let Some(live) = self.live.sessions.get_mut(&id) { - live.reset(); - live.status = Status::Failed { reason: reason.clone() }; - } - self.message = Some(reason); - } - - /// Why document `uid` can't be edited now, if it can't: you may only view it in its session, - /// or it is on its way to a server. - pub fn live_read_only(&self, uid: u64) -> Option<&'static str> { - let live = self.live.doc(uid)?; - match &live.client { - Some(c) if c.access() == Access::View => Some(VIEW_ONLY), - Some(_) => None, - None if live.status.active() && live.role() != Some(Role::Host) => Some(PUBLISHING), - None => None, - } - } - - /// The active document can't be edited now (see [`live_read_only`](Self::live_read_only)). - pub fn active_read_only(&self) -> Option<&'static str> { - self.tab().and_then(|uid| self.live_read_only(uid)) - } - - /// Your client id in document `uid`'s live session (`None`: it isn't in one). Refused for - /// `cmd` while the session is on its way: there is no id yet. - pub(crate) fn live_client_id(&self, cmd: &str, uid: u64) -> Result> { - let Some(live) = self.live.doc(uid) else { return Ok(None) }; - match &live.client { - Some(c) => Ok(Some(c.id())), - None if live.status.active() => Err(EngineError::Disabled(cmd.into(), "wait until the file's live session has started".into())), - None => Ok(None), - } - } - - /// Branch `id` of document `uid` got its base from the session document just now. - pub(crate) fn live_branch_base(&mut self, uid: u64, id: &str) { - if let Some(live) = self.live.doc_mut(uid).filter(|l| l.syncing()) { - live.branch_bases.insert(id.to_string()); - } - } - - /// Whether branch `id` of document `uid` got its base in the live session it is in now. - pub(crate) fn live_branch_in_session(&self, uid: u64, id: &str) -> bool { - self.live.doc(uid).is_some_and(|l| l.syncing() && l.branch_bases.contains(id)) - } - - /// Whether document `uid` is a live session's synced document: its edits sync and its undo - /// history is the user's own. - pub fn live_doc(&self, uid: u64) -> bool { - self.live.doc(uid).is_some_and(Live::syncing) - } - - /// Sync the edits of session document `uid` as one batch: one step of the user's undo history - /// called `label`, or not undoable (`None`: also what changed it outside a command). Nothing - /// while a gesture or preview shows, or inside a group (it syncs when the group ends). - pub(crate) fn live_edit(&mut self, uid: u64, label: Option<&str>) { - let Some(ds) = self.docs.iter().find(|d| d.uid == uid) else { return }; - let Some(live) = self.live.doc(uid) else { return }; - if live.hold > 0 || busy(ds) || live.synced.as_ref().is_some_and(|s| Arc::ptr_eq(s, &ds.doc)) { - return; - } - let (id, doc, synced) = (live.id, ds.doc.clone(), live.synced.clone()); - if self.live_read_only(uid).is_some() { - // Nothing goes out: what changed outside a command is put back. - if let Some(s) = synced { - self.apply_remote(uid, s); - } - return; - } - let Some(live) = self.live.sessions.get_mut(&id) else { return }; - let Some(client) = live.client.as_mut() else { return }; - match client.edit_with(&doc, label) { - Ok(msg) => { - live.synced = Some(doc); - self.live.send(id, msg); - } - Err(e) => self.live_fail(id, format!("Couldn't sync an edit: {e}")), - } - } - - /// A group of commands on document `uid` starts (`on`) or ends: in between its edits don't - /// sync on their own. - pub(crate) fn live_hold(&mut self, uid: u64, on: bool) { - if let Some(live) = self.live.doc_mut(uid).filter(|l| l.syncing()) { - live.hold = if on { live.hold.saturating_add(1) } else { live.hold.saturating_sub(1) }; - } - } - - /// Undo (`back`) or redo the user's own last step on the active document, in its session: - /// what it wrote goes back where nobody else wrote since. Returns whether there was a step to - /// take back. - pub(crate) fn live_travel(&mut self, back: bool) -> bool { - let Some(uid) = self.tab() else { return false }; - // Edits not synced yet go first. - self.live_edit(uid, None); - let Some(live) = self.live.doc_mut(uid) else { return false }; - let Some(client) = live.client.as_mut() else { return false }; - let (undo, redo) = client.history_len(); - let msgs = match if back { client.undo(&EngineRecompute) } else { client.redo(&EngineRecompute) } { - Ok(m) => m, - Err(e) => { - log::warn!("live session: {} failed: {e}", if back { "undo" } else { "redo" }); - return false; - } - }; - let (u, r) = client.history_len(); - let did = if back { r > redo } else { u > undo }; - let doc = Arc::new(client.document().clone()); - if did { - live.synced = Some(doc.clone()); - } - let id = live.id; - self.live.send(id, msgs); - if did { - self.apply_remote(uid, doc); - } - did - } - - /// Apply the server's batches that waited in session `id` (none while a gesture or preview - /// shows on its document). - fn live_deferred(&mut self, id: SessionId) { - let Some(live) = self.live.sessions.get(&id).filter(|l| !l.deferred.is_empty() && l.syncing()) else { return }; - let Some(uid) = live.doc_uid else { return }; - if self.docs.iter().find(|d| d.uid == uid).is_none_or(busy) { - return; - } - // Your own edits first: the client replays them over what others did. - self.live_edit(uid, None); - let Some(live) = self.live.sessions.get_mut(&id) else { return }; - let Some(client) = live.client.as_mut() else { return }; - let mut out = Vec::new(); - for m in std::mem::take(&mut live.deferred) { - match client.receive(m, &EngineRecompute) { - Ok(more) => out.extend(more), - Err(e) => return self.live_fail(id, format!("The live session got out of step ({e}); join again")), - } - } - let doc = Arc::new(client.document().clone()); - // Edits the server refused were rolled back: say why (the UI's toast). - let notices: Vec = client.take_notices().iter().map(figcraft_collab::Notice::message).collect(); - live.synced = Some(doc.clone()); - if !notices.is_empty() { - self.message = Some(notices.join("; ")); - } - self.live.send(id, out); - self.apply_remote(uid, doc); - } - - /// Show the peer followed in session `id`: their viewport (and their page, in the session's - /// document) on the session's tab. - fn live_follow_apply(&mut self, id: SessionId) { - let Some(live) = self.live.sessions.get_mut(&id) else { return }; - let Some(peer) = live.follow else { return }; - if !live.peers.contains_key(&peer) { - live.unfollow(); - return; - } - let Some((page, x, y, w, h)) = live.presence.get(&peer).and_then(|p| p.viewport) else { return }; - let Some(ds) = self.docs.iter_mut().find(|d| Some(d.uid) == live.tab) else { return }; - if live.syncing() && page != ds.page && ds.doc.pages.contains(&page) { - ds.page = page; - ds.selection.clear(); - ds.text_edit = None; - } - if !(w > 0.0 && h > 0.0 && x.is_finite() && y.is_finite()) { - return; - } - ds.view.cx = x + w / 2.0; - ds.view.cy = y + h / 2.0; - let zoom = (ds.view.vw / w).min(ds.view.vh / h); - if zoom.is_finite() { - ds.view.zoom = zoom.clamp(ViewState::MIN_ZOOM, ViewState::MAX_ZOOM); - } - ds.revision += 1; - live.followed = Some(FollowedView { page: ds.page, cx: ds.view.cx, cy: ds.view.cy, zoom: ds.view.zoom }); - } - - /// Sessions whose tab was closed end (hosts stop hosting, guests leave); the others go on. A - /// join still on its way from a closed tab shows on the one in front. - fn live_closed(&mut self) { - let (docs, tab) = (&self.docs, self.tab()); - let mut gone = Vec::new(); - for l in self.live.sessions.values_mut().filter(|l| l.tab != tab && !docs.iter().any(|d| Some(d.uid) == l.tab)) { - if l.doc_uid.is_none() && l.status.active() { - l.tab = tab; - } else { - gone.push((l.id, l.status == Status::Live)); - } - } - for (id, was_live) in gone { - self.live.end(id); - if was_live { - self.message = Some("The live session's file was closed, so you left the session".into()); - } - } - } - - /// Send your edits and presence in every session when they changed (headless sessions call - /// it after each command). A view you moved yourself stops following. - pub fn live_sync(&mut self) { - self.live_sync_in(|_| true); - } - - /// [`live_sync`](Self::live_sync) for the sessions whose document (`None`: a join on its way) - /// `mine` picks: a window calls it every frame it draws, after input, for the documents it - /// holds. - pub fn live_sync_in(&mut self, mine: impl Fn(Option) -> bool) { - // A recovery copy whose session was left has its outcome too. - if !self.live.recovering.is_empty() { - self.online_recoveries(); - } - if self.live.sessions.is_empty() { - return; - } - self.live_closed(); - let ids: Vec = self.live.sessions.values().filter(|l| l.status == Status::Live && mine(l.doc_uid)).map(|l| l.id).collect(); - for id in ids { - self.live_deferred(id); - if let Some(uid) = self.live.get(id).and_then(|l| l.doc_uid) { - self.live_edit(uid, None); - } - self.live_present(id); - } - } - - /// Hand your presence in session `id` to the link when it changed. - fn live_present(&mut self, id: SessionId) { - let Some(live) = self.live.get(id).filter(|l| l.status == Status::Live) else { return }; - let moved = live.followed.is_some_and(|f| { - self.docs - .iter() - .find(|d| Some(d.uid) == live.tab) - .is_some_and(|ds| ds.page != f.page || !close(ds.view.cx, f.cx) || !close(ds.view.cy, f.cy) || !close(ds.view.zoom, f.zoom)) - }); - let p = self.presence_in(live); - let changed = live.sent.as_ref() != Some(&p); - let Some(live) = self.live.sessions.get_mut(&id) else { return }; - if moved { - live.unfollow(); - } - if changed { - if let Some(link) = self.live.link.as_mut() { - link.presence(id, &p); - } - live.sent = Some(p); - } - } - - /// Stop following in the active document's session (Escape, `collab.follow {}`). False when - /// you weren't following anyone. - pub fn live_unfollow(&mut self) -> bool { - self.live_mut().is_some_and(Live::unfollow) - } - - /// Follow `peer`'s viewport in the active document's session (shown at once when known). - pub fn live_follow(&mut self, peer: u32) -> Result<()> { - let Some(live) = self.live_mut().filter(|l| l.peers.contains_key(&peer)) else { - return Err(bad("collab.follow", format!("nobody with client id {peer} is in the session"))); - }; - live.follow = Some(peer); - live.followed = None; - let id = live.id; - self.live_follow_apply(id); - Ok(()) - } - - /// The active document's session as JSON (`collab.peers`), with every session in brief. - pub fn live_summary(&self) -> Value { - self.summary(self.live()) - } - - /// The index of the open document session `l` syncs. - fn live_index(&self, l: &Live) -> Option { - l.doc_uid.and_then(|u| self.docs.iter().position(|d| d.uid == u)) - } - - fn summary(&self, l: &Live) -> Value { - let info = l.info.as_ref(); - let reason = |l: &Live| match &l.status { - Status::Failed { reason } => Some(reason.clone()), - _ => None, - }; - let synced = self.live_index(l).and_then(|i| self.docs.get(i)); - // Who types in which layer of the session document. - let editing = |e: &EditingText| { - let layer = synced.and_then(|d| d.doc.node(e.node)).map(|n| n.name.as_str()); - json!({"node": e.node, "layer": layer, "caret": e.caret, "selection": e.selection}) - }; - let peers: Vec = l - .peers - .values() - .map(|p| { - let presence = l.presence.get(&p.client_id); - let typing = presence.and_then(|pr| pr.editing_text.as_deref()).map(editing); - let chat = l.chats.get(&p.client_id); - json!({"id": p.client_id, "name": p.name, "color": p.color, "editing": typing, "chat": chat, "presence": presence}) - }) - .collect(); - let mine = synced.and_then(|d| d.text_edit.as_ref()).map(|t| editing(&editing_of(t))); - let sessions: Vec = self - .live - .iter() - .map(|o| { - let info = o.info.as_ref(); - json!({ - "session": o.id, - "document": self.live_index(o), - "status": o.status.id(), - "reason": reason(o), - "role": o.role(), - "fileName": info.map(|i| i.file_name.as_str()), - "link": info.map(|i| i.link.as_str()), - "peers": o.peers.len(), - }) - }) - .collect(); - json!({ - "available": self.live.available(), - "canHost": self.live.can_host(), - "session": (l.id != NO_SESSION).then_some(l.id), - "status": l.status.id(), - "reason": reason(l), - "attempt": match l.status { Status::Reconnecting { attempt } => Some(attempt), _ => None }, - "role": info.map(|i| i.role), - "me": info.map(|i| i.me), - "name": info.map(|i| i.name.as_str()), - "color": info.map(|i| i.color.as_str()), - "fileName": info.map(|i| i.file_name.as_str()), - "pages": info.map(|i| &i.pages), - "link": info.map(|i| i.link.as_str()), - "url": info.map(|i| i.url.as_str()), - "address": info.map(|i| i.address.as_str()), - "network": info.map(|i| i.network), - "access": l.client.as_ref().map(Client::access).or(info.map(|i| i.access)), - "readOnly": l.doc_uid.is_some_and(|u| self.live_read_only(u).is_some()), - "published": l.published, - "code": l.share.as_ref().and_then(Share::code), - "share": l.share.as_ref().map(|sh| { - let (edit, view) = (sh.links.as_ref().map(|s| &s.edit), sh.links.as_ref().and_then(|s| s.view.as_ref())); - let code = |l: Option<&SharedLink>| l.and_then(|l| l.code.as_deref()).map(code::grouped); - json!({ - "via": sh.via, - "access": sh.access.id(), - "shared": sh.links.is_some(), - "editCode": code(edit), - "viewCode": code(view), - "editLink": edit.map(|l| l.link.as_str()), - "viewLink": view.map(|l| l.link.as_str()), - }) - }), - "hosting": l.hosting.as_ref().map(hosting_json), - "follow": l.follow, - "signals": l.signals, - "chat": l.chat, - "spotlight": l.spotlight, - "spotlighting": l.spotlighting, - "syncing": l.syncing(), - "document": self.live_index(l), - "editing": mine, - "peers": peers, - "sessions": sessions, - }) - } -} - -/// What a server says about its hosting, made safe to show: no control characters, short, and a -/// terms address only when it is a web page (`http(s)://`). -fn clean_hosting(mut info: HostInfo) -> HostInfo { - let clean = |s: &str, n: usize| s.trim().chars().filter(|c| !c.is_control()).take(n).collect::(); - info.operator = clean(&info.operator, 120); - info.terms = clean(&info.terms, 500); - let web = info.terms.starts_with("https://") || info.terms.starts_with("http://"); - if !web || info.terms.contains(char::is_whitespace) { - info.terms.clear(); - } - info -} - -/// A server's hosting as JSON (`collab.peers`, `collab.serverInfo`). -pub fn hosting_json(h: &HostInfo) -> Value { - json!({ - "open": h.open, - "operator": h.operator, - "terms": h.terms, - "idleDays": (h.idle_secs > 0).then(|| (h.idle_secs as f64 / 86_400.0 * 100.0).round() / 100.0), - "maxFileMb": h.max_file_mb, - "expires": h.expires, - }) -} - -fn unavailable(cmd: &str) -> EngineError { - EngineError::Disabled( - cmd.to_string(), - "live sessions aren't available here (the desktop app and the MCP server host and join them; the web build joins)".into(), - ) -} - -fn host(s: &mut Session, p: &Value) -> Result { - start_host(s, "collab.host", p, Bind::Local, false)?; - Ok(s.live_summary()) -} - -/// Start hosting the open file for command `cmd` (on `bind` unless `p` says where; `short`: with -/// a secret a join code can carry): the new session. -fn start_host(s: &mut Session, cmd: &str, p: &Value, bind: Bind, short: bool) -> Result { - let port = match get(p, "port") { - None => None, - Some(v) => Some(v.as_u64().and_then(|n| u16::try_from(n).ok()).ok_or_else(|| bad(cmd, "`port` must be a whole number from 0 to 65535"))?), - }; - let bind = match string(p, "bind").as_deref().map(str::trim) { - None => bind, - Some("local") => Bind::Local, - Some("lan") => Bind::Lan, - Some(other) => other.parse::().map(Bind::Addr).map_err(|_| bad(cmd, "`bind` must be \"local\", \"lan\" or an IP address"))?, - }; - let (doc, uid) = s.ds().map(|ds| (ds.doc.clone(), ds.uid))?; - let opts = HostOptions { port, bind, name: display_name(&s.prefs), doc, short }; - let (id, info) = s.live.start(cmd, |l, id| l.host(id, opts).map_err(|e| EngineError::Other(format!("couldn't start the live session: {e}"))))?; - s.live.open(Live { id, status: Status::Live, info: Some(info), doc_uid: Some(uid), tab: Some(uid), ..Live::default() }); - Ok(id) -} - -fn publish(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "collab.publish"; - let server = req_string(CMD, p, "server")?; - start_publish(s, CMD, &server, p, false)?; - Ok(s.live_summary()) -} - -/// Start publishing the open file to `server` for command `cmd` (`share`: with links and join -/// codes for others too): the new session. -fn start_publish(s: &mut Session, cmd: &str, server: &str, p: &Value, share: bool) -> Result { - let key = string(p, "key").map(|k| k.trim().to_string()).filter(|k| !k.is_empty()); - let (doc, uid) = s.ds().map(|ds| (ds.doc.clone(), ds.uid))?; - let name = string(p, "name").map(|n| n.trim().chars().filter(|c| !c.is_control()).take(256).collect::()).filter(|n| !n.is_empty()); - let me = display_name(&s.prefs); - let opts = PublishOptions { server: server.trim().to_string(), key, name: name.unwrap_or_else(|| doc.name.clone()), me, doc, share }; - let (id, ()) = s.live.start(cmd, |l, id| l.publish(id, opts).map_err(|e| bad(cmd, e)))?; - // This tab becomes the server file's synced document once it is there. - s.live.open(Live { id, status: Status::Connecting, doc_uid: Some(uid), tab: Some(uid), server: server.trim().to_string(), ..Live::default() }); - Ok(id) -} - -/// Why a local network share has no view-only code. -const LOCAL_EDITS: &str = "everyone who joins a file shared on the local network can edit it: share it online for a view-only code"; - -fn share(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "collab.share"; - let via = match string(p, "via").as_deref().map(str::trim) { - None | Some("online") => Via::Online, - Some("local") => Via::Local, - Some(_) => return Err(bad(CMD, "`via` must be \"online\" or \"local\"")), - }; - let access = match string(p, "access") { - None => None, - Some(a) => Some(Access::parse(&a).ok_or_else(|| bad(CMD, "`access` must be \"edit\" or \"view\""))?), - }; - // A share of this file is there already: only which code is shown changes, or (the file is - // on the server, not shared at the moment) it gets new links and codes. Nothing is published - // twice. - if let Some(shared) = s.live().share.as_ref().filter(|_| s.live().status.active()).map(|sh| sh.via) { - if get(p, "via").is_some() && shared != via { - return Err(bad( - CMD, - "this file is shared the other way already: stop sharing it first (collab.stop), or leave its session (collab.leave)", - )); - } - if access == Some(Access::View) && shared == Via::Local { - return Err(bad(CMD, LOCAL_EDITS)); - } - if let Some(req) = share_request(s, Vec::new(), true).filter(|_| s.live().share.as_ref().is_some_and(|sh| sh.links.is_none())) { - let links = share_answer(s, req)?.ok_or_else(|| EngineError::Other("the server made no links".into()))?; - shared_now(s, Some(clean_shared(links))); - } - if let (Some(a), Some(sh)) = (access, s.live_mut().and_then(|l| l.share.as_mut())) { - sh.access = a; - } - return Ok(s.live_summary()); - } - let access = access.unwrap_or_default(); - let mut server = String::new(); - let id = match via { - Via::Local if access == Access::View => return Err(bad(CMD, LOCAL_EDITS)), - Via::Local => start_host(s, CMD, p, Bind::Lan, true)?, - Via::Online => { - server = string(p, "server").unwrap_or_else(|| s.prefs.publish_server.clone()).trim().to_string(); - if server.is_empty() { - return Err(EngineError::Disabled(CMD.into(), ONLINE_OFF.into())); - } - start_publish(s, CMD, &server, p, true)? - } - }; - if let Some(live) = s.live.sessions.get_mut(&id) { - // On the local network the session's own link is what others join with, and the code says - // what the link says: where the session is and its secret. (Online they arrive with the file.) - let links = live.info.as_ref().filter(|_| via == Via::Local).map(|i| { - let code = code::Local::from_link(&i.link).map(|l| l.code()); - Shared { edit: SharedLink { id: String::new(), link: i.link.clone(), code }, view: None } - }); - live.share = Some(Share { via, server, access, links }); - } - Ok(s.live_summary()) -} - -/// What others join the open file's server file with changed: in its session and its record. -fn shared_now(s: &mut Session, links: Option) { - if let Some((server, file)) = s.live().server_file() { - s.online_shared(&server, &file, links); - } -} - -/// Links and codes as a server gave them, with only codes that are codes (nine symbols). -pub(crate) fn clean_shared(mut links: Shared) -> Shared { - let clean = |l: &mut SharedLink| l.code = l.code.take().and_then(|c| code::normalize(&c).ok()).filter(|c| c.len() == code::ONLINE_LEN); - clean(&mut links.edit); - links.view.as_mut().map(clean); - links -} - -/// The request that changes who else gets into the server file the open file's session is its -/// owner's session of (`None`: it isn't one, or the file isn't there yet). -fn share_request(s: &Session, revoke: Vec, create: bool) -> Option { - let live = s.live(); - let (share, published) = (live.share.as_ref().filter(|s| s.via == Via::Online)?, live.published.as_ref()?); - if live.status != Status::Live { - return None; - } - // The token this session got when it published the file, else the recorded one. - let owner = published.owner.clone().or_else(|| s.online_owner(&share.server, &published.file)).unwrap_or_default(); - Some(NetRequest::Share { server: share.server.clone(), file: published.file.clone(), owner, revoke, create }) -} - -/// Send `req` ([`NetRequest::Share`]): the new links, if it made any. -pub(crate) fn share_answer(s: &mut Session, req: NetRequest) -> Result> { - match s.net(req)?.0 { - NetReply::Share(Ok(links)) => Ok(links), - NetReply::Share(Err(why)) => Err(EngineError::Other(why)), - _ => Err(EngineError::Other("the server answered something else".into())), - } -} - -/// What the server at `server` says about its hosting, and its answer to `req` about `file` with -/// its owner token (`collab.serverInfo`, `collab.manageFile`). -fn hosting_request(s: &mut Session, cmd: &str, server: &str, file: &str, owner: &str, req: Option) -> Result<(HostInfo, Option)> { - let req = NetRequest::Hosting { server: server.to_string(), file: file.to_string(), owner: owner.to_string(), req }; - match s.net(req)?.0 { - // The server's refusals say why themselves. - NetReply::Hosting(Ok((_, Some(AdminReply::Failed { reason })))) | NetReply::Hosting(Err(reason)) => Err(EngineError::Other(reason)), - NetReply::Hosting(Ok((info, reply))) => Ok((clean_hosting(info), reply)), - _ => Err(EngineError::Other(format!("{cmd}: the server answered something else"))), - } -} - -fn server_info(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "collab.serverInfo"; - let server = req_string(CMD, p, "server")?; - let (info, _) = hosting_request(s, CMD, server.trim(), "", "", None)?; - Ok(hosting_json(&info)) -} - -fn manage_file(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "collab.manageFile"; - let file = req_string(CMD, p, "file")?.trim().to_string(); - let (server, owner) = crate::online::manager(s, CMD, p, &file)?; - let action = req_string(CMD, p, "action")?; - let mut renamed = None; - let req = match action.trim() { - "share" => return crate::online::set_shared(s, CMD, &server, &file, &owner, true), - "unshare" => return crate::online::set_shared(s, CMD, &server, &file, &owner, false), - "links" => Admin::Links { file: file.clone() }, - "createLink" => { - let access = match string(p, "access").as_deref().map(str::trim) { - None | Some("view") => Access::View, - Some("edit") => Access::Edit, - Some(_) => return Err(bad(CMD, "`access` must be \"edit\" or \"view\"")), - }; - Admin::CreateLink { file: file.clone(), access, label: string(p, "label").unwrap_or_default() } - } - "revoke" => Admin::Revoke { file: file.clone(), link: req_string(CMD, p, "link")?.trim().to_string() }, - "rename" => { - let name = req_string(CMD, p, "name")?; - renamed = Some(name.clone()); - Admin::Rename { file: file.clone(), name } - } - "remove" => Admin::Remove { file: file.clone() }, - _ => return Err(bad(CMD, "`action` must be links, createLink, revoke, share, unshare, rename or remove")), - }; - let changes = matches!(req, Admin::Rename { .. } | Admin::Remove { .. }); - let reply = hosting_request(s, CMD, &server, &file, &owner, Some(req))?.1; - // The record follows what the server did. - if changes && reply == Some(AdminReply::Done) { - crate::online::managed(s, &server, &file, renamed.as_deref()); - if renamed.is_none() { - crate::online::removed(s, &server, &file); - } - } - match reply { - Some(AdminReply::Links(links)) => Ok(json!({"links": links.iter().map(|l| json!({ - "id": l.id, "access": l.access.id(), "label": l.label, "created": l.created - })).collect::>()})), - // The link layer gives the new link whole in place of its token. - Some(AdminReply::Link(l)) => Ok(json!({"id": l.link.id, "access": l.link.access.id(), "link": l.token})), - Some(AdminReply::Done) => Ok(json!({"done": true})), - _ => Err(bad(CMD, "the server answered something else")), - } -} - -fn report(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "collab.report"; - let reason: String = req_string(CMD, p, "reason")?.trim().chars().map(|c| if c.is_control() { ' ' } else { c }).take(MAX_REPORT).collect(); - if reason.is_empty() { - return Err(bad(CMD, "say in a few words what is wrong with the file (`reason`)")); - } - let live = s.live(); - if live.status != Status::Live || live.role() != Some(Role::Guest) { - return Err(bad(CMD, "join a file on a FigCraft server first: reports go to that server's operator")); - } - if live.hosting.is_none() { - return Err(bad(CMD, "this server doesn't take reports (it is older than this app); contact its operator another way")); - } - let id = live.id; - s.live.send(id, [ClientMsg::Report { reason }]); - Ok(json!({"sent": true})) -} - -/// Longest report sent (characters). -pub const MAX_REPORT: usize = 500; - -fn join(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "collab.join"; - let typed = match string(p, "code") { - Some(code) => code, - None => req_string(CMD, p, "link").map_err(|_| bad(CMD, "give the join `code` you were told, or the invite `link`"))?, - }; - let link = join_target(&s.prefs.publish_server, &typed).map_err(|e| bad(CMD, e))?; - let id = start_join(s, CMD, &link, None, None, None)?; - Ok(s.live.get(id).map_or(Value::Null, |l| s.summary(l))) -} - -/// Start joining the session behind `link` for command `cmd`: the new session. `published` and -/// `share`: the file is yours on a server (`collab.openFile`), with what others join it with; -/// `resume`: as the client you were there. -pub(crate) fn start_join( - s: &mut Session, - cmd: &str, - link: &str, - published: Option, - share: Option, - resume: Option, -) -> Result { - let (name, tab) = (display_name(&s.prefs), s.tab()); - let (id, ()) = s.live.start(cmd, |l, id| l.join_resuming(id, link, &name, resume).map_err(|e| bad(cmd, e)))?; - let server = share.as_ref().map(|s| s.server.clone()).unwrap_or_default(); - // The session's file opens in a tab of its own; until then the join shows on this one. - s.live.open(Live { id, status: Status::Connecting, tab, server, published, share, ..Live::default() }); - Ok(id) -} - -fn leave(s: &mut Session, _: &Value) -> Result { - let ended = s.live.shown(s.tab()).and_then(|id| s.live.end(id)); - if ended.as_ref().is_some_and(|l| l.syncing() && l.role() == Some(Role::Guest)) { - s.message = Some("You left the session; your copy stays open here".into()); - } - Ok(json!({"ended": ended.is_some_and(|l| l.status.active())})) -} - -fn stop(s: &mut Session, p: &Value) -> Result { - // A file shared online stays its owner's on the server: stopping takes the others' links (and - // their codes) away, which disconnects whoever joined with them, and the owner stays in the - // file. Anything else (a session this app hosts, one it joined, a file not shared now) ends. - let shared = s.live().share.as_ref().and_then(|sh| sh.links.as_ref()); - let ids: Vec = shared.into_iter().flat_map(|l| [Some(&l.edit), l.view.as_ref()]).flatten().map(|l| l.id.clone()).collect(); - let Some(req) = share_request(s, ids, false).filter(|_| shared.is_some()) else { return leave(s, p) }; - share_answer(s, req).map_err(|e| EngineError::Other(format!("couldn't stop sharing (the others can still get in): {e}")))?; - shared_now(s, None); - s.message = Some("Sharing stopped: the file stays on the server, open only to you".into()); - Ok(json!({"ended": false, "shared": false})) -} - -fn follow(s: &mut Session, p: &Value) -> Result { - match get(p, "client") { - None => { - s.live_unfollow(); - } - Some(v) => { - let id = v.as_u64().and_then(|n| u32::try_from(n).ok()).ok_or_else(|| bad("collab.follow", "`client` must be a client id"))?; - s.live_follow(id)?; - } - } - Ok(json!({"follow": s.live().follow})) -} - -/// Remove someone from the open file's session: in a session this app hosts, the hub drops them; -/// in a file of yours on a server, the server does (with the file's owner token). They are told -/// the session is over for them, keep their copy, and can't join again from their address while -/// the session (or the server) runs. -fn remove_person(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "collab.removePerson"; - let client = - get(p, "client").and_then(Value::as_u64).and_then(|n| u32::try_from(n).ok()).ok_or_else(|| bad(CMD, "`client` must be a client id"))?; - let live = s.live(); - if live.status != Status::Live { - return Err(bad(CMD, "the open file isn't in a live session")); - } - let name = - live.peers.get(&client).map(|p| p.name.clone()).ok_or_else(|| bad(CMD, format!("nobody with client id {client} is in the session")))?; - let (id, role, server_file) = (live.id, live.role(), live.server_file()); - match (role, server_file) { - (Some(Role::Host), _) => { - let link = s.live.link.as_deref_mut().ok_or_else(|| unavailable(CMD))?; - link.remove(id, client).map_err(EngineError::Other)?; - } - (Some(Role::Guest), Some((server, file))) => { - let owner = - s.live().published.as_ref().and_then(|p| p.owner.clone()).or_else(|| s.online_owner(&server, &file)).filter(|o| !o.is_empty()); - let owner = owner.ok_or_else(|| bad(CMD, "only the file's owner can remove people from it"))?; - let req = Some(Admin::RemovePeer { file: file.clone(), client }); - match hosting_request(s, CMD, &server, &file, &owner, req) { - Ok((_, Some(AdminReply::Done))) => {} - Ok(_) => return Err(EngineError::Other(format!("couldn't remove {name}: the server answered something else"))), - // A server older than this request can't read it. - Err(e) => return Err(EngineError::Other(format!("couldn't remove {name}: {e} (the server may be older than removing people)"))), - } - } - _ => return Err(bad(CMD, "only the person hosting the session, or the file's owner, can remove people")), - } - s.message = Some(format!("{name} was removed from the session")); - Ok(json!({"removed": client, "name": name})) -} - -/// You may remove others from the open file's session: you host it, or it is your file on a -/// server. -pub fn can_remove_people(s: &Session) -> bool { - let live = s.live(); - live.status == Status::Live - && match live.role() { - Some(Role::Host) => true, - Some(Role::Guest) => live.server_file().is_some_and(|(server, file)| { - live.published.as_ref().and_then(|p| p.owner.as_deref()).is_some_and(|o| !o.is_empty()) - || s.online_owner(&server, &file).is_some_and(|o| !o.is_empty()) - }), - None => false, - } -} - -fn cursor(s: &mut Session, p: &Value) -> Result { - let at = match (num(p, "x"), num(p, "y")) { - (Some(x), Some(y)) => Some((x, y)), - (None, None) => None, - _ => return Err(bad("collab.cursor", "give both `x` and `y` (page coordinates), or neither to hide the cursor")), - }; - if let Some(l) = s.live_mut() { - l.cursor = at; - } - s.live_sync(); - Ok(json!({"cursor": s.live().cursor.map(|(x, y)| [x, y])})) -} - -fn cursor_chat(s: &mut Session, p: &Value) -> Result { - let Some(v) = get(p, "text") else { - // The app's chat box (programmatic calls never open dialogs). - let open = s.signal_blocker().is_none(); - if open { - s.request_ui("dialog.cursorChat", json!({})); - } - return Ok(json!({"open": open, "chat": s.live().chat})); - }; - let text = v.as_str().ok_or_else(|| bad("collab.cursorChat", "`text` must be a string (empty closes the chat)"))?; - Ok(json!({"chat": s.live_chat(text)?})) -} - -fn spotlight(s: &mut Session, p: &Value) -> Result { - let on = match get(p, "on") { - None => !s.live().spotlighting, - Some(v) => v.as_bool().ok_or_else(|| bad("collab.spotlight", "`on` must be true or false"))?, - }; - s.live_spotlight(on)?; - Ok(json!({"spotlighting": s.live().spotlighting})) -} - -pub fn specs() -> Vec { - vec![ - spec!( - "collab.host", - "Start live session", - "{port? (default 47900, else any free port), bind?: \"local\" (this machine, default) | \"lan\" (the local network) | an IP address} → the open file's session: {session, link, url (invites: they carry the secret token), address, …}; sessions are per file, so other files' sessions go on (a session this file was in ends)", - false, - host - ), - spec!( - "collab.share", - "Share with a join code", - "{via?: \"online\" (default: the open file is kept on the server in Preferences, `pref.publishServer`, and the server hands out the codes) | \"local\" (this app hosts it for people on the same network; no server), access?: \"edit\" (default) | \"view\" (online only: which code `code` is), server? (online: another server's address, with ?key=… when it needs its key), name? (online: the file's name there), port?, bind?: \"lan\" (default) | \"local\" (this machine only) | an IP address (local)} → the open file's session as `collab.peers` gives it: `code` is what others type to join (`collab.join {code}`, the Join field), `link` an invite link. Local: at once, the code carries the host's IPv4 address, port and secret (K7M2Q-…, 20 symbols). Online: status \"connecting\" first; once live `code` is the server's (K7M-2QX-9TD) and `share` is {via, access, shared, editCode, viewCode, editLink, viewLink}: links made for others (the file's own links in `published` stay yours); `code` stays null when the server is older than join codes (give `share.editLink` / `share.viewLink` instead). Called again while shared: only switches which code `code` is (`access`). Called after `collab.stop` on a file that is still on the server: new links and codes for that same file (nothing is published twice). `collab.stop` stops sharing", - false, - share - ), - spec!( - "collab.stop", - "Stop live session", - "{} → {ended}: the open file's session ends (a session this app hosts stops for everyone; a joined one is left). A file shared online with `collab.share` is different: its sharing stops → {ended: false, shared: false}: the links and codes others got are revoked on the server, whoever joined with them is disconnected at once (they keep their copy), and you stay in the file, which stays on the server, open only to you (`collab.share` shares it again with new codes; `collab.leave` leaves it there; `collab.manageFile {action: \"remove\"}` with its owner token takes it off the server)", - false, - stop - ), - spec!( - "collab.join", - "Join live session", - "{code: a join code (K7M-2QX-9TD from a server, asked at the server in Preferences; a longer one for the local network; spaces, dashes and case don't matter, O reads as 0 and I or L as 1) | link: figcraft://join?host=…&file=…&token=…, figcraft://join?server=ws(s)://…&file=…&token=… or ws(s)://host:port/?file=…&token=… (a session an app hosts, or a server's share link; a code here works too)} → the new session: {session, status: \"connecting\", …}; its file opens in a tab of its own (other files' sessions go on) and `collab.peers` tells when it's live (`sessions` lists it until then, and if it fails)", - false, - join - ), - spec!( - "collab.leave", - "Leave live session", - "{} (the open file's session; the synced file stays open as a local copy) → {ended}", - false, - leave - ), - spec!( - "collab.publish", - "Publish to server", - "{server: ws://host:port/?key=… or wss://host/path/?key=… (the server's address with its key; `figcraft-server key` prints it; without a key on a server open to the public), key? (when the address has none), name? (default: the file's name)} → status \"connecting\": the open file is uploaded as a new file and this tab syncs through the server; `collab.peers` then has `published: {server, file, name, edit, view, owner (published without a key: the owner token, shown once), expires (seconds since 1970, or null)}` and `hosting` (the server's operator, terms and idle expiry)", - false, - publish - ), - spec!( - "collab.serverInfo", - "Server info", - "{server: ws://host:port/ or wss://host/path/} → {open (anyone may publish without the key), operator, terms (a web address), idleDays (files published without the key are deleted after this many days unopened; null: kept), maxFileMb, expires}", - false, - server_info - ), - spec!( - "collab.manageFile", - "Manage published file", - "{file (its id), server?, owner? (the server's address and the owner token you got when you published the file without the key; each defaults to what is recorded for one of your online files, see `collab.files`), action: links | createLink {access?: view (default) | edit, label?} | revoke {link (a link's id)} | share (others get new links and join codes) | unshare (the links and codes others got are revoked; the file stays yours on the server) | rename {name} | remove (the file is deleted from the server and leaves your online files)} → {links: [{id, access, label, created}]} | {id, access, link (a new share link, shown once)} | {shared, code, viewCode, editLink, viewLink} | {done}", - false, - manage_file - ), - spec!( - "collab.report", - "Report file to server operator", - "{reason (a few words, at most 500 characters)} → {sent}: the file you joined on a server is reported to its operator; the session's message says when the server kept it (servers older than this app don't take reports)", - false, - report - ), - spec!( - "collab.peers", - "People in this file", - "{} → the open file's session (each open file has its own or none): {available, canHost (false in the browser: it can only join), session (its id; null: this file isn't in a session), status: off|connecting|live|reconnecting|failed, reason?, role, me, name, color, fileName, pages, link, url, address, network, access: edit|view (view: a server's view link; edits are refused), readOnly (the open file can't be edited now: view only, or being published), published (a file you published: {server, file, name, edit, view}), code (the join code to give others, when the file was shared with `collab.share`; null otherwise or while it is on its way), share ({via: online|local, access, shared (false: a file of yours on a server that nobody else gets into now), editCode, viewCode, editLink, viewLink (what others join with; null when not shared)} or null), follow, syncing, document (index of the synced open document), editing (the text layer you type in: {node, layer, caret, selection}), peers: [{id, name, color, editing (theirs, same shape, or null), presence: {cursor: [page, x, y], selection, viewport: [page, x, y, w, h], editing_text}}], sessions: [{session, document (index of its open document; null for a join on its way), status, reason, role, fileName, link, peers (how many others)}] (every session of the app)}", - false, - |s, _| Ok(s.live_summary()) - ), - spec!("collab.follow", "Follow", "{client? (a peer's id; omit to stop following)} → {follow}", false, follow), - spec!( - "collab.removePerson", - "Remove from session", - "{client (a peer's id)} → {removed, name}: they are disconnected (they keep their copy) and can't join again from their address while the session runs; only the person hosting, or the owner of a file on a server, can do it", - false, - remove_person - ), - spec!( - "collab.cursor", - "Show cursor at", - "{x?, y? (page coordinates on the current page; omit both to hide)} → {cursor} (agents; in the app the pointer drives it): your pointer in the open file's session (null when the file isn't in one)", - false, - cursor - ), - spec!( - "collab.cursorChat", - "Cursor chat", - "{text? (shown next to your cursor for everyone, at most 100 characters; empty closes it; omit to open the chat box in the app)} → {chat} (or {open}); others see it fade after 5 s without changes", - false, - cursor_chat - ), - spec!( - "collab.spotlight", - "Spotlight me", - "{on? (default: toggle)} → {spotlighting}: everyone in the session follows your view and page until they move theirs", - false, - spotlight - ), - ] -} diff --git a/crates/engine/src/sharing/live/events.rs b/crates/engine/src/sharing/live/events.rs new file mode 100644 index 00000000..5b6008f8 --- /dev/null +++ b/crates/engine/src/sharing/live/events.rs @@ -0,0 +1,408 @@ +use super::host::{clean_hosting, clean_shared}; +use super::types::{Event, PageInfo, Role, SessionId, Status}; +use super::*; + +impl Session { + /// The active document (`None`: no document is open). + pub(super) fn tab(&self) -> Option { + self.ds().ok().map(|d| d.uid) + } + + /// The active document's live session (status off when it isn't in one). Everything the + /// window shows and the `collab.*` commands act on is this one. + pub fn live(&self) -> &Live { + self.live.on(self.tab()) + } + + /// The active document's live session, when it has one (running, on its way or failed). + pub fn live_mut(&mut self) -> Option<&mut Live> { + let id = self.live.shown(self.tab())?; + self.live.sessions.get_mut(&id) + } + + /// Document `uid`'s live session (status off when it isn't in one). + pub fn live_of(&self, uid: u64) -> &Live { + self.live.on(Some(uid)) + } + + /// The active document is its session's synced document: page and layer ids in others' + /// presence refer to it. + pub fn live_shared(&self) -> bool { + self.tab().is_some_and(|uid| self.live_doc(uid)) + } + + /// Install a document that came from the session (others' edits): the selection, page and view + /// stay where they can, and the undo history isn't touched. + pub fn apply_remote(&mut self, uid: u64, doc: Arc) { + let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) else { return }; + ds.doc = doc; + if !ds.doc.pages.contains(&ds.page) { + ds.page = ds.doc.first_page().unwrap_or(ds.page); + } + if ds.text_edit.as_ref().is_some_and(|t| !ds.doc.contains(t.id)) { + ds.text_edit = None; + } + if ds.vector_edit.as_ref().is_some_and(|v| !ds.doc.contains(v.id)) { + ds.vector_edit = None; + } + ds.clean_selection(); + ds.revision += 1; + } + + /// Your presence in the active document's session now: its page, selection, viewport and + /// edited text, and the pointer. + pub fn live_presence(&self) -> Presence { + self.presence_in(self.live()) + } + + /// Your presence in `live` (nothing while another document than its own is in front). + pub(super) fn presence_in(&self, live: &Live) -> Presence { + let Some(ds) = self.ds().ok().filter(|ds| live.tab == Some(ds.uid)) else { return Presence::default() }; + let (x0, y0) = ds.view.origin(); + let z = ds.view.zoom.max(1e-9); + sanitize(Presence { + cursor: live.cursor.map(|(x, y)| (ds.page, x, y)), + selection: Some(ds.selection.clone()), + viewport: Some((ds.page, x0, y0, ds.view.vw / z, ds.view.vh / z)), + editing_text: ds.text_edit.as_ref().map(|t| Box::new(editing_of(t))), + }) + } + + /// The others typing in text layer `id` of the active document, in its session. + pub fn live_text_editors(&self, id: NodeId) -> Vec<&Peer> { + if !self.live_shared() { + return Vec::new(); + } + let live = self.live(); + live.presence.iter().filter(|(_, p)| p.editing_text.as_ref().is_some_and(|e| e.node == id)).filter_map(|(c, _)| live.peers.get(c)).collect() + } + + /// You started typing in text layer `id`: say so when someone else is typing in it too (both + /// edits sync when they finish; the one finished last wins). + pub(crate) fn live_text_notice(&mut self, id: NodeId) { + let names: Vec<&str> = self.live_text_editors(id).iter().map(|p| p.name.as_str()).collect(); + if let Some(hint) = editing_hint(&names) { + self.message = Some(format!("{hint} too: the version finished last is kept")); + } + } + + /// Take in what happened in every session (the UI calls it every frame; the network wakes the + /// UI when something arrives). Returns whether anything changed. + pub fn live_poll(&mut self) -> bool { + let Some(link) = self.live.link.as_mut() else { return false }; + let events = link.poll(); + if events.is_empty() { + return false; + } + for (id, e) in events { + self.live_event(id, e); + } + self.live_closed(); + for id in self.live.sessions.keys().copied().collect::>() { + self.live_deferred(id); + self.live_follow_apply(id); + } + self.online_recoveries(); + true + } + + fn live_event(&mut self, id: SessionId, e: Event) { + let e = match e { + Event::Library { link, reply } => return self.library_answer(&link, reply), + Event::Net { job, reply } => return self.background_answer(job, *reply), + e => e, + }; + // A session left meanwhile: what was still on its way is dropped. + let front = self.tab(); + let Some(live) = self.live.sessions.get_mut(&id) else { return }; + // What happens in a file that isn't in front says which file (by its tab's name). + let open = self.docs.iter().find(|d| Some(d.uid) == live.doc_uid).map(|d| d.doc.name.as_str()); + let behind = open.or(live.file_name()).filter(|_| live.tab != front).map(|n| format!(" {n}")).unwrap_or_default(); + match e { + Event::Status(Status::Failed { reason }) => self.live_fail(id, reason), + Event::Status(Status::Off) => { + self.live.end(id); + } + Event::Status(st) => { + // Who's here comes again with the next welcome; edits wait for it. + if let Status::Reconnecting { .. } = st { + live.peers.clear(); + live.presence.clear(); + live.sent = None; + // Until the host says it reads them again (a spotlight of yours goes out + // again then). + live.signals = false; + live.chats.clear(); + live.chat.clear(); + live.spotlight = None; + if let Some(c) = live.client.as_mut() { + c.disconnect(); + } + } + live.status = st; + } + Event::Welcome { mut info, peers } => { + // The shared file's name and pages come with the document (the sync welcome). + if let Some(old) = live.info.as_ref().filter(|_| info.file_name.is_empty()) { + info.file_name = old.file_name.clone(); + info.pages = old.pages.clone(); + } + live.status = Status::Live; + live.info = Some(info); + live.peers = peers.into_iter().map(|p| (p.client_id, p)).collect(); + live.presence.retain(|id, _| live.peers.contains_key(id)); + if live.follow.is_some_and(|f| !live.peers.contains_key(&f)) { + live.unfollow(); + } + // Others need your presence again. + live.sent = None; + } + Event::PeerJoined(p) => { + self.message = Some(format!("{} joined{behind}", p.name)); + live.peers.insert(p.client_id, p); + } + Event::PeerLeft(peer) => { + live.presence.remove(&peer); + live.chats.remove(&peer); + if live.spotlight == Some(peer) { + live.spotlight = None; + } + if let Some(p) = live.peers.remove(&peer) { + self.message = Some(format!("{} left{behind}", p.name)); + } + if live.follow == Some(peer) { + live.unfollow(); + } + } + Event::Presence(peer, p) => { + if live.peers.contains_key(&peer) { + live.presence.insert(peer, p); + } + } + Event::Signal(peer, sig) => self.live_signal_in(id, peer, sig), + Event::Sync(welcome @ ServerMsg::Welcome { .. }) if live.client.is_none() => self.live_welcome(id, welcome), + // Batches, and the welcome back after a dropped connection: the client catches up in + // order. + Event::Sync(msg) => live.deferred.push(msg), + Event::Ended { reason } => { + self.message = Some(if behind.is_empty() { reason } else { format!("{}: {reason}", behind.trim_start()) }); + self.live.end(id); + } + Event::Published(p) => { + // Shared with a code: the popover shows it, nothing more to say. + if live.share.is_none() { + self.message = Some(format!("Published {} to the server", p.name)); + } + let server = live.server.clone(); + live.published = Some(p.clone()); + self.online_published(&server, &p); + } + Event::Shared(Ok(links)) => { + let links = clean_shared(links); + match live.server_file() { + Some((server, file)) => self.online_shared(&server, &file, Some(links)), + None => { + if let Some(share) = live.share.as_mut() { + share.links = Some(links); + } + } + } + } + Event::Shared(Err(why)) => self.message = Some(format!("The file is on the server, but couldn't be shared: {why}")), + Event::Hosting(info) => live.hosting = Some(clean_hosting(info)), + Event::Reported(error) => { + self.message = + Some(error.map_or_else(|| "Your report was sent to the server's operator".into(), |e| format!("The report wasn't sent: {e}"))); + } + // Answered above. + Event::Library { .. } | Event::Net { .. } => {} + } + } + + /// Someone's cursor chat or spotlight in session `id` (checked again: the host is untrusted + /// too), or the host saying it reads them. + fn live_signal_in(&mut self, id: SessionId, peer: u32, sig: Signal) { + let Some(sig) = sig.clean() else { return }; + let Some(live) = self.live.sessions.get_mut(&id) else { return }; + let name = live.peers.get(&peer).map(|p| p.name.clone()); + match (sig.kind.as_str(), name) { + (Signal::READY, _) if live.info.as_ref().is_some_and(|i| i.me == peer) => { + live.signals = true; + if live.spotlighting { + self.live.send(id, [ClientMsg::Signal(Signal::spotlight(true))]); + } + } + (Signal::CHAT, Some(_)) if sig.text.is_empty() => { + live.chats.remove(&peer); + } + (Signal::CHAT, Some(_)) => { + live.chats.insert(peer, sig.text); + } + (Signal::SPOTLIGHT, Some(name)) if sig.on() => { + // Theirs takes over from yours or anyone else's. + live.spotlight = Some(peer); + live.spotlighting = false; + live.follow = Some(peer); + live.followed = None; + self.message = Some(format!("{name} is spotlighting: you follow their view (move it to stop)")); + } + (Signal::SPOTLIGHT, Some(name)) if live.spotlight == Some(peer) => { + live.spotlight = None; + if live.follow == Some(peer) { + live.unfollow(); + } + self.message = Some(format!("{name} stopped spotlighting")); + } + _ => {} + } + } + + /// Why cursor chat or a spotlight can't go out now, if it can't. + pub(super) fn signal_blocker(&self) -> Option<&'static str> { + let live = self.live(); + if live.status != Status::Live { + Some("cursor chat and spotlight need a live session") + } else if !live.signals { + Some("the session's host runs an older version without cursor chat and spotlight") + } else { + None + } + } + + /// Show `text` next to your pointer for everyone (empty closes it); returns it as sent + /// (control characters out, at most [`Signal::MAX_CHAT`] characters). + pub fn live_chat(&mut self, text: &str) -> Result { + let sig = Signal::chat(text).clean().unwrap_or_default(); + if sig.text.is_empty() && self.live().chat.is_empty() { + return Ok(String::new()); + } + if let Some(why) = self.signal_blocker() { + return Err(EngineError::Disabled("collab.cursorChat".into(), why.into())); + } + let Some(live) = self.live_mut() else { return Ok(String::new()) }; + if sig.text != live.chat { + live.chat.clone_from(&sig.text); + let id = live.id; + self.live.send(id, [ClientMsg::Signal(sig.clone())]); + } + Ok(sig.text) + } + + /// Start (`on`) or stop asking everyone to follow your view. + pub fn live_spotlight(&mut self, on: bool) -> Result<()> { + if on == self.live().spotlighting { + return Ok(()); + } + if on && let Some(why) = self.signal_blocker() { + return Err(EngineError::Disabled("collab.spotlight".into(), why.into())); + } + let Some(live) = self.live_mut() else { return Ok(()) }; + if on { + live.unfollow(); + live.spotlight = None; + } + live.spotlighting = on; + let id = live.id; + self.live.send(id, [ClientMsg::Signal(Signal::spotlight(on))]); + Ok(()) + } + + /// Session `id`'s welcome: start the sync client; the host keeps editing the document it + /// shared, a guest opens the host's file as a new tab (or gets it again in that tab after a + /// reconnect). + fn live_welcome(&mut self, id: SessionId, welcome: ServerMsg) { + let client = match Client::join(welcome) { + Ok(c) => c, + Err(e) => return self.live_fail(id, format!("Couldn't open the shared file: {e}")), + }; + let Some(live) = self.live.sessions.get(&id) else { return }; + let existing = live.doc_uid.filter(|u| self.docs.iter().any(|d| d.uid == *u)); + let (uid, synced) = match (live.role(), existing) { + (Some(Role::Host), Some(uid)) => { + if let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) { + let was_dirty = ds.is_dirty(); + // Undo is per user from now on: earlier whole-document steps would revert + // others' edits. + ds.history.undo.clear(); + ds.history.redo.clear(); + ds.doc_mut().set_client_id(client.id()); + if !was_dirty { + ds.mark_saved(); + } + } + // Edits made since hosting started go out with the first sync. + (uid, None) + } + (Some(Role::Guest), Some(uid)) => { + // Joining again in this tab, or the file you published arriving from the server. + let doc = Arc::new(client.document().clone()); + let was_dirty = self.docs.iter().find(|d| d.uid == uid).is_some_and(DocState::is_dirty); + self.apply_remote(uid, doc.clone()); + if let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) { + // Undo is per user from now on: earlier whole-document steps would revert + // others' edits. + ds.history.undo.clear(); + ds.history.redo.clear(); + if !was_dirty { + ds.mark_saved(); + } + } + (uid, Some(doc)) + } + (Some(Role::Guest), None) => { + // The session's file gets a tab of its own, in front. + let ds = DocState::new(client.document().clone(), None); + self.message = Some(format!("Joined {}", ds.doc.name)); + let opened = (ds.uid, Some(ds.doc.clone())); + self.active = self.docs.len(); + self.docs.push(ds); + opened + } + _ => return, + }; + // A view link: the editing tools are off. + if client.access() == Access::View && self.tab() == Some(uid) && !matches!(self.tool.tool, ToolId::Move | ToolId::Hand) { + self.set_tool(ToolId::Move); + } + let Some(live) = self.live.sessions.get_mut(&id) else { return }; + let doc = client.document(); + if let Some(info) = live.info.as_mut() { + info.file_name = doc.name.clone(); + info.pages = doc.pages.iter().map(|p| PageInfo { id: p.0, name: doc.node(*p).map(|n| n.name.clone()).unwrap_or_default() }).collect(); + } + live.doc_uid = Some(uid); + live.tab = Some(uid); + live.synced = synced; + live.client = Some(client); + live.deferred.clear(); + } + + /// Session `id` is over with a message: it couldn't be joined, the connection was lost for + /// good or sync broke. Its tab shows why. + pub(super) fn live_fail(&mut self, id: SessionId, reason: String) { + log::warn!("live session: {reason}"); + self.live.leave(id); + if let Some(live) = self.live.sessions.get_mut(&id) { + live.reset(); + live.status = Status::Failed { reason: reason.clone() }; + } + self.message = Some(reason); + } + + /// Why document `uid` can't be edited now, if it can't: you may only view it in its session, + /// or it is on its way to a server. + pub fn live_read_only(&self, uid: u64) -> Option<&'static str> { + let live = self.live.doc(uid)?; + match &live.client { + Some(c) if c.access() == Access::View => Some(VIEW_ONLY), + Some(_) => None, + None if live.status.active() && live.role() != Some(Role::Host) => Some(PUBLISHING), + None => None, + } + } + + /// The active document can't be edited now (see [`live_read_only`](Self::live_read_only)). + pub fn active_read_only(&self) -> Option<&'static str> { + self.tab().and_then(|uid| self.live_read_only(uid)) + } +} diff --git a/crates/engine/src/sharing/live/host.rs b/crates/engine/src/sharing/live/host.rs new file mode 100644 index 00000000..685ab5c0 --- /dev/null +++ b/crates/engine/src/sharing/live/host.rs @@ -0,0 +1,272 @@ +use super::types::{Bind, HostOptions, NetReply, NetRequest, ONLINE_OFF, PublishOptions, Role, SessionId, Share, Shared, SharedLink, Status, Via}; +use super::*; + +/// What a server says about its hosting, made safe to show: no control characters, short, and a +/// terms address only when it is a web page (`http(s)://`). +pub(super) fn clean_hosting(mut info: HostInfo) -> HostInfo { + let clean = |s: &str, n: usize| s.trim().chars().filter(|c| !c.is_control()).take(n).collect::(); + info.operator = clean(&info.operator, 120); + info.terms = clean(&info.terms, 500); + let web = info.terms.starts_with("https://") || info.terms.starts_with("http://"); + if !web || info.terms.contains(char::is_whitespace) { + info.terms.clear(); + } + info +} + +/// A server's hosting as JSON (`collab.peers`, `collab.serverInfo`). +pub fn hosting_json(h: &HostInfo) -> Value { + json!({ + "open": h.open, + "operator": h.operator, + "terms": h.terms, + "idleDays": (h.idle_secs > 0).then(|| (h.idle_secs as f64 / 86_400.0 * 100.0).round() / 100.0), + "maxFileMb": h.max_file_mb, + "expires": h.expires, + }) +} + +pub(super) fn unavailable(cmd: &str) -> EngineError { + EngineError::Disabled( + cmd.to_string(), + "live sessions aren't available here (the desktop app and the MCP server host and join them; the web build joins)".into(), + ) +} + +pub(super) fn host(s: &mut Session, p: &Value) -> Result { + start_host(s, "collab.host", p, Bind::Local, false)?; + Ok(s.live_summary()) +} + +/// Start hosting the open file for command `cmd` (on `bind` unless `p` says where; `short`: with +/// a secret a join code can carry): the new session. +fn start_host(s: &mut Session, cmd: &str, p: &Value, bind: Bind, short: bool) -> Result { + let port = match get(p, "port") { + None => None, + Some(v) => Some(v.as_u64().and_then(|n| u16::try_from(n).ok()).ok_or_else(|| bad(cmd, "`port` must be a whole number from 0 to 65535"))?), + }; + let bind = match string(p, "bind").as_deref().map(str::trim) { + None => bind, + Some("local") => Bind::Local, + Some("lan") => Bind::Lan, + Some(other) => other.parse::().map(Bind::Addr).map_err(|_| bad(cmd, "`bind` must be \"local\", \"lan\" or an IP address"))?, + }; + let (doc, uid) = s.ds().map(|ds| (ds.doc.clone(), ds.uid))?; + let opts = HostOptions { port, bind, name: display_name(&s.prefs), doc, short }; + let (id, info) = s.live.start(cmd, |l, id| l.host(id, opts).map_err(|e| EngineError::Other(format!("couldn't start the live session: {e}"))))?; + s.live.open(Live { id, status: Status::Live, info: Some(info), doc_uid: Some(uid), tab: Some(uid), ..Live::default() }); + Ok(id) +} + +pub(super) fn publish(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "collab.publish"; + let server = req_string(CMD, p, "server")?; + start_publish(s, CMD, &server, p, false)?; + Ok(s.live_summary()) +} + +/// Start publishing the open file to `server` for command `cmd` (`share`: with links and join +/// codes for others too): the new session. +fn start_publish(s: &mut Session, cmd: &str, server: &str, p: &Value, share: bool) -> Result { + let key = string(p, "key").map(|k| k.trim().to_string()).filter(|k| !k.is_empty()); + let (doc, uid) = s.ds().map(|ds| (ds.doc.clone(), ds.uid))?; + let name = string(p, "name").map(|n| n.trim().chars().filter(|c| !c.is_control()).take(256).collect::()).filter(|n| !n.is_empty()); + let me = display_name(&s.prefs); + let opts = PublishOptions { server: server.trim().to_string(), key, name: name.unwrap_or_else(|| doc.name.clone()), me, doc, share }; + let (id, ()) = s.live.start(cmd, |l, id| l.publish(id, opts).map_err(|e| bad(cmd, e)))?; + // This tab becomes the server file's synced document once it is there. + s.live.open(Live { id, status: Status::Connecting, doc_uid: Some(uid), tab: Some(uid), server: server.trim().to_string(), ..Live::default() }); + Ok(id) +} + +/// Why a local network share has no view-only code. +const LOCAL_EDITS: &str = "everyone who joins a file shared on the local network can edit it: share it online for a view-only code"; + +pub(super) fn share(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "collab.share"; + let via = match string(p, "via").as_deref().map(str::trim) { + None | Some("online") => Via::Online, + Some("local") => Via::Local, + Some(_) => return Err(bad(CMD, "`via` must be \"online\" or \"local\"")), + }; + let access = match string(p, "access") { + None => None, + Some(a) => Some(Access::parse(&a).ok_or_else(|| bad(CMD, "`access` must be \"edit\" or \"view\""))?), + }; + // A share of this file is there already: only which code is shown changes, or (the file is + // on the server, not shared at the moment) it gets new links and codes. Nothing is published + // twice. + if let Some(shared) = s.live().share.as_ref().filter(|_| s.live().status.active()).map(|sh| sh.via) { + if get(p, "via").is_some() && shared != via { + return Err(bad( + CMD, + "this file is shared the other way already: stop sharing it first (collab.stop), or leave its session (collab.leave)", + )); + } + if access == Some(Access::View) && shared == Via::Local { + return Err(bad(CMD, LOCAL_EDITS)); + } + if let Some(req) = share_request(s, Vec::new(), true).filter(|_| s.live().share.as_ref().is_some_and(|sh| sh.links.is_none())) { + let links = share_answer(s, req)?.ok_or_else(|| EngineError::Other("the server made no links".into()))?; + shared_now(s, Some(clean_shared(links))); + } + if let (Some(a), Some(sh)) = (access, s.live_mut().and_then(|l| l.share.as_mut())) { + sh.access = a; + } + return Ok(s.live_summary()); + } + let access = access.unwrap_or_default(); + let mut server = String::new(); + let id = match via { + Via::Local if access == Access::View => return Err(bad(CMD, LOCAL_EDITS)), + Via::Local => start_host(s, CMD, p, Bind::Lan, true)?, + Via::Online => { + server = string(p, "server").unwrap_or_else(|| s.prefs.publish_server.clone()).trim().to_string(); + if server.is_empty() { + return Err(EngineError::Disabled(CMD.into(), ONLINE_OFF.into())); + } + start_publish(s, CMD, &server, p, true)? + } + }; + if let Some(live) = s.live.sessions.get_mut(&id) { + // On the local network the session's own link is what others join with, and the code says + // what the link says: where the session is and its secret. (Online they arrive with the file.) + let links = live.info.as_ref().filter(|_| via == Via::Local).map(|i| { + let code = code::Local::from_link(&i.link).map(|l| l.code()); + Shared { edit: SharedLink { id: String::new(), link: i.link.clone(), code }, view: None } + }); + live.share = Some(Share { via, server, access, links }); + } + Ok(s.live_summary()) +} + +/// What others join the open file's server file with changed: in its session and its record. +pub(super) fn shared_now(s: &mut Session, links: Option) { + if let Some((server, file)) = s.live().server_file() { + s.online_shared(&server, &file, links); + } +} + +/// Links and codes as a server gave them, with only codes that are codes (nine symbols). +pub(crate) fn clean_shared(mut links: Shared) -> Shared { + let clean = |l: &mut SharedLink| l.code = l.code.take().and_then(|c| code::normalize(&c).ok()).filter(|c| c.len() == code::ONLINE_LEN); + clean(&mut links.edit); + links.view.as_mut().map(clean); + links +} + +/// The request that changes who else gets into the server file the open file's session is its +/// owner's session of (`None`: it isn't one, or the file isn't there yet). +pub(super) fn share_request(s: &Session, revoke: Vec, create: bool) -> Option { + let live = s.live(); + let (share, published) = (live.share.as_ref().filter(|s| s.via == Via::Online)?, live.published.as_ref()?); + if live.status != Status::Live { + return None; + } + // The token this session got when it published the file, else the recorded one. + let owner = published.owner.clone().or_else(|| s.online_owner(&share.server, &published.file)).unwrap_or_default(); + Some(NetRequest::Share { server: share.server.clone(), file: published.file.clone(), owner, revoke, create }) +} + +/// Send `req` ([`NetRequest::Share`]): the new links, if it made any. +pub(crate) fn share_answer(s: &mut Session, req: NetRequest) -> Result> { + match s.net(req)?.0 { + NetReply::Share(Ok(links)) => Ok(links), + NetReply::Share(Err(why)) => Err(EngineError::Other(why)), + _ => Err(EngineError::Other("the server answered something else".into())), + } +} + +/// What the server at `server` says about its hosting, and its answer to `req` about `file` with +/// its owner token (`collab.serverInfo`, `collab.manageFile`). +pub(super) fn hosting_request( + s: &mut Session, + cmd: &str, + server: &str, + file: &str, + owner: &str, + req: Option, +) -> Result<(HostInfo, Option)> { + let req = NetRequest::Hosting { server: server.to_string(), file: file.to_string(), owner: owner.to_string(), req }; + match s.net(req)?.0 { + // The server's refusals say why themselves. + NetReply::Hosting(Ok((_, Some(AdminReply::Failed { reason })))) | NetReply::Hosting(Err(reason)) => Err(EngineError::Other(reason)), + NetReply::Hosting(Ok((info, reply))) => Ok((clean_hosting(info), reply)), + _ => Err(EngineError::Other(format!("{cmd}: the server answered something else"))), + } +} + +pub(super) fn server_info(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "collab.serverInfo"; + let server = req_string(CMD, p, "server")?; + let (info, _) = hosting_request(s, CMD, server.trim(), "", "", None)?; + Ok(hosting_json(&info)) +} + +pub(super) fn manage_file(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "collab.manageFile"; + let file = req_string(CMD, p, "file")?.trim().to_string(); + let (server, owner) = crate::online::manager(s, CMD, p, &file)?; + let action = req_string(CMD, p, "action")?; + let mut renamed = None; + let req = match action.trim() { + "share" => return crate::online::set_shared(s, CMD, &server, &file, &owner, true), + "unshare" => return crate::online::set_shared(s, CMD, &server, &file, &owner, false), + "links" => Admin::Links { file: file.clone() }, + "createLink" => { + let access = match string(p, "access").as_deref().map(str::trim) { + None | Some("view") => Access::View, + Some("edit") => Access::Edit, + Some(_) => return Err(bad(CMD, "`access` must be \"edit\" or \"view\"")), + }; + Admin::CreateLink { file: file.clone(), access, label: string(p, "label").unwrap_or_default() } + } + "revoke" => Admin::Revoke { file: file.clone(), link: req_string(CMD, p, "link")?.trim().to_string() }, + "rename" => { + let name = req_string(CMD, p, "name")?; + renamed = Some(name.clone()); + Admin::Rename { file: file.clone(), name } + } + "remove" => Admin::Remove { file: file.clone() }, + _ => return Err(bad(CMD, "`action` must be links, createLink, revoke, share, unshare, rename or remove")), + }; + let changes = matches!(req, Admin::Rename { .. } | Admin::Remove { .. }); + let reply = hosting_request(s, CMD, &server, &file, &owner, Some(req))?.1; + // The record follows what the server did. + if changes && reply == Some(AdminReply::Done) { + crate::online::managed(s, &server, &file, renamed.as_deref()); + if renamed.is_none() { + crate::online::removed(s, &server, &file); + } + } + match reply { + Some(AdminReply::Links(links)) => Ok(json!({"links": links.iter().map(|l| json!({ + "id": l.id, "access": l.access.id(), "label": l.label, "created": l.created + })).collect::>()})), + // The link layer gives the new link whole in place of its token. + Some(AdminReply::Link(l)) => Ok(json!({"id": l.link.id, "access": l.link.access.id(), "link": l.token})), + Some(AdminReply::Done) => Ok(json!({"done": true})), + _ => Err(bad(CMD, "the server answered something else")), + } +} + +pub(super) fn report(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "collab.report"; + let reason: String = req_string(CMD, p, "reason")?.trim().chars().map(|c| if c.is_control() { ' ' } else { c }).take(MAX_REPORT).collect(); + if reason.is_empty() { + return Err(bad(CMD, "say in a few words what is wrong with the file (`reason`)")); + } + let live = s.live(); + if live.status != Status::Live || live.role() != Some(Role::Guest) { + return Err(bad(CMD, "join a file on a FigCraft server first: reports go to that server's operator")); + } + if live.hosting.is_none() { + return Err(bad(CMD, "this server doesn't take reports (it is older than this app); contact its operator another way")); + } + let id = live.id; + s.live.send(id, [ClientMsg::Report { reason }]); + Ok(json!({"sent": true})) +} + +/// Longest report sent (characters). +pub const MAX_REPORT: usize = 500; diff --git a/crates/engine/src/sharing/live/join.rs b/crates/engine/src/sharing/live/join.rs new file mode 100644 index 00000000..972198ef --- /dev/null +++ b/crates/engine/src/sharing/live/join.rs @@ -0,0 +1,259 @@ +use super::host::{host, hosting_request, manage_file, publish, report, server_info, share, share_answer, share_request, shared_now, unavailable}; +use super::types::{Published, Role, SessionId, Share, Status, join_target}; +use super::*; + +fn join(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "collab.join"; + let typed = match string(p, "code") { + Some(code) => code, + None => req_string(CMD, p, "link").map_err(|_| bad(CMD, "give the join `code` you were told, or the invite `link`"))?, + }; + let link = join_target(&s.prefs.publish_server, &typed).map_err(|e| bad(CMD, e))?; + let id = start_join(s, CMD, &link, None, None, None)?; + Ok(s.live.get(id).map_or(Value::Null, |l| s.summary(l))) +} + +/// Start joining the session behind `link` for command `cmd`: the new session. `published` and +/// `share`: the file is yours on a server (`collab.openFile`), with what others join it with; +/// `resume`: as the client you were there. +pub(crate) fn start_join( + s: &mut Session, + cmd: &str, + link: &str, + published: Option, + share: Option, + resume: Option, +) -> Result { + let (name, tab) = (display_name(&s.prefs), s.tab()); + let (id, ()) = s.live.start(cmd, |l, id| l.join_resuming(id, link, &name, resume).map_err(|e| bad(cmd, e)))?; + let server = share.as_ref().map(|s| s.server.clone()).unwrap_or_default(); + // The session's file opens in a tab of its own; until then the join shows on this one. + s.live.open(Live { id, status: Status::Connecting, tab, server, published, share, ..Live::default() }); + Ok(id) +} + +fn leave(s: &mut Session, _: &Value) -> Result { + let ended = s.live.shown(s.tab()).and_then(|id| s.live.end(id)); + if ended.as_ref().is_some_and(|l| l.syncing() && l.role() == Some(Role::Guest)) { + s.message = Some("You left the session; your copy stays open here".into()); + } + Ok(json!({"ended": ended.is_some_and(|l| l.status.active())})) +} + +fn stop(s: &mut Session, p: &Value) -> Result { + // A file shared online stays its owner's on the server: stopping takes the others' links (and + // their codes) away, which disconnects whoever joined with them, and the owner stays in the + // file. Anything else (a session this app hosts, one it joined, a file not shared now) ends. + let shared = s.live().share.as_ref().and_then(|sh| sh.links.as_ref()); + let ids: Vec = shared.into_iter().flat_map(|l| [Some(&l.edit), l.view.as_ref()]).flatten().map(|l| l.id.clone()).collect(); + let Some(req) = share_request(s, ids, false).filter(|_| shared.is_some()) else { return leave(s, p) }; + share_answer(s, req).map_err(|e| EngineError::Other(format!("couldn't stop sharing (the others can still get in): {e}")))?; + shared_now(s, None); + s.message = Some("Sharing stopped: the file stays on the server, open only to you".into()); + Ok(json!({"ended": false, "shared": false})) +} + +fn follow(s: &mut Session, p: &Value) -> Result { + match get(p, "client") { + None => { + s.live_unfollow(); + } + Some(v) => { + let id = v.as_u64().and_then(|n| u32::try_from(n).ok()).ok_or_else(|| bad("collab.follow", "`client` must be a client id"))?; + s.live_follow(id)?; + } + } + Ok(json!({"follow": s.live().follow})) +} + +/// Remove someone from the open file's session: in a session this app hosts, the hub drops them; +/// in a file of yours on a server, the server does (with the file's owner token). They are told +/// the session is over for them, keep their copy, and can't join again from their address while +/// the session (or the server) runs. +fn remove_person(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "collab.removePerson"; + let client = + get(p, "client").and_then(Value::as_u64).and_then(|n| u32::try_from(n).ok()).ok_or_else(|| bad(CMD, "`client` must be a client id"))?; + let live = s.live(); + if live.status != Status::Live { + return Err(bad(CMD, "the open file isn't in a live session")); + } + let name = + live.peers.get(&client).map(|p| p.name.clone()).ok_or_else(|| bad(CMD, format!("nobody with client id {client} is in the session")))?; + let (id, role, server_file) = (live.id, live.role(), live.server_file()); + match (role, server_file) { + (Some(Role::Host), _) => { + let link = s.live.link.as_deref_mut().ok_or_else(|| unavailable(CMD))?; + link.remove(id, client).map_err(EngineError::Other)?; + } + (Some(Role::Guest), Some((server, file))) => { + let owner = + s.live().published.as_ref().and_then(|p| p.owner.clone()).or_else(|| s.online_owner(&server, &file)).filter(|o| !o.is_empty()); + let owner = owner.ok_or_else(|| bad(CMD, "only the file's owner can remove people from it"))?; + let req = Some(Admin::RemovePeer { file: file.clone(), client }); + match hosting_request(s, CMD, &server, &file, &owner, req) { + Ok((_, Some(AdminReply::Done))) => {} + Ok(_) => return Err(EngineError::Other(format!("couldn't remove {name}: the server answered something else"))), + // A server older than this request can't read it. + Err(e) => return Err(EngineError::Other(format!("couldn't remove {name}: {e} (the server may be older than removing people)"))), + } + } + _ => return Err(bad(CMD, "only the person hosting the session, or the file's owner, can remove people")), + } + s.message = Some(format!("{name} was removed from the session")); + Ok(json!({"removed": client, "name": name})) +} + +/// You may remove others from the open file's session: you host it, or it is your file on a +/// server. +pub fn can_remove_people(s: &Session) -> bool { + let live = s.live(); + live.status == Status::Live + && match live.role() { + Some(Role::Host) => true, + Some(Role::Guest) => live.server_file().is_some_and(|(server, file)| { + live.published.as_ref().and_then(|p| p.owner.as_deref()).is_some_and(|o| !o.is_empty()) + || s.online_owner(&server, &file).is_some_and(|o| !o.is_empty()) + }), + None => false, + } +} + +fn cursor(s: &mut Session, p: &Value) -> Result { + let at = match (num(p, "x"), num(p, "y")) { + (Some(x), Some(y)) => Some((x, y)), + (None, None) => None, + _ => return Err(bad("collab.cursor", "give both `x` and `y` (page coordinates), or neither to hide the cursor")), + }; + if let Some(l) = s.live_mut() { + l.cursor = at; + } + s.live_sync(); + Ok(json!({"cursor": s.live().cursor.map(|(x, y)| [x, y])})) +} + +fn cursor_chat(s: &mut Session, p: &Value) -> Result { + let Some(v) = get(p, "text") else { + // The app's chat box (programmatic calls never open dialogs). + let open = s.signal_blocker().is_none(); + if open { + s.request_ui("dialog.cursorChat", json!({})); + } + return Ok(json!({"open": open, "chat": s.live().chat})); + }; + let text = v.as_str().ok_or_else(|| bad("collab.cursorChat", "`text` must be a string (empty closes the chat)"))?; + Ok(json!({"chat": s.live_chat(text)?})) +} + +fn spotlight(s: &mut Session, p: &Value) -> Result { + let on = match get(p, "on") { + None => !s.live().spotlighting, + Some(v) => v.as_bool().ok_or_else(|| bad("collab.spotlight", "`on` must be true or false"))?, + }; + s.live_spotlight(on)?; + Ok(json!({"spotlighting": s.live().spotlighting})) +} + +pub fn specs() -> Vec { + vec![ + spec!( + "collab.host", + "Start live session", + "{port? (default 47900, else any free port), bind?: \"local\" (this machine, default) | \"lan\" (the local network) | an IP address} → the open file's session: {session, link, url (invites: they carry the secret token), address, …}; sessions are per file, so other files' sessions go on (a session this file was in ends)", + false, + host + ), + spec!( + "collab.share", + "Share with a join code", + "{via?: \"online\" (default: the open file is kept on the server in Preferences, `pref.publishServer`, and the server hands out the codes) | \"local\" (this app hosts it for people on the same network; no server), access?: \"edit\" (default) | \"view\" (online only: which code `code` is), server? (online: another server's address, with ?key=… when it needs its key), name? (online: the file's name there), port?, bind?: \"lan\" (default) | \"local\" (this machine only) | an IP address (local)} → the open file's session as `collab.peers` gives it: `code` is what others type to join (`collab.join {code}`, the Join field), `link` an invite link. Local: at once, the code carries the host's IPv4 address, port and secret (K7M2Q-…, 20 symbols). Online: status \"connecting\" first; once live `code` is the server's (K7M-2QX-9TD) and `share` is {via, access, shared, editCode, viewCode, editLink, viewLink}: links made for others (the file's own links in `published` stay yours); `code` stays null when the server is older than join codes (give `share.editLink` / `share.viewLink` instead). Called again while shared: only switches which code `code` is (`access`). Called after `collab.stop` on a file that is still on the server: new links and codes for that same file (nothing is published twice). `collab.stop` stops sharing", + false, + share + ), + spec!( + "collab.stop", + "Stop live session", + "{} → {ended}: the open file's session ends (a session this app hosts stops for everyone; a joined one is left). A file shared online with `collab.share` is different: its sharing stops → {ended: false, shared: false}: the links and codes others got are revoked on the server, whoever joined with them is disconnected at once (they keep their copy), and you stay in the file, which stays on the server, open only to you (`collab.share` shares it again with new codes; `collab.leave` leaves it there; `collab.manageFile {action: \"remove\"}` with its owner token takes it off the server)", + false, + stop + ), + spec!( + "collab.join", + "Join live session", + "{code: a join code (K7M-2QX-9TD from a server, asked at the server in Preferences; a longer one for the local network; spaces, dashes and case don't matter, O reads as 0 and I or L as 1) | link: figcraft://join?host=…&file=…&token=…, figcraft://join?server=ws(s)://…&file=…&token=… or ws(s)://host:port/?file=…&token=… (a session an app hosts, or a server's share link; a code here works too)} → the new session: {session, status: \"connecting\", …}; its file opens in a tab of its own (other files' sessions go on) and `collab.peers` tells when it's live (`sessions` lists it until then, and if it fails)", + false, + join + ), + spec!( + "collab.leave", + "Leave live session", + "{} (the open file's session; the synced file stays open as a local copy) → {ended}", + false, + leave + ), + spec!( + "collab.publish", + "Publish to server", + "{server: ws://host:port/?key=… or wss://host/path/?key=… (the server's address with its key; `figcraft-server key` prints it; without a key on a server open to the public), key? (when the address has none), name? (default: the file's name)} → status \"connecting\": the open file is uploaded as a new file and this tab syncs through the server; `collab.peers` then has `published: {server, file, name, edit, view, owner (published without a key: the owner token, shown once), expires (seconds since 1970, or null)}` and `hosting` (the server's operator, terms and idle expiry)", + false, + publish + ), + spec!( + "collab.serverInfo", + "Server info", + "{server: ws://host:port/ or wss://host/path/} → {open (anyone may publish without the key), operator, terms (a web address), idleDays (files published without the key are deleted after this many days unopened; null: kept), maxFileMb, expires}", + false, + server_info + ), + spec!( + "collab.manageFile", + "Manage published file", + "{file (its id), server?, owner? (the server's address and the owner token you got when you published the file without the key; each defaults to what is recorded for one of your online files, see `collab.files`), action: links | createLink {access?: view (default) | edit, label?} | revoke {link (a link's id)} | share (others get new links and join codes) | unshare (the links and codes others got are revoked; the file stays yours on the server) | rename {name} | remove (the file is deleted from the server and leaves your online files)} → {links: [{id, access, label, created}]} | {id, access, link (a new share link, shown once)} | {shared, code, viewCode, editLink, viewLink} | {done}", + false, + manage_file + ), + spec!( + "collab.report", + "Report file to server operator", + "{reason (a few words, at most 500 characters)} → {sent}: the file you joined on a server is reported to its operator; the session's message says when the server kept it (servers older than this app don't take reports)", + false, + report + ), + spec!( + "collab.peers", + "People in this file", + "{} → the open file's session (each open file has its own or none): {available, canHost (false in the browser: it can only join), session (its id; null: this file isn't in a session), status: off|connecting|live|reconnecting|failed, reason?, role, me, name, color, fileName, pages, link, url, address, network, access: edit|view (view: a server's view link; edits are refused), readOnly (the open file can't be edited now: view only, or being published), published (a file you published: {server, file, name, edit, view}), code (the join code to give others, when the file was shared with `collab.share`; null otherwise or while it is on its way), share ({via: online|local, access, shared (false: a file of yours on a server that nobody else gets into now), editCode, viewCode, editLink, viewLink (what others join with; null when not shared)} or null), follow, syncing, document (index of the synced open document), editing (the text layer you type in: {node, layer, caret, selection}), peers: [{id, name, color, editing (theirs, same shape, or null), presence: {cursor: [page, x, y], selection, viewport: [page, x, y, w, h], editing_text}}], sessions: [{session, document (index of its open document; null for a join on its way), status, reason, role, fileName, link, peers (how many others)}] (every session of the app)}", + false, + |s, _| Ok(s.live_summary()) + ), + spec!("collab.follow", "Follow", "{client? (a peer's id; omit to stop following)} → {follow}", false, follow), + spec!( + "collab.removePerson", + "Remove from session", + "{client (a peer's id)} → {removed, name}: they are disconnected (they keep their copy) and can't join again from their address while the session runs; only the person hosting, or the owner of a file on a server, can do it", + false, + remove_person + ), + spec!( + "collab.cursor", + "Show cursor at", + "{x?, y? (page coordinates on the current page; omit both to hide)} → {cursor} (agents; in the app the pointer drives it): your pointer in the open file's session (null when the file isn't in one)", + false, + cursor + ), + spec!( + "collab.cursorChat", + "Cursor chat", + "{text? (shown next to your cursor for everyone, at most 100 characters; empty closes it; omit to open the chat box in the app)} → {chat} (or {open}); others see it fade after 5 s without changes", + false, + cursor_chat + ), + spec!( + "collab.spotlight", + "Spotlight me", + "{on? (default: toggle)} → {spotlighting}: everyone in the session follows your view and page until they move theirs", + false, + spotlight + ), + ] +} diff --git a/crates/engine/src/sharing/live/sync.rs b/crates/engine/src/sharing/live/sync.rs new file mode 100644 index 00000000..f2fd3c54 --- /dev/null +++ b/crates/engine/src/sharing/live/sync.rs @@ -0,0 +1,347 @@ +use super::host::hosting_json; +use super::types::{NO_SESSION, SessionId, Share, SharedLink, Status}; +use super::*; + +impl Session { + /// Your client id in document `uid`'s live session (`None`: it isn't in one). Refused for + /// `cmd` while the session is on its way: there is no id yet. + pub(crate) fn live_client_id(&self, cmd: &str, uid: u64) -> Result> { + let Some(live) = self.live.doc(uid) else { return Ok(None) }; + match &live.client { + Some(c) => Ok(Some(c.id())), + None if live.status.active() => Err(EngineError::Disabled(cmd.into(), "wait until the file's live session has started".into())), + None => Ok(None), + } + } + + /// Branch `id` of document `uid` got its base from the session document just now. + pub(crate) fn live_branch_base(&mut self, uid: u64, id: &str) { + if let Some(live) = self.live.doc_mut(uid).filter(|l| l.syncing()) { + live.branch_bases.insert(id.to_string()); + } + } + + /// Whether branch `id` of document `uid` got its base in the live session it is in now. + pub(crate) fn live_branch_in_session(&self, uid: u64, id: &str) -> bool { + self.live.doc(uid).is_some_and(|l| l.syncing() && l.branch_bases.contains(id)) + } + + /// Whether document `uid` is a live session's synced document: its edits sync and its undo + /// history is the user's own. + pub fn live_doc(&self, uid: u64) -> bool { + self.live.doc(uid).is_some_and(Live::syncing) + } + + /// Sync the edits of session document `uid` as one batch: one step of the user's undo history + /// called `label`, or not undoable (`None`: also what changed it outside a command). Nothing + /// while a gesture or preview shows, or inside a group (it syncs when the group ends). + pub(crate) fn live_edit(&mut self, uid: u64, label: Option<&str>) { + let Some(ds) = self.docs.iter().find(|d| d.uid == uid) else { return }; + let Some(live) = self.live.doc(uid) else { return }; + if live.hold > 0 || busy(ds) || live.synced.as_ref().is_some_and(|s| Arc::ptr_eq(s, &ds.doc)) { + return; + } + let (id, doc, synced) = (live.id, ds.doc.clone(), live.synced.clone()); + if self.live_read_only(uid).is_some() { + // Nothing goes out: what changed outside a command is put back. + if let Some(s) = synced { + self.apply_remote(uid, s); + } + return; + } + let Some(live) = self.live.sessions.get_mut(&id) else { return }; + let Some(client) = live.client.as_mut() else { return }; + match client.edit_with(&doc, label) { + Ok(msg) => { + live.synced = Some(doc); + self.live.send(id, msg); + } + Err(e) => self.live_fail(id, format!("Couldn't sync an edit: {e}")), + } + } + + /// A group of commands on document `uid` starts (`on`) or ends: in between its edits don't + /// sync on their own. + pub(crate) fn live_hold(&mut self, uid: u64, on: bool) { + if let Some(live) = self.live.doc_mut(uid).filter(|l| l.syncing()) { + live.hold = if on { live.hold.saturating_add(1) } else { live.hold.saturating_sub(1) }; + } + } + + /// Undo (`back`) or redo the user's own last step on the active document, in its session: + /// what it wrote goes back where nobody else wrote since. Returns whether there was a step to + /// take back. + pub(crate) fn live_travel(&mut self, back: bool) -> bool { + let Some(uid) = self.tab() else { return false }; + // Edits not synced yet go first. + self.live_edit(uid, None); + let Some(live) = self.live.doc_mut(uid) else { return false }; + let Some(client) = live.client.as_mut() else { return false }; + let (undo, redo) = client.history_len(); + let msgs = match if back { client.undo(&EngineRecompute) } else { client.redo(&EngineRecompute) } { + Ok(m) => m, + Err(e) => { + log::warn!("live session: {} failed: {e}", if back { "undo" } else { "redo" }); + return false; + } + }; + let (u, r) = client.history_len(); + let did = if back { r > redo } else { u > undo }; + let doc = Arc::new(client.document().clone()); + if did { + live.synced = Some(doc.clone()); + } + let id = live.id; + self.live.send(id, msgs); + if did { + self.apply_remote(uid, doc); + } + did + } + + /// Apply the server's batches that waited in session `id` (none while a gesture or preview + /// shows on its document). + pub(super) fn live_deferred(&mut self, id: SessionId) { + let Some(live) = self.live.sessions.get(&id).filter(|l| !l.deferred.is_empty() && l.syncing()) else { return }; + let Some(uid) = live.doc_uid else { return }; + if self.docs.iter().find(|d| d.uid == uid).is_none_or(busy) { + return; + } + // Your own edits first: the client replays them over what others did. + self.live_edit(uid, None); + let Some(live) = self.live.sessions.get_mut(&id) else { return }; + let Some(client) = live.client.as_mut() else { return }; + let mut out = Vec::new(); + for m in std::mem::take(&mut live.deferred) { + match client.receive(m, &EngineRecompute) { + Ok(more) => out.extend(more), + Err(e) => return self.live_fail(id, format!("The live session got out of step ({e}); join again")), + } + } + let doc = Arc::new(client.document().clone()); + // Edits the server refused were rolled back: say why (the UI's toast). + let notices: Vec = client.take_notices().iter().map(figcraft_collab::Notice::message).collect(); + live.synced = Some(doc.clone()); + if !notices.is_empty() { + self.message = Some(notices.join("; ")); + } + self.live.send(id, out); + self.apply_remote(uid, doc); + } + + /// Show the peer followed in session `id`: their viewport (and their page, in the session's + /// document) on the session's tab. + pub(super) fn live_follow_apply(&mut self, id: SessionId) { + let Some(live) = self.live.sessions.get_mut(&id) else { return }; + let Some(peer) = live.follow else { return }; + if !live.peers.contains_key(&peer) { + live.unfollow(); + return; + } + let Some((page, x, y, w, h)) = live.presence.get(&peer).and_then(|p| p.viewport) else { return }; + let Some(ds) = self.docs.iter_mut().find(|d| Some(d.uid) == live.tab) else { return }; + if live.syncing() && page != ds.page && ds.doc.pages.contains(&page) { + ds.page = page; + ds.selection.clear(); + ds.text_edit = None; + } + if !(w > 0.0 && h > 0.0 && x.is_finite() && y.is_finite()) { + return; + } + ds.view.cx = x + w / 2.0; + ds.view.cy = y + h / 2.0; + let zoom = (ds.view.vw / w).min(ds.view.vh / h); + if zoom.is_finite() { + ds.view.zoom = zoom.clamp(ViewState::MIN_ZOOM, ViewState::MAX_ZOOM); + } + ds.revision += 1; + live.followed = Some(FollowedView { page: ds.page, cx: ds.view.cx, cy: ds.view.cy, zoom: ds.view.zoom }); + } + + /// Sessions whose tab was closed end (hosts stop hosting, guests leave); the others go on. A + /// join still on its way from a closed tab shows on the one in front. + pub(super) fn live_closed(&mut self) { + let (docs, tab) = (&self.docs, self.tab()); + let mut gone = Vec::new(); + for l in self.live.sessions.values_mut().filter(|l| l.tab != tab && !docs.iter().any(|d| Some(d.uid) == l.tab)) { + if l.doc_uid.is_none() && l.status.active() { + l.tab = tab; + } else { + gone.push((l.id, l.status == Status::Live)); + } + } + for (id, was_live) in gone { + self.live.end(id); + if was_live { + self.message = Some("The live session's file was closed, so you left the session".into()); + } + } + } + + /// Send your edits and presence in every session when they changed (headless sessions call + /// it after each command). A view you moved yourself stops following. + pub fn live_sync(&mut self) { + self.live_sync_in(|_| true); + } + + /// [`live_sync`](Self::live_sync) for the sessions whose document (`None`: a join on its way) + /// `mine` picks: a window calls it every frame it draws, after input, for the documents it + /// holds. + pub fn live_sync_in(&mut self, mine: impl Fn(Option) -> bool) { + // A recovery copy whose session was left has its outcome too. + if !self.live.recovering.is_empty() { + self.online_recoveries(); + } + if self.live.sessions.is_empty() { + return; + } + self.live_closed(); + let ids: Vec = self.live.sessions.values().filter(|l| l.status == Status::Live && mine(l.doc_uid)).map(|l| l.id).collect(); + for id in ids { + self.live_deferred(id); + if let Some(uid) = self.live.get(id).and_then(|l| l.doc_uid) { + self.live_edit(uid, None); + } + self.live_present(id); + } + } + + /// Hand your presence in session `id` to the link when it changed. + fn live_present(&mut self, id: SessionId) { + let Some(live) = self.live.get(id).filter(|l| l.status == Status::Live) else { return }; + let moved = live.followed.is_some_and(|f| { + self.docs + .iter() + .find(|d| Some(d.uid) == live.tab) + .is_some_and(|ds| ds.page != f.page || !close(ds.view.cx, f.cx) || !close(ds.view.cy, f.cy) || !close(ds.view.zoom, f.zoom)) + }); + let p = self.presence_in(live); + let changed = live.sent.as_ref() != Some(&p); + let Some(live) = self.live.sessions.get_mut(&id) else { return }; + if moved { + live.unfollow(); + } + if changed { + if let Some(link) = self.live.link.as_mut() { + link.presence(id, &p); + } + live.sent = Some(p); + } + } + + /// Stop following in the active document's session (Escape, `collab.follow {}`). False when + /// you weren't following anyone. + pub fn live_unfollow(&mut self) -> bool { + self.live_mut().is_some_and(Live::unfollow) + } + + /// Follow `peer`'s viewport in the active document's session (shown at once when known). + pub fn live_follow(&mut self, peer: u32) -> Result<()> { + let Some(live) = self.live_mut().filter(|l| l.peers.contains_key(&peer)) else { + return Err(bad("collab.follow", format!("nobody with client id {peer} is in the session"))); + }; + live.follow = Some(peer); + live.followed = None; + let id = live.id; + self.live_follow_apply(id); + Ok(()) + } + + /// The active document's session as JSON (`collab.peers`), with every session in brief. + pub fn live_summary(&self) -> Value { + self.summary(self.live()) + } + + /// The index of the open document session `l` syncs. + fn live_index(&self, l: &Live) -> Option { + l.doc_uid.and_then(|u| self.docs.iter().position(|d| d.uid == u)) + } + + pub(super) fn summary(&self, l: &Live) -> Value { + let info = l.info.as_ref(); + let reason = |l: &Live| match &l.status { + Status::Failed { reason } => Some(reason.clone()), + _ => None, + }; + let synced = self.live_index(l).and_then(|i| self.docs.get(i)); + // Who types in which layer of the session document. + let editing = |e: &EditingText| { + let layer = synced.and_then(|d| d.doc.node(e.node)).map(|n| n.name.as_str()); + json!({"node": e.node, "layer": layer, "caret": e.caret, "selection": e.selection}) + }; + let peers: Vec = l + .peers + .values() + .map(|p| { + let presence = l.presence.get(&p.client_id); + let typing = presence.and_then(|pr| pr.editing_text.as_deref()).map(editing); + let chat = l.chats.get(&p.client_id); + json!({"id": p.client_id, "name": p.name, "color": p.color, "editing": typing, "chat": chat, "presence": presence}) + }) + .collect(); + let mine = synced.and_then(|d| d.text_edit.as_ref()).map(|t| editing(&editing_of(t))); + let sessions: Vec = self + .live + .iter() + .map(|o| { + let info = o.info.as_ref(); + json!({ + "session": o.id, + "document": self.live_index(o), + "status": o.status.id(), + "reason": reason(o), + "role": o.role(), + "fileName": info.map(|i| i.file_name.as_str()), + "link": info.map(|i| i.link.as_str()), + "peers": o.peers.len(), + }) + }) + .collect(); + json!({ + "available": self.live.available(), + "canHost": self.live.can_host(), + "session": (l.id != NO_SESSION).then_some(l.id), + "status": l.status.id(), + "reason": reason(l), + "attempt": match l.status { Status::Reconnecting { attempt } => Some(attempt), _ => None }, + "role": info.map(|i| i.role), + "me": info.map(|i| i.me), + "name": info.map(|i| i.name.as_str()), + "color": info.map(|i| i.color.as_str()), + "fileName": info.map(|i| i.file_name.as_str()), + "pages": info.map(|i| &i.pages), + "link": info.map(|i| i.link.as_str()), + "url": info.map(|i| i.url.as_str()), + "address": info.map(|i| i.address.as_str()), + "network": info.map(|i| i.network), + "access": l.client.as_ref().map(Client::access).or(info.map(|i| i.access)), + "readOnly": l.doc_uid.is_some_and(|u| self.live_read_only(u).is_some()), + "published": l.published, + "code": l.share.as_ref().and_then(Share::code), + "share": l.share.as_ref().map(|sh| { + let (edit, view) = (sh.links.as_ref().map(|s| &s.edit), sh.links.as_ref().and_then(|s| s.view.as_ref())); + let code = |l: Option<&SharedLink>| l.and_then(|l| l.code.as_deref()).map(code::grouped); + json!({ + "via": sh.via, + "access": sh.access.id(), + "shared": sh.links.is_some(), + "editCode": code(edit), + "viewCode": code(view), + "editLink": edit.map(|l| l.link.as_str()), + "viewLink": view.map(|l| l.link.as_str()), + }) + }), + "hosting": l.hosting.as_ref().map(hosting_json), + "follow": l.follow, + "signals": l.signals, + "chat": l.chat, + "spotlight": l.spotlight, + "spotlighting": l.spotlighting, + "syncing": l.syncing(), + "document": self.live_index(l), + "editing": mine, + "peers": peers, + "sessions": sessions, + }) + } +} diff --git a/crates/engine/src/sharing/live/types.rs b/crates/engine/src/sharing/live/types.rs new file mode 100644 index 00000000..16a237d5 --- /dev/null +++ b/crates/engine/src/sharing/live/types.rs @@ -0,0 +1,482 @@ +use super::*; + +/// The server files are shared through online unless Preferences name another: the only place +/// its address is written. +/// +/// A build can ship another one, or none: set `FIGCRAFT_DEFAULT_SERVER` when compiling to a +/// server's address (`wss://host/`), or to nothing (`FIGCRAFT_DEFAULT_SERVER=`) for a build whose +/// online sharing stays off until the user enters a server in Preferences. +pub const DEFAULT_SERVER: &str = match option_env!("FIGCRAFT_DEFAULT_SERVER") { + Some(server) => server, + None => "wss://collab.craftapps.dev/", +}; + +/// Most selected ids one presence update carries (the rest aren't shown to others). +pub const MAX_SELECTION: usize = 10_000; + +/// Longest name shown to others (characters). +pub const MAX_NAME: usize = 64; + +/// The colours people get in a session, in the order the host hands them out (`#RRGGBB`, white +/// text reads on each). +pub const PEOPLE: [&str; 8] = ["#0D99FF", "#9747FF", "#14AE5C", "#F24822", "#D9730D", "#E83E8C", "#00A3A3", "#665CE0"]; + +/// `#RRGGBB` → (r, g, b); `None` for anything else. +pub fn parse_color(s: &str) -> Option<(u8, u8, u8)> { + let hex = s.strip_prefix('#').filter(|h| h.len() == 6 && h.bytes().all(|b| b.is_ascii_hexdigit()))?; + let byte = |i: usize| hex.get(i..i + 2).and_then(|b| u8::from_str_radix(b, 16).ok()); + Some((byte(0)?, byte(2)?, byte(4)?)) +} + +/// Why a join failed when a message from the host is over this app's size limit (`{mb}`: the +/// limit in MB). The UI translates it. +pub const TOO_LARGE_TO_JOIN: &str = "This file is too large to join here (up to {mb} MB)"; + +/// The connection to a live session. +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +#[serde(rename_all = "camelCase", tag = "state")] +pub enum Status { + /// Not in a session. + #[default] + Off, + /// Joining (connecting to the host, waiting for its welcome). + Connecting, + /// In the session. + Live, + /// The connection dropped; trying again (`attempt` from 1). + Reconnecting { attempt: u32 }, + /// Couldn't join, or the connection was lost for good. + Failed { reason: String }, +} + +impl Status { + pub fn id(&self) -> &'static str { + match self { + Status::Off => "off", + Status::Connecting => "connecting", + Status::Live => "live", + Status::Reconnecting { .. } => "reconnecting", + Status::Failed { .. } => "failed", + } + } + + /// In a session or on the way into one. + pub fn active(&self) -> bool { + matches!(self, Status::Connecting | Status::Live | Status::Reconnecting { .. }) + } + + /// What the Share popover says about it. + pub fn label(&self) -> String { + match self { + Status::Off => "Not in a live session".into(), + Status::Connecting => "Joining…".into(), + Status::Live => "Live".into(), + Status::Reconnecting { attempt } => format!("Connection lost, reconnecting (attempt {attempt})…"), + Status::Failed { reason } => reason.clone(), + } + } +} + +/// Your part in the session. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Role { + Host, + Guest, +} + +/// A page of the shared file. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct PageInfo { + pub id: u64, + pub name: String, +} + +/// The session you're in. +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionInfo { + pub role: Role, + /// Your client id, name and colour as the others see them. + pub me: u32, + pub name: String, + pub color: String, + /// The shared file's name and pages (the host's). + pub file_name: String, + pub pages: Vec, + /// The invite link (`figcraft://join?host=…&file=…&token=…`) and the same as a plain + /// WebSocket URL. Both carry the session's secret token: share them only with people you + /// invite. + pub link: String, + pub url: String, + /// `ip:port` the session listens on (host) or connects to (guest); a server's URL when it + /// isn't plain `ws://` at `/`. + pub address: String, + /// Reachable from other machines (the host listens on a network address, not loopback). + pub network: bool, + /// What you may do: edit, or only view (a server's view link). + pub access: Access, +} + +/// A file you published to a server: where, and its first share links (they carry their tokens: +/// share them like passwords; the server keeps only hashes). +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Published { + /// The server's address. + pub server: String, + /// The file's id and name on the server. + pub file: String, + pub name: String, + /// The link that lets people edit, and the one that lets them only view. + pub edit: String, + pub view: String, + /// Published without the server's key: the owner token that revokes links, renames or removes + /// the file (shown this once; the server keeps only its hash). + pub owner: Option, + /// When the server deletes the file if nobody opens it (seconds since 1970). + pub expires: Option, +} + +/// How a file is shared with a join code. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum Via { + /// Through a server: the file is kept there and the server hands out the codes. + Online, + /// From this app, to people on the same network. + Local, +} + +/// One way into a shared file: an invite link, and the join code that stands for it. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, serde::Deserialize)] +#[serde(default)] +pub struct SharedLink { + /// The link's id on its server (what revokes it; empty for a session this app hosts). + pub id: String, + /// The invite link (it carries its secret). + pub link: String, + /// Its join code's symbols. `None`: there is none (a server from before join codes, a host + /// without an IPv4 address): the link is shared instead. + pub code: Option, +} + +/// What others join a shared file with: to edit it and, on a server, to only view it. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, serde::Deserialize)] +#[serde(default)] +pub struct Shared { + pub edit: SharedLink, + pub view: Option, +} + +/// A file shared with a join code (`collab.share`). Online, the file is its owner's on the +/// server: sharing gives others links (and codes) of their own, and stopping takes those away +/// while the owner stays in the file. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Share { + pub via: Via, + /// Online: the server's address as it was given (it may carry the server's key: never shown). + pub server: String, + /// What the code shown lets people do. + pub access: Access, + /// What others join with now. `None`: on its way, or (online) not shared at the moment: the + /// file is on the server, open only to its owner. + pub links: Option, +} + +impl Share { + /// The way in to show: the one for [`access`](Self::access). + fn shown(&self) -> Option<&SharedLink> { + let links = self.links.as_ref()?; + if self.access == Access::View { links.view.as_ref() } else { Some(&links.edit) } + } + + /// The code to show, grouped (`K7M-2QX-9TD`). + pub fn code(&self) -> Option { + self.shown()?.code.as_deref().map(code::grouped) + } + + /// The invite link to give with (or in place of) the code. + pub fn link(&self) -> Option<&str> { + self.shown().map(|l| l.link.as_str()) + } +} + +/// What someone typed to join, as what the link joins: an invite link as it is, a local network +/// code as the link it stands for, an online code with `server` to ask about it. +pub fn join_target(server: &str, typed: &str) -> std::result::Result { + let typed = typed.trim(); + if typed.contains("://") || typed.contains('?') { + return Ok(typed.to_string()); + } + match code::parse(typed)? { + Code::Local(local) => Ok(local.link()), + Code::Online(_) if server.trim().is_empty() => Err(format!("that is an online join code, and {ONLINE_OFF}")), + Code::Online(code) => Ok(format!("figcraft://join?server={}&code={code}", server.trim())), + } +} + +/// Why nothing can be shared or joined online. +pub const ONLINE_OFF: &str = "online sharing is off: choose a server in Preferences (or share on the local network)"; + +/// A session's number on the link: the engine picks it (from 1) when it starts the session. +pub type SessionId = u64; + +/// The [`SessionId`] of events that aren't about a session (answers to background requests). +pub const NO_SESSION: SessionId = 0; + +/// What happened in a session since the last [`LiveLink::poll`]. +#[derive(Clone, Debug, PartialEq)] +pub enum Event { + Status(Status), + /// Joined, or joined again after a reconnect: who you are and who else is here. + Welcome { + info: SessionInfo, + peers: Vec, + }, + PeerJoined(Peer), + PeerLeft(u32), + Presence(u32, Presence), + /// Someone's cursor chat or spotlight, or (about you) the host's [`Signal::READY`]. + Signal(u32, Signal), + /// For the document sync client: the welcome (with the whole document), or a batch of + /// changes the server ordered, or the rejection of one of yours. + Sync(ServerMsg), + /// The host ended the session. + Ended { + reason: String, + }, + /// The document is on the server (`collab.publish`); joining it follows. + Published(Published), + /// The published file is shared (`collab.share`): the links and codes others join with, or + /// why the server made none. + Shared(std::result::Result), + /// What the server says about its hosting (operator, terms, this file's expiry). + Hosting(HostInfo), + /// The server's answer to `collab.report`: kept, or why not. + Reported(Option), + /// The answer to a team library request sent in the background + /// ([`LiveLink::library_background`]) about the file behind share link `link`. + Library { + link: String, + reply: std::result::Result, + }, + /// The answer to request `job` sent in the background ([`LiveLink::background`]). + Net { + job: u64, + reply: Box, + }, +} + +/// A request to a server outside a live session: its plugin directory, a team library, or keeping +/// a file there. +#[derive(Clone, Debug)] +pub enum NetRequest { + /// To the plugin directory of the server at `server` (`ws://host:port/`, with `?key=…` to + /// publish). + Plugins { server: String, req: PluginReq }, + /// About the team library of the file behind share link `link`. + Library { link: String, req: LibraryReq }, + /// Keep a file on a server without joining it ([`LiveLink::upload`]). + Upload(PublishOptions), + /// Change who else gets into `file` on the server at `server` (its address, with `?key=…`, or + /// the file's `owner` token): revoke the links with these ids, then, to `create`, make a new + /// edit link and view link with their join codes ([`LiveLink::share`]). + Share { server: String, file: String, owner: String, revoke: Vec, create: bool }, + /// Ask the server at `server` about its hosting and, with a file's owner token, manage that + /// file ([`LiveLink::hosting`]). + Hosting { server: String, file: String, owner: String, req: Option }, +} + +impl NetRequest { + /// What a session without the network says. + pub(crate) fn unavailable(&self) -> &'static str { + match self { + NetRequest::Plugins { .. } => crate::plugin_directory::NO_DIRECTORY, + NetRequest::Library { .. } | NetRequest::Upload(_) => NO_TEAM_LIBRARIES, + NetRequest::Hosting { .. } | NetRequest::Share { .. } => NO_HOSTING, + } + } + + /// How to answer that this request failed (and why). + pub fn failure(&self) -> fn(String) -> NetReply { + match self { + NetRequest::Plugins { .. } => |why| NetReply::Plugins(Err(why)), + NetRequest::Library { .. } => |why| NetReply::Library(Err(why)), + NetRequest::Upload(_) => |why| NetReply::Upload(Err(why)), + NetRequest::Hosting { .. } => |why| NetReply::Hosting(Err(why)), + NetRequest::Share { .. } => |why| NetReply::Share(Err(why)), + } + } + + /// The answer to this request stands for `other` when a command runs again: the same kind of + /// request to the same place (what it carries may differ: a usage report, a fresh timestamp). + pub(crate) fn stands_for(&self, other: &NetRequest) -> bool { + use std::mem::discriminant; + match (self, other) { + (NetRequest::Plugins { server: a, req: x }, NetRequest::Plugins { server: b, req: y }) => a == b && discriminant(x) == discriminant(y), + (NetRequest::Library { link: a, req: x }, NetRequest::Library { link: b, req: y }) => a == b && discriminant(x) == discriminant(y), + (NetRequest::Upload(a), NetRequest::Upload(b)) => a.server == b.server, + (NetRequest::Hosting { server: a, file: f, req: x, .. }, NetRequest::Hosting { server: b, file: g, req: y, .. }) => { + a == b && f == g && x.as_ref().map(discriminant) == y.as_ref().map(discriminant) + } + (NetRequest::Share { server: a, file: f, create: x, .. }, NetRequest::Share { server: b, file: g, create: y, .. }) => { + a == b && f == g && x == y + } + _ => false, + } + } +} + +/// The answer to a [`NetRequest`] (an error: the server couldn't be reached or refused). +#[derive(Clone, Debug, PartialEq)] +pub enum NetReply { + Plugins(std::result::Result), + Library(std::result::Result), + /// The new links, when some were asked for. + Share(std::result::Result, String>), + Upload(std::result::Result), + /// What the server says about its hosting, and its answer to the request, if one was sent. + Hosting(std::result::Result<(HostInfo, Option), String>), +} + +/// What a session without the network says about servers' hosting. +pub const NO_HOSTING: &str = "publishing to a server needs the desktop app or the MCP server"; + +/// Which network interface a hosted session listens on. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Bind { + /// This machine only (127.0.0.1): the default. + Local, + /// The machine's address on the local network (only when the user chooses to share there). + Lan, + /// A given interface address (e.g. a VPN's). + Addr(IpAddr), +} + +/// What [`LiveLink::host`] needs to start a session for the open file. +#[derive(Clone, Debug)] +pub struct HostOptions { + /// `None`: the default port, or any free one when it's taken. + pub port: Option, + pub bind: Bind, + /// Your name (empty: the host picks one). + pub name: String, + /// The shared file (the session's authoritative copy starts from it). + pub doc: Arc, + /// Shared with a join code (`collab.share`): the session's secret is short enough to type. + pub short: bool, +} + +/// What [`LiveLink::publish`] needs to put the open file on a server. +#[derive(Clone, Debug)] +pub struct PublishOptions { + /// The server's address (`ws://host:port/`, `wss://host/path/`), which may carry its key + /// (`?key=…`). + pub server: String, + /// The server's key, when the address doesn't carry it. Without one the file is published + /// as anyone may, on a server open to the public (with an owner token). + pub key: Option, + /// The file's name on the server. + pub name: String, + /// Your name in the session (empty: the server picks one). + pub me: String, + pub doc: Arc, + /// Share the file once it is there: links and join codes for others ([`Event::Shared`]; + /// `collab.share`). + pub share: bool, +} + +/// The networking behind live sessions, installed by the app ([`Lives::install`]). It carries +/// any number of sessions at once, each under the [`SessionId`] it was started with. Calls return +/// at once; what happens later arrives through [`poll`](Self::poll). +pub trait LiveLink: Send { + /// Sessions can be hosted here (the browser build can only join). + fn can_host(&self) -> bool { + true + } + /// Start hosting session `session` for `opts.doc`. + fn host(&mut self, session: SessionId, opts: HostOptions) -> std::result::Result; + /// Start joining the session behind an invite link, as session `session`. Errors are for + /// links that can't be used; they never echo the link (it holds the token). + fn join(&mut self, session: SessionId, link: &str, name: &str) -> std::result::Result<(), String>; + /// [`join`](Self::join) as the client `resume` names, when the server still knows it (an app + /// that starts over after a crash keeps its id, so what it made before is its own to send). + /// A link that can't resume joins as someone new. + fn join_resuming(&mut self, session: SessionId, link: &str, name: &str, _resume: Option) -> std::result::Result<(), String> { + self.join(session, link, name) + } + /// Start uploading `opts.doc` to a server as a new file, then join it with its edit link, as + /// session `session`: [`Event::Published`] with its links, then the welcome. Errors are for + /// addresses that can't be used; they never echo the key. + fn publish(&mut self, _session: SessionId, _opts: PublishOptions) -> std::result::Result<(), String> { + Err("publishing to a server needs the desktop app or the MCP server".into()) + } + /// Keep `opts.doc` on a server as a new file without joining it: where, and its links + /// (`library.publishToServer` with the server's key). Waits for the server. Errors never echo + /// the key. + fn upload(&mut self, _opts: PublishOptions) -> std::result::Result { + Err(NO_TEAM_LIBRARIES.into()) + } + /// One request about the team library of the file behind share link `link`; waits for the + /// server's answer (a few seconds at most when it can't be reached). Errors never echo the + /// link. + fn library(&mut self, _link: &str, _req: LibraryReq) -> std::result::Result { + Err(NO_TEAM_LIBRARIES.into()) + } + /// Send library requests `(link, request)` in the background, in order; each answer arrives as + /// [`Event::Library`]. Ignored while an earlier call's requests are still out. + fn library_background(&mut self, _jobs: Vec<(String, LibraryReq)>) {} + /// One request to the plugin directory of the server at `server` (`ws://host:port/`, with + /// `?key=…` to publish); waits for the answer (a few seconds at most when it can't be + /// reached). Errors never echo the key. + fn plugins(&mut self, _server: &str, _req: PluginReq) -> std::result::Result { + Err(crate::plugin_directory::NO_DIRECTORY.into()) + } + /// What the server at `server` says about its hosting, and with a file's owner token + /// (`file` and `owner`) its answer to `req` about that file; waits for the server. Errors + /// never echo the token. + fn hosting( + &mut self, + _server: &str, + _file: &str, + _owner: &str, + _req: Option, + ) -> std::result::Result<(HostInfo, Option), String> { + Err(NO_HOSTING.into()) + } + /// See [`NetRequest::Share`]; waits for the server. Errors never echo the key or the token. + fn share(&mut self, _server: &str, _file: &str, _owner: &str, _revoke: &[String], _create: bool) -> std::result::Result, String> { + Err(NO_HOSTING.into()) + } + /// Send `req` and wait for the answer (through the methods above). + fn request(&mut self, req: NetRequest) -> NetReply { + match req { + NetRequest::Share { server, file, owner, revoke, create } => NetReply::Share(self.share(&server, &file, &owner, &revoke, create)), + NetRequest::Plugins { server, req } => NetReply::Plugins(self.plugins(&server, req)), + NetRequest::Library { link, req } => NetReply::Library(self.library(&link, req)), + NetRequest::Upload(o) => NetReply::Upload(self.upload(o)), + NetRequest::Hosting { server, file, owner, req } => NetReply::Hosting(self.hosting(&server, &file, &owner, req)), + } + } + /// Send `req` on a thread of its own and return at once: the answer arrives as [`Event::Net`] + /// with `job` (a failure to send too). False when this link can't, and nothing was sent. + fn background(&mut self, _job: u64, _req: NetRequest) -> bool { + false + } + /// Remove client `client` from session `session`, which this link hosts: they are + /// disconnected and can't join again from their address while it runs. Returns at once. + fn remove(&mut self, _session: SessionId, _client: u32) -> std::result::Result<(), String> { + Err("removing people needs the desktop app or the MCP server".into()) + } + /// End session `session` (stop hosting or leave); the others go on. Nothing for a session + /// that is over already. + fn leave(&mut self, session: SessionId); + /// Your latest presence in `session` (the link throttles and coalesces). + fn presence(&mut self, session: SessionId, p: &Presence); + /// A message for `session`'s server (document changes), sent in order. + fn send(&mut self, session: SessionId, msg: ClientMsg); + /// Events since the last call, each with its session ([`NO_SESSION`] for answers to + /// background requests); never blocks. + fn poll(&mut self) -> Vec<(SessionId, Event)>; +} From ad4ba1ae9b800fa082561ba6c341c85c49064c83 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 19:20:45 +0000 Subject: [PATCH 27/67] refactor(engine): split dev/codegen.rs into codegen/ --- crates/engine/src/dev/codegen.rs | 1214 +--------------------- crates/engine/src/dev/codegen/arkui.rs | 228 ++++ crates/engine/src/dev/codegen/compose.rs | 131 +++ crates/engine/src/dev/codegen/css.rs | 288 +++++ crates/engine/src/dev/codegen/html.rs | 390 +++++++ crates/engine/src/dev/codegen/swift.rs | 168 +++ 6 files changed, 1222 insertions(+), 1197 deletions(-) create mode 100644 crates/engine/src/dev/codegen/arkui.rs create mode 100644 crates/engine/src/dev/codegen/compose.rs create mode 100644 crates/engine/src/dev/codegen/css.rs create mode 100644 crates/engine/src/dev/codegen/html.rs create mode 100644 crates/engine/src/dev/codegen/swift.rs diff --git a/crates/engine/src/dev/codegen.rs b/crates/engine/src/dev/codegen.rs index 5064c64f..093a8fd9 100644 --- a/crates/engine/src/dev/codegen.rs +++ b/crates/engine/src/dev/codegen.rs @@ -14,6 +14,23 @@ use crate::component_codegen::{Component, Param}; use crate::params::*; use crate::{bad, spec}; +mod arkui; +mod compose; +mod css; +mod html; +mod swift; + +pub(crate) use arkui::arkui; +pub(crate) use compose::{compose, kotlin_color}; +pub(crate) use css::Tokens; +#[cfg(test)] +pub(crate) use css::css_decls; +use css::{css, tok}; +pub use css::{css_var_name, css_var_of}; +use html::html; +pub(crate) use html::{Markup, class_name}; +pub(crate) use swift::swift; + pub fn specs() -> Vec { vec![spec!( "inspect.code", @@ -363,354 +380,6 @@ pub(crate) fn indent(depth: usize) -> String { " ".repeat(depth) } -// ------------------------------------------------------------------ CSS - -/// The CSS custom properties generated code uses: name → fallback value, in first-use order. -#[derive(Default)] -pub(crate) struct Tokens { - vars: Vec<(String, String)>, - /// Name variables as Tailwind theme variables (`--color-accent`), not by their CSS name. - pub tailwind: bool, - /// The file's theme names (computed on first use). - theme: Option, -} - -impl Tokens { - /// `var(--name, value)`, remembering the variable. - fn var(&mut self, doc: &Document, variable: &Variable, value: String) -> String { - let name = - if self.tailwind { self.theme.get_or_insert_with(|| crate::tailwind::Theme::new(doc)).name(variable) } else { css_var_of(variable) }; - let out = format!("var({name}, {value})"); - if !self.vars.iter().any(|(n, _)| *n == name) { - self.vars.push((name, value)); - } - out - } - - /// A `:root` rule declaring the variables (empty without any). - pub fn root_rule(&self) -> String { - if self.vars.is_empty() { - return String::new(); - } - let decls: String = self.vars.iter().map(|(n, v)| format!(" {n}: {v};\n")).collect(); - format!(":root {{\n{decls}}}\n") - } -} - -/// A variable's CSS custom property name: "Colors/Brand 500" → `--colors-brand-500`. -pub fn css_var_name(variable: &str) -> String { - let mut out = String::from("--"); - for c in variable.chars().flat_map(char::to_lowercase).take(80) { - if c.is_alphanumeric() || c == '_' { - out.push(c); - } else if !out.ends_with('-') { - out.push('-'); - } - } - let trimmed = out.trim_end_matches('-'); - if trimmed.len() <= 2 { "--variable".into() } else { trimmed.to_string() } -} - -/// A variable's CSS custom property: its Web code syntax (`var(--x)` or `--x`) when it has one, -/// else made from its name. -pub fn css_var_of(v: &Variable) -> String { - let Some(web) = v.code_syntax.get("WEB").map(|w| w.trim()) else { return css_var_name(&v.name) }; - let inner = web.strip_prefix("var(").map_or(web, |r| r.split([',', ')']).next().unwrap_or("").trim()); - let valid = inner.len() > 2 && inner.starts_with("--") && inner.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_'); - if valid { inner.chars().take(80).collect() } else { css_var_name(inner) } -} - -/// `value`, or a reference to the variable bound to the first of `fields` that has one. -fn tok(doc: &Document, n: &Node, fields: &[&str], value: String, t: &mut Tokens) -> String { - match fields.iter().find_map(|f| crate::devinspect::bound_variable(doc, n, f)) { - Some(v) => t.var(doc, v, value), - None => value, - } -} - -fn css(doc: &Document, id: NodeId) -> String { - let Some(n) = doc.node(id) else { return String::new() }; - let mut t = Tokens::default(); - let decls = css_decls(doc, n, &mut t, (false, false)); - format!("/* {} */\n{decls}", n.name.replace("*/", "* /")) -} - -/// The CSS declarations for `n`'s own look and size, one per line; `hug` leaves out the width -/// and/or height (they follow the content). Bound variables become `var(--name, value)`. -pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bool)) -> String { - let px = |v: f64| format!("{}px", num(v)); - let mut out = String::new(); - let mut put = |key: &str, value: String| out.push_str(&format!("{key}: {value};\n")); - if !hug.0 { - put("width", tok(doc, n, &["width"], px(n.width), t)); - } - if !hug.1 { - put("height", tok(doc, n, &["height"], px(n.height), t)); - } - if let Some(l) = &n.layout { - put("display", "flex".into()); - put("flex-direction", if l.mode == LayoutMode::Vertical { "column" } else { "row" }.into()); - if l.wrap { - put("flex-wrap", "wrap".into()); - } - put("gap", tok(doc, n, &["gap"], px(l.gap), t)); - let [pt, pr, pb, pl] = l.padding; - let sides = [ - tok(doc, n, &["paddingTop", "paddingVertical", "padding"], px(pt), t), - tok(doc, n, &["paddingRight", "paddingHorizontal", "padding"], px(pr), t), - tok(doc, n, &["paddingBottom", "paddingVertical", "padding"], px(pb), t), - tok(doc, n, &["paddingLeft", "paddingHorizontal", "padding"], px(pl), t), - ]; - put("padding", sides.join(" ")); - let justify = match l.main_align { - MainAlign::Min => "flex-start", - MainAlign::Center => "center", - MainAlign::Max => "flex-end", - MainAlign::SpaceBetween => "space-between", - MainAlign::SpaceAround => "space-around", - MainAlign::SpaceEvenly => "space-evenly", - }; - put("justify-content", justify.into()); - let align = match l.cross_align { - CrossAlign::Min => "flex-start", - CrossAlign::Center => "center", - CrossAlign::Max => "flex-end", - CrossAlign::Baseline => "baseline", - }; - put("align-items", align.into()); - if l.wrap { - // The lines: spread, or packed where the items align (baseline: at the start). - let spread = l.content_align == ContentAlign::SpaceBetween; - put( - "align-content", - if spread { - "space-between" - } else if align == "baseline" { - "flex-start" - } else { - align - } - .into(), - ); - } - } - if matches!(n.kind, NodeKind::Ellipse { .. }) { - put("border-radius", "50%".into()); - } else if let Some(r) = n.corners.uniform().filter(|r| *r > 0.0) { - put("border-radius", tok(doc, n, &["cornerRadius"], px(r), t)); - } else if n.corners.radii.iter().any(|r| *r > 0.0) { - let fields = ["topLeftRadius", "topRightRadius", "bottomRightRadius", "bottomLeftRadius"]; - let corners: Vec = n.corners.radii.iter().zip(fields).map(|(r, f)| tok(doc, n, &[f, "cornerRadius"], px(*r), t)).collect(); - put("border-radius", corners.join(" ")); - } - if n.opacity < 1.0 { - put("opacity", tok(doc, n, &["opacity"], num(f64::from(n.opacity)), t)); - } - if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) { - put("mix-blend-mode", n.blend.css().into()); - } - // Rotation turns counter-clockwise about the top-left corner (the position given); CSS - // rotate turns clockwise. - let rot = figcraft_geom::rotation_degrees(n.transform); - if rot.abs() > 0.01 && rot.is_finite() { - put("transform", format!("rotate({}deg)", num(-rot))); - put("transform-origin", "top left".into()); - } - for (i, p) in n.fills.iter().enumerate().filter(|(_, p)| p.visible) { - match &p.kind { - PaintKind::Solid { color } => { - let key = if matches!(n.kind, NodeKind::Text(_)) { "color" } else { "background" }; - let c = color.with_alpha(color.a * p.opacity).to_css(); - // A variable binds the first paint's colour. - put(key, if i == 0 { tok(doc, n, &["fill"], c, t) } else { c }); - } - PaintKind::Gradient(g) => put("background", css_gradient(g, n.width, n.height)), - PaintKind::Image(_) => put("background", "url(image.png) center / cover".into()), - PaintKind::Pattern(_) => put("background", "url(pattern.png) repeat".into()), - } - } - for (key, value) in css_borders(doc, n, t) { - put(&key, value); - } - let shadows: Vec = n - .effects - .iter() - .filter(|e| e.visible) - .filter_map(|e| match &e.kind { - EffectKind::DropShadow(s) => { - Some(format!("{}px {}px {}px {}px {}", num(s.offset.0), num(s.offset.1), num(s.radius), num(s.spread), s.color.to_css())) - } - EffectKind::InnerShadow(s) => { - Some(format!("inset {}px {}px {}px {}px {}", num(s.offset.0), num(s.offset.1), num(s.radius), num(s.spread), s.color.to_css())) - } - _ => None, - }) - .collect(); - if !shadows.is_empty() { - put("box-shadow", shadows.join(", ")); - } - for e in n.effects.iter().filter(|e| e.visible) { - match e.kind { - // CSS blurs are uniform: a progressive blur has no CSS equivalent and is left out. - EffectKind::LayerBlur(ref b) if !b.is_progressive() => put("filter", format!("blur({}px)", num(b.radius / 2.0))), - EffectKind::BackgroundBlur(ref b) if !b.is_progressive() => put("backdrop-filter", format!("blur({}px)", num(b.radius / 2.0))), - // CSS has no refraction: the frost is the nearest. - EffectKind::Glass(ref g) => put("backdrop-filter", format!("blur({}px)", num(g.sanitized().frost / 2.0))), - _ => {} - } - } - if let NodeKind::Text(tx) = &n.kind { - let s = &tx.style; - put("font-family", tok(doc, n, &["fontFamily"], format!("\"{}\"", s.font_family.replace('"', "'")), t)); - put("font-size", tok(doc, n, &["fontSize"], px(s.font_size), t)); - put("font-weight", s.weight().to_string()); - if s.italic() { - put("font-style", "italic".into()); - } - match s.line_height { - LineHeight::Pixels(v) => put("line-height", tok(doc, n, &["lineHeight"], px(v), t)), - LineHeight::Percent(p) => put("line-height", format!("{}%", num(p))), - LineHeight::Auto => {} - } - match s.letter_spacing { - LetterSpacing::Pixels(v) if v != 0.0 => put("letter-spacing", tok(doc, n, &["letterSpacing"], px(v), t)), - LetterSpacing::Percent(p) if p != 0.0 => put("letter-spacing", format!("{}em", num(p / 100.0))), - _ => {} - } - match tx.align_h { - TextAlignH::Left => {} - TextAlignH::Center => put("text-align", "center".into()), - TextAlignH::Right => put("text-align", "right".into()), - TextAlignH::Justified => put("text-align", "justify".into()), - } - match s.case { - TextCase::Original => {} - TextCase::Upper => put("text-transform", "uppercase".into()), - TextCase::Lower => put("text-transform", "lowercase".into()), - TextCase::Title => put("text-transform", "capitalize".into()), - TextCase::SmallCaps => put("font-variant", "small-caps".into()), - } - match s.decoration { - TextDecoration::None => {} - TextDecoration::Underline => put("text-decoration", "underline".into()), - TextDecoration::Strikethrough => put("text-decoration", "line-through".into()), - } - if s.decoration != TextDecoration::None { - let l = s.look(); - let len = |m: LineHeight| match m { - LineHeight::Auto => None, - LineHeight::Pixels(v) => Some(px(v)), - LineHeight::Percent(p) => Some(format!("{}%", num(p))), - }; - match l.style { - figcraft_doc::DecorationStyle::Solid => {} - figcraft_doc::DecorationStyle::Dotted => put("text-decoration-style", "dotted".into()), - figcraft_doc::DecorationStyle::Wavy => put("text-decoration-style", "wavy".into()), - } - if let Some(v) = len(l.thickness) { - put("text-decoration-thickness", v); - } - if let Some(c) = l.color { - put("text-decoration-color", c.to_css()); - } - if s.decoration == TextDecoration::Underline { - if let Some(v) = len(l.offset) { - put("text-underline-offset", v); - } - // Browsers skip ink by default; the canvas only when asked. - if !l.skip_ink { - put("text-decoration-skip-ink", "none".into()); - } - } - } - if tx.leading_trim == figcraft_doc::LeadingTrim::CapHeight { - put("text-box", "trim-both cap alphabetic".into()); - } - } - out -} - -/// A gradient fill on a `w`×`h` box as a CSS background: linear along its handles' direction -/// (CSS angles: 0deg up, 90deg right), radial with its handles' radii, angular as a conic -/// gradient from its start direction. -fn css_gradient(g: &figcraft_color::Gradient, w: f64, h: f64) -> String { - let stops = g.sorted_stops().iter().map(|s| format!("{} {}%", s.color.to_css(), (s.position * 100.0).round())).collect::>().join(", "); - let (dx, dy) = ((g.to.0 - g.from.0) * w, (g.to.1 - g.from.1) * h); - let angle = if dx == 0.0 && dy == 0.0 { 180.0 } else { dx.atan2(-dy).to_degrees().rem_euclid(360.0) }; - let pct = |v: f64| format!("{}%", num(v * 100.0)); - let at = format!("at {} {}", pct(g.from.0), pct(g.from.1)); - match g.kind { - figcraft_color::GradientKind::Linear => format!("linear-gradient({}deg, {stops})", num(angle)), - figcraft_color::GradientKind::Radial | figcraft_color::GradientKind::Diamond => { - let (rx, ry) = (((g.width.0 - g.from.0) * w).hypot((g.width.1 - g.from.1) * h), dx.hypot(dy)); - let rel = |r: f64, side: f64| if side > 0.0 { pct(r / side) } else { "50%".into() }; - format!("radial-gradient({} {} {at}, {stops})", rel(rx, w), rel(ry, h)) - } - figcraft_color::GradientKind::Angular => format!("conic-gradient(from {}deg {at}, {stops})", num(angle)), - } -} - -// ------------------------------------------------------------------ HTML and React markup - -/// A word for the node's kind, naming layers whose name transliterates to nothing ("###" → -/// `.text`, `.frame`), instead of the anonymous `layer-`. -fn kind_word(n: &Node) -> &str { - match &n.kind { - NodeKind::Page { .. } => "page", - NodeKind::Frame => "frame", - NodeKind::Group => "group", - NodeKind::Section { .. } => "section", - NodeKind::Rectangle => "rectangle", - NodeKind::Ellipse { .. } => "ellipse", - NodeKind::Polygon { .. } => "polygon", - NodeKind::Star { .. } => "star", - NodeKind::Line => "line", - NodeKind::Vector { .. } => "vector", - NodeKind::Text(_) => "text", - NodeKind::BooleanOp { .. } => "boolean", - NodeKind::Component { .. } | NodeKind::ComponentSet { .. } => "component", - NodeKind::Instance { .. } => "instance", - NodeKind::Slice => "slice", - } -} - -/// A CSS class name from a layer name ("Card / Title" → "card-title"), unique in `used`. The name -/// is transliterated to ASCII first, so non-Latin names stay readable: 로그인 화면 → -/// `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` (per-codepoint transliteration, the slug -/// libraries' way); a name left with nothing (symbols only) takes `kind`, and one starting with a -/// digit keeps the `layer-` prefix. -pub(crate) fn class_name(name: &str, kind: &str, used: &mut Vec) -> String { - let mut base = String::new(); - for c in deunicode::deunicode(name).chars().flat_map(char::to_lowercase) { - if c.is_ascii_alphanumeric() { - base.push(c); - } else if !base.ends_with('-') && !base.is_empty() { - base.push('-'); - } - } - // The 40-character cut lands on a word boundary when there is one past the first word. - let base = if base.chars().count() > 40 { - let cut: String = base.chars().take(40).collect(); - match cut.rfind('-') { - Some(i) if i > 0 => cut[..i].to_string(), - _ => cut, - } - } else { - base - }; - let base = base.trim_end_matches('-'); - let base = if base.is_empty() { - kind.to_string() - } else if base.starts_with(|c: char| c.is_ascii_digit()) { - format!("layer-{base}") - } else { - base.to_string() - }; - let name = (1..).map(|k| if k == 1 { base.clone() } else { format!("{base}-{k}") }).find(|n| !used.contains(n)).unwrap_or(base); - used.push(name.clone()); - name -} - /// The index of `markup`'s first tag-closing `>`, skipping `>` inside quoted attribute values. fn tag_end(markup: &str) -> Option { let mut quote = None; @@ -740,855 +409,6 @@ fn with_v_if(markup: &str, guard: Option<&str>) -> String { format!("{} v-if=\"{guard}\"{}", &markup[..at], &markup[at..]) } -fn html_escape(s: &str) -> String { - s.replace('&', "&").replace('<', "<").replace('>', ">").replace('\n', "
") -} - -/// Text as JSX children: HTML escapes, braces as expressions, line breaks as `
`. -fn jsx_text(s: &str) -> String { - let mut out = String::new(); - for c in s.chars().take(10_000) { - match c { - '&' => out.push_str("&"), - '<' => out.push_str("<"), - '>' => out.push_str(">"), - '{' => out.push_str("{\"{\"}"), - '}' => out.push_str("{\"}\"}"), - '\n' => out.push_str("
"), - '\r' => {} - c => out.push(c), - } - } - out -} - -/// The whole tree under `id` as nested HTML with a stylesheet: auto layout frames become -/// flexboxes, other children are placed absolutely in their parent, text becomes paragraphs. -fn html(doc: &Document, id: NodeId) -> String { - let ctx = Ctx::new(doc, id); - let mut out = Markup::new(&ctx, CodeLang::Html); - out.node(id, None, 0, ""); - format!("{}\n\n", out.markup, out.tokens.root_rule(), out.styles) -} - -/// Markup being built for a layer tree: HTML (`class`), React JSX (`className`), Vue -/// (`{{ }}` interpolation, `v-if`, `:attr` bindings) or Leptos `view!` markup (text as Rust -/// string literals); the last three with component parameters and instances as calls. With its -/// stylesheet. -pub(crate) struct Markup<'c, 'a> { - ctx: &'c Ctx<'a>, - /// Html, React, Vue or Leptos. - lang: CodeLang, - used: Vec, - pub markup: String, - pub styles: String, - pub tokens: Tokens, - /// The root's class (default: from its name) and extra JSX attributes. - pub root_class: Option, - pub root_attrs: String, - /// A Vue `v-if` guard for the element about to be written: Vue templates can't wrap an - /// element in an expression, so a boolean parameter's layer carries the condition itself. - v_if: Option, - /// A prefix for every generated class name (slot content joins the caller's stylesheet, where - /// two slots' layer names may repeat). - pub class_prefix: Option, - /// Each layer's path in the tree (names with sibling counts), class and declarations, so - /// variants can be compared layer by layer. - pub rules: Vec<(String, String, String)>, - /// Instances as their own (overridden) layers instead of component calls, except those - /// connected to code (page code). - pub inline: bool, - /// Auto layout frames and text as fluid boxes (`width: 100%` up to their width) for pages. - pub fluid: bool, - /// With `fluid`, other layers at least this share of the root's width are fluid too (a - /// divider, a banner image); smaller ones (icons) keep their size. Default: none. - pub fluid_leaves: f64, -} - -impl<'c, 'a> Markup<'c, 'a> { - pub fn new(ctx: &'c Ctx<'a>, lang: CodeLang) -> Self { - Markup { - ctx, - lang, - used: Vec::new(), - markup: String::new(), - styles: String::new(), - tokens: Tokens::default(), - root_class: None, - root_attrs: String::new(), - v_if: None, - class_prefix: None, - rules: Vec::new(), - inline: false, - fluid: false, - fluid_leaves: f64::INFINITY, - } - } - - /// JSX and Vue templates indent by two spaces, HTML and Leptos by four. - fn pad(&self, depth: usize) -> String { - if matches!(self.lang, CodeLang::React | CodeLang::Vue) { " ".repeat(depth) } else { indent(depth) } - } - - /// Component code (React or Leptos): parameters and instances as calls. - fn code(&self) -> bool { - self.lang != CodeLang::Html - } - - /// A parameter's name in the generated code. - fn ident(&self, p: &Param) -> String { - if self.lang == CodeLang::Leptos { crate::component_codegen::rust_ident(&p.ident) } else { p.ident.clone() } - } - - /// A layer, shown only while its boolean parameter is on (component code). - pub fn node(&mut self, id: NodeId, parent: Option<&Node>, depth: usize, path: &str) { - let Some(n) = self.ctx.doc.node(id) else { return }; - if self.code() - && id != self.ctx.root - && let Some(p) = self.ctx.param(n, "visible") - { - // A Vue template holds no arbitrary expressions, so the guard rides on the element. - if self.lang == CodeLang::Vue { - self.v_if = Some(self.ident(p)); - self.element(n, parent, depth, path); - self.v_if = None; - return; - } - let (pad, p) = (self.pad(depth), self.ident(p)); - let (open, close) = - if self.lang == CodeLang::Leptos { (format!("{{{p}.then(|| view! {{"), "})}") } else { (format!("{{{p} && ("), ")}") }; - self.markup.push_str(&format!("{pad}{open}\n")); - self.element(n, parent, depth + 1, path); - self.markup.push_str(&format!("{pad}{close}\n")); - return; - } - self.element(n, parent, depth, path); - } - - fn rule(&mut self, path: &str, class: &str, decls: String) { - self.styles.push_str(&format!(".{class} {{\n")); - for line in decls.lines() { - self.styles.push_str(&format!(" {line}\n")); - } - self.styles.push_str("}\n"); - self.rules.push((path.to_string(), class.to_string(), decls)); - } - - fn element(&mut self, n: &Node, parent: Option<&Node>, depth: usize, path: &str) { - let doc = self.ctx.doc; - let root = n.id == self.ctx.root; - let class = match self.root_class.take().filter(|_| root) { - Some(c) => { - self.used.push(c.clone()); - c - } - None => { - let class = class_name(&n.name, kind_word(n), &mut self.used); - match &self.class_prefix { - Some(prefix) => format!("{prefix}{class}"), - None => class, - } - } - }; - let pad = self.pad(depth); - let attr = if self.lang == CodeLang::React { "className" } else { "class" }; - // How it sits in its parent: in the flex flow, or placed at its position. - let mut decls = String::new(); - let in_flow = parent.is_some_and(|p| p.layout.is_some()) && !n.child_layout.absolute; - if root { - decls.push_str("position: relative;\n"); - } else if in_flow { - let vertical = parent.and_then(|p| p.layout.as_ref()).is_some_and(|l| l.mode == LayoutMode::Vertical); - let (main, cross) = - if vertical { (n.child_layout.sizing_v, n.child_layout.sizing_h) } else { (n.child_layout.sizing_h, n.child_layout.sizing_v) }; - if main == Sizing::Fill { - decls.push_str("flex: 1 1 0;\n"); - } - if cross == Sizing::Fill { - decls.push_str("align-self: stretch;\n"); - } - } else { - let c = n.transform.as_coeffs(); - decls.push_str(&format!("position: absolute;\nleft: {}px;\ntop: {}px;\n", num(c[4]), num(c[5]))); - let rot = figcraft_geom::rotation_degrees(n.transform); - if rot.abs() > 0.01 { - decls.push_str(&format!("transform: rotate({}deg);\ntransform-origin: 0 0;\n", num(-rot))); - } - } - // Nested instances are calls (component code), in a wrapper that places them when needed. - // Page code inlines instances, except those connected to code (codeConnect.set): their - // snippet, e.g. a design-system `