M0.9: sync reviewed GNOME controls fix with upstream

This commit is contained in:
Daniel Miguel
2026-10-16 00:13:38 +02:00
30 changed files with 607 additions and 97 deletions
+3 -1
View File
@@ -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-16 · **Change:** minor (Typography spacing icons support scrubbing (#373); 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-15 · **Change:** minor (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
@@ -315,6 +315,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. |
| 2026-10-15 | minor | Canvas retains a bounded margin while panning so newly exposed strips can reuse the previous render. Numbers unchanged. |
| 2026-10-15 | minor | Typography spacing icons scrub with the shared number-field gesture, modifier rates and one Undo step per drag (#373). Units and selected-character targeting are preserved. Numbers unchanged. |
| 2026-10-14 | minor | System theme on first run (M7): preferences without a theme default to System; the first frame reads the OS appearance directly, including when returning from a manual theme. Saved Light/Dark choices stay explicit; unavailable OS appearance falls back to Light. Numbers unchanged. |
| 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. |
+17
View File
@@ -781,6 +781,23 @@ impl Document {
self.paste_nodes(&src_nodes, &map, id, parent, index)
}
/// Deep-copy each of `ids`' subtrees to the end of `parent`, in order, returning the new root
/// ids. One id map covers every copy, so references between the copied subtrees (a navigation
/// from one copied frame to another) point at the copies.
pub fn duplicate_all_into(&mut self, ids: &[NodeId], parent: NodeId) -> DocResult<Vec<NodeId>> {
let mut map = BTreeMap::new();
let mut trees = Vec::with_capacity(ids.len());
for &id in ids {
let src_nodes: Vec<Node> = self.subtree(id).into_iter().filter_map(|i| self.node(i).cloned()).collect();
for n in &src_nodes {
let nid = self.alloc_id();
map.insert(n.id, nid);
}
trees.push((id, src_nodes));
}
trees.into_iter().map(|(id, src_nodes)| self.paste_nodes(&src_nodes, &map, id, parent, None)).collect()
}
/// Insert copies of `nodes` (a subtree rooted at `root`, from this or another document) with ids
/// remapped by `map`. Interactions, instance references and pattern sources pointing inside the
/// subtree are remapped too.
+61
View File
@@ -168,6 +168,27 @@ fn text_runs_restyle_and_replace() {
assert_eq!(t.characters, "");
}
/// A run that reaches past the text (a file can carry one: nothing clamps `runs` on load)
/// must not overflow the arithmetic that shifts the runs along, and must not leave the
/// text unwritable.
#[test]
fn text_runs_past_the_text_never_overflow() {
let style = || TextStyle { font_style: "Bold".into(), ..Default::default() };
for end in [usize::MAX, usize::MAX - 1, 1_000_000] {
let mut t = TextData::new("hi");
t.runs = vec![StyleRun { start: 0, end, style: style() }];
// Typing at the caret, and replacing a selection: both shift `end`.
t.replace_range(0, 0, "x");
assert_eq!(t.characters, "xhi", "end {end}");
t.replace_range(0, 3, "yo");
assert_eq!(t.characters, "yo", "end {end}");
// The text is still writable and the runs are back inside it.
t.replace_range(0, 0, "!");
assert_eq!(t.characters, "!yo", "end {end}");
assert!(t.runs.iter().all(|r| r.start < r.end && r.end <= t.char_count()), "end {end}: {:?}", t.runs);
}
}
#[test]
fn text_case() {
assert_eq!(TextCase::Title.apply("hello big world"), "Hello Big World");
@@ -382,6 +403,46 @@ fn custom_easings() {
assert_eq!(serde_json::from_value::<Easing>(serde_json::json!("easeInOut")).unwrap(), Easing::EaseInOut);
}
#[test]
fn text_style_updates_merge_equal_runs() {
let def = TextStyle { font_size: 14.0, ..Default::default() };
let mut t = TextData::new("abcd");
t.link_text_style(0, 2, &def, "S");
t.link_text_style(2, 4, &def, "S");
t.restyle(2, 4, |s| s.font_size = 24.0);
assert_eq!(t.runs.len(), 2);
t.update_text_style("S", &TextStyle { font_size: 24.0, ..def.clone() });
let spans = t.spans();
assert_eq!(spans.len(), 1);
assert_eq!((spans[0].0, spans[0].1), (0, 4));
assert_eq!(spans[0].2.font_size, 24.0);
assert_eq!(spans[0].2.style_id.as_deref(), Some("S"));
// Remapping two links to one id merges the runs.
let mut t = TextData::new("ab");
t.link_text_style(0, 1, &def, "A");
t.link_text_style(1, 2, &def, "B");
t.map_text_styles(|_| Some("C".into()));
assert_eq!(t.spans().len(), 1);
assert_eq!(t.style_at(1).style_id.as_deref(), Some("C"));
// Different values stay separate.
let mut t = TextData::new("ab");
t.link_text_style(0, 1, &def, "A");
t.link_text_style(1, 2, &TextStyle { font_size: 30.0, ..Default::default() }, "B");
t.map_text_styles(|_| Some("C".into()));
let spans = t.spans();
assert_eq!(spans.len(), 2);
assert_eq!((spans[0].2.font_size, spans[1].2.font_size), (14.0, 30.0));
// Unlinking a run equal to the base drops it.
let mut t = TextData::new("ab");
let base = t.style.clone();
t.link_text_style(0, 1, &base, "S");
t.map_text_styles(|_| None);
assert!(t.runs.is_empty());
}
#[test]
fn text_style_empty_range_links_and_unlinks_all() {
let def = TextStyle { font_style: "Bold".into(), ..Default::default() };
+12 -2
View File
@@ -562,7 +562,7 @@ impl TextData {
}
}
/// Drop empty/out-of-range runs and runs equal to the base style.
/// Drop empty/out-of-range runs and runs equal to the base style, and merge touching equal runs.
pub fn normalize(&mut self) {
let n = self.char_count();
let base = self.style.clone();
@@ -571,6 +571,14 @@ impl TextData {
r.end = r.end.min(n);
}
self.runs.sort_by_key(|r| r.start);
let mut merged: Vec<StyleRun> = Vec::with_capacity(self.runs.len());
for r in std::mem::take(&mut self.runs) {
match merged.last_mut() {
Some(prev) if prev.end == r.start && prev.style == r.style => prev.end = r.end,
_ => merged.push(r),
}
}
self.runs = merged;
}
/// Replace chars `start..end` with `text`, shifting runs (inserted text takes the style at `start`).
@@ -602,7 +610,9 @@ impl TextData {
if p <= start {
p
} else if p >= end {
p - removed + inserted
// Runs of a file that reach past the text (nothing clamps them on load)
// would otherwise overflow here; `normalize` below drops what is left.
p.saturating_sub(removed).saturating_add(inserted)
} else {
start + inserted
}
+1 -1
View File
@@ -216,7 +216,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
}
}
if let NodeKind::Text(td) = &mut kind {
td.characters = string(p, "text").or_else(|| string(p, "characters")).unwrap_or_default();
td.characters = super::props::normalize_line_endings(&string(p, "text").or_else(|| string(p, "characters")).unwrap_or_default());
if w > 0.0 {
td.auto_resize = TextAutoResize::Height;
}
+2 -1
View File
@@ -1003,7 +1003,8 @@ fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
if find.is_empty() {
return Err(bad("edit.findReplace", "`find` is empty"));
}
let replace = string(p, "replace");
// Line breaks stored as the editor stores them (CRLF, CR, U+2028 and U+2029 become LF).
let replace = string(p, "replace").map(|r| super::props::normalize_line_endings(&r));
let names = boolean(p, "layerNames").unwrap_or(false);
let page = s.page()?;
let doc = s.doc_mut()?;
+6 -4
View File
@@ -97,11 +97,13 @@ pub fn specs() -> Vec<CommandSpec> {
let doc = s.doc_mut()?;
let name = format!("Copy of {}", doc.get(src)?.name);
let np = doc.add_page(&name);
// Each layer and its copy (also those in sections), so flows can follow their start frames.
// One copy with one id map, so navigation between the copied frames points at the copies;
// then each layer and its copy (also those in sections), so flows can follow their start frames.
let children = doc.get(src)?.children.clone();
let copied = doc.duplicate_all_into(&children, np)?;
let mut copies = Vec::new();
for c in doc.get(src)?.children.clone() {
let copy = doc.duplicate_into(c, np, None)?;
copies.extend(doc.subtree(c).into_iter().zip(doc.subtree(copy)));
for (c, copy) in children.iter().zip(&copied) {
copies.extend(doc.subtree(*c).into_iter().zip(doc.subtree(*copy)));
}
// The copy keeps the page's colour, flows (pointing at the copied frames), prototype settings and guides.
if let NodeKind::Page { background, flows, prototype, guides } = doc.get(src)?.kind.clone()
+6 -1
View File
@@ -30,6 +30,11 @@ fontVariations / variationSettings ({axis: number}, e.g. {\"wght\": 550, \"wdth\
arcInner, booleanOperation (a boolean group's operation, or an operand's own; null: the group's), operandOperation (a layer in a boolean \
group: how it combines with the operands below it; null: the group's), constrainProportions, description, interactions, overflow, pluginData {k: v}";
/// CRLF, bare CR, U+2028 and U+2029 become LF, the only line ending text layout understands (editor insertion does the same).
pub(crate) fn normalize_line_endings(s: &str) -> String {
s.replace("\r\n", "\n").replace(['\r', '\u{2028}', '\u{2029}'], "\n")
}
/// camelCase object keys → snake_case (accept the inspect/camel forms for serde-backed values).
pub fn snake(v: &Value) -> Value {
match v {
@@ -599,7 +604,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
let n = doc.get_mut(id)?;
let t = text_mut(cmd, n)?;
let len = t.char_count();
t.replace_range(0, len, &s);
t.replace_range(0, len, &normalize_line_endings(&s));
n.overrides.insert("characters".into());
}
"fontFamily"
+2 -2
View File
@@ -472,11 +472,11 @@ fn apply_prop(doc: &Document, n: &mut Node, field: &str, val: &Value) {
}
}
"characters" => {
if let (NodeKind::Text(t), Some(sv)) = (&mut n.kind, val.as_str())
if let (NodeKind::Text(t), Some(sv)) = (&mut n.kind, val.as_str().map(crate::cmd::props::normalize_line_endings))
&& t.characters != sv
{
let len = t.char_count();
t.replace_range(0, len, sv);
t.replace_range(0, len, &sv);
}
}
"mainComponent" => {
+4
View File
@@ -1562,6 +1562,8 @@ mod library_range_styles_tests;
#[cfg(test)]
mod library_variables_tests;
#[cfg(test)]
mod line_endings_tests;
#[cfg(test)]
mod live_access_tests;
#[cfg(test)]
mod live_docs_tests;
@@ -1588,6 +1590,8 @@ mod open_issues_tests;
#[cfg(test)]
mod outline_options_tests;
#[cfg(test)]
mod page_copy_navigation_tests;
#[cfg(test)]
mod page_target_tests;
#[cfg(test)]
mod paint_reorder_tests;
+56
View File
@@ -0,0 +1,56 @@
//! Text set by a command stores every line ending as LF, like editor insertion does, so layout
//! sees two lines and no stray glyph for CRLF, bare CR, U+2028 or U+2029.
use serde_json::json;
use crate::Session;
use crate::components::tests::{card, chars, copy_of, ex, idv};
use crate::params::id_of;
const INPUTS: [&str; 5] = ["a\nb", "a\r\nb", "a\rb", "a\u{2028}b", "a\u{2029}b"];
#[test]
fn text_set_normalizes_line_endings() {
let mut s = Session::new();
let created = s.execute("text.create", &json!({"text": "", "x": 0, "y": 0})).unwrap();
let id = id_of(&created["id"]).unwrap();
for input in INPUTS {
s.execute("text.set", &json!({"id": id, "characters": input})).unwrap();
let t = s.doc().unwrap().node(id).unwrap().kind.text().unwrap().clone();
assert_eq!(t.characters, "a\nb", "text.set {input:?}");
}
}
#[test]
fn text_create_normalizes_line_endings() {
let mut s = Session::new();
for input in INPUTS {
let created = s.execute("text.create", &json!({"text": input, "x": 0, "y": 0})).unwrap();
let id = id_of(&created["id"]).unwrap();
let t = s.doc().unwrap().node(id).unwrap().kind.text().unwrap().clone();
assert_eq!(t.characters, "a\nb", "text.create {input:?}");
}
}
#[test]
fn find_replace_normalizes_line_endings() {
for input in INPUTS {
let mut s = Session::new();
let id = idv(&ex(&mut s, "text.create", json!({"text": "x", "x": 0, "y": 0})));
ex(&mut s, "edit.findReplace", json!({"find": "x", "replace": input}));
assert_eq!(chars(&s, id), "a\nb", "edit.findReplace {input:?}");
}
}
#[test]
fn text_property_values_normalize_line_endings() {
let mut s = Session::new();
let (c, _bg, title, _dot) = card(&mut s);
ex(&mut s, "component.addProperty", json!({"id": c, "name": "Title", "type": "text", "layer": title}));
let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0})));
let copy = copy_of(&s, i, title);
for input in INPUTS {
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": input}}));
assert_eq!(chars(&s, copy), "a\nb", "instance.setProperties {input:?}");
}
}
@@ -0,0 +1,48 @@
//! Duplicating a page copies its prototype connections: navigation between two frames that are both
//! on the page leads between the copies, while a destination outside the copied contents is kept.
use figcraft_doc::NodeId;
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn frame(s: &mut Session, x: f64) -> NodeId {
id_of(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 100, "height": 100}))["id"]).unwrap()
}
fn dest(s: &Session, id: NodeId) -> NodeId {
s.doc().unwrap().node(id).unwrap().interactions[0].actions[0].dest().unwrap()
}
#[test]
fn page_copy_navigation_uses_the_copied_destination() {
let mut s = Session::new();
let first = frame(&mut s, 0.0);
let second = frame(&mut s, 200.0);
ex(&mut s, "proto.interaction.add", json!({"id": first, "action": {"type": "navigate", "dest": second}}));
let copy = id_of(&ex(&mut s, "page.duplicate", json!({}))["id"]).unwrap();
let children = s.doc().unwrap().node(copy).unwrap().children.clone();
assert_eq!(children.len(), 2);
assert_eq!(dest(&s, children[0]), children[1]);
let visited = Default::default();
assert_eq!(crate::proto_run::navigate_target(s.doc().unwrap(), dest(&s, children[0]), &visited), Some(children[1]));
assert_eq!(dest(&s, first), second, "the original keeps its destination");
}
#[test]
fn frame_copy_keeps_an_outside_destination() {
let mut s = Session::new();
let first = frame(&mut s, 0.0);
let second = frame(&mut s, 200.0);
ex(&mut s, "proto.interaction.add", json!({"id": first, "action": {"type": "navigate", "dest": second}}));
ex(&mut s, "select.set", json!({"ids": [first]}));
ex(&mut s, "edit.duplicate", json!({}));
let copy = s.selection()[0];
assert_ne!(copy, first);
assert_eq!(dest(&s, copy), second);
}
+1 -6
View File
@@ -83,12 +83,7 @@ fn edit(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit)) -> Result
/// Type (or paste) `text` replacing the selection. Line endings are normalised and other control
/// characters dropped, so pasted plain text from any platform lands cleanly.
pub fn insert(s: &mut Session, text: &str) -> Result<()> {
let text: String = text
.replace("\r\n", "\n")
.replace(['\r', '\u{2028}', '\u{2029}'], "\n")
.chars()
.filter(|c| !c.is_control() || matches!(c, '\n' | '\t'))
.collect();
let text: String = crate::cmd::props::normalize_line_endings(text).chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).collect();
if text.is_empty() {
return Ok(());
}
+4
View File
@@ -1552,6 +1552,10 @@ impl<'d> Exporter<'d> {
.map(|(st, id)| {
let mut ef: Fields = vec![("styleID", u(id))];
style_fields(st, &mut ef);
// An empty link clears the text's own (a missing one would inherit it).
if st.link.is_none() && td.style.link.is_some() {
ef.push(("hyperlink", o(vec![("url", s(""))])));
}
if let Some(p) = st.fills.as_deref().or(td.style.fills.as_ref().map(|_| fills)) {
ef.push(("fillPaints", self.paints(p, true, &Bound::default())));
}
+2 -1
View File
@@ -1762,7 +1762,8 @@ fn text_style(v: &View, size: Option<f64>) -> Obj {
}
}
}
if let Some(u) = v.get("hyperlink").and_then(|h| h.get("url")).and_then(Kv::str).filter(|u| !u.is_empty()) {
// An empty URL is kept: on an override entry it clears the text's own link.
if let Some(u) = v.get("hyperlink").and_then(|h| h.get("url")).and_then(Kv::str) {
s.insert("hyperlink".into(), json!({"type": "URL", "url": u}));
}
s
+2
View File
@@ -130,6 +130,8 @@ fn type_style(cx: &mut Ctx, s: &Value, base: &TextStyle) -> TextStyle {
// and clipping a URL changed it into a different, working-looking one without a word
// (#459). The FIG importer already keeps it whole.
(Some("URL") | None, Some(url)) if !url.is_empty() => t.link = Some(url.to_string()),
// An empty URL clears the link the base style has.
(Some("URL") | None, Some(_)) => t.link = None,
_ => {}
}
}
@@ -0,0 +1,38 @@
//! A hyperlink cleared on part of a text layer stays cleared through FIG export and import.
use figcraft_doc::{Document, NodeKind, TextData};
use figcraft_format::fig;
fn links(doc: &Document, name: &str) -> Vec<Option<String>> {
let Some(NodeKind::Text(text)) = doc.nodes().find(|n| n.name == name).map(|n| &n.kind) else { return Vec::new() };
text.spans().into_iter().flat_map(|(start, end, style)| std::iter::repeat_n(style.link.clone(), end - start)).collect()
}
#[test]
fn cleared_range_hyperlink_round_trip() {
let mut document = Document::new("File");
let page = document.first_page().unwrap();
let url = "https://example.com/".to_string();
let mut text = TextData { characters: "AB".into(), ..Default::default() };
text.style.link = Some(url.clone());
text.restyle(1, 2, |style| style.link = None);
document.create(page, NodeKind::Text(text), "Cleared", 0.0, 0.0, 100.0, 30.0).unwrap();
let mut control = TextData { characters: "AB".into(), ..Default::default() };
control.restyle(1, 2, |style| style.link = Some(url.clone()));
document.create(page, NodeKind::Text(control), "Explicit", 0.0, 40.0, 100.0, 30.0).unwrap();
let expected = vec![Some(url.clone()), None];
assert_eq!(links(&document, "Cleared"), expected);
let native_bytes = figcraft_format::save_bytes(&document, None).unwrap();
let native = figcraft_format::load_bytes(&native_bytes).unwrap().0;
assert_eq!(links(&native, "Cleared"), expected);
let (bytes, export_warnings) = fig::export(&document, None).unwrap();
let (loaded, import_warnings) = fig::import(&bytes, "File").unwrap();
assert!(export_warnings.is_empty(), "{export_warnings:?}");
assert!(import_warnings.is_empty(), "{import_warnings:?}");
assert_eq!(links(&loaded, "Explicit"), vec![None, Some(url)]);
assert_eq!(links(&loaded, "Cleared"), expected);
}
+28
View File
@@ -237,6 +237,34 @@ fn fallback_fonts_dont_move_the_baseline() {
}
}
#[test]
fn hangul_comes_from_a_korean_font_when_one_is_installed() {
// The default (Latin) face has no Hangul. With a Korean system font (Apple SD Gothic Neo on
// macOS, Malgun Gothic on Windows, Noto Sans CJK KR on Linux) the fallback draws it instead
// of the missing-glyph box, at the run's weight. Skips on a machine without one.
let Some(f) = face() else { return };
if f.has_char('\u{D55C}') {
return;
}
let korean = ["Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans CJK KR", "Noto Sans KR"];
if !korean.iter().any(|k| find_face(k, "Regular").is_some_and(|k| k.has_char('\u{D55C}'))) {
return;
}
for style in ["Regular", "Bold"] {
let mut t = text("Ab \u{D55C}\u{AD6D}\u{C5B4}", TextAutoResize::WidthAndHeight);
t.style.font_style = style.into();
let l = layout(&t, None, None);
let hangul: Vec<_> = l.glyphs.iter().filter(|g| g.char_start >= 3).collect();
assert!(!hangul.is_empty());
for g in &hangul {
assert!(g.face.has_char('\u{D55C}'), "{style}: drawn by {} {}, which has no Hangul", g.face.family, g.face.style);
}
let used = &hangul[0].face;
let nearest = find_face(&used.family, style).map_or(used.weight, |n| n.weight);
assert_eq!(used.weight, nearest, "{style}: the fallback face is the one nearest the run's style");
}
}
#[test]
fn break_opportunities() {
// (before, after, may break between, derivation)
+35 -17
View File
@@ -405,6 +405,28 @@ fn names(f: &skrifa::FontRef) -> (Option<String>, Option<String>) {
(family, style)
}
/// Families tried, in order, for characters a text's own font lacks.
const FALLBACK_FAMILIES: [&str; 18] = [
"Inter",
"Helvetica Neue",
"Arial",
"Segoe UI",
"DejaVu Sans",
"Noto Sans",
"BIZ UDPGothic",
"Hiragino Sans",
"PingFang SC",
"Noto Sans CJK SC",
"Microsoft YaHei",
"Apple SD Gothic Neo",
"Malgun Gothic",
"Noto Sans CJK KR",
"Noto Sans KR",
"Noto Sans Arabic",
"Apple Symbols",
"Segoe UI Symbol",
];
/// A system font file entry not yet loaded.
#[derive(Clone, Debug)]
struct Pending {
@@ -614,24 +636,15 @@ impl FontDb {
/// Faces to try for characters the primary face lacks.
pub fn fallbacks(&mut self) -> Vec<Arc<Face>> {
self.fallbacks_for("Regular")
}
/// [`fallbacks`](Self::fallbacks), each family's face nearest `style`, so the characters a
/// bold run takes from a fallback font (Hangul in Inter Bold) come out bold too.
pub fn fallbacks_for(&mut self, style: &str) -> Vec<Arc<Face>> {
let mut out = Vec::new();
for fam in [
"Inter",
"Helvetica Neue",
"Arial",
"Segoe UI",
"DejaVu Sans",
"Noto Sans",
"BIZ UDPGothic",
"Hiragino Sans",
"PingFang SC",
"Noto Sans CJK SC",
"Microsoft YaHei",
"Noto Sans Arabic",
"Apple Symbols",
"Segoe UI Symbol",
] {
if let Some(f) = self.find(fam, "Regular") {
for fam in FALLBACK_FAMILIES {
if let Some(f) = self.find(fam, style) {
out.push(f);
}
}
@@ -734,6 +747,11 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap<String, (Str
scan_dir(&path, depth + 1, out);
continue;
}
// Font-like names can also belong to pipes or devices; opening a pipe can block.
// `meta` follows symlinks, so links to regular font files remain supported.
if !meta.is_file() {
continue;
}
let ext = path.extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).unwrap_or_default();
if !matches!(ext.as_str(), "ttf" | "otf" | "ttc" | "otc") {
continue;
+87 -1
View File
@@ -56,7 +56,9 @@ fn the_scan_reads_names_like_a_whole_file_read() {
let p = e.path();
if p.is_dir() {
stack.push(p);
} else if p.extension().is_some_and(|x| ["ttf", "otf", "ttc", "otc"].contains(&x.to_ascii_lowercase().to_str().unwrap_or(""))) {
} else if p.is_file()
&& p.extension().is_some_and(|x| ["ttf", "otf", "ttc", "otc"].contains(&x.to_ascii_lowercase().to_str().unwrap_or("")))
{
files.push(p);
}
}
@@ -68,6 +70,90 @@ fn the_scan_reads_names_like_a_whole_file_read() {
}
}
#[cfg(unix)]
#[test]
fn the_scan_skips_named_pipes() {
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
const INPUT: &str = "FIGCRAFT_TEST_PIPE_FONT_DIR";
if let Some(dir) = std::env::var_os(INPUT) {
let mut pending = BTreeMap::new();
scan_dir(std::path::Path::new(&dir), 0, &mut pending);
assert!(pending.is_empty());
std::fs::write(std::path::Path::new(&dir).join("complete"), b"scanned").unwrap();
return;
}
struct TempDir(std::path::PathBuf);
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
let path = std::env::temp_dir().join(format!("figcraft-font-pipe-{}", std::process::id()));
std::fs::create_dir(&path).unwrap();
let dir = TempDir(path);
// POSIX mkfifo is only a test fixture helper, never a runtime font dependency.
let status = match Command::new("mkfifo").arg(dir.0.join("pipe.ttf")).status() {
Ok(status) => status,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
eprintln!("skipping named-pipe fixture: POSIX mkfifo is unavailable");
return;
}
Err(error) => panic!("cannot create named-pipe fixture: {error}"),
};
assert!(status.success(), "mkfifo failed");
std::os::unix::fs::symlink("pipe.ttf", dir.0.join("linked.otf")).unwrap();
std::os::unix::fs::symlink("missing.ttf", dir.0.join("broken.ttf")).unwrap();
// A regression must fail without leaving a blocked test thread behind. Run only this
// test in a child, then kill and reap it if opening the pipe blocks the scanner.
struct Child(std::process::Child);
impl Drop for Child {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
let module = module_path!().split_once("::").unwrap().1;
let mut child = Child(
Command::new(std::env::current_exe().unwrap())
.args(["--exact", &format!("{module}::the_scan_skips_named_pipes"), "--nocapture"])
.env(INPUT, &dir.0)
.stdout(Stdio::null())
.spawn()
.unwrap(),
);
let deadline = Instant::now() + Duration::from_secs(10);
loop {
if let Some(status) = child.0.try_wait().unwrap() {
assert!(status.success(), "font-scan child failed: {status}");
break;
}
assert!(Instant::now() < deadline, "font scan blocked on a named pipe");
// std::process has no portable timed wait; a bounded poll keeps the regression finite.
std::thread::sleep(Duration::from_millis(10));
}
// An exact-filter typo otherwise makes libtest report success after running zero tests.
assert_eq!(std::fs::read(dir.0.join("complete")).unwrap(), b"scanned");
}
#[test]
fn fallback_faces_follow_the_runs_style() {
// A bold run's missing characters come from each fallback family's face nearest Bold, not its
// Regular, so Hangul or kana in a bold Latin run come out bold too. Same families, same order.
scan_system_fonts();
let mut w = db().write().unwrap();
let regular = w.fallbacks();
let bold = w.fallbacks_for("Bold");
let families = |v: &[Arc<Face>]| v.iter().map(|f| f.family.clone()).collect::<Vec<_>>();
assert_eq!(families(&regular), families(&bold));
for (r, b) in regular.iter().zip(&bold) {
assert!(b.weight >= r.weight, "{}: {} is lighter than {}", b.family, b.style, r.style);
}
}
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn user_font_dirs_follow_xdg_data_home() {
+3
View File
@@ -325,8 +325,10 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
let mut dbw = db().write().unwrap_or_else(std::sync::PoisonError::into_inner);
let mut fallbacks = dbw.fallbacks();
let mut faces: Vec<Option<Arc<Face>>> = Vec::with_capacity(styles.len());
let mut style_fallbacks: Vec<Vec<Arc<Face>>> = Vec::with_capacity(styles.len());
for s in &styles {
faces.push(dbw.find(&s.font_family, &s.font_style).or_else(|| dbw.default_face()));
style_fallbacks.push(dbw.fallbacks_for(&s.font_style));
}
// 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.
@@ -371,6 +373,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
if c == '\n' || primary.has_char(c) {
return primary.clone();
}
let fallbacks = style_fallbacks.get(st_idx).unwrap_or(&fallbacks);
fallbacks.iter().find(|f| f.has_char(c)).cloned().unwrap_or_else(|| primary.clone())
};
let first = chars.get(seg_start).copied().unwrap_or(' ');
+38 -38
View File
@@ -7,7 +7,7 @@ status = "draft — needs native review"
[ui]
"% of all commits" = "% všech commitů"
"(no longer published)" = "(již nepublikováno)"
"({n} warnings: some entries were skipped)" = { one = "({n} varování: některé položky byly přeskočeny)", other = "(Varování: {n}; některé položky byly přeskočeny)" }
"({n} warnings: some entries were skipped)" = { one = "({n} varování: některé položky byly přeskočeny)", few = "({n} varování: některé položky byly přeskočeny)", other = "({n} varování: některé položky byly přeskočeny)" }
"+ Create variable" = "+ Vytvořit proměnnou"
".fig (experimental)" = ".fig (experimentální)"
"1…9, 0" = "1…9, 0"
@@ -455,11 +455,11 @@ status = "draft — needs native review"
"Export settings" = "Nastavení exportu"
"Export variables" = "Exportovat proměnné"
"Export {name}" = "Exportovat {name}"
"Export {n} layers" = { one = "Exportovat {n} vrstvu", other = "Exportovat vrstvy: {n}" }
"Export {n} layers" = { one = "Exportovat {n} vrstvu", few = "Exportovat {n} vrstvy", other = "Exportovat {n} vrstev" }
"Exported {file}" = "Exportován soubor {file}"
"Exported {name}" = "Exportováno: {name}"
"Exported {name}; {n} kinds of content were left out" = { one = "Exportováno: {name}; vynechán {n} druh obsahu", other = "Exportováno: {name}; vynechané druhy obsahu: {n}" }
"Exported {n} files" = { one = "Exportován {n} soubor", other = "Exportované soubory: {n}" }
"Exported {name}; {n} kinds of content were left out" = { one = "Exportováno: {name}; vynechán {n} druh obsahu", few = "Exportováno: {name}; vynechány {n} druhy obsahu", other = "Exportováno: {name}; vynecháno {n} druhů obsahu" }
"Exported {n} files" = { one = "Exportován {n} soubor", few = "Exportovány {n} soubory", other = "Exportováno {n} souborů" }
"Export…" = "Exportovat…"
"Expose properties to instances" = "Zpřístupnit vlastnosti instancím"
"Exposure" = "Expozice"
@@ -477,7 +477,7 @@ status = "draft — needs native review"
"File not found" = "Soubor nenalezen"
"File published" = "Soubor publikován"
"Files aren't end-to-end encrypted: the server's operator can read them." = "Soubory nemají koncové šifrování: provozovatel serveru je může číst."
"Files nobody opens for {n} days are deleted." = { one = "Soubory, které nikdo neotevře déle než {n} den, se smažou.", other = "Soubory, které nikdo neotevře déle než {n} d, se smažou." }
"Files nobody opens for {n} days are deleted." = { one = "Soubory, které nikdo neotevře déle než {n} den, se smažou.", few = "Soubory, které nikdo neotevře déle než {n} dny, se smažou.", other = "Soubory, které nikdo neotevře déle než {n} dní, se smažou." }
"Fill" = "Výplň"
"Fill (fr)" = "Vyplnit (fr)"
"Fill container" = "Vyplnit kontejner"
@@ -601,7 +601,7 @@ status = "draft — needs native review"
"Import plugin from manifest…" = "Importovat plugin z manifestu…"
"Import variables" = "Importovat proměnné"
"Imported {name}" = "Importováno: {name}"
"Imported {n} new variables, updated {updated}" = { one = "Importována {n} nová proměnná, aktualizováno: {updated}", other = "Nově importované proměnné: {n}, aktualizované: {updated}" }
"Imported {n} new variables, updated {updated}" = { one = "Importována {n} nová proměnná, aktualizováno: {updated}", few = "Importovány {n} nové proměnné, aktualizováno: {updated}", other = "Importováno {n} nových proměnných, aktualizováno: {updated}" }
"Import…" = "Importovat…"
"In an instance, this comes from the main component" = "V instanci se toto přebírá z hlavní komponenty"
"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)." = "V prohlížeči se můžete připojit k relacím hostovaným desktopovou aplikací a k souborům na serveru FigCraft. Stránka otevřená přes https může přistupovat k jiným počítačům pouze přes odkazy wss:// (server za TLS)."
@@ -1013,7 +1013,7 @@ status = "draft — needs native review"
"Recent" = "Nedávné"
"Recent files" = "Nedávné soubory"
"Recents" = "Nedávné"
"Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them" = { one = "Po neočekávaném ukončení aplikace obnoven {n} neuložený soubor: uložte ho pro zachování", other = "Po neočekávaném ukončení aplikace obnoveny neuložené soubory (počet: {n}): uložte je pro zachování" }
"Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them" = { one = "Po neočekávaném ukončení aplikace obnoven {n} neuložený soubor: uložte ho pro zachování", few = "Po neočekávaném ukončení aplikace obnoveny {n} neuložené soubory: uložte je pro zachování", other = "Po neočekávaném ukončení aplikace obnoveno {n} neuložených souborů: uložte je pro zachování" }
"Rectangle" = "Obdélník"
"Rectangles" = "Obdélníky"
"Red" = "Červená"
@@ -1287,8 +1287,8 @@ status = "draft — needs native review"
"Swap to {name}" = "Vyměnit za {name}"
"Swap width and height" = "Prohodit šířku a výšku"
"Swapped instance" = "Instance vyměněna"
"Swapped {n} assets" = { one = "Vyměněn {n} zdroj", other = "Vyměněné zdroje: {n}" }
"Swapped {n} assets; {missed} had no match and still use the old library" = { one = "Vyměněn {n} zdroj; bez shody: {missed}, nadále používají původní knihovnu", other = "Vyměněné zdroje: {n}; bez shody: {missed}, nadále používají původní knihovnu" }
"Swapped {n} assets" = { one = "Vyměněn {n} zdroj", few = "Vyměněny {n} zdroje", other = "Vyměněno {n} zdrojů" }
"Swapped {n} assets; {missed} had no match and still use the old library" = { one = "Vyměněn {n} zdroj; bez shody: {missed}, nadále používají původní knihovnu", few = "Vyměněny {n} zdroje; bez shody: {missed}, nadále používají původní knihovnu", other = "Vyměněno {n} zdrojů; bez shody: {missed}, nadále používají původní knihovnu" }
"Sweep" = "Rozsah"
"System" = "Systém"
"Tab / Shift+Tab" = "Tab / Shift+Tab"
@@ -1479,7 +1479,7 @@ status = "draft — needs native review"
"Zoom to selection" = "Přiblížit na výběr"
"Zoom tool: click to zoom in, drag an area" = "Nástroj přiblížení: kliknutím přiblížíte, tažením vyberete oblast"
"Zoom tool: zoom out" = "Nástroj přiblížení: oddálit"
"and {n} more" = { one = "a {n} další", other = "a další (počet: {n})" }
"and {n} more" = { one = "a {n} další", few = "a {n} další", other = "a {n} dalších" }
"bleed marks" = "značky spadávky"
"by {author}" = "autor: {author}"
"call any command its access allows" = "volat libovolný příkaz povolený jeho oprávněními"
@@ -1531,35 +1531,35 @@ status = "draft — needs native review"
"{name}, in a live session" = "{name}, v živé relaci"
"{name}, online, open only to you" = "{name}, online, přístupný pouze vám"
"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, vybráno"
"{n} added" = { one = "Přidáno: {n}", other = "Přidáno: {n}" }
"{n} autosave versions" = { one = "{n} automaticky uložená verze", other = "Automaticky uložené verze: {n}" }
"{n} changed" = { one = "Změněno: {n}", other = "Změněno: {n}" }
"{n} columns" = { one = "{n} sloupec", other = "Sloupce: {n}" }
"{n} components" = { one = "{n} komponenta", other = "Komponenty: {n}" }
"{n} components and styles have newer versions" = { one = "{n} komponenta nebo styl má novější verzi", other = "Komponenty a styly s novější verzí: {n}" }
"{n} conflicts to resolve" = { one = "{n} konflikt k vyřešení", other = "Konflikty k vyřešení: {n}" }
"{n} days ago" = { one = "Před {n} dnem", other = "Před {n} dny" }
"{n} files" = { one = "{n} soubor", other = "Soubory: {n}" }
"{n} hours ago" = { one = "Před {n} hodinou", other = "Před {n} hodinami" }
"{n} instances" = { one = "{n} instance", other = "Instance: {n}" }
"{n} layers" = { one = "{n} vrstva", other = "Vrstvy: {n}" }
"{n} layers as one file" = { one = "{n} vrstva jako jeden soubor", other = "Vrstvy jako jeden soubor: {n}" }
"{n} layers selected" = { one = "Vybrána {n} vrstva", other = "Vybrané vrstvy: {n}" }
"{n} layers · {parts}" = { one = "{n} vrstva · {parts}", other = "Vrstvy: {n} · {parts}" }
"{n} matches" = { one = "{n} shoda", other = "Shody: {n}" }
"{n} minutes ago" = { one = "Před {n} minutou", other = "Před {n} minutami" }
"{n} months ago" = { one = "Před {n} měsícem", other = "Před {n} měsíci" }
"{n} added" = { one = "Přidáno: {n}", few = "Přidáno: {n}", other = "Přidáno: {n}" }
"{n} autosave versions" = { one = "{n} automaticky uložená verze", few = "{n} automaticky uložené verze", other = "{n} automaticky uložených verzí" }
"{n} changed" = { one = "Změněno: {n}", few = "Změněno: {n}", other = "Změněno: {n}" }
"{n} columns" = { one = "{n} sloupec", few = "{n} sloupce", other = "{n} sloupců" }
"{n} components" = { one = "{n} komponenta", few = "{n} komponenty", other = "{n} komponent" }
"{n} components and styles have newer versions" = { one = "{n} komponenta nebo styl má novější verzi", few = "{n} komponenty nebo styly mají novější verzi", other = "{n} komponent nebo stylů má novější verzi" }
"{n} conflicts to resolve" = { one = "{n} konflikt k vyřešení", few = "{n} konflikty k vyřešení", other = "{n} konfliktů k vyřešení" }
"{n} days ago" = { one = "Před {n} dnem", few = "Před {n} dny", other = "Před {n} dny" }
"{n} files" = { one = "{n} soubor", few = "{n} soubory", other = "{n} souborů" }
"{n} hours ago" = { one = "Před {n} hodinou", few = "Před {n} hodinami", other = "Před {n} hodinami" }
"{n} instances" = { one = "{n} instance", few = "{n} instance", other = "{n} instancí" }
"{n} layers" = { one = "{n} vrstva", few = "{n} vrstvy", other = "{n} vrstev" }
"{n} layers as one file" = { one = "{n} vrstva jako jeden soubor", few = "{n} vrstvy jako jeden soubor", other = "{n} vrstev jako jeden soubor" }
"{n} layers selected" = { one = "Vybrána {n} vrstva", few = "Vybrány {n} vrstvy", other = "Vybráno {n} vrstev" }
"{n} layers · {parts}" = { one = "{n} vrstva · {parts}", few = "{n} vrstvy · {parts}", other = "{n} vrstev · {parts}" }
"{n} matches" = { one = "{n} shoda", few = "{n} shody", other = "{n} shod" }
"{n} minutes ago" = { one = "Před {n} minutou", few = "Před {n} minutami", other = "Před {n} minutami" }
"{n} months ago" = { one = "Před {n} měsícem", few = "Před {n} měsíci", other = "Před {n} měsíci" }
"{n} of {total}" = "{n} z {total}"
"{n} people joined" = { one = "Připojena {n} osoba", other = "Připojené osoby: {n}" }
"{n} preferred components" = { one = "{n} preferovaná komponenta", other = "Preferované komponenty: {n}" }
"{n} ready" = { one = "Připraveno: {n}", other = "Připraveno: {n}" }
"{n} removed" = { one = "Odebráno: {n}", other = "Odebráno: {n}" }
"{n} replies" = { one = "{n} odpověď", other = "Odpovědi: {n}" }
"{n} rows" = { one = "{n} řádek", other = "Řádky: {n}" }
"{n} styles" = { one = "{n} styl", other = "Styly: {n}" }
"{n} variables" = { one = "{n} proměnná", other = "Proměnné: {n}" }
"{n} weeks ago" = { one = "Před {n} týdnem", other = "Před {n} týdny" }
"{n} years ago" = { one = "Před {n} rokem", other = "Před {n} lety" }
"{n} people joined" = { one = "Připojila se {n} osoba", few = "Připojily se {n} osoby", other = "Připojilo se {n} osob" }
"{n} preferred components" = { one = "{n} preferovaná komponenta", few = "{n} preferované komponenty", other = "{n} preferovaných komponent" }
"{n} ready" = { one = "Připraveno: {n}", few = "Připraveno: {n}", other = "Připraveno: {n}" }
"{n} removed" = { one = "Odebráno: {n}", few = "Odebráno: {n}", other = "Odebráno: {n}" }
"{n} replies" = { one = "{n} odpověď", few = "{n} odpovědi", other = "{n} odpovědí" }
"{n} rows" = { one = "{n} řádek", few = "{n} řádky", other = "{n} řádků" }
"{n} styles" = { one = "{n} styl", few = "{n} styly", other = "{n} stylů" }
"{n} variables" = { one = "{n} proměnná", few = "{n} proměnné", other = "{n} proměnných" }
"{n} weeks ago" = { one = "Před {n} týdnem", few = "Před {n} týdny", other = "Před {n} týdny" }
"{n} years ago" = { one = "Před {n} rokem", few = "Před {n} lety", other = "Před {n} lety" }
"{n}d" = "{n} d"
"{n}h" = "{n} h"
"{n}m" = "{n} min"
+71 -6
View File
@@ -186,7 +186,9 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
crate::guides::end_released(&ctx);
crate::live::track_pointer(app, &ctx);
app.sync_preedit();
request_render(app, worker, ppp);
// Retained rendering snaps the view outward and adds one pixel on each axis.
let max_texture_side = (ctx.input(|i| i.max_texture_side).min(16_384) as u32).saturating_sub(1).max(1);
request_render(app, worker, ppp, max_texture_side);
// Every finished render is shown, even when a newer one is already on its way: renders finish
// in order, and an edit every frame (a drag) would otherwise never show until it stopped.
let shown = app.session.ds().map(|ds| (ds.uid, ds.page)).ok();
@@ -322,11 +324,11 @@ fn covers(outer: &figcraft_render::View, inner: &figcraft_render::View) -> bool
o.x0 <= i.x0 + eps && o.y0 <= i.y0 + eps && o.x1 >= i.x1 - eps && o.y1 >= i.y1 - eps
}
fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) {
fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32, max_texture_side: u32) {
let Ok(ds) = app.session.ds() else { return };
let rect = app.canvas.rect;
let (mut ox, mut oy) = ds.view.origin();
let (scale, w, h) = if let Some(k) = ds.view.pixel_preview_scale() {
let (scale, visible_w, visible_h) = if let Some(k) = ds.view.pixel_preview_scale() {
// Pixel preview: render `k` pixels per page unit from a whole-pixel origin, shown magnified
// without smoothing.
(ox, oy) = ((ox * k).floor() / k, (oy * k).floor() / k);
@@ -340,12 +342,20 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) {
let outlined = if ds.view.outlines_selection { ds.selection.clone() } else { vec![] };
let proof = ds.view.proof_srgb && !doc.color_profile.is_srgb();
let outline_extras = figcraft_render::Outline { hidden: ds.view.outline_hidden, bounds: ds.view.outline_bounds, light: false };
// Render beyond the visible viewport. During a pan the previous texture then still covers
// the screen while the worker renders newly exposed content. Request a new buffer before the
// visible area reaches its edge, rather than exposing an empty strip each pointer move.
let margin = overscan_margin(visible_w, visible_h, max_texture_side);
let origin = (ox, oy);
let visible = (visible_w, visible_h);
let requested = (ox - f64::from(margin) / scale, oy - f64::from(margin) / scale);
let size = (visible_w.saturating_add(margin * 2).min(max_texture_side), visible_h.saturating_add(margin * 2).min(max_texture_side));
let key = JobKey {
doc: Arc::as_ptr(&doc) as usize,
page: ds.page,
origin: ((ox * 1000.0) as i64, (oy * 1000.0) as i64),
origin: ((requested.0 * 1000.0) as i64, (requested.1 * 1000.0) as i64),
scale: (scale * 10000.0) as i64,
size: (w, h),
size,
outline: ds.view.outlines,
outline_extras,
outlined: outlined.clone(),
@@ -354,13 +364,21 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) {
fonts: figcraft_text::layout_generation(),
proof,
};
if let Some(previous) = &app.canvas.submitted {
let previous_origin = (previous.origin.0 as f64 / 1000.0, previous.origin.1 as f64 / 1000.0);
let same_content = JobKey { origin: previous.origin, size: previous.size, ..key.clone() } == *previous;
let covers = cached_render_covers(previous_origin, previous.size, origin, visible, scale);
if same_content && covers {
return;
}
}
if app.canvas.submitted.as_ref() == Some(&key) {
return;
}
let job = Job {
doc,
page: ds.page,
view: figcraft_render::View { origin: (ox, oy), scale, width: w.min(16384), height: h.min(16384) },
view: figcraft_render::View { origin: requested, scale, width: size.0, height: size.1 },
outline: ds.view.outlines,
outline_extras,
outlined,
@@ -376,6 +394,53 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) {
app.canvas.submitted = Some(key);
}
/// Leave a 96-pixel reserve so the next render starts before a pan reaches a cached edge.
fn cached_render_covers(buffer_origin: (f64, f64), buffer_size: (u32, u32), view_origin: (f64, f64), view_size: (u32, u32), scale: f64) -> bool {
if !scale.is_finite() || scale <= 0.0 {
return false;
}
let guard_x = 96.0_f64.min(f64::from(buffer_size.0.saturating_sub(view_size.0)) / 4.0) / scale;
let guard_y = 96.0_f64.min(f64::from(buffer_size.1.saturating_sub(view_size.1)) / 4.0) / scale;
view_origin.0 >= buffer_origin.0 + guard_x
&& view_origin.1 >= buffer_origin.1 + guard_y
&& view_origin.0 + f64::from(view_size.0) / scale <= buffer_origin.0 + f64::from(buffer_size.0) / scale - guard_x
&& view_origin.1 + f64::from(view_size.1) / scale <= buffer_origin.1 + f64::from(buffer_size.1) / scale - guard_y
}
/// Bound the extra rendered area to about 1.6 times the visible area. A fixed large margin can
/// multiply the pixel count several-fold on a narrow or small viewport.
fn overscan_margin(width: u32, height: u32, max_texture_side: u32) -> u32 {
width.min(height).saturating_div(8).min(128).min(max_texture_side.saturating_sub(width.max(height)) / 2)
}
#[cfg(test)]
#[test]
fn pan_uses_cached_pixels_until_the_refill_boundary() {
let margin = overscan_margin(1000, 800, 16_383);
assert_eq!(margin, 100);
let buffer = (-f64::from(margin), -f64::from(margin));
let size = (1000 + 2 * margin, 800 + 2 * margin);
assert!(cached_render_covers(buffer, size, (0.0, 0.0), (1000, 800), 1.0));
assert!(cached_render_covers(buffer, size, (50.0, 0.0), (1000, 800), 1.0));
assert!(!cached_render_covers(buffer, size, (80.0, 0.0), (1000, 800), 1.0));
assert!(!cached_render_covers(buffer, size, (-80.0, 0.0), (1000, 800), 1.0));
assert!(cached_render_covers((-24.0, -24.0), (1048, 848), (10.0, 0.0), (1000, 800), 1.0));
assert!(!cached_render_covers((-24.0, -24.0), (1048, 848), (19.0, 0.0), (1000, 800), 1.0));
}
#[cfg(test)]
#[test]
fn overscan_stays_within_texture_and_pixel_budget() {
for (width, height, limit) in [(1000, 800, 16_383), (390, 844, 16_383), (2560, 1440, 16_383), (1000, 800, 1060)] {
let margin = overscan_margin(width, height, limit);
assert!(width + 2 * margin <= limit || width > limit);
assert!(height + 2 * margin <= limit || height > limit);
let rendered = u64::from(width + 2 * margin) * u64::from(height + 2 * margin);
let visible = u64::from(width) * u64::from(height);
assert!(rendered * 5 <= visible * 8); // At most 1.6× the visible pixels.
}
}
/// The zoom tool while Z is held (Alt: zoom out): a click zooms a step about the pointer, a drag
/// zooms to the dragged area. Takes the pointer from the other tools while it's active.
fn zoom_tool(app: &mut FigApp, ui: &Ui, resp: &egui::Response, pos: Pos2, mods: egui::Modifiers, typing: bool) -> bool {
+4 -1
View File
@@ -163,6 +163,9 @@ enum Plural {
/// CLDR integer rules. Existing one/other catalogs may omit the extra forms.
fn plural(code: &str, n: usize) -> Plural {
match code {
"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,
@@ -174,7 +177,7 @@ fn plural(code: &str, n: usize) -> Plural {
}
// CLDR's million-count "many" in French, Spanish and Portuguese still reads their existing
// "other" wording. Only Russian currently supplies separate few/many forms.
// "other" wording. Czech supplies separate few forms; Russian supplies 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).
+20
View File
@@ -419,6 +419,26 @@ fn the_key_scanner_reads_rust_literals() {
assert_eq!(literals(src), vec!["A \"b\"", "C {n}", "DE"]);
}
#[test]
fn czech_integer_plurals() {
let cs = LANGS.iter().position(|l| l.code == "cs").unwrap();
assert_eq!(plural("cs", 1), Plural::One);
assert_eq!(fill(counted(cs, "{n} layers", 1), &[("n", &1)]), "1 vrstva");
for n in [2, 3, 4] {
assert_eq!(plural("cs", n), Plural::Few, "{n}");
assert_eq!(fill(counted(cs, "{n} layers", n), &[("n", &n)]), format!("{n} vrstvy"));
}
for n in [0, 5, 11, 12, 21, 22, 100, 1_000_000] {
assert_eq!(plural("cs", n), Plural::Other, "{n}");
assert_eq!(fill(counted(cs, "{n} layers", n), &[("n", &n)]), format!("{n} vrstev"));
}
for (key, text) in LANGS[cs].ui {
if let Text::Plural { one, few, .. } = text {
assert!(one.is_some() && few.is_some(), "{key}: missing Czech plural forms");
}
}
}
#[test]
fn russian_language_tags_and_integer_plurals() {
let ru = LANGS.iter().position(|l| l.code == "ru").unwrap();
+20
View File
@@ -311,6 +311,26 @@ fn an_animated_gif_wakes_only_the_presenter_and_only_at_its_frames() {
h.assert_idle("the presenter closed");
}
#[test]
fn cjk_names_are_looked_for_in_edits_not_on_every_pan() {
let mut s = Session::new();
let a = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
let b = s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
s.execute("node.set", &json!({"id": a, "name": "中文"})).unwrap();
let mut h = Harness::new(s);
let ctx = h.ctx.clone();
assert_eq!(h.app.cjk_needed(&ctx, 0), crate::cjk::HAN, "the first look reads every name");
// A pan or zoom changes the revision but not the document: its hundreds of thousands of
// names aren't read again (that took 70 ms a frame in a 900 000-layer file).
let revision = h.app.session.ds().unwrap().revision;
h.run("view.set", json!({"zoom": 2, "cx": 300, "cy": 200}));
assert_ne!(h.app.session.ds().unwrap().revision, revision);
assert_eq!(h.app.cjk_needed(&ctx, 0), 0);
// An edit is read: the layers it changed.
h.run("node.set", json!({"id": b, "name": "한국어"}));
assert_eq!(h.app.cjk_needed(&ctx, 0), crate::cjk::HANGUL);
}
#[test]
fn a_hidden_window_answers_requests_that_need_no_frame() {
// #269: minimised or covered, the window draws no frames, but the app's logic still runs: it
+23 -7
View File
@@ -330,6 +330,9 @@ pub enum TitleBar {
/// How often open documents get an autosave checkpoint (seconds).
pub const AUTOSAVE_SECS: f64 = 30.0 * 60.0;
/// Most changed layers whose names are checked for CJK scripts one by one (beyond: all of them).
const CJK_CHANGES: usize = 10_000;
/// With auto-save on: how long after a file last changed it is saved, and how long a failed save
/// waits before trying again (seconds).
pub const AUTO_SAVE_SECS: f64 = 3.0;
@@ -601,8 +604,8 @@ pub struct FigApp {
/// System faces for scripts the built-in fonts lack were added (after the font scan), with
/// faces for these CJK scripts ([`cjk`]) in this UI language's order.
script_fonts: Option<(u8, &'static str)>,
/// The tab (uid) and revision whose names were last checked for CJK scripts.
cjk_checked: Option<(u64, u64)>,
/// The tab (uid) and document version whose names were last checked for CJK scripts.
cjk_checked: Option<(u64, Arc<figcraft_doc::Document>)>,
/// The language preference the UI strings follow (applied when it changes).
language: Option<String>,
/// A field had the keyboard when the last frame ended.
@@ -1066,11 +1069,24 @@ impl FigApp {
})
});
let dragging = ctx.input(|i| i.pointer.any_down());
if let Some(d) = self.session.docs.get(self.session.active).filter(|d| !dragging && self.cjk_checked != Some((d.uid, d.revision))) {
self.cjk_checked = Some((d.uid, d.revision));
let doc = &d.doc;
let comments = doc.comments.iter().flat_map(|c| std::iter::once(&c.first).chain(&c.replies)).flat_map(|m| [&m.author, &m.text]);
need |= std::iter::once(&doc.name).chain(doc.nodes().map(|n| &n.name)).chain(comments).fold(0, |m, s| m | cjk::scripts(s));
// Pans and zooms change the revision too: only a new version of the document is looked
// at, and only the layers it changed (a large file has hundreds of thousands of names).
let current = self.session.docs.get(self.session.active).filter(|_| !dragging).map(|d| (d.uid, d.doc.clone()));
if let Some((uid, doc)) = current {
let before = self.cjk_checked.take().filter(|(u, _)| *u == uid).map(|(_, b)| b);
if !before.as_ref().is_some_and(|b| Arc::ptr_eq(b, &doc)) {
let mut scan = |s: &str| need |= cjk::scripts(s);
scan(&doc.name);
match before.as_ref().and_then(|b| doc.changed_ids(b, CJK_CHANGES)) {
Some(ids) => ids.into_iter().filter_map(|id| doc.node(id)).for_each(|n| scan(&n.name)),
None => doc.nodes().for_each(|n| scan(&n.name)),
}
doc.comments.iter().flat_map(|c| std::iter::once(&c.first).chain(&c.replies)).for_each(|m| {
scan(&m.author);
scan(&m.text);
});
}
self.cjk_checked = Some((uid, doc));
}
need
}
+5 -1
View File
@@ -1,6 +1,6 @@
# Where FigCraft falls short of Figma
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (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-15 · **Change:** minor (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
@@ -197,6 +197,9 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
parallel, retained) and shows the image through wgpu. A new zoom can't reuse the retained
image; when renders from scratch take a frame or more, the whole view is previewed with a
sixteenth of the pixels first, then refined.
- **Landed (2026-10-15):** a bounded render margin is reused during panning and refilled before
the visible view reaches its edge. The extra rendered area is capped at 1.6× visible pixels;
this reduces blank strips without changing the CPU renderer.
- **Evidence (measured, `cargo xtask bench`):** a 50k-layer page ~8 ms zoomed out with levels of
detail, single-layer edits at 10k layers ~0.2 ms; full 1440×900 renders 7–15 ms at 10k layers.
Heavy effects (glass, background blur, noise) and 4K/5K canvases at 2x are where CPU time grows.
@@ -397,6 +400,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
| Date | Change | Summary |
|---|---|---|
| 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. |
| 2026-10-13 | minor | Gap 1: the experimental `.fig` export writes instance sizes (resized, filling an auto layout, scaled), variables with modes and bindings, shared styles and variable-width strokes, and the import reads them back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Hours and the ranking unchanged |
| 2026-10-12 | minor | Gap 2: your online files landed (the app records the files it put on a server with their owner tokens, Home ▸ Online lists them and opens them again as their owner, with sharing, rename, removal and forget from the list; one request per server tells which are still there; tabs mark files in a live session). What is left is unchanged: accounts, teams and per-person permissions, shared comments and versions. Hours unchanged |
+5 -4
View File
@@ -21,7 +21,7 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
- **Layer:** one TOML catalog per language in `crates/ui-egui/locales/`, baked into the binary by
the UI crate's build script (desktop and web; nothing read at run time). Keys are the English text
(`tr("Fill")`, `trf` for `{named}` placeholders, `trn` for counts with one/other plural forms), so
(`tr("Fill")`, `trf` for `{named}` placeholders, `trn` for counts with one/few/many/other plural forms), so
a missing translation shows English, never a raw key. One English text that needs two
translations takes a context (`"Fill|sizing"`).
- **Strings:** 1,417 UI keys in `en.toml` (36 with plural forms; ~110 are labels from engine and
@@ -55,9 +55,10 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
until someone translates it. Every draft translates every string as of 2026-10-12 (checked by
adding each to `COMPLETE` locally and running the check).
- **Not yet localized:** messages the engine writes (errors, a few toasts, Dev Mode property values
such as "inside"), number formatting (decimal points stay `.`), and plural forms beyond one/other
(French and Brazilian Portuguese take the singular for 0 as well; Japanese, Korean and Chinese
have one form; CLDR's "many" for millions reads as the plural).
such as "inside"), number formatting (decimal points stay `.`), and decimal plural rules
(`trn` takes integers: Czech uses one/few/other and Russian one/few/many; French and Brazilian
Portuguese take the singular for 0 as well; Japanese, Korean and Chinese have one form;
CLDR's "many" for millions in French, Spanish and Portuguese reads as the plural).
- **Script support in documents:** Unicode bidi with Arabic shaping and mirrored brackets in text
layers; right-to-left list markers and indents; right-to-left names in the layers panel, tabs
and canvas labels (visual order, joined Arabic); inline IME composition with the candidate window
+3 -3
View File
@@ -80,9 +80,9 @@ language also needs its script in `cjk::language`.
`few` for 2–4, 22–24… except 12–14; and `many` for the other integers, including 0. Keep `{n}`
in all Russian forms. Add new rules to `plural` in
[`crates/ui-egui/src/i18n.rs`](../crates/ui-egui/src/i18n.rs). A missing form reads `other`;
a missing key uses the fallback catalog's own plural rule. Czech has no rule of its own yet, so it
picks `one` for 1 and `other` for everything else: its `other` forms are count-neutral for 0,
2–4 and 5+ (e.g. `Vrstvy: {n}`).
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.
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: