From d5c46d6b5a851b5ee04fa3c42e0e7dcb2502498f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 4 Nov 2026 19:47:28 -0500 Subject: [PATCH] Release 0.15.1 --- .github/workflows/release.yml | 1 + apps/figcraft-cli/src/perf.rs | 3 +- .../figcraft-server/src/admin_budget_tests.rs | 8 + apps/figcraft-server/src/store/files.rs | 3 +- contributors/people.toml | 3 + crates/collab/src/change.rs | 11 + crates/collab/src/diff.rs | 45 +++- crates/collab/src/diff_tests.rs | 11 + crates/collab/src/docprops.rs | 152 +++++++++-- crates/collab/src/keyed_list_tests.rs | 132 +++++++++ crates/collab/src/lib.rs | 2 + crates/collab/src/replica.rs | 11 +- crates/collab/src/server.rs | 24 +- crates/collab/src/server_tests.rs | 20 ++ crates/color/src/lib.rs | 42 +-- crates/color/src/tests.rs | 38 +++ crates/doc/src/lib.rs | 21 +- crates/doc/src/proto_expr.rs | 28 +- crates/doc/src/proto_expr_tests.rs | 16 ++ crates/doc/src/tests.rs | 28 ++ crates/doc/src/text.rs | 20 ++ crates/engine/src/assets/components.rs | 4 + crates/engine/src/assets/components/diff.rs | 1 + .../engine/src/assets/components/instances.rs | 1 + crates/engine/src/assets/library.rs | 10 +- crates/engine/src/assets/library_server.rs | 5 +- crates/engine/src/automation/batch.rs | 10 +- crates/engine/src/automation/inspect.rs | 4 + crates/engine/src/canvas/shapebuilder.rs | 4 +- crates/engine/src/canvas/vectoredit.rs | 40 ++- .../engine/src/canvas/vectoredit/pointer.rs | 8 +- crates/engine/src/canvas/vectoredit/tests.rs | 127 +++++++++ crates/engine/src/cmd/comment.rs | 7 +- crates/engine/src/cmd/layout.rs | 5 +- crates/engine/src/cmd/props.rs | 19 +- crates/engine/src/cmd/proto.rs | 22 +- crates/engine/src/cmd/text.rs | 11 + .../engine/src/components_behaviour_tests.rs | 9 + crates/engine/src/content/datafill.rs | 15 +- crates/engine/src/dash_cap_tests.rs | 15 ++ crates/engine/src/dev/codegen.rs | 2 +- crates/engine/src/dev/codegen/css.rs | 5 + crates/engine/src/dev/codegen/html.rs | 38 ++- crates/engine/src/dev/component_codegen.rs | 14 +- crates/engine/src/dev/devcompare.rs | 6 +- crates/engine/src/dev/tailwind.rs | 2 + crates/engine/src/events.rs | 7 +- crates/engine/src/events_tests.rs | 4 + crates/engine/src/extensions/plugins.rs | 10 + crates/engine/src/extensions/widgets.rs | 7 +- crates/engine/src/html_codegen_tests.rs | 27 ++ crates/engine/src/panels_behaviour_tests.rs | 5 + crates/engine/src/plugins_tests.rs | 24 ++ crates/engine/src/selection/rename.rs | 9 + crates/engine/src/shapebuilder_tests.rs | 9 + crates/engine/src/text_behaviour_tests.rs | 66 +++++ crates/format/src/fig.rs | 26 +- crates/format/src/fig/export/layer.rs | 5 +- crates/format/src/fig/export_tests.rs | 24 ++ crates/format/src/fig/kiwi.rs | 34 ++- crates/format/src/fig/tests.rs | 54 ++++ crates/format/src/lib.rs | 5 + crates/format/src/penpot.rs | 92 ++++++- crates/format/src/rest.rs | 43 ++- crates/format/src/rest/tests.rs | 41 +++ crates/format/src/sketch.rs | 5 + crates/format/src/sketch/tests.rs | 7 + crates/format/src/staging_tests.rs | 14 + crates/layout/src/lib.rs | 12 + crates/layout/src/tests.rs | 21 ++ crates/mcp/src/prompts.rs | 46 +++- crates/pathops/src/brush.rs | 4 +- crates/pathops/src/brush_tests.rs | 11 + crates/pdf/src/text.rs | 16 +- crates/render/src/draw.rs | 5 + crates/render/src/gif_tests.rs | 27 ++ crates/render/src/images.rs | 11 +- crates/render/src/mask_bounds_tests.rs | 21 ++ crates/render/src/paint.rs | 4 +- crates/svg/src/import.rs | 27 +- crates/svg/src/import/budget.rs | 15 +- crates/svg/src/import/budget_tests.rs | 28 ++ crates/svg/src/import/paint.rs | 2 +- crates/svg/src/import/structure_tests.rs | 5 +- crates/text/src/fontdb_tests.rs | 5 +- crates/text/src/layout.rs | 251 +++++++++++++++++- crates/ui-egui/locales/en.toml | 6 + crates/ui-egui/src/canvas/board_bar.rs | 7 +- crates/ui-egui/src/dialogs/plugins_ui.rs | 2 +- crates/ui-egui/src/menu_contrast_tests.rs | 108 +++++++- .../ui-egui/src/panels/typography/fields.rs | 12 + crates/ui-egui/src/tabs.rs | 7 +- crates/vectordrawable/src/walk.rs | 46 +++- docs/ui-parity.md | 7 +- 94 files changed, 2022 insertions(+), 205 deletions(-) create mode 100644 crates/collab/src/keyed_list_tests.rs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 68a705399..975d54114 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,3 +48,4 @@ jobs: AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} + LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }} diff --git a/apps/figcraft-cli/src/perf.rs b/apps/figcraft-cli/src/perf.rs index 404724038..30c801207 100644 --- a/apps/figcraft-cli/src/perf.rs +++ b/apps/figcraft-cli/src/perf.rs @@ -498,7 +498,8 @@ fn measure_components(table: &mut Table, col: usize, instances: usize) -> CliRes static RUNS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); let run = RUNS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let dir = std::env::temp_dir().join(format!("figcraft-perf-{}-{run}", std::process::id())); - std::fs::create_dir_all(&dir).map_err(failed)?; + // `create_dir`, not `create_dir_all`: a directory or link already at this predictable name is refused. + std::fs::create_dir(&dir).map_err(failed)?; let path = dir.join("design-system.figcraft").to_string_lossy().into_owned(); let (lib_doc, lib_ds) = design_system_document(&spec).map_err(failed)?; let mut lib_s = Session::with_document(lib_doc); diff --git a/apps/figcraft-server/src/admin_budget_tests.rs b/apps/figcraft-server/src/admin_budget_tests.rs index 71c62897c..10acdabff 100644 --- a/apps/figcraft-server/src/admin_budget_tests.rs +++ b/apps/figcraft-server/src/admin_budget_tests.rs @@ -75,3 +75,11 @@ fn codes_are_found_through_the_index_as_before() { assert_eq!(store.resolve_code(who(105), &codes[2]).unwrap_err(), CODE_GONE); assert_eq!(store.resolve_code(who(106), &codes[3]).unwrap().0, files[3].0); } + +#[test] +fn a_name_taken_from_the_file_is_cleaned_like_a_given_one() { + let dir = TempDir::new("embedded-name"); + let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap(); + let (file, ..) = store.publish("", &snapshot("Bad\u{1b}[2Jname\u{202E}")).unwrap(); + assert_eq!(file.name, "Bad[2Jname"); +} diff --git a/apps/figcraft-server/src/store/files.rs b/apps/figcraft-server/src/store/files.rs index dba351861..fcc0fd109 100644 --- a/apps/figcraft-server/src/store/files.rs +++ b/apps/figcraft-server/src/store/files.rs @@ -95,8 +95,9 @@ impl Store { return Err(format!("the file has more than {} layers", self.cfg.core.max_nodes)); } let mut doc = replica.document().clone(); + // The name the file keeps, whichever one it comes from, is cleaned. let name = match clean_name(name) { - n if n.is_empty() => doc.name.clone(), + n if n.is_empty() => clean_name(&doc.name), n => n, }; doc.name = name.clone(); diff --git a/contributors/people.toml b/contributors/people.toml index db1f5ce59..c846d8725 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -98,3 +98,6 @@ sync_github_name = true [people.dylanmtaylor] sync_github_name = true + +[people.aleksei-suholita] +real_name = "Oleksii Sukholita" diff --git a/crates/collab/src/change.rs b/crates/collab/src/change.rs index bcc09ad86..442376f37 100644 --- a/crates/collab/src/change.rs +++ b/crates/collab/src/change.rs @@ -333,6 +333,17 @@ impl Change { } impl Entry { + /// The synced list this entry belongs to (`None` for entries kept in maps). + pub(crate) fn family(&self) -> Option { + match self { + Entry::Style(_) => Some(Family::Styles), + Entry::Comment(_) => Some(Family::Comments), + Entry::Collection(_) => Some(Family::Collections), + Entry::Variable(_) => Some(Family::Variables), + Entry::DevSnapshot(_) | Entry::Image(_) | Entry::Ballot(_) => None, + } + } + /// The client id space the entry's id lies in ([`figcraft_doc::ids`]): ids made during a live /// session carry their creator's client id, ids from before and ids of other forms are in /// space 0. `None` for entries keyed by something else (dev snapshots by layer, images by diff --git a/crates/collab/src/diff.rs b/crates/collab/src/diff.rs index 016dc5dd0..91e156155 100644 --- a/crates/collab/src/diff.rs +++ b/crates/collab/src/diff.rs @@ -95,7 +95,8 @@ pub fn diff(from: &Replica, to: &Document) -> Result { let mut changes = fields; let new: BTreeSet = added.iter().copied().collect(); // Creations, parents before children. - added.sort_by_cached_key(|id| (depth(to, *id), *id)); + let mut memo = BTreeMap::new(); + added.sort_by_cached_key(|id| (depth(to, *id, &mut memo), *id)); for id in &added { if let Some(n) = to.node(*id) { changes.push(create_change(n, replica.key(*id))?); @@ -121,18 +122,30 @@ pub fn diff(from: &Replica, to: &Document) -> Result { Ok(Diff { changes, replica }) } -/// Depth of `id` in `doc` (pages are 0). -fn depth(doc: &Document, id: NodeId) -> usize { - let mut d = 0; +/// Depth of `id` in `doc` (pages are 0). `memo` holds the depths found so far, so a deep chain of +/// new layers is walked once rather than once per layer. +fn depth(doc: &Document, id: NodeId, memo: &mut BTreeMap) -> usize { + let mut chain = vec![id]; + let mut base = 0; let mut cur = doc.node(id).and_then(|n| n.parent); while let Some(p) = cur { - d += 1; - if d > 100_000 { + if let Some(d) = memo.get(&p) { + base = *d + 1; break; } + // An acyclic chain is no longer than the document: this only stops a cycle. + if chain.len() > doc.node_count() { + break; + } + chain.push(p); cur = doc.node(p).and_then(|n| n.parent); } - d + // `chain` runs from `id` up; the last entry's depth is `base` when it ends at a root or a memo hit. + let top = chain.len(); + for (i, c) in chain.iter().enumerate() { + memo.insert(*c, base + (top - 1 - i)); + } + memo.get(&id).copied().unwrap_or(0) } /// New keys for the children `kids` (in their new order) whose `old` key no longer fits: the @@ -196,3 +209,21 @@ fn increasing_run(keys: &[Option<&str>]) -> Vec { } kept } + +#[cfg(test)] +mod depth_tests { + use super::*; + use figcraft_doc::NodeKind; + + #[test] + fn a_chain_longer_than_a_fixed_bound_is_walked_whole() { + let mut doc = Document::new("File"); + let mut parent = doc.first_page().unwrap(); + let mut last = parent; + for i in 0..100_050 { + last = doc.create(parent, NodeKind::Frame, &format!("F{i}"), 0.0, 0.0, 1.0, 1.0).unwrap(); + parent = last; + } + assert_eq!(depth(&doc, last, &mut BTreeMap::new()), 100_050); + } +} diff --git a/crates/collab/src/diff_tests.rs b/crates/collab/src/diff_tests.rs index 8c6a1211c..bb81d0b1b 100644 --- a/crates/collab/src/diff_tests.rs +++ b/crates/collab/src/diff_tests.rs @@ -310,3 +310,14 @@ fn many_sibling_placements_keep_the_order_of_counting_them() { assert_eq!(r.document().node(p).unwrap().children, kids[&p], "children of {p}"); } } + +#[test] +fn a_chain_of_new_nested_layers_is_created_parents_first() { + let (a, _, frame, _) = setup(); + let mut b = a.document().clone(); + let mut parent = frame; + for i in 0..400 { + parent = b.create(parent, NodeKind::Frame, &format!("F{i}"), 0.0, 0.0, 10.0, 10.0).unwrap(); + } + assert_eq!(round_trip(&a, &b).iter().filter(|c| matches!(c, Change::Create { .. })).count(), 400); +} diff --git a/crates/collab/src/docprops.rs b/crates/collab/src/docprops.rs index e203d2c3b..b590064df 100644 --- a/crates/collab/src/docprops.rs +++ b/crates/collab/src/docprops.rs @@ -54,18 +54,71 @@ struct List { entry: fn(T::Key) -> Entry, get: fn(&Document) -> &Vec, get_mut: fn(&mut Document) -> &mut Vec, + /// The list's slot in an [`Index`]. + index: fn(&mut Index) -> &mut Option>, } -const STYLES: List\n", out.markup, out.tokens.root_rule(), out.styles) + // Document text can reach the stylesheet through any property: `<\/` reads back as `\n{css}\n", out.markup) } /// Markup being built for a layer tree: HTML (`class`), React JSX (`className`), Vue @@ -101,7 +123,7 @@ pub(crate) struct Markup<'c, 'a> { ctx: &'c Ctx<'a>, /// Html, React, Vue or Leptos. lang: CodeLang, - used: Vec, + used: Names, pub markup: String, pub styles: String, pub tokens: Tokens, @@ -158,7 +180,7 @@ impl<'c, 'a> Markup<'c, 'a> { Markup { ctx, lang, - used: Vec::new(), + used: Names::default(), markup: String::new(), styles: String::new(), tokens: Tokens::default(), @@ -273,7 +295,7 @@ impl<'c, 'a> Markup<'c, 'a> { let root = n.id == self.ctx.root; let class = match self.root_class.take().filter(|_| root) { Some(c) => { - self.used.push(c.clone()); + self.used.insert(c.clone()); c } None => { @@ -329,7 +351,7 @@ impl<'c, 'a> Markup<'c, 'a> { let open = open("div"); let slot_ident = slot.map(|p| match self.ctx.comp { Some(c) => crate::component_codegen::slot_name(c, p), - None => class_name(&p.ident, "slot", &mut Vec::new()), + None => class_name(&p.ident, "slot", &mut Names::default()), }); if let Some(p) = slot.filter(|_| kids.is_empty()) { let content = if leptos { diff --git a/crates/engine/src/dev/component_codegen.rs b/crates/engine/src/dev/component_codegen.rs index bf7f442d1..11b380b73 100644 --- a/crates/engine/src/dev/component_codegen.rs +++ b/crates/engine/src/dev/component_codegen.rs @@ -11,7 +11,9 @@ use figcraft_doc::{Document, NodeId, NodeKind, PropDef}; use serde_json::{Value, json}; use crate::cmd::CommandSpec; -use crate::codegen::{CodeLang, Ctx, FLUTTER_IMPORT, Markup, Place, arkui, class_name, compose, flutter, flutter_indent, indent, literal, swift}; +use crate::codegen::{ + CodeLang, Ctx, FLUTTER_IMPORT, Markup, Names, Place, arkui, class_name, compose, flutter, flutter_indent, indent, literal, swift, +}; use crate::components::{prop_default_value, slot_edited, slots_of, variant_options, variant_props, variant_set}; use crate::params::{id_of, string, targets}; use crate::{bad, spec}; @@ -616,14 +618,14 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value /// The `` of a slot parameter: unique among the component's slot parameters, unlike /// `class_name` called with a fresh list per parameter (two idents can lowercase alike). pub(crate) fn slot_name(c: &Component, p: &Param) -> String { - let mut used = Vec::new(); + let mut used = Names::default(); for q in c.params.iter().filter(|q| matches!(q.kind, ParamKind::Slot)) { let name = class_name(&q.ident, "slot", &mut used); if q.ident == p.ident { return name; } } - class_name(&p.ident, "slot", &mut Vec::new()) + class_name(&p.ident, "slot", &mut Names::default()) } /// The values an instance sets for `c`'s parameters, in order (`None` for other layers): variant @@ -720,7 +722,7 @@ pub(crate) fn usage_with_styles(doc: &Document, id: NodeId, lang: CodeLang, dept .filter(|(name, _, _)| is_slot(name)) .map(|(name, expr, _)| { let slot = c.params.iter().find(|q| q.ident == *name); - let name = slot.map(|p| slot_name(&c, p)).unwrap_or_else(|| class_name(name, "slot", &mut Vec::new())); + let name = slot.map(|p| slot_name(&c, p)).unwrap_or_else(|| class_name(name, "slot", &mut Names::default())); format!("{pad} \n") }) .collect(); @@ -823,7 +825,7 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String { /// `data-*` attribute names for variant parameters, unique among them (in order). fn data_attrs(variants: &[&Param]) -> Vec { - let mut used = Vec::new(); + let mut used = Names::default(); variants.iter().map(|p| format!("data-{}", class_name(&p.prop, "prop", &mut used))).collect() } @@ -832,7 +834,7 @@ fn data_attrs(variants: &[&Param]) -> Vec { fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String) { let ctx = Ctx { doc, root: c.comp, comp: Some(c), base: 2 }; let root_name = c.set.and_then(|s| doc.node(s)).or_else(|| doc.node(c.comp)).map(|n| n.name.clone()).unwrap_or_default(); - let root_class = class_name(&root_name, "component", &mut Vec::new()); + let root_class = class_name(&root_name, "component", &mut Names::default()); let variants: Vec<&Param> = c.params.iter().filter(|p| matches!(p.kind, ParamKind::Variant(_))).collect(); let attr_names = data_attrs(&variants); let pass = |root: NodeId| { diff --git a/crates/engine/src/dev/devcompare.rs b/crates/engine/src/dev/devcompare.rs index 6804f1aa4..6d5e39e19 100644 --- a/crates/engine/src/dev/devcompare.rs +++ b/crates/engine/src/dev/devcompare.rs @@ -288,8 +288,10 @@ pub fn layer_changes(before: &Document, now: &Document, id: NodeId) -> Vec Option<(usize, usize)> { let parent = now.node(id)?.parent?; let (pb, pa) = (before.node(parent)?, now.node(parent)?); - let common_b: Vec = pb.children.iter().rev().copied().filter(|c| pa.children.contains(c)).collect(); - let common_a: Vec = pa.children.iter().rev().copied().filter(|c| pb.children.contains(c)).collect(); + let (in_b, in_a): (std::collections::BTreeSet, std::collections::BTreeSet) = + (pb.children.iter().copied().collect(), pa.children.iter().copied().collect()); + let common_b: Vec = pb.children.iter().rev().copied().filter(|c| in_a.contains(c)).collect(); + let common_a: Vec = pa.children.iter().rev().copied().filter(|c| in_b.contains(c)).collect(); let (ib, ia) = (common_b.iter().position(|c| *c == id)?, common_a.iter().position(|c| *c == id)?); (ib != ia).then_some((ib + 1, ia + 1)) } diff --git a/crates/engine/src/dev/tailwind.rs b/crates/engine/src/dev/tailwind.rs index 70a562a9c..8279ef9ce 100644 --- a/crates/engine/src/dev/tailwind.rs +++ b/crates/engine/src/dev/tailwind.rs @@ -310,6 +310,8 @@ fn decl(key: &str, value: &str) -> Vec<(String, String)> { ("text-transform", "uppercase") => kw("uppercase"), ("text-transform", "lowercase") => kw("lowercase"), ("text-transform", "capitalize") => kw("capitalize"), + ("text-wrap", "balance") => kw("text-balance"), + ("text-wrap", "pretty") => kw("text-pretty"), ("text-decoration", "underline") => kw("underline"), ("text-decoration", "line-through") => kw("line-through"), _ => one(format!("[{key}:{}]", arb(value).trim_matches(['[', ']']))), diff --git a/crates/engine/src/events.rs b/crates/engine/src/events.rs index fbc1b9b6c..05433412c 100644 --- a/crates/engine/src/events.rs +++ b/crates/engine/src/events.rs @@ -127,12 +127,15 @@ pub fn topics(v: Option<&Value>) -> Result, String> { for t in list { let Some(s) = t.as_str() else { return Err("`topics` must be an array of strings".into()) }; match s { + // Each once: the list is scanned for every retained event under the log's lock, so + // a long list of repeats would multiply that work. + "comments" | "document" | "all" if out.iter().any(|t| t == s) => {} "comments" | "document" | "all" => out.push(s.to_string()), other => return Err(format!("unknown topic `{other}` (comments, document, all)")), } } - if out.is_empty() { - out.push("all".into()); + if out.is_empty() || out.iter().any(|t| t == "all") { + out = vec!["all".into()]; } Ok(out) } diff --git a/crates/engine/src/events_tests.rs b/crates/engine/src/events_tests.rs index 36b118a79..9cdef52ca 100644 --- a/crates/engine/src/events_tests.rs +++ b/crates/engine/src/events_tests.rs @@ -193,6 +193,10 @@ fn topics_parse_and_reject_unknown_names() { assert_eq!(events::topics(Some(&json!([]))).unwrap(), vec!["all".to_string()]); assert_eq!(events::topics(Some(&json!(["comments", "document"]))).unwrap(), vec!["comments".to_string(), "document".to_string()]); assert!(events::topics(Some(&json!(["nope"]))).is_err()); + // Repeats collapse (the list is scanned per event under the log's lock), and `all` is all. + let many: Vec<&str> = std::iter::repeat_n("comments", 10_000).chain(["document"]).collect(); + assert_eq!(events::topics(Some(&json!(many))).unwrap(), vec!["comments".to_string(), "document".to_string()]); + assert_eq!(events::topics(Some(&json!(["comments", "all", "document"]))).unwrap(), vec!["all".to_string()]); assert!(events::topics(Some(&json!("comments"))).is_err()); } diff --git a/crates/engine/src/extensions/plugins.rs b/crates/engine/src/extensions/plugins.rs index 2b1094306..208efedd2 100644 --- a/crates/engine/src/extensions/plugins.rs +++ b/crates/engine/src/extensions/plugins.rs @@ -149,6 +149,16 @@ fn refusal(m: &Manifest, command: &str, params: &Value) -> Option { if DENIED.iter().any(|p| command.starts_with(p)) && !ALLOWED.contains(&command) { return Some(format!("`{command}` isn't available to plugins")); } + // A preview runs its inner command, which the plugin's permissions and the denied list must cover too. + // An inner command that can't be previewed (a nested preview) is refused by the preview itself, + // before it runs: descending into its parameters first would only rescan them. + if command == "text.preview" + && let Some(inner) = get(params, "command").and_then(Value::as_str) + && crate::textpreview::PREVIEWABLE.contains(&inner) + && let Some(why) = refusal(m, inner, get(params, "params").unwrap_or(&Value::Null)) + { + return Some(why); + } // A widget's own data is FigCraft's to write, so no plugin can forge or retarget a widget. if writes_widget_data(params, 0) { return Some(format!("plugins can't write `{}…` layer data", crate::widgets::DATA_PREFIX)); diff --git a/crates/engine/src/extensions/widgets.rs b/crates/engine/src/extensions/widgets.rs index 6386ceab4..13c6088c4 100644 --- a/crates/engine/src/extensions/widgets.rs +++ b/crates/engine/src/extensions/widgets.rs @@ -157,8 +157,9 @@ fn state_of(doc: &Document, w: NodeId) -> Map { /// fields), each once, in layer order. fn names(doc: &Document, w: NodeId, key: &str) -> Vec { let mut out: Vec = Vec::new(); + let mut seen = std::collections::BTreeSet::new(); for t in doc.subtree(w).into_iter().filter_map(|i| doc.node(i)?.plugin_data.get(key).cloned()) { - if !out.contains(&t) { + if seen.insert(t.clone()) { out.push(t); } } @@ -243,9 +244,7 @@ fn build(s: &mut Session, plugin: &str, w: NodeId, r: &WidgetRender) -> Result<( if !is_widget(n) || n.plugin_data.get(PLUGIN).map(String::as_str) != Some(plugin) { return Err(bad(cmd, "the layer is no longer this plugin's widget")); } - for c in n.children.clone() { - doc.remove(c)?; - } + doc.remove_children(w)?; crate::cmd::props::apply(cmd, doc, w, &props(&r.root, true))?; let n = doc.get_mut(w)?; n.locked = false; diff --git a/crates/engine/src/html_codegen_tests.rs b/crates/engine/src/html_codegen_tests.rs index cd1318ee0..d0de94dba 100644 --- a/crates/engine/src/html_codegen_tests.rs +++ b/crates/engine/src/html_codegen_tests.rs @@ -73,6 +73,20 @@ fn non_ascii_layer_names_transliterate_into_readable_classes() { assert!(code.contains("

