diff --git a/README.md b/README.md index 8aa0288..517fb67 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,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 and Spanish (a draft waiting for a native speaker's review), 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 two example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md). - **Live sessions (preview):** host the open file from the app (this machine or your local network), share 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 (Share ▸ Publish to server…) and hands out share links 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 9f1f6f8..8105f57 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,8 +1,8 @@ # FigCraft Roadmap -**Stage: alpha** · next: beta, ~28 points and ~200–365 h away (one Opus 5.5 agent; ~40–80 h wall-clock with 5 agents) +**Stage: alpha** · next: beta, ~28 points and ~195–355 h away (one Opus 5.5 agent; ~40–80 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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 ~79%; ready ~47%) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (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 ~79%; ready ~47%) · **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 @@ -18,14 +18,14 @@ start with [`docs/gaps.md`](docs/gaps.md). | Ready for real work | **~47%** | estimated, same doc (written dimension weights and scores) | | Essentials user | **~53%** | estimated: core features 77.8% × 0.92 launch/stability × 0.92 discoverability × 0.8 opening files people send ([method](docs/target-app-parity.md#essentials-user)) | | Mainstream practitioner | **~43%** | estimated: weekly areas 66.8% × 0.9 interaction × 0.9 stability × 0.8 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) | -| Remaining to beta | **~200–365 h** one agent | estimated | -| Remaining to full parity | **~363–697 h** one agent (~95–165 h wall-clock with 5 agents) | estimated | +| Remaining to beta | **~195–355 h** one agent | estimated | +| Remaining to full parity | **~348–672 h** one agent (~92–160 h wall-clock with 5 agents) | estimated | ### Readiness by audience | Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | |---|---|---|---| -| Full target (ready for real work) | ~47% | ~345–660 h | multiplayer (45–90), localization (60–100), plugins (15–40), the interaction pass (40–60), `.fig` import (30–60), GPU canvas (30–60), AI (30–60) | +| Full target (ready for real work) | ~47% | ~330–635 h | multiplayer (45–90), localization (45–75), plugins (15–40), the interaction pass (40–60), `.fig` import (30–60), GPU canvas (30–60), AI (30–60) | | Mainstream practitioner | ~43% | ~165–310 h | core editor depth incl. `.fig` import (83–163), the interaction pass (40–60), real-file corpus and platform QA (25–50), `.fig` export for handing work back (15–30) | | Essentials user | ~53% | ~85–160 h | `.fig` import so files people send open (30–60), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20), onboarding and starter templates (5–10) | @@ -50,7 +50,7 @@ against real Figma documents. Ready-for-real-work is ~47%, inside alpha's ~40– ~75% **and** reliable `.fig` import: the `.fig` reader (30–60 h), a real-file corpus with measured fidelity (15–30 h), the interaction pass (25–40 h of the 40–60), core depth to ~80% (40–75 h), basic multiplayer on the landed core (30–55 h), stability and platform QA (20–40 h), a partial GPU path (15–30 h) and -the localization layer with Figma's own languages (20–35 h). +Figma's own languages on the landed localization layer (15–25 h). ## By dimension @@ -63,7 +63,7 @@ the localization layer with Figma's own languages (20–35 h). | Stability | 55% | 20–40 | [gaps](docs/gaps.md) | | Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) | | Hardware | 55% | 13–27 | [hardware-parity](docs/hardware-parity.md) | -| Localization | 10% (English only) | 60–100 | [localization-parity](docs/localization-parity.md) | +| Localization | 20% (layer; English, Spanish draft) | 45–75 | [localization-parity](docs/localization-parity.md) | | Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) | | Ecosystem / plugins | 28% (own WASM plugins with panels and settings) | 15–40 | [gaps](docs/gaps.md) | | AI features | 0% | 30–60 | [gaps](docs/gaps.md) | @@ -100,7 +100,7 @@ the localization layer with Figma's own languages (20–35 h). |---|---|---|---| | English | en | 100% | full | | Simplified Chinese | zh-Hans | 0% | none | -| Spanish | es | 0% | none | +| Spanish | es | 100% | draft (needs native review) | | Hindi | hi | 0% | none | | Arabic | ar | 0% | none | | French | fr | 0% | none | @@ -112,7 +112,8 @@ the localization layer with Figma's own languages (20–35 h). | Vietnamese | vi | 0% | none | No other languages. Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese -(Brazil); FigCraft has no localization layer yet. Document text in any script (bidi, Arabic +(Brazil); FigCraft has the localization layer, English and a Spanish draft (Preferences ▸ Language; +adding a language: [`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping, IME) already works. Detail: [`docs/localization-parity.md`](docs/localization-parity.md). ## Upcoming @@ -122,7 +123,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). 1. `.fig` import, best effort, with a synthetic `.fig` corpus: 30–60 h. 2. Real-file corpus and measured fidelity: 15–30 h. 3. Tool-by-tool interaction pass against Figma: 40–60 h. -4. Localization layer, then ja, ko, es, pt-BR, fr, de: 35–55 h. +4. Languages on the landed localization layer: ja, ko, pt-BR, fr, de, and a native review of es: 15–30 h. 5. Multiplayer (M9): accounts and teams on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 45–90 h. ## Progress log @@ -182,11 +183,12 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Localization layer landed (gap 5): per-language catalogs baked in at build time with English text as keys and fallback, placeholders and plurals, command labels by id, Preferences ▸ Language (System follows the OS or the browser) applied live and `pref.language`; English catalog and a complete Spanish draft awaiting native review. Localization 10% → 20%, 60–100 h → 45–75 h; ready for real work unchanged (~47%); full parity ~363–697 h → ~348–672 h; beta ~200–365 h → ~195–355 h | | 2026-10-11 | minor | Hosting `figcraft-server`: the desktop app and `figcraft-cli` open `wss://` links (rustls with Mozilla's root certificates, always verified; `FIGCRAFT_CA_FILE` adds a private authority), releases ship the server for Linux x86_64 and aarch64, it stops gracefully on SIGTERM or Ctrl+C, and `docs/server.md` has a deployment guide (systemd unit, Caddy, nginx, Docker); percentages and hours unchanged until the next re-measure | | 2026-10-11 | minor | Plugin panels and settings landed (gap 6, ABI v2; v1 plugins unchanged): a plugin shows a panel of notes, fields (text, number, checkbox, menu, list, colour) and buttons drawn with FigCraft's widgets; a button or a changed value calls it back with every value (same limits per callback, one undo step per callback that edits, rolled back on failure), its module staying loaded while the panel is open; `plugin.ui` / `plugin.event` / `plugin.close` drive panels headless; per-plugin settings (64 KB) kept with the preferences; the Swatch grid example gained a palette panel that remembers the last palette. Plugins & widgets breadth 35% → 45%, ready 20% → 28%, 25–55 h → 15–40 h; ecosystem 20% → 28%; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~373–712 h → ~363–697 h | | 2026-10-11 | minor | Canvas fixes (gap 13 closed): boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked), a layer dragged inside a group stays in the group (frames and auto layout still take other drops), View ▸ Frame outlines and View ▸ Slices draw on the canvas, and the idle tests wait for the render thread instead of counting frames. Shapes & vector 1–3 h → 1–2 h (vector QA with real files left); percentages, feature breadth (~79%), ready for real work (~47%) and full parity hours unchanged at this rounding | | 2026-10-11 | minor | Code connect and Dev Mode plugins (gap 16): a component or set links to its code component per language (import path, name, a template with `{property}` and `{property ? "a" : "b"}` placeholders); `inspect.code`, `inspect.componentCode` and the Inspect panel show that snippet with the instance's values (also inside generated layer trees), edited in Connect to code with a live preview; links are saved, synced and undoable, and travel as `figcraft.connect.json` (`codeConnect.export` / `codeConnect.import`). Plugins that only read run from the Inspect tab. Dev Mode breadth 80% → 90%, ready 62% → 64%, 4–8 h → 1–3 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~378–722 h → ~375–717 h | -| 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~378–722 h → ~373–712 h | +| 2026-10-11 | minor | Stretch and scatter brushes and pen pressure landed (gap 9): eight brushes drawn in code (four stretched along the path, four scattered with spacing, size, rotation and scatter jitter from a seed) in the stroke settings, drawn everywhere strokes are and flattened by outline stroke; the Pencil records pen pressure as the stroke's width profile (Windows pens, browser styluses; macOS and Linux tablets report none) and can draw with a brush; Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–9 h; Strokes 2–4 h → 1–3 h; Hardware 50% → 55%, 15–30 h → 13–27 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~378–722 h → ~358–687 h | | 2026-10-11 | minor | Text on a path and pattern fills landed (gap 9): text follows a path or shape (start, side, alignment; edited in place; canvas, PNG, SVG and PDF; SVG `` imports as text on a path) and pattern fills tile a layer (grid or half-shifted rows or columns, scale, spacing, alignment; live as the source changes; SVG ``, PDF tiling patterns); Figma Draw extras breadth 45% → 65%, ready 30% → 45%, 10–18 h → 5–10 h; Text 8–14 h → 7–13 h; feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~375–715 h | | 2026-10-11 | minor | Team libraries on `figcraft-server` landed (gap 10): a file's library is published to the server under the file and enabled in other files by a share link (per-asset versions; updates noticed in the background and accepted with overrides kept), the server counts which files use which components, and a whole library can be swapped for another by names; Team libraries breadth 40% → 70%, ready 25% → 40%, 10–20 h → 4–8 h; collaboration 12% → 13%, 55–110 h → 49–98 h; feature breadth (~79%) and ready for real work (~47%) unchanged; full parity ~380–725 h → ~375–715 h | | 2026-10-11 | minor | Branches landed (gap 17): File ▸ Create branch… copies the file into a branch tab that remembers its base; Review and merge shows both sides' changes (layers with Inspect-style property changes, styles, variables, comments) and the conflicts (the same property changed on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merging is one undo step and archives the branch; Update from main; branches are saved in the `.figcraft` file. Comments, versions, find, misc breadth 80% → 85%, ready 58% → 60%, 4–8 h → 2–5 h; collaboration 12% → 13%; menu breadth 204/204 (the catalog gained File ▸ Create branch…); feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~380–725 h → ~378–722 h | diff --git a/apps/figcraft-web/Cargo.toml b/apps/figcraft-web/Cargo.toml index 3028157..c777994 100644 --- a/apps/figcraft-web/Cargo.toml +++ b/apps/figcraft-web/Cargo.toml @@ -25,4 +25,4 @@ log = { workspace = true } eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "Location", "MediaQueryList"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "Location", "MediaQueryList", "Navigator"] } diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index 0420f3b..0744d32 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -86,4 +86,8 @@ impl Services for WebServices { fn reduce_motion(&self) -> bool { self.reduce_motion.as_ref().is_some_and(web_sys::MediaQueryList::matches) } + /// The browser's language (`navigator.language`). + fn system_language(&self) -> Option { + web_sys::window().and_then(|w| w.navigator().language()) + } } diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml index f96097e..c534d94 100644 --- a/apps/figcraft/Cargo.toml +++ b/apps/figcraft/Cargo.toml @@ -25,9 +25,10 @@ webbrowser = { workspace = true } image = { workspace = true } env_logger = { version = "0.11", default-features = false, features = ["auto-color", "humantime"] } -# The system's reduce-motion setting (UISettings.AnimationsEnabled). +# The system's reduce-motion setting (UISettings.AnimationsEnabled) and interface language +# (GlobalizationPreferences.Languages). [target.'cfg(windows)'.dependencies] -windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement"] } +windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement", "System_UserProfile", "Foundation_Collections"] } [build-dependencies] winresource = "0.1" diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index c056fd5..fbbfe1a 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -13,6 +13,8 @@ pub struct Desktop { pub prefs: Option, /// The system's reduce-motion setting (read in the background at startup). reduce_motion: Arc, + /// The system's interface language (read at startup). + language: Option, } fn config_dir() -> Option { @@ -44,7 +46,34 @@ impl Desktop { let reduce_motion = Arc::new(AtomicBool::new(false)); let flag = reduce_motion.clone(); std::thread::spawn(move || flag.store(system_reduce_motion(), Ordering::Relaxed)); - Self { clipboard: arboard::Clipboard::new().ok(), prefs, reduce_motion } + Self { clipboard: arboard::Clipboard::new().ok(), prefs, reduce_motion, language: system_language() } + } +} + +/// The system's preferred interface language as a tag ("es-ES", "es_ES.UTF-8"): Windows' language +/// list, macOS's AppleLanguages, the locale variables elsewhere. +fn system_language() -> Option { + #[cfg(target_os = "windows")] + { + windows::System::UserProfile::GlobalizationPreferences::Languages() + .and_then(|l| l.GetAt(0)) + .map(|s| s.to_string()) + .inspect_err(|e| log::info!("language: can't read the system languages: {e}")) + .ok() + } + // `defaults` prints a list: ( "es-ES", en ). + #[cfg(target_os = "macos")] + { + let out = std::process::Command::new("defaults").args(["read", "-g", "AppleLanguages"]).output().ok()?; + String::from_utf8_lossy(&out.stdout).split(['(', ',', ')']).map(|s| s.trim().trim_matches('"').to_string()).find(|s| !s.is_empty()) + } + #[cfg(all(unix, not(target_os = "macos")))] + { + ["LC_ALL", "LC_MESSAGES", "LANG"].iter().filter_map(|k| std::env::var(k).ok()).find(|v| !v.is_empty() && v != "C" && v != "POSIX") + } + #[cfg(not(any(windows, unix)))] + { + None } } @@ -153,6 +182,9 @@ impl Services for Desktop { fn reduce_motion(&self) -> bool { self.reduce_motion.load(Ordering::Relaxed) } + fn system_language(&self) -> Option { + self.language.clone() + } } /// The window icon from the bundled app icon (if present). diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 57ba778..f869235 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -241,6 +241,18 @@ pub fn specs() -> Vec { s.prefs.user_name = if name.is_empty() { super::comment::DEFAULT_AUTHOR.into() } else { name }; Ok(json!({"name": s.prefs.user_name})) }), + spec!("pref.language", "Interface language", "{language: system|en|es|… (a language code; no catalog: English)}", false, |s, p| { + let lang = req_string("pref.language", p, "language")?; + let lang = lang.trim(); + // A BCP 47-style tag ("es", "pt-BR", "zh_Hans") or "system". + let tag = lang.len() <= 35 + && lang.split(['-', '_']).all(|part| !part.is_empty() && part.len() <= 8 && part.chars().all(|c| c.is_ascii_alphanumeric())); + if !tag { + return Err(bad("pref.language", format!("`{lang}` is not a language code"))); + } + s.prefs.language = lang.to_string(); + Ok(json!({"language": s.prefs.language})) + }), spec!("app.preferences", "Preferences", "{}", false, |s, _| ui(s, "dialog.preferences")), spec!("app.actions", "Actions", "{}", false, |s, _| ui(s, "palette.actions")), spec!("help.shortcuts", "Keyboard shortcuts", "{}", false, |s, _| ui(s, "panel.shortcuts")), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 869cc35..9849f2b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -481,6 +481,9 @@ pub struct Prefs { pub number_keys_opacity: bool, pub show_dimensions: bool, pub theme: String, + /// The interface language: a language code ("en", "es") or "system" (the OS or browser + /// language). Languages without a catalog show English. + pub language: String, /// The name comments are signed with (and whose comments count as yours). pub user_name: String, /// Reduce motion: the interface's own animations are off (the system's setting turns them off @@ -512,6 +515,7 @@ impl Default for Prefs { number_keys_opacity: true, show_dimensions: true, theme: "light".into(), + language: "system".into(), user_name: cmd::comment::DEFAULT_AUTHOR.into(), reduce_motion: false, invite_links: None, diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index f40007f..e8de6f5 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -7,6 +7,9 @@ //! JSON says the entry was verified (`people_entry`). //! //! A missing or malformed file gives empty tables (or no names) and a warning. +//! +//! Also bakes the UI string catalogs (`locales/.toml`, see docs/translating.md) into sorted +//! static tables for `i18n`. use std::fmt::Write as _; @@ -24,6 +27,56 @@ fn main() { if let Err(e) = std::fs::write(&out, generate(&json, &people)) { println!("cargo::warning=writing {}: {e}", out.display()); } + let out = out.with_file_name("locales.rs"); + if let Err(e) = std::fs::write(&out, locales()) { + println!("cargo::warning=writing {}: {e}", out.display()); + } +} + +/// `pub static LANGS: &[Lang]`: every `locales/*.toml`, English first, each table sorted by key. +fn locales() -> String { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales"); + println!("cargo::rerun-if-changed={}", dir.display()); + let mut files: Vec<_> = + std::fs::read_dir(&dir).into_iter().flatten().flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "toml")).collect(); + files.sort_by_key(|p| (p.file_stem().is_none_or(|s| s != "en"), p.clone())); + let mut o = String::from( + "pub static LANGS: &[Lang] = &[ +", + ); + for path in files { + let Some(t) = read(&path, "the language is left out", |t| t.parse::().map_err(|e| e.to_string())) else { continue }; + let code = path.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); + let meta = |k: &str| t.get("meta").and_then(|m| m.get(k)).and_then(toml::Value::as_str).unwrap_or("").to_string(); + let _ = writeln!(o, " Lang {{ code: {code:?}, name: {:?}, draft: {}, ui: &[", meta("name"), !meta("status").is_empty()); + table(&mut o, &path, t.get("ui")); + let _ = writeln!(o, " ], commands: &["); + table(&mut o, &path, t.get("commands")); + let _ = writeln!(o, " ], menu: &["); + table(&mut o, &path, t.get("menu")); + let _ = writeln!(o, " ] }},"); + } + o.push_str( + "]; +", + ); + o +} + +/// The rows of one catalog table: `"key" = "text"` or `"key" = { one = "…", other = "…" }`. +fn table(o: &mut String, path: &std::path::Path, t: Option<&toml::Value>) { + let Some(t) = t.and_then(toml::Value::as_table) else { return }; + // Sorted by key (byte order), as the lookup's binary search needs. + let mut rows: Vec<_> = t.iter().collect(); + rows.sort_by(|a, b| a.0.cmp(b.0)); + for (k, v) in rows { + let s = |k: &str| v.get(k).and_then(toml::Value::as_str); + match (v.as_str(), s("one"), s("other")) { + (Some(text), ..) => _ = writeln!(o, " ({k:?}, Text::Plain({text:?})),"), + (None, one, Some(other)) => _ = writeln!(o, " ({k:?}, Text::Plural({}, {other:?})),", name(one)), + _ => println!("cargo::warning={}: `{k}` is neither a string nor {{ one, other }}; left out", path.display()), + } + } } fn read(path: &std::path::Path, fallback: &str, parse: impl Fn(&str) -> Result) -> Option { diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml new file mode 100644 index 0000000..36db4ab --- /dev/null +++ b/crates/ui-egui/locales/en.toml @@ -0,0 +1,1290 @@ +# 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 = "English" + +[ui] +"% 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)" } +"+ Create variable" = "+ Create variable" +"1…9, 0" = "1…9, 0" +"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" +"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "A collection of this file with these modes and variables: override their values per mode and add variables of its own" +"About" = "About" +"About FigCraft" = "About FigCraft" +"Action" = "Action" +"Actions" = "Actions" +"Actions…" = "Actions…" +"Active" = "Active" +"Actual size (100%)" = "Actual size (100%)" +"Add a category" = "Add a category" +"Add a comment" = "Add a comment" +"Add a description" = "Add a description" +"Add a frame (F) or a shape (R) to get started" = "Add a frame (F) or a shape (R) to get started" +"Add a layer or component to tile first" = "Add a layer or component to tile first" +"Add a note for developers" = "Add a note for developers" +"Add action" = "Add action" +"Add actions for when no condition holds" = "Add actions for when no condition holds" +"Add an annotation for developers" = "Add an annotation for developers" +"Add another condition" = "Add another condition" +"Add auto layout" = "Add auto layout" +"Add background behind overlay" = "Add background behind overlay" +"Add content to this slot" = "Add content to this slot" +"Add effect" = "Add effect" +"Add export setting" = "Add export setting" +"Add fill" = "Add fill" +"Add interaction" = "Add interaction" +"Add layout grid" = "Add layout grid" +"Add library" = "Add library" +"Add library file…" = "Add library file…" +"Add max height" = "Add max height" +"Add max width" = "Add max width" +"Add min height" = "Add min height" +"Add min width" = "Add min width" +"Add mode" = "Add mode" +"Add new page" = "Add new page" +"Add or update collections from a variables JSON" = "Add or update collections from a variables JSON" +"Add properties to make instances configurable" = "Add properties to make instances configurable" +"Add reaction" = "Add reaction" +"Add starting point" = "Add starting point" +"Add stroke" = "Add stroke" +"Add variant" = "Add variant" +"Add variant with a new value" = "Add variant with a new value" +"Added" = "Added" +"Added the library \"{name}\"" = "Added the library \"{name}\"" +"Adjust" = "Adjust" +"Adjustment presets" = "Adjustment presets" +"Advanced stroke" = "Advanced stroke" +"After delay" = "After delay" +"After delay {ms}ms" = "After delay {ms}ms" +"Align" = "Align" +"Align bottom" = "Align bottom" +"Align center" = "Align center" +"Align horizontal centers" = "Align horizontal centers" +"Align left" = "Align left" +"Align middle" = "Align middle" +"Align right" = "Align right" +"Align text baselines" = "Align text baselines" +"Align top" = "Align top" +"Align vertical centers" = "Align vertical centers" +"Alignment" = "Alignment" +"All" = "All" +"All categories" = "All categories" +"All comments" = "All comments" +"All components" = "All components" +"All fonts" = "All fonts" +"All supported properties" = "All supported properties" +"All variables" = "All variables" +"Alpha" = "Alpha" +"Alt+drag" = "Alt+drag" +"Anchor" = "Anchor" +"Angular" = "Angular" +"Animation" = "Animation" +"Annotations" = "Annotations" +"Annotations shown on the canvas" = "Annotations shown on the canvas" +"Any status" = "Any status" +"Anyone on your network who has the invite link can join." = "Anyone on your network who has the invite link can join." +"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." = "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." +"Appearance" = "Appearance" +"Apply a component property" = "Apply a component property" +"Apply variable" = "Apply variable" +"Apply variable to text" = "Apply variable to text" +"Apply variable to visibility" = "Apply variable to visibility" +"Arc" = "Arc" +"Arc ratio" = "Arc ratio" +"Arc start" = "Arc start" +"Arc sweep" = "Arc sweep" +"Archive" = "Archive" +"Archived" = "Archived" +"Arrange" = "Arrange" +"Arrow" = "Arrow" +"As typed" = "As typed" +"Assets" = "Assets" +"Auto" = "Auto" +"Auto (space between)" = "Auto (space between)" +"Auto ({mode})" = "Auto ({mode})" +"Auto gap" = "Auto gap" +"Auto height" = "Auto height" +"Auto layout" = "Auto layout" +"Auto width" = "Auto width" +"Back" = "Back" +"Back to Ready for dev" = "Back to Ready for dev" +"Back to Ready for dev: the next change compares with how it is now" = "Back to Ready for dev: the next change compares with how it is now" +"Back to assets" = "Back to assets" +"Back to current" = "Back to current" +"Back to styles" = "Back to styles" +"Back to the canvas from a focused region" = "Back to the canvas from a focused region" +"Back to the current version" = "Back to the current version" +"Back to the values the font style implies" = "Back to the values the font style implies" +"Background" = "Background" +"Background blur" = "Background blur" +"Basics" = "Basics" +"Before" = "Before" +"Bend" = "Bend" +"Bevel" = "Bevel" +"Binary assets added" = "Binary assets added" +"Binary assets removed" = "Binary assets removed" +"Black & white" = "Black & white" +"Bleed" = "Bleed" +"Bleed marks" = "Bleed marks" +"Bleed marks need a bleed" = "Bleed marks need a bleed" +"Blend mode" = "Blend mode" +"Blue" = "Blue" +"Blur" = "Blur" +"Boolean" = "Boolean" +"Boolean groups" = "Boolean groups" +"Both directions" = "Both directions" +"Bottom" = "Bottom" +"Bottom left radius" = "Bottom left radius" +"Bottom right radius" = "Bottom right radius" +"Branch" = "Branch" +"Branch archived" = "Branch archived" +"Branch merged" = "Branch merged" +"Branch name" = "Branch name" +"Branch of {file}" = "Branch of {file}" +"Branch of {file} · merged or archived" = "Branch of {file} · merged or archived" +"Branch updated from main" = "Branch updated from main" +"Branch {name}" = "Branch {name}" +"Branches" = "Branches" +"Bring forward" = "Bring forward" +"Bring the main file's changes into the branch" = "Bring the main file's changes into the branch" +"Bring to front" = "Bring to front" +"Bristle" = "Bristle" +"Brush" = "Brush" +"Brush rotation jitter" = "Brush rotation jitter" +"Brush scatter" = "Brush scatter" +"Brush size jitter" = "Brush size jitter" +"Brush spacing" = "Brush spacing" +"Bulleted list" = "Bulleted list" +"CMYK colour" = "CMYK colour" +"CSS custom properties…" = "CSS custom properties…" +"Can edit" = "Can edit" +"Can't read this library: {e}" = "Can't read this library: {e}" +"Cancel" = "Cancel" +"Canvas" = "Canvas" +"Case" = "Case" +"Case-sensitive forms" = "Case-sensitive forms" +"Categories" = "Categories" +"Center" = "Center" +"Change alias" = "Change alias" +"Change resolved" = "Change resolved" +"Change style" = "Change style" +"Change to" = "Change to" +"Change to needs a layer inside a variant" = "Change to needs a layer inside a variant" +"Changed" = "Changed" +"Changed on both sides: keep one" = "Changed on both sides: keep one" +"Changes on main" = "Changes on main" +"Changes on the branch" = "Changes on the branch" +"Changes unknown" = "Changes unknown" +"Choose React, SwiftUI or Compose above to connect this component to its code." = "Choose React, SwiftUI or Compose above to connect this component to its code." +"Choose another profile to change it." = "Choose another profile to change it." +"Choose collection" = "Choose collection" +"Choose image" = "Choose image" +"Choose mode" = "Choose mode" +"Choose variable" = "Choose variable" +"Circle" = "Circle" +"Clear recent" = "Clear recent" +"Click + to replace mixed content" = "Click + to replace mixed content" +"Click anywhere on the canvas to leave one." = "Click anywhere on the canvas to leave one." +"Click or drag to merge · {alt} removes" = "Click or drag to merge · {alt} removes" +"Click or drag to remove · {alt} merges" = "Click or drag to remove · {alt} merges" +"Click to record a key" = "Click to record a key" +"Clip content" = "Clip content" +"Clip to shape" = "Clip to shape" +"Close" = "Close" +"Close overlay" = "Close overlay" +"Close preview" = "Close preview" +"Close tab" = "Close tab" +"Close version history" = "Close version history" +"Close when clicking outside" = "Close when clicking outside" +"Code" = "Code" +"Collapse" = "Collapse" +"Collapse panel" = "Collapse panel" +"Collection" = "Collection" +"Collections" = "Collections" +"Color" = "Color" +"Color burn" = "Color burn" +"Color dodge" = "Color dodge" +"Color profile" = "Color profile" +"Color styles" = "Color styles" +"Color values in this file are in this color space." = "Color values in this file are in this color space." +"Color variables" = "Color variables" +"Colour" = "Colour" +"Column" = "Column" +"Column gap" = "Column gap" +"Column sizes" = "Column sizes" +"Column span" = "Column span" +"Columns" = "Columns" +"Columns {count}" = "Columns {count}" +"Comment" = "Comment" +"Comments" = "Comments" +"Commits" = "Commits" +"Community" = "Community" +"Company" = "Company" +"Compare changes" = "Compare changes" +"Completed" = "Completed" +"Component" = "Component" +"Component name" = "Component name" +"Component property" = "Component property" +"Component set" = "Component set" +"Component sets" = "Component sets" +"Components" = "Components" +"Conditional" = "Conditional" +"Confetti" = "Confetti" +"Conflicts" = "Conflicts" +"Connect to code" = "Connect to code" +"Connected" = "Connected" +"Connected to code: {from}" = "Connected to code: {from}" +"Connection lost, reconnecting (attempt {attempt})…" = "Connection lost, reconnecting (attempt {attempt})…" +"Constrain proportions" = "Constrain proportions" +"Constraints" = "Constraints" +"Content" = "Content" +"Contents hidden" = "Contents hidden" +"Contextual alternates" = "Contextual alternates" +"Contrast" = "Contrast" +"Contributors" = "Contributors" +"Contributors: {people} · Commits: {commits}" = "Contributors: {people} · Commits: {commits}" +"Cool" = "Cool" +"Copied as CSS" = "Copied as CSS" +"Copied as PNG" = "Copied as PNG" +"Copied as SVG" = "Copied as SVG" +"Copied as text" = "Copied as text" +"Copied {value}" = "Copied {value}" +"Copy" = "Copy" +"Copy as" = "Copy as" +"Copy as CSS" = "Copy as CSS" +"Copy as PNG" = "Copy as PNG" +"Copy as SVG" = "Copy as SVG" +"Copy link" = "Copy link" +"Copy the {label}" = "Copy the {label}" +"Copy {value}" = "Copy {value}" +"Corner radius" = "Corner radius" +"Corner smoothing" = "Corner smoothing" +"Count" = "Count" +"Create" = "Create" +"Create a variable collection first" = "Create a variable collection first" +"Create a variable first" = "Create a variable first" +"Create alias" = "Create alias" +"Create branch" = "Create branch" +"Create collection" = "Create collection" +"Create component" = "Create component" +"Create component property" = "Create component property" +"Create components, styles or variables to publish them as a library." = "Create components, styles or variables to publish them as a library." +"Create property…" = "Create property…" +"Create style" = "Create style" +"Create style from the selected text" = "Create style from the selected text" +"Crop" = "Crop" +"Crop marks" = "Crop marks" +"Ctrl+arrows" = "Ctrl+arrows" +"Ctrl+click" = "Ctrl+click" +"Ctrl+scroll" = "Ctrl+scroll" +"Current version" = "Current version" +"Custom" = "Custom" +"Cut" = "Cut" +"Dark" = "Dark" +"Darken" = "Darken" +"Dash" = "Dash" +"Decoration" = "Decoration" +"Deep select" = "Deep select" +"Default" = "Default" +"Default content" = "Default content" +"Default value (the first variant)" = "Default value (the first variant)" +"Delay" = "Delay" +"Delete" = "Delete" +"Delete category (its annotations stay)" = "Delete category (its annotations stay)" +"Delete collection" = "Delete collection" +"Delete group" = "Delete group" +"Delete mode" = "Delete mode" +"Delete page" = "Delete page" +"Delete preset" = "Delete preset" +"Delete property" = "Delete property" +"Delete the style (layers keep their values)" = "Delete the style (layers keep their values)" +"Delete thread" = "Delete thread" +"Delete variable" = "Delete variable" +"Delete “{name}”? This can't be undone." = "Delete “{name}”? This can't be undone." +"Density" = "Density" +"Depth" = "Depth" +"Describe how and when to use this component" = "Describe how and when to use this component" +"Describe what changed" = "Describe what changed" +"Description" = "Description" +"Description (optional)" = "Description (optional)" +"Design" = "Design" +"Desktop" = "Desktop" +"Destination" = "Destination" +"Detach alias" = "Detach alias" +"Detach instance" = "Detach instance" +"Detach property" = "Detach property" +"Detach style" = "Detach style" +"Detach styles" = "Detach styles" +"Detach variable" = "Detach variable" +"Details" = "Details" +"Dev Mode" = "Dev Mode" +"Development" = "Development" +"Device" = "Device" +"Device at 100%" = "Device at 100%" +"Diamond" = "Diamond" +"Difference" = "Difference" +"Dimensions" = "Dimensions" +"Direction" = "Direction" +"Disconnect from code" = "Disconnect from code" +"Dispersion" = "Dispersion" +"Display name" = "Display name" +"Dissolve" = "Dissolve" +"Distribute and tidy" = "Distribute and tidy" +"Distribute horizontal spacing" = "Distribute horizontal spacing" +"Distribute vertical spacing" = "Distribute vertical spacing" +"Don't truncate" = "Don't truncate" +"Done" = "Done" +"Done editing" = "Done editing" +"Dots" = "Dots" +"Down" = "Down" +"Drafts" = "Drafts" +"Drag the stroke to add a width point" = "Drag the stroke to add a width point" +"Draw" = "Draw" +"Drawing tools" = "Drawing tools" +"Drop shadow" = "Drop shadow" +"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." +"Duo" = "Duo" +"Duplicate" = "Duplicate" +"Duplicate by dragging" = "Duplicate by dragging" +"Duplicate page" = "Duplicate page" +"Edit" = "Edit" +"Edit as an expression" = "Edit as an expression" +"Edit colour" = "Edit colour" +"Edit comment" = "Edit comment" +"Edit link" = "Edit link" +"Edit link copied" = "Edit link copied" +"Edit style" = "Edit style" +"Edit variable" = "Edit variable" +"Edit with the builder" = "Edit with the builder" +"Effect settings" = "Effect settings" +"Effect styles" = "Effect styles" +"Effects" = "Effects" +"Ellipse" = "Ellipse" +"Ellipses" = "Ellipses" +"Else" = "Else" +"Else if" = "Else if" +"Empty" = "Empty" +"End" = "End" +"End point" = "End point" +"Enter to save · Esc to cancel" = "Enter to save · Esc to cancel" +"Eraser" = "Eraser" +"Esc" = "Esc" +"Essential" = "Essential" +"Every conflict has a choice." = "Every conflict has a choice." +"Every top-level frame already starts a flow" = "Every top-level frame already starts a flow" +"Exact opacity (4 then 5: 45%)" = "Exact opacity (4 then 5: 45%)" +"Exclude" = "Exclude" +"Excludes" = "Excludes" +"Exclusion" = "Exclusion" +"Exit focus view (Esc)" = "Exit focus view (Esc)" +"Expand" = "Expand" +"Export" = "Export" +"Export all" = "Export all" +"Export page as PNG" = "Export page as PNG" +"Export variables" = "Export variables" +"Export {name}" = "Export {name}" +"Export {n} layers" = { one = "Export {n} layer", other = "Export {n} layers" } +"Exported {file}" = "Exported {file}" +"Exported {name}" = "Exported {name}" +"Exported {n} files" = { one = "Exported {n} file", other = "Exported {n} files" } +"Export…" = "Export…" +"Expose properties to instances" = "Expose properties to instances" +"Exposure" = "Exposure" +"Expression" = "Expression" +"Extend collection" = "Extend collection" +"Extends \"{name}\": overridden values are marked" = "Extends \"{name}\": overridden values are marked" +"Eye" = "Eye" +"Fade" = "Fade" +"False" = "False" +"Fetching from the server…" = "Fetching from the server…" +"File" = "File" +"File name" = "File name" +"Fill" = "Fill" +"Fill (fr)" = "Fill (fr)" +"Fill container" = "Fill container" +"Fill window" = "Fill window" +"Fills" = "Fills" +"Fill|sizing" = "Fill" +"Filter and sort" = "Filter and sort" +"Find" = "Find" +"Find and replace" = "Find and replace" +"Find layers" = "Find layers" +"First" = "First" +"First commit" = "First commit" +"Fit" = "Fit" +"Fit device to window" = "Fit device to window" +"Fit to contents" = "Fit to contents" +"Fit to window" = "Fit to window" +"Fit width" = "Fit width" +"Fixed" = "Fixed" +"Fixed gap" = "Fixed gap" +"Fixed size" = "Fixed size" +"Flatten" = "Flatten" +"Flip" = "Flip" +"Flip horizontal" = "Flip horizontal" +"Flip text to the other side" = "Flip text to the other side" +"Flip vertical" = "Flip vertical" +"Flip width profile" = "Flip width profile" +"Flow starting point" = "Flow starting point" +"Flow starting points" = "Flow starting points" +"Flows" = "Flows" +"Focus view" = "Focus view" +"Fold autosaves" = "Fold autosaves" +"Follow {name}" = "Follow {name}" +"Following" = "Following" +"Following {name} · {key} to stop" = "Following {name} · {key} to stop" +"Font" = "Font" +"Font not installed" = "Font not installed" +"Font size" = "Font size" +"Font style" = "Font style" +"Fractions" = "Fractions" +"Frame" = "Frame" +"Frame selection" = "Frame selection" +"Frame tools" = "Frame tools" +"Frames" = "Frames" +"From the library \"{name}\": read-only" = "From the library \"{name}\": read-only" +"Frost" = "Frost" +"Gap" = "Gap" +"Gap between items" = "Gap between items" +"Gap between rows" = "Gap between rows" +"Glass" = "Glass" +"Go to main component" = "Go to main component" +"Go to {name}" = "Go to {name}" +"Grab bag" = "Grab bag" +"Gradient" = "Gradient" +"Gray" = "Gray" +"Green" = "Green" +"Grid" = "Grid" +"Grid ({size})" = "Grid ({size})" +"Grid styles" = "Grid styles" +"Grid {size}" = "Grid {size}" +"Group" = "Group" +"Group selection" = "Group selection" +"Groups" = "Groups" +"Gutter" = "Gutter" +"Hand tool" = "Hand tool" +"Hard light" = "Hard light" +"Height" = "Height" +"Help" = "Help" +"Help and resources" = "Help and resources" +"Help page" = "Help page" +"Hidden" = "Hidden" +"Hide contents" = "Hide contents" +"Hide from publishing" = "Hide from publishing" +"Hide section contents" = "Hide section contents" +"Highlight layers on hover" = "Highlight layers on hover" +"Highlights" = "Highlights" +"Hold Alt" = "Hold Alt" +"Hold Alt+Z" = "Hold Alt+Z" +"Hold Z" = "Hold Z" +"Hold ⌥" = "Hold ⌥" +"Hold ⌥Z" = "Hold ⌥Z" +"Home" = "Home" +"Horizontal" = "Horizontal" +"Horizontal hex" = "Horizontal hex" +"Horizontal padding" = "Horizontal padding" +"Horizontal spacing" = "Horizontal spacing" +"Hosting a session needs the desktop app; here you can join one." = "Hosting a session needs the desktop app; here you can join one." +"Hover a layer to see its properties" = "Hover a layer to see its properties" +"Hover other layers to measure the distance" = "Hover other layers to measure the distance" +"Hue" = "Hue" +"Hug" = "Hug" +"Hug contents" = "Hug contents" +"If" = "If" +"If {condition}" = "If {condition}" +"Ignore auto layout (absolute position)" = "Ignore auto layout (absolute position)" +"Image" = "Image" +"Import" = "Import" +"Import path" = "Import path" +"Import plugin from manifest" = "Import plugin from manifest" +"Import plugin from manifest…" = "Import plugin from manifest…" +"Import variables" = "Import variables" +"Imported {name}" = "Imported {name}" +"Imported {n} new variables, updated {updated}" = { one = "Imported {n} new variable, updated {updated}", other = "Imported {n} new variables, updated {updated}" } +"Import…" = "Import…" +"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)." = "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)." +"Individual corners" = "Individual corners" +"Individual strokes" = "Individual strokes" +"Ink" = "Ink" +"Inner shadow" = "Inner shadow" +"Inside" = "Inside" +"Inspect" = "Inspect" +"Inspect the layer under the pointer: size, colours, typography" = "Inspect the layer under the pointer: size, colours, typography" +"Instance" = "Instance" +"Instance properties" = "Instance properties" +"Instance swap" = "Instance swap" +"Instance swap (no other component)" = "Instance swap (no other component)" +"Instances" = "Instances" +"Instant" = "Instant" +"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." +"Intersect" = "Intersect" +"Intersects" = "Intersects" +"Invite link" = "Invite link" +"Invite link copied" = "Invite link copied" +"Join" = "Join" +"Join a session…" = "Join a session…" +"Join live session" = "Join live session" +"Join us on Discord" = "Join us on Discord" +"Joined" = "Joined" +"Joined {file}" = "Joined {file}" +"Joining…" = "Joining…" +"Join|stroke" = "Join" +"Just now" = "Just now" +"Justify" = "Justify" +"Keep appearance" = "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." = "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." +"Keep tool selected after use" = "Keep tool selected after use" +"Keep values" = "Keep values" +"Key/Gamepad" = "Key/Gamepad" +"Key/Gamepad {key}" = "Key/Gamepad {key}" +"Keyboard shortcuts" = "Keyboard shortcuts" +"Language" = "Language" +"Lasso" = "Lasso" +"Last" = "Last" +"Last change: {note}" = "Last change: {note}" +"Last commit" = "Last commit" +"Layer blur" = "Layer blur" +"Layer name" = "Layer name" +"Layers" = "Layers" +"Layout" = "Layout" +"Layout grid" = "Layout grid" +"Layout grid settings" = "Layout grid settings" +"Layout grids" = "Layout grids" +"Leave session" = "Leave session" +"Leaves" = "Leaves" +"Left" = "Left" +"Left & right" = "Left & right" +"Left side" = "Left side" +"Left side width" = "Left side width" +"Letter case" = "Letter case" +"Letter spacing" = "Letter spacing" +"Libraries" = "Libraries" +"Libraries in this file" = "Libraries in this file" +"Library updates available" = "Library updates available" +"Library: click to collapse" = "Library: click to collapse" +"Library: click to expand" = "Library: click to expand" +"Library: its variables are read-only here; edit them in the library file and publish it" = "Library: its variables are read-only here; edit them in the library file and publish it" +"Light" = "Light" +"Lighten" = "Lighten" +"Light|glass" = "Light" +"Line" = "Line" +"Line arrow" = "Line arrow" +"Line delta" = "Line delta" +"Line height" = "Line height" +"Linear" = "Linear" +"Lines" = "Lines" +"Lines +/−" = "Lines +/−" +"Lines added" = "Lines added" +"Lines deleted" = "Lines deleted" +"Link" = "Link" +"Link copied" = "Link copied" +"Link this component to its code component: instances then show your real snippet with their values." = "Link this component to its code component: instances then show your real snippet with their values." +"List style" = "List style" +"Live session" = "Live session" +"Live · {people}" = "Live · {people}" +"Local components" = "Local components" +"Local styles" = "Local styles" +"Local variables" = "Local variables" +"Lock/Unlock" = "Lock/Unlock" +"Locked" = "Locked" +"Lowercase" = "Lowercase" +"Luminance" = "Luminance" +"Luminosity" = "Luminosity" +"Main" = "Main" +"Main component deleted" = "Main component deleted" +"Main menu" = "Main menu" +"Make slot" = "Make slot" +"Margin" = "Margin" +"Mark all as read" = "Mark all as read" +"Mark as resolved" = "Mark as resolved" +"Mark as unread" = "Mark as unread" +"Mark as unresolved" = "Mark as unresolved" +"Mask" = "Mask" +"Mass · Stiffness · Damping" = "Mass · Stiffness · Damping" +"Max H" = "Max H" +"Max W" = "Max W" +"Max lines" = "Max lines" +"Maximize" = "Maximize" +"Measure to the selection" = "Measure to the selection" +"Merge into main" = "Merge into main" +"Merge regions" = "Merge regions" +"Merge the branch into the main file (one undo step) and archive it" = "Merge the branch into the main file (one undo step) and archive it" +"Merged" = "Merged" +"Merged PRs" = "Merged PRs" +"Min H" = "Min H" +"Min W" = "Min W" +"Minimize" = "Minimize" +"Mirror angle" = "Mirror angle" +"Mirror angle and length" = "Mirror angle and length" +"Mirroring" = "Mirroring" +"Missing" = "Missing" +"Missing layer" = "Missing layer" +"Missing style" = "Missing style" +"Missing variable" = "Missing variable" +"Miter" = "Miter" +"Miter angle" = "Miter angle" +"Mixed" = "Mixed" +"Mixed fonts" = "Mixed fonts" +"Mixed interactions: click + to replace them" = "Mixed interactions: click + to replace them" +"Mode" = "Mode" +"Model" = "Model" +"Models" = "Models" +"Mono" = "Mono" +"More" = "More" +"More instance actions" = "More instance actions" +"Mouse down" = "Mouse down" +"Mouse enter" = "Mouse enter" +"Mouse leave" = "Mouse leave" +"Mouse up" = "Mouse up" +"Move" = "Move" +"Move down" = "Move down" +"Move in" = "Move in" +"Move out" = "Move out" +"Move tools" = "Move tools" +"Move up" = "Move up" +"Moved to {name}" = "Moved to {name}" +"Multi" = "Multi" +"Multiply" = "Multiply" +"Muted" = "Muted" +"Name" = "Name" +"Name (A–Z)" = "Name (A–Z)" +"Named instance" = "Named instance" +"Navigate to" = "Navigate to" +"Nested instance" = "Nested instance" +"New design file" = "New design file" +"Next or previous control in the focused region" = "Next or previous control in the focused region" +"Next ready for dev" = "Next ready for dev" +"Next size option" = "Next size option" +"No boolean variables fit here." = "No boolean variables fit here." +"No boolean variables yet. Create them in Local variables." = "No boolean variables yet. Create them in Local variables." +"No branches. A branch is a copy of the file to try changes in, then review and merge." = "No branches. A branch is a copy of the file to try changes in, then review and merge." +"No category" = "No category" +"No changes" = "No changes" +"No colour variables fit here." = "No colour variables fit here." +"No colour variables yet. Create them in Local variables." = "No colour variables yet. Create them in Local variables." +"No comments yet" = "No comments yet" +"No components yet. Select layers and press {keys}." = "No components yet. Select layers and press {keys}." +"No conflicts." = "No conflicts." +"No contributor data was built into this copy." = "No contributor data was built into this copy." +"No export settings. Add them in the Design tab." = "No export settings. Add them in the Design tab." +"No fonts match" = "No fonts match" +"No layer changes to show." = "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." = "No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." +"No list" = "No list" +"No matching comments" = "No matching comments" +"No matching components" = "No matching components" +"No matching layers" = "No matching layers" +"No matching styles" = "No matching styles" +"No matching variables" = "No matching variables" +"No mirroring" = "No mirroring" +"No model credits were built into this copy." = "No model credits were built into this copy." +"No number variables fit here." = "No number variables fit here." +"No number variables yet. Create them in Local variables." = "No number variables yet. Create them in Local variables." +"No paints" = "No paints" +"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "No plugins yet. Import a development plugin from its figcraft-plugin.json." +"No preferred components" = "No preferred components" +"No preset" = "No preset" +"No saved versions yet. Save a version to mark a point in your work you can come back to." = "No saved versions yet. Save a version to mark a point in your work you can come back to." +"No scrolling" = "No scrolling" +"No shortcuts match" = "No shortcuts match" +"No status" = "No status" +"No string variables fit here." = "No string variables fit here." +"No string variables yet. Create them in Local variables." = "No string variables yet. Create them in Local variables." +"No styles yet." = "No styles yet." +"No styles yet. Click + to create one from the selection." = "No styles yet. Click + to create one from the selection." +"No text styles yet. Click + to create one from the selected text." = "No text styles yet. Click + to create one from the selected text." +"No variables fit here." = "No variables fit here." +"No variables yet. Create them in Local variables." = "No variables yet. Create them in Local variables." +"Nobody else is here yet" = "Nobody else is here yet" +"Noise" = "Noise" +"None" = "None" +"None." = "None." +"Normal" = "Normal" +"Not bound" = "Not bound" +"Not in a live session" = "Not in a live session" +"Not in this font" = "Not in this font" +"Not properties, kept as written: {names}" = "Not properties, kept as written: {names}" +"Not read yet" = "Not read yet" +"Note {n}" = "Note {n}" +"Nothing changed since this was marked ready for dev." = "Nothing changed since this was marked ready for dev." +"Nothing here yet. Set a frame's status to list it." = "Nothing here yet. Set a frame's status to list it." +"Nothing to export" = "Nothing to export" +"Now" = "Now" +"Nudge (Shift: big nudge)" = "Nudge (Shift: big nudge)" +"Nudge amount" = "Nudge amount" +"Number" = "Number" +"Numbered list" = "Numbered list" +"Object" = "Object" +"Off" = "Off" +"Offered first when filling the slot (any content is allowed)" = "Offered first when filling the slot (any content is allowed)" +"Offered first when swapping the instance (any component is allowed)" = "Offered first when swapping the instance (any component is allowed)" +"Offset" = "Offset" +"On click" = "On click" +"On drag" = "On drag" +"On the server's machine, `figcraft-server key` prints it." = "On the server's machine, `figcraft-server key` prints it." +"On this page" = "On this page" +"Only the first changed layers are listed." = "Only the first changed layers are listed." +"Only the first {n} changed layers are listed." = "Only the first {n} changed layers are listed." +"Only this computer can join (turn on network sharing to invite others)." = "Only this computer can join (turn on network sharing to invite others)." +"Only your threads" = "Only your threads" +"Opacity" = "Opacity" +"Opacity & blend" = "Opacity & blend" +"Opacity 10%…90%, 0: 100%" = "Opacity 10%…90%, 0: 100%" +"Open" = "Open" +"Open .figcraft, .svg or images" = "Open .figcraft, .svg or images" +"Open branch" = "Open branch" +"Open file" = "Open file" +"Open files" = "Open files" +"Open in focus view" = "Open in focus view" +"Open invite links in FigCraft" = "Open invite links in FigCraft" +"Open link" = "Open link" +"Open link {url}" = "Open link {url}" +"Open overlay" = "Open overlay" +"Open prototype preview" = "Open prototype preview" +"Open variables ({count})" = "Open variables ({count})" +"Opened “{name}” in a new tab" = "Opened “{name}” in a new tab" +"Open|comments" = "Open" +"Options" = "Options" +"Or keep the file on a FigCraft server and share links to edit or to view it." = "Or keep the file on a FigCraft server and share links to edit or to view it." +"Orange" = "Orange" +"Outline stroke" = "Outline stroke" +"Outlines" = "Outlines" +"Outside" = "Outside" +"Overflow" = "Overflow" +"Overlay" = "Overlay" +"Overlay position" = "Overlay position" +"Overridden here (the collection it extends has another value)" = "Overridden here (the collection it extends has another value)" +"Overrides" = "Overrides" +"PDF print options" = "PDF print options" +"PDF print settings" = "PDF print settings" +"Padding" = "Padding" +"Page" = "Page" +"Pages" = "Pages" +"Pages: {pages}" = "Pages: {pages}" +"Paint bucket" = "Paint bucket" +"Pan the canvas" = "Pan the canvas" +"Panels" = "Panels" +"Paper" = "Paper" +"Paragraph indent" = "Paragraph indent" +"Paragraph spacing" = "Paragraph spacing" +"Pass through" = "Pass through" +"Paste a team library link (a view link)" = "Paste a team library link (a view link)" +"Paste here" = "Paste here" +"Paste the invite link the host shared with you." = "Paste the invite link the host shared with you." +"Paste to replace" = "Paste to replace" +"Pattern" = "Pattern" +"Pattern alignment" = "Pattern alignment" +"Pattern scale" = "Pattern scale" +"Pattern source" = "Pattern source" +"Pen" = "Pen" +"Pencil" = "Pencil" +"People in this file" = "People in this file" +"Phone" = "Phone" +"Pick color" = "Pick color" +"Pick color (I)" = "Pick color (I)" +"Pick one font family first" = "Pick one font family first" +"Pin a property" = "Pin a property" +"Pinch" = "Pinch" +"Pixel grid" = "Pixel grid" +"Pixel preview 1x" = "Pixel preview 1x" +"Pixel preview 2x" = "Pixel preview 2x" +"Place automatically" = "Place automatically" +"Place image" = "Place image" +"Plugins" = "Plugins" +"Plus darker" = "Plus darker" +"Plus lighter" = "Plus lighter" +"Point" = "Point" +"Points" = "Points" +"Polygon" = "Polygon" +"Polygons" = "Polygons" +"Position" = "Position" +"Post" = "Post" +"Preferences" = "Preferences" +"Preferred" = "Preferred" +"Present" = "Present" +"Present or preview" = "Present or preview" +"Present this flow" = "Present this flow" +"Presentation" = "Presentation" +"Preserve scroll position" = "Preserve scroll position" +"Presets" = "Presets" +"Press a key…" = "Press a key…" +"Preview" = "Preview" +"Preview this flow" = "Preview this flow" +"Preview this frame" = "Preview this frame" +"Previous ready for dev" = "Previous ready for dev" +"Progressive" = "Progressive" +"Properties" = "Properties" +"Prototype" = "Prototype" +"Prototype preview" = "Prototype preview" +"Prototype settings" = "Prototype settings" +"Publish" = "Publish" +"Publish changes" = "Publish changes" +"Publish to server" = "Publish to server" +"Publish to server…" = "Publish to server…" +"Published version {v}" = "Published version {v}" +"Published version {v} to the server" = "Published version {v} to the server" +"Published version {v}: save the file so other files can use it" = "Published version {v}: save the file so other files can use it" +"Published · {people}" = "Published · {people}" +"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Publishing to a server needs the desktop app; in the browser you can join a server's files" +"Publishing…" = "Publishing…" +"Push" = "Push" +"Push changes to main component" = "Push changes to main component" +"Put text on path" = "Put text on path" +"Radial" = "Radial" +"Radius" = "Radius" +"Ratio" = "Ratio" +"Reading the library…" = "Reading the library…" +"Ready for dev" = "Ready for dev" +"Real name" = "Real name" +"Recent" = "Recent" +"Recent files" = "Recent files" +"Recents" = "Recents" +"Rectangle" = "Rectangle" +"Rectangles" = "Rectangles" +"Red" = "Red" +"Reduce motion" = "Reduce motion" +"Refraction" = "Refraction" +"Remove" = "Remove" +"Remove all" = "Remove all" +"Remove annotation" = "Remove annotation" +"Remove auto layout" = "Remove auto layout" +"Remove export setting" = "Remove export setting" +"Remove interaction" = "Remove interaction" +"Remove library from this file" = "Remove library from this file" +"Remove min and max" = "Remove min and max" +"Remove min and max height" = "Remove min and max height" +"Remove min and max width" = "Remove min and max width" +"Remove regions" = "Remove regions" +"Remove slot" = "Remove slot" +"Remove starting point" = "Remove starting point" +"Remove stop" = "Remove stop" +"Remove text from path" = "Remove text from path" +"Remove the else and its actions" = "Remove the else and its actions" +"Remove this condition and its actions" = "Remove this condition and its actions" +"Removed" = "Removed" +"Removed since it was marked" = "Removed since it was marked" +"Rename" = "Rename" +"Rename duplicated layers" = "Rename duplicated layers" +"Rename group" = "Rename group" +"Rename mode" = "Rename mode" +"Rename page" = "Rename page" +"Replace all" = "Replace all" +"Replace mixed export settings" = "Replace mixed export settings" +"Replace with" = "Replace with" +"Replace with a new interaction" = "Replace with a new interaction" +"Reply" = "Reply" +"Reset" = "Reset" +"Reset adjustments" = "Reset adjustments" +"Reset all changes" = "Reset all changes" +"Reset all overrides on this layer" = "Reset all overrides on this layer" +"Reset overrides" = "Reset overrides" +"Reset slot" = "Reset slot" +"Reset this override" = "Reset this override" +"Reset to style" = "Reset to style" +"Reset to the inherited value" = "Reset to the inherited value" +"Resize by the nudge amount" = "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 frames to the device's screen (or the window without a device): their constraints and auto layout respond" +"Resize to fit" = "Resize to fit" +"Resolve" = "Resolve" +"Resolve change" = "Resolve change" +"Resolved" = "Resolved" +"Resources" = "Resources" +"Responsive" = "Responsive" +"Restart" = "Restart" +"Restore" = "Restore" +"Restore main component" = "Restore main component" +"Restore this version" = "Restore this version" +"Restored “{name}”" = "Restored “{name}”" +"Reverse the order" = "Reverse the order" +"Reversed triangle" = "Reversed triangle" +"Review and merge" = "Review and merge" +"Ribbon" = "Ribbon" +"Right" = "Right" +"Right side" = "Right side" +"Right side width" = "Right side width" +"Rotate 90°" = "Rotate 90°" +"Rotate 90° right" = "Rotate 90° right" +"Rotate to landscape" = "Rotate to landscape" +"Rotate to portrait" = "Rotate to portrait" +"Rotation" = "Rotation" +"Rotation jitter" = "Rotation jitter" +"Round" = "Round" +"Rounded" = "Rounded" +"Row" = "Row" +"Row gap" = "Row gap" +"Row sizes" = "Row sizes" +"Row span" = "Row span" +"Rows" = "Rows" +"Rows {count}" = "Rows {count}" +"Rulers" = "Rulers" +"Run" = "Run" +"Run {label}" = "Run {label}" +"Saturation" = "Saturation" +"Save" = "Save" +"Save as" = "Save as" +"Save as preset…" = "Save as preset…" +"Save version {key}" = "Save version {key}" +"Saved" = "Saved" +"Saved {name}" = "Saved {name}" +"Scale" = "Scale" +"Scatter" = "Scatter" +"Scoping: where pickers offer it" = "Scoping: where pickers offer it" +"Screen" = "Screen" +"Scroll behavior" = "Scroll behavior" +"Scroll to" = "Scroll to" +"Scroll to layer" = "Scroll to layer" +"Scroll with parent" = "Scroll with parent" +"Search" = "Search" +"Search actions, layers and tools" = "Search actions, layers and tools" +"Search assets" = "Search assets" +"Search comments" = "Search comments" +"Search fonts" = "Search fonts" +"Search layers" = "Search layers" +"Search shortcuts" = "Search shortcuts" +"Section" = "Section" +"Section color" = "Section color" +"Sections" = "Sections" +"Select a frame and add a starting point to present it as a flow." = "Select a frame and add a starting point to present it as a flow." +"Select a frame to preview it" = "Select a frame to preview it" +"Select a layer to see its properties and code" = "Select a layer to see its properties and code" +"Select all" = "Select all" +"Select all with same" = "Select all with same" +"Select layer" = "Select layer" +"Select layers to apply the style to" = "Select layers to apply the style to" +"Select overlapping shapes" = "Select overlapping shapes" +"Select points to edit their position, radius and mirroring" = "Select points to edit their position, radius and mirroring" +"Select slot" = "Select slot" +"Selection" = "Selection" +"Send backward" = "Send backward" +"Send to back" = "Send to back" +"Server address" = "Server address" +"Set variable" = "Set variable" +"Set variable mode" = "Set variable mode" +"Set variable {name}" = "Set variable {name}" +"Set {name} to {value}" = "Set {name} to {value}" +"Shadows" = "Shadows" +"Shape" = "Shape" +"Shape builder" = "Shape builder" +"Shape tools" = "Shape tools" +"Share" = "Share" +"Share on the local network" = "Share on the local network" +"Share: live session, export" = "Share: live session, export" +"Show" = "Show" +"Show as grid" = "Show as grid" +"Show as list" = "Show as list" +"Show autosaves" = "Show autosaves" +"Show behind transparent areas" = "Show behind transparent areas" +"Show comments (Shift+C)" = "Show comments (Shift+C)" +"Show device frame" = "Show device frame" +"Show dimensions on objects" = "Show dimensions on objects" +"Show hotspot hints" = "Show hotspot hints" +"Show only this on the canvas" = "Show only this on the canvas" +"Show open" = "Show open" +"Show section contents" = "Show section contents" +"Show this layer" = "Show this layer" +"Show usage" = "Show usage" +"Show/Hide" = "Show/Hide" +"Show/Hide UI" = "Show/Hide UI" +"Side by side" = "Side by side" +"Sidebar" = "Sidebar" +"Size" = "Size" +"Size jitter" = "Size jitter" +"Slashed zero" = "Slashed zero" +"Slice" = "Slice" +"Slices" = "Slices" +"Slide in" = "Slide in" +"Slide out" = "Slide out" +"Slot" = "Slot" +"Slot content" = "Slot content" +"Small capitals" = "Small capitals" +"Small caps" = "Small caps" +"Smart animate" = "Smart animate" +"Snap to pixel grid" = "Snap to pixel grid" +"Social media" = "Social media" +"Soft light" = "Soft light" +"Solid" = "Solid" +"Sort" = "Sort" +"Sort by date" = "Sort by date" +"Sort by page" = "Sort by page" +"Space drag" = "Space drag" +"Space+drag" = "Space+drag" +"Spacing" = "Spacing" +"Splay" = "Splay" +"Spread" = "Spread" +"Square" = "Square" +"Standard ligatures" = "Standard ligatures" +"Star" = "Star" +"Stars" = "Stars" +"Start" = "Start" +"Start from a blank canvas" = "Start from a blank canvas" +"Start live session" = "Start live session" +"Start on path" = "Start on path" +"Start point" = "Start point" +"Status" = "Status" +"Sticky" = "Sticky" +"Stop following {name}" = "Stop following {name}" +"Stop session" = "Stop session" +"Stop {n} {hex}" = "Stop {n} {hex}" +"Stretch" = "Stretch" +"Strikethrough" = "Strikethrough" +"String" = "String" +"Stroke" = "Stroke" +"Stroke style" = "Stroke style" +"Stroke weight" = "Stroke weight" +"Strokes" = "Strokes" +"Style" = "Style" +"Styles" = "Styles" +"Stylistic set 1" = "Stylistic set 1" +"Stylistic set 2" = "Stylistic set 2" +"Stylistic set 3" = "Stylistic set 3" +"Stylistic set 4" = "Stylistic set 4" +"Stylistic set 5" = "Stylistic set 5" +"Subtract" = "Subtract" +"Subtracts" = "Subtracts" +"Suffix" = "Suffix" +"Swap instance" = "Swap instance" +"Swap library: use another library's assets of the same names" = "Swap library: use another library's assets of the same names" +"Swap orientation" = "Swap orientation" +"Swap overlay" = "Swap overlay" +"Swap to {name}" = "Swap to {name}" +"Swap width and height" = "Swap width and height" +"Swapped instance" = "Swapped instance" +"Swapped {n} assets" = { one = "Swapped {n} asset", other = "Swapped {n} assets" } +"Swapped {n} assets; {missed} had no match and still use the old library" = { one = "Swapped {n} asset; {missed} had no match and still use the old library", other = "Swapped {n} assets; {missed} had no match and still use the old library" } +"Sweep" = "Sweep" +"System" = "System" +"Tab / Shift+Tab" = "Tab / Shift+Tab" +"Table" = "Table" +"Tablet" = "Tablet" +"Tabular numbers" = "Tabular numbers" +"Take the values from the first selected layer" = "Take the values from the first selected layer" +"Taper" = "Taper" +"Taper both ends" = "Taper both ends" +"Temperature" = "Temperature" +"Template" = "Template" +"Text" = "Text" +"Text align" = "Text align" +"Text content" = "Text content" +"Text style" = "Text style" +"Text styles" = "Text styles" +"Texts" = "Texts" +"Texture" = "Texture" +"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." +"The file now holds another library" = "The file now holds another library" +"The file now holds another library (\"{name}\")" = "The file now holds another library (\"{name}\")" +"The link carries the session's secret: share it only with people you invite." = "The link carries the session's secret: share it only with people you invite." +"The selection uses several text styles: click to apply one" = "The selection uses several text styles: click to apply one" +"Theme" = "Theme" +"There's no destination for that action" = "There's no destination for that action" +"This branch can't be reviewed: {e}" = "This branch can't be reviewed: {e}" +"This branch was merged or archived." = "This branch was merged or archived." +"This condition is edited as an expression" = "This condition is edited as an expression" +"This file" = "This file" +"This file has unsaved changes" = "This file has unsaved changes" +"This frame is new on the branch." = "This frame is new on the branch." +"This frame isn't on the branch." = "This frame isn't on the branch." +"Tidy up" = "Tidy up" +"Tile" = "Tile" +"Tile type" = "Tile type" +"Tint" = "Tint" +"Title" = "Title" +"Title case" = "Title case" +"To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." +"Toggle" = "Toggle" +"Toggle light/dark theme" = "Toggle light/dark theme" +"Toggle visibility" = "Toggle visibility" +"Tools" = "Tools" +"Top" = "Top" +"Top & bottom" = "Top & bottom" +"Top left radius" = "Top left radius" +"Top right radius" = "Top right radius" +"Transform" = "Transform" +"Triangle arrow" = "Triangle arrow" +"Trigger" = "Trigger" +"True" = "True" +"Truncate text" = "Truncate text" +"Truncate with an ellipsis" = "Truncate with an ellipsis" +"Try another search or filter." = "Try another search or filter." +"Two digits" = "Two digits" +"Type" = "Type" +"Type settings" = "Type settings" +"Typography" = "Typography" +"Underline" = "Underline" +"Undo takes back only your own changes; what others changed since stays." = "Undo takes back only your own changes; what others changed since stays." +"Ungroup" = "Ungroup" +"Uniform" = "Uniform" +"Union" = "Union" +"Unions" = "Unions" +"Unread" = "Unread" +"Unresolve" = "Unresolve" +"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" +"Unsaved changes: save first, or close again with {key} to discard" = "Unsaved changes: save first, or close again with {key} to discard" +"Up" = "Up" +"Update" = "Update" +"Update from main" = "Update from main" +"Update from selection" = "Update from selection" +"Updated {components} and {styles}" = "Updated {components} and {styles}" +"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." = "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." +"Uppercase" = "Uppercase" +"Use a fixed value" = "Use a fixed value" +"Use an expression, e.g. count + 1" = "Use an expression, e.g. count + 1" +"Use as mask" = "Use as mask" +"Use auto layout" = "Use auto layout" +"Use number keys for opacity" = "Use number keys for opacity" +"Use the layer's fill" = "Use the layer's fill" +"Used in {files} · {instances}" = "Used in {files} · {instances}" +"Username" = "Username" +"Value" = "Value" +"Variable" = "Variable" +"Variable collections" = "Variable collections" +"Variable modes" = "Variable modes" +"Variable width" = "Variable width" +"Variables" = "Variables" +"Variables JSON…" = "Variables JSON…" +"Variables store reusable values (colours, numbers, strings, booleans) with modes such as Light and Dark." = "Variables store reusable values (colours, numbers, strings, booleans) with modes such as Light and Dark." +"Variant" = "Variant" +"Vector" = "Vector" +"Vectors" = "Vectors" +"Version" = "Version" +"Version history" = "Version history" +"Version {version}" = "Version {version}" +"Version {v} · {components} · {styles} · {variables}" = "Version {v} · {components} · {styles} · {variables}" +"Vertical" = "Vertical" +"Vertical hex" = "Vertical hex" +"Vertical padding" = "Vertical padding" +"Vertical spacing" = "Vertical spacing" +"View" = "View" +"View link" = "View link" +"View link copied" = "View link copied" +"View only" = "View only" +"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." +"Viewing “{name}”" = "Viewing “{name}”" +"Violet" = "Violet" +"Visibility" = "Visibility" +"Visible" = "Visible" +"Vivid" = "Vivid" +"Warm" = "Warm" +"Watch" = "Watch" +"WebSocket URL" = "WebSocket URL" +"WebSocket URL copied" = "WebSocket URL copied" +"Wedge" = "Wedge" +"Weight" = "Weight" +"What changed since it was marked ready for dev" = "What changed since it was marked ready for dev" +"What it's for" = "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." = "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." +"While hovering" = "While hovering" +"While pressing" = "While pressing" +"White" = "White" +"Why it changed" = "Why it changed" +"Width" = "Width" +"Width point" = "Width point" +"Width profile" = "Width profile" +"With the layers' export settings" = "With the layers' export settings" +"Wrap" = "Wrap" +"Wrap in new section" = "Wrap in new section" +"Yellow" = "Yellow" +"Yesterday" = "Yesterday" +"You" = "You" +"You'll get the host's file as a new tab and edit it together." = "You'll get the host's file as a new tab and edit it together." +"Your copy of the file stays open" = "Your copy of the file stays open" +"Your name" = "Your name" +"Your name in comments" = "Your name in comments" +"Your system asks for reduced motion: animations stay off." = "Your system asks for reduced motion: animations stay off." +"Zoom" = "Zoom" +"Zoom around the pointer" = "Zoom around the pointer" +"Zoom device to fill window" = "Zoom device to fill window" +"Zoom in" = "Zoom in" +"Zoom out" = "Zoom out" +"Zoom to 100%" = "Zoom to 100%" +"Zoom to 200%" = "Zoom to 200%" +"Zoom to 50%" = "Zoom to 50%" +"Zoom to fit" = "Zoom to fit" +"Zoom to selection" = "Zoom to selection" +"Zoom tool: click to zoom in, drag an area" = "Zoom tool: click to zoom in, drag an area" +"Zoom tool: zoom out" = "Zoom tool: zoom out" +"and {n} more" = { one = "and {n} more", other = "and {n} more" } +"bleed marks" = "bleed marks" +"can edit the document" = "can edit the document" +"crop marks" = "crop marks" +"development: {path}" = "development: {path}" +"e.g. count + 1" = "e.g. count + 1" +"e.g. count >= 3 and not done" = "e.g. count >= 3 and not done" +"figcraft:// links from a browser, chat or email open this app and join the session." = "figcraft:// links from a browser, chat or email open this app and join the session." +"hidden" = "hidden" +"locked" = "locked" +"no bleed" = "no bleed" +"nobody else here yet" = "nobody else here yet" +"now" = "now" +"or" = "or" +"reads the document" = "reads the document" +"the plugin" = "the plugin" +"this session only" = "this session only" +"{instances} in {files}" = "{instances} in {files}" +"{language}: draft translation, waiting for a native speaker's review." = "{language}: draft translation, waiting for a native speaker's review." +"{length} bleed" = "{length} bleed" +"{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})" +"{name} (you)" = "{name} (you)" +"{name} ({layers} layers)" = "{name} ({layers} 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 {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." +"{name} from {path}" = "{name} from {path}" +"{name} · deleted on main" = "{name} · deleted on main" +"{name} · deleted on the branch" = "{name} · deleted on the branch" +"{name} · not published" = "{name} · not published" +"{name} · on a server" = "{name} · on a server" +"{name} · published version {v}" = "{name} · published version {v}" +"{name} · version {v} on the server" = "{name} · version {v} on the server" +"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} by {h}, selected" +"{n} added" = { one = "{n} added", other = "{n} added" } +"{n} autosave versions" = { one = "{n} autosave version", other = "{n} autosave versions" } +"{n} changed" = { one = "{n} changed", other = "{n} changed" } +"{n} columns" = { one = "{n} column", other = "{n} columns" } +"{n} components" = { one = "{n} component", other = "{n} components" } +"{n} components and styles have newer versions" = { one = "{n} component or style has a newer version", other = "{n} components and styles have newer versions" } +"{n} conflicts to resolve" = { one = "{n} conflict to resolve", other = "{n} conflicts to resolve" } +"{n} days ago" = { one = "{n} day ago", other = "{n} days ago" } +"{n} files" = { one = "{n} file", other = "{n} files" } +"{n} hours ago" = { one = "{n} hour ago", other = "{n} hours ago" } +"{n} instances" = { one = "{n} instance", other = "{n} instances" } +"{n} layers" = { one = "{n} layer", other = "{n} layers" } +"{n} layers as one file" = { one = "{n} layer as one file", other = "{n} layers as one file" } +"{n} layers selected" = { one = "{n} layer selected", other = "{n} layers selected" } +"{n} layers · {parts}" = { one = "{n} layer · {parts}", other = "{n} layers · {parts}" } +"{n} matches" = { one = "{n} match", other = "{n} matches" } +"{n} minutes ago" = { one = "{n} minute ago", other = "{n} minutes ago" } +"{n} months ago" = { one = "{n} month ago", other = "{n} months ago" } +"{n} of {total}" = "{n} of {total}" +"{n} people joined" = { one = "{n} person joined", other = "{n} people joined" } +"{n} preferred components" = { one = "{n} preferred component", other = "{n} preferred components" } +"{n} ready" = { one = "{n} ready", other = "{n} ready" } +"{n} removed" = { one = "{n} removed", other = "{n} removed" } +"{n} replies" = { one = "{n} reply", other = "{n} replies" } +"{n} rows" = { one = "{n} row", other = "{n} rows" } +"{n} styles" = { one = "{n} style", other = "{n} styles" } +"{n} variables" = { one = "{n} variable", other = "{n} variables" } +"{n} weeks ago" = { one = "{n} week ago", other = "{n} weeks ago" } +"{n} years ago" = { one = "{n} year ago", other = "{n} years ago" } +"{n}d" = "{n}d" +"{n}h" = "{n}h" +"{n}m" = "{n}m" +"{n}w" = "{n}w" +"{page}, nothing selected" = "{page}, nothing selected" +"{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." +"{tool} hold {key}" = "{tool} hold {key}" +"{when} · edited" = "{when} · edited" +"← ↑ → ↓" = "← ↑ → ↓" +"⇥ / ⇧⇥" = "⇥ / ⇧⇥" +"⌘ arrows" = "⌘ arrows" +"⌘ click" = "⌘ click" +"⌘ scroll" = "⌘ scroll" +"⌥ drag" = "⌥ drag" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml new file mode 100644 index 0000000..50c1bd7 --- /dev/null +++ b/crates/ui-egui/locales/es.toml @@ -0,0 +1,1825 @@ +# FigCraft interface strings: Spanish. +# +# Draft — needs native review: machine-drafted from the English catalog. Brand names and keyboard +# glyphs stay as they are. See docs/translating.md. + +[meta] +name = "Español" +status = "draft — needs native review" + +[ui] + +"% of all commits" = "% del total de commits" +"(no longer published)" = "(ya no publicado)" +"({n} warnings: some entries were skipped)" = { one = "({n} advertencia: se omitieron algunas entradas)", other = "({n} advertencias: se omitieron algunas entradas)" } +"+ Create variable" = "+ Crear variable" +"1…9, 0" = "1…9, 0" +"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR, {commits} commits, +{added} / −{deleted} líneas (Δ {delta}), +{bin_added} / −{bin_deleted} recursos binarios, {first} – {last}" +"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Una colección de este archivo con estos modos y variables: modifica sus valores por modo y añade variables propias" +"About" = "Acerca de" +"About FigCraft" = "Acerca de FigCraft" +"Action" = "Acción" +"Actions" = "Acciones" +"Actions…" = "Acciones…" +"Active" = "Activa" +"Actual size (100%)" = "Tamaño real (100%)" +"Add a category" = "Añadir una categoría" +"Add a comment" = "Añade un comentario" +"Add a description" = "Añadir una descripción" +"Add a frame (F) or a shape (R) to get started" = "Añade un marco (F) o una forma (R) para empezar" +"Add a layer or component to tile first" = "Añade antes una capa o un componente para el mosaico" +"Add a note for developers" = "Añade una nota para desarrollo" +"Add action" = "Añadir acción" +"Add actions for when no condition holds" = "Añadir acciones para cuando no se cumpla ninguna condición" +"Add an annotation for developers" = "Añade una anotación para desarrollo" +"Add another condition" = "Añadir otra condición" +"Add auto layout" = "Añadir diseño automático" +"Add background behind overlay" = "Añadir fondo tras la superposición" +"Add content to this slot" = "Añadir contenido a esta ranura" +"Add effect" = "Añadir efecto" +"Add export setting" = "Añadir ajuste de exportación" +"Add fill" = "Añadir relleno" +"Add interaction" = "Añadir interacción" +"Add layout grid" = "Añadir cuadrícula de diseño" +"Add library" = "Añadir biblioteca" +"Add library file…" = "Añadir archivo de biblioteca…" +"Add max height" = "Añadir alto máximo" +"Add max width" = "Añadir ancho máximo" +"Add min height" = "Añadir alto mínimo" +"Add min width" = "Añadir ancho mínimo" +"Add mode" = "Añadir modo" +"Add new page" = "Añadir página nueva" +"Add or update collections from a variables JSON" = "Añadir o actualizar colecciones desde un JSON de variables" +"Add properties to make instances configurable" = "Añade propiedades para que las instancias sean configurables" +"Add reaction" = "Añadir reacción" +"Add starting point" = "Añadir punto de inicio" +"Add stroke" = "Añadir trazo" +"Add variant" = "Añadir variante" +"Add variant with a new value" = "Añadir variante con un valor nuevo" +"Added" = "Añadido" +"Added the library \"{name}\"" = "Biblioteca \"{name}\" añadida" +"Adjust" = "Ajustar" +"Adjustment presets" = "Preajustes" +"Advanced stroke" = "Trazo avanzado" +"After delay" = "Tras un retardo" +"After delay {ms}ms" = "Tras {ms}ms" +"Align" = "Alineación" +"Align bottom" = "Alinear abajo" +"Align center" = "Alinear al centro" +"Align horizontal centers" = "Alinear centros horizontales" +"Align left" = "Alinear a la izquierda" +"Align middle" = "Alinear al medio" +"Align right" = "Alinear a la derecha" +"Align text baselines" = "Alinear líneas base del texto" +"Align top" = "Alinear arriba" +"Align vertical centers" = "Alinear centros verticales" +"Alignment" = "Alineación" +"All" = "Todos" +"All categories" = "Todas las categorías" +"All comments" = "Todos los comentarios" +"All components" = "Todos los componentes" +"All fonts" = "Todas las fuentes" +"All supported properties" = "Todas las propiedades compatibles" +"All variables" = "Todas las variables" +"Alpha" = "Alfa" +"Alt+drag" = "Alt+arrastrar" +"Anchor" = "Ancla" +"Angular" = "Angular" +"Animation" = "Animación" +"Annotations" = "Anotaciones" +"Annotations shown on the canvas" = "Anotaciones visibles en el lienzo" +"Any status" = "Cualquier estado" +"Anyone on your network who has the invite link can join." = "Cualquiera en tu red que tenga el enlace de invitación puede unirse." +"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." = "Cualquiera con el enlace de edición puede cambiar el archivo; el enlace de lectura solo permite verlo. Los enlaces llevan sus claves secretas: compártelos como contraseñas. `figcraft-server links` crea más y los revoca." +"Appearance" = "Apariencia" +"Apply a component property" = "Aplicar una propiedad de componente" +"Apply variable" = "Aplicar variable" +"Apply variable to text" = "Aplicar variable al texto" +"Apply variable to visibility" = "Aplicar variable a la visibilidad" +"Arc" = "Arco" +"Arc ratio" = "Proporción del arco" +"Arc start" = "Inicio del arco" +"Arc sweep" = "Barrido del arco" +"Archive" = "Archivar" +"Archived" = "Archivada" +"Arrange" = "Organizar" +"Arrow" = "Flecha" +"As typed" = "Como se escribió" +"Assets" = "Recursos" +"Auto" = "Auto" +"Auto (space between)" = "Auto (espacio entre)" +"Auto ({mode})" = "Auto ({mode})" +"Auto gap" = "Espacio auto" +"Auto height" = "Alto automático" +"Auto layout" = "Diseño automático" +"Auto width" = "Ancho automático" +"Back" = "Atrás" +"Back to Ready for dev" = "Volver a Listo para desarrollo" +"Back to Ready for dev: the next change compares with how it is now" = "Vuelve a Listo para desarrollo: el próximo cambio se compara con cómo está ahora" +"Back to assets" = "Volver a recursos" +"Back to current" = "Volver a la actual" +"Back to styles" = "Volver a estilos" +"Back to the canvas from a focused region" = "Volver al lienzo desde una región enfocada" +"Back to the current version" = "Volver a la versión actual" +"Back to the values the font style implies" = "Volver a los valores del estilo de fuente" +"Background" = "Fondo" +"Background blur" = "Desenfoque de fondo" +"Basics" = "Básico" +"Before" = "Antes" +"Bend" = "Curvar" +"Bevel" = "Bisel" +"Binary assets added" = "Recursos binarios añadidos" +"Binary assets removed" = "Recursos binarios quitados" +"Black & white" = "Blanco y negro" +"Bleed" = "Sangrado" +"Bleed marks" = "Marcas de sangrado" +"Bleed marks need a bleed" = "Las marcas de sangrado necesitan sangrado" +"Blend mode" = "Modo de fusión" +"Blue" = "Azul" +"Blur" = "Desenfoque" +"Boolean" = "Booleano" +"Boolean groups" = "Grupos booleanos" +"Both directions" = "Ambas direcciones" +"Bottom" = "Abajo" +"Bottom left radius" = "Radio inferior izquierdo" +"Bottom right radius" = "Radio inferior derecho" +"Branch" = "Rama" +"Branch archived" = "Rama archivada" +"Branch merged" = "Rama fusionada" +"Branch name" = "Nombre de la rama" +"Branch of {file}" = "Rama de {file}" +"Branch of {file} · merged or archived" = "Rama de {file} · fusionada o archivada" +"Branch updated from main" = "Rama actualizada desde la principal" +"Branch {name}" = "Rama {name}" +"Branches" = "Ramas" +"Bring forward" = "Traer adelante" +"Bring the main file's changes into the branch" = "Trae a la rama los cambios del archivo principal" +"Bring to front" = "Traer al frente" +"Bristle" = "Cerdas" +"Brush" = "Pincel" +"Brush rotation jitter" = "Variación de rotación del pincel" +"Brush scatter" = "Dispersión del pincel" +"Brush size jitter" = "Variación de tamaño del pincel" +"Brush spacing" = "Espaciado del pincel" +"Bulleted list" = "Lista con viñetas" +"CMYK colour" = "Color CMYK" +"CSS custom properties…" = "Propiedades personalizadas CSS…" +"Can edit" = "Puede editar" +"Can't read this library: {e}" = "No se puede leer esta biblioteca: {e}" +"Cancel" = "Cancelar" +"Canvas" = "Lienzo" +"Case" = "Mayúsculas y minúsculas" +"Case-sensitive forms" = "Formas sensibles a mayúsculas" +"Categories" = "Categorías" +"Center" = "Centro" +"Change alias" = "Cambiar alias" +"Change resolved" = "Cambio resuelto" +"Change style" = "Cambiar estilo" +"Change to" = "Cambiar a" +"Change to needs a layer inside a variant" = "«Cambiar a» requiere una capa dentro de una variante" +"Changed" = "Cambiado" +"Changed on both sides: keep one" = "Cambiado en ambos lados: conserva uno" +"Changes on main" = "Cambios en la principal" +"Changes on the branch" = "Cambios en la rama" +"Changes unknown" = "Cambios desconocidos" +"Choose React, SwiftUI or Compose above to connect this component to its code." = "Elige React, SwiftUI o Compose arriba para conectar este componente con su código." +"Choose another profile to change it." = "Elige otro perfil para cambiarlo." +"Choose collection" = "Elegir colección" +"Choose image" = "Elegir imagen" +"Choose mode" = "Elegir modo" +"Choose variable" = "Elegir variable" +"Circle" = "Círculo" +"Clear recent" = "Borrar recientes" +"Click + to replace mixed content" = "Haz clic en + para reemplazar el contenido mixto" +"Click anywhere on the canvas to leave one." = "Haz clic en cualquier parte del lienzo para dejar uno." +"Click or drag to merge · {alt} removes" = "Haz clic o arrastra para fusionar · {alt} quita" +"Click or drag to remove · {alt} merges" = "Haz clic o arrastra para quitar · {alt} fusiona" +"Click to record a key" = "Haz clic para grabar una tecla" +"Clip content" = "Recortar contenido" +"Clip to shape" = "Recortar a la forma" +"Close" = "Cerrar" +"Close overlay" = "Cerrar superposición" +"Close preview" = "Cerrar vista previa" +"Close tab" = "Cerrar pestaña" +"Close version history" = "Cerrar historial de versiones" +"Close when clicking outside" = "Cerrar al hacer clic fuera" +"Code" = "Código" +"Collapse" = "Contraer" +"Collapse panel" = "Contraer panel" +"Collection" = "Colección" +"Collections" = "Colecciones" +"Color" = "Color" +"Color burn" = "Subexponer color" +"Color dodge" = "Sobreexponer color" +"Color profile" = "Perfil de color" +"Color styles" = "Estilos de color" +"Color values in this file are in this color space." = "Los valores de color de este archivo están en este espacio de color." +"Color variables" = "Variables de color" +"Colour" = "Color" +"Column" = "Columna" +"Column gap" = "Espacio entre columnas" +"Column sizes" = "Tamaños de columna" +"Column span" = "Columnas que abarca" +"Columns" = "Columnas" +"Columns {count}" = "Columnas {count}" +"Comment" = "Comentario" +"Comments" = "Comentarios" +"Commits" = "Commits" +"Community" = "Comunidad" +"Company" = "Empresa" +"Compare changes" = "Comparar cambios" +"Completed" = "Completado" +"Component" = "Componente" +"Component name" = "Nombre del componente" +"Component property" = "Propiedad de componente" +"Component set" = "Conjunto de componentes" +"Component sets" = "Conjuntos de componentes" +"Components" = "Componentes" +"Conditional" = "Condicional" +"Confetti" = "Confeti" +"Conflicts" = "Conflictos" +"Connect to code" = "Conectar con el código" +"Connected" = "Conectado" +"Connected to code: {from}" = "Conectado al código: {from}" +"Connection lost, reconnecting (attempt {attempt})…" = "Conexión perdida, reconectando (intento {attempt})…" +"Constrain proportions" = "Mantener proporciones" +"Constraints" = "Restricciones" +"Content" = "Contenido" +"Contents hidden" = "Contenido oculto" +"Contextual alternates" = "Alternativas contextuales" +"Contrast" = "Contraste" +"Contributors" = "Colaboradores" +"Contributors: {people} · Commits: {commits}" = "Colaboradores: {people} · Commits: {commits}" +"Cool" = "Frío" +"Copied as CSS" = "Copiado como CSS" +"Copied as PNG" = "Copiado como PNG" +"Copied as SVG" = "Copiado como SVG" +"Copied as text" = "Copiado como texto" +"Copied {value}" = "Copiado: {value}" +"Copy" = "Copiar" +"Copy as" = "Copiar como" +"Copy as CSS" = "Copiar como CSS" +"Copy as PNG" = "Copiar como PNG" +"Copy as SVG" = "Copiar como SVG" +"Copy link" = "Copiar enlace" +"Copy the {label}" = "Copiar {label}" +"Copy {value}" = "Copiar {value}" +"Corner radius" = "Radio de esquina" +"Corner smoothing" = "Suavizado de esquinas" +"Count" = "Número" +"Create" = "Crear" +"Create a variable collection first" = "Crea primero una colección de variables" +"Create a variable first" = "Crea primero una variable" +"Create alias" = "Crear alias" +"Create branch" = "Crear rama" +"Create collection" = "Crear colección" +"Create component" = "Crear componente" +"Create component property" = "Crear propiedad de componente" +"Create components, styles or variables to publish them as a library." = "Crea componentes, estilos o variables para publicarlos como biblioteca." +"Create property…" = "Crear propiedad…" +"Create style" = "Crear estilo" +"Create style from the selected text" = "Crear estilo a partir del texto seleccionado" +"Crop" = "Recortar" +"Crop marks" = "Marcas de corte" +"Ctrl+arrows" = "Ctrl+flechas" +"Ctrl+click" = "Ctrl+clic" +"Ctrl+scroll" = "Ctrl+desplazar" +"Current version" = "Versión actual" +"Custom" = "Personalizado" +"Cut" = "Cortar" +"Dark" = "Oscuro" +"Darken" = "Oscurecer" +"Dash" = "Discontinuo" +"Decoration" = "Decoración" +"Deep select" = "Selección profunda" +"Default" = "Predeterminado" +"Default content" = "Contenido predeterminado" +"Default value (the first variant)" = "Valor predeterminado (la primera variante)" +"Delay" = "Retardo" +"Delete" = "Eliminar" +"Delete category (its annotations stay)" = "Eliminar categoría (sus anotaciones se mantienen)" +"Delete collection" = "Eliminar colección" +"Delete group" = "Eliminar grupo" +"Delete mode" = "Eliminar modo" +"Delete page" = "Eliminar página" +"Delete preset" = "Eliminar preajuste" +"Delete property" = "Eliminar propiedad" +"Delete the style (layers keep their values)" = "Eliminar el estilo (las capas conservan sus valores)" +"Delete thread" = "Eliminar hilo" +"Delete variable" = "Eliminar variable" +"Delete “{name}”? This can't be undone." = "¿Eliminar “{name}”? No se puede deshacer." +"Density" = "Densidad" +"Depth" = "Profundidad" +"Describe how and when to use this component" = "Describe cómo y cuándo usar este componente" +"Describe what changed" = "Describe qué cambió" +"Description" = "Descripción" +"Description (optional)" = "Descripción (opcional)" +"Design" = "Diseño" +"Desktop" = "Escritorio" +"Destination" = "Destino" +"Detach alias" = "Separar alias" +"Detach instance" = "Separar instancia" +"Detach property" = "Separar propiedad" +"Detach style" = "Separar estilo" +"Detach styles" = "Separar estilos" +"Detach variable" = "Separar variable" +"Details" = "Detalles" +"Dev Mode" = "Modo desarrollo" +"Development" = "Desarrollo" +"Device" = "Dispositivo" +"Device at 100%" = "Dispositivo al 100%" +"Diamond" = "Diamante" +"Difference" = "Diferencia" +"Dimensions" = "Dimensiones" +"Direction" = "Dirección" +"Disconnect from code" = "Desconectar del código" +"Dispersion" = "Dispersión" +"Display name" = "Nombre visible" +"Dissolve" = "Disolver" +"Distribute and tidy" = "Distribuir y ordenar" +"Distribute horizontal spacing" = "Distribuir espacio horizontal" +"Distribute vertical spacing" = "Distribuir espacio vertical" +"Don't truncate" = "No truncar" +"Done" = "Listo" +"Done editing" = "Terminar edición" +"Dots" = "Puntos" +"Down" = "Abajo" +"Drafts" = "Borradores" +"Drag the stroke to add a width point" = "Arrastra el trazo para añadir un punto de ancho" +"Draw" = "Dibujar" +"Drawing tools" = "Herramientas de dibujo" +"Drop shadow" = "Sombra paralela" +"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Con doble licencia MIT OR Apache-2.0. Hecho por el equipo y la comunidad de ArtCraft." +"Duo" = "Duo" +"Duplicate" = "Duplicar" +"Duplicate by dragging" = "Duplicar arrastrando" +"Duplicate page" = "Duplicar página" +"Edit" = "Editar" +"Edit as an expression" = "Editar como expresión" +"Edit colour" = "Editar color" +"Edit comment" = "Editar comentario" +"Edit link" = "Enlace de edición" +"Edit link copied" = "Enlace de edición copiado" +"Edit style" = "Editar estilo" +"Edit variable" = "Editar variable" +"Edit with the builder" = "Editar con el generador" +"Effect settings" = "Ajustes del efecto" +"Effect styles" = "Estilos de efecto" +"Effects" = "Efectos" +"Ellipse" = "Elipse" +"Ellipses" = "Elipses" +"Else" = "Si no" +"Else if" = "Si no, si" +"Empty" = "Vacía" +"End" = "Final" +"End point" = "Punto final" +"Enter to save · Esc to cancel" = "Intro para guardar · Esc para cancelar" +"Eraser" = "Borrador" +"Esc" = "Esc" +"Essential" = "Esenciales" +"Every conflict has a choice." = "Todos los conflictos tienen una elección." +"Every top-level frame already starts a flow" = "Todos los marcos de nivel superior ya inician un flujo" +"Exact opacity (4 then 5: 45%)" = "Opacidad exacta (4 y luego 5: 45 %)" +"Exclude" = "Excluir" +"Excludes" = "Exclusiones" +"Exclusion" = "Exclusión" +"Exit focus view (Esc)" = "Salir de la vista de enfoque (Esc)" +"Expand" = "Expandir" +"Export" = "Exportar" +"Export all" = "Exportar todo" +"Export page as PNG" = "Exportar página como PNG" +"Export variables" = "Exportar variables" +"Export {name}" = "Exportar {name}" +"Export {n} layers" = { one = "Exportar {n} capa", other = "Exportar {n} capas" } +"Exported {file}" = "Exportado: {file}" +"Exported {name}" = "{name} exportado" +"Exported {n} files" = { one = "{n} archivo exportado", other = "{n} archivos exportados" } +"Export…" = "Exportar…" +"Expose properties to instances" = "Exponer propiedades a las instancias" +"Exposure" = "Exposición" +"Expression" = "Expresión" +"Extend collection" = "Extender colección" +"Extends \"{name}\": overridden values are marked" = "Extiende \"{name}\": los valores modificados están marcados" +"Eye" = "Ojo" +"Fade" = "Desvanecido" +"False" = "Falso" +"Fetching from the server…" = "Obteniendo del servidor…" +"File" = "Archivo" +"File name" = "Nombre del archivo" +"Fill" = "Relleno" +"Fill (fr)" = "Llenar (fr)" +"Fill container" = "Llenar contenedor" +"Fill window" = "Llenar ventana" +"Fills" = "Rellenos" +"Fill|sizing" = "Llenar" +"Filter and sort" = "Filtrar y ordenar" +"Find" = "Buscar" +"Find and replace" = "Buscar y reemplazar" +"Find layers" = "Buscar capas" +"First" = "Primero" +"First commit" = "Primer commit" +"Fit" = "Ajustar" +"Fit device to window" = "Ajustar dispositivo a la ventana" +"Fit to contents" = "Ajustar al contenido" +"Fit to window" = "Ajustar a la ventana" +"Fit width" = "Ajustar al ancho" +"Fixed" = "Fijo" +"Fixed gap" = "Espacio fijo" +"Fixed size" = "Tamaño fijo" +"Flatten" = "Acoplar" +"Flip" = "Invertir" +"Flip horizontal" = "Voltear horizontalmente" +"Flip text to the other side" = "Pasar el texto al otro lado" +"Flip vertical" = "Voltear verticalmente" +"Flip width profile" = "Invertir perfil de ancho" +"Flow starting point" = "Punto de inicio del flujo" +"Flow starting points" = "Puntos de inicio de flujo" +"Flows" = "Flujos" +"Focus view" = "Vista de enfoque" +"Fold autosaves" = "Plegar autoguardados" +"Follow {name}" = "Seguir a {name}" +"Following" = "Siguiendo" +"Following {name} · {key} to stop" = "Siguiendo a {name} · {key} para parar" +"Font" = "Fuente" +"Font not installed" = "Fuente no instalada" +"Font size" = "Tamaño de fuente" +"Font style" = "Estilo de fuente" +"Fractions" = "Fracciones" +"Frame" = "Marco" +"Frame selection" = "Enmarcar selección" +"Frame tools" = "Herramientas de marco" +"Frames" = "Marcos" +"From the library \"{name}\": read-only" = "De la biblioteca \"{name}\": solo lectura" +"Frost" = "Escarcha" +"Gap" = "Espacio" +"Gap between items" = "Espacio entre elementos" +"Gap between rows" = "Espacio entre filas" +"Glass" = "Vidrio" +"Go to main component" = "Ir al componente principal" +"Go to {name}" = "Ir a {name}" +"Grab bag" = "Mezcla" +"Gradient" = "Degradado" +"Gray" = "Gris" +"Green" = "Verde" +"Grid" = "Cuadrícula" +"Grid ({size})" = "Cuadrícula ({size})" +"Grid styles" = "Estilos de cuadrícula" +"Grid {size}" = "Cuadrícula {size}" +"Group" = "Grupo" +"Group selection" = "Agrupar selección" +"Groups" = "Grupos" +"Gutter" = "Medianil" +"Hand tool" = "Herramienta mano" +"Hard light" = "Luz fuerte" +"Height" = "Alto" +"Help" = "Ayuda" +"Help and resources" = "Ayuda y recursos" +"Help page" = "Página de ayuda" +"Hidden" = "Oculta" +"Hide contents" = "Ocultar contenido" +"Hide from publishing" = "Ocultar al publicar" +"Hide section contents" = "Ocultar contenido de la sección" +"Highlight layers on hover" = "Resaltar capas al pasar el cursor" +"Highlights" = "Luces" +"Hold Alt" = "Mantén Alt" +"Hold Alt+Z" = "Mantén Alt+Z" +"Hold Z" = "Mantén Z" +"Hold ⌥" = "Mantén ⌥" +"Hold ⌥Z" = "Mantén ⌥Z" +"Home" = "Inicio" +"Horizontal" = "Horizontal" +"Horizontal hex" = "Hexágono horizontal" +"Horizontal padding" = "Margen interno horizontal" +"Horizontal spacing" = "Espaciado horizontal" +"Hosting a session needs the desktop app; here you can join one." = "Para alojar una sesión necesitas la app de escritorio; aquí puedes unirte a una." +"Hover a layer to see its properties" = "Pasa el puntero sobre una capa para ver sus propiedades" +"Hover other layers to measure the distance" = "Pasa sobre otras capas para medir la distancia" +"Hue" = "Tono" +"Hug" = "Ajustar" +"Hug contents" = "Ajustar al contenido" +"If" = "Si" +"If {condition}" = "Si {condition}" +"Ignore auto layout (absolute position)" = "Ignorar diseño automático (posición absoluta)" +"Image" = "Imagen" +"Import" = "Importar" +"Import path" = "Ruta de importación" +"Import plugin from manifest" = "Importar plugin desde manifiesto" +"Import plugin from manifest…" = "Importar plugin desde manifiesto…" +"Import variables" = "Importar variables" +"Imported {name}" = "{name} importado" +"Imported {n} new variables, updated {updated}" = { one = "Variables importadas: {n} nueva; actualizadas: {updated}", other = "Variables importadas: {n} nuevas; actualizadas: {updated}" } +"Import…" = "Importar…" +"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)." = "En el navegador puedes unirte a sesiones alojadas por la app de escritorio y a archivos de un servidor FigCraft. Una página abierta por https solo llega a otros equipos mediante enlaces wss:// (un servidor con TLS)." +"Individual corners" = "Esquinas individuales" +"Individual strokes" = "Trazos individuales" +"Ink" = "Tinta" +"Inner shadow" = "Sombra interior" +"Inside" = "Interior" +"Inspect" = "Inspeccionar" +"Inspect the layer under the pointer: size, colours, typography" = "Inspeccionar la capa bajo el puntero: tamaño, colores, tipografía" +"Instance" = "Instancia" +"Instance properties" = "Propiedades de instancia" +"Instance swap" = "Intercambio de instancia" +"Instance swap (no other component)" = "Intercambio de instancia (no hay otro componente)" +"Instances" = "Instancias" +"Instant" = "Instantánea" +"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." +"Intersect" = "Intersecar" +"Intersects" = "Intersecciones" +"Invite link" = "Enlace de invitación" +"Invite link copied" = "Enlace de invitación copiado" +"Join" = "Unirse" +"Join a session…" = "Unirse a una sesión…" +"Join live session" = "Unirse a sesión en vivo" +"Join us on Discord" = "Únete a nosotros en Discord" +"Joined" = "Conectado" +"Joined {file}" = "Conectado a {file}" +"Joining…" = "Uniéndose…" +"Join|stroke" = "Unión" +"Just now" = "Justo ahora" +"Justify" = "Justificar" +"Keep appearance" = "Mantener apariencia" +"Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Mantener apariencia convierte cada valor de color a {profile} para que los colores se vean igual. Mantener valores deja los números como están, así que los colores cambian." +"Keep tool selected after use" = "Mantener la herramienta tras usarla" +"Keep values" = "Mantener valores" +"Key/Gamepad" = "Tecla/Mando" +"Key/Gamepad {key}" = "Tecla/Mando {key}" +"Keyboard shortcuts" = "Atajos de teclado" +"Language" = "Idioma" +"Lasso" = "Lazo" +"Last" = "Último" +"Last change: {note}" = "Último cambio: {note}" +"Last commit" = "Último commit" +"Layer blur" = "Desenfoque de capa" +"Layer name" = "Nombre de capa" +"Layers" = "Capas" +"Layout" = "Disposición" +"Layout grid" = "Cuadrícula de diseño" +"Layout grid settings" = "Ajustes de la cuadrícula de diseño" +"Layout grids" = "Cuadrículas de diseño" +"Leave session" = "Salir de la sesión" +"Leaves" = "Hojas" +"Left" = "Izquierda" +"Left & right" = "Izquierda y derecha" +"Left side" = "Lado izquierdo" +"Left side width" = "Ancho del lado izquierdo" +"Letter case" = "Mayúsculas y minúsculas" +"Letter spacing" = "Espaciado" +"Libraries" = "Bibliotecas" +"Libraries in this file" = "Bibliotecas en este archivo" +"Library updates available" = "Actualizaciones disponibles" +"Library: click to collapse" = "Biblioteca: clic para contraer" +"Library: click to expand" = "Biblioteca: clic para expandir" +"Library: its variables are read-only here; edit them in the library file and publish it" = "Biblioteca: aquí sus variables son de solo lectura; edítalas en el archivo de la biblioteca y publícalo" +"Light" = "Claro" +"Lighten" = "Aclarar" +"Light|glass" = "Luz" +"Line" = "Línea" +"Line arrow" = "Flecha de línea" +"Line delta" = "Diferencia de líneas" +"Line height" = "Interlineado" +"Linear" = "Lineal" +"Lines" = "Líneas" +"Lines +/−" = "Líneas +/−" +"Lines added" = "Líneas añadidas" +"Lines deleted" = "Líneas eliminadas" +"Link" = "Enlace" +"Link copied" = "Enlace copiado" +"Link this component to its code component: instances then show your real snippet with their values." = "Vincula este componente a su componente de código: así las instancias muestran tu fragmento real con sus valores." +"List style" = "Estilo de lista" +"Live session" = "Sesión en vivo" +"Live · {people}" = "En vivo · {people}" +"Local components" = "Componentes locales" +"Local styles" = "Estilos locales" +"Local variables" = "Variables locales" +"Lock/Unlock" = "Bloquear/desbloquear" +"Locked" = "Bloqueada" +"Lowercase" = "Minúsculas" +"Luminance" = "Luminancia" +"Luminosity" = "Luminosidad" +"Main" = "Principal" +"Main component deleted" = "Componente principal eliminado" +"Main menu" = "Menú principal" +"Make slot" = "Convertir en ranura" +"Margin" = "Margen" +"Mark all as read" = "Marcar todo como leído" +"Mark as resolved" = "Marcar como resuelto" +"Mark as unread" = "Marcar como no leído" +"Mark as unresolved" = "Marcar como no resuelto" +"Mask" = "Máscara" +"Mass · Stiffness · Damping" = "Masa · Rigidez · Amortiguación" +"Max H" = "Máx. H" +"Max W" = "Máx. W" +"Max lines" = "Máx. de líneas" +"Maximize" = "Maximizar" +"Measure to the selection" = "Medir hasta la selección" +"Merge into main" = "Fusionar en la principal" +"Merge regions" = "Fusionar regiones" +"Merge the branch into the main file (one undo step) and archive it" = "Fusiona la rama en el archivo principal (un paso de deshacer) y la archiva" +"Merged" = "Fusionada" +"Merged PRs" = "PR fusionadas" +"Min H" = "Mín. H" +"Min W" = "Mín. W" +"Minimize" = "Minimizar" +"Mirror angle" = "Reflejar ángulo" +"Mirror angle and length" = "Reflejar ángulo y longitud" +"Mirroring" = "Reflejo" +"Missing" = "No encontrado" +"Missing layer" = "Capa no encontrada" +"Missing style" = "Estilo no encontrado" +"Missing variable" = "Variable no encontrada" +"Miter" = "Inglete" +"Miter angle" = "Ángulo de inglete" +"Mixed" = "Mixto" +"Mixed fonts" = "Fuentes mixtas" +"Mixed interactions: click + to replace them" = "Interacciones mixtas: haz clic en + para reemplazarlas" +"Mode" = "Modo" +"Model" = "Modelo" +"Models" = "Modelos" +"Mono" = "Mono" +"More" = "Más" +"More instance actions" = "Más acciones de instancia" +"Mouse down" = "Al presionar el botón" +"Mouse enter" = "Al entrar el puntero" +"Mouse leave" = "Al salir el puntero" +"Mouse up" = "Al soltar el botón" +"Move" = "Mover" +"Move down" = "Bajar" +"Move in" = "Mover hacia dentro" +"Move out" = "Mover hacia fuera" +"Move tools" = "Herramientas de movimiento" +"Move up" = "Subir" +"Moved to {name}" = "Movido a {name}" +"Multi" = "Multi" +"Multiply" = "Multiplicar" +"Muted" = "Apagado" +"Name" = "Nombre" +"Name (A–Z)" = "Nombre (A–Z)" +"Named instance" = "Instancia con nombre" +"Navigate to" = "Navegar a" +"Nested instance" = "Instancia anidada" +"New design file" = "Nuevo archivo de diseño" +"Next or previous control in the focused region" = "Control siguiente o anterior de la región enfocada" +"Next ready for dev" = "Siguiente listo para desarrollo" +"Next size option" = "Siguiente opción de tamaño" +"No boolean variables fit here." = "Ninguna variable booleana encaja aquí." +"No boolean variables yet. Create them in Local variables." = "Aún no hay variables booleanas. Créalas en Variables locales." +"No branches. A branch is a copy of the file to try changes in, then review and merge." = "No hay ramas. Una rama es una copia del archivo para probar cambios y luego revisarlos y fusionarlos." +"No category" = "Sin categoría" +"No changes" = "Sin cambios" +"No colour variables fit here." = "Ninguna variable de color encaja aquí." +"No colour variables yet. Create them in Local variables." = "Aún no hay variables de color. Créalas en Variables locales." +"No comments yet" = "Aún no hay comentarios" +"No components yet. Select layers and press {keys}." = "Aún no hay componentes. Selecciona capas y pulsa {keys}." +"No conflicts." = "Sin conflictos." +"No contributor data was built into this copy." = "Esta copia no incluye datos de colaboradores." +"No export settings. Add them in the Design tab." = "No hay ajustes de exportación. Añádelos en la pestaña Diseño." +"No fonts match" = "Ninguna fuente coincide" +"No layer changes to show." = "No hay cambios de capas que mostrar." +"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Aún no hay bibliotecas. Añade un archivo .figcraft publicado como biblioteca o pega el enlace de una biblioteca de equipo." +"No list" = "Sin lista" +"No matching comments" = "Ningún comentario coincide" +"No matching components" = "Ningún componente coincide" +"No matching layers" = "Ninguna capa coincide" +"No matching styles" = "Ningún estilo coincide" +"No matching variables" = "Ninguna variable coincide" +"No mirroring" = "Sin reflejo" +"No model credits were built into this copy." = "Esta copia no incluye créditos de modelos." +"No number variables fit here." = "Ninguna variable numérica encaja aquí." +"No number variables yet. Create them in Local variables." = "Aún no hay variables numéricas. Créalas en Variables locales." +"No paints" = "Sin pinturas" +"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Aún no hay plugins. Importa un plugin de desarrollo desde su figcraft-plugin.json." +"No preferred components" = "Sin componentes preferidos" +"No preset" = "Sin preajuste" +"No saved versions yet. Save a version to mark a point in your work you can come back to." = "Aún no hay versiones guardadas. Guarda una versión para marcar un punto de tu trabajo al que puedas volver." +"No scrolling" = "Sin desplazamiento" +"No shortcuts match" = "Ningún atajo coincide" +"No status" = "Sin estado" +"No string variables fit here." = "Ninguna variable de cadena encaja aquí." +"No string variables yet. Create them in Local variables." = "Aún no hay variables de cadena. Créalas en Variables locales." +"No styles yet." = "Aún no hay estilos." +"No styles yet. Click + to create one from the selection." = "Aún no hay estilos. Haz clic en + para crear uno a partir de la selección." +"No text styles yet. Click + to create one from the selected text." = "Aún no hay estilos de texto. Haz clic en + para crear uno a partir del texto seleccionado." +"No variables fit here." = "Ninguna variable encaja aquí." +"No variables yet. Create them in Local variables." = "Aún no hay variables. Créalas en Variables locales." +"Nobody else is here yet" = "Aún no hay nadie más" +"Noise" = "Ruido" +"None" = "Ninguno" +"None." = "Ninguno." +"Normal" = "Normal" +"Not bound" = "Sin vincular" +"Not in a live session" = "Sin sesión en vivo" +"Not in this font" = "No está en esta fuente" +"Not properties, kept as written: {names}" = "No son propiedades, se quedan tal cual: {names}" +"Not read yet" = "Aún no leída" +"Note {n}" = "Nota {n}" +"Nothing changed since this was marked ready for dev." = "Nada cambió desde que se marcó como listo para desarrollo." +"Nothing here yet. Set a frame's status to list it." = "Aún no hay nada. Asigna un estado a un marco para verlo aquí." +"Nothing to export" = "Nada que exportar" +"Now" = "Ahora" +"Nudge (Shift: big nudge)" = "Desplazar (Mayús: desplazamiento grande)" +"Nudge amount" = "Cantidad de desplazamiento" +"Number" = "Número" +"Numbered list" = "Lista numerada" +"Object" = "Objeto" +"Off" = "No" +"Offered first when filling the slot (any content is allowed)" = "Se ofrecen primero al rellenar la ranura (se permite cualquier contenido)" +"Offered first when swapping the instance (any component is allowed)" = "Se ofrecen primero al intercambiar la instancia (se permite cualquier componente)" +"Offset" = "Desplazamiento" +"On click" = "Al hacer clic" +"On drag" = "Al arrastrar" +"On the server's machine, `figcraft-server key` prints it." = "En el equipo del servidor, `figcraft-server key` la muestra." +"On this page" = "En esta página" +"Only the first changed layers are listed." = "Solo se muestran las primeras capas cambiadas." +"Only the first {n} changed layers are listed." = "Solo se muestran las primeras {n} capas cambiadas." +"Only this computer can join (turn on network sharing to invite others)." = "Solo este equipo puede unirse (activa el uso compartido en red para invitar a otros)." +"Only your threads" = "Solo tus hilos" +"Opacity" = "Opacidad" +"Opacity & blend" = "Opacidad y fusión" +"Opacity 10%…90%, 0: 100%" = "Opacidad 10 %…90 %, 0: 100 %" +"Open" = "Abrir" +"Open .figcraft, .svg or images" = "Abre archivos .figcraft, .svg o imágenes" +"Open branch" = "Abrir rama" +"Open file" = "Abrir archivo" +"Open files" = "Archivos abiertos" +"Open in focus view" = "Abrir en vista de enfoque" +"Open invite links in FigCraft" = "Abrir enlaces de invitación en FigCraft" +"Open link" = "Abrir enlace" +"Open link {url}" = "Abrir enlace {url}" +"Open overlay" = "Abrir superposición" +"Open prototype preview" = "Abrir vista previa del prototipo" +"Open variables ({count})" = "Abrir variables ({count})" +"Opened “{name}” in a new tab" = "“{name}” abierta en una pestaña nueva" +"Open|comments" = "Abiertos" +"Options" = "Opciones" +"Or keep the file on a FigCraft server and share links to edit or to view it." = "O guarda el archivo en un servidor FigCraft y comparte enlaces para editarlo o verlo." +"Orange" = "Naranja" +"Outline stroke" = "Contornear trazo" +"Outlines" = "Contornos" +"Outside" = "Exterior" +"Overflow" = "Desbordamiento" +"Overlay" = "Superposición" +"Overlay position" = "Posición de la superposición" +"Overridden here (the collection it extends has another value)" = "Modificado aquí (la colección que extiende tiene otro valor)" +"Overrides" = "Modificaciones" +"PDF print options" = "Opciones de impresión PDF" +"PDF print settings" = "Ajustes de impresión PDF" +"Padding" = "Margen interno" +"Page" = "Página" +"Pages" = "Páginas" +"Pages: {pages}" = "Páginas: {pages}" +"Paint bucket" = "Bote de pintura" +"Pan the canvas" = "Desplazar el lienzo" +"Panels" = "Paneles" +"Paper" = "Papel" +"Paragraph indent" = "Sangría de párrafo" +"Paragraph spacing" = "Espacio entre párrafos" +"Pass through" = "Atravesar" +"Paste a team library link (a view link)" = "Pega el enlace de una biblioteca de equipo (de lectura)" +"Paste here" = "Pegar aquí" +"Paste the invite link the host shared with you." = "Pega el enlace de invitación que te compartió el anfitrión." +"Paste to replace" = "Pegar para reemplazar" +"Pattern" = "Patrón" +"Pattern alignment" = "Alineación del patrón" +"Pattern scale" = "Escala del patrón" +"Pattern source" = "Origen del patrón" +"Pen" = "Pluma" +"Pencil" = "Lápiz" +"People in this file" = "Personas en este archivo" +"Phone" = "Teléfono" +"Pick color" = "Elegir color" +"Pick color (I)" = "Tomar color (I)" +"Pick one font family first" = "Elige primero una familia de fuentes" +"Pin a property" = "Fijar una propiedad" +"Pinch" = "Pellizco" +"Pixel grid" = "Cuadrícula de píxeles" +"Pixel preview 1x" = "Vista previa de píxeles 1x" +"Pixel preview 2x" = "Vista previa de píxeles 2x" +"Place automatically" = "Colocar automáticamente" +"Place image" = "Colocar imagen" +"Plugins" = "Plugins" +"Plus darker" = "Más oscuro" +"Plus lighter" = "Más claro" +"Point" = "Punto" +"Points" = "Puntos" +"Polygon" = "Polígono" +"Polygons" = "Polígonos" +"Position" = "Posición" +"Post" = "Publicar" +"Preferences" = "Preferencias" +"Preferred" = "Preferidos" +"Present" = "Presentar" +"Present or preview" = "Presentar o previsualizar" +"Present this flow" = "Presentar este flujo" +"Presentation" = "Presentación" +"Preserve scroll position" = "Conservar posición de desplazamiento" +"Presets" = "Ajustes predefinidos" +"Press a key…" = "Pulsa una tecla…" +"Preview" = "Vista previa" +"Preview this flow" = "Vista previa de este flujo" +"Preview this frame" = "Vista previa de este marco" +"Previous ready for dev" = "Anterior listo para desarrollo" +"Progressive" = "Progresivo" +"Properties" = "Propiedades" +"Prototype" = "Prototipo" +"Prototype preview" = "Vista previa del prototipo" +"Prototype settings" = "Ajustes del prototipo" +"Publish" = "Publicar" +"Publish changes" = "Publicar cambios" +"Publish to server" = "Publicar en servidor" +"Publish to server…" = "Publicar en servidor…" +"Published version {v}" = "Versión {v} publicada" +"Published version {v} to the server" = "Versión {v} publicada en el servidor" +"Published version {v}: save the file so other files can use it" = "Versión {v} publicada: guarda el archivo para que otros archivos puedan usarla" +"Published · {people}" = "Publicado · {people}" +"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Para publicar en un servidor necesitas la app de escritorio; en el navegador puedes unirte a archivos de un servidor" +"Publishing…" = "Publicando…" +"Push" = "Empujar" +"Push changes to main component" = "Enviar cambios al componente principal" +"Put text on path" = "Poner texto en trazado" +"Radial" = "Radial" +"Radius" = "Radio" +"Ratio" = "Proporción" +"Reading the library…" = "Leyendo la biblioteca…" +"Ready for dev" = "Listo para desarrollo" +"Real name" = "Nombre real" +"Recent" = "Recientes" +"Recent files" = "Archivos recientes" +"Recents" = "Recientes" +"Rectangle" = "Rectángulo" +"Rectangles" = "Rectángulos" +"Red" = "Rojo" +"Reduce motion" = "Reducir movimiento" +"Refraction" = "Refracción" +"Remove" = "Quitar" +"Remove all" = "Quitar todo" +"Remove annotation" = "Quitar anotación" +"Remove auto layout" = "Quitar diseño automático" +"Remove export setting" = "Quitar ajuste de exportación" +"Remove interaction" = "Quitar interacción" +"Remove library from this file" = "Quitar biblioteca de este archivo" +"Remove min and max" = "Quitar mín. y máx." +"Remove min and max height" = "Quitar alto mín. y máx." +"Remove min and max width" = "Quitar ancho mín. y máx." +"Remove regions" = "Quitar regiones" +"Remove slot" = "Quitar ranura" +"Remove starting point" = "Quitar punto de inicio" +"Remove stop" = "Quitar parada" +"Remove text from path" = "Quitar texto del trazado" +"Remove the else and its actions" = "Quitar el «si no» y sus acciones" +"Remove this condition and its actions" = "Quitar esta condición y sus acciones" +"Removed" = "Eliminado" +"Removed since it was marked" = "Eliminada desde que se marcó" +"Rename" = "Cambiar nombre" +"Rename duplicated layers" = "Renombrar capas duplicadas" +"Rename group" = "Cambiar nombre del grupo" +"Rename mode" = "Cambiar nombre del modo" +"Rename page" = "Cambiar nombre de página" +"Replace all" = "Reemplazar todo" +"Replace mixed export settings" = "Reemplazar ajustes de exportación mixtos" +"Replace with" = "Reemplazar con" +"Replace with a new interaction" = "Reemplazar por una interacción nueva" +"Reply" = "Responder" +"Reset" = "Restablecer" +"Reset adjustments" = "Restablecer ajustes" +"Reset all changes" = "Restablecer todos los cambios" +"Reset all overrides on this layer" = "Restablecer todas las modificaciones de esta capa" +"Reset overrides" = "Restablecer modificaciones" +"Reset slot" = "Restablecer ranura" +"Reset this override" = "Restablecer esta modificación" +"Reset to style" = "Restablecer al estilo" +"Reset to the inherited value" = "Restablecer al valor heredado" +"Resize by the nudge amount" = "Redimensionar según el desplazamiento" +"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Ajusta los marcos a la pantalla del dispositivo (o a la ventana sin dispositivo): sus restricciones y su diseño automático se adaptan" +"Resize to fit" = "Ajustar tamaño" +"Resolve" = "Resolver" +"Resolve change" = "Resolver cambio" +"Resolved" = "Resuelto" +"Resources" = "Recursos" +"Responsive" = "Adaptable" +"Restart" = "Reiniciar" +"Restore" = "Restaurar" +"Restore main component" = "Restaurar componente principal" +"Restore this version" = "Restaurar esta versión" +"Restored “{name}”" = "“{name}” restaurada" +"Reverse the order" = "Invertir el orden" +"Reversed triangle" = "Triángulo invertido" +"Review and merge" = "Revisar y fusionar" +"Ribbon" = "Cinta" +"Right" = "Derecha" +"Right side" = "Lado derecho" +"Right side width" = "Ancho del lado derecho" +"Rotate 90°" = "Girar 90°" +"Rotate 90° right" = "Girar 90° a la derecha" +"Rotate to landscape" = "Girar a horizontal" +"Rotate to portrait" = "Girar a vertical" +"Rotation" = "Rotación" +"Rotation jitter" = "Variación de rotación" +"Round" = "Redondo" +"Rounded" = "Redondeado" +"Row" = "Fila" +"Row gap" = "Espacio entre filas" +"Row sizes" = "Tamaños de fila" +"Row span" = "Filas que abarca" +"Rows" = "Filas" +"Rows {count}" = "Filas {count}" +"Rulers" = "Reglas" +"Run" = "Ejecutar" +"Run {label}" = "Ejecutar {label}" +"Saturation" = "Saturación" +"Save" = "Guardar" +"Save as" = "Guardar como" +"Save as preset…" = "Guardar como preajuste…" +"Save version {key}" = "Guardar versión {key}" +"Saved" = "Guardado" +"Saved {name}" = "Guardado {name}" +"Scale" = "Escala" +"Scatter" = "Dispersión" +"Scoping: where pickers offer it" = "Ámbito: dónde la ofrecen los selectores" +"Screen" = "Trama" +"Scroll behavior" = "Comportamiento de desplazamiento" +"Scroll to" = "Desplazar a" +"Scroll to layer" = "Desplazar a la capa" +"Scroll with parent" = "Desplazar con el padre" +"Search" = "Buscar" +"Search actions, layers and tools" = "Buscar acciones, capas y herramientas" +"Search assets" = "Buscar recursos" +"Search comments" = "Buscar comentarios" +"Search fonts" = "Buscar fuentes" +"Search layers" = "Buscar capas" +"Search shortcuts" = "Buscar atajos" +"Section" = "Sección" +"Section color" = "Color de sección" +"Sections" = "Secciones" +"Select a frame and add a starting point to present it as a flow." = "Selecciona un marco y añade un punto de inicio para presentarlo como flujo." +"Select a frame to preview it" = "Selecciona un marco para previsualizarlo" +"Select a layer to see its properties and code" = "Selecciona una capa para ver sus propiedades y su código" +"Select all" = "Seleccionar todo" +"Select all with same" = "Seleccionar todo con el mismo" +"Select layer" = "Seleccionar capa" +"Select layers to apply the style to" = "Selecciona capas para aplicarles el estilo" +"Select overlapping shapes" = "Selecciona formas superpuestas" +"Select points to edit their position, radius and mirroring" = "Selecciona puntos para editar su posición, radio y reflejo" +"Select slot" = "Seleccionar ranura" +"Selection" = "Selección" +"Send backward" = "Enviar atrás" +"Send to back" = "Enviar al fondo" +"Server address" = "Dirección del servidor" +"Set variable" = "Establecer variable" +"Set variable mode" = "Establecer modo de variable" +"Set variable {name}" = "Establecer variable {name}" +"Set {name} to {value}" = "Establecer {name} en {value}" +"Shadows" = "Sombras" +"Shape" = "Forma" +"Shape builder" = "Generador de formas" +"Shape tools" = "Herramientas de forma" +"Share" = "Compartir" +"Share on the local network" = "Compartir en la red local" +"Share: live session, export" = "Compartir: sesión en vivo, exportar" +"Show" = "Mostrar" +"Show as grid" = "Mostrar como cuadrícula" +"Show as list" = "Mostrar como lista" +"Show autosaves" = "Mostrar autoguardados" +"Show behind transparent areas" = "Mostrar detrás de áreas transparentes" +"Show comments (Shift+C)" = "Mostrar comentarios (Shift+C)" +"Show device frame" = "Mostrar carcasa del dispositivo" +"Show dimensions on objects" = "Mostrar dimensiones en los objetos" +"Show hotspot hints" = "Mostrar indicaciones de zonas activas" +"Show only this on the canvas" = "Mostrar solo esto en el lienzo" +"Show open" = "Ver abiertos" +"Show section contents" = "Mostrar contenido de la sección" +"Show this layer" = "Mostrar esta capa" +"Show usage" = "Ver uso" +"Show/Hide" = "Mostrar/ocultar" +"Show/Hide UI" = "Mostrar/ocultar interfaz" +"Side by side" = "En paralelo" +"Sidebar" = "Barra lateral" +"Size" = "Tamaño" +"Size jitter" = "Variación de tamaño" +"Slashed zero" = "Cero barrado" +"Slice" = "Sector" +"Slices" = "Sectores" +"Slide in" = "Deslizar hacia dentro" +"Slide out" = "Deslizar hacia fuera" +"Slot" = "Ranura" +"Slot content" = "Contenido de ranura" +"Small capitals" = "Versalitas" +"Small caps" = "Versalitas" +"Smart animate" = "Animación inteligente" +"Snap to pixel grid" = "Ajustar a la cuadrícula de píxeles" +"Social media" = "Redes sociales" +"Soft light" = "Luz suave" +"Solid" = "Sólido" +"Sort" = "Ordenar" +"Sort by date" = "Ordenar por fecha" +"Sort by page" = "Ordenar por página" +"Space drag" = "Espacio y arrastrar" +"Space+drag" = "Espacio+arrastrar" +"Spacing" = "Espaciado" +"Splay" = "Apertura" +"Spread" = "Extensión" +"Square" = "Cuadrado" +"Standard ligatures" = "Ligaduras estándar" +"Star" = "Estrella" +"Stars" = "Estrellas" +"Start" = "Inicio" +"Start from a blank canvas" = "Empieza con un lienzo en blanco" +"Start live session" = "Iniciar sesión en vivo" +"Start on path" = "Inicio en el trazado" +"Start point" = "Punto inicial" +"Status" = "Estado" +"Sticky" = "Adherido" +"Stop following {name}" = "Dejar de seguir a {name}" +"Stop session" = "Detener sesión" +"Stop {n} {hex}" = "Parada {n} {hex}" +"Stretch" = "Estirar" +"Strikethrough" = "Tachado" +"String" = "Cadena" +"Stroke" = "Trazo" +"Stroke style" = "Estilo de trazo" +"Stroke weight" = "Grosor del trazo" +"Strokes" = "Trazos" +"Style" = "Estilo" +"Styles" = "Estilos" +"Stylistic set 1" = "Conjunto estilístico 1" +"Stylistic set 2" = "Conjunto estilístico 2" +"Stylistic set 3" = "Conjunto estilístico 3" +"Stylistic set 4" = "Conjunto estilístico 4" +"Stylistic set 5" = "Conjunto estilístico 5" +"Subtract" = "Restar" +"Subtracts" = "Restas" +"Suffix" = "Sufijo" +"Swap instance" = "Intercambiar instancia" +"Swap library: use another library's assets of the same names" = "Intercambiar biblioteca: usar los recursos con los mismos nombres de otra biblioteca" +"Swap orientation" = "Cambiar orientación" +"Swap overlay" = "Intercambiar superposición" +"Swap to {name}" = "Intercambiar por {name}" +"Swap width and height" = "Intercambiar ancho y alto" +"Swapped instance" = "Instancia intercambiada" +"Swapped {n} assets" = { one = "{n} recurso intercambiado", other = "{n} recursos intercambiados" } +"Swapped {n} assets; {missed} had no match and still use the old library" = { one = "{n} recurso intercambiado; {missed} sin coincidencia siguen usando la biblioteca anterior", other = "{n} recursos intercambiados; {missed} sin coincidencia siguen usando la biblioteca anterior" } +"Sweep" = "Barrido" +"System" = "Sistema" +"Tab / Shift+Tab" = "Tab / Shift+Tab" +"Table" = "Tabla" +"Tablet" = "Tableta" +"Tabular numbers" = "Números tabulares" +"Take the values from the first selected layer" = "Tomar los valores de la primera capa seleccionada" +"Taper" = "Afinado" +"Taper both ends" = "Afinar ambos extremos" +"Temperature" = "Temperatura" +"Template" = "Plantilla" +"Text" = "Texto" +"Text align" = "Alineación del texto" +"Text content" = "Contenido de texto" +"Text style" = "Estilo de texto" +"Text styles" = "Estilos de texto" +"Texts" = "Textos" +"Texture" = "Textura" +"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "El lienzo muestra los colores en sRGB; los archivos PNG, JPG y WebP exportados se etiquetan como Display P3." +"The file now holds another library" = "El archivo ahora contiene otra biblioteca" +"The file now holds another library (\"{name}\")" = "El archivo ahora contiene otra biblioteca (\"{name}\")" +"The link carries the session's secret: share it only with people you invite." = "El enlace lleva la clave secreta de la sesión: compártelo solo con quienes invites." +"The selection uses several text styles: click to apply one" = "La selección usa varios estilos de texto: haz clic para aplicar uno" +"Theme" = "Tema" +"There's no destination for that action" = "No hay destino para esa acción" +"This branch can't be reviewed: {e}" = "Esta rama no se puede revisar: {e}" +"This branch was merged or archived." = "Esta rama se fusionó o archivó." +"This condition is edited as an expression" = "Esta condición se edita como expresión" +"This file" = "Este archivo" +"This file has unsaved changes" = "Este archivo tiene cambios sin guardar" +"This frame is new on the branch." = "Este marco es nuevo en la rama." +"This frame isn't on the branch." = "Este marco no está en la rama." +"Tidy up" = "Ordenar" +"Tile" = "Mosaico" +"Tile type" = "Tipo de mosaico" +"Tint" = "Matiz" +"Title" = "Título" +"Title case" = "Tipo título" +"To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Para compartir esta biblioteca con tu equipo, publica el archivo en un servidor (Compartir ▸ Publicar en servidor…) y luego publica allí la biblioteca." +"Toggle" = "Activar/desactivar" +"Toggle light/dark theme" = "Cambiar tema claro/oscuro" +"Toggle visibility" = "Mostrar/ocultar" +"Tools" = "Herramientas" +"Top" = "Arriba" +"Top & bottom" = "Arriba y abajo" +"Top left radius" = "Radio superior izquierdo" +"Top right radius" = "Radio superior derecho" +"Transform" = "Transformar" +"Triangle arrow" = "Flecha triangular" +"Trigger" = "Desencadenante" +"True" = "Verdadero" +"Truncate text" = "Truncar texto" +"Truncate with an ellipsis" = "Truncar con puntos suspensivos" +"Try another search or filter." = "Prueba otra búsqueda u otro filtro." +"Two digits" = "Dos dígitos" +"Type" = "Tipo" +"Type settings" = "Ajustes de texto" +"Typography" = "Tipografía" +"Underline" = "Subrayado" +"Undo takes back only your own changes; what others changed since stays." = "Deshacer revierte solo tus propios cambios; lo que otros hayan cambiado después se mantiene." +"Ungroup" = "Desagrupar" +"Uniform" = "Uniforme" +"Union" = "Unión" +"Unions" = "Uniones" +"Unread" = "No leídos" +"Unresolve" = "Reabrir" +"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Cambios sin guardar: guarda primero ({save}) o cierra la pestaña con {alt}+clic para descartarlos" +"Unsaved changes: save first, or close again with {key} to discard" = "Cambios sin guardar: guarda primero o vuelve a cerrar con {key} para descartarlos" +"Up" = "Arriba" +"Update" = "Actualizar" +"Update from main" = "Actualizar desde la principal" +"Update from selection" = "Actualizar desde la selección" +"Updated {components} and {styles}" = "Actualizados {components} y {styles}" +"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." = "Sube este archivo a un servidor FigCraft y mantiene esta pestaña sincronizada con él. Obtienes un enlace para editar y otro para ver." +"Uppercase" = "Mayúsculas" +"Use a fixed value" = "Usar un valor fijo" +"Use an expression, e.g. count + 1" = "Usar una expresión, p. ej. count + 1" +"Use as mask" = "Usar como máscara" +"Use auto layout" = "Usar diseño automático" +"Use number keys for opacity" = "Usar teclas numéricas para la opacidad" +"Use the layer's fill" = "Usar el relleno de la capa" +"Used in {files} · {instances}" = "Usado en {files} · {instances}" +"Username" = "Usuario" +"Value" = "Valor" +"Variable" = "Variable" +"Variable collections" = "Colecciones de variables" +"Variable modes" = "Modos de variables" +"Variable width" = "Ancho variable" +"Variables" = "Variables" +"Variables JSON…" = "JSON de variables…" +"Variables store reusable values (colours, numbers, strings, booleans) with modes such as Light and Dark." = "Las variables guardan valores reutilizables (colores, números, cadenas, booleanos) con modos como Claro y Oscuro." +"Variant" = "Variante" +"Vector" = "Vector" +"Vectors" = "Vectores" +"Version" = "Versión" +"Version history" = "Historial de versiones" +"Version {version}" = "Versión {version}" +"Version {v} · {components} · {styles} · {variables}" = "Versión {v} · {components} · {styles} · {variables}" +"Vertical" = "Vertical" +"Vertical hex" = "Hexágono vertical" +"Vertical padding" = "Margen interno vertical" +"Vertical spacing" = "Espaciado vertical" +"View" = "Ver" +"View link" = "Enlace de lectura" +"View link copied" = "Enlace de lectura copiado" +"View only" = "Solo lectura" +"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "Solo lectura: ves cada cambio al momento, pero no puedes editar. Sal de la sesión para quedarte con una copia editable." +"Viewing “{name}”" = "Viendo “{name}”" +"Violet" = "Violeta" +"Visibility" = "Visibilidad" +"Visible" = "Visible" +"Vivid" = "Vívido" +"Warm" = "Cálido" +"Watch" = "Reloj" +"WebSocket URL" = "URL de WebSocket" +"WebSocket URL copied" = "URL de WebSocket copiada" +"Wedge" = "Cuña" +"Weight" = "Grosor" +"What changed since it was marked ready for dev" = "Qué cambió desde que se marcó como listo para desarrollo" +"What it's for" = "Para qué sirve" +"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." = "No se sabe desde qué cambió: cambió antes de que FigCraft guardara copias de los marcos listos para desarrollo. Resuelve el cambio (o vuelve a marcarlo como listo) para comparar a partir de ahora." +"While hovering" = "Mientras se pasa el puntero" +"While pressing" = "Mientras se presiona" +"White" = "Blanco" +"Why it changed" = "Por qué cambió" +"Width" = "Ancho" +"Width point" = "Punto de ancho" +"Width profile" = "Perfil de ancho" +"With the layers' export settings" = "Con los ajustes de exportación de las capas" +"Wrap" = "Ajustar" +"Wrap in new section" = "Envolver en una sección nueva" +"Yellow" = "Amarillo" +"Yesterday" = "Ayer" +"You" = "Tú" +"You'll get the host's file as a new tab and edit it together." = "Recibirás el archivo del anfitrión en una pestaña nueva para editarlo en conjunto." +"Your copy of the file stays open" = "Tu copia del archivo sigue abierta" +"Your name" = "Tu nombre" +"Your name in comments" = "Tu nombre en los comentarios" +"Your system asks for reduced motion: animations stay off." = "Tu sistema pide reducir el movimiento: las animaciones siguen desactivadas." +"Zoom" = "Zoom" +"Zoom around the pointer" = "Zoom alrededor del puntero" +"Zoom device to fill window" = "Ampliar el dispositivo hasta llenar la ventana" +"Zoom in" = "Acercar" +"Zoom out" = "Alejar" +"Zoom to 100%" = "Zoom al 100%" +"Zoom to 200%" = "Zoom al 200%" +"Zoom to 50%" = "Zoom al 50%" +"Zoom to fit" = "Zoom para ajustar" +"Zoom to selection" = "Zoom a la selección" +"Zoom tool: click to zoom in, drag an area" = "Herramienta de zoom: clic para acercar, arrastra un área" +"Zoom tool: zoom out" = "Herramienta de zoom: alejar" +"and {n} more" = { one = "y {n} más", other = "y {n} más" } +"bleed marks" = "marcas de sangrado" +"can edit the document" = "puede editar el documento" +"crop marks" = "marcas de corte" +"development: {path}" = "desarrollo: {path}" +"e.g. count + 1" = "p. ej. count + 1" +"e.g. count >= 3 and not done" = "p. ej. count >= 3 and not done" +"figcraft:// links from a browser, chat or email open this app and join the session." = "Los enlaces figcraft:// de un navegador, chat o correo abren esta app y se unen a la sesión." +"hidden" = "oculta" +"locked" = "bloqueada" +"no bleed" = "sin sangrado" +"nobody else here yet" = "aún no hay nadie más" +"now" = "ahora" +"or" = "o" +"reads the document" = "lee el documento" +"the plugin" = "el plugin" +"this session only" = "solo esta sesión" +"{instances} in {files}" = "{instances} en {files}" +"{language}: draft translation, waiting for a native speaker's review." = "{language}: traducción en borrador, pendiente de revisión por un hablante nativo." +"{length} bleed" = "sangrado de {length}" +"{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})" +"{name} (you)" = "{name} (tú)" +"{name} ({layers} layers)" = "{name} ({layers} capas)" +"{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}, predeterminado {default}. Haz doble clic en el control para volver al valor del estilo; Escape mientras arrastras cancela." +"{name} from {path}" = "{name} de {path}" +"{name} · deleted on main" = "{name} · eliminado en la principal" +"{name} · deleted on the branch" = "{name} · eliminado en la rama" +"{name} · not published" = "{name} · sin publicar" +"{name} · on a server" = "{name} · en un servidor" +"{name} · published version {v}" = "{name} · versión publicada {v}" +"{name} · version {v} on the server" = "{name} · versión {v} en el servidor" +"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} por {h}, seleccionado" +"{n} added" = { one = "{n} añadida", other = "{n} añadidas" } +"{n} autosave versions" = { one = "{n} versión autoguardada", other = "{n} versiones autoguardadas" } +"{n} changed" = { one = "{n} cambiada", other = "{n} cambiadas" } +"{n} columns" = { one = "{n} columna", other = "{n} columnas" } +"{n} components" = { one = "{n} componente", other = "{n} componentes" } +"{n} components and styles have newer versions" = { one = "{n} componente o estilo tiene una versión más reciente", other = "{n} componentes y estilos tienen versiones más recientes" } +"{n} conflicts to resolve" = { one = "{n} conflicto por resolver", other = "{n} conflictos por resolver" } +"{n} days ago" = { one = "hace {n} día", other = "hace {n} días" } +"{n} files" = { one = "{n} archivo", other = "{n} archivos" } +"{n} hours ago" = { one = "hace {n} hora", other = "hace {n} horas" } +"{n} instances" = { one = "{n} instancia", other = "{n} instancias" } +"{n} layers" = { one = "{n} capa", other = "{n} capas" } +"{n} layers as one file" = { one = "{n} capa como un solo archivo", other = "{n} capas como un solo archivo" } +"{n} layers selected" = { one = "{n} capa seleccionada", other = "{n} capas seleccionadas" } +"{n} layers · {parts}" = { one = "{n} capa · {parts}", other = "{n} capas · {parts}" } +"{n} matches" = { one = "{n} coincidencia", other = "{n} coincidencias" } +"{n} minutes ago" = { one = "hace {n} minuto", other = "hace {n} minutos" } +"{n} months ago" = { one = "hace {n} mes", other = "hace {n} meses" } +"{n} of {total}" = "{n} de {total}" +"{n} people joined" = { one = "{n} persona se ha unido", other = "{n} personas se han unido" } +"{n} preferred components" = { one = "{n} componente preferido", other = "{n} componentes preferidos" } +"{n} ready" = { one = "{n} listo", other = "{n} listos" } +"{n} removed" = { one = "{n} eliminada", other = "{n} eliminadas" } +"{n} replies" = { one = "{n} respuesta", other = "{n} respuestas" } +"{n} rows" = { one = "{n} fila", other = "{n} filas" } +"{n} styles" = { one = "{n} estilo", other = "{n} estilos" } +"{n} variables" = { one = "{n} variable", other = "{n} variables" } +"{n} weeks ago" = { one = "hace {n} semana", other = "hace {n} semanas" } +"{n} years ago" = { one = "hace {n} año", other = "hace {n} años" } +"{n}d" = "{n} d" +"{n}h" = "{n} h" +"{n}m" = "{n} min" +"{n}w" = "{n} sem" +"{page}, nothing selected" = "{page}, nada seleccionado" +"{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." +"{tool} hold {key}" = "{tool} mantén {key}" +"{when} · edited" = "{when} · editado" +"← ↑ → ↓" = "← ↑ → ↓" +"⇥ / ⇧⇥" = "⇥ / ⇧⇥" +"⌘ arrows" = "⌘ flechas" +"⌘ click" = "⌘ clic" +"⌘ scroll" = "⌘ desplazar" +"⌥ drag" = "⌥ arrastrar" + +[commands] +"align.bottom" = "Alinear abajo" +"align.hcenter" = "Alinear centros horizontales" +"align.left" = "Alinear a la izquierda" +"align.right" = "Alinear a la derecha" +"align.top" = "Alinear arriba" +"align.vcenter" = "Alinear centros verticales" +"annotation.add" = "Añadir anotación" +"annotation.addCategory" = "Añadir categoría de anotación" +"annotation.categories" = "Categorías de anotación" +"annotation.edit" = "Editar anotación" +"annotation.editCategory" = "Editar categoría de anotación" +"annotation.list" = "Listar anotaciones" +"annotation.remove" = "Quitar anotación" +"annotation.removeCategory" = "Eliminar categoría de anotación" +"app.actions" = "Acciones" +"app.preferences" = "Preferencias" +"arrange.back" = "Enviar al fondo" +"arrange.backward" = "Enviar atrás" +"arrange.forward" = "Traer adelante" +"arrange.front" = "Traer al frente" +"arrange.packH" = "Agrupar en horizontal" +"arrange.packV" = "Agrupar en vertical" +"arrange.roundToPixels" = "Redondear a píxeles" +"arrange.tidy" = "Ordenar" +"bool.exclude" = "Excluir selección" +"bool.flatten" = "Acoplar booleana" +"bool.intersect" = "Intersecar selección" +"bool.subtract" = "Restar selección" +"bool.union" = "Unir selección" +"branch.archive" = "Archivar rama" +"branch.create" = "Crear rama" +"branch.list" = "Listar ramas" +"branch.merge" = "Fusionar rama" +"branch.open" = "Abrir rama" +"branch.review" = "Revisar rama" +"branch.updateFromMain" = "Actualizar desde la principal" +"codeConnect.export" = "Exportar conexiones de código" +"codeConnect.import" = "Importar conexiones de código" +"codeConnect.remove" = "Desconectar del código" +"codeConnect.set" = "Conectar con código" +"collab.cursor" = "Mostrar cursor en" +"collab.diff" = "Mostrar cambios sincronizados" +"collab.follow" = "Seguir" +"collab.host" = "Iniciar sesión en vivo" +"collab.join" = "Unirse a una sesión en vivo" +"collab.leave" = "Salir de la sesión en vivo" +"collab.peers" = "Personas en este archivo" +"collab.publish" = "Publicar en el servidor" +"collab.registerLinks" = "Abrir enlaces de invitación en FigCraft" +"collab.stop" = "Detener sesión en vivo" +"command.batch" = "Ejecutar varios comandos" +"comment.add" = "Añadir comentario" +"comment.delete" = "Eliminar comentario" +"comment.edit" = "Editar comentario" +"comment.link" = "Copiar enlace al comentario" +"comment.list" = "Listar comentarios" +"comment.markRead" = "Marcar comentario como leído" +"comment.move" = "Mover comentario" +"comment.next" = "Siguiente comentario" +"comment.open" = "Abrir comentario" +"comment.people" = "Personas en los comentarios" +"comment.react" = "Reaccionar al comentario" +"comment.reply" = "Responder al comentario" +"comment.resolve" = "Resolver comentario" +"comment.zoom" = "Zoom a los comentarios" +"component.addProperty" = "Añadir propiedad de componente" +"component.addVariant" = "Añadir variante" +"component.bindProperty" = "Vincular capa a propiedad" +"component.combineVariants" = "Combinar como variantes" +"component.create" = "Crear componente" +"component.createMultiple" = "Crear varios componentes" +"component.deleteProperty" = "Eliminar propiedad de componente" +"component.detach" = "Separar instancia" +"component.editProperty" = "Editar propiedad de componente" +"component.exposeInstance" = "Exponer instancia anidada" +"component.goToMain" = "Ir al componente principal" +"component.list" = "Listar componentes" +"component.makeSlot" = "Convertir en ranura" +"component.properties" = "Propiedades del componente" +"component.pushChanges" = "Enviar cambios al componente principal" +"component.removeSlot" = "Quitar ranura" +"component.resetInstance" = "Restablecer todos los cambios" +"component.resetOverride" = "Restablecer modificación" +"component.restoreMain" = "Restaurar componente principal" +"dev.compare" = "Comparar cambios" +"dev.focus" = "Vista de enfoque" +"dev.resolveChange" = "Resolver cambio" +"dev.unfocus" = "Salir de la vista de enfoque" +"distribute.horizontal" = "Distribuir espaciado horizontal" +"distribute.vertical" = "Distribuir espaciado vertical" +"doc.home" = "Inicio" +"doc.list" = "Listar documentos" +"doc.nextTab" = "Pestaña siguiente" +"doc.previousTab" = "Pestaña anterior" +"doc.switch" = "Cambiar de documento" +"edit.copy" = "Copiar" +"edit.copyAsCss" = "Copiar como CSS" +"edit.copyAsPng" = "Copiar como PNG" +"edit.copyAsSvg" = "Copiar como SVG" +"edit.copyAsText" = "Copiar como texto" +"edit.copyLink" = "Copiar enlace a la selección" +"edit.copyProperties" = "Copiar propiedades" +"edit.cut" = "Cortar" +"edit.delete" = "Eliminar" +"edit.duplicate" = "Duplicar" +"edit.findReplace" = "Buscar y reemplazar" +"edit.paste" = "Pegar" +"edit.pasteOver" = "Pegar sobre la selección" +"edit.pasteProperties" = "Pegar propiedades" +"edit.pasteReplace" = "Pegar para reemplazar" +"edit.pickColor" = "Elegir color" +"edit.redo" = "Rehacer" +"edit.repeat" = "Repetir el último comando" +"edit.setDefaultProperties" = "Establecer propiedades predeterminadas" +"edit.undo" = "Deshacer" +"effect.add" = "Añadir efecto" +"effect.move" = "Mover efecto" +"effect.remove" = "Quitar efecto" +"effect.set" = "Establecer efecto" +"export.add" = "Añadir ajuste de exportación" +"export.remove" = "Quitar ajuste de exportación" +"export.update" = "Editar ajuste de exportación" +"file.close" = "Cerrar pestaña" +"file.colorProfile" = "Perfil de color" +"file.duplicate" = "Duplicar archivo" +"file.export" = "Exportar" +"file.exportFramesPdf" = "Exportar marcos a PDF" +"file.exportLayers" = "Exportar capas" +"file.exportSvg" = "Exportar selección como SVG" +"file.import" = "Importar" +"file.new" = "Nuevo archivo de diseño" +"file.open" = "Abrir" +"file.openRecent" = "Abrir reciente" +"file.placeImage" = "Colocar imagen" +"file.rename" = "Cambiar nombre del archivo" +"file.revert" = "Volver a lo guardado" +"file.save" = "Guardar" +"file.saveAs" = "Guardar como" +"file.saveVersion" = "Guardar en el historial de versiones" +"file.versionHistory" = "Mostrar historial de versiones" +"fill.add" = "Añadir relleno" +"fill.move" = "Mover relleno" +"fill.pattern" = "Relleno de patrón" +"fill.remove" = "Quitar relleno" +"fill.set" = "Establecer relleno" +"frame.create" = "Crear marco" +"frame.presets" = "Listar ajustes predefinidos de marco" +"frame.swapOrientation" = "Cambiar orientación" +"grid.add" = "Añadir cuadrícula de diseño" +"grid.move" = "Mover cuadrícula de diseño" +"grid.remove" = "Quitar cuadrícula de diseño" +"guide.add" = "Añadir guía" +"guide.clear" = "Quitar todas las guías" +"guide.move" = "Mover guía" +"guide.remove" = "Quitar guía" +"help.about" = "Acerca de FigCraft" +"help.page" = "Página de ayuda" +"help.shortcuts" = "Atajos de teclado" +"image.adjust" = "Ajustar imagen" +"image.deletePreset" = "Eliminar ajuste predefinido de imagen" +"image.place" = "Colocar imagen" +"image.presets" = "Ajustes predefinidos de imagen" +"image.savePreset" = "Guardar ajuste predefinido de imagen" +"inspect.assets" = "Inspeccionar recursos" +"inspect.code" = "Código de la capa" +"inspect.componentCode" = "Código del componente" +"inspect.properties" = "Inspeccionar propiedades" +"inspect.readyForDev" = "Listo para desarrollo" +"instance.create" = "Crear instancia" +"instance.exposed" = "Instancias anidadas expuestas" +"instance.place" = "Colocar instancia" +"instance.resetSlot" = "Restablecer ranura" +"instance.setProperties" = "Establecer propiedades de la instancia" +"instance.slotInsert" = "Insertar en la ranura" +"instance.swap" = "Intercambiar instancia" +"instance.swapAt" = "Intercambiar instancia en un punto" +"layers.collapseAll" = "Contraer capas" +"layout.add" = "Añadir diseño automático" +"layout.remove" = "Quitar diseño automático" +"layout.setTrack" = "Establecer tamaño de pista de la cuadrícula" +"layout.suggest" = "Sugerir diseño automático" +"libraries.open" = "Bibliotecas" +"libraries.publish" = "Publicar biblioteca…" +"library.acceptUpdates" = "Aceptar actualizaciones de la biblioteca" +"library.add" = "Añadir biblioteca" +"library.aliasVariable" = "Crear alias de variable de biblioteca" +"library.applyStyle" = "Aplicar estilo de biblioteca" +"library.bindVariable" = "Vincular variable de biblioteca" +"library.extendCollection" = "Ampliar colección de biblioteca" +"library.import" = "Importar componente de biblioteca" +"library.importVariable" = "Importar variable de biblioteca" +"library.list" = "Listar bibliotecas" +"library.place" = "Colocar componente de biblioteca" +"library.publish" = "Publicar biblioteca" +"library.publishToServer" = "Publicar biblioteca en el servidor" +"library.remove" = "Quitar biblioteca" +"library.setVariableMode" = "Establecer modo de variable de biblioteca" +"library.swap" = "Intercambiar biblioteca" +"library.swapAt" = "Intercambiar instancia por componente de biblioteca" +"library.updates" = "Actualizaciones de la biblioteca" +"library.usage" = "Uso de la biblioteca" +"measure.distances" = "Medir distancias" +"measure.padding" = "Medir margen interno del diseño automático" +"node.create" = "Crear capa" +"node.get" = "Obtener capa" +"node.reorder" = "Reordenar capa" +"node.reparent" = "Mover capa dentro de" +"node.set" = "Establecer propiedades" +"object.batchRename" = "Cambiar nombre en lote" +"object.flipH" = "Voltear horizontalmente" +"object.flipV" = "Voltear verticalmente" +"object.frameSelection" = "Enmarcar selección" +"object.group" = "Agrupar selección" +"object.hideOthers" = "Ocultar las demás capas" +"object.mask" = "Usar como máscara" +"object.moveToPage" = "Mover a la página" +"object.opacityKeys" = "Opacidad con las teclas numéricas" +"object.rasterize" = "Rasterizar selección" +"object.releaseMask" = "Soltar máscara" +"object.removeFill" = "Quitar relleno" +"object.removeFrame" = "Quitar marco" +"object.removeStroke" = "Quitar trazo" +"object.rename" = "Cambiar nombre" +"object.resizeToFit" = "Ajustar tamaño al contenido" +"object.rotate180" = "Girar 180°" +"object.rotateLeft" = "Girar 90° a la izquierda" +"object.rotateRight" = "Girar 90° a la derecha" +"object.setThumbnail" = "Establecer como miniatura" +"object.swapFillStroke" = "Intercambiar relleno y trazo" +"object.toggleLock" = "Bloquear/desbloquear selección" +"object.toggleVisible" = "Mostrar/ocultar selección" +"object.ungroup" = "Desagrupar selección" +"object.wrapInSection" = "Envolver en una sección nueva" +"page.add" = "Añadir página" +"page.delete" = "Eliminar página" +"page.duplicate" = "Duplicar página" +"page.list" = "Listar páginas" +"page.move" = "Mover página" +"page.rename" = "Cambiar nombre de la página" +"page.select" = "Ir a la página" +"page.setBackground" = "Fondo de la página" +"paint.documentColors" = "Colores del documento" +"plugin.close" = "Cerrar panel del plugin" +"plugin.event" = "Usar control del panel del plugin" +"plugin.import" = "Importar plugin desde manifiesto…" +"plugin.list" = "Listar plugins" +"plugin.remove" = "Quitar plugin" +"plugin.run" = "Ejecutar plugin" +"plugin.ui" = "Panel del plugin" +"plugins.manage" = "Gestionar plugins…" +"plugins.runLast" = "Ejecutar el último plugin" +"pref.highlightHover" = "Resaltar capas al pasar el cursor" +"pref.keepTool" = "Mantener la herramienta tras usarla" +"pref.language" = "Idioma de la interfaz" +"pref.nudge" = "Cantidad de desplazamiento" +"pref.numberKeysOpacity" = "Usar teclas numéricas para la opacidad" +"pref.reduceMotion" = "Reducir movimiento" +"pref.renameDuplicates" = "Renombrar capas duplicadas" +"pref.showDimensions" = "Mostrar dimensiones en los objetos" +"pref.userName" = "Tu nombre en los comentarios" +"proto.action.add" = "Añadir acción de prototipo" +"proto.action.move" = "Reordenar acción de prototipo" +"proto.action.remove" = "Quitar acción de prototipo" +"proto.action.set" = "Editar acción de prototipo" +"proto.closePreview" = "Cerrar vista previa del prototipo" +"proto.conditional.addBlock" = "Añadir si no, si / si no" +"proto.conditional.removeBlock" = "Quitar si / si no, si / si no" +"proto.conditional.setCondition" = "Editar condición" +"proto.flow.add" = "Añadir punto de inicio de flujo" +"proto.flow.remove" = "Quitar flujo" +"proto.flow.rename" = "Cambiar nombre del flujo" +"proto.interaction.add" = "Añadir interacción" +"proto.interaction.clear" = "Quitar todas las interacciones" +"proto.interaction.remove" = "Quitar interacción" +"proto.interaction.set" = "Editar interacción" +"proto.present" = "Presentar" +"proto.presentOptions" = "Opciones de presentación" +"proto.preview" = "Abrir vista previa del prototipo" +"proto.settings" = "Ajustes del prototipo" +"section.toggleContents" = "Ocultar/mostrar contenido de la sección" +"select.add" = "Añadir a la selección" +"select.all" = "Seleccionar todo" +"select.byName" = "Seleccionar capas por nombre" +"select.children" = "Seleccionar hijos" +"select.inverse" = "Invertir selección" +"select.layersAt" = "Capas en un punto" +"select.matching" = "Seleccionar capas coincidentes" +"select.next" = "Seleccionar el siguiente elemento hermano" +"select.none" = "No seleccionar nada" +"select.parent" = "Seleccionar elemento padre" +"select.previous" = "Seleccionar el elemento hermano anterior" +"select.remove" = "Quitar de la selección" +"select.sameEffect" = "Seleccionar mismo efecto" +"select.sameFill" = "Seleccionar mismo relleno" +"select.sameFont" = "Seleccionar misma fuente" +"select.sameInstance" = "Seleccionar misma instancia" +"select.sameProperties" = "Seleccionar mismas propiedades" +"select.sameStroke" = "Seleccionar mismo trazo" +"select.sameText" = "Seleccionar mismas propiedades de texto" +"select.set" = "Seleccionar" +"shape.arrow" = "Flecha" +"shape.ellipse" = "Elipse" +"shape.line" = "Línea" +"shape.polygon" = "Polígono" +"shape.rectangle" = "Rectángulo" +"shape.star" = "Estrella" +"shapeBuilder.merge" = "Fusionar regiones" +"shapeBuilder.mode" = "Modo del generador de formas" +"shapeBuilder.regions" = "Regiones del generador de formas" +"shapeBuilder.remove" = "Quitar regiones" +"stroke.add" = "Añadir trazo" +"stroke.brush" = "Pincel" +"stroke.move" = "Mover trazo" +"stroke.remove" = "Quitar trazo" +"stroke.set" = "Establecer trazo" +"stroke.widthProfile" = "Perfil de ancho" +"style.apply" = "Aplicar estilo" +"style.create" = "Crear estilo" +"style.delete" = "Eliminar estilo" +"style.detach" = "Separar estilo" +"style.list" = "Listar estilos" +"style.update" = "Editar estilo" +"text.alignCenter" = "Centrar" +"text.alignJustify" = "Justificar" +"text.alignLeft" = "Alinear a la izquierda" +"text.alignRight" = "Alinear a la derecha" +"text.bold" = "Negrita" +"text.bulletList" = "Lista con viñetas" +"text.case" = "Mayúsculas y minúsculas" +"text.caseLower" = "Minúsculas" +"text.caseOriginal" = "Mayúsculas originales" +"text.caseSmallCaps" = "Versalitas" +"text.caseTitle" = "Tipo título" +"text.caseUpper" = "Mayúsculas" +"text.create" = "Texto" +"text.decoration" = "Decoración" +"text.edit.begin" = "Editar texto" +"text.edit.caret" = "Colocar el cursor de texto" +"text.edit.end" = "Dejar de editar el texto" +"text.edit.insert" = "Escribir texto" +"text.edit.key" = "Tecla de texto" +"text.edit.preedit" = "Componer texto (IME)" +"text.edit.selectLine" = "Seleccionar línea" +"text.edit.selectWord" = "Seleccionar palabra" +"text.edit.selection" = "Selección de texto" +"text.endPreview" = "Terminar vista previa del texto" +"text.fonts" = "Listar fuentes" +"text.italic" = "Cursiva" +"text.lineHeightDown" = "Reducir interlineado" +"text.lineHeightUp" = "Aumentar interlineado" +"text.link" = "Crear enlace" +"text.listStyle" = "Estilo de lista" +"text.maxLines" = "Máximo de líneas" +"text.numberList" = "Lista numerada" +"text.onPath" = "Texto en trazado" +"text.paragraphIndent" = "Sangría de párrafo" +"text.paragraphSpacing" = "Espacio entre párrafos" +"text.pathOptions" = "Opciones de texto en trazado" +"text.preview" = "Previsualizar cambio de texto" +"text.removeFromPath" = "Quitar texto del trazado" +"text.set" = "Establecer texto" +"text.setFeatures" = "Funciones OpenType" +"text.setVariations" = "Ejes de fuente variable" +"text.sizeDown" = "Reducir tamaño de fuente" +"text.sizeUp" = "Aumentar tamaño de fuente" +"text.strikethrough" = "Tachado" +"text.style" = "Aplicar estilo a un rango de texto" +"text.trackingDown" = "Reducir espaciado" +"text.trackingUp" = "Aumentar espaciado" +"text.underline" = "Subrayado" +"text.weightDown" = "Reducir peso de fuente" +"text.weightUp" = "Aumentar peso de fuente" +"tool.arrow" = "Flecha" +"tool.comment" = "Comentario" +"tool.ellipse" = "Elipse" +"tool.frame" = "Marco" +"tool.hand" = "Mano" +"tool.line" = "Línea" +"tool.move" = "Mover" +"tool.pen" = "Pluma" +"tool.pencil" = "Lápiz" +"tool.pencilBrush" = "Pincel del lápiz" +"tool.polygon" = "Polígono" +"tool.rectangle" = "Rectángulo" +"tool.scale" = "Escalar" +"tool.scaleAnchor" = "Ancla de escala" +"tool.section" = "Sección" +"tool.set" = "Elegir herramienta" +"tool.shapeBuilder" = "Generador de formas" +"tool.slice" = "Sector" +"tool.star" = "Estrella" +"tool.text" = "Texto" +"transform.move" = "Mover" +"transform.moveTo" = "Mover a" +"transform.nudge" = "Desplazar" +"transform.nudgeSize" = "Redimensionar con desplazamiento" +"transform.resize" = "Redimensionar" +"transform.rotate" = "Girar" +"transform.scale" = "Escalar" +"variable.addMode" = "Añadir modo" +"variable.bind" = "Vincular variable" +"variable.create" = "Crear variable" +"variable.createCollection" = "Crear colección de variables" +"variable.delete" = "Eliminar variable" +"variable.deleteCollection" = "Eliminar colección" +"variable.duplicate" = "Duplicar variable" +"variable.export" = "Exportar variables" +"variable.import" = "Importar variables" +"variable.list" = "Listar variables" +"variable.removeMode" = "Eliminar modo" +"variable.renameCollection" = "Cambiar nombre de la colección" +"variable.renameMode" = "Cambiar nombre del modo" +"variable.resetOverride" = "Restablecer modificación de variable" +"variable.set" = "Establecer valor de variable" +"variable.setMode" = "Establecer modo de variable" +"variable.update" = "Editar variable" +"vector.create" = "Vector" +"vector.deleteHeal" = "Eliminar y reparar selección" +"vector.edit.addVertex" = "Añadir punto" +"vector.edit.begin" = "Editar vector" +"vector.edit.bend" = "Curvar segmento" +"vector.edit.bounds" = "Límites de los puntos seleccionados" +"vector.edit.connect" = "Conectar puntos" +"vector.edit.cornerRadius" = "Radio de esquina del punto" +"vector.edit.cut" = "Cortar trazado" +"vector.edit.delete" = "Eliminar puntos" +"vector.edit.end" = "Terminar edición" +"vector.edit.erase" = "Borrar" +"vector.edit.lasso" = "Selección con lazo" +"vector.edit.mirroring" = "Simetría de manejadores" +"vector.edit.moveVertices" = "Mover puntos" +"vector.edit.nudge" = "Desplazar puntos" +"vector.edit.paint" = "Bote de pintura" +"vector.edit.pen" = "Punto de pluma" +"vector.edit.penEnd" = "Terminar trazado de pluma" +"vector.edit.position" = "Posición del punto" +"vector.edit.resize" = "Redimensionar puntos" +"vector.edit.rotate" = "Girar puntos" +"vector.edit.select" = "Seleccionar puntos" +"vector.edit.selectAll" = "Seleccionar todos los puntos" +"vector.edit.setHandle" = "Establecer manejador" +"vector.edit.smooth" = "Suavizar o afilar puntos" +"vector.edit.state" = "Estado de edición vectorial" +"vector.edit.tool" = "Herramienta de edición vectorial" +"vector.edit.transform" = "Transformar puntos" +"vector.edit.widthPoint" = "Punto de ancho" +"vector.flatten" = "Acoplar" +"vector.join" = "Unir selección" +"vector.offset" = "Desplazar trazado" +"vector.outlineStroke" = "Contornear trazo" +"vector.reverse" = "Invertir dirección del trazado" +"vector.simplify" = "Simplificar vector" +"vector.smoothJoin" = "Unir selección con suavizado" +"version.autosave" = "Guardar versión automáticamente" +"version.delete" = "Eliminar versión" +"version.duplicate" = "Duplicar versión" +"version.exitPreview" = "Volver a la versión actual" +"version.list" = "Listar versiones" +"version.preview" = "Ver versión" +"version.rename" = "Cambiar nombre de la versión" +"version.restore" = "Restaurar versión" +"view.assets" = "Panel de recursos" +"view.comments" = "Comentarios" +"view.design" = "Panel de diseño" +"view.designPrototype" = "Alternar entre Diseño y Prototipo" +"view.devMode" = "Modo desarrollo" +"view.frameOutlines" = "Contornos de marcos" +"view.get" = "Obtener vista" +"view.layers" = "Panel de capas" +"view.layoutGrids" = "Cuadrículas de diseño" +"view.leftSidebar" = "Mostrar/ocultar barra lateral izquierda" +"view.maskOutlines" = "Contornos de máscaras" +"view.nextFrame" = "Zoom al siguiente marco" +"view.nextPage" = "Página siguiente" +"view.nextRegion" = "Enfocar la siguiente región" +"view.outlines" = "Contornos" +"view.outlinesSelection" = "Contornos de la selección" +"view.pixelGrid" = "Cuadrícula de píxeles" +"view.pixelPreview" = "Vista previa de píxeles" +"view.prevFrame" = "Zoom al marco anterior" +"view.prevPage" = "Página anterior" +"view.previousRegion" = "Enfocar la región anterior" +"view.prototype" = "Panel de prototipo" +"view.rulers" = "Reglas" +"view.set" = "Establecer vista" +"view.slices" = "Sectores" +"view.snapGeometry" = "Ajustar a la geometría" +"view.snapObjects" = "Ajustar a los objetos" +"view.snapPixel" = "Ajustar a la cuadrícula de píxeles" +"view.themeDark" = "Tema oscuro" +"view.themeLight" = "Tema claro" +"view.themeSystem" = "Seguir el tema del sistema" +"view.toggleUi" = "Mostrar/ocultar interfaz" +"view.zoom100" = "Zoom al 100 %" +"view.zoom200" = "Zoom al 200 %" +"view.zoom50" = "Zoom al 50 %" +"view.zoomAt" = "Herramienta de zoom" +"view.zoomFit" = "Zoom para ajustar" +"view.zoomIn" = "Acercar" +"view.zoomOut" = "Alejar" +"view.zoomSelection" = "Zoom a la selección" + +[menu] +"bool.exclude" = "Excluir" +"bool.flatten" = "Acoplar" +"bool.intersect" = "Intersecar" +"bool.subtract" = "Restar" +"bool.union" = "Unir" +"file.duplicate" = "Duplicar" +"file.import" = "Importar desde archivo…" +"file.rename" = "Cambiar nombre" +"libraries.open" = "Abrir bibliotecas" +"select.sameEffect" = "Efecto" +"select.sameFill" = "Relleno" +"select.sameFont" = "Fuente" +"select.sameInstance" = "Instancia" +"select.sameProperties" = "Propiedades" +"select.sameStroke" = "Trazo" +"select.sameText" = "Propiedades de texto" +"text.alignCenter" = "Centrar texto" +"text.alignJustify" = "Justificar texto" +"text.alignLeft" = "Alinear texto a la izquierda" +"text.alignRight" = "Alinear texto a la derecha" +"view.assets" = "Recursos" +"view.design" = "Diseño" +"view.layers" = "Capas" +"view.outlines" = "Mostrar contornos" +"view.outlinesSelection" = "Mostrar contornos de la selección" +"view.prototype" = "Prototipo" +"view.themeDark" = "Oscuro" +"view.themeLight" = "Claro" +"view.themeSystem" = "Seguir al sistema" diff --git a/crates/ui-egui/src/a11y.rs b/crates/ui-egui/src/a11y.rs index 83e049d..f84a155 100644 --- a/crates/ui-egui/src/a11y.rs +++ b/crates/ui-egui/src/a11y.rs @@ -23,6 +23,7 @@ use egui::{Context, CornerRadius, Event, FocusDirection, Id, Key, LayerId, Modif use figcraft_engine::keymap; use crate::FigApp; +use crate::i18n::tr; use crate::theme::Tokens; /// The commands F6 and Shift+F6 are bound to. @@ -201,7 +202,7 @@ pub fn canvas(nav: &mut Nav, resp: &Response, describe: impl FnOnce() -> String) nav.next.canvas = Some(resp.id); area(nav, Region::Canvas, resp.rect); shared(&resp.ctx, |s| s.canvas = Some(resp.id)); - node(resp, "Canvas", |n| { + node(resp, tr("Canvas"), |n| { n.set_role(Role::Group); n.set_description(describe()); }); @@ -909,7 +910,7 @@ pub fn tree_item(resp: &Response, name: &str, info: &RowInfo) { n.set_expanded(open); } let mut d = info.kind.to_string(); - for (on, word) in [(info.hidden, "hidden"), (info.locked, "locked")] { + for (on, word) in [(info.hidden, tr("hidden")), (info.locked, tr("locked"))] { if on { d.push_str(", "); d.push_str(word); diff --git a/crates/ui-egui/src/assets_panel.rs b/crates/ui-egui/src/assets_panel.rs index 8b328a1..8eb9d22 100644 --- a/crates/ui-egui/src/assets_panel.rs +++ b/crates/ui-egui/src/assets_panel.rs @@ -21,6 +21,7 @@ use serde_json::{Value, json}; use crate::FigApp; use crate::a11y::Tip; +use crate::i18n::{tr, trf, trn}; use crate::icons::{self, Icon}; use crate::theme::{COMPONENT, Tokens, regular, semibold}; @@ -203,7 +204,7 @@ fn build_listing(app: &FigApp, doc: &Document, q: &str, key: (u64, String, u64)) lib.doc.styles.iter().filter(|s| m.style(&s.id).is_some_and(|x| x.published) && matches(&s.name, q)).map(StyleRow::of).collect(); sec.doc = Some(lib.doc.clone()); } - Some(Ok(lib)) => sec.error = Some(format!("The file now holds another library (\"{}\")", lib.manifest.name)), + Some(Ok(lib)) => sec.error = Some(trf("The file now holds another library (\"{name}\")", &[("name", &lib.manifest.name)])), Some(Err(e)) => sec.error = Some(e.to_string()), None => {} } @@ -259,7 +260,7 @@ fn search_row(app: &mut FigApp, ui: &mut Ui, w: f32) -> String { .id(Id::new("asset_filter_field")) .frame(egui::Frame::NONE) .font(regular(11.0)) - .hint_text("Search assets") + .hint_text(tr("Search assets")) .desired_width(te_rect.width()), ); if r.has_focus() { @@ -270,7 +271,7 @@ fn search_row(app: &mut FigApp, ui: &mut Ui, w: f32) -> String { } ui.data_mut(|d| d.insert_temp(filter_id, filter.clone())); let br = Rect::from_center_size(pos2(fr.max.x - 26.0, field.center().y), vec2(24.0, 24.0)); - let bresp = ui.interact(br, Id::new("asset_libraries_btn"), Sense::click()).tip("Libraries"); + let bresp = ui.interact(br, Id::new("asset_libraries_btn"), Sense::click()).tip(tr("Libraries")); if bresp.hovered() { ui.painter().rect_filled(br, CornerRadius::same(5), t.hover); } @@ -287,20 +288,17 @@ fn updates_banner(app: &mut FigApp, ui: &mut Ui, w: f32, count: usize) { let (row, _) = ui.allocate_exact_size(vec2(w, 44.0), Sense::hover()); let r = Rect::from_min_max(pos2(row.min.x + 12.0, row.min.y + 4.0), pos2(row.max.x - 12.0, row.max.y - 4.0)); ui.painter().rect_filled(r, CornerRadius::same(6), t.selected_row); - let what = if count == 1 { - "1 component or style has a newer version".to_string() - } else { - format!("{count} components and styles have newer versions") - }; + let what = trn("{n} components and styles have newer versions", count, &[]); ui.interact(r, Id::new("asset_updates"), Sense::hover()).on_hover_text(what); - ui.painter().text(pos2(r.min.x + 10.0, r.center().y), Align2::LEFT_CENTER, "Library updates available", semibold(11.0), t.text); + ui.painter().text(pos2(r.min.x + 10.0, r.center().y), Align2::LEFT_CENTER, tr("Library updates available"), semibold(11.0), t.text); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_max(pos2(r.max.x - 70.0, r.min.y + 6.0), pos2(r.max.x - 6.0, r.max.y)))); - if crate::widgets::button(&mut child, "Update", true, true).clicked() + if crate::widgets::button(&mut child, tr("Update"), true, true).clicked() && let Some(v) = app.run("library.acceptUpdates", json!({})) { let (c, s) = (v["components"].as_u64().unwrap_or(0), v["styles"].as_u64().unwrap_or(0)); - app.toast(format!("Updated {c} component{} and {s} style{}", if c == 1 { "" } else { "s" }, if s == 1 { "" } else { "s" })); + let (c, s) = (trn("{n} components", c as usize, &[]), trn("{n} styles", s as usize, &[])); + app.toast(trf("Updated {components} and {styles}", &[("components", &c), ("styles", &s)])); } } @@ -325,7 +323,7 @@ fn library_title(ui: &mut Ui, w: f32, name: &str, open: bool) -> bool { t.text, hr.max.x - chevron.max.x - 6.0 - PAD, ); - resp.on_hover_text(if open { "Library: click to collapse" } else { "Library: click to expand" }).clicked() + resp.on_hover_text(if open { tr("Library: click to collapse") } else { tr("Library: click to expand") }).clicked() } /// Secondary text, wrapped to the panel. @@ -383,9 +381,9 @@ fn tiles(ui: &mut Ui, w: f32, src: &Source, list: &[Tile], thumbs: &Thumbs, want } let label = Rect::from_min_size(pos2(x, r.max.y + 2.0), vec2(tile, TILE_LABEL - 4.0)); crate::bidi::paint_name(ui.painter(), pos2(x, label.center().y), &item.name, regular(11.0), t.text, tile); - let resp = resp.on_hover_text(format!( - "{}\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.", - item.name + let resp = resp.on_hover_text(trf( + "{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", &item.name)], )); if resp.drag_started() { *act = Some(Act::Drag(AssetDrag { src: src.clone(), component: item.id, set: item.set })); @@ -421,7 +419,7 @@ fn style_rows(ui: &mut Ui, w: f32, src: &Source, list: &[StyleRow], act: &mut Op icons::paint(ui.painter(), icon_rect, i, t.icon_secondary); } crate::bidi::paint_name(ui.painter(), pos2(r.min.x + 40.0, r.center().y), &s.name, regular(11.0), t.text, w - 40.0 - PAD); - if resp.on_hover_text(format!("{}\nClick to apply to the selection", s.name)).clicked() { + if resp.on_hover_text(trf("{name}\nClick to apply to the selection", &[("name", &s.name)])).clicked() { *act = Some(Act::Style(src.clone(), s.id.clone())); } } @@ -514,21 +512,21 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { let collapsed: HashSet = ui.data(|d| d.get_temp(collapsed_id)).unwrap_or_default(); let mut act = None; egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { - title(ui, w, "Local components"); + title(ui, w, tr("Local components")); if listing.local.is_empty() { if q.is_empty() { let keys = crate::toolbar::pretty_shortcut(crate::shortcuts::shortcut("component.create").unwrap_or("")); - hint(ui, &format!("No components yet. Select layers and press {keys}.")); + hint(ui, &trf("No components yet. Select layers and press {keys}.", &[("keys", &keys)])); } else { - hint(ui, "No matching components"); + hint(ui, tr("No matching components")); } } tiles(ui, w, &Source::Local, &listing.local, &thumbs, &mut wanted, &mut act); crate::widgets::separator(ui); ui.add_space(4.0); - title(ui, w, "Local styles"); + title(ui, w, tr("Local styles")); if listing.local_styles.is_empty() { - hint(ui, if q.is_empty() { "No styles yet." } else { "No matching styles" }); + hint(ui, if q.is_empty() { tr("No styles yet.") } else { tr("No matching styles") }); } style_rows(ui, w, &Source::Local, &listing.local_styles, &mut act); for sec in &listing.libraries { @@ -542,11 +540,11 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { continue; } if let Some(e) = &sec.error { - hint(ui, &format!("Can't read this library: {e}")); + hint(ui, &trf("Can't read this library: {e}", &[("e", e)])); continue; } if sec.doc.is_none() { - hint(ui, "Reading the library…"); + hint(ui, tr("Reading the library…")); continue; } let src = Source::Library(sec.id.clone()); @@ -590,7 +588,7 @@ fn place(app: &mut FigApp, src: &Source, id: NodeId, x: f64, y: f64) { fn apply_style(app: &mut FigApp, src: &Source, style: &str) { if app.session.selection().is_empty() { - app.toast("Select layers to apply the style to"); + app.toast(tr("Select layers to apply the style to")); return; } match src { @@ -697,12 +695,12 @@ fn libraries_view(app: &mut FigApp, ui: &mut Ui) { let w = ui.available_width(); let (hr, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); let back = Rect::from_center_size(pos2(hr.min.x + 24.0, hr.center().y), vec2(24.0, 24.0)); - let bresp = ui.interact(back, Id::new("asset_libraries_back"), Sense::click()).tip("Back to assets"); + let bresp = ui.interact(back, Id::new("asset_libraries_back"), Sense::click()).tip(tr("Back to assets")); if bresp.hovered() { ui.painter().rect_filled(back, CornerRadius::same(5), t.hover); } icons::paint(ui.painter(), back.shrink(5.0), Icon::ChevronLeft, t.icon); - ui.painter().text(pos2(back.max.x + 6.0, hr.center().y), Align2::LEFT_CENTER, "Libraries", semibold(12.0), t.text); + ui.painter().text(pos2(back.max.x + 6.0, hr.center().y), Align2::LEFT_CENTER, tr("Libraries"), semibold(12.0), t.text); if bresp.clicked() { app.ui.libraries = false; return; @@ -719,7 +717,7 @@ fn libraries_view(app: &mut FigApp, ui: &mut Ui) { fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { let form_id = Id::new("asset_publish_form"); let mut form: PublishForm = ui.data(|d| d.get_temp(form_id)).unwrap_or_default(); - title(ui, w, "This file"); + title(ui, w, tr("This file")); let m = doc.published.as_ref().map(|p| &p.manifest); // A file on a server publishes its library there (a live file doesn't keep a local one). let server = app.session.server_link(); @@ -728,15 +726,15 @@ fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { _ => None, }); let status = match (&server, on_server, m) { - (Some(_), Some(v), _) => format!("{} · version {v} on the server", doc.name), - (Some(_), None, _) => format!("{} · on a server", doc.name), - (None, _, Some(m)) => format!("{} · published version {}", doc.name, m.version), - (None, _, None) => format!("{} · not published", doc.name), + (Some(_), Some(v), _) => trf("{name} · version {v} on the server", &[("name", &doc.name), ("v", &v)]), + (Some(_), None, _) => trf("{name} · on a server", &[("name", &doc.name)]), + (None, _, Some(m)) => trf("{name} · published version {v}", &[("name", &doc.name), ("v", &m.version)]), + (None, _, None) => trf("{name} · not published", &[("name", &doc.name)]), }; hint(ui, &status); let all = library::publishable(doc); if all.is_empty() { - hint(ui, "Create components, styles or variables to publish them as a library."); + hint(ui, tr("Create components, styles or variables to publish them as a library.")); return; } let def = library::default_selection(doc, m); @@ -755,7 +753,7 @@ fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { on }; if !all.components.is_empty() { - crate::widgets::sublabels(ui, "Components", None); + crate::widgets::sublabels(ui, tr("Components"), None); for c in &all.components { let name = doc.node(*c).map(|n| n.name.as_str()).unwrap_or_default(); if check(ui, format!("c{}", c.0), def.components.contains(c), name) { @@ -764,7 +762,7 @@ fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { } } if !all.styles.is_empty() { - crate::widgets::sublabels(ui, "Styles", None); + crate::widgets::sublabels(ui, tr("Styles"), None); for s in &all.styles { let name = doc.style(s).map(|x| x.name.as_str()).unwrap_or_default(); if check(ui, format!("s{s}"), def.styles.contains(s), name) { @@ -773,7 +771,7 @@ fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { } } if !all.collections.is_empty() { - crate::widgets::sublabels(ui, "Variable collections", None); + crate::widgets::sublabels(ui, tr("Variable collections"), None); for c in &all.collections { let name = doc.variables.collection(c).map(|x| x.name.as_str()).unwrap_or_default(); if check(ui, format!("v{c}"), def.collections.contains(c), name) { @@ -785,7 +783,9 @@ fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { let description = form.description.clone().unwrap_or_else(|| m.map(|m| m.description.clone()).unwrap_or_default()); ui.horizontal(|ui| { ui.add_space(PAD); - if let Some(d) = crate::widgets::text_field(ui, Id::new("asset_publish_description"), &description, w - PAD * 2.0, "Description (optional)") { + if let Some(d) = + crate::widgets::text_field(ui, Id::new("asset_publish_description"), &description, w - PAD * 2.0, tr("Description (optional)")) + { form.description = Some(d); } }); @@ -795,9 +795,9 @@ fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { ui.horizontal(|ui| { ui.add_space(PAD); if server.is_some() { - to_server = crate::widgets::button(ui, "Publish to server", true, any).clicked(); + to_server = crate::widgets::button(ui, tr("Publish to server"), true, any).clicked(); } else { - publish = crate::widgets::button(ui, if m.is_some() { "Publish changes" } else { "Publish" }, true, any).clicked(); + publish = crate::widgets::button(ui, if m.is_some() { tr("Publish changes") } else { tr("Publish") }, true, any).clicked(); } }); ui.add_space(12.0); @@ -806,7 +806,9 @@ fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { None if app.session.live.can_host() => { hint( ui, - "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there.", + tr( + "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there.", + ), ); } None => {} @@ -815,7 +817,7 @@ fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { let description = form.description.clone().unwrap_or_else(|| description.clone()); if let Some(r) = app.run("library.publishToServer", json!({"description": description, "include": include})) { form = PublishForm::default(); - app.toast(format!("Published version {} to the server", r["version"].as_u64().unwrap_or(0))); + app.toast(trf("Published version {v} to the server", &[("v", &r["version"].as_u64().unwrap_or(0))])); } } if publish { @@ -827,9 +829,9 @@ fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { let saved = app.session.ds().is_ok_and(|d| d.path.is_some() && !d.is_dirty()); let v = r["version"].as_u64().unwrap_or(0); app.toast(if saved { - format!("Published version {v}") + trf("Published version {v}", &[("v", &v)]) } else { - format!("Published version {v}: save the file so other files can use it") + trf("Published version {v}: save the file so other files can use it", &[("v", &v)]) }); } } @@ -843,7 +845,7 @@ fn usage(app: &mut FigApp, ui: &mut Ui, w: f32, link: &str) { let mut ask = false; ui.horizontal(|ui| { ui.add_space(PAD); - ask = crate::widgets::button(ui, "Show usage", false, true).clicked(); + ask = crate::widgets::button(ui, tr("Show usage"), false, true).clicked(); }); if ask && let Some(v) = app.run("library.usage", json!({})) { ui.data_mut(|d| d.insert_temp(usage_id, Arc::new(v))); @@ -854,14 +856,15 @@ fn usage(app: &mut FigApp, ui: &mut Ui, w: f32, link: &str) { }; let files = v["files"].as_array().map_or(0, Vec::len); let instances = v["instances"].as_u64().unwrap_or(0); - let plural = |n: u64, one: &str, many: &str| format!("{n} {}", if n == 1 { one } else { many }); - hint(ui, &format!("Used in {} · {}", plural(files as u64, "file", "files"), plural(instances, "instance", "instances"))); + let files_n = |n: u64| trn("{n} files", n as usize, &[]); + let instances_n = |n: u64| trn("{n} instances", n as usize, &[]); + hint(ui, &trf("Used in {files} · {instances}", &[("files", &files_n(files as u64)), ("instances", &instances_n(instances))])); let t = Tokens::get(ui.ctx()); for c in v["components"].as_array().into_iter().flatten().take(USAGE_ROWS) { - let name = c["name"].as_str().unwrap_or("(no longer published)"); + let name = c["name"].as_str().unwrap_or(tr("(no longer published)")); let (n, f) = (c["instances"].as_u64().unwrap_or(0), c["files"].as_u64().unwrap_or(0)); let (r, _) = ui.allocate_exact_size(vec2(w, 20.0), Sense::hover()); - let right = format!("{} in {}", plural(n, "instance", "instances"), plural(f, "file", "files")); + let right = trf("{instances} in {files}", &[("instances", &instances_n(n)), ("files", &files_n(f))]); let rg = ui.painter().layout_no_wrap(right, regular(10.0), t.text_secondary); ui.painter().galley(pos2(r.max.x - PAD - rg.size().x, r.center().y - rg.size().y / 2.0), rg.clone(), t.text_secondary); crate::bidi::paint_name(ui.painter(), pos2(r.min.x + PAD, r.center().y), name, regular(11.0), t.text, w - PAD * 3.0 - rg.size().x); @@ -871,9 +874,9 @@ fn usage(app: &mut FigApp, ui: &mut Ui, w: f32, link: &str) { fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { let t = Tokens::get(ui.ctx()); - title(ui, w, "Libraries in this file"); + title(ui, w, tr("Libraries in this file")); if doc.libraries.enabled.is_empty() { - hint(ui, "No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link."); + hint(ui, tr("No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link.")); } let (mut remove, mut swap) = (None, None); let swappable = doc.libraries.enabled.len() > 1; @@ -885,12 +888,23 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { let n = m.components.iter().filter(|a| a.published).count(); let s = m.styles.iter().filter(|a| a.published).count(); let v = m.variables.iter().filter(|a| a.published).count(); - (format!("Version {} · {n} components · {s} styles · {v} variables", m.version), t.text_secondary) + ( + trf( + "Version {v} · {components} · {styles} · {variables}", + &[ + ("v", &m.version), + ("components", &trn("{n} components", n, &[])), + ("styles", &trn("{n} styles", s, &[])), + ("variables", &trn("{n} variables", v, &[])), + ], + ), + t.text_secondary, + ) } - Some(Ok(_)) => ("The file now holds another library".into(), t.danger), + Some(Ok(_)) => (tr("The file now holds another library").into(), t.danger), Some(Err(e)) => (e.to_string(), t.danger), - None if is_server_link(&l.path) => ("Fetching from the server…".into(), t.text_secondary), - None => ("Not read yet".into(), t.text_secondary), + None if is_server_link(&l.path) => (tr("Fetching from the server…").into(), t.text_secondary), + None => (tr("Not read yet").into(), t.text_secondary), }; let text_w = w - PAD - if swappable { 72.0 } else { 44.0 }; let name = ui.painter().layout_no_wrap(l.name.clone(), semibold(11.0), t.text); @@ -900,7 +914,7 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { ui.painter().with_clip_rect(clip).galley(pos2(r.min.x + PAD, r.min.y + 23.0), line, t.text_secondary); ui.interact(clip, Id::new(("asset_lib_row", &l.id)), Sense::hover()).on_hover_text(shown_path(&l.path)); let x = Rect::from_center_size(pos2(r.max.x - 24.0, r.center().y), vec2(24.0, 24.0)); - let xr = ui.interact(x, Id::new(("asset_lib_remove", &l.id)), Sense::click()).tip("Remove library from this file"); + let xr = ui.interact(x, Id::new(("asset_lib_remove", &l.id)), Sense::click()).tip(tr("Remove library from this file")); if xr.hovered() { ui.painter().rect_filled(x, CornerRadius::same(5), t.hover); } @@ -912,14 +926,14 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { let sw = Rect::from_center_size(pos2(r.max.x - 52.0, r.center().y), vec2(24.0, 24.0)); let sr = ui .interact(sw, Id::new(("asset_lib_swap", &l.id)), Sense::click()) - .tip("Swap library: use another library's assets of the same names"); + .tip(tr("Swap library: use another library's assets of the same names")); if sr.hovered() { ui.painter().rect_filled(sw, CornerRadius::same(5), t.hover); } icons::paint(ui.painter(), sw.shrink(6.0), Icon::Swap, t.icon); crate::right::menu_popup(&sr).show(|ui| { for other in doc.libraries.enabled.iter().filter(|o| o.id != l.id) { - if crate::right::menu_item(ui, &format!("Swap to {}", other.name), "") { + if crate::right::menu_item(ui, &trf("Swap to {name}", &[("name", &other.name)]), "") { swap = Some((l.id.clone(), other.id.clone())); ui.close(); } @@ -931,16 +945,16 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { let mut add = false; ui.horizontal(|ui| { ui.add_space(PAD); - add = crate::widgets::button(ui, "Add library file…", false, true).clicked(); + add = crate::widgets::button(ui, tr("Add library file…"), false, true).clicked(); }); ui.add_space(4.0); ui.horizontal(|ui| { ui.add_space(PAD); - let pasted = crate::widgets::text_field(ui, Id::new("asset_add_link"), "", w - PAD * 2.0, "Paste a team library link (a view link)"); + let pasted = crate::widgets::text_field(ui, Id::new("asset_add_link"), "", w - PAD * 2.0, tr("Paste a team library link (a view link)")); if let Some(link) = pasted.map(|l| l.trim().to_string()).filter(|l| !l.is_empty()) && let Some(r) = app.run("library.add", json!({"path": link})) { - app.toast(format!("Added the library \"{}\"", r["name"].as_str().unwrap_or_default())); + app.toast(trf("Added the library \"{name}\"", &[("name", &r["name"].as_str().unwrap_or_default())])); } }); if let Some(id) = remove { @@ -952,15 +966,15 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { let moved = ["components", "styles", "variables"].iter().map(|k| r[*k].as_u64().unwrap_or(0)).sum::(); let missed = r["unmatched"].as_array().map_or(0, Vec::len); app.toast(if missed == 0 { - format!("Swapped {moved} asset{}", if moved == 1 { "" } else { "s" }) + trn("Swapped {n} assets", moved as usize, &[]) } else { - format!("Swapped {moved} asset{}; {missed} had no match and still use the old library", if moved == 1 { "" } else { "s" }) + trn("Swapped {n} assets; {missed} had no match and still use the old library", moved as usize, &[("missed", &missed)]) }); } - if add && let Some(p) = app.services.pick_open("Add library", &["figcraft"]) { + if add && let Some(p) = app.services.pick_open(tr("Add library"), &["figcraft"]) { let path = p.to_string_lossy().to_string(); if let Some(r) = app.run("library.add", json!({"path": path})) { - app.toast(format!("Added the library \"{}\"", r["name"].as_str().unwrap_or_default())); + app.toast(trf("Added the library \"{name}\"", &[("name", &r["name"].as_str().unwrap_or_default())])); } } } diff --git a/crates/ui-egui/src/branch_ui.rs b/crates/ui-egui/src/branch_ui.rs index 3e507e2..2672966 100644 --- a/crates/ui-egui/src/branch_ui.rs +++ b/crates/ui-egui/src/branch_ui.rs @@ -14,6 +14,7 @@ use serde_json::json; use crate::FigApp; use crate::dev_compare::{self, BAR_H, LIST_W, Mode, Shot}; +use crate::i18n::{tr, trf, trn}; use crate::theme::{Tokens, regular, semibold}; use crate::widgets; @@ -75,10 +76,11 @@ pub fn banner(app: &mut FigApp, ctx: &egui::Context, canvas: Rect) { let t = Tokens::get(ctx); let review = widgets::canvas_banner(ctx, Id::new("branch_banner"), canvas, |ui| { ui.label(egui::RichText::new(format!("“{name}”")).font(semibold(11.0)).color(t.text)); - let state = if active { format!("Branch of {file}") } else { format!("Branch of {file} · merged or archived") }; + let state = + if active { trf("Branch of {file}", &[("file", &file)]) } else { trf("Branch of {file} · merged or archived", &[("file", &file)]) }; ui.label(egui::RichText::new(state).font(regular(11.0)).color(t.text_secondary)); ui.add_space(4.0); - widgets::button(ui, "Review and merge", true, active).clicked() + widgets::button(ui, tr("Review and merge"), true, active).clicked() }); if review { app.ui.branch_review = Some(ReviewView::new(main, id)); @@ -156,17 +158,17 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { ui.spacing_mut().item_spacing = vec2(0.0, 0.0); let Some(cache) = view.cache.as_ref() else { return }; let name = cache.review.as_ref().map(|r| r.name.clone()).unwrap_or_default(); - dev_compare::header(ui, &t, "Review and merge", &name, &mut view.mode, &mut open); + dev_compare::header(ui, &t, tr("Review and merge"), &name, &mut view.mode, &mut open); let body_h = size.y - BAR_H * 2.0 - 2.0; ui.horizontal_top(|ui| { let (r, _) = ui.allocate_exact_size(vec2(size.x - LIST_W - 1.0, body_h), Sense::hover()); match cache.shots.as_ref() { Some((_, Some(m), Some(b))) => { - dev_compare::images(ui, &t, r, [(m, "Main"), (b, "Branch")], view.mode, &mut view.split, view.hovered) + dev_compare::images(ui, &t, r, [(m, tr("Main")), (b, tr("Branch"))], view.mode, &mut view.split, view.hovered) } - Some((_, None, Some(_))) => dev_compare::message(ui, &t, r, "This frame is new on the branch."), - Some((_, Some(_), None)) => dev_compare::message(ui, &t, r, "This frame isn't on the branch."), - _ => dev_compare::message(ui, &t, r, "No layer changes to show."), + Some((_, None, Some(_))) => dev_compare::message(ui, &t, r, tr("This frame is new on the branch.")), + Some((_, Some(_), None)) => dev_compare::message(ui, &t, r, tr("This frame isn't on the branch.")), + _ => dev_compare::message(ui, &t, r, tr("No layer changes to show.")), } let (line, _) = ui.allocate_exact_size(vec2(1.0, body_h), Sense::hover()); ui.painter().rect_filled(line, CornerRadius::ZERO, t.border); @@ -176,7 +178,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { action = Some(a); } } - Err(e) => widgets::note(ui, &format!("This branch can't be reviewed: {e}"), t.danger), + Err(e) => widgets::note(ui, &trf("This branch can't be reviewed: {e}", &[("e", e)]), t.danger), }); }); if let Ok(r) = &cache.review @@ -193,13 +195,13 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { } Some(Action::Update) => { if app.run("branch.updateFromMain", json!({"id": view.id, "resolutions": resolutions()})).is_some() { - app.toast("Branch updated from main"); + app.toast(tr("Branch updated from main")); view.picks.clear(); } } Some(Action::Merge) => { if app.run("branch.merge", json!({"id": view.id, "resolutions": resolutions()})).is_some() { - app.toast("Branch merged"); + app.toast(tr("Branch merged")); open = false; } } @@ -231,11 +233,11 @@ fn list( egui::ScrollArea::vertical().id_salt("branch_review_list").auto_shrink([false, false]).show(ui, |ui| { ui.spacing_mut().item_spacing = vec2(0.0, 0.0); if !r.conflicts.is_empty() { - title(ui, t, "Conflicts", r.conflicts.len()); + title(ui, t, tr("Conflicts"), r.conflicts.len()); for c in &r.conflicts { let label = match c.deleted_by { - Some(Side::Branch) => format!("{} · deleted on the branch", c.name), - Some(Side::Main) => format!("{} · deleted on main", c.name), + Some(Side::Branch) => trf("{name} · deleted on the branch", &[("name", &c.name)]), + Some(Side::Main) => trf("{name} · deleted on main", &[("name", &c.name)]), None => format!("{} · {}", c.name, c.property), }; let resp = dev_compare::change_row( @@ -246,7 +248,7 @@ fn list( 0, 1, c.id.is_some() && c.id == selected, - "Changed on both sides: keep one", + tr("Changed on both sides: keep one"), ); if resp.hovered() { now_hovered = c.id; @@ -255,7 +257,7 @@ fn list( out = Some(Action::Show(id)); } let pick = picks.get(&c.key).copied(); - for (side, side_name, value) in [(Side::Branch, "Branch", &c.branch), (Side::Main, "Main", &c.main)] { + for (side, side_name, value) in [(Side::Branch, tr("Branch"), &c.branch), (Side::Main, tr("Main"), &c.main)] { ui.horizontal(|ui| { ui.add_space(28.0); let text = widgets::elide(ui, &format!("{side_name}: {value}"), 200.0); @@ -268,14 +270,22 @@ fn list( } widgets::separator(ui); } - for (label, side) in [("Changes on the branch", &r.branch), ("Changes on main", &r.main)] { + for (label, side) in [(tr("Changes on the branch"), &r.branch), (tr("Changes on main"), &r.main)] { title(ui, t, label, side.layers.len() + side.document.len()); if side.layers.is_empty() && side.document.is_empty() { - widgets::note(ui, "None.", t.text_secondary); + widgets::note(ui, tr("None."), t.text_secondary); } for c in &side.layers { - let resp = - dev_compare::change_row(ui, t, c.change, &c.name, c.depth.saturating_sub(1), c.layers, selected == Some(c.id), "Show this layer"); + let resp = dev_compare::change_row( + ui, + t, + c.change, + &c.name, + c.depth.saturating_sub(1), + c.layers, + selected == Some(c.id), + tr("Show this layer"), + ); if resp.hovered() { now_hovered = Some(c.id); } @@ -288,7 +298,7 @@ fn list( dev_compare::change_row(ui, t, d.change, &d.what, 0, 1, false, &d.what); } if side.truncated { - widgets::note(ui, "Only the first changed layers are listed.", t.text_secondary); + widgets::note(ui, tr("Only the first changed layers are listed."), t.text_secondary); } ui.add_space(8.0); } @@ -303,10 +313,10 @@ fn footer(ui: &mut Ui, t: &Tokens, r: &Review, picks: &BTreeMap) - let left = r.conflicts.iter().filter(|c| !picks.contains_key(&c.key)).count(); let active = r.state == BranchState::Active; let status = match (active, left) { - (false, _) => "This branch was merged or archived.".to_string(), - (true, 0) if r.conflicts.is_empty() => "No conflicts.".to_string(), - (true, 0) => "Every conflict has a choice.".to_string(), - (true, n) => format!("{n} conflict{} to resolve", if n == 1 { "" } else { "s" }), + (false, _) => tr("This branch was merged or archived.").to_string(), + (true, 0) if r.conflicts.is_empty() => tr("No conflicts.").to_string(), + (true, 0) => tr("Every conflict has a choice.").to_string(), + (true, n) => trn("{n} conflicts to resolve", n, &[]), }; ui.painter().text( pos2(rect.min.x + 16.0, rect.center().y), @@ -320,20 +330,20 @@ fn footer(ui: &mut Ui, t: &Tokens, r: &Review, picks: &BTreeMap) - ui.scope_builder(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::right_to_left(egui::Align::Center)), |ui| { ui.spacing_mut().item_spacing.x = 8.0; let ready = active && left == 0; - if widgets::button(ui, "Merge into main", true, ready) - .on_hover_text("Merge the branch into the main file (one undo step) and archive it") + if widgets::button(ui, tr("Merge into main"), true, ready) + .on_hover_text(tr("Merge the branch into the main file (one undo step) and archive it")) .clicked() { out = Some(Action::Merge); } let main_changed = !(r.main.layers.is_empty() && r.main.document.is_empty()); - if widgets::button(ui, "Update from main", false, ready && main_changed) - .on_hover_text("Bring the main file's changes into the branch") + if widgets::button(ui, tr("Update from main"), false, ready && main_changed) + .on_hover_text(tr("Bring the main file's changes into the branch")) .clicked() { out = Some(Action::Update); } - if widgets::button(ui, "Close", false, true).clicked() { + if widgets::button(ui, tr("Close"), false, true).clicked() { out = Some(Action::Close); } }); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index b5db77e..53bc709 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -10,6 +10,7 @@ use figcraft_geom::Point; use serde_json::json; use crate::FigApp; +use crate::i18n::{tr, trf, trn}; use crate::render_worker::{Job, Worker}; use crate::theme::{ACCENT, COMPONENT, Tokens, doc_c32, regular, semibold}; @@ -254,17 +255,17 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { pub fn describe(s: &figcraft_engine::Session) -> String { let Ok(ds) = s.ds() else { return String::new() }; match ds.selection.as_slice() { - [] => format!("{}, nothing selected", ds.doc.node(ds.page).map(|p| p.name.as_str()).unwrap_or("Page")), + [] => trf("{page}, nothing selected", &[("page", &ds.doc.node(ds.page).map(|p| p.name.as_str()).unwrap_or(tr("Page")))]), [id] => match ds.doc.node(*id) { Some(n) => { - let kind = n.kind.label(); + let kind = tr(n.kind.label()); let name = if n.name.is_empty() { kind } else { n.name.as_str() }; let (w, h) = (crate::widgets::fmt_num(n.width), crate::widgets::fmt_num(n.height)); - format!("{name}, {kind}, {w} by {h}, selected") + trf("{name}, {kind}, {w} by {h}, selected", &[("name", &name), ("kind", &kind), ("w", &w), ("h", &h)]) } None => String::new(), }, - many => format!("{} layers selected", many.len()), + many => trn("{n} layers selected", many.len(), &[]), } } @@ -625,7 +626,7 @@ fn swap_highlight(app: &FigApp, painter: &egui::Painter, doc: &Document, id: Nod let ink = Color32::WHITE; painter.add(Shape::convex_polygon(q.to_vec(), COMPONENT.gamma_multiply(0.12), Stroke::NONE)); quad(painter, q, stroke(COMPONENT, 2.0)); - let galley = painter.layout_no_wrap("Swap instance".to_string(), semibold(10.0), ink); + let galley = painter.layout_no_wrap(tr("Swap instance").to_string(), semibold(10.0), ink); let size = vec2(galley.size().x + 26.0, 18.0); let top = Rect::from_points(&q).min; let pill = Rect::from_min_size(pos2(top.x, top.y - size.y - 4.0), size); @@ -1196,9 +1197,9 @@ fn boards(doc: &Document, page: NodeId) -> Vec { pub(crate) fn status_label(n: &figcraft_doc::Node) -> Option<(&'static str, Color32)> { match n.dev_status { figcraft_doc::DevStatus::None => None, - figcraft_doc::DevStatus::ReadyForDev if n.dev_changed => Some(("Changed", crate::theme::DEV_CHANGED)), - figcraft_doc::DevStatus::ReadyForDev => Some(("Ready for dev", crate::theme::DEV_READY)), - figcraft_doc::DevStatus::Completed => Some(("Completed", crate::theme::DEV_COMPLETED)), + figcraft_doc::DevStatus::ReadyForDev if n.dev_changed => Some((tr("Changed"), crate::theme::DEV_CHANGED)), + figcraft_doc::DevStatus::ReadyForDev => Some((tr("Ready for dev"), crate::theme::DEV_READY)), + figcraft_doc::DevStatus::Completed => Some((tr("Completed"), crate::theme::DEV_COMPLETED)), } } diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index b41ab0a..06ed67c 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -10,6 +10,7 @@ use serde_json::json; use crate::FigApp; use crate::a11y::Tip; +use crate::i18n::{tr, tr_noop, trf}; use crate::icons::{self, Icon}; use crate::theme::{Tokens, doc_c32, regular, semibold}; use crate::widgets; @@ -386,7 +387,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { ui.spacing_mut().item_spacing = vec2(8.0, 8.0); // Header. let (hr, _) = ui.allocate_exact_size(vec2(240.0, 40.0), Sense::hover()); - ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, "Custom", semibold(11.0), t.text); + ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, tr("Custom"), semibold(11.0), t.text); let cr = Rect::from_center_size(pos2(hr.max.x - 20.0, hr.center().y), vec2(24.0, 24.0)); let cresp = ui.interact(cr, Id::new("cp_close"), Sense::click()); icons::paint(ui.painter(), cr.shrink(6.0), Icon::Close, t.icon); @@ -396,13 +397,13 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { // Paint type. if !matches!(target, PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. }) { let kinds = [ - (Icon::Fill, "Solid"), - (Icon::LayoutH, "Linear"), - (Icon::Ellipse, "Radial"), - (Icon::Rotate, "Angular"), - (Icon::Instance, "Diamond"), - (Icon::Image, "Image"), - (Icon::ViewGrid, "Pattern"), + (Icon::Fill, tr("Solid")), + (Icon::LayoutH, tr("Linear")), + (Icon::Ellipse, tr("Radial")), + (Icon::Rotate, tr("Angular")), + (Icon::Instance, tr("Diamond")), + (Icon::Image, tr("Image")), + (Icon::ViewGrid, tr("Pattern")), ]; let active = match &paint.kind { PaintKind::Solid { .. } => 0, @@ -423,7 +424,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { 0 => PaintKind::Solid { color: base.with_alpha(1.0) }, 5 => { // Pick an image file. - match app.services.pick_open("Choose image", &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"]) { + match app.services.pick_open(tr("Choose image"), &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"]) { Some(p) => match std::fs::read(&p) { Ok(bytes) => { let hash = app.session.doc_mut().ok().and_then(|d| figcraft_format_add(d, bytes)); @@ -440,7 +441,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { 6 => match pattern_sources(app, &target).first() { Some((id, _)) => Paint::pattern(id.0).kind, None => { - app.toast("Add a layer or component to tile first".to_string()); + app.toast(tr("Add a layer or component to tile first").to_string()); paint.kind.clone() } }, @@ -527,20 +528,20 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { edit_color = g.stops.get(sel).map(|s| s.color); ui.horizontal(|ui| { ui.add_space(16.0); - if ui.small_button("Remove stop").clicked() && g.stops.len() > 2 { + if ui.small_button(tr("Remove stop")).clicked() && g.stops.len() > 2 { g.stops.remove(sel); ui.data_mut(|d| d.insert_temp(sel_stop_id, 0usize)); paint.kind = PaintKind::Gradient(g.clone()); write(app, &target, &paint, false); } - if ui.small_button("Flip").clicked() { + if ui.small_button(tr("Flip")).clicked() { for s in &mut g.stops { s.position = 1.0 - s.position; } paint.kind = PaintKind::Gradient(g.clone()); write(app, &target, &paint, false); } - if ui.small_button("Rotate 90°").clicked() { + if ui.small_button(tr("Rotate 90°")).clicked() { let (fx, fy) = g.from; let rot = |(x, y): (f64, f64)| (fx - (y - fy), fy + (x - fx)); g.to = rot(g.to); @@ -561,7 +562,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { let sw = Rect::from_center_size(pos2(r.min.x + 13.0, r.center().y), vec2(14.0, 14.0)); if let Some(c) = stop { widgets::paint_swatch(ui.painter(), sw, &Paint::solid(c)); - let text = format!("Stop {} {}", sel + 1, c.to_hex()); + let text = trf("Stop {n} {hex}", &[("n", &(sel + 1)), ("hex", &c.to_hex())]); ui.painter().text(pos2(sw.max.x + 8.0, r.center().y), Align2::LEFT_CENTER, text, regular(11.0), t.text); } }); @@ -572,13 +573,13 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { let mut img = img.clone(); ui.horizontal(|ui| { ui.add_space(16.0); - let resp = widgets::dropdown(ui, &format!("{:?}", img.scale_mode), 100.0, None); + let resp = widgets::dropdown(ui, tr(&format!("{:?}", img.scale_mode)), 100.0, None); crate::right::menu_popup(&resp).show(|ui| { for (l, m) in [ - ("Fill", ImageScaleMode::Fill), - ("Fit", ImageScaleMode::Fit), - ("Crop", ImageScaleMode::Crop), - ("Tile", ImageScaleMode::Tile), + (tr("Fill"), ImageScaleMode::Fill), + (tr("Fit"), ImageScaleMode::Fit), + (tr("Crop"), ImageScaleMode::Crop), + (tr("Tile"), ImageScaleMode::Tile), ] { if crate::right::menu_item(ui, l, "") { img.scale_mode = m; @@ -588,7 +589,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { } } }); - if icons::button(ui, Icon::Rotate, 24.0, false, "Rotate 90°").clicked() { + if icons::button(ui, Icon::Rotate, 24.0, false, tr("Rotate 90°")).clicked() { img.rotation = (img.rotation + 1) % 4; paint.kind = PaintKind::Image(img.clone()); write(app, &target, &paint, false); @@ -596,19 +597,19 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { }); image_presets(app, ui, &target, &mut paint, img.filters); for (label, val) in [ - ("Exposure", img.filters.exposure), - ("Contrast", img.filters.contrast), - ("Saturation", img.filters.saturation), - ("Temperature", img.filters.temperature), - ("Tint", img.filters.tint), - ("Highlights", img.filters.highlights), - ("Shadows", img.filters.shadows), + (tr_noop("Exposure"), img.filters.exposure), + (tr_noop("Contrast"), img.filters.contrast), + (tr_noop("Saturation"), img.filters.saturation), + (tr_noop("Temperature"), img.filters.temperature), + (tr_noop("Tint"), img.filters.tint), + (tr_noop("Highlights"), img.filters.highlights), + (tr_noop("Shadows"), img.filters.shadows), ] { ui.horizontal(|ui| { ui.add_space(16.0); // Labels share a column so the sliders line up. let (lr, _) = ui.allocate_exact_size(vec2(72.0, 18.0), Sense::hover()); - ui.painter().text(lr.left_center(), Align2::LEFT_CENTER, label, regular(11.0), t.text); + ui.painter().text(lr.left_center(), Align2::LEFT_CENTER, tr(label), regular(11.0), t.text); let mut v = val; let r = ui.add(egui::Slider::new(&mut v, -1.0..=1.0).show_value(false)); if r.changed() { @@ -651,7 +652,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { Icon::Eyedropper, 24.0, false, - "Pick color (I)", + tr("Pick color (I)"), ) .clicked() { @@ -707,7 +708,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { if !doc_colors.is_empty() { ui.horizontal(|ui| { ui.add_space(16.0); - widgets::label(ui, "On this page", t.text_secondary); + widgets::label(ui, tr("On this page"), t.text_secondary); }); ui.horizontal_wrapped(|ui| { ui.add_space(16.0); @@ -723,7 +724,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { if !recent.is_empty() { ui.horizontal(|ui| { ui.add_space(16.0); - widgets::label(ui, "Recent", t.text_secondary); + widgets::label(ui, tr("Recent"), t.text_secondary); }); ui.horizontal_wrapped(|ui| { ui.add_space(16.0); @@ -775,12 +776,12 @@ fn pattern_controls(app: &mut FigApp, ui: &mut Ui, target: &PickerTarget, paint: use widgets::{FieldEdit, Lead}; // Some(live): changed, as part of a drag when live. let mut changed: Option = None; - let name = app.session.doc().ok().and_then(|d| d.node(NodeId(pt.source)).map(|n| n.name.clone())).unwrap_or_else(|| "Missing layer".into()); + let name = app.session.doc().ok().and_then(|d| d.node(NodeId(pt.source)).map(|n| n.name.clone())).unwrap_or_else(|| tr("Missing layer").into()); let sources = pattern_sources(app, target); ui.horizontal(|ui| { ui.add_space(16.0); let resp = widgets::elided_dropdown(ui, &name, 208.0); - crate::a11y::combo(&resp, "Pattern source", &name, crate::a11y::menu_open(&resp)); + crate::a11y::combo(&resp, tr("Pattern source"), &name, crate::a11y::menu_open(&resp)); crate::right::menu_popup(&resp).show(|ui| { for (id, label) in &sources { if crate::right::menu_check(ui, id.0 == pt.source, label, "") { @@ -793,10 +794,11 @@ fn pattern_controls(app: &mut FigApp, ui: &mut Ui, target: &PickerTarget, paint: }); ui.horizontal(|ui| { ui.add_space(16.0); - let tiles = [("Grid", PatternTile::Grid), ("Horizontal hex", PatternTile::HorizontalHex), ("Vertical hex", PatternTile::VerticalHex)]; - let label = tiles.iter().find(|t| t.1 == pt.tile).map_or("Grid", |t| t.0); + let tiles = + [(tr("Grid"), PatternTile::Grid), (tr("Horizontal hex"), PatternTile::HorizontalHex), (tr("Vertical hex"), PatternTile::VerticalHex)]; + let label = tiles.iter().find(|t| t.1 == pt.tile).map_or(tr("Grid"), |t| t.0); let resp = widgets::dropdown(ui, label, 120.0, Some(Icon::ViewGrid)); - crate::a11y::combo(&resp, "Tile type", label, crate::a11y::menu_open(&resp)); + crate::a11y::combo(&resp, tr("Tile type"), label, crate::a11y::menu_open(&resp)); crate::right::menu_popup(&resp).show(|ui| { for (l, v) in tiles { if crate::right::menu_check(ui, v == pt.tile, l, "") { @@ -806,10 +808,10 @@ fn pattern_controls(app: &mut FigApp, ui: &mut Ui, target: &PickerTarget, paint: } } }); - let aligns = [("Start", PatternAlign::Start), ("Center", PatternAlign::Center), ("End", PatternAlign::End)]; - let label = aligns.iter().find(|a| a.1 == pt.align).map_or("Start", |a| a.0); + let aligns = [(tr("Start"), PatternAlign::Start), (tr("Center"), PatternAlign::Center), (tr("End"), PatternAlign::End)]; + let label = aligns.iter().find(|a| a.1 == pt.align).map_or(tr("Start"), |a| a.0); let resp = widgets::dropdown(ui, label, 80.0, None); - crate::a11y::combo(&resp, "Pattern alignment", label, crate::a11y::menu_open(&resp)); + crate::a11y::combo(&resp, tr("Pattern alignment"), label, crate::a11y::menu_open(&resp)); crate::right::menu_popup(&resp).show(|ui| { for (l, v) in aligns { if crate::right::menu_check(ui, v == pt.align, l, "") { @@ -835,16 +837,16 @@ fn pattern_controls(app: &mut FigApp, ui: &mut Ui, target: &PickerTarget, paint: Some(FieldEdit::Scrub(v, end)) => Some((v, !end)), None => None, }; - if let Some((v, live)) = field(ui, "cp_pattern_scale", Lead::Icon(Icon::Scale, "Pattern scale"), pt.scale * 100.0, "%") { + if let Some((v, live)) = field(ui, "cp_pattern_scale", Lead::Icon(Icon::Scale, tr("Pattern scale")), pt.scale * 100.0, "%") { let (lo, hi) = PatternPaint::SCALE_RANGE; pt.scale = (v / 100.0).clamp(lo, hi); changed = Some(live); } - if let Some((v, live)) = field(ui, "cp_pattern_gap_x", Lead::Icon(Icon::PaddingH, "Horizontal spacing"), pt.spacing.0, "") { + if let Some((v, live)) = field(ui, "cp_pattern_gap_x", Lead::Icon(Icon::PaddingH, tr("Horizontal spacing")), pt.spacing.0, "") { pt.spacing.0 = v.clamp(0.0, PatternPaint::MAX_SPACING); changed = Some(live); } - if let Some((v, live)) = field(ui, "cp_pattern_gap_y", Lead::Icon(Icon::PaddingV, "Vertical spacing"), pt.spacing.1, "") { + if let Some((v, live)) = field(ui, "cp_pattern_gap_y", Lead::Icon(Icon::PaddingV, tr("Vertical spacing")), pt.spacing.1, "") { pt.spacing.1 = v.clamp(0.0, PatternPaint::MAX_SPACING); changed = Some(live); } @@ -870,7 +872,7 @@ fn saved_preset_row(ui: &mut Ui, name: &str) -> Option { let w = ui.available_width().max(180.0); let (r, resp) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click()); let del = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(20.0, 20.0)); - let del_resp = ui.interact(del, ui.id().with(("cp_preset_del", name)), Sense::click()).tip("Delete preset"); + 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); @@ -897,20 +899,20 @@ fn image_presets(app: &mut FigApp, ui: &mut Ui, target: &PickerTarget, paint: &m let built_in = ImageFilters::PRESETS.iter().filter(|(id, ..)| *id != "reset"); let current = built_in .clone() - .map(|(_, label, f)| (label.to_string(), *f)) + .map(|(_, label, f)| (tr(label).to_string(), *f)) .chain(saved.iter().cloned()) .find(|(_, f)| *f == filters) .map(|(name, _)| name) - .unwrap_or_else(|| if filters.is_identity() { "No preset".into() } else { "Custom".into() }); + .unwrap_or_else(|| if filters.is_identity() { tr("No preset").into() } else { tr("Custom").into() }); let name_id = Id::new("cp_preset_name"); let mut naming: Option = ui.data(|d| d.get_temp(name_id)); let mut action = None; ui.horizontal(|ui| { ui.add_space(16.0); - let resp = widgets::dropdown(ui, ¤t, 152.0, None).on_hover_text("Adjustment presets"); + let resp = widgets::dropdown(ui, ¤t, 152.0, None).on_hover_text(tr("Adjustment presets")); crate::right::menu_popup(&resp).show(|ui| { for (id, label, _) in built_in { - if crate::right::menu_item(ui, label, "") { + if crate::right::menu_item(ui, tr(label), "") { action = Some(PresetAction::Apply(id.to_string())); ui.close(); } @@ -925,12 +927,12 @@ fn image_presets(app: &mut FigApp, ui: &mut Ui, target: &PickerTarget, paint: &m if !saved.is_empty() { ui.separator(); } - if crate::right::menu_item(ui, "Save as preset…", "") { + if crate::right::menu_item(ui, tr("Save as preset…"), "") { naming = Some(format!("Preset {}", saved.len() + 1)); ui.close(); } }); - if widgets::button(ui, "Reset", false, !filters.is_identity()).on_hover_text("Reset adjustments").clicked() { + if widgets::button(ui, tr("Reset"), false, !filters.is_identity()).on_hover_text(tr("Reset adjustments")).clicked() { action = Some(PresetAction::Apply("reset".into())); } }); @@ -943,7 +945,7 @@ fn image_presets(app: &mut FigApp, ui: &mut Ui, target: &PickerTarget, paint: &m r.request_focus(); } save = r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); - save |= widgets::button(ui, "Save", true, !name.trim().is_empty()).clicked(); + save |= widgets::button(ui, tr("Save"), true, !name.trim().is_empty()).clicked(); }); if save && !name.trim().is_empty() { app.run("image.savePreset", json!({"name": name.trim(), "filters": filters})); diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 38df314..5918dfc 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -12,6 +12,7 @@ use figcraft_engine::cmd::comment::{self as cc, Filter, Query, Sort}; use serde_json::{Value, json}; use crate::FigApp; +use crate::i18n::{tr, trf, trn}; use crate::icons::{self, Icon}; use crate::theme::{ACCENT, Tokens, regular, semibold}; @@ -322,27 +323,27 @@ fn age(t: u64) -> Option { pub fn ago(created: u64) -> String { let Some(d) = age(created) else { return String::new() }; match d { - 0..60 => "now".into(), - 60..3600 => format!("{}m", d / 60), - 3600..86_400 => format!("{}h", d / 3600), - 86_400..604_800 => format!("{}d", d / 86_400), - _ => format!("{}w", d / 604_800), + 0..60 => tr("now").into(), + 60..3600 => trf("{n}m", &[("n", &(d / 60))]), + 3600..86_400 => trf("{n}h", &[("n", &(d / 3600))]), + 86_400..604_800 => trf("{n}d", &[("n", &(d / 86_400))]), + _ => trf("{n}w", &[("n", &(d / 604_800))]), } } /// "Just now", "5 minutes ago", "Yesterday", "3 weeks ago"… pub fn ago_long(t: u64) -> String { let Some(d) = age(t) else { return String::new() }; - let units = |n: u64, unit: &str| if n == 1 { format!("1 {unit} ago") } else { format!("{n} {unit}s ago") }; + let n = |div: u64| usize::try_from(d / div).unwrap_or(usize::MAX); match d { - 0..60 => "Just now".into(), - 60..3600 => units(d / 60, "minute"), - 3600..86_400 => units(d / 3600, "hour"), - 86_400..172_800 => "Yesterday".into(), - 172_800..604_800 => units(d / 86_400, "day"), - 604_800..2_592_000 => units(d / 604_800, "week"), - 2_592_000..31_536_000 => units(d / 2_592_000, "month"), - _ => units(d / 31_536_000, "year"), + 0..60 => tr("Just now").into(), + 60..3600 => trn("{n} minutes ago", n(60), &[]), + 3600..86_400 => trn("{n} hours ago", n(3600), &[]), + 86_400..172_800 => tr("Yesterday").into(), + 172_800..604_800 => trn("{n} days ago", n(86_400), &[]), + 604_800..2_592_000 => trn("{n} weeks ago", n(604_800), &[]), + 2_592_000..31_536_000 => trn("{n} months ago", n(2_592_000), &[]), + _ => trn("{n} years ago", n(31_536_000), &[]), } } @@ -546,7 +547,7 @@ fn composer(ui: &mut Ui, id: Id, text: &mut String, hint: &str, people: &[String ui.painter().line_segment([c + vec2(0.0, 5.0), c + vec2(0.0, -5.0)], Stroke::new(1.6, col)); ui.painter().line_segment([c + vec2(-4.0, -1.0), c + vec2(0.0, -5.0)], Stroke::new(1.6, col)); ui.painter().line_segment([c + vec2(4.0, -1.0), c + vec2(0.0, -5.0)], Stroke::new(1.6, col)); - if bresp.on_hover_text("Post").clicked() { + if bresp.on_hover_text(tr("Post")).clicked() { submit = true; } }); @@ -658,19 +659,24 @@ fn message( avatar(ui.painter(), r.center(), 12.0, &m.author); ui.add_space(4.0); ui.label(egui::RichText::new(&m.author).font(semibold(12.0)).color(t.text)); - let when = if m.edited > 0 { format!("{} · edited", ago(m.created)) } else { ago(m.created) }; + let when = if m.edited > 0 { trf("{when} · edited", &[("when", &ago(m.created))]) } else { ago(m.created) }; ui.label(egui::RichText::new(when).font(regular(11.0)).color(t.text_tertiary)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if m.author == cx.me { let menu = Id::new(("comment_msg_more_menu", id, i)); - let more = - hover_button(ui, Id::new(("comment_msg_more", id, i)), Icon::Dots, hovered || egui::Popup::is_id_open(&ctx, menu), "More"); + let more = hover_button( + ui, + Id::new(("comment_msg_more", id, i)), + Icon::Dots, + hovered || egui::Popup::is_id_open(&ctx, menu), + tr("More"), + ); crate::right::menu_popup(&more).id(menu).show(|ui| { - if crate::right::menu_item(ui, "Edit", "") { + if crate::right::menu_item(ui, tr("Edit"), "") { *act = Some(Act::Edit(i, m.text.clone())); ui.close(); } - if crate::right::menu_item(ui, if i == 0 { "Delete thread" } else { "Delete" }, "") { + if crate::right::menu_item(ui, if i == 0 { tr("Delete thread") } else { tr("Delete") }, "") { *act = Some(Act::Run("comment.delete", message_params(id, i))); ui.close(); } @@ -682,7 +688,7 @@ fn message( Id::new(("comment_msg_react", id, i)), Icon::Reaction, hovered || egui::Popup::is_id_open(&ctx, menu), - "Add reaction", + tr("Add reaction"), ); crate::right::menu_popup(&react).id(menu).show(|ui| { ui.horizontal(|ui| { @@ -709,12 +715,12 @@ fn message( ui.horizontal(|ui| { ui.add_space(indent); ui.vertical(|ui| { - if composer(ui, Id::new(("comment_edit", id, i)), text, "Edit comment", cx.people, cx.me, pick) { + if composer(ui, Id::new(("comment_edit", id, i)), text, tr("Edit comment"), cx.people, cx.me, pick) { let mut p = message_params(id, i); p["text"] = json!(text.trim()); *act = Some(Act::Run("comment.edit", p)); } - ui.label(egui::RichText::new("Enter to save · Esc to cancel").font(regular(10.5)).color(t.text_tertiary)); + ui.label(egui::RichText::new(tr("Enter to save · Esc to cancel")).font(regular(10.5)).color(t.text_tertiary)); }); }); } @@ -827,7 +833,7 @@ pub fn popovers(app: &mut FigApp, ctx: &egui::Context) { |ui| { popover_frame(&t).show(ui, |ui| { ui.set_width(POPOVER_W - 24.0); - post = composer(ui, Id::new("comment_draft_text"), &mut text, "Add a comment", &people, &me, &mut pick); + post = composer(ui, Id::new("comment_draft_text"), &mut text, tr("Add a comment"), &people, &me, &mut pick); }); }, ); @@ -876,31 +882,31 @@ pub fn thread(app: &mut FigApp, ctx: &egui::Context, id: u64, anchor: Pos2, boun ui.set_width(POPOVER_W - 24.0); // Header: resolve, more, close. ui.horizontal(|ui| { - ui.label(egui::RichText::new("Comment").font(semibold(12.0)).color(t.text)); + ui.label(egui::RichText::new(tr("Comment")).font(semibold(12.0)).color(t.text)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if icons::button(ui, Icon::Close, 24.0, false, "Close").clicked() { + if icons::button(ui, Icon::Close, 24.0, false, tr("Close")).clicked() { act = Some(Act::Close); } - let more = icons::button(ui, Icon::Dots, 24.0, false, "More"); + let more = icons::button(ui, Icon::Dots, 24.0, false, tr("More")); crate::right::menu_popup(&more).show(|ui| { - if crate::right::menu_item(ui, "Mark as unread", "") { + if crate::right::menu_item(ui, tr("Mark as unread"), "") { act = Some(Act::MarkUnread); ui.close(); } - if crate::right::menu_item(ui, "Copy link", "") { + if crate::right::menu_item(ui, tr("Copy link"), "") { act = Some(Act::CopyLink); ui.close(); } - if crate::right::menu_item(ui, if c.resolved { "Mark as unresolved" } else { "Mark as resolved" }, "") { + if crate::right::menu_item(ui, if c.resolved { tr("Mark as unresolved") } else { tr("Mark as resolved") }, "") { act = Some(Act::Run("comment.resolve", json!({"id": id, "resolved": !c.resolved}))); ui.close(); } - if c.first.author == me && crate::right::menu_item(ui, "Delete thread", "") { + if c.first.author == me && crate::right::menu_item(ui, tr("Delete thread"), "") { act = Some(Act::Run("comment.delete", json!({"id": id}))); ui.close(); } }); - let tip = if c.resolved { "Unresolve" } else { "Resolve" }; + let tip = if c.resolved { tr("Unresolve") } else { tr("Resolve") }; if icons::button(ui, Icon::Check, 24.0, c.resolved, tip).clicked() { act = Some(Act::Run("comment.resolve", json!({"id": id, "resolved": !c.resolved}))); } @@ -912,7 +918,7 @@ pub fn thread(app: &mut FigApp, ctx: &egui::Context, id: u64, anchor: Pos2, boun message(ui, &cx, i, m, &mut editing, &mut pick, &mut act); } }); - if composer(ui, reply_id(id), &mut reply, "Reply", &people, &me, &mut pick) { + if composer(ui, reply_id(id), &mut reply, tr("Reply"), &people, &me, &mut pick) { act = Some(Act::Run("comment.reply", json!({"id": id, "text": reply.trim()}))); } }); @@ -929,7 +935,7 @@ pub fn thread(app: &mut FigApp, ctx: &egui::Context, id: u64, anchor: Pos2, boun Some(Act::CopyLink) => { if let Some(link) = app.run("comment.link", json!({"id": id})).and_then(|v| v.get("link").and_then(Value::as_str).map(str::to_string)) { app.services.clipboard_set_text(&link); - app.toast("Link copied"); + app.toast(tr("Link copied")); } } // It closes: an open thread reads itself again. @@ -1010,26 +1016,31 @@ pub fn panel(app: &mut FigApp, ui: &mut Ui) { ui.add_space(8.0); ui.horizontal(|ui| { ui.add_space(16.0); - ui.label(egui::RichText::new("Comments").font(semibold(12.0)).color(t.text)); + ui.label(egui::RichText::new(tr("Comments")).font(semibold(12.0)).color(t.text)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.add_space(8.0); - let menu = icons::button(ui, Icon::Adjust, 24.0, app.ui.comments.filter != Filter::Open, "Filter and sort"); + let menu = icons::button(ui, Icon::Adjust, 24.0, app.ui.comments.filter != Filter::Open, tr("Filter and sort")); crate::right::menu_popup(&menu).width(200.0).show(|ui| { for f in Filter::ALL { - if crate::right::menu_check(ui, app.ui.comments.filter == f, f.label(), "") { + if crate::right::menu_check( + ui, + app.ui.comments.filter == f, + if f == Filter::Open { tr("Open|comments") } else { tr(f.label()) }, + "", + ) { app.ui.comments.filter = f; ui.close(); } } ui.separator(); for s in Sort::ALL { - if crate::right::menu_check(ui, app.ui.comments.sort == s, s.label(), "") { + if crate::right::menu_check(ui, app.ui.comments.sort == s, tr(s.label()), "") { app.ui.comments.sort = s; ui.close(); } } ui.separator(); - if crate::right::menu_item(ui, "Mark all as read", "") { + if crate::right::menu_item(ui, tr("Mark all as read"), "") { app.run("comment.markRead", json!({})); ui.close(); } @@ -1040,7 +1051,7 @@ pub fn panel(app: &mut FigApp, ui: &mut Ui) { ui.horizontal(|ui| { ui.add_space(16.0); let w = ui.available_width() - 16.0; - if let Some(s) = crate::widgets::text_field(ui, Id::new("comment_search"), &app.ui.comments.search, w, "Search comments") { + if let Some(s) = crate::widgets::text_field(ui, Id::new("comment_search"), &app.ui.comments.search, w, tr("Search comments")) { app.ui.comments.search = s; } }); @@ -1049,9 +1060,9 @@ pub fn panel(app: &mut FigApp, ui: &mut Ui) { ui.add_space(6.0); ui.horizontal(|ui| { ui.add_space(16.0); - ui.label(egui::RichText::new(app.ui.comments.filter.label()).font(regular(11.0)).color(t.text_secondary)); + 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("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(ACCENT)).sense(Sense::click())).clicked() { app.ui.comments.filter = Filter::Open; } }); @@ -1062,9 +1073,9 @@ pub fn panel(app: &mut FigApp, ui: &mut Ui) { let list = cc::query(&doc, &q, &me); if list.is_empty() { let (title, sub) = if doc.comments.iter().any(|c| doc.pages.contains(&c.page)) { - ("No matching comments", "Try another search or filter.") + (tr("No matching comments"), tr("Try another search or filter.")) } else { - ("No comments yet", "Click anywhere on the canvas to leave one.") + (tr("No comments yet"), tr("Click anywhere on the canvas to leave one.")) }; ui.add_space(24.0); ui.vertical_centered(|ui| { @@ -1120,10 +1131,10 @@ pub fn panel(app: &mut FigApp, ui: &mut Ui) { meta.push(doc.node(c.page).map(|n| n.name.clone()).unwrap_or_default()); } if !c.replies.is_empty() { - meta.push(format!("{} {}", c.replies.len(), if c.replies.len() == 1 { "reply" } else { "replies" })); + meta.push(trn("{n} replies", c.replies.len(), &[])); } if c.resolved { - meta.push("Resolved".into()); + meta.push(tr("Resolved").into()); } if !meta.is_empty() { let galley = ui.painter().layout_no_wrap(meta.join(" · "), regular(11.0), t.text_secondary); diff --git a/crates/ui-egui/src/component_panel.rs b/crates/ui-egui/src/component_panel.rs index 9bf9a57..0052a59 100644 --- a/crates/ui-egui/src/component_panel.rs +++ b/crates/ui-egui/src/component_panel.rs @@ -17,6 +17,7 @@ use figcraft_engine::components::{ use serde_json::{Value, json}; use crate::FigApp; +use crate::i18n::{tr, tr_noop, trn}; use crate::icons::{self, Icon}; use crate::right::{menu_item, menu_popup, row}; use crate::theme::{COMPONENT, Tokens, semibold}; @@ -57,8 +58,8 @@ pub fn instance_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[No /// A component's description: a multi-line field, saved when it loses focus. fn description_ui(app: &mut FigApp, ui: &mut Ui, id: NodeId, description: &str) { widgets::separator(ui); - widgets::sublabels(ui, "Description", None); - if let Some(new) = widgets::multiline_field(ui, Id::new(("comp_desc", id)), description, "Describe how and when to use this component", 3) { + widgets::sublabels(ui, tr("Description"), None); + if let Some(new) = widgets::multiline_field(ui, Id::new(("comp_desc", id)), description, tr("Describe how and when to use this component"), 3) { app.run("node.set", json!({"id": id, "props": {"description": new.chars().take(10_000).collect::()}})); } ui.add_space(8.0); @@ -131,7 +132,7 @@ fn slot_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, slot: NodeId, nam let comps = local_components(app, ui, doc); widgets::separator(ui); row(ui, 36.0, |ui| { - ui.label(egui::RichText::new("Slot").font(semibold(11.0)).color(t.text)); + ui.label(egui::RichText::new(tr("Slot")).font(semibold(11.0)).color(t.text)); }); row(ui, 32.0, |ui| { prop_label(ui, name, Icon::Slot); @@ -146,17 +147,16 @@ fn slot_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, slot: NodeId, prefer let t = Tokens::get(ui.ctx()); let edited = slot_edited(doc, slot); let label = match (edited, doc.node(slot).map_or(0, |n| n.children.len())) { - (false, 0) => "Empty".to_string(), - (false, _) => "Default content".to_string(), - (true, 1) => "1 layer".to_string(), - (true, k) => format!("{k} layers"), + (false, 0) => tr("Empty").to_string(), + (false, _) => tr("Default content").to_string(), + (true, k) => trn("{n} layers", k, &[]), }; - let resp = widgets::dropdown(ui, &label, w, Some(Icon::Slot)).on_hover_text("Add content to this slot"); + let resp = widgets::dropdown(ui, &label, w, Some(Icon::Slot)).on_hover_text(tr("Add content to this slot")); menu_popup(&resp).width(w.max(200.0)).show(|ui| { let insert = |app: &mut FigApp, c: NodeId| app.run("instance.slotInsert", json!({"id": slot, "component": c.0})); let heading = |ui: &mut Ui, text: &str| ui.label(egui::RichText::new(text).font(semibold(11.0)).color(t.text_secondary)); if !preferred.is_empty() { - heading(ui, "Preferred"); + heading(ui, tr("Preferred")); for c in preferred.iter().filter(|c| doc.contains(**c)) { let name = doc.node(*c).map(|n| n.name.clone()).unwrap_or_default(); if menu_item(ui, &name, "") { @@ -165,7 +165,7 @@ fn slot_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, slot: NodeId, prefer } } } - heading(ui, "All components"); + heading(ui, tr("All components")); egui::ScrollArea::vertical().max_height(240.0).show(ui, |ui| { // Never a component the slot is part of. for (c, name) in comps.iter().filter(|(c, _)| !doc.is_ancestor(*c, slot)) { @@ -175,11 +175,11 @@ fn slot_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, slot: NodeId, prefer } } }); - if menu_item(ui, "Select slot", "") { + if menu_item(ui, tr("Select slot"), "") { app.run("select.set", json!({"ids": [slot.0]})); ui.close(); } - if edited && menu_item(ui, "Reset slot", "") { + if edited && menu_item(ui, tr("Reset slot"), "") { app.run("instance.resetSlot", json!({"id": slot.0})); ui.close(); } @@ -209,18 +209,18 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { widgets::separator(ui); let existing: Vec = defs.keys().cloned().chain(variants.iter().map(|(k, _)| k.clone())).collect(); let fresh = (1..).map(|k| format!("Property {k}")).find(|n| !existing.contains(n)).unwrap_or_else(|| "Property".into()); - let plus = header_with_plus(ui, "Properties", "Create component property"); + let plus = header_with_plus(ui, tr("Properties"), tr("Create component property")); menu_popup(&plus).width(180.0).show(|ui| { let swap_default = comps.iter().map(|(c, _)| *c).find(|c| *c != owner && first != Some(*c)); let items: [(&str, &str, Value); 4] = [ - ("Variant", "variant", json!("Default")), - ("Boolean", "boolean", json!(true)), - ("Text", "text", json!("Text")), - ("Instance swap", "instanceSwap", swap_default.map(|c| json!(c.0)).unwrap_or(Value::Null)), + (tr("Variant"), "variant", json!("Default")), + (tr("Boolean"), "boolean", json!(true)), + (tr("Text"), "text", json!("Text")), + (tr("Instance swap"), "instanceSwap", swap_default.map(|c| json!(c.0)).unwrap_or(Value::Null)), ]; for (label, ty, default) in items { if ty == "instanceSwap" && default.is_null() { - ui.label(egui::RichText::new("Instance swap (no other component)").color(t.text_tertiary)); + ui.label(egui::RichText::new(tr("Instance swap (no other component)")).color(t.text_tertiary)); continue; } if menu_item(ui, label, "") { @@ -230,18 +230,18 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { } }); if defs.is_empty() && variants.is_empty() { - row(ui, 28.0, |ui| widgets::label(ui, "Add properties to make instances configurable", t.text_tertiary)); + row(ui, 28.0, |ui| widgets::label(ui, tr("Add properties to make instances configurable"), t.text_tertiary)); } // Variant properties: name, values (the matrix), add value, delete. for (k, vals) in &variants { row(ui, 32.0, |ui| { let (ir, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover()); icons::paint(ui.painter(), ir, Icon::ComponentSet, COMPONENT); - if let Some(nn) = widgets::text_field(ui, Id::new(("vprop_name", owner, k.as_str())), k, 110.0, "Name") { + if let Some(nn) = widgets::text_field(ui, Id::new(("vprop_name", owner, k.as_str())), k, 110.0, tr("Name")) { app.run("component.editProperty", json!({"id": owner, "name": k, "newName": nn})); } let def = vals.first().cloned().unwrap_or_default(); - let resp = widgets::dropdown(ui, &def, 70.0, None).on_hover_text("Default value (the first variant)"); + let resp = widgets::dropdown(ui, &def, 70.0, None).on_hover_text(tr("Default value (the first variant)")); menu_popup(&resp).show(|ui| { for v in vals { if menu_item(ui, v, "") { @@ -250,7 +250,7 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { } } }); - if icons::button(ui, Icon::Minus, 22.0, false, "Delete property").clicked() { + if icons::button(ui, Icon::Minus, 22.0, false, tr("Delete property")).clicked() { app.run("component.deleteProperty", json!({"id": owner, "name": k})); } }); @@ -268,7 +268,7 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { app.run("component.editProperty", json!({"id": owner, "name": k, "value": v, "newValue": nv})); } } - if last && icons::button(ui, Icon::Plus, 22.0, false, "Add variant with a new value").clicked() { + if last && icons::button(ui, Icon::Plus, 22.0, false, tr("Add variant with a new value")).clicked() { let nv = (vals.len() + 1..).map(|n| format!("Value {n}")).find(|n| !vals.contains(n)).unwrap_or_default(); app.run("component.addVariant", json!({"id": owner, "property": k, "value": nv})); } @@ -284,22 +284,22 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { row(ui, 32.0, |ui| { let (ir, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover()); icons::paint(ui.painter(), ir, type_icon(d), COMPONENT); - if let Some(nn) = widgets::text_field(ui, Id::new(("prop_name", owner, k.as_str())), k, 96.0, "Name") { + if let Some(nn) = widgets::text_field(ui, Id::new(("prop_name", owner, k.as_str())), k, 96.0, tr("Name")) { app.run("component.editProperty", json!({"id": owner, "name": k, "newName": nn})); } match d { PropDef::Boolean { default } => { - if widgets::checkbox(ui, *default, if *default { "True" } else { "False" }) { + if widgets::checkbox(ui, *default, if *default { tr("True") } else { tr("False") }) { app.run("component.editProperty", json!({"id": owner, "name": k, "default": !default})); } } PropDef::Text { default } => { - if let Some(v) = widgets::text_field(ui, Id::new(("prop_def", owner, k.as_str())), default, 84.0, "Default") { + if let Some(v) = widgets::text_field(ui, Id::new(("prop_def", owner, k.as_str())), default, 84.0, tr("Default")) { app.run("component.editProperty", json!({"id": owner, "name": k, "default": v})); } } PropDef::InstanceSwap { default, .. } => { - let label = doc.node(*default).map(|n| n.name.clone()).unwrap_or_else(|| "Missing".into()); + let label = doc.node(*default).map(|n| n.name.clone()).unwrap_or_else(|| tr("Missing").into()); let resp = widgets::dropdown(ui, &label, 84.0, None); menu_popup(&resp).show(|ui| { for (c, name) in comps.iter() { @@ -313,7 +313,7 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { PropDef::Variant { .. } | PropDef::Slot { .. } => {} } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if icons::button(ui, Icon::Minus, 22.0, false, "Delete property").clicked() { + if icons::button(ui, Icon::Minus, 22.0, false, tr("Delete property")).clicked() { app.run("component.deleteProperty", json!({"id": owner, "name": k})); } }); @@ -321,16 +321,15 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { // An instance swap or slot property's preferred components, on a row of their own. if let PropDef::Slot { preferred } | PropDef::InstanceSwap { preferred, .. } = d { let tip = if matches!(d, PropDef::Slot { .. }) { - "Offered first when filling the slot (any content is allowed)" + tr("Offered first when filling the slot (any content is allowed)") } else { - "Offered first when swapping the instance (any component is allowed)" + tr("Offered first when swapping the instance (any component is allowed)") }; row(ui, 30.0, |ui| { ui.add_space(22.0); let label = match preferred.len() { - 0 => "No preferred components".to_string(), - 1 => "1 preferred component".to_string(), - k => format!("{k} preferred components"), + 0 => tr("No preferred components").to_string(), + k => trn("{n} preferred components", k, &[]), }; let w = (ui.available_width() - 8.0).max(60.0); let resp = widgets::dropdown(ui, &label, w, None).on_hover_text(tip); @@ -350,7 +349,7 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { } if is_set { row(ui, 32.0, |ui| { - if widgets::dropdown(ui, "Add variant", 120.0, Some(Icon::Plus)).clicked() { + if widgets::dropdown(ui, tr("Add variant"), 120.0, Some(Icon::Plus)).clicked() { app.run("component.addVariant", json!({"id": owner})); } }); @@ -362,19 +361,20 @@ fn bind_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) { let t = Tokens::get(ui.ctx()); let Some(comp) = owning_component(doc, n.id) else { return }; let defs = prop_defs(doc, comp); - let mut fields: Vec<(&str, &str, &str)> = vec![("visible", "Visibility", "boolean")]; + // The English label also names a new property (document content); it shows translated. + let mut fields: Vec<(&str, &str, &str)> = vec![("visible", tr_noop("Visibility"), "boolean")]; if n.kind.text().is_some() { - fields.push(("characters", "Text", "text")); + fields.push(("characters", tr_noop("Text"), "text")); } if matches!(n.kind, NodeKind::Instance { .. }) { - fields.push(("mainComponent", "Instance", "instanceSwap")); + fields.push(("mainComponent", tr_noop("Instance"), "instanceSwap")); } if matches!(n.kind, NodeKind::Frame) { - fields.push(("slot", "Slot", "slot")); + fields.push(("slot", tr_noop("Slot"), "slot")); } widgets::separator(ui); row(ui, 36.0, |ui| { - ui.label(egui::RichText::new("Component property").font(semibold(11.0)).color(t.text)); + ui.label(egui::RichText::new(tr("Component property")).font(semibold(11.0)).color(t.text)); }); for (field, label, ty) in fields { let matching: Vec<&String> = defs @@ -400,9 +400,9 @@ fn bind_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) { _ => Icon::Instance, }; icons::paint(ui.painter(), ir, icon, if bound.is_some() { COMPONENT } else { t.icon_secondary }); - widgets::label(ui, label, t.text_secondary); - let shown = bound.clone().unwrap_or_else(|| "Not bound".into()); - let resp = widgets::dropdown(ui, &shown, 120.0, None).on_hover_text("Apply a component property"); + widgets::label(ui, tr(label), t.text_secondary); + let shown = bound.clone().unwrap_or_else(|| tr("Not bound").into()); + let resp = widgets::dropdown(ui, &shown, 120.0, None).on_hover_text(tr("Apply a component property")); menu_popup(&resp).width(180.0).show(|ui| { for k in &matching { if menu_item(ui, k, "") { @@ -411,12 +411,12 @@ fn bind_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) { } } if ty == "slot" { - if bound.is_none() && menu_item(ui, "Make slot", "") { + if bound.is_none() && menu_item(ui, tr("Make slot"), "") { // Named after the frame. app.run("component.makeSlot", json!({"id": n.id})); ui.close(); } - } else if menu_item(ui, "Create property…", "") { + } else if menu_item(ui, tr("Create property…"), "") { let existing: Vec = defs.keys().cloned().collect(); let name = (1..) .map(|k| if k == 1 { label.to_string() } else { format!("{label} {k}") }) @@ -426,11 +426,11 @@ fn bind_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) { ui.close(); } if ty == "slot" { - if bound.is_some() && menu_item(ui, "Remove slot", "") { + if bound.is_some() && menu_item(ui, tr("Remove slot"), "") { app.run("component.removeSlot", json!({"id": n.id})); ui.close(); } - } else if bound.is_some() && menu_item(ui, "Detach property", "") { + } else if bound.is_some() && menu_item(ui, tr("Detach property"), "") { app.run("component.bindProperty", json!({"layer": n.id, "property": null, "field": field})); ui.close(); } @@ -448,9 +448,9 @@ fn overrides_ui(app: &mut FigApp, ui: &mut Ui, n: &Node) { } widgets::separator(ui); row(ui, 36.0, |ui| { - ui.label(egui::RichText::new("Overrides").font(semibold(11.0)).color(t.text)); + ui.label(egui::RichText::new(tr("Overrides")).font(semibold(11.0)).color(t.text)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if icons::button(ui, Icon::Reset, 24.0, false, "Reset all overrides on this layer").clicked() { + if icons::button(ui, Icon::Reset, 24.0, false, tr("Reset all overrides on this layer")).clicked() { app.run("component.resetOverride", json!({"id": n.id})); } }); @@ -459,7 +459,7 @@ fn overrides_ui(app: &mut FigApp, ui: &mut Ui, n: &Node) { row(ui, 26.0, |ui| { widgets::label(ui, &group_label(&g), t.text_secondary); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if icons::button(ui, Icon::Close, 20.0, false, "Reset this override").clicked() { + if icons::button(ui, Icon::Close, 20.0, false, tr("Reset this override")).clicked() { app.run("component.resetOverride", json!({"id": n.id, "group": g})); } }); @@ -469,20 +469,20 @@ fn overrides_ui(app: &mut FigApp, ui: &mut Ui, n: &Node) { fn group_label(g: &str) -> String { match g { - "fills" => "Fill", - "strokes" => "Stroke", - "effects" => "Effects", - "opacity" => "Opacity & blend", - "visible" => "Visibility", - "name" => "Layer name", - "corners" => "Corner radius", - "characters" => "Text content", - "textStyle" => "Text style", - "size" => "Size", - "position" => "Position", - "component" => "Swapped instance", - "props" => "Instance properties", - "slot" => "Slot content", + "fills" => tr("Fill"), + "strokes" => tr("Stroke"), + "effects" => tr("Effects"), + "opacity" => tr("Opacity & blend"), + "visible" => tr("Visibility"), + "name" => tr("Layer name"), + "corners" => tr("Corner radius"), + "characters" => tr("Text content"), + "textStyle" => tr("Text style"), + "size" => tr("Size"), + "position" => tr("Position"), + "component" => tr("Swapped instance"), + "props" => tr("Instance properties"), + "slot" => tr("Slot content"), other => other, } .to_string() @@ -495,40 +495,40 @@ fn instance_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) { let id = n.id; let missing = !doc.contains(*component); let set = variant_set(doc, *component); - let comp_name = set.or(Some(*component)).and_then(|c| doc.node(c)).map(|c| c.name.clone()).unwrap_or_else(|| "Main component deleted".into()); + let comp_name = set.or(Some(*component)).and_then(|c| doc.node(c)).map(|c| c.name.clone()).unwrap_or_else(|| tr("Main component deleted").into()); let comps = local_components(app, ui, doc); let nested = inside_instance(doc, id); widgets::separator(ui); row(ui, 36.0, |ui| { - ui.label(egui::RichText::new(if nested { "Nested instance" } else { "Instance" }).font(semibold(11.0)).color(t.text)); + ui.label(egui::RichText::new(if nested { tr("Nested instance") } else { tr("Instance") }).font(semibold(11.0)).color(t.text)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let more = icons::button(ui, Icon::Dots, 24.0, false, "More instance actions"); + let more = icons::button(ui, Icon::Dots, 24.0, false, tr("More instance actions")); menu_popup(&more).width(220.0).show(|ui| { - if !missing && menu_item(ui, "Go to main component", "") { + if !missing && menu_item(ui, tr("Go to main component"), "") { app.run("component.goToMain", json!({"id": id})); ui.close(); } - if !missing && menu_item(ui, "Push changes to main component", "") { + if !missing && menu_item(ui, tr("Push changes to main component"), "") { app.run("component.pushChanges", json!({"id": id})); ui.close(); } - if missing && !nested && menu_item(ui, "Restore main component", "") { + if missing && !nested && menu_item(ui, tr("Restore main component"), "") { app.run("component.restoreMain", json!({"id": id})); ui.close(); } - if menu_item(ui, "Reset all changes", "") { + if menu_item(ui, tr("Reset all changes"), "") { app.run("component.resetInstance", json!({"ids": [id]})); ui.close(); } - if !nested && menu_item(ui, "Detach instance", "Alt+Cmd+B") { + if !nested && menu_item(ui, tr("Detach instance"), "Alt+Cmd+B") { app.run("component.detach", json!({"ids": [id]})); ui.close(); } }); - if !nested && icons::button(ui, Icon::Detach, 24.0, false, crate::shortcuts::tip("Detach instance", "component.detach")).clicked() { + if !nested && icons::button(ui, Icon::Detach, 24.0, false, crate::shortcuts::tip(tr("Detach instance"), "component.detach")).clicked() { app.run("component.detach", json!({"ids": [id]})); } - if icons::button(ui, Icon::Reset, 24.0, false, "Reset all changes").clicked() { + if icons::button(ui, Icon::Reset, 24.0, false, tr("Reset all changes")).clicked() { app.run("component.resetInstance", json!({"ids": [id]})); } }); @@ -536,7 +536,7 @@ fn instance_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) { // Main component with a swap menu, and "go to main". row(ui, 32.0, |ui| { let w = (ui.available_width() - 32.0).max(80.0); - let resp = widgets::dropdown(ui, &comp_name, w, Some(Icon::Instance)).on_hover_text("Swap instance"); + let resp = widgets::dropdown(ui, &comp_name, w, Some(Icon::Instance)).on_hover_text(tr("Swap instance")); menu_popup(&resp).width(w.max(200.0)).show(|ui| { egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { let mut page = None; @@ -556,10 +556,10 @@ fn instance_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) { }); }); if missing { - if icons::button(ui, Icon::Component, 24.0, false, "Restore main component").clicked() { + if icons::button(ui, Icon::Component, 24.0, false, tr("Restore main component")).clicked() { app.run("component.restoreMain", json!({"id": id})); } - } else if icons::button(ui, Icon::Component, 24.0, false, "Go to main component").clicked() { + } else if icons::button(ui, Icon::Component, 24.0, false, tr("Go to main component")).clicked() { app.run("component.goToMain", json!({"id": id})); } }); @@ -582,7 +582,7 @@ fn instance_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node) { // A nested instance inside a main component can expose its properties on the main's instances. if nested_in_main(doc, id) { row(ui, 32.0, |ui| { - if widgets::checkbox(ui, n.exposed, "Expose properties to instances") { + if widgets::checkbox(ui, n.exposed, tr("Expose properties to instances")) { app.run("component.exposeInstance", json!({"id": id, "exposed": !n.exposed})); } }); @@ -665,7 +665,7 @@ fn instance_props(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node, comps } PropDef::InstanceSwap { preferred, .. } => { let cur = figcraft_engine::params::id_of(&v); - let label = cur.and_then(|c| doc.node(c)).map(|c| c.name.clone()).unwrap_or_else(|| "None".into()); + let label = cur.and_then(|c| doc.node(c)).map(|c| c.name.clone()).unwrap_or_else(|| tr("None").into()); let resp = widgets::dropdown(ui, &label, w, Some(Icon::Instance)); menu_popup(&resp).width(200.0).show(|ui| { let t = Tokens::get(ui.ctx()); @@ -681,11 +681,11 @@ fn instance_props(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node, comps }; let preferred: Vec<(NodeId, String)> = preferred.iter().filter_map(|c| doc.node(*c).map(|n| (*c, n.name.clone()))).collect(); if !preferred.is_empty() { - heading(ui, "Preferred"); + heading(ui, tr("Preferred")); for (c, name) in &preferred { pick(ui, *c, name); } - heading(ui, "All components"); + heading(ui, tr("All components")); } egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { for (c, name) in comps { diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 7696981..3fc8b7a 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -7,6 +7,7 @@ use std::cmp::Ordering; use egui::RichText; +use crate::i18n::{tr, tr_noop, trf}; use crate::theme::{Tokens, semibold}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -51,9 +52,9 @@ impl NameMode { pub fn label(self) -> &'static str { match self { - NameMode::Username => "Username", - NameMode::DisplayName => "Display name", - NameMode::RealName => "Real name", + NameMode::Username => tr("Username"), + NameMode::DisplayName => tr("Display name"), + NameMode::RealName => tr("Real name"), } } } @@ -90,16 +91,16 @@ impl SortKey { /// Menu label and table header. pub fn label(self) -> (&'static str, &'static str) { match self { - SortKey::Name => ("Name (A–Z)", "Name"), - SortKey::Prs => ("Merged PRs", "PRs"), - SortKey::Commits => ("Commits", "Commits"), - SortKey::LinesAdded => ("Lines added", "+LOC"), - SortKey::LinesDeleted => ("Lines deleted", "−LOC"), - SortKey::LinesDelta => ("Line delta", "ΔLOC"), - SortKey::BinaryAdded => ("Binary assets added", "+Bin"), - SortKey::BinaryDeleted => ("Binary assets removed", "−Bin"), - SortKey::FirstCommit => ("First commit", "First"), - SortKey::LastCommit => ("Last commit", "Last"), + SortKey::Name => (tr("Name (A–Z)"), tr("Name")), + SortKey::Prs => (tr("Merged PRs"), "PRs"), + SortKey::Commits => (tr("Commits"), tr("Commits")), + SortKey::LinesAdded => (tr("Lines added"), "+LOC"), + SortKey::LinesDeleted => (tr("Lines deleted"), "−LOC"), + SortKey::LinesDelta => (tr("Line delta"), "ΔLOC"), + SortKey::BinaryAdded => (tr("Binary assets added"), "+Bin"), + SortKey::BinaryDeleted => (tr("Binary assets removed"), "−Bin"), + SortKey::FirstCommit => (tr("First commit"), tr("First")), + SortKey::LastCommit => (tr("Last commit"), tr("Last")), } } @@ -146,18 +147,20 @@ impl Contributor { /// One line with everything we know, for tooltips. pub fn summary(&self) -> String { - format!( - "@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}", - self.login, - group(self.prs), - group(self.commits), - group(self.lines_added), - group(self.lines_deleted), - signed(self.lines_delta()), - group(self.binary_added), - group(self.binary_deleted), - day(self.first_commit), - day(self.last_commit), + trf( + "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}", + &[ + ("login", &self.login), + ("prs", &group(self.prs)), + ("commits", &group(self.commits)), + ("added", &group(self.lines_added)), + ("deleted", &group(self.lines_deleted)), + ("delta", &signed(self.lines_delta())), + ("bin_added", &group(self.binary_added)), + ("bin_deleted", &group(self.binary_deleted)), + ("first", &day(self.first_commit)), + ("last", &day(self.last_commit)), + ], ) } } @@ -256,14 +259,14 @@ fn contributors_in(ui: &mut egui::Ui, contributors: &[Contributor], total_commit let id = egui::Id::new("credits_view"); let mut v = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); ui.horizontal_wrapped(|ui| { - ui.label(RichText::new("Show").color(t.text_secondary)); + ui.label(RichText::new(tr("Show")).color(t.text_secondary)); for m in NameMode::ALL { if ui.selectable_label(v.names == m, m.label()).clicked() { v.names = m; } } ui.separator(); - ui.label(RichText::new("Sort").color(t.text_secondary)); + ui.label(RichText::new(tr("Sort")).color(t.text_secondary)); egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| { for k in SortKey::ALL { if ui.selectable_label(v.key == k, k.label().0).clicked() { @@ -273,23 +276,23 @@ fn contributors_in(ui: &mut egui::Ui, contributors: &[Contributor], total_commit } }); // egui's icon font has these arrows; the UI font (Inter) may not have ▲/▼. - if ui.button(if v.ascending { "⏶" } else { "⏷" }).on_hover_text("Reverse the order").clicked() { + if ui.button(if v.ascending { "⏶" } else { "⏷" }).on_hover_text(tr("Reverse the order")).clicked() { v.ascending = !v.ascending; } ui.separator(); - if ui.selectable_label(!v.table, "Grab bag").clicked() { + if ui.selectable_label(!v.table, tr("Grab bag")).clicked() { v.table = false; } - if ui.selectable_label(v.table, "Table").clicked() { + if ui.selectable_label(v.table, tr("Table")).clicked() { v.table = true; } }); let list = sorted(contributors, v.names, v.key, v.ascending); - let counts = format!("Contributors: {} · Commits: {}", group(list.len() as u64), group(total_commits)); + let counts = trf("Contributors: {people} · Commits: {commits}", &[("people", &group(list.len() as u64)), ("commits", &group(total_commits))]); ui.label(RichText::new(counts).small().color(t.text_secondary)); ui.separator(); if list.is_empty() { - ui.label(RichText::new("No contributor data was built into this copy.").color(t.text_secondary)); + ui.label(RichText::new(tr("No contributor data was built into this copy.")).color(t.text_secondary)); } else if v.table { table(ui, &list, &mut v, LIST_HEIGHT); } else { @@ -484,7 +487,8 @@ fn percent(commits: u64, total: u64) -> String { if commits > 0 && p < 0.5 { "<1%".into() } else { format!("{:.0}%", p.min(100.0)) } } -const MODEL_HEADS: [&str; 6] = ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"]; +const MODEL_HEADS: [&str; 6] = + [tr_noop("Company"), tr_noop("Model"), tr_noop("Version"), tr_noop("Commits"), tr_noop("% of all commits"), tr_noop("Lines +/−")]; /// The Models table's cells for `m`, column by column; counts are the last three. fn model_cells(m: &Model, total: u64) -> [String; 6] { @@ -503,7 +507,7 @@ fn model_widths(ui: &egui::Ui, rows: &[[String; 6]]) -> Vec> { MODEL_HEADS .iter() .enumerate() - .map(|(i, h)| (i != 1).then(|| column_width(ui, h, rows.iter().filter_map(|r| r.get(i)).map(String::as_str)))) + .map(|(i, h)| (i != 1).then(|| column_width(ui, tr(h), rows.iter().filter_map(|r| r.get(i)).map(String::as_str)))) .collect() } @@ -511,7 +515,7 @@ fn model_widths(ui: &egui::Ui, rows: &[[String; 6]]) -> Vec> { fn models_in(ui: &mut egui::Ui, models: &[Model], total_commits: u64) { let t = Tokens::get(ui.ctx()); if models.is_empty() { - ui.label(RichText::new("No model credits were built into this copy.").color(t.text_secondary)); + ui.label(RichText::new(tr("No model credits were built into this copy.")).color(t.text_secondary)); return; } // Counted against every commit, or the busiest model's if the total somehow trails it. @@ -528,7 +532,7 @@ fn models_in(ui: &mut egui::Ui, models: &[Model], total_commits: u64) { |mut header| { for (i, h) in MODEL_HEADS.iter().enumerate() { header.col(|ui| { - header_cell(ui, &t, h, numeric(i), None); + header_cell(ui, &t, tr(h), numeric(i), None); }); } }, diff --git a/crates/ui-egui/src/dev_compare.rs b/crates/ui-egui/src/dev_compare.rs index 065b8d3..2e21eb5 100644 --- a/crates/ui-egui/src/dev_compare.rs +++ b/crates/ui-egui/src/dev_compare.rs @@ -11,6 +11,7 @@ use figcraft_engine::devcompare::{self, ChangeKind, Comparison, LayerChange}; use serde_json::json; use crate::FigApp; +use crate::i18n::{tr, tr_noop, trf, trn}; use crate::icons::Icon; use crate::theme::{ACCENT, DEV_CHANGED, Tokens, regular, semibold}; use crate::widgets; @@ -155,7 +156,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { ui.set_min_size(size); ui.spacing_mut().item_spacing = vec2(0.0, 0.0); let Some(cache) = view.cache.as_ref() else { return }; - header(ui, &t, "Compare changes", &cache.cmp.name, &mut view.mode, &mut open); + header(ui, &t, tr("Compare changes"), &cache.cmp.name, &mut view.mode, &mut open); let body_h = size.y - BAR_H * 2.0 - 2.0; let top_down = egui::Layout::top_down(egui::Align::Min); ui.horizontal_top(|ui| { @@ -163,13 +164,15 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { let (r, _) = ui.allocate_exact_size(vec2(images_w, body_h), Sense::hover()); if !cache.cmp.snapshot { let msg = if cache.cmp.changed { - "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." + tr( + "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.", + ) } else { - "Nothing changed since this was marked ready for dev." + tr("Nothing changed since this was marked ready for dev.") }; message(ui, &t, r, msg); } else if let (Some(before), Some(after)) = (&cache.before, &cache.after) { - images(ui, &t, r, [(before, "Before"), (after, "Now")], view.mode, &mut view.split, view.hovered); + images(ui, &t, r, [(before, tr("Before")), (after, tr("Now"))], view.mode, &mut view.split, view.hovered); } let (line, _) = ui.allocate_exact_size(vec2(1.0, body_h), Sense::hover()); ui.painter().rect_filled(line, CornerRadius::ZERO, t.border); @@ -189,7 +192,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { } Some(Action::Resolve) => { if app.run("dev.resolveChange", json!({"ids": [view.id]})).is_some() { - app.toast("Change resolved"); + app.toast(tr("Change resolved")); open = false; } } @@ -217,7 +220,7 @@ pub(crate) fn header(ui: &mut Ui, t: &Tokens, title: &str, name: &str, mode: &mu let name = widgets::elide(ui, name, 320.0); ui.painter().text(pos2(r.min.x + 28.0 + tw, r.center().y), Align2::LEFT_CENTER, name, regular(12.0), t.text_secondary); // Side by side / overlay, as a two-way switch left of the close button. - let modes = [(Mode::SideBySide, "Side by side"), (Mode::Overlay, "Overlay")]; + let modes = [(Mode::SideBySide, tr("Side by side")), (Mode::Overlay, tr("Overlay"))]; let mut x = r.max.x - 48.0; for (m, label) in modes.iter().rev() { let g = ui.painter().layout_no_wrap((*label).into(), regular(11.0), t.text); @@ -242,7 +245,7 @@ pub(crate) fn header(ui: &mut Ui, t: &Tokens, title: &str, name: &str, mode: &mu Id::new("compare_close"), Rect::from_center_size(pos2(r.max.x - 24.0, r.center().y), vec2(24.0, 24.0)), Icon::Close, - "Close", + tr("Close"), ) { *open = false; } @@ -325,16 +328,16 @@ fn pane(p: &egui::Painter, t: &Tokens, place: Place, shot: &Shot, hovered: Optio fn list(ui: &mut Ui, t: &Tokens, cmp: &Comparison, hovered: &mut Option) -> Option { let mut out = None; let summary = if !cmp.snapshot { - "Changes unknown".to_string() + tr("Changes unknown").to_string() } else if cmp.changes.is_empty() { - "No changes".to_string() + tr("No changes").to_string() } else { - let parts: Vec = [(cmp.modified, "changed"), (cmp.added, "added"), (cmp.removed, "removed")] + let parts: Vec = [(cmp.modified, tr_noop("{n} changed")), (cmp.added, tr_noop("{n} added")), (cmp.removed, tr_noop("{n} removed"))] .iter() .filter(|(n, _)| *n > 0) - .map(|(n, w)| format!("{n} {w}")) + .map(|(n, key)| trn(key, *n, &[])) .collect(); - format!("{} layer{} · {}", cmp.changes.len(), if cmp.changes.len() == 1 { "" } else { "s" }, parts.join(", ")) + trn("{n} layers · {parts}", cmp.changes.len(), &[("parts", &parts.join(", "))]) }; let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 36.0), Sense::hover()); ui.painter().text(pos2(r.min.x + 16.0, r.center().y), Align2::LEFT_CENTER, summary, semibold(11.0), t.text); @@ -342,7 +345,7 @@ fn list(ui: &mut Ui, t: &Tokens, cmp: &Comparison, hovered: &mut Option) egui::ScrollArea::vertical().id_salt("compare_list").auto_shrink([false, false]).show(ui, |ui| { ui.spacing_mut().item_spacing = vec2(0.0, 0.0); for c in &cmp.changes { - let tip = if c.change == ChangeKind::Removed { "Removed since it was marked" } else { "Select layer" }; + let tip = if c.change == ChangeKind::Removed { tr("Removed since it was marked") } else { tr("Select layer") }; let resp = change_row(ui, t, c.change, &c.name, c.depth, c.layers, false, tip); if resp.hovered() { now_hovered = Some(c.id); @@ -353,7 +356,7 @@ fn list(ui: &mut Ui, t: &Tokens, cmp: &Comparison, hovered: &mut Option) property_lines(ui, t, c.depth, c.properties.iter().map(|pc| pc.text.as_str())); } if cmp.truncated { - widgets::note(ui, &format!("Only the first {} changed layers are listed.", devcompare::MAX_CHANGES), t.text_secondary); + widgets::note(ui, &trf("Only the first {n} changed layers are listed.", &[("n", &devcompare::MAX_CHANGES)]), t.text_secondary); } }); *hovered = now_hovered; @@ -375,19 +378,19 @@ pub(crate) fn change_row( let indent = 16.0 + (depth.min(6) as f32) * 10.0; let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::click()); let resp = resp.on_hover_text(tip); - crate::a11y::list_item(&resp, &format!("{} {name}", kind.label()), selected); + crate::a11y::list_item(&resp, &format!("{} {name}", tr(kind.label())), selected); if selected { ui.painter().rect_filled(row.shrink2(vec2(8.0, 1.0)), CornerRadius::same(5), t.selected_row); } else if resp.hovered() { ui.painter().rect_filled(row.shrink2(vec2(8.0, 1.0)), CornerRadius::same(5), t.hover); } let col = kind_color(t, kind); - let tag = ui.painter().layout_no_wrap(kind.label().into(), semibold(9.0), col); + let tag = ui.painter().layout_no_wrap(tr(kind.label()).into(), semibold(9.0), col); let tag_r = Rect::from_min_size(pos2(row.max.x - 16.0 - tag.size().x - 10.0, row.center().y - 8.0), vec2(tag.size().x + 10.0, 16.0)); ui.painter().rect_filled(tag_r, CornerRadius::same(4), col.gamma_multiply(0.15)); ui.painter().galley(tag_r.center() - tag.size() / 2.0, tag, col); ui.painter().circle_filled(pos2(row.min.x + indent + 3.0, row.center().y), 3.0, col); - let name = if layers > 1 { format!("{name} ({layers} layers)") } else { name.to_string() }; + let name = if layers > 1 { trf("{name} ({layers} layers)", &[("name", &name), ("layers", &layers)]) } else { name.to_string() }; let name = widgets::elide(ui, &name, (tag_r.min.x - row.min.x - indent - 20.0).max(20.0)); let strike = kind == ChangeKind::Removed; let galley = ui.painter().layout_no_wrap(name, regular(11.0), if strike { t.text_secondary } else { t.text }); @@ -423,12 +426,12 @@ fn footer(ui: &mut Ui, t: &Tokens, cmp: &Comparison) -> Option { let inner = Rect::from_min_max(pos2(r.min.x + 16.0, r.min.y + 1.0), pos2(r.max.x - 16.0, r.max.y)); ui.scope_builder(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::right_to_left(egui::Align::Center)), |ui| { ui.spacing_mut().item_spacing.x = 8.0; - let resolve = widgets::button(ui, "Resolve change", true, cmp.changed) - .on_hover_text("Back to Ready for dev: the next change compares with how it is now"); + let resolve = widgets::button(ui, tr("Resolve change"), true, cmp.changed) + .on_hover_text(tr("Back to Ready for dev: the next change compares with how it is now")); if resolve.clicked() { out = Some(Action::Resolve); } - if widgets::button(ui, "Close", false, true).clicked() { + if widgets::button(ui, tr("Close"), false, true).clicked() { out = Some(Action::Close); } }); diff --git a/crates/ui-egui/src/dev_mode.rs b/crates/ui-egui/src/dev_mode.rs index dde55f5..4f35758 100644 --- a/crates/ui-egui/src/dev_mode.rs +++ b/crates/ui-egui/src/dev_mode.rs @@ -15,6 +15,7 @@ use figcraft_engine::plugins::Access; use serde_json::json; use crate::a11y::Tip; +use crate::i18n::{tr, tr_noop, trf, trn}; use crate::icons::{self, Icon}; use crate::right::{menu_check, menu_item_with, menu_popup, prop_row, row}; use crate::theme::{ACCENT, Tokens, doc_c32, regular, semibold}; @@ -28,7 +29,7 @@ const CALLOUT_GAP: f32 = 24.0; /// The status filters of the ready-for-dev list. const FILTERS: [(Option, &str); 3] = - [(Some(DevStatus::ReadyForDev), "Ready for dev"), (Some(DevStatus::Completed), "Completed"), (None, "Any status")]; + [(Some(DevStatus::ReadyForDev), tr_noop("Ready for dev")), (Some(DevStatus::Completed), tr_noop("Completed")), (None, tr_noop("Any status"))]; /// Which annotations the canvas shows. #[derive(Clone, Debug, Default, PartialEq, Eq)] @@ -69,10 +70,10 @@ impl CategoryFilter { fn label(&self, doc: &Document) -> String { match self { - Self::All => "All categories".into(), - Self::Uncategorized => "No category".into(), + Self::All => tr("All categories").into(), + Self::Uncategorized => tr("No category").into(), Self::Only(id) => { - annotations::categories(doc).iter().find(|c| c.id == *id).map(|c| c.name.clone()).unwrap_or_else(|| "All categories".into()) + annotations::categories(doc).iter().find(|c| c.id == *id).map(|c| c.name.clone()).unwrap_or_else(|| tr("All categories").into()) } } } @@ -213,7 +214,7 @@ impl Callout { } for (p, v) in pinned { let mid = y + 9.0; - painter.text(pos2(x, mid), Align2::LEFT_CENTER, p.label, regular(10.0), t.text_secondary); + painter.text(pos2(x, mid), Align2::LEFT_CENTER, tr(p.label), regular(10.0), t.text_secondary); let mut vx = x + 84.0; if let Some(c) = v.as_ref().and_then(|v| v.color.as_deref()).and_then(figcraft_color::Color::from_hex) { widgets::paint_swatch(painter, Rect::from_center_size(pos2(vx + 5.0, mid), vec2(10.0, 10.0)), &figcraft_color::Paint::solid(c)); @@ -270,20 +271,20 @@ pub fn connect_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId let connectable = code_connect::language(lang.id()).is_some(); widgets::separator(ui); let buttons: &[(Icon, &str)] = match (&link, connectable) { - (Some(_), _) => &[(Icon::Minus, "Disconnect from code")], - (None, true) => &[(Icon::Plus, "Connect to code")], + (Some(_), _) => &[(Icon::Minus, tr("Disconnect from code"))], + (None, true) => &[(Icon::Plus, tr("Connect to code"))], _ => &[], }; - let clicked = widgets::section_header(ui, "Connect to code", buttons, link.is_none()).is_some(); + let clicked = widgets::section_header(ui, tr("Connect to code"), buttons, link.is_none()).is_some(); let params = json!({"id": owner, "language": lang.id()}); let Some(link) = link else { if clicked { app.run("codeConnect.set", params); } let text = if connectable { - "Link this component to its code component: instances then show your real snippet with their values." + tr("Link this component to its code component: instances then show your real snippet with their values.") } else { - "Choose React, SwiftUI or Compose above to connect this component to its code." + tr("Choose React, SwiftUI or Compose above to connect this component to its code.") }; widgets::note(ui, text, t.text_secondary); return; @@ -294,32 +295,33 @@ pub fn connect_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId } // The badge, and what the snippet comes from. let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::hover()); - let g = ui.painter().layout_no_wrap("Connected".into(), semibold(10.0), Color32::WHITE); + let g = ui.painter().layout_no_wrap(tr("Connected").to_string(), semibold(10.0), Color32::WHITE); let pill = Rect::from_min_size(pos2(r.min.x + PAD, r.center().y - g.size().y / 2.0 - 2.0), g.size() + vec2(10.0, 4.0)); ui.painter().rect_filled(pill, CornerRadius::same(4), t.success); ui.painter().galley(pill.min + vec2(5.0, 2.0), g, Color32::WHITE); - let from = if link.import_path.is_empty() { link.name.clone() } else { format!("{} from {}", link.name, link.import_path) }; - crate::a11y::status(&resp, "Connected", &format!("Connected to code: {from}")); + let from = + if link.import_path.is_empty() { link.name.clone() } else { trf("{name} from {path}", &[("name", &link.name), ("path", &link.import_path)]) }; + crate::a11y::status(&resp, tr("Connected"), &trf("Connected to code: {from}", &[("from", &from)])); let from_x = pill.max.x + 8.0; let from = widgets::elide(ui, &from, r.max.x - PAD - from_x); ui.painter().text(pos2(from_x, r.center().y), Align2::LEFT_CENTER, from, regular(11.0), t.text_secondary); let mut edit: Option<(&str, String)> = None; - widgets::sublabels(ui, "Import path", Some("Name")); + widgets::sublabels(ui, tr("Import path"), Some(tr("Name"))); let col = widgets::col_width(ui.available_width()); row(ui, 32.0, |ui| { ui.spacing_mut().item_spacing.x = 8.0; - if let Some(v) = widgets::text_field(ui, Id::new(("code_connect_import", owner, lang.id())), &link.import_path, col, "Import path") { + if let Some(v) = widgets::text_field(ui, Id::new(("code_connect_import", owner, lang.id())), &link.import_path, col, tr("Import path")) { edit = Some(("importPath", v)); } - if let Some(v) = widgets::text_field(ui, Id::new(("code_connect_name", owner, lang.id())), &link.name, col, "Component name") { + if let Some(v) = widgets::text_field(ui, Id::new(("code_connect_name", owner, lang.id())), &link.name, col, tr("Component name")) { edit = Some(("name", v)); } }); - widgets::sublabels(ui, "Template", None); + widgets::sublabels(ui, tr("Template"), None); if let Some(v) = widgets::multiline_field(ui, template_id(owner, lang), &link.template, "", 3) { edit = Some(("template", v)); } - widgets::note(ui, "{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written.", t.text_secondary); + widgets::note(ui, tr("{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written."), t.text_secondary); if let Some((key, value)) = edit { let mut params = params; params[key] = json!(value); @@ -327,7 +329,7 @@ pub fn connect_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId && let Some(names) = out["unknown"].as_array().filter(|u| !u.is_empty()) { let names: Vec<&str> = names.iter().filter_map(serde_json::Value::as_str).collect(); - app.toast(format!("Not properties, kept as written: {}", names.join(", "))); + app.toast(trf("Not properties, kept as written: {names}", &[("names", &names.join(", "))])); } } ui.add_space(4.0); @@ -354,11 +356,11 @@ pub fn plugins_section(app: &mut FigApp, ui: &mut Ui) { } let t = Tokens::get(ui.ctx()); widgets::separator(ui); - widgets::section_header(ui, "Plugins", &[], false); + widgets::section_header(ui, tr("Plugins"), &[], false); let mut run = None; for (id, command, label) in &runnable { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::click()); - let resp = resp.tip(format!("Run {label}")); + let resp = resp.tip(trf("Run {label}", &[("label", label)])); if resp.hovered() { ui.painter().rect_filled(r.shrink2(vec2(8.0, 0.0)), CornerRadius::same(5), t.hover); } @@ -381,7 +383,7 @@ pub fn status(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { return; } widgets::separator(ui); - widgets::sublabels(ui, "Status", None); + widgets::sublabels(ui, tr("Status"), None); let w = ui.available_width() - PAD * 2.0; row(ui, 32.0, |ui| crate::section_panel::status_dropdown(app, ui, doc, sel, w)); if let [id] = sel { @@ -389,10 +391,13 @@ pub fn status(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let focused = app.session.ds().ok().and_then(figcraft_engine::DocState::focused) == Some(*id); row(ui, 32.0, |ui| { ui.spacing_mut().item_spacing.x = 8.0; - if !focused && widgets::button(ui, "Focus view", false, true).on_hover_text("Show only this on the canvas").clicked() { + if !focused && widgets::button(ui, tr("Focus view"), false, true).on_hover_text(tr("Show only this on the canvas")).clicked() { app.run("dev.focus", json!({"id": id})); } - if changed && widgets::button(ui, "Compare changes", true, true).on_hover_text("What changed since it was marked ready for dev").clicked() + if changed + && widgets::button(ui, tr("Compare changes"), true, true) + .on_hover_text(tr("What changed since it was marked ready for dev")) + .clicked() { app.run("dev.compare", json!({"id": id, "show": true})); } @@ -408,18 +413,24 @@ pub fn annotations_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: No let Some(n) = doc.node(id) else { return }; let sections = devinspect::properties(doc, id); widgets::separator(ui); - widgets::section_header(ui, "Annotations", &[], n.annotations.is_empty()); + widgets::section_header(ui, tr("Annotations"), &[], n.annotations.is_empty()); for (k, a) in n.annotations.iter().enumerate() { let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::hover()); - ui.painter().text(pos2(r.min.x + PAD, r.center().y), Align2::LEFT_CENTER, format!("Note {}", k + 1), semibold(10.0), t.text_secondary); + ui.painter().text( + pos2(r.min.x + PAD, r.center().y), + Align2::LEFT_CENTER, + trf("Note {n}", &[("n", &(k + 1))]), + semibold(10.0), + t.text_secondary, + ); let del = Rect::from_center_size(pos2(r.max.x - PAD - 4.0, r.center().y), vec2(22.0, 22.0)); - if widgets::icon_button_at(ui, Id::new(("annot_del", id, k)), del, Icon::Minus, "Remove annotation") { + if widgets::icon_button_at(ui, Id::new(("annot_del", id, k)), del, Icon::Minus, tr("Remove annotation")) { app.run("annotation.remove", json!({"id": id, "index": k})); } if let Some(cat) = category_menu(ui, doc, Id::new(("annot_cat", id, k)), a) { app.run("annotation.edit", json!({"id": id, "index": k, "category": cat})); } - if let Some(label) = widgets::multiline_field(ui, Id::new(("annot_note", id, k)), &a.label, "Add a note for developers", 2) { + if let Some(label) = widgets::multiline_field(ui, Id::new(("annot_note", id, k)), &a.label, tr("Add a note for developers"), 2) { if label.trim().is_empty() && a.properties.is_empty() { app.run("annotation.remove", json!({"id": id, "index": k})); } else { @@ -448,7 +459,7 @@ pub fn annotations_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: No } // A new annotation starts as a note; properties are pinned to it afterwards. if n.annotations.len() < annotations::MAX_PER_LAYER - && let Some(label) = widgets::multiline_field(ui, Id::new(("annot_new", id)), "", "Add an annotation for developers", 1) + && let Some(label) = widgets::multiline_field(ui, Id::new(("annot_new", id)), "", tr("Add an annotation for developers"), 1) && !label.trim().is_empty() { app.run("annotation.add", json!({"id": id, "label": label})); @@ -463,10 +474,10 @@ fn category_menu(ui: &mut Ui, doc: &Document, id: Id, a: &Annotation) -> Option< let w = ui.available_width() - PAD * 2.0; row(ui, 30.0, |ui| { let lead = current.map(|c| DropLead::Dot(doc_c32(ui.ctx(), c.color))); - let label = current.map_or("No category", |c| c.name.as_str()); + let label = current.map_or(tr("No category"), |c| c.name.as_str()); let resp = ui.push_id(id, |ui| widgets::dropdown_lead(ui, label, w, lead)).inner; menu_popup(&resp).show(|ui| { - if menu_check(ui, current.is_none(), "No category", "") { + if menu_check(ui, current.is_none(), tr("No category"), "") { out = Some(serde_json::Value::Null); ui.close(); } @@ -496,7 +507,7 @@ pub fn categories_section(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let Ok(doc) = app.session.doc().cloned() else { return }; widgets::separator(ui); - let add = widgets::section_header(ui, "Annotations", &[(Icon::Plus, "Add a category")], false).is_some(); + let add = widgets::section_header(ui, tr("Annotations"), &[(Icon::Plus, tr("Add a category"))], false).is_some(); let w = ui.available_width() - PAD * 2.0; let filter = app.ui.dev.categories.clone(); row(ui, 32.0, |ui| { @@ -504,9 +515,9 @@ pub fn categories_section(app: &mut FigApp, ui: &mut Ui) { CategoryFilter::Only(id) => annotations::categories(&doc).iter().find(|c| c.id == *id).map(|c| DropLead::Dot(doc_c32(ui.ctx(), c.color))), _ => Some(DropLead::Icon(Icon::Comment)), }; - let resp = widgets::dropdown_lead(ui, &filter.label(&doc), w, lead).on_hover_text("Annotations shown on the canvas"); + let resp = widgets::dropdown_lead(ui, &filter.label(&doc), w, lead).on_hover_text(tr("Annotations shown on the canvas")); menu_popup(&resp).show(|ui| { - for (f, label) in [(CategoryFilter::All, "All categories"), (CategoryFilter::Uncategorized, "No category")] { + for (f, label) in [(CategoryFilter::All, tr("All categories")), (CategoryFilter::Uncategorized, tr("No category"))] { if menu_check(ui, filter == f, label, "") { app.ui.dev.categories = f; ui.close(); @@ -520,13 +531,13 @@ pub fn categories_section(app: &mut FigApp, ui: &mut Ui) { } }); }); - widgets::sublabels(ui, "Categories", None); + widgets::sublabels(ui, tr("Categories"), None); let mut action: Option<(&str, serde_json::Value)> = None; for c in annotations::categories(&doc) { let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover()); // The colour: click for the palette. let sw = Rect::from_center_size(pos2(r.min.x + PAD + 8.0, r.center().y), vec2(16.0, 16.0)); - let sw_resp = ui.interact(sw, Id::new(("cat_color", &c.id)), Sense::click()).tip("Colour"); + let sw_resp = ui.interact(sw, Id::new(("cat_color", &c.id)), Sense::click()).tip(tr("Colour")); crate::a11y::color(&sw_resp, &c.name, &c.color.to_css_hex()); ui.painter().rect_filled(sw, CornerRadius::same(4), doc_c32(ui.ctx(), c.color)); if sw_resp.hovered() { @@ -556,7 +567,7 @@ pub fn categories_section(app: &mut FigApp, ui: &mut Ui) { action = Some(("annotation.editCategory", json!({"id": c.id, "name": name.trim()}))); } let del = Rect::from_center_size(pos2(r.max.x - PAD - 8.0, r.center().y), vec2(22.0, 22.0)); - if widgets::icon_button_at(ui, Id::new(("cat_del", &c.id)), del, Icon::Minus, "Delete category (its annotations stay)") { + if widgets::icon_button_at(ui, Id::new(("cat_del", &c.id)), del, Icon::Minus, tr("Delete category (its annotations stay)")) { action = Some(("annotation.removeCategory", json!({"id": c.id}))); } } @@ -577,11 +588,11 @@ fn pin_menu(ui: &mut Ui, id: Id, sections: &[devinspect::Section], pinned: &[Str let mut out = None; let w = ui.available_width() - PAD * 2.0; row(ui, 30.0, |ui| { - let resp = ui.push_id(id, |ui| widgets::dropdown(ui, "Pin a property", w, Some(Icon::Plus))).inner; + let resp = ui.push_id(id, |ui| widgets::dropdown(ui, tr("Pin a property"), w, Some(Icon::Plus))).inner; menu_popup(&resp).show(|ui| { for p in PINNABLE { let on = pinned.iter().any(|q| q == p.id); - if (on || annotations::applies(sections, p)) && menu_check(ui, on, p.label, "") { + if (on || annotations::applies(sections, p)) && menu_check(ui, on, tr(p.label), "") { out = Some(p); ui.close(); } @@ -604,13 +615,13 @@ pub fn ready_list(app: &mut FigApp, ui: &mut Ui) { } let doc = ds.doc.clone(); widgets::separator(ui); - widgets::section_header(ui, "Ready for dev", &[], app.ui.dev.ready.is_empty()); + widgets::section_header(ui, tr("Ready for dev"), &[], app.ui.dev.ready.is_empty()); let w = ui.available_width() - PAD * 2.0; row(ui, 32.0, |ui| { - let resp = widgets::dropdown(ui, FILTERS.get(filter).map_or("", |f| f.1), w, Some(Icon::Code)); + let resp = widgets::dropdown(ui, FILTERS.get(filter).map_or("", |f| tr(f.1)), w, Some(Icon::Code)); menu_popup(&resp).show(|ui| { for (i, (_, label)) in FILTERS.iter().enumerate() { - if menu_check(ui, i == filter, label, "") { + if menu_check(ui, i == filter, tr(label), "") { app.ui.dev.filter = i; ui.close(); } @@ -618,13 +629,13 @@ pub fn ready_list(app: &mut FigApp, ui: &mut Ui) { }); }); if app.ui.dev.ready.is_empty() { - widgets::note(ui, "Nothing here yet. Set a frame's status to list it.", t.text_secondary); + widgets::note(ui, tr("Nothing here yet. Set a frame's status to list it."), t.text_secondary); return; } let (mut go, mut focus) = (None, None); for item in &app.ui.dev.ready { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::click()); - let resp = resp.tip(format!("Go to {}", item.name)); + let resp = resp.tip(trf("Go to {name}", &[("name", &item.name)])); if resp.hovered() { ui.painter().rect_filled(r.shrink2(vec2(8.0, 0.0)), CornerRadius::same(5), t.hover); } @@ -646,7 +657,7 @@ pub fn ready_list(app: &mut FigApp, ui: &mut Ui) { CornerRadius::same(5), t.hover, ); - if widgets::icon_button_at(ui, Id::new(("ready_focus", item.id)), focus_r, Icon::Frame, "Open in focus view") { + if widgets::icon_button_at(ui, Id::new(("ready_focus", item.id)), focus_r, Icon::Frame, tr("Open in focus view")) { focus = Some(item.id); } } @@ -700,21 +711,21 @@ pub fn focus_bar(app: &mut FigApp, ctx: &egui::Context, canvas: Rect) { if let Some(b) = crate::canvas::status_badge(ui.painter(), n, ui.cursor().min + vec2(0.0, 12.0)) { ui.add_space(b.width()); } - if n.dev_status == DevStatus::ReadyForDev && n.dev_changed && widgets::button(ui, "Compare changes", true, true).clicked() { + if n.dev_status == DevStatus::ReadyForDev && n.dev_changed && widgets::button(ui, tr("Compare changes"), true, true).clicked() { out = Some(FocusAction::Compare); } if can_step { ui.add_space(4.0); - if widgets::icon_button(ui, Icon::ChevronLeft, "Previous ready for dev") { + if widgets::icon_button(ui, Icon::ChevronLeft, tr("Previous ready for dev")) { out = Some(FocusAction::Previous); } - let pos = at.map_or_else(|| format!("{} ready", list.len()), |k| format!("{} of {}", k + 1, list.len())); + let pos = at.map_or_else(|| trn("{n} ready", list.len(), &[]), |k| trf("{n} of {total}", &[("n", &(k + 1)), ("total", &list.len())])); ui.label(egui::RichText::new(pos).font(regular(11.0)).color(t.text_secondary)); - if widgets::icon_button(ui, Icon::ChevronRight, "Next ready for dev") { + if widgets::icon_button(ui, Icon::ChevronRight, tr("Next ready for dev")) { out = Some(FocusAction::Next); } } - if widgets::icon_button(ui, Icon::Close, "Exit focus view (Esc)") { + if widgets::icon_button(ui, Icon::Close, tr("Exit focus view (Esc)")) { out = Some(FocusAction::Exit); } out diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index c270442..e0b8faa 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -8,6 +8,7 @@ use serde_json::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}; @@ -41,9 +42,9 @@ impl AboutTab { pub fn label(self) -> &'static str { match self { - AboutTab::About => "About", - AboutTab::Contributors => "Contributors", - AboutTab::Models => "Models", + AboutTab::About => tr("About"), + AboutTab::Contributors => tr("Contributors"), + AboutTab::Models => tr("Models"), } } } @@ -81,23 +82,27 @@ fn about_body(ui: &mut egui::Ui, t: &Tokens) { icons::paint(ui.painter(), r.shrink(10.0), Icon::Logo, Color32::WHITE); ui.vertical(|ui| { ui.label(RichText::new("FigCraft").font(semibold(18.0))); - ui.label(RichText::new(format!("Version {VERSION}")).color(t.text_secondary)); + ui.label(RichText::new(trf("Version {version}", &[("version", &VERSION)])).color(t.text_secondary)); }); }); ui.add_space(12.0); - ui.label("Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps."); + ui.label(tr( + "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps.", + )); ui.add_space(8.0); ui.hyperlink_to("getartcraft.com/apps/figcraft", "https://getartcraft.com/apps/figcraft"); - ui.hyperlink_to("Join us on Discord", "https://discord.gg/artcraft"); + ui.hyperlink_to(tr("Join us on Discord"), "https://discord.gg/artcraft"); ui.add_space(8.0); - ui.label(RichText::new("Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community.").color(t.text_secondary).font(regular(11.0))); + ui.label( + RichText::new(tr("Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community.")).color(t.text_secondary).font(regular(11.0)), + ); } pub fn show(app: &mut FigApp, ctx: &Context) { let t = Tokens::get(ctx); if app.ui.about { let mut open = true; - window("About FigCraft", ctx).open(&mut open).show(ctx, |ui| { + window(tr("About FigCraft"), ctx).open(&mut open).show(ctx, |ui| { ui.set_width(ABOUT_WIDTH); tab_strip(ui, &t, &AboutTab::ALL, AboutTab::label, &mut app.ui.about_tab); ui.add_space(12.0); @@ -113,45 +118,49 @@ pub fn show(app: &mut FigApp, ctx: &Context) { } if app.ui.preferences { let mut open = true; - window("Preferences", ctx).open(&mut open).show(ctx, |ui| { + window(tr("Preferences"), ctx).open(&mut open).show(ctx, |ui| { ui.set_width(360.0); let prefs = app.session.prefs.clone(); let mut toggles = vec![]; for (label, val, cmd) in [ - ("Keep tool selected after use", prefs.keep_tool, "pref.keepTool"), - ("Highlight layers on hover", prefs.highlight_on_hover, "pref.highlightHover"), - ("Rename duplicated layers", prefs.rename_duplicates, "pref.renameDuplicates"), - ("Show dimensions on objects", prefs.show_dimensions, "pref.showDimensions"), - ("Use number keys for opacity", prefs.number_keys_opacity, "pref.numberKeysOpacity"), - ("Reduce motion", prefs.reduce_motion, "pref.reduceMotion"), + (tr("Keep tool selected after use"), prefs.keep_tool, "pref.keepTool"), + (tr("Highlight layers on hover"), prefs.highlight_on_hover, "pref.highlightHover"), + (tr("Rename duplicated layers"), prefs.rename_duplicates, "pref.renameDuplicates"), + (tr("Show dimensions on objects"), prefs.show_dimensions, "pref.showDimensions"), + (tr("Use number keys for opacity"), prefs.number_keys_opacity, "pref.numberKeysOpacity"), + (tr("Reduce motion"), prefs.reduce_motion, "pref.reduceMotion"), ] { if crate::widgets::checkbox(ui, val, label) { toggles.push(cmd); } } if app.services.reduce_motion() { - crate::widgets::label(ui, "Your system asks for reduced motion: animations stay off.", t.text_secondary); + crate::widgets::label(ui, tr("Your system asks for reduced motion: animations stay off."), t.text_secondary); } for c in toggles { app.run(c, json!({})); } // Where the app registers itself as the opener of invite links (Windows). if let Some(on) = app.session.invite_links_on() { - if crate::widgets::checkbox(ui, on, "Open invite links in FigCraft") { + if crate::widgets::checkbox(ui, on, tr("Open invite links in FigCraft")) { app.run("collab.registerLinks", json!({"on": !on})); } - crate::widgets::label(ui, "figcraft:// links from a browser, chat or email open this app and join the session.", t.text_secondary); + crate::widgets::label( + ui, + tr("figcraft:// links from a browser, chat or email open this app and join the session."), + t.text_secondary, + ); } ui.add_space(8.0); ui.horizontal(|ui| { - ui.label("Your name in comments"); - if let Some(name) = crate::widgets::text_field(ui, egui::Id::new("pref_user_name"), &prefs.user_name, 160.0, "You") { + ui.label(tr("Your name in comments")); + if let Some(name) = crate::widgets::text_field(ui, egui::Id::new("pref_user_name"), &prefs.user_name, 160.0, tr("You")) { app.run("pref.userName", json!({"name": name})); } }); ui.add_space(8.0); ui.horizontal(|ui| { - ui.label("Nudge amount"); + ui.label(tr("Nudge amount")); let mut small = prefs.nudge_small; let mut big = prefs.nudge_big; let a = ui.add(egui::DragValue::new(&mut small).range(0.1..=100.0)); @@ -162,13 +171,29 @@ pub fn show(app: &mut FigApp, ctx: &Context) { }); ui.add_space(8.0); ui.horizontal(|ui| { - ui.label("Theme"); - for (l, c) in [("Light", "view.themeLight"), ("Dark", "view.themeDark"), ("System", "view.themeSystem")] { - if ui.selectable_label(app.session.prefs.theme.eq_ignore_ascii_case(l), l).clicked() { + ui.label(tr("Theme")); + for (l, c) in [(tr_noop("Light"), "view.themeLight"), (tr_noop("Dark"), "view.themeDark"), (tr_noop("System"), "view.themeSystem")] { + if ui.selectable_label(app.session.prefs.theme.eq_ignore_ascii_case(l), tr(l)).clicked() { app.run(c, json!({})); } } }); + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.label(tr("Language")); + // Each language by its own name; System follows the OS (the browser on the web). + let langs = i18n::languages().iter().map(|l| (l.code, l.name)); + for (code, name) in [("system", tr("System"))].into_iter().chain(langs) { + if ui.selectable_label(app.session.prefs.language.eq_ignore_ascii_case(code), name).clicked() { + app.run("pref.language", json!({"language": code})); + ui.ctx().request_repaint(); + } + } + }); + if let Some(lang) = i18n::current().filter(|l| l.draft) { + let note = trf("{language}: draft translation, waiting for a native speaker's review.", &[("language", &lang.name)]); + crate::widgets::label(ui, ¬e, t.text_secondary); + } }); if !open { app.ui.preferences = false; @@ -178,19 +203,24 @@ pub fn show(app: &mut FigApp, ctx: &Context) { if let Some((mut find, mut replace)) = app.ui.find.clone() { let mut open = true; let mut action = None; - egui::Window::new("Find and replace").open(&mut open).anchor(Align2::LEFT_TOP, vec2(260.0, 120.0)).resizable(false).show(ctx, |ui| { - ui.set_width(280.0); - ui.add(egui::TextEdit::singleline(&mut find).hint_text("Find").desired_width(260.0)); - ui.add(egui::TextEdit::singleline(&mut replace).hint_text("Replace with").desired_width(260.0)); - ui.horizontal(|ui| { - if ui.button("Find").clicked() { - action = Some(false); - } - if ui.button("Replace all").clicked() { - action = Some(true); - } + egui::Window::new(tr("Find and replace")) + .id(egui::Id::new("Find and replace")) + .open(&mut open) + .anchor(Align2::LEFT_TOP, vec2(260.0, 120.0)) + .resizable(false) + .show(ctx, |ui| { + ui.set_width(280.0); + ui.add(egui::TextEdit::singleline(&mut find).hint_text(tr("Find")).desired_width(260.0)); + ui.add(egui::TextEdit::singleline(&mut replace).hint_text(tr("Replace with")).desired_width(260.0)); + ui.horizontal(|ui| { + if ui.button(tr("Find")).clicked() { + action = Some(false); + } + if ui.button(tr("Replace all")).clicked() { + action = Some(true); + } + }); }); - }); if let Some(rep) = action && !find.is_empty() { @@ -201,7 +231,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) { }; if let Some(v) = r { let n = v["matches"].as_array().map(|a| a.len()).unwrap_or(0); - app.toast(format!("{n} match{}", if n == 1 { "" } else { "es" })); + app.toast(trn("{n} matches", n, &[])); } } app.ui.find = if open { Some((find, replace)) } else { None }; @@ -229,42 +259,42 @@ fn color_profile_dialog(app: &mut FigApp, ctx: &Context) { let t = Tokens::get(ctx); let mut open = true; let mut apply = None; - window("Color profile", ctx).open(&mut open).show(ctx, |ui| { + window(tr("Color profile"), ctx).open(&mut open).show(ctx, |ui| { ui.set_width(340.0); - ui.label(RichText::new("Color values in this file are in this color space.").color(t.text_secondary).font(regular(11.0))); + ui.label(RichText::new(tr("Color values in this file are in this color space.")).color(t.text_secondary).font(regular(11.0))); ui.add_space(8.0); for p in ColorProfile::ALL { - let label = if p == current { format!("{} (current)", p.label()) } else { p.label().to_string() }; + let label = if p == current { trf("{profile} (current)", &[("profile", &p.label())]) } else { p.label().to_string() }; if crate::widgets::radio(ui, chosen == p, &label) { chosen = p; } } ui.add_space(8.0); if chosen == current { - ui.label(RichText::new("Choose another profile to change it.").color(t.text_secondary).font(regular(11.0))); + ui.label(RichText::new(tr("Choose another profile to change it.")).color(t.text_secondary).font(regular(11.0))); return; } ui.label( - RichText::new(format!( - "Keep appearance converts every color value to {} so colors look the same. Keep values leaves the numbers as they are, so colors shift.", - chosen.label() + RichText::new(trf( + "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", &chosen.label())], )) .font(regular(11.0)), ); if !chosen.is_srgb() { ui.add_space(4.0); ui.label( - RichText::new("The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3.") + RichText::new(tr("The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3.")) .color(t.text_secondary) .font(regular(11.0)), ); } ui.add_space(12.0); ui.horizontal(|ui| { - if crate::widgets::button(ui, "Keep appearance", true, true).clicked() { + if crate::widgets::button(ui, tr("Keep appearance"), true, true).clicked() { apply = Some(true); } - if crate::widgets::button(ui, "Keep values", false, true).clicked() { + if crate::widgets::button(ui, tr("Keep values"), false, true).clicked() { apply = Some(false); } }); @@ -311,23 +341,23 @@ fn export_dialog(app: &mut FigApp, ctx: &Context) { preset(1.0, ExportFormat::Pdf), ]; let mut choice = None; - window("Export", ctx).open(&mut open).show(ctx, |ui| { + window(tr("Export"), ctx).open(&mut open).show(ctx, |ui| { ui.set_width(320.0); let caption = |ui: &mut egui::Ui, s: &str| { ui.label(RichText::new(s).font(regular(11.0)).color(t.text_secondary)); }; if !jobs.is_empty() { - caption(ui, "With the layers' export settings"); + caption(ui, tr("With the layers' export settings")); const SHOWN: usize = 6; for j in jobs.iter().take(SHOWN) { ui.label(RichText::new(&j.file).font(regular(12.0)).color(t.text)); } if jobs.len() > SHOWN { - caption(ui, &format!("and {} more", jobs.len() - SHOWN)); + caption(ui, &trn("and {n} more", jobs.len() - SHOWN, &[])); } ui.add_space(6.0); let layers = jobs.iter().map(|j| j.id).collect::>().len(); - let label = format!("Export {layers} layer{}", if layers == 1 { "" } else { "s" }); + let label = trn("Export {n} layers", layers, &[]); if crate::widgets::button(ui, &label, true, true).clicked() { choice = Some(ExportChoice::Layers); } @@ -336,7 +366,7 @@ fn export_dialog(app: &mut FigApp, ctx: &Context) { ui.painter().line_segment([r.left_center(), r.right_center()], Stroke::new(1.0, t.border)); ui.add_space(10.0); } - caption(ui, &format!("{} layer{} as one file", ids.len(), if ids.len() == 1 { "" } else { "s" })); + caption(ui, &trn("{n} layers as one file", ids.len(), &[])); ui.add_space(4.0); for p in &presets { let label = if p.format.is_raster() { format!("{} @{}", p.format.label(), p.size.label()) } else { p.format.label().to_string() }; @@ -351,7 +381,7 @@ fn export_dialog(app: &mut FigApp, ctx: &Context) { } // The PDF preset's print options. ui.add_space(6.0); - caption(ui, "PDF print options"); + caption(ui, tr("PDF print options")); if let Some(next) = crate::export_panel::print_options(ui, egui::Id::new("export_dialog_print"), &print) { app.ui.pdf_print = next; } @@ -360,7 +390,7 @@ fn export_dialog(app: &mut FigApp, ctx: &Context) { match choice { Some(ExportChoice::Layers) => export_layers(app, &with_settings), Some(ExportChoice::Once(s)) if !ids.is_empty() => save_one(app, &ids, &s, &suggested_name(&doc, &ids, &s, "Export")), - Some(ExportChoice::Once(_)) => app.toast("Nothing to export"), + Some(ExportChoice::Once(_)) => app.toast(tr("Nothing to export")), None => {} } if close { diff --git a/crates/ui-egui/src/export_panel.rs b/crates/ui-egui/src/export_panel.rs index 1ef9d2b..fad2cf9 100644 --- a/crates/ui-egui/src/export_panel.rs +++ b/crates/ui-egui/src/export_panel.rs @@ -11,6 +11,7 @@ use figcraft_engine::cmd::file::export_jobs; use serde_json::{Value, json}; use crate::FigApp; +use crate::i18n::{tr, trf, trn}; use crate::icons::{self, Icon}; use crate::right::{common, menu_item, menu_popup, row}; use crate::theme::{Tokens, regular}; @@ -28,8 +29,8 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let shared = common(doc, sel, |n| n.exports.clone()); let mixed = shared.is_none() && sel.len() > 1; let list = shared.unwrap_or_default(); - let tip = if mixed { "Replace mixed export settings" } else { "Add export setting" }; - if widgets::section_header(ui, "Export", &[(Icon::Plus, tip)], list.is_empty()).is_some() { + let tip = if mixed { tr("Replace mixed export settings") } else { tr("Add export setting") }; + if widgets::section_header(ui, tr("Export"), &[(Icon::Plus, tip)], list.is_empty()).is_some() { if mixed { app.set_props(&ids, json!({"exports": [ExportSetting::default()]})); } else { @@ -37,7 +38,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { } } if mixed { - row(ui, 28.0, |ui| widgets::label(ui, "Click + to replace mixed content", t.text_secondary)); + row(ui, 28.0, |ui| widgets::label(ui, tr("Click + to replace mixed content"), t.text_secondary)); } for (i, e) in list.iter().enumerate() { setting_row(app, ui, &ids, i, e); @@ -51,8 +52,8 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { } let layers: BTreeSet = jobs.iter().map(|j| j.id).collect(); let label = match (layers.len(), layers.first().and_then(|i| doc.node(*i))) { - (1, Some(n)) => format!("Export {}", n.name), - (k, _) => format!("Export {k} layers"), + (1, Some(n)) => trf("Export {name}", &[("name", &n.name)]), + (k, _) => trn("Export {n} layers", k, &[]), }; if wide_button(ui, &label) { export_layers(app, sel); @@ -72,7 +73,7 @@ fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Expo { app.run("export.update", json!({"ids": ids, "index": i, "size": s})); } - if let Some(sfx) = widgets::text_field(ui, Id::new(("export_suffix", i)), &e.suffix, suffix_w, "Suffix") + if let Some(sfx) = widgets::text_field(ui, Id::new(("export_suffix", i)), &e.suffix, suffix_w, tr("Suffix")) && sfx != e.suffix { app.run("export.update", json!({"ids": ids, "index": i, "suffix": sfx})); @@ -86,7 +87,7 @@ fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Expo } } }); - if icons::button(ui, Icon::Minus, 24.0, false, "Remove export setting").clicked() { + if icons::button(ui, Icon::Minus, 24.0, false, tr("Remove export setting")).clicked() { app.run("export.remove", json!({"ids": ids, "index": i})); } }); @@ -108,7 +109,7 @@ fn print_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &PrintS let label = ui.painter().layout(print_summary(p), regular(11.0), t.text_secondary, r.width() - 32.0); ui.painter().galley(pos2(r.min.x + 8.0, r.center().y - label.size().y / 2.0), label, t.text_secondary); icons::paint(ui.painter(), Rect::from_center_size(pos2(r.max.x - 12.0, r.center().y), vec2(14.0, 14.0)), Icon::Dots, t.icon_secondary); - egui::Popup::from_toggle_button_response(&resp.on_hover_text("PDF print settings")) + egui::Popup::from_toggle_button_response(&resp.on_hover_text(tr("PDF print settings"))) .id(open_id) .width(216.0) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) @@ -127,17 +128,17 @@ fn print_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &PrintS fn print_summary(p: &PrintSettings) -> String { let mut parts = Vec::new(); if p.bleed() > 0.0 { - parts.push(format!("{} bleed", PrintSettings::length_label(p.bleed()))); + parts.push(trf("{length} bleed", &[("length", &PrintSettings::length_label(p.bleed()))])); } if p.crop_marks { - parts.push("crop marks".to_string()); + parts.push(tr("crop marks").to_string()); } if p.bleed_marks && p.bleed() > 0.0 { - parts.push("bleed marks".to_string()); + parts.push(tr("bleed marks").to_string()); } parts.push(if p.cmyk { "CMYK" } else { "RGB" }.to_string()); if p.bleed() <= 0.0 && !p.marks() { - parts.push("no bleed".to_string()); + parts.push(tr("no bleed").to_string()); } let s = parts.join(", "); let mut c = s.chars(); @@ -150,7 +151,7 @@ pub(crate) fn print_options(ui: &mut Ui, id: Id, p: &PrintSettings) -> Option Option {} [one] => save_one(app, &[one.id], &one.setting, &one.file), _ => { - let Some(dir) = app.services.pick_folder("Export") else { return }; + let Some(dir) = app.services.pick_folder(tr("Export")) else { return }; if let Some(r) = app.run("file.exportLayers", json!({"ids": ids, "dir": dir.to_string_lossy()})) { let n = r["files"].as_array().map_or(0, Vec::len); - app.toast(format!("Exported {n} file{}", if n == 1 { "" } else { "s" })); + app.toast(trn("Exported {n} files", n, &[])); } } } @@ -289,10 +290,10 @@ pub(crate) fn export_layers(app: &mut FigApp, ids: &[NodeId]) { /// Export `ids` together as one file with `setting`, through a save dialog suggesting `file`. pub(crate) fn save_one(app: &mut FigApp, ids: &[NodeId], setting: &ExportSetting, file: &str) { let ext = setting.format.ext(); - let Some(p) = app.services.pick_save("Export", file, &[ext]) else { return }; + let Some(p) = app.services.pick_save(tr("Export"), file, &[ext]) else { return }; let path = p.to_string_lossy().to_string(); if app.run("file.export", with_print(json!({"ids": ids, "format": ext, "size": setting.size, "path": path}), setting)).is_some() { - app.toast(format!("Exported {}", p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())); + app.toast(trf("Exported {name}", &[("name", &p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())])); } } @@ -305,11 +306,11 @@ pub(crate) fn suggested_name(doc: &Document, ids: &[NodeId], setting: &ExportSet /// The page's Export section: the whole page as one PNG. pub fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { - widgets::section_header(ui, "Export", &[], false); - if wide_button(ui, "Export page as PNG") { + widgets::section_header(ui, tr("Export"), &[], false); + if wide_button(ui, tr("Export page as PNG")) { let all: Vec = doc.node(page).map(|p| p.children.clone()).unwrap_or_default(); let setting = ExportSetting::default(); - let page_name = doc.node(page).map_or("Page", |p| p.name.as_str()); + let page_name = doc.node(page).map_or(tr("Page"), |p| p.name.as_str()); let file = format!("{page_name}.{}", setting.format.ext()); save_one(app, &all, &setting, &file); } diff --git a/crates/ui-egui/src/frame_labels.rs b/crates/ui-egui/src/frame_labels.rs index a57d95c..2718fdd 100644 --- a/crates/ui-egui/src/frame_labels.rs +++ b/crates/ui-egui/src/frame_labels.rs @@ -11,6 +11,7 @@ use figcraft_geom::Point; use crate::FigApp; use crate::canvas::status_badge; +use crate::i18n::tr; use crate::theme::{ACCENT, COMPONENT, Tokens, regular, semibold}; /// Where a label goes on screen: the corner it starts at, the angle it reads along (radians, @@ -143,7 +144,7 @@ pub fn draw(app: &FigApp, painter: &Painter, t: &Tokens, doc: &Document, id: Nod let height = (p01 - p00).length(); if edge.len > 120.0 && height > 60.0 { let center = at(n.width / 2.0, n.height / 2.0); - painter.text(center, Align2::CENTER_CENTER, "Contents hidden", regular(11.0), t.text_tertiary); + painter.text(center, Align2::CENTER_CENTER, tr("Contents hidden"), regular(11.0), t.text_tertiary); } } let rot = Rot2::from_angle(edge.angle); diff --git a/crates/ui-egui/src/frame_presets.rs b/crates/ui-egui/src/frame_presets.rs index eb92441..51662d3 100644 --- a/crates/ui-egui/src/frame_presets.rs +++ b/crates/ui-egui/src/frame_presets.rs @@ -5,13 +5,14 @@ use egui::{Align2, CornerRadius, Id, Sense, Ui, pos2, vec2}; use serde_json::json; use crate::FigApp; +use crate::i18n::tr; use crate::icons::{self, Icon}; use crate::theme::{Tokens, regular, semibold}; use crate::widgets::{self, PAD}; pub fn panel(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); - widgets::section_header(ui, "Frame", &[], false); + widgets::section_header(ui, tr("Frame"), &[], false); let w = ui.available_width(); let mut cats: Vec<&str> = Vec::new(); for (c, ..) in figcraft_engine::cmd::create::FRAME_PRESETS { @@ -34,7 +35,7 @@ pub fn panel(app: &mut FigApp, ui: &mut Ui) { if open { Icon::Chevron } else { Icon::ChevronRight }, t.icon_secondary, ); - ui.painter().text(pos2(r.min.x + PAD + 16.0, r.center().y), Align2::LEFT_CENTER, cat, semibold(11.0), t.text); + ui.painter().text(pos2(r.min.x + PAD + 16.0, r.center().y), Align2::LEFT_CENTER, tr(cat), semibold(11.0), t.text); if resp.clicked() { ui.data_mut(|d| d.insert_temp(open_id, !open)); } diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 73d0f61..7701488 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -4,6 +4,7 @@ use egui::{Align2, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2 use serde_json::json; use crate::FigApp; +use crate::i18n::{tr, tr_noop}; use crate::icons::{self, Icon}; use crate::theme::{ACCENT, Tokens, regular, semibold}; @@ -18,25 +19,26 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { 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; - for (icon, label) in [(Icon::Home, "Recents"), (Icon::Page, "Drafts"), (Icon::Assets, "Community")] { + for (icon, label) in [(Icon::Home, tr_noop("Recents")), (Icon::Page, tr_noop("Drafts")), (Icon::Assets, tr_noop("Community"))] { let r = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(224.0, 32.0)); if label == "Recents" { ui.painter().rect_filled(r, CornerRadius::same(5), t.selected_row); } icons::paint(ui.painter(), Rect::from_min_size(pos2(r.min.x + 10.0, r.center().y - 8.0), vec2(16.0, 16.0)), icon, t.icon); - ui.painter().text(pos2(r.min.x + 36.0, r.center().y), Align2::LEFT_CENTER, label, regular(12.0), t.text); + ui.painter().text(pos2(r.min.x + 36.0, r.center().y), Align2::LEFT_CENTER, tr(label), regular(12.0), t.text); y += 34.0; } let main = Rect::from_min_max(pos2(left.max.x + 40.0, rect.min.y + 24.0), rect.max - vec2(40.0, 24.0)); - ui.painter().text(main.min, Align2::LEFT_TOP, "Recents", semibold(18.0), t.text); + ui.painter().text(main.min, Align2::LEFT_TOP, tr("Recents"), semibold(18.0), t.text); // Action cards. let mut x = main.min.x; let cy = main.min.y + 48.0; let mut new_file = false; let mut open = false; - for (icon, title, sub) in - [(Icon::Plus, "New design file", "Start from a blank canvas"), (Icon::Page, "Open file", "Open .figcraft, .svg or images")] - { + for (icon, title, sub) in [ + (Icon::Plus, tr_noop("New design file"), tr_noop("Start from a blank canvas")), + (Icon::Page, tr_noop("Open file"), tr_noop("Open .figcraft, .svg or images")), + ] { let r = Rect::from_min_size(pos2(x, cy), vec2(260.0, 72.0)); let resp = ui.interact(r, ui.id().with(("card", title)), Sense::click()); ui.painter().rect_filled(r, CornerRadius::same(8), if resp.hovered() { t.hover } else { t.panel }); @@ -44,8 +46,8 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { 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, egui::Color32::WHITE); - ui.painter().text(pos2(r.min.x + 60.0, r.center().y - 8.0), Align2::LEFT_CENTER, title, semibold(12.0), t.text); - ui.painter().text(pos2(r.min.x + 60.0, r.center().y + 9.0), Align2::LEFT_CENTER, sub, regular(11.0), t.text_secondary); + ui.painter().text(pos2(r.min.x + 60.0, r.center().y - 8.0), Align2::LEFT_CENTER, tr(title), semibold(12.0), t.text); + ui.painter().text(pos2(r.min.x + 60.0, r.center().y + 9.0), Align2::LEFT_CENTER, tr(sub), regular(11.0), t.text_secondary); if resp.clicked() { if title.starts_with("New") { new_file = true; @@ -64,7 +66,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { } // Open documents and recent files. let mut y = cy + 108.0; - ui.painter().text(pos2(main.min.x, y), Align2::LEFT_TOP, "Open files", semibold(12.0), t.text_secondary); + ui.painter().text(pos2(main.min.x, y), Align2::LEFT_TOP, tr("Open files"), semibold(12.0), t.text_secondary); y += 28.0; let mut switch = None; for (i, d) in app.session.docs.iter().enumerate() { @@ -90,7 +92,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { y += 16.0; let recents = app.session.prefs.recent_files.clone(); if !recents.is_empty() { - ui.painter().text(pos2(main.min.x, y), Align2::LEFT_TOP, "Recent files", semibold(12.0), t.text_secondary); + ui.painter().text(pos2(main.min.x, y), Align2::LEFT_TOP, tr("Recent files"), semibold(12.0), t.text_secondary); y += 28.0; let mut open_path = None; for p in recents.iter().take(12) { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs new file mode 100644 index 0000000..f72bae5 --- /dev/null +++ b/crates/ui-egui/src/i18n.rs @@ -0,0 +1,166 @@ +//! The UI language: lookups over the string catalogs in `crates/ui-egui/locales/` (one TOML file +//! per language, baked in by build.rs; see docs/translating.md). +//! +//! Keys are the English text as written in the code: `tr("Fill")`, +//! `trf("Delete {name}", &[("name", &name)])`, `trn("{n} layers", count)`. A key a catalog lacks +//! shows its English text, so the UI never shows a raw key. Command labels are looked up by command +//! id ([`command`]): ids, the CLI, MCP and the control channel stay English. + +use std::borrow::Cow; +use std::fmt::Display; +use std::sync::atomic::{AtomicUsize, Ordering}; + +/// One catalog entry. +pub enum Text { + Plain(&'static str), + /// Plural forms: `one` (when the language has it) and `other`. + Plural(Option<&'static str>, &'static str), +} + +/// A language's catalog (generated from `locales/.toml`). +pub struct Lang { + pub code: &'static str, + /// The language's own name for itself (the language picker shows it untranslated). + pub name: &'static str, + /// Machine-drafted, waiting for a native speaker's review (`[meta] status`). + pub draft: bool, + /// UI strings by English text, sorted. + pub ui: &'static [(&'static str, Text)], + /// Command labels by command id, sorted. + pub commands: &'static [(&'static str, Text)], + /// Main-menu labels by command id where the menu words it differently ("Fill" in Select + /// matching for "Select same fill"), sorted. + pub menu: &'static [(&'static str, Text)], +} + +include!(concat!(env!("OUT_DIR"), "/locales.rs")); + +/// Index into [`LANGS`] of the language the UI shows (0: English). +static CURRENT: AtomicUsize = AtomicUsize::new(0); + +/// The languages the UI can show, English first. +pub fn languages() -> &'static [Lang] { + LANGS +} + +/// The language the UI shows. +pub fn current() -> Option<&'static Lang> { + LANGS.get(CURRENT.load(Ordering::Relaxed)) +} + +/// Show the language of `pref` (a language code, or "system" for `system`, the OS or browser +/// language). Unknown languages show English. +pub fn set(pref: &str, system: Option<&str>) { + CURRENT.store(resolve(pref, system), Ordering::Relaxed); +} + +/// The [`LANGS`] index for a preference: the exact tag ("pt-BR"), else its base language ("es" +/// for "es_ES.UTF-8"), else English. +fn resolve(pref: &str, system: Option<&str>) -> usize { + let want = if pref.is_empty() || pref == "system" { system.unwrap_or("en") } else { pref }; + let tag = want.split(['.', '@']).next().unwrap_or("").replace('_', "-"); + let base = tag.split('-').next().unwrap_or(""); + let find = |t: &str| LANGS.iter().position(|l| l.code.eq_ignore_ascii_case(t)); + find(&tag).or_else(|| find(base)).unwrap_or(0) +} + +fn find(table: &'static [(&'static str, Text)], key: &str) -> Option<&'static Text> { + table.binary_search_by(|(k, _)| (*k).cmp(key)).ok().and_then(|i| table.get(i)).map(|(_, t)| t) +} + +/// `key`'s entry in language `lang`, else in English. +fn lookup(lang: usize, key: &str) -> Option<&'static Text> { + [lang, 0].iter().filter_map(|i| LANGS.get(*i)).find_map(|l| find(l.ui, key)) +} + +/// The UI string for `key` (its English text) in the current language. One English text that needs +/// two translations gets a context after `|` ("Fill|sizing"); the English catalog maps it back. +pub fn tr(key: &str) -> &str { + match lookup(CURRENT.load(Ordering::Relaxed), key) { + Some(Text::Plain(s) | Text::Plural(_, s)) => s, + None => key, + } +} + +/// Mark a key for the catalog where the lookup happens later (`tr(label)` at display time), e.g. in +/// a `const` table. +pub const fn tr_noop(key: &str) -> &str { + key +} + +/// [`tr`] with `{name}` placeholders filled from `args`. +pub fn trf(key: &str, args: &[(&str, &dyn Display)]) -> String { + fill(tr(key), args) +} + +/// The plural form of `key` (its English "other" form) for count `n`, with `{n}` and `args` filled. +pub fn trn(key: &str, n: usize, args: &[(&str, &dyn Display)]) -> String { + let lang = CURRENT.load(Ordering::Relaxed); + let text = match lookup(lang, key) { + Some(Text::Plural(one, other)) => one.filter(|_| plural_one(lang, n)).unwrap_or(other), + Some(Text::Plain(s)) => s, + None => key, + }; + let mut all: Vec<(&str, &dyn Display)> = vec![("n", &n)]; + all.extend_from_slice(args); + fill(text, &all) +} + +/// Whether count `n` takes the "one" form. +// shortcut: n == 1 is CLDR's rule for English and Spanish (Spanish "many" for millions reads as +// "other" here); add per-language rules when a language needs them (French: 0 and 1). +fn plural_one(_lang: usize, n: usize) -> bool { + n == 1 +} + +/// Replace each `{name}` in `text` with its argument, in one pass (an argument's own braces stay). +fn fill(text: &str, args: &[(&str, &dyn Display)]) -> String { + let mut out = String::with_capacity(text.len() + 16); + let mut rest = text; + while let Some(open) = rest.find('{') { + let (before, tail) = rest.split_at(open); + out.push_str(before); + let arg = tail.find('}').and_then(|close| { + let name = tail.get(1..close)?; + args.iter().find(|(k, _)| *k == name).map(|(_, v)| (close, v)) + }); + match arg { + Some((close, v)) => { + out.push_str(&v.to_string()); + rest = tail.get(close + 1..).unwrap_or(""); + } + None => { + out.push('{'); + rest = tail.get(1..).unwrap_or(""); + } + } + } + out.push_str(rest); + out +} + +/// A command's label in the current language: the catalog's by command id, else `english` (the +/// engine's label). +pub fn command<'a>(id: &str, english: &'a str) -> Cow<'a, str> { + label(false, id, english) +} + +/// A main-menu item's label: the catalog's menu wording for the id, else the command's. An English +/// label ending in "…" (opens a dialog) keeps it. +pub fn menu_item<'a>(id: &str, english: &'a str) -> Cow<'a, str> { + label(true, id, english) +} + +fn label<'a>(menu: bool, id: &str, english: &'a str) -> Cow<'a, str> { + let lang = LANGS.get(CURRENT.load(Ordering::Relaxed)); + let text = lang.and_then(|l| menu.then(|| find(l.menu, id)).flatten().or_else(|| find(l.commands, id))); + match text { + Some(Text::Plain(s) | Text::Plural(_, s)) if english.ends_with('…') && !s.ends_with('…') => Cow::Owned(format!("{s}…")), + Some(Text::Plain(s) | Text::Plural(_, s)) => Cow::Borrowed(s), + None => Cow::Borrowed(english), + } +} + +#[cfg(test)] +#[path = "i18n_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/i18n_tests.rs b/crates/ui-egui/src/i18n_tests.rs new file mode 100644 index 0000000..66753d9 --- /dev/null +++ b/crates/ui-egui/src/i18n_tests.rs @@ -0,0 +1,320 @@ +//! The catalog checks: every key the code looks up is in the English catalog, no catalog has keys +//! the code doesn't use, placeholders match English, and complete languages translate everything. + +use std::collections::BTreeSet; + +use super::*; + +/// Languages that must translate every UI string and command label. +const COMPLETE: &[&str] = &["es"]; + +/// Keys looked up from data rather than written as `tr("…")` in the code: the main menu's +/// submenu names (the engine's menu catalog) and labels of engine and document enums. +fn dynamic_keys() -> BTreeSet { + let mut keys: BTreeSet = figcraft_engine::catalog::MENU.iter().flat_map(|m| m.menu.split('/')).map(str::to_string).collect(); + keys.extend(dynamic_labels().into_iter().map(str::to_string)); + keys +} + +/// Labels from engine and document data the UI shows through `tr(x.label())` and isn't written as +/// a literal anywhere in the UI: node kinds (and the Design panel header's plurals), blend modes, +/// stroke caps and width profiles, effects, brushes, image filters, frame preset categories, Dev Mode +/// properties, comment filters and sorts, change kinds, the live status. +// shortcut: a hand-kept list; enumerate each enum's `ALL` here when a label goes missing in review. +const DATA_LABELS: &[&str] = &[ + "Added", + "Align", + "All comments", + "Alpha", + "Background blur", + "Black & white", + "Bristle", + "Circle", + "Color burn", + "Color dodge", + "Component", + "Component set", + "Component sets", + "Confetti", + "Content", + "Cool", + "Darken", + "Desktop", + "Difference", + "Direction", + "Dots", + "Drop shadow", + "Duo", + "Ellipses", + "Exclude", + "Excludes", + "Exclusion", + "Eye", + "Fade", + "Fills", + "Frames", + "Glass", + "Group", + "Groups", + "Hard light", + "Hue", + "Ink", + "Inner shadow", + "Instances", + "Intersect", + "Intersects", + "Joining…", + "Layer blur", + "Leaves", + "Lighten", + "Line arrow", + "Lines", + "Luminance", + "Luminosity", + "Mono", + "Multi", + "Multiply", + "Muted", + "No status", + "Noise", + "Normal", + "Only your threads", + "Padding", + "Paper", + "Pass through", + "Phone", + "Pinch", + "Plus darker", + "Plus lighter", + "Polygons", + "Presentation", + "Progressive", + "Rectangles", + "Removed", + "Reversed triangle", + "Ribbon", + "Rounded", + "Screen", + "Sections", + "Slices", + "Social media", + "Soft light", + "Sort by date", + "Sort by page", + "Square", + "Stars", + "Strokes", + "Style", + "Subtract", + "Subtracts", + "Tablet", + "Taper", + "Taper both ends", + "Text align", + "Texts", + "Texture", + "Triangle arrow", + "Uniform", + "Union", + "Unions", + "Unread", + "Vectors", + "Vivid", + "Warm", + "Watch", + "Wedge", + "Weight", +]; + +/// Labels of engine enums the UI shows through `tr(x.label())`, read from the engine, plus [`DATA_LABELS`]. +fn dynamic_labels() -> Vec<&'static str> { + use figcraft_engine::keymap::{GESTURES, Group}; + let mut v: Vec<&'static str> = figcraft_engine::ToolId::ALL.iter().map(|t| t.label()).collect(); + v.extend(figcraft_engine::vectoredit::VTool::ALL.iter().map(|t| t.label())); + v.extend(Group::ALL.iter().map(|g| g.label())); + v.extend(GESTURES.iter().flat_map(|(_, label, mac, other)| [*label, *mac, *other])); + v.extend(DATA_LABELS); + v +} + +/// Ids of main-menu items whose label isn't the command's (besides a closing "…"). +fn menu_worded_differently() -> BTreeSet<&'static str> { + let bare = |s: &'static str| s.trim_end_matches('…'); + figcraft_engine::catalog::MENU + .iter() + .filter(|m| figcraft_engine::find_command(m.id).is_some_and(|c| bare(c.label) != bare(m.label))) + .map(|m| m.id) + .collect() +} + +/// The string literals passed to `tr`, `trf`, `trn` and `tr_noop` in this crate's sources. +fn used_keys() -> BTreeSet { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut keys = BTreeSet::new(); + for entry in std::fs::read_dir(dir).unwrap().flatten() { + let path = entry.path(); + let name = path.file_name().unwrap().to_string_lossy().into_owned(); + if !name.ends_with(".rs") || name.ends_with("_tests.rs") || name == "i18n.rs" { + continue; + } + keys.extend(literals(&std::fs::read_to_string(&path).unwrap())); + } + keys +} + +/// The first-argument string literals of the lookup functions in `src`. +fn literals(src: &str) -> Vec { + let mut out = Vec::new(); + for f in ["tr(", "trf(", "trn(", "tr_noop("] { + let mut from = 0; + while let Some(i) = src.get(from..).and_then(|s| s.find(f)) { + let at = from + i; + from = at + f.len(); + let prev = src.get(..at).and_then(|s| s.chars().next_back()); + if prev.is_some_and(|c| c.is_alphanumeric() || c == '_') { + continue; + } + let rest = src.get(from..).unwrap().trim_start(); + if let Some(lit) = rest.strip_prefix('"').and_then(string_literal) { + out.push(lit); + } + } + } + out +} + +/// A Rust string literal's value (after its opening quote), up to the closing quote. +fn string_literal(s: &str) -> Option { + let mut out = String::new(); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + match c { + '"' => return Some(out), + '\\' => match chars.next()? { + 'n' => out.push('\n'), + 't' => out.push('\t'), + '\n' => { + while chars.peek().is_some_and(|c| c.is_whitespace()) { + chars.next(); + } + } + c => out.push(c), + }, + c => out.push(c), + } + } + None +} + +fn placeholders(s: &str) -> BTreeSet<&str> { + s.split('{').skip(1).filter_map(|p| p.split_once('}').map(|(name, _)| name)).collect() +} + +fn forms(t: &Text) -> Vec<&'static str> { + match t { + Text::Plain(s) => vec![s], + Text::Plural(one, other) => one.iter().copied().chain([*other]).collect(), + } +} + +fn english() -> &'static Lang { + LANGS.first().filter(|l| l.code == "en").expect("locales/en.toml is baked in first") +} + +#[test] +fn every_key_in_the_code_is_in_the_english_catalog_and_no_other() { + let used: BTreeSet = used_keys().into_iter().chain(dynamic_keys()).collect(); + let en: BTreeSet = english().ui.iter().map(|(k, _)| k.to_string()).collect(); + let missing: Vec<_> = used.difference(&en).collect(); + let unused: Vec<_> = en.difference(&used).collect(); + assert!( + missing.is_empty(), + "keys missing from locales/en.toml:\n{}", + missing.iter().map(|k| format!("{k:?} = {k:?}")).collect::>().join("\n") + ); + assert!(unused.is_empty(), "keys in locales/en.toml the code doesn't use: {unused:?}"); +} + +#[test] +fn english_text_is_its_key_except_plurals() { + for (k, t) in english().ui { + if let Text::Plain(s) = t { + // "Fill|sizing": the same English as "Fill", translated apart (a context after `|`). + assert_eq!(k.split('|').next(), Some(*s), "an English entry differs from its key (the key is the English text)"); + } + } + assert!(english().commands.is_empty() && english().menu.is_empty(), "English command labels come from the engine"); +} + +#[test] +fn catalogs_have_known_keys_and_matching_placeholders() { + let en = english(); + let ids: BTreeSet<&str> = figcraft_engine::all_commands() + .iter() + .map(|c| c.id) + .chain(figcraft_engine::catalog::MENU.iter().map(|m| m.id).filter(|id| !id.is_empty())) + .collect(); + for lang in LANGS { + assert!(!lang.name.is_empty(), "{}: [meta] name is missing", lang.code); + for (k, t) in lang.ui { + let src = find(en.ui, k).unwrap_or_else(|| panic!("{}: unknown key {k:?}", lang.code)); + let want = placeholders(forms(src).last().unwrap()); + for (i, form) in forms(t).iter().enumerate() { + let got = placeholders(form); + // A "one" form may spell the count out ("una capa"). + let ok = if matches!(t, Text::Plural(Some(_), _)) && i == 0 { got.is_subset(&want) } else { got == want }; + assert!(ok, "{}: {k:?} has placeholders {got:?}, English has {want:?}", lang.code); + } + assert_eq!(matches!(t, Text::Plural(..)), matches!(src, Text::Plural(..)), "{}: {k:?} plural or not as in English", lang.code); + } + for (id, t) in lang.commands.iter().chain(lang.menu) { + assert!(ids.contains(id), "{}: unknown command id {id:?}", lang.code); + assert!(matches!(t, Text::Plain(s) if !s.trim().is_empty()), "{}: {id:?} is empty or plural", lang.code); + } + for (id, _) in lang.menu { + assert!(menu_worded_differently().contains(id), "{}: [menu] {id:?} reads like the command; [commands] covers it", lang.code); + } + if COMPLETE.contains(&lang.code) { + let missing: Vec<_> = en.ui.iter().filter(|(k, _)| find(lang.ui, k).is_none()).map(|(k, _)| k).collect(); + assert!(missing.is_empty(), "{}: untranslated: {missing:?}", lang.code); + let missing: Vec<_> = ids.iter().filter(|id| find(lang.commands, id).is_none()).collect(); + assert!(missing.is_empty(), "{}: untranslated commands: {missing:?}", lang.code); + let missing: Vec<_> = menu_worded_differently().into_iter().filter(|id| find(lang.menu, id).is_none()).collect(); + assert!(missing.is_empty(), "{}: menu items worded unlike their command, missing from [menu]: {missing:?}", lang.code); + } + } +} + +#[test] +fn lookups_fall_back_to_english_and_fill_placeholders() { + assert_eq!(fill("Delete {name}?", &[("name", &"Frame {1}")]), "Delete Frame {1}?"); + assert_eq!(fill("{a} and {b", &[("a", &1)]), "1 and {b"); + assert!(lookup(0, "no such key").is_none()); + assert_eq!(resolve("system", Some("es_ES.UTF-8")), resolve("es", None)); + assert_eq!(resolve("system", None), 0); + assert_eq!(resolve("xx-YY", None), 0); + let es = resolve("es-419", None); + assert_ne!(es, 0, "Spanish is baked in"); + // A key Spanish lacks reads English; Spanish entries read Spanish. + let (k, t) = LANGS[es].ui.first().unwrap(); + assert!(matches!(lookup(es, k), Some(x) if std::ptr::eq(x, t))); +} + +#[test] +fn the_key_scanner_reads_rust_literals() { + let src = r#"x(tr("A \"b\"")); trf( + "C {n}", &[]); attr("no"); tr_noop("D\ + E"); tr(label);"#; + assert_eq!(literals(src), vec!["A \"b\"", "C {n}", "DE"]); +} + +#[test] +fn the_language_preference_takes_language_codes() { + let mut s = figcraft_engine::Session::new(); + assert_eq!(s.prefs.language, "system"); + s.execute("pref.language", &serde_json::json!({"language": "es-419"})).unwrap(); + assert_eq!(s.prefs.language, "es-419"); + for bad in ["", "../es", "es--ES", "abcdefghi"] { + assert!(s.execute("pref.language", &serde_json::json!({"language": bad})).is_err(), "{bad:?}"); + } +} diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index e728605..b3fbdb5 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -8,6 +8,7 @@ use figcraft_doc::{Document, NodeId, NodeKind}; use serde_json::json; use crate::a11y::{self, Region, RowInfo, Tip}; +use crate::i18n::{tr, trf, trn}; use crate::icons::{self, Icon}; use crate::theme::{ACCENT, COMPONENT, Tokens, regular, semibold}; use crate::{FigApp, LeftTab}; @@ -80,7 +81,7 @@ pub fn rail(app: &mut FigApp, ui: &mut Ui) { let mut y = r.min.y + 8.0; // Main menu (logo). let lr = Rect::from_center_size(pos2(r.center().x, y + 16.0), vec2(36.0, 32.0)); - let lresp = ui.interact(lr, ui.id().with("rail_menu"), Sense::click()).tip("Main menu"); + let lresp = ui.interact(lr, ui.id().with("rail_menu"), Sense::click()).tip(tr("Main menu")); if lresp.hovered() || app.ui.main_menu { ui.painter().rect_filled(lr, CornerRadius::same(6), t.hover); } @@ -94,7 +95,8 @@ pub fn rail(app: &mut FigApp, ui: &mut Ui) { y += 44.0; ui.painter().line_segment([pos2(r.min.x + 18.0, y), pos2(r.max.x - 18.0, y)], Stroke::new(1.0, t.border)); y += 12.0; - let items = [(Icon::Page, "File", Some(LeftTab::File)), (Icon::Assets, "Assets", Some(LeftTab::Assets)), (Icon::Styles, "Variables", None)]; + let items = + [(Icon::Page, tr("File"), Some(LeftTab::File)), (Icon::Assets, tr("Assets"), Some(LeftTab::Assets)), (Icon::Styles, tr("Variables"), None)]; for (icon, label, tab) in items { let ir = Rect::from_center_size(pos2(r.center().x, y + 14.0), vec2(36.0, 28.0)); let hit = Rect::from_min_max(pos2(r.min.x, y), pos2(r.max.x, y + 48.0)); @@ -141,17 +143,17 @@ pub fn left_panel(app: &mut FigApp, ui: &mut Ui) { // Header: file name (or "Assets") + drafts + collapse. let (head, _) = ui.allocate_exact_size(vec2(full.width(), 64.0), Sense::hover()); if app.ui.left_tab == LeftTab::Assets { - ui.painter().text(pos2(head.min.x + 16.0, head.min.y + 24.0), Align2::LEFT_CENTER, "Assets", semibold(13.0), t.text); + ui.painter().text(pos2(head.min.x + 16.0, head.min.y + 24.0), Align2::LEFT_CENTER, tr("Assets"), semibold(13.0), t.text); } else { let name = app.session.doc().map(|d| d.name.clone()).unwrap_or_default(); let nr = Rect::from_min_max(pos2(head.min.x + 14.0, head.min.y + 14.0), pos2(head.max.x - 48.0, head.min.y + 34.0)); if let Some(new) = crate::widgets::text_field_inline(ui, Id::new("filename"), &name, nr, semibold(13.0)) { app.run("file.rename", json!({"name": new})); } - ui.painter().text(pos2(head.min.x + 16.0, head.min.y + 46.0), Align2::LEFT_CENTER, "Drafts", regular(11.0), t.text_secondary); + ui.painter().text(pos2(head.min.x + 16.0, head.min.y + 46.0), Align2::LEFT_CENTER, tr("Drafts"), regular(11.0), t.text_secondary); } let hr = Rect::from_center_size(pos2(head.max.x - 24.0, head.min.y + 24.0), vec2(24.0, 24.0)); - let hresp = ui.interact(hr, ui.id().with("hideui"), Sense::click()).tip("Collapse panel"); + let hresp = ui.interact(hr, ui.id().with("hideui"), Sense::click()).tip(tr("Collapse panel")); if hresp.hovered() { ui.painter().rect_filled(hr, CornerRadius::same(5), t.hover); } @@ -174,13 +176,13 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { let w = ui.available_width(); let (hr, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); let tresp = ui.interact(Rect::from_min_size(hr.min, vec2(w - 40.0, 40.0)), ui.id().with("pages_toggle"), Sense::click()); - a11y::toggle(&tresp, "Pages", app.ui.pages_open); - ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, "Pages", semibold(11.0), t.text); + a11y::toggle(&tresp, tr("Pages"), app.ui.pages_open); + ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, tr("Pages"), semibold(11.0), t.text); if tresp.clicked() { app.ui.pages_open = !app.ui.pages_open; } let sr = Rect::from_center_size(pos2(hr.max.x - 50.0, hr.center().y), vec2(24.0, 24.0)); - let sresp = ui.interact(sr, ui.id().with("lsearch"), Sense::click()).tip(crate::shortcuts::tip("Find", "edit.findReplace")); + let sresp = ui.interact(sr, ui.id().with("lsearch"), Sense::click()).tip(crate::shortcuts::tip(tr("Find"), "edit.findReplace")); if sresp.hovered() { ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover); } @@ -189,7 +191,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { app.ui.find = Some((String::new(), String::new())); } 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("Add new page"); + let aresp = ui.interact(ar, ui.id().with("addpage"), Sense::click()).tip(tr("Add new page")); if aresp.hovered() { ui.painter().rect_filled(ar, CornerRadius::same(5), t.hover); } @@ -247,15 +249,15 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { ui.memory_mut(|m| m.request_focus(Id::new(("pagename", id)).with("te"))); } crate::right::context_popup(&resp).show(|ui| { - if ui.button("Rename page").clicked() { + if ui.button(tr("Rename page")).clicked() { app.ui.rename_page = Some((id, name.clone())); ui.close(); } - if ui.button("Duplicate page").clicked() { + if ui.button(tr("Duplicate page")).clicked() { app.run("page.duplicate", json!({"id": id})); ui.close(); } - if ui.button("Delete page").clicked() { + if ui.button(tr("Delete page")).clicked() { app.run("page.delete", json!({"id": id})); ui.close(); } @@ -268,7 +270,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { if let Some((page, name)) = move_to { let ids = dragged_layers(dragging, &sel); if app.run("object.moveToPage", json!({"ids": ids, "page": page})).is_some() { - app.toast(format!("Moved to {name}")); + app.toast(trf("Moved to {name}", &[("name", &name)])); } } if let Some(p) = select @@ -527,7 +529,7 @@ fn search_field(app: &mut FigApp, ui: &mut Ui, t: &Tokens, w: f32, id: Id) { .id(id) .frame(egui::Frame::NONE) .font(regular(11.0)) - .hint_text("Find layers") + .hint_text(tr("Find layers")) .vertical_align(egui::Align::Center) .desired_width(te_rect.width()), ); @@ -544,9 +546,9 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let w = ui.available_width(); let (hr, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); - ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, "Layers", semibold(11.0), t.text); + ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, tr("Layers"), semibold(11.0), t.text); let sr = Rect::from_center_size(pos2(hr.max.x - 22.0, hr.center().y), vec2(24.0, 24.0)); - let sresp = ui.interact(sr, ui.id().with("layer_search_btn"), Sense::click()).tip("Search layers"); + let sresp = ui.interact(sr, ui.id().with("layer_search_btn"), Sense::click()).tip(tr("Search layers")); if sresp.hovered() || app.ui.layer_search { ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover); } @@ -631,7 +633,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { ui.add_space(8.0); ui.horizontal(|ui| { ui.add_space(16.0); - let hint = if filter.is_empty() { "Add a frame (F) or a shape (R) to get started" } else { "No matching layers" }; + let hint = if filter.is_empty() { tr("Add a frame (F) or a shape (R) to get started") } else { tr("No matching layers") }; crate::widgets::label(ui, hint, t.text_secondary); }); } @@ -669,14 +671,14 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { a11y::prefer(&mut app.ui.nav, Region::Layers, resp.id); } let info = RowInfo { - kind: n.kind.label(), + kind: tr(n.kind.label()), depth: row.depth, selected, expanded: row.has_kids.then(|| app.ui.expanded.contains(&row.id)), hidden: !n.visible, locked: n.locked, }; - a11y::tree_item(&resp, if n.name.is_empty() { n.kind.label() } else { &n.name }, &info); + a11y::tree_item(&resp, if n.name.is_empty() { tr(n.kind.label()) } else { &n.name }, &info); let in_selected = !selected && doc.ancestors(row.id).iter().any(|a| sel.contains(a)); let is_comp = matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. }); let rr = r.shrink2(vec2(8.0, 0.0)); @@ -702,7 +704,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { let tri = Rect::from_center_size(pos2(x0 + 6.0, r.center().y), vec2(14.0, 14.0)); let tresp = ui.interact(tri, ui.id().with(("tri", row.id)), Sense::click()); let open = app.ui.expanded.contains(&row.id); - a11y::button(&tresp, if open { "Collapse" } else { "Expand" }); + a11y::button(&tresp, if open { tr("Collapse") } else { tr("Expand") }); icons::paint(ui.painter(), tri.shrink(1.0), if open { Icon::Chevron } else { Icon::ChevronRight }, t.icon_secondary); if tresp.clicked() { if ui.input(|i| i.modifiers.alt) { @@ -742,7 +744,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { } else { regular(11.0) }; - let name = if n.name.is_empty() { n.kind.label().to_string() } else { n.name.clone() }; + let name = if n.name.is_empty() { tr(n.kind.label()).to_string() } else { n.name.clone() }; // Room left of the lock and eye buttons. let max_w = (rr.max.x - 56.0 - name_x).max(20.0); crate::bidi::paint_name(ui.painter(), pos2(name_x, r.center().y), &name, font, col, max_w); @@ -754,7 +756,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { let lr = Rect::from_center_size(pos2(rr.max.x - 36.0, r.center().y), vec2(20.0, 20.0)); if show_eye { let eresp = ui.interact(er, ui.id().with(("eye", row.id)), Sense::click()); - a11y::toggle(&eresp, "Hidden", !n.visible); + a11y::toggle(&eresp, tr("Hidden"), !n.visible); icons::paint(ui.painter(), er.shrink(3.0), if n.visible { Icon::Eye } else { Icon::EyeOff }, t.icon_secondary); if eresp.clicked() { vis = Some(row.id); @@ -762,7 +764,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { } if show_lock { let lresp = ui.interact(lr, ui.id().with(("lock", row.id)), Sense::click()); - a11y::toggle(&lresp, "Locked", n.locked); + a11y::toggle(&lresp, tr("Locked"), n.locked); icons::paint(ui.painter(), lr.shrink(3.0), if n.locked { Icon::Lock } else { Icon::Unlock }, t.icon_secondary); if lresp.clicked() { lock = Some(row.id); @@ -851,7 +853,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { if let (Some(dg), Some(p)) = (dragging, ui.input(|i| i.pointer.hover_pos())) { let label = match moving.len() { 1 => doc.node(dg).map(|n| n.name.clone()).unwrap_or_default(), - n => format!("{n} layers"), + n => trn("{n} layers", n, &[]), }; let galley = crate::bidi::name_galley(ui.painter(), &label, regular(11.0), t.text, 240.0); crate::widgets::drag_ghost(ui.ctx(), p, galley); diff --git a/crates/ui-egui/src/layout_panel.rs b/crates/ui-egui/src/layout_panel.rs index 23a7897..18721c0 100644 --- a/crates/ui-egui/src/layout_panel.rs +++ b/crates/ui-egui/src/layout_panel.rs @@ -9,6 +9,7 @@ use serde_json::json; use crate::FigApp; use crate::a11y::Tip; +use crate::i18n::{tr, tr_noop}; use crate::icons::{self, Icon}; use crate::right::{block, common, menu_item, menu_popup, row}; use crate::theme::{ACCENT, Tokens, regular}; @@ -26,11 +27,11 @@ pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node let frames = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like())); let al = common(doc, sel, |n| n.layout.clone()); let has_al = matches!(al, Some(Some(_))); - let title = if has_al { "Auto layout" } else { "Layout" }; + let title = if has_al { tr("Auto layout") } else { tr("Layout") }; let btns: Vec<(Icon, &str)> = if frames && !has_al { - vec![(Icon::Plus, crate::shortcuts::tip("Add auto layout", "layout.add"))] + vec![(Icon::Plus, crate::shortcuts::tip(tr("Add auto layout"), "layout.add"))] } else if has_al { - vec![(Icon::Minus, crate::shortcuts::tip("Remove auto layout", "layout.remove"))] + vec![(Icon::Minus, crate::shortcuts::tip(tr("Remove auto layout"), "layout.remove"))] } else { vec![] }; @@ -55,7 +56,7 @@ pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node if frames { let clip = common(doc, sel, |n| n.clip); row(ui, 32.0, |ui| { - if widgets::checkbox(ui, clip.unwrap_or(false), "Clip content") { + if widgets::checkbox(ui, clip.unwrap_or(false), tr("Clip content")) { app.set_props(&ids, json!({"clip": !clip.unwrap_or(false)})); } }); @@ -64,7 +65,8 @@ pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node fn direction(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { row(ui, 32.0, |ui| { - let items = [(Icon::LayoutV, "Vertical"), (Icon::LayoutH, "Horizontal"), (Icon::LayoutWrap, "Wrap"), (Icon::LayoutGrid, "Grid")]; + let items = + [(Icon::LayoutV, tr("Vertical")), (Icon::LayoutH, tr("Horizontal")), (Icon::LayoutWrap, tr("Wrap")), (Icon::LayoutGrid, tr("Grid"))]; let active = match (l.mode, l.wrap) { (LayoutMode::Vertical, _) => 0, (LayoutMode::Horizontal, false) => 1, @@ -83,18 +85,40 @@ fn direction(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { }); } -/// One axis' sizing, min/max keys and labels. +/// One axis' sizing, min/max keys and labels (the add-min and add-max menu items, the remove +/// button, the min and max field leads). struct Axis { sizing: &'static str, min: &'static str, max: &'static str, - noun: &'static str, - letter: &'static str, + add_min: &'static str, + add_max: &'static str, + remove: &'static str, + min_lead: &'static str, + max_lead: &'static str, } const AXES: [Axis; 2] = [ - Axis { sizing: "sizingH", min: "minWidth", max: "maxWidth", noun: "width", letter: "W" }, - Axis { sizing: "sizingV", min: "minHeight", max: "maxHeight", noun: "height", letter: "H" }, + Axis { + sizing: "sizingH", + min: "minWidth", + max: "maxWidth", + add_min: tr_noop("Add min width"), + add_max: tr_noop("Add max width"), + remove: tr_noop("Remove min and max width"), + min_lead: tr_noop("Min W"), + max_lead: tr_noop("Max W"), + }, + Axis { + sizing: "sizingV", + min: "minHeight", + max: "maxHeight", + add_min: tr_noop("Add min height"), + add_max: tr_noop("Add max height"), + remove: tr_noop("Remove min and max height"), + min_lead: tr_noop("Min H"), + max_lead: tr_noop("Max H"), + }, ]; /// A node's min (`is_min`) or max limit on one axis. @@ -113,14 +137,14 @@ fn dimensions(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], has let w = common(doc, sel, |n| (n.width * 100.0).round() / 100.0); let h = common(doc, sel, |n| (n.height * 100.0).round() / 100.0); let col = widgets::col_width(ui.available_width()); - widgets::sublabels(ui, "Dimensions", None); + widgets::sublabels(ui, tr("Dimensions"), None); row(ui, 32.0, |ui| { let e = crate::variable_ui::number_field(app, ui, &ids, "width", Id::new("lay_w"), Lead::Text("W"), w, "", col); app.field(&ids, "width", e, |v| json!(v.max(0.0))); let e = crate::variable_ui::number_field(app, ui, &ids, "height", Id::new("lay_h"), Lead::Text("H"), h, "", col); app.field(&ids, "height", e, |v| json!(v.max(0.0))); let locked = common(doc, sel, |n| n.constrain_proportions).unwrap_or(false); - if icons::button(ui, if locked { Icon::LinkOn } else { Icon::LinkOff }, 24.0, locked, "Constrain proportions").clicked() { + if icons::button(ui, if locked { Icon::LinkOn } else { Icon::LinkOff }, 24.0, locked, tr("Constrain proportions")).clicked() { app.set_props(&ids, json!({"constrainProportions": !locked})); } }); @@ -139,7 +163,8 @@ fn dimensions(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], has let cur = common(doc, sel, |n| if horizontal { n.child_layout.sizing_h } else { n.child_layout.sizing_v }); let any_limit = sel.iter().filter_map(|i| doc.node(*i)).any(|n| limit(n, horizontal, true).is_some() || limit(n, horizontal, false).is_some()); - let label = cur.map(|s| s.label()).unwrap_or("Mixed"); + // "Fill" here is the sizing (fill the container), not the paint. + let label = cur.map(|s| if s == Sizing::Fill { tr("Fill|sizing") } else { tr(s.label()) }).unwrap_or(tr("Mixed")); let icon = match cur { Some(Sizing::Hug) => Icon::Hug, Some(Sizing::Fill) => Icon::FillContainer, @@ -157,12 +182,12 @@ fn dimensions(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], has let current = first.map(|n| if horizontal { n.width } else { n.height }).unwrap_or(0.0); let ids2 = ids.clone(); menu_popup(&resp).width(200.0).show(|ui| { - let mut opts = vec![("Fixed", "fixed")]; + let mut opts = vec![(tr("Fixed"), "fixed")]; if has_al || is_text { - opts.push(("Hug contents", "hug")); + opts.push((tr("Hug contents"), "hug")); } if can_fill { - opts.push(("Fill container", "fill")); + opts.push((tr("Fill container"), "fill")); } for (l, v) in opts { if menu_item(ui, l, "") { @@ -173,15 +198,15 @@ fn dimensions(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], has if can_limit { ui.separator(); let (min, max) = (first.and_then(|n| limit(n, horizontal, true)), first.and_then(|n| limit(n, horizontal, false))); - if min.is_none() && menu_item(ui, &format!("Add min {}", axis.noun), "") { + if min.is_none() && menu_item(ui, tr(axis.add_min), "") { app.set_props(&ids2, json!({axis.min: current})); ui.close(); } - if max.is_none() && menu_item(ui, &format!("Add max {}", axis.noun), "") { + if max.is_none() && menu_item(ui, tr(axis.add_max), "") { app.set_props(&ids2, json!({axis.max: current})); ui.close(); } - if any_limit && menu_item(ui, "Remove min and max", "") { + if any_limit && menu_item(ui, tr("Remove min and max"), "") { app.set_props(&ids2, json!({axis.min: null, axis.max: null})); ui.close(); } @@ -208,26 +233,26 @@ fn limit_row(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], hori let current = nodes.first().map(|n| if horizontal { n.width } else { n.height }).unwrap_or(0.0); row(ui, 32.0, |ui| { for (is_min, key) in [(true, axis.min), (false, axis.max)] { - let lead = format!("{} {}", if is_min { "Min" } else { "Max" }, axis.letter); + let (lead, add) = if is_min { (tr(axis.min_lead), tr(axis.add_min)) } else { (tr(axis.max_lead), tr(axis.add_max)) }; if has(is_min) { let v = common(doc, sel, |n| limit(n, horizontal, is_min).map(|v| (v * 100.0).round() / 100.0)).flatten(); - let e = crate::variable_ui::number_field(app, ui, &ids, key, Id::new(("lay_limit", key)), Lead::Text(&lead), v, "", col); + let e = crate::variable_ui::number_field(app, ui, &ids, key, Id::new(("lay_limit", key)), Lead::Text(lead), v, "", col); app.field(&ids, key, e, |v| json!(v.max(0.0))); - } else if add_slot(ui, &lead, col).clicked() { + } else if add_slot(ui, lead, add, col).clicked() { app.set_props(&ids, json!({key: current})); } } - if icons::button(ui, Icon::Minus, 24.0, false, &format!("Remove min and max {}", axis.noun)).clicked() { + if icons::button(ui, Icon::Minus, 24.0, false, tr(axis.remove)).clicked() { app.set_props(&ids, json!({axis.min: null, axis.max: null})); } }); } -/// A quiet placeholder button the size of a field ("+ Max W"). -fn add_slot(ui: &mut Ui, label: &str, width: f32) -> egui::Response { +/// A quiet placeholder button the size of a field ("+ Max W") with the tooltip `tip`. +fn add_slot(ui: &mut Ui, label: &str, tip: &str, width: f32) -> egui::Response { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::click()); - let resp = resp.tip(format!("Add {}", label.to_lowercase())); + let resp = resp.tip(tip); let border = if resp.hovered() { t.field_hover_border } else { t.border }; ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(1.0, border), StrokeKind::Inside); icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(10.0, 10.0)), Icon::Plus, t.icon_secondary); @@ -286,7 +311,7 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { ids, "columnGap", Id::new("al_gap"), - Lead::Icon(Icon::Gap, "Column gap"), + Lead::Icon(Icon::Gap, tr("Column gap")), Some(l.gap), "", 96.0, @@ -299,7 +324,7 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { ids, "rowGap", Id::new("al_rowgap"), - Lead::Icon(Icon::DistributeV, "Row gap"), + Lead::Icon(Icon::DistributeV, tr("Row gap")), Some(l.cross_gap), "", 96.0, @@ -315,20 +340,20 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { ids, "gap", Id::new("al_gap"), - Lead::Icon(gap_icon, "Gap between items"), + Lead::Icon(gap_icon, tr("Gap between items")), if auto { None } else { Some(l.gap) }, "", 96.0, ); app.field(ids, "gap", e, |v| json!(v)); ui.add_space(8.0); - let resp = widgets::dropdown(ui, if auto { "Auto gap" } else { "Fixed gap" }, 96.0, None); + let resp = widgets::dropdown(ui, if auto { tr("Auto gap") } else { tr("Fixed gap") }, 96.0, None); menu_popup(&resp).show(|ui| { - if menu_item(ui, "Fixed", "") { + if menu_item(ui, tr("Fixed"), "") { app.set_props(ids, json!({"mainAlign": "min"})); ui.close(); } - if menu_item(ui, "Auto (space between)", "") { + if menu_item(ui, tr("Auto (space between)"), "") { app.set_props(ids, json!({"mainAlign": "spaceBetween"})); ui.close(); } @@ -339,7 +364,7 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { if l.mode == LayoutMode::Horizontal { let on = l.cross_align == CrossAlign::Baseline; row(ui, 28.0, |ui| { - if widgets::checkbox(ui, on, "Align text baselines") { + if widgets::checkbox(ui, on, tr("Align text baselines")) { app.set_props(ids, json!({"crossAlign": if on { "min" } else { "baseline" }})); } }); @@ -356,7 +381,7 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { ids, "paddingHorizontal", Id::new("al_padh"), - Lead::Icon(Icon::PaddingH, "Horizontal padding"), + Lead::Icon(Icon::PaddingH, tr("Horizontal padding")), (pl == pr).then_some(pl), "", col, @@ -368,7 +393,7 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { ids, "paddingVertical", Id::new("al_padv"), - Lead::Icon(Icon::PaddingV, "Vertical padding"), + Lead::Icon(Icon::PaddingV, tr("Vertical padding")), (pt == pb).then_some(pt), "", col, @@ -377,7 +402,7 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { }); if l.mode == LayoutMode::Horizontal && l.wrap { // Wrapped rows: the gap between rows. - widgets::sublabels(ui, "Row gap", None); + widgets::sublabels(ui, tr("Row gap"), None); row(ui, 32.0, |ui| { let e = crate::variable_ui::number_field( app, @@ -385,7 +410,7 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { ids, "crossGap", Id::new("al_crossgap"), - Lead::Icon(Icon::DistributeV, "Gap between rows"), + Lead::Icon(Icon::DistributeV, tr("Gap between rows")), Some(l.cross_gap), "", col, @@ -394,7 +419,7 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { }); } if l.mode == LayoutMode::Grid { - widgets::sublabels(ui, "Columns", Some("Rows")); + widgets::sublabels(ui, tr("Columns"), Some(tr("Rows"))); row(ui, 32.0, |ui| { let e = widgets::number_field(ui, Id::new("al_cols"), Lead::None, Some(f64::from(l.grid_columns)), "", col, 1.0); app.field(ids, "gridColumns", e, |v| json!(v.max(1.0))); @@ -411,7 +436,7 @@ fn tracks(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId]) { let Some(g) = figcraft_layout::grid_geometry(doc, id) else { return }; let col = widgets::col_width(ui.available_width()); for (axis, specs, ranges) in [("columns", &l.column_tracks, &g.columns), ("rows", &l.row_tracks, &g.rows)] { - widgets::sublabels(ui, if axis == "columns" { "Column sizes" } else { "Row sizes" }, None); + widgets::sublabels(ui, if axis == "columns" { tr("Column sizes") } else { tr("Row sizes") }, None); for (i, (a, b)) in ranges.iter().enumerate().take(MAX_TRACK_ROWS) { let track = specs.get(i).copied().unwrap_or_default(); let size = ((b - a) * 100.0).round() / 100.0; @@ -440,13 +465,13 @@ fn tracks(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId]) { let fill = track.sizing == Sizing::Fill; app.field_run(e, "layout.setTrack", |v| with(if fill { "fill" } else { "fixed" }, Some(v.max(0.0)))); let (label, icon) = match track.sizing { - Sizing::Fixed => ("Fixed", Icon::Fixed), - Sizing::Hug => ("Hug", Icon::Hug), - Sizing::Fill => ("Fill", Icon::FillContainer), + Sizing::Fixed => (tr("Fixed"), Icon::Fixed), + Sizing::Hug => (tr("Hug"), Icon::Hug), + Sizing::Fill => (tr("Fill|sizing"), Icon::FillContainer), }; let resp = widgets::dropdown(ui, label, col, Some(icon)); menu_popup(&resp).show(|ui| { - for (l, v) in [("Fixed", "fixed"), ("Hug contents", "hug"), ("Fill (fr)", "fill")] { + for (l, v) in [(tr("Fixed"), "fixed"), (tr("Hug contents"), "hug"), (tr("Fill (fr)"), "fill")] { if menu_item(ui, l, "") { app.run("layout.setTrack", with(v, None)); ui.close(); @@ -468,7 +493,7 @@ fn grid_cell(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { parent_of(doc, *id).and_then(|p| figcraft_layout::grid_geometry(doc, p.id)).and_then(|g| g.cells.into_iter().find(|c| c.id == *id)) { let anchored = doc.node(*id).is_some_and(|n| n.child_layout.grid_column.is_some() || n.child_layout.grid_row.is_some()); - widgets::sublabels(ui, "Column", Some("Row")); + widgets::sublabels(ui, tr("Column"), Some(tr("Row"))); row(ui, 32.0, |ui| { // Moving on one axis pins the other where it is, so the child doesn't jump. let at = |c: f64, r: f64| json!({"ids": [id], "props": {"gridColumn": (c - 1.0).max(0.0), "gridRow": (r - 1.0).max(0.0)}}); @@ -477,14 +502,14 @@ fn grid_cell(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { app.field_run(e, "node.set", |v| at(v, r0)); let e = widgets::number_field(ui, Id::new("al_cell_row"), Lead::None, Some(r0), "", col, 1.0); app.field_run(e, "node.set", |v| at(c0, v)); - if anchored && icons::button(ui, Icon::Reset, 24.0, false, "Place automatically").clicked() { + if anchored && icons::button(ui, Icon::Reset, 24.0, false, tr("Place automatically")).clicked() { app.set_props(&ids, json!({"gridColumn": null, "gridRow": null})); } }); } let cs = common(doc, sel, |n| f64::from(n.child_layout.column_span())); let rs = common(doc, sel, |n| f64::from(n.child_layout.row_span())); - widgets::sublabels(ui, "Column span", Some("Row span")); + widgets::sublabels(ui, tr("Column span"), Some(tr("Row span"))); row(ui, 32.0, |ui| { let e = widgets::number_field(ui, Id::new("al_span_c"), Lead::None, cs, "", col, 1.0); app.field(&ids, "gridColumnSpan", e, |v| json!(v.max(1.0))); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cdf7234..df8f4b0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -26,6 +26,7 @@ pub mod frame_stats; pub mod guides; pub mod handles; pub mod home; +pub mod i18n; pub mod icons; pub mod layers; pub mod layout_panel; @@ -146,6 +147,10 @@ pub trait Services { fn reduce_motion(&self) -> bool { false } + /// The OS or browser language as a tag ("es-ES"), for the "system" language preference. + fn system_language(&self) -> Option { + None + } } /// No-op services (tests, headless screenshots). @@ -376,6 +381,8 @@ pub struct FigApp { fonts_installed: bool, /// System faces for scripts the built-in fonts lack were added (after the font scan). script_fonts: bool, + /// The language preference the UI strings follow (applied when it changes). + language: Option, /// A field had the keyboard when the last frame ended. field_focus: bool, /// Control-channel requests waiting for the next frame. @@ -396,6 +403,7 @@ impl FigApp { worker: None, fonts_installed: false, script_fonts: false, + language: None, field_focus: false, control_queue: Vec::new(), control_input: Vec::new(), @@ -547,7 +555,7 @@ impl FigApp { let y = r.data.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0); comments::start_draft(self, x, y); } - "dialog.unsaved" => self.toast("This file has unsaved changes"), + "dialog.unsaved" => self.toast(i18n::tr("This file has unsaved changes")), "dialog.plugins" => self.ui.plugins_window = true, _ => {} } @@ -559,10 +567,10 @@ impl FigApp { pub fn save_as_dialog(&mut self) { let name = self.session.doc().map(|d| d.name.clone()).unwrap_or_else(|_| "Untitled".into()); - if let Some(p) = self.services.pick_save("Save as", &format!("{name}.figcraft"), &["figcraft"]) { + if let Some(p) = self.services.pick_save(i18n::tr("Save as"), &format!("{name}.figcraft"), &["figcraft"]) { let ps = p.to_string_lossy().to_string(); if self.run("file.saveAs", json!({"path": ps})).is_some() { - self.toast(format!("Saved {}", p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())); + self.toast(i18n::trf("Saved {name}", &[("name", &p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())])); } } } @@ -572,7 +580,7 @@ impl FigApp { let tab = figcraft_engine::branches::file_tab(&self.session); if self.session.docs.get(tab).is_some_and(|d| d.path.is_some()) { if self.run("file.save", json!({})).is_some() { - self.toast("Saved"); + self.toast(i18n::tr("Saved")); } } else { self.save_as_dialog(); @@ -580,7 +588,7 @@ impl FigApp { } pub fn open_dialog(&mut self) { - if let Some(p) = self.services.pick_open("Open", &["figcraft", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"]) { + if let Some(p) = self.services.pick_open(i18n::tr("Open"), &["figcraft", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"]) { let ps = p.to_string_lossy().to_string(); if self.run("file.open", json!({"path": ps})).is_some() { self.ui.home = false; @@ -594,7 +602,7 @@ impl FigApp { } else { &["svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff", "figcraft"] }; - if let Some(p) = self.services.pick_open(if place { "Place image" } else { "Import" }, exts) { + if let Some(p) = self.services.pick_open(if place { i18n::tr("Place image") } else { i18n::tr("Import") }, exts) { let ps = p.to_string_lossy().to_string(); self.run("file.import", json!({"path": ps})); } @@ -605,7 +613,7 @@ impl FigApp { self.session.prefs.reduce_motion || self.services.reduce_motion() } - /// Per-frame logic that doesn't draw (control requests, theme). + /// Per-frame logic that doesn't draw (control requests, theme, language). pub fn logic(&mut self, ctx: &egui::Context) { if !self.fonts_installed { theme::install_fonts(ctx, false); @@ -615,6 +623,10 @@ impl FigApp { self.script_fonts = true; theme::install_fonts(ctx, true); } + if self.language.as_ref() != Some(&self.session.prefs.language) { + i18n::set(&self.session.prefs.language, self.services.system_language().as_deref()); + self.language = Some(self.session.prefs.language.clone()); + } let dark = match self.session.prefs.theme.as_str() { "dark" => true, "system" => ctx.global_style().visuals.dark_mode, @@ -704,7 +716,7 @@ impl FigApp { .resizable(false) .frame(egui::Frame::NONE.fill(t.panel).stroke(egui::Stroke::new(1.0, t.border))) .show(ui, |ui| { - a11y::region(&mut self.ui.nav, ui, a11y::Region::Layers, egui::accesskit::Role::Navigation, "Sidebar"); + a11y::region(&mut self.ui.nav, ui, a11y::Region::Layers, egui::accesskit::Role::Navigation, i18n::tr("Sidebar")); layers::rail(self, ui) }); } @@ -716,9 +728,9 @@ impl FigApp { .frame(egui::Frame::NONE.fill(t.panel).stroke(egui::Stroke::new(1.0, t.border))) .show(ui, |ui| { let label = match self.ui.left_tab { - _ if self.ui.versions.open => "Version history", - LeftTab::File => "Layers", - LeftTab::Assets => "Assets", + _ if self.ui.versions.open => i18n::tr("Version history"), + LeftTab::File => i18n::tr("Layers"), + LeftTab::Assets => i18n::tr("Assets"), }; a11y::region(&mut self.ui.nav, ui, a11y::Region::Layers, egui::accesskit::Role::Pane, label); layers::left_panel(self, ui) @@ -732,9 +744,9 @@ impl FigApp { .frame(egui::Frame::NONE.fill(t.panel).stroke(egui::Stroke::new(1.0, t.border))) .show(ui, |ui| { let label = match self.ui.right_tab { - RightTab::Design => "Design", - RightTab::Prototype => "Prototype", - RightTab::Inspect => "Dev Mode", + RightTab::Design => i18n::tr("Design"), + RightTab::Prototype => i18n::tr("Prototype"), + RightTab::Inspect => i18n::tr("Dev Mode"), }; a11y::region(&mut self.ui.nav, ui, a11y::Region::Properties, egui::accesskit::Role::Pane, label); right::right_panel(self, ui) @@ -747,7 +759,7 @@ impl FigApp { self.worker = Some(worker); if !hidden { ui.scope_builder(egui::UiBuilder::new().id_salt("toolbar"), |ui| { - a11y::container(ui, egui::accesskit::Role::Toolbar, "Tools"); + a11y::container(ui, egui::accesskit::Role::Toolbar, i18n::tr("Tools")); toolbar::show(self, ui); }); } diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index 4640c2d..429f436 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -14,22 +14,27 @@ use serde_json::json; use crate::FigApp; use crate::a11y::{self, Tip}; +use crate::i18n::{tr, tr_noop, trf, trn}; use crate::icons::{self, Icon}; use crate::right::{menu_item, menu_item_with, menu_popup}; use crate::theme::{ACCENT, Tokens, regular, semibold}; use crate::widgets; /// In the browser: what joining needs (a page can't host, and https pages can't reach `ws://`). -const BROWSER_NOTE: &str = "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)."; +const BROWSER_NOTE: &str = tr_noop( + "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).", +); /// How undo works in a session (per person). -const UNDO_NOTE: &str = "Undo takes back only your own changes; what others changed since stays."; +const UNDO_NOTE: &str = tr_noop("Undo takes back only your own changes; what others changed since stays."); /// What a view link means for you. -const VIEW_NOTE: &str = "View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit."; +const VIEW_NOTE: &str = tr_noop("View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit."); /// What a published file's links are. -const PUBLISHED_NOTE: &str = "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."; +const PUBLISHED_NOTE: &str = tr_noop( + "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.", +); /// How fast a remote cursor closes in on where it was last reported (per second). const GLIDE: f64 = 20.0; @@ -195,7 +200,7 @@ pub fn draw(app: &mut FigApp, painter: &egui::Painter, ctx: &egui::Context) { /// A frame around the canvas in the followed person's colour, with their name at the top. fn follow_frame(painter: &egui::Painter, canvas: Rect, name: &str, fill: Color32) { painter.rect_stroke(canvas.shrink(1.0), CornerRadius::ZERO, Stroke::new(2.0, fill), egui::StrokeKind::Inside); - let text = format!("Following {name} · {} to stop", crate::toolbar::pretty_shortcut("Escape")); + let text = trf("Following {name} · {key} to stop", &[("name", &name), ("key", &crate::toolbar::pretty_shortcut("Escape"))]); let galley = painter.layout_no_wrap(text, semibold(11.0), text_on(fill)); let r = Rect::from_center_size(pos2(canvas.center().x, canvas.min.y + 2.0 + (galley.size().y + 6.0) / 2.0), galley.size() + vec2(16.0, 6.0)); painter.rect_filled(r, CornerRadius { nw: 0, ne: 0, sw: 6, se: 6 }, fill); @@ -228,7 +233,7 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { x += STEP; let c = pos2(x, cy); let following = follow == Some(*id); - let tip = if following { format!("Stop following {name}") } else { format!("Follow {name}") }; + let tip = if following { trf("Stop following {name}", &[("name", name)]) } else { trf("Follow {name}", &[("name", name)]) }; let resp = ui.interact(Rect::from_center_size(c, vec2(2.0 * R, 2.0 * R)), ui.id().with(("live_avatar", id)), Sense::click()).tip(tip); ring(ui, c); avatar(ui.painter(), c, R, name, *fill); @@ -248,18 +253,18 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { } let chev = Rect::from_center_size(pos2(x + R + 10.0, cy), vec2(16.0, 24.0)); let cresp = ui.interact(chev, ui.id().with("live_people"), Sense::click()); - a11y::menu_button(&cresp, "People in this file", a11y::menu_open(&cresp)); + a11y::menu_button(&cresp, tr("People in this file"), a11y::menu_open(&cresp)); if cresp.hovered() { ui.painter().rect_filled(chev, CornerRadius::same(4), t.hover); } icons::paint(ui.painter(), Rect::from_center_size(chev.center(), vec2(10.0, 10.0)), Icon::Chevron, t.icon_secondary); menu_popup(&cresp).width(220.0).show(|ui| { let row = |ui: &mut Ui, label: &str, fill: Color32, check: bool| { - menu_item_with(ui, label, if check { "Following" } else { "" }, 22.0, |p, r, _| { + menu_item_with(ui, label, if check { tr("Following") } else { "" }, 22.0, |p, r, _| { avatar(p, pos2(r.max.x - 15.0, r.center().y), 8.0, label, fill) }) }; - row(ui, &format!("{me_name} (you)"), me_fill, false); + row(ui, &trf("{name} (you)", &[("name", &me_name)]), me_fill, false); for (id, name, fill) in &peers { if row(ui, name, *fill, follow == Some(*id)) { clicked = Some(*id); @@ -267,7 +272,11 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { } } if peers.is_empty() { - widgets::label(ui, if app.session.live.status.active() { "Nobody else is here yet" } else { "Not in a live session" }, t.text_secondary); + widgets::label( + ui, + if app.session.live.status.active() { tr("Nobody else is here yet") } else { tr("Not in a live session") }, + t.text_secondary, + ); } }); if let Some(id) = clicked { @@ -287,7 +296,7 @@ pub fn share_popover(app: &mut FigApp, anchor: &egui::Response) { ui.with_layout(egui::Layout::top_down(egui::Align::Min), |ui| { ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap); ui.add_space(4.0); - ui.label(egui::RichText::new("Live session").font(semibold(12.0))); + ui.label(egui::RichText::new(tr("Live session")).font(semibold(12.0))); let status = app.session.live.status.clone(); let role = app.session.live.role(); let others = app.session.live.peers.len(); @@ -295,15 +304,18 @@ pub fn share_popover(app: &mut FigApp, anchor: &egui::Response) { // Connecting with a tab already chosen and nobody's welcome yet: the upload. let publishing = status == Status::Connecting && role.is_none() && app.session.live.doc_uid.is_some(); let (line, col) = match (&status, role) { - (Status::Live, Some(Role::Host)) => (format!("Live · {}", people_count(others)), t.success), - (Status::Live, _) if published => (format!("Published · {}", people_count(others)), t.success), - (Status::Connecting, _) if publishing => ("Publishing…".to_string(), t.text_secondary), + (Status::Live, Some(Role::Host)) => (trf("Live · {people}", &[("people", &people_count(others))]), t.success), + (Status::Live, _) if published => (trf("Published · {people}", &[("people", &people_count(others))]), t.success), + (Status::Connecting, _) if publishing => (tr("Publishing…").to_string(), t.text_secondary), (Status::Live, _) => { let file = app.session.live.info.as_ref().map(|i| i.file_name.clone()).unwrap_or_default(); - (if file.is_empty() { "Joined".to_string() } else { format!("Joined {file}") }, t.success) + (if file.is_empty() { tr("Joined").to_string() } else { trf("Joined {file}", &[("file", &file)]) }, t.success) + } + (Status::Reconnecting { attempt }, _) => { + (trf("Connection lost, reconnecting (attempt {attempt})…", &[("attempt", attempt)]), t.text_secondary) } (Status::Failed { .. }, _) => (status.label(), t.danger), - _ => (status.label(), t.text_secondary), + _ => (tr(&status.label()).to_string(), t.text_secondary), }; widgets::label(ui, &line, col); match (&status, role) { @@ -313,7 +325,7 @@ pub fn share_popover(app: &mut FigApp, anchor: &egui::Response) { } }); ui.separator(); - if menu_item(ui, "Export…", "") { + if menu_item(ui, tr("Export…"), "") { app.ui.export_preview = true; ui.close(); } @@ -322,51 +334,50 @@ pub fn share_popover(app: &mut FigApp, anchor: &egui::Response) { fn people_count(others: usize) -> String { match others { - 0 => "nobody else here yet".into(), - 1 => "1 person joined".into(), - n => format!("{n} people joined"), + 0 => tr("nobody else here yet").into(), + n => trn("{n} people joined", n, &[]), } } /// Not in a session: your name, where to share, start; or join one. fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { ui.horizontal(|ui| { - widgets::label(ui, "Your name", t.text); + widgets::label(ui, tr("Your name"), t.text); let name = app.session.prefs.user_name.clone(); - if let Some(n) = widgets::text_field(ui, egui::Id::new("live_name"), &name, 160.0, "Your name") { + if let Some(n) = widgets::text_field(ui, egui::Id::new("live_name"), &name, 160.0, tr("Your name")) { app.run("pref.userName", json!({"name": n})); } }); if !app.session.live.can_host() { - widgets::label(ui, "Hosting a session needs the desktop app; here you can join one.", t.text_secondary); - if widgets::button(ui, "Join a session…", true, true).clicked() { + widgets::label(ui, tr("Hosting a session needs the desktop app; here you can join one."), t.text_secondary); + if widgets::button(ui, tr("Join a session…"), true, true).clicked() { open_join(app, ""); ui.close(); } return; } - if widgets::checkbox(ui, app.ui.live.lan, "Share on the local network") { + if widgets::checkbox(ui, app.ui.live.lan, tr("Share on the local network")) { app.ui.live.lan = !app.ui.live.lan; } let reach = if app.ui.live.lan { - "Anyone on your network who has the invite link can join." + tr("Anyone on your network who has the invite link can join.") } else { - "Only this computer can join (turn on network sharing to invite others)." + tr("Only this computer can join (turn on network sharing to invite others).") }; widgets::label(ui, reach, t.text_secondary); ui.horizontal(|ui| { - if widgets::button(ui, "Start live session", true, true).clicked() { + if widgets::button(ui, tr("Start live session"), true, true).clicked() { let bind = if app.ui.live.lan { "lan" } else { "local" }; app.run("collab.host", json!({"bind": bind})); } - if widgets::button(ui, "Join a session…", false, true).clicked() { + if widgets::button(ui, tr("Join a session…"), false, true).clicked() { open_join(app, ""); ui.close(); } }); ui.separator(); - widgets::label(ui, "Or keep the file on a FigCraft server and share links to edit or to view it.", t.text_secondary); - if widgets::button(ui, "Publish to server…", false, true).clicked() { + widgets::label(ui, tr("Or keep the file on a FigCraft server and share links to edit or to view it."), t.text_secondary); + if widgets::button(ui, tr("Publish to server…"), false, true).clicked() { open_publish(app); ui.close(); } @@ -375,11 +386,11 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { /// Hosting: the invite link and URL to copy, and stop. fn host_ui(app: &mut FigApp, ui: &mut Ui) { let Some(info) = app.session.live.info.clone() else { return }; - link_rows(app, ui, &[("Invite link", &info.link, "Invite link copied"), ("WebSocket URL", &info.url, "WebSocket URL copied")]); + link_rows(app, ui, &[(tr("Invite link"), &info.link, tr("Invite link copied")), (tr("WebSocket URL"), &info.url, tr("WebSocket URL copied"))]); let t = Tokens::get(ui.ctx()); - widgets::label(ui, "The link carries the session's secret: share it only with people you invite.", t.text_secondary); - widgets::label(ui, UNDO_NOTE, t.text_secondary); - if widgets::button(ui, "Stop session", false, true).clicked() { + widgets::label(ui, tr("The link carries the session's secret: share it only with people you invite."), t.text_secondary); + widgets::label(ui, tr(UNDO_NOTE), t.text_secondary); + if widgets::button(ui, tr("Stop session"), false, true).clicked() { app.run("collab.stop", json!({})); } } @@ -390,18 +401,18 @@ fn guest_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { let live = &app.session.live; let view = live.info.as_ref().is_some_and(|i| i.access == Access::View); if let Some(i) = live.info.as_ref() { - widgets::label(ui, &format!("{} · {}", i.address, if view { "View only" } else { "Can edit" }), t.text_secondary); + widgets::label(ui, &format!("{} · {}", i.address, if view { tr("View only") } else { tr("Can edit") }), t.text_secondary); if !i.pages.is_empty() { let pages: Vec<&str> = i.pages.iter().map(|p| p.name.as_str()).take(8).collect(); - widgets::label(ui, &format!("Pages: {}", pages.join(", ")), t.text_secondary); + widgets::label(ui, &trf("Pages: {pages}", &[("pages", &pages.join(", "))]), t.text_secondary); } } if let Some(p) = live.published.clone() { - link_rows(app, ui, &[("Edit link", &p.edit, "Edit link copied"), ("View link", &p.view, "View link copied")]); - widgets::label(ui, PUBLISHED_NOTE, t.text_secondary); + link_rows(app, ui, &[(tr("Edit link"), &p.edit, tr("Edit link copied")), (tr("View link"), &p.view, tr("View link copied"))]); + widgets::label(ui, tr(PUBLISHED_NOTE), t.text_secondary); } - widgets::label(ui, if view { VIEW_NOTE } else { UNDO_NOTE }, t.text_secondary); - if widgets::button(ui, "Leave session", false, true).on_hover_text("Your copy of the file stays open").clicked() { + widgets::label(ui, tr(if view { VIEW_NOTE } else { UNDO_NOTE }), t.text_secondary); + if widgets::button(ui, tr("Leave session"), false, true).on_hover_text(tr("Your copy of the file stays open")).clicked() { app.run("collab.leave", json!({})); } } @@ -417,7 +428,7 @@ fn link_rows(app: &mut FigApp, ui: &mut Ui, rows: &[(&str, &String, &str)]) { widgets::label(ui, label, t.text_secondary); ui.label(egui::RichText::new(widgets::elide(ui, value, w)).font(regular(10.5)).monospace()); }); - if widgets::button(ui, "Copy", false, true).on_hover_text(format!("Copy the {}", label.to_lowercase())).clicked() { + if widgets::button(ui, tr("Copy"), false, true).on_hover_text(trf("Copy the {label}", &[("label", &label.to_lowercase())])).clicked() { app.services.clipboard_set_text(value); app.toast(*what); } @@ -459,24 +470,24 @@ pub fn join_dialog(app: &mut FigApp, ctx: &egui::Context) { let mut cancel = false; let mut go = false; let focus = std::mem::take(&mut app.ui.live.focus_join); - crate::dialogs::window("Join live session", ctx).open(&mut open).show(ctx, |ui| { + crate::dialogs::window(tr("Join live session"), ctx).id(egui::Id::new("Join live session")).open(&mut open).show(ctx, |ui| { ui.set_width(380.0); - widgets::label(ui, "Paste the invite link the host shared with you.", t.text); + widgets::label(ui, tr("Paste the invite link the host shared with you."), t.text); let resp = ui .add(egui::TextEdit::singleline(&mut link).hint_text("figcraft://join?host=…").desired_width(380.0).id(egui::Id::new("live_join_link"))); - a11y::text(&resp, "Invite link", ""); + a11y::text(&resp, tr("Invite link"), ""); if focus { resp.request_focus(); } go = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); - widgets::label(ui, "You'll get the host's file as a new tab and edit it together.", t.text_secondary); + widgets::label(ui, tr("You'll get the host's file as a new tab and edit it together."), t.text_secondary); if !app.session.live.can_host() { - widgets::label(ui, BROWSER_NOTE, t.text_secondary); + widgets::label(ui, tr(BROWSER_NOTE), t.text_secondary); } ui.add_space(4.0); ui.horizontal(|ui| { - go |= widgets::button(ui, "Join", true, !link.trim().is_empty()).clicked(); - cancel = widgets::button(ui, "Cancel", false, true).clicked(); + go |= widgets::button(ui, tr("Join"), true, !link.trim().is_empty()).clicked(); + cancel = widgets::button(ui, tr("Cancel"), false, true).clicked(); }); }); if go && !link.trim().is_empty() && app.run("collab.join", json!({"link": link.trim()})).is_some() { @@ -490,7 +501,7 @@ pub fn join_dialog(app: &mut FigApp, ctx: &egui::Context) { /// Open the Publish to server dialog for the open file (the last address used this run in it). pub fn open_publish(app: &mut FigApp) { if !app.session.live.can_host() { - app.toast("Publishing to a server needs the desktop app; in the browser you can join a server's files"); + app.toast(tr("Publishing to a server needs the desktop app; in the browser you can join a server's files")); return; } let name = app.session.doc().map(|d| d.name.clone()).unwrap_or_default(); @@ -506,14 +517,14 @@ pub fn publish_dialog(app: &mut FigApp, ctx: &egui::Context) { let mut cancel = false; let mut go = false; let focus = std::mem::take(&mut app.ui.live.focus_publish); - crate::dialogs::window("Publish to server", ctx).open(&mut open).show(ctx, |ui| { + crate::dialogs::window(tr("Publish to server"), ctx).id(egui::Id::new("Publish to server")).open(&mut open).show(ctx, |ui| { ui.set_width(380.0); widgets::label( ui, - "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.", + tr("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."), t.text, ); - widgets::label(ui, "Server address", t.text_secondary); + widgets::label(ui, tr("Server address"), t.text_secondary); let resp = ui.add( egui::TextEdit::singleline(&mut form.server) .hint_text("ws://server:47900/?key=…") @@ -521,21 +532,21 @@ pub fn publish_dialog(app: &mut FigApp, ctx: &egui::Context) { .id(egui::Id::new("live_publish_server")), ); // The address carries the server key: it isn't read out. - a11y::text(&resp, "Server address", ""); + a11y::text(&resp, tr("Server address"), ""); if focus { resp.request_focus(); } let enter = |ui: &Ui| ui.input(|i| i.key_pressed(egui::Key::Enter)); go = resp.lost_focus() && enter(ui); - widgets::label(ui, "On the server's machine, `figcraft-server key` prints it.", t.text_secondary); - widgets::label(ui, "File name", t.text_secondary); + widgets::label(ui, tr("On the server's machine, `figcraft-server key` prints it."), t.text_secondary); + widgets::label(ui, tr("File name"), t.text_secondary); let nresp = ui.add(egui::TextEdit::singleline(&mut form.name).desired_width(380.0).id(egui::Id::new("live_publish_name"))); - a11y::text(&nresp, "File name", &form.name); + a11y::text(&nresp, tr("File name"), &form.name); go |= nresp.lost_focus() && enter(ui); ui.add_space(4.0); ui.horizontal(|ui| { - go |= widgets::button(ui, "Publish", true, !form.server.trim().is_empty()).clicked(); - cancel = widgets::button(ui, "Cancel", false, true).clicked(); + go |= widgets::button(ui, tr("Publish"), true, !form.server.trim().is_empty()).clicked(); + cancel = widgets::button(ui, tr("Cancel"), false, true).clicked(); }); }); if go && !form.server.trim().is_empty() && app.run("collab.publish", json!({"server": form.server.trim(), "name": form.name.trim()})).is_some() { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 30529e6..512affc 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -7,6 +7,7 @@ use serde_json::json; use crate::FigApp; use crate::a11y; +use crate::i18n::{self, tr, tr_noop, trf}; use crate::icons::{self, Icon}; use crate::theme::{ACCENT, Tokens, regular}; use crate::toolbar::pretty_shortcut; @@ -88,14 +89,14 @@ pub fn invoke(app: &mut FigApp, id: &str) { if let (Some(first), Ok(doc)) = (ids.first(), app.session.doc()) { let css = figcraft_engine::codegen::code_for(doc, *first, figcraft_engine::codegen::CodeLang::Css); app.services.clipboard_set_text(&css); - app.toast("Copied as CSS"); + app.toast(tr("Copied as CSS")); } } "edit.copyAsSvg" => { let ids = app.session.selection(); if let Some(svg) = app.session.doc().ok().and_then(|d| figcraft_svg_export(d, &ids)) { app.services.clipboard_set_text(&svg); - app.toast("Copied as SVG"); + app.toast(tr("Copied as SVG")); } } "edit.copyAsPng" => { @@ -108,7 +109,7 @@ pub fn invoke(app: &mut FigApp, id: &str) { figcraft_render::to_srgb(&mut pix, profile); let rgba = figcraft_render::to_rgba8(&pix); app.services.clipboard_set_png(pix.width(), pix.height(), &rgba); - app.toast("Copied as PNG"); + app.toast(tr("Copied as PNG")); } } "edit.copyAsText" => { @@ -120,7 +121,7 @@ pub fn invoke(app: &mut FigApp, id: &str) { .filter_map(|i| doc.and_then(|d| d.node(*i)).and_then(|n| n.kind.text().map(|t| t.characters.clone()))) .collect(); app.services.clipboard_set_text(&text.join("\n")); - app.toast("Copied as text"); + app.toast(tr("Copied as text")); } "edit.copyLink" => { let ids = app.session.selection(); @@ -145,7 +146,10 @@ pub fn invoke(app: &mut FigApp, id: &str) { "edit.paste" => paste(app, None), "file.close" if app.session.ds().is_ok_and(|d| d.is_dirty() && d.branch_of.is_none()) => { let save = pretty_shortcut(crate::shortcuts::shortcut("file.save").unwrap_or("")); - app.toast(format!("Unsaved changes: save first ({save}), or close the tab with {}-click to discard", pretty_shortcut("Alt"))); + app.toast(trf( + "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard", + &[("save", &save), ("alt", &pretty_shortcut("Alt"))], + )); } _ => { app.run(id, json!({})); @@ -184,13 +188,13 @@ fn menu_level(app: &mut FigApp, ui: &mut Ui, prefix: &str) -> bool { } last_sep = false; if m.id == "file.openRecent" { - if recent_menu(app, ui, prefix, m.label, &t) { + if recent_menu(app, ui, prefix, m.label, &i18n::menu_item(m.id, m.label), &t) { return true; } continue; } let enabled = available(m.id); - if item(ui, m.label, crate::shortcuts::shortcut(m.id).unwrap_or(""), enabled, false, &t) && enabled { + if item(ui, &i18n::menu_item(m.id, m.label), crate::shortcuts::shortcut(m.id).unwrap_or(""), enabled, false, &t) && enabled { clicked = Some(m.id); } } else if let Some(rest) = m.menu.strip_prefix(prefix).and_then(|r| r.strip_prefix('/')) { @@ -199,7 +203,7 @@ fn menu_level(app: &mut FigApp, ui: &mut Ui, prefix: &str) -> bool { subs.push(sub.clone()); last_sep = false; let full = format!("{prefix}/{sub}"); - if submenu(ui, prefix, &full, &sub, &t, |ui| menu_level(app, ui, &full).then_some(())).is_some() { + if submenu(ui, prefix, &full, tr(&sub), &t, |ui| menu_level(app, ui, &full).then_some(())).is_some() { return true; } } @@ -212,10 +216,10 @@ fn menu_level(app: &mut FigApp, ui: &mut Ui, prefix: &str) -> bool { false } -/// File ▸ Open recent: the recent files (greyed when the file is gone) and Clear recent. Returns -/// whether an item ran. -fn recent_menu(app: &mut FigApp, ui: &mut Ui, prefix: &str, label: &str, t: &Tokens) -> bool { - let picked = submenu(ui, prefix, &format!("{prefix}/{label}"), label, t, |ui| { +/// File ▸ Open recent: the recent files (greyed when the file is gone) and Clear recent. `key` is +/// the menu's English label, `label` the one shown. Returns whether an item ran. +fn recent_menu(app: &mut FigApp, ui: &mut Ui, prefix: &str, key: &str, label: &str, t: &Tokens) -> bool { + let picked = submenu(ui, prefix, &format!("{prefix}/{key}"), label, t, |ui| { let recents = &app.session.prefs.recent_files; let mut picked = None; for (i, p) in recents.iter().enumerate() { @@ -229,7 +233,7 @@ fn recent_menu(app: &mut FigApp, ui: &mut Ui, prefix: &str, label: &str, t: &Tok if !recents.is_empty() { separator(ui); } - if item(ui, "Clear recent", "", !recents.is_empty(), false, t) && !recents.is_empty() { + if item(ui, tr("Clear recent"), "", !recents.is_empty(), false, t) && !recents.is_empty() { picked = Some(json!({"clear": true})); } picked @@ -272,13 +276,32 @@ fn menu_frame(ui: &mut Ui, t: &Tokens, add: impl FnOnce(&mut Ui) -> R) -> R { .inner_margin(egui::Margin::same(8)) .shadow(egui::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(60) }) .show(ui, |ui| { - ui.set_width(240.0); + // As wide as its widest row asked for (labels run longer in some languages). + let w = ui.data(|d| d.get_temp::(width_key(ui))).unwrap_or(0.0).max(MENU_W); + ui.set_width(w); ui.spacing_mut().item_spacing = vec2(0.0, 0.0); add(ui) }) .inner } +/// A menu's narrowest width. +const MENU_W: f32 = 240.0; + +/// Where a menu remembers the width its rows need (its frame's ui), per language. +fn width_key(ui: &Ui) -> Id { + ui.id().with(("menu_w", i18n::current().map(|l| l.code))) +} + +/// A row needs `w`: the menu widens to it from the next frame on. +fn need_width(ui: &Ui, w: f32) { + let key = width_key(ui); + if ui.data(|d| d.get_temp::(key)).unwrap_or(0.0) < w { + ui.data_mut(|d| d.insert_temp(key, w)); + ui.ctx().request_repaint(); + } +} + pub(crate) fn item(ui: &mut Ui, label: &str, shortcut: &str, enabled: bool, checked: bool, t: &Tokens) -> bool { indented_item(ui, label, shortcut, enabled, checked, 0.0, t) } @@ -301,16 +324,14 @@ fn indented_item(ui: &mut Ui, label: &str, shortcut: &str, enabled: bool, checke if checked { icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(12.0, 12.0)), Icon::Check, col); } + let galley = ui.painter().layout_no_wrap(label.to_string(), regular(12.0), col); + let keys = (!shortcut.is_empty()) + .then(|| ui.painter().layout_no_wrap(pretty_shortcut(shortcut), regular(12.0), if hov { t.menu_text } else { t.menu_dim })); + need_width(ui, 28.0 + indent + galley.size().x + keys.as_ref().map_or(8.0, |k| 16.0 + k.size().x + 10.0)); let text_rect = Rect::from_min_max(pos2(r.min.x + 28.0 + indent, r.min.y), pos2(r.max.x - 8.0, r.max.y)); - ui.painter().with_clip_rect(text_rect).text(pos2(text_rect.min.x, r.center().y), Align2::LEFT_CENTER, label, regular(12.0), col); - if !shortcut.is_empty() { - ui.painter().text( - pos2(r.max.x - 10.0, r.center().y), - Align2::RIGHT_CENTER, - pretty_shortcut(shortcut), - regular(12.0), - if hov { t.menu_text } else { t.menu_dim }, - ); + ui.painter().with_clip_rect(text_rect).galley(pos2(text_rect.min.x, r.center().y - galley.size().y / 2.0), galley, col); + if let Some(k) = keys { + ui.painter().galley(pos2(r.max.x - 10.0 - k.size().x, r.center().y - k.size().y / 2.0), k, col); } resp.clicked() } @@ -323,7 +344,9 @@ fn submenu_item(ui: &mut Ui, label: &str, t: &Tokens) -> egui::Response { if resp.hovered() || resp.has_focus() { ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); } - ui.painter().text(pos2(r.min.x + 28.0, r.center().y), Align2::LEFT_CENTER, label, regular(12.0), t.menu_text); + let galley = ui.painter().layout_no_wrap(label.to_string(), regular(12.0), t.menu_text); + need_width(ui, 28.0 + galley.size().x + 28.0); + ui.painter().galley(pos2(r.min.x + 28.0, r.center().y - galley.size().y / 2.0), galley, t.menu_text); icons::paint(ui.painter(), Rect::from_center_size(pos2(r.max.x - 12.0, r.center().y), vec2(12.0, 12.0)), Icon::ChevronRight, t.menu_text); resp } @@ -343,7 +366,19 @@ fn over_menu(ctx: &egui::Context) -> bool { ctx.input(|i| i.pointer.interact_pos()).and_then(|p| ctx.layer_id_at(p)).is_some_and(|l| l.order == egui::Order::Foreground) } -const TOP_MENUS: &[&str] = &["File", "Edit", "View", "Object", "Text", "Arrange", "Vector", "Plugins", "Preferences", "Libraries", "Help"]; +const TOP_MENUS: &[&str] = &[ + tr_noop("File"), + tr_noop("Edit"), + tr_noop("View"), + tr_noop("Object"), + tr_noop("Text"), + tr_noop("Arrange"), + tr_noop("Vector"), + tr_noop("Plugins"), + tr_noop("Preferences"), + tr_noop("Libraries"), + tr_noop("Help"), +]; pub fn popups(app: &mut FigApp, ctx: &egui::Context) { let t = Tokens::get(ctx); @@ -354,10 +389,10 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { menu_frame(ui, &t, |ui| { // Quick actions search row. let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click()); - a11y::menu_item(&resp, "Actions…", crate::shortcuts::shortcut("app.actions").unwrap_or(""), None); + a11y::menu_item(&resp, tr("Actions…"), crate::shortcuts::shortcut("app.actions").unwrap_or(""), None); ui.painter().rect_filled(r.shrink(2.0), CornerRadius::same(5), Color32::from_white_alpha(18)); icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 16.0, r.center().y), vec2(14.0, 14.0)), Icon::Search, t.menu_dim); - ui.painter().text(pos2(r.min.x + 32.0, r.center().y), Align2::LEFT_CENTER, "Actions…", regular(12.0), t.menu_dim); + ui.painter().text(pos2(r.min.x + 32.0, r.center().y), Align2::LEFT_CENTER, tr("Actions…"), regular(12.0), t.menu_dim); ui.painter().text( pos2(r.max.x - 10.0, r.center().y), Align2::RIGHT_CENTER, @@ -371,7 +406,7 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { } ui.add_space(6.0); for top in TOP_MENUS { - let resp = submenu_item(ui, top, &t); + let resp = submenu_item(ui, tr(top), &t); if resp.hovered() || resp.has_focus() { ui.data_mut(|d| d.insert_temp(Id::new("main_open"), top.to_string())); } @@ -411,53 +446,53 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { .filter(|n| matches!(n.kind, figcraft_doc::NodeKind::Section { .. })) .map(|n| n.hides_contents()) .collect(); - (!secs.is_empty()).then(|| if secs.iter().all(|h| *h) { "Show section contents" } else { "Hide section contents" }) + (!secs.is_empty()).then(|| if secs.iter().all(|h| *h) { tr("Show section contents") } else { tr("Hide section contents") }) }); let mut entries: Vec<(&str, &str)> = if has_sel { vec![ - ("Copy", "edit.copy"), - ("Paste here", "edit.paste"), - ("Paste to replace", "edit.pasteReplace"), + (tr("Copy"), "edit.copy"), + (tr("Paste here"), "edit.paste"), + (tr("Paste to replace"), "edit.pasteReplace"), ("-", ""), - ("Bring to front", "arrange.front"), - ("Bring forward", "arrange.forward"), - ("Send backward", "arrange.backward"), - ("Send to back", "arrange.back"), + (tr("Bring to front"), "arrange.front"), + (tr("Bring forward"), "arrange.forward"), + (tr("Send backward"), "arrange.backward"), + (tr("Send to back"), "arrange.back"), ("-", ""), - ("Group selection", "object.group"), - ("Frame selection", "object.frameSelection"), - ("Wrap in new section", "object.wrapInSection"), - ("Ungroup", "object.ungroup"), - ("Flatten", "vector.flatten"), - ("Outline stroke", "vector.outlineStroke"), - ("Use as mask", "object.mask"), + (tr("Group selection"), "object.group"), + (tr("Frame selection"), "object.frameSelection"), + (tr("Wrap in new section"), "object.wrapInSection"), + (tr("Ungroup"), "object.ungroup"), + (tr("Flatten"), "vector.flatten"), + (tr("Outline stroke"), "vector.outlineStroke"), + (tr("Use as mask"), "object.mask"), ("-", ""), - ("Add auto layout", "layout.add"), - ("Create component", "component.create"), - ("Detach instance", "component.detach"), + (tr("Add auto layout"), "layout.add"), + (tr("Create component"), "component.create"), + (tr("Detach instance"), "component.detach"), ("-", ""), - ("Show/Hide", "object.toggleVisible"), - ("Lock/Unlock", "object.toggleLock"), + (tr("Show/Hide"), "object.toggleVisible"), + (tr("Lock/Unlock"), "object.toggleLock"), ("-", ""), - ("Flip horizontal", "object.flipH"), - ("Flip vertical", "object.flipV"), + (tr("Flip horizontal"), "object.flipH"), + (tr("Flip vertical"), "object.flipV"), ("-", ""), - ("Copy as CSS", "edit.copyAsCss"), - ("Copy as SVG", "edit.copyAsSvg"), - ("Copy as PNG", "edit.copyAsPng"), + (tr("Copy as CSS"), "edit.copyAsCss"), + (tr("Copy as SVG"), "edit.copyAsSvg"), + (tr("Copy as PNG"), "edit.copyAsPng"), ("-", ""), - ("Delete", "edit.delete"), + (tr("Delete"), "edit.delete"), ] } else { vec![ - ("Paste here", "edit.paste"), + (tr("Paste here"), "edit.paste"), ("-", ""), - ("Select all", "select.all"), + (tr("Select all"), "select.all"), ("-", ""), - ("Show/Hide UI", "view.toggleUi"), - ("Zoom to fit", "view.zoomFit"), - ("Rulers", "view.rulers"), - ("Pixel grid", "view.pixelGrid"), + (tr("Show/Hide UI"), "view.toggleUi"), + (tr("Zoom to fit"), "view.zoomFit"), + (tr("Rulers"), "view.rulers"), + (tr("Pixel grid"), "view.pixelGrid"), ] }; if let Some(label) = section_toggle @@ -527,7 +562,7 @@ fn select_layer_menu(app: &mut FigApp, ui: &mut Ui, pos: egui::Pos2, t: &Tokens) return false; } let sel = ds.selection.clone(); - let resp = submenu_item(ui, "Select layer", t); + let resp = submenu_item(ui, tr("Select layer"), t); if resp.hovered() { ui.data_mut(|d| d.insert_temp(select_layer_id(), true)); } @@ -574,7 +609,7 @@ fn palette(app: &mut FigApp, ctx: &egui::Context) { ui.set_width(520.0); let r = ui.add( egui::TextEdit::singleline(&mut q) - .hint_text("Search actions, layers and tools") + .hint_text(tr("Search actions, layers and tools")) .desired_width(500.0) .font(regular(14.0)) .frame(egui::Frame::NONE.inner_margin(egui::Margin::same(8))), @@ -582,14 +617,15 @@ fn palette(app: &mut FigApp, ctx: &egui::Context) { r.request_focus(); ui.add_space(4.0); let ql = q.to_lowercase(); - let mut hits: Vec<(&str, &str, &str)> = figcraft_engine::all_commands() - .iter() - .filter(|c| ql.is_empty() || c.label.to_lowercase().contains(&ql) || c.id.to_lowercase().contains(&ql)) - .map(|c| (c.id, c.label, crate::shortcuts::shortcut(c.id).unwrap_or(""))) - .collect(); - for m in MENU.iter().filter(|m| UI_COMMANDS.contains(&m.id)) { - if ql.is_empty() || m.label.to_lowercase().contains(&ql) { - hits.push((m.id, m.label, crate::shortcuts::shortcut(m.id).unwrap_or(""))); + // Labels match in the UI language and in English. + let matches = + |label: &str, english: &str, id: &str| ql.is_empty() || [label, english, id].iter().any(|s| s.to_lowercase().contains(&ql)); + let mut hits: Vec<(&str, std::borrow::Cow, &str)> = Vec::new(); + let menu_items = MENU.iter().filter(|m| UI_COMMANDS.contains(&m.id)).map(|m| (m.id, m.label)); + for (id, english) in figcraft_engine::all_commands().iter().map(|c| (c.id, c.label)).chain(menu_items) { + let label = i18n::command(id, english); + if matches(&label, english, id) { + hits.push((id, label, crate::shortcuts::shortcut(id).unwrap_or(""))); } } hits.sort_by_key(|h| (!h.1.to_lowercase().starts_with(&ql), h.1.len())); @@ -603,7 +639,7 @@ fn palette(app: &mut FigApp, ctx: &egui::Context) { ui.painter().rect_filled(r, CornerRadius::same(5), if resp.hovered() { ACCENT } else { t.hover }); } let col = if resp.hovered() { Color32::WHITE } else { t.text }; - ui.painter().text(pos2(r.min.x + 10.0, r.center().y), Align2::LEFT_CENTER, *label, regular(12.0), col); + ui.painter().text(pos2(r.min.x + 10.0, r.center().y), Align2::LEFT_CENTER, label.as_ref(), regular(12.0), col); ui.painter().text( pos2(r.max.x - 10.0, r.center().y), Align2::RIGHT_CENTER, diff --git a/crates/ui-egui/src/plugins_ui.rs b/crates/ui-egui/src/plugins_ui.rs index 9268d56..d2244e8 100644 --- a/crates/ui-egui/src/plugins_ui.rs +++ b/crates/ui-egui/src/plugins_ui.rs @@ -8,6 +8,7 @@ use serde_json::{Map, Value, json}; use crate::FigApp; use crate::dialogs::{floating, window}; +use crate::i18n::{tr, trf}; use crate::theme::{Tokens, regular, semibold}; use crate::widgets::{self, FieldEdit, Lead}; @@ -48,9 +49,9 @@ fn run(app: &mut FigApp, cmd: &str, params: Value) { /// Plugins ▸ Development ▸ Import plugin from manifest…: pick a `figcraft-plugin.json`. pub fn import_dialog(app: &mut FigApp) { - let Some(p) = app.services.pick_open("Import plugin from manifest", &["json"]) else { return }; + let Some(p) = app.services.pick_open(tr("Import plugin from manifest"), &["json"]) else { return }; if let Some(v) = app.run("plugin.import", json!({"path": p.to_string_lossy()})) { - app.toast(format!("Imported {}", v["name"].as_str().unwrap_or("the plugin"))); + app.toast(trf("Imported {name}", &[("name", &v["name"].as_str().unwrap_or(tr("the plugin")))])); } } @@ -127,11 +128,11 @@ fn manage_window(app: &mut FigApp, ctx: &Context) { let mut open = true; let mut remove = None; let mut import = false; - window("Plugins", ctx).open(&mut open).show(ctx, |ui| { + window(tr("Plugins"), ctx).id(Id::new("Plugins")).open(&mut open).show(ctx, |ui| { ui.set_width(380.0); let plugins = &app.session.prefs.plugins; if plugins.is_empty() { - widgets::label(ui, "No plugins yet. Import a development plugin from its figcraft-plugin.json.", t.text_secondary); + widgets::label(ui, tr("No plugins yet. Import a development plugin from its figcraft-plugin.json."), t.text_secondary); } for (i, p) in plugins.iter().enumerate() { if i > 0 { @@ -143,13 +144,13 @@ fn manage_window(app: &mut FigApp, ctx: &Context) { ui.label(RichText::new(&p.manifest.name).font(semibold(12.0)).color(t.text)); widgets::label(ui, &summary(p), t.text_secondary); }); - if widgets::button(ui, "Remove", false, true).clicked() { + if widgets::button(ui, tr("Remove"), false, true).clicked() { remove = Some(p.manifest.id.clone()); } }); } ui.add_space(12.0); - if widgets::button(ui, "Import plugin from manifest…", true, true).clicked() { + if widgets::button(ui, tr("Import plugin from manifest…"), true, true).clicked() { import = true; } }); @@ -166,8 +167,8 @@ fn manage_window(app: &mut FigApp, ctx: &Context) { fn summary(p: &InstalledPlugin) -> String { let m = &p.manifest; let access = match m.permissions.access { - Access::Read => "reads the document", - Access::Write => "can edit the document", + Access::Read => tr("reads the document"), + Access::Write => tr("can edit the document"), }; let mut parts: Vec = Vec::new(); if !m.version.is_empty() { @@ -175,8 +176,8 @@ fn summary(p: &InstalledPlugin) -> String { } parts.push(access.to_string()); parts.push(match &p.path { - Some(path) => format!("development: {path}"), - None => "this session only".into(), + Some(path) => trf("development: {path}", &[("path", path)]), + None => tr("this session only").into(), }); parts.join(" · ") } @@ -207,8 +208,8 @@ fn form_window(app: &mut FigApp, ctx: &Context) { } ui.add_space(12.0); ui.horizontal(|ui| { - submit = widgets::button(ui, "Run", true, true).clicked(); - cancel = widgets::button(ui, "Cancel", false, true).clicked(); + submit = widgets::button(ui, tr("Run"), true, true).clicked(); + cancel = widgets::button(ui, tr("Cancel"), false, true).clicked(); }); }); app.ui.plugin_form = (open && !submit && !cancel).then_some(form.clone()); diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index ec412a1..975d049 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -33,6 +33,7 @@ use serde_json::{Value, json}; use crate::FigApp; use crate::a11y::Tip; +use crate::i18n::{tr, tr_noop}; use crate::icons::{self, Icon}; use crate::theme::{ACCENT, Tokens, regular, semibold}; use crate::widgets; @@ -155,13 +156,13 @@ impl PresentScale { pub fn label(self, device: bool) -> &'static str { match (self, device) { - (PresentScale::Fit, false) => "Fit to window", - (PresentScale::FitWidth, _) => "Fit width", - (PresentScale::Fill, false) => "Fill window", - (PresentScale::Actual, false) => "Actual size (100%)", - (PresentScale::Fit, true) => "Fit device to window", - (PresentScale::Fill, true) => "Zoom device to fill window", - (PresentScale::Actual, true) => "Device at 100%", + (PresentScale::Fit, false) => tr("Fit to window"), + (PresentScale::FitWidth, _) => tr("Fit width"), + (PresentScale::Fill, false) => tr("Fill window"), + (PresentScale::Actual, false) => tr("Actual size (100%)"), + (PresentScale::Fit, true) => tr("Fit device to window"), + (PresentScale::Fill, true) => tr("Zoom device to fill window"), + (PresentScale::Actual, true) => tr("Device at 100%"), } } @@ -1562,8 +1563,8 @@ fn inspect_panel(app: &mut FigApp, ui: &mut Ui, pres: &Presenter) { let found = pres.inspected.zip(pres.doc.as_ref()).and_then(|(id, doc)| doc.node(id).map(|n| (id, doc, n.name.clone()))); egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { let Some((id, doc, name)) = found else { - widgets::section_header(ui, "Inspect", &[], false); - crate::right::row(ui, 32.0, |ui| widgets::label(ui, "Hover a layer to see its properties", t.text_secondary)); + widgets::section_header(ui, tr("Inspect"), &[], false); + crate::right::row(ui, 32.0, |ui| widgets::label(ui, tr("Hover a layer to see its properties"), t.text_secondary)); return; }; widgets::section_header(ui, &name, &[], false); @@ -1641,7 +1642,7 @@ pub fn show_pane(app: &mut FigApp, ctx: &egui::Context) { let typing = ds.text_edit.is_some() || crate::a11y::field_focused(ctx); let chrome = Chrome { pane: true, bar_h: 36.0, pad: 12.0, round: style.visuals.window_corner_radius.nw, keys: !typing }; let mut action = None; - egui::Window::new("Prototype preview") + egui::Window::new(tr("Prototype preview")) .id(pane_id()) .title_bar(false) .collapsible(false) @@ -1809,7 +1810,7 @@ fn player(p: &mut Presenter, ui: &mut Ui, rect: Rect, now: f64, chrome: Chrome) if !chrome.pane { return Some(BarAction::Close); } - ui.painter().text(area.center(), Align2::CENTER_CENTER, "Select a frame to preview it", regular(12.0), Color32::from_gray(170)); + ui.painter().text(area.center(), Align2::CENTER_CENTER, tr("Select a frame to preview it"), regular(12.0), Color32::from_gray(170)); return action; } // Scroll animation (Scroll to). @@ -1900,26 +1901,31 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome) let settings = page_settings(doc, p.current); let device = settings.device(); let mut action = None; - // Right: the buttons, right to left. + // Right: the buttons, right to left (English labels key their ids; shown translated). let buttons: &[(BarAction, &str, Icon)] = if chrome.pane { - &[(BarAction::Close, "Close preview", Icon::Close), (BarAction::Present, "Present", Icon::Play), (BarAction::Restart, "Restart", Icon::Reset)] + &[ + (BarAction::Close, tr_noop("Close preview"), Icon::Close), + (BarAction::Present, tr_noop("Present"), Icon::Play), + (BarAction::Restart, tr_noop("Restart"), Icon::Reset), + ] } else { &[ - (BarAction::Close, "Close", Icon::Close), - (BarAction::Restart, "Restart", Icon::Reset), - (BarAction::Options, "Options", Icon::Dots), - (BarAction::Comments, "Comments", Icon::Comment), - (BarAction::Inspect, "Inspect", Icon::Code), + (BarAction::Close, tr_noop("Close"), Icon::Close), + (BarAction::Restart, tr_noop("Restart"), Icon::Reset), + (BarAction::Options, tr_noop("Options"), Icon::Dots), + (BarAction::Comments, tr_noop("Comments"), Icon::Comment), + (BarAction::Inspect, tr_noop("Inspect"), Icon::Code), ] }; let mut x = bar.max.x - if chrome.pane { 6.0 } else { 8.0 }; for (act, label, icon) in buttons { - let w = if chrome.pane { 24.0 } else { ui.painter().layout_no_wrap(label.to_string(), regular(11.5), text).size().x + 16.0 }; + let shown = tr(label); + let w = if chrome.pane { 24.0 } else { ui.painter().layout_no_wrap(shown.to_string(), regular(11.5), text).size().x + 16.0 }; let br = Rect::from_min_size(pos2(x - w, bar.center().y - 12.0), vec2(w, 24.0)); x = br.min.x - if chrome.pane { 2.0 } else { 4.0 }; let mut resp = ui.interact(br, ui.id().with(("pbar", *label)), Sense::click()); if chrome.pane { - resp = resp.tip(*label); + resp = resp.tip(shown); } let on = (*act == BarAction::Comments && p.view.comments) || (*act == BarAction::Inspect && p.view.inspect); if on { @@ -1928,15 +1934,15 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome) ui.painter().rect_filled(br, CornerRadius::same(5), hover); } if *act == BarAction::Comments { - resp = resp.tip("Show comments (Shift+C)"); + resp = resp.tip(tr("Show comments (Shift+C)")); } if *act == BarAction::Inspect { - resp = resp.tip("Inspect the layer under the pointer: size, colours, typography"); + 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); } else { - ui.painter().text(br.center(), Align2::CENTER_CENTER, *label, regular(11.5), text); + ui.painter().text(br.center(), Align2::CENTER_CENTER, shown, regular(11.5), 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)); @@ -1952,8 +1958,8 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome) // flow with its description. let playing = flows.iter().find(|f| f.start == p.start); if !flows.is_empty() { - let label = format!("{} ▾", playing.map_or("Flows", |f| f.name.as_str())); - let tip = playing.map(|f| f.description.trim()).filter(|d| !d.is_empty()).unwrap_or("Flows").to_string(); + let label = format!("{} ▾", playing.map_or(tr("Flows"), |f| f.name.as_str())); + let tip = playing.map(|f| f.description.trim()).filter(|d| !d.is_empty()).unwrap_or(tr("Flows")).to_string(); let galley = left.layout_no_wrap(label, semibold(11.5), text); let fr = Rect::from_min_size(pos2(lx, bar.center().y - 12.0), vec2(galley.size().x + 16.0, 24.0)); let resp = ui.interact(fr.intersect(left.clip_rect()), ui.id().with("pflows"), Sense::click()).tip(tip); @@ -1993,7 +1999,7 @@ fn options_menu(p: &mut Presenter, ui: &mut Ui, device: Option, responsi let mut action = None; let on_device = device.is_some(); // Without a device, responsive is one of the sizes: the frame fills the window at 100%. - if !on_device && menu_check(ui, responsive, "Responsive", "") { + if !on_device && menu_check(ui, responsive, tr("Responsive"), "") { action = Some(BarAction::Responsive(true)); ui.close(); } @@ -2006,14 +2012,14 @@ fn options_menu(p: &mut Presenter, ui: &mut Ui, device: Option, responsi ui.close(); } } - if (on_device || !responsive) && menu_check(ui, false, "Next size option", "Z") { + if (on_device || !responsive) && menu_check(ui, false, tr("Next size option"), "Z") { p.set_scale(p.view.scale.next(on_device)); ui.close(); } ui.separator(); // On a device: the frame takes the screen's size, or keeps its own (clipped or scrolling). if on_device { - for (on, label) in [(true, "Responsive"), (false, "Fixed size")] { + for (on, label) in [(true, tr("Responsive")), (false, tr("Fixed size"))] { if menu_check(ui, responsive == on, label, "") { action = (responsive != on).then_some(BarAction::Responsive(on)); ui.close(); @@ -2021,15 +2027,15 @@ fn options_menu(p: &mut Presenter, ui: &mut Ui, device: Option, responsi } ui.separator(); } - if device.is_some_and(|d| !d.colors.is_empty()) && menu_check(ui, p.view.device_frame, "Show device frame", "") { + if device.is_some_and(|d| !d.colors.is_empty()) && menu_check(ui, p.view.device_frame, tr("Show device frame"), "") { p.view.device_frame = !p.view.device_frame; ui.close(); } - if menu_check(ui, p.view.hotspot_hints, "Show hotspot hints", "") { + if menu_check(ui, p.view.hotspot_hints, tr("Show hotspot hints"), "") { p.view.hotspot_hints = !p.view.hotspot_hints; ui.close(); } - if menu_check(ui, false, "Restart", "R") { + if menu_check(ui, false, tr("Restart"), "R") { p.restart(p.start, now); ui.close(); } diff --git a/crates/ui-egui/src/proto_editor.rs b/crates/ui-egui/src/proto_editor.rs index a8a0573..029a332 100644 --- a/crates/ui-egui/src/proto_editor.rs +++ b/crates/ui-egui/src/proto_editor.rs @@ -12,6 +12,7 @@ use serde_json::{Value, json}; use crate::FigApp; use crate::a11y::Tip; +use crate::i18n::{tr, tr_noop}; use crate::icons::{self, Icon}; use crate::list_drag::ListDrag; use crate::proto_panel::{action_fields, action_of, action_summary, targets_for, value_json}; @@ -90,17 +91,17 @@ const INDENT: f32 = 12.0; /// The kinds an action can be, as its menus list them (a separator before each in `GROUPS`). const ACTIONS: [&str; 11] = [ - "Navigate to", - "Change to", - "Back", - "Scroll to", - "Open link", - "Open overlay", - "Swap overlay", - "Close overlay", - "Set variable", - "Set variable mode", - "Conditional", + tr_noop("Navigate to"), + tr_noop("Change to"), + tr_noop("Back"), + tr_noop("Scroll to"), + tr_noop("Open link"), + tr_noop("Open overlay"), + tr_noop("Swap overlay"), + tr_noop("Close overlay"), + tr_noop("Set variable"), + tr_noop("Set variable mode"), + tr_noop("Conditional"), ]; const GROUPS: [usize; 5] = [2, 4, 5, 8, 10]; @@ -111,7 +112,7 @@ fn action_menu(ui: &mut Ui) -> Option<&'static str> { if GROUPS.contains(&i) { ui.separator(); } - if menu_item(ui, l, "") { + if menu_item(ui, tr(l), "") { picked = Some(*l); } } @@ -121,10 +122,10 @@ fn action_menu(ui: &mut Ui) -> Option<&'static str> { /// Why an action of kind `label` can't be made here. fn unavailable(label: &str) -> &'static str { match label { - "Change to" => "Change to needs a layer inside a variant", - "Set variable" => "Create a variable first", - "Set variable mode" => "Create a variable collection first", - _ => "There's no destination for that action", + "Change to" => tr("Change to needs a layer inside a variant"), + "Set variable" => tr("Create a variable first"), + "Set variable mode" => tr("Create a variable collection first"), + _ => tr("There's no destination for that action"), } } @@ -201,7 +202,7 @@ fn link_button(ui: &mut Ui, icon: Icon, label: &str) -> egui::Response { fn add_action_row(app: &mut FigApp, ui: &mut Ui, cx: &Cx, path: Vec) { row(ui, 28.0, |ui| { - let resp = link_button(ui, Icon::Plus, "Add action"); + let resp = link_button(ui, Icon::Plus, tr("Add action")); menu_popup(&resp).width(200.0).show(|ui| { if let Some(l) = action_menu(ui) { match action_of(l, cx.doc, cx.page, cx.node, None) { @@ -229,7 +230,7 @@ fn action_item(app: &mut FigApp, ui: &mut Ui, cx: &Cx, a: &Action, path: Vec 0 && menu_item(ui, "Move up", "") { + if i > 0 && menu_item(ui, tr("Move up"), "") { moved = Some(i - 1); } - if i + 1 < count && menu_item(ui, "Move down", "") { + if i + 1 < count && menu_item(ui, tr("Move down"), "") { moved = Some(i + 1); } if let Some(to) = moved @@ -289,7 +290,7 @@ fn action_item(app: &mut FigApp, ui: &mut Ui, cx: &Cx, a: &Action, path: Vec 1 { ui.separator(); } - if menu_item(ui, "Remove", "") { + if menu_item(ui, tr("Remove"), "") { if run_at(app, &slot, "proto.action.remove", json!({})).is_some() { set_open(ui.ctx(), cx, slot.path.get(..slot.path.len().saturating_sub(2)).unwrap_or_default().to_vec()); } @@ -315,9 +316,9 @@ fn conditional_blocks(app: &mut FigApp, ui: &mut Ui, cx: &Cx, slot: &Slot, block let t = Tokens::get(ui.ctx()); for (b, block) in blocks.iter().enumerate() { let label = match (&block.condition, b) { - (Some(_), 0) => "If", - (Some(_), _) => "Else if", - (None, _) => "Else", + (Some(_), 0) => tr("If"), + (Some(_), _) => tr("Else if"), + (None, _) => tr("Else"), }; block_header(app, ui, slot, b, label, block.condition.as_ref()); if let Some(c) = &block.condition { @@ -332,10 +333,10 @@ fn conditional_blocks(app: &mut FigApp, ui: &mut Ui, cx: &Cx, slot: &Slot, block } let has_else = blocks.last().is_some_and(|b| b.condition.is_none()); row(ui, 28.0, |ui| { - if link_button(ui, Icon::Plus, "Else if").on_hover_text("Add another condition").clicked() { + if link_button(ui, Icon::Plus, tr("Else if")).on_hover_text(tr("Add another condition")).clicked() { run_at(app, slot, "proto.conditional.addBlock", json!({})); } - if !has_else && link_button(ui, Icon::Plus, "Else").on_hover_text("Add actions for when no condition holds").clicked() { + if !has_else && link_button(ui, Icon::Plus, tr("Else")).on_hover_text(tr("Add actions for when no condition holds")).clicked() { run_at(app, slot, "proto.conditional.addBlock", json!({"else": true})); } }); @@ -357,15 +358,15 @@ fn block_header(app: &mut FigApp, ui: &mut Ui, slot: &Slot, b: usize, label: &st let builder = simple(c).is_some(); let text = !builder || ui.data(|d| d.get_temp::(id)).unwrap_or(false); let tip = match (builder, text) { - (false, _) => "This condition is edited as an expression", - (true, true) => "Edit with the builder", - (true, false) => "Edit as an expression", + (false, _) => tr("This condition is edited as an expression"), + (true, true) => tr("Edit with the builder"), + (true, false) => tr("Edit as an expression"), }; if icons::button(ui, Icon::Code, 24.0, text, tip).clicked() && builder { ui.data_mut(|d| d.insert_temp(id, !text)); } } - let what = if cond.is_some() { "Remove this condition and its actions" } else { "Remove the else and its actions" }; + let what = if cond.is_some() { tr("Remove this condition and its actions") } else { tr("Remove the else and its actions") }; if icons::button(ui, Icon::Minus, 24.0, false, what).clicked() { run_at(app, slot, "proto.conditional.removeBlock", json!({ "block": b })); } @@ -449,7 +450,7 @@ fn condition_editor(app: &mut FigApp, ui: &mut Ui, cx: &Cx, slot: &Slot, b: usiz Some(s) => builder_row(app, ui, cx, slot, b, &s), None => { let id = slot.id("proto_cond_expr").with(b); - if let Some(e) = expr_field(ui, id, vars, Some(c), "e.g. count >= 3 and not done", |e| e.check_condition(vars), None::) { + if let Some(e) = expr_field(ui, id, vars, Some(c), tr("e.g. count >= 3 and not done"), |e| e.check_condition(vars), None::) { set_condition(app, slot, b, &e); } } @@ -465,7 +466,7 @@ fn builder_row(app: &mut FigApp, ui: &mut Ui, cx: &Cx, slot: &Slot, b: usize, s: let op_w = 48.0; let side = ((w - op_w - 16.0) / 2.0).max(40.0); // The variable tested, from the variable picker. - let name = var.map_or("Missing variable", |v| v.name.as_str()); + let name = var.map_or(tr("Missing variable"), |v| v.name.as_str()); let shown = widgets::elide(ui, name, side - 48.0); let resp = widgets::dropdown(ui, &shown, side, Some(Icon::Variable)).on_hover_text(name); if resp.clicked() { @@ -486,7 +487,7 @@ fn builder_row(app: &mut FigApp, ui: &mut Ui, cx: &Cx, slot: &Slot, b: usize, s: Rhs::Nothing => {} Rhs::Variable(other) => { let (rect, _) = ui.allocate_exact_size(vec2(side, FIELD_H), Sense::hover()); - let name = vars.get(other).map_or("Missing variable", |v| v.name.as_str()); + let name = vars.get(other).map_or(tr("Missing variable"), |v| v.name.as_str()); match widgets::bound_field(ui, slot.id("proto_cond_rhs_var").with(b), Lead::None, rect, name) { Some(BindEvent::Open(at)) => crate::variable_ui::open_proto(ui, VarSlot { slot: slot.clone(), part: VarPart::Rhs(b) }, kind, at), Some(BindEvent::Detach) => { @@ -538,7 +539,7 @@ fn literal_field(ui: &mut Ui, id: Id, cur: &VariableValue, w: f32) -> Option widgets::text_field(ui, id, s, w, "Text").filter(|n| n != s).map(VariableValue::String), + VariableValue::String(s) => widgets::text_field(ui, id, s, w, tr("Text")).filter(|n| n != s).map(VariableValue::String), VariableValue::Alias(_) => None, } } @@ -548,9 +549,9 @@ fn literal_field(ui: &mut Ui, id: Id, cur: &VariableValue, w: f32) -> Option) { let vars = &cx.doc.variables; let var = vars.get(variable); - widgets::sublabels(ui, "Variable", None); + widgets::sublabels(ui, tr("Variable"), None); row(ui, 32.0, |ui| { - let name = var.map_or("Choose variable", |v| v.name.as_str()); + let name = var.map_or(tr("Choose variable"), |v| v.name.as_str()); let w = ui.available_width(); let shown = widgets::elide(ui, name, w - 48.0); let resp = widgets::dropdown(ui, &shown, w, Some(Icon::Variable)); @@ -564,10 +565,10 @@ fn set_variable_fields(app: &mut FigApp, ui: &mut Ui, cx: &Cx, slot: &Slot, vari // The toggle between a fixed value and an expression ends the value's row. let toggle = |ui: &mut Ui| { let on = expr.is_some(); - let tip = if on { "Use a fixed value" } else { "Use an expression, e.g. count + 1" }; + let tip = if on { tr("Use a fixed value") } else { tr("Use an expression, e.g. count + 1") }; icons::button(ui, Icon::Code, 24.0, on, tip).clicked() }; - widgets::sublabels(ui, if expr.is_some() { "Expression" } else { "Value" }, None); + widgets::sublabels(ui, if expr.is_some() { tr("Expression") } else { tr("Value") }, None); let mut toggled = false; match expr { Some(e) => { @@ -576,7 +577,7 @@ fn set_variable_fields(app: &mut FigApp, ui: &mut Ui, cx: &Cx, slot: &Slot, vari slot.id("proto_set_expr"), vars, Some(e), - "e.g. count + 1", + tr("e.g. count + 1"), |e| e.check_value(vars, var.kind), Some(|ui: &mut Ui| { toggled = toggle(ui); diff --git a/crates/ui-egui/src/proto_panel.rs b/crates/ui-egui/src/proto_panel.rs index 2c86c75..20b2097 100644 --- a/crates/ui-egui/src/proto_panel.rs +++ b/crates/ui-egui/src/proto_panel.rs @@ -10,6 +10,7 @@ use serde_json::{Value, json}; use crate::FigApp; use crate::a11y::Tip; +use crate::i18n::{tr, tr_noop, trf}; use crate::icons::{self, Icon}; use crate::proto_editor::{Cx, Slot, set_action}; use crate::right::{menu_item, menu_popup, row}; @@ -36,8 +37,18 @@ pub fn editor(ctx: &egui::Context) -> Option<(NodeId, usize)> { ctx.data(|d| d.get_temp(editor_id())) } -const TRIGGERS: [&str; 10] = - ["On click", "On drag", "While hovering", "While pressing", "Key/Gamepad", "Mouse enter", "Mouse leave", "Mouse down", "Mouse up", "After delay"]; +const TRIGGERS: [&str; 10] = [ + tr_noop("On click"), + tr_noop("On drag"), + tr_noop("While hovering"), + tr_noop("While pressing"), + tr_noop("Key/Gamepad"), + tr_noop("Mouse enter"), + tr_noop("Mouse leave"), + tr_noop("Mouse down"), + tr_noop("Mouse up"), + tr_noop("After delay"), +]; fn trigger_kind(t: &Trigger) -> &'static str { match t { @@ -78,12 +89,23 @@ fn trigger_of(kind: &str, cur: &Trigger) -> Trigger { /// The trigger as the list shows it ("Key/Gamepad Space", "After delay 800ms"). fn trigger_summary(t: &Trigger) -> String { match t { - Trigger::KeyPress { key } if key.is_empty() => "Key/Gamepad".into(), - other => other.label(), + Trigger::KeyPress { key } if key.is_empty() => tr("Key/Gamepad").into(), + Trigger::KeyPress { key } => trf("Key/Gamepad {key}", &[("key", key)]), + Trigger::AfterDelay { ms } => trf("After delay {ms}ms", &[("ms", ms)]), + other => tr(trigger_kind(other)).into(), } } -const TRANSITIONS: [&str; 8] = ["Instant", "Dissolve", "Smart animate", "Move in", "Move out", "Push", "Slide in", "Slide out"]; +const TRANSITIONS: [&str; 8] = [ + tr_noop("Instant"), + tr_noop("Dissolve"), + tr_noop("Smart animate"), + tr_noop("Move in"), + tr_noop("Move out"), + tr_noop("Push"), + tr_noop("Slide in"), + tr_noop("Slide out"), +]; fn transition_of(label: &str, cur: Transition) -> Transition { let d = cur.direction().unwrap_or_default(); @@ -129,33 +151,33 @@ fn nav_targets(doc: &Document, page: NodeId) -> Vec { } fn name_of(doc: &Document, id: NodeId) -> String { - doc.node(id).map(|n| n.name.clone()).unwrap_or_else(|| "Missing layer".into()) + doc.node(id).map(|n| n.name.clone()).unwrap_or_else(|| tr("Missing layer").into()) } /// One-line action summary ("Navigate to Frame 2"). pub(crate) fn action_summary(doc: &Document, a: &Action) -> String { let vars = &doc.variables; match a { - Action::OpenUrl { url } if !url.is_empty() => format!("Open link {url}"), + Action::OpenUrl { url } if !url.is_empty() => trf("Open link {url}", &[("url", url)]), Action::SetVariable { variable, expr, .. } => { let name = vars.get(variable).map_or(variable.as_str(), |v| v.name.as_str()); match expr { - Some(e) => format!("Set {name} to {}", e.display(vars)), - None => format!("Set variable {name}"), + Some(e) => trf("Set {name} to {value}", &[("name", &name), ("value", &e.display(vars))]), + None => trf("Set variable {name}", &[("name", &name)]), } } Action::SetVariableMode { collection, mode } => { let c = vars.collection(collection); let mode = c.and_then(|c| c.modes.iter().find(|m| &m.id == mode)).map_or(mode.as_str(), |m| m.name.as_str()); - format!("Set {} to {mode}", c.map_or(collection.as_str(), |c| c.name.as_str())) + trf("Set {name} to {value}", &[("name", &c.map_or(collection.as_str(), |c| c.name.as_str())), ("value", &mode)]) } Action::Conditional { blocks } => match blocks.first().and_then(|b| b.condition.as_ref()) { - Some(c) => format!("If {}", c.display(vars)), - None => a.label().to_string(), + Some(c) => trf("If {condition}", &[("condition", &c.display(vars))]), + None => tr(a.label()).to_string(), }, other => match other.dest() { - Some(d) => format!("{} {}", other.label(), name_of(doc, d)), - None => other.label().to_string(), + Some(d) => format!("{} {}", tr(other.label()), name_of(doc, d)), + None => tr(other.label()).to_string(), }, } } @@ -272,7 +294,7 @@ pub fn prototype(app: &mut FigApp, ui: &mut Ui) { /// (one undo step); "Remove all" clears them. fn mixed_interactions(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, sel: &[NodeId]) { let t = Tokens::get(ui.ctx()); - if widgets::section_header(ui, "Interactions", &[(Icon::Plus, "Replace with a new interaction")], false).is_some() { + if widgets::section_header(ui, tr("Interactions"), &[(Icon::Plus, tr("Replace with a new interaction"))], false).is_some() { let first = sel.first().copied().unwrap_or(NodeId(0)); let action = action_of("Navigate to", doc, page, first, None).unwrap_or(Action::Back); let steps = json!([ @@ -283,10 +305,10 @@ fn mixed_interactions(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeI open_editor(ui.ctx(), Some((first, 0))); } row(ui, 32.0, |ui| { - widgets::label(ui, "Mixed interactions: click + to replace them", t.text_secondary); + widgets::label(ui, tr("Mixed interactions: click + to replace them"), t.text_secondary); }); row(ui, 28.0, |ui| { - if ui.add(egui::Button::new(egui::RichText::new("Remove all").font(regular(11.0)))).clicked() { + if ui.add(egui::Button::new(egui::RichText::new(tr("Remove all")).font(regular(11.0)))).clicked() { app.run("proto.interaction.clear", json!({"ids": sel})); } }); @@ -316,17 +338,17 @@ fn flow_row(app: &mut FigApp, ui: &mut Ui, f: &proto::Flow) { let (r, _) = ui.allocate_exact_size(vec2(w, 32.0), Sense::hover()); let button = |dx: f32| Rect::from_center_size(pos2(dx, r.center().y), vec2(24.0, 24.0)); let play = button(r.min.x + PAD + 8.0); - if icon_button(ui, Id::new(("flow_play", f.start)), play, Icon::Play, "Present this flow") { + if icon_button(ui, Id::new(("flow_play", f.start)), play, Icon::Play, tr("Present this flow")) { app.run("proto.present", json!({"start": f.start})); } let name_rect = Rect::from_min_max(pos2(play.max.x + 6.0, r.center().y - 9.0), pos2(r.max.x - 64.0, r.center().y + 9.0)); if let Some(n) = widgets::text_field_inline(ui, Id::new(("flow_name", f.start)), &f.name, name_rect, regular(11.0)) { app.run("proto.flow.rename", json!({"start": f.start, "name": n})); } - if icon_button(ui, Id::new(("flow_preview", f.start)), button(r.max.x - 44.0), Icon::Preview, "Preview this flow") { + if icon_button(ui, Id::new(("flow_preview", f.start)), button(r.max.x - 44.0), Icon::Preview, tr("Preview this flow")) { app.run("proto.preview", json!({"start": f.start})); } - if icon_button(ui, Id::new(("flow_rm", f.start)), button(r.max.x - 20.0), Icon::Minus, "Remove starting point") { + if icon_button(ui, Id::new(("flow_rm", f.start)), button(r.max.x - 20.0), Icon::Minus, tr("Remove starting point")) { app.run("proto.flow.remove", json!({"start": f.start})); } } @@ -334,17 +356,17 @@ fn flow_row(app: &mut FigApp, ui: &mut Ui, f: &proto::Flow) { fn flows_overview(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { let t = Tokens::get(ui.ctx()); let flows = flows_of(doc, page); - if widgets::section_header(ui, "Flow starting points", &[(Icon::Plus, "Add starting point")], flows.is_empty()).is_some() { + if widgets::section_header(ui, tr("Flow starting points"), &[(Icon::Plus, tr("Add starting point"))], flows.is_empty()).is_some() { let free = doc.top_frames(page).into_iter().find(|f| !flows.iter().any(|x| x.start == *f)); match free { Some(f) => { app.run("proto.flow.add", json!({"start": f})); } - None => app.toast("Every top-level frame already starts a flow"), + None => app.toast(tr("Every top-level frame already starts a flow")), } } if flows.is_empty() { - row(ui, 36.0, |ui| widgets::label(ui, "Select a frame and add a starting point to present it as a flow.", t.text_secondary)); + widgets::note(ui, tr("Select a frame and add a starting point to present it as a flow."), t.text_secondary); } for f in &flows { flow_row(app, ui, f); @@ -365,7 +387,7 @@ fn flows_overview(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { } fn settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { - if widgets::section_header(ui, "Prototype settings", &[(Icon::Preview, "Open prototype preview")], false).is_some() { + if widgets::section_header(ui, tr("Prototype settings"), &[(Icon::Preview, tr("Open prototype preview"))], false).is_some() { app.run("proto.preview", json!({})); } let settings = match doc.node(page).map(|n| &n.kind) { @@ -377,13 +399,16 @@ fn settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { // Phones and tablets turn: a toggle beside the device picker. let rotates = model.is_some_and(|d| d.rotates); let landscape = settings.landscape(); - widgets::sublabels(ui, "Device", None); + widgets::sublabels(ui, tr("Device"), None); row(ui, 32.0, |ui| { let w = ui.available_width() - if rotates { 32.0 } else { 0.0 }; - let resp = widgets::dropdown(ui, &settings.device, w, None); + // Device names stay as they are; "None" and the custom size are ours. + let custom = proto::PrototypeSettings::CUSTOM_DEVICE; + let shown = if settings.device == "None" || settings.device == custom { tr(&settings.device) } else { settings.device.as_str() }; + let resp = widgets::dropdown(ui, shown, w, None); menu_popup(&resp).show(|ui| { let mut pick = None; - if crate::right::menu_check(ui, settings.device == "None", "None", "") { + if crate::right::menu_check(ui, settings.device == "None", tr("None"), "") { pick = Some("None"); } ui.separator(); @@ -393,8 +418,7 @@ fn settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { } } ui.separator(); - let custom = proto::PrototypeSettings::CUSTOM_DEVICE; - if crate::right::menu_check(ui, settings.device == custom, custom, "") { + if crate::right::menu_check(ui, settings.device == custom, tr(custom), "") { pick = Some(custom); } if let Some(d) = pick { @@ -402,7 +426,7 @@ fn settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { ui.close(); } }); - let tip = if landscape { "Rotate to portrait" } else { "Rotate to landscape" }; + let tip = if landscape { tr("Rotate to portrait") } else { tr("Rotate to landscape") }; if rotates && icons::button(ui, Icon::Rotate, 24.0, landscape, tip).clicked() { app.run("proto.settings", json!({"rotation": if landscape { "portrait" } else { "landscape" }})); } @@ -414,13 +438,13 @@ fn settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { custom_size(app, ui, model); } row(ui, 32.0, |ui| { - let clicked = widgets::checkbox(ui, settings.responsive, "Responsive"); - ui.interact(ui.min_rect(), Id::new("proto_responsive_tip"), Sense::hover()).on_hover_text(RESPONSIVE_TIP); + let clicked = widgets::checkbox(ui, settings.responsive, tr("Responsive")); + ui.interact(ui.min_rect(), Id::new("proto_responsive_tip"), Sense::hover()).on_hover_text(tr(RESPONSIVE_TIP)); if clicked { app.run("proto.settings", json!({"responsive": !settings.responsive})); } }); - widgets::sublabels(ui, "Background", None); + widgets::sublabels(ui, tr("Background"), None); row(ui, 32.0, |ui| { widgets::swatch(ui, &Paint::solid(bg), 16.0); if let Some(h) = widgets::text_field(ui, Id::new("proto_bg"), &bg.to_hex(), 96.0, "") @@ -432,7 +456,8 @@ fn settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { } /// What "Responsive" does, on hover. -const RESPONSIVE_TIP: &str = "Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond"; +const RESPONSIVE_TIP: &str = + tr_noop("Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond"); /// A device colour as a swatch paint. fn color_paint(c: proto::DeviceColor) -> Paint { @@ -442,7 +467,7 @@ fn color_paint(c: proto::DeviceColor) -> Paint { /// The colours the device comes in: its swatch and a picker (each colour with its swatch). fn device_colors(app: &mut FigApp, ui: &mut Ui, colors: &[proto::DeviceColor], current: Option) { - widgets::sublabels(ui, "Color", None); + widgets::sublabels(ui, tr("Color"), None); row(ui, 32.0, |ui| { if let Some(c) = current { widgets::swatch(ui, &color_paint(c), 16.0); @@ -470,7 +495,7 @@ fn device_colors(app: &mut FigApp, ui: &mut Ui, colors: &[proto::DeviceColor], c fn custom_size(app: &mut FigApp, ui: &mut Ui, model: Option) { let Some(d) = model else { return }; let col = widgets::col_width(ui.available_width()); - widgets::sublabels(ui, "Size", None); + widgets::sublabels(ui, tr("Size"), None); row(ui, 32.0, |ui| { for (key, lead, v) in [("width", "W", d.width), ("height", "H", d.height)] { if let Some(FieldEdit::Set(x) | FieldEdit::Scrub(x, true)) = @@ -481,7 +506,7 @@ fn custom_size(app: &mut FigApp, ui: &mut Ui, model: Option) { app.run("proto.settings", json!({ key: x })); } } - if icons::button(ui, Icon::Rotate, 24.0, false, "Swap width and height").clicked() && d.width != d.height { + if icons::button(ui, Icon::Rotate, 24.0, false, tr("Swap width and height")).clicked() && d.width != d.height { app.run("proto.settings", json!({"width": d.height, "height": d.width})); } }); @@ -491,8 +516,9 @@ fn flow_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, fra let flows = flows_of(doc, page); let mine = flows.iter().find(|f| f.start == frame).cloned(); // A flow's row has its own preview button. - let buttons: &[(Icon, &str)] = if mine.is_none() { &[(Icon::Preview, "Preview this frame"), (Icon::Plus, "Add starting point")] } else { &[] }; - match widgets::section_header(ui, "Flow starting point", buttons, mine.is_none()) { + let buttons: &[(Icon, &str)] = + if mine.is_none() { &[(Icon::Preview, tr("Preview this frame")), (Icon::Plus, tr("Add starting point"))] } else { &[] }; + match widgets::section_header(ui, tr("Flow starting point"), buttons, mine.is_none()) { Some(0) => { app.run("proto.preview", json!({"start": frame})); } @@ -504,7 +530,7 @@ fn flow_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, fra if let Some(f) = mine { flow_row(app, ui, &f); row(ui, 32.0, |ui| { - if let Some(d) = widgets::text_field(ui, Id::new(("flow_desc", frame)), &f.description, ui.available_width(), "Add a description") + if let Some(d) = widgets::text_field(ui, Id::new(("flow_desc", frame)), &f.description, ui.available_width(), tr("Add a description")) && d != f.description { app.run("proto.flow.rename", json!({"start": frame, "description": d})); @@ -517,7 +543,7 @@ fn interactions(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, nod let t = Tokens::get(ui.ctx()); let id = node.id; let list = node.interactions.clone(); - if widgets::section_header(ui, "Interactions", &[(Icon::Plus, "Add interaction")], list.is_empty()).is_some() { + if widgets::section_header(ui, tr("Interactions"), &[(Icon::Plus, tr("Add interaction"))], list.is_empty()).is_some() { // Click → navigate to the next frame (or Back when there's nowhere to go). let action = action_of("Navigate to", doc, page, id, None).unwrap_or(Action::Back); if let Some(r) = app.run("proto.interaction.add", json!({"ids": targets_for(app, id), "trigger": {"type": "onClick"}, "action": action})) { @@ -552,7 +578,7 @@ fn interactions(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, nod open_editor(ui.ctx(), if active { None } else { Some((id, i)) }); } let rm = Rect::from_center_size(pos2(r.max.x - 20.0, r.center().y), vec2(24.0, 24.0)); - if icon_button(ui, ui.id().with(("proto_rm", id, i)), rm, Icon::Minus, "Remove interaction") { + if icon_button(ui, ui.id().with(("proto_rm", id, i)), rm, Icon::Minus, tr("Remove interaction")) { open_editor(ui.ctx(), None); app.run("proto.interaction.remove", json!({"ids": targets_for(app, id), "index": i})); return; @@ -591,9 +617,9 @@ fn editor_popover(app: &mut FigApp, ctx: &egui::Context, doc: &Document, page: N ui.spacing_mut().item_spacing = vec2(8.0, 0.0); // Header. let (hr, _) = ui.allocate_exact_size(vec2(width, 40.0), Sense::hover()); - ui.painter().text(pos2(hr.min.x + PAD, hr.center().y), Align2::LEFT_CENTER, "Interaction", semibold(11.0), t.text); + ui.painter().text(pos2(hr.min.x + PAD, hr.center().y), Align2::LEFT_CENTER, tr("Interaction"), semibold(11.0), t.text); let x = Rect::from_center_size(pos2(hr.max.x - 20.0, hr.center().y), vec2(24.0, 24.0)); - let xr = ui.interact(x, ui.id().with("proto_editor_close"), Sense::click()).tip("Close"); + let xr = ui.interact(x, ui.id().with("proto_editor_close"), Sense::click()).tip(tr("Close")); if xr.hovered() { ui.painter().rect_filled(x, CornerRadius::same(5), t.hover); } @@ -608,7 +634,7 @@ fn editor_popover(app: &mut FigApp, ctx: &egui::Context, doc: &Document, page: N egui::ScrollArea::vertical().auto_shrink([false, true]).max_height(max_h).min_scrolled_height(max_h).show(ui, |ui| { trigger_rows(app, ui, node, k, it); widgets::separator(ui); - widgets::sublabels(ui, if it.actions.len() > 1 { "Actions" } else { "Action" }, None); + widgets::sublabels(ui, if it.actions.len() > 1 { tr("Actions") } else { tr("Action") }, None); let cx = Cx { doc, page, node, k }; crate::proto_editor::action_list(app, ui, &cx, &it.actions, &[]); if figcraft_doc::proto::walk(&it.actions).any(animates) { @@ -643,18 +669,18 @@ fn capturing(ctx: &egui::Context) -> bool { fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Interaction) { let t = Tokens::get(ui.ctx()); - widgets::sublabels(ui, "Trigger", None); + widgets::sublabels(ui, tr("Trigger"), None); row(ui, 32.0, |ui| { - let resp = widgets::dropdown(ui, trigger_kind(&it.trigger), ui.available_width(), None); + let resp = widgets::dropdown(ui, tr(trigger_kind(&it.trigger)), ui.available_width(), None); menu_popup(&resp).show(|ui| { for (i, l) in TRIGGERS.iter().enumerate() { if i == 4 || i == 5 || i == 9 { ui.separator(); } - if menu_item(ui, l, "") { - let tr = trigger_of(l, &it.trigger); - patch(app, node, k, json!({"trigger": tr})); - if matches!(tr, Trigger::KeyPress { ref key } if key.is_empty()) { + if menu_item(ui, tr(l), "") { + let next = trigger_of(l, &it.trigger); + patch(app, node, k, json!({"trigger": next})); + if matches!(next, Trigger::KeyPress { ref key } if key.is_empty()) { ui.ctx().data_mut(|d| d.insert_temp(capture_id(), true)); } ui.close(); @@ -665,7 +691,7 @@ fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Inte match &it.trigger { Trigger::AfterDelay { ms } => { row(ui, 32.0, |ui| { - widgets::label(ui, "Delay", t.text_secondary); + widgets::label(ui, tr("Delay"), t.text_secondary); if let Some(e) = widgets::number_field(ui, Id::new(("proto_delay", node, k)), Lead::None, Some(f64::from(*ms)), "ms", 96.0, 10.0) { let v = match e { FieldEdit::Set(v) | FieldEdit::Scrub(v, _) => v, @@ -680,9 +706,9 @@ fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Inte row(ui, 32.0, |ui| { let cap = capturing(ui.ctx()); let label = if cap { - "Press a key…".to_string() + tr("Press a key…").to_string() } else if key.is_empty() { - "Click to record a key".to_string() + tr("Click to record a key").to_string() } else { key.clone() }; @@ -740,7 +766,7 @@ fn dest_dropdown( ) { widgets::sublabels(ui, label, None); row(ui, 32.0, |ui| { - let text = cur.map(|d| name_of(doc, d)).unwrap_or_else(|| "None".into()); + let text = cur.map(|d| name_of(doc, d)).unwrap_or_else(|| tr("None").into()); let resp = widgets::dropdown(ui, &text, ui.available_width(), Some(Icon::Frame)); menu_popup(&resp).show(|ui| { egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { @@ -766,24 +792,24 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: Action::Navigate { dest, preserve_scroll } => { let (dest, preserve_scroll) = (*dest, *preserve_scroll); let mk = |d| Action::Navigate { dest: d, preserve_scroll }; - dest_dropdown(app, ui, doc, &nav_targets(doc, page), Some(dest), "Destination", mk, slot); + dest_dropdown(app, ui, doc, &nav_targets(doc, page), Some(dest), tr("Destination"), mk, slot); row(ui, 28.0, |ui| { - if widgets::checkbox(ui, preserve_scroll, "Preserve scroll position") { + if widgets::checkbox(ui, preserve_scroll, tr("Preserve scroll position")) { set_action(app, slot, Action::Navigate { dest, preserve_scroll: !preserve_scroll }); } }); } - Action::SwapOverlay { dest } => dest_dropdown(app, ui, doc, &frames, Some(*dest), "Overlay", |d| Action::SwapOverlay { dest: d }, slot), + Action::SwapOverlay { dest } => dest_dropdown(app, ui, doc, &frames, Some(*dest), tr("Overlay"), |d| Action::SwapOverlay { dest: d }, slot), Action::ChangeTo { dest } => { let vs = variants_for(doc, node); - dest_dropdown(app, ui, doc, &vs, Some(*dest), "Variant", |d| Action::ChangeTo { dest: d }, slot); + dest_dropdown(app, ui, doc, &vs, Some(*dest), tr("Variant"), |d| Action::ChangeTo { dest: d }, slot); } Action::ScrollTo { dest } => { let layers: Vec = doc.top_level_frame(node).map(|f| doc.subtree(f).into_iter().skip(1).take(400).collect()).unwrap_or_default(); - dest_dropdown(app, ui, doc, &layers, Some(*dest), "Scroll to layer", |d| Action::ScrollTo { dest: d }, slot); + dest_dropdown(app, ui, doc, &layers, Some(*dest), tr("Scroll to layer"), |d| Action::ScrollTo { dest: d }, slot); } Action::OpenUrl { url } => { - widgets::sublabels(ui, "Link", None); + widgets::sublabels(ui, tr("Link"), None); row(ui, 32.0, |ui| { if let Some(u) = widgets::text_field(ui, slot.id("proto_url"), url, ui.available_width(), "https://") && &u != url @@ -795,13 +821,13 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: Action::OpenOverlay { dest, position, close_on_outside, background } => { let (dest, position, close_on_outside, background) = (*dest, *position, *close_on_outside, *background); let mk = move |d: NodeId| Action::OpenOverlay { dest: d, position, close_on_outside, background }; - dest_dropdown(app, ui, doc, &frames, Some(dest), "Overlay", mk, slot); - widgets::sublabels(ui, "Overlay position", None); + dest_dropdown(app, ui, doc, &frames, Some(dest), tr("Overlay"), mk, slot); + widgets::sublabels(ui, tr("Overlay position"), None); row(ui, 32.0, |ui| { - let resp = widgets::dropdown(ui, position.label(), ui.available_width(), None); + let resp = widgets::dropdown(ui, tr(position.label()), ui.available_width(), None); menu_popup(&resp).show(|ui| { for p in OverlayPosition::ALL { - if menu_item(ui, p.label(), "") { + if menu_item(ui, tr(p.label()), "") { set_action(app, slot, Action::OpenOverlay { dest, position: p, close_on_outside, background }); ui.close(); } @@ -809,12 +835,12 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: }); }); row(ui, 28.0, |ui| { - if widgets::checkbox(ui, close_on_outside, "Close when clicking outside") { + if widgets::checkbox(ui, close_on_outside, tr("Close when clicking outside")) { set_action(app, slot, Action::OpenOverlay { dest, position, close_on_outside: !close_on_outside, background }); } }); row(ui, 28.0, |ui| { - if widgets::checkbox(ui, background.is_some(), "Add background behind overlay") { + if widgets::checkbox(ui, background.is_some(), tr("Add background behind overlay")) { let bg = if background.is_some() { None } else { Some(figcraft_color::Color::new(0.0, 0.0, 0.0, 0.25)) }; set_action(app, slot, Action::OpenOverlay { dest, position, close_on_outside, background: bg }); } @@ -843,9 +869,9 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: Action::SetVariableMode { collection, mode } => { let colls = &doc.variables.collections; let current = doc.variables.collection(collection); - widgets::sublabels(ui, "Collection", None); + widgets::sublabels(ui, tr("Collection"), None); row(ui, 32.0, |ui| { - let resp = widgets::dropdown(ui, current.map_or("Choose collection", |c| c.name.as_str()), ui.available_width(), None); + let resp = widgets::dropdown(ui, current.map_or(tr("Choose collection"), |c| c.name.as_str()), ui.available_width(), None); menu_popup(&resp).show(|ui| { for c in colls { if menu_item(ui, &c.name, "") @@ -857,10 +883,10 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: } }); }); - widgets::sublabels(ui, "Mode", None); + widgets::sublabels(ui, tr("Mode"), None); row(ui, 32.0, |ui| { let modes = current.map(|c| c.modes.as_slice()).unwrap_or_default(); - let name = modes.iter().find(|m| &m.id == mode).map_or("Choose mode", |m| m.name.as_str()); + let name = modes.iter().find(|m| &m.id == mode).map_or(tr("Choose mode"), |m| m.name.as_str()); let resp = widgets::dropdown(ui, name, ui.available_width(), None); menu_popup(&resp).show(|ui| { for m in modes { @@ -879,15 +905,15 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: fn animation_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Interaction) { let t = Tokens::get(ui.ctx()); - widgets::sublabels(ui, "Animation", None); + widgets::sublabels(ui, tr("Animation"), None); row(ui, 32.0, |ui| { - let resp = widgets::dropdown(ui, it.transition.label(), ui.available_width(), None); + let resp = widgets::dropdown(ui, tr(it.transition.label()), ui.available_width(), None); menu_popup(&resp).show(|ui| { for (i, l) in TRANSITIONS.iter().enumerate() { if i == 3 { ui.separator(); } - if menu_item(ui, l, "") { + if menu_item(ui, tr(l), "") { patch(app, node, k, json!({"transition": transition_of(l, it.transition)})); ui.close(); } @@ -897,7 +923,8 @@ fn animation_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &In if let Some(d) = it.transition.direction() { row(ui, 32.0, |ui| { let dirs = [Direction::Left, Direction::Right, Direction::Top, Direction::Bottom]; - let items = [(Icon::ChevronLeft, "Left"), (Icon::ChevronRight, "Right"), (Icon::ChevronUp, "Up"), (Icon::Chevron, "Down")]; + let items = + [(Icon::ChevronLeft, tr("Left")), (Icon::ChevronRight, tr("Right")), (Icon::ChevronUp, tr("Up")), (Icon::Chevron, tr("Down"))]; let active = dirs.iter().position(|x| *x == d); if let Some(i) = widgets::segmented(ui, &items, active, 32.0) && let Some(nd) = dirs.get(i) @@ -908,7 +935,7 @@ fn animation_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &In } if !matches!(it.transition, Transition::Instant) { row(ui, 32.0, |ui| { - let resp = widgets::dropdown(ui, it.easing.label(), ui.available_width() - 80.0, None); + let resp = widgets::dropdown(ui, tr(it.easing.label()), ui.available_width() - 80.0, None); menu_popup(&resp).width(220.0).show(|ui| { let custom = [Easing::CUSTOM_BEZIER, Easing::CUSTOM_SPRING]; for (i, e) in Easing::ALL.iter().chain(custom.iter()).enumerate() { @@ -922,7 +949,7 @@ fn animation_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &In } 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); - ui.painter().text(pos2(r.min.x + 42.0, r.center().y), Align2::LEFT_CENTER, e.label(), regular(11.5), col); + ui.painter().text(pos2(r.min.x + 42.0, r.center().y), Align2::LEFT_CENTER, tr(e.label()), regular(11.5), col); if rr.clicked() { // Keep the current custom values when re-picking the same custom kind. let same_kind = std::mem::discriminant(e) == std::mem::discriminant(&it.easing); @@ -1023,7 +1050,7 @@ fn custom_easing_fields(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, e } }); if count == 3 { - widgets::sublabels(ui, "Mass · Stiffness · Damping", None); + widgets::sublabels(ui, tr("Mass · Stiffness · Damping"), None); } } @@ -1066,18 +1093,18 @@ fn scroll_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], if !in_frame && !is_frame { return; } - widgets::section_header(ui, "Scroll behavior", &[], false); + widgets::section_header(ui, tr("Scroll behavior"), &[], false); if in_frame { row(ui, 32.0, |ui| { - widgets::label(ui, "Position", t.text_secondary); + widgets::label(ui, tr("Position"), t.text_secondary); let cur = match node.scroll_position { - ScrollPosition::Scrolls => "Scroll with parent", - ScrollPosition::Fixed => "Fixed", - ScrollPosition::Sticky => "Sticky", + ScrollPosition::Scrolls => tr("Scroll with parent"), + ScrollPosition::Fixed => tr("Fixed"), + ScrollPosition::Sticky => tr("Sticky"), }; let resp = widgets::dropdown(ui, cur, ui.available_width(), None); menu_popup(&resp).show(|ui| { - for (l, v) in [("Scroll with parent", "scrolls"), ("Fixed", "fixed"), ("Sticky", "sticky")] { + for (l, v) in [(tr("Scroll with parent"), "scrolls"), (tr("Fixed"), "fixed"), (tr("Sticky"), "sticky")] { if menu_item(ui, l, "") { app.set_props(sel, json!({"scrollPosition": v})); ui.close(); @@ -1088,16 +1115,18 @@ fn scroll_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], } if is_frame { row(ui, 32.0, |ui| { - widgets::label(ui, "Overflow", t.text_secondary); + widgets::label(ui, tr("Overflow"), t.text_secondary); let cur = match node.overflow { - Overflow::None => "No scrolling", - Overflow::Horizontal => "Horizontal", - Overflow::Vertical => "Vertical", - Overflow::Both => "Both directions", + Overflow::None => tr("No scrolling"), + Overflow::Horizontal => tr("Horizontal"), + Overflow::Vertical => tr("Vertical"), + Overflow::Both => tr("Both directions"), }; let resp = widgets::dropdown(ui, cur, ui.available_width(), None); menu_popup(&resp).show(|ui| { - for (l, v) in [("No scrolling", "none"), ("Horizontal", "horizontal"), ("Vertical", "vertical"), ("Both directions", "both")] { + for (l, v) in + [(tr("No scrolling"), "none"), (tr("Horizontal"), "horizontal"), (tr("Vertical"), "vertical"), (tr("Both directions"), "both")] + { if menu_item(ui, l, "") { app.set_props(sel, json!({"overflow": v})); ui.close(); diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 3809e8e..b7e2534 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -7,6 +7,7 @@ use figcraft_engine::cmd::lists::moved_index; use serde_json::{Value, json}; use crate::a11y::{self, Tip}; +use crate::i18n::{tr, trf, trn}; use crate::icons::{self, Icon}; use crate::list_drag::{ListDrag, move_flags}; use crate::style_picker::{self, StyleSlot}; @@ -61,18 +62,21 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { let w = ui.available_width(); // 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()); - let share = Rect::from_min_size(pos2(top.max.x - 70.0, top.center().y - 16.0), vec2(56.0, 32.0)); - let sresp = ui.interact(share, ui.id().with("share"), Sense::click()).tip("Share: live session, export"); + // 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 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().text(share.center(), Align2::CENTER_CENTER, "Share", semibold(11.5), Color32::WHITE); + ui.painter().galley(share.center() - label.size() / 2.0, label, Color32::WHITE); 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)); let (tri_r, chev_r) = (Rect::from_min_max(play.min, pos2(play.max.x - 16.0, play.max.y)), Rect::from_min_max(pos2(play.max.x - 16.0, play.min.y), play.max)); let present_keys = crate::toolbar::pretty_shortcut(crate::shortcuts::shortcut("proto.present").unwrap_or("")); - let presp = ui.interact(tri_r, ui.id().with("play"), Sense::click()).tip(format!("Present {present_keys}")); - let mresp = ui.interact(chev_r, ui.id().with("play_menu"), Sense::click()).tip("Present or preview"); + let presp = ui.interact(tri_r, ui.id().with("play"), Sense::click()).tip(format!("{} {present_keys}", tr("Present"))); + let mresp = ui.interact(chev_r, ui.id().with("play_menu"), Sense::click()).tip(tr("Present or preview")); if presp.hovered() || mresp.hovered() { ui.painter().rect_filled(play, CornerRadius::same(5), t.hover); } @@ -85,11 +89,11 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { app.run("proto.present", json!({})); } menu_popup(&mresp).width(200.0).show(|ui| { - if menu_item(ui, "Present", &present_keys) { + if menu_item(ui, tr("Present"), &present_keys) { app.run("proto.present", json!({})); ui.close(); } - let (label, cmd) = if app.ui.preview.is_some() { ("Close preview", "proto.closePreview") } else { ("Preview", "proto.preview") }; + let (label, cmd) = if app.ui.preview.is_some() { (tr("Close preview"), "proto.closePreview") } else { (tr("Preview"), "proto.preview") }; if menu_item(ui, label, "") { app.run(cmd, json!({})); ui.close(); @@ -99,7 +103,7 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { let (tabs, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); ui.painter().line_segment([tabs.left_top(), tabs.right_top()], Stroke::new(1.0, t.border)); let mut x = tabs.min.x + 8.0; - for (tab, label) in [(RightTab::Design, "Design"), (RightTab::Prototype, "Prototype")] { + for (tab, label) in [(RightTab::Design, tr("Design")), (RightTab::Prototype, tr("Prototype"))] { let galley = ui.painter().layout_no_wrap(label.into(), semibold(11.0), t.text); let r = Rect::from_min_size(pos2(x, tabs.center().y - 12.0), vec2(galley.size().x + 16.0, 24.0)); let resp = ui.interact(r, ui.id().with(("rtab", label)), Sense::click()); @@ -120,7 +124,7 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { let zresp = ui.interact(zr, ui.id().with("zoommenu"), Sense::click()); // The value is only spelled out while a screen reader reads the tree. let value = if a11y::active(&zresp.ctx) { format!("{}%", (zoom * 100.0).round()) } else { String::new() }; - a11y::combo(&zresp, "Zoom", &value, a11y::menu_open(&zresp)); + a11y::combo(&zresp, tr("Zoom"), &value, a11y::menu_open(&zresp)); if zresp.hovered() { ui.painter().rect_filled(zr, CornerRadius::same(5), t.hover); } @@ -128,13 +132,13 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { icons::paint(ui.painter(), Rect::from_center_size(pos2(zr.max.x - 9.0, zr.center().y), vec2(10.0, 10.0)), Icon::Chevron, t.icon_secondary); menu_popup(&zresp).width(200.0).show(|ui| { for (label, cmd) in [ - ("Zoom in", "view.zoomIn"), - ("Zoom out", "view.zoomOut"), - ("Zoom to fit", "view.zoomFit"), - ("Zoom to 50%", "view.zoom50"), - ("Zoom to 100%", "view.zoom100"), - ("Zoom to 200%", "view.zoom200"), - ("Zoom to selection", "view.zoomSelection"), + (tr("Zoom in"), "view.zoomIn"), + (tr("Zoom out"), "view.zoomOut"), + (tr("Zoom to fit"), "view.zoomFit"), + (tr("Zoom to 50%"), "view.zoom50"), + (tr("Zoom to 100%"), "view.zoom100"), + (tr("Zoom to 200%"), "view.zoom200"), + (tr("Zoom to selection"), "view.zoomSelection"), ] { if menu_item(ui, label, crate::shortcuts::shortcut(cmd).unwrap_or("")) { app.run(cmd, json!({})); @@ -143,7 +147,7 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { } ui.separator(); let Ok(v) = app.session.ds().map(|d| d.view.clone()) else { return }; - for (label, scale) in [("Pixel preview 1x", 1u8), ("Pixel preview 2x", 2)] { + for (label, scale) in [(tr("Pixel preview 1x"), 1u8), (tr("Pixel preview 2x"), 2)] { let on = v.pixel_preview && v.pixel_scale == scale; if menu_check(ui, on, label, "") { app.run("view.pixelPreview", json!({"on": !on, "scale": scale})); @@ -151,11 +155,11 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { } } let toggles: [(&str, &str, bool); 5] = [ - ("Pixel grid", "view.pixelGrid", v.pixel_grid), - ("Snap to pixel grid", "view.snapPixel", v.snap_pixel), - ("Layout grids", "view.layoutGrids", v.layout_grids), - ("Rulers", "view.rulers", v.rulers), - ("Outlines", "view.outlines", v.outlines), + (tr("Pixel grid"), "view.pixelGrid", v.pixel_grid), + (tr("Snap to pixel grid"), "view.snapPixel", v.snap_pixel), + (tr("Layout grids"), "view.layoutGrids", v.layout_grids), + (tr("Rulers"), "view.rulers", v.rulers), + (tr("Outlines"), "view.outlines", v.outlines), ]; for (label, cmd, on) in toggles { if menu_check(ui, on, label, crate::shortcuts::shortcut(cmd).unwrap_or("")) { @@ -354,7 +358,7 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) { Some(NodeKind::Page { background, .. }) => *background, _ => figcraft_color::Color::DEFAULT_PAGE, }; - widgets::section_header(ui, "Page", &[], false); + widgets::section_header(ui, tr("Page"), &[], false); let mut open_picker = false; let mut new_hex = None; row(ui, 32.0, |ui| { @@ -376,7 +380,7 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) { app.run("page.setBackground", json!({"color": c.to_css_hex()})); } widgets::separator(ui); - widgets::section_header(ui, "Color profile", &[], false); + widgets::section_header(ui, tr("Color profile"), &[], false); row(ui, 32.0, |ui| { let resp = widgets::dropdown(ui, doc.color_profile.label(), 140.0, None); menu_popup(&resp).show(|ui| { @@ -393,9 +397,9 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) { widgets::separator(ui); // Local variables & styles. let vars = doc.variables.variables.iter().filter(|v| !figcraft_engine::library::is_remote_variable(doc, &v.id)).count(); - widgets::section_header(ui, "Local variables", &[], vars == 0); + widgets::section_header(ui, tr("Local variables"), &[], vars == 0); row(ui, 32.0, |ui| { - let r = widgets::dropdown(ui, &format!("Open variables ({vars})"), 200.0, Some(Icon::Styles)); + let r = widgets::dropdown(ui, &trf("Open variables ({count})", &[("count", &vars)]), 200.0, Some(Icon::Styles)); if r.clicked() { app.ui.preferences = false; app.ui.palette = None; @@ -405,7 +409,7 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) { widgets::separator(ui); let styles: Vec<(String, String, String)> = figcraft_engine::library::local_styles(doc).map(|s| (s.id.clone(), s.name.clone(), s.kind.type_name().to_string())).collect(); - widgets::section_header(ui, "Local styles", &[], styles.is_empty()); + widgets::section_header(ui, tr("Local styles"), &[], styles.is_empty()); for (_, name, ty) in &styles { row(ui, 28.0, |ui| { let icon = match ty.as_str() { @@ -426,9 +430,10 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) { fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let t = Tokens::get(ui.ctx()); let label = match common(doc, sel, |n| n.kind.label()) { - Some(l) if sel.len() == 1 => l.to_string(), - Some(l) => format!("{l}s"), - None => "Mixed".into(), + Some(l) if sel.len() == 1 => tr(l).to_string(), + // The plural ("Frames") is a key of its own. + Some(l) => tr(&format!("{l}s")).to_string(), + None => tr("Mixed").into(), }; let is_frame = sel.len() == 1 && sel.first().and_then(|i| doc.node(*i)).is_some_and(|n| matches!(n.kind, NodeKind::Frame)); let w = ui.available_width(); @@ -447,7 +452,7 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { t.icon_secondary, ); let sr = Rect::from_min_size(pos2(lr.max.x + 4.0, r.center().y - 12.0), vec2(24.0, 24.0)); - let sresp = ui.interact(sr, ui.id().with("swap_orient"), Sense::click()).tip("Swap orientation"); + let sresp = ui.interact(sr, ui.id().with("swap_orient"), Sense::click()).tip(tr("Swap orientation")); if sresp.hovered() { ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover); } @@ -462,7 +467,7 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { for (c, name, pw, ph) in figcraft_engine::cmd::create::FRAME_PRESETS { if *c != cat { cat = c; - ui.label(egui::RichText::new(*c).font(semibold(11.0)).color(t.text_secondary)); + ui.label(egui::RichText::new(tr(c)).font(semibold(11.0)).color(t.text_secondary)); } if menu_item(ui, name, &format!("{}×{}", pw, ph)) { app.set_props(&ids, json!({"width": pw, "height": ph})); @@ -475,9 +480,9 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { // Quick actions on the right. let mut x = r.max.x - 8.0; let actions = [ - (Icon::Component, "Create component", "component.create"), - (Icon::Mask, "Use as mask", "object.mask"), - (Icon::Union, "Boolean groups", "bool.union"), + (Icon::Component, tr("Create component"), "component.create"), + (Icon::Mask, tr("Use as mask"), "object.mask"), + (Icon::Union, tr("Boolean groups"), "bool.union"), ]; // Layers in boolean groups, or one boolean group: the operation they use (checked in the menu). let current = figcraft_engine::booleans::shown_op(doc, sel); @@ -504,8 +509,8 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { ] { let key = crate::shortcuts::shortcut(c).unwrap_or(""); let picked = match current { - Some(cur) => menu_check(ui, cur == op, op.label(), key), - None => menu_item(ui, op.label(), key), + Some(cur) => menu_check(ui, cur == op, tr(op.label()), key), + None => menu_item(ui, tr(op.label()), key), }; if picked { app.run(c, json!({})); @@ -513,7 +518,7 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { } } let key = crate::shortcuts::shortcut("vector.flatten").unwrap_or(""); - let flatten = if current.is_some() { menu_item_beside_checks(ui, "Flatten", key) } else { menu_item(ui, "Flatten", key) }; + let flatten = if current.is_some() { menu_item_beside_checks(ui, tr("Flatten"), key) } else { menu_item(ui, tr("Flatten"), key) }; if flatten { app.run("vector.flatten", json!({})); ui.close(); @@ -528,20 +533,20 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { } fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { - widgets::section_header(ui, "Position", &[], false); + widgets::section_header(ui, tr("Position"), &[], false); let ids = sel.to_vec(); let col = widgets::col_width(ui.available_width()); - widgets::sublabels(ui, "Alignment", None); + widgets::sublabels(ui, tr("Alignment"), None); row(ui, 32.0, |ui| { let cmds = ["align.left", "align.hcenter", "align.right", "align.top", "align.vcenter", "align.bottom"]; let tip = crate::shortcuts::tip; let items = [ - (Icon::AlignLeft, tip("Align left", cmds[0])), - (Icon::AlignHCenter, tip("Align horizontal centers", cmds[1])), - (Icon::AlignRight, tip("Align right", cmds[2])), - (Icon::AlignTop, tip("Align top", cmds[3])), - (Icon::AlignVCenter, tip("Align vertical centers", cmds[4])), - (Icon::AlignBottom, tip("Align bottom", cmds[5])), + (Icon::AlignLeft, tip(tr("Align left"), cmds[0])), + (Icon::AlignHCenter, tip(tr("Align horizontal centers"), cmds[1])), + (Icon::AlignRight, tip(tr("Align right"), cmds[2])), + (Icon::AlignTop, tip(tr("Align top"), cmds[3])), + (Icon::AlignVCenter, tip(tr("Align vertical centers"), cmds[4])), + (Icon::AlignBottom, tip(tr("Align bottom"), cmds[5])), ]; let w = col / 3.0; if let Some(i) = widgets::segmented(ui, &items[..3], None, w) { @@ -551,12 +556,12 @@ fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { app.run(cmds[i + 3], json!({"ids": ids})); } if sel.len() > 2 { - let more = icons::button(ui, Icon::DistributeH, 24.0, false, "Distribute and tidy"); + let more = icons::button(ui, Icon::DistributeH, 24.0, false, tr("Distribute and tidy")); menu_popup(&more).show(|ui| { for (l, c) in [ - ("Distribute horizontal spacing", "distribute.horizontal"), - ("Distribute vertical spacing", "distribute.vertical"), - ("Tidy up", "arrange.tidy"), + (tr("Distribute horizontal spacing"), "distribute.horizontal"), + (tr("Distribute vertical spacing"), "distribute.vertical"), + (tr("Tidy up"), "arrange.tidy"), ] { if menu_item(ui, l, crate::shortcuts::shortcut(c).unwrap_or("")) { app.run(c, json!({"ids": ids})); @@ -571,7 +576,7 @@ fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let rot = common(doc, sel, |n| (figcraft_geom::rotation_degrees(n.transform) * 100.0).round() / 100.0); let parent_al = sel.iter().all(|i| doc.node(*i).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some())); let absolute = common(doc, sel, |n| n.child_layout.absolute); - widgets::sublabels(ui, "Position", None); + widgets::sublabels(ui, tr("Position"), None); row(ui, 32.0, |ui| { let e = widgets::number_field(ui, Id::new("pos_x"), Lead::Text("X"), x, "", col, 1.0); app.field(&ids, "x", e, |v| json!(v)); @@ -580,21 +585,27 @@ fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { // Children of auto layout frames can ignore the layout (absolute position). if parent_al { let on = absolute.unwrap_or(false); - if icons::button(ui, Icon::AbsolutePosition, 24.0, on, if on { "Use auto layout" } else { "Ignore auto layout (absolute position)" }) - .clicked() + if icons::button( + ui, + Icon::AbsolutePosition, + 24.0, + on, + if on { tr("Use auto layout") } else { tr("Ignore auto layout (absolute position)") }, + ) + .clicked() { app.set_props(&ids, json!({"absolute": !on})); } } }); - widgets::sublabels(ui, "Rotation", None); + widgets::sublabels(ui, tr("Rotation"), None); row(ui, 32.0, |ui| { - let e = widgets::number_field(ui, Id::new("pos_rot"), Lead::Icon(Icon::CornerRadius, "Rotation"), rot, "°", col, 1.0); + let e = widgets::number_field(ui, Id::new("pos_rot"), Lead::Icon(Icon::CornerRadius, tr("Rotation")), rot, "°", col, 1.0); app.field(&ids, "rotation", e, |v| json!(v)); let items = [ - (Icon::Rotate, "Rotate 90° right"), - (Icon::FlipH, crate::shortcuts::tip("Flip horizontal", "object.flipH")), - (Icon::FlipV, crate::shortcuts::tip("Flip vertical", "object.flipV")), + (Icon::Rotate, tr("Rotate 90° right")), + (Icon::FlipH, crate::shortcuts::tip(tr("Flip horizontal"), "object.flipH")), + (Icon::FlipV, crate::shortcuts::tip(tr("Flip vertical"), "object.flipV")), ]; if let Some(i) = widgets::segmented(ui, &items, None, col / 3.0) { app.run(["object.rotateRight", "object.flipH", "object.flipV"][i], json!({"ids": ids})); @@ -608,12 +619,18 @@ fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { }); if in_frame { let c = common(doc, sel, |n| n.constraints); - widgets::sublabels(ui, "Constraints", None); + widgets::sublabels(ui, tr("Constraints"), None); row(ui, 32.0, |ui| { let h = c.map(|c| format!("{:?}", c.horizontal)).unwrap_or_else(|| "Mixed".into()); - let resp = widgets::dropdown(ui, &pretty(&h), col, None); + let resp = widgets::dropdown(ui, tr(&pretty(&h)), col, None); menu_popup(&resp).show(|ui| { - for (l, v) in [("Left", "left"), ("Right", "right"), ("Left & right", "leftRight"), ("Center", "center"), ("Scale", "scale")] { + for (l, v) in [ + (tr("Left"), "left"), + (tr("Right"), "right"), + (tr("Left & right"), "leftRight"), + (tr("Center"), "center"), + (tr("Scale"), "scale"), + ] { if menu_item(ui, l, "") { app.set_props(&ids, json!({"constraints": {"horizontal": v}})); ui.close(); @@ -621,9 +638,15 @@ fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { } }); let v = c.map(|c| format!("{:?}", c.vertical)).unwrap_or_else(|| "Mixed".into()); - let resp = widgets::dropdown(ui, &pretty(&v), col, None); + let resp = widgets::dropdown(ui, tr(&pretty(&v)), col, None); menu_popup(&resp).show(|ui| { - for (l, val) in [("Top", "top"), ("Bottom", "bottom"), ("Top & bottom", "topBottom"), ("Center", "center"), ("Scale", "scale")] { + for (l, val) in [ + (tr("Top"), "top"), + (tr("Bottom"), "bottom"), + (tr("Top & bottom"), "topBottom"), + (tr("Center"), "center"), + (tr("Scale"), "scale"), + ] { if menu_item(ui, l, "") { app.set_props(&ids, json!({"constraints": {"vertical": val}})); ui.close(); @@ -658,27 +681,28 @@ fn arc_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) -> let start = same(&|a| a.0.to_degrees().rem_euclid(360.0)); let sweep = same(&|a| a.1 / std::f64::consts::TAU * 100.0); let ratio = same(&|a| a.2 * 100.0); - widgets::section_header(ui, "Arc", &[], false); + widgets::section_header(ui, tr("Arc"), &[], false); let col = widgets::col_width(ui.available_width()); let field = |app: &mut FigApp, ui: &mut Ui, key: &str, icon: Icon, name: &str, value: Option, suffix: &str| { crate::variable_ui::number_field(app, ui, &ids, key, Id::new(("arc", key)), Lead::Icon(icon, name), value, suffix, col) }; - widgets::sublabels(ui, "Start", Some("Sweep")); + widgets::sublabels(ui, tr("Start"), Some(tr("Sweep"))); row(ui, 32.0, |ui| { - let e = field(app, ui, "arcStart", Icon::ArcStart, "Arc start", start, "°"); + let e = field(app, ui, "arcStart", Icon::ArcStart, tr("Arc start"), start, "°"); app.field(&ids, "arcStart", e, |v| json!(v)); - let e = field(app, ui, "arcSweep", Icon::ArcSweep, "Arc sweep", sweep, "%"); + let e = field(app, ui, "arcSweep", Icon::ArcSweep, tr("Arc sweep"), sweep, "%"); app.field(&ids, "arcSweep", e, |v| json!(v.clamp(-100.0, 100.0) * 3.6)); }); - widgets::sublabels(ui, "Ratio", None); + widgets::sublabels(ui, tr("Ratio"), None); row(ui, 32.0, |ui| { - let e = field(app, ui, "arcInner", Icon::ArcRatio, "Arc ratio", ratio, "%"); + let e = field(app, ui, "arcInner", Icon::ArcRatio, tr("Arc ratio"), ratio, "%"); app.field(&ids, "arcInner", e, |v| json!((v / 100.0).clamp(0.0, 0.99))); }); ui.add_space(8.0); true } +/// Constraint names as shown (English keys, translated at display). fn pretty(s: &str) -> String { match s { "LeftRight" => "Left & right".into(), @@ -692,11 +716,11 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let visible = common(doc, sel, |n| n.visible).unwrap_or(true); let hdr = widgets::section_header( ui, - "Appearance", + tr("Appearance"), &[ - (if visible { Icon::Eye } else { Icon::EyeOff }, crate::shortcuts::tip("Show/Hide", "object.toggleVisible")), - (Icon::Variable, "Apply variable to visibility"), - (Icon::Blend, "Blend mode"), + (if visible { Icon::Eye } else { Icon::EyeOff }, crate::shortcuts::tip(tr("Show/Hide"), "object.toggleVisible")), + (Icon::Variable, tr("Apply variable to visibility")), + (Icon::Blend, tr("Blend mode")), ], false, ); @@ -710,16 +734,16 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { ui.data_mut(|d| d.insert_temp(Id::new("blend_open"), !d.get_temp::(Id::new("blend_open")).unwrap_or(false))); } let col = widgets::col_width(ui.available_width()); - crate::variable_ui::bound_row(app, ui, &ids, "visible", VariableType::Boolean, Lead::Icon(Icon::Eye, "Visible"), col * 2.0 + 8.0); + crate::variable_ui::bound_row(app, ui, &ids, "visible", VariableType::Boolean, Lead::Icon(Icon::Eye, tr("Visible")), col * 2.0 + 8.0); // Masks: how they mask. if sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.is_mask)) { let mt = common(doc, sel, |n| n.mask_type); - widgets::sublabels(ui, "Mask", None); + widgets::sublabels(ui, tr("Mask"), None); row(ui, 32.0, |ui| { - let resp = widgets::dropdown(ui, mt.map(MaskType::label).unwrap_or("Mixed"), col * 2.0 + 8.0, Some(Icon::Mask)); + let resp = widgets::dropdown(ui, mt.map(|m| tr(m.label())).unwrap_or(tr("Mixed")), col * 2.0 + 8.0, Some(Icon::Mask)); menu_popup(&resp).show(|ui| { for m in MaskType::ALL { - if menu_item(ui, m.label(), "") { + if menu_item(ui, tr(m.label()), "") { app.set_props(&ids, json!({"maskType": m.id()})); ui.close(); } @@ -730,9 +754,9 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let op = common(doc, sel, |n| (f64::from(n.opacity) * 100.0).round()); let radius_ok = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.has_corner_radius())); let r = common(doc, sel, |n| n.corners.uniform().map(|r| (r * 100.0).round() / 100.0)); - widgets::sublabels(ui, "Opacity", radius_ok.then_some("Corner radius")); + widgets::sublabels(ui, tr("Opacity"), radius_ok.then_some(tr("Corner radius"))); row(ui, 32.0, |ui| { - let e = crate::variable_ui::number_field(app, ui, &ids, "opacity", Id::new("ap_op"), Lead::Icon(Icon::Opacity, "Opacity"), op, "%", col); + let e = crate::variable_ui::number_field(app, ui, &ids, "opacity", Id::new("ap_op"), Lead::Icon(Icon::Opacity, tr("Opacity")), op, "%", col); app.field(&ids, "opacity", e, |v| json!((v / 100.0).clamp(0.0, 1.0))); if radius_ok { let e = crate::variable_ui::number_field( @@ -741,14 +765,14 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { &ids, "cornerRadius", Id::new("ap_rad"), - Lead::Icon(Icon::CornerRadius, "Corner radius"), + Lead::Icon(Icon::CornerRadius, tr("Corner radius")), r.flatten(), "", col, ); app.field(&ids, "cornerRadius", e, |v| json!(v.max(0.0))); let open = ui.data(|d| d.get_temp::(Id::new("indep_corners"))).unwrap_or(false); - if icons::button(ui, Icon::IndependentCorners, 24.0, open, "Individual corners").clicked() { + if icons::button(ui, Icon::IndependentCorners, 24.0, open, tr("Individual corners")).clicked() { ui.data_mut(|d| d.insert_temp(Id::new("indep_corners"), !open)); } } @@ -756,10 +780,10 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { if radius_ok && ui.data(|d| d.get_temp::(Id::new("indep_corners"))).unwrap_or(false) { let radii = common(doc, sel, |n| n.corners.radii); let keys = [ - ("topLeftRadius", "Top left radius"), - ("topRightRadius", "Top right radius"), - ("bottomRightRadius", "Bottom right radius"), - ("bottomLeftRadius", "Bottom left radius"), + ("topLeftRadius", tr("Top left radius")), + ("topRightRadius", tr("Top right radius")), + ("bottomRightRadius", tr("Bottom right radius")), + ("bottomLeftRadius", tr("Bottom left radius")), ]; row(ui, 32.0, |ui| { for (k, (key, name)) in keys.iter().enumerate().take(2) { @@ -794,7 +818,7 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { } }); let sm = common(doc, sel, |n| (n.corners.smoothing * 100.0).round()); - widgets::sublabels(ui, "Corner smoothing", None); + widgets::sublabels(ui, tr("Corner smoothing"), None); row(ui, 32.0, |ui| { let e = widgets::number_field(ui, Id::new("smoothing"), Lead::None, sm, "%", col, 1.0); app.field(&ids, "cornerSmoothing", e, |v| json!((v / 100.0).clamp(0.0, 1.0))); @@ -805,13 +829,13 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let show_blend = ui.data(|d| d.get_temp::(Id::new("blend_open"))).unwrap_or(false) || blend.is_some_and(|b| !matches!(b, BlendMode::Normal | BlendMode::PassThrough)); if show_blend { - widgets::sublabels(ui, "Blend mode", None); + widgets::sublabels(ui, tr("Blend mode"), None); row(ui, 32.0, |ui| { - let label = blend.map(|b| b.label()).unwrap_or("Mixed"); + let label = blend.map(|b| tr(b.label())).unwrap_or(tr("Mixed")); let resp = widgets::dropdown(ui, label, col * 2.0 + 8.0, Some(Icon::Blend)); menu_popup(&resp).width(200.0).show(|ui| { for b in BlendMode::ALL { - if menu_item(ui, b.label(), "") { + if menu_item(ui, tr(b.label()), "") { app.set_props(&ids, json!({"blend": b.css()})); ui.close(); } @@ -839,7 +863,7 @@ fn variable_modes(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) return false; } let t = Tokens::get(ui.ctx()); - widgets::section_header(ui, "Variable modes", &[], false); + widgets::section_header(ui, tr("Variable modes"), &[], false); // 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()); @@ -862,14 +886,14 @@ fn variable_modes(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) None => Some(None), }; let inherited = r.local.and_then(|l| parent_modes.get(l)).or(c.default_mode()).map_or("", |m| name_of(m)); - let auto = format!("Auto ({inherited})"); + let auto = trf("Auto ({mode})", &[("mode", &inherited)]); let label = match &own { Some(Some(m)) => name_of(m).to_string(), Some(None) => auto.clone(), - None => "Mixed".into(), + None => tr("Mixed").into(), }; let hover = match r.library { - Some((_, lib)) => format!("{} (from {lib})", c.name), + Some((_, lib)) => trf("{name} (from {library})", &[("name", &c.name), ("library", &lib)]), None => c.name.clone(), }; row(ui, 32.0, |ui| { @@ -919,7 +943,7 @@ fn set_variable_mode(app: &mut FigApp, sel: &[NodeId], row: &crate::variable_ui: /// While a range of text is selected, the Fill section edits that range's colour. fn range_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, start: usize, end: usize) { let t = Tokens::get(ui.ctx()); - widgets::section_header(ui, "Fill", &[], false); + widgets::section_header(ui, tr("Fill"), &[], false); // The fills over the range, if every character shares them. let fills: Option> = doc.node(id).and_then(|n| n.kind.text().map(|tx| (n, tx))).and_then(|(n, tx)| { let mut it = @@ -948,18 +972,18 @@ fn range_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, start: app.run("text.style", json!({"id": id, "start": start, "end": end, "textFills": [nc.to_css_hex()]})); } } else { - ui.painter().text(pos2(text_r.min.x, r.center().y), Align2::LEFT_CENTER, "Gradient", regular(11.0), t.text); + ui.painter().text(pos2(text_r.min.x, r.center().y), Align2::LEFT_CENTER, tr("Gradient"), regular(11.0), t.text); } } None => { - ui.painter().text(pos2(text_r.min.x, r.center().y), Align2::LEFT_CENTER, "Mixed", regular(11.0), t.text_secondary); + ui.painter().text(pos2(text_r.min.x, r.center().y), Align2::LEFT_CENTER, tr("Mixed"), regular(11.0), t.text_secondary); if ui.interact(r, Id::new("range_mixed"), Sense::click()).clicked() { app.ui.picker = Some(crate::colorpicker::PickerTarget::TextRange { id, start, end }); } } } // Back to the layer's fill. - if icons::button(ui, Icon::Detach, 24.0, false, "Use the layer's fill").clicked() { + if icons::button(ui, Icon::Detach, 24.0, false, tr("Use the layer's fill")).clicked() { app.run("text.style", json!({"id": id, "start": start, "end": end, "textFills": null})); } }); @@ -977,12 +1001,15 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: let t = Tokens::get(ui.ctx()); let ids = sel.to_vec(); let list = common(doc, sel, |n| if fills { n.fills.clone() } else { n.strokes.clone() }); - let title = if fills { "Fill" } else { "Stroke" }; + let title = if fills { tr("Fill") } else { tr("Stroke") }; let empty = list.as_ref().is_none_or(|l| l.is_empty()); let slot = if fills { StyleSlot::Fill } else { StyleSlot::Stroke }; - if let Some(i) = - widgets::section_header(ui, title, &[(Icon::Styles, "Styles"), (Icon::Plus, if fills { "Add fill" } else { "Add stroke" })], empty) - { + if let Some(i) = widgets::section_header( + ui, + title, + &[(Icon::Styles, tr("Styles")), (Icon::Plus, if fills { tr("Add fill") } else { tr("Add stroke") })], + empty, + ) { if i == 1 { app.run(if fills { "fill.add" } else { "stroke.add" }, json!({"ids": ids})); } else { @@ -991,7 +1018,7 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: } style_picker::chip(app, ui, doc, &ids, slot); match &list { - None => row(ui, 32.0, |ui| widgets::label(ui, "Click + to replace mixed content", t.text_secondary)), + None => row(ui, 32.0, |ui| widgets::label(ui, tr("Click + to replace mixed content"), t.text_secondary)), Some(l) => { // The last paint is on top: shown first. Rows drag to reorder. let mut drag = ListDrag::new(ui, if fills { "fills" } else { "strokes" }); @@ -1018,16 +1045,16 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: let resp = widgets::dropdown( ui, match align { - Some(StrokeAlign::Inside) => "Inside", - Some(StrokeAlign::Center) => "Center", - Some(StrokeAlign::Outside) => "Outside", - None => "Mixed", + Some(StrokeAlign::Inside) => tr("Inside"), + Some(StrokeAlign::Center) => tr("Center"), + Some(StrokeAlign::Outside) => tr("Outside"), + None => tr("Mixed"), }, half, None, ); menu_popup(&resp).show(|ui| { - for (l, v) in [("Inside", "inside"), ("Center", "center"), ("Outside", "outside")] { + for (l, v) in [(tr("Inside"), "inside"), (tr("Center"), "center"), (tr("Outside"), "outside")] { if menu_item(ui, l, "") { app.set_props(&ids, json!({"strokeAlign": v})); ui.close(); @@ -1042,23 +1069,23 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: &ids, "strokeWeight", Id::new("stroke_w"), - Lead::Icon(Icon::Menu, "Stroke weight"), + Lead::Icon(Icon::Menu, tr("Stroke weight")), weight, "", weight_w, ); app.field(&ids, "strokeWeight", e, |v| json!(v.max(0.0))); if sided { - let sides_btn = icons::button(ui, Icon::IndependentCorners, 24.0, false, "Individual strokes"); + let sides_btn = icons::button(ui, Icon::IndependentCorners, 24.0, false, tr("Individual strokes")); menu_popup(&sides_btn).show(|ui| { let w = weight.unwrap_or(1.0); for (l, v) in [ - ("All", Value::Null), - ("Top", json!([w, 0.0, 0.0, 0.0])), - ("Right", json!([0.0, w, 0.0, 0.0])), - ("Bottom", json!([0.0, 0.0, w, 0.0])), - ("Left", json!([0.0, 0.0, 0.0, w])), - ("Custom", json!([w, w, w, w])), + (tr("All"), Value::Null), + (tr("Top"), json!([w, 0.0, 0.0, 0.0])), + (tr("Right"), json!([0.0, w, 0.0, 0.0])), + (tr("Bottom"), json!([0.0, 0.0, w, 0.0])), + (tr("Left"), json!([0.0, 0.0, 0.0, w])), + (tr("Custom"), json!([w, w, w, w])), ] { if menu_item(ui, l, "") { app.set_props(&ids, json!({"strokeSides": v})); @@ -1069,7 +1096,7 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: } let adv_id = Id::new("stroke_adv"); let open = ui.data(|d| d.get_temp::(adv_id)).unwrap_or(false); - if icons::button(ui, Icon::Dots, 24.0, open, "Advanced stroke").clicked() { + if icons::button(ui, Icon::Dots, 24.0, open, tr("Advanced stroke")).clicked() { ui.data_mut(|d| d.insert_temp(adv_id, !open)); } }); @@ -1134,12 +1161,12 @@ pub(crate) fn profile_art(painter: &egui::Painter, rect: Rect, profile: Option<& /// Stroke settings ▸ Width profile: the presets (each with its shape) and a flip. fn width_profile_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], profile: Option>, half: f32) { let t = Tokens::get(ui.ctx()); - widgets::sublabels(ui, "Width profile", None); + widgets::sublabels(ui, tr("Width profile"), None); row(ui, 32.0, |ui| { let label = match &profile { - None => "Mixed", - Some(None) => WidthPreset::Uniform.label(), - Some(Some(p)) => p.preset().map_or("Custom", WidthPreset::label), + None => tr("Mixed"), + Some(None) => tr(WidthPreset::Uniform.label()), + Some(Some(p)) => p.preset().map_or(tr("Custom"), |w| tr(w.label())), }; let resp = widgets::dropdown(ui, label, half * 2.0 + 8.0 - 32.0, None); if let Some(p) = &profile { @@ -1154,7 +1181,7 @@ fn width_profile_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], profile: Opt let a = Rect::from_min_max(pos2(r.max.x - 8.0 - 64.0, r.center().y - 5.0), pos2(r.max.x - 8.0, r.center().y + 5.0)); profile_art(pt, a, prof.as_ref(), col); }; - if menu_item_with(ui, preset.label(), "", 8.0 + 64.0, art) { + if menu_item_with(ui, tr(preset.label()), "", 8.0 + 64.0, art) { app.run("stroke.widthProfile", json!({"ids": ids, "preset": preset.id()})); ui.close(); } @@ -1162,7 +1189,7 @@ fn width_profile_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], profile: Opt }); // Flip: the profile runs from the other end of the path. let custom = profile.clone().flatten(); - let flip = icons::button(ui, Icon::FlipH, 24.0, false, "Flip width profile"); + let flip = icons::button(ui, Icon::FlipH, 24.0, false, tr("Flip width profile")); if flip.clicked() && let Some(p) = custom { @@ -1197,14 +1224,14 @@ pub(crate) fn brush_art(painter: &egui::Painter, rect: Rect, preset: &str, col: fn brush_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], brush: Option>, width: f32) { use figcraft_pathops::brush::{PRESETS, preset}; let t = Tokens::get(ui.ctx()); - widgets::sublabels(ui, "Brush", None); + widgets::sublabels(ui, tr("Brush"), None); row(ui, 32.0, |ui| { let label = match &brush { - None => "Mixed", - Some(b) => b.as_ref().and_then(|b| preset(&b.preset)).map_or("None", |p| p.label), + None => tr("Mixed"), + Some(b) => b.as_ref().and_then(|b| preset(&b.preset)).map_or(tr("None"), |p| tr(p.label)), }; let resp = widgets::dropdown(ui, label, width, None); - a11y::combo(&resp, "Brush", label, a11y::menu_open(&resp)); + a11y::combo(&resp, tr("Brush"), label, a11y::menu_open(&resp)); if let Some(Some(b)) = &brush { let r = resp.rect; brush_art( @@ -1215,7 +1242,7 @@ fn brush_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], brush: Option