From a55963841973ae4fc31122c49349fe6aacc7cdee Mon Sep 17 00:00:00 2001 From: YarilaFox <102650794+YarilaFox@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:57:10 +0500 Subject: [PATCH 01/54] M7: avoid Windows GNU UIA crash --- .cargo/config.toml | 7 +++++++ apps/figcraft/build.rs | 1 + apps/figcraft/src/main.rs | 5 +++++ 3 files changed, 13 insertions(+) diff --git a/.cargo/config.toml b/.cargo/config.toml index acebfe9f..8abc94d7 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -3,5 +3,12 @@ [env] CRAFT_FONTS_DIR = { value = "../craft-fonts", relative = true } +# The Windows UI Automation adapter can be asked about a node that disappeared in the +# same frame. On MinGW, converting that HRESULT through windows-result's thread COM +# error-info path has crashed in GetErrorInfo. Keep the HRESULT without that extra +# COM lookup; this leaves the AccessKit tree and screen-reader support enabled. +[target.x86_64-pc-windows-gnu] +rustflags = ["--cfg=windows_slim_errors"] + [alias] xtask = "run -q -p xtask --" diff --git a/apps/figcraft/build.rs b/apps/figcraft/build.rs index 64f97835..6ed8a047 100644 --- a/apps/figcraft/build.rs +++ b/apps/figcraft/build.rs @@ -1,6 +1,7 @@ //! Windows: embed the icon and version info in figcraft.exe and size its main thread's stack //! (no-op elsewhere). fn main() { + println!("cargo::rustc-check-cfg=cfg(windows_slim_errors)"); println!("cargo::rerun-if-changed=../../assets/app-icon/figcraft.ico"); let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); if target_os == "windows" { diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 00856c5d..887db45a 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -15,6 +15,11 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] +// A missing Windows GNU flag re-enables the COM error-info lookup that crashes while AccessKit +// reports a removed UI Automation node. Catch build configurations that drop the workaround. +#[cfg(all(target_os = "windows", target_env = "gnu", not(windows_slim_errors)))] +compile_error!("Windows GNU builds must set windows_slim_errors to keep UI Automation responsive"); + #[cfg(target_os = "macos")] mod apple_events; mod args; From f3e5d2abc4c5db3289dce999bc1c1ed47303975d Mon Sep 17 00:00:00 2001 From: Hamza Date: Wed, 21 Oct 2026 00:06:57 +0500 Subject: [PATCH 02/54] .fig import: a note says how much of the file it covers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A .fig import's notes were deduplicated and left uncounted, so each kind was named once with no sign of how much of the file it applied to: a file whose every text layer lost a setting read exactly like one where a single layer did. That is what made the loss in #467 invisible -- the reporter saw one line while three kinds of data went, one of them on all 83 text layers. The mapper now collects its notes through the same `Warnings` the REST importer already uses, which keeps the first-seen order and the cap and appends `(×n)` where a note was met more than once. Nothing else changes: the note text stays the catalog key it was, and the import report already reads a `(×n)` suffix off before translating the base (`ui-egui`'s `note_text`), so a counted note is still translated. `figcraft-cli info` prints these notes, so it now names each kind with its count. With the count dropped again, the test reports the two notes plain: ["connector layers import as static frame/vector snapshots ...", "connector label was skipped: depth limit prevents nested ..."] Part of #467. --- crates/format/src/fig/map.rs | 18 +++++---- .../map_connector_snapshot_budget_tests.rs | 40 +++++++++++++++++-- crates/format/src/rest/mod.rs | 19 +++++++-- 3 files changed, 61 insertions(+), 16 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 8e467151..f36cbb83 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -58,7 +58,7 @@ pub(super) fn to_rest(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec { library_contexts: Vec, /// Layers visited while scanning internal canvases, across all of them. library_scan_count: usize, - warnings: Vec, + warnings: crate::rest::Warnings, count: usize, /// Most layers produced or scanned, instance expansion included; the one place the cap comes /// from (see [`super::layer_budget`]). @@ -464,7 +464,7 @@ impl<'a> Mapper<'a> { override_scope_entries: 0, library_contexts: Vec::new(), library_scan_count: 0, - warnings: Vec::new(), + warnings: crate::rest::Warnings::default(), count: 0, max_nodes, networks: Vec::new(), @@ -493,12 +493,14 @@ impl<'a> Mapper<'a> { .cloned() } + /// A note about what this file's mapping left out or changed. Counted, as the REST importer + /// counts its own: these notes are met once per layer, so the kinds were named once each with + /// no sign of how much of the file they covered -- a file whose every text layer lost a + /// setting read the same as one where a single layer did (#467). The collector keeps the + /// first-seen order and the same cap, since some notes name fields of the file (image + /// filters). fn warn(&mut self, w: impl Into) { - let w = w.into(); - // Capped like the REST importer's list: some warnings name fields of the file (image filters). - if self.warnings.len() < crate::rest::MAX_WARNINGS && !self.warnings.contains(&w) { - self.warnings.push(w); - } + self.warnings.add(w.into()); } fn children(&self, id: &str) -> Vec { diff --git a/crates/format/src/fig/map_connector_snapshot_budget_tests.rs b/crates/format/src/fig/map_connector_snapshot_budget_tests.rs index ca47578d..8727c31b 100644 --- a/crates/format/src/fig/map_connector_snapshot_budget_tests.rs +++ b/crates/format/src/fig/map_connector_snapshot_budget_tests.rs @@ -115,14 +115,14 @@ fn connector_snapshot_respects_last_and_zero_child_slots() { assert!(!children[0]["fillGeometry"].as_array().unwrap().is_empty(), "saved outline survives the budget limit"); assert_eq!(children[0]["fillGeometry"][0]["path"], "M 0 0 L 20 10"); assert_eq!(mapper.count, MAX_NODES); - assert!(mapper.warnings.iter().any(|warning| warning == &format!("the file has more than {MAX_NODES} layers; connector label was skipped"))); + assert!(mapper.warnings.seen().any(|warning| warning == format!("the file has more than {MAX_NODES} layers; connector label was skipped"))); let mut mapper = Mapper::new(&message, MAX_NODES); mapper.count = MAX_NODES; let children = mapper.connector_snapshot_children(&view, "1:123"); assert!(children.is_empty()); assert_eq!(mapper.count, MAX_NODES, "the node budget is never exceeded"); - assert!(mapper.warnings.iter().any(|warning| warning.contains("connector snapshot children were skipped"))); + assert!(mapper.warnings.seen().any(|warning| warning.contains("connector snapshot children were skipped"))); } #[test] @@ -133,7 +133,7 @@ fn connector_snapshot_flattens_vector_at_depth_and_node_limits() { assert_eq!(depth_limited["type"], "VECTOR"); assert!(!depth_limited["fillGeometry"].as_array().unwrap().is_empty()); assert!(depth_limited.get("children").is_none()); - assert!(mapper.warnings.iter().any(|warning| warning == "connector label was skipped: depth limit prevents nested snapshot children")); + assert!(mapper.warnings.seen().any(|warning| warning == "connector label was skipped: depth limit prevents nested snapshot children")); let mut mapper = Mapper::new(&message, MAX_NODES); mapper.count = MAX_NODES - 1; @@ -142,5 +142,37 @@ fn connector_snapshot_flattens_vector_at_depth_and_node_limits() { assert!(!budget_limited["fillGeometry"].as_array().unwrap().is_empty()); assert!(budget_limited.get("children").is_none()); assert_eq!(mapper.count, MAX_NODES); - assert!(mapper.warnings.iter().any(|warning| warning == "connector label was skipped: node budget prevents nested snapshot children")); + assert!(mapper.warnings.seen().any(|warning| warning == "connector label was skipped: node budget prevents nested snapshot children")); +} + +/// A mapper note is met once per layer, so the kinds were named once each with no sign of how much +/// of the file they covered: a file whose every layer lost a setting read the same as one where a +/// single layer did (#467). They are counted now, with the `(×n)` the REST importer already uses +/// and the import report already reads back off before translating a note. +#[test] +fn a_note_met_more_than_once_carries_its_count() { + let message = message(); + + let mut mapper = Mapper::new(&message, MAX_NODES); + for _ in 0..2 { + mapper.layer("1:123", None, Affine::IDENTITY, None, None, MAX_DEPTH).expect("terminal connector"); + } + let notes = std::mem::take(&mut mapper.warnings).finish(); + let counted: Vec<&String> = notes.iter().filter(|n| n.contains("(×")).collect(); + assert!( + counted.iter().any(|n| n.ends_with("(×2)") && n.contains("connector label was skipped: depth limit")), + "the note met twice says so: {notes:?}" + ); + assert!(notes.iter().all(|n| !n.ends_with("(×1)")), "a count of one is left off: {notes:?}"); + + // One layer, and the same notes have no count at all. + let mut mapper = Mapper::new(&message, MAX_NODES); + mapper.layer("1:123", None, Affine::IDENTITY, None, None, MAX_DEPTH).expect("terminal connector"); + let once = std::mem::take(&mut mapper.warnings).finish(); + assert!(once.iter().all(|n| !n.contains("(×")), "{once:?}"); + assert_eq!( + once.len(), + counted.len() + notes.iter().filter(|n| !n.contains("(×")).count(), + "the same kinds, counted or not: {once:?} vs {notes:?}" + ); } diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 78279e91..b86b0b91 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -329,15 +329,16 @@ fn children(v: &Value) -> &[Value] { v.get("children").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default() } -/// Distinct warnings in first-seen order, with repeat counts. +/// Distinct warnings in first-seen order, with repeat counts. Shared with the `.fig` mapper +/// (`crate::fig::map`), whose notes repeat per layer in the same way. #[derive(Default)] -struct Warnings { +pub(crate) struct Warnings { list: Vec<(String, usize)>, index: BTreeMap, } impl Warnings { - fn add(&mut self, msg: String) { + pub(crate) fn add(&mut self, msg: String) { if let Some(i) = self.index.get(&msg) { if let Some(e) = self.list.get_mut(*i) { e.1 += 1; @@ -348,7 +349,17 @@ impl Warnings { } } - fn finish(self) -> Vec { + /// The notes met so far, without their counts, for the tests that ask whether one was met + /// (the product reads them through [`Self::finish`], with the counts). + #[cfg(test)] + pub(crate) fn seen(&self) -> impl Iterator { + self.list.iter().map(|(msg, _)| msg.as_str()) + } + + /// The notes, each with `(×n)` after it where it was met more than once. The import report + /// reads that suffix off before it looks the note up in the catalogue, so a counted note is + /// still translated (`ui-egui`'s `note_text`). + pub(crate) fn finish(self) -> Vec { self.list.into_iter().map(|(m, n)| if n > 1 { format!("{m} (×{n})") } else { m }).collect() } } From e211bf5476c44512bf3238e3698c085267f5da9b Mon Sep 17 00:00:00 2001 From: Ridzzz0Alam Date: Wed, 21 Oct 2026 18:22:16 +0200 Subject: [PATCH 03/54] M1: large .fig files keep their layers and their library links (#981) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A design-system .fig file (54 pages) stopped at 244 248 layers, the rest of its pages skipped, and most of its instances came in as frames "of components that aren't in the file", with the cap note shown twice. - The layer budget follows the compressed canvas at 8 bytes a layer, not 32: design systems are mostly instances, which pack tightly. Files under about 1.6 MB keep the 200 000 floor and the 2 million ceiling stays (measured on synthetic files: about 5.4 KB and 19 µs a layer). - The library copies the pages use (the file's hidden internal canvas) are mapped before the pages: at the cap the pages' last layers are skipped, not the mains their instances link to. The REST stage keeps building pages first (its ids are first-wins) and allows for the wrapper frame of each library copy, the one layer the mapper doesn't count; that layer tripped the REST stage's own cap and repeated the note. - Instances and swap defaults whose main is in the file but past the cap say so instead of "aren't in the file" (en and es catalogs). - The .fig import gives a note the mapper already gave only once. Tests in fig/layer_cap_tests.rs: the budget for a #981-sized canvas, library mains ahead of the pages at the cap, the note for a capped local main, and a whole 210 000-layer file with one cap note and its library links kept. All four fail on the previous code. --- crates/format/src/fig/layer_cap_tests.rs | 117 +++++++++++++++++++++++ crates/format/src/fig/map.rs | 32 ++++++- crates/format/src/fig/mod.rs | 17 +++- crates/format/src/rest/mod.rs | 33 +++++-- crates/ui-egui/locales/en.toml | 2 + crates/ui-egui/locales/es.toml | 2 + 6 files changed, 188 insertions(+), 15 deletions(-) create mode 100644 crates/format/src/fig/layer_cap_tests.rs diff --git a/crates/format/src/fig/layer_cap_tests.rs b/crates/format/src/fig/layer_cap_tests.rs new file mode 100644 index 00000000..6eeb15f4 --- /dev/null +++ b/crates/format/src/fig/layer_cap_tests.rs @@ -0,0 +1,117 @@ +//! Large `.fig` files at the layer cap (#981): the budget a design-system file gets, the library +//! copies its instances need kept ahead of the pages, honest notes for what the cap kept out, and +//! one note for the cap itself. + +use figcraft_doc::{Document, NodeKind}; + +use super::kiwi::{Kv, Schema}; +use super::*; + +type Fields = Vec<(&'static str, Kv<'static>)>; + +fn o(fields: Fields) -> Kv<'static> { + Kv::Object(fields) +} + +fn g(id: u64) -> Kv<'static> { + o(vec![("sessionID", Kv::Uint(1)), ("localID", Kv::Uint(id))]) +} + +fn xy(x: f64, y: f64) -> Kv<'static> { + o(vec![("x", Kv::Float(x as f32)), ("y", Kv::Float(y as f32))]) +} + +/// A layer under `parent`; siblings come in id order. +fn node(id: u64, parent: Option, ty: &'static str, name: &str, more: Fields) -> Kv<'static> { + let mut fields = vec![("guid", g(id)), ("phase", Kv::Enum("CREATED")), ("type", Kv::Enum(ty)), ("name", Kv::Str(name.into()))]; + if let Some(parent) = parent { + fields.push(("parentIndex", o(vec![("guid", g(parent)), ("position", Kv::Str(format!("{id:08}")))]))); + } + fields.extend(more); + o(fields) +} + +fn instance(id: u64, page: u64, main: u64) -> Kv<'static> { + node(id, Some(page), "INSTANCE", "Instance", vec![("symbolData", o(vec![("symbolID", g(main))]))]) +} + +/// A main `id` of `layers` rectangles under `parent`. +fn main(id: u64, parent: u64, name: &str, layers: u64) -> Vec> { + let mut out = vec![node(id, Some(parent), "SYMBOL", name, vec![("size", xy(10.0, 10.0))])]; + out.extend((1..=layers).map(|i| node(id * 10_000 + i, Some(id), "RECTANGLE", "R", vec![("size", xy(1.0, 1.0))]))); + out +} + +/// A page (2) of `count` instances of a main kept on the file's hidden library canvas (3), as a +/// design system's file carries the library components it uses. +fn library_message(count: u64, main_layers: u64) -> Kv<'static> { + let mut nodes = vec![ + node(1, None, "DOCUMENT", "Document", vec![]), + node(2, Some(1), "CANVAS", "Screens", vec![]), + node(3, Some(1), "CANVAS", "Internal", vec![("internalOnly", Kv::Bool(true))]), + ]; + nodes.extend(main(20, 3, "Library main", main_layers)); + nodes.extend((0..count).map(|i| instance(1_000 + i, 2, 20))); + o(vec![("nodeChanges", Kv::Array(nodes))]) +} + +/// The `.fig` import's two stages at layer cap `cap`. +fn import_at(msg: &Kv, cap: usize) -> (Document, Vec) { + let (json, mut warnings, networks) = map::to_rest(msg, "Capped", cap); + let (doc, more) = crate::rest::import_with_networks(&json, &|_: &str| None, networks, cap).unwrap(); + warnings.extend(more); + (doc, warnings) +} + +fn linked_to(doc: &Document, main_name: &str) -> usize { + doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { component, .. } if doc.node(component).is_some_and(|c| c.name == main_name))).count() +} + +#[test] +fn a_design_system_sized_canvas_gets_room() { + // The file in #981 stopped at 244 248 layers: a 7.8 MB canvas at 32 bytes a layer. + let reported = 244_248 * 32; + assert!(layer_budget(reported) >= 4 * 244_248, "{}", layer_budget(reported)); + // Small files still can't claim more than the REST import's cap, nor any file the ceiling. + assert_eq!(layer_budget(1 << 20), crate::rest::MAX_NODES); + assert_eq!(layer_budget(usize::MAX), MAX_FIG_NODES); +} + +#[test] +fn library_mains_come_before_the_pages_at_the_cap() { + // 40 instances of an 11-layer library main on a page, with room for 100 layers: mapped after + // the pages, the main found no room and every instance became a frame of "a component that + // isn't in the file". + let (doc, warnings) = import_at(&library_message(40, 10), 100); + assert!(warnings.iter().any(|w| w.contains("more than 100 layers")), "{warnings:?}"); + assert!(!warnings.iter().any(|w| w.contains("aren't in the file")), "{warnings:?}"); + let linked = linked_to(&doc, "Library main"); + assert!(linked >= 5, "{linked} instances linked to the library main: {warnings:?}"); + assert!(doc.nodes().any(|n| n.name == "Library main" && doc.is_remote(n.id))); +} + +#[test] +fn instances_of_a_main_the_cap_kept_out_say_so() { + // The page's instances come before their main: at the cap, the main is skipped though the + // file has it. + let mut nodes = vec![node(1, None, "DOCUMENT", "Document", vec![]), node(2, Some(1), "CANVAS", "Page", vec![])]; + nodes.extend((0..20).map(|i| instance(100 + i, 2, 900))); + nodes.extend(main(900, 2, "Local main", 10)); + let (doc, warnings) = import_at(&o(vec![("nodeChanges", Kv::Array(nodes))]), 60); + assert!(warnings.iter().any(|w| w.starts_with("instances whose component was past the layer limit were imported as frames")), "{warnings:?}"); + assert!(!warnings.iter().any(|w| w.contains("aren't in the file")), "{warnings:?}"); + assert!(!doc.nodes().any(|n| n.name == "Local main")); +} + +#[test] +fn a_whole_file_past_the_cap_keeps_its_library_links_and_says_so_once() { + // A 1 001-layer library main used 210 times: about 210 000 layers in a file far too small to + // lift the cap above the REST import's 200 000. + let file = super::tests::canvas(&Schema::parse(export::SCHEMA).unwrap(), &library_message(210, 1_000)); + assert_eq!(layer_budget(file.len()), crate::rest::MAX_NODES); + let (doc, warnings) = import(&file, "Design system.fig").unwrap(); + let cap: Vec<_> = warnings.iter().filter(|w| w.contains("layers; the rest were skipped")).collect(); + assert_eq!(cap.len(), 1, "{warnings:?}"); + assert!(!warnings.iter().any(|w| w.contains("aren't in the file")), "{warnings:?}"); + assert!(linked_to(&doc, "Library main") >= 190, "{warnings:?}"); +} diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 8e467151..7eb974da 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -42,6 +42,14 @@ pub(super) fn to_rest(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec= m.max_nodes { + let capped: Vec = m.instance_mains.difference(&m.mapped_mains).map(|id| json!(id)).collect(); + if !capped.is_empty() { + out[crate::rest::FIG_CAPPED_MAINS_FIELD] = Value::Array(capped); + } + } if !m.styles.is_empty() { out["styles"] = Value::Object(std::mem::take(&mut m.styles)); } @@ -389,6 +397,10 @@ struct Mapper<'a> { library_scan_count: usize, warnings: Vec, count: usize, + /// The mains in the file that instances name, and the mains mapped as layers: the difference + /// is what the layer cap kept out. + instance_mains: BTreeSet, + mapped_mains: BTreeSet, /// Most layers produced or scanned, instance expansion included; the one place the cap comes /// from (see [`super::layer_budget`]). max_nodes: usize, @@ -466,6 +478,8 @@ impl<'a> Mapper<'a> { library_scan_count: 0, warnings: Vec::new(), count: 0, + instance_mains: BTreeSet::new(), + mapped_mains: BTreeSet::new(), max_nodes, networks: Vec::new(), network_ids: HashMap::new(), @@ -556,9 +570,8 @@ impl<'a> Mapper<'a> { Some(d) => self.children(d), None => self.nodes.iter().filter(|(_, n)| n.get("type").and_then(Kv::str) == Some("CANVAS")).map(|(id, _)| id.clone()).collect(), }; - let mut out = Vec::new(); let mut internal = Vec::new(); - let mut visible_pages = Vec::new(); + let mut visible = Vec::new(); for p in pages { let Some(&n) = self.nodes.get(&p) else { continue }; if n.get("type").and_then(Kv::str) != Some("CANVAS") { @@ -568,17 +581,20 @@ impl<'a> Mapper<'a> { // The hidden page that holds styles, variables and library copies. if n.get("internalOnly").and_then(Kv::bool) == Some(true) { internal.push((p, n)); - continue; + } else { + visible.push((p, n)); } - visible_pages.push(p.clone()); - out.push(self.page(&p, n)); } + // The library copies the pages use come first: in a file past the layer cap, the pages' + // last layers are skipped rather than the mains every instance of them needs (#981). + let visible_pages: Vec = visible.iter().map(|(p, _)| p.clone()).collect(); let required_mains = self.referenced_component_mains(&visible_pages); for (id, canvas) in internal { if let Some(context) = self.library_context(&id, canvas, &required_mains) { self.library_contexts.push(context); } } + let out: Vec = visible.into_iter().map(|(p, n)| self.page(&p, n)).collect(); if out.is_empty() { self.warn("the file has no pages"); } @@ -879,6 +895,9 @@ impl<'a> Mapper<'a> { self.warn("connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained"); } self.count += 1; + if own == "SYMBOL" && inst.is_none() { + self.mapped_mains.insert(id.to_string()); + } let mut o = Obj::new(); o.insert("id".into(), json!(rest_id)); // Private provenance for the .fig importer; ordinary REST import ignores it. @@ -1994,6 +2013,9 @@ impl<'a> Mapper<'a> { self.warn("instances of components that aren't in the file show no layers"); return None; } + if !self.instance_mains.contains(&sym) { + self.instance_mains.insert(sym.clone()); + } if outer.is_some_and(|i| i.symbols.contains(&sym)) { return None; } diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index 4c103d08..c04043ae 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -23,6 +23,8 @@ mod export_tests; #[cfg(test)] mod interop_tests; pub mod kiwi; +#[cfg(test)] +mod layer_cap_tests; mod map; mod network; #[cfg(test)] @@ -64,8 +66,11 @@ const MAX_CHUNKS: usize = 16; /// compressed canvas, at least the REST import's cap and at most [`MAX_FIG_NODES`]. Instances /// expand into a layer per layer of their component, and chunks inflate up to [`MAX_CHUNK`], so the /// budget follows what the file takes, not what it unpacks to: a small file can't claim a large -/// count, however it's built; a large one gets room. -const PACKED_BYTES_PER_LAYER: usize = 32; +/// count, however it's built; a large one gets room. Design systems are mostly instances, which +/// pack tightly: a 7.8 MB canvas stopped at 244 248 layers at 32 bytes a layer (#981). At 8, files +/// under about 1.6 MB keep the floor and the ceiling comes at a 16 MB canvas (about 11 GB and 40 s +/// to import 2 million synthetic layers; memory grows with the count, about 5.4 KB a layer). +const PACKED_BYTES_PER_LAYER: usize = 8; const MAX_FIG_NODES: usize = 2_000_000; /// Most layers imported from a `.fig` file whose compressed canvas is `packed` bytes. @@ -108,7 +113,13 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec), Forma drop(msg); let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).cloned()); let (doc, more) = crate::rest::import_with_networks(&json, &images, networks, max_nodes)?; - warnings.extend(more.into_iter().filter_map(map::adapt_warning)); + // Both stages stop at the same cap and say so in the same words: one note (#981). + for w in more.into_iter().filter_map(map::adapt_warning) { + let base = w.rsplit_once(" (×").map_or(w.as_str(), |(b, _)| b); + if !warnings.iter().any(|seen| seen == base) { + warnings.push(w); + } + } Ok((doc, warnings)) } diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 78279e91..40c091c6 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -65,6 +65,8 @@ const MAX_DEPTH: usize = 256; pub(crate) const FIG_SOURCE_FIELD: &str = "_figSource"; /// Hidden .fig library context; accepted only by the `.fig` import ([`import_with_networks`]). pub(crate) const FIG_LIBRARY_CONTEXTS_FIELD: &str = "_figLibraryContexts"; +/// Ids of mains in a `.fig` file that its layer cap kept out; read only by the `.fig` import. +pub(crate) const FIG_CAPPED_MAINS_FIELD: &str = "_figCappedMains"; /// Longest SVG path string read from one geometry entry. const MAX_PATH_BYTES: usize = 4 << 20; /// Most path elements in one vector. @@ -215,6 +217,14 @@ fn import_parts( cx.style_definitions(v); cx.networks = networks; cx.max_nodes = max_nodes; + // The `.fig` mapper already kept the layers, library copies included, within the cap; each + // library copy's wrapper frame is the one layer here it doesn't count (#981). + let contexts = if fig_sources { v.get(FIG_LIBRARY_CONTEXTS_FIELD).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default() } else { &[] }; + if fig_sources { + cx.capped_mains = + v.get(FIG_CAPPED_MAINS_FIELD).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_owned).collect(); + cx.max_nodes = max_nodes.saturating_add(contexts.len()); + } for e in entries { let Some(root) = e.get("document") else { continue }; match text(root, "type") { @@ -236,11 +246,9 @@ fn import_parts( None => cx.warn("entries without a node type were skipped"), } } - if fig_sources { - for context in v.get(FIG_LIBRARY_CONTEXTS_FIELD).and_then(Value::as_array).into_iter().flatten() { - let store = cx.doc.ensure_library_store(); - cx.build(context, store, Space::default(), None, 0, false); - } + for context in contexts { + let store = cx.doc.ensure_library_store(); + cx.build(context, store, Space::default(), None, 0, false); } // Drop the empty page the new document started with once real pages exist. if let Some(p) = blank @@ -404,6 +412,8 @@ pub(crate) struct Ctx<'a> { networks: Vec>, /// Most layers imported: [`MAX_NODES`], or the `.fig` import's own cap. max_nodes: usize, + /// Mains the `.fig` file has but its layer cap kept out ([`FIG_CAPPED_MAINS_FIELD`]). + capped_mains: BTreeSet, } impl<'a> Ctx<'a> { @@ -432,6 +442,7 @@ impl<'a> Ctx<'a> { var_modes: BTreeMap::new(), networks: Vec::new(), max_nodes: MAX_NODES, + capped_mains: BTreeSet::new(), } } @@ -998,7 +1009,11 @@ impl<'a> Ctx<'a> { fn resolve_swap_defaults(&mut self) { for (owner, name, rest_id) in std::mem::take(&mut self.swap_defaults) { let Some(c) = self.component(&rest_id) else { - self.warn("instance swap properties whose default component isn't in the file were skipped"); + if self.capped_mains.contains(&rest_id) { + self.warn("instance swap properties whose default component was past the layer limit were skipped"); + } else { + self.warn("instance swap properties whose default component isn't in the file were skipped"); + } continue; }; let def = PropDef::InstanceSwap { default: c, preferred: Vec::new() }; @@ -1017,7 +1032,11 @@ impl<'a> Ctx<'a> { let frames: BTreeSet = self.instances.iter().map(|(i, _)| *i).collect(); let mut links: Vec<(NodeId, NodeId, &'a Value)> = Vec::new(); for (inst, j) in std::mem::take(&mut self.instances) { - match text(j, "componentId").and_then(|r| self.component(r)) { + let main = text(j, "componentId"); + match main.and_then(|r| self.component(r)) { + None if main.is_some_and(|r| self.capped_mains.contains(r)) => { + self.warn("instances whose component was past the layer limit were imported as frames"); + } None => self.warn("instances of components that aren't in the file were imported as frames"), Some(c) if self.doc.is_ancestor(c, inst) => self.warn("instances inside their own main component were imported as frames"), Some(c) => links.push((inst, c, j)), diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index ae566fc8..32045d4e 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1595,10 +1595,12 @@ name = "English" "image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "image filters are ambiguous because both filter containers are present; filter conversion was skipped" "image filters use FigCraft's approximate renderer" = "image filters use FigCraft's approximate renderer" "instance swap properties whose default component isn't in the file were skipped" = "instance swap properties whose default component isn't in the file were skipped" +"instance swap properties whose default component was past the layer limit were skipped" = "instance swap properties whose default component was past the layer limit were skipped" "instances inside their own main component were imported as frames" = "instances inside their own main component were imported as frames" "instances of components that aren't in the file show no layers" = "instances of components that aren't in the file show no layers" "instances of components that aren't in the file were imported as frames" = "instances of components that aren't in the file were imported as frames" "instances that would nest a component inside itself were imported as frames" = "instances that would nest a component inside itself were imported as frames" +"instances whose component was past the layer limit were imported as frames" = "instances whose component was past the layer limit were imported as frames" "instances without a main component were imported as empty frames" = "instances without a main component were imported as empty frames" "invalid internal .fig text path data was ignored" = "invalid internal .fig text path data was ignored" "invalid or oversized vector networks import as outlines" = "invalid or oversized vector networks import as outlines" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 596feda6..c6e77866 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1596,10 +1596,12 @@ status = "draft — needs native review" "image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "los filtros de imagen son ambiguos porque están los dos contenedores de filtros; se omitió la conversión de filtros" "image filters use FigCraft's approximate renderer" = "los filtros de imagen usan el renderizado aproximado de FigCraft" "instance swap properties whose default component isn't in the file were skipped" = "se omitieron las propiedades de intercambio de instancia cuyo componente predeterminado no está en el archivo" +"instance swap properties whose default component was past the layer limit were skipped" = "se omitieron las propiedades de intercambio de instancia cuyo componente predeterminado quedó fuera del límite de capas" "instances inside their own main component were imported as frames" = "las instancias dentro de su propio componente principal se importaron como marcos" "instances of components that aren't in the file show no layers" = "las instancias de componentes que no están en el archivo no muestran capas" "instances of components that aren't in the file were imported as frames" = "las instancias de componentes que no están en el archivo se importaron como marcos" "instances that would nest a component inside itself were imported as frames" = "las instancias que anidarían un componente dentro de sí mismo se importaron como marcos" +"instances whose component was past the layer limit were imported as frames" = "las instancias cuyo componente quedó fuera del límite de capas se importaron como marcos" "instances without a main component were imported as empty frames" = "las instancias sin componente principal se importaron como marcos vacíos" "invalid internal .fig text path data was ignored" = "se ignoraron datos internos no válidos de texto en trazado del .fig" "invalid or oversized vector networks import as outlines" = "las redes vectoriales no válidas o demasiado grandes se importan como contornos" From 5e2957a9011d71ff8fc3469830eb28ca17a2d3ad Mon Sep 17 00:00:00 2001 From: Ridzzz0Alam Date: Wed, 21 Oct 2026 19:15:58 +0200 Subject: [PATCH 04/54] M1: Save, Save as and Export arrive as downloads in the browser (#969) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cause: in the web build no file has a path, so Save goes to Save as, and Save as and every Export go through Services::pick_save / pick_folder. WebServices keeps the trait's defaults there, which return no path, so the callback never ran: no download, no message. Only Variables ▸ Export used the platform's downloads (#368). Fix: where services.downloads() is true, those routes run their command without a path and hand the bytes to services.download(): - engine: file.saveAs takes `data: true` and returns the .figcraft file as base64 `data` with its `name`, marking it saved (no path, nothing remembered). Programmatic Save as without either still asks for a path. - ui: FigApp::download decodes a result's `data` and says when nothing arrived; Save as, one-off export (PNG/JPG/WebP/SVG/PDF), .fig export, frames to PDF and multi-layer export (one download per file) use it. Tests: engine save_as_data_hands_back_the_file_and_marks_it_saved; ui-egui web_save_tests (save, export, refused download) with the fake browser Services from variables_export_ui_tests. The UI tests fail without the fix. --- crates/engine/src/cmd/file.rs | 58 +++++++++++++++---- crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/src/dialogs.rs | 15 ++++- crates/ui-egui/src/export_panel.rs | 17 ++++++ crates/ui-egui/src/lib.rs | 22 +++++++ crates/ui-egui/src/menus.rs | 9 +++ .../ui-egui/src/variables_export_ui_tests.rs | 8 +-- crates/ui-egui/src/web_save_tests.rs | 55 ++++++++++++++++++ 9 files changed, 169 insertions(+), 17 deletions(-) create mode 100644 crates/ui-egui/src/web_save_tests.rs diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 7380711f..8a75efb7 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -51,7 +51,7 @@ pub fn specs() -> Vec { spec!( "file.saveAs", "Save as", - "{path} (no extension adds .figcraft); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview", + "{path | data: true (no path: returns the file as base64 `data` with its `name`, for a download)} (no extension adds .figcraft); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview", false, save_as ), @@ -843,37 +843,53 @@ fn color_profile(s: &mut Session, p: &Value) -> Result { pub(crate) fn save_ends_preview(s: &Session, cmd: &str, p: &Value) -> bool { let path = string(p, "path").or_else(|| if cmd == "file.save" { s.docs.get(crate::branches::file_tab(s)).and_then(|ds| ds.path.clone()) } else { None }); - path.is_some_and(|path| ext(&path) != "fig") + path.is_some_and(|path| ext(&path) != "fig") || (cmd == "file.saveAs" && wants_data(p)) +} + +/// `file.saveAs` asked for the file's bytes instead of a path to write (the browser's download). +fn wants_data(p: &Value) -> bool { + p.get("data").and_then(Value::as_bool) == Some(true) } /// Save the file of the active tab (the main file for a branch tab, with its branches), or export /// the active document as an experimental `.fig` copy without changing its native save state. -fn save_to(s: &mut Session, path: String) -> Result { - if ext(&path) == "fig" { +/// Without a path the file comes back as base64 `data` for the caller to deliver (#969). +fn save_to(s: &mut Session, path: Option) -> Result { + if let Some(path) = path.as_deref().filter(|p| ext(p) == "fig") { return export_fig(s, &json!({"path": path})); } let i = crate::branches::file_tab(s); let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?; let mut snapshot = FileSnapshot::of(ds); let follows_path = ds.path.as_deref().map_or(ds.doc.name == "Untitled", |old| ds.doc.name == file_stem(old)); - if follows_path && ds.path.as_deref() != Some(path.as_str()) { - Arc::make_mut(&mut snapshot.doc).name = file_stem(&path); + if let Some(path) = path.as_deref() + && follows_path + && ds.path.as_deref() != Some(path) + { + Arc::make_mut(&mut snapshot.doc).name = file_stem(path); } let bytes = snapshot.encode()?; - write(&path, &bytes)?; + if let Some(path) = &path { + write(path, &bytes)?; + } ds.doc = snapshot.doc; - ds.path = Some(path.clone()); + if path.is_some() { + ds.path.clone_from(&path); + } ds.mark_saved(); ds.revision += 1; - let uid = ds.uid; + let (uid, name) = (ds.uid, format!("{}.figcraft", ds.doc.name)); branch_tabs_saved(s, uid); + let Some(path) = path else { + return Ok(json!({"name": name, "bytes": bytes.len(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})); + }; remember(s, &path); Ok(json!({"path": path, "bytes": bytes.len()})) } fn save(s: &mut Session, p: &Value) -> Result { if let Some(path) = string(p, "path") { - return save_to(s, path); + return save_to(s, Some(path)); } match s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?.path.clone() { // Earlier builds wrote native data under a .fig name: never overwrite that file with an export. @@ -881,7 +897,7 @@ fn save(s: &mut Session, p: &Value) -> Result { s.request_ui("dialog.saveAs", json!({})); Err(EngineError::Disabled("file.save".into(), "the saved path ends in .fig: choose Save as or pass `path`".into())) } - Some(path) => save_to(s, path), + Some(path) => save_to(s, Some(path)), None => { s.request_ui("dialog.saveAs", json!({})); Err(EngineError::Disabled("file.save".into(), "the file has no path yet: pass `path`".into())) @@ -895,8 +911,9 @@ fn save_as(s: &mut Session, p: &Value) -> Result { if ext(&path).is_empty() { path.push_str(".figcraft"); } - save_to(s, path) + save_to(s, Some(path)) } + None if wants_data(p) => save_to(s, None), None => { s.request_ui("dialog.saveAs", json!({})); Err(EngineError::Disabled("file.saveAs".into(), "pass `path`".into())) @@ -1563,6 +1580,23 @@ mod rest_import_tests { assert_eq!(s.docs.len(), open, "a refused open adds nothing"); } + #[test] + fn save_as_data_hands_back_the_file_and_marks_it_saved() { + // The browser has no paths: Save as returns the file for a download (#969). + let mut s = Session::new(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + assert!(s.execute("file.saveAs", &json!({})).is_err(), "no path and no data: still asks for a path"); + let recent = s.prefs.recent_files.clone(); + let r = s.execute("file.saveAs", &json!({"data": true})).unwrap(); + assert_eq!(r["name"], "Untitled.figcraft"); + assert!(!s.ds().unwrap().is_dirty()); + assert_eq!(s.ds().unwrap().path, None, "still no path to save over"); + assert_eq!(s.prefs.recent_files, recent, "nothing on disk to remember"); + let mut t = Session::new(); + t.execute("file.open", &json!({"data": r["data"], "name": r["name"]})).unwrap(); + assert!(t.doc().unwrap().nodes().any(|n| matches!(n.kind, NodeKind::Rectangle)), "the bytes reopen"); + } + #[test] fn open_and_import_rest_json() { let dir = std::env::temp_dir().join(format!("figcraft-rest-{}", std::process::id())); diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index ae566fc8..3ce0659d 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -319,6 +319,7 @@ name = "English" "Corner radius" = "Corner radius" "Corner smoothing" = "Corner smoothing" "Couldn't download the variables" = "Couldn't download the variables" +"Couldn't download {name}" = "Couldn't download {name}" "Couldn't reach {server}: showing what this computer remembers." = "Couldn't reach {server}: showing what this computer remembers." "Couldn't read {name}." = "Couldn't read {name}." "Couldn't save automatically: {error}" = "Couldn't save automatically: {error}" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 596feda6..bd572dbe 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -10,6 +10,7 @@ status = "draft — needs native review" [ui] "Compatibility notes" = "Notas de compatibilidad" "Couldn't download the variables" = "No se pudieron descargar las variables" +"Couldn't download {name}" = "No se pudo descargar {name}" "% of all commits" = "% del total de commits" "(no longer published)" = "(ya no publicado)" diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index bf9f1b29..9695faee 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -665,7 +665,11 @@ pub(crate) fn file_saved(app: &mut FigApp, result: &Value) { } fn result_file_name(result: &Value) -> String { - result["path"].as_str().and_then(|p| Path::new(p).file_name()).map(|f| f.to_string_lossy().into_owned()).unwrap_or_default() + match result["path"].as_str() { + Some(p) => Path::new(p).file_name().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default(), + // A download (#969). + None => result["name"].as_str().unwrap_or_default().to_owned(), + } } /// The `.fig` result stays an export: native save state is unchanged, and omissions are reported. @@ -681,6 +685,15 @@ pub(crate) fn fig_exported(app: &mut FigApp, result: &Value) { /// Save the whole file as `.fig`; the toast says how much was left out. fn save_fig(app: &mut FigApp, name: &str) { let filename = format!("{name}.fig"); + if app.services.downloads() { + if let Some(mut r) = app.run("file.export", json!({"format": "fig"})) + && app.download(&filename, &r) + { + r["name"] = json!(filename); + fig_exported(app, &r); + } + return; + } app.pick_save(tr("Export"), &filename, &["fig"], |app, p| { if let Some(r) = app.run("file.export", json!({"format": "fig", "path": p.to_string_lossy()})) { fig_exported(app, &r); diff --git a/crates/ui-egui/src/export_panel.rs b/crates/ui-egui/src/export_panel.rs index 8617c35a..6a4ca8ee 100644 --- a/crates/ui-egui/src/export_panel.rs +++ b/crates/ui-egui/src/export_panel.rs @@ -320,6 +320,15 @@ pub(crate) fn export_layers(app: &mut FigApp, ids: &[NodeId], assets: bool) { [] => {} // The suggested name keeps only the file's own name (a layer's folders apply to folders). [one] => save_one(app, &[one.id], &one.setting, one.file.rsplit('/').next().unwrap_or(&one.file)), + // No folders in the browser: each file arrives as its own download (#969). + _ if app.services.downloads() => { + let Some(r) = app.run("file.exportLayers", json!({"ids": ids, "assets": assets})) else { return }; + let files = r["files"].as_array().map_or(&[][..], Vec::as_slice); + let n = files.iter().filter(|f| app.download(f["name"].as_str().unwrap_or_default(), f)).count(); + if n > 0 { + app.toast(trn("Exported {n} files", n, &[])); + } + } _ => { let ids = ids.to_vec(); app.pick_folder(tr("Export"), move |app, dir| { @@ -337,6 +346,14 @@ pub(crate) fn save_one(app: &mut FigApp, ids: &[NodeId], setting: &ExportSetting let ext = setting.format.ext(); let mut params = setting_params(setting); params["ids"] = json!(ids); + if app.services.downloads() { + if let Some(r) = app.run("file.export", params) + && app.download(file, &r) + { + app.toast(trf("Exported {name}", &[("name", &file)])); + } + return; + } app.pick_save(tr("Export"), file, &[ext], move |app, p| { params["path"] = json!(p.to_string_lossy()); if app.run("file.export", params).is_some() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c93333b3..788bbf4f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -223,6 +223,8 @@ mod vector_tail_ui_tests; #[cfg(test)] mod web_clipboard_tests; #[cfg(test)] +mod web_save_tests; +#[cfg(test)] mod windows_qa_tests; #[cfg(test)] mod windows_tests; @@ -1140,6 +1142,14 @@ impl FigApp { } pub fn save_as_dialog(&mut self) { + if self.services.downloads() { + if let Some(r) = self.run("file.saveAs", json!({"data": true})) + && self.download(r["name"].as_str().unwrap_or_default(), &r) + { + dialogs::file_saved(self, &r); + } + return; + } let Ok(name) = self.session.doc().map(|d| d.name.clone()) else { return }; self.pick_save(i18n::tr("Save as"), &format!("{name}.figcraft"), &["figcraft", "fig"], |app, mut p| { if p.extension().is_none_or(|e| e.is_empty()) { @@ -1244,6 +1254,18 @@ impl FigApp { self.start_dialog(|services| services.pick_save(title, name, exts), true, done); } + /// Hand a command result's base64 `data` over as a download named `name`, where the platform + /// saves by downloads rather than save dialogs (the browser, #969). Says so when nothing arrived. + pub(crate) fn download(&mut self, name: &str, result: &Value) -> bool { + use base64::Engine as _; + let bytes = result["data"].as_str().and_then(|d| base64::engine::general_purpose::STANDARD.decode(d).ok()); + if bytes.is_some_and(|b| self.services.download(name, &b)) { + return true; + } + self.toast(i18n::trf("Couldn't download {name}", &[("name", &name)])); + false + } + /// Pick a folder; `done` runs as [`FigApp::pick_open`]'s does. pub fn pick_folder(&mut self, title: &str, done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { self.start_dialog(|services| services.pick_folder(title), true, done); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9659c6b3..afa34873 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -248,6 +248,15 @@ pub fn invoke(app: &mut FigApp, id: &str) { /// Every visible top-level frame of the page as one PDF page each, through a save dialog. fn export_frames_pdf(app: &mut FigApp) { let name = app.session.doc().map(|d| d.name.clone()).unwrap_or_else(|_| "Untitled".into()); + if app.services.downloads() { + let file = format!("{name}.pdf"); + if let Some(r) = app.run("file.exportFramesPdf", json!({})) + && app.download(&file, &r) + { + app.toast(trf("Exported {name}", &[("name", &file)])); + } + return; + } app.pick_save(tr("Export"), &format!("{name}.pdf"), &["pdf"], |app, p| { if app.run("file.exportFramesPdf", json!({"path": p.to_string_lossy()})).is_some() { app.toast(trf("Exported {name}", &[("name", &p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())])); diff --git a/crates/ui-egui/src/variables_export_ui_tests.rs b/crates/ui-egui/src/variables_export_ui_tests.rs index 40a84393..ce51a096 100644 --- a/crates/ui-egui/src/variables_export_ui_tests.rs +++ b/crates/ui-egui/src/variables_export_ui_tests.rs @@ -9,12 +9,12 @@ use serde_json::{Value, json}; use crate::variables_panel::export_dialog; use crate::{FigApp, NoServices, Services}; -type Downloads = Arc)>>>; +pub(crate) type Downloads = Arc)>>>; /// Services that save by downloads and keep them (`deliver` false: the browser refused). -struct Browser { - got: Downloads, - deliver: bool, +pub(crate) struct Browser { + pub(crate) got: Downloads, + pub(crate) deliver: bool, } impl Services for Browser { diff --git a/crates/ui-egui/src/web_save_tests.rs b/crates/ui-egui/src/web_save_tests.rs new file mode 100644 index 00000000..7d1d75d2 --- /dev/null +++ b/crates/ui-egui/src/web_save_tests.rs @@ -0,0 +1,55 @@ +//! Save, Save as and Export where the platform saves by downloads (the browser, which has no save +//! dialogs or paths): the file arrives as a download instead of nothing happening (#969). + +use base64::Engine as _; +use figcraft_doc::{ExportSetting, NodeId}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::FigApp; +use crate::variables_export_ui_tests::{Browser, Downloads}; + +fn browser(deliver: bool) -> (FigApp, Downloads, NodeId) { + let mut s = Session::new(); + let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let id = serde_json::from_value(r["id"].clone()).unwrap(); + let got = Downloads::default(); + (FigApp::new(s, Box::new(Browser { got: got.clone(), deliver })), got, id) +} + +fn toast(app: &FigApp) -> String { + app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default() +} + +#[test] +fn save_downloads_the_figcraft_file() { + let (mut app, got, _) = browser(true); + app.save(); + let got = got.lock().unwrap(); + let [(name, bytes)] = got.as_slice() else { panic!("one download, got {}", got.len()) }; + assert_eq!(name, "Untitled.figcraft"); + let mut reopened = Session::new(); + let data = base64::engine::general_purpose::STANDARD.encode(bytes); + reopened.execute("file.open", &json!({"data": data, "name": name})).unwrap(); + assert!(!app.session.ds().unwrap().is_dirty()); + assert_eq!(toast(&app), "Saved Untitled.figcraft"); +} + +#[test] +fn export_downloads_the_exported_file() { + let (mut app, got, id) = browser(true); + crate::export_panel::save_one(&mut app, &[id], &ExportSetting::default(), "Rectangle.png"); + let got = got.lock().unwrap(); + let [(name, bytes)] = got.as_slice() else { panic!("one download, got {}", got.len()) }; + assert_eq!(name, "Rectangle.png"); + assert!(bytes.starts_with(b"\x89PNG")); + assert_eq!(toast(&app), "Exported Rectangle.png"); +} + +#[test] +fn a_refused_download_says_so() { + let (mut app, got, _) = browser(false); + app.save_as_dialog(); + assert_eq!(got.lock().unwrap().len(), 1); + assert_eq!(toast(&app), "Couldn't download Untitled.figcraft"); +} From 9315e5e014705e5866fa1d74fa8e9b2075a00b1c Mon Sep 17 00:00:00 2001 From: Kameron Date: Wed, 21 Oct 2026 11:19:47 -0700 Subject: [PATCH 05/54] command.batch: named steps ($ref) and slash paths in field A batch step can name itself with "as" and later steps refer to it with {"$ref": "name"}, alongside positional {"$step": n}. "field" now takes a slash path into the result as well as a key, so {"$ref": "copy", "field": "ids/0"} reaches the first id edit.duplicate returned (the convention variable.bind uses for fills/0/color; a leading "/" as in JSON Pointer also works, and a key holding "/" still matches first). Resolution moves to crates/engine/src/batch.rs (BatchStep, BatchScope), shared by command.batch, the CLI script runner, the control channel and the MCP batch tool, which now passes "as" through. Duplicate names fail before any step runs; unknown, same-step or later names, bad paths and out-of-range indexes fail the batch with the step, name and full path. $ref is reserved like $step; nested batches keep their own names. Fixes #994. Co-Authored-By: Claude Opus 5.5 --- apps/figcraft-cli/src/files.rs | 65 ++++++-- crates/engine/src/batch.rs | 192 ++++++++++++++++++++++ crates/engine/src/batch_step_ref_tests.rs | 148 +++++++++++++++++ crates/engine/src/cmd/edit.rs | 9 +- crates/engine/src/collab_live_tests.rs | 6 +- crates/engine/src/lib.rs | 79 +++------ crates/engine/src/tests.rs | 5 + crates/mcp/src/tools.rs | 10 +- crates/mcp/tests/agent_tasks.rs | 29 ++++ docs/cli.md | 14 +- docs/control-protocol.md | 14 +- docs/mcp.md | 52 ++++-- 12 files changed, 516 insertions(+), 107 deletions(-) create mode 100644 crates/engine/src/batch.rs diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 46215599..d18e071a 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -4,7 +4,7 @@ use std::path::Path; use std::process::ExitCode; use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings}; -use figcraft_engine::Session; +use figcraft_engine::{BatchScope, BatchStep, Session}; use serde_json::{Value, json}; use crate::{CliError, CliResult}; @@ -309,7 +309,7 @@ pub fn render(file: &str, out: &str, scale: f64, page: Option<&str>) -> CliResul } /// Parse a script: a JSON array of steps, `{"commands": [...]}`, or JSON lines. -pub fn parse_script(text: &str) -> Result, String> { +pub fn parse_script(text: &str) -> Result, String> { let steps: Vec = match serde_json::from_str::(text) { Ok(Value::Array(a)) => a, Ok(Value::Object(o)) if o.contains_key("commands") => { @@ -334,7 +334,8 @@ pub fn parse_script(text: &str) -> Result, String> { Some(p @ Value::Object(_)) => p.clone(), Some(_) => return Err(format!("step {i} ({cmd}): `params` must be an object")), }; - Ok((cmd.to_string(), params)) + let name = figcraft_engine::batch::step_name(i, &s).map_err(|e| e.to_string())?; + Ok(BatchStep { name, ..BatchStep::new(cmd, params) }) }) .collect() } @@ -352,31 +353,30 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe None => Session::new(), }; // One undo step and all-or-nothing, the way the same commands behave through `command.batch` - // (MCP, the control channel, plugins): `{"$step": n}` resolves against the steps before it, and - // a step that fails takes the whole script back instead of leaving the earlier steps applied. - // The result lines are held back until the script is through, so none of them reports work that - // was rolled back. - let mut results: Vec = Vec::with_capacity(steps.len()); + // (MCP, the control channel, plugins): `{"$step": n}` and `{"$ref": "name"}` resolve against the + // steps before it, and a step that fails takes the whole script back instead of leaving the + // earlier steps applied. The result lines are held back until the script is through, so none of + // them reports work that was rolled back. + let mut scope = BatchScope::new(&steps).map_err(|e| CliError::Failed(format!("{script}: {e}")))?; let mut failed_at = 0; let outcome = s.grouped(&format!("run {script}"), |s| { - for (i, (cmd, params)) in steps.iter().enumerate() { + for (i, step) in steps.iter().enumerate() { failed_at = i; - let v = s.batch_step(&results, i, cmd, params)?; + s.batch_step(&mut scope, &step.command, &step.params)?; s.ui_requests.clear(); - results.push(v); } Ok(()) }); if let Err(e) = outcome { - let cmd = steps.get(failed_at).map_or("", |(cmd, _)| cmd.as_str()); + let cmd = steps.get(failed_at).map_or("", |step| step.command.as_str()); let i = failed_at; println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string(), "rolledBack": true})); eprintln!("error: step {i} ({cmd}) failed: {e}"); eprintln!("the script's document changes were rolled back"); return Ok(ExitCode::from(1)); } - for (i, ((cmd, _), v)) in steps.iter().zip(&results).enumerate() { - println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v})); + for (i, (step, v)) in steps.iter().zip(scope.results()).enumerate() { + println!("{}", json!({"step": i, "command": step.command, "ok": true, "result": v})); } if let Some(out) = out { let ds = s.ds().map_err(|e| CliError::Failed(e.to_string()))?; @@ -494,6 +494,43 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } + /// A script names its steps with `as` and reads paths into their results, like `command.batch`. + #[test] + fn a_script_refers_to_named_steps_and_paths() { + let dir = temp_dir("run-ref"); + let script = dir.join("script.json"); + std::fs::write( + &script, + r#"[ + {"command": "node.create", "as": "card", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}}, + {"command": "edit.duplicate", "as": "copy", "params": {"ids": [{"$ref": "card"}]}}, + {"command": "node.set", "params": {"ids": [{"$ref": "copy", "field": "ids/0"}], "props": {"name": "Copy"}}} + ]"#, + ) + .unwrap(); + let out = dir.join("out.figcraft"); + run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap(); + + let mut s = Session::empty(); + s.execute("file.open", &json!({"path": out.to_str().unwrap()})).unwrap(); + let doc = s.doc().unwrap(); + let top = doc.node(s.page().unwrap()).unwrap().children.clone(); + let names: Vec<_> = top.iter().map(|id| doc.node(*id).unwrap().name.clone()).collect(); + assert_eq!(names, ["Card", "Copy"]); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// Two steps with one name fail before anything runs. + #[test] + fn a_script_with_a_name_twice_runs_nothing() { + let dir = temp_dir("run-dup-name"); + let script = dir.join("script.json"); + std::fs::write(&script, r#"[{"command": "select.none", "as": "a"}, {"command": "select.none", "as": "a"}]"#).unwrap(); + let Err(CliError::Failed(e)) = run_script(script.to_str().unwrap(), None, None) else { panic!("a name twice must fail") }; + assert!(e.contains("step 1: `as` \"a\" is already step 0's name"), "{e}"); + std::fs::remove_dir_all(dir).unwrap(); + } + /// A step that fails takes the whole script back: nothing is written, and the failure says so. #[test] fn a_failed_step_leaves_no_output() { diff --git a/crates/engine/src/batch.rs b/crates/engine/src/batch.rs new file mode 100644 index 00000000..5c4550ec --- /dev/null +++ b/crates/engine/src/batch.rs @@ -0,0 +1,192 @@ +//! Step references in `command.batch`: a step's params can use what an earlier step returned. +//! +//! `{"$step": n}` stands for the `id` in the result of step `n` (zero-based), and `{"$ref": "name"}` +//! for the `id` in the result of the step that called itself `name` with `"as": "name"`. Either +//! takes a `field`, a key of that result or a slash path into it (`ids/0`, `map/3`, `bounds/x`), the +//! way `variable.bind` names `fills/0/color`. The CLI's script runner resolves its steps the same way. + +use std::collections::HashMap; + +use serde_json::{Map, Value}; + +use crate::{Result, bad}; + +/// Deepest params nesting the resolver walks before giving up. +const MAX_DEPTH: usize = 64; + +/// One step of a batch: a command, its params and the name later steps can call it by. +#[derive(Clone, Debug, PartialEq)] +pub struct BatchStep { + pub command: String, + pub params: Value, + /// The step's `as`, for `{"$ref": name}` in later steps. + pub name: Option, +} + +impl BatchStep { + /// A step without a name. + pub fn new(command: impl Into, params: Value) -> Self { + Self { command: command.into(), params, name: None } + } +} + +/// A step's `as` (`step` is its index, for the error): `None` when it has none. +pub fn step_name(step: usize, json: &Value) -> Result> { + match json.get("as") { + None | Some(Value::Null) => Ok(None), + Some(Value::String(name)) if !name.is_empty() => Ok(Some(name.clone())), + Some(_) => Err(bad("command.batch", format!("step {step}: `as` must be a name (a non-empty string)"))), + } +} + +/// The steps of one batch run so far: their results, and which step each `as` name belongs to. +/// A nested `command.batch` step gets its own scope, so its references are to its own steps. +#[derive(Clone, Debug, Default)] +pub struct BatchScope { + names: HashMap, + results: Vec, +} + +impl BatchScope { + /// The scope for running `steps`. Two steps with the same name fail here, before any step runs. + pub fn new(steps: &[BatchStep]) -> Result { + let mut names = HashMap::new(); + for (i, step) in steps.iter().enumerate() { + let Some(name) = &step.name else { continue }; + if let Some(first) = names.insert(name.clone(), i) { + return Err(bad("command.batch", format!("step {i}: `as` \"{name}\" is already step {first}'s name"))); + } + } + Ok(Self { names, results: Vec::with_capacity(steps.len()) }) + } + + /// The results of the steps run so far, in order. + pub fn results(&self) -> &[Value] { + &self.results + } + + pub fn into_results(self) -> Vec { + self.results + } + + /// The index of the step that runs next. + pub fn next_step(&self) -> usize { + self.results.len() + } + + pub(crate) fn push(&mut self, result: Value) { + self.results.push(result); + } + + /// `params` of the next step with every reference in it replaced. + pub(crate) fn resolve(&self, params: &Value) -> Result { + self.resolve_in(params, 0) + } + + fn resolve_in(&self, v: &Value, depth: usize) -> Result { + let step = self.next_step(); + if depth > MAX_DEPTH { + return Err(bad("command.batch", format!("step {step}: params nest deeper than {MAX_DEPTH} levels"))); + } + match v { + Value::Object(o) if o.contains_key("$step") || o.contains_key("$ref") => self.reference(o), + Value::Object(o) => { + let mut out = Map::with_capacity(o.len()); + for (k, x) in o { + out.insert(k.clone(), self.resolve_in(x, depth + 1)?); + } + Ok(Value::Object(out)) + } + Value::Array(a) => a.iter().map(|x| self.resolve_in(x, depth + 1)).collect::>>().map(Value::Array), + _ => Ok(v.clone()), + } + } + + /// The value a `{"$step": n}` or `{"$ref": "name"}` object (with an optional `field`) stands for. + fn reference(&self, o: &Map) -> Result { + let step = self.next_step(); + let fail = |msg: String| bad("command.batch", format!("step {step}: {msg}")); + let key = if o.contains_key("$step") { "$step" } else { "$ref" }; + if let Some(k) = o.keys().find(|k| *k != key && *k != "field") { + return Err(fail(format!("a `{key}` reference takes only `field`, not `{k}`"))); + } + let (n, who) = if key == "$step" { + let n = o.get("$step").and_then(Value::as_u64).ok_or_else(|| fail("`$step` must be a step index (0, 1, …)".into()))?; + let n = usize::try_from(n).ok().filter(|n| *n < step).ok_or_else(|| { + let range = if step == 0 { "there is none before step 0".to_string() } else { format!("0 to {}", step - 1) }; + fail(format!("`$step` {n} must be an earlier step ({range})")) + })?; + (n, format!("step {n}")) + } else { + let name = o.get("$ref").and_then(Value::as_str).ok_or_else(|| fail("`$ref` must be a step name (a string)".into()))?; + let n = *self.names.get(name).ok_or_else(|| fail(format!("`$ref` \"{name}\" names no step ({})", self.known_names())))?; + if n >= step { + return Err(fail(format!("`$ref` \"{name}\" is step {n}, not an earlier step"))); + } + (n, format!("step {n} (\"{name}\")")) + }; + let path = match o.get("field") { + None => "id", + Some(Value::String(f)) => f.as_str(), + Some(_) => return Err(fail("`field` must be a string".into())), + }; + let result = self.results.get(n).ok_or_else(|| fail(format!("{who} has no result")))?; + lookup(result, path).cloned().map_err(|why| fail(format!("{who}'s result has no `{path}`{why}"))) + } + + fn known_names(&self) -> String { + let mut names: Vec<_> = self.names.iter().collect(); + names.sort_by_key(|(_, i)| **i); + if names.is_empty() { + return "no step has an `as` name".into(); + } + let list: Vec<_> = names.iter().map(|(name, i)| format!("\"{name}\" is step {i}")).collect(); + list.join(", ") + } +} + +/// `path` in `v`: a key as written, else a slash path (`ids/0`, `bounds/x`; a leading `/` as in +/// JSON Pointer is allowed). The error is the detail after "has no `path`". +fn lookup<'a>(v: &'a Value, path: &str) -> std::result::Result<&'a Value, String> { + // A key as written first, so a key holding `/` still works as it did before paths. + if let Some(x) = v.get(path) { + return Ok(x); + } + let hint = if path.contains('.') && !path.contains('/') { " (paths use `/`, as in `ids/0`)" } else { "" }; + walk(v, path.strip_prefix('/').unwrap_or(path)).map_err(|why| format!("{why}{hint}")) +} + +fn walk<'a>(v: &'a Value, path: &str) -> std::result::Result<&'a Value, String> { + let mut cur = v; + let mut walked = String::new(); + for seg in path.split('/') { + let here = if walked.is_empty() { "the result".to_string() } else { format!("`{walked}`") }; + cur = match cur { + Value::Object(o) => o.get(seg).ok_or_else(|| if walked.is_empty() { String::new() } else { format!(": {here} has no `{seg}`") })?, + Value::Array(a) => match seg.parse::() { + Ok(i) => a.get(i).ok_or_else(|| { + let items = if a.len() == 1 { "1 item".to_string() } else { format!("{} items", a.len()) }; + format!(": {here} has {items}") + })?, + Err(_) => return Err(format!(": {here} is a list, so `{seg}` must be an index (0, 1, …)")), + }, + _ => return Err(format!(": {here} is {}", kind(cur))), + }; + if !walked.is_empty() { + walked.push('/'); + } + walked.push_str(seg); + } + Ok(cur) +} + +fn kind(v: &Value) -> &'static str { + match v { + Value::Null => "null", + Value::Bool(_) => "a boolean", + Value::Number(_) => "a number", + Value::String(_) => "a string", + Value::Array(_) => "a list", + Value::Object(_) => "an object", + } +} diff --git a/crates/engine/src/batch_step_ref_tests.rs b/crates/engine/src/batch_step_ref_tests.rs index ac69118e..451a6300 100644 --- a/crates/engine/src/batch_step_ref_tests.rs +++ b/crates/engine/src/batch_step_ref_tests.rs @@ -110,3 +110,151 @@ fn a_nested_batch_refers_to_its_own_steps() { let rect = s.doc().unwrap().node(id(&inner[1])).unwrap().clone(); assert_eq!(rect.parent, Some(id(&inner[0])), "in the nested batch's own first frame, not the outer one"); } + +/// Runs `commands` as a batch and returns the error message, which must be `BadParams`. +fn batch_error(s: &mut Session, commands: serde_json::Value) -> String { + match s.execute("command.batch", &json!({"commands": commands})) { + Err(EngineError::BadParams { msg, .. }) => msg, + other => panic!("expected BadParams, got {other:?}"), + } +} + +#[test] +fn a_named_step_is_referred_to_by_its_name() { + let mut s = Session::new(); + let results = s + .execute( + "command.batch", + &json!({"commands": [ + {"command": "node.create", "as": "card", "params": {"type": "frame", "width": 200, "height": 100}}, + {"command": "node.create", "as": "swatch", "params": {"type": "rectangle", "parent": {"$ref": "card"}, "width": 20, "height": 20}}, + {"command": "node.set", "params": {"ids": [{"$ref": "swatch", "field": "id"}], "props": {"name": "Swatch"}}}, + {"command": "node.create", "params": {"type": "text", "text": "hi", "parent": {"$step": 0}}} + ]}), + ) + .unwrap(); + let (frame, rect, text) = (id(&results[0]), id(&results[1]), id(&results[3])); + let doc = s.doc().unwrap(); + assert_eq!(doc.node(rect).unwrap().parent, Some(frame)); + assert_eq!(doc.node(rect).unwrap().name, "Swatch"); + assert_eq!(doc.node(text).unwrap().parent, Some(frame), "`$step` and `$ref` mix in one batch"); + assert_eq!(s.ds().unwrap().history.undo.len(), 1, "still one undo step"); +} + +/// `edit.duplicate` answers `{"ids": [...]}`: a slash path in `field` takes one of them. +#[test] +fn a_field_path_indexes_into_a_list_in_the_result() { + let mut s = Session::new(); + let results = s + .execute( + "command.batch", + &json!({"commands": [ + {"command": "node.create", "as": "a", "params": {"type": "frame", "name": "A", "width": 10, "height": 10}}, + {"command": "node.create", "as": "b", "params": {"type": "frame", "name": "B", "width": 10, "height": 10}}, + {"command": "edit.duplicate", "as": "copies", "params": {"ids": [{"$ref": "a"}, {"$ref": "b"}]}}, + {"command": "node.set", "params": {"ids": [{"$ref": "copies", "field": "ids/1"}], "props": {"name": "B copy"}}}, + {"command": "node.set", "params": {"ids": [{"$step": 2, "field": "/ids/0"}], "props": {"name": "A copy"}}} + ]}), + ) + .unwrap(); + let copies = results[2]["ids"].as_array().unwrap().clone(); + let doc = s.doc().unwrap(); + assert_eq!(doc.node(crate::params::id_of(&copies[0]).unwrap()).unwrap().name, "A copy", "a leading `/` works too"); + assert_eq!(doc.node(crate::params::id_of(&copies[1]).unwrap()).unwrap().name, "B copy"); +} + +#[test] +fn a_field_path_walks_nested_objects_and_takes_whole_values() { + let mut scope = crate::BatchScope::default(); + scope.push(json!({"ids": [7, 8], "bounds": {"x": 1.5, "corners": [{"r": 4}]}, "a/b": "slash key"})); + let resolved = scope + .resolve(&json!({ + "x": {"$step": 0, "field": "bounds/x"}, + "r": {"$step": 0, "field": "bounds/corners/0/r"}, + "all": {"$step": 0, "field": "ids"}, + "key": {"$step": 0, "field": "a/b"}, + "pointer": {"$step": 0, "field": "/ids"} + })) + .unwrap(); + assert_eq!(resolved, json!({"x": 1.5, "r": 4, "all": [7, 8], "key": "slash key", "pointer": [7, 8]}), "a key holding `/` is still read as a key"); +} + +#[test] +fn bad_names_and_paths_fail_and_roll_back() { + let mut s = Session::new(); + let before = s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(); + let frame = |name: &str| json!({"command": "node.create", "as": name, "params": {"type": "frame", "width": 10, "height": 10}}); + let dup = json!({"command": "edit.duplicate", "as": "copy", "params": {"ids": [{"$ref": "card"}]}}); + let set = |r: serde_json::Value| json!({"command": "node.set", "params": {"ids": [r], "props": {"name": "x"}}}); + let later = json!({"command": "node.create", "as": "later", "params": {"type": "frame", "width": 10, "height": 10}}); + for (commands, needle) in [ + (json!([frame("card"), frame("card")]), "step 1: `as` \"card\" is already step 0's name"), + (json!([frame("card"), set(json!({"$ref": "nope"}))]), "step 1: `$ref` \"nope\" names no step (\"card\" is step 0)"), + (json!([{"command": "node.create", "params": {"type": "frame"}}, set(json!({"$ref": "nope"}))]), "no step has an `as` name"), + (json!([frame("card"), set(json!({"$ref": "later"})), later]), "step 1: `$ref` \"later\" is step 2, not an earlier step"), + ( + json!([frame("card"), {"command": "node.set", "as": "self", "params": {"ids": [{"$ref": "self"}], "props": {}}}]), + "is step 1, not an earlier step", + ), + (json!([frame("card"), set(json!({"$ref": 0}))]), "`$ref` must be a step name"), + (json!([frame("card"), set(json!({"$ref": "card", "id": 3}))]), "a `$ref` reference takes only `field`, not `id`"), + (json!([frame("card"), set(json!({"$ref": "card", "$step": 0}))]), "a `$step` reference takes only `field`, not `$ref`"), + (json!([frame("card"), {"command": "node.create", "as": "", "params": {}}]), "step 1: `as` must be a name"), + (json!([frame("card"), {"command": "node.create", "as": 3, "params": {}}]), "step 1: `as` must be a name"), + ( + json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids/1"}))]), + "step 2: step 1 (\"copy\")'s result has no `ids/1`: `ids` has 1 item", + ), + (json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids/first"}))]), "`ids` is a list, so `first` must be an index"), + (json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids/0/x"}))]), "has no `ids/0/x`: `ids/0` is a number"), + (json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "map/0"}))]), "step 1 (\"copy\")'s result has no `map/0`"), + (json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids.0"}))]), "has no `ids.0` (paths use `/`, as in `ids/0`)"), + (json!([frame("card"), set(json!({"$step": 0, "field": "id/0"}))]), "has no `id/0`: `id` is a number"), + ] { + let msg = batch_error(&mut s, commands); + assert!(msg.contains(needle), "{msg:?} should contain {needle:?}"); + assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(), before, "rolled back"); + } +} + +#[test] +fn text_that_looks_like_a_named_reference_is_left_alone() { + let mut s = Session::new(); + let text = "{\"$ref\": \"card\"} $ref"; + let results = s.execute("command.batch", &json!({"commands": [{"command": "node.create", "params": {"type": "text", "text": text}}]})).unwrap(); + let n = s.doc().unwrap().node(id(&results[0])).unwrap().clone(); + assert!(matches!(n.kind, figcraft_doc::NodeKind::Text(t) if t.characters == text)); +} + +/// A nested batch has its own names: it can't see the outer batch's, may reuse them, and the +/// outer batch can't see its. +#[test] +fn a_nested_batch_has_its_own_names() { + let mut s = Session::new(); + let frame = |name: &str| json!({"command": "node.create", "as": "frame", "params": {"type": "frame", "name": name, "width": 100, "height": 100}}); + let results = s + .execute( + "command.batch", + &json!({"commands": [ + frame("Outer"), + {"command": "command.batch", "as": "inner", "params": {"commands": [ + frame("Inner"), + {"command": "node.create", "params": {"type": "rectangle", "parent": {"$ref": "frame"}, "width": 10, "height": 10}} + ]}}, + {"command": "node.set", "params": {"ids": [{"$ref": "inner", "field": "1/id"}], "props": {"name": "In inner"}}} + ]}), + ) + .unwrap(); + let inner = &results[1]; + let rect = s.doc().unwrap().node(id(&inner[1])).unwrap().clone(); + assert_eq!(rect.parent, Some(id(&inner[0])), "in the nested batch's own frame, not the outer one"); + assert_eq!(rect.name, "In inner", "a path indexes into a nested batch's result list"); + + let msg = batch_error( + &mut s, + json!([frame("Outer"), {"command": "command.batch", "params": {"commands": [ + {"command": "node.create", "params": {"type": "rectangle", "parent": {"$ref": "frame"}, "width": 10, "height": 10}} + ]}}]), + ); + assert!(msg.contains("`$ref` \"frame\" names no step"), "the outer names aren't visible inside: {msg}"); +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a16fbb38..c033c2cd 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -8,7 +8,7 @@ use serde_json::{Value, json}; use super::{CommandSpec, ok}; use crate::params::*; -use crate::{EngineError, Result, Session, bad, spec}; +use crate::{BatchStep, EngineError, Result, Session, bad, spec}; pub fn specs() -> Vec { vec![ @@ -336,15 +336,16 @@ pub fn specs() -> Vec { } ok() }), - spec!("command.batch", "Run several commands", "{label?, commands: [{command, params}]} (one undo step)", false, |s, p| { + spec!("command.batch", "Run several commands", "{label?, commands: [{command, params, as?}]} (one undo step)", false, |s, p| { let label = string(p, "label").unwrap_or_else(|| "Batch".into()); - let cmds: Vec<(String, Value)> = match get(p, "commands") { + let cmds: Vec = match get(p, "commands") { Some(Value::Array(a)) => { let mut out = Vec::new(); for (i, c) in a.iter().enumerate() { let cmd = c.get("command").and_then(Value::as_str).ok_or_else(|| bad("command.batch", format!("step {i} has no `command`")))?; - out.push((cmd.to_string(), c.get("params").cloned().unwrap_or(json!({})))); + let params = c.get("params").cloned().unwrap_or(json!({})); + out.push(BatchStep { name: crate::batch::step_name(i, c)?, ..BatchStep::new(cmd, params) }); } out } diff --git a/crates/engine/src/collab_live_tests.rs b/crates/engine/src/collab_live_tests.rs index 5b759391..d53eeff2 100644 --- a/crates/engine/src/collab_live_tests.rs +++ b/crates/engine/src/collab_live_tests.rs @@ -246,9 +246,9 @@ fn a_group_of_commands_syncs_as_one_step() { let (f, r) = frame_and_rect(room.server.document()); let start = room.server.replica().clone(); let cmds = vec![ - ("node.set".to_string(), json!({"id": r, "props": {"name": "A"}})), - ("node.set".to_string(), json!({"id": f, "props": {"name": "B"}})), - ("shape.rectangle".to_string(), json!({"x": 0, "y": 0, "width": 5, "height": 5})), + crate::BatchStep::new("node.set", json!({"id": r, "props": {"name": "A"}})), + crate::BatchStep::new("node.set", json!({"id": f, "props": {"name": "B"}})), + crate::BatchStep::new("shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5})), ]; room.s(0).batch("Three", &cmds).unwrap(); room.pump(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 79e7be0d..048c0722 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -13,6 +13,7 @@ pub mod al_keys; pub mod annotations; pub mod arc_handles; pub mod background; +pub mod batch; pub mod booleans; pub mod branches; pub mod catalog; @@ -64,6 +65,7 @@ pub mod tools; pub mod vectoredit; pub mod widgets; +pub use batch::{BatchScope, BatchStep}; pub use cmd::{CommandSpec, all_commands, find_command}; pub use figcraft_collab; pub use figcraft_doc as doc; @@ -115,47 +117,6 @@ pub fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.to_string(), msg: msg.into() } } -/// Deepest params nesting `resolve_step_refs` walks before giving up. -const STEP_REF_MAX_DEPTH: usize = 64; - -/// Replace every `{"$step": n}` / `{"$step": n, "field": "…"}` in `v` with that field (default -/// `id`) of `results[n]`, the result of an earlier batch step. -fn resolve_step_refs(v: &Value, results: &[Value], step: usize, depth: usize) -> Result { - if depth > STEP_REF_MAX_DEPTH { - return Err(bad("command.batch", format!("step {step}: params nest deeper than {STEP_REF_MAX_DEPTH} levels"))); - } - match v { - Value::Object(o) if o.contains_key("$step") => { - if let Some(k) = o.keys().find(|k| *k != "$step" && *k != "field") { - return Err(bad("command.batch", format!("step {step}: a `$step` reference takes only `field`, not `{k}`"))); - } - let n = o - .get("$step") - .and_then(Value::as_u64) - .ok_or_else(|| bad("command.batch", format!("step {step}: `$step` must be a step index (0, 1, …)")))?; - let field = match o.get("field") { - None => "id", - Some(Value::String(f)) => f.as_str(), - Some(_) => return Err(bad("command.batch", format!("step {step}: `field` must be a string"))), - }; - let Some(result) = usize::try_from(n).ok().and_then(|n| results.get(n)) else { - let range = if step == 0 { "there is none before step 0".to_string() } else { format!("0 to {}", step - 1) }; - return Err(bad("command.batch", format!("step {step}: `$step` {n} must be an earlier step ({range})"))); - }; - result.get(field).cloned().ok_or_else(|| bad("command.batch", format!("step {step}: step {n}'s result has no `{field}`"))) - } - Value::Object(o) => { - let mut out = serde_json::Map::with_capacity(o.len()); - for (k, x) in o { - out.insert(k.clone(), resolve_step_refs(x, results, step, depth + 1)?); - } - Ok(Value::Object(out)) - } - Value::Array(a) => a.iter().map(|x| resolve_step_refs(x, results, step, depth + 1)).collect::>>().map(Value::Array), - _ => Ok(v.clone()), - } -} - /// One undo step. #[derive(Clone, Debug)] pub struct HistoryEntry { @@ -1245,28 +1206,30 @@ impl Session { } /// Run several commands as one undo step. A param value `{"$step": n}` is replaced by the `id` - /// in the result of the earlier step `n` (`{"$step": n, "field": "…"}` picks another field), - /// so a step can target a layer that an earlier step of the same batch created. Any object - /// with a `$step` key in a step's params is such a reference, except inside a nested - /// `command.batch` step: its references are to its own steps. - pub fn batch(&mut self, label: &str, cmds: &[(String, Value)]) -> Result> { + /// in the result of the earlier step `n`, and `{"$ref": "name"}` by the `id` in the result of + /// the earlier step whose `as` is `name`; either takes a `field`, a key or slash path (`ids/0`) + /// into that result. So a step can target a layer that an earlier step of the same batch + /// created. Any object with a `$step` or `$ref` key in a step's params is such a reference, + /// except inside a nested `command.batch` step: its references are to its own steps. + pub fn batch(&mut self, label: &str, steps: &[BatchStep]) -> Result> { + let mut scope = BatchScope::new(steps)?; self.grouped(label, |s| { - let mut results: Vec = Vec::with_capacity(cmds.len()); - for (step, (id, p)) in cmds.iter().enumerate() { - let v = s.batch_step(&results, step, id, p)?; - results.push(v); + for step in steps { + s.batch_step(&mut scope, &step.command, &step.params)?; } - Ok(results) + Ok(scope.into_results()) }) } - /// One step of a batch: `{"$step": n}` in `params` resolves against `results` (the results of - /// the steps before it), then the command runs. [`Session::batch`] runs a list of these as one - /// undo step; a caller with its own list — the CLI's script runner — uses this so its steps - /// resolve `$step` the same way, inside its own [`Session::grouped`]. - pub fn batch_step(&mut self, results: &[Value], step: usize, cmd: &str, params: &Value) -> Result { - let p = if cmd == "command.batch" { params.clone() } else { resolve_step_refs(params, results, step, 0)? }; - self.execute(cmd, &p) + /// The next step of a batch: the references in `params` resolve against `scope` (the steps + /// before it), the command runs and its result joins `scope`. [`Session::batch`] runs a list + /// of these as one undo step; a caller with its own list — the CLI's script runner — uses this + /// so its steps resolve references the same way, inside its own [`Session::grouped`]. + pub fn batch_step(&mut self, scope: &mut BatchScope, cmd: &str, params: &Value) -> Result<()> { + let p = if cmd == "command.batch" { params.clone() } else { scope.resolve(params)? }; + let v = self.execute(cmd, &p)?; + scope.push(v); + Ok(()) } /// Run `f` as one undo step called `label`: the commands it executes push no steps of their diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 646c7c4b..4b811673 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -425,6 +425,11 @@ fn every_command_survives_junk_params() { // Rotation origins and placing with a tool as junk. json!({"tool": [1], "x": 1e308, "y": "NaN", "reset": "maybe", "on": {"x": 1}}), json!({"tool": "frame", "x": -1e308, "y": 1e308, "reset": true, "on": "yes"}), + // Batch step names and reference paths as junk. + json!({"commands": [{"command": "select.none", "as": "a"}, {"command": "select.none", "as": [1]}, {"command": "select.none", "as": "a"}]}), + json!({"commands": [{"command": "select.all", "as": "\u{0}"}, {"command": "node.set", "params": {"ids": [ + {"$ref": "\u{0}", "field": "ids/18446744073709551616/x"}, {"$ref": {"x": 1}}, {"$step": 0, "field": "//"}, {"$ref": "\u{0}", "field": "/"}], + "props": {"name": {"$ref": "nope", "field": 5}}}}]}), ]; for spec in all_commands() { if matches!( diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 7714da5a..1e4c887f 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -128,10 +128,10 @@ pub fn tool_definitions() -> Vec { tool( "batch", "Run commands as one step", - "Run several commands in order as ONE undo step (engine `command.batch`). If any command fails, everything is rolled back and the error names the failing step. Returns an array of the results in order. A later step can use a layer an earlier step created: anywhere in its params, {\"$step\": n} stands for the `id` in step n's result (zero-based; {\"$step\": n, \"field\": \"…\"} picks another field). Example: [{\"command\":\"node.create\",\"params\":{\"type\":\"frame\"}}, {\"command\":\"node.create\",\"params\":{\"type\":\"text\",\"text\":\"Hi\",\"parent\":{\"$step\":0}}}] builds a frame and a text inside it in one call.", + "Run several commands in order as ONE undo step (engine `command.batch`). If any command fails, everything is rolled back and the error names the failing step. Returns an array of the results in order. A later step can use what an earlier step returned: name a step with \"as\": \"card\", then anywhere in a later step's params {\"$ref\": \"card\"} stands for the `id` in its result ({\"$step\": n} does the same by zero-based position). Add \"field\" for another part of the result: a key or a slash path, e.g. {\"$ref\": \"copy\", \"field\": \"ids/0\"} for the first id edit.duplicate returned. Any object with a `$ref` or `$step` key is a reference. Example: [{\"command\":\"node.create\",\"as\":\"card\",\"params\":{\"type\":\"frame\"}}, {\"command\":\"node.create\",\"params\":{\"type\":\"text\",\"text\":\"Hi\",\"parent\":{\"$ref\":\"card\"}}}] builds a frame and a text inside it in one call.", obj( json!({ - "commands": {"type": "array", "minItems": 1, "description": "Steps to run", "items": obj(json!({"command": string("Command id"), "params": {"type": "object"}}), &["command"])}, + "commands": {"type": "array", "minItems": 1, "description": "Steps to run", "items": obj(json!({"command": string("Command id"), "params": {"type": "object"}, "as": string("Name for this step, unique in the batch; later steps use its result with {\"$ref\": name}")}), &["command"])}, "label": string("Undo history label (default \"Batch\")"), }), &["commands"], @@ -497,7 +497,11 @@ fn batch(b: &mut dyn Backend, a: &Map) -> Result Date: Wed, 21 Oct 2026 20:35:13 +0200 Subject: [PATCH 06/54] M1: several exported layers arrive as one zip in the browser (#969) Cause: in the browser, exporting several layers started one download per file. Chrome holds back every download after the first until the site is allowed automatic downloads, so the other files didn't arrive. Fix: file.exportLayers takes `zip: true` (without `dir`) and returns the files together as one base64 zip `data`, written by a new figcraft_format::zip_files (the workspace's existing zip dependency). The UI asks for it when the platform saves by downloads and hands over one `.zip`. Test: ui-egui several_layers_download_as_one_zip (one download, a zip holding both layers). It fails on the previous commit (two downloads). --- crates/engine/src/cmd/file.rs | 13 +++++++++++-- crates/format/src/lib.rs | 14 ++++++++++++++ crates/ui-egui/src/export_panel.rs | 13 +++++++------ crates/ui-egui/src/web_save_tests.rs | 16 ++++++++++++++++ 4 files changed, 48 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 8a75efb7..d5023372 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -65,7 +65,7 @@ pub fn specs() -> Vec { spec!( "file.exportLayers", "Export layers", - "{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own} every layer once per export setting, named `.` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data}], skipped (layers without settings)}", + "{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), zip? (no dir: the files come back together as one base64 zip `data` instead), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own} every layer once per export setting, named `.` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data}], skipped (layers without settings), data? (zip)}", false, export_layers ), @@ -1209,6 +1209,9 @@ fn export_layers(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Disabled(cmd.into(), "no export settings (add one with export.add)".into())); } let dir = string(p, "dir"); + // The browser saves one download at a time: several files go as one zip (#969). + let zip = dir.is_none() && boolean(p, "zip").unwrap_or(false); + let mut members = Vec::new(); // Print options passed here stand in for every PDF setting's own. let print = has_print_param(p).then(|| print_param(cmd, p, PrintSettings::default())).transpose()?; let mut files = Vec::with_capacity(jobs.len()); @@ -1228,12 +1231,18 @@ fn export_layers(s: &mut Session, p: &Value) -> Result { write(&path, &bytes)?; f["path"] = json!(path); } + None if zip => members.push((job.file.as_str(), bytes)), None => f["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&bytes)), } files.push(f); } let with_settings: std::collections::BTreeSet = jobs.iter().map(|j| j.id).collect(); - Ok(json!({"files": files, "skipped": ids.len().saturating_sub(with_settings.len())})) + let mut r = json!({"files": files, "skipped": ids.len().saturating_sub(with_settings.len())}); + if zip { + let archive = figcraft_format::zip_files(members.iter().map(|(n, b)| (*n, b.as_slice()))).map_err(|e| EngineError::Other(e.to_string()))?; + r["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&archive)); + } + Ok(r) } /// A PDF with one page per group of layers (an sRGB document), titled after the file; `area` diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 9f7e51bc..d0775323 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -343,6 +343,20 @@ pub(crate) fn put( z.write_all(bytes).map_err(io_err) } +/// `files` (name, bytes; a `/` in a name makes a folder) as one zip archive: several exports +/// delivered as a single download (#969). +pub fn zip_files<'a>(files: impl IntoIterator) -> Result, FormatError> { + let mut buf = std::io::Cursor::new(Vec::new()); + { + let mut z = zip::ZipWriter::new(&mut buf); + for (name, bytes) in files { + put(&mut z, name, zip::write::SimpleFileOptions::default(), bytes)?; + } + z.finish().map_err(io_err)?; + } + Ok(buf.into_inner()) +} + /// Add `doc` as a `document.json`-style member, serialized straight into the compressor. fn put_json(z: &mut zip::ZipWriter, name: &str, doc: &Document) -> Result<(), FormatError> { z.start_file(name, json_options()).map_err(io_err)?; diff --git a/crates/ui-egui/src/export_panel.rs b/crates/ui-egui/src/export_panel.rs index 6a4ca8ee..39fc4bd1 100644 --- a/crates/ui-egui/src/export_panel.rs +++ b/crates/ui-egui/src/export_panel.rs @@ -320,13 +320,14 @@ pub(crate) fn export_layers(app: &mut FigApp, ids: &[NodeId], assets: bool) { [] => {} // The suggested name keeps only the file's own name (a layer's folders apply to folders). [one] => save_one(app, &[one.id], &one.setting, one.file.rsplit('/').next().unwrap_or(&one.file)), - // No folders in the browser: each file arrives as its own download (#969). + // No folders in the browser, and it lets a page start one download at a time: the files + // arrive together as one zip (#969). _ if app.services.downloads() => { - let Some(r) = app.run("file.exportLayers", json!({"ids": ids, "assets": assets})) else { return }; - let files = r["files"].as_array().map_or(&[][..], Vec::as_slice); - let n = files.iter().filter(|f| app.download(f["name"].as_str().unwrap_or_default(), f)).count(); - if n > 0 { - app.toast(trn("Exported {n} files", n, &[])); + let name = format!("{}.zip", app.session.doc().map_or("Export", |d| d.name.as_str())); + if let Some(r) = app.run("file.exportLayers", json!({"ids": ids, "assets": assets, "zip": true})) + && app.download(&name, &r) + { + app.toast(trn("Exported {n} files", r["files"].as_array().map_or(0, Vec::len), &[])); } } _ => { diff --git a/crates/ui-egui/src/web_save_tests.rs b/crates/ui-egui/src/web_save_tests.rs index 7d1d75d2..5fbb2524 100644 --- a/crates/ui-egui/src/web_save_tests.rs +++ b/crates/ui-egui/src/web_save_tests.rs @@ -53,3 +53,19 @@ fn a_refused_download_says_so() { assert_eq!(got.lock().unwrap().len(), 1); assert_eq!(toast(&app), "Couldn't download Untitled.figcraft"); } + +#[test] +fn several_layers_download_as_one_zip() { + // The browser lets a page start one download at a time: two downloads lost the second. + let (mut app, got, a) = browser(true); + let r = app.session.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap(); + let b: NodeId = serde_json::from_value(r["id"].clone()).unwrap(); + app.session.execute("export.add", &json!({"ids": [a, b]})).unwrap(); + crate::export_panel::export_layers(&mut app, &[a, b], false); + let got = got.lock().unwrap(); + let [(name, bytes)] = got.as_slice() else { panic!("one download, got {}", got.len()) }; + assert_eq!(name, "Untitled.zip"); + assert!(bytes.starts_with(b"PK\x03\x04")); + assert!(bytes.windows(15).any(|w| w == b"Rectangle 2.png"), "both layers are in the zip"); + assert_eq!(toast(&app), "Exported 2 files"); +} From 2118480e7af3c50334bf0b4c599ecb6a3ee010bd Mon Sep 17 00:00:00 2001 From: vetlucasmartins Date: Wed, 21 Oct 2026 20:03:46 +0100 Subject: [PATCH 07/54] Dev Mode: React + Tailwind page code and Tailwind theme export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - inspect.code {language: "tailwind"}: a layer tree as a React component styled with Tailwind CSS v4 utilities, no stylesheet. Auto layout maps to flex utilities on the spacing scale; fixed-width auto layout frames, text and wide layers become fluid (w-full max-w-[…]); instances are inlined with their overrides, except those connected to code (codeConnect), which are their snippet with one import each; bound variables are theme utilities (bg-accent, rounded-card). - mobile: (same layer names): mobile-first code, desktop classes that differ behind lg:, resets for desktop defaults, padding compared per side; layers only on desktop are hidden below lg, those only on mobile are listed in the header. - variable.export {format: "tailwind"}: @import "tailwindcss" and an @theme block (--color-*, --radius-*, --text-*, --spacing-*, --font-*), names unique per file and shared with page code. Builds on the token roles of the CSS export px fix (#1008). --- crates/engine/src/cmd/variables_io.rs | 24 +- crates/engine/src/codegen.rs | 87 ++++- crates/engine/src/lib.rs | 3 + crates/engine/src/tailwind.rs | 490 ++++++++++++++++++++++++++ crates/engine/src/tailwind_tests.rs | 335 ++++++++++++++++++ docs/mcp.md | 16 + 6 files changed, 934 insertions(+), 21 deletions(-) create mode 100644 crates/engine/src/tailwind.rs create mode 100644 crates/engine/src/tailwind_tests.rs diff --git a/crates/engine/src/cmd/variables_io.rs b/crates/engine/src/cmd/variables_io.rs index 0f55ea2c..c74c46fe 100644 --- a/crates/engine/src/cmd/variables_io.rs +++ b/crates/engine/src/cmd/variables_io.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { spec!( "variable.export", "Export variables", - "{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-=\"\"]; numbers in px unless layers or scopes use them as opacity or font weight), path? (write it there)} → {text}, or {path} when written", + "{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-=\"\"]; numbers in px unless layers or scopes use them as opacity or font weight | tailwind: a Tailwind CSS v4 stylesheet, the default mode as an @theme block named the way inspect.code's tailwind utilities read them: --color-*, --radius-*, --text-*, --spacing-*, --font-*, unique per file), path? (write it there)} → {text}, or {path} when written", false, export ), @@ -46,8 +46,9 @@ fn export(s: &mut Session, p: &Value) -> Result { let text = match string(p, "format").as_deref().unwrap_or("json") { "json" => serde_json::to_string_pretty(&figcraft_format::rest::export_variables(&doc.variables, &colls)) .map_err(|e| EngineError::Other(e.to_string()))?, - "css" => css(doc, &colls), - _ => return Err(bad(cmd, "format: json|css")), + "css" => css(doc, &colls, false), + "tailwind" => css(doc, &colls, true), + _ => return Err(bad(cmd, "format: json|css|tailwind")), }; match string(p, "path") { Some(path) => { @@ -66,16 +67,23 @@ fn css_safe(text: &str) -> String { /// Each collection's variables as custom properties (named as generated code names them): the /// default mode's values on `:root`, every other mode's on `[data-=""]`; /// aliases are `var(--name)`. -fn css(doc: &Document, colls: &[&Collection]) -> String { +fn css(doc: &Document, colls: &[&Collection], tailwind: bool) -> String { let vars = &doc.variables; + // Tailwind theme names, unique per file and the same ones page code reads. + let theme = crate::tailwind::Theme::new(doc); + let name = |v: &Variable| if tailwind { theme.name(v) } else { css_var_of(v) }; // Lengths need a unit where CSS reads them (`border-radius: var(--radius)`); opacity and // font weight don't. let roles = crate::token_roles::Roles::new(doc); - let mut out = String::new(); + let mut out = if tailwind { "@import \"tailwindcss\";\n\n".to_string() } else { String::new() }; for c in colls { let attr = css_var_name(&c.name); for (i, m) in c.modes.iter().enumerate() { - let sel = if i == 0 { ":root".to_string() } else { format!("[data-{}=\"{}\"]", attr.trim_start_matches('-'), css_safe(&m.name)) }; + let sel = match i { + 0 if tailwind => "@theme".to_string(), + 0 => ":root".to_string(), + _ => format!("[data-{}=\"{}\"]", attr.trim_start_matches('-'), css_safe(&m.name)), + }; out.push_str(&format!("/* {} · {} */\n{sel} {{\n", css_safe(&c.name), css_safe(&m.name))); for v in vars.variables.iter().filter(|v| v.collection == c.id) { let value = match v.values.get(&m.id) { @@ -85,12 +93,12 @@ fn css(doc: &Document, colls: &[&Collection]) -> String { Some(VariableValue::String(t)) => format!("\"{}\"", css_safe(t)), Some(VariableValue::Boolean(b)) => b.to_string(), Some(VariableValue::Alias(a)) => match vars.get(a) { - Some(t) => format!("var({})", css_var_of(t)), + Some(t) => format!("var({})", name(t)), None => continue, }, None => continue, }; - out.push_str(&format!(" {}: {value};\n", css_var_of(v))); + out.push_str(&format!(" {}: {value};\n", name(v))); } out.push_str("}\n"); } diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index 0871f886..8997587c 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { vec![spec!( "inspect.code", "Code for layer", - "{id?, language?: css|html|react|leptos|swiftui|compose (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}", + "{id?, language?: css|html|react|leptos|tailwind|swiftui|compose (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead; tailwind is React with Tailwind CSS v4 utilities for pages: fluid auto layout boxes, instances inlined with their overrides, bound variables as theme utilities), mobile? (tailwind: a mobile frame with the same layer names; the code is mobile first with the desktop layer's classes behind lg:), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}", false, |s, p| { let cmd = "inspect.code"; @@ -40,7 +40,14 @@ pub fn specs() -> Vec { return Err(bad(cmd, "no such layer")); } let connected = crate::code_connect::link(doc, id, lang).is_some(); - let code = code_for(doc, id, lang); + let mobile = match get(p, "mobile") { + None | Some(serde_json::Value::Null) => None, + Some(v) => Some(id_of(v).filter(|m| doc.contains(*m)).ok_or_else(|| bad(cmd, "mobile: the id of a mobile frame"))?), + }; + let code = match (lang, mobile) { + (CodeLang::Tailwind, Some(m)) => crate::tailwind::code(doc, id, Some(m)), + _ => code_for(doc, id, lang), + }; let code = match rem { Some(root) if lang.is_web() => px_to_rem(&code, root), _ => code, @@ -61,18 +68,22 @@ pub enum CodeLang { React, /// A Leptos component (`view!` markup) with its stylesheet. Leptos, + /// A React function component styled with Tailwind CSS v4 utilities (no stylesheet). + Tailwind, SwiftUi, Compose, } impl CodeLang { - pub const ALL: [CodeLang; 6] = [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::SwiftUi, CodeLang::Compose]; + pub const ALL: [CodeLang; 7] = + [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::Tailwind, CodeLang::SwiftUi, CodeLang::Compose]; pub fn id(self) -> &'static str { match self { CodeLang::Css => "css", CodeLang::Html => "html", CodeLang::React => "react", CodeLang::Leptos => "leptos", + CodeLang::Tailwind => "tailwind", CodeLang::SwiftUi => "swiftui", CodeLang::Compose => "compose", } @@ -83,13 +94,14 @@ impl CodeLang { CodeLang::Html => "HTML + CSS", CodeLang::React => "React", CodeLang::Leptos => "Leptos", + CodeLang::Tailwind => "React + Tailwind", CodeLang::SwiftUi => "SwiftUI", CodeLang::Compose => "Compose", } } /// Languages whose lengths are CSS pixels (they can be shown in rem). pub fn is_web(self) -> bool { - matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos) + matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos | CodeLang::Tailwind) } pub fn from_id(s: &str) -> Option { if s.eq_ignore_ascii_case("tsx") || s.eq_ignore_ascii_case("jsx") { @@ -194,6 +206,7 @@ pub fn code_for(doc: &Document, id: NodeId, lang: CodeLang) -> String { match lang { CodeLang::Css => css(doc, id), CodeLang::Html => html(doc, id), + CodeLang::Tailwind => crate::tailwind::code(doc, id, None), CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose => crate::component_codegen::code(doc, id, lang), } } @@ -316,25 +329,32 @@ pub(crate) fn indent(depth: usize) -> String { /// The CSS custom properties generated code uses: name → fallback value, in first-use order. #[derive(Default)] -pub(crate) struct Tokens(pub Vec<(String, String)>); +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, variable: &Variable, value: String) -> String { - let name = css_var_of(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.0.iter().any(|(n, _)| *n == name) { - self.0.push((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.0.is_empty() { + if self.vars.is_empty() { return String::new(); } - let decls: String = self.0.iter().map(|(n, v)| format!(" {n}: {v};\n")).collect(); + let decls: String = self.vars.iter().map(|(n, v)| format!(" {n}: {v};\n")).collect(); format!(":root {{\n{decls}}}\n") } } @@ -365,7 +385,7 @@ pub fn css_var_of(v: &Variable) -> String { /// `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(v, value), + Some(v) => t.var(doc, v, value), None => value, } } @@ -659,6 +679,14 @@ pub(crate) struct Markup<'c, 'a> { /// 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> { @@ -673,6 +701,9 @@ impl<'c, 'a> Markup<'c, 'a> { root_class: None, root_attrs: String::new(), rules: Vec::new(), + inline: false, + fluid: false, + fluid_leaves: f64::INFINITY, } } @@ -754,7 +785,10 @@ impl<'c, 'a> Markup<'c, 'a> { } } // Nested instances are calls (component code), in a wrapper that places them when needed. - if self.code() && !root && matches!(n.kind, NodeKind::Instance { .. }) { + // Page code inlines instances, except those connected to code (codeConnect.set): their + // snippet, e.g. a design-system `"}), + ); + let p = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 1440, "height": 400, "name": "Landing"}))); + ex(&mut s, "layout.add", json!({"ids": [p], "direction": "vertical"})); + for text in ["Agendar diagnóstico", "Ver exemplos"] { + let i = idv(&ex(&mut s, "instance.create", json!({"component": comp, "x": 0, "y": 0, "parent": p}))); + ex(&mut s, "instance.setProperties", json!({"id": i, "properties": {"Label": text}})); + } + let out = code(&mut s, p, json!({})); + assert_eq!(out.matches("import { Button } from \"@/components/ui/button\";").count(), 1, "{out}"); + assert!(out.contains("`, with each import once at the top. A design + system mapped to shadcn/ui this way exports pages that use the project's own components. Bound variables become theme + utilities (`bg-accent`, `rounded-card`, `text-muted`) that `variable.export {format: "tailwind"}` + declares: `@import "tailwindcss";` and an `@theme` block named by namespace (`--color-*`, + `--radius-*`, `--text-*`, `--spacing-*`, `--font-*`; a number's namespace and unit come from what + layers bind it to), other modes on `[data-=""]`. With `mobile: ` (the + same layer names, mobile layout) the code is mobile first: the mobile frame's classes, the + desktop frame's that differ behind `lg:`, and layers only on desktop `hidden` plus `lg:flex` + (auto layout frames) or `lg:block` (other layers). - **Component code:** `inspect.code {id, language: react|leptos|swiftui|compose}` on a component, a component set or a variant writes the component as a reusable unit: React (TSX) with a props interface, Leptos with a `#[component]` function (`#[prop]` defaults, `view!` markup), SwiftUI From f0e75e183b86b31c16334aea39be2fd9e98768dd Mon Sep 17 00:00:00 2001 From: Ridzzz0Alam Date: Wed, 21 Oct 2026 21:06:59 +0200 Subject: [PATCH 08/54] M1: swap defaults past the layer cap say so (#981) Instance swap defaults that name an in-file main skipped by the layer cap were reported as "isn't in the file": only instance targets fed the capped-mains list. Defaults now feed it too. --- crates/format/src/fig/layer_cap_tests.rs | 21 +++++++++++++++++++++ crates/format/src/fig/map.rs | 21 +++++++++++++-------- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/crates/format/src/fig/layer_cap_tests.rs b/crates/format/src/fig/layer_cap_tests.rs index 6eeb15f4..2c562ba7 100644 --- a/crates/format/src/fig/layer_cap_tests.rs +++ b/crates/format/src/fig/layer_cap_tests.rs @@ -103,6 +103,27 @@ fn instances_of_a_main_the_cap_kept_out_say_so() { assert!(!doc.nodes().any(|n| n.name == "Local main")); } +#[test] +fn swap_defaults_the_cap_kept_out_say_so() { + // A kept main's instance swap default names a main with no instances, which comes after + // enough layers to be skipped at the cap. + let swap = o(vec![ + ("id", g(70)), + ("name", Kv::Str("Icon".into())), + ("type", Kv::Enum("INSTANCE_SWAP")), + ("initialValue", o(vec![("guidValue", g(900))])), + ]); + let mut nodes = vec![node(1, None, "DOCUMENT", "Document", vec![]), node(2, Some(1), "CANVAS", "Page", vec![])]; + nodes.push(node(50, Some(2), "SYMBOL", "Card", vec![("size", xy(10.0, 10.0)), ("componentPropDefs", Kv::Array(vec![swap]))])); + nodes.extend(main(60, 2, "Filler", 40)); + nodes.extend(main(900, 2, "Icon main", 10)); + let (doc, warnings) = import_at(&o(vec![("nodeChanges", Kv::Array(nodes))]), 30); + assert!(doc.nodes().any(|n| n.name == "Card")); + assert!(!doc.nodes().any(|n| n.name == "Icon main")); + assert!(warnings.iter().any(|w| w.starts_with("instance swap properties whose default component was past the layer limit")), "{warnings:?}"); + assert!(!warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}"); +} + #[test] fn a_whole_file_past_the_cap_keeps_its_library_links_and_says_so_once() { // A 1 001-layer library main used 210 times: about 210 000 layers in a file far too small to diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 7eb974da..b18f8a2b 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -45,7 +45,7 @@ pub(super) fn to_rest(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec= m.max_nodes { - let capped: Vec = m.instance_mains.difference(&m.mapped_mains).map(|id| json!(id)).collect(); + let capped: Vec = m.referenced_mains.difference(&m.mapped_mains).map(|id| json!(id)).collect(); if !capped.is_empty() { out[crate::rest::FIG_CAPPED_MAINS_FIELD] = Value::Array(capped); } @@ -397,9 +397,9 @@ struct Mapper<'a> { library_scan_count: usize, warnings: Vec, count: usize, - /// The mains in the file that instances name, and the mains mapped as layers: the difference - /// is what the layer cap kept out. - instance_mains: BTreeSet, + /// The mains in the file that instances or instance swap defaults name, and the mains mapped + /// as layers: the difference is what the layer cap kept out. + referenced_mains: BTreeSet, mapped_mains: BTreeSet, /// Most layers produced or scanned, instance expansion included; the one place the cap comes /// from (see [`super::layer_budget`]). @@ -478,7 +478,7 @@ impl<'a> Mapper<'a> { library_scan_count: 0, warnings: Vec::new(), count: 0, - instance_mains: BTreeSet::new(), + referenced_mains: BTreeSet::new(), mapped_mains: BTreeSet::new(), max_nodes, networks: Vec::new(), @@ -1869,7 +1869,12 @@ impl<'a> Mapper<'a> { "BOOL" => json!({"type": "BOOLEAN", "defaultValue": init.and_then(|i| i.get("boolValue")).and_then(Kv::bool).unwrap_or(true)}), "TEXT" => json!({"type": "TEXT", "defaultValue": init.and_then(prop_text).unwrap_or("")}), "INSTANCE_SWAP" => match init.and_then(|i| i.get("guidValue")).and_then(guid) { - Some(g) => json!({"type": "INSTANCE_SWAP", "defaultValue": g}), + Some(g) => { + if self.nodes.contains_key(&g) { + self.referenced_mains.insert(g.clone()); + } + json!({"type": "INSTANCE_SWAP", "defaultValue": g}) + } None => continue, }, "VARIANT" => json!({"type": "VARIANT"}), @@ -2013,8 +2018,8 @@ impl<'a> Mapper<'a> { self.warn("instances of components that aren't in the file show no layers"); return None; } - if !self.instance_mains.contains(&sym) { - self.instance_mains.insert(sym.clone()); + if !self.referenced_mains.contains(&sym) { + self.referenced_mains.insert(sym.clone()); } if outer.is_some_and(|i| i.symbols.contains(&sym)) { return None; From 9f2470b5bd132ac0fb889b21fbf8cdb4726b5f2e Mon Sep 17 00:00:00 2001 From: vetlucasmartins Date: Wed, 21 Oct 2026 21:00:02 +0100 Subject: [PATCH 09/54] Tailwind theme names: resolve collisions in linear time Clash detection compared every variable with every other, and suffixes rescanned the taken list, so a file with very many variables made the export quadratic. Counts in a HashMap, taken names in a HashSet and a next suffix per base keep it linear. --- crates/engine/src/tailwind.rs | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/tailwind.rs b/crates/engine/src/tailwind.rs index 7f54c8aa..2a6453e4 100644 --- a/crates/engine/src/tailwind.rs +++ b/crates/engine/src/tailwind.rs @@ -32,14 +32,30 @@ impl Theme { let mut t = Theme { roles, names: HashMap::new() }; // Short names ("accent") where they are unique, the full path ("palette-accent") where two // variables would share one, and a numeric suffix as a last resort. - let short: Vec<(VariableId, String)> = vars.iter().map(|v| (v.id.clone(), t.candidate(v, false))).collect(); - let mut taken: Vec = Vec::new(); - for (v, (id, s)) in vars.iter().zip(&short) { - let clash = short.iter().any(|(o, n)| o != id && n == s); - let base = if clash { t.candidate(v, true) } else { s.clone() }; - let name = (1..).map(|k| if k == 1 { base.clone() } else { format!("{base}-{k}") }).find(|n| !taken.contains(n)).unwrap_or(base); - taken.push(name.clone()); - t.names.insert(id.clone(), name); + let short: Vec = vars.iter().map(|v| t.candidate(v, false)).collect(); + // Linear in the number of variables: counts for clashes, a set of names taken and the next + // suffix per base, so a file with very many variables can't make this quadratic. + let mut count: HashMap<&str, usize> = HashMap::new(); + for n in &short { + *count.entry(n.as_str()).or_default() += 1; + } + let mut taken: std::collections::HashSet = std::collections::HashSet::new(); + let mut next: HashMap = HashMap::new(); + for (v, s) in vars.iter().zip(&short) { + let base = if count.get(s.as_str()).copied().unwrap_or(0) > 1 { t.candidate(v, true) } else { s.clone() }; + let mut name = base.clone(); + if taken.contains(&name) { + let k = next.entry(base.clone()).or_insert(1); + loop { + *k += 1; + name = format!("{base}-{k}"); + if !taken.contains(&name) { + break; + } + } + } + taken.insert(name.clone()); + t.names.insert(v.id.clone(), name); } t } From 82b05803b18f6ebfd38e423aabf90b95cc4995f1 Mon Sep 17 00:00:00 2001 From: Ridzzz0Alam Date: Wed, 21 Oct 2026 22:48:51 +0200 Subject: [PATCH 10/54] M1: a refused browser download keeps the unsaved changes (#969) `file.saveAs {data: true}` marked the file saved before the UI handed the bytes to `Services::download`. When the download was refused, the toast said "Couldn't download" but the document was clean, so closing it no longer warned and the edits could be lost. The data result now leaves the save state alone. A new `file.markSaved` (the bookkeeping a native save already did: the file and its branch tabs) runs from `save_as_dialog` only once the download was delivered. It ends a version preview like the other commands that act on the document itself. Tests: the engine save test checks the file stays dirty until `file.markSaved`; `a_refused_download_says_so` checks the document is still dirty. --- crates/engine/src/cmd/file.rs | 39 +++++++++++++++++++--------- crates/engine/src/lib.rs | 3 ++- crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/web_save_tests.rs | 1 + 5 files changed, 32 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d5023372..aa2ea340 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -55,6 +55,13 @@ pub fn specs() -> Vec { false, save_as ), + spec!( + "file.markSaved", + "Mark saved", + "{} clears the unsaved changes of the active tab's file and its branch tabs: run it once the file from `file.saveAs {data: true}` was delivered (a refused download keeps them)", + false, + mark_saved + ), spec!( "file.export", "Export", @@ -869,24 +876,30 @@ fn save_to(s: &mut Session, path: Option) -> Result { Arc::make_mut(&mut snapshot.doc).name = file_stem(path); } let bytes = snapshot.encode()?; - if let Some(path) = &path { - write(path, &bytes)?; - } - ds.doc = snapshot.doc; - if path.is_some() { - ds.path.clone_from(&path); - } - ds.mark_saved(); - ds.revision += 1; - let (uid, name) = (ds.uid, format!("{}.figcraft", ds.doc.name)); - branch_tabs_saved(s, uid); let Some(path) = path else { + // The download can still be refused: the caller marks the file saved once it was delivered. + let name = format!("{}.figcraft", ds.doc.name); return Ok(json!({"name": name, "bytes": bytes.len(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})); }; + write(&path, &bytes)?; + ds.doc = snapshot.doc; + ds.path = Some(path.clone()); + mark_saved(s, &json!({}))?; remember(s, &path); Ok(json!({"path": path, "bytes": bytes.len()})) } +/// The file of the active tab (with its branch tabs) now matches what was written or delivered. +fn mark_saved(s: &mut Session, _: &Value) -> Result { + let i = crate::branches::file_tab(s); + let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?; + ds.mark_saved(); + ds.revision += 1; + let uid = ds.uid; + branch_tabs_saved(s, uid); + Ok(json!({"saved": true})) +} + fn save(s: &mut Session, p: &Value) -> Result { if let Some(path) = string(p, "path") { return save_to(s, Some(path)); @@ -1590,7 +1603,7 @@ mod rest_import_tests { } #[test] - fn save_as_data_hands_back_the_file_and_marks_it_saved() { + fn save_as_data_hands_back_the_file_and_marks_it_saved_once_delivered() { // The browser has no paths: Save as returns the file for a download (#969). let mut s = Session::new(); s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); @@ -1598,6 +1611,8 @@ mod rest_import_tests { let recent = s.prefs.recent_files.clone(); let r = s.execute("file.saveAs", &json!({"data": true})).unwrap(); assert_eq!(r["name"], "Untitled.figcraft"); + assert!(s.ds().unwrap().is_dirty(), "not delivered yet: a refused download must not lose the edits"); + s.execute("file.markSaved", &json!({})).unwrap(); assert!(!s.ds().unwrap().is_dirty()); assert_eq!(s.ds().unwrap().path, None, "still no path to save over"); assert_eq!(s.prefs.recent_files, recent, "nothing on disk to remember"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 79e7be0d..e32d7543 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -402,7 +402,8 @@ pub const PREVIEW_READ_ONLY: &str = "you're viewing an earlier version: restore /// Commands that first end a version preview: they act on the document itself. Saving does so /// only with a native destination; export copies and pending format choices keep the preview. -const ENDS_PREVIEW: &[&str] = &["file.save", "file.saveAs", "file.close", "file.revert", "file.saveVersion", "file.duplicate", "version.restore"]; +const ENDS_PREVIEW: &[&str] = + &["file.save", "file.saveAs", "file.markSaved", "file.close", "file.revert", "file.saveVersion", "file.duplicate", "version.restore"]; static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index bd572dbe..25f3f258 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1920,6 +1920,7 @@ status = "draft — needs native review" "file.revert" = "Volver a lo guardado" "file.save" = "Guardar" "file.saveAs" = "Guardar como" +"file.markSaved" = "Marcar como guardado" "file.saveVersion" = "Guardar en el historial de versiones" "file.versionHistory" = "Mostrar historial de versiones" "fill.add" = "Añadir relleno" diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 788bbf4f..0707723d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1146,6 +1146,7 @@ impl FigApp { if let Some(r) = self.run("file.saveAs", json!({"data": true})) && self.download(r["name"].as_str().unwrap_or_default(), &r) { + self.run("file.markSaved", json!({})); dialogs::file_saved(self, &r); } return; diff --git a/crates/ui-egui/src/web_save_tests.rs b/crates/ui-egui/src/web_save_tests.rs index 5fbb2524..4879add1 100644 --- a/crates/ui-egui/src/web_save_tests.rs +++ b/crates/ui-egui/src/web_save_tests.rs @@ -52,6 +52,7 @@ fn a_refused_download_says_so() { app.save_as_dialog(); assert_eq!(got.lock().unwrap().len(), 1); assert_eq!(toast(&app), "Couldn't download Untitled.figcraft"); + assert!(app.session.ds().unwrap().is_dirty(), "no file arrived: closing must still warn about the edits"); } #[test] From 85010e491afbb1fd1cbbd94281e7e8a92884015f Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Wed, 21 Oct 2026 09:52:50 +0700 Subject: [PATCH 11/54] M7: missing fonts: follow each font variable's aliases once (#752) A file whose font family variables alias each other in several modes made the missing-fonts walk branch in every mode, billions of steps before the depth limit stopped it, freezing the app on open and `figcraft-cli info`. The walk now visits each variable once. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/fonts.rs | 12 ++++++++---- crates/engine/src/fonts_tests.rs | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs index 672d4f2c..bdda411c 100644 --- a/crates/engine/src/fonts.rs +++ b/crates/engine/src/fonts.rs @@ -64,16 +64,20 @@ pub fn missing(doc: &Document) -> Vec { used.into_values().filter(|(family, _)| !figcraft_text::has_family(family)).map(|(family, layers)| Missing { family, layers }).collect() } -/// Every string value of variable `id` in any mode, following aliases (at most 32 deep). +/// Every string value of variable `id` in any mode, following aliases (each variable once). fn string_values(vars: &Variables, id: &str) -> Vec { let mut out = Vec::new(); - let mut todo = vec![(id.to_string(), 0)]; - while let Some((id, depth)) = todo.pop() { + let mut seen = BTreeSet::new(); + let mut todo = vec![id.to_string()]; + while let Some(id) = todo.pop() { + if !seen.insert(id.clone()) { + continue; + } let Some(var) = vars.get(&id) else { continue }; for v in var.values.values() { match v { VariableValue::String(s) => out.push(s.clone()), - VariableValue::Alias(a) if depth < 32 => todo.push((a.clone(), depth + 1)), + VariableValue::Alias(a) => todo.push(a.clone()), _ => {} } } diff --git a/crates/engine/src/fonts_tests.rs b/crates/engine/src/fonts_tests.rs index 0fe6dcb3..5df9a854 100644 --- a/crates/engine/src/fonts_tests.rs +++ b/crates/engine/src/fonts_tests.rs @@ -119,6 +119,25 @@ fn blank_families_and_aliases_are_handled() { assert_eq!(families(&mut s), vec![("FigCraft Missing F".to_string(), 1), ("FigCraft Missing G".to_string(), 1)]); } +/// Aliases in several modes are followed once per variable: a chain that branches in every mode +/// would otherwise grow to billions of steps (a file can hold one). +#[test] +fn branching_alias_chains_are_walked_once_per_variable() { + let mut s = Session::new(); + s.execute("variable.createCollection", &json!({"name": "Type"})).unwrap(); + for i in 0..32 { + s.execute("variable.create", &json!({"collection": "Type", "name": format!("V{i}"), "type": "string", "value": "x"})).unwrap(); + } + let doc = s.doc_mut().unwrap(); + let ids: Vec = doc.variables.variables.iter().map(|v| v.id.clone()).collect(); + for (i, var) in doc.variables.variables.iter_mut().enumerate() { + let next = ids.get(i + 1).cloned().unwrap_or_else(|| "FigCraft Missing H".into()); + let leaf = |v: String| if i + 1 < ids.len() { VariableValue::Alias(v) } else { VariableValue::String(v) }; + var.values = [("m1".to_string(), leaf(next.clone())), ("m2".to_string(), leaf(next))].into(); + } + assert_eq!(string_values(&doc.variables, &ids[0]), vec!["FigCraft Missing H", "FigCraft Missing H"]); +} + #[test] fn needs_an_open_document() { let mut s = Session::empty(); From 0f85c2294e4637d2e8b09faea048a8469a382b56 Mon Sep 17 00:00:00 2001 From: F3R9 <214804886+F3R9@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:40:52 +0300 Subject: [PATCH 12/54] M9: avoid read-only snapshot rewrites and duplicate buffers Validated formatting, workspace Clippy, workspace tests excluding the two pre-existing localized startup assertion failures tracked in #1017, layers, assets, WASM and plugins. The startup tests are unchanged in this independent fix. --- ROADMAP.md | 5 +++ apps/figcraft-server/src/disk.rs | 9 +++--- apps/figcraft-server/src/store.rs | 14 ++++++--- apps/figcraft-server/src/store_tests.rs | 41 +++++++++++++++++++++++++ crates/collab/src/codec_tests.rs | 10 ++++++ crates/collab/src/message.rs | 12 +++++--- docs/server-memory-retention.md | 23 ++++++++++++++ 7 files changed, 100 insertions(+), 14 deletions(-) create mode 100644 docs/server-memory-retention.md diff --git a/ROADMAP.md b/ROADMAP.md index f43cd015..cdabaeca 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,10 @@ # FigCraft Roadmap +M9 / M7.4 server memory follow-up: read-only disconnects retain their durable client-id journal +without rebuilding the image-bearing snapshot. Snapshot encoding appends into its header buffer, +avoiding a second full payload copy while preserving the existing disk format. See +[`docs/server-memory-retention.md`](docs/server-memory-retention.md). + **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) diff --git a/apps/figcraft-server/src/disk.rs b/apps/figcraft-server/src/disk.rs index af6e0f99..b0699736 100644 --- a/apps/figcraft-server/src/disk.rs +++ b/apps/figcraft-server/src/disk.rs @@ -127,13 +127,14 @@ fn u64_at(b: &[u8], at: usize) -> Option { /// A snapshot file's bytes. pub fn encode_snapshot(generation: u64, state: &SessionState) -> Result, String> { - let body = message::encode(state).map_err(|e| e.to_string())?; - let mut out = Vec::with_capacity(SNAP_HEADER + body.len()); + let mut out = Vec::with_capacity(SNAP_HEADER); out.extend_from_slice(SNAP_MAGIC); out.push(FORMAT); out.extend_from_slice(&generation.to_le_bytes()); - out.extend_from_slice(&crc(&body).to_le_bytes()); - out.extend_from_slice(&body); + out.extend_from_slice(&0u32.to_le_bytes()); + let mut out = message::encode_into(state, out).map_err(|e| e.to_string())?; + let sum = crc(&out[SNAP_HEADER..]); + out[13..SNAP_HEADER].copy_from_slice(&sum.to_le_bytes()); Ok(out) } diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index ea9bc626..1fadf3a1 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -1385,14 +1385,18 @@ impl Backend for Store { } fn idle(&mut self, file: &str, server: &mut Server) -> bool { - let dirty = self.files.get_mut(file).and_then(|e| e.log.as_mut()).is_some_and(|log| { - let _ = flush_log(file, log); - log.dirty + let compact = self.files.get_mut(file).is_some_and(|entry| { + let snapshot_seq = entry.meta.seq; + entry.log.as_mut().is_some_and(|log| { + // Issued client ids and refusals are durable in the log; only edits need a + // new image-bearing snapshot. Still try a snapshot if syncing the log failed. + flush_log(file, log).is_err() || (log.dirty && server.seq() > snapshot_seq) + }) }); - if dirty { + if compact { self.compact(file, server); } - // It closes anyway, everything in its snapshot: a compaction cut short needs no report. + // It closes anyway, with its state in the snapshot and durable log. self.cut_short.retain(|f| f != file); if let Some(e) = self.files.get_mut(file) { e.log = None; diff --git a/apps/figcraft-server/src/store_tests.rs b/apps/figcraft-server/src/store_tests.rs index f380568c..2bbbaf0e 100644 --- a/apps/figcraft-server/src/store_tests.rs +++ b/apps/figcraft-server/src/store_tests.rs @@ -127,6 +127,34 @@ fn a_file_survives_a_crash_and_compacts() { assert_eq!(store.list(&|_| None)[0].seq, seq, "the description follows"); } +#[test] +fn read_only_sessions_keep_client_ids_without_rewriting_the_snapshot() { + let dir = TempDir::new("read-only-reconnects"); + let c = StoreConfig { compact_after: 1000, ..cfg(dir.path()) }; + let (id, original) = { + let mut store = Store::open(dir.path(), c.clone()).unwrap(); + let (id, _, _) = publish(&mut store, "Read only"); + let bytes = fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap(); + (id, bytes) + }; + let mut last_client = 0; + for _ in 0..6 { + let mut store = Store::open(dir.path(), c.clone()).unwrap(); + let mut server = store.open(&id).unwrap(); + let client = join(&mut server, Access::View); + assert!(client.id() > last_client, "issued ids survive closing and reopening the store"); + last_client = client.id(); + store.changed(&id, &mut server); + // Idle must sync even if no periodic flush ran before the connection closed. + assert!(store.idle(&id, &mut server)); + assert_eq!(store.compactions(), 0, "a client id does not require rebuilding the image-bearing snapshot"); + assert_eq!(fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap(), original); + let log = disk::read_log(&fs::read(dir.path().join(FILES).join(&id).join(LOG)).unwrap()).unwrap(); + assert!(!log.entries.is_empty()); + assert_eq!(server.seq(), 0); + } +} + #[test] fn a_long_log_is_compacted_while_open() { let dir = TempDir::new("compact"); @@ -241,6 +269,19 @@ fn temporary_directories_are_removed() { assert!(!path.exists()); } +#[test] +fn snapshot_encoding_keeps_the_existing_framing_and_checksum() { + let state = Server::new(doc("Snapshot framing"), "", figcraft_collab::Limits::default()).state().unwrap(); + let body = figcraft_collab::message::encode(&state).unwrap(); + let mut legacy = b"FCSN\x01".to_vec(); + legacy.extend_from_slice(&37u64.to_le_bytes()); + legacy.extend_from_slice(&crc32fast::hash(&body).to_le_bytes()); + legacy.extend_from_slice(&body); + let encoded = disk::encode_snapshot(37, &state).unwrap(); + assert_eq!(encoded, legacy); + assert_eq!(disk::decode_snapshot(&encoded).unwrap(), (37, state)); +} + #[test] fn random_bytes_never_panic_the_readers() { let mut rng = Rng::new(7); diff --git a/crates/collab/src/codec_tests.rs b/crates/collab/src/codec_tests.rs index 9f5d12fb..30d41490 100644 --- a/crates/collab/src/codec_tests.rs +++ b/crates/collab/src/codec_tests.rs @@ -11,6 +11,16 @@ use crate::message::{ use crate::sim::Rng; use crate::{Error, Replica, diff}; +#[test] +fn encoding_into_a_prefix_keeps_the_wire_body() { + let msg = moved_layer(); + let prefix = b"container header".to_vec(); + let bytes = crate::message::encode_into(&msg, prefix.clone()).unwrap(); + assert_eq!(&bytes[..prefix.len()], prefix); + assert_eq!(&bytes[prefix.len()..], encode(&msg).unwrap()); + assert_eq!(decode::(&bytes[prefix.len()..], bytes.len()).unwrap(), msg); +} + /// A typical edit: one layer dragged (its transform). fn moved_layer() -> ClientMsg { let mut doc = Document::new("f"); diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs index 4fda776a..6a1a0e1e 100644 --- a/crates/collab/src/message.rs +++ b/crates/collab/src/message.rs @@ -732,11 +732,13 @@ pub fn utc_date(secs: u64) -> String { /// The binary form of `msg`: [`WIRE_VERSION`], then postcard. pub fn encode(msg: &T) -> Result, Error> { - let body = postcard::to_allocvec(msg).map_err(|e| Error::Encode(e.to_string()))?; - let mut out = Vec::with_capacity(body.len() + 1); - out.push(WIRE_VERSION); - out.extend(body); - Ok(out) + encode_into(msg, Vec::new()) +} + +/// Append the binary form of `msg` to an owned prefix, without a second payload-sized buffer. +pub fn encode_into(msg: &T, mut prefix: Vec) -> Result, Error> { + prefix.push(WIRE_VERSION); + postcard::to_extend(msg, prefix).map_err(|e| Error::Encode(e.to_string())) } /// Read a binary message of at most `max_bytes` (untrusted input: never panics, allocates at most diff --git a/docs/server-memory-retention.md b/docs/server-memory-retention.md new file mode 100644 index 00000000..ef4e0b94 --- /dev/null +++ b/docs/server-memory-retention.md @@ -0,0 +1,23 @@ +# Server memory follow-up + +Joining a persisted session issues a client id and makes its journal dirty, even if nobody edits +the document. Previously, the last client leaving caused a full snapshot rewrite for that id. +For image-heavy documents this creates large temporary allocations and unnecessary disk writes; +an allocator can retain the freed memory after the room closes. + +On idle, the server now flushes the journal and only compacts when the document sequence is newer +than the stored snapshot, or when flushing failed and a snapshot must be attempted as a fallback. +Issued ids and refusals remain durable in the journal. Existing size/count compaction thresholds +remain in effect, and edited documents still compact when the last participant leaves. + +Snapshot encoding also appends the versioned postcard body directly to the snapshot header's +buffer, then fills the checksum. It no longer holds a separate complete encoded body alongside +the finished snapshot. The bytes, generation, checksum and file format are unchanged. + +Regression coverage uses synthetic documents: six read-only sessions across store reopenings +must preserve increasing client ids without rewriting the snapshot; exact legacy framing and +checksum must match; the binary message body must be unchanged when appended to a prefix. +Existing crash recovery, damaged-input and edit compaction tests continue to apply. + +This reduces unnecessary temporary allocations. It does not promise that the operating system +immediately reclaims every freed allocation, or that all large-document memory costs are solved. From 5a3dddc46fe1064b6e2e2b8ff04d220d0c0f9706 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 22 Oct 2026 21:29:58 +0200 Subject: [PATCH 13/54] M9: fall back to the Noto symbol and math fonts on Linux (#1156) Signs such as U+2316 and U+2912 are in no text face. The fallback list had the macOS and Windows symbol fonts (Apple Symbols, Segoe UI Symbol) but nothing a Linux install has, so they drew as empty boxes there even with Noto Sans Symbols 2 or Noto Sans Math installed. Adds Noto Sans Symbols, Noto Sans Symbols 2 (and the Symbols2 spelling some packages use) and Noto Sans Math after them. --- crates/text/src/behaviour_tests.rs | 22 ++++++++++++++++++++++ crates/text/src/fontdb.rs | 10 ++++++++-- crates/text/src/fontdb_tests.rs | 13 +++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/crates/text/src/behaviour_tests.rs b/crates/text/src/behaviour_tests.rs index b3cb4884..95491fed 100644 --- a/crates/text/src/behaviour_tests.rs +++ b/crates/text/src/behaviour_tests.rs @@ -265,6 +265,28 @@ fn hangul_comes_from_a_korean_font_when_one_is_installed() { } } +#[test] +fn symbols_come_from_a_symbol_font_when_one_is_installed() { + // ⌖ (U+2316) and ⤒ (U+2912) are in no Latin text face. With a symbol font that has them (Apple + // Symbols on macOS, Segoe UI Symbol on Windows, Noto Sans Symbols 2 and Noto Sans Math on + // Linux) the fallback draws them instead of the missing-glyph box (#1156). Each character + // skips on a machine with no installed fallback family that has it. + let Some(f) = face() else { return }; + let families = ["Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols", "Noto Sans Symbols 2", "Noto Sans Math"]; + for c in ['\u{2316}', '\u{2912}'] { + if f.has_char(c) || !families.iter().any(|k| find_face(k, "Regular").is_some_and(|k| k.has_char(c))) { + continue; + } + let t = text(&format!("A {c}"), TextAutoResize::WidthAndHeight); + let l = layout(&t, None, None); + let drawn: Vec<_> = l.glyphs.iter().filter(|g| g.char_start == 2).collect(); + assert!(!drawn.is_empty(), "U+{:04X} has a glyph", c as u32); + for g in drawn { + assert!(g.face.has_char(c), "U+{:04X} drawn by {} {}, which lacks it", c as u32, g.face.family, g.face.style); + } + } +} + #[test] fn break_opportunities() { // (before, after, may break between, derivation) diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 604f3eee..c3ae3abd 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -405,8 +405,10 @@ fn names(f: &skrifa::FontRef) -> (Option, Option) { (family, style) } -/// Families tried, in order, for characters a text's own font lacks. -const FALLBACK_FAMILIES: [&str; 20] = [ +/// Families tried, in order, for characters a text's own font lacks. The symbol fonts come last: +/// Apple Symbols (macOS), Segoe UI Symbol (Windows) and the Noto symbol and math fonts (Linux), for +/// signs such as ⌖ and ⤒ that no text face has. +const FALLBACK_FAMILIES: [&str; 24] = [ "Inter", "Helvetica Neue", "Arial", @@ -427,6 +429,10 @@ const FALLBACK_FAMILIES: [&str; 20] = [ "Noto Sans Arabic", "Apple Symbols", "Segoe UI Symbol", + "Noto Sans Symbols", + "Noto Sans Symbols 2", + "Noto Sans Symbols2", + "Noto Sans Math", ]; /// A system font file entry not yet loaded. diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs index f2456d28..f227910e 100644 --- a/crates/text/src/fontdb_tests.rs +++ b/crates/text/src/fontdb_tests.rs @@ -192,6 +192,19 @@ fn fallback_faces_follow_the_runs_style() { } } +#[test] +fn every_platform_has_a_symbol_font_in_the_fallbacks() { + // Symbols such as ⌖ (U+2316) and ⤒ (U+2912) are in no text face (#1156): macOS has Apple + // Symbols, Windows Segoe UI Symbol, and Linux the Noto symbol and math fonts (fontconfig names + // "Noto Sans Symbols 2"; some packages name it "Noto Sans Symbols2"). + for family in ["Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols", "Noto Sans Symbols 2", "Noto Sans Symbols2", "Noto Sans Math"] { + assert!(FALLBACK_FAMILIES.contains(&family), "{family} is a fallback"); + } + // Text faces come first: a symbol font only draws what they lack. + let at = |f: &str| FALLBACK_FAMILIES.iter().position(|x| *x == f).unwrap(); + assert!(at("Noto Sans") < at("Noto Sans Symbols") && at("DejaVu Sans") < at("Noto Sans Math")); +} + #[cfg(all(unix, not(target_os = "macos")))] #[test] fn user_font_dirs_follow_xdg_data_home() { From c7d2a5f019d0ec8fd1fef01f344ac0b6042b37e8 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 22 Oct 2026 21:46:12 +0200 Subject: [PATCH 14/54] M9: the symbol test also looks for the Noto Sans Symbols2 spelling (#1156) --- crates/text/src/behaviour_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/text/src/behaviour_tests.rs b/crates/text/src/behaviour_tests.rs index 95491fed..4db53184 100644 --- a/crates/text/src/behaviour_tests.rs +++ b/crates/text/src/behaviour_tests.rs @@ -272,7 +272,7 @@ fn symbols_come_from_a_symbol_font_when_one_is_installed() { // Linux) the fallback draws them instead of the missing-glyph box (#1156). Each character // skips on a machine with no installed fallback family that has it. let Some(f) = face() else { return }; - let families = ["Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols", "Noto Sans Symbols 2", "Noto Sans Math"]; + let families = ["Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols", "Noto Sans Symbols 2", "Noto Sans Symbols2", "Noto Sans Math"]; for c in ['\u{2316}', '\u{2912}'] { if f.has_char(c) || !families.iter().any(|k| find_face(k, "Regular").is_some_and(|k| k.has_char(c))) { continue; From 6cdc36f6c3672939ee34145455e73dd17479c698 Mon Sep 17 00:00:00 2001 From: saturngod Date: Thu, 22 Oct 2026 16:03:23 -0500 Subject: [PATCH 15/54] Fix stroke-only boolean operand outlines --- crates/render/src/boolean_cache_tests.rs | 100 +++++++++++++++++++++++ crates/render/src/draw.rs | 36 +++++++- 2 files changed, 132 insertions(+), 4 deletions(-) diff --git a/crates/render/src/boolean_cache_tests.rs b/crates/render/src/boolean_cache_tests.rs index 08a89d3a..6e10f746 100644 --- a/crates/render/src/boolean_cache_tests.rs +++ b/crates/render/src/boolean_cache_tests.rs @@ -153,3 +153,103 @@ fn deep_boolean_nesting_stops_at_the_outline_depth_limit() { d.create(inner, NodeKind::Rectangle, "R", 40.0, 10.0, 20.0, 20.0).unwrap(); assert!((outline(&d, b).area().abs() - 2.0 * 20.0 * 20.0).abs() < 0.5); } + +#[test] +fn stroke_only_boolean_operands_keep_their_holes() { + use figcraft_doc::StrokeAlign; + use figcraft_geom::Point; + + for kind in [NodeKind::Polygon { count: 6 }, NodeKind::Rectangle, NodeKind::Line] { + for align in [StrokeAlign::Inside, StrokeAlign::Center, StrokeAlign::Outside] { + let mut d = Document::new("stroke operands"); + let page = d.first_page().unwrap(); + let b = d.create(page, NodeKind::BooleanOp { op: BoolOp::Subtract }, "B", 0.0, 0.0, 100.0, 100.0).unwrap(); + d.get_mut(b).unwrap().fills = vec![Paint::solid(Color::BLACK)]; + // A nested group exercises the same path as a direct operand. + let g = d.create(b, NodeKind::Group, "G", 0.0, 0.0, 100.0, 100.0).unwrap(); + let id = d.create(g, kind.clone(), "stroke", 10.0, 10.0, 80.0, 80.0).unwrap(); + let n = d.get_mut(id).unwrap(); + let mut hidden_fill = Paint::solid(Color::BLACK); + hidden_fill.visible = false; + n.fills = vec![hidden_fill]; + n.strokes = vec![Paint::solid(Color::BLACK)]; + n.stroke.weight = 6.0; + n.stroke.align = align; + let path = outline(&d, b); + assert_eq!(path.winding(Point::new(50.0, 45.0)), 0, "{kind:?} {align:?}: empty interior"); + assert!(path.area().abs() > 100.0, "{kind:?} {align:?}: stroke survives"); + assert!(!dark(&render(&d, page), 50, 45)); + if !matches!(kind, NodeKind::Line) { + // Changing only stroke weight invalidates the boolean result. + let area = path.area().abs(); + d.get_mut(id).unwrap().stroke.weight = 3.0; + assert!(outline(&d, b).area().abs() < area); + d.get_mut(id).unwrap().fills[0].visible = true; + assert_ne!(outline(&d, b).winding(Point::new(50.0, 45.0)), 0, "visible fills keep the interior"); + } + } + } +} + +#[test] +fn boolean_operands_preserve_side_borders_and_container_children() { + use figcraft_doc::StrokeAlign; + use figcraft_geom::Point; + for kind in [NodeKind::Rectangle, NodeKind::Frame] { + let mut d = Document::new("borders"); + let page = d.first_page().unwrap(); + let b = d.create(page, NodeKind::BooleanOp { op: BoolOp::Union }, "B", 0.0, 0.0, 100.0, 100.0).unwrap(); + let id = d.create(b, kind.clone(), "operand", 0.0, 0.0, 100.0, 100.0).unwrap(); + let n = d.get_mut(id).unwrap(); + n.fills.clear(); + n.strokes = vec![Paint::solid(Color::BLACK)]; + n.stroke.weight = 8.0; + n.stroke.align = StrokeAlign::Inside; + n.stroke.sides = Some([8.0, 0.0, 0.0, 0.0]); + if matches!(kind, NodeKind::Frame) { + d.create(id, NodeKind::Rectangle, "child", 40.0, 40.0, 20.0, 20.0).unwrap(); + } + let p = outline(&d, b); + assert_ne!(p.winding(Point::new(50.0, 4.0)), 0, "top border"); + assert_eq!(p.winding(Point::new(98.0, 50.0)), 0, "no right border"); + assert_eq!(p.winding(Point::new(50.0, 98.0)), 0, "no bottom border"); + if matches!(kind, NodeKind::Frame) { + assert_ne!(p.winding(Point::new(50.0, 50.0)), 0, "child retained"); + } + d.get_mut(id).unwrap().stroke.sides = Some([0.0, 8.0, 0.0, 0.0]); + let p = outline(&d, b); + assert_eq!(p.winding(Point::new(50.0, 4.0)), 0, "side edit removes top"); + assert_ne!(p.winding(Point::new(98.0, 50.0)), 0, "side edit adds right"); + d.get_mut(id).unwrap().stroke.sides = None; + assert_ne!(outline(&d, b).winding(Point::new(50.0, 4.0)), 0, "uniform container border"); + } +} + +#[test] +fn stroke_only_text_operands_use_glyphs_instead_of_the_text_box() { + use figcraft_doc::{StrokeAlign, TextData}; + let mut d = Document::new("text strokes"); + let page = d.first_page().unwrap(); + let b = d.create(page, NodeKind::BooleanOp { op: BoolOp::Union }, "B", 0.0, 0.0, 400.0, 100.0).unwrap(); + let mut text = TextData::new("Hi"); + text.style.font_size = 32.0; + let id = d.create(b, NodeKind::Text(text), "text", 0.0, 0.0, 400.0, 100.0).unwrap(); + let n = d.get_mut(id).unwrap(); + n.fills.clear(); + n.strokes = vec![Paint::solid(Color::BLACK)]; + n.stroke.weight = 2.0; + n.stroke.align = StrokeAlign::Center; + let glyphs = outline(&d, id); + let result = outline(&d, b); + assert_eq!(result.elements().is_empty(), glyphs.elements().is_empty()); + if !glyphs.elements().is_empty() { + let expected = glyphs.bounding_box().inflate(3.0, 3.0); + let actual = result.bounding_box(); + assert!(actual.x0 >= expected.x0 && actual.x1 <= expected.x1); + assert!(actual.y0 >= expected.y0 && actual.y1 <= expected.y1); + assert!(actual.x1 < 200.0, "the unused text box must not contribute a border"); + } + let NodeKind::Text(text) = &mut d.get_mut(id).unwrap().kind else { panic!() }; + text.characters = " ".into(); + assert!(outline(&d, b).elements().is_empty(), "spaces have no stroked glyphs"); +} diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 63b38220..a80cd063 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -205,6 +205,18 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc { return Arc::new(o); } } + cached_stroke_outline(n, path, closed) +} + +/// A non-zero stroke outline, shared by painting and boolean operands. +fn cached_stroke_outline(n: &Node, path: &BezPath, closed: bool) -> Arc { + let props = &n.stroke; + if let Some(sides) = props.sides + && matches!(n.kind, NodeKind::Rectangle | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. }) + && n.corners.uniform() == Some(0.0) + { + return Arc::new(figcraft_pathops::rect_side_strokes(n.width, n.height, sides, props.align)); + } let mut h = std::collections::hash_map::DefaultHasher::new(); hash_path(path, &mut h); (props.weight.to_bits(), props.align as u8, props.cap_start as u8, props.cap_end as u8, props.join as u8, props.miter_limit.to_bits(), closed) @@ -276,7 +288,7 @@ fn node_outline_at(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, F .filter_map(|c| doc.node(*c)) .filter(|c| c.visible) .filter_map(|c| { - let (mut p, _) = subtree_outline(doc, c, depth + 1)?; + let (mut p, _) = subtree_outline_at(doc, c, depth + 1, true)?; p.apply_affine(c.transform); Some((p, c.bool_op.unwrap_or(*op))) }) @@ -300,6 +312,11 @@ fn node_outline_at(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, F /// The union outline of a node and its descendants (for booleans of groups/frames). pub fn subtree_outline(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, FillRule)> { + subtree_outline_at(doc, n, depth, false) +} + +/// Boolean operands with only a stroke contribute the painted band, not their interior. +fn subtree_outline_at(doc: &Document, n: &Node, depth: usize, stroke_only: bool) -> Option<(BezPath, FillRule)> { if depth > 64 { return None; } @@ -308,21 +325,32 @@ pub fn subtree_outline(doc: &Document, n: &Node, depth: usize) -> Option<(BezPat let mut out = BezPath::new(); if n.kind.is_frame_like() && let Some(p) = figcraft_doc::geometry::shape_path(n) - && n.fills.iter().any(|f| f.visible) { - out.extend(p.elements().iter().copied()); + if n.fills.iter().any(|f| f.visible) { + out.extend(p.elements().iter().copied()); + } else if stroke_only && n.has_visible_stroke() { + out.extend(cached_stroke_outline(n, &p, true).elements().iter().copied()); + } } for c in n.children.iter().filter_map(|c| doc.node(*c)) { if !c.visible { continue; } - if let Some((mut p, _)) = subtree_outline(doc, c, depth + 1) { + if let Some((mut p, _)) = subtree_outline_at(doc, c, depth + 1, stroke_only) { p.apply_affine(c.transform); out.extend(p.elements().iter().copied()); } } Some((out, FillRule::Winding)) } + _ if stroke_only && !n.fills.iter().any(|p| p.visible) && n.has_visible_stroke() => { + let (path, _) = node_outline_at(doc, n, depth)?; + let path = if matches!(n.kind, NodeKind::Text(_)) { path } else { figcraft_doc::geometry::stroke_geometry(n).unwrap_or(path) }; + let closed = figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. }); + // Boolean folding uses non-zero winding; the canvas stroke cache also contains + // even-odd rectangle rings, so use the winding-correct stroke outline here. + Some(((*cached_stroke_outline(n, &path, closed)).clone(), FillRule::Winding)) + } _ => node_outline_at(doc, n, depth), } } From 7a2568fe55a26fd609dc4335343162e16f498187 Mon Sep 17 00:00:00 2001 From: vetlucasmartins Date: Sat, 24 Oct 2026 00:50:53 +0100 Subject: [PATCH 16/54] tailwind: emit @theme static; fix assertion precedence in tests Tailwind v4 drops theme variables no utility uses, but page code also reads tokens through var() and (--name) utilities, so the export keeps them all. Two test assertions mixed && and || without parentheses. --- crates/engine/src/cmd/variables_io.rs | 4 +++- crates/engine/src/tailwind_tests.rs | 6 +++--- docs/mcp.md | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/variables_io.rs b/crates/engine/src/cmd/variables_io.rs index c74c46fe..eee312c2 100644 --- a/crates/engine/src/cmd/variables_io.rs +++ b/crates/engine/src/cmd/variables_io.rs @@ -80,7 +80,9 @@ fn css(doc: &Document, colls: &[&Collection], tailwind: bool) -> String { let attr = css_var_name(&c.name); for (i, m) in c.modes.iter().enumerate() { let sel = match i { - 0 if tailwind => "@theme".to_string(), + // `static`: Tailwind otherwise drops theme variables no utility uses, and pages also + // read them through `var()` and `(--name)` utilities. + 0 if tailwind => "@theme static".to_string(), 0 => ":root".to_string(), _ => format!("[data-{}=\"{}\"]", attr.trim_start_matches('-'), css_safe(&m.name)), }; diff --git a/crates/engine/src/tailwind_tests.rs b/crates/engine/src/tailwind_tests.rs index 615a6cec..3080a15d 100644 --- a/crates/engine/src/tailwind_tests.rs +++ b/crates/engine/src/tailwind_tests.rs @@ -66,7 +66,7 @@ fn per_side_strokes_are_borders_on_those_sides() { let css = ex(&mut s, "inspect.code", json!({"id": f, "language": "css"}))["code"].as_str().unwrap_or_default().to_string(); assert!(css.contains("border-bottom: 1px solid") && !css.contains("border:"), "{css}"); let tw = code(&mut s, f, json!({})); - assert!(tw.contains("border-b ") && tw.contains("border-[#262a31]") || tw.contains("border-[#262A31]"), "{tw}"); + assert!(tw.contains("border-b ") && (tw.contains("border-[#262a31]") || tw.contains("border-[#262A31]")), "{tw}"); // Without per-side weights it is one border. ex(&mut s, "node.set", json!({"id": f, "props": {"strokeSides": null, "strokeWeight": 2}})); let css = ex(&mut s, "inspect.code", json!({"id": f, "language": "css"}))["code"].as_str().unwrap_or_default().to_string(); @@ -140,7 +140,7 @@ fn variables_export_with_units_and_as_a_tailwind_theme() { let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap_or_default().to_string(); assert!(css.contains("--card: 12px;") && css.contains("--fade: 0.5;"), "{css}"); let tw = ex(&mut s, "variable.export", json!({"format": "tailwind"}))["text"].as_str().unwrap_or_default().to_string(); - for want in ["@import \"tailwindcss\";", "@theme {", "--color-accent: #3ddc97;", "--radius-card: 12px;", "--fade: 0.5;"] { + for want in ["@import \"tailwindcss\";", "@theme static {", "--color-accent: #3ddc97;", "--radius-card: 12px;", "--fade: 0.5;"] { assert!(tw.to_lowercase().contains(&want.to_lowercase()), "{want} in {tw}"); } } @@ -192,7 +192,7 @@ fn slashed_layer_names_and_phone_widths_merge_right() { assert!(button.contains("self-stretch") && button.contains("lg:self-auto"), "the slashed name keeps its mobile classes: {out}"); assert!(!out.contains("max-w-[342px] ") || out.contains("lg:max-w-[342px]"), "no phone-width cap below lg: {out}"); assert!(out.contains("Only in the mobile frame") && out.contains("Tag"), "{out}"); - let divider = out.lines().filter(|l| l.contains("{"See examples"}`, with each import once at the top. A design system mapped to shadcn/ui this way exports pages that use the project's own components. Bound variables become theme utilities (`bg-accent`, `rounded-card`, `text-muted`) that `variable.export {format: "tailwind"}` - declares: `@import "tailwindcss";` and an `@theme` block named by namespace (`--color-*`, + declares: `@import "tailwindcss";` and an `@theme static` block (every token is emitted, also those only read through `var()`) named by namespace (`--color-*`, `--radius-*`, `--text-*`, `--spacing-*`, `--font-*`; a number's namespace and unit come from what layers bind it to), other modes on `[data-=""]`. With `mobile: ` (the same layer names, mobile layout) the code is mobile first: the mobile frame's classes, the From 9361b230b55773782141151ee4f8d2030c7073ff Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 12:57:43 +0700 Subject: [PATCH 17/54] #1011: explicit imports, a method reference --- crates/engine/src/tailwind.rs | 4 ++-- crates/engine/src/tailwind_tests.rs | 2 +- crates/engine/src/token_roles.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/tailwind.rs b/crates/engine/src/tailwind.rs index 2a6453e4..d52aa29c 100644 --- a/crates/engine/src/tailwind.rs +++ b/crates/engine/src/tailwind.rs @@ -7,7 +7,7 @@ use std::collections::{BTreeMap, HashMap}; -use figcraft_doc::*; +use figcraft_doc::{Document, NodeId, NodeKind, Variable, VariableId, VariableType}; use crate::codegen::{CodeLang, Ctx, Markup, css_var_name, css_var_of, num}; use crate::component_codegen::component_type_name; @@ -454,7 +454,7 @@ pub fn code(doc: &Document, id: NodeId, mobile: Option) -> String { // ponytail: layers only on mobile are listed, not merged in; their place in the tree is ambiguous. let only_mobile: Vec = small .iter() - .flat_map(|m| m.keys()) + .flat_map(BTreeMap::keys) .filter(|p| !rules.iter().any(|(d, _, _)| d == *p)) .map(|p| p.trim_start_matches('/').split('/').map(|seg| seg.rsplit_once('#').map_or(seg, |(n, _)| n)).collect::>().join(" › ")) .take(20) diff --git a/crates/engine/src/tailwind_tests.rs b/crates/engine/src/tailwind_tests.rs index 3080a15d..224c4928 100644 --- a/crates/engine/src/tailwind_tests.rs +++ b/crates/engine/src/tailwind_tests.rs @@ -5,7 +5,7 @@ use crate::Session; use crate::components::tests::{ex, idv}; use crate::tailwind::{classes, merge}; -use figcraft_doc::*; +use figcraft_doc::NodeId; use serde_json::json; fn code(s: &mut Session, id: NodeId, extra: serde_json::Value) -> String { diff --git a/crates/engine/src/token_roles.rs b/crates/engine/src/token_roles.rs index dc609640..3827ecbe 100644 --- a/crates/engine/src/token_roles.rs +++ b/crates/engine/src/token_roles.rs @@ -6,7 +6,7 @@ use std::collections::HashMap; -use figcraft_doc::*; +use figcraft_doc::{Document, Variable, VariableId, VariableType, VariableValue}; use crate::codegen::num; From 647e5f17d905fa4f84032e9202afc46085c1e71c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 12:57:52 +0700 Subject: [PATCH 18/54] #1011: single-call Tailwind variables, rem only where it converts --- crates/engine/src/codegen.rs | 5 +++-- crates/engine/src/tailwind.rs | 15 ++++++++++++++- crates/engine/src/tailwind_tests.rs | 7 +++++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index c0b8e891..18db85c8 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -99,9 +99,10 @@ impl CodeLang { CodeLang::Compose => "Compose", } } - /// Languages whose lengths are CSS pixels (they can be shown in rem). + /// Languages whose lengths are CSS pixels that can be shown in rem (not Tailwind: its lengths + /// are utility classes). pub fn is_web(self) -> bool { - matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos | CodeLang::Tailwind) + matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos) } pub fn from_id(s: &str) -> Option { if s.eq_ignore_ascii_case("tsx") || s.eq_ignore_ascii_case("jsx") { diff --git a/crates/engine/src/tailwind.rs b/crates/engine/src/tailwind.rs index d52aa29c..70a562a9 100644 --- a/crates/engine/src/tailwind.rs +++ b/crates/engine/src/tailwind.rs @@ -105,9 +105,22 @@ fn arb(v: &str) -> String { format!("[{}]", v.trim().replace('_', "\\_").replace(' ', "_")) } -/// The custom property a `var(--name, fallback)` value reads. +/// The custom property a `var(--name, fallback)` value reads, when that one call is the whole +/// value (not `var(--a, 4px) 8px`, four corners of which only the first is a variable). fn var_name(v: &str) -> Option<&str> { let inner = v.trim().strip_prefix("var(")?; + let mut depth = 1usize; + let close = inner.char_indices().find_map(|(i, c)| { + match c { + '(' => depth += 1, + ')' => depth -= 1, + _ => {} + } + (depth == 0).then_some(i) + })?; + if close + 1 != inner.len() { + return None; + } Some(inner.split([',', ')']).next()?.trim()) } diff --git a/crates/engine/src/tailwind_tests.rs b/crates/engine/src/tailwind_tests.rs index 224c4928..9e41fecc 100644 --- a/crates/engine/src/tailwind_tests.rs +++ b/crates/engine/src/tailwind_tests.rs @@ -333,3 +333,10 @@ fn review_desktop_without_fill_clears_a_mobile_image_or_gradient() { let got = merge(Some(&[("background".to_string(), "[background:url(image.png)_center_/_cover]".to_string())]), &[]); assert!(got.contains(&"lg:bg-none".to_string()) && got.contains(&"lg:bg-transparent".to_string()), "{got:?}"); } + +#[test] +fn per_corner_radii_with_a_variable_first_keep_every_corner() { + let got: Vec = + classes("border-radius: var(--radius-a, 4px) 8px 8px 8px;\nborder-radius: var(--radius-card, 4px);\n").into_iter().map(|(_, c)| c).collect(); + assert_eq!(got, ["rounded-[var(--radius-a,_4px)_8px_8px_8px]", "rounded-card"]); +} From 9b78ff8c4a17ac78a253ca1545e0d5b48aa2383f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 13:02:24 +0700 Subject: [PATCH 19/54] #989: explicit imports, the .fig test helper named main_component --- crates/format/src/fig/layer_cap_tests.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/format/src/fig/layer_cap_tests.rs b/crates/format/src/fig/layer_cap_tests.rs index 2c562ba7..e7bb769c 100644 --- a/crates/format/src/fig/layer_cap_tests.rs +++ b/crates/format/src/fig/layer_cap_tests.rs @@ -5,7 +5,7 @@ use figcraft_doc::{Document, NodeKind}; use super::kiwi::{Kv, Schema}; -use super::*; +use super::{MAX_FIG_NODES, export, import, layer_budget, map}; type Fields = Vec<(&'static str, Kv<'static>)>; @@ -36,7 +36,7 @@ fn instance(id: u64, page: u64, main: u64) -> Kv<'static> { } /// A main `id` of `layers` rectangles under `parent`. -fn main(id: u64, parent: u64, name: &str, layers: u64) -> Vec> { +fn main_component(id: u64, parent: u64, name: &str, layers: u64) -> Vec> { let mut out = vec![node(id, Some(parent), "SYMBOL", name, vec![("size", xy(10.0, 10.0))])]; out.extend((1..=layers).map(|i| node(id * 10_000 + i, Some(id), "RECTANGLE", "R", vec![("size", xy(1.0, 1.0))]))); out @@ -50,7 +50,7 @@ fn library_message(count: u64, main_layers: u64) -> Kv<'static> { node(2, Some(1), "CANVAS", "Screens", vec![]), node(3, Some(1), "CANVAS", "Internal", vec![("internalOnly", Kv::Bool(true))]), ]; - nodes.extend(main(20, 3, "Library main", main_layers)); + nodes.extend(main_component(20, 3, "Library main", main_layers)); nodes.extend((0..count).map(|i| instance(1_000 + i, 2, 20))); o(vec![("nodeChanges", Kv::Array(nodes))]) } @@ -96,7 +96,7 @@ fn instances_of_a_main_the_cap_kept_out_say_so() { // file has it. let mut nodes = vec![node(1, None, "DOCUMENT", "Document", vec![]), node(2, Some(1), "CANVAS", "Page", vec![])]; nodes.extend((0..20).map(|i| instance(100 + i, 2, 900))); - nodes.extend(main(900, 2, "Local main", 10)); + nodes.extend(main_component(900, 2, "Local main", 10)); let (doc, warnings) = import_at(&o(vec![("nodeChanges", Kv::Array(nodes))]), 60); assert!(warnings.iter().any(|w| w.starts_with("instances whose component was past the layer limit were imported as frames")), "{warnings:?}"); assert!(!warnings.iter().any(|w| w.contains("aren't in the file")), "{warnings:?}"); @@ -115,8 +115,8 @@ fn swap_defaults_the_cap_kept_out_say_so() { ]); let mut nodes = vec![node(1, None, "DOCUMENT", "Document", vec![]), node(2, Some(1), "CANVAS", "Page", vec![])]; nodes.push(node(50, Some(2), "SYMBOL", "Card", vec![("size", xy(10.0, 10.0)), ("componentPropDefs", Kv::Array(vec![swap]))])); - nodes.extend(main(60, 2, "Filler", 40)); - nodes.extend(main(900, 2, "Icon main", 10)); + nodes.extend(main_component(60, 2, "Filler", 40)); + nodes.extend(main_component(900, 2, "Icon main", 10)); let (doc, warnings) = import_at(&o(vec![("nodeChanges", Kv::Array(nodes))]), 30); assert!(doc.nodes().any(|n| n.name == "Card")); assert!(!doc.nodes().any(|n| n.name == "Icon main")); From b6489aba3bdd6d22d3509e5e563dad45aff1a76e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 13:02:25 +0700 Subject: [PATCH 20/54] #989: .fig pages keep room for their top-level layers past large library copies --- crates/format/src/fig/layer_cap_tests.rs | 10 ++++++++ crates/format/src/fig/map.rs | 29 +++++++++++++++++------- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/crates/format/src/fig/layer_cap_tests.rs b/crates/format/src/fig/layer_cap_tests.rs index e7bb769c..1b869ea2 100644 --- a/crates/format/src/fig/layer_cap_tests.rs +++ b/crates/format/src/fig/layer_cap_tests.rs @@ -136,3 +136,13 @@ fn a_whole_file_past_the_cap_keeps_its_library_links_and_says_so_once() { assert!(!warnings.iter().any(|w| w.contains("aren't in the file")), "{warnings:?}"); assert!(linked_to(&doc, "Library main") >= 190, "{warnings:?}"); } + +#[test] +fn a_library_main_larger_than_the_cap_leaves_the_pages_room() { + // A main of 300 layers with room for 100: mapped first, it took the whole budget and the page + // came out empty. + let (doc, warnings) = import_at(&library_message(5, 300), 100); + let page = doc.nodes().find(|n| n.name == "Screens").expect("the page"); + let top = page.children.len(); + assert!(top >= 1, "the page kept {top} layers: {warnings:?}"); +} diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 36348ded..ca491e0b 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -404,6 +404,9 @@ struct Mapper<'a> { /// Most layers produced or scanned, instance expansion included; the one place the cap comes /// from (see [`super::layer_budget`]). max_nodes: usize, + /// Layers kept back for the visible pages' top level while library copies are mapped, so the + /// copies can't take the whole budget and leave the pages empty. + reserved: usize, /// Vector networks, each built once: the layers that show one (every instance of a main /// holding it) name it by `vectorNetworkRef`. Kept out of the JSON, where a large file's /// networks took gigabytes. @@ -481,6 +484,7 @@ impl<'a> Mapper<'a> { referenced_mains: BTreeSet::new(), mapped_mains: BTreeSet::new(), max_nodes, + reserved: 0, networks: Vec::new(), network_ids: HashMap::new(), } @@ -563,6 +567,11 @@ impl<'a> Mapper<'a> { scope } + /// How many layers may be produced now: the cap, less what is kept for the visible pages. + fn budget(&self) -> usize { + self.max_nodes.saturating_sub(self.reserved) + } + /// The DOCUMENT with its pages. fn document(&mut self) -> Value { let doc_id = self.nodes.iter().find(|(_, n)| n.get("type").and_then(Kv::str) == Some("DOCUMENT")).map(|(id, _)| id.clone()); @@ -589,11 +598,15 @@ impl<'a> Mapper<'a> { // last layers are skipped rather than the mains every instance of them needs (#981). let visible_pages: Vec = visible.iter().map(|(p, _)| p.clone()).collect(); let required_mains = self.referenced_component_mains(&visible_pages); + // The pages and their top-level layers, up to half the cap, stay for the pages. + let top: usize = visible_pages.iter().map(|p| 1 + self.children(p).len()).sum(); + self.reserved = top.min(self.max_nodes / 2); for (id, canvas) in internal { if let Some(context) = self.library_context(&id, canvas, &required_mains) { self.library_contexts.push(context); } } + self.reserved = 0; let out: Vec = visible.into_iter().map(|(p, n)| self.page(&p, n)).collect(); if out.is_empty() { self.warn("the file has no pages"); @@ -838,7 +851,7 @@ impl<'a> Mapper<'a> { self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped")); return None; } - if self.count >= self.max_nodes { + if self.count >= self.budget() { let max = self.max_nodes; self.warn(format!("the file has more than {max} layers; the rest were skipped")); return None; @@ -875,7 +888,7 @@ impl<'a> Mapper<'a> { let connector_snapshot = own == "CONNECTOR" && self.connector_snapshot_supported(&view); // Only a connector with a saved label needs the frame wrapper; an unlabelled one stays a single vector. let connector_terminal = - connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= self.max_nodes); + connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= self.budget()); // A supported connector snapshot has its own note below instead of the generic stand-in one. let stand = stand_in(own).filter(|_| !connector_snapshot); let ty = match own { @@ -1023,7 +1036,7 @@ impl<'a> Mapper<'a> { fn shape_with_text(&mut self, o: &mut Obj, v: &View<'a>, own: &str, rest_id: &str) -> Option { let shape = v.str("shapeWithTextType").unwrap_or("SQUARE"); let outlined = !v.list("fillGeometry").is_empty() || !v.list("strokeGeometry").is_empty(); - if own != "SHAPE_WITH_TEXT" || matches!(shape, "SQUARE" | "ROUNDED_RECTANGLE") || !outlined || self.count >= self.max_nodes { + if own != "SHAPE_WITH_TEXT" || matches!(shape, "SQUARE" | "ROUNDED_RECTANGLE") || !outlined || self.count >= self.budget() { return None; } let mut s = Obj::new(); @@ -1055,7 +1068,7 @@ impl<'a> Mapper<'a> { /// layer filling it, inset a little; centred unless it's a note. fn inner_text(&mut self, o: &Obj, v: &View<'a>, rest_id: &str, note: bool) -> Option { let chars = own_text(v)?; - if self.count >= self.max_nodes { + if self.count >= self.budget() { return None; } self.count += 1; @@ -1190,7 +1203,7 @@ impl<'a> Mapper<'a> { out.insert("opacity".into(), json!(root_opacity * line_opacity)); } if connector_has_saved_label(v) { - let reason = if self.count >= self.max_nodes { "node budget" } else { "depth limit" }; + let reason = if self.count >= self.budget() { "node budget" } else { "depth limit" }; self.warn(format!("connector label was skipped: {reason} prevents nested snapshot children")); } } @@ -1220,7 +1233,7 @@ impl<'a> Mapper<'a> { /// Snapshot the straight, manual connector contract carried by its generated children. fn connector_snapshot_children(&mut self, v: &View<'a>, rest_id: &str) -> Vec { - if self.count >= self.max_nodes { + if self.count >= self.budget() { self.warn(format!("the file has more than {} layers; connector snapshot children were skipped", self.max_nodes)); return Vec::new(); } @@ -1283,7 +1296,7 @@ impl<'a> Mapper<'a> { }; let background_view = View::of(background).with([background_style]); if !background_view.list("fillPaints").is_empty() { - if self.count >= self.max_nodes { + if self.count >= self.budget() { self.warn(format!("the file has more than {} layers; connector label background was skipped", self.max_nodes)); } else { let mut rect = Obj::new(); @@ -1311,7 +1324,7 @@ impl<'a> Mapper<'a> { transform: Affine, view: &View<'a>, ) { - if self.count >= self.max_nodes { + if self.count >= self.budget() { self.warn(format!("the file has more than {} layers; connector label was skipped", self.max_nodes)); return; } From a6949ec2e9bf2898af8c7f4f0941acce82e63630 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 13:02:34 +0700 Subject: [PATCH 21/54] #1010: a zip export stops at 256 MB of files, which are all in memory at once --- crates/engine/src/cmd/file.rs | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 53f51cc2..d74b8dae 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -1166,6 +1166,20 @@ fn size_param(cmd: &str, p: &Value) -> Result> { /// Most files one `file.exportLayers` writes. const MAX_EXPORT_FILES: usize = 1000; +/// Most bytes the files of one zip export add up to: the files, the archive and its base64 copy +/// are all in memory at once (in the browser too). +const MAX_ZIP_BYTES: usize = 256 << 20; + +/// Keep `bytes` as zip member `name`, unless the members would pass `limit` bytes together. +fn add_member<'a>(members: &mut Vec<(&'a str, Vec)>, name: &'a str, bytes: Vec, limit: usize) -> Result<()> { + let total = members.iter().map(|(_, b)| b.len()).sum::().saturating_add(bytes.len()); + if total > limit { + let mb = limit >> 20; + return Err(bad("file.exportLayers", format!("the files would pass {mb} MB as one zip: export fewer layers at a time, or to a folder"))); + } + members.push((name, bytes)); + Ok(()) +} /// One file of a layer export: the layer, its setting and the file name. pub struct ExportJob { @@ -1259,7 +1273,7 @@ fn export_layers(s: &mut Session, p: &Value) -> Result { write(&path, &bytes)?; f["path"] = json!(path); } - None if zip => members.push((job.file.as_str(), bytes)), + None if zip => add_member(&mut members, job.file.as_str(), bytes, MAX_ZIP_BYTES)?, None => f["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&bytes)), } files.push(f); @@ -1803,3 +1817,18 @@ mod staging_write_tests { let _ = std::fs::remove_dir_all(&d); } } + +#[cfg(test)] +mod zip_cap_tests { + use super::add_member; + + #[test] + fn zip_members_stop_at_the_limit_and_keep_what_fit() { + let mut members = Vec::new(); + add_member(&mut members, "a.png", vec![0; 6], 10).unwrap(); + add_member(&mut members, "b.png", vec![0; 4], 10).unwrap(); + let err = add_member(&mut members, "c.png", vec![0; 1], 10).unwrap_err().to_string(); + assert!(err.contains("as one zip"), "{err}"); + assert_eq!(members.iter().map(|(n, _)| *n).collect::>(), ["a.png", "b.png"]); + } +} From 494387a6a8754f07d6884e142f76f2c630e99c37 Mon Sep 17 00:00:00 2001 From: Ridzzz0Alam Date: Sat, 24 Oct 2026 12:13:40 +0200 Subject: [PATCH 22/54] M1: a dash pattern too fine for its path strokes solid (#1060) stroke_center handed any dash pattern to kurbo::stroke, which makes one outline per dash: a 0.01/0.01 pattern on a 10,000-unit line made 2.5M path elements, and a long line with a tiny pattern ran out of memory. The variable width stroke already drew solid past MAX_DASHES (20,000); that estimate is now width::dashes_fit and stroke_center uses it too, so both strokes follow one rule. The path length is only measured for dashed strokes. Test: a_dash_pattern_too_fine_for_its_path_strokes_solid (2.5M elements on the old code). --- crates/pathops/src/lib.rs | 15 ++++++++++++++- crates/pathops/src/width.rs | 10 ++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index f4b30e10..45d5122f 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -292,7 +292,7 @@ pub fn stroke_center(path: &BezPath, width: f64, props: &StrokeProps) -> BezPath .with_miter_limit(props.miter_limit.clamp(1.0, 100.0)) .with_start_cap(kcap(props.cap_start)) .with_end_cap(kcap(props.cap_end)); - if let Some(pat) = width::dash_pattern(props) { + if let Some(pat) = width::dash_pattern(props).filter(|p| width::dashes_fit(path.perimeter(1.0), p)) { style = style.with_dashes(0.0, pat); } kurbo::stroke(path.iter(), &style, &kurbo::StrokeOpts::default(), 0.05) @@ -719,6 +719,19 @@ mod tests { assert!((s.area().abs() - 100.0).abs() < 2.0, "{}", s.area()); } + #[test] + fn a_dash_pattern_too_fine_for_its_path_strokes_solid() { + // ~500k dashes on this line (#1060): drawn solid, as a variable width stroke is past + // `MAX_DASHES`, rather than one outline per dash. + let mut line = BezPath::new(); + line.move_to((0.0, 0.0)); + line.line_to((10_000.0, 0.0)); + let props = StrokeProps { weight: 2.0, dashes: vec![0.01, 0.01], align: StrokeAlign::Center, ..Default::default() }; + let s = stroke_center(&line, 2.0, &props); + assert!(s.elements().len() < 100, "{} elements", s.elements().len()); + assert!((s.area().abs() - 20_000.0).abs() < 10.0, "{}", s.area()); + } + #[test] fn inside_and_outside_strokes_respect_an_even_odd_hole() { // A ring: both loops run the same way, so only the even-odd rule leaves the hole open. diff --git a/crates/pathops/src/width.rs b/crates/pathops/src/width.rs index 03240284..312c5b91 100644 --- a/crates/pathops/src/width.rs +++ b/crates/pathops/src/width.rs @@ -17,7 +17,7 @@ pub use figcraft_geom::measure::{Polyline, polylines}; /// Every subpath gets at least this many width samples, so the width varies smoothly along long /// straight runs. const SAMPLES_PER_SUBPATH: f64 = 128.0; -/// The most dashes a dashed variable width stroke makes. +/// The most dashes a dashed stroke makes ([`dashes_fit`]). const MAX_DASHES: usize = 20_000; /// The nearest point of the stroke path to `p`: (subpath index, t by length, distance). @@ -242,6 +242,12 @@ pub fn dash_pattern(props: &StrokeProps) -> Option> { Some(if dashes.len() % 2 == 1 { [dashes.clone(), dashes].concat() } else { dashes }) } +/// Whether `pattern` (from [`dash_pattern`]) makes at most [`MAX_DASHES`] dashes along `len`; +/// past that a stroke draws solid instead of one outline per dash (#1060). +pub(crate) fn dashes_fit(len: f64, pattern: &[f64]) -> bool { + len / pattern.iter().sum::() * (pattern.len() as f64) < MAX_DASHES as f64 +} + /// Resample a polyline so no step is longer than `max_step`; returns the points and the length at /// each, or `None` past the sample budget. fn resample(line: &Polyline, max_step: f64, budget: &mut usize) -> Option<(Vec, Vec)> { @@ -294,7 +300,7 @@ pub fn variable_outline(path: &BezPath, width: f64, props: &StrokeProps, profile }) .collect(); match &pattern { - Some(pat) if len / pat.iter().sum::() * (pat.len() as f64) < MAX_DASHES as f64 => { + Some(pat) if dashes_fit(len, pat) => { for (p, h) in dashes(&pts, &hw, pat) { outline_piece(&mut out, &p, &h, false, style); } From 2baeff3452f2c49a0017d2a826b3bdf9715c3bad Mon Sep 17 00:00:00 2001 From: Ridzzz0Alam Date: Sat, 24 Oct 2026 12:44:55 +0200 Subject: [PATCH 23/54] M1: dash limit counts the pattern restarting at each subpath (#1060) kurbo restarts the dash pattern at every MoveTo, so many short subpaths with a pattern of tiny dashes before a long gap made ~1M dashes (5M path elements) while the length-based estimate allowed them. stroke_center now adds one pattern's length per subpath to the estimate: an upper bound on the dashes kurbo makes. Test: each_subpath_restarting_the_dash_pattern_counts_toward_the_limit (5M elements before). Reported by CodeRabbit on #1278. --- crates/pathops/src/lib.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 45d5122f..7fe7a64e 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -292,7 +292,9 @@ pub fn stroke_center(path: &BezPath, width: f64, props: &StrokeProps) -> BezPath .with_miter_limit(props.miter_limit.clamp(1.0, 100.0)) .with_start_cap(kcap(props.cap_start)) .with_end_cap(kcap(props.cap_end)); - if let Some(pat) = width::dash_pattern(props).filter(|p| width::dashes_fit(path.perimeter(1.0), p)) { + // Each subpath restarts the pattern, so it can add up to one more pattern's worth of dashes. + let subpaths = path.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count() as f64; + if let Some(pat) = width::dash_pattern(props).filter(|p| width::dashes_fit(path.perimeter(1.0) + subpaths * p.iter().sum::(), p)) { style = style.with_dashes(0.0, pat); } kurbo::stroke(path.iter(), &style, &kurbo::StrokeOpts::default(), 0.05) @@ -732,6 +734,22 @@ mod tests { assert!((s.area().abs() - 20_000.0).abs() < 10.0, "{}", s.area()); } + #[test] + fn each_subpath_restarting_the_dash_pattern_counts_toward_the_limit() { + // The pattern restarts at every subpath: on 10,000 short lines its 199 tiny dashes come + // round each time (~1M dashes), though the length alone suggests a few hundred. + let mut path = BezPath::new(); + for i in 0..10_000 { + path.move_to((0.0, f64::from(i) * 4.0)); + path.line_to((2.0, f64::from(i) * 4.0)); + } + let mut dashes = vec![0.01; 199]; + dashes.push(10_000.0); + let props = StrokeProps { weight: 1.0, dashes, align: StrokeAlign::Center, ..Default::default() }; + let s = stroke_center(&path, 1.0, &props); + assert!(s.elements().len() < 100_000, "{} elements", s.elements().len()); + } + #[test] fn inside_and_outside_strokes_respect_an_even_odd_hole() { // A ring: both loops run the same way, so only the even-odd rule leaves the hole open. From 42fbe8f00c0a8a4fc80548d5ca5440847306b5ba Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:52:03 +0000 Subject: [PATCH 24/54] Skip zero-length segments when finding arrowhead direction Found while testing crates/pathops/src/lib.rs:352 (path_end, used by arrowhead) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/pathops/src/lib.rs | 69 ++++++++++++++++++++++++--------------- 1 file changed, 43 insertions(+), 26 deletions(-) diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index f4b30e10..46793f98 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -359,36 +359,35 @@ pub fn stroke_outline_of(path: &BezPath, props: &StrokeProps, closed_shape: bool /// End point and outward direction of an open path at its start or end. fn path_end(path: &BezPath, at_end: bool) -> Option<(Point, Vec2)> { - let segs: Vec = path.segments().collect(); - let seg = if at_end { segs.last()? } else { segs.first()? }; use kurbo::ParamCurve; use kurbo::ParamCurveDeriv; - let (p, d) = if at_end { - let d = match seg { - PathSeg::Line(l) => l.p1 - l.p0, - PathSeg::Quad(q) => q.deriv().eval(1.0).to_vec2(), - PathSeg::Cubic(c) => { - let d = c.deriv().eval(1.0).to_vec2(); - if d.hypot() < 1e-9 { c.p3 - c.p2 } else { d } - } + let dir_of = |seg: &PathSeg| -> Option<(Point, Vec2)> { + let (p, d) = if at_end { + let d = match seg { + PathSeg::Line(l) => l.p1 - l.p0, + PathSeg::Quad(q) => q.deriv().eval(1.0).to_vec2(), + PathSeg::Cubic(c) => { + let d = c.deriv().eval(1.0).to_vec2(); + if d.hypot() < 1e-9 { c.p3 - c.p2 } else { d } + } + }; + (seg.end(), d) + } else { + let d = match seg { + PathSeg::Line(l) => l.p0 - l.p1, + PathSeg::Quad(q) => -q.deriv().eval(0.0).to_vec2(), + PathSeg::Cubic(c) => { + let d = -c.deriv().eval(0.0).to_vec2(); + if d.hypot() < 1e-9 { c.p0 - c.p1 } else { d } + } + }; + (seg.start(), d) }; - (seg.end(), d) - } else { - let d = match seg { - PathSeg::Line(l) => l.p0 - l.p1, - PathSeg::Quad(q) => -q.deriv().eval(0.0).to_vec2(), - PathSeg::Cubic(c) => { - let d = -c.deriv().eval(0.0).to_vec2(); - if d.hypot() < 1e-9 { c.p0 - c.p1 } else { d } - } - }; - (seg.start(), d) + let len = d.hypot(); + (len >= 1e-12).then(|| (p, d / len)) }; - let len = d.hypot(); - if len < 1e-12 { - return None; - } - Some((p, d / len)) + // Skip zero-length segments (repeated vertices) and use the nearest one with a direction. + if at_end { path.segments().collect::>().iter().rev().find_map(dir_of) } else { path.segments().find_map(|s| dir_of(&s)) } } /// The filled arrowhead shape for `cap` at one end of an open path. @@ -742,4 +741,22 @@ mod tests { let s = stroke_outline_of(&ring, &inside, true, Some(&nz)); assert!(s.winding(material) != 0 && s.winding(hole) != 0); } + + #[test] + fn arrowhead_skips_zero_length_end_segments() { + let mut p = BezPath::new(); + p.move_to((0.0, 0.0)); + p.line_to((100.0, 0.0)); + assert!(arrowhead(&p, StrokeCap::TriangleArrow, 2.0, true).is_some()); + p.line_to((100.0, 0.0)); + let (tip, dir) = path_end(&p, true).expect("end direction"); + assert_eq!((tip, dir), (Point::new(100.0, 0.0), Vec2::new(1.0, 0.0))); + assert!(arrowhead(&p, StrokeCap::TriangleArrow, 2.0, true).is_some()); + let mut q = BezPath::new(); + q.move_to((0.0, 0.0)); + q.line_to((0.0, 0.0)); + q.line_to((100.0, 0.0)); + let (tip, dir) = path_end(&q, false).expect("start direction"); + assert_eq!((tip, dir), (Point::new(0.0, 0.0), Vec2::new(-1.0, 0.0))); + } } From a6a8d7149cb2a70b2bec86c0b5544b77a8b70785 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:52:14 +0000 Subject: [PATCH 25/54] Scale the path of text on a path in transform.scale Found while testing crates/engine/src/cmd/arrange.rs:~495 (scale_node, the Text branch). Compare crates/layout/src/lib.rs:136, which calls TextPath::scale on resize. Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/arrange.rs | 3 +++ crates/engine/src/draw_tools_tests.rs | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index 10ad13e5..72f2118a 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -494,6 +494,9 @@ pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> { t.paragraph_spacing *= k; t.paragraph_indent *= k; t.list_spacing *= k; + if let Some(tp) = &mut t.path { + tp.scale(k, k); + } } if let NodeKind::Vector { network } = &mut n.kind { network.scale(k, k); diff --git a/crates/engine/src/draw_tools_tests.rs b/crates/engine/src/draw_tools_tests.rs index d67b0af4..adfbb32d 100644 --- a/crates/engine/src/draw_tools_tests.rs +++ b/crates/engine/src/draw_tools_tests.rs @@ -60,6 +60,18 @@ fn text_goes_onto_a_path_and_off_again() { assert!(s.doc().unwrap().node(v).is_some() && text_path(&s, t).is_none(), "undo brings the path back"); } +#[test] +fn scaling_text_scales_its_path() { + let mut s = Session::new(); + let t = id(s.execute("text.create", &json!({"text": "Around", "x": 0, "y": 0})).unwrap()); + let v = id(s.execute("vector.create", &json!({"path": "M0 0 C50 -50 150 -50 200 0", "x": 40, "y": 100})).unwrap()); + s.execute("text.onPath", &json!({"ids": [t, v]})).unwrap(); + let w0 = figcraft_geom::Shape::bounding_box(&text_path(&s, t).unwrap().path()).width(); + s.execute("transform.scale", &json!({"ids": [t], "factor": 2.0})).unwrap(); + let w1 = figcraft_geom::Shape::bounding_box(&text_path(&s, t).unwrap().path()).width(); + assert!((w1 - w0 * 2.0).abs() < 1.0, "path width {w0} -> {w1}"); +} + #[test] fn svg_text_paths_import_as_text_on_a_path() { // SVG text is parsed with Inter standing in for every family (CI has no fonts). From 2ef3ebb45d225217d1031d32e424bb61f15721aa Mon Sep 17 00:00:00 2001 From: skylartaylor Date: Sun, 25 Oct 2026 05:17:15 +0100 Subject: [PATCH 26/54] E21: require release font coverage and stabilize capture deadline tests --- .github/workflows/ci.yml | 9 +- .github/workflows/freebsd.yml | 2 +- .github/workflows/release.yml | 9 +- ATTRIBUTION.md | 7 +- ROADMAP.md | 5 +- crates/text/build.rs | 173 +++++++++++++++--- crates/text/tests/build_input.rs | 4 + crates/text/tests/fixtures/latin-families.txt | 52 ++++++ crates/ui-egui/src/idle_tests.rs | 7 +- crates/ui-egui/src/windows_tests.rs | 4 +- docs/egui-037-local-followups.md | 51 ++++-- docs/egui-037-migration-plan.md | 4 + docs/releasing.md | 13 +- 13 files changed, 274 insertions(+), 66 deletions(-) create mode 100644 crates/text/tests/build_input.rs create mode 100644 crates/text/tests/fixtures/latin-families.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7abae66a..b79af502 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,11 +21,10 @@ jobs: # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository. uses: storytold/craft-common-actions/.github/workflows/gate.yml@main with: - # The craft-fonts commit release.yml builds with. Needs fonts/latin-manifest.txt (Inter): - # crates/text/build.rs fails a build that requires craft-fonts without it. Only craft-fonts' - # pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter. Re-pin - # once one commit has both (storytold/craft-fonts#42). - craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 + # Combined Latin presentation and core UI catalog. Required web builds enforce + # Inter Regular/SemiBold and Noto Sans Georgian Regular after asset-policy filtering. + craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69 + require-craft-fonts: true packages: >- libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev libgtk-3-dev diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index e030b167..80adf16a 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -32,4 +32,4 @@ jobs: uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main with: run-package-check: true - craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 + craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8b37ee39..f93219fa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,13 +24,12 @@ jobs: with: app-name: figcraft app-title: FigCraft - # Needs fonts/latin-manifest.txt (Inter): crates/text/build.rs fails a release without it. Only - # craft-fonts' pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter. - # Re-pin once one commit has both (storytold/craft-fonts#42). - craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 + # Combined Latin presentation and core UI catalog. Required web builds enforce + # Inter Regular/SemiBold and Noto Sans Georgian Regular after asset-policy filtering. + craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69 has-server: true # The browser has no system fonts: without craft-fonts the web build can't draw text (#885). - # crates/text/build.rs keeps the web build to Inter. + # crates/text/build.rs keeps the web allowlist small (Inter and Georgian). web-craft-fonts: true version: ${{ inputs.version }} secrets: diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index bf6fdece..46b789a8 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -26,9 +26,12 @@ Release builds embed fonts from the optional craft-fonts build input |---|---|---| | Inter | SIL Open Font License 1.1 | https://github.com/rsms/inter | | Archivo | SIL Open Font License 1.1 | [Google Fonts / Archivo](https://github.com/google/fonts/tree/6c70c829f09ea345d3590406693220ea35c6553f/ofl/archivo) | -| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Source Sans 3, Source Serif 4, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) | +| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) | | Liberation Sans, Serif, Mono | SIL Open Font License 1.1 | https://github.com/liberationfonts | -| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) | +| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic, Noto Sans Georgian | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) | + +The central catalog also contains Adobe-authored Source Sans 3 and Source Serif 4. +FigCraft filters both families from every build in accordance with its asset policy. egui's default fonts (compiled into the `egui`/`epaint` crates) keep their own licences (OFL / Apache-2.0 / Ubuntu Font Licence); see those crates. diff --git a/ROADMAP.md b/ROADMAP.md index a27cc4df..0af8cbac 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -132,6 +132,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. + - **2026-10-24** · Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 98% / 78%. - **2026-10-24** · Boards (M12): File ▸ New board makes a board, a file kind of its own saved in `.figcraft` and carried to people joining a live session. Sticky notes (8 colours, text that shrinks as the note grows, the author's name, Shift+Cmd/Ctrl+Enter for the next one), shapes with text (7 outlines), connectors that re-route when what they join moves (computed in the engine's normalize pass, so local edits, undo and others' edits agree), marker, highlighter and eraser, stamps with the placer's name on hover; a board toolbar, a contextual bar over the selection and the Design panel hidden until asked for; prototyping and Dev Mode refuse on boards. Design files and boards have their own marks (a pen nib, a note) on tabs, the home screen and the Open files list. Checked in the app, with two app instances in one live session, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)). - **2026-10-15** · Auto-save (M1, #305): Preferences ▸ Save files automatically (`pref.autoSave`, off by default, desktop only) saves each open file that has a path 3 s after its last change, never during a gesture, a live text preview or while a version is shown; the file is encoded off the UI thread, a failed save says why and is tried again a minute later. Numbers unchanged. @@ -329,6 +331,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. | | 2026-10-24 | minor | Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 97% → 98% breadth, 76% → 78% ready, 2–4 h → 1–2 h; headline numbers unchanged at this rounding | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding | | 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged | diff --git a/crates/text/build.rs b/crates/text/build.rs index f46547cd..ecca6a45 100644 --- a/crates/text/build.rs +++ b/crates/text/build.rs @@ -1,6 +1,7 @@ //! Embeds the optional craft-fonts build input (`CRAFT_FONTS_DIR`): both `fonts/manifest.txt` and the //! opt-in Latin presentation fonts in `fonts/latin-manifest.txt` (Inter, Roboto, …). Unset or missing, -//! `CRAFT_FONTS` is empty and the app uses system fonts. Recipe: craft-fonts `docs/integration.md`. +//! `CRAFT_FONTS` is empty and native apps use system fonts. Required web releases enforce Inter +//! Regular/SemiBold and Noto Sans Georgian Regular after filtering. Recipe: craft-fonts `docs/integration.md`. //! Noto Sans CJK only with `FIGCRAFT_EMBED_CJK` set (the web build's only way to show CJK UI text). use std::fmt::Write as _; use std::path::PathBuf; @@ -10,30 +11,13 @@ fn main() { println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED"); println!("cargo::rerun-if-env-changed=FIGCRAFT_EMBED_CJK"); let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n"); - if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) { - let mut ok = false; - for manifest in ["fonts/latin-manifest.txt", "fonts/manifest.txt"] { - // Presentation faces are an optional extension of craft-fonts. Required mode - // validates the core manifest and every extension that is actually supplied. - let path = dir.join(manifest); - if manifest == "fonts/latin-manifest.txt" && !path.exists() { - watch(&path); - continue; - } - match craft_fonts(&dir, manifest) { - Ok(entries) => { - ok = true; - src.push_str(&entries); - } - Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => { - println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display()); - } - Err(e) => println!("cargo::warning=craft-fonts {manifest} skipped: CRAFT_FONTS_DIR={}: {e}", dir.display()), - } - } - if !ok { - println!("cargo::warning=building without craft-fonts (once it is there, `cargo clean -p figcraft-text` makes the next build use it)"); - } + let dir = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from); + let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some(); + let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32"); + let embed_cjk = std::env::var_os("FIGCRAFT_EMBED_CJK").is_some(); + match input_fonts(dir.as_deref(), wasm, required, embed_cjk) { + Ok(entries) => src.push_str(&entries), + Err(e) => println!("cargo::error={e}"), } src.push_str("];\n"); let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs"); @@ -42,13 +26,45 @@ fn main() { } } +/// Collect the effective catalog and enforce the required web release faces after filtering. +fn input_fonts(dir: Option<&std::path::Path>, wasm: bool, required: bool, embed_cjk: bool) -> Result { + let Some(dir) = dir else { + return if required { Err("CRAFT_FONTS_REQUIRED is set but CRAFT_FONTS_DIR is missing".into()) } else { Ok(String::new()) }; + }; + let mut out = String::new(); + let mut faces = Vec::new(); + for manifest in ["fonts/latin-manifest.txt", "fonts/manifest.txt"] { + let path = dir.join(manifest); + if manifest == "fonts/latin-manifest.txt" && !path.exists() { + watch(&path); + continue; + } + match craft_fonts(dir, manifest, wasm, embed_cjk) { + Ok(catalog) => { + out.push_str(&catalog.entries); + faces.extend(catalog.faces); + } + Err(e) if required => return Err(format!("CRAFT_FONTS_DIR={}: {e}", dir.display())), + Err(e) => println!("cargo::warning=craft-fonts {manifest} skipped: CRAFT_FONTS_DIR={}: {e}", dir.display()), + } + } + if required && wasm { + for (family, style) in [("Inter", "Regular"), ("Inter", "SemiBold"), ("Noto Sans Georgian", "Regular")] { + if !faces.iter().any(|(f, s)| f == family && s == style) { + return Err(format!("required web font missing: {family} {style}; CRAFT_FONTS_DIR must include both Latin and core release faces")); + } + } + } + Ok(out) +} + /// Rebuild when `manifest` changes or, while it is missing, when it is added to its folder. /// /// Cargo takes a watched path that doesn't exist as changed on every build, and then rebuilds this /// crate and every crate that depends on it each time. So a missing manifest is never watched /// itself: its folder is, when there is one (a craft-fonts checkout without the Latin manifest). /// With no craft-fonts at all there is nothing to watch, so the build can't notice it arriving: -/// the warning in `main` says how to pick it up. +/// rebuild this crate after adding a previously absent checkout. fn watch(manifest: &std::path::Path) { let watched = if manifest.exists() { Some(manifest) } else { manifest.parent().filter(|folder| folder.is_dir()) }; if let Some(path) = watched { @@ -56,20 +72,29 @@ fn watch(manifest: &std::path::Path) { } } -fn craft_fonts(dir: &std::path::Path, manifest_rel: &str) -> Result { +struct Catalog { + entries: String, + faces: Vec<(String, String)>, +} + +fn craft_fonts(dir: &std::path::Path, manifest_rel: &str, wasm: bool, embed_cjk: bool) -> Result { let manifest = dir.join(manifest_rel); watch(&manifest); let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?; - let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32"); let mut out = String::new(); + let mut faces = Vec::new(); for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) { let f: Vec<&str> = line.split(" | ").map(str::trim).collect(); let [family, style, file, scripts, ..] = f.as_slice() else { return Err(format!("malformed manifest line: {line}")); }; + // FigCraft's asset policy excludes Adobe-authored visual assets on every target. + if matches!(*family, "Source Sans 3" | "Source Serif 4") { + continue; + } // CJK fonts are large; the desktop app finds system CJK fonts instead unless asked. let cjk = scripts.contains("Hans"); - if cjk && std::env::var_os("FIGCRAFT_EMBED_CJK").is_none() { + if cjk && !embed_cjk { continue; } // Web builds have a size budget: Inter, Noto Sans Georgian (small, for the Georgian UI), only the @@ -80,7 +105,95 @@ fn craft_fonts(dir: &std::path::Path, manifest_rel: &str) -> Result Self { + let dir = std::env::temp_dir().join(format!("fig-font-input-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir_all(dir.join("fonts")).unwrap(); + // The build script validates manifest/file presence; font bytes are checked centrally. + std::fs::write(dir.join("fonts/face.ttf"), b"synthetic build-input fixture").unwrap(); + let fixture = Self(dir); + fixture.manifest("manifest.txt", &[("Noto Sans Georgian", "Regular")]); + fixture + } + + fn manifest(&self, name: &str, faces: &[(&str, &str)]) { + let text = faces.iter().map(|(family, style)| format!("{family} | {style} | fonts/face.ttf | Latn\n")).collect::(); + std::fs::write(self.0.join("fonts").join(name), text).unwrap(); + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn restored_latin_catalog_preserves_permitted_native_faces_and_web_budget() { + let f = Fixture::new(); + std::fs::write(f.0.join("fonts/latin-manifest.txt"), include_str!("tests/fixtures/latin-families.txt")).unwrap(); + let native = craft_fonts(&f.0, "fonts/latin-manifest.txt", false, false).unwrap(); + assert_eq!(native.faces.len(), 43, "all prior permitted presentation faces remain available"); + let web = craft_fonts(&f.0, "fonts/latin-manifest.txt", true, false).unwrap(); + assert_eq!(web.faces.len(), 4, "the web catalog keeps only the four Inter faces"); + for catalog in [&native, &web] { + assert!(!catalog.entries.contains("Source Sans 3")); + assert!(!catalog.entries.contains("Source Serif 4")); + } + assert!(native.faces.iter().any(|(family, _)| family == "Carlito")); + assert!(native.faces.iter().any(|(family, _)| family == "Liberation Serif")); + assert!(input_fonts(Some(&f.0), true, true, false).is_ok()); + } + + #[test] + fn required_web_rejects_core_only_and_partial_latin_catalogs() { + let f = Fixture::new(); + assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("Inter Regular")); + f.manifest("latin-manifest.txt", &[("Inter", "Regular"), ("Unrelated", "SemiBold")]); + assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("Inter SemiBold")); + } + + #[test] + fn required_web_embeds_both_catalogs_and_requires_georgian() { + let f = Fixture::new(); + f.manifest("latin-manifest.txt", &[("Inter", "Regular"), ("Inter", "SemiBold")]); + let entries = input_fonts(Some(&f.0), true, true, false).unwrap(); + assert_eq!(entries.matches("CraftFont {").count(), 3); + assert!(entries.contains("family: \"Inter\"")); + assert!(entries.contains("family: \"Noto Sans Georgian\"")); + f.manifest("manifest.txt", &[("Other", "Regular")]); + assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("Noto Sans Georgian Regular")); + } + + #[test] + fn optional_and_native_inputs_keep_their_existing_policy() { + let f = Fixture::new(); + assert!(input_fonts(None, true, false, false).unwrap().is_empty()); + assert!(input_fonts(None, true, true, false).unwrap_err().contains("CRAFT_FONTS_DIR is missing")); + assert!(input_fonts(Some(&f.0), true, false, false).unwrap().contains("Noto Sans Georgian")); + assert!(input_fonts(Some(&f.0), false, true, false).unwrap().contains("Noto Sans Georgian")); + } + + #[test] + fn required_web_rejects_missing_asset_even_with_all_face_rows() { + let f = Fixture::new(); + f.manifest("latin-manifest.txt", &[("Inter", "Regular"), ("Inter", "SemiBold")]); + std::fs::remove_file(f.0.join("fonts/face.ttf")).unwrap(); + assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("fonts/face.ttf")); + } } diff --git a/crates/text/tests/build_input.rs b/crates/text/tests/build_input.rs new file mode 100644 index 00000000..6f87ac1f --- /dev/null +++ b/crates/text/tests/build_input.rs @@ -0,0 +1,4 @@ +//! Exercise the actual build-script catalog policy without changing process environment. +#[allow(dead_code)] +#[path = "../build.rs"] +mod build_script; diff --git a/crates/text/tests/fixtures/latin-families.txt b/crates/text/tests/fixtures/latin-families.txt new file mode 100644 index 00000000..e9db6f41 --- /dev/null +++ b/crates/text/tests/fixtures/latin-families.txt @@ -0,0 +1,52 @@ +# Synthetic paths; family/style inventory from craft-fonts 76ba5a0496503c0e2841abd626295038cbe07cde. +Inter | Regular | fonts/face.ttf | Latn +Inter | Italic | fonts/face.ttf | Latn +Inter | SemiBold | fonts/face.ttf | Latn +Inter | Bold | fonts/face.ttf | Latn +Carlito | Regular | fonts/face.ttf | Latn +Carlito | Bold | fonts/face.ttf | Latn +Carlito | Italic | fonts/face.ttf | Latn +Carlito | Bold Italic | fonts/face.ttf | Latn +Caladea | Regular | fonts/face.ttf | Latn +Caladea | Bold | fonts/face.ttf | Latn +Caladea | Italic | fonts/face.ttf | Latn +Caladea | Bold Italic | fonts/face.ttf | Latn +Liberation Sans | Regular | fonts/face.ttf | Latn +Liberation Sans | Bold | fonts/face.ttf | Latn +Liberation Sans | Italic | fonts/face.ttf | Latn +Liberation Sans | Bold Italic | fonts/face.ttf | Latn +Liberation Serif | Regular | fonts/face.ttf | Latn +Liberation Serif | Bold | fonts/face.ttf | Latn +Liberation Serif | Italic | fonts/face.ttf | Latn +Liberation Serif | Bold Italic | fonts/face.ttf | Latn +Liberation Mono | Regular | fonts/face.ttf | Latn +Liberation Mono | Bold | fonts/face.ttf | Latn +Liberation Mono | Italic | fonts/face.ttf | Latn +Liberation Mono | Bold Italic | fonts/face.ttf | Latn +Source Sans 3 | Regular | fonts/face.ttf | Latn +Source Sans 3 | Italic | fonts/face.ttf | Latn +Source Sans 3 | Semibold | fonts/face.ttf | Latn +Source Sans 3 | Bold | fonts/face.ttf | Latn +Source Serif 4 | Regular | fonts/face.ttf | Latn +Source Serif 4 | Italic | fonts/face.ttf | Latn +Source Serif 4 | Semibold | fonts/face.ttf | Latn +Source Serif 4 | Bold | fonts/face.ttf | Latn +Montserrat | Variable | fonts/face.ttf | Latn +Montserrat | Italic Variable | fonts/face.ttf | Latn +Lato | Regular | fonts/face.ttf | Latn +Lato | Bold | fonts/face.ttf | Latn +Lato | Italic | fonts/face.ttf | Latn +Open Sans | Variable | fonts/face.ttf | Latn +Open Sans | Italic Variable | fonts/face.ttf | Latn +Roboto | Variable | fonts/face.ttf | Latn +Roboto | Italic Variable | fonts/face.ttf | Latn +Merriweather | Regular | fonts/face.ttf | Latn +Merriweather | Bold | fonts/face.ttf | Latn +Merriweather | Italic | fonts/face.ttf | Latn +Playfair Display | Variable | fonts/face.ttf | Latn +Playfair Display | Italic Variable | fonts/face.ttf | Latn +Poppins | Regular | fonts/face.ttf | Latn +Poppins | Bold | fonts/face.ttf | Latn +Poppins | Italic | fonts/face.ttf | Latn +Nunito Sans | Variable | fonts/face.ttf | Latn +Nunito Sans | Italic Variable | fonts/face.ttf | Latn diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index 4c35ae40..c5da205c 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -345,12 +345,15 @@ fn cjk_names_are_looked_for_in_edits_not_on_every_pan() { fn a_hidden_window_answers_at_once() { // #269, #720: minimised or covered, the window draws no frames, but the app's logic still runs: // it answers edits, inspection, saves and the interface's state; what needs a frame (a capture - // of the window) is an error at once instead of waiting for the window to show. + // of the window) queues for a forced hidden paint and otherwise expires at its deadline. let dir = std::env::temp_dir().join(format!("figcraft-hidden-save-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("hidden.figcraft"); let (mut h, _) = document(); h.settle(); + // Exercise admission well after startup, independently of settling speed. + h.time += 120.0; + h.app.clock.set_test_now(Some(h.time)); let (tx, rx) = std::sync::mpsc::channel(); let request = |id: u64, method: &str, params: Value| ControlRequest { id: json!(id), @@ -376,7 +379,7 @@ fn a_hidden_window_answers_at_once() { assert_eq!(h.app.capture.shots.len(), 1, "one admitted capture"); assert!(h.app.control_queue.is_empty(), "nothing waits for the window"); // Its deadline expires it once, with the same error the transport used to get. - h.app.clock.set_test_now(Some(crate::control::SHOT_TIMEOUT + 1.0)); + h.app.clock.set_test_now(Some(h.time + crate::control::SHOT_TIMEOUT + 1.0)); let _ = ctx.run_logic(&hidden, |ctx| crate::control::background(&mut h.app, ctx)); let shot = rx.try_recv().expect("expired"); assert_eq!(shot.id, json!(3)); diff --git a/crates/ui-egui/src/windows_tests.rs b/crates/ui-egui/src/windows_tests.rs index 1537f426..6da3a011 100644 --- a/crates/ui-egui/src/windows_tests.rs +++ b/crates/ui-egui/src/windows_tests.rs @@ -137,6 +137,8 @@ fn another_hidden_window_answers_requests_that_need_no_frame_in_its_own_file() { // A capture that needs a frame is admitted and asks the hidden window to paint, rather than // failing at once; only its deadline expires it (once). + // Model a request admitted long after startup, without waiting in real time. + app.clock.set_test_now(Some(120.0)); let shot = request(&mut app, "ui.screenshot", json!({"window": w, "path": "unused.png"})); let unknown = request(&mut app, "window.list", json!({"window": 99})); logic(&ctx, &mut app, &[ViewportId::ROOT, other]); @@ -145,7 +147,7 @@ fn another_hidden_window_answers_requests_that_need_no_frame_in_its_own_file() { assert!(unknown.try_recv().expect("answered").result.unwrap_err().contains("no window 99")); assert!(app.control_queue.is_empty()); // The whole request lifetime is over: one timeout reply, and nothing retained. - app.clock.set_test_now(Some(crate::control::SHOT_TIMEOUT + 1.0)); + app.clock.set_test_now(Some(app.clock.now() + crate::control::SHOT_TIMEOUT + 1.0)); logic(&ctx, &mut app, &[ViewportId::ROOT, other]); assert!(shot.try_recv().expect("answered").result.unwrap_err().contains("didn't deliver a screenshot")); assert!(app.capture.shots.is_empty(), "released after its deadline"); diff --git a/docs/egui-037-local-followups.md b/docs/egui-037-local-followups.md index dde4e5c1..670fc30f 100644 --- a/docs/egui-037-local-followups.md +++ b/docs/egui-037-local-followups.md @@ -1,7 +1,24 @@ # egui 0.37 local implementation — stage 1 notes and follow-ups +Current candidate font pin: `37827adc41fd165f9a9234ac40f94dbad353aa69` restores the complete prior Latin catalog +alongside the migration fallbacks. Central font tests and license checks passed; +publication is pending approval. Managed run `egui037-figcraft-latin-ci-20261025-0711` +passed with required fonts: five targeted build-input tests plus full CI, including +WASM and plugin builds (3,257 passing executions, zero failures, nine ignored across +72 summaries; repeated tests are counted). This supersedes the optional-font-only +qualification below for the restored catalog. + ## Current local qualification — 2026-10-25 +The published refresh preserves main `9f788753` and passed managed full CI +`egui037-figcraft-main-refresh-ci-20261025-0618`: 3,247 passing executions, zero +failures and nine ignored. The 3,242 counts below describe the earlier `10702311` +qualification, before five additional main-branch tests. These are execution counts, +not distinct regressions. The release font input is under review separately; a +passing optional-font host build does not qualify the required web font catalog. + +### Earlier qualification at `10702311` + The migration preserves captured main `10702311f345393ee1d8868c3257679befcfa14d` (53 commits and 114 paths beyond the first integration). The final review commit records both the migration history and that upstream target as parents. The selected egui revision is @@ -147,19 +164,19 @@ an unhandled command for hidden tabs. Hidden-browser-tab `ui.screenshot`/`ui.inp unqualified even though the native path now queues and settles them. This is a forkfix prerequisite, not a passing claim. -## Local dependency and lock prerequisites (parent) +## Dependency and lock prerequisites -- Parent config `local-integration-config.toml` patches the egui family and winit, and `craft-ui`/ - `craft-print`. It does **not** patch `craft-window`. FigCraft now disables craft-window's optional - `egui` feature (it only needs the frame probe), but Cargo still resolves a package's optional - dependencies into the lock, and that rev declares `egui = "0.36"`. The managed graph must - therefore also patch/repoint `craft-window` to the reviewed craft-libs revision whose workspace - targets 0.37 (or otherwise prove the lock has exactly one egui family); otherwise the lock will - contain a second egui 0.36. -- No `[patch]` rows or path dependencies live in this repository's manifests; the parent generates - `Cargo.lock`. Do not publish this tree's resolved paths. -- Fonts: production keeps its central `CRAFT_FONTS_DIR`/system-font policy; no font binaries are - added here. Color-emoji claims still require a separate `epaint/color_fonts` feature job. +- The committed root `[patch.crates-io]` table pins the egui family and winit to + immutable published fork revisions. `craft-window` is pinned to the reviewed + craft-libs revision targeting egui 0.37; its optional `egui` feature stays disabled + because FigCraft needs only the frame probe. +- `Cargo.lock` is committed and resolves the canonical Git sources without local + path overrides. Use `--locked`; the earlier parent integration config is not + required to reproduce this graph. +- Fonts remain an optional central `CRAFT_FONTS_DIR` input with native system-font + fallback; no font binaries are added here. Required release-font qualification + is separate from optional-font host CI. Color emoji still needs a separate + `epaint/color_fonts` feature check. ## Qualification gaps and baseline backports/perf operations @@ -182,6 +199,12 @@ operations to measure once the parent can build: `#8731` text tessellation, `#87 startup, `#8512` font discovery, `#8763`/PR `#8764` TextEdit click-away, `#8748` hidden deferred children. None of these were applied, removed or claimed as fixed. -## Optional Latin manifest compatibility +## Required web font catalog -The pinned central font catalog has a required `fonts/manifest.txt` and no optional `fonts/latin-manifest.txt`. Required-font builds now validate the core and all supplied extension assets, while an absent opt-in Latin manifest is watched but not required. This follows the existing build script documentation and keeps malformed or incomplete supplied manifests fatal in required mode. No font binary was copied into FigCraft. Both required-core and no-font configurations remain validation gates. +Native optional-font builds retain the system-font policy. A required web build must +embed Inter Regular and SemiBold plus Noto Sans Georgian Regular after filtering; +missing required face/style coverage fails the build, regardless of which manifest supplies it. Required mode +also rejects an unset `CRAFT_FONTS_DIR`. The migration's original `b076417` catalog +lacks Inter and is not a qualified required-web input. A combined central font pin restores the prior Latin catalog while preserving the new +core fallbacks. Source Sans 3 and Source Serif 4 are excluded on every target; no font binary belongs in this repository. Required-web, required-native +and optional-font configurations remain separate validation gates. diff --git a/docs/egui-037-migration-plan.md b/docs/egui-037-migration-plan.md index fc7ecbc4..27ce7c5c 100644 --- a/docs/egui-037-migration-plan.md +++ b/docs/egui-037-migration-plan.md @@ -1188,6 +1188,10 @@ validation and generates the lockfile from its local dependency config. ## Current local qualification — 2026-10-25 +Historical qualification: the following counts apply to the earlier `10702311` +integration. The current `9f788753` refresh and its 3,247 passing executions are +recorded in the status update above. + The migration preserves captured main `10702311f345393ee1d8868c3257679befcfa14d` (53 commits and 114 paths beyond the first integration). The final review commit records both the migration history and that upstream target as parents. The selected egui revision is diff --git a/docs/releasing.md b/docs/releasing.md index 07492f6c..3dbc0fbf 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -101,11 +101,14 @@ builds with `CRAFT_FONTS_DIR` and `CRAFT_FONTS_REQUIRED=1`, so releases embed it rather than ship without them. The browser has no system fonts, so a web build without craft-fonts can't draw text. `crates/text/build.rs` reads both `fonts/manifest.txt` and `fonts/latin-manifest.txt`, so the pin -must be a commit that has the Latin manifest (today `4391305`, on craft-fonts' -`pr/latin-presentation-fonts` branch; re-pin to `main` once that lands). That commit has no -Noto Sans Georgian (craft-fonts' main has it but no Latin manifest), so desktop Georgian uses an -installed font; re-pin when one commit has both (craft-fonts#42). The web build embeds only Inter, -Noto Sans Georgian and the Archivo SemiBold wordmark, when the pin has them. Packages carry each embedded font's licence as `OFL-.txt` +must combine the Latin presentation catalog and the current core UI faces. Required web builds +validate the emitted Inter Regular/SemiBold and Noto Sans Georgian Regular faces after filtering; +a missing required face, style or asset fails the required web build. Excluded Adobe rows +are skipped before resolving their asset paths. +Native builds retain the permitted Latin presentation faces. Source Sans 3 and Source Serif 4 +are filtered on every target under FigCraft's asset policy. The web build keeps its smaller +allowlist: Inter, Noto Sans Georgian and Archivo SemiBold when supplied. Packages carry each +embedded font's licence as `OFL-.txt` (`copy_font_licences` in `packaging/env.sh`; `Contents/Resources/Licenses` in the macOS app). ### macOS From d7c7986347c85d27b2fe80411dd5b04003e108c0 Mon Sep 17 00:00:00 2001 From: skylartaylor Date: Sun, 25 Oct 2026 05:32:03 +0100 Subject: [PATCH 27/54] E21: separate capture, IME and field-save regression phases --- crates/ui-egui/src/control.rs | 414 +++++++++++++++++------------- crates/ui-egui/src/field_tests.rs | 280 ++++++++++---------- crates/ui-egui/src/shortcuts.rs | 300 ++++++++++++---------- 3 files changed, 547 insertions(+), 447 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index fdecaf18..b7ca225d 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -455,171 +455,18 @@ pub fn drain(app: &mut FigApp, ctx: &Context) { let viewport = ctx.viewport_id(); // This pass's geometry defines the epoch: a resize, DPI change or zoom change (back or not) // invalidates captures issued under the old one. - let spec = current_spec(ctx, viewport); - if let Some(spec) = spec { - let changed = app.capture.geometry.get(&viewport).is_none_or(|(old, _)| *old != spec); - if changed { - app.capture.epoch = app.capture.epoch.wrapping_add(1); - app.capture.geometry.insert(viewport, (spec, app.capture.epoch)); - } - } - let epoch = app.capture.geometry.get(&viewport).map_or(0, |(_, e)| *e); + let (spec, epoch) = pass_geometry(app, ctx, viewport); expire(app, ctx, now); // Drain completed captures first, so their budget is free before new requests are admitted. - let mut i = 0; - while i < app.capture.shots.len() { - // Each viewport drains its own captures: their geometry and document differ. - if app.capture.shots[i].viewport != viewport { - i += 1; - continue; - } - let Some(slot) = app.capture.shots[i].slot.clone() else { - i += 1; - continue; - }; - let Some(image) = slot.take() else { - i += 1; - continue; - }; - let mut r = app.capture.shots.remove(i); - r.release(); - let cancelled = r.cancelled.load(std::sync::atomic::Ordering::Relaxed); - let current = current_spec(ctx, viewport); - let (document, presentation) = app.windows.presentation(&app.session, r.window); - if cancelled { - // The transport stopped waiting: no reply, no file. - } else if now >= r.deadline { - reply(&mut r, Err("the window didn't deliver a screenshot in time (is it visible?)".into())); - } else if r.window != app.windows.current || r.document != document || r.presentation != presentation { - reply(&mut r, Err("the document changed while the screenshot was being captured".into())); - } else if current.is_none() { - // Fail closed: with no representable current geometry the pixels cannot be proven to - // belong to the surface that was admitted, so they are never the capture's reply. - reply(&mut r, Err("the window's current geometry is unavailable (is it minimised?)".into())); - } else if let Some(current) = current - && (r.epoch != epoch || r.spec.is_none_or(|s| s.size_px != current.size_px || s.pixels_per_point != current.pixels_per_point)) - { - // Geometry moved between issuance and arrival (even back): retry against the new pass, - // never accept the old pixels. Document changes are errors above. - if r.retries >= 2 { - reply(&mut r, Err("the window kept changing size while the screenshot was captured".into())); - } else { - r.retries += 1; - r.spec = Some(current); - r.epoch = epoch; - r.reserved = r.spec.and_then(|s| s.cost()).unwrap_or(0); - r.settle = 0; - r.settle_frame = None; - r.issued = false; - app.capture.shots.push(r); - } - } else if let (Some(current), Some(spec)) = (current, r.spec) - // The admitted surface is in whole UI points; the readback is in physical pixels. Up - // to one pixel of rounding either way is the same surface, not a scale change. The - // difference is absolute: an undersized readback is a scale mismatch too. - && (image.size[0].abs_diff(spec.size_px[0]) > 1 || image.size[1].abs_diff(spec.size_px[1]) > 1) - { - if r.retries >= 2 { - reply( - &mut r, - Err(format!( - "the screenshot is {}x{} but the window is {}x{} (a scale mismatch)", - image.size[0], image.size[1], spec.size_px[0], spec.size_px[1] - )), - ); - } else { - r.retries += 1; - r.spec = Some(current); - r.epoch = epoch; - r.reserved = r.spec.and_then(|s| s.cost()).unwrap_or(0); - r.settle = 0; - r.settle_frame = None; - r.issued = false; - app.capture.shots.push(r); - } - } else { - let result = shot_reply(&image, r.path.as_deref()); - reply(&mut r, result); - } - ctx.request_repaint(); - } + drain_completed(app, ctx, viewport, now, epoch); // Admit queued captures for the window drawing now; other windows keep waiting for their frame. - for r in std::mem::take(&mut app.control_queue) { - if r.is_cancelled() { - continue; - } - let target = r.params.get("window").and_then(Value::as_u64).unwrap_or(app.windows.focused); - if target != app.windows.current { - if app.windows.exists(target) { - ctx.request_repaint_of(crate::windows::viewport(target)); - app.control_queue.push(r); - } else { - let _ = r.reply.send(ControlReply { id: r.id, result: Err(format!("no window {target}")) }); - } - continue; - } - if r.method == "ui.screenshot" { - admit(app, r, target, now); - continue; - } - // Every control-channel gesture is fenced from the user's system clipboard (the flag is - // restored so a batch already in a frame's raw input keeps its own provenance). - let before = app.control_synthetic; - app.control_synthetic = true; - let res = handle(app, ctx, &r.method, &r.params); - app.control_synthetic = before; - if r.method == "ui.input" && std::mem::take(&mut app.control_input_queued) { - // Acknowledge once the pass that applies the batch is presented, never from a sizing - // pass whose input is thrown away. - app.input_acks.push(crate::PendingAck { viewport, tx: r.reply, reply: ControlReply { id: r.id, result: res } }); - } else { - let _ = r.reply.send(ControlReply { id: r.id, result: res }); - } - ctx.request_repaint(); - } + admit_queued(app, ctx, viewport, now); // Fill in this pass's geometry, enforce the byte budget (a post-callback check alone would // not bound peak memory), and release anything over it before issuing. - let mut i = 0; - while i < app.capture.shots.len() { - let total: usize = app.capture.shots.iter().map(|s| s.reserved).sum(); - let mut remove = false; - { - let r = &mut app.capture.shots[i]; - if r.viewport != viewport { - i += 1; - continue; - } - if !r.issued && r.spec != spec { - // A request can resize during its settling frames. Reserve and validate this - // actual surface before issuance, never the old (possibly much smaller) one. - r.spec = spec; - r.settle = 0; - r.settle_frame = None; - if spec.is_none() { - r.reserved = 0; - } - } - if let Some(spec) = r.spec { - let raw = spec.bytes().unwrap_or(usize::MAX); - let cost = spec.cost().unwrap_or(usize::MAX); - if raw > MAX_SHOT_IMAGE_BYTES || total.saturating_sub(r.reserved).saturating_add(cost) > MAX_SHOT_TOTAL_BYTES { - reply(r, Err(format!("the screenshot needs {cost} bytes, over the capture budget"))); - remove = true; - } else if r.reserved != cost { - r.reserved = cost; - } - } - } - if remove { - let mut r = app.capture.shots.remove(i); - r.release(); - } else { - i += 1; - } - } + enforce_capture_budget(app, viewport, spec); // Settle on completed UI frames only, then issue once. (The screenshot command itself forces // the paint that delivers it.) The request's document and window were fixed at admission and @@ -630,6 +477,243 @@ pub fn drain(app: &mut FigApp, ctx: &Context) { // first pass calls `request_discard` is therefore counted once, after it presents its layout, // not when its discarded sizing pass runs. Passes after the first (`current_pass_index > 0`) // belong to such a frame and never add another count. + settle_and_issue(app, ctx, viewport, now, epoch, spec); + request_paint(app, ctx); +} + +/// Establish this pass's screenshot geometry and epoch: a resize, DPI change or zoom change (even +/// back) bumps the epoch captures issued under the old one were admitted against. +fn pass_geometry(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId) -> (Option, u64) { + let spec = current_spec(ctx, viewport); + if let Some(spec) = spec { + let changed = app.capture.geometry.get(&viewport).is_none_or(|(old, _)| *old != spec); + if changed { + app.capture.epoch = app.capture.epoch.wrapping_add(1); + app.capture.geometry.insert(viewport, (spec, app.capture.epoch)); + } + } + let epoch = app.capture.geometry.get(&viewport).map_or(0, |(_, e)| *e); + (spec, epoch) +} + +/// Take the image delivery for the shot at `index`; `None` when no image has arrived yet. +fn arrived_shot(app: &mut FigApp, index: usize) -> Option<(ShotRequest, Arc)> { + let slot = app.capture.shots.get(index)?.slot.clone()?; + let image = slot.take()?; + let mut r = app.capture.shots.remove(index); + r.release(); + Some((r, image)) +} + +/// Drain images delivered for this viewport: reply, re-queue for a geometry retry, or drop a +/// cancelled capture. Each viewport drains its own captures: their geometry and document differ. +fn drain_completed(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId, now: f64, epoch: u64) { + let mut i = 0; + while i < app.capture.shots.len() { + if app.capture.shots[i].viewport != viewport { + i += 1; + continue; + } + let Some((r, image)) = arrived_shot(app, i) else { + i += 1; + continue; + }; + settle_arrival(app, ctx, viewport, now, epoch, r, &image); + ctx.request_repaint(); + } +} + +/// Whether the pass geometry differs from the one the capture was issued at (even back to an old +/// size): the pixels cannot be accepted for the admitted surface. +fn geometry_moved(r: &ShotRequest, current: CaptureSpec, epoch: u64) -> bool { + r.epoch != epoch || r.spec.is_none_or(|s| s.size_px != current.size_px || s.pixels_per_point != current.pixels_per_point) +} + +/// The admitted surface is in whole UI points; the readback is in physical pixels. Up to one +/// pixel of rounding either way is the same surface, not a scale change. The difference is +/// absolute: an undersized readback is a scale mismatch too. +fn scale_mismatch(image: &egui::ColorImage, spec: CaptureSpec) -> bool { + image.size[0].abs_diff(spec.size_px[0]) > 1 || image.size[1].abs_diff(spec.size_px[1]) > 1 +} + +/// Re-queue a capture against the new pass, or hand it back when its retries are exhausted. +fn requeue_capture(app: &mut FigApp, mut r: ShotRequest, current: CaptureSpec, epoch: u64) -> Option { + if r.retries >= 2 { + return Some(r); + } + r.retries += 1; + r.spec = Some(current); + r.epoch = epoch; + r.reserved = r.spec.and_then(|s| s.cost()).unwrap_or(0); + r.settle = 0; + r.settle_frame = None; + r.issued = false; + app.capture.shots.push(r); + None +} + +/// Settle an arrived image: reply with it, retry against the new pass on a geometry/scale change, +/// or drop it when the transport cancelled or the window/document moved on. +fn settle_arrival(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId, now: f64, epoch: u64, mut r: ShotRequest, image: &egui::ColorImage) { + let cancelled = r.cancelled.load(std::sync::atomic::Ordering::Relaxed); + let current = current_spec(ctx, viewport); + let (document, presentation) = app.windows.presentation(&app.session, r.window); + if cancelled { + // The transport stopped waiting: no reply, no file. + return; + } + if now >= r.deadline { + reply(&mut r, Err("the window didn't deliver a screenshot in time (is it visible?)".into())); + return; + } + if r.window != app.windows.current || r.document != document || r.presentation != presentation { + reply(&mut r, Err("the document changed while the screenshot was being captured".into())); + return; + } + let Some(current) = current else { + // Fail closed: with no representable current geometry the pixels cannot be proven to + // belong to the surface that was admitted, so they are never the capture's reply. + reply(&mut r, Err("the window's current geometry is unavailable (is it minimised?)".into())); + return; + }; + if geometry_moved(&r, current, epoch) { + // Geometry moved between issuance and arrival (even back): retry against the new pass, + // never accept the old pixels. Document changes are errors above. + if let Some(mut r) = requeue_capture(app, r, current, epoch) { + reply(&mut r, Err("the window kept changing size while the screenshot was captured".into())); + } + return; + } + if let Some(spec) = r.spec + && scale_mismatch(image, spec) + { + if let Some(mut r) = requeue_capture(app, r, current, epoch) { + reply( + &mut r, + Err(format!( + "the screenshot is {}x{} but the window is {}x{} (a scale mismatch)", + image.size[0], image.size[1], spec.size_px[0], spec.size_px[1] + )), + ); + } + return; + } + let result = shot_reply(image, r.path.as_deref()); + reply(&mut r, result); +} + +/// Admit queued captures for the window drawing now; other windows keep waiting for their frame. +fn admit_queued(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId, now: f64) { + for r in std::mem::take(&mut app.control_queue) { + route_queued(app, ctx, viewport, now, r); + } +} + +/// Route one queued control request: retarget another window, admit a capture, or run a +/// state-only request for the window drawing now. +fn route_queued(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId, now: f64, r: ControlRequest) { + if r.is_cancelled() { + return; + } + let target = r.params.get("window").and_then(Value::as_u64).unwrap_or(app.windows.focused); + if target != app.windows.current { + if app.windows.exists(target) { + ctx.request_repaint_of(crate::windows::viewport(target)); + app.control_queue.push(r); + } else { + let _ = r.reply.send(ControlReply { id: r.id, result: Err(format!("no window {target}")) }); + } + return; + } + if r.method == "ui.screenshot" { + admit(app, r, target, now); + return; + } + // Every control-channel gesture is fenced from the user's system clipboard (the flag is + // restored so a batch already in a frame's raw input keeps its own provenance). + let before = app.control_synthetic; + app.control_synthetic = true; + let res = handle(app, ctx, &r.method, &r.params); + app.control_synthetic = before; + if r.method == "ui.input" && std::mem::take(&mut app.control_input_queued) { + // Acknowledge once the pass that applies the batch is presented, never from a sizing + // pass whose input is thrown away. + app.input_acks.push(crate::PendingAck { viewport, tx: r.reply, reply: ControlReply { id: r.id, result: res } }); + } else { + let _ = r.reply.send(ControlReply { id: r.id, result: res }); + } + ctx.request_repaint(); +} + +/// Reserve this request's actual surface against the capture budget; `true` when it must be +/// removed for exceeding it. +fn reserve_for_spec(r: &mut ShotRequest, spec: Option, total: usize) -> bool { + if !r.issued && r.spec != spec { + // A request can resize during its settling frames. Reserve and validate this + // actual surface before issuance, never the old (possibly much smaller) one. + r.spec = spec; + r.settle = 0; + r.settle_frame = None; + if spec.is_none() { + r.reserved = 0; + } + } + let Some(spec) = r.spec else { return false }; + let raw = spec.bytes().unwrap_or(usize::MAX); + let cost = spec.cost().unwrap_or(usize::MAX); + if raw > MAX_SHOT_IMAGE_BYTES || total.saturating_sub(r.reserved).saturating_add(cost) > MAX_SHOT_TOTAL_BYTES { + reply(r, Err(format!("the screenshot needs {cost} bytes, over the capture budget"))); + return true; + } + if r.reserved != cost { + r.reserved = cost; + } + false +} + +/// Fill in this pass's geometry and enforce the byte budget (a post-callback check alone would +/// not bound peak memory), releasing anything over it before issuing. +fn enforce_capture_budget(app: &mut FigApp, viewport: egui::ViewportId, spec: Option) { + let mut i = 0; + while i < app.capture.shots.len() { + let total: usize = app.capture.shots.iter().map(|s| s.reserved).sum(); + let remove = { + let r = &mut app.capture.shots[i]; + if r.viewport != viewport { + i += 1; + continue; + } + reserve_for_spec(r, spec, total) + }; + if remove { + let mut r = app.capture.shots.remove(i); + r.release(); + } else { + i += 1; + } + } +} + +/// Count one completed frame into the capture's settling, reporting whether it may now be issued. +fn settled_for_issue(r: &mut ShotRequest, frame_nr: u64) -> bool { + match r.settle_frame { + None => { + // Anchor the count at the first pass that sees this request's geometry; this + // frame has not completed yet. + r.settle_frame = Some(frame_nr); + false + } + Some(seen) if frame_nr > seen => { + r.settle_frame = Some(frame_nr); + r.settle = r.settle.saturating_add(1); + r.settle >= SHOT_SETTLE_PASSES + } + _ => false, + } +} + +/// Settle on completed UI frames only, then issue once (against this pass's surface). +fn settle_and_issue(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId, now: f64, epoch: u64, spec: Option) { let first_pass = ctx.current_pass_index() == 0; let frame_nr = ctx.cumulative_frame_nr_for(viewport); for r in &mut app.capture.shots { @@ -639,20 +723,7 @@ pub fn drain(app: &mut FigApp, ctx: &Context) { if r.cancelled.load(std::sync::atomic::Ordering::Relaxed) || now >= r.deadline { continue; } - match r.settle_frame { - None => { - // Anchor the count at the first pass that sees this request's geometry; this - // frame has not completed yet. - r.settle_frame = Some(frame_nr); - continue; - } - Some(seen) if frame_nr > seen => { - r.settle_frame = Some(frame_nr); - r.settle = r.settle.saturating_add(1); - } - _ => continue, - } - if r.settle < SHOT_SETTLE_PASSES { + if !settled_for_issue(r, frame_nr) { continue; } let slot = CaptureSlot::new(ctx, viewport); @@ -665,7 +736,6 @@ pub fn drain(app: &mut FigApp, ctx: &Context) { r.issued = true; ctx.send_viewport_cmd_to(viewport, egui::ViewportCommand::Screenshot(CaptureSlot::callback(&slot))); } - request_paint(app, ctx); } /// A screenshot's reply: the PNG written to `path` (through the automation roots, when the app diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index 68a6564b..e09df06e 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -872,148 +872,146 @@ fn a_prepared_field_save_cannot_commit_into_a_same_pass_replacement_document() { for route in ["control", "tab"] { for save_as in [false, true] { for discard in [false, true] { - let (mut u, a, _) = setup(); - let dir = std::env::temp_dir().join(format!("figcraft-field-save-owner-{}-{route}-{save_as}-{discard}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let (path, other, copy) = (dir.join("a.figcraft"), dir.join("b.figcraft"), dir.join("copy.figcraft")); - u.app.session.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); - let owner = u.app.session.ds().unwrap().uid; - let owner_bytes = std::fs::read(&path).unwrap(); - u.app.session.execute("file.new", &json!({"name": "Same-pass B"})).unwrap(); - let b = id(u.app.session.execute("shape.rectangle", &json!({"x": 200, "y": 40, "width": 30, "height": 30})).unwrap()); - u.app.session.execute("file.saveAs", &json!({"path": other.to_string_lossy()})).unwrap(); - u.app.session.execute("shape.rectangle", &json!({"x": 300, "y": 0, "width": 10, "height": 10})).unwrap(); - u.app.session.execute("select.set", &json!({"ids": [b]})).unwrap(); - let other_index = u.app.session.active; - let other_uid = u.app.session.ds().unwrap().uid; - let other_name = u.app.session.doc().unwrap().name.clone(); - let other_model = u.app.session.ds().unwrap().doc.clone(); - let before_switch_revision = u.app.session.ds().unwrap().revision; - let other_history = u.app.session.undo_depth(); - let other_selection = u.app.session.ds().unwrap().selection.clone(); - let other_dirty = u.app.session.ds().unwrap().is_dirty(); - let other_bytes = std::fs::read(&other).unwrap(); - assert!(other_dirty, "B has independent unsaved content"); - let owner_index = u.app.session.docs.iter().position(|d| d.uid == owner).unwrap(); - u.app.session.execute("doc.switch", &json!({"index": owner_index})).unwrap(); - // Every command bumps the outgoing document's revision, even a nonediting switch. - let other_revision = u.app.session.docs.iter().find(|d| d.uid == other_uid).unwrap().revision; - assert_eq!(other_revision, before_switch_revision + 1, "the setup switch advances only B's command revision"); - let asked = std::rc::Rc::new(std::cell::Cell::new(0)); - u.app.services = Box::new(SaveAsPicker { path: Some(copy.clone()), asked: asked.clone() }); - let output = u.frame_output(vec![]); - let nodes = output.platform_output.accesskit_update.unwrap().nodes; - let tab = nodes - .iter() - .map(|(_, node)| node) - .find(|node| node.role() == Role::Tab && node.label() == Some(other_name.as_str())) - .expect("the actual B tab"); - let bounds = tab.bounds().unwrap(); - let tab_at = pos2(((bounds.x0 + bounds.x1) / 2.0) as f32, ((bounds.y0 + bounds.y1) / 2.0) as f32); - let (at, _) = field(&mut u, "X"); - click(&mut u, at); - assert_eq!(u.ctx.memory(|m| m.focused()), Some(Id::unique("pos_x").with("te"))); - assert!(u.ctx.text_edit_focused()); - u.frame(vec![Event::Text("77".into())]); - assert_eq!(u.ctx.data(|d| d.get_temp::(Id::unique("pos_x").with("editing"))), Some("77".into())); - assert_eq!(geom(&u, a).0, 10.0, "A's raw draft is not committed yet"); - assert_eq!(u.app.session.ds().unwrap().uid, owner, "the save begins with A presented"); - let reply = if route == "control" { - let (reply, rx) = std::sync::mpsc::channel(); - u.app.control_queue.push(crate::ControlRequest { - id: json!("same-pass-switch"), - method: "engine.execute".into(), - params: json!({"command": "doc.switch", "params": {"index": other_index}, "window": 0}), - reply, - cancelled: Default::default(), - }); - Some(rx) - } else { - None - }; - let mut modifiers = if cfg!(target_os = "macos") { Modifiers::COMMAND } else { Modifiers::COMMAND.plus(Modifiers::CTRL) }; - if save_as { - modifiers = modifiers.plus(Modifiers::SHIFT); - } - let mut events = vec![ - Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: true, repeat: false, modifiers }, - Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers }, - ]; - if route == "tab" { - events.push(Event::PointerMoved(tab_at)); - for pressed in [true, false] { - events.push(Event::PointerButton { - pos: tab_at, - button: egui::PointerButton::Primary, - pressed, - modifiers: Default::default(), - }); - } - } - if discard { - events.insert(0, Event::ModifiersChanged(modifiers)); - u.frame_raw_with_discard(egui::RawInput { events, ..Default::default() }); - } else { - u.frame_with(events, modifiers); - } - if let Some(reply) = reply { - let reply = reply.try_recv().expect("real control::drain acknowledged doc.switch in this pass"); - assert!(reply.result.is_ok(), "{:?}", reply.result); - } - let unchanged = |u: &Ui| { - assert_eq!(u.app.session.ds().unwrap().uid, other_uid, "the real same-pass route presents B"); - assert_eq!( - geom(u, b), - (200.0, 40.0, 30.0, 30.0), - "A's draft cannot enter B's field: route={route}, save_as={save_as}, discard={discard}" - ); - assert!(std::sync::Arc::ptr_eq(&u.app.session.ds().unwrap().doc, &other_model), "B's model is untouched"); - assert_eq!(u.app.session.ds().unwrap().revision, other_revision); - assert_eq!(u.app.session.undo_depth(), other_history, "B's undo/redo history is unchanged"); - assert_eq!(u.app.session.ds().unwrap().selection, other_selection); - assert_eq!(u.app.session.ds().unwrap().is_dirty(), other_dirty); - assert_eq!(std::fs::read(&other).unwrap(), other_bytes); - assert_eq!(std::fs::read(&path).unwrap(), owner_bytes); - assert_eq!(u.app.session.docs.iter().find(|d| d.uid == owner).unwrap().doc.world_bounds(a).unwrap().x0, 10.0); - assert_eq!(asked.get(), 0, "the cancelled request never asks for a path"); - assert!(!copy.exists()); - assert!(u.app.ui.after_field.is_none(), "cancel before late fallback or queueing"); - }; - unchanged(&u); - for _ in 0..3 { - u.frame(vec![]); - unchanged(&u); - } - // A cancelled draft is not a permanent veto: B accepts a later real field edit - // and saves its own value through the same focused-field shortcut path. - let (at, _) = field(&mut u, "X"); - click(&mut u, at); - assert_eq!(u.ctx.memory(|m| m.focused()), Some(Id::unique("pos_x").with("te"))); - u.frame(vec![Event::Text("88".into())]); - assert_eq!(geom(&u, b).0, 200.0, "fresh B text still waits for a normal commit"); - assert_eq!(u.ctx.data(|d| d.get_temp::(Id::unique("pos_x").with("editing"))), Some("88".into())); - u.frame_with( - vec![ - Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: true, repeat: false, modifiers }, - Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers }, - ], - modifiers, - ); - assert_eq!(geom(&u, b).0, 88.0, "a fresh B field commits normally"); - assert!(u.app.ui.after_field.is_some()); - for _ in 0..3 { - u.frame(vec![]); - } - assert_eq!(u.app.session.ds().unwrap().uid, other_uid); - assert_eq!(saved_x(if save_as { © } else { &other }, b), 88.0, "a later valid save belongs to B"); - if save_as { - assert_eq!(std::fs::read(&other).unwrap(), other_bytes); - } - assert_eq!(std::fs::read(&path).unwrap(), owner_bytes, "A stays unsaved"); - assert_eq!(asked.get(), usize::from(save_as)); - assert!(u.app.ui.after_field.is_none()); - std::fs::remove_dir_all(&dir).ok(); + prepared_save_cannot_enter_a_replacement(route, save_as, discard); } } } } + +/// One route/save-as/discard combination of the same-pass replacement test: a save prepared for A +/// must not commit A's cached X editor into B when routing presents B before the fields draw, +/// even though both panels use the same widget ID. +fn prepared_save_cannot_enter_a_replacement(route: &str, save_as: bool, discard: bool) { + let (mut u, a, _) = setup(); + let dir = std::env::temp_dir().join(format!("figcraft-field-save-owner-{}-{route}-{save_as}-{discard}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (path, other, copy) = (dir.join("a.figcraft"), dir.join("b.figcraft"), dir.join("copy.figcraft")); + u.app.session.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + let owner = u.app.session.ds().unwrap().uid; + let owner_bytes = std::fs::read(&path).unwrap(); + u.app.session.execute("file.new", &json!({"name": "Same-pass B"})).unwrap(); + let b = id(u.app.session.execute("shape.rectangle", &json!({"x": 200, "y": 40, "width": 30, "height": 30})).unwrap()); + u.app.session.execute("file.saveAs", &json!({"path": other.to_string_lossy()})).unwrap(); + u.app.session.execute("shape.rectangle", &json!({"x": 300, "y": 0, "width": 10, "height": 10})).unwrap(); + u.app.session.execute("select.set", &json!({"ids": [b]})).unwrap(); + let other_index = u.app.session.active; + let other_uid = u.app.session.ds().unwrap().uid; + let other_name = u.app.session.doc().unwrap().name.clone(); + let other_model = u.app.session.ds().unwrap().doc.clone(); + let before_switch_revision = u.app.session.ds().unwrap().revision; + let other_history = u.app.session.undo_depth(); + let other_selection = u.app.session.ds().unwrap().selection.clone(); + let other_dirty = u.app.session.ds().unwrap().is_dirty(); + let other_bytes = std::fs::read(&other).unwrap(); + assert!(other_dirty, "B has independent unsaved content"); + let owner_index = u.app.session.docs.iter().position(|d| d.uid == owner).unwrap(); + u.app.session.execute("doc.switch", &json!({"index": owner_index})).unwrap(); + // Every command bumps the outgoing document's revision, even a nonediting switch. + let other_revision = u.app.session.docs.iter().find(|d| d.uid == other_uid).unwrap().revision; + assert_eq!(other_revision, before_switch_revision + 1, "the setup switch advances only B's command revision"); + let asked = std::rc::Rc::new(std::cell::Cell::new(0)); + u.app.services = Box::new(SaveAsPicker { path: Some(copy.clone()), asked: asked.clone() }); + let output = u.frame_output(vec![]); + let nodes = output.platform_output.accesskit_update.unwrap().nodes; + let tab = nodes + .iter() + .map(|(_, node)| node) + .find(|node| node.role() == Role::Tab && node.label() == Some(other_name.as_str())) + .expect("the actual B tab"); + let bounds = tab.bounds().unwrap(); + let tab_at = pos2(((bounds.x0 + bounds.x1) / 2.0) as f32, ((bounds.y0 + bounds.y1) / 2.0) as f32); + let (at, _) = field(&mut u, "X"); + click(&mut u, at); + assert_eq!(u.ctx.memory(|m| m.focused()), Some(Id::unique("pos_x").with("te"))); + assert!(u.ctx.text_edit_focused()); + u.frame(vec![Event::Text("77".into())]); + assert_eq!(u.ctx.data(|d| d.get_temp::(Id::unique("pos_x").with("editing"))), Some("77".into())); + assert_eq!(geom(&u, a).0, 10.0, "A's raw draft is not committed yet"); + assert_eq!(u.app.session.ds().unwrap().uid, owner, "the save begins with A presented"); + let reply = if route == "control" { + let (reply, rx) = std::sync::mpsc::channel(); + u.app.control_queue.push(crate::ControlRequest { + id: json!("same-pass-switch"), + method: "engine.execute".into(), + params: json!({"command": "doc.switch", "params": {"index": other_index}, "window": 0}), + reply, + cancelled: Default::default(), + }); + Some(rx) + } else { + None + }; + let mut modifiers = if cfg!(target_os = "macos") { Modifiers::COMMAND } else { Modifiers::COMMAND.plus(Modifiers::CTRL) }; + if save_as { + modifiers = modifiers.plus(Modifiers::SHIFT); + } + let mut events = vec![ + Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: true, repeat: false, modifiers }, + Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers }, + ]; + if route == "tab" { + events.push(Event::PointerMoved(tab_at)); + for pressed in [true, false] { + events.push(Event::PointerButton { pos: tab_at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + } + } + if discard { + events.insert(0, Event::ModifiersChanged(modifiers)); + u.frame_raw_with_discard(egui::RawInput { events, ..Default::default() }); + } else { + u.frame_with(events, modifiers); + } + if let Some(reply) = reply { + let reply = reply.try_recv().expect("real control::drain acknowledged doc.switch in this pass"); + assert!(reply.result.is_ok(), "{:?}", reply.result); + } + let unchanged = |u: &Ui| { + assert_eq!(u.app.session.ds().unwrap().uid, other_uid, "the real same-pass route presents B"); + assert_eq!(geom(u, b), (200.0, 40.0, 30.0, 30.0), "A's draft cannot enter B's field: route={route}, save_as={save_as}, discard={discard}"); + assert!(std::sync::Arc::ptr_eq(&u.app.session.ds().unwrap().doc, &other_model), "B's model is untouched"); + assert_eq!(u.app.session.ds().unwrap().revision, other_revision); + assert_eq!(u.app.session.undo_depth(), other_history, "B's undo/redo history is unchanged"); + assert_eq!(u.app.session.ds().unwrap().selection, other_selection); + assert_eq!(u.app.session.ds().unwrap().is_dirty(), other_dirty); + assert_eq!(std::fs::read(&other).unwrap(), other_bytes); + assert_eq!(std::fs::read(&path).unwrap(), owner_bytes); + assert_eq!(u.app.session.docs.iter().find(|d| d.uid == owner).unwrap().doc.world_bounds(a).unwrap().x0, 10.0); + assert_eq!(asked.get(), 0, "the cancelled request never asks for a path"); + assert!(!copy.exists()); + assert!(u.app.ui.after_field.is_none(), "cancel before late fallback or queueing"); + }; + unchanged(&u); + for _ in 0..3 { + u.frame(vec![]); + unchanged(&u); + } + // A cancelled draft is not a permanent veto: B accepts a later real field edit + // and saves its own value through the same focused-field shortcut path. + let (at, _) = field(&mut u, "X"); + click(&mut u, at); + assert_eq!(u.ctx.memory(|m| m.focused()), Some(Id::unique("pos_x").with("te"))); + u.frame(vec![Event::Text("88".into())]); + assert_eq!(geom(&u, b).0, 200.0, "fresh B text still waits for a normal commit"); + assert_eq!(u.ctx.data(|d| d.get_temp::(Id::unique("pos_x").with("editing"))), Some("88".into())); + u.frame_with( + vec![ + Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: true, repeat: false, modifiers }, + Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers }, + ], + modifiers, + ); + assert_eq!(geom(&u, b).0, 88.0, "a fresh B field commits normally"); + assert!(u.app.ui.after_field.is_some()); + for _ in 0..3 { + u.frame(vec![]); + } + assert_eq!(u.app.session.ds().unwrap().uid, other_uid); + assert_eq!(saved_x(if save_as { © } else { &other }, b), 88.0, "a later valid save belongs to B"); + if save_as { + assert_eq!(std::fs::read(&other).unwrap(), other_bytes); + } + assert_eq!(std::fs::read(&path).unwrap(), owner_bytes, "A stays unsaved"); + assert_eq!(asked.get(), usize::from(save_as)); + assert!(u.app.ui.after_field.is_none()); + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 9aa72c12..2abb1cfc 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -674,153 +674,185 @@ fn text_keys(app: &mut FigApp, ctx: &Context, events: &[Event], ime_input: Optio } match ev { Event::Text(t) => { - if skip_text { - skip_text = false; - continue; - } - let before = ime_stamp(app); - if let Err(e) = figcraft_engine::textedit::insert(&mut app.session, t) { - app.toast(e.to_string()); - } - if ime_stamp(app) != before { - note_ime_application_change(app, ctx); + if !std::mem::take(&mut skip_text) { + insert_text(app, ctx, t); } } - // An accepted IME commit earns an explicit receipt only around the exact mutation. - // List AutoFormat inside the same command is an application transformation. - Event::Ime(egui::ImeEvent::Commit(t)) => { - let before = ime_stamp(app); - match figcraft_engine::textedit::insert_ime_commit(&mut app.session, t) { - Ok(ImeInsertion::Plain) => ime_receipt(app, ctx, before), - Ok(ImeInsertion::Transformed) => note_ime_application_change(app, ctx), - Ok(ImeInsertion::Nothing) => {} - Err(e) => app.toast(e.to_string()), - } - } - // The composition shows inline at the caret; the document gets it on Commit. - Event::Ime(egui::ImeEvent::Preedit { text, active_range_chars }) => { - let cursor = active_range_chars.as_ref().map(|r| r.start); - if let Err(e) = figcraft_engine::textedit::preedit(&mut app.session, text, cursor) { - app.toast(e.to_string()); - } - } - // An IME replacing text around the caret (0.37's incoming byte-counted deletion) - // deletes it before the paired Commit inserts the replacement. - Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes, after_bytes }) => { - let before = ime_stamp(app); - match figcraft_engine::textedit::delete_surrounding_bytes(&mut app.session, *before_bytes, *after_bytes) { - Ok(true) => ime_receipt(app, ctx, before), - Ok(false) => {} - Err(e) => app.toast(e.to_string()), - } - } - Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars, after_chars }) => { - let before = ime_stamp(app); - match figcraft_engine::textedit::delete_surrounding_chars(&mut app.session, *before_chars, *after_chars) { - Ok(true) => ime_receipt(app, ctx, before), - Ok(false) => {} - Err(e) => app.toast(e.to_string()), - } - } - Event::Paste(t) => { - // Plain text only: the engine normalises line endings and drops control chars. - let before = ime_stamp(app); - if let Err(e) = figcraft_engine::textedit::insert(&mut app.session, t) { - app.toast(e.to_string()); - } - if ime_stamp(app) != before { - note_ime_application_change(app, ctx); - } - } - Event::Copy | Event::Cut => { - let before = ime_stamp(app); - let selected = figcraft_engine::textedit::selected_text(&app.session).filter(|t| !t.is_empty()); - // Input injected through the control channel must never read or replace the user's - // system clipboard. It may still edit the document: a synthetic Cut deletes the - // selection exactly like a user's Cut; only the OS clipboard write is fenced. - if !app.control_synthetic - && let Some(text) = &selected - { - app.clipboard_set_text(text); - } - if matches!(ev, Event::Cut) && selected.is_some() { - let _ = figcraft_engine::textedit::key(&mut app.session, "Delete", false, false, false); - } - if ime_stamp(app) != before { - note_ime_application_change(app, ctx); - } - } - Event::Key { key, physical_key, pressed: true, modifiers: m, .. } => { - // Keys during a composition are the IME's (Enter picks a candidate, Escape cancels). - if app.session.ds().is_ok_and(|d| d.text_edit.as_ref().is_some_and(|te| te.preedit.is_some())) { - continue; - } - if m.command || m.alt || m.ctrl { - let before = ime_stamp(app); - if text_shortcut(app, *key, *physical_key, *m) { - // A text shortcut that changed the committed text/selection (a list, - // Bold, alignment) is an application edit; Save and friends are not. - if ime_stamp(app) != before { - note_ime_application_change(app, ctx); - } - skip_text = true; - continue; - } - } - let name = match key { - Key::Backspace => "Backspace", - Key::Delete => "Delete", - Key::ArrowLeft => "Left", - Key::ArrowRight => "Right", - Key::ArrowUp => "Up", - Key::ArrowDown => "Down", - Key::Home => "Home", - Key::End => "End", - Key::Enter => "Enter", - Key::Escape => "Escape", - Key::Tab => "Tab", - Key::A if m.command => "SelectAll", - Key::Z if m.command => { - note_ime_application_change(app, ctx); - let _ = figcraft_engine::textedit::end(&mut app.session); - app.run(if m.shift { "edit.redo" } else { "edit.undo" }, json!({})); - continue; - } - _ => continue, - }; - if name == "Enter" && m.command { - let _ = figcraft_engine::textedit::end(&mut app.session); - continue; - } - let before = ime_stamp(app); - let (word, line) = keymap::text_motion(platform(), name, mods(*m)); - if let Err(e) = figcraft_engine::textedit::key(&mut app.session, name, m.shift, word, line) { - app.toast(e.to_string()); - } - if ime_stamp(app) != before { - note_ime_application_change(app, ctx); - } + Event::Ime(ime) => ime_event(app, ctx, ime), + Event::Paste(t) => insert_text(app, ctx, t), + Event::Copy => copy_or_cut(app, ctx, false), + Event::Cut => copy_or_cut(app, ctx, true), + Event::Key { key, physical_key, pressed: true, modifiers: m, .. } if text_key_event(app, ctx, *key, *physical_key, *m) => { + skip_text = true; } _ => {} } } // Triple-click on the edited text selects its line (double-click is handled by the engine). - if ctx.input(|i| i.pointer.button_triple_clicked(egui::PointerButton::Primary)) - && let Some(pos) = ctx.input(|i| i.pointer.interact_pos()) - && app.canvas.rect.contains(pos) - && let Some(te) = app.session.ds().ok().and_then(|d| d.text_edit.clone()) - { - let p = app.to_page(pos); - let inside = app.session.doc().ok().and_then(|d| d.world_bounds(te.id)).is_some_and(|b| b.contains(p)); - if inside && let Ok(idx) = app.session.text_index_at(te.id, p) { + triple_click_line(app, ctx); + app.sync_preedit(); +} + +/// Insert plain text (the engine normalises line endings and drops control chars) into the edit, +/// noting the application change the mutation caused. +fn insert_text(app: &mut FigApp, ctx: &Context, t: &str) { + let before = ime_stamp(app); + if let Err(e) = figcraft_engine::textedit::insert(&mut app.session, t) { + app.toast(e.to_string()); + } + if ime_stamp(app) != before { + note_ime_application_change(app, ctx); + } +} + +/// An IME commit, preedit or surrounding-text deletion for the edit. +fn ime_event(app: &mut FigApp, ctx: &Context, ev: &egui::ImeEvent) { + match ev { + // An accepted IME commit earns an explicit receipt only around the exact mutation. + // List AutoFormat inside the same command is an application transformation. + egui::ImeEvent::Commit(t) => { let before = ime_stamp(app); - app.run("text.edit.selectLine", json!({"index": idx})); + match figcraft_engine::textedit::insert_ime_commit(&mut app.session, t) { + Ok(ImeInsertion::Plain) => ime_receipt(app, ctx, before), + Ok(ImeInsertion::Transformed) => note_ime_application_change(app, ctx), + Ok(ImeInsertion::Nothing) => {} + Err(e) => app.toast(e.to_string()), + } + } + // The composition shows inline at the caret; the document gets it on Commit. + egui::ImeEvent::Preedit { text, active_range_chars } => { + let cursor = active_range_chars.as_ref().map(|r| r.start); + if let Err(e) = figcraft_engine::textedit::preedit(&mut app.session, text, cursor) { + app.toast(e.to_string()); + } + } + // An IME replacing text around the caret (0.37's incoming byte-counted deletion) + // deletes it before the paired Commit inserts the replacement. + egui::ImeEvent::DeleteSurroundingBytes { before_bytes, after_bytes } => { + let before = ime_stamp(app); + match figcraft_engine::textedit::delete_surrounding_bytes(&mut app.session, *before_bytes, *after_bytes) { + Ok(true) => ime_receipt(app, ctx, before), + Ok(false) => {} + Err(e) => app.toast(e.to_string()), + } + } + egui::ImeEvent::DeleteSurrounding { before_chars, after_chars } => { + let before = ime_stamp(app); + match figcraft_engine::textedit::delete_surrounding_chars(&mut app.session, *before_chars, *after_chars) { + Ok(true) => ime_receipt(app, ctx, before), + Ok(false) => {} + Err(e) => app.toast(e.to_string()), + } + } + // Legacy Enabled/Disabled events were ignored by the original event dispatcher too. + _ => {} + } +} + +/// Copy or cut the edit's selection. Input injected through the control channel must never read +/// or replace the user's system clipboard; it may still edit the document: a synthetic Cut +/// deletes the selection exactly like a user's Cut; only the OS clipboard write is fenced. +fn copy_or_cut(app: &mut FigApp, ctx: &Context, cut: bool) { + let before = ime_stamp(app); + let selected = figcraft_engine::textedit::selected_text(&app.session).filter(|t| !t.is_empty()); + if !app.control_synthetic + && let Some(text) = &selected + { + app.clipboard_set_text(text); + } + if cut && selected.is_some() { + let _ = figcraft_engine::textedit::key(&mut app.session, "Delete", false, false, false); + } + if ime_stamp(app) != before { + note_ime_application_change(app, ctx); + } +} + +/// A text-editing key press. `true` when it ran a shortcut, whose following Text event is +/// swallowed (a shortcut that changed committed text/selection is also an application edit). +fn text_key_event(app: &mut FigApp, ctx: &Context, key: Key, physical: Option, m: Modifiers) -> bool { + // Keys during a composition are the IME's (Enter picks a candidate, Escape cancels). + if app.session.ds().is_ok_and(|d| d.text_edit.as_ref().is_some_and(|te| te.preedit.is_some())) { + return false; + } + if m.command || m.alt || m.ctrl { + let before = ime_stamp(app); + if text_shortcut(app, key, physical, m) { + // A text shortcut that changed the committed text/selection (a list, + // Bold, alignment) is an application edit; Save and friends are not. if ime_stamp(app) != before { note_ime_application_change(app, ctx); } + return true; } } - app.sync_preedit(); + if key == Key::Z && m.command { + note_ime_application_change(app, ctx); + let _ = figcraft_engine::textedit::end(&mut app.session); + app.run(if m.shift { "edit.redo" } else { "edit.undo" }, json!({})); + return false; + } + let Some(name) = text_key_name(key, m.command) else { return false }; + if name == "Enter" && m.command { + let _ = figcraft_engine::textedit::end(&mut app.session); + return false; + } + let before = ime_stamp(app); + let (word, line) = keymap::text_motion(platform(), name, mods(m)); + if let Err(e) = figcraft_engine::textedit::key(&mut app.session, name, m.shift, word, line) { + app.toast(e.to_string()); + } + if ime_stamp(app) != before { + note_ime_application_change(app, ctx); + } + false +} + +/// The text-edit command a named key runs, if any. +fn text_key_name(key: Key, command: bool) -> Option<&'static str> { + Some(match key { + Key::Backspace => "Backspace", + Key::Delete => "Delete", + Key::ArrowLeft => "Left", + Key::ArrowRight => "Right", + Key::ArrowUp => "Up", + Key::ArrowDown => "Down", + Key::Home => "Home", + Key::End => "End", + Key::Enter => "Enter", + Key::Escape => "Escape", + Key::Tab => "Tab", + Key::A if command => "SelectAll", + _ => return None, + }) +} + +/// Triple-click on the edited text selects its line (double-click is handled by the engine). +fn triple_click_line(app: &mut FigApp, ctx: &Context) { + if !ctx.input(|i| i.pointer.button_triple_clicked(egui::PointerButton::Primary)) { + return; + } + let Some(pos) = ctx.input(|i| i.pointer.interact_pos()).filter(|pos| app.canvas.rect.contains(*pos)) else { + return; + }; + let Some(te) = app.session.ds().ok().and_then(|d| d.text_edit.clone()) else { return }; + let p = app.to_page(pos); + if let Some(idx) = text_line_index(app, te.id, p) { + let before = ime_stamp(app); + app.run("text.edit.selectLine", json!({"index": idx})); + if ime_stamp(app) != before { + note_ime_application_change(app, ctx); + } + } +} + +/// The text index of the line under `p`, when the edit's node contains it. +fn text_line_index(app: &FigApp, id: figcraft_doc::NodeId, p: figcraft_geom::Point) -> Option { + let inside = app.session.doc().ok().and_then(|d| d.world_bounds(id)).is_some_and(|b| b.contains(p)); + if !inside { + return None; + } + app.session.text_index_at(id, p).ok() } /// Shortcuts live while editing text (Bold, lists, alignment, size/weight/spacing steps, Save…). From fc344de33c33e52baff87d348186fd468cafcdde Mon Sep 17 00:00:00 2001 From: skylartaylor Date: Sun, 25 Oct 2026 08:10:37 +0100 Subject: [PATCH 28/54] M0: correct the migration qualification reference --- docs/egui-037-migration-plan.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/egui-037-migration-plan.md b/docs/egui-037-migration-plan.md index 6b8c5059..6cf1bb09 100644 --- a/docs/egui-037-migration-plan.md +++ b/docs/egui-037-migration-plan.md @@ -1189,8 +1189,9 @@ validation and generates the lockfile from its local dependency config. ## Historical local qualification — 2026-10-25 The following counts apply to the earlier `10702311` integration, not the later -`2a3b7d4f` qualification in the status update above. The intervening `9f788753` -refresh passed 3,247 executions; the runs listed below passed 3,242. +`2a3b7d4f` qualification documented in `docs/egui-037-local-followups.md`. The +status update above covers the subsequent `92682728` integration. The intervening +`9f788753` refresh passed 3,247 executions; the runs listed below passed 3,242. The migration preserves captured main `10702311f345393ee1d8868c3257679befcfa14d` (53 commits and 114 paths beyond the first integration). The final review commit records From a107e62bcc283d837109a20d9e40202acad97b7c Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:13:55 +0100 Subject: [PATCH 29/54] Fix missing end arrowhead when a closed subpath follows an open one Found while testing crates/pathops/src/points.rs:89-92 (Styled::arrowheads) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/pathops/src/lib.rs | 23 +++++++++++++++++++++++ crates/pathops/src/points.rs | 4 ++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index f4b30e10..1d6dd999 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -693,6 +693,29 @@ mod tests { assert_ne!(explicit, layer_only); } + #[test] + fn layer_end_arrow_is_drawn_when_a_closed_run_follows_the_open_one() { + let verts = [(0.0, 0.0), (50.0, 0.0), (0.0, 100.0), (50.0, 100.0), (25.0, 150.0)]; + let net = VectorNetwork { + vertices: verts + .iter() + .enumerate() + .map(|(i, &(x, y))| figcraft_doc::Vertex { cap: (i == 0).then_some(StrokeCap::Round), ..figcraft_doc::Vertex::new(x, y) }) + .collect(), + segments: vec![ + figcraft_doc::Segment::line(0, 1), + figcraft_doc::Segment::line(2, 3), + figcraft_doc::Segment::line(3, 4), + figcraft_doc::Segment::line(4, 2), + ], + ..Default::default() + }; + let path = BezPath::new(); + let plain = StrokeProps { weight: 2.0, ..Default::default() }; + let arrow = StrokeProps { cap_end: StrokeCap::TriangleArrow, ..plain.clone() }; + assert_ne!(stroke_outline_of(&path, &arrow, false, Some(&net)), stroke_outline_of(&path, &plain, false, Some(&net))); + } + #[test] fn equal_joins_do_not_break_closed_stroke_runs() { let path = sq(0.0, 0.0, 20.0); diff --git a/crates/pathops/src/points.rs b/crates/pathops/src/points.rs index 05f58216..650f309f 100644 --- a/crates/pathops/src/points.rs +++ b/crates/pathops/src/points.rs @@ -86,8 +86,8 @@ impl Styled { /// and last end (as without point styles). pub(crate) fn arrowheads(&self, weight: f64, props: &StrokeProps) -> BezPath { let mut out = BezPath::new(); - let last = self.runs.len().saturating_sub(1); - for (k, run) in self.runs.iter().enumerate().filter(|(_, r)| !r.closed) { + let last = self.runs.iter().filter(|r| !r.closed).count().saturating_sub(1); + for (k, run) in self.runs.iter().filter(|r| !r.closed).enumerate() { let start = self.cap_at(run.start, if k == 0 { props.cap_start } else { StrokeCap::None }); let end = self.cap_at(run.end, if k == last { props.cap_end } else { StrokeCap::None }); for (at_end, cap) in [(false, start), (true, end)] { From b751271c2ac0588a13a67781e7bae8dfc1023202 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 25 Oct 2026 14:52:12 +0100 Subject: [PATCH 30/54] M-egui037: document input lifecycle and UI regression contracts --- apps/figcraft-web/src/web.rs | 3 ++ apps/figcraft/src/control_server.rs | 3 ++ apps/figcraft/src/control_server_tests.rs | 9 ++++ apps/figcraft/src/main.rs | 3 ++ crates/collab-net/src/sessions_tests.rs | 3 ++ crates/engine/src/cmd/edit.rs | 4 ++ crates/engine/src/text_behaviour_tests.rs | 5 +++ crates/engine/src/textedit.rs | 32 +++++++++++++ crates/engine/src/textfit_tests.rs | 2 + crates/engine/src/tools.rs | 4 ++ crates/text/build.rs | 21 +++++++++ crates/ui-egui/src/a11y.rs | 2 + crates/ui-egui/src/a11y_tests.rs | 2 + crates/ui-egui/src/assets_panel.rs | 13 ++++++ crates/ui-egui/src/canvas.rs | 4 ++ crates/ui-egui/src/clipboard_fence_tests.rs | 16 +++++++ crates/ui-egui/src/colorpicker.rs | 9 ++++ crates/ui-egui/src/control.rs | 13 ++++++ crates/ui-egui/src/control_capture_tests.rs | 45 +++++++++++++++++++ crates/ui-egui/src/credits_tests.rs | 2 + crates/ui-egui/src/dialogs.rs | 4 ++ crates/ui-egui/src/field_tests.rs | 9 ++++ crates/ui-egui/src/frame_labels.rs | 3 ++ crates/ui-egui/src/frame_presets.rs | 2 + crates/ui-egui/src/guides.rs | 1 + crates/ui-egui/src/handles.rs | 1 + crates/ui-egui/src/home.rs | 4 ++ crates/ui-egui/src/idle_tests.rs | 7 +++ crates/ui-egui/src/ime_tests.rs | 42 +++++++++++++++++ crates/ui-egui/src/layers.rs | 8 ++++ crates/ui-egui/src/layers_behaviour_tests.rs | 3 ++ crates/ui-egui/src/layers_rename_tests.rs | 2 + crates/ui-egui/src/layout_panel.rs | 2 + crates/ui-egui/src/lib.rs | 10 +++++ crates/ui-egui/src/live_join_tests.rs | 1 + crates/ui-egui/src/live_server_tests.rs | 1 + crates/ui-egui/src/live_share_tests.rs | 5 +++ crates/ui-egui/src/live_tests.rs | 2 + crates/ui-egui/src/menu_aim_tests.rs | 3 ++ crates/ui-egui/src/menu_input_tests.rs | 2 + crates/ui-egui/src/menus.rs | 6 +++ crates/ui-egui/src/panel_remove_tests.rs | 5 +++ crates/ui-egui/src/panel_reorder_tests.rs | 2 + crates/ui-egui/src/perf_tests.rs | 3 ++ crates/ui-egui/src/plugin_panel_tests.rs | 3 ++ crates/ui-egui/src/plugins_ui.rs | 5 +++ crates/ui-egui/src/present_tests.rs | 2 + crates/ui-egui/src/present_view_tests.rs | 3 ++ crates/ui-egui/src/proto_canvas.rs | 3 ++ crates/ui-egui/src/proto_editor.rs | 1 + crates/ui-egui/src/proto_editor_tests.rs | 5 +++ crates/ui-egui/src/proto_panel.rs | 10 +++++ crates/ui-egui/src/proto_reorder_tests.rs | 1 + crates/ui-egui/src/right.rs | 18 ++++++++ crates/ui-egui/src/rtl_names_tests.rs | 11 +++++ crates/ui-egui/src/section_panel.rs | 3 ++ crates/ui-egui/src/select_all_tests.rs | 6 +++ crates/ui-egui/src/selection_colors.rs | 10 +++++ crates/ui-egui/src/selection_spacing.rs | 6 +++ crates/ui-egui/src/selection_spacing_tests.rs | 3 ++ crates/ui-egui/src/shortcuts.rs | 11 +++++ crates/ui-egui/src/style_picker_tests.rs | 2 + crates/ui-egui/src/tabs.rs | 2 + crates/ui-egui/src/theme.rs | 2 + crates/ui-egui/src/theme_startup_tests.rs | 2 + crates/ui-egui/src/titlebar_tests.rs | 2 + crates/ui-egui/src/toolbar.rs | 3 ++ crates/ui-egui/src/type_settings_tests.rs | 2 + crates/ui-egui/src/typography.rs | 5 +++ crates/ui-egui/src/typography_scrub_tests.rs | 3 ++ crates/ui-egui/src/variable_ui.rs | 1 + crates/ui-egui/src/variables_panel.rs | 9 ++++ crates/ui-egui/src/vector_panel.rs | 2 + crates/ui-egui/src/versions.rs | 2 + crates/ui-egui/src/widgets.rs | 3 ++ crates/ui-egui/src/windows_tests.rs | 14 ++++++ 76 files changed, 478 insertions(+) diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index 802d229a..40451d43 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -71,6 +71,9 @@ struct WebShell { impl eframe::App for WebShell { // eframe runs `logic` for visible tabs and, with only `run_logic`, for hidden ones: keep // autosave/live/control deadlines moving without a paint. `FigApp::ui` does not repeat it. + /// Run the browser shell's eframe logic callback: guard `background_tick` so hidden tabs + /// still advance autosave, live and control deadlines without painting. `FigApp::ui` does + /// not repeat it. fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { figcraft_ui_egui::windows::guard(&mut self.fig, ctx, |fig| fig.background_tick(ctx)); } diff --git a/apps/figcraft/src/control_server.rs b/apps/figcraft/src/control_server.rs index 2af5610d..ada44c0f 100644 --- a/apps/figcraft/src/control_server.rs +++ b/apps/figcraft/src/control_server.rs @@ -97,6 +97,9 @@ fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut } } +/// Serve one control connection: read bounded JSON lines, reject oversized frames and a +/// full queue, queue one request, wait under its gate, and write one reply. Non-JSON input +/// answers with an error and closes the connection so HTTP body lines cannot become commands. fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> std::io::Result<()> { out.set_read_timeout(Some(timeout))?; out.set_write_timeout(Some(timeout))?; diff --git a/apps/figcraft/src/control_server_tests.rs b/apps/figcraft/src/control_server_tests.rs index 6144cdf3..f29a7773 100644 --- a/apps/figcraft/src/control_server_tests.rs +++ b/apps/figcraft/src/control_server_tests.rs @@ -28,6 +28,7 @@ fn reply(reader: &mut BufReader) -> Value { serde_json::from_str(&line).unwrap() } +/// Build a `ui.inspect` control request with a throwaway reply channel. fn request() -> ControlRequest { ControlRequest { id: json!(0), method: "ui.inspect".into(), params: json!({}), reply: mpsc::channel().0, state: Default::default() } } @@ -115,6 +116,8 @@ fn full_queue_rejects_before_dispatch_and_ui_queue_stays_bounded() { worker.join().unwrap().unwrap(); } +/// Asserts a commit that writes the file and queues its reply wins the gate: the transport's +/// timeout branch consumes that reply and leaves the state `Replied`. #[test] fn a_committed_reply_wins_over_the_transport_timeout() { let dir = std::env::temp_dir().join(format!("figcraft-control-commit-{}", std::process::id())); @@ -139,6 +142,7 @@ fn a_committed_reply_wins_over_the_transport_timeout() { let _ = std::fs::remove_dir_all(&dir); } +/// Asserts a zero-duration wait reports `Timeout` and cancels the open gate. #[test] fn a_timeout_cancels_an_open_request() { let gate = RequestGate::new(); @@ -147,6 +151,8 @@ fn a_timeout_cancels_an_open_request() { assert_eq!(gate.state(), RequestState::Cancelled, "nothing may write after the timeout"); } +/// Asserts a closed reply channel reports `Disconnected` and leaves the gate `Open`, not +/// cancelled. #[test] fn a_disconnected_reply_channel_is_not_reported_as_a_timeout() { let gate = RequestGate::new(); @@ -268,6 +274,9 @@ fn a_limit_sized_frame_is_accepted_at_eof() { worker.join().unwrap().unwrap(); } +/// Asserts expired requests stop occupying the UI queue cap, a later request is admitted +/// and answered, and a window that finally draws discards its expired command before +/// dispatch. #[test] fn timed_out_requests_release_a_full_deferred_ui_queue() { use figcraft_ui_egui::{FigApp, NoServices, control, windows}; diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index be380c60..4a1488ad 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -62,6 +62,9 @@ struct App { } impl eframe::App for App { + /// Native eframe logic callback: store the first context, drain control requests, run + /// guarded background work, poll macOS open events, handle close requests and title + /// changes, and schedule recovery repaints on the app clock. fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { { let mut slot = self.ctx_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner); diff --git a/crates/collab-net/src/sessions_tests.rs b/crates/collab-net/src/sessions_tests.rs index 3c39e4d8..cf569a6d 100644 --- a/crates/collab-net/src/sessions_tests.rs +++ b/crates/collab-net/src/sessions_tests.rs @@ -89,6 +89,9 @@ fn rect(s: &mut Session) -> u64 { s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap() } +/// Asserts two files are hosted and joined independently: each has its own port, session and +/// edits, presence stays in its file's session, and closing one file's session leaves the +/// other live. #[test] fn two_files_are_hosted_and_joined_independently() { let mut host = session("Hana"); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 044ae884..38b24ea6 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -601,6 +601,10 @@ pub fn design_clipboard_buffer(html: &str) -> Option> { figcraft_format::fig::is_fig(&bytes).then_some(bytes) } +/// Paste design layers from clipboard HTML or base64 `data`: decode and reject missing, +/// invalid or oversized data, load a temporary `.fig`, take its top-level page layers, +/// store them as a foreign-file clipboard source with a bumped layers generation, then +/// delegate to `paste` and append import warnings. fn paste_design_layers(s: &mut Session, p: &Value) -> Result { const CMD: &str = "edit.pasteDesignLayers"; let bytes = match (string(p, "html"), string(p, "data")) { diff --git a/crates/engine/src/text_behaviour_tests.rs b/crates/engine/src/text_behaviour_tests.rs index d88066cb..8cd8b145 100644 --- a/crates/engine/src/text_behaviour_tests.rs +++ b/crates/engine/src/text_behaviour_tests.rs @@ -134,6 +134,8 @@ fn ime_composition_with_cjk() { assert!(s.ds().unwrap().text_edit.as_ref().unwrap().preedit.is_none()); } +/// Asserts `delete_surrounding_bytes` removes an exact UTF-8 byte range across CJK and a +/// supplementary emoji, leaves nothing past the start, and the whole edit is one undo step. #[test] fn ime_delete_surrounding_bytes_is_exact_and_one_undo_step() { // 0.37's `DeleteSurroundingBytes` counts UTF-8 bytes around the selection, before the paired @@ -156,6 +158,9 @@ fn ime_delete_surrounding_bytes_is_exact_and_one_undo_step() { assert_eq!(chars(&s, t), "ab\u{4F60}\u{597D}c\u{1F600}d", "one undo restores the whole session"); } +/// Asserts a byte range that only partly covers a character leaves it in place, while an +/// exact range deletes it, and a selection survives for the paired commit with its +/// direction preserved. #[test] fn ime_delete_surrounding_bytes_keeps_partial_characters_and_selection() { // The fork deletes the byte range exactly and leaves a character the range only partly diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index 9c62c331..6551783c 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -69,6 +69,8 @@ pub struct ImeSurrounding { /// A mutex (rather than an atomic) also works on wasm32 targets without 64-bit atomics. static EDITING_SESSION: LazyLock> = LazyLock::new(|| Mutex::new(0)); +/// Return the next editing-session token from a mutex-protected counter (`wrapping_add`). +/// Zero is a valid token; the mutex also works on wasm32 without 64-bit atomics. fn next_editing_session() -> u64 { let mut session = match EDITING_SESSION.lock() { Ok(session) => session, @@ -143,6 +145,9 @@ fn range(te: &TextEdit) -> (usize, usize) { (te.caret.min(te.anchor), te.caret.max(te.anchor)) } +/// Apply an unconditional text-model mutation through `edit_if`, whose success clears the +/// composition, clamps caret and anchor to the character count, marks a `characters` override +/// when the node is source-backed, normalizes the document and bumps the revision. fn edit(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit)) -> Result<()> { edit_if(s, |t, te| { f(t, te); @@ -230,6 +235,11 @@ pub fn insert_ime_commit(s: &mut Session, text: &str) -> Result { insert_model(s, text, true) } +/// Insert committed text, optionally as an IME commit: normalize line endings, drop control +/// characters except `\n`/`\t`, ignore a bare newline/CR IME commit, clear an active +/// composition for an empty or control-only IME commit, and turn a committed space after a +/// line-start `-`, `*` or `1.` into a bulleted/numbered list. Reports whether the insertion was +/// plain, transformed, or a no-op. fn insert_model(s: &mut Session, text: &str, ime_commit: bool) -> Result { // Stock `TextEdit` deliberately ignores these two commits (they are not a mutation); only a // real commit may do so, since typing Enter goes through `insert`. @@ -894,6 +904,9 @@ mod tests { 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"); @@ -1132,6 +1145,8 @@ mod tests { } } + /// 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"); @@ -1146,6 +1161,8 @@ mod tests { 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"); @@ -1162,6 +1179,8 @@ mod tests { 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); @@ -1182,6 +1201,8 @@ mod tests { 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"); @@ -1197,6 +1218,8 @@ mod tests { 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"); @@ -1215,6 +1238,8 @@ mod tests { 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"); @@ -1235,6 +1260,7 @@ mod tests { 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"); @@ -1250,6 +1276,8 @@ mod tests { 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("-"); @@ -1261,6 +1289,8 @@ mod tests { 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"); @@ -1283,6 +1313,8 @@ mod tests { 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"); diff --git a/crates/engine/src/textfit_tests.rs b/crates/engine/src/textfit_tests.rs index f9508942..e35f810c 100644 --- a/crates/engine/src/textfit_tests.rs +++ b/crates/engine/src/textfit_tests.rs @@ -64,6 +64,8 @@ fn a_one_word_last_line_is_avoided() { assert!(l.lines.len() == 1 || words.split_whitespace().count() > 1 || words.contains('\u{a0}'), "{words:?}"); } +/// Asserts `data.fill` keeps a short copy's font size, shrinks a long copy, and leaves the +/// source text layer untouched. #[test] fn data_fill_sizes_each_copys_text() { figcraft_text::scan_system_fonts(); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index e6776d2c..e65afe2c 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -2010,6 +2010,10 @@ impl Session { 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. fn double_click(&mut self, ev: &PointerEvent) -> Result<()> { let ds = self.ds()?; let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); diff --git a/crates/text/build.rs b/crates/text/build.rs index ecca6a45..ba087983 100644 --- a/crates/text/build.rs +++ b/crates/text/build.rs @@ -6,6 +6,9 @@ use std::fmt::Write as _; use std::path::PathBuf; +/// Font build script: emit `craft_fonts.rs` from `input_fonts`, passing the +/// `CRAFT_FONTS_DIR`, `CRAFT_FONTS_REQUIRED`, wasm target and `FIGCRAFT_EMBED_CJK` inputs; +/// report generation and write failures with `cargo::error`. fn main() { println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR"); println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED"); @@ -77,6 +80,10 @@ struct Catalog { faces: Vec<(String, String)>, } +/// Parse a font manifest of `family | style | file | scripts` rows into `CraftFont` +/// entries: skip Adobe-authored families, skip CJK unless requested, keep the web catalog +/// to its budgeted families, canonicalize and watch each face, and emit `include_bytes!` +/// items. fn craft_fonts(dir: &std::path::Path, manifest_rel: &str, wasm: bool, embed_cjk: bool) -> Result { let manifest = dir.join(manifest_rel); watch(&manifest); @@ -121,6 +128,8 @@ mod tests { struct Fixture(PathBuf); impl Fixture { + /// Test fixture: create a unique temporary directory, write a synthetic face file and + /// install the default Georgian manifest. fn new() -> Self { let dir = std::env::temp_dir().join(format!("fig-font-input-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); std::fs::create_dir_all(dir.join("fonts")).unwrap(); @@ -131,6 +140,7 @@ mod tests { fixture } + /// Test fixture: write manifest rows that all point at the fixture's `fonts/face.ttf` face. fn manifest(&self, name: &str, faces: &[(&str, &str)]) { let text = faces.iter().map(|(family, style)| format!("{family} | {style} | fonts/face.ttf | Latn\n")).collect::(); std::fs::write(self.0.join("fonts").join(name), text).unwrap(); @@ -138,11 +148,15 @@ mod tests { } impl Drop for Fixture { + /// Remove the fixture's temporary directory, ignoring errors. fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } + /// Asserts the native Latin catalog keeps its 43 permitted presentation faces, the web + /// catalog keeps only the four Inter faces, Adobe-authored families are absent, and + /// permitted native faces remain. #[test] fn restored_latin_catalog_preserves_permitted_native_faces_and_web_budget() { let f = Fixture::new(); @@ -160,6 +174,7 @@ mod tests { assert!(input_fonts(Some(&f.0), true, true, false).is_ok()); } + /// Asserts required web input rejects a catalog missing Inter Regular or Inter SemiBold. #[test] fn required_web_rejects_core_only_and_partial_latin_catalogs() { let f = Fixture::new(); @@ -168,6 +183,8 @@ mod tests { assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("Inter SemiBold")); } + /// Asserts required web input embeds the Inter and Georgian catalogs (three entries) and + /// rejects a manifest without Noto Sans Georgian Regular. #[test] fn required_web_embeds_both_catalogs_and_requires_georgian() { let f = Fixture::new(); @@ -180,6 +197,8 @@ mod tests { assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("Noto Sans Georgian Regular")); } + /// Asserts absent font input is empty when optional and an error when required, while present + /// input includes Noto Sans Georgian. #[test] fn optional_and_native_inputs_keep_their_existing_policy() { let f = Fixture::new(); @@ -189,6 +208,8 @@ mod tests { assert!(input_fonts(Some(&f.0), false, true, false).unwrap().contains("Noto Sans Georgian")); } + /// Asserts required web input rejects a manifest whose face file is missing, even when every + /// face row is listed. #[test] fn required_web_rejects_missing_asset_even_with_all_face_rows() { let f = Fixture::new(); diff --git a/crates/ui-egui/src/a11y.rs b/crates/ui-egui/src/a11y.rs index 79a28118..3c5fafb6 100644 --- a/crates/ui-egui/src/a11y.rs +++ b/crates/ui-egui/src/a11y.rs @@ -253,6 +253,8 @@ struct Shared { name: String, } +/// Run `f` on the per-context temporary `Shared` accessibility state keyed by +/// `figcraft_a11y`. fn shared(ctx: &Context, f: impl FnOnce(&mut Shared) -> R) -> R { ctx.data_mut(|d| f(d.get_temp_mut_or_default::(Id::unique("figcraft_a11y")))) } diff --git a/crates/ui-egui/src/a11y_tests.rs b/crates/ui-egui/src/a11y_tests.rs index a2c54cbe..14be637d 100644 --- a/crates/ui-egui/src/a11y_tests.rs +++ b/crates/ui-egui/src/a11y_tests.rs @@ -404,6 +404,8 @@ fn the_align_buttons_are_off_for_a_layer_alone_on_the_page() { assert!(!align_left(&tree(&mut u)), "one in a frame aligns to it"); } +/// Asserts side-panel and Pages edges are not focusable, and Tab never stops on the +/// side-panel edges. #[test] fn the_custom_panel_edges_never_enter_the_keyboard_or_focus_order() { // FigCraft's panels are fixed, so egui makes no `panel.__resize` widgets; the draggable edges diff --git a/crates/ui-egui/src/assets_panel.rs b/crates/ui-egui/src/assets_panel.rs index e9f0ffce..db0cc0f7 100644 --- a/crates/ui-egui/src/assets_panel.rs +++ b/crates/ui-egui/src/assets_panel.rs @@ -164,6 +164,7 @@ enum Act { Hide(NodeId, bool), } +/// Stable egui id for the assets drag payload. fn drag_id() -> Id { Id::unique("asset_drag") } @@ -581,6 +582,10 @@ fn render_thumbs(ui: &Ui, w: f32, thumbs: &mut Thumbs, wanted: &[(Source, NodeId } } +/// Draw the Assets panel: refresh libraries, switch to the libraries view when asked, +/// otherwise cache a listing keyed by revision, query and library state, render local and +/// enabled-library assets with reused thumbnails, and dispatch place, style, hide, toggle +/// and drag actions. pub fn show(app: &mut FigApp, ui: &mut Ui) { refresh_libraries(app, ui); if app.ui.libraries { @@ -773,6 +778,8 @@ fn drag_feedback(app: &mut FigApp, ui: &mut Ui, doc: &Document, libs: &HashMap) { let t = Tokens::get(ui.ctx()); let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, Id::unique("asset_ghost"))); @@ -831,6 +838,10 @@ fn libraries_view(app: &mut FigApp, ui: &mut Ui) { }); } +/// Draw the publish form: status, publishable components/styles/collections, description and +/// publish buttons. Run `library.publish` locally or publish to the server in the +/// background, reset the form on success, and report when the file is unsaved so other files +/// cannot yet use it. fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { let form_id = Id::unique("asset_publish_form"); let mut form: PublishForm = ui.data(|d| d.get_temp(form_id)).unwrap_or_default(); @@ -1010,6 +1021,8 @@ fn usage(app: &mut FigApp, ui: &mut Ui, w: f32, link: &str) { ui.add_space(8.0); } +/// Draw the libraries enabled in this file with version/count/status, remove and swap +/// actions, add-file and pasted-link controls, and background fetch/cancel handling. fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { let t = Tokens::get(ui.ctx()); title(ui, w, tr("Libraries in this file")); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4211b3cc..a6770823 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -163,6 +163,10 @@ fn pointer_event(app: &FigApp, kind: PointerKind, pos: Pos2, m: &egui::Modifiers } } +/// Run one canvas pass: update the viewport size and first-fit zoom; handle pointer/key input +/// and accessibility; draw the drop overlay, guides, live pointer and preedit; request/poll +/// renders and upload the texture; draw the page background and last texture; then draw overlays, +/// labels, handles, comments, versions/branch/dev/live UI and the cursor. pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { let rect = ui.max_rect(); app.canvas.rect = rect; diff --git a/crates/ui-egui/src/clipboard_fence_tests.rs b/crates/ui-egui/src/clipboard_fence_tests.rs index 9211a4d4..76ddf81c 100644 --- a/crates/ui-egui/src/clipboard_fence_tests.rs +++ b/crates/ui-egui/src/clipboard_fence_tests.rs @@ -25,14 +25,17 @@ struct Recorded { struct Clipboard(Arc>); impl Services for Clipboard { + /// Record a system-clipboard text write in the test clipboard service. fn clipboard_set_text(&mut self, s: &str) { let mut r = self.0.lock().unwrap(); r.text.push(s.to_string()); r.last_text = Some(s.to_string()); } + /// Return the test system clipboard's last text. fn clipboard_get_text(&mut self) -> Option { self.0.lock().unwrap().last_text.clone() } + /// Count a PNG clipboard write in the test clipboard service. fn clipboard_set_png(&mut self, _w: u32, _h: u32, _rgba: &[u8]) { self.0.lock().unwrap().pngs += 1; } @@ -46,6 +49,7 @@ struct Win { } impl Win { + /// Create a test window over a session containing one selected rectangle. fn new() -> Self { let mut s = Session::new(); s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap(); @@ -54,6 +58,7 @@ impl Win { win } + /// Build a test window around `s` with a recording clipboard service and run three frames. fn with_session(s: Session) -> Self { let recorded = Arc::new(Mutex::new(Recorded::default())); let app = FigApp::new(s, Box::new(Clipboard(recorded.clone()))); @@ -64,6 +69,7 @@ impl Win { win } + /// Run one production UI frame with `events` at the next 60 Hz test time. fn frame(&mut self, events: Vec) { self.time += 1.0 / 60.0; self.app.clock.set_test_now(Some(self.time)); @@ -74,12 +80,15 @@ impl Win { out.textures_delta.clear(); } + /// Return the recorded `(text writes, PNG writes)` count. fn writes(&self) -> (usize, usize) { let r = self.recorded.lock().unwrap(); (r.text.len(), r.pngs) } } +/// Asserts that a control-channel copy shortcut writes no system clipboard, still runs the +/// command and is acknowledged. #[test] fn a_synthetic_copy_shortcut_leaves_the_system_clipboard_alone() { let mut w = Win::new(); @@ -106,6 +115,8 @@ fn a_synthetic_copy_shortcut_leaves_the_system_clipboard_alone() { assert!(rx.try_recv().is_ok(), "the batch is acknowledged"); } +/// Asserts copy commands run under the synthetic-control fence write nothing, while the same +/// command afterwards writes once. #[test] fn a_copy_command_invoked_for_a_synthetic_click_still_keeps_the_clipboard() { let mut w = Win::new(); @@ -120,6 +131,7 @@ fn a_copy_command_invoked_for_a_synthetic_click_still_keeps_the_clipboard() { assert_eq!(w.writes(), (1, 0)); } +/// Return the characters of a text node in the test window. fn edit_text(w: &Win, id: NodeId) -> String { match &w.app.session.doc().unwrap().node(id).unwrap().kind { NodeKind::Text(t) => t.characters.clone(), @@ -127,6 +139,8 @@ fn edit_text(w: &Win, id: NodeId) -> String { } } +/// Asserts a synthetic Cut deletes the selected text, keeps the edit session active, writes +/// no system clipboard, and ends its fence with the frame. #[test] fn a_synthetic_cut_deletes_the_selection_but_leaves_the_users_clipboard_alone() { let mut s = Session::new(); @@ -147,6 +161,8 @@ fn a_synthetic_cut_deletes_the_selection_but_leaves_the_users_clipboard_alone() assert!(!w.app.control_synthetic, "the fence ends with the frame"); } +/// Asserts a programmatic copy leaves the system clipboard alone and invalidates the stale +/// mirror, so a later user paste uses the system text rather than the programmatic layers. #[test] fn a_programmatic_copy_does_not_let_a_stale_mirror_hide_the_users_paste() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index 90d00c3f..f1cde02f 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -339,6 +339,8 @@ fn hue_color(ctx: &egui::Context, h: f32) -> Color32 { doc_c32(ctx, Hsb { h, s: 1.0, b: 1.0 }.to_color()) } +/// Draw the saturation/value square and its knob, updating `hsb.s` and `hsb.b` on click or +/// drag; returns `(changed, done)`. fn sv_square(ui: &mut Ui, rect: Rect, hsb: &mut Hsb) -> (bool, bool) { // White → hue horizontally, then transparent → black vertically. let mut m = Mesh::default(); @@ -377,6 +379,8 @@ fn sv_square(ui: &mut Ui, rect: Rect, hsb: &mut Hsb) -> (bool, bool) { (changed, resp.drag_stopped() || resp.clicked()) } +/// Draw the hue gradient and knob, updating `hsb.h` clamped below 360; returns +/// `(changed, done)`. fn hue_slider(ui: &mut Ui, rect: Rect, hsb: &mut Hsb) -> (bool, bool) { let n = 12; let mut m = Mesh::default(); @@ -417,6 +421,8 @@ fn slider_knob(ui: &mut Ui, rect: Rect, t: f32, id: Id) -> Option<(f32, bool)> { None } +/// Draw the checkerboard and transparent-to-opaque alpha gradient with a knob; returns the +/// new alpha and done state, or `None` when the slider is not interacting. fn alpha_slider(ui: &mut Ui, rect: Rect, color: Color) -> Option<(f32, bool)> { widgets::checker(&ui.painter().with_clip_rect(rect), rect, rect.height() / 2.0); let mut m = Mesh::default(); @@ -441,6 +447,9 @@ pub fn window_id() -> Id { Id::unique("color_picker") } +/// Draw the colour picker for the current target: close it when there is no target or the +/// target's selection no longer matches, otherwise apply eyedropper, paint-kind, colour and +/// gradient-stop edits live, positioned beside a plugin field when applicable. pub fn show(app: &mut FigApp, ctx: &egui::Context) { let Some(target) = app.ui.picker.clone() else { // A colour sampled for a picker that has closed has no paint to go to. diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 4c3be3de..97e62d1f 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -70,18 +70,22 @@ pub struct RequestGate { } impl RequestGate { + /// Create a request gate in its default `Open` state. pub fn new() -> Self { Self::default() } + /// Return a copy of the gate's current request state. pub fn state(&self) -> RequestState { *self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner) } + /// Whether the gate is `Cancelled`. pub fn is_cancelled(&self) -> bool { self.state() == RequestState::Cancelled } + /// Whether the gate is `Replied`. pub fn is_replied(&self) -> bool { self.state() == RequestState::Replied } @@ -121,6 +125,7 @@ pub struct ControlRequest { } impl ControlRequest { + /// Whether this request's gate is `Cancelled`. pub fn is_cancelled(&self) -> bool { self.state.is_cancelled() } @@ -147,6 +152,7 @@ struct SlotInner { } impl CaptureSlot { + /// Create an empty capture slot for `viewport`, holding a clone of the UI context. fn new(ctx: &Context, viewport: egui::ViewportId) -> Arc { Arc::new(Self { inner: Mutex::new(SlotInner::default()), ctx: ctx.clone(), viewport }) } @@ -161,6 +167,8 @@ impl CaptureSlot { }) } + /// Store the first delivered image only while the slot is open and untaken, ignoring later + /// or closed deliveries, then wake the viewport after releasing the lock. pub(crate) fn complete(&self, image: Arc) { { let mut inner = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner); @@ -214,6 +222,7 @@ pub struct CaptureSpec { const SHOT_PNG_OVERHEAD: usize = 64 * 1024; impl CaptureSpec { + /// Return the checked pixel byte count (`width × height × 4`), or `None` on overflow. fn bytes(&self) -> Option { self.size_px[0].checked_mul(self.size_px[1])?.checked_mul(4) } @@ -272,6 +281,7 @@ pub struct ShotRequest { } impl ShotRequest { + /// Close and drop the capture slot, if any, and zero the reserved byte count. fn release(&mut self) { if let Some(slot) = self.slot.take() { slot.close(); @@ -327,6 +337,7 @@ fn current_spec(ctx: &Context, viewport: egui::ViewportId) -> Option) { let _ = r.reply.send(ControlReply { id: r.id.clone(), result }); } @@ -819,6 +830,8 @@ enum EncodedShot { } impl EncodedShot { + /// Deliver the encoded shot: for `File`, write the PNG through `file_access` and return its + /// path and dimensions; for `Data`, return the prebuilt result. fn finish(self) -> Result { match self { Self::File { path, png, width, height } => { diff --git a/crates/ui-egui/src/control_capture_tests.rs b/crates/ui-egui/src/control_capture_tests.rs index ce4df1aa..6068ce52 100644 --- a/crates/ui-egui/src/control_capture_tests.rs +++ b/crates/ui-egui/src/control_capture_tests.rs @@ -12,6 +12,7 @@ use serde_json::json; use crate::control::{CaptureSpec, ControlReply, ControlRequest}; use crate::layers_drag_tests::Ui; +/// Queue a `ui.screenshot` request in the test app and return its reply receiver. fn push(u: &mut Ui, id: u64, params: serde_json::Value) -> std::sync::mpsc::Receiver { let (tx, rx) = std::sync::mpsc::channel(); u.app.control_queue.push(ControlRequest { id: json!(id), method: "ui.screenshot".into(), params, reply: tx, state: Default::default() }); @@ -38,10 +39,13 @@ fn resized_frame(u: &mut Ui, size: egui::Vec2) -> egui::FullOutput { out } +/// Whether any viewport in the frame output issues a `Screenshot` command. fn has_screenshot(out: &egui::FullOutput) -> bool { out.viewport_output.values().any(|v| v.commands.iter().any(|c| matches!(c, egui::ViewportCommand::Screenshot(_)))) } +/// Asserts reverse capture completion replies only to the matching request, then drains both +/// requests. #[test] fn two_captures_correlate_by_request_across_reverse_completion() { let mut u = Ui::new(Session::empty()); @@ -67,6 +71,7 @@ fn two_captures_correlate_by_request_across_reverse_completion() { assert!(u.app.capture.shots.is_empty(), "no retained requests"); } +/// Asserts a cancelled capture gets no reply and its slot is released. #[test] fn a_cancelled_capture_never_replies_or_writes() { let mut u = Ui::new(Session::empty()); @@ -89,6 +94,7 @@ fn commit_dir(tag: &str) -> std::path::PathBuf { dir } +/// Asserts cancellation after encoding still prevents the final file write and reply. #[test] fn a_cancellation_after_encoding_prevents_the_final_write() { let dir = commit_dir("cancelled"); @@ -107,6 +113,7 @@ fn a_cancellation_after_encoding_prevents_the_final_write() { let _ = std::fs::remove_dir_all(&dir); } +/// Asserts a capture that arrives past its deadline reports a timeout and writes no PNG. #[test] fn a_real_arrival_that_crosses_the_deadline_does_not_write_its_png() { let mut u = Ui::new(Session::empty()); @@ -129,6 +136,8 @@ fn a_real_arrival_that_crosses_the_deadline_does_not_write_its_png() { let _ = std::fs::remove_dir_all(&dir); } +/// Asserts an expiry during encoding reports the timeout under the gate, marks the request +/// replied and writes no file. #[test] fn an_expiry_during_encoding_prevents_the_final_write_and_reports_timeout() { let dir = commit_dir("expired"); @@ -149,6 +158,8 @@ fn an_expiry_during_encoding_prevents_the_final_write_and_reports_timeout() { let _ = std::fs::remove_dir_all(&dir); } +/// Asserts a commit before the deadline writes the file and queues one reply, and a second +/// commit is refused. #[test] fn a_commit_before_the_deadline_writes_and_queues_its_reply_once() { let dir = commit_dir("committed"); @@ -172,6 +183,8 @@ fn a_commit_before_the_deadline_writes_and_queues_its_reply_once() { let _ = std::fs::remove_dir_all(&dir); } +/// Asserts a capture past its deadline sends one timeout reply, releases its slot, and late +/// pixels are ignored. #[test] fn a_capture_after_its_deadline_replies_once_and_late_pixels_are_inert() { let mut u = Ui::new(Session::empty()); @@ -191,6 +204,8 @@ fn a_capture_after_its_deadline_replies_once_and_late_pixels_are_inert() { assert!(rx.try_recv().is_err(), "a late capture is inert"); } +/// Asserts the snapshot count budget rejects an over-budget request before issuing, and every +/// admitted capture still completes under its own id. #[test] fn a_snapshot_batch_is_capped_before_issuing_over_the_count_budget() { let mut u = Ui::new(Session::empty()); @@ -213,6 +228,7 @@ fn a_snapshot_batch_is_capped_before_issuing_over_the_count_budget() { rxs.clear(); } +/// Asserts an undersized readback is not accepted but reissued with one retry and no reply. #[test] fn an_undersized_readback_is_reissued_instead_of_accepted() { let mut u = Ui::new(Session::empty()); @@ -230,6 +246,7 @@ fn an_undersized_readback_is_reissued_instead_of_accepted() { assert!(!r.issued, "waits for the settled geometry again"); } +/// Asserts a one-pixel rounding difference is accepted as the same surface. #[test] fn a_one_pixel_rounding_difference_is_still_the_same_surface() { let mut u = Ui::new(Session::empty()); @@ -245,6 +262,7 @@ fn a_one_pixel_rounding_difference_is_still_the_same_surface() { assert!(u.app.capture.shots.is_empty(), "released"); } +/// Asserts an A→B→A tab switch invalidates the admitted capture with "document changed". #[test] fn a_document_a_to_b_to_a_switch_is_not_the_admitted_capture() { let mut s = Session::empty(); @@ -268,6 +286,8 @@ fn a_document_a_to_b_to_a_switch_is_not_the_admitted_capture() { assert!(u.app.capture.shots.is_empty(), "released"); } +/// Asserts a geometry change bumps the capture epoch and reissues instead of accepting old +/// pixels. #[test] fn a_geometry_change_reissues_instead_of_accepting_old_pixels() { let mut u = Ui::new(Session::empty()); @@ -288,6 +308,8 @@ fn a_geometry_change_reissues_instead_of_accepting_old_pixels() { assert!(!r.issued, "waits for the settled geometry again"); } +/// Asserts a translucent pixel round-trips through the PNG as straight RGBA without +/// darkening. #[test] fn a_translucent_pixel_round_trips_straight_rgba() { let img = egui::ColorImage::from_rgba_unmultiplied([1, 1], &[128, 64, 32, 128]); @@ -298,6 +320,8 @@ fn a_translucent_pixel_round_trips_straight_rgba() { assert_eq!(decoded.get_pixel(0, 0).0, [128, 64, 32, 128], "straight alpha is not darkened"); } +/// Asserts a capture is not issued before three completed real UI frames; the following +/// completed frame issues it. #[test] fn a_capture_is_not_issued_before_three_completed_real_ui_frames() { let mut u = Ui::new(Session::empty()); @@ -313,6 +337,8 @@ fn a_capture_is_not_issued_before_three_completed_real_ui_frames() { assert!(u.app.capture.shots[0].issued, "issued once three frames have presented their layout"); } +/// Asserts a discarded sizing pass does not count toward capture settling, and the next +/// completed frame issues it. #[test] fn a_discarded_sizing_pass_does_not_count_as_a_settled_capture_frame() { let mut u = Ui::new(Session::empty()); @@ -327,6 +353,8 @@ fn a_discarded_sizing_pass_does_not_count_as_a_settled_capture_frame() { assert!(u.app.capture.shots[0].issued, "the next completed frame issues the capture"); } +/// Asserts pixels delivered while hidden force a draining paint and are answered rather than +/// timed out. #[test] fn a_hidden_window_that_delivers_pixels_is_asked_to_paint_the_draining_pass() { let mut u = Ui::new(Session::empty()); @@ -354,6 +382,8 @@ fn a_hidden_window_that_delivers_pixels_is_asked_to_paint_the_draining_pass() { assert!(u.app.capture.shots.is_empty(), "released"); } +/// Asserts a capture for a window with unknown visibility is parked with its deadline and a +/// paint request, then times out once. #[test] fn a_capture_for_a_window_with_unknown_visibility_is_admitted_with_a_deadline() { let mut u = Ui::new(Session::empty()); @@ -386,6 +416,8 @@ fn a_capture_for_a_window_with_unknown_visibility_is_admitted_with_a_deadline() assert!(u.app.capture.shots.is_empty(), "released"); } +/// Asserts a completed capture is rejected when current geometry is unavailable, failing +/// closed. #[test] fn a_completed_capture_is_rejected_when_current_geometry_is_unavailable() { let mut u = Ui::new(Session::empty()); @@ -412,6 +444,7 @@ fn a_completed_capture_is_rejected_when_current_geometry_is_unavailable() { assert!(u.app.capture.shots.is_empty(), "released"); } +/// Asserts a tab switch while a capture settles invalidates it with "document changed". #[test] fn a_capture_belongs_to_the_document_admitted_not_the_one_showing_when_it_arrives() { let mut s = Session::empty(); @@ -435,6 +468,8 @@ fn a_capture_belongs_to_the_document_admitted_not_the_one_showing_when_it_arrive assert!(u.app.capture.shots.is_empty(), "released"); } +/// Asserts the capture budget covers the encoding and reply peaks, not just pixels, and an +/// over-budget resize is rejected before readback. #[test] fn the_capture_budget_counts_the_encoding_peak_not_only_the_pixels() { let fits = CaptureSpec { viewport: egui::ViewportId::ROOT, size_px: [4096, 4096], pixels_per_point: 1.0, zoom: 1.0 }; @@ -462,6 +497,8 @@ fn the_capture_budget_counts_the_encoding_peak_not_only_the_pixels() { assert!(!has_screenshot(&out), "over-budget growth is rejected before any readback command"); } +/// Asserts resizing settling captures rechecks aggregate reserved bytes, rejects the request +/// that no longer fits, and makes the survivor reserve its real cost. #[test] fn resizing_settling_captures_rechecks_the_aggregate_reserved_bytes() { let mut u = Ui::new(Session::empty()); @@ -481,6 +518,8 @@ fn resizing_settling_captures_rechecks_the_aggregate_reserved_bytes() { assert!(!has_screenshot(&out), "neither request was read back using the old small reservation"); } +/// Asserts an allowed resize restarts settling, issues only after three new-size frames, and +/// reserves the current cost. #[test] fn an_allowed_resize_restarts_settling_and_issues_with_the_current_reservation() { let mut u = Ui::new(Session::empty()); @@ -518,6 +557,8 @@ fn push_input(u: &mut Ui, id: u64) -> std::sync::mpsc::Receiver { rx } +/// Asserts injected input is queued, applied by the presenting pass, and acknowledged once +/// after that pass. #[test] fn injected_input_is_acknowledged_only_after_the_pass_that_applies_it() { let mut u = Ui::new(Session::empty()); @@ -534,6 +575,8 @@ fn injected_input_is_acknowledged_only_after_the_pass_that_applies_it() { assert!(!u.app.control_synthetic, "the fence ends with the frame"); } +/// Asserts a discarded pass neither acknowledges nor replays an injected batch; the presented +/// pass acknowledges once. #[test] fn a_discarded_pass_does_not_acknowledge_or_lose_an_injected_batch() { let mut u = Ui::new(Session::empty()); @@ -565,6 +608,8 @@ fn a_discarded_pass_does_not_acknowledge_or_lose_an_injected_batch() { assert!(app.input_acks.is_empty() && app.applied_acks.is_empty()); } +/// Asserts injected text is applied once across a discarded sizing pass and acknowledged +/// once. #[test] fn injected_text_lands_once_across_a_discarded_sizing_pass() { // egui reruns a discarded pass with the same `RawInput`. The custom editor applied the text in diff --git a/crates/ui-egui/src/credits_tests.rs b/crates/ui-egui/src/credits_tests.rs index 8b10a622..fb3993ad 100644 --- a/crates/ui-egui/src/credits_tests.rs +++ b/crates/ui-egui/src/credits_tests.rs @@ -51,10 +51,12 @@ impl Ui { u } + /// Store the About table's current view in test context data. fn set_view(&self, v: View) { self.ctx.data_mut(|d| d.insert_temp(egui::Id::unique("credits_view"), v)); } + /// Read the stored About table view, defaulting when unset. fn view(&self) -> View { self.ctx.data(|d| d.get_temp::(egui::Id::unique("credits_view"))).unwrap_or_default() } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 3ec562a2..8d4d8fdb 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -179,6 +179,10 @@ pub fn keys(app: &mut FigApp, ctx: &Context) { } } +/// Draw the app's dialogs and cards: pending opens/imports/missing fonts, About, +/// Preferences, shortcuts, find/replace, batch rename, menu input, variables, dev compare, +/// branch, export preview, colour profile, plugin, live join/publish, online dialogs and +/// close confirmation. pub fn show(app: &mut FigApp, ctx: &Context) { // Dropped files waiting to open: their card, then (once it has shown) the files. crate::pending_open::pending_opens(app, ctx); diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index 4e724553..341e7cca 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -467,6 +467,8 @@ fn lead(u: &Ui, id: Id) -> Pos2 { u.ctx.read_response(id.with("lead")).unwrap_or_else(|| panic!("no field {id:?}")).rect.center() } +/// Asserts scrubbing the colour picker's R and alpha fields updates the colour live, each +/// scrub adding one undo step. #[test] fn scrubbing_colour_picker_fields_is_live_and_one_undo_step() { let (mut u, a, _) = setup(); @@ -490,6 +492,8 @@ fn scrubbing_colour_picker_fields_is_live_and_one_undo_step() { assert_eq!(undo_steps(&u), steps + 2, "alpha"); } +/// Asserts scrubbing a prototype delay sets the value once when released and adds one undo +/// step. #[test] fn scrubbing_a_prototype_delay_sets_it_once_when_let_go() { let mut s = Session::new(); @@ -514,6 +518,7 @@ fn scrubbing_a_prototype_delay_sets_it_once_when_let_go() { assert_eq!((ms, undo_steps(&u)), (800, steps + 1)); } +/// Asserts scrubbing the scale panel's width scales the layer live in one undo step. #[test] fn scrubbing_the_scale_panel_width_scales_live_in_one_undo_step() { let (mut u, a, _) = setup(); @@ -584,6 +589,8 @@ fn the_intensity_label_fits_whole_in_every_language() { out.textures_delta.clear(); } +/// Asserts a label too long for its number field is elided before the value, while the value +/// stays whole and both remain inside the field. #[test] fn a_label_too_long_for_its_field_ends_in_an_ellipsis_before_the_value() { let ctx = egui::Context::default(); @@ -780,6 +787,8 @@ fn a_deferred_field_save_keeps_its_document_when_the_next_input_switches_tabs() } } +/// Asserts a deferred field save does not retarget a closed, moved or foreign owner: the other +/// file stays untouched, Save as is not opened, and the stale request is consumed once. #[test] fn a_deferred_field_save_does_not_retarget_a_closed_moved_or_foreign_owner() { for change in ["closed", "moved", "foreign"] { diff --git a/crates/ui-egui/src/frame_labels.rs b/crates/ui-egui/src/frame_labels.rs index 4c4b0de1..a87fc9f4 100644 --- a/crates/ui-egui/src/frame_labels.rs +++ b/crates/ui-egui/src/frame_labels.rs @@ -58,6 +58,9 @@ pub fn turn_since(painter: &Painter, from: ShapeIdx, pivot: Pos2, angle: f32) { }); } +/// Recursively rotate shape geometry by `rot`/`angle` about `pivot`: leaves +/// no-op/callback/band shapes alone, rotates points and centers, adds the angle to +/// ellipses/rects/text, and rotates meshes. fn turn(shape: &mut Shape, rot: Rot2, angle: f32, pivot: Pos2) { let at = |p: &mut Pos2| *p = pivot + rot * (*p - pivot); match shape { diff --git a/crates/ui-egui/src/frame_presets.rs b/crates/ui-egui/src/frame_presets.rs index 0d1c4b91..9eb28162 100644 --- a/crates/ui-egui/src/frame_presets.rs +++ b/crates/ui-egui/src/frame_presets.rs @@ -10,6 +10,8 @@ use crate::icons::{self, Icon}; use crate::theme::{Tokens, regular, semibold}; use crate::widgets::{self, PAD}; +/// Draw the expandable frame-preset categories; choosing a preset runs `frame.create`, +/// switches to the Move tool and zooms to the new selection. pub fn panel(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); widgets::section_header(ui, tr("Frame"), &[], false); diff --git a/crates/ui-egui/src/guides.rs b/crates/ui-egui/src/guides.rs index 827597ac..3200ca42 100644 --- a/crates/ui-egui/src/guides.rs +++ b/crates/ui-egui/src/guides.rs @@ -49,6 +49,7 @@ struct Placed { line: Option<[Point; 2]>, } +/// Stable egui id for guide dragging. fn drag_id() -> Id { Id::unique("guide_drag") } diff --git a/crates/ui-egui/src/handles.rs b/crates/ui-egui/src/handles.rs index b7a575fa..d29d59ee 100644 --- a/crates/ui-egui/src/handles.rs +++ b/crates/ui-egui/src/handles.rs @@ -67,6 +67,7 @@ fn crop_corners(app: &FigApp, id: NodeId, r: (f64, f64, f64, f64)) -> Option<[Po ]) } +/// Stable egui id for handle dragging. fn drag_id() -> Id { Id::unique("figcraft_handle_drag") } diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index ebea682e..9c809b99 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -188,6 +188,8 @@ fn dropped(app: &mut FigApp, ctx: &egui::Context) { } } +/// Draw the Home sidebar: app icon and wordmark, file/template search (Escape clears it) +/// and section tabs; choosing a section clears the file query. fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) { let mut y = left.min.y + 20.0; crate::theme::paint_app_icon(ui, Rect::from_min_size(pos2(left.min.x + 12.0, y - 4.0), vec2(32.0, 32.0))); @@ -475,6 +477,8 @@ fn matches(template: &Template, query: &str, kind: Option) -> bool { || has(template.size)) } +/// Draw template search and kind filter, show no-match guidance and a "Show all templates" +/// button that clears the query and kind, and lay matching templates out in a grid. fn templates(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { heading(ui, t, tr("Templates")); ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index cc19d6a9..ee25124a 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -248,6 +248,8 @@ fn a_finished_background_render_wakes_the_ui() { assert!(worker.poll().is_some(), "the woken frame finds the image"); } +/// Asserts a control request wakes an idle UI and is answered, and screenshots keep frames +/// coming until delivered before the UI rests again. #[test] fn control_requests_and_screenshots_wake_an_idle_ui() { let (mut h, _) = document(); @@ -341,6 +343,8 @@ fn cjk_names_are_looked_for_in_edits_not_on_every_pan() { assert_eq!(h.app.cjk_needed(&ctx, 0), crate::cjk::HANGUL); } +/// Asserts a hidden window answers edits and inspection at once, while a screenshot is +/// parked with a deadline and times out once instead of waiting for a frame. #[test] fn a_hidden_window_answers_at_once() { // #269, #720: minimised or covered, the window draws no frames, but the app's logic still runs: @@ -424,6 +428,8 @@ fn auto_save_writes_a_changed_file_then_rests() { let _ = std::fs::remove_dir_all(&dir); } +/// Asserts a hidden logic tick runs background work and advances the autosave deadline +/// without counting a UI frame, and is idempotent at the same time. #[test] fn a_hidden_logic_tick_runs_background_work_but_counts_no_ui_frame() { let (mut h, _) = document(); @@ -444,6 +450,7 @@ fn a_hidden_logic_tick_runs_background_work_but_counts_no_ui_frame() { assert_eq!(h.app.ui.next_autosave, deadline, "idempotent at the same monotonic time"); } +/// Asserts a discarded sizing pass counts one drawn frame, not one per pass. #[test] fn a_discarded_sizing_pass_sets_up_and_counts_one_frame_only() { let (mut h, _) = document(); diff --git a/crates/ui-egui/src/ime_tests.rs b/crates/ui-egui/src/ime_tests.rs index e699dda5..e6c2f698 100644 --- a/crates/ui-egui/src/ime_tests.rs +++ b/crates/ui-egui/src/ime_tests.rs @@ -48,6 +48,7 @@ fn ime_descriptor(u: &mut Ui, events: Vec) -> IMEOutput { u.frame_raw(RawInput { events, ..Default::default() }).platform_output.ime.expect("the IME stays on while editing") } +/// Return the available surrounding-text payload of an IME output, panicking when unavailable. fn available(ime: &IMEOutput) -> &IMESurroundingTextPayload { match &ime.surrounding_text { IMESurroundingText::Available(payload) => payload, @@ -55,6 +56,7 @@ fn available(ime: &IMEOutput) -> &IMESurroundingTextPayload { } } +/// Create a text layer with `text` and return its node id. fn text_layer(s: &mut Session, text: &str) -> NodeId { NodeId(s.execute("text.create", &json!({"text": text, "x": 40, "y": 40})).unwrap()["id"].as_u64().unwrap()) } @@ -103,6 +105,8 @@ fn pinyin_composes_inline_and_commits_once_with_the_ime_on_every_frame() { assert_eq!(text(&u, t), "Hi你好\n!"); } +/// Asserts an empty IME commit ends the composition, inserts nothing, keeps the editor +/// focused, and the next pass observes the committed text. #[test] fn an_empty_ime_commit_ends_the_composition_without_inserting_text() { // Safari's egui web integration sends `Commit("")` (not `Preedit("")`) when the last @@ -126,6 +130,8 @@ fn an_empty_ime_commit_ends_the_composition_without_inserting_text() { assert_eq!(text(&u, t), "Hello"); } +/// Asserts byte deletion before the paired commit replaces the exact UTF-8 range, and one undo +/// restores the pre-IME text. #[test] fn an_ime_replacing_text_deletes_in_bytes_before_the_paired_commit() { // 0.37 sends `DeleteSurroundingBytes` immediately before the commit that replaces it. The @@ -146,6 +152,8 @@ fn an_ime_replacing_text_deletes_in_bytes_before_the_paired_commit() { assert_eq!(text(&u, t), "ab\u{4F60}\u{597D}cd", "one undo restores the pre-IME text"); } +/// Asserts a caret click invalidates the whole stale IME batch (both byte and character +/// deletions) while a later direct commit still works. #[test] fn a_caret_click_rejects_the_stale_delete_and_commit_in_the_same_input_batch() { figcraft_text::scan_system_fonts(); @@ -185,6 +193,8 @@ fn a_caret_click_rejects_the_stale_delete_and_commit_in_the_same_input_batch() { } } +/// Asserts a control-channel document switch rejects the old composition's delete and commit +/// batch, while a direct commit to the new target remains valid. #[test] fn a_document_switch_rejects_the_old_compositions_delete_and_commit_batch() { figcraft_text::scan_system_fonts(); @@ -214,6 +224,8 @@ fn a_document_switch_rejects_the_old_compositions_delete_and_commit_batch() { assert_eq!(text(&u, b), "BravoX", "a direct commit to the new noncomposing target remains valid"); } +/// Asserts the first observation and ordinary typing are `Other`, while an accepted commit is +/// `InputMethod` with UTF-8 offsets, the same owner and generation. #[test] fn ime_cause_is_input_method_only_for_an_accepted_commit() { figcraft_text::scan_system_fonts(); @@ -240,6 +252,8 @@ fn ime_cause_is_input_method_only_for_an_accepted_commit() { assert_eq!(text(&u, t), "Hi你好x"); } +/// Asserts a frame mixing IME and application edits is `Other`, and a later pure commit is +/// `InputMethod` without lingering state. #[test] fn ime_cause_reports_mixed_application_and_ime_mutations_as_other() { figcraft_text::scan_system_fonts(); @@ -267,6 +281,7 @@ fn ime_cause_reports_mixed_application_and_ime_mutations_as_other() { assert_eq!(text(&u, t), "Hiあbうcえおdか"); } +/// Asserts a control-channel text edit is an application change (`Other`). #[test] fn a_control_channel_text_edit_is_an_application_change() { figcraft_text::scan_system_fonts(); @@ -283,6 +298,8 @@ fn a_control_channel_text_edit_is_an_application_change() { assert_eq!(available(&out).text, "Hiz"); } +/// Asserts a byte delete followed by a commit is `InputMethod` and describes the +/// post-mutation text and caret. #[test] fn ime_delete_then_commit_is_input_method_and_describes_the_post_mutation_text() { figcraft_text::scan_system_fonts(); @@ -302,6 +319,8 @@ fn ime_delete_then_commit_is_input_method_and_describes_the_post_mutation_text() assert_eq!(text(&u, t), "abXd"); } +/// Asserts preedit text is excluded from surrounding text and earns no receipt, while the +/// following commit is `InputMethod`. #[test] fn preedit_is_not_surrounding_text_but_the_commit_that_follows_is_input_method() { figcraft_text::scan_system_fonts(); @@ -322,6 +341,8 @@ fn preedit_is_not_surrounding_text_but_the_commit_that_follows_is_input_method() assert_eq!(available(&committed).cursor, 8); } +/// Asserts surrounding text is logical UTF-8 with byte offsets and keeps a reversed +/// selection. #[test] fn ime_surrounding_text_is_logical_utf8_and_keeps_a_reversed_selection() { figcraft_text::scan_system_fonts(); @@ -339,6 +360,8 @@ fn ime_surrounding_text_is_logical_utf8_and_keeps_a_reversed_selection() { assert!(payload.cursor < payload.anchor, "the reversed selection is not sorted"); } +/// Asserts empty text is available while a selection wider than the 4000-byte budget is +/// explicitly unavailable but still has a nonzero IME context generation. #[test] fn empty_text_is_available_while_a_too_wide_selection_is_explicitly_unavailable() { figcraft_text::scan_system_fonts(); @@ -361,6 +384,7 @@ fn empty_text_is_available_while_a_too_wide_selection_is_explicitly_unavailable( assert!(wide.context_generation > 0, "the owner stays known when only the text is unavailable"); } +/// Asserts replacing a too-wide selection with a commit is still an `InputMethod` receipt. #[test] fn a_commit_replacing_a_too_wide_selection_is_still_an_input_method_receipt() { figcraft_text::scan_system_fonts(); @@ -382,6 +406,8 @@ fn a_commit_replacing_a_too_wide_selection_is_still_an_input_method_receipt() { assert_eq!((available(&out).cursor, available(&out).anchor), (1, 1)); } +/// Asserts another text node is another owner, and re-entering the same node changes the +/// context generation while keeping the owner key. #[test] fn a_new_edit_session_and_another_text_node_are_new_ime_owners() { figcraft_text::scan_system_fonts(); @@ -405,6 +431,8 @@ fn a_new_edit_session_and_another_text_node_are_new_ime_owners() { assert_ne!(again.context_generation, first.context_generation, "re-entering is a replaced target"); } +/// Asserts an accepted commit survives a discarded rerun without inserting twice and keeps +/// its `InputMethod` cause, while a mixed rerun is `Other`. #[test] fn an_accepted_commit_survives_a_discarded_pass_without_reapplying_or_losing_its_cause() { figcraft_text::scan_system_fonts(); @@ -428,6 +456,9 @@ fn an_accepted_commit_survives_a_discarded_pass_without_reapplying_or_losing_its assert_eq!(mixed.change_cause, ImeChangeCause::Other); } +/// Asserts IME context state is scoped per viewport: each viewport has its own generation, +/// the root keeps its context across an aux pass, and a closed aux viewport does not inherit +/// the old generation. #[test] fn ime_context_state_is_scoped_per_viewport() { figcraft_text::scan_system_fonts(); @@ -480,6 +511,7 @@ fn ime_context_state_is_scoped_per_viewport() { assert_ne!(aux_again.context_generation, aux_ime.context_generation); } +/// Asserts the engine's IME surrounding-text byte limit equals egui's wire limit. #[test] fn the_engine_surrounding_limit_matches_the_wire_limit() { // A divergence would truncate or overrun the bounded payload silently. @@ -488,6 +520,8 @@ fn the_engine_surrounding_limit_matches_the_wire_limit() { assert_eq!(engine, wire); } +/// Asserts a frame observes the IME exactly once in both the editor pass and the Home +/// early-return pass. #[test] fn a_frame_observes_the_ime_once_even_when_it_ends_early() { figcraft_text::scan_system_fonts(); @@ -506,6 +540,8 @@ fn a_frame_observes_the_ime_once_even_when_it_ends_early() { assert_eq!(u.app.ime_observations - before, 1, "one idle observation on the home screen"); } +/// Asserts a double-click inside an existing edit selects the word without changing the IME +/// owner or generation. #[test] fn a_double_click_inside_the_edit_keeps_the_ime_generation() { figcraft_text::scan_system_fonts(); @@ -536,6 +572,8 @@ fn a_double_click_inside_the_edit_keeps_the_ime_generation() { assert_eq!(available(&after).text, "Hello world"); } +/// Asserts a `window.new` that moves the edited file leaves the root window emitting no IME +/// descriptor. #[test] fn a_window_new_that_moves_the_edited_file_leaves_the_root_ime_idle() { figcraft_text::scan_system_fonts(); @@ -560,6 +598,8 @@ fn a_window_new_that_moves_the_edited_file_leaves_the_root_ime_idle() { assert!(out.platform_output.ime.is_none(), "the root must not emit a descriptor for a file it no longer shows"); } +/// Asserts a CRLF-normalized commit is application-origin (`Other`), and bare-newline and +/// control-only commits insert nothing. #[test] fn a_transformed_commit_is_application_origin_and_a_bare_newline_does_nothing() { figcraft_text::scan_system_fonts(); @@ -582,6 +622,8 @@ fn a_transformed_commit_is_application_origin_and_a_bare_newline_does_nothing() assert!(matches!(control.surrounding_text, IMESurroundingText::Available(_))); } +/// Asserts character deletion keeps the selection for the paired commit, which replaces it +/// and reports `InputMethod`. #[test] fn ime_delete_surrounding_chars_keeps_the_selection_for_the_paired_commit() { figcraft_text::scan_system_fonts(); diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 42a9569e..0ce4fa7a 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -134,6 +134,8 @@ pub fn rail(app: &mut FigApp, ui: &mut Ui) { } } +/// Draw the left panel: versions when open, otherwise the file/Assets header with in-place +/// file rename and collapse control, followed by Pages and Layers or the Assets panel. pub fn left_panel(app: &mut FigApp, ui: &mut Ui) { if app.ui.versions.open { crate::versions::panel(app, ui); @@ -173,6 +175,8 @@ pub fn left_panel(app: &mut FigApp, ui: &mut Ui) { } } +/// Draw the page list with open/search/add controls, filtering, selection, rename, +/// reorder/drag and layer drop-to-page; Dev Mode allows opening pages but blocks mutations. fn pages(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); // Dev Mode only inspects: rows open pages, but don't add, rename, reorder, duplicate or delete. @@ -444,6 +448,7 @@ struct Sweep { ids: Vec, } +/// Stable egui id for the eye/padlock sweep state. fn sweep_id() -> Id { Id::unique("layer_toggle_sweep") } @@ -654,6 +659,7 @@ fn rename_layer(app: &mut FigApp, id: NodeId) { app.ui.layers_seen_sel = app.session.selection(); } +/// Stable egui id for a layer row's rename field. fn rename_id(id: NodeId) -> Id { Id::unique(("rename", id)) } @@ -722,6 +728,8 @@ fn search_field(ui: &mut Ui, t: &Tokens, w: f32, id: Id, hint: &str, text: &mut } } +/// Draw the layer tree with search, cached rows, reveal-selection expansion and scrolling, +/// rename/Tab navigation, selection and drag/drop, sweep toggles and Dev Mode restrictions. fn layers(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); // Dev Mode only inspects: rows select and expand, but don't rename, reorder, hide or lock. diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs index e801ad00..0f312499 100644 --- a/crates/ui-egui/src/layers_behaviour_tests.rs +++ b/crates/ui-egui/src/layers_behaviour_tests.rs @@ -312,6 +312,7 @@ fn pages_down_to(u: &mut Ui, to: f32) -> Vec { opened } +/// Asserts a 60-page list scrolls with the wheel to its last page, past its first pages. #[test] fn a_long_pages_list_scrolls_and_leaves_room_for_the_layers() { let mut s = Session::new(); @@ -383,6 +384,8 @@ fn double_click_checking_focus(u: &mut Ui, p: Pos2) { } } +/// Asserts double-clicking a page selects and renames it, with the keyboard in its name +/// field for the screen-reader tree. #[test] fn double_clicking_a_page_renames_it_with_the_focus_in_the_screen_reader_tree() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/layers_rename_tests.rs b/crates/ui-egui/src/layers_rename_tests.rs index 6ffa9696..c1c229eb 100644 --- a/crates/ui-egui/src/layers_rename_tests.rs +++ b/crates/ui-egui/src/layers_rename_tests.rs @@ -144,6 +144,8 @@ fn rename_without_a_name_renames_one_row_or_opens_the_dialog() { assert!(std::sync::Arc::ptr_eq(&doc, &s.ds().unwrap().doc)); } +/// Asserts a layer rename continues after its row is scrolled out of view and commits on +/// Enter. #[test] fn a_rename_scrolled_out_of_view_goes_on() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/layout_panel.rs b/crates/ui-egui/src/layout_panel.rs index eef37755..0874da2f 100644 --- a/crates/ui-egui/src/layout_panel.rs +++ b/crates/ui-egui/src/layout_panel.rs @@ -283,6 +283,8 @@ fn add_slot(ui: &mut Ui, label: &str, tip: &str, width: f32) -> egui::Response { resp } +/// Draw the 3×3 main/cross alignment grid, auto-gap modes, grid column/row gaps and the +/// horizontal baseline checkbox. fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { let t = Tokens::get(ui.ctx()); let grid_mode = l.mode == LayoutMode::Grid; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d3c17453..6f0effd8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -488,6 +488,7 @@ pub struct FrameClock { } impl Default for FrameClock { + /// Start the frame clock at the current web time with no injected test time. fn default() -> Self { Self { origin: web_time::Instant::now(), test_now: None } } @@ -722,6 +723,9 @@ pub struct UiState { } impl Default for UiState { + /// Seed the default UI state (panel widths, tabs, pages, Home, presenters, pickers and + /// dialogs off, and the rest of the transient UI fields); `FigApp::new` overrides panel + /// widths and the pages height from preferences. fn default() -> Self { Self { left_width: PANEL_DEFAULT, @@ -915,6 +919,9 @@ pub struct FigApp { pub type Done = Box; impl FigApp { + /// Construct `FigApp` from a session and services, taking panel widths and pages height + /// from preferences and initializing UI, canvas, clocks, capture/IME and transient channel + /// state. pub fn new(session: Session, services: Box) -> Self { // The main window's panels open as wide as they were left. let (left_width, right_width) = session.prefs.panel_widths; @@ -2044,6 +2051,9 @@ impl FigApp { } } + /// Draw the toast: start its lifetime on the frame **after** the first drawn frame, request + /// that frame and the frame that hides it, expire and remove it at the deadline, and place it + /// at the bottom centre. fn toast_ui(&mut self, ctx: &egui::Context) { let Some((msg, clock)) = &mut self.ui.toast else { return }; let now = ctx.input(|i| i.time); diff --git a/crates/ui-egui/src/live_join_tests.rs b/crates/ui-egui/src/live_join_tests.rs index 81eddc76..bc1db8ad 100644 --- a/crates/ui-egui/src/live_join_tests.rs +++ b/crates/ui-egui/src/live_join_tests.rs @@ -14,6 +14,7 @@ fn escape(h: &mut Harness) { } } +/// Whether the Join link field currently has focus. fn link_focused(h: &Harness) -> bool { h.ctx.memory(|m| m.has_focus(egui::Id::unique("live_join_link"))) } diff --git a/crates/ui-egui/src/live_server_tests.rs b/crates/ui-egui/src/live_server_tests.rs index 0bf1e631..014be152 100644 --- a/crates/ui-egui/src/live_server_tests.rs +++ b/crates/ui-egui/src/live_server_tests.rs @@ -20,6 +20,7 @@ fn key(h: &mut Harness, key: Key) { } } +/// Whether the widget with the given id currently has focus. fn focused(h: &Harness, id: &str) -> bool { h.ctx.memory(|m| m.has_focus(egui::Id::unique(id))) } diff --git a/crates/ui-egui/src/live_share_tests.rs b/crates/ui-egui/src/live_share_tests.rs index 06d902bd..d7fc61c8 100644 --- a/crates/ui-egui/src/live_share_tests.rs +++ b/crates/ui-egui/src/live_share_tests.rs @@ -385,6 +385,9 @@ fn without_a_code_the_link_is_shared_instead() { assert_eq!(script.lock().unwrap().copied.last().map(String::as_str), Some(PLAIN)); } +/// Asserts the join field accepts a typed code or invite link, joins on Enter, clears after +/// joining, shows failure/retry, rejects an invalid code with an error, and accepts local +/// codes and links. #[test] fn the_join_field_takes_a_code_or_a_link_and_enter_joins() { let (mut h, script) = harness(); @@ -450,6 +453,8 @@ fn the_home_screen_joins_with_a_code_and_opens_the_file() { assert_eq!(h.app.session.docs.len(), 2); } +/// Asserts the join dialog uses the same shared join field, focuses it, and closes once the +/// join starts. #[test] fn the_join_dialog_is_the_same_field() { let (mut h, script) = harness(); diff --git a/crates/ui-egui/src/live_tests.rs b/crates/ui-egui/src/live_tests.rs index eaff7354..63c3c42a 100644 --- a/crates/ui-egui/src/live_tests.rs +++ b/crates/ui-egui/src/live_tests.rs @@ -290,6 +290,8 @@ fn cursors_follow_pages_in_a_shared_document() { assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Hana left")); } +/// Asserts clicking a peer's avatar follows that peer, and Escape or scrolling the canvas +/// stops following. #[test] fn clicking_an_avatar_follows_and_escape_stops() { let (mut h, _) = Harness::new(); diff --git a/crates/ui-egui/src/menu_aim_tests.rs b/crates/ui-egui/src/menu_aim_tests.rs index ed167e12..6f7c4b8a 100644 --- a/crates/ui-egui/src/menu_aim_tests.rs +++ b/crates/ui-egui/src/menu_aim_tests.rs @@ -87,6 +87,7 @@ impl Ui { const TOPS: [&str; 6] = ["File", "Edit", "View", "Object", "Text", "Arrange"]; +/// Stable id for a top-level menu in the menu-aim test harness. fn top(name: &str) -> Id { Id::unique(("menu", name)) } @@ -145,6 +146,8 @@ fn a_reopened_main_menu_shows_no_stale_submenu() { assert!(u.tops_shown().is_empty(), "reopened with {:?} open", u.tops_shown()); } +/// Asserts leaving a top menu and returning to it does not restore the submenu that was open +/// before. #[test] fn a_submenu_one_level_down_reopens_closed() { let mut u = Ui::new(); diff --git a/crates/ui-egui/src/menu_input_tests.rs b/crates/ui-egui/src/menu_input_tests.rs index a50173bc..6fc65533 100644 --- a/crates/ui-egui/src/menu_input_tests.rs +++ b/crates/ui-egui/src/menu_input_tests.rs @@ -69,6 +69,8 @@ fn batch_rename_opens_its_existing_dialog() { assert!(ui.app.ui.last_error.is_none()); } +/// Asserts `file.rename` opens the left panel's inline file-name editor (not a dialog), +/// focuses it, and commits the typed name on Enter. #[test] fn rename_file_edits_the_file_name_in_place() { let mut ui = Ui::new(Session::new()); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e73d242d..e5ddb899 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -478,10 +478,12 @@ struct At { path: String, } +/// Scoped egui id for the current menu level. fn at_key(ui: &Ui) -> Id { ui.scope_id().with("menu_level") } +/// Return the current menu root/path, defaulting to the main menu root. fn level_at(ui: &Ui) -> At { ui.data(|d| d.get_temp::(at_key(ui))).unwrap_or(At { root: Id::unique("menus"), path: String::new() }) } @@ -817,6 +819,9 @@ const TOP_MENUS: &[&str] = &[ tr_noop("Help"), ]; +/// Draw the main and context menu trees: forget closed/stale submenu state, apply +/// control-channel Select-layer open/close, close on outside click or Escape, then draw the +/// colour picker and palette popups. pub fn popups(app: &mut FigApp, ctx: &egui::Context) { let t = Tokens::get(ctx); // A closed menu forgets its open submenus (and a context menu opened somewhere else starts @@ -1020,6 +1025,7 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { /// The context menu's Select layer submenu (its key in the context menu's tree). const SELECT_LAYER: &str = "Select layer"; +/// Stable egui id for the context menu's pending Select-layer open request. fn select_layer_ask_id() -> Id { Id::unique("ctx_select_layer_ask") } diff --git a/crates/ui-egui/src/panel_remove_tests.rs b/crates/ui-egui/src/panel_remove_tests.rs index a2e8d3ee..1608902d 100644 --- a/crates/ui-egui/src/panel_remove_tests.rs +++ b/crates/ui-egui/src/panel_remove_tests.rs @@ -64,10 +64,13 @@ fn remove_button(u: &mut Ui, list: &str, index: usize) -> Pos2 { .unwrap_or_else(|| panic!("no Remove button in {list} row {index} {row:?} {all:?}")) } +/// Read a temporary boolean open-settings flag keyed by `salt` and row index. fn flag(u: &Ui, salt: &'static str, i: usize) -> bool { u.ctx.data(|d| d.get_temp::(Id::unique((salt, i)))).unwrap_or(false) } +/// Asserts removing a fill keeps the open picker on the surviving paint (with its index +/// shifted), and removing the paint being edited closes the picker. #[test] fn removing_a_fill_keeps_the_picker_on_its_paint() { let mut s = Session::new(); @@ -93,6 +96,8 @@ fn removing_a_fill_keeps_the_picker_on_its_paint() { assert_eq!(u.app.ui.picker, None); } +/// Asserts removing effects or grids moves open settings and an open colour picker to the +/// surviving row, or closes them when their own row is removed. #[test] fn removing_effects_and_grids_keeps_open_settings_with_their_rows() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/panel_reorder_tests.rs b/crates/ui-egui/src/panel_reorder_tests.rs index 54caa687..ffeb948e 100644 --- a/crates/ui-egui/src/panel_reorder_tests.rs +++ b/crates/ui-egui/src/panel_reorder_tests.rs @@ -73,6 +73,8 @@ fn escape_cancels_a_row_drag() { assert_eq!(u.app.session.ds().unwrap().selection, [r], "the Escape only cancels the drag"); } +/// Asserts effect and layout-grid rows reorder by drag, while a mixed fill list exposes no +/// drag handle. #[test] fn effects_and_grids_drag_too_and_mixed_lists_dont() { let (mut u, r) = setup(); diff --git a/crates/ui-egui/src/perf_tests.rs b/crates/ui-egui/src/perf_tests.rs index 7b5d1212..eba5e68c 100644 --- a/crates/ui-egui/src/perf_tests.rs +++ b/crates/ui-egui/src/perf_tests.rs @@ -68,6 +68,8 @@ impl Bench { } } +/// Build and print a synthetic UI benchmark report for the given layer count; it makes no +/// assertions. fn report(layers: usize) { let mut b = Bench::new(layers); let mut rows: Vec<(&str, f64)> = Vec::new(); @@ -180,6 +182,7 @@ fn design_system(icons: usize, instances: usize) -> (Document, figcraft_engine:: figcraft_engine::synthetic_ds::design_system_with_instances(&spec, instances).unwrap() } +/// Build a test mouse-wheel event for the given vertical delta. fn wheel(d: f32) -> Event { Event::MouseWheel { unit: egui::MouseWheelUnit::Point, diff --git a/crates/ui-egui/src/plugin_panel_tests.rs b/crates/ui-egui/src/plugin_panel_tests.rs index 3b0f2746..53b1ba56 100644 --- a/crates/ui-egui/src/plugin_panel_tests.rs +++ b/crates/ui-egui/src/plugin_panel_tests.rs @@ -89,6 +89,9 @@ fn an_open_plugin_panel_draws_no_frames_while_idle() { Harness::new(session()).assert_idle("a plugin panel"); } +/// Asserts a plugin colour field's swatch opens the picker on that field, a picked colour +/// reaches the plugin, and a second plugin's panel opens while the first remains (two panels +/// present). #[test] fn a_colour_field_opens_the_colour_picker_and_two_panels_show_side_by_side() { let mut u = Ui::new(session()); diff --git a/crates/ui-egui/src/plugins_ui.rs b/crates/ui-egui/src/plugins_ui.rs index 83295359..26479c4d 100644 --- a/crates/ui-egui/src/plugins_ui.rs +++ b/crates/ui-egui/src/plugins_ui.rs @@ -245,6 +245,9 @@ const INSTALL: &str = "plugin.directory.install"; const MANAGE_W: f32 = 400.0; const ROW_TEXT_W: f32 = 300.0; +/// Draw the Plugins window with installed and browse tabs, dispatching remove, import, +/// browse, confirm and stop actions; closing the window cancels its directory requests and +/// clears confirmation. fn manage_window(app: &mut FigApp, ctx: &Context) { let t = Tokens::get(ctx); let mut open = true; @@ -509,6 +512,8 @@ fn summary(p: &InstalledPlugin) -> String { parts.join(" · ") } +/// Draw a plugin command's parameter form: close it when the plugin or command disappeared, +/// edit values and colour picks, run `plugin.run` on Run, and close on Cancel. fn form_window(app: &mut FigApp, ctx: &Context) { let Some(mut form) = app.ui.plugin_form.clone() else { return }; let Some((name, command)) = app diff --git a/crates/ui-egui/src/present_tests.rs b/crates/ui-egui/src/present_tests.rs index 4679c15d..93729922 100644 --- a/crates/ui-egui/src/present_tests.rs +++ b/crates/ui-egui/src/present_tests.rs @@ -137,6 +137,8 @@ fn preview_pane_opens_follows_edits_and_the_selection() { assert!(u.app.ui.preview.is_none()); } +/// Asserts the preview's device viewport and scroll range, wheel and drag scrolling, the +/// end clamp with spring-back, and that navigation resets scroll to the top. #[test] fn the_frame_scrolls_on_the_device_screen_by_wheel_and_drag() { let (mut u, a, b, _) = setup(); diff --git a/crates/ui-egui/src/present_view_tests.rs b/crates/ui-egui/src/present_view_tests.rs index 97fbba03..361a4caf 100644 --- a/crates/ui-egui/src/present_view_tests.rs +++ b/crates/ui-egui/src/present_view_tests.rs @@ -135,6 +135,8 @@ fn num(v: &Value) -> f64 { v.as_f64().unwrap() } +/// Asserts presenter fit/fit-width/fill/actual scaling, pan ranges, wheel and drag panning, +/// option carry-over to the next presentation, and R restarting the flow. #[test] fn presenter_scales_by_its_options_and_pans_what_overflows() { let (mut u, big) = big(); @@ -272,6 +274,7 @@ fn scrolling_keeps_its_momentum_and_springs_back_from_the_ends() { assert_eq!(u.preview()["flinging"], json!(false)); } +/// Asserts wheel momentum continues after input stops and then comes to rest. #[test] fn the_wheel_coasts_after_it_stops() { let (mut u, a, ..) = setup(); diff --git a/crates/ui-egui/src/proto_canvas.rs b/crates/ui-egui/src/proto_canvas.rs index 1faba513..b5d402b4 100644 --- a/crates/ui-egui/src/proto_canvas.rs +++ b/crates/ui-egui/src/proto_canvas.rs @@ -20,6 +20,7 @@ struct Drag { pos: Pos2, } +/// Stable egui id for prototype connection dragging. fn drag_id() -> Id { Id::unique("proto_canvas_drag") } @@ -59,6 +60,8 @@ struct Sources { ids: Arc>, } +/// Return the cached subtree nodes with interactions for `page`, recomputing only when the +/// document pointer or page changes. pub(crate) fn sources(ctx: &egui::Context, doc: &Arc, page: NodeId) -> Arc> { ctx.data_mut(|d| { let c = d.get_temp_mut_or_default::(Id::unique("proto_canvas_sources")); diff --git a/crates/ui-egui/src/proto_editor.rs b/crates/ui-egui/src/proto_editor.rs index cc3c352b..7163807e 100644 --- a/crates/ui-egui/src/proto_editor.rs +++ b/crates/ui-egui/src/proto_editor.rs @@ -129,6 +129,7 @@ fn unavailable(label: &str) -> &'static str { } } +/// Stable egui id for a node interaction's open action path. fn open_id(cx: &Cx) -> Id { Id::unique(("proto_open_action", cx.node, cx.k)) } diff --git a/crates/ui-egui/src/proto_editor_tests.rs b/crates/ui-egui/src/proto_editor_tests.rs index e6838a0a..63c152e1 100644 --- a/crates/ui-egui/src/proto_editor_tests.rs +++ b/crates/ui-egui/src/proto_editor_tests.rs @@ -78,6 +78,9 @@ fn condition(u: &Ui, btn: NodeId, block: usize) -> String { blocks[block].condition.as_ref().map(|c| c.display(&doc.variables)).unwrap_or_default() } +/// Asserts the interaction editor shows conditional blocks and nested actions, restarts a +/// condition when a variable of another type is picked, ignores a right-hand-side variable pick +/// for a complex condition, and one undo restores the previous condition. #[test] fn the_interaction_editor_shows_conditionals_and_picks_their_variables() { let (mut s, _, b, btn) = setup(); @@ -154,6 +157,8 @@ fn the_builder_takes_variable_operator_value_conditions() { } } +/// Asserts invalid typed conditions keep their draft and show errors without saving, a valid +/// one clears the error and saves, and Enter does not leak to shortcuts. #[test] fn typed_conditions_are_checked_and_errors_show_until_fixed() { let (mut s, _, b, btn) = setup(); diff --git a/crates/ui-egui/src/proto_panel.rs b/crates/ui-egui/src/proto_panel.rs index 972f068b..1b2e2df9 100644 --- a/crates/ui-egui/src/proto_panel.rs +++ b/crates/ui-egui/src/proto_panel.rs @@ -426,6 +426,8 @@ fn flows_overview(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { } } +/// Draw prototype settings: device and rotation, device colour, custom size, responsive +/// toggle and page background, running `proto.settings`. fn settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { if widgets::section_header(ui, tr("Prototype settings"), &[(Icon::Preview, tr("Open prototype preview"))], false).is_some() { app.run("proto.preview", json!({})); @@ -552,6 +554,7 @@ fn custom_size(app: &mut FigApp, ui: &mut Ui, model: Option) { }); } +/// Show, create or preview the flow starting at `frame` and edit its description. fn flow_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, frame: NodeId) { let flows = flows_of(doc, page); let mine = flows.iter().find(|f| f.start == frame).cloned(); @@ -579,6 +582,8 @@ fn flow_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, fra } } +/// List a node's interactions, add a default click navigation (or Back), open the editor +/// popover and remove interactions. fn interactions(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, node: &Node) { let t = Tokens::get(ui.ctx()); let id = node.id; @@ -696,6 +701,7 @@ fn animates(a: &Action) -> bool { ) } +/// Stable egui id for the prototype key-capture flag. fn capture_id() -> Id { Id::unique("proto_key_capture") } @@ -704,6 +710,8 @@ fn capturing(ctx: &egui::Context) -> bool { ctx.data(|d| d.get_temp::(capture_id())).unwrap_or(false) } +/// Draw an interaction's trigger controls: trigger picker, mouse/delay fields, key capture +/// and controller-button selection, patching the interaction. fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Interaction) { let t = Tokens::get(ui.ctx()); widgets::sublabels(ui, tr("Trigger"), None); @@ -1021,6 +1029,8 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: } } +/// Draw an interaction's animation controls: transition, direction, easing, duration and +/// custom easing fields, plus an animated curve preview. fn animation_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Interaction) { let t = Tokens::get(ui.ctx()); widgets::sublabels(ui, tr("Animation"), None); diff --git a/crates/ui-egui/src/proto_reorder_tests.rs b/crates/ui-egui/src/proto_reorder_tests.rs index 1f70f48d..d69be4f6 100644 --- a/crates/ui-egui/src/proto_reorder_tests.rs +++ b/crates/ui-egui/src/proto_reorder_tests.rs @@ -44,6 +44,7 @@ fn setup() -> (Ui, NodeId) { (u, btn) } +/// Stable id for a prototype node's open action path in the reorder tests. fn open_id(btn: NodeId) -> egui::Id { egui::Id::unique(("proto_open_action", btn, 0usize)) } diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index e2dca4a6..a084b9d0 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -78,6 +78,8 @@ impl FigApp { } } +/// Draw the right panel: people, present and share controls, Design/Prototype tabs and zoom +/// menu, then route the scroll area to comments, frame presets, design, prototype or inspect. pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let w = ui.available_width(); @@ -393,6 +395,8 @@ fn design(app: &mut FigApp, ui: &mut Ui) { crate::export_panel::section(app, ui, &doc, &sel); } +/// Draw the page section: background and colour profile, local variables/styles, and the +/// page export section. fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) { let t = Tokens::get(ui.ctx()); let Ok(page) = app.session.page() else { return }; @@ -455,6 +459,8 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) { crate::export_panel::page_section(app, ui, doc, page); } +/// Draw the selection header: type name (or Mixed), frame/group conversion, orientation +/// swap and frame presets, component/mask/boolean quick actions, and instance details. fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let t = Tokens::get(ui.ctx()); let label = match common(doc, sel, |n| n.kind.label()) { @@ -589,6 +595,8 @@ fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { ui.add_space(8.0); } +/// Draw alignment and distribution, X/Y and rotation with flip/rotate controls, the +/// absolute-position toggle and frame constraints. fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let ids = sel.to_vec(); let col = widgets::col_width(ui.available_width()); @@ -807,6 +815,8 @@ fn pretty(s: &str) -> String { } } +/// Draw visibility/blend/mask, opacity, uniform or per-corner radius, corner smoothing and +/// the blend-mode row. fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let ids = sel.to_vec(); let visible = common(doc, sel, |n| n.visible).unwrap_or(true); @@ -1080,6 +1090,8 @@ fn range_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, start: }); } +/// Draw a selected-text range fill, or the fill/stroke paint rows with add, style, reorder, +/// stroke align/weight, per-side weights and advanced stroke settings. fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: bool) { if fills && let Some((id, a, b)) = figcraft_engine::textedit::selected_range(&app.session) @@ -1641,6 +1653,8 @@ fn paint_label(p: &Paint) -> String { } } +/// Draw the effect list with add/style/type/toggle/remove/reorder, open settings, and keep an +/// open picker and settings flags aligned when rows move or are removed. fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let ids = sel.to_vec(); let list = common(doc, sel, |n| n.effects.clone()); @@ -1723,6 +1737,8 @@ fn fx_field(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, prop: &str, crate::variable_ui::number_field(app, ui, ids, &format!("effects/{i}/{prop}"), Id::unique(("fx", prop, i)), lead, Some(value), "", width) } +/// Draw type-specific effect fields for shadows, blurs, noise, texture and glass, using live +/// scrub edits that stay one undo step and bindable colour fields. fn effect_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Effect) { let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0; let set = |app: &mut FigApp, e2: Effect, scrub: Option| { @@ -1959,6 +1975,8 @@ fn fx_color(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Effect, } } +/// Draw the layout-grid list with add/style/type/toggle/remove/reorder, per-grid settings, +/// and keep open-settings flags aligned when rows move or are removed. fn grids(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let ids = sel.to_vec(); let list = common(doc, sel, |n| n.grids.clone()); diff --git a/crates/ui-egui/src/rtl_names_tests.rs b/crates/ui-egui/src/rtl_names_tests.rs index c88ab267..22f59817 100644 --- a/crates/ui-egui/src/rtl_names_tests.rs +++ b/crates/ui-egui/src/rtl_names_tests.rs @@ -34,6 +34,7 @@ fn caret_x(ctx: &egui::Context, text: &str, cursor: CCursor) -> f32 { ctx.fonts_mut(|f| f.layout_no_wrap(text.to_string(), regular(11.0), Color32::WHITE).pos_from_cursor(cursor).min.x) } +/// Read the primary character cursor of a test text field. fn cursor(ctx: &egui::Context, id: Id) -> CCursor { egui::TextEdit::load_state(ctx, id).unwrap().cursor.char_range().unwrap().primary } @@ -75,6 +76,8 @@ fn fonts() -> Option { Some(ctx) } +/// Asserts names keep their logical text while glyphs paint in visual order, including +/// ellipsis placement at the overflow end and mixed-direction rows. #[test] fn names_keep_logical_text_and_paint_in_visual_order() { let Some(ctx) = fonts() else { return }; @@ -137,6 +140,8 @@ fn names_keep_logical_text_and_paint_in_visual_order() { }); } +/// Asserts Arabic letters take joined contextual forms and truncation puts the ellipsis at +/// the visual end of a right-to-left row. #[test] fn arabic_joining_and_truncation_use_the_visual_end() { let Some(ctx) = fonts() else { return }; @@ -177,6 +182,8 @@ fn arabic_joining_and_truncation_use_the_visual_end() { }); } +/// Asserts the shaper mirrors right-to-left parentheses in both glyph order and the atlas +/// shapes chosen for them. #[test] fn rtl_brackets_are_mirrored_by_the_shaper() { let Some(ctx) = fonts() else { return }; @@ -211,6 +218,8 @@ fn rtl_brackets_are_mirrored_by_the_shaper() { }); } +/// Asserts Right/Left move the caret visually while logical offsets jump, Shift extends +/// visually, and typing inserts at the logical caret; Latin text keeps egui's own caret. #[test] fn rename_caret_moves_visually_with_logical_offsets() { let Some(ctx) = fonts() else { return }; @@ -266,6 +275,8 @@ fn rename_caret_moves_visually_with_logical_offsets() { assert_eq!(caret(&ctx, lid).0, 2); } +/// Asserts clicks map right-to-left visual positions to logical carets, so a click at the +/// visual left end leaves the caret at the logical end and typing appends there. #[test] fn clicks_place_the_logical_caret_by_what_is_shown() { let Some(ctx) = fonts() else { return }; diff --git a/crates/ui-egui/src/section_panel.rs b/crates/ui-egui/src/section_panel.rs index 586a5597..f3240d57 100644 --- a/crates/ui-egui/src/section_panel.rs +++ b/crates/ui-egui/src/section_panel.rs @@ -48,6 +48,9 @@ pub fn status_dropdown(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[Nod }); } +/// Draw section colour and hide-contents controls, Dev status, changed-reason Resolve, the +/// last-change note and Resize to fit; returns for an empty selection or one without a Dev +/// status. pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let sections = !sel.is_empty() && sel.iter().all(|i| is_section(doc, *i)); if sel.is_empty() || !sel.iter().all(|i| has_status(doc, *i)) { diff --git a/crates/ui-egui/src/select_all_tests.rs b/crates/ui-egui/src/select_all_tests.rs index a68a6854..d86de12d 100644 --- a/crates/ui-egui/src/select_all_tests.rs +++ b/crates/ui-egui/src/select_all_tests.rs @@ -45,6 +45,7 @@ fn selection(ctx: &egui::Context, te: Id) -> Option<(usize, usize)> { Some((a.min(b), a.max(b))) } +/// Asserts clicking a hex field focuses it and selects all six characters. #[test] fn a_click_into_a_hex_field_selects_it_all() { let ctx = egui::Context::default(); @@ -57,6 +58,7 @@ fn a_click_into_a_hex_field_selects_it_all() { assert_eq!(selection(&ctx, id.with("te")), Some((0, 6))); } +/// Asserts clicking an inline field selects it all and typing replaces the selection. #[test] fn a_click_into_an_inline_field_selects_it_all() { // A fill's hex in the Design panel, a variable's value, a rename. @@ -75,6 +77,7 @@ fn a_click_into_an_inline_field_selects_it_all() { assert_eq!(out.as_deref(), Some("red")); } +/// Asserts clicking a search box selects its whole query. #[test] fn a_click_into_a_search_box_selects_its_query() { let ctx = egui::Context::default(); @@ -87,6 +90,7 @@ fn a_click_into_a_search_box_selects_its_query() { assert_eq!(selection(&ctx, id), Some((0, 6))); } +/// Asserts Tab focus selects a field's whole value. #[test] fn tab_into_a_field_selects_it_all() { let ctx = egui::Context::default(); @@ -106,6 +110,7 @@ fn tab_into_a_field_selects_it_all() { assert_eq!(selection(&ctx, id), Some((0, 7))); } +/// Asserts a drag selects a non-empty proper subrange rather than the whole value. #[test] fn a_drag_selects_only_what_it_covers() { let ctx = egui::Context::default(); @@ -120,6 +125,7 @@ fn a_drag_selects_only_what_it_covers() { assert!(!(a == 0 && b == q.chars().count()), "not all of it: {a}..{b}"); } +/// Asserts re-clicking a field after focus was lost mid-edit selects all. #[test] fn a_field_left_mid_edit_selects_all_on_the_next_click() { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/selection_colors.rs b/crates/ui-egui/src/selection_colors.rs index c699788b..682b37fe 100644 --- a/crates/ui-egui/src/selection_colors.rs +++ b/crates/ui-egui/src/selection_colors.rs @@ -24,6 +24,9 @@ struct Cache { colors: Arc>, } +/// Return the cached selection colours when the document and selection match. During an +/// interaction reuse an existing matching cache, or return an empty list without discovering +/// colours; otherwise discover and cache them. fn colors(ui: &Ui, document: u64, doc: &Arc, ids: &[NodeId], interacting: bool) -> Arc> { let key = Id::unique("selection_colors_cache"); if let Some(cache) = ui.data(|d| d.get_temp::(key)) @@ -49,6 +52,9 @@ pub(crate) fn open_picker(app: &mut FigApp, targets: Vec) { app.ui.picker = Some(PickerTarget::SelectionColor { targets, selection: ds.selection.clone(), document: ds.uid }); } +/// Draw the selection-colour groups (at least two): swatch opens the picker, hex and opacity +/// edits, style/variable detach/apply/bind, and “select layers with this color”; disabled +/// during an interaction. pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId]) { let Ok(ds) = app.session.ds() else { return }; let interacting = ds.interaction.is_some(); @@ -249,6 +255,8 @@ mod tests { assert!(!p.frame(vec![]).iter().any(|(s, _)| s == "Selection colors")); } + /// Asserts the panel reuses cached colours through a live interaction and refreshes the cache + /// after the commit. #[test] fn panel_keeps_cached_colors_during_interaction_and_refreshes_after_commit() { let (mut p, a, _) = Panel::new(); @@ -268,6 +276,8 @@ mod tests { assert!(!Arc::ptr_eq(&before.colors, &after.colors)); } + /// Asserts no cached colours are discovered during an interaction, and cancelling restores + /// the panel with the original colours. #[test] fn panel_defers_discovery_without_a_matching_cache_during_interaction() { let (mut p, a, _) = Panel::new(); diff --git a/crates/ui-egui/src/selection_spacing.rs b/crates/ui-egui/src/selection_spacing.rs index 7d1d27b4..414348bb 100644 --- a/crates/ui-egui/src/selection_spacing.rs +++ b/crates/ui-egui/src/selection_spacing.rs @@ -24,6 +24,8 @@ struct LayoutCache { reorder: Option>, } +/// Return one cached spacing/reorder calculation per document, page, selection and frame, +/// refreshing next frame after an edit and reusing an unchanged document snapshot. fn layout(app: &FigApp, ctx: &egui::Context, ids: &[NodeId]) -> Arc { let Ok(ds) = app.session.ds() else { return Arc::new(SelectionSpacing::default()) }; let key = Id::unique("selection_spacing_layout"); @@ -52,11 +54,13 @@ fn layout(app: &FigApp, ctx: &egui::Context, ids: &[NodeId]) -> Arc Option> { layout(app, ctx, ids); ctx.data(|d| d.get_temp::(Id::unique("selection_spacing_layout"))).and_then(|c| c.reorder) } +/// Stable egui id for spacing/reorder drag state. fn drag_id() -> Id { Id::unique("selection_spacing_drag") } @@ -107,6 +111,8 @@ fn start_drag(app: &mut FigApp, ctx: &egui::Context, ids: Vec, p: Pos2, } } +/// Draw horizontal and vertical spacing fields, running `arrange.spacing` with +/// `fromGesture`. pub fn panel(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId]) { let layout = layout(app, ui.ctx(), ids); if layout.horizontal.is_none() && layout.vertical.is_none() { diff --git a/crates/ui-egui/src/selection_spacing_tests.rs b/crates/ui-egui/src/selection_spacing_tests.rs index aaeaabc3..b4b384d0 100644 --- a/crates/ui-egui/src/selection_spacing_tests.rs +++ b/crates/ui-egui/src/selection_spacing_tests.rs @@ -43,6 +43,7 @@ fn drag(u: &mut Ui, p: Pos2, by: egui::Vec2, release: bool) { } } +/// Asserts typed and scrubbed spacing changes take effect with exactly one undo step. #[test] fn spacing_fields_type_scrub_and_keep_one_undo_step() { let (mut u, ids) = scene(true); @@ -88,6 +89,8 @@ fn canvas_gap_drag_keeps_selection_and_undo_redo_for_both_grid_axes() { } } +/// Asserts Escape rolls a spacing drag back, and releasing outside the window commits once +/// while keeping the selection. #[test] fn escape_rolls_back_and_an_outside_release_finishes_the_gesture() { let (mut u, ids) = scene(false); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 2abb1cfc..d47d9607 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -205,6 +205,9 @@ pub struct AfterFieldSave { } impl AfterFieldSave { + /// Run a deferred field-save binding only if the originating window still exists and owns + /// the document; temporarily activate that document, run the binding, then restore the + /// previously front tab. fn run(self, app: &mut FigApp) { // A closed file or one moved to another window cannot turn this into a save of the // current tab. The UiState normally stays in its window's pane; validate that too. @@ -299,6 +302,7 @@ pub(crate) fn revalidate_field_save(app: &FigApp, ctx: &Context, save: Option egui::Id { egui::Id::unique(("field_save_cancelled", ctx.viewport_id(), editor)) } @@ -644,12 +648,16 @@ pub(crate) struct ImeInputBaseline { } impl ImeInputBaseline { + /// Capture an IME baseline only while a text edit and an active preedit exist, recording + /// the IME target and the caret/anchor. pub(crate) fn capture(app: &FigApp) -> Option { let te = app.session.ds().ok()?.text_edit.as_ref()?; te.preedit.as_ref()?; Some(Self { target: ime_target(app)?, caret: te.caret, anchor: te.anchor }) } + /// Whether the IME target changed, editing or the preedit ended, or the caret/anchor moved + /// since the baseline was captured. fn interrupted(&self, app: &FigApp) -> bool { ime_target(app) != Some(self.target) || app @@ -661,6 +669,9 @@ impl ImeInputBaseline { } } +/// Apply a batch of text, IME, paste, copy, cut and key events: reject all IME events for an +/// interrupted composition, swallow text produced by a key that ran a shortcut, then finish +/// with triple-click line selection and preedit sync. fn text_keys(app: &mut FigApp, ctx: &Context, events: &[Event], ime_input: Option) { // Validate once before the batch: an accepted DeleteSurrounding deliberately clears preedit, // but its following Commit still belongs to the same valid transaction. An interrupted diff --git a/crates/ui-egui/src/style_picker_tests.rs b/crates/ui-egui/src/style_picker_tests.rs index f86ddd73..b732c11d 100644 --- a/crates/ui-egui/src/style_picker_tests.rs +++ b/crates/ui-egui/src/style_picker_tests.rs @@ -95,6 +95,8 @@ fn text_session() -> (Session, figcraft_doc::NodeId) { (s, t) } +/// Asserts hovering a text style previews it without an edit, leaving the popover restores +/// the value, and clicking applies one undo step and closes the picker. #[test] fn text_styles_preview_on_hover_and_apply_on_click() { let (s, t) = text_session(); diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index 92ff9f63..6f17910a 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -97,6 +97,8 @@ fn tab_drop_position(visible: &[(usize, Rect)], from: usize, pointer_x: f32) -> } } +/// Draw the top tab bar: Home, document and presenter tabs, new-tab and window controls, +/// open/close/reorder animation and tab closing, pausing the presenter when going Home. pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); egui::Panel::top("tab_bar").exact_size(HEIGHT).frame(egui::Frame::NONE.fill(t.tab_bar)).show(ui, |ui| { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 80d1399c..9a384294 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -173,6 +173,7 @@ impl Tokens { } } +/// Stable id for temporary theme tokens. fn tokens_id() -> egui::Id { egui::Id::unique("figcraft_tokens") } @@ -436,6 +437,7 @@ pub fn c32(c: figcraft_color::Color) -> Color32 { Color32::from_rgba_unmultiplied(r, g, b, a) } +/// Stable id for the current document-to-screen colour conversion. fn screen_conversion_id() -> egui::Id { egui::Id::unique("figcraft_screen_conversion") } diff --git a/crates/ui-egui/src/theme_startup_tests.rs b/crates/ui-egui/src/theme_startup_tests.rs index 1ae40f58..c128a597 100644 --- a/crates/ui-egui/src/theme_startup_tests.rs +++ b/crates/ui-egui/src/theme_startup_tests.rs @@ -84,6 +84,8 @@ fn unavailable_system_theme_uses_the_existing_light_fallback() { assert_eq!(Tokens::get(&ctx), Tokens::light()); } +/// Checks that the test graph enables egui's `default_fonts` and `monochrome_emoji_fonts` +/// features by asserting both a Latin and an emoji glyph are available. #[test] fn tests_have_the_explicit_default_and_monochrome_emoji_font_features() { // 0.37 egui has no default features, and kittest's own dev-features do not propagate to this diff --git a/crates/ui-egui/src/titlebar_tests.rs b/crates/ui-egui/src/titlebar_tests.rs index 8fa4ad47..fff303ef 100644 --- a/crates/ui-egui/src/titlebar_tests.rs +++ b/crates/ui-egui/src/titlebar_tests.rs @@ -81,6 +81,8 @@ fn window_buttons_minimise_maximise_and_close() { assert!(u.frame(vec![]).contains(&ViewportCommand::Close)); } +/// Asserts closing the custom titlebar over a presenter with unsaved changes asks with the +/// unsaved-changes dialog instead of emitting `Close`. #[test] fn titlebar_close_asks_over_the_presenter_when_a_file_is_unsaved() { let mut u = Ui::new(TitleBar::Custom); diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 7af3356a..8f8f6cc1 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -91,6 +91,7 @@ const BOARD_GROUPS: &[Group] = &[ Group::tools("comment", tr_noop("Comment"), &[(ToolId::Comment, Icon::Comment)]), ]; +/// Return the last tool index remembered for a toolbar group, falling back to `default`. fn remembered(ui: &Ui, key: &str, default: ToolId) -> ToolId { ui.data(|d| d.get_temp::(egui::Id::unique(("tb_last", key)))).and_then(|i| ToolId::ALL.get(i as usize).copied()).unwrap_or(default) } @@ -535,6 +536,8 @@ fn options_flyout(app: &mut FigApp, ui: &mut Ui, g: &Group, bottom_left: egui::P } } +/// Draw a tool group's flyout above its button, with keyboard/hover highlighting, remembered +/// selection when a tool is clicked, and Escape to close. fn flyout(app: &mut FigApp, ui: &mut Ui, g: &Group, bottom_left: egui::Pos2) { let t = Tokens::get(ui.ctx()); let item_h = 32.0; diff --git a/crates/ui-egui/src/type_settings_tests.rs b/crates/ui-egui/src/type_settings_tests.rs index cbde6cc1..421c1ce2 100644 --- a/crates/ui-egui/src/type_settings_tests.rs +++ b/crates/ui-egui/src/type_settings_tests.rs @@ -2,6 +2,8 @@ //! de paragraphe") stays inside the popover, which otherwise widened a little every frame. use egui::{Area, Id, Pos2, RawInput, Rect, vec2}; +/// Asserts a long type-settings row label does not widen the popover beyond 264 pt across +/// frames. #[test] fn a_long_label_does_not_widen_the_type_settings_popover() { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index 33c0d5a0..f61aa183 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -1167,10 +1167,13 @@ const ROW: f32 = 26.0; /// The font list's tallest (it scrolls past that). const LIST_HEIGHT: f32 = 380.0; +/// Return the persisted list of recently used font families. fn recent_families(ctx: &egui::Context) -> Vec { ctx.data_mut(|d| d.get_persisted::>(Id::unique(RECENT_KEY))).unwrap_or_default() } +/// Remember a font family at the front of the recent list, removing an earlier +/// case-insensitive match and keeping at most eight entries. fn remember_family(ctx: &egui::Context, family: &str) { ctx.data_mut(|d| { let v = d.get_persisted_mut_or_default::>(Id::unique(RECENT_KEY)); @@ -1568,6 +1571,8 @@ mod variable_tests { assert!(p.app.ui.last_error.is_none(), "{:?}", p.app.ui.last_error); } + /// Asserts bound font family and style chips replace the dropdowns, a selected text range + /// offers no variable binding, and detaching restores the last values. #[test] fn bound_font_family_and_style_show_their_variables_and_detach() { let mut p = Panel::new(); diff --git a/crates/ui-egui/src/typography_scrub_tests.rs b/crates/ui-egui/src/typography_scrub_tests.rs index 1e629acf..9b95b8e5 100644 --- a/crates/ui-egui/src/typography_scrub_tests.rs +++ b/crates/ui-egui/src/typography_scrub_tests.rs @@ -44,6 +44,7 @@ impl Fields { out.textures_delta.clear(); out.platform_output.cursor_icon } + /// Screen position of a typography field's label/lead, used to start a scrub. fn lead(&self, key: &str) -> Pos2 { let rect = self.ctx.read_response(Id::unique(key).with("click")).unwrap().rect; Pos2::new(rect.min.x + 12.0, rect.center().y) @@ -157,6 +158,8 @@ fn typography_icon_scrub_changes_only_the_selected_characters_without_ending_tex assert_eq!(f.value("ty_ls"), 0.0); } +/// Asserts typography spacing fields accept typed percent units, commit on Enter, and +/// Escape restores the previous value without adding an undo step. #[test] fn typography_spacing_fields_still_accept_typed_units_and_escape_cancels() { let mut f = Fields::new(); diff --git a/crates/ui-egui/src/variable_ui.rs b/crates/ui-egui/src/variable_ui.rs index 776d8aae..fe822a1f 100644 --- a/crates/ui-egui/src/variable_ui.rs +++ b/crates/ui-egui/src/variable_ui.rs @@ -40,6 +40,7 @@ struct Picker { at: Pos2, } +/// Stable egui id for the variable picker. fn picker_id() -> Id { Id::unique("variable_picker") } diff --git a/crates/ui-egui/src/variables_panel.rs b/crates/ui-egui/src/variables_panel.rs index 2a9e3c04..372e3f04 100644 --- a/crates/ui-egui/src/variables_panel.rs +++ b/crates/ui-egui/src/variables_panel.rs @@ -173,6 +173,8 @@ pub fn show(app: &mut FigApp, ctx: &Context) { app.ui.variables = open.then_some(st); } +/// Draw the variables header: the title at the left, and the import/export/Close controls at the +/// right. fn header(app: &mut FigApp, ui: &mut Ui, open: &mut bool) { let t = Tokens::get(ui.ctx()); let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover()); @@ -253,6 +255,7 @@ fn extend(app: &mut FigApp, lib: &str, c: &str, st: &mut Editor) { } } +/// Stable egui id for the rename field of a `Rename` target. fn rename_id(target: &Rename) -> Id { Id::unique(("var_rename", target)) } @@ -281,6 +284,8 @@ fn unique(base: &str, taken: impl Fn(&str) -> bool) -> String { (1..10_000).map(|k| if k == 1 { base.to_string() } else { format!("{base} {k}") }).find(|n| !taken(n)).unwrap_or_else(|| base.to_string()) } +/// Draw the collections sidebar with library headings, collection rows and a create-collection +/// button. fn sidebar(app: &mut FigApp, ui: &mut Ui, views: &[View], local: &Variables, st: &mut Editor) { let t = Tokens::get(ui.ctx()); let (hr, _) = ui.allocate_exact_size(vec2(SIDEBAR_W, 40.0), Sense::hover()); @@ -505,6 +510,8 @@ fn group_of(name: &str) -> &str { name.rsplit_once('/').map(|(g, _)| g).unwrap_or("") } +/// Draw the variables table for the selected collection: search, library/extended notes, +/// grouped rows, mode columns and the create-variable row when editable. fn table(app: &mut FigApp, ui: &mut Ui, view: &View, st: &mut Editor) { let t = Tokens::get(ui.ctx()); // A sidebar click this frame picked another source: shown next frame. @@ -594,6 +601,8 @@ fn table(app: &mut FigApp, ui: &mut Ui, view: &View, st: &mut Editor) { }); } +/// Draw a collection's mode header row with rename, duplicate, reorder and delete actions +/// and an add-mode button when the collection is editable and not extended. fn header_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st: &mut Editor, width: f32) { let t = Tokens::get(ui.ctx()); // An extended collection has the modes of the one it extends. diff --git a/crates/ui-egui/src/vector_panel.rs b/crates/ui-egui/src/vector_panel.rs index 29ae8726..07dda3ff 100644 --- a/crates/ui-egui/src/vector_panel.rs +++ b/crates/ui-egui/src/vector_panel.rs @@ -72,6 +72,8 @@ fn width_point(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) { ui.add_space(8.0); } +/// Draw the selected points' position, dimensions, corner radius, mirroring and per-point +/// stroke cap/join controls; the width tool delegates to its own panel. pub fn points(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) { if ve.tool == vectoredit::VTool::Width { width_point(app, ui, doc, ve); diff --git a/crates/ui-egui/src/versions.rs b/crates/ui-egui/src/versions.rs index 3519b610..19eb6a04 100644 --- a/crates/ui-egui/src/versions.rs +++ b/crates/ui-egui/src/versions.rs @@ -403,6 +403,8 @@ fn current_row(ui: &mut Ui, more: bool, active: bool) -> bool { !active && resp.on_hover_text(tr("Back to the current version")).clicked() } +/// Draw one version row with timeline, name, time, up to three description lines and a +/// more/context menu; returns the chosen preview, restore, edit, duplicate or delete action. fn version_row(ui: &mut Ui, row: &Row, last: bool, previewing: bool) -> Option { let t = Tokens::get(ui.ctx()); let w = ui.available_width(); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index f356b797..a5dd2c7b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -223,6 +223,9 @@ fn cancel_retargeted_field_save(ui: &mut Ui, id: Id) { ui.memory_mut(|m| m.surrender_focus(editor)); } +/// Implement the shared number field: label/icon lead, scrubbing, keyboard stepping and +/// typing, select-all when a click starts editing, bindable values and deferred-save +/// cancellation; returns an edit and/or bind event. fn number_field_impl( ui: &mut Ui, id: Id, diff --git a/crates/ui-egui/src/windows_tests.rs b/crates/ui-egui/src/windows_tests.rs index 55a7babd..0f5884dd 100644 --- a/crates/ui-egui/src/windows_tests.rs +++ b/crates/ui-egui/src/windows_tests.rs @@ -45,6 +45,8 @@ fn names(app: &FigApp) -> Vec { app.session.docs.iter().map(|d| d.doc.name.clone()).collect() } +/// Asserts closing a window in the presenter shows the unsaved-changes dialog and does not +/// dismiss the preview. #[test] fn closing_a_window_in_the_presenter_shows_the_unsaved_changes_dialog() { let mut session = Session::new(); @@ -104,6 +106,8 @@ fn windows_hold_tabs_and_close_with_their_files() { assert_eq!(app.session.ds().unwrap().doc.name, "A"); } +/// Asserts a hidden other window answers edits and inspection in its own file while the main +/// window keeps its file, and a capture parks with a deadline and times out once. #[test] fn another_hidden_window_answers_requests_that_need_no_frame_in_its_own_file() { let mut s = Session::empty(); @@ -180,6 +184,8 @@ fn a_request_for_a_window_on_screen_wakes_it_while_the_main_window_is_hidden() { assert!(woken.lock().unwrap().contains(&other), "the other window is asked to draw: {:?}", woken.lock().unwrap()); } +/// Asserts every direct UI frame clears the logic-tick marker so background work runs each +/// frame. #[test] fn every_direct_ui_frame_runs_background_work() { // Direct `App::ui` harnesses never call `App::logic`: the tick marker the UI pass consumes @@ -192,6 +198,8 @@ fn every_direct_ui_frame_runs_background_work() { } } +/// Asserts synthetic input for a child window reaches the pane that draws it, leaving the +/// main window's pane untouched. #[test] fn synthetic_input_for_a_child_window_reaches_the_pane_that_draws_it() { let mut s = Session::empty(); @@ -214,6 +222,8 @@ fn synthetic_input_for_a_child_window_reaches_the_pane_that_draws_it() { assert_eq!(crate::windows::take_input(&mut app, other), events, "the child's next frame takes its batch"); } +/// Asserts a child raw-input hook with no batch does not error or drain the root window's +/// pending acknowledgement. #[test] fn a_frame_without_a_batch_does_not_error_another_windows_acknowledgement() { let mut s = Session::empty(); @@ -238,6 +248,8 @@ fn a_frame_without_a_batch_does_not_error_another_windows_acknowledgement() { assert_eq!(app.input_acks[0].reply.id, json!(5)); } +/// Asserts a child window runs `viewport_setup` and its pane keeps the dark theme after the +/// swap back. #[test] fn a_child_window_runs_the_ui_setup_the_main_window_does() { // eframe calls `App::ui` only for the root window, so a child that draws while the root does @@ -263,6 +275,8 @@ fn a_child_window_runs_the_ui_setup_the_main_window_does() { assert_eq!(again, Some(json!(true))); } +/// Asserts a closed and later reused viewport does not inherit its old IME context +/// generation. #[test] fn an_emptied_window_forgets_its_ime_context() { // `windows::begin` ends the viewport when a window loses its last file; a later window that From 000c0e3d4e8f33c0e5e9c0b96aff73336b9a03c5 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:22:29 +0000 Subject: [PATCH 31/54] Scale per-point corner radius of vector layers in transform.scale Found while testing crates/engine/src/cmd/arrange.rs:~560 (scale_node, the Vector branch calling VectorNetwork::scale); crates/doc/src/vector.rs:631 (VectorNetwork::scale) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/arrange.rs | 3 +++ crates/engine/src/scale_tool_tests.rs | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index 10ad13e5..7ee1f2c1 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -497,6 +497,9 @@ pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> { } if let NodeKind::Vector { network } = &mut n.kind { network.scale(k, k); + for v in &mut network.vertices { + v.radius *= k; + } } n.width *= k; n.height *= k; diff --git a/crates/engine/src/scale_tool_tests.rs b/crates/engine/src/scale_tool_tests.rs index 647a7ce1..f61eba93 100644 --- a/crates/engine/src/scale_tool_tests.rs +++ b/crates/engine/src/scale_tool_tests.rs @@ -240,3 +240,17 @@ fn the_scale_tool_only_acts_on_handles() { assert_rect(bounds(&s, r), [10.0, 10.0, 110.0, 60.0]); assert!(near(s.doc().unwrap().node(r).unwrap().width, 100.0)); } + +#[test] +fn scale_node_scales_vector_point_corner_radius() { + let mut s = Session::new(); + let v = id_of(&ex(&mut s, "vector.create", json!({"points": [[0, 0], [100, 0], [100, 100], [0, 100]], "closed": true}))["id"]).unwrap(); + ex(&mut s, "node.set", json!({"id": v, "props": {"cornerRadius": 10}})); + ex(&mut s, "transform.scale", json!({"ids": [v], "factor": 2})); + let d = s.doc().unwrap(); + let NodeKind::Vector { network } = &d.node(v).unwrap().kind else { panic!("not a vector") }; + assert!(!network.vertices.is_empty()); + for vx in &network.vertices { + assert_eq!(vx.radius, 20.0, "corner radius scaled with the layer"); + } +} From 4eb5224f023a7baf70df5f3df9b785480324f6f8 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 25 Oct 2026 17:13:00 +0100 Subject: [PATCH 32/54] M0: correct egui 0.37 status docs and stabilize join-code regression --- crates/collab-net/src/code_tests.rs | 5 ++++- crates/ui-egui/src/windows.rs | 2 +- docs/egui-037-migration-plan.md | 8 ++++---- docs/gaps.md | 8 +++++--- docs/hardware-parity.md | 7 ++++--- docs/ui-parity.md | 5 +++-- 6 files changed, 21 insertions(+), 14 deletions(-) diff --git a/crates/collab-net/src/code_tests.rs b/crates/collab-net/src/code_tests.rs index 7fe766ec..182f3782 100644 --- a/crates/collab-net/src/code_tests.rs +++ b/crates/collab-net/src/code_tests.rs @@ -103,7 +103,10 @@ fn a_local_code_with_the_wrong_secret_or_a_typo_doesnt_join() { assert!(host.live().peers.is_empty()); // A typing mistake is caught before anything connects. let mut typo: Vec = local.code().chars().collect(); - typo[7] = if typo[7] == 'A' { 'B' } else { 'A' }; + // The last symbol contains only checksum bits. Changing payload bits can collide with + // the eight-bit checksum, so it does not guarantee a malformed code. + let last = typo.last_mut().unwrap(); + *last = if *last == 'A' { 'B' } else { 'A' }; let e = guest.execute("collab.join", &json!({"code": typo.iter().collect::()})).unwrap_err().to_string(); assert!(e.contains("typing mistake"), "{e}"); // A view-only code doesn't exist here, and says where it does. diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index c9c9fdce..95ec721d 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -248,7 +248,7 @@ fn exchange(app: &mut FigApp, p: &mut Pane) { /// file, at screen point `at` (else beside this window). Returns its id. With one window (the /// browser) or a screen reader running, a file opens as a tab here instead (and the error says so). pub fn open(app: &mut FigApp, ctx: &Context, index: Option, path: Option<&str>, at: Option) -> Result { - // shortcut: eframe 0.36 gives only the main window an AccessKit adapter, so other windows have + // shortcut: eframe 0.37 gives only the main window an AccessKit adapter, so other windows have // no screen-reader tree; while a screen reader runs files stay tabs here. Upgrade when eframe // adapts every viewport. let one = if ctx.embed_viewports() { diff --git a/docs/egui-037-migration-plan.md b/docs/egui-037-migration-plan.md index 6cf1bb09..93fd815b 100644 --- a/docs/egui-037-migration-plan.md +++ b/docs/egui-037-migration-plan.md @@ -1088,10 +1088,10 @@ agent/model/tool signature; contributor credit remains username-only (`@skylarta real evidence exists. 12. **Lockfile resolution:** one egui family, one winit, one wgpu, one craft-window. If Cargo chooses two versions, stop and fix the pins rather than accepting a duplicate. -13. **AccessKit per-viewport:** 0.37's eframe integrations initialize adapters per created viewport - in the inspected source; this may remove the 0.36 “main window only” limitation, but it has not - been exercised with a screen reader here. Keep the tab fallback until a real test says - otherwise. +13. **AccessKit per-viewport:** the pinned 0.37 fork initializes the native AccessKit adapter only + for the root viewport; creating a secondary viewport does not initialize an adapter. The + “main window only” limitation remains, and no real screen-reader qualification is claimed. + Keep the tab fallback until secondary-window accessibility is implemented and tested. 14. **Automation screenshot thread:** the callback must not write files outside `file_access` confinement. The shared completion-slot design in 6.3 is required, not optional. diff --git a/docs/gaps.md b/docs/gaps.md index e76731e7..280b04c5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -439,8 +439,9 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe needs to; closing one asks about unsaved changes; `window.new`, `window.list`, `window.focus` and `window` on any control request. - **Missing:** wide-gamut colour on the canvas (egui draws sRGB, so P3 colours outside sRGB clip on - screen; exports keep them); a screen-reader tree in windows other than the main one (eframe 0.36 - gives only the main window one); window positions restored at the next start. + screen; exports keep them); a screen-reader tree in windows other than the main one (eframe 0.37 + gives only the main window one; verified in the fork's viewport setup); window positions restored at + the next start. - **Estimate:** 4–8 h. ### 19. AI features @@ -487,6 +488,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | egui/eframe 0.37 verified for gap 18: the remaining limitations are unchanged. egui-wgpu 0.37 still picks an 8-bit sRGB surface with no colour-space option, so P3 colours outside sRGB still clip on screen; eframe 0.37 still gives only the main window an AccessKit adapter, so other windows have no screen-reader tree. Numbers unchanged | | 2026-10-25 | minor | Known gaps from the #1311 review: gap 4, the export preview follows a slice's first export setting; gap 14, the scopes a paint style's color takes are FigCraft's own rule; gap 15, `proto.linkNotice` from the palette or control channel isn't saved until the next preferences save; gap 16, the file name field still renames in Dev Mode (to check against Figma first). Ranking and hours unchanged | | 2026-10-24 | minor | Gap 16: Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Left: publishing mappings with a CLI; 2–4 h → 1–2 h | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. New gap 24 (the board tail, 30–60 h); the ranking above it is unchanged | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 1b2536dc..61282d53 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware and platform parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out @@ -15,10 +15,10 @@ since 2025). FigCraft renders the canvas on the CPU and presents it through wgpu | Canvas rendering | GPU | CPU (tiny-skia), banded across cores, retained (re-renders only what an edit or pan touches), levels of detail below 50% zoom; when renders from scratch are slow, a new zoom shows the whole view coarsely first, then refines it (#312); shown through wgpu (WebGPU or WebGL2 in the browser) | 30–60 for a GPU path | | Effects (blurs, glass, noise) | GPU | CPU | in the above | | HiDPI / Retina | yes | yes (all); a canvas image rendered at the screen's density is placed on whole device pixels, so fractional pans, zooms and display scales don't blur it (#359; geometry tests, the snapping not judged by eye) | — | -| Wide-gamut display (Display P3) | yes, on capable displays | colours stored and exported in P3; the screen shows sRGB: eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no colour space option, although wgpu 30 can present Display P3 or extended sRGB on Metal, DX12, Vulkan and WebGPU (see gaps #18). The colour picker warns about colours outside sRGB and View ▸ Soft-proof sRGB stripes them on the canvas | 2–5 | +| Wide-gamut display (Display P3) | yes, on capable displays | colours stored and exported in P3; the screen shows sRGB: eframe/egui-wgpu 0.37 configure an 8-bit sRGB surface with no colour space option, although wgpu 30 can present Display P3 or extended sRGB on Metal, DX12, Vulkan and WebGPU (see gaps #18). The colour picker warns about colours outside sRGB and View ▸ Soft-proof sRGB stripes them on the canvas | 2–5 | | Trackpad pinch zoom and two-finger pan | yes | yes (egui zoom delta, smooth scroll) | — | | Pen tablet pressure (pencil, Figma Draw brushes) | yes (forum reports of regressions in 2025) | Windows pens (winit pointer input) and browser styluses that send touch events with a force (an Apple Pencil in Safari); the Pencil records it as the stroke's width profile. A mouse, macOS and Linux tablets and pens sending only pointer events in a browser report none: constant width | 1–3 | -| Several windows / multi-monitor | yes (a window per file; presenter in its own window) | desktop: File ▸ New window, a tab's Open in new window or a tab dragged off the strip; each window has its own tabs, panels and canvas, draws only when it needs to and asks about unsaved changes when it closes; a minimised or covered window still answers control and MCP requests, while screenshots with hidden or unknown visibility request a hidden paint and expire at their 60-second admission deadline if unfinished; injected pointer/key/text input waits for a real UI frame (#313, #332, #720). Browser and Wayland hidden-paint behavior remain unqualified. Only the main window has a screen-reader tree (eframe 0.36), so files stay tabs while a screen reader runs; positions aren't restored at the next start; the presenter stays inside its window; one window in the browser | 1–2 | +| Several windows / multi-monitor | yes (a window per file; presenter in its own window) | desktop: File ▸ New window, a tab's Open in new window or a tab dragged off the strip; each window has its own tabs, panels and canvas, draws only when it needs to and asks about unsaved changes when it closes; a minimised or covered window still answers control and MCP requests, while screenshots with hidden or unknown visibility request a hidden paint and expire at their 60-second admission deadline if unfinished; injected pointer/key/text input waits for a real UI frame (#313, #332, #720). Browser and Wayland hidden-paint behavior remain unqualified. Only the main window has a screen-reader tree (eframe 0.37), so files stay tabs while a screen reader runs; positions aren't restored at the next start; the presenter stays inside its window; one window in the browser | 1–2 | | Screen readers | partial | AccessKit tree on desktop (names, roles, states), never tested with a real screen reader; none in the browser | 4–8 + human QA | | Keyboard layouts (non-US) | yes | keymap uses logical keys; not checked on AZERTY/QWERTZ/JIS | 1–2 | @@ -50,6 +50,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import). | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | egui/eframe 0.37 dependency migration: the workspace pins the egui family at 0.37, and the wide-gamut and several-windows rows now cite 0.37. The limitations are unchanged: egui-wgpu 0.37 offers no surface colour-space option, so the screen still shows sRGB; eframe 0.37 still gives only the main window an AccessKit adapter, so files stay tabs while a screen reader runs. Numbers unchanged | | 2026-10-25 | minor | Hidden or unknown-visibility screenshots request a hidden paint and expire at their 60-second admission deadline if unfinished; injected pointer/key/text input waits for a real UI frame. Browser and Wayland hidden-paint behavior remain unqualified. Numbers unchanged. | | 2026-10-15 | minor | Community fixes (#290, #306, #312, #313, #328, #332, #346, #474): coarse zoom previews when renders from scratch are slow; a minimised or covered window answers control and MCP requests (screenshots and input fail at once while it is hidden); `.figcraft` files open from Finder on macOS; a message box when the window can't open on Windows; on Linux the window id matches the desktop entry, the RPM requires glibc 2.35 and user fonts under `XDG_DATA_HOME` are found. Numbers unchanged | | 2026-10-18 | minor | macOS quit crash (#781): on Macs with a Touch Bar the app could crash as it quit (an exception in AppKit's Touch Bar support while the window's content view, which the accessibility adapter subclasses, was torn down). Quitting now keeps everything (preferences, this run's recovery copies, live sessions ended) and then ends the process before that teardown. Quit from the menu bar ends the app without the unsaved-changes question, so unsaved files' recovery copies now stay and reopen at the next start instead of being removed. Numbers unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e1c219c6..74b056d8 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -20,7 +20,7 @@ interaction feel not yet compared. | Minimize UI (Cmd+\\) | done | | | Resizable panels | done | each panel's edge drags its width (left 200–480, right 240–480, 240 by default; a double-click on the edge or View ▸ Panels ▸ Reset panel widths puts the default back); the widths are each window's own, the main window's are kept in the preferences when a drag ends and come back at the next start, a new window opens with those of the window it comes from; Minimize UI hides the panels and brings them back as they were; the view keeps its centre and zoom while a panel is resized; in a window too small for them the panels give way so the canvas keeps 160 px (the left one first), down to the smallest window (800×500) (`panel_width_tests`). Not compared with Figma's widths; no snap points | | Actions palette (Cmd+K, Cmd+/) | done | tabs: All (every command, grouped by menu, or Other), Assets (components and styles) and Plugins & widgets; results ranked prefix first, then shortest, at most 60 rows; Up/Down move a highlight and Enter runs it; Recents list what was run from the palette (matching translated labels); one open/toggle path from every entry point (`palette_tests`). Ranking and the Assets results not compared with Figma's | -| Context menus | done | canvas and layers; submenus as in the main menu below, and a right-click in a new spot starts a fresh menu (Select layer closed); Shift+F10 opens them from the keyboard (at the focused layer row or below the canvas selection) and the menu takes the keyboard; the Menu key isn't delivered by the windowing layer (egui 0.36 has no key for it) | +| Context menus | partial | canvas and layers; submenus as in the main menu below, and a right-click in a new spot starts a fresh menu (Select layer closed); Shift+F10 opens them from the keyboard (at the focused layer row or below the canvas selection) and the menu takes the keyboard; the Menu key reaches egui as `Key::Menu` (egui-winit 0.37 maps the native key), but FigCraft's keymap has no binding for `Menu`, so it does nothing yet | | Home screen: Recents, Online and Templates | done (ours) | The sidebar switches between the three (`ui.set {homeSection}`). Online: your online files (see the collaboration checklist). Recents: new file, open, join with a code, open files, recent files (cards with the picture each file carries on its page colour, the file's icon, name and "Edited ... ago" or "File not found", full path on hover; or a Name, Last modified and Created table with sortable headers; sorted by name, creation or last change either way; grid or list and the sort kept in the preferences, `ui.set {homeRecentsView, homeRecentsSort, homeRecentsOrder}`; a click selects, a double-click or Enter opens, arrow keys move the selection; Open, Show in folder and Remove from recents on right-click), a Getting started card (six tips with their shortcuts from the keymap; its close button is remembered in the preferences and Help ▸ Getting started brings it back) and, while there are no recent files, four templates to start from. Templates: the eight starter templates as cards (thumbnail rendered from the document, cached per theme; name, description, kind and size), a search field and a kind filter, an empty state; Tab reaches every card and Enter opens it; cards and tabs are named for screen readers. A first run opens here. No Community files or drafts folder | | Main menu from the logo | done | generated from `catalog.rs`; popups sized to content. Submenus open after 0.15 s on their row (a click or the keyboard opens one at once) and close at once when another row is hovered; while the pointer heads for an open submenu inside the triangle to its near edge, the rows it crosses don't take over, and resting 0.3 s on one hands over; closing the menu, however it closes, clears which submenus were open, so it reopens with none (`menu_aim_tests`, 10 tests; timings tuned by feel, not measured against Figma). Menu and palette items that need a value ask for it first: Create link, Offset path and Move to page in a dialog of the shared controls, the language, user name, server and nudge items in Preferences, Open recent on the home screen's Recents; File ▸ Rename edits the file name in place | | Keyboard shortcuts panel | done | closes when the home screen shows (#548); docked, tabbed by group like the documentation, searchable, per-platform keycaps; held keys, gestures and presentation keys listed beside the bindings; a label too long for its column ends with "…" and shows in full on hover | @@ -172,6 +172,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | Context menus: the Menu key is no longer an egui limitation at 0.37 (egui-winit maps the native Menu/ContextMenu key to egui's `Key::Menu`), but FigCraft normalizes no binding for `Menu`, so it does not open a context menu yet; Shift+F10 still does, with the same behaviour. The row moves from done to partial. Numbers unchanged | | 2026-10-24 | minor | Selection colors: a row's Styles menu also lists the document's color variables, and picking one binds it to every fill and stroke the row stands for, replacing a color style there (`selection.bindColorVariable`; a text range with fills of its own takes the variable's value); a row linked to a style or variable has a Detach button that unlinks it and keeps the color (`selection.detachColor`). Still partial: no library styles or variables, no new style or variable from a row | | 2026-10-24 | minor | The Text on a path tool (`tool.textOnPath`, in the Text flyout): a click on a shape's or vector's path puts new text on it, starting at the click, with the path's fill (when one shows) and effects, as the help center's Guide to text describes; the path layer is removed, and an edit left empty puts it back with no undo step (`draw_tools_tests`, `cursor_tests`) | | 2026-10-24 | minor | Boards (M12): eight board bindings pinned (tool keys Q sticky, B shapes, X connector, M marker, U highlighter, E eraser, W stamp, and Shift+Cmd/Ctrl+Enter for the next sticky; boards only, on keys no design-file shortcut uses): 49 → 57 pinned | From d7da3f1bb15baf8a26074ffc03953f789b171543 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:01:22 +0000 Subject: [PATCH 33/54] Scale down images past 16384 px a side instead of drawing nothing Fixes #1336 Co-Authored-By: Claude Opus 5.5 --- crates/render/src/image_budget_tests.rs | 22 ++++++++++++++++++---- crates/render/src/images.rs | 19 +++++++++++-------- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/crates/render/src/image_budget_tests.rs b/crates/render/src/image_budget_tests.rs index 6228efc5..9a51f405 100644 --- a/crates/render/src/image_budget_tests.rs +++ b/crates/render/src/image_budget_tests.rs @@ -48,9 +48,11 @@ fn an_image_within_the_limits_decodes() { } #[test] -fn an_image_wider_or_taller_than_the_limit_is_refused() { - assert!(images::decode_within(&png(30, 20), ColorProfile::Srgb, 29, u64::MAX).is_none()); - assert!(images::decode_within(&png(20, 30), ColorProfile::Srgb, 29, u64::MAX).is_none()); +fn an_image_wider_or_taller_than_the_limit_is_scaled_down_to_fit() { + let (p, _) = images::decode_within(&png(30, 20), ColorProfile::Srgb, 15, u64::MAX).unwrap(); + assert_eq!((p.width(), p.height()), (15, 10)); + let (p, _) = images::decode_within(&png(20, 30), ColorProfile::Srgb, 15, u64::MAX).unwrap(); + assert_eq!((p.width(), p.height()), (10, 15)); } #[test] @@ -63,10 +65,22 @@ fn a_huge_image_is_refused_from_its_header_alone() { // 16 000 × 16 000 is within the side limit; its pixels (1 GB) aren't within a 1 MB budget, and // the file has none to decode: only the header is read. assert!(images::decode_within(&png_header_only(16_000, 16_000), ColorProfile::Srgb, 16_384, 1 << 20).is_none()); - // Past the side limit, the shipped decode refuses it the same way. + // Past the side limit, the shipped decode reads its header and finds no pixels to scale down. assert!(images::decode(&png_header_only(20_000, 10)).is_none()); } +#[test] +fn an_image_past_the_side_limit_is_drawn_downscaled() { + // #1336: one side over 16 384 px drew nothing. + let p = images::decode(&png(16_385, 4)).unwrap(); + assert_eq!(p.width(), 16_384); + assert!(p.height() >= 1 && p.height() <= 4); + assert!(p.pixels().iter().all(|c| c.alpha() == 255)); + let p = images::decode(&png(4, 18_078)).unwrap(); + assert_eq!(p.height(), 16_384); + assert!(p.width() >= 1 && p.width() <= 4); +} + fn key(name: &str) -> images::Key { (name.to_string(), [0; 7], ColorProfile::Srgb) } diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index f4f9c5d9..586c2dac 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -15,7 +15,7 @@ use tiny_skia::Pixmap; pub(crate) type Key = (String, [i32; 7], ColorProfile); -/// The largest image side decoded. +/// The largest side of a decoded image: larger images are scaled down to fit. const MAX_SIDE: u32 = 16_384; /// The most bytes one image's decoding may allocate: the image crate's own default limit (a /// larger image is refused from its header). @@ -83,26 +83,25 @@ fn fkey(f: &ImageFilters) -> [i32; 7] { [q(f.exposure), q(f.contrast), q(f.saturation), q(f.temperature), q(f.tint), q(f.highlights), q(f.shadows)] } -/// Decode encoded image bytes into a premultiplied sRGB pixmap (max 16k per side). +/// Decode encoded image bytes into a premultiplied sRGB pixmap (scaled down to 16k per side). pub fn decode(bytes: &[u8]) -> Option { decode_in(bytes, ColorProfile::Srgb).map(|(p, _)| p) } -/// Decode encoded image bytes into a premultiplied pixmap (max 16k per side): converted from the +/// Decode encoded image bytes into a premultiplied pixmap (scaled down to 16k per side): converted from the /// image's own profile into `target` (`false`), or as sRGB when it has none (`true`: the sRGB → /// `target` conversion is still to do). fn decode_in(bytes: &[u8], target: ColorProfile) -> Option<(Pixmap, bool)> { decode_within(bytes, target, MAX_SIDE, MAX_DECODED_BYTES) } -/// [`decode_in`] for images at most `max_side` wide and tall whose decoded pixels take at most -/// `max_bytes`; larger ones are refused from their header, before any pixels are decoded. +/// [`decode_in`] for images whose decoded pixels take at most `max_bytes` (larger ones are refused +/// from their header, before any pixels are decoded), scaled down to at most `max_side` wide and +/// tall. pub(crate) fn decode_within(bytes: &[u8], target: ColorProfile, max_side: u32, max_bytes: u64) -> Option<(Pixmap, bool)> { use image::ImageDecoder; let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; let mut limits = image::Limits::default(); - limits.max_image_width = Some(max_side); - limits.max_image_height = Some(max_side); limits.max_alloc = Some(max_bytes); reader.limits(limits); let mut dec = reader.into_decoder().ok()?; @@ -110,7 +109,11 @@ pub(crate) fn decode_within(bytes: &[u8], target: ColorProfile, max_side: u32, m return None; } let source = crate::image_profile::Source::read(bytes, &mut dec); - let img = image::DynamicImage::from_decoder(dec).ok()?; + let mut img = image::DynamicImage::from_decoder(dec).ok()?; + if img.width() > max_side || img.height() > max_side { + // `thumbnail` keeps the aspect ratio and works in integers (no float copy of the pixels). + img = img.thumbnail(max_side, max_side); + } match source.convert(&img, target) { Some(rgba) => Some((premultiplied(&rgba)?, false)), None => Some((premultiplied(&img.to_rgba8())?, true)), From 35cea3ef8c7ecbf7f5d08c76f608992a9348a16e Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Sun, 25 Oct 2026 22:13:52 +0400 Subject: [PATCH 34/54] i18n(ka): shorter wording for labels that were cut off Decided in the native-speaker review of the screens (all-t01). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/ui-egui/locales/ka.toml | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index eefb2dd3..aebbe528 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -21,7 +21,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "ამ ფაილის კოლექცია ამ რეჟიმებითა და ცვლადებით: შეცვალეთ მათი მნიშვნელობები თითოეული რეჟიმისთვის და დაამატეთ საკუთარი ცვლადები" "A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "ამ სახელით .figcraft ფაილი უკვე არსებობს. ჩანაცვლების დასადასტურებლად აირჩიეთ მისი სრული სახელი." "A layer on its own aligns to its frame: select more layers, or one in a frame" = "ცალკე შრე თავის ჩარჩოსთან სწორდება: მონიშნეთ მეტი შრე ან ჩარჩოში მყოფი შრე" -"A responsive product page: hero, features, figures, a quote and a footer" = "პროდუქტის ადაპტური გვერდი: მთავარი ბლოკი, შესაძლებლობები, ციფრები, ციტატა და ქვედა ზოლი" +"A responsive product page: hero, features, figures, a quote and a footer" = "პროდუქტის ადაპტური გვერდი ყველა ძირითადი ბლოკით" "A spring plays until it settles: its mass, stiffness and damping set how long" = "ზამბარა მოძრაობს, სანამ არ დამშვიდდება: მისი მასა, სიხისტე და მილევა განსაზღვრავს ხანგრძლივობას" "A title, an agenda, a big figure, a chart and a closing slide" = "სათაური, დღის წესრიგი, დიდი ციფრი, დიაგრამა და დასკვნითი სლაიდი" "A to Z" = "ა-დან ჰ-მდე" @@ -104,7 +104,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Alpha" = "ალფა" "Alphabetical" = "ანბანის მიხედვით" "Alt+drag" = "Alt+გადათრევა" -"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "ანალიტიკის ეკრანი ბადეზე: ციფრები, ვექტორებით დახატული დიაგრამები და ცხრილი" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "ანალიტიკა ბადეზე: ციფრები, დიაგრამები და ცხრილი" "An invite link that opens FigCraft and joins" = "მოსაწვევი ბმული, რომელიც ხსნის FigCraft-ს და უერთდება სესიას" "Anchor" = "საყრდენი" "Android" = "Android" @@ -215,7 +215,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Clear from this list" = "ამ სიიდან ამოღება" "Code or invite link" = "კოდი ან მოსაწვევი ბმული" "Color profile: {name}" = "ფერის პროფილი: {name}" -"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "ფერისა და ტექსტის სტილები, ღია და მუქი ცვლადები, ღილაკები, შეყვანის ველები და ბარათები" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "სტილები, ღია და მუქი ცვლადები, ღილაკები და ბარათები" "Connector" = "შემაერთებელი" "Convert to frame" = "ჩარჩოდ გარდაქმნა" "Convert to section" = "სექციად გარდაქმნა" @@ -480,7 +480,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Expand" = "გაშლა" "Export" = "ექსპორტირება" "Export all" = "ყველაფრის ექსპორტირება" -"Export frames and layers as PNG, JPG, SVG or PDF" = "ჩარჩოებისა და შრეების ექსპორტირება PNG, JPG, SVG ან PDF ფორმატში" +"Export frames and layers as PNG, JPG, SVG or PDF" = "ჩარჩოები და შრეები PNG, JPG, SVG ან PDF ფორმატში" "Export page as PNG" = "გვერდის PNG ფაილად ექსპორტირება" "Export settings" = "ექსპორტის პარამეტრები" "Export variables" = "ცვლადების ექსპორტირება" @@ -553,7 +553,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Font size" = "შრიფტის ზომა" "Font style" = "შრიფტის სტილი" "Forget on this computer" = "ამ კომპიუტერზე დავიწყება" -"Four screens of a trail app with components and a prototype flow" = "ლაშქრობის აპის ოთხი ეკრანი კომპონენტებითა და პროტოტიპის ნაკადით" +"Four screens of a trail app with components and a prototype flow" = "ლაშქრობის აპის ოთხი ეკრანი და პროტოტიპის ნაკადი" "Fractions" = "წილადები" "Frame" = "ჩარჩო" "Frame selection" = "მონიშნულის ჩარჩოში მოქცევა" @@ -576,7 +576,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Gradient" = "გრადიენტი" "Gray" = "ნაცრისფერი" "Green" = "მწვანე" -"Greyscale components and two example screens to sketch a flow with" = "ნაცრისფერი კომპონენტები და ორი სანიმუშო ეკრანი ნაკადის ჩანახატისთვის" +"Greyscale components and two example screens to sketch a flow with" = "ნაცრისფერი კომპონენტები ნაკადის ჩანახატისთვის" "Grid" = "ბადე" "Grid ({size})" = "ბადე ({size})" "Grid styles" = "ბადის სტილები" @@ -623,7 +623,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Hue" = "ფერის ტონი" "Hug" = "შიგთავსზე მორგება" "Hug contents" = "შიგთავსზე მორგება" -"Icon components on a 24 px grid with keylines and a size preview" = "ხატულების კომპონენტები 24 px ბადეზე საორიენტაციო ხაზებითა და ზომების გადახედვით" +"Icon components on a 24 px grid with keylines and a size preview" = "ხატულები 24 px ბადეზე, საორიენტაციო ხაზებით" "Icon set starter" = "ხატულების ნაკრების საწყისი" "If it keeps happening, please report it at {link} with this message." = "თუ ეს განმეორდება, გთხოვთ, ეს შეტყობინება გამოგვიგზავნეთ აქ: {link}" "If" = "თუ" @@ -745,7 +745,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Link" = "ბმული" "Link copied" = "ბმული დაკოპირდა" "Link docs, code or tickets for developers." = "მიაბით დოკუმენტაცია, კოდი ან ტიკეტები დეველოპერებისთვის." -"Link frames on the Prototype tab, then present the flow" = "დააკავშირეთ ჩარჩოები „პროტოტიპის“ ჩანართზე, შემდეგ წარადგინეთ ნაკადი" +"Link frames on the Prototype tab, then present the flow" = "დააკავშირეთ ჩარჩოები და წარადგინეთ ნაკადი" "Link this component to its code component: instances then show your real snippet with their values." = "დააკავშირეთ ეს კომპონენტი მის კოდის კომპონენტთან: ინსტანსები მაშინ თქვენს რეალურ ფრაგმენტს აჩვენებს მათი მნიშვნელობებით." "List spacing" = "სიის დაშორება" "List style" = "სიის სტილი" @@ -921,7 +921,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "On click" = "დაწკაპუნებით" "On drag" = "გადათრევისას" "On this page" = "ამ გვერდზე" -"One announcement in five common sizes: square, portrait, story, link and banner" = "ერთი განცხადება ხუთ ხშირად გამოყენებულ ზომაში: კვადრატი, ვერტიკალური, სთორი, ბმული და ბანერი" +"One announcement in five common sizes: square, portrait, story, link and banner" = "ერთი განცხადება ხუთ პოპულარულ ზომაში" "Online" = "ონლაინ" "Online files" = "ონლაინ ფაილები" "Online sharing is off: files are shared on the local network only." = "ონლაინ გაზიარება გამორთულია: ფაილები მხოლოდ ლოკალურ ქსელში ზიარდება." @@ -1003,7 +1003,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Pick color" = "ფერის არჩევა" "Pick color (I)" = "ფერის აღება (I)" "Pick one font family first" = "ჯერ აირჩიეთ ერთი შრიფტის ოჯახი" -"Pick the Frame tool, then drag on the canvas or choose a device size" = "აირჩიეთ ჩარჩოს ხელსაწყო, შემდეგ გადაათრიეთ ტილოზე ან აირჩიეთ მოწყობილობის ზომა" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "ჩარჩოს ხელსაწყოთი გადაათრიეთ ან აირჩიეთ ზომა" "Pin a property" = "თვისების მიმაგრება" "Pinch" = "მოჭყლეტა" "Pixel grid" = "პიქსელური ბადე" @@ -1182,7 +1182,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Save files automatically" = "ფაილების ავტომატურად შენახვა" "Search templates" = "შაბლონების ძიება" "See all templates" = "ყველა შაბლონის ნახვა" -"Select layers and they space themselves as their content changes" = "მონიშნეთ შრეები და შიგთავსის ცვლილებისას ისინი ავტომატურად დაშორდება ერთმანეთს" +"Select layers and they space themselves as their content changes" = "შრეები შიგთავსის ცვლილებისას თავად დაშორდება" "SHA-256: {sha}…" = "SHA-256: {sha}…" "Same as layer" = "შრის მსგავსად" "Saturation" = "გაჯერება" @@ -1330,7 +1330,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Start on path" = "დასაწყისი ტრაექტორიაზე" "Start point" = "საწყისი წერტილი" "Start sharing" = "გაზიარების დაწყება" -"Start sharing with the Share button, then pass on the code it gives" = "გაზიარება დაიწყეთ ღილაკით „გაზიარება“, შემდეგ გადაეცით მის მიერ მოცემული კოდი" +"Start sharing with the Share button, then pass on the code it gives" = "დააჭირეთ „გაზიარებას“ და გადაეცით მიღებული კოდი" "Starting to share…" = "გაზიარების დაწყება…" "Status" = "სტატუსი" "Sticky" = "მიმაგრებული" @@ -1451,7 +1451,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Try another search or filter." = "სცადეთ სხვა ძიება ან ფილტრი." "Try another search, or browse templates." = "სცადეთ სხვა ძიება ან დაათვალიერეთ შაბლონები." "Try another word, or look at every kind." = "სცადეთ სხვა სიტყვა ან ნახეთ ყველა სახეობა." -"Turn a layer into a component, reuse it, and change every copy at once" = "შრე აქციეთ კომპონენტად, გამოიყენეთ ხელახლა და შეცვალეთ ყველა ასლი ერთდროულად" +"Turn a layer into a component, reuse it, and change every copy at once" = "შრე აქციეთ კომპონენტად და შეცვალეთ ყველა ასლი" "Two digits" = "ორი ციფრი" "Type" = "ტიპი" "Type settings" = "ტექსტის პარამეტრები" From 236f3185d68512e0f4af0eefe26d24c2e1c4ddd7 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 25 Oct 2026 13:04:39 -0600 Subject: [PATCH 35/54] Migrate FigCraft to egui 0.37 (#1312) * E21: require release font coverage and stabilize capture deadline tests * E21: separate capture, IME and field-save regression phases * M0: correct the migration qualification reference * M-egui037: document input lifecycle and UI regression contracts * M0: correct egui 0.37 status docs and stabilize join-code regression --------- Co-authored-by: Dylan M. Taylor Co-authored-by: Codebuff --- .github/workflows/ci.yml | 9 +- .github/workflows/freebsd.yml | 2 +- .github/workflows/release.yml | 9 +- ATTRIBUTION.md | 13 +- Cargo.lock | 191 +-- Cargo.toml | 32 +- NOTICE | 17 + ROADMAP.md | 4 +- apps/figcraft-web/src/web.rs | 10 +- apps/figcraft/Cargo.toml | 2 +- apps/figcraft/src/control_server.rs | 45 +- apps/figcraft/src/control_server_tests.rs | 54 +- apps/figcraft/src/main.rs | 17 +- crates/collab-net/src/code_tests.rs | 5 +- crates/collab-net/src/sessions_tests.rs | 7 + crates/engine/src/cmd/edit.rs | 19 +- crates/engine/src/lib.rs | 15 + crates/engine/src/text_behaviour_tests.rs | 57 + crates/engine/src/textedit.rs | 538 ++++++- crates/engine/src/textfit_tests.rs | 4 + crates/engine/src/tools.rs | 10 +- crates/text/build.rs | 187 ++- crates/text/tests/build_input.rs | 4 + crates/text/tests/fixtures/latin-families.txt | 52 + crates/ui-egui/Cargo.toml | 5 +- crates/ui-egui/src/a11y.rs | 20 +- crates/ui-egui/src/a11y_tests.rs | 28 + crates/ui-egui/src/assets_panel.rs | 57 +- crates/ui-egui/src/bidi.rs | 274 +--- crates/ui-egui/src/board_bar.rs | 8 +- crates/ui-egui/src/board_ui_tests.rs | 4 +- crates/ui-egui/src/branch_ui.rs | 4 +- crates/ui-egui/src/canvas.rs | 6 +- crates/ui-egui/src/clipboard_fence_tests.rs | 200 +++ crates/ui-egui/src/colorpicker.rs | 51 +- crates/ui-egui/src/comments.rs | 26 +- crates/ui-egui/src/component_panel.rs | 15 +- crates/ui-egui/src/control.rs | 919 ++++++++++-- crates/ui-egui/src/control_capture_tests.rs | 657 +++++++++ crates/ui-egui/src/corner_individual_tests.rs | 6 +- crates/ui-egui/src/credits.rs | 4 +- crates/ui-egui/src/credits_tests.rs | 6 +- crates/ui-egui/src/cursors.rs | 2 +- crates/ui-egui/src/dev_compare.rs | 8 +- crates/ui-egui/src/dev_mode.rs | 42 +- crates/ui-egui/src/dev_mode_tests.rs | 9 +- crates/ui-egui/src/dialogs.rs | 20 +- crates/ui-egui/src/export_panel.rs | 12 +- crates/ui-egui/src/export_preview_tests.rs | 4 +- crates/ui-egui/src/field_tests.rs | 364 ++++- crates/ui-egui/src/font_preview.rs | 2 +- crates/ui-egui/src/frame_labels.rs | 8 +- crates/ui-egui/src/frame_presets.rs | 4 +- crates/ui-egui/src/guides.rs | 5 +- crates/ui-egui/src/handles.rs | 3 +- crates/ui-egui/src/home.rs | 29 +- crates/ui-egui/src/home_recents.rs | 4 +- crates/ui-egui/src/icons.rs | 4 +- crates/ui-egui/src/idle_tests.rs | 89 +- crates/ui-egui/src/ime_tests.rs | 559 ++++++++ crates/ui-egui/src/layers.rs | 59 +- crates/ui-egui/src/layers_behaviour_tests.rs | 8 +- crates/ui-egui/src/layers_drag_tests.rs | 27 + crates/ui-egui/src/layers_rename_tests.rs | 3 + crates/ui-egui/src/layout_panel.rs | 43 +- crates/ui-egui/src/lib.rs | 359 ++++- crates/ui-egui/src/list_drag.rs | 28 +- crates/ui-egui/src/live.rs | 40 +- crates/ui-egui/src/live_join_tests.rs | 3 +- crates/ui-egui/src/live_server_tests.rs | 3 +- crates/ui-egui/src/live_share_tests.rs | 9 +- crates/ui-egui/src/live_tests.rs | 3 + crates/ui-egui/src/menu_aim_tests.rs | 7 +- crates/ui-egui/src/menu_input_tests.rs | 4 +- crates/ui-egui/src/menus.rs | 81 +- crates/ui-egui/src/online.rs | 15 +- .../ui-egui/src/padding_individual_tests.rs | 6 +- crates/ui-egui/src/panel_remove_tests.rs | 14 +- crates/ui-egui/src/panel_reorder_tests.rs | 3 + crates/ui-egui/src/pending_open.rs | 2 +- crates/ui-egui/src/perf_tests.rs | 12 +- crates/ui-egui/src/plugin_panel_tests.rs | 8 +- crates/ui-egui/src/plugins_ui.rs | 26 +- crates/ui-egui/src/present.rs | 144 +- crates/ui-egui/src/present_tests.rs | 4 + crates/ui-egui/src/present_view_tests.rs | 5 + crates/ui-egui/src/proto_canvas.rs | 7 +- crates/ui-egui/src/proto_editor.rs | 5 +- crates/ui-egui/src/proto_editor_tests.rs | 9 +- crates/ui-egui/src/proto_panel.rs | 59 +- crates/ui-egui/src/proto_reorder_tests.rs | 3 +- crates/ui-egui/src/right.rs | 189 ++- crates/ui-egui/src/rtl_names_tests.rs | 275 +++- crates/ui-egui/src/scale_panel.rs | 6 +- crates/ui-egui/src/section_panel.rs | 5 +- crates/ui-egui/src/select_all_tests.rs | 18 +- crates/ui-egui/src/selection_colors.rs | 20 +- crates/ui-egui/src/selection_spacing.rs | 14 +- crates/ui-egui/src/selection_spacing_tests.rs | 7 +- crates/ui-egui/src/shortcuts.rs | 625 +++++++-- crates/ui-egui/src/style_picker.rs | 22 +- crates/ui-egui/src/style_picker_tests.rs | 6 +- crates/ui-egui/src/tabs.rs | 30 +- crates/ui-egui/src/theme.rs | 17 +- crates/ui-egui/src/theme_startup_tests.rs | 17 + crates/ui-egui/src/titlebar_tests.rs | 4 +- crates/ui-egui/src/toolbar.rs | 29 +- crates/ui-egui/src/type_settings_tests.rs | 4 +- crates/ui-egui/src/typography.rs | 57 +- crates/ui-egui/src/typography_scrub_tests.rs | 13 +- crates/ui-egui/src/variable_ui.rs | 17 +- crates/ui-egui/src/variables_panel.rs | 33 +- crates/ui-egui/src/vector_panel.rs | 19 +- crates/ui-egui/src/versions.rs | 8 +- crates/ui-egui/src/widgets.rs | 55 +- crates/ui-egui/src/windows.rs | 124 +- crates/ui-egui/src/windows_tests.rs | 153 +- crates/ui-egui/tests/it/recent_file_names.rs | 2 +- .../tests/type_settings_long_labels.rs | 2 +- docs/control-protocol.md | 33 +- docs/egui-037-local-followups.md | 214 +++ docs/egui-037-migration-plan.md | 1230 +++++++++++++++++ docs/gaps.md | 8 +- docs/hardware-parity.md | 8 +- docs/plugins.md | 5 +- docs/releasing.md | 13 +- docs/ui-parity.md | 5 +- .../linux/flatpak/ai.storyteller.figcraft.yml | 1 + packaging/linux/package.sh | 1 - packaging/macos/package.sh | 1 + packaging/windows/figcraft.wxs | 12 + packaging/windows/package.ps1 | 16 + plugins/Cargo.toml | 3 +- xtask/src/main.rs | 13 +- 134 files changed, 7698 insertions(+), 1396 deletions(-) create mode 100644 crates/text/tests/build_input.rs create mode 100644 crates/text/tests/fixtures/latin-families.txt create mode 100644 crates/ui-egui/src/clipboard_fence_tests.rs create mode 100644 crates/ui-egui/src/control_capture_tests.rs create mode 100644 docs/egui-037-local-followups.md create mode 100644 docs/egui-037-migration-plan.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1968076f..b79af502 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,11 +21,10 @@ jobs: # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository. uses: storytold/craft-common-actions/.github/workflows/gate.yml@main with: - # The craft-fonts commit release.yml builds with. Needs fonts/latin-manifest.txt (Inter): - # crates/text/build.rs fails a build that requires craft-fonts without it. Only craft-fonts' - # pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter. Re-pin - # once one commit has both (storytold/craft-fonts#42). - craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 + # Combined Latin presentation and core UI catalog. Required web builds enforce + # Inter Regular/SemiBold and Noto Sans Georgian Regular after asset-policy filtering. + craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69 + require-craft-fonts: true packages: >- libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev libgtk-3-dev diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 3b03a99c..80adf16a 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -32,4 +32,4 @@ jobs: uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main with: run-package-check: true - craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc + craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd2aefdd..f93219fa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,13 +24,12 @@ jobs: with: app-name: figcraft app-title: FigCraft - # Needs fonts/latin-manifest.txt (Inter): crates/text/build.rs fails a release without it. Only - # craft-fonts' pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter. - # Re-pin once one commit has both (storytold/craft-fonts#42). - craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 + # Combined Latin presentation and core UI catalog. Required web builds enforce + # Inter Regular/SemiBold and Noto Sans Georgian Regular after asset-policy filtering. + craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69 has-server: true # The browser has no system fonts: without craft-fonts the web build can't draw text (#885). - # crates/text/build.rs keeps the web build to Inter. + # crates/text/build.rs keeps the web allowlist small (Inter and Georgian). web-craft-fonts: true version: ${{ inputs.version }} secrets: diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 96dff6a9..46b789a8 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -26,8 +26,17 @@ Release builds embed fonts from the optional craft-fonts build input |---|---|---| | Inter | SIL Open Font License 1.1 | https://github.com/rsms/inter | | Archivo | SIL Open Font License 1.1 | [Google Fonts / Archivo](https://github.com/google/fonts/tree/6c70c829f09ea345d3590406693220ea35c6553f/ofl/archivo) | -| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Source Sans 3, Source Serif 4, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) | +| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) | | Liberation Sans, Serif, Mono | SIL Open Font License 1.1 | https://github.com/liberationfonts | -| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) | +| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic, Noto Sans Georgian | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) | + +The central catalog also contains Adobe-authored Source Sans 3 and Source Serif 4. +FigCraft filters both families from every build in accordance with its asset policy. egui's default fonts (compiled into the `egui`/`epaint` crates) keep their own licences (OFL / Apache-2.0 / Ubuntu Font Licence); see those crates. + +## egui dependency font icons + +| Asset | Author | Source | Licence | Use | +|---|---|---|---|---| +| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset, included by default_fonts independently of monochrome_emoji_fonts; emoji-icon-font.ttf is the full monochrome fallback. Dependency assets unmodified here; no font file copied into this repository. | diff --git a/Cargo.lock b/Cargo.lock index 2d84daad..37affd4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,21 +4,21 @@ version = 4 [[package]] name = "accesskit" -version = "0.24.1" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" dependencies = [ "uuid", ] [[package]] name = "accesskit_atspi_common" -version = "0.18.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4" dependencies = [ "accesskit", - "accesskit_consumer 0.36.0", + "accesskit_consumer", "atspi-common", "phf", "serde", @@ -27,43 +27,37 @@ dependencies = [ [[package]] name = "accesskit_consumer" -version = "0.35.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" dependencies = [ "accesskit", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] -name = "accesskit_consumer" -version = "0.36.0" +name = "accesskit_ios" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937" dependencies = [ "accesskit", - "hashbrown 0.16.1", -] - -[[package]] -name = "accesskit_consumer" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", ] [[package]] name = "accesskit_macos" -version = "0.26.3" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4" dependencies = [ "accesskit", - "accesskit_consumer 0.38.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -71,9 +65,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.21.1" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -89,13 +83,13 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.32.1" +version = "0.35.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "static_assertions", "windows", "windows-core", @@ -103,11 +97,12 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.32.2" +version = "0.34.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77" dependencies = [ "accesskit", + "accesskit_ios", "accesskit_macos", "accesskit_unix", "accesskit_windows", @@ -907,9 +902,8 @@ source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09 [[package]] name = "craft-window" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1f805378d454dac48b5c214029d254055fdf961b#1f805378d454dac48b5c214029d254055fdf961b" +source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f" dependencies = [ - "egui", "wayland-client", ] @@ -1125,9 +1119,8 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "ecolor" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "emath", @@ -1135,9 +1128,8 @@ dependencies = [ [[package]] name = "eframe" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1159,6 +1151,7 @@ dependencies = [ "pollster 1.0.1", "profiling", "raw-window-handle", + "send_wrapper", "static_assertions", "wasm-bindgen", "web-sys", @@ -1170,9 +1163,8 @@ dependencies = [ [[package]] name = "egui" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit", "ahash", @@ -1190,9 +1182,8 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1210,30 +1201,33 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", "egui", + "image", "log", "objc2 0.6.5", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", + "parking_lot", "profiling", "raw-window-handle", "smithay-clipboard", "web-time", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_extras" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05cbdc785020861553f230f23d330ba1fb72613b9d8e0e457f3bdc8ba6db6dcf" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "egui", @@ -1245,9 +1239,8 @@ dependencies = [ [[package]] name = "egui_glow" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "egui", @@ -1265,9 +1258,8 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "emath" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", ] @@ -1292,22 +1284,22 @@ checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" [[package]] name = "enum-map" -version = "2.7.3" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" +checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06" dependencies = [ "enum-map-derive", ] [[package]] name = "enum-map-derive" -version = "0.17.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" +checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] @@ -1356,9 +1348,8 @@ dependencies = [ [[package]] name = "epaint" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1374,16 +1365,17 @@ dependencies = [ "self_cell", "skrifa 0.44.0", "smallvec", + "unicode-bidi", "unicode-general-category", + "unicode-properties", "unicode-segmentation", "vello_cpu", ] [[package]] name = "epaint_default_fonts" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[package]] name = "equivalent" @@ -2061,9 +2053,9 @@ dependencies = [ [[package]] name = "glifo" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ "bytemuck", "foldhash 0.2.0", @@ -3364,9 +3356,9 @@ dependencies = [ [[package]] name = "phf" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ "phf_macros", "phf_shared", @@ -3375,9 +3367,9 @@ dependencies = [ [[package]] name = "phf_generator" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", "phf_shared", @@ -3385,9 +3377,9 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ "phf_generator", "phf_shared", @@ -3398,9 +3390,9 @@ dependencies = [ [[package]] name = "phf_shared" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" dependencies = [ "siphasher", ] @@ -4071,6 +4063,12 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.229" @@ -4876,9 +4874,9 @@ dependencies = [ [[package]] name = "vello_common" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" dependencies = [ "bytemuck", "fearless_simd", @@ -4891,9 +4889,9 @@ dependencies = [ [[package]] name = "vello_cpu" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", "glifo", @@ -5693,8 +5691,7 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winit" version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc" dependencies = [ "ahash", "android-activity", @@ -6190,3 +6187,23 @@ dependencies = [ "syn 3.0.9", "winnow", ] + +[[patch.unused]] +name = "egui_demo_lib" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_inspection" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_kittest" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" diff --git a/Cargo.toml b/Cargo.toml index 39abeca3..5395ff97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,8 +35,11 @@ figcraft-plugin = { path = "crates/plugin" } # Which paths stored in a document may be followed (craft-libs). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } # Who draws the window frame on Linux — the compositor or the app — so every Craft app shows the -# same native titlebar per desktop (craft-libs). The `egui` feature adds `decorate`/`decorations`. -craft-window = { git = "https://github.com/storytold/craft-libs", rev = "1f805378d454dac48b5c214029d254055fdf961b", features = ["egui"] } +# same native titlebar per desktop (craft-libs). FigCraft only reads the probe result and maps it +# to its own `TitleBar`, so it does not enable craft-window's optional `egui` helpers (which would +# pull a second egui family version). The rev is the last merged one; the released 0.37 pin is +# supplied by the migration's reviewed craft-libs revision. +craft-window = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f" } # foundation kurbo = { version = "0.13", features = ["serde"] } @@ -79,9 +82,9 @@ wasmi = { version = "2", default-features = false, features = ["std", "stable", wat = "1.261" # ui -egui = "0.36" -egui_extras = { version = "0.36", default-features = false } -eframe = { version = "0.36", default-features = false } +egui = "0.37" +egui_extras = { version = "0.37", default-features = false } +eframe = { version = "0.37", default-features = false } rfd = "0.17" arboard = { version = "3.6", default-features = false } webbrowser = "1" @@ -130,7 +133,24 @@ debug = false strip = true panic = "abort" +# Keep the complete egui family and native event bridge on reviewed fork commits. +[patch.crates-io] +ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } + # craft-libs' vendored krilla 0.8.2: decoded PNGs are capped at 1 GiB and reserved fallibly, and a # panic while drawing unwinds without aborting. -[patch.crates-io] krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" } diff --git a/NOTICE b/NOTICE index a0baaa27..9970631d 100644 --- a/NOTICE +++ b/NOTICE @@ -51,3 +51,20 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +egui icon font subset (dependency asset) +---------------------------------------- +epaint_default_fonts 0.37 embeds egui-icons.ttf, a five-glyph subset of +emoji-icon-font by John Slegers. The full monochrome emoji-icon-font fallback +uses the same MIT license. Upstream license text follows unchanged. + +MIT License + +Copyright (c) 2014 John Slegers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/ROADMAP.md b/ROADMAP.md index 64ba8ffc..0e0ae25b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Dev Mode measurements kept on the canvas and a read-only layers panel; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -132,6 +132,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. - **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change. - **2026-10-24** · Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 98% / 78%. - **2026-10-24** · Boards (M12): File ▸ New board makes a board, a file kind of its own saved in `.figcraft` and carried to people joining a live session. Sticky notes (8 colours, text that shrinks as the note grows, the author's name, Shift+Cmd/Ctrl+Enter for the next one), shapes with text (7 outlines), connectors that re-route when what they join moves (computed in the engine's normalize pass, so local edits, undo and others' edits agree), marker, highlighter and eraser, stamps with the placer's name on hover; a board toolbar, a contextual bar over the selection and the Design panel hidden until asked for; prototyping and Dev Mode refuse on boards. Design files and boards have their own marks (a pen nib, a note) on tabs, the home screen and the Open files list. Checked in the app, with two app instances in one live session, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)). @@ -334,6 +335,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. | | 2026-10-24 | minor | Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 97% → 98% breadth, 76% → 78% ready, 2–4 h → 1–2 h; headline numbers unchanged at this rounding | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding | | 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged | diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index ddb876b0..40451d43 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -69,7 +69,15 @@ struct WebShell { } impl eframe::App for WebShell { - // `FigApp::ui` runs its own per-frame logic first (fonts, theme), like the desktop app. + // eframe runs `logic` for visible tabs and, with only `run_logic`, for hidden ones: keep + // autosave/live/control deadlines moving without a paint. `FigApp::ui` does not repeat it. + /// Run the browser shell's eframe logic callback: guard `background_tick` so hidden tabs + /// still advance autosave, live and control deadlines without painting. `FigApp::ui` does + /// not repeat it. + fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + figcraft_ui_egui::windows::guard(&mut self.fig, ctx, |fig| fig.background_tick(ctx)); + } + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { self.fig.ui(ui); } diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml index 6196d08d..141b9517 100644 --- a/apps/figcraft/Cargo.toml +++ b/apps/figcraft/Cargo.toml @@ -21,7 +21,7 @@ figcraft-text = { workspace = true } # craft-libs#217 branch and must be repointed to a merged `rev` before this PR is mergeable. craft-window = { workspace = true } egui = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] } +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "wayland", "x11"] } serde_json = { workspace = true } log = { workspace = true } rfd = { workspace = true } diff --git a/apps/figcraft/src/control_server.rs b/apps/figcraft/src/control_server.rs index ff9c2fbe..ada44c0f 100644 --- a/apps/figcraft/src/control_server.rs +++ b/apps/figcraft/src/control_server.rs @@ -6,7 +6,7 @@ use std::net::{TcpListener, TcpStream}; use std::sync::{Arc, Mutex}; use std::time::Duration; -use figcraft_ui_egui::ControlRequest; +use figcraft_ui_egui::{ControlReply, ControlRequest, RequestGate, RequestState}; use serde_json::{Value, json}; type Queue = Arc>>; @@ -21,6 +21,34 @@ const TIMEOUT: Duration = Duration::from_secs(60); /// core busy until it recovers. const ACCEPT_RETRY: Duration = Duration::from_millis(100); +/// Why a request's transport wait ended without a control reply. +#[derive(Debug, PartialEq, Eq)] +enum WaitFailure { + Timeout, + /// The UI side dropped its sender before replying (the app is closing). + Disconnected, +} + +/// Wait for the UI's reply, and settle a timeout under the same gate the UI's final commit uses. +/// A commit that got the gate first has already queued its reply: consume that reply instead of +/// reporting a timeout. If the gate is still open, cancel it so the UI writes nothing. +fn wait_for_reply(rx: &std::sync::mpsc::Receiver, gate: &RequestGate, timeout: Duration) -> Result { + match rx.recv_timeout(timeout) { + Ok(reply) => Ok(reply), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => after_timeout(rx, gate), + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(WaitFailure::Disconnected), + } +} + +/// The timeout branch of [`wait_for_reply`]. Kept separate so it can be tested deterministically +/// against a gate whose commit already happened. +fn after_timeout(rx: &std::sync::mpsc::Receiver, gate: &RequestGate) -> Result { + match gate.cancel_if_open() { + RequestState::Replied => rx.try_recv().map_err(|_| WaitFailure::Disconnected), + RequestState::Open | RequestState::Cancelled => Err(WaitFailure::Timeout), + } +} + /// Deferred requests for a hidden window must not make the UI's queue grow without a bound. pub fn drain_queue(queue: &Queue, target: &mut Vec) { target.retain(|request| !request.is_cancelled()); @@ -69,6 +97,9 @@ fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut } } +/// Serve one control connection: read bounded JSON lines, reject oversized frames and a +/// full queue, queue one request, wait under its gate, and write one reply. Non-JSON input +/// answers with an error and closes the connection so HTTP body lines cannot become commands. fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> std::io::Result<()> { out.set_read_timeout(Some(timeout))?; out.set_write_timeout(Some(timeout))?; @@ -96,7 +127,7 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s let method = v.get("method").and_then(Value::as_str).unwrap_or("").to_string(); let params = v.get("params").cloned().unwrap_or(json!({})); let (tx, rx) = std::sync::mpsc::channel(); - let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let gate = RequestGate::new(); let mut pending = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner); pending.retain(|request| !request.is_cancelled()); if pending.len() >= MAX_QUEUED_REQUESTS { @@ -104,20 +135,22 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s writeln!(out, "{}", json!({"id": id, "ok": false, "error": "control request queue is full"}))?; continue; } - pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, cancelled: cancelled.clone() }); + pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, state: gate.clone() }); drop(pending); if let Some(c) = ctx.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() { c.request_repaint(); } - match rx.recv_timeout(timeout) { + match wait_for_reply(&rx, &gate, timeout) { Ok(r) => match r.result { Ok(res) => json!({"id": r.id, "ok": true, "result": res}), Err(e) => json!({"id": r.id, "ok": false, "error": e}), }, - Err(_) => { - cancelled.store(true, std::sync::atomic::Ordering::Relaxed); + Err(WaitFailure::Timeout) => { json!({"id": id, "ok": false, "error": "timed out waiting for the UI: screenshots and input need the FigCraft window on screen (is it minimised or covered?)"}) } + Err(WaitFailure::Disconnected) => { + json!({"id": id, "ok": false, "error": "the FigCraft window closed before answering"}) + } } } Err(e) => { diff --git a/apps/figcraft/src/control_server_tests.rs b/apps/figcraft/src/control_server_tests.rs index 011b0613..f29a7773 100644 --- a/apps/figcraft/src/control_server_tests.rs +++ b/apps/figcraft/src/control_server_tests.rs @@ -28,8 +28,9 @@ fn reply(reader: &mut BufReader) -> Value { serde_json::from_str(&line).unwrap() } +/// Build a `ui.inspect` control request with a throwaway reply channel. fn request() -> ControlRequest { - ControlRequest { id: json!(0), method: "ui.inspect".into(), params: json!({}), reply: mpsc::channel().0, cancelled: Default::default() } + ControlRequest { id: json!(0), method: "ui.inspect".into(), params: json!({}), reply: mpsc::channel().0, state: Default::default() } } #[test] @@ -115,6 +116,52 @@ fn full_queue_rejects_before_dispatch_and_ui_queue_stays_bounded() { worker.join().unwrap().unwrap(); } +/// Asserts a commit that writes the file and queues its reply wins the gate: the transport's +/// timeout branch consumes that reply and leaves the state `Replied`. +#[test] +fn a_committed_reply_wins_over_the_transport_timeout() { + let dir = std::env::temp_dir().join(format!("figcraft-control-commit-{}", std::process::id())); + let path = dir.join("shot.png"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let gate = RequestGate::new(); + let (tx, rx) = mpsc::channel(); + // The UI's final commit gets the gate first: the file and reply are both committed before the + // transport's timeout branch can look at the gate. + let committed = gate.commit(|| { + std::fs::write(&path, b"png").unwrap(); + tx.send(ControlReply { id: json!(7), result: Ok(json!({"path": path.to_string_lossy()})) }).unwrap(); + }); + assert!(committed.is_some()); + assert!(path.is_file()); + let reply = after_timeout(&rx, &gate).expect("the queued commit is consumed"); + assert_eq!(reply.id, json!(7)); + assert!(reply.result.is_ok()); + assert_eq!(gate.state(), RequestState::Replied, "the timeout must not overwrite a commit"); + assert!(path.is_file(), "a winning commit keeps its file"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Asserts a zero-duration wait reports `Timeout` and cancels the open gate. +#[test] +fn a_timeout_cancels_an_open_request() { + let gate = RequestGate::new(); + let (_tx, rx) = mpsc::channel::(); + assert!(matches!(wait_for_reply(&rx, &gate, Duration::ZERO), Err(WaitFailure::Timeout))); + assert_eq!(gate.state(), RequestState::Cancelled, "nothing may write after the timeout"); +} + +/// Asserts a closed reply channel reports `Disconnected` and leaves the gate `Open`, not +/// cancelled. +#[test] +fn a_disconnected_reply_channel_is_not_reported_as_a_timeout() { + let gate = RequestGate::new(); + let (tx, rx) = mpsc::channel::(); + drop(tx); + assert!(matches!(wait_for_reply(&rx, &gate, Duration::from_secs(1)), Err(WaitFailure::Disconnected))); + assert_eq!(gate.state(), RequestState::Open, "a closed app is not a cancel decision"); +} + #[test] fn idle_and_partial_frame_connections_expire() { for bytes in [b"".as_slice(), b"{\"id\":"] { @@ -227,6 +274,9 @@ fn a_limit_sized_frame_is_accepted_at_eof() { worker.join().unwrap().unwrap(); } +/// Asserts expired requests stop occupying the UI queue cap, a later request is admitted +/// and answered, and a window that finally draws discards its expired command before +/// dispatch. #[test] fn timed_out_requests_release_a_full_deferred_ui_queue() { use figcraft_ui_egui::{FigApp, NoServices, control, windows}; @@ -262,7 +312,7 @@ fn timed_out_requests_release_a_full_deferred_ui_queue() { // Include expired requests in the socket queue, too: admission must reclaim their slots. pending.lock().unwrap().extend((0..MAX_QUEUED_REQUESTS).map(|_| { let r = request(); - r.cancelled.store(true, std::sync::atomic::Ordering::Relaxed); + r.state.cancel_if_open(); r })); let (mut client, server) = pair(); diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 0b71149a..4a1488ad 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -62,6 +62,9 @@ struct App { } impl eframe::App for App { + /// Native eframe logic callback: store the first context, drain control requests, run + /// guarded background work, poll macOS open events, handle close requests and title + /// changes, and schedule recovery repaints on the app clock. fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { { let mut slot = self.ctx_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner); @@ -71,7 +74,9 @@ impl eframe::App for App { } let title = with_app(|fig| { control_server::drain_queue(&self.queue, &mut fig.control_queue); - figcraft_ui_egui::control::drain_hidden(fig, ctx); + // Phase-safe background work: control queues, autosave/recovery deadlines, live polling. + // It runs on logic-only callbacks too, so it never reads UI input. + figcraft_ui_egui::windows::guard(fig, ctx, |fig| fig.background_tick(ctx)); #[cfg(target_os = "macos")] apple_events::poll(&self.apple_events, fig, ctx); // Closing the window asks about unsaved changes (and keeps the app running while other @@ -86,8 +91,14 @@ impl eframe::App for App { ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); self.title = title; } - let now = ctx.input(|i| i.time); - if let Some(wait) = with_app(|fig| recovery::lock(&self.recovery).as_mut().and_then(|r| r.tick(&fig.session, now))).flatten() { + // Recovery deadlines use the same monotonic clock as the app's background work: a + // logic-only tick leaves `ctx.input(i.time)` stale. + if let Some(wait) = with_app(|fig| { + let now = fig.now_secs(); + recovery::lock(&self.recovery).as_mut().and_then(|r| r.tick(&fig.session, now)) + }) + .flatten() + { ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait)); } } diff --git a/crates/collab-net/src/code_tests.rs b/crates/collab-net/src/code_tests.rs index c2745e9a..4cf10f6a 100644 --- a/crates/collab-net/src/code_tests.rs +++ b/crates/collab-net/src/code_tests.rs @@ -106,7 +106,10 @@ fn a_local_code_with_the_wrong_secret_or_a_typo_doesnt_join() { assert!(host.live().peers.is_empty()); // A typing mistake is caught before anything connects. let mut typo: Vec = local.code().chars().collect(); - typo[7] = if typo[7] == 'A' { 'B' } else { 'A' }; + // The last symbol contains only checksum bits. Changing payload bits can collide with + // the eight-bit checksum, so it does not guarantee a malformed code. + let last = typo.last_mut().unwrap(); + *last = if *last == 'A' { 'B' } else { 'A' }; let e = guest.execute("collab.join", &json!({"code": typo.iter().collect::()})).unwrap_err().to_string(); assert!(e.contains("typing mistake"), "{e}"); // A view-only code doesn't exist here, and says where it does. diff --git a/crates/collab-net/src/sessions_tests.rs b/crates/collab-net/src/sessions_tests.rs index 404244fa..9e76e54e 100644 --- a/crates/collab-net/src/sessions_tests.rs +++ b/crates/collab-net/src/sessions_tests.rs @@ -89,6 +89,9 @@ fn rect(s: &mut Session) -> u64 { s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap() } +/// Asserts two files are hosted and joined independently: each has its own port, session and +/// edits, presence stays in its file's session, and closing one file's session leaves the +/// other live. #[test] fn two_files_are_hosted_and_joined_independently() { let mut host = session("Hana"); @@ -128,6 +131,10 @@ fn two_files_are_hosted_and_joined_independently() { assert_eq!(guest.ds().unwrap().uid, guest.docs[2].uid, "the file that arrived last is in front"); guest.execute("doc.switch", &json!({"index": guest.docs.iter().position(|d| d.uid == gb).unwrap()})).unwrap(); // An edit in A reaches the guest's copy of A only, while B is in front at the guest. + // Concurrent joins may complete in either order. Put B in front explicitly so the + // following assertions exercise A's background session independent of network timing. + guest.execute("doc.switch", &json!({"index": guest.docs.iter().position(|d| d.uid == gb).unwrap()})).unwrap(); + assert_eq!(guest.ds().unwrap().uid, gb, "B is in front while A receives edits"); let in_a = rect(&mut host); host.execute("doc.switch", &json!({"index": 1})).unwrap(); let in_b = rect(&mut host); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 8969f7c4..38b24ea6 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -541,6 +541,7 @@ pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<( s.clipboard.nodes = nodes; s.clipboard.images = images; s.clipboard.bounds = bounds; + s.clipboard.layers_generation = s.clipboard.layers_generation.wrapping_add(1); Ok(()) } @@ -600,6 +601,10 @@ pub fn design_clipboard_buffer(html: &str) -> Option> { figcraft_format::fig::is_fig(&bytes).then_some(bytes) } +/// Paste design layers from clipboard HTML or base64 `data`: decode and reject missing, +/// invalid or oversized data, load a temporary `.fig`, take its top-level page layers, +/// store them as a foreign-file clipboard source with a bumped layers generation, then +/// delegate to `paste` and append import warnings. fn paste_design_layers(s: &mut Session, p: &Value) -> Result { const CMD: &str = "edit.pasteDesignLayers"; let bytes = match (string(p, "html"), string(p, "data")) { @@ -616,8 +621,18 @@ fn paste_design_layers(s: &mut Session, p: &Value) -> Result { let (nodes, images, bounds) = copied(&doc, &tops); let props = s.clipboard.props.take(); // No open file has id 0. So paste treats this copy as a copy from a different file. - s.clipboard = - crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None, copied_components: BTreeSet::new() }; + let layers_generation = s.clipboard.layers_generation.wrapping_add(1); + s.clipboard = crate::Clipboard { + nodes, + images, + props, + bounds, + source_doc: 0, + source: Some(doc), + source_path: None, + copied_components: BTreeSet::new(), + layers_generation, + }; let mut out = paste(s, p)?; if !other.issues.is_empty() { out["warnings"] = json!(other.issues); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 99b0f6f2..60ae4aa9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -329,6 +329,17 @@ pub struct TextEdit { pub anchor: usize, /// The node was created by this edit (deleted if left empty). pub created: bool, + /// Identity of this editing session (`textedit::begin` assigns a fresh one). + /// + /// The IME owner is `(document uid, node)`, which stays the same through ordinary edits + /// and caret moves; this token changes only when editing is started again, so an IME + /// context is never silently inherited by a replaced target that reuses the same node. + /// + /// It is deliberately not serialized: current consumers only inspect a live session. A + /// serialized `TextEdit` restored from data would get zero here, so it must not be restored + /// into a live editor without minting (or otherwise invalidating) a new session. + #[serde(default, skip_serializing)] + pub editing_session: u64, /// An IME composition in progress, shown inline at the caret; the document gets it on commit. #[serde(default, skip_serializing_if = "Option::is_none")] pub preedit: Option, @@ -774,6 +785,10 @@ pub struct Clipboard { pub source_doc: u64, /// Copied component/set roots, excluding cuts and originals discarded by undo (ids are reused). pub copied_components: std::collections::BTreeSet, + /// Monotonic generation of the copied layers (contents, images, bounds and source). A UI + /// clipboard mirror records it, so a later programmatic copy that never touched the system + /// clipboard is not mistaken for the user's own copy just because the text still matches. + pub layers_generation: u64, /// The document copied from (pastes into other files follow its mains) and its file path. pub source: Option>, pub source_path: Option, diff --git a/crates/engine/src/text_behaviour_tests.rs b/crates/engine/src/text_behaviour_tests.rs index ceaad8a7..f15817e8 100644 --- a/crates/engine/src/text_behaviour_tests.rs +++ b/crates/engine/src/text_behaviour_tests.rs @@ -137,6 +137,63 @@ fn ime_composition_with_cjk() { assert!(s.ds().unwrap().text_edit.as_ref().unwrap().preedit.is_none()); } +/// Asserts `delete_surrounding_bytes` removes an exact UTF-8 byte range across CJK and a +/// supplementary emoji, leaves nothing past the start, and the whole edit is one undo step. +#[test] +fn ime_delete_surrounding_bytes_is_exact_and_one_undo_step() { + // 0.37's `DeleteSurroundingBytes` counts UTF-8 bytes around the selection, before the paired + // commit. Multibyte CJK and a supplementary emoji must be removed exactly, not as tofu or + // extended to grapheme clusters, and the whole edit remains one undo step. + let mut s = Session::new(); + let t = text(&mut s, "ab\u{4F60}\u{597D}c\u{1F600}d", json!({"fontSize": 24})); + begin(&mut s, t, Some(4), false).unwrap(); + assert!(crate::textedit::delete_surrounding_bytes(&mut s, 6, 1).unwrap(), "你好 before and c after"); + assert_eq!(chars(&s, t), "ab\u{1F600}d"); + assert_eq!(caret(&s), (2, 2)); + crate::textedit::set_caret(&mut s, 3, false).unwrap(); + assert!(crate::textedit::delete_surrounding_bytes(&mut s, 4, 0).unwrap(), "the emoji is four bytes"); + assert_eq!(chars(&s, t), "abd"); + assert_eq!(caret(&s), (2, 2)); + // Past the start of the text: nothing more to delete, not an error. + assert!(!crate::textedit::delete_surrounding_bytes(&mut s, 0, 0).unwrap()); + crate::textedit::end(&mut s).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(chars(&s, t), "ab\u{4F60}\u{597D}c\u{1F600}d", "one undo restores the whole session"); +} + +/// Asserts a byte range that only partly covers a character leaves it in place, while an +/// exact range deletes it, and a selection survives for the paired commit with its +/// direction preserved. +#[test] +fn ime_delete_surrounding_bytes_keeps_partial_characters_and_selection() { + // The fork deletes the byte range exactly and leaves a character the range only partly + // covers. FigCraft's editor counts characters with the same rule (mirroring egui's + // `surrounding_bytes_to_chars`), including around a selection. + let mut s = Session::new(); + let t = text(&mut s, "\u{e9}x\u{1F600}", json!({"fontSize": 24})); + let del = |s: &mut Session, b: usize, a: usize| crate::textedit::delete_surrounding_bytes(s, b, a).unwrap(); + begin(&mut s, t, Some(1), false).unwrap(); + assert!(!del(&mut s, 1, 0), "one byte ends inside 'é' (two bytes): leave it"); + assert_eq!(chars(&s, t), "\u{e9}x\u{1F600}"); + crate::textedit::set_caret(&mut s, 2, false).unwrap(); + assert!(!del(&mut s, 0, 2), "two bytes start inside the four-byte emoji: leave it"); + assert_eq!(chars(&s, t), "\u{e9}x\u{1F600}"); + assert!(del(&mut s, 0, 4), "its four bytes cover it exactly"); + assert_eq!(chars(&s, t), "\u{e9}x"); + assert_eq!(caret(&s), (2, 2)); + // Complete bytes before the selection go; a character the range only partly covers stays, + // and the selection itself is kept for the paired commit. + crate::textedit::set_caret(&mut s, 1, false).unwrap(); + crate::textedit::set_caret(&mut s, 1, true).unwrap(); + crate::textedit::set_caret(&mut s, 2, true).unwrap(); + assert!(del(&mut s, 2, 2), "é before (two bytes); two bytes start inside the four-byte emoji"); + // The selected 'x' is kept for the paired commit, as in stock `TextEdit`; the caret moves left + // with the deleted prefix and the selection direction is preserved. + assert_eq!(chars(&s, t), "x"); + assert_eq!(caret(&s), (0, 1)); + crate::textedit::end(&mut s).unwrap(); +} + #[test] fn vertical_trim_through_node_set() { let mut s = Session::new(); diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index 47dfdf5b..e236e322 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -1,9 +1,85 @@ //! On-canvas text editing: caret, selection, typing. A whole editing session is one undo step. +use std::sync::{LazyLock, Mutex}; + use figcraft_doc::{Document, ListStyle, NodeId, NodeKind, TextData}; use crate::{EngineError, Preedit, Result, Session, TextEdit}; +/// Most committed UTF-8 bytes an outgoing IME snapshot may describe. +/// +/// This mirrors the fork's `egui::output::MAX_IME_SURROUNDING_TEXT_BYTES`; the engine keeps its +/// own constant so this layer has no egui dependency. The query below returns only this bounded +/// window around the selection, never a copy of the whole document. +pub const MAX_IME_SURROUNDING_BYTES: usize = 4000; + +/// Stable identity of the on-canvas text edit an IME context belongs to. +/// +/// `document` and `node` do not change through ordinary text or caret edits. `editing_session` +/// changes when [`begin`] starts a new session for an equal `(document, node)` pair (leaving and +/// re-entering the same layer), so a replaced target never inherits an old composition. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ImeEditOwner { + /// The document tab's uid. + pub document: u64, + /// The edited text node. + pub node: NodeId, + /// [`TextEdit::editing_session`] of the current edit. + pub editing_session: u64, +} + +/// Why the committed text around the IME caret cannot be described. +/// +/// These are the only reasons this custom editor reports +/// `egui::output::IMESurroundingText::Unavailable`; an empty text layer is available with empty +/// text and a collapsed selection. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ImeSurroundingUnavailable { + /// No text layer is being edited. + NotEditing, + /// The editing target is not a text node any more. + NotText, + /// The selection alone is wider than [`MAX_IME_SURROUNDING_BYTES`], so no bounded snapshot + /// can contain both selection ends. + SelectionTooWide, + /// Defensive: the computed window was not a character-boundary slice. The rounding above + /// makes this unreachable for a well-formed text model. + InvalidWindow, +} + +/// One bounded, committed logical-text observation for the outgoing IME snapshot. +/// +/// `text` is never display/bidi order and never contains the pre-edit (which is UI-only in this +/// editor). `cursor` and `anchor` are UTF-8 byte offsets into `text`; `anchor > cursor` is a +/// valid reversed selection. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ImeSurrounding { + /// Identity of the edit this observation belongs to. + pub owner: ImeEditOwner, + /// At most [`MAX_IME_SURROUNDING_BYTES`] bytes of committed text around the selection. + pub text: String, + /// UTF-8 byte offset of the primary caret in [`Self::text`]. + pub cursor: usize, + /// UTF-8 byte offset of the selection anchor in [`Self::text`]. + pub anchor: usize, +} + +/// Every editing session gets its own token; zero is valid and never reused deliberately. +/// +/// A mutex (rather than an atomic) also works on wasm32 targets without 64-bit atomics. +static EDITING_SESSION: LazyLock> = LazyLock::new(|| Mutex::new(0)); + +/// Return the next editing-session token from a mutex-protected counter (`wrapping_add`). +/// Zero is a valid token; the mutex also works on wasm32 without 64-bit atomics. +fn next_editing_session() -> u64 { + let mut session = match EDITING_SESSION.lock() { + Ok(session) => session, + Err(poisoned) => poisoned.into_inner(), + }; + *session = session.wrapping_add(1); + *session +} + fn text_of(s: &Session, id: NodeId) -> Result { match &s.doc()?.get(id)?.kind { NodeKind::Text(t) => Ok(t.clone()), @@ -23,7 +99,7 @@ pub fn begin(s: &mut Session, id: NodeId, caret: Option, created: bool) - Some(c) => (c.min(n), c.min(n)), None => (n, 0), }; - ds.text_edit = Some(TextEdit { id, caret, anchor, created, preedit: None }); + ds.text_edit = Some(TextEdit { id, caret, anchor, created, editing_session: next_editing_session(), preedit: None }); ds.selection = vec![id]; ds.revision += 1; s.live_text_notice(id); @@ -69,14 +145,33 @@ fn range(te: &TextEdit) -> (usize, usize) { (te.caret.min(te.anchor), te.caret.max(te.anchor)) } +/// Apply an unconditional text-model mutation through `edit_if`, whose success clears the +/// composition, clamps caret and anchor to the character count, marks a `characters` override +/// when the node is source-backed, normalizes the document and bumps the revision. fn edit(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit)) -> Result<()> { + edit_if(s, |t, te| { + f(t, te); + true + }) + .map(|_| ()) +} + +/// [`edit`] for a mutation that can still turn out to be a no-op. +/// +/// The closure returns whether it changed the model; return `false` only before mutating (the +/// text is handed over directly). A no-op leaves the composition (`preedit`), the revision and the +/// document untouched, so an IME deletion that finds nothing to delete does not cancel an +/// in-progress composition. +fn edit_if(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit) -> bool) -> Result { let ds = s.ds_mut()?; let Some(mut te) = ds.text_edit.clone() else { return Err(EngineError::Other("not editing text".into())) }; let before = ds.doc.clone(); let doc = ds.doc_mut(); let n = doc.get_mut(te.id)?; let NodeKind::Text(t) = &mut n.kind else { return Err(EngineError::Other("not a text layer".into())) }; - f(t, &mut te); + if !f(t, &mut te) { + return Ok(false); + } // Any edit ends a composition (a commit replaces it with the final text). te.preedit = None; let len = t.char_count(); @@ -88,16 +183,83 @@ fn edit(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit)) -> Result crate::normalize(doc, &before); ds.text_edit = Some(te); ds.revision += 1; - Ok(()) + Ok(true) +} + +/// What an accepted IME `Commit` did to the committed model. +/// +/// An IME receipt may label the snapshot `InputMethod` only for [`Self::Plain`]. AutoFormat +/// (typing "- " at a line start turns the paragraph into a list) is an application +/// transformation in the same command, so the frame's change must be reported as `Other`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ImeInsertion { + /// Nothing was inserted (an empty, control-only, or bare-newline commit). + Nothing, + /// The committed text replaced the selection exactly. + Plain, + /// The editor transformed the commit before applying it: paragraph AutoFormat, or line-ending + /// normalisation/control-character removal. The frame's change is application-origin. + Transformed, } /// Type (or paste) `text` replacing the selection. Line endings are normalised and other control /// characters dropped, so pasted plain text from any platform lands cleanly. pub fn insert(s: &mut Session, text: &str) -> Result<()> { - let text: String = crate::cmd::props::normalize_line_endings(text).chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).collect(); - if text.is_empty() { + insert_model(s, text, false).map(|_| ()) +} + +/// End the composition without changing the document, for an empty IME commit. +/// +/// Returns without touching the session when no composition is active (or nothing is being +/// edited): an empty commit is otherwise a no-op, never an error. +fn clear_composition(s: &mut Session) -> Result<()> { + let composing = s.ds().ok().and_then(|d| d.text_edit.as_ref()).is_some_and(|te| te.preedit.is_some()); + if !composing { return Ok(()); } + let ds = s.ds_mut()?; + if let Some(te) = ds.text_edit.as_mut() { + te.preedit = None; + } + ds.revision += 1; + Ok(()) +} + +/// Apply an IME `Commit`, reporting whether it was a plain insertion. +/// +/// The UI calls this instead of [`insert`] so only a commit that the editor applied as a plain +/// insertion can earn an `InputMethod` receipt. AutoFormat, line-ending normalisation and +/// control-character removal are application transformations and are reported as +/// [`ImeInsertion::Transformed`]. Like stock `TextEdit`, a bare `"\n"`/`"\r"` commit is ignored. +pub fn insert_ime_commit(s: &mut Session, text: &str) -> Result { + insert_model(s, text, true) +} + +/// Insert committed text, optionally as an IME commit: normalize line endings, drop control +/// characters except `\n`/`\t`, ignore a bare newline/CR IME commit, clear an active +/// composition for an empty or control-only IME commit, and turn a committed space after a +/// line-start `-`, `*` or `1.` into a bulleted/numbered list. Reports whether the insertion was +/// plain, transformed, or a no-op. +fn insert_model(s: &mut Session, text: &str, ime_commit: bool) -> Result { + // Stock `TextEdit` deliberately ignores these two commits (they are not a mutation); only a + // real commit may do so, since typing Enter goes through `insert`. + if ime_commit && matches!(text, "\n" | "\r") { + return Ok(ImeInsertion::Nothing); + } + let normalized: String = + crate::cmd::props::normalize_line_endings(text).chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).collect(); + if normalized.is_empty() { + // An empty commit is how some integrations (Safari's egui web integration when the last + // composition character is deleted) end the composition. It inserts nothing, but it must + // still clear the pre-edit; stock `TextEdit` does the same in its non-ignored `Commit` arm. + if ime_commit { + clear_composition(s)?; + } + return Ok(ImeInsertion::Nothing); + } + // A commit the editor had to transform is an application edit, not a plain insertion. + let mut outcome = if normalized == text { ImeInsertion::Plain } else { ImeInsertion::Transformed }; + let text = normalized; edit(s, |t, te| { let (a, b) = range(te); // "- ", "* " or "1. " typed at the start of a plain paragraph starts a list. @@ -114,13 +276,15 @@ pub fn insert(s: &mut Session, text: &str) -> Result<()> { t.replace_range(start, a, ""); t.set_para_lists(start, start, |pl| pl.list = list); (te.caret, te.anchor) = (start, start); + outcome = ImeInsertion::Transformed; return; } } t.replace_range(a, b, &text); te.caret = a + text.chars().count(); te.anchor = te.caret; - }) + })?; + Ok(outcome) } /// List keys: Tab nests the selected list items a level deeper; Shift+Tab, Backspace at an item's @@ -345,6 +509,160 @@ fn select(s: &mut Session, anchor: usize, caret: usize) -> Result<()> { Ok(()) } +/// Delete the text surrounding the caret/selection that an IME reports with +/// [`egui::ImeEvent::DeleteSurroundingBytes`]: `before_bytes` UTF-8 bytes end where the selection +/// starts and `after_bytes` start where it ends. As in stock `TextEdit`, the selected text itself +/// is kept for the paired `Commit`; only whole characters covered by the byte counts beyond the +/// selection are removed (a character the range only partly covers stays). Returns whether +/// anything was deleted. +pub fn delete_surrounding_bytes(s: &mut Session, before_bytes: usize, after_bytes: usize) -> Result { + let Some(te) = s.ds()?.text_edit.clone() else { return Ok(false) }; + let t = text_of(s, te.id)?; + let (a, b) = range(&te); + let (before, after) = surrounding_chars(&t.characters, a, b, before_bytes, after_bytes); + if before == 0 && after == 0 { + return Ok(false); + } + edit_if(s, |t, te| { + let (a, b) = range(te); + let (before, after) = surrounding_chars(&t.characters, a, b, before_bytes, after_bytes); + if before == 0 && after == 0 { + return false; + } + // Delete after the selection first so the before range's indices stay valid. + if after > 0 { + t.replace_range(b, b + after, ""); + } + if before > 0 { + t.replace_range(a - before, a, ""); + } + // Keep the selection: both ends move left by the deleted prefix, reversed or not. + te.caret = te.caret.saturating_sub(before); + te.anchor = te.anchor.saturating_sub(before); + true + }) +} + +/// Delete surrounding text counted in characters (the older `DeleteSurrounding` event). The +/// selection is kept, as in stock `TextEdit`; only text before and after it is removed. +pub fn delete_surrounding_chars(s: &mut Session, before_chars: usize, after_chars: usize) -> Result { + let Some(te) = s.ds()?.text_edit.clone() else { return Ok(false) }; + let t = text_of(s, te.id)?; + let (a, b) = range(&te); + let (_, b_byte) = char_byte_offsets(&t.characters, a, b); + let before = before_chars.min(a); + let after = t.characters.get(b_byte..).map_or(0, |rest| rest.chars().take(after_chars).count()); + if before == 0 && after == 0 { + return Ok(false); + } + edit_if(s, |t, te| { + let (a, b) = range(te); + let (_, b_byte) = char_byte_offsets(&t.characters, a, b); + let before = before_chars.min(a); + let after = t.characters.get(b_byte..).map_or(0, |rest| rest.chars().take(after_chars).count()); + if before == 0 && after == 0 { + return false; + } + if after > 0 { + t.replace_range(b, b + after, ""); + } + if before > 0 { + t.replace_range(a - before, a, ""); + } + te.caret = te.caret.saturating_sub(before); + te.anchor = te.anchor.saturating_sub(before); + true + }) +} + +/// The UTF-8 byte offsets of character indices `a` and `b` in one forward pass. +/// +/// An index at or past the end maps to `text.len()`. Walking once (instead of twice) matters on +/// every IME event over a large text layer; the walk is still O(max(a, b)) characters, and callers +/// that need the end still pay O(n). See [`ime_surrounding`]. +fn char_byte_offsets(text: &str, a: usize, b: usize) -> (usize, usize) { + let (mut a_byte, mut b_byte) = (None, None); + for (i, (byte, _)) in text.char_indices().enumerate() { + if a_byte.is_none() && i == a { + a_byte = Some(byte); + } + if b_byte.is_none() && i == b { + b_byte = Some(byte); + } + if a_byte.is_some() && b_byte.is_some() { + break; + } + } + (a_byte.unwrap_or(text.len()), b_byte.unwrap_or(text.len())) +} + +/// `(before, after)` characters covered by the IME's byte counts around the selection `[a, b)` +/// (character indices): mirror of egui's own `surrounding_bytes_to_chars`. +fn surrounding_chars(text: &str, a: usize, b: usize, before_bytes: usize, after_bytes: usize) -> (usize, usize) { + let (a_byte, b_byte) = char_byte_offsets(text, a, b); + let from = a_byte.saturating_sub(before_bytes); + let before = text[..a_byte].char_indices().rev().take_while(|(i, _)| *i >= from).count(); + let after = text[b_byte..].char_indices().take_while(|(i, c)| i + c.len_utf8() <= after_bytes).count(); + (before, after) +} + +/// The committed text around the IME caret for the outgoing surrounding-text snapshot. +/// +/// This is the edited node's logical model text (never the bidi display order), with the +/// pre-edit excluded because a composition is UI-only in this editor until it is committed. +/// Character caret/anchor indices become independent UTF-8 byte offsets, so a reversed +/// selection stays reversed. At most [`MAX_IME_SURROUNDING_BYTES`] bytes are copied: the window +/// is centred on the selection and rounded inward to character boundaries. An empty text layer +/// is `Ok` with `text == ""` and both offsets zero; the result is [`ImeSurroundingUnavailable`] +/// only when there is no text edit, the target is not a text node, or the selection alone is +/// wider than the budget. +/// +/// Cost: one character walk up to the later of the two cursor indices (to turn char indices into +/// UTF-8 bytes) plus the bounded copy. The caret is a character index, so reaching a caret near +/// the end of a very large layer is still O(n); the UI calls this once per pass and twice around +/// each text-affecting event (`ime_stamp`), so such a layer pays O(caret/anchor) per event. The +/// document is never cloned here. +pub fn ime_surrounding(s: &Session) -> std::result::Result { + let ds = s.ds().map_err(|_| ImeSurroundingUnavailable::NotEditing)?; + let te = ds.text_edit.as_ref().ok_or(ImeSurroundingUnavailable::NotEditing)?; + let node = ds.doc.node(te.id).ok_or(ImeSurroundingUnavailable::NotText)?; + let NodeKind::Text(t) = &node.kind else { return Err(ImeSurroundingUnavailable::NotText) }; + let text = t.characters.as_str(); + // One pass for both offsets (the caret and anchor are already clamped by `edit`/`select`). + let (cursor, anchor) = char_byte_offsets(text, te.caret, te.anchor); + let (selection_start, selection_end) = (cursor.min(anchor), cursor.max(anchor)); + let selection_len = selection_end - selection_start; + if selection_len > MAX_IME_SURROUNDING_BYTES { + return Err(ImeSurroundingUnavailable::SelectionTooWide); + } + // Spend the budget on both sides, giving room unused on one side to the other. + let budget = MAX_IME_SURROUNDING_BYTES - selection_len; + let before_available = selection_start; + let after_available = text.len() - selection_end; + let mut before = (budget / 2).min(before_available); + let mut after = (budget - before).min(after_available); + before = (budget - after).min(before_available); + after = (budget - before).min(after_available); + let mut window_start = selection_start - before; + let mut window_end = selection_end + after; + // Round inward so the window stays within the byte budget and on character boundaries. + while window_start < window_end && !text.is_char_boundary(window_start) { + window_start += 1; + } + while window_end > window_start && !text.is_char_boundary(window_end) { + window_end -= 1; + } + let Some(window) = text.get(window_start..window_end) else { + return Err(ImeSurroundingUnavailable::InvalidWindow); + }; + Ok(ImeSurrounding { + owner: ImeEditOwner { document: ds.uid, node: te.id, editing_session: te.editing_session }, + text: window.to_owned(), + cursor: cursor.saturating_sub(window_start), + anchor: anchor.saturating_sub(window_start), + }) +} + /// Show an IME composition inline at the caret (`cursor` = the IME's cursor inside it, in chars; /// default its end). The document doesn't change until the composition is committed with /// [`insert`]; an empty `text` cancels it. Starting to compose over a selection deletes it, as @@ -596,6 +914,9 @@ mod tests { 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"); @@ -612,10 +933,16 @@ mod tests { 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)); @@ -827,4 +1154,203 @@ mod tests { } } } + + /// 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); + } } diff --git a/crates/engine/src/textfit_tests.rs b/crates/engine/src/textfit_tests.rs index d9ffd5fd..e35f810c 100644 --- a/crates/engine/src/textfit_tests.rs +++ b/crates/engine/src/textfit_tests.rs @@ -8,6 +8,7 @@ use serde_json::json; /// A fixed-width (auto-height) headline of `width` px at 64px. Returns its id. fn headline(s: &mut Session, text: &str, width: f64) -> NodeId { + figcraft_text::scan_system_fonts(); let t = idv(&ex(s, "text.create", json!({"text": text, "x": 0, "y": 0, "props": {"name": "Headline", "fontSize": 64}}))); ex(s, "node.set", json!({"id": t, "props": {"width": width, "textAutoResize": "height"}})); t @@ -63,8 +64,11 @@ fn a_one_word_last_line_is_avoided() { assert!(l.lines.len() == 1 || words.split_whitespace().count() > 1 || words.contains('\u{a0}'), "{words:?}"); } +/// Asserts `data.fill` keeps a short copy's font size, shrinks a long copy, and leaves the +/// source text layer untouched. #[test] fn data_fill_sizes_each_copys_text() { + figcraft_text::scan_system_fonts(); let mut s = Session::new(); let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 1080, "height": 1350, "name": "Post"}))); let t = idv(&ex(&mut s, "text.create", json!({"text": "Curto", "x": 80, "y": 80, "parent": f, "props": {"name": "Headline", "fontSize": 96}}))); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index cc52caec..b6a6d101 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -2020,6 +2020,10 @@ impl Session { 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. fn double_click(&mut self, ev: &PointerEvent) -> Result<()> { let ds = self.ds()?; let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); @@ -2069,13 +2073,15 @@ impl Session { && 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. + // 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())?; - textedit::begin(self, target, Some(idx), false)?; if editing { textedit::select_word(self, idx)?; } else { + textedit::begin(self, target, Some(idx), false)?; textedit::key(self, "SelectAll", false, false, false)?; } return Ok(()); diff --git a/crates/text/build.rs b/crates/text/build.rs index 05dfc653..ba087983 100644 --- a/crates/text/build.rs +++ b/crates/text/build.rs @@ -1,32 +1,26 @@ //! Embeds the optional craft-fonts build input (`CRAFT_FONTS_DIR`): both `fonts/manifest.txt` and the //! opt-in Latin presentation fonts in `fonts/latin-manifest.txt` (Inter, Roboto, …). Unset or missing, -//! `CRAFT_FONTS` is empty and the app uses system fonts. Recipe: craft-fonts `docs/integration.md`. +//! `CRAFT_FONTS` is empty and native apps use system fonts. Required web releases enforce Inter +//! Regular/SemiBold and Noto Sans Georgian Regular after filtering. Recipe: craft-fonts `docs/integration.md`. //! Noto Sans CJK only with `FIGCRAFT_EMBED_CJK` set (the web build's only way to show CJK UI text). use std::fmt::Write as _; use std::path::PathBuf; +/// Font build script: emit `craft_fonts.rs` from `input_fonts`, passing the +/// `CRAFT_FONTS_DIR`, `CRAFT_FONTS_REQUIRED`, wasm target and `FIGCRAFT_EMBED_CJK` inputs; +/// report generation and write failures with `cargo::error`. fn main() { println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR"); println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED"); println!("cargo::rerun-if-env-changed=FIGCRAFT_EMBED_CJK"); let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n"); - if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) { - let mut ok = false; - for manifest in ["fonts/latin-manifest.txt", "fonts/manifest.txt"] { - match craft_fonts(&dir, manifest) { - Ok(entries) => { - ok = true; - src.push_str(&entries); - } - Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => { - println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display()); - } - Err(e) => println!("cargo::warning=craft-fonts {manifest} skipped: CRAFT_FONTS_DIR={}: {e}", dir.display()), - } - } - if !ok { - println!("cargo::warning=building without craft-fonts (once it is there, `cargo clean -p figcraft-text` makes the next build use it)"); - } + let dir = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from); + let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some(); + let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32"); + let embed_cjk = std::env::var_os("FIGCRAFT_EMBED_CJK").is_some(); + match input_fonts(dir.as_deref(), wasm, required, embed_cjk) { + Ok(entries) => src.push_str(&entries), + Err(e) => println!("cargo::error={e}"), } src.push_str("];\n"); let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs"); @@ -35,13 +29,45 @@ fn main() { } } +/// Collect the effective catalog and enforce the required web release faces after filtering. +fn input_fonts(dir: Option<&std::path::Path>, wasm: bool, required: bool, embed_cjk: bool) -> Result { + let Some(dir) = dir else { + return if required { Err("CRAFT_FONTS_REQUIRED is set but CRAFT_FONTS_DIR is missing".into()) } else { Ok(String::new()) }; + }; + let mut out = String::new(); + let mut faces = Vec::new(); + for manifest in ["fonts/latin-manifest.txt", "fonts/manifest.txt"] { + let path = dir.join(manifest); + if manifest == "fonts/latin-manifest.txt" && !path.exists() { + watch(&path); + continue; + } + match craft_fonts(dir, manifest, wasm, embed_cjk) { + Ok(catalog) => { + out.push_str(&catalog.entries); + faces.extend(catalog.faces); + } + Err(e) if required => return Err(format!("CRAFT_FONTS_DIR={}: {e}", dir.display())), + Err(e) => println!("cargo::warning=craft-fonts {manifest} skipped: CRAFT_FONTS_DIR={}: {e}", dir.display()), + } + } + if required && wasm { + for (family, style) in [("Inter", "Regular"), ("Inter", "SemiBold"), ("Noto Sans Georgian", "Regular")] { + if !faces.iter().any(|(f, s)| f == family && s == style) { + return Err(format!("required web font missing: {family} {style}; CRAFT_FONTS_DIR must include both Latin and core release faces")); + } + } + } + Ok(out) +} + /// Rebuild when `manifest` changes or, while it is missing, when it is added to its folder. /// /// Cargo takes a watched path that doesn't exist as changed on every build, and then rebuilds this /// crate and every crate that depends on it each time. So a missing manifest is never watched /// itself: its folder is, when there is one (a craft-fonts checkout without the Latin manifest). /// With no craft-fonts at all there is nothing to watch, so the build can't notice it arriving: -/// the warning in `main` says how to pick it up. +/// rebuild this crate after adding a previously absent checkout. fn watch(manifest: &std::path::Path) { let watched = if manifest.exists() { Some(manifest) } else { manifest.parent().filter(|folder| folder.is_dir()) }; if let Some(path) = watched { @@ -49,20 +75,33 @@ fn watch(manifest: &std::path::Path) { } } -fn craft_fonts(dir: &std::path::Path, manifest_rel: &str) -> Result { +struct Catalog { + entries: String, + faces: Vec<(String, String)>, +} + +/// Parse a font manifest of `family | style | file | scripts` rows into `CraftFont` +/// entries: skip Adobe-authored families, skip CJK unless requested, keep the web catalog +/// to its budgeted families, canonicalize and watch each face, and emit `include_bytes!` +/// items. +fn craft_fonts(dir: &std::path::Path, manifest_rel: &str, wasm: bool, embed_cjk: bool) -> Result { let manifest = dir.join(manifest_rel); watch(&manifest); let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?; - let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32"); let mut out = String::new(); + let mut faces = Vec::new(); for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) { let f: Vec<&str> = line.split(" | ").map(str::trim).collect(); let [family, style, file, scripts, ..] = f.as_slice() else { return Err(format!("malformed manifest line: {line}")); }; + // FigCraft's asset policy excludes Adobe-authored visual assets on every target. + if matches!(*family, "Source Sans 3" | "Source Serif 4") { + continue; + } // CJK fonts are large; the desktop app finds system CJK fonts instead unless asked. let cjk = scripts.contains("Hans"); - if cjk && std::env::var_os("FIGCRAFT_EMBED_CJK").is_none() { + if cjk && !embed_cjk { continue; } // Web builds have a size budget: Inter, Noto Sans Georgian (small, for the Georgian UI), only the @@ -73,7 +112,109 @@ fn craft_fonts(dir: &std::path::Path, manifest_rel: &str) -> Result Self { + let dir = std::env::temp_dir().join(format!("fig-font-input-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir_all(dir.join("fonts")).unwrap(); + // The build script validates manifest/file presence; font bytes are checked centrally. + std::fs::write(dir.join("fonts/face.ttf"), b"synthetic build-input fixture").unwrap(); + let fixture = Self(dir); + fixture.manifest("manifest.txt", &[("Noto Sans Georgian", "Regular")]); + fixture + } + + /// Test fixture: write manifest rows that all point at the fixture's `fonts/face.ttf` face. + fn manifest(&self, name: &str, faces: &[(&str, &str)]) { + let text = faces.iter().map(|(family, style)| format!("{family} | {style} | fonts/face.ttf | Latn\n")).collect::(); + std::fs::write(self.0.join("fonts").join(name), text).unwrap(); + } + } + + impl Drop for Fixture { + /// Remove the fixture's temporary directory, ignoring errors. + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + /// Asserts the native Latin catalog keeps its 43 permitted presentation faces, the web + /// catalog keeps only the four Inter faces, Adobe-authored families are absent, and + /// permitted native faces remain. + #[test] + fn restored_latin_catalog_preserves_permitted_native_faces_and_web_budget() { + let f = Fixture::new(); + std::fs::write(f.0.join("fonts/latin-manifest.txt"), include_str!("tests/fixtures/latin-families.txt")).unwrap(); + let native = craft_fonts(&f.0, "fonts/latin-manifest.txt", false, false).unwrap(); + assert_eq!(native.faces.len(), 43, "all prior permitted presentation faces remain available"); + let web = craft_fonts(&f.0, "fonts/latin-manifest.txt", true, false).unwrap(); + assert_eq!(web.faces.len(), 4, "the web catalog keeps only the four Inter faces"); + for catalog in [&native, &web] { + assert!(!catalog.entries.contains("Source Sans 3")); + assert!(!catalog.entries.contains("Source Serif 4")); + } + assert!(native.faces.iter().any(|(family, _)| family == "Carlito")); + assert!(native.faces.iter().any(|(family, _)| family == "Liberation Serif")); + assert!(input_fonts(Some(&f.0), true, true, false).is_ok()); + } + + /// Asserts required web input rejects a catalog missing Inter Regular or Inter SemiBold. + #[test] + fn required_web_rejects_core_only_and_partial_latin_catalogs() { + let f = Fixture::new(); + assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("Inter Regular")); + f.manifest("latin-manifest.txt", &[("Inter", "Regular"), ("Unrelated", "SemiBold")]); + assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("Inter SemiBold")); + } + + /// Asserts required web input embeds the Inter and Georgian catalogs (three entries) and + /// rejects a manifest without Noto Sans Georgian Regular. + #[test] + fn required_web_embeds_both_catalogs_and_requires_georgian() { + let f = Fixture::new(); + f.manifest("latin-manifest.txt", &[("Inter", "Regular"), ("Inter", "SemiBold")]); + let entries = input_fonts(Some(&f.0), true, true, false).unwrap(); + assert_eq!(entries.matches("CraftFont {").count(), 3); + assert!(entries.contains("family: \"Inter\"")); + assert!(entries.contains("family: \"Noto Sans Georgian\"")); + f.manifest("manifest.txt", &[("Other", "Regular")]); + assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("Noto Sans Georgian Regular")); + } + + /// Asserts absent font input is empty when optional and an error when required, while present + /// input includes Noto Sans Georgian. + #[test] + fn optional_and_native_inputs_keep_their_existing_policy() { + let f = Fixture::new(); + assert!(input_fonts(None, true, false, false).unwrap().is_empty()); + assert!(input_fonts(None, true, true, false).unwrap_err().contains("CRAFT_FONTS_DIR is missing")); + assert!(input_fonts(Some(&f.0), true, false, false).unwrap().contains("Noto Sans Georgian")); + assert!(input_fonts(Some(&f.0), false, true, false).unwrap().contains("Noto Sans Georgian")); + } + + /// Asserts required web input rejects a manifest whose face file is missing, even when every + /// face row is listed. + #[test] + fn required_web_rejects_missing_asset_even_with_all_face_rows() { + let f = Fixture::new(); + f.manifest("latin-manifest.txt", &[("Inter", "Regular"), ("Inter", "SemiBold")]); + std::fs::remove_file(f.0.join("fonts/face.ttf")).unwrap(); + assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("fonts/face.ttf")); + } } diff --git a/crates/text/tests/build_input.rs b/crates/text/tests/build_input.rs new file mode 100644 index 00000000..6f87ac1f --- /dev/null +++ b/crates/text/tests/build_input.rs @@ -0,0 +1,4 @@ +//! Exercise the actual build-script catalog policy without changing process environment. +#[allow(dead_code)] +#[path = "../build.rs"] +mod build_script; diff --git a/crates/text/tests/fixtures/latin-families.txt b/crates/text/tests/fixtures/latin-families.txt new file mode 100644 index 00000000..e9db6f41 --- /dev/null +++ b/crates/text/tests/fixtures/latin-families.txt @@ -0,0 +1,52 @@ +# Synthetic paths; family/style inventory from craft-fonts 76ba5a0496503c0e2841abd626295038cbe07cde. +Inter | Regular | fonts/face.ttf | Latn +Inter | Italic | fonts/face.ttf | Latn +Inter | SemiBold | fonts/face.ttf | Latn +Inter | Bold | fonts/face.ttf | Latn +Carlito | Regular | fonts/face.ttf | Latn +Carlito | Bold | fonts/face.ttf | Latn +Carlito | Italic | fonts/face.ttf | Latn +Carlito | Bold Italic | fonts/face.ttf | Latn +Caladea | Regular | fonts/face.ttf | Latn +Caladea | Bold | fonts/face.ttf | Latn +Caladea | Italic | fonts/face.ttf | Latn +Caladea | Bold Italic | fonts/face.ttf | Latn +Liberation Sans | Regular | fonts/face.ttf | Latn +Liberation Sans | Bold | fonts/face.ttf | Latn +Liberation Sans | Italic | fonts/face.ttf | Latn +Liberation Sans | Bold Italic | fonts/face.ttf | Latn +Liberation Serif | Regular | fonts/face.ttf | Latn +Liberation Serif | Bold | fonts/face.ttf | Latn +Liberation Serif | Italic | fonts/face.ttf | Latn +Liberation Serif | Bold Italic | fonts/face.ttf | Latn +Liberation Mono | Regular | fonts/face.ttf | Latn +Liberation Mono | Bold | fonts/face.ttf | Latn +Liberation Mono | Italic | fonts/face.ttf | Latn +Liberation Mono | Bold Italic | fonts/face.ttf | Latn +Source Sans 3 | Regular | fonts/face.ttf | Latn +Source Sans 3 | Italic | fonts/face.ttf | Latn +Source Sans 3 | Semibold | fonts/face.ttf | Latn +Source Sans 3 | Bold | fonts/face.ttf | Latn +Source Serif 4 | Regular | fonts/face.ttf | Latn +Source Serif 4 | Italic | fonts/face.ttf | Latn +Source Serif 4 | Semibold | fonts/face.ttf | Latn +Source Serif 4 | Bold | fonts/face.ttf | Latn +Montserrat | Variable | fonts/face.ttf | Latn +Montserrat | Italic Variable | fonts/face.ttf | Latn +Lato | Regular | fonts/face.ttf | Latn +Lato | Bold | fonts/face.ttf | Latn +Lato | Italic | fonts/face.ttf | Latn +Open Sans | Variable | fonts/face.ttf | Latn +Open Sans | Italic Variable | fonts/face.ttf | Latn +Roboto | Variable | fonts/face.ttf | Latn +Roboto | Italic Variable | fonts/face.ttf | Latn +Merriweather | Regular | fonts/face.ttf | Latn +Merriweather | Bold | fonts/face.ttf | Latn +Merriweather | Italic | fonts/face.ttf | Latn +Playfair Display | Variable | fonts/face.ttf | Latn +Playfair Display | Italic Variable | fonts/face.ttf | Latn +Poppins | Regular | fonts/face.ttf | Latn +Poppins | Bold | fonts/face.ttf | Latn +Poppins | Italic | fonts/face.ttf | Latn +Nunito Sans | Variable | fonts/face.ttf | Latn +Nunito Sans | Italic Variable | fonts/face.ttf | Latn diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 09cd2226..d72b1416 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -18,7 +18,7 @@ figcraft-render = { workspace = true } figcraft-pathops = { workspace = true } figcraft-engine = { workspace = true } figcraft-format = { workspace = true } -egui = { workspace = true } +egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } @@ -34,3 +34,6 @@ toml = "1" [dev-dependencies] wat = { workspace = true } +# Keep the test font features explicit; production uses the same bundled fallback faces. +# Dependency dev-features do not propagate into app release builds. +egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } diff --git a/crates/ui-egui/src/a11y.rs b/crates/ui-egui/src/a11y.rs index 5fc098a4..3c5fafb6 100644 --- a/crates/ui-egui/src/a11y.rs +++ b/crates/ui-egui/src/a11y.rs @@ -239,6 +239,10 @@ pub fn scroll_focus(nav: &mut Nav, ui: &Ui) { #[derive(Clone, Default)] struct Shared { canvas: Option, + /// The root `Ui::scope_id` of the frame. A resizable panel's real `__resize` widget is + /// `root_scope.with().with("__resize")`; FigCraft's panels are fixed, so its own + /// `panel_edge` and `pages_edge` IDs are the ones to exclude today. + root_scope: Option, keyboard: bool, /// The focused widget showed its focus itself this frame (no ring needed). ring: Option, @@ -249,8 +253,10 @@ struct Shared { name: String, } +/// Run `f` on the per-context temporary `Shared` accessibility state keyed by +/// `figcraft_a11y`. fn shared(ctx: &Context, f: impl FnOnce(&mut Shared) -> R) -> R { - ctx.data_mut(|d| f(d.get_temp_mut_or_default::(Id::new("figcraft_a11y")))) + ctx.data_mut(|d| f(d.get_temp_mut_or_default::(Id::unique("figcraft_a11y")))) } /// Whether a widget other than the canvas has the keyboard (a field, a focused button): the @@ -284,8 +290,10 @@ pub fn keyboard(ctx: &Context) -> bool { /// Before the panels draw: pointer presses, the focused control's region, and the navigation /// keys (F6, Tab, arrows, Escape), which are taken out of the input when they move the keyboard. -pub fn begin(app: &mut FigApp, ctx: &Context) { +pub fn begin(app: &mut FigApp, ctx: &Context, root_scope: Id) { app.ui.nav.last = std::mem::take(&mut app.ui.nav.next); + // Before the keys: a panel edge derived from the root scope must already be excluded. + shared(ctx, |s| s.root_scope = Some(root_scope)); keys(app, ctx); let keyboard = app.ui.nav.keyboard; shared(ctx, |s| { @@ -646,7 +654,13 @@ fn step_within(app: &mut FigApp, ctx: &Context, region: Region, step: i32) { /// them, are left out). fn focusables(ctx: &Context, nav: &Nav, region: Region) -> Vec<(Id, Rect)> { let canvas = nav.last.canvas; - let edges = [Id::new("left_panel").with("__resize"), Id::new("right_panel").with("__resize")]; + // Both the real resize widgets (should a panel ever become resizable) and FigCraft's own + // custom panel and pages-list edges, which must never enter the F6/Tab order. + let mut edges = vec![Id::unique(("panel_edge", "left")), Id::unique(("panel_edge", "right")), Id::unique("pages_edge")]; + if let Some(root) = shared(ctx, |s| s.root_scope) { + edges.push(root.with("left_panel").with("__resize")); + edges.push(root.with("right_panel").with("__resize")); + } ctx.viewport(|v| { v.prev_pass .widgets diff --git a/crates/ui-egui/src/a11y_tests.rs b/crates/ui-egui/src/a11y_tests.rs index d041a5b3..14be637d 100644 --- a/crates/ui-egui/src/a11y_tests.rs +++ b/crates/ui-egui/src/a11y_tests.rs @@ -403,3 +403,31 @@ fn the_align_buttons_are_off_for_a_layer_alone_on_the_page() { u.app.run("select.set", json!({"ids": [inner]})); assert!(!align_left(&tree(&mut u)), "one in a frame aligns to it"); } + +/// Asserts side-panel and Pages edges are not focusable, and Tab never stops on the +/// side-panel edges. +#[test] +fn the_custom_panel_edges_never_enter_the_keyboard_or_focus_order() { + // FigCraft's panels are fixed, so egui makes no `panel.__resize` widgets; the draggable edges + // are `Id::unique(("panel_edge", ...))`. They sense the pointer but must not be focusable, or + // Tab/F6 would stop on an unnamed control. + let (mut u, _, _) = setup(); + let (left, right) = (egui::Id::unique(("panel_edge", "left")), egui::Id::unique(("panel_edge", "right"))); + // The Pages list's edge has the same contract as the side panels' edges. + let pages = egui::Id::unique("pages_edge"); + for f in [left, right, pages] { + let focusable = u.ctx.viewport(|v| v.prev_pass.widgets.get(f).map(|w| w.sense.is_focusable())); + assert_eq!(focusable, Some(false), "the panel/pages edge still senses clicks/drags but is not focusable"); + } + let mut seen = Vec::new(); + for back in [false, true] { + f6(&mut u, back); + for _ in 0..30 { + press(&mut u, Key::Tab, Modifiers::NONE); + if let Some(id) = u.ctx.memory(|m| m.focused()) { + seen.push(id); + } + } + } + assert!(!seen.contains(&left) && !seen.contains(&right), "Tab never stops on a panel edge: {seen:?}"); +} diff --git a/crates/ui-egui/src/assets_panel.rs b/crates/ui-egui/src/assets_panel.rs index 3f1fcc77..db0cc0f7 100644 --- a/crates/ui-egui/src/assets_panel.rs +++ b/crates/ui-egui/src/assets_panel.rs @@ -164,8 +164,9 @@ enum Act { Hide(NodeId, bool), } +/// Stable egui id for the assets drag payload. fn drag_id() -> Id { - Id::new("asset_drag") + Id::unique("asset_drag") } /// Whether component `id` has the same layers in two versions of a document (layers an edit @@ -276,7 +277,7 @@ fn refresh_libraries(app: &mut FigApp, ui: &Ui) { return; } let paths: Vec = doc.libraries.enabled.iter().map(|l| l.path.clone()).collect(); - let id = Id::new("asset_libraries_checked"); + let id = Id::unique("asset_libraries_checked"); let now = ui.input(|i| i.time); let last: Option = ui.data(|d| d.get_temp(id)); if last.is_none_or(|l| now - l >= LIBRARY_REFRESH) { @@ -285,7 +286,7 @@ fn refresh_libraries(app: &mut FigApp, ui: &Ui) { } ui.data_mut(|d| d.insert_temp(id, now)); // Team libraries: in the background, at once for one not fetched yet. - let sid = Id::new("asset_server_libraries_checked"); + let sid = Id::unique("asset_server_libraries_checked"); let fetched: Option = ui.data(|d| d.get_temp(sid)); let unread = paths.iter().any(|p| is_server_link(p) && app.session.libraries.cached(p).is_none()); if paths.iter().any(|p| is_server_link(p)) && (unread || fetched.is_none_or(|l| now - l >= SERVER_REFRESH)) { @@ -299,7 +300,7 @@ fn refresh_libraries(app: &mut FigApp, ui: &Ui) { /// The search field with the libraries button; returns the lowercase query. fn search_row(app: &mut FigApp, ui: &mut Ui, w: f32) -> String { let t = Tokens::get(ui.ctx()); - let filter_id = Id::new("asset_filter"); + let filter_id = Id::unique("asset_filter"); let mut filter: String = ui.data(|d| d.get_temp(filter_id)).unwrap_or_default(); let (fr, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); let field = Rect::from_min_max(pos2(fr.min.x + 12.0, fr.min.y + 8.0), pos2(fr.max.x - 44.0, fr.max.y - 4.0)); @@ -309,7 +310,7 @@ fn search_row(app: &mut FigApp, ui: &mut Ui, w: f32) -> String { let mut child = ui.new_child(egui::UiBuilder::new().max_rect(te_rect)); let r = child.add( egui::TextEdit::singleline(&mut filter) - .id(Id::new("asset_filter_field")) + .id(Id::unique("asset_filter_field")) .frame(egui::Frame::NONE) .font(regular(11.0)) .hint_text(tr("Search assets")) @@ -324,7 +325,7 @@ fn search_row(app: &mut FigApp, ui: &mut Ui, w: f32) -> String { } ui.data_mut(|d| d.insert_temp(filter_id, filter.clone())); let br = Rect::from_center_size(pos2(fr.max.x - 26.0, field.center().y), vec2(24.0, 24.0)); - let bresp = ui.interact(br, Id::new("asset_libraries_btn"), Sense::click()).tip(tr("Libraries")); + let bresp = ui.interact(br, Id::unique("asset_libraries_btn"), Sense::click()).tip(tr("Libraries")); if bresp.hovered() { ui.painter().rect_filled(br, CornerRadius::same(5), t.hover); } @@ -342,7 +343,7 @@ fn updates_banner(app: &mut FigApp, ui: &mut Ui, w: f32, count: usize) { let r = Rect::from_min_max(pos2(row.min.x + 12.0, row.min.y + 4.0), pos2(row.max.x - 12.0, row.max.y - 4.0)); ui.painter().rect_filled(r, CornerRadius::same(6), t.selected_row); let what = trn("{n} components and styles have newer versions", count, &[]); - ui.interact(r, Id::new("asset_updates"), Sense::hover()).on_hover_text(what); + ui.interact(r, Id::unique("asset_updates"), Sense::hover()).on_hover_text(what); ui.painter().text(pos2(r.min.x + 10.0, r.center().y), Align2::LEFT_CENTER, tr("Library updates available"), semibold(11.0), t.text); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_max(pos2(r.max.x - 70.0, r.min.y + 6.0), pos2(r.max.x - 6.0, r.max.y)))); @@ -438,7 +439,7 @@ fn tiles(ui: &mut Ui, w: f32, src: &Source, list: &[Tile], thumbs: &Thumbs, want let r = Rect::from_min_size(pos2(x, y), vec2(tile, tile)); let resp = ui.interact( r.union(Rect::from_min_size(r.left_bottom(), vec2(tile, TILE_LABEL))), - Id::new(("asset", src, item.id.0)), + Id::unique(("asset", src, item.id.0)), Sense::click_and_drag(), ); ui.painter().rect_filled(r, CornerRadius::same(6), t.field); @@ -581,6 +582,10 @@ fn render_thumbs(ui: &Ui, w: f32, thumbs: &mut Thumbs, wanted: &[(Source, NodeId } } +/// Draw the Assets panel: refresh libraries, switch to the libraries view when asked, +/// otherwise cache a listing keyed by revision, query and library state, render local and +/// enabled-library assets with reused thumbnails, and dispatch place, style, hide, toggle +/// and drag actions. pub fn show(app: &mut FigApp, ui: &mut Ui) { refresh_libraries(app, ui); if app.ui.libraries { @@ -593,7 +598,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { let revision = ds.revision; let q = search_row(app, ui, w); let key = (revision, q.clone(), libraries_key(app, &doc)); - let listing_id = Id::new("asset_listing"); + let listing_id = Id::unique("asset_listing"); let listing = match ui.data(|d| d.get_temp::>(listing_id)) { Some(l) if l.key == key => l, _ => { @@ -605,12 +610,12 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { if listing.updates > 0 { updates_banner(app, ui, w, listing.updates); } - let mut thumbs: Thumbs = ui.data(|d| d.get_temp(Id::new("asset_thumbs"))).unwrap_or_default(); + let mut thumbs: Thumbs = ui.data(|d| d.get_temp(Id::unique("asset_thumbs"))).unwrap_or_default(); let mut sources: Vec<(Source, Arc)> = vec![(Source::Local, doc.clone())]; sources.extend(listing.libraries.iter().filter_map(|l| l.doc.clone().map(|d| (Source::Library(l.id.clone()), d)))); invalidate_thumbs(ui, &mut thumbs, &sources); let mut wanted = Vec::new(); - let collapsed_id = Id::new("asset_collapsed_libraries"); + let collapsed_id = Id::unique("asset_collapsed_libraries"); let collapsed: HashSet = ui.data(|d| d.get_temp(collapsed_id)).unwrap_or_default(); let mut act = None; egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { @@ -657,7 +662,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { render_thumbs(ui, w, &mut thumbs, &wanted); let drag = ui.data(|d| d.get_temp::(drag_id())); let tex = drag.as_ref().and_then(|d| thumbs.map.get(&(d.src.clone(), d.component)).cloned().flatten()); - ui.data_mut(|d| d.insert_temp(Id::new("asset_thumbs"), thumbs)); + ui.data_mut(|d| d.insert_temp(Id::unique("asset_thumbs"), thumbs)); match act { Some(Act::Drag(d)) => ui.data_mut(|m| { m.insert_temp(drag_id(), d); @@ -773,9 +778,11 @@ fn drag_feedback(app: &mut FigApp, ui: &mut Ui, doc: &Document, libs: &HashMap) { let t = Tokens::get(ui.ctx()); - let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, Id::new("asset_ghost"))); + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, Id::unique("asset_ghost"))); let r = Rect::from_center_size(p, vec2(64.0, 64.0)); painter.rect_filled(r, CornerRadius::same(6), t.panel.gamma_multiply(0.9)); painter.rect_stroke(r, CornerRadius::same(6), Stroke::new(1.0, COMPONENT), egui::StrokeKind::Inside); @@ -812,7 +819,7 @@ fn libraries_view(app: &mut FigApp, ui: &mut Ui) { let w = ui.available_width(); let (hr, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); let back = Rect::from_center_size(pos2(hr.min.x + 24.0, hr.center().y), vec2(24.0, 24.0)); - let bresp = ui.interact(back, Id::new("asset_libraries_back"), Sense::click()).tip(tr("Back to assets")); + let bresp = ui.interact(back, Id::unique("asset_libraries_back"), Sense::click()).tip(tr("Back to assets")); if bresp.hovered() { ui.painter().rect_filled(back, CornerRadius::same(5), t.hover); } @@ -831,8 +838,12 @@ fn libraries_view(app: &mut FigApp, ui: &mut Ui) { }); } +/// Draw the publish form: status, publishable components/styles/collections, description and +/// publish buttons. Run `library.publish` locally or publish to the server in the +/// background, reset the form on success, and report when the file is unsaved so other files +/// cannot yet use it. fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { - let form_id = Id::new("asset_publish_form"); + let form_id = Id::unique("asset_publish_form"); let mut form: PublishForm = ui.data(|d| d.get_temp(form_id)).unwrap_or_default(); title(ui, w, tr("This file")); let m = doc.published.as_ref().map(|p| &p.manifest); @@ -901,7 +912,7 @@ fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { ui.horizontal(|ui| { ui.add_space(PAD); if let Some(d) = - crate::widgets::text_field(ui, Id::new("asset_publish_description"), &description, w - PAD * 2.0, tr("Description (optional)")) + crate::widgets::text_field(ui, Id::unique("asset_publish_description"), &description, w - PAD * 2.0, tr("Description (optional)")) { form.description = Some(d); } @@ -965,7 +976,7 @@ fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { /// How other files use this file's library on its server (asked for with a button: it is a /// request to the server). fn usage(app: &mut FigApp, ui: &mut Ui, w: f32, link: &str) { - let usage_id = Id::new(("asset_library_usage", link)); + let usage_id = Id::unique(("asset_library_usage", link)); let loading = app.session.waiting_for(USAGE); let (mut ask, mut stop) = (false, false); ui.horizontal(|ui| { @@ -1010,6 +1021,8 @@ fn usage(app: &mut FigApp, ui: &mut Ui, w: f32, link: &str) { ui.add_space(8.0); } +/// Draw the libraries enabled in this file with version/count/status, remove and swap +/// actions, add-file and pasted-link controls, and background fetch/cancel handling. fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { let t = Tokens::get(ui.ctx()); title(ui, w, tr("Libraries in this file")); @@ -1050,9 +1063,9 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { let clip = Rect::from_min_size(pos2(r.min.x + PAD, r.min.y), vec2(text_w, r.height())); ui.painter().with_clip_rect(clip).galley(pos2(r.min.x + PAD, r.min.y + 6.0), name, t.text); ui.painter().with_clip_rect(clip).galley(pos2(r.min.x + PAD, r.min.y + 23.0), line, t.text_secondary); - ui.interact(clip, Id::new(("asset_lib_row", &l.id)), Sense::hover()).on_hover_text(shown_path(&l.path)); + ui.interact(clip, Id::unique(("asset_lib_row", &l.id)), Sense::hover()).on_hover_text(shown_path(&l.path)); let x = Rect::from_center_size(pos2(r.max.x - 24.0, r.center().y), vec2(24.0, 24.0)); - let xr = ui.interact(x, Id::new(("asset_lib_remove", &l.id)), Sense::click()).tip(tr("Remove library from this file")); + let xr = ui.interact(x, Id::unique(("asset_lib_remove", &l.id)), Sense::click()).tip(tr("Remove library from this file")); if xr.hovered() { ui.painter().rect_filled(x, CornerRadius::same(5), t.hover); } @@ -1063,7 +1076,7 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { if swappable { let sw = Rect::from_center_size(pos2(r.max.x - 52.0, r.center().y), vec2(24.0, 24.0)); let sr = ui - .interact(sw, Id::new(("asset_lib_swap", &l.id)), Sense::click()) + .interact(sw, Id::unique(("asset_lib_swap", &l.id)), Sense::click()) .tip(tr("Swap library: use another library's assets of the same names")); if sr.hovered() { ui.painter().rect_filled(sw, CornerRadius::same(5), t.hover); @@ -1089,7 +1102,7 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { let pasted = ui .horizontal(|ui| { ui.add_space(PAD); - crate::widgets::text_field(ui, Id::new("asset_add_link"), "", w - PAD * 2.0, tr("Paste a team library link (a view link)")) + crate::widgets::text_field(ui, Id::unique("asset_add_link"), "", w - PAD * 2.0, tr("Paste a team library link (a view link)")) }) .inner; if let Some(link) = pasted.map(|l| l.trim().to_string()).filter(|l| !l.is_empty()) @@ -1140,7 +1153,7 @@ fn added(app: &mut FigApp, r: &Value) { /// Tests: the components whose thumbnails are cached (for this file's assets). #[cfg(test)] pub(crate) fn cached_thumbnails(ctx: &egui::Context) -> Vec { - let thumbs: Thumbs = ctx.data(|d| d.get_temp(Id::new("asset_thumbs"))).unwrap_or_default(); + let thumbs: Thumbs = ctx.data(|d| d.get_temp(Id::unique("asset_thumbs"))).unwrap_or_default(); let mut out: Vec = thumbs.map.keys().filter(|(s, _)| *s == Source::Local).map(|(_, id)| *id).collect(); out.sort(); out diff --git a/crates/ui-egui/src/bidi.rs b/crates/ui-egui/src/bidi.rs index 58c5df93..0c7bf99e 100644 --- a/crates/ui-egui/src/bidi.rs +++ b/crates/ui-egui/src/bidi.rs @@ -1,69 +1,23 @@ //! Names in right-to-left and mixed-direction scripts (Hebrew, Arabic…) in the panels: layer, page, -//! style, component and file names. egui has no bidi: it shapes each run of one font on its own, -//! reversing right-to-left runs but leaving the words and runs of a name in logical order. So -//! names go through [`figcraft_text::display`] (visual order, Arabic letters joined, mirrored -//! brackets) and every shown cluster is laid out as its own section, which egui's shaper leaves as -//! it is. Names are elided at their end (the left end of a right-to-left name) and edited by -//! [`single_line`], whose caret, clicks and selection follow the shown text (the arrow keys move -//! visually). Left-to-right names take egui's own path unchanged. +//! style, component and file names. egui 0.37's epaint shapes the logical (typing-order) text with +//! the Unicode bidirectional algorithm (UAX #9), lays every run out in visual order, joins Arabic +//! letters through the font's OpenType features, mirrors brackets, and keeps one glyph per char so +//! that caret, selection and click offsets stay logical. Names are therefore passed to egui in +//! logical order and elided at their visual end. The rename editor is egui's own single-line +//! `TextEdit`, whose arrow keys move the caret on screen while its state remains a logical range. -use std::collections::HashMap; use std::sync::Arc; -use egui::text::{ByteIndex, CCursor, CCursorRange, LayoutJob, LayoutSection, TextFormat, TextWrapping}; -use egui::{Color32, FontId, Galley, Id, Painter, Pos2, Rect, Response, Stroke, Ui, pos2, vec2}; -use figcraft_text::display::{self, VisualLine}; - -/// Shown (visual, elided) lines by name, font and width: elision measures many candidates, so the -/// result is kept between frames (cleared when it grows large). -#[derive(Clone, Default)] -struct Elided(Arc>>); - -/// A name, its font's size and family (proportional or not) and the width it was cut to (bits). -type ElidedKey = (String, u32, bool, u32); - -const ELIDED_MAX: usize = 4096; +use egui::text::{LayoutJob, TextWrapping}; +use egui::{Color32, FontId, Galley, Id, Painter, Pos2, Rect, Response, Ui, pos2, vec2}; /// `name` on one line in `font`, at most `max_w` wide (ending in an ellipsis when cut), ready to -/// paint: in visual order with Arabic letters joined when it has right-to-left text. +/// paint. The text stays in logical order; egui shapes, joins and reorders it, and puts the +/// ellipsis at the row's visual end (the left end of a right-to-left name). pub fn name_galley(painter: &Painter, name: &str, font: FontId, color: Color32, max_w: f32) -> Arc { let max_w = max_w.max(1.0); - if !display::has_rtl(name) { - let mut job = LayoutJob::simple_singleline(name.to_string(), font, color); - job.wrap = TextWrapping::truncate_at_width(max_w); - return painter.layout_job(job); - } - let key = (name.to_string(), font.size.to_bits(), matches!(font.family, egui::FontFamily::Proportional), max_w.to_bits()); - let cache = painter.ctx().data_mut(|d| d.get_temp_mut_or_default::(Id::new("bidi_elided")).clone()); - let hit = cache.0.lock().ok().and_then(|m| m.get(&key).cloned()); - let shown = hit.unwrap_or_else(|| { - let fits = |v: &VisualLine| visual_galley(painter, v, &font, color).size().x <= max_w; - let v = display::elide(name, fits); - if let Ok(mut m) = cache.0.lock() { - if m.len() >= ELIDED_MAX { - m.clear(); - } - m.insert(key, v.clone()); - } - v - }); - visual_galley(painter, &shown, &font, color) -} - -/// A line in display order laid out with each cluster as its own section, so egui's shaper (which -/// would reverse a right-to-left run once more) gets one ordered, joined cluster at a time. -fn visual_galley(painter: &Painter, line: &VisualLine, font: &FontId, color: Color32) -> Arc { - let chars: Vec = line.text.chars().collect(); - let format = TextFormat::simple(font.clone(), color); - let mut job = LayoutJob::default(); - for c in &line.clusters { - let start = job.text.len(); - job.text.extend(chars.get(c.shown.clone()).unwrap_or_default()); - // Pushed as is: `LayoutJob::append` would merge it with the section before. - if job.text.len() > start { - job.sections.push(LayoutSection { leading_space: 0.0, byte_range: ByteIndex(start)..ByteIndex(job.text.len()), format: format.clone() }); - } - } + let mut job = LayoutJob::simple_singleline(name.to_string(), font, color); + job.wrap = TextWrapping::truncate_at_width(max_w); painter.layout_job(job) } @@ -76,209 +30,29 @@ pub fn paint_name(painter: &Painter, left_center: Pos2, name: &str, font: FontId rect } -/// Each shown cluster's left and right x in a galley of `v.text` laid out from x = 0. -fn edges(v: &VisualLine, galley: &Galley) -> Vec<(f64, f64)> { - let chars: Vec<(f64, f64)> = galley - .rows - .first() - .map(|r| r.row.glyphs.iter().map(|g| (f64::from(g.pos.x), f64::from(g.pos.x + g.advance_width))).collect()) - .unwrap_or_default(); - v.cluster_edges(&chars) -} - -/// The shown text of a field being edited, laid out once per frame. -struct Shown { - line: VisualLine, - galley: Arc, - edges: Vec<(f64, f64)>, -} - -impl Shown { - fn new(ui: &Ui, text: &str, font: &FontId, color: Color32) -> Shown { - let line = display::visual_line(text); - let galley = visual_galley(ui.painter(), &line, font, color); - let edges = edges(&line, &galley); - Shown { line, galley, edges } - } - - fn caret_x(&self, i: usize) -> f32 { - self.line.caret_x(i, &self.edges) as f32 - } - - fn hit(&self, x: f32) -> usize { - self.line.hit(f64::from(x), &self.edges) - } -} - -fn scroll_id(id: Id) -> Id { - id.with("bidi_scroll") -} - -fn anchor_id(id: Id) -> Id { - id.with("bidi_anchor") -} - -/// A frameless one-line text editor for a name (a [`egui::TextEdit`] with id `id`). Text with -/// right-to-left characters shows in visual order, and the caret, the selection, clicks, drags and -/// the left and right arrow keys follow what's on screen. +/// A frameless one-line text editor for a name (a [`egui::TextEdit`] with id `id`). The editor's +/// text is logical; egui's galley paints it in visual order, and its caret, selection, clicks, +/// drags and left and right arrows follow what is on screen while reporting logical char offsets. pub fn single_line(ui: &mut Ui, id: Id, text: &mut String, font: FontId, width: f32) -> Response { - let rtl = display::has_rtl(text); - let focused = ui.memory(|m| m.has_focus(id)); - let color = ui.visuals().text_color(); - if rtl && focused { - visual_arrows(ui, id, text, &font, color); - } // Centred in the height it's given (a rename row), like the name it replaces. let height = ui.available_height().max(0.0); - let edit = egui::TextEdit::singleline(text) + egui::TextEdit::singleline(text) .id(id) .frame(egui::Frame::NONE) - .font(font.clone()) + .font(font) .desired_width(width) - .vertical_align(egui::Align::Center) - .min_size(vec2(0.0, height)); - if !rtl { - ui.data_mut(|d| d.remove::(scroll_id(id))); - return edit.show(ui).response.response; - } - // egui would draw the chars in logical order: hide its text, caret and selection and draw the - // shown text over it. - let out = ui - .scope(|ui| { - let v = ui.visuals_mut(); - v.selection.bg_fill = Color32::TRANSPARENT; - v.selection.stroke = Stroke::new(0.0, Color32::TRANSPARENT); - v.text_cursor.stroke = Stroke::NONE; - edit.text_color(Color32::TRANSPARENT).show(ui) - }) - .inner; - let response = out.response.response; - let shown = Shown::new(ui, text, &font, color); - let clip = out.text_clip_rect; - let mut scroll: f32 = ui.data(|d| d.get_temp(scroll_id(id))).unwrap_or(0.0); - let origin = |scroll: f32| pos2(clip.min.x - scroll, out.galley_pos.y); - // Clicks and drags place the caret by what's on screen. - let mut range = out.state.cursor.char_range(); - if let Some(p) = response.interact_pointer_pos() { - let at = shown.hit(p.x - origin(scroll).x); - if response.double_clicked() { - let (a, b) = word_at(text, at); - range = Some(CCursorRange::two(CCursor::new(a), CCursor::new(b))); - } else if response.clicked() || (response.is_pointer_button_down_on() && !response.dragged()) { - // Pressed (not yet dragging): the caret and the drag's anchor go here. - ui.data_mut(|d| d.insert_temp(anchor_id(id), at)); - range = Some(CCursorRange::one(CCursor::new(at))); - } else if response.dragged() { - let anchor = ui.data(|d| d.get_temp::(anchor_id(id))).unwrap_or(at); - range = Some(CCursorRange { primary: CCursor::new(at), secondary: CCursor::new(anchor), h_pos: None }); - } - if range != out.state.cursor.char_range() { - let mut state = out.state.clone(); - state.cursor.set_char_range(range); - state.store(ui.ctx(), id); - } - } - // Keep the caret in view. - let caret = range.map_or(0, |r| r.primary.index.0); - let cx = shown.caret_x(caret); - let w = clip.width().max(1.0); - if cx - scroll > w - 1.0 { - scroll = cx - w + 1.0; - } - if cx - scroll < 0.0 { - scroll = cx; - } - scroll = scroll.clamp(0.0, (shown.galley.size().x - w + 1.0).max(0.0)); - ui.data_mut(|d| d.insert_temp(scroll_id(id), scroll)); - let o = origin(scroll); - let painter = ui.painter().with_clip_rect(clip.expand2(vec2(1.0, 0.0))); - let h = shown.galley.size().y; - if focused && let Some(r) = range { - let (a, b) = (r.primary.index.0, r.secondary.index.0); - for (x0, x1) in shown.line.selection(a, b, &shown.edges) { - let sel = Rect::from_min_max(pos2(o.x + x0 as f32, o.y), pos2(o.x + x1 as f32, o.y + h)); - painter.rect_filled(sel, 0.0, ui.visuals().selection.bg_fill); - } - } - painter.galley(o, shown.galley.clone(), color); - if focused { - let x = (o.x + cx).round() + 0.5; - painter.line_segment([pos2(x, o.y), pos2(x, o.y + h)], ui.visuals().text_cursor.stroke); - ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); - } - response -} - -/// Plain and Shift-held left and right arrows move the caret across the shown text (consumed so -/// the editor doesn't move it logically). Word and line jumps stay logical. -fn visual_arrows(ui: &mut Ui, id: Id, text: &str, font: &FontId, color: Color32) { - let moves: Vec<(bool, bool)> = ui.input_mut(|i| { - let mut out = Vec::new(); - i.events.retain(|e| match e { - egui::Event::Key { key: key @ (egui::Key::ArrowLeft | egui::Key::ArrowRight), pressed: true, modifiers, .. } - if !modifiers.alt && !modifiers.command && !modifiers.ctrl => - { - out.push((*key == egui::Key::ArrowRight, modifiers.shift)); - false - } - _ => true, - }); - out - }); - if moves.is_empty() { - return; - } - let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), id) else { return }; - let shown = Shown::new(ui, text, font, color); - let mut r = state.cursor.char_range().unwrap_or_default(); - for (right, shift) in moves { - let (lo, hi) = (r.primary.index.0.min(r.secondary.index.0), r.primary.index.0.max(r.secondary.index.0)); - let next = if !shift && lo != hi { - // A selection collapses to its end on the side of the arrow. - let (xa, xb) = (shown.caret_x(lo), shown.caret_x(hi)); - if (xa < xb) == right { hi } else { lo } - } else { - shown.line.step(r.primary.index.0, right, &shown.edges) - }; - r = if shift { - CCursorRange { primary: CCursor::new(next), secondary: r.secondary, h_pos: None } - } else { - CCursorRange::one(CCursor::new(next)) - }; - } - state.cursor.set_char_range(Some(r)); - state.store(ui.ctx(), id); -} - -/// The word around char `i` (letters, digits and marks; else the single char). -fn word_at(text: &str, i: usize) -> (usize, usize) { - let chars: Vec = text.chars().collect(); - let is_word = - |k: usize| chars.get(k).is_some_and(|c| c.is_alphanumeric() || matches!(*c as u32, 0x0300..=0x036F | 0x0591..=0x05C7 | 0x064B..=0x065F)); - let i = i.min(chars.len()); - let at = if is_word(i) { - i - } else if i > 0 && is_word(i - 1) { - i - 1 - } else { - return (i, (i + 1).min(chars.len())); - }; - let mut a = at; - while a > 0 && is_word(a - 1) { - a -= 1; - } - let mut b = at; - while is_word(b) { - b += 1; - } - (a, b) + .align(egui::Align2::LEFT_CENTER) + .min_size(vec2(0.0, height)) + .show(ui) + .response + .response } /// Faces the UI adds for scripts its own fonts lack (desktop: from the system), as egui font data; /// `taken` as for [`crate::theme::system_fallbacks`]. pub fn script_fallbacks(have: &dyn Fn(char) -> bool, taken: &mut Vec>) -> Vec<(String, egui::FontData)> { - // Hebrew, Arabic letters, Arabic presentation forms (what joined names are drawn with). - const SAMPLES: [char; 3] = ['\u{05D0}', '\u{0628}', '\u{FE91}']; + // Hebrew and Arabic letters; egui's shaper selects the joined contextual forms itself. + const SAMPLES: [char; 2] = ['\u{05D0}', '\u{0628}']; const FAMILIES: [&str; 11] = [ "Segoe UI", "Arial", diff --git a/crates/ui-egui/src/board_bar.rs b/crates/ui-egui/src/board_bar.rs index c312ddb9..6ddd1cb8 100644 --- a/crates/ui-egui/src/board_bar.rs +++ b/crates/ui-egui/src/board_bar.rs @@ -170,7 +170,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { let size = vec2(h.width(), 40.0); let at = place(sel, size, app.canvas.rect); let t = Tokens::get(ctx); - egui::Area::new(egui::Id::new("board_bar")).fixed_pos(at).order(egui::Order::Middle).show(ctx, |ui| { + egui::Area::new(egui::Id::unique("board_bar")).fixed_pos(at).order(egui::Order::Middle).show(ctx, |ui| { let (rect, _) = ui.allocate_exact_size(size, Sense::click()); a11y::container_bounds(ui, rect); let p = ui.painter(); @@ -198,7 +198,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { fn color_part(app: &mut FigApp, ui: &mut Ui, mut x: f32, cy: f32, t: &Tokens) -> f32 { for (i, (name, c)) in BOARD_COLORS.iter().enumerate() { let r = Rect::from_center_size(pos2(x + SWATCH / 2.0, cy), vec2(SWATCH, SWATCH)); - let resp = ui.interact(r, ui.id().with(("bb_color", i)), Sense::click()).on_hover_text(tr(color_label(name))); + let resp = ui.interact(r, ui.scope_id().with(("bb_color", i)), Sense::click()).on_hover_text(tr(color_label(name))); a11y::button(&resp, tr(color_label(name))); if resp.hovered() { ui.painter().rect_filled(r.expand(2.0), CornerRadius::same(6), t.hover); @@ -215,7 +215,7 @@ fn color_part(app: &mut FigApp, ui: &mut Ui, mut x: f32, cy: f32, t: &Tokens) -> /// The shape menu at `at` (its left edge and middle), showing `shape`; where the next part starts. fn shape_part(app: &mut FigApp, ui: &mut Ui, shape: BoardShape, at: Pos2, t: &Tokens) -> f32 { let r = Rect::from_min_size(pos2(at.x, at.y - CELL / 2.0), vec2(CELL + 12.0, CELL)); - let resp = ui.interact(r, ui.id().with("bb_shape"), Sense::click()).on_hover_text(tr("Shape")); + let resp = ui.interact(r, ui.scope_id().with("bb_shape"), Sense::click()).on_hover_text(tr("Shape")); a11y::menu_button(&resp, tr("Shape"), a11y::menu_open(&resp)); if resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); @@ -240,7 +240,7 @@ fn shape_part(app: &mut FigApp, ui: &mut Ui, shape: BoardShape, at: Pos2, t: &To fn size_part(app: &mut FigApp, ui: &mut Ui, mut x: f32, cy: f32, t: &Tokens) -> f32 { for (label, size) in TEXT_SIZES { let r = Rect::from_center_size(pos2(x + CELL / 2.0, cy), vec2(CELL, CELL)); - let resp = ui.interact(r, ui.id().with(("bb_size", label)), Sense::click()).on_hover_text(tr("Text size")); + let resp = ui.interact(r, ui.scope_id().with(("bb_size", label)), Sense::click()).on_hover_text(tr("Text size")); a11y::button(&resp, &format!("{} {label}", tr("Text size"))); if resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); diff --git a/crates/ui-egui/src/board_ui_tests.rs b/crates/ui-egui/src/board_ui_tests.rs index 4ffb3cf4..d184c934 100644 --- a/crates/ui-egui/src/board_ui_tests.rs +++ b/crates/ui-egui/src/board_ui_tests.rs @@ -56,12 +56,12 @@ fn pick_option(u: &mut Ui, i: usize, n: usize) { /// Whether the bar over the selection showed in the last frame. fn bar_shown(ctx: &egui::Context) -> bool { - ctx.memory(|m| m.areas().visible_last_frame(&egui::LayerId::new(egui::Order::Middle, egui::Id::new("board_bar")))) + ctx.memory(|m| m.areas().visible_last_frame(&egui::LayerId::new(egui::Order::Middle, egui::Id::unique("board_bar")))) } /// The bar over the selection, when it shows. fn bar(u: &Ui) -> Option { - bar_shown(&u.ctx).then(|| u.ctx.memory(|m| m.area_rect(egui::Id::new("board_bar")))).flatten() + bar_shown(&u.ctx).then(|| u.ctx.memory(|m| m.area_rect(egui::Id::unique("board_bar")))).flatten() } #[test] diff --git a/crates/ui-egui/src/branch_ui.rs b/crates/ui-egui/src/branch_ui.rs index 1e3c558c..c52fc216 100644 --- a/crates/ui-egui/src/branch_ui.rs +++ b/crates/ui-egui/src/branch_ui.rs @@ -75,7 +75,7 @@ pub fn banner(app: &mut FigApp, ctx: &egui::Context, canvas: Rect) { let Some(b) = m.branches.iter().find(|b| b.id == id) else { return }; let (file, name, active) = (m.doc.name.clone(), b.name.clone(), b.state == BranchState::Active); let t = Tokens::get(ctx); - let review = widgets::canvas_banner(ctx, Id::new("branch_banner"), canvas, |ui| { + let review = widgets::canvas_banner(ctx, Id::unique("branch_banner"), canvas, |ui| { ui.label(egui::RichText::new(format!("“{name}”")).font(semibold(11.0)).color(t.text)); let state = if active { trf("Branch of {file}", &[("file", &file)]) } else { trf("Branch of {file} · merged or archived", &[("file", &file)]) }; @@ -146,7 +146,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { let mut open = true; let mut action = None; egui::Window::new("Review and merge") - .id(Id::new("branch_review_window")) + .id(Id::unique("branch_review_window")) .title_bar(false) .collapsible(false) .resizable(false) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ca46b4c8..de38ceb8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -163,12 +163,16 @@ fn pointer_event(app: &FigApp, kind: PointerKind, pos: Pos2, m: &egui::Modifiers } } +/// Run one canvas pass: update the viewport size and first-fit zoom; handle pointer/key input +/// and accessibility; draw the drop overlay, guides, live pointer and preedit; request/poll +/// renders and upload the texture; draw the page background and last texture; then draw overlays, +/// labels, handles, comments, versions/branch/dev/live UI and the cursor. pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { let rect = ui.max_rect(); app.canvas.rect = rect; let ctx = ui.ctx().clone(); let ppp = ctx.pixels_per_point(); - let resp = ui.interact(rect, ui.id().with("canvas"), Sense::click_and_drag()); + let resp = ui.interact(rect, ui.scope_id().with("canvas"), Sense::click_and_drag()); let key_box = app.ui.key_box; crate::a11y::canvas(&mut app.ui.nav, &resp, || key_box.map_or_else(|| describe(&app.session), describe_box)); if let Ok(ds) = app.session.ds_mut() diff --git a/crates/ui-egui/src/clipboard_fence_tests.rs b/crates/ui-egui/src/clipboard_fence_tests.rs new file mode 100644 index 00000000..76ddf81c --- /dev/null +++ b/crates/ui-egui/src/clipboard_fence_tests.rs @@ -0,0 +1,200 @@ +//! Programmatic gestures never replace the user's system clipboard: keys and clicks injected +//! through the control channel (`ui.input`, `ui.menu.invoke`, …) may copy inside FigCraft but the +//! OS clipboard is the user's. Every copy command goes through `FigApp::clipboard_set_text` / +//! `FigApp::clipboard_set_png`, which drop the write while the frame's input is synthetic. + +use std::sync::{Arc, Mutex}; + +use egui::{Event, Pos2, RawInput, Rect, vec2}; +use figcraft_doc::{NodeId, NodeKind}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::control::ControlRequest; +use crate::{FigApp, Services}; + +#[derive(Default)] +struct Recorded { + text: Vec, + pngs: usize, + /// What the system clipboard holds now (the last text written). + last_text: Option, +} + +/// The system clipboard of the test machine. +struct Clipboard(Arc>); + +impl Services for Clipboard { + /// Record a system-clipboard text write in the test clipboard service. + fn clipboard_set_text(&mut self, s: &str) { + let mut r = self.0.lock().unwrap(); + r.text.push(s.to_string()); + r.last_text = Some(s.to_string()); + } + /// Return the test system clipboard's last text. + fn clipboard_get_text(&mut self) -> Option { + self.0.lock().unwrap().last_text.clone() + } + /// Count a PNG clipboard write in the test clipboard service. + fn clipboard_set_png(&mut self, _w: u32, _h: u32, _rgba: &[u8]) { + self.0.lock().unwrap().pngs += 1; + } +} + +struct Win { + ctx: egui::Context, + app: FigApp, + time: f64, + recorded: Arc>, +} + +impl Win { + /// Create a test window over a session containing one selected rectangle. + fn new() -> Self { + let mut s = Session::new(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap(); + let mut win = Self::with_session(s); + let _ = win.app.run("select.all", json!({})); + win + } + + /// Build a test window around `s` with a recording clipboard service and run three frames. + fn with_session(s: Session) -> Self { + let recorded = Arc::new(Mutex::new(Recorded::default())); + let app = FigApp::new(s, Box::new(Clipboard(recorded.clone()))); + let mut win = Self { ctx: egui::Context::default(), app, time: 0.0, recorded }; + for _ in 0..3 { + win.frame(vec![]); + } + win + } + + /// Run one production UI frame with `events` at the next 60 Hz test time. + fn frame(&mut self, events: Vec) { + self.time += 1.0 / 60.0; + self.app.clock.set_test_now(Some(self.time)); + let input = + RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), events, ..Default::default() }; + let app = &mut self.app; + let mut out = self.ctx.run_ui(input, |ui| app.ui(ui)); + out.textures_delta.clear(); + } + + /// Return the recorded `(text writes, PNG writes)` count. + fn writes(&self) -> (usize, usize) { + let r = self.recorded.lock().unwrap(); + (r.text.len(), r.pngs) + } +} + +/// Asserts that a control-channel copy shortcut writes no system clipboard, still runs the +/// command and is acknowledged. +#[test] +fn a_synthetic_copy_shortcut_leaves_the_system_clipboard_alone() { + let mut w = Win::new(); + assert!(!w.app.session.selection().is_empty(), "something to copy"); + let (tx, rx) = std::sync::mpsc::channel(); + w.app.control_queue.push(ControlRequest { + id: json!(1), + method: "ui.input".into(), + // Shift+Cmd+C: `edit.copyAsPng`, which wrote the OS clipboard directly before the fence. + params: json!({"events": [{"key": "C", "shift": true, "cmd": true}]}), + reply: tx, + state: Default::default(), + }); + w.frame(vec![]); + let events = crate::windows::take_input(&mut w.app, egui::ViewportId::ROOT); + assert!(!events.is_empty(), "the injected shortcut"); + w.frame(events); + assert_eq!(w.writes(), (0, 0), "a control-channel gesture never replaces the user's clipboard"); + assert!( + w.app.ui.toast.as_ref().is_some_and(|(m, _)| m.contains("Copied")), + "the command itself ran (only its OS clipboard write is fenced): {:?}", + w.app.ui.toast + ); + assert!(rx.try_recv().is_ok(), "the batch is acknowledged"); +} + +/// Asserts copy commands run under the synthetic-control fence write nothing, while the same +/// command afterwards writes once. +#[test] +fn a_copy_command_invoked_for_a_synthetic_click_still_keeps_the_clipboard() { + let mut w = Win::new(); + // What a synthetic pointer click on Copy runs, fenced as `control::drain`/`background` fence it. + w.app.control_synthetic = true; + crate::menus::invoke(&mut w.app, "edit.copyAsCss"); + crate::menus::invoke(&mut w.app, "edit.copyAsText"); + assert_eq!(w.writes(), (0, 0)); + w.app.control_synthetic = false; + // A user's own copy still writes. + crate::menus::invoke(&mut w.app, "edit.copyAsCss"); + assert_eq!(w.writes(), (1, 0)); +} + +/// Return the characters of a text node in the test window. +fn edit_text(w: &Win, id: NodeId) -> String { + match &w.app.session.doc().unwrap().node(id).unwrap().kind { + NodeKind::Text(t) => t.characters.clone(), + _ => panic!("not text"), + } +} + +/// Asserts a synthetic Cut deletes the selected text, keeps the edit session active, writes +/// no system clipboard, and ends its fence with the frame. +#[test] +fn a_synthetic_cut_deletes_the_selection_but_leaves_the_users_clipboard_alone() { + let mut s = Session::new(); + let id = NodeId(s.execute("text.create", &json!({"text": "abc", "x": 40, "y": 40})).unwrap()["id"].as_u64().unwrap()); + let mut w = Win::with_session(s); + figcraft_engine::textedit::begin(&mut w.app.session, id, Some(3), false).unwrap(); + figcraft_engine::textedit::set_caret(&mut w.app.session, 0, true).unwrap(); + + // A control-channel gesture is fenced from the OS clipboard, but its Cut still edits the + // document: the selection is deleted exactly as a user's Cut would delete it. + w.app.push_control_input(vec![Event::Cut]); + let events = crate::windows::take_input(&mut w.app, egui::ViewportId::ROOT); + assert_eq!(events, [Event::Cut], "the production input path owns the synthetic frame"); + w.frame(events); + assert_eq!(w.writes(), (0, 0), "a synthetic cut never writes the user's clipboard"); + assert_eq!(edit_text(&w, id), "", "the selected text is deleted"); + assert!(w.app.session.ds().unwrap().text_edit.is_some(), "the edit session stays active"); + assert!(!w.app.control_synthetic, "the fence ends with the frame"); +} + +/// Asserts a programmatic copy leaves the system clipboard alone and invalidates the stale +/// mirror, so a later user paste uses the system text rather than the programmatic layers. +#[test] +fn a_programmatic_copy_does_not_let_a_stale_mirror_hide_the_users_paste() { + let mut s = Session::new(); + let hello = figcraft_doc::NodeId(s.execute("text.create", &json!({"text": "hello", "x": 10, "y": 10})).unwrap()["id"].as_u64().unwrap()); + let other = figcraft_doc::NodeId(s.execute("text.create", &json!({"text": "other", "x": 10, "y": 80})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [hello]})).unwrap(); + let mut w = Win::with_session(s); + + // The user copies the "hello" text layer: the OS clipboard and `last_copy` become "hello". + crate::menus::invoke(&mut w.app, "edit.copy"); + assert_eq!(w.app.ui.last_copy.as_deref(), Some("hello")); + assert_eq!(w.writes().0, 1, "the user's copy reaches the system clipboard"); + + // A control-channel copy changes the engine clipboard to "other" but never writes the system + // clipboard. The stale "hello" mirror must not claim the programmatic selection is ours. + let _ = w.app.run("select.set", json!({"ids": [other]})); + w.app.control_synthetic = true; + crate::menus::invoke(&mut w.app, "edit.copy"); + w.app.control_synthetic = false; + assert_eq!(w.writes().0, 1, "the programmatic copy leaves the system clipboard alone"); + assert_eq!(w.app.ui.last_copy, None, "the old system-clipboard mirror is invalidated"); + + // The user pastes: the system clipboard's own text wins over the programmatic layers. + crate::menus::paste(&mut w.app, Some("hello"), None); + let doc = w.app.session.doc().unwrap(); + let texts: Vec = doc + .pages + .iter() + .filter_map(|p| doc.node(*p)) + .flat_map(|p| p.children.iter().copied()) + .filter_map(|id| doc.node(id).and_then(|n| n.kind.text().map(|t| t.characters.clone()))) + .collect(); + assert_eq!(texts.iter().filter(|t| t.as_str() == "hello").count(), 2, "the user's text was pasted"); + assert_eq!(texts.iter().filter(|t| t.as_str() == "other").count(), 1, "the programmatic selection was not pasted"); +} diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index 9d37477a..f1cde02f 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -199,7 +199,7 @@ fn finish(app: &mut FigApp) { /// The selected gradient stop in the picker (shared with the on-canvas stops). pub fn stop_id() -> Id { - Id::new("cp_stop") + Id::unique("cp_stop") } /// Apply a swatch colour: a solid paint's colour, or the selected gradient stop's. @@ -224,7 +224,7 @@ fn set_color(app: &mut FigApp, target: &PickerTarget, paint: &mut Paint, c: Colo fn page_colors(app: &FigApp, ui: &Ui) -> Vec { let Ok(ds) = app.session.ds() else { return vec![] }; let key = (std::sync::Arc::as_ptr(&ds.doc) as usize, ds.page); - let cache_id = Id::new("cp_doc_colors"); + let cache_id = Id::unique("cp_doc_colors"); if let Some((k, colors)) = ui.data(|d| d.get_temp::<((usize, figcraft_doc::NodeId), Vec)>(cache_id)) && k == key { @@ -241,7 +241,7 @@ const MODELS: [&str; 5] = ["Hex", "RGB", "HSL", "HSB", "CSS"]; /// The model dropdown with its fields and the alpha field. Returns a new colour and whether the /// edit is done (false while a field is being scrubbed). fn model_fields(ui: &mut Ui, c: Color) -> Option<(Color, bool)> { - let model_id = Id::new("cp_model"); + let model_id = Id::unique("cp_model"); let model: usize = ui.data(|d| d.get_temp(model_id)).unwrap_or(0).min(MODELS.len() - 1); let mut out: Option = None; let mut done = true; @@ -259,7 +259,7 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option<(Color, bool)> { }); let num = |ui: &mut Ui, id: &str, v: f64, w: f32| match widgets::number_field( ui, - Id::new(("cp_f", id)), + Id::unique(("cp_f", id)), widgets::Lead::None, Some(v.round()), "", @@ -309,19 +309,19 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option<(Color, bool)> { } } "CSS" => { - if let Some(text) = widgets::text_field(ui, Id::new("cp_css"), &c.to_css(), 152.0, "") { + if let Some(text) = widgets::text_field(ui, Id::unique("cp_css"), &c.to_css(), 152.0, "") { out = Color::parse_css(&text); } } _ => { - if let Some(h) = widgets::text_field(ui, Id::new("cp_hex"), &c.to_hex(), 101.0, "") { + if let Some(h) = widgets::text_field(ui, Id::unique("cp_hex"), &c.to_hex(), 101.0, "") { out = Color::from_hex_or_name(&h).map(|h| h.with_alpha(if h.a < 1.0 { h.a } else { c.a })); } } } if MODELS.get(model) != Some(&"CSS") { let alpha = Some((f64::from(out.unwrap_or(c).a) * 100.0).round()); - let edit = match widgets::number_field(ui, Id::new("cp_a"), widgets::Lead::None, alpha, "%", 44.0, 1.0) { + let edit = match widgets::number_field(ui, Id::unique("cp_a"), widgets::Lead::None, alpha, "%", 44.0, 1.0) { Some(widgets::FieldEdit::Set(v)) => Some((v, true)), Some(widgets::FieldEdit::Scrub(v, end)) => Some((v, end)), None => None, @@ -339,6 +339,8 @@ fn hue_color(ctx: &egui::Context, h: f32) -> Color32 { doc_c32(ctx, Hsb { h, s: 1.0, b: 1.0 }.to_color()) } +/// Draw the saturation/value square and its knob, updating `hsb.s` and `hsb.b` on click or +/// drag; returns `(changed, done)`. fn sv_square(ui: &mut Ui, rect: Rect, hsb: &mut Hsb) -> (bool, bool) { // White → hue horizontally, then transparent → black vertically. let mut m = Mesh::default(); @@ -362,7 +364,7 @@ fn sv_square(ui: &mut Ui, rect: Rect, hsb: &mut Hsb) -> (bool, bool) { m2.add_triangle(0, 1, 2); m2.add_triangle(0, 2, 3); ui.painter().add(m2); - let resp = ui.interact(rect, Id::new("cp_sv"), Sense::click_and_drag()); + let resp = ui.interact(rect, Id::unique("cp_sv"), Sense::click_and_drag()); let mut changed = false; if (resp.dragged() || resp.clicked() || resp.drag_started()) && let Some(p) = resp.interact_pointer_pos() @@ -377,6 +379,8 @@ fn sv_square(ui: &mut Ui, rect: Rect, hsb: &mut Hsb) -> (bool, bool) { (changed, resp.drag_stopped() || resp.clicked()) } +/// Draw the hue gradient and knob, updating `hsb.h` clamped below 360; returns +/// `(changed, done)`. fn hue_slider(ui: &mut Ui, rect: Rect, hsb: &mut Hsb) -> (bool, bool) { let n = 12; let mut m = Mesh::default(); @@ -393,7 +397,7 @@ fn hue_slider(ui: &mut Ui, rect: Rect, hsb: &mut Hsb) -> (bool, bool) { } } ui.painter().add(m); - slider_knob(ui, rect, hsb.h / 360.0, Id::new("cp_hue")) + slider_knob(ui, rect, hsb.h / 360.0, Id::unique("cp_hue")) .map(|(t, done)| { hsb.h = (t * 360.0).clamp(0.0, 359.9); (true, done) @@ -417,6 +421,8 @@ fn slider_knob(ui: &mut Ui, rect: Rect, t: f32, id: Id) -> Option<(f32, bool)> { None } +/// Draw the checkerboard and transparent-to-opaque alpha gradient with a knob; returns the +/// new alpha and done state, or `None` when the slider is not interacting. fn alpha_slider(ui: &mut Ui, rect: Rect, color: Color) -> Option<(f32, bool)> { widgets::checker(&ui.painter().with_clip_rect(rect), rect, rect.height() / 2.0); let mut m = Mesh::default(); @@ -432,15 +438,18 @@ fn alpha_slider(ui: &mut Ui, rect: Rect, color: Color) -> Option<(f32, bool)> { m.add_triangle(0, 1, 2); m.add_triangle(0, 2, 3); ui.painter().add(m); - slider_knob(ui, rect, color.a, Id::new("cp_alpha")) + slider_knob(ui, rect, color.a, Id::unique("cp_alpha")) } /// The colour picker window's area id. A fixed id (not `Window::new`'s, which egui keys by -/// `Id::new(Some(title))`) so the style picker can tell a click inside the picker from one outside. +/// `Id::unique(Some(title))`) so the style picker can tell a click inside the picker from one outside. pub fn window_id() -> Id { - Id::new("color_picker") + Id::unique("color_picker") } +/// Draw the colour picker for the current target: close it when there is no target or the +/// target's selection no longer matches, otherwise apply eyedropper, paint-kind, colour and +/// gradient-stop edits live, positioned beside a plugin field when applicable. pub fn show(app: &mut FigApp, ctx: &egui::Context) { let Some(target) = app.ui.picker.clone() else { // A colour sampled for a picker that has closed has no paint to go to. @@ -477,7 +486,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { let mut close_now = false; egui::Window::new("color_picker") // A fixed area id so the style picker can tell a click in here from a click outside - // (`Window::new` alone would key the area by `Id::new(Some(title))`). + // (`Window::new` alone would key the area by `Id::unique(Some(title))`). .id(window_id()) .title_bar(false) .fixed_pos(pos) @@ -494,7 +503,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { let (hr, _) = ui.allocate_exact_size(vec2(240.0, 40.0), Sense::hover()); ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, tr("Custom"), semibold(11.0), t.text); let cr = Rect::from_center_size(pos2(hr.max.x - 20.0, hr.center().y), vec2(24.0, 24.0)); - let cresp = ui.interact(cr, Id::new("cp_close"), Sense::click()); + let cresp = ui.interact(cr, Id::unique("cp_close"), Sense::click()); icons::paint(ui.painter(), cr.shrink(6.0), Icon::Close, t.icon); if cresp.clicked() { close_now = true; @@ -605,11 +614,11 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { let mut changed = false; let mut done = false; // The bar first, so the stops on it take their own clicks. - let bresp = ui.interact(bar, Id::new("cp_bar"), Sense::click()); + let bresp = ui.interact(bar, Id::unique("cp_bar"), Sense::click()); for (i, s) in g.stops.clone().iter().enumerate() { let x = bar.min.x + s.position * bar.width(); let r = Rect::from_center_size(pos2(x, bar.center().y), vec2(12.0, 18.0)); - let resp = ui.interact(r, Id::new(("cp_stopk", i)), Sense::click_and_drag()); + let resp = ui.interact(r, Id::unique(("cp_stopk", i)), Sense::click_and_drag()); ui.painter().rect_filled(r, CornerRadius::same(3), doc_c32(ui.ctx(), s.color.with_alpha(1.0))); ui.painter().rect_stroke( r, @@ -773,7 +782,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { } if let Some(color) = edit_color { let mut hsb = ui - .data(|d| d.get_temp::<(f32, f32, f32, [u8; 4])>(Id::new("cp_hsb"))) + .data(|d| d.get_temp::<(f32, f32, f32, [u8; 4])>(Id::unique("cp_hsb"))) .filter(|(_, _, _, rgba)| *rgba == color.to_rgba8()) .map(|(h, s, b, _)| Hsb { h, s, b }) .unwrap_or_else(|| Hsb::from_color(color)); @@ -817,7 +826,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { changed = true; done |= d; } - ui.data_mut(|d| d.insert_temp(Id::new("cp_hsb"), (hsb.h, hsb.s, hsb.b, nc.to_rgba8()))); + ui.data_mut(|d| d.insert_temp(Id::unique("cp_hsb"), (hsb.h, hsb.s, hsb.b, nc.to_rgba8()))); // A Display P3 colour sRGB can't show: the canvas (sRGB) shows the nearest one. if app.session.doc().is_ok_and(|d| d.color_profile.conversion_to(figcraft_color::ColorProfile::Srgb).clips(nc)) { gamut_warning(ui, &t); @@ -983,7 +992,7 @@ fn pattern_controls(app: &mut FigApp, ui: &mut Ui, target: &PickerTarget, paint: ui.add_space(16.0); let field = |ui: &mut Ui, key: &str, lead: Lead, value: f64, suffix: &str| match widgets::number_field( ui, - Id::new(key), + Id::unique(key), lead, Some(value), suffix, @@ -1029,7 +1038,7 @@ fn saved_preset_row(ui: &mut Ui, name: &str) -> Option { let w = ui.available_width().max(180.0); let (r, resp) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click()); let del = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(20.0, 20.0)); - let del_resp = ui.interact(del, ui.id().with(("cp_preset_del", name)), Sense::click()).tip(tr("Delete preset")); + let del_resp = ui.interact(del, ui.scope_id().with(("cp_preset_del", name)), Sense::click()).tip(tr("Delete preset")); let hot = resp.hovered() || del_resp.hovered(); if hot { ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); @@ -1061,7 +1070,7 @@ fn image_presets(app: &mut FigApp, ui: &mut Ui, target: &PickerTarget, paint: &m .find(|(_, f)| *f == filters) .map(|(name, _)| name) .unwrap_or_else(|| if filters.is_identity() { tr("No preset").into() } else { tr("Custom").into() }); - let name_id = Id::new("cp_preset_name"); + let name_id = Id::unique("cp_preset_name"); let mut naming: Option = ui.data(|d| d.get_temp(name_id)); let mut action = None; ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index d33c81ee..468836de 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -476,7 +476,7 @@ pub fn start_draft(app: &mut FigApp, x: f64, y: f64) { /// The reply field's id for a thread. fn reply_id(thread: u64) -> Id { - Id::new(("comment_reply", thread)) + Id::unique(("comment_reply", thread)) } fn set_cursor(ctx: &egui::Context, id: Id, at: usize) { @@ -650,7 +650,7 @@ fn message( let t = Tokens::get(ui.ctx()); let ctx = ui.ctx().clone(); let id = cx.c.id; - let hover_key = Id::new(("comment_msg_hover", id, i)); + let hover_key = Id::unique(("comment_msg_hover", id, i)); let hovered = ctx.data(|d| d.get_temp::(hover_key)).unwrap_or(false); let indent = 36.0; let block = ui.scope(|ui| { @@ -663,10 +663,10 @@ fn message( ui.label(egui::RichText::new(when).font(regular(11.0)).color(t.text_tertiary)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if m.author == cx.me { - let menu = Id::new(("comment_msg_more_menu", id, i)); + let menu = Id::unique(("comment_msg_more_menu", id, i)); let more = hover_button( ui, - Id::new(("comment_msg_more", id, i)), + Id::unique(("comment_msg_more", id, i)), Icon::Dots, hovered || egui::Popup::is_id_open(&ctx, menu), tr("More"), @@ -682,10 +682,10 @@ fn message( } }); } - let menu = Id::new(("comment_msg_react_menu", id, i)); + let menu = Id::unique(("comment_msg_react_menu", id, i)); let react = hover_button( ui, - Id::new(("comment_msg_react", id, i)), + Id::unique(("comment_msg_react", id, i)), Icon::Reaction, hovered || egui::Popup::is_id_open(&ctx, menu), tr("Add reaction"), @@ -715,7 +715,7 @@ fn message( ui.horizontal(|ui| { ui.add_space(indent); ui.vertical(|ui| { - if composer(ui, Id::new(("comment_edit", id, i)), text, tr("Edit comment"), cx.people, cx.me, pick) { + if composer(ui, Id::unique(("comment_edit", id, i)), text, tr("Edit comment"), cx.people, cx.me, pick) { let mut p = message_params(id, i); p["text"] = json!(text.trim()); *act = Some(Act::Run("comment.edit", p)); @@ -828,12 +828,12 @@ pub fn popovers(app: &mut FigApp, ctx: &egui::Context) { let me = me(app); let mut pick = std::mem::take(&mut app.ui.comments.mention); let mut post = false; - egui::Area::new(Id::new("comment_draft")).order(egui::Order::Foreground).fixed_pos(popover_pos(app.to_screen(x, y), canvas)).show( + egui::Area::new(Id::unique("comment_draft")).order(egui::Order::Foreground).fixed_pos(popover_pos(app.to_screen(x, y), canvas)).show( ctx, |ui| { popover_frame(&t).show(ui, |ui| { ui.set_width(POPOVER_W - 24.0); - post = composer(ui, Id::new("comment_draft_text"), &mut text, tr("Add a comment"), &people, &me, &mut pick); + post = composer(ui, Id::unique("comment_draft_text"), &mut text, tr("Add a comment"), &people, &me, &mut pick); }); }, ); @@ -877,7 +877,7 @@ pub fn thread(app: &mut FigApp, ctx: &egui::Context, id: u64, anchor: Pos2, boun let mut pick = std::mem::take(&mut app.ui.comments.mention); let mut act: Option = None; let cx = ThreadCtx { c: &c, me: &me, people: &people }; - egui::Area::new(Id::new("comment_thread")).order(egui::Order::Foreground).fixed_pos(popover_pos(anchor, bounds)).show(ctx, |ui| { + egui::Area::new(Id::unique("comment_thread")).order(egui::Order::Foreground).fixed_pos(popover_pos(anchor, bounds)).show(ctx, |ui| { popover_frame(&t).show(ui, |ui| { ui.set_width(POPOVER_W - 24.0); // Header: resolve, more, close. @@ -934,7 +934,7 @@ pub fn thread(app: &mut FigApp, ctx: &egui::Context, id: u64, anchor: Pos2, boun } Some(Act::CopyLink) => { if let Some(link) = app.run("comment.link", json!({"id": id})).and_then(|v| v.get("link").and_then(Value::as_str).map(str::to_string)) { - app.services.clipboard_set_text(&link); + app.clipboard_set_text(&link); app.toast(tr("Link copied")); } } @@ -1051,7 +1051,7 @@ pub fn panel(app: &mut FigApp, ui: &mut Ui) { ui.horizontal(|ui| { ui.add_space(16.0); let w = ui.available_width() - 16.0; - if let Some(s) = crate::widgets::text_field(ui, Id::new("comment_search"), &app.ui.comments.search, w, tr("Search comments")) { + if let Some(s) = crate::widgets::text_field(ui, Id::unique("comment_search"), &app.ui.comments.search, w, tr("Search comments")) { app.ui.comments.search = s; } }); @@ -1178,7 +1178,7 @@ pub fn presenter(app: &mut FigApp, ctx: &egui::Context, frame: NodeId, place: cr let mut clicked = None; for p in &pins { let r = pin_rect(p.at); - egui::Area::new(Id::new(("present_comment_pin", p.id))).order(egui::Order::Foreground).fixed_pos(r.min).show(ctx, |ui| { + egui::Area::new(Id::unique(("present_comment_pin", p.id))).order(egui::Order::Foreground).fixed_pos(r.min).show(ctx, |ui| { let (rr, resp) = ui.allocate_exact_size(r.size(), Sense::click()); paint_pin(ui.painter(), rr, p, open == Some(p.id) || resp.hovered()); if resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { diff --git a/crates/ui-egui/src/component_panel.rs b/crates/ui-egui/src/component_panel.rs index 6caab6d5..06a0385c 100644 --- a/crates/ui-egui/src/component_panel.rs +++ b/crates/ui-egui/src/component_panel.rs @@ -79,7 +79,8 @@ fn combine_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { fn description_ui(app: &mut FigApp, ui: &mut Ui, id: NodeId, description: &str) { widgets::separator(ui); widgets::sublabels(ui, tr("Description"), None); - if let Some(new) = widgets::multiline_field(ui, Id::new(("comp_desc", id)), description, tr("Describe how and when to use this component"), 3) { + if let Some(new) = widgets::multiline_field(ui, Id::unique(("comp_desc", id)), description, tr("Describe how and when to use this component"), 3) + { app.run("node.set", json!({"id": id, "props": {"description": new.chars().take(10_000).collect::()}})); } ui.add_space(8.0); @@ -91,7 +92,7 @@ fn description_ui(app: &mut FigApp, ui: &mut Ui, id: NodeId, description: &str) pub(crate) fn local_components(app: &FigApp, ui: &Ui, doc: &Document) -> std::sync::Arc> { type Cached = ((u64, u64), std::sync::Arc>); let key = app.session.ds().map(|d| (d.uid, d.revision)).unwrap_or_default(); - let id = Id::new("component_panel_local_components"); + let id = Id::unique("component_panel_local_components"); if let Some((k, list)) = ui.data(|d| d.get_temp::(id)) && k == key { @@ -267,7 +268,7 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { let header = row_rect(ui, 32.0, |ui| { let (ir, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover()); icons::paint(ui.painter(), ir, Icon::ComponentSet, COMPONENT); - if let Some(nn) = widgets::text_field(ui, Id::new(("vprop_name", owner, k.as_str())), k, 110.0, tr("Name")) { + if let Some(nn) = widgets::text_field(ui, Id::unique(("vprop_name", owner, k.as_str())), k, 110.0, tr("Name")) { app.run("component.editProperty", json!({"id": owner, "name": k, "newName": nn})); } let def = defaults.get(k).or(vals.first()).cloned().unwrap_or_default(); @@ -292,7 +293,7 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { row(ui, 30.0, |ui| { ui.add_space(22.0); for v in &chunk { - if let Some(nv) = widgets::text_field(ui, Id::new(("vprop_val", owner, k.as_str(), v.as_str())), v, 62.0, "") + if let Some(nv) = widgets::text_field(ui, Id::unique(("vprop_val", owner, k.as_str(), v.as_str())), v, 62.0, "") && nv != *v { app.run("component.editProperty", json!({"id": owner, "name": k, "value": v, "newValue": nv})); @@ -320,7 +321,7 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { row(ui, 32.0, |ui| { let (ir, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover()); icons::paint(ui.painter(), ir, type_icon(d), COMPONENT); - if let Some(nn) = widgets::text_field(ui, Id::new(("prop_name", owner, k.as_str())), k, 96.0, tr("Name")) { + if let Some(nn) = widgets::text_field(ui, Id::unique(("prop_name", owner, k.as_str())), k, 96.0, tr("Name")) { app.run("component.editProperty", json!({"id": owner, "name": k, "newName": nn})); } match d { @@ -330,7 +331,7 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { } } PropDef::Text { default } => { - if let Some(v) = widgets::text_field(ui, Id::new(("prop_def", owner, k.as_str())), default, 84.0, tr("Default")) { + if let Some(v) = widgets::text_field(ui, Id::unique(("prop_def", owner, k.as_str())), default, 84.0, tr("Default")) { app.run("component.editProperty", json!({"id": owner, "name": k, "default": v})); } } @@ -701,7 +702,7 @@ fn instance_props(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node, comps PropDef::Text { .. } => { crate::variable_ui::bindable(app, ui, &[id], &bind_field, VariableType::String, w, |app, ui| { let cur = v.as_str().unwrap_or_default().to_string(); - if let Some(ns) = widgets::text_field(ui, Id::new(("iprop", id, k.as_str())), &cur, w, "") + if let Some(ns) = widgets::text_field(ui, Id::unique(("iprop", id, k.as_str())), &cur, w, "") && ns != cur { app.run("instance.setProperties", json!({"ids": [id], "properties": {k.clone(): ns}})); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 4629b22c..97e62d1f 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1,6 +1,7 @@ //! The control channel's UI-thread side. The app crate accepts JSON lines on a localhost socket -//! and queues [`ControlRequest`]s; [`drain`] runs them at the start of a frame so replies reflect -//! the real UI. Methods: +//! and queues [`ControlRequest`]s. [`background`] runs the phase-safe part from every logic +//! callback; [`drain`] admits and answers the UI-only work inside a real frame so replies reflect +//! the drawn UI. Methods: //! //! - `engine.execute {command, params}` — run any command (no dialogs open) //! - `engine.commands {}` — every command with its params doc @@ -16,8 +17,9 @@ //! prototype playing (presenter or preview pane) //! - `ui.menu.invoke {id}` — a menu item exactly like clicking it (may open a dialog) //! - `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeSearch?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, focusRegion?, preview?, presentScroll?, …}` -//! - `ui.screenshot {path?}` — PNG of the window (needs a visible window), written to `path` or -//! returned as base64 `data` +//! - `ui.screenshot {path?}` — PNG of the window, written to `path` or returned as base64 `data`; +//! a hidden window is asked to paint (`RequestPaintWhileHidden`) and the request expires once at +//! its deadline (native; the shared web runner does not implement hidden paint yet) //! - `ui.render {path?, scale?, ids?}` — PNG of the document (no UI chrome; works without a //! window), written to `path` or returned as base64 `data` //! - `window.new {path?, index?}` → `{window}` / `window.list {}` / `window.focus {id}` — several @@ -27,6 +29,8 @@ //! Any method takes `window` (an id from `window.list`) to run in that window, with its tabs and //! panels; without it, requests go to the window last in front. +use std::sync::{Arc, Mutex}; + use egui::Context; use figcraft_engine::cmd::comment::{Filter, Sort}; use figcraft_engine::{PointerEvent, PointerKind}; @@ -39,125 +43,436 @@ pub struct ControlReply { pub result: Result, } +/// Which side has finished a control request. +/// +/// The transport and the UI race to settle one request: while it is `Open`, either may cancel it +/// (the transport's timeout) or commit it (the UI's final reply). Once settled, neither side may +/// change it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum RequestState { + #[default] + Open, + /// The transport stopped waiting: no file may be written and no reply is wanted. + Cancelled, + /// The UI wrote the file (if any) and queued the reply before releasing the gate. + Replied, +} + +/// The gate the transport's timeout and the UI's final reply share for one request. +/// +/// Encoding happens outside it, so a slow PNG never blocks the timeout. The final file write and +/// reply enqueue happen under it, after rechecking cancellation and the deadline, so a timeout +/// cannot overtake a committed file and a cancellation cannot slip between the check and the +/// write. It is never held across socket I/O. +#[derive(Clone, Default)] +pub struct RequestGate { + state: Arc>, +} + +impl RequestGate { + /// Create a request gate in its default `Open` state. + pub fn new() -> Self { + Self::default() + } + + /// Return a copy of the gate's current request state. + pub fn state(&self) -> RequestState { + *self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// Whether the gate is `Cancelled`. + pub fn is_cancelled(&self) -> bool { + self.state() == RequestState::Cancelled + } + + /// Whether the gate is `Replied`. + pub fn is_replied(&self) -> bool { + self.state() == RequestState::Replied + } + + /// Cancel an open request, as the transport's timeout does. Returns the resulting state so a + /// timeout that lost the race knows to consume the already-queued reply. + pub fn cancel_if_open(&self) -> RequestState { + let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if *state == RequestState::Open { + *state = RequestState::Cancelled; + } + *state + } + + /// Run the final commit (file write and reply enqueue) only while the request is still open, + /// then mark it replied before releasing the gate. `None` means the transport cancelled it or + /// it was already committed; the closure must not have run then. + pub fn commit(&self, finish: impl FnOnce() -> T) -> Option { + let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if *state != RequestState::Open { + return None; + } + let value = finish(); + *state = RequestState::Replied; + Some(value) + } +} + pub struct ControlRequest { pub id: Value, pub method: String, pub params: Value, pub reply: std::sync::mpsc::Sender, - /// The transport stops waiting after its timeout; discard requests still queued then. - pub cancelled: std::sync::Arc, + /// The transport stops waiting after its timeout; discard requests still queued then. The UI + /// passes the same gate to its screenshot's final commit. + pub state: RequestGate, } impl ControlRequest { + /// Whether this request's gate is `Cancelled`. pub fn is_cancelled(&self) -> bool { - self.cancelled.load(std::sync::atomic::Ordering::Relaxed) + self.state.is_cancelled() } } -/// Pending screenshot replies. -pub struct PendingShot { +/// One screenshot's completion slot. +/// +/// The renderer's one-shot callback only stores the raw image here and wakes the captured +/// viewport; it never encodes or writes a file, and it holds only a [`std::sync::Weak`] reference, so a late +/// capture after the request (or the app) is gone is a no-op. The live request owns the only +/// strong reference, together with the viewport `Context` clone used for the wakeup. +pub struct CaptureSlot { + inner: Mutex, + ctx: Context, + viewport: egui::ViewportId, +} + +#[derive(Default)] +struct SlotInner { + image: Option>, + closed: bool, + /// The one image was already handed to a frame (a second callback cannot store another). + taken: bool, +} + +impl CaptureSlot { + /// Create an empty capture slot for `viewport`, holding a clone of the UI context. + fn new(ctx: &Context, viewport: egui::ViewportId) -> Arc { + Arc::new(Self { inner: Mutex::new(SlotInner::default()), ctx: ctx.clone(), viewport }) + } + + /// The one-shot callback for `ViewportCommand::Screenshot`. + fn callback(slot: &Arc) -> egui::ScreenshotCallback { + let weak = Arc::downgrade(slot); + egui::ScreenshotCallback::new(move |image| { + if let Some(slot) = weak.upgrade() { + slot.complete(image); + } + }) + } + + /// Store the first delivered image only while the slot is open and untaken, ignoring later + /// or closed deliveries, then wake the viewport after releasing the lock. + pub(crate) fn complete(&self, image: Arc) { + { + let mut inner = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if inner.closed || inner.image.is_some() || inner.taken { + return; + } + inner.image = Some(image); + } + // The lock is released before waking: the wakeup may draw the frame that drains this slot. + self.ctx.request_repaint_of(self.viewport); + } + + /// The captured image, once: a second callback or drain finds the slot taken. + fn take(&self) -> Option> { + let mut inner = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if inner.taken { + return None; + } + let image = inner.image.take(); + inner.taken = image.is_some(); + image + } + + /// Whether the renderer delivered the image. A hidden window's frame must be asked to paint + /// again to drain it: the capture command's own paint was the one that produced it. + fn has_result(&self) -> bool { + self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner).image.is_some() + } + + /// Forget a cancelled, timed-out or superseded request: a late callback finds it closed. + fn close(&self) { + let mut inner = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + inner.closed = true; + inner.image = None; + } +} + +/// A screenshot's capture-time geometry (the viewport surface and the scale it was admitted at). +/// Pixels are derived from these dimensions, never from a stale `ViewportInfo::inner_rect` or a +/// previous pass's viewport rectangle. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CaptureSpec { + pub viewport: egui::ViewportId, + /// Expected readback pixels. + pub size_px: [usize; 2], + pub pixels_per_point: f32, + pub zoom: f32, +} + +/// Slack allowed for PNG headers and chunk framing past the raw pixel bytes. +const SHOT_PNG_OVERHEAD: usize = 64 * 1024; + +impl CaptureSpec { + /// Return the checked pixel byte count (`width × height × 4`), or `None` on overflow. + fn bytes(&self) -> Option { + self.size_px[0].checked_mul(self.size_px[1])?.checked_mul(4) + } + + /// The bytes this capture may hold at its peak. + /// + /// `shot_reply` first holds the readback image, the straight-RGBA copy handed to the encoder + /// and the PNG being built (two raw buffers plus the PNG), then drops the copy and builds the + /// base64 reply from the PNG (readback plus PNG plus four thirds of the PNG). The budget is the + /// larger of those phases, never a sum of all four buffers at once. The reservation is a bound, + /// not an estimate of the encoded size; `shot_reply` enforces the reply side of it and must + /// drop the straight-RGBA copy before encoding base64. + fn cost(&self) -> Option { + let raw = self.bytes()?; + let png = raw.checked_add(SHOT_PNG_OVERHEAD)?; + let encode_phase = raw.checked_mul(2)?.checked_add(png)?; + let reply = png.checked_div(3)?.checked_add(1)?.checked_mul(4)?; + let reply_phase = raw.checked_add(png)?.checked_add(reply)?; + Some(encode_phase.max(reply_phase)) + } +} + +/// One admitted screenshot: its deadline, capture identity and completion slot. +pub struct ShotRequest { pub id: Value, /// Where to write the PNG; `None`: return it as data. pub path: Option, pub reply: std::sync::mpsc::Sender, - /// When it was asked for (UI clock, seconds). - pub since: f64, - /// The window it captures. + /// The transport's timeout and this request's final write share this gate. + pub state: RequestGate, + /// Monotonic seconds ([`crate::FrameClock`]); covers admission, settling, retries and arrival. + pub deadline: f64, pub viewport: egui::ViewportId, + /// The window whose document generation the capture belongs to. + pub window: u64, + /// The document UID shown when the request was admitted. + pub document: u64, + /// That presentation's monotonic generation (the UID alone cannot tell A→B→A apart). + pub presentation: u64, + pub spec: Option, + /// The geometry epoch at issuance; a resize/DPI/zoom change rejects the pixels. + pub epoch: u64, + /// Completed, presented UI frames since admission (hidden logic ticks never count; a + /// discarded sizing pass inside a frame does not count separately from that frame's final + /// presented pass). + pub settle: u8, + /// The last completed-frame number counted into `settle`; `None` until the first observed + /// pass anchors the count. A frame is counted only once it has completed, so its first pass + /// is never treated as settled before the frame has presented a layout. + pub settle_frame: Option, + pub issued: bool, + pub slot: Option>, + /// Image bytes reserved for this request (in flight or completed but undrained). + pub reserved: usize, + pub retries: u8, +} + +impl ShotRequest { + /// Close and drop the capture slot, if any, and zero the reserved byte count. + fn release(&mut self) { + if let Some(slot) = self.slot.take() { + slot.close(); + } + self.reserved = 0; + } +} + +/// Screenshot state owned by the app: pending requests and the geometry epoch they were admitted +/// against. Pending and completed-but-undrained images both count toward the budget. +#[derive(Default)] +pub struct CaptureState { + pub(crate) shots: Vec, + /// The last UI geometry of each viewport and the epoch it defines (a resize, DPI change or + /// zoom change — even back — bumps it). Per viewport: one window's frames must not invalidate + /// another's captures. + pub(crate) geometry: std::collections::HashMap, + pub(crate) epoch: u64, } /// How long a screenshot waits for the window to deliver it (the control server gives up then too). -const SHOT_TIMEOUT: f64 = 60.0; +pub(crate) const SHOT_TIMEOUT: f64 = 60.0; -fn pending(ctx: &Context) -> std::sync::Arc>> { - ctx.data_mut(|d| d.get_temp_mut_or_insert_with(egui::Id::new("pending_shots"), || std::sync::Arc::new(std::sync::Mutex::new(Vec::new()))).clone()) +/// Completed real UI frames a capture waits for before it is issued, so fonts, layout and the +/// renderer have settled (hidden logic ticks do not count; a frame counts once, after presenting +/// its final pass). +const SHOT_SETTLE_PASSES: u8 = 3; + +/// The suite's capture budget, counted over in-flight and completed-but-undrained images. +pub(crate) const MAX_SHOTS: usize = 4; +const MAX_SHOT_IMAGE_BYTES: usize = 128 * 1024 * 1024; +const MAX_SHOT_TOTAL_BYTES: usize = 256 * 1024 * 1024; +/// A PNG larger than this is refused before its base64 reply is built (a PNG cannot normally grow +/// past its raw pixels, so only pathological content reaches it). +const MAX_SHOT_REPLY_BYTES: usize = 192 * 1024 * 1024; + +/// The capture geometry of the UI pass drawing now, if the surface size is known. The screenshot +/// is the whole viewport surface (a notch's area is still read back), and pixel bounds are rounded +/// up: a fractional DPI must not turn a valid surface into a one-pixel mismatch. +fn current_spec(ctx: &Context, viewport: egui::ViewportId) -> Option { + let surface = ctx.viewport_rect(); + let rect = if surface.width() >= 1.0 && surface.height() >= 1.0 { surface } else { ctx.content_rect() }; + let ppp = ctx.pixels_per_point(); + let zoom = ctx.zoom_factor(); + if !(rect.width().is_finite() && rect.height().is_finite() && ppp.is_finite() && zoom.is_finite()) { + return None; + } + let w = (rect.width() * ppp).ceil(); + let h = (rect.height() * ppp).ceil(); + if w < 1.0 || h < 1.0 || w > 32768.0 || h > 32768.0 { + return None; + } + Some(CaptureSpec { viewport, size_px: [w as usize, h as usize], pixels_per_point: ppp, zoom }) } -pub fn drain(app: &mut FigApp, ctx: &Context) { - let now = ctx.input(|i| i.time); - let list = pending(ctx); - // Finish screenshots that arrived (a window's capture can arrive in any window's input). - let shots: Vec<(egui::ViewportId, std::sync::Arc)> = ctx.input(|i| { - i.events - .iter() - .filter_map(|e| match e { - egui::Event::Screenshot { viewport_id, image, .. } => Some((*viewport_id, image.clone())), - _ => None, - }) - .collect() - }); - for (vp, img) in &shots { - list.lock().unwrap_or_else(std::sync::PoisonError::into_inner).retain(|p| { - if p.viewport != *vp { - return true; - } - let res = shot_reply(img, p.path.as_deref()); - let _ = p.reply.send(ControlReply { id: p.id.clone(), result: res }); - false - }); +/// Send one reply carrying the request id, ignoring a disconnected receiver. +fn reply(r: &mut ShotRequest, result: Result) { + let _ = r.reply.send(ControlReply { id: r.id.clone(), result }); +} + +/// Admit a screenshot request. Geometry is filled in on the next UI pass; until then the request +/// is only counted, so a hidden window's capture can wait for its forced paint without inventing +/// geometry from a stale pass. +fn admit(app: &mut FigApp, r: ControlRequest, window: u64, now: f64) { + let canceled = r.is_cancelled(); + let ControlRequest { id, method: _, params, reply: tx, state } = r; + if app.capture.shots.len() >= MAX_SHOTS { + if !canceled { + let _ = tx.send(ControlReply { id, result: Err(format!("too many screenshots are pending (at most {MAX_SHOTS})")) }); + } + return; } - let reqs = std::mem::take(&mut app.control_queue); - for r in reqs { - if r.is_cancelled() { - continue; - } - // Requests for another window wait for its frame. - let target = r.params.get("window").and_then(Value::as_u64).unwrap_or(app.windows.focused); - if target != app.windows.current { - if app.windows.exists(target) { - ctx.request_repaint_of(crate::windows::viewport(target)); - app.control_queue.push(r); - } else { - let _ = r.reply.send(ControlReply { id: r.id, result: Err(format!("no window {target}")) }); - } - continue; - } - if r.method == "ui.screenshot" { - let path = r.params.get("path").and_then(Value::as_str).map(str::to_string); - let viewport = ctx.viewport_id(); - list.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(PendingShot { id: r.id, path, reply: r.reply, since: now, viewport }); - ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default())); - continue; - } - let res = handle(app, ctx, &r.method, &r.params); - let _ = r.reply.send(ControlReply { id: r.id, result: res }); - ctx.request_repaint(); - } - // The window delivers a screenshot some frames after it was asked for (once the GPU has copied - // it back), and only while frames are drawn: keep drawing until it arrives (a hidden window - // never delivers; its requests expire). - let mut list = list.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - list.retain(|p| { - let live = now - p.since < SHOT_TIMEOUT; - if !live { - let _ = p.reply.send(ControlReply { id: p.id.clone(), result: Err("the window didn't deliver a screenshot (is it visible?)".into()) }); - } - live + let path = params.get("path").and_then(Value::as_str).map(str::to_string); + let (document, presentation) = app.windows.presentation(&app.session, window); + app.capture.shots.push(ShotRequest { + id, + path, + reply: tx, + state, + deadline: now + SHOT_TIMEOUT, + viewport: crate::windows::viewport(window), + window, + document, + presentation, + spec: None, + epoch: app.capture.epoch, + settle: 0, + settle_frame: None, + issued: false, + slot: None, + reserved: 0, + retries: 0, }); - if !list.is_empty() { - ctx.request_repaint(); +} + +/// Expire requests whose whole lifetime (admission, settling, retries, arrival) is over, exactly +/// once, and release their image reservation. +fn expire(app: &mut FigApp, ctx: &Context, now: f64) { + let mut i = 0; + while i < app.capture.shots.len() { + let (deadline, cancelled, window) = { + let r = &app.capture.shots[i]; + (r.deadline, r.state.is_cancelled(), r.window) + }; + let closed = !app.windows.exists(window); + if now >= deadline || cancelled || closed { + let mut r = app.capture.shots.remove(i); + r.release(); + if !cancelled { + let why = if closed { + "the window closed before the screenshot was captured" + } else { + "the window didn't deliver a screenshot (is it visible?)" + }; + reply(&mut r, Err(why.into())); + } + ctx.request_repaint(); + } else { + i += 1; + } } } -/// Methods only a drawn frame can answer: a capture of the window, raw window input and a new -/// window (the main window's frame opens it). Everything else acts on the app's state, which a -/// hidden window answers without a frame (#269, #720). -const NEEDS_FRAME: &[&str] = &["ui.screenshot", "ui.input", "window.new"]; +/// Whether the platform reports window `id` as visible; `None` means it has not said (a +/// minimised window can report no `visible` flag at all). +fn visibility(ctx: &Context, id: u64) -> Option { + ctx.input(|i| { + let info = i.raw.viewports.get(&crate::windows::viewport(id))?; + // Some platforms report `visible: true` for a minimised window; `minimized` is the more + // specific signal there. No information at all stays `None` (unknown), never visible. + if info.minimized == Some(true) { + return Some(false); + } + info.visible() + }) +} /// Whether window `id` draws no frames now: minimised, or covered by other windows (on macOS also /// on another Space). fn hidden(ctx: &Context, id: u64) -> bool { - ctx.input(|i| i.raw.viewports.get(&crate::windows::viewport(id)).and_then(egui::ViewportInfo::visible)) == Some(false) + visibility(ctx, id) == Some(false) } -/// A hidden window (minimised, or covered by other windows) draws no frames, but the desktop app's -/// `logic` still runs, for the main window even while every window is hidden: it answers the -/// requests for any hidden window, in that window's file, so an attached agent can edit, inspect, -/// type and save while the app is out of sight. What needs a frame ([`NEEDS_FRAME`]) is an error at -/// once instead of waiting for the window to show. A request for a window on screen waits for its -/// frame; another window on screen is asked to draw, which the main window's frame would do but -/// doesn't while it is hidden. A request for a window that doesn't exist is an error at once. -pub fn drain_hidden(app: &mut FigApp, ctx: &Context) { +/// Send `RequestPaintWhileHidden` for work that needs a real UI pass (a queued capture or +/// synthetic input), and schedule a wakeup for the next capture deadline. Native eframe honours +/// this command; the shared web runner does not implement it yet, so a hidden browser tab remains +/// an explicit, recorded qualification gap (see `docs/egui-037-local-followups.md`). +fn request_paint(app: &FigApp, ctx: &Context) { + for r in &app.capture.shots { + if !app.windows.exists(r.window) { + continue; + } + // A capture that is still settling needs the real UI pass its geometry comes from; one + // whose pixels arrived while the window was hidden needs the pass that drains the slot. + // The capture command's own paint was drained when it produced the image. + if !r.issued || r.slot.as_ref().is_some_and(|s| s.has_result()) { + ctx.send_viewport_cmd_to(r.viewport, egui::ViewportCommand::RequestPaintWhileHidden); + } + } + for r in &app.control_queue { + if r.method == "ui.input" { + let target = r.params.get("window").and_then(Value::as_u64).unwrap_or(app.windows.focused); + if hidden(ctx, target) { + ctx.send_viewport_cmd_to(crate::windows::viewport(target), egui::ViewportCommand::RequestPaintWhileHidden); + } + } + } + // A batch queued for a frame that has not drawn it yet also keeps asking. During a child + // window's pass `app.control_input` is that window's live pane; from the main window's logic + // it is the root's, so the viewport drawing now is the one to wake. + if !app.control_input.is_empty() { + ctx.send_viewport_cmd_to(ctx.viewport_id(), egui::ViewportCommand::RequestPaintWhileHidden); + } + for viewport in app.windows.pending_input_viewports() { + ctx.send_viewport_cmd_to(viewport, egui::ViewportCommand::RequestPaintWhileHidden); + } + // A batch already handed to a hidden window's raw input (a logic-only pass takes it, egui + // saves it for the next pass) still needs the real pass that applies and acknowledges it. + if let Some(viewport) = app.applied_frame { + ctx.send_viewport_cmd_to(viewport, egui::ViewportCommand::RequestPaintWhileHidden); + } + if let Some(d) = app.capture.shots.iter().map(|r| r.deadline).reduce(f64::min) { + crate::repaint_in(ctx, (d - app.clock.now()).clamp(0.0, SHOT_TIMEOUT)); + } +} + +/// Work a logic-only callback can do safely: expire deadlines, admit or park screenshot requests, +/// and answer state-only control requests for hidden windows. Input/geometry are never read here. +pub fn background(app: &mut FigApp, ctx: &Context) { + let now = app.clock.now(); + expire(app, ctx, now); for r in std::mem::take(&mut app.control_queue) { if r.is_cancelled() { continue; @@ -167,18 +482,32 @@ pub fn drain_hidden(app: &mut FigApp, ctx: &Context) { let _ = r.reply.send(ControlReply { id: r.id, result: Err(format!("no window {target}")) }); continue; } - if !hidden(ctx, target) { - // Another window on screen draws only when asked: while the main window is hidden no - // frame of it does that (`drain`), so ask here. - if target != app.windows.current { - ctx.request_repaint_of(crate::windows::viewport(target)); - } + let is_hidden = hidden(ctx, target); + if r.method == "ui.screenshot" && visibility(ctx, target) != Some(true) { + // Hidden, minimised, or with visibility the platform has not reported: admit it now + // so the request gets its deadline and a forced paint. Waiting for a frame that may + // never draw would leave it queued past the transport's own timeout with no reply. + admit(app, r, target, now); + continue; + } + if !is_hidden && target == app.windows.current { + // The window's frame admits and issues it against real geometry. app.control_queue.push(r); continue; } - if NEEDS_FRAME.contains(&r.method.as_str()) { - let error = - format!("window {target} is minimised or hidden: `{}` needs it on screen (`ui.render` draws the document without it)", r.method); + if !is_hidden { + // Another visible window draws only when asked. + ctx.request_repaint_of(crate::windows::viewport(target)); + app.control_queue.push(r); + continue; + } + if r.method == "ui.input" { + // Keep one synthetic batch until a real UI pass consumes it. + app.control_queue.push(r); + continue; + } + if r.method == "window.new" { + let error = format!("window {target} is minimised or hidden: `window.new` needs it on screen"); let _ = r.reply.send(ControlReply { id: r.id, result: Err(error) }); continue; } @@ -186,37 +515,400 @@ pub fn drain_hidden(app: &mut FigApp, ctx: &Context) { // ends in recovery copies and leaves every window its own state. let res = crate::windows::as_window(app, target, |app| { let mut res = Err("internal error".to_string()); + // Every control-channel gesture is fenced from the user's system clipboard. + let before = app.control_synthetic; + app.control_synthetic = true; crate::windows::guard(app, ctx, |app| res = handle(app, ctx, &r.method, &r.params)); + app.control_synthetic = before; res }); let _ = r.reply.send(ControlReply { id: r.id, result: res.unwrap_or_else(|| Err(format!("no window {target}"))) }); } + request_paint(app, ctx); +} + +/// The UI phase's control work: drain completed captures, admit and settle queued screenshots +/// against this pass's geometry, and run state-only requests for the window drawing now. +pub fn drain(app: &mut FigApp, ctx: &Context) { + let now = app.clock.now(); + let viewport = ctx.viewport_id(); + // This pass's geometry defines the epoch: a resize, DPI change or zoom change (back or not) + // invalidates captures issued under the old one. + let (spec, epoch) = pass_geometry(app, ctx, viewport); + expire(app, ctx, now); + + // Drain completed captures first, so their budget is free before new requests are admitted. + drain_completed(app, ctx, viewport, now, epoch); + + // Admit queued captures for the window drawing now; other windows keep waiting for their frame. + admit_queued(app, ctx, viewport, now); + + // Fill in this pass's geometry, enforce the byte budget (a post-callback check alone would + // not bound peak memory), and release anything over it before issuing. + enforce_capture_budget(app, viewport, spec); + + // Settle on completed UI frames only, then issue once. (The screenshot command itself forces + // the paint that delivers it.) The request's document and window were fixed at admission and + // are never rewritten here: a tab switched during settling is an error, not a capture of the + // new one. + // + // `cumulative_frame_nr` counts frames whose final (presented) pass has finished. A frame whose + // first pass calls `request_discard` is therefore counted once, after it presents its layout, + // not when its discarded sizing pass runs. Passes after the first (`current_pass_index > 0`) + // belong to such a frame and never add another count. + settle_and_issue(app, ctx, viewport, now, epoch, spec); + request_paint(app, ctx); +} + +/// Establish this pass's screenshot geometry and epoch: a resize, DPI change or zoom change (even +/// back) bumps the epoch captures issued under the old one were admitted against. +fn pass_geometry(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId) -> (Option, u64) { + let spec = current_spec(ctx, viewport); + if let Some(spec) = spec { + let changed = app.capture.geometry.get(&viewport).is_none_or(|(old, _)| *old != spec); + if changed { + app.capture.epoch = app.capture.epoch.wrapping_add(1); + app.capture.geometry.insert(viewport, (spec, app.capture.epoch)); + } + } + let epoch = app.capture.geometry.get(&viewport).map_or(0, |(_, e)| *e); + (spec, epoch) +} + +/// Take the image delivery for the shot at `index`; `None` when no image has arrived yet. +fn arrived_shot(app: &mut FigApp, index: usize) -> Option<(ShotRequest, Arc)> { + let slot = app.capture.shots.get(index)?.slot.clone()?; + let image = slot.take()?; + let mut r = app.capture.shots.remove(index); + r.release(); + Some((r, image)) +} + +/// Drain images delivered for this viewport: reply, re-queue for a geometry retry, or drop a +/// cancelled capture. Each viewport drains its own captures: their geometry and document differ. +fn drain_completed(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId, now: f64, epoch: u64) { + let mut i = 0; + while i < app.capture.shots.len() { + if app.capture.shots[i].viewport != viewport { + i += 1; + continue; + } + let Some((r, image)) = arrived_shot(app, i) else { + i += 1; + continue; + }; + settle_arrival(app, ctx, viewport, now, epoch, r, &image); + ctx.request_repaint(); + } +} + +/// Whether the pass geometry differs from the one the capture was issued at (even back to an old +/// size): the pixels cannot be accepted for the admitted surface. +fn geometry_moved(r: &ShotRequest, current: CaptureSpec, epoch: u64) -> bool { + r.epoch != epoch || r.spec.is_none_or(|s| s.size_px != current.size_px || s.pixels_per_point != current.pixels_per_point) +} + +/// The admitted surface is in whole UI points; the readback is in physical pixels. Up to one +/// pixel of rounding either way is the same surface, not a scale change. The difference is +/// absolute: an undersized readback is a scale mismatch too. +fn scale_mismatch(image: &egui::ColorImage, spec: CaptureSpec) -> bool { + image.size[0].abs_diff(spec.size_px[0]) > 1 || image.size[1].abs_diff(spec.size_px[1]) > 1 +} + +/// Re-queue a capture against the new pass, or hand it back when its retries are exhausted. +fn requeue_capture(app: &mut FigApp, mut r: ShotRequest, current: CaptureSpec, epoch: u64) -> Option { + if r.retries >= 2 { + return Some(r); + } + r.retries += 1; + r.spec = Some(current); + r.epoch = epoch; + r.reserved = r.spec.and_then(|s| s.cost()).unwrap_or(0); + r.settle = 0; + r.settle_frame = None; + r.issued = false; + app.capture.shots.push(r); + None +} + +/// Settle an arrived image: reply with it, retry against the new pass on a geometry/scale change, +/// or drop it when the transport cancelled or the window/document moved on. +fn settle_arrival(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId, now: f64, epoch: u64, mut r: ShotRequest, image: &egui::ColorImage) { + let cancelled = r.state.is_cancelled(); + let current = current_spec(ctx, viewport); + let (document, presentation) = app.windows.presentation(&app.session, r.window); + if cancelled { + // The transport stopped waiting: no reply, no file. + return; + } + if now >= r.deadline { + reply(&mut r, Err("the window didn't deliver a screenshot in time (is it visible?)".into())); + return; + } + if r.window != app.windows.current || r.document != document || r.presentation != presentation { + reply(&mut r, Err("the document changed while the screenshot was being captured".into())); + return; + } + let Some(current) = current else { + // Fail closed: with no representable current geometry the pixels cannot be proven to + // belong to the surface that was admitted, so they are never the capture's reply. + reply(&mut r, Err("the window's current geometry is unavailable (is it minimised?)".into())); + return; + }; + if geometry_moved(&r, current, epoch) { + // Geometry moved between issuance and arrival (even back): retry against the new pass, + // never accept the old pixels. Document changes are errors above. + if let Some(mut r) = requeue_capture(app, r, current, epoch) { + reply(&mut r, Err("the window kept changing size while the screenshot was captured".into())); + } + return; + } + if let Some(spec) = r.spec + && scale_mismatch(image, spec) + { + if let Some(mut r) = requeue_capture(app, r, current, epoch) { + reply( + &mut r, + Err(format!( + "the screenshot is {}x{} but the window is {}x{} (a scale mismatch)", + image.size[0], image.size[1], spec.size_px[0], spec.size_px[1] + )), + ); + } + return; + } + // Encode outside the shared gate: a slow PNG must not hold the transport's timeout. The + // fresh clock read and final write happen under the gate, so a timeout that arrives while the + // image is being encoded either wins (no file, no reply) or finds the committed reply. + let encoded = encode_shot(image, r.path.as_deref()); + commit_shot(&r.state, &r.id, &r.reply, r.deadline, &app.clock, encoded); +} + +/// Admit queued captures for the window drawing now; other windows keep waiting for their frame. +fn admit_queued(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId, now: f64) { + for r in std::mem::take(&mut app.control_queue) { + route_queued(app, ctx, viewport, now, r); + } +} + +/// Route one queued control request: retarget another window, admit a capture, or run a +/// state-only request for the window drawing now. +fn route_queued(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId, now: f64, r: ControlRequest) { + if r.is_cancelled() { + return; + } + let target = r.params.get("window").and_then(Value::as_u64).unwrap_or(app.windows.focused); + if target != app.windows.current { + if app.windows.exists(target) { + ctx.request_repaint_of(crate::windows::viewport(target)); + app.control_queue.push(r); + } else { + let _ = r.reply.send(ControlReply { id: r.id, result: Err(format!("no window {target}")) }); + } + return; + } + if r.method == "ui.screenshot" { + admit(app, r, target, now); + return; + } + // Every control-channel gesture is fenced from the user's system clipboard (the flag is + // restored so a batch already in a frame's raw input keeps its own provenance). + let before = app.control_synthetic; + app.control_synthetic = true; + let res = handle(app, ctx, &r.method, &r.params); + app.control_synthetic = before; + if r.method == "ui.input" && std::mem::take(&mut app.control_input_queued) { + // Acknowledge once the pass that applies the batch is presented, never from a sizing + // pass whose input is thrown away. + app.input_acks.push(crate::PendingAck { viewport, tx: r.reply, reply: ControlReply { id: r.id, result: res } }); + } else { + let _ = r.reply.send(ControlReply { id: r.id, result: res }); + } + ctx.request_repaint(); +} + +/// Reserve this request's actual surface against the capture budget; `true` when it must be +/// removed for exceeding it. +fn reserve_for_spec(r: &mut ShotRequest, spec: Option, total: usize) -> bool { + if !r.issued && r.spec != spec { + // A request can resize during its settling frames. Reserve and validate this + // actual surface before issuance, never the old (possibly much smaller) one. + r.spec = spec; + r.settle = 0; + r.settle_frame = None; + if spec.is_none() { + r.reserved = 0; + } + } + let Some(spec) = r.spec else { return false }; + let raw = spec.bytes().unwrap_or(usize::MAX); + let cost = spec.cost().unwrap_or(usize::MAX); + if raw > MAX_SHOT_IMAGE_BYTES || total.saturating_sub(r.reserved).saturating_add(cost) > MAX_SHOT_TOTAL_BYTES { + reply(r, Err(format!("the screenshot needs {cost} bytes, over the capture budget"))); + return true; + } + if r.reserved != cost { + r.reserved = cost; + } + false +} + +/// Fill in this pass's geometry and enforce the byte budget (a post-callback check alone would +/// not bound peak memory), releasing anything over it before issuing. +fn enforce_capture_budget(app: &mut FigApp, viewport: egui::ViewportId, spec: Option) { + let mut i = 0; + while i < app.capture.shots.len() { + let total: usize = app.capture.shots.iter().map(|s| s.reserved).sum(); + let remove = { + let r = &mut app.capture.shots[i]; + if r.viewport != viewport { + i += 1; + continue; + } + reserve_for_spec(r, spec, total) + }; + if remove { + let mut r = app.capture.shots.remove(i); + r.release(); + } else { + i += 1; + } + } +} + +/// Count one completed frame into the capture's settling, reporting whether it may now be issued. +fn settled_for_issue(r: &mut ShotRequest, frame_nr: u64) -> bool { + match r.settle_frame { + None => { + // Anchor the count at the first pass that sees this request's geometry; this + // frame has not completed yet. + r.settle_frame = Some(frame_nr); + false + } + Some(seen) if frame_nr > seen => { + r.settle_frame = Some(frame_nr); + r.settle = r.settle.saturating_add(1); + r.settle >= SHOT_SETTLE_PASSES + } + _ => false, + } +} + +/// Settle on completed UI frames only, then issue once (against this pass's surface). +fn settle_and_issue(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId, now: f64, epoch: u64, spec: Option) { + let first_pass = ctx.current_pass_index() == 0; + let frame_nr = ctx.cumulative_frame_nr_for(viewport); + for r in &mut app.capture.shots { + if !first_pass || r.viewport != viewport || r.issued || r.spec.is_none() { + continue; + } + if r.state.is_cancelled() || now >= r.deadline { + continue; + } + if !settled_for_issue(r, frame_nr) { + continue; + } + let slot = CaptureSlot::new(ctx, viewport); + r.slot = Some(slot.clone()); + r.epoch = epoch; + // Issued against this pass's surface: the size the readback is expected at. + if let Some(spec) = spec { + r.spec = Some(spec); + } + r.issued = true; + ctx.send_viewport_cmd_to(viewport, egui::ViewportCommand::Screenshot(CaptureSlot::callback(&slot))); + } +} + +/// An encoded screenshot that has not been written or returned yet. Encoding is the expensive +/// part, so it is done before the request's shared commit gate is taken. +enum EncodedShot { + /// The PNG for a `path` request; writing it is part of the final commit. + File { path: String, png: Vec, width: usize, height: usize }, + /// The complete base64 reply for a request without a path. + Data(Value), +} + +impl EncodedShot { + /// Deliver the encoded shot: for `File`, write the PNG through `file_access` and return its + /// path and dimensions; for `Data`, return the prebuilt result. + fn finish(self) -> Result { + match self { + Self::File { path, png, width, height } => { + figcraft_engine::file_access::write(&path, &png).map_err(|e| e.to_string())?; + Ok(json!({"path": path, "width": width, "height": height})) + } + Self::Data(result) => Ok(result), + } + } } /// A screenshot's reply: the PNG written to `path` (through the automation roots, when the app -/// has them), or returned as base64 `data`. +/// has them), or returned as base64 `data`. Control requests split this into [`encode_shot`] and +/// [`commit_shot`]; encoding and automation-root tests use the whole operation. +#[cfg(test)] fn shot_reply(img: &egui::ColorImage, path: Option<&str>) -> Result { + encode_shot(img, path)?.finish() +} + +/// Encode the straight-alpha PNG and the data reply. No request gate is held here. +fn encode_shot(img: &egui::ColorImage, path: Option<&str>) -> Result { let [w, h] = img.size; - let mut rgba = Vec::with_capacity(w * h * 4); + let raw = w.checked_mul(h).and_then(|n| n.checked_mul(4)).ok_or("bad screenshot size")?; + if raw > MAX_SHOT_IMAGE_BYTES { + return Err(format!("the screenshot is {raw} bytes, over the capture budget")); + } + let mut rgba = Vec::with_capacity(raw); for p in &img.pixels { rgba.extend_from_slice(&p.to_srgba_unmultiplied()); } let buf = image::RgbaImage::from_raw(w as u32, h as u32, rgba).ok_or("bad screenshot buffer")?; let mut png = std::io::Cursor::new(Vec::new()); buf.write_to(&mut png, image::ImageFormat::Png).map_err(|e| e.to_string())?; + // The encoder copy is not needed once the PNG exists. Drop it before the (possibly bigger) + // base64 reply is built so the peak is the larger phase accounted by `CaptureSpec::cost`, + // not the sum of all four buffers. + drop(buf); let png = png.into_inner(); match path { - Some(path) => { - figcraft_engine::file_access::write(path, &png).map_err(|e| e.to_string())?; - Ok(json!({"path": path, "width": w, "height": h})) - } + Some(path) => Ok(EncodedShot::File { path: path.to_string(), png, width: w, height: h }), None => { + if png.len() > MAX_SHOT_REPLY_BYTES { + // Refuse before the base64 string (4/3 of the PNG) is allocated. + return Err(format!("the encoded screenshot is {} bytes, over the reply budget", png.len())); + } use base64::Engine as _; - Ok(json!({"format": "png", "width": w, "height": h, "data": base64::engine::general_purpose::STANDARD.encode(&png)})) + Ok(EncodedShot::Data(json!({"format": "png", "width": w, "height": h, "data": base64::engine::general_purpose::STANDARD.encode(&png)}))) } } } +/// Commit an encoded screenshot's file and reply while holding the request's shared gate. The +/// cancellation and a fresh deadline are rechecked under the gate: if the transport gave up while +/// the image was being encoded, no file is written; a commit that gets there first queues its +/// reply before the timeout can mark the request cancelled. +fn commit_shot( + gate: &RequestGate, + id: &Value, + reply: &std::sync::mpsc::Sender, + deadline: f64, + clock: &crate::FrameClock, + encoded: Result, +) -> bool { + gate.commit(|| { + // Read the clock after the gate is held: a request whose deadline passed while it waited + // for the gate must not write its file. + let result = if clock.now() < deadline { + encoded.and_then(EncodedShot::finish) + } else { + Err("the window didn't deliver a screenshot in time (is it visible?)".into()) + }; + // Enqueue before the gate is released and before `Replied` is visible to the transport. + let _ = reply.send(ControlReply { id: id.clone(), result }); + }) + .is_some() +} + /// A controller button given as its standard-layout index, its key trigger name ("Gamepad A") or /// its short name ("A", "LB", "Up"). fn pad_name(v: &Value) -> Option<&'static str> { @@ -238,11 +930,16 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul "engine.execute" => { let cmd = p.get("command").and_then(Value::as_str).ok_or("`command` required")?; let params = p.get("params").cloned().unwrap_or(json!({})); + let layers_generation = app.session.clipboard.layers_generation; let r = app.session.execute(cmd, ¶ms).map_err(|e| e.to_string()); app.clear_comment_draft_after_tool_set(cmd, r.is_ok()); + app.invalidate_synthetic_copy(layers_generation); // Programmatic calls never open dialogs. app.session.ui_requests.retain(|u| !u.kind.starts_with("dialog.")); app.handle_ui_requests(); + // A programmatic `doc.switch` changes the presented file just like a tab click: the + // capture generation must see it even though this path bypasses `FigApp::run`. + app.observe_presentation(); r } "engine.commands" => Ok(Value::Array( @@ -320,8 +1017,9 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul app.session.execute(cmd, ¶ms).map_err(|e| e.to_string()) } _ => { - // Number keys: opacity (two in quick succession make an exact percentage). - let now = ctx.input(|i| i.time); + // Number keys: opacity (two in quick succession make an exact percentage). A + // hidden window's logic tick has a stale `ctx.input(i.time)`. + let now = app.clock.now(); match egui::Key::from_name(name) { Some(k) if m.is_none() && crate::shortcuts::opacity_digit(app, k, now) => Ok(json!({"opacity": true})), _ => Err(format!("no shortcut `{key}`")), @@ -367,6 +1065,7 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul // Content moves by `d` points (negative y scrolls down, like a wheel turned towards you). events.push(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Wheel, delta: d.to_vec2(), phase: egui::TouchPhase::Move, modifiers: mods, @@ -397,7 +1096,7 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul (None, None) => return Err("controller buttons go to a prototype that is playing: run proto.present or proto.preview".into()), } } - app.control_input.extend(events); + app.control_input_queued = app.push_control_input(events); ctx.request_repaint(); Ok(json!({"queued": list.len().min(256)})) } @@ -764,3 +1463,7 @@ fn pointer(app: &mut FigApp, p: &Value) -> Result { #[cfg(test)] #[path = "control_roots_tests.rs"] mod roots_tests; + +#[cfg(test)] +#[path = "control_capture_tests.rs"] +mod capture_tests; diff --git a/crates/ui-egui/src/control_capture_tests.rs b/crates/ui-egui/src/control_capture_tests.rs new file mode 100644 index 00000000..6068ce52 --- /dev/null +++ b/crates/ui-egui/src/control_capture_tests.rs @@ -0,0 +1,657 @@ +//! Screenshot capture (the local completion-slot adapter): per-request identity and reverse +//! completion, cancellation/late delivery, budget admission, straight-alpha round-trips and zero +//! retained bytes after draining. Driven headless through the real UI pass, so geometry comes from +//! the frame that issues the capture, not from a stale logic tick. + +use std::sync::Arc; + +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::control::{CaptureSpec, ControlReply, ControlRequest}; +use crate::layers_drag_tests::Ui; + +/// Queue a `ui.screenshot` request in the test app and return its reply receiver. +fn push(u: &mut Ui, id: u64, params: serde_json::Value) -> std::sync::mpsc::Receiver { + let (tx, rx) = std::sync::mpsc::channel(); + u.app.control_queue.push(ControlRequest { id: json!(id), method: "ui.screenshot".into(), params, reply: tx, state: Default::default() }); + rx +} + +/// Run UI passes until every admitted capture has been issued (settled on real UI passes). +fn settle_to_issue(u: &mut Ui, n: usize) { + for _ in 0..12 { + u.frame(vec![]); + if u.app.capture.shots.len() == n && u.app.capture.shots.iter().all(|r| r.issued) { + return; + } + } + panic!("{n} captures were not issued: {} admitted", u.app.capture.shots.len()); +} + +/// A real changed viewport size, without allocating a readback image of that size. +fn resized_frame(u: &mut Ui, size: egui::Vec2) -> egui::FullOutput { + let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), ..Default::default() }; + let app = &mut u.app; + let mut out = u.ctx.run_ui(input, |ui| app.ui(ui)); + out.textures_delta.clear(); + out +} + +/// Whether any viewport in the frame output issues a `Screenshot` command. +fn has_screenshot(out: &egui::FullOutput) -> bool { + out.viewport_output.values().any(|v| v.commands.iter().any(|c| matches!(c, egui::ViewportCommand::Screenshot(_)))) +} + +/// Asserts reverse capture completion replies only to the matching request, then drains both +/// requests. +#[test] +fn two_captures_correlate_by_request_across_reverse_completion() { + let mut u = Ui::new(Session::empty()); + let first = push(&mut u, 1, json!({})); + let second = push(&mut u, 2, json!({})); + settle_to_issue(&mut u, 2); + let [a, b] = [0, 1].map(|i| u.app.capture.shots[i].spec.expect("geometry")); + assert_eq!(a.viewport, b.viewport); + assert!(a.size_px[0] > 0 && a.size_px[1] > 0); + // The second capture arrives first; only its own request is completed. + let slot_b = u.app.capture.shots[1].slot.clone().expect("issued"); + slot_b.complete(Arc::new(egui::ColorImage::filled(b.size_px, egui::Color32::GREEN))); + u.frame(vec![]); + let got = second.try_recv().expect("the second capture's reply"); + assert_eq!(got.id, json!(2)); + assert!(first.try_recv().is_err(), "the first is still in flight"); + assert_eq!(u.app.capture.shots.len(), 1); + let slot_a = u.app.capture.shots[0].slot.clone().expect("issued"); + slot_a.complete(Arc::new(egui::ColorImage::filled(a.size_px, egui::Color32::RED))); + u.frame(vec![]); + let got = first.try_recv().expect("the first capture's reply"); + assert_eq!(got.id, json!(1)); + assert!(u.app.capture.shots.is_empty(), "no retained requests"); +} + +/// Asserts a cancelled capture gets no reply and its slot is released. +#[test] +fn a_cancelled_capture_never_replies_or_writes() { + let mut u = Ui::new(Session::empty()); + let rx = push(&mut u, 3, json!({})); + settle_to_issue(&mut u, 1); + let spec = u.app.capture.shots[0].spec.expect("geometry"); + let slot = u.app.capture.shots[0].slot.clone().expect("issued"); + u.app.capture.shots[0].state.cancel_if_open(); + slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::BLUE))); + u.frame(vec![]); + assert!(rx.try_recv().is_err(), "a cancelled transport gets no reply"); + assert!(u.app.capture.shots.is_empty(), "the slot is released"); +} + +/// A unique directory for a commit test; the caller removes it. +fn commit_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("figcraft-capture-commit-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir"); + dir +} + +/// Asserts cancellation after encoding still prevents the final file write and reply. +#[test] +fn a_cancellation_after_encoding_prevents_the_final_write() { + let dir = commit_dir("cancelled"); + let path = dir.join("shot.png"); + let gate = crate::control::RequestGate::new(); + let (tx, rx) = std::sync::mpsc::channel(); + let clock = crate::FrameClock::default(); + let img = egui::ColorImage::filled([2, 2], egui::Color32::RED); + // Encoding is complete before the transport cancels: the final gate must still refuse the file. + let encoded = super::encode_shot(&img, Some(path.to_str().expect("utf-8 path"))).expect("encoded"); + assert!(!path.exists(), "encoding alone does not write"); + assert_eq!(gate.cancel_if_open(), crate::control::RequestState::Cancelled); + assert!(!super::commit_shot(&gate, &json!(40), &tx, 100.0, &clock, Ok(encoded)), "a cancelled request does not commit"); + assert!(!path.exists(), "the cancellation landed before the final write"); + assert!(rx.try_recv().is_err(), "a cancelled transport gets no reply"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Asserts a capture that arrives past its deadline reports a timeout and writes no PNG. +#[test] +fn a_real_arrival_that_crosses_the_deadline_does_not_write_its_png() { + let mut u = Ui::new(Session::empty()); + let dir = commit_dir("settle-expired"); + let path = dir.join("shot.png"); + let rx = push(&mut u, 60, json!({"path": path.to_string_lossy()})); + settle_to_issue(&mut u, 1); + let spec = u.app.capture.shots[0].spec.expect("geometry"); + let slot = u.app.capture.shots[0].slot.clone().expect("issued"); + let deadline = u.app.capture.shots[0].deadline; + let epoch = u.app.capture.shots[0].epoch; + slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::RED))); + // A frame's saved time may precede encoding; the final write must use a fresh clock. + let stale_now = u.app.clock.now(); + u.app.clock.set_test_now(Some(deadline + 1.0)); + super::drain_completed(&mut u.app, &u.ctx, egui::ViewportId::ROOT, stale_now, epoch); + let reply = rx.try_recv().expect("the late commit reports a timeout"); + assert!(reply.result.unwrap_err().contains("didn't deliver")); + assert!(!path.exists(), "the final deadline check must precede the PNG write"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Asserts an expiry during encoding reports the timeout under the gate, marks the request +/// replied and writes no file. +#[test] +fn an_expiry_during_encoding_prevents_the_final_write_and_reports_timeout() { + let dir = commit_dir("expired"); + let path = dir.join("shot.png"); + let gate = crate::control::RequestGate::new(); + let (tx, rx) = std::sync::mpsc::channel(); + let mut clock = crate::FrameClock::default(); + clock.set_test_now(Some(5.0)); + let img = egui::ColorImage::filled([2, 2], egui::Color32::RED); + let encoded = super::encode_shot(&img, Some(path.to_str().expect("utf-8 path"))).expect("encoded"); + // The gate is open, but the fresh clock read under it is already at the deadline. + assert!(super::commit_shot(&gate, &json!(41), &tx, 5.0, &clock, Ok(encoded)), "the open request is settled once"); + let reply = rx.try_recv().expect("a timeout reply"); + assert_eq!(reply.id, json!(41)); + assert!(reply.result.unwrap_err().contains("didn't deliver"), "the late commit reports the same timeout"); + assert!(!path.exists(), "an expired request never writes its file"); + assert!(gate.is_replied(), "the timeout reply was committed under the gate"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Asserts a commit before the deadline writes the file and queues one reply, and a second +/// commit is refused. +#[test] +fn a_commit_before_the_deadline_writes_and_queues_its_reply_once() { + let dir = commit_dir("committed"); + let path = dir.join("shot.png"); + let gate = crate::control::RequestGate::new(); + let (tx, rx) = std::sync::mpsc::channel(); + let mut clock = crate::FrameClock::default(); + clock.set_test_now(Some(1.0)); + let img = egui::ColorImage::filled([2, 2], egui::Color32::GREEN); + let encoded = super::encode_shot(&img, Some(path.to_str().expect("utf-8 path"))).expect("encoded"); + assert!(super::commit_shot(&gate, &json!(42), &tx, 10.0, &clock, Ok(encoded)), "an open request commits"); + let reply = rx.try_recv().expect("the committed reply"); + assert_eq!(reply.id, json!(42)); + assert_eq!(reply.result.expect("written")["path"], json!(path.to_string_lossy())); + assert!(path.is_file(), "the file is written under the gate"); + assert!(gate.is_replied(), "a timeout now consumes the committed reply"); + // A second commit is refused: the gate is no longer open. + let again = super::encode_shot(&img, Some(path.to_str().expect("utf-8 path"))).expect("encoded"); + assert!(!super::commit_shot(&gate, &json!(43), &tx, 10.0, &clock, Ok(again))); + assert!(rx.try_recv().is_err(), "no duplicate reply"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Asserts a capture past its deadline sends one timeout reply, releases its slot, and late +/// pixels are ignored. +#[test] +fn a_capture_after_its_deadline_replies_once_and_late_pixels_are_inert() { + let mut u = Ui::new(Session::empty()); + let rx = push(&mut u, 4, json!({})); + settle_to_issue(&mut u, 1); + let spec = u.app.capture.shots[0].spec.expect("geometry"); + let slot = u.app.capture.shots[0].slot.clone().expect("issued"); + u.advance_clock(crate::control::SHOT_TIMEOUT + 1.0); + u.frame(vec![]); + let reply = rx.try_recv().expect("timed out"); + assert_eq!(reply.id, json!(4)); + assert!(reply.result.unwrap_err().contains("didn't deliver"), "one timeout reply"); + assert!(u.app.capture.shots.is_empty(), "released"); + // The renderer finishing afterwards finds a closed slot: no reply, no write, no panic. + slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::RED))); + u.frame(vec![]); + assert!(rx.try_recv().is_err(), "a late capture is inert"); +} + +/// Asserts the snapshot count budget rejects an over-budget request before issuing, and every +/// admitted capture still completes under its own id. +#[test] +fn a_snapshot_batch_is_capped_before_issuing_over_the_count_budget() { + let mut u = Ui::new(Session::empty()); + let mut rxs: Vec<_> = (0..crate::control::MAX_SHOTS as u64).map(|i| push(&mut u, 10 + i, json!({}))).collect(); + settle_to_issue(&mut u, crate::control::MAX_SHOTS); + let over = push(&mut u, 99, json!({})); + u.frame(vec![]); + let reply = over.try_recv().expect("rejected at admission"); + assert!(reply.result.unwrap_err().contains("too many screenshots"), "budget error"); + assert_eq!(u.app.capture.shots.len(), crate::control::MAX_SHOTS); + // Every admitted capture still completes with its own identity. Each drain removes its + // request, so always complete the first remaining one: the vector shifts under the loop. + for (i, rx) in rxs.iter_mut().enumerate() { + let spec = u.app.capture.shots.first().expect("one left").spec.expect("geometry"); + let slot = u.app.capture.shots.first().and_then(|r| r.slot.clone()).expect("issued"); + slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::RED))); + u.frame(vec![]); + assert_eq!(rx.try_recv().expect("answered").id, json!(10 + i as u64), "request {i} keeps its identity"); + } + rxs.clear(); +} + +/// Asserts an undersized readback is not accepted but reissued with one retry and no reply. +#[test] +fn an_undersized_readback_is_reissued_instead_of_accepted() { + let mut u = Ui::new(Session::empty()); + let rx = push(&mut u, 30, json!({})); + settle_to_issue(&mut u, 1); + let spec = u.app.capture.shots[0].spec.expect("geometry"); + let slot = u.app.capture.shots[0].slot.clone().expect("issued"); + assert!(spec.size_px[0] > 2, "room for a two-pixel undersized readback"); + // Smaller by two pixels on one axis: a scale mismatch, not the admitted surface. + slot.complete(Arc::new(egui::ColorImage::filled([spec.size_px[0] - 2, spec.size_px[1]], egui::Color32::RED))); + u.frame(vec![]); + assert!(rx.try_recv().is_err(), "the undersized pixels are not accepted"); + let r = u.app.capture.shots.first().expect("reissued"); + assert_eq!(r.retries, 1, "one retry, not an error"); + assert!(!r.issued, "waits for the settled geometry again"); +} + +/// Asserts a one-pixel rounding difference is accepted as the same surface. +#[test] +fn a_one_pixel_rounding_difference_is_still_the_same_surface() { + let mut u = Ui::new(Session::empty()); + let rx = push(&mut u, 31, json!({})); + settle_to_issue(&mut u, 1); + let spec = u.app.capture.shots[0].spec.expect("geometry"); + let slot = u.app.capture.shots[0].slot.clone().expect("issued"); + slot.complete(Arc::new(egui::ColorImage::filled([spec.size_px[0] - 1, spec.size_px[1] + 1], egui::Color32::RED))); + u.frame(vec![]); + let reply = rx.try_recv().expect("a pixel of rounding is accepted"); + assert_eq!(reply.id, json!(31)); + assert!(reply.result.is_ok()); + assert!(u.app.capture.shots.is_empty(), "released"); +} + +/// Asserts an A→B→A tab switch invalidates the admitted capture with "document changed". +#[test] +fn a_document_a_to_b_to_a_switch_is_not_the_admitted_capture() { + let mut s = Session::empty(); + s.execute("file.new", &json!({"name": "A"})).unwrap(); + s.execute("file.new", &json!({"name": "B"})).unwrap(); + let (a, b) = (s.docs.iter().position(|d| d.doc.name == "A").expect("file A"), s.docs.iter().position(|d| d.doc.name == "B").expect("file B")); + let mut u = Ui::new(s); + u.app.session.active = a; + let rx = push(&mut u, 32, json!({})); + settle_to_issue(&mut u, 1); + let spec = u.app.capture.shots[0].spec.expect("geometry"); + let slot = u.app.capture.shots[0].slot.clone().expect("issued"); + // A→B→A before the callback is drained: the final UID is A again, but its pixels were never + // what this capture asked for. + assert!(u.app.run("doc.switch", json!({"index": b})).is_some()); + assert!(u.app.run("doc.switch", json!({"index": a})).is_some()); + slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::RED))); + u.frame(vec![]); + let reply = rx.try_recv().expect("answered"); + assert!(reply.result.unwrap_err().contains("document changed"), "the A→B→A switch invalidates the capture"); + assert!(u.app.capture.shots.is_empty(), "released"); +} + +/// Asserts a geometry change bumps the capture epoch and reissues instead of accepting old +/// pixels. +#[test] +fn a_geometry_change_reissues_instead_of_accepting_old_pixels() { + let mut u = Ui::new(Session::empty()); + let rx = push(&mut u, 5, json!({})); + settle_to_issue(&mut u, 1); + let old = u.app.capture.shots[0].spec.expect("geometry"); + let slot = u.app.capture.shots[0].slot.clone().expect("issued"); + // A resize between issuance and arrival bumps the epoch. + u.app + .capture + .geometry + .insert(egui::ViewportId::ROOT, (CaptureSpec { size_px: [old.size_px[0] + 1, old.size_px[1]], ..old }, u.app.capture.epoch.wrapping_add(1))); + slot.complete(Arc::new(egui::ColorImage::filled(old.size_px, egui::Color32::RED))); + u.frame(vec![]); + assert!(rx.try_recv().is_err(), "the old pixels are not accepted"); + let r = u.app.capture.shots.first().expect("reissued"); + assert_eq!(r.retries, 1, "one retry, not an error"); + assert!(!r.issued, "waits for the settled geometry again"); +} + +/// Asserts a translucent pixel round-trips through the PNG as straight RGBA without +/// darkening. +#[test] +fn a_translucent_pixel_round_trips_straight_rgba() { + let img = egui::ColorImage::from_rgba_unmultiplied([1, 1], &[128, 64, 32, 128]); + let reply = super::shot_reply(&img, None).expect("encoded"); + use base64::Engine as _; + let png = base64::engine::general_purpose::STANDARD.decode(reply["data"].as_str().expect("data")).expect("base64"); + let decoded = image::load_from_memory(&png).expect("png").to_rgba8(); + assert_eq!(decoded.get_pixel(0, 0).0, [128, 64, 32, 128], "straight alpha is not darkened"); +} + +/// Asserts a capture is not issued before three completed real UI frames; the following +/// completed frame issues it. +#[test] +fn a_capture_is_not_issued_before_three_completed_real_ui_frames() { + let mut u = Ui::new(Session::empty()); + let _rx = push(&mut u, 6, json!({})); + u.frame(vec![]); + assert_eq!(u.app.capture.shots.len(), 1, "admitted"); + assert!(!u.app.capture.shots[0].issued, "not on the frame that anchors the count"); + u.frame(vec![]); + assert!(!u.app.capture.shots[0].issued, "not on the second frame"); + u.frame(vec![]); + assert!(!u.app.capture.shots[0].issued, "the third frame has not completed when its pass 0 runs"); + u.frame(vec![]); + assert!(u.app.capture.shots[0].issued, "issued once three frames have presented their layout"); +} + +/// Asserts a discarded sizing pass does not count toward capture settling, and the next +/// completed frame issues it. +#[test] +fn a_discarded_sizing_pass_does_not_count_as_a_settled_capture_frame() { + let mut u = Ui::new(Session::empty()); + let _rx = push(&mut u, 33, json!({})); + // Each `frame_raw_with_discard` presents a layout after rerunning a discarded first pass. + // Only that completed frame counts; the discarded pass must not make the capture look settled. + u.frame_raw_with_discard(egui::RawInput::default()); + u.frame_raw_with_discard(egui::RawInput::default()); + u.frame_raw_with_discard(egui::RawInput::default()); + assert!(!u.app.capture.shots[0].issued, "three multipass frames present only two prior layouts here; the third pass 0 is not settled"); + u.frame(vec![]); + assert!(u.app.capture.shots[0].issued, "the next completed frame issues the capture"); +} + +/// Asserts pixels delivered while hidden force a draining paint and are answered rather than +/// timed out. +#[test] +fn a_hidden_window_that_delivers_pixels_is_asked_to_paint_the_draining_pass() { + let mut u = Ui::new(Session::empty()); + let rx = push(&mut u, 7, json!({})); + settle_to_issue(&mut u, 1); + let spec = u.app.capture.shots[0].spec.expect("geometry"); + let slot = u.app.capture.shots[0].slot.clone().expect("issued"); + // The renderer completes the readback while the window is hidden; the screenshot command that + // produced it is drained, so without a new forced paint the request would only time out. + slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::RED))); + let mut input = egui::RawInput::default(); + input.viewports.insert(egui::ViewportId::ROOT, egui::ViewportInfo { minimized: Some(true), ..Default::default() }); + let ctx = u.ctx.clone(); + let out = ctx.run_logic(&input, |ctx| crate::control::background(&mut u.app, ctx)); + let asked = out + .viewport_commands + .get(&egui::ViewportId::ROOT) + .is_some_and(|cmds| cmds.iter().any(|c| matches!(c, egui::ViewportCommand::RequestPaintWhileHidden))); + assert!(asked, "the completed pixels keep the hidden window painting"); + // The forced paint drains the slot; the deadline is untouched. + u.frame(vec![]); + let reply = rx.try_recv().expect("the completed capture is answered, not timed out"); + assert_eq!(reply.id, json!(7)); + assert!(reply.result.unwrap()["width"].as_u64().is_some(), "written or returned"); + assert!(u.app.capture.shots.is_empty(), "released"); +} + +/// Asserts a capture for a window with unknown visibility is parked with its deadline and a +/// paint request, then times out once. +#[test] +fn a_capture_for_a_window_with_unknown_visibility_is_admitted_with_a_deadline() { + let mut u = Ui::new(Session::empty()); + let rx = push(&mut u, 40, json!({})); + // A minimised window may report no `visible` flag at all. The request must still be parked + // with its deadline rather than left in `control_queue` waiting for a frame that never draws. + let mut input = egui::RawInput::default(); + // Neither `visible` nor `minimized` is reported: the platform has not said whether the + // window draws, so waiting for its frame would leave the request queued indefinitely. + input.viewports.insert(egui::ViewportId::ROOT, egui::ViewportInfo::default()); + let ctx = u.ctx.clone(); + let out = ctx.run_logic(&input, |ctx| crate::control::background(&mut u.app, ctx)); + assert_eq!(u.app.capture.shots.len(), 1, "admitted, not left queued"); + assert!(u.app.control_queue.is_empty(), "the parked request no longer waits in the input queue"); + let r = &u.app.capture.shots[0]; + assert!(!r.issued); + let now = u.app.clock.now(); + assert!((r.deadline - (now + crate::control::SHOT_TIMEOUT)).abs() < 1e-6, "the hidden request has its deadline"); + let asked = out + .viewport_commands + .get(&egui::ViewportId::ROOT) + .is_some_and(|cmds| cmds.iter().any(|c| matches!(c, egui::ViewportCommand::RequestPaintWhileHidden))); + assert!(asked, "the parked capture asks the window to paint"); + // The deadline still answers the transport when no frame draws. + u.advance_clock(crate::control::SHOT_TIMEOUT + 2.0); + u.frame(vec![]); + let reply = rx.try_recv().expect("the parked capture times out"); + assert_eq!(reply.id, json!(40)); + assert!(reply.result.unwrap_err().contains("didn't deliver")); + assert!(u.app.capture.shots.is_empty(), "released"); +} + +/// Asserts a completed capture is rejected when current geometry is unavailable, failing +/// closed. +#[test] +fn a_completed_capture_is_rejected_when_current_geometry_is_unavailable() { + let mut u = Ui::new(Session::empty()); + let rx = push(&mut u, 41, json!({})); + settle_to_issue(&mut u, 1); + let spec = u.app.capture.shots[0].spec.expect("geometry"); + let slot = u.app.capture.shots[0].slot.clone().expect("issued"); + slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::RED))); + // A surface that cannot represent geometry at all (smaller than one point here). The pixels + // may still match the old admitted spec, but they cannot be proven current: fail closed. + u.advance_clock(1.0); + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(0.0, 0.0))), + time: Some(1.0), + ..Default::default() + }; + let app = &mut u.app; + let ctx = u.ctx.clone(); + let mut out = ctx.run_ui(input, |ui| crate::control::drain(app, ui.ctx())); + out.textures_delta.clear(); + let reply = rx.try_recv().expect("answered instead of accepted"); + assert_eq!(reply.id, json!(41)); + assert!(reply.result.unwrap_err().contains("geometry is unavailable"), "fail closed"); + assert!(u.app.capture.shots.is_empty(), "released"); +} + +/// Asserts a tab switch while a capture settles invalidates it with "document changed". +#[test] +fn a_capture_belongs_to_the_document_admitted_not_the_one_showing_when_it_arrives() { + let mut s = Session::empty(); + s.execute("file.new", &json!({"name": "A"})).unwrap(); + s.execute("file.new", &json!({"name": "B"})).unwrap(); + let (a, b) = (s.docs.iter().position(|d| d.doc.name == "A").expect("file A"), s.docs.iter().position(|d| d.doc.name == "B").expect("file B")); + let mut u = Ui::new(s); + u.app.session.active = a; + let rx = push(&mut u, 8, json!({})); + u.frame(vec![]); + assert_eq!(u.app.capture.shots.len(), 1, "admitted against A"); + // The tab switches while the capture settles: the pixels that arrive are B's, not A's. + u.app.session.active = b; + settle_to_issue(&mut u, 1); + let spec = u.app.capture.shots[0].spec.expect("geometry"); + let slot = u.app.capture.shots[0].slot.clone().expect("issued"); + slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::RED))); + u.frame(vec![]); + let reply = rx.try_recv().expect("answered"); + assert!(reply.result.unwrap_err().contains("document changed"), "a tab switch invalidates the capture"); + assert!(u.app.capture.shots.is_empty(), "released"); +} + +/// Asserts the capture budget covers the encoding and reply peaks, not just pixels, and an +/// over-budget resize is rejected before readback. +#[test] +fn the_capture_budget_counts_the_encoding_peak_not_only_the_pixels() { + let fits = CaptureSpec { viewport: egui::ViewportId::ROOT, size_px: [4096, 4096], pixels_per_point: 1.0, zoom: 1.0 }; + let over = CaptureSpec { viewport: egui::ViewportId::ROOT, size_px: [5000, 5000], pixels_per_point: 1.0, zoom: 1.0 }; + // The encode phase holds the readback and the straight-RGBA copy while the PNG is built; the + // reply phase holds the readback and PNG plus the base64 string after that copy is dropped. + let raw = fits.bytes().expect("pixels"); + let png = raw + super::SHOT_PNG_OVERHEAD; + let encode_phase = raw * 2 + png; + let reply_phase = raw + png + (png / 3 + 1) * 4; + assert_eq!(fits.cost(), Some(encode_phase.max(reply_phase)), "cost covers both live encoding phases"); + // 5000² × 4 is under the per-image pixel budget but its PNG copy and base64 reply are not. + assert!(over.bytes().expect("pixels") <= super::MAX_SHOT_IMAGE_BYTES); + assert!(over.cost().expect("cost") > super::MAX_SHOT_TOTAL_BYTES, "the reply is part of the bound"); + assert!(fits.cost().expect("cost") <= super::MAX_SHOT_TOTAL_BYTES); + // The real resize path enforces that bound, even after admission at a smaller size. + let mut u = Ui::new(Session::empty()); + let rx = push(&mut u, 9, json!({})); + u.frame(vec![]); + assert_eq!(u.app.capture.shots.len(), 1, "admitted"); + let out = resized_frame(&mut u, egui::vec2(5000.0, 5000.0)); + let reply = rx.try_recv().expect("rejected"); + assert!(reply.result.unwrap_err().contains("capture budget"), "the encoding peak is refused"); + assert!(u.app.capture.shots.is_empty(), "nothing retained"); + assert!(!has_screenshot(&out), "over-budget growth is rejected before any readback command"); +} + +/// Asserts resizing settling captures rechecks aggregate reserved bytes, rejects the request +/// that no longer fits, and makes the survivor reserve its real cost. +#[test] +fn resizing_settling_captures_rechecks_the_aggregate_reserved_bytes() { + let mut u = Ui::new(Session::empty()); + let a = push(&mut u, 81, json!({})); + let b = push(&mut u, 82, json!({})); + u.frame(vec![]); + assert_eq!(u.app.capture.shots.len(), 2, "both fit at the original size"); + let out = resized_frame(&mut u, egui::vec2(3200.0, 3200.0)); + let replies: Vec<_> = [a.try_recv(), b.try_recv()].into_iter().filter_map(Result::ok).collect(); + assert_eq!(replies.len(), 1, "each resized request fits alone, but not both together"); + assert!(replies[0].result.as_ref().unwrap_err().contains("capture budget")); + assert_eq!(u.app.capture.shots.len(), 1); + let remaining = &u.app.capture.shots[0]; + assert_eq!(remaining.spec.unwrap().size_px, [3200, 3200]); + assert_eq!(remaining.reserved, remaining.spec.unwrap().cost().unwrap(), "the survivor reserves its real current cost"); + assert!(!remaining.issued); + assert!(!has_screenshot(&out), "neither request was read back using the old small reservation"); +} + +/// Asserts an allowed resize restarts settling, issues only after three new-size frames, and +/// reserves the current cost. +#[test] +fn an_allowed_resize_restarts_settling_and_issues_with_the_current_reservation() { + let mut u = Ui::new(Session::empty()); + let _rx = push(&mut u, 83, json!({})); + u.frame(vec![]); + u.frame(vec![]); + u.frame(vec![]); + assert_eq!(u.app.capture.shots[0].settle, 2); + let size = egui::vec2(2000.0, 1200.0); + let out = resized_frame(&mut u, size); + assert!(!has_screenshot(&out), "the third old-size frame cannot issue a newly resized surface"); + assert_eq!(u.app.capture.shots[0].settle, 0); + for _ in 0..2 { + assert!(!has_screenshot(&resized_frame(&mut u, size)), "wait for three completed new-size frames"); + } + let out = resized_frame(&mut u, size); + assert!(has_screenshot(&out), "a valid settled capture still issues"); + let request = &u.app.capture.shots[0]; + assert!(request.issued); + assert_eq!(request.spec.unwrap().size_px, [2000, 1200]); + assert_eq!(request.reserved, request.spec.unwrap().cost().unwrap()); +} + +/// Queue an injected-input request (the desktop app's `raw_input_hook` is simulated by +/// `windows::take_input` in these tests). +fn push_input(u: &mut Ui, id: u64) -> std::sync::mpsc::Receiver { + let (tx, rx) = std::sync::mpsc::channel(); + u.app.control_queue.push(ControlRequest { + id: json!(id), + method: "ui.input".into(), + params: json!({"events": [{"move": [10.0, 10.0]}]}), + reply: tx, + state: Default::default(), + }); + rx +} + +/// Asserts injected input is queued, applied by the presenting pass, and acknowledged once +/// after that pass. +#[test] +fn injected_input_is_acknowledged_only_after_the_pass_that_applies_it() { + let mut u = Ui::new(Session::empty()); + let rx = push_input(&mut u, 20); + u.frame(vec![]); + assert!(rx.try_recv().is_err(), "queued, not applied yet"); + let events = crate::windows::take_input(&mut u.app, egui::ViewportId::ROOT); + assert!(!events.is_empty(), "the batch the window draws with"); + assert!(u.app.control_synthetic, "the applying frame is fenced from the system clipboard"); + u.frame(vec![]); + let reply = rx.try_recv().expect("acknowledged by the presented pass"); + assert_eq!(reply.id, json!(20)); + assert!(reply.result.is_ok()); + assert!(!u.app.control_synthetic, "the fence ends with the frame"); +} + +/// Asserts a discarded pass neither acknowledges nor replays an injected batch; the presented +/// pass acknowledges once. +#[test] +fn a_discarded_pass_does_not_acknowledge_or_lose_an_injected_batch() { + let mut u = Ui::new(Session::empty()); + let rx = push_input(&mut u, 21); + u.frame(vec![]); + let events = crate::windows::take_input(&mut u.app, egui::ViewportId::ROOT); + assert!(!events.is_empty()); + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0))), + time: Some(1.0), + ..Default::default() + }; + let app = &mut u.app; + let ctx = u.ctx.clone(); + let mut passes = 0; + let mut out = ctx.run_ui(input, |ui| { + passes += 1; + app.ui(ui); + if passes == 1 { + ui.ctx().request_discard("test sizing pass"); + } + }); + out.textures_delta.clear(); + assert_eq!(passes, 2); + // The batch's side effects already happened; it is never replayed. Its acknowledgement waits + // for the pass whose output is actually presented. + assert_eq!(rx.try_recv().expect("acknowledged by the presented pass").id, json!(21)); + assert!(app.control_input.is_empty(), "the batch is not replayed"); + assert!(app.input_acks.is_empty() && app.applied_acks.is_empty()); +} + +/// Asserts injected text is applied once across a discarded sizing pass and acknowledged +/// once. +#[test] +fn injected_text_lands_once_across_a_discarded_sizing_pass() { + // egui reruns a discarded pass with the same `RawInput`. The custom editor applied the text in + // the first pass; the rerun must not insert it a second time. + let mut s = Session::new(); + let id = NodeId(s.execute("text.create", &json!({"text": "X", "x": 40, "y": 40})).unwrap()["id"].as_u64().unwrap()); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, id, Some(1), false).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + u.app.control_queue.push(ControlRequest { + id: json!(22), + method: "ui.input".into(), + params: json!({"events": [{"text": "abc"}]}), + reply: tx, + state: Default::default(), + }); + u.frame(vec![]); + let events = crate::windows::take_input(&mut u.app, egui::ViewportId::ROOT); + assert!(!events.is_empty(), "the applying frame's batch"); + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0))), + time: Some(1.0), + events, + ..Default::default() + }; + let app = &mut u.app; + let ctx = u.ctx.clone(); + let mut passes = 0; + let mut out = ctx.run_ui(input, |ui| { + passes += 1; + app.ui(ui); + if passes == 1 { + ui.ctx().request_discard("a grid-like sizing pass"); + } + }); + out.textures_delta.clear(); + assert_eq!(passes, 2, "egui reran the frame"); + let typed = match &app.session.doc().expect("a file").node(id).expect("the text layer").kind { + figcraft_doc::NodeKind::Text(t) => t.characters.clone(), + _ => panic!("not text"), + }; + assert_eq!(typed, "Xabc", "the discarded pass's text was not applied twice"); + assert_eq!(rx.try_recv().expect("acknowledged once").id, json!(22)); + assert!(app.input_acks.is_empty() && app.applied_acks.is_empty()); +} diff --git a/crates/ui-egui/src/corner_individual_tests.rs b/crates/ui-egui/src/corner_individual_tests.rs index 316c15a4..c87f4f33 100644 --- a/crates/ui-egui/src/corner_individual_tests.rs +++ b/crates/ui-egui/src/corner_individual_tests.rs @@ -52,7 +52,7 @@ fn named_button(u: &mut Ui, label: &str) -> Pos2 { /// Corner field `k` (an index into the radii). fn field(u: &Ui, k: usize) -> Rect { - u.ctx.read_response(Id::new(("corner", k)).with("field")).expect("corner field shows").rect + u.ctx.read_response(Id::unique(("corner", k)).with("field")).expect("corner field shows").rect } /// The iOS button next to Corner smoothing sets 60% in one undo step, as the help page's "click @@ -103,8 +103,8 @@ fn polygons_and_stars_take_corner_smoothing() { // With no corners of their own, the button is named for what it opens. let t = named_button(&mut u, "Corner smoothing"); click(&mut u, t); - assert!((0..4).all(|k| u.ctx.read_response(Id::new(("corner", k)).with("field")).is_none()), "{cmd}: one radius, no corner fields"); - let smoothing = u.ctx.read_response(Id::new("smoothing").with("field")).expect("the smoothing field shows").rect.center(); + assert!((0..4).all(|k| u.ctx.read_response(Id::unique(("corner", k)).with("field")).is_none()), "{cmd}: one radius, no corner fields"); + let smoothing = u.ctx.read_response(Id::unique("smoothing").with("field")).expect("the smoothing field shows").rect.center(); let history = u.app.session.undo_depth().0; click(&mut u, smoothing); u.frame(vec![Event::Text("60".into())]); diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 3fc8b7a2..bce5822c 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -256,7 +256,7 @@ pub fn contributors_ui(ui: &mut egui::Ui) { /// [`contributors_ui`] over a given list (tests pass their own). fn contributors_in(ui: &mut egui::Ui, contributors: &[Contributor], total_commits: u64) { let t = Tokens::get(ui.ctx()); - let id = egui::Id::new("credits_view"); + let id = egui::Id::unique("credits_view"); let mut v = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); ui.horizontal_wrapped(|ui| { ui.label(RichText::new(tr("Show")).color(t.text_secondary)); @@ -446,7 +446,7 @@ fn cell(ui: &mut egui::Ui, numeric: bool, text: &str) { fn header_cell(ui: &mut egui::Ui, t: &Tokens, label: &str, numeric: bool, sort: Option>) -> egui::Response { let rect = ui.max_rect(); let resp = ui.allocate_rect(rect, if sort.is_some() { egui::Sense::click() } else { egui::Sense::hover() }); - let kind = if sort.is_some() { egui::WidgetType::Button } else { egui::WidgetType::Label }; + let kind = if sort.is_some() { egui::Role::Button } else { egui::Role::Label }; resp.widget_info(|| egui::WidgetInfo::labeled(kind, true, label)); let resp = if sort.is_some() { resp.on_hover_cursor(egui::CursorIcon::PointingHand) } else { resp }; let current = sort.flatten(); diff --git a/crates/ui-egui/src/credits_tests.rs b/crates/ui-egui/src/credits_tests.rs index eebce841..fb3993ad 100644 --- a/crates/ui-egui/src/credits_tests.rs +++ b/crates/ui-egui/src/credits_tests.rs @@ -51,12 +51,14 @@ impl Ui { u } + /// Store the About table's current view in test context data. fn set_view(&self, v: View) { - self.ctx.data_mut(|d| d.insert_temp(egui::Id::new("credits_view"), v)); + self.ctx.data_mut(|d| d.insert_temp(egui::Id::unique("credits_view"), v)); } + /// Read the stored About table view, defaulting when unset. fn view(&self) -> View { - self.ctx.data(|d| d.get_temp::(egui::Id::new("credits_view"))).unwrap_or_default() + self.ctx.data(|d| d.get_temp::(egui::Id::unique("credits_view"))).unwrap_or_default() } /// One frame; returns the rect `draw` filled. diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index 1c4e4ad0..dec2a1b3 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -121,7 +121,7 @@ impl Shown { pub fn show(self, ctx: &egui::Context, at: Pos2) { ctx.set_cursor_icon(self.icon()); if let Shown::Drawn(g) = self { - paint(&ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("canvas_cursor"))), at, g); + paint(&ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("canvas_cursor"))), at, g); } } } diff --git a/crates/ui-egui/src/dev_compare.rs b/crates/ui-egui/src/dev_compare.rs index 0fd57aa7..a8c11354 100644 --- a/crates/ui-egui/src/dev_compare.rs +++ b/crates/ui-egui/src/dev_compare.rs @@ -179,7 +179,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { let mut open = true; let mut action = None; egui::Window::new("Compare changes") - .id(Id::new("dev_compare_window")) + .id(Id::unique("dev_compare_window")) .title_bar(false) .collapsible(false) .resizable(false) @@ -277,7 +277,7 @@ pub(crate) fn header(ui: &mut Ui, t: &Tokens, title: &str, name: &str, mode: &mu for (m, label) in modes.iter().rev() { let g = ui.painter().layout_no_wrap((*label).into(), regular(11.0), t.text); let cell = Rect::from_min_max(pos2(x - g.size().x - 20.0, r.center().y - 12.0), pos2(x, r.center().y + 12.0)); - let resp = ui.interact(cell, Id::new(("compare_mode", label)), Sense::click()); + let resp = ui.interact(cell, Id::unique(("compare_mode", label)), Sense::click()); let on = *mode == *m; crate::a11y::tab(&resp, label, on); if on { @@ -294,7 +294,7 @@ pub(crate) fn header(ui: &mut Ui, t: &Tokens, title: &str, name: &str, mode: &mu } if widgets::icon_button_at( ui, - Id::new("compare_close"), + Id::unique("compare_close"), Rect::from_center_size(pos2(r.max.x - 24.0, r.center().y), vec2(24.0, 24.0)), Icon::Close, tr("Close"), @@ -344,7 +344,7 @@ pub(crate) fn images(ui: &mut Ui, t: &Tokens, r: Rect, shots: [(&Shot, &str); 2] p.text(pos2(img.min.x, label_y), Align2::LEFT_CENTER, before_label, semibold(11.0), t.text_secondary); p.text(pos2(img.max.x, label_y), Align2::RIGHT_CENTER, after_label, semibold(11.0), t.text_secondary); // The slider: a line with a knob, dragged (or clicked) anywhere over the images. - let resp = ui.interact(img, Id::new("compare_split"), Sense::click_and_drag()); + let resp = ui.interact(img, Id::unique("compare_split"), Sense::click_and_drag()); if let Some(pos) = resp.interact_pointer_pos().filter(|_| resp.dragged() || resp.clicked()) { *split = ((pos.x - img.min.x) / img.width().max(1.0)).clamp(0.0, 1.0); } diff --git a/crates/ui-egui/src/dev_mode.rs b/crates/ui-egui/src/dev_mode.rs index 8783b0dd..87ce06bd 100644 --- a/crates/ui-egui/src/dev_mode.rs +++ b/crates/ui-egui/src/dev_mode.rs @@ -291,7 +291,7 @@ impl Callout { /// Where the template being edited in Connect to code keeps its draft. fn template_id(owner: NodeId, lang: CodeLang) -> Id { - Id::new(("code_connect_template", owner, lang.id())) + Id::unique(("code_connect_template", owner, lang.id())) } /// Layer `id`'s code in `lang`, generated again only when the document or the choice change (a @@ -390,7 +390,7 @@ pub fn playground_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: Nod "text" => row(ui, 32.0, |ui| { widgets::label(ui, name, t.text_secondary); let cur = value.as_str().unwrap_or(""); - let field = Id::new(("playground_text", id, name)); + let field = Id::unique(("playground_text", id, name)); if let Some(new) = widgets::text_field_named(ui, field, cur, ui.available_width().max(60.0), name) && new != cur { @@ -471,10 +471,10 @@ pub fn connect_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId let col = widgets::col_width(ui.available_width()); row(ui, 32.0, |ui| { ui.spacing_mut().item_spacing.x = 8.0; - if let Some(v) = widgets::text_field(ui, Id::new(("code_connect_import", owner, lang.id())), &link.import_path, col, tr("Import path")) { + if let Some(v) = widgets::text_field(ui, Id::unique(("code_connect_import", owner, lang.id())), &link.import_path, col, tr("Import path")) { edit = Some(("importPath", v)); } - if let Some(v) = widgets::text_field(ui, Id::new(("code_connect_name", owner, lang.id())), &link.name, col, tr("Component name")) { + if let Some(v) = widgets::text_field(ui, Id::unique(("code_connect_name", owner, lang.id())), &link.name, col, tr("Component name")) { edit = Some(("name", v)); } }); @@ -585,13 +585,13 @@ pub fn annotations_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: No t.text_secondary, ); let del = Rect::from_center_size(pos2(r.max.x - PAD - 4.0, r.center().y), vec2(22.0, 22.0)); - if widgets::icon_button_at(ui, Id::new(("annot_del", id, k)), del, Icon::Minus, tr("Remove annotation")) { + if widgets::icon_button_at(ui, Id::unique(("annot_del", id, k)), del, Icon::Minus, tr("Remove annotation")) { app.run("annotation.remove", json!({"id": id, "index": k})); } - if let Some(cat) = category_menu(ui, doc, Id::new(("annot_cat", id, k)), a) { + if let Some(cat) = category_menu(ui, doc, Id::unique(("annot_cat", id, k)), a) { app.run("annotation.edit", json!({"id": id, "index": k, "category": cat})); } - if let Some(label) = widgets::multiline_field(ui, Id::new(("annot_note", id, k)), &a.label, tr("Add a note for developers"), 2) { + if let Some(label) = widgets::multiline_field(ui, Id::unique(("annot_note", id, k)), &a.label, tr("Add a note for developers"), 2) { if label.trim().is_empty() && a.properties.is_empty() { app.run("annotation.remove", json!({"id": id, "index": k})); } else { @@ -602,7 +602,7 @@ pub fn annotations_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: No for (p, v) in annotations::pinned(doc, id, a) { prop_row(app, ui, &v.unwrap_or_else(|| Prop { label: p.label.to_string(), value: "—".into(), color: None, token: None })); } - if let Some(p) = pin_menu(ui, Id::new(("annot_pin", id, k)), §ions, &a.properties) { + if let Some(p) = pin_menu(ui, Id::unique(("annot_pin", id, k)), §ions, &a.properties) { let mut props = a.properties.clone(); match props.iter().position(|q| q == p.id) { Some(i) => { @@ -620,7 +620,7 @@ pub fn annotations_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: No } // A new annotation starts as a note; properties are pinned to it afterwards. if n.annotations.len() < annotations::MAX_PER_LAYER - && let Some(label) = widgets::multiline_field(ui, Id::new(("annot_new", id)), "", tr("Add an annotation for developers"), 1) + && let Some(label) = widgets::multiline_field(ui, Id::unique(("annot_new", id)), "", tr("Add an annotation for developers"), 1) && !label.trim().is_empty() { app.run("annotation.add", json!({"id": id, "label": label})); @@ -636,7 +636,7 @@ pub fn dev_resources_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: let Some(n) = doc.node(id).filter(|n| !n.is_page()) else { return }; let list = dev_resources::list(doc, id); let can_add = !components::inside_instance(doc, id) && n.dev_resources.len() < dev_resources::MAX_PER_LAYER; - let (adding_id, editing_id) = (Id::new(("devres_adding", id)), Id::new(("devres_editing", id))); + let (adding_id, editing_id) = (Id::unique(("devres_adding", id)), Id::unique(("devres_editing", id))); let mut adding = can_add && ui.data(|d| d.get_temp::(adding_id)).unwrap_or(false); let mut editing = ui.data(|d| d.get_temp::(editing_id)).filter(|i| *i < n.dev_resources.len()); widgets::separator(ui); @@ -652,10 +652,10 @@ pub fn dev_resources_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: let mut edit = None; row(ui, 32.0, |ui| { ui.spacing_mut().item_spacing.x = 8.0; - if let Some(v) = widgets::text_field(ui, Id::new(("devres_name", id, i)), name, col, tr("Name")) { + if let Some(v) = widgets::text_field(ui, Id::unique(("devres_name", id, i)), name, col, tr("Name")) { edit = Some(json!({"id": id, "index": i, "name": v})); } - if let Some(v) = widgets::text_field(ui, Id::new(("devres_url", id, i)), url, col, "https://…") { + if let Some(v) = widgets::text_field(ui, Id::unique(("devres_url", id, i)), url, col, "https://…") { edit = Some(json!({"id": id, "index": i, "url": v})); } if widgets::icon_button(ui, Icon::Check, tr("Done")) { @@ -692,10 +692,10 @@ pub fn dev_resources_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: if let Some(i) = item.index { let del = Rect::from_center_size(pos2(r.max.x - PAD - 4.0, r.center().y), vec2(22.0, 22.0)); let pen = del.translate(vec2(-26.0, 0.0)); - if widgets::icon_button_at(ui, Id::new(("devres_edit", id, i)), pen, Icon::Pencil, tr("Edit dev resource")) { + if widgets::icon_button_at(ui, Id::unique(("devres_edit", id, i)), pen, Icon::Pencil, tr("Edit dev resource")) { editing = Some(i); } - if widgets::icon_button_at(ui, Id::new(("devres_del", id, i)), del, Icon::Minus, tr("Remove dev resource")) { + if widgets::icon_button_at(ui, Id::unique(("devres_del", id, i)), del, Icon::Minus, tr("Remove dev resource")) { app.run("devResource.remove", json!({"id": id, "index": i})); editing = None; } @@ -704,7 +704,7 @@ pub fn dev_resources_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: if adding { let w = ui.available_width() - PAD * 2.0; let mut added = None; - row(ui, 32.0, |ui| added = widgets::text_field(ui, Id::new(("devres_new", id)), "", w, tr("Paste a link (https://…)"))); + row(ui, 32.0, |ui| added = widgets::text_field(ui, Id::unique(("devres_new", id)), "", w, tr("Paste a link (https://…)"))); if let Some(url) = added.filter(|u| !u.trim().is_empty()) && app.run("devResource.add", json!({"id": id, "url": url})).is_some() { @@ -810,7 +810,7 @@ fn measurement_row(ui: &mut Ui, t: &Tokens, doc: &Document, m: &Placed) -> Optio if resp.clicked() { action = Some(("select.set", json!({"ids": [m.from]}))); } - if widgets::icon_button_at(ui, Id::new(("measure_del", m.from, m.index)), del, Icon::Minus, tr("Remove measurement")) { + if widgets::icon_button_at(ui, Id::unique(("measure_del", m.from, m.index)), del, Icon::Minus, tr("Remove measurement")) { action = Some(("devMode.removeMeasurement", json!({"from": m.from, "index": m.index}))); } action @@ -886,7 +886,7 @@ pub fn categories_section(app: &mut FigApp, ui: &mut Ui) { let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover()); // The colour: click for the palette. let sw = Rect::from_center_size(pos2(r.min.x + PAD + 8.0, r.center().y), vec2(16.0, 16.0)); - let sw_resp = ui.interact(sw, Id::new(("cat_color", &c.id)), Sense::click()).tip(tr("Colour")); + let sw_resp = ui.interact(sw, Id::unique(("cat_color", &c.id)), Sense::click()).tip(tr("Colour")); crate::a11y::color(&sw_resp, &c.name, &c.color.to_css_hex()); ui.painter().rect_filled(sw, CornerRadius::same(4), doc_c32(ui.ctx(), c.color)); if sw_resp.hovered() { @@ -909,14 +909,14 @@ pub fn categories_section(app: &mut FigApp, ui: &mut Ui) { }); }); let name_r = Rect::from_min_max(pos2(sw.max.x + 8.0, r.min.y + 3.0), pos2(r.max.x - PAD - 28.0, r.max.y - 3.0)); - if let Some(name) = widgets::text_field_inline(ui, Id::new(("cat_name", &c.id)), &c.name, name_r, regular(11.0)) + if let Some(name) = widgets::text_field_inline(ui, Id::unique(("cat_name", &c.id)), &c.name, name_r, regular(11.0)) && name.trim() != c.name && !name.trim().is_empty() { action = Some(("annotation.editCategory", json!({"id": c.id, "name": name.trim()}))); } let del = Rect::from_center_size(pos2(r.max.x - PAD - 8.0, r.center().y), vec2(22.0, 22.0)); - if widgets::icon_button_at(ui, Id::new(("cat_del", &c.id)), del, Icon::Minus, tr("Delete category (its annotations stay)")) { + if widgets::icon_button_at(ui, Id::unique(("cat_del", &c.id)), del, Icon::Minus, tr("Delete category (its annotations stay)")) { action = Some(("annotation.removeCategory", json!({"id": c.id}))); } } @@ -1014,7 +1014,7 @@ pub fn ready_list(app: &mut FigApp, ui: &mut Ui) { CornerRadius::same(5), t.hover, ); - if widgets::icon_button_at(ui, Id::new(("ready_focus", item.id)), focus_r, Icon::Frame, tr("Open in focus view")) { + if widgets::icon_button_at(ui, Id::unique(("ready_focus", item.id)), focus_r, Icon::Frame, tr("Open in focus view")) { focus = Some(item.id); } } @@ -1058,7 +1058,7 @@ pub fn focus_bar(app: &mut FigApp, ctx: &egui::Context, canvas: Rect) { // Somewhere to go: another ready frame, or into the list from outside it. let can_step = list.len() > usize::from(at.is_some()); let t = Tokens::get(ctx); - let action = widgets::canvas_banner(ctx, Id::new("dev_focus_bar"), canvas, |ui| { + let action = widgets::canvas_banner(ctx, Id::unique("dev_focus_bar"), canvas, |ui| { let mut out = None; let icon = crate::layers::node_icon(&doc, id); icons::paint(ui.painter(), Rect::from_center_size(ui.cursor().min + vec2(7.0, 12.0), vec2(14.0, 14.0)), icon, t.icon_secondary); diff --git a/crates/ui-egui/src/dev_mode_tests.rs b/crates/ui-egui/src/dev_mode_tests.rs index 3e2ea9ba..1a8de0e2 100644 --- a/crates/ui-egui/src/dev_mode_tests.rs +++ b/crates/ui-egui/src/dev_mode_tests.rs @@ -126,8 +126,13 @@ fn playground_combo(u: &mut crate::layers_drag_tests::Ui, name: &str) -> Option< if centre(&combo).y < 860.0 { return Some(combo); } - let wheel = - Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, -100.0), phase: TouchPhase::Move, modifiers: Default::default() }; + let wheel = Event::MouseWheel { + unit: MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, + delta: vec2(0.0, -100.0), + phase: TouchPhase::Move, + modifiers: Default::default(), + }; u.frame(vec![Event::PointerMoved(pos2(1300.0, 500.0)), wheel]); for _ in 0..30 { u.frame(vec![]); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 85d8ca15..8d4d8fdb 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -105,7 +105,7 @@ pub(crate) fn tab_strip(ui: &mut egui::Ui, t: &Tokens, tabs let active = *current == tab; let galley = ui.painter().layout_no_wrap(label.into(), semibold(11.0), if active { t.text } else { t.text_secondary }); let (r, resp) = ui.allocate_exact_size(vec2(galley.size().x + 16.0, 24.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, active, label)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, label)); if active || resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); } @@ -179,6 +179,10 @@ pub fn keys(app: &mut FigApp, ctx: &Context) { } } +/// Draw the app's dialogs and cards: pending opens/imports/missing fonts, About, +/// Preferences, shortcuts, find/replace, batch rename, menu input, variables, dev compare, +/// branch, export preview, colour profile, plugin, live join/publish, online dialogs and +/// close confirmation. pub fn show(app: &mut FigApp, ctx: &Context) { // Dropped files waiting to open: their card, then (once it has shown) the files. crate::pending_open::pending_opens(app, ctx); @@ -246,7 +250,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) { ui.add_space(8.0); ui.horizontal(|ui| { ui.label(tr("Your name in comments")); - if let Some(name) = crate::widgets::text_field(ui, egui::Id::new("pref_user_name"), &prefs.user_name, 160.0, tr("You")) { + if let Some(name) = crate::widgets::text_field(ui, egui::Id::unique("pref_user_name"), &prefs.user_name, 160.0, tr("You")) { app.run("pref.userName", json!({"name": name})); } }); @@ -256,7 +260,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) { // Where Share ▸ Online keeps files and join codes are asked; no key here // (preferences are saved as text). let name = tr("Server for online sharing"); - if let Some(url) = crate::widgets::text_field_named(ui, egui::Id::new("pref_publish_server"), &prefs.publish_server, 220.0, name) { + if let Some(url) = crate::widgets::text_field_named(ui, egui::Id::unique("pref_publish_server"), &prefs.publish_server, 220.0, name) { app.run("pref.publishServer", json!({"url": url})); } let usual = prefs.publish_server == figcraft_engine::live::DEFAULT_SERVER; @@ -329,7 +333,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) { let mut open = true; let mut action = None; egui::Window::new(tr("Find and replace")) - .id(egui::Id::new("Find and replace")) + .id(egui::Id::unique("Find and replace")) .open(&mut open) .anchor(Align2::LEFT_TOP, vec2(260.0, 120.0)) .resizable(false) @@ -396,7 +400,7 @@ fn menu_input_dialog(app: &mut FigApp, ctx: &Context) { match &mut input { MenuInput::Link(url) => { ui.horizontal(|ui| { - let field = crate::widgets::text_input(ui, egui::Id::new("menu_input_link"), url, 250.0, "https://"); + let field = crate::widgets::text_input(ui, egui::Id::unique("menu_input_link"), url, 250.0, "https://"); let enter = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); let ok = !url.trim().is_empty(); if (crate::widgets::button(ui, tr("Done"), true, ok).clicked() || enter) && ok { @@ -406,7 +410,7 @@ fn menu_input_dialog(app: &mut FigApp, ctx: &Context) { } MenuInput::Offset(offset) => { ui.horizontal(|ui| { - let id = egui::Id::new("menu_input_offset"); + let id = egui::Id::unique("menu_input_offset"); if let Some(crate::widgets::FieldEdit::Set(v) | crate::widgets::FieldEdit::Scrub(v, _)) = crate::widgets::number_field(ui, id, crate::widgets::Lead::None, Some(*offset), "", 120.0, 1.0) { @@ -573,7 +577,7 @@ fn batch_rename_dialog(app: &mut FigApp, ctx: &Context) { let mut open = true; let mut cancel = false; let mut apply = false; - window(tr("Rename layers"), ctx).id(egui::Id::new("Rename layers")).open(&mut open).show(ctx, |ui| { + window(tr("Rename layers"), ctx).id(egui::Id::unique("Rename layers")).open(&mut open).show(ctx, |ui| { ui.set_width(320.0); ui.label(RichText::new(trn("{n} layers", n, &[])).color(t.text_secondary)); ui.add_space(6.0); @@ -802,7 +806,7 @@ fn export_dialog(app: &mut FigApp, ctx: &Context) { // The PDF preset's print options. ui.add_space(6.0); caption(ui, tr("PDF print options")); - if let Some(next) = crate::export_panel::print_options(ui, egui::Id::new("export_dialog_print"), &print) { + if let Some(next) = crate::export_panel::print_options(ui, egui::Id::unique("export_dialog_print"), &print) { app.ui.pdf_print = next; } divider(ui); diff --git a/crates/ui-egui/src/export_panel.rs b/crates/ui-egui/src/export_panel.rs index 5e05cc19..63d34f55 100644 --- a/crates/ui-egui/src/export_panel.rs +++ b/crates/ui-egui/src/export_panel.rs @@ -74,13 +74,13 @@ fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Expo if let Some(size_w) = size_w { let presets: Vec = ExportSize::SCALES.iter().map(|value| ExportSize::Scale { value: *value }.label()).chain(["512w".into(), "512h".into()]).collect(); - if let Some(s) = widgets::combo_field(ui, Id::new(("export_size", i)), &e.size.label(), size_w, &presets) + if let Some(s) = widgets::combo_field(ui, Id::unique(("export_size", i)), &e.size.label(), size_w, &presets) && ExportSize::parse(&s) != Some(e.size) { app.run("export.update", json!({"ids": ids, "index": i, "size": s})); } } - if let Some(sfx) = widgets::text_field(ui, Id::new(("export_suffix", i)), &e.suffix, suffix_w, tr("Suffix")) + if let Some(sfx) = widgets::text_field(ui, Id::unique(("export_suffix", i)), &e.suffix, suffix_w, tr("Suffix")) && sfx != e.suffix { app.run("export.update", json!({"ids": ids, "index": i, "suffix": sfx})); @@ -96,7 +96,7 @@ fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Expo }); let more = icons::button(ui, Icon::Dots, button, false, tr("Export settings")); egui::Popup::from_toggle_button_response(&more) - .id(Id::new(("export_more", i))) + .id(Id::unique(("export_more", i))) .width(216.0) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) .show(|ui| { @@ -142,7 +142,7 @@ fn advanced_options(ui: &mut Ui, e: &ExportSetting, text: bool) -> Option fn print_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &PrintSettings) { let t = Tokens::get(ui.ctx()); row(ui, 26.0, |ui| { - let open_id = Id::new(("export_print", i)); + let open_id = Id::unique(("export_print", i)); let open = egui::Popup::is_id_open(ui.ctx(), open_id); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), FIELD_H), Sense::click()); if resp.hovered() || open { @@ -156,7 +156,7 @@ fn print_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &PrintS .width(216.0) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) .show(|ui| { - if let Some(next) = print_options(ui, Id::new(("export_print_fields", i)), p) { + if let Some(next) = print_options(ui, Id::unique(("export_print_fields", i)), p) { let mut params = print_params(&next); params["ids"] = json!(ids); params["index"] = json!(i); @@ -264,7 +264,7 @@ fn preview(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId) { } let revision = app.session.ds().map(|d| d.revision).unwrap_or(0); let now = ui.input(|i| i.time); - let cache_id = Id::new("export_preview_tex"); + let cache_id = Id::unique("export_preview_tex"); let cached: Option = ui.data(|d| d.get_temp(cache_id)); let stale = cached.as_ref().is_none_or(|c| c.id != id || c.revision != revision); let due = cached.as_ref().is_none_or(|c| c.id != id || now - c.at >= PREVIEW_REFRESH); diff --git a/crates/ui-egui/src/export_preview_tests.rs b/crates/ui-egui/src/export_preview_tests.rs index f888ab47..4433356f 100644 --- a/crates/ui-egui/src/export_preview_tests.rs +++ b/crates/ui-egui/src/export_preview_tests.rs @@ -77,7 +77,7 @@ fn export_rows_offer_a_size_for_images_only() { s.execute("select.set", &json!({"ids": [r]})).unwrap(); s.execute("export.add", &json!({"ids": [r]})).unwrap(); let mut u = crate::layers_drag_tests::Ui::new(s); - let size_menu = |u: &crate::layers_drag_tests::Ui| u.ctx.read_response(egui::Id::new(("export_size", 0)).with("menu")).is_some(); + let size_menu = |u: &crate::layers_drag_tests::Ui| u.ctx.read_response(egui::Id::unique(("export_size", 0)).with("menu")).is_some(); u.frame(vec![]); assert!(size_menu(&u), "PNG has sizes"); for format in ["svg", "pdf"] { @@ -108,7 +108,7 @@ fn advanced_options_offer_the_bounding_box_for_text_layers() { let options = |u: &mut crate::layers_drag_tests::Ui, id: NodeId| { u.app.run("select.set", json!({"ids": [id]})); u.frame(vec![]); - egui::Popup::open_id(&u.ctx, egui::Id::new(("export_more", 0))); + egui::Popup::open_id(&u.ctx, egui::Id::unique(("export_more", 0))); u.frame(vec![]); crate::import_report_tests::shown_texts(u.frame_output(vec![]).shapes) }; diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index dd63988a..341e7cca 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -467,29 +467,33 @@ fn lead(u: &Ui, id: Id) -> Pos2 { u.ctx.read_response(id.with("lead")).unwrap_or_else(|| panic!("no field {id:?}")).rect.center() } +/// Asserts scrubbing the colour picker's R and alpha fields updates the colour live, each +/// scrub adding one undo step. #[test] fn scrubbing_colour_picker_fields_is_live_and_one_undo_step() { let (mut u, a, _) = setup(); let rgba = |u: &Ui| u.app.session.doc().unwrap().node(a).unwrap().fills[0].solid_color().unwrap().to_rgba8(); let [r0, .., a0] = rgba(&u); - u.ctx.data_mut(|d| d.insert_temp(Id::new("cp_model"), 1usize)); + u.ctx.data_mut(|d| d.insert_temp(Id::unique("cp_model"), 1usize)); u.app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }); // The picker window takes a frame to size itself. for _ in 0..3 { u.frame(vec![]); } let steps = undo_steps(&u); - let from = lead(&u, Id::new(("cp_f", "r"))); + let from = lead(&u, Id::unique(("cp_f", "r"))); drag(&mut u, from, -20.0, Modifiers::NONE); assert_eq!((rgba(&u)[0], undo_steps(&u)), (r0 - 20, steps + 1), "R"); // The alpha field: a scrub moves it too. - let from = lead(&u, Id::new("cp_a")); + let from = lead(&u, Id::unique("cp_a")); drag(&mut u, from, -30.0, Modifiers::NONE); let [.., alpha] = rgba(&u); assert!(alpha < a0, "alpha {a0} → {alpha}"); assert_eq!(undo_steps(&u), steps + 2, "alpha"); } +/// Asserts scrubbing a prototype delay sets the value once when released and adds one undo +/// step. #[test] fn scrubbing_a_prototype_delay_sets_it_once_when_let_go() { let mut s = Session::new(); @@ -505,7 +509,7 @@ fn scrubbing_a_prototype_delay_sets_it_once_when_let_go() { u.frame(vec![]); } let steps = undo_steps(&u); - let from = lead(&u, Id::new(("proto_delay", f1, 0usize))); + let from = lead(&u, Id::unique(("proto_delay", f1, 0usize))); drag(&mut u, from, 30.0, Modifiers::NONE); let ms = match u.app.session.doc().unwrap().node(f1).unwrap().interactions[0].trigger { figcraft_doc::proto::Trigger::AfterDelay { ms } => ms, @@ -514,13 +518,14 @@ fn scrubbing_a_prototype_delay_sets_it_once_when_let_go() { assert_eq!((ms, undo_steps(&u)), (800, steps + 1)); } +/// Asserts scrubbing the scale panel's width scales the layer live in one undo step. #[test] fn scrubbing_the_scale_panel_width_scales_live_in_one_undo_step() { let (mut u, a, _) = setup(); u.app.run("tool.set", json!({"tool": "scale"})); u.frame(vec![]); let steps = undo_steps(&u); - let from = lead(&u, Id::new("scale_w")); + let from = lead(&u, Id::unique("scale_w")); drag(&mut u, from, 20.0, Modifiers::NONE); let (_, _, w, h) = geom(&u, a); assert_eq!((w, h, undo_steps(&u)), (60.0, 45.0, steps + 1), "40×30 scaled to a width of 60"); @@ -550,14 +555,14 @@ fn the_glass_light_intensity_field_has_a_label() { s.execute("select.set", &json!({"ids": [r]})).unwrap(); s.execute("effect.add", &json!({"type": "glass"})).unwrap(); let mut u = Ui::new(s); - u.ctx.data_mut(|d| d.insert_temp(Id::new(("fx_open", 0usize)), true)); + u.ctx.data_mut(|d| d.insert_temp(Id::unique(("fx_open", 0usize)), true)); u.frame(vec![]); let shapes = u.frame_output(vec![]).shapes; - let lead = u.ctx.read_response(Id::new(("fx_gi", 0usize)).with("lead")).expect("the intensity field is shown").rect; + let lead = u.ctx.read_response(Id::unique(("fx_gi", 0usize)).with("lead")).expect("the intensity field is shown").rect; let labels: Vec<(String, bool)> = painted_texts(&shapes).into_iter().filter(|(_, _, at)| lead.contains(at.center())).map(|(text, cut, _)| (text, cut)).collect(); assert_eq!(labels, [("Intensity".to_string(), false)], "labelled, and not cut short"); - let field = u.ctx.read_response(Id::new(("fx_gi", 0usize)).with("field")).expect("its value").rect; + let field = u.ctx.read_response(Id::unique(("fx_gi", 0usize)).with("field")).expect("its value").rect; assert_eq!(field.max.x - lead.min.x, GLASS_FIELD_W, "the width the next test measures against"); } @@ -584,13 +589,15 @@ fn the_intensity_label_fits_whole_in_every_language() { out.textures_delta.clear(); } +/// Asserts a label too long for its number field is elided before the value, while the value +/// stays whole and both remain inside the field. #[test] fn a_label_too_long_for_its_field_ends_in_an_ellipsis_before_the_value() { let ctx = egui::Context::default(); let mut field = egui::Rect::NOTHING; let mut out = ctx.run_ui(Default::default(), |ui| { let lead = crate::widgets::Lead::Text("A label much wider than its field"); - let _ = crate::widgets::number_field(ui, Id::new("long"), lead, Some(80.0), "%", 60.0, 1.0); + let _ = crate::widgets::number_field(ui, Id::unique("long"), lead, Some(80.0), "%", 60.0, 1.0); field = ui.min_rect(); }); out.textures_delta.clear(); @@ -623,7 +630,7 @@ fn grid_ui(grid: serde_json::Value) -> (Ui, NodeId) { s.execute("node.set", &json!({"ids": [f], "props": {"grids": [grid]}})).unwrap(); s.execute("select.set", &json!({"ids": [f]})).unwrap(); let mut u = Ui::new(s); - u.ctx.data_mut(|d| d.insert_temp(Id::new(("grid_open", 0usize)), true)); + u.ctx.data_mut(|d| d.insert_temp(Id::unique(("grid_open", 0usize)), true)); u.frame(vec![]); (u, f) } @@ -633,6 +640,7 @@ fn grid_ui(grid: serde_json::Value) -> (Ui, NodeId) { fn scroll_to(u: &mut Ui, id: Id) { let wheel = Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, delta: egui::vec2(0.0, -300.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE, @@ -655,7 +663,7 @@ fn scroll_to(u: &mut Ui, id: Id) { /// placeholder said 0 everywhere, which turned the gap's mode off. #[test] fn typing_auto_is_zero_only_where_zero_is_the_automatic_value() { - let count_id = Id::new(("grid_field", "count", 0usize)); + let count_id = Id::unique(("grid_field", "count", 0usize)); let (mut u, f) = grid_ui(json!({"kind": "columns", "align": "min", "count": 3, "size": 8, "gutter": 10})); let count = |u: &Ui| u.app.session.doc().unwrap().node(f).unwrap().grids[0].count; scroll_to(&mut u, count_id); @@ -665,7 +673,7 @@ fn typing_auto_is_zero_only_where_zero_is_the_automatic_value() { assert_eq!(count(&u), 4, "and a number is that count"); // A stretched grid works its width out from the frame: the field shows Auto and takes no edit. let (mut u, f) = grid_ui(json!({"kind": "columns", "align": "stretch", "count": 3, "size": 0})); - let size_id = Id::new(("grid_field", "size", 0usize)); + let size_id = Id::unique(("grid_field", "size", 0usize)); scroll_to(&mut u, size_id); type_at(&mut u, size_id, "40"); assert_eq!(u.app.session.doc().unwrap().node(f).unwrap().grids[0].size, 0.0, "a stretched grid's width isn't set"); @@ -683,6 +691,336 @@ fn typing_auto_is_zero_only_where_zero_is_the_automatic_value() { u.frame(vec![]); let gap = |u: &Ui| u.app.session.doc().unwrap().node(f).unwrap().layout.as_ref().unwrap().gap; let before = gap(&u); - type_at(&mut u, Id::new("al_gap"), "Auto"); + type_at(&mut u, Id::unique("al_gap"), "Auto"); assert_eq!(gap(&u), before, "the word isn't a value where Auto is a mode"); } + +/// Save starts after the field commits, even if the next raw input releases a tab click first. +#[test] +fn a_deferred_field_save_keeps_its_document_when_the_next_input_switches_tabs() { + for (save_as, same_batch, discard) in + [(false, false, false), (true, false, false), (false, true, false), (true, true, false), (false, true, true), (true, true, true)] + { + let (mut u, a, _) = setup(); + let dir = std::env::temp_dir().join(format!("figcraft-deferred-field-tab-{}-{save_as}-{same_batch}-{discard}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (path, copy, other) = (dir.join("a.figcraft"), dir.join("copy.figcraft"), dir.join("b.figcraft")); + u.app.session.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + let owner = u.app.session.ds().unwrap().uid; + u.app.session.execute("file.new", &json!({"name": "Deferred B"})).unwrap(); + u.app.session.execute("shape.rectangle", &json!({"x": 200, "y": 0, "width": 30, "height": 30})).unwrap(); + u.app.session.execute("file.saveAs", &json!({"path": other.to_string_lossy()})).unwrap(); + u.app.session.execute("shape.rectangle", &json!({"x": 300, "y": 0, "width": 10, "height": 10})).unwrap(); + let other_uid = u.app.session.ds().unwrap().uid; + let other_name = u.app.session.doc().unwrap().name.clone(); + let other_bytes = std::fs::read(&other).unwrap(); + let index = u.app.session.docs.iter().position(|d| d.uid == owner).unwrap(); + u.app.session.execute("doc.switch", &json!({"index": index})).unwrap(); + let asked = std::rc::Rc::new(std::cell::Cell::new(0)); + u.app.services = Box::new(SaveAsPicker { path: Some(copy.clone()), asked: asked.clone() }); + u.frame(vec![]); + let output = u.frame_output(vec![]); + let nodes = output.platform_output.accesskit_update.unwrap().nodes; + let tab = nodes + .iter() + .map(|(_, node)| node) + .find(|node| node.role() == Role::Tab && node.label() == Some(other_name.as_str())) + .expect("the actual B tab"); + let bounds = tab.bounds().unwrap(); + let at = pos2(((bounds.x0 + bounds.x1) / 2.0) as f32, ((bounds.y0 + bounds.y1) / 2.0) as f32); + let (field_at, _) = field(&mut u, "X"); + click(&mut u, field_at); + assert!(crate::a11y::field_focused(&u.ctx)); + assert!(u.ctx.text_edit_focused(), "the actual field editor owns raw text"); + assert_eq!(u.ctx.memory(|m| m.focused()), Some(Id::unique("pos_x").with("te")), "X owns the keyboard"); + if !same_batch { + u.frame(vec![Event::Text("77".into())]); + assert_eq!(u.ctx.data(|d| d.get_temp::(Id::unique("pos_x").with("editing"))), Some("77".into()), "raw text reached X's draft"); + } + assert_eq!(geom(&u, a).0, 10.0, "the field has not committed before the shortcut"); + let mut modifiers = if cfg!(target_os = "macos") { Modifiers::COMMAND } else { Modifiers::COMMAND.plus(Modifiers::CTRL) }; + if save_as { + modifiers = modifiers.plus(Modifiers::SHIFT); + } + let button = |pressed| Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + // Pressing the tab relinquishes the real field; its release switches documents in the + // next frame, before shortcuts::handle consumes the deferred request. + let mut events = vec![ + Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: true, repeat: false, modifiers }, + Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers }, + Event::PointerMoved(at), + button(true), + ]; + if same_batch { + events.insert(0, Event::Text("77".into())); + } + if discard { + events.insert(0, Event::ModifiersChanged(modifiers)); + u.frame_raw_with_discard(egui::RawInput { events, ..Default::default() }); + } else { + u.frame_with(events, modifiers); + } + assert!(u.app.ui.after_field.is_some(), "the real shortcut deferred a save"); + if !discard { + assert!( + !u.ctx.input(|i| i.events.iter().any(|e| matches!(e, Event::Key { key: Key::S, pressed: true, .. }))), + "the save press was consumed once" + ); + } + // A discarded presented pass sees the original RawInput but does not dispatch it again; + // pending/picker/write assertions below also prove that pass did not duplicate the save. + assert_eq!(u.app.session.ds().unwrap().uid, owner, "press alone did not switch tabs"); + assert_eq!(geom(&u, a).0, 77.0, "the original field committed before delivery"); + u.frame(vec![button(false)]); + assert_eq!(u.app.session.ds().unwrap().uid, other_uid, "the real tab release switched to B"); + for _ in 0..3 { + u.frame(vec![]); + } + assert_eq!(u.app.session.ds().unwrap().uid, other_uid, "saving A restores the front tab B"); + assert!(u.app.session.ds().unwrap().is_dirty(), "B was not saved"); + assert_eq!(std::fs::read(&other).unwrap(), other_bytes, "B's file was not overwritten"); + assert_eq!(asked.get(), usize::from(save_as), "only the original Save as asks for a path"); + assert_eq!(saved_x(if save_as { © } else { &path }, a), 77.0, "A's typed field is saved"); + assert_eq!(saved_x(&path, a), if save_as { 10.0 } else { 77.0 }); + assert!(u.app.ui.after_field.is_none()); + std::fs::remove_dir_all(&dir).ok(); + } +} + +/// Asserts a deferred field save does not retarget a closed, moved or foreign owner: the other +/// file stays untouched, Save as is not opened, and the stale request is consumed once. +#[test] +fn a_deferred_field_save_does_not_retarget_a_closed_moved_or_foreign_owner() { + for change in ["closed", "moved", "foreign"] { + for save_as in [false, true] { + let (mut u, a, _) = setup(); + u.ctx.set_embed_viewports(false); + let dir = std::env::temp_dir().join(format!("figcraft-deferred-field-invalid-{}-{change}-{save_as}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (path, other, copy) = (dir.join("a.figcraft"), dir.join("b.figcraft"), dir.join("copy.figcraft")); + u.app.session.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + let owner = u.app.session.ds().unwrap().uid; + u.app.session.execute("file.new", &json!({"name": "Deferred B"})).unwrap(); + u.app.session.execute("file.saveAs", &json!({"path": other.to_string_lossy()})).unwrap(); + u.app.session.execute("shape.rectangle", &json!({"x": 200, "y": 0, "width": 10, "height": 10})).unwrap(); + let other_uid = u.app.session.ds().unwrap().uid; + let other_bytes = std::fs::read(&other).unwrap(); + let index = u.app.session.docs.iter().position(|d| d.uid == owner).unwrap(); + u.app.session.execute("doc.switch", &json!({"index": index})).unwrap(); + let asked = std::rc::Rc::new(std::cell::Cell::new(0)); + u.app.services = Box::new(SaveAsPicker { path: Some(copy.clone()), asked: asked.clone() }); + let (at, _) = field(&mut u, "X"); + click(&mut u, at); + assert!(crate::a11y::field_focused(&u.ctx)); + assert!(u.ctx.text_edit_focused(), "the actual field editor owns raw text"); + assert_eq!(u.ctx.memory(|m| m.focused()), Some(Id::unique("pos_x").with("te")), "X owns the keyboard"); + u.frame(vec![Event::Text("77".into())]); + assert_eq!(u.ctx.data(|d| d.get_temp::(Id::unique("pos_x").with("editing"))), Some("77".into()), "raw text reached X's draft"); + assert_eq!(geom(&u, a).0, 10.0, "typed field text is not committed before Save"); + let mut modifiers = if cfg!(target_os = "macos") { Modifiers::COMMAND } else { Modifiers::COMMAND.plus(Modifiers::CTRL) }; + if save_as { + modifiers = modifiers.plus(Modifiers::SHIFT); + } + u.frame_with( + vec![ + Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: true, repeat: false, modifiers }, + Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers }, + ], + modifiers, + ); + assert!(u.app.ui.after_field.is_some()); + assert_eq!(geom(&u, a).0, 77.0, "field commit is still before the save"); + let owner_index = u.app.session.docs.iter().position(|d| d.uid == owner).unwrap(); + if change == "closed" { + u.app.session.execute("file.close", &json!({"index": owner_index, "force": true})).unwrap(); + } else if change == "moved" { + let window = crate::windows::open(&mut u.app, &u.ctx, Some(owner_index), None, None).unwrap(); + assert_ne!(window, 0); + assert_eq!(u.app.windows.of(owner), window); + } else { + // An impossible/misrouted pane must fail closed too; no real window has this id. + u.app.windows.current = u64::MAX; + } + let other_index = u.app.session.docs.iter().position(|d| d.uid == other_uid).unwrap(); + u.app.session.execute("doc.switch", &json!({"index": other_index})).unwrap(); + if change == "foreign" { + // Exercise the actual consumer without begin() resetting this deliberate invalid + // scope; the valid input/field producer above was the real app path. + // Match this direct pass and the harness clock; egui's default predicted_dt is f32. + let time = u.ctx.input(|i| i.time) + 1.0 / 60.0; + u.advance_clock(time); + u.ctx + .run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |ui| { + crate::shortcuts::handle(&mut u.app, ui.ctx(), false, Default::default()) + }) + .textures_delta + .clear(); + u.app.windows.current = 0; + } else { + u.frame(vec![]); + } + for _ in 0..2 { + u.frame(vec![]); + } + assert_eq!(u.app.session.ds().unwrap().uid, other_uid); + assert!(u.app.session.ds().unwrap().is_dirty(), "stale delivery must not save B"); + assert_eq!(std::fs::read(&other).unwrap(), other_bytes); + assert_eq!(saved_x(&path, a), 10.0, "a cancelled owner is not saved"); + assert_eq!(asked.get(), 0, "a stale Save as must not open for B"); + assert!(!copy.exists()); + assert!(u.app.ui.after_field.is_none(), "consume stale requests once"); + std::fs::remove_dir_all(&dir).ok(); + } + } +} + +/// A save prepared for A must not force its cached X editor into B when routing changes the +/// presented document before the fields draw, even though both panels use the same widget ID. +#[test] +fn a_prepared_field_save_cannot_commit_into_a_same_pass_replacement_document() { + for route in ["control", "tab"] { + for save_as in [false, true] { + for discard in [false, true] { + prepared_save_cannot_enter_a_replacement(route, save_as, discard); + } + } + } +} + +/// One route/save-as/discard combination of the same-pass replacement test: a save prepared for A +/// must not commit A's cached X editor into B when routing presents B before the fields draw, +/// even though both panels use the same widget ID. +fn prepared_save_cannot_enter_a_replacement(route: &str, save_as: bool, discard: bool) { + let (mut u, a, _) = setup(); + let dir = std::env::temp_dir().join(format!("figcraft-field-save-owner-{}-{route}-{save_as}-{discard}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (path, other, copy) = (dir.join("a.figcraft"), dir.join("b.figcraft"), dir.join("copy.figcraft")); + u.app.session.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + let owner = u.app.session.ds().unwrap().uid; + let owner_bytes = std::fs::read(&path).unwrap(); + u.app.session.execute("file.new", &json!({"name": "Same-pass B"})).unwrap(); + let b = id(u.app.session.execute("shape.rectangle", &json!({"x": 200, "y": 40, "width": 30, "height": 30})).unwrap()); + u.app.session.execute("file.saveAs", &json!({"path": other.to_string_lossy()})).unwrap(); + u.app.session.execute("shape.rectangle", &json!({"x": 300, "y": 0, "width": 10, "height": 10})).unwrap(); + u.app.session.execute("select.set", &json!({"ids": [b]})).unwrap(); + let other_index = u.app.session.active; + let other_uid = u.app.session.ds().unwrap().uid; + let other_name = u.app.session.doc().unwrap().name.clone(); + let other_model = u.app.session.ds().unwrap().doc.clone(); + let before_switch_revision = u.app.session.ds().unwrap().revision; + let other_history = u.app.session.undo_depth(); + let other_selection = u.app.session.ds().unwrap().selection.clone(); + let other_dirty = u.app.session.ds().unwrap().is_dirty(); + let other_bytes = std::fs::read(&other).unwrap(); + assert!(other_dirty, "B has independent unsaved content"); + let owner_index = u.app.session.docs.iter().position(|d| d.uid == owner).unwrap(); + u.app.session.execute("doc.switch", &json!({"index": owner_index})).unwrap(); + // Every command bumps the outgoing document's revision, even a nonediting switch. + let other_revision = u.app.session.docs.iter().find(|d| d.uid == other_uid).unwrap().revision; + assert_eq!(other_revision, before_switch_revision + 1, "the setup switch advances only B's command revision"); + let asked = std::rc::Rc::new(std::cell::Cell::new(0)); + u.app.services = Box::new(SaveAsPicker { path: Some(copy.clone()), asked: asked.clone() }); + let output = u.frame_output(vec![]); + let nodes = output.platform_output.accesskit_update.unwrap().nodes; + let tab = nodes + .iter() + .map(|(_, node)| node) + .find(|node| node.role() == Role::Tab && node.label() == Some(other_name.as_str())) + .expect("the actual B tab"); + let bounds = tab.bounds().unwrap(); + let tab_at = pos2(((bounds.x0 + bounds.x1) / 2.0) as f32, ((bounds.y0 + bounds.y1) / 2.0) as f32); + let (at, _) = field(&mut u, "X"); + click(&mut u, at); + assert_eq!(u.ctx.memory(|m| m.focused()), Some(Id::unique("pos_x").with("te"))); + assert!(u.ctx.text_edit_focused()); + u.frame(vec![Event::Text("77".into())]); + assert_eq!(u.ctx.data(|d| d.get_temp::(Id::unique("pos_x").with("editing"))), Some("77".into())); + assert_eq!(geom(&u, a).0, 10.0, "A's raw draft is not committed yet"); + assert_eq!(u.app.session.ds().unwrap().uid, owner, "the save begins with A presented"); + let reply = if route == "control" { + let (reply, rx) = std::sync::mpsc::channel(); + u.app.control_queue.push(crate::ControlRequest { + id: json!("same-pass-switch"), + method: "engine.execute".into(), + params: json!({"command": "doc.switch", "params": {"index": other_index}, "window": 0}), + reply, + state: Default::default(), + }); + Some(rx) + } else { + None + }; + let mut modifiers = if cfg!(target_os = "macos") { Modifiers::COMMAND } else { Modifiers::COMMAND.plus(Modifiers::CTRL) }; + if save_as { + modifiers = modifiers.plus(Modifiers::SHIFT); + } + let mut events = vec![ + Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: true, repeat: false, modifiers }, + Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers }, + ]; + if route == "tab" { + events.push(Event::PointerMoved(tab_at)); + for pressed in [true, false] { + events.push(Event::PointerButton { pos: tab_at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + } + } + if discard { + events.insert(0, Event::ModifiersChanged(modifiers)); + u.frame_raw_with_discard(egui::RawInput { events, ..Default::default() }); + } else { + u.frame_with(events, modifiers); + } + if let Some(reply) = reply { + let reply = reply.try_recv().expect("real control::drain acknowledged doc.switch in this pass"); + assert!(reply.result.is_ok(), "{:?}", reply.result); + } + let unchanged = |u: &Ui| { + assert_eq!(u.app.session.ds().unwrap().uid, other_uid, "the real same-pass route presents B"); + assert_eq!(geom(u, b), (200.0, 40.0, 30.0, 30.0), "A's draft cannot enter B's field: route={route}, save_as={save_as}, discard={discard}"); + assert!(std::sync::Arc::ptr_eq(&u.app.session.ds().unwrap().doc, &other_model), "B's model is untouched"); + assert_eq!(u.app.session.ds().unwrap().revision, other_revision); + assert_eq!(u.app.session.undo_depth(), other_history, "B's undo/redo history is unchanged"); + assert_eq!(u.app.session.ds().unwrap().selection, other_selection); + assert_eq!(u.app.session.ds().unwrap().is_dirty(), other_dirty); + assert_eq!(std::fs::read(&other).unwrap(), other_bytes); + assert_eq!(std::fs::read(&path).unwrap(), owner_bytes); + assert_eq!(u.app.session.docs.iter().find(|d| d.uid == owner).unwrap().doc.world_bounds(a).unwrap().x0, 10.0); + assert_eq!(asked.get(), 0, "the cancelled request never asks for a path"); + assert!(!copy.exists()); + assert!(u.app.ui.after_field.is_none(), "cancel before late fallback or queueing"); + }; + unchanged(&u); + for _ in 0..3 { + u.frame(vec![]); + unchanged(&u); + } + // A cancelled draft is not a permanent veto: B accepts a later real field edit + // and saves its own value through the same focused-field shortcut path. + let (at, _) = field(&mut u, "X"); + click(&mut u, at); + assert_eq!(u.ctx.memory(|m| m.focused()), Some(Id::unique("pos_x").with("te"))); + u.frame(vec![Event::Text("88".into())]); + assert_eq!(geom(&u, b).0, 200.0, "fresh B text still waits for a normal commit"); + assert_eq!(u.ctx.data(|d| d.get_temp::(Id::unique("pos_x").with("editing"))), Some("88".into())); + u.frame_with( + vec![ + Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: true, repeat: false, modifiers }, + Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers }, + ], + modifiers, + ); + assert_eq!(geom(&u, b).0, 88.0, "a fresh B field commits normally"); + assert!(u.app.ui.after_field.is_some()); + for _ in 0..3 { + u.frame(vec![]); + } + assert_eq!(u.app.session.ds().unwrap().uid, other_uid); + assert_eq!(saved_x(if save_as { © } else { &other }, b), 88.0, "a later valid save belongs to B"); + if save_as { + assert_eq!(std::fs::read(&other).unwrap(), other_bytes); + } + assert_eq!(std::fs::read(&path).unwrap(), owner_bytes, "A stays unsaved"); + assert_eq!(asked.get(), usize::from(save_as)); + assert!(u.app.ui.after_field.is_none()); + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/crates/ui-egui/src/font_preview.rs b/crates/ui-egui/src/font_preview.rs index a16177dc..ad05645d 100644 --- a/crates/ui-egui/src/font_preview.rs +++ b/crates/ui-egui/src/font_preview.rs @@ -32,7 +32,7 @@ fn key(style: &TextStyle, text: &str, ppp: f32) -> u64 { pub fn texture(ctx: &egui::Context, style: &TextStyle, text: &str, budget: &mut usize) -> Option { let ppp = ctx.pixels_per_point(); let k = key(style, text, ppp); - let cache = ctx.data_mut(|d| d.get_temp_mut_or_default::(Id::new("font_previews")).clone()); + let cache = ctx.data_mut(|d| d.get_temp_mut_or_default::(Id::unique("font_previews")).clone()); if let Some(t) = cache.0.lock().ok()?.get(&k) { return t.clone(); } diff --git a/crates/ui-egui/src/frame_labels.rs b/crates/ui-egui/src/frame_labels.rs index 9e8b2e7a..a87fc9f4 100644 --- a/crates/ui-egui/src/frame_labels.rs +++ b/crates/ui-egui/src/frame_labels.rs @@ -58,10 +58,16 @@ pub fn turn_since(painter: &Painter, from: ShapeIdx, pivot: Pos2, angle: f32) { }); } +/// Recursively rotate shape geometry by `rot`/`angle` about `pivot`: leaves +/// no-op/callback/band shapes alone, rotates points and centers, adds the angle to +/// ellipses/rects/text, and rotates meshes. fn turn(shape: &mut Shape, rot: Rot2, angle: f32, pivot: Pos2) { let at = |p: &mut Pos2| *p = pivot + rot * (*p - pivot); match shape { Shape::Noop | Shape::Callback(_) => {} + // Bands are not drawn by this UI; if one ever arrives, rotate it via its own `angle` + // rather than approximating the samples as points. + Shape::Band(_) => {} Shape::Vec(v) => v.iter_mut().for_each(|s| turn(s, rot, angle, pivot)), Shape::Circle(c) => at(&mut c.center), Shape::Ellipse(e) => { @@ -166,7 +172,7 @@ pub fn rename_field(app: &mut FigApp, ui: &mut egui::Ui) { let font = regular(11.0); let text_w = ui.painter().layout_no_wrap(name.clone(), font.clone(), Color32::WHITE).size().x; let rect = Rect::from_min_size(pos2(area.min.x, area.max.y - 16.0), vec2((text_w + 24.0).max(area.width()).clamp(80.0, 480.0), 16.0)); - let (done, open) = crate::widgets::rename_field(ui, egui::Id::new(("frame_label_rename", r.id)), &name, rect, font, r.start); + let (done, open) = crate::widgets::rename_field(ui, egui::Id::unique(("frame_label_rename", r.id)), &name, rect, font, r.start); app.ui.rename_label = open.then_some(crate::layers::Rename { id: r.id, start: false }); if let Some(new) = done { app.run("object.rename", serde_json::json!({"ids": [r.id], "name": new})); diff --git a/crates/ui-egui/src/frame_presets.rs b/crates/ui-egui/src/frame_presets.rs index 51662d3d..9eb28162 100644 --- a/crates/ui-egui/src/frame_presets.rs +++ b/crates/ui-egui/src/frame_presets.rs @@ -10,6 +10,8 @@ use crate::icons::{self, Icon}; use crate::theme::{Tokens, regular, semibold}; use crate::widgets::{self, PAD}; +/// Draw the expandable frame-preset categories; choosing a preset runs `frame.create`, +/// switches to the Move tool and zooms to the new selection. pub fn panel(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); widgets::section_header(ui, tr("Frame"), &[], false); @@ -22,7 +24,7 @@ pub fn panel(app: &mut FigApp, ui: &mut Ui) { } let mut chosen = None; for cat in cats { - let open_id = Id::new(("preset_cat", cat)); + let open_id = Id::unique(("preset_cat", cat)); // The first category starts open. let open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(cat == "Phone"); let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click()); diff --git a/crates/ui-egui/src/guides.rs b/crates/ui-egui/src/guides.rs index befc9b53..c3bb6589 100644 --- a/crates/ui-egui/src/guides.rs +++ b/crates/ui-egui/src/guides.rs @@ -49,13 +49,14 @@ struct Placed { line: Option<[Point; 2]>, } +/// Stable egui id for guide dragging. fn drag_id() -> Id { - Id::new("guide_drag") + Id::unique("guide_drag") } /// The dragged guide as it would land. fn preview_id() -> Id { - Id::new("guide_preview") + Id::unique("guide_preview") } /// The page's guides and those of its top-level frames (frames last, so they're found first). diff --git a/crates/ui-egui/src/handles.rs b/crates/ui-egui/src/handles.rs index ec489bf8..d29d59ee 100644 --- a/crates/ui-egui/src/handles.rs +++ b/crates/ui-egui/src/handles.rs @@ -67,8 +67,9 @@ fn crop_corners(app: &FigApp, id: NodeId, r: (f64, f64, f64, f64)) -> Option<[Po ]) } +/// Stable egui id for handle dragging. fn drag_id() -> Id { - Id::new("figcraft_handle_drag") + Id::unique("figcraft_handle_drag") } /// The gradient being edited (single node, picker open on a gradient paint). diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index c3f7ee3a..9c809b99 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -188,6 +188,8 @@ fn dropped(app: &mut FigApp, ctx: &egui::Context) { } } +/// Draw the Home sidebar: app icon and wordmark, file/template search (Escape clears it) +/// and section tabs; choosing a section clears the file query. fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) { let mut y = left.min.y + 20.0; crate::theme::paint_app_icon(ui, Rect::from_min_size(pos2(left.min.x + 12.0, y - 4.0), vec2(32.0, 32.0))); @@ -195,8 +197,13 @@ fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) { y += 52.0; let search = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(SIDEBAR - 16.0, widgets::FIELD_H)); ui.scope_builder(egui::UiBuilder::new().max_rect(search), |ui| { - let response = - widgets::text_input(ui, Id::new("home_file_search"), &mut app.ui.home_view.file_query, search.width(), tr("Search files and templates")); + let response = widgets::text_input( + ui, + Id::unique("home_file_search"), + &mut app.ui.home_view.file_query, + search.width(), + tr("Search files and templates"), + ); a11y::text(&response, tr("Search files and templates"), &app.ui.home_view.file_query); if (response.has_focus() || response.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)) { app.ui.home_view.file_query.clear(); @@ -205,7 +212,7 @@ fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) { y += widgets::FIELD_H + 12.0; for (section, icon, label) in Section::ALL { let r = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(SIDEBAR - 16.0, 32.0)); - let resp = ui.interact(r, ui.id().with(("home_section", label)), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with(("home_section", label)), Sense::click()); let current = app.ui.home_view.file_query.trim().is_empty() && app.ui.home_view.section == section; a11y::tab(&resp, tr(label), current); if current || resp.hovered() { @@ -250,7 +257,7 @@ fn search_results(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { detail_size: 11.0, detail_ink: t.text_secondary, }; - if listed(ui, t, ui.id().with(("search_online", &f.server, &f.file)), 36.0, &row) { + if listed(ui, t, ui.scope_id().with(("search_online", &f.server, &f.file)), 36.0, &row) { open_online = Some(f.clone()); } } @@ -385,7 +392,7 @@ fn open_files(app: &mut FigApp, ui: &mut Ui, t: &Tokens, query: &str) -> usize { detail_size: 11.0, detail_ink: t.text_secondary, }; - if listed(ui, t, ui.id().with(("open", d.uid)), 36.0, &row) { + if listed(ui, t, ui.scope_id().with(("open", d.uid)), 36.0, &row) { switch = Some(i); } } @@ -423,7 +430,7 @@ fn getting_started(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { let close = Rect::from_min_size(pos2(rect.max.x - 36.0, rect.min.y + 12.0), vec2(24.0, 24.0)); if widgets::icon_button_at( ui, - ui.id().with("tips_close"), + ui.scope_id().with("tips_close"), close, Icon::Close, tr("Hide these tips (Getting started in the Help menu shows them again)"), @@ -470,10 +477,12 @@ fn matches(template: &Template, query: &str, kind: Option) -> bool { || has(template.size)) } +/// Draw template search and kind filter, show no-match guidance and a "Show all templates" +/// button that clears the query and kind, and lay matching templates out in a grid. fn templates(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { heading(ui, t, tr("Templates")); ui.horizontal(|ui| { - let search = widgets::text_input(ui, Id::new("home_template_search"), &mut app.ui.home_view.query, 260.0, tr("Search templates")); + let search = widgets::text_input(ui, Id::unique("home_template_search"), &mut app.ui.home_view.query, 260.0, tr("Search templates")); a11y::text(&search, tr("Search templates"), &app.ui.home_view.query); let labels: Vec<&str> = std::iter::once(tr("All")).chain(Kind::ALL.iter().map(|k| tr(k.label()))).collect(); let active = app.ui.home_view.kind.and_then(|k| Kind::ALL.iter().position(|x| *x == k)).map_or(0, |i| i + 1); @@ -506,7 +515,7 @@ fn templates(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { fn template_grid(app: &mut FigApp, ui: &mut Ui, t: &Tokens, list: &[&Template]) { let columns = (((ui.available_width() + GAP) / (CARD_W + GAP)).floor() as usize).max(1); let dark = ui.visuals().dark_mode; - let mut thumbs: Thumbs = ui.data(|d| d.get_temp(Id::new("template_thumbs"))).unwrap_or_default(); + let mut thumbs: Thumbs = ui.data(|d| d.get_temp(Id::unique("template_thumbs"))).unwrap_or_default(); let mut chosen = None; let mut rendered = false; for row in list.chunks(columns) { @@ -529,7 +538,7 @@ fn template_grid(app: &mut FigApp, ui: &mut Ui, t: &Tokens, list: &[&Template]) }); ui.add_space(GAP); } - ui.data_mut(|d| d.insert_temp(Id::new("template_thumbs"), thumbs)); + ui.data_mut(|d| d.insert_temp(Id::unique("template_thumbs"), thumbs)); if let Some(template) = chosen && app.run("file.newFromTemplate", json!({"template": template.id, "name": tr(template.name)})).is_some() { @@ -601,6 +610,6 @@ fn thumbnail(ctx: &egui::Context, template: &Template, background: figcraft_colo /// Tests: the templates whose pictures are cached for the light or dark theme. #[cfg(test)] pub(crate) fn cached_thumbnails(ctx: &egui::Context, dark: bool) -> Vec<&'static str> { - let thumbs: Thumbs = ctx.data(|d| d.get_temp(Id::new("template_thumbs"))).unwrap_or_default(); + let thumbs: Thumbs = ctx.data(|d| d.get_temp(Id::unique("template_thumbs"))).unwrap_or_default(); thumbs.0.iter().filter(|((_, d), thumb)| *d == dark && thumb.is_some()).map(|((id, _), _)| *id).collect() } diff --git a/crates/ui-egui/src/home_recents.rs b/crates/ui-egui/src/home_recents.rs index 2d39c6fe..0e151da2 100644 --- a/crates/ui-egui/src/home_recents.rs +++ b/crates/ui-egui/src/home_recents.rs @@ -685,7 +685,7 @@ fn heading(ui: &mut Ui, t: &Tokens, text: &str) { /// The id of `path`'s card or row (stable, so the keyboard stays on it as the order changes). fn card_id(path: &str) -> Id { - Id::new(("home_recent_file", path)) + Id::unique(("home_recent_file", path)) } /// Clicks, keys and the context menu on a card or row: a click selects, a double-click or Enter @@ -909,7 +909,7 @@ fn list(ui: &mut Ui, t: &Tokens, recents: &mut Recents, files: &[Shown], now: u6 let x0 = if i == 0 { header.min.x } else { header.min.x + date_x(i) }; let x1 = if i + 1 < columns.len() { header.min.x + date_x(i + 1) } else { header.max.x }; let cell = Rect::from_min_max(pos2(x0, header.min.y), pos2(x1, header.max.y)); - let resp = ui.interact(cell, Id::new(("home_recents_header", key.id())), Sense::click()); + let resp = ui.interact(cell, Id::unique(("home_recents_header", key.id())), Sense::click()); a11y::button(&resp, &trf("Sort by {column}", &[("column", label)])); let on = sort.key == *key; if resp.hovered() { diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index aeb35096..a94cdec8 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -1283,7 +1283,7 @@ pub fn paint_outlines(painter: &Painter, rect: Rect, size: f64, parts: &[(&figcr /// Every icon at the sizes the UI uses, labelled: for reviewing the set (`ui.set {iconGallery}`). pub fn gallery(ctx: &egui::Context) { let t = crate::theme::Tokens::get(ctx); - egui::Area::new(egui::Id::new("icon_gallery")).order(egui::Order::Foreground).fixed_pos(Pos2::ZERO).show(ctx, |ui| { + egui::Area::new(egui::Id::unique("icon_gallery")).order(egui::Order::Foreground).fixed_pos(Pos2::ZERO).show(ctx, |ui| { let screen = ctx.content_rect(); let (r, _) = ui.allocate_exact_size(screen.size(), egui::Sense::click()); ui.painter().rect_filled(r, 0.0, t.panel); @@ -1381,7 +1381,7 @@ mod tests { let mut u = crate::layers_drag_tests::Ui::new(s); u.frame(vec![]); let shapes = u.frame_output(vec![]).shapes; - let lead = |name: &str| u.ctx.read_response(egui::Id::new(name).with("lead")).map(|r| r.rect).unwrap_or_else(|| panic!("{name} is shown")); + let lead = |name: &str| u.ctx.read_response(egui::Id::unique(name).with("lead")).map(|r| r.rect).unwrap_or_else(|| panic!("{name} is shown")); let (rotation, radius) = (marks(&shapes, lead("pos_rot")), marks(&shapes, lead("ap_rad"))); assert!(!rotation.is_empty() && !radius.is_empty(), "both draw an icon"); assert_ne!(rotation, radius); diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index 192073b3..ee25124a 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -36,6 +36,7 @@ impl Harness { /// One frame with `events`, 1/60 s after the last; returns the repaint delay it asked for. fn frame(&mut self, events: Vec) -> Duration { self.time += 1.0 / 60.0; + self.app.clock.set_test_now(Some(self.time)); let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), events, ..Default::default() }; let app = &mut self.app; @@ -247,6 +248,8 @@ fn a_finished_background_render_wakes_the_ui() { assert!(worker.poll().is_some(), "the woken frame finds the image"); } +/// Asserts a control request wakes an idle UI and is answered, and screenshots keep frames +/// coming until delivered before the UI rests again. #[test] fn control_requests_and_screenshots_wake_an_idle_ui() { let (mut h, _) = document(); @@ -258,7 +261,7 @@ fn control_requests_and_screenshots_wake_an_idle_ui() { method: "ui.inspect".into(), params: json!({}), reply: tx.clone(), - cancelled: Default::default(), + state: Default::default(), }); assert_eq!(h.frame(vec![]), Duration::ZERO); assert!(rx.try_recv().unwrap().result.is_ok()); @@ -266,15 +269,20 @@ fn control_requests_and_screenshots_wake_an_idle_ui() { // A screenshot keeps frames coming until the window delivers it, then the UI rests again. let path = std::env::temp_dir().join(format!("figcraft-idle-shot-{}.png", std::process::id())); let params = json!({"path": path.to_string_lossy()}); - h.app.control_queue.push(ControlRequest { id: json!(2), method: "ui.screenshot".into(), params, reply: tx, cancelled: Default::default() }); + h.app.control_queue.push(ControlRequest { id: json!(2), method: "ui.screenshot".into(), params, reply: tx, state: Default::default() }); for _ in 0..5 { - assert_eq!(h.frame(vec![]), Duration::ZERO, "waiting for the screenshot"); + let delay = h.frame(vec![]); + assert!(delay <= Duration::from_secs_f64(crate::control::SHOT_TIMEOUT), "waiting for the screenshot"); } assert!(rx.try_recv().is_err(), "not delivered yet"); - let image = Arc::new(egui::ColorImage::filled([4, 4], egui::Color32::RED)); - h.frame(vec![Event::Screenshot { viewport_id: ViewportId::ROOT, user_data: egui::UserData::default(), image }]); + // The settled UI issued the viewport capture command; the renderer completes its one-shot + // slot, and the next UI pass writes the reply. + let spec = h.app.capture.shots.first().and_then(|r| r.spec).expect("admitted against real geometry"); + let slot = h.app.capture.shots.first().and_then(|r| r.slot.clone()).expect("issued after settling"); + slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::RED))); + h.frame(vec![]); let reply = rx.try_recv().unwrap(); - assert_eq!(reply.result.unwrap()["width"], json!(4)); + assert_eq!(reply.result.unwrap()["width"], json!(spec.size_px[0])); let _ = std::fs::remove_file(&path); h.assert_idle("after a screenshot"); } @@ -335,41 +343,52 @@ fn cjk_names_are_looked_for_in_edits_not_on_every_pan() { assert_eq!(h.app.cjk_needed(&ctx, 0), crate::cjk::HANGUL); } +/// Asserts a hidden window answers edits and inspection at once, while a screenshot is +/// parked with a deadline and times out once instead of waiting for a frame. #[test] fn a_hidden_window_answers_at_once() { // #269, #720: minimised or covered, the window draws no frames, but the app's logic still runs: // it answers edits, inspection, saves and the interface's state; what needs a frame (a capture - // of the window) is an error at once instead of waiting for the window to show. + // of the window) queues for a forced hidden paint and otherwise expires at its deadline. let dir = std::env::temp_dir().join(format!("figcraft-hidden-save-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("hidden.figcraft"); let (mut h, _) = document(); h.settle(); + // Exercise admission well after startup, independently of settling speed. + h.time += 120.0; + h.app.clock.set_test_now(Some(h.time)); let (tx, rx) = std::sync::mpsc::channel(); let request = |id: u64, method: &str, params: Value| ControlRequest { id: json!(id), method: method.into(), params, reply: tx.clone(), - cancelled: Default::default(), + state: Default::default(), }; h.app.control_queue.push(request(1, "engine.execute", json!({"command": "file.save", "params": {"path": path.to_string_lossy()}}))); h.app.control_queue.push(request(2, "ui.inspect", json!({}))); h.app.control_queue.push(request(3, "ui.screenshot", json!({"path": dir.join("shot.png").to_string_lossy()}))); let mut hidden = RawInput::default(); hidden.viewports.insert(ViewportId::ROOT, egui::ViewportInfo { minimized: Some(true), ..Default::default() }); - let app = &mut h.app; - let _ = h.ctx.run_logic(&hidden, |ctx| crate::control::drain_hidden(app, ctx)); + let ctx = h.ctx.clone(); + let _ = ctx.run_logic(&hidden, |ctx| crate::control::background(&mut h.app, ctx)); let saved = rx.try_recv().unwrap(); assert_eq!(saved.id, json!(1)); assert!(saved.result.is_ok() && path.exists(), "saved while hidden: {:?}", saved.result); let inspected = rx.try_recv().unwrap(); assert_eq!(inspected.id, json!(2)); assert!(inspected.result.is_ok(), "the interface's state: {:?}", inspected.result); - let shot = rx.try_recv().unwrap(); - assert_eq!(shot.id, json!(3)); - assert!(shot.result.unwrap_err().contains("minimised or hidden"), "a capture needs the window on screen"); + assert!(rx.try_recv().is_err(), "a capture queues for a forced hidden paint"); + assert_eq!(h.app.capture.shots.len(), 1, "one admitted capture"); assert!(h.app.control_queue.is_empty(), "nothing waits for the window"); + // Its deadline expires it once, with the same error the transport used to get. + h.app.clock.set_test_now(Some(h.time + crate::control::SHOT_TIMEOUT + 1.0)); + let _ = ctx.run_logic(&hidden, |ctx| crate::control::background(&mut h.app, ctx)); + let shot = rx.try_recv().expect("expired"); + assert_eq!(shot.id, json!(3)); + assert!(shot.result.unwrap_err().contains("didn't deliver a screenshot"), "a capture needs paint"); + assert!(h.app.capture.shots.is_empty(), "released after its deadline"); let _ = std::fs::remove_dir_all(&dir); } @@ -408,3 +427,47 @@ fn auto_save_writes_a_changed_file_then_rests() { h.assert_idle("an auto-saved file"); let _ = std::fs::remove_dir_all(&dir); } + +/// Asserts a hidden logic tick runs background work and advances the autosave deadline +/// without counting a UI frame, and is idempotent at the same time. +#[test] +fn a_hidden_logic_tick_runs_background_work_but_counts_no_ui_frame() { + let (mut h, _) = document(); + h.settle(); + let frames = h.app.frame_stats.frames; + let deadline_before = h.app.ui.next_autosave; + h.time += crate::AUTOSAVE_SECS + 1.0; + h.app.clock.set_test_now(Some(h.time)); + let mut input = RawInput::default(); + input.viewports.insert(ViewportId::ROOT, egui::ViewportInfo { minimized: Some(true), ..Default::default() }); + let ctx = h.ctx.clone(); + let _ = ctx.run_logic(&input, |ctx| h.app.background_tick(ctx)); + assert_eq!(h.app.frame_stats.frames, frames, "a logic-only tick is not a UI frame"); + let deadline = h.app.ui.next_autosave; + assert!(deadline > deadline_before + crate::AUTOSAVE_SECS - 1.0, "the autosave deadline advanced"); + // Repeating the same due instant does not advance it again (consumed/deadline-driven work). + let _ = ctx.run_logic(&input, |ctx| h.app.background_tick(ctx)); + assert_eq!(h.app.ui.next_autosave, deadline, "idempotent at the same monotonic time"); +} + +/// Asserts a discarded sizing pass counts one drawn frame, not one per pass. +#[test] +fn a_discarded_sizing_pass_sets_up_and_counts_one_frame_only() { + let (mut h, _) = document(); + h.settle(); + let frames = h.app.frame_stats.frames; + let input = + RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(h.time + 1.0 / 60.0), ..Default::default() }; + let mut passes = 0; + let app = &mut h.app; + let mut out = h.ctx.run_ui(input, |ui| { + passes += 1; + app.ui(ui); + if passes == 1 { + ui.ctx().request_discard("test sizing pass"); + } + }); + out.textures_delta.clear(); + assert_eq!(passes, 2, "egui reran the frame"); + assert_eq!(app.frame_stats.frames, frames + 1, "one frame was drawn, not one per pass"); +} diff --git a/crates/ui-egui/src/ime_tests.rs b/crates/ui-egui/src/ime_tests.rs index a6db0cbd..e6c2f698 100644 --- a/crates/ui-egui/src/ime_tests.rs +++ b/crates/ui-egui/src/ime_tests.rs @@ -3,6 +3,7 @@ //! edit (an idle frame too) with the candidate window at the caret, the composition shows without //! entering the document, keys pressed while composing are the IME's, and the commit lands once. +use egui::output::{IMEOutput, IMESurroundingText, IMESurroundingTextPayload, ImeChangeCause}; use egui::{Event, ImeEvent, Key, Modifiers, RawInput, Rect}; use figcraft_doc::{NodeId, NodeKind}; use figcraft_engine::Session; @@ -42,6 +43,24 @@ fn composing(u: &Ui) -> Option { u.app.session.ds().unwrap().text_edit.as_ref().expect("still editing").preedit.as_ref().map(|p| p.text.clone()) } +/// The whole IME descriptor one frame leaves for the platform. +fn ime_descriptor(u: &mut Ui, events: Vec) -> IMEOutput { + u.frame_raw(RawInput { events, ..Default::default() }).platform_output.ime.expect("the IME stays on while editing") +} + +/// Return the available surrounding-text payload of an IME output, panicking when unavailable. +fn available(ime: &IMEOutput) -> &IMESurroundingTextPayload { + match &ime.surrounding_text { + IMESurroundingText::Available(payload) => payload, + IMESurroundingText::Unavailable => panic!("expected available surrounding text"), + } +} + +/// Create a text layer with `text` and return its node id. +fn text_layer(s: &mut Session, text: &str) -> NodeId { + NodeId(s.execute("text.create", &json!({"text": text, "x": 40, "y": 40})).unwrap()["id"].as_u64().unwrap()) +} + #[test] fn pinyin_composes_inline_and_commits_once_with_the_ime_on_every_frame() { // The text is measured with a font: without craft-fonts a system one, scanned here rather than @@ -85,3 +104,543 @@ fn pinyin_composes_inline_and_commits_once_with_the_ime_on_every_frame() { ime(&mut u, vec![key(Key::Enter, true), key(Key::Enter, false), Event::Text("!".into())]); assert_eq!(text(&u, t), "Hi你好\n!"); } + +/// Asserts an empty IME commit ends the composition, inserts nothing, keeps the editor +/// focused, and the next pass observes the committed text. +#[test] +fn an_empty_ime_commit_ends_the_composition_without_inserting_text() { + // Safari's egui web integration sends `Commit("")` (not `Preedit("")`) when the last + // composition character is deleted. It ends the composition; it must not leave the pre-edit + // rendering or insert anything. + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "Hello"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(5), false).unwrap(); + ime(&mut u, vec![preedit("にほ")]); + assert_eq!(composing(&u).as_deref(), Some("にほ")); + let after = ime_descriptor(&mut u, vec![Event::Ime(ImeEvent::Commit(String::new()))]); + assert_eq!(composing(&u), None, "the empty commit ends the composition"); + assert_eq!(text(&u, t), "Hello", "and inserts nothing"); + assert!(matches!(after.surrounding_text, IMESurroundingText::Available(_))); + // The editor is still focused, and the next pass observes committed text only. + let next = ime_descriptor(&mut u, vec![]); + assert_eq!(composing(&u), None); + assert_eq!(available(&next).text, "Hello"); + assert_eq!(text(&u, t), "Hello"); +} + +/// Asserts byte deletion before the paired commit replaces the exact UTF-8 range, and one undo +/// restores the pre-IME text. +#[test] +fn an_ime_replacing_text_deletes_in_bytes_before_the_paired_commit() { + // 0.37 sends `DeleteSurroundingBytes` immediately before the commit that replaces it. The + // custom engine editor deletes the exact UTF-8 range (never display-bidi text) and keeps the + // whole edit as one undo step. + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = NodeId(s.execute("text.create", &json!({"text": "ab\u{4F60}\u{597D}cd", "x": 40, "y": 40})).unwrap()["id"].as_u64().unwrap()); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(4), false).unwrap(); + ime(&mut u, vec![]); + // Compose, then replace "你好c" with "X". + ime(&mut u, vec![preedit("x")]); + ime(&mut u, vec![Event::Ime(ImeEvent::DeleteSurroundingBytes { before_bytes: 6, after_bytes: 1 }), Event::Ime(ImeEvent::Commit("X".into()))]); + assert_eq!(text(&u, t), "abXd"); + figcraft_engine::textedit::end(&mut u.app.session).unwrap(); + u.app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&u, t), "ab\u{4F60}\u{597D}cd", "one undo restores the pre-IME text"); +} + +/// Asserts a caret click invalidates the whole stale IME batch (both byte and character +/// deletions) while a later direct commit still works. +#[test] +fn a_caret_click_rejects_the_stale_delete_and_commit_in_the_same_input_batch() { + figcraft_text::scan_system_fonts(); + for delete in + [ImeEvent::DeleteSurrounding { before_chars: 1, after_chars: 1 }, ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 1 }] + { + let mut s = Session::new(); + let t = text_layer(&mut s, "abcdef"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(6), false).unwrap(); + ime(&mut u, vec![preedit("z")]); + let p = u.app.to_screen(42.0, 45.0); + // Real canvas pointer handling moves the caret and cancels preedit before text_keys. + ime( + &mut u, + vec![ + Event::PointerMoved(p), + Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + Event::Ime(delete), + Event::Ime(ImeEvent::Commit("Q".into())), + ], + ); + let edit = u.app.session.ds().unwrap().text_edit.as_ref().unwrap(); + assert!(edit.caret < 6, "the production canvas click actually moved the caret"); + assert!(edit.preedit.is_none(), "the click ended the composition"); + assert_eq!(text(&u, t), "abcdef", "neither stale deletion nor its Commit may mutate the clicked caret"); + u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }]); + // A later direct commit without an active composition is legitimate. + let caret = u.app.session.ds().unwrap().text_edit.as_ref().unwrap().caret; + ime(&mut u, vec![Event::Ime(ImeEvent::Commit("X".into()))]); + let mut expected = "abcdef".to_string(); + expected.insert(caret, 'X'); + assert_eq!(text(&u, t), expected, "the interrupted batch does not poison a later direct commit"); + figcraft_engine::textedit::end(&mut u.app.session).unwrap(); + u.app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&u, t), "abcdef", "the valid edit retains one-session undo"); + } +} + +/// Asserts a control-channel document switch rejects the old composition's delete and commit +/// batch, while a direct commit to the new target remains valid. +#[test] +fn a_document_switch_rejects_the_old_compositions_delete_and_commit_batch() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let a = text_layer(&mut s, "Alpha"); + s.execute("file.new", &json!({"name": "B"})).unwrap(); + let b = text_layer(&mut s, "Bravo"); + figcraft_engine::textedit::begin(&mut s, b, Some(5), false).unwrap(); + s.execute("doc.switch", &json!({"index": 0})).unwrap(); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, a, Some(5), false).unwrap(); + ime(&mut u, vec![preedit("z")]); + let (tx, rx) = std::sync::mpsc::channel(); + u.app.control_queue.push(crate::control::ControlRequest { + id: json!(91), + method: "engine.execute".into(), + params: json!({"window": 0, "command": "doc.switch", "params": {"index": 1}}), + reply: tx, + state: Default::default(), + }); + ime(&mut u, vec![Event::Ime(ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 }), Event::Ime(ImeEvent::Commit("Q".into()))]); + assert!(rx.try_recv().unwrap().result.is_ok(), "production control drain switched the document"); + assert_eq!(u.app.session.active, 1); + assert_eq!(text(&u, b), "Bravo", "the new target cannot inherit the old composition's mutations"); + assert_eq!(u.app.session.docs[0].doc.node(a).unwrap().kind.text().unwrap().characters, "Alpha"); + ime(&mut u, vec![Event::Ime(ImeEvent::Commit("X".into()))]); + assert_eq!(text(&u, b), "BravoX", "a direct commit to the new noncomposing target remains valid"); +} + +/// Asserts the first observation and ordinary typing are `Other`, while an accepted commit is +/// `InputMethod` with UTF-8 offsets, the same owner and generation. +#[test] +fn ime_cause_is_input_method_only_for_an_accepted_commit() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "Hi"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(2), false).unwrap(); + + let first = ime_descriptor(&mut u, vec![]); + assert_eq!(first.change_cause, ImeChangeCause::Other, "the first focused observation starts a context"); + assert_eq!(available(&first).text, "Hi"); + assert_eq!((available(&first).cursor, available(&first).anchor), (2, 2)); + + let commit = ime_descriptor(&mut u, vec![Event::Ime(ImeEvent::Commit("你好".into()))]); + assert_eq!(commit.change_cause, ImeChangeCause::InputMethod); + assert_eq!(available(&commit).text, "Hi你好"); + assert_eq!(available(&commit).cursor, 8, "UTF-8 bytes, not characters"); + assert_eq!(commit.context_generation, first.context_generation, "a commit is not a context change"); + assert_eq!(commit.owner, first.owner); + + let typed = ime_descriptor(&mut u, vec![Event::Text("x".into())]); + assert_eq!(typed.change_cause, ImeChangeCause::Other, "ordinary typing is an application edit"); + assert_eq!(available(&typed).text, "Hi你好x"); + assert_eq!(text(&u, t), "Hi你好x"); +} + +/// Asserts a frame mixing IME and application edits is `Other`, and a later pure commit is +/// `InputMethod` without lingering state. +#[test] +fn ime_cause_reports_mixed_application_and_ime_mutations_as_other() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "Hi"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(2), false).unwrap(); + ime(&mut u, vec![]); + + let mixed = ime_descriptor(&mut u, vec![Event::Ime(ImeEvent::Commit("あ".into())), Event::Text("b".into())]); + assert_eq!(mixed.change_cause, ImeChangeCause::Other, "an application edit in the same frame dominates"); + assert_eq!(available(&mixed).text, "Hiあb"); + + // The next frame is only an accepted commit: the frame-scoped `Other` does not linger. + let pure = ime_descriptor(&mut u, vec![Event::Ime(ImeEvent::Commit("う".into()))]); + assert_eq!(pure.change_cause, ImeChangeCause::InputMethod); + assert_eq!(available(&pure).text, "Hiあbう"); + + // An application edit before the IME receipt, and IME → application → IME, are both Other. + let before = ime_descriptor(&mut u, vec![Event::Text("c".into()), Event::Ime(ImeEvent::Commit("え".into()))]); + assert_eq!(before.change_cause, ImeChangeCause::Other); + let sandwiched = + ime_descriptor(&mut u, vec![Event::Ime(ImeEvent::Commit("お".into())), Event::Text("d".into()), Event::Ime(ImeEvent::Commit("か".into()))]); + assert_eq!(sandwiched.change_cause, ImeChangeCause::Other); + assert_eq!(text(&u, t), "Hiあbうcえおdか"); +} + +/// Asserts a control-channel text edit is an application change (`Other`). +#[test] +fn a_control_channel_text_edit_is_an_application_change() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "Hi"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(2), false).unwrap(); + ime(&mut u, vec![]); + + // A command from the control channel (not an ImeEvent) changes committed text. + u.app.session.execute("text.edit.insert", &json!({"text": "z"})).unwrap(); + let out = ime_descriptor(&mut u, vec![]); + assert_eq!(out.change_cause, ImeChangeCause::Other); + assert_eq!(available(&out).text, "Hiz"); +} + +/// Asserts a byte delete followed by a commit is `InputMethod` and describes the +/// post-mutation text and caret. +#[test] +fn ime_delete_then_commit_is_input_method_and_describes_the_post_mutation_text() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "ab\u{4F60}\u{597D}cd"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(4), false).unwrap(); + ime(&mut u, vec![]); + + let out = ime_descriptor( + &mut u, + vec![Event::Ime(ImeEvent::DeleteSurroundingBytes { before_bytes: 6, after_bytes: 1 }), Event::Ime(ImeEvent::Commit("X".into()))], + ); + assert_eq!(out.change_cause, ImeChangeCause::InputMethod); + assert_eq!(available(&out).text, "abXd", "the snapshot is observed after both mutations"); + assert_eq!(available(&out).cursor, 3); + assert_eq!(text(&u, t), "abXd"); +} + +/// Asserts preedit text is excluded from surrounding text and earns no receipt, while the +/// following commit is `InputMethod`. +#[test] +fn preedit_is_not_surrounding_text_but_the_commit_that_follows_is_input_method() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "Hi"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(2), false).unwrap(); + ime(&mut u, vec![]); + + let composing = ime_descriptor(&mut u, vec![preedit("にほ")]); + assert_eq!(available(&composing).text, "Hi", "the composition is excluded"); + assert_eq!((available(&composing).cursor, available(&composing).anchor), (2, 2)); + assert_eq!(composing.change_cause, ImeChangeCause::Other, "preedit alone earns no receipt"); + + let committed = ime_descriptor(&mut u, vec![preedit(""), Event::Ime(ImeEvent::Commit("にほ".into()))]); + assert_eq!(committed.change_cause, ImeChangeCause::InputMethod); + assert_eq!(available(&committed).text, "Hiにほ"); + assert_eq!(available(&committed).cursor, 8); +} + +/// Asserts surrounding text is logical UTF-8 with byte offsets and keeps a reversed +/// selection. +#[test] +fn ime_surrounding_text_is_logical_utf8_and_keeps_a_reversed_selection() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "café😀e\u{301}x"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(8), false).unwrap(); + figcraft_engine::textedit::set_caret(&mut u.app.session, 8, false).unwrap(); + figcraft_engine::textedit::set_caret(&mut u.app.session, 5, true).unwrap(); + + let out = ime_descriptor(&mut u, vec![]); + let payload = available(&out); + assert_eq!(payload.text, "café😀e\u{301}x"); + assert_eq!((payload.cursor, payload.anchor), (9, 13)); + assert!(payload.cursor < payload.anchor, "the reversed selection is not sorted"); +} + +/// Asserts empty text is available while a selection wider than the 4000-byte budget is +/// explicitly unavailable but still has a nonzero IME context generation. +#[test] +fn empty_text_is_available_while_a_too_wide_selection_is_explicitly_unavailable() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, ""); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(0), false).unwrap(); + let empty = ime_descriptor(&mut u, vec![]); + let payload = available(&empty); + assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("", 0, 0)); + + // The same editor over a selection wider than the 4000-byte budget cannot describe it. + let mut s = Session::new(); + let long = "a".repeat(5000); + let t = text_layer(&mut s, &long); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, None, false).unwrap(); + let wide = ime_descriptor(&mut u, vec![]); + assert_eq!(wide.surrounding_text, IMESurroundingText::Unavailable); + assert!(wide.context_generation > 0, "the owner stays known when only the text is unavailable"); +} + +/// Asserts replacing a too-wide selection with a commit is still an `InputMethod` receipt. +#[test] +fn a_commit_replacing_a_too_wide_selection_is_still_an_input_method_receipt() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let long = "a".repeat(5000); + let t = text_layer(&mut s, &long); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, None, false).unwrap(); + let wide = ime_descriptor(&mut u, vec![]); + assert_eq!(wide.surrounding_text, IMESurroundingText::Unavailable); + + // The unavailable baseline and the pre-mutation stamp carry the engine's document revision, + // so replacing the over-wide selection with a normal commit is a provable IME receipt rather + // than an unproven `Other` change. + let out = ime_descriptor(&mut u, vec![Event::Ime(ImeEvent::Commit("X".into()))]); + assert_eq!(out.change_cause, ImeChangeCause::InputMethod); + assert_eq!(text(&u, t), "X"); + assert_eq!(available(&out).text, "X"); + assert_eq!((available(&out).cursor, available(&out).anchor), (1, 1)); +} + +/// Asserts another text node is another owner, and re-entering the same node changes the +/// context generation while keeping the owner key. +#[test] +fn a_new_edit_session_and_another_text_node_are_new_ime_owners() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let a = text_layer(&mut s, "A"); + let b = text_layer(&mut s, "B"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, a, Some(1), false).unwrap(); + let first = ime_descriptor(&mut u, vec![]); + + figcraft_engine::textedit::end(&mut u.app.session).unwrap(); + figcraft_engine::textedit::begin(&mut u.app.session, b, Some(1), false).unwrap(); + let other = ime_descriptor(&mut u, vec![]); + assert_ne!(other.owner, first.owner, "another text node is another owner"); + assert_ne!(other.context_generation, first.context_generation); + + figcraft_engine::textedit::end(&mut u.app.session).unwrap(); + figcraft_engine::textedit::begin(&mut u.app.session, a, Some(1), false).unwrap(); + let again = ime_descriptor(&mut u, vec![]); + assert_eq!(again.owner, first.owner, "the owner key is the document and node"); + assert_ne!(again.context_generation, first.context_generation, "re-entering is a replaced target"); +} + +/// Asserts an accepted commit survives a discarded rerun without inserting twice and keeps +/// its `InputMethod` cause, while a mixed rerun is `Other`. +#[test] +fn an_accepted_commit_survives_a_discarded_pass_without_reapplying_or_losing_its_cause() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "Hi"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(2), false).unwrap(); + let first = ime_descriptor(&mut u, vec![]); + + let out = u.frame_raw_with_discard(RawInput { events: vec![Event::Ime(ImeEvent::Commit("あ".into()))], ..Default::default() }); + let ime = out.platform_output.ime.expect("the presented pass keeps the IME on"); + assert_eq!(text(&u, t), "Hiあ", "the discarded rerun must not insert twice"); + assert_eq!(available(&ime).text, "Hiあ"); + assert_eq!(ime.change_cause, ImeChangeCause::InputMethod, "the unchanged second pass keeps the first pass's cause"); + assert_eq!(ime.context_generation, first.context_generation); + + let mixed = u + .frame_raw_with_discard(RawInput { events: vec![Event::Ime(ImeEvent::Commit("い".into())), Event::Text("x".into())], ..Default::default() }); + let mixed = mixed.platform_output.ime.expect("still editing"); + assert_eq!(text(&u, t), "Hiあいx"); + assert_eq!(mixed.change_cause, ImeChangeCause::Other); +} + +/// Asserts IME context state is scoped per viewport: each viewport has its own generation, +/// the root keeps its context across an aux pass, and a closed aux viewport does not inherit +/// the old generation. +#[test] +fn ime_context_state_is_scoped_per_viewport() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "Hi"); + figcraft_engine::textedit::begin(&mut s, t, Some(2), false).unwrap(); + let mut app = crate::FigApp::new(s, Box::new(crate::NoServices)); + // No window drew here, so give the canvas a finite rect for the caret geometry. + app.canvas.rect = Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); + let ctx = egui::Context::default(); + ctx.set_embed_viewports(false); + let aux = egui::ViewportId::from_hash_of("figcraft-ime-aux"); + + let mut raw = RawInput { viewport_id: egui::ViewportId::ROOT, ..Default::default() }; + raw.viewports.entry(egui::ViewportId::ROOT).or_default(); + let root_out = ctx.run_ui(raw, |ui| { + ui.ctx().show_viewport_deferred(aux, egui::ViewportBuilder::default(), |_, _| {}); + crate::shortcuts::ime_output(&mut app, ui.ctx()); + }); + let root = root_out.platform_output.ime.clone().expect("root descriptor"); + root_out.drop_without_applying_deltas(); + + let mut raw = RawInput { viewport_id: aux, ..Default::default() }; + raw.viewports.entry(aux).or_default(); + let aux_out = ctx.run_ui(raw, |ui| crate::shortcuts::ime_output(&mut app, ui.ctx())); + let aux_ime = aux_out.platform_output.ime.clone().expect("aux descriptor"); + aux_out.drop_without_applying_deltas(); + assert_eq!(aux_ime.owner, root.owner, "the same edit is the same owner"); + assert_ne!(aux_ime.context_generation, root.context_generation, "each viewport gets its own generation"); + assert_eq!(available(&aux_ime).text, "Hi"); + + // The root's next consecutive pass keeps its context even though the aux pass ran between. + let mut raw = RawInput { viewport_id: egui::ViewportId::ROOT, ..Default::default() }; + raw.viewports.entry(egui::ViewportId::ROOT).or_default(); + let root_out = ctx.run_ui(raw, |ui| { + ui.ctx().show_viewport_deferred(aux, egui::ViewportBuilder::default(), |_, _| {}); + crate::shortcuts::ime_output(&mut app, ui.ctx()); + }); + let root_again = root_out.platform_output.ime.clone().expect("root descriptor"); + root_out.drop_without_applying_deltas(); + assert_eq!(root_again.context_generation, root.context_generation); + + // Closing the aux viewport releases its state; it never inherits the old context. + app.ime.end_viewport(aux); + let mut raw = RawInput { viewport_id: aux, ..Default::default() }; + raw.viewports.entry(aux).or_default(); + let aux_out = ctx.run_ui(raw, |ui| crate::shortcuts::ime_output(&mut app, ui.ctx())); + let aux_again = aux_out.platform_output.ime.clone().expect("aux descriptor"); + aux_out.drop_without_applying_deltas(); + assert_ne!(aux_again.context_generation, aux_ime.context_generation); +} + +/// Asserts the engine's IME surrounding-text byte limit equals egui's wire limit. +#[test] +fn the_engine_surrounding_limit_matches_the_wire_limit() { + // A divergence would truncate or overrun the bounded payload silently. + let engine = figcraft_engine::textedit::MAX_IME_SURROUNDING_BYTES; + let wire = egui::output::MAX_IME_SURROUNDING_TEXT_BYTES; + assert_eq!(engine, wire); +} + +/// Asserts a frame observes the IME exactly once in both the editor pass and the Home +/// early-return pass. +#[test] +fn a_frame_observes_the_ime_once_even_when_it_ends_early() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "Hi"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(2), false).unwrap(); + let before = u.app.ime_observations; + u.frame(vec![]); + assert_eq!(u.app.ime_observations - before, 1, "one final observation for the editor pass"); + + // The home/present early returns make the same single idle observation. + u.app.ui.home = true; + let before = u.app.ime_observations; + u.frame(vec![]); + assert_eq!(u.app.ime_observations - before, 1, "one idle observation on the home screen"); +} + +/// Asserts a double-click inside an existing edit selects the word without changing the IME +/// owner or generation. +#[test] +fn a_double_click_inside_the_edit_keeps_the_ime_generation() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "Hello world"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(2), false).unwrap(); + let (p, zoom) = { + let doc = u.app.session.doc().unwrap(); + let node = doc.node(t).unwrap(); + let Some((layout, dy)) = figcraft_text::layout_node(node) else { return }; + if layout.glyphs.is_empty() || layout.lines.is_empty() { + return; // no fonts + } + let (x0, x1) = (layout.caret(7).0, layout.caret(8).0); + let line = &layout.lines[0]; + let world = doc.world_transform(t); + let p = world * figcraft_geom::Point::new((x0 + x1) / 2.0, line.top + line.height / 2.0 + dy); + (p, u.app.session.ds().unwrap().view.zoom) + }; + let first = ime_descriptor(&mut u, vec![]); + let ev = figcraft_engine::PointerEvent::at(figcraft_engine::PointerKind::DoubleClick, p.x, p.y, zoom); + u.app.session.pointer(&ev).unwrap(); + + let after = ime_descriptor(&mut u, vec![]); + assert_eq!(after.owner, first.owner, "the same text node is the same owner"); + assert_eq!(after.context_generation, first.context_generation, "a word-select inside the edit is not a replaced target"); + assert_eq!(available(&after).text, "Hello world"); +} + +/// Asserts a `window.new` that moves the edited file leaves the root window emitting no IME +/// descriptor. +#[test] +fn a_window_new_that_moves_the_edited_file_leaves_the_root_ime_idle() { + figcraft_text::scan_system_fonts(); + let mut s = Session::empty(); + s.execute("file.new", &json!({"name": "A"})).unwrap(); + let uid = s.ds().unwrap().uid; + let t = text_layer(&mut s, "Hi"); + figcraft_engine::textedit::begin(&mut s, t, Some(2), false).unwrap(); + s.execute("file.new", &json!({"name": "B"})).unwrap(); + let a = s.docs.iter().position(|d| d.uid == uid).unwrap(); + s.execute("doc.switch", &json!({"index": a})).unwrap(); + let mut u = Ui::new(s); + u.ctx.set_embed_viewports(false); + let first = ime_descriptor(&mut u, vec![]); + assert_eq!(available(&first).text, "Hi"); + + // A `window.new` queued before the pass is applied after `windows::begin` picked the file + // for the root: the root no longer shows the edited file when the pass ends. + u.app.window_requests.push(figcraft_engine::UiRequest { kind: "window.new".into(), data: json!({"index": a}) }); + let out = u.frame_raw(RawInput::default()); + assert_ne!(u.app.windows.of(uid), 0, "the file moved to its own window"); + assert!(out.platform_output.ime.is_none(), "the root must not emit a descriptor for a file it no longer shows"); +} + +/// Asserts a CRLF-normalized commit is application-origin (`Other`), and bare-newline and +/// control-only commits insert nothing. +#[test] +fn a_transformed_commit_is_application_origin_and_a_bare_newline_does_nothing() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "Hi"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(2), false).unwrap(); + ime(&mut u, vec![]); + + let normalized = ime_descriptor(&mut u, vec![Event::Ime(ImeEvent::Commit("a\r\nb".into()))]); + assert_eq!(normalized.change_cause, ImeChangeCause::Other, "CRLF normalisation is an application transformation"); + assert_eq!(text(&u, t), "Hia\nb"); + assert_eq!(available(&normalized).text, "Hia\nb"); + + let newline = ime_descriptor(&mut u, vec![Event::Ime(ImeEvent::Commit("\n".into()))]); + assert_eq!(text(&u, t), "Hia\nb", "stock TextEdit ignores a bare newline commit"); + assert!(matches!(newline.surrounding_text, IMESurroundingText::Available(_))); + let control = ime_descriptor(&mut u, vec![Event::Ime(ImeEvent::Commit("\u{0}\u{7}".into()))]); + assert_eq!(text(&u, t), "Hia\nb", "a control-only commit inserts nothing"); + assert!(matches!(control.surrounding_text, IMESurroundingText::Available(_))); +} + +/// Asserts character deletion keeps the selection for the paired commit, which replaces it +/// and reports `InputMethod`. +#[test] +fn ime_delete_surrounding_chars_keeps_the_selection_for_the_paired_commit() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "ab\u{4F60}\u{597D}cd"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(2), false).unwrap(); + figcraft_engine::textedit::set_caret(&mut u.app.session, 4, true).unwrap(); // "你好" + ime(&mut u, vec![]); + + let out = ime_descriptor( + &mut u, + vec![Event::Ime(ImeEvent::DeleteSurrounding { before_chars: 1, after_chars: 1 }), Event::Ime(ImeEvent::Commit("X".into()))], + ); + // The delete removes "b" and "c"; the selected "你好" stays for the commit, which replaces it. + assert_eq!(text(&u, t), "aXd"); + assert_eq!(available(&out).text, "aXd"); + assert_eq!(available(&out).cursor, 2); + assert_eq!(out.change_cause, ImeChangeCause::InputMethod); +} diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index c84db661..fcc0bbc6 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -81,7 +81,7 @@ pub fn rail(app: &mut FigApp, ui: &mut Ui) { let mut y = r.min.y + 8.0; // Main menu (logo). let lr = Rect::from_center_size(pos2(r.center().x, y + 16.0), vec2(36.0, 32.0)); - let lresp = ui.interact(lr, ui.id().with("rail_menu"), Sense::click()).tip(tr("Main menu")); + let lresp = ui.interact(lr, ui.scope_id().with("rail_menu"), Sense::click()).tip(tr("Main menu")); if lresp.hovered() || app.ui.main_menu { ui.painter().rect_filled(lr, CornerRadius::same(6), t.hover); } @@ -100,7 +100,7 @@ pub fn rail(app: &mut FigApp, ui: &mut Ui) { for (icon, label, tab) in items { let ir = Rect::from_center_size(pos2(r.center().x, y + 14.0), vec2(36.0, 28.0)); let hit = Rect::from_min_max(pos2(r.min.x, y), pos2(r.max.x, y + 48.0)); - let resp = ui.interact(hit, ui.id().with(("rail", label)), Sense::click()).tip(label); + let resp = ui.interact(hit, ui.scope_id().with(("rail", label)), Sense::click()).tip(label); let active = tab.is_some_and(|tb| tb == app.ui.left_tab && !app.ui.left_collapsed && !app.ui.versions.open); if tab.is_some() { a11y::tab(&resp, label, active); @@ -134,6 +134,8 @@ pub fn rail(app: &mut FigApp, ui: &mut Ui) { } } +/// Draw the left panel: versions when open, otherwise the file/Assets header with in-place +/// file rename and collapse control, followed by Pages and Layers or the Assets panel. pub fn left_panel(app: &mut FigApp, ui: &mut Ui) { if app.ui.versions.open { crate::versions::panel(app, ui); @@ -149,13 +151,13 @@ pub fn left_panel(app: &mut FigApp, ui: &mut Ui) { let name = app.session.doc().map(|d| d.name.clone()).unwrap_or_default(); let nr = Rect::from_min_max(pos2(head.min.x + 14.0, head.min.y + 14.0), pos2(head.max.x - 48.0, head.min.y + 34.0)); let start = std::mem::take(&mut app.ui.rename_file); - if let (Some(new), _) = crate::widgets::rename_field(ui, Id::new("filename"), &name, nr, semibold(13.0), start) { + if let (Some(new), _) = crate::widgets::rename_field(ui, Id::unique("filename"), &name, nr, semibold(13.0), start) { app.run("file.rename", json!({"name": new})); } ui.painter().text(pos2(head.min.x + 16.0, head.min.y + 46.0), Align2::LEFT_CENTER, tr("Drafts"), regular(11.0), t.text_secondary); } let hr = Rect::from_center_size(pos2(head.max.x - 24.0, head.min.y + 24.0), vec2(24.0, 24.0)); - let hresp = ui.interact(hr, ui.id().with("hideui"), Sense::click()).tip(tr("Collapse panel")); + let hresp = ui.interact(hr, ui.scope_id().with("hideui"), Sense::click()).tip(tr("Collapse panel")); if hresp.hovered() { ui.painter().rect_filled(hr, CornerRadius::same(5), t.hover); } @@ -173,6 +175,8 @@ pub fn left_panel(app: &mut FigApp, ui: &mut Ui) { } } +/// Draw the page list with open/search/add controls, filtering, selection, rename, +/// reorder/drag and layer drop-to-page; Dev Mode allows opening pages but blocks mutations. fn pages(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); // Dev Mode only inspects: rows open pages, but don't add, rename, reorder, duplicate or delete. @@ -182,19 +186,19 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } let w = ui.available_width(); let (hr, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); - let tresp = ui.interact(Rect::from_min_size(hr.min, vec2(w - 40.0, 40.0)), ui.id().with("pages_toggle"), Sense::click()); + let tresp = ui.interact(Rect::from_min_size(hr.min, vec2(w - 40.0, 40.0)), ui.scope_id().with("pages_toggle"), Sense::click()); a11y::toggle(&tresp, tr("Pages"), app.ui.pages_open); ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, tr("Pages"), semibold(11.0), t.text); if tresp.clicked() { app.ui.pages_open = !app.ui.pages_open; } let sr = Rect::from_center_size(pos2(hr.max.x - 50.0, hr.center().y), vec2(24.0, 24.0)); - let sresp = ui.interact(sr, ui.id().with("lsearch"), Sense::click()).tip(tr("Search pages")); + let sresp = ui.interact(sr, ui.scope_id().with("lsearch"), Sense::click()).tip(tr("Search pages")); if sresp.hovered() || app.ui.page_search { ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover); } icons::paint(ui.painter(), sr.shrink(5.0), Icon::Search, t.icon); - let search_id = Id::new("page_search_field"); + let search_id = Id::unique("page_search_field"); if sresp.clicked() { app.ui.page_search = !app.ui.page_search; app.ui.page_filter.clear(); @@ -203,7 +207,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } } let ar = Rect::from_center_size(pos2(hr.max.x - 22.0, hr.center().y), vec2(24.0, 24.0)); - let aresp = ui.interact(ar, ui.id().with("addpage"), Sense::click()).tip(tr("Add new page")); + let aresp = ui.interact(ar, ui.scope_id().with("addpage"), Sense::click()).tip(tr("Add new page")); if aresp.hovered() { ui.painter().rect_filled(ar, CornerRadius::same(5), t.hover); } @@ -285,7 +289,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { let tr = Rect::from_min_max(pos2(r.min.x + 8.0, r.min.y + 4.0), pos2(r.max.x - 4.0, r.max.y - 4.0)); let start = ui.data_mut(|d| d.remove_temp::(page_rename_start_id())).unwrap_or(false); let font = if id == cur { semibold(11.0) } else { regular(11.0) }; - if let (Some(new), _) = crate::widgets::rename_field(ui, Id::new(("pagename", id)), &name, tr, font, start) { + if let (Some(new), _) = crate::widgets::rename_field(ui, Id::unique(("pagename", id)), &name, tr, font, start) { rename_done = Some((id, new)); } } else if !divider { @@ -391,8 +395,9 @@ fn pages_edge(app: &mut FigApp, ui: &mut Ui, shown: f32) { let w = ui.available_width(); let (line, _) = ui.allocate_exact_size(vec2(w, 1.0), Sense::hover()); let grab = ui.style().interaction.resize_grab_radius_side; - let id = Id::new("pages_edge"); - let resp = ui.interact(line.expand2(vec2(0.0, grab)), id, Sense::click_and_drag()); + let id = Id::unique("pages_edge"); + // Not focusable, like the side panels' edges: Tab/F6 must not stop on an unnamed drag handle. + let resp = ui.interact(line.expand2(vec2(0.0, grab)), id, Sense::CLICK | Sense::DRAG); let active = resp.hovered() || resp.dragged(); if active { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); @@ -425,12 +430,12 @@ fn pages_edge(app: &mut FigApp, ui: &mut Ui, shown: f32) { /// A page rename just asked for by a double click: its field takes the focus when drawn (temp data). fn page_rename_start_id() -> Id { - Id::new("page_rename_start") + Id::unique("page_rename_start") } /// The layer row being dragged in the panel (temp data). fn layer_drag_id() -> Id { - Id::new("layer_drag") + Id::unique("layer_drag") } /// A press on a row's eye or padlock dragged across other rows: every row passed gets the state the @@ -443,8 +448,9 @@ struct Sweep { ids: Vec, } +/// Stable egui id for the eye/padlock sweep state. fn sweep_id() -> Id { - Id::new("layer_toggle_sweep") + Id::unique("layer_toggle_sweep") } /// What a drag of row `dragging` moves: the selection when the row is in it, else the row. @@ -489,7 +495,7 @@ fn hash_expanded(expanded: &BTreeSet) -> u64 { pub(crate) fn rows_for(ui: &Ui, doc: &Arc, page: NodeId, expanded: &BTreeSet, filter: &str) -> Arc> { let ex = if filter.is_empty() { hash_expanded(expanded) } else { 0 }; ui.data_mut(|d| { - let c = d.get_temp_mut_or_default::(Id::new("layer_rows")); + let c = d.get_temp_mut_or_default::(Id::unique("layer_rows")); let fresh = c.doc.as_ref().is_some_and(|d| Arc::ptr_eq(d, doc)) && c.page == Some(page) && c.expanded == ex && c.filter == filter; if !fresh { let rows = listed_rows(doc, page, expanded, filter); @@ -653,8 +659,9 @@ fn rename_layer(app: &mut FigApp, id: NodeId) { app.ui.layers_seen_sel = app.session.selection(); } +/// Stable egui id for a layer row's rename field. fn rename_id(id: NodeId) -> Id { - Id::new(("rename", id)) + Id::unique(("rename", id)) } /// Left edge of a row's contents (disclosure triangle) in the row's inner rect `rr`. @@ -704,7 +711,7 @@ fn search_field(ui: &mut Ui, t: &Tokens, w: f32, id: Id, hint: &str, text: &mut .frame(egui::Frame::NONE) .font(regular(11.0)) .hint_text(hint) - .vertical_align(egui::Align::Center) + .align(egui::Align2::LEFT_CENTER) .desired_width(te_rect.width()), ); // After the field: the click that opened it ends elsewhere, which would take the focus back. @@ -721,6 +728,8 @@ fn search_field(ui: &mut Ui, t: &Tokens, w: f32, id: Id, hint: &str, text: &mut } } +/// Draw the layer tree with search, cached rows, reveal-selection expansion and scrolling, +/// rename/Tab navigation, selection and drag/drop, sweep toggles and Dev Mode restrictions. fn layers(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); // Dev Mode only inspects: rows select and expand, but don't rename, reorder, hide or lock. @@ -732,12 +741,12 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { let (hr, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, tr("Layers"), semibold(11.0), t.text); let sr = Rect::from_center_size(pos2(hr.max.x - 22.0, hr.center().y), vec2(24.0, 24.0)); - let sresp = ui.interact(sr, ui.id().with("layer_search_btn"), Sense::click()).tip(tr("Search layers")); + let sresp = ui.interact(sr, ui.scope_id().with("layer_search_btn"), Sense::click()).tip(tr("Search layers")); if sresp.hovered() || app.ui.layer_search { ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover); } icons::paint(ui.painter(), sr.shrink(5.0), Icon::Search, t.icon); - let search_id = Id::new("layer_search_field"); + let search_id = Id::unique("layer_search_field"); if sresp.clicked() { app.ui.layer_search = !app.ui.layer_search; app.ui.layer_filter.clear(); @@ -889,7 +898,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { // Expand triangle. if row.has_kids { let tri = Rect::from_center_size(pos2(x0 + 6.0, r.center().y), vec2(14.0, 14.0)); - let tresp = ui.interact(tri, ui.id().with(("tri", row.id)), Sense::click()); + let tresp = ui.interact(tri, ui.scope_id().with(("tri", row.id)), Sense::click()); let open = app.ui.expanded.contains(&row.id); a11y::button(&tresp, if open { tr("Collapse") } else { tr("Expand") }); icons::paint(ui.painter(), tri.shrink(1.0), if open { Icon::Chevron } else { Icon::ChevronRight }, t.icon_secondary); @@ -956,7 +965,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { icons::paint(ui.painter(), er.shrink(3.0), if hidden { Icon::EyeOff } else { Icon::Eye }, t.icon_secondary); } if show_eye && !dev { - let eresp = ui.interact(er, ui.id().with(("eye", row.id)), Sense::click_and_drag()); + let eresp = ui.interact(er, ui.scope_id().with(("eye", row.id)), Sense::click_and_drag()); a11y::toggle(&eresp, tr("Hidden"), hidden); if eresp.clicked() { vis = Some(row.id); @@ -970,7 +979,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { icons::paint(ui.painter(), lr.shrink(3.0), if locked || row.locked_above { Icon::Lock } else { Icon::Unlock }, col); } if show_lock && !dev { - let lresp = ui.interact(lr, ui.id().with(("lock", row.id)), Sense::click_and_drag()); + let lresp = ui.interact(lr, ui.scope_id().with(("lock", row.id)), Sense::click_and_drag()); a11y::toggle(&lresp, tr("Locked"), locked || row.locked_above); if lresp.clicked() { lock = Some(row.id); @@ -1025,7 +1034,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { ui.painter().rect_stroke(rr, CornerRadius::same(5), Stroke::new(2.0, ACCENT), egui::StrokeKind::Inside); // Holding over a collapsed group or frame opens it, to drop deeper inside. if row.has_kids && !app.ui.expanded.contains(&row.id) { - let hold_id = Id::new("layer_drag_hold"); + let hold_id = Id::unique("layer_drag_hold"); let now = ui.input(|i| i.time); match ui.data(|d| d.get_temp::<(NodeId, f64)>(hold_id)) { Some((id, since)) if id == row.id => { @@ -1060,7 +1069,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { // Empty space below clears selection. let rest = Rect::from_x_y_ranges(band.x_range(), (top + total)..=(top + total.max(viewport.max.y))); // A click target for the pointer only (no stop for the keyboard). - if rest.height() > 0.0 && ui.interact(rest, Id::new("layers_empty_space"), Sense::CLICK).clicked() { + if rest.height() > 0.0 && ui.interact(rest, Id::unique("layers_empty_space"), Sense::CLICK).clicked() { app.run("select.none", json!({})); } }); @@ -1096,7 +1105,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { if ui.input(|i| i.pointer.any_released()) { ui.data_mut(|d| { d.remove::(drag_id); - d.remove::<(NodeId, f64)>(Id::new("layer_drag_hold")); + d.remove::<(NodeId, f64)>(Id::unique("layer_drag_hold")); }); // A drag started in the design can end after a switch to Dev Mode. if let (Some(_), Some((target, zone))) = (dragging, drop) diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs index 4d987e66..0f312499 100644 --- a/crates/ui-egui/src/layers_behaviour_tests.rs +++ b/crates/ui-egui/src/layers_behaviour_tests.rs @@ -312,6 +312,7 @@ fn pages_down_to(u: &mut Ui, to: f32) -> Vec { opened } +/// Asserts a 60-page list scrolls with the wheel to its last page, past its first pages. #[test] fn a_long_pages_list_scrolls_and_leaves_room_for_the_layers() { let mut s = Session::new(); @@ -330,6 +331,7 @@ fn a_long_pages_list_scrolls_and_leaves_room_for_the_layers() { // The wheel over the pages list scrolls it to its last page. let wheel = Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, delta: egui::vec2(0.0, -400.0), phase: egui::TouchPhase::Move, modifiers: Default::default(), @@ -382,6 +384,8 @@ fn double_click_checking_focus(u: &mut Ui, p: Pos2) { } } +/// Asserts double-clicking a page selects and renames it, with the keyboard in its name +/// field for the screen-reader tree. #[test] fn double_clicking_a_page_renames_it_with_the_focus_in_the_screen_reader_tree() { let mut s = Session::new(); @@ -394,10 +398,10 @@ fn double_clicking_a_page_renames_it_with_the_focus_in_the_screen_reader_tree() double_click_checking_focus(&mut u, r2); assert_eq!(u.app.session.page().unwrap(), p2); assert_eq!(u.app.ui.rename_page.as_ref().map(|r| r.0), Some(p2), "a double click renames the page"); - assert!(u.ctx.memory(|m| m.has_focus(Id::new(("pagename", p2)).with("te"))), "with the keyboard in its name"); + assert!(u.ctx.memory(|m| m.has_focus(Id::unique(("pagename", p2)).with("te"))), "with the keyboard in its name"); // The page that's open. u.app.ui.rename_page = None; - u.ctx.memory_mut(|m| m.surrender_focus(Id::new(("pagename", p2)).with("te"))); + u.ctx.memory_mut(|m| m.surrender_focus(Id::unique(("pagename", p2)).with("te"))); u.app.session.execute("page.select", &json!({"id": p1})).unwrap(); for _ in 0..30 { u.frame(vec![]); diff --git a/crates/ui-egui/src/layers_drag_tests.rs b/crates/ui-egui/src/layers_drag_tests.rs index fb74e155..978a3b23 100644 --- a/crates/ui-egui/src/layers_drag_tests.rs +++ b/crates/ui-egui/src/layers_drag_tests.rs @@ -43,6 +43,7 @@ impl Ui { /// screen and clock. pub(crate) fn frame_raw(&mut self, raw: RawInput) -> egui::FullOutput { self.time += 1.0 / 60.0; + self.app.clock.set_test_now(Some(self.time)); let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), ..raw }; let app = &mut self.app; let mut out = self.ctx.run_ui(input, |ui| app.ui(ui)); @@ -50,6 +51,32 @@ impl Ui { out } + /// One frame that egui reruns as a discarded pass (the returned output is the presented pass). + /// + /// The rerun sees the same `RawInput`; the custom text editor must not apply its events twice, + /// and the IME descriptor of the presented pass must still describe the first pass's result. + pub(crate) fn frame_raw_with_discard(&mut self, raw: RawInput) -> egui::FullOutput { + self.time += 1.0 / 60.0; + self.app.clock.set_test_now(Some(self.time)); + let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), ..raw }; + let Ui { ctx, app, .. } = self; + let mut out = ctx.run_ui(input, |ui| { + app.ui(ui); + if ui.ctx().current_pass_index() == 0 { + ui.ctx().request_discard("ime test: exercise a discarded pass"); + } + }); + out.textures_delta.clear(); + out + } + + /// Move the harness time (and the app's monotonic clock) forward, e.g. past a deadline; the + /// next frame continues from here. + pub(crate) fn advance_clock(&mut self, secs: f64) { + self.time = secs; + self.app.clock.set_test_now(Some(secs)); + } + /// The middle of `id`'s row in the layers panel (found by hovering down the panel). pub(crate) fn row_at(&mut self, id: NodeId) -> Pos2 { let mut hits = Vec::new(); diff --git a/crates/ui-egui/src/layers_rename_tests.rs b/crates/ui-egui/src/layers_rename_tests.rs index c4b9211c..c1c229eb 100644 --- a/crates/ui-egui/src/layers_rename_tests.rs +++ b/crates/ui-egui/src/layers_rename_tests.rs @@ -144,6 +144,8 @@ fn rename_without_a_name_renames_one_row_or_opens_the_dialog() { assert!(std::sync::Arc::ptr_eq(&doc, &s.ds().unwrap().doc)); } +/// Asserts a layer rename continues after its row is scrolled out of view and commits on +/// Enter. #[test] fn a_rename_scrolled_out_of_view_goes_on() { let mut s = Session::new(); @@ -158,6 +160,7 @@ fn a_rename_scrolled_out_of_view_goes_on() { let p = egui::pos2(150.0, 400.0); let wheel = Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, delta: egui::vec2(0.0, -400.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE, diff --git a/crates/ui-egui/src/layout_panel.rs b/crates/ui-egui/src/layout_panel.rs index b5ff1d45..0874da2f 100644 --- a/crates/ui-egui/src/layout_panel.rs +++ b/crates/ui-egui/src/layout_panel.rs @@ -156,9 +156,9 @@ fn dimensions(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], has let col = widgets::col_width(ui.available_width()); widgets::sublabels(ui, tr("Dimensions"), None); row(ui, 32.0, |ui| { - let e = crate::variable_ui::number_field_mixed(app, ui, &ids, "width", Id::new("lay_w"), Lead::Text("W"), w, "", col); + let e = crate::variable_ui::number_field_mixed(app, ui, &ids, "width", Id::unique("lay_w"), Lead::Text("W"), w, "", col); app.field_of(&ids, "width", e, w_of, |v| json!(v.max(0.0))); - let e = crate::variable_ui::number_field_mixed(app, ui, &ids, "height", Id::new("lay_h"), Lead::Text("H"), h, "", col); + let e = crate::variable_ui::number_field_mixed(app, ui, &ids, "height", Id::unique("lay_h"), Lead::Text("H"), h, "", col); app.field_of(&ids, "height", e, h_of, |v| json!(v.max(0.0))); let locked = common(doc, sel, |n| n.constrain_proportions).unwrap_or(false); let link = ui @@ -259,7 +259,7 @@ fn limit_row(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], hori let (lead, add) = if is_min { (tr(axis.min_lead), tr(axis.add_min)) } else { (tr(axis.max_lead), tr(axis.add_max)) }; if has(is_min) { let v = common(doc, sel, |n| limit(n, horizontal, is_min).map(|v| (v * 100.0).round() / 100.0)).flatten(); - let e = crate::variable_ui::number_field(app, ui, &ids, key, Id::new(("lay_limit", key)), Lead::Text(lead), v, "", col); + let e = crate::variable_ui::number_field(app, ui, &ids, key, Id::unique(("lay_limit", key)), Lead::Text(lead), v, "", col); app.field(&ids, key, e, |v| json!(v.max(0.0))); } else if add_slot(ui, lead, add, col).clicked() { app.set_props(&ids, json!({key: current})); @@ -283,6 +283,8 @@ fn add_slot(ui: &mut Ui, label: &str, tip: &str, width: f32) -> egui::Response { resp } +/// Draw the 3×3 main/cross alignment grid, auto-gap modes, grid column/row gaps and the +/// horizontal baseline checkbox. fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { let t = Tokens::get(ui.ctx()); let grid_mode = l.mode == LayoutMode::Grid; @@ -309,7 +311,7 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { for xi in 0..3 { let c = pos2(grid.min.x + 12.0 + xi as f32 * 24.0, grid.min.y + 10.0 + yi as f32 * 22.0); let r = Rect::from_center_size(c, vec2(22.0, 20.0)); - let resp = ui.interact(r, Id::new(("al_grid", xi, yi)), Sense::click()); + let resp = ui.interact(r, Id::unique(("al_grid", xi, yi)), Sense::click()); let (main_i, cross_i) = if horizontal { (xi, yi) } else { (yi, xi) }; let on = (cur_main == main_i || cur_main == 9) && (cur_cross == cross_i || cur_cross == 9); if on { @@ -338,7 +340,7 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { ui, ids, "columnGap", - Id::new("al_gap"), + Id::unique("al_gap"), Lead::Icon(Icon::Gap, tr("Column gap")), Some(l.gap), "", @@ -351,7 +353,7 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { ui, ids, "rowGap", - Id::new("al_rowgap"), + Id::unique("al_rowgap"), Lead::Icon(Icon::DistributeV, tr("Row gap")), Some(l.cross_gap), "", @@ -363,7 +365,7 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { let auto = l.main_align.is_auto_gap(); let gap_icon = if l.mode == LayoutMode::Vertical { Icon::DistributeV } else { Icon::Gap }; let (shown, lead) = ((!auto).then_some(l.gap), Lead::Icon(gap_icon, tr("Gap between items"))); - let e = crate::variable_ui::number_field_or(app, ui, ids, "gap", Id::new("al_gap"), shown, 96.0, tr("Auto"), lead, ""); + let e = crate::variable_ui::number_field_or(app, ui, ids, "gap", Id::unique("al_gap"), shown, 96.0, tr("Auto"), lead, ""); app.field(ids, "gap", e, |v| json!(v)); ui.add_space(8.0); let label = match l.main_align { @@ -406,10 +408,10 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { let col = widgets::col_width(ui.available_width()); let [pt, pr, pb, pl] = l.padding; - let open = ui.data(|d| d.get_temp::(Id::new("indep_padding"))).unwrap_or(false); + let open = ui.data(|d| d.get_temp::(Id::unique("indep_padding"))).unwrap_or(false); let toggle = |ui: &mut Ui| { if icons::button(ui, Icon::IndependentPadding, 24.0, open, tr("Individual padding")).clicked() { - ui.data_mut(|d| d.insert_temp(Id::new("indep_padding"), !open)); + ui.data_mut(|d| d.insert_temp(Id::unique("indep_padding"), !open)); } }; if open { @@ -424,7 +426,8 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { for (r, pair) in sides.chunks(2).enumerate() { row(ui, 32.0, |ui| { for (key, name, icon, v, k) in pair { - let e = crate::variable_ui::number_field(app, ui, ids, key, Id::new(("padding", *k)), Lead::Icon(*icon, name), Some(*v), "", col); + let e = + crate::variable_ui::number_field(app, ui, ids, key, Id::unique(("padding", *k)), Lead::Icon(*icon, name), Some(*v), "", col); app.field(ids, key, e, |v| json!(v.max(0.0))); } if r == 0 { @@ -439,7 +442,7 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { ui, ids, "paddingHorizontal", - Id::new("al_padh"), + Id::unique("al_padh"), Lead::Icon(Icon::PaddingH, tr("Horizontal padding")), (pl == pr).then_some(pl), "", @@ -451,7 +454,7 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { ui, ids, "paddingVertical", - Id::new("al_padv"), + Id::unique("al_padv"), Lead::Icon(Icon::PaddingV, tr("Vertical padding")), (pt == pb).then_some(pt), "", @@ -475,7 +478,7 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { ui, ids, "crossGap", - Id::new("al_crossgap"), + Id::unique("al_crossgap"), shown, col, tr("Auto"), @@ -500,9 +503,9 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { if l.mode == LayoutMode::Grid { widgets::sublabels(ui, tr("Columns"), Some(tr("Rows"))); row(ui, 32.0, |ui| { - let e = widgets::number_field(ui, Id::new("al_cols"), Lead::None, Some(f64::from(l.grid_columns)), "", col, 1.0); + let e = widgets::number_field(ui, Id::unique("al_cols"), Lead::None, Some(f64::from(l.grid_columns)), "", col, 1.0); app.field(ids, "gridColumns", e, |v| json!(v.max(1.0))); - let e = widgets::number_field(ui, Id::new("al_rows"), Lead::None, Some(f64::from(l.grid_rows)), "", col, 1.0); + let e = widgets::number_field(ui, Id::unique("al_rows"), Lead::None, Some(f64::from(l.grid_rows)), "", col, 1.0); app.field(ids, "gridRows", e, |v| json!(v.max(1.0))); }); } @@ -571,7 +574,7 @@ fn tracks(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId]) { let (value, suffix) = if track.sizing == Sizing::Fill { (track.value, "fr") } else { (size, "") }; let e = widgets::number_field( ui, - Id::new(("al_track", axis, i)), + Id::unique(("al_track", axis, i)), Lead::Text(&label), Some(value), suffix, @@ -614,9 +617,9 @@ fn grid_cell(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { // Moving on one axis pins the other where it is, so the child doesn't jump. let at = |c: f64, r: f64| json!({"ids": [id], "props": {"gridColumn": (c - 1.0).max(0.0), "gridRow": (r - 1.0).max(0.0)}}); let (c0, r0) = ((cell.column + 1) as f64, (cell.row + 1) as f64); - let e = widgets::number_field(ui, Id::new("al_cell_col"), Lead::None, Some(c0), "", col, 1.0); + let e = widgets::number_field(ui, Id::unique("al_cell_col"), Lead::None, Some(c0), "", col, 1.0); app.field_run(e, "node.set", |v| at(v, r0)); - let e = widgets::number_field(ui, Id::new("al_cell_row"), Lead::None, Some(r0), "", col, 1.0); + let e = widgets::number_field(ui, Id::unique("al_cell_row"), Lead::None, Some(r0), "", col, 1.0); app.field_run(e, "node.set", |v| at(c0, v)); if anchored && icons::button(ui, Icon::Reset, 24.0, false, tr("Place automatically")).clicked() { app.set_props(&ids, json!({"gridColumn": null, "gridRow": null})); @@ -627,9 +630,9 @@ fn grid_cell(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let rs = common(doc, sel, |n| f64::from(n.child_layout.row_span())); widgets::sublabels(ui, tr("Column span"), Some(tr("Row span"))); row(ui, 32.0, |ui| { - let e = widgets::number_field(ui, Id::new("al_span_c"), Lead::None, cs, "", col, 1.0); + let e = widgets::number_field(ui, Id::unique("al_span_c"), Lead::None, cs, "", col, 1.0); app.field(&ids, "gridColumnSpan", e, |v| json!(v.max(1.0))); - let e = widgets::number_field(ui, Id::new("al_span_r"), Lead::None, rs, "", col, 1.0); + let e = widgets::number_field(ui, Id::unique("al_span_r"), Lead::None, rs, "", col, 1.0); app.field(&ids, "gridRowSpan", e, |v| json!(v.max(1.0))); }); // Alignment in the cell; Auto follows the frame's alignment. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 23666252..eff9fa4c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -90,6 +90,8 @@ mod canvas_fixes_tests; #[cfg(test)] mod canvas_image_rect_tests; #[cfg(test)] +mod clipboard_fence_tests; +#[cfg(test)] mod colorpicker_eyedropper_tests; #[cfg(test)] mod colorpicker_selection_tests; @@ -254,7 +256,7 @@ use figcraft_doc::NodeId; use figcraft_engine::Session; use serde_json::{Value, json}; -pub use control::{ControlReply, ControlRequest}; +pub use control::{ControlReply, ControlRequest, RequestGate, RequestState}; /// Platform services the app crate provides (file dialogs, clipboard, URLs). pub trait Services { @@ -477,6 +479,35 @@ pub(crate) fn repaint_in(ctx: &egui::Context, secs: f64) { } } +/// A monotonic clock for background work (autosave, recovery, capture deadlines). +/// +/// `Context::input(|i| i.time)` is the UI pass's time and is stale in `run_logic`; wall-clock time +/// can jump. `web_time::Instant` is monotonic on native and in the browser. Tests drive deadlines +/// directly with [`FrameClock::set_test_now`]. +pub struct FrameClock { + origin: web_time::Instant, + test_now: Option, +} + +impl Default for FrameClock { + /// Start the frame clock at the current web time with no injected test time. + fn default() -> Self { + Self { origin: web_time::Instant::now(), test_now: None } + } +} + +impl FrameClock { + /// Seconds since the app started (or the injected test time). + pub fn now(&self) -> f64 { + self.test_now.unwrap_or_else(|| self.origin.elapsed().as_secs_f64()) + } + + /// Set a fake monotonic time (tests); `None` returns to the real clock. + pub fn set_test_now(&mut self, secs: Option) { + self.test_now = secs; + } +} + /// The side panels' widths: the least and most a drag of their edge gives, what a double-click on /// the edge puts back, and how much of the window they leave the canvas. pub const LEFT_PANEL: std::ops::RangeInclusive = 200.0..=480.0; @@ -645,7 +676,13 @@ pub struct UiState { /// What FigCraft last put on the system clipboard: when the clipboard still holds it, a paste /// uses our copied layers; anything else there (an image, SVG, text) came from another app. + /// `None` also means a programmatic copy changed the engine clipboard without writing the OS + /// clipboard, so the system text is no longer known to be ours. pub last_copy: Option, + /// The engine clipboard's layers generation when `last_copy` was mirrored. A programmatic + /// copy changes the engine clipboard without writing the OS clipboard; the generation then + /// no longer matches, so the stale mirror is not mistaken for the user's own copy. + pub last_copy_generation: u64, /// Since the last V key release: whether its press arrived, and whether a paste event did /// (`shortcuts::swallowed_paste`). pub v_pressed: bool, @@ -653,14 +690,14 @@ pub struct UiState { /// The window got the focus back since the last V release. pub refocused: bool, /// A save shortcut pressed while a panel field had the keyboard: the field lets go (which - /// commits what was typed) and the command runs the next frame (`shortcuts::handle`). - pub after_field: Option<&'static str>, + /// commits what was typed) and the command runs next frame for that document/window. + pub after_field: Option, /// The modifiers held, as of the last modifier change in the raw events: a paste event doesn't /// carry them. pub held: egui::Modifiers, - /// When (UI clock, seconds) open documents next get an autosave checkpoint. + /// When (monotonic [`FrameClock`], seconds) open documents next get an autosave checkpoint. pub next_autosave: f64, - /// With auto-save on: when (UI clock, seconds) files with unsaved changes are next saved, and + /// With auto-save on: when (monotonic [`FrameClock`], seconds) files with unsaved changes are next saved, and /// the revisions of those files it waits on (a change restarts the wait). pub auto_save_at: Option<(f64, u64)>, /// Files being encoded for auto-save on threads of their own (tab `uid`). @@ -690,6 +727,9 @@ pub struct UiState { } impl Default for UiState { + /// Seed the default UI state (panel widths, tabs, pages, Home, presenters, pickers and + /// dialogs off, and the rest of the transient UI fields); `FigApp::new` overrides panel + /// widths and the pages height from preferences. fn default() -> Self { Self { left_width: PANEL_DEFAULT, @@ -778,6 +818,7 @@ impl Default for UiState { dev: dev_mode::DevUi::default(), last_copy: None, + last_copy_generation: 0, v_pressed: false, paste_event: false, refocused: false, @@ -800,6 +841,14 @@ impl Default for UiState { } } +/// A control reply waiting for the pass that presents an injected input batch. The viewport keeps +/// one window's batch from acknowledging (or erroring) another window's request. +pub(crate) struct PendingAck { + pub(crate) viewport: egui::ViewportId, + pub(crate) tx: std::sync::mpsc::Sender, + pub(crate) reply: ControlReply, +} + /// The whole FigCraft UI. pub struct FigApp { pub session: Session, @@ -838,12 +887,46 @@ pub struct FigApp { /// The automation roots the app was started with: every frame and control request runs /// confined to them ([`windows::guard`]). pub automation_roots: Option>, + /// The active-document id when synthetic input was queued: a batch is dropped when the file + /// changed before a UI pass applied it. + pub(crate) control_input_doc: Option, + /// This frame applies events injected through the control channel: no system clipboard + /// read or write may happen for them. + pub(crate) control_synthetic: bool, + /// `ui.input` replies waiting for the pass that actually presents the injected batch (a + /// discarded sizing pass must not acknowledge it). + pub(crate) input_acks: Vec, + /// Replies whose batch this frame took into its raw input: sent only if the pass is presented. + pub(crate) applied_acks: Vec, + /// The batch taken into this frame's raw input, kept so a discarded pass gives it back. + pub(crate) applied_input: Option>, + /// `handle("ui.input")` queued raw events whose acknowledgement waits for the applied pass + /// (controller-only input is applied at once and needs no fence). + pub(crate) control_input_queued: bool, + /// The viewport whose batch this frame took into its raw input (`applied_input`/`applied_acks`). + pub(crate) applied_frame: Option, + /// Monotonic time for background work and capture deadlines. + pub(crate) clock: FrameClock, + /// Screenshot requests admitted, settling and awaiting readback. + pub(crate) capture: control::CaptureState, + /// [`Self::background_tick`] ran for this pass (so [`Self::ui`] does not repeat it). + logic_ticked: bool, + /// The last frame whose UI setup ran (multi-pass frames set up once). + ui_frame_stamp: Option<(egui::ViewportId, u64)>, + /// IME owner/generation/change-cause state for the on-canvas text editor, per viewport. + pub(crate) ime: egui::ime_context::ImeContextTracker, + /// Test-only count of the one IME observation each viewport pass makes. + #[cfg(test)] + pub(crate) ime_observations: u32, } /// What to do with the result of a command run in the background. pub type Done = Box; impl FigApp { + /// Construct `FigApp` from a session and services, taking panel widths and pages height + /// from preferences and initializing UI, canvas, clocks, capture/IME and transient channel + /// state. pub fn new(session: Session, services: Box) -> Self { // The main window's panels open as wide as they were left. let (left_width, right_width) = session.prefs.panel_widths; @@ -869,6 +952,20 @@ impl FigApp { pending_dialog: None, ui_context: None, automation_roots: None, + control_input_doc: None, + control_synthetic: false, + input_acks: Vec::new(), + applied_acks: Vec::new(), + applied_input: None, + control_input_queued: false, + applied_frame: None, + clock: FrameClock::default(), + capture: control::CaptureState::default(), + logic_ticked: false, + ui_frame_stamp: None, + ime: egui::ime_context::ImeContextTracker::new(), + #[cfg(test)] + ime_observations: 0, } } @@ -881,12 +978,34 @@ impl FigApp { /// Run a command, surfacing errors as a toast. Returns the result. pub fn run(&mut self, id: &str, params: Value) -> Option { + let layers_generation = self.session.clipboard.layers_generation; let r = self.session.execute(id, ¶ms); self.clear_comment_draft_after_tool_set(id, r.is_ok()); self.handle_ui_requests(); + self.invalidate_synthetic_copy(layers_generation); + // A command may change the file the window shows (`doc.switch`, `doc.nextTab`, + // `file.open`/`file.close`). Record that presentation before returning, so an in-flight + // screenshot admitted on the old file cannot accept pixels from the new one. + self.observe_presentation(); r.map_err(|e| self.failed(id, &e)).ok() } + /// A programmatic command that changed the engine clipboard never wrote the system clipboard: + /// forget the old system mirror so a later user paste does not trust it as our own copy. + /// `before` is the layers generation captured before the command ran. + pub(crate) fn invalidate_synthetic_copy(&mut self, before: u64) { + if self.control_synthetic && self.session.clipboard.layers_generation != before { + self.ui.last_copy = None; + } + } + + /// Record the file the window drawing now shows, with a monotonic generation that changes on + /// every switch (an A→B→A switch is two generations, never the admitted one again). + pub(crate) fn observe_presentation(&mut self) { + let window = self.windows.current; + self.windows.presentation(&self.session, window); + } + /// [`run`](Self::run) for something the user did (a dialog, a drop, a paste, a recent file): /// a file it imports shows its import report. Programmatic calls use `run` and never open one. pub fn run_user(&mut self, id: &str, params: Value) -> Option { @@ -1009,6 +1128,83 @@ impl FigApp { } } + /// Finish the frame that applies an injected batch: acknowledge it once the pass is presented, + /// and stop fencing the system clipboard. Only the viewport whose batch this frame took acts. + pub(crate) fn end_input_frame(&mut self, ctx: &egui::Context) { + if self.applied_frame != Some(ctx.viewport_id()) { + return; + } + self.applied_input = None; + if ctx.will_discard() { + // Keep `applied_frame`/`applied_acks`: the rerun pass must present (and acknowledge) + // the same frame without replaying the input. + return; + } + self.applied_frame = None; + for ack in self.applied_acks.drain(..) { + let _ = ack.tx.send(ack.reply); + } + self.control_synthetic = false; + } + + /// The app's monotonic clock in seconds, also usable from logic-only callbacks (recovery, + /// control deadlines). Never `ctx.input(i.time)`, which is stale there. + pub fn now_secs(&self) -> f64 { + self.clock.now() + } + + /// Queue raw input injected through the control channel for the window drawing next, tagged + /// with the document it belongs to (a batch is dropped if another file is active by then). + pub(crate) fn push_control_input(&mut self, events: Vec) -> bool { + if events.is_empty() { + return false; + } + let doc = self.session.docs.get(self.session.active).map(|d| d.uid); + if !self.control_input.is_empty() && self.control_input_doc != doc { + // A queued batch belongs to the file that was open when it was injected; it cannot be + // merged with one for the file showing now. Its acknowledgement says so. + self.control_input.clear(); + let viewport = crate::windows::viewport(self.windows.current); + crate::windows::fail_input_acks(self, viewport); + } + // Always the live pane vector: while a child window draws, `windows::enter` has swapped + // its pane into the app, and `leave` swaps these events back into that pane. + self.control_input.extend(events); + self.control_input_doc = doc; + self.control_synthetic = true; + true + } + + /// The acknowledgements waiting for `viewport`'s input batch (other windows keep theirs). + pub(crate) fn take_input_acks(&mut self, viewport: egui::ViewportId) -> Vec { + let mut mine = Vec::new(); + for ack in std::mem::take(&mut self.input_acks) { + if ack.viewport == viewport { + mine.push(ack); + } else { + self.input_acks.push(ack); + } + } + mine + } + + /// Put `text` on the system clipboard. Input injected through the control channel never + /// replaces what the user copied: this is the one write path every copy command goes through. + pub(crate) fn clipboard_set_text(&mut self, text: &str) { + if self.control_synthetic { + return; + } + self.services.clipboard_set_text(text); + } + + /// [`Self::clipboard_set_text`] for an image (straight RGBA). + pub(crate) fn clipboard_set_png(&mut self, width: u32, height: u32, rgba: &[u8]) { + if self.control_synthetic { + return; + } + self.services.clipboard_set_png(width, height, rgba); + } + pub fn toast(&mut self, msg: impl Into) { self.ui.toast = Some((msg.into(), ToastClock::Raised)); } @@ -1174,8 +1370,10 @@ impl FigApp { fn panel_edge(ui: &mut egui::Ui, name: &str, x: f32, rows: egui::Rangef, width: f32, grows: f32) -> Option { let grab = ui.style().interaction.resize_grab_radius_side; let rect = egui::Rect::from_x_y_ranges(x - grab..=x + grab, rows); - let id = egui::Id::new(("panel_edge", name)); - let resp = ui.interact(rect, id, egui::Sense::click_and_drag()); + let id = egui::Id::unique(("panel_edge", name)); + // Not focusable: an edge is a pointer target, so it must not take Tab/F6 focus from the + // panels' controls (see `a11y::focusables`). + let resp = ui.interact(rect, id, egui::Sense::CLICK | egui::Sense::DRAG); if resp.hovered() || resp.dragged() { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); ui.painter().vline(x, rows, egui::Stroke::new(2.0, theme::ACCENT)); @@ -1417,18 +1615,55 @@ impl FigApp { need } - /// Per-frame logic that doesn't draw (control requests, theme, language). - pub fn logic(&mut self, ctx: &egui::Context) { + /// Work safe from a logic-only callback (`App::logic`, which also runs while hidden) and from + /// a normal UI pass: consumed control work, live polling, background completions, autosave and + /// recovery deadlines. It never reads UI input, so it must not scan events, run shortcuts or + /// lay out anything. + pub fn background_tick(&mut self, ctx: &egui::Context) { + control::background(self, ctx); + // What happened in a live session (the network woke us for it). + if self.session.live_poll() { + if let Some(m) = self.session.message.take() { + self.toast(live::failure_text(&m)); + } + self.background_done(ctx); + } + // Files that arrived without a command (joined, recovered) take the kept View toggles too. + self.session.apply_view_prefs(); + let now = self.clock.now(); + self.autosave_tick(ctx, now); + self.auto_save_tick(ctx, now); + self.logic_ticked = true; + } + + /// UI-phase setup that needs a real current `Ui`: fonts/language/theme, interface zoom and the + /// render worker. Runs once per frame (pass 0) for the main window, and once per frame for + /// every child window (`windows::draw`), never on a logic-only tick. + fn ui_setup(&mut self, ctx: &egui::Context) { + self.viewport_setup(ctx); + } + + /// The app-global part of [`Self::ui_setup`] that every window's frame needs: language, fonts, + /// theme and `ui.dark`, the render worker, and the interface zoom plan. eframe only calls + /// `App::ui` for the root window, so a child must run this itself before it draws; a child that + /// only runs while the root is minimised still gets theme switches, CJK faces for its own + /// document, and the right `ui.dark` for its tabs and dialogs. + pub(crate) fn viewport_setup(&mut self, ctx: &egui::Context) { if self.language.as_ref() != Some(&self.session.prefs.language) { i18n::set(&self.session.prefs.language, self.services.system_language().as_deref()); self.language = Some(self.session.prefs.language.clone()); } + // Screen-reader and bidi language of the UI (fork E17). + ctx.set_language(i18n::current().map(|l| l.code.to_string())); if !self.fonts_installed { // Cmd/Ctrl+= / - / 0 zoom the canvas (view.zoomIn, view.zoomOut, view.zoom100): egui's // own keyboard zoom would scale the whole interface on the same keys. ctx.options_mut(|o| o.zoom_with_keyboard = false); theme::install_fonts(ctx, false, 0); self.fonts_installed = true; + // Installed fonts apply from the next frame; a child window draws on here, so ask for + // that frame (the root draws nothing on the frame it installs them). + ctx.request_repaint(); } else if figcraft_text::system_fonts_scanned() { // Once the system fonts are indexed: faces for names in scripts the UI fonts lack, and // for each CJK script once something shows it. @@ -1443,12 +1678,15 @@ impl FigApp { } // egui's zoom is for the whole app and multiplies each window's own system scale, so the // size is the same relative to every screen; the main window, whose size can hold a size - // back, sets it. The browser keeps its own zoom. - if ctx.viewport_id() == egui::ViewportId::ROOT { + // back, sets it. The browser keeps its own zoom. Every window records its own plan (the + // Preferences warning and `ui.inspect` read it), from its own size. + { let window_px = ctx.content_rect().size() * ctx.pixels_per_point(); let size = if cfg!(target_arch = "wasm32") { 1.0 } else { self.session.prefs.ui_scale }; let plan = ui_scale::plan(size, ctx.native_pixels_per_point(), Some((window_px.x, window_px.y))); - ctx.set_zoom_factor(plan.zoom); + if ctx.viewport_id() == egui::ViewportId::ROOT { + ctx.set_zoom_factor(plan.zoom); + } self.ui.ui_scale = plan; } let dark = match self.session.prefs.theme.as_str() { @@ -1469,8 +1707,7 @@ impl FigApp { /// Every [`AUTOSAVE_SECS`], each open document with unsaved changes since its last version /// gets an autosave checkpoint in its version history. - fn autosave_tick(&mut self, ctx: &egui::Context) { - let now = ctx.input(|i| i.time); + fn autosave_tick(&mut self, ctx: &egui::Context, now: f64) { if now < self.ui.next_autosave { // Wake up for it even if nothing else draws a frame by then. repaint_in(ctx, self.ui.next_autosave - now); @@ -1491,8 +1728,7 @@ impl FigApp { /// With auto-save on, files that have a path are saved [`AUTO_SAVE_SECS`] after they last /// changed (not during a gesture, a live preview or while a version is shown). The file is /// encoded on a thread of its own, so a big one doesn't hold up the window, and written here. - fn auto_save_tick(&mut self, ctx: &egui::Context) { - let now = ctx.input(|i| i.time); + fn auto_save_tick(&mut self, ctx: &egui::Context, now: f64) { let mut failed = self.auto_save_done(); // The browser has no files to save to. let on = !cfg!(target_arch = "wasm32") && self.session.prefs.auto_save; @@ -1567,25 +1803,36 @@ impl FigApp { let t0 = std::time::Instant::now(); let ctx = ui.ctx().clone(); self.ui_context = Some(ctx.clone()); - self.frame_stats.record(&ctx); + // `App::logic` already ticked this pass; direct harnesses do not call it. The marker only + // covers the tick that shares this pass: clearing it here keeps a direct `App::ui` harness + // ticking on every frame (it used to tick every other frame). + if !std::mem::take(&mut self.logic_ticked) { + self.background_tick(&ctx); + self.logic_ticked = false; + } + // Setup once per frame (pass 0), not once per pass and never on a logic-only tick. + let stamp = (ctx.viewport_id(), ctx.cumulative_frame_nr()); let first = !self.fonts_installed; - self.logic(&ctx); - self.autosave_tick(&ctx); - self.auto_save_tick(&ctx); - if first { - // New fonts apply from the next frame; draw nothing until then. + if ctx.current_pass_index() == 0 && self.ui_frame_stamp != Some(stamp) { + self.ui_frame_stamp = Some(stamp); + self.frame_stats.record(&ctx); + self.ui_setup(&ctx); + } + if first && self.fonts_installed { + // New fonts apply from the next frame; draw nothing until then. Anything taken into + // this frame's (undrawn) input waits for the first drawn frame. ctx.request_repaint(); + if let Some(events) = self.applied_input.take() { + self.push_control_input(events); + } + self.input_acks.append(&mut self.applied_acks); + self.applied_frame = None; + self.control_synthetic = false; + // The next (drawn) frame ticks for itself in a direct harness. + self.logic_ticked = false; + self.observe_ime(&ctx, false); return; } - // What happened in a live session (the network woke us for it). - if self.session.live_poll() { - if let Some(m) = self.session.message.take() { - self.toast(live::failure_text(&m)); - } - self.background_done(&ctx); - } - // Files that arrived without a command (joined, recovered) take the kept View toggles too. - self.session.apply_view_prefs(); windows::begin(self, &ctx); let (files, prefs) = (self.session.docs.len(), self.session.prefs.clone()); self.frame(ui); @@ -1594,6 +1841,10 @@ impl FigApp { windows::repaint_others(self, &ctx); } windows::show(self, &ctx); + // The injected batch was applied by the frame that took it. A discarded sizing pass keeps + // its acknowledgement for the presented pass but never replays the input (its side effects + // already happened and would otherwise duplicate). + self.end_input_frame(&ctx); // Plugins installed, removed or set up are kept at once: a crash doesn't lose them. // So are the owner tokens of files put online. if std::mem::take(&mut self.session.plugins.unsaved) | std::mem::take(&mut self.session.live.unsaved) { @@ -1618,11 +1869,17 @@ impl FigApp { /// One frame of the window drawing now. pub(crate) fn frame(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); + // Bind this input batch before controls or pointer input can replace its composing + // target/caret. Outgoing IME receipts run after mutation and cannot authorize it. + let ime_input = shortcuts::ImeInputBaseline::capture(self); let t = theme::Tokens::get(&ctx); // A field that had the keyboard when the frame began keeps its keys from the shortcuts // even if it lets go during the frame (Enter commits it), or as it began (egui drops the // focus on Escape before the frame, which cancels the field's edit and nothing else). let typing = std::mem::take(&mut self.field_focus) || a11y::field_focused(&ctx); + // Bind the originating field/file before tabs can switch. Its widget commits only after + // processing this batch; delivery remains deferred until the following frame. + let field_save = shortcuts::prepare_field_save(self, &ctx); let swallowed = shortcuts::swallowed_paste(self, &ctx); // A dialog that closed acts first, in the window it was opened from. self.poll_dialog(&ctx); @@ -1631,7 +1888,7 @@ impl FigApp { windows::requests(self, &ctx); selection_spacing::sync(self, &ctx); // Before any widget: the navigation keys (F6, Tab, arrows, Escape) move the keyboard. - a11y::begin(self, &ctx); + a11y::begin(self, &ctx, ui.scope_id()); if self.ui.title_bar == TitleBar::Custom { tabs::resize_edges(&ctx); } @@ -1644,6 +1901,9 @@ impl FigApp { self.end_pen_outside_design(); tabs::relink_presents(self); tabs::tab_bar(self, ui); + // Control/window work and this pass's physical tab click may have replaced the owner. + // A shared field ID in that replacement must never consume the old commit marker. + let field_save = shortcuts::revalidate_field_save(self, &ctx, field_save); if self.ui.present.is_some() { // A close request can arrive while the presenter fills the window. Let its unsaved // changes dialog take Escape and draw above the player just as it does in the editor. @@ -1653,6 +1913,8 @@ impl FigApp { // What went wrong while presenting (a widget's plugin failing) shows over it. self.toast_ui(&ctx); windows::requests(self, &ctx); + shortcuts::clear_field_save_marker(&ctx); + self.observe_ime(&ctx, false); return; } if !self.has_files() || self.ui.home { @@ -1664,6 +1926,8 @@ impl FigApp { dialogs::show(self, &ctx); self.toast_ui(&ctx); windows::requests(self, &ctx); + shortcuts::clear_field_save_marker(&ctx); + self.observe_ime(&ctx, false); return; } let hidden = self.session.ds().map(|d| d.view.ui_hidden).unwrap_or(false); @@ -1748,7 +2012,7 @@ impl FigApp { branch_ui::keys(self, &ctx); live::dialog_keys(self, &ctx); dialogs::keys(self, &ctx); - shortcuts::handle(self, &ctx, typing, swallowed); + shortcuts::handle_input(self, &ctx, typing, swallowed, ime_input, field_save); menus::popups(self, &ctx); variable_ui::picker(self, &ctx); style_picker::show(self, &ctx); @@ -1768,8 +2032,33 @@ impl FigApp { // changed), from the window with its file. let windows = &self.windows; self.session.live_sync_in(|uid| uid.map_or(0, |u| windows.of(u)) == windows.current); + // The canvas editor's IME context is observed exactly once per pass, after every command + // this pass ran (shortcuts, menus, dialogs, window requests and live sync). A focused panel + // field or an open palette owns the platform IME instead; the early `home`/present paths + // above make the same single idle observation. + self.observe_ime(&ctx, !typing && !self.field_focus && self.ui.palette.is_none()); } + /// The pass's one IME observation: the focused on-canvas editor, or an explicit idle. + /// + /// Calling `ImeContextTracker::observe` more than once in a pass would treat an idle followed + /// by a focused observation as a context change (dropping the frame's receipts) and bump the + /// generation. Keeping this in one place makes the fork contract structural. + fn observe_ime(&mut self, ctx: &egui::Context, focused: bool) { + #[cfg(test)] + { + self.ime_observations = self.ime_observations.saturating_add(1); + } + if focused { + shortcuts::ime_output(self, ctx); + } else { + let _ = self.ime.observe(ctx.viewport_id(), None); + } + } + + /// Draw the toast: start its lifetime on the frame **after** the first drawn frame, request + /// that frame and the frame that hides it, expire and remove it at the deadline, and place it + /// at the bottom centre. fn toast_ui(&mut self, ctx: &egui::Context) { let Some((msg, clock)) = &mut self.ui.toast else { return }; let now = ctx.input(|i| i.time); @@ -1798,7 +2087,7 @@ impl FigApp { repaint_in(ctx, left); } let screen = ctx.content_rect(); - egui::Area::new(egui::Id::new("toast")) + egui::Area::new(egui::Id::unique("toast")) .fixed_pos(egui::pos2(screen.center().x, screen.max.y - 96.0)) .pivot(egui::Align2::CENTER_BOTTOM) .order(egui::Order::Tooltip) @@ -1928,7 +2217,7 @@ pub(crate) fn drop_overlay(ctx: &egui::Context, rect: egui::Rect, label: &str) { return; } let t = theme::Tokens::get(ctx); - let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("drop_overlay"))); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("drop_overlay"))); let area = rect.shrink(4.0); painter.rect_filled(area, egui::CornerRadius::same(8), t.accent.gamma_multiply(0.12)); painter.rect_stroke(area, egui::CornerRadius::same(8), egui::Stroke::new(2.0, theme::ACCENT), egui::StrokeKind::Inside); diff --git a/crates/ui-egui/src/list_drag.rs b/crates/ui-egui/src/list_drag.rs index b701a15a..08a391ae 100644 --- a/crates/ui-egui/src/list_drag.rs +++ b/crates/ui-egui/src/list_drag.rs @@ -6,15 +6,15 @@ //! lists bottom first; other lists (an interaction's actions, pages) show their items in model //! order. -use egui::{AsId, Id, Rect, Sense, Ui, pos2, vec2}; +use egui::{AsIdSalt, Id, Rect, Sense, Ui, pos2, vec2}; use crate::icons::{self, Icon}; use crate::theme::{Tokens, regular}; use crate::widgets::{self, PAD}; /// The id of list `list` (any hashable key, e.g. `"fills"`). -fn list_id(list: impl AsId) -> Id { - Id::new(("list_drag", list)) +fn list_id(list: impl AsIdSalt) -> Id { + Id::unique(("list_drag", list)) } /// The row of list `list` being dragged (its model index; temp data). @@ -24,7 +24,7 @@ fn drag_id(list: Id) -> Id { /// The grip of item `index` of list `list` (tests find rows by it). #[cfg(test)] -pub(crate) fn grip_id(list: impl AsId, index: usize) -> Id { +pub(crate) fn grip_id(list: impl AsIdSalt, index: usize) -> Id { grip_of(list_id(list), index) } @@ -49,12 +49,12 @@ pub struct ListDrag { impl ListDrag { /// A list shown topmost item (the model's last) first, as the Design panel shows paints. - pub fn new(ui: &Ui, list: impl AsId) -> Self { + pub fn new(ui: &Ui, list: impl AsIdSalt) -> Self { Self::with_order(ui, list_id(list), true) } /// A list shown in model order, first item on top (an interaction's actions). - pub fn in_order(ui: &Ui, list: impl AsId) -> Self { + pub fn in_order(ui: &Ui, list: impl AsIdSalt) -> Self { Self::with_order(ui, list_id(list), false) } @@ -164,34 +164,34 @@ fn drop_slot(rows: &[(usize, Rect)], from: usize, p: egui::Pos2, reversed: bool) Some((if reversed { n - 1 - at } else { at }, y)) } -/// Keep per-row flags (`Id::new((salt, index))`, e.g. an open settings section) with their rows +/// Keep per-row flags (`Id::unique((salt, index))`, e.g. an open settings section) with their rows /// when item `from` of a `len`-long list moves to `to`. pub fn move_flags(ui: &Ui, salt: &'static str, len: usize, from: usize, to: usize) { ui.data_mut(|d| { - let flags: Vec> = (0..len).map(|i| d.get_temp::(Id::new((salt, i)))).collect(); + let flags: Vec> = (0..len).map(|i| d.get_temp::(Id::unique((salt, i)))).collect(); for (i, f) in flags.into_iter().enumerate() { let j = figcraft_engine::cmd::lists::moved_index(i, from, to); match f { Some(v) => { - d.insert_temp(Id::new((salt, j)), v); + d.insert_temp(Id::unique((salt, j)), v); } - None => d.remove::(Id::new((salt, j))), + None => d.remove::(Id::unique((salt, j))), } } }); } -/// Keep per-row flags (`Id::new((salt, index))`) with their rows when item `removed` of a +/// Keep per-row flags (`Id::unique((salt, index))`) with their rows when item `removed` of a /// `len`-long list goes; its own flag goes with it. pub fn remove_flags(ui: &Ui, salt: &'static str, len: usize, removed: usize) { ui.data_mut(|d| { - let flags: Vec> = (0..len).map(|i| d.get_temp::(Id::new((salt, i)))).collect(); + let flags: Vec> = (0..len).map(|i| d.get_temp::(Id::unique((salt, i)))).collect(); for i in 0..len { - d.remove::(Id::new((salt, i))); + d.remove::(Id::unique((salt, i))); } for (i, f) in flags.into_iter().enumerate() { if let (Some(j), Some(v)) = (figcraft_engine::cmd::lists::removed_index(i, removed), f) { - d.insert_temp(Id::new((salt, j)), v); + d.insert_temp(Id::unique((salt, j)), v); } } }); diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index a4baa303..7a1af420 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -354,7 +354,7 @@ pub fn chat_box(app: &mut FigApp, ctx: &egui::Context) { let _ = app.session.live_chat(""); return; } - let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("live_chat"))); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("live_chat"))); chat_bubble(&painter, tip, None, &chat.text, fill, alpha); app.ui.live.chat = Some(chat); return; @@ -366,7 +366,7 @@ pub fn chat_box(app: &mut FigApp, ctx: &egui::Context) { } ctx.request_repaint_after(std::time::Duration::from_secs_f64(CHAT_HOLD + CHAT_FADE - idle)); let fg = text_on(fill); - let resp = egui::Area::new(egui::Id::new("live_chat_box")) + let resp = egui::Area::new(egui::Id::unique("live_chat_box")) .order(egui::Order::Foreground) .fixed_pos(tip + vec2(12.0, 16.0)) .show(ctx, |ui| { @@ -378,7 +378,7 @@ pub fn chat_box(app: &mut FigApp, ctx: &egui::Context) { let w = ui.painter().layout_no_wrap(chat.text.clone(), regular(12.0), fg).size().x + 8.0; ui.add( egui::TextEdit::singleline(&mut chat.text) - .id(egui::Id::new("live_chat_text")) + .id(egui::Id::unique("live_chat_text")) .frame(egui::Frame::NONE) .font(regular(12.0)) .text_color(fg) @@ -425,7 +425,7 @@ pub fn spotlight_banner(app: &mut FigApp, ctx: &egui::Context, canvas: Rect) { return; }; let t = Tokens::get(ctx); - let clicked = widgets::canvas_banner(ctx, egui::Id::new("live_spotlight"), canvas, |ui| { + let clicked = widgets::canvas_banner(ctx, egui::Id::unique("live_spotlight"), canvas, |ui| { ui.label(egui::RichText::new(text).font(regular(11.0)).color(t.text)); widgets::button(ui, button, true, true).clicked() }); @@ -473,7 +473,7 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { let mut spotlight = false; if in_session { let c = pos2(x, cy); - let resp = ui.interact(Rect::from_center_size(c, vec2(2.0 * R, 2.0 * R)), ui.id().with("live_me"), Sense::click()); + let resp = ui.interact(Rect::from_center_size(c, vec2(2.0 * R, 2.0 * R)), ui.scope_id().with("live_me"), Sense::click()); a11y::menu_button(&resp, tr("You"), a11y::menu_open(&resp)); if spotlighting || resp.hovered() { ui.painter().circle_stroke(c, R + 2.5, Stroke::new(2.0, me_fill)); @@ -491,7 +491,7 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { let c = pos2(x, cy); let following = follow == Some(*id); let tip = if following { trf("Stop following {name}", &[("name", name)]) } else { trf("Follow {name}", &[("name", name)]) }; - let resp = ui.interact(Rect::from_center_size(c, vec2(2.0 * R, 2.0 * R)), ui.id().with(("live_avatar", id)), Sense::click()).tip(tip); + let resp = ui.interact(Rect::from_center_size(c, vec2(2.0 * R, 2.0 * R)), ui.scope_id().with(("live_avatar", id)), Sense::click()).tip(tip); ring(ui, c); avatar(ui.painter(), c, R, name, *fill); if following || resp.hovered() { @@ -517,7 +517,7 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { ui.painter().text(c, Align2::CENTER_CENTER, format!("+{more}"), semibold(10.0), t.text); } let chev = Rect::from_center_size(pos2(x + R + 10.0, cy), vec2(16.0, 24.0)); - let cresp = ui.interact(chev, ui.id().with("live_people"), Sense::click()); + let cresp = ui.interact(chev, ui.scope_id().with("live_people"), Sense::click()); a11y::menu_button(&cresp, tr("People in this file"), a11y::menu_open(&cresp)); if cresp.hovered() { ui.painter().rect_filled(chev, CornerRadius::same(4), t.hover); @@ -665,7 +665,7 @@ fn online_note(app: &mut FigApp, ui: &mut Ui, t: &Tokens, server: &str) { /// home screen is closed. The session of the join that started, if one did. pub fn join_row(app: &mut FigApp, ui: &mut Ui, width: f32) -> Option { let mut typed = std::mem::take(&mut app.ui.live.code); - let joined = ui.horizontal(|ui| join_field(app, ui, &mut typed, egui::Id::new("live_join_code"), width, false)).inner; + let joined = ui.horizontal(|ui| join_field(app, ui, &mut typed, egui::Id::unique("live_join_code"), width, false)).inner; app.ui.live.code = typed; joined } @@ -714,7 +714,7 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { ui.horizontal(|ui| { widgets::label(ui, tr("Your name"), t.text); let name = app.session.prefs.user_name.clone(); - if let Some(n) = widgets::text_field(ui, egui::Id::new("live_name"), &name, 160.0, tr("Your name")) { + if let Some(n) = widgets::text_field(ui, egui::Id::unique("live_name"), &name, 160.0, tr("Your name")) { app.run("pref.userName", json!({"name": n})); } }); @@ -755,7 +755,7 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { } ui.horizontal(|ui| { widgets::label(ui, tr("Port"), t.text); - if let Some(port) = widgets::text_field_named(ui, egui::Id::new("live_port"), &app.ui.live.port, 70.0, tr("Port")) { + if let Some(port) = widgets::text_field_named(ui, egui::Id::unique("live_port"), &app.ui.live.port, 70.0, tr("Port")) { // Digits only; empty: the usual port, or any free one. app.ui.live.port = port.trim().parse::().map(|p| p.to_string()).unwrap_or_default(); } @@ -885,11 +885,11 @@ fn share_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens, share: &Share) { }); ui.horizontal(|ui| { if widgets::button(ui, tr("Copy code"), true, true).clicked() { - app.services.clipboard_set_text(&code); + app.clipboard_set_text(&code); app.toast(tr("Join code copied")); } if widgets::button(ui, tr("Copy link"), false, true).on_hover_text(tr("An invite link that opens FigCraft and joins")).clicked() { - app.services.clipboard_set_text(&link); + app.clipboard_set_text(&link); app.toast(tr("Invite link copied")); } }); @@ -1007,7 +1007,7 @@ fn report_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { .hint_text(tr("Spam, stolen work, abuse…")) .char_limit(figcraft_engine::live::MAX_REPORT) .desired_width(ui.available_width()) - .id(egui::Id::new("live_report_reason")), + .id(egui::Id::unique("live_report_reason")), ); a11y::text(&resp, tr("Report"), &reason); crate::widgets::select_all_on_focus(ui, &resp, &reason); @@ -1042,7 +1042,7 @@ fn link_rows(app: &mut FigApp, ui: &mut Ui, rows: &[(&str, &String, &str)]) { ui.label(egui::RichText::new(widgets::elide(ui, value, w)).font(regular(10.5)).monospace()); }); if widgets::button(ui, tr("Copy"), false, true).on_hover_text(trf("Copy the {label}", &[("label", &label.to_lowercase())])).clicked() { - app.services.clipboard_set_text(value); + app.clipboard_set_text(value); app.toast(*what); } }); @@ -1085,11 +1085,11 @@ pub fn join_dialog(app: &mut FigApp, ctx: &egui::Context) { let mut cancel = false; let mut go = false; let focus = std::mem::take(&mut app.ui.live.focus_join); - crate::dialogs::window(tr("Join live session"), ctx).id(egui::Id::new("Join live session")).open(&mut open).show(ctx, |ui| { + crate::dialogs::window(tr("Join live session"), ctx).id(egui::Id::unique("Join live session")).open(&mut open).show(ctx, |ui| { ui.set_width(380.0); widgets::label(ui, tr("Type the join code you were given, or paste an invite link."), t.text); ui.horizontal(|ui| { - go = join_field(app, ui, &mut link, egui::Id::new("live_join_link"), 300.0, focus).is_some(); + go = join_field(app, ui, &mut link, egui::Id::unique("live_join_link"), 300.0, focus).is_some(); cancel = widgets::button(ui, tr("Cancel"), false, true).clicked(); }); widgets::label(ui, tr("You'll get the shared file as a new tab and edit it together."), t.text_secondary); @@ -1158,7 +1158,7 @@ pub fn publish_dialog(app: &mut FigApp, ctx: &egui::Context) { let open_server = info.as_ref().is_some_and(|v| v["open"] == true); // A first look at an address already filled in. ask |= focus && !address.is_empty() && !keyed && info.is_none(); - crate::dialogs::window(tr("Publish to server"), ctx).id(egui::Id::new("Publish to server")).open(&mut open).show(ctx, |ui| { + crate::dialogs::window(tr("Publish to server"), ctx).id(egui::Id::unique("Publish to server")).open(&mut open).show(ctx, |ui| { ui.set_width(380.0); widgets::label( ui, @@ -1170,7 +1170,7 @@ pub fn publish_dialog(app: &mut FigApp, ctx: &egui::Context) { egui::TextEdit::singleline(&mut form.server) .hint_text("wss://server/ or ws://server:47900/?key=…") .desired_width(380.0) - .id(egui::Id::new("live_publish_server")), + .id(egui::Id::unique("live_publish_server")), ); // The address may carry the server key: it isn't read out. a11y::text(&resp, tr("Server address"), ""); @@ -1202,7 +1202,7 @@ pub fn publish_dialog(app: &mut FigApp, ctx: &egui::Context) { ), } widgets::label(ui, tr("File name"), t.text_secondary); - let nresp = ui.add(egui::TextEdit::singleline(&mut form.name).desired_width(380.0).id(egui::Id::new("live_publish_name"))); + let nresp = ui.add(egui::TextEdit::singleline(&mut form.name).desired_width(380.0).id(egui::Id::unique("live_publish_name"))); a11y::text(&nresp, tr("File name"), &form.name); crate::widgets::select_all_on_focus(ui, &nresp, &form.name); go |= nresp.lost_focus() && enter(ui) && (keyed || open_server); @@ -1249,7 +1249,7 @@ fn published_dialog(app: &mut FigApp, ctx: &egui::Context) { let t = Tokens::get(ctx); let mut open = true; let mut done = false; - crate::dialogs::window(tr("File published"), ctx).id(egui::Id::new("File published")).open(&mut open).show(ctx, |ui| { + crate::dialogs::window(tr("File published"), ctx).id(egui::Id::unique("File published")).open(&mut open).show(ctx, |ui| { ui.set_width(400.0); ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap); widgets::label(ui, &trf("{name} is on the server.", &[("name", &p.name)]), t.text); diff --git a/crates/ui-egui/src/live_join_tests.rs b/crates/ui-egui/src/live_join_tests.rs index 61567ce0..bc1db8ad 100644 --- a/crates/ui-egui/src/live_join_tests.rs +++ b/crates/ui-egui/src/live_join_tests.rs @@ -14,8 +14,9 @@ fn escape(h: &mut Harness) { } } +/// Whether the Join link field currently has focus. fn link_focused(h: &Harness) -> bool { - h.ctx.memory(|m| m.has_focus(egui::Id::new("live_join_link"))) + h.ctx.memory(|m| m.has_focus(egui::Id::unique("live_join_link"))) } #[test] diff --git a/crates/ui-egui/src/live_server_tests.rs b/crates/ui-egui/src/live_server_tests.rs index 4a92edf0..014be152 100644 --- a/crates/ui-egui/src/live_server_tests.rs +++ b/crates/ui-egui/src/live_server_tests.rs @@ -20,8 +20,9 @@ fn key(h: &mut Harness, key: Key) { } } +/// Whether the widget with the given id currently has focus. fn focused(h: &Harness, id: &str) -> bool { - h.ctx.memory(|m| m.has_focus(egui::Id::new(id))) + h.ctx.memory(|m| m.has_focus(egui::Id::unique(id))) } /// Joined through a server's link with `access`: the welcome and the document (a new tab). diff --git a/crates/ui-egui/src/live_share_tests.rs b/crates/ui-egui/src/live_share_tests.rs index 66750cac..d7fc61c8 100644 --- a/crates/ui-egui/src/live_share_tests.rs +++ b/crates/ui-egui/src/live_share_tests.rs @@ -385,13 +385,16 @@ fn without_a_code_the_link_is_shared_instead() { assert_eq!(script.lock().unwrap().copied.last().map(String::as_str), Some(PLAIN)); } +/// Asserts the join field accepts a typed code or invite link, joins on Enter, clears after +/// joining, shows failure/retry, rejects an invalid code with an error, and accepts local +/// codes and links. #[test] fn the_join_field_takes_a_code_or_a_link_and_enter_joins() { let (mut h, script) = harness(); let shapes = open(&mut h); // Click into the field (its hint), type a code the way people type it, press Enter. click(&mut h, &shapes, "Code or invite link"); - assert!(h.ctx.memory(|m| m.has_focus(egui::Id::new("live_join_code")))); + assert!(h.ctx.memory(|m| m.has_focus(egui::Id::unique("live_join_code")))); type_text(&mut h, " k7m 2qx-9td "); assert_eq!(crate::live::inspect(&h.app)["joinField"], " k7m 2qx-9td "); key(&mut h, Key::Enter); @@ -450,13 +453,15 @@ fn the_home_screen_joins_with_a_code_and_opens_the_file() { assert_eq!(h.app.session.docs.len(), 2); } +/// Asserts the join dialog uses the same shared join field, focuses it, and closes once the +/// join starts. #[test] fn the_join_dialog_is_the_same_field() { let (mut h, script) = harness(); crate::menus::invoke(&mut h.app, "collab.join"); let shapes = h.frames(2); assert!(has_text(&shapes, "Type the join code you were given, or paste an invite link.") && has_text(&shapes, "Code or invite link")); - assert!(h.ctx.memory(|m| m.has_focus(egui::Id::new("live_join_link"))), "the field has the keyboard"); + assert!(h.ctx.memory(|m| m.has_focus(egui::Id::unique("live_join_link"))), "the field has the keyboard"); type_text(&mut h, "4fh j8r 2wn"); key(&mut h, Key::Enter); h.frames(1); diff --git a/crates/ui-egui/src/live_tests.rs b/crates/ui-egui/src/live_tests.rs index 331316b8..63c3c42a 100644 --- a/crates/ui-egui/src/live_tests.rs +++ b/crates/ui-egui/src/live_tests.rs @@ -290,6 +290,8 @@ fn cursors_follow_pages_in_a_shared_document() { assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Hana left")); } +/// Asserts clicking a peer's avatar follows that peer, and Escape or scrolling the canvas +/// stops following. #[test] fn clicking_an_avatar_follows_and_escape_stops() { let (mut h, _) = Harness::new(); @@ -317,6 +319,7 @@ fn clicking_an_avatar_follows_and_escape_stops() { h.frame(vec![Event::PointerMoved(pos2(700.0, 500.0))]); h.frame(vec![Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, delta: vec2(0.0, -40.0), phase: egui::TouchPhase::Move, modifiers: Default::default(), diff --git a/crates/ui-egui/src/menu_aim_tests.rs b/crates/ui-egui/src/menu_aim_tests.rs index 5e2fe076..6f7c4b8a 100644 --- a/crates/ui-egui/src/menu_aim_tests.rs +++ b/crates/ui-egui/src/menu_aim_tests.rs @@ -87,8 +87,9 @@ impl Ui { const TOPS: [&str; 6] = ["File", "Edit", "View", "Object", "Text", "Arrange"]; +/// Stable id for a top-level menu in the menu-aim test harness. fn top(name: &str) -> Id { - Id::new(("menu", name)) + Id::unique(("menu", name)) } /// The submenus of top-level menu `menu` (their keys), from the menu catalog. @@ -145,6 +146,8 @@ fn a_reopened_main_menu_shows_no_stale_submenu() { assert!(u.tops_shown().is_empty(), "reopened with {:?} open", u.tops_shown()); } +/// Asserts leaving a top menu and returning to it does not restore the submenu that was open +/// before. #[test] fn a_submenu_one_level_down_reopens_closed() { let mut u = Ui::new(); @@ -152,7 +155,7 @@ fn a_submenu_one_level_down_reopens_closed() { u.settle(u.top_row(0).center()); let keys = submenu_keys("File"); assert!(!keys.is_empty(), "File has submenus"); - let sub_shown = |u: &Ui| keys.iter().find(|k| u.shown(Id::new(("submenu", k.as_str())))).cloned(); + let sub_shown = |u: &Ui| keys.iter().find(|k| u.shown(Id::unique(("submenu", k.as_str())))).cloned(); // Down the middle of File's panel until one of its submenus opens. let panel = u.rect(top("File")); let mut y = panel.min.y + 10.0; diff --git a/crates/ui-egui/src/menu_input_tests.rs b/crates/ui-egui/src/menu_input_tests.rs index f317b65b..6fc65533 100644 --- a/crates/ui-egui/src/menu_input_tests.rs +++ b/crates/ui-egui/src/menu_input_tests.rs @@ -69,6 +69,8 @@ fn batch_rename_opens_its_existing_dialog() { assert!(ui.app.ui.last_error.is_none()); } +/// Asserts `file.rename` opens the left panel's inline file-name editor (not a dialog), +/// focuses it, and commits the typed name on Enter. #[test] fn rename_file_edits_the_file_name_in_place() { let mut ui = Ui::new(Session::new()); @@ -79,7 +81,7 @@ fn rename_file_edits_the_file_name_in_place() { ui.frame(vec![]); ui.frame(vec![]); assert!(!ui.app.ui.rename_file, "the rename started"); - assert!(ui.ctx.memory(|m| m.has_focus(egui::Id::new("filename").with("te"))), "the file name's editor has the keyboard"); + assert!(ui.ctx.memory(|m| m.has_focus(egui::Id::unique("filename").with("te"))), "the file name's editor has the keyboard"); ui.frame(vec![egui::Event::Text("Brand kit".into())]); ui.frame(vec![egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]); assert_eq!(ui.app.session.doc().unwrap().name, "Brand kit"); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index fb4966ed..0da6ed1b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -37,6 +37,16 @@ pub const UI_COMMANDS: &[&str] = &[ /// middle of the view. /// In Dev Mode only a comment's link pastes (it opens the thread); everything else would edit. pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) { + // A gesture injected through the control channel pastes only our own copied layers: it must not + // read (and so expose) the user's system clipboard. + if app.control_synthetic { + // Synthetic input is user-like: Dev Mode refuses the paste here too, and only the engine's + // own copied layers are pasted (the system clipboard is never read). + if !dev_mode_refuses(app, "edit.paste") { + app.run("edit.paste", at.map_or(json!({}), |(x, y)| json!({"x": x, "y": y}))); + } + return; + } // Only HTML that holds readable design data takes this path; other HTML pastes as before. if let Some(html) = app.services.clipboard_get_html().filter(|h| figcraft_engine::cmd::edit::design_clipboard_buffer(h).is_some()) { if dev_mode_refuses(app, "edit.paste") { @@ -50,6 +60,7 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) if let Some(r) = app.run("edit.pasteDesignLayers", params) { // The pasted layers are now our copy: text another app puts on the clipboard later is newer. app.ui.last_copy = Some(app.services.clipboard_get_text().unwrap_or_default()); + app.ui.last_copy_generation = app.session.clipboard.layers_generation; let left_out = r["warnings"].as_array().map_or(0, Vec::len); if left_out > 0 { app.toast(trn("Pasted design layers; {n} kinds of content were left out", left_out, &[])); @@ -58,7 +69,11 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) return; } let sys_text = app.services.clipboard_get_text().or_else(|| event_text.map(str::to_string)).filter(|t| !t.is_empty()); - let ours = sys_text.is_some() && sys_text == app.ui.last_copy; + // The text matching `last_copy` means the system clipboard still holds what we mirrored only + // while the engine clipboard is the one we mirrored. A control-channel copy changes the engine + // clipboard without writing the system clipboard, so its generation no longer matches and the + // user's own text is pasted instead of the programmatic selection. + let ours = sys_text.is_some() && sys_text == app.ui.last_copy && app.ui.last_copy_generation == app.session.clipboard.layers_generation; let (cx, cy) = at.or_else(|| app.session.ds().ok().map(|d| (d.view.cx, d.view.cy))).unwrap_or((0.0, 0.0)); if !ours { if let Some((png, w, h)) = clipboard_png(app) { @@ -72,8 +87,10 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) app.run("file.import", json!({"data": b64, "name": "Pasted image.png", "x": x, "y": y})); return; } - // Text from elsewhere when it's newer than our copy, or when we have nothing to paste. - if let Some(text) = sys_text.filter(|_| app.ui.last_copy.is_some() || app.session.clipboard.nodes.is_empty()) { + // Not ours: any other text is newer (another app's text, SVG, a comment link) and wins. + // Our own marker for copied layers without a text/SVG mirror is never a text paste; when + // the engine copy moved on it falls through to `edit.paste` (the only layers we still have). + if let Some(text) = sys_text.filter(|t| !is_clipboard_marker(t)) { return paste_text(app, &text, (cx, cy), at.is_none()); } } @@ -82,6 +99,11 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) } } +/// The marker FigCraft puts on the system clipboard for copied layers with no text/SVG mirror. +fn is_clipboard_marker(text: &str) -> bool { + text.starts_with("figcraft://clipboard/") +} + /// Paste text from another app at `(cx, cy)` (`center`: centred there): a comment's link opens its /// thread, SVG imports, anything else becomes a text layer. fn paste_text(app: &mut FigApp, text: &str, (cx, cy): (f64, f64), center: bool) { @@ -183,14 +205,14 @@ pub fn invoke(app: &mut FigApp, id: &str) { let ids = app.session.selection(); if let (Some(first), Ok(doc)) = (ids.first(), app.session.doc()) { let css = figcraft_engine::codegen::code_for(doc, *first, figcraft_engine::codegen::CodeLang::Css); - app.services.clipboard_set_text(&css); + app.clipboard_set_text(&css); app.toast(tr("Copied as CSS")); } } "edit.copyAsSvg" => { let ids = app.session.selection(); if let Some(svg) = app.session.doc().ok().and_then(|d| figcraft_svg_export(d, &ids)) { - app.services.clipboard_set_text(&svg); + app.clipboard_set_text(&svg); app.toast(tr("Copied as SVG")); } } @@ -202,7 +224,7 @@ pub fn invoke(app: &mut FigApp, id: &str) { // Clipboard images are read as sRGB. figcraft_render::to_srgb(&mut pix, profile); let rgba = figcraft_render::to_rgba8(&pix); - app.services.clipboard_set_png(pix.width(), pix.height(), &rgba); + app.clipboard_set_png(pix.width(), pix.height(), &rgba); app.toast(tr("Copied as PNG")); } } @@ -214,7 +236,7 @@ pub fn invoke(app: &mut FigApp, id: &str) { .iter() .filter_map(|i| doc.and_then(|d| d.node(*i)).and_then(|n| n.kind.text().map(|t| t.characters.clone()))) .collect(); - app.services.clipboard_set_text(&text.join("\n")); + app.clipboard_set_text(&text.join("\n")); app.toast(tr("Copied as text")); } "edit.copyLink" => { @@ -261,10 +283,15 @@ pub fn invoke(app: &mut FigApp, id: &str) { let mirror = doc.and_then(|d| text_layers_text(d, &ids).or_else(|| figcraft_svg_export(d, &ids))); app.run(id, json!({})); // Mirror to the system clipboard (text or SVG, else a marker) and remember it, so a - // later paste knows whether the clipboard still holds our layers. - let text = mirror.unwrap_or_else(|| format!("figcraft://clipboard/{}", ids.len())); - app.services.clipboard_set_text(&text); - app.ui.last_copy = Some(text); + // later paste knows whether the clipboard still holds our layers. Control-channel + // gestures keep the engine's copy but never replace the user's clipboard; `run` then + // invalidates the old mirror because it no longer describes the engine clipboard. + if !app.control_synthetic { + let text = mirror.unwrap_or_else(|| format!("figcraft://clipboard/{}", ids.len())); + app.clipboard_set_text(&text); + app.ui.last_copy = Some(text); + app.ui.last_copy_generation = app.session.clipboard.layers_generation; + } } "edit.paste" => paste(app, None, None), "file.close" if app.session.ds().is_ok_and(|d| d.is_dirty() && d.branch_of.is_none()) => { @@ -393,7 +420,7 @@ fn recent_menu(app: &mut FigApp, ui: &mut Ui, prefix: &str, key: &str, label: &s /// it. `key` names the submenu (its menu path, unique in its menu tree). Returns what `add` /// returned. pub(crate) fn submenu(ui: &mut Ui, key: &str, label: &str, t: &Tokens, add: impl FnOnce(&mut Ui) -> Option) -> Option { - submenu_at(ui, Id::new(("submenu", key)), key, label, 4.0, false, t, add) + submenu_at(ui, Id::unique(("submenu", key)), key, label, 4.0, false, t, add) } // --------------------------------------------------------------------------------------------- @@ -453,12 +480,14 @@ struct At { path: String, } +/// Scoped egui id for the current menu level. fn at_key(ui: &Ui) -> Id { - ui.id().with("menu_level") + ui.scope_id().with("menu_level") } +/// Return the current menu root/path, defaulting to the main menu root. fn level_at(ui: &Ui) -> At { - ui.data(|d| d.get_temp::(at_key(ui))).unwrap_or(At { root: Id::new("menus"), path: String::new() }) + ui.data(|d| d.get_temp::(at_key(ui))).unwrap_or(At { root: Id::unique("menus"), path: String::new() }) } fn with_level(ctx: &egui::Context, at: &At, f: impl FnOnce(&mut Level) -> R) -> R { @@ -702,7 +731,7 @@ const MENU_W: f32 = 240.0; /// Where a menu remembers the width its rows need (its frame's ui), per language. fn width_key(ui: &Ui) -> Id { - ui.id().with(("menu_w", i18n::current().map(|l| l.code))) + ui.scope_id().with(("menu_w", i18n::current().map(|l| l.code))) } /// A row needs `w`: the menu widens to it from the next frame on. @@ -765,12 +794,12 @@ pub fn open_main_menu(ctx: &egui::Context, top: &str) { /// The context menu's area (and menu tree). fn ctx_menu_id() -> Id { - Id::new("ctx_menu") + Id::unique("ctx_menu") } /// The main menu's area. pub fn main_menu_id() -> Id { - Id::new("main_menu") + Id::unique("main_menu") } /// Whether the pointer is over a menu (any foreground area). @@ -792,6 +821,9 @@ const TOP_MENUS: &[&str] = &[ tr_noop("Help"), ]; +/// Draw the main and context menu trees: forget closed/stale submenu state, apply +/// control-channel Select-layer open/close, close on outside click or Escape, then draw the +/// colour picker and palette popups. pub fn popups(app: &mut FigApp, ctx: &egui::Context) { let t = Tokens::get(ctx); // A closed menu forgets its open submenus (and a context menu opened somewhere else starts @@ -800,7 +832,7 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { forget_tree(ctx, main_menu_id()); } let ctx_at = app.ui.context_menu.as_ref().map(|(p, _)| *p); - let ctx_seen = Id::new("ctx_menu_at"); + let ctx_seen = Id::unique("ctx_menu_at"); if ctx.data(|d| d.get_temp::>(ctx_seen)) != Some(ctx_at) { ctx.data_mut(|d| d.insert_temp(ctx_seen, ctx_at)); forget_tree(ctx, ctx_menu_id()); @@ -834,7 +866,9 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { } ui.add_space(6.0); for top in TOP_MENUS { - if submenu_at(ui, Id::new(("menu", *top)), top, tr(top), 12.0, false, &t, |ui| menu_level(app, ui, top).then_some(())).is_some() { + if submenu_at(ui, Id::unique(("menu", *top)), top, tr(top), 12.0, false, &t, |ui| menu_level(app, ui, top).then_some(())) + .is_some() + { close = true; } } @@ -853,7 +887,7 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { } if let Some((pos, ids)) = app.ui.context_menu.clone() { if let Some(f) = ctx.memory(|m| m.focused()) { - a11y::popup(&mut app.ui.nav, f, Id::new("ctx_menu")); + a11y::popup(&mut app.ui.nav, f, Id::unique("ctx_menu")); } let mut close = false; let area = egui::Area::new(ctx_menu_id()).fixed_pos(pos).order(egui::Order::Foreground).constrain(true).show(ctx, |ui| { @@ -1021,8 +1055,9 @@ const SELECT_LAYER: &str = "Select layer"; /// The context menu's Copy as submenu: its key in the tree, and the marker row in the entries. const COPY_AS: &str = "Copy as"; +/// Stable egui id for the context menu's pending Select-layer open request. fn select_layer_ask_id() -> Id { - Id::new("ctx_select_layer_ask") + Id::unique("ctx_select_layer_ask") } /// Open (or close) the context menu's Select layer submenu (applied when the menu next draws). @@ -1051,7 +1086,7 @@ fn select_layer_menu(app: &mut FigApp, ui: &mut Ui, pos: egui::Pos2, t: &Tokens) return false; } let sel = ds.selection.clone(); - let picked = submenu_at(ui, Id::new("ctx_select_layer"), SELECT_LAYER, tr("Select layer"), 4.0, true, t, |ui| { + let picked = submenu_at(ui, Id::unique("ctx_select_layer"), SELECT_LAYER, tr("Select layer"), 4.0, true, t, |ui| { let mut picked = None; for (id, depth, name) in &layers { if indented_item(ui, name, "", true, sel.contains(id), 12.0 * (*depth).min(8) as f32, t) { @@ -1117,7 +1152,7 @@ fn palette(app: &mut FigApp, ctx: &egui::Context) { i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown) as i64 - i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp) as i64 }); - egui::Area::new(Id::new("palette")).fixed_pos(area.min).order(egui::Order::Foreground).show(ctx, |ui| { + egui::Area::new(Id::unique("palette")).fixed_pos(area.min).order(egui::Order::Foreground).show(ctx, |ui| { egui::Frame::NONE .fill(t.panel) .corner_radius(CornerRadius::same(13)) diff --git a/crates/ui-egui/src/online.rs b/crates/ui-egui/src/online.rs index 2f054986..61901fae 100644 --- a/crates/ui-egui/src/online.rs +++ b/crates/ui-egui/src/online.rs @@ -163,8 +163,9 @@ fn row(app: &mut FigApp, ui: &mut Ui, t: &Tokens, f: &OnlineFile, busy: bool) { let shown = FileRow { mark: Some((glyph, tint)), name: &f.name, ink, detail: &details, detail_size: 11.0, detail_ink: t.text_secondary }; file_row(ui, t, r.with_max_x(more.min.x - 4.0), &resp, &shown); // The menu button takes its own clicks. - let mresp = - ui.interact(more, ui.id().with(("online_more", &f.server, &f.file)), Sense::click()).tip(trf("Actions for {name}", &[("name", &f.name)])); + let mresp = ui + .interact(more, ui.scope_id().with(("online_more", &f.server, &f.file)), Sense::click()) + .tip(trf("Actions for {name}", &[("name", &f.name)])); a11y::menu_button(&mresp, &trf("Actions for {name}", &[("name", &f.name)]), a11y::menu_open(&mresp)); if mresp.hovered() { ui.painter().rect_filled(more, CornerRadius::same(5), t.pressed); @@ -199,14 +200,14 @@ fn row(app: &mut FigApp, ui: &mut Ui, t: &Tokens, f: &OnlineFile, busy: bool) { if let Some(code) = shared.and_then(|s| s.edit.code.as_deref()) && item(ui, tr("Copy code"), true) { - app.services.clipboard_set_text(&figcraft_engine::figcraft_collab::code::grouped(code)); + app.clipboard_set_text(&figcraft_engine::figcraft_collab::code::grouped(code)); app.toast(tr("Join code copied")); ui.close(); } if let Some(s) = shared && item(ui, tr("Copy link"), true) { - app.services.clipboard_set_text(&s.edit.link); + app.clipboard_set_text(&s.edit.link); app.toast(tr("Invite link copied")); ui.close(); } @@ -257,7 +258,7 @@ pub fn dialogs(app: &mut FigApp, ctx: &egui::Context) { let t = Tokens::get(ctx); if let Some((server, file, name)) = app.ui.online.remove.take() { let (mut open, mut cancel, mut go) = (true, false, false); - crate::dialogs::window(tr("Remove from server"), ctx).id(egui::Id::new("Remove online file")).open(&mut open).show(ctx, |ui| { + crate::dialogs::window(tr("Remove from server"), ctx).id(egui::Id::unique("Remove online file")).open(&mut open).show(ctx, |ui| { ui.set_width(380.0); ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap); widgets::label(ui, &trf("Remove {name} from {server}?", &[("name", &name), ("server", &server_host(&server))]), t.text); @@ -282,11 +283,11 @@ pub fn dialogs(app: &mut FigApp, ctx: &egui::Context) { if let Some((server, file, mut name)) = app.ui.online.rename.take() { let (mut open, mut cancel, mut go) = (true, false, false); let focus = std::mem::take(&mut app.ui.online.focus_rename); - crate::dialogs::window(tr("Rename on the server"), ctx).id(egui::Id::new("Rename online file")).open(&mut open).show(ctx, |ui| { + crate::dialogs::window(tr("Rename on the server"), ctx).id(egui::Id::unique("Rename online file")).open(&mut open).show(ctx, |ui| { ui.set_width(380.0); ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap); widgets::label(ui, tr("The name in your online files and on the server. The document inside keeps its own name."), t.text_secondary); - let resp = widgets::text_input(ui, egui::Id::new("online_rename"), &mut name, 380.0, tr("File name")); + let resp = widgets::text_input(ui, egui::Id::unique("online_rename"), &mut name, 380.0, tr("File name")); a11y::text(&resp, tr("File name"), &name); if focus { resp.request_focus(); diff --git a/crates/ui-egui/src/padding_individual_tests.rs b/crates/ui-egui/src/padding_individual_tests.rs index bc95886e..bf076d48 100644 --- a/crates/ui-egui/src/padding_individual_tests.rs +++ b/crates/ui-egui/src/padding_individual_tests.rs @@ -61,12 +61,12 @@ fn side_fields_show_only_once_the_toggle_opens_and_set_one_side() { let (mut u, f) = scene(); assert_eq!(padding(&u, f), [10.0; 4]); // Shut: no per-side fields. - assert!(u.ctx.read_response(Id::new(("padding", 0)).with("field")).is_none(), "top field hidden until toggled"); + assert!(u.ctx.read_response(Id::unique(("padding", 0)).with("field")).is_none(), "top field hidden until toggled"); // Click the toggle: the four sides appear. let t = named_button(&mut u, "Individual padding"); click(&mut u, t); - let top = u.ctx.read_response(Id::new(("padding", 0)).with("field")).expect("top field shows").rect.center(); - assert!(u.ctx.read_response(Id::new(("padding", 3)).with("field")).is_some(), "left field shows"); + let top = u.ctx.read_response(Id::unique(("padding", 0)).with("field")).expect("top field shows").rect.center(); + assert!(u.ctx.read_response(Id::unique(("padding", 3)).with("field")).is_some(), "left field shows"); // Typing in the top field sets only the top, in one undo step. let history = u.app.session.undo_depth().0; click(&mut u, top); diff --git a/crates/ui-egui/src/panel_remove_tests.rs b/crates/ui-egui/src/panel_remove_tests.rs index 3805dca3..1608902d 100644 --- a/crates/ui-egui/src/panel_remove_tests.rs +++ b/crates/ui-egui/src/panel_remove_tests.rs @@ -35,6 +35,7 @@ fn remove_button(u: &mut Ui, list: &str, index: usize) -> Pos2 { } let wheel = Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, delta: egui::vec2(0.0, -100.0), phase: egui::TouchPhase::Move, modifiers: Default::default(), @@ -63,10 +64,13 @@ fn remove_button(u: &mut Ui, list: &str, index: usize) -> Pos2 { .unwrap_or_else(|| panic!("no Remove button in {list} row {index} {row:?} {all:?}")) } +/// Read a temporary boolean open-settings flag keyed by `salt` and row index. fn flag(u: &Ui, salt: &'static str, i: usize) -> bool { - u.ctx.data(|d| d.get_temp::(Id::new((salt, i)))).unwrap_or(false) + u.ctx.data(|d| d.get_temp::(Id::unique((salt, i)))).unwrap_or(false) } +/// Asserts removing a fill keeps the open picker on the surviving paint (with its index +/// shifted), and removing the paint being edited closes the picker. #[test] fn removing_a_fill_keeps_the_picker_on_its_paint() { let mut s = Session::new(); @@ -78,7 +82,7 @@ fn removing_a_fill_keeps_the_picker_on_its_paint() { let mut u = Ui::new(s); u.ctx.enable_accesskit(); // Green's swatch, then red's Remove: the picker follows green to index 0. - let sw = u.ctx.read_response(Id::new(("swatch", true, 1usize))).unwrap().rect.center(); + let sw = u.ctx.read_response(Id::unique(("swatch", true, 1usize))).unwrap().rect.center(); click(&mut u, sw); assert_eq!(u.app.ui.picker, Some(PickerTarget::Paint { fills: true, index: 1, ids: vec![r] })); let rm = remove_button(&mut u, "fills", 0); @@ -92,6 +96,8 @@ fn removing_a_fill_keeps_the_picker_on_its_paint() { assert_eq!(u.app.ui.picker, None); } +/// Asserts removing effects or grids moves open settings and an open colour picker to the +/// surviving row, or closes them when their own row is removed. #[test] fn removing_effects_and_grids_keeps_open_settings_with_their_rows() { let mut s = Session::new(); @@ -105,7 +111,7 @@ fn removing_effects_and_grids_keeps_open_settings_with_their_rows() { let mut u = Ui::new(s); u.ctx.enable_accesskit(); // Effect 2's settings open and its colour in the picker; effect 0 goes. - u.ctx.data_mut(|d| d.insert_temp(Id::new(("fx_open", 2usize)), true)); + u.ctx.data_mut(|d| d.insert_temp(Id::unique(("fx_open", 2usize)), true)); u.app.ui.picker = Some(PickerTarget::Effect { ids: vec![f], index: 2, secondary: false }); let rm = remove_button(&mut u, "effects", 0); click(&mut u, rm); @@ -118,7 +124,7 @@ fn removing_effects_and_grids_keeps_open_settings_with_their_rows() { assert!(!flag(&u, "fx_open", 0) && !flag(&u, "fx_open", 1)); assert_eq!(u.app.ui.picker, None); // Grid 1's settings open; grid 0 goes. - u.ctx.data_mut(|d| d.insert_temp(Id::new(("grid_open", 1usize)), true)); + u.ctx.data_mut(|d| d.insert_temp(Id::unique(("grid_open", 1usize)), true)); let rm = remove_button(&mut u, "grids", 0); click(&mut u, rm); assert_eq!(u.app.session.doc().unwrap().node(f).unwrap().grids.len(), 1); diff --git a/crates/ui-egui/src/panel_reorder_tests.rs b/crates/ui-egui/src/panel_reorder_tests.rs index 00f969b5..ffeb948e 100644 --- a/crates/ui-egui/src/panel_reorder_tests.rs +++ b/crates/ui-egui/src/panel_reorder_tests.rs @@ -73,6 +73,8 @@ fn escape_cancels_a_row_drag() { assert_eq!(u.app.session.ds().unwrap().selection, [r], "the Escape only cancels the drag"); } +/// Asserts effect and layout-grid rows reorder by drag, while a mixed fill list exposes no +/// drag handle. #[test] fn effects_and_grids_drag_too_and_mixed_lists_dont() { let (mut u, r) = setup(); @@ -94,6 +96,7 @@ fn effects_and_grids_drag_too_and_mixed_lists_dont() { // The layout grid section is below the fold: scroll the panel. let wheel = Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, delta: egui::vec2(0.0, -400.0), phase: egui::TouchPhase::Move, modifiers: Default::default(), diff --git a/crates/ui-egui/src/pending_open.rs b/crates/ui-egui/src/pending_open.rs index f5e978c6..c6ade598 100644 --- a/crates/ui-egui/src/pending_open.rs +++ b/crates/ui-egui/src/pending_open.rs @@ -175,7 +175,7 @@ pub fn pending_opens(app: &mut FigApp, ctx: &egui::Context) { let t = Tokens::get(ctx); let pending = &app.ui.pending_opens; // Shown at once: the frame after this one may block the window while the file opens. - crate::dialogs::window("", ctx).id(egui::Id::new("pending_open")).title_bar(false).movable(false).interactable(false).fade_in(false).show( + crate::dialogs::window("", ctx).id(egui::Id::unique("pending_open")).title_bar(false).movable(false).interactable(false).fade_in(false).show( ctx, |ui| { ui.set_width(480.0); diff --git a/crates/ui-egui/src/perf_tests.rs b/crates/ui-egui/src/perf_tests.rs index f8f39e93..eba5e68c 100644 --- a/crates/ui-egui/src/perf_tests.rs +++ b/crates/ui-egui/src/perf_tests.rs @@ -68,6 +68,8 @@ impl Bench { } } +/// Build and print a synthetic UI benchmark report for the given layer count; it makes no +/// assertions. fn report(layers: usize) { let mut b = Bench::new(layers); let mut rows: Vec<(&str, f64)> = Vec::new(); @@ -77,6 +79,7 @@ fn report(layers: usize) { rows.push(("hover over the canvas", b.mean(60, |i| pts[i].clone()))); let wheel = |d: f32| Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, delta: vec2(0.0, d), phase: egui::TouchPhase::Move, modifiers: Default::default(), @@ -179,8 +182,15 @@ fn design_system(icons: usize, instances: usize) -> (Document, figcraft_engine:: figcraft_engine::synthetic_ds::design_system_with_instances(&spec, instances).unwrap() } +/// Build a test mouse-wheel event for the given vertical delta. fn wheel(d: f32) -> Event { - Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, d), phase: egui::TouchPhase::Move, modifiers: Default::default() } + Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, + delta: vec2(0.0, d), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + } } /// UI frames with a large design system: the Assets panel listing its components and the Design diff --git a/crates/ui-egui/src/plugin_panel_tests.rs b/crates/ui-egui/src/plugin_panel_tests.rs index e4bc6d4f..53b1ba56 100644 --- a/crates/ui-egui/src/plugin_panel_tests.rs +++ b/crates/ui-egui/src/plugin_panel_tests.rs @@ -89,14 +89,18 @@ fn an_open_plugin_panel_draws_no_frames_while_idle() { Harness::new(session()).assert_idle("a plugin panel"); } +/// Asserts a plugin colour field's swatch opens the picker on that field, a picked colour +/// reaches the plugin, and a second plugin's panel opens while the first remains (two panels +/// present). #[test] fn a_colour_field_opens_the_colour_picker_and_two_panels_show_side_by_side() { let mut u = Ui::new(session()); u.ctx.enable_accesskit(); u.frame(vec![]); let nodes = |u: &mut Ui| u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes; - // The swatch beside a colour field opens the colour picker on it. - let swatch = nodes(&mut u).iter().map(|(_, n)| n).find(|n| n.label() == Some("Color")).and_then(|n| n.bounds()).unwrap(); + // The swatch beside the accent field (named for that field, not just "Color") opens the + // colour picker on the plugin field, not on another colour control in the window. + let swatch = nodes(&mut u).iter().map(|(_, n)| n).find(|n| n.label() == Some("Accent Color")).and_then(|n| n.bounds()).unwrap(); let at = Pos2::new(((swatch.x0 + swatch.x1) / 2.0) as f32, ((swatch.y0 + swatch.y1) / 2.0) as f32); u.button(at, true); u.button(at, false); diff --git a/crates/ui-egui/src/plugins_ui.rs b/crates/ui-egui/src/plugins_ui.rs index 745cde88..26479c4d 100644 --- a/crates/ui-egui/src/plugins_ui.rs +++ b/crates/ui-egui/src/plugins_ui.rs @@ -81,8 +81,8 @@ pub(crate) fn field_value(app: &FigApp, plugin: &str, control: &str) -> Option Option { let id = match &app.ui.plugin_form { - Some(f) if f.id == plugin => Id::new("plugin_form"), - _ => Id::new(("plugin_panel", plugin)), + Some(f) if f.id == plugin => Id::unique("plugin_form"), + _ => Id::unique(("plugin_panel", plugin)), }; ctx.memory(|m| m.area_rect(id)) } @@ -206,7 +206,7 @@ fn panel_windows(app: &mut FigApp, ctx: &Context) { let mut used = None; // Side by side leftwards from the properties panel, three to a row. let at = egui::pos2(right - PANEL_STEP * (i % 3) as f32, 52.0 + 28.0 * (i / 3) as f32); - floating(open.title(), ctx).id(Id::new(("plugin_panel", id))).pivot(Align2::RIGHT_TOP).default_pos(at).open(&mut keep).show(ctx, |ui| { + floating(open.title(), ctx).id(Id::unique(("plugin_panel", id))).pivot(Align2::RIGHT_TOP).default_pos(at).open(&mut keep).show(ctx, |ui| { ui.set_width(320.0); used = controls(ui, id, &open.panel.controls, &open.values, &t); }); @@ -245,12 +245,15 @@ const INSTALL: &str = "plugin.directory.install"; const MANAGE_W: f32 = 400.0; const ROW_TEXT_W: f32 = 300.0; +/// Draw the Plugins window with installed and browse tabs, dispatching remove, import, +/// browse, confirm and stop actions; closing the window cancels its directory requests and +/// clears confirmation. fn manage_window(app: &mut FigApp, ctx: &Context) { let t = Tokens::get(ctx); let mut open = true; let mut asked = None; let mut tab = app.ui.plugins_manage.tab; - window(tr("Plugins"), ctx).id(Id::new("Plugins")).open(&mut open).show(ctx, |ui| { + window(tr("Plugins"), ctx).id(Id::unique("Plugins")).open(&mut open).show(ctx, |ui| { ui.set_width(MANAGE_W); tab_strip(ui, &t, &PluginsTab::ALL, PluginsTab::label, &mut tab); ui.add_space(12.0); @@ -326,7 +329,7 @@ fn browse_tab(app: &mut FigApp, ui: &mut Ui, t: &Tokens) -> Option { // The field commits on Enter (which also browses) or when Browse takes the focus. let mut enter = false; if let Some(s) = - widgets::text_field(ui, Id::new("plugin_directory_server"), &server, MANAGE_W - 90.0, tr("Server address (ws://host:47900/)")) + widgets::text_field(ui, Id::unique("plugin_directory_server"), &server, MANAGE_W - 90.0, tr("Server address (ws://host:47900/)")) { enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); server = s.clone(); @@ -351,7 +354,7 @@ fn browse_tab(app: &mut FigApp, ui: &mut Ui, t: &Tokens) -> Option { return asked; }; ui.add_space(8.0); - if let Some(q) = widgets::text_field(ui, Id::new("plugin_directory_query"), &app.ui.plugins_manage.query, MANAGE_W, tr("Search plugins")) { + if let Some(q) = widgets::text_field(ui, Id::unique("plugin_directory_query"), &app.ui.plugins_manage.query, MANAGE_W, tr("Search plugins")) { app.ui.plugins_manage.query = q; } ui.add_space(8.0); @@ -432,7 +435,7 @@ fn confirm_window(app: &mut FigApp, ctx: &Context, t: &Tokens) { let installing = app.session.waiting_for(INSTALL); let title = if update { tr("Update plugin") } else { tr("Install plugin") }; let (mut open, mut install, mut cancel) = (true, false, false); - window(title, ctx).id(Id::new("plugin_install_confirm")).open(&mut open).show(ctx, |ui| { + window(title, ctx).id(Id::unique("plugin_install_confirm")).open(&mut open).show(ctx, |ui| { ui.set_width(340.0); ui.label(RichText::new(format!("{} {}", e.name, e.version)).font(semibold(13.0)).color(t.text)); widgets::label(ui, &trf("From {server}", &[("server", &server)]), t.text_secondary); @@ -509,6 +512,8 @@ fn summary(p: &InstalledPlugin) -> String { parts.join(" · ") } +/// Draw a plugin command's parameter form: close it when the plugin or command disappeared, +/// edit values and colour picks, run `plugin.run` on Run, and close on Cancel. fn form_window(app: &mut FigApp, ctx: &Context) { let Some(mut form) = app.ui.plugin_form.clone() else { return }; let Some((name, command)) = app @@ -529,7 +534,7 @@ fn form_window(app: &mut FigApp, ctx: &Context) { let mut pick = None; let label = command.label.trim_end_matches(['…', '.']).trim(); let title = if label.contains(name.as_str()) { label.to_string() } else { format!("{name}: {label}") }; - window(&title, ctx).id(Id::new("plugin_form")).open(&mut open).show(ctx, |ui| { + window(&title, ctx).id(Id::unique("plugin_form")).open(&mut open).show(ctx, |ui| { ui.set_width(320.0); match controls(ui, &format!("{}/{}", form.id, form.command), &command.params, &form.values, &t) { Some(Used::Set(name, v)) => { @@ -586,7 +591,7 @@ fn controls(ui: &mut Ui, salt: &str, params: &[Param], values: &Map { let current = values.get(&p.name).cloned().unwrap_or_else(|| p.default_value()); - field(ui, Id::new(("plugin_control", salt, &p.name)), p, ¤t) + field(ui, Id::unique(("plugin_control", salt, &p.name)), p, ¤t) } }; if hit.is_some() { @@ -643,7 +648,8 @@ fn field(ui: &mut Ui, id: Id, p: &Param, current: &Value) -> Option { let (picked, typed) = ui .horizontal(|ui| { let color = figcraft_color::Color::from_hex(hex).unwrap_or(figcraft_color::Color::BLACK); - let picked = widgets::swatch(ui, &figcraft_color::Paint::solid(color), widgets::FIELD_H).clicked(); + let label = format!("{name} {}", tr("Color")); + let picked = widgets::swatch_named(ui, &figcraft_color::Paint::solid(color), widgets::FIELD_H, &label).clicked(); (picked, widgets::text_field_named(ui, id, hex, FIELD_W - widgets::FIELD_H - 8.0, name).and_then(|s| p.coerce(&json!(s)).ok())) }) .inner; diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index 9b59015f..bbaae4b6 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -1740,7 +1740,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { app.ui.comments.close(); } finish(app, &mut pres); - link_notice(app, &mut pres, &ctx, Id::new("present_link_notice")); + link_notice(app, &mut pres, &ctx, Id::unique("present_link_notice")); // The options carry over to the next presentation. app.ui.present_view = pres.view; if live && let Some(BarAction::Responsive(on)) = action { @@ -1779,78 +1779,82 @@ fn navigation_bar(p: &mut Presenter, ctx: &egui::Context, now: f64) -> Option Id { - Id::new("proto_preview_pane") + Id::unique("proto_preview_pane") } /// Show the preview pane while `app.ui.preview` is set (call once per frame, before shortcuts: @@ -2217,7 +2221,7 @@ fn player(p: &mut Presenter, ui: &mut Ui, rect: Rect, now: f64, chrome: Chrome) if p.step_fling(now) { ctx.request_repaint(); } - let resp = ui.interact(area, ui.id().with("present_area"), Sense::click_and_drag()); + let resp = ui.interact(area, ui.scope_id().with("present_area"), Sense::click_and_drag()); let over = resp.contains_pointer(); if let Some(l) = layout(p, area, chrome) { // The pane only takes the keyboard while the pointer is over it. While a link notice shows @@ -2317,7 +2321,7 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome) let w = if chrome.pane { 24.0 } else { ui.painter().layout_no_wrap(shown.to_string(), regular(11.5), text).size().x + 16.0 }; let br = Rect::from_min_size(pos2(x - w, bar.center().y - 12.0), vec2(w, 24.0)); x = br.min.x - if chrome.pane { 2.0 } else { 4.0 }; - let mut resp = ui.interact(br, ui.id().with(("pbar", *label)), Sense::click()); + let mut resp = ui.interact(br, ui.scope_id().with(("pbar", *label)), Sense::click()); if chrome.pane { resp = resp.tip(shown); } @@ -2369,7 +2373,7 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome) let tip = playing.map(|f| f.description.trim()).filter(|d| !d.is_empty()).unwrap_or(tr("Flows")).to_string(); let galley = left.layout_no_wrap(label, semibold(11.5), text); let fr = Rect::from_min_size(pos2(lx, bar.center().y - 12.0), vec2(galley.size().x + 30.0, 24.0)); - let resp = ui.interact(fr.intersect(left.clip_rect()), ui.id().with("pflows"), Sense::click()).tip(tip); + let resp = ui.interact(fr.intersect(left.clip_rect()), ui.scope_id().with("pflows"), Sense::click()).tip(tip); if resp.hovered() { left.rect_filled(fr, CornerRadius::same(5), hover); } diff --git a/crates/ui-egui/src/present_tests.rs b/crates/ui-egui/src/present_tests.rs index 73bccaed..93729922 100644 --- a/crates/ui-egui/src/present_tests.rs +++ b/crates/ui-egui/src/present_tests.rs @@ -137,6 +137,8 @@ fn preview_pane_opens_follows_edits_and_the_selection() { assert!(u.app.ui.preview.is_none()); } +/// Asserts the preview's device viewport and scroll range, wheel and drag scrolling, the +/// end clamp with spring-back, and that navigation resets scroll to the top. #[test] fn the_frame_scrolls_on_the_device_screen_by_wheel_and_drag() { let (mut u, a, b, _) = setup(); @@ -150,6 +152,7 @@ fn the_frame_scrolls_on_the_device_screen_by_wheel_and_drag() { Event::PointerMoved(c), Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, delta: vec2(0.0, -60.0), phase: egui::TouchPhase::Move, modifiers: Default::default(), @@ -203,6 +206,7 @@ fn a_top_level_frame_taller_than_the_phone_scrolls_without_an_overflow_setting() Event::PointerMoved(c), Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, delta: vec2(0.0, -60.0), phase: egui::TouchPhase::Move, modifiers: Default::default(), diff --git a/crates/ui-egui/src/present_view_tests.rs b/crates/ui-egui/src/present_view_tests.rs index 42647dc0..361a4caf 100644 --- a/crates/ui-egui/src/present_view_tests.rs +++ b/crates/ui-egui/src/present_view_tests.rs @@ -135,6 +135,8 @@ fn num(v: &Value) -> f64 { v.as_f64().unwrap() } +/// Asserts presenter fit/fit-width/fill/actual scaling, pan ranges, wheel and drag panning, +/// option carry-over to the next presentation, and R restarting the flow. #[test] fn presenter_scales_by_its_options_and_pans_what_overflows() { let (mut u, big) = big(); @@ -165,6 +167,7 @@ fn presenter_scales_by_its_options_and_pans_what_overflows() { Event::PointerMoved(at), Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, delta: vec2(0.0, -80.0), phase: egui::TouchPhase::Move, modifiers: Default::default(), @@ -271,6 +274,7 @@ fn scrolling_keeps_its_momentum_and_springs_back_from_the_ends() { assert_eq!(u.preview()["flinging"], json!(false)); } +/// Asserts wheel momentum continues after input stops and then comes to rest. #[test] fn the_wheel_coasts_after_it_stops() { let (mut u, a, ..) = setup(); @@ -281,6 +285,7 @@ fn the_wheel_coasts_after_it_stops() { Event::PointerMoved(c), Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, delta: vec2(0.0, -40.0), phase: egui::TouchPhase::Move, modifiers: Default::default(), diff --git a/crates/ui-egui/src/proto_canvas.rs b/crates/ui-egui/src/proto_canvas.rs index 956523ee..b5d402b4 100644 --- a/crates/ui-egui/src/proto_canvas.rs +++ b/crates/ui-egui/src/proto_canvas.rs @@ -20,8 +20,9 @@ struct Drag { pos: Pos2, } +/// Stable egui id for prototype connection dragging. fn drag_id() -> Id { - Id::new("proto_canvas_drag") + Id::unique("proto_canvas_drag") } /// One drawn connection. @@ -59,9 +60,11 @@ struct Sources { ids: Arc>, } +/// Return the cached subtree nodes with interactions for `page`, recomputing only when the +/// document pointer or page changes. pub(crate) fn sources(ctx: &egui::Context, doc: &Arc, page: NodeId) -> Arc> { ctx.data_mut(|d| { - let c = d.get_temp_mut_or_default::(Id::new("proto_canvas_sources")); + let c = d.get_temp_mut_or_default::(Id::unique("proto_canvas_sources")); if !(c.doc.as_ref().is_some_and(|d| Arc::ptr_eq(d, doc)) && c.page == Some(page)) { let ids = doc.subtree(page).into_iter().filter(|i| doc.node(*i).is_some_and(|n| !n.interactions.is_empty())).collect(); *c = Sources { doc: Some(doc.clone()), page: Some(page), ids: Arc::new(ids) }; diff --git a/crates/ui-egui/src/proto_editor.rs b/crates/ui-egui/src/proto_editor.rs index 76ee3044..7163807e 100644 --- a/crates/ui-egui/src/proto_editor.rs +++ b/crates/ui-egui/src/proto_editor.rs @@ -32,7 +32,7 @@ pub struct Slot { impl Slot { /// A widget id for `what` of this action. pub fn id(&self, what: &'static str) -> Id { - Id::new((what, self.node, self.k, &self.path)) + Id::unique((what, self.node, self.k, &self.path)) } } @@ -129,8 +129,9 @@ fn unavailable(label: &str) -> &'static str { } } +/// Stable egui id for a node interaction's open action path. fn open_id(cx: &Cx) -> Id { - Id::new(("proto_open_action", cx.node, cx.k)) + Id::unique(("proto_open_action", cx.node, cx.k)) } /// The open action (the conditionals around it are open too); the first one by default. diff --git a/crates/ui-egui/src/proto_editor_tests.rs b/crates/ui-egui/src/proto_editor_tests.rs index 80d49616..63c152e1 100644 --- a/crates/ui-egui/src/proto_editor_tests.rs +++ b/crates/ui-egui/src/proto_editor_tests.rs @@ -78,6 +78,9 @@ fn condition(u: &Ui, btn: NodeId, block: usize) -> String { blocks[block].condition.as_ref().map(|c| c.display(&doc.variables)).unwrap_or_default() } +/// Asserts the interaction editor shows conditional blocks and nested actions, restarts a +/// condition when a variable of another type is picked, ignores a right-hand-side variable pick +/// for a complex condition, and one undo restores the previous condition. #[test] fn the_interaction_editor_shows_conditionals_and_picks_their_variables() { let (mut s, _, b, btn) = setup(); @@ -95,7 +98,7 @@ fn the_interaction_editor_shows_conditionals_and_picks_their_variables() { u.app.ui.right_tab = crate::RightTab::Prototype; crate::proto_panel::open_editor(&u.ctx, Some((btn, 0))); // The conditional open, then its nested set variable, then the else-if as an expression. - let open = egui::Id::new(("proto_open_action", btn, 0usize)); + let open = egui::Id::unique(("proto_open_action", btn, 0usize)); for path in [vec![1usize], vec![1, 0, 1], vec![1, 2, 0]] { u.ctx.data_mut(|d| d.insert_temp(open, path)); u.frames(3, Vec::new); @@ -154,6 +157,8 @@ fn the_builder_takes_variable_operator_value_conditions() { } } +/// Asserts invalid typed conditions keep their draft and show errors without saving, a valid +/// one clears the error and saves, and Enter does not leak to shortcuts. #[test] fn typed_conditions_are_checked_and_errors_show_until_fixed() { let (mut s, _, b, btn) = setup(); @@ -169,7 +174,7 @@ fn typed_conditions_are_checked_and_errors_show_until_fixed() { let mut u = Ui::new(s); u.app.ui.right_tab = crate::RightTab::Prototype; crate::proto_panel::open_editor(&u.ctx, Some((btn, 0))); - u.ctx.data_mut(|d| d.insert_temp(egui::Id::new(("proto_open_action", btn, 0usize)), vec![1usize])); + u.ctx.data_mut(|d| d.insert_temp(egui::Id::unique(("proto_open_action", btn, 0usize)), vec![1usize])); u.frames(3, Vec::new); // The else if is complex: it shows as an expression field. let field = Slot { node: btn, k: 0, path: vec![1] }.id("proto_cond_expr").with(1usize); diff --git a/crates/ui-egui/src/proto_panel.rs b/crates/ui-egui/src/proto_panel.rs index 747dbaf6..1b2e2df9 100644 --- a/crates/ui-egui/src/proto_panel.rs +++ b/crates/ui-egui/src/proto_panel.rs @@ -22,7 +22,7 @@ use crate::widgets::{self, FIELD_H, FieldEdit, Lead, PAD}; /// The interaction whose editor popover is open: (layer, index). fn editor_id() -> Id { - Id::new("proto_editor") + Id::unique("proto_editor") } pub fn open_editor(ctx: &egui::Context, target: Option<(NodeId, usize)>) { @@ -378,17 +378,17 @@ fn flow_row(app: &mut FigApp, ui: &mut Ui, f: &proto::Flow) { let (r, _) = ui.allocate_exact_size(vec2(w, 32.0), Sense::hover()); let button = |dx: f32| Rect::from_center_size(pos2(dx, r.center().y), vec2(24.0, 24.0)); let play = button(r.min.x + PAD + 8.0); - if icon_button(ui, Id::new(("flow_play", f.start)), play, Icon::Play, tr("Present this flow")) { + if icon_button(ui, Id::unique(("flow_play", f.start)), play, Icon::Play, tr("Present this flow")) { app.run("proto.present", json!({"start": f.start})); } let name_rect = Rect::from_min_max(pos2(play.max.x + 6.0, r.center().y - 9.0), pos2(r.max.x - 64.0, r.center().y + 9.0)); - if let Some(n) = widgets::text_field_inline(ui, Id::new(("flow_name", f.start)), &f.name, name_rect, regular(11.0)) { + if let Some(n) = widgets::text_field_inline(ui, Id::unique(("flow_name", f.start)), &f.name, name_rect, regular(11.0)) { app.run("proto.flow.rename", json!({"start": f.start, "name": n})); } - if icon_button(ui, Id::new(("flow_preview", f.start)), button(r.max.x - 44.0), Icon::Preview, tr("Preview this flow")) { + if icon_button(ui, Id::unique(("flow_preview", f.start)), button(r.max.x - 44.0), Icon::Preview, tr("Preview this flow")) { app.run("proto.preview", json!({"start": f.start})); } - if icon_button(ui, Id::new(("flow_rm", f.start)), button(r.max.x - 20.0), Icon::Minus, tr("Remove starting point")) { + if icon_button(ui, Id::unique(("flow_rm", f.start)), button(r.max.x - 20.0), Icon::Minus, tr("Remove starting point")) { app.run("proto.flow.remove", json!({"start": f.start})); } } @@ -426,6 +426,8 @@ fn flows_overview(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { } } +/// Draw prototype settings: device and rotation, device colour, custom size, responsive +/// toggle and page background, running `proto.settings`. fn settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { if widgets::section_header(ui, tr("Prototype settings"), &[(Icon::Preview, tr("Open prototype preview"))], false).is_some() { app.run("proto.preview", json!({})); @@ -479,7 +481,7 @@ fn settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { } row(ui, 32.0, |ui| { let clicked = widgets::checkbox(ui, settings.responsive, tr("Responsive")); - ui.interact(ui.min_rect(), Id::new("proto_responsive_tip"), Sense::hover()).on_hover_text(tr(RESPONSIVE_TIP)); + ui.interact(ui.min_rect(), Id::unique("proto_responsive_tip"), Sense::hover()).on_hover_text(tr(RESPONSIVE_TIP)); if clicked { app.run("proto.settings", json!({"responsive": !settings.responsive})); } @@ -487,7 +489,7 @@ fn settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { widgets::sublabels(ui, tr("Background"), None); row(ui, 32.0, |ui| { widgets::swatch(ui, &Paint::solid(bg), 16.0); - if let Some(h) = widgets::text_field(ui, Id::new("proto_bg"), &bg.to_hex(), 96.0, "") + if let Some(h) = widgets::text_field(ui, Id::unique("proto_bg"), &bg.to_hex(), 96.0, "") && let Some(c) = figcraft_color::Color::from_hex_or_name(&h) { app.run("proto.settings", json!({"background": c.to_css_hex()})); @@ -539,7 +541,7 @@ fn custom_size(app: &mut FigApp, ui: &mut Ui, model: Option) { row(ui, 32.0, |ui| { for (key, lead, v) in [("width", "W", d.width), ("height", "H", d.height)] { if let Some(FieldEdit::Set(x) | FieldEdit::Scrub(x, true)) = - widgets::number_field(ui, Id::new(("proto_custom", key)), Lead::Text(lead), Some(v), "", col, 1.0) + widgets::number_field(ui, Id::unique(("proto_custom", key)), Lead::Text(lead), Some(v), "", col, 1.0) && let Some(x) = proto::PrototypeSettings::custom_side(x) && x != v { @@ -552,6 +554,7 @@ fn custom_size(app: &mut FigApp, ui: &mut Ui, model: Option) { }); } +/// Show, create or preview the flow starting at `frame` and edit its description. fn flow_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, frame: NodeId) { let flows = flows_of(doc, page); let mine = flows.iter().find(|f| f.start == frame).cloned(); @@ -570,7 +573,7 @@ fn flow_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, fra if let Some(f) = mine { flow_row(app, ui, &f); row(ui, 32.0, |ui| { - if let Some(d) = widgets::text_field(ui, Id::new(("flow_desc", frame)), &f.description, ui.available_width(), tr("Add a description")) + if let Some(d) = widgets::text_field(ui, Id::unique(("flow_desc", frame)), &f.description, ui.available_width(), tr("Add a description")) && d != f.description { app.run("proto.flow.rename", json!({"start": frame, "description": d})); @@ -579,6 +582,8 @@ fn flow_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, fra } } +/// List a node's interactions, add a default click navigation (or Back), open the editor +/// popover and remove interactions. fn interactions(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, node: &Node) { let t = Tokens::get(ui.ctx()); let id = node.id; @@ -597,7 +602,7 @@ fn interactions(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, nod let w = ui.available_width(); let (r, _) = ui.allocate_exact_size(vec2(w, 44.0), Sense::hover()); let body = Rect::from_min_max(pos2(r.min.x + PAD - 6.0, r.min.y + 2.0), pos2(r.max.x - 40.0, r.max.y - 2.0)); - let resp = ui.interact(body, ui.id().with(("proto_it", id, i)), Sense::click()); + let resp = ui.interact(body, ui.scope_id().with(("proto_it", id, i)), Sense::click()); let active = open == Some((id, i)); if active { ui.painter().rect_filled(body, CornerRadius::same(5), t.selected_row); @@ -618,7 +623,7 @@ fn interactions(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, nod open_editor(ui.ctx(), if active { None } else { Some((id, i)) }); } let rm = Rect::from_center_size(pos2(r.max.x - 20.0, r.center().y), vec2(24.0, 24.0)); - if icon_button(ui, ui.id().with(("proto_rm", id, i)), rm, Icon::Minus, tr("Remove interaction")) { + if icon_button(ui, ui.scope_id().with(("proto_rm", id, i)), rm, Icon::Minus, tr("Remove interaction")) { open_editor(ui.ctx(), None); app.run("proto.interaction.remove", json!({"ids": targets_for(app, id), "index": i})); return; @@ -644,7 +649,7 @@ fn editor_popover(app: &mut FigApp, ctx: &egui::Context, doc: &Document, page: N let screen = ctx.content_rect(); let pos = pos2(at.x - width, at.y.min(screen.max.y - 420.0).max(screen.min.y + 48.0)); let mut close = false; - egui::Area::new(Id::new("proto_editor_area")).fixed_pos(pos).order(egui::Order::Foreground).show(ctx, |ui| { + egui::Area::new(Id::unique("proto_editor_area")).fixed_pos(pos).order(egui::Order::Foreground).show(ctx, |ui| { egui::Frame::new() .fill(t.panel) .stroke(Stroke::new(1.0, t.border)) @@ -657,7 +662,7 @@ fn editor_popover(app: &mut FigApp, ctx: &egui::Context, doc: &Document, page: N let (hr, _) = ui.allocate_exact_size(vec2(width, 40.0), Sense::hover()); ui.painter().text(pos2(hr.min.x + PAD, hr.center().y), Align2::LEFT_CENTER, tr("Interaction"), semibold(11.0), t.text); let x = Rect::from_center_size(pos2(hr.max.x - 20.0, hr.center().y), vec2(24.0, 24.0)); - let xr = ui.interact(x, ui.id().with("proto_editor_close"), Sense::click()).tip(tr("Close")); + let xr = ui.interact(x, ui.scope_id().with("proto_editor_close"), Sense::click()).tip(tr("Close")); if xr.hovered() { ui.painter().rect_filled(x, CornerRadius::same(5), t.hover); } @@ -696,14 +701,17 @@ fn animates(a: &Action) -> bool { ) } +/// Stable egui id for the prototype key-capture flag. fn capture_id() -> Id { - Id::new("proto_key_capture") + Id::unique("proto_key_capture") } fn capturing(ctx: &egui::Context) -> bool { ctx.data(|d| d.get_temp::(capture_id())).unwrap_or(false) } +/// Draw an interaction's trigger controls: trigger picker, mouse/delay fields, key capture +/// and controller-button selection, patching the interaction. fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Interaction) { let t = Tokens::get(ui.ctx()); widgets::sublabels(ui, tr("Trigger"), None); @@ -729,7 +737,7 @@ fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Inte Trigger::MouseEnter | Trigger::MouseLeave | Trigger::MouseDown | Trigger::MouseUp => { row(ui, 32.0, |ui| { widgets::label(ui, tr("Delay"), t.text_secondary); - let id = Id::new(("proto_mouse_delay", node, k)); + let id = Id::unique(("proto_mouse_delay", node, k)); if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = widgets::number_field(ui, id, Lead::None, Some(f64::from(it.delay_ms)), "ms", 96.0, 10.0) && v.is_finite() @@ -742,7 +750,7 @@ fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Inte row(ui, 32.0, |ui| { widgets::label(ui, tr("Delay"), t.text_secondary); if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = - widgets::number_field(ui, Id::new(("proto_delay", node, k)), Lead::None, Some(f64::from(*ms)), "ms", 96.0, 10.0) + widgets::number_field(ui, Id::unique(("proto_delay", node, k)), Lead::None, Some(f64::from(*ms)), "ms", 96.0, 10.0) && v.is_finite() { patch(app, node, k, json!({"trigger": {"type": "afterDelay", "ms": v.clamp(0.0, 600_000.0).round() as u32}})); @@ -1021,6 +1029,8 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: } } +/// Draw an interaction's animation controls: transition, direction, easing, duration and +/// custom easing fields, plus an animated curve preview. fn animation_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Interaction) { let t = Tokens::get(ui.ctx()); widgets::sublabels(ui, tr("Animation"), None); @@ -1082,7 +1092,7 @@ fn animation_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &In ui.label(egui::RichText::new(ms).font(regular(11.0)).color(t.text_secondary)) .tip(tr("A spring plays until it settles: its mass, stiffness and damping set how long")); } else if let Some(e) = - widgets::number_field(ui, Id::new(("proto_dur", node, k)), Lead::None, Some(f64::from(it.duration_ms)), "ms", 72.0, 10.0) + widgets::number_field(ui, Id::unique(("proto_dur", node, k)), Lead::None, Some(f64::from(it.duration_ms)), "ms", 72.0, 10.0) { match e { FieldEdit::Set(v) => { @@ -1112,7 +1122,7 @@ fn animation_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &In let box_r = Rect::from_min_max(pos2(r.min.x + PAD, r.min.y + 4.0), pos2(r.max.x - PAD, r.max.y - 4.0)); ui.painter().rect_filled(box_r, CornerRadius::same(5), t.field); let period = it.easing.seconds(it.duration_ms).max(0.05) + CURVE_HOLD; - let elapsed = curve_play(ui, Id::new(("proto_curve", node, k)), Id::new((format!("{:?}", it.easing), it.duration_ms)), resp.hovered()); + let elapsed = curve_play(ui, Id::unique(("proto_curve", node, k)), Id::unique((format!("{:?}", it.easing), it.duration_ms)), resp.hovered()); let phase = if resp.hovered() || elapsed < period * CURVE_PLAYS { ui.ctx().request_repaint_after(std::time::Duration::from_millis(33)); ((elapsed % period) / (period - CURVE_HOLD)).min(1.0) @@ -1147,12 +1157,13 @@ fn custom_easing_fields(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, e for i in 0..count { let (Some(name), Some(v), Some(step)) = (names.get(i), vals.get(i), steps.get(i)) else { continue }; let lead = if count == 4 { Lead::Text(name) } else { Lead::None }; - let edit = widgets::number_field(ui, Id::new(("proto_ease", node, k, i)), lead, Some((v * 100.0).round() / 100.0), "", w, *step).map( - |e| match e { - FieldEdit::Set(x) => (x, true, false), - FieldEdit::Scrub(x, done) => (x, done, true), - }, - ); + let edit = + widgets::number_field(ui, Id::unique(("proto_ease", node, k, i)), lead, Some((v * 100.0).round() / 100.0), "", w, *step).map(|e| { + match e { + FieldEdit::Set(x) => (x, true, false), + FieldEdit::Scrub(x, done) => (x, done, true), + } + }); if let Some((x, done, scrub)) = edit && x.is_finite() { diff --git a/crates/ui-egui/src/proto_reorder_tests.rs b/crates/ui-egui/src/proto_reorder_tests.rs index 1a061399..d69be4f6 100644 --- a/crates/ui-egui/src/proto_reorder_tests.rs +++ b/crates/ui-egui/src/proto_reorder_tests.rs @@ -44,8 +44,9 @@ fn setup() -> (Ui, NodeId) { (u, btn) } +/// Stable id for a prototype node's open action path in the reorder tests. fn open_id(btn: NodeId) -> egui::Id { - egui::Id::new(("proto_open_action", btn, 0usize)) + egui::Id::unique(("proto_open_action", btn, 0usize)) } /// The kinds of the actions at `prefix` (the interaction's, or a block's). diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 2243d11a..a084b9d0 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -78,6 +78,8 @@ impl FigApp { } } +/// Draw the right panel: people, present and share controls, Design/Prototype tabs and zoom +/// menu, then route the scroll area to comments, frame presets, design, prototype or inspect. pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let w = ui.available_width(); @@ -87,7 +89,7 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { let label = ui.painter().layout_no_wrap(tr("Share").to_string(), semibold(11.5), t.accent_on); let sw = (label.size().x + 24.0).max(56.0); let share = Rect::from_min_size(pos2(top.max.x - 14.0 - sw, top.center().y - 16.0), vec2(sw, 32.0)); - let sresp = ui.interact(share, ui.id().with("share"), Sense::click()).tip(tr("Share: live session, export")); + let sresp = ui.interact(share, ui.scope_id().with("share"), Sense::click()).tip(tr("Share: live session, export")); ui.painter().rect_filled(share, CornerRadius::same(6), if sresp.hovered() { t.accent_hover } else { t.accent }); ui.painter().galley(share.center() - label.size() / 2.0, label, t.accent_on); crate::live::share_popover(app, &sresp); @@ -96,8 +98,8 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { let (tri_r, chev_r) = (Rect::from_min_max(play.min, pos2(play.max.x - 16.0, play.max.y)), Rect::from_min_max(pos2(play.max.x - 16.0, play.min.y), play.max)); let present_keys = crate::toolbar::pretty_shortcut(crate::shortcuts::shortcut("proto.present").unwrap_or("")); - let presp = ui.interact(tri_r, ui.id().with("play"), Sense::click()).tip(format!("{} {present_keys}", tr("Present"))); - let mresp = ui.interact(chev_r, ui.id().with("play_menu"), Sense::click()).tip(tr("Present or preview")); + let presp = ui.interact(tri_r, ui.scope_id().with("play"), Sense::click()).tip(format!("{} {present_keys}", tr("Present"))); + let mresp = ui.interact(chev_r, ui.scope_id().with("play_menu"), Sense::click()).tip(tr("Present or preview")); if presp.hovered() || mresp.hovered() { ui.painter().rect_filled(play, CornerRadius::same(5), t.hover); } @@ -127,7 +129,7 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { for (tab, label) in [(RightTab::Design, tr("Design")), (RightTab::Prototype, tr("Prototype"))] { let galley = ui.painter().layout_no_wrap(label.into(), semibold(11.0), t.text); let r = Rect::from_min_size(pos2(x, tabs.center().y - 12.0), vec2(galley.size().x + 16.0, 24.0)); - let resp = ui.interact(r, ui.id().with(("rtab", label)), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with(("rtab", label)), Sense::click()); let active = app.ui.right_tab == tab; a11y::tab(&resp, label, active); if active { @@ -145,7 +147,7 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { // Zoom menu. let zoom = app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0); let zr = Rect::from_min_size(pos2(tabs.max.x - 64.0, tabs.center().y - 12.0), vec2(56.0, 24.0)); - let zresp = ui.interact(zr, ui.id().with("zoommenu"), Sense::click()); + let zresp = ui.interact(zr, ui.scope_id().with("zoommenu"), Sense::click()); // The value is only spelled out while a screen reader reads the tree. let value = if a11y::active(&zresp.ctx) { format!("{}%", (zoom * 100.0).round()) } else { String::new() }; a11y::combo(&zresp, tr("Zoom"), &value, a11y::menu_open(&zresp)); @@ -393,6 +395,8 @@ fn design(app: &mut FigApp, ui: &mut Ui) { crate::export_panel::section(app, ui, &doc, &sel); } +/// Draw the page section: background and colour profile, local variables/styles, and the +/// page export section. fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) { let t = Tokens::get(ui.ctx()); let Ok(page) = app.session.page() else { return }; @@ -408,7 +412,7 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) { if widgets::swatch(ui, &p, 16.0).clicked() { open_picker = true; } - if let Some(h) = widgets::text_field(ui, Id::new("pagebg"), &bg.to_hex(), 96.0, "") { + if let Some(h) = widgets::text_field(ui, Id::unique("pagebg"), &bg.to_hex(), 96.0, "") { new_hex = Some(h); } widgets::label(ui, "100 %", t.text); @@ -455,6 +459,8 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) { crate::export_panel::page_section(app, ui, doc, page); } +/// Draw the selection header: type name (or Mixed), frame/group conversion, orientation +/// swap and frame presets, component/mask/boolean quick actions, and instance details. fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let t = Tokens::get(ui.ctx()); let label = match common(doc, sel, |n| n.kind.label()) { @@ -479,7 +485,7 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let w = ui.available_width(); let (r, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); let lr = Rect::from_min_size(pos2(r.min.x + 8.0, r.center().y - 12.0), vec2(120.0, 24.0)); - let resp = ui.interact(lr, ui.id().with("typedd"), Sense::click()); + let resp = ui.interact(lr, ui.scope_id().with("typedd"), Sense::click()); if resp.hovered() { ui.painter().rect_filled(lr, CornerRadius::same(5), t.hover); } @@ -497,7 +503,7 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { } if is_frame { let sr = Rect::from_min_size(pos2(lr.max.x + 4.0, r.center().y - 12.0), vec2(24.0, 24.0)); - let sresp = ui.interact(sr, ui.id().with("swap_orient"), Sense::click()).tip(tr("Swap orientation")); + let sresp = ui.interact(sr, ui.scope_id().with("swap_orient"), Sense::click()).tip(tr("Swap orientation")); if sresp.hovered() { ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover); } @@ -536,7 +542,7 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { for (icon, label, cmd) in actions.into_iter().rev() { let br = Rect::from_min_size(pos2(x - 24.0, r.center().y - 12.0), vec2(24.0, 24.0)); let tip = if cmd == "bool.union" { label } else { crate::shortcuts::tip(label, cmd) }; - let bresp = ui.interact(br, ui.id().with(("qa", cmd)), Sense::click()).tip(tip); + let bresp = ui.interact(br, ui.scope_id().with(("qa", cmd)), Sense::click()).tip(tip); if bresp.hovered() { ui.painter().rect_filled(br, CornerRadius::same(5), t.hover); } @@ -589,6 +595,8 @@ fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { ui.add_space(8.0); } +/// Draw alignment and distribution, X/Y and rotation with flip/rotate controls, the +/// absolute-position toggle and frame constraints. fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let ids = sel.to_vec(); let col = widgets::col_width(ui.available_width()); @@ -640,9 +648,9 @@ fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId] let absolute = common(doc, sel, |n| n.child_layout.absolute); widgets::sublabels(ui, tr("Position"), None); row(ui, 32.0, |ui| { - let e = widgets::number_field_mixed(ui, Id::new("pos_x"), Lead::Text("X"), x, "", col, 1.0); + let e = widgets::number_field_mixed(ui, Id::unique("pos_x"), Lead::Text("X"), x, "", col, 1.0); app.field_of(&ids, "x", e, x_of, |v| json!(v)); - let e = widgets::number_field_mixed(ui, Id::new("pos_y"), Lead::Text("Y"), y, "", col, 1.0); + let e = widgets::number_field_mixed(ui, Id::unique("pos_y"), Lead::Text("Y"), y, "", col, 1.0); app.field_of(&ids, "y", e, y_of, |v| json!(v)); // Children of auto layout frames can ignore the layout (absolute position). if parent_al { @@ -662,7 +670,7 @@ fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId] }); widgets::sublabels(ui, tr("Rotation"), None); row(ui, 32.0, |ui| { - let e = widgets::number_field_mixed(ui, Id::new("pos_rot"), Lead::Icon(Icon::Angle, tr("Rotation")), rot, "°", col, 1.0); + let e = widgets::number_field_mixed(ui, Id::unique("pos_rot"), Lead::Icon(Icon::Angle, tr("Rotation")), rot, "°", col, 1.0); app.field_of(&ids, "rotation", e, rot_of, |v| json!(v)); let items = [ (Icon::Rotate, tr("Rotate 90° right")), @@ -745,7 +753,7 @@ fn arc_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) -> widgets::section_header(ui, tr("Arc"), &[], false); let col = widgets::col_width(ui.available_width()); let field = |app: &mut FigApp, ui: &mut Ui, key: &str, icon: Icon, name: &str, value: Option, suffix: &str| { - crate::variable_ui::number_field(app, ui, &ids, key, Id::new(("arc", key)), Lead::Icon(icon, name), value, suffix, col) + crate::variable_ui::number_field(app, ui, &ids, key, Id::unique(("arc", key)), Lead::Icon(icon, name), value, suffix, col) }; widgets::sublabels(ui, tr("Start"), Some(tr("Sweep"))); row(ui, 32.0, |ui| { @@ -787,10 +795,10 @@ fn points_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) let col = widgets::col_width(ui.available_width()); widgets::sublabels(ui, tr("Count"), all_stars.then_some(tr("Ratio"))); row(ui, 32.0, |ui| { - let e = widgets::number_field(ui, Id::new("pts_count"), Lead::Icon(Icon::Polygon, tr("Count")), count, "", col, 1.0); + let e = widgets::number_field(ui, Id::unique("pts_count"), Lead::Icon(Icon::Polygon, tr("Count")), count, "", col, 1.0); app.field(&ids, "pointCount", e, |v| json!(v.round().clamp(3.0, 120.0))); if all_stars { - let e = widgets::number_field(ui, Id::new("pts_ratio"), Lead::Icon(Icon::Star, tr("Ratio")), ratio, "%", col, 1.0); + let e = widgets::number_field(ui, Id::unique("pts_ratio"), Lead::Icon(Icon::Star, tr("Ratio")), ratio, "%", col, 1.0); app.field(&ids, "innerRadius", e, |v| json!((v / 100.0).clamp(0.001, 1.0))); } }); @@ -807,6 +815,8 @@ fn pretty(s: &str) -> String { } } +/// Draw visibility/blend/mask, opacity, uniform or per-corner radius, corner smoothing and +/// the blend-mode row. fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let ids = sel.to_vec(); let visible = common(doc, sel, |n| n.visible).unwrap_or(true); @@ -827,7 +837,7 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { crate::variable_ui::open_at_pointer(ui, &ids, "visible", VariableType::Boolean); } if hdr == Some(2) { - ui.data_mut(|d| d.insert_temp(Id::new("blend_open"), !d.get_temp::(Id::new("blend_open")).unwrap_or(false))); + ui.data_mut(|d| d.insert_temp(Id::unique("blend_open"), !d.get_temp::(Id::unique("blend_open")).unwrap_or(false))); } let col = widgets::col_width(ui.available_width()); crate::variable_ui::bound_row(app, ui, &ids, "visible", VariableType::Boolean, Lead::Icon(Icon::Eye, tr("Visible")), col * 2.0 + 8.0); @@ -858,7 +868,7 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { widgets::sublabels(ui, tr("Opacity"), radius_ok.then_some(tr("Corner radius"))); row(ui, 32.0, |ui| { let lead = Lead::Icon(Icon::Opacity, tr("Opacity")); - let e = crate::variable_ui::number_field_mixed(app, ui, &ids, "opacity", Id::new("ap_op"), lead, op, "%", col); + let e = crate::variable_ui::number_field_mixed(app, ui, &ids, "opacity", Id::unique("ap_op"), lead, op, "%", col); app.field_of(&ids, "opacity", e, op_of, |v| json!((v / 100.0).clamp(0.0, 1.0))); if radius_ok { let e = crate::variable_ui::number_field_mixed( @@ -866,7 +876,7 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { ui, &ids, "cornerRadius", - Id::new("ap_rad"), + Id::unique("ap_rad"), Lead::Icon(Icon::CornerRadius, tr("Corner radius")), r.flatten(), "", @@ -875,15 +885,15 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { // A layer with corners of its own starts from its largest. let radius_of = |n: &Node| n.corners.uniform().unwrap_or_else(|| n.corners.radii.iter().copied().fold(0.0, f64::max)); app.field_of(&ids, "cornerRadius", e, radius_of, |v| json!(v.max(0.0))); - let open = ui.data(|d| d.get_temp::(Id::new("indep_corners"))).unwrap_or(false); + let open = ui.data(|d| d.get_temp::(Id::unique("indep_corners"))).unwrap_or(false); // Polygons and stars have no corners of their own: the button opens only Corner smoothing. let tip = if indep_ok { tr("Individual corners") } else { tr("Corner smoothing") }; if smooth_ok && icons::button(ui, Icon::IndependentCorners, 24.0, open, tip).clicked() { - ui.data_mut(|d| d.insert_temp(Id::new("indep_corners"), !open)); + ui.data_mut(|d| d.insert_temp(Id::unique("indep_corners"), !open)); } } }); - if smooth_ok && ui.data(|d| d.get_temp::(Id::new("indep_corners"))).unwrap_or(false) { + if smooth_ok && ui.data(|d| d.get_temp::(Id::unique("indep_corners"))).unwrap_or(false) { let radii = common(doc, sel, |n| n.corners.radii); // `k` indexes `radii` ([top left, top right, bottom right, bottom left]); the fields sit // where their corners are: top left, top right / bottom left, bottom right. @@ -902,7 +912,7 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { ui, &ids, key, - Id::new(("corner", *k)), + Id::unique(("corner", *k)), Lead::Icon(*icon, name), radii.map(|r| r[*k]), "", @@ -915,7 +925,7 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let sm = common(doc, sel, |n| (n.corners.smoothing * 100.0).round()); widgets::sublabels(ui, tr("Corner smoothing"), None); row(ui, 32.0, |ui| { - let e = widgets::number_field(ui, Id::new("smoothing"), Lead::None, sm, "%", col, 1.0); + let e = widgets::number_field(ui, Id::unique("smoothing"), Lead::None, sm, "%", col, 1.0); app.field(&ids, "cornerSmoothing", e, |v| json!((v / 100.0).clamp(0.0, 1.0))); if widgets::button(ui, tr("iOS"), false, true).on_hover_text(tr("Set corner smoothing to 60%")).clicked() { app.set_props(&ids, json!({"cornerSmoothing": 0.6})); @@ -923,7 +933,7 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { }); } let blend = common(doc, sel, |n| n.blend); - let show_blend = ui.data(|d| d.get_temp::(Id::new("blend_open"))).unwrap_or(false) + let show_blend = ui.data(|d| d.get_temp::(Id::unique("blend_open"))).unwrap_or(false) || blend.is_some_and(|b| !matches!(b, BlendMode::Normal | BlendMode::PassThrough)); if show_blend { widgets::sublabels(ui, tr("Blend mode"), None); @@ -992,7 +1002,7 @@ fn variable_modes(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) let name = widgets::elide(ui, &c.name, name_w); ui.painter().text(pos2(rect.min.x, rect.center().y), Align2::LEFT_CENTER, &name, regular(11.0), t.text); if name != c.name || r.library.is_some() { - ui.interact(rect, Id::new(("var_mode_name", &c.id)), Sense::hover()).on_hover_text(&hover); + ui.interact(rect, Id::unique(("var_mode_name", &c.id)), Sense::hover()).on_hover_text(&hover); } let resp = widgets::dropdown(ui, &label, pick_w, None); menu_popup(&resp).width(180.0).show(|ui| { @@ -1051,13 +1061,13 @@ fn range_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, start: match fills.as_ref().and_then(|f| f.first()) { Some(p) => { widgets::paint_swatch(ui.painter(), sw, p); - if ui.interact(sw.expand(2.0), Id::new("range_swatch"), Sense::click()).clicked() { + if ui.interact(sw.expand(2.0), Id::unique("range_swatch"), Sense::click()).clicked() { app.ui.picker = Some(crate::colorpicker::PickerTarget::TextRange { id, start, end }); } if let Some(c) = p.solid_color() { let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r)); if let Some(h) = - widgets::text_field_inline(&mut child, Id::new("range_hex"), &c.to_hex(), text_r.shrink2(vec2(0.0, 4.0)), regular(11.0)) + widgets::text_field_inline(&mut child, Id::unique("range_hex"), &c.to_hex(), text_r.shrink2(vec2(0.0, 4.0)), regular(11.0)) && let Some(nc) = figcraft_color::Color::from_hex_or_name(&h) { app.run("text.style", json!({"id": id, "start": start, "end": end, "textFills": [nc.to_css_hex()]})); @@ -1068,7 +1078,7 @@ fn range_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, start: } None => { ui.painter().text(pos2(text_r.min.x, r.center().y), Align2::LEFT_CENTER, tr("Mixed"), regular(11.0), t.text_secondary); - if ui.interact(r, Id::new("range_mixed"), Sense::click()).clicked() { + if ui.interact(r, Id::unique("range_mixed"), Sense::click()).clicked() { app.ui.picker = Some(crate::colorpicker::PickerTarget::TextRange { id, start, end }); } } @@ -1080,6 +1090,8 @@ fn range_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, start: }); } +/// Draw a selected-text range fill, or the fill/stroke paint rows with add, style, reorder, +/// stroke align/weight, per-side weights and advanced stroke settings. fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: bool) { if fills && let Some((id, a, b)) = figcraft_engine::textedit::selected_range(&app.session) @@ -1164,7 +1176,7 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: ui, &ids, "strokeWeight", - Id::new("stroke_w"), + Id::unique("stroke_w"), Lead::Icon(Icon::Menu, tr("Stroke weight")), weight, "", @@ -1190,7 +1202,7 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: } }); } - let adv_id = Id::new("stroke_adv"); + let adv_id = Id::unique("stroke_adv"); let open = ui.data(|d| d.get_temp::(adv_id)).unwrap_or(false); if icons::button(ui, Icon::Dots, 24.0, open, tr("Advanced stroke")).clicked() { ui.data_mut(|d| d.insert_temp(adv_id, !open)); @@ -1205,13 +1217,13 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: row(ui, 32.0, |ui| { for (k, (label, field)) in fields.clone().enumerate().skip(first).take(2) { let v = sides.get(k).copied(); - let e = crate::variable_ui::number_field(app, ui, &ids, field, Id::new(("stroke_side", k)), Lead::Text(label), v, "", col); + let e = crate::variable_ui::number_field(app, ui, &ids, field, Id::unique(("stroke_side", k)), Lead::Text(label), v, "", col); app.field(&ids, field, e, |x| json!(x.max(0.0))); } }); } } - if ui.data(|d| d.get_temp::(Id::new("stroke_adv"))).unwrap_or(false) { + if ui.data(|d| d.get_temp::(Id::unique("stroke_adv"))).unwrap_or(false) { stroke_settings(app, ui, doc, sel); } } @@ -1369,16 +1381,16 @@ fn brush_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], brush: Option