From 024b7fe45284ce0206b4e732720a7506b7ad53fa Mon Sep 17 00:00:00 2001 From: MortenHusted Date: Tue, 3 Nov 2026 09:53:18 +0100 Subject: [PATCH 01/13] M3: importing a file keeps its own variables and styles when their ids are already taken `file.import` of a FigCraft file kept the document's style, variable or collection for every id both files used, and the pasted layers kept those ids. Ids are numbered per file, so two files made apart share them, and the imported layers were bound to the document's unrelated variables, modes and styles. `append_pages` now gives the incoming file new ids first (`Renames`): - an incoming style, variable or collection is shared, as before, only when the document has the same under its id and everything it names is shared too (the same file imported again); a collection and its variables are shared only together; library copies keep their ids; - every other id the document already uses gets a new one from its allocators, which now take ids to avoid (the incoming file's own); - layers' style links, bindings, explicit modes and prototype actions, styles' bindings, variables' collections, aliases, calculations and extensions, and the renamed collections' mode ids follow. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/ids.rs | 27 +- crates/doc/src/ids_tests.rs | 16 ++ crates/engine/src/cmd/file.rs | 1 + crates/engine/src/cmd/file/import_ids.rs | 254 +++++++++++++++++++ crates/engine/src/cmd/file/open.rs | 13 +- crates/engine/src/import_ids_tests.rs | 172 +++++++++++++ crates/engine/src/lib.rs | 2 + crates/engine/src/library_variables_tests.rs | 28 ++ 8 files changed, 508 insertions(+), 5 deletions(-) create mode 100644 crates/engine/src/cmd/file/import_ids.rs create mode 100644 crates/engine/src/import_ids_tests.rs diff --git a/crates/doc/src/ids.rs b/crates/doc/src/ids.rs index 7e244fbb0..622ca807e 100644 --- a/crates/doc/src/ids.rs +++ b/crates/doc/src/ids.rs @@ -14,6 +14,8 @@ //! space 0. The session server accepts a new object only with an id in its creator's space or //! space 0, so two people never make the same id. +use std::collections::BTreeSet; + use crate::{CollectionId, Document, ModeId, NodeId, StyleId, VariableId}; /// What style ids start with. @@ -74,25 +76,42 @@ impl Document { /// A new style id. pub fn new_style_id(&mut self) -> StyleId { - let taken: Vec = self.styles.iter().filter_map(|s| id_number(&s.id, STYLE_ID_PREFIX)).collect(); + self.new_style_id_avoiding(&BTreeSet::new()) + } + + /// A new style id that is none of `avoid` either: ids of styles about to come in with a file + /// being imported. + pub fn new_style_id_avoiding(&mut self, avoid: &BTreeSet) -> StyleId { + let taken: Vec = self.styles.iter().map(|s| &s.id).chain(avoid).filter_map(|id| id_number(id, STYLE_ID_PREFIX)).collect(); if let Some(n) = self.session_number(taken) { return format!("{STYLE_ID_PREFIX}{n}"); } (self.styles.len() + 1..) .map(|k| format!("{STYLE_ID_PREFIX}{k}")) - .find(|id| self.style(id).is_none()) + .find(|id| self.style(id).is_none() && !avoid.contains(id)) .unwrap_or_else(|| format!("{STYLE_ID_PREFIX}0")) } /// A new variable id. pub fn new_variable_id(&mut self) -> VariableId { - let taken: Vec = self.variables.variables.iter().filter_map(|v| id_number(&v.id, VARIABLE_ID_PREFIX)).collect(); + self.new_variable_id_avoiding(&BTreeSet::new()) + } + + /// A new variable id that is none of `avoid` either (see [`Self::new_style_id_avoiding`]). + pub fn new_variable_id_avoiding(&mut self, avoid: &BTreeSet) -> VariableId { + let taken = self.variables.variables.iter().map(|v| &v.id).chain(avoid).filter_map(|id| id_number(id, VARIABLE_ID_PREFIX)).collect(); self.new_numbered_id(VARIABLE_ID_PREFIX, taken) } /// A new variable collection id. pub fn new_collection_id(&mut self) -> CollectionId { - let taken: Vec = self.variables.collections.iter().filter_map(|c| id_number(&c.id, COLLECTION_ID_PREFIX)).collect(); + self.new_collection_id_avoiding(&BTreeSet::new()) + } + + /// A new variable collection id that is none of `avoid` either (see + /// [`Self::new_style_id_avoiding`]). + pub fn new_collection_id_avoiding(&mut self, avoid: &BTreeSet) -> CollectionId { + let taken = self.variables.collections.iter().map(|c| &c.id).chain(avoid).filter_map(|id| id_number(id, COLLECTION_ID_PREFIX)).collect(); self.new_numbered_id(COLLECTION_ID_PREFIX, taken) } diff --git a/crates/doc/src/ids_tests.rs b/crates/doc/src/ids_tests.rs index a0091623d..e725c2ce4 100644 --- a/crates/doc/src/ids_tests.rs +++ b/crates/doc/src/ids_tests.rs @@ -1,6 +1,8 @@ //! Ids of styles, variables, collections, modes and comments: unchanged outside a live session, //! from the client's own space during one. +use std::collections::BTreeSet; + use crate::ids::{COLLECTION_ID_PREFIX, id_number, id_space, mode_number, space_of}; use crate::{Collection, Comment, Document, Message, Mode, NodeId, NodeKind, Style, StyleKind}; @@ -93,6 +95,20 @@ fn ids_of_the_space_already_in_the_document_stay_taken() { assert_eq!(e.new_style_id(), format!("S:{}", C3 | 1)); } +#[test] +fn new_ids_can_avoid_ids_about_to_come_in() { + let mut d = Document::new("a"); + d.styles.push(style("S:1")); + let avoid: BTreeSet = ["S:2", "S:3", "VariableID:4", "VariableCollectionId:2", "lib:VariableID:9"].map(String::from).into(); + assert_eq!(d.new_style_id_avoiding(&avoid), "S:4"); + assert_eq!(d.new_variable_id_avoiding(&avoid), "VariableID:5"); + assert_eq!(d.new_collection_id_avoiding(&avoid), "VariableCollectionId:3"); + // In a session the numbers come from the client's space, above those of it to avoid. + let avoid: BTreeSet = [format!("VariableID:{}", C3 | 7)].into(); + d.set_client_id(3); + assert_eq!(d.new_variable_id_avoiding(&avoid), format!("VariableID:{}", C3 | 8)); +} + #[test] fn the_highest_space_counts_every_kind_of_id() { let mut d = Document::new("a"); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 0cfdd1ffa..79de7c28c 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -15,6 +15,7 @@ use crate::params::{bool_of, boolean, get, id, num, num_or, req_index, req_strin use crate::{DocState, EngineError, Result, Session, bad, spec}; mod export; +mod import_ids; mod load; mod open; mod save; diff --git a/crates/engine/src/cmd/file/import_ids.rs b/crates/engine/src/cmd/file/import_ids.rs new file mode 100644 index 000000000..2b6a7e697 --- /dev/null +++ b/crates/engine/src/cmd/file/import_ids.rs @@ -0,0 +1,254 @@ +//! Ids a file being imported brings that the document already uses for something else. +//! +//! Style, variable and collection ids are numbered per file (`S:1`, `VariableID:1`, +//! `VariableCollectionId:1`; see [`figcraft_doc::ids`]), so two files made apart use the same ones +//! for unrelated things. Kept as they were, the imported layers would show the document's +//! variables and styles instead of their own. + +use std::collections::{BTreeMap, BTreeSet}; + +use figcraft_doc::ids::MODE_ID_INFIX; +use figcraft_doc::proto::{Action, Expr, MAX_EXPR_DEPTH, walk_mut}; +use figcraft_doc::{Document, ModeId, NodeId, NodeKind, VariableValue, Variables}; + +/// A style, variable or collection of the file being imported. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum Asset<'a> { + Style(&'a str), + Variable(&'a str), + Collection(&'a str), +} + +/// New ids for styles, variables and collections of a file being imported (its id → the new one). +#[derive(Debug, Default)] +pub(super) struct Renames { + styles: BTreeMap, + variables: BTreeMap, + collections: BTreeMap, +} + +impl Renames { + /// The new ids `other`'s styles, variables and collections take when appended to `doc`. + /// + /// One whose id `doc` doesn't use keeps it. One `doc` already has is shared and keeps its id: + /// the same content under the same id, naming only things that are shared too (the same file + /// imported again). A collection and its variables are shared only all together, so variables + /// of one file never join a collection of another. Library copies keep their ids and are shared + /// with the document's copies, as before (`merge_store` records them). Every other id `doc` + /// already uses is replaced by a new one from `doc`'s allocators, clear of `other`'s ids too. + pub(super) fn new(doc: &mut Document, other: &Document) -> Renames { + let (theirs, libs) = (&other.variables, &other.libraries); + // Each with whether `doc` has the same under its id (None: nothing there), and whether + // it's a library copy. + let styles = other.styles.iter().map(|s| { + let here = doc.styles.iter().find(|m| m.id == s.id); + (Asset::Style(&s.id), here.map(|m| m == s), libs.style_import(&s.id).is_some()) + }); + let colls = theirs.collections.iter().map(|c| { + let here = doc.variables.collection_by_id(&c.id); + (Asset::Collection(&c.id), here.map(|m| m == c), libs.collection_import(&c.id).is_some()) + }); + let vars = theirs.variables.iter().map(|v| { + let here = doc.variables.get_by_id(&v.id); + (Asset::Variable(&v.id), here.map(|m| m == v), libs.variable_import(&v.id).is_some()) + }); + // `taken`: ids `doc` uses; `differs`: what `doc` doesn't have the same of. + let (mut taken, mut copies, mut differs) = (BTreeSet::new(), BTreeSet::new(), BTreeSet::new()); + for (a, same, copy) in styles.chain(colls).chain(vars) { + match same { + None => { + differs.insert(a); + } + Some(same) => { + taken.insert(a); + if copy { + copies.insert(a); + } else if !same { + differs.insert(a); + } + } + } + } + // What differs makes what names it differ (library copies aside), and a collection and its + // variables differ together. + let mut named_by: BTreeMap> = BTreeMap::new(); + for v in &theirs.variables { + let (var, coll) = (Asset::Variable(&v.id), Asset::Collection(&v.collection)); + named_by.entry(coll).or_default().push(var); + named_by.entry(var).or_default().push(coll); + for r in v.values.values().flat_map(VariableValue::references).chain(&v.extends) { + named_by.entry(Asset::Variable(r)).or_default().push(var); + } + } + for c in &theirs.collections { + if let Some(e) = &c.extends { + named_by.entry(Asset::Collection(e)).or_default().push(Asset::Collection(&c.id)); + } + } + for s in &other.styles { + for v in s.bound.values() { + named_by.entry(Asset::Variable(v)).or_default().push(Asset::Style(&s.id)); + } + } + let mut todo: Vec = differs.iter().copied().collect(); + while let Some(a) = todo.pop() { + for &n in named_by.get(&a).into_iter().flatten() { + if !copies.contains(&n) && differs.insert(n) { + todo.push(n); + } + } + } + let renamed = |a: Asset| taken.contains(&a) && differs.contains(&a); + Renames { + collections: fresh( + theirs.collections.iter().map(|c| &c.id), + |id| renamed(Asset::Collection(id)), + |avoid| doc.new_collection_id_avoiding(avoid), + ), + variables: fresh(theirs.variables.iter().map(|v| &v.id), |id| renamed(Asset::Variable(id)), |avoid| doc.new_variable_id_avoiding(avoid)), + styles: fresh(other.styles.iter().map(|s| &s.id), |id| renamed(Asset::Style(id)), |avoid| doc.new_style_id_avoiding(avoid)), + } + } + + pub(super) fn is_empty(&self) -> bool { + self.styles.is_empty() && self.variables.is_empty() && self.collections.is_empty() + } + + /// `other` with the renamed styles, variables and collections under their new ids, and what + /// names them following: layers' style links (text ranges too), bindings, explicit modes and + /// prototype actions; styles' bindings; variables' collections, aliases and calculations; what + /// extended collections and their variables extend; and the ids of the renamed collections' + /// modes, which name their collection (`/mode`). + pub(super) fn applied(&self, other: &Document) -> Document { + let theirs = &other.variables; + let mut doc = other.clone(); + let (styles, vars, colls) = (&self.styles, &self.variables, &self.collections); + for s in &mut doc.styles { + rename(styles, &mut s.id); + s.bound.values_mut().for_each(|v| rename(vars, v)); + } + for c in &mut doc.variables.collections { + for m in &mut c.modes { + self.mode(theirs, &c.id, &mut m.id); + } + for id in std::iter::once(&mut c.id).chain(&mut c.extends) { + rename(colls, id); + } + } + for v in &mut doc.variables.variables { + let values = std::mem::take(&mut v.values).into_iter().map(|(mut mode, mut val)| { + self.mode(theirs, &v.collection, &mut mode); + val.references_mut().into_iter().for_each(|r| rename(vars, r)); + (mode, val) + }); + v.values = values.collect(); + let overrides = std::mem::take(&mut v.overrides).into_iter().map(|mut mode| { + self.mode(theirs, &v.collection, &mut mode); + mode + }); + v.overrides = overrides.collect(); + rename(colls, &mut v.collection); + for id in std::iter::once(&mut v.id).chain(&mut v.extends) { + rename(vars, id); + } + } + let ids: Vec = doc.ids().collect(); + for id in ids { + let Some(n) = doc.node_mut(id) else { continue }; + let links = [&mut n.styles.fill, &mut n.styles.stroke, &mut n.styles.effect, &mut n.styles.grid, &mut n.styles.text]; + for s in links.into_iter().flatten() { + rename(styles, s); + } + if let NodeKind::Text(t) = &mut n.kind { + t.map_text_styles(|s| Some(styles.get(s).cloned().unwrap_or_else(|| s.to_string()))); + } + n.bound.values_mut().for_each(|v| rename(vars, v)); + n.var_modes = std::mem::take(&mut n.var_modes) + .into_iter() + .map(|(mut c, mut mode)| { + self.mode(theirs, &c, &mut mode); + rename(colls, &mut c); + (c, mode) + }) + .collect(); + for it in &mut n.interactions { + walk_mut(&mut it.actions, &mut |a| self.action(theirs, a)); + } + } + doc + } + + /// Rename mode `mode` of collection `of` (`theirs`: the imported file's variables as they were). + /// A mode id names the collection it was made in: `of`, or one `of` extends (an extended + /// collection has the modes of the one it extends). Library copies keep their library's mode + /// ids, which may name one of the file's own collections. + fn mode(&self, theirs: &Variables, of: &str, mode: &mut ModeId) { + let mut at = Some(of); + for _ in 0..=theirs.collections.len() { + let Some(c) = at else { return }; + if let Some(k) = mode.strip_prefix(c).and_then(|rest| rest.strip_prefix(MODE_ID_INFIX)) { + if let Some(new) = self.collections.get(c) { + *mode = format!("{new}{MODE_ID_INFIX}{k}"); + } + return; + } + at = theirs.collection_by_id(c).and_then(|c| c.extends.as_deref()); + } + } + + /// Rename what one prototype action names (not the actions nested in it). + fn action(&self, theirs: &Variables, a: &mut Action) { + match a { + Action::SetVariable { variable, expr, .. } => { + rename(&self.variables, variable); + if let Some(e) = expr { + self.expr(theirs, e, 0); + } + } + Action::SetVariableMode { collection, mode } => { + self.mode(theirs, collection, mode); + rename(&self.collections, collection); + } + Action::Conditional { blocks } => blocks.iter_mut().filter_map(|b| b.condition.as_mut()).for_each(|c| self.expr(theirs, c, 0)), + _ => {} + } + } + + fn expr(&self, theirs: &Variables, e: &mut Expr, depth: usize) { + match e { + Expr::Value(v) => v.references_mut().into_iter().for_each(|r| rename(&self.variables, r)), + Expr::Op { args, .. } if depth < MAX_EXPR_DEPTH => args.iter_mut().for_each(|a| self.expr(theirs, a, depth + 1)), + Expr::Op { .. } => {} + Expr::Mode { collection } => rename(&self.collections, collection), + Expr::InMode { variable, mode } => { + if let Some(v) = theirs.get_by_id(variable) { + self.mode(theirs, &v.collection, mode); + } + rename(&self.variables, variable); + } + } + } +} + +/// New ids, from `new_id`, for those of `ids` that `pick` picks; each clear of all of `ids`. +fn fresh<'a>( + ids: impl Iterator + Clone, + pick: impl Fn(&str) -> bool, + mut new_id: impl FnMut(&BTreeSet) -> String, +) -> BTreeMap { + let mut avoid: BTreeSet = ids.clone().cloned().collect(); + let mut out = BTreeMap::new(); + for id in ids.filter(|id| pick(id)) { + let new = new_id(&avoid); + avoid.insert(new.clone()); + out.insert(id.clone(), new); + } + out +} + +/// `id` as `map` renames it. +fn rename(map: &BTreeMap, id: &mut String) { + if let Some(new) = map.get(id.as_str()) { + *id = new.clone(); + } +} diff --git a/crates/engine/src/cmd/file/open.rs b/crates/engine/src/cmd/file/open.rs index 59afad98d..795584595 100644 --- a/crates/engine/src/cmd/file/open.rs +++ b/crates/engine/src/cmd/file/open.rs @@ -1,3 +1,4 @@ +use super::import_ids::Renames; use super::load::{ext, file_stem, import_summary, load_path, load_with, read, rest_images, sibling_variables, variables_param}; use super::*; @@ -218,8 +219,18 @@ pub(super) fn import(s: &mut Session, p: &Value) -> Result { /// Append `other`'s pages (with their settings and flows, its images, styles and variables) to /// `doc`. Ids are remapped across all pages at once, so instances keep their main, instance layers /// their source and interactions and flows their targets wherever those are. Styles, variables and -/// collections whose id the document already has are kept as they are. +/// collections the document already has (the same under the same id, as when a file comes in +/// again) and library copies are shared; others whose id the document uses for something else get +/// new ids, and what comes in names them by those ([`Renames`]). pub(super) fn append_pages(doc: &mut Document, other: &Document) -> Result> { + let renames = Renames::new(doc, other); + let renamed; + let other = if renames.is_empty() { + other + } else { + renamed = renames.applied(other); + &renamed + }; for (k, v) in &other.images { doc.images.entry(k.clone()).or_insert_with(|| v.clone()); } diff --git a/crates/engine/src/import_ids_tests.rs b/crates/engine/src/import_ids_tests.rs new file mode 100644 index 000000000..263aa8169 --- /dev/null +++ b/crates/engine/src/import_ids_tests.rs @@ -0,0 +1,172 @@ +//! `file.import` of a file whose style, variable and collection ids the document already uses for +//! other things: ids are numbered per file, so two files made apart share them. The imported +//! layers keep their own variables, modes and styles, and a file imported again still shares what +//! it brought the first time. + +use base64::Engine as _; +use figcraft_doc::proto::Action; +use figcraft_doc::{Document, Node, NodeId, VariableValue}; +use serde_json::{Value, json}; + +use crate::Session; +use crate::components::tests::ex; +use crate::params::id_of; + +fn idv(v: &Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +fn var_id(s: &Session, name: &str) -> String { + s.doc().unwrap().variables.get(name).unwrap().id.clone() +} + +fn fill(s: &Session, id: NodeId) -> String { + s.doc().unwrap().node(id).unwrap().fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default() +} + +/// The document's file: collection A (Light, Dark) with colours `a1`…`a4`, paint style `Host` +/// and a frame filled with `a1`. +fn host() -> (Session, NodeId) { + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "A", "modes": ["Light", "Dark"]})); + for i in 1..=4 { + ex(&mut s, "variable.create", json!({"collection": "A", "name": format!("a{i}"), "type": "color", "value": format!("#A{i}0000")})); + } + ex(&mut s, "style.create", json!({"type": "paint", "name": "Host", "value": "#FF0000"})); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 50, "name": "Host frame"}))); + ex(&mut s, "variable.bind", json!({"ids": [f], "field": "fill", "variable": "a1"})); + (s, f) +} + +/// Another file made apart: collection B (Light, Dark) with `b1` (green in each mode), `b2` (an +/// alias of `b1`) and `size`; paint style `Brand` bound to `b1`. Frame `Card` shows B in Dark, its +/// corners bound to `size`; in it `Swatch` is filled with `b2` and on click sets B to Dark and +/// `size` to `size + 1`, and `Styled` uses `Brand`. +fn other() -> Session { + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "B", "modes": ["Light", "Dark"]})); + ex(&mut s, "variable.create", json!({"collection": "B", "name": "b1", "type": "color", "values": {"Light": "#00B000", "Dark": "#004000"}})); + ex(&mut s, "variable.create", json!({"collection": "B", "name": "b2", "type": "color", "value": {"alias": "b1"}})); + ex(&mut s, "variable.create", json!({"collection": "B", "name": "size", "type": "number", "value": 8})); + let brand = ex(&mut s, "style.create", json!({"type": "paint", "name": "Brand", "value": "#00B000"}))["id"].clone(); + ex(&mut s, "style.bind", json!({"style": brand, "field": "fill", "variable": "b1"})); + let card = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"}))); + ex(&mut s, "variable.setMode", json!({"ids": [card], "collection": "B", "mode": "Dark"})); + ex(&mut s, "variable.bind", json!({"ids": [card], "field": "cornerRadius", "variable": "size"})); + let swatch = idv(&ex(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40, "parent": card, "name": "Swatch"}))); + ex(&mut s, "variable.bind", json!({"ids": [swatch], "field": "fill", "variable": "b2"})); + let actions = json!([ + {"type": "setVariableMode", "collection": "B", "mode": "Dark"}, + {"type": "setVariable", "variable": "size", "expression": "size + 1"} + ]); + ex(&mut s, "proto.interaction.add", json!({"id": swatch, "actions": actions})); + let styled = idv(&ex(&mut s, "shape.rectangle", json!({"x": 60, "y": 10, "width": 40, "height": 40, "parent": card, "name": "Styled"}))); + ex(&mut s, "style.apply", json!({"ids": [styled], "style": "Brand"})); + s +} + +fn import(s: &mut Session, file: &Session) -> Value { + let bytes = figcraft_format::save_bytes(file.doc().unwrap(), None).unwrap(); + let data = base64::engine::general_purpose::STANDARD.encode(bytes); + ex(s, "file.import", json!({"data": data, "name": "other.figcraft"})) +} + +/// The layers on the pages after the first `kept` pages. +fn layers_after(doc: &Document, kept: usize) -> Vec<&Node> { + doc.pages[kept..].iter().flat_map(|p| doc.subtree(*p).into_iter().skip(1)).filter_map(|id| doc.node(id)).collect() +} + +fn layer<'a>(doc: &'a Document, kept: usize, name: &str) -> &'a Node { + layers_after(doc, kept).into_iter().find(|n| n.name == name).unwrap() +} + +#[test] +fn imported_layers_keep_their_own_variables_and_styles_when_the_ids_are_taken() { + let (mut s, frame) = host(); + let file = other(); + assert_eq!(var_id(&file, "b1"), var_id(&s, "a1"), "the ids must collide for this test"); + assert_eq!(file.doc().unwrap().styles[0].id, s.doc().unwrap().styles[0].id); + let before = s.doc().unwrap().clone(); + import(&mut s, &file); + let doc = s.doc().unwrap(); + assert_eq!(doc.variables.variables.len(), 4 + 3); + assert_eq!(doc.variables.collections.iter().map(|c| c.name.as_str()).collect::>(), ["A", "B"]); + assert_eq!(doc.styles.iter().map(|st| st.name.as_str()).collect::>(), ["Host", "Brand"]); + // The document's own are as they were. + assert_eq!(doc.variables.variables[..4], before.variables.variables[..]); + assert_eq!(doc.variables.collections[0], before.variables.collections[0]); + assert_eq!(doc.styles[0], before.styles[0]); + // Everything imported names B's variables, B and Brand. + let b = doc.variables.collection("B").unwrap().id.clone(); + let imported = layers_after(doc, before.pages.len()); + assert!(imported.iter().any(|n| !n.bound.is_empty())); + for n in imported { + for v in n.bound.values() { + assert_eq!(doc.variables.get_by_id(v).unwrap().collection, b, "{}: {v}", n.name); + } + assert!(n.var_modes.keys().all(|c| *c == b), "{}: {:?}", n.name, n.var_modes); + if let Some(st) = &n.styles.fill { + assert_eq!(doc.styles.iter().find(|x| x.id == *st).unwrap().name, "Brand"); + } + } + let brand = doc.styles.iter().find(|st| st.name == "Brand").unwrap(); + assert_eq!(brand.bound.values().map(|v| doc.variables.get_by_id(v).unwrap().name.as_str()).collect::>(), ["b1"]); + // Bindings applied again leave the imported layers alone. + let swatch = layer(doc, before.pages.len(), "Swatch").id; + let a2 = var_id(&s, "a2"); + ex(&mut s, "variable.set", json!({"variable": a2, "value": "#123456"})); + assert_eq!(fill(&s, swatch), "004000"); + assert_eq!(fill(&s, frame), "A10000"); + assert!(s.doc().unwrap().validate().is_empty()); +} + +#[test] +fn an_alias_a_frame_mode_and_prototype_actions_follow_the_renamed_collection() { + let (mut s, _) = host(); + let file = other(); + let kept = s.doc().unwrap().pages.len(); + import(&mut s, &file); + let doc = s.doc().unwrap(); + let vars = &doc.variables; + let b = vars.collection("B").unwrap(); + let in_b = |name: &str| vars.variables.iter().find(|v| v.name == name && v.collection == b.id).unwrap(); + let (b1, b2, size) = (in_b("b1"), in_b("b2"), in_b("size")); + assert!(b2.values.values().all(|v| *v == VariableValue::Alias(b1.id.clone())), "{:?}", b2.values); + // B's mode ids name B's new id, and its variables' values are by those. + let modes: Vec<&str> = b.modes.iter().map(|m| m.id.as_str()).collect(); + assert_eq!(modes, [format!("{}/mode1", b.id), format!("{}/mode2", b.id)]); + assert!([b1, b2, size].iter().all(|v| v.values.keys().all(|m| modes.contains(&m.as_str())))); + // The frame shows B in Dark: its mode is one of B's. + let card = layer(doc, kept, "Card"); + let dark = b.find_mode("Dark").unwrap().id.clone(); + assert_eq!(card.var_modes.iter().collect::>(), [(&b.id, &dark)]); + assert_eq!(card.bound.get("cornerRadius"), Some(&size.id)); + let swatch = layer(doc, kept, "Swatch"); + assert_eq!(swatch.bound.get("fill"), Some(&b2.id)); + let shown = vars.resolve(&b2.id, &doc.modes_for(swatch.id)); + assert!(matches!(shown, Some(VariableValue::Color(c)) if c.to_hex() == "004000"), "{shown:?}"); + let actions = &swatch.interactions[0].actions; + assert_eq!(actions[0], Action::SetVariableMode { collection: b.id.clone(), mode: dark }); + let Action::SetVariable { variable, expr: Some(e), .. } = &actions[1] else { panic!("{actions:?}") }; + assert_eq!((variable, e.variables()), (&size.id, vec![size.id.as_str()])); +} + +#[test] +fn importing_the_same_file_again_shares_what_it_brought() { + let file = other(); + let mut s = other(); + let before = s.doc().unwrap().clone(); + import(&mut s, &file); + let doc = s.doc().unwrap(); + assert_eq!(doc.variables, before.variables); + assert_eq!(doc.styles, before.styles); + let swatch = layer(doc, before.pages.len(), "Swatch"); + assert_eq!(swatch.bound.get("fill"), Some(&var_id(&s, "b2"))); + // A file imported into an empty one, then again: one copy of its variables. + let mut t = Session::new(); + import(&mut t, &file); + import(&mut t, &file); + let doc = t.doc().unwrap(); + assert_eq!((doc.variables.variables.len(), doc.variables.collections.len(), doc.styles.len()), (3, 1, 1)); + assert!(layers_after(doc, 1).iter().flat_map(|n| n.bound.values()).all(|v| doc.variables.get_by_id(v).is_some())); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e919444e2..0dee66b47 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -367,6 +367,8 @@ mod images_left_in_file_tests; #[cfg(test)] mod import_export_tests; #[cfg(test)] +mod import_ids_tests; +#[cfg(test)] mod import_journal_tests; #[cfg(test)] mod inspect_behaviour_tests; diff --git a/crates/engine/src/library_variables_tests.rs b/crates/engine/src/library_variables_tests.rs index 5a6b329d3..bc82f7a94 100644 --- a/crates/engine/src/library_variables_tests.rs +++ b/crates/engine/src/library_variables_tests.rs @@ -385,6 +385,34 @@ fn library_components_keep_their_variable_bindings() { let _ = std::fs::remove_dir_all(&d); } +#[test] +fn a_file_bringing_older_copies_of_the_same_library_variables_shares_them() { + let d = dir("older-copies"); + let lib_path = path_str(&d.join("kit.figcraft")); + let mut lib = Session::new(); + theme(&mut lib); + let lid = publish_to(&mut lib, &lib_path); + let mut s = Session::new(); + ex(&mut s, "library.add", json!({"path": lib_path})); + let r = idv(&ex(&mut s, "shape.rectangle", json!({"width": 10, "height": 10, "name": "Swatch"}))); + ex(&mut s, "library.bindVariable", json!({"library": lid, "variable": "primary", "field": "fill", "ids": [r]})); + let old_path = path_str(&d.join("old.figcraft")); + ex(&mut s, "file.saveAs", json!({"path": old_path})); + // The copies take an update; the saved file keeps the older ones under the same ids. + ex(&mut lib, "variable.set", json!({"variable": "primary", "mode": "Light", "value": "#444444"})); + republish(&mut lib); + ex(&mut s, "library.acceptUpdates", json!({})); + let before = s.doc().unwrap().clone(); + ex(&mut s, "file.import", json!({"path": old_path})); + let doc = s.doc().unwrap(); + assert_eq!((&doc.variables, &doc.libraries.variables), (&before.variables, &before.libraries.variables), "shared by id, as before"); + let swatches: Vec<&Node> = doc.nodes().filter(|n| n.name == "Swatch").collect(); + assert_eq!(swatches.len(), 2); + assert!(swatches.iter().all(|n| n.bound.get("fill") == Some(©_of(&s, "primary").id))); + healthy(&s); + let _ = std::fs::remove_dir_all(&d); +} + #[test] fn library_variable_commands_fail_cleanly() { let mut s = Session::new(); From de881846a88555a35ea866e5a89e8ec063fe1359 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Wed, 4 Nov 2026 04:35:37 -0500 Subject: [PATCH 02/13] M8: joining the ends of a path broken open closes its fill loop through the new segment (#1951) Cause: deleting a point with Delete (not Shift+Delete) opens the path and leaves its region's loop with a gap, filled closed by a straight line across it; joining the two ends again (the pen onto the other end, Connect, Join) added the segment to the network but not to the loop, which still spanned the gap. Unrounded, the straight line and the segment coincide; once the points are rounded, the fill and the selection outline run straight across while the stroke, walked from the network itself, follows the segment and its arcs (#1506's picture). Fix: `ops::connect` splices the new segment into the loop whose segments are the chain between the two ends, so the loop closes through it; a chain no loop fills still becomes a region of its own. Tests: the chamfer made by Shift+Delete, by Delete then Connect, and drawn with the pen, each rounded, with the fill tracing the stroke (engine); every loop order and segment direction of a chamfered rectangle (doc). Co-Authored-By: Claude Fable 5.1 --- crates/doc/src/tests.rs | 55 +++++++++++++ crates/engine/src/canvas/vectoredit/ops.rs | 12 ++- crates/engine/src/canvas/vectoredit/tests.rs | 82 ++++++++++++++++++++ docs/target-app-parity.md | 6 +- 4 files changed, 151 insertions(+), 4 deletions(-) diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 6ac6f455b..3a84a4c34 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -681,6 +681,61 @@ fn two_segment_closed_loop_rounded_corners() { assert!(!path.elements().is_empty()); } +/// A rectangle whose top-right corner was cut off in the vector editor (a diagonal between a point +/// on the top edge and one on the right edge), then rounded: the fill, which the fill and the +/// selection outline draw, is cut by an arc at every point just as the stroke is, however the loop +/// lists its segments and whichever way each one runs (#1951, #1506). +#[test] +fn a_chamfered_rectangles_rounded_fill_is_cut_at_every_point() { + use figcraft_geom::BezPath; + use figcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest}; + let corners = [Point::new(0.0, 0.0), Point::new(50.0, 0.0), Point::new(100.0, 50.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)]; + let keeps_off = |path: &BezPath, p: Point| path.segments().map(|s| s.nearest(p, 1e-9).distance_sq.sqrt()).fold(f64::INFINITY, f64::min); + let length = |path: &BezPath| path.segments().map(|s| s.arclen(1e-9)).sum::(); + for first in 0..corners.len() { + for backwards in [false, true] { + for flipped in 0..(1u32 << corners.len()) { + let mut net = VectorNetwork::from_points(&corners, true); + for v in &mut net.vertices { + v.radius = 43.0; + } + for (i, s) in net.segments.iter_mut().enumerate() { + if flipped & (1 << i) != 0 { + std::mem::swap(&mut s.a, &mut s.b); + std::mem::swap(&mut s.ta, &mut s.tb); + } + } + let lp = &mut net.regions[0].loops[0]; + lp.rotate_left(first); + if backwards { + lp.reverse(); + } + let why = format!("loop from segment {first}{}, segments flipped {flipped:05b}", if backwards { " backwards" } else { "" }); + let stroke = net.stroke_path(); + let fill = net.fill_path(); + let region = net.rounded().unwrap().region_path(0).unwrap(); + for (i, p) in corners.iter().enumerate() { + assert!(keeps_off(&stroke, *p) > 2.0, "{why}: the stroke is cut at point {i} ({:.2} from it)", keeps_off(&stroke, *p)); + assert!(keeps_off(&fill, *p) > 2.0, "{why}: the fill runs into point {i} ({:.2} from it)", keeps_off(&fill, *p)); + assert!(keeps_off(®ion, *p) > 2.0, "{why}: the region runs into point {i} ({:.2} from it)", keeps_off(®ion, *p)); + } + assert!( + (length(&fill) - length(&stroke)).abs() < 1e-6, + "{why}: fill {} and stroke {} trace the same outline", + length(&fill), + length(&stroke) + ); + assert!( + (length(®ion) - length(&stroke)).abs() < 1e-6, + "{why}: region {} and stroke {} trace the same outline", + length(®ion), + length(&stroke) + ); + } + } + } +} + #[test] fn stored_variable_ids_resolve_only_their_exact_collection() { let mut vars = crate::proto_expr_tests::vars(); diff --git a/crates/engine/src/canvas/vectoredit/ops.rs b/crates/engine/src/canvas/vectoredit/ops.rs index b44ee941d..993e45066 100644 --- a/crates/engine/src/canvas/vectoredit/ops.rs +++ b/crates/engine/src/canvas/vectoredit/ops.rs @@ -658,8 +658,16 @@ pub fn connect(net: &mut VectorNetwork, a: usize, b: usize, ta: (f64, f64), tb: net.segments.push(Segment { a, b, ta: finite_t(ta), tb: finite_t(tb) }); let si = net.segments.len() - 1; if ends && let Some(mut lp) = path_between(net, b, a, si) { - let unfilled = !net.regions.iter().any(|r| r.loops.iter().flatten().any(|s| lp.contains(s))); - if unfilled { + let filled = net.regions.iter().any(|r| r.loops.iter().flatten().any(|s| lp.contains(s))); + if filled { + // A loop broken open at these ends (a point of it deleted, say) closes through the new + // segment, so the fill follows it instead of a straight line across the gap (#1951). + for l in net.regions.iter_mut().flat_map(|r| r.loops.iter_mut()) { + if l.len() == lp.len() && l.iter().all(|s| lp.contains(s)) { + *l = std::iter::once(si).chain(lp.iter().copied()).collect(); + } + } + } else { lp.insert(0, si); net.regions.push(Region { loops: vec![lp], winding: WindingRule::NonZero, fills: None, ..Default::default() }); } diff --git a/crates/engine/src/canvas/vectoredit/tests.rs b/crates/engine/src/canvas/vectoredit/tests.rs index 30d62e1af..3812c1b7b 100644 --- a/crates/engine/src/canvas/vectoredit/tests.rs +++ b/crates/engine/src/canvas/vectoredit/tests.rs @@ -597,3 +597,85 @@ fn the_pen_shows_its_ghost_anchor_on_the_segment_under_it() { assert_eq!(net(&s, id).vertices.len(), 5, "the press added the point"); assert_eq!(ve(&s).anchor, None, "the click ended the pen's path"); } + +/// A rectangle's corner cut off in the editor (a point added on each edge next to it, the corner +/// deleted with Shift+Delete so the two join) and the shape rounded: the fill, which the fill and +/// the selection outline draw, traces the stroke's outline, cut at both ends of the diagonal (#1951). +#[test] +fn a_rounded_chamfer_cut_in_the_editor_fills_along_its_arcs() { + for top_first in [true, false] { + let mut s = Session::new(); + let id = rect(&mut s); + // Splitting keeps the split edge's index, so the other edge's stays: top 0, right 1. + let (first, second) = if top_first { (0, 1) } else { (1, 0) }; + ex(&mut s, "vector.edit.addVertex", json!({"segment": first, "t": 0.5})); + ex(&mut s, "vector.edit.addVertex", json!({"segment": second, "t": 0.5})); + let corner = vertex_at(&net(&s, id), 100.0, 0.0); + ex(&mut s, "vector.edit.select", json!({"vertices": [corner]})); + ex(&mut s, "vector.deleteHeal", json!({})); + let n = net(&s, id); + assert_eq!((n.vertices.len(), n.segments.len(), n.regions.len()), (5, 5, 1), "top first {top_first}: a pentagon"); + ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 43, "vertices": [0, 1, 2, 3, 4]})); + fill_traces_stroke(&s, id, &format!("top first {top_first}")); + // A region with a fill of its own is drawn through the region path and the planar normaliser. + if let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(id).unwrap().kind { + network.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]); + } + fill_traces_stroke(&s, id, &format!("top first {top_first}, region fill")); + } +} + +/// The chamfer made other ways: the corner deleted plainly, which opens the path between the two +/// new points, and the two connected again; or the pentagon drawn with the pen (#1951). +#[test] +fn a_rounded_chamfer_made_other_ways_fills_along_its_arcs() { + let mut s = Session::new(); + let id = rect(&mut s); + ex(&mut s, "vector.edit.addVertex", json!({"segment": 0, "t": 0.5})); + ex(&mut s, "vector.edit.addVertex", json!({"segment": 1, "t": 0.5})); + let corner = vertex_at(&net(&s, id), 100.0, 0.0); + ex(&mut s, "vector.edit.select", json!({"vertices": [corner]})); + ex(&mut s, "vector.edit.delete", json!({})); + let n = net(&s, id); + assert_eq!((n.segments.len(), n.regions[0].loops[0].len()), (4, 4), "the path opened: {:?}", n.regions); + let (top, right) = (vertex_at(&n, 50.0, 0.0), vertex_at(&n, 100.0, 50.0)); + ex(&mut s, "vector.edit.connect", json!({"a": top, "b": right})); + assert_eq!(net(&s, id).segments.len(), 5, "connected again"); + ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 43, "vertices": [0, 1, 2, 3, 4]})); + fill_traces_stroke(&s, id, "deleted and connected"); + // Drawn with the pen: continue from the end of an open two-point path and close on its start. + let mut s = Session::new(); + let r = ex(&mut s, "vector.create", json!({"points": [[0, 0], [50, 0]], "closed": false})); + let id = crate::params::id_of(&r["id"]).unwrap(); + ex(&mut s, "vector.edit.begin", json!({"id": id})); + ex(&mut s, "vector.edit.tool", json!({"tool": "pen"})); + click(&mut s, 50.0, 0.0); + for (x, y) in [(100.0, 50.0), (100.0, 100.0), (0.0, 100.0), (0.0, 0.0)] { + click(&mut s, x, y); + } + let n = net(&s, id); + assert_eq!((n.vertices.len(), n.segments.len(), n.regions.len()), (5, 5, 1), "closed by the pen"); + ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 43, "vertices": [0, 1, 2, 3, 4]})); + fill_traces_stroke(&s, id, "drawn with the pen"); +} + +/// The rounded chamfered rectangle `id` (five points on the origin's 100×100 box, the top-right +/// corner cut off between (50, 0) and (100, 50)): one region with every segment in its loop, and a +/// fill that keeps off every sharp point and traces the same outline as the stroke. +fn fill_traces_stroke(s: &Session, id: NodeId, why: &str) { + use figcraft_geom::BezPath; + use figcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest}; + let n = net(s, id); + assert_eq!(n.regions.len(), 1, "{why}: one region, {:?}", n.regions); + assert_eq!(n.regions[0].loops[0].len(), n.segments.len(), "{why}: every segment in the loop, {:?}", n.regions[0].loops); + let doc = s.doc().unwrap(); + let (fill, _) = figcraft_render::node_outline(doc, doc.node(id).unwrap()).unwrap(); + let stroke = n.stroke_path(); + let keeps_off = |path: &BezPath, p: Point| path.segments().map(|s| s.nearest(p, 1e-9).distance_sq.sqrt()).fold(f64::INFINITY, f64::min); + let length = |path: &BezPath| path.segments().map(|s| s.arclen(1e-9)).sum::(); + for p in [pt(50.0, 0.0), pt(100.0, 50.0), pt(0.0, 0.0), pt(100.0, 100.0), pt(0.0, 100.0)] { + assert!(keeps_off(&stroke, p) > 2.0, "{why}: the stroke is cut at {p:?} ({:.2} from it)", keeps_off(&stroke, p)); + assert!(keeps_off(&fill, p) > 2.0, "{why}: the fill runs into {p:?} ({:.2} from it)", keeps_off(&fill, p)); + } + assert!((length(&fill) - length(&stroke)).abs() < 1e-3, "{why}: fill {} and stroke {} trace the same outline", length(&fill), length(&stroke)); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 75a426231..2093798ed 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (bulleted lists use the same bullet at every level of indentation, as the help center says, instead of a white bullet and a square at the deeper levels; typing `1)` and a space at the start of a paragraph starts a numbered list, as `1.` does, both being the help center's creation characters; a link on text is underlined by default, as the help center says, and Underline takes the underline off while the link stays; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Thai, Lao, Khmer and Burmese lines break between words (#1219); MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719)) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** patch (joining the two ends of a path broken open by a deleted point closes it again, so its fill and selection outline follow the new segment and its corner radius instead of a straight line across the gap (#1951); bulleted lists use the same bullet at every level of indentation, as the help center says, instead of a white bullet and a square at the deeper levels; typing `1)` and a space at the start of a paragraph starts a numbered list, as `1.` does, both being the help center's creation characters; a link on text is underlined by default, as the help center says, and Underline takes the underline off while the link stays; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Thai, Lao, Khmer and Burmese lines break between words (#1219); MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719)) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -245,7 +245,8 @@ Hours in this narrative are the builders' estimates; the tables above supersede pen that extends, branches and closes paths (vector networks), paint bucket fill/unfill of any closed area (faces with holes), point/segment selection, handle mirroring (none/angle/angle and length) with a Point panel (position, corner radius, mirroring), smooth/sharp points, join and - merge, delete vs delete-and-heal, nudging, one undo step per gesture, eraser (⇧E), shape builder + merge (joining the two ends of a path broken open by a deleted point closes it again, so its + fill follows the new segment, #1951), delete vs delete-and-heal, nudging, one undo step per gesture, eraser (⇧E), shape builder (⇧M: the regions of overlapping shapes, click or drag to merge, Alt to remove, curves kept), variable width strokes (width profile presets with previews in the stroke settings, the Variable width tool ⇧W: drag the path to add a width point, slide points, drag their handles, Delete; @@ -648,6 +649,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-11-04 | patch | M8, #1951 (#1506 again): a path broken open by deleting a point (Delete, not Shift+Delete) is filled closed by a straight line across the gap, as an open path's fill is; joining its two ends again (the pen onto the other end, Connect, ⌘J) left the loop without the new segment, so once the points were rounded the fill and the selection outline ran straight across while the stroke followed the segment and its arcs. Joining now closes the loop through the new segment ("Click to close the path."; "You can use the Paint tool to add fills to any closed regions of a vector network."): `ops::connect` splices it into the loop whose segments are the chain between the ends. Evidence: `vectoredit::tests::a_rounded_chamfer_made_other_ways_fills_along_its_arcs` (fails on main: the loop kept 4 of 5 segments), `a_rounded_chamfer_cut_in_the_editor_fills_along_its_arcs` (Shift+Delete, with and without a region fill), `doc::tests::a_chamfered_rectangles_rounded_fill_is_cut_at_every_point` (fill, region path and stroke agree for every loop order and segment direction of a chamfered rectangle). | | 2026-11-01 | patch | M4: bulleted lists draw the same bullet at every level of indentation ("Figma bullets will keep the same styling at all levels of indentation", Create bulleted and numbered lists); the deeper levels drew a white bullet and a small square, pinned as silent before. Numbered levels (1., a., i.) are unchanged. Found by a documentation audit of the text rules. Test: `tail_tests::list_numbering_by_level` (the bullets row). Numbers unchanged | | 2026-11-01 | patch | M4: typing `1)` followed by a space at the start of a plain paragraph starts a numbered list, as `1.` already did ("Enter one of the creation characters below followed by a Space: 1. 1)", Create bulleted and numbered lists). Found by a documentation audit of the text rules. Test: `text_tail_tests::typing_a_creation_character_and_a_space_starts_a_list`. Numbers unchanged | | 2026-11-01 | patch | M4: a link made with `text.link` (Cmd/Ctrl+K, Shift+Cmd/Ctrl+U) underlines the linked text unless it has a decoration of its own ("Links are styled with an underline by default. Use the keyboard shortcut to remove the underline", Add links to text); Underline (Cmd/Ctrl+U) takes the underline off and the link stays. Found by a documentation audit of the text rules. Test: `text_behaviour_tests::a_link_is_underlined_by_default_and_underline_takes_it_off`. Numbers unchanged | From 9ff2105d9be63429d47fce215d7a083c959fc561 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Wed, 4 Nov 2026 06:03:00 -0500 Subject: [PATCH 03/13] M8: a reconnected loop keeps its direction, so a hole stays a hole whichever end was clicked first (#1951) Cause: `ops::connect` rebuilt the broken loop along the path from the second end to the first, so connecting the ends in the other order reversed the loop's walk; a hole wound against its outline then wound with it and the non-zero rule filled it. Fix: the new segment goes into the loop at its gap, in the loop's own order. Test: a ring whose hole loses a corner with Delete and is connected again, either end first (red before for one order). Co-Authored-By: Claude Fable 5.1 --- crates/engine/src/canvas/vectoredit/ops.rs | 40 ++++++++++++++++++++-- docs/target-app-parity.md | 2 +- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/canvas/vectoredit/ops.rs b/crates/engine/src/canvas/vectoredit/ops.rs index 993e45066..9e7b9b372 100644 --- a/crates/engine/src/canvas/vectoredit/ops.rs +++ b/crates/engine/src/canvas/vectoredit/ops.rs @@ -661,10 +661,15 @@ pub fn connect(net: &mut VectorNetwork, a: usize, b: usize, ta: (f64, f64), tb: let filled = net.regions.iter().any(|r| r.loops.iter().flatten().any(|s| lp.contains(s))); if filled { // A loop broken open at these ends (a point of it deleted, say) closes through the new - // segment, so the fill follows it instead of a straight line across the gap (#1951). + // segment, so the fill follows it instead of a straight line across the gap (#1951). The + // segment goes in at the gap, in the loop's own order, so the loop keeps its direction: a + // hole wound against its outline stays a hole whichever end was clicked first. + let segs = &net.segments; + let shares = |x: usize, y: usize| segs.get(x).zip(segs.get(y)).is_some_and(|(p, q)| p.a == q.a || p.a == q.b || p.b == q.a || p.b == q.b); for l in net.regions.iter_mut().flat_map(|r| r.loops.iter_mut()) { if l.len() == lp.len() && l.iter().all(|s| lp.contains(s)) { - *l = std::iter::once(si).chain(lp.iter().copied()).collect(); + let gap = (1..l.len()).find(|&k| l.get(k - 1).zip(l.get(k)).is_some_and(|(&x, &y)| !shares(x, y))).unwrap_or(l.len()); + l.insert(gap, si); } } } else { @@ -1005,6 +1010,37 @@ mod tests { assert!(connect(&mut n, 1, 99, (0.0, 0.0), (0.0, 0.0)).is_none()); } + #[test] + fn connecting_the_ends_of_a_hole_keeps_its_winding() { + // A ring: the hole's loop walks the other way round from the outline's, so the non-zero + // rule leaves it empty. + let mut n = square(); + for (x, y) in [(25.0, 25.0), (75.0, 25.0), (75.0, 75.0), (25.0, 75.0)] { + n.vertices.push(Vertex::new(x, y)); + } + for (a, b) in [(4, 5), (5, 6), (6, 7), (7, 4)] { + n.segments.push(Segment::line(a, b)); + } + n.regions[0].loops.push(vec![7, 6, 5, 4]); + let (hole, ring, cut) = (Point::new(40.0, 60.0), Point::new(10.0, 50.0), Point::new(70.0, 30.0)); + assert!(!n.fill_contains(hole) && n.fill_contains(ring) && !n.fill_contains(cut), "a ring to start with"); + // Delete a corner of the hole (the path opens; its fill closes across the gap, so the cut-off + // corner is filled), then connect the two open ends again, either end first: the hole is a + // triangle, still empty. + let at = |m: &VectorNetwork, (x, y): (f64, f64)| m.vertices.iter().position(|v| v.x == x && v.y == y).unwrap(); + for (first, second) in [((25.0, 25.0), (75.0, 75.0)), ((75.0, 75.0), (25.0, 25.0))] { + let mut m = n.clone(); + delete_vertices(&mut m, &[5], false); + assert!(!m.fill_contains(hole) && m.fill_contains(cut), "opened: {:?}", m.regions); + let (a, b) = (at(&m, first), at(&m, second)); + connect(&mut m, a, b, (0.0, 0.0), (0.0, 0.0)).unwrap(); + let loops = &m.regions[0].loops; + assert_eq!((m.regions.len(), loops.len(), loops[1].len()), (1, 2, 3), "{first:?} first: {:?}", m.regions); + assert!(!m.fill_contains(hole), "{first:?} first: the hole is filled, {loops:?}"); + assert!(m.fill_contains(ring) && m.fill_contains(cut), "{first:?} first: the ring is filled"); + } + } + #[test] fn join_merges_overlapping_points_and_connects_ends() { let mut n = open_line(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 2093798ed..63f77f30e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -649,7 +649,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| -| 2026-11-04 | patch | M8, #1951 (#1506 again): a path broken open by deleting a point (Delete, not Shift+Delete) is filled closed by a straight line across the gap, as an open path's fill is; joining its two ends again (the pen onto the other end, Connect, ⌘J) left the loop without the new segment, so once the points were rounded the fill and the selection outline ran straight across while the stroke followed the segment and its arcs. Joining now closes the loop through the new segment ("Click to close the path."; "You can use the Paint tool to add fills to any closed regions of a vector network."): `ops::connect` splices it into the loop whose segments are the chain between the ends. Evidence: `vectoredit::tests::a_rounded_chamfer_made_other_ways_fills_along_its_arcs` (fails on main: the loop kept 4 of 5 segments), `a_rounded_chamfer_cut_in_the_editor_fills_along_its_arcs` (Shift+Delete, with and without a region fill), `doc::tests::a_chamfered_rectangles_rounded_fill_is_cut_at_every_point` (fill, region path and stroke agree for every loop order and segment direction of a chamfered rectangle). | +| 2026-11-04 | patch | M8, #1951 (#1506 again): a path broken open by deleting a point (Delete, not Shift+Delete) is filled closed by a straight line across the gap, as an open path's fill is; joining its two ends again (the pen onto the other end, Connect, ⌘J) left the loop without the new segment, so once the points were rounded the fill and the selection outline ran straight across while the stroke followed the segment and its arcs. Joining now closes the loop through the new segment ("Click to close the path."; "You can use the Paint tool to add fills to any closed regions of a vector network."): `ops::connect` puts it into the loop whose segments are the chain between the ends, at the gap and in the loop's own order, so a hole keeps its winding whichever end was clicked first. Evidence: `vectoredit::tests::a_rounded_chamfer_made_other_ways_fills_along_its_arcs` (fails on main: the loop kept 4 of 5 segments), `a_rounded_chamfer_cut_in_the_editor_fills_along_its_arcs` (Shift+Delete, with and without a region fill), `doc::tests::a_chamfered_rectangles_rounded_fill_is_cut_at_every_point` (fill, region path and stroke agree for every loop order and segment direction of a chamfered rectangle). | | 2026-11-01 | patch | M4: bulleted lists draw the same bullet at every level of indentation ("Figma bullets will keep the same styling at all levels of indentation", Create bulleted and numbered lists); the deeper levels drew a white bullet and a small square, pinned as silent before. Numbered levels (1., a., i.) are unchanged. Found by a documentation audit of the text rules. Test: `tail_tests::list_numbering_by_level` (the bullets row). Numbers unchanged | | 2026-11-01 | patch | M4: typing `1)` followed by a space at the start of a plain paragraph starts a numbered list, as `1.` already did ("Enter one of the creation characters below followed by a Space: 1. 1)", Create bulleted and numbered lists). Found by a documentation audit of the text rules. Test: `text_tail_tests::typing_a_creation_character_and_a_space_starts_a_list`. Numbers unchanged | | 2026-11-01 | patch | M4: a link made with `text.link` (Cmd/Ctrl+K, Shift+Cmd/Ctrl+U) underlines the linked text unless it has a decoration of its own ("Links are styled with an underline by default. Use the keyboard shortcut to remove the underline", Add links to text); Underline (Cmd/Ctrl+U) takes the underline off and the link stays. Found by a documentation audit of the text rules. Test: `text_behaviour_tests::a_link_is_underlined_by_default_and_underline_takes_it_off`. Numbers unchanged | From cacf73a0940c56469b892c4c68e6c7807be443b5 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Wed, 4 Nov 2026 06:45:00 -0500 Subject: [PATCH 04/13] M8: a reconnected loop is closed only where the segment makes it one continuous chain (#1951) A loop whose segments are the chain between the two ends but are listed out of walking order has no place for the new segment that makes it continuous; inserting it at the first gap would leave the other gaps. Such a loop is left as it was (the walkers' bridging still draws it), as a loop broken in two places is. Test: the chain A-B-C-D-E with its loop listed out of order, joined A to E. Co-Authored-By: Claude Fable 5.1 --- crates/engine/src/canvas/vectoredit/ops.rs | 23 ++++++++++++++++++++-- docs/target-app-parity.md | 2 +- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/canvas/vectoredit/ops.rs b/crates/engine/src/canvas/vectoredit/ops.rs index 9e7b9b372..32b8eef44 100644 --- a/crates/engine/src/canvas/vectoredit/ops.rs +++ b/crates/engine/src/canvas/vectoredit/ops.rs @@ -663,13 +663,20 @@ pub fn connect(net: &mut VectorNetwork, a: usize, b: usize, ta: (f64, f64), tb: // A loop broken open at these ends (a point of it deleted, say) closes through the new // segment, so the fill follows it instead of a straight line across the gap (#1951). The // segment goes in at the gap, in the loop's own order, so the loop keeps its direction: a - // hole wound against its outline stays a hole whichever end was clicked first. + // hole wound against its outline stays a hole whichever end was clicked first. Only a + // loop the segment makes one continuous chain is closed; one listed out of walking order + // is left as it was. let segs = &net.segments; let shares = |x: usize, y: usize| segs.get(x).zip(segs.get(y)).is_some_and(|(p, q)| p.a == q.a || p.a == q.b || p.b == q.a || p.b == q.b); for l in net.regions.iter_mut().flat_map(|r| r.loops.iter_mut()) { if l.len() == lp.len() && l.iter().all(|s| lp.contains(s)) { let gap = (1..l.len()).find(|&k| l.get(k - 1).zip(l.get(k)).is_some_and(|(&x, &y)| !shares(x, y))).unwrap_or(l.len()); - l.insert(gap, si); + let mut closed = l.clone(); + closed.insert(gap, si); + let n = closed.len(); + if (0..n).all(|k| closed.get(k).zip(closed.get((k + 1) % n)).is_some_and(|(&x, &y)| shares(x, y))) { + *l = closed; + } } } } else { @@ -1041,6 +1048,18 @@ mod tests { } } + #[test] + fn connecting_leaves_a_loop_out_of_walking_order_alone() { + // The open chain A-B-C-D-E with its loop listed out of walking order: no place for the new + // segment makes it one continuous chain, so the loop is left as it was, and no region is added. + let pts = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0), Point::new(-50.0, 50.0)]; + let mut n = VectorNetwork::from_points(&pts, false); + n.regions.push(Region { loops: vec![vec![0, 2, 1, 3]], winding: WindingRule::NonZero, fills: None, ..Default::default() }); + let s = connect(&mut n, 0, 4, (0.0, 0.0), (0.0, 0.0)).unwrap(); + assert_eq!(n.regions.len(), 1); + assert_eq!(n.regions[0].loops, vec![vec![0, 2, 1, 3]], "left as it was, without segment {s}"); + } + #[test] fn join_merges_overlapping_points_and_connects_ends() { let mut n = open_line(); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 63f77f30e..2c0cfe224 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -649,7 +649,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| -| 2026-11-04 | patch | M8, #1951 (#1506 again): a path broken open by deleting a point (Delete, not Shift+Delete) is filled closed by a straight line across the gap, as an open path's fill is; joining its two ends again (the pen onto the other end, Connect, ⌘J) left the loop without the new segment, so once the points were rounded the fill and the selection outline ran straight across while the stroke followed the segment and its arcs. Joining now closes the loop through the new segment ("Click to close the path."; "You can use the Paint tool to add fills to any closed regions of a vector network."): `ops::connect` puts it into the loop whose segments are the chain between the ends, at the gap and in the loop's own order, so a hole keeps its winding whichever end was clicked first. Evidence: `vectoredit::tests::a_rounded_chamfer_made_other_ways_fills_along_its_arcs` (fails on main: the loop kept 4 of 5 segments), `a_rounded_chamfer_cut_in_the_editor_fills_along_its_arcs` (Shift+Delete, with and without a region fill), `doc::tests::a_chamfered_rectangles_rounded_fill_is_cut_at_every_point` (fill, region path and stroke agree for every loop order and segment direction of a chamfered rectangle). | +| 2026-11-04 | patch | M8, #1951 (#1506 again): a path broken open by deleting a point (Delete, not Shift+Delete) is filled closed by a straight line across the gap, as an open path's fill is; joining its two ends again (the pen onto the other end, Connect, ⌘J) left the loop without the new segment, so once the points were rounded the fill and the selection outline ran straight across while the stroke followed the segment and its arcs. Joining now closes the loop through the new segment ("Click to close the path."; "You can use the Paint tool to add fills to any closed regions of a vector network."): `ops::connect` puts it into the loop whose segments are the chain between the ends, at the gap and in the loop's own order, so a hole keeps its winding whichever end was clicked first; a loop listed out of walking order is left as it was. Evidence: `vectoredit::tests::a_rounded_chamfer_made_other_ways_fills_along_its_arcs` (fails on main: the loop kept 4 of 5 segments), `a_rounded_chamfer_cut_in_the_editor_fills_along_its_arcs` (Shift+Delete, with and without a region fill), `doc::tests::a_chamfered_rectangles_rounded_fill_is_cut_at_every_point` (fill, region path and stroke agree for every loop order and segment direction of a chamfered rectangle). | | 2026-11-01 | patch | M4: bulleted lists draw the same bullet at every level of indentation ("Figma bullets will keep the same styling at all levels of indentation", Create bulleted and numbered lists); the deeper levels drew a white bullet and a small square, pinned as silent before. Numbered levels (1., a., i.) are unchanged. Found by a documentation audit of the text rules. Test: `tail_tests::list_numbering_by_level` (the bullets row). Numbers unchanged | | 2026-11-01 | patch | M4: typing `1)` followed by a space at the start of a plain paragraph starts a numbered list, as `1.` already did ("Enter one of the creation characters below followed by a Space: 1. 1)", Create bulleted and numbered lists). Found by a documentation audit of the text rules. Test: `text_tail_tests::typing_a_creation_character_and_a_space_starts_a_list`. Numbers unchanged | | 2026-11-01 | patch | M4: a link made with `text.link` (Cmd/Ctrl+K, Shift+Cmd/Ctrl+U) underlines the linked text unless it has a decoration of its own ("Links are styled with an underline by default. Use the keyboard shortcut to remove the underline", Add links to text); Underline (Cmd/Ctrl+U) takes the underline off and the link stays. Found by a documentation audit of the text rules. Test: `text_behaviour_tests::a_link_is_underlined_by_default_and_underline_takes_it_off`. Numbers unchanged | From 23792e8503a750c1260afe7d2db1384d5e972470 Mon Sep 17 00:00:00 2001 From: Markus Reimr Date: Wed, 4 Nov 2026 14:32:29 +0100 Subject: [PATCH 05/13] M10: translate German text-wrap controls and tooltips --- ROADMAP.md | 4 +++- crates/ui-egui/locales/de.toml | 6 ++++++ docs/localization-parity.md | 3 ++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 410705919..7ceeeaf67 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-30 · **Change:** minor (a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (six German translations for the text-wrap control (M10): Auto, balanced and pretty wrapping; catalog remains a draft; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); 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-11-04** · M10: the text layer's Wrap style control and its Balance/Pretty options and three explanatory tooltips now have German translations. These six UI strings previously fell back to English. German stays a draft; no full native review claimed. Grade and estimates unchanged. - **2026-10-30**: M8, #1125: damaged `trak` size-table offsets are checked instead of wrapping on 32-bit targets, and an incomplete normal track is dropped entirely, never returned in part. Font indexing makes its existing bounds explicit, preserving later faces and sparse name-table reads. Synthetic regression tests in `fontdb_tests.rs`. Numbers unchanged. - **2026-10-30** · Text style dedupe is O(spans) (M8, #1102): `shape_text` deduped spans into styles by a scan over the styles already collected — O(spans × styles), so a styled text's dedupe grew with the square of its runs (a synthetic 50k-run text with 100 alternating styles spent 7.7 ms in it). Now the last two styles again (spans run in blocks), then buckets keyed by a cheap fingerprint (family and style bytes, size bits with −0.0 folded, the enums, fill count and solid colours), each bucket resolved with the full equality: the result is the scan's, never worse than it, and O(spans) when names and sizes differ (the same synthetic text: 2.6 ms). The font-db write lock is untouched: `find()` loads faces, which needs the write. `layout_dedupe_tests`. Numbers unchanged. - **2026-10-29** · M10: correct nine German labels in `de.toml`: gradient stops use Verlaufsstopp, OpenType ss01–ss05 use Stilsatz, and the Home-screen preference names the Startbildschirm consistently in the UI and Actions palette. This is a partial terminology pass; German remains a draft awaiting a full native-speaker review and maintainer approval. Numbers unchanged. @@ -357,6 +358,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-11-04 | minor | six German translations for the text-wrap control (M10): Auto, balanced and pretty wrapping; catalog remains a draft. Six previously missing UI entries added to `de.toml`; command labels and menu wordings unchanged. Full native review is still pending; grade and estimates unchanged | | 2026-10-31 | minor | M12: as the help describes, holding Cmd/Ctrl while moving a connector's end places it on any part of an object instead of snapping to a side. It keeps that spot as a fraction of the object's box, so it follows the object when it moves or resizes, and without the key an end snaps as before. `board.connector {fromAt, toAt}` and `board.reattach {at}` take it; saved only when set. Numbers unchanged | | 2026-10-31 | patch | M11: a trackpad or touch tap sends its press and release in one frame, so a plugin panel's field committed in the frame its button was clicked and the value was lost: the Counter's Set count… kept the old count. The plugin now gets both, the field's value first. The `window.figcraft` docs name `screenshot`'s `data`. Numbers unchanged | | 2026-10-31 | patch | M4: Dash and Custom strokes have a Dash cap (None, Round, Square) in the advanced stroke settings and `node.set {dashCap}`, on both ends of every dash on the canvas and in PNG, PDF and SVG. Unset, dashes keep taking the end point caps, so older files look the same; the saved file writes it only when set. Numbers unchanged | diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 344e70912..765203109 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -147,6 +147,7 @@ status = "draft — needs native review" "Auto layout" = "Auto-Layout" "Auto rows" = "Automatische Zeilen" "Auto width" = "Automatische Breite" +"Auto: as many words as fit on each line" = "Auto: Jede Zeile wird so weit wie möglich gefüllt" "Back to the FigCraft server" = "Zurück zum FigCraft-Server" "Back to the font's value" = "Zurück zum Wert der Schrift" "Back" = "Zurück" @@ -160,6 +161,8 @@ status = "draft — needs native review" "Back to the values the font style implies" = "Zurück zu den Werten des Schriftschnitts" "Background" = "Hintergrund" "Background blur" = "Hintergrundunschärfe" +"Balance" = "Ausgeglichen" +"Balance: every line close to the same length" = "Ausgeglichen: Alle Zeilen sind möglichst gleich lang" "Basics" = "Grundlagen" "Before" = "Vorher" "Bend" = "Biegen" @@ -1021,6 +1024,8 @@ status = "draft — needs native review" "Post" = "Senden" "Preferences" = "Einstellungen" "Preferred" = "Bevorzugt" +"Pretty" = "Optimiert" +"Pretty: full lines, no one word alone on the last" = "Optimiert: Volle Zeilen ohne einzelnes Wort in der letzten Zeile" "Present" = "Präsentieren" "Present or preview" = "Präsentieren oder Vorschau" "Present this flow" = "Diesen Ablauf präsentieren" @@ -1555,6 +1560,7 @@ status = "draft — needs native review" "With the layers' export settings" = "Mit den Exporteinstellungen der Ebenen" "Wrap" = "Umbrechen" "Wrap in new section" = "In neuen Abschnitt einschließen" +"Wrap style" = "Umbruchstil" "Yellow" = "Gelb" "Yesterday" = "Gestern" "You" = "Sie" diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 541e7ab90..6a831bffc 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (Bosnian draft catalog, one/few/other plurals; German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft; Arabic draft catalog, count-neutral plurals and web font gate; Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (six German translations for the text-wrap control (M10): Auto, balanced and pretty wrapping; catalog remains a draft; Bosnian draft catalog, one/few/other plurals; German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft; Arabic draft catalog, count-neutral plurals and web font gate; Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed) · **Target:** Figma Design (desktop app 126.10.7) FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian @@ -125,6 +125,7 @@ Hindi and Arabic. Traditional Chinese is not planned (no catalog; its users see | Date | Change | Summary | |---|---|---| +| 2026-11-04 | minor | six German translations for the text-wrap control (M10): Auto, balanced and pretty wrapping; catalog remains a draft. Six previously missing UI entries added to `de.toml`; command labels and menu wordings unchanged. Full native review is still pending; grade and estimates unchanged | | 2026-10-30 | minor | Bosnian (`bs`, Bosanski) draft catalog: every UI string (1,906), command label (629) and menu wording (31), machine-drafted in Latin script with ijekavian forms and marked as a draft awaiting native review. Bosnian plurals: one for 1, 21, 31… (not 11), few for 2–4, 22–24… (not 12–14), other for the rest. Grade and hours unchanged (Bosnian is not one of the twelve key languages) | | 2026-10-29 | minor | German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft. Nine existing translations corrected; keys, placeholders, coverage, grade and estimates unchanged | | 2026-10-28 | minor | Arabic draft: 1,857 UI entries, 620 command labels and 31 menu wordings, with count-neutral plurals. AI-assisted, native review pending. Desktop uses the existing Arabic fallback and bidi; web requires an embedded Arabic face. No panel mirroring or parity-grade increase claimed | From 05d1b147fd0c70424365e09ec00ba6d24fb82e41 Mon Sep 17 00:00:00 2001 From: Markus Reimr Date: Wed, 4 Nov 2026 16:17:10 +0100 Subject: [PATCH 06/13] M10: synchronize measured German localization coverage --- ROADMAP.md | 13 +++++++++---- docs/localization-parity.md | 15 ++++++++++++--- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7ceeeaf67..6e29e428f 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-11-04 · **Change:** minor (six German translations for the text-wrap control (M10): Auto, balanced and pretty wrapping; catalog remains a draft; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (German coverage re-measured at 2,592 / 2,647 entries (97.9%, 2026-11-04); six German translations for the text-wrap control (M10): Auto, balanced and pretty wrapping; catalog remains a draft; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); 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 @@ -97,7 +97,7 @@ native speakers' reviews of the nine language drafts need people. ## Languages -| Language | Code | Strings | Status | +| Language | Code | Catalog entries | Status | |---|---|---|---| | English | en | 100% | full | | Simplified Chinese | zh-Hans | 95% | draft (needs native review) | @@ -108,11 +108,14 @@ native speakers' reviews of the nine language drafts need people. | Portuguese (Brazil) | pt-BR | 95% | draft (needs native review) | | Indonesian | id | 99% | draft (needs native review) | | Japanese | ja | 99% | draft (needs native review) | -| German | de | 96% | draft (needs native review) | +| German | de | 2,592 / 2,647 entries (97.9%, measured 2026-11-04) | draft (needs native review) | | Korean | ko | 95% | draft (needs native review) | | Vietnamese | vi | 100% | draft (needs native review) | -Percentages measured 2026-10-17 over 2,272 entries (strings added since a draft show English). Also shipped: Russian (`ru`, 99%, reviewed by VanTan), Swedish (`sv`, 99.6%, command labels since 2026-10-17; native review welcome), Turkish (`tr`, 99%), Czech (`cs`, 99%, draft) and Bosnian (`bs`, 100% on 2026-10-30, draft). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese +German coverage was re-measured on 2026-11-04 with `cargo xtask i18n-report`: 1,969 / 1,975 UI keys, +594 / 641 command labels and 29 / 31 menu wordings, or 2,592 / 2,647 entries (97.9%). The count measures +catalog entries, not native-speaker approval; the method is in [localization parity](docs/localization-parity.md#languages). +The older percentages use the 2026-10-17 measurement over 2,272 entries (strings added since a draft show English); rows with a later date supersede it. Also shipped: Russian (`ru`, 99%, reviewed by VanTan), Swedish (`sv`, 99.6%, command labels since 2026-10-17; native review welcome), Turkish (`tr`, 99%), Czech (`cs`, 99%, draft) and Bosnian (`bs`, 100% on 2026-10-30, draft). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese (Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian, Vietnamese and Arabic drafts (Preferences ▸ Language; Traditional Chinese has no catalog; CJK UI @@ -132,6 +135,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-11-04** · M10: re-measure German catalog coverage with `cargo xtask i18n-report` and synchronize both language rows at 2,592 / 2,647 entries (97.9%). Record the current key counts and counting method; distinguish the older dated measurements. German remains a draft; grade and estimates unchanged. - **2026-11-04** · M10: the text layer's Wrap style control and its Balance/Pretty options and three explanatory tooltips now have German translations. These six UI strings previously fell back to English. German stays a draft; no full native review claimed. Grade and estimates unchanged. - **2026-10-30**: M8, #1125: damaged `trak` size-table offsets are checked instead of wrapping on 32-bit targets, and an incomplete normal track is dropped entirely, never returned in part. Font indexing makes its existing bounds explicit, preserving later faces and sparse name-table reads. Synthetic regression tests in `fontdb_tests.rs`. Numbers unchanged. - **2026-10-30** · Text style dedupe is O(spans) (M8, #1102): `shape_text` deduped spans into styles by a scan over the styles already collected — O(spans × styles), so a styled text's dedupe grew with the square of its runs (a synthetic 50k-run text with 100 alternating styles spent 7.7 ms in it). Now the last two styles again (spans run in blocks), then buckets keyed by a cheap fingerprint (family and style bytes, size bits with −0.0 folded, the enums, fill count and solid colours), each bucket resolved with the full equality: the result is the scan's, never worse than it, and O(spans) when names and sizes differ (the same synthetic text: 2.6 ms). The font-db write lock is untouched: `find()` loads faces, which needs the write. `layout_dedupe_tests`. Numbers unchanged. @@ -358,6 +362,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-11-04 | minor | German coverage re-measured with `cargo xtask i18n-report`: 1,969 / 1,975 UI keys, 594 / 641 command labels and 29 / 31 menu wordings; both language rows now show 2,592 / 2,647 (97.9%). Missing: 6 UI keys, 47 command labels, 2 menu wordings. Older measurements are dated; native review, grade and estimates unchanged | | 2026-11-04 | minor | six German translations for the text-wrap control (M10): Auto, balanced and pretty wrapping; catalog remains a draft. Six previously missing UI entries added to `de.toml`; command labels and menu wordings unchanged. Full native review is still pending; grade and estimates unchanged | | 2026-10-31 | minor | M12: as the help describes, holding Cmd/Ctrl while moving a connector's end places it on any part of an object instead of snapping to a side. It keeps that spot as a fraction of the object's box, so it follows the object when it moves or resizes, and without the key an end snaps as before. `board.connector {fromAt, toAt}` and `board.reattach {at}` take it; saved only when set. Numbers unchanged | | 2026-10-31 | patch | M11: a trackpad or touch tap sends its press and release in one frame, so a plugin panel's field committed in the frame its button was clicked and the value was lost: the Counter's Set count… kept the old count. The plugin now gets both, the field's value first. The `window.figcraft` docs name `screenshot`'s `data`. Numbers unchanged | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 6a831bffc..e4b720639 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (six German translations for the text-wrap control (M10): Auto, balanced and pretty wrapping; catalog remains a draft; Bosnian draft catalog, one/few/other plurals; German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft; Arabic draft catalog, count-neutral plurals and web font gate; Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (German coverage re-measured at 2,592 / 2,647 entries (97.9%, 2026-11-04); six German translations for the text-wrap control (M10): Auto, balanced and pretty wrapping; catalog remains a draft; Bosnian draft catalog, one/few/other plurals; German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft; Arabic draft catalog, count-neutral plurals and web font gate; Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed) · **Target:** Figma Design (desktop app 126.10.7) FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian @@ -85,7 +85,7 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v ## Languages -| Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | Hours to full | +| Language | Code | Translated catalog entries | Dialogs / tooltips / help | Script support | Native review | Status | Hours to full | |---|---|---|---|---|---|---|---| | English | en | 2,352 / 2,352 (100%, source) | yes | Latin | yes (source) | full | 0 | | Simplified Chinese (Mandarin) | zh-Hans | 2,216 / 2,352 (94.2%, draft) | yes | CJK text and IME in documents; UI text in an installed Chinese face, ahead of Japanese ones (web: the `FIGCRAFT_EMBED_CJK` build); no Traditional Chinese catalog (zh-TW, zh-HK read English) | no | draft | 1–2 (after native review) | @@ -96,11 +96,19 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v | Portuguese | pt-BR | 2,331 / 2,352 (99.1%, draft) | yes | Latin | no | draft | 1–2 (after native review) | | Indonesian | id | 2,292 / 2,352 (97.4%, draft) | yes | Latin | no | draft | 1–2 (after native review) | | Japanese | ja | 2,304 / 2,352 (98.0%, draft) | yes | CJK text and IME in documents; UI text in an installed Japanese face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | -| German | de | 2,226 / 2,352 (94.6%, draft) | yes | Latin; long words elide in narrow fields | no | draft | 1–2 (after native review) | +| German | de | 2,592 / 2,647 entries (97.9%, measured 2026-11-04) | yes | Latin; long words elide in narrow fields | no | draft | 1–2 (after native review) | | Korean | ko | 2,216 / 2,352 (94.2%, draft) | yes | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) | | Vietnamese | vi | 2,352 / 2,352 (100%, draft) | yes | Latin with stacked diacritics | no | draft | 1–2 (after native review) | | Thai | th | 1,791 / 1,791 UI strings, 589 of 598 command labels, 31 menu wordings (draft) | yes | Thai text in documents; UI text (and layer and page names in Thai, whatever the UI language) in an installed Thai face, desktop only; the web build leaves Thai out | no | draft | 1–2 (after native review) | +German was re-measured on **2026-11-04** with `cargo xtask i18n-report`: **1,969 / 1,975 UI keys**, +**594 / 641 command labels** and **29 / 31 menu wordings**, totaling **2,592 / 2,647 entries (97.9%)**. +The report confirms 6 UI keys, 47 command labels and 2 menu wordings are missing. The denominator uses +the report's key sets: English UI keys, the union of registered command ids and main-menu ids, +and menu labels worded differently from their command. A plural entry counts as one key, and a +present translation counts even when its wording matches English. This is catalog coverage; +German remains a draft awaiting full native-speaker review. The other rows retain their dated measurements. + Other languages (measured 2026-10-22): Russian (`ru`) 2,352 / 2,352 (100%), reviewed and approved by VanTan, one/few/many integer plurals; Swedish (`sv`) 2,314 / 2,352 (98.4%), its command labels and menu wordings added 2026-10-17 (#599), native review welcome; Turkish (`tr`) 2,291 / 2,352 (97.4%), whose header @@ -125,6 +133,7 @@ Hindi and Arabic. Traditional Chinese is not planned (no catalog; its users see | Date | Change | Summary | |---|---|---| +| 2026-11-04 | minor | German coverage re-measured with `cargo xtask i18n-report`: 1,969 / 1,975 UI keys, 594 / 641 command labels and 29 / 31 menu wordings; both language rows now show 2,592 / 2,647 (97.9%). Missing: 6 UI keys, 47 command labels, 2 menu wordings. Older measurements are dated; native review, grade and estimates unchanged | | 2026-11-04 | minor | six German translations for the text-wrap control (M10): Auto, balanced and pretty wrapping; catalog remains a draft. Six previously missing UI entries added to `de.toml`; command labels and menu wordings unchanged. Full native review is still pending; grade and estimates unchanged | | 2026-10-30 | minor | Bosnian (`bs`, Bosanski) draft catalog: every UI string (1,906), command label (629) and menu wording (31), machine-drafted in Latin script with ijekavian forms and marked as a draft awaiting native review. Bosnian plurals: one for 1, 21, 31… (not 11), few for 2–4, 22–24… (not 12–14), other for the rest. Grade and hours unchanged (Bosnian is not one of the twelve key languages) | | 2026-10-29 | minor | German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft. Nine existing translations corrected; keys, placeholders, coverage, grade and estimates unchanged | From cd77b017bde826d201c4d0f25cd8d2f7c28dd647 Mon Sep 17 00:00:00 2001 From: Andreas Haraminac Date: Wed, 4 Nov 2026 19:06:37 +0000 Subject: [PATCH 07/13] M6: correct architecture guide for existing .fig support --- docs/architecture.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 5c7300f82..18d7ac550 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # FigCraft architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (the `vectordrawable` crate, L4: Android Vector Drawable export; earlier: the shared hub in `collab-net` and the `figcraft-server` app) · **Target:** Figma Design +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (corrected the file I/O overview to include the existing best-effort `.fig` import and experimental export; earlier: the `vectordrawable` crate, L4: Android Vector Drawable export; the shared hub in `collab-net` and the `figcraft-server` app) · **Target:** Figma Design How FigCraft is built today. Engine first, a thin swappable UI, everything a command; a Figma-shaped data model (a scene graph of frames with auto layout, components and variables). ~108,800 lines of @@ -23,7 +23,7 @@ replaced. | L2 | `figcraft-plugin` | ~1.0k | Plugin manifest and the WebAssembly sandbox (wasmi, metered, memory cap, no file system, network or clock) | | L2 | `figcraft-pathops` | ~1.1k | Booleans (linesweeper), outline stroke with alignment, arrowheads | | L3 | `figcraft-render` | ~5.4k | Scene → pixels on tiny-skia (CPU): isolated layers for opacity/blend/effects, clips, masks, shadows, blurs (uniform, progressive, background), noise, texture, glass, text; banded in parallel; retained rendering of only what changed; levels of detail when zoomed out | -| L3 | `figcraft-format` | ~5.2k | Native `.figcraft` (zip of `document.json` + images; streamed JSON), local library files, versions, REST API file JSON import (`rest/`) | +| L3 | `figcraft-format` | ~5.2k | Native `.figcraft` (zip of `document.json` + images; streamed JSON), local library files, versions, REST API file JSON import (`rest/`), best-effort `.fig` import and experimental export (`fig` module) | | L4 | `figcraft-svg` | ~3.7k | SVG export and import (usvg) | | L4 | `figcraft-pdf` | ~2.3k | PDF export (krilla) with print options | | L4 | `figcraft-vectordrawable` | ~1.2k | Android Vector Drawable (XML) export: baked path data, gradients through `aapt:attr`, clip groups, compatibility modes and warnings | @@ -67,9 +67,11 @@ express an effect. `.figcraft` is the native format (atomic saves, autosave checkpoints; the desktop app keeps recovery copies of unsaved documents that reopen after a crash or a forced quit). Imports: REST API -file JSON with variables and images, SVG, raster images, clipboard -content. Exports: PNG, JPG, WebP, SVG, PDF, code. There is no `.fig` reader yet -([`file-format-parity.md`](file-format-parity.md)). +file JSON with variables and images, SVG, raster images, clipboard content and `.fig` files (best +effort). Exports: PNG, JPG, WebP, SVG, PDF, code and `.fig` (experimental). The `.fig` +[reader](../crates/format/src/fig.rs) and [writer](../crates/format/src/fig/export.rs) belong to +`figcraft-format`; fidelity limits and validation are documented in +[`file-format-parity.md`](file-format-parity.md). ## Robustness @@ -83,6 +85,7 @@ input. | Date | Change | Summary | |---|---|---| +| 2026-11-04 | minor | Corrected the file I/O overview and format-crate row: best-effort `.fig` import and experimental export already exist in `figcraft-format`. No capability or readiness change. | | 2026-10-18 | minor | Added `vectordrawable` (L4), the Android Vector Drawable (XML) writer | | 2026-10-11 | minor | `collab-net` hosts through one hub for the app's sessions and the standalone server; added the `figcraft-server` app | | 2026-10-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) | From f566f9611d4540a237a8efc98d93f850a56c7c8f Mon Sep 17 00:00:00 2001 From: fluid-chey Date: Wed, 4 Nov 2026 12:54:06 -0700 Subject: [PATCH 08/13] M9: a slow join in a busy room isn't dropped for other people's cursors A client taking in a large welcome holds one queue slot for the whole transfer, while everyone's presence fills the rest. Five people moving their cursors fill 1024 slots in about 7 s, and the joiner is dropped with the same result on every try. Presence now stays out of a queue that is half full: it is latest-wins, and the other half stays free for edits, which can't be left out. Co-Authored-By: Claude Opus 5.5 --- crates/collab-net/src/hub.rs | 3 ++ crates/collab-net/src/hub/barred_tests.rs | 2 ++ crates/collab-net/src/hub/conn.rs | 2 ++ crates/collab-net/src/hub/queue.rs | 18 +++++++++- crates/collab-net/src/hub/queue_tests.rs | 40 +++++++++++++++++++++-- 5 files changed, 62 insertions(+), 3 deletions(-) diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index b79eb1652..6fadf0946 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -737,6 +737,9 @@ impl Hub { continue; } let Some(&conn) = room.ids.get(&id) else { continue }; + if matches!(msg, ServerMsg::Presence { .. }) && self.conns.get(&conn).is_some_and(|c| !c.out.takes_presence()) { + continue; + } let Some(b) = bytes.get_or_insert_with(|| encode(&msg)).clone() else { continue }; if !deliver(&self.conns, conn, &b, &mut held, &mut stuck) { stuck.push(conn); diff --git a/crates/collab-net/src/hub/barred_tests.rs b/crates/collab-net/src/hub/barred_tests.rs index a88caf633..eeb293231 100644 --- a/crates/collab-net/src/hub/barred_tests.rs +++ b/crates/collab-net/src/hub/barred_tests.rs @@ -74,6 +74,8 @@ fn a_kicked_connection_with_a_full_queue_is_shut() { let out = Line { tx, queued: Arc::default(), + waiting: Arc::default(), + max_presence: 32, block: Arc::new(AtomicUsize::new(0)), all: Arc::new(AtomicUsize::new(0)), max: 1 << 20, diff --git a/crates/collab-net/src/hub/conn.rs b/crates/collab-net/src/hub/conn.rs index 1f365cbab..08b9c355e 100644 --- a/crates/collab-net/src/hub/conn.rs +++ b/crates/collab-net/src/hub/conn.rs @@ -235,6 +235,8 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimit let out = Line { tx, queued: Arc::default(), + waiting: Arc::default(), + max_presence: limits.queue.max(4) / 2, block: block.queued.clone(), all: shared.queued.clone(), max: limits.max_queued_bytes, diff --git a/crates/collab-net/src/hub/queue.rs b/crates/collab-net/src/hub/queue.rs index 1c6a5dfcf..3559b0f10 100644 --- a/crates/collab-net/src/hub/queue.rs +++ b/crates/collab-net/src/hub/queue.rs @@ -48,6 +48,7 @@ pub(crate) struct Charges { pub(super) struct Charge { n: usize, mine: Arc, + waiting: Arc, _all: Arc, _block: Option>, } @@ -55,6 +56,7 @@ pub(super) struct Charge { impl Drop for Charge { fn drop(&mut self) { self.mine.fetch_sub(self.n, Ordering::Relaxed); + self.waiting.fetch_sub(1, Ordering::Relaxed); } } @@ -76,6 +78,10 @@ pub(crate) enum Push { pub(crate) struct Line { pub(crate) tx: SyncSender, pub(crate) queued: Arc, + /// Messages waiting in the queue. + pub(crate) waiting: Arc, + /// With this many waiting, presence is left out (see [`Line::takes_presence`]). + pub(crate) max_presence: usize, pub(crate) block: Arc, pub(crate) all: Arc, pub(crate) max: usize, @@ -84,6 +90,15 @@ pub(crate) struct Line { } impl Line { + /// Whether presence goes in the queue now. A client still taking in a long message (the welcome + /// of a large file on a slow link) would otherwise have its queue filled by everyone's cursors + /// and be dropped, again on every try. Presence is latest-wins, so what's left out is made up + /// for by the next update, and this keeps room for what can't be: other people's edits. A client + /// that reads nothing at all still ends at the write timeout. + pub(crate) fn takes_presence(&self) -> bool { + self.waiting.load(Ordering::Relaxed) < self.max_presence + } + /// Queue `b`. One message always fits an empty queue (a welcome as large as the largest file /// gets out). `charges` is what the message counts against, shared by its recipients; /// `over_budget` lets it past the server's budget (the hub just dropped the biggest consumer @@ -118,7 +133,8 @@ impl Line { } }; self.queued.fetch_add(n, Ordering::Relaxed); - let charge = Charge { n, mine: self.queued.clone(), _all: all, _block: block }; + self.waiting.fetch_add(1, Ordering::Relaxed); + let charge = Charge { n, mine: self.queued.clone(), waiting: self.waiting.clone(), _all: all, _block: block }; self.tx.try_send(Out::Bytes(b.clone(), Some(charge))).map_err(|e| match e { TrySendError::Full(_) => Push::Full, TrySendError::Disconnected(_) => Push::Gone, diff --git a/crates/collab-net/src/hub/queue_tests.rs b/crates/collab-net/src/hub/queue_tests.rs index 13879bd1e..57447cc4a 100644 --- a/crates/collab-net/src/hub/queue_tests.rs +++ b/crates/collab-net/src/hub/queue_tests.rs @@ -1,7 +1,8 @@ //! The hub's send queues (no writers: nothing is ever sent, so what is queued stays queued): a //! broadcast counts once against the server's budget however many copies are queued, a block of //! addresses can't take more than its share, and when the server's budget is used up the -//! connection holding the most is shed, not the next client that needs a little. +//! connection holding the most is shed, not the next client that needs a little. Presence stays out +//! of a busy queue. use std::collections::HashMap; use std::net::{IpAddr, TcpListener, TcpStream}; @@ -16,7 +17,20 @@ use super::{BlockUse, Blocks, Charges, Conn, Kind, Line, Out, Push, deliver}; /// A line with its own queue counter, the given block counter and the server's. fn line(block: &Arc, all: &Arc, max_block: usize, max_all: usize) -> (Line, Receiver) { let (tx, rx) = sync_channel(64); - (Line { tx, queued: Arc::default(), block: block.clone(), all: all.clone(), max: 1 << 20, max_block, max_all }, rx) + ( + Line { + tx, + queued: Arc::default(), + waiting: Arc::default(), + max_presence: 32, + block: block.clone(), + all: all.clone(), + max: 1 << 20, + max_block, + max_all, + }, + rx, + ) } fn socket() -> TcpStream { @@ -120,3 +134,25 @@ fn blocks_nobody_uses_are_forgotten() { let _c = blocks.get("203.0.113.3".parse().unwrap()); assert_eq!(blocks.len(), 2, "203.0.113.1 went"); } + +#[test] +fn a_busy_queue_leaves_presence_out_and_keeps_room_for_edits() { + let (l, rx) = line(&Arc::default(), &Arc::default(), usize::MAX, usize::MAX); + let msg = Bytes::from_static(b"x"); + for _ in 0..32 { + assert!(l.takes_presence()); + l.push(&msg, &mut Charges::default(), false).unwrap(); + } + assert!(!l.takes_presence(), "half full"); + // Edits still fit in the half kept for them. + for _ in 0..32 { + l.push(&msg, &mut Charges::default(), false).unwrap(); + } + assert_eq!(l.push(&msg, &mut Charges::default(), false), Err(Push::Full), "a client that takes in nothing still ends"); + // It catches up: presence goes in again. + for _ in 0..33 { + drop(rx.recv().unwrap()); + } + assert!(l.takes_presence()); + assert_eq!(l.waiting.load(Ordering::Relaxed), 31); +} From cd5c9aeb09408fcb7ce50590fb9ce39b59e4cbe5 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Thu, 5 Nov 2026 03:23:07 -0500 Subject: [PATCH 09/13] =?UTF-8?q?M4:=20a=20paragraph's=20base=20direction?= =?UTF-8?q?=20can=20be=20forced:=20Text=20=E2=96=B8=20Text=20direction=20(?= =?UTF-8?q?#1830)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Direction came from the letters only; Add right-to-left text says controls override it per paragraph (Text > Text direction, Left to right or Right to left). `TextDirection` on `ParaList`, `Bidi::of` with a base level per paragraph, `text.direction` and the three menu commands (the edited paragraphs while editing), `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect `textDirection` / `lineDirections`, the CSS `direction`, and the `.fig` lines' `sourceDirectionality` in and out. Co-Authored-By: Claude Fable 5.1 --- ROADMAP.md | 1 + crates/doc/src/text.rs | 29 +++++-- crates/engine/src/automation/inspect.rs | 12 +++ crates/engine/src/catalog.rs | 7 +- crates/engine/src/cmd/props.rs | 8 +- crates/engine/src/cmd/text.rs | 31 ++++++- crates/engine/src/dev/codegen/css.rs | 7 ++ crates/engine/src/text_behaviour_tests.rs | 51 ++++++++++++ crates/engine/src/text_tail_tests.rs | 13 ++- crates/format/src/fig/export.rs | 4 +- crates/format/src/fig/export/shape.rs | 11 ++- crates/format/src/fig/map/text.rs | 12 +++ crates/format/src/fig/tests.rs | 3 + crates/format/src/fig/text_direction_tests.rs | 83 +++++++++++++++++++ crates/format/src/fig/text_list_tests.rs | 2 +- crates/format/src/rest/text.rs | 20 +++-- crates/text/src/behaviour_tests.rs | 2 +- crates/text/src/layout.rs | 46 +++++++--- crates/text/src/tail_tests.rs | 4 +- crates/text/src/tests.rs | 26 +++++- crates/ui-egui/locales/en.toml | 4 + crates/ui-egui/src/panels/typography.rs | 2 +- .../ui-egui/src/panels/typography/fields.rs | 30 ++++++- docs/localization-parity.md | 4 +- docs/ui-parity.md | 4 +- 25 files changed, 365 insertions(+), 51 deletions(-) create mode 100644 crates/format/src/fig/text_direction_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 410705919..c5ca92f35 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-11-05** · M4, #1830: a paragraph's base direction can be forced (Text ▸ Text direction: Auto, Left to right, Right to left) as Add right-to-left text says ("controls are available to override text direction per paragraph"). `ParaList::direction`, `text.direction {direction, ids?}` and the three menu commands (the edited paragraphs while editing, else the layer's), `node.set textDirection`, the Type settings control once a right-to-left script is in the text, `textDirection` / `lineDirections` in inspect, the CSS `direction`, and the `.fig` lines' `sourceDirectionality` in and out. Layout gives each paragraph its base level (`Bidi::of` per paragraph). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests`. - **2026-10-30**: M8, #1125: damaged `trak` size-table offsets are checked instead of wrapping on 32-bit targets, and an incomplete normal track is dropped entirely, never returned in part. Font indexing makes its existing bounds explicit, preserving later faces and sparse name-table reads. Synthetic regression tests in `fontdb_tests.rs`. Numbers unchanged. - **2026-10-30** · Text style dedupe is O(spans) (M8, #1102): `shape_text` deduped spans into styles by a scan over the styles already collected — O(spans × styles), so a styled text's dedupe grew with the square of its runs (a synthetic 50k-run text with 100 alternating styles spent 7.7 ms in it). Now the last two styles again (spans run in blocks), then buckets keyed by a cheap fingerprint (family and style bytes, size bits with −0.0 folded, the enums, fill count and solid colours), each bucket resolved with the full equality: the result is the scan's, never worse than it, and O(spans) when names and sizes differ (the same synthetic text: 2.6 ms). The font-db write lock is untouched: `find()` loads faces, which needs the write. `layout_dedupe_tests`. Numbers unchanged. - **2026-10-29** · M10: correct nine German labels in `de.toml`: gradient stops use Verlaufsstopp, OpenType ss01–ss05 use Stilsatz, and the Home-screen preference names the Startbildschirm consistently in the UI and Actions palette. This is a partial terminology pass; German remains a draft awaiting a full native-speaker review and maintainer approval. Numbers unchanged. diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index ba196edb3..0707f4201 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -259,10 +259,24 @@ pub enum ListStyle { Numbered, } +/// A paragraph's base direction: by its letters, or forced ("controls are available to override +/// text direction per paragraph", Add right-to-left text). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum TextDirection { + /// The first letter with a direction decides (Unicode rules P2 and P3). + #[default] + Auto, + /// Left to right, whatever the letters. + Ltr, + /// Right to left, whatever the letters. + Rtl, +} + /// Deepest list nesting (levels 0..=MAX_LIST_INDENT). pub const MAX_LIST_INDENT: u8 = 8; -/// One paragraph's list settings. +/// One paragraph's list settings and base direction. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(default)] pub struct ParaList { @@ -276,6 +290,9 @@ pub struct ParaList { /// (items that go on counting ignore it; other paragraphs are always 0). #[serde(skip_serializing_if = "crate::node::is_default")] pub start_offset: u32, + /// The paragraph's base direction (Text ▸ Text direction); `Auto` follows its letters. + #[serde(skip_serializing_if = "crate::node::is_default")] + pub direction: TextDirection, } /// Character style. In runs, every field is an override; `None` means "inherit the base". @@ -991,20 +1008,20 @@ mod case_tests { // Files written before start numbers load with none, and write the same bytes. let old = r#"{"list":"numbered","indent":1}"#; let pl: ParaList = serde_json::from_str(old).unwrap(); - assert_eq!(pl, ParaList { list: N, indent: 1, start_offset: 0 }); + assert_eq!(pl, ParaList { list: N, indent: 1, start_offset: 0, ..ParaList::default() }); assert_eq!(serde_json::to_string(&pl).unwrap(), old); // One numbered paragraph with a start keeps it (it doesn't fold into `list`). let mut t = TextData { characters: "Next".into(), ..Default::default() }; - t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start_offset: 1 }); - assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start_offset: 1 }); + t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start_offset: 1, ..ParaList::default() }); + assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start_offset: 1, ..ParaList::default() }); // Enter copies it to the new item; different starts don't make the list mixed. t.replace_range(4, 4, "\nMore"); assert_eq!(t.para_list(1).start_offset, 1); t.set_para_lists(5, 5, |p| p.start_offset = 0); assert!(t.common_para_list(0, t.char_count()).is_some_and(|p| p.list == N)); // Bullets have no number, so no start. - t.set_para_lists(0, 0, |p| *p = ParaList { list: B, indent: 1, start_offset: 4 }); - assert_eq!(t.para_list(0), ParaList { list: B, indent: 1, start_offset: 0 }); + t.set_para_lists(0, 0, |p| *p = ParaList { list: B, indent: 1, start_offset: 4, ..ParaList::default() }); + assert_eq!(t.para_list(0), ParaList { list: B, indent: 1, start_offset: 0, ..ParaList::default() }); // A plain paragraph has none. t.set_para_lists(0, 0, |p| p.list = P); assert_eq!(t.para_list(0), ParaList::default()); diff --git a/crates/engine/src/automation/inspect.rs b/crates/engine/src/automation/inspect.rs index 871dbea6f..324635071 100644 --- a/crates/engine/src/automation/inspect.rs +++ b/crates/engine/src/automation/inspect.rs @@ -53,6 +53,7 @@ pub const NODE_KEYS: &[&str] = &[ "layout", "leadingTrim", "letterSpacing", + "lineDirections", "lineHeight", "lineIndentations", "lineTypes", @@ -96,6 +97,7 @@ pub const NODE_KEYS: &[&str] = &[ "textDecorationSkipInk", "textDecorationStyle", "textDecorationThickness", + "textDirection", "textPath", "textStyleRanges", "type", @@ -432,6 +434,16 @@ fn text_props(t: &TextData, o: &mut serde_json::Map, options: &In insert_prop(o, options, "listStartOffsets", || t.paragraphs.iter().map(|p| json!(p.start_offset)).collect()); } } + // The paragraphs' base direction: one for the layer when they agree, else each one's (Auto + // throughout says nothing). + let dirs: Vec = t.paragraphs.iter().map(|p| p.direction).collect(); + if let Some(first) = dirs.first().copied().filter(|d| *d != figcraft_doc::TextDirection::Auto) + && dirs.iter().all(|d| *d == first) + { + insert_prop(o, options, "textDirection", || json!(first)); + } else if dirs.iter().any(|d| *d != figcraft_doc::TextDirection::Auto) { + insert_prop(o, options, "lineDirections", || dirs.iter().map(|d| json!(d)).collect()); + } if t.list_spacing != 0.0 { insert_prop(o, options, "listSpacing", || json!(t.list_spacing)); } diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 62236b8e7..a8a18b38c 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -216,6 +216,11 @@ pub static MENU: &[MenuItem] = &[ m!("Text/Case", "Uppercase", "text.caseUpper"), m!("Text/Case", "Lowercase", "text.caseLower"), m!("Text/Case", "Title case", "text.caseTitle"), + // "Go to Text > Text direction. Select Left to right or Right to left." (Add right-to-left + // text); Auto, the way back to detection, is FigCraft's own (in `OWN`). + m!("Text/Text direction", "Auto", "text.directionAuto"), + m!("Text/Text direction", "Left to right", "text.directionLtr"), + m!("Text/Text direction", "Right to left", "text.directionRtl"), m!("Text", SEP, ""), m!("Text", "Missing fonts…", "text.missingFonts"), // Arrange @@ -292,7 +297,7 @@ pub fn items() -> impl Iterator { } /// Items of FigCraft's own that the parity count leaves out (as it does the `collab.` ones). -const OWN: [&str; 5] = ["file.newFromTemplate", "file.newBoard", "help.gettingStarted", "text.missingFonts", "image.crop"]; +const OWN: [&str; 6] = ["file.newFromTemplate", "file.newBoard", "help.gettingStarted", "text.missingFonts", "image.crop", "text.directionAuto"]; /// Coverage: (implemented, total, missing ids). `extra` lists ids implemented by the UI layer. pub fn parity(extra: &[&str]) -> (usize, usize, Vec<&'static str>) { diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index abd380cfc..de47105df 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -956,6 +956,12 @@ fn apply_text(cmd: &str, doc: &mut Document, id: NodeId, props: &Map { + let d: figcraft_doc::TextDirection = enum_of(v).ok_or_else(|| bad(cmd, "textDirection: auto|ltr|rtl"))?; + let t = text_mut(cmd, doc.get_mut(id)?)?; + let (start, end) = range_of(props, t.char_count()); + t.set_para_lists(start, end, |p| p.direction = d); + } "listSpacing" => text_mut(cmd, doc.get_mut(id)?)?.list_spacing = f64_of(v).filter(|s| s.is_finite()).unwrap_or(0.0).clamp(0.0, 10_000.0), "hangingPunctuation" | "hangingList" => { let on = v.as_bool().ok_or_else(|| bad(cmd, format!("`{k}` must be true or false")))?; @@ -1210,7 +1216,7 @@ pub fn override_group(k: &str) -> &'static str { "name" => "name", "characters" | "text" => "characters", "features" | "fontFeatures" | "openTypeFeatures" | "listStyle" | "indentation" | "listSpacing" | "maxLines" | "variationSettings" - | "hangingPunctuation" | "hangingList" | "wrapStyle" | "textWrap" => "textStyle", + | "hangingPunctuation" | "hangingList" | "wrapStyle" | "textWrap" | "textDirection" | "direction" => "textStyle", "cornerRadius" | "cornerSmoothing" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => "corners", "width" | "height" | "w" | "h" => "size", "sizingH" | "sizingV" | "layoutSizingHorizontal" | "layoutSizingVertical" | "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => "resizing", diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 7914f259e..290faf913 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -1,8 +1,8 @@ //! Text menu and text editing commands. use figcraft_doc::{ - LetterSpacing, LineHeight, ListStyle, NodeId, NodeKind, TextAlignH, TextAutoResize, TextCase, TextData, TextDecoration, TextPath, TextStyle, - VariableId, + LetterSpacing, LineHeight, ListStyle, NodeId, NodeKind, TextAlignH, TextAutoResize, TextCase, TextData, TextDecoration, TextDirection, TextPath, + TextStyle, VariableId, }; use serde_json::{Map, Value, json}; @@ -91,6 +91,13 @@ fn set_list(s: &mut Session, p: &Value, l: ListStyle, toggle: bool) -> Result Result { + para_range(s, p, |t, a, b| t.set_para_lists(a, b, |pl| pl.direction = d)) +} + /// Whether every targeted char has `pred` (to decide toggle direction). fn all_have(s: &Session, p: &Value, pred: impl Fn(&TextStyle) -> bool) -> bool { let Ok(doc) = s.doc() else { return false }; @@ -319,6 +326,26 @@ pub fn specs() -> Vec { para(s, p, |t| t.wrap_style = w) } ), + spec!( + "text.direction", + "Text direction", + "{direction: auto|ltr|rtl, ids?} the base direction of the paragraphs (while editing: the selected ones): auto goes by the first letter with a direction, ltr runs them left to right and rtl right to left whatever their letters (Text ▸ Text direction)", + true, + |s, p| { + let d: TextDirection = + get(p, "direction").and_then(super::props::enum_of).ok_or_else(|| bad("text.direction", "`direction`: auto|ltr|rtl"))?; + set_direction(s, p, d) + } + ), + spec!("text.directionAuto", "Text direction: auto", "{ids?} while editing: the selected paragraphs", true, |s, p| { + set_direction(s, p, TextDirection::Auto) + }), + spec!("text.directionLtr", "Text direction: left to right", "{ids?} while editing: the selected paragraphs", true, |s, p| { + set_direction(s, p, TextDirection::Ltr) + }), + spec!("text.directionRtl", "Text direction: right to left", "{ids?} while editing: the selected paragraphs", true, |s, p| { + set_direction(s, p, TextDirection::Rtl) + }), spec!("text.paragraphIndent", "Paragraph indent", "{indent (px), ids?}", true, |s, p| { let v = finite(req_num("text.paragraphIndent", p, "indent")?).clamp(0.0, 10_000.0); para(s, p, |t| t.paragraph_indent = v) diff --git a/crates/engine/src/dev/codegen/css.rs b/crates/engine/src/dev/codegen/css.rs index 2395c7873..c772367fd 100644 --- a/crates/engine/src/dev/codegen/css.rs +++ b/crates/engine/src/dev/codegen/css.rs @@ -307,6 +307,13 @@ fn text_decls(doc: &Document, n: &Node, tx: &figcraft_doc::TextData, t: &mut Tok figcraft_doc::WrapStyle::Balance => put("text-wrap", "balance".into()), figcraft_doc::WrapStyle::Pretty => put("text-wrap", "pretty".into()), } + // A base direction forced on every paragraph (CSS has one per element). + let dir = tx.paragraphs.first().map(|p| p.direction).filter(|d| tx.paragraphs.iter().all(|p| p.direction == *d)); + match dir { + Some(figcraft_doc::TextDirection::Ltr) => put("direction", "ltr".into()), + Some(figcraft_doc::TextDirection::Rtl) => put("direction", "rtl".into()), + _ => {} + } match s.case { TextCase::Original => {} TextCase::Upper => put("text-transform", "uppercase".into()), diff --git a/crates/engine/src/text_behaviour_tests.rs b/crates/engine/src/text_behaviour_tests.rs index 1e700c688..6db19a1cc 100644 --- a/crates/engine/src/text_behaviour_tests.rs +++ b/crates/engine/src/text_behaviour_tests.rs @@ -371,6 +371,57 @@ fn max_lines_on_auto_height_text_hugs_that_many_lines() { assert_eq!(resize_mode(&s, fixed), TextAutoResize::Truncate); } +#[test] +fn text_direction_is_forced_per_paragraph() { + // Add right-to-left text: "By default, Figma automatically handles text direction based on + // language detection. However, controls are available to override text direction per + // paragraph" ("Go to Text > Text direction. Select Left to right or Right to left."). + let font = test_mono_font(); + let mut s = Session::new(); + let t = text(&mut s, "abc def\nghi\njkl", json!({"fontFamily": font, "fontSize": 16, "lineHeight": 24})); + let rtl = |s: &Session| -> Vec { + let (l, _) = figcraft_text::layout_node(s.doc().unwrap().node(t).unwrap()).unwrap(); + l.lines.iter().map(|l| l.rtl).collect() + }; + let inspect = |s: &Session| crate::inspect::node_json(s.doc().unwrap(), t, 0); + assert_eq!(rtl(&s), [false, false, false], "Latin letters read left to right"); + assert!(inspect(&s).get("textDirection").is_none() && inspect(&s).get("lineDirections").is_none()); + // The layer's paragraphs at once, by the command or `node.set`; inspect reports the one + // direction they share, and Dev Mode's CSS has it. + s.execute("text.direction", &json!({"ids": [t], "direction": "rtl"})).unwrap(); + assert_eq!(rtl(&s), [true, true, true], "forced right to left whatever the letters"); + assert_eq!(inspect(&s)["textDirection"], json!("rtl")); + let css = |s: &mut Session| s.execute("inspect.code", &json!({"id": t})).unwrap()["code"].as_str().unwrap().to_string(); + assert!(css(&mut s).contains("direction: rtl;"), "{}", css(&mut s)); + s.execute("node.set", &json!({"id": t, "props": {"textDirection": "ltr"}})).unwrap(); + assert_eq!(rtl(&s), [false, false, false]); + assert_eq!(inspect(&s)["textDirection"], json!("ltr")); + assert!(css(&mut s).contains("direction: ltr;")); + s.execute("text.directionAuto", &json!({"ids": [t]})).unwrap(); + assert!(inspect(&s).get("textDirection").is_none(), "auto throughout says nothing"); + assert!(!css(&mut s).contains("direction:")); + // While editing, the caret's paragraph alone (the menu's Right to left); inspect lists each + // paragraph's direction when they differ, and the CSS stays quiet. + s.execute("text.edit.begin", &json!({"id": t, "caret": 9})).unwrap(); + s.execute("text.directionRtl", &json!({})).unwrap(); + s.execute("text.edit.end", &json!({})).unwrap(); + assert_eq!(rtl(&s), [false, true, false], "the second paragraph only"); + assert_eq!(inspect(&s)["lineDirections"], json!(["auto", "rtl", "auto"])); + assert!(!css(&mut s).contains("direction:")); + // Enter inside a forced paragraph keeps the direction for the new one, as list settings carry. + s.execute("text.edit.begin", &json!({"id": t, "caret": 10})).unwrap(); + s.execute("text.edit.insert", &json!({"text": "\n"})).unwrap(); + s.execute("text.edit.end", &json!({})).unwrap(); + assert_eq!(rtl(&s), [false, true, true, false]); + // A direction the document has words for; junk is refused. + s.execute("node.set", &json!({"id": t, "props": {"characters": "\u{05E9}\u{05DC}\u{05D5}\u{05DD} abc", "textDirection": "auto"}})).unwrap(); + assert_eq!(rtl(&s), [true], "Hebrew first: right to left by detection"); + s.execute("node.set", &json!({"id": t, "props": {"textDirection": "ltr"}})).unwrap(); + assert_eq!(rtl(&s), [false], "forced left to right"); + assert!(s.execute("node.set", &json!({"id": t, "props": {"textDirection": "sideways"}})).is_err()); + assert!(s.execute("text.direction", &json!({"ids": [t], "direction": "up"})).is_err()); +} + #[test] fn balance_and_pretty_wrap_styles_break_lines_as_documented() { // Explore text properties: "Auto: Figma fits as many words as possible on each line before diff --git a/crates/engine/src/text_tail_tests.rs b/crates/engine/src/text_tail_tests.rs index 8e399ba3a..ccd8328ff 100644 --- a/crates/engine/src/text_tail_tests.rs +++ b/crates/engine/src/text_tail_tests.rs @@ -64,7 +64,7 @@ fn a_list_with_a_start_number_toggles_off() { let mut s = Session::new(); let t = text(&mut s, "two\nthree\nfour"); if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind { - let n = |start_offset| ParaList { list: N, indent: 0, start_offset }; + let n = |start_offset| ParaList { list: N, indent: 0, start_offset, ..ParaList::default() }; td.paragraphs = vec![n(1), n(0), n(0)]; } let j = crate::inspect::node_json(s.doc().unwrap(), t, 0); @@ -157,7 +157,8 @@ fn list_keys_at_list_items() { for (chars, before, caret, k, shift, after, want, why) in cases { let mut s = Session::new(); let t = text(&mut s, chars); - let ps: Vec = before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent, start_offset: 0 }).collect(); + let ps: Vec = + before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent, start_offset: 0, ..ParaList::default() }).collect(); if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind { td.paragraphs = ps; } @@ -181,7 +182,11 @@ fn paragraph_lists_follow_edits() { // A paragraph's list stays with it as text is typed, pasted and deleted: new paragraphs copy // the one they split from; joined paragraphs keep the first one's. let mut t = TextData::new("ab\ncd\nef"); - t.paragraphs = vec![ParaList { list: B, indent: 1, start_offset: 0 }, ParaList::default(), ParaList { list: N, indent: 0, start_offset: 0 }]; + t.paragraphs = vec![ + ParaList { list: B, indent: 1, start_offset: 0, ..ParaList::default() }, + ParaList::default(), + ParaList { list: N, indent: 0, start_offset: 0, ..ParaList::default() }, + ]; let get = |t: &TextData| (0..t.paragraph_count()).map(|p| (t.para_list(p).list, t.para_list(p).indent)).collect::>(); t.replace_range(1, 1, "x\ny"); assert_eq!(get(&t), [(B, 1), (B, 1), (P, 0), (N, 0)], "a pasted newline: a new item like it"); @@ -196,7 +201,7 @@ fn paragraph_lists_follow_edits() { (seed >> 33) as usize % n.max(1) }; let mut t = TextData::new("a\nb\nc\nd"); - t.paragraphs = vec![ParaList { list: B, indent: 2, start_offset: 0 }; 4]; + t.paragraphs = vec![ParaList { list: B, indent: 2, start_offset: 0, ..ParaList::default() }; 4]; for _ in 0..500 { let n = t.char_count(); let (a, b) = (rnd(n + 2), rnd(n + 2)); diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index 9e2c4b364..d42679324 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -27,8 +27,8 @@ use figcraft_doc::proto::{ use figcraft_doc::{ AutoLayout, BoolOp, ContentAlign, CrossAlign, Document, EffectKind, ExportSize, GridAlign, GridKind, GridTrack, HConstraint, LayoutMode, LetterSpacing, LineHeight, ListStyle, MainAlign, MaskType, Node, NodeId, NodeKind, NoiseMode, PropDef, Sizing, StrokeAlign, StrokeCap, - StrokeJoin, StyleKind, TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, VConstraint, VariableType, - VariableValue, VectorNetwork, WindingRule, + StrokeJoin, StyleKind, TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextDirection, TextStyle, VConstraint, + VariableType, VariableValue, VectorNetwork, WindingRule, }; use serde_json::Value; use sha1::Digest; diff --git a/crates/format/src/fig/export/shape.rs b/crates/format/src/fig/export/shape.rs index e4e2cae6b..69228a0cc 100644 --- a/crates/format/src/fig/export/shape.rs +++ b/crates/format/src/fig/export/shape.rs @@ -1,8 +1,8 @@ use super::common::{style_fields, unsupported_decoration_look}; use super::{ Affine, BezPath, Bound, Exporter, Fields, Kv, ListStyle, MAX_CANVAS, MAX_CHARS, MAX_NETWORK, Node, NodeKind, Paint, StrokeAlign, StrokeCap, - StrokeJoin, TextAlignH, TextAlignV, TextAutoResize, TextData, TextStyle, VectorNetwork, WindingRule, arr, b, cap_name, e, finite, fl, int, - join_name, network, o, path_blob, s, u, xy, + StrokeJoin, TextAlignH, TextAlignV, TextAutoResize, TextData, TextDirection, TextStyle, VectorNetwork, WindingRule, arr, b, cap_name, e, finite, + fl, int, join_name, network, o, path_blob, s, u, xy, }; impl<'d> Exporter<'d> { @@ -128,11 +128,16 @@ impl<'d> Exporter<'d> { let listed = *l != ListStyle::None; let begins = first(i, l); let start = starts.get(i).copied().unwrap_or(0); + let direction = match td.para_list(i).direction { + TextDirection::Auto => "AUTO", + TextDirection::Ltr => "LTR", + TextDirection::Rtl => "RTL", + }; o(vec![ ("lineType", e(ty)), ("styleId", int(0)), ("indentationLevel", int(i64::from(listed))), - ("sourceDirectionality", e("AUTO")), + ("sourceDirectionality", e(direction)), ("listStartOffset", int(i64::from(start))), ("isFirstLineOfList", b(begins)), ]) diff --git a/crates/format/src/fig/map/text.rs b/crates/format/src/fig/map/text.rs index 13bed7440..0249b1086 100644 --- a/crates/format/src/fig/map/text.rs +++ b/crates/format/src/fig/map/text.rs @@ -130,6 +130,18 @@ impl<'a> Mapper<'a> { if lines.iter().any(|l| *l != "NONE") { o.insert("lineTypes".into(), json!(lines)); } + // A forced base direction per paragraph (`sourceDirectionality`; not a REST field). + let dirs: Vec<&str> = raw + .iter() + .map(|l| match l.get("sourceDirectionality").and_then(Kv::str) { + Some("LTR") => "LTR", + Some("RTL") => "RTL", + _ => "AUTO", + }) + .collect(); + if dirs.iter().any(|d| *d != "AUTO") { + o.insert("lineDirections".into(), json!(dirs)); + } // Where a numbered list starts counting, less one (not a REST field). let starts: Vec = raw.iter().map(|l| num(l, "listStartOffset").filter(|n| n.is_finite()).map_or(0, |n| n.clamp(0.0, f64::from(u32::MAX)) as u32)).collect(); diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 2d4a1f7d6..8833727ee 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -1491,6 +1491,9 @@ mod image_filters; #[path = "text_list_tests.rs"] mod text_list; +#[path = "text_direction_tests.rs"] +mod text_direction; + /// `.fig` files in the folder `FIGCRAFT_FIG_SAMPLES` names (none when it's unset). pub(super) fn samples() -> Vec { let Some(dir) = std::env::var_os("FIGCRAFT_FIG_SAMPLES") else { return Vec::new() }; diff --git a/crates/format/src/fig/text_direction_tests.rs b/crates/format/src/fig/text_direction_tests.rs new file mode 100644 index 000000000..a3647946d --- /dev/null +++ b/crates/format/src/fig/text_direction_tests.rs @@ -0,0 +1,83 @@ +//! A paragraph's forced base direction (#1830): each text line's `sourceDirectionality` (AUTO, +//! LTR or RTL) comes in as the paragraph's direction and goes back out. Fixtures built in code with +//! the parent module's helpers. + +use figcraft_doc::{NodeKind, TextDirection}; + +use super::*; + +const DIRECTION_SCHEMA: &str = " +enum NodeType NONE DOCUMENT CANVAS TEXT +enum LineType PLAIN ORDERED_LIST UNORDERED_LIST +enum SourceDirectionality AUTO LTR RTL +struct GUID uint sessionID, uint localID +struct Vector float x, float y +struct Matrix float m00, float m01, float m02, float m10, float m11, float m12 +message ParentIndex GUID guid, string position +message TextLineData LineType lineType, int indentationLevel, SourceDirectionality sourceDirectionality, int listStartOffset, bool isFirstLineOfList +message TextData string characters, TextLineData[] lines +message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, float fontSize, TextData textData +message Message NodeChange[] nodeChanges +"; + +/// A plain line with a base direction. +fn line(direction: &'static str) -> Kv<'static> { + o(vec![ + ("lineType", e("PLAIN")), + ("indentationLevel", Kv::Int(0)), + ("sourceDirectionality", e(direction)), + ("listStartOffset", Kv::Int(0)), + ("isFirstLineOfList", Kv::Bool(false)), + ]) +} + +fn text_node(id: u64, name: &str, chars: &str, lines: Vec>) -> Kv<'static> { + let pos = ["!", "\"", "#", "$"][id as usize % 4]; + node( + id, + Some((1, pos)), + "TEXT", + name, + vec![ + ("size", xy(200.0, 60.0)), + ("transform", at(0.0, 0.0)), + ("fontSize", f(16.0)), + ("textData", o(vec![("characters", s(chars)), ("lines", arr(lines))])), + ], + ) +} + +fn directions(doc: &figcraft_doc::Document, name: &str) -> Vec { + let NodeKind::Text(t) = &named(doc, name).kind else { panic!("{name} is no text") }; + (0..t.paragraph_count()).map(|p| t.para_list(p).direction).collect() +} + +#[test] +fn a_lines_source_directionality_is_its_paragraphs_direction_both_ways() { + use TextDirection::{Auto as A, Ltr as L, Rtl as R}; + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page", vec![]), + text_node(2, "Detected", "abc\ndef", vec![line("AUTO"), line("AUTO")]), + text_node(3, "Forced", "abc\ndef\nghi", vec![line("RTL"), line("AUTO"), line("LTR")]), + ]; + let sc = Schema::parse(DIRECTION_SCHEMA).unwrap(); + let (doc, warnings) = import(&canvas(&sc, &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(directions(&doc, "Detected"), [A, A]); + let NodeKind::Text(t) = &named(&doc, "Detected").kind else { panic!() }; + assert!(t.paragraphs.is_empty(), "AUTO throughout keeps the paragraphs folded"); + assert_eq!(directions(&doc, "Forced"), [R, A, L]); + // Out again: every line says its direction, and a reader gets the same paragraphs back. + let (bytes, _) = super::super::export(&doc, None).unwrap(); + let a = Archive::open(&bytes).unwrap(); + let (schema, message) = decode_canvas(&a.canvas).unwrap(); + let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap(); + let text = msg.get("nodeChanges").unwrap().list().iter().find(|n| n.get("name").and_then(Kv::str) == Some("Forced")).unwrap(); + let lines = text.get("textData").and_then(|d| d.get("lines")).map(Kv::list).unwrap(); + let out: Vec<&str> = lines.iter().map(|l| l.get("sourceDirectionality").and_then(Kv::str).unwrap()).collect(); + assert_eq!(out, ["RTL", "AUTO", "LTR"]); + let (back, _) = import(&bytes, "x").unwrap(); + assert_eq!(directions(&back, "Forced"), [R, A, L]); + assert_eq!(directions(&back, "Detected"), [A, A]); +} diff --git a/crates/format/src/fig/text_list_tests.rs b/crates/format/src/fig/text_list_tests.rs index eca7b0dc5..a2d6702bc 100644 --- a/crates/format/src/fig/text_list_tests.rs +++ b/crates/format/src/fig/text_list_tests.rs @@ -75,7 +75,7 @@ fn the_export_writes_where_a_list_starts() { let page = doc.first_page().unwrap(); use ListStyle::{Bulleted as B, None as P, Numbered as N}; let mut td = figcraft_doc::TextData::new("two\nthree\nplain\nsix\ndot"); - let pl = |list, start_offset| ParaList { list, indent: 0, start_offset }; + let pl = |list, start_offset| ParaList { list, indent: 0, start_offset, ..ParaList::default() }; td.paragraphs = vec![pl(N, 1), pl(N, 0), pl(P, 0), pl(N, 5), pl(B, 4)]; doc.create(page, NodeKind::Text(td), "List", 0.0, 0.0, 200.0, 80.0).unwrap(); let (bytes, _) = super::super::export(&doc, None).unwrap(); diff --git a/crates/format/src/rest/text.rs b/crates/format/src/rest/text.rs index 83055dc10..ca5c51d15 100644 --- a/crates/format/src/rest/text.rs +++ b/crates/format/src/rest/text.rs @@ -5,7 +5,7 @@ use std::collections::BTreeMap; use figcraft_doc::{ DecorationLength, DecorationStyle, LetterSpacing, LineHeight, ListStyle, ParaList, StyleRun, TextAlignH, TextAlignV, TextAutoResize, TextCase, - TextData, TextDecoration, TextPath, TextStyle, + TextData, TextDecoration, TextDirection, TextPath, TextStyle, }; use serde_json::Value; @@ -256,23 +256,27 @@ fn runs(cx: &mut Ctx, v: &Value, td: &TextData) -> Vec { out } -/// Each paragraph's list type, nesting level and start number from `lineTypes`, -/// `lineIndentations` and (from `.fig` files) `listStartOffsets`. +/// Each paragraph's list type, nesting level, start number and base direction from `lineTypes`, +/// `lineIndentations` and (from `.fig` files) `listStartOffsets` and `lineDirections`. fn lists(v: &Value, td: &mut TextData) { let types = list(v, "lineTypes"); let indents = list(v, "lineIndentations"); let starts = list(v, "listStartOffsets"); - td.paragraphs = types - .iter() - .enumerate() - .map(|(i, t)| ParaList { - list: match t.as_str() { + let dirs = list(v, "lineDirections"); + td.paragraphs = (0..types.len().max(dirs.len())) + .map(|i| ParaList { + list: match types.get(i).and_then(Value::as_str) { Some("UNORDERED") => ListStyle::Bulleted, Some("ORDERED") => ListStyle::Numbered, _ => ListStyle::None, }, indent: indents.get(i).and_then(Value::as_f64).filter(|d| d.is_finite()).map_or(0, |d| d.clamp(0.0, 255.0) as u8), start_offset: starts.get(i).and_then(Value::as_f64).filter(|n| n.is_finite()).map_or(0, |n| n.clamp(0.0, f64::from(u32::MAX)) as u32), + direction: match dirs.get(i).and_then(Value::as_str) { + Some("LTR") => TextDirection::Ltr, + Some("RTL") => TextDirection::Rtl, + _ => TextDirection::Auto, + }, }) .collect(); // Clamp the levels and fold one style for all back into the layer's list style. diff --git a/crates/text/src/behaviour_tests.rs b/crates/text/src/behaviour_tests.rs index 439a81fcf..731e639d4 100644 --- a/crates/text/src/behaviour_tests.rs +++ b/crates/text/src/behaviour_tests.rs @@ -109,7 +109,7 @@ fn a_plain_paragraph_at_a_list_level_starts_where_the_items_text_does() { if face().is_none() { return; } - let level = |list: ListStyle, indent: u8| ParaList { list, indent, start_offset: 0 }; + let level = |list: ListStyle, indent: u8| ParaList { list, indent, start_offset: 0, ..ParaList::default() }; let mut t = text("a\nb\nc", TextAutoResize::WidthAndHeight); t.paragraphs = vec![level(ListStyle::Bulleted, 1), level(ListStyle::None, 1), level(ListStyle::None, 0)]; let l = layout(&t, None, None); diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index dd728916b..732e59ec7 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use figcraft_doc::{ DecorationLength, DecorationStyle, LeadingTrim, LetterSpacing, LineHeight, ListStyle, ParaList, TextAlignH, TextAutoResize, TextCase, TextData, - TextDecoration, TextStyle, WrapStyle, color::PaintKind, valid_feature_tag, + TextDecoration, TextDirection, TextStyle, WrapStyle, color::PaintKind, valid_feature_tag, }; use figcraft_geom::{Affine, BezPath, Rect}; use harfrust::{Direction, Feature, ShapeOptions, ShaperInstance, Tag, UnicodeBuffer, Variation}; @@ -272,21 +272,29 @@ pub(crate) struct Bidi { } impl Bidi { - /// None when the text has nothing right to left (the common case costs one scan). - pub(crate) fn of(text: &str) -> Option { - if !text.chars().any(is_rtl_char) { + /// None when the text has nothing right to left and no paragraph forces its direction (the + /// common case costs one scan). `forced`, by paragraph: `Some(true)` runs right to left and + /// `Some(false)` left to right whatever the letters, `None` goes by the first letter with a + /// direction (Text ▸ Text direction, #1830). + pub(crate) fn of(text: &str, forced: &[Option]) -> Option { + if forced.iter().all(Option::is_none) && !text.chars().any(is_rtl_char) { return None; } - let info = unicode_bidi::BidiInfo::new(text, None); - let mut paras = info.paragraphs.iter().peekable(); let n = text.chars().count(); let (mut levels, mut rtl_para) = (Vec::with_capacity(n), Vec::with_capacity(n)); - for (b, _) in text.char_indices() { - while paras.peek().is_some_and(|p| p.range.end <= b) { - paras.next(); + // Paragraph by paragraph (the algorithm starts over at each one), so each can be given its + // base level. + for (p, para) in text.split_inclusive('\n').enumerate() { + let base = forced.get(p).copied().flatten().map(|rtl| if rtl { unicode_bidi::Level::rtl() } else { unicode_bidi::Level::ltr() }); + let info = unicode_bidi::BidiInfo::new(para, base); + let mut paras = info.paragraphs.iter().peekable(); + for (b, _) in para.char_indices() { + while paras.peek().is_some_and(|q| q.range.end <= b) { + paras.next(); + } + levels.push(info.levels.get(b).map(|l| l.number()).unwrap_or(0)); + rtl_para.push(paras.peek().is_some_and(|q| q.level.is_rtl())); } - levels.push(info.levels.get(b).map(|l| l.number()).unwrap_or(0)); - rtl_para.push(paras.peek().is_some_and(|p| p.level.is_rtl())); } Some(Bidi { levels, rtl_para }) } @@ -479,8 +487,20 @@ fn shape_text(text: &TextData) -> ShapedText { .filter_map(|(k, on)| <[u8; 4]>::try_from(k.as_bytes()).ok().map(|t| Feature::new(Tag::new(&t), u32::from(*on), ..))) .collect(); - // Shape each span, split by paragraph, bidi run and fallback face. - let bidi = Bidi::of(&text.characters); + // Shape each span, split by paragraph, bidi run and fallback face. A paragraph's direction is + // its letters' unless forced (Text ▸ Text direction). + let forced: Vec> = if text.paragraphs.is_empty() { + Vec::new() + } else { + (0..text.paragraph_count()) + .map(|p| match text.para_list(p).direction { + TextDirection::Auto => None, + TextDirection::Ltr => Some(false), + TextDirection::Rtl => Some(true), + }) + .collect() + }; + let bidi = Bidi::of(&text.characters, &forced); let level_at = |i: usize| bidi.as_ref().and_then(|b| b.levels.get(i)).copied().unwrap_or(0); // A grapheme cluster (emoji sequence, letter with marks) is shaped with one face. let cluster_start = crate::breaks::grapheme_starts(&text.characters); diff --git a/crates/text/src/tail_tests.rs b/crates/text/src/tail_tests.rs index 805f744d8..035e1912a 100644 --- a/crates/text/src/tail_tests.rs +++ b/crates/text/src/tail_tests.rs @@ -43,11 +43,11 @@ fn starts(l: &TextLayout) -> Vec<(f64, Option)> { } fn pl(list: ListStyle, indent: u8) -> ParaList { - ParaList { list, indent, start_offset: 0 } + ParaList { list, indent, start_offset: 0, ..ParaList::default() } } fn from(list: ListStyle, indent: u8, start_offset: u32) -> ParaList { - ParaList { list, indent, start_offset } + ParaList { list, indent, start_offset, ..ParaList::default() } } #[test] diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 6d341419c..e38963855 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -406,14 +406,36 @@ fn bidi_visual_order_follows_levels() { assert_eq!(crate::layout::visual_order(&[0, 0, 0]), vec![0, 1, 2]); assert!(crate::layout::visual_order(&[]).is_empty()); // Plain left-to-right text skips the analysis. - assert!(crate::layout::Bidi::of("Hello, world 123").is_none()); - let b = crate::layout::Bidi::of("abc \u{5D0}\u{5D1}\u{5D2}\n\u{5D3} x").unwrap(); + assert!(crate::layout::Bidi::of("Hello, world 123", &[]).is_none()); + assert!(crate::layout::Bidi::of("Hello, world 123", &[None, None]).is_none()); + let b = crate::layout::Bidi::of("abc \u{5D0}\u{5D1}\u{5D2}\n\u{5D3} x", &[]).unwrap(); assert_eq!(b.levels.get(..4), Some(&[0, 0, 0, 0][..])); assert!(b.levels.get(4..7).unwrap().iter().all(|l| l % 2 == 1)); // Paragraph direction comes from each paragraph's first strong char. assert!(!b.rtl_para[0] && b.rtl_para[8]); } +/// A paragraph's forced base direction (Text ▸ Text direction, #1830): the base level is given, +/// the letters' own levels still follow the rules, and the other paragraphs keep theirs. +#[test] +fn forced_direction_sets_a_paragraphs_base_level() { + use crate::layout::Bidi; + // Latin letters forced right to left: an embedded left-to-right run (level 2) in a + // right-to-left paragraph; the next paragraph, unforced, stays left to right. + let b = Bidi::of("ab\ncd", &[Some(true), None]).unwrap(); + assert_eq!(b.rtl_para, [true, true, true, false, false]); + assert_eq!((b.levels.get(..2), b.levels.get(3..)), (Some(&[2, 2][..]), Some(&[0, 0][..]))); + // Hebrew forced left to right: a right-to-left run (level 1) in a left-to-right paragraph. + let b = Bidi::of("\u{5D0}\u{5D1}", &[Some(false)]).unwrap(); + assert_eq!(b.rtl_para, [false, false]); + assert_eq!(b.levels, [1, 1]); + // Forcing what the letters already say changes nothing; a paragraph past the forced list is + // detected. + let b = Bidi::of("\u{5D0}\n\u{5D1}", &[Some(true)]).unwrap(); + assert_eq!(b.rtl_para, [true, true, true]); + assert_eq!((b.levels.first(), b.levels.last()), (Some(&1), Some(&1))); +} + /// The x of the glyph for char `i`. fn glyph_x(l: &TextLayout, i: usize) -> f64 { l.glyphs.iter().find(|g| g.char_start == i && g.char_end > i).map(|g| g.x).unwrap() diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 11f0bb4ac..8b83d8c85 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -407,6 +407,10 @@ name = "English" "Decoration" = "Decoration" "Deep select" = "Deep select" "Wrap style" = "Wrap style" +"Text direction" = "Text direction" +"Auto: by the first letter with a direction" = "Auto: by the first letter with a direction" +"Left to right" = "Left to right" +"Right to left" = "Right to left" "Balance" = "Balance" "Pretty" = "Pretty" "Auto: as many words as fit on each line" = "Auto: as many words as fit on each line" diff --git a/crates/ui-egui/src/panels/typography.rs b/crates/ui-egui/src/panels/typography.rs index 0f41fd2e3..a3a5bd66a 100644 --- a/crates/ui-egui/src/panels/typography.rs +++ b/crates/ui-egui/src/panels/typography.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use egui::{Align2, Color32, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use figcraft_doc::{ DecorationLength, Document, LeadingTrim, LetterSpacing, LineHeight, ListStyle, NodeId, NodeKind, TextAlignH, TextAlignV, TextAutoResize, - TextCase, TextData, TextDecoration, TextStyle, VariableType, trim_num, + TextCase, TextData, TextDecoration, TextDirection, TextStyle, VariableType, trim_num, }; use serde_json::{Value, json}; diff --git a/crates/ui-egui/src/panels/typography/fields.rs b/crates/ui-egui/src/panels/typography/fields.rs index 20b9d8e90..c290e9893 100644 --- a/crates/ui-egui/src/panels/typography/fields.rs +++ b/crates/ui-egui/src/panels/typography/fields.rs @@ -4,8 +4,9 @@ use super::picker::{font_picker, remember_family}; use super::spacing::{SpacingField, commit_spacing, parse_letter_spacing, parse_line_height, spacing_field, spacing_input}; use super::{ Arc, DecorationLength, Document, FEATURES, FIELD_H, FieldEdit, FigApp, Icon, Id, Lead, LeadingTrim, ListStyle, MIXED, NodeId, SIZES, Sense, TABS, - TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, Tokens, Ui, Value, VariableType, common, field_row, icons, - json, live, menu_item, menu_popup, regular, row, semibold, text_style_params, tr, tr_noop, trf, trim_num, uniform, vec2, widgets, + TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextDirection, TextStyle, Tokens, Ui, Value, VariableType, common, + field_row, icons, json, live, menu_item, menu_popup, regular, row, semibold, text_style_params, tr, tr_noop, trf, trim_num, uniform, vec2, + widgets, }; /// The font rows: family, style and size, line height and letter spacing. @@ -397,6 +398,31 @@ fn type_settings( let style = ["auto", "balance", "pretty"].get(i).copied().unwrap_or("auto"); app.set_props(ids, json!({"wrapStyle": style})); } + // The paragraphs' base direction, once a right-to-left script is in the text or a direction + // is forced ("controls are available to override text direction per paragraph", Add + // right-to-left text). While editing: the selected paragraphs. + let has_direction = + |t: &TextData| figcraft_text::display::has_rtl(&t.characters) || t.paragraphs.iter().any(|p| p.direction != TextDirection::Auto); + if ids.iter().any(|i| doc.node(*i).and_then(|n| n.kind.text()).is_some_and(has_direction)) { + caption(ui, tr("Text direction")); + let dir = common(doc, ids, |n| { + n.kind.text().and_then(|t| { + let (a, b) = range.filter(|r| r.0 == n.id).map_or((0, t.char_count()), |(_, a, b)| (a, b)); + let (p0, p1) = t.para_span(a, b); + let first = t.para_list(p0).direction; + (p0..=p1).all(|p| t.para_list(p).direction == first).then_some(first as usize) + }) + }) + .flatten(); + let items = [ + (tr("Auto"), tr_noop("Auto: by the first letter with a direction")), + ("→", tr_noop("Left to right")), + ("←", tr_noop("Right to left")), + ]; + if let Some(i) = text_seg(ui, "direction", &items, dir, w) { + app.run("text.direction", json!({"ids": ids, "direction": (["auto", "ltr", "rtl"].get(i).copied().unwrap_or("auto"))})); + } + } let resize = common(doc, ids, |n| n.kind.text().map(|t| t.auto_resize)).flatten(); // Auto-height text with a line limit truncates too. let truncating = common(doc, ids, |n| n.kind.text().map(TextData::truncates)).flatten(); diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 541e7ab90..ac8f1c3f9 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -57,7 +57,8 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v Portuguese take the singular for 0 as well; Japanese, Korean, Chinese and Vietnamese have one form; CLDR's "many" for millions in French, Spanish and Portuguese reads as the plural). - **Script support in documents:** Unicode bidi with Arabic shaping and mirrored brackets in text - layers; right-to-left list markers and indents; right-to-left names in the layers panel, tabs + layers; a paragraph's base direction forced with Text ▸ Text direction (#1830); right-to-left + list markers and indents; right-to-left names in the layers panel, tabs and canvas labels (visual order, joined Arabic); inline IME composition with the candidate window at the caret; system-font fallback for scripts the UI fonts lack. Devanagari shapes through harfrust. No vertical text (Figma has none either). @@ -125,6 +126,7 @@ Hindi and Arabic. Traditional Chinese is not planned (no catalog; its users see | Date | Change | Summary | |---|---|---| +| 2026-11-05 | minor | Documents: a paragraph's base direction can be forced left to right or right to left (Text ▸ Text direction, `text.direction`, `node.set textDirection`, the Type settings control, the `.fig` lines' `sourceDirectionality`), as Add right-to-left text says (#1830); detection stays the default. Four new UI strings (English only; other catalogs fall back). | | 2026-10-30 | minor | Bosnian (`bs`, Bosanski) draft catalog: every UI string (1,906), command label (629) and menu wording (31), machine-drafted in Latin script with ijekavian forms and marked as a draft awaiting native review. Bosnian plurals: one for 1, 21, 31… (not 11), few for 2–4, 22–24… (not 12–14), other for the rest. Grade and hours unchanged (Bosnian is not one of the twelve key languages) | | 2026-10-29 | minor | German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft. Nine existing translations corrected; keys, placeholders, coverage, grade and estimates unchanged | | 2026-10-28 | minor | Arabic draft: 1,857 UI entries, 620 command labels and 31 menu wordings, with count-neutral plurals. AI-assisted, native review pending. Desktop uses the existing Arabic fallback and bidi; web requires an embedded Arabic face. No panel mirroring or parity-grade increase claimed | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index cc0fc9db8..5092a08f2 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-11-02 · **Change:** minor (a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; 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; 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) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; 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; 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) · **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: @@ -91,6 +91,7 @@ interaction feel not yet compared. | Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | a group past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold) draws without its result, with a warning, and Flatten refuses it (#1104) | | Text: point, auto height, fixed; caret, words, lines, IME | done | Backspace or Delete at the start of a list item takes its marker and keeps its level, the paragraph starting where the item's text did ("delete the counter, but keep the same level of indentation", Create bulleted and numbered lists; `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests`); max lines on auto-width text keep it auto width, hugging up to that many lines and growing with its longest line ("Text resizing is set to auto height or auto width", Explore text properties; `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines`); Alt+Enter while editing opens the link at the caret, web and mail links only (`text.openLink`; "press ⌥Option Return/Alt Enter to activate a focused link", Accessibility at Figma; `text_link_tests`); the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | | Text: wrap style (Auto, Balance, Pretty) | done | per text layer (`wrapStyle` on `node.set`, `text.wrapStyle`, the Type settings control under Vertical trim, inspect, CSS `text-wrap: balance` or `pretty`): Auto breaks greedily; Balance narrows each paragraph as far as it goes without another line, so its lines come out close to the same length (what browsers do for `text-wrap: balance`); Pretty keeps lines full and, when the last line is one word, narrows one of the last four lines just enough to give a word to the lines after it (Explore text properties: "Auto: Figma fits as many words as possible on each line before wrapping to the next", "Balance: Figma distributes text as evenly as possible across every line, so each line is close to the same length", "Pretty: Figma keeps each line full, but adjusts the last four lines to avoid an orphan"). Saved line breaks from a file still win. Not yet included: per-paragraph settings, text-style support and the `.fig` import/export mapping (#1827). Test: `text_behaviour_tests::balance_and_pretty_wrap_styles_break_lines_as_documented` | +| Text: direction override (Text ▸ Text direction) | done | per paragraph (`ParaList::direction`: auto, ltr, rtl): Text ▸ Text direction ▸ Auto / Left to right / Right to left and `text.direction {direction, ids?}` set the edited paragraphs while editing, else every paragraph of the selected texts; `node.set textDirection` the layer's (or the chars `start`/`end` name); the Type settings control appears once a right-to-left script is in the text or a direction is forced; inspect reports `textDirection` when the paragraphs agree and `lineDirections` otherwise; Dev Mode's CSS writes `direction: ltr` or `rtl` when every paragraph is forced the same way; `.fig` import and export carry each line's `sourceDirectionality`. Layout gives the paragraph its base level, so a forced right-to-left paragraph of Latin letters starts at the right, mirrors its list gutter and indent and orders its runs right to left, and a forced left-to-right paragraph of Hebrew the other way ("By default, Figma automatically handles text direction based on language detection. However, controls are available to override text direction per paragraph", "Go to Text > Text direction. Select Left to right or Right to left", Add right-to-left text). Not included: a direction in text styles (the article names none). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests` | | Text: superscript and subscript | done | `sups` / `subs` use the font's own glyphs where it has the feature and every letter or digit of the run gets one; otherwise the standard glyphs are drawn shrunk and raised or lowered by the font's OS/2 superscript and subscript metrics (0.65 em at 0.35 em up or 0.14 em down without them), and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says ("Figma draws a synthesized version by using the font's standard version of the character, shrinking it, and positioning it accordingly"; mixed glyphs "all update to the synthesized form for consistency"), #1828; tests `text::tests::superscript_native_or_synthesised_and_never_mixed`, `text_behaviour_tests::superscript_and_subscript_are_synthesised_without_glyphs_for_them` | | Eyedropper (I) | done | picks the colour seen under the pointer (opaque) into the selection's fill and the recent colours, one undo step (`fx_behaviour_tests`); with Cmd/Ctrl+Shift held it makes a colour style or variable of it instead, as the reference app's eyedropper does: a Create from color window asks which, its name (the next free "Color n") and a variable's collection (the file's first, or a new one made with it in the same undo step), and leaves the selection's fills alone (`eyedropper_create_tests`, `sampled_color_tests`) | | Comment (C), Shift+C to show/hide | done | local only | @@ -178,6 +179,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-11-05 | minor | M4, #1830: a paragraph's base direction can be forced (`TextDirection`: Auto, Ltr, Rtl on `ParaList`): Text ▸ Text direction ▸ Auto / Left to right / Right to left (`text.directionAuto` / `Ltr` / `Rtl`) and `text.direction {direction, ids?}` set the edited paragraphs while editing, else the layer's; `node.set textDirection`; the Type settings control once a right-to-left script is in the text; inspect `textDirection` / `lineDirections`; CSS `direction`; `.fig` lines' `sourceDirectionality` in and out. Layout gives each paragraph its base level (`Bidi::of` runs the algorithm per paragraph, as it starts over at each). Before, direction came from the letters only ("controls are available to override text direction per paragraph", Add right-to-left text). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests::a_lines_source_directionality_is_its_paragraphs_direction_both_ways`. | | 2026-11-02 | patch | M8, #1875: a click with the Bend tool on a sharp point between two segments gives it handles along the line between its neighbours, a third of the way to each ("Click on a point or path where you want to add a curve", Edit vector layers); a click on a curved point still makes it sharp, a drag still sets the handles by hand, an endpoint is left alone. Between a handle's end and a point both within reach of the pointer, the nearer one on the page is hit (the handle on a tie), so a short path's point (its handles a few pixels long) can be clicked again. Before, only a drag curved a point, and a short one left two tiny handles in its direction. Tests: `vectoredit::tests::short_handles_leave_their_point_clickable`, `vectoredit::tests::a_bend_click_curves_a_sharp_point_between_its_neighbours`, `bend_tool_smooths_and_sharpens_points`. Numbers unchanged. | | 2026-11-02 | minor | M4, #1827: wrap style on a text layer (`WrapStyle`: Auto, Balance, Pretty): Balance re-breaks each paragraph at the narrowest width that keeps its line count (bisection over the greedy breaker, as browsers balance), Pretty narrows one of the last four lines just enough so the last line isn't one word; `text.wrapStyle`, `node.set wrapStyle` (`textWrap` too), inspect `wrapStyle`, Dev Mode CSS `text-wrap` (Tailwind `text-balance` / `text-pretty`), the Type settings control under Vertical trim. Per-paragraph settings, text-style support and the `.fig` import/export mapping stay open. Test: `text_behaviour_tests::balance_and_pretty_wrap_styles_break_lines_as_documented`. | | 2026-11-02 | patch | M4: Backspace or Delete at the start of a list item takes its marker and keeps its level ("Use Backspace or Delete at the beginning of a list item to delete the counter, but keep the same level of indentation", Create bulleted and numbered lists): a plain paragraph may now carry a list level, and the text layout starts it where that level's item text starts (the gutters stay even when no list is left). Backspace used to drop the level too, and Delete at an item's start deleted the next character. Tab on such a paragraph types a tab, as outside a list. Tests: `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests::a_plain_paragraph_at_a_list_level_starts_where_the_items_text_does` | From c16c3676c27fdf199fea1598c0f16228318af6d5 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Thu, 5 Nov 2026 03:46:28 -0500 Subject: [PATCH 10/13] M4: text direction: CSS keeps Left beside a forced rtl, paragraph arrays read to the paragraph count, the empty last paragraph keeps its direction (#1830) From review: `text-align: left` beside `direction: rtl` (Left is absolute on the canvas); the REST import reads the four parallel paragraph arrays up to the paragraph count, not the 256-entry list limit; `rtl_para` gets an entry for the position after the last char so an empty last paragraph keeps its forced direction; the localization status line. Co-Authored-By: Claude Fable 5.1 --- crates/engine/src/dev/codegen/css.rs | 8 +++++++- crates/engine/src/text_behaviour_tests.rs | 7 +++++++ crates/format/src/rest/text.rs | 11 ++++++----- crates/text/src/layout.rs | 15 +++++++++++++-- crates/text/src/tests.rs | 12 +++++++++--- docs/localization-parity.md | 2 +- docs/ui-parity.md | 2 +- 7 files changed, 44 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/dev/codegen/css.rs b/crates/engine/src/dev/codegen/css.rs index c772367fd..d521674e9 100644 --- a/crates/engine/src/dev/codegen/css.rs +++ b/crates/engine/src/dev/codegen/css.rs @@ -311,7 +311,13 @@ fn text_decls(doc: &Document, n: &Node, tx: &figcraft_doc::TextData, t: &mut Tok let dir = tx.paragraphs.first().map(|p| p.direction).filter(|d| tx.paragraphs.iter().all(|p| p.direction == *d)); match dir { Some(figcraft_doc::TextDirection::Ltr) => put("direction", "ltr".into()), - Some(figcraft_doc::TextDirection::Rtl) => put("direction", "rtl".into()), + Some(figcraft_doc::TextDirection::Rtl) => { + put("direction", "rtl".into()); + // Left alignment is absolute on the canvas; `start` would be the right edge here. + if tx.align_h == TextAlignH::Left { + put("text-align", "left".into()); + } + } _ => {} } match s.case { diff --git a/crates/engine/src/text_behaviour_tests.rs b/crates/engine/src/text_behaviour_tests.rs index 6db19a1cc..025b3bc2b 100644 --- a/crates/engine/src/text_behaviour_tests.rs +++ b/crates/engine/src/text_behaviour_tests.rs @@ -393,6 +393,7 @@ fn text_direction_is_forced_per_paragraph() { assert_eq!(inspect(&s)["textDirection"], json!("rtl")); let css = |s: &mut Session| s.execute("inspect.code", &json!({"id": t})).unwrap()["code"].as_str().unwrap().to_string(); assert!(css(&mut s).contains("direction: rtl;"), "{}", css(&mut s)); + assert!(css(&mut s).contains("text-align: left;"), "Left stays the left edge, which `start` would not be: {}", css(&mut s)); s.execute("node.set", &json!({"id": t, "props": {"textDirection": "ltr"}})).unwrap(); assert_eq!(rtl(&s), [false, false, false]); assert_eq!(inspect(&s)["textDirection"], json!("ltr")); @@ -413,6 +414,12 @@ fn text_direction_is_forced_per_paragraph() { s.execute("text.edit.insert", &json!({"text": "\n"})).unwrap(); s.execute("text.edit.end", &json!({})).unwrap(); assert_eq!(rtl(&s), [false, true, true, false]); + // An empty last paragraph (after a final newline) keeps the direction forced on it. + s.execute("node.set", &json!({"id": t, "props": {"characters": "abc\n", "textDirection": "auto"}})).unwrap(); + s.execute("text.edit.begin", &json!({"id": t, "caret": 4})).unwrap(); + s.execute("text.directionRtl", &json!({})).unwrap(); + s.execute("text.edit.end", &json!({})).unwrap(); + assert_eq!(rtl(&s), [false, true], "the empty last line runs right to left"); // A direction the document has words for; junk is refused. s.execute("node.set", &json!({"id": t, "props": {"characters": "\u{05E9}\u{05DC}\u{05D5}\u{05DD} abc", "textDirection": "auto"}})).unwrap(); assert_eq!(rtl(&s), [true], "Hebrew first: right to left by detection"); diff --git a/crates/format/src/rest/text.rs b/crates/format/src/rest/text.rs index ca5c51d15..391fa4e1c 100644 --- a/crates/format/src/rest/text.rs +++ b/crates/format/src/rest/text.rs @@ -10,7 +10,7 @@ use figcraft_doc::{ use serde_json::Value; use super::Ctx; -use super::props::{flag, len, list, num, paints, text}; +use super::props::{flag, len, num, paints, text}; /// Most characters read from one text node. const MAX_CHARS: usize = 1 << 20; @@ -259,10 +259,11 @@ fn runs(cx: &mut Ctx, v: &Value, td: &TextData) -> Vec { /// Each paragraph's list type, nesting level, start number and base direction from `lineTypes`, /// `lineIndentations` and (from `.fig` files) `listStartOffsets` and `lineDirections`. fn lists(v: &Value, td: &mut TextData) { - let types = list(v, "lineTypes"); - let indents = list(v, "lineIndentations"); - let starts = list(v, "listStartOffsets"); - let dirs = list(v, "lineDirections"); + // One entry per paragraph: read up to the paragraph count (bounded by the text's size), not the + // general list limit, so a long text keeps every paragraph's settings. + let n = td.paragraph_count(); + let column = |k: &str| v.get(k).and_then(Value::as_array).map(|a| a.get(..a.len().min(n)).unwrap_or_default()).unwrap_or_default(); + let (types, indents, starts, dirs) = (column("lineTypes"), column("lineIndentations"), column("listStartOffsets"), column("lineDirections")); td.paragraphs = (0..types.len().max(dirs.len())) .map(|i| ParaList { list: match types.get(i).and_then(Value::as_str) { diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 732e59ec7..bbf498a96 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -265,7 +265,8 @@ impl Item { } } -/// Bidi embedding levels per char, and whether each char's paragraph runs right to left. +/// Bidi embedding levels per char, and whether each char's paragraph runs right to left; +/// `rtl_para` has one more entry, for the position after the last char (an empty last paragraph). pub(crate) struct Bidi { pub(crate) levels: Vec, pub(crate) rtl_para: Vec, @@ -296,6 +297,15 @@ impl Bidi { rtl_para.push(paras.peek().is_some_and(|q| q.level.is_rtl())); } } + // The position after the last char: in the last paragraph, or in the empty one after a + // final newline (its forced direction, else left to right), so an empty last line knows + // its direction too. + let after = if text.is_empty() || text.ends_with('\n') { + forced.get(text.matches('\n').count()).copied().flatten().unwrap_or(false) + } else { + rtl_para.last().copied().unwrap_or(false) + }; + rtl_para.push(after); Some(Bidi { levels, rtl_para }) } } @@ -1271,7 +1281,8 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) // Letter spacing separates glyphs: none after the line's last visible one. let end_spacing = vis_end.checked_sub(1).filter(|i| *i >= *s).and_then(|i| items.get(i)).map_or(0.0, |it| it.spacing); let width: f64 = items.get(*s..vis_end).unwrap_or(&[]).iter().map(|it| it.adv).sum::() - end_spacing; - let first_char = items.get(*s).map(|it| it.char_start).unwrap_or(0); + // An empty last line has no item: the position after the last char is its paragraph's. + let first_char = items.get(*s).map_or(chars.len(), |it| it.char_start); let rtl_line = rtl_para_at(first_char); let line_indent = line_indent_of(*para, para_start_line, first_char); // Hanging punctuation at the line's edges (the visible text's first and last chars). diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index e38963855..0469a0cfc 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -423,17 +423,23 @@ fn forced_direction_sets_a_paragraphs_base_level() { // Latin letters forced right to left: an embedded left-to-right run (level 2) in a // right-to-left paragraph; the next paragraph, unforced, stays left to right. let b = Bidi::of("ab\ncd", &[Some(true), None]).unwrap(); - assert_eq!(b.rtl_para, [true, true, true, false, false]); + assert_eq!(b.rtl_para, [true, true, true, false, false, false], "and the position after the last char"); assert_eq!((b.levels.get(..2), b.levels.get(3..)), (Some(&[2, 2][..]), Some(&[0, 0][..]))); // Hebrew forced left to right: a right-to-left run (level 1) in a left-to-right paragraph. let b = Bidi::of("\u{5D0}\u{5D1}", &[Some(false)]).unwrap(); - assert_eq!(b.rtl_para, [false, false]); + assert_eq!(b.rtl_para, [false, false, false]); assert_eq!(b.levels, [1, 1]); // Forcing what the letters already say changes nothing; a paragraph past the forced list is // detected. let b = Bidi::of("\u{5D0}\n\u{5D1}", &[Some(true)]).unwrap(); - assert_eq!(b.rtl_para, [true, true, true]); + assert_eq!(b.rtl_para, [true, true, true, true]); assert_eq!((b.levels.first(), b.levels.last()), (Some(&1), Some(&1))); + // The empty paragraph after a final newline has its own direction: forced, else left to right. + let b = Bidi::of("ab\n", &[None, Some(true)]).unwrap(); + assert_eq!(b.rtl_para, [false, false, false, true]); + let b = Bidi::of("\u{5D0}\n", &[]).unwrap(); + assert_eq!(b.rtl_para, [true, true, false]); + assert_eq!(Bidi::of("", &[Some(true)]).map(|b| b.rtl_para), Some(vec![true])); } /// The x of the glyph for char `i`. diff --git a/docs/localization-parity.md b/docs/localization-parity.md index ac8f1c3f9..3a887c576 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (Bosnian draft catalog, one/few/other plurals; German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft; Arabic draft catalog, count-neutral plurals and web font gate; Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (documents: a paragraph's base direction can be forced with Text ▸ Text direction, as Add right-to-left text says (#1830); Bosnian draft catalog, one/few/other plurals; German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft; Arabic draft catalog, count-neutral plurals and web font gate; Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed) · **Target:** Figma Design (desktop app 126.10.7) FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 5092a08f2..d551c65d1 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -91,7 +91,7 @@ interaction feel not yet compared. | Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | a group past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold) draws without its result, with a warning, and Flatten refuses it (#1104) | | Text: point, auto height, fixed; caret, words, lines, IME | done | Backspace or Delete at the start of a list item takes its marker and keeps its level, the paragraph starting where the item's text did ("delete the counter, but keep the same level of indentation", Create bulleted and numbered lists; `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests`); max lines on auto-width text keep it auto width, hugging up to that many lines and growing with its longest line ("Text resizing is set to auto height or auto width", Explore text properties; `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines`); Alt+Enter while editing opens the link at the caret, web and mail links only (`text.openLink`; "press ⌥Option Return/Alt Enter to activate a focused link", Accessibility at Figma; `text_link_tests`); the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | | Text: wrap style (Auto, Balance, Pretty) | done | per text layer (`wrapStyle` on `node.set`, `text.wrapStyle`, the Type settings control under Vertical trim, inspect, CSS `text-wrap: balance` or `pretty`): Auto breaks greedily; Balance narrows each paragraph as far as it goes without another line, so its lines come out close to the same length (what browsers do for `text-wrap: balance`); Pretty keeps lines full and, when the last line is one word, narrows one of the last four lines just enough to give a word to the lines after it (Explore text properties: "Auto: Figma fits as many words as possible on each line before wrapping to the next", "Balance: Figma distributes text as evenly as possible across every line, so each line is close to the same length", "Pretty: Figma keeps each line full, but adjusts the last four lines to avoid an orphan"). Saved line breaks from a file still win. Not yet included: per-paragraph settings, text-style support and the `.fig` import/export mapping (#1827). Test: `text_behaviour_tests::balance_and_pretty_wrap_styles_break_lines_as_documented` | -| Text: direction override (Text ▸ Text direction) | done | per paragraph (`ParaList::direction`: auto, ltr, rtl): Text ▸ Text direction ▸ Auto / Left to right / Right to left and `text.direction {direction, ids?}` set the edited paragraphs while editing, else every paragraph of the selected texts; `node.set textDirection` the layer's (or the chars `start`/`end` name); the Type settings control appears once a right-to-left script is in the text or a direction is forced; inspect reports `textDirection` when the paragraphs agree and `lineDirections` otherwise; Dev Mode's CSS writes `direction: ltr` or `rtl` when every paragraph is forced the same way; `.fig` import and export carry each line's `sourceDirectionality`. Layout gives the paragraph its base level, so a forced right-to-left paragraph of Latin letters starts at the right, mirrors its list gutter and indent and orders its runs right to left, and a forced left-to-right paragraph of Hebrew the other way ("By default, Figma automatically handles text direction based on language detection. However, controls are available to override text direction per paragraph", "Go to Text > Text direction. Select Left to right or Right to left", Add right-to-left text). Not included: a direction in text styles (the article names none). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests` | +| Text: direction override (Text ▸ Text direction) | done | per paragraph (`ParaList::direction`: auto, ltr, rtl): Text ▸ Text direction ▸ Auto / Left to right / Right to left and `text.direction {direction, ids?}` set the edited paragraphs while editing, else every paragraph of the selected texts; `node.set textDirection` the layer's (or the chars `start`/`end` name); the Type settings control appears once a right-to-left script is in the text or a direction is forced; inspect reports `textDirection` when the paragraphs agree and `lineDirections` otherwise; Dev Mode's CSS writes `direction: ltr` or `rtl` when every paragraph is forced the same way (with `text-align: left` beside `rtl`, as Left is absolute on the canvas); `.fig` import and export carry each line's `sourceDirectionality`, read up to the paragraph count. Layout gives the paragraph its base level, so a forced right-to-left paragraph of Latin letters starts at the right, mirrors its list gutter and indent and orders its runs right to left, and a forced left-to-right paragraph of Hebrew the other way ("By default, Figma automatically handles text direction based on language detection. However, controls are available to override text direction per paragraph", "Go to Text > Text direction. Select Left to right or Right to left", Add right-to-left text). Not included: a direction in text styles (the article names none). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests` | | Text: superscript and subscript | done | `sups` / `subs` use the font's own glyphs where it has the feature and every letter or digit of the run gets one; otherwise the standard glyphs are drawn shrunk and raised or lowered by the font's OS/2 superscript and subscript metrics (0.65 em at 0.35 em up or 0.14 em down without them), and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says ("Figma draws a synthesized version by using the font's standard version of the character, shrinking it, and positioning it accordingly"; mixed glyphs "all update to the synthesized form for consistency"), #1828; tests `text::tests::superscript_native_or_synthesised_and_never_mixed`, `text_behaviour_tests::superscript_and_subscript_are_synthesised_without_glyphs_for_them` | | Eyedropper (I) | done | picks the colour seen under the pointer (opaque) into the selection's fill and the recent colours, one undo step (`fx_behaviour_tests`); with Cmd/Ctrl+Shift held it makes a colour style or variable of it instead, as the reference app's eyedropper does: a Create from color window asks which, its name (the next free "Color n") and a variable's collection (the file's first, or a new one made with it in the same undo step), and leaves the selection's fills alone (`eyedropper_create_tests`, `sampled_color_tests`) | | Comment (C), Shift+C to show/hide | done | local only | From a3e96bbb479ea529f5ca5c5c71805e058d1e2a9a Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Thu, 5 Nov 2026 17:06:55 -0500 Subject: [PATCH 11/13] docs: ui-parity status line dated 2026-11-05 with its history row Co-Authored-By: Claude Fable 5.1 --- docs/ui-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 8d4e7d8cb..0f0e92272 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-11-04 · **Change:** minor (text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; 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; 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) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-05 · **Change:** minor (text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; 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; 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) · **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: From 853e8c6cc437e8ebe0c336f4621815e7396a8714 Mon Sep 17 00:00:00 2001 From: Filip Strajnar Date: Fri, 6 Nov 2026 12:27:59 +0100 Subject: [PATCH 12/13] M8: a minimised window under Wayland answers control requests (#1906) Under Wayland an app can't tell that its window is minimised (no `minimized`, no `occluded`), and the compositor stops asking it to draw. eframe keeps running the app's logic for a window whose redraw is overdue, but `control::background` still left every request for "the window's frame", which never came: with the window minimised on GNOME or KDE Plasma each control-channel or attached-MCP request waited and timed out after 60 s ("it was not applied"). `control::background` now treats a window that drew no frame for `NOT_DRAWING_AFTER` (0.2 s) while a request waited for it as hidden: requests that act on the app's state are answered from the logic passes, as they are for a window the platform reports minimised. `FigApp::control_waits` keeps, per window, its pass count and the time the wait began; a frame drawn since starts the wait again, and a pass is asked for 0.2 s later while a request waits. Screenshots and `ui.input` still wait for a drawn frame. Measured on GNOME Wayland with the window minimised by its own button: 0.54 s a request (before: 13.7 s for one, then 60 s timeouts). Test: `idle_tests::a_window_that_stopped_drawing_answers_from_its_logic_passes`. Docs: control-protocol.md, mcp.md, hardware-parity.md, target-app-parity.md, ROADMAP.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LvG6E2LxvJ6MuFmiLA6Zqm --- ROADMAP.md | 3 +- crates/ui-egui/src/app.rs | 1 + crates/ui-egui/src/control.rs | 28 ++++++++++++++++++- crates/ui-egui/src/idle_tests.rs | 47 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 3 ++ docs/control-protocol.md | 6 ++-- docs/hardware-parity.md | 5 ++-- docs/mcp.md | 3 +- docs/target-app-parity.md | 5 ++-- 9 files changed, 92 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 60f5962d2..60ab41a98 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-11-02 · **Change:** minor (glass bends towards its core, and rounded rectangles work it out in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** patch (under Wayland a minimised window answers control and MCP requests in about half a second instead of timing out after 60 s (#1906); glass bends towards its core, and rounded rectangles work it out in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); 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 @@ -358,6 +358,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-11-06 | patch | M8, #1906: under Wayland a minimised window answers control-channel and attached-MCP requests from its logic passes once it has drawn nothing for 0.2 s while a request waits (about half a second each; every request timed out after 60 s before). Test: `idle_tests::a_window_that_stopped_drawing_answers_from_its_logic_passes`. Numbers unchanged | | 2026-11-02 | patch | M8, #1885: a glass whose shape is too big to keep whole at the zoom (past `MAX_SHAPE_PX` with its core's margin) bends along its edge normals instead of working out a core, so its fields never cover more pixels than a kept shape's, and the core's feature transform holds 16 bytes a pixel. The edge normals are box-blurred twice again and the bend directions once, as documented. Test: `glass::tests::a_shape_too_big_to_keep_bends_along_its_normals`. Numbers unchanged | | 2026-11-02 | minor | M3, #1885: glass bends the backdrop towards its core, the shape shrunk by `depth` and the bevel, as the reference tool's: round a sharp corner the bend turns smoothly instead of meeting along the diagonal, and a part thinner than twice that (a capsule, a letter's stroke) bends across itself towards its own middle (pixels about as deep as any within 2 px). Rectangles, frames, components, instances and sections whose corners aren't smoothed work their glass out in closed form, with nothing to rasterise, transform or keep: a zoom frame draws in 40 ms instead of 54 and a resize frame in 46 instead of 67 (`glass_render_timings`, release, 2×). Other shapes keep their fields, with the bend directions for the last two depths asked for beside them, so dragging the Depth slider works out only those again. Tests: `glass::tests::a_sharp_corner_bends_round_towards_the_core`, `glass::tests::a_thin_part_bends_across_itself`, `glass::tests::neighbouring_strokes_bend_towards_their_own_middles`, `glass::tests::a_rounded_rectangle_in_closed_form_matches_its_distance_transform`, `glass_cut_tests::what_the_shape_depends_on_works_it_out_again_and_nothing_else_does`. Numbers unchanged | | 2026-11-02 | minor | M3, #1885: glass's light is a line on its 1-unit bevel added to the backdrop, as the reference tool's: fully where the edge faces the light, none below 0.35 of facing it and 0.6 as much on the opposite side, with a faint gradient across the curve, 5% darker facing the light and lighter opposite (it was a glow mixed towards white, as deep as `splay` made it). `splay` turns the bend from along the edge's normal towards the way away from the layer's centre, as the reference tool's does, and no longer widens the light: files with a splay above 0 look different. The direction to the centre is worked out in the shape's own frame, so cut renders match. Tests: `glass::tests::the_light_lies_on_the_bevel_and_the_gradient_across_the_curve`, `backdrop_tests::splay_turns_the_bend_towards_the_centre`, `glass_cut_tests::the_light_is_a_line_on_the_bevel`. Numbers unchanged | diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index e13acce68..279f76897 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -37,6 +37,7 @@ impl FigApp { field_focus: false, panels_unsaved: false, control_queue: Vec::new(), + control_waits: Default::default(), control_input: Vec::new(), frame_stats: frame_stats::FrameStats::default(), windows: windows::Windows::default(), diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 322d9032c..2310f48e2 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -454,6 +454,24 @@ fn hidden(ctx: &Context, id: u64) -> bool { visibility(ctx, id) == Some(false) } +/// How long, in seconds, a window may draw no frame while a control request waits for it before +/// [`background`] answers the request as it answers one for a hidden window. eframe runs the app's +/// logic for a window whose redraw is 0.25 s overdue, so the second such pass answers. +pub const NOT_DRAWING_AFTER: f64 = 0.2; + +/// Whether window `id` has drawn no frame for [`NOT_DRAWING_AFTER`] while a request waited for it. +/// The platform may still call it visible: under Wayland an app can't tell that its window is +/// minimised, and the compositor then stops asking it to draw. The wait starts again whenever the +/// window draws. +fn stopped_drawing(app: &mut FigApp, ctx: &Context, id: u64, now: f64) -> bool { + let pass = ctx.cumulative_pass_nr_for(crate::windows::viewport(id)); + let wait = app.control_waits.entry(id).or_insert((pass, now)); + if wait.0 != pass { + *wait = (pass, now); + } + now - wait.1 >= NOT_DRAWING_AFTER +} + /// 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 @@ -511,7 +529,7 @@ pub fn background(app: &mut FigApp, ctx: &Context) { let _ = r.reply.send(ControlReply { id: r.id, result: Err(format!("no window {target}")) }); continue; } - let is_hidden = hidden(ctx, target); + let is_hidden = hidden(ctx, target) || stopped_drawing(app, ctx, target, now); 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 @@ -564,6 +582,14 @@ pub fn background(app: &mut FigApp, ctx: &Context) { } let _ = r.reply.send(ControlReply { id: r.id, result: res.unwrap_or_else(|| Err(format!("no window {target}"))) }); } + // A window a request still waits for is checked again soon: if it draws, its frame answers; + // if it doesn't (minimised under Wayland), the next logic pass does. + let waiting: std::collections::BTreeSet = + app.control_queue.iter().map(|r| r.params.get("window").and_then(Value::as_u64).unwrap_or(app.windows.focused)).collect(); + app.control_waits.retain(|id, _| waiting.contains(id)); + if !waiting.is_empty() { + crate::repaint_in(ctx, NOT_DRAWING_AFTER); + } request_paint(app, ctx); } diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index ae34ded78..ad547b7cc 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -434,6 +434,53 @@ fn a_hidden_window_answers_at_once() { let _ = std::fs::remove_dir_all(&dir); } +/// #1906: under Wayland a minimised window still reports itself visible (no `minimized`, no +/// `occluded`) and draws no frames; eframe runs only its logic. A request waiting for that +/// window's frame is answered from the logic passes once the window has drawn nothing for +/// [`crate::control::NOT_DRAWING_AFTER`], instead of timing out after 60 s. A window that draws +/// keeps answering from its frame. +#[test] +fn a_window_that_stopped_drawing_answers_from_its_logic_passes() { + let (mut h, text) = document(); + h.settle(); + h.time += 120.0; + h.app.clock.set_test_now(Some(h.time)); + let (tx, rx) = std::sync::mpsc::channel(); + let rename = |id: u64, name: &str| ControlRequest { + id: json!(id), + method: "engine.execute".into(), + params: json!({"command": "node.set", "params": {"ids": [text.clone()], "props": {"name": name}}}), + reply: tx.clone(), + state: Default::default(), + }; + let node = figcraft_doc::NodeId(text.as_u64().unwrap()); + let name = |h: &Harness| h.app.session.doc().unwrap().node(node).unwrap().name.clone(); + // What Wayland reports for a minimised window: nothing about its visibility. + let unknown = RawInput::default(); + let ctx = h.ctx.clone(); + h.app.control_queue.push(rename(1, "while minimised")); + let _ = ctx.run_logic(&unknown, |ctx| crate::control::background(&mut h.app, ctx)); + assert!(rx.try_recv().is_err(), "a window that may still draw answers from its frame"); + assert_eq!(h.app.control_queue.len(), 1); + // No frame comes: the next logic pass answers. + h.app.clock.set_test_now(Some(h.time + crate::control::NOT_DRAWING_AFTER + 0.05)); + let _ = ctx.run_logic(&unknown, |ctx| crate::control::background(&mut h.app, ctx)); + let reply = rx.try_recv().expect("answered while the window draws nothing"); + assert_eq!(reply.id, json!(1)); + assert!(reply.result.is_ok(), "{:?}", reply.result); + assert_eq!(name(&h), "while minimised"); + assert!(h.app.control_queue.is_empty(), "nothing waits for the window"); + // Back on screen: a frame drawn after the wait began starts it again, and the frame answers. + h.time += 1.0; + h.app.clock.set_test_now(Some(h.time)); + h.app.control_queue.push(rename(2, "on screen")); + let _ = ctx.run_logic(&unknown, |ctx| crate::control::background(&mut h.app, ctx)); + h.app.clock.set_test_now(Some(h.time + crate::control::NOT_DRAWING_AFTER + 0.05)); + h.frame(vec![]); + let reply = rx.try_recv().expect("the frame answers"); + assert_eq!((reply.id, name(&h)), (json!(2), "on screen".to_string())); +} + #[test] fn auto_save_writes_a_changed_file_then_rests() { let dir = std::env::temp_dir().join(format!("figcraft-auto-save-{}", std::process::id())); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a7397cee4..76d1b10a1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -750,6 +750,9 @@ pub struct FigApp { panels_unsaved: bool, /// Control-channel requests waiting for the next frame. pub control_queue: Vec, + /// Windows a control request waits for: the window's pass count when the wait began and the + /// app-clock time it began, to tell a window that stopped drawing ([`control::background`]). + control_waits: std::collections::BTreeMap, /// Input events from the control channel (`ui.input`) for the next frame's raw input. pub control_input: Vec, /// Frames drawn and what asked for them (`ui.inspect`). diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 82ba08bd1..6a2cbe00f 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -59,8 +59,10 @@ queue. A screenshot settles on three completed, presented UI frames (a discarded not count), then is issued, and expires once at its 60 s deadline if no paint arrives. `window.new` remains an error at once (`window 0 is minimised or hidden: …`) instead of waiting for the window to show; `ui.render` draws the document without it. Under Wayland the app can't tell that its -window is minimised: where the compositor then stops drawing it, a request waits for the window -and gets the 60 s timeout error. The browser's shared eframe runner does not implement +window is minimised, and the compositor stops asking it to draw: a window that draws no frame for +0.2 s while a request waits for it is treated as hidden, so the requests that act on the app's +state are answered without a frame (about half a second each), while `ui.screenshot` and +`ui.input` keep waiting for one. The browser's shared eframe runner does not implement `RequestPaintWhileHidden` yet, so hidden-tab screenshots and injected input are unqualified there. Coordinates are page units, y down. `x`/`y` in `node.set`/`node.create` are relative to the parent diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index eb8ac2e77..1d64bc411 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-30 · **Change:** minor (images copied in other apps paste in the browser (#492); text reads only the fallback fonts it draws with; `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); a batch keeps one undo snapshot, not one per step (#1099); one depth cap bounds a whole render, pattern sources included, and render threads get 8 MiB stacks (#1082); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); 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) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** patch (under Wayland a minimised window answers control and MCP requests in about half a second instead of timing out after 60 s (#1906); images copied in other apps paste in the browser (#492); text reads only the fallback fonts it draws with; `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); a batch keeps one undo snapshot, not one per step (#1099); one depth cap bounds a whole render, pattern sources included, and render threads get 8 MiB stacks (#1082); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); 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) · **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 @@ -41,7 +41,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import). |---|---|---|---| | macOS | desktop app (Intel and Apple silicon) | universal `.dmg`, signed and notarized (v0.2.0) | where nearly all QA happens. Quitting ends the process once the preferences, recovery copies and live sessions are taken care of: the window's teardown crashed on Macs with a Touch Bar (#781); the Quit menu and ⌘Q ask about unsaved changes like the main window's close button (#857), and like it close the main window only: with several windows open, each quit closes one; a quit that never asked — killed or crashed — still leaves recovery copies for the next start | | Windows x64 / arm64 | desktop app | `.msi` and portable zip for x64, x86 and arm64 | built in CI; Windows: QA pass done 2026-10-11, findings in PR. The portable zip keeps its settings in `config\` beside `figcraft.exe` (a `portable` file there), taking an installed copy's preferences on its first start (#1320). Draws with Direct3D 12 (FXC shaders) or OpenGL; when the graphics fail before the first frame it starts again on Vulkan, then OpenGL, then the power-saving GPU with lower limits (#965) | -| Linux | none (browser only) | AppImage, `.deb`, `.rpm`, Flatpak, tarball, x86_64 and aarch64; riscv64 tarball | CI tests run here. Clipboard (also the BSDs): reads run on a helper thread that gives up after 2 s, and images copied in other apps paste under Wayland through the data-control protocol (#693). Under Wayland the app can't tell its window is minimised, so control requests that need a drawn frame may still wait there (#720). None of this run on a Linux desktop here. On a Wayland compositor without server-side decorations (GNOME) the window gets winit's Adwaita controls (#592: checked by its author in a GNOME Wayland session; KDE not run) | +| Linux | none (browser only) | AppImage, `.deb`, `.rpm`, Flatpak, tarball, x86_64 and aarch64; riscv64 tarball | CI tests run here. Clipboard (also the BSDs): reads run on a helper thread that gives up after 2 s, and images copied in other apps paste under Wayland through the data-control protocol (#693). Under Wayland the app can't tell its window is minimised: a window that draws nothing for 0.2 s while a control request waits for it answers the request as a hidden window does (about half a second each, #1906), and only requests that need a drawn frame (screenshots, input) wait there (#720). None of this run on a Linux desktop here. On a Wayland compositor without server-side decorations (GNOME) the window gets winit's Adwaita controls (#592: checked by its author in a GNOME Wayland session; KDE not run) | | FreeBSD | none | tarball | | | Web | primary (figma.com) | `figcraft-web` (WASM, WebGPU/WebGL2), web zip | no accessibility tree; file access through the browser; copy and paste through the system clipboard (copies written from the copy event, pastes read from the paste event, its text or an image copied in another app); an internal error stops the page (a panic can't be caught there), but its unsaved files are kept in the page's local storage (about 5 MB) and open again on reload (#1123) | | iOS / Android | apps: view, comment, mirror prototypes | none (the web build is not touch-first) | 20–40 h for a viewer | @@ -50,6 +50,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import). | Date | Change | Summary | |---|---|---| +| 2026-11-06 | patch | M8, #1906: under Wayland (seen on GNOME and KDE Plasma) a minimised FigCraft window still reports itself visible and the compositor stops asking it to draw, so every control or attached-MCP request waited for a frame and timed out after 60 s ("it was not applied"), though eframe kept running the app's logic. `control::background` now treats a window that drew no frame for `NOT_DRAWING_AFTER` (0.2 s) while a request waited for it as hidden: state-only requests are answered from the logic passes (0.54 s each, measured on GNOME Wayland; they took 13.7 s or timed out before), the wait starts again when the window draws, and screenshots and `ui.input` still wait for a frame. Test: `idle_tests::a_window_that_stopped_drawing_answers_from_its_logic_passes` | | 2026-10-31 | minor | M1, #492: Web row: the browser pastes images copied in other apps, read from the paste event under the same limits as the desktop. Numbers unchanged. | | 2026-10-30 | minor | M8: text reads only the fallback fonts it draws with. Each fallback family reads the face nearest a run's style, not all of them (Noto Sans CJK is seven files of 16–20 MB); families naming the faces of one file share its bytes; and the faces are worked out once per style until fonts change, not for every text laid out. First text laid out on a Linux machine with Noto Sans CJK, DejaVu Sans and Inter: 41 files (265 MiB, kept in memory) and 41 faces parsed → 3 files (20 MiB) and 4 faces; fallback lookups per text laid out ~45 µs → 0.2 µs. Tests: `fontdb_fallback_tests`. Numbers unchanged | | 2026-10-30 | minor | M8, #1117: AI agents and plugins no longer learn where the open files are on disk from `doc.list`. Numbers unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index b5e49e749..0530d0370 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -42,7 +42,8 @@ replaced then, so a pause between calls is not an error. The app keeps answering while its window is minimised or covered by other windows (see `docs/control-protocol.md`): edits, inspection (`inspect_ui` too), pointer, key and text input and -renders work as usual (minimised, each request to the app takes up to about 0.1 s). Only a capture +renders work as usual (minimised, each request to the app takes up to about 0.1 s; under Wayland, +which can't report a minimised window, about half a second). Only a capture of the window itself (`screenshot` with `window`) needs it on screen: it is then an error at once; `screenshot` without `window` renders the document instead. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5d910dabf..7a535ff77 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -642,8 +642,9 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th `figcraft-cli mcp` and the desktop app, #715). A minimised or covered window answers every request that acts on the app's state without drawing (about 100 ms each on Windows), and the ones that need a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) fail at once instead of - waiting 60 s (#720; not tested on macOS, and under Wayland a minimised window can't be told - apart). + waiting 60 s (#720; not tested on macOS). Under Wayland a minimised window can't be told apart, + so a window that draws nothing for 0.2 s while a request waits is answered as a hidden one + (about half a second a request, #1906). - **Tests:** 963 `#[test]` functions (2026-10-10) including junk-parameter sweeps over every command (and every vector-edit command while editing), importer fuzzing and render regressions. ## Revision history From cf1fed3332c2a55bb6fea6d0c68c6836b43b0ce2 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 7 Nov 2026 07:58:49 +0700 Subject: [PATCH 13/13] Spanish labels for the text direction commands --- crates/ui-egui/locales/es.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index b9e004dd0..96183d523 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -2318,6 +2318,10 @@ status = "draft — needs native review" "text.caseUpper" = "Mayúsculas" "text.create" = "Texto" "text.decoration" = "Decoración" +"text.direction" = "Dirección del texto" +"text.directionAuto" = "Dirección del texto: automática" +"text.directionLtr" = "Dirección del texto: de izquierda a derecha" +"text.directionRtl" = "Dirección del texto: de derecha a izquierda" "text.edit.begin" = "Editar texto" "text.edit.caret" = "Colocar el cursor de texto" "text.edit.end" = "Dejar de editar el texto"