") && code.contains("

"), "{code}"); } +#[test] +fn many_layers_with_one_name_get_numbered_in_linear_time() { + // Each name used to retry every suffix from 2 against a list: cubic in their number. + let mut used = crate::codegen::Names::default(); + let start = std::time::Instant::now(); + let names: Vec = (0..20_000).map(|_| crate::codegen::class_name("Card / Title", "frame", &mut used)).collect(); + assert!(start.elapsed() < std::time::Duration::from_secs(5), "{:?}", start.elapsed()); + assert_eq!(names[0], "card-title"); + assert_eq!(names[1], "card-title-2"); + assert_eq!(names[19_999], "card-title-20000"); + let distinct: std::collections::BTreeSet<&String> = names.iter().collect(); + assert_eq!(distinct.len(), names.len()); +} + #[test] fn other_scripts_and_symbol_names_transliterate_or_fall_back_to_the_kind() { let mut s = Session::new(); @@ -163,3 +177,16 @@ fn rem_preserves_text_property_default() { } } } + +#[test] +fn the_stylesheet_never_contains_a_closing_style_tag_from_document_text() { + let mut s = Session::new(); + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + let t = id(s.execute("text.create", &json!({"text": "x", "x": 0, "y": 0})).unwrap()); + s.execute("node.set", &json!({"id": t, "props": {"fontFamily": "a"}})).unwrap(); + s.execute("node.reparent", &json!({"ids": [t], "parent": f})).unwrap(); + let code = code_for(s.doc().unwrap(), f, CodeLang::Html); + let (_, css) = code.split_once("").count(), 1, "{code}"); + assert!(!code.contains("