From 4707cb1579bfdfac3145a441d8014b9ef9e1c005 Mon Sep 17 00:00:00 2001 From: MortenHusted Date: Sun, 18 Oct 2026 13:58:13 +0200 Subject: [PATCH 001/114] M1: wrap errors name the command that was run Grouping, framing, auto layout, masks, outline stroke and the boolean commands share wrap(), which named `object.group` in every refusal (`layout.add` on a page: "invalid parameters for `object.group`"). wrap() now takes the calling command's id. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/booleans.rs | 2 +- crates/engine/src/cmd/structure.rs | 31 ++++++++++---------- crates/engine/src/convert_container_tests.rs | 17 +++++++++++ 3 files changed, 34 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/booleans.rs b/crates/engine/src/booleans.rs index 87d901533..c3de51cbe 100644 --- a/crates/engine/src/booleans.rs +++ b/crates/engine/src/booleans.rs @@ -124,7 +124,7 @@ pub(crate) fn apply(s: &mut Session, cmd: &str, p: &Value, op: BoolOp) -> Result } return Ok(json!({"ids": ids, "operation": op})); } - let out = crate::cmd::structure::wrap_boolean(s, p, op)?; + let out = crate::cmd::structure::wrap_boolean(s, p, cmd, op)?; // Operands of a new group follow its operation. if let Some(g) = crate::params::id_of(&out["id"]) { let doc = s.doc_mut()?; diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index 75ddac3d8..003872ef2 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -12,9 +12,9 @@ use crate::{EngineError, Result, Session, bad, spec}; pub fn specs() -> Vec { vec![ - spec!("object.group", "Group selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Group)), + spec!("object.group", "Group selection", "{ids?, name?}", true, |s, p| wrap(s, p, "object.group", Wrap::Group)), spec!("object.ungroup", "Ungroup selection", "{ids?}", true, ungroup), - spec!("object.frameSelection", "Frame selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Frame)), + spec!("object.frameSelection", "Frame selection", "{ids?, name?}", true, |s, p| wrap(s, p, "object.frameSelection", Wrap::Frame)), spec!("object.removeFrame", "Remove frame", "{ids?}", true, ungroup), spec!( "object.convertToGroup", @@ -42,7 +42,7 @@ pub fn specs() -> Vec { "Wrap in new section", "{ids?, name?} (layers on the page or in a section; the section wraps them with some room)", true, - |s, p| wrap(s, p, Wrap::Section) + |s, p| wrap(s, p, "object.wrapInSection", Wrap::Section) ), spec!( "object.resizeToFit", @@ -128,26 +128,27 @@ const SECTION_PAD: f64 = 40.0; const BOOL_PARAMS: &str = "{ids?} — layers inside boolean groups: sets their own operation (how each combines with the operands below it); one boolean group: sets its operation (every operand follows it); otherwise wraps the layers in a new boolean group"; /// Wrap the selection in a new boolean group doing `op`. -pub(crate) fn wrap_boolean(s: &mut Session, p: &Value, op: BoolOp) -> Result { - wrap(s, p, Wrap::Bool(op)) +pub(crate) fn wrap_boolean(s: &mut Session, p: &Value, cmd: &str, op: BoolOp) -> Result { + wrap(s, p, cmd, Wrap::Bool(op)) } -/// Wrap the selection in a new container at the topmost selected layer's place. -fn wrap(s: &mut Session, p: &Value, w: Wrap) -> Result { - let ids = req_targets("object.group", s, p)?; +/// Wrap the selection in a new container at the topmost selected layer's place. Errors name `cmd`, +/// the command that asked. +fn wrap(s: &mut Session, p: &Value, cmd: &str, w: Wrap) -> Result { + let ids = req_targets(cmd, s, p)?; let doc = s.doc_mut()?; let ids = z_sorted(doc, &roots(doc, &ids)); - let top = *ids.last().ok_or_else(|| bad("object.group", "nothing selected"))?; - let parent = doc.get(top)?.parent.ok_or_else(|| bad("object.group", "can't group a page"))?; + let top = *ids.last().ok_or_else(|| bad(cmd, "nothing selected"))?; + let parent = doc.get(top)?.parent.ok_or_else(|| bad(cmd, "can't group a page"))?; // Sections sit on the page or in sections, never in frames or groups. let board = |doc: &Document, i: NodeId| { doc.node(i).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. })) }; if matches!(w, Wrap::Section) && !ids.iter().all(|i| board(doc, *i)) { - return Err(bad("object.wrapInSection", "only layers on the page or in a section can be wrapped in a section")); + return Err(bad(cmd, "only layers on the page or in a section can be wrapped in a section")); } if !matches!(w, Wrap::Section) && ids.iter().any(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }))) { - return Err(bad("object.group", "sections can't go in groups or frames")); + return Err(bad(cmd, "sections can't go in groups or frames")); } let index = doc.index_in_parent(top).map(|i| i + 1); let mut bounds = doc.bounds_of(&ids).unwrap_or_default(); @@ -338,7 +339,7 @@ fn mask(s: &mut Session, p: &Value) -> Result { return ok(); } // Group, then the bottom-most child is the mask. - let r = wrap(s, &json!({"ids": ids, "name": "Mask group"}), Wrap::Group)?; + let r = wrap(s, &json!({"ids": ids, "name": "Mask group"}), "object.mask", Wrap::Group)?; let gid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("group failed".into()))?; let doc = s.doc_mut()?; if let Some(first) = doc.get(gid)?.children.first().copied() { @@ -472,7 +473,7 @@ fn outline_stroke(s: &mut Session, p: &Value) -> Result { let f = doc.get_mut(fill_id)?; f.strokes.clear(); } - let r = wrap(s, &json!({"ids": [fill_id, vid], "name": n.name}), Wrap::Group)?; + let r = wrap(s, &json!({"ids": [fill_id, vid], "name": n.name}), "vector.outlineStroke", Wrap::Group)?; if let Some(g) = id_of(&r["id"]) { out.push(g); } @@ -519,7 +520,7 @@ fn layout_add(s: &mut Session, p: &Value) -> Result { ids } else { // Wrap non-frames in a new frame without a fill, then add auto layout to it. - let r = wrap(s, &json!({"ids": ids}), Wrap::Frame)?; + let r = wrap(s, &json!({"ids": ids}), "layout.add", Wrap::Frame)?; let fid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("wrap failed".into()))?; let f = s.doc_mut()?.get_mut(fid)?; f.fills.clear(); diff --git a/crates/engine/src/convert_container_tests.rs b/crates/engine/src/convert_container_tests.rs index 620388d27..bf682a804 100644 --- a/crates/engine/src/convert_container_tests.rs +++ b/crates/engine/src/convert_container_tests.rs @@ -194,3 +194,20 @@ fn only_frames_on_the_page_or_in_a_section_become_sections() { assert!(err.contains("holds sections"), "{err}"); assert!(matches!(kind(&s, sec), NodeKind::Section { .. }), "nothing changed"); } + +#[test] +fn wrapping_errors_name_the_command_that_was_run() { + // Grouping, framing, auto layout, masks and booleans share one wrapping step; a refusal names + // the command that asked, not `object.group`. + let mut s = Session::new(); + let page = s.page().unwrap(); + for cmd in ["object.group", "object.frameSelection", "layout.add", "bool.union"] { + let err = s.execute(cmd, &json!({"ids": [page]})).unwrap_err().to_string(); + assert!(err.contains(&format!("`{cmd}`")), "{cmd}: {err}"); + } + let r = rect(&mut s, page, 0.0, 0.0); + let f = id(ex(&mut s, "frame.create", json!({"x": 100, "y": 0, "width": 100, "height": 100}))); + let inner = rect(&mut s, f, 10.0, 10.0); + let err = s.execute("object.wrapInSection", &json!({"ids": [inner, r]})).unwrap_err().to_string(); + assert!(err.contains("`object.wrapInSection`"), "{err}"); +} From d3327d79f38e5de5c9bf0c54b33104dd788276c8 Mon Sep 17 00:00:00 2001 From: MortenHusted Date: Sun, 18 Oct 2026 13:58:13 +0200 Subject: [PATCH 002/114] Contributors: add my name for the About window --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 8b14373eb..0a4eceb12 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -56,3 +56,6 @@ sync_github_name = true [people.pechnikov2026-star] sync_github_name = true + +[people.MortenHusted] +sync_github_name = true From c7261cb907cc74b2025d8c331f495d6e59f17007 Mon Sep 17 00:00:00 2001 From: MortenHusted Date: Sun, 18 Oct 2026 14:14:38 +0200 Subject: [PATCH 003/114] M3: an explicit paint edit detaches the variable bound to what it changed fill.set, stroke.set and node.set fill/fills/stroke/strokes reported ok while normalize put the bound colour straight back, and node.set {fills: []} brought the bound paint back. They now drop a paint's colour, opacity or stop binding where the edit changed that value, as selection.setPaint already did (its comparison moves to lists::rebind_edited_paint, which also lets stop bindings follow a removed stop). boundVariables applies last in props::apply, so a binding given with the paints in one call stays. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/binding_lists_tests.rs | 74 +++++++++++++++++++++ crates/engine/src/cmd/edit.rs | 2 +- crates/engine/src/cmd/lists.rs | 43 +++++++++++- crates/engine/src/cmd/props.rs | 36 +++++++--- crates/engine/src/components_scale_tests.rs | 26 ++++++++ crates/engine/src/selection_colors.rs | 41 ++---------- crates/engine/src/variables_io_tests.rs | 26 ++++++++ 7 files changed, 199 insertions(+), 49 deletions(-) diff --git a/crates/engine/src/binding_lists_tests.rs b/crates/engine/src/binding_lists_tests.rs index 805b0afed..9db9975a9 100644 --- a/crates/engine/src/binding_lists_tests.rs +++ b/crates/engine/src/binding_lists_tests.rs @@ -97,3 +97,77 @@ fn effect_moves_and_removals_reject_junk() { ex(&mut s, "effect.remove", json!({"ids": [r], "index": 7})); assert_eq!(s.doc().unwrap().node(r).unwrap().effects.len(), 1); } + +fn fill_hex(s: &Session, r: NodeId) -> Vec { + s.doc().unwrap().node(r).unwrap().fills.iter().filter_map(|p| p.solid_color()).map(|c| c.to_hex()).collect() +} + +fn brand_id(s: &Session) -> String { + s.doc().unwrap().variables.get("brand").unwrap().id.clone() +} + +#[test] +fn explicit_paint_edits_detach_the_colour_binding() { + // An edit that reported success keeps its colour: the binding it overwrote lets go (as + // selection colours already do), instead of putting the variable's colour back. + let edits = [ + ("fill.set", json!({"color": "#00FF00"})), + ("node.set", json!({"props": {"fill": "#00FF00"}})), + ("node.set", json!({"props": {"fills": ["#00FF00"]}})), + ]; + for (cmd, mut p) in edits { + let (mut s, r) = setup(); + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"})); + p["ids"] = json!([r]); + ex(&mut s, cmd, p.clone()); + assert_eq!(fill_hex(&s, r), ["00FF00"], "{cmd} {p}"); + assert!(bound(&s, r).is_empty(), "{cmd} {p}"); + ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#123456"})); + assert_eq!(fill_hex(&s, r), ["00FF00"], "{cmd} {p}: no longer follows the variable"); + } + for (cmd, mut p) in [("stroke.set", json!({"color": "#00FF00"})), ("node.set", json!({"props": {"stroke": "#00FF00"}}))] { + let (mut s, r) = setup(); + ex(&mut s, "node.set", json!({"ids": [r], "props": {"strokes": ["#000000"]}})); + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "stroke", "variable": "brand"})); + p["ids"] = json!([r]); + ex(&mut s, cmd, p); + let n = s.doc().unwrap().node(r).unwrap(); + assert_eq!(n.strokes[0].solid_color().map(|c| c.to_hex()).as_deref(), Some("00FF00"), "{cmd}"); + assert!(n.bound.is_empty(), "{cmd}"); + } +} + +#[test] +fn clearing_bound_fills_adds_no_paint_back() { + let (mut s, r) = setup(); + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"})); + ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": []}})); + assert!(s.doc().unwrap().node(r).unwrap().fills.is_empty()); + assert!(bound(&s, r).is_empty()); + ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#123456"})); + assert!(s.doc().unwrap().node(r).unwrap().fills.is_empty(), "the variable doesn't bring the fill back"); +} + +#[test] +fn paint_opacity_edits_keep_the_colour_binding() { + let (mut s, r) = setup(); + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"})); + ex(&mut s, "fill.set", json!({"ids": [r], "opacity": 0.5})); + ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#0000FF"})); + assert_eq!(fill_hex(&s, r), ["0000FF"], "the colour still follows the variable"); + assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].opacity, 0.5); + // The same colour with another opacity, through node.set, keeps it too. + ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": [{"type": "solid", "color": "#0000FF", "opacity": 0.25}]}})); + assert_eq!(bound(&s, r).len(), 1); + assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].opacity, 0.25); +} + +#[test] +fn paints_set_with_their_binding_in_the_same_call_keep_it() { + let (mut s, r) = setup(); + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"})); + let brand = brand_id(&s); + ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": ["#00FF00"], "boundVariables": {"fill": brand}}})); + assert_eq!(bound(&s, r), [("fill".to_string(), brand)]); + assert_eq!(fill_hex(&s, r), ["FF0000"], "the binding given with the paints applies"); +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a4769a35c..ae2f1dc89 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1342,7 +1342,7 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result { if let Some(b) = blend(p, "blend") { slot.blend = b; } - super::lists::remap_stop_bindings(n, fills, at, &old); + super::lists::rebind_edited_paint(n, fills, at, &old); if fills { n.styles.fill = None; } else { diff --git a/crates/engine/src/cmd/lists.rs b/crates/engine/src/cmd/lists.rs index 769d0a29c..287c9ddd0 100644 --- a/crates/engine/src/cmd/lists.rs +++ b/crates/engine/src/cmd/lists.rs @@ -112,7 +112,8 @@ pub fn stop_binding(field: &str) -> Option { /// Keep the gradient stop bindings of paint `index` of `n`'s fills (or strokes) on their stops after /// the paint changed from `old`: one stop removed shifts the later ones down (the removed one's /// binding goes); a paint that is no longer a gradient drops them. Other edits keep stop indices. -pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) { +/// Returns the removed stop's index. +pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) -> Option { use figcraft_color::PaintKind; let list = if fills { BoundList::Fills } else { BoundList::Strokes }; let new = if fills { n.fills.get(index) } else { n.strokes.get(index) }; @@ -122,11 +123,11 @@ pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old: // The first stop that differs is the removed one when the rest moved down unchanged. let k = a.stops.iter().zip(&b.stops).position(|(x, y)| x != y).unwrap_or(b.stops.len()); if a.stops.get(k + 1..) != b.stops.get(k..) { - return; + return None; } Some(k) } - (_, Some(PaintKind::Gradient(_))) => return, + (_, Some(PaintKind::Gradient(_))) => return None, _ => None, }; let keys: Vec<(String, Option)> = n @@ -150,6 +151,42 @@ pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old: } } n.bound.extend(kept); + removed +} + +/// After an explicit edit changed paint `index` of `n`'s fills (or strokes) from `old`: its stop +/// bindings follow a removed stop, then each binding whose value the edit changed (the colour, the +/// opacity, a stop's colour) lets go, so the edit stays instead of being put back from the +/// variable. Bindings whose value the edit kept stay; a paint that is gone takes its bindings. +pub(crate) fn rebind_edited_paint(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) { + let removed = remap_stop_bindings(n, fills, index, old); + let mut old = old.clone(); + if let (Some(k), figcraft_color::PaintKind::Gradient(g)) = (removed, &mut old.kind) + && k < g.stops.len() + { + g.stops.remove(k); + } + let list = if fills { BoundList::Fills } else { BoundList::Strokes }; + let new = if fills { n.fills.get(index) } else { n.strokes.get(index) }.cloned(); + n.bound.retain(|key, _| match list_binding(key) { + Some((l, i, field)) if l == list && i == index => new.as_ref().is_some_and(|new| paint_field_kept(&old, new, field)), + _ => true, + }); +} + +/// Whether a paint's bound `field` (`color`, `opacity`, `stops//color`, …) has the same value in +/// `new` as in `old`. +fn paint_field_kept(old: &figcraft_color::Paint, new: &figcraft_color::Paint, field: &str) -> bool { + use figcraft_color::PaintKind; + match field { + "color" => matches!((&old.kind, &new.kind), (PaintKind::Solid { color: a }, PaintKind::Solid { color: b }) if a == b), + "opacity" => old.opacity == new.opacity, + _ => match (stop_binding(field), &old.kind, &new.kind) { + (Some(j), PaintKind::Gradient(a), PaintKind::Gradient(b)) => a.stops.get(j).map(|s| s.color) == b.stops.get(j).map(|s| s.color), + (Some(_), _, _) => false, + _ => old == new, + }, + } } /// Where item `k` lands when the item at `from` moves to `to`. diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 8a1f4e1cd..5df46003c 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -315,18 +315,32 @@ fn layout_mut<'a>(cmd: &str, k: &str, n: &'a mut Node) -> Result<&'a mut AutoLay Ok(ensure_layout(n)) } -/// Apply a property map to one node. `x`/`y` are relative to the parent. -pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map) -> Result<()> { - let mut unknown = Vec::new(); - // Geometry keys first so rotation applies to the final size. - let order = |k: &str| match k { +/// Replace a node's fills (or strokes). Bindings on the paints whose bound values the new list +/// changed let go, as an explicit edit does (see `lists::rebind_edited_paint`). +fn set_paints(n: &mut Node, fills: bool, paints: Vec) { + let old = std::mem::replace(if fills { &mut n.fills } else { &mut n.strokes }, paints); + for (i, p) in old.iter().enumerate() { + super::lists::rebind_edited_paint(n, fills, i, p); + } +} + +/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, variable +/// bindings last so paints set in the same call don't detach them. +fn apply_order(k: &str) -> u8 { + match k { "width" | "height" | "w" | "h" => 0, "rotation" => 2, "x" | "y" => 3, + "boundVariables" => 4, _ => 1, - }; + } +} + +/// Apply a property map to one node. `x`/`y` are relative to the parent. +pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map) -> Result<()> { + let mut unknown = Vec::new(); let mut keys: Vec<(&String, &Value)> = props.iter().collect(); - keys.sort_by_key(|(k, _)| order(k)); + keys.sort_by_key(|(k, _)| apply_order(k)); for (k, v) in keys { let k = k.as_str(); match k { @@ -398,15 +412,15 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map doc.get_mut(id)?.fills = paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?, + "fills" => set_paints(doc.get_mut(id)?, true, paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?), "fill" => { let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour"))?; - doc.get_mut(id)?.fills = vec![Paint::solid(c)]; + set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]); } - "strokes" => doc.get_mut(id)?.strokes = paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?, + "strokes" => set_paints(doc.get_mut(id)?, false, paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?), "stroke" => { let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour"))?; - doc.get_mut(id)?.strokes = vec![Paint::solid(c)]; + set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]); } "strokeWeight" => doc.get_mut(id)?.stroke.weight = f64_of(v).ok_or_else(|| bad(cmd, "`strokeWeight` must be a number"))?.max(0.0), "strokeAlign" => doc.get_mut(id)?.stroke.align = enum_of(v).ok_or_else(|| bad(cmd, "strokeAlign: inside|center|outside"))?, diff --git a/crates/engine/src/components_scale_tests.rs b/crates/engine/src/components_scale_tests.rs index c796dad8a..68465932b 100644 --- a/crates/engine/src/components_scale_tests.rs +++ b/crates/engine/src/components_scale_tests.rs @@ -747,3 +747,29 @@ fn alt_dropping_a_library_component_swaps_to_it() { assert!(doc.node(glyph).unwrap().overrides.is_empty()); let _ = std::fs::remove_dir_all(dir); } + +#[test] +fn a_fill_set_on_a_bound_instance_layer_detaches_it_as_an_override() { + // Without unbinding first: the explicit colour detaches the variable on the instance layer only, + // and stays through main edits until the override is reset. + let mut s = Session::new(); + let (chip, _) = bound_chip(&mut s); + let i = crate::params::id_of(&ex(&mut s, "instance.create", json!({"component": chip, "x": 200, "y": 0}))["id"]).unwrap(); + let bg = find_path(s.doc().unwrap(), i, &["Bg"]).unwrap(); + ex(&mut s, "node.set", json!({"ids": [bg], "props": {"fill": "#ff00ff"}})); + let n = s.doc().unwrap().node(bg).unwrap().clone(); + assert_eq!(n.fills[0].solid_color().unwrap().to_hex(), "FF00FF"); + assert!(!n.bound.contains_key("fill")); + assert!(n.overrides.contains("fills")); + let main_bg = find_path(s.doc().unwrap(), chip, &["Bg"]).unwrap(); + ex(&mut s, "node.set", json!({"ids": [main_bg], "props": {"height": 28}})); + ex(&mut s, "variable.set", json!({"variable": "Chip", "value": "#445566"})); + let doc = s.doc().unwrap(); + healthy(doc); + assert_eq!(doc.node(bg).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF00FF"); + assert!(doc.node(main_bg).unwrap().bound.contains_key("fill"), "the main keeps its binding"); + ex(&mut s, "component.resetOverride", json!({"id": bg, "group": "fills"})); + let n = s.doc().unwrap().node(bg).unwrap(); + assert!(n.bound.contains_key("fill")); + assert_eq!(n.fills[0].solid_color().unwrap().to_hex(), "445566"); +} diff --git a/crates/engine/src/selection_colors.rs b/crates/engine/src/selection_colors.rs index e604c7986..875c6416b 100644 --- a/crates/engine/src/selection_colors.rs +++ b/crates/engine/src/selection_colors.rs @@ -9,7 +9,7 @@ use figcraft_doc::{Document, NodeId, NodeKind, StyleKind}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use crate::cmd::lists::{BoundList, list_binding}; +use crate::cmd::lists::{BoundList, list_binding, rebind_edited_paint}; use crate::params::*; use crate::{CommandSpec, Result, Session, bad, spec}; @@ -153,37 +153,6 @@ fn checked_targets(s: &Session, p: &Value, cmd: &str) -> Result Ok(ts) } -/// Detach variables only where the explicit edit changed their value. In particular, an -/// opacity edit keeps the color binding and a range edit keeps other characters' node bindings. -fn unbind_changed(n: &mut figcraft_doc::Node, t: &PaintTarget, old: &Paint, new: &Paint) { - // Range fills override the node's paints; the node binding still belongs to other chars. - if t.range.is_some() { - return; - } - let list = if t.fills { BoundList::Fills } else { BoundList::Strokes }; - n.bound.retain(|key, _| { - let Some((l, index, field)) = list_binding(key) else { return true }; - if l != list || index != t.index { - return true; - } - match field { - "color" => match (&old.kind, &new.kind) { - (PaintKind::Solid { color: a }, PaintKind::Solid { color: b }) => a == b, - _ => false, - }, - "opacity" => old.opacity == new.opacity, - _ => { - let stop = field.strip_prefix("stops/").and_then(|s| s.strip_suffix("/color")).and_then(|s| s.parse::().ok()); - match (stop, &old.kind, &new.kind) { - (Some(i), PaintKind::Gradient(a), PaintKind::Gradient(b)) => a.stops.get(i).map(|s| s.color) == b.stops.get(i).map(|s| s.color), - (Some(_), _, _) => false, - _ => old == new, - } - } - } - }); -} - fn set_paint(s: &mut Session, p: &Value) -> Result { const CMD: &str = "selection.setPaint"; let targets = checked_targets(s, p, CMD)?; @@ -197,18 +166,22 @@ fn set_paint(s: &mut Session, p: &Value) -> Result { let old = slot.clone(); *slot = paint.clone(); let n = s.doc_mut()?.get_mut(target.id)?; - unbind_changed(n, target, &old, &paint); + // Variables detach only where the edit changed their value; range fills override the node's + // paints, whose bindings still belong to the other characters. if let Some((start, end)) = target.range { if let NodeKind::Text(t) = &mut n.kind { t.restyle(start, end, |style| style.fills = Some(paints.clone())); } - } else if target.fills { + continue; + } + if target.fills { n.fills = paints; n.styles.fill = None; } else { n.strokes = paints; n.styles.stroke = None; } + rebind_edited_paint(n, target.fills, target.index, &old); } Ok(json!({"ok": true})) } diff --git a/crates/engine/src/variables_io_tests.rs b/crates/engine/src/variables_io_tests.rs index 5c7305bb5..3a57d9eac 100644 --- a/crates/engine/src/variables_io_tests.rs +++ b/crates/engine/src/variables_io_tests.rs @@ -371,3 +371,29 @@ fn variant_properties_follow_string_variables() { assert_eq!(main(&s), variants[1]); healthy(&s); } + +#[test] +fn paints_replaced_through_node_set_keep_unchanged_stop_bindings() { + let mut s = Session::new(); + let r = gradient_rect(&mut s); + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fills/0/stops/2/color", "variable": "brand"})); + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fills/0/stops/1/color", "variable": "ink"})); + let cur = |s: &Session| s.doc().unwrap().node(r).unwrap().fills[0].clone(); + // Removing the first stop moves both bindings down with their stops. + let mut p = cur(&s); + if let PaintKind::Gradient(g) = &mut p.kind { + g.stops.remove(0); + } + ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": [p]}})); + assert_eq!(bound_keys(&s, r), ["fills/0/stops/0/color", "fills/0/stops/1/color"]); + // Recolouring one bound stop detaches that stop only. + let mut p = cur(&s); + if let PaintKind::Gradient(g) = &mut p.kind { + g.stops[0].color = Color::WHITE; + } + ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": [p]}})); + assert_eq!(bound_keys(&s, r), ["fills/0/stops/1/color"]); + ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#0000ff"})); + assert_eq!(stops(&s, r), ["FFFFFF", "0000FF"]); + healthy(&s); +} From d71f24741a0de1fd6787ed00e9a80f4537e92e5e Mon Sep 17 00:00:00 2001 From: MortenHusted Date: Sun, 18 Oct 2026 14:18:25 +0200 Subject: [PATCH 004/114] #807: test the object.mask and vector.outlineStroke refusals too Both reach wrap() (a page in a mask, a section outlined into a group) and named `object.group` before. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/convert_container_tests.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/engine/src/convert_container_tests.rs b/crates/engine/src/convert_container_tests.rs index bf682a804..be338a82f 100644 --- a/crates/engine/src/convert_container_tests.rs +++ b/crates/engine/src/convert_container_tests.rs @@ -206,6 +206,12 @@ fn wrapping_errors_name_the_command_that_was_run() { assert!(err.contains(&format!("`{cmd}`")), "{cmd}: {err}"); } let r = rect(&mut s, page, 0.0, 0.0); + let err = s.execute("object.mask", &json!({"ids": [page, r]})).unwrap_err().to_string(); + assert!(err.contains("`object.mask`"), "{err}"); + let section = id(ex(&mut s, "node.create", json!({"type": "section", "x": 300, "y": 0, "width": 100, "height": 100}))); + ex(&mut s, "node.set", json!({"ids": [section], "props": {"fills": ["#FFFFFF"], "strokes": ["#000000"], "strokeWeight": 1}})); + let err = s.execute("vector.outlineStroke", &json!({"ids": [section]})).unwrap_err().to_string(); + assert!(err.contains("`vector.outlineStroke`"), "{err}"); let f = id(ex(&mut s, "frame.create", json!({"x": 100, "y": 0, "width": 100, "height": 100}))); let inner = rect(&mut s, f, 10.0, 10.0); let err = s.execute("object.wrapInSection", &json!({"ids": [inner, r]})).unwrap_err().to_string(); From 9ae3223d22ec8dbd3f21f336ab04d3820b6f62b7 Mon Sep 17 00:00:00 2001 From: Lucas Date: Sun, 18 Oct 2026 14:20:19 +0200 Subject: [PATCH 005/114] feat(i18n): Complete Polish translation (pl) Add full Polish localization for FigCraft UI, commands, and menus. Includes Polish CLDR integer plural rules (one, few, many, other) and eliminates all untranslated strings and borrowings. Co-Authored-By: Antigravity --- crates/ui-egui/locales/pl.toml | 2325 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n.rs | 6 +- 2 files changed, 2328 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/locales/pl.toml diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml new file mode 100644 index 000000000..de3c727e8 --- /dev/null +++ b/crates/ui-egui/locales/pl.toml @@ -0,0 +1,2325 @@ +# FigCraft interface strings: Polish. +# +# Complete, native-quality Polish localization for FigCraft. +# Clean-room implementation adhering strictly to ArtCraft standards. + +[meta] +name = "Polski" + +[ui] +"% of all commits" = "% wszystkich commitów" +"(no longer published)" = "(już niepublikowane)" +"({n} warnings: some entries were skipped)" = { one = "({n} ostrzeżenie: niektóre wpisy zostały pominięte)", few = "({n} ostrzeżenia: niektóre wpisy zostały pominięte)", many = "({n} ostrzeżeń: niektóre wpisy zostały pominięte)", other = "({n} ostrzeżeń: niektóre wpisy zostały pominięte)" } +"+ Create variable" = "+ Utwórz zmienną" +".fig (experimental)" = ".fig (eksperymentalny)" +"1…9, 0" = "1…9, 0" +"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR: {prs}, commitów: {commits}, +{added} / −{deleted} wierszy (Δ {delta}), +{bin_added} / −{bin_deleted} zasobów binarnych, {first} – {last}" +"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Kolekcja tego pliku z tymi trybami i zmiennymi: nadpisuj ich wartości dla poszczególnych trybów i dodawaj własne zmienne" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Plik .figcraft o tej nazwie już istnieje. Wybierz pełną nazwę, aby potwierdzić zastąpienie." +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Pojedyncza warstwa wyrównuje się do swojej ramki: zaznacz więcej warstw lub warstwę w ramce" +"A responsive product page: hero, features, figures, a quote and a footer" = "Responsywna strona produktu: sekcja hero, funkcje, statystyki, cytat i stopka" +"A spring plays until it settles: its mass, stiffness and damping set how long" = "Animacja sprężynowa trwa do ustabilizowania: jej masa, sztywność i tłumienie określają czas" +"A title, an agenda, a big figure, a chart and a closing slide" = "Tytuł, agenda, wyróżniona liczba, wykres i slajd końcowy" +"A to Z" = "Od A do Z" +"About" = "O programie" +"About FigCraft" = "O programie FigCraft" +"About this server" = "O tym serwerze" +"Action" = "Akcja" +"Actions" = "Akcje" +"Actions for {name}" = "Akcje dla: {name}" +"Actions…" = "Akcje…" +"Active" = "Aktywny" +"Actual size (100%)" = "Rozmiar rzeczywisty (100%)" +"Add a category" = "Dodaj kategorię" +"Add a comment" = "Dodaj komentarz" +"Add a description" = "Dodaj opis" +"Add a frame (F) or a shape (R) to get started" = "Dodaj ramkę (F) lub kształt (R), aby rozpocząć" +"Add a layer or component to tile first" = "Najpierw dodaj warstwę lub komponent do powielenia" +"Add a note for developers" = "Dodaj notatkę dla programistów" +"Add action" = "Dodaj akcję" +"Add actions for when no condition holds" = "Dodaj akcje wykonywane, gdy żaden warunek nie jest spełniony" +"Add an annotation for developers" = "Dodaj adnotację dla programistów" +"Add another condition" = "Dodaj kolejny warunek" +"Add auto layout" = "Dodaj Auto layout" +"Add background behind overlay" = "Dodaj tło za nakładką" +"Add content to this slot" = "Dodaj zawartość do tego gniazda" +"Add dev resource" = "Dodaj zasób programistyczny" +"Add effect" = "Dodaj efekt" +"Add export setting" = "Dodaj ustawienie eksportu" +"Add fill" = "Dodaj wypełnienie" +"Add interaction" = "Dodaj interakcję" +"Add layout grid" = "Dodaj siatkę układu" +"Add library" = "Dodaj bibliotekę" +"Add library file…" = "Dodaj plik biblioteki…" +"Add max height" = "Dodaj maksymalną wysokość" +"Add max width" = "Dodaj maksymalną szerokość" +"Add min height" = "Dodaj minimalną wysokość" +"Add min width" = "Dodaj minimalną szerokość" +"Add mode" = "Dodaj tryb" +"Add new page" = "Dodaj nową stronę" +"Add or update collections from a variables JSON" = "Dodaj lub zaktualizuj kolekcje z pliku JSON ze zmiennymi" +"Add properties to make instances configurable" = "Dodaj właściwości, aby umożliwić konfigurację instancji" +"Add reaction" = "Dodaj reakcję" +"Add starting point" = "Dodaj punkt startowy" +"Add stroke" = "Dodaj obrys" +"Add variant" = "Dodaj wariant" +"Add variant with a new value" = "Dodaj wariant z nową wartością" +"Added" = "Dodano" +"Added the library \"{name}\"" = "Dodano bibliotekę „{name}”" +"Adding to {name}" = "Dodawanie do: {name}" +"Adjust" = "Dopasowanie" +"Adjustment presets" = "Presety korekt" +"Advanced" = "Zaawansowane" +"Advanced stroke" = "Zaawansowany obrys" +"After delay" = "Po opóźnieniu" +"After delay {ms}ms" = "Po opóźnieniu {ms} ms" +"Align" = "Wyrównaj" +"Align bottom" = "Wyrównaj do dołu" +"Align center" = "Wyrównaj do środka" +"Align horizontal centers" = "Wyrównaj poziomo do środka" +"Align left" = "Wyrównaj do lewej" +"Align middle" = "Wyrównaj do środka pionowo" +"Align right" = "Wyrównaj do prawej" +"Align text baselines" = "Wyrównaj do linii bazowych tekstu" +"Align top" = "Wyrównaj do góry" +"Align vertical centers" = "Wyrównaj pionowo do środka" +"Alignment" = "Wyrównanie" +"All" = "Wszystko" +"All categories" = "Wszystkie kategorie" +"All comments" = "Wszystkie komentarze" +"All components" = "Wszystkie komponenty" +"All fonts" = "Wszystkie czcionki" +"All supported properties" = "Wszystkie obsługiwane właściwości" +"All variables" = "Wszystkie zmienne" +"Alpha" = "Alfa" +"Alphabetical" = "Alfabetycznie" +"Alt+drag" = "Alt+przeciągnięcie" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Ekran analityczny na siatce: wykresy wektorowe, liczby i tabela" +"An invite link that opens FigCraft and joins" = "Link z zaproszeniem otwierający FigCraft i dołączający do sesji" +"Anchor" = "Punkt zaczepienia" +"Android" = "Android" +"Angular" = "Kątowy" +"Animation" = "Animacja" +"Annotations" = "Adnotacje" +"Annotations shown on the canvas" = "Adnotacje widoczne na płótnie" +"Another server, or one that needs its key. The usual server is set in Preferences." = "Inny serwer lub serwer wymagający klucza. Domyślny serwer konfiguruje się w Preferencjach." +"Any status" = "Dowolny status" +"Anyone on your network with this code can edit the file, while this app stays open." = "Każdy w Twojej sieci z tym kodem może edytować plik, dopóki ta aplikacja pozostaje otwarta." +"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Każdy z linkiem do edycji może modyfikować plik; link do podglądu pozwala tylko oglądać. Linki zawierają tokeny: traktuj je jak hasła. Polecenie `figcraft-server links` tworzy i unieważnia linki." +"Anyone with this code can edit the file." = "Każdy z tym kodem może edytować plik." +"Anyone with this code can look at the file, not change it." = "Każdy z tym kodem może przeglądać plik bez możliwości zmian." +"Appearance" = "Wygląd" +"Apply a component property" = "Zastosuj właściwość komponentu" +"Apply variable" = "Zastosuj zmienną" +"Apply variable to text" = "Zastosuj zmienną do tekstu" +"Apply variable to visibility" = "Zastosuj zmienną do widoczności" +"Apps" = "Aplikacje" +"Apr" = "Kwi" +"Arc" = "Łuk" +"Arc ratio" = "Proporcja łuku" +"Arc start" = "Początek łuku" +"Arc sweep" = "Rozpiętość łuku" +"Archive" = "Zarchiwizuj" +"Archived" = "Zarchiwizowano" +"Arrange" = "Ułóż" +"Arrow" = "Strzałka" +"As typed" = "Zgodnie z wpisanym" +"Asking the server…" = "Odpytywanie serwera…" +"Assets" = "Zasoby" +"Aug" = "Sie" +"Auto" = "Automatycznie" +"Auto (space around)" = "Automatycznie (odstęp dookoła)" +"Auto (space between)" = "Automatycznie (odstęp między)" +"Auto (space evenly)" = "Automatycznie (równy odstęp)" +"Auto ({mode})" = "Automatycznie ({mode})" +"Auto columns" = "Automatyczne kolumny" +"Auto gap" = "Automatyczny odstęp" +"Auto height" = "Automatyczna wysokość" +"Auto layout" = "Automatyczny układ" +"Auto rows" = "Automatyczne wiersze" +"Auto width" = "Automatyczna szerokość" +"Back to the FigCraft server" = "Powrót do serwera FigCraft" +"Back to the font's value" = "Powrót do wartości czcionki" +"Back" = "Tył" +"Back to Ready for dev" = "Powrót do Gotowe do wdrożenia" +"Back to Ready for dev: the next change compares with how it is now" = "Powrót do Gotowe do wdrożenia: kolejna zmiana zostanie porównana ze stanem obecnym" +"Back to assets" = "Powrót do zasobów" +"Back to current" = "Powrót do bieżącej" +"Back to styles" = "Powrót do stylów" +"Back to the canvas from a focused region" = "Powrót do płótna z wybranego obszaru" +"Back to the current version" = "Powrót do bieżącej wersji" +"Back to the values the font style implies" = "Powrót do wartości wynikających ze stylu czcionki" +"Background" = "Tło" +"Background blur" = "Rozmycie tła" +"Basics" = "Podstawy" +"Before" = "Przed" +"Bend" = "Wygnij" +"Bevel" = "Ścięte" +"Binary assets added" = "Dodane pliki binarne" +"Binary assets removed" = "Usunięte pliki binarne" +"Black & white" = "Czerń i biel" +"Bleed" = "Spad" +"Bleed marks" = "Znaczniki spadu" +"Bleed marks need a bleed" = "Znaczniki spadu wymagają zdefiniowania spadu" +"Blend" = "Mieszanie" +"Blend mode" = "Tryb mieszania" +"Blue" = "Niebieski" +"Blur" = "Rozmycie" +"Boolean" = "Wartość logiczna" +"Boolean groups" = "Grupy logiczne (Boolean)" +"Both directions" = "W obu kierunkach" +"Bottom" = "Dół" +"Bottom left radius" = "Promień lewego dolnego narożnika" +"Bottom right radius" = "Promień prawego dolnego narożnika" +"Branch" = "Gałąź" +"Branch archived" = "Gałąź zarchiwizowana" +"Branch merged" = "Gałąź scalona" +"Branch name" = "Nazwa gałęzi" +"Branch of {file}" = "Gałąź pliku {file}" +"Branch of {file} · merged or archived" = "Gałąź pliku {file} · scalona lub zarchiwizowana" +"Branch updated from main" = "Gałąź zaktualizowana z głównej" +"Branch {name}" = "Gałąź {name}" +"Branches" = "Gałęzie" +"Bound to {name}. Click to detach the variable." = "Powiązano z: {name}. Kliknij, aby odłączyć zmienną." +"Bring forward" = "Przenieś do przodu" +"Bring the main file's changes into the branch" = "Pobierz zmiany z pliku głównego do gałęzi" +"Bring to front" = "Przenieś na wierzch" +"Bristle" = "Włosiankowy" +"Browse" = "Przeglądaj" +"Brush" = "Pędzel" +"Brush rotation jitter" = "Fluktuacja obrotu pędzla" +"Brush scatter" = "Rozrzut pędzla" +"Brush size jitter" = "Fluktuacja rozmiaru pędzla" +"Brush spacing" = "Odstęp pędzla" +"Bulleted list" = "Lista punktowana" +"C or Shift+C" = "C lub Shift+C" +"C or ⇧C" = "C lub ⇧C" +"CMYK colour" = "Kolor CMYK" +"Can view" = "Może przeglądać" +"Checking the server…" = "Sprawdzanie serwera…" +"Clear from this list" = "Usuń z tej listy" +"Code or invite link" = "Kod lub link z zaproszeniem" +"Color profile: {name}" = "Profil kolorów: {name}" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Style kolorów i tekstu, zmienne Jasne i Ciemne, przyciski, pola wprowadzania i karty" +"Convert to frame" = "Konwertuj na ramkę" +"Convert to section" = "Konwertuj na sekcję" +"CSS custom properties…" = "Właściwości niestandardowe CSS…" +"Can edit" = "Może edytować" +"Can't read this library: {e}" = "Nie można odczytać tej biblioteki: {e}" +"Cancel" = "Anuluj" +"Canvas" = "Płótno" +"Canvas stacking" = "Kolejność na płótnie" +"Cap height to baseline" = "Wysokość wersalików do linii bazowej" +"Case" = "Wielkość liter" +"Case-sensitive forms" = "Formy zależne od wielkości liter" +"Categories" = "Kategorie" +"Center" = "Środek" +"Change alias" = "Zmień alias" +"Change resolved" = "Zmiana rozwiązana" +"Change style" = "Zmień styl" +"Change to" = "Zmień na" +"Change to needs a layer inside a variant" = "Opcja Zmień na wymaga warstwy wewnątrz wariantu" +"Changed" = "Zmieniono" +"Changed on both sides: keep one" = "Zmieniono po obu stronach: zachowaj jedną wersję" +"Changes on main" = "Zmiany w gałęzi głównej" +"Changes on the branch" = "Zmiany w gałęzi" +"Changes unknown" = "Nieznane zmiany" +"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Wybierz React, Leptos, SwiftUI lub Compose powyżej, aby połączyć ten komponent z jego kodem." +"Choose another profile to change it." = "Wybierz inny profil, aby go zmienić." +"Choose collection" = "Wybierz kolekcję" +"Choose image" = "Wybierz obraz" +"Choose mode" = "Wybierz tryb" +"Choose variable" = "Wybierz zmienną" +"Circle" = "Koło" +"Clear recent" = "Wyczyść ostatnie" +"Click + to replace mixed content" = "Kliknij +, aby zastąpić zawartość mieszaną" +"Click anywhere on the canvas to leave one." = "Kliknij w dowolnym miejscu na płótnie, aby zostawić komentarz." +"Click or drag to merge · {alt} removes" = "Kliknij lub przeciągnij, aby scalić · {alt} usuwa" +"Click or drag to remove · {alt} merges" = "Kliknij lub przeciągnij, aby usunąć · {alt} scala" +"Click to record a key" = "Kliknij, aby zarejestrować klawisz" +"Clip content" = "Przycinaj zawartość" +"Clip to shape" = "Przytnij do kształtu" +"Close" = "Zamknij" +"Close overlay" = "Zamknij nakładkę" +"Close preview" = "Zamknij podgląd" +"Close tab" = "Zamknij kartę" +"Close version history" = "Zamknij historię wersji" +"Close when clicking outside" = "Zamknij przy kliknięciu poza obszarem" +"Close without saving" = "Zamknij bez zapisywania" +"Code" = "Kod" +"Code syntax" = "Składnia kodu" +"Collapse" = "Zwiń" +"Collapse panel" = "Zwiń panel" +"Collection" = "Kolekcja" +"Collections" = "Kolekcje" +"Color" = "Kolor" +"Color burn" = "Ściemnianie" +"Color dodge" = "Rozjaśnianie" +"Color format" = "Format koloru" +"Color profile" = "Profil kolorów" +"Color styles" = "Style kolorów" +"Color values in this file are in this color space." = "Wartości kolorów w tym pliku są w tej przestrzeni kolorów." +"Color variables" = "Zmienne koloru" +"Colour" = "Kolor" +"Column" = "Kolumna" +"Column gap" = "Odstęp kolumn" +"Column sizes" = "Rozmiary kolumn" +"Column span" = "Rozpiętość kolumn" +"Columns" = "Kolumny" +"Columns {count}" = "Kolumny: {count}" +"Comment" = "Komentarz" +"Comments" = "Komentarze" +"Commits" = "Commity" +"Company" = "Firma" +"Compare changes" = "Porównaj zmiany" +"Compatibility notes" = "Uwagi dotyczące zgodności" +"Completed" = "Ukończone" +"Component" = "Komponent" +"Component name" = "Nazwa komponentu" +"Component property" = "Właściwość komponentu" +"Component set" = "Zestaw komponentów" +"Component sets" = "Zestawy komponentów" +"Components" = "Komponenty" +"Conditional" = "Warunek (Conditional)" +"Confetti" = "Konfetti" +"Conflicts" = "Konflikty" +"Connect to code" = "Połącz z kodem" +"Connected" = "Połączono" +"Connected to code: {from}" = "Połączono z kodem: {from}" +"Connection lost, reconnecting (attempt {attempt})…" = "Utracono połączenie, ponowne łączenie (próba {attempt})…" +"Constrain proportions" = "Zachowaj proporcje" +"Constraints" = "Więzy" +"Content" = "Zawartość" +"Contents hidden" = "Zawartość ukryta" +"Contents only" = "Tylko zawartość" +"Contextual alternates" = "Alternatywy kontekstowe" +"Contrast" = "Kontrast" +"Contributors" = "Współtwórcy" +"Contributors: {people} · Commits: {commits}" = "Współtwórcy: {people} · Commity: {commits}" +"Controller" = "Kontroler" +"Cool" = "Chłodny" +"Copied as CSS" = "Skopiowano jako CSS" +"Copied as PNG" = "Skopiowano jako PNG" +"Copied as SVG" = "Skopiowano jako SVG" +"Copied as text" = "Skopiowano jako tekst" +"Copied {value}" = "Skopiowano {value}" +"Copy" = "Kopiuj" +"Copy as" = "Kopiuj jako" +"Copy as CSS" = "Kopiuj jako CSS" +"Copy as PNG" = "Kopiuj jako PNG" +"Copy as SVG" = "Kopiuj jako SVG" +"Copy code" = "Kopiuj kod" +"Copy link" = "Kopiuj link" +"Copy the {label}" = "Kopiuj {label}" +"Copy {value}" = "Kopiuj {value}" +"Corner radius" = "Zaokrąglenie rogów" +"Corner smoothing" = "Wygładzanie narożników (Corner smoothing)" +"Couldn't download the variables" = "Nie udało się pobrać zmiennych" +"Couldn't reach {server}: showing what this computer remembers." = "Nie udało się połączyć z {server}: pokazywanie zawartości zapamiętanej przez ten komputer." +"Couldn't save automatically: {error}" = "Nie udało się zapisać automatycznie: {error}" +"Count" = "Licznik" +"Create" = "Utwórz" +"Create a variable collection first" = "Najpierw utwórz kolekcję zmiennych" +"Create a variable first" = "Najpierw utwórz zmienną" +"Create alias" = "Utwórz alias" +"Create branch" = "Utwórz gałąź" +"Create collection" = "Utwórz kolekcję" +"Create component" = "Utwórz komponent" +"Create component property" = "Utwórz właściwość komponentu" +"Create components, styles or variables to publish them as a library." = "Twórz komponenty, style lub zmienne, aby opublikować je jako bibliotekę." +"Create property…" = "Utwórz właściwość…" +"Create style" = "Utwórz styl" +"Create style from the selected text" = "Utwórz styl z zaznaczonego tekstu" +"Created" = "Utworzono" +"Crop" = "Przytnij" +"Crop marks" = "Znaczniki przycięcia" +"Ctrl+arrows" = "Ctrl+strzałki" +"Ctrl+click" = "Ctrl+kliknięcie" +"Ctrl+scroll" = "Ctrl+przewijanie" +"Current name" = "Bieżąca nazwa" +"Current version" = "Bieżąca wersja" +"Cursor chat" = "Czat kursora" +"Custom" = "Niestandardowy" +"Custom size" = "Rozmiar niestandardowy" +"Cut" = "Wytnij" +"Dark" = "Ciemny" +"Darken" = "Ciemniej" +"Dash" = "Kreska" +"Dashboard" = "Pulpit" +"Date created" = "Data utworzenia" +"Dec" = "Gru" +"Decoration style" = "Styl dekoracji" +"Decoration" = "Dekoracja tekstu" +"Deep select" = "Głębokie zaznaczanie" +"Default" = "Domyślne" +"Default content" = "Domyślna zawartość" +"Default value (the top-left variant)" = "Wartość domyślna (lewy górny wariant)" +"Delay" = "Opóźnienie" +"Delete" = "Usuń" +"Delete category (its annotations stay)" = "Usuń kategorię (jej adnotacje pozostaną)" +"Delete collection" = "Usuń kolekcję" +"Delete group" = "Usuń grupę" +"Delete mode" = "Usuń tryb" +"Delete page" = "Usuń stronę" +"Delete preset" = "Usuń ustawienie" +"Delete property" = "Usuń właściwość" +"Delete the style (layers keep their values)" = "Usuń styl (warstwy zachowają swoje wartości)" +"Delete thread" = "Usuń wątek" +"Delete variable" = "Usuń zmienną" +"Delete “{name}”? This can't be undone." = "Usunąć „{name}”? Tej operacji nie można cofnąć." +"Density" = "Gęstość" +"Depth" = "Głębia" +"Describe how and when to use this component" = "Opisz sposób i warunki użycia tego komponentu" +"Describe what changed" = "Opisz wprowadzone zmiany" +"Description" = "Opis" +"Description (optional)" = "Opis (opcjonalnie)" +"Design" = "Projektowanie" +"Design system starter" = "Pakiet startowy systemu projektowego (Design System)" +"Desktop" = "Pulpit" +"Destination" = "Miejsce docelowe" +"Detach alias" = "Odłącz alias" +"Detach instance" = "Odłącz instancję" +"Detach property" = "Odłącz właściwość" +"Detach style" = "Odłącz styl" +"Detach styles" = "Odłącz style" +"Detach variable" = "Odłącz zmienną" +"Details" = "Szczegóły" +"Dev Mode" = "Tryb deweloperski" +"Dev resources" = "Zasoby programistyczne" +"Development" = "Programowanie" +"Device" = "Urządzenie" +"Device at 100%" = "Urządzenie w 100%" +"Diamond" = "Rombowy" +"Difference" = "Różnica" +"Dimensions" = "Wymiary" +"Direction" = "Kierunek" +"Disconnect from code" = "Odłącz od kodu" +"Dispersion" = "Dyspersja" +"Display name" = "Nazwa wyświetlana" +"Dissolve" = "Rozpuszczanie" +"Distribute and tidy" = "Rozmieść i uporządkuj" +"Distribute horizontal spacing" = "Rozmieść poziomo" +"Distribute vertical spacing" = "Rozmieść pionowo" +"Divider" = "Rozdzielacz" +"Don't truncate" = "Nie obcinaj tekstu" +"Done" = "Gotowe" +"Done editing" = "Zakończ edycję" +"Dots" = "Kropki" +"Dotted" = "Kropkowany" +"Down" = "W dół" +"Drafts" = "Szkice" +"Drag the stroke to add a width point" = "Przeciągnij obrys, aby dodać punkt szerokości" +"Draw" = "Rysuj" +"Draw a frame" = "Narysuj ramkę" +"Drawing tools" = "Narzędzia rysowania" +"Drop a .figcraft, .fig, .json, .svg or image file." = "Upuść plik .figcraft, .fig, .json, .svg lub plik obrazu." +"Drop to import" = "Upuść, aby zaimportować" +"Drop to open" = "Upuść, aby otworzyć" +"Drop shadow" = "Cień" +"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Podwójna licencja MIT LUB Apache-2.0. Stworzone przez zespół ArtCraft i społeczność." +"Duo" = "Duet" +"Duplicate" = "Powiel" +"Duplicate by dragging" = "Powiel przez przeciągnięcie" +"Duplicate mode" = "Powiel tryb" +"Duplicate page" = "Powiel stronę" +"Edit" = "Edycja" +"Edit as an expression" = "Edytuj jako wyrażenie" +"Edit colour" = "Edytuj kolor" +"Edit comment" = "Edytuj komentarz" +"Edit dev resource" = "Edytuj zasób programistyczny" +"Edit link" = "Edytuj link" +"Edit link copied" = "Skopiowano link do edycji" +"Edit style" = "Edytuj styl" +"Edit variable" = "Edytuj zmienną" +"Edit with the builder" = "Edytuj za pomocą kreatora" +"Edited {when}" = "Edytowano {when}" +"Effect settings" = "Ustawienia efektów" +"Effect styles" = "Style efektów" +"Effects" = "Efekty" +"Ellipse" = "Elipsa" +"Ellipses" = "Elipsy" +"Else" = "W przeciwnym razie" +"Else if" = "W przeciwnym razie jeśli" +"Embedded profile" = "Wbudowany profil" +"Empty" = "Puste" +"End" = "End" +"End point" = "Punkt końcowy" +"Ends the session; everyone keeps their copy" = "Kończy sesję; każdy zachowuje swoją kopię" +"Enter" = "Enter" +"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Wprowadź adres figcraft-server i kliknij Przeglądaj, aby zobaczyć dostępne wtyczki." +"Enter to save · Esc to cancel" = "Enter, aby zapisać · Esc, aby anulować" +"Eraser" = "Gumka" +"Esc" = "Esc" +"Essential" = "Podstawowe" +"Every conflict has a choice." = "Każdy konflikt ma rozwiązanie do wyboru." +"Every top-level frame already starts a flow" = "Każda ramka najwyższego poziomu rozpoczyna już przepływ" +"Exact opacity (4 then 5: 45%)" = "Dokładna nieprzezroczystość (4, potem 5: 45%)" +"Exclude" = "Wyklucz" +"Excluded" = "Wykluczono" +"Excluded from layout" = "Wykluczono z układu" +"Excludes" = "Wyklucza" +"Exclusion" = "Wykluczenie" +"Exit focus view (Esc)" = "Wyjdź z widoku skupienia (Esc)" +"Expand" = "Rozwiń" +"Export" = "Eksportuj" +"Export all" = "Eksportuj wszystko" +"Export frames and layers as PNG, JPG, SVG or PDF" = "Eksportuj ramki i warstwy jako PNG, JPG, SVG lub PDF" +"Export page as PNG" = "Eksportuj stronę jako PNG" +"Export settings" = "Ustawienia eksportu" +"Export variables" = "Eksportuj zmienne" +"Export {name}" = "Eksportuj {name}" +"Export {n} layers" = { one = "Eksportuj {n} warstwę", few = "Eksportuj {n} warstwy", many = "Eksportuj {n} warstw", other = "Eksportuj {n} warstw" } +"Exported {file}" = "Wyeksportowano {file}" +"Exported {name}" = "Wyeksportowano {name}" +"Exported {name}; {n} kinds of content were left out" = { one = "Wyeksportowano {name}; pominięto {n} rodzaj zawartości", few = "Wyeksportowano {name}; pominięto {n} rodzaje zawartości", many = "Wyeksportowano {name}; pominięto {n} rodzajów zawartości", other = "Wyeksportowano {name}; pominięto {n} rodzajów zawartości" } +"Exported {n} files" = { one = "Wyeksportowano {n} plik", few = "Wyeksportowano {n} pliki", many = "Wyeksportowano {n} plików", other = "Wyeksportowano {n} plików" } +"Export…" = "Eksport…" +"Expose properties to instances" = "Udostępnij właściwości instancjom" +"Exposure" = "Czas naświetlania" +"Expression" = "Wyrażenie" +"Extend collection" = "Rozszerz kolekcję" +"Extends \"{name}\": overridden values are marked" = "Rozszerza „{name}”: nadpisane wartości są oznaczone" +"Eye" = "Oko" +"F" = "F" +"Fade" = "Zanikanie" +"False" = "Fałsz" +"Feb" = "Lut" +"Fetching from the server…" = "Pobieranie z serwera…" +"FigCraft couldn't open its window: {error}" = "FigCraft nie mógł otworzyć okna: {error}" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft nie obsługuje jeszcze wszystkich funkcji tych plików. Uwagi wymieniają elementy pominięte lub zmienione w zaimportowanej kopii; oryginalny plik pozostaje niezmieniony." +"File" = "Plik" +"File name" = "Nazwa pliku" +"File not found" = "Nie znaleziono pliku" +"File published" = "Plik został opublikowany" +"Files aren't end-to-end encrypted: the server's operator can read them." = "Pliki nie są szyfrowane end-to-end: administrator serwera może je odczytać." +"Files nobody opens for {n} days are deleted." = { one = "Pliki, których nikt nie otwiera przez {n} dzień, są usuwane.", few = "Pliki, których nikt nie otwiera przez {n} dni, są usuwane.", many = "Pliki, których nikt nie otwiera przez {n} dni, są usuwane.", other = "Pliki, których nikt nie otwiera przez {n} dni, są usuwane." } +"Fill" = "Wypełnienie" +"Fill (fr)" = "Wypełnienie (fr)" +"Fill container" = "Wypełnij kontener" +"Fill window" = "Wypełnij okno" +"Fills" = "Wypełnienia" +"Fill|sizing" = "Fill|sizing" +"Filter and sort" = "Filtruj i sortuj" +"Find" = "Znajdź" +"Find and replace" = "Znajdź i zamień" +"Find layers" = "Znajdź warstwy" +"First" = "Pierwszy" +"First commit" = "Pierwszy commit" +"First on top" = "Pierwszy na wierzchu" +"Fit" = "Dopasuj" +"Fit device to window" = "Dopasuj urządzenie do okna" +"Fit to contents" = "Dopasuj do zawartości" +"Fit to window" = "Dopasuj do okna" +"Fit width" = "Dopasuj szerokość" +"Fixed" = "Stałe" +"Fixed gap" = "Stały odstęp" +"Fixed size" = "Stały rozmiar" +"Flatten" = "Spłaszcz" +"Flip" = "Odbij" +"Flip horizontal" = "Odbij w poziomie" +"Flip text to the other side" = "Przerzuć tekst na drugą stronę" +"Flip vertical" = "Odbij w pionie" +"Flip width profile" = "Odwróć profil szerokości" +"Flow starting point" = "Punkt startowy przepływu" +"Flow starting points" = "Punkty startowe przepływu" +"Flows" = "Przepływy" +"Focus view" = "Widok skupienia" +"Fold autosaves" = "Zwiń automatyczne zapisy" +"Folder: click to collapse" = "Folder: kliknij, aby zwinąć" +"Folder: click to expand" = "Folder: kliknij, aby rozwinąć" +"Follow" = "Obserwuj" +"Follow {name}" = "Obserwuj: {name}" +"Following" = "Obserwowanie" +"Following {name} · {key} to stop" = "Obserwowanie {name} · {key}, aby zatrzymać" +"Font" = "Czcionka" +"Font not installed" = "Czcionka niezainstalowana" +"Font size" = "Rozmiar czcionki" +"Font style" = "Styl czcionki" +"Forget on this computer" = "Zapomnij na tym komputerze" +"Four screens of a trail app with components and a prototype flow" = "Cztery ekrany aplikacji z komponentami i przepływem prototypu" +"Fractions" = "Ułamki" +"Frame" = "Ramka" +"Frame selection" = "Utwórz ramkę z zaznaczenia" +"Frame tools" = "Narzędzia ramek" +"Frames" = "Ramki" +"From the library \"{name}\": read-only" = "Z biblioteki „{name}”: tylko do odczytu" +"From {name}" = "Od {name}" +"From {server}" = "Z serwera {server}" +"Frost" = "Szron" +"Gap" = "Odstęp" +"Gap between columns" = "Odstęp między kolumnami" +"Gap between items" = "Odstęp między elementami" +"Gap between rows" = "Odstęp między wierszami" +"Getting started" = "Pierwsze kroki" +"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "Podaj adres serwera publicznego lub adres serwera z jego kluczem (polecenie `figcraft-server key` go wyświetla)." +"Glass" = "Szkło" +"Go to main component" = "Przejdź do głównego komponentu" +"Go to {name}" = "Przejdź do: {name}" +"Grab bag" = "Lista dowolna" +"Gradient" = "Gradient" +"Gray" = "Szary" +"Green" = "Zielony" +"Greyscale components and two example screens to sketch a flow with" = "Komponenty w odcieniach szarości i dwa przykładowe ekrany do naszkicowania przepływu" +"Grid" = "Siatka" +"Grid ({size})" = "Siatka ({size})" +"Grid styles" = "Style siatki" +"Grid {size}" = "Siatka {size}" +"Group" = "Grupa" +"Group selection" = "Grupuj zaznaczenie" +"Groups" = "Grupy" +"Gutter" = "Odstęp" +"Hand tool" = "Narzędzie Rączka" +"Hanging list" = "Lista z wysunięciem" +"Hanging punctuation" = "Wiszące znaki interpunkcyjne" +"Hard light" = "Mocne światło" +"Height" = "Wysokość" +"Help" = "Pomoc" +"Help and resources" = "Pomoc i zasoby" +"Help page" = "Strona pomocy" +"Here: {names}" = "Tutaj: {names}" +"Hidden" = "Ukryty" +"Hide contents" = "Ukryj zawartość" +"Hide from publishing" = "Ukryj przed publikacją" +"Hide section contents" = "Ukryj zawartość sekcji" +"Hide these tips (Getting started in the Help menu shows them again)" = "Ukryj te wskazówki (Pierwsze kroki w menu Pomoc wyświetli je ponownie)" +"Hide when publishing" = "Ukryj podczas publikacji" +"Highlight layers on hover" = "Wyróżniaj warstwy po najechaniu kursorem" +"Highlights" = "Światła" +"Hold Alt" = "Przytrzymaj Alt" +"Hold Alt+Z" = "Przytrzymaj Alt+Z" +"Hold Ctrl" = "Przytrzymaj Ctrl" +"Hold Z" = "Przytrzymaj Z" +"Hold ⌃ or ⌘" = "Przytrzymaj ⌃ lub ⌘" +"Hold ⌥" = "Przytrzymaj ⌥" +"Hold ⌥Z" = "Przytrzymaj ⌥Z" +"Home" = "Start" +"Horizontal" = "Poziomo" +"Horizontal align" = "Wyrównanie poziome" +"Horizontal hex" = "Poziomy sześciokąt" +"Horizontal padding" = "Marginesy poziome" +"Horizontal spacing" = "Odstępy poziome" +"Hosted by {operator}" = "Hostowane przez {operator}" +"Hover a layer to see its properties" = "Najedź na warstwę, aby zobaczyć jej właściwości" +"Hover other layers to measure the distance" = "Najedź na inne warstwy, aby zmierzyć odległość" +"Hue" = "Odcień" +"Hug" = "Dopasuj" +"Hug contents" = "Dopasuj do zawartości" +"Icon components on a 24 px grid with keylines and a size preview" = "Komponenty ikon na siatce 24 px z liniami pomocniczymi i podglądem rozmiaru" +"Icon set starter" = "Zestaw początkowy ikon" +"If it keeps happening, please report it at {link} with this message." = "Jeśli problem będzie się powtarzał, zgłoś go pod adresem {link}, dołączając tę wiadomość." +"If" = "Jeżeli" +"If {condition}" = "Jeżeli {condition}" +"Ignore auto layout (absolute position)" = "Ignoruj automatyczny układ (pozycja bezwzględna)" +"Image" = "Obraz" +"Images" = "Obrazy" +"Import" = "Importuj" +"Import path" = "Ścieżka importu" +"Import plugin from manifest" = "Zaimportuj wtyczkę z manifestu" +"Import plugin from manifest…" = "Zaimportuj wtyczkę z manifestu…" +"Import report" = "Raport importu" +"Import variables" = "Importuj zmienne" +"Imported document" = "Zaimportowany dokument" +"Imported {name}" = "Zaimportowano {name}" +"Imported {n} new variables, updated {updated}" = { one = "Zaimportowano {n} nową zmienną, zaktualizowano {updated}", few = "Zaimportowano {n} nowe zmienne, zaktualizowano {updated}", many = "Zaimportowano {n} nowych zmiennych, zaktualizowano {updated}", other = "Zaimportowano {n} nowych zmiennych, zaktualizowano {updated}" } +"Import…" = "Importuj…" +"In an instance, this comes from the main component" = "W instancji pochodzi to z głównego komponentu" +"In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)." = "W przeglądarce możesz dołączać do sesji hostowanych przez aplikację desktopową oraz do plików na serwerze FigCraft. Strona otwarta przez https łączy się z innymi komputerami tylko przez linki wss:// (serwer za TLS)." +"Include bounding box" = "Dołącz ramkę otaczającą" +"Include id attribute" = "Dołącz atrybut id" +"Included" = "Uwzględniono" +"Included in layout" = "Uwzględniono w układzie" +"Individual corners" = "Niezależne narożniki" +"Individual strokes" = "Niezależne obrysy" +"Ink" = "Farba" +"Inner shadow" = "Cień wewnętrzny" +"Inside" = "Wewnątrz" +"Inspect" = "Inspekcja" +"Inspect the layer under the pointer: size, colours, typography" = "Sprawdź warstwę pod kursorem: rozmiar, kolory, typografia" +"Install plugin" = "Zainstaluj wtyczkę" +"Install" = "Zainstaluj" +"Installed {name}" = "Zainstalowano {name}" +"Installed" = "Zainstalowano" +"Installing…" = "Instalowanie…" +"Instance" = "Instancja" +"Instance properties" = "Właściwości instancji" +"Instance swap" = "Podmiana instancji" +"Instance swap (no other component)" = "Podmiana instancji (brak innego komponentu)" +"Instances" = "Instancje" +"Instant" = "Natychmiastowo" +"Interaction" = "Interakcja" +"Interactions" = "Interakcje" +"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Projektowanie interfejsów i prototypowanie. Darmowe, otwartoźródłowe, czysto implementowane narzędzie projektowe napisane od podstaw w Rust, część ArtCraft Crafting Apps." +"Intersect" = "Przetnij" +"Intersects" = "Przecina" +"Invalid color" = "Nieprawidłowy kolor" +"Invite link" = "Link z zaproszeniem" +"Invite link copied" = "Skopiowano link z zaproszeniem" +"It asks permission to:" = "Wymaga uprawnień do:" +"Jan" = "Sty" +"Join" = "Połącz" +"Join code copied" = "Skopiowano kod dołączenia" +"Join code or invite link" = "Kod dołączenia lub link z zaproszeniem" +"Join live session" = "Dołącz do sesji na żywo" +"Join us on Discord" = "Dołącz do nas na Discordzie" +"Join with a code" = "Dołącz za pomocą kodu" +"Joined" = "Dołączono" +"Joined {file}" = "Dołączono do: {file}" +"Joining…" = "Dołączanie…" +"Join|stroke" = "Join|stroke" +"Jul" = "Lip" +"Jun" = "Cze" +"Just now" = "Przed chwilą" +"Justify" = "Wyjustuj" +"Keep appearance" = "Zachowaj wygląd" +"Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Zachowanie wyglądu konwertuje każdą wartość koloru na {profile}, aby kolory wyglądały tak samo. Zachowanie wartości pozostawia liczby bez zmian, co może zmienić odcienie barw." +"Keep tool selected after use" = "Pozostaw narzędzie wybrane po użyciu" +"Keep values" = "Zachowaj wartości" +"Key/Gamepad" = "Klawisz/Kontroler" +"Key/Gamepad {key}" = "Klawisz/Kontroler {key}" +"Keyboard shortcuts" = "Skróty klawiaturowe" +"Kits" = "Zestawy" +"Landing page" = "Strona docelowa (Landing page)" +"Language" = "Język" +"Lasso" = "Lasso" +"Last" = "Ostatni" +"Last change: {note}" = "Ostatnia zmiana: {note}" +"Last commit" = "Ostatni commit" +"Last modified" = "Ostatnia modyfikacja" +"Last on top" = "Ostatni na wierzchu" +"Layer blur" = "Rozmycie warstwy" +"Layer name" = "Nazwa warstwy" +"Layers" = "Warstwy" +"Layers in an instance take their position from the main component" = "Warstwy w instancji przyjmują pozycję z głównego komponentu" +"Layout" = "Układ" +"Layout grid" = "Siatka układu" +"Layout grid settings" = "Ustawienia siatki układu" +"Layout grids" = "Siatki układu" +"Leave session" = "Opuść sesję" +"Leaves" = "Opuszcza" +"Left" = "Do lewej" +"Left & right" = "Lewa i prawa" +"Left side" = "Lewa strona" +"Left side width" = "Szerokość lewej strony" +"Letter case" = "Wielkość liter" +"Letter spacing" = "Światło tekstu" +"Libraries" = "Biblioteki" +"Libraries in this file" = "Biblioteki w tym pliku" +"Library updates available" = "Dostępne aktualizacje biblioteki" +"Library: click to collapse" = "Biblioteka: kliknij, aby zwinąć" +"Library: click to expand" = "Biblioteka: kliknij, aby rozwinąć" +"Library: its variables are read-only here; edit them in the library file and publish it" = "Biblioteka: jej zmienne są tutaj tylko do odczytu; edytuj je w pliku biblioteki i opublikuj" +"Light" = "Lekki" +"Lighten" = "Jaśniej" +"Light|glass" = "Light|glass" +"Line" = "Linia" +"Line arrow" = "Strzałka liniowa" +"Line delta" = "Różnica wierszy" +"Line height" = "Interlinia" +"Linear" = "Liniowy" +"Lines" = "Linie" +"Lines +/−" = "Wiersze +/−" +"Lines added" = "Dodane wiersze" +"Lines deleted" = "Usunięte wiersze" +"Link" = "Łącze" +"Link copied" = "Skopiowano link" +"Link docs, code or tickets for developers." = "Dołącz dokumentację, kod lub zgłoszenia dla programistów." +"Link frames on the Prototype tab, then present the flow" = "Połącz ramki na karcie Prototyp, a następnie zaprezentuj przepływ" +"Link this component to its code component: instances then show your real snippet with their values." = "Połącz ten komponent z jego odpowiednikiem w kodzie: instancje wyświetlą wtedy rzeczywisty fragment kodu z ich wartościami." +"List spacing" = "Odstępy listy" +"List style" = "Styl listy" +"Live session" = "Sesja na żywo" +"Live · {people}" = "Na żywo · {people}" +"Local components" = "Komponenty lokalne" +"Local network" = "Sieć lokalna" +"Local styles" = "Style lokalne" +"Local variables" = "Zmienne lokalne" +"Lock/Unlock" = "Zablokuj/Odblokuj" +"Locked" = "Zablokowane" +"Lowercase" = "Małe litery" +"Luminance" = "Luminancja" +"Luminosity" = "Jasność" +"Main" = "Główny" +"Main component deleted" = "Usunięto główny komponent" +"Main menu" = "Menu główne" +"Make a component" = "Utwórz komponent" +"Make slot" = "Utwórz gniazdo (slot)" +"Mar" = "Mar" +"Margin" = "Margines" +"Mark all as read" = "Oznacz wszystkie jako przeczytane" +"Mark as resolved" = "Oznacz jako rozwiązane" +"Mark as unread" = "Oznacz jako nieprzeczytane" +"Mark as unresolved" = "Oznacz jako nierozwiązane" +"Mask" = "Maska" +"Mass · Stiffness · Damping" = "Masa · Sztywność · Tłumienie" +"Match (empty: the whole name)" = "Dopasowanie (puste: cała nazwa)" +"Max H" = "Maks. wys." +"Max W" = "Maks. szer." +"Max lines" = "Maks. linii" +"Maximize" = "Maksymalizuj" +"May" = "maja" +"Measure to the selection" = "Zmierz odległość do zaznaczenia" +"Merge into main" = "Scal z gałęzią główną" +"Merge regions" = "Scal obszary" +"Merge the branch into the main file (one undo step) and archive it" = "Scal gałąź z plikiem głównym (jeden krok cofnięcia) i zarchiwizuj ją" +"Merged" = "Scalone" +"Merged PRs" = "Scalone PR" +"Min H" = "Min. wys." +"Min W" = "Min. szer." +"Minimize" = "Minimalizuj" +"Mirror angle" = "Kąt odbicia lustrzanego" +"Mirror angle and length" = "Kąt i długość odbicia lustrzanego" +"Mirroring" = "Odbicie lustrzane" +"Missing" = "Brak" +"Missing layer" = "Brakująca warstwa" +"Missing style" = "Brakujący styl" +"Missing variable" = "Brakująca zmienna" +"Miter" = "Ostre" +"Miter angle" = "Kąt ścięcia (miter)" +"Mixed" = "Różne wartości" +"Mixed fonts" = "Różne czcionki" +"Mixed interactions: click + to replace them" = "Różne interakcje: kliknij +, aby je zastąpić" +"Mobile app" = "Aplikacja mobilna" +"Mode" = "Tryb" +"Model" = "Model" +"Models" = "Modele" +"Mono" = "Mono" +"More" = "Więcej" +"More instance actions" = "Więcej akcji instancji" +"Mouse down" = "Naciśnięcie myszy" +"Mouse enter" = "Wskazanie myszą (Mouse enter)" +"Mouse leave" = "Opuszczenie kursora myszy" +"Mouse up" = "Zwolnienie przycisku myszy" +"Move along the toolbar or the layer rows" = "Przechodź wzdłuż paska narzędzi lub wierszy warstw" +"Move" = "Przesuń" +"Move column left" = "Przesuń kolumnę w lewo" +"Move column right" = "Przesuń kolumnę w prawo" +"Move down" = "Przesuń w dół" +"Move in" = "Wsuń" +"Move out" = "Wysuń" +"Move tools" = "Narzędzia przesuwania" +"Move up" = "Przesuń w górę" +"Moved to {name}" = "Przeniesiono do: {name}" +"Multi" = "Wiele" +"Multiply" = "Mnożenie" +"Muted" = "Stonowany" +"Name" = "Nazwa" +"Name (A–Z)" = "Nazwa (A–Z)" +"Named instance" = "Nazwana instancja" +"Navigate to" = "Przejdź do" +"Nested instance" = "Zagnieżdżona instancja" +"New design file" = "Nowy plik projektu" +"Newest first" = "Najpierw najnowsze" +"Next or previous control in the focused region" = "Następny lub poprzedni element w aktywnym obszarze" +"Next frame" = "Następna klatka" +"Next ready for dev" = "Następny element gotowy do wdrożenia" +"Next size option" = "Następna opcja rozmiaru" +"No assets: no export settings, icons or images. Add export settings in the Design tab." = "Brak zasobów: brak ustawień eksportu, ikon lub obrazów. Dodaj ustawienia eksportu na karcie Projekt." +"No boolean variables fit here." = "Żadna zmienna logiczna (boolean) tutaj nie pasuje." +"No boolean variables yet. Create them in Local variables." = "Brak zmiennych logicznych. Utwórz je w Zmiennych lokalnych." +"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Brak gałęzi. Gałąź to kopia pliku do testowania zmian, a następnie ich przeglądu i scalenia." +"No category" = "Bez kategorii" +"No changes" = "Brak zmian" +"No color styles" = "Brak stylów kolorów" +"No colour variables fit here." = "Żadna zmienna koloru tutaj nie pasuje." +"No colour variables yet. Create them in Local variables." = "Brak zmiennych koloru. Utwórz je w Zmiennych lokalnych." +"No comments yet" = "Brak komentarzy" +"No compatibility warnings reported" = "Nie zgłoszono ostrzeżeń o zgodności" +"No components yet. Select layers and press {keys}." = "Brak komponentów. Zaznacz warstwy i naciśnij {keys}." +"No conflicts." = "Brak konfliktów." +"No contributor data was built into this copy." = "Ta kopia nie zawiera danych o współtwórcach." +"No device" = "Brak urządzenia" +"No fonts match" = "Brak pasujących czcionek" +"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Brak kodu dołączenia (serwer jest starszy niż kody dołączenia lub ten adres nie może być zakodowany): zamiast tego udostępnij link." +"No layer changes to show." = "Brak zmian warstw do wyświetlenia." +"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Brak bibliotek. Dodaj plik .figcraft opublikowany jako biblioteka lub wklej link do biblioteki zespołu." +"No list" = "Brak listy" +"No matching comments" = "Brak pasujących komentarzy" +"No matching components" = "Brak pasujących komponentów" +"No matching layers" = "Brak pasujących warstw" +"No matching styles" = "Brak pasujących stylów" +"No matching variables" = "Brak pasujących zmiennych" +"No mirroring" = "Brak odbicia lustrzanego" +"No model credits were built into this copy." = "Ta kopia nie zawiera informacji o modelach." +"No number variables fit here." = "Żadna zmienna liczbowa tutaj nie pasuje." +"No number variables yet. Create them in Local variables." = "Brak zmiennych liczbowych. Utwórz je w Zmiennych lokalnych." +"No other pages" = "Brak innych stron" +"No paints" = "Brak wypełnień/obrysów" +"No plugins on {server}." = "Brak wtyczek na {server}." +"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Brak wtyczek. Zaimportuj wtyczkę deweloperską z pliku figcraft-plugin.json." +"No preferred components" = "Brak preferowanych komponentów" +"No preset" = "Brak presetu" +"No saved versions yet. Save a version to mark a point in your work you can come back to." = "Brak zapisanych wersji. Zapisz wersję, aby oznaczyć punkt w pracy, do którego możesz wrócić." +"No scrolling" = "Brak przewijania" +"No shortcuts match" = "Brak pasujących skrótów" +"No status" = "Brak statusu" +"No string variables fit here." = "Żadna zmienna tekstowa tutaj nie pasuje." +"No string variables yet. Create them in Local variables." = "Brak zmiennych tekstowych. Utwórz je w Zmiennych lokalnych." +"No styles yet." = "Brak stylów." +"No styles yet. Click + to create one from the selection." = "Brak stylów. Kliknij +, aby utworzyć styl z zaznaczenia." +"No templates match your search" = "Żaden szablon nie pasuje do wyszukiwania" +"No text styles yet. Click + to create one from the selected text." = "Brak stylów tekstu. Kliknij +, aby utworzyć styl z zaznaczonego tekstu." +"No variables fit here." = "Żadne zmienne tutaj nie pasują." +"No variables yet. Create them in Local variables." = "Brak zmiennych. Utwórz je w Zmiennych lokalnych." +"Nobody else is here yet" = "Nikogo innego tu jeszcze nie ma" +"Noise" = "Szum" +"None" = "Brak" +"None." = "Brak." +"Normal" = "Zwykły" +"Not bound" = "Nieprzypisane" +"Not in a live session" = "Nie jesteś w sesji na żywo" +"Not in this font" = "Brak w tej czcionce" +"Not properties, kept as written: {names}" = "Nie są to właściwości, zachowano bez zmian: {names}" +"Not read yet" = "Jeszcze nieprzeczytane" +"Not shared" = "Nieudostępnione" +"Note {n}" = "Notatka {n}" +"Nothing changed since this was marked ready for dev." = "Nic się nie zmieniło od czasu oznaczenia jako gotowe do wdrożenia." +"Nothing here yet. Set a frame's status to list it." = "Nic tu jeszcze nie ma. Ustaw status ramki, aby ją wyświetlić." +"Nothing online yet. Use Share on a file to put it online and it is listed here." = "Brak plików online. Użyj Udostępnij na pliku, aby umieścić go online i wyświetlić tutaj." +"Nothing to export" = "Nic do wyeksportowania" +"Nov" = "Lis" +"Now" = "Teraz" +"Nudge (Shift: big nudge)" = "Krok przesunięcia (Shift: duży krok)" +"Nudge amount" = "Wielkość kroku przesunięcia" +"Number" = "Liczba" +"Number ↑" = "Liczba ↑" +"Number ↓" = "Liczba ↓" +"Numbered list" = "Lista numerowana" +"Object" = "Obiekt" +"Oct" = "Paź" +"Off" = "Wyłączone" +"Offered first when filling the slot (any content is allowed)" = "Proponowane w pierwszej kolejności przy wypełnianiu gniazda (dowolna zawartość jest dozwolona)" +"Offered first when swapping the instance (any component is allowed)" = "Proponowane w pierwszej kolejności przy podmianie instancji (dowolny komponent jest dozwolony)" +"Offset" = "Przesunięcie" +"Oldest first" = "Najpierw najstarsze" +"On click" = "Przy kliknięciu" +"On drag" = "Podczas przeciągania" +"On this page" = "Na tej stronie" +"One announcement in five common sizes: square, portrait, story, link and banner" = "Jedno ogłoszenie w pięciu popularnych rozmiarach: kwadrat, pion, relacja, link i baner" +"Online" = "Online" +"Online files" = "Pliki online" +"Online sharing is off: files are shared on the local network only." = "Udostępnianie online jest wyłączone: pliki są udostępniane tylko w sieci lokalnej." +"Only the first changed layers are listed." = "Wymieniono tylko pierwsze zmienione warstwy." +"Only the first {n} changed layers are listed." = "Wymieniono tylko pierwsze {n} zmienionych warstw." +"Only your threads" = "Tylko Twoje wątki" +"Opacity" = "Krycie" +"Opacity & blend" = "Nieprzezroczystość i mieszanie" +"Opacity 10%…90%, 0: 100%" = "Nieprzezroczystość 10%…90%, 0: 100%" +"Open" = "Otwórz" +"Open .figcraft, .svg or images" = "Otwórz .figcraft, .svg lub obrazy" +"Open branch" = "Otwórz gałąź" +"Open file" = "Otwórz plik" +"Open files" = "Otwórz pliki" +"Open in focus view" = "Otwórz w widoku skupienia" +"Open in new window" = "Otwórz w nowym oknie" +"Open invite links in FigCraft" = "Otwieraj linki z zaproszeniem w FigCraft" +"Open link" = "Otwórz łącze" +"Open link {url}" = "Otwórz link: {url}" +"Open overlay" = "Otwórz nakładkę" +"Open prototype preview" = "Otwórz podgląd prototypu" +"Open variables ({count})" = "Otwórz zmienne ({count})" +"Opened as a new document" = "Otwarto jako nowy dokument" +"Opened “{name}” in a new tab" = "Otwarto „{name}” w nowej karcie" +"Opening the file…" = "Otwieranie pliku…" +"Opening {n} files as new files" = { one = "Otwieranie jako nowy plik", few = "Otwieranie {n} plików jako nowe pliki", many = "Otwieranie {n} plików jako nowe pliki", other = "Otwieranie {n} plików jako nowe pliki" } +"Open|comments" = "Open|comments" +"Options" = "Opcje" +"Orange" = "Pomarańczowy" +"Order" = "Kolejność" +"Other apps may not open every .fig file exported here." = "Inne aplikacje mogą nie otworzyć każdego pliku .fig wyeksportowanego tutaj." +"Other" = "Inne" +"Others lose access and keep their copy; the file stays on the server for you" = "Inni tracą dostęp i zachowują swoją kopię; plik pozostaje na serwerze dla Ciebie" +"Others type this under Share ▸ Join with a code" = "Inni wpisują to w menu Udostępnij ▸ Dołącz za pomocą kodu" +"Outline stroke" = "Przekształć obrys w ścieżkę" +"Outline text" = "Kontury tekstu" +"Outlines" = "Kontury" +"Outside" = "Na zewnątrz" +"Overflow" = "Przepełnienie (overflow)" +"Overlay" = "Nakładka" +"Overlay position" = "Pozycja nakładki" +"Overridden here (the collection it extends has another value)" = "Nadpisane tutaj (kolekcja, którą rozszerza, ma inną wartość)" +"Overrides" = "Nadpisania" +"Owner token" = "Token właściciela" +"Owner token copied" = "Skopiowano token właściciela" +"PDF print options" = "Opcje druku PDF" +"PDF print settings" = "Ustawienia druku PDF" +"Padding" = "Marginesy wewnętrzne" +"Page" = "Strona" +"Pages" = "Strony" +"Pages added to the document" = "Strony dodane do dokumentu" +"Placed on the current page" = "Umieszczono na bieżącej stronie" +"Pages: {pages}" = "Strony: {pages}" +"Paint bucket" = "Wiadro z farbą" +"Pan the canvas" = "Przesuwaj płótno" +"Pan with nothing selected (Shift: farther)" = "Przesuwaj przy braku zaznaczenia (Shift: dalej)" +"Panels" = "Panele" +"Paper" = "Papier" +"Paragraph indent" = "Wcięcie akapitu" +"Paragraph indent applies to left-aligned text" = "Wcięcie akapitu dotyczy tekstu wyrównanego do lewej" +"Paragraph spacing" = "Odstęp akapitu" +"Pass through" = "Przepuszczanie" +"Paste a link (https://…)" = "Wklej link (https://…)" +"Paste a team library link (a view link)" = "Wklej link do biblioteki zespołu (link do podglądu)" +"Paste here" = "Wklej tutaj" +"Paste to replace" = "Wklej, aby zastąpić" +"Pasted design layers; {n} kinds of content were left out" = { one = "Wklejono warstwy projektu; pominięto {n} rodzaj zawartości", few = "Wklejono warstwy projektu; pominięto {n} rodzaje zawartości", many = "Wklejono warstwy projektu; pominięto {n} rodzajów zawartości", other = "Wklejono warstwy projektu; pominięto {n} rodzajów zawartości" } +"Pattern" = "Wzór" +"Pattern alignment" = "Wyrównanie wzoru" +"Pattern scale" = "Skala wzoru" +"Pattern source" = "Źródło wzoru" +"Pen" = "Pióro" +"Pencil" = "Ołówek" +"People in this file" = "Osoby w tym pliku" +"People join from anywhere with a code. The file is kept on {server} while you share it." = "Użytkownicy dołączają z dowolnego miejsca za pomocą kodu. Plik jest przechowywany na {server} podczas udostępniania." +"People on your network join with a code. Nothing goes through a server." = "Użytkownicy w Twojej sieci dołączają za pomocą kodu. Nic nie przechodzi przez serwer zewnętrzny." +"Phone" = "Telefon" +"Pick color" = "Wybierz kolor" +"Pick color (I)" = "Wybierz kolor (I)" +"Pick one font family first" = "Najpierw wybierz jedną rodzinę czcionek" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "Wybierz narzędzie Ramka, a następnie przeciągnij po płótnie lub wybierz rozmiar urządzenia" +"Pin a property" = "Przypnij właściwość" +"Pinch" = "Uszczypnięcie" +"Pixel grid" = "Siatka pikseli" +"Pixel preview 1x" = "Podgląd pikselowy 1x" +"Pixel preview 2x" = "Podgląd pikselowy 2x" +"Place a shape or frame (its tool chosen)" = "Umieść kształt lub ramkę (wybrane narzędzie)" +"Place automatically" = "Umieść automatycznie" +"Place image" = "Umieść obraz" +"Plugin" = "Wtyczka" +"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Wtyczki działają w piaskownicy FigCraft, bez dostępu do plików, sieci czy zegara. Instaluj tylko zaufane wtyczki." +"Plugins" = "Wtyczki" +"Plugins & widgets" = "Wtyczki i widżety" +"Plus darker" = "Plus ciemniejszy" +"Plus lighter" = "Plus jaśniejszy" +"Point" = "Punkt" +"Points" = "Punkty" +"Polygon" = "Wielokąt" +"Polygons" = "Wielokąty" +"Port" = "Port" +"Position" = "Położenie" +"Post" = "Opublikuj" +"Preferences" = "Preferencje" +"Preferred" = "Preferowane" +"Present" = "Prezentuj" +"Present or preview" = "Prezentuj lub wyświetl podgląd" +"Present this flow" = "Prezentuj ten przepływ" +"Presentation" = "Prezentacja" +"Presentation slides" = "Slajdy prezentacji" +"Presenting: comments" = "Prezentacja: komentarze" +"Presenting: cycle the size options" = "Prezentacja: przełączaj opcje rozmiaru" +"Presenting: full screen" = "Prezentacja: pełny ekran" +"Presenting: next frame" = "Prezentacja: następna ramka" +"Presenting: previous frame" = "Prezentacja: poprzednia ramka" +"Presenting: restart" = "Prezentacja: uruchom ponownie" +"Preserve scroll position" = "Zachowaj pozycję przewijania" +"Presets" = "Ustawienia wstępne" +"Press a key…" = "Naciśnij klawisz…" +"Playground" = "Piaskownica (Playground)" +"Preview" = "Podgląd" +"Preview this flow" = "Podgląd tego przepływu" +"Preview this frame" = "Podgląd tej ramki" +"Previous frame" = "Poprzednia klatka" +"Previous ready for dev" = "Poprzedni gotowy do wdrożenia" +"Progressive" = "Progresywny" +"Properties" = "Właściwości" +"Prototype" = "Prototyp" +"Prototype preview" = "Podgląd prototypu" +"Prototype settings" = "Ustawienia prototypu" +"Publish" = "Publikuj" +"Publish changes" = "Publikuj zmiany" +"Publish to another server…" = "Publikuj na innym serwerze…" +"Publish to server" = "Publikuj na serwerze" +"Published version {v}" = "Opublikowano wersję {v}" +"Published version {v} to the server" = "Opublikowano wersję {v} na serwerze" +"Published version {v}: save the file so other files can use it" = "Opublikowano wersję {v}: zapisz plik, aby inne pliki mogły go używać" +"Published · {people}" = "Opublikowano · {people}" +"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Publikowanie na serwerze wymaga aplikacji desktopowej; w przeglądarce możesz dołączać do plików serwera" +"Publishing with the server's key." = "Publikowanie przy użyciu klucza serwera." +"Publishing…" = "Publikowanie…" +"Push" = "Wypychanie" +"Push changes to main component" = "Wypchnij zmiany do głównego komponentu" +"Put text on path" = "Umieść tekst na ścieżce" +"R" = "R" +"Radial" = "Promienisty" +"Radius" = "Promień" +"Ratio" = "Proporcje" +"Reading the library…" = "Odczytywanie biblioteki…" +"Ready for dev" = "Gotowe do wdrożenia" +"Real name" = "Imię i nazwisko" +"Recent" = "Ostatnie" +"Recent files" = "Ostatnie pliki" +"Recents" = "Ostatnie" +"Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them" = { one = "Odzyskano niezapisany plik po nieoczekiwanym zamknięciu programu: zapisz go, aby go zachować", few = "Odzyskano {n} niezapisane pliki po nieoczekiwanym zamknięciu programu: zapisz je, aby je zachować", many = "Odzyskano {n} niezapisanych plików po nieoczekiwanym zamknięciu programu: zapisz je, aby je zachować", other = "Odzyskano {n} niezapisanych plików po nieoczekiwanym zamknięciu programu: zapisz je, aby je zachować" } +"Rectangle" = "Prostokąt" +"Rectangles" = "Prostokąty" +"Red" = "Czerwony" +"Reduce motion" = "Ogranicz ruch" +"Refraction" = "Załamanie światła (refrakcja)" +"Regular expression" = "Wyrażenie regularne" +"Remove" = "Usuń" +"Remove all" = "Usuń wszystko" +"Remove annotation" = "Usuń adnotację" +"Remove auto layout" = "Usuń Auto layout" +"Remove dev resource" = "Usuń zasób programistyczny" +"Remove export setting" = "Usuń ustawienie eksportu" +"Remove from recents" = "Usuń z ostatnich" +"Remove from server" = "Usuń z serwera" +"Remove from server…" = "Usuń z serwera…" +"Remove guide" = "Usuń linię pomocniczą" +"Remove interaction" = "Usuń interakcję" +"Remove library from this file" = "Usuń bibliotekę z tego pliku" +"Remove min and max" = "Usuń min. i maks." +"Remove min and max height" = "Usuń minimalną i maksymalną wysokość" +"Remove min and max width" = "Usuń minimalną i maksymalną szerokość" +"Remove regions" = "Usuń obszary" +"Remove slot" = "Usuń gniazdo" +"Remove starting point" = "Usuń punkt startowy" +"Remove stop" = "Usuń punkt" +"Remove text from path" = "Usuń tekst ze ścieżki" +"Remove the else and its actions" = "Usuń warunek przeciwny (else) i jego akcje" +"Remove this condition and its actions" = "Usuń ten warunek i jego akcje" +"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Usunąć ten plik z serwera? Nie będzie można go już stamtąd udostępnić ani otworzyć. Twoja kopia pozostanie otwarta tutaj." +"Remove {name} from session" = "Usuń {name} z sesji" +"Remove {name} from {server}?" = "Usunąć {name} z {server}?" +"Removed" = "Usunięto" +"Removed from the server" = "Usunięto z serwera" +"Removed since it was marked" = "Usunięto od momentu oznaczenia" +"Rename" = "Zmień nazwę" +"Rename duplicated layers" = "Zmień nazwę powielonych warstw" +"Rename folder" = "Zmień nazwę folderu" +"Rename group" = "Zmień nazwę grupy" +"Rename layers" = "Zmień nazwę warstw" +"Rename mode" = "Zmień nazwę trybu" +"Rename on the server" = "Zmień nazwę na serwerze" +"Rename on the server…" = "Zmień nazwę na serwerze…" +"Rename page" = "Zmień nazwę strony" +"Rename to" = "Zmień nazwę na" +"Replace all" = "Zamień wszystko" +"Replace mixed export settings" = "Zastąp zróżnicowane ustawienia eksportu" +"Replace with" = "Zamień na" +"Replace with a new interaction" = "Zastąp nową interakcją" +"Reply" = "Odpowiedz" +"Report" = "Raport" +"Report this file…" = "Zgłoś ten plik…" +"Reset" = "Resetuj" +"Reset adjustments" = "Zresetuj korekty" +"Reset all changes" = "Zresetuj wszystkie zmiany" +"Reset all overrides on this layer" = "Zresetuj wszystkie nadpisania na tej warstwie" +"Reset overrides" = "Zresetuj nadpisania" +"Reset slot" = "Zresetuj gniazdo" +"Reset this override" = "Zresetuj to nadpisanie" +"Reset to style" = "Przywróć styl" +"Reset to the inherited value" = "Przywróć wartość dziedziczoną" +"Resize by the nudge amount" = "Zmień rozmiar o wielkość kroku przesunięcia" +"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Dopasuj ramki do ekranu urządzenia (lub okna bez urządzenia): ich ograniczenia i automatyczny układ odpowiednio zareagują" +"Resize to fit" = "Dopasuj rozmiar" +"Resizing" = "Zmiana rozmiaru" +"Resolve" = "Rozwiąż" +"Resolve change" = "Rozwiąż zmianę" +"Resolved" = "Rozwiązany" +"Resources" = "Zasoby" +"Responsive" = "Responsywny" +"Restart" = "Uruchom ponownie" +"Restore" = "Przywróć" +"Restore main component" = "Przywróć główny komponent" +"Restore this version" = "Przywróć tę wersję" +"Restored “{name}”" = "Przywrócono „{name}”" +"Reverse the order" = "Odwróć kolejność" +"Reversed triangle" = "Odwrócony trójkąt" +"Review and merge" = "Przejrzyj i scal" +"Ribbon" = "Wstążka" +"Right" = "Do prawej" +"Right side" = "Prawa strona" +"Right side width" = "Szerokość prawej strony" +"Rotate 90°" = "Obróć o 90°" +"Rotate 90° right" = "Obróć o 90° w prawo" +"Rotate to landscape" = "Obróć do orientacji poziomej" +"Rotate to portrait" = "Obróć do orientacji pionowej" +"Rotation" = "Obrót" +"Rotation jitter" = "Drżenie obrotu (jitter)" +"Round" = "Okrągłe" +"Rounded" = "Zaokrąglone" +"Row" = "Wiersz" +"Row gap" = "Odstęp między wierszami" +"Row sizes" = "Rozmiary wierszy" +"Row span" = "Rozpiętość wierszy" +"Rows" = "Wiersze" +"Rows {count}" = "Wiersze: {count}" +"Rulers" = "Linijki" +"Run" = "Uruchom" +"Run {label}" = "Uruchom {label}" +"Save files automatically" = "Zapisuj pliki automatycznie" +"Search templates" = "Szukaj szablonów" +"See all templates" = "Zobacz wszystkie szablony" +"Select layers and they space themselves as their content changes" = "Zaznacz warstwy, a będą automatycznie zachowywać odstępy przy zmianie zawartości" +"SHA-256: {sha}…" = "SHA-256: {sha}…" +"Same as layer" = "Taki sam jak warstwa" +"Saturation" = "Nasycenie" +"Save" = "Zapisz" +"Save as" = "Zapisz jako" +"Save as preset…" = "Zapisz jako preset…" +"Save version {key}" = "Zapisz wersję {key}" +"Saved" = "Zapisano" +"Saved {name}" = "Zapisano: {name}" +"Say something" = "Napisz coś" +"Scale" = "Skaluj" +"Scatter" = "Rozproszenie" +"Scoping: where pickers offer it" = "Zakres: gdzie próbniki go oferują" +"Screen" = "Ekran" +"Scroll behavior" = "Zachowanie przewijania" +"Scroll to" = "Przewiń do" +"Scroll to layer" = "Przewiń do warstwy" +"Scroll with parent" = "Przewijaj z rodzicem" +"Search plugins" = "Szukaj wtyczek" +"Search" = "Szukaj" +"Search actions, layers and tools" = "Szukaj akcji, warstw i narzędzi" +"Search assets" = "Szukaj zasobów" +"Search comments" = "Szukaj komentarzy" +"Search fonts" = "Szukaj czcionek" +"Search layers" = "Szukaj warstw" +"Search shortcuts" = "Szukaj skrótów" +"Section" = "Sekcja" +"Section color" = "Kolor sekcji" +"Sections" = "Sekcje" +"Select a frame and add a starting point to present it as a flow." = "Wybierz ramkę i dodaj punkt początkowy, aby zaprezentować ją jako przepływ." +"Select a frame to preview it" = "Wybierz ramkę, aby wyświetlić jej podgląd" +"Select a layer to see its properties and code" = "Wybierz warstwę, aby zobaczyć jej właściwości i kod" +"Select all" = "Zaznacz wszystko" +"Select all with same" = "Zaznacz wszystko z takim samym" +"Select layer" = "Zaznacz warstwę" +"Select layers to apply the style to" = "Zaznacz warstwy, do których chcesz zastosować styl" +"Select layers with this color" = "Zaznacz warstwy z tym kolorem" +"Select overlapping shapes" = "Zaznacz nakładające się kształty" +"Select points to edit their position, radius and mirroring" = "Zaznacz punkty, aby edytować ich pozycję, promień i odbicie lustrzane" +"Select slot" = "Zaznacz gniazdo (slot)" +"Selection colors" = "Kolory zaznaczenia" +"Selection" = "Zaznaczenie" +"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Ramka zaznaczenia, {w} × {h} w ({x}, {y}): strzałki przesuwają, Shift+strzałki lub {cmd}+strzałki zmieniają rozmiar, Enter zatwierdza, Escape anuluje" +"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Ramka zaznaczenia: strzałki przesuwają, Shift+strzałki lub {cmd}+strzałki zmieniają rozmiar, Enter zatwierdza, Escape anuluje" +"Send backward" = "Przenieś do tyłu" +"Send report" = "Wyślij zgłoszenie" +"Send to back" = "Przenieś na spód" +"Sep" = "Wrz" +"Server address (ws://host:47900/)" = "Adres serwera (ws://host:47900/)" +"Server address" = "Adres serwera" +"Server for online sharing" = "Serwer do udostępniania online" +"Set as default" = "Ustaw jako domyślne" +"Set variable" = "Ustaw zmienną" +"Set variable mode" = "Ustaw tryb zmiennej" +"Set variable {name}" = "Ustaw zmienną {name}" +"Set {name} to {value}" = "Ustaw {name} na {value}" +"Shadows" = "Cienie" +"Shape" = "Kształt" +"Shape builder" = "Kreator kształtów" +"Shape tools" = "Narzędzia kształtów" +"Share" = "Udostępnij" +"Share this file" = "Udostępnij ten plik" +"Share with a code" = "Udostępnij za pomocą kodu" +"Share: live session, export" = "Udostępnianie: sesja na żywo, eksport" +"Shared" = "Udostępniono" +"Shared: copy its code from the file's menu" = "Udostępniono: skopiuj kod z menu pliku" +"Sharing a file needs the desktop app; here you can join one." = "Udostępnianie pliku wymaga aplikacji desktopowej; tutaj możesz tylko dołączyć." +"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Udostępnianie w sieci lokalnej (udostępnianie online wyłączone: brak serwera w Preferencjach)." +"Sharing on your network · {people}" = "Udostępnianie w Twojej sieci · {people}" +"Sharing online · {people}" = "Udostępnianie online · {people}" +"Sharing stopped: the file stays on the server, open only to you" = "Zatrzymano udostępnianie: plik pozostaje na serwerze, otwarty tylko dla Ciebie" +"Show" = "Pokaż" +"Show all templates" = "Pokaż wszystkie szablony" +"Show as grid" = "Pokaż jako siatkę" +"Show as list" = "Pokaż jako listę" +"Show autosaves" = "Pokaż automatyczne zapisy" +"Show behind transparent areas" = "Pokaż za przezroczystymi obszarami" +"Show comments (Shift+C)" = "Pokaż komentarze (Shift+C)" +"Show device frame" = "Pokaż ramkę urządzenia" +"Show dimensions on objects" = "Pokaż wymiary na obiektach" +"Show hotspot hints" = "Pokaż podpowiedzi obszarów klikalnych (hotspots)" +"Show in Explorer" = "Pokaż w Eksploratorze" +"Show in Finder" = "Pokaż w Finderze" +"Show in folder" = "Pokaż w folderze" +"Show only this on the canvas" = "Pokaż tylko ten element na płótnie" +"Show open" = "Pokaż otwarte" +"Show section contents" = "Pokaż zawartość sekcji" +"Show this layer" = "Pokaż tę warstwę" +"Show usage" = "Pokaż użycie" +"Show/Hide" = "Pokaż/Ukryj" +"Show/Hide UI" = "Pokaż/Ukryj interfejs" +"Side by side" = "Obok siebie" +"Sidebar" = "Panel boczny" +"Simplify stroke" = "Uprość obrys" +"Size" = "Rozmiar" +"Size jitter" = "Rozrzut rozmiaru" +"Skip ink" = "Pomiń atrament" +"Slashed zero" = "Przekreślone zero" +"Slice" = "Wycinek" +"Slices" = "Plasterki" +"Slide in" = "Wsuń slajd" +"Slide out" = "Wysuń slajd" +"Slot" = "Gniazdo (slot)" +"Slot content" = "Zawartość gniazda" +"Small capitals" = "Kapitaliki" +"Small caps" = "Kapitaliki" +"Smart animate" = "Inteligentna animacja (Smart animate)" +"Snap to pixel grid" = "Przyciągaj do siatki pikseli" +"Social and slides" = "Social media i slajdy" +"Social media" = "Media społecznościowe" +"Social post sizes" = "Rozmiary postów w mediach społecznościowych" +"Soft light" = "Łagodne światło" +"Solid" = "Jednolity" +"Some variants have the same property values: give each one a unique combination" = "Niektóre warianty mają takie same wartości właściwości: nadaj każdemu unikalną kombinację" +"Sort" = "Sortuj" +"Sort by" = "Sortuj według" +"Sort by date" = "Sortuj według daty" +"Sort by page" = "Sortuj według strony" +"Sort by {column}" = "Sortuj według: {column}" +"Space around" = "Równe odstępy z marginesem (Space around)" +"Space between" = "Równe odstępy między elementami (Space between)" +"Space drag" = "Przesuwanie spacją" +"Space evenly" = "Rozmieść równomiernie" +"Space+drag" = "Spacja+przeciągnięcie" +"Spacing" = "Odstępy" +"Spam, stolen work, abuse…" = "Spam, plagiat, naruszenie zasad…" +"Splay" = "Rozszerzenie" +"Spotlight me" = "Wyróżnij mój widok (Spotlight me)" +"Spread" = "Rozszerzenie" +"Square" = "Kwadratowe" +"Standard" = "Standardowy" +"Standard ligatures" = "Ligatury standardowe" +"Star" = "Gwiazda" +"Stars" = "Gwiazdy" +"Start" = "Uruchom" +"Start ascending from / stop descending at" = "Początek narastania od / koniec opadania w" +"Start from a blank canvas" = "Zacznij od pustego płótna" +"Start from a template" = "Zacznij od szablonu" +"Start on path" = "Początek na ścieżce" +"Start point" = "Punkt początkowy" +"Start sharing" = "Rozpocznij udostępnianie" +"Start sharing with the Share button, then pass on the code it gives" = "Rozpocznij udostępnianie przyciskiem Udostępnij, a następnie przekaż wygenerowany kod" +"Starting to share…" = "Rozpoczynanie udostępniania…" +"Status" = "Stan" +"Sticky" = "Przyklejony (sticky)" +"Stop" = "Zatrzymaj" +"Stop following {name}" = "Przestań obserwować: {name}" +"Stop session" = "Zatrzymaj sesję" +"Stop sharing" = "Zatrzymaj udostępnianie" +"Stop spotlighting" = "Zatrzymaj wyróżnianie widoku" +"Stop {n} {hex}" = "Punkt przejścia {n} {hex}" +"Stretch" = "Rozciągnięcie" +"Strikethrough" = "Przekreślenie" +"String" = "Tekst (string)" +"Stroke" = "Obrys" +"Stroke style" = "Styl obrysu" +"Stroke weight" = "Grubość obrysu" +"Strokes" = "Obrysy" +"Style" = "Styl" +"Styles" = "Style" +"Stylistic set 1" = "Zestaw stylistyczny 1" +"Stylistic set 2" = "Zestaw stylistyczny 2" +"Stylistic set 3" = "Zestaw stylistyczny 3" +"Stylistic set 4" = "Zestaw stylistyczny 4" +"Stylistic set 5" = "Zestaw stylistyczny 5" +"Subtract" = "Odejmij" +"Subtracts" = "Odejmuje" +"Suffix" = "Przyrostek" +"Suspend snapping while dragging" = "Zawieś przyciąganie podczas przeciągania" +"Swap instance" = "Podmień instancję" +"Swap library: use another library's assets of the same names" = "Podmień bibliotekę: użyj zasobów innej biblioteki o tych samych nazwach" +"Swap orientation" = "Zmień orientację" +"Swap overlay" = "Podmień nakładkę" +"Swap to {name}" = "Podmień na {name}" +"Swap width and height" = "Zamień szerokość i wysokość" +"Swapped instance" = "Podmieniono instancję" +"Swapped {n} assets" = { one = "Podmieniono {n} zasób", few = "Podmieniono {n} zasoby", many = "Podmieniono {n} zasobów", other = "Podmieniono {n} zasobów" } +"Swapped {n} assets; {missed} had no match and still use the old library" = { one = "Podmieniono {n} zasób; {missed} nie ma dopasowania i nadal używa starej biblioteki", few = "Podmieniono {n} zasoby; {missed} nie ma dopasowania i nadal używa starej biblioteki", many = "Podmieniono {n} zasobów; {missed} nie ma dopasowania i nadal używa starej biblioteki", other = "Podmieniono {n} zasobów; {missed} nie ma dopasowania i nadal używa starej biblioteki" } +"Sweep" = "Przemiatanie (sweep)" +"System" = "Systemowy" +"Tab / Shift+Tab" = "Tab / Shift+Tab" +"Table" = "Tabela" +"Tablet" = "Tablet" +"Tabular numbers" = "Cyfry tabelaryczne" +"Take the values from the first selected layer" = "Pobierz wartości z pierwszej zaznaczonej warstwy" +"Taper" = "Zwężanie" +"Taper both ends" = "Zwężaj oba końce" +"Tell the server's operator what is wrong with it" = "Poinformuj administratora serwera, co jest nie tak" +"Temperature" = "Temperatura" +"Template" = "Szablon" +"Templates" = "Szablony" +"Terms" = "Warunki" +"Text" = "Tekst" +"Text align" = "Wyrównanie tekstu" +"Text content" = "Zawartość tekstu" +"Text style" = "Styl tekstu" +"Text styles" = "Style tekstu" +"Texts" = "Teksty" +"Texture" = "Tekstura" +"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "Płótno wyświetla podgląd kolorów w sRGB; wyeksportowane pliki PNG, JPG i WebP są oznaczane profilem Display P3." +"The destination document is unavailable. Nothing was imported." = "Dokument docelowy jest niedostępny. Nic nie zostało zaimportowane." +"The destination document was closed. Nothing was imported." = "Dokument docelowy został zamknięty. Nic nie zostało zaimportowane." +"The destination page was removed. Nothing was imported." = "Strona docelowa została usunięta. Nic nie zostało zaimportowane." +"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "Plik został usunięty z serwera: nikt nie może go już stamtąd otworzyć ani do niego dołączyć, a tej operacji nie można cofnąć. Otwarte kopie pozostają u ich posiadaczy." +"The file now holds another library" = "Plik zawiera teraz inną bibliotekę" +"The file now holds another library (\"{name}\")" = "Plik zawiera teraz inną bibliotekę („{name}”)" +"The files you shared or published online from this computer. They stay on their server until you remove them." = "Pliki udostępnione lub opublikowane online z tego komputera. Pozostają na serwerze do momentu ich usunięcia." +"The link carries the session's secret: share it only with people you invite." = "Link zawiera sekret sesji: udostępniaj go tylko osobom, które zapraszasz." +"The name in your online files and on the server. The document inside keeps its own name." = "Nazwa w Twoich plikach online i na serwerze. Dokument wewnątrz zachowuje własną nazwę." +"The selection uses several text styles: click to apply one" = "Zaznaczenie używa kilku stylów tekstu: kliknij, aby zastosować jeden" +"The server deletes this file if nobody opens it before {date}." = "Serwer usunie ten plik, jeśli nikt go nie otworzy przed {date}." +"The whole file" = "Cały plik" +"Theme" = "Motyw" +"There's no destination for that action" = "Brak miejsca docelowego dla tej akcji" +"Thickness" = "Grubość" +"This branch can't be reviewed: {e}" = "Tej gałęzi nie można przejrzeć: {e}" +"This branch was merged or archived." = "Ta gałąź została scalona lub zarchiwizowana." +"This computer only" = "Tylko ten komputer" +"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Ten komputer pamięta token właściciela, więc plik może być zarządzany z listy Online na ekranie głównym. Skopiuj go, aby zarządzać plikiem z innego miejsca: jest to hasło do tego pliku i nie zostanie ponownie wyświetlone." +"This condition is edited as an expression" = "Ten warunek jest edytowany jako wyrażenie" +"This file" = "Ten plik" +"This file has unsaved changes" = "Ten plik ma niezapisane zmiany" +"This file is on {server}, open only to you. Share it to let others in with a new code." = "Ten plik znajduje się na {server}, otwarty tylko dla Ciebie. Udostępnij go, aby wpuścić innych nowym kodem." +"This frame is new on the branch." = "Ta ramka jest nowa w gałęzi." +"This frame isn't on the branch." = "Tej ramki nie ma w gałęzi." +"This server is open to the public: no key needed." = "Ten serwer jest otwarty publicznie: klucz nie jest wymagany." +"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Ten serwer wymaga klucza: dodaj ?key=… do adresu (polecenie `figcraft-server key` wyświetla go na maszynie serwera)." +"This server takes files only with its key: see Advanced." = "Ten serwer przyjmuje pliki tylko z kluczem: zobacz Zaawansowane." +"Tidy up" = "Uporządkuj" +"Tile" = "Sąsiadująco" +"Tile type" = "Typ kafelka" +"Tint" = "Odcień barwny" +"Title" = "Tytuł" +"Title case" = "Jak W Tytule" +"To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Aby udostępnić tę bibliotekę zespołowi, opublikuj plik na serwerze (Udostępnij ▸ Publikuj na serwerze…), a następnie opublikuj tam bibliotekę." +"Toggle" = "Przełącz" +"Toggle light/dark theme" = "Przełącz motyw jasny/ciemny" +"Toggle visibility" = "Przełącz widoczność" +"Tools" = "Narzędzia" +"Top" = "Góra" +"Top & bottom" = "Góra i dół" +"Top left radius" = "Promień górnego lewego narożnika" +"Top right radius" = "Promień górnego prawego narożnika" +"Transform" = "Przekształć" +"Triangle arrow" = "Strzałka trójkątna" +"Trigger" = "Wyzwalacz" +"True" = "Prawda" +"Truncate text" = "Utnij tekst" +"Truncate with an ellipsis" = "Utnij tekst z wielokropkiem (…)" +"Try again" = "Spróbuj ponownie" +"Try another search or filter." = "Wypróbuj inne wyszukiwanie lub filtr." +"Try another word, or look at every kind." = "Wypróbuj inne słowo lub przejrzyj wszystkie typy." +"Turn a layer into a component, reuse it, and change every copy at once" = "Przekształć warstwę w komponent, używaj jej ponownie i modyfikuj wszystkie kopie naraz" +"Two digits" = "Dwie cyfry" +"Type" = "Tekst" +"Type settings" = "Ustawienia tekstu" +"Type the join code you were given, or paste an invite link." = "Wpisz otrzymany kod dołączenia lub wklej link z zaproszeniem." +"Typography" = "Typografia" +"Underline" = "Podkreślenie" +"Undo takes back only your own changes; what others changed since stays." = "Cofanie odwołuje tylko Twoje własne zmiany; modyfikacje wprowadzone przez innych pozostają." +"Ungroup" = "Rozgrupuj" +"Uniform" = "Jednolity" +"Union" = "Suma zbiorów" +"Unions" = "Sumy" +"Unknown — treated as sRGB" = "Nieznany — traktowany jako sRGB" +"Unread" = "Nieprzeczytane" +"Unresolve" = "Cofnij rozwiązanie" +"Unsaved changes" = "Niezapisane zmiany" +"Unsaved changes in {names}. Close the window and lose them?" = "Niezapisane zmiany w: {names}. Zamknąć okno i utracić je?" +"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Niezapisane zmiany: najpierw zapisz ({save}) lub zamknij kartę z {alt}-kliknięciem, aby odrzucić" +"Unsaved changes: save first, or close again with {key} to discard" = "Niezapisane zmiany: najpierw zapisz lub naciśnij ponownie {key}, aby odrzucić" +"Up" = "W górę" +"Update plugin" = "Zaktualizuj wtyczkę" +"Update" = "Aktualizuj" +"Update from main" = "Zaktualizuj z gałęzi głównej" +"Update from selection" = "Zaktualizuj z zaznaczenia" +"Updated {components} and {styles}" = "Zaktualizowano {components} i {styles}" +"Updated {name}" = "Zaktualizowano {name}" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Aktualizacja sterownika graficznego często pomaga. Aby używać tylko OpenGL, uruchom FigCraft z wiersza poleceń za pomocą:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Aktualizacja sterownika graficznego często pomaga. Zmienna WGPU_BACKEND (vulkan, gl, metal) pozwala wybrać inne graficzne API do wypróbowania." +"Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Przesyła ten plik na serwer FigCraft i synchronizuje tę kartę na bieżąco. Otrzymujesz link do edycji oraz link do podglądu." +"Uppercase" = "WIELKIE LITERY" +"Use a fixed value" = "Użyj stałej wartości" +"Use an expression, e.g. count + 1" = "Użyj wyrażenia, np. count + 1" +"Use as mask" = "Użyj jako maski" +"Use auto layout" = "Użyj automatycznego układu" +"Use number keys for opacity" = "Użyj klawiszy numerycznych dla nieprzezroczystości" +"Use the .figcraft or .fig extension." = "Użyj rozszerzenia .figcraft lub .fig." +"Use the layer's fill" = "Użyj wypełnienia warstwy" +"Used in {files} · {instances}" = "Używane w {files} · {instances}" +"Username" = "Nazwa użytkownika" +"Value" = "Wartość" +"Uses variables: each layer shows them in its own mode" = "Używa zmiennych: każda warstwa wyświetla je we własnym trybie" +"Variable" = "Zmienna" +"Variable collections" = "Kolekcje zmiennych" +"Variable modes" = "Tryby zmiennych" +"Variable width" = "Zmienna szerokość" +"Variables" = "Zmienne" +"Variables JSON…" = "JSON ze zmiennymi…" +"Variables store reusable values (colours, numbers, strings, booleans) with modes such as Light and Dark." = "Zmienne przechowują wartości wielokrotnego użytku (kolory, liczby, teksty, wartości logiczne) z trybami takimi jak Jasny i Ciemny." +"Variant" = "Wariant" +"Vector" = "Wektor" +"Vectors" = "Wektory" +"Version" = "Wersja" +"Version history" = "Historia wersji" +"Version {version}" = "Wersja {version}" +"Version {v} · {components} · {styles} · {variables}" = "Wersja {v} · {components} · {styles} · {variables}" +"Vertical" = "Pionowo" +"Vertical align" = "Wyrównanie pionowe" +"Vertical hex" = "Pionowy sześciokąt" +"Vertical padding" = "Marginesy pionowe" +"Vertical spacing" = "Odstępy pionowe" +"Vertical trim" = "Przycinanie pionowe (vertical trim)" +"View" = "Widok" +"View link" = "Link do podglądu" +"View link copied" = "Skopiowano link do podglądu" +"View only" = "Tylko podgląd" +"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "Tylko podgląd: widzisz każdą zmianę w czasie rzeczywistym, ale nie możesz edytować. Opuść sesję, aby zachować kopię do edycji." +"Viewing “{name}”" = "Podglądanie „{name}”" +"Violet" = "Fiolet" +"Visibility" = "Widoczność" +"Visible" = "Widoczne" +"Vivid" = "Żywe kolory" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "Zmienna WGPU_BACKEND jest ustawiona i określa używane API graficzne; jej usunięcie pozwala FigCraft dokonać wyboru." +"Warm" = "Ciepły" +"Watch" = "Zegarek" +"Wavy" = "Falisty" +"Web" = "Internet" +"Websites" = "Strony internetowe" +"WebSocket URL" = "Adres WebSocket URL" +"WebSocket URL copied" = "Skopiowano adres WebSocket URL" +"Wedge" = "Klin" +"Weight" = "Grubość" +"What you try here changes this preview and the code below, not the file" = "To, co wypróbujesz tutaj, zmienia ten podgląd i kod poniżej, a nie plik" +"What changed since it was marked ready for dev" = "Co się zmieniło od czasu oznaczenia jako gotowe do wdrożenia" +"What is wrong with this file? The server's operator gets your report." = "Co jest nie tak z tym plikiem? Administrator serwera otrzyma Twoje zgłoszenie." +"What it's for" = "Do czego to służy" +"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "Nie wiadomo, w stosunku do czego zaszła zmiana: ramka zmieniła się, zanim FigCraft zaczął zapisywać kopię ramek gotowych do wdrożenia. Rozwiąż zmianę (lub oznacz ponownie jako gotowe), aby móc porównywać od teraz." +"While hovering" = "Podczas najechania" +"While pressing" = "Podczas naciskania" +"White" = "Biały" +"Who runs it, its terms and how long it keeps files" = "Kto zarządza serwerem, jego regulamin i czas przechowywania plików" +"Why it changed" = "Dlaczego to się zmieniło" +"Widget" = "Widżet" +"Widgets" = "Widżety" +"Width" = "Szerokość" +"Width point" = "Punkt szerokości" +"Width profile" = "Profil szerokości" +"Wireframe kit" = "Zestaw makiet (Wireframe kit)" +"With the layers' export settings" = "Z ustawieniami eksportu warstw" +"Wrap" = "Owinięcie" +"Wrap in new section" = "Zawiń w nową sekcję" +"Yellow" = "Żółty" +"Yesterday" = "Wczoraj" +"You" = "Ty" +"You'll get the shared file as a new tab and edit it together." = "Otrzymasz udostępniony plik jako nową kartę i będziecie go edytować wspólnie." +"You're spotlighting: everyone follows your view" = "Wyróżniasz swój widok (spotlight): wszyscy podążają za Twoim widokiem" +"Your copy of the file stays open" = "Twoja kopia pliku pozostaje otwarta" +"Your copy stays open; the file stays on the server" = "Twoja kopia pozostaje otwarta; plik pozostaje na serwerze" +"Your name" = "Twoje imię i nazwisko" +"Your name in comments" = "Twoje imię w komentarzach" +"Your system asks for reduced motion: animations stay off." = "Twój system preferuje ograniczony ruch: animacje pozostają wyłączone." +"Z" = "Z" +"Z to A" = "Od Z do A" +"Zoom" = "Powiększenie" +"Zoom around the pointer" = "Powiększaj wokół kursora" +"Zoom device to fill window" = "Dopasuj urządzenie do okna z powiększeniem" +"Zoom in" = "Powiększ" +"Zoom out" = "Pomniejsz" +"Zoom to 100%" = "Powiększ do 100%" +"Zoom to 200%" = "Powiększ do 200%" +"Zoom to 50%" = "Zmniejsz do 50%" +"Zoom to fit" = "Dopasuj do okna" +"Zoom to selection" = "Powiększaj do zaznaczenia" +"Zoom tool: click to zoom in, drag an area" = "Narzędzie powiększenia: kliknij, aby przybliżyć, przeciągnij obszar" +"Zoom tool: zoom out" = "Narzędzie powiększenia: oddal" +"and {n} more" = { one = "i jeszcze {n}", few = "i jeszcze {n}", many = "i jeszcze {n}", other = "i jeszcze {n}" } +"bleed marks" = "znaczniki spadu" +"by {author}" = "autor: {author}" +"call any command its access allows" = "wywołaj dowolne polecenie, na które zezwala dostęp" +"call only: {commands}" = "wywołaj tylko: {commands}" +"can edit the document" = "może edytować dokument" +"conditional prototype actions nested too deeply were skipped" = "zbyt głęboko zagnieżdżone warunkowe akcje prototypu zostały pominięte" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "warunkowe akcje prototypu z warunkami niemożliwymi do obliczenia zostały pominięte" +"connector label background was skipped: style data is missing" = "tło etykiety łącznika zostało pominięte: brak danych stylu" +"connector label was skipped: background bounds are invalid" = "etykieta łącznika została pominięta: nieprawidłowe granice tła" +"connector label was skipped: background transform is invalid" = "etykieta łącznika została pominięta: nieprawidłowe przekształcenie tła" +"connector label was skipped: composed text transform is invalid" = "etykieta łącznika została pominięta: nieprawidłowe przekształcenie tekstu złożonego" +"connector label was skipped: derived background bounds are missing" = "etykieta łącznika została pominięta: brak wyznaczonych granic tła" +"connector label was skipped: derived text bounds are missing" = "etykieta łącznika została pominięta: brak wyznaczonych granic tekstu" +"connector label was skipped: text bounds are invalid" = "etykieta łącznika została pominięta: nieprawidłowe granice tekstu" +"connector label was skipped: text style data is missing" = "etykieta łącznika została pominięta: brak danych stylu tekstu" +"connector label was skipped: text transform is invalid" = "etykieta łącznika została pominięta: nieprawidłowe przekształcenie tekstu" +"connector labels were skipped" = "etykiety łączników zostały pominięte" +"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "warstwy łączników są importowane jako statyczne ramki/wektory z etykietami; aktywne punkty końcowe nie są zachowywane" +"connector snapshot vector data is missing; connector was left empty" = "brak danych wektorowych łącznika; łącznik pozostał pusty" +"connectors whose ends can't be placed are empty" = "łączniki, których końców nie można umieścić, pozostają puste" +"crop marks" = "Znaczniki przycięcia" +"damaged vector geometry was skipped" = "uszkodzona geometria wektorowa została pominięta" +"damaged vector networks import as outlines" = "uszkodzone sieci wektorowe są importowane jako kontury" +"development: {path}" = "wersja rozwojowa: {path}" +"document children other than pages were skipped" = "elementy podrzędne dokumentu inne niż strony zostały pominięte" +"duplicate grid track sizing entries use equal tracks" = "zduplikowane wpisy rozmiaru siatki używają równych pasów" +"e.g. count + 1" = "np. count + 1" +"e.g. count >= 3 and not done" = "np. count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "złamane i zakrzywione łączniki bez zapisanej ścieżki są importowane jako linie proste" +"entries without a node type were skipped" = "wpisy bez typu węzła zostały pominięte" +"figcraft:// links from a browser, chat or email open this app and join the session." = "linki figcraft:// z przeglądarki, czatu lub poczty e-mail otwierają tę aplikację i dołączają do sesji." +"filled areas of a vector with their own fill use the layer's fill" = "wypełnione obszary wektora z własnym wypełnieniem używają wypełnienia warstwy" +"from {server}" = "z serwera {server}" +"gamepad triggers without exactly one known controller button were skipped" = "wyzwalacze kontrolera bez dokładnie jednego znanego przycisku zostały pominięte" +"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "lista ścieżek siatki przekracza 1000 pozycji; ścieżki używają domyślnego równego rozmiaru" +"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "rozmiar ścieżek siatki jest nieobsługiwany lub nieprawidłowy; dotknięte ścieżki używają równego rozmiaru" +"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "lista rozmiarów ścieżek siatki przekracza 1000 pozycji; ścieżki używają domyślnego równego rozmiaru" +"hidden" = "ukryty" +"iOS" = "iOS" +"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "filtry obrazu są niejednoznaczne, ponieważ obecne są oba kontenery filtrów; konwersja filtrów została pominięta" +"image filters use FigCraft's approximate renderer" = "filtry obrazu używają przybliżonego renderera FigCraft" +"instance swap properties whose default component isn't in the file were skipped" = "właściwości podmiany instancji, których domyślny komponent nie znajduje się w pliku, zostały pominięte" +"instances inside their own main component were imported as frames" = "instancje wewnątrz własnego głównego komponentu zostały zaimportowane jako ramki" +"instances of components that aren't in the file show no layers" = "instancje komponentów, których nie ma w pliku, nie pokazują żadnych warstw" +"instances of components that aren't in the file were imported as frames" = "instancje komponentów, których nie ma w pliku, zostały zaimportowane jako ramki" +"instances that would nest a component inside itself were imported as frames" = "instancje zagnieżdżające komponent wewnątrz samego siebie zostały zaimportowane jako ramki" +"instances without a main component were imported as empty frames" = "instancje bez głównego komponentu zostały zaimportowane jako puste ramki" +"invalid internal .fig text path data was ignored" = "nieprawidłowe wewnętrzne dane ścieżki tekstu .fig zostały zignorowane" +"invalid or oversized vector networks import as outlines" = "nieprawidłowe lub przewymiarowane sieci wektorowe są importowane jako kontury" +"invalid or oversized vector path data was skipped" = "nieprawidłowe lub przewymiarowane dane ścieżki wektorowej zostały pominięte" +"just now" = "przed chwilą" +"key triggers without a supported key were skipped" = "wyzwalacze klawiszy z nieobsługiwanym klawiszem zostały pominięte" +"key up triggers import as key presses" = "wyzwalacze zwolnienia klawisza są importowane jako naciśnięcia klawiszy" +"layers without a position were placed at their parent's origin" = "warstwy bez określonej pozycji zostały umieszczone w punkcie początkowym rodzica" +"layers without a type were skipped" = "warstwy bez typu zostały pominięte" +"legacy image filter container is unsupported; filter conversion was skipped" = "przestarzały kontener filtrów obrazu jest nieobsługiwany; konwersja filtrów została pominięta" +"locked" = "Zablokowane" +"malformed image filters container was skipped" = "nieprawidłowy kontener filtrów obrazu został pominięty" +"malformed image paintFilter container was skipped" = "nieprawidłowy kontener filtrów malowania (paintFilter) został pominięty" +"malformed slot content assignment was skipped" = "nieprawidłowe przypisanie zawartości gniazda zostało pominięte" +"no bleed" = "brak spadu" +"nobody else here yet" = "nikogo innego tu jeszcze nie ma" +"non-default slot settings are not represented and were skipped" = "niedomyślne ustawienia gniazd nie są reprezentowane i zostały pominięte" +"now" = "teraz" +"or" = "lub" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "połączenia punktów obrysu zostały zachowane; obrysy przerywane lub o zmiennej szerokości renderują się z łączeniem warstwy" +"preferred slot component keys cannot be resolved to file nodes and were skipped" = "klucze preferowanych komponentów gniazda nie mogą zostać dopasowane do węzłów pliku i zostały pominięte" +"prototype actions of unknown types were skipped" = "akcje prototypu o nieznanych typach zostały pominięte" +"prototype actions without a destination in the file were skipped" = "akcje prototypu bez miejsca docelowego w pliku zostały pominięte" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "wyrażenia prototypu operujące na zmiennych, których nie ma w pliku JSON ze zmiennymi, zostały pominięte" +"prototype expressions with unsupported functions were skipped" = "wyrażenia prototypu z nieobsługiwanymi funkcjami zostały pominięte" +"prototype interactions without supported actions were skipped" = "interakcje prototypu bez obsługiwanych akcji zostały pominięte" +"prototype navigations of unknown types were skipped" = "nawigacje prototypu o nieznanych typach zostały pominięte" +"prototype transitions of unknown types play instantly" = "przejścia prototypu o nieznanych typach odtwarzają się natychmiast" +"prototype triggers of unknown types were skipped" = "wyzwalacze prototypu o nieznanych typach zostały pominięte" +"put a widget on the canvas" = "umieść widżet na płótnie" +"read and edit the document" = "odczytuj i edytuj dokument" +"read the document" = "odczytuj dokument" +"reads the document" = "odczytuje dokument" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "akcje set-variable operujące na zmiennych, których nie ma w pliku JSON ze zmiennymi, zostały pominięte" +"set-variable actions with values that can't be evaluated were skipped" = "akcje set-variable z wartościami, których nie można obliczyć, zostały pominięte" +"set-variable actions with values that don't fit the variable were skipped" = "akcje set-variable z wartościami niepasującymi do zmiennej zostały pominięte" +"set-variable actions without a value were skipped" = "akcje set-variable bez wartości zostały pominięte" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "akcje set-variable-mode dla kolekcji lub trybów, których nie ma w pliku JSON ze zmiennymi, zostały pominięte" +"slot content assignment target is missing or is not slot content" = "cel przypisania zawartości gniazda nie istnieje lub nie jest zawartością gniazda" +"slot content assignment uses the empty GUID sentinel and was skipped" = "przypisanie zawartości gniazda używa pustego identyfikatora GUID i zostało pominięte" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "niektóre punkty wektorowe odwołują się do ustawień punktów brakujących w pliku; używają połączeń warstwy i zwykłych narożników" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "niektóre segmenty wektorowe odwołują się do ustawień stylu brakujących w pliku; używają obrysu warstwy" +"stale or invalid grid track sizing entries were ignored" = "nieaktualne lub nieprawidłowe wpisy rozmiarów ścieżek siatki zostały zignorowane" +"stars and polygons without point counts or geometry use the default point count" = "gwiazdy i wielokąty bez określonej liczby wierzchołków lub geometrii używają domyślnej liczby punktów" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "ustawienia stylu niektórych segmentów wektorowych nie są obsługiwane; segmenty te używają obrysu warstwy" +"text path underline or strikethrough decoration is not rendered" = "podkreślenie lub przekreślenie tekstu na ścieżce nie jest renderowane" +"text style overrides refer to missing table entries" = "nadpisania stylu tekstu odwołują się do brakujących wpisów w tabeli" +"the file has no pages" = "plik nie zawiera stron" +"the plugin" = "wtyczka" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "plik JSON ze zmiennymi nie zawiera `variableCollections` (oczekiwano odpowiedzi ze zmiennymi lokalnymi) i został zignorowany" +"this session only" = "tylko ta sesja" +"unknown component property types were skipped" = "nieznane typy właściwości komponentu zostały pominięte" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "aliasy zmiennych wskazujące na zmienne nieobecne w pliku JSON ze zmiennymi (lub innego typu) zostały pominięte" +"variable bindings to properties the layer doesn't have were skipped" = "powiązania zmiennych z właściwościami, których warstwa nie posiada, zostały pominięte" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "powiązania zmiennych ze zmiennymi nieobecnymi w pliku JSON ze zmiennymi (zmienne biblioteki) zostały pominięte" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "powiązania zmiennych zostały pominięte: plik JSON nie zawiera definicji zmiennych (przekaż JSON zmiennych lokalnych jako `variables` lub osadź jego `meta` obok pliku JSON)" +"variable bindings whose variable type doesn't fit the property were skipped" = "powiązania zmiennych, których typ nie pasuje do właściwości, zostały pominięte" +"variable collections without modes were skipped" = "kolekcje zmiennych bez trybów zostały pominięte" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "odwołania do trybu zmiennej w wyrażeniach prototypu niebędące zmienną i jednym z jej trybów zostały pominięte" +"variable modes of collections that aren't in the file were skipped" = "tryby zmiennych dla kolekcji nieobecnych w pliku zostały pominięte" +"variable modes of collections that aren't in the variables JSON were skipped" = "tryby zmiennych dla kolekcji nieobecnych w pliku JSON ze zmiennymi zostały pominięte" +"variable values that don't match their type (or composed colours) were skipped" = "wartości zmiennych niezgodne ze swoim typem (lub złożone kolory) zostały pominięte" +"variables of unknown types were skipped" = "zmienne o nieznanych typach zostały pominięte" +"variables whose collection isn't in the variables JSON were skipped" = "zmienne, których kolekcji nie ma w pliku JSON ze zmiennymi, zostały pominięte" +"variables without readable values were skipped" = "zmienne bez czytelnych wartości zostały pominięte" +"vector points with unknown stroke joins use the layer join" = "punkty wektorowe z nieznanymi połączeniami obrysu używają połączenia warstwy" +"vectors without geometry are empty" = "wektory bez geometrii są puste" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "wektory bez geometrii są puste (wyeksportuj plik JSON z opcją geometry=paths)" +"very long text was cut to 1,048,576 characters" = "bardzo długi tekst został przycięty do 1 048 576 znaków" +"very long vector paths were skipped" = "bardzo długie ścieżki wektorowe zostały pominięte" +"video actions are not supported and were skipped" = "akcje wideo nie są obsługiwane i zostały pominięte" +"video triggers are not supported and were skipped" = "wyzwalacze wideo nie są obsługiwane i zostały pominięte" +"yesterday" = "wczoraj" +"{instances} in {files}" = "{instances} w {files}" +"{language}: draft translation, waiting for a native speaker's review." = "{language}: wersja robocza tłumaczenia, oczekuje na weryfikację przez native speakera." +"{length} bleed" = "spad {length}" +"{month} {day}, {year}" = "{day} {month} {year}" +"{name} cleared from the list" = "Usunięto {name} z listy" +"{name} is forgotten on this computer; the file stays on {server}" = "{name} usunięto z pamięci tego komputera; plik pozostaje na {server}" +"{name} is on the server." = "{name} znajduje się na serwerze." +"{name} is spotlighting" = "{name} prezentuje widok (spotlight)" +"{name}\nClick to apply to the selection" = "{name}\nKliknij, aby zastosować do zaznaczenia" +"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nPrzeciągnij na płótno lub kliknij, aby umieścić na środku widoku.\nPrzytrzymaj klawisz Alt (Option) podczas upuszczania na instancję, aby ją zamienić." +"{name} (from {library})" = "{name} (z biblioteki {library})" +"{name} (you)" = "{name} (Ty)" +"{name} ({layers} layers)" = "{name} ({layers} warstw)" +"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name} ({tag}): {min} – {max}, domyślnie {default}. Kliknij dwukrotnie suwak, aby przywrócić wartość stylu; naciśnięcie Escape podczas przeciągania anuluje." +"{name} from {path}" = "{name} z {path}" +"{name} was removed from the server" = "Plik {name} został usunięty z serwera" +"{name} · deleted on main" = "{name} · usunięto w gałęzi głównej" +"{name} · deleted on the branch" = "{name} · usunięto w gałęzi" +"{name} · not published" = "{name} · nieopublikowane" +"{name} · on a server" = "{name} · na serwerze" +"{name} · published version {v}" = "{name} · opublikowana wersja {v}" +"{name} · version {v} on the server" = "{name} · wersja {v} na serwerze" +"{name}, in a live session" = "{name}, w sesji na żywo" +"{name}, online, open only to you" = "{name}, online, otwarty tylko dla Ciebie" +"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, zaznaczono" +"{n} added" = { one = "Dodano {n}", few = "Dodano {n}", many = "Dodano {n}", other = "Dodano {n}" } +"{n} autosave versions" = { one = "{n} wersja autozapisu", few = "{n} wersje autozapisu", many = "{n} wersji autozapisu", other = "{n} wersji autozapisu" } +"{n} changed" = { one = "Zmieniono {n}", few = "Zmieniono {n}", many = "Zmieniono {n}", other = "Zmieniono {n}" } +"{n} columns" = { one = "{n} kolumna", few = "{n} kolumny", many = "{n} kolumn", other = "{n} kolumn" } +"{n} components" = { one = "{n} komponent", few = "{n} komponenty", many = "{n} komponentów", other = "{n} komponentów" } +"{n} components and styles have newer versions" = { one = "{n} komponent lub styl ma nowszą wersję", few = "{n} komponenty i style mają nowsze wersje", many = "{n} komponentów i stylów ma nowsze wersje", other = "{n} komponentów i stylów ma nowsze wersje" } +"{n} conflicts to resolve" = { one = "{n} konflikt do rozwiązania", few = "{n} konflikty do rozwiązania", many = "{n} konfliktów do rozwiązania", other = "{n} konfliktów do rozwiązania" } +"{n} days ago" = { one = "{n} dzień temu", few = "{n} dni temu", many = "{n} dni temu", other = "{n} dni temu" } +"{n} files" = { one = "{n} plik", few = "{n} pliki", many = "{n} plików", other = "{n} plików" } +"{n} hours ago" = { one = "{n} godzinę temu", few = "{n} godziny temu", many = "{n} godzin temu", other = "{n} godzin temu" } +"{n} instances" = { one = "{n} instancja", few = "{n} instancje", many = "{n} instancji", other = "{n} instancji" } +"{n} layers" = { one = "{n} warstwa", few = "{n} warstwy", many = "{n} warstw", other = "{n} warstw" } +"{n} layers as one file" = { one = "{n} warstwa jako jeden plik", few = "{n} warstwy jako jeden plik", many = "{n} warstw jako jeden plik", other = "{n} warstw jako jeden plik" } +"{n} layers selected" = { one = "Zaznaczono {n} warstwę", few = "Zaznaczono {n} warstwy", many = "Zaznaczono {n} warstw", other = "Zaznaczono {n} warstw" } +"{n} layers · {parts}" = { one = "{n} warstwa · {parts}", few = "{n} warstwy · {parts}", many = "{n} warstw · {parts}", other = "{n} warstw · {parts}" } +"{n} matches" = { one = "{n} dopasowanie", few = "{n} dopasowania", many = "{n} dopasowań", other = "{n} dopasowań" } +"{n} minutes ago" = { one = "{n} minutę temu", few = "{n} minuty temu", many = "{n} minut temu", other = "{n} minut temu" } +"{n} months ago" = { one = "{n} miesiąc temu", few = "{n} miesiące temu", many = "{n} miesięcy temu", other = "{n} miesięcy temu" } +"{n} more notes omitted from this report" = { one = "Pominięto jeszcze {n} notatkę w tym raporcie", few = "Pominięto jeszcze {n} notatki w tym raporcie", many = "Pominięto jeszcze {n} notatek w tym raporcie", other = "Pominięto jeszcze {n} notatek w tym raporcie" } +"{n} of {total}" = "{n} z {total}" +"{n} people joined" = { one = "Dołączyła {n} osoba", few = "Dołączyły {n} osoby", many = "Dołączyło {n} osób", other = "Dołączyło {n} osób" } +"{n} preferred components" = { one = "{n} preferowany komponent", few = "{n} preferowane komponenty", many = "{n} preferowanych komponentów", other = "{n} preferowanych komponentów" } +"{n} ready" = { one = "{n} gotowy", few = "{n} gotowe", many = "{n} gotowych", other = "{n} gotowych" } +"{n} removed" = { one = "Usunięto {n}", few = "Usunięto {n}", many = "Usunięto {n}", other = "Usunięto {n}" } +"{n} replies" = { one = "{n} odpowiedź", few = "{n} odpowiedzi", many = "{n} odpowiedzi", other = "{n} odpowiedzi" } +"{n} rows" = { one = "{n} wiersz", few = "{n} wiersze", many = "{n} wierszy", other = "{n} wierszy" } +"{n} styles" = { one = "{n} styl", few = "{n} style", many = "{n} stylów", other = "{n} stylów" } +"{n} variables" = { one = "{n} zmienna", few = "{n} zmienne", many = "{n} zmiennych", other = "{n} zmiennych" } +"{n} weeks ago" = { one = "{n} tydzień temu", few = "{n} tygodnie temu", many = "{n} tygodni temu", other = "{n} tygodni temu" } +"{n} years ago" = { one = "{n} rok temu", few = "{n} lata temu", many = "{n} lat temu", other = "{n} lat temu" } +"{n}d" = "{n} d" +"{n}h" = "{n} godz." +"{n}m" = "{n} min" +"{n}w" = "{n} tyg." +"{page}, nothing selected" = "{page}, nic nie zaznaczono" +"{profile} (current)" = "{profile} (bieżący)" +"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} to jej wartość; {property ? \"a\" : \"b\"} wybiera tekst. Pozostałe wyrażenia {…} pozostają bez zmian." +"{server} can't say what became of your files (it is older than this app)." = "{server} nie może określić stanu Twoich plików (jest starszy niż ta aplikacja)." +"{server} · No longer on the server" = "{server} · Brak na serwerze" +"{server} · {shared} · Opened {date}" = "{server} · {shared} · Otwarto {date}" +"{tool} hold {key}" = "{tool} przytrzymaj {key}" +"{value} isn't a colour" = "Wartość {value} nie jest kolorem" +"{when} · edited" = "{when} · edytowano" +"← ↑ → ↓" = "← ↑ → ↓" +"← → ↑ ↓" = "← → ↑ ↓" +"←" = "←" +"→, Space or N" = "→, Spacja lub N" +"↵" = "↵" +"⇥ / ⇧⇥" = "⇥ / ⇧⇥" +"⌘ arrows" = "⌘ strzałki" +"⌘ click" = "⌘ kliknięcie" +"⌘ scroll" = "⌘ przewijanie" +"⌥ drag" = "⌥ przeciągnięcie" + +[commands] +"align.bottom" = "Wyrównaj do dołu" +"align.hcenter" = "Wyrównaj poziomo do środka" +"align.left" = "Wyrównaj do lewej" +"align.right" = "Wyrównaj do prawej" +"align.top" = "Wyrównaj do góry" +"align.vcenter" = "Wyrównaj pionowo do środka" +"annotation.add" = "Dodaj adnotację" +"annotation.addCategory" = "Dodaj kategorię adnotacji" +"annotation.categories" = "Kategorie adnotacji" +"annotation.edit" = "Edytuj adnotację" +"annotation.editCategory" = "Edytuj kategorię adnotacji" +"annotation.list" = "Lista adnotacji" +"annotation.remove" = "Usuń adnotację" +"annotation.removeCategory" = "Usuń kategorię adnotacji" +"app.actions" = "Akcje" +"app.preferences" = "Preferencje" +"arrange.back" = "Przesuń na sam spód" +"arrange.backward" = "Przesuń do tyłu" +"arrange.forward" = "Przesuń do przodu" +"arrange.front" = "Przesuń na sam wierzch" +"arrange.packH" = "Upakuj w poziomie" +"arrange.packV" = "Upakuj w pionie" +"arrange.roundToPixels" = "Zaokrąglij do pikseli" +"arrange.spacing" = "Odstępy" +"arrange.tidy" = "Uporządkuj" +"bool.exclude" = "Wyklucz zaznaczenie" +"bool.flatten" = "Spłaszcz operację logiczną" +"bool.intersect" = "Część wspólna zaznaczenia" +"bool.subtract" = "Odejmij zaznaczenie" +"bool.union" = "Połącz zaznaczenie" +"branch.archive" = "Zarchiwizuj gałąź" +"branch.create" = "Utwórz gałąź" +"branch.list" = "Lista gałęzi" +"branch.merge" = "Scal gałąź" +"branch.open" = "Otwórz gałąź" +"branch.review" = "Przejrzyj gałąź" +"branch.updateFromMain" = "Zaktualizuj z gałęzi głównej" +"codeConnect.export" = "Eksportuj połączenia kodu" +"codeConnect.import" = "Importuj połączenia kodu" +"codeConnect.remove" = "Odłącz od kodu" +"codeConnect.set" = "Połącz z kodem" +"collab.cursor" = "Pokaż kursor na pozycji" +"collab.cursorChat" = "Czat kursora" +"collab.diff" = "Pokaż zmiany synchronizacji" +"collab.files" = "Twoje pliki online" +"collab.follow" = "Obserwuj" +"collab.forgetFile" = "Zapomnij plik online" +"collab.host" = "Rozpocznij sesję na żywo" +"collab.join" = "Dołącz do sesji na żywo" +"collab.leave" = "Opuść sesję na żywo" +"collab.manageFile" = "Zarządzaj opublikowanym plikiem" +"collab.openFile" = "Otwórz plik online" +"collab.peers" = "Osoby w tym pliku" +"collab.publish" = "Opublikuj na serwerze" +"collab.recoverFile" = "Odzyskaj plik online" +"collab.registerLinks" = "Otwieraj linki z zaproszeniem w FigCraft" +"collab.report" = "Zgłoś plik operatorowi serwera" +"collab.serverInfo" = "Informacje o serwerze" +"collab.share" = "Udostępnij z kodem dołączenia" +"collab.spotlight" = "Wyróżnij mój widok" +"collab.stop" = "Zatrzymaj sesję na żywo" +"command.batch" = "Wykonaj serię poleceń" +"comment.add" = "Dodaj komentarz" +"comment.delete" = "Usuń komentarz" +"comment.edit" = "Edytuj komentarz" +"comment.link" = "Kopiuj link do komentarza" +"comment.list" = "Lista komentarzy" +"comment.markRead" = "Oznacz komentarz jako przeczytany" +"comment.move" = "Przenieś komentarz" +"comment.next" = "Następny komentarz" +"comment.open" = "Otwórz komentarz" +"comment.people" = "Osoby w komentarzach" +"comment.react" = "Zareaguj na komentarz" +"comment.reply" = "Odpowiedz na komentarz" +"comment.resolve" = "Rozwiąż komentarz" +"comment.zoom" = "Powiększ do komentarzy" +"component.addProperty" = "Dodaj właściwość komponentu" +"component.addVariant" = "Dodaj wariant" +"component.bindProperty" = "Powiąż warstwę z właściwością" +"component.combineVariants" = "Połącz jako warianty" +"component.create" = "Utwórz komponent" +"component.createMultiple" = "Utwórz wiele komponentów" +"component.deleteProperty" = "Usuń właściwość komponentu" +"component.detach" = "Odłącz instancję" +"component.editProperty" = "Edytuj właściwość komponentu" +"component.exposeInstance" = "Udostępnij zagnieżdżoną instancję" +"component.goToMain" = "Przejdź do głównego komponentu" +"component.hideWhenPublishing" = "Ukryj podczas publikowania" +"component.list" = "Lista komponentów" +"component.makeSlot" = "Utwórz gniazdo (Slot)" +"component.playground" = "Środowisko testowe komponentu (Playground)" +"component.properties" = "Właściwości komponentu" +"component.pushChanges" = "Zastosuj zmiany w głównym komponencie" +"component.removeSlot" = "Usuń gniazdo" +"component.resetInstance" = "Zresetuj wszystkie zmiany" +"component.resetOverride" = "Zresetuj zmianę" +"component.restoreMain" = "Przywróć główny komponent" +"dev.compare" = "Porównaj zmiany" +"dev.focus" = "Widok skupienia" +"dev.resolveChange" = "Rozwiąż zmianę" +"dev.unfocus" = "Wyjdź z widoku skupienia" +"devResource.add" = "Dodaj zasób programistyczny" +"devResource.edit" = "Edytuj zasób programistyczny" +"devResource.list" = "Lista zasobów programistycznych" +"devResource.remove" = "Usuń zasób programistyczny" +"distribute.horizontal" = "Rozmieść równe odstępy poziome" +"distribute.vertical" = "Rozmieść równe odstępy pionowe" +"doc.home" = "Ekran główny" +"doc.list" = "Lista dokumentów" +"doc.nextTab" = "Następna karta" +"doc.previousTab" = "Poprzednia karta" +"doc.switch" = "Przełącz dokument" +"edit.copy" = "Kopiuj" +"edit.copyAsCss" = "Kopiuj jako CSS" +"edit.copyAsPng" = "Kopiuj jako PNG" +"edit.copyAsSvg" = "Kopiuj jako SVG" +"edit.copyAsText" = "Kopiuj jako tekst" +"edit.copyLink" = "Kopiuj łącze do zaznaczenia" +"edit.copyProperties" = "Kopiuj właściwości" +"edit.cut" = "Wytnij" +"edit.delete" = "Usuń" +"edit.duplicate" = "Powiel" +"edit.findReplace" = "Znajdź i zamień…" +"edit.paste" = "Wklej" +"edit.pasteDesignLayers" = "Wklej warstwy projektu" +"edit.pasteOver" = "Wklej na zaznaczenie" +"edit.pasteProperties" = "Wklej właściwości" +"edit.pasteReplace" = "Wklej, aby zastąpić" +"edit.pickColor" = "Wybierz kolor" +"edit.redo" = "Ponów" +"edit.repeat" = "Powtórz ostatnie polecenie" +"edit.setDefaultProperties" = "Ustaw właściwości domyślne" +"edit.undo" = "Cofnij" +"effect.add" = "Dodaj efekt" +"effect.move" = "Przenieś efekt" +"effect.remove" = "Usuń efekt" +"effect.set" = "Ustaw efekt" +"export.add" = "Dodaj ustawienie eksportu" +"export.remove" = "Usuń ustawienie eksportu" +"export.update" = "Edytuj ustawienie eksportu" +"file.close" = "Zamknij kartę" +"file.colorProfile" = "Profil kolorów" +"file.duplicate" = "Powiel plik" +"file.export" = "Eksportuj…" +"file.exportFramesPdf" = "Eksportuj ramki do PDF" +"file.exportLayers" = "Eksportuj warstwy" +"file.exportSvg" = "Eksportuj zaznaczenie jako SVG" +"file.import" = "Importuj z pliku…" +"file.new" = "Nowy plik projektu" +"file.newFromTemplate" = "Nowy z szablonu" +"file.open" = "Otwórz…" +"file.openRecent" = "Otwórz ostatnie" +"file.placeImage" = "Umieść obraz" +"file.recover" = "Odzyskaj plik" +"file.rename" = "Zmień nazwę pliku" +"file.revert" = "Przywróć do zapisanego" +"file.save" = "Zapisz" +"file.saveAs" = "Zapisz jako…" +"file.saveVersion" = "Zapisz w historii wersji…" +"file.versionHistory" = "Pokaż historię wersji" +"fill.add" = "Dodaj wypełnienie" +"fill.move" = "Przenieś wypełnienie" +"fill.pattern" = "Wypełnienie wzorem" +"fill.remove" = "Usuń wypełnienie" +"fill.set" = "Ustaw wypełnienie" +"frame.create" = "Utwórz ramkę" +"frame.presets" = "Lista presetów ramek" +"frame.swapOrientation" = "Zmień orientację ramki" +"grid.add" = "Dodaj siatkę układu" +"grid.move" = "Przenieś siatkę układu" +"grid.remove" = "Usuń siatkę układu" +"guide.add" = "Dodaj prowadnicę" +"guide.clear" = "Wyczyść prowadnice" +"guide.move" = "Przesuń prowadnicę" +"guide.remove" = "Usuń prowadnicę" +"guide.select" = "Zaznacz prowadnicę" +"help.about" = "O programie FigCraft" +"help.gettingStarted" = "Wprowadzenie" +"help.page" = "Strona pomocy" +"help.shortcuts" = "Skróty klawiszowe" +"image.adjust" = "Dostosuj obraz" +"image.deletePreset" = "Usuń preset obrazu" +"image.place" = "Umieść obraz" +"image.presets" = "Presety obrazów" +"image.savePreset" = "Zapisz preset obrazu" +"inspect.assets" = "Zasoby do inspekcji" +"inspect.code" = "Kod warstwy" +"inspect.componentCode" = "Kod komponentu" +"inspect.properties" = "Właściwości do inspekcji" +"inspect.readyForDev" = "Gotowe do wdrożenia" +"instance.create" = "Utwórz instancję" +"instance.exposed" = "Udostępnione zagnieżdżone instancje" +"instance.place" = "Umieść instancję" +"instance.resetSlot" = "Zresetuj gniazdo" +"instance.setProperties" = "Ustaw właściwości instancji" +"instance.slotInsert" = "Wstaw do gniazda" +"instance.swap" = "Podmień instancję" +"instance.swapAt" = "Podmień instancję w punkcie" +"layers.collapseAll" = "Zwiń wszystkie warstwy" +"layout.add" = "Dodaj automatyczny układ" +"layout.remove" = "Usuń automatyczny układ" +"layout.setTrack" = "Ustaw rozmiar linii siatki" +"layout.suggest" = "Sugeruj automatyczny układ" +"libraries.open" = "Biblioteki" +"libraries.publish" = "Publikuj bibliotekę…" +"library.acceptUpdates" = "Zaakceptuj aktualizacje biblioteki" +"library.add" = "Dodaj bibliotekę" +"library.aliasVariable" = "Utwórz alias zmiennej biblioteki" +"library.applyStyle" = "Zastosuj styl biblioteki" +"library.bindVariable" = "Powiąż zmienną biblioteki" +"library.extendCollection" = "Rozszerz kolekcję biblioteki" +"library.import" = "Importuj komponent biblioteki" +"library.importVariable" = "Importuj zmienną biblioteki" +"library.list" = "Lista bibliotek" +"library.place" = "Umieść komponent biblioteki" +"library.publish" = "Opublikuj bibliotekę" +"library.publishToServer" = "Opublikuj bibliotekę na serwerze" +"library.remove" = "Usuń bibliotekę" +"library.setVariableMode" = "Ustaw tryb zmiennej biblioteki" +"library.swap" = "Podmień bibliotekę" +"library.swapAt" = "Podmień instancję na komponent biblioteki" +"library.updates" = "Aktualizacje biblioteki" +"library.usage" = "Użycie biblioteki" +"measure.distances" = "Zmierz odległości" +"measure.padding" = "Zmierz marginesy automatycznego układu" +"node.create" = "Utwórz warstwę" +"node.get" = "Pobierz warstwę" +"node.reorder" = "Zmień kolejność warstwy" +"node.reparent" = "Przenieś warstwę do" +"node.set" = "Ustaw właściwości" +"object.batchRename" = "Zmień nazwy wsadowo…" +"object.convertToFrame" = "Konwertuj na ramkę" +"object.convertToGroup" = "Konwertuj na grupę" +"object.flipH" = "Odbij w poziomie" +"object.flipV" = "Odbij w pionie" +"object.frameSelection" = "Umieść zaznaczenie w ramce" +"object.group" = "Grupuj zaznaczenie" +"object.hideOthers" = "Ukryj pozostałe warstwy" +"object.mask" = "Użyj jako maski" +"object.moveToPage" = "Przenieś na stronę" +"object.opacityKeys" = "Krycie z klawiatury numerycznej" +"object.rasterize" = "Rasteryzuj zaznaczenie" +"object.releaseMask" = "Zwolnij maskę" +"object.removeFill" = "Usuń wypełnienie" +"object.removeFrame" = "Usuń ramkę" +"object.removeStroke" = "Usuń obrys" +"object.rename" = "Zmień nazwę" +"object.resizeToFit" = "Dopasuj rozmiar do zawartości" +"object.rotate180" = "Obróć o 180°" +"object.rotateLeft" = "Obróć o 90° w lewo" +"object.rotateRight" = "Obróć o 90° w prawo" +"object.setThumbnail" = "Ustaw jako miniaturę" +"object.swapFillStroke" = "Zamień wypełnienie i obrys" +"object.toggleLock" = "Zablokuj/Odblokuj zaznaczenie" +"object.toggleVisible" = "Pokaż/Ukryj zaznaczenie" +"object.ungroup" = "Rozgrupuj zaznaczenie" +"object.wrapInSection" = "Umieść w nowej sekcji" +"page.add" = "Dodaj stronę" +"page.delete" = "Usuń stronę" +"page.duplicate" = "Powiel stronę" +"page.list" = "Lista stron" +"page.move" = "Przenieś stronę" +"page.rename" = "Zmień nazwę strony" +"page.select" = "Przejdź do strony" +"page.setBackground" = "Tło strony" +"paint.documentColors" = "Kolory dokumentu" +"plugin.close" = "Zamknij panel wtyczki" +"plugin.directory.install" = "Zainstaluj wtyczkę z katalogu" +"plugin.directory.list" = "Przeglądaj katalog wtyczek" +"plugin.directory.publish" = "Opublikuj wtyczkę w katalogu" +"plugin.directory.unpublish" = "Wycofaj wtyczkę z katalogu" +"plugin.event" = "Użyj kontrolki panelu wtyczki" +"plugin.import" = "Importuj wtyczkę z manifestu…" +"plugin.list" = "Lista wtyczek" +"plugin.remove" = "Usuń wtyczkę" +"plugin.run" = "Uruchom wtyczkę" +"plugin.ui" = "Panel wtyczki" +"plugins.manage" = "Zarządzaj wtyczkami…" +"plugins.runLast" = "Uruchom ponownie ostatnią wtyczkę" +"pref.highlightHover" = "Podświetlaj warstwy przy najechaniu myszą" +"pref.keepTool" = "Zachowaj wybrane narzędzie po użyciu" +"pref.language" = "Język interfejsu" +"pref.nudge" = "Wartości przesunięcia (Nudge)" +"pref.numberKeysOpacity" = "Klawisze numeryczne zmieniają krycie" +"pref.publishServer" = "Serwer do udostępniania online" +"pref.reduceMotion" = "Ogranicz animacje" +"pref.renameDuplicates" = "Zmieniaj nazwy duplikatów" +"pref.showDimensions" = "Pokazuj wymiary przy zaznaczeniu" +"pref.userName" = "Twoja nazwa w komentarzach" +"proto.action.add" = "Dodaj akcję prototypu" +"proto.action.move" = "Zmień kolejność akcji prototypu" +"proto.action.remove" = "Usuń akcję prototypu" +"proto.action.set" = "Edytuj akcję prototypu" +"proto.closePreview" = "Zamknij podgląd prototypu" +"proto.conditional.addBlock" = "Dodaj else if / else" +"proto.conditional.removeBlock" = "Usuń if / else if / else" +"proto.conditional.setCondition" = "Edytuj warunek" +"proto.evaluate" = "Oblicz wyrażenie prototypu" +"proto.flow.add" = "Dodaj punkt początkowy przepływu" +"proto.flow.remove" = "Usuń przepływ" +"proto.flow.rename" = "Zmień nazwę przepływu" +"proto.interaction.add" = "Dodaj interakcję" +"proto.interaction.clear" = "Usuń wszystkie interakcje" +"proto.interaction.remove" = "Usuń interakcję" +"proto.interaction.set" = "Edytuj interakcję" +"proto.present" = "Prezentuj" +"proto.presentOptions" = "Opcje prezentacji" +"proto.preview" = "Otwórz podgląd prototypu" +"proto.settings" = "Ustawienia prototypu" +"section.toggleContents" = "Ukryj/Pokaż zawartość sekcji" +"select.add" = "Dodaj do zaznaczenia" +"select.all" = "Zaznacz wszystko" +"select.area" = "Zaznacz obszar" +"select.byName" = "Zaznacz warstwy po nazwie" +"select.children" = "Zaznacz elementy podrzędne" +"select.inverse" = "Odwróć zaznaczenie" +"select.layersAt" = "Warstwy w punkcie" +"select.matching" = "Zaznacz pasujące warstwy" +"select.next" = "Zaznacz następną warstwę" +"select.none" = "Odznacz wszystko" +"select.parent" = "Zaznacz element nadrzędny" +"select.previous" = "Zaznacz poprzednią warstwę" +"select.remove" = "Usuń z zaznaczenia" +"select.sameEffect" = "Zaznacz elementy z takim samym efektem" +"select.sameFill" = "Zaznacz elementy z takim samym wypełnieniem" +"select.sameFont" = "Zaznacz elementy z taką samą czcionką" +"select.sameInstance" = "Zaznacz taką samą instancję" +"select.sameProperties" = "Zaznacz elementy z takimi samymi właściwościami" +"select.sameStroke" = "Zaznacz elementy z takim samym obrysem" +"select.sameText" = "Zaznacz elementy z takimi samymi właściwościami tekstu" +"select.set" = "Zaznacz" +"selection.applyColorStyle" = "Zastosuj styl koloru do zaznaczenia" +"selection.colors" = "Kolory zaznaczenia" +"selection.selectColor" = "Zaznacz warstwy z kolorem" +"selection.setPaint" = "Zmień kolor zaznaczenia" +"shape.arrow" = "Strzałka" +"shape.ellipse" = "Elipsa" +"shape.line" = "Linia" +"shape.polygon" = "Wielokąt" +"shape.rectangle" = "Prostokąt" +"shape.star" = "Gwiazda" +"shapeBuilder.merge" = "Połącz obszary kształtów" +"shapeBuilder.mode" = "Tryb generatora kształtów" +"shapeBuilder.regions" = "Obszary generatora kształtów" +"shapeBuilder.remove" = "Usuń obszary" +"stroke.add" = "Dodaj obrys" +"stroke.brush" = "Pędzel" +"stroke.move" = "Przenieś obrys" +"stroke.remove" = "Usuń obrys" +"stroke.set" = "Ustaw obrys" +"stroke.widthProfile" = "Profil grubości" +"style.apply" = "Zastosuj styl" +"style.bind" = "Powiąż zmienną w stylu" +"style.create" = "Utwórz styl" +"style.delete" = "Usuń styl" +"style.detach" = "Odłącz styl" +"style.duplicate" = "Powiel styl" +"style.list" = "Lista stylów" +"style.move" = "Przenieś style" +"style.renameFolder" = "Zmień nazwę folderu stylów" +"style.update" = "Edytuj styl" +"template.list" = "Lista szablonów" +"text.alignCenter" = "Wyrównaj tekst do środka" +"text.alignJustify" = "Wyjustuj tekst" +"text.alignLeft" = "Wyrównaj tekst do lewej" +"text.alignRight" = "Wyrównaj tekst do prawej" +"text.bold" = "Pogrubienie" +"text.bulletList" = "Lista punktowana" +"text.case" = "Wielkość liter" +"text.caseLower" = "Małe litery" +"text.caseOriginal" = "Oryginalna wielkość liter" +"text.caseSmallCaps" = "Kapitaliki" +"text.caseTitle" = "Jak nazwy własne" +"text.caseUpper" = "Wielkie litery" +"text.create" = "Tekst" +"text.decoration" = "Dekoracja tekstu" +"text.edit.begin" = "Edytuj tekst" +"text.edit.caret" = "Ustaw kursor tekstu" +"text.edit.end" = "Zakończ edycję tekstu" +"text.edit.insert" = "Wpisz tekst" +"text.edit.key" = "Klawisz tekstu" +"text.edit.preedit" = "Redaguj tekst" +"text.edit.selectLine" = "Zaznacz wiersz" +"text.edit.selectWord" = "Zaznacz słowo" +"text.edit.selection" = "Zaznaczenie tekstu" +"text.endPreview" = "Zakończ podgląd tekstu" +"text.fonts" = "Lista czcionek" +"text.italic" = "Kursywa" +"text.lineHeightDown" = "Zmniejsz interlinię" +"text.lineHeightUp" = "Zwiększ interlinię" +"text.link" = "Wstaw łącze" +"text.listIndent" = "Zwiększ wcięcie listy" +"text.listOutdent" = "Zmniejsz wcięcie listy" +"text.listStyle" = "Styl listy" +"text.maxLines" = "Maksymalna liczba wierszy" +"text.numberList" = "Lista numerowana" +"text.onPath" = "Tekst na ścieżce" +"text.paragraphIndent" = "Wcięcie akapitu" +"text.paragraphSpacing" = "Odstęp między akapitami" +"text.pathOptions" = "Opcje ścieżki tekstu" +"text.preview" = "Podgląd zmiany tekstu" +"text.removeFromPath" = "Usuń tekst ze ścieżki" +"text.set" = "Ustaw tekst" +"text.setFeatures" = "Funkcje OpenType" +"text.setVariations" = "Osie czcionki zmiennej (Variable Font)" +"text.sizeDown" = "Zmniejsz rozmiar czcionki" +"text.sizeUp" = "Zwiększ rozmiar czcionki" +"text.strikethrough" = "Przekreślenie" +"text.style" = "Zastosuj styl do fragmentu tekstu" +"text.trackingDown" = "Zmniejsz odstęp między literami" +"text.trackingUp" = "Zwiększ odstęp między literami" +"text.underline" = "Podkreślenie" +"text.weightDown" = "Zmniejsz grubość czcionki" +"text.weightUp" = "Zwiększ grubość czcionki" +"tool.arrow" = "Strzałka" +"tool.comment" = "Komentarz" +"tool.ellipse" = "Elipsa" +"tool.frame" = "Ramka" +"tool.hand" = "Rączka" +"tool.line" = "Linia" +"tool.move" = "Wskaźnik" +"tool.pen" = "Pióro" +"tool.pencil" = "Ołówek" +"tool.pencilBrush" = "Pędzel ołówka" +"tool.place" = "Umieść za pomocą narzędzia" +"tool.polygon" = "Wielokąt" +"tool.rectangle" = "Prostokąt" +"tool.scale" = "Skalowanie" +"tool.scaleAnchor" = "Punkt zaczepienia skalowania" +"tool.section" = "Sekcja" +"tool.set" = "Wybierz narzędzie" +"tool.shapeBuilder" = "Generator kształtów" +"tool.slice" = "Plaster" +"tool.star" = "Gwiazda" +"tool.text" = "Tekst" +"transform.move" = "Przenieś" +"transform.moveTo" = "Przenieś do" +"transform.nudge" = "Przesuń o krok (Nudge)" +"transform.nudgeSize" = "Zmień rozmiar o krok" +"transform.resize" = "Zmień rozmiar" +"transform.rotate" = "Obróć" +"transform.rotationOrigin" = "Przesuń punkt obrotu" +"transform.scale" = "Skaluj" +"transform.setRotationOrigin" = "Ustaw punkt obrotu" +"variable.addMode" = "Dodaj tryb" +"variable.bind" = "Powiąż zmienną" +"variable.create" = "Utwórz zmienną" +"variable.createCollection" = "Utwórz kolekcję zmiennych" +"variable.delete" = "Usuń zmienną" +"variable.deleteCollection" = "Usuń kolekcję" +"variable.duplicate" = "Powiel zmienną" +"variable.export" = "Eksportuj zmienne" +"variable.import" = "Importuj zmienne" +"variable.list" = "Lista zmiennych" +"variable.moveMode" = "Przenieś tryb" +"variable.removeMode" = "Usuń tryb" +"variable.renameCollection" = "Zmień nazwę kolekcji" +"variable.renameMode" = "Zmień nazwę trybu" +"variable.resetOverride" = "Zresetuj nadpisanie zmiennej" +"variable.set" = "Ustaw wartość zmiennej" +"variable.setMode" = "Ustaw tryb zmiennej" +"variable.update" = "Edytuj zmienną" +"vector.create" = "Wektor" +"vector.deleteHeal" = "Usuń punkt i napraw zaznaczenie" +"vector.edit.addVertex" = "Dodaj punkt" +"vector.edit.begin" = "Edytuj wektor" +"vector.edit.bend" = "Zegnij segment" +"vector.edit.bounds" = "Granice zaznaczonych punktów" +"vector.edit.connect" = "Połącz punkty" +"vector.edit.cornerRadius" = "Promień zaokrąglenia punktu" +"vector.edit.cut" = "Rozetnij ścieżkę" +"vector.edit.delete" = "Usuń punkty" +"vector.edit.end" = "Zakończ edycję" +"vector.edit.erase" = "Wymaż" +"vector.edit.lasso" = "Zaznacz lasso" +"vector.edit.mirroring" = "Symetria uchwytów" +"vector.edit.moveHandles" = "Przesuń uchwyty" +"vector.edit.moveVertices" = "Przesuń punkty" +"vector.edit.nudge" = "Przesuń punkty o krok" +"vector.edit.paint" = "Wiadro z farbą" +"vector.edit.pen" = "Punkt pióra" +"vector.edit.penEnd" = "Zakończ ścieżkę pióra" +"vector.edit.position" = "Pozycja punktu" +"vector.edit.resize" = "Zmień rozmiar punktów" +"vector.edit.rotate" = "Obróć punkty" +"vector.edit.select" = "Zaznacz punkty" +"vector.edit.selectAll" = "Zaznacz wszystkie punkty" +"vector.edit.setHandle" = "Ustaw uchwyt" +"vector.edit.smooth" = "Wygładź lub wyostrz punkty" +"vector.edit.state" = "Stan edycji wektora" +"vector.edit.strokeCap" = "Zakończenie obrysu punktu" +"vector.edit.strokeJoin" = "Połączenie obrysu punktu" +"vector.edit.tool" = "Narzędzie edycji wektora" +"vector.edit.transform" = "Przekształć punkty" +"vector.edit.widthPoint" = "Punkt szerokości" +"vector.flatten" = "Spłaszcz do wektora" +"vector.join" = "Połącz ścieżki" +"vector.offset" = "Przesunięcie ścieżki (Offset)" +"vector.outlineStroke" = "Przekształć obrys w ścieżkę" +"vector.reverse" = "Odwróć kierunek ścieżki" +"vector.simplify" = "Uprość wektor" +"vector.smoothJoin" = "Płynne połączenie zaznaczenia" +"version.autosave" = "Wersja autozapisu" +"version.delete" = "Usuń wersję" +"version.duplicate" = "Powiel wersję" +"version.exitPreview" = "Powrót do bieżącej wersji" +"version.list" = "Historia wersji" +"version.preview" = "Wyświetl wersję" +"version.rename" = "Zmień nazwę wersji" +"version.restore" = "Przywróć wersję" +"view.assets" = "Panel zasobów" +"view.comments" = "Pokaż komentarze" +"view.contextMenu" = "Pokaż menu kontekstowe" +"view.design" = "Panel projektu" +"view.designPrototype" = "Przełącz między Projektem a Prototypem" +"view.devMode" = "Tryb programisty" +"view.frameOutlines" = "Kontury ramek" +"view.fullscreen" = "Tryb pełnoekranowy" +"view.get" = "Pobierz widok" +"view.layers" = "Panel warstw" +"view.layoutGrids" = "Siatki układu" +"view.leftSidebar" = "Pokaż/Ukryj lewy pasek boczny" +"view.maskOutlines" = "Kontury masek" +"view.multiplayerCursors" = "Kursory innych użytkowników" +"view.nextFrame" = "Przejdź do następnej ramki" +"view.nextPage" = "Następna strona" +"view.nextRegion" = "Przejdź do następnego obszaru" +"view.outlines" = "Pokaż kontury" +"view.outlinesHiddenLayers" = "Pokaż kontury ukrytych warstw" +"view.outlinesObjectBounds" = "Pokaż granice obiektów" +"view.outlinesSelection" = "Pokaż kontury zaznaczenia" +"view.pan" = "Przesuń widok" +"view.panelWidths" = "Zresetuj szerokości paneli" +"view.pixelGrid" = "Siatka pikseli" +"view.pixelPreview" = "Podgląd pikseli" +"view.prevFrame" = "Przejdź do poprzedniej ramki" +"view.prevPage" = "Poprzednia strona" +"view.previousRegion" = "Przejdź do poprzedniego obszaru" +"view.proofSrgb" = "Symulacja sRGB (Soft-proof)" +"view.prototype" = "Panel prototypu" +"view.rulers" = "Linijki" +"view.set" = "Ustaw widok" +"view.slices" = "Plastry" +"view.snapGeometry" = "Przyciągaj do geometrii" +"view.snapObjects" = "Przyciągaj do obiektów" +"view.snapPixel" = "Przyciągaj do pikseli" +"view.themeDark" = "Ciemny motyw" +"view.themeLight" = "Jasny motyw" +"view.themeSystem" = "Motyw zgodny z systemem" +"view.toggleUi" = "Pokaż/Ukryj interfejs" +"view.zoom100" = "Powiększenie 100%" +"view.zoom200" = "Powiększenie 200%" +"view.zoom50" = "Powiększenie 50%" +"view.zoomAt" = "Narzędzie lupy" +"view.zoomFit" = "Dopasuj do ekranu" +"view.zoomIn" = "Powiększ" +"view.zoomOut" = "Pomniejsz" +"view.zoomSelection" = "Powiększ do zaznaczenia" +"widget.click" = "Kliknij widżet" +"widget.insert" = "Wstaw widżet" +"widget.list" = "Lista widżetów" +"window.focus" = "Przesuń okno na wierzch" +"window.new" = "Nowe okno" + +[menu] +"bool.exclude" = "Wyklucz" +"bool.flatten" = "Spłaszcz" +"bool.intersect" = "Część wspólna" +"bool.subtract" = "Odejmij" +"bool.union" = "Połącz" +"file.duplicate" = "Powiel" +"file.import" = "Importuj z pliku…" +"file.rename" = "Zmień nazwę" +"libraries.open" = "Otwórz biblioteki" +"select.sameEffect" = "Efekt" +"select.sameFill" = "Wypełnienie" +"select.sameFont" = "Czcionka" +"select.sameInstance" = "Instancja" +"select.sameProperties" = "Właściwości" +"select.sameStroke" = "Obrys" +"select.sameText" = "Właściwości tekstu" +"text.alignCenter" = "Wyrównaj do środka" +"text.alignJustify" = "Wyjustuj" +"text.alignLeft" = "Wyrównaj do lewej" +"text.alignRight" = "Wyrównaj do prawej" +"view.assets" = "Zasoby" +"view.design" = "Projekt" +"view.layers" = "Warstwy" +"view.outlines" = "Pokaż kontury" +"view.outlinesHiddenLayers" = "Uwzględnij ukryte warstwy" +"view.outlinesObjectBounds" = "Uwzględnij granice obiektów" +"view.outlinesSelection" = "Pokaż kontury zaznaczenia" +"view.prototype" = "Prototyp" +"view.themeDark" = "Ciemny" +"view.themeLight" = "Jasny" +"view.themeSystem" = "Zgodny z systemem" diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 22ea1a728..51e4c37b6 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -171,9 +171,9 @@ fn plural(code: &str, n: usize) -> Plural { "cs" if n == 1 => Plural::One, "cs" if (2..=4).contains(&n) => Plural::Few, "cs" => Plural::Other, - "ru" if n % 10 == 1 && n % 100 != 11 => Plural::One, - "ru" if (2..=4).contains(&(n % 10)) && !(12..=14).contains(&(n % 100)) => Plural::Few, - "ru" => Plural::Many, + "ru" | "pl" if n % 10 == 1 && n % 100 != 11 => Plural::One, + "ru" | "pl" if (2..=4).contains(&(n % 10)) && !(12..=14).contains(&(n % 100)) => Plural::Few, + "ru" | "pl" => Plural::Many, "ja" | "ko" | "zh-Hans" => Plural::Other, "fr" | "pt-BR" if n <= 1 => Plural::One, _ if n == 1 => Plural::One, From 2c3ea03c2ec488a9f2b144cb7dedfb7220a8a7a1 Mon Sep 17 00:00:00 2001 From: leandrodslv <205958219+leandrodslv@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:44:44 +0200 Subject: [PATCH 006/114] feat(ui): add the interface size preference (#540) Preferences > Interface size and the `pref.uiScale` command make the whole interface larger or smaller. Auto keeps the system's display scale; a fixed size from 75 % to 300 % is absolute (100 % is one screen pixel per point on every system). The size is held back while the window is too small for it. Strings are added in en, es and fr; the control protocol, MCP, UI parity and roadmap docs are updated. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/engine/src/a11y_keys_tests.rs | 50 +++++++++++ crates/engine/src/cmd/view.rs | 18 ++++ crates/engine/src/lib.rs | 22 +++++ crates/ui-egui/locales/en.toml | 6 ++ crates/ui-egui/locales/es.toml | 7 ++ crates/ui-egui/locales/fr.toml | 7 ++ crates/ui-egui/src/dialogs.rs | 48 ++++++++++ crates/ui-egui/src/lib.rs | 23 +++++ crates/ui-egui/src/ui_scale.rs | 127 +++++++++++++++++++++++++++ crates/ui-egui/src/ui_scale_tests.rs | 75 ++++++++++++++++ docs/control-protocol.md | 4 +- docs/mcp.md | 6 ++ docs/ui-parity.md | 2 + 14 files changed, 395 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/src/ui_scale.rs create mode 100644 crates/ui-egui/src/ui_scale_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 8a587286d..e5ed24d65 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -319,6 +319,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged | | 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. | | 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | +| 2026-10-17 | minor | Interface size (#540): Preferences ▸ Interface size and `pref.uiScale` make the whole interface larger or smaller. Auto keeps the system's display scale (its entry shows the size it draws at); a fixed size from 75 % to 300 % is absolute, so 100 % is one screen pixel per point on every system. A window too small for a fixed size draws a smaller one until it is larger (the choice is kept); the browser keeps its own zoom. Cmd/Ctrl +/-/0 still zoom the canvas only. Numbers unchanged. | | 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | | 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | diff --git a/crates/engine/src/a11y_keys_tests.rs b/crates/engine/src/a11y_keys_tests.rs index 6687886cd..72507df5f 100644 --- a/crates/engine/src/a11y_keys_tests.rs +++ b/crates/engine/src/a11y_keys_tests.rs @@ -55,3 +55,53 @@ fn reduce_motion_is_a_preference_that_persists() { let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap(); assert!(!old.reduce_motion); } + +#[test] +fn interface_size_is_auto_until_chosen_and_a_chosen_size_is_absolute_and_persists() { + let mut s = Session::new(); + assert_eq!(s.prefs.ui_scale, None); + assert_eq!(s.execute("pref.uiScale", &json!({})).unwrap()["scale"], "auto"); + // A fixed size: 1.0 is one screen pixel per point, so the number is not multiplied by the system's scale. + assert_eq!(s.execute("pref.uiScale", &json!({"scale": 1.25})).unwrap()["scale"], 1.25); + assert_eq!(s.prefs.ui_scale, Some(1.25)); + // Reading changes nothing; "auto" goes back to the system's scale. + assert_eq!(s.execute("pref.uiScale", &json!({})).unwrap()["scale"], 1.25); + assert_eq!(s.execute("pref.uiScale", &json!({"scale": "Auto"})).unwrap()["scale"], "auto"); + assert_eq!(s.prefs.ui_scale, None); + // Nothing to undo: the document didn't change. + assert!(!s.ds().map(|d| d.is_dirty()).unwrap_or(false)); + // Saved with the other preferences. + s.prefs.ui_scale = Some(2.5); + let v = serde_json::to_value(&s.prefs).unwrap(); + assert_eq!(v["ui_scale"], 2.5); + let back: crate::Prefs = serde_json::from_value(v).unwrap(); + assert_eq!(back.ui_scale, Some(2.5)); +} + +#[test] +fn interface_size_stops_at_the_ends_and_junk_never_changes_it() { + let mut s = Session::new(); + assert_eq!(s.execute("pref.uiScale", &json!({"scale": 0.01})).unwrap()["scale"], crate::UI_SCALE_MIN); + assert_eq!(s.execute("pref.uiScale", &json!({"scale": -4})).unwrap()["scale"], crate::UI_SCALE_MIN); + assert_eq!(s.execute("pref.uiScale", &json!({"scale": 1e308})).unwrap()["scale"], crate::UI_SCALE_MAX); + // The size is a factor (1.25), not a percentage: 125 is far past the end and stops there. + assert_eq!(s.execute("pref.uiScale", &json!({"scale": 125})).unwrap()["scale"], crate::UI_SCALE_MAX); + s.prefs.ui_scale = Some(1.5); + for junk in [json!({"scale": "NaN"}), json!({"scale": "big"}), json!({"scale": [1]}), json!({"scale": {"x": 1}})] { + assert!(s.execute("pref.uiScale", &junk).is_err(), "{junk}"); + assert_eq!(s.prefs.ui_scale, Some(1.5), "{junk}"); + } +} + +#[test] +fn a_settings_file_without_or_with_a_wrong_interface_size_reads_as_auto_and_keeps_the_rest() { + let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap(); + assert_eq!(old.ui_scale, None); + for bad in [json!("big"), json!(null), json!([2]), json!(true)] { + let p: crate::Prefs = serde_json::from_value(json!({"theme": "dark", "ui_scale": bad})).unwrap(); + assert_eq!((p.ui_scale, p.theme.as_str()), (None, "dark"), "{bad}"); + } + // A number out of range is clamped rather than refused. + let p: crate::Prefs = serde_json::from_value(json!({"ui_scale": 99, "theme": "dark"})).unwrap(); + assert_eq!((p.ui_scale, p.theme.as_str()), (Some(crate::UI_SCALE_MAX), "dark")); +} diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 69b938e91..9b929118a 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -370,6 +370,24 @@ pub fn specs() -> Vec { spec!("pref.showDimensions", "Show dimensions on objects", "{on?}", false, |s, p| pref(s, p, |x| &mut x.show_dimensions)), spec!("pref.numberKeysOpacity", "Use number keys for opacity", "{on?}", false, |s, p| pref(s, p, |x| &mut x.number_keys_opacity)), spec!("pref.reduceMotion", "Reduce motion", "{on?}", false, |s, p| pref(s, p, |x| &mut x.reduce_motion)), + spec!( + "pref.uiScale", + "Interface size", + "{scale?: \"auto\" or a fixed size from 0.75 to 3, 1 being one screen pixel per point whatever scale the system reports, so 1.25 is 125 %, not 125 (outside the range it stops at the nearest end; omit: just read it)} → {scale: \"auto\" or the size}: how large the whole interface is drawn; auto keeps the system's scale; the canvas zoom is separate", + false, + |s, p| { + match get(p, "scale") { + None => {} + Some(Value::String(v)) if v.trim().eq_ignore_ascii_case("auto") => s.prefs.ui_scale = None, + Some(_) => { + let scale = req_num("pref.uiScale", p, "scale")?; + s.prefs.ui_scale = + Some(crate::clamp_ui_scale(scale).ok_or_else(|| bad("pref.uiScale", "`scale` must be a finite number or \"auto\""))?); + } + } + Ok(json!({"scale": s.prefs.ui_scale.map_or(json!("auto"), |v| json!(v))})) + } + ), spec!("pref.autoSave", "Save files automatically", "{on?}", false, |s, p| pref(s, p, |x| &mut x.auto_save)), spec!("pref.nudge", "Nudge amount", "{small?, big?}", false, |s, p| { s.prefs.nudge_small = num(p, "small").unwrap_or(s.prefs.nudge_small).clamp(0.01, 1000.0); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 312c2e878..c8010399e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -603,6 +603,22 @@ impl DocState { } } +/// The smallest and largest fixed interface size: 1.0 is one screen pixel per point, whatever scale +/// the system reports. +pub const UI_SCALE_MIN: f64 = 0.75; +pub const UI_SCALE_MAX: f64 = 3.0; + +/// `scale` kept between [`UI_SCALE_MIN`] and [`UI_SCALE_MAX`]; not a finite number: `None` (Auto). +pub fn clamp_ui_scale(scale: f64) -> Option { + scale.is_finite().then(|| scale.clamp(UI_SCALE_MIN, UI_SCALE_MAX)) +} + +/// Reads the saved interface size. Anything but a number reads as Auto and a number out of range is +/// clamped, so a settings file edited by hand never costs the other preferences. +fn read_ui_scale<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result, D::Error> { + Ok(Value::deserialize(d)?.as_f64().and_then(clamp_ui_scale)) +} + /// App-wide preferences that commands read (nudge amounts, behaviours). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] @@ -630,6 +646,11 @@ pub struct Prefs { /// Reduce motion: the interface's own animations are off (the system's setting turns them off /// too). Prototype transitions still play. pub reduce_motion: bool, + /// How large the whole interface is drawn. `None` is Auto: the scale the system reports. A + /// number is a fixed size, 1.0 being one screen pixel per point, so it ignores the system's + /// scale (kept between [`UI_SCALE_MIN`] and [`UI_SCALE_MAX`]). The canvas zoom is separate. + #[serde(deserialize_with = "read_ui_scale")] + pub ui_scale: Option, /// Files that have a path are saved by themselves a few seconds after they change (the app's /// timer; off: only File ▸ Save writes them). pub auto_save: bool, @@ -698,6 +719,7 @@ impl Default for Prefs { language: "system".into(), user_name: cmd::comment::DEFAULT_AUTHOR.into(), reduce_motion: false, + ui_scale: None, auto_save: false, invite_links: None, plugins: Vec::new(), diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 17f6b4c5f..b16d95509 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -134,7 +134,9 @@ name = "English" "Auto (space between)" = "Auto (space between)" "Auto (space evenly)" = "Auto (space evenly)" "Auto ({mode})" = "Auto ({mode})" +"Auto ({percent}%)" = "Auto ({percent}%)" "Auto columns" = "Auto columns" +"Auto follows your system's display scaling; the other sizes ignore it. Document zoom is separate." = "Auto follows your system's display scaling; the other sizes ignore it. Document zoom is separate." "Auto gap" = "Auto gap" "Auto height" = "Auto height" "Auto layout" = "Auto layout" @@ -653,6 +655,7 @@ name = "English" "Interaction" = "Interaction" "Interactions" = "Interactions" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." +"Interface size" = "Interface size" "Intersect" = "Intersect" "Intersects" = "Intersects" "Invalid color" = "Invalid color" @@ -1371,6 +1374,7 @@ name = "English" "The selection uses several text styles: click to apply one" = "The selection uses several text styles: click to apply one" "The server deletes this file if nobody opens it before {date}." = "The server deletes this file if nobody opens it before {date}." "The whole file" = "The whole file" +"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." "Theme" = "Theme" "There's no destination for that action" = "There's no destination for that action" "Thickness" = "Thickness" @@ -1450,6 +1454,7 @@ name = "English" "Use number keys for opacity" = "Use number keys for opacity" "Use the .figcraft or .fig extension." = "Use the .figcraft or .fig extension." "Use the layer's fill" = "Use the layer's fill" +"Use your browser's zoom to change the interface size." = "Use your browser's zoom to change the interface size." "Used in {files} · {instances}" = "Used in {files} · {instances}" "Username" = "Username" "Value" = "Value" @@ -1720,6 +1725,7 @@ name = "English" "{n}m" = "{n}m" "{n}w" = "{n}w" "{page}, nothing selected" = "{page}, nothing selected" +"{percent}%" = "{percent}%" "{profile} (current)" = "{profile} (current)" "{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." "{server} can't say what became of your files (it is older than this app)." = "{server} can't say what became of your files (it is older than this app)." diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index d054e2223..61ec88733 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -8,6 +8,8 @@ name = "Español" status = "draft — needs native review" [ui] +"Auto ({percent}%)" = "Auto ({percent}%)" +"Auto follows your system's display scaling; the other sizes ignore it. Document zoom is separate." = "Auto sigue el escalado de pantalla del sistema; los demás tamaños lo ignoran. El zoom del documento es independiente." "Compatibility notes" = "Notas de compatibilidad" "Couldn't download the variables" = "No se pudieron descargar las variables" @@ -654,6 +656,7 @@ status = "draft — needs native review" "Interaction" = "Interacción" "Interactions" = "Interacciones" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Diseño de interfaces y prototipos. Una herramienta de diseño gratuita y de código abierto, de implementación independiente (clean-room), escrita desde cero en Rust, parte de las Crafting Apps de ArtCraft." +"Interface size" = "Tamaño de la interfaz" "Intersect" = "Intersecar" "Intersects" = "Intersecciones" "Invalid color" = "Color no válido" @@ -1372,6 +1375,7 @@ status = "draft — needs native review" "The selection uses several text styles: click to apply one" = "La selección usa varios estilos de texto: haz clic para aplicar uno" "The server deletes this file if nobody opens it before {date}." = "El servidor elimina este archivo si nadie lo abre antes del {date}." "The whole file" = "El archivo completo" +"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "La ventana es demasiado pequeña para {size}%, así que la interfaz se dibuja al {drawn}%. Agranda la ventana para usar {size}%." "Theme" = "Tema" "There's no destination for that action" = "No hay destino para esa acción" "Thickness" = "Grosor" @@ -1451,6 +1455,7 @@ status = "draft — needs native review" "Use number keys for opacity" = "Usar teclas numéricas para la opacidad" "Use the .figcraft or .fig extension." = "Usa la extensión .figcraft o .fig." "Use the layer's fill" = "Usar el relleno de la capa" +"Use your browser's zoom to change the interface size." = "Usa el zoom del navegador para cambiar el tamaño de la interfaz." "Used in {files} · {instances}" = "Usado en {files} · {instances}" "Username" = "Usuario" "Value" = "Valor" @@ -1721,6 +1726,7 @@ status = "draft — needs native review" "{n}m" = "{n} min" "{n}w" = "{n} sem" "{page}, nothing selected" = "{page}, nada seleccionado" +"{percent}%" = "{percent}%" "{profile} (current)" = "{profile} (actual)" "{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} es su valor; {property ? \"a\" : \"b\"} elige un texto. Cualquier otro {…} se queda tal cual." "{server} can't say what became of your files (it is older than this app)." = "{server} no puede decir qué fue de tus archivos (es anterior a esta aplicación)." @@ -2032,6 +2038,7 @@ status = "draft — needs native review" "pref.reduceMotion" = "Reducir movimiento" "pref.renameDuplicates" = "Renombrar capas duplicadas" "pref.showDimensions" = "Mostrar dimensiones en los objetos" +"pref.uiScale" = "Tamaño de la interfaz" "pref.userName" = "Tu nombre en los comentarios" "proto.action.add" = "Añadir acción de prototipo" "proto.action.move" = "Reordenar acción de prototipo" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 5edf1a624..ff88477fa 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -134,7 +134,9 @@ status = "draft — needs native review" "Auto (space between)" = "Auto (espace entre)" "Auto (space evenly)" = "Auto (espace égal)" "Auto ({mode})" = "Auto ({mode})" +"Auto ({percent}%)" = "Auto ({percent} %)" "Auto columns" = "Colonnes auto" +"Auto follows your system's display scaling; the other sizes ignore it. Document zoom is separate." = "Auto suit la mise à l’échelle d’affichage de votre système ; les autres tailles l’ignorent. Le zoom du document est distinct." "Auto gap" = "Espacement auto" "Auto height" = "Hauteur auto" "Auto layout" = "Mise en page auto" @@ -652,6 +654,7 @@ status = "draft — needs native review" "Interaction" = "Interaction" "Interactions" = "Interactions" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Design d’interfaces et prototypage. Un outil de design libre et gratuit, développé en salle blanche (clean-room) et écrit de zéro en Rust, membre des Crafting Apps d’ArtCraft." +"Interface size" = "Taille de l’interface" "Intersect" = "Intersection" "Intersects" = "Intersections" "Invalid color" = "Couleur non valide" @@ -1366,6 +1369,7 @@ status = "draft — needs native review" "The selection uses several text styles: click to apply one" = "La sélection utilise plusieurs styles de texte : cliquez pour en appliquer un" "The server deletes this file if nobody opens it before {date}." = "Le serveur supprime ce fichier si personne ne l’ouvre avant le {date}." "The whole file" = "Le fichier entier" +"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "La fenêtre est trop petite pour {size} %, l’interface est donc affichée à {drawn} %. Agrandissez la fenêtre pour utiliser {size} %." "Theme" = "Thème" "There's no destination for that action" = "Cette action n’a pas de destination" "Thickness" = "Épaisseur" @@ -1443,6 +1447,7 @@ status = "draft — needs native review" "Use number keys for opacity" = "Utiliser les touches numériques pour l’opacité" "Use the .figcraft or .fig extension." = "Utilisez l’extension .figcraft ou .fig." "Use the layer's fill" = "Utiliser le remplissage du calque" +"Use your browser's zoom to change the interface size." = "Utilisez le zoom de votre navigateur pour modifier la taille de l’interface." "Used in {files} · {instances}" = "Utilisé dans {files} · {instances}" "Username" = "Nom d’utilisateur" "Uses variables: each layer shows them in its own mode" = "Utilise des variables : chaque calque les affiche dans son propre mode" @@ -1710,6 +1715,7 @@ status = "draft — needs native review" "{n}m" = "{n} min" "{n}w" = "{n} sem." "{page}, nothing selected" = "{page}, aucune sélection" +"{percent}%" = "{percent} %" "{profile} (current)" = "{profile} (actuel)" "{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} donne sa valeur ; {property ? \"a\" : \"b\"} choisit un texte. Tout autre {…} reste tel quel." "{server} can't say what became of your files (it is older than this app)." = "{server} ne peut pas dire ce que sont devenus vos fichiers (il est plus ancien que cette application)." @@ -2021,6 +2027,7 @@ status = "draft — needs native review" "pref.reduceMotion" = "Réduire les animations" "pref.renameDuplicates" = "Renommer les calques dupliqués" "pref.showDimensions" = "Afficher les dimensions sur les objets" +"pref.uiScale" = "Taille de l’interface" "pref.userName" = "Votre nom dans les commentaires" "proto.action.add" = "Ajouter une action de prototype" "proto.action.move" = "Réordonner l’action de prototype" diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 448501abf..7527969ea 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -273,6 +273,8 @@ pub fn show(app: &mut FigApp, ctx: &Context) { } }); ui.add_space(8.0); + interface_size(app, ui, prefs.ui_scale); + ui.add_space(8.0); ui.horizontal(|ui| { ui.label(tr("Theme")); for (l, c) in [(tr_noop("Light"), "view.themeLight"), (tr_noop("Dark"), "view.themeDark"), (tr_noop("System"), "view.themeSystem")] { @@ -475,6 +477,52 @@ pub(crate) fn batch_rename_params(b: &BatchRenameUi) -> serde_json::Value { /// Rename layers: Match, Rename to with buttons for the current name and numbers, where numbers /// start, a preview of the first new names, then one `object.batchRename`. +/// Preferences ▸ Interface size: Auto (the system's scale) or a fixed size, as a drop-down that +/// takes effect at once, with what each means written out. The browser has its own zoom, so there +/// the row only says so. +fn interface_size(app: &mut FigApp, ui: &mut egui::Ui, chosen: Option) { + let t = Tokens::get(ui.ctx()); + if cfg!(target_arch = "wasm32") { + ui.label(tr("Interface size")); + crate::widgets::label(ui, tr("Use your browser's zoom to change the interface size."), t.text_secondary); + return; + } + let percent = |scale: f32| crate::ui_scale::percent(scale); + let auto = trf("Auto ({percent}%)", &[("percent", &percent(app.ui.ui_native))]); + let fixed = |size: f64| trf("{percent}%", &[("percent", &percent(size as f32))]); + let shown = chosen.map_or_else(|| auto.clone(), fixed); + ui.horizontal(|ui| { + ui.label(tr("Interface size")); + let resp = crate::widgets::dropdown(ui, &shown, 120.0, None); + crate::a11y::combo(&resp, tr("Interface size"), &shown, crate::a11y::menu_open(&resp)); + crate::right::menu_popup(&resp).show(|ui| { + if crate::right::menu_check(ui, chosen.is_none(), &auto, "") { + app.run("pref.uiScale", json!({"scale": "auto"})); + ui.close(); + } + for size in crate::ui_scale::SIZES { + if crate::right::menu_check(ui, chosen == Some(size), &fixed(size), "") { + app.run("pref.uiScale", json!({"scale": size})); + ui.close(); + } + } + }); + }); + crate::widgets::label( + ui, + tr("Auto follows your system's display scaling; the other sizes ignore it. Document zoom is separate."), + t.text_secondary, + ); + // Said in words, not only by the menu's value: the window is too small for the size chosen. + if let Some(size) = chosen.filter(|_| app.ui.ui_scale.limited) { + let note = trf( + "The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%.", + &[("size", &percent(size as f32)), ("drawn", &percent(app.ui.ui_scale.drawn))], + ); + crate::widgets::label(ui, ¬e, t.text_secondary); + } +} + fn batch_rename_dialog(app: &mut FigApp, ctx: &Context) { let Some(mut b) = app.ui.batch_rename.clone() else { return }; let t = Tokens::get(ctx); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 450317b43..f329c5212 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -61,6 +61,7 @@ pub mod tabs; pub mod theme; pub mod toolbar; pub mod typography; +pub mod ui_scale; pub mod variable_ui; pub mod variables_panel; pub mod vector_panel; @@ -204,6 +205,8 @@ mod text_enter_tests; #[cfg(test)] mod titlebar_tests; #[cfg(test)] +mod ui_scale_tests; +#[cfg(test)] mod variables_export_ui_tests; #[cfg(test)] mod variables_rename_tests; @@ -459,6 +462,11 @@ pub struct UiState { pub menu_input: Option, /// File ▸ Rename: the file name in the left panel starts its inline rename on the next frame. pub rename_file: bool, + /// What the interface size drew last frame: the system's scale (what Auto shows) and whether + /// the window held a fixed size back. Set by [`FigApp::logic`] for the main window. + pub ui_scale: ui_scale::Plan, + /// The system's scale of the main window, as of the last frame. + pub ui_native: f32, pub shortcuts_panel: bool, /// The Keyboard shortcuts panel's tab and search text. pub shortcuts_tab: figcraft_engine::keymap::Group, @@ -612,6 +620,8 @@ impl Default for UiState { preferences: false, menu_input: None, rename_file: false, + ui_scale: ui_scale::plan(None, None, None), + ui_native: 1.0, shortcuts_panel: false, shortcuts_tab: figcraft_engine::keymap::Group::Essential, shortcuts_query: String::new(), @@ -1261,6 +1271,17 @@ impl FigApp { theme::install_fonts(ctx, true, cjk); } } + // egui's zoom is for the whole app, so only the main window sets it (a window on another + // screen has its own system scale). The browser keeps its own zoom: Auto there. + if ctx.viewport_id() == egui::ViewportId::ROOT { + let native = ctx.native_pixels_per_point(); + let window_px = ctx.content_rect().size() * ctx.pixels_per_point(); + let fixed = if cfg!(target_arch = "wasm32") { None } else { self.session.prefs.ui_scale }; + let plan = ui_scale::plan(fixed, native, Some((window_px.x, window_px.y))); + ctx.set_zoom_factor(plan.zoom); + self.ui.ui_scale = plan; + self.ui.ui_native = native.filter(|n| n.is_finite() && *n > 0.0).unwrap_or(1.0); + } let dark = match self.session.prefs.theme.as_str() { "dark" => true, // The active visuals were set by us last frame (possibly for a manual theme). @@ -1679,6 +1700,8 @@ impl FigApp { "importReport": self.ui.import_report.as_ref().map(import_report::Report::inspect), }); state["homeSearch"] = json!(self.ui.home_view.file_query); + // Set apart from the literal above, which is at the macro recursion limit. + state["uiScale"] = json!({"choice": self.session.prefs.ui_scale.map_or(json!("auto"), |v| json!(v)), "drawn": self.ui.ui_scale.drawn, "limited": self.ui.ui_scale.limited}); state["pendingOpens"] = json!(self.ui.pending_opens.len()); state["missingFonts"] = json!(self.ui.missing_fonts.as_ref().map(missing_fonts::Shown::inspect)); state diff --git a/crates/ui-egui/src/ui_scale.rs b/crates/ui-egui/src/ui_scale.rs new file mode 100644 index 000000000..91d790ccb --- /dev/null +++ b/crates/ui-egui/src/ui_scale.rs @@ -0,0 +1,127 @@ +//! The interface size (Preferences ▸ Interface size, `pref.uiScale`): how the saved choice becomes +//! egui's zoom factor. +//! +//! egui draws at `pixels_per_point = zoom factor × the system's scale`. Auto leaves the zoom at 1 so +//! the system's scale rules. A fixed size is absolute: 100 % is one screen pixel per point on every +//! system, so the zoom is the size divided by the system's scale (a Retina Mac at 2× needs 0.5 to +//! show 100 %). A fixed size is held back while the window is too small to lay the interface out at +//! it; the saved choice never changes. + +/// The smallest window, in points, the interface is laid out for: the main window's minimum size. +pub const FIT_POINTS: (f32, f32) = (800.0, 500.0); + +/// The zoom factors egui's own settings window allows. +const ZOOM_RANGE: (f32, f32) = (0.1, 10.0); + +/// What to give egui for a saved choice on this screen and window. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Plan { + /// For `Context::set_zoom_factor`. + pub zoom: f32, + /// Physical pixels per point once drawn (the zoom times the system's scale). + pub drawn: f32, + /// A fixed size was chosen but the window is too small for it, so a smaller one is drawn. + pub limited: bool, +} + +/// The plan for `choice` (`None`: Auto), the system's scale `native` and the window's size in +/// physical pixels `window_px`. An unknown, zero or not finite value reads as no information: a +/// system scale of 1 and a window of any size. +pub fn plan(choice: Option, native: Option, window_px: Option<(f32, f32)>) -> Plan { + let native = native.filter(|n| n.is_finite() && *n > 0.0).unwrap_or(1.0); + let Some(want) = choice.map(|c| c as f32).filter(|c| c.is_finite() && *c > 0.0) else { + return Plan { zoom: 1.0, drawn: native, limited: false }; + }; + let fit = + window_px.filter(|(w, h)| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0).map(|(w, h)| (w / FIT_POINTS.0).min(h / FIT_POINTS.1)); + // Never below 1 (or the size asked, when that is smaller): a tiny window doesn't shrink the interface further. + let target = match fit { + Some(fit) if want > fit => fit.max(want.min(1.0)), + _ => want, + }; + let zoom = (target / native).clamp(ZOOM_RANGE.0, ZOOM_RANGE.1); + let drawn = zoom * native; + Plan { zoom, drawn, limited: drawn < want - 0.005 } +} + +/// A size as the percentage Preferences shows (1.25 → 125). +pub fn percent(scale: f32) -> u32 { + (scale * 100.0).round().clamp(0.0, 100_000.0) as u32 +} + +/// The fixed sizes Preferences offers, after Auto. +pub const SIZES: [f64; 7] = [0.75, 1.0, 1.25, 1.5, 2.0, 2.5, 3.0]; + +#[cfg(test)] +mod tests { + use super::*; + + const BIG: Option<(f32, f32)> = Some((5000.0, 3000.0)); + + #[test] + fn auto_keeps_the_systems_scale_on_every_screen() { + for native in [1.0, 1.25, 1.5, 1.75, 2.0, 3.0] { + let p = plan(None, Some(native), BIG); + assert_eq!((p.zoom, p.drawn, p.limited), (1.0, native, false), "{native}"); + } + } + + #[test] + fn a_fixed_size_is_the_same_size_whatever_the_systems_scale() { + // Windows 100/125/150/175 %, Retina 2×, a bare X11 window manager's guessed 2×, a 3× screen. + for native in [1.0, 1.25, 1.5, 1.75, 2.0, 3.0] { + for size in SIZES { + let p = plan(Some(size), Some(native), BIG); + assert!((p.drawn - size as f32).abs() < 1e-5, "{size} on {native}: drawn {}", p.drawn); + assert!(!p.limited, "{size} on {native}"); + } + } + // 100 % on a 2× screen really is half the Auto size: the zoom is 0.5. + assert_eq!(plan(Some(1.0), Some(2.0), BIG).zoom, 0.5); + } + + #[test] + fn a_missing_or_unusable_scale_reads_as_1_and_never_gives_egui_a_bad_zoom() { + for native in [None, Some(0.0), Some(-2.0), Some(f32::NAN), Some(f32::INFINITY)] { + for choice in [None, Some(1.5), Some(f64::NAN), Some(f64::INFINITY), Some(-1.0), Some(0.0), Some(1e300)] { + let p = plan(choice, native, Some((f32::NAN, 0.0))); + assert!(p.zoom.is_finite() && (ZOOM_RANGE.0..=ZOOM_RANGE.1).contains(&p.zoom), "{choice:?} {native:?}: {p:?}"); + assert!(p.drawn.is_finite() && p.drawn > 0.0, "{choice:?} {native:?}: {p:?}"); + } + } + // A choice that isn't a usable number is Auto. + assert_eq!(plan(Some(f64::NAN), Some(2.0), BIG), plan(None, Some(2.0), BIG)); + } + + #[test] + fn a_small_window_holds_a_big_size_back_without_going_below_100() { + // The minimum window on a Retina Mac: 800 × 500 points is 1600 × 1000 pixels. + let min = Some((1600.0, 1000.0)); + let p = plan(Some(3.0), Some(2.0), min); + assert_eq!((p.drawn, p.limited), (2.0, true)); + assert!(!plan(Some(2.0), Some(2.0), min).limited); + // The same window on a 1× screen: 800 × 500 pixels hold only 100 %. + let p = plan(Some(2.0), Some(1.0), Some((800.0, 500.0))); + assert_eq!((p.drawn, p.limited), (1.0, true)); + // A window smaller than the minimum (a tiling window manager): never below 100 %, and never + // below a smaller size the person chose. + assert_eq!(plan(Some(2.0), Some(1.0), Some((400.0, 250.0))).drawn, 1.0); + let p = plan(Some(0.75), Some(1.0), Some((400.0, 250.0))); + assert_eq!((p.drawn, p.limited), (0.75, false)); + } + + #[test] + fn the_limit_does_not_depend_on_the_zoom_so_it_cannot_flicker() { + // The window's size in pixels is the same whatever zoom is drawn now, so the plan for it is too. + let px = Some((1700.0, 1100.0)); + let a = plan(Some(2.5), Some(2.0), px); + let b = plan(Some(2.5), Some(2.0), px); + assert_eq!(a, b); + } + + #[test] + fn percentages_round_like_the_menu_shows_them() { + assert_eq!([percent(0.75), percent(1.0), percent(1.25), percent(2.0), percent(1.7999)], [75, 100, 125, 200, 180]); + assert_eq!([percent(f32::NAN), percent(-1.0)], [0, 0]); + } +} diff --git a/crates/ui-egui/src/ui_scale_tests.rs b/crates/ui-egui/src/ui_scale_tests.rs new file mode 100644 index 000000000..cda3dd1ad --- /dev/null +++ b/crates/ui-egui/src/ui_scale_tests.rs @@ -0,0 +1,75 @@ +//! The interface size, driven through egui headless on screens of different system scales: Auto +//! leaves the system's scale alone, a fixed size comes out the same on every screen, and the +//! Preferences drop-down tells a screen reader its name and value. +//! +//! The test window's size is fixed in points, so it grows with the zoom here; the limit for a small +//! window is tested on its own in `ui_scale::tests`, and the sizes below stay inside it. + +use egui::accesskit::Role; +use egui::{RawInput, ViewportId, ViewportInfo}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +/// One frame on a screen whose system scale is `native` (a real window reports it every frame). +fn frame_at(u: &mut Ui, native: f32) -> egui::FullOutput { + let mut raw = RawInput::default(); + raw.viewports.insert(ViewportId::ROOT, ViewportInfo { native_pixels_per_point: Some(native), ..Default::default() }); + u.frame_raw(raw) +} + +/// Frames on that screen, enough for a new zoom to take effect. +fn settle(u: &mut Ui, native: f32) { + for _ in 0..4 { + frame_at(u, native); + } +} + +const SCREENS: [f32; 5] = [1.0, 1.25, 1.5, 2.0, 3.0]; + +#[test] +fn auto_leaves_the_systems_scale_and_the_menu_can_say_what_it_is() { + for native in SCREENS { + let mut u = Ui::new(Session::new()); + settle(&mut u, native); + assert_eq!(u.ctx.zoom_factor(), 1.0, "{native}"); + assert_eq!(u.ctx.pixels_per_point(), native, "{native}"); + assert_eq!(u.app.ui.ui_native, native, "what Auto shows in the menu: {native}"); + } +} + +#[test] +fn a_fixed_size_draws_the_same_on_every_screen_and_auto_brings_the_systems_scale_back() { + for native in SCREENS { + let mut u = Ui::new(Session::new()); + for size in [0.75_f64, 1.0, 1.5] { + assert!(u.app.run("pref.uiScale", json!({"scale": size})).is_some()); + settle(&mut u, native); + let drawn = u.ctx.pixels_per_point(); + assert!((drawn - size as f32).abs() < 1e-4, "{size} on a {native}× screen drew at {drawn}"); + assert!(!u.app.ui.ui_scale.limited, "{size} on {native}"); + } + assert!(u.app.run("pref.uiScale", json!({"scale": "auto"})).is_some()); + settle(&mut u, native); + assert_eq!((u.ctx.zoom_factor(), u.ctx.pixels_per_point()), (1.0, native), "{native}"); + } +} + +#[test] +fn the_interface_size_menu_has_a_name_a_value_and_a_state_for_a_screen_reader() { + let mut u = Ui::new(Session::new()); + u.app.ui.preferences = true; + u.ctx.enable_accesskit(); + let combo = |u: &mut Ui| { + settle(u, 2.0); + let out = frame_at(u, 2.0); + let nodes: Vec<_> = out.platform_output.accesskit_update.expect("an AccessKit update").nodes.into_iter().map(|(_, n)| n).collect(); + let n = nodes.iter().find(|n| n.role() == Role::ComboBox && n.label() == Some("Interface size")).expect("an Interface size menu"); + (n.value().map(str::to_owned), n.is_expanded()) + }; + // Auto says the size it draws at: a 2× screen is 200 %. + assert_eq!(combo(&mut u), (Some("Auto (200%)".to_owned()), Some(false))); + u.app.run("pref.uiScale", json!({"scale": 1.25})); + assert_eq!(combo(&mut u), (Some("125%".to_owned()), Some(false))); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3ae9d287b..a807fe658 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -169,7 +169,9 @@ instance, a dragged axis slider; see `text.preview` in [mcp.md](mcp.md)). `canvas` or `properties`), whether `keyboard` mode is on (F6 or Tab moved the focus since the last pointer press, so the focus ring shows) and the name of the `control` with the keyboard (what a screen reader announces; empty when nothing has it). `reduceMotion` is true when the preference or -the system's accessibility setting turns the interface's animations off. `live` is the live +the system's accessibility setting turns the interface's animations off. `uiScale` is the interface +size: the `choice` (`"auto"` or a fixed size, `pref.uiScale`), what is `drawn` (physical pixels per +point) and whether the window is too small for the choice (`limited`, so `drawn` is smaller). `live` is the live session of the window's active file as the window shows it (sessions are per file: another tab shows its own, or `status: "off"`): `session` (its id, null when the file isn't in one), `sessions` (how many the app has running), `status`, `role`, the number of `peers`, who you `follow`, the diff --git a/docs/mcp.md b/docs/mcp.md index c5ffe99f5..42a6222db 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -417,6 +417,12 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: nested layers), Escape or a click drops it. - `pref.reduceMotion {on?}`: reduced motion (the interface's own animations off; the system's accessibility setting turns it on too). Prototype transitions still play. +- `pref.uiScale {scale?}`: how large the whole interface is drawn. `scale` is `"auto"` (the system's + display scale, the default) or a fixed size from 0.75 to 3 where 1 is one screen pixel per point + whatever scale the system reports, so a 2× screen needs 2 to match Auto. 1.25 means 125 %, not 125 + (outside the range it stops at the nearest end). Omit `scale` to read it. The canvas zoom is separate. + A window too small for a fixed size draws a smaller one until it is larger; the choice is kept. + In the browser the interface always follows the browser's zoom. - `pref.autoSave {on?}`: files that have a path save themselves a few seconds after they change (off by default; the desktop app's timer does it, so scripts still call `file.save`). - `pref.language {language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…}`: the app's interface language, applied live ("system": diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 766284f1d..c935e0094 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -165,6 +165,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Keyboard navigation: F6 regions, Tab within, focus ring | done | | | Layers panel from the keyboard: arrows, F2 / Cmd+R rename (the row gets the keyboard back), Shift+F10 context menu; 10k-layer lists | done | revealing a layer, expanding, collapsing and arrow keys timed in `cargo xtask bench` | | Reduced motion | done | system setting or preference | +| Interface size (Preferences ▸ Interface size, `pref.uiScale`) | done on desktop | Auto (the system's scale) or a fixed size from 75 % to 300 %, where 100 % is one screen pixel per point on every system; held back while the window is too small for it; the browser keeps its own zoom | | WCAG AA contrast of secondary and accent text | done | | | Tested with VoiceOver, NVDA, Narrator, Orca | missing | human QA | @@ -176,6 +177,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | 2026-10-17 | minor | Escape or Enter while the pen draws ends the path and keeps its vector in edit mode with the pen, so the next path is drawn into the same layer (unconnected); another Escape leaves edit mode (#760). Entering edit mode on a layer that already is a vector no longer records an empty undo step. Numbers unchanged | | 2026-10-17 | minor | Text layers keep the platform IME on for every frame of an edit, not only frames with input (an idle frame turned it off and macOS dropped each composition, #747); the candidate window opens at the caret instead of the canvas corner; keys pressed while composing go to the IME | | 2026-10-17 | minor | The community batches #733, #735, #736, #729 and #753: submenus open after a short hover, keep their place along a safe triangle and reopen closed (main and context menus); the Actions palette's tabs, ranked search, keyboard highlight and recents; menu items that need a value ask for it, File ▸ Rename edits in place; Shift+I and Resources open Assets; a draggable Pages/Layers divider; Shift-drag moves the selection; a double-click near a thin stroke, merging dropped points and Flatten keeping open paths in vector editing; live font preview and the selection highlight after line breaks; the colour picker on a new fill or stroke; Paste over and Paste properties from the keyboard; Selection colors (partial: no library styles or variables there) and smart-selection spacing fields and canvas handles (partial: no reorder rings), both also checked by their author on macOS; the keyboard shortcuts panel closes on the home screen; Adwaita window controls on Wayland compositors without server decorations (checked by its author on GNOME; KDE not run). Checked in the app on Windows (submenus by their author), not side by side with Figma. Summary and hours unchanged | +| 2026-10-17 | minor | Interface size preference (#540): Preferences ▸ Interface size and `pref.uiScale`, Auto or a fixed size from 75 % to 300 %, absolute (100 % is one screen pixel per point whatever the system's scale) | | 2026-10-15 | minor | Hex fields take CSS colour names (`red` → `FF0000`): fills, strokes, effects, grids, variables, backgrounds and the colour picker | | 2026-10-15 | minor | Typography: drag the line-height, letter-spacing and paragraph-spacing icons to scrub their values; shared numeric-field rates, resize cursor and one Undo step per gesture (#373). | | 2026-10-15 | minor | A click or Tab into a one-line field selects all of its text (hex, inline, name, search and dialog fields); the first click into a fill's hex takes the keyboard | From a6659dcdfc52d36f265058faeec5ba010574b685 Mon Sep 17 00:00:00 2001 From: leandrodslv <205958219+leandrodslv@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:55:19 +0200 Subject: [PATCH 007/114] test(ui): sweep every window size, system scale and interface size Draws the interface the way a real window does (a fixed number of physical pixels, its size in points being that divided by the scale drawn now) over 10 window sizes, 6 system scales and every interface size. Guards the rule a fixed size promises: above 100 % the window still holds the smallest layout, the panels leave room for a canvas, and the size settles without flicker. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/ui_scale_tests.rs | 82 ++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/crates/ui-egui/src/ui_scale_tests.rs b/crates/ui-egui/src/ui_scale_tests.rs index cda3dd1ad..74f1f676c 100644 --- a/crates/ui-egui/src/ui_scale_tests.rs +++ b/crates/ui-egui/src/ui_scale_tests.rs @@ -73,3 +73,85 @@ fn the_interface_size_menu_has_a_name_a_value_and_a_state_for_a_screen_reader() u.app.run("pref.uiScale", json!({"scale": 1.25})); assert_eq!(combo(&mut u), (Some("125%".to_owned()), Some(false))); } + +/// Every window size, system scale and interface size, drawn the way a real window is: the window is +/// a fixed number of physical pixels, and its size in points is that divided by the scale drawn now +/// (egui-winit does the same each frame), so a bigger size shrinks the window in points. +/// +/// What a fixed size promises: whatever it draws at above 100 %, the window still holds the smallest +/// layout the interface is made for (`ui_scale::FIT_POINTS`), and the panels leave room for a canvas. +#[test] +fn every_window_size_system_scale_and_interface_size_lays_out_inside_the_window() { + use crate::ui_scale::{FIT_POINTS, SIZES}; + use crate::{FigApp, NoServices}; + use egui::{Pos2, Rect, vec2}; + + // Window sizes in points: the main window's minimum, common laptop and desktop screens up to 5K, + // and a short wide strip. + let windows: [(f32, f32); 10] = [ + (800.0, 500.0), + (1024.0, 640.0), + (1280.0, 720.0), + (1366.0, 768.0), + (1440.0, 900.0), + (1920.0, 1080.0), + (2560.0, 1440.0), + (3840.0, 2160.0), + (5120.0, 2880.0), + (2560.0, 520.0), + ]; + let mut choices: Vec> = vec![None]; + choices.extend(SIZES.iter().map(|s| Some(*s))); + + for native in [1.0_f32, 1.25, 1.5, 1.75, 2.0, 3.0] { + for (w, h) in windows { + let px = vec2(w * native, h * native); + for choice in &choices { + let case = format!("{w}x{h} pt at {native}x, {choice:?}"); + let mut app = FigApp::new(Session::new(), Box::new(NoServices)); + let scale = choice.map_or(json!("auto"), |c| json!(c)); + assert!(app.run("pref.uiScale", json!({"scale": scale})).is_some(), "{case}"); + let ctx = egui::Context::default(); + let mut time = 0.0; + let mut seen = Vec::new(); + for _ in 0..8 { + time += 1.0 / 60.0; + let ppp = ctx.pixels_per_point(); + let mut raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, px / ppp)), time: Some(time), ..Default::default() }; + raw.viewports.insert(ViewportId::ROOT, ViewportInfo { native_pixels_per_point: Some(native), ..Default::default() }); + let mut out = ctx.run_ui(raw, |ui| app.ui(ui)); + out.textures_delta.clear(); + seen.push((ctx.pixels_per_point(), ctx.content_rect().size())); + } + // It settles within a few frames and then stays put: no flicker between two sizes. + let (ppp, content) = seen[seen.len() - 1]; + assert_eq!(seen[seen.len() - 2].0, ppp, "{case}: still changing"); + assert!(ppp.is_finite() && ppp > 0.0 && content.x.is_finite() && content.y.is_finite(), "{case}: {ppp} {content:?}"); + let plan = app.ui.ui_scale; + assert!((ppp - plan.drawn).abs() < 1e-3, "{case}: egui draws at {ppp}, the plan said {}", plan.drawn); + match choice { + None => assert!((ppp - native).abs() < 1e-3 && !plan.limited, "{case}: Auto must keep the system's scale"), + Some(want) => { + let want = *want as f32; + assert_eq!(plan.limited, ppp < want - 0.005, "{case}: limited flag"); + // Above 100 % the window holds the smallest layout; at or below it there is nothing more to give up. + if ppp > 1.001 { + assert!( + content.x >= FIT_POINTS.0 - 0.5 && content.y >= FIT_POINTS.1 - 0.5, + "{case}: window is {content:?} points at {ppp}" + ); + } + // It never draws bigger than asked, and only a window too small for it draws smaller. + assert!(ppp <= want + 1e-3, "{case}"); + if !plan.limited { + assert!((ppp - want).abs() < 1e-3, "{case}"); + } + } + } + let panels = &app.inspect()["panels"]; + let side = panels["left"].as_f64().unwrap_or(0.0) + panels["right"].as_f64().unwrap_or(0.0); + assert!(side < f64::from(content.x) - 100.0, "{case}: the panels ({side} pt) leave no canvas in {content:?}"); + } + } + } +} From 4d9d12ea13c2c01ef3f2fe9358bcb7fa8d856965 Mon Sep 17 00:00:00 2001 From: leandrodslv <205958219+leandrodslv@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:14:19 +0200 Subject: [PATCH 008/114] fix(ui): let escape close an open menu before its dialog Escape closed the whole Preferences dialog even while a menu was open over it. That held while Preferences had no menu of its own; the Interface size menu is the first, so one Escape threw away the dialog with the menu. Dialog keys now leave Escape to an open menu, and the next Escape closes the dialog as before. Adds a keyboard test of the Interface size menu: Tab reaches it, Enter opens it, the arrows move through the sizes, Enter picks one and closes the menu, and Escape closes only the menu. Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/dialogs.rs | 6 ++- crates/ui-egui/src/ui_scale_tests.rs | 63 ++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7527969ea..3fb2c2d4f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -143,8 +143,12 @@ fn about_body(ui: &mut egui::Ui, t: &Tokens) { } /// Escape closes the topmost simple dialog (one without menus of its own), before the canvas's -/// shortcuts see it (it doesn't deselect), like a system dialog: from one of its fields too. +/// shortcuts see it (it doesn't deselect), like a system dialog: from one of its fields too. A menu +/// open over the dialog (Preferences ▸ Interface size) takes the first Escape and closes alone. pub fn keys(app: &mut FigApp, ctx: &Context) { + if egui::Popup::is_any_open(ctx) { + return; + } let u = &mut app.ui; let open = u.color_profile.is_some() || u.menu_input.is_some() diff --git a/crates/ui-egui/src/ui_scale_tests.rs b/crates/ui-egui/src/ui_scale_tests.rs index 74f1f676c..189552e7e 100644 --- a/crates/ui-egui/src/ui_scale_tests.rs +++ b/crates/ui-egui/src/ui_scale_tests.rs @@ -155,3 +155,66 @@ fn every_window_size_system_scale_and_interface_size_lays_out_inside_the_window( } } } + +/// The interface size is reachable and changeable without a pointer: Tab reaches the menu, Enter +/// opens it, the arrows and Tab move through its entries, Enter picks one and closes the menu, and +/// Escape closes it alone, without changing anything and without closing Preferences (a second +/// Escape does). +#[test] +fn the_interface_size_menu_works_from_the_keyboard() { + use crate::a11y::focused_name; + use egui::{Event, Key, Modifiers, Popup}; + + // A key press, then two quiet frames for the focus (and the name a screen reader reads) to settle. + let press = |u: &mut Ui, key: Key| { + u.frame(vec![ + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }, + Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }, + ]); + u.frame(vec![]); + u.frame(vec![]); + }; + let open = |u: &mut Ui| { + // The Preferences dialog is not modal for the keyboard: Tab crosses the main window first. + let mut tabs = 0; + while focused_name(&u.ctx) != "Interface size" { + assert!(tabs < 140, "Tab never reaches the Interface size menu (focus: {:?})", focused_name(&u.ctx)); + press(u, Key::Tab); + tabs += 1; + } + press(u, Key::Enter); + assert!(Popup::is_any_open(&u.ctx), "Enter opens the menu"); + }; + let chosen = |u: &Ui| u.app.inspect()["uiScale"]["choice"].clone(); + + let mut u = Ui::new(Session::new()); + u.app.ui.preferences = true; + u.ctx.enable_accesskit(); + settle(&mut u, 1.0); + assert_eq!(chosen(&u), json!("auto")); + + // Escape closes the menu and nothing else: the size is unchanged and Preferences stays open. + open(&mut u); + press(&mut u, Key::Escape); + u.frame(vec![]); + assert!(!Popup::is_any_open(&u.ctx)); + assert!(u.app.ui.preferences, "the first Escape closes the menu only"); + assert_eq!(chosen(&u), json!("auto")); + + // The arrows enter the menu on Auto (which says the size it draws at) and go down through the + // sizes (75, 100, 125); Enter picks the one that has the keyboard and closes the menu. + open(&mut u); + press(&mut u, Key::ArrowDown); + assert_eq!(focused_name(&u.ctx), "Auto (100%)"); + for _ in 0..3 { + press(&mut u, Key::ArrowDown); + } + assert_eq!(focused_name(&u.ctx), "125%"); + press(&mut u, Key::Enter); + assert_eq!(chosen(&u), json!(1.25)); + assert!(!Popup::is_any_open(&u.ctx), "picking a size closes the menu"); + assert!(u.app.ui.preferences, "picking a size leaves Preferences open"); + // With no menu open, Escape closes Preferences as before. + press(&mut u, Key::Escape); + assert!(!u.app.ui.preferences); +} From 32bc822451abde10ca038cb95473817fc55d7417 Mon Sep 17 00:00:00 2001 From: Lucas Date: Sun, 18 Oct 2026 14:45:19 +0200 Subject: [PATCH 009/114] fix(i18n): Restrict Polish plural One to n == 1 per CLDR rules Polish integer plural rules use Plural::One strictly for n == 1, while higher counts ending in 1 (e.g. 21, 101) use Plural::Many. Also adds unit tests for Polish integer plural coverage. Co-Authored-By: Antigravity --- crates/ui-egui/src/i18n.rs | 3 ++- crates/ui-egui/src/i18n_tests.rs | 23 ++++++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 51e4c37b6..4a7ef81ce 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -171,7 +171,8 @@ fn plural(code: &str, n: usize) -> Plural { "cs" if n == 1 => Plural::One, "cs" if (2..=4).contains(&n) => Plural::Few, "cs" => Plural::Other, - "ru" | "pl" if n % 10 == 1 && n % 100 != 11 => Plural::One, + "pl" if n == 1 => Plural::One, + "ru" if n % 10 == 1 && n % 100 != 11 => Plural::One, "ru" | "pl" if (2..=4).contains(&(n % 10)) && !(12..=14).contains(&(n % 100)) => Plural::Few, "ru" | "pl" => Plural::Many, "ja" | "ko" | "zh-Hans" => Plural::Other, diff --git a/crates/ui-egui/src/i18n_tests.rs b/crates/ui-egui/src/i18n_tests.rs index d4d81d7b9..738be97bd 100644 --- a/crates/ui-egui/src/i18n_tests.rs +++ b/crates/ui-egui/src/i18n_tests.rs @@ -400,7 +400,7 @@ fn french_and_portuguese_count_zero_as_singular() { for code in ["fr", "pt-BR"] { assert!(one(code, 0) && one(code, 1) && !one(code, 2), "{code}"); } - for code in ["en", "es", "de", "cs"] { + for code in ["en", "es", "de", "cs", "pl"] { for n in [0, 1, 2, 3, 4, 5, 21] { assert_eq!(one(code, n), n == 1, "{code}: {n}"); } @@ -493,6 +493,27 @@ fn russian_language_tags_and_integer_plurals() { } } +#[test] +fn polish_integer_plurals() { + let pl = LANGS.iter().position(|l| l.code == "pl").unwrap(); + assert_eq!(LANGS[pl].name, "Polski"); + assert_eq!(plural("pl", 1), Plural::One); + assert_eq!(fill(counted(pl, "{n} layers", 1), &[("n", &1)]), "1 warstwa"); + for n in [2, 3, 4, 22, 23, 24, 102, 104, 1_002] { + assert_eq!(plural("pl", n), Plural::Few, "{n}"); + assert_eq!(fill(counted(pl, "{n} layers", n), &[("n", &n)]), format!("{n} warstwy")); + } + for n in [0, 5, 10, 11, 12, 13, 14, 19, 20, 21, 25, 31, 100, 101, 111, 112, 114, 1_000_000] { + assert_eq!(plural("pl", n), Plural::Many, "{n}"); + assert_eq!(fill(counted(pl, "{n} layers", n), &[("n", &n)]), format!("{n} warstw")); + } + for (key, text) in LANGS[pl].ui { + if let Text::Plural { one, few, many, .. } = text { + assert!(one.is_some() && few.is_some() && many.is_some(), "{key}: missing Polish plural forms"); + } + } +} + #[test] fn plural_fallback_uses_the_language_of_its_catalog() { let la = LANGS.iter().position(|l| l.code == "es-419").unwrap(); From a3e9af917d96c28a21cb606236768c975836bfcc Mon Sep 17 00:00:00 2001 From: Al Rifat Sabbir <150162343+alrifatsabbir@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:08:34 +0600 Subject: [PATCH 010/114] Add Bengali UI Translation Add Bengali translation (bn.toml) --- crates/ui-egui/locales/bn.toml | 1744 ++++++++++++++++++++++++++++++++ 1 file changed, 1744 insertions(+) create mode 100644 crates/ui-egui/locales/bn.toml diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml new file mode 100644 index 000000000..7cf7dcb92 --- /dev/null +++ b/crates/ui-egui/locales/bn.toml @@ -0,0 +1,1744 @@ +# FigCraft interface strings: English, the source language. +# +# Keys are the English text as the code writes it (`tr("…")`, `trf`, `trn`, `tr_noop`); values are +# the same text, except plural entries ({ one, other }). Command labels come from the engine. +# `cargo test -p figcraft-ui-egui i18n` lists missing and unused keys. See docs/translating.md. + +[meta] +name = "বাংলা" +status = "draft — needs native review" +[meta] +name = "বাংলা" +status = "draft — needs native review" + +[ui] +"% of all commits" = "% of all commits" +"(no longer published)" = "(প্রকাশিত নয়)" +"({n} warnings: some entries were skipped)" = { one = "({n}টি সতর্কতা: কিছু এন্ট্রি বাদ দেওয়া হয়েছে)", other = "({n}টি সতর্কতা: কিছু এন্ট্রি বাদ দেওয়া হয়েছে)" } +"+ Create variable" = "+ ভেরিয়েবল তৈরি করুন" +".fig (experimental)" = ".fig (পরীক্ষামূলক)" +"1…9, 0" = "১…৯, ০" +"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs}টি PR, {commits}টি কমিট, +{added} / −{deleted} লাইন (Δ {delta}), +{bin_added} / −{bin_deleted} বাইনারি অ্যাসেট, {first} – {last}" +"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "এই মোড এবং ভেরিয়েবল সহ এই ফাইলের একটি সংগ্রহ: প্রতি মোডে তাদের মান ওভাররাইড করুন এবং এর নিজস্ব ভেরিয়েবল যোগ করুন" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "এই নামের একটি .figcraft ফাইল ইতিমধ্যে বিদ্যমান। প্রতিস্থাপন নিশ্চিত করতে এর সম্পূর্ণ নাম বেছে নিন।" +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "একটি লেয়ার একা তার ফ্রেমের সাথে সারিবদ্ধ হয়: আরও লেয়ার নির্বাচন করুন, বা ফ্রেমে থাকা একটি লেয়ার নির্বাচন করুন" +"A responsive product page: hero, features, figures, a quote and a footer" = "একটি রেসপনসিভ প্রোডাক্ট পেজ: হিরো, ফিচার, ফিগার, একটি উদ্ধৃতি এবং একটি ফুটার" +"A spring plays until it settles: its mass, stiffness and damping set how long" = "একটি স্প্রিং স্থির হওয়া পর্যন্ত চলে: এর ভর, দৃঢ়তা (stiffness) এবং ড্যাম্পিং নির্ধারণ করে কতক্ষণ চলবে" +"A title, an agenda, a big figure, a chart and a closing slide" = "একটি শিরোনাম, একটি আলোচ্যসূচি, একটি বড় ফিগার, একটি চার্ট এবং একটি সমাপ্তি স্লাইড" +"A to Z" = "A থেকে Z" +"About" = "সম্পর্কিত" +"About FigCraft" = "FigCraft সম্পর্কে" +"About this server" = "এই সার্ভার সম্পর্কে" +"Action" = "অ্যাকশন" +"Actions" = "অ্যাকশনসমূহ" +"Actions for {name}" = "{name}-এর জন্য অ্যাকশন" +"Actions…" = "অ্যাকশনসমূহ..." +"Active" = "সক্রিয়" +"Actual size (100%)" = "প্রকৃত আকার (১০০%)" +"Add a category" = "একটি বিভাগ যোগ করুন" +"Add a comment" = "একটি মন্তব্য যোগ করুন" +"Add a description" = "একটি বিবরণ যোগ করুন" +"Add a frame (F) or a shape (R) to get started" = "শুরু করতে একটি ফ্রেম (F) বা শেপ (R) যোগ করুন" +"Add a layer or component to tile first" = "প্রথমে টাইল করার জন্য একটি লেয়ার বা কম্পোনেন্ট যোগ করুন" +"Add a note for developers" = "ডেভেলপারদের জন্য একটি নোট যোগ করুন" +"Add action" = "অ্যাকশন যোগ করুন" +"Add actions for when no condition holds" = "কোনো শর্ত পূর্ণ না হলে তার জন্য অ্যাকশন যোগ করুন" +"Add an annotation for developers" = "ডেভেলপারদের জন্য একটি অ্যানোটেশন যোগ করুন" +"Add another condition" = "আরেকটি শর্ত যোগ করুন" +"Add auto layout" = "অটো লেআউট যোগ করুন" +"Add background behind overlay" = "ওভারলে-এর পেছনে ব্যাকগ্রাউন্ড যোগ করুন" +"Add content to this slot" = "এই স্লটে কন্টেন্ট যোগ করুন" +"Add dev resource" = "ডেভ রিসোর্স যোগ করুন" +"Add effect" = "ইফেক্ট যোগ করুন" +"Add export setting" = "এক্সপোর্ট সেটিংস যোগ করুন" +"Add fill" = "ফিল যোগ করুন" +"Add interaction" = "ইন্টারঅ্যাকশন যোগ করুন" +"Add layout grid" = "লেআউট গ্রিড যোগ করুন" +"Add library" = "লাইব্রেরি যোগ করুন" +"Add library file…" = "লাইব্রেরি ফাইল যোগ করুন..." +"Add max height" = "সর্বোচ্চ উচ্চতা যোগ করুন" +"Add max width" = "সর্বোচ্চ প্রস্থ যোগ করুন" +"Add min height" = "সর্বনিম্ন উচ্চতা যোগ করুন" +"Add min width" = "সর্বনিম্ন প্রস্থ যোগ করুন" +"Add mode" = "মোড যোগ করুন" +"Add new page" = "নতুন পেজ যোগ করুন" +"Add or update collections from a variables JSON" = "একটি ভেরিয়েবল JSON থেকে সংগ্রহ যোগ বা আপডেট করুন" +"Add properties to make instances configurable" = "ইনস্ট্যান্সগুলোকে কনফিগারযোগ্য করতে প্রপার্টি যোগ করুন" +"Add reaction" = "রিঅ্যাকশন যোগ করুন" +"Add starting point" = "শুরু করার বিন্দু যোগ করুন" +"Add stroke" = "স্ট্রোক যোগ করুন" +"Add variant" = "ভ্যারিয়েন্ট যোগ করুন" +"Add variant with a new value" = "একটি নতুন মান সহ ভ্যারিয়েন্ট যোগ করুন" +"Added" = "যোগ করা হয়েছে" +"Added the library \"{name}\"" = "\"{name}\" লাইব্রেরি যোগ করা হয়েছে" +"Adding to {name}" = "{name}-এ যোগ করা হচ্ছে" +"Adjust" = "সামঞ্জস্য করুন" +"Adjustment presets" = "অ্যাডজাস্টমেন্ট প্রিসেট" +"Advanced" = "উন্নত" +"Advanced stroke" = "উন্নত স্ট্রোক" +"After delay" = "বিলম্বের পর" +"After delay {ms}ms" = "{ms}ms বিলম্বের পর" +"Align" = "সারিবদ্ধ করুন" +"Align bottom" = "নিচে সারিবদ্ধ করুন" +"Align center" = "কেন্দ্রে সারিবদ্ধ করুন" +"Align horizontal centers" = "অনুভূমিক কেন্দ্র বরাবর সারিবদ্ধ করুন" +"Align left" = "বামে সারিবদ্ধ করুন" +"Align middle" = "মাঝামাঝি সারিবদ্ধ করুন" +"Align right" = "ডানে সারিবদ্ধ করুন" +"Align text baselines" = "টেক্সট বেসলাইন সারিবদ্ধ করুন" +"Align top" = "উপরে সারিবদ্ধ করুন" +"Align vertical centers" = "উল্লম্ব কেন্দ্র বরাবর সারিবদ্ধ করুন" +"Alignment" = "সারিবদ্ধকরণ" +"All" = "সব" +"All categories" = "সব বিভাগ" +"All comments" = "সব মন্তব্য" +"All components" = "সব কম্পোনেন্ট" +"All fonts" = "সব ফন্ট" +"All supported properties" = "সমর্থিত সব প্রপার্টি" +"All the fonts this file uses are installed" = "এই ফাইলে ব্যবহৃত সব ফন্ট ইনস্টল করা আছে" +"All variables" = "সব ভেরিয়েবল" +"Alpha" = "আলফা" +"Alphabetical" = "বর্ণানুক্রমিক" +"Alt+drag" = "Alt+ড্র্যাগ" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "গ্রিডের উপর একটি অ্যানালিটিক্স স্ক্রিন: ফিগার, ভেক্টর থেকে আঁকা চার্ট এবং একটি টেবিল" +"An invite link that opens FigCraft and joins" = "একটি আমন্ত্রণ লিংক যা FigCraft খুলে যুক্ত হয়" +"Anchor" = "অ্যাঙ্কর" +"Android" = "অ্যান্ড্রয়েড" +"Angular" = "অ্যাঙ্গুলার" +"Animation" = "অ্যানিমেশন" +"Annotations" = "অ্যানোটেশন" +"Annotations shown on the canvas" = "ক্যানভাসে অ্যানোটেশনগুলো দেখানো হচ্ছে" +"Another server, or one that needs its key. The usual server is set in Preferences." = "অন্য একটি সার্ভার, অথবা যার কী প্রয়োজন। সাধারণ সার্ভারটি প্রেফারেন্সেসে সেট করা আছে।" +"Any status" = "যেকোনো স্ট্যাটাস" +"Anyone on your network with this code can edit the file, while this app stays open." = "এই অ্যাপ খোলা থাকা অবস্থায় আপনার নেটওয়ার্কে এই কোড জানা যেকোনো ব্যক্তি ফাইলটি এডিট করতে পারবেন।" +"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "এডিট লিংক থাকা যেকোনো ব্যক্তি ফাইলটি পরিবর্তন করতে পারবেন; ভিউ লিংক শুধু দেখতে দেয়। লিংকগুলো গোপন তথ্য বহন করে: পাসওয়ার্ডের মতো এগুলো শেয়ার করুন। `figcraft-server links` কমান্ড দিয়ে আরও তৈরি ও বাতিল করা যায়।" +"Anyone with this code can edit the file." = "এই কোড জানা যেকোনো ব্যক্তি ফাইলটি এডিট করতে পারবেন।" +"Anyone with this code can look at the file, not change it." = "এই কোড জানা যেকোনো ব্যক্তি ফাইলটি দেখতে পারবেন, পরিবর্তন করতে পারবেন না।" +"Appearance" = "চেহারা" +"Apply a component property" = "একটি কম্পোনেন্ট প্রপার্টি প্রয়োগ করুন" +"Apply variable" = "ভেরিয়েবল প্রয়োগ করুন" +"Apply variable to text" = "টেক্সটে ভেরিয়েবল প্রয়োগ করুন" +"Apply variable to visibility" = "দৃশ্যমানতায় ভেরিয়েবল প্রয়োগ করুন" +"Apps" = "অ্যাপস" +"Apr" = "এপ্রিল" +"Arc" = "আর্ক" +"Arc ratio" = "আর্ক অনুপাত" +"Arc start" = "আর্ক শুরু" +"Arc sweep" = "আর্ক সুইপ" +"Archive" = "আর্কাইভ" +"Archived" = "আর্কাইভ করা হয়েছে" +"Arrange" = "সাজান" +"Arrow" = "তীর" +"As typed" = "যেমনটি টাইপ করা হয়েছে" +"Asking the server…" = "সার্ভারকে জিজ্ঞাসা করা হচ্ছে..." +"Assets" = "অ্যাসেট" +"Aug" = "আগস্ট" +"Auto" = "অটো" +"Auto (space around)" = "অটো (চারপাশে স্পেস)" +"Auto (space between)" = "অটো (মাঝখানে স্পেস)" +"Auto (space evenly)" = "অটো (সমানভাবে স্পেস)" +"Auto ({mode})" = "অটো ({mode})" +"Auto columns" = "অটো কলাম" +"Auto gap" = "অটো গ্যাপ" +"Auto height" = "অটো উচ্চতা" +"Auto layout" = "অটো লেআউট" +"Auto rows" = "অটো সারি" +"Auto width" = "অটো প্রস্থ" +"Back to the FigCraft server" = "FigCraft সার্ভারে ফিরে যান" +"Back to the font's value" = "ফন্টের মানে ফিরে যান" +"Back" = "পেছনে" +"Back to Ready for dev" = "ডেভের জন্য প্রস্তুত অবস্থায় ফিরে যান" +"Back to Ready for dev: the next change compares with how it is now" = "ডেভের জন্য প্রস্তুত অবস্থায় ফিরে যান: পরবর্তী পরিবর্তনটি বর্তমান অবস্থার সাথে তুলনা করবে" +"Back to assets" = "অ্যাসেটে ফিরে যান" +"Back to current" = "বর্তমানে ফিরে যান" +"Back to styles" = "স্টাইলে ফিরে যান" +"Back to the canvas from a focused region" = "ফোকাসড অঞ্চল থেকে ক্যানভাসে ফিরে যান" +"Back to the current version" = "বর্তমান সংস্করণে ফিরে যান" +"Back to the values the font style implies" = "ফন্ট স্টাইল নির্দেশিত মানে ফিরে যান" +"Background" = "ব্যাকগ্রাউন্ড" +"Background blur" = "ব্যাকগ্রাউন্ড ব্লার" +"Basics" = "মৌলিক বিষয়" +"Before" = "আগে" +"Bend" = "বাঁক" +"Bevel" = "বেভেল" +"Binary assets added" = "বাইনারি অ্যাসেট যোগ করা হয়েছে" +"Binary assets removed" = "বাইনারি অ্যাসেট সরানো হয়েছে" +"Black & white" = "কালো ও সাদা" +"Bleed" = "ব্লিড" +"Bleed marks" = "ব্লিড মার্ক" +"Bleed marks need a bleed" = "ব্লিড মার্কের জন্য ব্লিড প্রয়োজন" +"Blend" = "ব্লেন্ড" +"Blend mode" = "ব্লেন্ড মোড" +"Blue" = "নীল" +"Blur" = "ব্লার" +"Boolean" = "বুলিয়ান" +"Boolean groups" = "বুলিয়ান গ্রুপ" +"Both directions" = "উভয় দিক" +"Bottom" = "নিচে" +"Bottom left radius" = "নিচের বাম ব্যাসার্ধ" +"Bottom right radius" = "নিচের ডান ব্যাসার্ধ" +"Branch" = "ব্রাঞ্চ" +"Branch archived" = "ব্রাঞ্চ আর্কাইভ করা হয়েছে" +"Branch merged" = "ব্রাঞ্চ মার্জ করা হয়েছে" +"Branch name" = "ব্রাঞ্চের নাম" +"Branch of {file}" = "{file}-এর ব্রাঞ্চ" +"Branch of {file} · merged or archived" = "{file}-এর ব্রাঞ্চ · মার্জ বা আর্কাইভ করা হয়েছে" +"Branch updated from main" = "প্রধান (main) থেকে ব্রাঞ্চ আপডেট করা হয়েছে" +"Branch {name}" = "ব্রাঞ্চ {name}" +"Branches" = "ব্রাঞ্চসমূহ" +"Bound to {name}. Click to detach the variable." = "{name}-এর সাথে আবদ্ধ। ভেরিয়েবলটি বিচ্ছিন্ন করতে ক্লিক করুন।" +"Bring forward" = "সামনে আনুন" +"Bring the main file's changes into the branch" = "প্রধান ফাইলের পরিবর্তনগুলো ব্রাঞ্চে নিয়ে আসুন" +"Bring to front" = "সবার সামনে আনুন" +"Bristle" = "ব্রিসল" +"Browse" = "ব্রাউজ করুন" +"Brush" = "ব্রাশ" +"Brush rotation jitter" = "ব্রাশ ঘূর্ণন জিটার" +"Brush scatter" = "ব্রাশ স্ক্যাটার" +"Brush size jitter" = "ব্রাশ সাইজ জিটার" +"Brush spacing" = "ব্রাশ স্পেসিং" +"Bulleted list" = "বুলেটেড তালিকা" +"C or Shift+C" = "C বা Shift+C" +"C or ⇧C" = "C বা ⇧C" +"CMYK colour" = "CMYK রঙ" +"Can view" = "দেখতে পারেন" +"Checking the fonts installed on this computer…" = "এই কম্পিউটারে ইনস্টল করা ফন্টগুলো চেক করা হচ্ছে..." +"Checking the server…" = "সার্ভার চেক করা হচ্ছে..." +"Clear from this list" = "এই তালিকা থেকে মুছে ফেলুন" +"Code or invite link" = "কোড বা আমন্ত্রণ লিংক" +"Color profile: {name}" = "কালার প্রোফাইল: {name}" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "রঙ ও টেক্সট স্টাইল, লাইট এবং ডার্ক ভেরিয়েবল, বোতাম, ইনপুট এবং কার্ড" +"Convert to frame" = "ফ্রেম রূপান্তর করুন" +"Convert to section" = "সেকশনে রূপান্তর করুন" +"CSS custom properties…" = "CSS কাস্টম প্রপার্টি..." +"Can edit" = "এডিট করতে পারেন" +"Can't read this library: {e}" = "এই লাইব্রেরি পড়া যাচ্ছে না: {e}" +"Cancel" = "বাতিল" +"Canvas" = "ক্যানভাস" +"Canvas stacking" = "ক্যানভাস স্ট্যাকিং" +"Cap height to baseline" = "বেসলাইনে ক্যাপ হাইট" +"Case" = "কেস" +"Case-sensitive forms" = "কেস-সেন্সিটিভ ফর্ম" +"Categories" = "বিভাগসমূহ" +"Center" = "কেন্দ্র" +"Change alias" = "অ্যালিয়াস পরিবর্তন করুন" +"Change resolved" = "পরিবর্তন সমাধান করা হয়েছে" +"Change style" = "স্টাইল পরিবর্তন করুন" +"Change to" = "এতে পরিবর্তন করুন" +"Change to needs a layer inside a variant" = "পরিবর্তন করার জন্য ভ্যারিয়েন্টের ভেতরে একটি লেয়ার প্রয়োজন" +"Changed" = "পরিবর্তিত" +"Changed on both sides: keep one" = "উভয় দিকেই পরিবর্তিত হয়েছে: একটি রাখুন" +"Changes on main" = "প্রধান (main)-এ পরিবর্তন" +"Changes on the branch" = "ব্রাঞ্চে পরিবর্তন" +"Changes unknown" = "পরিবর্তন অজ্ঞাত" +"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "এই কম্পোনেন্টটিকে কোডের সাথে যুক্ত করতে উপরে React, Leptos, SwiftUI বা Compose বেছে নিন।" +"Choose another profile to change it." = "পরিবর্তন করতে অন্য একটি প্রোফাইল বেছে নিন।" +"Choose collection" = "সংগ্রহ (collection) বেছে নিন" +"Choose image" = "ছবি বেছে নিন" +"Choose mode" = "মোড বেছে নিন" +"Choose variable" = "ভেরিয়েবল বেছে নিন" +"Circle" = "বৃত্ত" +"Clear recent" = "সাম্প্রতিকগুলো মুছে ফেলুন" +"Click + to replace mixed content" = "মিশ্র কন্টেন্ট প্রতিস্থাপন করতে + এ ক্লিক করুন" +"Click anywhere on the canvas to leave one." = "একটি রাখতে ক্যানভাসের যেকোনো জায়গায় ক্লিক করুন।" +"Click or drag to merge · {alt} removes" = "মার্জ করতে ক্লিক বা ড্র্যাগ করুন · {alt} সরায়" +"Click or drag to remove · {alt} merges" = "সরাতে ক্লিক বা ড্র্যাগ করুন · {alt} মার্জ করে" +"Click to record a key" = "একটি কি রেকর্ড করতে ক্লিক করুন" +"Clip content" = "ক্লিপ কন্টেন্ট" +"Clip to shape" = "শেপে ক্লিপ করুন" +"Close" = "বন্ধ করুন" +"Close overlay" = "ওভারলে বন্ধ করুন" +"Close preview" = "প্রিভিউ বন্ধ করুন" +"Close tab" = "ট্যাব বন্ধ করুন" +"Close version history" = "সংস্করণ ইতিহাস বন্ধ করুন" +"Close when clicking outside" = "বাইরে ক্লিক করলে বন্ধ করুন" +"Close without saving" = "সেভ না করেই বন্ধ করুন" +"Code" = "কোড" +"Code syntax" = "কোড সিনট্যাক্স" +"Collapse" = "সংকুচিত করুন" +"Collapse panel" = "প্যানেল সংকুচিত করুন" +"Collection" = "সংগ্রহ" +"Collections" = "সংগ্রহসমূহ" +"Color" = "রঙ" +"Color burn" = "কালার বার্ন" +"Color dodge" = "কালার ডজ" +"Color format" = "কালার ফরম্যাট" +"Color profile" = "কালার প্রোফাইল" +"Color styles" = "কালার স্টাইল" +"Color values in this file are in this color space." = "এই ফাইলের রঙের মানগুলো এই কালার স্পেসে রয়েছে।" +"Color variables" = "কালার ভেরিয়েবল" +"Colour" = "রঙ" +"Column" = "কলাম" +"Column gap" = "কলাম গ্যাপ" +"Column sizes" = "কলামের আকার" +"Column span" = "কলাম স্প্যান" +"Columns" = "কলামসমূহ" +"Columns {count}" = "কলাম {count}" +"Comment" = "মন্তব্য" +"Comments" = "মন্তব্যসমূহ" +"Commits" = "কমিটসমূহ" +"Company" = "কোম্পানি" +"Compare changes" = "পরিবর্তন তুলনা করুন" +"Compatibility notes" = "সঙ্গতি নোট (Compatibility notes)" +"Completed" = "সম্পন্ন" +"Component" = "কম্পোনেন্ট" +"Component name" = "কম্পোনেন্টের নাম" +"Component property" = "কম্পোনেন্ট প্রপার্টি" +"Component set" = "কম্পোনেন্ট সেট" +"Component sets" = "কম্পোনেন্ট সেটসমূহ" +"Components" = "কম্পোনেন্টস" +"Conditional" = "শর্তসাপেক্ষ" +"Confetti" = "কনফেটি" +"Conflicts" = "দ্বন্দ্ব (Conflicts)" +"Connect to code" = "কোডের সাথে সংযুক্ত করুন" +"Connected" = "সংযুক্ত" +"Connected to code: {from}" = "কোডের সাথে সংযুক্ত: {from}" +"Connection lost, reconnecting (attempt {attempt})…" = "সংযোগ বিচ্ছিন্ন হয়েছে, পুনরায় সংযোগ স্থাপন করা হচ্ছে (চেষ্টা {attempt})..." +"Constrain proportions" = "অনুপাত ঠিক রাখুন" +"Constraints" = "সীমাবদ্ধতা (Constraints)" +"Content" = "কন্টেন্ট" +"Contents hidden" = "কন্টেন্ট লুকানো আছে" +"Contents only" = "শুধু কন্টেন্ট" +"Contextual alternates" = "প্রসঙ্গভিত্তিক বিকল্প" +"Contrast" = "কনট্রাস্ট (Contrast)" +"Contributors" = "অবদানকারীগণ" +"Contributors: {people} · Commits: {commits}" = "অবদানকারী: {people} · কমিট: {commits}" +"Controller" = "কন্ট্রোলার" +"Cool" = "কুল" +"Copied as CSS" = "CSS হিসেবে কপি করা হয়েছে" +"Copied as PNG" = "PNG হিসেবে কপি করা হয়েছে" +"Copied as SVG" = "SVG হিসেবে কপি করা হয়েছে" +"Copied as text" = "টেক্সট হিসেবে কপি করা হয়েছে" +"Copied {value}" = "{value} কপি করা হয়েছে" +"Copy" = "কপি করুন" +"Copy as" = "যেভাবে কপি করবেন" +"Copy as CSS" = "CSS হিসেবে কপি করুন" +"Copy as PNG" = "PNG হিসেবে কপি করুন" +"Copy as SVG" = "SVG হিসেবে কপি করুন" +"Copy code" = "কোড কপি করুন" +"Copy link" = "লিংক কপি করুন" +"Copy the {label}" = "{label} কপি করুন" +"Copy {value}" = "{value} কপি করুন" +"Corner radius" = "কোণার ব্যাসার্ধ (Corner radius)" +"Corner smoothing" = "কোণা মসৃণকরণ" +"Couldn't download the variables" = "ভেরিয়েবলগুলো ডাউনলোড করা যায়নি" +"Couldn't reach {server}: showing what this computer remembers." = "{server}-এ পৌঁছানো যায়নি: এই কম্পিউটার যা মনে রেখেছে তা দেখানো হচ্ছে।" +"Couldn't save automatically: {error}" = "স্বয়ংক্রিয়ভাবে সেভ করা যায়নি: {error}" +"Count" = "গণনা" +"Create" = "তৈরি করুন" +"Create a variable collection first" = "প্রথমে একটি ভেরিয়েবল সংগ্রহ তৈরি করুন" +"Create a variable first" = "প্রথমে একটি ভেরিয়েবল তৈরি করুন" +"Create alias" = "অ্যালিয়াস তৈরি করুন" +"Create branch" = "ব্রাঞ্চ তৈরি করুন" +"Create collection" = "সংগ্রহ তৈরি করুন" +"Create component" = "কম্পোনেন্ট তৈরি করুন" +"Create component property" = "কম্পোনেন্ট প্রপার্টি তৈরি করুন" +"Create components, styles or variables to publish them as a library." = "লাইব্রেরি হিসেবে প্রকাশ করতে কম্পোনেন্ট, স্টাইল বা ভেরিয়েবল তৈরি করুন।" +"Create property…" = "প্রপার্টি তৈরি করুন..." +"Create style" = "স্টাইল তৈরি করুন" +"Create style from the selected text" = "নির্বাচিত টেক্সট থেকে স্টাইল তৈরি করুন" +"Created" = "তৈরি করা হয়েছে" +"Crop" = "ক্রপ" +"Crop marks" = "ক্রপ মার্ক" +"Ctrl+arrows" = "Ctrl+অ্যারো" +"Ctrl+click" = "Ctrl+ক্লিক" +"Ctrl+scroll" = "Ctrl+স্ক্রল" +"Current name" = "বর্তমান নাম" +"Current version" = "বর্তমান সংস্করণ" +"Cursor chat" = "কার্সার চ্যাট" +"Custom" = "কাস্টম" +"Custom size" = "কাস্টম আকার" +"Cut" = "কাট করুন" +"Dark" = "ডার্ক" +"Darken" = "ডার্কেন" +"Dash" = "ড্যাশ" +"Dashboard" = "ড্যাশবোর্ড" +"Date created" = "তৈরির তারিখ" +"Dec" = "ডিসেম্বর" +"Decoration style" = "সজ্জা স্টাইল (Decoration style)" +"Decoration" = "সজ্জা" +"Deep select" = "ডিপ সিলেক্ট" +"Default" = "ডিফল্ট" +"Default content" = "ডিফল্ট কন্টেন্ট" +"Default value (the top-left variant)" = "ডিফল্ট মান (উপরের-বাম ভ্যারিয়েন্ট)" +"Delay" = "বিলম্ব" +"Delete" = "ডিলিট করুন" +"Delete category (its annotations stay)" = "বিভাগ ডিলিট করুন (এর অ্যানোটেশনগুলো থেকে যাবে)" +"Delete collection" = "সংগ্রহ ডিলিট করুন" +"Delete group" = "গ্রুপ ডিলিট করুন" +"Delete mode" = "মোড ডিলিট করুন" +"Delete page" = "পেজ ডিলিট করুন" +"Delete preset" = "প্রিসেট ডিলিট করুন" +"Delete property" = "প্রপার্টি ডিলিট করুন" +"Delete the style (layers keep their values)" = "স্টাইল ডিলিট করুন (লেয়ারগুলো তাদের মান ধরে রাখবে)" +"Delete thread" = "থ্রেড ডিলিট করুন" +"Delete variable" = "ভেরিয়েবল ডিলিট করুন" +"Delete “{name}”? This can't be undone." = "“{name}” ডিলিট করবেন? এটি আর ফেরানো যাবে না।" +"Density" = "ঘনত্ব" +"Depth" = "গভীরতা" +"Describe how and when to use this component" = "এই কম্পোনেন্টটি কীভাবে এবং কখন ব্যবহার করতে হবে তা বর্ণনা করুন" +"Describe what changed" = "কী পরিবর্তিত হয়েছে তা বর্ণনা করুন" +"Description" = "বিবরণ" +"Description (optional)" = "বিবরণ (ঐচ্ছিক)" +"Design" = "ডিজাইন" +"Design system starter" = "ডিজাইন সিস্টেম স্টার্টার" +"Desktop" = "ডেস্কটপ" +"Destination" = "গন্তব্য" +"Detach alias" = "অ্যালিয়াস বিচ্ছিন্ন করুন" +"Detach instance" = "ইনস্ট্যান্স বিচ্ছিন্ন করুন" +"Detach property" = "প্রপার্টি বিচ্ছিন্ন করুন" +"Detach style" = "স্টাইল বিচ্ছিন্ন করুন" +"Detach styles" = "স্টাইলগুলো বিচ্ছিন্ন করুন" +"Detach variable" = "ভেরিয়েবল বিচ্ছিন্ন করুন" +"Details" = "বিস্তারিত" +"Dev Mode" = "ডেভ মোড" +"Dev resources" = "ডেভ রিসোর্স" +"Development" = "ডেভেলপমেন্ট" +"Device" = "ডিভাইস" +"Device at 100%" = "১০০%-এ ডিভাইস" +"Diamond" = "ডায়মন্ড" +"Difference" = "পার্থক্য" +"Dimensions" = "মাত্রা (Dimensions)" +"Direction" = "দিক" +"Disconnect from code" = "কোড থেকে সংযোগ বিচ্ছিন্ন করুন" +"Dispersion" = "বিচ্ছুরণ" +"Display name" = "প্রদর্শিত নাম" +"Dissolve" = "ডিসলভ" +"Distribute and tidy" = "বণ্টন ও পরিপাটি করুন" +"Distribute horizontal spacing" = "অনুভূমিক ব্যবধান বিতরণ করুন" +"Distribute vertical spacing" = "উল্লম্ব ব্যবধান বিতরণ করুন" +"Divider" = "ডিভাইডার" +"Don't truncate" = "ট্রাঙ্কেট করবেন না" +"Done" = "সম্পন্ন" +"Done editing" = "সম্পাদনা শেষ" +"Dots" = "বিন্দুসমূহ" +"Dotted" = "ডটেড" +"Down" = "নিচে" +"Drafts" = "ড্রাফট" +"Drag the stroke to add a width point" = "প্রস্থ বিন্দু যোগ করতে স্ট্রোকে ড্র্যাগ করুন" +"Draw" = "আঁকুন" +"Draw a frame" = "একটি ফ্রেম আঁকুন" +"Drawing tools" = "অঙ্কন টুলস" +"Drop a .figcraft, .fig, .json, .svg or image file." = "একটি .figcraft, .fig, .json, .svg বা ছবির ফাইল ড্রপ করুন।" +"Drop to import" = "ইম্পোর্ট করতে ড্রপ করুন" +"Drop to open" = "খুলতে ড্রপ করুন" +"Drop shadow" = "ড্রপ শ্যাডো" +"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "ডুয়াল-লাইসেন্সকৃত MIT বা Apache-2.0। ArtCraft টিম এবং কমিউনিটি দ্বারা তৈরি।" +"Duo" = "ডুও" +"Duplicate" = "প্রতিলিপি তৈরি করুন (Duplicate)" +"Duplicate by dragging" = "ড্র্যাগ করে ডুপ্লিকেট করুন" +"Duplicate mode" = "মোড ডুপ্লিকেট করুন" +"Duplicate page" = "পেজ ডুপ্লিকেট করুন" +"Edit" = "এডিট" +"Edit as an expression" = "এক্সপ্রেশন হিসেবে এডিট করুন" +"Edit colour" = "রঙ এডিট করুন" +"Edit comment" = "মন্তব্য এডিট করুন" +"Edit dev resource" = "ডেভ রিসোর্স এডিট করুন" +"Edit link" = "লিংক এডিট করুন" +"Edit link copied" = "এডিট লিংক কপি করা হয়েছে" +"Edit style" = "স্টাইল এডিট করুন" +"Edit variable" = "ভেরিয়েবল এডিট করুন" +"Edit with the builder" = "বিল্ডার দিয়ে এডিট করুন" +"Edited {when}" = "{when} এডিট করা হয়েছে" +"Effect settings" = "ইফেক্ট সেটিংস" +"Effect styles" = "ইফেক্ট স্টাইল" +"Effects" = "ইফেক্টসমূহ" +"Ellipse" = "উপবৃত্ত" +"Ellipses" = "উপবৃত্তসমূহ" +"Else" = "অন্যথায় (Else)" +"Else if" = "অন্যথায় যদি (Else if)" +"Embedded profile" = "এমবেডেড প্রোফাইল" +"Empty" = "খালি" +"End" = "শেষ" +"End point" = "শেষ বিন্দু" +"Ends the session; everyone keeps their copy" = "সেশন শেষ করে; সবাই তাদের কপি নিজের কাছে রাখে" +"Enter" = "এন্টার" +"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "একটি figcraft-সার্ভারের ঠিকানা দিন এবং এটি যে প্লাগইনগুলো অফার করে তা দেখতে ব্রাউজ টিপুন।" +"Enter to save · Esc to cancel" = "সেভ করতে Enter · বাতিল করতে Esc" +"Eraser" = "ইরেজার" +"Esc" = "Esc" +"Essential" = "অত্যাবশ্যকীয়" +"Every conflict has a choice." = "প্রতিটি দ্বন্দ্বেরই একটি সমাধান বা পছন্দ থাকে।" +"Every top-level frame already starts a flow" = "প্রতিটি শীর্ষ-স্তরের ফ্রেম ইতিমধ্যে একটি ফ্লো শুরু করে" +"Exact opacity (4 then 5: 45%)" = "সঠিক অস্বচ্ছতা (4 তারপর 5: 45%)" +"Exclude" = "বাদ দিন" +"Excluded" = "বাদ দেওয়া হয়েছে" +"Excluded from layout" = "লেআউট থেকে বাদ দেওয়া হয়েছে" +"Excludes" = "বাদ দেয়" +"Exclusion" = "এক্সক্লুশন" +"Exit focus view (Esc)" = "ফোকাস ভিউ থেকে বের হন (Esc)" +"Expand" = "প্রসারিত করুন" +"Export" = "এক্সপোর্ট" +"Export all" = "সব এক্সপোর্ট করুন" +"Export frames and layers as PNG, JPG, SVG or PDF" = "ফ্রেম এবং লেয়ারগুলোকে PNG, JPG, SVG বা PDF হিসেবে এক্সপোর্ট করুন" +"Export page as PNG" = "পেজ PNG হিসেবে এক্সপোর্ট করুন" +"Export settings" = "এক্সপোর্ট সেটিংস" +"Export variables" = "ভেরিয়েবল এক্সপোর্ট করুন" +"Export {name}" = "{name} এক্সপোর্ট করুন" +"Export {n} layers" = { one = "{n}টি লেয়ার এক্সপোর্ট করুন", other = "{n}টি লেয়ার এক্সপোর্ট করুন" } +"Exported {file}" = "{file} এক্সপোর্ট করা হয়েছে" +"Exported {name}" = "{name} এক্সপোর্ট করা হয়েছে" +"Exported {name}; {n} kinds of content were left out" = { one = "{name} এক্সপোর্ট করা হয়েছে; {n} ধরনের কন্টেন্ট বাদ দেওয়া হয়েছে", other = "{name} এক্সপোর্ট করা হয়েছে; {n} ধরনের কন্টেন্ট বাদ দেওয়া হয়েছে" } +"Exported {n} files" = { one = "{n}টি ফাইল এক্সপোর্ট করা হয়েছে", other = "{n}টি ফাইল এক্সপোর্ট করা হয়েছে" } +"Export…" = "এক্সপোর্ট..." +"Expose properties to instances" = "ইনস্ট্যান্সে প্রপার্টি উন্মুক্ত করুন" +"Exposure" = "এক্সপোজার" +"Expression" = "এক্সপ্রেশন" +"Extend collection" = "সংগ্রহ প্রসারিত করুন" +"Extends \"{name}\": overridden values are marked" = "\"{name}\"-কে প্রসারিত করে: ওভাররাইড করা মানগুলো চিহ্নিত করা আছে" +"Eye" = "চোখ" +"F" = "F" +"Fade" = "ফেড" +"False" = "মিথ্যা (False)" +"Feb" = "ফেব্রুয়ারি" +"Fetching from the server…" = "সার্ভার থেকে ডেটা আনা হচ্ছে..." +"FigCraft couldn't open its window: {error}" = "FigCraft এর উইন্ডো খুলতে পারেনি: {error}" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft এখনো এই ফাইলগুলোর সব ফিচার সমর্থন করে না। নোটগুলোতে ইম্পোর্ট করা কপিতে যা বাদ দেওয়া হয়েছে বা পরিবর্তন করা হয়েছে তার তালিকা রয়েছে; মূল ফাইলটি অপরিবর্তিত রয়েছে।" +"File" = "ফাইল" +"File name" = "ফাইলের নাম" +"File not found" = "ফাইল পাওয়া যায়নি" +"File published" = "ফাইল প্রকাশিত হয়েছে" +"Files aren't end-to-end encrypted: the server's operator can read them." = "ফাইলগুলো এন্ড-টু-এন্ড এনক্রিপ্টেড নয়: সার্ভারের অপারেটর এগুলো পড়তে পারেন।" +"Files nobody opens for {n} days are deleted." = { one = "যে ফাইল {n} দিন কেউ খোলে না তা ডিলিট করা হয়।", other = "যে ফাইলগুলো {n} দিন কেউ খোলে না সেগুলো ডিলিট করা হয়।" } +"Fill" = "ফিল" +"Fill (fr)" = "ফিল (fr)" +"Fill container" = "কন্টেইনার পূর্ণ করুন" +"Fill window" = "উইন্ডো পূর্ণ করুন" +"Fills" = "ফিলসমূহ" +"Fill|sizing" = "ফিল" +"Filter and sort" = "ফিল্টার ও সাজান" +"Find" = "খুঁজুন" +"Find and replace" = "খুঁজুন ও প্রতিস্থাপন করুন" +"Find layers" = "লেয়ার খুঁজুন" +"First" = "প্রথম" +"First commit" = "প্রথম কমিট" +"First on top" = "প্রথমে উপরে" +"Fit" = "ফিট" +"Fit device to window" = "উইন্ডোতে ডিভাইস ফিট করুন" +"Fit to contents" = "কন্টেন্ট অনুযায়ী ফিট করুন" +"Fit to window" = "উইন্ডো অনুযায়ী ফিট করুন" +"Fit width" = "প্রস্থ ফিট করুন" +"Fixed" = "নির্দিষ্ট (Fixed)" +"Fixed gap" = "নির্দিষ্ট গ্যাপ" +"Fixed size" = "নির্দিষ্ট আকার" +"Flatten" = "ফ্ল্যাট করুন" +"Flip" = "উল্টান (Flip)" +"Flip horizontal" = "অনুভূমিকভাবে উল্টান" +"Flip text to the other side" = "টেক্সট অন্য পাশে উল্টান" +"Flip vertical" = "উল্লম্বভাবে উল্টান" +"Flip width profile" = "প্রস্থ প্রোফাইল উল্টান" +"Flow starting point" = "ফ্লো শুরুর বিন্দু" +"Flow starting points" = "ফ্লো শুরুর বিন্দুসমূহ" +"Flows" = "ফ্লোসমূহ" +"Focus view" = "ফোকাস ভিউ" +"Fold autosaves" = "স্বয়ংক্রিয় সেভ ভাঁজ করুন" +"Folder: click to collapse" = "ফোল্ডার: সংকুচিত করতে ক্লিক করুন" +"Folder: click to expand" = "ফোল্ডার: প্রসারিত করতে ক্লিক করুন" +"Follow" = "অনুসরণ করুন" +"Follow {name}" = "{name}-কে অনুসরণ করুন" +"Following" = "অনুসরণ করা হচ্ছে" +"Following {name} · {key} to stop" = "{name}-কে অনুসরণ করা হচ্ছে · থামতে {key} টিপুন" +"Font" = "ফন্ট" +"Font not installed" = "ফন্ট ইনস্টল করা নেই" +"Font size" = "ফন্টের আকার" +"Font style" = "ফন্ট স্টাইল" +"Forget on this computer" = "এই কম্পিউটার থেকে মুছে ফেলুন" +"Four screens of a trail app with components and a prototype flow" = "কম্পোনেন্ট এবং প্রোটোটাইপ ফ্লো সহ ট্রেইল অ্যাপের চারটি স্ক্রিন" +"Fractions" = "ভগ্নাংশ" +"Frame" = "ফ্রেম" +"Frame selection" = "ফ্রেম নির্বাচন" +"Frame tools" = "ফ্রেম টুলস" +"Frames" = "ফ্রেমসমূহ" +"From the library \"{name}\": read-only" = "\"{name}\" লাইব্রেরি থেকে: শুধুমাত্র পড়ার উপযোগী (read-only)" +"From {name}" = "{name} থেকে" +"From {server}" = "{server} থেকে" +"Frost" = "ফ্রস্ট" +"Gap" = "গ্যাপ" +"Gap between columns" = "কলামের মধ্যবর্তী গ্যাপ" +"Gap between items" = "আইটেমের মধ্যবর্তী গ্যাপ" +"Gap between rows" = "সারির মধ্যবর্তী গ্যাপ" +"Getting started" = "শুরু করা" +"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "পাবলিকের জন্য উন্মুক্ত কোনো সার্ভারের ঠিকানা দিন, অথবা কী সহ সার্ভারের ঠিকানা দিন (`figcraft-server key` এটি প্রিন্ট করে)।" +"Glass" = "গ্লাস" +"Go to main component" = "প্রধান কম্পোনেন্টে যান" +"Go to {name}" = "{name}-এ যান" +"Grab bag" = "গ্র্যাব ব্যাগ" +"Gradient" = "গ্রেডিয়েন্ট" +"Gray" = "ধূসর" +"Green" = "সবুজ" +"Greyscale components and two example screens to sketch a flow with" = "একটি ফ্লো স্কেচ করার জন্য গ্রেস্কেল কম্পোনেন্ট এবং দুটি উদাহরণ স্ক্রিন" +"Grid" = "গ্রিড" +"Grid ({size})" = "গ্রিড ({size})" +"Grid styles" = "গ্রিড স্টাইল" +"Grid {size}" = "গ্রিড {size}" +"Group" = "গ্রুপ" +"Group selection" = "গ্রুপ নির্বাচন" +"Groups" = "গ্রুপসমূহ" +"Gutter" = "গাটার" +"Hand tool" = "হ্যান্ড টুল" +"Hanging list" = "হ্যাংগিং তালিকা" +"Hanging punctuation" = "হ্যাংগিং বিরামচিহ্ন" +"Hard light" = "হার্ড লাইট" +"Height" = "উচ্চতা" +"Help" = "সহায়তা" +"Help and resources" = "সহায়তা ও রিসোর্স" +"Help page" = "সহায়তা পেজ" +"Here: {names}" = "এখানে: {names}" +"Hidden" = "লুকানো" +"Hide contents" = "কন্টেন্ট লুকান" +"Hide from publishing" = "প্রকাশ করা থেকে লুকান" +"Hide section contents" = "সেকশনের কন্টেন্ট লুকান" +"Hide these tips (Getting started in the Help menu shows them again)" = "এই টিপসগুলো লুকান (সহায়তা মেনুর 'শুরু করা' অপশনটি এগুলো আবার দেখাবে)" +"Hide when publishing" = "প্রকাশ করার সময় লুকান" +"Highlight layers on hover" = "হোভারে লেয়ার হাইলাইট করুন" +"Highlights" = "হাইলাইটস" +"Hold Alt" = "Alt চেপে ধরে রাখুন" +"Hold Alt+Z" = "Alt+Z চেপে ধরে রাখুন" +"Hold Ctrl" = "Ctrl চেপে ধরে রাখুন" +"Hold Z" = "Z চেপে ধরে রাখুন" +"Hold ⌃ or ⌘" = "⌃ বা ⌘ চেপে ধরে রাখুন" +"Hold ⌥" = "⌥ চেপে ধরে রাখুন" +"Hold ⌥Z" = "⌥Z চেপে ধরে রাখুন" +"Home" = "হোম" +"Horizontal" = "অনুভূমিক" +"Horizontal align" = "অনুভূমিক সারিবদ্ধকরণ" +"Horizontal hex" = "অনুভূমিক হেক্স" +"Horizontal padding" = "অনুভূমিক প্যাডিং" +"Horizontal spacing" = "অনুভূমিক ব্যবধান" +"Hosted by {operator}" = "হোস্ট করেছেন: {operator}" +"Hover a layer to see its properties" = "প্রপার্টি দেখতে কোনো লেয়ারের উপর হোভার করুন" +"Hover other layers to measure the distance" = "দূরত্ব মাপতে অন্য লেয়ারগুলোর উপর হোভার করুন" +"Hue" = "হিউ (Hue)" +"Hug" = "হাগ" +"Hug contents" = "কন্টেন্ট অনুযায়ী হাগ করুন" +"Icon components on a 24 px grid with keylines and a size preview" = "কিলিন এবং সাইজ প্রিভিউ সহ ২৪ পিক্সেল গ্রিডে আইকন কম্পোনেন্ট" +"Icon set starter" = "আইকন সেট স্টার্টার" +"If it keeps happening, please report it at {link} with this message." = "যদি এটি বারবার ঘটতে থাকে, তবে অনুগ্রহ করে এই বার্তা সহ {link}-এ রিপোর্ট করুন।" +"If" = "যদি (If)" +"If {condition}" = "যদি {condition}" +"Ignore auto layout (absolute position)" = "অটো লেআউট উপেক্ষা করুন (অ্যাবসোলিউট পজিশন)" +"Image" = "ছবি" +"Images" = "ছবিসমূহ" +"Import" = "ইম্পোর্ট" +"Import path" = "ইম্পোর্ট পাথ" +"Import plugin from manifest" = "মেনিফেস্ট থেকে প্লাগইন ইম্পোর্ট করুন" +"Import plugin from manifest…" = "মেনিফেস্ট থেকে প্লাগইন ইম্পোর্ট করুন..." +"Import report" = "ইম্পোর্ট রিপোর্ট" +"Import variables" = "ভেরিয়েবল ইম্পোর্ট করুন" +"Imported document" = "ইম্পোর্ট করা ডকুমেন্ট" +"Imported {name}" = "{name} ইম্পোর্ট করা হয়েছে" +"Imported {n} new variables, updated {updated}" = { one = "{n}টি নতুন ভেরিয়েবল ইম্পোর্ট করা হয়েছে, {updated} আপডেট করা হয়েছে", other = "{n}টি নতুন ভেরিয়েবল ইম্পোর্ট করা হয়েছে, {updated} আপডেট করা হয়েছে" } +"Import…" = "ইম্পোর্ট..." +"In an instance, this comes from the main component" = "একটি ইনস্ট্যান্সে, এটি প্রধান কম্পোনেন্ট থেকে আসে" +"In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)." = "ব্রাউজারে আপনি ডেস্কটপ অ্যাপের হোস্ট করা সেশন এবং FigCraft সার্ভারের ফাইলে যুক্ত হতে পারবেন। https-এর মাধ্যমে খোলা কোনো পেজ শুধুমাত্র wss:// লিঙ্কের (TLS-এর পেছনের সার্ভার) মাধ্যমে অন্যান্য কম্পিউটারে পৌঁছায়।" +"Include bounding box" = "বাউন্ডিং বক্স অন্তর্ভুক্ত করুন" +"Include id attribute" = "id অ্যাট্রিবিউট অন্তর্ভুক্ত করুন" +"Included" = "অন্তর্ভুক্ত" +"Included in layout" = "লেআউটে অন্তর্ভুক্ত" +"Individual corners" = "পৃথক কোণা" +"Individual strokes" = "পৃথক স্ট্রোক" +"Ink" = "কালি (Ink)" +"Inner shadow" = "ইনার শ্যাডো" +"Inside" = "ভিতরে" +"Inspect" = "ইন্সপেক্ট" +"Inspect the layer under the pointer: size, colours, typography" = "পয়েন্টারের নিচের লেয়ারটি ইন্সপেক্ট করুন: আকার, রঙ, টাইপোগ্রাফি" +"Install plugin" = "প্লাগইন ইনস্টল করুন" +"Install" = "ইনস্টল করুন" +"Installed {name}" = "{name} ইনস্টল করা হয়েছে" +"Installed" = "ইনস্টল করা হয়েছে" +"Installing…" = "ইনস্টল হচ্ছে..." +"Instance" = "ইনস্ট্যান্স" +"Instance properties" = "ইনস্ট্যান্স প্রপার্টি" +"Instance swap" = "ইনস্ট্যান্স সোয়াপ" +"Instance swap (no other component)" = "ইনস্ট্যান্স সোয়াপ (অন্য কোনো কম্পোনেন্ট নেই)" +"Instances" = "ইনস্ট্যান্সসমূহ" +"Instant" = "তাৎক্ষণিক" +"Intensity" = "তীব্রতা" +"Interaction" = "ইন্টারঅ্যাকশন" +"Interactions" = "ইন্টারঅ্যাকশনসমূহ" +"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "ইন্টারফেস ডিজাইন এবং প্রোটোটাইপিং। Rust-এ শূন্য থেকে তৈরি একটি ফ্রি, ওপেন-সোর্স, ক্লিন-রুম ডিজাইন টুল, যা ArtCraft Crafting Apps-এর অংশ।" +"Intersect" = "ছেদ (Intersect)" +"Intersects" = "ছেদ করে" +"Invalid color" = "অবৈধ রঙ" +"Invite link" = "আমন্ত্রণ লিংক" +"Invite link copied" = "আমন্ত্রণ লিংক কপি করা হয়েছে" +"It asks permission to:" = "এটি অনুমতি চায়:" +"Jan" = "জানুয়ারি" +"Join" = "যুক্ত হোন" +"Join code copied" = "যুক্তি কোড (Join code) কপি করা হয়েছে" +"Join code or invite link" = "যুক্ত হওয়ার কোড বা আমন্ত্রণ লিংক" +"Join live session" = "লাইভ সেশনে যুক্ত হোন" +"Join us on Discord" = "ডিসকর্ডে আমাদের সাথে যুক্ত হোন" +"Join with a code" = "কোড দিয়ে যুক্ত হোন" +"Joined" = "যুক্ত হয়েছেন" +"Joined {file}" = "{file}-এ যুক্ত হয়েছেন" +"Joining…" = "যুক্ত হওয়া হচ্ছে..." +"Join|stroke" = "জয়েন" +"Jul" = "জুলাই" +"Jun" = "জুন" +"Just now" = "এইমাত্র" +"Justify" = "উভয়মুখী সারিবদ্ধকরণ (Justify)" +"Keep appearance" = "চেহারা অপরিবর্তিত রাখুন" +"Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "চেহারা অপরিবর্তিত রাখলে প্রতিটি রঙের মান {profile}-এ রূপান্তরিত হয় যাতে রঙগুলো একই দেখায়। মান অপরিবর্তিত রাখলে সংখ্যাগুলো যেমন আছে তেমনই থাকে, ফলে রঙ পরিবর্তিত হয়।" +"Keep tool selected after use" = "ব্যবহারের পরেও টুল নির্বাচিত রাখুন" +"Keep values" = "মান অপরিবর্তিত রাখুন" +"Key/Gamepad" = "কি/গেমপ্যাড" +"Key/Gamepad {key}" = "কি/গেমপ্যাড {key}" +"Keyboard shortcuts" = "কিবোর্ড শর্টকাট" +"Kits" = "কিটস" +"Landing page" = "ল্যান্ডিং পেজ" +"Language" = "ভাষা" +"Lasso" = "ল্যাসো" +"Last" = "শেষ" +"Last change: {note}" = "শেষ পরিবর্তন: {note}" +"Last commit" = "শেষ কমিট" +"Last modified" = "সর্বশেষ সংশোধিত" +"Last on top" = "প্রথমে নিচে" +"Layer blur" = "লেয়ার ব্লার" +"Layer name" = "লেয়ারের নাম" +"Layers" = "লেয়ারসমূহ" +"Layers in an instance take their position from the main component" = "ইনস্ট্যান্সের লেয়ারগুলো তাদের অবস্থান প্রধান কম্পোনেন্ট থেকে নেয়" +"Layout" = "লেআউট" +"Layout grid" = "লেআউট গ্রিড" +"Layout grid settings" = "লেআউট গ্রিড সেটিংস" +"Layout grids" = "লেআউট গ্রিডসমূহ" +"Leave session" = "সেশন ত্যাগ করুন" +"Leaves" = "ত্যাগ করে" +"Left" = "বামে" +"Left & right" = "বাম ও ডান" +"Left side" = "বাম পাশ" +"Left side width" = "বাম পাশের প্রস্থ" +"Letter case" = "লেটার কেস" +"Letter spacing" = "অক্ষরের ব্যবধান" +"Libraries" = "লাইব্রেরিসমূহ" +"Libraries in this file" = "এই ফাইলের লাইব্রেরিগুলো" +"Library updates available" = "লাইব্রেরি আপডেট উপলব্ধ আছে" +"Library: click to collapse" = "লাইব্রেরি: সংকুচিত করতে ক্লিক করুন" +"Library: click to expand" = "লাইব্রেরি: প্রসারিত করতে ক্লিক করুন" +"Library: its variables are read-only here; edit them in the library file and publish it" = "লাইব্রেরি: এর ভেরিয়েবলগুলো এখানে শুধুমাত্র পড়ার উপযোগী; লাইব্রেরি ফাইলে গিয়ে এগুলো এডিট করুন এবং প্রকাশ করুন" +"Light" = "লাইট" +"Lighten" = "লাইটেন" +"Light|glass" = "লাইট" +"Line" = "লাইন" +"Line arrow" = "লাইন তীর" +"Line delta" = "লাইন ডেল্টা" +"Line height" = "লাইনের উচ্চতা" +"Linear" = "লিনিয়ার" +"Lines" = "লাইনসমূহ" +"Lines +/−" = "লাইন +/−" +"Lines added" = "লাইন যোগ করা হয়েছে" +"Lines deleted" = "লাইন ডিলিট করা হয়েছে" +"Link" = "লিংক" +"Link copied" = "লিংক কপি করা হয়েছে" +"Link docs, code or tickets for developers." = "ডেভেলপারদের জন্য ডকুমেন্ট, কোড বা টিকিট লিংক করুন।" +"Link frames on the Prototype tab, then present the flow" = "প্রোটোটাইপ ট্যাবে ফ্রেমগুলো লিংক করুন, এরপর ফ্লো উপস্থাপন করুন" +"Link this component to its code component: instances then show your real snippet with their values." = "এই কম্পোনেন্টটিকে এর কোড কম্পোনেন্টের সাথে লিংক করুন: ইনস্ট্যান্সগুলো তখন তাদের মানসহ আপনার আসল স্নিপেট দেখাবে।" +"List spacing" = "তালিকায় ব্যবধান" +"List style" = "তালিকায় স্টাইল" +"Live session" = "লাইভ সেশন" +"Live · {people}" = "লাইভ · {people}" +"Local components" = "স্থানীয় কম্পোনেন্ট" +"Local network" = "স্থানীয় নেটওয়ার্ক" +"Local styles" = "স্থানীয় স্টাইল" +"Local variables" = "স্থানীয় ভেরিয়েবল" +"Lock/Unlock" = "লক/আনলক" +"Locked" = "লক করা" +"Lowercase" = "ছোট হাতের অক্ষর" +"Luminance" = "লুমিন্যান্স" +"Luminosity" = "উজ্জ্বলতা (Luminosity)" +"Main" = "প্রধান" +"Main component deleted" = "প্রধান কম্পোনেন্ট ডিলিট করা হয়েছে" +"Main menu" = "প্রধান মেনু" +"Make a component" = "একটি কম্পোনেন্ট তৈরি করুন" +"Make slot" = "স্লট তৈরি করুন" +"Mar" = "মার্চ" +"Margin" = "মার্জিন" +"Mark all as read" = "সব পঠিত হিসেবে চিহ্নিত করুন" +"Mark as resolved" = "সমাধানকৃত হিসেবে চিহ্নিত করুন" +"Mark as unread" = "অপঠিত হিসেবে চিহ্নিত করুন" +"Mark as unresolved" = "অমীমাংসিত হিসেবে চিহ্নিত করুন" +"Mask" = "মাস্ক" +"Mass · Stiffness · Damping" = "ভর · দৃঢ়তা · ড্যাম্পিং" +"Match (empty: the whole name)" = "মেলবন্ধন (খালি: সম্পূর্ণ নাম)" +"Max H" = "সর্বোচ্চ উচ্চতা" +"Max W" = "সর্বোচ্চ প্রস্থ" +"Max lines" = "সর্বোচ্চ লাইন" +"Maximize" = "বড় করুন" +"May" = "মে" +"Measure to the selection" = "নির্বাচন পর্যন্ত পরিমাপ করুন" +"Merge into main" = "প্রধান ফাইলের সাথে মার্জ করুন" +"Merge regions" = "অঞ্চলগুলো মার্জ করুন" +"Merge the branch into the main file (one undo step) and archive it" = "ব্রাঞ্চটিকে প্রধান ফাইলের সাথে মার্জ করুন (একটি আনডু ধাপ) এবং আর্কাইভ করুন" +"Merged" = "মার্জ করা হয়েছে" +"Merged PRs" = "মার্জ করা PR" +"Min H" = "সর্বনিম্ন উচ্চতা" +"Min W" = "সর্বনিম্ন প্রস্থ" +"Minimize" = "ছোট করুন" +"Mirror angle" = "মিরর কোণ" +"Mirror angle and length" = "মিরর কোণ ও দৈর্ঘ্য" +"Mirroring" = "মিররিং" +"Missing" = "অনুপস্থিত" +"Missing fonts" = "অনুপস্থিত ফন্ট" +"Missing layer" = "অনুপস্থিত লেয়ার" +"Missing style" = "অনুপস্থিত স্টাইল" +"Missing variable" = "অনুপস্থিত ভেরিয়েবল" +"Miter" = "মাইটার" +"Miter angle" = "মাইটার কোণ" +"Mixed" = "মিশ্রিত" +"Mixed fonts" = "মিশ্র ফন্ট" +"Mixed interactions: click + to replace them" = "মিশ্র ইন্টারঅ্যাকশন: প্রতিস্থাপন করতে + এ ক্লিক করুন" +"Mobile app" = "মোবাইল অ্যাপ" +"Mode" = "মোড" +"Model" = "মডেল" +"Models" = "মডেলসমূহ" +"Mono" = "মোনো" +"More" = "আরও" +"More instance actions" = "আরও ইনস্ট্যান্স অ্যাকশন" +"Mouse down" = "মাউস ডাউন" +"Mouse enter" = "মাউস এন্টার" +"Mouse leave" = "মাউস লিভ" +"Mouse up" = "মাউস আপ" +"Move along the toolbar or the layer rows" = "টুলবার বা লেয়ার সারির বরাবর সরান" +"Move" = "সরান" +"Move column left" = "কলাম বামে সরান" +"Move column right" = "কলাম ডানে সরান" +"Move down" = "নিচে সরান" +"Move in" = "ভিতরে সরান" +"Move out" = "বাইরে সরান" +"Move tools" = "সরানোর টুলস" +"Move up" = "উপরে সরান" +"Moved to {name}" = "{name}-এ সরানো হয়েছে" +"Multi" = "মাল্টি" +"Multiply" = "মাল্টিপ্লাই" +"Muted" = "মিউটেড" +"Name" = "নাম" +"Name (A–Z)" = "নাম (A–Z)" +"Named instance" = "নামকৃত ইনস্ট্যান্স" +"Navigate to" = "এতে যান" +"Nested instance" = "নেস্টেড ইনস্ট্যান্স" +"New design file" = "নতুন ডিজাইন ফাইল" +"Newest first" = "সবচেয়ে নতুন প্রথমে" +"Next or previous control in the focused region" = "ফোকাসড অঞ্চলের পরবর্তী বা পূর্ববর্তী কন্ট্রোল" +"Next frame" = "পরবর্তী ফ্রেম" +"Next ready for dev" = "ডেভের জন্য পরবর্তী প্রস্তুত" +"Next size option" = "পরবর্তী আকারের বিকল্প" +"No assets: no export settings, icons or images. Add export settings in the Design tab." = "কোনো অ্যাসেট নেই: কোনো এক্সপোর্ট সেটিংস, আইকন বা ছবি নেই। ডিজাইন ট্যাবে এক্সপোর্ট সেটিংস যোগ করুন।" +"No boolean variables fit here." = "এখানে কোনো বুলিয়ান ভেরিয়েবল মানানসই নয়।" +"No boolean variables yet. Create them in Local variables." = "এখনো কোনো বুলিয়ান ভেরিয়েবল নেই। স্থানীয় ভেরিয়েবলে (Local variables) এগুলো তৈরি করুন।" +"No branches. A branch is a copy of the file to try changes in, then review and merge." = "কোনো ব্রাঞ্চ নেই। ব্রাঞ্চ হলো পরিবর্তনগুলো পরীক্ষা করার জন্য ফাইলের একটি কপি, এরপর পর্যালোচনা করে মার্জ করা হয়।" +"No category" = "কোনো বিভাগ নেই" +"No changes" = "কোনো পরিবর্তন নেই" +"No color styles" = "কোনো কালার স্টাইল নেই" +"No colour variables fit here." = "এখানে কোনো রঙের ভেরিয়েবল মানানসই নয়।" +"No colour variables yet. Create them in Local variables." = "এখনো কোনো রঙের ভেরিয়েবল নেই। স্থানীয় ভেরিয়েবলে এগুলো তৈরি করুন।" +"No comments yet" = "এখনো কোনো মন্তব্য নেই" +"No compatibility warnings reported" = "কোনো সঙ্গতির সতর্কতা রিপোর্ট করা হয়নি" +"No components yet. Select layers and press {keys}." = "এখনো কোনো কম্পোনেন্ট নেই। লেয়ার নির্বাচন করুন এবং {keys} টিপুন।" +"No conflicts." = "কোনো দ্বন্দ্ব নেই।" +"No contributor data was built into this copy." = "এই কপিতে কোনো অবদানকারীর ডেটা যুক্ত করা হয়নি।" +"No device" = "কোনো ডিভাইস নেই" +"No fonts match" = "কোনো ফন্ট মেলে না" +"No files or templates match your search" = "আপনার অনুসন্ধানের সাথে কোনো ফাইল বা টেমপ্লেট মেলে না" +"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "এখানে কোনো যুক্ত হওয়ার কোড নেই (সার্ভারটি যুক্ত হওয়ার কোডের চেয়ে পুরনো, অথবা এই ঠিকানাকে কোডে রূপান্তর করা যায় না): পরিবর্তে লিংক শেয়ার করুন।" +"No layer changes to show." = "দেখানোর মতো কোনো লেয়ার পরিবর্তন নেই।" +"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "এখনো কোনো লাইব্রেরি নেই। লাইব্রেরি হিসেবে প্রকাশিত একটি .figcraft ফাইল যোগ করুন, অথবা টিম লাইব্রেরির লিংক পেস্ট করুন।" +"No list" = "কোনো তালিকা নেই" +"No matching comments" = "কোনো মিলছে এমন মন্তব্য নেই" +"No matching components" = "কোনো মিলছে এমন কম্পোনেন্ট নেই" +"No matching layers" = "কোনো মিলছে এমন লেয়ার নেই" +"No matching styles" = "কোনো মিলছে এমন স্টাইল নেই" +"No matching variables" = "কোনো মিলছে এমন ভেরিয়েবল নেই" +"No mirroring" = "কোনো মিররিং নেই" +"No model credits were built into this copy." = "এই কপিতে কোনো মডেল ক্রেডিট যুক্ত করা হয়নি।" +"No number variables fit here." = "এখানে কোনো নম্বরের ভেরিয়েবল মানানসই নয়।" +"No number variables yet. Create them in Local variables." = "এখনো কোনো নম্বরের ভেরিয়েবল নেই। স্থানীয় ভেরিয়েবলে এগুলো তৈরি করুন।" +"No other pages" = "অন্য কোনো পেজ নেই" +"No paints" = "কোনো পেইন্ট নেই" +"No plugins on {server}." = "{server}-এ কোনো প্লাগইন নেই।" +"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "এখনো কোনো প্লাগইন নেই। এর figcraft-plugin.json থেকে একটি ডেভেলপমেন্ট প্লাগইন ইম্পোর্ট করুন।" +"No preferred components" = "কোনো পছন্দের কম্পোনেন্ট নেই" +"No preset" = "কোনো প্রিসেট নেই" +"No saved versions yet. Save a version to mark a point in your work you can come back to." = "এখনো কোনো সংরক্ষিত সংস্করণ নেই। আপনার কাজের এমন একটি পয়েন্ট চিহ্নিত করতে একটি সংস্করণ সংরক্ষণ করুন যেখানে আপনি পরে ফিরে আসতে পারেন।" +"No scrolling" = "কোনো স্ক্রোলিং নেই" +"No shortcuts match" = "কোনো শর্টকাট মেলে না" +"No status" = "কোনো স্ট্যাটাস নেই" +"No string variables fit here." = "এখানে কোনো স্ট্রিং ভেরিয়েবল মানানসই নয়।" +"No string variables yet. Create them in Local variables." = "এখনো কোনো স্ট্রিং ভেরিয়েবল নেই। স্থানীয় ভেরিয়েবলে এগুলো তৈরি করুন।" +"No styles yet." = "এখনো কোনো স্টাইল নেই।" +"No styles yet. Click + to create one from the selection." = "এখনো কোনো স্টাইল নেই। নির্বাচন থেকে একটি তৈরি করতে + এ ক্লিক করুন।" +"No templates match your search" = "আপনার অনুসন্ধানের সাথে কোনো টেমপ্লেট মেলে না" +"No text styles yet. Click + to create one from the selected text." = "এখনো কোনো টেক্সট স্টাইল নেই। নির্বাচিত টেক্সট থেকে একটি তৈরি করতে + এ ক্লিক করুন।" +"No variables fit here." = "এখানে কোনো ভেরিয়েবল মানানসই নয়।" +"No variables yet. Create them in Local variables." = "এখনো কোনো ভেরিয়েবল নেই। স্থানীয় ভেরিয়েবলে এগুলো তৈরি করুন।" +"Nobody else is here yet" = "এখানে আর কেউ নেই" +"Noise" = "নয়েজ" +"None" = "কিছুই না" +"None." = "কিছুই না।" +"Normal" = "স্বাভাবিক" +"Not bound" = "আবদ্ধ নয়" +"Not in a live session" = "লাইভ সেশনে নেই" +"Not in this font" = "এই ফন্টে নেই" +"Not properties, kept as written: {names}" = "প্রপার্টি নয়, যেমনটি লেখা হয়েছে তেমনি রাখা হয়েছে: {names}" +"Not read yet" = "এখনো পড়া হয়নি" +"Not shared" = "শেয়ার করা হয়নি" +"Note {n}" = "নোট {n}" +"Nothing changed since this was marked ready for dev." = "এটি ডেভের জন্য প্রস্তুত হিসেবে চিহ্নিত করার পর থেকে কিছু পরিবর্তিত হয়নি।" +"Nothing here yet. Set a frame's status to list it." = "এখানে এখনো কিছু নেই। তালিকাভুক্ত করতে একটি ফ্রেমের স্ট্যাটাস সেট করুন।" +"Nothing online yet. Use Share on a file to put it online and it is listed here." = "অনলাইনে এখনো কিছু নেই। কোনো ফাইল অনলাইনে দিতে শেয়ার ব্যবহার করুন এবং এটি এখানে তালিকাভুক্ত হবে।" +"Nothing to export" = "এক্সপোর্ট করার মতো কিছু নেই" +"Nov" = "নভেম্বর" +"Now" = "এখন" +"Nudge (Shift: big nudge)" = "নাজ (Shift: বড় নাজ)" +"Nudge amount" = "নাজ পরিমাণ" +"Number" = "নম্বর" +"Number ↑" = "নম্বর ↑" +"Number ↓" = "নম্বর ↓" +"Numbered list" = "সাংখ্যিক তালিকা" +"Object" = "অবজেক্ট" +"Oct" = "অক্টোবর" +"Off" = "বন্ধ" +"Offered first when filling the slot (any content is allowed)" = "স্লট পূর্ণ করার সময় প্রথমে অফার করা হয় (যেকোনো কন্টেন্ট অনুমোদিত)" +"Offered first when swapping the instance (any component is allowed)" = "ইনস্ট্যান্স সোয়াপ করার সময় প্রথমে অফার করা হয় (যেকোনো কম্পোনেন্ট অনুমোদিত)" +"Offset" = "অফসেট" +"Oldest first" = "সবচেয়ে পুরনো প্রথমে" +"On click" = "ক্লিকে" +"On drag" = "ড্র্যাগে" +"On this page" = "এই পেজে" +"One announcement in five common sizes: square, portrait, story, link and banner" = "পাঁচটি সাধারণ আকারে একটি ঘোষণা: স্কয়ার, পোর্ট্রেট, স্টোরি, লিংক এবং ব্যানার" +"Online" = "অনলাইন" +"Online files" = "অনলাইন ফাইলসমূহ" +"Online sharing is off: files are shared on the local network only." = "অনলাইন শেয়ারিং বন্ধ আছে: ফাইলগুলো শুধুমাত্র স্থানীয় নেটওয়ার্কে শেয়ার করা হচ্ছে।" +"Only the first changed layers are listed." = "শুধুমাত্র প্রথম পরিবর্তিত লেয়ারগুলো তালিকাভুক্ত করা হয়েছে।" +"Only the first {n} changed layers are listed." = "শুধুমাত্র প্রথম {n}টি পরিবর্তিত লেয়ার তালিকাভুক্ত করা হয়েছে।" +"Only your threads" = "শুধুমাত্র আপনার থ্রেড" +"Opacity" = "অস্বচ্ছতা (Opacity)" +"Opacity & blend" = "অস্বচ্ছতা ও ব্লেন্ড" +"Opacity 10%…90%, 0: 100%" = "অস্বচ্ছতা ১০%…৯০%, ০: ১০০%" +"Open" = "খুলুন" +"Open .figcraft, .svg or images" = ".figcraft, .svg বা ছবি খুলুন" +"Open branch" = "ব্রাঞ্চ খুলুন" +"Open file" = "ফাইল খুলুন" +"Open files" = "ফাইলসমূহ খুলুন" +"Open in focus view" = "ফোকাস ভিউতে খুলুন" +"Open in new window" = "নতুন উইন্ডোতে খুলুন" +"Open invite links in FigCraft" = "FigCraft-এ আমন্ত্রণ লিংক খুলুন" +"Open link" = "লিংক খুলুন" +"Open link {url}" = "লিংক খুলুন {url}" +"Open overlay" = "ওভারলে খুলুন" +"Open prototype preview" = "প্রোটোটাইপ প্রিভিউ খুলুন" +"Open variables ({count})" = "ভেরিয়েবল খুলুন ({count})" +"Opened as a new document" = "নতুন ডকুমেন্ট হিসেবে খোলা হয়েছে" +"Opened “{name}” in a new tab" = "নতুন ট্যাবে “{name}” খোলা হয়েছে" +"Opening the file…" = "ফাইল খোলা হচ্ছে..." +"Opening {n} files as new files" = { one = "নতুন ফাইল হিসেবে খোলা হচ্ছে", other = "{n}টি ফাইল নতুন ফাইল হিসেবে খোলা হচ্ছে" } +"Open|comments" = "খুলুন" +"Options" = "বিকল্পসমূহ" +"Orange" = "কমলা" +"Order" = "ক্রম" +"Other apps may not open every .fig file exported here." = "অন্যান্য অ্যাপ এখানে এক্সপোর্ট করা প্রতিটি .fig ফাইল নাও খুলতে পারে।" +"Other" = "অন্যান্য" +"Others lose access and keep their copy; the file stays on the server for you" = "অন্যরা অ্যাক্সেস হারায় এবং তাদের কপি নিজের কাছে রাখে; ফাইলটি আপনার জন্য সার্ভারেই থেকে যায়" +"Others type this under Share ▸ Join with a code" = "অন্যরা 'Share ▸ Join with a code' এর নিচে এটি টাইপ করে" +"Outline stroke" = "আউটলাইন স্ট্রোক" +"Outline text" = "আউটলাইন টেক্সট" +"Outlines" = "আউটলাইনসমূহ" +"Outside" = "বাইরে" +"Overflow" = "ওভারফ্লো" +"Overlay" = "ওভারলে" +"Overlay position" = "ওভারলে অবস্থান" +"Overridden here (the collection it extends has another value)" = "এখানে ওভাররাইড করা হয়েছে (যে সংগ্রহটি এটি প্রসারিত করে তার অন্য একটি মান রয়েছে)" +"Overrides" = "ওভাররাইডসমূহ" +"Owner token" = "ওনার টোকেন" +"Owner token copied" = "ওনার টোকেন কপি করা হয়েছে" +"PDF print options" = "PDF প্রিন্ট বিকল্প" +"PDF print settings" = "PDF প্রিন্ট সেটিংস" +"Padding" = "প্যাডিং" +"Page" = "পেজ" +"Pages" = "পেজসমূহ" +"Pages added to the document" = "ডকুমেন্টে পেজ যোগ করা হয়েছে" +"Placed on the current page" = "বর্তমান পেজে রাখা হয়েছে" +"Pages: {pages}" = "পেজসমূহ: {pages}" +"Paint bucket" = "পেইন্ট বালতি" +"Pan the canvas" = "ক্যানভাস প্যান করুন" +"Pan with nothing selected (Shift: farther)" = "কিছু নির্বাচিত না করে প্যান করুন (Shift: দূরে)" +"Panels" = "প্যানেলসমূহ" +"Paper" = "কাগজ" +"Paragraph indent" = "প্যারাগ্রাফ ইনডেন্ট" +"Paragraph indent applies to left-aligned text" = "প্যারাগ্রাফ ইনডেন্ট বাম-সারিবদ্ধ টেক্সটের ক্ষেত্রে প্রযোজ্য" +"Paragraph spacing" = "প্যারাগ্রাফ ব্যবধান" +"Pass through" = "পাস থ্রু" +"Paste a link (https://…)" = "একটি লিংক পেস্ট করুন (https://…)" +"Paste a team library link (a view link)" = "একটি টিম লাইব্রেরি লিংক পেস্ট করুন (ভিউ লিংক)" +"Paste here" = "এখানে পেস্ট করুন" +"Paste to replace" = "প্রতিস্থাপন করতে পেস্ট করুন" +"Pasted design layers; {n} kinds of content were left out" = { one = "ডিজাইন লেয়ার পেস্ট করা হয়েছে; {n} ধরনের কন্টেন্ট বাদ দেওয়া হয়েছে", other = "ডিজাইন লেয়ার পেস্ট করা হয়েছে; {n} ধরনের কন্টেন্ট বাদ দেওয়া হয়েছে" } +"Pattern" = "প্যাটার্ন" +"Pattern alignment" = "প্যাটার্ন সারিবদ্ধকরণ" +"Pattern scale" = "প্যাটার্ন স্কেল" +"Pattern source" = "প্যাটার্ন উৎস" +"Pen" = "কলম" +"Pencil" = "পেন্সিল" +"People in this file" = "এই ফাইলের লোকজন" +"People join from anywhere with a code. The file is kept on {server} while you share it." = "কোডের মাধ্যমে যেকোনো স্থান থেকে লোকজন যুক্ত হতে পারে। ফাইলটি শেয়ার করার সময় {server}-এ রাখা হয়।" +"People on your network join with a code. Nothing goes through a server." = "আপনার নেটওয়ার্কের লোকজন কোডের মাধ্যমে যুক্ত হয়। কোনো সার্ভারের মাধ্যমে কিছু যায় না।" +"Phone" = "ফোন" +"Pick color" = "রঙ বাছুন" +"Pick color (I)" = "রঙ বাছুন (I)" +"Pick one font family first" = "প্রথমে একটি ফন্ট ফ্যামিলি বাছুন" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "ফ্রেম টুল বেছে নিন, এরপর ক্যানভাসে ড্র্যাগ করুন অথবা একটি ডিভাইসের আকার বেছে নিন" +"Pin a property" = "একটি প্রপার্টি পিন করুন" +"Pinch" = "পিন্চ" +"Pixel grid" = "পিক্সেল গ্রিড" +"Pixel preview 1x" = "পিক্সেল প্রিভিউ 1x" +"Pixel preview 2x" = "পিক্সেল প্রিভিউ 2x" +"Place a shape or frame (its tool chosen)" = "একটি শেপ বা ফ্রেম রাখুন (এর টুল নির্বাচিত)" +"Place automatically" = "স্বয়ংক্রিয়ভাবে রাখুন" +"Place image" = "ছবি রাখুন" +"Plugin" = "প্লাগইন" +"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "প্লাগইনগুলো ফাইল, নেটওয়ার্ক বা ঘড়ি ছাড়া FigCraft-এর স্যান্ডবক্সে চলে। শুধুমাত্র বিশ্বস্ত প্লাগইন ইনস্টল করুন।" +"Plugins" = "প্লাগইনসমূহ" +"Plugins & widgets" = "প্লাগইন ও উইজেট" +"Plus darker" = "প্লাস অন্ধকার" +"Plus lighter" = "প্লাস হালকা" +"Point" = "বিন্দু" +"Points" = "বিন্দুসমূহ" +"Polygon" = "বহুভুজ" +"Polygons" = "বহুভুজসমূহ" +"Port" = "পোর্ট" +"Position" = "অবস্থান" +"Post" = "পোস্ট" +"Preferences" = "প্রেফারেন্সেস" +"Preferred" = "পছন্দসই" +"Present" = "উপস্থাপন করুন" +"Present or preview" = "উপস্থাপন বা প্রিভিউ করুন" +"Present this flow" = "এই ফ্লো উপস্থাপন করুন" +"Presentation" = "উপস্থাপন" +"Presentation slides" = "উপস্থাপন স্লাইড" +"Presenting: comments" = "উপস্থাপন করা হচ্ছে: মন্তব্য" +"Presenting: cycle the size options" = "উপস্থাপন করা হচ্ছে: আকারের বিকল্পগুলো সাইকেল করুন" +"Presenting: full screen" = "উপস্থাপন করা হচ্ছে: ফুল স্ক্রিন" +"Presenting: next frame" = "উপস্থাপন করা হচ্ছে: পরবর্তী ফ্রেম" +"Presenting: previous frame" = "উপস্থাপন করা হচ্ছে: পূর্ববর্তী ফ্রেম" +"Presenting: restart" = "উপস্থাপন করা হচ্ছে: পুনরায় শুরু" +"Preserve scroll position" = "স্ক্রোল অবস্থান সংরক্ষণ করুন" +"Presets" = "প্রিসেটসমূহ" +"Press a key…" = "একটি কি টিপুন..." +"Playground" = "প্লেগ্রাউন্ড" +"Preview" = "প্রিভিউ" +"Preview this flow" = "এই ফ্লো প্রিভিউ করুন" +"Preview this frame" = "এই ফ্রেম প্রিভিউ করুন" +"Previous frame" = "পূর্ববর্তী ফ্রেম" +"Previous ready for dev" = "ডেভের জন্য পূর্ববর্তী প্রস্তুত" +"Progressive" = "প্রোগ্রেসিভ" +"Properties" = "প্রপার্টিজ" +"Prototype" = "প্রোটোটাইপ" +"Prototype preview" = "প্রোটোটাইপ প্রিভিউ" +"Prototype settings" = "প্রোটোটাইপ সেটিংস" +"Publish" = "প্রকাশ করুন" +"Publish changes" = "পরিবর্তন প্রকাশ করুন" +"Publish to another server…" = "অন্য সার্ভারে প্রকাশ করুন..." +"Publish to server" = "সার্ভারে প্রকাশ করুন" +"Published version {v}" = "প্রকাশিত সংস্করণ {v}" +"Published version {v} to the server" = "সংস্করণ {v} সার্ভারে প্রকাশ করা হয়েছে" +"Published version {v}: save the file so other files can use it" = "প্রকাশিত সংস্করণ {v}: ফাইলটি সেভ করুন যাতে অন্যান্য ফাইল এটি ব্যবহার করতে পারে" +"Published · {people}" = "প্রকাশিত · {people}" +"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "সার্ভারে প্রকাশ করতে ডেস্কটপ অ্যাপ প্রয়োজন; ব্রাউজারে আপনি সার্ভারের ফাইলে যুক্ত হতে পারবেন" +"Publishing with the server's key." = "সার্ভারের কী দিয়ে প্রকাশ করা হচ্ছে।" +"Publishing…" = "প্রকাশ করা হচ্ছে..." +"Push" = "পুশ" +"Push changes to main component" = "প্রধান কম্পোনেন্টে পরিবর্তন পুশ করুন" +"Put text on path" = "পাথে টেক্সট রাখুন" +"R" = "R" +"Radial" = "রেডিয়াল" +"Radius" = "ব্যাসার্ধ" +"Ratio" = "অনুপাত" +"Reading the library…" = "লাইব্রেরি পড়া হচ্ছে..." +"Ready for dev" = "ডেভের জন্য প্রস্তুত" +"Real name" = "আসল নাম" +"Recent" = "সাম্প্রতিক" +"Recent files" = "সাম্প্রতিক ফাইল" +"Recents" = "সাম্প্রতিকসমূহ" +"Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them" = { one = "অ্যাপটি অপ্রত্যাশিতভাবে বন্ধ হয়ে যাওয়ার পর একটি অসংরক্ষিত ফাইল পুনরুদ্ধার করা হয়েছে: এটি রাখতে সেভ করুন", other = "অ্যাপটি অপ্রত্যাশিতভাবে বন্ধ হয়ে যাওয়ার পর {n}টি অসংরক্ষিত ফাইল পুনরুদ্ধার করা হয়েছে: এগুলো রাখতে সেভ করুন" } +"Rectangle" = "আয়তক্ষেত্র" +"Rectangles" = "আয়তক্ষেত্রসমূহ" +"Red" = "লাল" +"Reduce motion" = "মোশন কমান" +"Refraction" = "প্রতিসরণ" +"Regular expression" = "রেগুলার এক্সপ্রেশন" +"Remove" = "সরান" +"Remove all" = "সব সরান" +"Remove annotation" = "অ্যানোটেশন সরান" +"Remove auto layout" = "অটো লেআউট সরান" +"Remove dev resource" = "ডেভ রিসোর্স সরান" +"Remove export setting" = "এক্সপোর্ট সেটিংস সরান" +"Remove from recents" = "সাম্প্রতিক থেকে সরান" +"Remove from server" = "সার্ভার থেকে সরান" +"Remove from server…" = "সার্ভার থেকে সরান..." +"Remove guide" = "গাইড সরান" +"Remove interaction" = "ইন্টারঅ্যাকশন সরান" +"Remove library from this file" = "এই ফাইল থেকে লাইব্রেরি সরান" +"Remove min and max" = "সর্বনিম্ন এবং সর্বোচ্চ সরান" +"Remove min and max height" = "সর্বনিম্ন এবং সর্বোচ্চ উচ্চতা সরান" +"Remove min and max width" = "সর্বনিম্ন এবং সর্বোচ্চ প্রস্থ সরান" +"Remove regions" = "অঞ্চলগুলো সরান" +"Remove slot" = "স্লট সরান" +"Remove starting point" = "শুরু করার বিন্দু সরান" +"Remove stop" = "স্টপ সরান" +"Remove text from path" = "পাথ থেকে টেক্সট সরান" +"Remove the else and its actions" = "else এবং এর অ্যাকশনগুলো সরান" +"Remove this condition and its actions" = "এই শর্ত এবং এর অ্যাকশনগুলো সরান" +"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "সার্ভার থেকে এই ফাইলটি সরাবেন? এটি সেখান থেকে আর শেয়ার বা খোলা যাবে না। আপনার কপিটি এখানে খোলা থাকবে।" +"Remove {name} from session" = "সেশন থেকে {name}-কে সরান" +"Remove {name} from {server}?" = "{server} থেকে {name}-কে সরাবেন?" +"Removed" = "সরে গেছে" +"Removed from the server" = "সার্ভার থেকে সরানো হয়েছে" +"Removed since it was marked" = "চিহ্নিত করার পর থেকে সরানো হয়েছে" +"Rename" = "নাম পরিবর্তন করুন" +"Rename duplicated layers" = "ডুপ্লিকেট করা লেয়ারের নাম পরিবর্তন করুন" +"Rename folder" = "ফোল্ডারের নাম পরিবর্তন করুন" +"Rename group" = "গ্রুপের নাম পরিবর্তন করুন" +"Rename layers" = "লেয়ারের নাম পরিবর্তন করুন" +"Rename mode" = "মোডের নাম পরিবর্তন করুন" +"Rename on the server" = "সার্ভারে নাম পরিবর্তন করুন" +"Rename on the server…" = "সার্ভারে নাম পরিবর্তন করুন..." +"Rename page" = "পেজের নাম পরিবর্তন করুন" +"Rename to" = "নাম পরিবর্তন করে রাখুন" +"Replace all" = "সব প্রতিস্থাপন করুন" +"Replace mixed export settings" = "মিশ্র এক্সপোর্ট সেটিংস প্রতিস্থাপন করুন" +"Replace with" = "এটি দিয়ে প্রতিস্থাপন করুন" +"Replace with a new interaction" = "একটি নতুন ইন্টারঅ্যাকশন দিয়ে প্রতিস্থাপন করুন" +"Reply" = "উত্তর দিন" +"Report" = "রিপোর্ট" +"Report this file…" = "এই ফাইলটি রিপোর্ট করুন..." +"Reset" = "রিসেট" +"Reset adjustments" = "অ্যাডজাস্টমেন্ট রিসেট করুন" +"Reset all changes" = "সব পরিবর্তন রিসেট করুন" +"Reset all overrides on this layer" = "এই লেয়ারের সব ওভাররাইড রিসেট করুন" +"Reset overrides" = "ওভাররাইড রিসেট করুন" +"Reset slot" = "স্লট রিসেট করুন" +"Reset this override" = "এই ওভাররাইড রিসেট করুন" +"Reset to style" = "স্টাইলে রিসেট করুন" +"Reset to the inherited value" = "উত্তরাধিকারসূত্রে প্রাপ্ত মানে রিসেট করুন" +"Resize by the nudge amount" = "নাজ পরিমাণ অনুযায়ী আকার পরিবর্তন করুন" +"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "ডিভাইসের স্ক্রিনে (বা ডিভাইস ছাড়া উইন্ডোতে) ফ্রেমের আকার পরিবর্তন করুন: তাদের সীমাবদ্ধতা এবং অটো লেআউট সাড়া দেয়" +"Resize to fit" = "ফিট করতে আকার পরিবর্তন করুন" +"Resizing" = "আকার পরিবর্তন করা হচ্ছে" +"Resolve" = "সমাধান করুন" +"Resolve change" = "পরিবর্তন সমাধান করুন" +"Resolved" = "সমাধান করা হয়েছে" +"Resources" = "রিসোর্সসমূহ" +"Responsive" = "রেসপনসিভ" +"Restart" = "পুনরায় শুরু করুন" +"Restore" = "পুনরুদ্ধার করুন" +"Restore main component" = "প্রধান কম্পোনেন্ট পুনরুদ্ধার করুন" +"Restore this version" = "এই সংস্করণ পুনরুদ্ধার করুন" +"Restored “{name}”" = "“{name}” পুনরুদ্ধার করা হয়েছে" +"Reverse the order" = "ক্রম উল্টে দিন" +"Reversed triangle" = "উল্টানো ত্রিভুজ" +"Review and merge" = "পর্যালোচনা ও মার্জ করুন" +"Ribbon" = "রিবন" +"Right" = "ডানে" +"Right side" = "ডান পাশ" +"Right side width" = "ডান পাশের প্রস্থ" +"Rotate 90°" = "৯০° ঘোরান" +"Rotate 90° right" = "ডানে ৯০° ঘোরান" +"Rotate to landscape" = "ল্যান্ডস্কেপে ঘোরান" +"Rotate to portrait" = "পোর্ট্রেটে ঘোরান" +"Rotation" = "ঘূর্ণন" +"Rotation jitter" = "ঘূর্ণন জিটার" +"Round" = "গোল" +"Rounded" = "গোল করা" +"Row" = "সারি" +"Row gap" = "সারির গ্যাপ" +"Row sizes" = "সারির আকার" +"Row span" = "সারি স্প্যান" +"Rows" = "সারিগুলো" +"Rows {count}" = "সারি {count}" +"Rulers" = "রুলার" +"Run" = "চালান" +"Run {label}" = "চালান {label}" +"Save files automatically" = "স্বয়ংক্রিয়ভাবে ফাইল সেভ করুন" +"Search templates" = "টেমপ্লেট খুঁজুন" +"See all templates" = "সব টেমপ্লেট দেখুন" +"Select layers and they space themselves as their content changes" = "লেয়ার নির্বাচন করুন এবং তাদের কন্টেন্ট পরিবর্তিত হওয়ার সাথে সাথে তারা নিজেদের মধ্যে দূরত্ব বজায় রাখবে" +"SHA-256: {sha}…" = "SHA-256: {sha}…" +"Same as layer" = "লেয়ারের মতোই" +"Saturation" = "স্যাচুরেশন" +"Save" = "সেভ করুন" +"Save as" = "এভাবে সেভ করুন" +"Save as preset…" = "প্রিসেট হিসেবে সেভ করুন..." +"Save version {key}" = "সংস্করণ সেভ করুন {key}" +"Saved" = "সেভ করা হয়েছে" +"Saved {name}" = "সেভ করা হয়েছে {name}" +"Say something" = "কিছু বলুন" +"Scale" = "স্কেল" +"Scatter" = "স্ক্যাটার" +"Scoping: where pickers offer it" = "স্কোপিং: যেখানে পিকার এটি অফার করে" +"Screen" = "স্ক্রিন" +"Scroll behavior" = "স্ক্রোল আচরণ" +"Scroll to" = "স্ক্রোল করে যান" +"Scroll to layer" = "লেয়ারে স্ক্রোল করুন" +"Scroll with parent" = "প্যারেন্টের সাথে স্ক্রোল করুন" +"Search plugins" = "প্লাগইন খুঁজুন" +"Search" = "অনুসন্ধান" +"Search actions, layers and tools" = "অ্যাকশন, লেয়ার এবং টুল খুঁজুন" +"Search assets" = "অ্যাসেট খুঁজুন" +"Search comments" = "মন্তব্য খুঁজুন" +"Search files and templates" = "ফাইল এবং টেমপ্লেট খুঁজুন" +"Search results" = "অনুসন্ধানের ফলাফল" +"Search fonts" = "ফন্ট খুঁজুন" +"Search layers" = "লেয়ার খুঁজুন" +"Search shortcuts" = "শর্টকাট খুঁজুন" +"Section" = "সেকশন" +"Section color" = "সেকশনের রঙ" +"Sections" = "সেকশনসমূহ" +"Select a frame and add a starting point to present it as a flow." = "একটি ফ্রেম নির্বাচন করুন এবং ফ্লো হিসেবে উপস্থাপন করতে একটি শুরুর বিন্দু যোগ করুন।" +"Select a frame to preview it" = "প্রিভিউ করতে একটি ফ্রেম নির্বাচন করুন" +"Select a layer to see its properties and code" = "এর প্রপার্টি এবং কোড দেখতে একটি লেয়ার নির্বাচন করুন" +"Select all" = "সব নির্বাচন করুন" +"Select all with same" = "একই রকম সব নির্বাচন করুন" +"Select layer" = "লেয়ার নির্বাচন করুন" +"Select layers to apply the style to" = "স্টাইল প্রয়োগ করার জন্য লেয়ার নির্বাচন করুন" +"Select layers with this color" = "এই রঙের লেয়ারগুলো নির্বাচন করুন" +"Select overlapping shapes" = "overlapping শেপগুলো নির্বাচন করুন" +"Select points to edit their position, radius and mirroring" = "অবস্থান, ব্যাসার্ধ এবং মিররিং এডিট করতে পয়েন্ট নির্বাচন করুন" +"Select slot" = "স্লট নির্বাচন করুন" +"Selection colors" = "নির্বাচনের রঙ" +"Selection" = "নির্বাচন" +"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "নির্বাচন বাক্স, {w} × {h}, অবস্থান {x}, {y}: অ্যারো কি দিয়ে সরানো যায়, Shift+arrows বা {cmd}+arrows দিয়ে আকার পরিবর্তন করা যায়, Enter দিয়ে নির্বাচন হয়, Escape দিয়ে বাতিল হয়" +"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "নির্বাচন বাক্স: অ্যারো কি দিয়ে সরানো যায়, Shift+arrows বা {cmd}+arrows দিয়ে আকার পরিবর্তন করা যায়, Enter দিয়ে নির্বাচন হয়, Escape দিয়ে বাতিল হয়" +"Send backward" = "পেছনে পাঠান" +"Send report" = "রিপোর্ট পাঠান" +"Send to back" = "সবার পেছনে পাঠান" +"Sep" = "সেপ্টেম্বর" +"Server address (ws://host:47900/)" = "সার্ভার ঠিকানা (ws://host:47900/)" +"Server address" = "সার্ভারের ঠিকানা" +"Server for online sharing" = "অনলাইন শেয়ারিংয়ের সার্ভার" +"Set as default" = "ডিফল্ট হিসেবে সেট করুন" +"Set variable" = "ভেরিয়েবল সেট করুন" +"Set variable mode" = "ভেরিয়েবল মোড সেট করুন" +"Set variable {name}" = "ভেরিয়েবল সেট করুন {name}" +"Set {name} to {value}" = "{name}-এর মান {value} সেট করুন" +"Shadows" = "ছায়াসমূহ (Shadows)" +"Shape" = "শেপ" +"Shape builder" = "শেপ বিল্ডার" +"Shape tools" = "শেপ টুলস" +"Share" = "শেয়ার" +"Share this file" = "এই ফাইলটি শেয়ার করুন" +"Share with a code" = "কোড দিয়ে শেয়ার করুন" +"Share: live session, export" = "শেয়ার: লাইভ সেশন, এক্সপোর্ট" +"Shared" = "শেয়ার করা হয়েছে" +"Shared: copy its code from the file's menu" = "শেয়ার করা হয়েছে: ফাইলের মেনু থেকে এর কোড কপি করুন" +"Sharing a file needs the desktop app; here you can join one." = "ফাইল শেয়ার করতে ডেস্কটপ অ্যাপ প্রয়োজন; এখানে আপনি একটিতে যুক্ত হতে পারবেন।" +"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "স্থানীয় নেটওয়ার্কে শেয়ার করা হচ্ছে (অনলাইন শেয়ারিং বন্ধ আছে: প্রেফারেন্সেসে কোনো সার্ভার সেট করা নেই)।" +"Sharing on your network · {people}" = "আপনার নেটওয়ার্কে শেয়ার করা হচ্ছে · {people}" +"Sharing online · {people}" = "অনলাইনে শেয়ার করা হচ্ছে · {people}" +"Sharing stopped: the file stays on the server, open only to you" = "শেয়ারিং বন্ধ করা হয়েছে: ফাইলটি সার্ভারেই আছে, শুধুমাত্র আপনার জন্য খোলা" +"Show" = "দেখান" +"Show all templates" = "সব টেমপ্লেট দেখান" +"Show as grid" = "গ্রিড হিসেবে দেখান" +"Show as list" = "তালিকা হিসেবে দেখান" +"Show autosaves" = "স্বয়ংক্রিয় সেভ দেখান" +"Show behind transparent areas" = "স্বচ্ছ এলাকার পেছনে দেখান" +"Show comments (Shift+C)" = "মন্তব্য দেখান (Shift+C)" +"Show device frame" = "ডিভাইস ফ্রেম দেখান" +"Show dimensions on objects" = "অবজেক্টে মাত্রা (dimensions) দেখান" +"Show hotspot hints" = "হটস্পট ইঙ্গিত দেখান" +"Show in Explorer" = "এক্সপ্লোরারে দেখান" +"Show in Finder" = "ফাইন্ডারে দেখান" +"Show in folder" = "ফোল্ডারে দেখান" +"Show only this on the canvas" = "ক্যানভাসে শুধুমাত্র এটি দেখান" +"Show open" = "খোলা দেখান" +"Show section contents" = "সেকশনের কন্টেন্ট দেখান" +"Show this layer" = "এই লেয়ারটি দেখান" +"Show usage" = "ব্যবহার দেখান" +"Show/Hide" = "দেখান/লুকান" +"Show/Hide UI" = "UI দেখান/লুকান" +"Side by side" = "পাশাপাশি" +"Sidebar" = "সাইডবার" +"Simplify stroke" = "স্ট্রোক সরল করুন" +"Size" = "আকার" +"Size jitter" = "আকার জিটার" +"Skip ink" = "কালি বাদ দিন" +"Slashed zero" = "স্ল্যাশড জিরো" +"Slice" = "স্লাইস" +"Slices" = "স্লাইসসমূহ" +"Slide in" = "স্লাইড ইন" +"Slide out" = "স্লাইড আউট" +"Slot" = "স্লট" +"Slot content" = "স্লট কন্টেন্ট" +"Small capitals" = "ছোট বড় হাতের অক্ষর" +"Small caps" = "স্মল ক্যাপস" +"Smart animate" = "স্মার্ট অ্যানিমেট" +"Snap to pixel grid" = "পিক্সেল গ্রিডে স্ন্যাপ করুন" +"Social and slides" = "সোশ্যাল ও স্লাইড" +"Social media" = "সোশ্যাল মিডিয়া" +"Social post sizes" = "সোশ্যাল পোস্টের আকার" +"Soft light" = "সফট লাইট" +"Solid" = "সলিড" +"Some variants have the same property values: give each one a unique combination" = "কিছু ভ্যারিয়েন্টের প্রপার্টি মান একই: প্রতিটিকে একটি অনন্য কম্বিনেশন দিন" +"Sort" = "সাজান" +"Sort by" = "যেভাবে সাজাবেন" +"Sort by date" = "তারিখ অনুযায়ী সাজান" +"Sort by page" = "পেজ অনুযায়ী সাজান" +"Sort by {column}" = "{column} অনুযায়ী সাজান" +"Space around" = "চারপাশে স্পেস" +"Space between" = "মাঝখানে স্পেস" +"Space drag" = "স্পেস ড্র্যাগ" +"Space evenly" = "সমানভাবে স্পেস" +"Space+drag" = "Space+ড্র্যাগ" +"Spacing" = "ব্যবধান" +"Spam, stolen work, abuse…" = "স্প্যাম, চুরি করা কাজ, অপব্যবহার..." +"Splay" = "সপ্লে" +"Spotlight me" = "আমাকে স্পটলাইট করুন" +"Spread" = "স্প্রেড" +"Square" = "বর্গক্ষেত্র" +"Standard" = "স্ট্যান্ডার্ড" +"Standard ligatures" = "স্ট্যান্ডার্ড লিগেচার" +"Star" = "তারকা" +"Stars" = "তারকাসমূহ" +"Start" = "শুরু" +"Start ascending from / stop descending at" = "এখান থেকে ওঠা শুরু করুন / এখানে নামা বন্ধ করুন" +"Start from a blank canvas" = "খালি ক্যানভাস থেকে শুরু করুন" +"Start from a template" = "টেমপ্লেট থেকে শুরু করুন" +"Start on path" = "পাথ থেকে শুরু করুন" +"Start point" = "শুরুর বিন্দু" +"Start sharing" = "শেয়ার করা শুরু করুন" +"Start sharing with the Share button, then pass on the code it gives" = "শেয়ার বোতাম দিয়ে শেয়ার করা শুরু করুন, তারপর এটি যে কোড দেয় তা দিন" +"Starting to share…" = "শেয়ার করা শুরু হচ্ছে..." +"Status" = "স্ট্যাটাস" +"Sticky" = "স্টিকি" +"Stop" = "থামুন" +"Stop following {name}" = "{name}-কে অনুসরণ করা বন্ধ করুন" +"Stop session" = "সেশন বন্ধ করুন" +"Stop sharing" = "শেয়ার করা বন্ধ করুন" +"Stop spotlighting" = "স্পটলাইটিং বন্ধ করুন" +"Stop {n} {hex}" = "স্টপ {n} {hex}" +"Stretch" = "টেনে বড় করা (Stretch)" +"Strikethrough" = "স্ট্রাইকথ্রু" +"String" = "স্ট্রিং" +"Stroke" = "স্ট্রোক" +"Stroke style" = "স্ট্রোক স্টাইল" +"Stroke weight" = "স্ট্রোকের ওজন" +"Strokes" = "স্ট্রোকসমূহ" +"Style" = "স্টাইল" +"Styles" = "স্টাইলসমূহ" +"Stylistic set 1" = "স্টাইলিস্টিক সেট ১" +"Stylistic set 2" = "স্টাইলিস্টিক সেট ২" +"Stylistic set 3" = "স্টাইলিস্টিক সেট ৩" +"Stylistic set 4" = "স্টাইলিস্টিক সেট ৪" +"Stylistic set 5" = "স্টাইলিস্টিক সেট ৫" +"Subtract" = "বিয়োগ করুন" +"Subtracts" = "বিয়োগ করে" +"Suffix" = "সাফিক্স" +"Suspend snapping while dragging" = "ড্র্যাগ করার সময় স্ন্যাপিং স্থগিত রাখুন" +"Swap instance" = "ইনস্ট্যান্স সোয়াপ করুন" +"Swap library: use another library's assets of the same names" = "লাইব্রেরি সোয়াপ করুন: একই নামের অন্য লাইব্রেরির অ্যাসেট ব্যবহার করুন" +"Swap orientation" = "ভিত্তিক বিন্যাস (orientation) সোয়াপ করুন" +"Swap overlay" = "ওভারলে সোয়াপ করুন" +"Swap to {name}" = "{name}-এ সোয়াপ করুন" +"Swap width and height" = "প্রস্থ ও উচ্চতা সোয়াপ করুন" +"Swapped instance" = "সোয়াপ করা ইনস্ট্যান্স" +"Swapped {n} assets" = { one = "{n}টি অ্যাসেট সোয়াপ করা হয়েছে", other = "{n}টি অ্যাসেট সোয়াপ করা হয়েছে" } +"Swapped {n} assets; {missed} had no match and still use the old library" = { one = "{n}টি অ্যাসেট সোয়াপ করা হয়েছে; {missed}-এর কোনো মিল পাওয়া যায়নি এবং এখনো পুরোনো লাইব্রেরি ব্যবহার করছে", other = "{n}টি অ্যাসেট সোয়াপ করা হয়েছে; {missed}-এর কোনো মিল পাওয়া যায়নি এবং এখনো পুরোনো লাইব্রেরি ব্যবহার করছে" } +"Sweep" = "সুইপ" +"System" = "সিস্টেম" +"Tab / Shift+Tab" = "Tab / Shift+Tab" +"Table" = "টেবিল" +"Tablet" = "ট্যাবলেট" +"Tabular numbers" = "টাবুলার সংখ্যা" +"Take the values from the first selected layer" = "প্রথমে নির্বাচিত লেয়ার থেকে মানগুলো নিন" +"Taper" = "ট্যাপার" +"Taper both ends" = "উভয় প্রান্ত ট্যাপার করুন" +"Tell the server's operator what is wrong with it" = "সার্ভারের অপারেটরকে জানান এতে কী সমস্যা রয়েছে" +"Temperature" = "তাপমাত্রা" +"Template" = "টেমপ্লেট" +"Templates" = "টেমপ্লেটসমূহ" +"Terms" = "শর্তাবলী" +"Text" = "টেক্সট" +"Text align" = "টেক্সট সারিবদ্ধকরণ" +"Text content" = "টেক্সট কন্টেন্ট" +"Text style" = "টেক্সট স্টাইল" +"Text styles" = "টেক্সট স্টাইলসমূহ" +"Texts" = "টেক্সটসমূহ" +"Texture" = "টেক্সচার" +"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "ক্যানভাস sRGB-তে রঙ প্রিভিউ করে; এক্সপোর্ট করা PNG, JPG এবং WebP ফাইলে Display P3 ট্যাগ করা থাকে।" +"The destination document is unavailable. Nothing was imported." = "গন্তব্য ডকুমেন্ট অনুপলব্ধ। কিছু ইম্পোর্ট করা হয়নি।" +"The destination document was closed. Nothing was imported." = "গন্তব্য ডকুমেন্ট বন্ধ করা হয়েছিল। কিছু ইম্পোর্ট করা হয়নি।" +"The destination page was removed. Nothing was imported." = "গন্তব্য পেজ সরানো হয়েছে। কিছু ইম্পোর্ট করা হয়নি।" +"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "ফাইলটি সার্ভার থেকে ডিলিট করা হয়েছে: কেউ আর সেখান থেকে এটি খুলতে বা যুক্ত হতে পারবে না, এবং এটি আর ফেরানো যাবে না। যে কপিগুলো খোলা আছে তা যার যার কাছে আছে তার সাথেই থাকবে।" +"The file now holds another library" = "ফাইলটিতে এখন অন্য একটি লাইব্রেরি রয়েছে" +"The file now holds another library (\"{name}\")" = "ফাইলটিতে এখন অন্য একটি লাইব্রেরি রয়েছে (\"{name}\")" +"The files you shared or published online from this computer. They stay on their server until you remove them." = "এই কম্পিউটার থেকে আপনি যে ফাইলগুলো অনলাইনে শেয়ার বা প্রকাশ করেছেন। আপনি না সরানো পর্যন্ত সেগুলো তাদের সার্ভারেই থাকে।" +"The link carries the session's secret: share it only with people you invite." = "লিংকটি সেশনের গোপনীয়তা বহন করে: শুধুমাত্র আপনার আমন্ত্রিত লোকদের সাথেই এটি শেয়ার করুন।" +"The name in your online files and on the server. The document inside keeps its own name." = "আপনার অনলাইন ফাইল এবং সার্ভারে থাকা নাম। ভেতরের ডকুমেন্টটি তার নিজস্ব নাম বজায় রাখে।" +"The selection uses several text styles: click to apply one" = "নির্বাচনে একাধিক টেক্সট স্টাইল ব্যবহার করা হয়েছে: একটি প্রয়োগ করতে ক্লিক করুন" +"The server deletes this file if nobody opens it before {date}." = "{date}-এর আগে কেউ না খুললে সার্ভার এই ফাইলটি ডিলিট করে দেবে।" +"The whole file" = "সম্পূর্ণ ফাইল" +"Theme" = "থিম" +"There's no destination for that action" = "সেই অ্যাকশনের জন্য কোনো গন্তব্য নেই" +"Thickness" = "পুরুত্ব" +"This branch can't be reviewed: {e}" = "এই ব্রাঞ্চটি পর্যালোচনা করা যাচ্ছে না: {e}" +"This branch was merged or archived." = "এই ব্রাঞ্চটি মার্জ বা আর্কাইভ করা হয়েছে।" +"This computer only" = "শুধুমাত্র এই কম্পিউটার" +"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "এই কম্পিউটার ওনার টোকেন মনে রাখে, তাই হোম স্ক্রিনের অনলাইন তালিকা থেকে ফাইলটি পরিচালনা করা যেতে পারে। অন্য কোথাও থেকে ফাইলটি পরিচালনা করতে এটি কপি করুন: এটি এই ফাইলের জন্য একটি পাসওয়ার্ড এবং আর কখনো দেখানো হবে না।" +"This condition is edited as an expression" = "এই শর্তটি এক্সপ্রেশন হিসেবে এডিট করা হয়েছে" +"This file" = "এই ফাইল" +"This file has unsaved changes" = "এই ফাইলে সেভ করা হয়নি এমন পরিবর্তন রয়েছে" +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "এই ফাইলে এমন ফন্ট ব্যবহার করা হয়েছে যা এই কম্পিউটারে ইনস্টল করা নেই। আপনি ইনস্টল না করা পর্যন্ত সেগুলোর টেক্সট একটি ফলব্যাক ফন্টে দেখানো হয়।" +"This file is on {server}, open only to you. Share it to let others in with a new code." = "এই ফাইলটি {server}-এ আছে, শুধুমাত্র আপনার জন্য খোলা। নতুন কোড দিয়ে অন্যদের প্রবেশ করাতে এটি শেয়ার করুন।" +"This frame is new on the branch." = "এই ফ্রেমটি ব্রাঞ্চে নতুন।" +"This frame isn't on the branch." = "এই ফ্রেমটি ব্রাঞ্চে নেই।" +"This server is open to the public: no key needed." = "এই সার্ভারটি সবার জন্য উন্মুক্ত: কোনো কী প্রয়োজন নেই।" +"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "এই সার্ভারের কী প্রয়োজন: ঠিকানায় ?key=… যোগ করুন (`figcraft-server key` এটি সার্ভারের মেশিনে প্রিন্ট করে)।" +"This server takes files only with its key: see Advanced." = "এই সার্ভার শুধুমাত্র এর কী সহ ফাইল নেয়: উন্নত (Advanced) দেখুন।" +"Tidy up" = "পরিপাটি করুন" +"Tile" = "টাইল" +"Tile type" = "টাইল টাইপ" +"Tint" = "টিন্ট" +"Title" = "শিরোনাম" +"Title case" = "টাইটেল কেস" +"To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "আপনার টিমের সাথে এই লাইব্রেরি শেয়ার করতে, ফাইলটি একটি সার্ভারে প্রকাশ করুন (Share ▸ Publish to server…), এরপর সেখানে লাইব্রেরি প্রকাশ করুন।" +"Toggle" = "টগল" +"Toggle light/dark theme" = "লাইট/ডার্ক থিম টগল করুন" +"Toggle visibility" = "দৃশ্যমানতা টগল করুন" +"Tools" = "টুলস" +"Top" = "উপরে" +"Top & bottom" = "উপরে ও নিচে" +"Top left radius" = "ওপরের বাম ব্যাসার্ধ" +"Top right radius" = "ওপরের ডান ব্যাসার্ধ" +"Transform" = "ট্রান্সফর্ম" +"Triangle arrow" = "ত্রিভুজ তীর" +"Trigger" = "ট্রিগার" +"True" = "সত্য (True)" +"Truncate text" = "টেক্সট ট্রাঙ্কেট করুন" +"Truncate with an ellipsis" = "উপবৃত্ত (ellipsis) দিয়ে ট্রাঙ্কেট করুন" +"Try again" = "আবার চেষ্টা করুন" +"Try another search or filter." = "অন্য অনুসন্ধান বা ফিল্টার ব্যবহার করে দেখুন।" +"Try another search, or browse templates." = "অন্য অনুসন্ধান ব্যবহার করুন, অথবা টেমপ্লেট ব্রাউজ করুন।" +"Try another word, or look at every kind." = "অন্য শব্দ ব্যবহার করুন, অথবা সব ধরণ দেখুন।" +"Turn a layer into a component, reuse it, and change every copy at once" = "একটি লেয়ারকে কম্পোনেন্টে রূপান্তর করুন, এটি পুনরায় ব্যবহার করুন এবং এক সাথে সব কপি পরিবর্তন করুন" +"Two digits" = "দুটি অঙ্ক" +"Type" = "ধরন (Type)" +"Type settings" = "টাইপ সেটিংস" +"Type the join code you were given, or paste an invite link." = "আপনাকে দেওয়া যুক্ত হওয়ার কোডটি টাইপ করুন, অথবা একটি আমন্ত্রণ লিংক পেস্ট করুন।" +"Typography" = "টাইপোগ্রাফি" +"Underline" = "আন্ডারলাইন" +"Undo takes back only your own changes; what others changed since stays." = "আনডু কেবল আপনার নিজস্ব পরিবর্তনগুলো ফিরিয়ে নেয়; এর মধ্যে অন্যরা যা পরিবর্তন করেছে তা থেকেই যায়।" +"Ungroup" = "আনগ্রুপ" +"Uniform" = "ইউনিফর্ম" +"Union" = "ইউনিয়ন" +"Unions" = "ইউনিয়নসমূহ" +"Unknown — treated as sRGB" = "অজানা — sRGB হিসেবে গণ্য করা হয়েছে" +"Unread" = "অপঠিত" +"Unresolve" = "অমীমাংসিত করুন" +"Unsaved changes" = "সংরক্ষিত হয়নি এমন পরিবর্তন" +"Unsaved changes in {names}. Close the window and lose them?" = "{names}-এ সংরক্ষিত হয়নি এমন পরিবর্তন রয়েছে। উইন্ডোটি বন্ধ করলে কি সেগুলো হারিয়ে যাবে?" +"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "সংরক্ষিত হয়নি এমন পরিবর্তন: প্রথমে সেভ করুন ({save}), অথবা বাতিল করতে {alt}-ক্লিক করে ট্যাব বন্ধ করুন" +"Unsaved changes: save first, or close again with {key} to discard" = "সংরক্ষিত হয়নি এমন পরিবর্তন: প্রথমে সেভ করুন, অথবা বাতিল করতে {key} দিয়ে আবার বন্ধ করুন" +"Up" = "উপরে" +"Update plugin" = "প্লাগইন আপডেট করুন" +"Update" = "আপডেট" +"Update from main" = "প্রধান (main) থেকে আপডেট করুন" +"Update from selection" = "নির্বাচন থেকে আপডেট করুন" +"Updated {components} and {styles}" = "{components} এবং {styles} আপডেট করা হয়েছে" +"Updated {name}" = "{name} আপডেট করা হয়েছে" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "গ্রাফিক্স ড্রাইভার আপডেট করা প্রায়ই সহায়ক হয়। শুধুমাত্র OpenGL ব্যবহার করতে, কমান্ড প্রম্পট থেকে FigCraft শুরু করুন:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "গ্রাফিক্স ড্রাইভার আপডেট করা প্রায়ই সহায়ক হয়। WGPU_BACKEND (vulkan, gl, metal) চেষ্টা করার জন্য অন্য একটি গ্রাফিক্স API এর নাম বলে।" +"Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "এই ফাইলটি একটি FigCraft সার্ভারে আপলোড করে এবং এই ট্যাবটিকে এর সাথে সিঙ্ক করে রাখে। আপনি এডিট করার জন্য একটি লিংক এবং দেখার জন্য একটি লিংক পান।" +"Uppercase" = "বড় হাতের অক্ষর" +"Use a fixed value" = "একটি নির্দিষ্ট মান ব্যবহার করুন" +"Use an expression, e.g. count + 1" = "একটি এক্সপ্রেশন ব্যবহার করুন, যেমন count + 1" +"Use as mask" = "মাস্ক হিসেবে ব্যবহার করুন" +"Use auto layout" = "অটো লেআউট ব্যবহার করুন" +"Use number keys for opacity" = "অস্বচ্ছতার জন্য নম্বর কি ব্যবহার করুন" +"Use the .figcraft or .fig extension." = ".figcraft বা .fig এক্সটেনশন ব্যবহার করুন।" +"Use the layer's fill" = "লেয়ারের ফিল ব্যবহার করুন" +"Used in {files} · {instances}" = "{files} · {instances}-এ ব্যবহৃত" +"Username" = "ব্যবহারকারী নাম" +"Value" = "মান" +"Uses variables: each layer shows them in its own mode" = "ভেরিয়েবল ব্যবহার করে: প্রতিটি লেয়ার তাদের নিজস্ব মোডে সেগুলো দেখায়" +"Variable" = "ভেরিয়েবল" +"Variable collections" = "ভেরিয়েবল সংগ্রহসমূহ" +"Variable modes" = "ভেরিয়েবল মোড" +"Variable width" = "পরিবর্তনশীল প্রস্থ (Variable width)" +"Variables" = "ভেরিয়েবলসমূহ" +"Variables JSON…" = "ভেরিয়েবল JSON..." +"Variables store reusable values (colours, numbers, strings, booleans) with modes such as Light and Dark." = "ভেরিয়েবলগুলো লাইট এবং ডার্কের মতো মোডের সাথে পুনঃব্যবহারযোগ্য মান (রঙ, নম্বর, স্ট্রিং, বুলিয়ান) সংরক্ষণ করে।" +"Variant" = "ভ্যারিয়েন্ট" +"Vector" = "ভেক্টর" +"Vectors" = "ভেক্টরসমূহ" +"Version" = "সংস্করণ" +"Version history" = "সংস্করণ ইতিহাস" +"Version {version}" = "সংস্করণ {version}" +"Version {v} · {components} · {styles} · {variables}" = "সংস্করণ {v} · {components} · {styles} · {variables}" +"Vertical" = "উল্লম্ব" +"Vertical align" = "উল্লম্ব সারিবদ্ধকরণ" +"Vertical hex" = "উল্লম্ব হেক্স" +"Vertical padding" = "উল্লম্ব প্যাডিং" +"Vertical spacing" = "উল্লম্ব ব্যবধান" +"Vertical trim" = "উল্লম্ব ট্রিম" +"View" = "ভিউ" +"View link" = "ভিউ লিংক" +"View link copied" = "ভিউ লিংক কপি করা হয়েছে" +"View only" = "শুধুমাত্র দেখার উপযোগী" +"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "শুধুমাত্র দেখার উপযোগী: আপনি প্রতিটি পরিবর্তন ঘটার সাথে সাথে দেখতে পান কিন্তু এডিট করতে পারেন না। আপনি এডিট করতে পারেন এমন একটি কপি রাখতে ত্যাগ করুন।" +"Viewing “{name}”" = "“{name}” দেখা হচ্ছে" +"Violet" = "বেগুনি" +"Visibility" = "দৃশ্যমানতা" +"Visible" = "দৃশ্যমান" +"Vivid" = "ভিভিড" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND সেট করা আছে এবং ব্যবহারের জন্য গ্রাফিক্স API-এর নাম দেয়; এটি আনসেট করলে FigCraft কে বেছে নিতে দেয়।" +"Warm" = "উষ্ণ" +"Watch" = "ওয়াচ" +"Wavy" = "ঢেউ খেলানো" +"Web" = "ওয়েব" +"Websites" = "ওয়েবসাইটসমূহ" +"WebSocket URL" = "WebSocket URL" +"WebSocket URL copied" = "WebSocket URL কপি করা হয়েছে" +"Wedge" = "ওয়েজ" +"Weight" = "ওজন" +"What you try here changes this preview and the code below, not the file" = "আপনি এখানে যা চেষ্টা করেন তা ফাইলটিকে নয়, কেবল এই প্রিভিউ এবং নিচের কোডটিকে পরিবর্তন করে" +"What changed since it was marked ready for dev" = "এটি ডেভের জন্য প্রস্তুত হিসেবে চিহ্নিত করার পর থেকে কী পরিবর্তিত হয়েছে" +"What is wrong with this file? The server's operator gets your report." = "এই ফাইলে কী সমস্যা রয়েছে? সার্ভারের অপারেটর আপনার রিপোর্ট পান।" +"What it's for" = "এটি কি জন্য" +"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "এটি কি থেকে পরিবর্তিত হয়েছে তা অজানা: FigCraft ডেভের জন্য প্রস্তুত ফ্রেমের একটি কপি রাখার আগেই এটি পরিবর্তিত হয়েছে। এখন থেকে তুলনা করতে পরিবর্তনটি সমাধান করুন (বা এটি আবার প্রস্তুত হিসেবে চিহ্নিত করুন)।" +"While hovering" = "হোভার করার সময়" +"While pressing" = "চেপে ধরে রাখার সময়" +"White" = "সাদা" +"Who runs it, its terms and how long it keeps files" = "এটি কে পরিচালনা করে, এর শর্তাবলী এবং এটি কতদিন ফাইল রাখে" +"Why it changed" = "কেন এটি পরিবর্তিত হয়েছে" +"Widget" = "উইজেট" +"Widgets" = "উইজেটসমূহ" +"Width" = "প্রস্থ" +"Width point" = "প্রস্থ বিন্দু" +"Width profile" = "প্রস্থ প্রোফাইল" +"Wireframe kit" = "ওয়্যারফ্রেম কিট" +"With the layers' export settings" = "লেয়ারের এক্সপোর্ট সেটিংস সহ" +"Wrap" = "র‍্যাপ" +"Wrap in new section" = "নতুন সেকশনে র‍্যাপ করুন" +"Yellow" = "হলুদ" +"Yesterday" = "গতকাল" +"You" = "আপনি" +"You'll get the shared file as a new tab and edit it together." = "আপনি একটি নতুন ট্যাব হিসেবে শেয়ার করা ফাইলটি পাবেন এবং একসাথে এডিট করবেন।" +"You're spotlighting: everyone follows your view" = "আপনি স্পটলাইট করছেন: সবাই আপনার ভিউ অনুসরণ করছে" +"Your copy of the file stays open" = "ফাইলের আপনার কপিটি খোলা থাকে" +"Your copy stays open; the file stays on the server" = "আপনার কপি খোলা থাকে; ফাইলটি সার্ভারেই থাকে" +"Your name" = "আপনার নাম" +"Your name in comments" = "মন্তব্যে আপনার নাম" +"Your system asks for reduced motion: animations stay off." = "আপনার সিস্টেম কম মোশনের অনুরোধ জানায়: অ্যানিমেশন বন্ধ থাকে।" +"Z" = "Z" +"Z to A" = "Z থেকে A" +"Zoom" = "জুম" +"Zoom around the pointer" = "পয়েন্টারের চারপাশে জুম করুন" +"Zoom device to fill window" = "উইন্ডো পূর্ণ করতে ডিভাইস জুম করুন" +"Zoom in" = "জুম ইন" +"Zoom out" = "জুম আউট" +"Zoom to 100%" = "১০০%-এ জুম করুন" +"Zoom to 200%" = "২০০%-এ জুম করুন" +"Zoom to 50%" = "৫০%-এ জুম করুন" +"Zoom to fit" = "ফিট করতে জুম করুন" +"Zoom to selection" = "নির্বাচনে জুম করুন" +"Zoom tool: click to zoom in, drag an area" = "জুম টুল: জুম ইন করতে ক্লিক করুন, একটি এলাকা ড্র্যাগ করুন" +"Zoom tool: zoom out" = "জুম টুল: জুম আউট" +"and {n} more" = { one = "এবং আরও {n}টি", other = "এবং আরও {n}টি" } +"bleed marks" = "ব্লিড মার্ক" +"by {author}" = "{author} দ্বারা" +"call any command its access allows" = "এর অ্যাক্সেস অনুমতি দেয় এমন যেকোনো কমান্ড কল করুন" +"call only: {commands}" = "শুধুমাত্র কল করুন: {commands}" +"can edit the document" = "ডকুমেন্ট এডিট করতে পারেন" +"conditional prototype actions nested too deeply were skipped" = "শর্তসাপেক্ষ প্রোটোটাইপ অ্যাকশনগুলো খুব গভীরভাবে নেস্ট করার কারণে বাদ দেওয়া হয়েছে" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "মূল্যায়ন করা যায় না এমন শর্তযুক্ত প্রোটোটাইপ অ্যাকশনগুলো বাদ দেওয়া হয়েছে" +"connector label background was skipped: style data is missing" = "কানেক্টর লেবেলের ব্যাকগ্রাউন্ড বাদ দেওয়া হয়েছে: স্টাইল ডেটা অনুপস্থিত" +"connector label was skipped: background bounds are invalid" = "কানেক্টর লেবেল বাদ দেওয়া হয়েছে: ব্যাকগ্রাউন্ড বাউন্ড অবৈধ" +"connector label was skipped: background transform is invalid" = "কানেক্টর লেবেল বাদ দেওয়া হয়েছে: ব্যাকগ্রাউন্ড ট্রান্সফর্ম অবৈধ" +"connector label was skipped: composed text transform is invalid" = "কানেক্টর লেবেল বাদ দেওয়া হয়েছে: কম্পোজড টেক্সট ট্রান্সফর্ম অবৈধ" +"connector label was skipped: derived background bounds are missing" = "কানেক্টর লেবেল বাদ দেওয়া হয়েছে: ডিরাইভড ব্যাকগ্রাউন্ড বাউন্ড অনুপস্থিত" +"connector label was skipped: derived text bounds are missing" = "কানেক্টর লেবেল বাদ দেওয়া হয়েছে: ডিরাইভড টেক্সট বাউন্ড অনুপস্থিত" +"connector label was skipped: text bounds are invalid" = "কানেক্টর লেবেল বাদ দেওয়া হয়েছে: টেক্সট বাউন্ড অবৈধ" +"connector label was skipped: text style data is missing" = "কানেক্টর লেবেল বাদ দেওয়া হয়েছে: টেক্সট স্টাইল ডেটা অনুপস্থিত" +"connector label was skipped: text transform is invalid" = "কানেক্টর লেবেল বাদ দেওয়া হয়েছে: টেক্সট ট্রান্সফর্ম অবৈধ" +"connector labels were skipped" = "কানেক্টর লেবেলগুলো বাদ দেওয়া হয়েছে" +"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "কানেক্টর লেবেল উপস্থিত থাকলে স্ট্যাটিক ফ্রেম/ভেক্টর স্ন্যাপশট হিসেবে ইম্পোর্ট হয়; লাইভ এন্ডপয়েন্ট বাইন্ডিং রাখা হয় না" +"connector snapshot vector data is missing; connector was left empty" = "কানেক্টর স্ন্যাপশট ভেক্টর ডেটা অনুপস্থিত; কানেক্টর খালি রাখা হয়েছে" +"connectors whose ends can't be placed are empty" = "যে কানেক্টরের প্রান্তগুলো রাখা যায় না সেগুলো খালি" +"crop marks" = "ক্রপ মার্ক" +"damaged vector geometry was skipped" = "ক্ষতিগ্রস্ত ভেক্টর জিওমেট্রি বাদ দেওয়া হয়েছে" +"damaged vector networks import as outlines" = "ক্ষতিগ্রস্ত ভেক্টর নেটওয়ার্ক আউটলাইন হিসেবে ইম্পোর্ট হয়" +"development: {path}" = "ডেভেলপমেন্ট: {path}" +"document children other than pages were skipped" = "পেজ ছাড়া ডকুমেন্টের অন্যান্য চিলড্রেন বাদ দেওয়া হয়েছে" +"duplicate grid track sizing entries use equal tracks" = "ডুপ্লিকেট গ্রিড ট্র্যাক সাইজিং এন্ট্রিগুলো সমান ট্র্যাক ব্যবহার করে" +"e.g. count + 1" = "যেমন count + 1" +"e.g. count >= 3 and not done" = "যেমন count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "সংরক্ষিত পাথ ছাড়া এলবো এবং কার্ভড কানেক্টর সরল রেখা হিসেবে ইম্পোর্ট হয়" +"entries without a node type were skipped" = "নোটের ধরন ছাড়া এন্ট্রিগুলো বাদ দেওয়া হয়েছে" +"figcraft:// links from a browser, chat or email open this app and join the session." = "ব্রাউজার, চ্যাট বা ইমেল থেকে figcraft:// লিংকগুলো এই অ্যাপ খুলে সেশনে যুক্ত হয়।" +"filled areas of a vector with their own fill use the layer's fill" = "ভেক্টরের নিজস্ব ফিলযুক্ত পূর্ণ এলাকাগুলো লেয়ারের ফিল ব্যবহার করে" +"from {server}" = "{server} থেকে" +"gamepad triggers without exactly one known controller button were skipped" = "সঠিকভাবে একটি পরিচিত কন্ট্রোলার বোতাম ছাড়া গেমপ্যাড ট্রিগার বাদ দেওয়া হয়েছে" +"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "গ্রিড ট্র্যাক তালিকা ১০০০ এন্ট্রি অতিক্রম করেছে; ট্র্যাকগুলো ডিফল্ট সমান সাইজিং ব্যবহার করে" +"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "গ্রিড ট্র্যাক সাইজিং অসমর্থিত বা ত্রুটিপূর্ণ; প্রভাবিত ট্র্যাকগুলো সমান সাইজিং ব্যবহার করে" +"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "গ্রিড ট্র্যাক সাইজিং তালিকা ১০০০ এন্ট্রি অতিক্রম করেছে; ট্র্যাকগুলো ডিফল্ট সমান সাইজিং ব্যবহার করে" +"hidden" = "লুকানো" +"iOS" = "iOS" +"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "ছবির ফিল্টারগুলো দ্ব্যর্থক কারণ উভয় ফিল্টার কন্টেইনার উপস্থিত রয়েছে; ফিল্টার রূপান্তর বাদ দেওয়া হয়েছে" +"image filters use FigCraft's approximate renderer" = "ছবির ফিল্টারগুলো FigCraft-এর অনুমানমূলক রেন্ডারার ব্যবহার করে" +"instance swap properties whose default component isn't in the file were skipped" = "ইনস্ট্যান্স সোয়াপ প্রপার্টি যার ডিফল্ট কম্পোনেন্ট ফাইলে নেই তা বাদ দেওয়া হয়েছে" +"instances inside their own main component were imported as frames" = "নিজের প্রধান কম্পোনেন্টের ভেতরের ইনস্ট্যান্সগুলো ফ্রেম হিসেবে ইম্পোর্ট করা হয়েছে" +"instances of components that aren't in the file show no layers" = "ফাইলে নেই এমন কম্পোনেন্টের ইনস্ট্যান্স কোনো লেয়ার দেখায় না" +"instances of components that aren't in the file were imported as frames" = "ফাইলে নেই এমন কম্পোনেন্টের ইনস্ট্যান্স ফ্রেম হিসেবে ইম্পোর্ট করা হয়েছে" +"instances that would nest a component inside itself were imported as frames" = "যে ইনস্ট্যান্সগুলো কোনো কম্পোনেন্টকে নিজের ভেতরে নেস্ট করত সেগুলোকে ফ্রেম হিসেবে ইম্পোর্ট করা হয়েছে" +"instances without a main component were imported as empty frames" = "প্রধান কম্পোনেন্ট ছাড়া ইনস্ট্যান্সগুলো খালি ফ্রেম হিসেবে ইম্পোর্ট করা হয়েছে" +"invalid internal .fig text path data was ignored" = "অবৈধ অভ্যন্তরীণ .fig টেক্সট পাথ ডেটা উপেক্ষা করা হয়েছে" +"invalid or oversized vector networks import as outlines" = "অবৈধ বা অতিরিক্ত বড় ভেক্টর নেটওয়ার্ক আউটলাইন হিসেবে ইম্পোর্ট হয়" +"invalid or oversized vector path data was skipped" = "অবৈধ বা অতিরিক্ত বড় ভেক্টর পাথ ডেটা বাদ দেওয়া হয়েছে" +"just now" = "এইমাত্র" +"key triggers without a supported key were skipped" = "সমর্থিত কি ছাড়া কি ট্রিগার বাদ দেওয়া হয়েছে" +"key up triggers import as key presses" = "কি আপ ট্রিগার কি প্রেস হিসেবে ইম্পোর্ট হয়" +"layers without a position were placed at their parent's origin" = "অবস্থান ছাড়া লেয়ারগুলো তাদের প্যারেন্টের মূল অবস্থানে রাখা হয়েছে" +"layers without a type were skipped" = "ধরন ছাড়া লেয়ারগুলো বাদ দেওয়া হয়েছে" +"legacy image filter container is unsupported; filter conversion was skipped" = "লিগ্যাসি ইমেজ ফিল্টার কন্টেইনার অসমর্থিত; ফিল্টার রূপান্তর বাদ দেওয়া হয়েছে" +"locked" = "লক করা" +"malformed image filters container was skipped" = "ত্রুটিপূর্ণ ইমেজ ফিল্টার কন্টেইনার বাদ দেওয়া হয়েছে" +"malformed image paintFilter container was skipped" = "ত্রুটিপূর্ণ ইমেজ paintFilter কন্টেইনার বাদ দেওয়া হয়েছে" +"malformed slot content assignment was skipped" = "ত্রুটিপূর্ণ স্লট কন্টেন্ট অ্যাসাইনমেন্ট বাদ দেওয়া হয়েছে" +"no bleed" = "কোনো ব্লিড নেই" +"nobody else here yet" = "এখানে আর কেউ নেই" +"non-default slot settings are not represented and were skipped" = "অ-ডিফল্ট স্লট সেটিংস উপস্থাপিত হয়নি এবং বাদ দেওয়া হয়েছে" +"now" = "এখন" +"only in a text style or variable" = "শুধুমাত্র টেক্সট স্টাইল বা ভেরিয়েবলে" +"or" = "অথবা" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "পয়েন্ট স্ট্রোক জয়েন্ট সংরক্ষিত হয়েছে; ড্যাশড বা পরিবর্তনশীল প্রস্থের স্ট্রোক লেয়ার জয়েন্ট দিয়ে রেন্ডার হয়" +"preferred slot component keys cannot be resolved to file nodes and were skipped" = "পছন্দসই স্লট কম্পোনেন্ট কি ফাইল নোডে সমাধান করা যায়নি এবং বাদ দেওয়া হয়েছে" +"prototype actions of unknown types were skipped" = "অজানা ধরনের প্রোটোটাইপ অ্যাকশন বাদ দেওয়া হয়েছে" +"prototype actions without a destination in the file were skipped" = "ফাইলে গন্তব্য নেই এমন প্রোটোটাইপ অ্যাকশন বাদ দেওয়া হয়েছে" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "ভেরিয়েবল JSON-এ নেই এমন ভেরিয়েবলের উপর প্রোটোটাইপ এক্সপ্রেশন বাদ দেওয়া হয়েছে" +"prototype expressions with unsupported functions were skipped" = "অসমর্থিত ফাংশনযুক্ত প্রোটোটাইপ এক্সপ্রেশন বাদ দেওয়া হয়েছে" +"prototype interactions without supported actions were skipped" = "সমর্থিত অ্যাকশন ছাড়া প্রোটোটাইপ ইন্টারঅ্যাকশন বাদ দেওয়া হয়েছে" +"prototype navigations of unknown types were skipped" = "অজানা ধরনের প্রোটোটাইপ নেভিগেশন বাদ দেওয়া হয়েছে" +"prototype transitions of unknown types play instantly" = "অজানা ধরনের প্রোটোটাইপ ট্রানজিশন তাৎক্ষণিকভাবে চলে" +"prototype triggers of unknown types were skipped" = "অজানা ধরনের প্রোটোটাইপ ট্রিগার বাদ দেওয়া হয়েছে" +"put a widget on the canvas" = "ক্যানভাসে একটি উইজেট রাখুন" +"read and edit the document" = "ডকুমেন্ট পড়ুন এবং এডিট করুন" +"read the document" = "ডকুমেন্ট পড়ুন" +"reads the document" = "ডকুমেন্ট পড়ে" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "ভেরিয়েবল JSON-এ নেই এমন ভেরিয়েবলের উপর set-variable অ্যাকশন বাদ দেওয়া হয়েছে" +"set-variable actions with values that can't be evaluated were skipped" = "মূল্যায়ন করা যায় না এমন মানযুক্ত set-variable অ্যাকশন বাদ দেওয়া হয়েছে" +"set-variable actions with values that don't fit the variable were skipped" = "ভেরিয়েবলের সাথে মেলে না এমন মানযুক্ত set-variable অ্যাকশন বাদ দেওয়া হয়েছে" +"set-variable actions without a value were skipped" = "মান ছাড়া set-variable অ্যাকশন বাদ দেওয়া হয়েছে" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "ভেরিয়েবল JSON-এ নেই এমন সংগ্রহ বা মোডের উপর set-variable-mode অ্যাকশন বাদ দেওয়া হয়েছে" +"slot content assignment target is missing or is not slot content" = "স্লট কন্টেন্ট অ্যাসাইনমেন্ট লক্ষ্য অনুপস্থিত অথবা স্লট কন্টেন্ট নয়" +"slot content assignment uses the empty GUID sentinel and was skipped" = "স্লট কন্টেন্ট অ্যাসাইনমেন্ট খালি GUID সেন্টিমেল ব্যবহার করে এবং বাদ দেওয়া হয়েছে" +"slot layers without a matching slot property were imported as frames" = "মিল রয়েছে এমন স্লট প্রপার্টি ছাড়া স্লট লেয়ারগুলো ফ্রেম হিসেবে ইম্পোর্ট করা হয়েছে" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "কিছু ভেক্টর পয়েন্ট ফাইল থেকে অনুপস্থিত পয়েন্ট সেটিংস উল্লেখ করে; সেগুলো লেয়ারের জয়েন্ট এবং সাধারণ কোণা ব্যবহার করে" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "কিছু ভেক্টর সেগমেন্ট ফাইল থেকে অনুপস্থিত স্টাইল সেটিংস উল্লেখ করে; সেগুলো লেয়ারের স্ট্রোক ব্যবহার করে" +"stale or invalid grid track sizing entries were ignored" = "পুরোনো বা অবৈধ গ্রিড ট্র্যাক সাইজিং এন্ট্রি উপেক্ষা করা হয়েছে" +"stars and polygons without point counts or geometry use the default point count" = "পয়েন্ট সংখ্যা বা জিওমেট্রি ছাড়া তারকা এবং বহুভুজ ডিফল্ট পয়েন্ট সংখ্যা ব্যবহার করে" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "কিছু ভেক্টর সেগমেন্টের স্টাইল সেটিংস সমর্থিত নয়; সেই সেগমেন্টগুলো লেয়ারের স্ট্রোক ব্যবহার করে" +"text path underline or strikethrough decoration is not rendered" = "টেক্সট পাথ আন্ডারলাইন বা স্ট্রাইকথ্রু সজ্জা রেন্ডার হয় না" +"text style overrides refer to missing table entries" = "টেক্সট স্টাইল ওভাররাইড অনুপস্থিত টেবিল এন্ট্রি নির্দেশ করে" +"the file has no pages" = "ফাইলে কোনো পেজ নেই" +"the plugin" = "প্লাগইনটি" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "ভেরিয়েবল JSON-এ কোনো `variableCollections` নেই (স্থানীয় ভেরিয়েবল প্রতিক্রিয়া প্রত্যাশিত ছিল) এবং উপেক্ষা করা হয়েছে" +"this session only" = "শুধুমাত্র এই সেশন" +"unknown component property types were skipped" = "অজানা কম্পোনেন্ট প্রপার্টি ধরন বাদ দেওয়া হয়েছে" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "ভেরিয়েবল JSON-এ নেই (বা অন্য ধরনের) এমন ভেরিয়েবলের ভেরিয়েবল অ্যালিয়াস বাদ দেওয়া হয়েছে" +"variable bindings to properties the layer doesn't have were skipped" = "লেয়ারের নেই এমন প্রপার্টির ভেরিয়েবল বাইন্ডিং বাদ দেওয়া হয়েছে" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "ভেরিয়েবল JSON-এ নেই (লাইব্রেরি ভেরিয়েবল) এমন ভেরিয়েবলের ভেরিয়েবল বাইন্ডিং বাদ দেওয়া হয়েছে" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "ভেরিয়েবল বাইন্ডিং বাদ দেওয়া হয়েছে: ফাইল JSON-এ কোনো ভেরিয়েবল সংজ্ঞা নেই (স্থানীয় ভেরিয়েবল JSON কে `variables` হিসেবে পাস করুন, অথবা ফাইল JSON এর পাশে এর `meta` এমবেড করুন)" +"variable bindings whose variable type doesn't fit the property were skipped" = "প্রপার্টির সাথে মানানসই নয় এমন ভেরিয়েবল ধরনের ভেরিয়েবল বাইন্ডিং বাদ দেওয়া হয়েছে" +"variable collections without modes were skipped" = "মোড ছাড়া ভেরিয়েবল সংগ্রহ বাদ দেওয়া হয়েছে" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "প্রোটোটাইপ এক্সপ্রেশনের ভেরিয়েবল মোড লুকআপ যা কোনো ভেরিয়েবল এবং এর একটি মোড নয় তা বাদ দেওয়া হয়েছে" +"variable modes of collections that aren't in the file were skipped" = "ফাইলে নেই এমন সংগ্রহের ভেরিয়েবল মোড বাদ দেওয়া হয়েছে" +"variable modes of collections that aren't in the variables JSON were skipped" = "ভেরিয়েবল JSON-এ নেই এমন সংগ্রহের ভেরিয়েবল মোড বাদ দেওয়া হয়েছে" +"variable values that don't match their type (or composed colours) were skipped" = "তাদের ধরনের সাথে মেলে না এমন ভেরিয়েবল মান বাদ দেওয়া হয়েছে" +"variables of unknown types were skipped" = "অজানা ধরনের ভেরিয়েবল বাদ দেওয়া হয়েছে" +"variables whose collection isn't in the variables JSON were skipped" = "সংগ্রহ ভেরিয়েবল JSON-এ নেই এমন ভেরিয়েবল বাদ দেওয়া হয়েছে" +"variables without readable values were skipped" = "পড়ার উপযোগী মান নেই এমন ভেরিয়েবল বাদ দেওয়া হয়েছে" +"vector points with unknown stroke joins use the layer join" = "অজানা স্ট্রোক জয়েন্টযুক্ত ভেক্টর পয়েন্ট লেয়ার জয়েন্ট ব্যবহার করে" +"vectors without geometry are empty" = "জিওমেট্রি ছাড়া ভেক্টর খালি" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "জিওমেট্রি ছাড়া ভেক্টর খালি (geometry=paths সহ ফাইল JSON এক্সপোর্ট করুন)" +"very long text was cut to 1,048,576 characters" = "খুব দীর্ঘ টেক্সট কেটে ১,০৪৮,৫৭৬ অক্ষরে ছোট করা হয়েছে" +"very long vector paths were skipped" = "খুব দীর্ঘ ভেক্টর পাথ বাদ দেওয়া হয়েছে" +"video actions are not supported and were skipped" = "ভিডিও অ্যাকশন সমর্থিত নয় এবং বাদ দেওয়া হয়েছে" +"video triggers are not supported and were skipped" = "ভিডিও ট্রিগার সমর্থিত নয় এবং বাদ দেওয়া হয়েছে" +"yesterday" = "গতকাল" +"{instances} in {files}" = "{files}-এ {instances}টি ইনস্ট্যান্স" +"{language}: draft translation, waiting for a native speaker's review." = "{language}: ড্রাফট অনুবাদ, স্থানীয় ভাষাভাষীর পর্যালোচনার অপেক্ষায়।" +"{length} bleed" = "{length} ব্লিড" +"{month} {day}, {year}" = "{day} {month}, {year}" +"{name} cleared from the list" = "তালিকা থেকে {name} মুছে ফেলা হয়েছে" +"{name} is forgotten on this computer; the file stays on {server}" = "এই কম্পিউটার থেকে {name} মুছে ফেলা হয়েছে; ফাইলটি {server}-এ রয়ে গেছে" +"{name} is on the server." = "{name} সার্ভারে আছে।" +"{name} is spotlighting" = "{name} স্পটলাইট করছে" +"{name}\nClick to apply to the selection" = "{name}\nনির্বাচনে প্রয়োগ করতে ক্লিক করুন" +"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nক্যানভাসে ড্র্যাগ করুন, অথবা ভিউর মাঝখানে রাখতে ক্লিক করুন।\nকোনো ইনস্ট্যান্সে ড্রপ করার সময় সোয়াপ করতে Alt (Option) চেপে ধরে রাখুন।" +"{name} (from {library})" = "{name} ({library} থেকে)" +"{name} (you)" = "{name} (আপনি)" +"{name} ({layers} layers)" = "{name} ({layers}টি লেয়ার)" +"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name} ({tag}): {min} – {max}, ডিফল্ট {default}. স্টাইলের মানে ফিরে যেতে স্লাইডারে ডাবল-ক্লিক করুন; ড্র্যাগ করার সময় Escape টিপলে বাতিল হবে।" +"{name} from {path}" = "{path} থেকে {name}" +"{name} was removed from the server" = "সার্ভার থেকে {name} সরানো হয়েছে" +"{name} · deleted on main" = "{name} · প্রধান (main)-এ ডিলিট করা হয়েছে" +"{name} · deleted on the branch" = "{name} · ব্রাঞ্চে ডিলিট করা হয়েছে" +"{name} · not published" = "{name} · প্রকাশিত নয়" +"{name} · on a server" = "{name} · একটি সার্ভারে" +"{name} · published version {v}" = "{name} · প্রকাশিত সংস্করণ {v}" +"{name} · version {v} on the server" = "{name} · সার্ভারে সংস্করণ {v}" +"{name}, in a live session" = "{name}, লাইভ সেশনে" +"{name}, online, open only to you" = "{name}, অনলাইনে, শুধুমাত্র আপনার জন্য খোলা" +"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, নির্বাচিত" +"{n} added" = { one = "{n}টি যোগ করা হয়েছে", other = "{n}টি যোগ করা হয়েছে" } +"{n} autosave versions" = { one = "{n}টি স্বয়ংক্রিয় সেভ সংস্করণ", other = "{n}টি স্বয়ংক্রিয় সেভ সংস্করণ" } +"{n} changed" = { one = "{n}টি পরিবর্তিত হয়েছে", other = "{n}টি পরিবর্তিত হয়েছে" } +"{n} columns" = { one = "{n}টি কলাম", other = "{n}টি কলাম" } +"{n} components" = { one = "{n}টি কম্পোনেন্ট", other = "{n}টি কম্পোনেন্ট" } +"{n} components and styles have newer versions" = { one = "{n}টি কম্পোনেন্ট বা স্টাইলের নতুন সংস্করণ রয়েছে", other = "{n}টি কম্পোনেন্ট এবং স্টাইলের নতুন সংস্করণ রয়েছে" } +"{n} conflicts to resolve" = { one = "{n}টি দ্বন্দ্ব সমাধান করতে হবে", other = "{n}টি দ্বন্দ্ব সমাধান করতে হবে" } +"{n} days ago" = { one = "{n} দিন আগে", other = "{n} দিন আগে" } +"{n} files" = { one = "{n}টি ফাইল", other = "{n}টি ফাইল" } +"{n} hours ago" = { one = "{n} ঘণ্টা আগে", other = "{n} ঘণ্টা আগে" } +"{n} instances" = { one = "{n}টি ইনস্ট্যান্স", other = "{n}টি ইনস্ট্যান্স" } +"{n} layers" = { one = "{n}টি লেয়ার", other = "{n}টি লেয়ার" } +"{n} layers as one file" = { one = "{n}টি লেয়ার একটি ফাইল হিসেবে", other = "{n}টি লেয়ার একটি ফাইল হিসেবে" } +"{n} layers selected" = { one = "{n}টি লেয়ার নির্বাচিত", other = "{n}টি লেয়ার নির্বাচিত" } +"{n} layers · {parts}" = { one = "{n}টি লেয়ার · {parts}", other = "{n}টি লেয়ার · {parts}" } +"{n} matches" = { one = "{n}টি মিল", other = "{n}টি মিল" } +"{n} minutes ago" = { one = "{n} মিনিট আগে", other = "{n} মিনিট আগে" } +"{n} months ago" = { one = "{n} মাস আগে", other = "{n} মাস আগে" } +"{n} more notes omitted from this report" = { one = "এই রিপোর্ট থেকে আরও {n}টি নোট বাদ দেওয়া হয়েছে", other = "এই রিপোর্ট থেকে আরও {n}টি নোট বাদ দেওয়া হয়েছে" } +"{n} of {total}" = "{total}-এর মধ্যে {n}" +"{n} people joined" = { one = "{n} জন যুক্ত হয়েছেন", other = "{n} জন যুক্ত হয়েছেন" } +"{n} preferred components" = { one = "{n}টি পছন্দের কম্পোনেন্ট", other = "{n}টি পছন্দের কম্পোনেন্ট" } +"{n} ready" = { one = "{n}টি প্রস্তুত", other = "{n}টি প্রস্তুত" } +"{n} removed" = { one = "{n}টি সরানো হয়েছে", other = "{n}টি সরানো হয়েছে" } +"{n} replies" = { one = "{n}টি উত্তর", other = "{n}টি উত্তর" } +"{n} rows" = { one = "{n}টি সারি", other = "{n}টি সারি" } +"{n} styles" = { one = "{n}টি স্টাইল", other = "{n}টি স্টাইল" } +"{n} text layers" = { one = "{n}টি টেক্সট লেয়ার", other = "{n}টি টেক্সট লেয়ার" } +"{n} variables" = { one = "{n}টি ভেরিয়েবল", other = "{n}টি ভেরিয়েবল" } +"{n} weeks ago" = { one = "{n} সপ্তাহ আগে", other = "{n} সপ্তাহ আগে" } +"{n} years ago" = { one = "{n} বছর আগে", other = "{n} বছর আগে" } +"{n}d" = "{n}d" +"{n}h" = "{n}h" +"{n}m" = "{n}m" +"{n}w" = "{n}w" +"{page}, nothing selected" = "{page}, কিছু নির্বাচিত নেই" +"{profile} (current)" = "{profile} (বর্তমান)" +"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} হলো এর মান; {property ? \"a\" : \"b\"} একটি স্ট্রিং বাছে। অন্যান্য {…} যেমন লেখা আছে তেমনই থাকে।" +"{server} can't say what became of your files (it is older than this app)." = "{server} বলতে পারছে না আপনার ফাইলের কী হয়েছে (এটি এই অ্যাপের চেয়ে পুরনো)।" +"{server} · No longer on the server" = "{server} · আর সার্ভারে নেই" +"{server} · {shared} · Opened {date}" = "{server} · {shared} · খোলা হয়েছে {date}" +"{tool} hold {key}" = "{tool} চেপে ধরে রাখুন {key}" +"{value} isn't a colour" = "{value} কোনো রঙ নয়" +"{when} · edited" = "{when} · এডিট করা হয়েছে" +"← ↑ → ↓" = "← ↑ → ↓" +"← → ↑ ↓" = "← → ↑ ↓" +"←" = "←" +"→, Space or N" = "→, স্পেস বা N" +"↵" = "↵" +"⇥ / ⇧⇥" = "⇥ / ⇧⇥" +"⌘ arrows" = "⌘ অ্যারো" +"⌘ click" = "⌘ ক্লিক" +"⌘ scroll" = "⌘ স্ক্রল" +"⌥ drag" = "⌥ ড্র্যাগ" From 4506f49ad4297ec96576989fa80b39a2464f0b61 Mon Sep 17 00:00:00 2001 From: Muamer Mustafic Date: Sun, 18 Oct 2026 15:21:28 +0200 Subject: [PATCH 011/114] M3: removing auto layout drops its gap and padding bindings (#810) layout.remove and node.set {layout: null|"none"} now drop the bindings of auto layout fields (gap, crossGap, padding...), as frame-to-section already did for some of them (it now drops all of them). Re-applying a binding no longer creates an auto layout on a frame without one: the binding waits for the next auto layout. Closes #810 Co-authored-by: Cursor --- crates/engine/src/cmd/props.rs | 16 +++++++++-- crates/engine/src/cmd/structure.rs | 6 ++-- crates/engine/src/cmd/styles.rs | 5 ++++ .../engine/src/variables_behaviour_tests.rs | 28 +++++++++++++++++++ 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 8a1f4e1cd..d802e02fa 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -297,6 +297,12 @@ pub const LAYOUT_KEYS: &[&str] = &[ "gridRowTracks", ]; +/// Drop the bindings of auto layout fields (gap, padding…): they go with the auto layout, or +/// re-applying them would bring it back. +pub fn drop_layout_bindings(n: &mut Node) { + n.bound.retain(|k, _| !LAYOUT_KEYS.contains(&k.as_str())); +} + /// The auto layout of a frame-like node, created when it has none yet. Other nodes can't hold an /// auto layout: an auto layout key on them is refused, not stored where nothing reads it /// (`node.set` on several layers skips them instead, see `edit::set`). @@ -486,8 +492,14 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map n.layout = None, - Value::String(s) if s.eq_ignore_ascii_case("none") => n.layout = None, + Value::Null => { + n.layout = None; + drop_layout_bindings(n); + } + Value::String(s) if s.eq_ignore_ascii_case("none") => { + n.layout = None; + drop_layout_bindings(n); + } Value::String(s) => { let mode = match s.to_ascii_lowercase().as_str() { "horizontal" | "row" => LayoutMode::Horizontal, diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index 75ddac3d8..0f29069db 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -71,7 +71,9 @@ pub fn specs() -> Vec { // Hugging needs auto layout and the children that filled have nothing to fill: both // become fixed (the frame keeps filling its own auto layout parent). let doc = s.doc_mut()?; - doc.get_mut(i)?.layout = None; + let n = doc.get_mut(i)?; + n.layout = None; + super::props::drop_layout_bindings(n); figcraft_layout::coherent_sizing(doc, i); } ok() @@ -285,7 +287,7 @@ fn convert_to_section(s: &mut Session, p: &Value) -> Result { n.grids.clear(); n.guides.clear(); n.styles.grid = None; - n.bound.retain(|k, _| !k.starts_with("padding") && !matches!(k.as_str(), "gap" | "counterAxisSpacing")); + super::props::drop_layout_bindings(n); let kids = n.children.clone(); for k in kids { // Out of the flow there is nothing to fill or to be absolute against. diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index adeb22506..649ee033b 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -1021,6 +1021,7 @@ fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::B let modes = doc.modes_for(id); let Some(n) = doc.node(id) else { continue }; let binds = n.bound.clone(); + let has_layout = n.layout.is_some(); let mut sets = serde_json::Map::new(); let mut effect_sets: Vec<(usize, String, VariableValue)> = Vec::new(); let mut paint_sets: Vec<(bool, usize, Option, Color)> = Vec::new(); @@ -1030,6 +1031,10 @@ fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::B if !include(&field) { continue; } + // A binding never creates an auto layout: without one it waits for the next. + if !has_layout && super::props::LAYOUT_KEYS.contains(&field.as_str()) { + continue; + } let Some(val) = doc.variables.resolve(&var, &modes) else { continue }; // One list item's value: a paint's colour, an effect's or a layout grid's field. if let Some((list, i, prop)) = list_binding(&field) { diff --git a/crates/engine/src/variables_behaviour_tests.rs b/crates/engine/src/variables_behaviour_tests.rs index 5a868a637..c5912959e 100644 --- a/crates/engine/src/variables_behaviour_tests.rs +++ b/crates/engine/src/variables_behaviour_tests.rs @@ -279,6 +279,34 @@ fn bindings_by_field_and_type() { } } +#[test] +fn auto_layout_bindings_go_with_the_auto_layout() { + // Docs silent; pinned (#810): removing a frame's auto layout drops its gap and padding bindings, + // and a binding never brings an auto layout back. One made without auto layout waits for it. + let mut s = Session::new(); + run(&mut s, "variable.createCollection", json!({"name": "C"})); + run(&mut s, "variable.create", json!({"collection": "C", "name": "g", "type": "number", "value": 12})); + let removals = + [("layout.remove", json!({})), ("node.set", json!({"props": {"layout": null}})), ("node.set", json!({"props": {"layout": "none"}}))]; + for (cmd, mut p) in removals { + let f = frame(&mut s, None); + run(&mut s, "layout.add", json!({"ids": [f]})); + for field in ["gap", "crossGap", "paddingTop"] { + run(&mut s, "variable.bind", json!({"ids": [f], "field": field, "variable": "g"})); + } + p["ids"] = json!([f]); + run(&mut s, cmd, p.clone()); + let n = node(&s, f); + assert!(n.layout.is_none(), "{cmd} {p}: auto layout stays removed"); + assert!(n.bound.is_empty(), "{cmd} {p}: layout bindings dropped"); + } + let f = frame(&mut s, None); + run(&mut s, "variable.bind", json!({"ids": [f], "field": "gap", "variable": "g"})); + assert!(node(&s, f).layout.is_none(), "binding gap adds no auto layout"); + run(&mut s, "layout.add", json!({"ids": [f]})); + assert_eq!(node(&s, f).layout.map(|l| l.gap), Some(12.0), "the waiting binding applies to the new auto layout"); +} + #[test] fn deleting_a_bound_variable_keeps_the_last_value() { // Documented: deleting a variable unbinds what used it, which keeps showing its last value. From bbf0d62863a4a82ef14b8efe48fa4c0fa0b7e226 Mon Sep 17 00:00:00 2001 From: Al Rifat Sabbir <150162343+alrifatsabbir@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:43:17 +0600 Subject: [PATCH 012/114] Update Bengali translations in bn.toml Reviewed and updated bugs. --- crates/ui-egui/locales/bn.toml | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index 7cf7dcb92..adedc983a 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -1,18 +1,15 @@ -# FigCraft interface strings: English, the source language. +# FigCraft interface strings: বাংলা, ইংরেজি থেকে অনূদিত। # -# Keys are the English text as the code writes it (`tr("…")`, `trf`, `trn`, `tr_noop`); values are -# the same text, except plural entries ({ one, other }). Command labels come from the engine. -# `cargo test -p figcraft-ui-egui i18n` lists missing and unused keys. See docs/translating.md. +# কীগুলো কোডে ব্যবহৃত ইংরেজি টেক্সট (`tr("…")`, `trf`, `trn`, `tr_noop`); মানগুলো অনূদিত, +# বহুবচন এন্ট্রি ({ one, other }) ছাড়া। কমান্ডের লেবেল ইঞ্জিন থেকে আসে। +# `cargo test -p figcraft-ui-egui i18n` অনুপস্থিত ও অব্যবহৃত কী তালিকাভুক্ত করে। docs/translating.md দেখুন। -[meta] -name = "বাংলা" -status = "draft — needs native review" [meta] name = "বাংলা" status = "draft — needs native review" [ui] -"% of all commits" = "% of all commits" +"% of all commits" = "সমস্ত কমিটের শতাংশ" "(no longer published)" = "(প্রকাশিত নয়)" "({n} warnings: some entries were skipped)" = { one = "({n}টি সতর্কতা: কিছু এন্ট্রি বাদ দেওয়া হয়েছে)", other = "({n}টি সতর্কতা: কিছু এন্ট্রি বাদ দেওয়া হয়েছে)" } "+ Create variable" = "+ ভেরিয়েবল তৈরি করুন" From b778ba17cd89b10ec2a309ebcdc69ef06f9db918 Mon Sep 17 00:00:00 2001 From: Muamer Mustafic Date: Sun, 18 Oct 2026 16:08:12 +0200 Subject: [PATCH 013/114] M1: copying text layers puts their text on the clipboard (#808) edit.copy and edit.cut mirror a selection made only of text layers to the system clipboard as plain text, one layer per line, instead of SVG. Other selections stay SVG; pasting back into FigCraft still pastes the copied layers. Closes #808 Co-authored-by: Cursor --- crates/ui-egui/src/menus.rs | 18 +++++++++++++----- crates/ui-egui/src/web_clipboard_tests.rs | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e32f50c88..d34b609b5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -206,12 +206,13 @@ pub fn invoke(app: &mut FigApp, id: &str) { "collab.publish" => crate::live::open_publish(app), "edit.copy" | "edit.cut" => { let ids = app.session.selection(); - // SVG for other apps, taken before a cut removes the layers. - let svg = app.session.doc().ok().and_then(|d| figcraft_svg_export(d, &ids)); + // For other apps: text layers' text, other layers as SVG; taken before a cut removes them. + let doc = app.session.doc().ok(); + let mirror = doc.and_then(|d| text_layers_text(d, &ids).or_else(|| figcraft_svg_export(d, &ids))); app.run(id, json!({})); - // Mirror to the system clipboard (SVG text, else a marker) and remember it, so a later - // paste knows whether the clipboard still holds our layers. - let text = svg.unwrap_or_else(|| format!("figcraft://clipboard/{}", ids.len())); + // Mirror to the system clipboard (text or SVG, else a marker) and remember it, so a + // later paste knows whether the clipboard still holds our layers. + let text = mirror.unwrap_or_else(|| format!("figcraft://clipboard/{}", ids.len())); app.services.clipboard_set_text(&text); app.ui.last_copy = Some(text); } @@ -239,6 +240,13 @@ fn export_frames_pdf(app: &mut FigApp) { }); } +/// The text of a selection made only of text layers, one layer per line (None for any other +/// selection, or when there is no text). +fn text_layers_text(doc: &figcraft_doc::Document, ids: &[figcraft_doc::NodeId]) -> Option { + let texts: Option> = ids.iter().map(|i| doc.node(*i).and_then(|n| n.kind.text()).map(|t| t.characters.as_str())).collect(); + texts.map(|t| t.join("\n")).filter(|t| !t.is_empty()) +} + fn figcraft_svg_export(doc: &figcraft_doc::Document, ids: &[figcraft_doc::NodeId]) -> Option { let bytes = figcraft_engine::cmd::file::export_bytes(doc, ids, figcraft_doc::ExportFormat::Svg, 1.0).ok()?; String::from_utf8(bytes).ok() diff --git a/crates/ui-egui/src/web_clipboard_tests.rs b/crates/ui-egui/src/web_clipboard_tests.rs index 26389ff48..bda57373b 100644 --- a/crates/ui-egui/src/web_clipboard_tests.rs +++ b/crates/ui-egui/src/web_clipboard_tests.rs @@ -141,6 +141,24 @@ fn text_copied_elsewhere_after_our_copy_still_pastes_as_text() { assert_eq!(p.count(text), 1, "newer text from elsewhere becomes a text layer"); } +#[test] +fn copying_text_layers_puts_their_text_on_the_clipboard() { + // #808: other apps get a text layer's text, not its SVG; FigCraft still pastes the layers. + let mut p = Page::new(); + let a = p.app.session.execute("text.create", &json!({"text": "Hello", "x": 0, "y": 200})).unwrap(); + let b = p.app.session.execute("text.create", &json!({"text": "World", "x": 0, "y": 300})).unwrap(); + p.app.session.execute("select.set", &json!({"ids": [a["id"], b["id"]]})).unwrap(); + p.frame(vec![]); + p.copy(); + assert_eq!(p.clipboard, "Hello\nWorld", "one line per text layer"); + p.paste(); + assert_eq!(p.count(text), 4, "the copied text layers were pasted, not one layer of their text"); + let r = p.app.session.doc().unwrap().nodes().find(|n| rect(&n.kind)).map(|n| n.id.0).unwrap(); + p.app.session.execute("select.set", &json!({"ids": [r, a["id"]]})).unwrap(); + p.copy(); + assert!(p.clipboard.contains(" Date: Sun, 18 Oct 2026 17:13:49 +0200 Subject: [PATCH 014/114] M4: text.rescanFonts and text.loadFont: fonts installed while the app runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The system fonts are indexed once per process, so a font installed while FigCraft (or `figcraft-cli mcp`) runs stayed missing until a restart. - text.rescanFonts walks the font folders again and offers the families installed since (loaded families keep their faces) → {added} - text.loadFont {path | data} adds a font file without installing it → {families} - both clear the layout cache so text lays out again in the new face Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/create.rs | 4 +-- crates/engine/src/cmd/text.rs | 30 ++++++++++++++++++ crates/engine/src/tests.rs | 19 ++++++++++++ crates/engine/src/textedit.rs | 3 +- crates/text/src/fontdb.rs | 54 +++++++++++++++++++++++++++++++++ crates/text/src/fontdb_tests.rs | 31 +++++++++++++++++++ crates/text/src/lib.rs | 2 +- docs/mcp.md | 4 ++- 8 files changed, 142 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 3a9f6d67f..65c526e4b 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -363,12 +363,12 @@ fn free_spot(s: &Session, w: f64, h: f64) -> Result<(f64, f64)> { } #[cfg(not(target_arch = "wasm32"))] -fn read_path(path: &str) -> Result> { +pub(super) fn read_path(path: &str) -> Result> { crate::file_access::read(path) } #[cfg(target_arch = "wasm32")] -fn read_path(path: &str) -> Result> { +pub(super) fn read_path(path: &str) -> Result> { Err(crate::EngineError::Other(format!("{path}: files can't be read in the browser"))) } diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 3b861895a..129c8ead1 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -474,5 +474,35 @@ pub fn specs() -> Vec { }) } ), + spec!( + "text.rescanFonts", + "Rescan installed fonts", + "{} walks the system font folders again, for fonts installed while the app runs → {added: [families that were unknown]}", + false, + |_, _| Ok(json!({"added": figcraft_text::rescan_system_fonts()})) + ), + spec!( + "text.loadFont", + "Load font file", + "{path | data (base64)} adds a font file (.ttf, .otf, .ttc, .otc; every face of a collection) to this session's fonts without installing it → {families}", + false, + |_, p| { + let cmd = "text.loadFont"; + let bytes = if let Some(b64) = string(p, "data") { + use base64::Engine as _; + let b64 = b64.split_once(',').map(|(_, d)| d.to_string()).unwrap_or(b64); + base64::engine::general_purpose::STANDARD.decode(b64.trim()).map_err(|e| bad(cmd, format!("`data`: {e}")))? + } else if let Some(path) = string(p, "path") { + super::create::read_path(&path)? + } else { + return Err(bad(cmd, "pass `path` or `data` (base64)")); + }; + let families = figcraft_text::load_font_bytes(bytes); + if families.is_empty() { + return Err(bad(cmd, "not a font file")); + } + Ok(json!({"families": families})) + } + ), ] } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d8ebfc9e6..7cfa320b7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -434,6 +434,8 @@ fn every_command_survives_junk_params() { | "file.placeImage" | "image.place" | "file.revert" + // Takes no params and walks the font folders: one walk per junk is slow. + | "text.rescanFonts" ) { continue; } @@ -624,3 +626,20 @@ fn find_replace_changing_only_the_case_replaces_every_occurrence() { assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "FigCraft and FigCraft, FigCraft"); assert_eq!(s.doc().unwrap().node(f).unwrap().name, "FigCraft / FigCraft"); } + +#[test] +fn fonts_load_from_files_and_rescans_report_new_families() { + let mut s = sess(); + assert!(s.execute("text.loadFont", &json!({})).is_err()); + assert!(s.execute("text.loadFont", &json!({"data": "bm90IGEgZm9udA=="})).is_err(), "not a font file"); + assert!(s.execute("text.loadFont", &json!({"data": "%%%"})).is_err()); + assert!(s.execute("text.loadFont", &json!({"path": "no-such-dir/x.ttf"})).is_err()); + if let Some(f) = figcraft_text::CRAFT_FONTS.first() { + use base64::Engine as _; + let data = base64::engine::general_purpose::STANDARD.encode(f.bytes); + let r = s.execute("text.loadFont", &json!({"data": data})).unwrap(); + assert_eq!(r["families"].as_array().map(Vec::len), Some(1)); + } + let r = s.execute("text.rescanFonts", &json!({})).unwrap(); + assert!(r["added"].is_array()); +} diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index 76b353a00..38d6a1996 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -793,7 +793,8 @@ mod tests { json!({"variations": {"wght": "heavy", "toolong": 1}, "text": "\u{0}\u{7}", "cursor": 1e300}), json!({"variations": {"wght": 1e300, "opsz": -1e300}, "replace": "yes", "text": "日本語", "cursor": -1}), ]; - for spec in crate::cmd::all_commands().iter().filter(|c| c.id.starts_with("text.")) { + // `text.rescanFonts` takes no params and walks the font folders: one walk per junk is slow. + for spec in crate::cmd::all_commands().iter().filter(|c| c.id.starts_with("text.") && c.id != "text.rescanFonts") { for j in &junk { let (mut s, id) = setup("Hi there\nyou"); begin(&mut s, id, Some(2), false).unwrap(); diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index f9eda2c6d..aa5777b5e 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -670,6 +670,23 @@ impl FontDb { self.faces.len() } + /// Offer the families of a later walk of the font folders: new families become pending, and a + /// family not loaded yet takes the walk's files (styles installed since). Loaded families keep + /// their faces. Returns the display names of the families that were unknown. + fn merge_rescan(&mut self, found: BTreeMap)>) -> Vec { + let mut added = Vec::new(); + for (k, v) in found { + if self.families.contains_key(&k) { + continue; + } + if !self.pending.contains_key(&k) { + added.push(v.0.clone()); + } + self.pending.insert(k, v); + } + added + } + /// Index the system font directories (desktop only): family names are read now, faces loaded /// when first used. Safe to call more than once. pub fn scan_system(&mut self) { @@ -887,6 +904,43 @@ pub fn scan_system_fonts() { }); } +/// Walk the system font folders again, for fonts installed while the app runs (the first scan is +/// once per process). Returns the families that were unknown before. Before the first scan this +/// is that scan; a no-op on the web or with `FIGCRAFT_NO_SYSTEM_FONTS` set. +pub fn rescan_system_fonts() -> Vec { + #[cfg(not(target_arch = "wasm32"))] + if !system_fonts_opted_out() { + if SCANNED.get().is_none() { + let before: std::collections::BTreeSet = + raw_db().read().unwrap_or_else(std::sync::PoisonError::into_inner).families().into_iter().collect(); + scan_system_fonts(); + return family_names().into_iter().filter(|f| !before.contains(f)).collect(); + } + let mut found: BTreeMap)> = BTreeMap::new(); + for dir in system_font_dirs() { + scan_dir(&dir, 0, &mut found); + } + let added = raw_db().write().unwrap_or_else(std::sync::PoisonError::into_inner).merge_rescan(found); + if !added.is_empty() { + crate::clear_layout_cache(); + } + return added; + } + Vec::new() +} + +/// Load font file bytes (every face of a collection) into the global db, for a font that isn't +/// installed. Returns the families of the faces added (none: not a font file). +pub fn load_font_bytes(bytes: Vec) -> Vec { + let faces = db().write().unwrap_or_else(std::sync::PoisonError::into_inner).add_bytes(bytes); + if !faces.is_empty() { + crate::clear_layout_cache(); + } + let mut families: Vec = faces.iter().map(|f| f.family.clone()).collect(); + families.dedup(); + families +} + /// `FIGCRAFT_NO_SYSTEM_FONTS` is set (and not `0`): renders use only the embedded fonts. #[cfg(not(target_arch = "wasm32"))] fn system_fonts_opted_out() -> bool { diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs index 96ce78426..3574fdfba 100644 --- a/crates/text/src/fontdb_tests.rs +++ b/crates/text/src/fontdb_tests.rs @@ -217,3 +217,34 @@ fn user_font_dirs_preserve_non_utf8_paths() { assert_eq!(dirs[0].as_os_str().as_bytes(), b"/home/\xff/.fonts"); assert_eq!(dirs[1].as_os_str().as_bytes(), b"/data/\xfe/fonts"); } + +#[test] +fn a_rescan_offers_fonts_installed_since() { + let entry = |s: &str| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: s.into() }; + let mut db = pending(&["Regular"]); + let found = |fams: &[(&str, &[&str])]| -> BTreeMap)> { + fams.iter().map(|(f, styles)| (f.to_lowercase(), ((*f).to_string(), styles.iter().map(|s| entry(s)).collect()))).collect() + }; + // A new family is reported and offered; a pending one takes the walk's files (a new style). + let added = db.merge_rescan(found(&[("CJK UI", &["Regular", "Bold"]), ("Brand Sans", &["Regular"])])); + assert_eq!(added, ["Brand Sans"]); + assert!(db.has_family("brand sans")); + assert_eq!(left(&db), ["Regular", "Bold"]); + // A loaded family keeps its faces and isn't reported again. + db.families.insert("brand sans".into(), ("Brand Sans".into(), Vec::new())); + db.pending.remove("brand sans"); + assert!(db.merge_rescan(found(&[("Brand Sans", &["Regular"])])).is_empty()); + assert!(!db.pending.contains_key("brand sans")); +} + +#[test] +fn font_bytes_load_into_the_db() { + assert!(load_font_bytes(b"not a font".to_vec()).is_empty()); + assert!(load_font_bytes(Vec::new()).is_empty()); + // With the embedded fonts (absent on CI builds without craft-fonts): a file's family is added. + if let Some(f) = CRAFT_FONTS.first() { + let families = load_font_bytes(f.bytes.to_vec()); + assert_eq!(families.len(), 1); + assert!(families.iter().all(|f| has_family(f))); + } +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 683350f61..e107593e3 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -17,7 +17,7 @@ pub use breaks::grapheme_step; pub use color::{ColorBitmap, ColorInk, ColorLayer}; pub use fontdb::{ Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, has_family, - installed_fonts_known, no_coords, scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned, + installed_fonts_known, load_font_bytes, no_coords, rescan_system_fonts, scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned, }; pub use layout::{Decoration, Glyph, Line, TextLayout, layout}; pub use onpath::OnPath; diff --git a/docs/mcp.md b/docs/mcp.md index c5ffe99f5..787f0c82c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -234,7 +234,9 @@ Or in a project's `.mcp.json`: `text.setVariations {variations, replace?}` merges instead. `text.fonts {family, style?, axes: true}` lists a font's axes and named instances (named instances are also valid `fontStyle` values). `text.missingFonts` lists the families the document names that aren't installed - (`{families: [{family, layers}]}`); their text draws in a fallback font. + (`{families: [{family, layers}]}`); their text draws in a fallback font. Fonts are indexed once + per process: after installing one, `text.rescanFonts` picks it up (`{added: [families]}`), and + `text.loadFont {path | data (base64)}` adds a font file without installing it (`{families}`). OpenType features: `features {"liga": false, "tnum": true}`. Decorations: `textDecorationStyle` (`solid`, `dotted`, `wavy`), `textDecorationThickness` and `textDecorationOffset` (`auto`, px, or `"10%"` of the font size; the offset runs from the baseline From ba4c502ada1809c3a27a7795a312d0303528e0c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20Valentin=20N=C3=A9meth?= <61153253+attisvalentin@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:35:31 +0200 Subject: [PATCH 015/114] M4: text.refit: auto-sizing text measures its box again once its font is there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A text box keeps the size it was measured at, so text laid out while its family was missing kept the fallback font's box after the font arrived (a hugging chip clipped its label). - text.refit {ids?} measures auto-sizing text again (whole document by default) → {refitted} - text.rescanFonts and text.loadFont refit the open document's text in the families they add, as one undo step Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/text.rs | 19 +++++--- crates/engine/src/fonts.rs | 77 +++++++++++++++++++++++++++----- crates/engine/src/fonts_tests.rs | 63 ++++++++++++++++++++++++++ docs/mcp.md | 3 ++ 4 files changed, 145 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 129c8ead1..fbc413344 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -477,16 +477,20 @@ pub fn specs() -> Vec { spec!( "text.rescanFonts", "Rescan installed fonts", - "{} walks the system font folders again, for fonts installed while the app runs → {added: [families that were unknown]}", - false, - |_, _| Ok(json!({"added": figcraft_text::rescan_system_fonts()})) + "{} walks the system font folders again, for fonts installed while the app runs; the open document's auto-sizing text in those families fits them (text.refit) → {added: [families that were unknown], refitted: [ids]}", + true, + |s, _| { + let added = figcraft_text::rescan_system_fonts(); + let refitted = crate::fonts::refit_open(s, None, Some(&added)); + Ok(json!({"added": added, "refitted": refitted})) + } ), spec!( "text.loadFont", "Load font file", - "{path | data (base64)} adds a font file (.ttf, .otf, .ttc, .otc; every face of a collection) to this session's fonts without installing it → {families}", - false, - |_, p| { + "{path | data (base64)} adds a font file (.ttf, .otf, .ttc, .otc; every face of a collection) to this session's fonts without installing it; the open document's auto-sizing text in its families fits it (text.refit) → {families, refitted: [ids]}", + true, + |s, p| { let cmd = "text.loadFont"; let bytes = if let Some(b64) = string(p, "data") { use base64::Engine as _; @@ -501,7 +505,8 @@ pub fn specs() -> Vec { if families.is_empty() { return Err(bad(cmd, "not a font file")); } - Ok(json!({"families": families})) + let refitted = crate::fonts::refit_open(s, None, Some(&families)); + Ok(json!({"families": families, "refitted": refitted})) } ), ] diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs index 3548124d5..42cc8a569 100644 --- a/crates/engine/src/fonts.rs +++ b/crates/engine/src/fonts.rs @@ -1,13 +1,15 @@ //! Fonts a document names that this machine doesn't have (#752): text layers, their styled //! ranges, text styles and the string variables bound to a font family. Text in such a family -//! is drawn in a fallback font, so the user is told which ones are missing. +//! is drawn in a fallback font, so the user is told which ones are missing. Once such a font is +//! there, [`refit`] lets auto-sizing text measure its box in it again. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; -use figcraft_doc::{Document, NodeKind, StyleKind, VariableValue, Variables}; +use figcraft_doc::{Document, NodeId, NodeKind, StyleKind, VariableValue, Variables}; use serde_json::{Value, json}; use crate::cmd::CommandSpec; +use crate::params::{get, req_targets}; use crate::{Result, Session, spec}; /// A family the document names that no installed or built-in font provides. @@ -79,6 +81,52 @@ fn string_values(vars: &Variables, id: &str) -> Vec { out } +/// Auto-sizing text layers measure their boxes again: a box measured while its family was missing +/// (in a fallback font) follows the real face once that's installed or loaded. Only layers under +/// `under` (every layer: `None`) that use one of `families` (any family: `None`). Returns the +/// layers whose size changed. +pub fn refit(doc: &mut Document, under: Option<&[NodeId]>, families: Option<&[String]>) -> Vec { + let want: Option> = families.map(|f| f.iter().map(|x| x.trim().to_lowercase()).collect()); + let scope: Option> = under.map(|ids| ids.iter().flat_map(|i| doc.subtree(*i)).collect()); + let ids: Vec = doc + .nodes() + .filter(|n| scope.as_ref().is_none_or(|s| s.contains(&n.id))) + .filter(|n| { + let NodeKind::Text(t) = &n.kind else { return false }; + let uses = |w: &BTreeSet| { + w.contains(&t.style.font_family.trim().to_lowercase()) + || t.spans().iter().any(|(_, _, st)| w.contains(&st.font_family.trim().to_lowercase())) + }; + want.as_ref().is_none_or(uses) + && figcraft_text::auto_size(n).is_some_and(|(w, h)| (w - n.width).abs() > 1e-6 || (h - n.height).abs() > 1e-6) + }) + .map(|n| n.id) + .collect(); + if !ids.is_empty() { + let before = doc.clone(); + doc.mark_dirty(ids.iter().copied()); + crate::normalize(doc, &before); + } + ids +} + +/// [`refit`] in the open document, if there is one. +pub(crate) fn refit_open(s: &mut Session, under: Option<&[NodeId]>, families: Option<&[String]>) -> Vec { + match s.doc_mut() { + Ok(doc) => refit(doc, under, families), + Err(_) => Vec::new(), + } +} + +fn refit_text(s: &mut Session, p: &Value) -> Result { + let under = match get(p, "ids") { + Some(Value::Array(_)) => Some(req_targets("text.refit", s, p)?), + _ => None, + }; + s.doc()?; + Ok(json!({"refitted": refit_open(s, under.as_deref(), None)})) +} + fn missing_fonts(s: &mut Session, _: &Value) -> Result { let families: Vec = missing(s.doc()?).into_iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect(); s.request_ui("dialog.missingFonts", json!({})); @@ -86,13 +134,22 @@ fn missing_fonts(s: &mut Session, _: &Value) -> Result { } pub fn specs() -> Vec { - vec![spec!( - "text.missingFonts", - "Missing fonts", - "{} fonts the open document names that aren't installed (text layers, styled ranges, text styles, font family variables); the app shows them → {families: [{family, layers}]}", - false, - missing_fonts - )] + vec![ + spec!( + "text.missingFonts", + "Missing fonts", + "{} fonts the open document names that aren't installed (text layers, styled ranges, text styles, font family variables); the app shows them → {families: [{family, layers}]}", + false, + missing_fonts + ), + spec!( + "text.refit", + "Fit text to fonts", + "{ids? (these layers and the text in them; default: the whole document)} auto-sizing text measures its box again, e.g. a box measured in a fallback font while its family was missing → {refitted: [ids whose size changed]}", + true, + refit_text + ), + ] } #[cfg(test)] diff --git a/crates/engine/src/fonts_tests.rs b/crates/engine/src/fonts_tests.rs index a6e2d213f..c947773d2 100644 --- a/crates/engine/src/fonts_tests.rs +++ b/crates/engine/src/fonts_tests.rs @@ -124,3 +124,66 @@ fn needs_an_open_document() { let mut s = Session::empty(); assert!(s.execute("text.missingFonts", &json!({})).is_err()); } + +/// Make `id`'s box stale, as if it was measured in another font: `dw` wider than its content. +fn stale(s: &mut Session, id: figcraft_doc::NodeId, dw: f64) -> f64 { + let doc = s.doc_mut().unwrap(); + let n = doc.get_mut(id).unwrap(); + let want = figcraft_text::auto_size(n).unwrap().0; + n.width = want + dw; + doc.take_dirty(); + want +} + +fn width(s: &Session, id: figcraft_doc::NodeId) -> f64 { + s.doc().unwrap().node(id).unwrap().width +} + +fn refitted(r: &Value) -> Vec { + r["refitted"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect() +} + +#[test] +fn refit_measures_stale_text_boxes_again() { + let mut s = Session::new(); + let a = text(&mut s, "FigCraft Refit A"); + let b = text(&mut s, "FigCraft Refit B"); + let fixed = text(&mut s, "FigCraft Refit A"); + if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(fixed).unwrap().kind { + t.auto_resize = figcraft_doc::TextAutoResize::None; + } + s.execute("select.none", &json!({})).unwrap(); + let want_a = stale(&mut s, a, -30.0); + let want_b = stale(&mut s, b, 25.0); + s.doc_mut().unwrap().get_mut(fixed).unwrap().width = 7.0; + s.doc_mut().unwrap().take_dirty(); + // Only the families asked for. + let only_a = super::refit(s.doc_mut().unwrap(), None, Some(&["figcraft refit a".to_string()])); + assert_eq!(only_a, vec![a]); + assert_eq!(width(&s, a), want_a); + assert_eq!(width(&s, b), want_b + 25.0); + // Only the layers asked for, then everything; a fixed box keeps its size. + stale(&mut s, a, 12.0); + assert!(refitted(&s.execute("text.refit", &json!({"ids": [b]})).unwrap()) == vec![b.0]); + assert_eq!((width(&s, a), width(&s, b)), (want_a + 12.0, want_b)); + assert!(refitted(&s.execute("text.refit", &json!({})).unwrap()) == vec![a.0]); + assert_eq!(width(&s, a), want_a); + assert_eq!(width(&s, fixed), 7.0); + // Nothing stale: nothing changes. + assert!(refitted(&s.execute("text.refit", &json!({})).unwrap()).is_empty()); + assert!(s.execute("text.refit", &json!({"ids": ["nope"]})).is_err()); + assert!(Session::empty().execute("text.refit", &json!({})).is_err()); +} + +#[test] +fn refit_reaches_text_in_instances() { + let mut s = Session::new(); + let t = text(&mut s, "FigCraft Refit C"); + let comp = id_of(&s.execute("component.create", &json!({"ids": [t]})).unwrap()["id"]).unwrap(); + let inst = id_of(&s.execute("instance.create", &json!({"component": comp, "x": 300, "y": 0})).unwrap()["id"]).unwrap(); + let copy = s.doc().unwrap().node(inst).unwrap().children[0]; + let want = stale(&mut s, copy, -20.0); + let r = s.execute("text.refit", &json!({"ids": [inst]})).unwrap(); + assert_eq!(refitted(&r), vec![copy.0]); + assert_eq!(width(&s, copy), want); +} diff --git a/docs/mcp.md b/docs/mcp.md index 787f0c82c..63ff7dd0b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -237,6 +237,9 @@ Or in a project's `.mcp.json`: (`{families: [{family, layers}]}`); their text draws in a fallback font. Fonts are indexed once per process: after installing one, `text.rescanFonts` picks it up (`{added: [families]}`), and `text.loadFont {path | data (base64)}` adds a font file without installing it (`{families}`). + Text boxes keep the size they were measured at: both refit the open document's auto-sizing text + in the new families (`refitted: [ids]`), and `text.refit {ids?}` does it for any text, e.g. a + file saved while its fonts were missing. OpenType features: `features {"liga": false, "tnum": true}`. Decorations: `textDecorationStyle` (`solid`, `dotted`, `wavy`), `textDecorationThickness` and `textDecorationOffset` (`auto`, px, or `"10%"` of the font size; the offset runs from the baseline From 33c0f94cc8b5475d38c866a38fcbdc8eb774340f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Attila=20Valentin=20N=C3=A9meth?= <61153253+attisvalentin@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:46:20 +0200 Subject: [PATCH 016/114] M4: font review fixes: new styles of loaded families, ids checks, repeat loads - text.rescanFonts offers a loaded family the files it hasn't read (styles installed since) and refits every family that gained faces; `added` still lists only the families that were unknown - text.refit refuses an `ids` that isn't a list instead of refitting the whole document - text.loadFont with a file already loaded returns its families without keeping its bytes or adding its faces again - doc comments on the functions this change touches Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/create.rs | 2 + crates/engine/src/cmd/text.rs | 8 ++-- crates/engine/src/fonts.rs | 7 ++- crates/engine/src/fonts_tests.rs | 8 ++++ crates/engine/src/tests.rs | 2 + crates/engine/src/textedit.rs | 1 + crates/text/src/fontdb.rs | 80 +++++++++++++++++++++++--------- crates/text/src/fontdb_tests.rs | 27 ++++++++--- crates/text/src/lib.rs | 5 +- 9 files changed, 105 insertions(+), 35 deletions(-) diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 65c526e4b..6ec7d4e59 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -362,11 +362,13 @@ fn free_spot(s: &Session, w: f64, h: f64) -> Result<(f64, f64)> { }) } +/// The bytes of the file at `path`, within the session's allowed file roots. #[cfg(not(target_arch = "wasm32"))] pub(super) fn read_path(path: &str) -> Result> { crate::file_access::read(path) } +/// The browser has no files to read: always an error. #[cfg(target_arch = "wasm32")] pub(super) fn read_path(path: &str) -> Result> { Err(crate::EngineError::Other(format!("{path}: files can't be read in the browser"))) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index fbc413344..0f0f003de 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -477,12 +477,12 @@ pub fn specs() -> Vec { spec!( "text.rescanFonts", "Rescan installed fonts", - "{} walks the system font folders again, for fonts installed while the app runs; the open document's auto-sizing text in those families fits them (text.refit) → {added: [families that were unknown], refitted: [ids]}", + "{} walks the system font folders again, for fonts installed while the app runs; the open document's auto-sizing text in the families that gained faces (new ones, or styles installed since) fits them (text.refit) → {added: [families that were unknown], refitted: [ids]}", true, |s, _| { - let added = figcraft_text::rescan_system_fonts(); - let refitted = crate::fonts::refit_open(s, None, Some(&added)); - Ok(json!({"added": added, "refitted": refitted})) + let rescan = figcraft_text::rescan_system_fonts(); + let refitted = crate::fonts::refit_open(s, None, Some(&rescan.changed)); + Ok(json!({"added": rescan.added, "refitted": refitted})) } ), spec!( diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs index 42cc8a569..672d4f2ca 100644 --- a/crates/engine/src/fonts.rs +++ b/crates/engine/src/fonts.rs @@ -118,10 +118,12 @@ pub(crate) fn refit_open(s: &mut Session, under: Option<&[NodeId]>, families: Op } } +/// `text.refit`: the layers `ids` names (an `ids` that isn't a list of layers is refused), else the +/// whole document. fn refit_text(s: &mut Session, p: &Value) -> Result { let under = match get(p, "ids") { - Some(Value::Array(_)) => Some(req_targets("text.refit", s, p)?), - _ => None, + None | Some(Value::Null) => None, + Some(_) => Some(req_targets("text.refit", s, p)?), }; s.doc()?; Ok(json!({"refitted": refit_open(s, under.as_deref(), None)})) @@ -133,6 +135,7 @@ fn missing_fonts(s: &mut Session, _: &Value) -> Result { Ok(json!({"families": families})) } +/// The font commands: `text.missingFonts` and `text.refit`. pub fn specs() -> Vec { vec![ spec!( diff --git a/crates/engine/src/fonts_tests.rs b/crates/engine/src/fonts_tests.rs index c947773d2..463fb9046 100644 --- a/crates/engine/src/fonts_tests.rs +++ b/crates/engine/src/fonts_tests.rs @@ -135,14 +135,17 @@ fn stale(s: &mut Session, id: figcraft_doc::NodeId, dw: f64) -> f64 { want } +/// The width of layer `id`. fn width(s: &Session, id: figcraft_doc::NodeId) -> f64 { s.doc().unwrap().node(id).unwrap().width } +/// The ids a refit reports. fn refitted(r: &Value) -> Vec { r["refitted"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect() } +/// Stale boxes fit their text again, by family, by layer or everywhere; fixed boxes keep theirs. #[test] fn refit_measures_stale_text_boxes_again() { let mut s = Session::new(); @@ -172,9 +175,14 @@ fn refit_measures_stale_text_boxes_again() { // Nothing stale: nothing changes. assert!(refitted(&s.execute("text.refit", &json!({})).unwrap()).is_empty()); assert!(s.execute("text.refit", &json!({"ids": ["nope"]})).is_err()); + // An `ids` that isn't a list is refused, not taken as the whole document. + stale(&mut s, a, 9.0); + assert!(s.execute("text.refit", &json!({"ids": a.0.to_string()})).is_err()); + assert_eq!(width(&s, a), want_a + 9.0); assert!(Session::empty().execute("text.refit", &json!({})).is_err()); } +/// Text inside an instance is refitted too. #[test] fn refit_reaches_text_in_instances() { let mut s = Session::new(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7cfa320b7..ad3b6aa33 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -376,6 +376,7 @@ fn pointer_draw_move_resize() { assert_eq!(s.selection(), vec![r]); } +/// No command panics on junk parameters. #[test] fn every_command_survives_junk_params() { // A junk-parameter sweep: no command may panic (guard turns panics into Internal errors, @@ -627,6 +628,7 @@ fn find_replace_changing_only_the_case_replaces_every_occurrence() { assert_eq!(s.doc().unwrap().node(f).unwrap().name, "FigCraft / FigCraft"); } +/// `text.loadFont` refuses what isn't a font file; `text.rescanFonts` reports what it added. #[test] fn fonts_load_from_files_and_rescans_report_new_families() { let mut s = sess(); diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index 38d6a1996..902ba1494 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -782,6 +782,7 @@ mod tests { assert!(r.is_null() || r["axes"].is_array()); } + /// No text command panics on junk parameters while a text is being edited. #[test] fn text_commands_survive_junk_while_editing() { let junk = [ diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index aa5777b5e..814d3a2f7 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -444,9 +444,22 @@ pub struct FontDb { families: BTreeMap)>, /// System faces known by name but not loaded (lower-case family → entries). pending: BTreeMap)>, + /// System faces read from disk (file, face index), so a rescan only offers new ones. + loaded_files: std::collections::BTreeSet<(std::path::PathBuf, u32)>, + /// Font files added as bytes (hash and length → families), so loading one again adds nothing. + loaded_bytes: std::collections::HashMap<(u64, usize), Vec>, system_scanned: bool, } +/// What a later walk of the font folders found ([`rescan_system_fonts`]). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Rescan { + /// Families that were unknown before. + pub added: Vec, + /// Every family that gained faces: the new ones and those with styles installed since. + pub changed: Vec, +} + static DB: OnceLock> = OnceLock::new(); /// Set by [`scan_system_fonts_on_demand`]. static ON_DEMAND: AtomicBool = AtomicBool::new(false); @@ -580,6 +593,7 @@ impl FontDb { }; if let Some(face) = Face::from_bytes(FontBytes::Shared(bytes), e.index, None, Some(&e.style)) { self.add(face); + self.loaded_files.insert((e.path, e.index)); } } } @@ -670,21 +684,33 @@ impl FontDb { self.faces.len() } - /// Offer the families of a later walk of the font folders: new families become pending, and a - /// family not loaded yet takes the walk's files (styles installed since). Loaded families keep - /// their faces. Returns the display names of the families that were unknown. - fn merge_rescan(&mut self, found: BTreeMap)>) -> Vec { - let mut added = Vec::new(); - for (k, v) in found { + /// Offer the families of a later walk of the font folders: new families become pending, a + /// family not loaded yet takes the walk's files, and a loaded family keeps its faces and gets + /// the files it hasn't read (styles installed since) as pending, read when it is next used. + fn merge_rescan(&mut self, found: BTreeMap)>) -> Rescan { + let mut out = Rescan::default(); + for (k, (display, entries)) in found { if self.families.contains_key(&k) { + let waiting = self.pending.get(&k).map(|(_, v)| v.clone()).unwrap_or_default(); + let new: Vec = entries + .into_iter() + .filter(|e| { + !self.loaded_files.contains(&(e.path.clone(), e.index)) && !waiting.iter().any(|w| w.path == e.path && w.index == e.index) + }) + .collect(); + if !new.is_empty() { + out.changed.push(display.clone()); + self.pending.entry(k).or_insert_with(|| (display, Vec::new())).1.extend(new); + } continue; } if !self.pending.contains_key(&k) { - added.push(v.0.clone()); + out.added.push(display.clone()); } - self.pending.insert(k, v); + out.changed.push(display.clone()); + self.pending.insert(k, (display, entries)); } - added + out } /// Index the system font directories (desktop only): family names are read now, faces loaded @@ -905,39 +931,51 @@ pub fn scan_system_fonts() { } /// Walk the system font folders again, for fonts installed while the app runs (the first scan is -/// once per process). Returns the families that were unknown before. Before the first scan this -/// is that scan; a no-op on the web or with `FIGCRAFT_NO_SYSTEM_FONTS` set. -pub fn rescan_system_fonts() -> Vec { +/// once per process): the families that were unknown before, and every family that gained faces. +/// Before the first scan this is that scan; a no-op on the web or with `FIGCRAFT_NO_SYSTEM_FONTS`. +pub fn rescan_system_fonts() -> Rescan { #[cfg(not(target_arch = "wasm32"))] if !system_fonts_opted_out() { if SCANNED.get().is_none() { let before: std::collections::BTreeSet = raw_db().read().unwrap_or_else(std::sync::PoisonError::into_inner).families().into_iter().collect(); scan_system_fonts(); - return family_names().into_iter().filter(|f| !before.contains(f)).collect(); + let added: Vec = family_names().into_iter().filter(|f| !before.contains(f)).collect(); + return Rescan { changed: added.clone(), added }; } let mut found: BTreeMap)> = BTreeMap::new(); for dir in system_font_dirs() { scan_dir(&dir, 0, &mut found); } - let added = raw_db().write().unwrap_or_else(std::sync::PoisonError::into_inner).merge_rescan(found); - if !added.is_empty() { + let rescan = raw_db().write().unwrap_or_else(std::sync::PoisonError::into_inner).merge_rescan(found); + if !rescan.changed.is_empty() { crate::clear_layout_cache(); } - return added; + return rescan; } - Vec::new() + Rescan::default() } /// Load font file bytes (every face of a collection) into the global db, for a font that isn't -/// installed. Returns the families of the faces added (none: not a font file). +/// installed. Returns the families of its faces (none: not a font file); the same file again +/// returns them without adding its faces twice. pub fn load_font_bytes(bytes: Vec) -> Vec { - let faces = db().write().unwrap_or_else(std::sync::PoisonError::into_inner).add_bytes(bytes); - if !faces.is_empty() { - crate::clear_layout_cache(); + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + bytes.hash(&mut h); + let key = (h.finish(), bytes.len()); + let mut db = db().write().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(families) = db.loaded_bytes.get(&key) { + return families.clone(); } + let faces = db.add_bytes(bytes); let mut families: Vec = faces.iter().map(|f| f.family.clone()).collect(); families.dedup(); + if !families.is_empty() { + db.loaded_bytes.insert(key, families.clone()); + drop(db); + crate::clear_layout_cache(); + } families } diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs index 3574fdfba..f2456d28b 100644 --- a/crates/text/src/fontdb_tests.rs +++ b/crates/text/src/fontdb_tests.rs @@ -218,6 +218,7 @@ fn user_font_dirs_preserve_non_utf8_paths() { assert_eq!(dirs[1].as_os_str().as_bytes(), b"/data/\xfe/fonts"); } +/// A rescan offers new families and the styles installed since, and never a file twice. #[test] fn a_rescan_offers_fonts_installed_since() { let entry = |s: &str| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: s.into() }; @@ -225,26 +226,40 @@ fn a_rescan_offers_fonts_installed_since() { let found = |fams: &[(&str, &[&str])]| -> BTreeMap)> { fams.iter().map(|(f, styles)| (f.to_lowercase(), ((*f).to_string(), styles.iter().map(|s| entry(s)).collect()))).collect() }; - // A new family is reported and offered; a pending one takes the walk's files (a new style). - let added = db.merge_rescan(found(&[("CJK UI", &["Regular", "Bold"]), ("Brand Sans", &["Regular"])])); - assert_eq!(added, ["Brand Sans"]); + // A new family is added; a pending one takes the walk's files (a new style); both changed. + let r = db.merge_rescan(found(&[("CJK UI", &["Regular", "Bold"]), ("Brand Sans", &["Regular"])])); + assert_eq!(r.added, ["Brand Sans"]); + assert_eq!(r.changed, ["Brand Sans", "CJK UI"]); assert!(db.has_family("brand sans")); assert_eq!(left(&db), ["Regular", "Bold"]); - // A loaded family keeps its faces and isn't reported again. + // A loaded family keeps its faces and gets only the files it hasn't read, once. db.families.insert("brand sans".into(), ("Brand Sans".into(), Vec::new())); db.pending.remove("brand sans"); - assert!(db.merge_rescan(found(&[("Brand Sans", &["Regular"])])).is_empty()); + db.loaded_files.insert(("no-such-dir/Regular.ttf".into(), 0)); + let same = db.merge_rescan(found(&[("Brand Sans", &["Regular"])])); + assert_eq!(same, Rescan::default()); assert!(!db.pending.contains_key("brand sans")); + let bold = db.merge_rescan(found(&[("Brand Sans", &["Regular", "Black"])])); + assert_eq!((bold.added.len(), bold.changed), (0, vec!["Brand Sans".to_string()])); + let waiting = |db: &FontDb| db.pending.get("brand sans").map(|(_, v)| v.iter().map(|e| e.style.clone()).collect::>()).unwrap_or_default(); + assert_eq!(waiting(&db), ["Black"]); + assert_eq!(db.merge_rescan(found(&[("Brand Sans", &["Regular", "Black"])])), Rescan::default()); + assert_eq!(waiting(&db), ["Black"]); } +/// Font file bytes add their faces once; junk adds nothing. #[test] fn font_bytes_load_into_the_db() { assert!(load_font_bytes(b"not a font".to_vec()).is_empty()); assert!(load_font_bytes(Vec::new()).is_empty()); - // With the embedded fonts (absent on CI builds without craft-fonts): a file's family is added. + // With the embedded fonts (absent on CI builds without craft-fonts): a file's family is added, + // and loading the same file again returns it without adding its faces twice. if let Some(f) = CRAFT_FONTS.first() { let families = load_font_bytes(f.bytes.to_vec()); assert_eq!(families.len(), 1); assert!(families.iter().all(|f| has_family(f))); + let faces = db().read().unwrap().face_count(); + assert_eq!(load_font_bytes(f.bytes.to_vec()), families); + assert_eq!(db().read().unwrap().face_count(), faces); } } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index e107593e3..667ab7317 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -16,8 +16,9 @@ mod onpath; pub use breaks::grapheme_step; pub use color::{ColorBitmap, ColorInk, ColorLayer}; pub use fontdb::{ - Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, has_family, - installed_fonts_known, load_font_bytes, no_coords, rescan_system_fonts, scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned, + Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, Rescan, axis_label, db, face_with_char, family_names, family_styles, find_face, + has_family, installed_fonts_known, load_font_bytes, no_coords, rescan_system_fonts, scan_system_fonts, scan_system_fonts_on_demand, + system_fonts_scanned, }; pub use layout::{Decoration, Glyph, Line, TextLayout, layout}; pub use onpath::OnPath; From f03fc476c87d3900015ae5aac155e10c1cab2664 Mon Sep 17 00:00:00 2001 From: slyim <125447219+slyim@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:59:56 +0300 Subject: [PATCH 017/114] M4: toolbar marks share one size and stroke weight; a simpler Slice icon (#737, #738, #739) Every mark in the bottom toolbar is now painted in one 18-point box. The mode switcher drew its marks in a 14-point box, so Draw, Prototype and Inspect read smaller than the tools. The flyout carets move from a 10-point box, whose stroke rounded to one physical pixel thinner than the row's, to a 14-point box that rounds to the same width at 1x, 1.5x and 2x. Frame and Text are inset by a unit: their long pixel-hinted strokes read heavier than the anti-aliased outlines beside them. Slice is a knife (a slim blade and a solid handle), one silhouette instead of a box crossed by a diagonal. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/icons.rs | 26 ++++++++------ crates/ui-egui/src/toolbar.rs | 66 ++++++++++++++++++++++++++++++++--- 2 files changed, 77 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index fa4fb66ea..1a2b72a38 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -446,18 +446,21 @@ fn draw(p: &P, icon: Icon) { p.path(&h, false); } Frame => { - p.line(5.5, 2.0, 5.5, 14.0); - p.line(10.5, 2.0, 10.5, 14.0); - p.line(2.0, 5.5, 14.0, 5.5); - p.line(2.0, 10.5, 14.0, 10.5); + // Inset a unit from the box: four long hinted strokes read heavier than the + // anti-aliased outlines beside them at full size (#737). + p.line(5.5, 3.0, 5.5, 13.0); + p.line(10.5, 3.0, 10.5, 13.0); + p.line(3.0, 5.5, 13.0, 5.5); + p.line(3.0, 10.5, 13.0, 10.5); } Section | Section2 => { p.rect(2.5, 5.5, 13.5, 13.5, 1.0); p.fill_rect(2.0, 2.0, 9.0, 6.0, 1.0); } Slice => { - p.rect(2.5, 2.5, 13.5, 13.5, 0.0); - p.line(2.5, 13.5, 13.5, 2.5); + // A knife, one silhouette (#739): a slim blade to the top right, a solid handle. + p.poly(&[(13.5, 2.5), (5.0, 8.0), (8.0, 11.0)], true); + p.fill(&capsule((2.8, 13.2), (5.4, 10.6), 1.3)); } Bend => { // A curve with a smooth point and its handle line. @@ -523,11 +526,12 @@ fn draw(p: &P, icon: Icon) { p.line(9.0, 4.5, 11.5, 7.0); } Text => { - p.line(2.0, 3.5, 13.0, 3.5); - p.line(7.5, 3.0, 7.5, 13.0); - p.line(2.5, 3.0, 2.5, 6.0); - p.line(12.5, 3.0, 12.5, 6.0); - p.line(5.0, 12.5, 10.0, 12.5); + // Inset like the Frame mark, so its long strokes don't outweigh the outlines (#737). + p.line(3.0, 3.5, 12.0, 3.5); + p.line(7.5, 3.0, 7.5, 12.5); + p.line(3.5, 3.0, 3.5, 5.5); + p.line(11.5, 3.0, 11.5, 5.5); + p.line(5.5, 12.5, 9.5, 12.5); } Comment => { let mut b: Vec<(f32, f32)> = arc_pts(8.5, 7.5, 5.0, 2.75, TAU + 1.95).collect(); diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 6da9badb6..aad447085 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -11,6 +11,18 @@ use crate::i18n::{tr, tr_noop, trf}; use crate::icons::{self, Icon}; use crate::theme::{ACCENT, Tokens, regular}; +/// Every mark in the toolbar row is painted in a box this size, so they share one optical size +/// and one stroke width (#737, #738). +const GLYPH: f32 = 18.0; +/// The flyout carets: smaller than the marks, but on a box whose stroke rounds to the same width +/// as theirs at 1×, 1.5× and 2× (a 10-point box drew them one physical pixel thinner). +const CARET: f32 = 14.0; + +/// `GLYPH`'s box centred in `r`. +fn glyph_rect(r: Rect) -> Rect { + Rect::from_center_size(r.center(), vec2(GLYPH, GLYPH)) +} + struct Group { key: &'static str, /// The flyout button's name for screen readers. @@ -121,7 +133,7 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { if cresp.hovered() { ui.painter().rect_filled(cr, CornerRadius::same(4), t.hover); } - icons::paint(ui.painter(), Rect::from_center_size(cr.center(), vec2(10.0, 10.0)), Icon::Chevron, t.icon_secondary); + icons::paint(ui.painter(), Rect::from_center_size(cr.center(), vec2(CARET, CARET)), Icon::Chevron, t.icon_secondary); if cresp.clicked() { app.ui.tool_flyout = if app.ui.tool_flyout == Some(g.key) { None } else { Some(g.key) }; } @@ -149,7 +161,7 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { } else if resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); } - icons::paint(ui.painter(), r.shrink(7.0), icon, if actions_open { Color32::WHITE } else { t.icon }); + icons::paint(ui.painter(), glyph_rect(r), icon, if actions_open { Color32::WHITE } else { t.icon }); if resp.clicked() { if id == "actions" { crate::menus::toggle_palette(&mut app.ui); @@ -181,7 +193,7 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { } else if resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(6), t.hover); } - icons::paint(ui.painter(), r.shrink(9.0), *icon, if i == active { t.accent_text } else { t.icon_secondary }); + icons::paint(ui.painter(), glyph_rect(r), *icon, if i == active { t.accent_text } else { t.icon_secondary }); if resp.clicked() { match i { 0 => { @@ -379,7 +391,7 @@ fn tool_cell(ui: &mut Ui, t: &Tokens, r: Rect, id: impl egui::AsIdSalt, icon: Ic Color32::TRANSPARENT }; ui.painter().rect_filled(r, CornerRadius::same(5), bg); - icons::paint(ui.painter(), r.shrink(7.0), icon, if active { Color32::WHITE } else { t.icon }); + icons::paint(ui.painter(), glyph_rect(r), icon, if active { Color32::WHITE } else { t.icon }); resp } @@ -452,3 +464,49 @@ fn flyout_id(key: &str) -> egui::Id { pub fn pretty_shortcut(s: &str) -> String { figcraft_engine::keymap::display(s, crate::shortcuts::platform()) } + +#[cfg(test)] +mod tests { + use super::*; + use egui::Shape; + + /// The stroke widths `icon` paints with in a `size` box at `ppp` pixels per point. + fn widths(ppp: f32, icon: Icon, size: f32) -> Vec { + let ctx = egui::Context::default(); + let input = || { + let mut i = egui::RawInput::default(); + i.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(ppp); + i + }; + ctx.run_ui(input(), |_| {}).textures_delta.clear(); + let mut out = + ctx.run_ui(input(), |ui| icons::paint(ui.painter(), Rect::from_center_size(pos2(50.0, 50.0), vec2(size, size)), icon, Color32::WHITE)); + out.textures_delta.clear(); + assert_eq!(ctx.pixels_per_point(), ppp); + let mut w: Vec = out + .shapes + .iter() + .filter_map(|c| match &c.shape { + Shape::LineSegment { stroke, .. } => Some(stroke.width), + Shape::Path(p) => Some(p.stroke.width), + Shape::Rect(r) => Some(r.stroke.width), + _ => None, + }) + .filter(|w| *w > 0.0) + .collect(); + w.dedup(); + w + } + + /// The flyout carets and the marks beside them draw with one stroke width (#737), and the + /// mode switcher's marks are the tool marks' size (#738). + #[test] + fn toolbar_marks_share_one_stroke_width() { + for ppp in [1.0, 1.5, 2.0] { + let mark = widths(ppp, Icon::Rectangle, GLYPH); + assert_eq!(mark.len(), 1, "one width at {ppp}×: {mark:?}"); + assert_eq!(widths(ppp, Icon::Chevron, CARET), mark, "caret at {ppp}×"); + assert_eq!(widths(ppp, Icon::Code, GLYPH), mark, "mode mark at {ppp}×"); + } + } +} From bc10bb190dde26bfd8ea5f1bbd1f392d36b0cc51 Mon Sep 17 00:00:00 2001 From: Ahmed5Emad Date: Sun, 18 Oct 2026 20:03:43 +0300 Subject: [PATCH 018/114] M2: individual padding in the Layout panel The Spacing row's toggle opens per-side Top/Right/Bottom/Left fields in a 2x2 grid in place of the H/V fields, each bound to its own padding key with one undo step; shut H/V fields show both values ("16,54") when the sides disagree instead of "Mixed". Original code-drawn side icons, English and Spanish strings (padding_individual_tests). --- ROADMAP.md | 4 +- crates/engine/src/cmd/layout.rs | 28 ++++++ crates/ui-egui/locales/en.toml | 5 + crates/ui-egui/locales/es.toml | 5 + crates/ui-egui/src/icons.rs | 43 ++++++++- crates/ui-egui/src/layout_panel.rs | 92 +++++++++++++++---- crates/ui-egui/src/lib.rs | 2 + .../ui-egui/src/padding_individual_tests.rs | 75 +++++++++++++++ docs/auto-layout-parity.md | 3 +- 9 files changed, 235 insertions(+), 22 deletions(-) create mode 100644 crates/ui-egui/src/padding_individual_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 8a587286d..b00408dc7 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-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (individual padding in the Layout panel, M2) 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 @@ -131,6 +131,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-18** · Individual padding in the Layout panel (M2): the Spacing row's toggle opens per-side Top/Right/Bottom/Left fields in a 2×2 grid in place of the H/V fields, each bound to its own padding key with one undo step; shut H/V fields show both values ("16,54") when the sides disagree instead of "Mixed". Original code-drawn side icons, English and Spanish strings. Numbers unchanged. - **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. - **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. - **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. @@ -316,6 +317,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Individual padding in the Layout panel (M2): the Spacing row's toggle opens per-side Top/Right/Bottom/Left fields in a 2×2 grid in place of the H/V fields, each bound to its own padding key with one undo step; shut H/V fields show both values ("16,54") when the sides disagree instead of "Mixed". Original code-drawn side icons, English and Spanish strings. Numbers unchanged. | | 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged | | 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. | | 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 5643ac706..b545e52d2 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -149,6 +149,34 @@ mod tests { assert!(s.execute("node.set", &json!({"id": b, "props": {"absolute": "maybe"}})).is_err()); } + #[test] + fn each_side_padding_sets_its_side_alone() { + let mut s = Session::new(); + let (f, _, _) = al_frame(&mut s); + // One side at a time: the other three stay 0 ([top, right, bottom, left]). + for (key, v, want) in [ + ("paddingTop", 8.0, [8.0, 0.0, 0.0, 0.0]), + ("paddingRight", 12.0, [0.0, 12.0, 0.0, 0.0]), + ("paddingBottom", 16.0, [0.0, 0.0, 16.0, 0.0]), + ("paddingLeft", 20.0, [0.0, 0.0, 0.0, 20.0]), + ] { + ex(&mut s, "node.set", json!({"id": f, "props": {"padding": 0}})); + ex(&mut s, "node.set", json!({"id": f, "props": {key: v}})); + assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, want, "{key}"); + } + // The [t, r, b, l] array and the axis pairs agree with the four sides. + ex(&mut s, "node.set", json!({"id": f, "props": {"padding": [8, 12, 16, 20]}})); + assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [8.0, 12.0, 16.0, 20.0]); + ex(&mut s, "node.set", json!({"id": f, "props": {"paddingHorizontal": 4}})); + assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [8.0, 4.0, 16.0, 4.0]); + ex(&mut s, "node.set", json!({"id": f, "props": {"paddingVertical": 2}})); + assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [2.0, 4.0, 2.0, 4.0]); + // Negatives clamp to 0; junk is refused. + ex(&mut s, "node.set", json!({"id": f, "props": {"paddingTop": -5}})); + assert_eq!(node(&s, f).layout.as_ref().unwrap().padding[0], 0.0); + assert!(s.execute("node.set", &json!({"id": f, "props": {"paddingLeft": "wide"}})).is_err()); + } + #[test] fn baseline_alignment_lines_up_text() { let mut s = Session::new(); diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 17f6b4c5f..d5d28eeb3 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -172,6 +172,7 @@ name = "English" "Both directions" = "Both directions" "Bottom" = "Bottom" "Bottom left radius" = "Bottom left radius" +"Bottom padding" = "Bottom padding" "Bottom right radius" = "Bottom right radius" "Branch" = "Branch" "Branch archived" = "Branch archived" @@ -632,6 +633,7 @@ name = "English" "Included" = "Included" "Included in layout" = "Included in layout" "Individual corners" = "Individual corners" +"Individual padding" = "Individual padding" "Individual strokes" = "Individual strokes" "Ink" = "Ink" "Inner shadow" = "Inner shadow" @@ -702,6 +704,7 @@ name = "English" "Leaves" = "Leaves" "Left" = "Left" "Left & right" = "Left & right" +"Left padding" = "Left padding" "Left side" = "Left side" "Left side width" = "Left side width" "Letter case" = "Letter case" @@ -1134,6 +1137,7 @@ name = "English" "Review and merge" = "Review and merge" "Ribbon" = "Ribbon" "Right" = "Right" +"Right padding" = "Right padding" "Right side" = "Right side" "Right side width" = "Right side width" "Rotate 90°" = "Rotate 90°" @@ -1402,6 +1406,7 @@ name = "English" "Top" = "Top" "Top & bottom" = "Top & bottom" "Top left radius" = "Top left radius" +"Top padding" = "Top padding" "Top right radius" = "Top right radius" "Transform" = "Transform" "Triangle arrow" = "Triangle arrow" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index d054e2223..85f035215 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -175,6 +175,7 @@ status = "draft — needs native review" "Both directions" = "Ambas direcciones" "Bottom" = "Abajo" "Bottom left radius" = "Radio inferior izquierdo" +"Bottom padding" = "Margen interno inferior" "Bottom right radius" = "Radio inferior derecho" "Branch" = "Rama" "Branch archived" = "Rama archivada" @@ -633,6 +634,7 @@ status = "draft — needs native review" "Included" = "Incluidos" "Included in layout" = "Incluidos en el diseño" "Individual corners" = "Esquinas individuales" +"Individual padding" = "Margen interno individual" "Individual strokes" = "Trazos individuales" "Ink" = "Tinta" "Inner shadow" = "Sombra interior" @@ -703,6 +705,7 @@ status = "draft — needs native review" "Leaves" = "Hojas" "Left" = "Izquierda" "Left & right" = "Izquierda y derecha" +"Left padding" = "Margen interno izquierdo" "Left side" = "Lado izquierdo" "Left side width" = "Ancho del lado izquierdo" "Letter case" = "Mayúsculas y minúsculas" @@ -1135,6 +1138,7 @@ status = "draft — needs native review" "Review and merge" = "Revisar y fusionar" "Ribbon" = "Cinta" "Right" = "Derecha" +"Right padding" = "Margen interno derecho" "Right side" = "Lado derecho" "Right side width" = "Ancho del lado derecho" "Rotate 90°" = "Girar 90°" @@ -1403,6 +1407,7 @@ status = "draft — needs native review" "Top" = "Arriba" "Top & bottom" = "Arriba y abajo" "Top left radius" = "Radio superior izquierdo" +"Top padding" = "Margen interno superior" "Top right radius" = "Radio superior derecho" "Transform" = "Transformar" "Triangle arrow" = "Flecha triangular" diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index fa4fb66ea..f4f2f31ba 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -85,6 +85,8 @@ icons! { CornerRadius, Angle, IndependentCorners, + /// Individual padding: a frame with one inward tick per side, each set alone. + IndependentPadding, LinkOn, LinkOff, LayoutH, @@ -139,6 +141,11 @@ icons! { Fixed, PaddingH, PaddingV, + /// Individual padding sides: a frame with the controlled side filled. + PaddingTop, + PaddingRight, + PaddingBottom, + PaddingLeft, Gap, Reset, Detach, @@ -706,6 +713,14 @@ fn draw(p: &P, icon: Icon) { p.corner(x, y, dx, dy, 4.0, 2.0); } } + IndependentPadding => { + // A frame outline with one inward tick per side: each side's padding, set alone. + p.rect(2.5, 2.5, 13.5, 13.5, 2.0); + p.line(8.0, 2.5, 8.0, 5.0); + p.line(13.5, 8.0, 11.0, 8.0); + p.line(8.0, 13.5, 8.0, 11.0); + p.line(2.5, 8.0, 5.0, 8.0); + } LinkOn | Link => { p.path(&capsule((4.0, 12.0), (6.5, 9.5), 2.4), true); p.path(&capsule((9.5, 6.5), (12.0, 4.0), 2.4), true); @@ -923,12 +938,32 @@ fn draw(p: &P, icon: Icon) { p.line(3.0, 7.5, 13.0, 7.5); } PaddingH => { - p.rect(2.5, 3.5, 13.5, 12.5, 1.0); - p.fill_rect(5.0, 6.0, 11.0, 10.0, 0.0); + // The horizontal axis: bars across the top and bottom in their frame. + p.rect(2.5, 2.5, 13.5, 13.5, 1.0); + p.fill_rect(2.5, 2.5, 13.5, 5.5, 1.0); + p.fill_rect(2.5, 10.5, 13.5, 13.5, 1.0); } PaddingV => { - p.rect(3.5, 2.5, 12.5, 13.5, 1.0); - p.fill_rect(6.0, 5.0, 10.0, 11.0, 0.0); + // The vertical axis: bars down the left and right in their frame. + p.rect(2.5, 2.5, 13.5, 13.5, 1.0); + p.fill_rect(2.5, 2.5, 5.5, 13.5, 1.0); + p.fill_rect(10.5, 2.5, 13.5, 13.5, 1.0); + } + PaddingTop => { + p.rect(2.5, 2.5, 13.5, 13.5, 1.0); + p.fill_rect(2.5, 2.5, 13.5, 5.5, 1.0); + } + PaddingRight => { + p.rect(2.5, 2.5, 13.5, 13.5, 1.0); + p.fill_rect(10.5, 2.5, 13.5, 13.5, 1.0); + } + PaddingBottom => { + p.rect(2.5, 2.5, 13.5, 13.5, 1.0); + p.fill_rect(2.5, 10.5, 13.5, 13.5, 1.0); + } + PaddingLeft => { + p.rect(2.5, 2.5, 13.5, 13.5, 1.0); + p.fill_rect(2.5, 2.5, 5.5, 13.5, 1.0); } Online => { p.circle(8.0, 8.0, 5.5); diff --git a/crates/ui-egui/src/layout_panel.rs b/crates/ui-egui/src/layout_panel.rs index 11d303693..72d44b9af 100644 --- a/crates/ui-egui/src/layout_panel.rs +++ b/crates/ui-egui/src/layout_panel.rs @@ -4,7 +4,7 @@ //! alignment in its cell, and clip content. Everything goes through `node.set` props or `layout.*` //! commands. -use egui::{Align2, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use egui::{Align, Align2, CornerRadius, Id, Layout, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use figcraft_doc::*; use serde_json::json; @@ -404,31 +404,91 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { let col = widgets::col_width(ui.available_width()); let [pt, pr, pb, pl] = l.padding; + let open = ui.data(|d| d.get_temp::(Id::new("indep_padding"))).unwrap_or(false); + if open { + // Each side on its own, like Figma's individual padding: a 2x2 grid of sides with + // the toggle at its right, replacing the H/V row while open. Every measure copies + // a plain row: 32px rows, the panel's own inter-row gap, 8px between the fields. + let gap = ui.spacing().item_spacing.y; + let total = 32.0 * 2.0 + gap; + let fields_w = col * 2.0 + 8.0; + let top = ("paddingTop", tr("Top padding"), Icon::PaddingTop, pt, 0); + let right = ("paddingRight", tr("Right padding"), Icon::PaddingRight, pr, 1); + let bottom = ("paddingBottom", tr("Bottom padding"), Icon::PaddingBottom, pb, 2); + let left = ("paddingLeft", tr("Left padding"), Icon::PaddingLeft, pl, 3); + block(ui, total, 0.0, |ui| { + ui.allocate_ui_with_layout(vec2(fields_w, total), Layout::top_down(Align::LEFT), |ui| { + ui.spacing_mut().item_spacing.y = 0.0; + // The horizontal pair (left, right) on the left, the vertical pair on the right. + for (r, pair) in [[&left, &top], [&right, &bottom]].iter().enumerate() { + if r > 0 { + ui.add_space(gap); + } + ui.allocate_ui_with_layout(vec2(fields_w, 32.0), Layout::left_to_right(Align::Center), |ui| { + ui.spacing_mut().item_spacing.x = 8.0; + for (key, name, icon, v, k) in pair.iter() { + let e = crate::variable_ui::number_field( + app, + ui, + ids, + key, + Id::new(("padding", *k)), + Lead::Icon(*icon, name), + Some(*v), + "", + col, + ); + app.field(ids, key, e, |v| json!(v.max(0.0))); + } + }); + } + }); + // The toggle keeps the shut row's rect: full-height strip, button 4px + // down, centred in the top 32px exactly like the shut row. + ui.allocate_ui_with_layout(vec2(24.0, total), Layout::top_down(Align::Center), |ui| { + ui.add_space(4.0); + if icons::button(ui, Icon::IndependentPadding, 24.0, true, tr("Individual padding")).clicked() { + ui.data_mut(|d| d.insert_temp(Id::new("indep_padding"), false)); + } + }); + }); + return; + } row(ui, 32.0, |ui| { - let e = crate::variable_ui::number_field( - app, - ui, - ids, - "paddingHorizontal", - Id::new("al_padh"), - Lead::Icon(Icon::PaddingH, tr("Horizontal padding")), - (pl == pr).then_some(pl), - "", - col, - ); - app.field(ids, "paddingHorizontal", e, |v| json!(v.max(0.0))); - let e = crate::variable_ui::number_field( + // Sides that disagree show both values ("16,54") instead of "Mixed". + let v_pair = format!("{},{}", widgets::fmt_num(pt), widgets::fmt_num(pb)); + let v_empty = if pt == pb { tr("Mixed") } else { v_pair.as_str() }; + let e = crate::variable_ui::number_field_or( app, ui, ids, "paddingVertical", Id::new("al_padv"), - Lead::Icon(Icon::PaddingV, tr("Vertical padding")), (pt == pb).then_some(pt), - "", col, + v_empty, + Lead::Icon(Icon::PaddingV, tr("Vertical padding")), + "", ); app.field(ids, "paddingVertical", e, |v| json!(v.max(0.0))); + let h_pair = format!("{},{}", widgets::fmt_num(pl), widgets::fmt_num(pr)); + let h_empty = if pl == pr { tr("Mixed") } else { h_pair.as_str() }; + let e = crate::variable_ui::number_field_or( + app, + ui, + ids, + "paddingHorizontal", + Id::new("al_padh"), + (pl == pr).then_some(pl), + col, + h_empty, + Lead::Icon(Icon::PaddingH, tr("Horizontal padding")), + "", + ); + app.field(ids, "paddingHorizontal", e, |v| json!(v.max(0.0))); + if icons::button(ui, Icon::IndependentPadding, 24.0, false, tr("Individual padding")).clicked() { + ui.data_mut(|d| d.insert_temp(Id::new("indep_padding"), true)); + } }); if l.mode != LayoutMode::Grid && l.wrap { // Wrapped rows (horizontal) or columns (vertical): the gap between them. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 450317b43..47c6d960e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -156,6 +156,8 @@ mod online_tests; #[cfg(test)] mod open_issues_tests; #[cfg(test)] +mod padding_individual_tests; +#[cfg(test)] mod palette_tests; #[cfg(test)] mod panel_remove_tests; diff --git a/crates/ui-egui/src/padding_individual_tests.rs b/crates/ui-egui/src/padding_individual_tests.rs new file mode 100644 index 000000000..e6397033e --- /dev/null +++ b/crates/ui-egui/src/padding_individual_tests.rs @@ -0,0 +1,75 @@ +//! Individual padding in the Design panel, driven headless: the four side fields only +//! appear once the toggle opens, and typing in one sets that side alone. + +use egui::accesskit::Role; +use egui::{Event, Id, Key, Modifiers, Pos2, pos2}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +/// An auto layout frame with uniform padding 10, selected. +fn scene() -> (Ui, NodeId) { + let mut s = Session::new(); + let f = + NodeId( + s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 200, "props": {"layout": "horizontal", "padding": 10}})) + .unwrap()["id"] + .as_u64() + .unwrap(), + ); + s.execute("select.set", &json!({"ids": [f]})).unwrap(); + (Ui::new(s), f) +} + +fn padding(u: &Ui, f: NodeId) -> [f64; 4] { + u.app.session.doc().unwrap().node(f).unwrap().layout.clone().unwrap().padding +} + +fn click(u: &mut Ui, p: Pos2) { + u.button(p, true); + u.button(p, false); + u.frame(vec![]); +} + +fn enter(u: &mut Ui) { + u.frame(vec![ + Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }, + Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }, + ]); +} + +/// The middle of the toggle button named `label`, found in the AccessKit tree. +fn named_button(u: &mut Ui, label: &str) -> Pos2 { + u.ctx.enable_accesskit(); + u.frame(vec![]); + let out = u.frame_output(vec![]); + let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes; + let n = nodes.iter().map(|(_, n)| n).find(|n| n.role() == Role::Button && n.label() == Some(label)).expect(label); + let b = n.bounds().expect("bounds"); + pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) +} + +#[test] +fn side_fields_show_only_once_the_toggle_opens_and_set_one_side() { + let (mut u, f) = scene(); + assert_eq!(padding(&u, f), [10.0; 4]); + // Shut: no per-side fields. + assert!(u.ctx.read_response(Id::new(("padding", 0)).with("field")).is_none(), "top field hidden until toggled"); + // Click the toggle: the four sides appear. + let t = named_button(&mut u, "Individual padding"); + click(&mut u, t); + let top = u.ctx.read_response(Id::new(("padding", 0)).with("field")).expect("top field shows").rect.center(); + assert!(u.ctx.read_response(Id::new(("padding", 3)).with("field")).is_some(), "left field shows"); + // Typing in the top field sets only the top, in one undo step. + let history = u.app.session.undo_depth().0; + click(&mut u, top); + u.frame(vec![Event::Text("8".into())]); + enter(&mut u); + u.frame(vec![]); + assert_eq!(padding(&u, f), [8.0, 10.0, 10.0, 10.0]); + assert_eq!(u.app.session.undo_depth().0, history + 1); + u.app.run("edit.undo", json!({})); + assert_eq!(padding(&u, f), [10.0; 4]); +} diff --git a/docs/auto-layout-parity.md b/docs/auto-layout-parity.md index ea2cc54f5..5342135a3 100644 --- a/docs/auto-layout-parity.md +++ b/docs/auto-layout-parity.md @@ -64,7 +64,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver | Nested hug/fill settling in one pass (three levels, wrapping fill text, grid rows) | done | | | Absolute position (ignore auto layout) with constraints | done | | | Strokes included in layout (inside strokes), canvas stacking | done | | -| On-canvas padding and gap handles, incl. wrap row/column gap and grid gaps | done | an automatic gap's handle shows the distance it has; dragging it fixes the gap from there (silent in the documentation) | +| On-canvas padding and gap handles, incl. wrap row/column gap and grid gaps | done | an automatic gap's handle shows the distance it has; dragging it fixes the gap from there (silent in the documentation); the Layout panel's Spacing row opens per-side padding fields (Top/Right/Bottom/Left) behind a toggle | | Drag to reorder (wrap-aware, by row or column), into grid cells, across nested frames | done | `al_drag_tests.rs` | | Arrow keys move children one place (flows, rows, columns, grid cells, into neighbours) | done | `al_keys_tests.rs` | | Constraints: left, right, left and right, centre, scale; top/bottom likewise | done | | @@ -77,6 +77,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Individual padding in the Layout panel (M2): the Spacing row's toggle opens per-side Top/Right/Bottom/Left fields in a 2×2 grid in place of the H/V fields, each bound to its own padding key with one undo step; shut H/V fields show both values ("16,54") when the sides disagree instead of "Mixed". Original code-drawn side icons, English and Spanish strings (`padding_individual_tests`). Numbers unchanged | | 2026-10-17 | minor | Community batch #753: adding auto layout to an empty frame keeps its size, Fixed, while a frame with content hugs it (#369); `node.set` applies auto layout keys to the frames among its targets and warns about the others, and a font style the family lacks warns with the nearest face (#603). Checked over MCP. Numbers unchanged | | 2026-10-13 | minor | Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. | | 2026-10-13 | minor | Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged. | From 7558ac0444277ae61fb9b3da603a6745c798f720 Mon Sep 17 00:00:00 2001 From: slyim <125447219+slyim@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:06:21 +0300 Subject: [PATCH 019/114] #826: doc comments for draw and main_toolbar Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/icons.rs | 1 + crates/ui-egui/src/toolbar.rs | 2 ++ 2 files changed, 3 insertions(+) diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 1a2b72a38..758a57ea2 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -421,6 +421,7 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { draw(&p, icon); } +/// Draw `icon` on the 16-unit grid through `p`, which maps grid units to the target box. fn draw(p: &P, icon: Icon) { use Icon::*; match icon { diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index aad447085..de5993b4a 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -94,6 +94,8 @@ pub(crate) fn main_width() -> f32 { GROUPS.len() as f32 * (cell + gap) + gap * 10.0 + 2.0 * (cell + gap) + 16.0 + 4.0 * 34.0 + 24.0 } +/// The main toolbar pill: the tool groups with their flyouts, Resources and Actions, then the +/// Draw · Design · Prototype · Inspect mode switcher. fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { let (cell, gap) = (32.0, 4.0); let width = main_width(); From 7a5bd57611ade8faffe20131db2cc7e463b583dc Mon Sep 17 00:00:00 2001 From: slyim <125447219+slyim@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:11:38 +0300 Subject: [PATCH 020/114] #826: the Slice handle overlaps the blade's base so the knife joins at every size Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/icons.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 758a57ea2..35adef116 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -459,9 +459,11 @@ fn draw(p: &P, icon: Icon) { p.fill_rect(2.0, 2.0, 9.0, 6.0, 1.0); } Slice => { - // A knife, one silhouette (#739): a slim blade to the top right, a solid handle. + // A knife, one silhouette (#739): a slim blade to the top right, outlined like the + // rest of the row, and a solid handle whose end overlaps the blade's base so the two + // join at every size. p.poly(&[(13.5, 2.5), (5.0, 8.0), (8.0, 11.0)], true); - p.fill(&capsule((2.8, 13.2), (5.4, 10.6), 1.3)); + p.fill(&capsule((2.8, 13.2), (5.9, 10.1), 1.3)); } Bend => { // A curve with a smooth point and its handle line. From aec3a22fdfa764b431fa0caa24f9c980456e5cb6 Mon Sep 17 00:00:00 2001 From: Ahmed5Emad Date: Sun, 18 Oct 2026 20:24:12 +0300 Subject: [PATCH 021/114] M2: shut padding pair fields edit their own axis The closed Spacing row paired each icon with the other axis: the side-bars field wrote top/bottom and the top/bottom-bars field wrote left/right. The H field now binds paddingHorizontal (left+right) and the V field paddingVertical (top+bottom); the PaddingH/PaddingV paintings were swapped to match so every icon is pixel-identical. (shut_pair_fields_edit_their_own_axis). --- crates/ui-egui/src/icons.rs | 14 ++++---- crates/ui-egui/src/layout_panel.rs | 30 ++++++++--------- .../ui-egui/src/padding_individual_tests.rs | 32 +++++++++++++++++++ 3 files changed, 54 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index f4f2f31ba..d9cb32ccb 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -938,17 +938,17 @@ fn draw(p: &P, icon: Icon) { p.line(3.0, 7.5, 13.0, 7.5); } PaddingH => { - // The horizontal axis: bars across the top and bottom in their frame. - p.rect(2.5, 2.5, 13.5, 13.5, 1.0); - p.fill_rect(2.5, 2.5, 13.5, 5.5, 1.0); - p.fill_rect(2.5, 10.5, 13.5, 13.5, 1.0); - } - PaddingV => { - // The vertical axis: bars down the left and right in their frame. + // Horizontal padding: bars down the padded left and right sides in their frame. p.rect(2.5, 2.5, 13.5, 13.5, 1.0); p.fill_rect(2.5, 2.5, 5.5, 13.5, 1.0); p.fill_rect(10.5, 2.5, 13.5, 13.5, 1.0); } + PaddingV => { + // Vertical padding: bars across the padded top and bottom in their frame. + p.rect(2.5, 2.5, 13.5, 13.5, 1.0); + p.fill_rect(2.5, 2.5, 13.5, 5.5, 1.0); + p.fill_rect(2.5, 10.5, 13.5, 13.5, 1.0); + } PaddingTop => { p.rect(2.5, 2.5, 13.5, 13.5, 1.0); p.fill_rect(2.5, 2.5, 13.5, 5.5, 1.0); diff --git a/crates/ui-egui/src/layout_panel.rs b/crates/ui-egui/src/layout_panel.rs index 72d44b9af..9f69c6c4c 100644 --- a/crates/ui-egui/src/layout_panel.rs +++ b/crates/ui-egui/src/layout_panel.rs @@ -456,21 +456,6 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { } row(ui, 32.0, |ui| { // Sides that disagree show both values ("16,54") instead of "Mixed". - let v_pair = format!("{},{}", widgets::fmt_num(pt), widgets::fmt_num(pb)); - let v_empty = if pt == pb { tr("Mixed") } else { v_pair.as_str() }; - let e = crate::variable_ui::number_field_or( - app, - ui, - ids, - "paddingVertical", - Id::new("al_padv"), - (pt == pb).then_some(pt), - col, - v_empty, - Lead::Icon(Icon::PaddingV, tr("Vertical padding")), - "", - ); - app.field(ids, "paddingVertical", e, |v| json!(v.max(0.0))); let h_pair = format!("{},{}", widgets::fmt_num(pl), widgets::fmt_num(pr)); let h_empty = if pl == pr { tr("Mixed") } else { h_pair.as_str() }; let e = crate::variable_ui::number_field_or( @@ -486,6 +471,21 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { "", ); app.field(ids, "paddingHorizontal", e, |v| json!(v.max(0.0))); + let v_pair = format!("{},{}", widgets::fmt_num(pt), widgets::fmt_num(pb)); + let v_empty = if pt == pb { tr("Mixed") } else { v_pair.as_str() }; + let e = crate::variable_ui::number_field_or( + app, + ui, + ids, + "paddingVertical", + Id::new("al_padv"), + (pt == pb).then_some(pt), + col, + v_empty, + Lead::Icon(Icon::PaddingV, tr("Vertical padding")), + "", + ); + app.field(ids, "paddingVertical", e, |v| json!(v.max(0.0))); if icons::button(ui, Icon::IndependentPadding, 24.0, false, tr("Individual padding")).clicked() { ui.data_mut(|d| d.insert_temp(Id::new("indep_padding"), true)); } diff --git a/crates/ui-egui/src/padding_individual_tests.rs b/crates/ui-egui/src/padding_individual_tests.rs index e6397033e..8fdb31925 100644 --- a/crates/ui-egui/src/padding_individual_tests.rs +++ b/crates/ui-egui/src/padding_individual_tests.rs @@ -73,3 +73,35 @@ fn side_fields_show_only_once_the_toggle_opens_and_set_one_side() { u.app.run("edit.undo", json!({})); assert_eq!(padding(&u, f), [10.0; 4]); } + +/// The middle of the shut pair field with accessible name `label`. +fn named_field(u: &mut Ui, label: &str) -> Pos2 { + u.ctx.enable_accesskit(); + u.frame(vec![]); + let out = u.frame_output(vec![]); + let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes; + let n = nodes.iter().map(|(_, n)| n).find(|n| n.role() != Role::Button && n.label() == Some(label)).expect(label); + let b = n.bounds().expect("bounds"); + pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) +} + +/// The shut pair fields edit their own axis: horizontal touches left and right +/// only, vertical touches top and bottom only. +#[test] +fn shut_pair_fields_edit_their_own_axis() { + let (mut u, f) = scene(); + u.app.run("node.set", json!({"ids": [f], "props": {"paddingLeft": 16.0, "paddingRight": 54.0}})); + assert_eq!(padding(&u, f), [10.0, 54.0, 10.0, 16.0]); + let h = named_field(&mut u, "Horizontal padding"); + click(&mut u, h); + u.frame(vec![Event::Text("20".into())]); + enter(&mut u); + u.frame(vec![]); + assert_eq!(padding(&u, f), [10.0, 20.0, 10.0, 20.0]); + let v = named_field(&mut u, "Vertical padding"); + click(&mut u, v); + u.frame(vec![Event::Text("7".into())]); + enter(&mut u); + u.frame(vec![]); + assert_eq!(padding(&u, f), [7.0, 20.0, 7.0, 20.0]); +} From 72990373c4c89e582bafaae14a6b75c8287fcda2 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:27:45 +0200 Subject: [PATCH 022/114] Keep multiline layer names inside the SwiftUI/Compose too-deep comment Found while testing crates/engine/src/codegen.rs:865 (swift_layer) and :1038 (compose_layer) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/codegen.rs | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index 6dd806462..9741e4700 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -858,11 +858,16 @@ pub(crate) fn swift(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String, extra swift_layer(ctx, n, depth, out, extra); } +/// A layer name safe for a single-line `//` comment: line breaks become spaces. +fn one_line(name: &str) -> String { + name.replace(['\n', '\r', '\u{85}', '\u{2028}', '\u{2029}'], " ") +} + fn swift_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[String]) { let doc = ctx.doc; let pad = indent(depth); if ctx.too_deep(depth) { - out.push_str(&format!("{pad}// {} …\n", n.name)); + out.push_str(&format!("{pad}// {} …\n", one_line(&n.name))); return; } // Nested instances are calls; an instance swap parameter is a view. @@ -1035,7 +1040,7 @@ fn compose_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) { let doc = ctx.doc; let pad = indent(depth); if ctx.too_deep(depth) { - out.push_str(&format!("{pad}// {} …\n", n.name)); + out.push_str(&format!("{pad}// {} …\n", one_line(&n.name))); return; } // Nested instances are calls; an instance swap parameter is a composable. @@ -1168,4 +1173,26 @@ mod tests { assert!(kt.contains("Modifier.offset(7.dp, 9.dp)")); assert!(code_for(doc, NodeId(999_999), CodeLang::Css).is_empty()); } + + #[test] + fn too_deep_placeholder_keeps_a_multiline_layer_name_in_the_comment() { + let mut s = Session::new(); + let name = "deep\nevil()\r\u{2028}more()"; + let mut parent = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 50, "name": name})).unwrap()["id"]).unwrap(); + let root = parent; + for _ in 0..16 { + parent = + id_of(&s.execute("frame.create", &json!({"x": 1, "y": 1, "width": 40, "height": 40, "parent": parent, "name": name})).unwrap()["id"]) + .unwrap(); + } + let doc = s.doc().unwrap(); + for lang in [CodeLang::SwiftUi, CodeLang::Compose] { + let code = code_for(doc, root, lang); + assert!(code.contains("// deep evil() more() …\n"), "{code}"); + for line in code.lines() { + let t = line.trim_start(); + assert!(!t.starts_with("evil()") && !t.starts_with("more()"), "layer name leaked as code:\n{code}"); + } + } + } } From 309bac6185ee13d109836a48608680cfb6087298 Mon Sep 17 00:00:00 2001 From: Muamer Mustafic Date: Sun, 18 Oct 2026 19:32:46 +0200 Subject: [PATCH 023/114] =?UTF-8?q?M7:=20the=20web=20build=20opens=20files?= =?UTF-8?q?:=20File=20=E2=96=B8=20Open,=20Import=20and=20dropped=20files?= =?UTF-8?q?=20(#759)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File ▸ Open, Import and Place image did nothing in the browser (the pickers return a path, which a browser never has) and dropped files were ignored (eframe hands their contents over asynchronously). - Services::pick_open_contents: a platform can pick a file's name and contents; the web build uses an . Open and Import pass them to file.open / file.import as data + name. - A dropped file without a path waits behind the opening card until its contents are read (bytes_async on wasm), or says it couldn't be read. - Canvas drops still import, as on the desktop. Closes #759. Co-authored-by: Cursor --- apps/figcraft-web/Cargo.toml | 2 +- apps/figcraft-web/src/web.rs | 56 +++++++++- crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/src/async_dialog_tests.rs | 40 +++++++- crates/ui-egui/src/canvas.rs | 12 +-- crates/ui-egui/src/home.rs | 13 +-- crates/ui-egui/src/home_tests.rs | 62 +++++++++++ crates/ui-egui/src/import_report_tests.rs | 1 + crates/ui-egui/src/lib.rs | 120 ++++++++++++++++------ crates/ui-egui/src/pending_open.rs | 53 ++++++++-- docs/hardware-parity.md | 5 +- 12 files changed, 302 insertions(+), 64 deletions(-) diff --git a/apps/figcraft-web/Cargo.toml b/apps/figcraft-web/Cargo.toml index 2f1a8190f..ffd44f3bc 100644 --- a/apps/figcraft-web/Cargo.toml +++ b/apps/figcraft-web/Cargo.toml @@ -26,4 +26,4 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } wasm-bindgen = "0.2" js-sys = "0.3" wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] } diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index 292136212..015112527 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -1,7 +1,7 @@ //! The browser shell: web `Services` and the eframe web runner. use figcraft_engine::Session; -use figcraft_ui_egui::{FigApp, Services}; +use figcraft_ui_egui::{ContentsFuture, FigApp, Services}; use wasm_bindgen::JsCast as _; const CANVAS_ID: &str = "figcraft_canvas"; @@ -75,9 +75,10 @@ impl eframe::App for WebShell { } } -/// Browser services. Open dialogs return nothing for now (the trait's defaults); files are saved -/// as downloads. Copies go to the system clipboard through egui's output; pastes arrive as paste -/// events with their text (a page can't read the clipboard when it likes). +/// Browser services. Files are opened and imported through the browser's file picker, which hands +/// over their contents (pickers that need a path return nothing, the trait's defaults); files are +/// saved as downloads. Copies go to the system clipboard through egui's output; pastes arrive as +/// paste events with their text (a page can't read the clipboard when it likes). struct WebServices { /// `prefers-reduced-motion: reduce` (it follows the setting while the page is open). reduce_motion: Option, @@ -85,6 +86,16 @@ struct WebServices { } impl Services for WebServices { + /// An `` taking `exts`, clicked; its file is read once picked. + fn pick_open_contents(&mut self, _title: &str, exts: &[&str]) -> Option { + let accept = exts.iter().map(|e| format!(".{e}")).collect::>().join(","); + Some(Box::pin(async move { + pick_file(&accept).await.unwrap_or_else(|e| { + log::error!("couldn't open a file: {e:?}"); + None + }) + })) + } fn downloads(&self) -> bool { true } @@ -136,6 +147,43 @@ impl Services for WebServices { } } +/// The largest file read from the picker (browsers read at most 4 GiB into one buffer). +const MAX_PICKED_BYTES: f64 = u32::MAX as f64; + +/// The file picked in the browser's file picker (`accept`: the extensions it offers), with its +/// name; `None` when the picker is cancelled. +async fn pick_file(accept: &str) -> Result)>, wasm_bindgen::JsValue> { + let window = web_sys::window().ok_or("no window")?; + let document = window.document().ok_or("no document")?; + let body = document.body().ok_or("no body")?; + let input: web_sys::HtmlInputElement = document.create_element("input")?.dyn_into()?; + input.set_type("file"); + input.set_accept(accept); + input.set_hidden(true); + // Some browsers (older Safari) open the picker only for an input in the page. + body.append_child(&input)?; + // Settles on `change` (a file was picked) or `cancel` (the picker was closed without one). + let mut settle = |resolve: js_sys::Function, _reject: js_sys::Function| { + let done = wasm_bindgen::closure::Closure::once_into_js(move || { + let _ = resolve.call0(&wasm_bindgen::JsValue::NULL); + }); + let _ = input.add_event_listener_with_callback("change", done.unchecked_ref()); + let _ = input.add_event_listener_with_callback("cancel", done.unchecked_ref()); + }; + let closed = js_sys::Promise::new(&mut settle); + input.click(); + let result = wasm_bindgen_futures::JsFuture::from(closed).await; + let file = input.files().and_then(|files| files.get(0)); + input.remove(); + result?; + let Some(file) = file else { return Ok(None) }; + if file.size() > MAX_PICKED_BYTES { + return Err(format!("{} is too large to read in the browser", file.name()).into()); + } + let buffer = wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await?; + Ok(Some((file.name(), js_sys::Uint8Array::new(&buffer).to_vec()))) +} + fn download(name: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> { let window = web_sys::window().ok_or("no window")?; let document = window.document().ok_or("no document")?; diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 17f6b4c5f..5d5b18563 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -319,6 +319,7 @@ name = "English" "Corner smoothing" = "Corner smoothing" "Couldn't download the variables" = "Couldn't download the variables" "Couldn't reach {server}: showing what this computer remembers." = "Couldn't reach {server}: showing what this computer remembers." +"Couldn't read {name}." = "Couldn't read {name}." "Couldn't save automatically: {error}" = "Couldn't save automatically: {error}" "Count" = "Count" "Create" = "Create" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index d054e2223..56b6d29c2 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -320,6 +320,7 @@ status = "draft — needs native review" "Corner radius" = "Radio de esquina" "Corner smoothing" = "Suavizado de esquinas" "Couldn't reach {server}: showing what this computer remembers." = "No se pudo contactar con {server}: se muestra lo que este ordenador recuerda." +"Couldn't read {name}." = "No se pudo leer {name}." "Couldn't save automatically: {error}" = "No se pudo guardar automáticamente: {error}" "Count" = "Número" "Create" = "Crear" diff --git a/crates/ui-egui/src/async_dialog_tests.rs b/crates/ui-egui/src/async_dialog_tests.rs index 26808fe5d..ad5dcfbc8 100644 --- a/crates/ui-egui/src/async_dialog_tests.rs +++ b/crates/ui-egui/src/async_dialog_tests.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use figcraft_engine::Session; -use crate::{DialogFuture, FigApp, Services}; +use crate::{ContentsFuture, DialogFuture, FigApp, Services}; struct OpenFile(PathBuf); @@ -79,6 +79,44 @@ fn save_as_saves_the_file_it_was_opened_for_after_another_tab_comes_to_the_front let _ = std::fs::remove_dir_all(dir); } +/// The browser: the picker hands over a file's name and contents, never a path. +struct Contents(&'static str, Vec); + +impl Services for Contents { + fn pick_open(&mut self, _title: &str, _exts: &[&str]) -> DialogFuture { + panic!("a platform that picks contents is never asked for a path") + } + fn pick_open_contents(&mut self, _title: &str, _exts: &[&str]) -> Option { + Some(Box::pin(std::future::ready(Some((self.0.to_string(), self.1.clone()))))) + } +} + +#[test] +fn open_dialog_opens_picked_contents_as_a_new_unsaved_document() { + let bytes = figcraft_format::save_bytes(&figcraft_doc::Document::new("Picked"), None).unwrap(); + let mut app = FigApp::new(Session::empty(), Box::new(Contents("Picked.figcraft", bytes))); + app.ui.home = true; + app.open_dialog(); + assert!(app.session.docs.is_empty(), "opening waits for the picker result"); + app.poll_dialog(&egui::Context::default()); + assert_eq!(app.session.docs.len(), 1); + assert_eq!(app.session.docs[0].path, None, "no path to save over"); + assert_eq!(app.session.docs[0].doc.name, "Picked"); + assert!(!app.ui.home, "and the editor shows it"); +} + +#[test] +fn import_dialog_imports_picked_contents_into_the_file_in_front() { + let svg = br#""#.to_vec(); + let mut app = FigApp::new(Session::new(), Box::new(Contents("Logo.svg", svg))); + let rectangles = |app: &FigApp| app.session.doc().unwrap().nodes().filter(|n| matches!(n.kind, figcraft_doc::NodeKind::Rectangle)).count(); + assert_eq!(rectangles(&app), 0); + app.import_dialog(false); + app.poll_dialog(&egui::Context::default()); + assert_eq!(app.session.docs.len(), 1, "imported, not opened"); + assert_eq!(rectangles(&app), 1, "the SVG's content"); +} + #[test] fn a_dialog_whose_file_closed_meanwhile_does_nothing() { let (mut app, dir, path) = two_files("closed"); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9e0c1d797..ce003f66c 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -507,21 +507,17 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { let path = f.path().to_path_buf(); let pt = hover_pos.map(|h| app.to_page(h)).unwrap_or_default(); let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped.png".into()); - let params = if path.exists() { - json!({"path": path.to_string_lossy(), "x": pt.x, "y": pt.y}) - } else if let Some(bytes) = crate::dropped_bytes(&f) { - use base64::Engine as _; - let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); - json!({"data": b64, "name": name, "x": pt.x, "y": pt.y}) + let (params, read) = if path.exists() { + (json!({"path": path.to_string_lossy(), "x": pt.x, "y": pt.y}), None) } else { - continue; + (json!({"name": name, "x": pt.x, "y": pt.y}), Some(crate::dropped_bytes(&f))) }; let Some(target) = app.session.ds().ok().map(|doc| crate::pending_open::ImportTarget { uid: doc.uid, page: doc.page, name: doc.doc.name.clone() }) else { continue; }; - app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.import", target: Some(target) }); + app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.import", target: Some(target), read }); } let Some(pos) = hover_pos.filter(|p| app.canvas.rect.contains(*p)) else { // A drag released off the canvas, or off the window, ends where the pointer was last seen. diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 236e27861..0d372d1bd 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -187,16 +187,9 @@ fn dropped(app: &mut FigApp, ctx: &egui::Context) { } // `opens` saw an extension, so there is a file name to tell the format by. let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - let params = if path.exists() { - json!({"path": path.to_string_lossy()}) - } else if let Some(bytes) = crate::dropped_bytes(&f) { - use base64::Engine as _; - let data = base64::engine::general_purpose::STANDARD.encode(&bytes); - json!({"data": data, "name": name}) - } else { - continue; - }; - app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.open", target: None }); + let (params, read) = + if path.exists() { (json!({"path": path.to_string_lossy()}), None) } else { (json!({"name": name}), Some(crate::dropped_bytes(&f))) }; + app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.open", target: None, read }); } } diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs index c493a0a1d..ea812ea09 100644 --- a/crates/ui-egui/src/home_tests.rs +++ b/crates/ui-egui/src/home_tests.rs @@ -573,6 +573,68 @@ fn dropping_a_file_without_one_on_disk_opens_it_as_a_new_unsaved_document() { assert!(!path.exists(), "nothing is written"); } +/// A drop whose contents arrive later, as every drop in the browser does: `None` until `arrived` +/// holds them. +fn later(arrived: &std::rc::Rc>>>>) -> crate::ReadFuture { + let arrived = arrived.clone(); + Box::pin(std::future::poll_fn(move |_| match arrived.borrow_mut().take() { + Some(bytes) => std::task::Poll::Ready(bytes), + None => std::task::Poll::Pending, + })) +} + +#[test] +fn a_dropped_file_whose_contents_arrive_later_opens_once_they_do() { + let svg = br#""#.to_vec(); + let arrived = std::rc::Rc::new(std::cell::RefCell::new(None)); + let mut u = first_run(); + u.app.ui.home = true; + u.app.ui.pending_opens.push(crate::pending_open::PendingOpen { + label: "Browser drop.svg".into(), + params: json!({"name": "Browser drop.svg"}), + command: "file.open", + target: None, + read: Some(later(&arrived)), + }); + // egui lays a new card out invisibly on its first frame. + u.frame(vec![]); + for _ in 0..5 { + let s = flat(&u.frame_output(vec![]).shapes); + assert!(has_text(&s, "Browser drop.svg"), "the card shows while the contents are read"); + } + assert!(u.app.session.docs.is_empty(), "nothing opens before the contents arrive"); + *arrived.borrow_mut() = Some(Some(svg)); + for _ in 0..3 { + u.frame(vec![]); + } + assert!(u.app.ui.pending_opens.is_empty()); + assert_eq!(u.app.ui.last_error, None); + assert_eq!(u.app.session.docs.len(), 1, "the contents open"); + assert_eq!(u.app.session.docs[0].path, None); + assert!(u.app.session.docs[0].doc.nodes().any(|n| matches!(n.kind, figcraft_doc::NodeKind::Rectangle)), "the SVG's content"); + assert!(!u.app.ui.home); +} + +#[test] +fn a_dropped_file_that_cannot_be_read_says_so_and_opens_nothing() { + let arrived = std::rc::Rc::new(std::cell::RefCell::new(Some(None))); + let mut u = first_run(); + u.app.ui.home = true; + u.app.ui.pending_opens.push(crate::pending_open::PendingOpen { + label: "Unreadable.figcraft".into(), + params: json!({"name": "Unreadable.figcraft"}), + command: "file.open", + target: None, + read: Some(later(&arrived)), + }); + for _ in 0..3 { + u.frame(vec![]); + } + assert!(u.app.ui.pending_opens.is_empty()); + assert!(u.app.session.docs.is_empty()); + assert_eq!(u.app.ui.last_error.as_deref(), Some("Couldn't read Unreadable.figcraft.")); +} + #[test] fn dropping_a_native_file_on_the_home_screen_opens_it() { let dir = Temp::new(); diff --git a/crates/ui-egui/src/import_report_tests.rs b/crates/ui-egui/src/import_report_tests.rs index a24115823..8c10c7f3e 100644 --- a/crates/ui-egui/src/import_report_tests.rs +++ b/crates/ui-egui/src/import_report_tests.rs @@ -177,6 +177,7 @@ fn a_dropped_file_shows_its_report_once_it_opens() { params: json!({"path": path.to_string_lossy()}), command: "file.open", target: None, + read: None, }); for _ in 0..6 { u.frame(vec![]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 450317b43..026228dc3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -236,6 +236,11 @@ pub trait Services { fn pick_open(&mut self, _title: &str, _exts: &[&str]) -> DialogFuture { Box::pin(std::future::ready(None)) } + /// Pick a file and read it, where the platform hands over a file's name and contents rather + /// than a path to it (the browser). `None`: the platform picks paths ([`Services::pick_open`]). + fn pick_open_contents(&mut self, _title: &str, _exts: &[&str]) -> Option { + None + } fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> DialogFuture { Box::pin(std::future::ready(None)) } @@ -290,14 +295,38 @@ pub trait Services { } pub type DialogFuture = Pin>>>; -type DialogDone = Box; +/// A picked file's name and contents ([`Services::pick_open_contents`]). +pub type ContentsFuture = Pin)>>>>; +/// A dropped file's contents, read once they arrive (`None`: they couldn't be read). +pub type ReadFuture = Pin>>>>; +type PickFuture = Pin>>>; +type DialogDone = Box; + +/// What a dialog picked: a path, or a file's name and contents where there are no paths. +enum Picked { + Path(PathBuf), + Contents(String, Vec), +} + +impl Picked { + /// The file as `file.open` and `file.import` take it. + fn file_params(self) -> Value { + match self { + Picked::Path(p) => json!({"path": p.to_string_lossy()}), + Picked::Contents(name, bytes) => { + use base64::Engine as _; + json!({"data": base64::engine::general_purpose::STANDARD.encode(&bytes), "name": name}) + } + } + } +} /// A native dialog in flight. The app keeps working while it is open, so its result goes to the /// file and window it was opened for, not to whichever is in front when it closes. struct PendingDialog { - future: DialogFuture, - /// The path picked, kept until the window that holds `doc` draws. - path: Option, + future: PickFuture, + /// What was picked, kept until the window that holds `doc` draws. + picked: Option, done: DialogDone, /// The file it acts on (`None`: none, such as Open). doc: Option, @@ -1099,16 +1128,11 @@ impl FigApp { pub fn open_dialog(&mut self) { // Opening needs no file in front: it goes ahead even if that file closed meanwhile. - self.start_dialog( - |services| services.pick_open(i18n::tr("Open"), OPEN_EXTENSIONS), - false, - |app, p| { - let ps = p.to_string_lossy().to_string(); - if app.run_user("file.open", json!({"path": ps})).is_some() { - app.ui.home = false; - } - }, - ); + self.pick_file(i18n::tr("Open"), OPEN_EXTENSIONS, false, |app, file| { + if app.run_user("file.open", file).is_some() { + app.ui.home = false; + } + }); } pub fn import_dialog(&mut self, place: bool) { @@ -1118,12 +1142,30 @@ impl FigApp { &["svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff", "figcraft", "fig"] }; let title = if place { i18n::tr("Place image") } else { i18n::tr("Import") }; - self.pick_open(title, exts, move |app, p| { - let ps = p.to_string_lossy().to_string(); - app.run_user("file.import", json!({"path": ps})); + self.pick_file(title, exts, true, |app, file| { + app.run_user("file.import", file); }); } + /// Pick a file to open or import; `done` gets it as `file.open` and `file.import` take it (a + /// path, or the name and contents where the platform has no paths). `pin`: as + /// [`FigApp::pick_open`]'s file in front. + fn pick_file(&mut self, title: &str, exts: &[&str], pin: bool, done: impl FnOnce(&mut FigApp, Value) + 'static) { + self.start_pick( + |services| -> PickFuture { + match services.pick_open_contents(title, exts) { + Some(read) => Box::pin(async move { read.await.map(|(name, bytes)| Picked::Contents(name, bytes)) }), + None => { + let pick = services.pick_open(title, exts); + Box::pin(async move { pick.await.map(Picked::Path) }) + } + } + }, + pin, + move |app, picked| done(app, picked.file_params()), + ); + } + /// Pick a file to open. `done` runs with the file in front now back in front (and not at all /// if that file closed meanwhile). pub fn pick_open(&mut self, title: &str, exts: &[&str], done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { @@ -1142,12 +1184,28 @@ impl FigApp { /// Open a native dialog from the window drawing now, for the file in front (`pin`). fn start_dialog(&mut self, start: impl FnOnce(&mut dyn Services) -> DialogFuture, pin: bool, done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { + self.start_pick( + |services| -> PickFuture { + let pick = start(services); + Box::pin(async move { pick.await.map(Picked::Path) }) + }, + pin, + move |app, picked| { + if let Picked::Path(p) = picked { + done(app, p); + } + }, + ); + } + + /// [`FigApp::start_dialog`] for any kind of pick. + fn start_pick(&mut self, start: impl FnOnce(&mut dyn Services) -> PickFuture, pin: bool, done: impl FnOnce(&mut FigApp, Picked) + 'static) { if self.pending_dialog.is_some() { return; } let doc = if pin { self.session.ds().ok().filter(|d| self.shows(d.uid)).map(|d| d.uid) } else { None }; let future = start(self.services.as_mut()); - self.pending_dialog = Some(PendingDialog { future, path: None, done: Box::new(done), doc, window: self.windows.current }); + self.pending_dialog = Some(PendingDialog { future, picked: None, done: Box::new(done), doc, window: self.windows.current }); if let Some(ctx) = &self.ui_context { ctx.request_repaint(); } @@ -1157,12 +1215,12 @@ impl FigApp { /// that file in front; then the tab in front before comes back (unless the dialog opened a file). fn poll_dialog(&mut self, ctx: &egui::Context) { let Some(d) = &mut self.pending_dialog else { return }; - if d.path.is_none() { + if d.picked.is_none() { let waker = Waker::from(Arc::new(RepaintWaker(ctx.clone()))); - let Poll::Ready(path) = d.future.as_mut().poll(&mut TaskContext::from_waker(&waker)) else { return }; - d.path = path; + let Poll::Ready(picked) = d.future.as_mut().poll(&mut TaskContext::from_waker(&waker)) else { return }; + d.picked = picked; } - let (doc, origin, picked) = (d.doc, d.window, d.path.is_some()); + let (doc, origin, picked) = (d.doc, d.window, d.picked.is_some()); let index = doc.map(|u| self.session.docs.iter().position(|ds| ds.uid == u)); // Cancelled, or its file closed meanwhile. if !picked || index == Some(None) { @@ -1178,12 +1236,12 @@ impl FigApp { ctx.request_repaint_of(windows::viewport(window)); return; } - let Some(PendingDialog { path: Some(path), done, .. }) = self.pending_dialog.take() else { return }; + let Some(PendingDialog { picked: Some(picked), done, .. }) = self.pending_dialog.take() else { return }; let before = self.session.ds().ok().map(|d| d.uid); if let Some(Some(i)) = index { self.session.active = i; } - done(self, path); + done(self, picked); if doc.is_some() && self.session.ds().ok().map(|d| d.uid) == doc && let Some(i) = before.and_then(|u| self.session.docs.iter().position(|d| d.uid == u)) @@ -1693,17 +1751,17 @@ pub(crate) fn opens(path: &Path) -> bool { path.extension().and_then(|e| e.to_str()).is_some_and(|e| OPEN_EXTENSIONS.iter().any(|x| x.eq_ignore_ascii_case(e))) } -/// The contents of a dropped file that has no file on disk to read by path. +/// Reads the contents of a dropped file that has no file on disk to read by path. #[cfg(not(target_arch = "wasm32"))] -pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle) -> Option> { - f.bytes().ok() +pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle) -> ReadFuture { + Box::pin(std::future::ready(f.bytes().ok())) } -/// The contents of a dropped file that has no file on disk to read by path. +/// Reads the contents of a dropped file; the browser hands them over asynchronously. #[cfg(target_arch = "wasm32")] -pub(crate) fn dropped_bytes(_f: &egui::DroppedFileHandle) -> Option> { - // Browser drops arrive asynchronously; not supported yet. - None +pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle) -> ReadFuture { + let f = f.clone(); + Box::pin(async move { f.bytes_async().await.ok() }) } /// While files are dragged over the window: a tinted, outlined `rect` with `label` in its middle, diff --git a/crates/ui-egui/src/pending_open.rs b/crates/ui-egui/src/pending_open.rs index 9d19b9420..ef798eca9 100644 --- a/crates/ui-egui/src/pending_open.rs +++ b/crates/ui-egui/src/pending_open.rs @@ -1,13 +1,17 @@ //! Files dropped on the home screen or the canvas open a frame later, behind a card that says what //! is happening: opening a large `.fig` blocks the UI thread for a while, and the card is on screen -//! before it does (rather than a window that freezes without a word). +//! before it does (rather than a window that freezes without a word). A file with no path to read +//! (every drop in the browser) waits behind the card until its contents arrive. + +use std::sync::Arc; +use std::task::{Context as TaskContext, Poll, Waker}; use figcraft_doc::NodeId; -use serde_json::Value; +use serde_json::{Value, json}; -use crate::FigApp; use crate::i18n::{tr, trf, trn}; use crate::theme::{Tokens, semibold}; +use crate::{FigApp, ReadFuture, RepaintWaker}; /// A dropped file waiting to be opened (`file.open`, a document of its own) or imported /// (`file.import`, into the current document). @@ -18,6 +22,8 @@ pub struct PendingOpen { pub command: &'static str, /// Canvas imports stay with the document and page that received the drop. pub target: Option, + /// The file's contents still on their way; they go into `params` as `data` once read. + pub read: Option, } pub struct ImportTarget { @@ -74,15 +80,48 @@ fn cancel(app: &mut FigApp, message: String) { /// frame, so the second one is the first the window shows. const FRAMES_BEFORE_OPENING: u8 = 2; -/// The card while files wait, then (once it has been on screen) open them. Called before the import -/// report, so a report for these files shows after them. +/// Polls the contents still being read: those that arrived go into their file's parameters, files +/// that couldn't be read are dropped with a message. Whether any are still on their way. +fn read_contents(app: &mut FigApp, ctx: &egui::Context) -> bool { + let waker = Waker::from(Arc::new(RepaintWaker(ctx.clone()))); + let mut cx = TaskContext::from_waker(&waker); + let mut reading = false; + let mut unread = Vec::new(); + app.ui.pending_opens.retain_mut(|pending| { + let Some(read) = pending.read.as_mut() else { return true }; + match read.as_mut().poll(&mut cx) { + Poll::Pending => { + reading = true; + true + } + Poll::Ready(Some(bytes)) => { + use base64::Engine as _; + pending.params["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&bytes)); + pending.read = None; + true + } + Poll::Ready(None) => { + unread.push(pending.label.clone()); + false + } + } + }); + for name in unread { + cancel(app, trf("Couldn't read {name}.", &[("name", &name)])); + } + reading +} + +/// The card while files wait, then (once it has been on screen and their contents are read) open +/// them. Called before the import report, so a report for these files shows after them. pub fn pending_opens(app: &mut FigApp, ctx: &egui::Context) { + let reading = read_contents(app, ctx); if app.ui.pending_opens.is_empty() { app.ui.pending_open_frames = 0; return; } ctx.request_repaint(); - if app.ui.pending_open_frames >= FRAMES_BEFORE_OPENING { + if app.ui.pending_open_frames >= FRAMES_BEFORE_OPENING && !reading { app.ui.pending_open_frames = 0; for pending in std::mem::take(&mut app.ui.pending_opens) { let command = pending.command; @@ -92,7 +131,7 @@ pub fn pending_opens(app: &mut FigApp, ctx: &egui::Context) { } return; } - app.ui.pending_open_frames += 1; + app.ui.pending_open_frames = app.ui.pending_open_frames.saturating_add(1); let title = match app.ui.pending_opens.first().map(|p| p.command) { Some("file.import") => { let name = app diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index df8229818..bc0de68dc 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-18 · **Change:** minor (macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (the web build opens files: File ▸ Open, Import and Place image through the browser's file picker, dropped files read (#759); macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out @@ -43,13 +43,14 @@ Nothing has been timed against Figma on the same document (needs `.fig` import). | 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 | | 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) | | 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; no pasting images from other apps) | +| Web | primary (figma.com) | `figcraft-web` (WASM, WebGPU/WebGL2), web zip | no accessibility tree; file access through the browser: File ▸ Open, Import and Place image use its file picker, dropped files are read (a drop on the home screen opens, a drop on the canvas imports, as on the desktop), saving and exports are downloads; pickers for libraries, variables, plugin manifests and image fills still need a path and do nothing there; copy and paste through the system clipboard (copies written from the copy event, pastes read from the paste event; no pasting images from other apps) | | iOS / Android | apps: view, comment, mirror prototypes | none (the web build is not touch-first) | 20–40 h for a viewer | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Opening files in the web build (#759): File ▸ Open, Import and Place image did nothing there (the pickers return a path, which a browser never has) and dropped files were ignored (eframe hands their contents over asynchronously). A platform can now pick a file's name and contents (`Services::pick_open_contents`; the browser's ``), which `file.open` and `file.import` take as `data` and `name`, and a dropped file without a path waits behind the opening card until its contents arrive, or says it couldn't be read (`async_dialog_tests`, `home_tests`; the browser half built for wasm, not run in a browser). Numbers unchanged | | 2026-10-18 | minor | macOS quit crash (#781): on Macs with a Touch Bar the app could crash as it quit (an exception in AppKit's Touch Bar support while the window's content view, which the accessibility adapter subclasses, was torn down). Quitting now keeps everything (preferences, this run's recovery copies, live sessions ended) and then ends the process before that teardown. Quit from the menu bar ends the app without the unsaved-changes question, so unsaved files' recovery copies now stay and reopen at the next start instead of being removed. Numbers unchanged | | 2026-10-17 | minor | Linux row: clipboard reads on Linux and the BSDs time out after 2 s on a helper thread, images copied in other apps paste under Wayland (arboard's data-control feature, #693), a minimised window can't be detected under Wayland, so frame-only control requests may still wait there (#720), and Wayland compositors without server-side decorations give the window Adwaita controls (#592, checked by its author on GNOME, not by the maintainers; KDE not run); compiled in CI, not run on a Linux desktop. HiDPI row: native-density canvas images placed on whole device pixels (#359, geometry tests; not judged by eye). Numbers unchanged | | 2026-10-14 | minor | Copy and paste in the browser: the web build never wrote to the system clipboard, so Cmd+V after Cmd+C pasted whatever text the clipboard held before (as a text layer) instead of the copied layers. Copies (layers, Copy as SVG/CSS/text/PNG, links) now go out through egui's output, which eframe writes during the copy event (`web_clipboard_tests`). Numbers unchanged. | From 54458cfb20b3e8f9164dbcd065b669891a204c3b Mon Sep 17 00:00:00 2001 From: slyim <125447219+slyim@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:52:39 +0300 Subject: [PATCH 024/114] #826: tests for the inset Frame and Text marks and the connected Slice knife frame_and_text_marks_are_inset fails if either mark reaches the box edge again; slice_is_one_connected_knife fails if the handle stops short of the blade's base or the icon goes back to separate marks. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/icons.rs | 55 +++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 35adef116..3ef6b3726 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -1233,4 +1233,59 @@ mod tests { assert!(!rotation.is_empty() && !radius.is_empty(), "both draw an icon"); assert_ne!(rotation, radius); } + + /// The shapes `icon` paints in a 32-point box at the origin: two points per grid unit. + fn shapes_at_32(icon: Icon) -> Vec { + let ctx = egui::Context::default(); + let mut out = + ctx.run_ui(egui::RawInput::default(), |ui| paint(ui.painter(), Rect::from_min_size(Pos2::ZERO, Vec2::splat(32.0)), icon, Color32::WHITE)); + out.textures_delta.clear(); + out.shapes.into_iter().map(|c| c.shape).collect() + } + + /// Frame and Text stay a grid unit inside the box, so their long hinted strokes don't + /// outweigh the outlines beside them in the toolbar (#737). + #[test] + fn frame_and_text_marks_are_inset() { + for icon in [Icon::Frame, Icon::Text] { + let pts: Vec = shapes_at_32(icon) + .iter() + .flat_map(|s| match s { + Shape::LineSegment { points, .. } => points.to_vec(), + other => panic!("{icon:?} draws straight strokes only: {other:?}"), + }) + .collect(); + assert!(!pts.is_empty()); + for p in pts { + // Grid units 3..13, two points each. + assert!((6.0..=26.0).contains(&p.x) && (6.0..=26.0).contains(&p.y), "{icon:?} reaches {p:?}"); + } + } + } + + /// Slice is one knife (#739): an outlined blade and a solid handle that covers the middle of + /// the blade's base, so the two read as one mark rather than separate pieces. + #[test] + fn slice_is_one_connected_knife() { + let shapes = shapes_at_32(Icon::Slice); + let paths: Vec<_> = shapes + .iter() + .map(|s| match s { + Shape::Path(p) => p, + other => panic!("Slice draws paths only: {other:?}"), + }) + .collect(); + let blade = paths.iter().find(|p| p.closed && p.fill == Color32::TRANSPARENT).expect("an outlined blade"); + let handle = paths.iter().find(|p| p.fill != Color32::TRANSPARENT).expect("a solid handle"); + assert_eq!((paths.len(), blade.points.len()), (2, 3), "a blade and a handle, nothing else"); + // The blade's base runs between its second and third points; the handle is convex. + let base = blade.points[1] + (blade.points[2] - blade.points[1]) / 2.0; + let h = &handle.points; + let side = |i: usize| { + let (a, b) = (h[i], h[(i + 1) % h.len()]); + (b - a).x * (base - a).y - (b - a).y * (base - a).x + }; + let inside = (0..h.len()).all(|i| side(i) >= 0.0) || (0..h.len()).all(|i| side(i) <= 0.0); + assert!(inside, "the handle covers the blade's base at {base:?}"); + } } From 02e8f644fde722ee647228cbacc9c090dab9a09b Mon Sep 17 00:00:00 2001 From: Ahmed5Emad Date: Sun, 18 Oct 2026 21:02:22 +0300 Subject: [PATCH 025/114] M2: doc comments for the padding diff's functions --- crates/engine/src/cmd/layout.rs | 1 + crates/ui-egui/src/layout_panel.rs | 2 ++ crates/ui-egui/src/padding_individual_tests.rs | 5 +++++ 3 files changed, 8 insertions(+) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index b545e52d2..da1301ec0 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -149,6 +149,7 @@ mod tests { assert!(s.execute("node.set", &json!({"id": b, "props": {"absolute": "maybe"}})).is_err()); } + /// Each side key writes its own padding slot and leaves the other three alone. #[test] fn each_side_padding_sets_its_side_alone() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/layout_panel.rs b/crates/ui-egui/src/layout_panel.rs index 9f69c6c4c..6c8d66935 100644 --- a/crates/ui-egui/src/layout_panel.rs +++ b/crates/ui-egui/src/layout_panel.rs @@ -401,6 +401,8 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { } } +/// The Spacing row: gap fields, then the H/V padding pair (or the 2x2 per-side +/// grid while the individual-padding toggle is open). fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { let col = widgets::col_width(ui.available_width()); let [pt, pr, pb, pl] = l.padding; diff --git a/crates/ui-egui/src/padding_individual_tests.rs b/crates/ui-egui/src/padding_individual_tests.rs index 8fdb31925..28a207bc8 100644 --- a/crates/ui-egui/src/padding_individual_tests.rs +++ b/crates/ui-egui/src/padding_individual_tests.rs @@ -23,16 +23,19 @@ fn scene() -> (Ui, NodeId) { (Ui::new(s), f) } +/// The frame's padding as `[top, right, bottom, left]`. fn padding(u: &Ui, f: NodeId) -> [f64; 4] { u.app.session.doc().unwrap().node(f).unwrap().layout.clone().unwrap().padding } +/// A full press-and-release at `p`, then settling one frame. fn click(u: &mut Ui, p: Pos2) { u.button(p, true); u.button(p, false); u.frame(vec![]); } +/// A press-and-release of Enter, committing the focused number field. fn enter(u: &mut Ui) { u.frame(vec![ Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }, @@ -51,6 +54,8 @@ fn named_button(u: &mut Ui, label: &str) -> Pos2 { pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) } +/// The side fields stay hidden until the toggle opens, then typing in one sets +/// that side alone in a single undo step. #[test] fn side_fields_show_only_once_the_toggle_opens_and_set_one_side() { let (mut u, f) = scene(); From c3cb8c3e0eca767a5e8cc59940c835695e0a78e6 Mon Sep 17 00:00:00 2001 From: Hamza Date: Sun, 18 Oct 2026 23:48:54 +0500 Subject: [PATCH 026/114] cli: confine info, export, convert, render and run to automation roots `figcraft-cli mcp` takes `--automation-read-root` / `--automation-write-root` to confine the files an agent's commands touch, but the file subcommands took neither: `figcraft-cli run script.json --out /anywhere/x.figcraft` read and wrote as the user who started it. The README invites agent use, and a script or agent driving `run`, `export` or `convert` had no way to say which folders it may reach (#833). `info`, `export`, `convert`, `render` and `run` take the same two flags, with the same semantics: repeatable, the folders must exist, reads inside a read or write root, writes only inside a write root, everything else refused -- so giving no write root denies writing altogether. Without the flags they behave as before. The flags would have been decorative on their own: `figcraft_engine` enforces roots through `file_access`, which the engine's own loads already go through, but the CLI wrote its output with `std::fs` and read its script the same way. Those three calls now go through `file_access` too, so the confinement reaches the files the CLI itself touches. --- apps/figcraft-cli/src/files.rs | 11 ++++-- apps/figcraft-cli/src/main.rs | 54 ++++++++++++++++++++------- apps/figcraft-cli/tests/cli.rs | 67 ++++++++++++++++++++++++++++++++++ docs/cli.md | 22 +++++++++++ 4 files changed, 136 insertions(+), 18 deletions(-) diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 65b19ee46..9dc357b1b 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -29,11 +29,13 @@ fn image_format(e: &str) -> Option { } } +/// Writes through [`figcraft_engine::file_access`], so a run given automation roots can only +/// write inside a write root (#833). Unconfined it writes as before. fn write(path: &str, bytes: &[u8]) -> CliResult<()> { if let Some(dir) = Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) { - std::fs::create_dir_all(dir).map_err(|e| CliError::Failed(format!("{}: {e}", dir.display())))?; + figcraft_engine::file_access::create_dir_all(dir).map_err(|e| CliError::Failed(e.to_string()))?; } - std::fs::write(path, bytes).map_err(|e| CliError::Failed(format!("{path}: {e}"))) + figcraft_engine::file_access::write(path, bytes).map_err(|e| CliError::Failed(e.to_string())) } fn page_named(doc: &Document, name: Option<&str>) -> CliResult { @@ -244,7 +246,7 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult { println!("{out}"); return Ok(ExitCode::SUCCESS); } - std::fs::create_dir_all(out).map_err(|e| CliError::Failed(format!("{out}: {e}")))?; + figcraft_engine::file_access::create_dir_all(out).map_err(|e| CliError::Failed(e.to_string()))?; let ids = match target { Some(t) => vec![t], None => { @@ -329,7 +331,8 @@ pub fn parse_script(text: &str) -> Result, String> { } pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliResult { - let text = std::fs::read_to_string(script).map_err(|e| CliError::Failed(format!("{script}: {e}")))?; + let bytes = figcraft_engine::file_access::read(script).map_err(|e| CliError::Failed(e.to_string()))?; + let text = String::from_utf8(bytes).map_err(|_| CliError::Failed(format!("{script}: not UTF-8")))?; let steps = parse_script(&text).map_err(|e| CliError::Failed(format!("{script}: {e}")))?; let mut s = match input { Some(path) => { diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs index 281d79ddc..d9f757d0f 100644 --- a/apps/figcraft-cli/src/main.rs +++ b/apps/figcraft-cli/src/main.rs @@ -22,6 +22,7 @@ USAGE: figcraft-cli convert figcraft-cli render -o [--scale N] [--page NAME] figcraft-cli run [--in ] [--out ] + (info, export, convert, render and run also take --automation-read-root DIR / --automation-write-root DIR) figcraft-cli commands [--json] [--filter TEXT] figcraft-cli mcp [--attach PORT] [--automation-read-root DIR]... [--automation-write-root DIR]... figcraft-cli plugin publish --server ws://host:port/[?key=KEY] @@ -42,12 +43,34 @@ crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines) `plugin publish` sends a plugin folder (figcraft-plugin.json, its module, an optional assets/ folder) to a figcraft-server's plugin directory as a new version; the server's key comes from the address or the FIGCRAFT_SERVER_KEY environment variable (which keeps it out of the shell history). -`mcp --automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders -must exist) confine the files the agent's commands touch: reads only inside a read or write root, -writes only inside a write root, everything else refused. A running app confines itself: start it -with `figcraft --control PORT` and the same flags (`--attach` refuses them). +`--automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders +must exist) confine the files a subcommand touches: reads only inside a read or write root, writes +only inside a write root, everything else refused -- so giving no write root denies writing +altogether. They are taken by `mcp`, to confine the agent's commands, and by `info`, `export`, +`convert`, `render` and `run`, to confine that one run; without them a subcommand reads and writes +as the user who started it, as before. A running app confines itself: start it with +`figcraft --control PORT` and the same flags (`--attach` refuses them). See docs/cli.md and docs/mcp.md."; +/// The automation roots a file subcommand was given, if any. Omitting both leaves the run +/// unconfined, as before; giving either confines every file the subcommand touches (#833). +const ROOT_FLAGS: [&str; 2] = ["automation-read-root", "automation-write-root"]; + +fn roots_of(a: &Args) -> CliResult>> { + let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root")); + if read.is_empty() && write.is_empty() { + return Ok(None); + } + let roots = figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?; + Ok(Some(std::sync::Arc::new(roots))) +} + +/// Runs `f` confined to the roots `a` carries. +fn confined(a: &Args, f: impl FnOnce() -> CliResult) -> CliResult { + let roots = roots_of(a)?; + figcraft_engine::file_access::confined(roots.as_ref(), f) +} + fn main() -> ExitCode { let argv: Vec = std::env::args().skip(1).collect(); match run(&argv) { @@ -98,35 +121,38 @@ fn run(argv: &[String]) -> CliResult { Ok(ExitCode::SUCCESS) } "info" => { - let a = Args::parse(rest, &[], &["json"])?; + let a = Args::parse(rest, &ROOT_FLAGS, &["json"])?; let file = a.one_positional("info ")?; - files::info(&file, a.flag("json")) + confined(&a, || files::info(&file, a.flag("json"))) } "export" => { - let a = Args::parse(rest, &["format", "scale", "page", "node", "o", "out", "bleed"], &["crop-marks", "bleed-marks", "cmyk"])?; + let valued = [&["format", "scale", "page", "node", "o", "out", "bleed"][..], &ROOT_FLAGS[..]].concat(); + let a = Args::parse(rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?; let file = a.one_positional("export -o ")?; let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("export needs -o ".into()))?; let target = files::Target { format: a.value("format"), scale: a.scale()?, page: a.value("page"), node: a.value("node"), print: a.print()? }; - files::export(&file, &out, &target) + confined(&a, || files::export(&file, &out, &target)) } "convert" => { - let a = Args::parse(rest, &[], &[])?; + let a = Args::parse(rest, &ROOT_FLAGS, &[])?; match a.positional.as_slice() { - [i, o] => files::convert(i, o), + [i, o] => confined(&a, || files::convert(i, o)), _ => Err(CliError::Usage("convert ".into())), } } "render" => { - let a = Args::parse(rest, &["o", "out", "scale", "page"], &[])?; + let valued = [&["o", "out", "scale", "page"][..], &ROOT_FLAGS[..]].concat(); + let a = Args::parse(rest, &valued, &[])?; let file = a.one_positional("render -o ")?; let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("render needs -o ".into()))?; - files::render(&file, &out, a.scale()?, a.value("page").as_deref()) + confined(&a, || files::render(&file, &out, a.scale()?, a.value("page").as_deref())) } "run" => { - let a = Args::parse(rest, &["in", "out"], &[])?; + let valued = [&["in", "out"][..], &ROOT_FLAGS[..]].concat(); + let a = Args::parse(rest, &valued, &[])?; let script = a.one_positional("run ")?; - files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref()) + confined(&a, || files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref())) } "commands" => { let a = Args::parse(rest, &["filter"], &["json"])?; diff --git a/apps/figcraft-cli/tests/cli.rs b/apps/figcraft-cli/tests/cli.rs index 7eaf051f5..399e57bbd 100644 --- a/apps/figcraft-cli/tests/cli.rs +++ b/apps/figcraft-cli/tests/cli.rs @@ -172,3 +172,70 @@ fn commands_json_lists_everything() { let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); assert!(v.as_array().unwrap().len() > 200); } + +/// The automation roots confine the file subcommands too, not just `mcp` (#833): a read outside a +/// root, and a write outside a write root, are refused and leave no file behind. Without the flags +/// the same commands behave as they always did. +#[test] +fn file_subcommands_are_confined_to_automation_roots() { + let d = tmp("file-roots"); + let _ = std::fs::remove_dir_all(&d); + for sub in ["in", "out"] { + std::fs::create_dir_all(d.join(sub)).unwrap(); + } + let p = |rel: &str| d.join(rel).to_string_lossy().into_owned(); + let out_of = |o: &std::process::Output| String::from_utf8_lossy(&o.stderr).into_owned(); + + // A document to work on, made unconfined. + std::fs::write(d.join("in/make.json"), br#"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":40,"height":20}}]"#).unwrap(); + let made = bin().args(["run", &p("in/make.json"), "--out", &p("in/doc.figcraft")]).output().unwrap(); + assert!(made.status.success(), "{}", out_of(&made)); + + // Inside the roots: renders as usual. + let ok = bin() + .args(["render", &p("in/doc.figcraft"), "-o", &p("out/ok.png"), "--automation-read-root", &p("in"), "--automation-write-root", &p("out")]) + .output() + .unwrap(); + assert!(ok.status.success(), "{}", out_of(&ok)); + assert!(d.join("out/ok.png").is_file()); + + // A write outside the write root is refused, and nothing is written. + let escaped = d.join("escaped.png"); + let refused = bin() + .args([ + "render", + &p("in/doc.figcraft"), + "-o", + &escaped.to_string_lossy(), + "--automation-read-root", + &p("in"), + "--automation-write-root", + &p("out"), + ]) + .output() + .unwrap(); + assert!(!refused.status.success()); + assert!(out_of(&refused).contains("may be written"), "{}", out_of(&refused)); + assert!(!escaped.exists(), "the refused render wrote a file anyway"); + + // No write root at all: writing is refused outright, reading still works. + let no_write = bin().args(["render", &p("in/doc.figcraft"), "-o", &p("out/no.png"), "--automation-read-root", &p("in")]).output().unwrap(); + assert!(!no_write.status.success()); + assert!(out_of(&no_write).contains("no folder may be written"), "{}", out_of(&no_write)); + + // A read outside every root is refused: `info` on the document with only `out` readable. + let read_refused = bin().args(["info", &p("in/doc.figcraft"), "--automation-read-root", &p("out")]).output().unwrap(); + assert!(!read_refused.status.success()); + assert!(out_of(&read_refused).contains("may be read"), "{}", out_of(&read_refused)); + + // `convert` and `run` take them as well, and a root that is not a folder is refused. + let missing = bin().args(["convert", &p("in/doc.figcraft"), &p("out/c.svg"), "--automation-read-root", &p("missing")]).output().unwrap(); + assert!(!missing.status.success()); + assert!(out_of(&missing).contains("automation-read-root"), "{}", out_of(&missing)); + + // Unconfined, the same render still writes wherever it is told. + let plain = bin().args(["render", &p("in/doc.figcraft"), "-o", &escaped.to_string_lossy()]).output().unwrap(); + assert!(plain.status.success(), "{}", out_of(&plain)); + assert!(escaped.is_file()); + let _ = std::fs::remove_dir_all(&d); +} diff --git a/docs/cli.md b/docs/cli.md index 7af73fd60..699b6ba27 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -153,6 +153,28 @@ figcraft-cli run start.json --out dashboard.figcraft figcraft-cli export dashboard.figcraft -o dashboard.png ``` +### Confining a run: `--automation-read-root DIR` / `--automation-write-root DIR` + +`info`, `export`, `convert`, `render` and `run` take these two flags, with the same meaning they +have on [`mcp`](#mcp---attach-port---automation-read-root-dir---automation-write-root-dir): each is +repeatable, each folder must exist, and together they confine every file that subcommand touches. +Reads are allowed inside a read or write root, writes only inside a write root, and everything else +is refused — so a run given only a read root cannot write at all. Without either flag a subcommand +reads and writes as the user who started it. + +```sh +# Renders in/doc.figcraft to out/ok.png and can touch nothing else. +figcraft-cli render in/doc.figcraft -o out/ok.png \ + --automation-read-root in --automation-write-root out + +figcraft-cli render in/doc.figcraft -o ./escaped.png \ + --automation-read-root in --automation-write-root out +# error: automation path rejected: . is outside the folders that may be written: …/out +``` + +This is for driving the CLI from a script or an agent that should not reach the rest of the disk; +the flags leave the output identical when nothing strays outside the roots. + ### `commands [--json] [--filter TEXT]` Every command with its label, shortcut, undoable flag (`*`) and parameter docs; `--json` for From 39f30d63c843314acdc262866f9d0488ce679ef1 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:27:31 +0200 Subject: [PATCH 027/114] Refuse empty mode lists and blank names in variable.createCollection Found while testing crates/engine/src/cmd/styles.rs:119-124 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/styles.rs | 22 ++++++++++++++----- .../engine/src/variables_behaviour_tests.rs | 21 ++++++++++++++++++ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index adeb22506..1578912ad 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -110,16 +110,26 @@ pub fn specs() -> Vec { )) }), spec!("variable.createCollection", "Create variable collection", "{name, modes? [names]}", true, |s, p| { - let name = req_string("variable.createCollection", p, "name")?; - let doc = s.doc_mut()?; - if doc.variables.collections.iter().any(|c| c.name == name) { - return Err(bad("variable.createCollection", format!("a collection named `{name}` already exists"))); + let cmd = "variable.createCollection"; + let name = req_string(cmd, p, "name")?.trim().to_string(); + if name.is_empty() { + return Err(bad(cmd, "name can't be empty")); } - let id = doc.new_collection_id(); let modes: Vec = match get(p, "modes") { - Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(str::to_string)).collect(), + Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(|m| m.trim().to_string())).collect(), _ => vec!["Mode 1".into()], }; + if modes.is_empty() { + return Err(bad(cmd, "a collection needs at least one mode")); + } + if modes.iter().any(String::is_empty) { + return Err(bad(cmd, "mode names can't be empty")); + } + let doc = s.doc_mut()?; + if doc.variables.collections.iter().any(|c| c.name == name) { + return Err(bad(cmd, format!("a collection named `{name}` already exists"))); + } + let id = doc.new_collection_id(); let modes = modes.into_iter().enumerate().map(|(i, m)| Mode { id: format!("{id}/mode{}", i + 1), name: m }).collect(); doc.variables.collections.push(Collection { id: id.clone(), name, modes, extends: None }); Ok(json!({"id": id})) diff --git a/crates/engine/src/variables_behaviour_tests.rs b/crates/engine/src/variables_behaviour_tests.rs index 5a868a637..5562dac7d 100644 --- a/crates/engine/src/variables_behaviour_tests.rs +++ b/crates/engine/src/variables_behaviour_tests.rs @@ -365,3 +365,24 @@ fn number_variables_are_pixels_and_edits_are_one_undo_step() { s.undo().unwrap(); assert_eq!(lh(&s), LineHeight::Pixels(20.0), "the value and the bound layer go back together"); } + +#[test] +fn create_collection_keeps_at_least_one_named_mode() { + // A collection keeps at least one mode (removeMode) and mode names can't be empty (renameMode), + // so createCollection refuses an empty mode list, blank mode names and a blank collection name. + let mut s = Session::new(); + for p in [ + json!({"name": "C", "modes": []}), + json!({"name": "C", "modes": [1]}), + json!({"name": "C", "modes": ["", " "]}), + json!({"name": "C", "modes": ["A", " "]}), + json!({"name": " "}), + json!({"name": ""}), + ] { + assert!(s.execute("variable.createCollection", &p).is_err(), "{p} should be refused"); + } + assert!(s.doc().unwrap().variables.collections.is_empty(), "a refused call creates nothing"); + run(&mut s, "variable.createCollection", json!({"name": "C", "modes": [" Light ", "Dark"]})); + let names: Vec = s.doc().unwrap().variables.collections[0].modes.iter().map(|m| m.name.clone()).collect(); + assert_eq!(names, ["Light", "Dark"]); +} From 35cc685e90e42bce8ce30a049c13bd8985dd6bf9 Mon Sep 17 00:00:00 2001 From: Hamza Date: Mon, 19 Oct 2026 00:52:40 +0500 Subject: [PATCH 028/114] cli: an unconfined write still follows a symbolic link Routing the CLI's writes through `file_access` changed what happens when the output path is a symbolic link. `file_access::write` stages the bytes and renames them into place, and a rename replaces the link with a regular file, where `std::fs::write` followed it and wrote to its target. A render to a link reported success while the target kept its old contents. Unconfined writes go back to `std::fs::write`. Confined ones keep the staged write, which is right there: `Roots::check` resolves the path first and has already refused a link leading out of the root, so the write lands on the resolved target inside it. --- apps/figcraft-cli/src/files.rs | 15 ++++++++++++--- apps/figcraft-cli/tests/cli.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 9dc357b1b..462155998 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -29,13 +29,22 @@ fn image_format(e: &str) -> Option { } } -/// Writes through [`figcraft_engine::file_access`], so a run given automation roots can only -/// write inside a write root (#833). Unconfined it writes as before. +/// Writes through [`figcraft_engine::file_access`] when automation roots are in force, so a +/// confined run can only write inside a write root (#833). +/// +/// Unconfined it writes with `std::fs::write`, as it always did. `file_access::write` stages the +/// bytes and renames them into place, which **replaces** a symbolic link at `path` instead of +/// writing through it to its target -- a change of behaviour for anyone whose output path is a +/// link, and nothing to do with confinement. Inside a root the staged write is the right one: +/// `Roots::check` has already refused a link that leads out of the root. fn write(path: &str, bytes: &[u8]) -> CliResult<()> { if let Some(dir) = Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) { figcraft_engine::file_access::create_dir_all(dir).map_err(|e| CliError::Failed(e.to_string()))?; } - figcraft_engine::file_access::write(path, bytes).map_err(|e| CliError::Failed(e.to_string())) + if figcraft_engine::file_access::current().is_some() { + return figcraft_engine::file_access::write(path, bytes).map_err(|e| CliError::Failed(e.to_string())); + } + std::fs::write(path, bytes).map_err(|e| CliError::Failed(format!("{path}: {e}"))) } fn page_named(doc: &Document, name: Option<&str>) -> CliResult { diff --git a/apps/figcraft-cli/tests/cli.rs b/apps/figcraft-cli/tests/cli.rs index 399e57bbd..eb42b8d88 100644 --- a/apps/figcraft-cli/tests/cli.rs +++ b/apps/figcraft-cli/tests/cli.rs @@ -239,3 +239,31 @@ fn file_subcommands_are_confined_to_automation_roots() { assert!(escaped.is_file()); let _ = std::fs::remove_dir_all(&d); } + +/// An output path that is a symbolic link is written through, to its target, as `std::fs::write` +/// always did. Routing the CLI's writes through `file_access` for #833 would otherwise have +/// replaced the link with a regular file, because that write stages the bytes and renames them +/// into place. +#[cfg(unix)] +#[test] +fn an_unconfined_write_follows_a_symbolic_link() { + let d = tmp("symlink-out"); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(d.join("real")).unwrap(); + let p = |rel: &str| d.join(rel).to_string_lossy().into_owned(); + std::fs::write(d.join("in.json"), br#"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":40,"height":20}}]"#).unwrap(); + let made = bin().args(["run", &p("in.json"), "--out", &p("doc.figcraft")]).output().unwrap(); + assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr)); + + let target = d.join("real/target.png"); + std::fs::write(&target, b"placeholder").unwrap(); + let link = d.join("link.png"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + + let out = bin().args(["render", &p("doc.figcraft"), "-o", &link.to_string_lossy()]).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), "the link is still a link"); + let written = std::fs::read(&target).unwrap(); + assert!(written.starts_with(b"\x89PNG"), "the target holds the render, not the placeholder"); + let _ = std::fs::remove_dir_all(&d); +} From 98a156118384416998a3aa62c92d6a8794e9f4d0 Mon Sep 17 00:00:00 2001 From: Al Rifat Sabbir <150162343+alrifatsabbir@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:04:00 +0600 Subject: [PATCH 029/114] Update supported languages in translating.md Added Bengali to the list of supported languages and clarified the status of Russian and Turkish translations. --- docs/translating.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/translating.md b/docs/translating.md index d8addcbc7..4a6ff0d91 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -16,8 +16,8 @@ Simplified. The catalogs: English (`en`, the source), and Spanish (`es`, with the Latin American overlay `es-419`), French (`fr`), German (`de`), Dutch (`nl`), Brazilian Portuguese (`pt-BR`), Japanese -(`ja`), Korean (`ko`), Simplified Chinese (`zh-Hans`), Czech (`cs`), Indonesian (`id`) and Finnish -(`fi`), machine-drafted and waiting for native speakers' review. Russian (`ru`, Русский) was +(`ja`), Korean (`ko`), Simplified Chinese (`zh-Hans`), Czech (`cs`), Indonesian (`id`), Finnish +(`fi`) and Bengali (`bn`), machine-drafted and waiting for native speakers' review. Russian (`ru`, Русский) was reviewed and approved by VanTan; its catalog keeps Frame, Dev Mode and Smart animate as familiar tool names and uses « » quotes. Turkish (`tr`) was machine-drafted and reviewed by a native speaker, and Swedish (`sv`) translates every UI string, command label and menu wording. From 0a3f2341a81e1bb08ef51334bccc0a0429f02747 Mon Sep 17 00:00:00 2001 From: Al Rifat Sabbir <150162343+alrifatsabbir@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:10:55 +0600 Subject: [PATCH 030/114] Add support for Bengali text in the UI Updated cjk.rs --- crates/ui-egui/src/cjk.rs | 41 +++++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/cjk.rs b/crates/ui-egui/src/cjk.rs index fd3ecf8cb..dd195dcf2 100644 --- a/crates/ui-egui/src/cjk.rs +++ b/crates/ui-egui/src/cjk.rs @@ -1,8 +1,8 @@ -//! Chinese, Japanese and Korean text in the UI. egui's own fonts and Inter have no CJK glyphs, so +//! Chinese, Japanese, Korean and Bengali text in the UI. egui's own fonts and Inter have no CJK or non-Latin glyphs, so //! the UI adds a face per script it needs: the UI language's, the scripts in the open file's names //! and comments, the language picker's and typed text. Desktop finds them among the installed //! fonts (the system font scan), only once one is needed, so English users load none; the web -//! build has only the CJK faces built in from craft-fonts (`FIGCRAFT_EMBED_CJK`), else none. +//! build has only the CJK and non-Latin faces built in from craft-fonts (`FIGCRAFT_EMBED_CJK`), else none. use std::sync::Arc; @@ -12,7 +12,15 @@ pub const KANA: u8 = 1; pub const HANGUL: u8 = 2; /// Han characters (and CJK punctuation and full-width forms). pub const HAN: u8 = 4; +pub const BENGALI: u8 = 8; +const BENGALI_FONTS: &[&str] = &[ + "Noto Sans Bengali", + "Nirmala UI", + "Bangla Sangam MN", + "Kalpurush", + "SolaimanLipi", +]; /// Japanese faces, the built-in one first: Windows, macOS, then Linux names. const JAPANESE: &[&str] = &[ "BIZ UDPGothic", @@ -46,24 +54,26 @@ const CHINESE: &[&str] = &[ "Droid Sans Fallback", ]; -/// The CJK scripts in `s`. +/// The CJK and Bengali scripts in `s`. pub fn scripts(s: &str) -> u8 { s.chars().fold(0, |m, c| { m | match u32::from(c) { 0x3040..=0x30FF | 0x31F0..=0x31FF | 0xFF66..=0xFF9F => KANA, 0x1100..=0x11FF | 0x3130..=0x318F | 0xA960..=0xA97F | 0xAC00..=0xD7FF | 0xFFA0..=0xFFDC => HANGUL, 0x3000..=0x303F | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0xFF01..=0xFF65 | 0x20000..=0x3FFFF => HAN, + 0x0980..=0x09FF => BENGALI, _ => 0, } }) } -/// The CJK scripts a UI language (a catalog code) is written in. +/// The scripts a UI language (a catalog code) is written in. pub fn language(code: &str) -> u8 { match code.split('-').next() { Some("ja") => KANA | HAN, Some("ko") => HANGUL, Some("zh") => HAN, + Some("bn") => BENGALI, _ => 0, } } @@ -75,7 +85,9 @@ pub fn can_show(code: &str) -> bool { let need = language(code); need == 0 || !cfg!(target_arch = "wasm32") - || samples(need, 0).iter().all(|(c, _)| figcraft_text::face_with_char(*c, &["BIZ UDPGothic", "Noto Sans CJK SC"]).is_some()) + || samples(need, 0).iter().all(|(c, families)| { + figcraft_text::face_with_char(*c, families).is_some() + }) } /// A character standing for each script in `need`, with the families to look for it in, the UI @@ -89,10 +101,21 @@ fn samples(need: u8, ui: u8) -> Vec<(char, Vec<&'static str>)> { (false, HANGUL) => ('漢', [KOREAN, JAPANESE, CHINESE].concat()), (false, _) => ('漢', [JAPANESE, KOREAN, CHINESE].concat()), }; - let mut v = vec![(KANA, ('あ', JAPANESE.to_vec())), (HAN, han), (HANGUL, ('한', KOREAN.to_vec()))]; + let mut v = vec![ + (KANA, ('あ', JAPANESE.to_vec())), + (HAN, han), + (HANGUL, ('한', KOREAN.to_vec())), + (BENGALI, ('অ', BENGALI_FONTS.to_vec())), + ]; match ui { HANGUL => v.reverse(), HAN => v.swap(0, 1), + BENGALI => { + if let Some(pos) = v.iter().position(|(bit, _)| *bit == BENGALI) { + let item = v.remove(pos); + v.insert(0, item); + } + } _ => {} } v.into_iter().filter(|(bit, _)| need & bit != 0).map(|(_, s)| s).collect() @@ -124,11 +147,13 @@ mod tests { assert_eq!(scripts("ボタン"), KANA); assert_eq!(scripts("버튼"), HANGUL); assert_eq!(scripts("按钮"), HAN); + assert_eq!(scripts("বাংলা"), BENGALI); assert_eq!(scripts("日本語のレイヤー(1)"), KANA | HAN); assert_eq!(language("ja"), KANA | HAN); assert_eq!(language("ko"), HANGUL); assert_eq!(language("zh-Hant"), HAN); assert_eq!(language("zh-Hans"), HAN); + assert_eq!(language("bn"), BENGALI); assert_eq!(language("fr"), 0); assert!(["en", "es", "es-419", "fr", "de", "pt-BR", "tr", "id"].iter().all(|c| can_show(c))); } @@ -137,7 +162,7 @@ mod tests { fn the_ui_language_script_comes_first() { let order = |need, ui| samples(need, ui).into_iter().map(|(c, _)| c).collect::(); let first_face = |need, ui| samples(need, ui).first().and_then(|(_, f)| f.first()).copied(); - assert_eq!(order(KANA | HAN | HANGUL, 0), "あ漢한"); + assert_eq!(order(KANA | HAN | HANGUL | BENGALI, 0), "あ漢한অ"); assert_eq!(order(KANA | HAN | HANGUL, KANA | HAN), "あ漢한"); assert_eq!(order(KANA | HAN | HANGUL, HANGUL), "한漢あ"); assert_eq!(order(HANGUL, 0), "한"); @@ -150,6 +175,6 @@ mod tests { assert_eq!(order(KANA | HAN, language("zh-Hans")), "们あ"); assert!(["Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC"].iter().all(|f| samples(HAN, HAN)[0].1[..4].contains(f))); assert!(faces(0, "en", &|_| false).is_empty()); - assert!(faces(KANA | HANGUL | HAN, "ja", &|_| true).is_empty(), "nothing is looked up for scripts the UI draws"); + assert!(faces(KANA | HANGUL | HAN | BENGALI, "ja", &|_| true).is_empty(), "nothing is looked up for scripts the UI draws"); } } From 23d0d9e49e692e42ded69f90c84962c842fb1e41 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:14:08 +0000 Subject: [PATCH 031/114] Accept --control=PORT like --control PORT Fixes #837 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- apps/figcraft/src/args.rs | 6 +++--- apps/figcraft/src/args_tests.rs | 8 ++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/figcraft/src/args.rs b/apps/figcraft/src/args.rs index 3b4136d99..7e9562e99 100644 --- a/apps/figcraft/src/args.rs +++ b/apps/figcraft/src/args.rs @@ -7,7 +7,7 @@ /// What the app was started with. #[derive(Debug, Default, PartialEq, Eq)] pub struct Args { - /// `--control `: the JSON-lines control server. + /// `--control ` (or `--control=`): the JSON-lines control server. pub control: Option, pub files: Vec, /// The last invite link given. @@ -63,8 +63,8 @@ pub fn parse(args: &[String]) -> Args { }; if let Some(roots) = roots { roots.push(inline.or_else(|| it.next().cloned()).unwrap_or_default()); - } else if a == "--control" { - out.control = it.next().and_then(|p| p.parse().ok()); + } else if flag == "--control" { + out.control = inline.or_else(|| it.next().cloned()).and_then(|p| p.parse().ok()); } else if is_invite(a) { out.join = Some(a.clone()); } else if let Some(path) = file_url_path(a) { diff --git a/apps/figcraft/src/args_tests.rs b/apps/figcraft/src/args_tests.rs index 2ae29b3c8..3b880209b 100644 --- a/apps/figcraft/src/args_tests.rs +++ b/apps/figcraft/src/args_tests.rs @@ -16,6 +16,14 @@ fn files_and_the_control_port() { assert_eq!(parse(&args(&["--control", "nope"])).control, None); } +#[test] +fn the_control_port_accepts_the_equals_form() { + let a = parse(&args(&["--control=7980", "a.figcraft"])); + assert_eq!(a, Args { control: Some(7980), files: args(&["a.figcraft"]), ..Args::default() }); + // A bad port is ignored, not taken as a file. + assert_eq!(parse(&args(&["--control=nope"])), Args::default()); +} + #[test] fn automation_roots_repeat_and_are_never_files() { let a = parse(&args(&["--automation-read-root", "in", "--automation-write-root=out", "--automation-read-root=more", "a.figcraft"])); From 3878f89bd69b0d3694fa198506199cb24c9719b7 Mon Sep 17 00:00:00 2001 From: Al Rifat Sabbir <150162343+alrifatsabbir@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:27:24 +0600 Subject: [PATCH 032/114] Change order of characters in language script test --- crates/ui-egui/src/cjk.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/cjk.rs b/crates/ui-egui/src/cjk.rs index dd195dcf2..b5dbf7399 100644 --- a/crates/ui-egui/src/cjk.rs +++ b/crates/ui-egui/src/cjk.rs @@ -162,7 +162,7 @@ mod tests { fn the_ui_language_script_comes_first() { let order = |need, ui| samples(need, ui).into_iter().map(|(c, _)| c).collect::(); let first_face = |need, ui| samples(need, ui).first().and_then(|(_, f)| f.first()).copied(); - assert_eq!(order(KANA | HAN | HANGUL | BENGALI, 0), "あ漢한অ"); + assert_eq!(order(KANA | HAN | HANGUL | BENGALI, language("bn")), "অあ漢한"); assert_eq!(order(KANA | HAN | HANGUL, KANA | HAN), "あ漢한"); assert_eq!(order(KANA | HAN | HANGUL, HANGUL), "한漢あ"); assert_eq!(order(HANGUL, 0), "한"); From 85c5286d396b91f2208d9d640aeace82afec43d6 Mon Sep 17 00:00:00 2001 From: Muamer Mustafic Date: Sun, 18 Oct 2026 23:40:33 +0200 Subject: [PATCH 033/114] M1: doc comments for the menu invoke and SVG clipboard export (#808) Co-authored-by: Cursor --- crates/ui-egui/src/menus.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d34b609b5..85caa9da6 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -112,6 +112,8 @@ pub fn record_recent(app: &mut FigApp, key: &str) { r.truncate(figcraft_engine::MAX_RECENT_ACTIONS); } +/// Run a menu, shortcut or palette action by id: actions that need the UI (dialogs, panels, the +/// clipboard) are handled here, the rest go to the engine. pub fn invoke(app: &mut FigApp, id: &str) { match id { // Edit ▸ Delete (and the Delete binding) with an interaction open in the Prototype tab @@ -247,6 +249,7 @@ fn text_layers_text(doc: &figcraft_doc::Document, ids: &[figcraft_doc::NodeId]) texts.map(|t| t.join("\n")).filter(|t| !t.is_empty()) } +/// The layers as SVG text for other apps (None when the export fails). fn figcraft_svg_export(doc: &figcraft_doc::Document, ids: &[figcraft_doc::NodeId]) -> Option { let bytes = figcraft_engine::cmd::file::export_bytes(doc, ids, figcraft_doc::ExportFormat::Svg, 1.0).ok()?; String::from_utf8(bytes).ok() From 79ac16a2561e1f571999a09d890f8abf08fbeccb Mon Sep 17 00:00:00 2001 From: Muamer Mustafic Date: Sun, 18 Oct 2026 23:40:35 +0200 Subject: [PATCH 034/114] M3: doc comments for the structure commands and binding re-apply (#810) Co-authored-by: Cursor --- crates/engine/src/cmd/structure.rs | 2 ++ crates/engine/src/cmd/styles.rs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index 0f29069db..7a5bd1fcc 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -10,6 +10,8 @@ use super::{CommandSpec, ok}; use crate::params::*; use crate::{EngineError, Result, Session, bad, spec}; +/// The layer structure commands: group, frame, mask, boolean operations, flatten, auto layout +/// add/remove, move to page and vector path edits. pub fn specs() -> Vec { vec![ spec!("object.group", "Group selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Group)), diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index 649ee033b..47bbd0aa0 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -1007,6 +1007,8 @@ pub(crate) fn apply_paint_bindings_on_load(doc: &mut Document) { doc.mark_dirty(pending); } +/// Re-apply the variable bindings whose field passes `include`, on the `only` nodes (every bound +/// node when `None`). Auto layout fields wait while their node has no auto layout. fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::BTreeSet>, include: impl Fn(&str) -> bool) { use super::lists::{BoundList, list_binding}; if doc.variables.variables.is_empty() { From 847e610550042966daa3dc6fe094585163831469 Mon Sep 17 00:00:00 2001 From: Al Rifat Sabbir <150162343+alrifatsabbir@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:46:01 +0600 Subject: [PATCH 035/114] Add Bengali script support in build.rs --- crates/text/build.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/text/build.rs b/crates/text/build.rs index d02defefe..836efa153 100644 --- a/crates/text/build.rs +++ b/crates/text/build.rs @@ -62,12 +62,13 @@ fn craft_fonts(dir: &std::path::Path, manifest_rel: &str) -> Result Date: Sun, 18 Oct 2026 23:50:27 +0200 Subject: [PATCH 036/114] M7: a web file picker closed without a cancel event settles on refocus (#759) Co-authored-by: Cursor --- apps/figcraft-web/src/web.rs | 62 ++++++++++++++++++++++++++++++------ 1 file changed, 52 insertions(+), 10 deletions(-) diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index 015112527..dd767116a 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -162,19 +162,11 @@ async fn pick_file(accept: &str) -> Result)>, wasm_bindg input.set_hidden(true); // Some browsers (older Safari) open the picker only for an input in the page. body.append_child(&input)?; - // Settles on `change` (a file was picked) or `cancel` (the picker was closed without one). - let mut settle = |resolve: js_sys::Function, _reject: js_sys::Function| { - let done = wasm_bindgen::closure::Closure::once_into_js(move || { - let _ = resolve.call0(&wasm_bindgen::JsValue::NULL); - }); - let _ = input.add_event_listener_with_callback("change", done.unchecked_ref()); - let _ = input.add_event_listener_with_callback("cancel", done.unchecked_ref()); - }; - let closed = js_sys::Promise::new(&mut settle); + let (closed, picker) = PickerClosed::new(&window, input.clone()); input.click(); let result = wasm_bindgen_futures::JsFuture::from(closed).await; let file = input.files().and_then(|files| files.get(0)); - input.remove(); + drop(picker); result?; let Some(file) = file else { return Ok(None) }; if file.size() > MAX_PICKED_BYTES { @@ -184,6 +176,56 @@ async fn pick_file(accept: &str) -> Result)>, wasm_bindg Ok(Some((file.name(), js_sys::Uint8Array::new(&buffer).to_vec()))) } +/// How long after the page gets focus back the file picker counts as closed (milliseconds): a +/// picked file's `change` can come just after the focus, and must win. +const PICKER_FOCUS_SETTLE_MS: i32 = 1_000; + +/// Settles a file picker's promise once: on `change` (a file was picked), on `cancel` (closed +/// without one) or shortly after the page gets focus back, for browsers that never send `cancel` +/// (Safari before 16.4), where the pick would otherwise stay pending and block every later one. +/// Dropping it (also when the pick is abandoned) removes the input, the focus listener and its timer. +struct PickerClosed { + window: web_sys::Window, + input: web_sys::HtmlInputElement, + _settle: wasm_bindgen::closure::Closure, + on_focus: wasm_bindgen::closure::Closure, + timer: std::rc::Rc>>, +} + +impl PickerClosed { + fn new(window: &web_sys::Window, input: web_sys::HtmlInputElement) -> (js_sys::Promise, Self) { + let resolve = std::rc::Rc::new(std::cell::RefCell::new(None::)); + let slot = resolve.clone(); + let closed = js_sys::Promise::new(&mut |res, _rej| *slot.borrow_mut() = Some(res)); + let settle = wasm_bindgen::closure::Closure::::new(move || { + if let Some(res) = resolve.borrow_mut().take() { + let _ = res.call0(&wasm_bindgen::JsValue::NULL); + } + }); + let _ = input.add_event_listener_with_callback("change", settle.as_ref().unchecked_ref()); + let _ = input.add_event_listener_with_callback("cancel", settle.as_ref().unchecked_ref()); + let timer = std::rc::Rc::new(std::cell::Cell::new(None)); + let (w, t, f) = (window.clone(), timer.clone(), settle.as_ref().unchecked_ref::().clone()); + let on_focus = wasm_bindgen::closure::Closure::::new(move || { + if t.get().is_none() { + t.set(w.set_timeout_with_callback_and_timeout_and_arguments_0(&f, PICKER_FOCUS_SETTLE_MS).ok()); + } + }); + let _ = window.add_event_listener_with_callback("focus", on_focus.as_ref().unchecked_ref()); + (closed, Self { window: window.clone(), input, _settle: settle, on_focus, timer }) + } +} + +impl Drop for PickerClosed { + fn drop(&mut self) { + if let Some(t) = self.timer.take() { + self.window.clear_timeout_with_handle(t); + } + let _ = self.window.remove_event_listener_with_callback("focus", self.on_focus.as_ref().unchecked_ref()); + self.input.remove(); + } +} + fn download(name: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> { let window = web_sys::window().ok_or("no window")?; let document = window.document().ok_or("no document")?; From 4a54aa733fe2a755337a58faaae1787d088c2215 Mon Sep 17 00:00:00 2001 From: MortenHusted Date: Sun, 18 Oct 2026 23:00:13 +0200 Subject: [PATCH 037/114] M3: .fig imports keep the properties variant layers are bound to Each variant of a component set keeps a copy of the set's property definitions (an id and a parentPropDefId, no name or type), and the variant's layers and its instances' assignments use the copy's id. Those copies were skipped, so imported variants lost every boolean, text and instance swap binding, and instances lost their property values. In Untitled UI (8,074 copies) an input's "Hint text" no longer followed its property. A copy now names the definition it was made from. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/fig/map.rs | 32 +++++-- crates/format/src/fig/mod.rs | 2 + crates/format/src/fig/prop_value_tests.rs | 107 ++++++++++++++++++++++ 3 files changed, 134 insertions(+), 7 deletions(-) create mode 100644 crates/format/src/fig/prop_value_tests.rs diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 7df4d9e21..6e4ea86f3 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -368,6 +368,9 @@ struct Mapper<'a> { blobs: Vec<&'a [u8]>, /// Component property definitions: id → (REST name "Name#id", type). prop_defs: BTreeMap, + /// The copies of a set's definitions each variant keeps (no name or type of their own; the + /// variant's layers and its instances' assignments use them): copy id → the definition's id. + prop_parents: BTreeMap, /// File nodes by asset `key` and `version`: references can name a collection, variable or /// style as the asset instead of the guid, and the file carries the resources to resolve /// them (the collection node itself says the same `key`). @@ -402,6 +405,7 @@ impl<'a> Mapper<'a> { let mut nodes = BTreeMap::new(); let mut kids: BTreeMap> = BTreeMap::new(); let mut prop_defs = BTreeMap::new(); + let mut prop_parents = BTreeMap::new(); let changes = msg.get("nodeChanges").map(Kv::list).unwrap_or_default(); // The last change of a GUID wins; a REMOVED change deletes what came before it. for n in changes { @@ -423,10 +427,11 @@ impl<'a> Mapper<'a> { kids.entry(parent).or_default().push((p.get("position").and_then(Kv::str).unwrap_or(""), id.clone())); } for d in n.get("componentPropDefs").map(Kv::list).unwrap_or_default() { - if let (Some(did), Some(name), Some(ty)) = - (d.get("id").and_then(guid), d.get("name").and_then(Kv::str), d.get("type").and_then(Kv::str)) - { + let Some(did) = d.get("id").and_then(guid) else { continue }; + if let (Some(name), Some(ty)) = (d.get("name").and_then(Kv::str), d.get("type").and_then(Kv::str)) { prop_defs.insert(did.clone(), (format!("{name}#{did}"), ty)); + } else if let Some(parent) = d.get("parentPropDefId").and_then(guid) { + prop_parents.insert(did, parent); } } } @@ -450,6 +455,7 @@ impl<'a> Mapper<'a> { kids, blobs, prop_defs, + prop_parents, assets, styles: Obj::new(), components: Obj::new(), @@ -651,7 +657,19 @@ impl<'a> Mapper<'a> { } fn is_instance_swap(&self, def: &Kv) -> Option<()> { - matches!(self.prop_defs.get(&guid(def)?), Some((_, "INSTANCE_SWAP"))).then_some(()) + matches!(self.prop_def(&guid(def)?), Some((_, "INSTANCE_SWAP"))).then_some(()) + } + + /// The definition `def` names: its own, or the set's for a variant's copy of it. + fn prop_def(&self, def: &str) -> Option<&(String, &'a str)> { + let mut def = def; + for _ in 0..=MAX_DEPTH { + if let Some(found) = self.prop_defs.get(def) { + return Some(found); + } + def = self.prop_parents.get(def)?; + } + None } /// The branches of the internal canvas `id` that hold the `required_mains` (with the frames @@ -1892,7 +1910,7 @@ impl<'a> Mapper<'a> { // Expanded frames must follow their main frame's source link. A direct slot reference // makes the engine resolve ownership against the instance, where the property is not defined. if !(field == "slot" && inst.is_some()) - && let Some((name, _)) = self.prop_defs.get(&def) + && let Some((name, _)) = self.prop_def(&def) { refs.insert(field.into(), json!(name)); } @@ -1945,7 +1963,7 @@ impl<'a> Mapper<'a> { let assignments = v.list("componentPropAssignments"); let mut props = Obj::new(); for a in assignments { - let Some((name, ty)) = a.get("defID").and_then(guid).and_then(|d| self.prop_defs.get(&d).cloned()) else { continue }; + let Some((name, ty)) = a.get("defID").and_then(guid).and_then(|d| self.prop_def(&d).cloned()) else { continue }; let val = a.get("value"); let p = match ty { "BOOL" => val.and_then(|x| x.get("boolValue")).and_then(Kv::bool).map(|b| json!({"type": "BOOLEAN", "value": b})), @@ -2031,7 +2049,7 @@ impl<'a> Mapper<'a> { all_props.entry(d).or_insert(val); } let Some(def) = a.get("defID").and_then(guid) else { continue }; - if self.prop_defs.get(&def).is_none_or(|(_, ty)| *ty != "SLOT") { + if self.prop_def(&def).is_none_or(|(_, ty)| *ty != "SLOT") { continue; } let var_value = a.get("varValue"); diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index fc9293d9e..4c103d088 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -26,6 +26,8 @@ pub mod kiwi; mod map; mod network; #[cfg(test)] +mod prop_value_tests; +#[cfg(test)] mod slot_tests; #[cfg(test)] mod source_identity_tests; diff --git a/crates/format/src/fig/prop_value_tests.rs b/crates/format/src/fig/prop_value_tests.rs new file mode 100644 index 000000000..3e24c3301 --- /dev/null +++ b/crates/format/src/fig/prop_value_tests.rs @@ -0,0 +1,107 @@ +//! Component properties as current `.fig` files write them, on synthetic messages: the copy of a +//! set's definitions each variant keeps (`parentPropDefId`, no name or type of its own). + +use figcraft_doc::NodeKind; +use serde_json::Value; + +use super::kiwi::Kv; +use super::map::to_rest; + +fn g(local: u32) -> Kv<'static> { + Kv::Object(vec![("sessionID", Kv::Uint(9)), ("localID", Kv::Uint(u64::from(local)))]) +} + +fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + Kv::Object(fields) +} + +fn s(text: &str) -> Kv<'static> { + Kv::Str(text.into()) +} + +fn node(local: u32, kind: &'static str, name: &str, parent: Option, mut fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + let mut all = vec![("guid", g(local)), ("type", Kv::Enum(kind)), ("name", s(name))]; + if let Some(parent) = parent { + all.push(("parentIndex", o(vec![("guid", g(parent)), ("position", s("!"))]))); + } + all.append(&mut fields); + o(all) +} + +/// A layer's `field` bound to property definition `def`. +fn bound(field: &'static str, def: u32) -> (&'static str, Kv<'static>) { + let entry = o(vec![ + ("variableField", Kv::Enum(field)), + ("variableData", o(vec![("value", o(vec![("propRefValue", o(vec![("defId", g(def))]))])), ("dataType", Kv::Enum("PROP_REF"))])), + ]); + ("parameterConsumptionMap", o(vec![("entries", Kv::Array(vec![entry]))])) +} + +fn definition(id: u32, name: &str, kind: &'static str, initial: Kv<'static>) -> Kv<'static> { + o(vec![("id", g(id)), ("name", s(name)), ("type", Kv::Enum(kind)), ("initialValue", initial)]) +} + +fn message(nodes: Vec>) -> Kv<'static> { + let mut all = vec![node(1, "DOCUMENT", "Document", None, vec![]), node(2, "CANVAS", "Page", Some(1), vec![])]; + all.extend(nodes); + o(vec![("nodeChanges", Kv::Array(all))]) +} + +fn by_id<'v>(v: &'v Value, id: &str) -> &'v Value { + fn find<'v>(v: &'v Value, id: &str) -> Option<&'v Value> { + if v.get("id").and_then(Value::as_str) == Some(id) { + return Some(v); + } + v.get("children").and_then(Value::as_array).into_iter().flatten().find_map(|v| find(v, id)) + } + find(v, id).unwrap_or_else(|| panic!("no layer {id}")) +} + +fn instance_props(doc: &figcraft_doc::Document) -> &std::collections::BTreeMap { + let instance = doc.ids().filter_map(|id| doc.node(id)).find(|n| n.name == "Instance").unwrap(); + let NodeKind::Instance { props, .. } = &instance.kind else { panic!("not an instance") }; + props +} + +#[test] +fn variant_layers_bind_to_the_set_properties_their_copies_name() { + let msg = message(vec![ + node( + 9, + "FRAME", + "Field", + Some(2), + vec![ + ("isStateGroup", Kv::Bool(true)), + ("componentPropDefs", Kv::Array(vec![definition(70, "Hint", "BOOL", o(vec![("boolValue", Kv::Bool(true))]))])), + ], + ), + node( + 10, + "SYMBOL", + "State=Default", + Some(9), + vec![("componentPropDefs", Kv::Array(vec![o(vec![("id", g(71)), ("parentPropDefId", g(70))])]))], + ), + node(11, "TEXT", "Hint text", Some(10), vec![bound("VISIBLE", 71)]), + node( + 20, + "INSTANCE", + "Instance", + Some(2), + vec![ + ("symbolData", o(vec![("symbolID", g(10))])), + ("componentPropAssignments", Kv::Array(vec![o(vec![("defID", g(71)), ("value", o(vec![("boolValue", Kv::Bool(false))]))])])), + ], + ), + ]); + let (rest, _, _) = to_rest(&msg, "Synthetic", crate::rest::MAX_NODES); + assert_eq!(by_id(&rest["document"], "9:9")["componentPropertyDefinitions"]["Hint#9:70"]["type"], "BOOLEAN"); + assert_eq!(by_id(&rest["document"], "9:11")["componentPropertyReferences"]["visible"], "Hint#9:70"); + assert_eq!(by_id(&rest["document"], "9:20")["componentProperties"]["Hint#9:70"]["value"], false); + + let (doc, _) = crate::rest::import_fig(&rest, &crate::rest::no_images).unwrap(); + let hint = doc.ids().filter_map(|id| doc.node(id)).find(|n| n.name == "Hint text" && n.source.is_none()).unwrap(); + assert_eq!(hint.prop_refs.get("visible").map(String::as_str), Some("Hint")); + assert_eq!(instance_props(&doc).get("Hint"), Some(&Value::Bool(false))); +} From 544b2455c9ab82fbba44c95a3b3396da9c22bdc2 Mon Sep 17 00:00:00 2001 From: MortenHusted Date: Sun, 18 Oct 2026 16:49:34 +0200 Subject: [PATCH 038/114] M8: a path in a root reached through a link is told why it is refused When a path can't be resolved (a link that leads nowhere, `..` below a missing folder), the reason is given only for paths inside a root. That check compared the path as typed with the roots resolved, so a root reached through a link (`/tmp/work` on macOS is `/private/tmp/work`) called its own dangling links "outside the folders that may be written: /private/tmp/work", and a link out of a root still had its reason told. The existing part of the path is now resolved before the comparison, as the roots were. This is also why `links_that_lead_out_of_a_root_are_outside` and `outside_paths_are_refused_alike_whatever_exists_there` failed on macOS, where the temp folder is under `/var` -> `/private/var`. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/file_access.rs | 22 +++++++++++++++++----- crates/engine/src/file_access_tests.rs | 17 +++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 8abeaee5d..709b34a71 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -62,7 +62,7 @@ impl Roots { Ok(resolved) if roots.iter().any(|r| resolved.starts_with(r)) => return Ok(resolved), Ok(_) => {} // Why a path can't be resolved says what exists there: only told for paths inside a root. - Err(why) if lexically_inside(path, &roots) => return Err(format!("automation path rejected: {asked}: {why}")), + Err(why) if inside_as_far_as_it_exists(path, &roots) => return Err(format!("automation path rejected: {asked}: {why}")), Err(_) => {} } if roots.is_empty() { @@ -79,9 +79,10 @@ impl Roots { } } -/// Whether `path`, made absolute with its `.` and `..` taken by name (no file system), lies in one -/// of `roots` (as resolved, or as shown without Windows' `\\?\` prefix). -fn lexically_inside(path: &Path, roots: &[PathBuf]) -> bool { +/// Whether `path` lies in one of `roots` once made absolute with its `.` and `..` taken by name and +/// its longest leading part that exists resolved, so links on the way are followed as the roots' +/// were (`/tmp/work/x` is inside root `/private/tmp/work`, a link out of a root leads out of it). +fn inside_as_far_as_it_exists(path: &Path, roots: &[PathBuf]) -> bool { let Ok(abs) = std::path::absolute(path) else { return false }; let mut lexical = PathBuf::new(); for c in abs.components() { @@ -93,7 +94,18 @@ fn lexically_inside(path: &Path, roots: &[PathBuf]) -> bool { c => lexical.push(c), } } - roots.iter().any(|r| lexical.starts_with(r) || lexical.starts_with(shown(r))) + let mut rest: Vec<&OsStr> = Vec::new(); + let mut at = lexical.as_path(); + let resolved = loop { + if let Ok(mut base) = std::fs::canonicalize(at) { + base.extend(rest.iter().rev()); + break base; + } + let (Some(name), Some(parent)) = (at.file_name(), at.parent()) else { return false }; + rest.push(name); + at = parent; + }; + roots.iter().any(|r| resolved.starts_with(r)) } /// Whether `path` lies in the app's own folder `folder`, both resolved (links and `..` followed); diff --git a/crates/engine/src/file_access_tests.rs b/crates/engine/src/file_access_tests.rs index a05ad88ce..ba55e1348 100644 --- a/crates/engine/src/file_access_tests.rs +++ b/crates/engine/src/file_access_tests.rs @@ -348,6 +348,10 @@ fn outside_paths_are_refused_alike_whatever_exists_there() { let mut outside = vec![f.p("outside/secret.txt"), f.p("outside/none.txt"), f.p("outside/missing/../x.txt"), f.p("nowhere/deeper/x.txt")]; if symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("outside/dangling.txt")) { outside.push(f.p("outside/dangling.txt")); + // Reached through a link in the root, it is outside all the same. + if symlink_dir(&f.0.join("outside"), &f.0.join("root/escape")) { + outside.push(f.p("root/escape/dangling.txt")); + } } for p in &outside { let e = refusal(p); @@ -359,6 +363,19 @@ fn outside_paths_are_refused_alike_whatever_exists_there() { } } +#[test] +fn a_root_given_through_a_link_tells_why_paths_in_it_are_refused() { + let f = Fixture::new("linked-root"); + if !symlink_dir(&f.0.join("root"), &f.0.join("via")) || !symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("root/dangling.txt")) { + eprintln!("skipped: this system doesn't let tests make symbolic links"); + return; + } + // Like `/tmp/work` on macOS, where `/tmp` leads to `/private/tmp`. + let r = Roots::new(&[], &[f.p("via")]).unwrap(); + let e = r.check(Path::new(&f.p("via/dangling.txt")), Access::Write).unwrap_err(); + assert!(e.contains("leads nowhere"), "{e}"); +} + #[test] fn only_resolved_paths_count_as_the_apps_own_folder() { let f = Fixture::new("own"); From c530b4067d6324853cec77c95c8b1a7351540cb7 Mon Sep 17 00:00:00 2001 From: MortenHusted Date: Sun, 18 Oct 2026 23:01:33 +0200 Subject: [PATCH 039/114] M3: .fig imports read property values written only as variables A component property assignment carries its value in `value`, and in current files also in the variable form `varValue`. Many assignments leave `value` empty and say it only in `varValue` (booleans as boolValue, instance swaps as symbolIdValue, text as textDataValue), and those were ignored: a hidden layer showed and a swapped icon stayed the default. In Untitled UI that is 1,643 booleans and 185 swaps, so inputs showed their hint text, help icon and leading icon on top of the neighbouring layers. Both forms are read now. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/fig/map.rs | 30 +++++++--- crates/format/src/fig/prop_value_tests.rs | 67 ++++++++++++++++++++++- 2 files changed, 89 insertions(+), 8 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 6e4ea86f3..22043650a 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -646,7 +646,7 @@ impl<'a> Mapper<'a> { targets.extend(data.and_then(|d| d.get("symbolID")).and_then(guid)); for assignment in row.get("componentPropAssignments").map(Kv::list).unwrap_or_default() { if assignment.get("defID").is_some_and(|def| self.is_instance_swap(def).is_some()) { - targets.extend(assignment.get("value").and_then(|v| v.get("guidValue")).and_then(guid)); + targets.extend(assigned(assignment).and_then(prop_guid)); } } let overrides = data.and_then(|d| d.get("symbolOverrides")).map(Kv::list).unwrap_or_default(); @@ -1929,7 +1929,7 @@ impl<'a> Mapper<'a> { if own_text(v) != Some(t) { o.remove("characterStyleOverrides"); o.remove("styleOverrideTable"); - if let Some(tv) = val.get("textValue") { + if let Some(tv) = prop_text_data(val) { let size = o.get("style").and_then(|s| s.get("fontSize")).and_then(Value::as_f64); char_styles(o, tv, size, &mut |w| self.warn(w)); } @@ -1951,7 +1951,7 @@ impl<'a> Mapper<'a> { // An instance swap property value picks the main. if let Some(i) = outer && let Some(def) = prop_ref(v, "OVERRIDDEN_SYMBOL_ID") - && let Some(g) = i.props.get(&def).and_then(|p| p.get("guidValue")).and_then(guid) + && let Some(g) = i.props.get(&def).and_then(|p| prop_guid(p)) { sym = Some(g); } @@ -1964,11 +1964,11 @@ impl<'a> Mapper<'a> { let mut props = Obj::new(); for a in assignments { let Some((name, ty)) = a.get("defID").and_then(guid).and_then(|d| self.prop_def(&d).cloned()) else { continue }; - let val = a.get("value"); + let val = assigned(a); let p = match ty { "BOOL" => val.and_then(|x| x.get("boolValue")).and_then(Kv::bool).map(|b| json!({"type": "BOOLEAN", "value": b})), "TEXT" => val.and_then(prop_text).map(|t| json!({"type": "TEXT", "value": t})), - "INSTANCE_SWAP" => val.and_then(|x| x.get("guidValue")).and_then(guid).map(|g| json!({"type": "INSTANCE_SWAP", "value": g})), + "INSTANCE_SWAP" => val.and_then(prop_guid).map(|g| json!({"type": "INSTANCE_SWAP", "value": g})), _ => None, }; if let Some(p) = p { @@ -2045,7 +2045,7 @@ impl<'a> Mapper<'a> { let mut all_props: BTreeMap> = outer.map(|i| (*i.props).clone()).unwrap_or_default(); let mut slot_contents: BTreeMap = outer.map(|i| (*i.slot_contents).clone()).unwrap_or_default(); for a in assignments { - if let (Some(d), Some(val)) = (a.get("defID").and_then(guid), a.get("value")) { + if let (Some(d), Some(val)) = (a.get("defID").and_then(guid), assigned(a)) { all_props.entry(d).or_insert(val); } let Some(def) = a.get("defID").and_then(guid) else { continue }; @@ -2795,7 +2795,23 @@ fn char_styles(o: &mut Obj, td: &Kv, size: Option, warn: &mut dyn FnMut(Str /// The text of a component property value. fn prop_text<'k>(v: &'k Kv) -> Option<&'k str> { - v.get("textValue").and_then(|t| t.get("characters")).and_then(Kv::str) + prop_text_data(v).and_then(|t| t.get("characters")).and_then(Kv::str) +} + +/// The text data of a component property value: `textValue`, or `textDataValue` in the variable form. +fn prop_text_data<'k, 's>(v: &'k Kv<'s>) -> Option<&'k Kv<'s>> { + v.get("textValue").or_else(|| v.get("textDataValue")) +} + +/// The main an instance swap value names: `guidValue`, or `symbolIdValue` in the variable form. +fn prop_guid(v: &Kv) -> Option { + v.get("guidValue").and_then(guid).or_else(|| v.get("symbolIdValue")?.get("guid").and_then(guid)) +} + +/// The value a component property assignment sets: `value`, or the variable form `varValue` holds +/// when `value` is empty, as current files write many booleans and instance swaps. +fn assigned<'k, 's>(assignment: &'k Kv<'s>) -> Option<&'k Kv<'s>> { + assignment.get("value").filter(|v| !matches!(v, Kv::Object(fields) if fields.is_empty())).or_else(|| assignment.get("varValue")?.get("value")) } /// The property a layer's `field` is bound to (`parameterConsumptionMap`). diff --git a/crates/format/src/fig/prop_value_tests.rs b/crates/format/src/fig/prop_value_tests.rs index 3e24c3301..577b811a9 100644 --- a/crates/format/src/fig/prop_value_tests.rs +++ b/crates/format/src/fig/prop_value_tests.rs @@ -1,5 +1,6 @@ //! Component properties as current `.fig` files write them, on synthetic messages: the copy of a -//! set's definitions each variant keeps (`parentPropDefId`, no name or type of its own). +//! set's definitions each variant keeps (`parentPropDefId`, no name or type of its own), and +//! assignments that carry their value only in the variable form (`varValue`). use figcraft_doc::NodeKind; use serde_json::Value; @@ -41,6 +42,15 @@ fn definition(id: u32, name: &str, kind: &'static str, initial: Kv<'static>) -> o(vec![("id", g(id)), ("name", s(name)), ("type", Kv::Enum(kind)), ("initialValue", initial)]) } +/// An assignment of `def` that leaves `value` empty and says it in `varValue` only. +fn variable_assignment(def: u32, data_type: &'static str, value: Kv<'static>) -> Kv<'static> { + o(vec![ + ("defID", g(def)), + ("value", o(vec![])), + ("varValue", o(vec![("value", value), ("dataType", Kv::Enum(data_type)), ("resolvedDataType", Kv::Enum(data_type))])), + ]) +} + fn message(nodes: Vec>) -> Kv<'static> { let mut all = vec![node(1, "DOCUMENT", "Document", None, vec![]), node(2, "CANVAS", "Page", Some(1), vec![])]; all.extend(nodes); @@ -105,3 +115,58 @@ fn variant_layers_bind_to_the_set_properties_their_copies_name() { assert_eq!(hint.prop_refs.get("visible").map(String::as_str), Some("Hint")); assert_eq!(instance_props(&doc).get("Hint"), Some(&Value::Bool(false))); } + +#[test] +fn property_values_written_only_as_variables_are_applied() { + let msg = message(vec![ + node( + 10, + "SYMBOL", + "Field", + Some(2), + vec![( + "componentPropDefs", + Kv::Array(vec![ + definition(70, "Hint", "BOOL", o(vec![("boolValue", Kv::Bool(true))])), + definition(71, "Label", "TEXT", o(vec![("textValue", o(vec![("characters", s("Label"))]))])), + definition(72, "Icon", "INSTANCE_SWAP", o(vec![("guidValue", g(40))])), + ]), + )], + ), + node(11, "TEXT", "Hint text", Some(10), vec![bound("VISIBLE", 70)]), + node(12, "TEXT", "Label", Some(10), vec![("textData", o(vec![("characters", s("Label"))])), bound("TEXT_DATA", 71)]), + node(13, "INSTANCE", "Icon", Some(10), vec![("symbolData", o(vec![("symbolID", g(40))])), bound("OVERRIDDEN_SYMBOL_ID", 72)]), + node(40, "SYMBOL", "Mail", Some(2), vec![]), + node(41, "SYMBOL", "Card", Some(2), vec![]), + node( + 20, + "INSTANCE", + "Instance", + Some(2), + vec![ + ("symbolData", o(vec![("symbolID", g(10))])), + ( + "componentPropAssignments", + Kv::Array(vec![ + variable_assignment(70, "BOOLEAN", o(vec![("boolValue", Kv::Bool(false))])), + variable_assignment(71, "TEXT_DATA", o(vec![("textDataValue", o(vec![("characters", s("Name on card"))]))])), + variable_assignment(72, "SYMBOL_ID", o(vec![("symbolIdValue", o(vec![("guid", g(41))]))])), + ]), + ), + ], + ), + ]); + let (rest, _, _) = to_rest(&msg, "Synthetic", crate::rest::MAX_NODES); + let instance = by_id(&rest["document"], "9:20"); + assert_eq!(instance["componentProperties"]["Hint#9:70"]["value"], false); + assert_eq!(instance["componentProperties"]["Label#9:71"]["value"], "Name on card"); + assert_eq!(instance["componentProperties"]["Icon#9:72"]["value"], "9:41"); + assert_eq!(by_id(instance, "I9:20;9:11")["visible"], false); + assert_eq!(by_id(instance, "I9:20;9:12")["characters"], "Name on card"); + assert_eq!(by_id(instance, "I9:20;9:13")["componentId"], "9:41"); + + let (doc, _) = crate::rest::import_fig(&rest, &crate::rest::no_images).unwrap(); + let props = instance_props(&doc); + assert_eq!(props.get("Hint"), Some(&Value::Bool(false))); + assert_eq!(props.get("Label"), Some(&Value::String("Name on card".into()))); +} From f97789ade13a7c3ba27dac8078e1c38b14ff5eb4 Mon Sep 17 00:00:00 2001 From: MortenHusted Date: Sun, 18 Oct 2026 23:13:03 +0200 Subject: [PATCH 040/114] M7: CSS borders follow individual stroke sides A layer with individual stroke sides (a header with only a bottom divider, strokeSides [0, 0, 1, 0]) came out of the CSS, HTML, React and Leptos code as `border: 1px solid ...` on all four sides, because the border was written from the stroke weight alone. With individual sides it is now a border-top/right/bottom/left for each side that has a weight, and a side bound to a variable names it (border-top: var(--border-top, 3px)). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/codegen.rs | 24 +++++++++++++++++--- crates/engine/src/inspect_behaviour_tests.rs | 20 ++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index 6dd806462..ac7c96fa0 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -259,6 +259,25 @@ fn stroke_color(n: &Node) -> Option { n.strokes.iter().filter(|p| p.visible).find_map(|p| p.solid_color()) } +/// The CSS borders a solid stroke makes: one `border`, or with individual stroke sides a border +/// on each side that has a weight. +fn css_borders(doc: &Document, n: &Node, t: &mut Tokens) -> Vec<(String, String)> { + let Some(c) = stroke_color(n) else { return Vec::new() }; + let Some(sides) = n.stroke.sides else { + let weight = tok(doc, n, &["strokeWeight"], format!("{}px", num(n.stroke.weight)), t); + return vec![("border".into(), format!("{weight} solid {}", tok(doc, n, &["stroke"], c.to_css(), t)))]; + }; + let color = tok(doc, n, &["stroke"], c.to_css(), t); + let named = ["top", "right", "bottom", "left"].into_iter().zip(crate::cmd::props::STROKE_SIDES); + named + .zip(sides) + .filter(|(_, weight)| *weight > 0.0) + .map(|((side, field), weight)| { + (format!("border-{side}"), format!("{} solid {color}", tok(doc, n, &[field], format!("{}px", num(weight)), t))) + }) + .collect() +} + fn drop_shadow(n: &Node) -> Option<&Shadow> { n.effects.iter().filter(|e| e.visible).find_map(|e| match &e.kind { EffectKind::DropShadow(s) => Some(s), @@ -452,9 +471,8 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo PaintKind::Pattern(_) => put("background", "url(pattern.png) repeat".into()), } } - if let Some(c) = stroke_color(n) { - let weight = tok(doc, n, &["strokeWeight"], px(n.stroke.weight), t); - put("border", format!("{weight} solid {}", tok(doc, n, &["stroke"], c.to_css(), t))); + for (key, value) in css_borders(doc, n, t) { + put(&key, value); } let shadows: Vec = n .effects diff --git a/crates/engine/src/inspect_behaviour_tests.rs b/crates/engine/src/inspect_behaviour_tests.rs index a52cac86f..c4bcc6be6 100644 --- a/crates/engine/src/inspect_behaviour_tests.rs +++ b/crates/engine/src/inspect_behaviour_tests.rs @@ -214,6 +214,26 @@ fn css_for_common_layers() { } } +/// Individual stroke sides are borders on those sides only: a header with a divider under it isn't +/// boxed in, and a side bound to a variable names it. +#[test] +fn css_borders_follow_individual_stroke_sides() { + let mut s = Session::new(); + let r = rect(&mut s, 0.0, 0.0, 100.0, 50.0, None); + set(&mut s, &[r], json!({"strokes": ["#000000"], "strokeWeight": 1, "strokeSides": [0, 0, 2, 0]})); + let c = css(&mut s, r); + assert!(c.contains("border-bottom: 2px solid #000000;"), "{c}"); + assert!(!c.contains("border:") && !c.contains("border-top") && !c.contains("border-right") && !c.contains("border-left"), "{c}"); + + let col = ex(&mut s, "variable.createCollection", json!({"name": "Borders"})); + let col = col["id"].as_str().unwrap().to_string(); + ex(&mut s, "variable.create", json!({"collection": col, "name": "border/top", "type": "number", "value": 3})); + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "strokeTopWeight", "variable": "border/top"})); + let c = css(&mut s, r); + assert!(c.contains("border-top: var(--border-top, 3px) solid #000000;"), "{c}"); + assert!(c.contains("border-bottom: 2px solid #000000;"), "{c}"); +} + #[test] fn native_code_for_a_simple_layer() { let mut s = Session::new(); From 017517fbb9c1b1dfa96ff23ed9e5f5f8363b23da Mon Sep 17 00:00:00 2001 From: MortenHusted Date: Sun, 18 Oct 2026 23:44:37 +0200 Subject: [PATCH 041/114] #847: an instance's values are kept under the original definition An assignment and the layers that read it can name different copies of one definition (the set's own id on one side, a variant's copy on the other). The values were kept under the id the assignment names and read under the id the layer names, so such a value never reached its layer. Both sides now go through prop_root, the definition the copies were made from (CodeRabbit on #847). In Untitled UI, 143 assignments name a copy that no layer reads while another copy of it is read. Co-Authored-By: Claude Opus 5.5 --- crates/format/src/fig/map.rs | 22 ++++++++++++++----- crates/format/src/fig/prop_value_tests.rs | 26 ++++++++++++++++++----- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 22043650a..8e4671511 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -660,6 +660,18 @@ impl<'a> Mapper<'a> { matches!(self.prop_def(&guid(def)?), Some((_, "INSTANCE_SWAP"))).then_some(()) } + /// The definition a copy of it was made from, followed to the original: the values an instance + /// sets are kept under it, since an assignment and the layers that read it may name different + /// copies of one definition. + fn prop_root(&self, def: &str) -> String { + let mut def = def; + for _ in 0..=MAX_DEPTH { + let Some(parent) = self.prop_parents.get(def) else { break }; + def = parent; + } + def.to_string() + } + /// The definition `def` names: its own, or the set's for a variant's copy of it. fn prop_def(&self, def: &str) -> Option<&(String, &'a str)> { let mut def = def; @@ -926,7 +938,7 @@ impl<'a> Mapper<'a> { } self.prop_refs(&mut o, &view, inst); let slot_content = if own == "FRAME" { - prop_ref(&view, "SLOT_CONTENT_ID").and_then(|def| inst.and_then(|i| i.slot_contents.get(&def).cloned())) + prop_ref(&view, "SLOT_CONTENT_ID").and_then(|def| inst.and_then(|i| i.slot_contents.get(&self.prop_root(&def)).cloned())) } else { None }; @@ -1914,7 +1926,7 @@ impl<'a> Mapper<'a> { { refs.insert(field.into(), json!(name)); } - let Some(val) = inst.and_then(|i| i.props.get(&def)) else { continue }; + let Some(val) = inst.and_then(|i| i.props.get(&self.prop_root(&def))) else { continue }; match field { "visible" => { if let Some(b) = val.get("boolValue").and_then(Kv::bool) { @@ -1951,7 +1963,7 @@ impl<'a> Mapper<'a> { // An instance swap property value picks the main. if let Some(i) = outer && let Some(def) = prop_ref(v, "OVERRIDDEN_SYMBOL_ID") - && let Some(g) = i.props.get(&def).and_then(|p| prop_guid(p)) + && let Some(g) = i.props.get(&self.prop_root(&def)).and_then(|p| prop_guid(p)) { sym = Some(g); } @@ -2046,7 +2058,7 @@ impl<'a> Mapper<'a> { let mut slot_contents: BTreeMap = outer.map(|i| (*i.slot_contents).clone()).unwrap_or_default(); for a in assignments { if let (Some(d), Some(val)) = (a.get("defID").and_then(guid), assigned(a)) { - all_props.entry(d).or_insert(val); + all_props.entry(self.prop_root(&d)).or_insert(val); } let Some(def) = a.get("defID").and_then(guid) else { continue }; if self.prop_def(&def).is_none_or(|(_, ty)| *ty != "SLOT") { @@ -2070,7 +2082,7 @@ impl<'a> Mapper<'a> { self.warn("slot content assignment target is missing or is not slot content"); continue; } - slot_contents.entry(def).or_insert(target); + slot_contents.entry(self.prop_root(&def)).or_insert(target); } let mut symbols = outer.map(|i| i.symbols.clone()).unwrap_or_default(); symbols.push(sym.clone()); diff --git a/crates/format/src/fig/prop_value_tests.rs b/crates/format/src/fig/prop_value_tests.rs index 577b811a9..24248d32a 100644 --- a/crates/format/src/fig/prop_value_tests.rs +++ b/crates/format/src/fig/prop_value_tests.rs @@ -73,9 +73,10 @@ fn instance_props(doc: &figcraft_doc::Document) -> &std::collections::BTreeMap Kv<'static> { + message(vec![ node( 9, "FRAME", @@ -101,10 +102,15 @@ fn variant_layers_bind_to_the_set_properties_their_copies_name() { Some(2), vec![ ("symbolData", o(vec![("symbolID", g(10))])), - ("componentPropAssignments", Kv::Array(vec![o(vec![("defID", g(71)), ("value", o(vec![("boolValue", Kv::Bool(false))]))])])), + ("componentPropAssignments", Kv::Array(vec![o(vec![("defID", g(assigned)), ("value", o(vec![("boolValue", Kv::Bool(false))]))])])), ], ), - ]); + ]) +} + +#[test] +fn variant_layers_bind_to_the_set_properties_their_copies_name() { + let msg = field_set(71); let (rest, _, _) = to_rest(&msg, "Synthetic", crate::rest::MAX_NODES); assert_eq!(by_id(&rest["document"], "9:9")["componentPropertyDefinitions"]["Hint#9:70"]["type"], "BOOLEAN"); assert_eq!(by_id(&rest["document"], "9:11")["componentPropertyReferences"]["visible"], "Hint#9:70"); @@ -116,6 +122,16 @@ fn variant_layers_bind_to_the_set_properties_their_copies_name() { assert_eq!(instance_props(&doc).get("Hint"), Some(&Value::Bool(false))); } +/// An assignment reaches layers that read another copy of its definition: here the instance sets +/// the set's own definition while the variant's layer reads the variant's copy. +#[test] +fn assignments_reach_layers_that_read_another_copy_of_their_definition() { + let (rest, _, _) = to_rest(&field_set(70), "Synthetic", crate::rest::MAX_NODES); + let instance = by_id(&rest["document"], "9:20"); + assert_eq!(instance["componentProperties"]["Hint#9:70"]["value"], false); + assert_eq!(by_id(instance, "I9:20;9:11")["visible"], false); +} + #[test] fn property_values_written_only_as_variables_are_applied() { let msg = message(vec![ From 147a37435006f8853bb961f8d197ddd7107bbe32 Mon Sep 17 00:00:00 2001 From: MortenHusted Date: Sun, 18 Oct 2026 23:22:31 +0200 Subject: [PATCH 042/114] M3: a refused slot names component.makeSlot component.makeSlot adds its slot property through add_prop, which named itself in every error: making a second slot called "Content" said "invalid parameters for `component.addProperty`: a property named `Content` already exists", a command the caller never ran. add_prop now takes the command errors name, the fix #807 makes for wrap. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/component.rs | 9 +++++++-- crates/engine/src/slot_tests.rs | 11 +++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index ff7a56df4..42730a37c 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -608,7 +608,7 @@ fn make_slot(s: &mut Session, p: &Value) -> Result { let owner = Owner::resolve(doc, id).ok_or_else(|| bad(cmd, "the frame isn't inside a component"))?; let name = string(p, "name").unwrap_or_else(|| fresh_prop_name(doc, &owner, &n.name)); let preferred = get(p, "preferred").cloned().unwrap_or(Value::Null); - add_prop(s, &json!({"id": id, "layer": id, "name": name, "type": "slot", "preferred": preferred})) + add_prop_for(cmd, s, &json!({"id": id, "layer": id, "name": name, "type": "slot", "preferred": preferred})) } fn remove_slot(s: &mut Session, p: &Value) -> Result { @@ -1039,7 +1039,12 @@ fn valid_prop_name(cmd: &str, name: &str) -> Result { } fn add_prop(s: &mut Session, p: &Value) -> Result { - let cmd = "component.addProperty"; + add_prop_for("component.addProperty", s, p) +} + +/// Add a component property for command `cmd`, which errors name (`component.makeSlot` adds its +/// slot property here). +fn add_prop_for(cmd: &str, s: &mut Session, p: &Value) -> Result { let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component"))?; let name = valid_prop_name(cmd, &req_string(cmd, p, "name")?)?; let t = req_string(cmd, p, "type")?; diff --git a/crates/engine/src/slot_tests.rs b/crates/engine/src/slot_tests.rs index 516cb9d6c..cbf824d9f 100644 --- a/crates/engine/src/slot_tests.rs +++ b/crates/engine/src/slot_tests.rs @@ -41,6 +41,17 @@ fn props_of(s: &mut Session, id: NodeId) -> Vec { ex(s, "component.properties", json!({"id": id}))["properties"].as_array().cloned().unwrap_or_default() } +/// A refused slot is reported by `component.makeSlot`, the command that was run, not by the +/// property command it adds its slot property through. +#[test] +fn a_refused_slot_names_make_slot() { + let mut s = Session::new(); + let (c, ..) = slot_card(&mut s); + let other = idv(&ex(&mut s, "frame.create", json!({"x": 150, "y": 10, "width": 40, "height": 30, "parent": c, "name": "Other"}))); + let err = s.execute("component.makeSlot", &json!({"id": other, "name": "Content"})).unwrap_err().to_string(); + assert!(err.contains("`component.makeSlot`") && err.contains("a property named `Content` already exists"), "{err}"); +} + #[test] fn slots_are_frames_of_a_main_with_a_slot_property() { let mut s = Session::new(); From 6f90717e712d5052d64512c997a1d9a066b83cc7 Mon Sep 17 00:00:00 2001 From: MortenHusted Date: Sun, 18 Oct 2026 23:35:50 +0200 Subject: [PATCH 043/114] M3: effects refuse keys they don't read instead of dropping them node.set refuses unknown properties, but the effects inside it were read by serde, which drops keys it doesn't know. A drop shadow given offsetX/offsetY (the names variable bindings use for its offset) was stored with the default offset [0, 4] and the call said ok. effect.add and effect.set dropped them the same way. The three now refuse what the effect doesn't read and say what it takes: "effects[0]: unknown properties: offsetX, offsetY (a dropShadow takes blend, color, offset, radius, showBehind, spread, type, visible)". A key counts as read when setting it to null makes the effect fail to parse or come out different, so serde's aliases count as read without a list of them here. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 6 ++-- crates/engine/src/cmd/props.rs | 44 +++++++++++++++++++++++++++++- crates/engine/src/effects_tests.rs | 26 ++++++++++++++++++ 3 files changed, 72 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a4769a35c..3c4a180df 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -261,7 +261,7 @@ pub fn specs() -> Vec { }), spec!("effect.set", "Set effect", "{ids?, index, effect {..}}", true, |s, p| { let i = req_index("effect.set", p, "index")?; - let e: Effect = parse("effect.set", p, "effect")?.ok_or_else(|| bad("effect.set", "`effect` required"))?; + let e = super::props::effect_from("effect.set", p, "effect")?.ok_or_else(|| bad("effect.set", "`effect` required"))?; for id in req_targets("effect.set", s, p)? { let n = s.doc_mut()?.get_mut(id)?; if let Some(slot) = n.effects.get_mut(i) { @@ -1376,8 +1376,8 @@ fn pattern_fill(s: &mut Session, p: &Value) -> Result { } fn effect_add(s: &mut Session, p: &Value) -> Result { - let e: Effect = match get(p, "effect") { - Some(v) => serde_json::from_value(v.clone()).map_err(|e| bad("effect.add", e.to_string()))?, + let e: Effect = match super::props::effect_from("effect.add", p, "effect")? { + Some(e) => e, None => { let ty = string(p, "type").unwrap_or_else(|| "dropShadow".into()); Effect::of_type(&ty).ok_or_else(|| bad("effect.add", format!("unknown effect type `{ty}`")))? diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 8a1f4e1cd..8fd4ff40c 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -61,6 +61,48 @@ pub fn snake(v: &Value) -> Value { } } +/// `v` as an effect list, refusing keys an effect doesn't read: serde dropped them without a word, +/// so a shadow given `offsetY` kept the default offset. +pub fn effects_from(cmd: &str, v: &Value) -> Result> { + let effects: Vec = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("effects: {e}")))?; + for (i, given) in v.as_array().into_iter().flatten().enumerate() { + refuse_ignored_effect_keys(cmd, &format!("effects[{i}]"), given, |v| serde_json::from_value(snake(v)).ok())?; + } + Ok(effects) +} + +/// `p[key]` as one effect, read as serde reads it (its aliases too), refusing keys it doesn't read. +pub fn effect_from(cmd: &str, p: &Value, key: &str) -> Result> { + let Some(v) = get(p, key) else { return Ok(None) }; + let effect: Effect = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("`{key}`: {e}")))?; + refuse_ignored_effect_keys(cmd, &format!("`{key}`"), v, |v| serde_json::from_value(v.clone()).ok())?; + Ok(Some(effect)) +} + +/// Refuse the keys of effect `given` that `parse` ignores, naming the keys the effect takes. Set to +/// null, a key it reads fails to parse or changes the effect; a key it ignores changes nothing. +fn refuse_ignored_effect_keys(cmd: &str, at: &str, given: &Value, parse: impl Fn(&Value) -> Option) -> Result<()> { + let Some(fields) = given.as_object() else { return Ok(()) }; + let parsed = parse(given); + let ignored: Vec<&str> = fields + .iter() + .filter(|(_, v)| !v.is_null()) + .filter(|(k, _)| { + let mut probe = fields.clone(); + probe.insert((*k).clone(), Value::Null); + parse(&Value::Object(probe)).is_some_and(|e| Some(e) == parsed) + }) + .map(|(k, _)| k.as_str()) + .collect(); + if ignored.is_empty() { + return Ok(()); + } + let takes = parsed.and_then(|e| serde_json::to_value(e).ok()).map(crate::inspect::camel).unwrap_or_default(); + let kind = takes.get("type").and_then(Value::as_str).unwrap_or("effect").to_string(); + let names: Vec<&str> = takes.as_object().into_iter().flatten().map(|(k, _)| k.as_str()).collect(); + Err(bad(cmd, format!("{at}: unknown properties: {} (a {kind} takes {})", ignored.join(", "), names.join(", ")))) +} + pub(crate) fn enum_of(v: &Value) -> Option { let s = v.as_str()?; // Accept camelCase, snake/upper-case and spaced variants. @@ -462,7 +504,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map doc.get_mut(id)?.corners.smoothing = f64_of(v).unwrap_or(0.0).clamp(0.0, 1.0), - "effects" => doc.get_mut(id)?.effects = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("effects: {e}")))?, + "effects" => doc.get_mut(id)?.effects = effects_from(cmd, v)?, "clip" | "clipsContent" => doc.get_mut(id)?.clip = v.as_bool().ok_or_else(|| bad(cmd, "`clip` must be a boolean"))?, "isMask" => doc.get_mut(id)?.is_mask = v.as_bool().unwrap_or(false), "devStatus" => { diff --git a/crates/engine/src/effects_tests.rs b/crates/engine/src/effects_tests.rs index be69fac20..607b77114 100644 --- a/crates/engine/src/effects_tests.rs +++ b/crates/engine/src/effects_tests.rs @@ -51,6 +51,32 @@ fn effect_set_edits_noise_texture_and_glass() { assert!(s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "glass", "depth": "deep"}})).is_err()); } +/// A key an effect doesn't have is refused, naming what it takes: serde dropped it without a word, +/// so a shadow given `offsetX`/`offsetY` (the names variable bindings use) kept the default offset. +#[test] +fn effects_refuse_keys_they_dont_have() { + let mut s = Session::new(); + let r = rect(&mut s); + let shadow = json!({"type": "dropShadow", "color": "#00000040", "offsetX": 0, "offsetY": 1, "radius": 3}); + let err = s.execute("node.set", &json!({"ids": [r], "props": {"effects": [shadow]}})).unwrap_err().to_string(); + assert!(err.contains("effects[0]") && err.contains("offsetX, offsetY") && err.contains("offset"), "{err}"); + assert!(s.doc().unwrap().node(r).unwrap().effects.is_empty(), "nothing stored"); + let err = s.execute("effect.add", &json!({"ids": [r], "effect": {"type": "layerBlur", "radious": 8}})).unwrap_err().to_string(); + assert!(err.contains("radious"), "{err}"); + s.execute("effect.add", &json!({"ids": [r], "type": "dropShadow"})).unwrap(); + let err = s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "dropShadow", "spred": 2}})).unwrap_err().to_string(); + assert!(err.contains("spred"), "{err}"); + // Every kind's own keys still go in, camelCase or snake_case, and so does what inspect reports. + for ty in ["innerShadow", "layerBlur", "backgroundBlur", "noise", "texture", "glass"] { + s.execute("effect.add", &json!({"ids": [r], "type": ty})).unwrap(); + } + let reported = crate::inspect::node_json(s.doc().unwrap(), r, 0)["effects"].clone(); + s.execute("node.set", &json!({"ids": [r], "props": {"effects": reported}})).unwrap(); + let camel = json!({"type": "dropShadow", "offset": [0, 1], "showBehind": true, "visible": true}); + let snake = json!({"type": "innerShadow", "offset": [0, 1], "show_behind": false}); + s.execute("node.set", &json!({"ids": [r], "props": {"effects": [camel, snake]}})).unwrap(); +} + #[test] fn scaling_scales_grain_and_glass() { let mut s = Session::new(); From 4e4bf54bb6b9168b7e61a393b10d8099d14555a1 Mon Sep 17 00:00:00 2001 From: Cody Campbell Date: Sun, 18 Oct 2026 18:53:32 -0400 Subject: [PATCH 044/114] M1: smart-selection drag reordering --- ROADMAP.md | 2 + crates/engine/src/cmd/arrange.rs | 49 +++++ crates/engine/src/lib.rs | 3 + crates/engine/src/selection_reorder.rs | 107 ++++++++++ crates/engine/src/selection_reorder_tests.rs | 152 ++++++++++++++ crates/engine/src/tests.rs | 3 + crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 2 + crates/ui-egui/src/selection_spacing.rs | 192 ++++++++++++++---- crates/ui-egui/src/selection_spacing_tests.rs | 128 ++++++++++++ docs/gaps.md | 6 +- docs/parity-checklist.md | 4 +- docs/target-app-parity.md | 5 +- docs/ui-parity.md | 5 +- 14 files changed, 607 insertions(+), 52 deletions(-) create mode 100644 crates/engine/src/selection_reorder.rs create mode 100644 crates/engine/src/selection_reorder_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 8a587286d..1c3db7e40 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-18** · Smart-selection drag reordering (M1): Smart-selection center rings insert a layer through a regular row, column or complete top-left-aligned grid; Command/Ctrl-drag swaps entries. Unequal sizes reflow with the original gaps, hierarchy and selection stay intact, and each drag is one undo step. Escape or Undo during the drag cancels; switching documents, pages or tools rolls back the owning gesture. Mixed gaps, incomplete grids and unsupported selections keep spacing controls without reorder rings. Multiple marked items and irregular-grid insertion remain unimplemented. Engine and raw-input UI regression tests; desktop Linux before/during/after screenshots and insertion, undo/redo and Escape checked on synthetic cards. No side-by-side comparison with the incumbent. Headline estimates unchanged. Validation: 1,039 engine unit tests passed (2 ignored), 555 UI unit tests passed (1 ignored); formatting, layering, assets, parity and `cargo xtask wasm` passed. Clippy passes with only the existing `collapsible_match` findings suppressed; strict `cargo xtask ci` stops on seven findings in unchanged UI files. The workspace test run stops on the unchanged text crate’s `tail_tests::hanging_punctuation_outside_the_aligned_edge` (2 lines instead of 1); no text code changed. + - **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. - **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. - **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index ad6c21d12..3b4d2b263 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -27,6 +27,13 @@ pub fn specs() -> Vec { spacing ), spec!("arrange.tidy", "Tidy up", "{ids?, spacing?}", true, tidy), + spec!( + "arrange.reorder", + "Reorder selection", + "{ids?, source, target, swap?, fromGesture?} (insert at target's visual slot; no hierarchy changes)", + true, + reorder + ), spec!("arrange.packH", "Pack horizontal", "{ids?}", true, |s, p| pack(s, p, true)), spec!("arrange.packV", "Pack vertical", "{ids?}", true, |s, p| pack(s, p, false)), spec!("arrange.roundToPixels", "Round to pixels", "{ids?}", true, round_px), @@ -554,6 +561,48 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result { ok() } +/// Reorder visual slots without rebuilding or reparenting layers. +fn reorder(s: &mut Session, p: &Value) -> Result { + let cmd = "arrange.reorder"; + let ids = req_targets(cmd, s, p)?; + let source = req_id(cmd, p, "source")?; + let target = req_id(cmd, p, "target")?; + let ds = s.ds()?; + let base = ds.interaction.as_ref().filter(|_| boolean(p, "fromGesture") == Some(true)).map(|i| i.doc.clone()).unwrap_or_else(|| ds.doc.clone()); + let layout = + crate::selection_reorder::ReorderLayout::new(&base, &ids).ok_or_else(|| bad(cmd, "select a regular row, column or complete aligned grid"))?; + let positions = layout + .positions(source, target, boolean(p, "swap") == Some(true)) + .ok_or_else(|| bad(cmd, "source and target must belong to the selection"))?; + // Validate every delta before editing; absolute targets always come from the gesture's + // snapshot so crossing several slots and returning to the start does not accumulate edits. + let doc = s.doc()?; + let mut moves = Vec::new(); + for (id, at) in positions { + let now = doc.world_bounds(id).ok_or_else(|| bad(cmd, "layer is no longer available"))?; + let (dx, dy) = (at.x - now.x0, at.y - now.y0); + if !dx.is_finite() || !dy.is_finite() { + return Err(bad(cmd, "non-finite reorder result")); + } + if dx.abs() > 1e-9 || dy.abs() > 1e-9 { + let node = doc.get(id)?; + let parent = node.parent.map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY); + let transform = parent.inverse() * Affine::translate((dx, dy)) * parent * node.transform; + if !transform.as_coeffs().iter().all(|v| v.is_finite()) { + return Err(bad(cmd, "non-finite local transform")); + } + moves.push((id, dx, dy)); + } + } + if !moves.is_empty() { + let doc = s.doc_mut()?; + for (id, dx, dy) in moves { + move_world(doc, id, dx, dy)?; + } + } + ok() +} + /// Set the distances between smart-selection tracks without rebuilding/reparenting the layers. fn spacing(s: &mut Session, p: &Value) -> Result { let ids = req_targets("arrange.spacing", s, p)?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 312c2e878..c3c53c35b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -49,6 +49,7 @@ pub mod proto_run; pub mod rename; pub mod rotation_origin; pub mod selection_colors; +pub mod selection_reorder; pub mod selection_spacing; pub mod shape_handles; pub mod shapebuilder; @@ -1838,5 +1839,7 @@ mod view_behaviour_tests; #[cfg(test)] mod widgets_tests; +#[cfg(test)] +mod selection_reorder_tests; #[cfg(test)] mod selection_spacing_tests; diff --git a/crates/engine/src/selection_reorder.rs b/crates/engine/src/selection_reorder.rs new file mode 100644 index 000000000..e538fc8bc --- /dev/null +++ b/crates/engine/src/selection_reorder.rs @@ -0,0 +1,107 @@ +//! Geometry for reordering a smart selection. Only regular, non-overlapping rows, columns +//! and complete top-left-aligned grids have unambiguous insertion slots. +//! Behavior reference: https://help.figma.com/hc/en-us/articles/360040450233 +//! (public documentation only; multi-item marking and irregular grids remain out of scope). +use figcraft_doc::{Document, NodeId}; +use figcraft_geom::{Point, Rect}; + +use crate::selection_spacing::selection_spacing; + +#[derive(Clone, Debug)] +pub struct ReorderLayout { + /// Visual order, independent of selection order and layer stacking. + pub slots: Vec<(NodeId, Rect)>, + columns: usize, + rows: usize, + gap_x: f64, + gap_y: f64, +} + +impl ReorderLayout { + pub fn new(doc: &Document, ids: &[NodeId]) -> Option { + let layout = selection_spacing(doc, ids); + let h = layout.horizontal.as_ref(); + let v = layout.vertical.as_ref(); + let gap = |axis: Option<&crate::selection_spacing::Axis>| match axis { + Some(a) => a.value.filter(|v| *v >= 0.0), + None => Some(0.0), + }; + let (gap_x, gap_y) = (gap(h)?, gap(v)?); + let columns = h.map_or(1, |a| a.tracks.len()); + let rows = v.map_or(1, |a| a.tracks.len()); + if ids.len() < 2 || columns.checked_mul(rows)? != ids.len() { + return None; + } + let mut cells = std::collections::BTreeMap::new(); + for id in ids { + let col = match h { + Some(a) => a.tracks.iter().position(|t| t.ids.contains(id))?, + None => 0, + }; + let row = match v { + Some(a) => a.tracks.iter().position(|t| t.ids.contains(id))?, + None => 0, + }; + let b = doc.world_bounds(*id)?; + if columns > 1 && rows > 1 { + let x = h?.tracks.get(col)?.bounds.x0; + let y = v?.tracks.get(row)?.bounds.y0; + if (b.x0 - x).abs() > 0.01 || (b.y0 - y).abs() > 0.01 { + return None; + } + } + if cells.insert((row, col), (*id, b)).is_some() { + return None; + } + } + Some(Self { slots: cells.into_values().collect(), columns, rows, gap_x, gap_y }) + } + + /// Insertion shifts intervening items; swapping exchanges just two entries. Sizes and + /// original gaps are retained, so unequal-sized layers reflow instead of overlapping. + pub fn positions(&self, source: NodeId, target: NodeId, swap: bool) -> Option> { + let from = self.slots.iter().position(|(id, _)| *id == source)?; + let to = self.slots.iter().position(|(id, _)| *id == target)?; + if from == to { + return Some(self.slots.iter().map(|(id, b)| (*id, Point::new(b.x0, b.y0))).collect()); + } + let mut order = self.slots.clone(); + if swap { + order.swap(from, to); + } else { + let item = order.remove(from); + order.insert(to, item); + } + let mut widths = vec![0.0_f64; self.columns]; + let mut heights = vec![0.0_f64; self.rows]; + for (i, (_, b)) in order.iter().enumerate() { + let w = widths.get_mut(i % self.columns)?; + *w = w.max(b.width()); + let h = heights.get_mut(i / self.columns)?; + *h = h.max(b.height()); + } + let x0 = self.slots.iter().map(|(_, b)| b.x0).fold(f64::INFINITY, f64::min); + let y0 = self.slots.iter().map(|(_, b)| b.y0).fold(f64::INFINITY, f64::min); + let starts = |sizes: &[f64], mut at: f64, gap: f64| { + sizes + .iter() + .map(|size| { + let start = at; + at += size + gap; + start + }) + .collect::>() + }; + let xs = starts(&widths, x0, self.gap_x); + let ys = starts(&heights, y0, self.gap_y); + order + .iter() + .enumerate() + .map(|(i, (id, b))| { + let x = if self.columns == 1 { b.x0 } else { *xs.get(i % self.columns)? }; + let y = if self.rows == 1 { b.y0 } else { *ys.get(i / self.columns)? }; + Some((*id, Point::new(x, y))) + }) + .collect() + } +} diff --git a/crates/engine/src/selection_reorder_tests.rs b/crates/engine/src/selection_reorder_tests.rs new file mode 100644 index 000000000..83ae5b310 --- /dev/null +++ b/crates/engine/src/selection_reorder_tests.rs @@ -0,0 +1,152 @@ +use crate::{Session, selection_reorder::ReorderLayout}; +use figcraft_doc::NodeId; +use serde_json::json; + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64, parent: Option) -> NodeId { + NodeId(s.execute("shape.rectangle", &json!({"x":x,"y":y,"width":w,"height":h,"parent":parent})).unwrap()["id"].as_u64().unwrap()) +} +fn xy(s: &Session, id: NodeId) -> (f64, f64) { + let b = s.doc().unwrap().world_bounds(id).unwrap(); + (b.x0, b.y0) +} + +#[test] +fn insertion_reflows_unequal_sizes_with_original_gaps_and_hierarchy() { + for vertical in [false, true] { + let mut s = Session::new(); + let mut ids = vec![]; + for (at, size) in [(0., 40.), (50., 60.), (120., 20.)] { + ids.push(rect( + &mut s, + if vertical { 0. } else { at }, + if vertical { at } else { 0. }, + if vertical { 30. } else { size }, + if vertical { size } else { 30. }, + None, + )); + } + let before = s.doc().unwrap().clone(); + // Explicit selection order must not determine visual order. + s.execute("arrange.reorder", &json!({"ids":[ids[2],ids[0],ids[1]],"source":ids[0],"target":ids[2]})).unwrap(); + for (id, at) in [(ids[1], 0.), (ids[2], 70.), (ids[0], 100.)] { + assert_eq!(xy(&s, id), if vertical { (0., at) } else { (at, 0.) }); + let old = before.node(id).unwrap(); + let new = s.doc().unwrap().node(id).unwrap(); + assert_eq!((new.width, new.height, new.parent), (old.width, old.height, old.parent)); + } + assert_eq!(s.doc().unwrap().node(s.ds().unwrap().page).unwrap().children, before.node(s.ds().unwrap().page).unwrap().children); + s.undo().unwrap(); + assert_eq!(s.doc().unwrap(), &before); + s.redo().unwrap(); + assert_eq!(xy(&s, ids[0]), if vertical { (0., 100.) } else { (100., 0.) }); + } +} + +#[test] +fn grids_insert_across_rows_and_swap_only_the_two_entries() { + let mut s = Session::new(); + let mut ids = vec![]; + for row in 0..2 { + for col in 0..3 { + ids.push(rect(&mut s, col as f64 * 70., row as f64 * 60., 50., 40., None)); + } + } + let before = s.doc().unwrap().clone(); + s.execute("arrange.reorder", &json!({"ids":ids,"source":ids[0],"target":ids[4]})).unwrap(); + assert_eq!(xy(&s, ids[0]), (70., 60.)); + assert_eq!(xy(&s, ids[3]), (140., 0.)); + assert_eq!(xy(&s, ids[5]), (140., 60.)); + s.undo().unwrap(); + s.execute("arrange.reorder", &json!({"ids":ids,"source":ids[0],"target":ids[4],"swap":true})).unwrap(); + assert_eq!(xy(&s, ids[0]), (70., 60.)); + assert_eq!(xy(&s, ids[4]), (0., 0.)); + for id in [ids[1], ids[2], ids[3], ids[5]] { + assert_eq!(s.doc().unwrap().node(id), before.node(id)); + } +} + +#[test] +fn grid_reflow_keeps_gaps_with_different_sizes() { + let mut s = Session::new(); + let a = rect(&mut s, 0., 0., 40., 30., None); + let b = rect(&mut s, 50., 0., 60., 30., None); + let c = rect(&mut s, 0., 50., 40., 50., None); + let d = rect(&mut s, 50., 50., 60., 50., None); + s.execute("arrange.reorder", &json!({"ids":[a,b,c,d],"source":a,"target":d})).unwrap(); + assert_eq!(xy(&s, b), (0., 0.)); + assert_eq!(xy(&s, c), (70., 0.)); + assert_eq!(xy(&s, d), (0., 70.)); + assert_eq!(xy(&s, a), (70., 70.)); +} + +#[test] +fn previews_are_absolute_and_returning_to_start_is_a_noop() { + let mut s = Session::new(); + let ids: Vec<_> = (0..3).map(|i| rect(&mut s, i as f64 * 70., 0., 50., 40., None)).collect(); + s.execute("select.set", &json!({"ids":ids})).unwrap(); + let before = s.doc().unwrap().clone(); + let history = s.undo_depth().0; + s.begin_interaction("Reorder selection").unwrap(); + for target in [ids[2], ids[1], ids[0]] { + s.execute("arrange.reorder", &json!({"ids":ids,"source":ids[0],"target":target,"fromGesture":true})).unwrap(); + } + s.commit_interaction().unwrap(); + assert_eq!(s.doc().unwrap(), &before); + assert_eq!(s.undo_depth().0, history); + s.begin_interaction("Reorder selection").unwrap(); + for target in [ids[1], ids[2]] { + s.execute("arrange.reorder", &json!({"ids":ids,"source":ids[0],"target":target,"fromGesture":true})).unwrap(); + } + s.commit_interaction().unwrap(); + assert_eq!(s.undo_depth().0, history + 1); + assert_eq!(s.selection(), ids); + s.undo().unwrap(); + assert_eq!(s.doc().unwrap(), &before); +} + +#[test] +fn invalid_selections_and_targets_do_not_edit_the_document() { + let mut s = Session::new(); + let a = rect(&mut s, 0., 0., 50., 40., None); + let b = rect(&mut s, 70., 0., 50., 40., None); + let c = rect(&mut s, 160., 0., 50., 40., None); + for args in [ + json!({"ids":[a,b,c],"source":a,"target":b}), // Mixed gaps. + json!({"ids":[a,b],"source":c,"target":b}), + json!({"ids":[a,b],"source":a,"target":999999}), + json!({"ids":[a,a,b],"source":a,"target":b}), + json!({"ids":[a,b],"source":[],"target":b}), + ] { + let before = s.doc().unwrap().clone(); + assert!(s.execute("arrange.reorder", &args).is_err()); + assert_eq!(s.doc().unwrap(), &before); + } + for props in [json!({"locked":true}), json!({"visible":false})] { + s.execute("node.set", &json!({"id":b,"props":props})).unwrap(); + assert!(ReorderLayout::new(s.doc().unwrap(), &[a, b]).is_none()); + s.execute("node.set", &json!({"id":b,"props":{"locked":false,"visible":true}})).unwrap(); + } + let d = rect(&mut s, 0., 70., 50., 40., None); + assert!(ReorderLayout::new(s.doc().unwrap(), &[a, b, d]).is_none(), "an incomplete grid has no slots"); + let frame = + NodeId(s.execute("frame.create", &json!({"width":300,"height":100,"props":{"layout":"horizontal"}})).unwrap()["id"].as_u64().unwrap()); + let x = rect(&mut s, 0., 0., 50., 40., Some(frame)); + let y = rect(&mut s, 70., 0., 50., 40., Some(frame)); + assert!(ReorderLayout::new(s.doc().unwrap(), &[x, y]).is_none()); + assert!(ReorderLayout::new(s.doc().unwrap(), &[a, x]).is_none()); +} + +#[test] +fn reordering_children_of_a_rotated_parent_uses_page_coordinates() { + let mut s = Session::new(); + let frame = NodeId( + s.execute("frame.create", &json!({"x":200,"y":200,"width":300,"height":100,"props":{"rotation":90}})).unwrap()["id"].as_u64().unwrap(), + ); + let a = rect(&mut s, 0., 0., 50., 40., Some(frame)); + let b = rect(&mut s, 70., 0., 50., 40., Some(frame)); + let (pa, pb) = (xy(&s, a), xy(&s, b)); + s.execute("arrange.reorder", &json!({"ids":[a,b],"source":a,"target":b})).unwrap(); + let (qa, qb) = (xy(&s, a), xy(&s, b)); + assert!((qa.0 - pb.0).abs() < 1e-7 && (qa.1 - pb.1).abs() < 1e-7); + assert!((qb.0 - pa.0).abs() < 1e-7 && (qb.1 - pa.1).abs() < 1e-7); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d8ebfc9e6..145f4f074 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -418,6 +418,9 @@ fn every_command_survives_junk_params() { // Selection color slots: malformed indexes, ranges and paints. json!({"targets": [{"id": 2, "fills": true, "index": -1, "range": [0, 1e308]}], "paint": {"type": "solid", "color": "invalid"}}), json!({"targets": [{"id": 2, "fills": "yes", "index": 0, "range": [4, 1]}], "style": 5}), + // Smart-selection reorder targets and modes as junk. + json!({"ids": [2, 3], "source": -1, "target": {"id": 3}, "swap": [], "fromGesture": true}), + json!({"source": 1e308, "target": "NaN", "swap": "yes", "fromGesture": []}), // Rotation origins and placing with a tool as junk. json!({"tool": [1], "x": 1e308, "y": "NaN", "reset": "maybe", "on": {"x": 1}}), json!({"tool": "frame", "x": -1e308, "y": 1e308, "reset": true, "on": "yes"}), diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 17f6b4c5f..ffc75fe12 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -8,6 +8,7 @@ name = "English" [ui] +"Reorder selection" = "Reorder selection" "% of all commits" = "% of all commits" "(no longer published)" = "(no longer published)" "({n} warnings: some entries were skipped)" = { one = "({n} warning: some entries were skipped)", other = "({n} warnings: some entries were skipped)" } diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index d054e2223..96662b9d4 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -8,6 +8,7 @@ name = "Español" status = "draft — needs native review" [ui] +"Reorder selection" = "Reordenar selección" "Compatibility notes" = "Notas de compatibilidad" "Couldn't download the variables" = "No se pudieron descargar las variables" @@ -1741,6 +1742,7 @@ status = "draft — needs native review" "⌥ drag" = "⌥ arrastrar" [commands] +"arrange.reorder" = "Reordenar selección" "align.bottom" = "Alinear abajo" "align.hcenter" = "Alinear centros horizontales" "align.left" = "Alinear a la izquierda" diff --git a/crates/ui-egui/src/selection_spacing.rs b/crates/ui-egui/src/selection_spacing.rs index 5110be1c2..c24654042 100644 --- a/crates/ui-egui/src/selection_spacing.rs +++ b/crates/ui-egui/src/selection_spacing.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use egui::{CursorIcon, Id, Pos2, Rect, Stroke, Ui, vec2}; use figcraft_doc::{Document, NodeId}; +use figcraft_engine::selection_reorder::ReorderLayout; use figcraft_engine::selection_spacing::{Axis, SelectionSpacing, selection_spacing}; use serde_json::json; @@ -20,6 +21,7 @@ struct LayoutCache { ids: Vec, frame: u64, layout: Arc, + reorder: Option>, } fn layout(app: &FigApp, ctx: &egui::Context, ids: &[NodeId]) -> Arc { @@ -41,25 +43,58 @@ fn layout(app: &FigApp, ctx: &egui::Context, ids: &[NodeId]) -> Arc Option> { + layout(app, ctx, ids); + ctx.data(|d| d.get_temp::(Id::new("selection_spacing_layout"))).and_then(|c| c.reorder) +} + fn drag_id() -> Id { Id::new("selection_spacing_drag") } +#[derive(Clone)] +enum Action { + Spacing { axis: Axis, value: f64, last_value: Option }, + Reorder { layout: Arc, source: NodeId, target: NodeId, swap: bool }, +} + #[derive(Clone)] struct Drag { ids: Vec, - axis: Axis, + action: Action, start: Pos2, - value: f64, uid: u64, page: NodeId, moved: bool, - last_value: Option, +} + +fn start_drag(app: &mut FigApp, ctx: &egui::Context, ids: Vec, p: Pos2, action: Action) { + let label = match action { + Action::Spacing { .. } => tr("Spacing"), + Action::Reorder { .. } => tr("Reorder selection"), + }; + if app.session.begin_interaction(label).is_ok() + && let Ok(ds) = app.session.ds() + { + let drag = Drag { ids, action, start: p, uid: ds.uid, page: ds.page, moved: false }; + app.ui.layers_focus = false; + app.ui.key_box = None; + ctx.memory_mut(|m| { + if let Some(f) = m.focused() { + m.surrender_focus(f); + } + }); + ctx.data_mut(|d| d.insert_temp(drag_id(), drag)); + } } pub fn panel(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId]) { @@ -146,7 +181,7 @@ pub fn cancel(app: &mut FigApp, ctx: &egui::Context) -> bool { /// Retire handles when another document, page, tool or editing context takes over. pub fn sync(app: &mut FigApp, ctx: &egui::Context) { if let Some(drag) = ctx.data(|d| d.get_temp::(drag_id())) { - let same = app.session.ds().is_ok_and(|d| d.uid == drag.uid && d.page == drag.page && d.selection == drag.ids); + let same = app.session.ds().is_ok_and(|d| d.uid == drag.uid && d.page == drag.page && d.selection == drag.ids && d.interaction.is_some()); if !same || !available(app) { cancel(app, ctx); } @@ -157,39 +192,77 @@ pub fn sync(app: &mut FigApp, ctx: &egui::Context) { pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Option, hovered: bool, space: bool) -> bool { let dragging = ctx.data(|d| d.get_temp::(drag_id())); if let Some(mut drag) = dragging { - let same = app.session.ds().is_ok_and(|d| d.uid == drag.uid && d.page == drag.page && d.selection == drag.ids); + let same = app.session.ds().is_ok_and(|d| d.uid == drag.uid && d.page == drag.page && d.selection == drag.ids && d.interaction.is_some()); if !same { cancel(app, ctx); return true; } let down = ctx.input(|i| i.pointer.primary_down()); if let Some(p) = pos { - let zoom = app.session.ds().map_or(1.0, |d| d.view.zoom).max(1e-6); - let distance = if drag.axis.vertical { p.y - drag.start.y } else { p.x - drag.start.x }; - drag.moved |= distance.abs() >= 3.0; - if drag.moved { - let value = ctx - .input(|i| { - let quantum = if i.modifiers.alt { - 0.1 - } else if i.modifiers.shift { - 10.0 - } else { - 1.0 - }; - ((drag.value + f64::from(distance) / zoom) / quantum).round() * quantum - }) - .clamp(-1e6, 1e6); - let key = if drag.axis.vertical { "vertical" } else { "horizontal" }; - if drag.last_value != Some(value) { - match app.session.execute("arrange.spacing", &json!({"ids":drag.ids,key:value,"fromGesture":true})) { - Ok(_) => drag.last_value = Some(value), - Err(e) => app.toast(e.to_string()), + match &mut drag.action { + Action::Spacing { axis, value: original, last_value } => { + let zoom = app.session.ds().map_or(1.0, |d| d.view.zoom).max(1e-6); + let distance = if axis.vertical { p.y - drag.start.y } else { p.x - drag.start.x }; + drag.moved |= distance.abs() >= 3.0; + if drag.moved { + let value = ctx + .input(|i| { + let quantum = if i.modifiers.alt { + 0.1 + } else if i.modifiers.shift { + 10.0 + } else { + 1.0 + }; + ((*original + f64::from(distance) / zoom) / quantum).round() * quantum + }) + .clamp(-1e6, 1e6); + let key = if axis.vertical { "vertical" } else { "horizontal" }; + if *last_value != Some(value) { + match app.session.execute("arrange.spacing", &json!({"ids":drag.ids,key:value,"fromGesture":true})) { + Ok(_) => *last_value = Some(value), + Err(e) => app.toast(e.to_string()), + } + } } } + Action::Reorder { layout, source, target, swap } if app.canvas.rect.contains(p) => { + drag.moved |= p.distance(drag.start) >= 3.0; + // Hit original slots, never the moving preview: otherwise a stationary + // pointer could alternate between two permutations on successive frames. + let nearest = layout + .slots + .iter() + .min_by(|a, b| { + let center = |b: figcraft_geom::Rect| app.to_screen((b.x0 + b.x1) * 0.5, (b.y0 + b.y1) * 0.5); + center(a.1).distance_sq(p).total_cmp(¢er(b.1).distance_sq(p)) + }) + .map(|(id, _)| *id) + .unwrap_or(*source); + let swapping = ctx.input(|i| i.modifiers.command); + if drag.moved && (nearest != *target || swapping != *swap) { + match app + .session + .execute("arrange.reorder", &json!({"ids":drag.ids,"source":source,"target":nearest,"swap":swapping,"fromGesture":true})) + { + Ok(_) => { + *target = nearest; + *swap = swapping; + } + Err(e) => app.toast(e.to_string()), + } + } + } + // A release over a panel or outside the window commits the last canvas + // preview; the release's sentinel coordinates must not select a new slot. + Action::Reorder { .. } => {} } } - ctx.set_cursor_icon(if drag.axis.vertical { CursorIcon::ResizeVertical } else { CursorIcon::ResizeHorizontal }); + ctx.set_cursor_icon(match &drag.action { + Action::Spacing { axis, .. } if axis.vertical => CursorIcon::ResizeVertical, + Action::Spacing { .. } => CursorIcon::ResizeHorizontal, + Action::Reorder { .. } => CursorIcon::Grabbing, + }); if !down { let _ = app.session.commit_interaction(); ctx.data_mut(|d| d.remove::(drag_id())); @@ -198,11 +271,27 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Option, hovered: } return true; } - if !hovered || space || !available(app) || ctx.input(|i| i.modifiers.command) { + if !hovered || space || !available(app) { return false; } let Some(p) = pos else { return false }; let ids = app.session.selection(); + if let Some(reorder) = reorder_layout(app, ctx, &ids) { + for (id, b) in &reorder.slots { + let center = app.to_screen((b.x0 + b.x1) * 0.5, (b.y0 + b.y1) * 0.5); + if center.distance(p) <= 8.0 { + ctx.set_cursor_icon(CursorIcon::Grab); + if ctx.input(|i| i.pointer.primary_pressed()) { + let source = *id; + start_drag(app, ctx, ids, p, Action::Reorder { layout: reorder, source, target: source, swap: false }); + } + return true; + } + } + } + if ctx.input(|i| i.modifiers.command) { + return false; + } let layout = layout(app, ctx, &ids); for axis in [layout.horizontal.as_ref(), layout.vertical.as_ref()].into_iter().flatten() { for h in handles(app, axis) { @@ -210,20 +299,8 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Option, hovered: continue; } ctx.set_cursor_icon(if axis.vertical { CursorIcon::ResizeVertical } else { CursorIcon::ResizeHorizontal }); - if ctx.input(|i| i.pointer.primary_pressed()) - && app.session.begin_interaction(tr("Spacing")).is_ok() - && let Ok(ds) = app.session.ds() - { - let drag = - Drag { ids, axis: axis.clone(), start: p, value: h.value, uid: ds.uid, page: ds.page, moved: false, last_value: axis.value }; - app.ui.layers_focus = false; - app.ui.key_box = None; - ctx.memory_mut(|m| { - if let Some(f) = m.focused() { - m.surrender_focus(f); - } - }); - ctx.data_mut(|d| d.insert_temp(drag_id(), drag)); + if ctx.input(|i| i.pointer.primary_pressed()) { + start_drag(app, ctx, ids, p, Action::Spacing { axis: axis.clone(), value: h.value, last_value: axis.value }); } return true; } @@ -244,8 +321,33 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { if drag.is_none() && !hover.is_some_and(|p| rect.contains(p)) { return; } - let axes = if let Some(d) = &drag { - vec![d.axis.clone()] + let reorder = match drag.as_ref().map(|d| &d.action) { + Some(Action::Reorder { layout, .. }) => Some(layout.clone()), + Some(Action::Spacing { .. }) => None, + None => reorder_layout(app, ctx, &ds.selection), + }; + if let Some(layout) = reorder { + for (id, _) in &layout.slots { + let Some(b) = ds.doc.world_bounds(*id) else { continue }; + let center = app.to_screen((b.x0 + b.x1) * 0.5, (b.y0 + b.y1) * 0.5); + let hot = hover.is_some_and(|p| center.distance(p) <= 8.0); + let active = matches!(drag.as_ref().map(|d| &d.action), Some(Action::Reorder { source, .. }) if source == id); + if active { + painter.circle_filled(center, 4.5, PINK); + } else { + painter.circle_stroke(center, 4.5, Stroke::new(if hot { 2.5 } else { 1.5 }, PINK)); + } + } + } + if let Some(Drag { action: Action::Reorder { layout, target, .. }, moved: true, .. }) = &drag { + if let Some((_, b)) = layout.slots.iter().find(|(id, _)| id == target) { + let rect = Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1)).expand(3.0); + painter.rect_stroke(rect, 0, Stroke::new(2.0, crate::theme::ACCENT), egui::StrokeKind::Outside); + } + return; + } + let axes = if let Some(Drag { action: Action::Spacing { axis, .. }, .. }) = &drag { + vec![axis.clone()] } else { let layout = layout(app, ctx, &ds.selection); [layout.horizontal.clone(), layout.vertical.clone()].into_iter().flatten().collect() diff --git a/crates/ui-egui/src/selection_spacing_tests.rs b/crates/ui-egui/src/selection_spacing_tests.rs index 453bd60dc..685f7b4e9 100644 --- a/crates/ui-egui/src/selection_spacing_tests.rs +++ b/crates/ui-egui/src/selection_spacing_tests.rs @@ -205,3 +205,131 @@ fn stationary_gap_drag_does_not_change_the_document_again() { u.button(p + vec2(20.2 * z, 0.), false); assert_eq!(xy(&u, ids[1]), (200., 100.)); } + +#[test] +fn reorder_ring_drag_inserts_across_grid_rows_as_one_undo_step() { + let (mut u, ids) = scene(true); + let before = u.app.session.undo_depth().0; + let p = u.app.to_screen(125., 130.); + let target = u.app.to_screen(205., 230.); + let output = u.frame_output(vec![Event::PointerMoved(p)]); + assert_eq!(output.platform_output.cursor_icon, egui::CursorIcon::Grab); + drag(&mut u, p, target - p, true); + assert_eq!(xy(&u, ids[0]), (180., 200.)); + assert_eq!(xy(&u, ids[3]), (260., 100.)); + assert_eq!(u.app.session.selection(), ids); + assert_eq!(u.app.session.undo_depth().0, before + 1); + u.app.run("edit.undo", json!({})); + assert_eq!(xy(&u, ids[0]), (100., 100.)); + u.app.run("edit.redo", json!({})); + assert_eq!(xy(&u, ids[0]), (180., 200.)); +} + +#[test] +fn reorder_ring_escape_click_and_returning_home_make_no_undo_step() { + for action in [0, 1, 2] { + let (mut u, ids) = scene(false); + let history = u.app.session.undo_depth().0; + let before = u.app.session.doc().unwrap().clone(); + let p = u.app.to_screen(125., 130.); + let target = u.app.to_screen(285., 130.); + u.frame(vec![Event::PointerMoved(p)]); + if action == 0 { + u.button(p, true); + } else { + drag(&mut u, p, target - p, false); + assert_eq!(xy(&u, ids[0]), (260., 100.)); + if action == 1 { + key(&mut u, Key::Escape); + } else { + u.frame(vec![Event::PointerMoved(p)]); + } + } + u.button(p, false); + u.frame(vec![]); + assert_eq!(u.app.session.doc().unwrap(), &before); + assert_eq!(u.app.session.undo_depth().0, history); + assert_eq!(u.app.session.selection(), ids); + assert!(u.app.session.ds().unwrap().interaction.is_none()); + } +} + +#[test] +fn command_drag_swaps_and_a_stationary_pointer_keeps_the_preview() { + let (mut u, ids) = scene(true); + let p = u.app.to_screen(125., 130.); + let target = u.app.to_screen(205., 230.); + // The modifier can already be down when the handle is pressed. + let m = Modifiers::COMMAND; + u.frame_with(vec![Event::PointerMoved(p)], m); + u.frame_with(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: m }], m); + u.frame_with(vec![Event::PointerMoved(target)], m); + assert_eq!(xy(&u, ids[0]), (180., 200.)); + assert_eq!(xy(&u, ids[4]), (100., 100.)); + assert_eq!(xy(&u, ids[1]), (180., 100.)); + let snapshot = u.app.session.ds().unwrap().doc.clone(); + for _ in 0..5 { + u.frame_with(vec![], m); + } + assert!(std::sync::Arc::ptr_eq(&snapshot, &u.app.session.ds().unwrap().doc)); + // Changing modifiers without moving the pointer switches to insertion. + u.frame(vec![]); + assert_eq!(xy(&u, ids[1]), (100., 100.)); + assert_eq!(xy(&u, ids[4]), (100., 200.)); + u.button(target, false); +} + +#[test] +fn switching_documents_during_reordering_rolls_back_its_owner() { + let (mut u, ids) = scene(false); + let before = u.app.session.doc().unwrap().clone(); + let p = u.app.to_screen(125., 130.); + let target = u.app.to_screen(285., 130.); + drag(&mut u, p, target - p, false); + u.app.run("file.new", json!({})); + u.app.session.begin_interaction("Unrelated").unwrap(); + u.frame(vec![]); + assert!(u.app.session.ds().unwrap().interaction.is_some()); + u.app.session.active = 0; + assert_eq!(u.app.session.doc().unwrap(), &before); + assert_eq!(u.app.session.selection(), ids); +} + +#[test] +fn reorder_release_outside_window_commits_and_undo_while_dragging_cancels() { + for undo in [false, true] { + let (mut u, ids) = scene(false); + let history = u.app.session.undo_depth().0; + let p = u.app.to_screen(125., 130.); + let target = u.app.to_screen(285., 130.); + drag(&mut u, p, target - p, false); + if undo { + let m = Modifiers::COMMAND; + u.frame_with(vec![Event::Key { key: Key::Z, physical_key: None, pressed: true, repeat: false, modifiers: m }], m); + } + u.frame(vec![Event::PointerGone]); + u.button(Pos2::new(-1., -1.), false); + u.frame(vec![]); + assert_eq!(u.app.session.undo_depth().0, history + usize::from(!undo)); + assert_eq!(xy(&u, ids[0]), if undo { (100., 100.) } else { (260., 100.) }); + assert!(u.app.session.ds().unwrap().interaction.is_none()); + } +} + +#[test] +fn an_externally_finished_reorder_cannot_resume_on_later_pointer_motion() { + let (mut u, ids) = scene(false); + let p = u.app.to_screen(125., 130.); + let target = u.app.to_screen(285., 130.); + drag(&mut u, p, target - p, false); + u.app.session.commit_interaction().unwrap(); + let history = u.app.session.undo_depth().0; + let committed = u.app.session.doc().unwrap().clone(); + let middle = u.app.to_screen(205., 130.); + u.frame(vec![Event::PointerMoved(middle)]); + u.button(middle, false); + u.frame(vec![]); + assert_eq!(u.app.session.doc().unwrap(), &committed); + assert_eq!(u.app.session.undo_depth().0, history); + assert_eq!(xy(&u, ids[0]), (260., 100.)); +} diff --git a/docs/gaps.md b/docs/gaps.md index 9b1a770c1..5f2255e63 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (smart-selection drag reordering; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -174,6 +174,9 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe Layers; Paste over and Paste properties from the keyboard; then (#753) Selection colors for mixed selections and smart-selection spacing fields and canvas handles (no reorder rings). Each was checked in the app (on Windows; the submenus by their author), none side by side with Figma, none on macOS. +- **Landed (2026-10-18):** smart-selection center rings for single-layer insertion and + Command/Ctrl swaps in regular rows, columns and complete top-left-aligned grids; live preview, + cancellation and one undo step. Multiple marked layers and irregular-grid insertion remain. - **Evidence:** ROADMAP item 7 ("Left: tool by tool, panel by panel against the incumbent"). - **Impact:** Figma users notice the feel within minutes; it decides whether they stay. - **Estimate:** 40–60 h. Detail in [`ui-parity.md`](ui-parity.md). @@ -444,6 +447,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Smart-selection center rings insert a layer through a regular row, column or complete top-left-aligned grid; Command/Ctrl-drag swaps entries. Unequal sizes reflow with the original gaps, hierarchy and selection stay intact, and each drag is one undo step. Escape or Undo during the drag cancels; switching documents, pages or tools rolls back the owning gesture. Mixed gaps, incomplete grids and unsupported selections keep spacing controls without reorder rings. Multiple marked items and irregular-grid insertion remain unimplemented. Engine and raw-input UI regression tests; desktop Linux before/during/after screenshots and insertion, undo/redo and Escape checked on synthetic cards. No side-by-side comparison with the incumbent. Headline estimates unchanged. | | 2026-10-17 | minor | The 2026-10-17 landings. Gap 1: a new Landed entry (community batches #727 and #728, then #745: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments, images in the message, instance layers' size and resizing overrides, Hug where nothing is recorded; budgets from the file's size; the import report; a `.fig` copy from Save as; paste from another design app), its Missing list cut to what is still left, and the wrong "whether other apps open its files is unknown" replaced by what was checked; all on synthetic files, so the beta blocker and hours stand. Gap 2: server limits, log recovery and web join links in the evidence. Gap 4: menus, the Actions palette and the editor fixes (#733, #735, #736, #729), then Selection colors and smart-selection spacing (#753) landed, none compared side by side. Gap 5: catalog counts re-measured (2,272 entries; drafts at 95–99%; Indonesian, Czech, Turkish and Swedish listed). Gap 8: the Linux and BSD clipboard changes and Wayland window controls, not run there by the maintainers. Ranking and hours unchanged | | 2026-10-15 | minor | Gap 7: bounded canvas overscan for smoother panning. Estimates and ranking unchanged. | | 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 775a4fcb4..d53c9c52e 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,8 +1,8 @@ # Parity checklist -> **Generated:** 2026-10-17 by `cargo xtask parity`. Do not edit by hand. +> **Generated:** 2026-10-18 by `cargo xtask parity`. Do not edit by hand. -FigCraft commands registered for the incumbent's main menu and toolbar (561 commands in the engine). +FigCraft commands registered for the incumbent's main menu and toolbar (563 commands in the engine). **212 / 212 menu items (100.0%)** diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 16a5af69b..a9a1a507a 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-10-17 · **Change:** minor (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); 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) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (smart-selection drag reordering; 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); 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) · **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 @@ -95,7 +95,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | # | Area | Weight | Breadth | Ready | Remaining | Hours | |---|---|---|---|---|---|---| | 1 | Canvas & navigation | 6 | 98% | 74% | Outline mode's Include hidden layers and Include object bounds, Display P3 on screen, QA at extreme zooms (behaviour suite for navigation, rulers, guides, layout grids and outline mode landed: guide selection, Auto grid counts, outline-mode clicks) | 1–4 | -| 2 | Selection & transform | 8 | 97% | 72% | Handles, measuring and snapping checked tool by tool against Figma and real files (snap distance, handle reach, cursors); children turned off a right angle scale without skew, approximately (behaviour suite from the documentation, rotated groups and mixed selections, equal spacing snapping, Cmd/Ctrl to ignore constraints and marquees inside frames landed) | 3–5 | +| 2 | Selection & transform | 8 | 97% | 72% | Handles, measuring and snapping checked tool by tool against Figma and real files (snap distance, handle reach, cursors); children turned off a right angle scale without skew, approximately (behaviour suite from the documentation, rotated groups and mixed selections, equal spacing snapping, Cmd/Ctrl to ignore constraints and marquees inside frames landed; smart-selection rings reorder single layers in regular rows, columns and complete aligned grids, with Command/Ctrl swaps; multi-item marks and irregular-grid insertion remain) | 3–5 | | 3 | Layers panel | 5 | 99% | 85% | QA with long real files (behaviour suite from the documentation, Rename layers dialog, sweeping eyes and padlocks, page dragging and dividers, several layers dropped together, keyboard box selection, F2 rename, Shift+F10 context menu and 10k-layer timings landed) | 1–2 | | 4 | Shapes & vector | 8 | 99% | 73% | Corner smoothing on polygons, stars and vectors; the transform box around points of several layers; per-point caps and joins on dashed, variable-width and brush strokes; vector QA with real files (behaviour suites from the documentation, corner radius and star ratio handles, rounded vector points drawn, also beside curved segments, a cap and join per point, several handles dragged together, cut and paint bucket drags, delete and heal along the curve, a boolean group's look by operation and the miter angle landed) | 1–3 | | 5 | Paint | 7 | 90% | 71% | Video fills, wide-gamut canvas, HEIC images; plus darker in PDF (behaviour suite from the documentation, pass-through and isolating groups, clipping frames that don't isolate, plus darker, animated GIF fills, TIFF images and embedded image colour profiles landed) | 2–5 | @@ -638,6 +638,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Smart-selection center rings insert a layer through a regular row, column or complete top-left-aligned grid; Command/Ctrl-drag swaps entries. Unequal sizes reflow with the original gaps, hierarchy and selection stay intact, and each drag is one undo step. Escape or Undo during the drag cancels; switching documents, pages or tools rolls back the owning gesture. Mixed gaps, incomplete grids and unsupported selections keep spacing controls without reorder rings. Multiple marked items and irregular-grid insertion remain unimplemented. Engine and raw-input UI regression tests; desktop Linux before/during/after screenshots and insertion, undo/redo and Escape checked on synthetic cards. No side-by-side comparison with the incumbent. Headline estimates unchanged. | | 2026-10-17 | minor | Menu breadth re-run with `cargo xtask parity`: 212 / 212 (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Rows for the 2026-10-17 landings: import/export (area 14) and the file formats dimension name the `.fig` import's new fidelity (instance identity, overrides by key, text style precedence, grid tracks, slots, connector labels, per-point joins, text on a path, image adjustments, list start numbers, vertical trim), budgets from the file's size, the import report, a `.fig` copy from Save as and paste from another design app; the localization evidence takes the re-measured catalog counts (2,272 entries, drafts at 95–99%, Indonesian and Czech drafts, Swedish and Turkish catalogs); the Automation line adds control requests answered while the window is minimised (#720); batch #753's Selection colors, smart-selection spacing, Present tab and auto layout on empty frames are in ui-parity, prototyping-parity and auto-layout-parity. Numbers unchanged: every `.fig` and paste change is checked on synthetic files or clipboard HTML made from our own export, and real files are what gap 1's blocker and the file-exchange discounts wait for; the UI changes (menus, the Actions palette, editor fixes) are not compared side by side, so UI/UX stays at 58%; the localization grade and hours stay (Indonesian's draft is offset by the drafts' new gaps) | | 2026-10-17 | minor | Automation roots (M8, #715, #481): `figcraft-cli mcp --automation-read-root DIR --automation-write-root DIR` (each repeatable, existing folders) and the desktop app with the same flags confine every file a command reads to the read and write roots and every file it writes to the write roots; paths are resolved (links followed, `..`, Windows `\\?\` and UNC paths, case) and compared by whole folder names, anything else is an ordinary error. The engine reaches the file system through one module (enforced by `crates/engine/clippy.toml`), and a sweep runs every command with paths outside the roots. `--attach` refuses the flags (the app confines itself); `ui.screenshot` and `ui.render` return PNG data without a `path`, so the attached MCP server writes no temporary files. Numbers unchanged. | | 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 766284f1d..eab5aafe4 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (smart-selection drag reordering; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -69,7 +69,7 @@ interaction feel not yet compared. | Numeric fields: arithmetic, scrubbing by dragging the label, mixed values | done | Numeric panels use `widgets::number_field`; Typography line height, letter spacing and paragraph spacing share its leading-icon scrub logic (#373), retaining their existing unit parsing, Auto/Mixed values, variable chips and selected-character targeting (`typography_scrub_tests`). Scrubbing the label: a step a point (1 px; 1% of opacity, 1° of rotation), ten with Shift, a tenth with Alt, each stretch at the rate held while it was dragged, one undo step when let go; past the edge of the screen it goes on where the platform reports the mouse's own motion (desktop), otherwise it stops there. Arrow keys: Up/Down step a pixel field by the small nudge and Shift by the big nudge (the preferences), other fields by their own step and ten of it; they work while typing (from what is typed) and with the keyboard on the field. Typing: `+ - * /`, brackets, units (`px`, `°`, `%`, `x`, `ms`), a decimal comma; `+10`, `*2`, `/4` are relative to the value, and `50%`, `+10%`, `-10%` too in a field that isn't a percentage. Enter, Tab or a click elsewhere commits (Tab and Shift+Tab move to the next and previous control), Escape restores; a field left as it was commits nothing (no undo step, and a rounded display isn't written back). Mixed values show "Mixed": a number sets them all, a relative edit or an arrow key goes to each layer from its own value in one undo step, for X, Y, W, H, rotation, opacity, corner radius and stroke weight (elsewhere `+10` on Mixed sets 10 and `*2` does nothing) (`field_tests`). Left: scrubbing a Mixed field (nothing happens), `x2`-style suffix edits, the pointer isn't wrapped to the other side of the screen; rates not compared with Figma's | | Transform box at small sizes | done | a press inside a layer smaller than its handles moves it, the nearest handle wins, handles answer just outside and are hidden under 16 px on screen; cursors follow each handle and the box's rotation | | Locked layers | done | selectable from the layers panel; no handles, not dragged with the rest of a selection | -| Smart selection spacing | partial | with layers in a row, a column or an aligned grid selected, the Layout section shows horizontal and vertical spacing ("Mixed" until set; typed, scrubbed, fractional and negative) and pink handles in the gaps on the canvas drag them, one undo step, Escape cancels (`arrange.spacing`; sizes, parents and offsets within each track kept; at most 2,000 layers; flow children, locked layers and layers inside instances left out). No reorder rings for swapping layers; checked by its author on macOS and in the app on Windows (#402) | +| Smart selection spacing | partial | with layers in a row, a column or an aligned grid selected, the Layout section shows horizontal and vertical spacing ("Mixed" until set; typed, scrubbed, fractional and negative) and pink handles in the gaps on the canvas drag them, one undo step, Escape cancels (`arrange.spacing`; sizes, parents and offsets within each track kept; at most 2,000 layers; flow children, locked layers and layers inside instances left out). Center rings drag a single layer through a regular row, column or complete top-left-aligned grid; Command/Ctrl swaps entries. Preview, target outline, Escape cancellation and one-step undo (`arrange.reorder`, `selection_reorder_tests`, `selection_spacing_tests`). Sizes, gaps and layer hierarchy kept. Mixed gaps and incomplete or differently aligned grids have no rings; multiple marked-item dragging remains. Spacing checked by its author on macOS and in the app on Windows (#402) | | Selection colors | partial | for a mixed selection the Design panel lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart): editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a colour style of the document (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.selectColor`). No library styles or variable binding from there (#403) | | Keyboard box selection | done | Alt+Space on macOS, Ctrl+Space on Windows and Linux, or F8 (`select.area`): a box around the selection or in the middle of the view; arrows move it, Cmd/Ctrl+arrows or Shift+arrows size it (Shift with Cmd/Ctrl: farther), Enter selects like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter nested), Escape closes | @@ -172,6 +172,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | Smart-selection center rings insert a layer through a regular row, column or complete top-left-aligned grid; Command/Ctrl-drag swaps entries. Unequal sizes reflow with the original gaps, hierarchy and selection stay intact, and each drag is one undo step. Escape or Undo during the drag cancels; switching documents, pages or tools rolls back the owning gesture. Mixed gaps, incomplete grids and unsupported selections keep spacing controls without reorder rings. Multiple marked items and irregular-grid insertion remain unimplemented. Engine and raw-input UI regression tests; desktop Linux before/during/after screenshots and insertion, undo/redo and Escape checked on synthetic cards. No side-by-side comparison with the incumbent. Headline estimates unchanged. | | 2026-10-18 | minor | A rename (layers double-click, F2, File ▸ Rename, a page) selects the whole name in the frame its field first draws, so the first key replaces the name instead of being appended. Checked in the app on Windows over the control channel | | 2026-10-17 | minor | Escape or Enter while the pen draws ends the path and keeps its vector in edit mode with the pen, so the next path is drawn into the same layer (unconnected); another Escape leaves edit mode (#760). Entering edit mode on a layer that already is a vector no longer records an empty undo step. Numbers unchanged | | 2026-10-17 | minor | Text layers keep the platform IME on for every frame of an edit, not only frames with input (an idle frame turned it off and macOS dropped each composition, #747); the candidate window opens at the caret instead of the canvas corner; keys pressed while composing go to the IME | From caf0f44d90eb1afd57c06971f7c78d9ed887388c Mon Sep 17 00:00:00 2001 From: AJ Richardson Date: Mon, 19 Oct 2026 16:08:26 +1000 Subject: [PATCH 045/114] #855: The Search button for the pages doesn't work as expected --- crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/src/layers.rs | 57 ++++++++++++++++++-- crates/ui-egui/src/layers_behaviour_tests.rs | 44 +++++++++++++++ crates/ui-egui/src/lib.rs | 5 ++ 5 files changed, 103 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 17f6b4c5f..23879786d 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1184,6 +1184,7 @@ name = "English" "Search results" = "Search results" "Search fonts" = "Search fonts" "Search layers" = "Search layers" +"Search pages" = "Search pages" "Search shortcuts" = "Search shortcuts" "Section" = "Section" "Section color" = "Section color" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index d054e2223..0d5097e16 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1185,6 +1185,7 @@ status = "draft — needs native review" "Search results" = "Resultados de búsqueda" "Search fonts" = "Buscar fuentes" "Search layers" = "Buscar capas" +"Search pages" = "Buscar páginas" "Search shortcuts" = "Buscar atajos" "Section" = "Sección" "Section color" = "Color de sección" diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 32f66f80e..8159d569b 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -184,13 +184,20 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { app.ui.pages_open = !app.ui.pages_open; } let sr = Rect::from_center_size(pos2(hr.max.x - 50.0, hr.center().y), vec2(24.0, 24.0)); - let sresp = ui.interact(sr, ui.id().with("lsearch"), Sense::click()).tip(crate::shortcuts::tip(tr("Find"), "edit.findReplace")); + let sresp = ui.interact(sr, ui.id().with("lsearch"), Sense::click()).tip(tr("Search pages")); if sresp.hovered() { ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover); } icons::paint(ui.painter(), sr.shrink(5.0), Icon::Search, t.icon); + let search_id = Id::new("page_search_field"); + let mut focus_search = false; if sresp.clicked() { - app.ui.find = Some((String::new(), String::new())); + app.ui.page_search = !app.ui.page_search; + app.ui.page_filter.clear(); + if app.ui.page_search { + app.ui.pages_open = true; + focus_search = true; + } } let ar = Rect::from_center_size(pos2(hr.max.x - 22.0, hr.center().y), vec2(24.0, 24.0)); let aresp = ui.interact(ar, ui.id().with("addpage"), Sense::click()).tip(tr("Add new page")); @@ -204,9 +211,16 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { if !app.ui.pages_open { return; } + if app.ui.page_search { + page_search_field(app, ui, &t, w, search_id, focus_search); + } let Ok(ds) = app.session.ds() else { return }; let pages: Vec<(NodeId, String, bool)> = ds.doc.pages.iter().filter_map(|p| ds.doc.node(*p).map(|n| (*p, n.name.clone(), ds.doc.is_divider(*p)))).collect(); + let filter = app.ui.page_filter.trim().to_lowercase(); + let visible_pages: Vec<(NodeId, String, bool)> = + pages.iter().filter(|(_, name, divider)| filter.is_empty() || (!divider && name.to_lowercase().contains(&filter))).cloned().collect(); + let filtering = !filter.is_empty(); let cur = ds.page; let sel = ds.selection.clone(); let mut select = None; @@ -220,9 +234,12 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { // A long list scrolls in its own area, so the layers keep their room below it. let max_h = pages_list_height(app.ui.pages_height, ui.available_height()); let mut moved = egui::ScrollArea::vertical().id_salt("pages_list").max_height(max_h).show(ui, |ui| { - for (k, (id, name, divider)) in pages.iter().cloned().enumerate() { - let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click_and_drag()); - reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() }); + for (k, (id, name, divider)) in visible_pages.iter().cloned().enumerate() { + let sense = if filtering { Sense::click() } else { Sense::click_and_drag() }; + let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), sense); + if !filtering { + reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() }); + } a11y::list_item(&resp, if divider { tr("Divider") } else { &name }, id == cur); let r = r.shrink2(vec2(8.0, 0.0)); a11y::ring_around(ui, r, &resp); @@ -311,6 +328,36 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { pages_edge(app, ui, moved.inner_rect.height().min(moved.content_size.y)); } +/// The Pages panel's inline filter (Escape clears and closes it). +fn page_search_field(app: &mut FigApp, ui: &mut Ui, t: &Tokens, w: f32, id: Id, focus: bool) { + let (fr, _) = ui.allocate_exact_size(vec2(w, 32.0), Sense::hover()); + let field = Rect::from_min_max(pos2(fr.min.x + 12.0, fr.min.y + 2.0), pos2(fr.max.x - 12.0, fr.max.y - 6.0)); + ui.painter().rect_filled(field, CornerRadius::same(5), t.field); + icons::paint(ui.painter(), Rect::from_center_size(pos2(field.min.x + 12.0, field.center().y), vec2(12.0, 12.0)), Icon::Search, t.icon_secondary); + let te_rect = Rect::from_min_max(pos2(field.min.x + 24.0, field.center().y - 7.0), pos2(field.max.x - 6.0, field.center().y + 7.0)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(te_rect)); + let r = child.add( + egui::TextEdit::singleline(&mut app.ui.page_filter) + .id(id) + .frame(egui::Frame::NONE) + .font(regular(11.0)) + .hint_text(tr("Search pages")) + .vertical_align(egui::Align::Center) + .desired_width(te_rect.width()), + ); + if focus { + r.request_focus(); + } + crate::widgets::select_all_on_focus(ui, &r, &app.ui.page_filter); + if r.has_focus() { + ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + } + if (r.has_focus() || r.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + app.ui.page_search = false; + app.ui.page_filter.clear(); + } +} + /// The pages list's height in a panel with `avail` below the Pages header: the height asked for /// (by default 40% of it), at least three rows, and leaving the layers their header and three rows. pub(crate) fn pages_list_height(asked: Option, avail: f32) -> f32 { diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs index d4ef4f8ac..170154050 100644 --- a/crates/ui-egui/src/layers_behaviour_tests.rs +++ b/crates/ui-egui/src/layers_behaviour_tests.rs @@ -199,6 +199,50 @@ fn page_row(u: &mut Ui, page: NodeId, back: NodeId) -> Pos2 { panic!("no row for page {page:?}"); } +#[test] +fn pages_search_button_opens_the_filter_and_escape_clears_it() { + let s = Session::new(); + let _ = s.page().unwrap(); + let mut u = Ui::new(s); + + // The search icon in the Pages header opens the page-name field, not Find and Replace. + click(&mut u, Pos2::new(246.0, 124.0), Modifiers::NONE); + assert!(u.app.ui.page_search, "the Pages search is open"); + assert!(u.app.ui.find.is_none(), "page search does not open Find and Replace"); + u.frame(vec![Event::Text("COMPON".into())]); + assert_eq!(u.app.ui.page_filter, "COMPON", "typing can start immediately"); + u.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + assert!(!u.app.ui.page_search, "Escape closes page search"); + assert!(u.app.ui.page_filter.is_empty(), "Escape clears the filter"); +} + +#[test] +fn pages_search_filters_page_names_case_insensitively() { + let mut s = Session::new(); + let _overview = s.page().unwrap(); + let components = id(s.execute("page.add", &json!({"name": "Components"})).unwrap()); + let library = id(s.execute("page.add", &json!({"name": "Library"})).unwrap()); + s.execute("page.select", &json!({"id": library})).unwrap(); + let mut u = Ui::new(s); + u.app.ui.page_search = true; + u.app.ui.page_filter = "COMPON".into(); + let shapes = u.frame_output(vec![]).shapes; + let text = crate::live_tests::texts(&crate::live_tests::flat(&shapes)); + assert!(text.iter().any(|(label, _, _)| label == "Components"), "the matching page remains visible"); + assert!(!text.iter().any(|(label, _, _)| label == "Library"), "nonmatching pages are filtered out"); + + // Search leaves only Components; the active page starts as Library. + for y in (150..400).step_by(4) { + let p = Pos2::new(150.0, y as f32); + u.button(p, true); + u.button(p, false); + if u.app.session.page().unwrap() == components { + break; + } + } + assert_eq!(u.app.session.page().unwrap(), components, "matching is case-insensitive and opens the named page"); +} + #[test] fn pages_drag_to_reorder_and_dividers_open_nothing() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 450317b43..efb0776d9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -431,6 +431,9 @@ pub struct UiState { pub last_right_tab: RightTab, pub left_tab: LeftTab, pub pages_open: bool, + /// Pages panel search: open, and the case-insensitive name filter. + pub page_search: bool, + pub page_filter: String, /// The height asked for the pages list (`None`: a share of the panel). pub pages_height: Option, pub expanded: BTreeSet, @@ -595,6 +598,8 @@ impl Default for UiState { last_right_tab: RightTab::Design, left_tab: LeftTab::File, pages_open: true, + page_search: false, + page_filter: String::new(), pages_height: None, expanded: BTreeSet::new(), rename: None, From 3b7b28c03c9feb94e729e3ff95f997ada79bf85a Mon Sep 17 00:00:00 2001 From: AJ Richardson Date: Mon, 19 Oct 2026 16:37:52 +1000 Subject: [PATCH 046/114] improve page search functionality to close with Escape key even when unfocused --- crates/ui-egui/src/layers.rs | 2 +- crates/ui-egui/src/layers_behaviour_tests.rs | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 8159d569b..b93b97a39 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -352,7 +352,7 @@ fn page_search_field(app: &mut FigApp, ui: &mut Ui, t: &Tokens, w: f32, id: Id, if r.has_focus() { ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); } - if (r.has_focus() || r.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)) { + if app.ui.page_search && ui.input(|i| i.key_pressed(egui::Key::Escape)) { app.ui.page_search = false; app.ui.page_filter.clear(); } diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs index 170154050..a1f3685a2 100644 --- a/crates/ui-egui/src/layers_behaviour_tests.rs +++ b/crates/ui-egui/src/layers_behaviour_tests.rs @@ -216,6 +216,19 @@ fn pages_search_button_opens_the_filter_and_escape_clears_it() { assert!(u.app.ui.page_filter.is_empty(), "Escape clears the filter"); } +#[test] +fn pages_search_escape_closes_even_when_the_field_is_unfocused() { + let mut u = Ui::new(Session::new()); + u.app.ui.page_search = true; + u.app.ui.page_filter = "COMPON".into(); + u.frame(vec![]); + + u.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + + assert!(!u.app.ui.page_search, "Escape closes page search without field focus"); + assert!(u.app.ui.page_filter.is_empty(), "Escape clears the filter without field focus"); +} + #[test] fn pages_search_filters_page_names_case_insensitively() { let mut s = Session::new(); From be9e72f74d34fdcaaf256d078ee6dcad3691bf40 Mon Sep 17 00:00:00 2001 From: MortenHusted Date: Mon, 19 Oct 2026 09:15:34 +0200 Subject: [PATCH 047/114] M3: instances follow the variable modes their main component sets An instance copied its main's variable modes when it was made and kept the copy as modes of its own. Switching it to another variant kept the old variant's modes, and a mode set on the main later never reached it. Instances now take the modes their main sets through the link (`Document::inherited_modes`), closer than any frame around them. Modes set on the instance itself still win and stay through variant switches. Bindings are re-applied for the instances of a main that chose another mode and for an instance that switched mains. Detaching keeps the modes the instance took from its main, and the Variable modes panel shows them as the instance's Auto value. Instances made before this change keep their copy as modes of their own; setting one back to Auto lets it follow its main. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/lib.rs | 38 +++++-- crates/engine/src/cmd/component.rs | 14 +++ crates/engine/src/components.rs | 2 + crates/engine/src/instance_modes_tests.rs | 128 ++++++++++++++++++++++ crates/engine/src/lib.rs | 24 +++- crates/ui-egui/src/right.rs | 4 +- 6 files changed, 194 insertions(+), 16 deletions(-) create mode 100644 crates/engine/src/instance_modes_tests.rs diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 07700e8ce..d131b2f08 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -949,21 +949,39 @@ impl Document { self.styles.iter().find(|s| s.id == id || s.name == id) } - /// The effective variable modes for a node (walking up its ancestors). + /// The effective variable modes for a node: the ones it sets over [`Self::inherited_modes`]. pub fn modes_for(&self, id: NodeId) -> BTreeMap { - let mut out = BTreeMap::new(); - let mut chain = vec![id]; - chain.extend(self.ancestors(id)); - for c in chain.into_iter().rev() { - if let Some(n) = self.node(c) { - for (k, v) in &n.var_modes { - out.insert(k.clone(), v.clone()); - } - } + let mut out = self.inherited_modes(id); + if let Some(n) = self.node(id) { + out.extend(n.var_modes.iter().map(|(k, v)| (k.clone(), v.clone()))); } out } + /// The modes a node is on where it sets none (Auto): its ancestors', and for an instance the + /// ones its main component sets, which are closer than any container's. + pub fn inherited_modes(&self, id: NodeId) -> BTreeMap { + let mut out = BTreeMap::new(); + for c in self.ancestors(id).into_iter().rev() { + if let Some(n) = self.node(c) { + out.extend(self.main_modes(n).chain(&n.var_modes).map(|(k, v)| (k.clone(), v.clone()))); + } + } + if let Some(n) = self.node(id) { + out.extend(self.main_modes(n).map(|(k, v)| (k.clone(), v.clone()))); + } + out + } + + /// The modes an instance's main component sets (none for other layers). + fn main_modes<'a>(&'a self, n: &Node) -> impl Iterator { + let main = match n.kind { + NodeKind::Instance { component, .. } => self.node(component), + _ => None, + }; + main.into_iter().flat_map(|m| &m.var_modes) + } + /// Check structural invariants (used by tests and after loading untrusted files). pub fn validate(&self) -> Vec { let mut issues = Vec::new(); diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index ff7a56df4..6840bcf48 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -472,6 +472,7 @@ fn detach(s: &mut Session, p: &Value) -> Result { // Own layers lose their link (slot content stays as it is); nested instances stay // instances of their own mains. let nested = unlink_layers(doc, i, Some(i)); + keep_main_modes(doc, i)?; let n = doc.get_mut(i)?; n.kind = NodeKind::Frame; n.source = None; @@ -491,6 +492,19 @@ fn detach(s: &mut Session, p: &Value) -> Result { Ok(json!({"ids": out})) } +/// Make the variable modes instance `i` takes from its main its own (it's being detached). +fn keep_main_modes(doc: &mut Document, i: NodeId) -> Result<()> { + let modes = match doc.get(i)?.kind { + NodeKind::Instance { component, .. } => doc.node(component).map(|m| m.var_modes.clone()).unwrap_or_default(), + _ => return Ok(()), + }; + let own = &mut doc.get_mut(i)?.var_modes; + for (c, m) in modes { + own.entry(c).or_insert(m); + } + Ok(()) +} + fn reset_instance(s: &mut Session, p: &Value) -> Result { let cmd = "component.resetInstance"; let ids = req_targets(cmd, s, p)?; diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs index 56efc5c9a..ea67bb07c 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -285,6 +285,8 @@ pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId, root.dev_resources.clear(); // The main's interactions play on its instances in prototypes; the instance's own start empty. root.interactions.clear(); + // So do the variable modes the main sets (see `Document::inherited_modes`). + root.var_modes.clear(); doc.insert(root, parent, index)?; sync_instance(doc, id); Ok(id) diff --git a/crates/engine/src/instance_modes_tests.rs b/crates/engine/src/instance_modes_tests.rs new file mode 100644 index 000000000..79007622d --- /dev/null +++ b/crates/engine/src/instance_modes_tests.rs @@ -0,0 +1,128 @@ +//! Variable modes on instances: an instance is on the modes its main component sets, under the +//! ones set on the instance itself, so switching variants or changing the main's mode reaches it. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use figcraft_doc::NodeId; + +fn run(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn id(v: Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +fn fill(s: &Session, n: NodeId) -> String { + s.doc().unwrap().node(n).unwrap().fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default() +} + +fn set_mode(s: &mut Session, n: NodeId, mode: Value) { + run(s, "variable.setMode", json!({"ids": [n], "collection": "Tone", "mode": mode})); +} + +/// A "Tone" collection whose variable `c` is red in mode A and blue in mode B. +fn tone(s: &mut Session) { + run(s, "variable.createCollection", json!({"name": "Tone", "modes": ["A", "B"]})); + run(s, "variable.create", json!({"collection": "Tone", "name": "c", "type": "color", "values": {"A": "#FF0000", "B": "#0000FF"}})); +} + +/// A component filled from `c`, on `mode` when one is given. +fn swatch(s: &mut Session, name: &str, mode: Option<&str>) -> NodeId { + let f = id(run(s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 40, "name": name}))); + run(s, "variable.bind", json!({"ids": [f], "field": "fill", "variable": "c"})); + if let Some(m) = mode { + set_mode(s, f, json!(m)); + } + id(run(s, "component.create", json!({"ids": [f]}))) +} + +/// A set "Kind" whose variants A and B each set their own Tone mode. +fn kinds(s: &mut Session) -> (NodeId, NodeId) { + tone(s); + let a = swatch(s, "Kind=A", Some("A")); + let b = swatch(s, "Kind=B", Some("B")); + let set = id(run(s, "component.combineVariants", json!({"ids": [a, b]}))); + (set, b) +} + +fn instance(s: &mut Session, component: NodeId) -> NodeId { + id(run(s, "instance.create", json!({"component": component, "x": 200, "y": 0}))) +} + +fn switch(s: &mut Session, i: NodeId, kind: &str) { + run(s, "instance.setProperties", json!({"ids": [i], "properties": {"Kind": kind}})); +} + +#[test] +fn switching_variants_takes_the_new_variants_mode() { + let mut s = Session::new(); + let (set, _) = kinds(&mut s); + let i = instance(&mut s, set); + assert_eq!(fill(&s, i), "FF0000"); + switch(&mut s, i, "B"); + assert_eq!(fill(&s, i), "0000FF", "variant B sets mode B"); + switch(&mut s, i, "A"); + assert_eq!(fill(&s, i), "FF0000"); + assert!(s.doc().unwrap().node(i).unwrap().var_modes.is_empty(), "the instance sets no mode of its own"); +} + +#[test] +fn instances_follow_a_mode_their_main_sets_later() { + let mut s = Session::new(); + tone(&mut s); + let main = swatch(&mut s, "Chip", None); + let i = instance(&mut s, main); + assert_eq!(fill(&s, i), "FF0000", "no mode set: the collection's default"); + set_mode(&mut s, main, json!("B")); + assert_eq!(fill(&s, i), "0000FF"); + set_mode(&mut s, main, Value::Null); + assert_eq!(fill(&s, i), "FF0000", "the main back on Auto"); +} + +#[test] +fn a_mode_set_on_the_instance_wins_and_stays_through_variant_switches() { + let mut s = Session::new(); + let (set, _) = kinds(&mut s); + let i = instance(&mut s, set); + set_mode(&mut s, i, json!("B")); + assert_eq!(fill(&s, i), "0000FF"); + for kind in ["B", "A"] { + switch(&mut s, i, kind); + assert_eq!(fill(&s, i), "0000FF", "own mode B on variant {kind}"); + } + set_mode(&mut s, i, Value::Null); + assert_eq!(fill(&s, i), "FF0000", "back on Auto: variant A's mode"); +} + +#[test] +fn a_main_mode_is_closer_than_the_frame_around_the_instance() { + let mut s = Session::new(); + let (_, b) = kinds(&mut s); + let frame = id(run(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 200, "height": 100}))); + set_mode(&mut s, frame, json!("A")); + let i = id(run(&mut s, "instance.create", json!({"component": b, "x": 10, "y": 10, "parent": frame}))); + assert_eq!(fill(&s, i), "0000FF", "the main's mode B, not the frame's A"); + // Nested: an outer component on mode A holding an instance of B. + let outer_frame = id(run(&mut s, "frame.create", json!({"x": 0, "y": 500, "width": 100, "height": 100, "name": "Outer"}))); + run(&mut s, "instance.create", json!({"component": b, "x": 0, "y": 0, "parent": outer_frame})); + set_mode(&mut s, outer_frame, json!("A")); + let outer = id(run(&mut s, "component.create", json!({"ids": [outer_frame]}))); + let copy = instance(&mut s, outer); + let nested = s.doc().unwrap().node(copy).unwrap().children[0]; + assert_eq!(fill(&s, nested), "0000FF"); +} + +#[test] +fn detaching_keeps_the_mode_it_took_from_its_main() { + let mut s = Session::new(); + let (set, b) = kinds(&mut s); + let i = instance(&mut s, set); + switch(&mut s, i, "B"); + run(&mut s, "component.detach", json!({"ids": [i]})); + set_mode(&mut s, b, json!("A")); + assert_eq!(fill(&s, i), "0000FF", "the detached frame keeps mode B"); + assert_eq!(s.doc().unwrap().node(i).unwrap().var_modes.len(), 1); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 312c2e878..3145c8a94 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1460,21 +1460,35 @@ fn affects_layout(before: &Document, doc: &Document, id: NodeId) -> bool { } /// The layers whose variable bindings an edit may have changed: every bound layer when a variable -/// changed, else the touched layers and everything below those that moved, are new or chose -/// another variable mode (modes are inherited). +/// changed, else the touched layers and everything below those that moved, are new, chose +/// another variable mode or became an instance of another main (modes are inherited, an +/// instance's from its main too), and the instances of a main that chose another mode. fn binding_scope(doc: &Document, before: &Document, touched: &BTreeSet) -> Option> { if doc.variables != before.variables { return None; } + let main = |n: &Node| match n.kind { + figcraft_doc::NodeKind::Instance { component, .. } => Some(component), + _ => None, + }; let mut out = BTreeSet::new(); for &id in touched { let Some(n) = doc.node(id) else { continue }; - let inherited = before.node(id).is_none_or(|b| b.var_modes != n.var_modes || b.parent != n.parent); - if inherited { + let Some(b) = before.node(id) else { + out.extend(doc.subtree(id)); + continue; + }; + let modes = b.var_modes != n.var_modes; + if modes || b.parent != n.parent || main(b) != main(n) { out.extend(doc.subtree(id)); } else { out.insert(id); } + if modes && matches!(n.kind, figcraft_doc::NodeKind::Component { .. }) { + for i in components::instances_of(doc, id) { + out.extend(doc.subtree(i)); + } + } } Some(out) } @@ -1652,6 +1666,8 @@ mod import_journal_tests; #[cfg(test)] mod inspect_behaviour_tests; #[cfg(test)] +mod instance_modes_tests; +#[cfg(test)] mod instance_resizing_tests; #[cfg(test)] mod interaction_rollback_tests; diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 53eaf9535..5fd7dfc44 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -961,8 +961,8 @@ fn variable_modes(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) // The collection's name, then a wider mode picker ("Auto (Light)") across both field columns. let col = widgets::col_width(ui.available_width()); let (name_w, pick_w) = ((col * 0.7).round(), (col * 1.3).round()); - // Auto resolves from the parent of the first selected layer. - let parent_modes = sel.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent).map(|p| doc.modes_for(p)).unwrap_or_default(); + // Auto resolves as for the first selected layer: from its parent, or an instance's main. + let parent_modes = sel.first().map(|i| doc.inherited_modes(*i)).unwrap_or_default(); let mut group = None; for r in &rows { let c = r.coll; From be212f00e6af5398ca0a889783b7151c8c49b265 Mon Sep 17 00:00:00 2001 From: slyim <125447219+slyim@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:25:34 +0300 Subject: [PATCH 048/114] #826: the stroke-width test covers the Frame and Text marks Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/toolbar.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index de5993b4a..2f8c19724 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -509,6 +509,9 @@ mod tests { assert_eq!(mark.len(), 1, "one width at {ppp}×: {mark:?}"); assert_eq!(widths(ppp, Icon::Chevron, CARET), mark, "caret at {ppp}×"); assert_eq!(widths(ppp, Icon::Code, GLYPH), mark, "mode mark at {ppp}×"); + for icon in [Icon::Frame, Icon::Text] { + assert_eq!(widths(ppp, icon, GLYPH), mark, "{icon:?} at {ppp}×"); + } } } } From 5d40b3f42f8b6d9ef8f324ea9a530cbff4bb58de Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Mon, 19 Oct 2026 10:53:34 +0200 Subject: [PATCH 049/114] fix(pages): Delete deletes the page picked in the Pages list (#859) Clicking a page row (or the add-page button) gives the Pages list the keyboard: Delete or Backspace with no layers selected now deletes the current page instead of failing with "nothing is selected". No confirmation; a "Page deleted" toast says so and Undo brings it back. The context menu's Delete page shows the same toast. Selecting layers, clicking a layer row or pressing on the canvas gives Delete back to the layers. --- crates/ui-egui/locales/cs.toml | 1 + crates/ui-egui/locales/de.toml | 1 + crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/locales/fi.toml | 1 + crates/ui-egui/locales/fr.toml | 1 + crates/ui-egui/locales/id.toml | 1 + crates/ui-egui/locales/ja.toml | 1 + crates/ui-egui/locales/ko.toml | 1 + crates/ui-egui/locales/nl.toml | 1 + crates/ui-egui/locales/pt-BR.toml | 1 + crates/ui-egui/locales/ru.toml | 1 + crates/ui-egui/locales/sv.toml | 1 + crates/ui-egui/locales/tr.toml | 1 + crates/ui-egui/locales/zh-Hans.toml | 1 + crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/layers.rs | 13 +++- crates/ui-egui/src/layers_keys_tests.rs | 81 ++++++++++++++++++++++++- crates/ui-egui/src/lib.rs | 5 ++ crates/ui-egui/src/menus.rs | 6 ++ crates/ui-egui/src/selection_spacing.rs | 1 + 21 files changed, 119 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 980513149..a7e8be383 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -929,6 +929,7 @@ status = "draft — needs native review" "PDF print settings" = "Nastavení tisku PDF" "Padding" = "Vnitřní odsazení" "Page" = "Stránka" +"Page deleted" = "Stránka smazána" "Pages" = "Stránky" "Pages added to the document" = "Stránky přidány do dokumentu" "Placed on the current page" = "Umístěno na aktuální stránku" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 07669c1a7..e910863b9 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -898,6 +898,7 @@ status = "draft — needs native review" "PDF print settings" = "PDF-Druckeinstellungen" "Padding" = "Innenabstand" "Page" = "Seite" +"Page deleted" = "Seite gelöscht" "Pages" = "Seiten" "Pages added to the document" = "Seiten zum Dokument hinzugefügt" "Placed on the current page" = "Auf der aktuellen Seite platziert" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 17f6b4c5f..7220519db 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -950,6 +950,7 @@ name = "English" "PDF print settings" = "PDF print settings" "Padding" = "Padding" "Page" = "Page" +"Page deleted" = "Page deleted" "Pages" = "Pages" "Pages added to the document" = "Pages added to the document" "Placed on the current page" = "Placed on the current page" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index d054e2223..63c731316 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -951,6 +951,7 @@ status = "draft — needs native review" "PDF print settings" = "Ajustes de impresión PDF" "Padding" = "Margen interno" "Page" = "Página" +"Page deleted" = "Página eliminada" "Pages" = "Páginas" "Pages added to the document" = "Páginas añadidas al documento" "Placed on the current page" = "Colocado en la página actual" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 602574d79..6f12dd555 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -952,6 +952,7 @@ status = "draft — needs native review" "PDF print settings" = "PDF-tulostusasetukset" "Padding" = "Sisämarginaali" "Page" = "Sivu" +"Page deleted" = "Sivu poistettu" "Pages" = "Sivut" "Pages added to the document" = "Sivut lisätty asiakirjaan" "Pages: {pages}" = "Sivut: {pages}" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 5edf1a624..4af37b1d7 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -947,6 +947,7 @@ status = "draft — needs native review" "PDF print settings" = "Réglages d’impression PDF" "Padding" = "Marge intérieure" "Page" = "Page" +"Page deleted" = "Page supprimée" "Pages" = "Pages" "Pages added to the document" = "Pages ajoutées au document" "Pick the Frame tool, then drag on the canvas or choose a device size" = "Choisissez l’outil Cadre, puis faites glisser sur le canevas ou choisissez une taille d’appareil" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 65a9939cb..9c5aa4a6c 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -933,6 +933,7 @@ status = "draft — needs native review" "PDF print settings" = "Pengaturan cetak PDF" "Padding" = "Padding" "Page" = "Halaman" +"Page deleted" = "Halaman dihapus" "Pages" = "Halaman" "Pages added to the document" = "Halaman ditambahkan ke dokumen" "Placed on the current page" = "Ditempatkan di halaman saat ini" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index def8ebc47..b5dcaf272 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -938,6 +938,7 @@ status = "draft — needs native review" "PDF print settings" = "PDF 印刷設定" "Padding" = "パディング" "Page" = "ページ" +"Page deleted" = "ページは削除済み" "Pages" = "ページ" "Pages added to the document" = "ページをドキュメントに追加した" "Placed on the current page" = "現在のページに配置した" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 3e1916233..70669778d 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -892,6 +892,7 @@ status = "draft — needs native review" "PDF print settings" = "PDF 인쇄 설정" "Padding" = "패딩" "Page" = "페이지" +"Page deleted" = "페이지가 삭제됨" "Pages" = "페이지" "Pages added to the document" = "문서에 페이지 추가됨" "Placed on the current page" = "현재 페이지에 배치됨" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 8d8cac680..d49e5f037 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -952,6 +952,7 @@ status = "draft — needs native review" "PDF print settings" = "PDF-afdrukinstellingen" "Padding" = "Padding" "Page" = "Pagina" +"Page deleted" = "Pagina verwijderd" "Pages" = "Pagina's" "Pages added to the document" = "Pagina's toegevoegd aan het document" "Pages: {pages}" = "Pagina's: {pages}" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index d970db144..00ec38a96 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -891,6 +891,7 @@ status = "draft — needs native review" "PDF print settings" = "Configurações de impressão em PDF" "Padding" = "Espaçamento interno" "Page" = "Página" +"Page deleted" = "Página excluída" "Pages" = "Páginas" "Pages added to the document" = "Páginas adicionadas ao documento" "Placed on the current page" = "Colocado na página atual" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 8d371cbed..c72c3edbc 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -932,6 +932,7 @@ name = "Русский" "PDF print settings" = "Настройки печати PDF" "Padding" = "Внутренние отступы" "Page" = "Страница" +"Page deleted" = "Страница удалена" "Pages" = "Страницы" "Pages added to the document" = "Страницы добавлены в документ" "Placed on the current page" = "Размещено на текущей странице" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index d57bb25a5..7c3174142 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -942,6 +942,7 @@ name = "Svenska" "PDF print settings" = "PDF-utskriftsinställningar" "Padding" = "Utfyllnad" "Page" = "Sida" +"Page deleted" = "Sidan har raderats" "Pages" = "Sidor" "Pages added to the document" = "Sidor tillagda i dokumentet" "Placed on the current page" = "Placerat på den aktuella sidan" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index a9da62ef1..c9339e1fe 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -933,6 +933,7 @@ name = "Türkçe" "PDF print settings" = "PDF baskı ayarları" "Padding" = "İç boşluk" "Page" = "Sayfa" +"Page deleted" = "Sayfa silindi" "Pages" = "Sayfalar" "Pages added to the document" = "Sayfalar belgeye eklendi" "Placed on the current page" = "Geçerli sayfaya yerleştirildi" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index a90c1c033..e726ca325 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -893,6 +893,7 @@ status = "draft — needs native review" "PDF print settings" = "PDF 打印设置" "Padding" = "内边距" "Page" = "页面" +"Page deleted" = "页面已删除" "Pages" = "页面" "Pages added to the document" = "页面已添加到文档" "Placed on the current page" = "已放置在当前页面" diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9e0c1d797..94daf657f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -544,6 +544,7 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { // A press on the canvas gives the keyboard back to it (arrows nudge again). if resp.hovered() && ctx.input(|i| i.pointer.any_pressed()) { app.ui.layers_focus = false; + app.ui.pages_focus = false; app.ui.key_box = None; } // Zoom (pinch, Cmd/Ctrl+scroll) about the pointer; plain scroll pans. diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 32f66f80e..514b8f75b 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -199,6 +199,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } icons::paint(ui.painter(), ar.shrink(5.0), Icon::Plus, t.icon); if aresp.clicked() { + app.ui.pages_focus = true; app.run("page.add", json!({})); } if !app.ui.pages_open { @@ -257,6 +258,8 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } if resp.clicked() && !divider { select = Some(id); + app.ui.pages_focus = true; + app.ui.layers_focus = false; } if resp.double_clicked() { app.ui.rename_page = Some((id, name.clone())); @@ -274,7 +277,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { ui.close(); } if ui.button(tr("Delete page")).clicked() { - app.run("page.delete", json!({"id": id})); + delete_page(app, id); ui.close(); } }); @@ -311,6 +314,13 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { pages_edge(app, ui, moved.inner_rect.height().min(moved.content_size.y)); } +/// Delete `page` (no confirmation: Undo brings it back) and say so. +pub(crate) fn delete_page(app: &mut FigApp, page: NodeId) { + if app.run("page.delete", json!({"id": page})).is_some() { + app.toast(tr("Page deleted")); + } +} + /// The pages list's height in a panel with `avail` below the Pages header: the height asked for /// (by default 40% of it), at least three rows, and leaving the layers their header and three rows. pub(crate) fn pages_list_height(asked: Option, avail: f32) -> f32 { @@ -1064,6 +1074,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { } if let Some((id, m)) = clicked { app.ui.layers_focus = true; + app.ui.pages_focus = false; let order: Vec = rows.iter().map(|r| r.id).collect(); let pos = |x: NodeId| order.iter().position(|o| *o == x); let anchor = app.ui.layer_anchor.filter(|a| sel.contains(a)).and_then(pos); diff --git a/crates/ui-egui/src/layers_keys_tests.rs b/crates/ui-egui/src/layers_keys_tests.rs index b78890a89..d0b5f7860 100644 --- a/crates/ui-egui/src/layers_keys_tests.rs +++ b/crates/ui-egui/src/layers_keys_tests.rs @@ -1,8 +1,8 @@ //! Arrow keys in the layers panel once a row has been clicked: up/down through the rows (Shift //! extends), left/right to collapse, expand, and go to the parent or first child; a press on the -//! canvas gives arrows back to nudging. +//! canvas gives arrows back to nudging. Delete after a click in the Pages list deletes the page. -use egui::{Event, Key, Modifiers, pos2}; +use egui::{Event, Key, Modifiers, Pos2, pos2, vec2}; use figcraft_doc::NodeId; use figcraft_engine::Session; use serde_json::json; @@ -107,3 +107,80 @@ fn a_canvas_press_gives_arrows_back_to_nudging() { let doc = u.app.session.doc().unwrap(); assert_eq!(doc.node(c).unwrap().transform.translation().x, 201.0); } + +/// The middle of `page`'s row in the Pages list, found by clicking down the list until it opens; +/// then back to `back`. +fn page_row(u: &mut Ui, page: NodeId, back: NodeId) -> Pos2 { + for y in (90..400).step_by(4) { + let p = pos2(150.0, y as f32); + u.button(p, true); + u.button(p, false); + if u.app.session.page().unwrap() == page { + u.app.session.execute("page.select", &json!({"id": back})).unwrap(); + u.frame(vec![]); + // Rows are 32 high: aim at the middle, past the hit tolerance at its edge. + return p + vec2(0.0, 12.0); + } + } + panic!("no row for page {page:?}"); +} + +fn pages(u: &Ui) -> usize { + u.app.session.doc().unwrap().pages.len() +} + +#[test] +fn delete_after_clicking_a_page_deletes_it_without_asking() { + // #859: Delete on a page picked in the Pages list said "nothing is selected". + let (mut u, ..) = setup(); + let first = u.app.session.page().unwrap(); + let second = id(u.app.session.execute("page.add", &json!({})).unwrap()); + u.app.session.execute("page.select", &json!({"id": first})).unwrap(); + u.frame(vec![]); + let row = page_row(&mut u, second, first); + u.button(row, true); + u.button(row, false); + assert_eq!(u.app.session.page().unwrap(), second); + key(&mut u, Key::Delete, false); + // Gone at once, no confirmation: a toast says so and Undo brings it back. + assert_eq!(pages(&u), 1); + assert!(!u.app.session.doc().unwrap().pages.contains(&second)); + assert_eq!(u.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Page deleted")); + u.app.run("edit.undo", json!({})); + assert_eq!(pages(&u), 2); + // Backspace (the Mac's delete key) does the same. + let row = page_row(&mut u, second, first); + u.button(row, true); + u.button(row, false); + key(&mut u, Key::Backspace, false); + assert_eq!(pages(&u), 1); + // The last page stays. + key(&mut u, Key::Delete, false); + assert_eq!(pages(&u), 1); +} + +#[test] +fn selected_layers_or_a_canvas_press_take_delete_back_from_the_pages_list() { + let (mut u, a, ..) = setup(); + let first = u.app.session.page().unwrap(); + let second = id(u.app.session.execute("page.add", &json!({})).unwrap()); + u.frame(vec![]); + let row = page_row(&mut u, first, second); + u.button(row, true); + u.button(row, false); + assert_eq!(u.app.session.page().unwrap(), first); + assert!(u.app.ui.pages_focus); + // Layers selected after the click (Select all, say): Delete deletes them, not the page. + u.app.run("select.set", json!({"ids": [a]})); + key(&mut u, Key::Delete, false); + assert_eq!(pages(&u), 2); + assert!(u.app.session.doc().unwrap().node(a).is_none()); + // A press on the canvas gives Delete back to it. + let p = pos2(900.0, 700.0); + u.button(p, true); + u.button(p, false); + assert!(!u.app.ui.pages_focus); + u.app.run("select.none", json!({})); + key(&mut u, Key::Delete, false); + assert_eq!(pages(&u), 2, "Delete with nothing selected on the canvas deletes no page"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 450317b43..9a1cc3a83 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -500,6 +500,10 @@ pub struct UiState { /// The layers panel has the keyboard: arrow keys move through its rows instead of nudging /// (set by a click on a row, cleared by a press on the canvas). pub layers_focus: bool, + /// The pages list has the keyboard: Delete with no layers selected deletes the current page (set + /// by a click on a page row or the add-page button, cleared by a click on a layer row or a press + /// on the canvas). + pub pages_focus: bool, pub layers_seen_sel: Vec, pub dark: bool, pub follow_system: bool, @@ -634,6 +638,7 @@ impl Default for UiState { layer_filter: String::new(), layer_anchor: None, layers_focus: false, + pages_focus: false, layers_seen_sel: Vec::new(), dark: false, follow_system: false, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e32f50c88..dfdf290ae 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -117,6 +117,12 @@ pub fn invoke(app: &mut FigApp, id: &str) { // Edit ▸ Delete (and the Delete binding) with an interaction open in the Prototype tab // removes that interaction, not the layer (#767). "edit.delete" if app.ui_context.clone().is_some_and(|ctx| crate::proto_panel::delete_selected_interaction(app, &ctx)) => {} + // After a click in the Pages list, with no layers selected, it deletes the current page (#859). + "edit.delete" if app.ui.pages_focus && app.session.selection().is_empty() => { + if let Ok(page) = app.session.page() { + crate::layers::delete_page(app, page); + } + } "file.open" => app.open_dialog(), "plugin.import" => crate::plugins_ui::import_dialog(app), "file.import" => app.import_dialog(false), diff --git a/crates/ui-egui/src/selection_spacing.rs b/crates/ui-egui/src/selection_spacing.rs index 5110be1c2..c05b9ec3f 100644 --- a/crates/ui-egui/src/selection_spacing.rs +++ b/crates/ui-egui/src/selection_spacing.rs @@ -217,6 +217,7 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Option, hovered: let drag = Drag { ids, axis: axis.clone(), start: p, value: h.value, uid: ds.uid, page: ds.page, moved: false, last_value: axis.value }; app.ui.layers_focus = false; + app.ui.pages_focus = false; app.ui.key_box = None; ctx.memory_mut(|m| { if let Some(f) = m.focused() { From 139e0ba3084a964ca1e09e0c78c07b105dcb0709 Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Mon, 19 Oct 2026 10:53:34 +0200 Subject: [PATCH 050/114] chore(contributors): add AganzeFelicite to people.toml --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 8b14373eb..03b99aa1e 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -56,3 +56,6 @@ sync_github_name = true [people.pechnikov2026-star] sync_github_name = true + +[people.AganzeFelicite] +sync_github_name = true From d2dfe2ef20c4ba1f78ada75508ae03743cf857d7 Mon Sep 17 00:00:00 2001 From: YarilaFox <102650794+YarilaFox@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:21:52 +0500 Subject: [PATCH 051/114] M7: preserve active tab and support tab reordering --- crates/engine/src/cmd/file.rs | 42 ++++++++++++++++++++++++++--- crates/ui-egui/locales/cs.toml | 1 + crates/ui-egui/locales/de.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/locales/fi.toml | 1 + crates/ui-egui/locales/fr.toml | 1 + crates/ui-egui/locales/id.toml | 1 + crates/ui-egui/locales/ja.toml | 1 + crates/ui-egui/locales/ko.toml | 1 + crates/ui-egui/locales/nl.toml | 1 + crates/ui-egui/locales/pt-BR.toml | 1 + crates/ui-egui/locales/ru.toml | 1 + crates/ui-egui/locales/sv.toml | 1 + crates/ui-egui/locales/tr.toml | 1 + crates/ui-egui/locales/zh-Hans.toml | 1 + 15 files changed, 53 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index bc13bc701..8e59b3031 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -92,6 +92,20 @@ pub fn specs() -> Vec { ), spec!("file.placeImage", "Place image", "{path | data, x?, y?}", true, super::create::place_image), spec!("file.close", "Close tab", "{index?, force? (discard unsaved changes)}", false, close), + spec!("file.moveTab", "Move tab", "{from, to} (indices among open documents)", false, |s, p| { + let from = req_index("file.moveTab", p, "from")?; + let to = req_index("file.moveTab", p, "to")?; + if from >= s.docs.len() || to >= s.docs.len() { + return Err(bad("file.moveTab", "no such document")); + } + if from != to { + let active_uid = s.docs.get(s.active).map(|d| d.uid); + let doc = s.docs.remove(from); + s.docs.insert(to, doc); + s.active = active_uid.and_then(|uid| s.docs.iter().position(|d| d.uid == uid)).unwrap_or(0); + } + Ok(json!({"index": to})) + }), spec!( "file.recover", "Recover file", @@ -1374,14 +1388,36 @@ fn close(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Disabled("file.close".into(), "the document has unsaved changes (pass force: true to discard)".into())); } let uid = ds.uid; + let active_uid = s.docs.get(s.active).map(|d| d.uid); s.docs.remove(i); - if s.active >= s.docs.len() { - s.active = s.docs.len().saturating_sub(1); - } + // Removing a tab before the active one shifts its index; keep the same document selected. + s.active = active_uid.and_then(|a| s.docs.iter().position(|d| d.uid == a)).unwrap_or(i.min(s.docs.len().saturating_sub(1))); crate::branches::close_tabs(s, uid, None); ok() } +#[cfg(test)] +mod tab_order_tests { + use super::*; + + #[test] + fn moving_and_closing_inactive_tabs_preserves_the_active_document() { + let mut session = Session::new(); + for name in ["Two", "Three", "Four"] { + session.execute("file.new", &json!({"name": name})).unwrap(); + } + session.execute("doc.switch", &json!({"index": 2})).unwrap(); + let active_uid = session.docs[session.active].uid; + let moved_uid = session.docs[0].uid; + session.execute("file.moveTab", &json!({"from": 0, "to": 2})).unwrap(); + assert_eq!(session.docs[2].uid, moved_uid); + assert_eq!(session.docs[session.active].uid, active_uid); + session.execute("file.close", &json!({"index": 0})).unwrap(); + assert_eq!(session.docs[session.active].uid, active_uid); + assert!(session.execute("file.moveTab", &json!({"from": 0, "to": 99})).is_err()); + } +} + /// Ctrl+Tab / Ctrl+Shift+Tab: the next or previous open document, wrapping around. fn cycle_doc(s: &mut Session, dir: i64) -> Result { let n = s.docs.len() as i64; diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 980513149..b092c6051 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -1849,6 +1849,7 @@ status = "draft — needs native review" "file.exportLayers" = "Exportovat vrstvy" "file.exportSvg" = "Exportovat výběr jako SVG" "file.import" = "Importovat" +"file.moveTab" = "Přesunout kartu" "file.new" = "Nový soubor návrhu" "file.newFromTemplate" = "Nový ze šablony" "file.open" = "Otevřít" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 07669c1a7..e0f72435f 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -1795,6 +1795,7 @@ status = "draft — needs native review" "file.exportLayers" = "Ebenen exportieren" "file.exportSvg" = "Auswahl als SVG exportieren" "file.import" = "Importieren" +"file.moveTab" = "Tab verschieben" "file.new" = "Neue Designdatei" "file.open" = "Öffnen" "file.openRecent" = "Zuletzt verwendete öffnen" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index d054e2223..970118ef2 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1890,6 +1890,7 @@ status = "draft — needs native review" "file.exportLayers" = "Exportar capas" "file.exportSvg" = "Exportar selección como SVG" "file.import" = "Importar" +"file.moveTab" = "Mover pestaña" "file.new" = "Nuevo archivo de diseño" "file.newFromTemplate" = "Nuevo desde plantilla" "file.open" = "Abrir" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 602574d79..2e944086c 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -1891,6 +1891,7 @@ status = "draft — needs native review" "file.exportLayers" = "Vie tasot" "file.exportSvg" = "Vie valinta SVG-muodossa" "file.import" = "Tuo" +"file.moveTab" = "Siirrä välilehti" "file.new" = "Uusi suunnittelutiedosto" "file.newFromTemplate" = "Uusi mallista" "file.open" = "Avaa" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 5edf1a624..a052fdaf9 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -1879,6 +1879,7 @@ status = "draft — needs native review" "file.exportLayers" = "Exporter les calques" "file.exportSvg" = "Exporter la sélection en SVG" "file.import" = "Importer" +"file.moveTab" = "Déplacer l’onglet" "file.new" = "Nouveau fichier de design" "file.newFromTemplate" = "Nouveau à partir d’un modèle" "file.open" = "Ouvrir" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 65a9939cb..ca8a9561d 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -1853,6 +1853,7 @@ status = "draft — needs native review" "file.exportLayers" = "Ekspor layer" "file.exportSvg" = "Ekspor pilihan sebagai SVG" "file.import" = "Impor" +"file.moveTab" = "Pindahkan tab" "file.new" = "File desain baru" "file.newFromTemplate" = "Baru dari templat" "file.open" = "Buka" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index def8ebc47..218fbca6c 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -1864,6 +1864,7 @@ status = "draft — needs native review" "file.exportLayers" = "レイヤーを書き出し" "file.exportSvg" = "選択範囲を SVG で書き出し" "file.import" = "インポート" +"file.moveTab" = "タブを移動" "file.new" = "新規デザインファイル" "file.newFromTemplate" = "テンプレートから新規作成" "file.open" = "開く" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 3e1916233..b9931717a 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -1786,6 +1786,7 @@ status = "draft — needs native review" "file.exportLayers" = "레이어 내보내기" "file.exportSvg" = "선택 항목을 SVG로 내보내기" "file.import" = "가져오기" +"file.moveTab" = "탭 이동" "file.new" = "새 디자인 파일" "file.open" = "열기" "file.openRecent" = "최근 파일 열기" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 8d8cac680..d007002ca 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -1890,6 +1890,7 @@ status = "draft — needs native review" "file.exportLayers" = "Lagen exporteren" "file.exportSvg" = "Selectie exporteren als SVG" "file.import" = "Importeren" +"file.moveTab" = "Tabblad verplaatsen" "file.new" = "Nieuw ontwerpbestand" "file.newFromTemplate" = "Nieuw vanuit sjabloon" "file.open" = "Openen" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index d970db144..80529d015 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -1785,6 +1785,7 @@ status = "draft — needs native review" "file.exportLayers" = "Exportar camadas" "file.exportSvg" = "Exportar seleção como SVG" "file.import" = "Importar" +"file.moveTab" = "Mover aba" "file.new" = "Novo arquivo de design" "file.open" = "Abrir" "file.openRecent" = "Abrir recente" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 8d371cbed..b6c08e952 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -1855,6 +1855,7 @@ name = "Русский" "file.exportLayers" = "Экспортировать слои" "file.exportSvg" = "Экспортировать выделение в SVG" "file.import" = "Импорт" +"file.moveTab" = "Переместить вкладку" "file.new" = "Новый файл дизайна" "file.newFromTemplate" = "Создать из шаблона" "file.open" = "Открыть" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index d57bb25a5..babc35b1e 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -1872,6 +1872,7 @@ name = "Svenska" "file.exportLayers" = "Exportera lager" "file.exportSvg" = "Exportera som SVG" "file.import" = "Importera" +"file.moveTab" = "Flytta flik" "file.new" = "Nytt" "file.newFromTemplate" = "Nytt från mall" "file.open" = "Öppna" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index a9da62ef1..7a604aa0f 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -1853,6 +1853,7 @@ name = "Türkçe" "file.exportLayers" = "Katmanları dışa aktar" "file.exportSvg" = "Seçimi SVG olarak dışa aktar" "file.import" = "İçe aktar" +"file.moveTab" = "Sekmeyi taşı" "file.new" = "Yeni tasarım dosyası" "file.newFromTemplate" = "Şablondan yeni" "file.open" = "Aç" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index a90c1c033..8752fbbfa 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -1787,6 +1787,7 @@ status = "draft — needs native review" "file.exportLayers" = "导出图层" "file.exportSvg" = "将所选项导出为 SVG" "file.import" = "导入" +"file.moveTab" = "移动标签页" "file.new" = "新建设计文件" "file.open" = "打开" "file.openRecent" = "打开最近的文件" From 2af54a26fb20e5064b25513a0ec565991c58ea96 Mon Sep 17 00:00:00 2001 From: YarilaFox <102650794+YarilaFox@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:23:04 +0500 Subject: [PATCH 052/114] M7: keep tab strip usable with many files --- crates/ui-egui/src/tabs.rs | 386 +++++++++++++++++++++++---- crates/ui-egui/src/titlebar_tests.rs | 75 ++++++ 2 files changed, 408 insertions(+), 53 deletions(-) diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index 381b09dd1..047d5faa6 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -2,6 +2,8 @@ //! the title bar: its empty space drags the window (double-click maximises), and it carries the //! window buttons; the window's edges resize it. +use std::collections::{HashMap, HashSet}; + use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2}; use serde_json::json; @@ -12,22 +14,151 @@ use crate::theme::{Tokens, regular}; use crate::{FigApp, TitleBar}; pub const HEIGHT: f32 = 40.0; +const MIN_TAB_WIDTH: f32 = 96.0; +const OVERFLOW_WIDTH: f32 = 32.0; +const OPEN_TIME: f64 = 0.18; +const CLOSE_TIME: f64 = 0.24; + +#[derive(Clone)] +struct TabSnapshot { + name: String, + rect: Rect, + active: bool, +} + +#[derive(Clone)] +struct ClosingTab { + snapshot: TabSnapshot, + started: f64, +} + +#[derive(Clone, Default)] +struct TabMotion { + initialized: bool, + known: HashSet, + opening: HashMap, + last_visible: HashMap, + closing: Vec, +} + +impl TabMotion { + /// Track tabs added or removed since the previous frame and expire their animations. + fn begin_frame(&mut self, current: HashSet, now: f64, reduce_motion: bool) { + if self.initialized && !reduce_motion { + for uid in current.difference(&self.known) { + self.opening.insert(*uid, now); + } + for uid in self.known.difference(¤t) { + if let Some(snapshot) = self.last_visible.get(uid) { + self.closing.push(ClosingTab { snapshot: snapshot.clone(), started: now }); + } + } + } + self.initialized = true; + self.known = current; + self.last_visible.clear(); + if reduce_motion { + self.opening.clear(); + self.closing.clear(); + } else { + self.opening.retain(|uid, started| self.known.contains(uid) && now - *started < OPEN_TIME); + self.closing.retain(|tab| now - tab.started < CLOSE_TIME); + } + } + + /// Return the entering tab's progress, or full width when it is not animating. + fn open_progress(&self, uid: u64, now: f64) -> f32 { + self.opening.get(&uid).map_or(1.0, |started| ((now - started) / OPEN_TIME).clamp(0.0, 1.0) as f32) + } + + /// Keep requesting frames while an opening or closing tab remains visible. + fn is_animating(&self) -> bool { + !self.opening.is_empty() || !self.closing.is_empty() + } +} + +/// Paint removed tabs as fading snapshots below the strip until their exit motion ends. +fn paint_closing_tabs(ui: &Ui, t: &Tokens, motion: &TabMotion, now: f64) { + if motion.closing.is_empty() { + return; + } + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, ui.id().with("closing_tabs"))); + for tab in &motion.closing { + let progress = ((now - tab.started) / CLOSE_TIME).clamp(0.0, 1.0) as f32; + let opacity = (1.0 - progress).powi(2); + let r = tab.snapshot.rect.translate(vec2(0.0, 40.0 * progress * progress)); + painter.rect_filled(r, CornerRadius::same(4), if tab.snapshot.active { t.tab_active } else { t.hover }.linear_multiply(opacity)); + icons::paint( + &painter, + Rect::from_center_size(pos2(r.min.x + 18.0, r.center().y), vec2(14.0, 14.0)), + Icon::Logo, + crate::theme::ACCENT.linear_multiply(opacity), + ); + crate::bidi::paint_name( + &painter, + pos2(r.min.x + 32.0, r.center().y), + &tab.snapshot.name, + regular(12.0), + t.text.linear_multiply(opacity), + (r.width() - 64.0).max(0.0), + ); + icons::paint( + &painter, + Rect::from_center_size(pos2(r.max.x - 16.0, r.center().y), vec2(10.0, 10.0)), + Icon::Close, + t.icon_secondary.linear_multiply(opacity), + ); + } +} + +struct TabLayout { + first: usize, + widths: Vec, + overflow: bool, +} + +/// Shrink open tabs before moving the excess into the overflow menu. +fn tab_layout(preferred: &[f32], available: f32, active: Option) -> TabLayout { + let count = preferred.len(); + let overflow = count as f32 * MIN_TAB_WIDTH > available; + let visible = if overflow { ((available - OVERFLOW_WIDTH).max(0.0) / MIN_TAB_WIDTH).floor() as usize } else { count }.min(count); + let first = active.unwrap_or(0).saturating_sub(visible / 2).min(count - visible); + let mut widths = preferred[first..first + visible].to_vec(); + let budget = (available - if overflow { OVERFLOW_WIDTH } else { 0.0 }).max(0.0); + let mut excess = (widths.iter().sum::() - budget).max(0.0); + while excess > 0.01 { + let flexible = widths.iter().filter(|w| **w > MIN_TAB_WIDTH + 0.01).count(); + if flexible == 0 { + break; + } + let share = excess / flexible as f32; + let mut removed = 0.0; + for width in &mut widths { + let shrink = (*width - MIN_TAB_WIDTH).max(0.0).min(share); + *width -= shrink; + removed += shrink; + } + excess -= removed; + } + TabLayout { first, widths, overflow } +} + +/// Final document index and insertion marker for a tab dropped among the visible tabs. +fn tab_drop_position(visible: &[(usize, Rect)], from: usize, pointer_x: f32) -> Option<(usize, f32)> { + let others: Vec<_> = visible.iter().copied().filter(|(index, _)| *index != from).collect(); + let slot = others.partition_point(|(_, rect)| rect.center().x < pointer_x); + if let Some(&(index, rect)) = others.get(slot) { + Some((index - usize::from(from < index), rect.min.x)) + } else { + let &(index, rect) = others.last()?; + Some((index + usize::from(from > index), rect.max.x)) + } +} pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); egui::Panel::top("tab_bar").exact_size(HEIGHT).frame(egui::Frame::NONE.fill(t.tab_bar)).show(ui, |ui| { let rect = ui.max_rect(); - // Empty space moves the window (first, so the tabs and buttons on top take their clicks). - if app.ui.title_bar != TitleBar::Native { - let drag = ui.interact(rect, ui.id().with("titlebar_drag"), Sense::click_and_drag()); - if drag.drag_started() { - ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); - } - if drag.double_clicked() { - let max = ui.input(|i| i.viewport().maximized.unwrap_or(false)); - ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max)); - } - } let buttons_w = if app.ui.title_bar == TitleBar::Custom { window_buttons(app, ui, rect) } else { 0.0 }; let mut x = rect.min.x + if app.ui.title_bar == TitleBar::Inset { 76.0 } else { 8.0 }; // Home. @@ -49,28 +180,65 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { } x += 44.0; ui.painter().line_segment([pos2(x, rect.min.y), pos2(x, rect.max.y)], Stroke::new(1.0, t.border)); + let document_tabs: Vec = app.session.docs.iter().enumerate().filter_map(|(i, d)| app.shows(d.uid).then_some(i)).collect(); + let now = ui.input(|input| input.time); + let motion_id = ui.id().with("tab_motion"); + let mut motion = ui.ctx().data_mut(|data| data.get_temp::(motion_id).unwrap_or_default()); + let current_uids = document_tabs.iter().filter_map(|&i| app.session.docs.get(i).map(|d| d.uid)).collect(); + motion.begin_frame(current_uids, now, app.reduce_motion()); + let preferred: Vec = document_tabs + .iter() + .map(|&i| { + let d = &app.session.docs[i]; + let mark_w = if live_mark(app, d.uid).is_some() { 18.0 } else { 0.0 }; + let dot_w = if d.is_dirty() && mark_w > 0.0 { 12.0 } else { 0.0 }; + (regular(12.0).size * 0.6 * d.doc.name.chars().count() as f32 + 64.0 + mark_w + dot_w).clamp(120.0, 220.0) + }) + .collect(); + // Leave the new-tab button, theme toggle, window controls and a draggable gap free. + let present_w = if app.ui.present.is_some() || app.ui.paused_present.is_some() { 128.0 } else { 0.0 }; + let available = (rect.max.x - buttons_w - 70.0 - x - 34.0 - present_w).max(0.0); + let active_tab = document_tabs.iter().position(|&i| i == app.session.active); + let layout = tab_layout(&preferred, available, active_tab); + let mut tab_rects = Vec::with_capacity(layout.widths.len()); + let mut tab_x = x; + for &width in &layout.widths { + tab_rects.push(Rect::from_min_size(pos2(tab_x, rect.min.y), vec2(width, HEIGHT))); + tab_x += width; + } let mut switch = None; let mut close = None; // A tab dragged off the strip, or "Open in new window", moves to a window of its own. let mut detach = None; + let mut dragging = None; + let mut dropped = None; let several = !ui.ctx().embed_viewports(); - for (i, d) in app.session.docs.iter().enumerate().filter(|(_, d)| app.shows(d.uid)) { + for (slot, &w) in layout.widths.iter().enumerate() { + let i = document_tabs[layout.first + slot]; + let d = &app.session.docs[i]; let active = i == app.session.active && !app.ui.home && app.ui.present.is_none(); let name = d.doc.name.clone(); // In a live session: a mark after the name says whether others get in. let live = live_mark(app, d.uid); let mark_w = if live.is_some() { 18.0 } else { 0.0 }; - // (A file in a session is rarely saved here: its unsaved dot gets room beside the mark.) let dirty = d.is_dirty(); - let dot_w = if dirty && live.is_some() { 12.0 } else { 0.0 }; - let w = (regular(12.0).size * 0.6 * name.chars().count() as f32 + 64.0 + mark_w + dot_w).clamp(120.0, 220.0); - let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(w, HEIGHT)); - let resp = ui.interact(r, ui.id().with(("tab", d.uid)), if several { Sense::click_and_drag() } else { Sense::click() }); - if resp.drag_stopped() + let r = tab_rects[slot]; + let body_r = Rect::from_min_max(r.min, pos2(r.max.x - 32.0, r.max.y)); + let close_r = Rect::from_min_max(body_r.right_top(), r.max); + let resp = ui.interact(body_r, ui.id().with(("tab", d.uid)), Sense::click_and_drag()); + let cresp = ui.interact(close_r, ui.id().with(("tabclose", d.uid)), Sense::click()).tip(tr("Close tab")); + if (resp.dragged() || resp.drag_stopped()) && let Some(p) = ui.input(|inp| inp.pointer.latest_pos()) - && (p.y - rect.center().y).abs() > HEIGHT * 2.0 { - detach = Some((i, Some(p))); + if resp.drag_stopped() { + if several && (p.y - rect.center().y).abs() > HEIGHT * 2.0 { + detach = Some((i, Some(p))); + } else if (rect.min.y - 12.0..=rect.max.y + 12.0).contains(&p.y) { + dropped = Some((i, p.x)); + } + } else { + dragging = Some((i, p)); + } } if several { resp.context_menu(|ui| { @@ -88,43 +256,47 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { }; let resp = if live.is_some() { resp.on_hover_text(&label) } else { resp }; crate::a11y::tab(&resp, &label, active); + let progress = motion.open_progress(d.uid, now); + let paint_r = r.translate(vec2(-28.0 * (1.0 - progress).powi(2), 0.0)); + let painter = ui.painter().with_clip_rect(r); if active { - ui.painter().rect_filled(r, CornerRadius::ZERO, t.tab_active); - } else if resp.hovered() { - ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover); + painter.rect_filled(paint_r, CornerRadius::ZERO, t.tab_active.linear_multiply(progress)); + } else if resp.hovered() || cresp.hovered() { + painter.rect_filled(paint_r, CornerRadius::ZERO, t.hover.linear_multiply(progress)); } icons::paint( - ui.painter(), - Rect::from_center_size(pos2(r.min.x + 18.0, r.center().y), vec2(14.0, 14.0)), + &painter, + Rect::from_center_size(pos2(paint_r.min.x + 18.0, paint_r.center().y), vec2(14.0, 14.0)), Icon::Logo, - crate::theme::ACCENT, + crate::theme::ACCENT.linear_multiply(progress), ); // The unsaved-changes dot follows the name on screen, whichever way the name reads. let shown = crate::bidi::paint_name( - ui.painter(), - pos2(r.min.x + 32.0, r.center().y), + &painter, + pos2(paint_r.min.x + 32.0, paint_r.center().y), &name, regular(12.0), - t.text, - w - mark_w - if dirty { 72.0 } else { 60.0 }, + t.text.linear_multiply(progress), + (w - mark_w - if dirty { 72.0 } else { 60.0 }).max(0.0), ); if let Some(shared) = live { - let at = Rect::from_center_size(pos2(r.max.x - 38.0, r.center().y), vec2(14.0, 14.0)); - icons::paint(ui.painter(), at, crate::online::mark(shared), if shared { t.success } else { t.icon_secondary }); + let at = Rect::from_center_size(pos2(paint_r.max.x - 38.0, paint_r.center().y), vec2(14.0, 14.0)); + icons::paint(&painter, at, crate::online::mark(shared), if shared { t.success } else { t.icon_secondary }.linear_multiply(progress)); } if dirty { - ui.painter().text(pos2(shown.max.x + 4.0, r.center().y), Align2::LEFT_CENTER, "•", regular(12.0), t.text); + painter.text(pos2(shown.max.x + 4.0, paint_r.center().y), Align2::LEFT_CENTER, "•", regular(12.0), t.text.linear_multiply(progress)); } - let cr = Rect::from_center_size(pos2(r.max.x - 16.0, r.center().y), vec2(18.0, 18.0)); - if resp.hovered() || active { - let cresp = ui.interact(cr, ui.id().with(("tabclose", d.uid)), Sense::click()).tip(tr("Close tab")); + let cr = Rect::from_center_size(close_r.center() + vec2(paint_r.min.x - r.min.x, 0.0), vec2(18.0, 18.0)); + // The close target never overlaps the draggable body, including on inactive tabs. + if resp.hovered() || cresp.hovered() || active { if cresp.hovered() { - ui.painter().rect_filled(cr, CornerRadius::same(4), t.pressed); - } - icons::paint(ui.painter(), cr.shrink(4.0), Icon::Close, t.icon_secondary); - if cresp.clicked() { - close = Some(i); + painter.rect_filled(cr, CornerRadius::same(4), t.pressed.linear_multiply(progress)); } + icons::paint(&painter, cr.shrink(4.0), Icon::Close, t.icon_secondary.linear_multiply(progress)); + } + motion.last_visible.insert(d.uid, TabSnapshot { name, rect: r, active }); + if cresp.clicked() { + close = Some(i); } if resp.clicked() && close.is_none() { switch = Some(i); @@ -135,35 +307,68 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { x += w; ui.painter().line_segment([pos2(x, rect.min.y + 10.0), pos2(x, rect.max.y - 10.0)], Stroke::new(1.0, t.border)); } + let visible: Vec<(usize, Rect)> = tab_rects.iter().copied().enumerate().map(|(slot, r)| (document_tabs[layout.first + slot], r)).collect(); + if let Some((from, p)) = dragging + && (rect.min.y - 12.0..=rect.max.y + 12.0).contains(&p.y) + && let Some((_, at)) = tab_drop_position(&visible, from, p.x) + { + ui.painter().line_segment([pos2(at, rect.min.y + 5.0), pos2(at, rect.max.y - 5.0)], Stroke::new(3.0, crate::theme::ACCENT)); + } + let reorder = dropped.and_then(|(from, at)| tab_drop_position(&visible, from, at).map(|(to, _)| (from, to))); + if layout.overflow { + let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(OVERFLOW_WIDTH, HEIGHT)); + let resp = ui.interact(r, ui.id().with("tab_overflow"), Sense::click()).tip(tr("Open files")); + crate::a11y::menu_button(&resp, tr("Open files"), crate::a11y::menu_open(&resp)); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover); + } + icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), Icon::Chevron, t.icon); + egui::Popup::menu(&resp).style(crate::right::menu_style).show(|ui| { + ui.set_min_width(180.0); + egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| { + for (slot, &i) in document_tabs.iter().enumerate() { + if (layout.first..layout.first + layout.widths.len()).contains(&slot) { + continue; + } + let d = &app.session.docs[i]; + if ui.button(&d.doc.name).clicked() { + switch = Some(i); + ui.close(); + } + } + }); + }); + x += OVERFLOW_WIDTH; + } // Presentations live in their own tab. Switching back to the design keeps the player // state (current screen, navigation history and scroll position) for the return trip. if app.ui.present.is_some() || app.ui.paused_present.is_some() { let w = 128.0; let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(w, HEIGHT)); - let resp = ui.interact(r, ui.id().with("presentation_tab"), Sense::click()).tip(tr("Present")); + let body_r = Rect::from_min_max(r.min, pos2(r.max.x - 32.0, r.max.y)); + let close_r = Rect::from_min_max(body_r.right_top(), r.max); + let resp = ui.interact(body_r, ui.id().with("presentation_tab"), Sense::click()).tip(tr("Present")); + let close_resp = ui.interact(close_r, ui.id().with("presentation_close"), Sense::click()).tip(tr("Close preview")); let active = app.ui.present.is_some(); crate::a11y::tab(&resp, tr("Present"), active); if active { ui.painter().rect_filled(r, CornerRadius::ZERO, t.tab_active); - } else if resp.hovered() { + } else if resp.hovered() || close_resp.hovered() { ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover); } icons::paint(ui.painter(), Rect::from_center_size(pos2(x + 19.0, r.center().y), vec2(14.0, 14.0)), Icon::Play, t.icon); ui.painter().text(pos2(x + 34.0, r.center().y), Align2::LEFT_CENTER, tr("Present"), regular(12.0), t.text); - let cr = Rect::from_center_size(pos2(r.max.x - 16.0, r.center().y), vec2(18.0, 18.0)); - if resp.hovered() || active { - let close_resp = ui.interact(cr, ui.id().with("presentation_close"), Sense::click()).tip(tr("Close preview")); + let cr = Rect::from_center_size(close_r.center(), vec2(18.0, 18.0)); + if resp.hovered() || close_resp.hovered() || active { if close_resp.hovered() { ui.painter().rect_filled(cr, CornerRadius::same(4), t.pressed); } icons::paint(ui.painter(), cr.shrink(4.0), Icon::Close, t.icon_secondary); - if close_resp.clicked() { - app.ui.present = None; - app.ui.paused_present = None; - app.ui.present_uid = None; - } else if resp.clicked() && !active { - resume_presentation(app); - } + } + if close_resp.clicked() { + app.ui.present = None; + app.ui.paused_present = None; + app.ui.present_uid = None; } else if resp.clicked() && !active { resume_presentation(app); } @@ -195,6 +400,18 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { let cmd = if app.ui.dark { "view.themeLight" } else { "view.themeDark" }; app.run(cmd, json!({})); } + // Only the free space drags the window. A full-bar drag target steals tab drags. + if app.ui.title_bar != TitleBar::Native && x + 34.0 < theme_r.min.x { + let free = Rect::from_min_max(pos2(x + 34.0, rect.min.y), pos2(theme_r.min.x, rect.max.y)); + let drag = ui.interact(free, ui.id().with("titlebar_drag"), Sense::click_and_drag()); + if drag.drag_started() { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); + } + if drag.double_clicked() { + let max = ui.input(|i| i.viewport().maximized.unwrap_or(false)); + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max)); + } + } if let Some(i) = switch { if let Some(p) = app.ui.present.take() { app.ui.paused_present = Some(p); @@ -209,6 +426,11 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { app.toast(e); } } + if let Some((from, to)) = reorder + && from != to + { + app.run("file.moveTab", json!({"from": from, "to": to})); + } if let Some(i) = close { // A branch tab's edits live on in its main file: closing it loses nothing. let dirty = app.session.docs.get(i).is_some_and(|d| d.is_dirty() && d.branch_of.is_none()); @@ -230,6 +452,11 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { app.ui.present_uid = None; } } + paint_closing_tabs(ui, &t, &motion, now); + if motion.is_animating() { + ui.ctx().request_repaint(); + } + ui.ctx().data_mut(|data| data.insert_temp(motion_id, motion)); let _ = Color32::TRANSPARENT; let _ = Align2::LEFT_TOP; }); @@ -325,3 +552,56 @@ pub fn resize_edges(ctx: &egui::Context) { } }); } + +#[cfg(test)] +mod layout_tests { + use super::{MIN_TAB_WIDTH, OVERFLOW_WIDTH, TabMotion, TabSnapshot, tab_drop_position, tab_layout}; + use egui::{Rect, pos2}; + use std::collections::HashSet; + + #[test] + fn tabs_shrink_to_fit_before_overflowing() { + let preferred = vec![120.0; 10]; + let layout = tab_layout(&preferred, 1100.0, Some(5)); + assert!(!layout.overflow); + assert_eq!(layout.first, 0); + assert_eq!(layout.widths.len(), preferred.len()); + assert!(layout.widths.iter().all(|&width| (width - 110.0).abs() < 0.01)); + } + + #[test] + fn excess_tabs_leave_room_for_overflow_and_keep_the_active_tab_visible() { + let preferred = vec![120.0; 24]; + let available = 1100.0; + let layout = tab_layout(&preferred, available, Some(23)); + assert!(layout.overflow); + assert!(layout.first <= 23 && 23 < layout.first + layout.widths.len()); + assert!(layout.widths.iter().all(|&width| width >= MIN_TAB_WIDTH)); + assert!(layout.widths.iter().sum::() + OVERFLOW_WIDTH <= available + 0.01); + } + + #[test] + fn drop_position_places_a_tab_between_its_neighbors() { + let visible = (0..4) + .map(|index| (index, Rect::from_min_max(pos2(index as f32 * 120.0, 0.0), pos2((index + 1) as f32 * 120.0, 40.0)))) + .collect::>(); + assert_eq!(tab_drop_position(&visible, 0, 330.0).map(|(to, _)| to), Some(2)); + assert_eq!(tab_drop_position(&visible, 3, 30.0).map(|(to, _)| to), Some(0)); + } + + #[test] + fn new_tabs_animate_and_closed_tabs_fall_unless_motion_is_reduced() { + let mut motion = TabMotion::default(); + motion.begin_frame(HashSet::from([1]), 0.0, false); + assert!(!motion.is_animating()); + let snapshot = TabSnapshot { name: "First".into(), rect: Rect::from_min_max(pos2(0.0, 0.0), pos2(120.0, 40.0)), active: false }; + motion.last_visible.insert(1, snapshot.clone()); + motion.begin_frame(HashSet::from([1, 2]), 1.0, false); + assert_eq!(motion.open_progress(2, 1.0), 0.0); + motion.last_visible.insert(1, snapshot); + motion.begin_frame(HashSet::from([2]), 1.05, false); + assert_eq!(motion.closing.len(), 1); + motion.begin_frame(HashSet::from([2]), 1.06, true); + assert!(!motion.is_animating()); + } +} diff --git a/crates/ui-egui/src/titlebar_tests.rs b/crates/ui-egui/src/titlebar_tests.rs index e9224404d..9f1916c17 100644 --- a/crates/ui-egui/src/titlebar_tests.rs +++ b/crates/ui-egui/src/titlebar_tests.rs @@ -158,6 +158,81 @@ fn tabs_and_new_tab_keep_their_clicks() { assert!(opened, "the new-tab button took no click"); } +#[test] +fn many_tabs_do_not_cover_the_window_buttons() { + let mut u = Ui::new(TitleBar::Custom); + for _ in 0..24 { + u.app.session.execute("file.new", &json!({})).unwrap(); + } + u.frame(vec![]); + let before = u.app.session.docs.len(); + u.click(pos2(SIZE.x - 222.0, 20.0)); + assert_eq!(u.app.session.docs.len(), before + 1, "the new-tab button must stay reachable"); + assert!(u.click(window_button(0)).contains(&ViewportCommand::Minimized(true))); + assert!(u.click(window_button(1)).contains(&ViewportCommand::Maximized(true))); + u.click(window_button(2)); + assert!(u.frame(vec![]).contains(&ViewportCommand::Close)); +} + +#[test] +fn close_button_on_inactive_tab_closes_it_without_switching() { + let mut u = Ui::new(TitleBar::Custom); + u.app.session.execute("file.new", &json!({})).unwrap(); + u.app.session.execute("file.new", &json!({})).unwrap(); + let active_uid = u.app.session.docs[u.app.session.active].uid; + let inactive_uid = u.app.session.docs[1].uid; + let close_button = pos2(270.0, 20.0); + u.frame(vec![Event::PointerMoved(close_button)]); + u.click(close_button); + assert!( + !u.app.session.docs.iter().any(|doc| doc.uid == inactive_uid), + "docs={}, active={}, last_error={:?}, target_dirty={}", + u.app.session.docs.len(), + u.app.session.active, + u.app.ui.last_error, + u.app.session.docs.iter().find(|doc| doc.uid == inactive_uid).is_some_and(|doc| doc.is_dirty()) + ); + assert_eq!(u.app.session.docs[u.app.session.active].uid, active_uid); +} + +#[test] +fn closing_a_tab_before_the_active_one_keeps_the_active_file() { + let mut u = Ui::new(TitleBar::Custom); + for _ in 0..3 { + u.app.session.execute("file.new", &json!({})).unwrap(); + } + u.app.session.execute("doc.switch", &json!({"index": 2})).unwrap(); + let active_uid = u.app.session.docs[u.app.session.active].uid; + let closed_uid = u.app.session.docs[0].uid; + let close_button = pos2(156.0, 20.0); + u.frame(vec![Event::PointerMoved(close_button)]); + u.click(close_button); + assert!(!u.app.session.docs.iter().any(|doc| doc.uid == closed_uid)); + assert_eq!(u.app.session.docs[u.app.session.active].uid, active_uid); +} + +#[test] +fn dragging_a_tab_along_the_bar_reorders_it_without_detaching() { + let mut u = Ui::new(TitleBar::Custom); + for name in ["Two", "Three", "Four"] { + u.app.session.execute("file.new", &json!({"name": name})).unwrap(); + } + let moved_uid = u.app.session.docs[0].uid; + let active_uid = u.app.session.docs[u.app.session.active].uid; + u.frame(vec![]); + let commands = u.drag(pos2(100.0, 20.0), vec2(310.0, 0.0)); + assert!(!commands.contains(&ViewportCommand::StartDrag), "{commands:?}"); + assert_eq!( + u.app.session.docs[2].uid, + moved_uid, + "order={:?}, active={}, error={:?}", + u.app.session.docs.iter().map(|d| d.uid).collect::>(), + u.app.session.active, + u.app.ui.last_error + ); + assert_eq!(u.app.session.docs[u.app.session.active].uid, active_uid); +} + #[test] fn a_system_title_bar_leaves_the_window_alone() { let mut u = Ui::new(TitleBar::Native); From fd02772d224f2e406c64b7df49be36d13d000d91 Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Mon, 19 Oct 2026 11:34:21 +0200 Subject: [PATCH 053/114] refactor(text): one helper for the chars that end a line --- crates/text/src/layout.rs | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 7fc6e11ad..6266dc2b1 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -157,6 +157,11 @@ fn deco_len(m: DecorationLength, size: f64, auto: f64) -> f64 { if v.is_finite() { v } else { auto } } +/// A char that ends its line: a newline (which also ends the paragraph). +fn is_line_break(c: char) -> bool { + c == '\n' +} + /// Opening punctuation, which hangs outside a line's start. fn opens(c: char) -> bool { matches!(c, '"' | '\'' | '\u{201C}' | '\u{2018}' | '\u{201E}' | '\u{201A}' | '\u{00AB}' | '\u{2039}' | '(' | '[' | '{' | '\u{00BF}' | '\u{00A1}') @@ -332,7 +337,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) } // Colour emoji are their own, large faces (Apple Color Emoji is ~180 MB): they are read only // for a text with emoji, or with characters nothing above covers. - let covered = |c: char| c == '\n' || faces.iter().flatten().any(|f| f.has_char(c)) || fallbacks.iter().any(|f| f.has_char(c)); + let covered = |c: char| is_line_break(c) || faces.iter().flatten().any(|f| f.has_char(c)) || fallbacks.iter().any(|f| f.has_char(c)); let emoji = if chars.iter().any(|c| is_emoji(*c) || !covered(*c)) { dbw.emoji_fallbacks() } else { Vec::new() }; fallbacks.extend(emoji.iter().cloned()); drop(dbw); @@ -370,7 +375,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) { return f.clone(); } - if c == '\n' || primary.has_char(c) { + if is_line_break(c) || primary.has_char(c) { return primary.clone(); } let fallbacks = style_fallbacks.get(st_idx).unwrap_or(&fallbacks); @@ -382,13 +387,15 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) let mut seg_end = seg_start + 1; while seg_end < *end && chars.get(seg_end).is_some_and(|c| { - *c != '\n' && first != '\n' && (!cluster_start.get(seg_end).copied().unwrap_or(true) || face_for(*c).id == face.id) + !is_line_break(*c) + && !is_line_break(first) + && (!cluster_start.get(seg_end).copied().unwrap_or(true) || face_for(*c).id == face.id) }) && level_at(seg_end) == level { seg_end += 1; } - if first == '\n' { + if is_line_break(first) { items.push(Item { face: face.clone(), coords: no_coords(), @@ -401,7 +408,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) char_start: seg_start, char_end: seg_start + 1, style: st_idx, - ch: '\n', + ch: first, level, }); } else { @@ -546,7 +553,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) let baseline = y + (lh - (asc + desc)) / 2.0 + asc; // Trailing spaces don't count for width/alignment. let mut vis_end = *e; - while vis_end > *s && items.get(vis_end - 1).is_some_and(|it| it.is_space() || it.ch == '\n') { + while vis_end > *s && items.get(vis_end - 1).is_some_and(|it| it.is_space() || is_line_break(it.ch)) { vis_end -= 1; } // Letter spacing separates glyphs: none after the line's last visible one. @@ -574,7 +581,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) .get(vis_end..*e) .unwrap_or(&[]) .iter() - .filter(|it| it.ch != '\n') + .filter(|it| !is_line_break(it.ch)) .map(|it| it.adv + if it.is_space() { justify_extra } else { 0.0 }) .sum(); let mut pen = if rtl_line { -trailing } else { line_indent - hang_l }; @@ -649,7 +656,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) (0..line_items.len()).collect() }; for it in order.iter().filter_map(|k| line_items.get(*k)) { - if it.ch == '\n' { + if is_line_break(it.ch) { continue; } let size = styles.get(it.style).map(|st| sanitize_size(st.font_size)).unwrap_or(12.0) * it.scale; From 855bbb328fde58feee6d8cbc5d5d380c87235cc9 Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Mon, 19 Oct 2026 11:34:34 +0200 Subject: [PATCH 054/114] refactor(text): Line::break_after for carets and selections Carets, clicks and selections asked `para_end && !last_line` to mean "the line ends with a break char". Record that on the line instead, so a break that doesn't end the paragraph can use the same rules. No behaviour change: today only a newline ends a line early. --- crates/text/src/layout.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 6266dc2b1..7aef4e5ed 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -82,6 +82,9 @@ pub struct Line { pub x: f64, /// The line ends a paragraph (hard break or end of text). pub para_end: bool, + /// The line ends with a line break char: a newline, or a line separator (U+2028) that breaks + /// the line inside its paragraph. + pub break_after: bool, /// The paragraph runs right to left (its first strong character is right-to-left). pub rtl: bool, } @@ -498,6 +501,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) } i += 1; } + let break_after = |e: usize| e.checked_sub(1).and_then(|i| items.get(i)).is_some_and(|it| is_line_break(it.ch)); if line_start < items.len() || lines.is_empty() || lines.last().is_some_and(|l| l.2) { lines.push((line_start, items.len(), true, para)); } @@ -694,6 +698,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) width: width + line_indent - hang_l - hang_r, x: 0.0, para_end: *para_end, + break_after: break_after(*e), rtl: rtl_line, }); y += lh; @@ -1151,11 +1156,11 @@ impl TextLayout { let last_line = li + 1 == self.lines.len(); let in_line = index >= line.char_start && (index < line.char_end || (last_line && index <= line.char_end)); // A caret at the end of a soft-wrapped line belongs to the next line. - if !(in_line || index == line.char_end && line.para_end && !last_line) { + if !(in_line || index == line.char_end && line.break_after && !last_line) { continue; } - if index == line.char_end && line.para_end && !last_line { - // Before the newline: end of this line. + if index == line.char_end && line.break_after && !last_line { + // Before the line break: end of this line. let x = self.line_end_x(line); return (x, li); } @@ -1186,8 +1191,8 @@ impl TextLayout { if !line.rtl && !gl.iter().any(|g| g.rtl) { return None; } - // The caret can't sit after a paragraph's newline on its own line. - let end = if line.para_end && li + 1 < self.lines.len() { line.char_end.saturating_sub(1) } else { line.char_end }; + // The caret can't sit after a line's break on its own line. + let end = if line.break_after && li + 1 < self.lines.len() { line.char_end.saturating_sub(1) } else { line.char_end }; let next = (line.char_start..=end.max(line.char_start)) .filter(|i| *i != index) .map(|i| (i, self.caret(i).0)) @@ -1254,7 +1259,7 @@ impl TextLayout { } } let last_line = li + 1 == self.lines.len(); - let end = if line.para_end && !last_line { line.char_end.saturating_sub(1).max(line.char_start) } else { line.char_end }; + let end = if line.break_after && !last_line { line.char_end.saturating_sub(1).max(line.char_start) } else { line.char_end }; // Right of a right-to-left line is its start. if line.rtl { line.char_start } else { end } } @@ -1286,7 +1291,7 @@ impl TextLayout { let x0 = if a <= line.char_start { self.line_start_x(line) } else { self.caret(a).0 }; let x1 = if b >= line.char_end { // The last line has no paragraph break to show, so it ends at its glyphs. - self.line_end_x(line) + if line.para_end && !last && b > line.char_end.saturating_sub(1) { 4.0 } else { 0.0 } + self.line_end_x(line) + if line.break_after && !last && b > line.char_end.saturating_sub(1) { 4.0 } else { 0.0 } } else { self.caret(b).0 }; From 692f45123c5b4b28053bfa90c531f5861f414335 Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Mon, 19 Oct 2026 11:34:47 +0200 Subject: [PATCH 055/114] fix(text): a line separator breaks the line inside its paragraph (#852) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Figma stores Shift+Enter as U+2028. `.fig` imports keep it, but layout only broke lines at a newline, so imported text ran soft-broken lines together ("Option 1SL: …"), lost the empty line of a separator followed by a newline, and put glyph bullets on one line. A line separator now ends its line without ending the paragraph: no paragraph spacing and no new list marker after it, the next line keeps the item's indent, and it draws no glyph. A trailing one leaves an empty last line, and a line it cuts short isn't stretched when justified. --- crates/text/src/layout.rs | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 7aef4e5ed..f9139f6fb 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -160,9 +160,13 @@ fn deco_len(m: DecorationLength, size: f64, auto: f64) -> f64 { if v.is_finite() { v } else { auto } } -/// A char that ends its line: a newline (which also ends the paragraph). +/// The line separator (U+2028): a line break inside a paragraph, as Figma's Shift+Enter stores it +/// (imported text keeps it; typed text has newlines only). +const LINE_SEPARATOR: char = '\u{2028}'; + +/// A char that ends its line: a newline (which also ends the paragraph) or a line separator. fn is_line_break(c: char) -> bool { - c == '\n' + c == '\n' || c == LINE_SEPARATOR } /// Opening punctuation, which hangs outside a line's start. @@ -475,6 +479,17 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) para += 1; continue; } + // A line separator (Figma's Shift+Enter) breaks the line inside its paragraph: no + // paragraph spacing and no new list marker after it. + if it.ch == LINE_SEPARATOR { + lines.push((line_start, i + 1, false, para)); + i += 1; + line_start = i; + x = 0.0; + last_break = None; + para_first_line = false; + continue; + } let line_indent = line_indent_of(para, para_first_line, it.char_start); let w = it.adv; // Hanging punctuation takes no room inside the box. @@ -502,7 +517,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) i += 1; } let break_after = |e: usize| e.checked_sub(1).and_then(|i| items.get(i)).is_some_and(|it| is_line_break(it.ch)); - if line_start < items.len() || lines.is_empty() || lines.last().is_some_and(|l| l.2) { + if line_start < items.len() || lines.is_empty() || lines.last().is_some_and(|l| l.2 || break_after(l.1)) { lines.push((line_start, items.len(), true, para)); } @@ -574,7 +589,9 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) // Justify: extra space per gap. let gaps = items.get(*s..vis_end).unwrap_or(&[]).iter().filter(|it| it.is_space()).count(); let justify_extra = match (text.align_h, wrap) { - (TextAlignH::Justified, Some(maxw)) if !para_end && gaps > 0 => ((maxw - line_indent + hang_l + hang_r - width) / gaps as f64).max(0.0), + (TextAlignH::Justified, Some(maxw)) if !para_end && !break_after(*e) && gaps > 0 => { + ((maxw - line_indent + hang_l + hang_r - width) / gaps as f64).max(0.0) + } _ => 0.0, }; // Where the visible text ends once justified. From 9e4dcc9204b3d694f6c9a2085524b6d9defa2444 Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Mon, 19 Oct 2026 11:34:58 +0200 Subject: [PATCH 056/114] test(text): line separators in layout, lists and carets (#852) --- crates/text/src/lib.rs | 2 + crates/text/src/line_separator_tests.rs | 82 +++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 crates/text/src/line_separator_tests.rs diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 683350f61..8160cc90f 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -150,6 +150,8 @@ mod behaviour_tests; #[cfg(test)] mod cache_tests; #[cfg(test)] +mod line_separator_tests; +#[cfg(test)] mod list_tests; #[cfg(test)] mod metrics_tests; diff --git a/crates/text/src/line_separator_tests.rs b/crates/text/src/line_separator_tests.rs new file mode 100644 index 000000000..d5ad16b02 --- /dev/null +++ b/crates/text/src/line_separator_tests.rs @@ -0,0 +1,82 @@ +//! A line separator (U+2028: what Figma's Shift+Enter stores, and `.fig` imports keep) breaks the +//! line inside its paragraph: no paragraph spacing or new list marker after it, and no glyph (#852). + +use super::*; +use figcraft_doc::{LineHeight, ListStyle, TextAutoResize, TextData}; + +const EPS: f64 = 1e-6; + +/// A 20 px text on 30 px lines in the default face, or None (skip) on a machine without fonts. +fn text(s: &str) -> Option { + scan_system_fonts(); + let f = db().write().unwrap().default_face()?; + let mut t = TextData::new(s); + (t.style.font_family, t.style.font_style) = (f.family.clone(), f.style.clone()); + t.style.font_size = 20.0; + t.style.line_height = LineHeight::Pixels(30.0); + t.auto_resize = TextAutoResize::WidthAndHeight; + Some(t) +} + +/// The chars of each line. +fn line_texts(t: &TextData, l: &TextLayout) -> Vec { + let chars: Vec = t.characters.chars().collect(); + l.lines.iter().map(|ln| chars.get(ln.char_start..ln.char_end).unwrap_or(&[]).iter().collect::()).collect() +} + +#[test] +fn breaks_the_line_but_not_the_paragraph() { + let Some(mut t) = text("one\u{2028}two\nthree") else { return }; + t.paragraph_spacing = 10.0; + let l = layout(&t, None, None); + assert_eq!(line_texts(&t, &l), ["one\u{2028}", "two\n", "three"]); + assert!(!l.lines[0].para_end && l.lines[0].break_after); + assert!(l.lines[1].para_end && l.lines[1].break_after); + assert!(!l.lines[2].break_after); + // Paragraph spacing after the newline only: 30, then 30 + 10. + assert!((l.lines[1].top - 30.0).abs() < EPS && (l.lines[2].top - 70.0).abs() < EPS, "{} {}", l.lines[1].top, l.lines[2].top); + // The separator draws nothing. + assert!(l.glyphs.iter().all(|g| !(g.char_start..g.char_end).contains(&3))); +} + +#[test] +fn before_a_newline_or_at_the_end_it_leaves_an_empty_line() { + // "Hi,⏎¶Season" in #852: Figma shows an empty line between the two. + let Some(t) = text("Hi,\u{2028}\nSeason") else { return }; + let l = layout(&t, None, None); + assert_eq!(line_texts(&t, &l), ["Hi,\u{2028}", "\n", "Season"]); + let Some(t) = text("end\u{2028}") else { return }; + let l = layout(&t, None, None); + assert_eq!(line_texts(&t, &l), ["end\u{2028}", ""]); + assert!((l.height - 60.0).abs() < EPS, "{}", l.height); +} + +#[test] +fn a_list_item_continues_after_it_without_a_new_marker() { + let Some(mut t) = text("item\u{2028}more\nnext") else { return }; + t.list = ListStyle::Bulleted; + let l = layout(&t, None, None); + assert_eq!(line_texts(&t, &l), ["item\u{2028}", "more\n", "next"]); + // Markers are the glyphs that cover no char: one per paragraph. + assert_eq!(l.glyphs.iter().filter(|g| g.char_start == g.char_end).count(), 2); + // The line after the separator lines up with the item's text, past the bullet. + let text_x = |li: usize| l.glyphs.iter().find(|g| g.char_start == l.lines[li].char_start && g.char_end > g.char_start).map(|g| g.x); + let (first, cont) = (text_x(0).unwrap(), text_x(1).unwrap()); + assert!(first > 0.0 && (first - cont).abs() < 1e-3, "{first} {cont}"); +} + +#[test] +fn carets_and_clicks_treat_it_like_a_newline() { + let (Some(sep), Some(nl)) = (text("ab\u{2028}cd"), text("ab\ncd")) else { return }; + let (ls, ln) = (layout(&sep, None, None), layout(&nl, None, None)); + for i in 0..=5 { + assert_eq!(ls.caret(i), ln.caret(i), "caret before char {i}"); + } + for line in &ln.lines { + for x in [-10.0, 5.0, line.width + 50.0] { + assert_eq!(ls.hit(x, line.top + 5.0), ln.hit(x, line.top + 5.0), "click at {x} on the line at {}", line.top); + } + } + // A click right of the first line lands before the separator, not after it. + assert_eq!(ls.hit(ls.lines[0].width + 50.0, ls.lines[0].top + 5.0), 2); +} From 7af44512e78083e152f60ab57a03a8fe2adaf2b3 Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Mon, 19 Oct 2026 11:46:47 +0200 Subject: [PATCH 057/114] refactor(pages): remember which file the Pages list focus belongs to --- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/layers.rs | 7 ++++--- crates/ui-egui/src/layers_keys_tests.rs | 4 ++-- crates/ui-egui/src/lib.rs | 10 +++++----- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/selection_spacing.rs | 2 +- 6 files changed, 14 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 94daf657f..234b07b27 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -544,7 +544,7 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { // A press on the canvas gives the keyboard back to it (arrows nudge again). if resp.hovered() && ctx.input(|i| i.pointer.any_pressed()) { app.ui.layers_focus = false; - app.ui.pages_focus = false; + app.ui.pages_focus = None; app.ui.key_box = None; } // Zoom (pinch, Cmd/Ctrl+scroll) about the pointer; plain scroll pans. diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 514b8f75b..eaba5538d 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -199,7 +199,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } icons::paint(ui.painter(), ar.shrink(5.0), Icon::Plus, t.icon); if aresp.clicked() { - app.ui.pages_focus = true; + app.ui.pages_focus = app.session.ds().ok().map(|d| d.uid); app.run("page.add", json!({})); } if !app.ui.pages_open { @@ -209,6 +209,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { let pages: Vec<(NodeId, String, bool)> = ds.doc.pages.iter().filter_map(|p| ds.doc.node(*p).map(|n| (*p, n.name.clone(), ds.doc.is_divider(*p)))).collect(); let cur = ds.page; + let uid = ds.uid; let sel = ds.selection.clone(); let mut select = None; let mut rename_done: Option<(NodeId, String)> = None; @@ -258,7 +259,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } if resp.clicked() && !divider { select = Some(id); - app.ui.pages_focus = true; + app.ui.pages_focus = Some(uid); app.ui.layers_focus = false; } if resp.double_clicked() { @@ -1074,7 +1075,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { } if let Some((id, m)) = clicked { app.ui.layers_focus = true; - app.ui.pages_focus = false; + app.ui.pages_focus = None; let order: Vec = rows.iter().map(|r| r.id).collect(); let pos = |x: NodeId| order.iter().position(|o| *o == x); let anchor = app.ui.layer_anchor.filter(|a| sel.contains(a)).and_then(pos); diff --git a/crates/ui-egui/src/layers_keys_tests.rs b/crates/ui-egui/src/layers_keys_tests.rs index d0b5f7860..48a6a5914 100644 --- a/crates/ui-egui/src/layers_keys_tests.rs +++ b/crates/ui-egui/src/layers_keys_tests.rs @@ -169,7 +169,7 @@ fn selected_layers_or_a_canvas_press_take_delete_back_from_the_pages_list() { u.button(row, true); u.button(row, false); assert_eq!(u.app.session.page().unwrap(), first); - assert!(u.app.ui.pages_focus); + assert!(u.app.ui.pages_focus.is_some()); // Layers selected after the click (Select all, say): Delete deletes them, not the page. u.app.run("select.set", json!({"ids": [a]})); key(&mut u, Key::Delete, false); @@ -179,7 +179,7 @@ fn selected_layers_or_a_canvas_press_take_delete_back_from_the_pages_list() { let p = pos2(900.0, 700.0); u.button(p, true); u.button(p, false); - assert!(!u.app.ui.pages_focus); + assert!(u.app.ui.pages_focus.is_none()); u.app.run("select.none", json!({})); key(&mut u, Key::Delete, false); assert_eq!(pages(&u), 2, "Delete with nothing selected on the canvas deletes no page"); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9a1cc3a83..702a87d6b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -500,10 +500,10 @@ pub struct UiState { /// The layers panel has the keyboard: arrow keys move through its rows instead of nudging /// (set by a click on a row, cleared by a press on the canvas). pub layers_focus: bool, - /// The pages list has the keyboard: Delete with no layers selected deletes the current page (set - /// by a click on a page row or the add-page button, cleared by a click on a layer row or a press - /// on the canvas). - pub pages_focus: bool, + /// The file (its `uid`) whose pages list has the keyboard: Delete with no layers selected deletes + /// its current page (set by a click on a page row or the add-page button, cleared by a click on a + /// layer row or a press on the canvas). + pub pages_focus: Option, pub layers_seen_sel: Vec, pub dark: bool, pub follow_system: bool, @@ -638,7 +638,7 @@ impl Default for UiState { layer_filter: String::new(), layer_anchor: None, layers_focus: false, - pages_focus: false, + pages_focus: None, layers_seen_sel: Vec::new(), dark: false, follow_system: false, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index dfdf290ae..a8961b222 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -118,7 +118,7 @@ pub fn invoke(app: &mut FigApp, id: &str) { // removes that interaction, not the layer (#767). "edit.delete" if app.ui_context.clone().is_some_and(|ctx| crate::proto_panel::delete_selected_interaction(app, &ctx)) => {} // After a click in the Pages list, with no layers selected, it deletes the current page (#859). - "edit.delete" if app.ui.pages_focus && app.session.selection().is_empty() => { + "edit.delete" if app.ui.pages_focus.is_some() && app.session.selection().is_empty() => { if let Ok(page) = app.session.page() { crate::layers::delete_page(app, page); } diff --git a/crates/ui-egui/src/selection_spacing.rs b/crates/ui-egui/src/selection_spacing.rs index c05b9ec3f..3048ab274 100644 --- a/crates/ui-egui/src/selection_spacing.rs +++ b/crates/ui-egui/src/selection_spacing.rs @@ -217,7 +217,7 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Option, hovered: let drag = Drag { ids, axis: axis.clone(), start: p, value: h.value, uid: ds.uid, page: ds.page, moved: false, last_value: axis.value }; app.ui.layers_focus = false; - app.ui.pages_focus = false; + app.ui.pages_focus = None; app.ui.key_box = None; ctx.memory_mut(|m| { if let Some(f) = m.focused() { From 9f895a37d55a925e8c269836c8cdbd7875e7fe24 Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Mon, 19 Oct 2026 11:48:35 +0200 Subject: [PATCH 058/114] fix(pages): Delete deletes no page once the Pages list is off screen The Pages list kept the keyboard after a switch to the Assets tab or to another open file, so Delete with nothing selected deleted a page there. It now deletes a page only while the list that was clicked is on screen (File tab, list expanded, panel and interface shown) and its file is the one open. --- crates/ui-egui/src/layers.rs | 7 +++++ crates/ui-egui/src/layers_keys_tests.rs | 38 +++++++++++++++++++++++++ crates/ui-egui/src/menus.rs | 5 ++-- 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index eaba5538d..e168da7de 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -315,6 +315,13 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { pages_edge(app, ui, moved.inner_rect.height().min(moved.content_size.y)); } +/// The Pages list has the keyboard: a page row (or the add-page button) of the open file was +/// clicked last, and the list is still on screen. +pub(crate) fn pages_have_keyboard(app: &FigApp) -> bool { + let Ok(ds) = app.session.ds() else { return false }; + app.ui.pages_focus == Some(ds.uid) && app.ui.pages_open && !app.ui.left_collapsed && !ds.view.ui_hidden && app.ui.left_tab == LeftTab::File +} + /// Delete `page` (no confirmation: Undo brings it back) and say so. pub(crate) fn delete_page(app: &mut FigApp, page: NodeId) { if app.run("page.delete", json!({"id": page})).is_some() { diff --git a/crates/ui-egui/src/layers_keys_tests.rs b/crates/ui-egui/src/layers_keys_tests.rs index 48a6a5914..71a1242dc 100644 --- a/crates/ui-egui/src/layers_keys_tests.rs +++ b/crates/ui-egui/src/layers_keys_tests.rs @@ -184,3 +184,41 @@ fn selected_layers_or_a_canvas_press_take_delete_back_from_the_pages_list() { key(&mut u, Key::Delete, false); assert_eq!(pages(&u), 2, "Delete with nothing selected on the canvas deletes no page"); } + +#[test] +fn delete_deletes_no_page_once_the_pages_list_is_off_screen_or_another_file_is_open() { + let (mut u, ..) = setup(); + let first = u.app.session.page().unwrap(); + let second = id(u.app.session.execute("page.add", &json!({})).unwrap()); + u.app.session.execute("page.select", &json!({"id": first})).unwrap(); + u.frame(vec![]); + let row = page_row(&mut u, second, first); + let click = |u: &mut Ui| { + u.button(row, true); + u.button(row, false); + assert!(u.app.ui.pages_focus.is_some()); + }; + // The Assets tab, a collapsed Pages list, a collapsed left panel. + let hide: [fn(&mut Ui, bool); 3] = [ + |u, on| u.app.ui.left_tab = if on { crate::LeftTab::Assets } else { crate::LeftTab::File }, + |u, on| u.app.ui.pages_open = !on, + |u, on| u.app.ui.left_collapsed = on, + ]; + for (k, hide) in hide.iter().enumerate() { + click(&mut u); + hide(&mut u, true); + u.frame(vec![]); + key(&mut u, Key::Delete, false); + assert_eq!(pages(&u), 2, "case {k}: the list is off screen"); + hide(&mut u, false); + u.frame(vec![]); + } + // Another file open: its pages are safe. + click(&mut u); + u.app.run("file.new", json!({})); + u.app.run("page.add", json!({})); + u.frame(vec![]); + assert_eq!(pages(&u), 2); + key(&mut u, Key::Delete, false); + assert_eq!(pages(&u), 2, "the other file keeps its pages"); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a8961b222..244adcf1a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -117,8 +117,9 @@ pub fn invoke(app: &mut FigApp, id: &str) { // Edit ▸ Delete (and the Delete binding) with an interaction open in the Prototype tab // removes that interaction, not the layer (#767). "edit.delete" if app.ui_context.clone().is_some_and(|ctx| crate::proto_panel::delete_selected_interaction(app, &ctx)) => {} - // After a click in the Pages list, with no layers selected, it deletes the current page (#859). - "edit.delete" if app.ui.pages_focus.is_some() && app.session.selection().is_empty() => { + // After a click in the Pages list, while the list is on screen and its file open, with no + // layers selected, it deletes the current page (#859). + "edit.delete" if crate::layers::pages_have_keyboard(app) && app.session.selection().is_empty() => { if let Ok(page) = app.session.page() { crate::layers::delete_page(app, page); } From e17fccd7abe5419c19869dc3ed70899dd7eb6981 Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Mon, 19 Oct 2026 11:50:07 +0200 Subject: [PATCH 059/114] fix(pages): a second Delete after deleting layers keeps the page After a click in the Pages list, layers selected since (Select all, say) took the first Delete, which left nothing selected, so a second Delete deleted the page. Delete that doesn't take the page now ends the Pages list's hold on the keyboard. --- crates/ui-egui/src/layers_keys_tests.rs | 7 +++++++ crates/ui-egui/src/menus.rs | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/crates/ui-egui/src/layers_keys_tests.rs b/crates/ui-egui/src/layers_keys_tests.rs index 71a1242dc..63f8ce74c 100644 --- a/crates/ui-egui/src/layers_keys_tests.rs +++ b/crates/ui-egui/src/layers_keys_tests.rs @@ -175,7 +175,14 @@ fn selected_layers_or_a_canvas_press_take_delete_back_from_the_pages_list() { key(&mut u, Key::Delete, false); assert_eq!(pages(&u), 2); assert!(u.app.session.doc().unwrap().node(a).is_none()); + // Nothing is selected now, but the list gave up the keyboard: a second Delete keeps the page. + assert!(u.app.ui.pages_focus.is_none()); + key(&mut u, Key::Delete, false); + assert_eq!(pages(&u), 2, "a second Delete keeps the page"); // A press on the canvas gives Delete back to it. + u.button(row, true); + u.button(row, false); + assert!(u.app.ui.pages_focus.is_some()); let p = pos2(900.0, 700.0); u.button(p, true); u.button(p, false); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 244adcf1a..0d83024a4 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -124,6 +124,12 @@ pub fn invoke(app: &mut FigApp, id: &str) { crate::layers::delete_page(app, page); } } + // Otherwise the list gives up the keyboard: once Delete has taken layers selected since the + // click (Select all, say), a second Delete must not take the page. + "edit.delete" if app.ui.pages_focus.is_some() => { + app.ui.pages_focus = None; + app.run(id, json!({})); + } "file.open" => app.open_dialog(), "plugin.import" => crate::plugins_ui::import_dialog(app), "file.import" => app.import_dialog(false), From 1b66fec3ece5123e21ba6680ff11d0e8182edebb Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Mon, 19 Oct 2026 12:12:54 +0200 Subject: [PATCH 060/114] fix(text): the caret after a line break sits on the next line `caret` put the index right after a line break (a newline, or a line separator) at the end of the line above, so after Enter the caret showed on the previous line until something was typed, and Up/Down counted lines from there. The index before the break stays at the end of its line; the one after it is now on the next line, as after a wrap. Selection highlights already started such lines at their own start (#665); their comment no longer describes the old caret. --- crates/text/src/layout.rs | 13 ++++--------- crates/text/src/tests.rs | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index f9139f6fb..014bb1cde 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -1171,16 +1171,12 @@ impl TextLayout { pub fn caret(&self, index: usize) -> (f64, usize) { for (li, line) in self.lines.iter().enumerate() { let last_line = li + 1 == self.lines.len(); + // A caret at a line's end belongs to the next line, whether the line wraps or ends with a + // line break (before the break it is still on this line: past its glyphs). let in_line = index >= line.char_start && (index < line.char_end || (last_line && index <= line.char_end)); - // A caret at the end of a soft-wrapped line belongs to the next line. - if !(in_line || index == line.char_end && line.break_after && !last_line) { + if !in_line { continue; } - if index == line.char_end && line.break_after && !last_line { - // Before the line break: end of this line. - let x = self.line_end_x(line); - return (x, li); - } let gl = self.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]); for g in gl { if g.char_end == g.char_start { @@ -1303,8 +1299,7 @@ impl TextLayout { out.extend(Self::mixed_selection(gl, a, b, line)); continue; } - // From the line's own start: `caret(char_start)` after a hard break is the end of the - // line above. + // From the line's own start. let x0 = if a <= line.char_start { self.line_start_x(line) } else { self.caret(a).0 }; let x1 = if b >= line.char_end { // The last line has no paragraph break to show, so it ends at its glyphs. diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index f5eaab6bf..ebf5889ce 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -481,3 +481,19 @@ fn selection_after_a_line_break_starts_at_the_line_start() { // A break followed by text still doesn't highlight the next line's start. assert!(rect_on(&l, &l.selection_rects(0, 6), 1).is_none()); } + +#[test] +fn the_caret_after_a_line_break_sits_on_the_next_line() { + if !have_fonts() { + return; + } + // Before the break: the end of its line; after it: the start of the next (after Enter, the + // caret shows on the new line, and Up/Down count from there). + let l = layout(&TextData::new("ab\ncd"), None, None); + assert_eq!(l.caret(2).1, 0); + assert!(l.caret(2).0 > 0.0); + assert_eq!(l.caret(3), (0.0, 1)); + // A trailing break: the caret sits on the empty last line. + let l = layout(&TextData::new("ab\n"), None, None); + assert_eq!(l.caret(3), (0.0, 1)); +} From b930ea5871ab6b7cc11a92c86a7ba787b8461609 Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Mon, 19 Oct 2026 12:13:12 +0200 Subject: [PATCH 061/114] test(text): the caret's line on either side of a line separator (#852) --- crates/text/src/line_separator_tests.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/text/src/line_separator_tests.rs b/crates/text/src/line_separator_tests.rs index d5ad16b02..a7042483f 100644 --- a/crates/text/src/line_separator_tests.rs +++ b/crates/text/src/line_separator_tests.rs @@ -77,6 +77,10 @@ fn carets_and_clicks_treat_it_like_a_newline() { assert_eq!(ls.hit(x, line.top + 5.0), ln.hit(x, line.top + 5.0), "click at {x} on the line at {}", line.top); } } + // Before the separator: the end of the first line; after it: the start of the second. + assert_eq!(ls.caret(2).1, 0); + assert!(ls.caret(2).0 > 0.0); + assert_eq!(ls.caret(3), (0.0, 1)); // A click right of the first line lands before the separator, not after it. assert_eq!(ls.hit(ls.lines[0].width + 50.0, ls.lines[0].top + 5.0), 2); } From 40d7ce14ce94a86f6ad251624be43961f0adeb3c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:18:38 +0000 Subject: [PATCH 062/114] ci(deps): bump actions/checkout Bumps the actions group with 1 update in the / directory: [actions/checkout](https://github.com/actions/checkout). Updates `actions/checkout` from 4 to 7 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v4...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e613617a1..9700a8e91 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,7 @@ jobs: name: test (ubuntu-latest) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install Linux system dependencies (egui/winit/rfd) run: | @@ -74,7 +74,7 @@ jobs: name: wasm32 check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable with: targets: wasm32-unknown-unknown From 3e83ae44b73bcff28880131dfb270ed67b478137 Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Mon, 19 Oct 2026 12:50:43 +0200 Subject: [PATCH 063/114] fix(pages): a prototype gesture on the canvas ends the Pages list focus --- crates/ui-egui/src/canvas.rs | 2 ++ crates/ui-egui/src/proto_canvas.rs | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 234b07b27..9adaf8ab6 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -490,6 +490,8 @@ fn zoom_tool(app: &mut FigApp, ui: &Ui, resp: &egui::Response, pos: Pos2, mods: /// then doesn't show. fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { if crate::proto_canvas::input(app, ui, resp) { + // A prototype gesture is a press on the canvas too: the Pages list gives up the keyboard. + app.ui.pages_focus = None; return true; } let ctx = ui.ctx().clone(); diff --git a/crates/ui-egui/src/proto_canvas.rs b/crates/ui-egui/src/proto_canvas.rs index 1187944df..5def6c2a9 100644 --- a/crates/ui-egui/src/proto_canvas.rs +++ b/crates/ui-egui/src/proto_canvas.rs @@ -282,6 +282,26 @@ pub fn input(app: &mut FigApp, ui: &egui::Ui, resp: &egui::Response) -> bool { mod tests { use super::*; + /// A press on a connection handle takes the keyboard from the Pages list, as a press anywhere + /// else on the canvas does, so a later Delete can't take the page (#859). + #[test] + fn a_press_on_a_connection_handle_ends_the_pages_list_focus() { + let mut s = figcraft_engine::Session::new(); + let a = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 150})).unwrap(); + let a = crate::present_tests::id(&a); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let mut u = crate::present_tests::Ui::new(s); + u.app.ui.right_tab = RightTab::Prototype; + u.run("view.zoomSelection", json!({})); + u.frames(2, Vec::new); + u.app.ui.pages_focus = u.app.session.ds().ok().map(|d| d.uid); + let doc = u.app.session.doc().unwrap().clone(); + let (_, at) = *handles(&u.app, &doc, &[a]).first().unwrap(); + u.button(at, true); + assert!(u.app.ui.pages_focus.is_none()); + u.button(at, false); + } + #[test] fn curves_leave_the_facing_side() { let a = Rect::from_min_size(pos2(0.0, 0.0), vec2(50.0, 20.0)); From 6e7b63bc16e5e8a1b321fbb1ec19c6600ec4b8bd Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Mon, 19 Oct 2026 12:50:43 +0200 Subject: [PATCH 064/114] fix(pages): Delete that removes an interaction ends the Pages list focus --- crates/ui-egui/src/proto_editor_tests.rs | 23 +++++++++++++++++++++++ crates/ui-egui/src/proto_panel.rs | 2 ++ 2 files changed, 25 insertions(+) diff --git a/crates/ui-egui/src/proto_editor_tests.rs b/crates/ui-egui/src/proto_editor_tests.rs index c63e62c60..80d49616d 100644 --- a/crates/ui-egui/src/proto_editor_tests.rs +++ b/crates/ui-egui/src/proto_editor_tests.rs @@ -304,3 +304,26 @@ fn escape_closes_prototype_editor_without_deselecting_layer() { u.key(pos2(100.0, 100.0), egui::Key::Escape); assert!(u.app.session.selection().is_empty(), "selection cleared on third escape"); } + +/// After a click in the Pages list, Delete that removes an interaction ends the list's hold on the +/// keyboard: with the layer deselected afterwards, Delete doesn't take the page (#859). +#[test] +fn deleting_an_interaction_ends_the_pages_list_focus() { + let (s, _a, _b, btn) = setup(); + let mut u = Ui::new(s); + let first = u.app.session.page().unwrap(); + u.run("page.add", json!({})); + u.run("page.select", json!({"id": first})); + // As after a click on the page's row (layers_keys_tests clicks the real row). + u.app.ui.pages_focus = u.app.session.ds().ok().map(|d| d.uid); + u.app.ui.right_tab = crate::RightTab::Prototype; + u.run("select.set", json!({"ids": [btn]})); + crate::proto_panel::open_editor(&u.ctx, Some((btn, 0))); + u.frames(1, Vec::new); + u.key(pos2(100.0, 100.0), egui::Key::Delete); + assert_eq!(u.app.session.doc().unwrap().node(btn).unwrap().interactions.len(), 0); + assert!(u.app.ui.pages_focus.is_none()); + u.run("select.none", json!({})); + u.key(pos2(100.0, 100.0), egui::Key::Delete); + assert_eq!(u.app.session.doc().unwrap().pages.len(), 2, "the page stays"); +} diff --git a/crates/ui-egui/src/proto_panel.rs b/crates/ui-egui/src/proto_panel.rs index 85af66c59..111afa785 100644 --- a/crates/ui-egui/src/proto_panel.rs +++ b/crates/ui-egui/src/proto_panel.rs @@ -53,6 +53,8 @@ pub fn delete_selected_interaction(app: &mut FigApp, ctx: &egui::Context) -> boo return false; } open_editor(ctx, None); + // Delete went to the interaction, so a later one doesn't go to the Pages list. + app.ui.pages_focus = None; let ids = targets_for(app, node); app.run("proto.interaction.remove", json!({"ids": ids, "index": index})); true From 710c95e527e6b6fa35d28aac043c49472b7ebffc Mon Sep 17 00:00:00 2001 From: AganzeFelicite Date: Mon, 19 Oct 2026 12:52:09 +0200 Subject: [PATCH 065/114] test(text): a selection across a line separator shows the break (#852) --- crates/text/src/line_separator_tests.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/text/src/line_separator_tests.rs b/crates/text/src/line_separator_tests.rs index a7042483f..6891f97b1 100644 --- a/crates/text/src/line_separator_tests.rs +++ b/crates/text/src/line_separator_tests.rs @@ -84,3 +84,14 @@ fn carets_and_clicks_treat_it_like_a_newline() { // A click right of the first line lands before the separator, not after it. assert_eq!(ls.hit(ls.lines[0].width + 50.0, ls.lines[0].top + 5.0), 2); } + +#[test] +fn a_selection_across_it_shows_the_break_like_a_newline() { + let (Some(sep), Some(nl)) = (text("ab\u{2028}cd"), text("ab\ncd")) else { return }; + let (ls, ln) = (layout(&sep, None, None), layout(&nl, None, None)); + let (before, across) = (ls.selection_rects(0, 2), ls.selection_rects(0, 5)); + assert_eq!((before.len(), across.len()), (1, 2)); + // Reaching past the separator adds the 4 px break mark at the first line's end. + assert!((across[0].width() - before[0].width() - 4.0).abs() < EPS, "{} {}", across[0].width(), before[0].width()); + assert_eq!(across, ln.selection_rects(0, 5)); +} From b7c9c712b1be155e6426e04673abdf4ca92a4dd9 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:21:34 +0000 Subject: [PATCH 066/114] Reject server addresses whose port is not all ASCII digits Found while testing crates/collab-net/src/invite.rs:valid_host (around line 205) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/collab-net/src/invite.rs | 3 ++- crates/collab-net/src/invite_tests.rs | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/collab-net/src/invite.rs b/crates/collab-net/src/invite.rs index 617ad5187..47ca18503 100644 --- a/crates/collab-net/src/invite.rs +++ b/crates/collab-net/src/invite.rs @@ -220,7 +220,8 @@ fn valid_host(s: &str) -> bool { return true; } let Some((name, port)) = s.rsplit_once(':') else { return false }; - port.parse::().is_ok_and(|p| p != 0) + port.bytes().all(|b| b.is_ascii_digit()) + && port.parse::().is_ok_and(|p| p != 0) && (1..=253).contains(&name.len()) && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.') && !name.starts_with(['-', '.']) diff --git a/crates/collab-net/src/invite_tests.rs b/crates/collab-net/src/invite_tests.rs index 02017517d..d4d132963 100644 --- a/crates/collab-net/src/invite_tests.rs +++ b/crates/collab-net/src/invite_tests.rs @@ -123,3 +123,11 @@ proptest! { prop_assert_eq!(Invite::parse(&i.url()), Ok(i)); } } + +#[test] +fn port_must_be_all_digits() { + assert!(Endpoint::parse("ws://localhost:80/").is_ok()); + for bad in ["ws://localhost:+80/", "ws://localhost:-80/", "ws://localhost:/"] { + assert!(Endpoint::parse(bad).is_err(), "accepted {bad}"); + } +} From 0108a7c2f5dda45304320469caa0c8e36cac1d65 Mon Sep 17 00:00:00 2001 From: MortenHusted Date: Mon, 19 Oct 2026 13:34:40 +0200 Subject: [PATCH 067/114] M3: copied components paste as instances within their file Co-Authored-By: Codex (GPT-6) --- crates/engine/src/cmd/edit.rs | 23 +++- .../engine/src/components_behaviour_tests.rs | 112 ++++++++++++++++++ crates/engine/src/lib.rs | 9 ++ 3 files changed, 143 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a4769a35c..52ffc04a5 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -515,6 +515,13 @@ pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<( s.clipboard.source = Some(source); s.clipboard.source_path = source_path; s.clipboard.source_doc = uid; + s.clipboard.copied_components = nodes + .iter() + .filter(|(_, sub)| { + cmd == "edit.copy" && sub.first().is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. })) + }) + .map(|(root, _)| *root) + .collect(); s.clipboard.nodes = nodes; s.clipboard.images = images; s.clipboard.bounds = bounds; @@ -593,7 +600,8 @@ fn paste_design_layers(s: &mut Session, p: &Value) -> Result { let (nodes, images, bounds) = copied(&doc, &tops); let props = s.clipboard.props.take(); // No open file has id 0. So paste treats this copy as a copy from a different file. - s.clipboard = crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None }; + s.clipboard = + crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None, copied_components: BTreeSet::new() }; let mut out = paste(s, p)?; if !other.issues.is_empty() { out["warnings"] = json!(other.issues); @@ -623,6 +631,7 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re } // From another file: its instances follow the mains this file can have. let foreign = clip.source.as_ref().filter(|_| s.ds().is_ok_and(|d| d.uid != clip.source_doc)); + let copy_local = s.ds()?.uid == clip.source_doc; let doc = s.doc_mut()?; for (k, v) in &clip.images { doc.images.entry(k.clone()).or_insert_with(|| v.clone()); @@ -636,6 +645,16 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re let mut out = Vec::new(); let mut all = BTreeMap::new(); for (root, nodes) in &clip.nodes { + if copy_local + && clip.copied_components.contains(root) + && let Some(n) = nodes.first().filter(|n| matches!(n.kind, NodeKind::Component { .. })) + && doc.node(*root).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. })) + { + let instance = crate::components::create_instance(doc, *root, parent, 0.0, 0.0)?; + doc.get_mut(instance)?.transform = doc.world_transform(parent).inverse() * shift * n.transform; + out.push(instance); + continue; + } let mut map = BTreeMap::new(); for n in nodes { map.insert(n.id, doc.alloc_id()); @@ -661,10 +680,12 @@ fn paste(s: &mut Session, p: &Value) -> Result { // else in the frame's centre; otherwise in place on the page (or the viewport centre). let sel = s.selection(); let doc = s.doc()?; + let copy_local = s.ds()?.uid == s.clipboard.source_doc; let target = id(p, "parent").or_else(|| { sel.first() .copied() .filter(|i| sel.len() == 1 && doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && !matches!(n.kind, NodeKind::Instance { .. }))) + .filter(|i| !copy_local || !s.clipboard.copied_components.iter().any(|root| doc.is_ancestor(*root, *i))) }); let parent = target.unwrap_or(page); let at = match (num(p, "x"), num(p, "y")) { diff --git a/crates/engine/src/components_behaviour_tests.rs b/crates/engine/src/components_behaviour_tests.rs index 15f660e1e..e068c6cff 100644 --- a/crates/engine/src/components_behaviour_tests.rs +++ b/crates/engine/src/components_behaviour_tests.rs @@ -82,6 +82,118 @@ fn text(n: &Node) -> &TextData { n.kind.text().unwrap() } +#[test] +fn copying_a_main_component_pastes_a_linked_instance() { + for command in ["edit.paste", "edit.pasteOver", "edit.pasteReplace"] { + let mut s = Session::new(); + let k = kit(&mut s); + ex(&mut s, "select.set", json!({"ids": [k.card]})); + ex(&mut s, "edit.copy", json!({})); + if command != "edit.paste" { + ex(&mut s, "select.set", json!({"ids": [k.inst]})); + } + let before = undo_len(&s); + let pasted = crate::params::id_of(&ex(&mut s, command, json!({}))["ids"][0]).unwrap(); + assert_eq!(component_of(&s, pasted), k.card, "{command}"); + assert_eq!(node(&s, pasted).parent, Some(s.page().unwrap())); + assert_eq!(node(&s, pasted).source, Some(k.card)); + assert_eq!(undo_len(&s), before + 1); + ex(&mut s, "edit.undo", json!({})); + assert!(s.doc().unwrap().node(pasted).is_none()); + ex(&mut s, "edit.redo", json!({})); + assert_eq!(component_of(&s, pasted), k.card); + ex(&mut s, "node.set", json!({"id": k.title, "props": {"characters": "Updated"}})); + assert_eq!(chars(&s, copy_of(&s, pasted, k.title)), "Updated"); + healthy(&s); + } +} + +#[test] +fn copying_a_variant_pastes_an_instance_but_copying_its_set_keeps_the_components() { + let mut s = Session::new(); + let (set, _, hover) = button_set(&mut s); + ex(&mut s, "select.set", json!({"ids": [hover]})); + ex(&mut s, "edit.copy", json!({})); + let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({}))["ids"][0]).unwrap(); + assert_eq!(component_of(&s, pasted), hover); + assert_eq!(node(&s, pasted).name, node(&s, set).name); + let NodeKind::Instance { props, .. } = &node(&s, pasted).kind else { panic!("pasted variant is not an instance") }; + assert_eq!(chars(&s, layer(&s, pasted, "Label").unwrap()), "Hover"); + assert_eq!(props["Show dot"], true); + assert_eq!(node(&s, pasted).parent, Some(s.page().unwrap())); + + ex(&mut s, "select.set", json!({"ids": [set]})); + ex(&mut s, "edit.copy", json!({})); + let copied_set = crate::params::id_of(&ex(&mut s, "edit.paste", json!({}))["ids"][0]).unwrap(); + assert!(matches!(node(&s, copied_set).kind, NodeKind::ComponentSet { .. })); + assert_eq!(node(&s, copied_set).parent, Some(s.page().unwrap())); + assert_eq!(kids(&s, copied_set).len(), 2); + for variant in kids(&s, copied_set) { + assert!(matches!(node(&s, variant).kind, NodeKind::Component { .. })); + assert!(!kids(&s, set).contains(&variant)); + } + healthy(&s); +} + +#[test] +fn cut_components_and_components_copied_to_another_file_remain_components() { + for (copy, undo_cut, new_file) in [("edit.cut", false, false), ("edit.cut", true, false), ("edit.copy", false, true)] { + let mut s = Session::new(); + let k = kit(&mut s); + ex(&mut s, copy, json!({"ids": [k.card]})); + if undo_cut { + ex(&mut s, "edit.undo", json!({})); + } + if new_file { + ex(&mut s, "file.new", json!({})); + } + let page = s.page().unwrap(); + let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": page}))["ids"][0]).unwrap(); + assert!(matches!(node(&s, pasted).kind, NodeKind::Component { .. }), "{copy}, undo={undo_cut}, new_file={new_file}"); + assert_eq!(kids(&s, pasted).len(), 3); + healthy(&s); + } +} + +#[test] +fn copying_a_component_does_not_link_to_an_id_reused_after_undo() { + let mut s = Session::new(); + let frame = idv(&ex(&mut s, "frame.create", json!({"width": 120, "height": 60, "name": "Copied main"}))); + let main = idv(&ex(&mut s, "component.create", json!({"ids": [frame]}))); + ex(&mut s, "edit.copy", json!({"ids": [main]})); + s.undo().unwrap(); + s.undo().unwrap(); + let other = idv(&ex(&mut s, "frame.create", json!({"width": 300, "height": 200, "name": "Unrelated main"}))); + ex(&mut s, "component.create", json!({"ids": [other]})); + assert_eq!(other, main, "undo makes the original id available again"); + let page = s.page().unwrap(); + let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": page}))["ids"][0]).unwrap(); + assert_eq!(node(&s, pasted).name, "Copied main"); + assert_eq!((node(&s, pasted).width, node(&s, pasted).height), (120.0, 60.0)); + assert!(matches!(node(&s, pasted).kind, NodeKind::Component { .. })); + healthy(&s); +} + +#[test] +fn pasted_component_instances_keep_the_copied_transform_in_the_target_frame() { + let mut s = Session::new(); + let k = kit(&mut s); + ex(&mut s, "node.set", json!({"id": k.card, "props": {"rotation": 30}})); + let original = s.doc().unwrap().world_transform(k.card); + let frame = idv(&ex(&mut s, "frame.create", json!({"x": -100, "y": -100, "width": 1000, "height": 1000}))); + ex(&mut s, "edit.copy", json!({"ids": [k.card]})); + let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": frame}))["ids"][0]).unwrap(); + assert_eq!(component_of(&s, pasted), k.card); + for (actual, expected) in s.doc().unwrap().world_transform(pasted).as_coeffs().into_iter().zip(original.as_coeffs()) { + assert!((actual - expected).abs() < 1e-9); + } + assert_eq!(node(&s, pasted).parent, Some(frame)); + let before = s.doc().unwrap().clone(); + assert!(s.execute("edit.paste", &json!({"parent": k.card})).is_err()); + assert_eq!(s.doc().unwrap(), &before, "pasting into the main is refused atomically"); + healthy(&s); +} + #[test] fn overridable_properties_stay_through_main_edits_and_reset() { // (what, layer, group, override on the instance, the same property edited on the main, read, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 312c2e878..cf5698011 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -735,6 +735,8 @@ pub struct Clipboard { /// The page-space bounds of the copied selection. pub bounds: Option, pub source_doc: u64, + /// Copied component/set roots, excluding cuts and originals discarded by undo (ids are reused). + pub copied_components: std::collections::BTreeSet, /// The document copied from (pastes into other files follow its mains) and its file path. pub source: Option>, pub source_path: Option, @@ -1166,6 +1168,13 @@ impl Session { } ds.clean_selection(); ds.revision += 1; + let (uid, doc) = (ds.uid, ds.doc.clone()); + if self.clipboard.source_doc == uid { + self.clipboard.copied_components.retain(|id| { + doc.node(*id) + .is_some_and(|n| matches!(n.kind, figcraft_doc::NodeKind::Component { .. } | figcraft_doc::NodeKind::ComponentSet { .. })) + }); + } Ok(true) } From 41c123657e0771358ed2ed5bcec797596e96132f Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Mon, 19 Oct 2026 15:25:00 +0400 Subject: [PATCH 068/114] i18n: Georgian (ka) interface language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds crates/ui-egui/locales/ka.toml (ქართული): every key of en.toml (1,730 UI strings), every command label (567) and every menu wording (31), marked as a draft. Georgian has no letter case and keeps a noun singular after a numeral, so plural entries give the same text for one and other. System picks it for ka, ka-GE and ka_GE.UTF-8; pref.language's help text lists ka. Inter and egui's fonts have no Georgian, so the UI draws it in craft-fonts' Noto Sans Georgian: it joins the built-in faces in theme::install_fonts and the web build keeps it (crates/text/build.rs). craft-fonts-ref moves to a71c20e3 (storytold/craft-fonts#27, the commit that adds the face) in the release and FreeBSD workflows, and the release builds the web version with craft-fonts (web-craft-fonts: true). Tests: the Georgian locale tags, draft status and count-neutral plurals, and that the built-in Georgian face draws every Georgian letter in the catalog (skipped without craft-fonts). Docs: localization-parity, translating, mcp, README and ROADMAP. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- .github/workflows/freebsd.yml | 2 +- .github/workflows/release.yml | 3 +- README.md | 2 +- ROADMAP.md | 2 +- crates/engine/src/cmd/view.rs | 2 +- crates/text/build.rs | 6 +- crates/ui-egui/locales/ka.toml | 2344 ++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n_tests.rs | 35 + crates/ui-egui/src/theme.rs | 2 +- docs/localization-parity.md | 10 +- docs/mcp.md | 2 +- docs/translating.md | 18 +- 12 files changed, 2407 insertions(+), 21 deletions(-) create mode 100644 crates/ui-egui/locales/ka.toml diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index b01af0524..ff15bfbc4 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -32,4 +32,4 @@ jobs: uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main with: run-package-check: true - craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 + craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 75f7a5fae..e8444c525 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,8 +24,9 @@ jobs: with: app-name: figcraft app-title: FigCraft - craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 + craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc has-server: true + web-craft-fonts: true version: ${{ inputs.version }} secrets: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} diff --git a/README.md b/README.md index 0a2598984..ae4c9f24c 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control - **Its own renderer:** tiny-skia on every core for the canvas and exports, with layer blending, effects and masks; booleans from linesweeper, geometry from kurbo, text from skrifa and HarfRust. - **Runs in the browser:** the whole engine and UI compile to WebAssembly. - **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes. -- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech and Indonesian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). +- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian and Georgian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). - **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and three example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md). - **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md). - **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md). diff --git a/ROADMAP.md b/ROADMAP.md index 8a587286d..a83c23473 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -111,7 +111,7 @@ native speakers' reviews of the six language drafts need people. | Korean | ko | 95% | draft (needs native review) | | Vietnamese | vi | 0% | none | -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%) and Czech (`cs`, 99%, draft). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese +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 Georgian (`ka`, 100%, 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 and Indonesian drafts (Preferences ▸ Language; Traditional Chinese has no catalog; CJK UI diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 69b938e91..fbc2c54dd 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -409,7 +409,7 @@ pub fn specs() -> Vec { spec!( "pref.language", "Interface language", - "{language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…; else English}", + "{language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|zh-Hans|…; else English}", false, |s, p| { let lang = req_string("pref.language", p, "language")?; diff --git a/crates/text/build.rs b/crates/text/build.rs index d02defefe..a3f4c6125 100644 --- a/crates/text/build.rs +++ b/crates/text/build.rs @@ -65,9 +65,9 @@ fn craft_fonts(dir: &std::path::Path, manifest_rel: &str) -> Result= 3 and not done" = "მაგ. count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "შენახული ტრაექტორიის გარეშე კუთხოვანი და მრუდი შემაერთებლები სწორ ხაზებად იმპორტირდება" +"entries without a node type were skipped" = "კვანძის ტიპის გარეშე ჩანაწერები გამოტოვებულია" +"figcraft:// links from a browser, chat or email open this app and join the session." = "ბრაუზერიდან, ჩატიდან ან ელფოსტიდან figcraft:// ბმულები ხსნის ამ აპს და უერთდება სესიას." +"filled areas of a vector with their own fill use the layer's fill" = "ვექტორის შევსებული არეები საკუთარი შევსებით შრის შევსებას იყენებს" +"from {server}" = "წყარო: {server}" +"gamepad triggers without exactly one known controller button were skipped" = "გეიმპადის გამშვებები, რომლებსაც ზუსტად ერთი ცნობილი კონტროლერის ღილაკი არ აქვს, გამოტოვებულია" +"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "ბადის ტრეკების სია 1000 ჩანაწერს აღემატება; ტრეკები ნაგულისხმევ თანაბარ ზომებს იყენებს" +"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "ბადის ტრეკის ზომა მხარდაუჭერელი ან დაზიანებულია; შესაბამისი ტრეკები თანაბარ ზომებს იყენებს" +"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "ბადის ტრეკების ზომების სია 1000 ჩანაწერს აღემატება; ტრეკები ნაგულისხმევ თანაბარ ზომებს იყენებს" +"hidden" = "დამალული" +"iOS" = "iOS" +"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "გამოსახულების ფილტრები ორაზროვანია, რადგან ფილტრების ორივე კონტეინერია მოცემული; ფილტრების გარდაქმნა გამოტოვებულია" +"image filters use FigCraft's approximate renderer" = "გამოსახულების ფილტრები FigCraft-ის მიახლოებითი რენდერით გამოისახება" +"instance swap properties whose default component isn't in the file were skipped" = "ინსტანსის ჩანაცვლების თვისებები, რომელთა ნაგულისხმევი კომპონენტი ფაილში არ არის, გამოტოვებულია" +"instances inside their own main component were imported as frames" = "საკუთარი მთავარი კომპონენტის შიგნით მყოფი ინსტანსები ჩარჩოებად იმპორტირდა" +"instances of components that aren't in the file show no layers" = "იმ კომპონენტების ინსტანსები, რომლებიც ფაილში არ არის, შრეებს არ აჩვენებს" +"instances of components that aren't in the file were imported as frames" = "იმ კომპონენტების ინსტანსები, რომლებიც ფაილში არ არის, ჩარჩოებად იმპორტირდა" +"instances that would nest a component inside itself were imported as frames" = "ინსტანსები, რომლებიც კომპონენტს საკუთარ თავში ჩადგამდა, ჩარჩოებად იმპორტირდა" +"instances without a main component were imported as empty frames" = "მთავარი კომპონენტის გარეშე ინსტანსები ცარიელ ჩარჩოებად იმპორტირდა" +"invalid internal .fig text path data was ignored" = ".fig-ის შიდა ტექსტის ტრაექტორიის არასწორი მონაცემები იგნორირებულია" +"invalid or oversized vector networks import as outlines" = "არასწორი ან ზედმეტად დიდი ვექტორული ქსელები კონტურებად იმპორტირდება" +"invalid or oversized vector path data was skipped" = "ვექტორული ტრაექტორიის არასწორი ან ზედმეტად დიდი მონაცემები გამოტოვებულია" +"just now" = "ახლახან" +"key triggers without a supported key were skipped" = "მხარდაჭერილი კლავიშის გარეშე კლავიშის გამშვებები გამოტოვებულია" +"key up triggers import as key presses" = "კლავიშის აშვების გამშვებები კლავიშის დაჭერებად იმპორტირდება" +"layers without a position were placed at their parent's origin" = "პოზიციის გარეშე შრეები მშობლის საწყის წერტილში განთავსდა" +"layers without a type were skipped" = "ტიპის გარეშე შრეები გამოტოვებულია" +"legacy image filter container is unsupported; filter conversion was skipped" = "გამოსახულების ფილტრების ძველი კონტეინერი მხარდაუჭერელია; ფილტრების გარდაქმნა გამოტოვებულია" +"locked" = "ჩაკეტილი" +"malformed image filters container was skipped" = "გამოსახულების ფილტრების დაზიანებული კონტეინერი გამოტოვებულია" +"malformed image paintFilter container was skipped" = "გამოსახულების paintFilter-ის დაზიანებული კონტეინერი გამოტოვებულია" +"malformed slot content assignment was skipped" = "სლოტის შიგთავსის დაზიანებული მინიჭება გამოტოვებულია" +"no bleed" = "ჭრისპირა არის გარეშე" +"nobody else here yet" = "აქ ჯერ სხვა არავინაა" +"non-default slot settings are not represented and were skipped" = "სლოტის არანაგულისხმევი პარამეტრები არ არის წარმოდგენილი და გამოტოვებულია" +"now" = "ახლა" +"or" = "ან" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "წერტილების კონტურის შეერთებები შენარჩუნებულია; ტირეებიანი ან ცვლადი სისქის კონტურები შრის შეერთებით გამოისახება" +"preferred slot component keys cannot be resolved to file nodes and were skipped" = "სლოტის სასურველი კომპონენტების გასაღებები ფაილის კვანძებთან ვერ დაკავშირდა და გამოტოვებულია" +"prototype actions of unknown types were skipped" = "უცნობი ტიპის პროტოტიპის მოქმედებები გამოტოვებულია" +"prototype actions without a destination in the file were skipped" = "პროტოტიპის მოქმედებები, რომელთა დანიშნულება ფაილში არ არის, გამოტოვებულია" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "პროტოტიპის ექსპრესიები იმ ცვლადებზე, რომლებიც ცვლადების JSON-ში არ არის, გამოტოვებულია" +"prototype expressions with unsupported functions were skipped" = "მხარდაუჭერელი ფუნქციების შემცველი პროტოტიპის ექსპრესიები გამოტოვებულია" +"prototype interactions without supported actions were skipped" = "მხარდაჭერილი მოქმედებების გარეშე პროტოტიპის ურთიერთქმედებები გამოტოვებულია" +"prototype navigations of unknown types were skipped" = "უცნობი ტიპის პროტოტიპის ნავიგაციები გამოტოვებულია" +"prototype transitions of unknown types play instantly" = "უცნობი ტიპის პროტოტიპის გადასვლები მყისიერად სრულდება" +"prototype triggers of unknown types were skipped" = "უცნობი ტიპის პროტოტიპის გამშვებები გამოტოვებულია" +"put a widget on the canvas" = "ვიჯეტის ტილოზე განთავსება" +"read and edit the document" = "დოკუმენტის წაკითხვა და რედაქტირება" +"read the document" = "დოკუმენტის წაკითხვა" +"reads the document" = "კითხულობს დოკუმენტს" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "ცვლადის დაყენების მოქმედებები იმ ცვლადებზე, რომლებიც ცვლადების JSON-ში არ არის, გამოტოვებულია" +"set-variable actions with values that can't be evaluated were skipped" = "ცვლადის დაყენების მოქმედებები, რომელთა მნიშვნელობის გამოთვლა ვერ ხერხდება, გამოტოვებულია" +"set-variable actions with values that don't fit the variable were skipped" = "ცვლადის დაყენების მოქმედებები, რომელთა მნიშვნელობა ცვლადს არ შეესაბამება, გამოტოვებულია" +"set-variable actions without a value were skipped" = "მნიშვნელობის გარეშე ცვლადის დაყენების მოქმედებები გამოტოვებულია" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "ცვლადის რეჟიმის დაყენების მოქმედებები იმ კოლექციებზე ან რეჟიმებზე, რომლებიც ცვლადების JSON-ში არ არის, გამოტოვებულია" +"slot content assignment target is missing or is not slot content" = "სლოტის შიგთავსის მინიჭების სამიზნე აკლია ან სლოტის შიგთავსი არ არის" +"slot content assignment uses the empty GUID sentinel and was skipped" = "სლოტის შიგთავსის მინიჭება ცარიელ GUID მარკერს იყენებს და გამოტოვებულია" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "ზოგი ვექტორული წერტილი მიუთითებს წერტილის პარამეტრებზე, რომლებიც ფაილს აკლია; ისინი შრის შეერთებებსა და ჩვეულებრივ კუთხეებს იყენებს" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "ზოგი ვექტორული სეგმენტი მიუთითებს სტილის პარამეტრებზე, რომლებიც ფაილს აკლია; ისინი შრის კონტურს იყენებს" +"stale or invalid grid track sizing entries were ignored" = "ბადის ტრეკის ზომის მოძველებული ან არასწორი ჩანაწერები იგნორირებულია" +"stars and polygons without point counts or geometry use the default point count" = "ვარსკვლავები და მრავალკუთხედები წვეროების რაოდენობის ან გეომეტრიის გარეშე წვეროების ნაგულისხმევ რაოდენობას იყენებს" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "ზოგი ვექტორული სეგმენტის სტილის პარამეტრები მხარდაუჭერელია; ეს სეგმენტები შრის კონტურს იყენებს" +"text path underline or strikethrough decoration is not rendered" = "ტრაექტორიაზე განთავსებული ტექსტის ხაზგასმა ან გადახაზვა არ გამოისახება" +"text style overrides refer to missing table entries" = "ტექსტის სტილის გადაფარვები ცხრილის არარსებულ ჩანაწერებზე მიუთითებს" +"the file has no pages" = "ფაილს გვერდები არ აქვს" +"the plugin" = "პლაგინი" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "ცვლადების JSON-ს `variableCollections` არ აქვს (მოსალოდნელი იყო ლოკალური ცვლადების პასუხი) და იგნორირებულია" +"this session only" = "მხოლოდ ეს სესია" +"unknown component property types were skipped" = "კომპონენტის თვისებების უცნობი ტიპები გამოტოვებულია" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "ცვლადის ალიასები იმ ცვლადებზე, რომლებიც ცვლადების JSON-ში არ არის (ან სხვა ტიპისაა), გამოტოვებულია" +"variable bindings to properties the layer doesn't have were skipped" = "ცვლადების მიბმები იმ თვისებებზე, რომლებიც შრეს არ აქვს, გამოტოვებულია" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "ცვლადების მიბმები იმ ცვლადებზე, რომლებიც ცვლადების JSON-ში არ არის (ბიბლიოთეკის ცვლადები), გამოტოვებულია" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "ცვლადების მიბმები გამოტოვებულია: ფაილის JSON-ში ცვლადების განსაზღვრებები არ არის (გადაეცით ლოკალური ცვლადების JSON როგორც `variables`, ან ჩააშენეთ მისი `meta` ფაილის JSON-ის გვერდით)" +"variable bindings whose variable type doesn't fit the property were skipped" = "ცვლადების მიბმები, რომელთა ცვლადის ტიპი თვისებას არ შეესაბამება, გამოტოვებულია" +"variable collections without modes were skipped" = "რეჟიმების გარეშე ცვლადების კოლექციები გამოტოვებულია" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "პროტოტიპის ექსპრესიებში ცვლადის რეჟიმის მოძიებები, რომლებიც ცვლადსა და მის ერთ-ერთ რეჟიმს არ წარმოადგენს, გამოტოვებულია" +"variable modes of collections that aren't in the file were skipped" = "იმ კოლექციების ცვლადის რეჟიმები, რომლებიც ფაილში არ არის, გამოტოვებულია" +"variable modes of collections that aren't in the variables JSON were skipped" = "იმ კოლექციების ცვლადის რეჟიმები, რომლებიც ცვლადების JSON-ში არ არის, გამოტოვებულია" +"variable values that don't match their type (or composed colours) were skipped" = "ცვლადის მნიშვნელობები, რომლებიც თავიანთ ტიპს არ შეესაბამება (ან შედგენილი ფერები), გამოტოვებულია" +"variables of unknown types were skipped" = "უცნობი ტიპის ცვლადები გამოტოვებულია" +"variables whose collection isn't in the variables JSON were skipped" = "ცვლადები, რომელთა კოლექცია ცვლადების JSON-ში არ არის, გამოტოვებულია" +"variables without readable values were skipped" = "წაკითხვადი მნიშვნელობების გარეშე ცვლადები გამოტოვებულია" +"vector points with unknown stroke joins use the layer join" = "უცნობი კონტურის შეერთების მქონე ვექტორული წერტილები შრის შეერთებას იყენებს" +"vectors without geometry are empty" = "გეომეტრიის გარეშე ვექტორები ცარიელია" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "გეომეტრიის გარეშე ვექტორები ცარიელია (ფაილის JSON-ის ექსპორტი გააკეთეთ geometry=paths პარამეტრით)" +"very long text was cut to 1,048,576 characters" = "ძალიან გრძელი ტექსტი 1 048 576 სიმბოლომდე შემოკლდა" +"very long vector paths were skipped" = "ძალიან გრძელი ვექტორული ტრაექტორიები გამოტოვებულია" +"video actions are not supported and were skipped" = "ვიდეოს მოქმედებები მხარდაუჭერელია და გამოტოვებულია" +"video triggers are not supported and were skipped" = "ვიდეოს გამშვებები მხარდაუჭერელია და გამოტოვებულია" +"yesterday" = "გუშინ" +"{instances} in {files}" = "{instances} — {files}" +"{language}: draft translation, waiting for a native speaker's review." = "{language}: თარგმანის მონახაზი, ელოდება მშობლიური ენის მცოდნის განხილვას." +"{length} bleed" = "ჭრისპირა არე: {length}" +"{month} {day}, {year}" = "{day} {month}, {year}" +"{name} cleared from the list" = "{name} ამოიშალა სიიდან" +"{name} is forgotten on this computer; the file stays on {server}" = "{name} დავიწყებულია ამ კომპიუტერზე; ფაილი {server}-ზე რჩება" +"{name} is on the server." = "{name} სერვერზეა." +"{name} is spotlighting" = "ყურადღება {name}-ზეა" +"{name}\nClick to apply to the selection" = "{name}\nდაწკაპეთ მონიშნულზე გამოსაყენებლად" +"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nგადაათრიეთ ტილოზე ან დაწკაპეთ ხედის შუაში განსათავსებლად.\nინსტანსის ჩასანაცვლებლად მასზე ჩაგდებისას დააჭირეთ Alt-ს (Option)." +"{name} (from {library})" = "{name} ({library}-იდან)" +"{name} (you)" = "{name} (თქვენ)" +"{name} ({layers} layers)" = "{name} (შრე: {layers})" +"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name} ({tag}): {min} – {max}, ნაგულისხმევი {default}. სტილის მნიშვნელობაზე დასაბრუნებლად ორჯერ დაწკაპეთ სლაიდერზე; Escape გადათრევისას აუქმებს." +"{name} from {path}" = "{name} — {path}" +"{name} was removed from the server" = "{name} წაიშალა სერვერიდან" +"{name} · deleted on main" = "{name} · წაშლილია მთავარში" +"{name} · deleted on the branch" = "{name} · წაშლილია განშტოებაში" +"{name} · not published" = "{name} · არ არის გამოქვეყნებული" +"{name} · on a server" = "{name} · სერვერზე" +"{name} · published version {v}" = "{name} · გამოქვეყნებული ვერსია {v}" +"{name} · version {v} on the server" = "{name} · ვერსია {v} სერვერზე" +"{name}, in a live session" = "{name}, ცოცხალ სესიაში" +"{name}, online, open only to you" = "{name}, ონლაინ, ღიაა მხოლოდ თქვენთვის" +"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, მონიშნულია" +"{n} added" = { one = "დამატებულია: {n}", other = "დამატებულია: {n}" } +"{n} autosave versions" = { one = "ავტოშენახული ვერსია: {n}", other = "ავტოშენახული ვერსია: {n}" } +"{n} changed" = { one = "შეცვლილია: {n}", other = "შეცვლილია: {n}" } +"{n} columns" = { one = "სვეტი: {n}", other = "სვეტი: {n}" } +"{n} components" = { one = "კომპონენტი: {n}", other = "კომპონენტი: {n}" } +"{n} components and styles have newer versions" = { one = "ახალი ვერსია აქვს კომპონენტს ან სტილს: {n}", other = "ახალი ვერსია აქვს კომპონენტს ან სტილს: {n}" } +"{n} conflicts to resolve" = { one = "გადასაწყვეტი კონფლიქტი: {n}", other = "გადასაწყვეტი კონფლიქტი: {n}" } +"{n} days ago" = { one = "{n} დღის წინ", other = "{n} დღის წინ" } +"{n} files" = { one = "ფაილი: {n}", other = "ფაილი: {n}" } +"{n} hours ago" = { one = "{n} საათის წინ", other = "{n} საათის წინ" } +"{n} instances" = { one = "ინსტანსი: {n}", other = "ინსტანსი: {n}" } +"{n} layers" = { one = "შრე: {n}", other = "შრე: {n}" } +"{n} layers as one file" = { one = "შრე: {n}, ერთ ფაილად", other = "შრე: {n}, ერთ ფაილად" } +"{n} layers selected" = { one = "მონიშნული შრე: {n}", other = "მონიშნული შრე: {n}" } +"{n} layers · {parts}" = { one = "შრე: {n} · {parts}", other = "შრე: {n} · {parts}" } +"{n} matches" = { one = "დამთხვევა: {n}", other = "დამთხვევა: {n}" } +"{n} minutes ago" = { one = "{n} წუთის წინ", other = "{n} წუთის წინ" } +"{n} months ago" = { one = "{n} თვის წინ", other = "{n} თვის წინ" } +"{n} more notes omitted from this report" = { one = "ამ ანგარიშიდან გამოტოვებული სხვა შენიშვნები: {n}", other = "ამ ანგარიშიდან გამოტოვებული სხვა შენიშვნები: {n}" } +"{n} of {total}" = "{n} / {total}" +"{n} people joined" = { one = "შემოუერთდა ადამიანი: {n}", other = "შემოუერთდა ადამიანი: {n}" } +"{n} preferred components" = { one = "სასურველი კომპონენტი: {n}", other = "სასურველი კომპონენტი: {n}" } +"{n} ready" = { one = "მზადაა: {n}", other = "მზადაა: {n}" } +"{n} removed" = { one = "წაშლილია: {n}", other = "წაშლილია: {n}" } +"{n} replies" = { one = "პასუხი: {n}", other = "პასუხი: {n}" } +"{n} rows" = { one = "სტრიქონი: {n}", other = "სტრიქონი: {n}" } +"{n} styles" = { one = "სტილი: {n}", other = "სტილი: {n}" } +"{n} variables" = { one = "ცვლადი: {n}", other = "ცვლადი: {n}" } +"{n} weeks ago" = { one = "{n} კვირის წინ", other = "{n} კვირის წინ" } +"{n} years ago" = { one = "{n} წლის წინ", other = "{n} წლის წინ" } +"{n}d" = "{n} დღ" +"{n}h" = "{n} სთ" +"{n}m" = "{n} წთ" +"{n}w" = "{n} კვ" +"{page}, nothing selected" = "{page}, არაფერია მონიშნული" +"{profile} (current)" = "{profile} (მიმდინარე)" +"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} მისი მნიშვნელობაა; {property ? \"a\" : \"b\"} ირჩევს სტრიქონს. ნებისმიერი სხვა {…} რჩება დაწერილი სახით." +"{server} can't say what became of your files (it is older than this app)." = "{server} ვერ გეტყვით, რა მოუვიდა თქვენს ფაილებს (ის ამ აპზე ძველია)." +"{server} · No longer on the server" = "{server} · სერვერზე აღარ არის" +"{server} · {shared} · Opened {date}" = "{server} · {shared} · გაიხსნა {date}" +"{tool} hold {key}" = "{tool} დააჭირეთ {key}" +"{value} isn't a colour" = "{value} ფერი არ არის" +"{when} · edited" = "{when} · რედაქტირებულია" +"← ↑ → ↓" = "← ↑ → ↓" +"← → ↑ ↓" = "← → ↑ ↓" +"←" = "←" +"→, Space or N" = "→, Space ან N" +"↵" = "↵" +"⇥ / ⇧⇥" = "⇥ / ⇧⇥" +"⌘ arrows" = "⌘ ისრები" +"⌘ click" = "⌘ დაწკაპუნება" +"⌘ scroll" = "⌘ გადახვევა" +"⌥ drag" = "⌥ გადათრევა" +"All the fonts this file uses are installed" = "ამ ფაილის ყველა შრიფტი დაინსტალირებულია" +"Checking the fonts installed on this computer…" = "ამ კომპიუტერზე დაინსტალირებული შრიფტების შემოწმება…" +"Intensity" = "ძალა" +"Missing fonts" = "მიუწვდომელი შრიფტები" +"No files or templates match your search" = "ძიების შესაბამისი ფაილი ან შაბლონი არ მოიძებნა" +"Search files and templates" = "ფაილებისა და შაბლონების ძიება" +"Search results" = "ძიების შედეგები" +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "ეს ფაილი იყენებს შრიფტებს, რომლებიც ამ კომპიუტერზე დაინსტალირებული არ არის. მათი ტექსტი სათადარიგო შრიფტით გამოისახება, სანამ მათ არ დააინსტალირებთ." +"Try another search, or browse templates." = "სცადეთ სხვა ძიება ან დაათვალიერეთ შაბლონები." +"only in a text style or variable" = "მხოლოდ ტექსტის სტილში ან ცვლადში" +"slot layers without a matching slot property were imported as frames" = "სლოტის შრეები, რომლებსაც შესაბამისი სლოტის თვისება არ აქვს, ჩარჩოებად იმპორტირდა" +"{n} text layers" = { one = "ტექსტის შრე: {n}", other = "ტექსტის შრე: {n}" } + +[commands] +"align.bottom" = "ქვემოთ სწორება" +"align.hcenter" = "ჰორიზონტალური ცენტრების სწორება" +"align.left" = "მარცხნივ სწორება" +"align.right" = "მარჯვნივ სწორება" +"align.top" = "ზემოთ სწორება" +"align.vcenter" = "ვერტიკალური ცენტრების სწორება" +"annotation.add" = "ანოტაციის დამატება" +"annotation.addCategory" = "ანოტაციის კატეგორიის დამატება" +"annotation.categories" = "ანოტაციის კატეგორიები" +"annotation.edit" = "ანოტაციის რედაქტირება" +"annotation.editCategory" = "ანოტაციის კატეგორიის რედაქტირება" +"annotation.list" = "ანოტაციების სია" +"annotation.remove" = "ანოტაციის წაშლა" +"annotation.removeCategory" = "ანოტაციის კატეგორიის წაშლა" +"app.actions" = "მოქმედებები" +"app.preferences" = "პარამეტრები" +"arrange.back" = "უკანა პლანზე გადატანა" +"arrange.backward" = "უკან გადატანა" +"arrange.forward" = "წინ გადმოტანა" +"arrange.front" = "წინა პლანზე გადმოტანა" +"arrange.packH" = "ჰორიზონტალურად შეკვრა" +"arrange.packV" = "ვერტიკალურად შეკვრა" +"arrange.roundToPixels" = "პიქსელებამდე დამრგვალება" +"arrange.spacing" = "ინტერვალი" +"arrange.tidy" = "მოწესრიგება" +"bool.exclude" = "მონიშნულის გამორიცხვა" +"bool.flatten" = "ლოგიკური ოპერაციის გაბრტყელება" +"bool.intersect" = "მონიშნულის გადაკვეთა" +"bool.subtract" = "მონიშნულის გამოკლება" +"bool.union" = "მონიშნულის გაერთიანება" +"branch.archive" = "განშტოების დაარქივება" +"branch.create" = "განშტოების შექმნა" +"branch.list" = "განშტოებების სია" +"branch.merge" = "განშტოების შერწყმა" +"branch.open" = "განშტოების გახსნა" +"branch.review" = "განშტოების განხილვა" +"branch.updateFromMain" = "მთავარიდან განახლება" +"codeConnect.export" = "კოდთან კავშირების ექსპორტირება" +"codeConnect.import" = "კოდთან კავშირების იმპორტი" +"codeConnect.remove" = "კოდთან კავშირის გაწყვეტა" +"codeConnect.set" = "კოდთან დაკავშირება" +"collab.cursor" = "კურსორის ჩვენება მდებარეობაზე" +"collab.cursorChat" = "კურსორის ჩატი" +"collab.diff" = "სინქრონიზებული ცვლილებების ჩვენება" +"collab.files" = "თქვენი ონლაინ ფაილები" +"collab.follow" = "მიყოლა" +"collab.forgetFile" = "ონლაინ ფაილის დავიწყება" +"collab.host" = "ცოცხალი სესიის დაწყება" +"collab.join" = "ცოცხალ სესიასთან შეერთება" +"collab.leave" = "ცოცხალი სესიიდან გასვლა" +"collab.manageFile" = "გამოქვეყნებული ფაილის მართვა" +"collab.openFile" = "ონლაინ ფაილის გახსნა" +"collab.peers" = "ამ ფაილში მყოფი ადამიანები" +"collab.publish" = "სერვერზე გამოქვეყნება" +"collab.recoverFile" = "ონლაინ ფაილის აღდგენა" +"collab.registerLinks" = "მოსაწვევი ბმულების FigCraft-ში გახსნა" +"collab.removePerson" = "სესიიდან მოცილება" +"collab.report" = "ფაილზე საჩივარი სერვერის ოპერატორთან" +"collab.serverInfo" = "სერვერის ინფორმაცია" +"collab.share" = "შეერთების კოდით გაზიარება" +"collab.spotlight" = "ჩემზე ყურადღების გამახვილება" +"collab.stop" = "ცოცხალი სესიის შეწყვეტა" +"command.batch" = "რამდენიმე ბრძანების გაშვება" +"comment.add" = "კომენტარის დამატება" +"comment.delete" = "კომენტარის წაშლა" +"comment.edit" = "კომენტარის რედაქტირება" +"comment.link" = "კომენტარის ბმულის კოპირება" +"comment.list" = "კომენტარების სია" +"comment.markRead" = "კომენტარის წაკითხულად მარკირება" +"comment.move" = "კომენტარის გადატანა" +"comment.next" = "შემდეგი კომენტარი" +"comment.open" = "კომენტარის გახსნა" +"comment.people" = "კომენტარებში მონაწილე ადამიანები" +"comment.react" = "კომენტარზე რეაგირება" +"comment.reply" = "კომენტარზე პასუხი" +"comment.resolve" = "კომენტარის გადაწყვეტა" +"comment.zoom" = "კომენტარების მასშტაბირება" +"component.addProperty" = "კომპონენტის თვისების დამატება" +"component.addVariant" = "ვარიანტის დამატება" +"component.bindProperty" = "შრის თვისებაზე მიბმა" +"component.combineVariants" = "ვარიანტებად გაერთიანება" +"component.create" = "კომპონენტის შექმნა" +"component.createMultiple" = "რამდენიმე კომპონენტის შექმნა" +"component.deleteProperty" = "კომპონენტის თვისების წაშლა" +"component.detach" = "ინსტანსის გამოყოფა" +"component.editProperty" = "კომპონენტის თვისების რედაქტირება" +"component.exposeInstance" = "ჩადგმული ინსტანსის გამოტანა" +"component.goToMain" = "მთავარ კომპონენტზე გადასვლა" +"component.hideWhenPublishing" = "გამოქვეყნებისას დამალვა" +"component.list" = "კომპონენტების სია" +"component.makeSlot" = "სლოტად გადაქცევა" +"component.playground" = "კომპონენტის სავარჯიშო მოედანი" +"component.properties" = "კომპონენტის თვისებები" +"component.pushChanges" = "ცვლილებების მთავარ კომპონენტში გადაგზავნა" +"component.removeSlot" = "სლოტის მოხსნა" +"component.resetInstance" = "ყველა ცვლილების ჩამოყრა" +"component.resetOverride" = "გადაფარვის ჩამოყრა" +"component.restoreMain" = "მთავარი კომპონენტის აღდგენა" +"dev.compare" = "ცვლილებების შედარება" +"dev.focus" = "ფოკუსის ხედი" +"dev.resolveChange" = "ცვლილების გადაწყვეტა" +"dev.unfocus" = "ფოკუსის ხედიდან გასვლა" +"devResource.add" = "დეველოპერის რესურსის დამატება" +"devResource.edit" = "დეველოპერის რესურსის რედაქტირება" +"devResource.list" = "დეველოპერის რესურსების სია" +"devResource.remove" = "დეველოპერის რესურსის წაშლა" +"distribute.horizontal" = "ჰორიზონტალური შუალედების განაწილება" +"distribute.vertical" = "ვერტიკალური შუალედების განაწილება" +"doc.home" = "საწყისი ეკრანი" +"doc.list" = "დოკუმენტების სია" +"doc.nextTab" = "შემდეგი ჩანართი" +"doc.previousTab" = "წინა ჩანართი" +"doc.switch" = "დოკუმენტის გადართვა" +"edit.copy" = "კოპირება" +"edit.copyAsCss" = "კოპირება როგორც CSS" +"edit.copyAsPng" = "კოპირება როგორც PNG" +"edit.copyAsSvg" = "კოპირება როგორც SVG" +"edit.copyAsText" = "კოპირება როგორც ტექსტი" +"edit.copyLink" = "მონიშნულის ბმულის კოპირება" +"edit.copyProperties" = "თვისებების კოპირება" +"edit.cut" = "ამოჭრა" +"edit.delete" = "წაშლა" +"edit.duplicate" = "დუბლირება" +"edit.findReplace" = "ძებნა და ჩანაცვლება" +"edit.paste" = "ჩასმა" +"edit.pasteDesignLayers" = "დიზაინის შრეების ჩასმა" +"edit.pasteOver" = "მონიშნულზე ჩასმა" +"edit.pasteProperties" = "თვისებების ჩასმა" +"edit.pasteReplace" = "ჩასმა ჩასანაცვლებლად" +"edit.pickColor" = "ფერის არჩევა" +"edit.redo" = "ხელახლა შესრულება" +"edit.repeat" = "ბოლო ბრძანების გამეორება" +"edit.setDefaultProperties" = "ნაგულისხმევი თვისებების დაყენება" +"edit.undo" = "დაბრუნება" +"effect.add" = "ეფექტის დამატება" +"effect.move" = "ეფექტის გადატანა" +"effect.remove" = "ეფექტის წაშლა" +"effect.set" = "ეფექტის დაყენება" +"export.add" = "ექსპორტის პარამეტრის დამატება" +"export.remove" = "ექსპორტის პარამეტრის წაშლა" +"export.update" = "ექსპორტის პარამეტრის რედაქტირება" +"file.close" = "ჩანართის დახურვა" +"file.colorProfile" = "ფერის პროფილი" +"file.duplicate" = "ფაილის დუბლირება" +"file.export" = "ექსპორტირება" +"file.exportFramesPdf" = "ჩარჩოების PDF-ად ექსპორტირება" +"file.exportLayers" = "შრეების ექსპორტირება" +"file.exportSvg" = "მონიშნულის SVG-ად ექსპორტირება" +"file.import" = "იმპორტი" +"file.new" = "ახალი დიზაინის ფაილი" +"file.newFromTemplate" = "შაბლონიდან შექმნა" +"file.open" = "გახსნა" +"file.openRecent" = "ბოლოს გახსნილი ფაილების გახსნა" +"file.placeImage" = "გამოსახულების განთავსება" +"file.recover" = "ფაილის აღდგენა" +"file.rename" = "ფაილის სახელის შეცვლა" +"file.revert" = "წინა ვერსიაზე დაბრუნება" +"file.save" = "შენახვა" +"file.saveAs" = "შენახვა როგორც" +"file.saveVersion" = "ვერსიების ისტორიაში შენახვა" +"file.versionHistory" = "ვერსიების ისტორიის ჩვენება" +"fill.add" = "შევსების დამატება" +"fill.move" = "შევსების გადატანა" +"fill.pattern" = "პატერნით შევსება" +"fill.remove" = "შევსების წაშლა" +"fill.set" = "შევსების დაყენება" +"frame.create" = "ჩარჩოს შექმნა" +"frame.presets" = "ჩარჩოს პრესეტების სია" +"frame.swapOrientation" = "ორიენტაციის შეცვლა" +"grid.add" = "განლაგების ბადის დამატება" +"grid.move" = "განლაგების ბადის გადატანა" +"grid.remove" = "განლაგების ბადის წაშლა" +"guide.add" = "დამხმარე ხაზის დამატება" +"guide.clear" = "ყველა დამხმარე ხაზის წაშლა" +"guide.move" = "დამხმარე ხაზის გადაადგილება" +"guide.remove" = "დამხმარე ხაზის მოცილება" +"guide.select" = "დამხმარე ხაზის მონიშვნა" +"help.gettingStarted" = "პირველი ნაბიჯები" +"help.about" = "FigCraft-ის შესახებ" +"help.page" = "დახმარების გვერდი" +"help.shortcuts" = "კლავიატურის მალსახმობები" +"image.adjust" = "გამოსახულების კორექცია" +"image.deletePreset" = "გამოსახულების პრესეტის წაშლა" +"image.place" = "გამოსახულების განთავსება" +"image.presets" = "გამოსახულების პრესეტები" +"image.savePreset" = "გამოსახულების პრესეტის შენახვა" +"inspect.assets" = "რესურსების ინსპექტირება" +"inspect.code" = "შრის კოდი" +"inspect.componentCode" = "კომპონენტის კოდი" +"inspect.properties" = "თვისებების ინსპექტირება" +"inspect.readyForDev" = "მზადაა დეველოპერისთვის" +"instance.create" = "ინსტანსის შექმნა" +"instance.exposed" = "გამოტანილი ჩადგმული ინსტანსები" +"instance.place" = "ინსტანსის განთავსება" +"instance.resetSlot" = "სლოტის ჩამოყრა" +"instance.setProperties" = "ინსტანსის თვისებების დაყენება" +"instance.slotInsert" = "სლოტში ჩასმა" +"instance.swap" = "ინსტანსის ჩანაცვლება" +"instance.swapAt" = "ინსტანსის ჩანაცვლება წერტილში" +"layers.collapseAll" = "შრეების ჩაკეცვა" +"layout.add" = "ავტოგანლაგების დამატება" +"layout.remove" = "ავტოგანლაგების მოხსნა" +"layout.setTrack" = "ბადის ტრეკის ზომის დაყენება" +"layout.suggest" = "ავტოგანლაგების შეთავაზება" +"libraries.open" = "ბიბლიოთეკები" +"libraries.publish" = "ბიბლიოთეკის გამოქვეყნება…" +"library.acceptUpdates" = "ბიბლიოთეკის განახლებების მიღება" +"library.add" = "ბიბლიოთეკის დამატება" +"library.aliasVariable" = "ბიბლიოთეკის ცვლადის ალიასი" +"library.applyStyle" = "ბიბლიოთეკის სტილის გამოყენება" +"library.bindVariable" = "ბიბლიოთეკის ცვლადის მიბმა" +"library.extendCollection" = "ბიბლიოთეკის კოლექციის გაფართოება" +"library.import" = "ბიბლიოთეკის კომპონენტის იმპორტი" +"library.importVariable" = "ბიბლიოთეკის ცვლადის იმპორტი" +"library.list" = "ბიბლიოთეკების სია" +"library.place" = "ბიბლიოთეკის კომპონენტის განთავსება" +"library.publish" = "ბიბლიოთეკის გამოქვეყნება" +"library.publishToServer" = "ბიბლიოთეკის სერვერზე გამოქვეყნება" +"library.remove" = "ბიბლიოთეკის წაშლა" +"library.setVariableMode" = "ბიბლიოთეკის ცვლადის რეჟიმის დაყენება" +"library.swap" = "ბიბლიოთეკის ჩანაცვლება" +"library.swapAt" = "ინსტანსის ბიბლიოთეკის კომპონენტით ჩანაცვლება" +"library.updates" = "ბიბლიოთეკის განახლებები" +"library.usage" = "ბიბლიოთეკის გამოყენება" +"measure.distances" = "მანძილების გაზომვა" +"measure.padding" = "ავტოგანლაგების შიდა დაშორების გაზომვა" +"node.create" = "შრის შექმნა" +"node.get" = "შრის მიღება" +"node.reorder" = "შრის გადალაგება" +"node.reparent" = "შრის შიგნით გადატანა" +"node.set" = "თვისებების დაყენება" +"object.batchRename" = "ჯგუფური სახელის შეცვლა" +"object.convertToFrame" = "ჩარჩოდ გარდაქმნა" +"object.convertToGroup" = "ჯგუფად გარდაქმნა" +"object.convertToSection" = "სექციად გარდაქმნა" +"object.flipH" = "ჰორიზონტალურად არეკვლა" +"object.flipV" = "ვერტიკალურად არეკვლა" +"object.frameSelection" = "მონიშნულის ჩარჩოში მოქცევა" +"object.group" = "ჯგუფის მონიშვნა" +"object.hideOthers" = "სხვა შრეების დამალვა" +"object.mask" = "ნიღბად გამოყენება" +"object.moveToPage" = "გვერდზე გადატანა" +"object.opacityKeys" = "გაუმჭვირვალობა ციფრული კლავიშებით" +"object.rasterize" = "მონიშნულის რასტრირება" +"object.releaseMask" = "ნიღბის მოხსნა" +"object.removeFill" = "შევსების წაშლა" +"object.removeFrame" = "ჩარჩოს მოხსნა" +"object.removeStroke" = "კონტურის წაშლა" +"object.rename" = "სახელის შეცვლა" +"object.resizeToFit" = "შიგთავსზე ზომის მორგება" +"object.rotate180" = "180°-ით შემობრუნება" +"object.rotateLeft" = "90°-ით მარცხნივ შემობრუნება" +"object.rotateRight" = "90°-ით მარჯვნივ შემობრუნება" +"object.setThumbnail" = "ესკიზად დაყენება" +"object.swapFillStroke" = "შევსებისა და კონტურის გაცვლა" +"object.toggleLock" = "მონიშნულის ჩაკეტვა/განბლოკვა" +"object.toggleVisible" = "მონიშნულის ჩვენება/დამალვა" +"object.ungroup" = "მონიშნულის დაჯგუფების მოხსნა" +"object.wrapInSection" = "ახალ სექციაში მოქცევა" +"page.add" = "გვერდის დამატება" +"page.delete" = "გვერდის წაშლა" +"page.duplicate" = "გვერდის დუბლირება" +"page.list" = "გვერდების სია" +"page.move" = "გვერდის გადატანა" +"page.rename" = "გვერდის სახელის შეცვლა" +"page.select" = "გვერდზე გადასვლა" +"page.setBackground" = "გვერდის ფონი" +"paint.documentColors" = "დოკუმენტის ფერები" +"plugin.close" = "პლაგინის პანელის დახურვა" +"plugin.directory.install" = "პლაგინის ინსტალაცია კატალოგიდან" +"plugin.directory.list" = "პლაგინების კატალოგის დათვალიერება" +"plugin.directory.publish" = "პლაგინის კატალოგში გამოქვეყნება" +"plugin.directory.unpublish" = "პლაგინის კატალოგიდან მოხსნა" +"plugin.event" = "პლაგინის პანელის მართვის ელემენტის გამოყენება" +"plugin.import" = "პლაგინის იმპორტი მანიფესტიდან…" +"plugin.list" = "პლაგინების სია" +"plugin.remove" = "პლაგინის წაშლა" +"plugin.run" = "პლაგინის გაშვება" +"plugin.ui" = "პლაგინის პანელი" +"plugins.manage" = "პლაგინების მართვა…" +"plugins.runLast" = "ბოლო პლაგინის გაშვება" +"pref.autoSave" = "ფაილების ავტომატურად შენახვა" +"pref.highlightHover" = "შრეების გამოკვეთა მაჩვენებლის მიტანისას" +"pref.keepTool" = "ხელსაწყოს არჩეულად დატოვება გამოყენების შემდეგ" +"pref.language" = "ინტერფეისის ენა" +"pref.nudge" = "მცირედ გადაწევის ბიჯი" +"pref.numberKeysOpacity" = "გაუმჭვირვალობისთვის ციფრული კლავიშების გამოყენება" +"pref.publishServer" = "ონლაინ გაზიარების სერვერი" +"pref.reduceMotion" = "მოძრაობის შემცირება" +"pref.renameDuplicates" = "დუბლირებული შრეების სახელის შეცვლა" +"pref.showDimensions" = "ზომების ჩვენება ობიექტებზე" +"pref.userName" = "თქვენი სახელი კომენტარებში" +"proto.action.add" = "პროტოტიპის მოქმედების დამატება" +"proto.action.move" = "პროტოტიპის მოქმედების გადალაგება" +"proto.action.remove" = "პროტოტიპის მოქმედების წაშლა" +"proto.action.set" = "პროტოტიპის მოქმედების რედაქტირება" +"proto.closePreview" = "პროტოტიპის წინასწარი ნახვის დახურვა" +"proto.conditional.addBlock" = "„სხვა შემთხვევაში, თუ“ / „სხვა შემთხვევაში“-ს დამატება" +"proto.conditional.removeBlock" = "„თუ“ / „სხვა შემთხვევაში, თუ“ / „სხვა შემთხვევაში“-ს წაშლა" +"proto.conditional.setCondition" = "პირობის რედაქტირება" +"proto.evaluate" = "პროტოტიპის ექსპრესიის გამოთვლა" +"proto.flow.add" = "ნაკადის საწყისი წერტილის დამატება" +"proto.flow.remove" = "ნაკადის წაშლა" +"proto.flow.rename" = "ნაკადის სახელის შეცვლა" +"proto.interaction.add" = "ურთიერთქმედების დამატება" +"proto.interaction.clear" = "ყველა ურთიერთქმედების წაშლა" +"proto.interaction.remove" = "ურთიერთქმედების წაშლა" +"proto.interaction.set" = "ურთიერთქმედების რედაქტირება" +"proto.present" = "წარდგენა" +"proto.presentOptions" = "წარდგენის პარამეტრები" +"proto.preview" = "პროტოტიპის წინასწარი ნახვის გახსნა" +"proto.settings" = "პროტოტიპის პარამეტრები" +"section.toggleContents" = "სექციის შიგთავსის დამალვა/ჩვენება" +"select.add" = "მონიშნულზე დამატება" +"select.all" = "ყველაფრის მონიშვნა" +"select.area" = "არის მონიშვნა" +"select.byName" = "შრეების სახელით მონიშვნა" +"select.children" = "შვილობილების მონიშვნა" +"select.inverse" = "მონიშვნის შებრუნება" +"select.layersAt" = "შრეები წერტილში" +"select.matching" = "შესაბამისი შრეების მონიშვნა" +"select.next" = "შემდეგი მეზობლის მონიშვნა" +"select.none" = "მონიშვნის მოხსნა" +"select.parent" = "მშობლის მონიშვნა" +"select.previous" = "წინა მეზობლის მონიშვნა" +"select.remove" = "მონიშნულიდან ამოღება" +"select.sameEffect" = "იგივე ეფექტის მონიშვნა" +"select.sameFill" = "იგივე შევსების მონიშვნა" +"select.sameFont" = "იგივე შრიფტის მონიშვნა" +"select.sameInstance" = "იგივე ინსტანსის მონიშვნა" +"select.sameProperties" = "იგივე თვისებების მონიშვნა" +"select.sameStroke" = "იგივე კონტურის მონიშვნა" +"select.sameText" = "ტექსტის იგივე თვისებების მონიშვნა" +"select.set" = "მონიშვნა" +"selection.applyColorStyle" = "მონიშნულის ფერის სტილის გამოყენება" +"selection.colors" = "მონიშნულის ფერები" +"selection.selectColor" = "ამ ფერის შრეების მონიშვნა" +"selection.setPaint" = "მონიშნულის ფერის შეცვლა" +"shape.arrow" = "ისარი" +"shape.ellipse" = "ელიფსი" +"shape.line" = "ხაზი" +"shape.polygon" = "მრავალკუთხედი" +"shape.rectangle" = "მართკუთხედი" +"shape.star" = "ვარსკვლავი" +"shapeBuilder.merge" = "არეების შერწყმა" +"shapeBuilder.mode" = "ფიგურების კონსტრუქტორის რეჟიმი" +"shapeBuilder.regions" = "ფიგურების კონსტრუქტორის არეები" +"shapeBuilder.remove" = "არეების წაშლა" +"stroke.add" = "კონტურის დამატება" +"stroke.brush" = "ფუნჯი" +"stroke.move" = "კონტურის გადატანა" +"stroke.remove" = "კონტურის წაშლა" +"stroke.set" = "კონტურის დაყენება" +"stroke.widthProfile" = "სიგანის პროფილი" +"style.apply" = "სტილის გამოყენება" +"style.bind" = "ცვლადის სტილში მიბმა" +"style.create" = "სტილის შექმნა" +"style.delete" = "სტილის წაშლა" +"style.detach" = "სტილის გამოყოფა" +"style.duplicate" = "სტილის დუბლირება" +"style.list" = "სტილების სია" +"style.move" = "სტილების გადატანა" +"style.renameFolder" = "სტილების საქაღალდის სახელის შეცვლა" +"style.update" = "სტილის რედაქტირება" +"text.alignCenter" = "ცენტრში სწორება" +"text.alignJustify" = "ორმხრივი სწორება" +"text.alignLeft" = "მარცხნივ სწორება" +"text.alignRight" = "მარჯვნივ სწორება" +"text.bold" = "მუქი" +"text.bulletList" = "ბურთულებიანი სია" +"text.case" = "ასოთა რეგისტრი" +"text.caseLower" = "პატარა ასოები" +"text.caseOriginal" = "საწყისი რეგისტრი" +"text.caseSmallCaps" = "პატარა მთავრული" +"text.caseTitle" = "სათაურის რეგისტრი" +"text.caseUpper" = "მთავრული" +"text.create" = "ტექსტი" +"text.decoration" = "გაფორმება" +"text.edit.begin" = "ტექსტის რედაქტირება" +"text.edit.caret" = "კურსორის დაყენება" +"text.edit.end" = "ტექსტის რედაქტირების დასრულება" +"text.edit.insert" = "ტექსტის აკრეფა" +"text.edit.key" = "ტექსტის კლავიში" +"text.edit.preedit" = "ტექსტის აწყობა (IME)" +"text.edit.selectLine" = "სტრიქონის მონიშვნა" +"text.edit.selectWord" = "სიტყვის მონიშვნა" +"text.edit.selection" = "ტექსტის მონიშვნა" +"text.endPreview" = "ტექსტის გადახედვის დასრულება" +"text.fonts" = "შრიფტების სია" +"text.italic" = "დახრილი" +"text.lineHeightDown" = "სტრიქონის სიმაღლის შემცირება" +"text.lineHeightUp" = "სტრიქონის სიმაღლის გაზრდა" +"text.missingFonts" = "მიუწვდომელი შრიფტები" +"text.link" = "ბმულის შექმნა" +"text.listIndent" = "სიის შეწევის გაზრდა" +"text.listOutdent" = "სიის შეწევის შემცირება" +"text.listStyle" = "სიის სტილი" +"text.maxLines" = "მაქს. სტრიქონები" +"text.numberList" = "დანომრილი სია" +"text.onPath" = "ტექსტი ტრაექტორიაზე" +"text.paragraphIndent" = "აბზაცის შეწევა" +"text.paragraphSpacing" = "აბზაცთა ინტერვალი" +"text.pathOptions" = "ტრაექტორიაზე ტექსტის პარამეტრები" +"text.preview" = "ტექსტის ცვლილების გადახედვა" +"text.removeFromPath" = "ტექსტის ტრაექტორიიდან მოხსნა" +"text.set" = "ტექსტის დაყენება" +"text.setFeatures" = "OpenType შესაძლებლობები" +"text.setVariations" = "ცვლადი შრიფტის ღერძები" +"text.sizeDown" = "შრიფტის ზომის შემცირება" +"text.sizeUp" = "შრიფტის ზომის გაზრდა" +"text.strikethrough" = "გადახაზული" +"text.style" = "ტექსტის დიაპაზონის სტილის მინიჭება" +"text.trackingDown" = "ასოებს შორის დაშორების შემცირება" +"text.trackingUp" = "ასოებს შორის დაშორების გაზრდა" +"text.underline" = "ხაზგასმული" +"text.weightDown" = "შრიფტის სისქის შემცირება" +"text.weightUp" = "შრიფტის სისქის გაზრდა" +"tool.arrow" = "ისარი" +"tool.comment" = "კომენტარი" +"tool.ellipse" = "ელიფსი" +"tool.frame" = "ჩარჩო" +"tool.hand" = "ხელი" +"tool.line" = "ხაზი" +"tool.move" = "გადაადგილება" +"tool.pen" = "კალამი" +"tool.pencil" = "ფანქარი" +"tool.pencilBrush" = "ფანქრის ფუნჯი" +"tool.place" = "ხელსაწყოთი განთავსება" +"tool.polygon" = "მრავალკუთხედი" +"tool.rectangle" = "მართკუთხედი" +"tool.scale" = "მასშტაბირება" +"tool.scaleAnchor" = "მასშტაბირების საყრდენი წერტილი" +"tool.section" = "სექცია" +"tool.set" = "ხელსაწყოს არჩევა" +"tool.shapeBuilder" = "ფიგურების კონსტრუქტორი" +"tool.slice" = "ნაჭერი" +"tool.star" = "ვარსკვლავი" +"tool.text" = "ტექსტი" +"transform.move" = "გადაადგილება" +"transform.moveTo" = "გადატანა" +"transform.nudge" = "მცირედ გადაწევა" +"transform.nudgeSize" = "ზომის შეცვლა მცირედ გადაწევით" +"transform.resize" = "ზომის შეცვლა" +"transform.rotate" = "შემობრუნება" +"transform.rotationOrigin" = "შემობრუნების ცენტრის გადატანა" +"transform.scale" = "მასშტაბირება" +"transform.setRotationOrigin" = "შემობრუნების ცენტრის დაყენება" +"variable.addMode" = "რეჟიმის დამატება" +"variable.bind" = "ცვლადის მიბმა" +"variable.create" = "ცვლადის შექმნა" +"variable.createCollection" = "ცვლადების კოლექციის შექმნა" +"variable.delete" = "ცვლადის წაშლა" +"variable.deleteCollection" = "კოლექციის წაშლა" +"variable.duplicate" = "ცვლადის დუბლირება" +"variable.export" = "ცვლადების ექსპორტირება" +"variable.import" = "ცვლადების იმპორტი" +"variable.list" = "ცვლადების სია" +"variable.moveMode" = "რეჟიმის გადატანა" +"variable.removeMode" = "რეჟიმის წაშლა" +"variable.renameCollection" = "კოლექციის სახელის შეცვლა" +"variable.renameMode" = "რეჟიმის სახელის შეცვლა" +"variable.resetOverride" = "ცვლადის გადაფარვის ჩამოყრა" +"variable.set" = "ცვლადის მნიშვნელობის დაყენება" +"variable.setMode" = "ცვლადის რეჟიმის დაყენება" +"variable.update" = "ცვლადის რედაქტირება" +"vector.create" = "ვექტორი" +"vector.deleteHeal" = "მონიშნულის წაშლა ხარვეზის შევსებით" +"vector.edit.addVertex" = "წერტილის დამატება" +"vector.edit.begin" = "ვექტორის რედაქტირება" +"vector.edit.bend" = "სეგმენტის მოხრა" +"vector.edit.bounds" = "მონიშნული წერტილების საზღვრები" +"vector.edit.connect" = "წერტილების შეერთება" +"vector.edit.cornerRadius" = "წერტილის კუთხის რადიუსი" +"vector.edit.cut" = "ტრაექტორიის გაჭრა" +"vector.edit.delete" = "წერტილების წაშლა" +"vector.edit.end" = "რედაქტირების დასრულება" +"vector.edit.erase" = "წაშლა" +"vector.edit.lasso" = "ლასოთი მონიშვნა" +"vector.edit.mirroring" = "სახელურების არეკვლა" +"vector.edit.moveHandles" = "სახელურების გადატანა" +"vector.edit.moveVertices" = "წერტილების გადატანა" +"vector.edit.nudge" = "წერტილების მცირედ გადაწევა" +"vector.edit.paint" = "საღებავის ვედრო" +"vector.edit.pen" = "კალმის წერტილი" +"vector.edit.penEnd" = "კალმის ტრაექტორიის დასრულება" +"vector.edit.position" = "წერტილის პოზიცია" +"vector.edit.resize" = "წერტილების ზომის შეცვლა" +"vector.edit.rotate" = "წერტილების შემობრუნება" +"vector.edit.select" = "წერტილების მონიშვნა" +"vector.edit.selectAll" = "ყველა წერტილის მონიშვნა" +"vector.edit.setHandle" = "სახელურის დაყენება" +"vector.edit.smooth" = "წერტილების მოგლუვება ან გამახვილება" +"vector.edit.state" = "ვექტორის რედაქტირების მდგომარეობა" +"vector.edit.strokeCap" = "კონტურის დაბოლოება წერტილში" +"vector.edit.strokeJoin" = "კონტურის შეერთება წერტილში" +"vector.edit.tool" = "ვექტორის რედაქტირების ხელსაწყო" +"vector.edit.transform" = "წერტილების გარდაქმნა" +"vector.edit.widthPoint" = "სიგანის წერტილი" +"vector.flatten" = "გაბრტყელება" +"vector.join" = "მონიშნულის შეერთება" +"vector.offset" = "ტრაექტორიის წანაცვლება" +"vector.outlineStroke" = "კონტურის ფიგურად გარდაქმნა" +"vector.reverse" = "ტრაექტორიის მიმართულების შებრუნება" +"vector.simplify" = "ვექტორის გამარტივება" +"vector.smoothJoin" = "მონიშნულის გლუვი შეერთება" +"version.autosave" = "ვერსიის ავტოშენახვა" +"version.delete" = "ვერსიის წაშლა" +"version.duplicate" = "ვერსიის დუბლირება" +"version.exitPreview" = "მიმდინარე ვერსიაზე დაბრუნება" +"version.list" = "ვერსიების სია" +"version.preview" = "ვერსიის ნახვა" +"version.rename" = "ვერსიის სახელის შეცვლა" +"version.restore" = "ვერსიის აღდგენა" +"view.assets" = "რესურსების პანელი" +"view.comments" = "კომენტარები" +"view.contextMenu" = "კონტექსტური მენიუს ჩვენება" +"view.design" = "დიზაინის პანელი" +"view.designPrototype" = "გადართვა დიზაინსა და პროტოტიპს შორის" +"view.devMode" = "დეველოპერის რეჟიმი" +"view.frameOutlines" = "ჩარჩოების კონტურები" +"view.get" = "ხედის მიღება" +"view.layers" = "შრეების პანელი" +"view.layoutGrids" = "განლაგების ბადეები" +"view.leftSidebar" = "მარცხენა გვერდითი ზოლის ჩვენება/დამალვა" +"view.maskOutlines" = "ნიღბების კონტურები" +"view.multiplayerCursors" = "სხვა მონაწილეების კურსორები" +"view.nextFrame" = "შემდეგი ჩარჩოს მასშტაბირება" +"view.nextPage" = "შემდეგი გვერდი" +"view.nextRegion" = "შემდეგ არეზე ფოკუსირება" +"view.outlines" = "კონტურები" +"view.outlinesHiddenLayers" = "კონტურებში დამალული შრეების ჩართვა" +"view.outlinesObjectBounds" = "კონტურებში ობიექტების საზღვრების ჩართვა" +"view.outlinesSelection" = "მონიშნულის კონტურები" +"view.pan" = "ხედის გადაადგილება" +"view.panelWidths" = "პანელების სიგანის ჩამოყრა" +"view.pixelGrid" = "პიქსელური ბადე" +"view.pixelPreview" = "პიქსელების წინასწარი ნახვა" +"view.prevFrame" = "წინა ჩარჩოს მასშტაბირება" +"view.prevPage" = "წინა გვერდი" +"view.previousRegion" = "წინა არეზე ფოკუსირება" +"view.proofSrgb" = "ეკრანზე sRGB-ის სიმულაცია" +"view.fullscreen" = "სრული ეკრანი" +"view.prototype" = "პროტოტიპის პანელი" +"view.rulers" = "სახაზავები" +"view.set" = "ხედის დაყენება" +"view.slices" = "ნაჭრები" +"view.snapGeometry" = "გეომეტრიაზე მიბმა" +"view.snapObjects" = "ობიექტებზე მიბმა" +"view.snapPixel" = "პიქსელურ ბადეზე მიბმა" +"view.themeDark" = "ბნელი თემა" +"view.themeLight" = "ღია თემა" +"view.themeSystem" = "სისტემის თემის მიყოლა" +"view.toggleUi" = "ინტერფეისის ჩვენება/დამალვა" +"view.zoom100" = "მასშტაბი 100%" +"view.zoom200" = "მასშტაბი 200%" +"view.zoom50" = "მასშტაბი 50%" +"view.zoomAt" = "მასშტაბის ხელსაწყო" +"view.zoomFit" = "ეკრანში ჩატევა" +"view.zoomIn" = "გადიდება" +"view.zoomOut" = "დაპატარავება" +"view.zoomSelection" = "მონიშნულის მასშტაბირება" +"widget.click" = "ვიჯეტზე დაწკაპუნება" +"widget.insert" = "ვიჯეტის ჩასმა" +"widget.list" = "ვიჯეტების სია" +"window.focus" = "ფანჯრის წინა პლანზე გადმოტანა" +"window.new" = "ახალი ფანჯარა" +"template.list" = "შაბლონების სია" + +[menu] +"bool.exclude" = "გამორიცხვა" +"bool.flatten" = "გაბრტყელება" +"bool.intersect" = "გადაკვეთა" +"bool.subtract" = "გამოკლება" +"bool.union" = "გაერთიანება" +"file.duplicate" = "დუბლირება" +"file.import" = "ფაილიდან იმპორტი…" +"file.rename" = "სახელის შეცვლა" +"libraries.open" = "ბიბლიოთეკების გახსნა" +"select.sameEffect" = "ეფექტი" +"select.sameFill" = "შევსება" +"select.sameFont" = "შრიფტი" +"select.sameInstance" = "ინსტანსი" +"select.sameProperties" = "თვისებები" +"select.sameStroke" = "კონტური" +"select.sameText" = "ტექსტის თვისებები" +"text.alignCenter" = "ტექსტის ცენტრში სწორება" +"text.alignJustify" = "ტექსტის ორმხრივი სწორება" +"text.alignLeft" = "ტექსტის მარცხნივ სწორება" +"text.alignRight" = "ტექსტის მარჯვნივ სწორება" +"view.assets" = "რესურსები" +"view.design" = "დიზაინი" +"view.layers" = "შრეები" +"view.outlines" = "კონტურების ჩვენება" +"view.outlinesHiddenLayers" = "დამალული შრეების ჩართვა" +"view.outlinesObjectBounds" = "ობიექტების საზღვრების ჩართვა" +"view.outlinesSelection" = "მონიშნულის კონტურების ჩვენება" +"view.prototype" = "პროტოტიპი" +"view.themeDark" = "ბნელი" +"view.themeLight" = "ღია" +"view.themeSystem" = "სისტემის მიყოლა" diff --git a/crates/ui-egui/src/i18n_tests.rs b/crates/ui-egui/src/i18n_tests.rs index d4d81d7b9..98a3ac4ee 100644 --- a/crates/ui-egui/src/i18n_tests.rs +++ b/crates/ui-egui/src/i18n_tests.rs @@ -421,6 +421,41 @@ fn japanese_korean_and_chinese_have_no_plural_forms() { } } +#[test] +fn georgian_is_a_draft_with_count_neutral_plurals() { + let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code; + for tag in ["ka", "ka-GE", "ka_GE.UTF-8"] { + assert_eq!(code("system", Some(tag)), "ka", "{tag}"); + } + let ka = LANGS.iter().position(|l| l.code == "ka").unwrap(); + assert!(LANGS[ka].draft && LANGS[ka].name == "ქართული"); + // A noun stays singular after a numeral: both forms read the same where English counts in both. + for (k, t) in LANGS[ka].ui { + if let (Text::Plural { one: Some(one), other, .. }, Some(Text::Plural { one: Some(en_one), .. })) = (t, find(english().ui, k)) { + assert!(!en_one.contains("{n}") || one == other, "ka: {k:?}"); + } + } +} + +/// Release builds embed craft-fonts' Noto Sans Georgian (`theme::install_fonts`' built-in faces), +/// since Inter and egui's fonts have no Georgian: it draws every Georgian letter the catalog uses. +#[test] +fn the_built_in_georgian_face_draws_the_georgian_catalog() { + let Some(face) = figcraft_text::find_face("Noto Sans Georgian", "Regular").filter(|f| f.family == "Noto Sans Georgian") else { + eprintln!("built without craft-fonts' Noto Sans Georgian; skipping"); + return; + }; + let ka = &LANGS[LANGS.iter().position(|l| l.code == "ka").unwrap()]; + let georgian = |c: &char| matches!(u32::from(*c), 0x10A0..=0x10FF | 0x1C90..=0x1CBF | 0x2D00..=0x2D2F); + let mut letters: BTreeSet = BTreeSet::new(); + for (_, t) in ka.ui.iter().chain(ka.commands).chain(ka.menu) { + letters.extend(forms(t).into_iter().flat_map(str::chars).filter(georgian)); + } + assert!(letters.len() > 30, "{letters:?}"); + let missing: String = letters.iter().filter(|c| !face.has_char(**c)).collect(); + assert!(missing.is_empty(), "no glyph for {missing:?}"); +} + #[test] fn chinese_reads_simplified_and_traditional_has_no_catalog() { let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code; diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index a4fa87544..dfdacd2d1 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -253,7 +253,7 @@ pub fn install_fonts(ctx: &Context, system: bool, cjk: u8) { semi.extend(fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default()); fonts.families.insert(FontFamily::Name("semibold".into()), semi); // CJK and other scripts from craft-fonts as fallbacks (Noto Sans CJK only with FIGCRAFT_EMBED_CJK). - const BUILT_IN: [&str; 4] = ["Inter", "BIZ UDPGothic", "Noto Sans CJK SC", "Noto Sans Arabic"]; + const BUILT_IN: [&str; 5] = ["Inter", "BIZ UDPGothic", "Noto Sans CJK SC", "Noto Sans Arabic", "Noto Sans Georgian"]; let lang = crate::i18n::current().map_or("en", |l| l.code); // A Chinese UI draws Han in a Chinese face: the built-in Japanese one goes after the system faces. let late = |family: &str| family == "BIZ UDPGothic" && crate::cjk::language(lang) == crate::cjk::HAN; diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 50fcd4ec3..ccf8c22ac 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,10 +1,10 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (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; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (Georgian (`ka`) 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; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **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 -Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts (machine-drafted, +Portuguese, Japanese, Korean, Simplified Chinese, Indonesian, Czech and Georgian drafts (machine-drafted, waiting for native speakers' review), plus Swedish and Turkish catalogs. This page lists the twelve key languages of the craftrules standard, then the other languages Figma ships. How to add a language: [`translating.md`](translating.md). @@ -32,7 +32,7 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v channel stay English. Accessibility names come from the same strings. - **Language preference:** Preferences ▸ Language (System, then each catalog by its own name: English, Čeština, Deutsch, Español, Español (Latinoamérica), Français, Bahasa Indonesia, 日本語, - 한국어, Português (Brasil), Русский, Svenska, Türkçe, 简体中文), applied at once without a + ქართული, 한국어, Português (Brasil), Русский, Svenska, Türkçe, 简体中文), applied at once without a restart; System takes the first of the OS's preferred languages that has a catalog (on Windows only the first preferred language counts, since that list also holds the languages added for a keyboard; macOS AppleLanguages, `LANGUAGE` then the locale variables on Linux and BSD; a regional @@ -102,7 +102,9 @@ Other languages (measured 2026-10-17): Russian (`ru`) 2,244 / 2,272 (98.8%), rev by VanTan, one/few/many integer plurals; Swedish (`sv`) 2,264 (99.6%), its command labels and menu wordings added 2026-10-17 (#599), native review welcome; Turkish (`tr`) 2,243 (98.7%), whose header says it was machine-drafted and reviewed by a native speaker; Czech (`cs`) 2,244 (98.8%), a draft -with one/few/other plurals. Figma ships none beyond the eight above. +with one/few/other plurals; Georgian (`ka`) 2,328 / 2,328 (100%, measured 2026-10-19), a draft +whose plural entries read the same for one and other (a noun stays singular after a count), its UI +text in craft-fonts' Noto Sans Georgian (built in, on the web too). Figma ships none beyond the eight above. The layer is done. Per-language hours above are agent hours for a catalog of ~1,800 entries (a machine draft, then a pass over the panels for overflow), plus each language's plural rule where diff --git a/docs/mcp.md b/docs/mcp.md index c5ffe99f5..b9f30922f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -419,7 +419,7 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: accessibility setting turns it on too). Prototype transitions still play. - `pref.autoSave {on?}`: files that have a path save themselves a few seconds after they change (off by default; the desktop app's timer does it, so scripts still call `file.save`). -- `pref.language {language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…}`: the app's interface language, applied live ("system": +- `pref.language {language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|zh-Hans|…}`: the app's interface language, applied live ("system": - `pref.language {language: system|en|es|es-419|fr|de|pt-BR|ja|ko|sv|zh-Hans|…}`: the app's interface language, applied live ("system": the OS or browser language; a language without a catalog shows English). Command ids, labels and every result stay English. diff --git a/docs/translating.md b/docs/translating.md index d8addcbc7..2d186770f 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -16,8 +16,8 @@ Simplified. The catalogs: English (`en`, the source), and Spanish (`es`, with the Latin American overlay `es-419`), French (`fr`), German (`de`), Dutch (`nl`), Brazilian Portuguese (`pt-BR`), Japanese -(`ja`), Korean (`ko`), Simplified Chinese (`zh-Hans`), Czech (`cs`), Indonesian (`id`) and Finnish -(`fi`), machine-drafted and waiting for native speakers' review. Russian (`ru`, Русский) was +(`ja`), Korean (`ko`), Simplified Chinese (`zh-Hans`), Czech (`cs`), Indonesian (`id`), Finnish +(`fi`) and Georgian (`ka`), machine-drafted and waiting for native speakers' review. Russian (`ru`, Русский) was reviewed and approved by VanTan; its catalog keeps Frame, Dev Mode and Smart animate as familiar tool names and uses « » quotes. Turkish (`tr`) was machine-drafted and reviewed by a native speaker, and Swedish (`sv`) translates every UI string, command label and menu wording. @@ -57,14 +57,18 @@ komponenta, instance, varianta, automatické rozvržení, omezení, výplň, tah proměnná, styl). Indonesian uses bare imperatives for buttons and menu items (Simpan, Hapus), the formal Anda (never kamu), keeps established design terms in English (frame, layer, component, instance, auto layout, plugin, file) and keeps a noun unchanged after a count, so its plural entries -give only `other`. +give only `other`. Georgian has no letter case (never write Mtavruli for capitals), uses verbal nouns +for commands (გვერდის დამატება) and keeps a noun singular after a numeral, so counts read +"შრე: {n}" and its plural entries give the same text for `one` and `other`. CJK text needs a CJK font, which egui and Inter lack: the desktop app adds an installed one when something on screen needs it (see `crates/ui-egui/src/cjk.rs`). The web build has no system fonts, so Japanese, Korean and Chinese appear there only in a build with craft-fonts and `FIGCRAFT_EMBED_CJK=1` (Noto Sans CJK SC, +16 MB); otherwise they are left out of the picker. A -Chinese UI puts its Chinese face first so Han takes Simplified Chinese shapes. A new CJK or other non-Latin -language also needs its script in `cjk::language`. +Chinese UI puts its Chinese face first so Han takes Simplified Chinese shapes. Georgian is drawn in +craft-fonts' Noto Sans Georgian, one of the built-in faces in `theme::install_fonts` (and kept in +web builds by `crates/text/build.rs`). A new CJK or other non-Latin language also needs its script in +`cjk::language`, or a built-in craft-fonts face. ## Adding a language @@ -84,8 +88,8 @@ language also needs its script in `cjk::language`. 4. To require full coverage, add the code to `COMPLETE` in [`crates/ui-egui/src/i18n_tests.rs`](../crates/ui-egui/src/i18n_tests.rs). Every later PR that adds a string then has to translate it into each complete language, so only Spanish is there: - French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech and Indonesian - are registered as drafts, Russian and Turkish are reviewed but not required, and a string added + French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian and + Georgian are registered as drafts, Russian and Turkish are reviewed but not required, and a string added later shows English in them until it is translated. To list what a draft lacks, add its code to `COMPLETE` locally and run the check. 5. Run the check and look at the panels in the app (longer words must fit the right panel): From 6a163721dcf5eb98f36b482aeeaa926b23646625 Mon Sep 17 00:00:00 2001 From: justinjohn0306 Date: Mon, 19 Oct 2026 17:33:29 +0530 Subject: [PATCH 069/114] M3: explicit edits of bound scalar fields detach their variable (#809) An explicit edit of a bound width, height, opacity, gap, padding, corner radius, stroke weight, arc or text field reported ok, but normalize wrote the variable's value straight back. Now the edit sticks and that field's binding is dropped, as paint edits already do. - `styles::bound_before` / `detach_edited`: compare each layer-level bound field's stored value before and after the edit (`bound_field`), so keys written unchanged keep their bindings and fields moved along with another (proportions, min/max) lose theirs. A binding the edit itself made (`boundVariables`) stays. List and component property bindings are left to their own rules. - Hooked into node.set, transform.resize, object.opacityKeys, text.style and `Session::preview` (canvas drags of the selected layers). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/arrange.rs | 2 + crates/engine/src/cmd/edit.rs | 10 +- crates/engine/src/cmd/styles.rs | 98 +++++++++++++++++++ crates/engine/src/cmd/text.rs | 2 + crates/engine/src/lib.rs | 3 + .../engine/src/variables_behaviour_tests.rs | 54 ++++++++++ docs/mcp.md | 5 +- docs/target-app-parity.md | 1 + 8 files changed, 172 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index ad6c21d12..22c9c181e 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -528,6 +528,7 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result { let (col, row) = string(p, "anchor").and_then(|a| crate::tools::anchor_of(&a)).unwrap_or((0, 0)); let (ax, ay) = (f64::from(col) / 2.0, f64::from(row) / 2.0); let doc = s.doc_mut()?; + let before = super::styles::bound_before(doc, &ids); for i in ids { let n = doc.get(i)?; let (ow, oh) = (n.width, n.height); @@ -551,6 +552,7 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result { } super::props::restore_worlds(doc, &kept)?; } + super::styles::detach_edited(doc, &before); ok() } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a4769a35c..ef63a31a7 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -931,9 +931,13 @@ pub fn opacity_from_digits(digits: &str) -> Option { fn opacity_keys(s: &mut Session, p: &Value) -> Result { let digits = req_string("object.opacityKeys", p, "digits")?; let o = opacity_from_digits(&digits).ok_or_else(|| bad("object.opacityKeys", "`digits` must be one or two digits"))?; - for i in req_targets("object.opacityKeys", s, p)? { - s.doc_mut()?.get_mut(i)?.opacity = o; + let ids = req_targets("object.opacityKeys", s, p)?; + let doc = s.doc_mut()?; + let before = super::styles::bound_before(doc, &ids); + for i in ids { + doc.get_mut(i)?.opacity = o; } + super::styles::detach_edited(doc, &before); Ok(json!({"opacity": o})) } @@ -1181,10 +1185,12 @@ fn set(s: &mut Session, p: &Value) -> Result { } } let doc = s.doc_mut()?; + let before = super::styles::bound_before(doc, &ids); for i in ids { let props = if skipped.contains(&i) { &without_layout } else { &props }; super::props::apply("node.set", doc, i, props)?; } + super::styles::detach_edited(doc, &before); if let Some(o) = pivot { for (i, q) in pinned { let d = o - doc.world_transform(i) * q; diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index adeb22506..c449cace8 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -950,6 +950,104 @@ pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTr apply_bindings_matching(doc, only, |_| true); } +/// The layers `ids` before an explicit edit, for [`detach_edited`] (only those with bindings). +pub(crate) fn bound_before(doc: &Document, ids: &[NodeId]) -> Vec> { + ids.iter().filter_map(|i| doc.node_arc(*i)).filter(|n| !n.bound.is_empty()).cloned().collect() +} + +/// An explicit edit sticks: each layer's own bound field (not a list item's) whose value the edit +/// changed drops its binding, which would otherwise write the variable's value back (#809). +/// Compared by value, so keys written unchanged keep their bindings and fields moved along with +/// another (proportions, min/max) lose theirs. +pub(crate) fn detach_edited(doc: &mut Document, before: &[std::sync::Arc]) { + for old in before { + let Some(n) = doc.node(old.id) else { continue }; + let edited: Vec = n + .bound + .keys() + // A binding the edit itself made (`boundVariables`) stays. + .filter(|f| old.bound.get(*f) == n.bound.get(*f)) + .filter(|f| super::lists::list_binding(f).is_none() && !f.starts_with(crate::components::PROP_BINDING)) + .filter(|f| bound_field(old, f) != bound_field(n, f)) + .cloned() + .collect(); + if edited.is_empty() { + continue; + } + if let Ok(n) = doc.get_mut(old.id) { + for f in edited { + n.bound.remove(&f); + } + } + } +} + +/// The stored value a layer-level binding writes, comparable (`None`: the layer has no such field). +fn bound_field(n: &Node, field: &str) -> Option { + let text = |f: &dyn Fn(&TextStyle) -> String| match &n.kind { + NodeKind::Text(t) => Some(std::iter::once(&t.style).chain(t.runs.iter().map(|r| &r.style)).map(f).collect::>().join(",")), + _ => None, + }; + let layout = n.layout.as_ref(); + let cl = &n.child_layout; + let pad = |i: usize| layout.and_then(|l| l.padding.get(i).copied()); + Some(match field { + "width" => format!("{}", n.width), + "height" => format!("{}", n.height), + "minWidth" => format!("{:?}", cl.min_w), + "maxWidth" => format!("{:?}", cl.max_w), + "minHeight" => format!("{:?}", cl.min_h), + "maxHeight" => format!("{:?}", cl.max_h), + "opacity" => format!("{}", n.opacity), + "visible" => format!("{}", n.visible), + "gap" | "columnGap" => format!("{:?}", layout.map(|l| l.gap)), + "crossGap" | "rowGap" => format!("{:?}", layout.map(|l| l.cross_gap)), + "padding" => format!("{:?}", layout.map(|l| l.padding)), + "paddingTop" => format!("{:?}", pad(0)), + "paddingRight" => format!("{:?}", pad(1)), + "paddingBottom" => format!("{:?}", pad(2)), + "paddingLeft" => format!("{:?}", pad(3)), + "paddingHorizontal" => format!("{:?}", (pad(1), pad(3))), + "paddingVertical" => format!("{:?}", (pad(0), pad(2))), + "cornerRadius" => format!("{:?}", n.corners.radii), + "topLeftRadius" => format!("{:?}", n.corners.radii.first()), + "topRightRadius" => format!("{:?}", n.corners.radii.get(1)), + "bottomRightRadius" => format!("{:?}", n.corners.radii.get(2)), + "bottomLeftRadius" => format!("{:?}", n.corners.radii.get(3)), + "strokeWeight" => format!("{:?}", (n.stroke.weight, n.stroke.sides)), + "strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" => format!("{:?}", n.stroke.sides), + "arcStart" | "arcSweep" | "arcInner" => match &n.kind { + NodeKind::Ellipse { start, sweep, inner } => format!( + "{}", + if field == "arcStart" { + start + } else if field == "arcSweep" { + sweep + } else { + inner + } + ), + _ => return None, + }, + "characters" => match &n.kind { + NodeKind::Text(t) => t.characters.clone(), + _ => return None, + }, + "paragraphSpacing" | "paragraphIndent" => match &n.kind { + NodeKind::Text(t) => format!("{}", if field == "paragraphSpacing" { t.paragraph_spacing } else { t.paragraph_indent }), + _ => return None, + }, + "fontFamily" => text(&|s| s.font_family.clone())?, + "fontStyle" => text(&|s| s.font_style.clone())?, + "fontWeight" => text(&|s| s.weight().to_string())?, + "fontSize" => text(&|s| s.font_size.to_string())?, + "lineHeight" => text(&|s| format!("{:?}", s.line_height))?, + "letterSpacing" => text(&|s| format!("{:?}", s.letter_spacing))?, + // shortcut: other layer-level fields keep their binding on edits; add a reader when one is bound in practice. + _ => return None, + }) +} + /// Imported files (`.fig`, REST) keep a solid paint's alpha in its opacity, a bound paint's too, /// while the bound colour brings its own alpha: take the variable's alpha back out of the opacity /// (styles' paints and layers') before the bindings are first resolved, so it applies once. diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 3b861895a..a10e9265b 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -364,6 +364,7 @@ pub fn specs() -> Vec { let ids = req_targets("text.style", s, p)?; let m = p.as_object().cloned().unwrap_or_default(); let doc = s.doc_mut()?; + let before = super::styles::bound_before(doc, &ids); for i in ids { let n = doc.get_mut(i)?; let NodeKind::Text(t) = &mut n.kind else { return Err(bad("text.style", format!("{i} is not text"))) }; @@ -377,6 +378,7 @@ pub fn specs() -> Vec { super::props::apply_text_style("text.style", t, a, b, k, v)?; } } + super::styles::detach_edited(doc, &before); ok() } ), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 312c2e878..29b40eaa9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1098,8 +1098,11 @@ impl Session { let Some(it) = ds.interaction.clone() else { return Err(EngineError::Other("no gesture in progress".into())) }; let mut doc = (*it.doc).clone(); let mut sel = it.selection.clone(); + // What the gesture changes on the selected layers is an explicit edit. + let before = cmd::styles::bound_before(&doc, &it.selection); let res = f(&mut doc, &mut sel); if res.is_ok() { + cmd::styles::detach_edited(&mut doc, &before); normalize(&mut doc, &it.doc); // The same preview again (a handle held still) keeps the document: a new one would // re-render the canvas for nothing. diff --git a/crates/engine/src/variables_behaviour_tests.rs b/crates/engine/src/variables_behaviour_tests.rs index 5a868a637..45fda6a59 100644 --- a/crates/engine/src/variables_behaviour_tests.rs +++ b/crates/engine/src/variables_behaviour_tests.rs @@ -365,3 +365,57 @@ fn number_variables_are_pixels_and_edits_are_one_undo_step() { s.undo().unwrap(); assert_eq!(lh(&s), LineHeight::Pixels(20.0), "the value and the bound layer go back together"); } + +#[test] +fn explicit_edits_of_bound_fields_stick_and_detach_that_binding() { + // #809: an edit of a bound field detaches its binding (as a paint edit does) instead of + // the variable's value coming back; bindings whose value the edit kept stay. + let mut s = Session::new(); + run(&mut s, "variable.createCollection", json!({"name": "C"})); + let w = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "w", "type": "number", "value": 48}))); + let o = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "o", "type": "number", "value": 40}))); + let r = rect(&mut s, None); + for (field, v) in [("width", &w), ("height", &w), ("opacity", &o)] { + run(&mut s, "variable.bind", json!({"ids": [r], "field": field, "variable": v})); + } + assert_eq!((node(&s, r).width, node(&s, r).opacity), (48.0, 0.4)); + + run(&mut s, "node.set", json!({"ids": [r], "width": 100, "opacity": 0.4})); + let n = node(&s, r); + assert_eq!(n.width, 100.0); + assert!(!n.bound.contains_key("width")); + assert!(n.bound.contains_key("height") && n.bound.contains_key("opacity"), "unchanged values keep their bindings"); + + run(&mut s, "transform.resize", json!({"ids": [r], "height": 30})); + assert_eq!(node(&s, r).height, 30.0); + assert!(!node(&s, r).bound.contains_key("height")); + + run(&mut s, "object.opacityKeys", json!({"ids": [r], "digits": "9"})); + assert_eq!(node(&s, r).opacity, 0.9); + assert!(node(&s, r).bound.is_empty()); + + // A value set with its binding in the same call keeps the new binding. + run(&mut s, "node.set", json!({"ids": [r], "width": 5, "boundVariables": {"width": w}})); + assert_eq!((node(&s, r).width, node(&s, r).bound.get("width")), (48.0, Some(&w))); +} + +#[test] +fn a_gesture_or_a_text_style_on_a_bound_field_detaches_it() { + let mut s = Session::new(); + run(&mut s, "variable.createCollection", json!({"name": "C"})); + let v = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "r", "type": "number", "value": 8}))); + let r = rect(&mut s, None); + run(&mut s, "variable.bind", json!({"ids": [r], "field": "cornerRadius", "variable": v})); + s.begin_interaction("Edit").unwrap(); + let props = serde_json::Map::from_iter([("cornerRadius".to_string(), json!(2))]); + s.preview(|d, _| crate::cmd::props::apply("node.set", d, r, &props)).unwrap(); + s.commit_interaction().unwrap(); + assert_eq!(node(&s, r).corners.radii, [2.0; 4]); + assert!(node(&s, r).bound.is_empty()); + + let t = id(run(&mut s, "text.create", json!({"x": 0, "y": 0, "text": "Hi"}))); + run(&mut s, "variable.bind", json!({"ids": [t], "field": "fontSize", "variable": v})); + run(&mut s, "text.style", json!({"id": t, "fontSize": 20})); + assert!(node(&s, t).bound.is_empty()); + assert_eq!(node(&s, t).kind.text().unwrap().style.font_size, 20.0); +} diff --git a/docs/mcp.md b/docs/mcp.md index c5ffe99f5..00f5386d8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -267,7 +267,10 @@ Or in a project's `.mcp.json`: `style.duplicate {style}` adds " copy" right below, `style.renameFolder {folder, name}`. - **Variables**: `variable.bind` refuses a variable of a type the field doesn't take (a colour on `width`); scopes only filter pickers. `variable.delete` / `variable.deleteCollection` unbind the - layers using them (they keep their last value) and give aliases to them their values. + layers using them (they keep their last value) and give aliases to them their values. An + explicit edit that changes a bound field's value (`node.set`, `transform.resize`, + `object.opacityKeys`, `text.style`, a canvas drag) detaches that field's variable; fields the edit + left as they were stay bound. `variable.addMode {from}` duplicates a mode, `variable.moveMode {collection, mode, to}` reorders (index 0 is the default mode, which layers on Auto follow). `variable.update {codeSyntax: {WEB?, iOS?, ANDROID?}}` sets code names (`""` clears); generated CSS uses the Web one. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 16a5af69b..3e1e64b02 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -638,6 +638,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | Bound scalar fields (M3, #809): an explicit edit that changes a bound width, height, min/max size, opacity, visibility, gap, padding, corner radius, stroke weight, arc or text field (`node.set`, `transform.resize`, `object.opacityKeys`, `text.style`, canvas drags) detaches that binding instead of the variable's value coming back; compared by value, so fields the edit left alone stay bound. Numbers unchanged | | 2026-10-17 | minor | Menu breadth re-run with `cargo xtask parity`: 212 / 212 (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Rows for the 2026-10-17 landings: import/export (area 14) and the file formats dimension name the `.fig` import's new fidelity (instance identity, overrides by key, text style precedence, grid tracks, slots, connector labels, per-point joins, text on a path, image adjustments, list start numbers, vertical trim), budgets from the file's size, the import report, a `.fig` copy from Save as and paste from another design app; the localization evidence takes the re-measured catalog counts (2,272 entries, drafts at 95–99%, Indonesian and Czech drafts, Swedish and Turkish catalogs); the Automation line adds control requests answered while the window is minimised (#720); batch #753's Selection colors, smart-selection spacing, Present tab and auto layout on empty frames are in ui-parity, prototyping-parity and auto-layout-parity. Numbers unchanged: every `.fig` and paste change is checked on synthetic files or clipboard HTML made from our own export, and real files are what gap 1's blocker and the file-exchange discounts wait for; the UI changes (menus, the Actions palette, editor fixes) are not compared side by side, so UI/UX stays at 58%; the localization grade and hours stay (Indonesian's draft is offset by the drafts' new gaps) | | 2026-10-17 | minor | Automation roots (M8, #715, #481): `figcraft-cli mcp --automation-read-root DIR --automation-write-root DIR` (each repeatable, existing folders) and the desktop app with the same flags confine every file a command reads to the read and write roots and every file it writes to the write roots; paths are resolved (links followed, `..`, Windows `\\?\` and UNC paths, case) and compared by whole folder names, anything else is an ordinary error. The engine reaches the file system through one module (enforced by `crates/engine/clippy.toml`), and a sweep runs every command with paths outside the roots. `--attach` refuses the flags (the app confines itself); `ui.screenshot` and `ui.render` return PNG data without a `path`, so the attached MCP server writes no temporary files. Numbers unchanged. | | 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | From 82a50dad1b2dca0a2fd64c4332c59a901af987e7 Mon Sep 17 00:00:00 2001 From: justinjohn0306 Date: Mon, 19 Oct 2026 17:53:24 +0530 Subject: [PATCH 070/114] M7: a preference to start on the Home screen (#857) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Launching opens a new empty file, which then has to be closed to get back to Home. Preferences ▸ "Start on the Home screen" (`pref.startOnHome {on?}`, off by default) starts the desktop app on Home instead. Files named on the command line and recovered files still open. Co-Authored-By: Claude Opus 5.5 --- apps/figcraft/src/main.rs | 4 ++-- crates/engine/src/cmd/view.rs | 1 + crates/engine/src/lib.rs | 3 +++ crates/ui-egui/locales/de.toml | 2 ++ crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 2 ++ crates/ui-egui/locales/fi.toml | 2 ++ crates/ui-egui/locales/fr.toml | 2 ++ crates/ui-egui/locales/ja.toml | 2 ++ crates/ui-egui/locales/ko.toml | 2 ++ crates/ui-egui/locales/nl.toml | 2 ++ crates/ui-egui/locales/pt-BR.toml | 2 ++ crates/ui-egui/locales/sv.toml | 2 ++ crates/ui-egui/locales/zh-Hans.toml | 2 ++ crates/ui-egui/src/dialogs.rs | 3 +++ docs/mcp.md | 2 ++ 16 files changed, 32 insertions(+), 2 deletions(-) diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 1bba81e81..e98f93733 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -194,9 +194,9 @@ fn main() -> eframe::Result { session.live.links = figcraft_collab_net::links::system(); session.invite_links_startup(); // A first run (no settings kept yet, nothing to open) starts on the home screen, with its - // tips and templates; later runs start in a new file. + // tips and templates; later runs start in a new file unless the preference says Home. let first_run = svc.prefs.is_none() && args.join.is_none(); - if session.docs.is_empty() && !first_run { + if session.docs.is_empty() && !first_run && !session.prefs.start_on_home { let _ = session.execute("file.new", &json!({})); } // The interface language from the first frame on, and for the message when the window can't diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 69b938e91..bdf673390 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -371,6 +371,7 @@ pub fn specs() -> Vec { spec!("pref.numberKeysOpacity", "Use number keys for opacity", "{on?}", false, |s, p| pref(s, p, |x| &mut x.number_keys_opacity)), spec!("pref.reduceMotion", "Reduce motion", "{on?}", false, |s, p| pref(s, p, |x| &mut x.reduce_motion)), spec!("pref.autoSave", "Save files automatically", "{on?}", false, |s, p| pref(s, p, |x| &mut x.auto_save)), + spec!("pref.startOnHome", "Start on the Home screen", "{on?}", false, |s, p| pref(s, p, |x| &mut x.start_on_home)), spec!("pref.nudge", "Nudge amount", "{small?, big?}", false, |s, p| { s.prefs.nudge_small = num(p, "small").unwrap_or(s.prefs.nudge_small).clamp(0.01, 1000.0); s.prefs.nudge_big = num(p, "big").unwrap_or(s.prefs.nudge_big).clamp(0.01, 10000.0); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 312c2e878..4653253f5 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -633,6 +633,8 @@ pub struct Prefs { /// Files that have a path are saved by themselves a few seconds after they change (the app's /// timer; off: only File ▸ Save writes them). pub auto_save: bool, + /// The desktop app starts on the Home screen instead of in a new file. + pub start_on_home: bool, /// `figcraft://` invite links open this app (`collab.registerLinks`); `None`: on for installed /// builds, off for development builds. pub invite_links: Option, @@ -699,6 +701,7 @@ impl Default for Prefs { user_name: cmd::comment::DEFAULT_AUTHOR.into(), reduce_motion: false, auto_save: false, + start_on_home: false, invite_links: None, plugins: Vec::new(), plugin_settings: BTreeMap::new(), diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 07669c1a7..c5d7dc7ee 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -1102,6 +1102,7 @@ status = "draft — needs native review" "Save as" = "Speichern unter" "Save as preset…" = "Als Vorgabe speichern…" "Save files automatically" = "Dateien automatisch speichern" +"Start on the Home screen" = "Mit Start beginnen" "Save version {key}" = "Version speichern {key}" "Saved" = "Gespeichert" "Saved {name}" = "{name} gespeichert" @@ -1923,6 +1924,7 @@ status = "draft — needs native review" "plugins.manage" = "Plugins verwalten…" "plugins.runLast" = "Letztes Plugin ausführen" "pref.autoSave" = "Dateien automatisch speichern" +"pref.startOnHome" = "Mit Start beginnen" "pref.highlightHover" = "Ebenen beim Überfahren hervorheben" "pref.keepTool" = "Werkzeug nach Verwendung ausgewählt lassen" "pref.language" = "Sprache der Benutzeroberfläche" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 17f6b4c5f..26f10defa 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1298,6 +1298,7 @@ name = "English" "Start from a blank canvas" = "Start from a blank canvas" "Start from a template" = "Start from a template" "Start on path" = "Start on path" +"Start on the Home screen" = "Start on the Home screen" "Start point" = "Start point" "Start sharing" = "Start sharing" "Start sharing with the Share button, then pass on the code it gives" = "Start sharing with the Share button, then pass on the code it gives" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index d054e2223..1a73816e1 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1155,6 +1155,7 @@ status = "draft — needs native review" "Run" = "Ejecutar" "Run {label}" = "Ejecutar {label}" "Save files automatically" = "Guardar archivos automáticamente" +"Start on the Home screen" = "Empezar en Inicio" "Search templates" = "Buscar plantillas" "See all templates" = "Ver todas las plantillas" "Select layers and they space themselves as their content changes" = "Selecciona capas y se espaciarán solas cuando cambie su contenido" @@ -2023,6 +2024,7 @@ status = "draft — needs native review" "plugins.manage" = "Gestionar plugins…" "plugins.runLast" = "Ejecutar el último plugin" "pref.autoSave" = "Guardar archivos automáticamente" +"pref.startOnHome" = "Empezar en Inicio" "pref.highlightHover" = "Resaltar capas al pasar el cursor" "pref.keepTool" = "Mantener la herramienta tras usarla" "pref.language" = "Idioma de la interfaz" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 602574d79..6a47a8f2e 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -1162,6 +1162,7 @@ status = "draft — needs native review" "Save as" = "Tallenna nimellä" "Save as preset…" = "Tallenna esiasetuksena…" "Save files automatically" = "Tallenna tiedostot automaattisesti" +"Start on the Home screen" = "Aloita Aloitus-näkymästä" "Save version {key}" = "Tallenna versio {key}" "Saved" = "Tallennettu" "Saved {name}" = "Tallennettu: {name}" @@ -2024,6 +2025,7 @@ status = "draft — needs native review" "plugins.manage" = "Hallitse liitännäisiä…" "plugins.runLast" = "Suorita viimeisin liitännäinen" "pref.autoSave" = "Tallenna tiedostot automaattisesti" +"pref.startOnHome" = "Aloita Aloitus-näkymästä" "pref.highlightHover" = "Korosta tasot osoitettaessa" "pref.keepTool" = "Pidä työkalu valittuna käytön jälkeen" "pref.language" = "Käyttöliittymän kieli" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 5edf1a624..947281a25 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -1157,6 +1157,7 @@ status = "draft — needs native review" "Save as" = "Enregistrer sous" "Save as preset…" = "Enregistrer comme préréglage…" "Save files automatically" = "Enregistrer les fichiers automatiquement" +"Start on the Home screen" = "Démarrer sur l’Accueil" "Save version {key}" = "Enregistrer une version {key}" "Saved" = "Enregistré" "Saved {name}" = "{name} enregistré" @@ -2012,6 +2013,7 @@ status = "draft — needs native review" "plugins.manage" = "Gérer les plugins…" "plugins.runLast" = "Exécuter le dernier plugin" "pref.autoSave" = "Enregistrer les fichiers automatiquement" +"pref.startOnHome" = "Démarrer sur l’Accueil" "pref.highlightHover" = "Mettre en évidence les calques au survol" "pref.keepTool" = "Garder l’outil sélectionné après usage" "pref.language" = "Langue de l’interface" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index def8ebc47..ed71e6719 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -1146,6 +1146,7 @@ status = "draft — needs native review" "Save as" = "名前を付けて保存" "Save as preset…" = "プリセットとして保存…" "Save files automatically" = "ファイルを自動保存" +"Start on the Home screen" = "起動時にホームを表示" "Save version {key}" = "バージョンを保存 {key}" "Saved" = "保存済み" "Saved {name}" = "{name} を保存済み" @@ -1996,6 +1997,7 @@ status = "draft — needs native review" "plugins.manage" = "プラグインを管理…" "plugins.runLast" = "最後のプラグインを実行" "pref.autoSave" = "ファイルを自動保存" +"pref.startOnHome" = "起動時にホームを表示" "pref.highlightHover" = "ホバー時にレイヤーをハイライト" "pref.keepTool" = "使用後もツールを選択したままにする" "pref.language" = "インターフェースの言語" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 3e1916233..43c9a22ea 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -1096,6 +1096,7 @@ status = "draft — needs native review" "Save as" = "다른 이름으로 저장" "Save as preset…" = "프리셋으로 저장…" "Save files automatically" = "파일 자동 저장" +"Start on the Home screen" = "홈에서 시작" "Save version {key}" = "버전 저장 {key}" "Saved" = "저장됨" "Saved {name}" = "{name} 저장됨" @@ -1914,6 +1915,7 @@ status = "draft — needs native review" "plugins.manage" = "플러그인 관리…" "plugins.runLast" = "마지막 플러그인 실행" "pref.autoSave" = "파일 자동 저장" +"pref.startOnHome" = "홈에서 시작" "pref.highlightHover" = "마우스를 올리면 레이어 강조" "pref.keepTool" = "사용 후에도 도구 선택 유지" "pref.language" = "인터페이스 언어" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 8d8cac680..0e28ad9c9 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -1162,6 +1162,7 @@ status = "draft — needs native review" "Save as" = "Opslaan als" "Save as preset…" = "Opslaan als preset…" "Save files automatically" = "Bestanden automatisch opslaan" +"Start on the Home screen" = "Starten op Home" "Save version {key}" = "Versie opslaan {key}" "Saved" = "Opgeslagen" "Saved {name}" = "{name} opgeslagen" @@ -2023,6 +2024,7 @@ status = "draft — needs native review" "plugins.manage" = "Plugins beheren…" "plugins.runLast" = "Laatste plugin uitvoeren" "pref.autoSave" = "Bestanden automatisch opslaan" +"pref.startOnHome" = "Starten op Home" "pref.highlightHover" = "Lagen markeren bij aanwijzen" "pref.keepTool" = "Gereedschap geselecteerd houden na gebruik" "pref.language" = "Interfacetaal" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index d970db144..625599f7e 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -1095,6 +1095,7 @@ status = "draft — needs native review" "Save as" = "Salvar como" "Save as preset…" = "Salvar como predefinição…" "Save files automatically" = "Salvar arquivos automaticamente" +"Start on the Home screen" = "Começar no Início" "Save version {key}" = "Salvar versão {key}" "Saved" = "Salvo" "Saved {name}" = "{name} salvo" @@ -1913,6 +1914,7 @@ status = "draft — needs native review" "plugins.manage" = "Gerenciar plugins…" "plugins.runLast" = "Executar o último plugin" "pref.autoSave" = "Salvar arquivos automaticamente" +"pref.startOnHome" = "Começar no Início" "pref.highlightHover" = "Destacar camadas ao passar o cursor" "pref.keepTool" = "Manter a ferramenta selecionada após o uso" "pref.language" = "Idioma da interface" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index d57bb25a5..3593e2cd6 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -1144,6 +1144,7 @@ name = "Svenska" "Run" = "Kör" "Run {label}" = "Kör {label}" "Save files automatically" = "Spara filer automatiskt" +"Start on the Home screen" = "Börja på Start" "Search templates" = "Sök mallar" "See all templates" = "Se alla mallar" "Select layers and they space themselves as their content changes" = "Markera lager så fördelar de avståndet själva när innehållet ändras" @@ -2004,6 +2005,7 @@ name = "Svenska" "plugins.manage" = "Hantera plugin-program…" "plugins.runLast" = "Kör senaste plugin" "pref.autoSave" = "Spara filer automatiskt" +"pref.startOnHome" = "Börja på Start" "pref.highlightHover" = "Framhäv lager vid hovring" "pref.keepTool" = "Behåll verktyget markerat efter användning" "pref.language" = "Gränssnittsspråk" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index a90c1c033..e4a52a683 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -1097,6 +1097,7 @@ status = "draft — needs native review" "Save as" = "另存为" "Save as preset…" = "存储为预设…" "Save files automatically" = "自动保存文件" +"Start on the Home screen" = "启动时显示主页" "Save version {key}" = "保存版本 {key}" "Saved" = "已保存" "Saved {name}" = "已保存 {name}" @@ -1915,6 +1916,7 @@ status = "draft — needs native review" "plugins.manage" = "管理插件…" "plugins.runLast" = "运行上一个插件" "pref.autoSave" = "自动保存文件" +"pref.startOnHome" = "启动时显示主页" "pref.highlightHover" = "悬停时高亮图层" "pref.keepTool" = "使用后保持工具选中" "pref.language" = "界面语言" diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 448501abf..5b1888a0a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -223,6 +223,9 @@ pub fn show(app: &mut FigApp, ctx: &Context) { if !cfg!(target_arch = "wasm32") && crate::widgets::checkbox(ui, prefs.auto_save, tr("Save files automatically")) { toggles.push("pref.autoSave"); } + if !cfg!(target_arch = "wasm32") && crate::widgets::checkbox(ui, prefs.start_on_home, tr("Start on the Home screen")) { + toggles.push("pref.startOnHome"); + } for c in toggles { app.run(c, json!({})); } diff --git a/docs/mcp.md b/docs/mcp.md index c5ffe99f5..90cc75f5c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -419,6 +419,8 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: accessibility setting turns it on too). Prototype transitions still play. - `pref.autoSave {on?}`: files that have a path save themselves a few seconds after they change (off by default; the desktop app's timer does it, so scripts still call `file.save`). +- `pref.startOnHome {on?}`: the desktop app starts on the Home screen instead of in a new file + (off by default; files named on the command line still open). - `pref.language {language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…}`: the app's interface language, applied live ("system": - `pref.language {language: system|en|es|es-419|fr|de|pt-BR|ja|ko|sv|zh-Hans|…}`: the app's interface language, applied live ("system": the OS or browser language; a language without a catalog shows English). Command ids, labels and From ccddaa74cd82546420119d814f1ee0fcd6a9fc1a Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:27:23 +0000 Subject: [PATCH 071/114] Clamp overflowing colour channels to 1 instead of 0 Found while testing crates/color/src/lib.rs:63 (clamp01, used by Color::new via parse_css at :155-166) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/color/src/lib.rs | 2 +- crates/color/src/tests.rs | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index d6859a510..8e9f98d92 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -61,7 +61,7 @@ impl<'de> Deserialize<'de> for Color { } fn clamp01(v: f32) -> f32 { - if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 } + if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) } } impl Default for Color { diff --git a/crates/color/src/tests.rs b/crates/color/src/tests.rs index 68a838e05..4cf875bbf 100644 --- a/crates/color/src/tests.rs +++ b/crates/color/src/tests.rs @@ -80,7 +80,7 @@ fn blend_names() { #[test] fn nan_inputs_are_sanitized() { let c = Color::new(f32::NAN, 2.0, -1.0, f32::INFINITY); - assert_eq!(c.to_rgba8(), [0, 255, 0, 0]); + assert_eq!(c.to_rgba8(), [0, 255, 0, 255]); } #[test] @@ -106,3 +106,11 @@ fn crop_defaults_to_fill_and_roundtrips() { assert!((a - b).abs() < 1e-9); } } + +#[test] +fn infinite_channels_clamp() { + let c = Color::parse_css("rgb(1e40, 0, 0)").unwrap(); + assert_eq!(c.r, 1.0); + let c = Color::new(f32::INFINITY, f32::NEG_INFINITY, f32::NAN, f32::INFINITY); + assert_eq!((c.r, c.g, c.b, c.a), (1.0, 0.0, 0.0, 1.0)); +} From d2367d1eac095bc92c3541ff5dc3aed292142cfb Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Mon, 19 Oct 2026 16:35:09 +0400 Subject: [PATCH 072/114] Web: offer Georgian only when Noto Sans Georgian is built in; ROADMAP lists the Georgian draft Without craft-fonts the web build has no Georgian face and can't read installed fonts, so the language picker and System must skip Georgian there, as they already skip Japanese, Korean and Chinese without their faces. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- ROADMAP.md | 2 +- crates/ui-egui/src/cjk.rs | 28 +++++++++++++++++++++++----- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a83c23473..bceed27a2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -113,7 +113,7 @@ native speakers' reviews of the six language drafts need people. 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 Georgian (`ka`, 100%, 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 and Indonesian drafts (Preferences ▸ +French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Georgian drafts (Preferences ▸ Language; Traditional Chinese has no catalog; CJK UI text uses an installed CJK font, loaded only when needed; adding a language: [`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping, diff --git a/crates/ui-egui/src/cjk.rs b/crates/ui-egui/src/cjk.rs index fd3ecf8cb..d280bca5a 100644 --- a/crates/ui-egui/src/cjk.rs +++ b/crates/ui-egui/src/cjk.rs @@ -69,13 +69,22 @@ pub fn language(code: &str) -> u8 { } /// Whether the UI can draw language `code`: always on desktop (a system face is found when it's -/// needed; tofu at worst), on the web only with built-in faces for its scripts. The language -/// picker and the system language skip a language it can't. +/// needed; tofu at worst), on the web only with built-in faces for its scripts (Georgian: craft-fonts' +/// Noto Sans Georgian). The language picker and the system language skip a language it can't. pub fn can_show(code: &str) -> bool { + shows(code, cfg!(target_arch = "wasm32")) +} + +/// [`can_show`] in a desktop or (`web`) a web build. +fn shows(code: &str, web: bool) -> bool { + if !web { + return true; + } + if code.split('-').next() == Some("ka") { + return figcraft_text::face_with_char('ა', &["Noto Sans Georgian"]).is_some(); + } let need = language(code); - need == 0 - || !cfg!(target_arch = "wasm32") - || samples(need, 0).iter().all(|(c, _)| figcraft_text::face_with_char(*c, &["BIZ UDPGothic", "Noto Sans CJK SC"]).is_some()) + need == 0 || samples(need, 0).iter().all(|(c, _)| figcraft_text::face_with_char(*c, &["BIZ UDPGothic", "Noto Sans CJK SC"]).is_some()) } /// A character standing for each script in `need`, with the families to look for it in, the UI @@ -133,6 +142,15 @@ mod tests { assert!(["en", "es", "es-419", "fr", "de", "pt-BR", "tr", "id"].iter().all(|c| can_show(c))); } + #[test] + fn the_web_offers_georgian_only_with_its_face() { + assert!(shows("ka", false), "desktop always lists Georgian"); + let face = figcraft_text::face_with_char('ა', &["Noto Sans Georgian"]).is_some(); + assert_eq!(shows("ka", true), face); + assert_eq!(shows("ka-GE", true), face); + assert!(shows("en", true) && shows("tr", true)); + } + #[test] fn the_ui_language_script_comes_first() { let order = |need, ui| samples(need, ui).into_iter().map(|(c, _)| c).collect::(); From df42e380f2f68259479c0f0e3182e3841627cef3 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:37:21 +0000 Subject: [PATCH 073/114] Reject non-hex characters inside invite link percent escapes Found while testing crates/collab-net/src/invite.rs:242 (percent_decode) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/collab-net/src/invite.rs | 3 +++ crates/collab-net/src/invite_tests.rs | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/crates/collab-net/src/invite.rs b/crates/collab-net/src/invite.rs index 617ad5187..4a2e11cbc 100644 --- a/crates/collab-net/src/invite.rs +++ b/crates/collab-net/src/invite.rs @@ -239,6 +239,9 @@ fn percent_decode(s: &str) -> Option { while let Some(&b) = bytes.get(i) { if b == b'%' { let hex = bytes.get(i + 1..i + 3)?; + if !hex.iter().all(u8::is_ascii_hexdigit) { + return None; + } out.push(u8::from_str_radix(std::str::from_utf8(hex).ok()?, 16).ok()?); i += 3; } else { diff --git a/crates/collab-net/src/invite_tests.rs b/crates/collab-net/src/invite_tests.rs index 02017517d..3a02f0fa0 100644 --- a/crates/collab-net/src/invite_tests.rs +++ b/crates/collab-net/src/invite_tests.rs @@ -123,3 +123,11 @@ proptest! { prop_assert_eq!(Invite::parse(&i.url()), Ok(i)); } } + +#[test] +fn plus_or_sign_inside_a_percent_escape_is_broken() { + assert!(Endpoint::parse("ws://example.org/a/?key=%41").is_ok()); + for bad in ["%+1", "%-1", "%1", "%+a"] { + assert!(Endpoint::parse(&format!("ws://example.org/a/?key={bad}")).is_err(), "{bad}"); + } +} From 258d6f61c0ad632da8c397fc02585225ff571164 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:49:19 +0200 Subject: [PATCH 074/114] Reject non-hex characters in live::parse_color Found while testing crates/engine/src/live.rs:85 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/live.rs | 2 +- crates/engine/tests/live_parse_color.rs | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 crates/engine/tests/live_parse_color.rs diff --git a/crates/engine/src/live.rs b/crates/engine/src/live.rs index 4001b1563..ed13de5f0 100644 --- a/crates/engine/src/live.rs +++ b/crates/engine/src/live.rs @@ -81,7 +81,7 @@ pub const PEOPLE: [&str; 8] = ["#0D99FF", "#9747FF", "#14AE5C", "#F24822", "#D97 /// `#RRGGBB` → (r, g, b); `None` for anything else. pub fn parse_color(s: &str) -> Option<(u8, u8, u8)> { - let hex = s.strip_prefix('#').filter(|h| h.len() == 6 && h.is_ascii())?; + let hex = s.strip_prefix('#').filter(|h| h.len() == 6 && h.bytes().all(|b| b.is_ascii_hexdigit()))?; let byte = |i: usize| hex.get(i..i + 2).and_then(|b| u8::from_str_radix(b, 16).ok()); Some((byte(0)?, byte(2)?, byte(4)?)) } diff --git a/crates/engine/tests/live_parse_color.rs b/crates/engine/tests/live_parse_color.rs new file mode 100644 index 000000000..9e28f74ec --- /dev/null +++ b/crates/engine/tests/live_parse_color.rs @@ -0,0 +1,8 @@ +use figcraft_engine::live::parse_color; + +#[test] +fn parse_color_rejects_signs_and_non_hex() { + assert_eq!(parse_color("#+1+2+3"), None); + assert_eq!(parse_color("#-1-2-3"), None); + assert_eq!(parse_color("#0D99FF"), Some((13, 153, 255))); +} From 4b12a82839907bffc221b6a8ed585eaa9927d794 Mon Sep 17 00:00:00 2001 From: YarilaFox <102650794+YarilaFox@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:43:58 +0500 Subject: [PATCH 075/114] M7: refresh FigCraft brand colors and wordmark --- crates/text/build.rs | 5 +- crates/ui-egui/src/colorpicker.rs | 2 +- crates/ui-egui/src/comments.rs | 2 +- crates/ui-egui/src/dialogs.rs | 11 ++-- crates/ui-egui/src/home.rs | 8 +-- crates/ui-egui/src/home_recents.rs | 2 +- crates/ui-egui/src/present.rs | 4 +- crates/ui-egui/src/proto_panel.rs | 2 +- crates/ui-egui/src/right.rs | 6 +-- crates/ui-egui/src/theme.rs | 83 ++++++++++++++++++++++++------ crates/ui-egui/src/toolbar.rs | 6 +-- crates/ui-egui/src/widgets.rs | 8 +-- 12 files changed, 95 insertions(+), 44 deletions(-) diff --git a/crates/text/build.rs b/crates/text/build.rs index d02defefe..f5f5096b3 100644 --- a/crates/text/build.rs +++ b/crates/text/build.rs @@ -65,9 +65,8 @@ fn craft_fonts(dir: &std::path::Path, manifest_rel: &str) -> Result Option { let del_resp = ui.interact(del, ui.id().with(("cp_preset_del", name)), Sense::click()).tip(tr("Delete preset")); let hot = resp.hovered() || del_resp.hovered(); if hot { - ui.painter().rect_filled(r, CornerRadius::same(5), crate::theme::ACCENT); + ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); } let col = if hot { Color32::WHITE } else { t.text }; ui.painter().text(pos2(r.min.x + 8.0, r.center().y), Align2::LEFT_CENTER, name, regular(11.5), col); diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 5918dfc03..2706bd8a1 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -400,7 +400,7 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { if let Some((x, y, _)) = &app.ui.comments.draft { let r = pin_rect(app.to_screen(*x, *y)); painter.rect_filled(r, BUBBLE, ACCENT); - painter.circle_filled(r.center(), 3.0, Color32::WHITE); + painter.circle_filled(r.center(), 3.0, crate::theme::ACCENT_ON); } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 448501abf..4550d1867 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3,7 +3,7 @@ use std::path::Path; -use egui::{Align2, Color32, Context, CornerRadius, RichText, Sense, Stroke, vec2}; +use egui::{Align2, Context, CornerRadius, RichText, Sense, Stroke, vec2}; use figcraft_color::ColorProfile; use figcraft_doc::{ExportFormat, ExportSetting, ExportSize, NodeId}; use figcraft_engine::cmd::file::{export_jobs, exportable_layers}; @@ -12,8 +12,7 @@ use serde_json::{Value, json}; use crate::FigApp; use crate::export_panel::{export_layers, save_one, suggested_name}; use crate::i18n::{self, tr, tr_noop, trf, trn}; -use crate::icons::{self, Icon}; -use crate::theme::{ACCENT, Tokens, regular, semibold}; +use crate::theme::{Tokens, regular, semibold}; pub const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -122,10 +121,10 @@ pub(crate) fn tab_strip(ui: &mut egui::Ui, t: &Tokens, tabs fn about_body(ui: &mut egui::Ui, t: &Tokens) { ui.horizontal(|ui| { let (r, _) = ui.allocate_exact_size(vec2(48.0, 48.0), Sense::hover()); - ui.painter().rect_filled(r, CornerRadius::same(10), ACCENT); - icons::paint(ui.painter(), r.shrink(10.0), Icon::Logo, Color32::WHITE); + crate::theme::paint_app_icon(ui, r); ui.vertical(|ui| { - ui.label(RichText::new("FigCraft").font(semibold(18.0))); + let (wordmark, _) = ui.allocate_exact_size(vec2(130.0, 23.0), Sense::hover()); + crate::theme::paint_wordmark(ui.painter(), egui::pos2(wordmark.min.x, wordmark.center().y), 18.0, t); ui.label(RichText::new(trf("Version {version}", &[("version", &VERSION)])).color(t.text_secondary)); }); }); diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 236e27861..34f351000 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -202,9 +202,9 @@ fn dropped(app: &mut FigApp, ctx: &egui::Context) { fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) { let mut y = left.min.y + 20.0; - icons::paint(ui.painter(), Rect::from_min_size(pos2(left.min.x + 16.0, y), vec2(20.0, 20.0)), Icon::Logo, ACCENT); - ui.painter().text(pos2(left.min.x + 44.0, y + 10.0), Align2::LEFT_CENTER, "FigCraft", semibold(14.0), t.text); - y += 48.0; + crate::theme::paint_app_icon(ui, Rect::from_min_size(pos2(left.min.x + 12.0, y - 4.0), vec2(32.0, 32.0))); + crate::theme::paint_wordmark(ui.painter(), pos2(left.min.x + 52.0, y + 12.0), 16.0, t); + y += 52.0; let search = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(SIDEBAR - 16.0, widgets::FIELD_H)); ui.scope_builder(egui::UiBuilder::new().max_rect(search), |ui| { let response = @@ -325,7 +325,7 @@ fn action_card(ui: &mut Ui, t: &Tokens, icon: Icon, title: &str, sub: &str) -> b card_frame(ui, t, r, &resp); let ir = Rect::from_min_size(pos2(r.min.x + 16.0, r.center().y - 16.0), vec2(32.0, 32.0)); ui.painter().rect_filled(ir, CornerRadius::same(6), ACCENT); - icons::paint(ui.painter(), ir.shrink(8.0), icon, Color32::WHITE); + icons::paint(ui.painter(), ir.shrink(8.0), icon, t.accent_on); ui.painter().text(pos2(r.min.x + 60.0, r.center().y - 8.0), Align2::LEFT_CENTER, title_shown, semibold(12.0), t.text); ui.painter().text(pos2(r.min.x + 60.0, r.center().y + 9.0), Align2::LEFT_CENTER, sub_shown, regular(11.0), t.text_secondary); resp.clicked() diff --git a/crates/ui-egui/src/home_recents.rs b/crates/ui-egui/src/home_recents.rs index aeeb0f276..708fb4dbf 100644 --- a/crates/ui-egui/src/home_recents.rs +++ b/crates/ui-egui/src/home_recents.rs @@ -739,7 +739,7 @@ fn file_icon(ui: &Ui, t: &Tokens, center: egui::Pos2, path: &str) { match FileKind::of(Path::new(path)) { FileKind::FigCraft | FileKind::Fig => { ui.painter().rect_filled(r, CornerRadius::same(4), ACCENT); - icons::paint(ui.painter(), r.shrink(3.0), Icon::Logo, Color32::WHITE); + icons::paint(ui.painter(), r.shrink(3.0), Icon::Logo, t.accent_on); } kind => { ui.painter().rect_filled(r, CornerRadius::same(4), t.field); diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index 36eceb86b..786f5c5a1 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -2234,9 +2234,9 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome) resp = resp.tip(tr("Inspect the layer under the pointer: size, colours, typography")); } if chrome.pane { - icons::paint(ui.painter(), br.shrink(5.0), *icon, t.icon); + icons::paint(ui.painter(), br.shrink(5.0), *icon, if on { t.accent_on } else { t.icon }); } else { - ui.painter().text(br.center(), Align2::CENTER_CENTER, shown, regular(11.5), text); + ui.painter().text(br.center(), Align2::CENTER_CENTER, shown, regular(11.5), if on { t.accent_on } else { text }); } if *act == BarAction::Options { let menu = crate::right::menu_popup(&resp).width(220.0).show(|ui| options_menu(p, ui, device, settings.responsive, now)); diff --git a/crates/ui-egui/src/proto_panel.rs b/crates/ui-egui/src/proto_panel.rs index 85af66c59..710bab999 100644 --- a/crates/ui-egui/src/proto_panel.rs +++ b/crates/ui-egui/src/proto_panel.rs @@ -1048,7 +1048,7 @@ fn animation_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &In let w = ui.available_width().max(200.0); let (r, rr) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click()); if rr.hovered() { - ui.painter().rect_filled(r, CornerRadius::same(5), ACCENT); + ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); } let col = if rr.hovered() { Color32::WHITE } else { t.text }; curve(ui.painter(), Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 4.0), vec2(28.0, 18.0)), *e, col, None); diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 53eaf9535..e6d2b8b9d 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -84,12 +84,12 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { // Top bar: people (you and the others in a live session), present, share. let (top, _) = ui.allocate_exact_size(vec2(w, 48.0), Sense::hover()); // As wide as its label needs (longer in some languages). - let label = ui.painter().layout_no_wrap(tr("Share").to_string(), semibold(11.5), Color32::WHITE); + let label = ui.painter().layout_no_wrap(tr("Share").to_string(), semibold(11.5), t.accent_on); let sw = (label.size().x + 24.0).max(56.0); let share = Rect::from_min_size(pos2(top.max.x - 14.0 - sw, top.center().y - 16.0), vec2(sw, 32.0)); let sresp = ui.interact(share, ui.id().with("share"), Sense::click()).tip(tr("Share: live session, export")); ui.painter().rect_filled(share, CornerRadius::same(6), if sresp.hovered() { t.accent_hover } else { ACCENT }); - ui.painter().galley(share.center() - label.size() / 2.0, label, Color32::WHITE); + ui.painter().galley(share.center() - label.size() / 2.0, label, t.accent_on); crate::live::share_popover(app, &sresp); // Play presents; its chevron opens the play menu (present, or preview in a pane). let play = Rect::from_min_size(pos2(share.min.x - 50.0, top.center().y - 14.0), vec2(42.0, 28.0)); @@ -284,7 +284,7 @@ pub fn menu_row(ui: &mut Ui, label: &str, shortcut: &str, art_w: f32, art: impl a11y::own_focus(&resp); let hovered = resp.hovered() || resp.has_focus(); if hovered { - ui.painter().rect_filled(r, CornerRadius::same(5), ACCENT); + ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); } let col = if hovered { Color32::WHITE } else { t.text }; ui.painter().galley(pos2(r.min.x + MENU_PAD, r.center().y - text.size().y / 2.0), text, col); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index a4fa87544..517f9089f 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -1,6 +1,6 @@ //! Design tokens (UI3 light and dark) and fonts. -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use egui::{Color32, Context, FontData, FontDefinitions, FontFamily, FontId, Stroke, TextStyle, Visuals}; @@ -23,6 +23,7 @@ pub struct Tokens { pub field_hover_border: Color32, pub accent: Color32, pub accent_hover: Color32, + pub accent_on: Color32, pub accent_text: Color32, pub selected_row: Color32, pub selected_row_child: Color32, @@ -44,7 +45,10 @@ pub struct Tokens { pub focus: Color32, } -pub const ACCENT: Color32 = Color32::from_rgb(0x0D, 0x99, 0xFF); +/// FigCraft's brand green, shared with the original app icon. +pub const ACCENT: Color32 = Color32::from_rgb(0x02, 0xDA, 0x96); +/// Ink on the bright green brand surface (white would not have enough contrast). +pub const ACCENT_ON: Color32 = Color32::from_rgb(0x07, 0x35, 0x2A); pub const COMPONENT: Color32 = Color32::from_rgb(0x97, 0x47, 0xFF); /// Handoff status badges (white text on these, in both themes). pub const DEV_READY: Color32 = Color32::from_rgb(0x14, 0xAE, 0x5C); @@ -71,11 +75,12 @@ impl Tokens { field: Color32::from_rgb(0xF5, 0xF5, 0xF5), field_hover_border: Color32::from_rgb(0xE6, 0xE6, 0xE6), accent: ACCENT, - accent_hover: Color32::from_rgb(0x00, 0x7B, 0xE5), - // 5.2:1 on white, 4.6:1 on selected rows: WCAG AA for text. - accent_text: Color32::from_rgb(0x00, 0x6B, 0xD1), - selected_row: Color32::from_rgb(0xE5, 0xF4, 0xFF), - selected_row_child: Color32::from_rgb(0xF2, 0xF9, 0xFF), + accent_hover: Color32::from_rgb(0x00, 0xAD, 0x78), + accent_on: ACCENT_ON, + // Darker green keeps links and labels legible on white and selected rows. + accent_text: Color32::from_rgb(0x00, 0x7A, 0x56), + selected_row: Color32::from_rgb(0xE0, 0xFB, 0xF1), + selected_row_child: Color32::from_rgb(0xF1, 0xFC, 0xF7), component: COMPONENT, component_row: Color32::from_rgb(0xF1, 0xE9, 0xFF), danger: Color32::from_rgb(0xF2, 0x48, 0x22), @@ -87,9 +92,9 @@ impl Tokens { shadow: Color32::from_black_alpha(40), menu: Color32::from_rgb(0x1E, 0x1E, 0x1E), menu_text: Color32::from_rgb(0xFF, 0xFF, 0xFF), - menu_hover: ACCENT, + menu_hover: Color32::from_rgb(0x00, 0x6B, 0x4C), menu_dim: Color32::from_rgb(0x9A, 0x9A, 0x9A), - focus: Color32::from_rgb(0x00, 0x6B, 0xD1), + focus: Color32::from_rgb(0x00, 0x7A, 0x56), } } @@ -111,10 +116,11 @@ impl Tokens { field: Color32::from_rgb(0x38, 0x38, 0x38), field_hover_border: Color32::from_rgb(0x4D, 0x4D, 0x4D), accent: ACCENT, - accent_hover: Color32::from_rgb(0x3D, 0xAD, 0xFF), - accent_text: Color32::from_rgb(0x7C, 0xC4, 0xF8), - selected_row: Color32::from_rgb(0x2A, 0x45, 0x5C), - selected_row_child: Color32::from_rgb(0x2D, 0x37, 0x41), + accent_hover: Color32::from_rgb(0x52, 0xE9, 0xB7), + accent_on: ACCENT_ON, + accent_text: Color32::from_rgb(0x52, 0xE9, 0xB7), + selected_row: Color32::from_rgb(0x16, 0x42, 0x36), + selected_row_child: Color32::from_rgb(0x12, 0x37, 0x2D), component: Color32::from_rgb(0xD2, 0x7C, 0xFF), component_row: Color32::from_rgb(0x3E, 0x30, 0x52), danger: Color32::from_rgb(0xF2, 0x48, 0x22), @@ -126,7 +132,7 @@ impl Tokens { shadow: Color32::from_black_alpha(90), menu: Color32::from_rgb(0x1E, 0x1E, 0x1E), menu_text: Color32::from_rgb(0xFF, 0xFF, 0xFF), - menu_hover: ACCENT, + menu_hover: Color32::from_rgb(0x00, 0x6B, 0x4C), menu_dim: Color32::from_rgb(0x9A, 0x9A, 0x9A), focus: ACCENT, } @@ -226,7 +232,8 @@ pub fn on_dark(ui: &mut egui::Ui, f: impl FnOnce(&mut egui::Ui) -> R) -> R { r } -/// UI fonts: Inter (from the craft-fonts build input) when present, else egui's defaults; with +/// UI fonts: Inter and the Archivo wordmark (from the craft-fonts build input) when present; +/// otherwise egui's defaults. With /// `system`, also system faces for scripts those lack (Hebrew, joined Arabic) so names in them show, /// and faces for the CJK scripts in `cjk` ([`crate::cjk`]). pub fn install_fonts(ctx: &Context, system: bool, cjk: u8) { @@ -252,6 +259,18 @@ pub fn install_fonts(ctx: &Context, system: bool, cjk: u8) { } semi.extend(fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default()); fonts.families.insert(FontFamily::Name("semibold".into()), semi); + // Keep the logo's typeface independent of UI text. System Archivo is used when available; + // releases with craft-fonts embed it so the wordmark does not depend on installation. + let archivo = find("Archivo", "SemiBold") + .map(FontData::from_static) + .or_else(|| system.then(|| figcraft_text::find_face("Archivo", "SemiBold")).flatten().map(|face| font_data(&face))); + let mut brand = Vec::new(); + if let Some(data) = archivo { + fonts.font_data.insert("archivo".into(), Arc::new(data)); + brand.push("archivo".into()); + } + brand.extend(fonts.families.get(&FontFamily::Name("semibold".into())).cloned().unwrap_or_default()); + fonts.families.insert(FontFamily::Name("brand".into()), brand); // CJK and other scripts from craft-fonts as fallbacks (Noto Sans CJK only with FIGCRAFT_EMBED_CJK). const BUILT_IN: [&str; 4] = ["Inter", "BIZ UDPGothic", "Noto Sans CJK SC", "Noto Sans Arabic"]; let lang = crate::i18n::current().map_or("en", |l| l.code); @@ -339,6 +358,40 @@ pub fn semibold(size: f32) -> FontId { FontId::new(size, FontFamily::Name("semibold".into())) } +pub fn brand(size: f32) -> FontId { + FontId::new(size, FontFamily::Name("brand".into())) +} + +/// The two-tone Archivo wordmark used in the home sidebar and About window. +pub fn paint_wordmark(painter: &egui::Painter, left_center: egui::Pos2, size: f32, t: &Tokens) { + let font = brand(size); + let fig = painter.layout_no_wrap("Fig".into(), font.clone(), t.text); + let fig_width = fig.size().x; + painter.galley(egui::pos2(left_center.x, left_center.y - fig.size().y / 2.0), fig, t.text); + painter.text(egui::pos2(left_center.x + fig_width, left_center.y), egui::Align2::LEFT_CENTER, "Craft", font, t.accent); +} + +/// Paint the owner's original chameleon icon from the existing app asset. +pub fn paint_app_icon(ui: &egui::Ui, rect: egui::Rect) { + static ICON: OnceLock> = OnceLock::new(); + let image = ICON.get_or_init(|| { + let rgba = image::load_from_memory(include_bytes!("../../../assets/app-icon/figcraft-64.png")).ok()?.to_rgba8(); + Some(egui::ColorImage::from_rgba_unmultiplied([rgba.width() as usize, rgba.height() as usize], rgba.as_raw())) + }); + let Some(image) = image else { + ui.painter().rect_filled(rect, egui::CornerRadius::same(5), ACCENT); + crate::icons::paint(ui.painter(), rect.shrink(rect.width() * 0.22), crate::icons::Icon::Logo, ACCENT_ON); + return; + }; + let id = egui::Id::new("figcraft_original_app_icon"); + let texture = ui.ctx().data(|data| data.get_temp::(id)).unwrap_or_else(|| { + let texture = ui.ctx().load_texture("figcraft-original-app-icon", image.clone(), egui::TextureOptions::LINEAR); + ui.ctx().data_mut(|data| data.insert_temp(id, texture.clone())); + texture + }); + ui.painter().image(texture.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE); +} + pub fn regular(size: f32) -> FontId { FontId::new(size, FontFamily::Proportional) } diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 6da9badb6..1902ad89a 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -149,7 +149,7 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { } else if resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); } - icons::paint(ui.painter(), r.shrink(7.0), icon, if actions_open { Color32::WHITE } else { t.icon }); + icons::paint(ui.painter(), r.shrink(7.0), icon, if actions_open { t.accent_on } else { t.icon }); if resp.clicked() { if id == "actions" { crate::menus::toggle_palette(&mut app.ui); @@ -379,7 +379,7 @@ fn tool_cell(ui: &mut Ui, t: &Tokens, r: Rect, id: impl egui::AsIdSalt, icon: Ic Color32::TRANSPARENT }; ui.painter().rect_filled(r, CornerRadius::same(5), bg); - icons::paint(ui.painter(), r.shrink(7.0), icon, if active { Color32::WHITE } else { t.icon }); + icons::paint(ui.painter(), r.shrink(7.0), icon, if active { t.accent_on } else { t.icon }); resp } @@ -396,7 +396,7 @@ fn done_button(ui: &mut Ui, t: &Tokens, x: f32, cy: f32, tip: &str) -> bool { let done = Rect::from_min_size(pos2(x + 12.0, cy - 14.0), vec2(DONE_W, 28.0)); let resp = ui.interact(done, ui.id().with("tool_done"), Sense::click()).tip(tip); ui.painter().rect_filled(done, CornerRadius::same(6), if resp.hovered() { t.accent_hover } else { ACCENT }); - ui.painter().text(done.center(), Align2::CENTER_CENTER, tr("Done"), crate::theme::semibold(11.5), Color32::WHITE); + ui.painter().text(done.center(), Align2::CENTER_CENTER, tr("Done"), crate::theme::semibold(11.5), t.accent_on); resp.clicked() } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 940d7a799..ea7f4de3b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -974,7 +974,7 @@ pub fn checkbox(ui: &mut Ui, on: bool, label: &str) -> bool { let b = Rect::from_min_size(pos2(rect.min.x, rect.center().y - 7.0), vec2(14.0, 14.0)); if on { ui.painter().rect_filled(b, CornerRadius::same(3), t.accent); - icons::paint(ui.painter(), b.shrink(1.5), Icon::Check, Color32::WHITE); + icons::paint(ui.painter(), b.shrink(1.5), Icon::Check, t.accent_on); } else { ui.painter().rect_stroke(b, CornerRadius::same(3), Stroke::new(1.0, t.border_strong), StrokeKind::Inside); } @@ -990,7 +990,7 @@ pub fn radio(ui: &mut Ui, on: bool, label: &str) -> bool { let c = pos2(rect.min.x + 7.0, rect.center().y); if on { ui.painter().circle_filled(c, 7.0, t.accent); - ui.painter().circle_filled(c, 2.5, Color32::WHITE); + ui.painter().circle_filled(c, 2.5, t.accent_on); } else { ui.painter().circle_stroke(c, 6.5, Stroke::new(1.0, if resp.hovered() { t.accent } else { t.border_strong })); } @@ -1008,8 +1008,8 @@ pub fn button(ui: &mut Ui, label: &str, primary: bool, enabled: bool) -> Respons a11y::button(&resp, label); let hot = enabled && resp.hovered(); let (fill, text) = match (primary, hot) { - (true, false) => (t.accent, Color32::WHITE), - (true, true) => (t.accent_hover, Color32::WHITE), + (true, false) => (t.accent, t.accent_on), + (true, true) => (t.accent_hover, t.accent_on), (false, false) => (Color32::TRANSPARENT, t.text), (false, true) => (t.hover, t.text), }; From 2f14eeda609ed8b41c12d9a31024b708e54db425 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 19 Oct 2026 22:09:04 +0700 Subject: [PATCH 076/114] #871: leave progress docs to the maintainer --- docs/target-app-parity.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3e1e64b02..16a5af69b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -638,7 +638,6 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| -| 2026-10-19 | minor | Bound scalar fields (M3, #809): an explicit edit that changes a bound width, height, min/max size, opacity, visibility, gap, padding, corner radius, stroke weight, arc or text field (`node.set`, `transform.resize`, `object.opacityKeys`, `text.style`, canvas drags) detaches that binding instead of the variable's value coming back; compared by value, so fields the edit left alone stay bound. Numbers unchanged | | 2026-10-17 | minor | Menu breadth re-run with `cargo xtask parity`: 212 / 212 (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Rows for the 2026-10-17 landings: import/export (area 14) and the file formats dimension name the `.fig` import's new fidelity (instance identity, overrides by key, text style precedence, grid tracks, slots, connector labels, per-point joins, text on a path, image adjustments, list start numbers, vertical trim), budgets from the file's size, the import report, a `.fig` copy from Save as and paste from another design app; the localization evidence takes the re-measured catalog counts (2,272 entries, drafts at 95–99%, Indonesian and Czech drafts, Swedish and Turkish catalogs); the Automation line adds control requests answered while the window is minimised (#720); batch #753's Selection colors, smart-selection spacing, Present tab and auto layout on empty frames are in ui-parity, prototyping-parity and auto-layout-parity. Numbers unchanged: every `.fig` and paste change is checked on synthetic files or clipboard HTML made from our own export, and real files are what gap 1's blocker and the file-exchange discounts wait for; the UI changes (menus, the Actions palette, editor fixes) are not compared side by side, so UI/UX stays at 58%; the localization grade and hours stay (Indonesian's draft is offset by the drafts' new gaps) | | 2026-10-17 | minor | Automation roots (M8, #715, #481): `figcraft-cli mcp --automation-read-root DIR --automation-write-root DIR` (each repeatable, existing folders) and the desktop app with the same flags confine every file a command reads to the read and write roots and every file it writes to the write roots; paths are resolved (links followed, `..`, Windows `\\?\` and UNC paths, case) and compared by whole folder names, anything else is an ordinary error. The engine reaches the file system through one module (enforced by `crates/engine/clippy.toml`), and a sweep runs every command with paths outside the roots. `--attach` refuses the flags (the app confines itself); `ui.screenshot` and `ui.render` return PNG data without a `path`, so the attached MCP server writes no temporary files. Numbers unchanged. | | 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | From 09427e72be1ec8214f97066046935f1931e4918c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 19 Oct 2026 22:09:05 +0700 Subject: [PATCH 077/114] #871: text style shortcuts and text.set detach the bound field they change --- crates/engine/src/cmd/text.rs | 17 +++++++++++------ crates/engine/src/variables_behaviour_tests.rs | 12 ++++++++++++ docs/mcp.md | 3 ++- 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index a10e9265b..2e7d282aa 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -18,6 +18,7 @@ fn style(s: &mut Session, p: &Value, f: impl Fn(&mut TextStyle)) -> Result Result = ids.into_iter().flat_map(|i| doc.subtree(i)).collect(); + let before = super::styles::bound_before(doc, &ids); + for d in ids { + if let Some(NodeKind::Text(t)) = doc.node_mut(d).map(|n| &mut n.kind) { + let n = t.char_count(); + t.restyle(0, n, &f); } } + super::styles::detach_edited(doc, &before); ok() } @@ -348,11 +351,13 @@ pub fn specs() -> Vec { let c = string(p, "characters").or_else(|| string(p, "text")).ok_or_else(|| bad("text.set", "`characters` required"))?; let ids = req_targets("text.set", s, p)?; let doc = s.doc_mut()?; + let before = super::styles::bound_before(doc, &ids); for i in ids { let mut m = Map::new(); m.insert("characters".into(), json!(c)); super::props::apply("text.set", doc, i, &m)?; } + super::styles::detach_edited(doc, &before); ok() }), spec!( diff --git a/crates/engine/src/variables_behaviour_tests.rs b/crates/engine/src/variables_behaviour_tests.rs index 45fda6a59..d1ac8498c 100644 --- a/crates/engine/src/variables_behaviour_tests.rs +++ b/crates/engine/src/variables_behaviour_tests.rs @@ -418,4 +418,16 @@ fn a_gesture_or_a_text_style_on_a_bound_field_detaches_it() { run(&mut s, "text.style", json!({"id": t, "fontSize": 20})); assert!(node(&s, t).bound.is_empty()); assert_eq!(node(&s, t).kind.text().unwrap().style.font_size, 20.0); + + // The font size/weight/tracking shortcuts and text content edits detach too. + run(&mut s, "variable.bind", json!({"ids": [t], "field": "fontSize", "variable": v})); + assert_eq!(node(&s, t).kind.text().unwrap().style.font_size, 8.0); + run(&mut s, "text.sizeUp", json!({"ids": [t]})); + assert_eq!(node(&s, t).kind.text().unwrap().style.font_size, 9.0); + assert!(node(&s, t).bound.is_empty()); + let c = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "c", "type": "string", "value": "Hello"}))); + run(&mut s, "variable.bind", json!({"ids": [t], "field": "characters", "variable": c})); + run(&mut s, "text.set", json!({"id": t, "characters": "Bye"})); + assert_eq!(node(&s, t).kind.text().unwrap().characters, "Bye"); + assert!(node(&s, t).bound.is_empty()); } diff --git a/docs/mcp.md b/docs/mcp.md index 00f5386d8..520f20997 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -269,7 +269,8 @@ Or in a project's `.mcp.json`: `width`); scopes only filter pickers. `variable.delete` / `variable.deleteCollection` unbind the layers using them (they keep their last value) and give aliases to them their values. An explicit edit that changes a bound field's value (`node.set`, `transform.resize`, - `object.opacityKeys`, `text.style`, a canvas drag) detaches that field's variable; fields the edit + `object.opacityKeys`, `text.style`, `text.set`, the text size, weight and + tracking shortcuts, a canvas drag) detaches that field's variable; fields the edit left as they were stay bound. `variable.addMode {from}` duplicates a mode, `variable.moveMode {collection, mode, to}` reorders (index 0 is the default mode, which layers on Auto follow). `variable.update {codeSyntax: {WEB?, From 0008fc79012182209866d52415d37c1be22ec279 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 19 Oct 2026 22:18:15 +0700 Subject: [PATCH 078/114] #878: move the parse_color test into live_tests --- crates/engine/src/live_tests.rs | 8 ++++++++ crates/engine/tests/live_parse_color.rs | 8 -------- 2 files changed, 8 insertions(+), 8 deletions(-) delete mode 100644 crates/engine/tests/live_parse_color.rs diff --git a/crates/engine/src/live_tests.rs b/crates/engine/src/live_tests.rs index cacc4f378..5cdccf698 100644 --- a/crates/engine/src/live_tests.rs +++ b/crates/engine/src/live_tests.rs @@ -507,3 +507,11 @@ fn hosting_commands_check_what_they_are_given() { assert!(e.contains("join a file"), "{e}"); assert!(s.execute("collab.report", &json!({"reason": " \n "})).unwrap_err().to_string().contains("few words")); } + +#[test] +fn parse_color_rejects_signs_and_non_hex() { + use crate::live::parse_color; + assert_eq!(parse_color("#+1+2+3"), None); + assert_eq!(parse_color("#-1-2-3"), None); + assert_eq!(parse_color("#0D99FF"), Some((13, 153, 255))); +} diff --git a/crates/engine/tests/live_parse_color.rs b/crates/engine/tests/live_parse_color.rs deleted file mode 100644 index 9e28f74ec..000000000 --- a/crates/engine/tests/live_parse_color.rs +++ /dev/null @@ -1,8 +0,0 @@ -use figcraft_engine::live::parse_color; - -#[test] -fn parse_color_rejects_signs_and_non_hex() { - assert_eq!(parse_color("#+1+2+3"), None); - assert_eq!(parse_color("#-1-2-3"), None); - assert_eq!(parse_color("#0D99FF"), Some((13, 153, 255))); -} From 5738ce673c890541b519af4e3f41b48009fec98b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 19 Oct 2026 22:20:37 +0700 Subject: [PATCH 079/114] #839: mcp reuses roots_of for its automation roots --- apps/figcraft-cli/src/main.rs | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs index d9f757d0f..badc5bc30 100644 --- a/apps/figcraft-cli/src/main.rs +++ b/apps/figcraft-cli/src/main.rs @@ -56,18 +56,17 @@ See docs/cli.md and docs/mcp.md."; /// unconfined, as before; giving either confines every file the subcommand touches (#833). const ROOT_FLAGS: [&str; 2] = ["automation-read-root", "automation-write-root"]; -fn roots_of(a: &Args) -> CliResult>> { +fn roots_of(a: &Args) -> CliResult> { let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root")); if read.is_empty() && write.is_empty() { return Ok(None); } - let roots = figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?; - Ok(Some(std::sync::Arc::new(roots))) + Ok(Some(figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?)) } /// Runs `f` confined to the roots `a` carries. fn confined(a: &Args, f: impl FnOnce() -> CliResult) -> CliResult { - let roots = roots_of(a)?; + let roots = roots_of(a)?.map(std::sync::Arc::new); figcraft_engine::file_access::confined(roots.as_ref(), f) } @@ -163,23 +162,20 @@ fn run(argv: &[String]) -> CliResult { Ok(ExitCode::SUCCESS) } "mcp" => { - let a = Args::parse(rest, &["attach", "automation-read-root", "automation-write-root"], &[])?; + let valued = [&["attach"][..], &ROOT_FLAGS[..]].concat(); + let a = Args::parse(rest, &valued, &[])?; if !a.positional.is_empty() { return Err(CliError::Usage("mcp takes no positional arguments".into())); } - let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root")); - let roots = match (read.is_empty() && write.is_empty(), a.value("attach")) { - (true, _) => None, - // The app runs the commands: only its own roots can confine them. - (false, Some(_)) => { - return Err(CliError::Usage( - "--attach can't take automation roots: start the app with them instead (`figcraft --control PORT --automation-read-root DIR ...`)" - .into(), - )); - } - (false, None) => Some(figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?), - }; - mcp(a.value("attach").as_deref(), roots) + let attach = a.value("attach"); + // The app runs the commands: only its own roots can confine them. + if attach.is_some() && ROOT_FLAGS.iter().any(|f| !a.values(f).is_empty()) { + return Err(CliError::Usage( + "--attach can't take automation roots: start the app with them instead (`figcraft --control PORT --automation-read-root DIR ...`)" + .into(), + )); + } + mcp(attach.as_deref(), roots_of(&a)?) } "plugin" => { let a = Args::parse(rest, &["server"], &[])?; From bafc814712761919efbba4ae80ff473650d84a46 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 19 Oct 2026 22:22:32 +0700 Subject: [PATCH 080/114] #830: drop the progress-doc edit (docs/hardware-parity.md) --- docs/hardware-parity.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index bc0de68dc..df8229818 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-18 · **Change:** minor (the web build opens files: File ▸ Open, Import and Place image through the browser's file picker, dropped files read (#759); macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out @@ -43,14 +43,13 @@ Nothing has been timed against Figma on the same document (needs `.fig` import). | 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 | | 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) | | FreeBSD | none | tarball | | -| Web | primary (figma.com) | `figcraft-web` (WASM, WebGPU/WebGL2), web zip | no accessibility tree; file access through the browser: File ▸ Open, Import and Place image use its file picker, dropped files are read (a drop on the home screen opens, a drop on the canvas imports, as on the desktop), saving and exports are downloads; pickers for libraries, variables, plugin manifests and image fills still need a path and do nothing there; copy and paste through the system clipboard (copies written from the copy event, pastes read from the paste event; no pasting images from other apps) | +| 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; no pasting images from other apps) | | iOS / Android | apps: view, comment, mirror prototypes | none (the web build is not touch-first) | 20–40 h for a viewer | ## Revision history | Date | Change | Summary | |---|---|---| -| 2026-10-18 | minor | Opening files in the web build (#759): File ▸ Open, Import and Place image did nothing there (the pickers return a path, which a browser never has) and dropped files were ignored (eframe hands their contents over asynchronously). A platform can now pick a file's name and contents (`Services::pick_open_contents`; the browser's ``), which `file.open` and `file.import` take as `data` and `name`, and a dropped file without a path waits behind the opening card until its contents arrive, or says it couldn't be read (`async_dialog_tests`, `home_tests`; the browser half built for wasm, not run in a browser). Numbers unchanged | | 2026-10-18 | minor | macOS quit crash (#781): on Macs with a Touch Bar the app could crash as it quit (an exception in AppKit's Touch Bar support while the window's content view, which the accessibility adapter subclasses, was torn down). Quitting now keeps everything (preferences, this run's recovery copies, live sessions ended) and then ends the process before that teardown. Quit from the menu bar ends the app without the unsaved-changes question, so unsaved files' recovery copies now stay and reopen at the next start instead of being removed. Numbers unchanged | | 2026-10-17 | minor | Linux row: clipboard reads on Linux and the BSDs time out after 2 s on a helper thread, images copied in other apps paste under Wayland (arboard's data-control feature, #693), a minimised window can't be detected under Wayland, so frame-only control requests may still wait there (#720), and Wayland compositors without server-side decorations give the window Adwaita controls (#592, checked by its author on GNOME, not by the maintainers; KDE not run); compiled in CI, not run on a Linux desktop. HiDPI row: native-density canvas images placed on whole device pixels (#359, geometry tests; not judged by eye). Numbers unchanged | | 2026-10-14 | minor | Copy and paste in the browser: the web build never wrote to the system clipboard, so Cmd+V after Cmd+C pasted whatever text the clipboard held before (as a text layer) instead of the copied layers. Copies (layers, Copy as SVG/CSS/text/PNG, links) now go out through egui's output, which eframe writes during the copy event (`web_clipboard_tests`). Numbers unchanged. | From b744ff3e145353ee271d2c792f575ef58fce5f94 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 19 Oct 2026 22:39:23 +0700 Subject: [PATCH 081/114] #830: cap files read in the browser at 512 MiB, and say why a file isn't opened --- apps/figcraft-web/src/web.rs | 25 ++++---- crates/ui-egui/locales/cs.toml | 1 + crates/ui-egui/locales/de.toml | 1 + crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/locales/fi.toml | 1 + crates/ui-egui/locales/fr.toml | 1 + crates/ui-egui/locales/id.toml | 1 + crates/ui-egui/locales/ja.toml | 1 + crates/ui-egui/locales/ko.toml | 1 + crates/ui-egui/locales/nl.toml | 1 + crates/ui-egui/locales/pt-BR.toml | 1 + crates/ui-egui/locales/ru.toml | 1 + crates/ui-egui/locales/sv.toml | 1 + crates/ui-egui/locales/tr.toml | 1 + crates/ui-egui/locales/zh-Hans.toml | 1 + crates/ui-egui/src/async_dialog_tests.rs | 32 +++++++++- crates/ui-egui/src/canvas.rs | 2 +- crates/ui-egui/src/home.rs | 7 ++- crates/ui-egui/src/home_tests.rs | 11 ++-- crates/ui-egui/src/lib.rs | 74 ++++++++++++++++++------ crates/ui-egui/src/pending_open.rs | 13 ++--- 22 files changed, 136 insertions(+), 43 deletions(-) diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index dd767116a..f08ca90f2 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -147,12 +147,10 @@ impl Services for WebServices { } } -/// The largest file read from the picker (browsers read at most 4 GiB into one buffer). -const MAX_PICKED_BYTES: f64 = u32::MAX as f64; - /// The file picked in the browser's file picker (`accept`: the extensions it offers), with its -/// name; `None` when the picker is cancelled. -async fn pick_file(accept: &str) -> Result)>, wasm_bindgen::JsValue> { +/// name; `None` when the picker is cancelled, `Some(Err)` with what to tell the user when the file +/// can't be read. +async fn pick_file(accept: &str) -> Result), String>>, wasm_bindgen::JsValue> { let window = web_sys::window().ok_or("no window")?; let document = window.document().ok_or("no document")?; let body = document.body().ok_or("no body")?; @@ -169,15 +167,22 @@ async fn pick_file(accept: &str) -> Result)>, wasm_bindg drop(picker); result?; let Some(file) = file else { return Ok(None) }; - if file.size() > MAX_PICKED_BYTES { - return Err(format!("{} is too large to read in the browser", file.name()).into()); + let name = file.name(); + if let Err(why) = figcraft_ui_egui::browser_can_read(&name, file.size()) { + return Ok(Some(Err(why))); } - let buffer = wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await?; - Ok(Some((file.name(), js_sys::Uint8Array::new(&buffer).to_vec()))) + Ok(Some(match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await { + Ok(buffer) => Ok((name, js_sys::Uint8Array::new(&buffer).to_vec())), + Err(e) => { + log::error!("couldn't read {name}: {e:?}"); + Err(figcraft_ui_egui::unreadable(&name)) + } + })) } /// How long after the page gets focus back the file picker counts as closed (milliseconds): a -/// picked file's `change` can come just after the focus, and must win. +/// picked file's `change` can come just after the focus, and must win. A `change` later than this +/// (a slow cloud-backed file in a browser that never sends `cancel`) counts as a cancel. const PICKER_FOCUS_SETTLE_MS: i32 = 1_000; /// Settles a file picker's promise once: on `change` (a file was picked), on `cancel` (closed diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 980513149..8ef219660 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -1629,6 +1629,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "Zapomenuto v tomto počítači: {name}; soubor zůstává na serveru {server}" "{name} is on the server." = "{name} je na serveru." "{name} is spotlighting" = "{name} sdílí pohled" +"{name} is too large to open in the browser (over 512 MB)." = "Soubor {name} je příliš velký na otevření v prohlížeči (více než 512 MB)." "{name}\nClick to apply to the selection" = "{name}\nKliknutím použijete na výběr" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nPřetáhněte na plátno nebo kliknutím umístěte doprostřed zobrazení.\nPři puštění na instanci podržte Alt (Option) a vyměňte ji." "{name} (from {library})" = "{name} (z {library})" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 07669c1a7..2294e82d4 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -1583,6 +1583,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "{name} wurde auf diesem Computer vergessen; die Datei bleibt auf {server}" "{name} is on the server." = "{name} liegt auf dem Server." "{name} is spotlighting" = "{name} hat das Spotlight" +"{name} is too large to open in the browser (over 512 MB)." = "{name} ist zu groß, um im Browser geöffnet zu werden (über 512 MB)." "{name} was removed from the server" = "{name} wurde vom Server entfernt" "{name} · deleted on main" = "{name} · in der Hauptdatei gelöscht" "{name} · deleted on the branch" = "{name} · im Branch gelöscht" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 5d5b18563..9a7aba634 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1668,6 +1668,7 @@ name = "English" "{name} is forgotten on this computer; the file stays on {server}" = "{name} is forgotten on this computer; the file stays on {server}" "{name} is on the server." = "{name} is on the server." "{name} is spotlighting" = "{name} is spotlighting" +"{name} is too large to open in the browser (over 512 MB)." = "{name} is too large to open in the browser (over 512 MB)." "{name}\nClick to apply to the selection" = "{name}\nClick to apply to the selection" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." "{name} (from {library})" = "{name} (from {library})" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 56b6d29c2..b4c7c3352 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1669,6 +1669,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "{name} se olvidó en este ordenador; el archivo se queda en {server}" "{name} is on the server." = "{name} está en el servidor." "{name} is spotlighting" = "{name} está en el foco" +"{name} is too large to open in the browser (over 512 MB)." = "{name} es demasiado grande para abrirlo en el navegador (más de 512 MB)." "{name}\nClick to apply to the selection" = "{name}\nHaz clic para aplicarlo a la selección" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nArrastra al lienzo o haz clic para colocarlo en el centro de la vista.\nMantén Alt (Opción) al soltar sobre una instancia para intercambiarla." "{name} (from {library})" = "{name} (de {library})" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 602574d79..2521e46cb 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -1676,6 +1676,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "{name} on unohdettu tällä tietokoneella. Tiedosto jää palvelimelle {server}." "{name} is on the server." = "{name} on palvelimella." "{name} is spotlighting" = "{name} nostaa näkymäänsä esiin" +"{name} is too large to open in the browser (over 512 MB)." = "{name} on liian suuri avattavaksi selaimessa (yli 512 Mt)." "{name} was removed from the server" = "{name} poistettiin palvelimelta" "{name} · deleted on main" = "{name} · poistettu päähaarassa" "{name} · deleted on the branch" = "{name} · poistettu haarassa" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 5edf1a624..1faff4c3a 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -1665,6 +1665,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "{name} est oublié sur cet ordinateur ; le fichier reste sur {server}" "{name} is on the server." = "{name} est sur le serveur." "{name} is spotlighting" = "{name} est sous les projecteurs" +"{name} is too large to open in the browser (over 512 MB)." = "{name} est trop volumineux pour être ouvert dans le navigateur (plus de 512 Mo)." "{name} was removed from the server" = "{name} a été retiré du serveur" "{name} · deleted on main" = "{name} · supprimé dans le principal" "{name} · deleted on the branch" = "{name} · supprimé dans la branche" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 65a9939cb..705484d89 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -1633,6 +1633,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "{name} dilupakan di komputer ini; file tetap ada di {server}" "{name} is on the server." = "{name} ada di server." "{name} is spotlighting" = "{name} sedang spotlight" +"{name} is too large to open in the browser (over 512 MB)." = "{name} terlalu besar untuk dibuka di browser (lebih dari 512 MB)." "{name}\nClick to apply to the selection" = "{name}\nKlik untuk menerapkan ke pilihan" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nSeret ke kanvas, atau klik untuk menaruh di tengah tampilan.\nTahan Alt (Option) saat melepas di atas instance untuk menukarnya." "{name} (from {library})" = "{name} (dari {library})" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index def8ebc47..069eb09ac 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -1650,6 +1650,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "{name} の記録をこのコンピューターから消去済み。ファイルは {server} に残る" "{name} is on the server." = "{name} はサーバーにある。" "{name} is spotlighting" = "{name} がスポットライト中" +"{name} is too large to open in the browser (over 512 MB)." = "{name} は大きすぎるため、ブラウザーで開けません(512 MB 超)。" "{name} was removed from the server" = "{name} をサーバーから削除済み" "{name} · deleted on main" = "{name} · メインで削除" "{name} · deleted on the branch" = "{name} · ブランチで削除" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 3e1916233..e3d8bae8a 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -1574,6 +1574,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "{name}의 기록을 이 컴퓨터에서 지움. 파일은 {server}에 남음" "{name} is on the server." = "{name}이(가) 서버에 있음." "{name} is spotlighting" = "{name} 님이 스포트라이트 중" +"{name} is too large to open in the browser (over 512 MB)." = "{name}은(는) 너무 커서 브라우저에서 열 수 없습니다(512MB 초과)." "{name} was removed from the server" = "{name}을(를) 서버에서 제거함" "{name} · deleted on main" = "{name} · 메인에서 삭제됨" "{name} · deleted on the branch" = "{name} · 브랜치에서 삭제됨" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 8d8cac680..7a8b41f48 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -1673,6 +1673,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "{name} is op deze computer vergeten; het bestand blijft op {server}" "{name} is on the server." = "{name} staat op de server." "{name} is spotlighting" = "{name} staat in de spotlight" +"{name} is too large to open in the browser (over 512 MB)." = "{name} is te groot om in de browser te openen (meer dan 512 MB)." "{name} was removed from the server" = "{name} is van de server verwijderd" "{name} · deleted on main" = "{name} · verwijderd op main" "{name} · deleted on the branch" = "{name} · verwijderd op de branch" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index d970db144..1fdbd1a3f 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -1573,6 +1573,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "{name} foi esquecido neste computador; o arquivo continua em {server}" "{name} is on the server." = "{name} está no servidor." "{name} is spotlighting" = "{name} está em destaque" +"{name} is too large to open in the browser (over 512 MB)." = "{name} é grande demais para abrir no navegador (mais de 512 MB)." "{name} was removed from the server" = "{name} foi removido do servidor" "{name} · deleted on main" = "{name} · excluído no principal" "{name} · deleted on the branch" = "{name} · excluído na branch" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 8d371cbed..17e50e778 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -1635,6 +1635,7 @@ name = "Русский" "{name} is forgotten on this computer; the file stays on {server}" = "{name} забыт на этом компьютере; файл остаётся на {server}" "{name} is on the server." = "{name} находится на сервере." "{name} is spotlighting" = "{name} показывает экран всем" +"{name} is too large to open in the browser (over 512 MB)." = "Файл {name} слишком велик для открытия в браузере (больше 512 МБ)." "{name}\nClick to apply to the selection" = "{name}\nНажмите, чтобы применить к выделению" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nПеретащите на холст или нажмите, чтобы разместить в центре вида.\nУдерживайте Alt (Option) при переносе на экземпляр, чтобы заменить его." "{name} (from {library})" = "{name} (из {library})" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index d57bb25a5..c57f2f6aa 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -1650,6 +1650,7 @@ name = "Svenska" "{name} is forgotten on this computer; the file stays on {server}" = "{name} har glömts på den här datorn; filen finns kvar på {server}" "{name} is on the server." = "{name} finns på servern." "{name} is spotlighting" = "{name} har spotlight" +"{name} is too large to open in the browser (over 512 MB)." = "{name} är för stor för att öppnas i webbläsaren (över 512 MB)." "{name}\nClick to apply to the selection" = "{name}\nKlicka för att använda på markeringen" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nDra till arbetsytan, eller klicka för att placera i mitten av vyn.\nHåll ned Alt (Option) när du släpper på en instans för att byta ut den." "{name} (from {library})" = "{name} (från {library})" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index a9da62ef1..83b63c8cc 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -1633,6 +1633,7 @@ name = "Türkçe" "{name} is forgotten on this computer; the file stays on {server}" = "{name} bu bilgisayarda unutuldu; dosya {server} üzerinde kalır" "{name} is on the server." = "{name} sunucuda." "{name} is spotlighting" = "{name} öne çıkarıyor" +"{name} is too large to open in the browser (over 512 MB)." = "{name}, tarayıcıda açılamayacak kadar büyük (512 MB'tan fazla)." "{name}\nClick to apply to the selection" = "{name}\nSeçime uygulamak için tıklayın" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nTuvale sürükleyin veya görünümün ortasına yerleştirmek için tıklayın.\nBir örneği değiştirmek için üzerine bırakırken Alt (Option) tuşunu basılı tutun." "{name} (from {library})" = "{name} ({library} kitaplığından)" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index a90c1c033..57f2d44ba 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -1568,6 +1568,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "已在这台电脑上忘记 {name};文件仍保留在 {server} 上" "{name} is on the server." = "{name} 已在服务器上。" "{name} is spotlighting" = "{name} 正在聚焦" +"{name} is too large to open in the browser (over 512 MB)." = "{name} 太大,无法在浏览器中打开(超过 512 MB)。" "{name}\nClick to apply to the selection" = "{name}\n点击应用于所选项" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\n拖到画布上,或点击放置在视图中央。\n拖放到实例上时按住 Alt(Option)可替换该实例。" "{name} (from {library})" = "{name}(来自 {library})" diff --git a/crates/ui-egui/src/async_dialog_tests.rs b/crates/ui-egui/src/async_dialog_tests.rs index ad5dcfbc8..f38ac8242 100644 --- a/crates/ui-egui/src/async_dialog_tests.rs +++ b/crates/ui-egui/src/async_dialog_tests.rs @@ -87,10 +87,40 @@ impl Services for Contents { panic!("a platform that picks contents is never asked for a path") } fn pick_open_contents(&mut self, _title: &str, _exts: &[&str]) -> Option { - Some(Box::pin(std::future::ready(Some((self.0.to_string(), self.1.clone()))))) + Some(Box::pin(std::future::ready(Some(Ok((self.0.to_string(), self.1.clone())))))) } } +/// The browser, with a picked file it won't read. +struct Unreadable(String); + +impl Services for Unreadable { + fn pick_open_contents(&mut self, _title: &str, _exts: &[&str]) -> Option { + Some(Box::pin(std::future::ready(Some(Err(self.0.clone()))))) + } +} + +#[test] +fn a_picked_file_that_cannot_be_read_says_why_and_opens_nothing() { + let why = crate::browser_can_read("Huge.fig", 600.0 * 1024.0 * 1024.0).unwrap_err(); + let mut app = FigApp::new(Session::empty(), Box::new(Unreadable(why.clone()))); + app.open_dialog(); + app.poll_dialog(&egui::Context::default()); + assert!(app.session.docs.is_empty()); + assert_eq!(app.ui.last_error.as_deref(), Some(why.as_str())); + assert!(why.contains("Huge.fig"), "{why}"); +} + +#[test] +fn the_browser_reads_files_up_to_512_mib() { + let max = (512u64 << 20) as f64; + assert!(crate::browser_can_read("a", 0.0).is_ok()); + assert!(crate::browser_can_read("a", max).is_ok()); + assert!(crate::browser_can_read("a", max + 1.0).is_err()); + assert!(crate::browser_can_read("a", f64::NAN).is_err()); + assert!(crate::browser_can_read("a", f64::INFINITY).is_err()); +} + #[test] fn open_dialog_opens_picked_contents_as_a_new_unsaved_document() { let bytes = figcraft_format::save_bytes(&figcraft_doc::Document::new("Picked"), None).unwrap(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ce003f66c..fba8be683 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -510,7 +510,7 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { let (params, read) = if path.exists() { (json!({"path": path.to_string_lossy(), "x": pt.x, "y": pt.y}), None) } else { - (json!({"name": name, "x": pt.x, "y": pt.y}), Some(crate::dropped_bytes(&f))) + (json!({"name": name, "x": pt.x, "y": pt.y}), Some(crate::dropped_bytes(&f, &name))) }; let Some(target) = app.session.ds().ok().map(|doc| crate::pending_open::ImportTarget { uid: doc.uid, page: doc.page, name: doc.doc.name.clone() }) diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 0d372d1bd..f945b27d0 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -187,8 +187,11 @@ fn dropped(app: &mut FigApp, ctx: &egui::Context) { } // `opens` saw an extension, so there is a file name to tell the format by. let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - let (params, read) = - if path.exists() { (json!({"path": path.to_string_lossy()}), None) } else { (json!({"name": name}), Some(crate::dropped_bytes(&f))) }; + let (params, read) = if path.exists() { + (json!({"path": path.to_string_lossy()}), None) + } else { + (json!({"name": name}), Some(crate::dropped_bytes(&f, &name))) + }; app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.open", target: None, read }); } } diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs index ea812ea09..0606c2e5d 100644 --- a/crates/ui-egui/src/home_tests.rs +++ b/crates/ui-egui/src/home_tests.rs @@ -573,9 +573,12 @@ fn dropping_a_file_without_one_on_disk_opens_it_as_a_new_unsaved_document() { assert!(!path.exists(), "nothing is written"); } -/// A drop whose contents arrive later, as every drop in the browser does: `None` until `arrived` +/// What a drop's read gives, once it has arrived. +type Arrival = std::rc::Rc, String>>>>; + +/// A drop whose contents arrive later, as every drop in the browser does: pending until `arrived` /// holds them. -fn later(arrived: &std::rc::Rc>>>>) -> crate::ReadFuture { +fn later(arrived: &Arrival) -> crate::ReadFuture { let arrived = arrived.clone(); Box::pin(std::future::poll_fn(move |_| match arrived.borrow_mut().take() { Some(bytes) => std::task::Poll::Ready(bytes), @@ -603,7 +606,7 @@ fn a_dropped_file_whose_contents_arrive_later_opens_once_they_do() { assert!(has_text(&s, "Browser drop.svg"), "the card shows while the contents are read"); } assert!(u.app.session.docs.is_empty(), "nothing opens before the contents arrive"); - *arrived.borrow_mut() = Some(Some(svg)); + *arrived.borrow_mut() = Some(Ok(svg)); for _ in 0..3 { u.frame(vec![]); } @@ -617,7 +620,7 @@ fn a_dropped_file_whose_contents_arrive_later_opens_once_they_do() { #[test] fn a_dropped_file_that_cannot_be_read_says_so_and_opens_nothing() { - let arrived = std::rc::Rc::new(std::cell::RefCell::new(Some(None))); + let arrived = std::rc::Rc::new(std::cell::RefCell::new(Some(Err(crate::unreadable("Unreadable.figcraft"))))); let mut u = first_run(); u.app.ui.home = true; u.app.ui.pending_opens.push(crate::pending_open::PendingOpen { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 026228dc3..4b44c861d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -295,32 +295,55 @@ pub trait Services { } pub type DialogFuture = Pin>>>; -/// A picked file's name and contents ([`Services::pick_open_contents`]). -pub type ContentsFuture = Pin)>>>>; -/// A dropped file's contents, read once they arrive (`None`: they couldn't be read). -pub type ReadFuture = Pin>>>>; +/// A picked file's name and contents ([`Services::pick_open_contents`]); `None` when the picker +/// was cancelled, `Err` with what to tell the user when the file couldn't be read. +pub type ContentsFuture = Pin), String>>>>>; +/// A dropped file's contents, read once they arrive (`Err`: what to tell the user when they +/// couldn't be read). +pub type ReadFuture = Pin, String>>>>; type PickFuture = Pin>>>; type DialogDone = Box; -/// What a dialog picked: a path, or a file's name and contents where there are no paths. +/// What a dialog picked: a path, or a file's name and contents where there are no paths, or why +/// the picked file couldn't be read. enum Picked { Path(PathBuf), Contents(String, Vec), + Unreadable(String), } impl Picked { - /// The file as `file.open` and `file.import` take it. - fn file_params(self) -> Value { + /// The file as `file.open` and `file.import` take it (`Err`: what to tell the user instead). + fn file_params(self) -> Result { match self { - Picked::Path(p) => json!({"path": p.to_string_lossy()}), + Picked::Path(p) => Ok(json!({"path": p.to_string_lossy()})), Picked::Contents(name, bytes) => { use base64::Engine as _; - json!({"data": base64::engine::general_purpose::STANDARD.encode(&bytes), "name": name}) + Ok(json!({"data": base64::engine::general_purpose::STANDARD.encode(&bytes), "name": name})) } + Picked::Unreadable(why) => Err(why), } } } +/// What the user is told about a file whose contents couldn't be read. +pub fn unreadable(name: &str) -> String { + i18n::trf("Couldn't read {name}.", &[("name", &name)]) +} + +/// The largest file read in the browser: wasm has at most 4 GiB of memory, and a file's contents +/// are held about three times on their way to `file.open` (the bytes, base64, decoded again). +/// As large as one member of a `.figcraft` file may be. +const MAX_BROWSER_FILE: f64 = (512u64 << 20) as f64; + +/// Checks a file of `size` bytes before the browser reads it (`Err`: what to tell the user). +pub fn browser_can_read(name: &str, size: f64) -> Result<(), String> { + if size.is_nan() || size > MAX_BROWSER_FILE { + return Err(i18n::trf("{name} is too large to open in the browser (over 512 MB).", &[("name", &name)])); + } + Ok(()) +} + /// A native dialog in flight. The app keeps working while it is open, so its result goes to the /// file and window it was opened for, not to whichever is in front when it closes. struct PendingDialog { @@ -809,6 +832,11 @@ impl FigApp { fn failed(&mut self, id: &str, e: &figcraft_engine::EngineError) { let msg = e.to_string(); log::warn!("{id}: {msg}"); + self.show_error(msg); + } + + /// Show an error as a toast, kept as the last error. + pub(crate) fn show_error(&mut self, msg: String) { self.toast(msg.clone()); self.ui.last_error = Some(msg); } @@ -1154,7 +1182,9 @@ impl FigApp { self.start_pick( |services| -> PickFuture { match services.pick_open_contents(title, exts) { - Some(read) => Box::pin(async move { read.await.map(|(name, bytes)| Picked::Contents(name, bytes)) }), + Some(read) => { + Box::pin(async move { read.await.map(|r| r.map_or_else(Picked::Unreadable, |(name, bytes)| Picked::Contents(name, bytes))) }) + } None => { let pick = services.pick_open(title, exts); Box::pin(async move { pick.await.map(Picked::Path) }) @@ -1162,7 +1192,10 @@ impl FigApp { } }, pin, - move |app, picked| done(app, picked.file_params()), + move |app, picked| match picked.file_params() { + Ok(file) => done(app, file), + Err(why) => app.show_error(why), + }, ); } @@ -1751,17 +1784,22 @@ pub(crate) fn opens(path: &Path) -> bool { path.extension().and_then(|e| e.to_str()).is_some_and(|e| OPEN_EXTENSIONS.iter().any(|x| x.eq_ignore_ascii_case(e))) } -/// Reads the contents of a dropped file that has no file on disk to read by path. +/// Reads the contents of dropped file `name` that has no file on disk to read by path. #[cfg(not(target_arch = "wasm32"))] -pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle) -> ReadFuture { - Box::pin(std::future::ready(f.bytes().ok())) +pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle, name: &str) -> ReadFuture { + Box::pin(std::future::ready(f.bytes().map_err(|_| unreadable(name)))) } -/// Reads the contents of a dropped file; the browser hands them over asynchronously. +/// Reads the contents of dropped file `name`; the browser hands them over asynchronously. #[cfg(target_arch = "wasm32")] -pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle) -> ReadFuture { - let f = f.clone(); - Box::pin(async move { f.bytes_async().await.ok() }) +pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle, name: &str) -> ReadFuture { + let (f, name) = (f.clone(), name.to_string()); + Box::pin(async move { + if let Some(file) = f.web_file() { + browser_can_read(&name, file.size())?; + } + f.bytes_async().await.map_err(|_| unreadable(&name)) + }) } /// While files are dragged over the window: a tinted, outlined `rect` with `label` in its middle, diff --git a/crates/ui-egui/src/pending_open.rs b/crates/ui-egui/src/pending_open.rs index ef798eca9..9575a1ea9 100644 --- a/crates/ui-egui/src/pending_open.rs +++ b/crates/ui-egui/src/pending_open.rs @@ -72,8 +72,7 @@ fn run_pending(app: &mut FigApp, pending: PendingOpen) -> Option { /// An import that has no destination left: nothing is imported, and the user is told why. fn cancel(app: &mut FigApp, message: String) { - app.ui.last_error = Some(message.clone()); - app.toast(message); + app.show_error(message); } /// Frames the card is drawn before the files open: egui lays a new card out invisibly on its first @@ -94,20 +93,20 @@ fn read_contents(app: &mut FigApp, ctx: &egui::Context) -> bool { reading = true; true } - Poll::Ready(Some(bytes)) => { + Poll::Ready(Ok(bytes)) => { use base64::Engine as _; pending.params["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&bytes)); pending.read = None; true } - Poll::Ready(None) => { - unread.push(pending.label.clone()); + Poll::Ready(Err(why)) => { + unread.push(why); false } } }); - for name in unread { - cancel(app, trf("Couldn't read {name}.", &[("name", &name)])); + for why in unread { + app.show_error(why); } reading } From 80c8c7fa8775cc02ba520a4e242b4f58baa6a6c2 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 19 Oct 2026 22:39:25 +0700 Subject: [PATCH 082/114] #830: "Couldn't read {name}." in every full catalog --- crates/ui-egui/locales/cs.toml | 1 + crates/ui-egui/locales/de.toml | 1 + crates/ui-egui/locales/fi.toml | 1 + crates/ui-egui/locales/fr.toml | 1 + crates/ui-egui/locales/id.toml | 1 + crates/ui-egui/locales/ja.toml | 1 + crates/ui-egui/locales/ko.toml | 1 + crates/ui-egui/locales/nl.toml | 1 + crates/ui-egui/locales/pt-BR.toml | 1 + crates/ui-egui/locales/ru.toml | 1 + crates/ui-egui/locales/sv.toml | 1 + crates/ui-egui/locales/tr.toml | 1 + crates/ui-egui/locales/zh-Hans.toml | 1 + 13 files changed, 13 insertions(+) diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 8ef219660..c4cd98f40 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -310,6 +310,7 @@ status = "draft — needs native review" "Corner radius" = "Poloměr rohu" "Corner smoothing" = "Vyhlazení rohů" "Couldn't reach {server}: showing what this computer remembers." = "Server {server} není dostupný: zobrazuje se stav uložený v tomto počítači." +"Couldn't read {name}." = "Soubor {name} se nepodařilo přečíst." "Count" = "Počet" "Create" = "Vytvořit" "Create a variable collection first" = "Nejprve vytvořte kolekci proměnných" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 2294e82d4..51afd4420 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -306,6 +306,7 @@ status = "draft — needs native review" "Corner smoothing" = "Eckenglättung" "Couldn't download the variables" = "Die Variablen konnten nicht heruntergeladen werden" "Couldn't reach {server}: showing what this computer remembers." = "{server} war nicht erreichbar: Es wird angezeigt, was dieser Computer gespeichert hat." +"Couldn't read {name}." = "{name} konnte nicht gelesen werden." "Couldn't save automatically: {error}" = "Automatisches Speichern fehlgeschlagen: {error}" "Count" = "Anzahl" "Create" = "Erstellen" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 2521e46cb..db5d25794 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -321,6 +321,7 @@ status = "draft — needs native review" "Corner smoothing" = "Kulman pehmennys" "Couldn't download the variables" = "Muuttujia ei voitu ladata" "Couldn't reach {server}: showing what this computer remembers." = "Palvelimeen {server} ei saatu yhteyttä. Näytetään tämän tietokoneen muistamat tiedot." +"Couldn't read {name}." = "Tiedostoa {name} ei voitu lukea." "Couldn't save automatically: {error}" = "Automaattinen tallennus epäonnistui: {error}" "Count" = "Määrä" "Create" = "Luo" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 1faff4c3a..98c860ba8 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -318,6 +318,7 @@ status = "draft — needs native review" "Corner smoothing" = "Lissage des angles" "Couldn't download the variables" = "Impossible de télécharger les variables" "Couldn't reach {server}: showing what this computer remembers." = "Impossible de joindre {server} : affichage de ce dont cet ordinateur se souvient." +"Couldn't read {name}." = "Impossible de lire {name}." "Couldn't save automatically: {error}" = "Impossible d’enregistrer automatiquement : {error}" "Count" = "Nombre" "Create" = "Créer" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 705484d89..0b5598614 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -314,6 +314,7 @@ status = "draft — needs native review" "Corner radius" = "Radius sudut" "Corner smoothing" = "Penghalusan sudut" "Couldn't reach {server}: showing what this computer remembers." = "Tidak dapat terhubung ke {server}: menampilkan data yang diingat komputer ini." +"Couldn't read {name}." = "Tidak dapat membaca {name}." "Count" = "Jumlah" "Create" = "Buat" "Create a variable collection first" = "Buat koleksi variabel terlebih dahulu" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 069eb09ac..712a6f3f5 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -316,6 +316,7 @@ status = "draft — needs native review" "Corner radius" = "角の半径" "Corner smoothing" = "角のスムージング" "Couldn't reach {server}: showing what this computer remembers." = "{server} に接続できない:このコンピューターが覚えている内容を表示中。" +"Couldn't read {name}." = "{name} を読み込めませんでした。" "Couldn't save automatically: {error}" = "自動保存できませんでした:{error}" "Count" = "数" "Create" = "作成" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index e3d8bae8a..cfdb018da 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -305,6 +305,7 @@ status = "draft — needs native review" "Corner radius" = "모서리 반경" "Corner smoothing" = "모서리 스무딩" "Couldn't reach {server}: showing what this computer remembers." = "{server}에 연결할 수 없음: 이 컴퓨터가 기억하는 내용을 표시함." +"Couldn't read {name}." = "{name}을(를) 읽을 수 없습니다." "Couldn't save automatically: {error}" = "자동으로 저장할 수 없음: {error}" "Count" = "개수" "Create" = "만들기" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 7a8b41f48..da645e7dd 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -321,6 +321,7 @@ status = "draft — needs native review" "Corner smoothing" = "Hoekafvlakking" "Couldn't download the variables" = "De variabelen konden niet worden gedownload" "Couldn't reach {server}: showing what this computer remembers." = "Kon {server} niet bereiken: toont wat deze computer onthoudt." +"Couldn't read {name}." = "Kon {name} niet lezen." "Couldn't save automatically: {error}" = "Kan niet automatisch opslaan: {error}" "Count" = "Aantal" "Create" = "Maken" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 1fdbd1a3f..343463f36 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -304,6 +304,7 @@ status = "draft — needs native review" "Corner smoothing" = "Suavização de cantos" "Couldn't download the variables" = "Não foi possível baixar as variáveis" "Couldn't reach {server}: showing what this computer remembers." = "Não foi possível alcançar {server}: mostrando o que este computador lembra." +"Couldn't read {name}." = "Não foi possível ler {name}." "Couldn't save automatically: {error}" = "Não foi possível salvar automaticamente: {error}" "Count" = "Quantidade" "Create" = "Criar" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 17e50e778..eb264cd99 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -311,6 +311,7 @@ name = "Русский" "Corner radius" = "Радиус углов" "Corner smoothing" = "Сглаживание углов" "Couldn't reach {server}: showing what this computer remembers." = "Не удалось связаться с {server}: показаны данные, сохранённые на этом компьютере." +"Couldn't read {name}." = "Не удалось прочитать {name}." "Count" = "Количество" "Create" = "Создать" "Create a variable collection first" = "Сначала создайте коллекцию переменных" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index c57f2f6aa..97e4cb412 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -315,6 +315,7 @@ name = "Svenska" "Corner smoothing" = "Hörnutjämning" "Couldn't download the variables" = "Det gick inte att hämta variablerna" "Couldn't reach {server}: showing what this computer remembers." = "Det gick inte att nå {server}: visar det som den här datorn minns." +"Couldn't read {name}." = "Det gick inte att läsa {name}." "Couldn't save automatically: {error}" = "Det gick inte att spara automatiskt: {error}" "Count" = "Antal" "Create" = "Skapa" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 83b63c8cc..e9fbf7f08 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -314,6 +314,7 @@ name = "Türkçe" "Corner radius" = "Köşe yarıçapı" "Corner smoothing" = "Köşe yumuşatma" "Couldn't reach {server}: showing what this computer remembers." = "{server} sunucusuna ulaşılamadı: bu bilgisayarın hatırladıkları gösteriliyor." +"Couldn't read {name}." = "{name} okunamadı." "Count" = "Sayı" "Create" = "Oluştur" "Create a variable collection first" = "Önce bir değişken koleksiyonu oluşturun" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 57f2d44ba..81874aeb3 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -306,6 +306,7 @@ status = "draft — needs native review" "Corner radius" = "圆角半径" "Corner smoothing" = "圆角平滑" "Couldn't reach {server}: showing what this computer remembers." = "无法连接 {server}:显示的是这台电脑记录的内容。" +"Couldn't read {name}." = "无法读取 {name}。" "Couldn't save automatically: {error}" = "无法自动保存:{error}" "Count" = "数量" "Create" = "创建" From 5a4b7c279502214203f8292e9a6adcded0e51c8a Mon Sep 17 00:00:00 2001 From: YarilaFox <102650794+YarilaFox@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:51:47 +0500 Subject: [PATCH 083/114] M7: make brand accents legible and fix home tests --- .github/workflows/release.yml | 1 + crates/text/build.rs | 4 +-- crates/ui-egui/src/assets_panel.rs | 2 +- crates/ui-egui/src/comments.rs | 9 +++--- crates/ui-egui/src/dev_mode.rs | 2 +- crates/ui-egui/src/home.rs | 2 +- crates/ui-egui/src/home_recents.rs | 2 +- crates/ui-egui/src/home_recents_tests.rs | 6 ++-- crates/ui-egui/src/layers.rs | 4 +-- crates/ui-egui/src/lib.rs | 6 ++-- crates/ui-egui/src/present.rs | 2 +- crates/ui-egui/src/proto_panel.rs | 2 +- crates/ui-egui/src/right.rs | 8 ++--- crates/ui-egui/src/style_picker.rs | 2 +- crates/ui-egui/src/theme.rs | 41 +++++++++++++----------- crates/ui-egui/src/toolbar.rs | 8 ++--- crates/ui-egui/src/typography.rs | 14 ++++---- crates/ui-egui/src/widgets.rs | 10 +++--- 18 files changed, 66 insertions(+), 59 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 75f7a5fae..6e1aa660f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,6 +25,7 @@ jobs: app-name: figcraft app-title: FigCraft craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 + web-craft-fonts: true has-server: true version: ${{ inputs.version }} secrets: diff --git a/crates/text/build.rs b/crates/text/build.rs index f5f5096b3..6d59a9931 100644 --- a/crates/text/build.rs +++ b/crates/text/build.rs @@ -65,8 +65,8 @@ fn craft_fonts(dir: &std::path::Path, manifest_rel: &str) -> Result String { ); crate::widgets::select_all_on_focus(ui, &r, &filter); if r.has_focus() { - ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, crate::theme::ACCENT), egui::StrokeKind::Inside); if ui.input(|i| i.key_pressed(egui::Key::Escape)) { filter.clear(); } diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 2706bd8a1..d33c81ee0 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -189,10 +189,10 @@ pub fn cluster(points: &[Pos2], r: f32) -> Vec> { } /// A message's text with its @mentions of `people` picked out, wrapped at `width`. -pub fn rich_text(text: &str, people: &[&str], color: Color32, width: f32) -> LayoutJob { +pub fn rich_text(text: &str, people: &[&str], color: Color32, mention_color: Color32, width: f32) -> LayoutJob { let mut job = LayoutJob::default(); let plain = TextFormat { font_id: regular(12.0), color, ..Default::default() }; - let mention = TextFormat { font_id: semibold(12.0), color: ACCENT, ..Default::default() }; + let mention = TextFormat { font_id: semibold(12.0), color: mention_color, ..Default::default() }; let mut at = 0; for r in mention_ranges(text, people) { job.append(text.get(at..r.start).unwrap_or(""), 0.0, plain.clone()); @@ -729,7 +729,7 @@ fn message( ui.add_space(indent); let mut names: Vec<&str> = cx.people.iter().map(String::as_str).collect(); names.push(cx.me); - ui.add(egui::Label::new(rich_text(&m.text, &names, t.text, ui.available_width())).wrap()); + ui.add(egui::Label::new(rich_text(&m.text, &names, t.text, t.accent_text, ui.available_width())).wrap()); }); } } @@ -1062,7 +1062,8 @@ pub fn panel(app: &mut FigApp, ui: &mut Ui) { ui.add_space(16.0); ui.label(egui::RichText::new(tr(app.ui.comments.filter.label())).font(regular(11.0)).color(t.text_secondary)); ui.add_space(4.0); - if ui.add(egui::Label::new(egui::RichText::new(tr("Show open")).font(regular(11.0)).color(ACCENT)).sense(Sense::click())).clicked() { + if ui.add(egui::Label::new(egui::RichText::new(tr("Show open")).font(regular(11.0)).color(t.accent_text)).sense(Sense::click())).clicked() + { app.ui.comments.filter = Filter::Open; } }); diff --git a/crates/ui-egui/src/dev_mode.rs b/crates/ui-egui/src/dev_mode.rs index d34560885..0b429820d 100644 --- a/crates/ui-egui/src/dev_mode.rs +++ b/crates/ui-egui/src/dev_mode.rs @@ -628,7 +628,7 @@ pub fn dev_resources_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: } icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + PAD + 7.0, r.center().y), vec2(14.0, 14.0)), Icon::Link, t.icon_secondary); let x = r.min.x + PAD + 22.0; - ui.painter().text(pos2(x, r.center().y - 7.0), Align2::LEFT_CENTER, widgets::elide(ui, name, text_w), semibold(11.0), ACCENT); + ui.painter().text(pos2(x, r.center().y - 7.0), Align2::LEFT_CENTER, widgets::elide(ui, name, text_w), semibold(11.0), t.accent_text); let below = match item.from.and_then(|f| doc.node(f)) { Some(m) => trf("From {name}", &[("name", &m.name)]), None => url.clone(), diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 34f351000..700390a9a 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -324,7 +324,7 @@ fn action_card(ui: &mut Ui, t: &Tokens, icon: Icon, title: &str, sub: &str) -> b } card_frame(ui, t, r, &resp); let ir = Rect::from_min_size(pos2(r.min.x + 16.0, r.center().y - 16.0), vec2(32.0, 32.0)); - ui.painter().rect_filled(ir, CornerRadius::same(6), ACCENT); + ui.painter().rect_filled(ir, CornerRadius::same(6), t.accent); icons::paint(ui.painter(), ir.shrink(8.0), icon, t.accent_on); ui.painter().text(pos2(r.min.x + 60.0, r.center().y - 8.0), Align2::LEFT_CENTER, title_shown, semibold(12.0), t.text); ui.painter().text(pos2(r.min.x + 60.0, r.center().y + 9.0), Align2::LEFT_CENTER, sub_shown, regular(11.0), t.text_secondary); diff --git a/crates/ui-egui/src/home_recents.rs b/crates/ui-egui/src/home_recents.rs index 708fb4dbf..81d682406 100644 --- a/crates/ui-egui/src/home_recents.rs +++ b/crates/ui-egui/src/home_recents.rs @@ -738,7 +738,7 @@ fn file_icon(ui: &Ui, t: &Tokens, center: egui::Pos2, path: &str) { let r = Rect::from_center_size(center, vec2(16.0, 16.0)); match FileKind::of(Path::new(path)) { FileKind::FigCraft | FileKind::Fig => { - ui.painter().rect_filled(r, CornerRadius::same(4), ACCENT); + ui.painter().rect_filled(r, CornerRadius::same(4), t.accent); icons::paint(ui.painter(), r.shrink(3.0), Icon::Logo, t.accent_on); } kind => { diff --git a/crates/ui-egui/src/home_recents_tests.rs b/crates/ui-egui/src/home_recents_tests.rs index 1c3235ddb..e49866ab4 100644 --- a/crates/ui-egui/src/home_recents_tests.rs +++ b/crates/ui-egui/src/home_recents_tests.rs @@ -256,9 +256,11 @@ fn rects_of(s: &[Shape], c: egui::Color32) -> usize { s.iter().filter(|s| matches!(s, Shape::Rect(r) if r.fill == c)).count() } -/// Images drawn (meshes with a texture of their own). +/// Preview images in the main content area, excluding the branded sidebar icon. fn images(s: &[Shape]) -> usize { - s.iter().filter(|s| matches!(s, Shape::Mesh(m) if m.texture_id != egui::TextureId::default())).count() + s.iter() + .filter(|s| matches!(s, Shape::Mesh(m) if m.texture_id != egui::TextureId::default() && m.vertices.iter().any(|v| v.pos.x > 240.0))) + .count() } #[test] diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 32f66f80e..26b63a5b5 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -332,7 +332,7 @@ fn pages_edge(app: &mut FigApp, ui: &mut Ui, shown: f32) { if active { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); } - let (width, colour) = if active { (2.0, t.accent) } else { (1.0, t.border) }; + let (width, colour) = if active { (2.0, ACCENT) } else { (1.0, t.border) }; ui.painter().line_segment([line.left_center(), line.right_center()], Stroke::new(width, colour)); let asked = if resp.double_clicked() { Some(None) @@ -642,7 +642,7 @@ fn search_field(app: &mut FigApp, ui: &mut Ui, t: &Tokens, w: f32, id: Id) { ); crate::widgets::select_all_on_focus(ui, &r, &app.ui.layer_filter); if r.has_focus() { - ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, ACCENT), egui::StrokeKind::Inside); } if (r.has_focus() || r.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)) { app.ui.layer_search = false; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 450317b43..c38242532 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1032,7 +1032,7 @@ impl FigApp { let resp = ui.interact(rect, id, egui::Sense::click_and_drag()); if resp.hovered() || resp.dragged() { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); - ui.painter().vline(x, rows, egui::Stroke::new(2.0, theme::Tokens::get(ui.ctx()).accent)); + ui.painter().vline(x, rows, egui::Stroke::new(2.0, theme::ACCENT)); } if resp.double_clicked() { return Some(PANEL_DEFAULT); @@ -1716,11 +1716,11 @@ pub(crate) fn drop_overlay(ctx: &egui::Context, rect: egui::Rect, label: &str) { let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("drop_overlay"))); let area = rect.shrink(4.0); painter.rect_filled(area, egui::CornerRadius::same(8), t.accent.gamma_multiply(0.12)); - painter.rect_stroke(area, egui::CornerRadius::same(8), egui::Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); + painter.rect_stroke(area, egui::CornerRadius::same(8), egui::Stroke::new(2.0, theme::ACCENT), egui::StrokeKind::Inside); let galley = painter.layout_no_wrap(label.to_owned(), theme::semibold(14.0), t.text); let pill = egui::Rect::from_center_size(area.center(), galley.size() + egui::vec2(32.0, 16.0)); painter.rect_filled(pill, egui::CornerRadius::same(8), t.panel); - painter.rect_stroke(pill, egui::CornerRadius::same(8), egui::Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + painter.rect_stroke(pill, egui::CornerRadius::same(8), egui::Stroke::new(1.0, theme::ACCENT), egui::StrokeKind::Inside); painter.galley(pill.center() - galley.size() / 2.0, galley, t.text); // Hovering sends no pointer events on every platform: keep the overlay current. ctx.request_repaint(); diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index 786f5c5a1..9c0f1608d 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -2223,7 +2223,7 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome) } let on = (*act == BarAction::Comments && p.view.comments) || (*act == BarAction::Inspect && p.view.inspect); if on { - ui.painter().rect_filled(br, CornerRadius::same(5), ACCENT); + ui.painter().rect_filled(br, CornerRadius::same(5), t.accent); } else if resp.hovered() { ui.painter().rect_filled(br, CornerRadius::same(5), hover); } diff --git a/crates/ui-egui/src/proto_panel.rs b/crates/ui-egui/src/proto_panel.rs index 710bab999..86085b8f3 100644 --- a/crates/ui-egui/src/proto_panel.rs +++ b/crates/ui-egui/src/proto_panel.rs @@ -763,7 +763,7 @@ fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Inte let id = capture_id().with("field"); ui.painter().rect_filled(r, CornerRadius::same(5), t.field); if cap { - ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(1.0, t.accent), StrokeKind::Inside); + ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(1.0, ACCENT), StrokeKind::Inside); ui.memory_mut(|m| m.request_focus(id)); let _ = ui.interact(r, id, Sense::focusable_noninteractive()); let pressed = ui.input(|i| { diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index e6d2b8b9d..2dc71192c 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -11,7 +11,7 @@ use crate::i18n::{tr, trf, trn}; use crate::icons::{self, Icon}; use crate::list_drag::{ListDrag, move_flags, remove_flags}; use crate::style_picker::{self, StyleSlot}; -use crate::theme::{ACCENT, Tokens, regular, semibold}; +use crate::theme::{Tokens, regular, semibold}; use crate::widgets::{self, Edit, FIELD_H, FieldEdit, Lead, PAD}; use crate::{FigApp, RightTab}; @@ -88,7 +88,7 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { let sw = (label.size().x + 24.0).max(56.0); let share = Rect::from_min_size(pos2(top.max.x - 14.0 - sw, top.center().y - 16.0), vec2(sw, 32.0)); let sresp = ui.interact(share, ui.id().with("share"), Sense::click()).tip(tr("Share: live session, export")); - ui.painter().rect_filled(share, CornerRadius::same(6), if sresp.hovered() { t.accent_hover } else { ACCENT }); + ui.painter().rect_filled(share, CornerRadius::same(6), if sresp.hovered() { t.accent_hover } else { t.accent }); ui.painter().galley(share.center() - label.size() / 2.0, label, t.accent_on); crate::live::share_popover(app, &sresp); // Play presents; its chevron opens the play menu (present, or preview in a pane). @@ -286,11 +286,11 @@ pub fn menu_row(ui: &mut Ui, label: &str, shortcut: &str, art_w: f32, art: impl if hovered { ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); } - let col = if hovered { Color32::WHITE } else { t.text }; + let col = t.menu_text; ui.painter().galley(pos2(r.min.x + MENU_PAD, r.center().y - text.size().y / 2.0), text, col); if let Some(k) = keys { let at = pos2(r.max.x - MENU_PAD - k.size().x, r.center().y - k.size().y / 2.0); - ui.painter().galley(at, k, if hovered { Color32::WHITE } else { t.text_secondary }); + ui.painter().galley(at, k, if hovered { t.menu_text } else { t.menu_dim }); } art(ui.painter(), r, col); resp diff --git a/crates/ui-egui/src/style_picker.rs b/crates/ui-egui/src/style_picker.rs index 966ea4828..29c9c5a94 100644 --- a/crates/ui-egui/src/style_picker.rs +++ b/crates/ui-egui/src/style_picker.rs @@ -482,7 +482,7 @@ fn style_grid(ui: &mut Ui, list: &[(&Style, Key)], applied: Option<&Key>, editab None => preview(ui, r, s, StyleSlot::Fill), } if applied == Some(key) { - ui.painter().rect_stroke(r.expand(2.0), CornerRadius::same(5), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); + ui.painter().rect_stroke(r.expand(2.0), CornerRadius::same(5), Stroke::new(2.0, crate::theme::ACCENT), egui::StrokeKind::Inside); } else if resp.hovered() && !text { ui.painter().rect_stroke(r.expand(2.0), CornerRadius::same(4), Stroke::new(1.0, t.menu_text), egui::StrokeKind::Inside); } diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 517f9089f..01b9e7f59 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -45,14 +45,17 @@ pub struct Tokens { pub focus: Color32, } -/// FigCraft's brand green, shared with the original app icon. -pub const ACCENT: Color32 = Color32::from_rgb(0x02, 0xDA, 0x96); +/// FigCraft's brand green for solid surfaces. +pub const BRAND: Color32 = Color32::from_rgb(0x02, 0xDA, 0x96); +/// Contrast-safe green for thin selection outlines and white-on-green labels. +/// It reaches 4.5:1 on white and 3:1 on the dark canvas. +pub const ACCENT: Color32 = Color32::from_rgb(0x00, 0x87, 0x5F); /// Ink on the bright green brand surface (white would not have enough contrast). pub const ACCENT_ON: Color32 = Color32::from_rgb(0x07, 0x35, 0x2A); pub const COMPONENT: Color32 = Color32::from_rgb(0x97, 0x47, 0xFF); /// Handoff status badges (white text on these, in both themes). -pub const DEV_READY: Color32 = Color32::from_rgb(0x14, 0xAE, 0x5C); -pub const DEV_CHANGED: Color32 = Color32::from_rgb(0xD9, 0x7A, 0x06); +pub const DEV_READY: Color32 = Color32::from_rgb(0x08, 0x7D, 0x4F); +pub const DEV_CHANGED: Color32 = Color32::from_rgb(0xA7, 0x5B, 0x00); pub const DEV_COMPLETED: Color32 = Color32::from_rgb(0x6E, 0x6E, 0x73); impl Tokens { @@ -74,7 +77,7 @@ impl Tokens { pressed: Color32::from_rgb(0xE6, 0xE6, 0xE6), field: Color32::from_rgb(0xF5, 0xF5, 0xF5), field_hover_border: Color32::from_rgb(0xE6, 0xE6, 0xE6), - accent: ACCENT, + accent: BRAND, accent_hover: Color32::from_rgb(0x00, 0xAD, 0x78), accent_on: ACCENT_ON, // Darker green keeps links and labels legible on white and selected rows. @@ -84,7 +87,7 @@ impl Tokens { component: COMPONENT, component_row: Color32::from_rgb(0xF1, 0xE9, 0xFF), danger: Color32::from_rgb(0xF2, 0x48, 0x22), - success: Color32::from_rgb(0x14, 0xAE, 0x5C), + success: DEV_READY, warning: Color32::from_rgb(0xFF, 0xCD, 0x29), guide: Color32::from_rgb(0xF2, 0x48, 0x22), tab_bar: Color32::from_rgb(0xE8, 0xE8, 0xE8), @@ -92,7 +95,7 @@ impl Tokens { shadow: Color32::from_black_alpha(40), menu: Color32::from_rgb(0x1E, 0x1E, 0x1E), menu_text: Color32::from_rgb(0xFF, 0xFF, 0xFF), - menu_hover: Color32::from_rgb(0x00, 0x6B, 0x4C), + menu_hover: ACCENT, menu_dim: Color32::from_rgb(0x9A, 0x9A, 0x9A), focus: Color32::from_rgb(0x00, 0x7A, 0x56), } @@ -115,7 +118,7 @@ impl Tokens { pressed: Color32::from_rgb(0x44, 0x44, 0x44), field: Color32::from_rgb(0x38, 0x38, 0x38), field_hover_border: Color32::from_rgb(0x4D, 0x4D, 0x4D), - accent: ACCENT, + accent: BRAND, accent_hover: Color32::from_rgb(0x52, 0xE9, 0xB7), accent_on: ACCENT_ON, accent_text: Color32::from_rgb(0x52, 0xE9, 0xB7), @@ -124,7 +127,7 @@ impl Tokens { component: Color32::from_rgb(0xD2, 0x7C, 0xFF), component_row: Color32::from_rgb(0x3E, 0x30, 0x52), danger: Color32::from_rgb(0xF2, 0x48, 0x22), - success: Color32::from_rgb(0x14, 0xAE, 0x5C), + success: DEV_READY, warning: Color32::from_rgb(0xFF, 0xCD, 0x29), guide: Color32::from_rgb(0xF2, 0x48, 0x22), tab_bar: Color32::from_rgb(0x1E, 0x1E, 0x1E), @@ -132,9 +135,9 @@ impl Tokens { shadow: Color32::from_black_alpha(90), menu: Color32::from_rgb(0x1E, 0x1E, 0x1E), menu_text: Color32::from_rgb(0xFF, 0xFF, 0xFF), - menu_hover: Color32::from_rgb(0x00, 0x6B, 0x4C), + menu_hover: ACCENT, menu_dim: Color32::from_rgb(0x9A, 0x9A, 0x9A), - focus: ACCENT, + focus: BRAND, } } @@ -192,7 +195,7 @@ fn visuals(t: Tokens) -> Visuals { v.window_corner_radius = egui::CornerRadius::same(13); v.menu_corner_radius = egui::CornerRadius::same(13); v.selection.bg_fill = t.accent.gamma_multiply(0.3); - v.selection.stroke = Stroke::new(1.0, t.accent); + v.selection.stroke = Stroke::new(1.0, ACCENT); v.hyperlink_color = t.accent_text; for w in [&mut v.widgets.inactive, &mut v.widgets.hovered, &mut v.widgets.active, &mut v.widgets.open, &mut v.widgets.noninteractive] { w.corner_radius = egui::CornerRadius::same(5); @@ -208,10 +211,10 @@ fn visuals(t: Tokens) -> Visuals { v.widgets.hovered.bg_stroke = Stroke::new(1.0, t.field_hover_border); v.widgets.active.bg_fill = t.pressed; v.widgets.active.weak_bg_fill = t.pressed; - v.widgets.active.bg_stroke = Stroke::new(1.0, t.accent); + v.widgets.active.bg_stroke = Stroke::new(1.0, ACCENT); v.popup_shadow = egui::Shadow { offset: [0, 4], blur: 16, spread: 0, color: t.shadow }; v.window_shadow = egui::Shadow { offset: [0, 8], blur: 24, spread: 0, color: t.shadow }; - v.text_cursor.stroke = Stroke::new(1.0, t.accent); + v.text_cursor.stroke = Stroke::new(1.0, ACCENT); v } @@ -261,9 +264,9 @@ pub fn install_fonts(ctx: &Context, system: bool, cjk: u8) { fonts.families.insert(FontFamily::Name("semibold".into()), semi); // Keep the logo's typeface independent of UI text. System Archivo is used when available; // releases with craft-fonts embed it so the wordmark does not depend on installation. - let archivo = find("Archivo", "SemiBold") - .map(FontData::from_static) - .or_else(|| system.then(|| figcraft_text::find_face("Archivo", "SemiBold")).flatten().map(|face| font_data(&face))); + let archivo = find("Archivo", "SemiBold").map(FontData::from_static).or_else(|| { + system.then(|| figcraft_text::find_face("Archivo", "SemiBold")).flatten().filter(|face| face.family == "Archivo").map(|face| font_data(&face)) + }); let mut brand = Vec::new(); if let Some(data) = archivo { fonts.font_data.insert("archivo".into(), Arc::new(data)); @@ -368,7 +371,7 @@ pub fn paint_wordmark(painter: &egui::Painter, left_center: egui::Pos2, size: f3 let fig = painter.layout_no_wrap("Fig".into(), font.clone(), t.text); let fig_width = fig.size().x; painter.galley(egui::pos2(left_center.x, left_center.y - fig.size().y / 2.0), fig, t.text); - painter.text(egui::pos2(left_center.x + fig_width, left_center.y), egui::Align2::LEFT_CENTER, "Craft", font, t.accent); + painter.text(egui::pos2(left_center.x + fig_width, left_center.y), egui::Align2::LEFT_CENTER, "Craft", font, t.accent_text); } /// Paint the owner's original chameleon icon from the existing app asset. @@ -379,7 +382,7 @@ pub fn paint_app_icon(ui: &egui::Ui, rect: egui::Rect) { Some(egui::ColorImage::from_rgba_unmultiplied([rgba.width() as usize, rgba.height() as usize], rgba.as_raw())) }); let Some(image) = image else { - ui.painter().rect_filled(rect, egui::CornerRadius::same(5), ACCENT); + ui.painter().rect_filled(rect, egui::CornerRadius::same(5), BRAND); crate::icons::paint(ui.painter(), rect.shrink(rect.width() * 0.22), crate::icons::Icon::Logo, ACCENT_ON); return; }; diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 1902ad89a..58409b974 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -9,7 +9,7 @@ use crate::FigApp; use crate::a11y::{self, Nav, Region, Tip}; use crate::i18n::{tr, tr_noop, trf}; use crate::icons::{self, Icon}; -use crate::theme::{ACCENT, Tokens, regular}; +use crate::theme::{Tokens, regular}; struct Group { key: &'static str, @@ -145,7 +145,7 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { // The Actions button reads on while its panel is open; clicking it again closes it. let actions_open = id == "actions" && app.ui.palette.is_some(); if actions_open { - ui.painter().rect_filled(r, CornerRadius::same(5), ACCENT); + ui.painter().rect_filled(r, CornerRadius::same(5), t.accent); } else if resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); } @@ -372,7 +372,7 @@ fn tool_cell(ui: &mut Ui, t: &Tokens, r: Rect, id: impl egui::AsIdSalt, icon: Ic }); a11y::toggle(&resp, tip, active); let bg = if active { - ACCENT + t.accent } else if resp.hovered() { t.hover } else { @@ -395,7 +395,7 @@ fn done_button(ui: &mut Ui, t: &Tokens, x: f32, cy: f32, tip: &str) -> bool { divider(ui, t, x, cy); let done = Rect::from_min_size(pos2(x + 12.0, cy - 14.0), vec2(DONE_W, 28.0)); let resp = ui.interact(done, ui.id().with("tool_done"), Sense::click()).tip(tip); - ui.painter().rect_filled(done, CornerRadius::same(6), if resp.hovered() { t.accent_hover } else { ACCENT }); + ui.painter().rect_filled(done, CornerRadius::same(6), if resp.hovered() { t.accent_hover } else { t.accent }); ui.painter().text(done.center(), Align2::CENTER_CENTER, tr("Done"), crate::theme::semibold(11.5), t.accent_on); resp.clicked() } diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index 7740a9e02..06185d0cd 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -587,7 +587,7 @@ fn decoration_details(app: &mut FigApp, ui: &mut Ui, styles: &[TextStyle], apply if !auto && ui .add( - egui::Label::new(egui::RichText::new(tr("Auto")).font(regular(11.0)).color(Tokens::get(ui.ctx()).accent)) + egui::Label::new(egui::RichText::new(tr("Auto")).font(regular(11.0)).color(Tokens::get(ui.ctx()).accent_text)) .sense(Sense::click()), ) .on_hover_text(tip) @@ -713,7 +713,7 @@ fn variable_axes(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], styles: &[TextSt } if styles.iter().any(|s| !s.variations.is_empty()) { ui.add_space(8.0); - let r = ui.add(egui::Label::new(egui::RichText::new(tr("Reset to style")).font(regular(11.0)).color(t.accent)).sense(Sense::click())); + let r = ui.add(egui::Label::new(egui::RichText::new(tr("Reset to style")).font(regular(11.0)).color(t.accent_text)).sense(Sense::click())); if r.on_hover_text(tr("Back to the values the font style implies")).clicked() { app.run("text.setVariations", json!({"ids": ids, "variations": {}, "replace": true})); } @@ -789,7 +789,7 @@ fn axis_slider(ui: &mut Ui, id: Id, axis: &figcraft_text::Axis, value: Option, supported: match on { Some(true) => { ui.painter().rect_filled(b, CornerRadius::same(3), t.accent); - icons::paint(ui.painter(), b.shrink(1.5), Icon::Check, Color32::WHITE); + icons::paint(ui.painter(), b.shrink(1.5), Icon::Check, t.accent_on); } Some(false) => { ui.painter().rect_stroke(b, CornerRadius::same(3), Stroke::new(1.0, t.border_strong), StrokeKind::Inside); } None => { ui.painter().rect_filled(b, CornerRadius::same(3), t.accent); - ui.painter().line_segment([pos2(b.min.x + 3.5, b.center().y), pos2(b.max.x - 3.5, b.center().y)], Stroke::new(1.5, Color32::WHITE)); + ui.painter().line_segment([pos2(b.min.x + 3.5, b.center().y), pos2(b.max.x - 3.5, b.center().y)], Stroke::new(1.5, t.accent_on)); } } let col = if supported { t.text } else { t.text_tertiary }; @@ -1025,7 +1025,7 @@ fn lead_field(ui: &mut Ui, id: Id, glyph: LeadGlyph, value: &str, width: f32, ti crate::a11y::text(&resp, tip, value); ui.painter().rect_filled(rect, CornerRadius::same(5), t.field); if editing.is_some() { - ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.accent), StrokeKind::Inside); + ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, crate::theme::ACCENT), StrokeKind::Inside); } else if resp.hovered() { ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.field_hover_border), StrokeKind::Inside); } @@ -1274,7 +1274,7 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) if hovered { ui.painter().rect_filled(rect, CornerRadius::same(5), t.accent); } - let col = if hovered { Color32::WHITE } else { t.text }; + let col = if hovered { t.accent_on } else { t.text }; if current.is_some_and(|c| c.eq_ignore_ascii_case(f)) { icons::paint( ui.painter(), diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index ea7f4de3b..ca7504aec 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -217,7 +217,7 @@ fn number_field_impl( let hovered = field_resp.hovered() || lead_resp.hovered() || editing.is_some(); ui.painter().rect_filled(rect, CornerRadius::same(5), t.field); if editing.is_some() { - ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.accent), StrokeKind::Inside); + ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, crate::theme::ACCENT), StrokeKind::Inside); } else if hovered { ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.field_hover_border), StrokeKind::Inside); } @@ -507,7 +507,7 @@ fn text_field_in(ui: &mut Ui, id: Id, value: &str, rect: Rect, placeholder: &str a11y::text(&resp, name, value); ui.painter().rect_filled(rect, CornerRadius::same(5), t.field); if editing.is_some() { - ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.accent), StrokeKind::Inside); + ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, crate::theme::ACCENT), StrokeKind::Inside); } else if resp.hovered() { ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.field_hover_border), StrokeKind::Inside); } @@ -570,7 +570,7 @@ pub fn text_input(ui: &mut Ui, id: Id, text: &mut String, width: f32, hint: &str ui.painter().rect_filled(rect, CornerRadius::same(5), t.field); let r = field_editor(ui, rect, id, text, hint); if r.has_focus() { - ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.accent), StrokeKind::Inside); + ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, crate::theme::ACCENT), StrokeKind::Inside); } else if ui.rect_contains_pointer(rect) { ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.field_hover_border), StrokeKind::Inside); } @@ -992,7 +992,7 @@ pub fn radio(ui: &mut Ui, on: bool, label: &str) -> bool { ui.painter().circle_filled(c, 7.0, t.accent); ui.painter().circle_filled(c, 2.5, t.accent_on); } else { - ui.painter().circle_stroke(c, 6.5, Stroke::new(1.0, if resp.hovered() { t.accent } else { t.border_strong })); + ui.painter().circle_stroke(c, 6.5, Stroke::new(1.0, if resp.hovered() { crate::theme::ACCENT } else { t.border_strong })); } ui.painter().text(pos2(rect.min.x + 22.0, rect.center().y), Align2::LEFT_CENTER, label, regular(11.0), t.text); resp.clicked() @@ -1046,7 +1046,7 @@ pub fn text_field_inline(ui: &mut Ui, id: Id, value: &str, rect: Rect, font: egu match buf { Some(mut text) => { ui.painter().rect_filled(rect.expand(2.0), CornerRadius::same(4), t.panel); - ui.painter().rect_stroke(rect.expand(2.0), CornerRadius::same(4), Stroke::new(1.0, t.accent), StrokeKind::Inside); + ui.painter().rect_stroke(rect.expand(2.0), CornerRadius::same(4), Stroke::new(1.0, crate::theme::ACCENT), StrokeKind::Inside); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect)); let r = crate::bidi::single_line(&mut child, te_id, &mut text, font, rect.width()); a11y::own_focus(&r); From afa431b47a84fb6fce9f30c26c741a29777d1859 Mon Sep 17 00:00:00 2001 From: YarilaFox <102650794+YarilaFox@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:26:49 +0500 Subject: [PATCH 084/114] M7: credit Archivo font in attribution --- ATTRIBUTION.md | 1 + 1 file changed, 1 insertion(+) diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 1f72aeb7c..96dff6a9e 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -25,6 +25,7 @@ Release builds embed fonts from the optional craft-fonts build input | Family | Licence | Upstream | |---|---|---| | Inter | SIL Open Font License 1.1 | https://github.com/rsms/inter | +| Archivo | SIL Open Font License 1.1 | [Google Fonts / Archivo](https://github.com/google/fonts/tree/6c70c829f09ea345d3590406693220ea35c6553f/ofl/archivo) | | Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Source Sans 3, Source Serif 4, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) | | Liberation Sans, Serif, Mono | SIL Open Font License 1.1 | https://github.com/liberationfonts | | BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) | From 113d7ec1069a6c5541a187038096a3ce425ef7a3 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 19 Oct 2026 23:37:27 +0700 Subject: [PATCH 085/114] #831: overflow menu lists open files with the shared menu rows (active checked); drop the falling close animation; update tab motion in place instead of cloning it every frame --- crates/ui-egui/src/tabs.rs | 159 +++++++++++-------------------------- 1 file changed, 47 insertions(+), 112 deletions(-) diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index 047d5faa6..c10edcfd8 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -2,8 +2,6 @@ //! the title bar: its empty space drags the window (double-click maximises), and it carries the //! window buttons; the window's edges resize it. -use std::collections::{HashMap, HashSet}; - use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2}; use serde_json::json; @@ -17,98 +15,40 @@ pub const HEIGHT: f32 = 40.0; const MIN_TAB_WIDTH: f32 = 96.0; const OVERFLOW_WIDTH: f32 = 32.0; const OPEN_TIME: f64 = 0.18; -const CLOSE_TIME: f64 = 0.24; - -#[derive(Clone)] -struct TabSnapshot { - name: String, - rect: Rect, - active: bool, -} - -#[derive(Clone)] -struct ClosingTab { - snapshot: TabSnapshot, - started: f64, -} +/// Which tabs were open last frame and when the newly opened ones appeared. It is updated in place +/// in egui's memory; it only allocates when the set of tabs changes. #[derive(Clone, Default)] struct TabMotion { initialized: bool, - known: HashSet, - opening: HashMap, - last_visible: HashMap, - closing: Vec, + known: Vec, + opening: Vec<(u64, f64)>, } impl TabMotion { - /// Track tabs added or removed since the previous frame and expire their animations. - fn begin_frame(&mut self, current: HashSet, now: f64, reduce_motion: bool) { - if self.initialized && !reduce_motion { - for uid in current.difference(&self.known) { - self.opening.insert(*uid, now); - } - for uid in self.known.difference(¤t) { - if let Some(snapshot) = self.last_visible.get(uid) { - self.closing.push(ClosingTab { snapshot: snapshot.clone(), started: now }); + /// Track tabs added since the previous frame and expire their animations. + fn begin_frame(&mut self, current: impl Iterator + Clone, now: f64, reduce_motion: bool) { + if !self.known.iter().copied().eq(current.clone()) { + if self.initialized && !reduce_motion { + for uid in current.clone().filter(|uid| !self.known.contains(uid)) { + self.opening.push((uid, now)); } } + self.known = current.collect(); } self.initialized = true; - self.known = current; - self.last_visible.clear(); if reduce_motion { self.opening.clear(); - self.closing.clear(); } else { - self.opening.retain(|uid, started| self.known.contains(uid) && now - *started < OPEN_TIME); - self.closing.retain(|tab| now - tab.started < CLOSE_TIME); + let known = &self.known; + self.opening.retain(|(uid, started)| known.contains(uid) && now - *started < OPEN_TIME); } } - - /// Return the entering tab's progress, or full width when it is not animating. - fn open_progress(&self, uid: u64, now: f64) -> f32 { - self.opening.get(&uid).map_or(1.0, |started| ((now - started) / OPEN_TIME).clamp(0.0, 1.0) as f32) - } - - /// Keep requesting frames while an opening or closing tab remains visible. - fn is_animating(&self) -> bool { - !self.opening.is_empty() || !self.closing.is_empty() - } } -/// Paint removed tabs as fading snapshots below the strip until their exit motion ends. -fn paint_closing_tabs(ui: &Ui, t: &Tokens, motion: &TabMotion, now: f64) { - if motion.closing.is_empty() { - return; - } - let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, ui.id().with("closing_tabs"))); - for tab in &motion.closing { - let progress = ((now - tab.started) / CLOSE_TIME).clamp(0.0, 1.0) as f32; - let opacity = (1.0 - progress).powi(2); - let r = tab.snapshot.rect.translate(vec2(0.0, 40.0 * progress * progress)); - painter.rect_filled(r, CornerRadius::same(4), if tab.snapshot.active { t.tab_active } else { t.hover }.linear_multiply(opacity)); - icons::paint( - &painter, - Rect::from_center_size(pos2(r.min.x + 18.0, r.center().y), vec2(14.0, 14.0)), - Icon::Logo, - crate::theme::ACCENT.linear_multiply(opacity), - ); - crate::bidi::paint_name( - &painter, - pos2(r.min.x + 32.0, r.center().y), - &tab.snapshot.name, - regular(12.0), - t.text.linear_multiply(opacity), - (r.width() - 64.0).max(0.0), - ); - icons::paint( - &painter, - Rect::from_center_size(pos2(r.max.x - 16.0, r.center().y), vec2(10.0, 10.0)), - Icon::Close, - t.icon_secondary.linear_multiply(opacity), - ); - } +/// The entering tab's progress among `opening` (from [`TabMotion`]), or 1 when it is not animating. +fn open_progress(opening: &[(u64, f64)], uid: u64, now: f64) -> f32 { + opening.iter().find(|(u, _)| *u == uid).map_or(1.0, |(_, started)| ((now - started) / OPEN_TIME).clamp(0.0, 1.0) as f32) } struct TabLayout { @@ -183,9 +123,14 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { let document_tabs: Vec = app.session.docs.iter().enumerate().filter_map(|(i, d)| app.shows(d.uid).then_some(i)).collect(); let now = ui.input(|input| input.time); let motion_id = ui.id().with("tab_motion"); - let mut motion = ui.ctx().data_mut(|data| data.get_temp::(motion_id).unwrap_or_default()); - let current_uids = document_tabs.iter().filter_map(|&i| app.session.docs.get(i).map(|d| d.uid)).collect(); - motion.begin_frame(current_uids, now, app.reduce_motion()); + let current_uids = document_tabs.iter().filter_map(|&i| app.session.docs.get(i).map(|d| d.uid)); + let reduce_motion = app.reduce_motion(); + // Cloning `opening` is free while nothing animates (an empty Vec). + let opening = ui.ctx().data_mut(|data| { + let motion = data.get_temp_mut_or_default::(motion_id); + motion.begin_frame(current_uids, now, reduce_motion); + motion.opening.clone() + }); let preferred: Vec = document_tabs .iter() .map(|&i| { @@ -217,7 +162,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { let i = document_tabs[layout.first + slot]; let d = &app.session.docs[i]; let active = i == app.session.active && !app.ui.home && app.ui.present.is_none(); - let name = d.doc.name.clone(); + let name = &d.doc.name; // In a live session: a mark after the name says whether others get in. let live = live_mark(app, d.uid); let mark_w = if live.is_some() { 18.0 } else { 0.0 }; @@ -250,13 +195,13 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { }); } let label = match live { - Some(true) => trf("{name}, in a live session", &[("name", &name)]), - Some(false) => trf("{name}, online, open only to you", &[("name", &name)]), + Some(true) => trf("{name}, in a live session", &[("name", name)]), + Some(false) => trf("{name}, online, open only to you", &[("name", name)]), None => name.clone(), }; let resp = if live.is_some() { resp.on_hover_text(&label) } else { resp }; crate::a11y::tab(&resp, &label, active); - let progress = motion.open_progress(d.uid, now); + let progress = open_progress(&opening, d.uid, now); let paint_r = r.translate(vec2(-28.0 * (1.0 - progress).powi(2), 0.0)); let painter = ui.painter().with_clip_rect(r); if active { @@ -274,7 +219,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { let shown = crate::bidi::paint_name( &painter, pos2(paint_r.min.x + 32.0, paint_r.center().y), - &name, + name, regular(12.0), t.text.linear_multiply(progress), (w - mark_w - if dirty { 72.0 } else { 60.0 }).max(0.0), @@ -294,7 +239,6 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { } icons::paint(&painter, cr.shrink(4.0), Icon::Close, t.icon_secondary.linear_multiply(progress)); } - motion.last_visible.insert(d.uid, TabSnapshot { name, rect: r, active }); if cresp.clicked() { close = Some(i); } @@ -323,15 +267,13 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover); } icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), Icon::Chevron, t.icon); - egui::Popup::menu(&resp).style(crate::right::menu_style).show(|ui| { - ui.set_min_width(180.0); + crate::right::menu_popup(&resp).show(|ui| { egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| { - for (slot, &i) in document_tabs.iter().enumerate() { - if (layout.first..layout.first + layout.widths.len()).contains(&slot) { - continue; - } - let d = &app.session.docs[i]; - if ui.button(&d.doc.name).clicked() { + for &i in &document_tabs { + let Some(d) = app.session.docs.get(i) else { continue }; + let on = i == app.session.active && !app.ui.home && app.ui.present.is_none(); + let label = if d.is_dirty() { format!("{} •", d.doc.name) } else { d.doc.name.clone() }; + if crate::right::menu_check(ui, on, &label, "") { switch = Some(i); ui.close(); } @@ -452,13 +394,9 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { app.ui.present_uid = None; } } - paint_closing_tabs(ui, &t, &motion, now); - if motion.is_animating() { + if !opening.is_empty() { ui.ctx().request_repaint(); } - ui.ctx().data_mut(|data| data.insert_temp(motion_id, motion)); - let _ = Color32::TRANSPARENT; - let _ = Align2::LEFT_TOP; }); } @@ -555,9 +493,8 @@ pub fn resize_edges(ctx: &egui::Context) { #[cfg(test)] mod layout_tests { - use super::{MIN_TAB_WIDTH, OVERFLOW_WIDTH, TabMotion, TabSnapshot, tab_drop_position, tab_layout}; + use super::{MIN_TAB_WIDTH, OPEN_TIME, OVERFLOW_WIDTH, TabMotion, open_progress, tab_drop_position, tab_layout}; use egui::{Rect, pos2}; - use std::collections::HashSet; #[test] fn tabs_shrink_to_fit_before_overflowing() { @@ -590,18 +527,16 @@ mod layout_tests { } #[test] - fn new_tabs_animate_and_closed_tabs_fall_unless_motion_is_reduced() { + fn new_tabs_animate_unless_motion_is_reduced() { let mut motion = TabMotion::default(); - motion.begin_frame(HashSet::from([1]), 0.0, false); - assert!(!motion.is_animating()); - let snapshot = TabSnapshot { name: "First".into(), rect: Rect::from_min_max(pos2(0.0, 0.0), pos2(120.0, 40.0)), active: false }; - motion.last_visible.insert(1, snapshot.clone()); - motion.begin_frame(HashSet::from([1, 2]), 1.0, false); - assert_eq!(motion.open_progress(2, 1.0), 0.0); - motion.last_visible.insert(1, snapshot); - motion.begin_frame(HashSet::from([2]), 1.05, false); - assert_eq!(motion.closing.len(), 1); - motion.begin_frame(HashSet::from([2]), 1.06, true); - assert!(!motion.is_animating()); + motion.begin_frame([1].into_iter(), 0.0, false); + assert!(motion.opening.is_empty(), "the tabs open at start-up don't animate"); + motion.begin_frame([1, 2].into_iter(), 1.0, false); + assert_eq!(open_progress(&motion.opening, 2, 1.0), 0.0); + assert_eq!(open_progress(&motion.opening, 1, 1.0), 1.0); + motion.begin_frame([1, 2].into_iter(), 1.0 + 2.0 * OPEN_TIME, false); + assert!(motion.opening.is_empty(), "the animation ends"); + motion.begin_frame([1, 2, 3].into_iter(), 2.0, true); + assert!(motion.opening.is_empty(), "Reduce motion opens tabs at once"); } } From 4cc2230013ed3253e957239c8374dc96e7946097 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 19 Oct 2026 23:43:38 +0700 Subject: [PATCH 086/114] #861: one search field for Pages and Layers; Escape closes it only from the field and goes no further; the search button shows when it's on; filter pages in the row loop --- crates/ui-egui/src/layers.rs | 70 ++++++-------------- crates/ui-egui/src/layers_behaviour_tests.rs | 26 ++++++-- 2 files changed, 43 insertions(+), 53 deletions(-) diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 25aed36e0..cd2652f49 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -185,18 +185,16 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } let sr = Rect::from_center_size(pos2(hr.max.x - 50.0, hr.center().y), vec2(24.0, 24.0)); let sresp = ui.interact(sr, ui.id().with("lsearch"), Sense::click()).tip(tr("Search pages")); - if sresp.hovered() { + if sresp.hovered() || app.ui.page_search { ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover); } icons::paint(ui.painter(), sr.shrink(5.0), Icon::Search, t.icon); let search_id = Id::new("page_search_field"); - let mut focus_search = false; if sresp.clicked() { app.ui.page_search = !app.ui.page_search; app.ui.page_filter.clear(); if app.ui.page_search { app.ui.pages_open = true; - focus_search = true; } } let ar = Rect::from_center_size(pos2(hr.max.x - 22.0, hr.center().y), vec2(24.0, 24.0)); @@ -213,14 +211,12 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { return; } if app.ui.page_search { - page_search_field(app, ui, &t, w, search_id, focus_search); + search_field(ui, &t, w, search_id, tr("Search pages"), &mut app.ui.page_filter, &mut app.ui.page_search, sresp.clicked()); } let Ok(ds) = app.session.ds() else { return }; let pages: Vec<(NodeId, String, bool)> = ds.doc.pages.iter().filter_map(|p| ds.doc.node(*p).map(|n| (*p, n.name.clone(), ds.doc.is_divider(*p)))).collect(); let filter = app.ui.page_filter.trim().to_lowercase(); - let visible_pages: Vec<(NodeId, String, bool)> = - pages.iter().filter(|(_, name, divider)| filter.is_empty() || (!divider && name.to_lowercase().contains(&filter))).cloned().collect(); let filtering = !filter.is_empty(); let cur = ds.page; let uid = ds.uid; @@ -236,7 +232,10 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { // A long list scrolls in its own area, so the layers keep their room below it. let max_h = pages_list_height(app.ui.pages_height, ui.available_height()); let mut moved = egui::ScrollArea::vertical().id_salt("pages_list").max_height(max_h).show(ui, |ui| { - for (k, (id, name, divider)) in visible_pages.iter().cloned().enumerate() { + for (k, (id, name, divider)) in pages.iter().cloned().enumerate() { + if filtering && (divider || !name.to_lowercase().contains(&filter)) { + continue; + } let sense = if filtering { Sense::click() } else { Sense::click_and_drag() }; let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), sense); if !filtering { @@ -332,36 +331,6 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { pages_edge(app, ui, moved.inner_rect.height().min(moved.content_size.y)); } -/// The Pages panel's inline filter (Escape clears and closes it). -fn page_search_field(app: &mut FigApp, ui: &mut Ui, t: &Tokens, w: f32, id: Id, focus: bool) { - let (fr, _) = ui.allocate_exact_size(vec2(w, 32.0), Sense::hover()); - let field = Rect::from_min_max(pos2(fr.min.x + 12.0, fr.min.y + 2.0), pos2(fr.max.x - 12.0, fr.max.y - 6.0)); - ui.painter().rect_filled(field, CornerRadius::same(5), t.field); - icons::paint(ui.painter(), Rect::from_center_size(pos2(field.min.x + 12.0, field.center().y), vec2(12.0, 12.0)), Icon::Search, t.icon_secondary); - let te_rect = Rect::from_min_max(pos2(field.min.x + 24.0, field.center().y - 7.0), pos2(field.max.x - 6.0, field.center().y + 7.0)); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(te_rect)); - let r = child.add( - egui::TextEdit::singleline(&mut app.ui.page_filter) - .id(id) - .frame(egui::Frame::NONE) - .font(regular(11.0)) - .hint_text(tr("Search pages")) - .vertical_align(egui::Align::Center) - .desired_width(te_rect.width()), - ); - if focus { - r.request_focus(); - } - crate::widgets::select_all_on_focus(ui, &r, &app.ui.page_filter); - if r.has_focus() { - ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); - } - if app.ui.page_search && ui.input(|i| i.key_pressed(egui::Key::Escape)) { - app.ui.page_search = false; - app.ui.page_filter.clear(); - } -} - /// The Pages list has the keyboard: a page row (or the add-page button) of the open file was /// clicked last, and the list is still on screen. pub(crate) fn pages_have_keyboard(app: &FigApp) -> bool { @@ -688,8 +657,10 @@ fn set_expanded_deep(doc: &Document, id: NodeId, open: bool, expanded: &mut BTre } } -/// The search row under the Layers header (live filter; Escape closes it). -fn search_field(app: &mut FigApp, ui: &mut Ui, t: &Tokens, w: f32, id: Id) { +/// The search row under the Pages or Layers header: a live filter `text`, taking the keyboard when +/// `focus` (just opened). Escape in it clears it and closes the row (`open`), and goes no further +/// (the canvas keeps its selection). +fn search_field(ui: &mut Ui, t: &Tokens, w: f32, id: Id, hint: &str, text: &mut String, open: &mut bool, focus: bool) { let (fr, _) = ui.allocate_exact_size(vec2(w, 32.0), Sense::hover()); let field = Rect::from_min_max(pos2(fr.min.x + 12.0, fr.min.y + 2.0), pos2(fr.max.x - 12.0, fr.max.y - 6.0)); ui.painter().rect_filled(field, CornerRadius::same(5), t.field); @@ -697,21 +668,25 @@ fn search_field(app: &mut FigApp, ui: &mut Ui, t: &Tokens, w: f32, id: Id) { let te_rect = Rect::from_min_max(pos2(field.min.x + 24.0, field.center().y - 7.0), pos2(field.max.x - 6.0, field.center().y + 7.0)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(te_rect)); let r = child.add( - egui::TextEdit::singleline(&mut app.ui.layer_filter) + egui::TextEdit::singleline(text) .id(id) .frame(egui::Frame::NONE) .font(regular(11.0)) - .hint_text(tr("Find layers")) + .hint_text(hint) .vertical_align(egui::Align::Center) .desired_width(te_rect.width()), ); - crate::widgets::select_all_on_focus(ui, &r, &app.ui.layer_filter); + // After the field: the click that opened it ends elsewhere, which would take the focus back. + if focus { + r.request_focus(); + } + crate::widgets::select_all_on_focus(ui, &r, text); if r.has_focus() { ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); } - if (r.has_focus() || r.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)) { - app.ui.layer_search = false; - app.ui.layer_filter.clear(); + if (r.has_focus() || r.lost_focus()) && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { + *open = false; + text.clear(); } } @@ -730,12 +705,9 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { if sresp.clicked() { app.ui.layer_search = !app.ui.layer_search; app.ui.layer_filter.clear(); - if app.ui.layer_search { - ui.memory_mut(|m| m.request_focus(search_id)); - } } if app.ui.layer_search { - search_field(app, ui, &t, w, search_id); + search_field(ui, &t, w, search_id, tr("Find layers"), &mut app.ui.layer_filter, &mut app.ui.layer_search, sresp.clicked()); } let Ok(ds) = app.session.ds() else { return }; let doc = ds.doc.clone(); diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs index a1f3685a2..b963ee827 100644 --- a/crates/ui-egui/src/layers_behaviour_tests.rs +++ b/crates/ui-egui/src/layers_behaviour_tests.rs @@ -217,16 +217,34 @@ fn pages_search_button_opens_the_filter_and_escape_clears_it() { } #[test] -fn pages_search_escape_closes_even_when_the_field_is_unfocused() { - let mut u = Ui::new(Session::new()); +fn pages_search_leaves_escape_to_the_canvas_when_the_field_is_unfocused() { + let mut s = Session::new(); + let r = rect(&mut s, "r", 0.0, None); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = Ui::new(s); u.app.ui.page_search = true; u.app.ui.page_filter = "COMPON".into(); u.frame(vec![]); u.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); - assert!(!u.app.ui.page_search, "Escape closes page search without field focus"); - assert!(u.app.ui.page_filter.is_empty(), "Escape clears the filter without field focus"); + assert!(u.app.session.selection().is_empty(), "Escape deselects on the canvas"); + assert!(u.app.ui.page_search, "the page search stays open"); + assert_eq!(u.app.ui.page_filter, "COMPON", "and keeps its filter"); +} + +#[test] +fn escape_in_the_pages_search_does_not_reach_the_canvas() { + let mut s = Session::new(); + let r = rect(&mut s, "r", 0.0, None); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = Ui::new(s); + click(&mut u, Pos2::new(246.0, 124.0), Modifiers::NONE); + assert!(u.app.ui.page_search); + u.frame(vec![Event::Text("x".into())]); + u.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + assert!(!u.app.ui.page_search, "Escape closes the page search"); + assert_eq!(u.app.session.selection(), vec![r], "and leaves the selection alone"); } #[test] From 203ec13e03bd70ce696f9afabab48dd49172ce45 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 19 Oct 2026 23:43:40 +0700 Subject: [PATCH 087/114] #821: text.loadFont reads at most MAX_FONT_FILE (the font scan's 256 MiB cap) --- crates/engine/src/cmd/create.rs | 6 ++---- crates/engine/src/cmd/text.rs | 2 +- crates/text/src/fontdb.rs | 8 +++++++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 6ec7d4e59..3a9f6d67f 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -362,15 +362,13 @@ fn free_spot(s: &Session, w: f64, h: f64) -> Result<(f64, f64)> { }) } -/// The bytes of the file at `path`, within the session's allowed file roots. #[cfg(not(target_arch = "wasm32"))] -pub(super) fn read_path(path: &str) -> Result> { +fn read_path(path: &str) -> Result> { crate::file_access::read(path) } -/// The browser has no files to read: always an error. #[cfg(target_arch = "wasm32")] -pub(super) fn read_path(path: &str) -> Result> { +fn read_path(path: &str) -> Result> { Err(crate::EngineError::Other(format!("{path}: files can't be read in the browser"))) } diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 0f0f003de..6e1f5c3db 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -497,7 +497,7 @@ pub fn specs() -> Vec { let b64 = b64.split_once(',').map(|(_, d)| d.to_string()).unwrap_or(b64); base64::engine::general_purpose::STANDARD.decode(b64.trim()).map_err(|e| bad(cmd, format!("`data`: {e}")))? } else if let Some(path) = string(p, "path") { - super::create::read_path(&path)? + super::file::read_capped(&path, figcraft_text::fontdb::MAX_FONT_FILE)? } else { return Err(bad(cmd, "pass `path` or `data` (base64)")); }; diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 814d3a2f7..604f3eee5 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -819,13 +819,19 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap MAX_FONT_FILE { + continue; + } + let Some(bytes) = usize::try_from(meta.len()).ok().and_then(|l| index_bytes(&path, l)) else { continue }; for (index, family, style) in faces_in(&bytes) { out.entry(family.to_lowercase()).or_insert_with(|| (family.clone(), Vec::new())).1.push(Pending { path: path.clone(), index, style }); } } } +/// The largest font file read (CJK collections run to ~120 MB, Apple Color Emoji ~180 MB). +pub const MAX_FONT_FILE: u64 = 256 * 1024 * 1024; + /// The parts of a font file the scan reads: headers, table directories and `name` tables, at their /// offsets in a zeroed buffer of the file's length (reading whole files was most of the scan). fn index_bytes(path: &std::path::Path, len: usize) -> Option> { From 7126deaed001c6e03c25046a44c80c75e8233ec5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 19 Oct 2026 23:43:40 +0700 Subject: [PATCH 088/114] #821: es and fi labels for text.rescanFonts, text.loadFont, text.refit --- crates/ui-egui/locales/es.toml | 3 +++ crates/ui-egui/locales/fi.toml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index b4c7c3352..1af2f09f4 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -2139,6 +2139,7 @@ status = "draft — needs native review" "text.listIndent" = "Aumentar sangría de la lista" "text.listOutdent" = "Reducir sangría de la lista" "text.listStyle" = "Estilo de lista" +"text.loadFont" = "Cargar archivo de fuente" "text.maxLines" = "Máximo de líneas" "text.missingFonts" = "Fuentes que faltan" "text.numberList" = "Lista numerada" @@ -2147,7 +2148,9 @@ status = "draft — needs native review" "text.paragraphSpacing" = "Espacio entre párrafos" "text.pathOptions" = "Opciones de texto en trazado" "text.preview" = "Previsualizar cambio de texto" +"text.refit" = "Ajustar texto a las fuentes" "text.removeFromPath" = "Quitar texto del trazado" +"text.rescanFonts" = "Volver a buscar fuentes instaladas" "text.set" = "Establecer texto" "text.setFeatures" = "Funciones OpenType" "text.setVariations" = "Ejes de fuente variable" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index db5d25794..2dff384bc 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -2141,6 +2141,7 @@ status = "draft — needs native review" "text.listIndent" = "Sisennä luettelo" "text.listOutdent" = "Vähennä luettelon sisennystä" "text.listStyle" = "Luettelotyyli" +"text.loadFont" = "Lataa fonttitiedosto" "text.maxLines" = "Rivien enimmäismäärä" "text.missingFonts" = "Puuttuvat fontit" "text.numberList" = "Numeroitu luettelo" @@ -2149,7 +2150,9 @@ status = "draft — needs native review" "text.paragraphSpacing" = "Kappaleväli" "text.pathOptions" = "Tekstipolun asetukset" "text.preview" = "Esikatsele tekstimuutosta" +"text.refit" = "Sovita teksti fontteihin" "text.removeFromPath" = "Poista teksti polulta" +"text.rescanFonts" = "Etsi asennetut fontit uudelleen" "text.set" = "Aseta teksti" "text.setFeatures" = "OpenType-ominaisuudet" "text.setVariations" = "Muuttuvan fontin akselit" From 6325a323eecb26ddf2fdf738e34fbb9230a21b74 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 19 Oct 2026 23:43:41 +0700 Subject: [PATCH 089/114] #821: move the loadFont/rescanFonts test into fonts_tests --- crates/engine/src/fonts_tests.rs | 18 ++++++++++++++++++ crates/engine/src/tests.rs | 18 ------------------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/fonts_tests.rs b/crates/engine/src/fonts_tests.rs index 463fb9046..0fe6dcb3f 100644 --- a/crates/engine/src/fonts_tests.rs +++ b/crates/engine/src/fonts_tests.rs @@ -195,3 +195,21 @@ fn refit_reaches_text_in_instances() { assert_eq!(refitted(&r), vec![copy.0]); assert_eq!(width(&s, copy), want); } + +/// `text.loadFont` refuses what isn't a font file; `text.rescanFonts` reports what it added. +#[test] +fn fonts_load_from_files_and_rescans_report_new_families() { + let mut s = Session::new(); + assert!(s.execute("text.loadFont", &json!({})).is_err()); + assert!(s.execute("text.loadFont", &json!({"data": "bm90IGEgZm9udA=="})).is_err(), "not a font file"); + assert!(s.execute("text.loadFont", &json!({"data": "%%%"})).is_err()); + assert!(s.execute("text.loadFont", &json!({"path": "no-such-dir/x.ttf"})).is_err()); + if let Some(f) = figcraft_text::CRAFT_FONTS.first() { + use base64::Engine as _; + let data = base64::engine::general_purpose::STANDARD.encode(f.bytes); + let r = s.execute("text.loadFont", &json!({"data": data})).unwrap(); + assert_eq!(r["families"].as_array().map(Vec::len), Some(1)); + } + let r = s.execute("text.rescanFonts", &json!({})).unwrap(); + assert!(r["added"].is_array()); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index ad3b6aa33..d6e2c9e05 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -627,21 +627,3 @@ fn find_replace_changing_only_the_case_replaces_every_occurrence() { assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "FigCraft and FigCraft, FigCraft"); assert_eq!(s.doc().unwrap().node(f).unwrap().name, "FigCraft / FigCraft"); } - -/// `text.loadFont` refuses what isn't a font file; `text.rescanFonts` reports what it added. -#[test] -fn fonts_load_from_files_and_rescans_report_new_families() { - let mut s = sess(); - assert!(s.execute("text.loadFont", &json!({})).is_err()); - assert!(s.execute("text.loadFont", &json!({"data": "bm90IGEgZm9udA=="})).is_err(), "not a font file"); - assert!(s.execute("text.loadFont", &json!({"data": "%%%"})).is_err()); - assert!(s.execute("text.loadFont", &json!({"path": "no-such-dir/x.ttf"})).is_err()); - if let Some(f) = figcraft_text::CRAFT_FONTS.first() { - use base64::Engine as _; - let data = base64::engine::general_purpose::STANDARD.encode(f.bytes); - let r = s.execute("text.loadFont", &json!({"data": data})).unwrap(); - assert_eq!(r["families"].as_array().map(Vec::len), Some(1)); - } - let r = s.execute("text.rescanFonts", &json!({})).unwrap(); - assert!(r["added"].is_array()); -} From 9fddc4b058aa4440f8e629ea8530cef2efbe0504 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 19 Oct 2026 23:49:00 +0700 Subject: [PATCH 090/114] #854: drop the progress-doc edits (ROADMAP, gaps, parity docs, generated checklist); the maintainer adds them --- ROADMAP.md | 2 -- docs/gaps.md | 6 +----- docs/parity-checklist.md | 4 ++-- docs/target-app-parity.md | 5 ++--- docs/ui-parity.md | 5 ++--- 5 files changed, 7 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1c3db7e40..8a587286d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,8 +131,6 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log -- **2026-10-18** · Smart-selection drag reordering (M1): Smart-selection center rings insert a layer through a regular row, column or complete top-left-aligned grid; Command/Ctrl-drag swaps entries. Unequal sizes reflow with the original gaps, hierarchy and selection stay intact, and each drag is one undo step. Escape or Undo during the drag cancels; switching documents, pages or tools rolls back the owning gesture. Mixed gaps, incomplete grids and unsupported selections keep spacing controls without reorder rings. Multiple marked items and irregular-grid insertion remain unimplemented. Engine and raw-input UI regression tests; desktop Linux before/during/after screenshots and insertion, undo/redo and Escape checked on synthetic cards. No side-by-side comparison with the incumbent. Headline estimates unchanged. Validation: 1,039 engine unit tests passed (2 ignored), 555 UI unit tests passed (1 ignored); formatting, layering, assets, parity and `cargo xtask wasm` passed. Clippy passes with only the existing `collapsible_match` findings suppressed; strict `cargo xtask ci` stops on seven findings in unchanged UI files. The workspace test run stops on the unchanged text crate’s `tail_tests::hanging_punctuation_outside_the_aligned_edge` (2 lines instead of 1); no text code changed. - - **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. - **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. - **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. diff --git a/docs/gaps.md b/docs/gaps.md index 5f2255e63..9b1a770c1 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (smart-selection drag reordering; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -174,9 +174,6 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe Layers; Paste over and Paste properties from the keyboard; then (#753) Selection colors for mixed selections and smart-selection spacing fields and canvas handles (no reorder rings). Each was checked in the app (on Windows; the submenus by their author), none side by side with Figma, none on macOS. -- **Landed (2026-10-18):** smart-selection center rings for single-layer insertion and - Command/Ctrl swaps in regular rows, columns and complete top-left-aligned grids; live preview, - cancellation and one undo step. Multiple marked layers and irregular-grid insertion remain. - **Evidence:** ROADMAP item 7 ("Left: tool by tool, panel by panel against the incumbent"). - **Impact:** Figma users notice the feel within minutes; it decides whether they stay. - **Estimate:** 40–60 h. Detail in [`ui-parity.md`](ui-parity.md). @@ -447,7 +444,6 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| -| 2026-10-18 | minor | Smart-selection center rings insert a layer through a regular row, column or complete top-left-aligned grid; Command/Ctrl-drag swaps entries. Unequal sizes reflow with the original gaps, hierarchy and selection stay intact, and each drag is one undo step. Escape or Undo during the drag cancels; switching documents, pages or tools rolls back the owning gesture. Mixed gaps, incomplete grids and unsupported selections keep spacing controls without reorder rings. Multiple marked items and irregular-grid insertion remain unimplemented. Engine and raw-input UI regression tests; desktop Linux before/during/after screenshots and insertion, undo/redo and Escape checked on synthetic cards. No side-by-side comparison with the incumbent. Headline estimates unchanged. | | 2026-10-17 | minor | The 2026-10-17 landings. Gap 1: a new Landed entry (community batches #727 and #728, then #745: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments, images in the message, instance layers' size and resizing overrides, Hug where nothing is recorded; budgets from the file's size; the import report; a `.fig` copy from Save as; paste from another design app), its Missing list cut to what is still left, and the wrong "whether other apps open its files is unknown" replaced by what was checked; all on synthetic files, so the beta blocker and hours stand. Gap 2: server limits, log recovery and web join links in the evidence. Gap 4: menus, the Actions palette and the editor fixes (#733, #735, #736, #729), then Selection colors and smart-selection spacing (#753) landed, none compared side by side. Gap 5: catalog counts re-measured (2,272 entries; drafts at 95–99%; Indonesian, Czech, Turkish and Swedish listed). Gap 8: the Linux and BSD clipboard changes and Wayland window controls, not run there by the maintainers. Ranking and hours unchanged | | 2026-10-15 | minor | Gap 7: bounded canvas overscan for smoother panning. Estimates and ranking unchanged. | | 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index d53c9c52e..775a4fcb4 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,8 +1,8 @@ # Parity checklist -> **Generated:** 2026-10-18 by `cargo xtask parity`. Do not edit by hand. +> **Generated:** 2026-10-17 by `cargo xtask parity`. Do not edit by hand. -FigCraft commands registered for the incumbent's main menu and toolbar (563 commands in the engine). +FigCraft commands registered for the incumbent's main menu and toolbar (561 commands in the engine). **212 / 212 menu items (100.0%)** diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a9a1a507a..16a5af69b 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-10-18 · **Change:** minor (smart-selection drag reordering; 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); 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) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (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); 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) · **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 @@ -95,7 +95,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | # | Area | Weight | Breadth | Ready | Remaining | Hours | |---|---|---|---|---|---|---| | 1 | Canvas & navigation | 6 | 98% | 74% | Outline mode's Include hidden layers and Include object bounds, Display P3 on screen, QA at extreme zooms (behaviour suite for navigation, rulers, guides, layout grids and outline mode landed: guide selection, Auto grid counts, outline-mode clicks) | 1–4 | -| 2 | Selection & transform | 8 | 97% | 72% | Handles, measuring and snapping checked tool by tool against Figma and real files (snap distance, handle reach, cursors); children turned off a right angle scale without skew, approximately (behaviour suite from the documentation, rotated groups and mixed selections, equal spacing snapping, Cmd/Ctrl to ignore constraints and marquees inside frames landed; smart-selection rings reorder single layers in regular rows, columns and complete aligned grids, with Command/Ctrl swaps; multi-item marks and irregular-grid insertion remain) | 3–5 | +| 2 | Selection & transform | 8 | 97% | 72% | Handles, measuring and snapping checked tool by tool against Figma and real files (snap distance, handle reach, cursors); children turned off a right angle scale without skew, approximately (behaviour suite from the documentation, rotated groups and mixed selections, equal spacing snapping, Cmd/Ctrl to ignore constraints and marquees inside frames landed) | 3–5 | | 3 | Layers panel | 5 | 99% | 85% | QA with long real files (behaviour suite from the documentation, Rename layers dialog, sweeping eyes and padlocks, page dragging and dividers, several layers dropped together, keyboard box selection, F2 rename, Shift+F10 context menu and 10k-layer timings landed) | 1–2 | | 4 | Shapes & vector | 8 | 99% | 73% | Corner smoothing on polygons, stars and vectors; the transform box around points of several layers; per-point caps and joins on dashed, variable-width and brush strokes; vector QA with real files (behaviour suites from the documentation, corner radius and star ratio handles, rounded vector points drawn, also beside curved segments, a cap and join per point, several handles dragged together, cut and paint bucket drags, delete and heal along the curve, a boolean group's look by operation and the miter angle landed) | 1–3 | | 5 | Paint | 7 | 90% | 71% | Video fills, wide-gamut canvas, HEIC images; plus darker in PDF (behaviour suite from the documentation, pass-through and isolating groups, clipping frames that don't isolate, plus darker, animated GIF fills, TIFF images and embedded image colour profiles landed) | 2–5 | @@ -638,7 +638,6 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| -| 2026-10-18 | minor | Smart-selection center rings insert a layer through a regular row, column or complete top-left-aligned grid; Command/Ctrl-drag swaps entries. Unequal sizes reflow with the original gaps, hierarchy and selection stay intact, and each drag is one undo step. Escape or Undo during the drag cancels; switching documents, pages or tools rolls back the owning gesture. Mixed gaps, incomplete grids and unsupported selections keep spacing controls without reorder rings. Multiple marked items and irregular-grid insertion remain unimplemented. Engine and raw-input UI regression tests; desktop Linux before/during/after screenshots and insertion, undo/redo and Escape checked on synthetic cards. No side-by-side comparison with the incumbent. Headline estimates unchanged. | | 2026-10-17 | minor | Menu breadth re-run with `cargo xtask parity`: 212 / 212 (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Rows for the 2026-10-17 landings: import/export (area 14) and the file formats dimension name the `.fig` import's new fidelity (instance identity, overrides by key, text style precedence, grid tracks, slots, connector labels, per-point joins, text on a path, image adjustments, list start numbers, vertical trim), budgets from the file's size, the import report, a `.fig` copy from Save as and paste from another design app; the localization evidence takes the re-measured catalog counts (2,272 entries, drafts at 95–99%, Indonesian and Czech drafts, Swedish and Turkish catalogs); the Automation line adds control requests answered while the window is minimised (#720); batch #753's Selection colors, smart-selection spacing, Present tab and auto layout on empty frames are in ui-parity, prototyping-parity and auto-layout-parity. Numbers unchanged: every `.fig` and paste change is checked on synthetic files or clipboard HTML made from our own export, and real files are what gap 1's blocker and the file-exchange discounts wait for; the UI changes (menus, the Actions palette, editor fixes) are not compared side by side, so UI/UX stays at 58%; the localization grade and hours stay (Indonesian's draft is offset by the drafts' new gaps) | | 2026-10-17 | minor | Automation roots (M8, #715, #481): `figcraft-cli mcp --automation-read-root DIR --automation-write-root DIR` (each repeatable, existing folders) and the desktop app with the same flags confine every file a command reads to the read and write roots and every file it writes to the write roots; paths are resolved (links followed, `..`, Windows `\\?\` and UNC paths, case) and compared by whole folder names, anything else is an ordinary error. The engine reaches the file system through one module (enforced by `crates/engine/clippy.toml`), and a sweep runs every command with paths outside the roots. `--attach` refuses the flags (the app confines itself); `ui.screenshot` and `ui.render` return PNG data without a `path`, so the attached MCP server writes no temporary files. Numbers unchanged. | | 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index eab5aafe4..766284f1d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (smart-selection drag reordering; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -69,7 +69,7 @@ interaction feel not yet compared. | Numeric fields: arithmetic, scrubbing by dragging the label, mixed values | done | Numeric panels use `widgets::number_field`; Typography line height, letter spacing and paragraph spacing share its leading-icon scrub logic (#373), retaining their existing unit parsing, Auto/Mixed values, variable chips and selected-character targeting (`typography_scrub_tests`). Scrubbing the label: a step a point (1 px; 1% of opacity, 1° of rotation), ten with Shift, a tenth with Alt, each stretch at the rate held while it was dragged, one undo step when let go; past the edge of the screen it goes on where the platform reports the mouse's own motion (desktop), otherwise it stops there. Arrow keys: Up/Down step a pixel field by the small nudge and Shift by the big nudge (the preferences), other fields by their own step and ten of it; they work while typing (from what is typed) and with the keyboard on the field. Typing: `+ - * /`, brackets, units (`px`, `°`, `%`, `x`, `ms`), a decimal comma; `+10`, `*2`, `/4` are relative to the value, and `50%`, `+10%`, `-10%` too in a field that isn't a percentage. Enter, Tab or a click elsewhere commits (Tab and Shift+Tab move to the next and previous control), Escape restores; a field left as it was commits nothing (no undo step, and a rounded display isn't written back). Mixed values show "Mixed": a number sets them all, a relative edit or an arrow key goes to each layer from its own value in one undo step, for X, Y, W, H, rotation, opacity, corner radius and stroke weight (elsewhere `+10` on Mixed sets 10 and `*2` does nothing) (`field_tests`). Left: scrubbing a Mixed field (nothing happens), `x2`-style suffix edits, the pointer isn't wrapped to the other side of the screen; rates not compared with Figma's | | Transform box at small sizes | done | a press inside a layer smaller than its handles moves it, the nearest handle wins, handles answer just outside and are hidden under 16 px on screen; cursors follow each handle and the box's rotation | | Locked layers | done | selectable from the layers panel; no handles, not dragged with the rest of a selection | -| Smart selection spacing | partial | with layers in a row, a column or an aligned grid selected, the Layout section shows horizontal and vertical spacing ("Mixed" until set; typed, scrubbed, fractional and negative) and pink handles in the gaps on the canvas drag them, one undo step, Escape cancels (`arrange.spacing`; sizes, parents and offsets within each track kept; at most 2,000 layers; flow children, locked layers and layers inside instances left out). Center rings drag a single layer through a regular row, column or complete top-left-aligned grid; Command/Ctrl swaps entries. Preview, target outline, Escape cancellation and one-step undo (`arrange.reorder`, `selection_reorder_tests`, `selection_spacing_tests`). Sizes, gaps and layer hierarchy kept. Mixed gaps and incomplete or differently aligned grids have no rings; multiple marked-item dragging remains. Spacing checked by its author on macOS and in the app on Windows (#402) | +| Smart selection spacing | partial | with layers in a row, a column or an aligned grid selected, the Layout section shows horizontal and vertical spacing ("Mixed" until set; typed, scrubbed, fractional and negative) and pink handles in the gaps on the canvas drag them, one undo step, Escape cancels (`arrange.spacing`; sizes, parents and offsets within each track kept; at most 2,000 layers; flow children, locked layers and layers inside instances left out). No reorder rings for swapping layers; checked by its author on macOS and in the app on Windows (#402) | | Selection colors | partial | for a mixed selection the Design panel lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart): editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a colour style of the document (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.selectColor`). No library styles or variable binding from there (#403) | | Keyboard box selection | done | Alt+Space on macOS, Ctrl+Space on Windows and Linux, or F8 (`select.area`): a box around the selection or in the middle of the view; arrows move it, Cmd/Ctrl+arrows or Shift+arrows size it (Shift with Cmd/Ctrl: farther), Enter selects like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter nested), Escape closes | @@ -172,7 +172,6 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| -| 2026-10-18 | minor | Smart-selection center rings insert a layer through a regular row, column or complete top-left-aligned grid; Command/Ctrl-drag swaps entries. Unequal sizes reflow with the original gaps, hierarchy and selection stay intact, and each drag is one undo step. Escape or Undo during the drag cancels; switching documents, pages or tools rolls back the owning gesture. Mixed gaps, incomplete grids and unsupported selections keep spacing controls without reorder rings. Multiple marked items and irregular-grid insertion remain unimplemented. Engine and raw-input UI regression tests; desktop Linux before/during/after screenshots and insertion, undo/redo and Escape checked on synthetic cards. No side-by-side comparison with the incumbent. Headline estimates unchanged. | | 2026-10-18 | minor | A rename (layers double-click, F2, File ▸ Rename, a page) selects the whole name in the frame its field first draws, so the first key replaces the name instead of being appended. Checked in the app on Windows over the control channel | | 2026-10-17 | minor | Escape or Enter while the pen draws ends the path and keeps its vector in edit mode with the pen, so the next path is drawn into the same layer (unconnected); another Escape leaves edit mode (#760). Entering edit mode on a layer that already is a vector no longer records an empty undo step. Numbers unchanged | | 2026-10-17 | minor | Text layers keep the platform IME on for every frame of an edit, not only frames with input (an idle frame turned it off and macOS dropped each composition, #747); the candidate window opens at the caret instead of the canvas corner; keys pressed while composing go to the IME | From cfbb5a8f504f3c0150b0ee404621a1ac20da38cd Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 19 Oct 2026 23:49:01 +0700 Subject: [PATCH 091/114] #854: no help-centre URL in selection_reorder.rs --- crates/engine/src/selection_reorder.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/engine/src/selection_reorder.rs b/crates/engine/src/selection_reorder.rs index e538fc8bc..d54109c79 100644 --- a/crates/engine/src/selection_reorder.rs +++ b/crates/engine/src/selection_reorder.rs @@ -1,7 +1,6 @@ //! Geometry for reordering a smart selection. Only regular, non-overlapping rows, columns //! and complete top-left-aligned grids have unambiguous insertion slots. -//! Behavior reference: https://help.figma.com/hc/en-us/articles/360040450233 -//! (public documentation only; multi-item marking and irregular grids remain out of scope). +//! Marking several items and irregular grids are out of scope. use figcraft_doc::{Document, NodeId}; use figcraft_geom::{Point, Rect}; From 109b90c0aac1a9c881e126c2389da07858efa4d0 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 19 Oct 2026 23:49:02 +0700 Subject: [PATCH 092/114] #854: locale entries in sorted position --- crates/ui-egui/locales/en.toml | 2 +- crates/ui-egui/locales/es.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 132fda0bd..0544ecccd 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -8,7 +8,6 @@ name = "English" [ui] -"Reorder selection" = "Reorder selection" "% of all commits" = "% of all commits" "(no longer published)" = "(no longer published)" "({n} warnings: some entries were skipped)" = { one = "({n} warning: some entries were skipped)", other = "({n} warnings: some entries were skipped)" } @@ -1101,6 +1100,7 @@ name = "English" "Rename on the server…" = "Rename on the server…" "Rename page" = "Rename page" "Rename to" = "Rename to" +"Reorder selection" = "Reorder selection" "Replace all" = "Replace all" "Replace mixed export settings" = "Replace mixed export settings" "Replace with" = "Replace with" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index dc8bc40bf..862f05700 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -8,7 +8,6 @@ name = "Español" status = "draft — needs native review" [ui] -"Reorder selection" = "Reordenar selección" "Compatibility notes" = "Notas de compatibilidad" "Couldn't download the variables" = "No se pudieron descargar las variables" @@ -1102,6 +1101,7 @@ status = "draft — needs native review" "Rename on the server…" = "Cambiar el nombre en el servidor…" "Rename page" = "Cambiar nombre de página" "Rename to" = "Nuevo nombre" +"Reorder selection" = "Reordenar selección" "Replace all" = "Reemplazar todo" "Replace mixed export settings" = "Reemplazar ajustes de exportación mixtos" "Replace with" = "Reemplazar con" @@ -1743,7 +1743,6 @@ status = "draft — needs native review" "⌥ drag" = "⌥ arrastrar" [commands] -"arrange.reorder" = "Reordenar selección" "align.bottom" = "Alinear abajo" "align.hcenter" = "Alinear centros horizontales" "align.left" = "Alinear a la izquierda" @@ -1766,6 +1765,7 @@ status = "draft — needs native review" "arrange.front" = "Traer al frente" "arrange.packH" = "Agrupar en horizontal" "arrange.packV" = "Agrupar en vertical" +"arrange.reorder" = "Reordenar selección" "arrange.roundToPixels" = "Redondear a píxeles" "arrange.spacing" = "Espaciado" "arrange.tidy" = "Ordenar" From bb793470f1c30d3df6cfcf44148af37acaa7cd7d Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 19 Oct 2026 23:49:03 +0700 Subject: [PATCH 093/114] #854: no reorder ring on a layer under 16 px on screen, and its hit radius is at most a quarter of the layer, so a small layer still drags the selection --- crates/ui-egui/src/selection_spacing.rs | 17 ++++++++---- crates/ui-egui/src/selection_spacing_tests.rs | 26 +++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/selection_spacing.rs b/crates/ui-egui/src/selection_spacing.rs index 01e32d30f..3a39955ac 100644 --- a/crates/ui-egui/src/selection_spacing.rs +++ b/crates/ui-egui/src/selection_spacing.rs @@ -61,6 +61,15 @@ fn drag_id() -> Id { Id::new("selection_spacing_drag") } +/// The reorder ring of a layer with world bounds `b`: its centre on screen and hit radius. The +/// radius is 8 px, at most a quarter of the layer's smaller side on screen, so most of a small +/// layer still drags the selection; a layer under 16 px on screen has no ring. +fn ring(app: &FigApp, b: figcraft_geom::Rect) -> Option<(Pos2, f32)> { + let zoom = app.session.ds().map_or(1.0, |d| d.view.zoom); + let side = ((b.x1 - b.x0).min(b.y1 - b.y0) * zoom) as f32; + (side >= 16.0).then(|| (app.to_screen((b.x0 + b.x1) * 0.5, (b.y0 + b.y1) * 0.5), (side / 4.0).min(8.0))) +} + #[derive(Clone)] enum Action { Spacing { axis: Axis, value: f64, last_value: Option }, @@ -279,8 +288,7 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Option, hovered: let ids = app.session.selection(); if let Some(reorder) = reorder_layout(app, ctx, &ids) { for (id, b) in &reorder.slots { - let center = app.to_screen((b.x0 + b.x1) * 0.5, (b.y0 + b.y1) * 0.5); - if center.distance(p) <= 8.0 { + if ring(app, *b).is_some_and(|(center, r)| center.distance(p) <= r) { ctx.set_cursor_icon(CursorIcon::Grab); if ctx.input(|i| i.pointer.primary_pressed()) { let source = *id; @@ -329,9 +337,8 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { }; if let Some(layout) = reorder { for (id, _) in &layout.slots { - let Some(b) = ds.doc.world_bounds(*id) else { continue }; - let center = app.to_screen((b.x0 + b.x1) * 0.5, (b.y0 + b.y1) * 0.5); - let hot = hover.is_some_and(|p| center.distance(p) <= 8.0); + let Some((center, r)) = ds.doc.world_bounds(*id).and_then(|b| ring(app, b)) else { continue }; + let hot = hover.is_some_and(|p| center.distance(p) <= r); let active = matches!(drag.as_ref().map(|d| &d.action), Some(Action::Reorder { source, .. }) if source == id); if active { painter.circle_filled(center, 4.5, PINK); diff --git a/crates/ui-egui/src/selection_spacing_tests.rs b/crates/ui-egui/src/selection_spacing_tests.rs index 685f7b4e9..2ee44742f 100644 --- a/crates/ui-egui/src/selection_spacing_tests.rs +++ b/crates/ui-egui/src/selection_spacing_tests.rs @@ -333,3 +333,29 @@ fn an_externally_finished_reorder_cannot_resume_on_later_pointer_motion() { assert_eq!(u.app.session.undo_depth().0, history); assert_eq!(xy(&u, ids[0]), (260., 100.)); } + +#[test] +fn small_layers_on_screen_have_no_reorder_ring_and_drag_the_selection_from_their_middle() { + let mut u = Ui::new(Session::new()); + let z = u.app.session.ds().unwrap().view.zoom; + // 12 px squares 36 px apart on screen: a regular row, too small for rings. + let ids: Vec = (0..3) + .map(|col| { + let p = json!({"x": (100. + f64::from(col) * 36.) / z, "y": 100. / z, "width": 12. / z, "height": 12. / z}); + NodeId(u.app.session.execute("shape.rectangle", &p).unwrap()["id"].as_u64().unwrap()) + }) + .collect(); + u.app.session.execute("select.set", &json!({"ids": ids})).unwrap(); + let before: Vec<_> = ids.iter().map(|id| xy(&u, *id)).collect(); + let p = u.app.to_screen(before[0].0 + 6. / z, before[0].1 + 6. / z); + let output = u.frame_output(vec![Event::PointerMoved(p)]); + assert_ne!(output.platform_output.cursor_icon, egui::CursorIcon::Grab); + drag(&mut u, p, vec2(0., 60.), true); + let after: Vec<_> = ids.iter().map(|id| xy(&u, *id)).collect(); + assert!(after[0].1 > before[0].1, "the selection moved: {before:?} -> {after:?}"); + assert!( + before.iter().zip(&after).all(|(b, a)| (a.0 - b.0).abs() < 1e-6 && (a.1 - b.1 - (after[0].1 - before[0].1)).abs() < 1e-6), + "all together, in order" + ); + assert_eq!(u.app.session.selection(), ids); +} From 99a8ef0e26555f80c5ee8c859c8589feba972068 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 19 Oct 2026 23:55:26 +0700 Subject: [PATCH 094/114] #832: drop the progress-doc edits (ROADMAP header, log and revision rows, auto-layout-parity row); the maintainer adds them --- ROADMAP.md | 4 +--- docs/auto-layout-parity.md | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b00408dc7..8a587286d 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-18 · **Change:** minor (individual padding in the Layout panel, M2) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) 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 @@ -131,7 +131,6 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log -- **2026-10-18** · Individual padding in the Layout panel (M2): the Spacing row's toggle opens per-side Top/Right/Bottom/Left fields in a 2×2 grid in place of the H/V fields, each bound to its own padding key with one undo step; shut H/V fields show both values ("16,54") when the sides disagree instead of "Mixed". Original code-drawn side icons, English and Spanish strings. Numbers unchanged. - **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. - **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. - **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. @@ -317,7 +316,6 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| -| 2026-10-18 | minor | Individual padding in the Layout panel (M2): the Spacing row's toggle opens per-side Top/Right/Bottom/Left fields in a 2×2 grid in place of the H/V fields, each bound to its own padding key with one undo step; shut H/V fields show both values ("16,54") when the sides disagree instead of "Mixed". Original code-drawn side icons, English and Spanish strings. Numbers unchanged. | | 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged | | 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. | | 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | diff --git a/docs/auto-layout-parity.md b/docs/auto-layout-parity.md index 5342135a3..ea2cc54f5 100644 --- a/docs/auto-layout-parity.md +++ b/docs/auto-layout-parity.md @@ -64,7 +64,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver | Nested hug/fill settling in one pass (three levels, wrapping fill text, grid rows) | done | | | Absolute position (ignore auto layout) with constraints | done | | | Strokes included in layout (inside strokes), canvas stacking | done | | -| On-canvas padding and gap handles, incl. wrap row/column gap and grid gaps | done | an automatic gap's handle shows the distance it has; dragging it fixes the gap from there (silent in the documentation); the Layout panel's Spacing row opens per-side padding fields (Top/Right/Bottom/Left) behind a toggle | +| On-canvas padding and gap handles, incl. wrap row/column gap and grid gaps | done | an automatic gap's handle shows the distance it has; dragging it fixes the gap from there (silent in the documentation) | | Drag to reorder (wrap-aware, by row or column), into grid cells, across nested frames | done | `al_drag_tests.rs` | | Arrow keys move children one place (flows, rows, columns, grid cells, into neighbours) | done | `al_keys_tests.rs` | | Constraints: left, right, left and right, centre, scale; top/bottom likewise | done | | @@ -77,7 +77,6 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver | Date | Change | Summary | |---|---|---| -| 2026-10-18 | minor | Individual padding in the Layout panel (M2): the Spacing row's toggle opens per-side Top/Right/Bottom/Left fields in a 2×2 grid in place of the H/V fields, each bound to its own padding key with one undo step; shut H/V fields show both values ("16,54") when the sides disagree instead of "Mixed". Original code-drawn side icons, English and Spanish strings (`padding_individual_tests`). Numbers unchanged | | 2026-10-17 | minor | Community batch #753: adding auto layout to an empty frame keeps its size, Fixed, while a frame with content hugs it (#369); `node.set` applies auto layout keys to the frames among its targets and warns about the others, and a font style the family lacks warns with the nearest face (#603). Checked over MCP. Numbers unchanged | | 2026-10-13 | minor | Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. | | 2026-10-13 | minor | Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged. | From 742e911652b41cc1bed3428a422533ee0709adfd Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 19 Oct 2026 23:58:26 +0700 Subject: [PATCH 095/114] #832: the per-side fields are two plain rows like Individual corners (toggle in the first); shut H/V fields read Mixed when their sides disagree; no comment naming the incumbent --- crates/ui-egui/src/layout_panel.rs | 143 +++++++----------- .../ui-egui/src/padding_individual_tests.rs | 6 +- 2 files changed, 60 insertions(+), 89 deletions(-) diff --git a/crates/ui-egui/src/layout_panel.rs b/crates/ui-egui/src/layout_panel.rs index 6c8d66935..b5ff1d45f 100644 --- a/crates/ui-egui/src/layout_panel.rs +++ b/crates/ui-egui/src/layout_panel.rs @@ -4,7 +4,7 @@ //! alignment in its cell, and clip content. Everything goes through `node.set` props or `layout.*` //! commands. -use egui::{Align, Align2, CornerRadius, Id, Layout, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use egui::{Align2, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use figcraft_doc::*; use serde_json::json; @@ -401,97 +401,66 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { } } -/// The Spacing row: gap fields, then the H/V padding pair (or the 2x2 per-side -/// grid while the individual-padding toggle is open). +/// The Spacing row: the H/V padding pair, or, with Individual padding on, two rows of one field +/// per side (like Individual corners). fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { let col = widgets::col_width(ui.available_width()); let [pt, pr, pb, pl] = l.padding; let open = ui.data(|d| d.get_temp::(Id::new("indep_padding"))).unwrap_or(false); - if open { - // Each side on its own, like Figma's individual padding: a 2x2 grid of sides with - // the toggle at its right, replacing the H/V row while open. Every measure copies - // a plain row: 32px rows, the panel's own inter-row gap, 8px between the fields. - let gap = ui.spacing().item_spacing.y; - let total = 32.0 * 2.0 + gap; - let fields_w = col * 2.0 + 8.0; - let top = ("paddingTop", tr("Top padding"), Icon::PaddingTop, pt, 0); - let right = ("paddingRight", tr("Right padding"), Icon::PaddingRight, pr, 1); - let bottom = ("paddingBottom", tr("Bottom padding"), Icon::PaddingBottom, pb, 2); - let left = ("paddingLeft", tr("Left padding"), Icon::PaddingLeft, pl, 3); - block(ui, total, 0.0, |ui| { - ui.allocate_ui_with_layout(vec2(fields_w, total), Layout::top_down(Align::LEFT), |ui| { - ui.spacing_mut().item_spacing.y = 0.0; - // The horizontal pair (left, right) on the left, the vertical pair on the right. - for (r, pair) in [[&left, &top], [&right, &bottom]].iter().enumerate() { - if r > 0 { - ui.add_space(gap); - } - ui.allocate_ui_with_layout(vec2(fields_w, 32.0), Layout::left_to_right(Align::Center), |ui| { - ui.spacing_mut().item_spacing.x = 8.0; - for (key, name, icon, v, k) in pair.iter() { - let e = crate::variable_ui::number_field( - app, - ui, - ids, - key, - Id::new(("padding", *k)), - Lead::Icon(*icon, name), - Some(*v), - "", - col, - ); - app.field(ids, key, e, |v| json!(v.max(0.0))); - } - }); - } - }); - // The toggle keeps the shut row's rect: full-height strip, button 4px - // down, centred in the top 32px exactly like the shut row. - ui.allocate_ui_with_layout(vec2(24.0, total), Layout::top_down(Align::Center), |ui| { - ui.add_space(4.0); - if icons::button(ui, Icon::IndependentPadding, 24.0, true, tr("Individual padding")).clicked() { - ui.data_mut(|d| d.insert_temp(Id::new("indep_padding"), false)); - } - }); - }); - return; - } - row(ui, 32.0, |ui| { - // Sides that disagree show both values ("16,54") instead of "Mixed". - let h_pair = format!("{},{}", widgets::fmt_num(pl), widgets::fmt_num(pr)); - let h_empty = if pl == pr { tr("Mixed") } else { h_pair.as_str() }; - let e = crate::variable_ui::number_field_or( - app, - ui, - ids, - "paddingHorizontal", - Id::new("al_padh"), - (pl == pr).then_some(pl), - col, - h_empty, - Lead::Icon(Icon::PaddingH, tr("Horizontal padding")), - "", - ); - app.field(ids, "paddingHorizontal", e, |v| json!(v.max(0.0))); - let v_pair = format!("{},{}", widgets::fmt_num(pt), widgets::fmt_num(pb)); - let v_empty = if pt == pb { tr("Mixed") } else { v_pair.as_str() }; - let e = crate::variable_ui::number_field_or( - app, - ui, - ids, - "paddingVertical", - Id::new("al_padv"), - (pt == pb).then_some(pt), - col, - v_empty, - Lead::Icon(Icon::PaddingV, tr("Vertical padding")), - "", - ); - app.field(ids, "paddingVertical", e, |v| json!(v.max(0.0))); - if icons::button(ui, Icon::IndependentPadding, 24.0, false, tr("Individual padding")).clicked() { - ui.data_mut(|d| d.insert_temp(Id::new("indep_padding"), true)); + let toggle = |ui: &mut Ui| { + if icons::button(ui, Icon::IndependentPadding, 24.0, open, tr("Individual padding")).clicked() { + ui.data_mut(|d| d.insert_temp(Id::new("indep_padding"), !open)); } - }); + }; + if open { + // `l` is the selection's common layout, so its sides are common too. `k` indexes + // [top, right, bottom, left]. + let sides = [ + ("paddingLeft", tr("Left padding"), Icon::PaddingLeft, pl, 3), + ("paddingTop", tr("Top padding"), Icon::PaddingTop, pt, 0), + ("paddingRight", tr("Right padding"), Icon::PaddingRight, pr, 1), + ("paddingBottom", tr("Bottom padding"), Icon::PaddingBottom, pb, 2), + ]; + for (r, pair) in sides.chunks(2).enumerate() { + row(ui, 32.0, |ui| { + for (key, name, icon, v, k) in pair { + let e = crate::variable_ui::number_field(app, ui, ids, key, Id::new(("padding", *k)), Lead::Icon(*icon, name), Some(*v), "", col); + app.field(ids, key, e, |v| json!(v.max(0.0))); + } + if r == 0 { + toggle(ui); + } + }); + } + } else { + row(ui, 32.0, |ui| { + let e = crate::variable_ui::number_field( + app, + ui, + ids, + "paddingHorizontal", + Id::new("al_padh"), + Lead::Icon(Icon::PaddingH, tr("Horizontal padding")), + (pl == pr).then_some(pl), + "", + col, + ); + app.field(ids, "paddingHorizontal", e, |v| json!(v.max(0.0))); + let e = crate::variable_ui::number_field( + app, + ui, + ids, + "paddingVertical", + Id::new("al_padv"), + Lead::Icon(Icon::PaddingV, tr("Vertical padding")), + (pt == pb).then_some(pt), + "", + col, + ); + app.field(ids, "paddingVertical", e, |v| json!(v.max(0.0))); + toggle(ui); + }); + } if l.mode != LayoutMode::Grid && l.wrap { // Wrapped rows (horizontal) or columns (vertical): the gap between them. let vertical = l.mode == LayoutMode::Vertical; diff --git a/crates/ui-egui/src/padding_individual_tests.rs b/crates/ui-egui/src/padding_individual_tests.rs index 28a207bc8..bc95886e9 100644 --- a/crates/ui-egui/src/padding_individual_tests.rs +++ b/crates/ui-egui/src/padding_individual_tests.rs @@ -90,13 +90,15 @@ fn named_field(u: &mut Ui, label: &str) -> Pos2 { pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) } -/// The shut pair fields edit their own axis: horizontal touches left and right -/// only, vertical touches top and bottom only. +/// The shut pair fields read Mixed when their sides disagree and edit their own axis: horizontal +/// touches left and right only, vertical touches top and bottom only. #[test] fn shut_pair_fields_edit_their_own_axis() { let (mut u, f) = scene(); u.app.run("node.set", json!({"ids": [f], "props": {"paddingLeft": 16.0, "paddingRight": 54.0}})); assert_eq!(padding(&u, f), [10.0, 54.0, 10.0, 16.0]); + let shown = crate::live_tests::texts(&crate::live_tests::flat(&u.frame_output(vec![]).shapes)); + assert!(shown.iter().any(|(t, _, _)| t == "Mixed"), "sides that disagree read Mixed: {shown:?}"); let h = named_field(&mut u, "Horizontal padding"); click(&mut u, h); u.frame(vec![Event::Text("20".into())]); From 08c3f8f9441b1c2a237c41c828334a7914b7d0aa Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 19 Oct 2026 23:59:59 +0700 Subject: [PATCH 096/114] #812: mark Polish as a machine draft (status line, neutral header) --- crates/ui-egui/locales/pl.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index de3c727e8..1e9c79551 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -1,10 +1,10 @@ # FigCraft interface strings: Polish. # -# Complete, native-quality Polish localization for FigCraft. -# Clean-room implementation adhering strictly to ArtCraft standards. +# Draft: machine-drafted, waiting for a native speaker's review. [meta] name = "Polski" +status = "draft — needs native review" [ui] "% of all commits" = "% wszystkich commitów" From 75784f24d66dc4529eb48dcc609393b088f48f14 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 20 Oct 2026 00:00:00 +0700 Subject: [PATCH 097/114] #812: Polish for the UI strings and commands main added since --- crates/ui-egui/locales/pl.toml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 1e9c79551..aa5b7533a 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -90,6 +90,7 @@ status = "draft — needs native review" "All components" = "Wszystkie komponenty" "All fonts" = "Wszystkie czcionki" "All supported properties" = "Wszystkie obsługiwane właściwości" +"All the fonts this file uses are installed" = "Wszystkie czcionki używane w tym pliku są zainstalowane" "All variables" = "Wszystkie zmienne" "Alpha" = "Alfa" "Alphabetical" = "Alfabetycznie" @@ -196,6 +197,7 @@ status = "draft — needs native review" "C or ⇧C" = "C lub ⇧C" "CMYK colour" = "Kolor CMYK" "Can view" = "Może przeglądać" +"Checking the fonts installed on this computer…" = "Sprawdzanie czcionek zainstalowanych na tym komputerze…" "Checking the server…" = "Sprawdzanie serwera…" "Clear from this list" = "Usuń z tej listy" "Code or invite link" = "Kod lub link z zaproszeniem" @@ -316,6 +318,7 @@ status = "draft — needs native review" "Corner smoothing" = "Wygładzanie narożników (Corner smoothing)" "Couldn't download the variables" = "Nie udało się pobrać zmiennych" "Couldn't reach {server}: showing what this computer remembers." = "Nie udało się połączyć z {server}: pokazywanie zawartości zapamiętanej przez ten komputer." +"Couldn't read {name}." = "Nie udało się odczytać pliku {name}." "Couldn't save automatically: {error}" = "Nie udało się zapisać automatycznie: {error}" "Count" = "Licznik" "Create" = "Utwórz" @@ -646,6 +649,7 @@ status = "draft — needs native review" "Instance swap (no other component)" = "Podmiana instancji (brak innego komponentu)" "Instances" = "Instancje" "Instant" = "Natychmiastowo" +"Intensity" = "Intensywność" "Interaction" = "Interakcja" "Interactions" = "Interakcje" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Projektowanie interfejsów i prototypowanie. Darmowe, otwartoźródłowe, czysto implementowane narzędzie projektowe napisane od podstaw w Rust, część ArtCraft Crafting Apps." @@ -770,6 +774,7 @@ status = "draft — needs native review" "Mirror angle and length" = "Kąt i długość odbicia lustrzanego" "Mirroring" = "Odbicie lustrzane" "Missing" = "Brak" +"Missing fonts" = "Brakujące czcionki" "Missing layer" = "Brakująca warstwa" "Missing style" = "Brakujący styl" "Missing variable" = "Brakująca zmienna" @@ -829,6 +834,7 @@ status = "draft — needs native review" "No contributor data was built into this copy." = "Ta kopia nie zawiera danych o współtwórcach." "No device" = "Brak urządzenia" "No fonts match" = "Brak pasujących czcionek" +"No files or templates match your search" = "Żaden plik ani szablon nie pasuje do wyszukiwania" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Brak kodu dołączenia (serwer jest starszy niż kody dołączenia lub ten adres nie może być zakodowany): zamiast tego udostępnij link." "No layer changes to show." = "Brak zmian warstw do wyświetlenia." "No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Brak bibliotek. Dodaj plik .figcraft opublikowany jako biblioteka lub wklej link do biblioteki zespołu." @@ -1174,6 +1180,8 @@ status = "draft — needs native review" "Search actions, layers and tools" = "Szukaj akcji, warstw i narzędzi" "Search assets" = "Szukaj zasobów" "Search comments" = "Szukaj komentarzy" +"Search files and templates" = "Szukaj plików i szablonów" +"Search results" = "Wyniki wyszukiwania" "Search fonts" = "Szukaj czcionek" "Search layers" = "Szukaj warstw" "Search shortcuts" = "Szukaj skrótów" @@ -1373,6 +1381,7 @@ status = "draft — needs native review" "This condition is edited as an expression" = "Ten warunek jest edytowany jako wyrażenie" "This file" = "Ten plik" "This file has unsaved changes" = "Ten plik ma niezapisane zmiany" +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Ten plik używa czcionek, które nie są zainstalowane na tym komputerze. Do czasu ich zainstalowania tekst jest wyświetlany czcionką zastępczą." "This file is on {server}, open only to you. Share it to let others in with a new code." = "Ten plik znajduje się na {server}, otwarty tylko dla Ciebie. Udostępnij go, aby wpuścić innych nowym kodem." "This frame is new on the branch." = "Ta ramka jest nowa w gałęzi." "This frame isn't on the branch." = "Tej ramki nie ma w gałęzi." @@ -1402,6 +1411,7 @@ status = "draft — needs native review" "Truncate with an ellipsis" = "Utnij tekst z wielokropkiem (…)" "Try again" = "Spróbuj ponownie" "Try another search or filter." = "Wypróbuj inne wyszukiwanie lub filtr." +"Try another search, or browse templates." = "Spróbuj innego wyszukiwania lub przejrzyj szablony." "Try another word, or look at every kind." = "Wypróbuj inne słowo lub przejrzyj wszystkie typy." "Turn a layer into a component, reuse it, and change every copy at once" = "Przekształć warstwę w komponent, używaj jej ponownie i modyfikuj wszystkie kopie naraz" "Two digits" = "Dwie cyfry" @@ -1592,6 +1602,7 @@ status = "draft — needs native review" "nobody else here yet" = "nikogo innego tu jeszcze nie ma" "non-default slot settings are not represented and were skipped" = "niedomyślne ustawienia gniazd nie są reprezentowane i zostały pominięte" "now" = "teraz" +"only in a text style or variable" = "tylko w stylu tekstu lub zmiennej" "or" = "lub" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "połączenia punktów obrysu zostały zachowane; obrysy przerywane lub o zmiennej szerokości renderują się z łączeniem warstwy" "preferred slot component keys cannot be resolved to file nodes and were skipped" = "klucze preferowanych komponentów gniazda nie mogą zostać dopasowane do węzłów pliku i zostały pominięte" @@ -1614,6 +1625,7 @@ status = "draft — needs native review" "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "akcje set-variable-mode dla kolekcji lub trybów, których nie ma w pliku JSON ze zmiennymi, zostały pominięte" "slot content assignment target is missing or is not slot content" = "cel przypisania zawartości gniazda nie istnieje lub nie jest zawartością gniazda" "slot content assignment uses the empty GUID sentinel and was skipped" = "przypisanie zawartości gniazda używa pustego identyfikatora GUID i zostało pominięte" +"slot layers without a matching slot property were imported as frames" = "warstwy gniazd bez pasującej właściwości gniazda zostały zaimportowane jako ramki" "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "niektóre punkty wektorowe odwołują się do ustawień punktów brakujących w pliku; używają połączeń warstwy i zwykłych narożników" "some vector segments refer to style settings missing from the file; they use the layer's stroke" = "niektóre segmenty wektorowe odwołują się do ustawień stylu brakujących w pliku; używają obrysu warstwy" "stale or invalid grid track sizing entries were ignored" = "nieaktualne lub nieprawidłowe wpisy rozmiarów ścieżek siatki zostały zignorowane" @@ -1655,6 +1667,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "{name} usunięto z pamięci tego komputera; plik pozostaje na {server}" "{name} is on the server." = "{name} znajduje się na serwerze." "{name} is spotlighting" = "{name} prezentuje widok (spotlight)" +"{name} is too large to open in the browser (over 512 MB)." = "Plik {name} jest za duży, aby otworzyć go w przeglądarce (ponad 512 MB)." "{name}\nClick to apply to the selection" = "{name}\nKliknij, aby zastosować do zaznaczenia" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nPrzeciągnij na płótno lub kliknij, aby umieścić na środku widoku.\nPrzytrzymaj klawisz Alt (Option) podczas upuszczania na instancję, aby ją zamienić." "{name} (from {library})" = "{name} (z biblioteki {library})" @@ -1699,6 +1712,7 @@ status = "draft — needs native review" "{n} replies" = { one = "{n} odpowiedź", few = "{n} odpowiedzi", many = "{n} odpowiedzi", other = "{n} odpowiedzi" } "{n} rows" = { one = "{n} wiersz", few = "{n} wiersze", many = "{n} wierszy", other = "{n} wierszy" } "{n} styles" = { one = "{n} styl", few = "{n} style", many = "{n} stylów", other = "{n} stylów" } +"{n} text layers" = { one = "{n} warstwa tekstowa", few = "{n} warstwy tekstowe", many = "{n} warstw tekstowych", other = "{n} warstw tekstowych" } "{n} variables" = { one = "{n} zmienna", few = "{n} zmienne", many = "{n} zmiennych", other = "{n} zmiennych" } "{n} weeks ago" = { one = "{n} tydzień temu", few = "{n} tygodnie temu", many = "{n} tygodni temu", other = "{n} tygodni temu" } "{n} years ago" = { one = "{n} rok temu", few = "{n} lata temu", many = "{n} lat temu", other = "{n} lat temu" } @@ -1783,6 +1797,7 @@ status = "draft — needs native review" "collab.publish" = "Opublikuj na serwerze" "collab.recoverFile" = "Odzyskaj plik online" "collab.registerLinks" = "Otwieraj linki z zaproszeniem w FigCraft" +"collab.removePerson" = "Usuń z sesji" "collab.report" = "Zgłoś plik operatorowi serwera" "collab.serverInfo" = "Informacje o serwerze" "collab.share" = "Udostępnij z kodem dołączenia" @@ -1960,6 +1975,7 @@ status = "draft — needs native review" "object.batchRename" = "Zmień nazwy wsadowo…" "object.convertToFrame" = "Konwertuj na ramkę" "object.convertToGroup" = "Konwertuj na grupę" +"object.convertToSection" = "Konwertuj na sekcję" "object.flipH" = "Odbij w poziomie" "object.flipV" = "Odbij w pionie" "object.frameSelection" = "Umieść zaznaczenie w ramce" @@ -2006,6 +2022,7 @@ status = "draft — needs native review" "plugin.ui" = "Panel wtyczki" "plugins.manage" = "Zarządzaj wtyczkami…" "plugins.runLast" = "Uruchom ponownie ostatnią wtyczkę" +"pref.autoSave" = "Zapisuj pliki automatycznie" "pref.highlightHover" = "Podświetlaj warstwy przy najechaniu myszą" "pref.keepTool" = "Zachowaj wybrane narzędzie po użyciu" "pref.language" = "Język interfejsu" @@ -2122,6 +2139,7 @@ status = "draft — needs native review" "text.listOutdent" = "Zmniejsz wcięcie listy" "text.listStyle" = "Styl listy" "text.maxLines" = "Maksymalna liczba wierszy" +"text.missingFonts" = "Brakujące czcionki" "text.numberList" = "Lista numerowana" "text.onPath" = "Tekst na ścieżce" "text.paragraphIndent" = "Wcięcie akapitu" From d087645a00f07c62c47549b3c6bbb83217b9511a Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 20 Oct 2026 00:00:03 +0700 Subject: [PATCH 098/114] #812: Polish in docs/translating.md and the plural comment --- crates/ui-egui/src/i18n.rs | 2 +- docs/translating.md | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 4a7ef81ce..98bc3d8f7 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -183,7 +183,7 @@ fn plural(code: &str, n: usize) -> Plural { } // CLDR's million-count "many" in French, Spanish and Portuguese still reads their existing -// "other" wording. Czech supplies separate few forms; Russian supplies few/many forms. +// "other" wording. Czech supplies separate few forms; Russian and Polish supply few/many forms. /// Whether count `n` takes the "one" form: 0 and 1 in French and Brazilian Portuguese, never in /// Japanese, Korean and Chinese (no plural forms: always "other"), 1 elsewhere (CLDR's integer rules). diff --git a/docs/translating.md b/docs/translating.md index d8addcbc7..a6acd9b0b 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -16,8 +16,8 @@ Simplified. The catalogs: English (`en`, the source), and Spanish (`es`, with the Latin American overlay `es-419`), French (`fr`), German (`de`), Dutch (`nl`), Brazilian Portuguese (`pt-BR`), Japanese -(`ja`), Korean (`ko`), Simplified Chinese (`zh-Hans`), Czech (`cs`), Indonesian (`id`) and Finnish -(`fi`), machine-drafted and waiting for native speakers' review. Russian (`ru`, Русский) was +(`ja`), Korean (`ko`), Simplified Chinese (`zh-Hans`), Czech (`cs`), Indonesian (`id`), Finnish +(`fi`) and Polish (`pl`), machine-drafted and waiting for native speakers' review. Russian (`ru`, Русский) was reviewed and approved by VanTan; its catalog keeps Frame, Dev Mode and Smart animate as familiar tool names and uses « » quotes. Turkish (`tr`) was machine-drafted and reviewed by a native speaker, and Swedish (`sv`) translates every UI string, command label and menu wording. @@ -81,6 +81,8 @@ language also needs its script in `cjk::language`. a missing key uses the fallback catalog's own plural rule. Czech uses `one` for 1, `few` for 2–4 and `other` for 0 and 5+ (e.g. `{n} vrstva`, `{n} vrstvy`, `{n} vrstev`). Keep `{n}` in all three forms. Czech's CLDR `many` applies only to decimals; `trn` takes integer counts. + Polish uses `one` for 1 only, `few` like Russian (2–4, 22–24… except 12–14) and `many` for + the other integers, including 0 and 21 (e.g. `{n} warstwa`, `{n} warstwy`, `{n} warstw`). 4. To require full coverage, add the code to `COMPLETE` in [`crates/ui-egui/src/i18n_tests.rs`](../crates/ui-egui/src/i18n_tests.rs). Every later PR that adds a string then has to translate it into each complete language, so only Spanish is there: From f2ede63dd931502f81f1f72f7d92e5795ac2507f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 20 Oct 2026 00:00:09 +0700 Subject: [PATCH 099/114] #867: comments describe the line separator without naming another product --- crates/text/src/layout.rs | 6 +++--- crates/text/src/line_separator_tests.rs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 014bb1cde..26ae3d338 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -160,8 +160,8 @@ fn deco_len(m: DecorationLength, size: f64, auto: f64) -> f64 { if v.is_finite() { v } else { auto } } -/// The line separator (U+2028): a line break inside a paragraph, as Figma's Shift+Enter stores it -/// (imported text keeps it; typed text has newlines only). +/// The line separator (U+2028): a line break inside a paragraph, as `.fig` files store a soft +/// break (imported text keeps it; typed text has newlines only). const LINE_SEPARATOR: char = '\u{2028}'; /// A char that ends its line: a newline (which also ends the paragraph) or a line separator. @@ -479,7 +479,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) para += 1; continue; } - // A line separator (Figma's Shift+Enter) breaks the line inside its paragraph: no + // A line separator (a soft break) breaks the line inside its paragraph: no // paragraph spacing and no new list marker after it. if it.ch == LINE_SEPARATOR { lines.push((line_start, i + 1, false, para)); diff --git a/crates/text/src/line_separator_tests.rs b/crates/text/src/line_separator_tests.rs index 6891f97b1..13f91786c 100644 --- a/crates/text/src/line_separator_tests.rs +++ b/crates/text/src/line_separator_tests.rs @@ -1,4 +1,4 @@ -//! A line separator (U+2028: what Figma's Shift+Enter stores, and `.fig` imports keep) breaks the +//! A line separator (U+2028: a soft break in `.fig` files, kept on import) breaks the //! line inside its paragraph: no paragraph spacing or new list marker after it, and no glyph (#852). use super::*; @@ -41,7 +41,7 @@ fn breaks_the_line_but_not_the_paragraph() { #[test] fn before_a_newline_or_at_the_end_it_leaves_an_empty_line() { - // "Hi,⏎¶Season" in #852: Figma shows an empty line between the two. + // "Hi,⏎¶Season" in #852: an empty line between the two. let Some(t) = text("Hi,\u{2028}\nSeason") else { return }; let l = layout(&t, None, None); assert_eq!(line_texts(&t, &l), ["Hi,\u{2028}", "\n", "Season"]); From 7543759150cb3cd0b39536c8db5f60d932711dbc Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 20 Oct 2026 00:02:46 +0700 Subject: [PATCH 100/114] #872: leave the progress docs (ROADMAP, localization-parity) to the maintainer --- ROADMAP.md | 4 ++-- docs/localization-parity.md | 10 ++++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index bceed27a2..8a587286d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -111,9 +111,9 @@ native speakers' reviews of the six language drafts need people. | Korean | ko | 95% | draft (needs native review) | | Vietnamese | vi | 0% | none | -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 Georgian (`ka`, 100%, draft). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese +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%) and Czech (`cs`, 99%, 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 and Georgian drafts (Preferences ▸ +French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and Indonesian drafts (Preferences ▸ Language; Traditional Chinese has no catalog; CJK UI text uses an installed CJK font, loaded only when needed; adding a language: [`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping, diff --git a/docs/localization-parity.md b/docs/localization-parity.md index ccf8c22ac..50fcd4ec3 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,10 +1,10 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (Georgian (`ka`) 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; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (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; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **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 -Portuguese, Japanese, Korean, Simplified Chinese, Indonesian, Czech and Georgian drafts (machine-drafted, +Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts (machine-drafted, waiting for native speakers' review), plus Swedish and Turkish catalogs. This page lists the twelve key languages of the craftrules standard, then the other languages Figma ships. How to add a language: [`translating.md`](translating.md). @@ -32,7 +32,7 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v channel stay English. Accessibility names come from the same strings. - **Language preference:** Preferences ▸ Language (System, then each catalog by its own name: English, Čeština, Deutsch, Español, Español (Latinoamérica), Français, Bahasa Indonesia, 日本語, - ქართული, 한국어, Português (Brasil), Русский, Svenska, Türkçe, 简体中文), applied at once without a + 한국어, Português (Brasil), Русский, Svenska, Türkçe, 简体中文), applied at once without a restart; System takes the first of the OS's preferred languages that has a catalog (on Windows only the first preferred language counts, since that list also holds the languages added for a keyboard; macOS AppleLanguages, `LANGUAGE` then the locale variables on Linux and BSD; a regional @@ -102,9 +102,7 @@ Other languages (measured 2026-10-17): Russian (`ru`) 2,244 / 2,272 (98.8%), rev by VanTan, one/few/many integer plurals; Swedish (`sv`) 2,264 (99.6%), its command labels and menu wordings added 2026-10-17 (#599), native review welcome; Turkish (`tr`) 2,243 (98.7%), whose header says it was machine-drafted and reviewed by a native speaker; Czech (`cs`) 2,244 (98.8%), a draft -with one/few/other plurals; Georgian (`ka`) 2,328 / 2,328 (100%, measured 2026-10-19), a draft -whose plural entries read the same for one and other (a noun stays singular after a count), its UI -text in craft-fonts' Noto Sans Georgian (built in, on the web too). Figma ships none beyond the eight above. +with one/few/other plurals. Figma ships none beyond the eight above. The layer is done. Per-language hours above are agent hours for a catalog of ~1,800 entries (a machine draft, then a pass over the panels for overflow), plus each language's plural rule where From 381ec852034538af1e72b71ccb604d5d486f79b1 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 20 Oct 2026 00:02:47 +0700 Subject: [PATCH 101/114] #872: keep the web release without craft-fonts (Georgian is desktop-only for now) --- .github/workflows/release.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e8444c525..0d1723d83 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,6 @@ jobs: app-title: FigCraft craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc has-server: true - web-craft-fonts: true version: ${{ inputs.version }} secrets: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} From 97f2c99d6bd6d117361718130a23a049ad794049 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 20 Oct 2026 00:03:02 +0700 Subject: [PATCH 102/114] #872: the web release leaves Georgian out; one pref.language line in docs/mcp.md --- docs/mcp.md | 3 +-- docs/translating.md | 5 +++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/mcp.md b/docs/mcp.md index b9f30922f..62bbc9915 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -419,8 +419,7 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: accessibility setting turns it on too). Prototype transitions still play. - `pref.autoSave {on?}`: files that have a path save themselves a few seconds after they change (off by default; the desktop app's timer does it, so scripts still call `file.save`). -- `pref.language {language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|zh-Hans|…}`: the app's interface language, applied live ("system": -- `pref.language {language: system|en|es|es-419|fr|de|pt-BR|ja|ko|sv|zh-Hans|…}`: the app's interface language, applied live ("system": +- `pref.language {language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|sv|zh-Hans|…}`: the app's interface language, applied live ("system": the OS or browser language; a language without a catalog shows English). Command ids, labels and every result stay English. diff --git a/docs/translating.md b/docs/translating.md index 2d186770f..8de314e57 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -66,8 +66,9 @@ something on screen needs it (see `crates/ui-egui/src/cjk.rs`). The web build ha so Japanese, Korean and Chinese appear there only in a build with craft-fonts and `FIGCRAFT_EMBED_CJK=1` (Noto Sans CJK SC, +16 MB); otherwise they are left out of the picker. A Chinese UI puts its Chinese face first so Han takes Simplified Chinese shapes. Georgian is drawn in -craft-fonts' Noto Sans Georgian, one of the built-in faces in `theme::install_fonts` (and kept in -web builds by `crates/text/build.rs`). A new CJK or other non-Latin language also needs its script in +craft-fonts' Noto Sans Georgian, one of the built-in faces in `theme::install_fonts`. Web builds +keep it only when built with craft-fonts (`crates/text/build.rs`); the web release isn't, so its +picker leaves Georgian out. A new CJK or other non-Latin language also needs its script in `cjk::language`, or a built-in craft-fonts face. ## Adding a language From ce615ada10f8a75646f876e21dbb492f95fb766b Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 20 Oct 2026 00:03:30 +0700 Subject: [PATCH 103/114] #872: ka.toml keys in en.toml's order, commands sorted --- crates/ui-egui/locales/ka.toml | 39 +++++++++++++++++----------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index 127e6212b..10d0c8e73 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -9,8 +9,6 @@ name = "ქართული" status = "draft — needs native review" [ui] - -"Compatibility notes" = "თავსებადობის შენიშვნები" "% of all commits" = "ყველა კომიტის %" "(no longer published)" = "(აღარ არის გამოქვეყნებული)" "({n} warnings: some entries were skipped)" = { one = "(გაფრთხილება: {n}; ზოგი ჩანაწერი გამოტოვებულია)", other = "(გაფრთხილება: {n}; ზოგი ჩანაწერი გამოტოვებულია)" } @@ -94,6 +92,7 @@ status = "draft — needs native review" "All components" = "ყველა კომპონენტი" "All fonts" = "ყველა შრიფტი" "All supported properties" = "ყველა მხარდაჭერილი თვისება" +"All the fonts this file uses are installed" = "ამ ფაილის ყველა შრიფტი დაინსტალირებულია" "All variables" = "ყველა ცვლადი" "Alpha" = "ალფა" "Alphabetical" = "ანბანის მიხედვით" @@ -200,11 +199,14 @@ status = "draft — needs native review" "C or ⇧C" = "C ან ⇧C" "CMYK colour" = "CMYK ფერი" "Can view" = "შეუძლია ნახვა" +"Checking the fonts installed on this computer…" = "ამ კომპიუტერზე დაინსტალირებული შრიფტების შემოწმება…" "Checking the server…" = "სერვერის შემოწმება…" "Clear from this list" = "ამ სიიდან ამოღება" "Code or invite link" = "კოდი ან მოსაწვევი ბმული" "Color profile: {name}" = "ფერის პროფილი: {name}" "Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "ფერისა და ტექსტის სტილები, ღია და მუქი ცვლადები, ღილაკები, შეყვანის ველები და ბარათები" +"Convert to frame" = "ჩარჩოდ გარდაქმნა" +"Convert to section" = "სექციად გარდაქმნა" "CSS custom properties…" = "CSS-ის მორგებული თვისებები…" "Can edit" = "შეუძლია რედაქტირება" "Can't read this library: {e}" = "ამ ბიბლიოთეკის წაკითხვა ვერ მოხერხდა: {e}" @@ -274,6 +276,7 @@ status = "draft — needs native review" "Commits" = "კომიტები" "Company" = "კომპანია" "Compare changes" = "ცვლილებების შედარება" +"Compatibility notes" = "თავსებადობის შენიშვნები" "Completed" = "დასრულებული" "Component" = "კომპონენტი" "Component name" = "კომპონენტის სახელი" @@ -304,8 +307,6 @@ status = "draft — needs native review" "Copied as SVG" = "დაკოპირდა როგორც SVG" "Copied as text" = "დაკოპირდა როგორც ტექსტი" "Copied {value}" = "დაკოპირდა: {value}" -"Convert to frame" = "ჩარჩოდ გარდაქმნა" -"Convert to section" = "სექციად გარდაქმნა" "Copy" = "კოპირება" "Copy as" = "კოპირება როგორც" "Copy as CSS" = "კოპირება როგორც CSS" @@ -649,6 +650,7 @@ status = "draft — needs native review" "Instance swap (no other component)" = "ინსტანსის ჩანაცვლება (სხვა კომპონენტი არ არის)" "Instances" = "ინსტანსები" "Instant" = "მყისიერი" +"Intensity" = "ძალა" "Interaction" = "ურთიერთქმედება" "Interactions" = "ურთიერთქმედებები" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "ინტერფეისის დიზაინი და პროტოტიპირება. უფასო, ღია კოდის, დამოუკიდებლად (clean-room) შექმნილი დიზაინის ხელსაწყო, ნულიდან დაწერილი Rust-ზე, ArtCraft Crafting Apps-ის ნაწილი." @@ -773,6 +775,7 @@ status = "draft — needs native review" "Mirror angle and length" = "კუთხისა და სიგრძის არეკვლა" "Mirroring" = "არეკვლა" "Missing" = "აკლია" +"Missing fonts" = "მიუწვდომელი შრიფტები" "Missing layer" = "შრე ვერ მოიძებნა" "Missing style" = "სტილი ვერ მოიძებნა" "Missing variable" = "ცვლადი ვერ მოიძებნა" @@ -832,6 +835,7 @@ status = "draft — needs native review" "No contributor data was built into this copy." = "ამ ასლში მონაწილეების მონაცემები არ არის ჩაშენებული." "No device" = "მოწყობილობის გარეშე" "No fonts match" = "შესაბამისი შრიფტი არ მოიძებნა" +"No files or templates match your search" = "ძიების შესაბამისი ფაილი ან შაბლონი არ მოიძებნა" "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "აქ შეერთების კოდი არ არის (სერვერი შეერთების კოდებზე ძველია, ან ეს მისამართი კოდში ვერ ეტევა): ამის ნაცვლად გააზიარეთ ბმული." "No layer changes to show." = "საჩვენებელი შრეების ცვლილებები არ არის." "No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "ბიბლიოთეკები ჯერ არ არის. დაამატეთ ბიბლიოთეკად გამოქვეყნებული .figcraft ფაილი ან ჩასვით გუნდის ბიბლიოთეკის ბმული." @@ -1177,6 +1181,8 @@ status = "draft — needs native review" "Search actions, layers and tools" = "მოქმედებების, შრეებისა და ხელსაწყოების ძიება" "Search assets" = "რესურსების ძიება" "Search comments" = "კომენტარების ძიება" +"Search files and templates" = "ფაილებისა და შაბლონების ძიება" +"Search results" = "ძიების შედეგები" "Search fonts" = "შრიფტების ძიება" "Search layers" = "შრეების ძიება" "Search shortcuts" = "მალსახმობების ძიება" @@ -1376,6 +1382,7 @@ status = "draft — needs native review" "This condition is edited as an expression" = "ეს პირობა ექსპრესიად რედაქტირდება" "This file" = "ეს ფაილი" "This file has unsaved changes" = "ამ ფაილს შეუნახავი ცვლილებები აქვს" +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "ეს ფაილი იყენებს შრიფტებს, რომლებიც ამ კომპიუტერზე დაინსტალირებული არ არის. მათი ტექსტი სათადარიგო შრიფტით გამოისახება, სანამ მათ არ დააინსტალირებთ." "This file is on {server}, open only to you. Share it to let others in with a new code." = "ეს ფაილი {server}-ზეა და მხოლოდ თქვენთვისაა ღია. გააზიარეთ, რომ სხვები ახალი კოდით შემოუშვათ." "This frame is new on the branch." = "ეს ჩარჩო განშტოებაში ახალია." "This frame isn't on the branch." = "ეს ჩარჩო განშტოებაში არ არის." @@ -1405,6 +1412,7 @@ status = "draft — needs native review" "Truncate with an ellipsis" = "შეკვეცა მრავალწერტილით" "Try again" = "ხელახლა ცდა" "Try another search or filter." = "სცადეთ სხვა ძიება ან ფილტრი." +"Try another search, or browse templates." = "სცადეთ სხვა ძიება ან დაათვალიერეთ შაბლონები." "Try another word, or look at every kind." = "სცადეთ სხვა სიტყვა ან ნახეთ ყველა სახეობა." "Turn a layer into a component, reuse it, and change every copy at once" = "შრე აქციეთ კომპონენტად, გამოიყენეთ ხელახლა და შეცვალეთ ყველა ასლი ერთდროულად" "Two digits" = "ორი ციფრი" @@ -1595,6 +1603,7 @@ status = "draft — needs native review" "nobody else here yet" = "აქ ჯერ სხვა არავინაა" "non-default slot settings are not represented and were skipped" = "სლოტის არანაგულისხმევი პარამეტრები არ არის წარმოდგენილი და გამოტოვებულია" "now" = "ახლა" +"only in a text style or variable" = "მხოლოდ ტექსტის სტილში ან ცვლადში" "or" = "ან" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "წერტილების კონტურის შეერთებები შენარჩუნებულია; ტირეებიანი ან ცვლადი სისქის კონტურები შრის შეერთებით გამოისახება" "preferred slot component keys cannot be resolved to file nodes and were skipped" = "სლოტის სასურველი კომპონენტების გასაღებები ფაილის კვანძებთან ვერ დაკავშირდა და გამოტოვებულია" @@ -1617,6 +1626,7 @@ status = "draft — needs native review" "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "ცვლადის რეჟიმის დაყენების მოქმედებები იმ კოლექციებზე ან რეჟიმებზე, რომლებიც ცვლადების JSON-ში არ არის, გამოტოვებულია" "slot content assignment target is missing or is not slot content" = "სლოტის შიგთავსის მინიჭების სამიზნე აკლია ან სლოტის შიგთავსი არ არის" "slot content assignment uses the empty GUID sentinel and was skipped" = "სლოტის შიგთავსის მინიჭება ცარიელ GUID მარკერს იყენებს და გამოტოვებულია" +"slot layers without a matching slot property were imported as frames" = "სლოტის შრეები, რომლებსაც შესაბამისი სლოტის თვისება არ აქვს, ჩარჩოებად იმპორტირდა" "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "ზოგი ვექტორული წერტილი მიუთითებს წერტილის პარამეტრებზე, რომლებიც ფაილს აკლია; ისინი შრის შეერთებებსა და ჩვეულებრივ კუთხეებს იყენებს" "some vector segments refer to style settings missing from the file; they use the layer's stroke" = "ზოგი ვექტორული სეგმენტი მიუთითებს სტილის პარამეტრებზე, რომლებიც ფაილს აკლია; ისინი შრის კონტურს იყენებს" "stale or invalid grid track sizing entries were ignored" = "ბადის ტრეკის ზომის მოძველებული ან არასწორი ჩანაწერები იგნორირებულია" @@ -1702,6 +1712,7 @@ status = "draft — needs native review" "{n} replies" = { one = "პასუხი: {n}", other = "პასუხი: {n}" } "{n} rows" = { one = "სტრიქონი: {n}", other = "სტრიქონი: {n}" } "{n} styles" = { one = "სტილი: {n}", other = "სტილი: {n}" } +"{n} text layers" = { one = "ტექსტის შრე: {n}", other = "ტექსტის შრე: {n}" } "{n} variables" = { one = "ცვლადი: {n}", other = "ცვლადი: {n}" } "{n} weeks ago" = { one = "{n} კვირის წინ", other = "{n} კვირის წინ" } "{n} years ago" = { one = "{n} წლის წინ", other = "{n} წლის წინ" } @@ -1728,18 +1739,6 @@ status = "draft — needs native review" "⌘ click" = "⌘ დაწკაპუნება" "⌘ scroll" = "⌘ გადახვევა" "⌥ drag" = "⌥ გადათრევა" -"All the fonts this file uses are installed" = "ამ ფაილის ყველა შრიფტი დაინსტალირებულია" -"Checking the fonts installed on this computer…" = "ამ კომპიუტერზე დაინსტალირებული შრიფტების შემოწმება…" -"Intensity" = "ძალა" -"Missing fonts" = "მიუწვდომელი შრიფტები" -"No files or templates match your search" = "ძიების შესაბამისი ფაილი ან შაბლონი არ მოიძებნა" -"Search files and templates" = "ფაილებისა და შაბლონების ძიება" -"Search results" = "ძიების შედეგები" -"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "ეს ფაილი იყენებს შრიფტებს, რომლებიც ამ კომპიუტერზე დაინსტალირებული არ არის. მათი ტექსტი სათადარიგო შრიფტით გამოისახება, სანამ მათ არ დააინსტალირებთ." -"Try another search, or browse templates." = "სცადეთ სხვა ძიება ან დაათვალიერეთ შაბლონები." -"only in a text style or variable" = "მხოლოდ ტექსტის სტილში ან ცვლადში" -"slot layers without a matching slot property were imported as frames" = "სლოტის შრეები, რომლებსაც შესაბამისი სლოტის თვისება არ აქვს, ჩარჩოებად იმპორტირდა" -"{n} text layers" = { one = "ტექსტის შრე: {n}", other = "ტექსტის შრე: {n}" } [commands] "align.bottom" = "ქვემოთ სწორება" @@ -1919,8 +1918,8 @@ status = "draft — needs native review" "guide.move" = "დამხმარე ხაზის გადაადგილება" "guide.remove" = "დამხმარე ხაზის მოცილება" "guide.select" = "დამხმარე ხაზის მონიშვნა" -"help.gettingStarted" = "პირველი ნაბიჯები" "help.about" = "FigCraft-ის შესახებ" +"help.gettingStarted" = "პირველი ნაბიჯები" "help.page" = "დახმარების გვერდი" "help.shortcuts" = "კლავიატურის მალსახმობები" "image.adjust" = "გამოსახულების კორექცია" @@ -2106,6 +2105,7 @@ status = "draft — needs native review" "style.move" = "სტილების გადატანა" "style.renameFolder" = "სტილების საქაღალდის სახელის შეცვლა" "style.update" = "სტილის რედაქტირება" +"template.list" = "შაბლონების სია" "text.alignCenter" = "ცენტრში სწორება" "text.alignJustify" = "ორმხრივი სწორება" "text.alignLeft" = "მარცხნივ სწორება" @@ -2134,12 +2134,12 @@ status = "draft — needs native review" "text.italic" = "დახრილი" "text.lineHeightDown" = "სტრიქონის სიმაღლის შემცირება" "text.lineHeightUp" = "სტრიქონის სიმაღლის გაზრდა" -"text.missingFonts" = "მიუწვდომელი შრიფტები" "text.link" = "ბმულის შექმნა" "text.listIndent" = "სიის შეწევის გაზრდა" "text.listOutdent" = "სიის შეწევის შემცირება" "text.listStyle" = "სიის სტილი" "text.maxLines" = "მაქს. სტრიქონები" +"text.missingFonts" = "მიუწვდომელი შრიფტები" "text.numberList" = "დანომრილი სია" "text.onPath" = "ტექსტი ტრაექტორიაზე" "text.paragraphIndent" = "აბზაცის შეწევა" @@ -2262,6 +2262,7 @@ status = "draft — needs native review" "view.designPrototype" = "გადართვა დიზაინსა და პროტოტიპს შორის" "view.devMode" = "დეველოპერის რეჟიმი" "view.frameOutlines" = "ჩარჩოების კონტურები" +"view.fullscreen" = "სრული ეკრანი" "view.get" = "ხედის მიღება" "view.layers" = "შრეების პანელი" "view.layoutGrids" = "განლაგების ბადეები" @@ -2283,7 +2284,6 @@ status = "draft — needs native review" "view.prevPage" = "წინა გვერდი" "view.previousRegion" = "წინა არეზე ფოკუსირება" "view.proofSrgb" = "ეკრანზე sRGB-ის სიმულაცია" -"view.fullscreen" = "სრული ეკრანი" "view.prototype" = "პროტოტიპის პანელი" "view.rulers" = "სახაზავები" "view.set" = "ხედის დაყენება" @@ -2308,7 +2308,6 @@ status = "draft — needs native review" "widget.list" = "ვიჯეტების სია" "window.focus" = "ფანჯრის წინა პლანზე გადმოტანა" "window.new" = "ახალი ფანჯარა" -"template.list" = "შაბლონების სია" [menu] "bool.exclude" = "გამორიცხვა" From e358c695cc8a5f9b5f6e8f0e8daaac20792b1156 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 20 Oct 2026 00:05:51 +0700 Subject: [PATCH 104/114] =?UTF-8?q?#813:=20Bengali=20in=20script=20detecti?= =?UTF-8?q?on=20(cjk::ALL),=20restore=20the=20=E3=81=82=E6=BC=A2=ED=95=9C?= =?UTF-8?q?=20assert,=20doc=20comments,=20rustfmt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/src/cjk.rs | 28 ++++++++++++---------------- crates/ui-egui/src/lib.rs | 4 ++-- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/cjk.rs b/crates/ui-egui/src/cjk.rs index 5f8af5dce..35323efe7 100644 --- a/crates/ui-egui/src/cjk.rs +++ b/crates/ui-egui/src/cjk.rs @@ -1,8 +1,9 @@ -//! Chinese, Japanese, Korean and Bengali text in the UI. egui's own fonts and Inter have no CJK or non-Latin glyphs, so -//! the UI adds a face per script it needs: the UI language's, the scripts in the open file's names +//! Chinese, Japanese, Korean and Bengali text in the UI. egui's own fonts and Inter have no glyphs +//! for them, so the UI adds a face per script it needs: the UI language's, the scripts in the open file's names //! and comments, the language picker's and typed text. Desktop finds them among the installed //! fonts (the system font scan), only once one is needed, so English users load none; the web -//! build has only the CJK and non-Latin faces built in from craft-fonts (`FIGCRAFT_EMBED_CJK`), else none. +//! build has only the CJK faces built in from craft-fonts (`FIGCRAFT_EMBED_CJK`), else none (no +//! Bengali face is built in, so the web leaves Bengali out). use std::sync::Arc; @@ -12,15 +13,14 @@ pub const KANA: u8 = 1; pub const HANGUL: u8 = 2; /// Han characters (and CJK punctuation and full-width forms). pub const HAN: u8 = 4; +/// Bengali script. pub const BENGALI: u8 = 8; +/// Every script bit: nothing more to look for once all have a face. +pub const ALL: u8 = KANA | HANGUL | HAN | BENGALI; + +/// Bengali faces: cross-platform, Windows, macOS, then Linux names. +const BENGALI_FONTS: &[&str] = &["Noto Sans Bengali", "Nirmala UI", "Bangla Sangam MN", "Kalpurush", "SolaimanLipi"]; -const BENGALI_FONTS: &[&str] = &[ - "Noto Sans Bengali", - "Nirmala UI", - "Bangla Sangam MN", - "Kalpurush", - "SolaimanLipi", -]; /// Japanese faces, the built-in one first: Windows, macOS, then Linux names. const JAPANESE: &[&str] = &[ "BIZ UDPGothic", @@ -108,12 +108,7 @@ fn samples(need: u8, ui: u8) -> Vec<(char, Vec<&'static str>)> { (false, HANGUL) => ('漢', [KOREAN, JAPANESE, CHINESE].concat()), (false, _) => ('漢', [JAPANESE, KOREAN, CHINESE].concat()), }; - let mut v = vec![ - (KANA, ('あ', JAPANESE.to_vec())), - (HAN, han), - (HANGUL, ('한', KOREAN.to_vec())), - (BENGALI, ('অ', BENGALI_FONTS.to_vec())), - ]; + let mut v = vec![(KANA, ('あ', JAPANESE.to_vec())), (HAN, han), (HANGUL, ('한', KOREAN.to_vec())), (BENGALI, ('অ', BENGALI_FONTS.to_vec()))]; match ui { HANGUL => v.reverse(), HAN => v.swap(0, 1), @@ -178,6 +173,7 @@ mod tests { fn the_ui_language_script_comes_first() { let order = |need, ui| samples(need, ui).into_iter().map(|(c, _)| c).collect::(); let first_face = |need, ui| samples(need, ui).first().and_then(|(_, f)| f.first()).copied(); + assert_eq!(order(KANA | HAN | HANGUL, 0), "あ漢한"); assert_eq!(order(KANA | HAN | HANGUL | BENGALI, language("bn")), "অあ漢한"); assert_eq!(order(KANA | HAN | HANGUL, KANA | HAN), "あ漢한"); assert_eq!(order(KANA | HAN | HANGUL, HANGUL), "한漢あ"); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4b44c861d..3b5b108d9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1288,11 +1288,11 @@ impl FigApp { self.session.prefs.reduce_motion || self.services.reduce_motion() } - /// The CJK scripts the UI shows: the language's, the language picker's (Preferences open), + /// The CJK and Bengali scripts the UI shows: the language's, the language picker's (Preferences open), /// typed text's, and those in the open file's names and comments (read when it changes, not /// mid-drag). Nothing is checked once every script has a face. fn cjk_needed(&mut self, ctx: &egui::Context, installed: u8) -> u8 { - if installed == cjk::KANA | cjk::HANGUL | cjk::HAN { + if installed == cjk::ALL { return installed; } let mut need = i18n::current().map_or(0, |l| cjk::language(l.code)); From 22ef4fdd99596d7ba095fac6de9ad7accd2b9ec1 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 20 Oct 2026 00:13:45 +0700 Subject: [PATCH 105/114] #874: locale entries in sorted position --- crates/ui-egui/locales/de.toml | 4 ++-- crates/ui-egui/locales/es.toml | 4 ++-- crates/ui-egui/locales/fi.toml | 4 ++-- crates/ui-egui/locales/fr.toml | 4 ++-- crates/ui-egui/locales/ja.toml | 4 ++-- crates/ui-egui/locales/ko.toml | 4 ++-- crates/ui-egui/locales/nl.toml | 4 ++-- crates/ui-egui/locales/pt-BR.toml | 4 ++-- crates/ui-egui/locales/sv.toml | 4 ++-- crates/ui-egui/locales/zh-Hans.toml | 4 ++-- 10 files changed, 20 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index c5d7dc7ee..c2ea2e6e8 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -1102,7 +1102,6 @@ status = "draft — needs native review" "Save as" = "Speichern unter" "Save as preset…" = "Als Vorgabe speichern…" "Save files automatically" = "Dateien automatisch speichern" -"Start on the Home screen" = "Mit Start beginnen" "Save version {key}" = "Version speichern {key}" "Saved" = "Gespeichert" "Saved {name}" = "{name} gespeichert" @@ -1225,6 +1224,7 @@ status = "draft — needs native review" "Start ascending from / stop descending at" = "Aufwärts zählen ab / abwärts zählen bis" "Start from a blank canvas" = "Mit einer leeren Arbeitsfläche beginnen" "Start on path" = "Anfang auf dem Pfad" +"Start on the Home screen" = "Mit Start beginnen" "Start point" = "Startpunkt" "Start sharing" = "Teilen starten" "Starting to share…" = "Teilen wird gestartet…" @@ -1924,7 +1924,6 @@ status = "draft — needs native review" "plugins.manage" = "Plugins verwalten…" "plugins.runLast" = "Letztes Plugin ausführen" "pref.autoSave" = "Dateien automatisch speichern" -"pref.startOnHome" = "Mit Start beginnen" "pref.highlightHover" = "Ebenen beim Überfahren hervorheben" "pref.keepTool" = "Werkzeug nach Verwendung ausgewählt lassen" "pref.language" = "Sprache der Benutzeroberfläche" @@ -1934,6 +1933,7 @@ status = "draft — needs native review" "pref.reduceMotion" = "Bewegung reduzieren" "pref.renameDuplicates" = "Duplizierte Ebenen umbenennen" "pref.showDimensions" = "Abmessungen an Objekten anzeigen" +"pref.startOnHome" = "Mit Start beginnen" "pref.userName" = "Ihr Name in Kommentaren" "proto.action.add" = "Prototyp-Aktion hinzufügen" "proto.action.move" = "Prototyp-Aktion neu anordnen" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 1a73816e1..2a61b974b 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1155,7 +1155,6 @@ status = "draft — needs native review" "Run" = "Ejecutar" "Run {label}" = "Ejecutar {label}" "Save files automatically" = "Guardar archivos automáticamente" -"Start on the Home screen" = "Empezar en Inicio" "Search templates" = "Buscar plantillas" "See all templates" = "Ver todas las plantillas" "Select layers and they space themselves as their content changes" = "Selecciona capas y se espaciarán solas cuando cambie su contenido" @@ -1300,6 +1299,7 @@ status = "draft — needs native review" "Start from a blank canvas" = "Empieza con un lienzo en blanco" "Start from a template" = "Empieza con una plantilla" "Start on path" = "Inicio en el trazado" +"Start on the Home screen" = "Empezar en Inicio" "Start point" = "Punto inicial" "Start sharing" = "Empezar a compartir" "Start sharing with the Share button, then pass on the code it gives" = "Empieza a compartir con el botón Compartir y pasa el código que te da" @@ -2024,7 +2024,6 @@ status = "draft — needs native review" "plugins.manage" = "Gestionar plugins…" "plugins.runLast" = "Ejecutar el último plugin" "pref.autoSave" = "Guardar archivos automáticamente" -"pref.startOnHome" = "Empezar en Inicio" "pref.highlightHover" = "Resaltar capas al pasar el cursor" "pref.keepTool" = "Mantener la herramienta tras usarla" "pref.language" = "Idioma de la interfaz" @@ -2034,6 +2033,7 @@ status = "draft — needs native review" "pref.reduceMotion" = "Reducir movimiento" "pref.renameDuplicates" = "Renombrar capas duplicadas" "pref.showDimensions" = "Mostrar dimensiones en los objetos" +"pref.startOnHome" = "Empezar en Inicio" "pref.userName" = "Tu nombre en los comentarios" "proto.action.add" = "Añadir acción de prototipo" "proto.action.move" = "Reordenar acción de prototipo" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 6a47a8f2e..783395d83 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -1162,7 +1162,6 @@ status = "draft — needs native review" "Save as" = "Tallenna nimellä" "Save as preset…" = "Tallenna esiasetuksena…" "Save files automatically" = "Tallenna tiedostot automaattisesti" -"Start on the Home screen" = "Aloita Aloitus-näkymästä" "Save version {key}" = "Tallenna versio {key}" "Saved" = "Tallennettu" "Saved {name}" = "Tallennettu: {name}" @@ -1301,6 +1300,7 @@ status = "draft — needs native review" "Start from a blank canvas" = "Aloita tyhjältä piirtoalueelta" "Start from a template" = "Aloita mallista" "Start on path" = "Aloituskohta polulla" +"Start on the Home screen" = "Aloita Aloitus-näkymästä" "Start point" = "Alkupiste" "Start sharing" = "Aloita jakaminen" "Start sharing with the Share button, then pass on the code it gives" = "Aloita jakaminen Jaa-painikkeella ja välitä sen antama koodi" @@ -2025,7 +2025,6 @@ status = "draft — needs native review" "plugins.manage" = "Hallitse liitännäisiä…" "plugins.runLast" = "Suorita viimeisin liitännäinen" "pref.autoSave" = "Tallenna tiedostot automaattisesti" -"pref.startOnHome" = "Aloita Aloitus-näkymästä" "pref.highlightHover" = "Korosta tasot osoitettaessa" "pref.keepTool" = "Pidä työkalu valittuna käytön jälkeen" "pref.language" = "Käyttöliittymän kieli" @@ -2035,6 +2034,7 @@ status = "draft — needs native review" "pref.reduceMotion" = "Vähennä liikettä" "pref.renameDuplicates" = "Nimeä monistetut tasot uudelleen" "pref.showDimensions" = "Näytä objektien mitat" +"pref.startOnHome" = "Aloita Aloitus-näkymästä" "pref.userName" = "Nimesi kommenteissa" "proto.action.add" = "Lisää prototyyppitoiminto" "proto.action.move" = "Muuta prototyyppitoimintojen järjestystä" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 947281a25..ea20ab107 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -1157,7 +1157,6 @@ status = "draft — needs native review" "Save as" = "Enregistrer sous" "Save as preset…" = "Enregistrer comme préréglage…" "Save files automatically" = "Enregistrer les fichiers automatiquement" -"Start on the Home screen" = "Démarrer sur l’Accueil" "Save version {key}" = "Enregistrer une version {key}" "Saved" = "Enregistré" "Saved {name}" = "{name} enregistré" @@ -1294,6 +1293,7 @@ status = "draft — needs native review" "Start from a blank canvas" = "Commencer avec un canevas vide" "Start from a template" = "Commencer avec un modèle" "Start on path" = "Début sur le tracé" +"Start on the Home screen" = "Démarrer sur l’Accueil" "Start point" = "Point de départ" "Start sharing" = "Commencer le partage" "Start sharing with the Share button, then pass on the code it gives" = "Commencez à partager avec le bouton Partager, puis communiquez le code qu’il donne" @@ -2013,7 +2013,6 @@ status = "draft — needs native review" "plugins.manage" = "Gérer les plugins…" "plugins.runLast" = "Exécuter le dernier plugin" "pref.autoSave" = "Enregistrer les fichiers automatiquement" -"pref.startOnHome" = "Démarrer sur l’Accueil" "pref.highlightHover" = "Mettre en évidence les calques au survol" "pref.keepTool" = "Garder l’outil sélectionné après usage" "pref.language" = "Langue de l’interface" @@ -2023,6 +2022,7 @@ status = "draft — needs native review" "pref.reduceMotion" = "Réduire les animations" "pref.renameDuplicates" = "Renommer les calques dupliqués" "pref.showDimensions" = "Afficher les dimensions sur les objets" +"pref.startOnHome" = "Démarrer sur l’Accueil" "pref.userName" = "Votre nom dans les commentaires" "proto.action.add" = "Ajouter une action de prototype" "proto.action.move" = "Réordonner l’action de prototype" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index ed71e6719..0f617b6fb 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -1146,7 +1146,6 @@ status = "draft — needs native review" "Save as" = "名前を付けて保存" "Save as preset…" = "プリセットとして保存…" "Save files automatically" = "ファイルを自動保存" -"Start on the Home screen" = "起動時にホームを表示" "Save version {key}" = "バージョンを保存 {key}" "Saved" = "保存済み" "Saved {name}" = "{name} を保存済み" @@ -1283,6 +1282,7 @@ status = "draft — needs native review" "Start from a blank canvas" = "空白のキャンバスから始める" "Start from a template" = "テンプレートから始める" "Start on path" = "パス上の開始位置" +"Start on the Home screen" = "起動時にホームを表示" "Start point" = "始点" "Start sharing" = "共有を開始" "Start sharing with the Share button, then pass on the code it gives" = "共有ボタンで共有を開始し、表示されたコードを相手に伝える" @@ -1997,7 +1997,6 @@ status = "draft — needs native review" "plugins.manage" = "プラグインを管理…" "plugins.runLast" = "最後のプラグインを実行" "pref.autoSave" = "ファイルを自動保存" -"pref.startOnHome" = "起動時にホームを表示" "pref.highlightHover" = "ホバー時にレイヤーをハイライト" "pref.keepTool" = "使用後もツールを選択したままにする" "pref.language" = "インターフェースの言語" @@ -2007,6 +2006,7 @@ status = "draft — needs native review" "pref.reduceMotion" = "動きを減らす" "pref.renameDuplicates" = "複製したレイヤーの名前を変更" "pref.showDimensions" = "オブジェクトに寸法を表示" +"pref.startOnHome" = "起動時にホームを表示" "pref.userName" = "コメントに表示する名前" "proto.action.add" = "プロトタイプのアクションを追加" "proto.action.move" = "プロトタイプのアクションの順序を変更" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 43c9a22ea..a96a0991f 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -1096,7 +1096,6 @@ status = "draft — needs native review" "Save as" = "다른 이름으로 저장" "Save as preset…" = "프리셋으로 저장…" "Save files automatically" = "파일 자동 저장" -"Start on the Home screen" = "홈에서 시작" "Save version {key}" = "버전 저장 {key}" "Saved" = "저장됨" "Saved {name}" = "{name} 저장됨" @@ -1219,6 +1218,7 @@ status = "draft — needs native review" "Start ascending from / stop descending at" = "오름차순 시작 값 / 내림차순 끝 값" "Start from a blank canvas" = "빈 캔버스로 시작" "Start on path" = "패스 시작 위치" +"Start on the Home screen" = "홈에서 시작" "Start point" = "시작점" "Start sharing" = "공유 시작" "Starting to share…" = "공유를 시작하는 중…" @@ -1915,7 +1915,6 @@ status = "draft — needs native review" "plugins.manage" = "플러그인 관리…" "plugins.runLast" = "마지막 플러그인 실행" "pref.autoSave" = "파일 자동 저장" -"pref.startOnHome" = "홈에서 시작" "pref.highlightHover" = "마우스를 올리면 레이어 강조" "pref.keepTool" = "사용 후에도 도구 선택 유지" "pref.language" = "인터페이스 언어" @@ -1925,6 +1924,7 @@ status = "draft — needs native review" "pref.reduceMotion" = "동작 줄이기" "pref.renameDuplicates" = "복제한 레이어 이름 바꾸기" "pref.showDimensions" = "개체에 치수 표시" +"pref.startOnHome" = "홈에서 시작" "pref.userName" = "댓글에 표시할 이름" "proto.action.add" = "프로토타입 액션 추가" "proto.action.move" = "프로토타입 액션 순서 변경" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 0e28ad9c9..cf861d2ae 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -1162,7 +1162,6 @@ status = "draft — needs native review" "Save as" = "Opslaan als" "Save as preset…" = "Opslaan als preset…" "Save files automatically" = "Bestanden automatisch opslaan" -"Start on the Home screen" = "Starten op Home" "Save version {key}" = "Versie opslaan {key}" "Saved" = "Opgeslagen" "Saved {name}" = "{name} opgeslagen" @@ -1301,6 +1300,7 @@ status = "draft — needs native review" "Start from a blank canvas" = "Begin vanaf een leeg canvas" "Start from a template" = "Begin met een sjabloon" "Start on path" = "Begin op het pad" +"Start on the Home screen" = "Starten op Home" "Start point" = "Startpunt" "Start sharing" = "Delen starten" "Start sharing with the Share button, then pass on the code it gives" = "Begin met delen via de knop Delen en geef daarna de code door die je krijgt" @@ -2024,7 +2024,6 @@ status = "draft — needs native review" "plugins.manage" = "Plugins beheren…" "plugins.runLast" = "Laatste plugin uitvoeren" "pref.autoSave" = "Bestanden automatisch opslaan" -"pref.startOnHome" = "Starten op Home" "pref.highlightHover" = "Lagen markeren bij aanwijzen" "pref.keepTool" = "Gereedschap geselecteerd houden na gebruik" "pref.language" = "Interfacetaal" @@ -2034,6 +2033,7 @@ status = "draft — needs native review" "pref.reduceMotion" = "Beweging verminderen" "pref.renameDuplicates" = "Gedupliceerde lagen hernoemen" "pref.showDimensions" = "Afmetingen op objecten tonen" +"pref.startOnHome" = "Starten op Home" "pref.userName" = "Je naam in reacties" "proto.action.add" = "Prototypeactie toevoegen" "proto.action.move" = "Volgorde van prototypeactie wijzigen" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 625599f7e..721ebf64c 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -1095,7 +1095,6 @@ status = "draft — needs native review" "Save as" = "Salvar como" "Save as preset…" = "Salvar como predefinição…" "Save files automatically" = "Salvar arquivos automaticamente" -"Start on the Home screen" = "Começar no Início" "Save version {key}" = "Salvar versão {key}" "Saved" = "Salvo" "Saved {name}" = "{name} salvo" @@ -1218,6 +1217,7 @@ status = "draft — needs native review" "Start ascending from / stop descending at" = "Contar para cima a partir de / para baixo até" "Start from a blank canvas" = "Comece com uma tela em branco" "Start on path" = "Início no caminho" +"Start on the Home screen" = "Começar no Início" "Start point" = "Ponto inicial" "Start sharing" = "Começar a compartilhar" "Starting to share…" = "Começando a compartilhar…" @@ -1914,7 +1914,6 @@ status = "draft — needs native review" "plugins.manage" = "Gerenciar plugins…" "plugins.runLast" = "Executar o último plugin" "pref.autoSave" = "Salvar arquivos automaticamente" -"pref.startOnHome" = "Começar no Início" "pref.highlightHover" = "Destacar camadas ao passar o cursor" "pref.keepTool" = "Manter a ferramenta selecionada após o uso" "pref.language" = "Idioma da interface" @@ -1924,6 +1923,7 @@ status = "draft — needs native review" "pref.reduceMotion" = "Reduzir movimento" "pref.renameDuplicates" = "Renomear camadas duplicadas" "pref.showDimensions" = "Mostrar dimensões nos objetos" +"pref.startOnHome" = "Começar no Início" "pref.userName" = "Seu nome nos comentários" "proto.action.add" = "Adicionar ação de protótipo" "proto.action.move" = "Reordenar ação de protótipo" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 3593e2cd6..183a53810 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -1144,7 +1144,6 @@ name = "Svenska" "Run" = "Kör" "Run {label}" = "Kör {label}" "Save files automatically" = "Spara filer automatiskt" -"Start on the Home screen" = "Börja på Start" "Search templates" = "Sök mallar" "See all templates" = "Se alla mallar" "Select layers and they space themselves as their content changes" = "Markera lager så fördelar de avståndet själva när innehållet ändras" @@ -1287,6 +1286,7 @@ name = "Svenska" "Start from a blank canvas" = "Börja med en tom arbetsyta" "Start from a template" = "Börja med en mall" "Start on path" = "Början på banan" +"Start on the Home screen" = "Börja på Start" "Start point" = "Startpunkt" "Start sharing" = "Börja dela" "Start sharing with the Share button, then pass on the code it gives" = "Börja dela med knappen Dela och skicka sedan vidare koden du får" @@ -2005,7 +2005,6 @@ name = "Svenska" "plugins.manage" = "Hantera plugin-program…" "plugins.runLast" = "Kör senaste plugin" "pref.autoSave" = "Spara filer automatiskt" -"pref.startOnHome" = "Börja på Start" "pref.highlightHover" = "Framhäv lager vid hovring" "pref.keepTool" = "Behåll verktyget markerat efter användning" "pref.language" = "Gränssnittsspråk" @@ -2015,6 +2014,7 @@ name = "Svenska" "pref.reduceMotion" = "Minska rörelse" "pref.renameDuplicates" = "Byt namn på duplicerade lager" "pref.showDimensions" = "Visa dimensioner på objekt" +"pref.startOnHome" = "Börja på Start" "pref.userName" = "Ditt namn i kommentarer" "proto.action.add" = "Lägg till prototypåtgärd" "proto.action.move" = "Ändra ordning på prototypåtgärd" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index e4a52a683..5383c0411 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -1097,7 +1097,6 @@ status = "draft — needs native review" "Save as" = "另存为" "Save as preset…" = "存储为预设…" "Save files automatically" = "自动保存文件" -"Start on the Home screen" = "启动时显示主页" "Save version {key}" = "保存版本 {key}" "Saved" = "已保存" "Saved {name}" = "已保存 {name}" @@ -1220,6 +1219,7 @@ status = "draft — needs native review" "Start ascending from / stop descending at" = "升序起点 / 降序终点" "Start from a blank canvas" = "从空白画布开始" "Start on path" = "路径起点" +"Start on the Home screen" = "启动时显示主页" "Start point" = "起点" "Start sharing" = "开始分享" "Starting to share…" = "正在开始分享…" @@ -1916,7 +1916,6 @@ status = "draft — needs native review" "plugins.manage" = "管理插件…" "plugins.runLast" = "运行上一个插件" "pref.autoSave" = "自动保存文件" -"pref.startOnHome" = "启动时显示主页" "pref.highlightHover" = "悬停时高亮图层" "pref.keepTool" = "使用后保持工具选中" "pref.language" = "界面语言" @@ -1926,6 +1925,7 @@ status = "draft — needs native review" "pref.reduceMotion" = "减少动态效果" "pref.renameDuplicates" = "重命名副本图层" "pref.showDimensions" = "在对象上显示尺寸" +"pref.startOnHome" = "启动时显示主页" "pref.userName" = "评论中显示的名字" "proto.action.add" = "添加原型操作" "proto.action.move" = "调整原型操作顺序" From 9877b8e6cce1da2da20d44f8e39464b764efcc29 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 20 Oct 2026 00:13:46 +0700 Subject: [PATCH 106/114] #874: test that the Start on the Home screen preference persists (old settings read off) --- crates/engine/src/cmd/view.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index bdf673390..b8a2569c0 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -523,3 +523,24 @@ pub fn specs() -> Vec { #[allow(dead_code)] fn unused(_: NodeId) {} + +#[cfg(test)] +mod start_on_home_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn start_on_home_is_a_preference_that_persists() { + let mut s = Session::new(); + assert!(!s.prefs.start_on_home); + assert_eq!(s.execute("pref.startOnHome", &json!({})).unwrap()["on"], true); + assert!(s.prefs.start_on_home); + // Saved with the other preferences and read back; older settings files without it read as off. + let saved: crate::Prefs = serde_json::from_value(serde_json::to_value(&s.prefs).unwrap()).unwrap(); + assert!(saved.start_on_home); + assert_eq!(s.execute("pref.startOnHome", &json!({"on": false})).unwrap()["on"], false); + let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap(); + assert!(!old.start_on_home); + } +} From 21d5090db2d3ea08eaddd840e775a6cbb56b8011 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 20 Oct 2026 00:13:47 +0700 Subject: [PATCH 107/114] #874: --control starts in a new file whatever the preference (control callers expect a document), with a test --- apps/figcraft/src/main.rs | 25 ++++++++++++++++++++++--- docs/mcp.md | 3 ++- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index e98f93733..00856c5d5 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -193,10 +193,8 @@ fn main() -> eframe::Result { // default, never a build from source unless asked). session.live.links = figcraft_collab_net::links::system(); session.invite_links_startup(); - // A first run (no settings kept yet, nothing to open) starts on the home screen, with its - // tips and templates; later runs start in a new file unless the preference says Home. let first_run = svc.prefs.is_none() && args.join.is_none(); - if session.docs.is_empty() && !first_run && !session.prefs.start_on_home { + if session.docs.is_empty() && opens_new_file(first_run, session.prefs.start_on_home, port.is_some()) { let _ = session.execute("file.new", &json!({})); } // The interface language from the first frame on, and for the message when the window can't @@ -268,6 +266,14 @@ fn main() -> eframe::Result { result } +/// With nothing to open, whether the window starts in a new file rather than on the home screen. +/// A first run (no settings kept yet) shows the home screen's tips and templates, and later runs +/// start in a new file unless the preference asks for Home, which `--control` overrides: its +/// callers expect a document to act on. +fn opens_new_file(first_run: bool, start_on_home: bool, control: bool) -> bool { + !first_run && (!start_on_home || control) +} + fn desktop_title_bar() -> figcraft_ui_egui::TitleBar { use figcraft_ui_egui::TitleBar; if cfg!(target_os = "macos") { TitleBar::Inset } else { TitleBar::Custom } @@ -292,3 +298,16 @@ mod linux_window_tests { assert_eq!(viewport.decorations, Some(false)); } } + +#[cfg(test)] +mod start_tests { + use super::opens_new_file; + + #[test] + fn the_home_preference_starts_on_home_except_for_control_callers() { + assert!(opens_new_file(false, false, false), "a new file by default"); + assert!(!opens_new_file(false, true, false), "Home when the preference asks"); + assert!(opens_new_file(false, true, true), "--control still starts with a document"); + assert!(!opens_new_file(true, false, false), "a first run shows the home screen"); + } +} diff --git a/docs/mcp.md b/docs/mcp.md index 90cc75f5c..fb94dabf6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -420,7 +420,8 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: - `pref.autoSave {on?}`: files that have a path save themselves a few seconds after they change (off by default; the desktop app's timer does it, so scripts still call `file.save`). - `pref.startOnHome {on?}`: the desktop app starts on the Home screen instead of in a new file - (off by default; files named on the command line still open). + (off by default; files named on the command line still open, and `--control` starts in a new + file anyway, so control callers have a document). - `pref.language {language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…}`: the app's interface language, applied live ("system": - `pref.language {language: system|en|es|es-419|fr|de|pt-BR|ja|ko|sv|zh-Hans|…}`: the app's interface language, applied live ("system": the OS or browser language; a language without a catalog shows English). Command ids, labels and From a9975ccc220aec2794643206d1bb9ced435eedc5 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 20 Oct 2026 00:13:55 +0700 Subject: [PATCH 108/114] #818: drop the progress-doc edits (ROADMAP revision row, ui-parity rows); the maintainer adds them --- ROADMAP.md | 1 - docs/ui-parity.md | 2 -- 2 files changed, 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index e5ed24d65..8a587286d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -319,7 +319,6 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged | | 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. | | 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | -| 2026-10-17 | minor | Interface size (#540): Preferences ▸ Interface size and `pref.uiScale` make the whole interface larger or smaller. Auto keeps the system's display scale (its entry shows the size it draws at); a fixed size from 75 % to 300 % is absolute, so 100 % is one screen pixel per point on every system. A window too small for a fixed size draws a smaller one until it is larger (the choice is kept); the browser keeps its own zoom. Cmd/Ctrl +/-/0 still zoom the canvas only. Numbers unchanged. | | 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | | 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. | | 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index c935e0094..766284f1d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -165,7 +165,6 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Keyboard navigation: F6 regions, Tab within, focus ring | done | | | Layers panel from the keyboard: arrows, F2 / Cmd+R rename (the row gets the keyboard back), Shift+F10 context menu; 10k-layer lists | done | revealing a layer, expanding, collapsing and arrow keys timed in `cargo xtask bench` | | Reduced motion | done | system setting or preference | -| Interface size (Preferences ▸ Interface size, `pref.uiScale`) | done on desktop | Auto (the system's scale) or a fixed size from 75 % to 300 %, where 100 % is one screen pixel per point on every system; held back while the window is too small for it; the browser keeps its own zoom | | WCAG AA contrast of secondary and accent text | done | | | Tested with VoiceOver, NVDA, Narrator, Orca | missing | human QA | @@ -177,7 +176,6 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | 2026-10-17 | minor | Escape or Enter while the pen draws ends the path and keeps its vector in edit mode with the pen, so the next path is drawn into the same layer (unconnected); another Escape leaves edit mode (#760). Entering edit mode on a layer that already is a vector no longer records an empty undo step. Numbers unchanged | | 2026-10-17 | minor | Text layers keep the platform IME on for every frame of an edit, not only frames with input (an idle frame turned it off and macOS dropped each composition, #747); the candidate window opens at the caret instead of the canvas corner; keys pressed while composing go to the IME | | 2026-10-17 | minor | The community batches #733, #735, #736, #729 and #753: submenus open after a short hover, keep their place along a safe triangle and reopen closed (main and context menus); the Actions palette's tabs, ranked search, keyboard highlight and recents; menu items that need a value ask for it, File ▸ Rename edits in place; Shift+I and Resources open Assets; a draggable Pages/Layers divider; Shift-drag moves the selection; a double-click near a thin stroke, merging dropped points and Flatten keeping open paths in vector editing; live font preview and the selection highlight after line breaks; the colour picker on a new fill or stroke; Paste over and Paste properties from the keyboard; Selection colors (partial: no library styles or variables there) and smart-selection spacing fields and canvas handles (partial: no reorder rings), both also checked by their author on macOS; the keyboard shortcuts panel closes on the home screen; Adwaita window controls on Wayland compositors without server decorations (checked by its author on GNOME; KDE not run). Checked in the app on Windows (submenus by their author), not side by side with Figma. Summary and hours unchanged | -| 2026-10-17 | minor | Interface size preference (#540): Preferences ▸ Interface size and `pref.uiScale`, Auto or a fixed size from 75 % to 300 %, absolute (100 % is one screen pixel per point whatever the system's scale) | | 2026-10-15 | minor | Hex fields take CSS colour names (`red` → `FF0000`): fills, strokes, effects, grids, variables, backgrounds and the colour picker | | 2026-10-15 | minor | Typography: drag the line-height, letter-spacing and paragraph-spacing icons to scrub their values; shared numeric-field rates, resize cursor and one Undo step per gesture (#373). | | 2026-10-15 | minor | A click or Tab into a one-line field selects all of its text (hex, inline, name, search and dialog fields); the first click into a fill's hex takes the keyboard | From 20e4028afa2a5f7c922ba591e62d1a7a1dac9be1 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 20 Oct 2026 00:23:14 +0700 Subject: [PATCH 109/114] #818: interface sizes are relative to the system's display scale (100% is native, Auto goes; 75-200% in Preferences, pref.uiScale takes 0.75-3, "auto" reads as 1); interface_size moved above batch_rename_dialog's doc comment; tests and docs follow --- crates/engine/src/a11y_keys_tests.rs | 34 +++++----- crates/engine/src/cmd/view.rs | 9 ++- crates/engine/src/lib.rs | 25 +++---- crates/ui-egui/locales/en.toml | 3 +- crates/ui-egui/locales/es.toml | 3 +- crates/ui-egui/locales/fr.toml | 3 +- crates/ui-egui/src/dialogs.rs | 28 ++++---- crates/ui-egui/src/lib.rs | 22 +++---- crates/ui-egui/src/ui_scale.rs | 99 +++++++++++++--------------- crates/ui-egui/src/ui_scale_tests.rs | 69 +++++++++---------- docs/control-protocol.md | 5 +- docs/mcp.md | 11 ++-- 12 files changed, 138 insertions(+), 173 deletions(-) diff --git a/crates/engine/src/a11y_keys_tests.rs b/crates/engine/src/a11y_keys_tests.rs index 72507df5f..e4892bcae 100644 --- a/crates/engine/src/a11y_keys_tests.rs +++ b/crates/engine/src/a11y_keys_tests.rs @@ -57,25 +57,23 @@ fn reduce_motion_is_a_preference_that_persists() { } #[test] -fn interface_size_is_auto_until_chosen_and_a_chosen_size_is_absolute_and_persists() { +fn interface_size_is_relative_to_the_system_and_persists() { let mut s = Session::new(); - assert_eq!(s.prefs.ui_scale, None); - assert_eq!(s.execute("pref.uiScale", &json!({})).unwrap()["scale"], "auto"); - // A fixed size: 1.0 is one screen pixel per point, so the number is not multiplied by the system's scale. + assert_eq!(s.prefs.ui_scale, 1.0, "the system's own size by default"); + assert_eq!(s.execute("pref.uiScale", &json!({})).unwrap()["scale"], 1.0); assert_eq!(s.execute("pref.uiScale", &json!({"scale": 1.25})).unwrap()["scale"], 1.25); - assert_eq!(s.prefs.ui_scale, Some(1.25)); - // Reading changes nothing; "auto" goes back to the system's scale. + assert_eq!(s.prefs.ui_scale, 1.25); + // Reading changes nothing; "auto" (older scripts) is the system's own size. assert_eq!(s.execute("pref.uiScale", &json!({})).unwrap()["scale"], 1.25); - assert_eq!(s.execute("pref.uiScale", &json!({"scale": "Auto"})).unwrap()["scale"], "auto"); - assert_eq!(s.prefs.ui_scale, None); + assert_eq!(s.execute("pref.uiScale", &json!({"scale": "Auto"})).unwrap()["scale"], 1.0); // Nothing to undo: the document didn't change. assert!(!s.ds().map(|d| d.is_dirty()).unwrap_or(false)); - // Saved with the other preferences. - s.prefs.ui_scale = Some(2.5); + // Saved with the other preferences and read back. + s.prefs.ui_scale = 0.9; let v = serde_json::to_value(&s.prefs).unwrap(); - assert_eq!(v["ui_scale"], 2.5); + assert_eq!(v["ui_scale"], 0.9); let back: crate::Prefs = serde_json::from_value(v).unwrap(); - assert_eq!(back.ui_scale, Some(2.5)); + assert_eq!(back.ui_scale, 0.9); } #[test] @@ -86,22 +84,22 @@ fn interface_size_stops_at_the_ends_and_junk_never_changes_it() { assert_eq!(s.execute("pref.uiScale", &json!({"scale": 1e308})).unwrap()["scale"], crate::UI_SCALE_MAX); // The size is a factor (1.25), not a percentage: 125 is far past the end and stops there. assert_eq!(s.execute("pref.uiScale", &json!({"scale": 125})).unwrap()["scale"], crate::UI_SCALE_MAX); - s.prefs.ui_scale = Some(1.5); + s.prefs.ui_scale = 1.5; for junk in [json!({"scale": "NaN"}), json!({"scale": "big"}), json!({"scale": [1]}), json!({"scale": {"x": 1}})] { assert!(s.execute("pref.uiScale", &junk).is_err(), "{junk}"); - assert_eq!(s.prefs.ui_scale, Some(1.5), "{junk}"); + assert_eq!(s.prefs.ui_scale, 1.5, "{junk}"); } } #[test] -fn a_settings_file_without_or_with_a_wrong_interface_size_reads_as_auto_and_keeps_the_rest() { +fn a_settings_file_without_or_with_a_wrong_interface_size_reads_as_the_systems_size_and_keeps_the_rest() { let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap(); - assert_eq!(old.ui_scale, None); + assert_eq!(old.ui_scale, 1.0); for bad in [json!("big"), json!(null), json!([2]), json!(true)] { let p: crate::Prefs = serde_json::from_value(json!({"theme": "dark", "ui_scale": bad})).unwrap(); - assert_eq!((p.ui_scale, p.theme.as_str()), (None, "dark"), "{bad}"); + assert_eq!((p.ui_scale, p.theme.as_str()), (1.0, "dark"), "{bad}"); } // A number out of range is clamped rather than refused. let p: crate::Prefs = serde_json::from_value(json!({"ui_scale": 99, "theme": "dark"})).unwrap(); - assert_eq!((p.ui_scale, p.theme.as_str()), (Some(crate::UI_SCALE_MAX), "dark")); + assert_eq!((p.ui_scale, p.theme.as_str()), (crate::UI_SCALE_MAX, "dark")); } diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 9b929118a..eb573c570 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -373,19 +373,18 @@ pub fn specs() -> Vec { spec!( "pref.uiScale", "Interface size", - "{scale?: \"auto\" or a fixed size from 0.75 to 3, 1 being one screen pixel per point whatever scale the system reports, so 1.25 is 125 %, not 125 (outside the range it stops at the nearest end; omit: just read it)} → {scale: \"auto\" or the size}: how large the whole interface is drawn; auto keeps the system's scale; the canvas zoom is separate", + "{scale?: a size from 0.75 to 3 relative to the system's display scale, 1 being the system's own size, so 1.25 is 125 %, not 125 (outside the range it stops at the nearest end; \"auto\" is 1; omit: just read it)} → {scale}: how large the whole interface is drawn; the canvas zoom is separate", false, |s, p| { match get(p, "scale") { None => {} - Some(Value::String(v)) if v.trim().eq_ignore_ascii_case("auto") => s.prefs.ui_scale = None, + Some(Value::String(v)) if v.trim().eq_ignore_ascii_case("auto") => s.prefs.ui_scale = 1.0, Some(_) => { let scale = req_num("pref.uiScale", p, "scale")?; - s.prefs.ui_scale = - Some(crate::clamp_ui_scale(scale).ok_or_else(|| bad("pref.uiScale", "`scale` must be a finite number or \"auto\""))?); + s.prefs.ui_scale = crate::clamp_ui_scale(scale).ok_or_else(|| bad("pref.uiScale", "`scale` must be a finite number"))?; } } - Ok(json!({"scale": s.prefs.ui_scale.map_or(json!("auto"), |v| json!(v))})) + Ok(json!({"scale": s.prefs.ui_scale})) } ), spec!("pref.autoSave", "Save files automatically", "{on?}", false, |s, p| pref(s, p, |x| &mut x.auto_save)), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c8010399e..c70a145f6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -603,20 +603,21 @@ impl DocState { } } -/// The smallest and largest fixed interface size: 1.0 is one screen pixel per point, whatever scale -/// the system reports. +/// The smallest and largest interface size, relative to the system's display scale (1.0 is the +/// system's own size). pub const UI_SCALE_MIN: f64 = 0.75; pub const UI_SCALE_MAX: f64 = 3.0; -/// `scale` kept between [`UI_SCALE_MIN`] and [`UI_SCALE_MAX`]; not a finite number: `None` (Auto). +/// `scale` kept between [`UI_SCALE_MIN`] and [`UI_SCALE_MAX`]; not a finite number: `None`. pub fn clamp_ui_scale(scale: f64) -> Option { scale.is_finite().then(|| scale.clamp(UI_SCALE_MIN, UI_SCALE_MAX)) } -/// Reads the saved interface size. Anything but a number reads as Auto and a number out of range is -/// clamped, so a settings file edited by hand never costs the other preferences. -fn read_ui_scale<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result, D::Error> { - Ok(Value::deserialize(d)?.as_f64().and_then(clamp_ui_scale)) +/// Reads the saved interface size. Anything but a number reads as 1 (the system's size) and a +/// number out of range is clamped, so a settings file edited by hand never costs the other +/// preferences. +fn read_ui_scale<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result { + Ok(Value::deserialize(d)?.as_f64().and_then(clamp_ui_scale).unwrap_or(1.0)) } /// App-wide preferences that commands read (nudge amounts, behaviours). @@ -646,11 +647,11 @@ pub struct Prefs { /// Reduce motion: the interface's own animations are off (the system's setting turns them off /// too). Prototype transitions still play. pub reduce_motion: bool, - /// How large the whole interface is drawn. `None` is Auto: the scale the system reports. A - /// number is a fixed size, 1.0 being one screen pixel per point, so it ignores the system's - /// scale (kept between [`UI_SCALE_MIN`] and [`UI_SCALE_MAX`]). The canvas zoom is separate. + /// How large the whole interface is drawn, relative to the system's display scale: 1.0 is the + /// system's own size, 1.25 a quarter larger (kept between [`UI_SCALE_MIN`] and + /// [`UI_SCALE_MAX`]). The canvas zoom is separate. #[serde(deserialize_with = "read_ui_scale")] - pub ui_scale: Option, + pub ui_scale: f64, /// Files that have a path are saved by themselves a few seconds after they change (the app's /// timer; off: only File ▸ Save writes them). pub auto_save: bool, @@ -719,7 +720,7 @@ impl Default for Prefs { language: "system".into(), user_name: cmd::comment::DEFAULT_AUTHOR.into(), reduce_motion: false, - ui_scale: None, + ui_scale: 1.0, auto_save: false, invite_links: None, plugins: Vec::new(), diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index b16d95509..16e74b5e2 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -134,9 +134,7 @@ name = "English" "Auto (space between)" = "Auto (space between)" "Auto (space evenly)" = "Auto (space evenly)" "Auto ({mode})" = "Auto ({mode})" -"Auto ({percent}%)" = "Auto ({percent}%)" "Auto columns" = "Auto columns" -"Auto follows your system's display scaling; the other sizes ignore it. Document zoom is separate." = "Auto follows your system's display scaling; the other sizes ignore it. Document zoom is separate." "Auto gap" = "Auto gap" "Auto height" = "Auto height" "Auto layout" = "Auto layout" @@ -1064,6 +1062,7 @@ name = "English" "Reduce motion" = "Reduce motion" "Refraction" = "Refraction" "Regular expression" = "Regular expression" +"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." "Remove" = "Remove" "Remove all" = "Remove all" "Remove annotation" = "Remove annotation" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 61ec88733..2e7bcff2a 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -8,8 +8,6 @@ name = "Español" status = "draft — needs native review" [ui] -"Auto ({percent}%)" = "Auto ({percent}%)" -"Auto follows your system's display scaling; the other sizes ignore it. Document zoom is separate." = "Auto sigue el escalado de pantalla del sistema; los demás tamaños lo ignoran. El zoom del documento es independiente." "Compatibility notes" = "Notas de compatibilidad" "Couldn't download the variables" = "No se pudieron descargar las variables" @@ -1065,6 +1063,7 @@ status = "draft — needs native review" "Reduce motion" = "Reducir movimiento" "Refraction" = "Refracción" "Regular expression" = "Expresión regular" +"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Relativo al escalado de pantalla del sistema (100% es el tamaño del propio sistema). El zoom del documento es independiente." "Remove" = "Quitar" "Remove all" = "Quitar todo" "Remove annotation" = "Quitar anotación" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index ff88477fa..c3dd05532 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -134,9 +134,7 @@ status = "draft — needs native review" "Auto (space between)" = "Auto (espace entre)" "Auto (space evenly)" = "Auto (espace égal)" "Auto ({mode})" = "Auto ({mode})" -"Auto ({percent}%)" = "Auto ({percent} %)" "Auto columns" = "Colonnes auto" -"Auto follows your system's display scaling; the other sizes ignore it. Document zoom is separate." = "Auto suit la mise à l’échelle d’affichage de votre système ; les autres tailles l’ignorent. Le zoom du document est distinct." "Auto gap" = "Espacement auto" "Auto height" = "Hauteur auto" "Auto layout" = "Mise en page auto" @@ -1061,6 +1059,7 @@ status = "draft — needs native review" "Reduce motion" = "Réduire les animations" "Refraction" = "Réfraction" "Regular expression" = "Expression régulière" +"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Relative à la mise à l’échelle d’affichage de votre système (100 % est la taille du système). Le zoom du document est distinct." "Remove" = "Retirer" "Remove all" = "Tout retirer" "Remove annotation" = "Retirer l’annotation" diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 3fb2c2d4f..c69f5616a 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -479,33 +479,25 @@ pub(crate) fn batch_rename_params(b: &BatchRenameUi) -> serde_json::Value { p } -/// Rename layers: Match, Rename to with buttons for the current name and numbers, where numbers -/// start, a preview of the first new names, then one `object.batchRename`. -/// Preferences ▸ Interface size: Auto (the system's scale) or a fixed size, as a drop-down that -/// takes effect at once, with what each means written out. The browser has its own zoom, so there +/// Preferences ▸ Interface size: a size relative to the system's display scale (100 % is the +/// system's own), as a drop-down that takes effect at once. The browser has its own zoom, so there /// the row only says so. -fn interface_size(app: &mut FigApp, ui: &mut egui::Ui, chosen: Option) { +fn interface_size(app: &mut FigApp, ui: &mut egui::Ui, chosen: f64) { let t = Tokens::get(ui.ctx()); if cfg!(target_arch = "wasm32") { ui.label(tr("Interface size")); crate::widgets::label(ui, tr("Use your browser's zoom to change the interface size."), t.text_secondary); return; } - let percent = |scale: f32| crate::ui_scale::percent(scale); - let auto = trf("Auto ({percent}%)", &[("percent", &percent(app.ui.ui_native))]); - let fixed = |size: f64| trf("{percent}%", &[("percent", &percent(size as f32))]); - let shown = chosen.map_or_else(|| auto.clone(), fixed); + let percent = |size: f64| trf("{percent}%", &[("percent", &crate::ui_scale::percent(size as f32))]); + let shown = percent(chosen); ui.horizontal(|ui| { ui.label(tr("Interface size")); let resp = crate::widgets::dropdown(ui, &shown, 120.0, None); crate::a11y::combo(&resp, tr("Interface size"), &shown, crate::a11y::menu_open(&resp)); crate::right::menu_popup(&resp).show(|ui| { - if crate::right::menu_check(ui, chosen.is_none(), &auto, "") { - app.run("pref.uiScale", json!({"scale": "auto"})); - ui.close(); - } for size in crate::ui_scale::SIZES { - if crate::right::menu_check(ui, chosen == Some(size), &fixed(size), "") { + if crate::right::menu_check(ui, (chosen - size).abs() < 1e-6, &percent(size), "") { app.run("pref.uiScale", json!({"scale": size})); ui.close(); } @@ -514,19 +506,21 @@ fn interface_size(app: &mut FigApp, ui: &mut egui::Ui, chosen: Option) { }); crate::widgets::label( ui, - tr("Auto follows your system's display scaling; the other sizes ignore it. Document zoom is separate."), + tr("Relative to your system's display scaling (100% is the system's own size). Document zoom is separate."), t.text_secondary, ); // Said in words, not only by the menu's value: the window is too small for the size chosen. - if let Some(size) = chosen.filter(|_| app.ui.ui_scale.limited) { + if app.ui.ui_scale.limited { let note = trf( "The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%.", - &[("size", &percent(size as f32)), ("drawn", &percent(app.ui.ui_scale.drawn))], + &[("size", &crate::ui_scale::percent(chosen as f32)), ("drawn", &crate::ui_scale::percent(app.ui.ui_scale.zoom))], ); crate::widgets::label(ui, ¬e, t.text_secondary); } } +/// Rename layers: Match, Rename to with buttons for the current name and numbers, where numbers +/// start, a preview of the first new names, then one `object.batchRename`. fn batch_rename_dialog(app: &mut FigApp, ctx: &Context) { let Some(mut b) = app.ui.batch_rename.clone() else { return }; let t = Tokens::get(ctx); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f329c5212..7f7dcca6b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -462,11 +462,9 @@ pub struct UiState { pub menu_input: Option, /// File ▸ Rename: the file name in the left panel starts its inline rename on the next frame. pub rename_file: bool, - /// What the interface size drew last frame: the system's scale (what Auto shows) and whether - /// the window held a fixed size back. Set by [`FigApp::logic`] for the main window. + /// What the interface size drew last frame and whether the window held it back. Set by + /// [`FigApp::logic`] for the main window. pub ui_scale: ui_scale::Plan, - /// The system's scale of the main window, as of the last frame. - pub ui_native: f32, pub shortcuts_panel: bool, /// The Keyboard shortcuts panel's tab and search text. pub shortcuts_tab: figcraft_engine::keymap::Group, @@ -620,8 +618,7 @@ impl Default for UiState { preferences: false, menu_input: None, rename_file: false, - ui_scale: ui_scale::plan(None, None, None), - ui_native: 1.0, + ui_scale: ui_scale::plan(1.0, None, None), shortcuts_panel: false, shortcuts_tab: figcraft_engine::keymap::Group::Essential, shortcuts_query: String::new(), @@ -1271,16 +1268,15 @@ impl FigApp { theme::install_fonts(ctx, true, cjk); } } - // egui's zoom is for the whole app, so only the main window sets it (a window on another - // screen has its own system scale). The browser keeps its own zoom: Auto there. + // egui's zoom is for the whole app and multiplies each window's own system scale, so the + // size is the same relative to every screen; the main window, whose size can hold a size + // back, sets it. The browser keeps its own zoom. if ctx.viewport_id() == egui::ViewportId::ROOT { - let native = ctx.native_pixels_per_point(); let window_px = ctx.content_rect().size() * ctx.pixels_per_point(); - let fixed = if cfg!(target_arch = "wasm32") { None } else { self.session.prefs.ui_scale }; - let plan = ui_scale::plan(fixed, native, Some((window_px.x, window_px.y))); + let size = if cfg!(target_arch = "wasm32") { 1.0 } else { self.session.prefs.ui_scale }; + let plan = ui_scale::plan(size, ctx.native_pixels_per_point(), Some((window_px.x, window_px.y))); ctx.set_zoom_factor(plan.zoom); self.ui.ui_scale = plan; - self.ui.ui_native = native.filter(|n| n.is_finite() && *n > 0.0).unwrap_or(1.0); } let dark = match self.session.prefs.theme.as_str() { "dark" => true, @@ -1701,7 +1697,7 @@ impl FigApp { }); state["homeSearch"] = json!(self.ui.home_view.file_query); // Set apart from the literal above, which is at the macro recursion limit. - state["uiScale"] = json!({"choice": self.session.prefs.ui_scale.map_or(json!("auto"), |v| json!(v)), "drawn": self.ui.ui_scale.drawn, "limited": self.ui.ui_scale.limited}); + state["uiScale"] = json!({"choice": self.session.prefs.ui_scale, "zoom": self.ui.ui_scale.zoom, "drawn": self.ui.ui_scale.drawn, "limited": self.ui.ui_scale.limited}); state["pendingOpens"] = json!(self.ui.pending_opens.len()); state["missingFonts"] = json!(self.ui.missing_fonts.as_ref().map(missing_fonts::Shown::inspect)); state diff --git a/crates/ui-egui/src/ui_scale.rs b/crates/ui-egui/src/ui_scale.rs index 91d790ccb..6b275a078 100644 --- a/crates/ui-egui/src/ui_scale.rs +++ b/crates/ui-egui/src/ui_scale.rs @@ -1,11 +1,10 @@ //! The interface size (Preferences ▸ Interface size, `pref.uiScale`): how the saved choice becomes //! egui's zoom factor. //! -//! egui draws at `pixels_per_point = zoom factor × the system's scale`. Auto leaves the zoom at 1 so -//! the system's scale rules. A fixed size is absolute: 100 % is one screen pixel per point on every -//! system, so the zoom is the size divided by the system's scale (a Retina Mac at 2× needs 0.5 to -//! show 100 %). A fixed size is held back while the window is too small to lay the interface out at -//! it; the saved choice never changes. +//! egui draws at `pixels_per_point = zoom factor × the system's scale`. The size is relative to the +//! system's scale, so it is the zoom itself: 100 % is the system's own size on every screen (native +//! on a Retina Mac too), 125 % a quarter larger. A size above 100 % is held back while the window is +//! too small to lay the interface out at it; the saved choice never changes. /// The smallest window, in points, the interface is laid out for: the main window's minimum size. pub const FIT_POINTS: (f32, f32) = (800.0, 500.0); @@ -16,32 +15,31 @@ const ZOOM_RANGE: (f32, f32) = (0.1, 10.0); /// What to give egui for a saved choice on this screen and window. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Plan { - /// For `Context::set_zoom_factor`. + /// For `Context::set_zoom_factor`: the size drawn, relative to the system's scale. pub zoom: f32, /// Physical pixels per point once drawn (the zoom times the system's scale). pub drawn: f32, - /// A fixed size was chosen but the window is too small for it, so a smaller one is drawn. + /// The window is too small for the size chosen, so a smaller one is drawn. pub limited: bool, } -/// The plan for `choice` (`None`: Auto), the system's scale `native` and the window's size in -/// physical pixels `window_px`. An unknown, zero or not finite value reads as no information: a -/// system scale of 1 and a window of any size. -pub fn plan(choice: Option, native: Option, window_px: Option<(f32, f32)>) -> Plan { +/// The plan for `choice` (relative to the system's scale), the system's scale `native` and the +/// window's size in physical pixels `window_px`. An unknown, zero or not finite value reads as no +/// information: a size of 1, a system scale of 1 and a window of any size. +pub fn plan(choice: f64, native: Option, window_px: Option<(f32, f32)>) -> Plan { let native = native.filter(|n| n.is_finite() && *n > 0.0).unwrap_or(1.0); - let Some(want) = choice.map(|c| c as f32).filter(|c| c.is_finite() && *c > 0.0) else { - return Plan { zoom: 1.0, drawn: native, limited: false }; - }; - let fit = - window_px.filter(|(w, h)| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0).map(|(w, h)| (w / FIT_POINTS.0).min(h / FIT_POINTS.1)); - // Never below 1 (or the size asked, when that is smaller): a tiny window doesn't shrink the interface further. - let target = match fit { + let want = Some(choice as f32).filter(|c| c.is_finite() && *c > 0.0).unwrap_or(1.0); + // The largest zoom at which the window still holds the smallest layout. + let fit = window_px + .filter(|(w, h)| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0) + .map(|(w, h)| (w / FIT_POINTS.0).min(h / FIT_POINTS.1) / native); + // Never below 100 % (or the size asked, when that is smaller): a tiny window doesn't shrink the interface further. + let zoom = match fit { Some(fit) if want > fit => fit.max(want.min(1.0)), _ => want, - }; - let zoom = (target / native).clamp(ZOOM_RANGE.0, ZOOM_RANGE.1); - let drawn = zoom * native; - Plan { zoom, drawn, limited: drawn < want - 0.005 } + } + .clamp(ZOOM_RANGE.0, ZOOM_RANGE.1); + Plan { zoom, drawn: zoom * native, limited: zoom < want - 0.005 } } /// A size as the percentage Preferences shows (1.25 → 125). @@ -49,79 +47,70 @@ pub fn percent(scale: f32) -> u32 { (scale * 100.0).round().clamp(0.0, 100_000.0) as u32 } -/// The fixed sizes Preferences offers, after Auto. -pub const SIZES: [f64; 7] = [0.75, 1.0, 1.25, 1.5, 2.0, 2.5, 3.0]; +/// The sizes Preferences offers, relative to the system's scale (`pref.uiScale` takes up to 3). +pub const SIZES: [f64; 8] = [0.75, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0]; #[cfg(test)] mod tests { use super::*; - const BIG: Option<(f32, f32)> = Some((5000.0, 3000.0)); + const BIG: Option<(f32, f32)> = Some((20000.0, 12000.0)); #[test] - fn auto_keeps_the_systems_scale_on_every_screen() { - for native in [1.0, 1.25, 1.5, 1.75, 2.0, 3.0] { - let p = plan(None, Some(native), BIG); - assert_eq!((p.zoom, p.drawn, p.limited), (1.0, native, false), "{native}"); - } - } - - #[test] - fn a_fixed_size_is_the_same_size_whatever_the_systems_scale() { + fn a_size_is_relative_to_the_systems_scale_on_every_screen() { // Windows 100/125/150/175 %, Retina 2×, a bare X11 window manager's guessed 2×, a 3× screen. for native in [1.0, 1.25, 1.5, 1.75, 2.0, 3.0] { + let p = plan(1.0, Some(native), BIG); + assert_eq!((p.zoom, p.drawn, p.limited), (1.0, native, false), "100 % is the system's own size on {native}"); for size in SIZES { - let p = plan(Some(size), Some(native), BIG); - assert!((p.drawn - size as f32).abs() < 1e-5, "{size} on {native}: drawn {}", p.drawn); + let p = plan(size, Some(native), BIG); + assert!((p.zoom - size as f32).abs() < 1e-5, "{size} on {native}: zoom {}", p.zoom); + assert!((p.drawn - size as f32 * native).abs() < 1e-4, "{size} on {native}: drawn {}", p.drawn); assert!(!p.limited, "{size} on {native}"); } } - // 100 % on a 2× screen really is half the Auto size: the zoom is 0.5. - assert_eq!(plan(Some(1.0), Some(2.0), BIG).zoom, 0.5); } #[test] fn a_missing_or_unusable_scale_reads_as_1_and_never_gives_egui_a_bad_zoom() { for native in [None, Some(0.0), Some(-2.0), Some(f32::NAN), Some(f32::INFINITY)] { - for choice in [None, Some(1.5), Some(f64::NAN), Some(f64::INFINITY), Some(-1.0), Some(0.0), Some(1e300)] { + for choice in [1.0, 1.5, f64::NAN, f64::INFINITY, -1.0, 0.0, 1e300] { let p = plan(choice, native, Some((f32::NAN, 0.0))); assert!(p.zoom.is_finite() && (ZOOM_RANGE.0..=ZOOM_RANGE.1).contains(&p.zoom), "{choice:?} {native:?}: {p:?}"); assert!(p.drawn.is_finite() && p.drawn > 0.0, "{choice:?} {native:?}: {p:?}"); } } - // A choice that isn't a usable number is Auto. - assert_eq!(plan(Some(f64::NAN), Some(2.0), BIG), plan(None, Some(2.0), BIG)); + // A choice that isn't a usable number is the system's own size. + assert_eq!(plan(f64::NAN, Some(2.0), BIG), plan(1.0, Some(2.0), BIG)); } #[test] fn a_small_window_holds_a_big_size_back_without_going_below_100() { - // The minimum window on a Retina Mac: 800 × 500 points is 1600 × 1000 pixels. + // The minimum window on a Retina Mac: 800 × 500 points is 1600 × 1000 pixels, which holds 100 % only. let min = Some((1600.0, 1000.0)); - let p = plan(Some(3.0), Some(2.0), min); - assert_eq!((p.drawn, p.limited), (2.0, true)); - assert!(!plan(Some(2.0), Some(2.0), min).limited); - // The same window on a 1× screen: 800 × 500 pixels hold only 100 %. - let p = plan(Some(2.0), Some(1.0), Some((800.0, 500.0))); - assert_eq!((p.drawn, p.limited), (1.0, true)); + let p = plan(1.5, Some(2.0), min); + assert_eq!((p.zoom, p.drawn, p.limited), (1.0, 2.0, true)); + assert!(!plan(1.0, Some(2.0), min).limited); + // A larger window on a 1× screen: 1200 × 750 pixels hold 150 %. + let p = plan(2.0, Some(1.0), Some((1200.0, 750.0))); + assert_eq!((p.zoom, p.limited), (1.5, true)); // A window smaller than the minimum (a tiling window manager): never below 100 %, and never // below a smaller size the person chose. - assert_eq!(plan(Some(2.0), Some(1.0), Some((400.0, 250.0))).drawn, 1.0); - let p = plan(Some(0.75), Some(1.0), Some((400.0, 250.0))); - assert_eq!((p.drawn, p.limited), (0.75, false)); + assert_eq!(plan(2.0, Some(1.0), Some((400.0, 250.0))).zoom, 1.0); + let p = plan(0.75, Some(1.0), Some((400.0, 250.0))); + assert_eq!((p.zoom, p.limited), (0.75, false)); } #[test] fn the_limit_does_not_depend_on_the_zoom_so_it_cannot_flicker() { // The window's size in pixels is the same whatever zoom is drawn now, so the plan for it is too. let px = Some((1700.0, 1100.0)); - let a = plan(Some(2.5), Some(2.0), px); - let b = plan(Some(2.5), Some(2.0), px); - assert_eq!(a, b); + assert_eq!(plan(2.5, Some(2.0), px), plan(2.5, Some(2.0), px)); } #[test] fn percentages_round_like_the_menu_shows_them() { - assert_eq!([percent(0.75), percent(1.0), percent(1.25), percent(2.0), percent(1.7999)], [75, 100, 125, 200, 180]); + assert_eq!([percent(0.75), percent(0.9), percent(1.1), percent(1.25), percent(2.0), percent(1.7999)], [75, 90, 110, 125, 200, 180]); assert_eq!([percent(f32::NAN), percent(-1.0)], [0, 0]); } } diff --git a/crates/ui-egui/src/ui_scale_tests.rs b/crates/ui-egui/src/ui_scale_tests.rs index 189552e7e..018c963a1 100644 --- a/crates/ui-egui/src/ui_scale_tests.rs +++ b/crates/ui-egui/src/ui_scale_tests.rs @@ -1,5 +1,5 @@ -//! The interface size, driven through egui headless on screens of different system scales: Auto -//! leaves the system's scale alone, a fixed size comes out the same on every screen, and the +//! The interface size, driven through egui headless on screens of different system scales: 100 % +//! leaves the system's scale alone, another size is relative to it on every screen, and the //! Preferences drop-down tells a screen reader its name and value. //! //! The test window's size is fixed in points, so it grows with the zoom here; the limit for a small @@ -29,25 +29,25 @@ fn settle(u: &mut Ui, native: f32) { const SCREENS: [f32; 5] = [1.0, 1.25, 1.5, 2.0, 3.0]; #[test] -fn auto_leaves_the_systems_scale_and_the_menu_can_say_what_it_is() { +fn by_default_the_interface_is_the_systems_own_size() { for native in SCREENS { let mut u = Ui::new(Session::new()); settle(&mut u, native); assert_eq!(u.ctx.zoom_factor(), 1.0, "{native}"); assert_eq!(u.ctx.pixels_per_point(), native, "{native}"); - assert_eq!(u.app.ui.ui_native, native, "what Auto shows in the menu: {native}"); } } #[test] -fn a_fixed_size_draws_the_same_on_every_screen_and_auto_brings_the_systems_scale_back() { +fn a_size_is_relative_to_the_systems_scale_on_every_screen_and_auto_is_100() { for native in SCREENS { let mut u = Ui::new(Session::new()); - for size in [0.75_f64, 1.0, 1.5] { + for size in [0.75_f64, 0.9, 1.25, 1.5] { assert!(u.app.run("pref.uiScale", json!({"scale": size})).is_some()); settle(&mut u, native); let drawn = u.ctx.pixels_per_point(); - assert!((drawn - size as f32).abs() < 1e-4, "{size} on a {native}× screen drew at {drawn}"); + assert!((drawn - size as f32 * native).abs() < 1e-4, "{size} on a {native}× screen drew at {drawn}"); + assert!((u.ctx.zoom_factor() - size as f32).abs() < 1e-5, "{size} on {native}"); assert!(!u.app.ui.ui_scale.limited, "{size} on {native}"); } assert!(u.app.run("pref.uiScale", json!({"scale": "auto"})).is_some()); @@ -68,8 +68,8 @@ fn the_interface_size_menu_has_a_name_a_value_and_a_state_for_a_screen_reader() let n = nodes.iter().find(|n| n.role() == Role::ComboBox && n.label() == Some("Interface size")).expect("an Interface size menu"); (n.value().map(str::to_owned), n.is_expanded()) }; - // Auto says the size it draws at: a 2× screen is 200 %. - assert_eq!(combo(&mut u), (Some("Auto (200%)".to_owned()), Some(false))); + // 100 % is the system's own size, whatever it is (2× here). + assert_eq!(combo(&mut u), (Some("100%".to_owned()), Some(false))); u.app.run("pref.uiScale", json!({"scale": 1.25})); assert_eq!(combo(&mut u), (Some("125%".to_owned()), Some(false))); } @@ -100,17 +100,14 @@ fn every_window_size_system_scale_and_interface_size_lays_out_inside_the_window( (5120.0, 2880.0), (2560.0, 520.0), ]; - let mut choices: Vec> = vec![None]; - choices.extend(SIZES.iter().map(|s| Some(*s))); for native in [1.0_f32, 1.25, 1.5, 1.75, 2.0, 3.0] { for (w, h) in windows { let px = vec2(w * native, h * native); - for choice in &choices { - let case = format!("{w}x{h} pt at {native}x, {choice:?}"); + for want in SIZES { + let case = format!("{w}x{h} pt at {native}x, {want}"); let mut app = FigApp::new(Session::new(), Box::new(NoServices)); - let scale = choice.map_or(json!("auto"), |c| json!(c)); - assert!(app.run("pref.uiScale", json!({"scale": scale})).is_some(), "{case}"); + assert!(app.run("pref.uiScale", json!({"scale": want})).is_some(), "{case}"); let ctx = egui::Context::default(); let mut time = 0.0; let mut seen = Vec::new(); @@ -129,24 +126,18 @@ fn every_window_size_system_scale_and_interface_size_lays_out_inside_the_window( assert!(ppp.is_finite() && ppp > 0.0 && content.x.is_finite() && content.y.is_finite(), "{case}: {ppp} {content:?}"); let plan = app.ui.ui_scale; assert!((ppp - plan.drawn).abs() < 1e-3, "{case}: egui draws at {ppp}, the plan said {}", plan.drawn); - match choice { - None => assert!((ppp - native).abs() < 1e-3 && !plan.limited, "{case}: Auto must keep the system's scale"), - Some(want) => { - let want = *want as f32; - assert_eq!(plan.limited, ppp < want - 0.005, "{case}: limited flag"); - // Above 100 % the window holds the smallest layout; at or below it there is nothing more to give up. - if ppp > 1.001 { - assert!( - content.x >= FIT_POINTS.0 - 0.5 && content.y >= FIT_POINTS.1 - 0.5, - "{case}: window is {content:?} points at {ppp}" - ); - } - // It never draws bigger than asked, and only a window too small for it draws smaller. - assert!(ppp <= want + 1e-3, "{case}"); - if !plan.limited { - assert!((ppp - want).abs() < 1e-3, "{case}"); - } - } + // The size drawn, relative to the system's scale. + let zoom = ppp / native; + let want = want as f32; + assert_eq!(plan.limited, zoom < want - 0.005, "{case}: limited flag"); + // Above 100 % the window holds the smallest layout; at or below it there is nothing more to give up. + if zoom > 1.001 { + assert!(content.x >= FIT_POINTS.0 - 0.5 && content.y >= FIT_POINTS.1 - 0.5, "{case}: window is {content:?} points at {ppp}"); + } + // It never draws bigger than asked, and only a window too small for it draws smaller. + assert!(zoom <= want + 1e-3, "{case}"); + if !plan.limited { + assert!((zoom - want).abs() < 1e-3, "{case}"); } let panels = &app.inspect()["panels"]; let side = panels["left"].as_f64().unwrap_or(0.0) + panels["right"].as_f64().unwrap_or(0.0); @@ -191,7 +182,7 @@ fn the_interface_size_menu_works_from_the_keyboard() { u.app.ui.preferences = true; u.ctx.enable_accesskit(); settle(&mut u, 1.0); - assert_eq!(chosen(&u), json!("auto")); + assert_eq!(chosen(&u), json!(1.0)); // Escape closes the menu and nothing else: the size is unchanged and Preferences stays open. open(&mut u); @@ -199,14 +190,14 @@ fn the_interface_size_menu_works_from_the_keyboard() { u.frame(vec![]); assert!(!Popup::is_any_open(&u.ctx)); assert!(u.app.ui.preferences, "the first Escape closes the menu only"); - assert_eq!(chosen(&u), json!("auto")); + assert_eq!(chosen(&u), json!(1.0)); - // The arrows enter the menu on Auto (which says the size it draws at) and go down through the - // sizes (75, 100, 125); Enter picks the one that has the keyboard and closes the menu. + // The arrows enter the menu on its first size and go down through the sizes (75, 90, 100, 110, + // 125); Enter picks the one that has the keyboard and closes the menu. open(&mut u); press(&mut u, Key::ArrowDown); - assert_eq!(focused_name(&u.ctx), "Auto (100%)"); - for _ in 0..3 { + assert_eq!(focused_name(&u.ctx), "75%"); + for _ in 0..4 { press(&mut u, Key::ArrowDown); } assert_eq!(focused_name(&u.ctx), "125%"); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c54fbd7e4..08c08b085 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -173,8 +173,9 @@ instance, a dragged axis slider; see `text.preview` in [mcp.md](mcp.md)). pointer press, so the focus ring shows) and the name of the `control` with the keyboard (what a screen reader announces; empty when nothing has it). `reduceMotion` is true when the preference or the system's accessibility setting turns the interface's animations off. `uiScale` is the interface -size: the `choice` (`"auto"` or a fixed size, `pref.uiScale`), what is `drawn` (physical pixels per -point) and whether the window is too small for the choice (`limited`, so `drawn` is smaller). `live` is the live +size: the `choice` (relative to the system's display scale, `pref.uiScale`), the `zoom` drawn +(relative too), what that is in physical pixels per point (`drawn`) and whether the window is too +small for the choice (`limited`, so `zoom` is smaller). `live` is the live session of the window's active file as the window shows it (sessions are per file: another tab shows its own, or `status: "off"`): `session` (its id, null when the file isn't in one), `sessions` (how many the app has running), `status`, `role`, the number of `peers`, who you `follow`, the diff --git a/docs/mcp.md b/docs/mcp.md index 42a6222db..46c0fd0f8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -417,12 +417,11 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: nested layers), Escape or a click drops it. - `pref.reduceMotion {on?}`: reduced motion (the interface's own animations off; the system's accessibility setting turns it on too). Prototype transitions still play. -- `pref.uiScale {scale?}`: how large the whole interface is drawn. `scale` is `"auto"` (the system's - display scale, the default) or a fixed size from 0.75 to 3 where 1 is one screen pixel per point - whatever scale the system reports, so a 2× screen needs 2 to match Auto. 1.25 means 125 %, not 125 - (outside the range it stops at the nearest end). Omit `scale` to read it. The canvas zoom is separate. - A window too small for a fixed size draws a smaller one until it is larger; the choice is kept. - In the browser the interface always follows the browser's zoom. +- `pref.uiScale {scale?}`: how large the whole interface is drawn, relative to the system's display + scale: 1 (the default) is the system's own size on every screen, 1.25 means 125 %, not 125; from + 0.75 to 3 (outside the range it stops at the nearest end; `"auto"` is 1). Omit `scale` to read it. + The canvas zoom is separate. A window too small for a size above 100 % draws a smaller one until + it is larger; the choice is kept. In the browser the interface always follows the browser's zoom. - `pref.autoSave {on?}`: files that have a path save themselves a few seconds after they change (off by default; the desktop app's timer does it, so scripts still call `file.save`). - `pref.language {language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…}`: the app's interface language, applied live ("system": From ec9bb5f978af44ffdb57d303a0dd8c3254301690 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 20 Oct 2026 00:26:06 +0700 Subject: [PATCH 110/114] #812: a shorter Intensity label that fits the glass field --- crates/ui-egui/locales/pl.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index aa5b7533a..23f7dee8e 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -649,7 +649,7 @@ status = "draft — needs native review" "Instance swap (no other component)" = "Podmiana instancji (brak innego komponentu)" "Instances" = "Instancje" "Instant" = "Natychmiastowo" -"Intensity" = "Intensywność" +"Intensity" = "Siła" "Interaction" = "Interakcja" "Interactions" = "Interakcje" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Projektowanie interfejsów i prototypowanie. Darmowe, otwartoźródłowe, czysto implementowane narzędzie projektowe napisane od podstaw w Rust, część ArtCraft Crafting Apps." From 9ea757736f821178b196a893d84c26bce3cb5649 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 20 Oct 2026 00:45:55 +0700 Subject: [PATCH 111/114] #880: leave web font embedding off until the font pin carries Archivo --- .github/workflows/release.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6e1aa660f..75f7a5fae 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,7 +25,6 @@ jobs: app-name: figcraft app-title: FigCraft craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 - web-craft-fonts: true has-server: true version: ${{ inputs.version }} secrets: From 088693cfd351c8f4056bb782a03d4d8d49d23c58 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Tue, 20 Oct 2026 00:45:56 +0700 Subject: [PATCH 112/114] #880: canvas overlays take the green that stands out on the page; white text stays on the darker green --- crates/ui-egui/src/canvas.rs | 96 +++++++++++++++++------------- crates/ui-egui/src/frame_labels.rs | 2 +- crates/ui-egui/src/handles.rs | 19 +++--- crates/ui-egui/src/proto_canvas.rs | 27 ++++++--- crates/ui-egui/src/theme.rs | 36 +++++++++++ 5 files changed, 124 insertions(+), 56 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9e0c1d797..71dd9e8b7 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -12,7 +12,7 @@ use serde_json::json; use crate::FigApp; use crate::i18n::{tr, trf, trn}; use crate::render_worker::{Done, Job, Worker}; -use crate::theme::{ACCENT, COMPONENT, Tokens, doc_c32, regular, semibold}; +use crate::theme::{COMPONENT, Tokens, doc_c32, regular, semibold}; pub struct CanvasState { pub rect: Rect, @@ -233,6 +233,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { }) .unwrap_or(t.canvas); painter.rect_filled(rect, CornerRadius::ZERO, bg); + crate::theme::set_canvas_page(&ctx, bg); if let (Some(tex), Some(v), true) = (&app.canvas.texture, &app.canvas.tex_view, shown.is_some() && app.canvas.tex_of == shown) { // Re-project the last render onto the current view. let tl = app.to_screen(v.origin.0, v.origin.1); @@ -247,8 +248,8 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { if let (Some(start), Some(p)) = (app.canvas.zoom_drag, ctx.input(|i| i.pointer.hover_pos())) { let area = Rect::from_two_pos(start, p); if area.width() > 4.0 || area.height() > 4.0 { - painter.rect_filled(area, CornerRadius::ZERO, ACCENT.gamma_multiply(0.1)); - painter.rect_stroke(area, CornerRadius::ZERO, Stroke::new(1.0, ACCENT), StrokeKind::Inside); + painter.rect_filled(area, CornerRadius::ZERO, accent(&painter).gamma_multiply(0.1)); + painter.rect_stroke(area, CornerRadius::ZERO, Stroke::new(1.0, accent(&painter)), StrokeKind::Inside); } } crate::dev_mode::draw(app, &painter); @@ -809,11 +810,11 @@ fn swap_highlight(app: &FigApp, painter: &egui::Painter, doc: &Document, id: Nod } /// The outline colour for a layer: purple for components, sets and instances and every layer in -/// them, blue otherwise. -fn outline_colour(doc: &Document, id: NodeId) -> Color32 { +/// them, the page's overlay `green` otherwise. +fn outline_colour(doc: &Document, id: NodeId, green: Color32) -> Color32 { use figcraft_engine::components::{owning_component, owning_instance}; let set = matches!(doc.node(id).map(|n| &n.kind), Some(NodeKind::ComponentSet { .. })); - if set || owning_component(doc, id).is_some() || owning_instance(doc, id).is_some() { COMPONENT } else { ACCENT } + if set || owning_component(doc, id).is_some() || owning_instance(doc, id).is_some() { COMPONENT } else { green } } /// The outline path of shapes (ellipses, vectors) for hover/selection outlines. @@ -946,20 +947,20 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ && app.session.prefs.highlight_on_hover && app.session.tool.overlay.measure_box.is_none() { - draw_outline(app, painter, &doc, h, stroke(outline_colour(&doc, h), 1.5)); + draw_outline(app, painter, &doc, h, stroke(outline_colour(&doc, h, accent(painter)), 1.5)); } // Inside a boolean group (an operand selected or in vector edit mode): every operand's outline, // so the shapes making the live result stay visible while one of them changes. let operands = figcraft_engine::booleans::outlined_operands(&doc, &sel, ds.vector_edit.as_ref().map(|ve| ve.id)); for id in operands.iter().filter(|o| !sel.contains(o)) { - draw_outline(app, painter, &doc, *id, stroke(outline_colour(&doc, *id).gamma_multiply(0.6), 1.0)); + draw_outline(app, painter, &doc, *id, stroke(outline_colour(&doc, *id, accent(painter)).gamma_multiply(0.6), 1.0)); } // Mask outlines (a mask doesn't draw itself), frame outlines and slices, as the View menu shows. if view.mask_outlines || view.frame_outlines || view.show_slices { for id in doc.subtree(page) { let Some(n) = doc.node(id).filter(|n| n.visible) else { continue }; if view.mask_outlines && n.is_mask { - draw_outline(app, painter, &doc, id, stroke(ACCENT, 1.0)); + draw_outline(app, painter, &doc, id, stroke(accent(painter), 1.0)); } else if view.frame_outlines && n.kind.is_frame_like() { draw_outline(app, painter, &doc, id, stroke(t.text_secondary, 1.0)); } else if view.show_slices @@ -975,20 +976,20 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ if let Some(d) = ov.drop_target && let Some(q) = node_quad(app, &doc, d) { - quad(painter, q, stroke(if figcraft_engine::components::is_slot(&doc, d) { COMPONENT } else { ACCENT }, 1.0)); + quad(painter, q, stroke(if figcraft_engine::components::is_slot(&doc, d) { COMPONENT } else { accent(painter) }, 1.0)); } slot_badges(app, painter, &doc, &sel, ov.drop_target); if let Some(t) = ov.swap_target { swap_highlight(app, painter, &doc, t); } if let Some([x0, y0, x1, y1]) = ov.insert_line { - painter.line_segment([app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(ACCENT, 2.0)); + painter.line_segment([app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(accent(painter), 2.0)); } if let Some([x0, y0, x1, y1]) = ov.drop_cell { let r = Rect::from_two_pos(app.to_screen(x0, y0), app.to_screen(x1, y1)); - painter.rect_filled(r, CornerRadius::same(2), ACCENT.gamma_multiply(0.12)); + painter.rect_filled(r, CornerRadius::same(2), accent(painter).gamma_multiply(0.12)); let pts = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()]; - painter.add(Shape::dashed_line(&pts, stroke(ACCENT, 1.5), 4.0, 3.0)); + painter.add(Shape::dashed_line(&pts, stroke(accent(painter), 1.5), 4.0, 3.0)); } // Vector edit mode, with the transform box around several selected points. // The selection's size label, which measurement labels keep clear of. @@ -1011,13 +1012,13 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ width_overlay(app, painter, &doc, &ve, zoom); } else if let Some(c) = app.session.vector_edit_box() { let label = format!("{} × {}", fmt((c[1] - c[0]).hypot()), fmt((c[3] - c[0]).hypot())); - size_label = transform_box(app, painter, c.map(|p| app.to_screen(p.x, p.y)), ACCENT, true, 4, label); + size_label = transform_box(app, painter, c.map(|p| app.to_screen(p.x, p.y)), accent(painter), true, 4, label); } } // Selection. let editing = ds.text_edit.clone(); if !sel.is_empty() && editing.is_none() && ds.vector_edit.is_none() { - let col = if sel.iter().any(|i| outline_colour(&doc, *i) == COMPONENT) { COMPONENT } else { ACCENT }; + let col = if sel.iter().any(|i| outline_colour(&doc, *i, accent(painter)) == COMPONENT) { COMPONENT } else { accent(painter) }; for id in &sel { draw_outline(app, painter, &doc, *id, stroke(col, 1.0)); } @@ -1086,12 +1087,12 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ { let layout = &text.layout; if let Some(q) = node_quad(app, edoc, te.id) { - quad(painter, q, stroke(ACCENT, 1.0)); + quad(painter, q, stroke(accent(painter), 1.0)); } let (a, b) = (te.caret.min(te.anchor), te.caret.max(te.anchor)); if a != b && preedit.is_none() { for q in text.selection(app, a, b) { - painter.add(Shape::convex_polygon(q.to_vec(), ACCENT.gamma_multiply(0.3), Stroke::NONE)); + painter.add(Shape::convex_polygon(q.to_vec(), accent(painter).gamma_multiply(0.3), Stroke::NONE)); } } if let Some(p) = preedit { @@ -1122,8 +1123,8 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ // Marquee. if let Some([x0, y0, x1, y1]) = ov.marquee.or(app.ui.key_box) { let r = Rect::from_two_pos(app.to_screen(x0, y0), app.to_screen(x1, y1)); - painter.rect_filled(r, CornerRadius::ZERO, ACCENT.gamma_multiply(0.1)); - painter.rect_stroke(r, CornerRadius::ZERO, stroke(ACCENT, 1.0), StrokeKind::Inside); + painter.rect_filled(r, CornerRadius::ZERO, accent(painter).gamma_multiply(0.1)); + painter.rect_stroke(r, CornerRadius::ZERO, stroke(accent(painter), 1.0), StrokeKind::Inside); } // Pen path. if !ov.pen.is_empty() { @@ -1138,7 +1139,7 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ } for p in &pts { painter.circle_filled(*p, 3.5, Color32::WHITE); - painter.circle_stroke(*p, 3.5, stroke(ACCENT, 1.0)); + painter.circle_stroke(*p, 3.5, stroke(accent(painter), 1.0)); } if let Some([x0, y0, x1, y1]) = ov.rubber { let (a, b) = (app.to_screen(x0, y0), app.to_screen(x1, y1)); @@ -1150,11 +1151,11 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ [a, h, b, b], false, Color32::TRANSPARENT, - stroke(ACCENT, 1.0), + stroke(accent(painter), 1.0), )); } None => { - painter.line_segment([a, b], stroke(ACCENT, 1.0)); + painter.line_segment([a, b], stroke(accent(painter), 1.0)); } } } @@ -1165,7 +1166,7 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ let c = app.to_screen(x, y); let rs = (r * app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0)) as f32; painter.circle_stroke(c, rs, stroke(Color32::WHITE, 3.0)); - painter.circle_stroke(c, rs, stroke(ACCENT, 1.5)); + painter.circle_stroke(c, rs, stroke(accent(painter), 1.5)); } // Lasso (vector edit mode). if ov.lasso.len() > 1 { @@ -1173,7 +1174,7 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ if let Some(first) = pts.first().copied() { pts.push(first); } - painter.add(Shape::dashed_line(&pts, stroke(ACCENT, 1.0), 4.0, 3.0)); + painter.add(Shape::dashed_line(&pts, stroke(accent(painter), 1.0), 4.0, 3.0)); } // Guides (under the rulers). crate::guides::draw(app, painter); @@ -1207,7 +1208,7 @@ fn transform_box(app: &FigApp, painter: &egui::Painter, q: [Pos2; 4], col: Color fn pill(painter: &egui::Painter, at: Pos2, align: Align2, text: String, col: Color32) -> Rect { let galley = painter.layout_no_wrap(text, semibold(11.0), Color32::WHITE); let r = align.anchor_size(at, galley.size() + vec2(8.0, 4.0)); - painter.rect_filled(r, CornerRadius::same(3), col); + painter.rect_filled(r, CornerRadius::same(3), crate::theme::under_white(col)); painter.galley(r.min + vec2(4.0, 2.0), galley, Color32::WHITE); r } @@ -1264,7 +1265,7 @@ fn width_overlay(app: &FigApp, painter: &egui::Painter, doc: &Document, ve: &fig let sp = |p: &[f64; 2]| app.to_screen(p[0], p[1]); let draw = |h: &figcraft_engine::vectoredit::WidthHandle, ghost: bool| { let (c, a, b) = (sp(&h.at), sp(&h.sides[0]), sp(&h.sides[1])); - let col = if ghost { ACCENT.gamma_multiply(0.5) } else { ACCENT }; + let col = if ghost { accent(painter).gamma_multiply(0.5) } else { accent(painter) }; painter.line_segment([a, b], stroke(Color32::from_white_alpha(160), 3.0)); painter.line_segment([a, b], stroke(col, 1.0)); for (e, side) in [(a, WidthSide::Right), (b, WidthSide::Left)] { @@ -1276,7 +1277,7 @@ fn width_overlay(app: &FigApp, painter: &egui::Painter, doc: &Document, ve: &fig let on = !ghost && ve.width_point == Some(h.index); let hovered = !ghost && hover == Some(WHit::Point { index: h.index }); let r = if hovered { 5.0 } else { 4.0 }; - painter.circle_filled(c, r, if on { ACCENT } else { Color32::WHITE }); + painter.circle_filled(c, r, if on { accent(painter) } else { Color32::WHITE }); painter.circle_stroke(c, r, stroke(col, 1.0)); }; for h in width_handles(doc, ve, zoom) { @@ -1292,9 +1293,9 @@ fn width_overlay(app: &FigApp, painter: &egui::Painter, doc: &Document, ve: &fig /// A bezier handle from its anchor `v` to its end `e` (screen points): the line, and a dot of /// radius `r` filled with `fill` (vector edit mode and the Pen draw them alike). fn bezier_handle(painter: &egui::Painter, v: Pos2, e: Pos2, r: f32, fill: Color32) { - painter.line_segment([v, e], stroke(ACCENT, 1.0)); + painter.line_segment([v, e], stroke(accent(painter), 1.0)); painter.circle_filled(e, r, fill); - painter.circle_stroke(e, r, stroke(ACCENT, 1.0)); + painter.circle_stroke(e, r, stroke(accent(painter), 1.0)); } /// Vector edit mode: a layer's segments, handles and points (`points`) with its `hover`ed part, and @@ -1313,7 +1314,7 @@ fn vector_overlay( // Paint bucket target: hatched like a fill to come. if active && !ov.face.is_empty() { let polys: Vec> = ov.face.iter().map(|poly| poly.iter().map(sp).collect()).collect(); - hatched(painter, &polys, ACCENT); + hatched(painter, &polys, accent(painter)); } if active { region_hatch(app, painter); @@ -1331,7 +1332,7 @@ fn vector_overlay( } else { 1.5 }; - painter.add(egui::epaint::CubicBezierShape::from_points_stroke([a, b, c, d], false, Color32::TRANSPARENT, stroke(ACCENT, w))); + painter.add(egui::epaint::CubicBezierShape::from_points_stroke([a, b, c, d], false, Color32::TRANSPARENT, stroke(accent(painter), w))); } if !points { return; @@ -1339,7 +1340,7 @@ fn vector_overlay( for h in &o.handles { let hovered = hover == Some(VHit::Handle { seg: h.seg, start: h.start }); let on = o.selected_handles.contains(&(h.seg, h.start)); - bezier_handle(painter, sp(&h.vertex), sp(&h.handle), if hovered { 4.5 } else { 3.5 }, if on { ACCENT } else { Color32::WHITE }); + bezier_handle(painter, sp(&h.vertex), sp(&h.handle), if hovered { 4.5 } else { 3.5 }, if on { accent(painter) } else { Color32::WHITE }); } for (i, v) in o.vertices.iter().enumerate() { let p = sp(v); @@ -1347,14 +1348,14 @@ fn vector_overlay( let size = if hovered { 9.0 } else { 7.0 }; let r = Rect::from_center_size(p, vec2(size, size)); let on = o.selected_vertices.contains(&i); - painter.rect_filled(r, CornerRadius::same(5), if on { ACCENT } else { Color32::WHITE }); - painter.rect_stroke(r, CornerRadius::same(5), stroke(ACCENT, 1.0), StrokeKind::Inside); + painter.rect_filled(r, CornerRadius::same(5), if on { accent(painter) } else { Color32::WHITE }); + painter.rect_stroke(r, CornerRadius::same(5), stroke(accent(painter), 1.0), StrokeKind::Inside); if o.anchor == Some(i) { - painter.circle_stroke(p, 7.5, stroke(ACCENT, 1.0)); + painter.circle_stroke(p, 7.5, stroke(accent(painter), 1.0)); } } if let Some([x0, y0, x1, y1]) = ov.rubber.filter(|_| active) { - painter.add(Shape::dashed_line(&[app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(ACCENT, 1.0), 4.0, 3.0)); + painter.add(Shape::dashed_line(&[app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(accent(painter), 1.0), 4.0, 3.0)); } } @@ -1371,7 +1372,7 @@ fn region_hatch(app: &FigApp, painter: &egui::Painter) { let r = painter.clip_rect(); let clip = figcraft_geom::Rect::new(f64::from(r.min.x), f64::from(r.min.y), f64::from(r.max.x), f64::from(r.max.y)); for [a, b] in app.session.vector_region_hatch(view, Some(clip)) { - painter.line_segment([pos2(a.x as f32, a.y as f32), pos2(b.x as f32, b.y as f32)], stroke(ACCENT, REGION_HATCH_WIDTH)); + painter.line_segment([pos2(a.x as f32, a.y as f32), pos2(b.x as f32, b.y as f32)], stroke(accent(painter), REGION_HATCH_WIDTH)); } } @@ -1386,10 +1387,10 @@ fn builder_overlay(app: &FigApp, painter: &egui::Painter, t: &Tokens, arr: &figc for r in &arr.regions { for poly in screen(r) { painter.add(Shape::closed_line(poly.clone(), stroke(Color32::from_white_alpha(150), 2.5))); - painter.add(Shape::closed_line(poly, stroke(ACCENT, 1.0))); + painter.add(Shape::closed_line(poly, stroke(accent(painter), 1.0))); } } - let col = if ov.builder_remove { t.danger } else { ACCENT }; + let col = if ov.builder_remove { t.danger } else { accent(painter) }; for r in ov.builder_regions.iter().filter_map(|i| arr.regions.get(*i)) { hatched(painter, &screen(r), col); } @@ -1499,8 +1500,16 @@ fn rulers(app: &FigApp, painter: &egui::Painter, t: &Tokens, doc: &Document, sel // Highlight the selection's span. if let Some(b) = doc.bounds_of(sel) { let (a, bb) = (app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1)); - painter.rect_filled(Rect::from_min_max(pos2(a.x, r.min.y), pos2(bb.x, r.min.y + size)), CornerRadius::ZERO, ACCENT.gamma_multiply(0.15)); - painter.rect_filled(Rect::from_min_max(pos2(r.min.x, a.y), pos2(r.min.x + size, bb.y)), CornerRadius::ZERO, ACCENT.gamma_multiply(0.15)); + painter.rect_filled( + Rect::from_min_max(pos2(a.x, r.min.y), pos2(bb.x, r.min.y + size)), + CornerRadius::ZERO, + accent(painter).gamma_multiply(0.15), + ); + painter.rect_filled( + Rect::from_min_max(pos2(r.min.x, a.y), pos2(r.min.x + size, bb.y)), + CornerRadius::ZERO, + accent(painter).gamma_multiply(0.15), + ); } let mut x = (p0.x / step).floor() * step; while x <= p1.x { @@ -1528,3 +1537,8 @@ pub fn fmt(v: f64) -> String { #[cfg(test)] #[path = "zoom_preview_tests.rs"] mod zoom_preview_tests; + +/// The overlay green for the page this painter draws on ([`crate::theme::canvas_accent`]). +fn accent(p: &egui::Painter) -> Color32 { + crate::theme::canvas_accent(p.ctx()) +} diff --git a/crates/ui-egui/src/frame_labels.rs b/crates/ui-egui/src/frame_labels.rs index 79557141e..9e8b2e7ab 100644 --- a/crates/ui-egui/src/frame_labels.rs +++ b/crates/ui-egui/src/frame_labels.rs @@ -103,7 +103,7 @@ pub fn draw(app: &FigApp, painter: &Painter, t: &Tokens, doc: &Document, id: Nod let col = if is_comp { COMPONENT } else if selected || hovered { - ACCENT + crate::theme::canvas_accent(painter.ctx()) } else { t.text_secondary }; diff --git a/crates/ui-egui/src/handles.rs b/crates/ui-egui/src/handles.rs index ca38bee71..ec489bf82 100644 --- a/crates/ui-egui/src/handles.rs +++ b/crates/ui-egui/src/handles.rs @@ -11,7 +11,7 @@ use figcraft_geom::Point; use serde_json::json; use crate::colorpicker::PickerTarget; -use crate::theme::{ACCENT, semibold}; +use crate::theme::semibold; use crate::{FigApp, RightTab}; pub(crate) const PINK: Color32 = Color32::from_rgb(0xF2, 0x48, 0x8C); @@ -293,7 +293,12 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { painter.rect_filled(r, CornerRadius::same(2), crate::theme::doc_c32(painter.ctx(), s.color.with_alpha(1.0))); painter.rect_stroke(r, CornerRadius::same(2), Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside); if i == selected { - painter.rect_stroke(r.expand(2.0), CornerRadius::same(3), Stroke::new(1.5, ACCENT), StrokeKind::Outside); + painter.rect_stroke( + r.expand(2.0), + CornerRadius::same(3), + Stroke::new(1.5, crate::theme::canvas_accent(painter.ctx())), + StrokeKind::Outside, + ); } } for (i, p) in [a, b, c].iter().enumerate() { @@ -301,7 +306,7 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { continue; } painter.circle_filled(*p, 5.0, Color32::WHITE); - painter.circle_stroke(*p, 5.0, Stroke::new(1.5, ACCENT)); + painter.circle_stroke(*p, 5.0, Stroke::new(1.5, crate::theme::canvas_accent(painter.ctx()))); } return; } @@ -311,8 +316,8 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { painter.line_segment([a, b], Stroke::new(1.0, Color32::from_black_alpha(120))); // The start is hollow (least blur), the end filled (most blur by default). painter.circle_filled(a, 5.0, Color32::WHITE); - painter.circle_stroke(a, 5.0, Stroke::new(1.5, ACCENT)); - painter.circle_filled(b, 5.0, ACCENT); + painter.circle_stroke(a, 5.0, Stroke::new(1.5, crate::theme::canvas_accent(painter.ctx()))); + painter.circle_filled(b, 5.0, crate::theme::canvas_accent(painter.ctx())); painter.circle_stroke(b, 5.0, Stroke::new(1.5, Color32::WHITE)); return; } @@ -320,11 +325,11 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { let Some(c) = crop_corners(app, id, r) else { return }; let outline = vec![c[0], c[1], c[2], c[3], c[0]]; painter.add(Shape::line(outline.clone(), Stroke::new(2.0, Color32::WHITE))); - painter.add(Shape::line(outline, Stroke::new(1.0, ACCENT))); + painter.add(Shape::line(outline, Stroke::new(1.0, crate::theme::canvas_accent(painter.ctx())))); for p in c { let hr = Rect::from_center_size(p, vec2(8.0, 8.0)); painter.rect_filled(hr, CornerRadius::ZERO, Color32::WHITE); - painter.rect_stroke(hr, CornerRadius::ZERO, Stroke::new(1.0, ACCENT), StrokeKind::Inside); + painter.rect_stroke(hr, CornerRadius::ZERO, Stroke::new(1.0, crate::theme::canvas_accent(painter.ctx())), StrokeKind::Inside); } return; } diff --git a/crates/ui-egui/src/proto_canvas.rs b/crates/ui-egui/src/proto_canvas.rs index 1187944df..97acdeb1d 100644 --- a/crates/ui-egui/src/proto_canvas.rs +++ b/crates/ui-egui/src/proto_canvas.rs @@ -8,7 +8,6 @@ use egui::{Color32, CornerRadius, Id, Pos2, Rect, Shape, Stroke, StrokeKind, pos use figcraft_doc::{Document, NodeId}; use serde_json::{Value, json}; -use crate::theme::ACCENT; use crate::{FigApp, RightTab}; const HANDLE_R: f32 = 7.0; @@ -140,7 +139,7 @@ fn arrow(painter: &egui::Painter, pts: &[Pos2; 4], col: Color32) { } fn plus_handle(painter: &egui::Painter, c: Pos2, hot: bool) { - painter.circle_filled(c, HANDLE_R + if hot { 1.0 } else { 0.0 }, ACCENT); + painter.circle_filled(c, HANDLE_R + if hot { 1.0 } else { 0.0 }, crate::theme::canvas_accent(painter.ctx())); painter.circle_stroke(c, HANDLE_R + if hot { 1.0 } else { 0.0 }, Stroke::new(1.5, Color32::WHITE)); painter.line_segment([c - vec2(3.5, 0.0), c + vec2(3.5, 0.0)], Stroke::new(1.5, Color32::WHITE)); painter.line_segment([c - vec2(0.0, 3.5), c + vec2(0.0, 3.5)], Stroke::new(1.5, Color32::WHITE)); @@ -161,7 +160,11 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { let selected = sel.contains(&nd.source); let is_open = open == Some((nd.source, nd.index)); let near = dragging.is_none() && hover.is_some_and(|q| bezier_distance(&nd.pts, q) < 5.0); - let col = if selected || is_open || near { ACCENT } else { ACCENT.gamma_multiply(0.55) }; + let col = if selected || is_open || near { + crate::theme::canvas_accent(painter.ctx()) + } else { + crate::theme::canvas_accent(painter.ctx()).gamma_multiply(0.55) + }; let w = if is_open || near { 2.5 } else if selected { @@ -177,7 +180,12 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { .and_then(|it| figcraft_doc::proto::walk(&it.actions).find_map(|a| a.dest())) && let Some(r) = screen_rect(app, doc, dest) { - painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.0, ACCENT.gamma_multiply(0.7)), StrokeKind::Outside); + painter.rect_stroke( + r, + CornerRadius::ZERO, + Stroke::new(1.0, crate::theme::canvas_accent(painter.ctx()).gamma_multiply(0.7)), + StrokeKind::Outside, + ); } painter.add(egui::epaint::CubicBezierShape::from_points_stroke(nd.pts, false, Color32::TRANSPARENT, Stroke::new(w, col))); painter.circle_filled(nd.pts[0], 3.5, col); @@ -190,12 +198,17 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { && doc.top_level_frame(d.source) != Some(f) && let Some(r) = screen_rect(app, doc, f) { - painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(2.0, ACCENT), StrokeKind::Outside); + painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(2.0, crate::theme::canvas_accent(painter.ctx())), StrokeKind::Outside); } let dx = ((d.pos.x - d.from.x).abs() * 0.5).max(30.0) * if d.pos.x >= d.from.x { 1.0 } else { -1.0 }; let pts = [d.from, d.from + vec2(dx, 0.0), d.pos - vec2(dx, 0.0), d.pos]; - painter.add(egui::epaint::CubicBezierShape::from_points_stroke(pts, false, Color32::TRANSPARENT, Stroke::new(2.0, ACCENT))); - arrow(painter, &pts, ACCENT); + painter.add(egui::epaint::CubicBezierShape::from_points_stroke( + pts, + false, + Color32::TRANSPARENT, + Stroke::new(2.0, crate::theme::canvas_accent(painter.ctx())), + )); + arrow(painter, &pts, crate::theme::canvas_accent(painter.ctx())); plus_handle(painter, d.from, true); } None => { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 01b9e7f59..d81e1380b 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -52,6 +52,27 @@ pub const BRAND: Color32 = Color32::from_rgb(0x02, 0xDA, 0x96); pub const ACCENT: Color32 = Color32::from_rgb(0x00, 0x87, 0x5F); /// Ink on the bright green brand surface (white would not have enough contrast). pub const ACCENT_ON: Color32 = Color32::from_rgb(0x07, 0x35, 0x2A); +/// Id of the canvas overlays' green for this frame ([`set_canvas_page`]). +const CANVAS_ACCENT: &str = "figcraft-canvas-accent"; + +/// Remember the page colour the canvas shows. Its overlays (outlines, handles, arrows) take +/// whichever green contrasts more with it: the bright one on a dark page, the darker on a light one. +pub fn set_canvas_page(ctx: &Context, page: Color32) { + let c = |x: Color32| figcraft_color::Color::new(f32::from(x.r()) / 255.0, f32::from(x.g()) / 255.0, f32::from(x.b()) / 255.0, 1.0); + let pick = if c(BRAND).contrast(c(page)) > c(ACCENT).contrast(c(page)) { BRAND } else { ACCENT }; + ctx.data_mut(|d| d.insert_temp(egui::Id::new(CANVAS_ACCENT), pick)); +} + +/// The green for overlays drawn on the page ([`set_canvas_page`]); [`ACCENT`] before a page shows. +pub fn canvas_accent(ctx: &Context) -> Color32 { + ctx.data(|d| d.get_temp(egui::Id::new(CANVAS_ACCENT))).unwrap_or(ACCENT) +} + +/// A fill under white text: the darker green stands in for the bright one. +pub fn under_white(c: Color32) -> Color32 { + if c == BRAND { ACCENT } else { c } +} + pub const COMPONENT: Color32 = Color32::from_rgb(0x97, 0x47, 0xFF); /// Handoff status badges (white text on these, in both themes). pub const DEV_READY: Color32 = Color32::from_rgb(0x08, 0x7D, 0x4F); @@ -426,6 +447,21 @@ pub fn doc_c32(ctx: &Context, c: figcraft_color::Color) -> Color32 { mod tests { use super::*; + #[test] + fn canvas_overlays_take_the_green_that_stands_out_on_the_page() { + let ctx = Context::default(); + assert_eq!(canvas_accent(&ctx), ACCENT, "before any page shows"); + set_canvas_page(&ctx, Color32::from_rgb(0x1E, 0x1E, 0x1E)); + assert_eq!(canvas_accent(&ctx), BRAND, "the bright green on a dark page"); + set_canvas_page(&ctx, Color32::WHITE); + assert_eq!(canvas_accent(&ctx), ACCENT, "the darker green on a light page"); + set_canvas_page(&ctx, Color32::from_rgb(0xF5, 0xF5, 0xF5)); + assert_eq!(canvas_accent(&ctx), ACCENT); + // White text never sits on the bright green. + assert_eq!(under_white(BRAND), ACCENT); + assert_eq!(under_white(COMPONENT), COMPONENT); + } + #[test] fn ui_font_draws_every_mac_shortcut_glyph() { figcraft_text::scan_system_fonts(); From a8fa1f0fb46a2f30b405d09d08de866a8792cbbc Mon Sep 17 00:00:00 2001 From: Al Rifat Sabbir <150162343+alrifatsabbir@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:01:50 +0600 Subject: [PATCH 113/114] Add Al Rifat Sabbir to contributors list --- contributors/people.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 0a4eceb12..dfa2f02fc 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -37,6 +37,11 @@ real_name = "Justin John" [people.Losminek] +[people.alrifatsabbir] +real_name = "Al Rifat Sabbir" +display_name = "Al Rifat Sabbir" +sync_github_name = true + [people.RevDonz] [people.Nael-Nathanael] From 7d0ecd2294e0bd1a4afe301cc2d116ea582c40b9 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:17:28 +0000 Subject: [PATCH 114/114] Place files dropped together on the canvas side by side Fixes #886 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/home_tests.rs | 27 +++++++++++++++++++++++++++ crates/ui-egui/src/pending_open.rs | 22 ++++++++++++++++++++-- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs index 0606c2e5d..c74c35bff 100644 --- a/crates/ui-egui/src/home_tests.rs +++ b/crates/ui-egui/src/home_tests.rs @@ -699,6 +699,33 @@ fn dropping_a_native_file_on_canvas_keeps_its_receiving_document_after_tab_switc assert_eq!(u.app.session.docs.iter().map(|doc| doc.doc.pages.len()).collect::>(), [2, 1]); } +#[test] +fn dropping_several_files_on_canvas_places_them_side_by_side() { + let dir = Temp::new(); + let svg = r##""##; + let paths: Vec<_> = ["a.svg", "b.svg", "c.svg"].iter().map(|n| dir.path(n)).collect(); + for path in &paths { + std::fs::write(path, svg).unwrap(); + } + let mut u = Ui::new(Session::new()); + u.app.ui.home = false; + u.frame(vec![]); + let pos = u.app.canvas.rect.center(); + let files: Vec = paths.iter().map(|p| std::sync::Arc::new(Dropped(p.clone())) as egui::DroppedFileHandle).collect(); + u.frame_raw(egui::RawInput { events: vec![Event::PointerMoved(pos)], dropped_files: files, ..Default::default() }); + for _ in 0..4 { + u.frame(vec![]); + } + assert!(u.app.ui.pending_opens.is_empty()); + let doc = u.app.session.doc().unwrap(); + let page = u.app.session.page().unwrap(); + let rects: Vec<_> = doc.node(page).unwrap().children.iter().map(|c| doc.world_bounds(*c).unwrap()).collect(); + assert_eq!(rects.len(), 3); + for pair in rects.windows(2) { + assert!(pair[1].x0 > pair[0].x1, "left to right with a gap: {rects:?}"); + } +} + #[test] fn canvas_import_uses_queued_page_and_restores_the_page_selected_while_waiting() { let dir = Temp::new(); diff --git a/crates/ui-egui/src/pending_open.rs b/crates/ui-egui/src/pending_open.rs index 9575a1ea9..501367c0c 100644 --- a/crates/ui-egui/src/pending_open.rs +++ b/crates/ui-egui/src/pending_open.rs @@ -75,6 +75,9 @@ fn cancel(app: &mut FigApp, message: String) { app.show_error(message); } +/// Space between files dropped together on the canvas. +const IMPORT_GAP: f64 = 20.0; + /// Frames the card is drawn before the files open: egui lays a new card out invisibly on its first /// frame, so the second one is the first the window shows. const FRAMES_BEFORE_OPENING: u8 = 2; @@ -122,11 +125,26 @@ pub fn pending_opens(app: &mut FigApp, ctx: &egui::Context) { ctx.request_repaint(); if app.ui.pending_open_frames >= FRAMES_BEFORE_OPENING && !reading { app.ui.pending_open_frames = 0; - for pending in std::mem::take(&mut app.ui.pending_opens) { + // Files dropped together land side by side: each starts where the previous one ends. + let mut next_x: Option = None; + for mut pending in std::mem::take(&mut app.ui.pending_opens) { let command = pending.command; - if run_pending(app, pending).is_some() && command == "file.open" { + let canvas = command == "file.import" && pending.params.get("x").is_some(); + if canvas && let Some(x) = next_x { + pending.params["x"] = json!(x); + } + let page = pending.target.as_ref().map(|t| (t.uid, t.page)); + let result = run_pending(app, pending); + if result.is_some() && command == "file.open" { app.ui.home = false; } + if canvas { + let right = result.as_ref().and_then(|r| r.get("id")?.as_u64()).and_then(|id| { + let doc = &app.session.docs.iter().find(|d| Some(d.uid) == page.map(|p| p.0))?.doc; + doc.world_bounds(NodeId(id)).map(|b| b.x1) + }); + next_x = right.map(|r| r + IMPORT_GAP).or(next_x); + } } return; }