M7: localization layer (#128)
UI strings come from per-language TOML catalogs compiled in (English text as the key and fallback, named placeholders, one/other plurals, context suffixes); command labels are translated at display time while ids, CLI, MCP and control output stay English; accessibility names follow. Preferences > Language (System / English / Español, live; the system language from the OS or the browser), pref.language for scripts. A key check test guards the catalogs. The Spanish catalog is a machine draft awaiting native review; docs/translating.md explains adding a language.
This commit is contained in:
parent
76df2d4136
commit
a2e2012fdc
|
|
@ -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).
|
||||
|
|
|
|||
24
ROADMAP.md
24
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 `<textPath>` 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 `<pattern>`, 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 |
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
web_sys::window().and_then(|w| w.navigator().language())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ pub struct Desktop {
|
|||
pub prefs: Option<Prefs>,
|
||||
/// The system's reduce-motion setting (read in the background at startup).
|
||||
reduce_motion: Arc<AtomicBool>,
|
||||
/// The system's interface language (read at startup).
|
||||
language: Option<String>,
|
||||
}
|
||||
|
||||
fn config_dir() -> Option<PathBuf> {
|
||||
|
|
@ -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<String> {
|
||||
#[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<String> {
|
||||
self.language.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// The window icon from the bundled app icon (if present).
|
||||
|
|
|
|||
|
|
@ -241,6 +241,18 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
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")),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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/<code>.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::<toml::Table>().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<T>(path: &std::path::Path, fallback: &str, parse: impl Fn(&str) -> Result<T, String>) -> Option<T> {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<String> = 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::<u64>();
|
||||
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())]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, Side>) -
|
|||
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<String, Side>) -
|
|||
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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<NodeId> {
|
|||
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)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<bool> = 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<PresetAction> {
|
|||
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<String> = 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}));
|
||||
|
|
|
|||
|
|
@ -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<u64> {
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -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::<String>()}}));
|
||||
}
|
||||
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<String> = 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<String> = 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 {
|
||||
|
|
|
|||
|
|
@ -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::<View>(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<Option<f32>> {
|
|||
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<Option<f32>> {
|
|||
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);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<NodeId>) -> Option<Action> {
|
||||
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<String> = [(cmp.modified, "changed"), (cmp.added, "added"), (cmp.removed, "removed")]
|
||||
let parts: Vec<String> = [(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<NodeId>)
|
|||
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<NodeId>)
|
|||
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<Action> {
|
|||
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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<DevStatus>, &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, "<Button size={size}>{label}</Button>", 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
|
||||
|
|
|
|||
|
|
@ -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::<std::collections::BTreeSet<_>>().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 {
|
||||
|
|
|
|||
|
|
@ -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<NodeId> = 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<Pr
|
|||
let t = Tokens::get(ui.ctx());
|
||||
let mut next = *p;
|
||||
ui.horizontal(|ui| {
|
||||
widgets::label(ui, "Bleed", t.text_secondary);
|
||||
widgets::label(ui, tr("Bleed"), t.text_secondary);
|
||||
let w = (ui.available_width() - 4.0).max(48.0);
|
||||
if let Some(b) = widgets::text_field(ui, id.with("bleed"), &PrintSettings::length_label(p.bleed()), w, "0 mm")
|
||||
.and_then(|v| PrintSettings::parse_length(&v))
|
||||
|
|
@ -159,13 +160,13 @@ pub(crate) fn print_options(ui: &mut Ui, id: Id, p: &PrintSettings) -> Option<Pr
|
|||
next.bleed = b;
|
||||
}
|
||||
});
|
||||
for (on, label) in [(&mut next.crop_marks, "Crop marks"), (&mut next.bleed_marks, "Bleed marks"), (&mut next.cmyk, "CMYK colour")] {
|
||||
for (on, label) in [(&mut next.crop_marks, tr("Crop marks")), (&mut next.bleed_marks, tr("Bleed marks")), (&mut next.cmyk, tr("CMYK colour"))] {
|
||||
if widgets::checkbox(ui, *on, label) {
|
||||
*on = !*on;
|
||||
}
|
||||
}
|
||||
if p.bleed_marks && p.bleed() <= 0.0 {
|
||||
widgets::label(ui, "Bleed marks need a bleed", t.text_secondary);
|
||||
widgets::label(ui, tr("Bleed marks need a bleed"), t.text_secondary);
|
||||
}
|
||||
(next != *p).then_some(next)
|
||||
}
|
||||
|
|
@ -205,7 +206,7 @@ fn preview(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId) {
|
|||
}
|
||||
let icon = if open { Icon::Chevron } else { Icon::ChevronRight };
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 8.0, r.center().y), vec2(12.0, 12.0)), icon, t.icon_secondary);
|
||||
ui.painter().text(pos2(r.min.x + 20.0, r.center().y), Align2::LEFT_CENTER, "Preview", regular(11.0), t.text);
|
||||
ui.painter().text(pos2(r.min.x + 20.0, r.center().y), Align2::LEFT_CENTER, tr("Preview"), regular(11.0), t.text);
|
||||
if resp.clicked() {
|
||||
open = !open;
|
||||
}
|
||||
|
|
@ -277,10 +278,10 @@ pub(crate) fn export_layers(app: &mut FigApp, ids: &[NodeId]) {
|
|||
[] => {}
|
||||
[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<NodeId> = 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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/<code>.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;
|
||||
|
|
@ -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<String> {
|
||||
let mut keys: BTreeSet<String> = 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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> = used_keys().into_iter().chain(dynamic_keys()).collect();
|
||||
let en: BTreeSet<String> = 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::<Vec<_>>().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:?}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
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<String>,
|
||||
/// 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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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<R>(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::<f32>(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::<f32>(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>, &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,
|
||||
|
|
|
|||
|
|
@ -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<String> = 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());
|
||||
|
|
|
|||
|
|
@ -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<Device>, 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<Device>, 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<Device>, 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<usize>) {
|
||||
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<usi
|
|||
let header = row_rect(ui, 32.0, |ui| {
|
||||
if !single {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(12.0, FIELD_H), Sense::click());
|
||||
let resp = resp.tip(if open { "Close" } else { "Open" });
|
||||
let resp = resp.tip(if open { tr("Close") } else { tr("Open") });
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(r.center(), vec2(12.0, 12.0)),
|
||||
|
|
@ -244,7 +245,7 @@ fn action_item(app: &mut FigApp, ui: &mut Ui, cx: &Cx, a: &Action, path: Vec<usi
|
|||
}
|
||||
let w = ui.available_width() - if single { 0.0 } else { 32.0 };
|
||||
if open {
|
||||
let resp = widgets::dropdown(ui, a.label(), w, None);
|
||||
let resp = widgets::dropdown(ui, tr(a.label()), w, None);
|
||||
menu_popup(&resp).show(|ui| {
|
||||
if let Some(l) = action_menu(ui) {
|
||||
match action_of(l, cx.doc, cx.page, cx.node, Some(a)) {
|
||||
|
|
@ -268,13 +269,13 @@ fn action_item(app: &mut FigApp, ui: &mut Ui, cx: &Cx, a: &Action, path: Vec<usi
|
|||
}
|
||||
}
|
||||
if !single {
|
||||
let resp = icons::button(ui, Icon::Dots, 24.0, false, "More");
|
||||
let resp = icons::button(ui, Icon::Dots, 24.0, false, tr("More"));
|
||||
menu_popup(&resp).width(180.0).show(|ui| {
|
||||
let mut moved = None;
|
||||
if i > 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<usi
|
|||
if count > 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::<bool>(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::<fn(&mut Ui)>) {
|
||||
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::<fn(&mut Ui)>) {
|
||||
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<Var
|
|||
.filter(|n| n.to_hex() != hex)
|
||||
.map(VariableValue::Color)
|
||||
}
|
||||
VariableValue::String(s) => 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<Var
|
|||
fn set_variable_fields(app: &mut FigApp, ui: &mut Ui, cx: &Cx, slot: &Slot, variable: &str, value: &Value, expr: Option<&Expr>) {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<NodeId> {
|
|||
}
|
||||
|
||||
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<proto::DeviceColor>) {
|
||||
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<proto::Device>) {
|
||||
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<proto::Device>) {
|
|||
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<NodeId> = 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();
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -8,6 +8,7 @@ use figcraft_doc::{Document, NodeId};
|
|||
use serde_json::json;
|
||||
|
||||
use crate::FigApp;
|
||||
use crate::i18n::tr;
|
||||
use crate::right::row;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets::{self, FieldEdit, Lead};
|
||||
|
|
@ -15,13 +16,13 @@ use crate::widgets::{self, FieldEdit, Lead};
|
|||
/// The Scale section, shown in place of Position while the Scale tool is active.
|
||||
pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||||
let Some(b) = doc.bounds_of(sel) else { return };
|
||||
widgets::section_header(ui, "Scale", &[], false);
|
||||
widgets::section_header(ui, tr("Scale"), &[], false);
|
||||
let col = widgets::col_width(ui.available_width());
|
||||
// Anchor: column and row (0 start, 1 middle, 2 end) of the point that stays put.
|
||||
let anchor = app.session.tool.scale_anchor;
|
||||
let mut factor: Option<f64> = None;
|
||||
let round = |v: f64| (v * 100.0).round() / 100.0;
|
||||
widgets::sublabels(ui, "Dimensions", None);
|
||||
widgets::sublabels(ui, tr("Dimensions"), None);
|
||||
row(ui, 32.0, |ui| {
|
||||
if let Some(FieldEdit::Set(w)) = widgets::number_field(ui, Id::new("scale_w"), Lead::Text("W"), Some(round(b.width())), "", col, 1.0)
|
||||
&& b.width() > 1e-9
|
||||
|
|
@ -34,7 +35,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
|||
factor = Some(h / b.height());
|
||||
}
|
||||
});
|
||||
widgets::sublabels(ui, "Scale", Some("Anchor"));
|
||||
widgets::sublabels(ui, tr("Scale"), Some(tr("Anchor")));
|
||||
crate::right::block(ui, 52.0, 2.0, |ui| {
|
||||
if let Some(FieldEdit::Set(k)) = widgets::number_field(ui, Id::new("scale_k"), Lead::None, Some(1.0), "x", col, 0.1) {
|
||||
factor = Some(k);
|
||||
|
|
|
|||
|
|
@ -6,20 +6,21 @@ use figcraft_doc::{DevStatus, Document, NodeId, NodeKind};
|
|||
use serde_json::json;
|
||||
|
||||
use crate::FigApp;
|
||||
use crate::i18n::{tr, tr_noop, trf};
|
||||
use crate::icons::Icon;
|
||||
use crate::right::{common, menu_item, menu_popup, row};
|
||||
use crate::widgets;
|
||||
|
||||
/// Section fill presets: light tints that keep dark text readable.
|
||||
const SECTION_COLORS: [(&str, &str); 8] = [
|
||||
("White", "#FFFFFF"),
|
||||
("Gray", "#EDEDED"),
|
||||
("Red", "#FFE0DE"),
|
||||
("Orange", "#FFE8D1"),
|
||||
("Yellow", "#FFF4C2"),
|
||||
("Green", "#DDF5E3"),
|
||||
("Blue", "#DCEBFF"),
|
||||
("Violet", "#EBE3FF"),
|
||||
(tr_noop("White"), "#FFFFFF"),
|
||||
(tr_noop("Gray"), "#EDEDED"),
|
||||
(tr_noop("Red"), "#FFE0DE"),
|
||||
(tr_noop("Orange"), "#FFE8D1"),
|
||||
(tr_noop("Yellow"), "#FFF4C2"),
|
||||
(tr_noop("Green"), "#DDF5E3"),
|
||||
(tr_noop("Blue"), "#DCEBFF"),
|
||||
(tr_noop("Violet"), "#EBE3FF"),
|
||||
];
|
||||
|
||||
fn is_section(doc: &Document, id: NodeId) -> bool {
|
||||
|
|
@ -32,14 +33,14 @@ pub use figcraft_engine::devinspect::has_status;
|
|||
/// "Mixed" when they differ).
|
||||
pub fn status_dropdown(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId], width: f32) {
|
||||
let label = match common(doc, ids, |n| (n.dev_status, n.dev_changed)) {
|
||||
Some((DevStatus::ReadyForDev, true)) => "Changed",
|
||||
Some((d, _)) => d.label(),
|
||||
None => "Mixed",
|
||||
Some((DevStatus::ReadyForDev, true)) => tr("Changed"),
|
||||
Some((d, _)) => tr(d.label()),
|
||||
None => tr("Mixed"),
|
||||
};
|
||||
let resp = widgets::dropdown(ui, label, width, Some(Icon::Code));
|
||||
menu_popup(&resp).show(|ui| {
|
||||
for d in DevStatus::ALL {
|
||||
if menu_item(ui, d.label(), "") {
|
||||
if menu_item(ui, tr(d.label()), "") {
|
||||
app.set_props(ids, json!({"devStatus": d.id()}));
|
||||
ui.close();
|
||||
}
|
||||
|
|
@ -56,7 +57,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
|||
let ids = sel.to_vec();
|
||||
let col = widgets::col_width(ui.available_width());
|
||||
if sections {
|
||||
widgets::sublabels(ui, "Section color", None);
|
||||
widgets::sublabels(ui, tr("Section color"), None);
|
||||
row(ui, 32.0, |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
let current = common(doc, sel, |n| n.fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()));
|
||||
|
|
@ -67,6 +68,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
|||
let on = current.as_ref().and_then(|c| c.as_ref()).is_some_and(|h| h.eq_ignore_ascii_case(hex.trim_start_matches('#')));
|
||||
let stroke = if on { Stroke::new(2.0, crate::theme::ACCENT) } else { Stroke::new(1.0, t.border) };
|
||||
ui.painter().rect_stroke(r, CornerRadius::same(9), stroke, egui::StrokeKind::Inside);
|
||||
let name = tr(name);
|
||||
crate::a11y::radio(&resp, name, on);
|
||||
if resp.on_hover_text(name).clicked() {
|
||||
app.set_props(&ids, json!({"fills": [hex]}));
|
||||
|
|
@ -75,12 +77,12 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
|||
});
|
||||
row(ui, 28.0, |ui| {
|
||||
let hidden = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.hides_contents()));
|
||||
if widgets::checkbox(ui, hidden, "Hide contents") {
|
||||
if widgets::checkbox(ui, hidden, tr("Hide contents")) {
|
||||
app.run("section.toggleContents", json!({"ids": ids, "hidden": !hidden}));
|
||||
}
|
||||
});
|
||||
}
|
||||
widgets::sublabels(ui, "Status", None);
|
||||
widgets::sublabels(ui, tr("Status"), None);
|
||||
let changed = sel.iter().any(|i| doc.node(*i).is_some_and(|n| n.dev_status == DevStatus::ReadyForDev && n.dev_changed));
|
||||
row(ui, 32.0, |ui| status_dropdown(app, ui, doc, &ids, col * 2.0 + 8.0));
|
||||
if changed {
|
||||
|
|
@ -91,13 +93,13 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
|||
let draft_id = id.with("draft");
|
||||
// The typed reason stays a draft until Enter or Resolve: clicking away keeps it.
|
||||
let mut draft: String = ui.data(|d| d.get_temp(draft_id)).unwrap_or_default();
|
||||
let committed = widgets::text_field(ui, id, &draft, col * 2.0 - 64.0, "Why it changed");
|
||||
let committed = widgets::text_field(ui, id, &draft, col * 2.0 - 64.0, tr("Why it changed"));
|
||||
let enter = committed.is_some() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
if let Some(text) = committed {
|
||||
draft = text;
|
||||
ui.data_mut(|d| d.insert_temp(draft_id, draft.clone()));
|
||||
}
|
||||
let button = widgets::button(ui, "Resolve", true, true).on_hover_text("Back to Ready for dev");
|
||||
let button = widgets::button(ui, tr("Resolve"), true, true).on_hover_text(tr("Back to Ready for dev"));
|
||||
if enter || button.clicked() {
|
||||
let reason = draft.trim();
|
||||
let params = if reason.is_empty() { json!({"ids": ids}) } else { json!({"ids": ids, "reason": reason}) };
|
||||
|
|
@ -108,14 +110,14 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
|||
} else if let Some(note) = common(doc, sel, |n| n.dev_note.clone()).filter(|n| !n.is_empty()) {
|
||||
row(ui, 24.0, |ui| {
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let text = widgets::elide(ui, &format!("Last change: {note}"), col * 2.0 + 8.0);
|
||||
let text = widgets::elide(ui, &trf("Last change: {note}", &[("note", ¬e)]), col * 2.0 + 8.0);
|
||||
widgets::label(ui, &text, t.text_secondary);
|
||||
});
|
||||
}
|
||||
row(ui, 32.0, |ui| {
|
||||
let fit = ui
|
||||
.add_sized(vec2(col * 2.0 + 8.0, widgets::FIELD_H), egui::Button::new("Resize to fit"))
|
||||
.on_hover_text(crate::shortcuts::tip("Fit to contents", "object.resizeToFit"));
|
||||
.add_sized(vec2(col * 2.0 + 8.0, widgets::FIELD_H), egui::Button::new(tr("Resize to fit")))
|
||||
.on_hover_text(crate::shortcuts::tip(tr("Fit to contents"), "object.resizeToFit"));
|
||||
if fit.clicked() {
|
||||
app.run("object.resizeToFit", json!({"ids": ids}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use egui::{Align2, Context, CornerRadius, Sense, Stroke, StrokeKind, Ui, pos2, v
|
|||
use figcraft_engine::keymap::{GESTURES, Group, KEYS, Platform};
|
||||
|
||||
use crate::FigApp;
|
||||
use crate::i18n::{self, tr};
|
||||
use crate::theme::{Tokens, regular};
|
||||
|
||||
/// Most keys a row shows (a command's first bindings; the rest stay in the menus' alternates).
|
||||
|
|
@ -17,7 +18,7 @@ const MAX_ROWS: usize = 9;
|
|||
|
||||
/// One row: the action and its keys as written on this platform.
|
||||
struct Row {
|
||||
label: &'static str,
|
||||
label: std::borrow::Cow<'static, str>,
|
||||
keys: Vec<String>,
|
||||
}
|
||||
|
||||
|
|
@ -29,11 +30,15 @@ fn tallest_column() -> usize {
|
|||
|
||||
fn rows(group: Option<Group>, query: &str, platform: Platform) -> Vec<Row> {
|
||||
let q = query.trim().to_lowercase();
|
||||
let wanted = |g: Group, label: &str| group.is_none_or(|x| x == g) && (q.is_empty() || label.to_lowercase().contains(&q));
|
||||
// Labels match in the UI language and in English.
|
||||
let wanted = |g: Group, label: &str, english: &str| {
|
||||
group.is_none_or(|x| x == g) && (q.is_empty() || label.to_lowercase().contains(&q) || english.to_lowercase().contains(&q))
|
||||
};
|
||||
let mut out: Vec<Row> = KEYS
|
||||
.iter()
|
||||
.filter(|b| wanted(b.group, b.title()))
|
||||
.map(|b| {
|
||||
.map(|b| (b, i18n::command(b.id, b.title())))
|
||||
.filter(|(b, label)| wanted(b.group, label, b.title()))
|
||||
.map(|(b, label)| {
|
||||
// Alternatives that read the same (⌘+ and ⇧⌘+) show once.
|
||||
let mut keys: Vec<String> = Vec::with_capacity(MAX_KEYS);
|
||||
for k in b.keys(platform) {
|
||||
|
|
@ -42,15 +47,13 @@ fn rows(group: Option<Group>, query: &str, platform: Platform) -> Vec<Row> {
|
|||
keys.push(shown);
|
||||
}
|
||||
}
|
||||
Row { label: b.title(), keys }
|
||||
Row { label, keys }
|
||||
})
|
||||
.collect();
|
||||
out.extend(
|
||||
GESTURES.iter().filter(|(g, label, ..)| wanted(*g, label)).map(|(_, label, mac, other)| Row {
|
||||
label,
|
||||
keys: vec![if platform == Platform::Mac { (*mac).to_string() } else { (*other).to_string() }],
|
||||
}),
|
||||
);
|
||||
out.extend(GESTURES.iter().filter(|(g, label, ..)| wanted(*g, tr(label), label)).map(|(_, label, mac, other)| Row {
|
||||
label: tr(label).into(),
|
||||
keys: vec![tr(if platform == Platform::Mac { mac } else { other }).to_string()],
|
||||
}));
|
||||
out
|
||||
}
|
||||
|
||||
|
|
@ -75,14 +78,14 @@ fn row(ui: &mut Ui, t: &Tokens, row: &Row, width: f32) {
|
|||
for (i, k) in row.keys.iter().enumerate().rev() {
|
||||
right = keycap(ui, t, k, right, cy);
|
||||
if i > 0 {
|
||||
let or = ui.painter().layout_no_wrap("or".into(), regular(10.0), t.text_tertiary);
|
||||
let or = ui.painter().layout_no_wrap(tr("or").into(), regular(10.0), t.text_tertiary);
|
||||
right -= 4.0 + or.size().x;
|
||||
ui.painter().galley(pos2(right, cy - or.size().y / 2.0), or, t.text_tertiary);
|
||||
right -= 4.0;
|
||||
}
|
||||
}
|
||||
let label_w = (right - r.min.x - 14.0).max(20.0);
|
||||
let label = crate::widgets::elide(ui, row.label, label_w);
|
||||
let label = crate::widgets::elide(ui, &row.label, label_w);
|
||||
ui.painter().text(pos2(r.min.x + 6.0, cy), Align2::LEFT_CENTER, label, regular(12.0), t.text);
|
||||
}
|
||||
|
||||
|
|
@ -94,12 +97,12 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
|
|||
let platform = crate::shortcuts::platform();
|
||||
let width = (ctx.content_rect().width() - 48.0).clamp(480.0, 1040.0);
|
||||
let mut open = true;
|
||||
crate::dialogs::window("Keyboard shortcuts", ctx).anchor(Align2::CENTER_BOTTOM, vec2(0.0, -16.0)).open(&mut open).show(ctx, |ui| {
|
||||
crate::dialogs::window(tr("Keyboard shortcuts"), ctx).anchor(Align2::CENTER_BOTTOM, vec2(0.0, -16.0)).open(&mut open).show(ctx, |ui| {
|
||||
ui.set_width(width);
|
||||
ui.horizontal(|ui| {
|
||||
crate::dialogs::tab_strip(ui, &t, &Group::ALL, Group::label, &mut app.ui.shortcuts_tab);
|
||||
crate::dialogs::tab_strip(ui, &t, &Group::ALL, |g| tr(g.label()), &mut app.ui.shortcuts_tab);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.add(egui::TextEdit::singleline(&mut app.ui.shortcuts_query).hint_text("Search shortcuts").desired_width(180.0));
|
||||
ui.add(egui::TextEdit::singleline(&mut app.ui.shortcuts_query).hint_text(tr("Search shortcuts")).desired_width(180.0));
|
||||
});
|
||||
});
|
||||
ui.add_space(10.0);
|
||||
|
|
@ -109,7 +112,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
|
|||
if list.is_empty() {
|
||||
ui.allocate_ui(vec2(width, body_h), |ui| {
|
||||
ui.set_min_height(body_h);
|
||||
ui.label(egui::RichText::new("No shortcuts match").color(t.text_secondary).font(regular(12.0)));
|
||||
ui.label(egui::RichText::new(tr("No shortcuts match")).color(t.text_secondary).font(regular(12.0)));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ use serde_json::{Value, json};
|
|||
use crate::FigApp;
|
||||
use crate::a11y::Tip;
|
||||
use crate::colorpicker::PickerTarget;
|
||||
use crate::i18n::{tr, trf};
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::theme::{Tokens, regular, semibold};
|
||||
use crate::widgets;
|
||||
|
|
@ -88,10 +89,10 @@ impl StyleSlot {
|
|||
|
||||
fn title(self) -> &'static str {
|
||||
match self {
|
||||
StyleSlot::Fill | StyleSlot::Stroke => "Color styles",
|
||||
StyleSlot::Effect => "Effect styles",
|
||||
StyleSlot::Grid => "Grid styles",
|
||||
StyleSlot::Text => "Text styles",
|
||||
StyleSlot::Fill | StyleSlot::Stroke => tr("Color styles"),
|
||||
StyleSlot::Effect => tr("Effect styles"),
|
||||
StyleSlot::Grid => tr("Grid styles"),
|
||||
StyleSlot::Text => tr("Text styles"),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -203,8 +204,8 @@ pub fn text_link(app: &FigApp, doc: &Document, ids: &[NodeId]) -> Option<Option<
|
|||
pub fn chip(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId], slot: StyleSlot) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (name, mixed) = match link_of(app, doc, ids, slot) {
|
||||
Some(Some(id)) => (doc.style(&id).map_or_else(|| "Missing style".to_string(), |st| st.name.clone()), false),
|
||||
None if slot == StyleSlot::Text => ("Mixed".to_string(), true),
|
||||
Some(Some(id)) => (doc.style(&id).map_or_else(|| tr("Missing style").to_string(), |st| st.name.clone()), false),
|
||||
None if slot == StyleSlot::Text => (tr("Mixed").to_string(), true),
|
||||
_ => return,
|
||||
};
|
||||
let mut open_it = false;
|
||||
|
|
@ -218,9 +219,9 @@ pub fn chip(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId], slot:
|
|||
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(14.0, 14.0)), icon, t.icon_secondary);
|
||||
let col = if mixed { t.text_secondary } else { t.text };
|
||||
crate::bidi::paint_name(ui.painter(), pos2(r.min.x + 26.0, r.center().y), &name, regular(11.0), col, r.width() - 32.0);
|
||||
let tip = if mixed { "The selection uses several text styles: click to apply one" } else { "Change style" };
|
||||
let tip = if mixed { tr("The selection uses several text styles: click to apply one") } else { tr("Change style") };
|
||||
open_it = resp.on_hover_text(tip).clicked();
|
||||
let detach = if mixed { "Detach styles" } else { "Detach style" };
|
||||
let detach = if mixed { tr("Detach styles") } else { tr("Detach style") };
|
||||
if icons::button(ui, Icon::Detach, 24.0, false, detach).clicked() {
|
||||
app.run("style.detach", json!({"ids": ids, "which": slot.key()}));
|
||||
}
|
||||
|
|
@ -424,7 +425,7 @@ fn style_row(ui: &mut Ui, s: &Style, key: Key, slot: StyleSlot, applied: bool, e
|
|||
}
|
||||
}
|
||||
if hovered && editable {
|
||||
if menu_icon(ui, edit_r, Icon::Adjust, "Edit style", false).clicked() {
|
||||
if menu_icon(ui, edit_r, Icon::Adjust, tr("Edit style"), false).clicked() {
|
||||
out.act = Some(Act::Edit(s.id.clone()));
|
||||
return;
|
||||
}
|
||||
|
|
@ -517,21 +518,21 @@ fn listing(app: &FigApp, ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut
|
|||
x -= 26.0;
|
||||
r
|
||||
};
|
||||
let create_tip = if slot == StyleSlot::Text { "Create style from the selected text" } else { "Create style" };
|
||||
let create_tip = if slot == StyleSlot::Text { tr("Create style from the selected text") } else { tr("Create style") };
|
||||
if menu_icon(ui, slot_rect(), Icon::Plus, create_tip, false).clicked() {
|
||||
out.act = Some(Act::Create);
|
||||
}
|
||||
if link != Some(None) && menu_icon(ui, slot_rect(), Icon::Detach, "Detach style", false).clicked() {
|
||||
if link != Some(None) && menu_icon(ui, slot_rect(), Icon::Detach, tr("Detach style"), false).clicked() {
|
||||
out.act = Some(Act::Detach);
|
||||
}
|
||||
if slot.has_grid() {
|
||||
let (icon, tip) = if grid { (Icon::ViewList, "Show as list") } else { (Icon::ViewGrid, "Show as grid") };
|
||||
let (icon, tip) = if grid { (Icon::ViewList, tr("Show as list")) } else { (Icon::ViewGrid, tr("Show as grid")) };
|
||||
if menu_icon(ui, slot_rect(), icon, tip, false).clicked() {
|
||||
out.act = Some(Act::ToggleView);
|
||||
}
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut p.query).hint_text("Search").font(regular(11.0)).desired_width(W));
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut p.query).hint_text(tr("Search")).font(regular(11.0)).desired_width(W));
|
||||
if !r.has_focus() && !crate::a11y::field_focused(ui.ctx()) {
|
||||
r.request_focus();
|
||||
}
|
||||
|
|
@ -555,9 +556,9 @@ fn listing(app: &FigApp, ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut
|
|||
if sources.iter().all(|(_, l)| l.is_empty()) {
|
||||
let any = library::local_styles(doc).any(|s| slot.holds(&s.kind));
|
||||
let hint = match (any, slot) {
|
||||
(true, _) => "No matching styles",
|
||||
(false, StyleSlot::Text) => "No text styles yet. Click + to create one from the selected text.",
|
||||
(false, _) => "No styles yet. Click + to create one from the selection.",
|
||||
(true, _) => tr("No matching styles"),
|
||||
(false, StyleSlot::Text) => tr("No text styles yet. Click + to create one from the selected text."),
|
||||
(false, _) => tr("No styles yet. Click + to create one from the selection."),
|
||||
};
|
||||
caption(ui, hint);
|
||||
}
|
||||
|
|
@ -568,7 +569,7 @@ fn listing(app: &FigApp, ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut
|
|||
} else if list.is_empty() {
|
||||
continue;
|
||||
} else if sources.len() > 1 {
|
||||
caption(ui, "This file");
|
||||
caption(ui, tr("This file"));
|
||||
}
|
||||
let lib_id = lib.map(|l| l.manifest.id.clone());
|
||||
for (f, styles) in by_folder(list.iter().copied()) {
|
||||
|
|
@ -611,7 +612,7 @@ fn listing(app: &FigApp, ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut
|
|||
}
|
||||
if !colors.is_empty() {
|
||||
ui.add_space(6.0);
|
||||
caption(ui, "Color variables");
|
||||
caption(ui, tr("Color variables"));
|
||||
}
|
||||
for (lib, c, vars) in &colors {
|
||||
crate::variable_ui::group_caption(ui, *lib, c);
|
||||
|
|
@ -644,9 +645,9 @@ fn property_row(ui: &mut Ui, label: &str, paint: Option<&Paint>, slot: StyleSlot
|
|||
/// A layout grid's summary ("Columns 12", "Grid 8").
|
||||
fn grid_label(g: &figcraft_doc::LayoutGrid) -> String {
|
||||
match g.kind {
|
||||
GridKind::Grid => format!("Grid {}", g.size),
|
||||
GridKind::Columns => format!("Columns {}", g.count),
|
||||
GridKind::Rows => format!("Rows {}", g.count),
|
||||
GridKind::Grid => trf("Grid {size}", &[("size", &g.size)]),
|
||||
GridKind::Columns => trf("Columns {count}", &[("count", &g.count)]),
|
||||
GridKind::Rows => trf("Rows {count}", &[("count", &g.count)]),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -672,23 +673,23 @@ fn text_properties(ui: &mut Ui, id: &str, def: &TextStyle, out: &mut Frame) {
|
|||
// The dropdowns' menus open outside the dark scope (they're regular popups).
|
||||
let (family_resp, style_resp) = crate::theme::on_dark(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(8.0, 6.0);
|
||||
let family_resp = widgets::elided_dropdown(ui, &def.font_family, W).on_hover_text("Font");
|
||||
let family_resp = widgets::elided_dropdown(ui, &def.font_family, W).on_hover_text(tr("Font"));
|
||||
let mut style_resp = None;
|
||||
ui.horizontal(|ui| {
|
||||
style_resp = Some(widgets::elided_dropdown(ui, &def.font_style, half).on_hover_text("Font style"));
|
||||
style_resp = Some(widgets::elided_dropdown(ui, &def.font_style, half).on_hover_text(tr("Font style")));
|
||||
if let Some(widgets::FieldEdit::Set(v) | widgets::FieldEdit::Scrub(v, true)) =
|
||||
widgets::number_field(ui, Id::new(("ts_size", id)), widgets::Lead::Text("Size"), Some(def.font_size), "", half, 1.0)
|
||||
widgets::number_field(ui, Id::new(("ts_size", id)), widgets::Lead::Text(tr("Size")), Some(def.font_size), "", half, 1.0)
|
||||
{
|
||||
set(out, &|d| d.font_size = v.clamp(1.0, 5000.0));
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
if let Some(v) = widgets::text_field(ui, Id::new(("ts_lh", id)), &def.line_height.label(), half, "Line height")
|
||||
if let Some(v) = widgets::text_field(ui, Id::new(("ts_lh", id)), &def.line_height.label(), half, tr("Line height"))
|
||||
&& let Some(lh) = crate::typography::parse_line_height(&v, Some(def.line_height)).and_then(|v| serde_json::from_value(v).ok())
|
||||
{
|
||||
set(out, &|d| d.line_height = lh);
|
||||
}
|
||||
if let Some(v) = widgets::text_field(ui, Id::new(("ts_ls", id)), &def.letter_spacing.label(), half, "Letter spacing")
|
||||
if let Some(v) = widgets::text_field(ui, Id::new(("ts_ls", id)), &def.letter_spacing.label(), half, tr("Letter spacing"))
|
||||
&& let Some(ls) = crate::typography::parse_letter_spacing(&v, Some(def.letter_spacing)).and_then(|v| serde_json::from_value(v).ok())
|
||||
{
|
||||
set(out, &|d| d.letter_spacing = ls);
|
||||
|
|
@ -735,12 +736,12 @@ fn editor(ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut Frame) {
|
|||
return;
|
||||
};
|
||||
let (hr, _) = ui.allocate_exact_size(vec2(W, 24.0), Sense::hover());
|
||||
if menu_icon(ui, Rect::from_min_size(hr.min, vec2(24.0, 24.0)), Icon::ChevronLeft, "Back to styles", false).clicked() {
|
||||
if menu_icon(ui, Rect::from_min_size(hr.min, vec2(24.0, 24.0)), Icon::ChevronLeft, tr("Back to styles"), false).clicked() {
|
||||
out.act = Some(Act::Back);
|
||||
}
|
||||
ui.painter().text(pos2(hr.min.x + 30.0, hr.center().y), Align2::LEFT_CENTER, "Edit style", semibold(11.0), t.menu_text);
|
||||
ui.painter().text(pos2(hr.min.x + 30.0, hr.center().y), Align2::LEFT_CENTER, tr("Edit style"), semibold(11.0), t.menu_text);
|
||||
ui.add_space(6.0);
|
||||
caption(ui, "Name");
|
||||
caption(ui, tr("Name"));
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut e.name).font(regular(11.0)).desired_width(W));
|
||||
if e.focus {
|
||||
r.request_focus();
|
||||
|
|
@ -751,18 +752,18 @@ fn editor(ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut Frame) {
|
|||
e.focus = false;
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
caption(ui, "Description");
|
||||
caption(ui, tr("Description"));
|
||||
ui.add(egui::TextEdit::multiline(&mut e.description).font(regular(11.0)).desired_width(W).desired_rows(2));
|
||||
ui.add_space(8.0);
|
||||
caption(ui, "Properties");
|
||||
caption(ui, tr("Properties"));
|
||||
ui.scope(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
match &style.kind {
|
||||
StyleKind::Paint { paints } if paints.is_empty() => caption(ui, "No paints"),
|
||||
StyleKind::Paint { paints } if paints.is_empty() => caption(ui, tr("No paints")),
|
||||
StyleKind::Paint { paints } => {
|
||||
// Top of the list paints on top: show in reverse like the Fill section.
|
||||
for (i, paint) in paints.iter().enumerate().rev() {
|
||||
let label = format!("{} {:.0}%", paint.label(), paint.opacity * 100.0);
|
||||
let label = format!("{} {:.0}%", tr(&paint.label()), paint.opacity * 100.0);
|
||||
if property_row(ui, &label, Some(paint), slot) {
|
||||
out.act = Some(Act::EditPaint(style.id.clone(), i));
|
||||
}
|
||||
|
|
@ -770,7 +771,7 @@ fn editor(ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut Frame) {
|
|||
}
|
||||
StyleKind::Effect { effects } => {
|
||||
for fx in effects.iter().rev() {
|
||||
property_row(ui, fx.label(), None, slot);
|
||||
property_row(ui, tr(fx.label()), None, slot);
|
||||
}
|
||||
}
|
||||
StyleKind::Grid { grids } => {
|
||||
|
|
@ -783,10 +784,11 @@ fn editor(ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut Frame) {
|
|||
});
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
if has_selection && menu_button(ui, "Update from selection").on_hover_text("Take the values from the first selected layer").clicked() {
|
||||
if has_selection && menu_button(ui, tr("Update from selection")).on_hover_text(tr("Take the values from the first selected layer")).clicked()
|
||||
{
|
||||
out.act = Some(Act::UpdateFromSelection(style.id.clone()));
|
||||
}
|
||||
if menu_button(ui, "Delete").on_hover_text("Delete the style (layers keep their values)").clicked() {
|
||||
if menu_button(ui, tr("Delete")).on_hover_text(tr("Delete the style (layers keep their values)")).clicked() {
|
||||
out.act = Some(Act::Delete(style.id.clone()));
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2};
|
|||
use serde_json::json;
|
||||
|
||||
use crate::a11y::Tip;
|
||||
use crate::i18n::{tr, trf};
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::theme::{Tokens, regular};
|
||||
use crate::{FigApp, TitleBar};
|
||||
|
|
@ -31,9 +32,9 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
|
|||
let mut x = rect.min.x + if app.ui.title_bar == TitleBar::Inset { 76.0 } else { 8.0 };
|
||||
// Home.
|
||||
let hr = Rect::from_min_size(pos2(x, rect.min.y), vec2(44.0, HEIGHT));
|
||||
let hresp = ui.interact(hr, ui.id().with("home"), Sense::click()).tip("Home");
|
||||
let hresp = ui.interact(hr, ui.id().with("home"), Sense::click()).tip(tr("Home"));
|
||||
let home_active = app.ui.home || app.session.docs.is_empty();
|
||||
crate::a11y::tab(&hresp, "Home", home_active);
|
||||
crate::a11y::tab(&hresp, tr("Home"), home_active);
|
||||
if home_active {
|
||||
ui.painter().rect_filled(hr, CornerRadius::ZERO, t.tab_active);
|
||||
} else if hresp.hovered() {
|
||||
|
|
@ -80,7 +81,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
|
|||
}
|
||||
let cr = Rect::from_center_size(pos2(r.max.x - 16.0, r.center().y), vec2(18.0, 18.0));
|
||||
if resp.hovered() || active {
|
||||
let cresp = ui.interact(cr, ui.id().with(("tabclose", d.uid)), Sense::click()).tip("Close tab");
|
||||
let cresp = ui.interact(cr, ui.id().with(("tabclose", d.uid)), Sense::click()).tip(tr("Close tab"));
|
||||
if cresp.hovered() {
|
||||
ui.painter().rect_filled(cr, CornerRadius::same(4), t.pressed);
|
||||
}
|
||||
|
|
@ -100,7 +101,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
|
|||
}
|
||||
// New tab.
|
||||
let nr = Rect::from_center_size(pos2(x + 20.0, rect.center().y), vec2(28.0, 28.0));
|
||||
let nresp = ui.interact(nr, ui.id().with("newtab"), Sense::click()).tip(crate::shortcuts::tip("New design file", "file.new"));
|
||||
let nresp = ui.interact(nr, ui.id().with("newtab"), Sense::click()).tip(crate::shortcuts::tip(tr("New design file"), "file.new"));
|
||||
if nresp.hovered() {
|
||||
ui.painter().rect_filled(nr, CornerRadius::same(5), t.hover);
|
||||
}
|
||||
|
|
@ -110,12 +111,12 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
|
|||
app.ui.home = false;
|
||||
}
|
||||
// Right side: theme toggle (left of the window buttons).
|
||||
let tr = Rect::from_center_size(pos2(rect.max.x - buttons_w - 24.0, rect.center().y), vec2(28.0, 28.0));
|
||||
let tresp = ui.interact(tr, ui.id().with("theme"), Sense::click()).tip("Toggle light/dark theme");
|
||||
let theme_r = Rect::from_center_size(pos2(rect.max.x - buttons_w - 24.0, rect.center().y), vec2(28.0, 28.0));
|
||||
let tresp = ui.interact(theme_r, ui.id().with("theme"), Sense::click()).tip(tr("Toggle light/dark theme"));
|
||||
if tresp.hovered() {
|
||||
ui.painter().rect_filled(tr, CornerRadius::same(5), t.hover);
|
||||
ui.painter().rect_filled(theme_r, CornerRadius::same(5), t.hover);
|
||||
}
|
||||
icons::paint(ui.painter(), tr.shrink(7.0), if app.ui.dark { Icon::Sun } else { Icon::Moon }, t.icon);
|
||||
icons::paint(ui.painter(), theme_r.shrink(7.0), if app.ui.dark { Icon::Sun } else { Icon::Moon }, t.icon);
|
||||
if tresp.clicked() {
|
||||
let cmd = if app.ui.dark { "view.themeLight" } else { "view.themeDark" };
|
||||
app.run(cmd, json!({}));
|
||||
|
|
@ -129,7 +130,10 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
|
|||
let dirty = app.session.docs.get(i).is_some_and(|d| d.is_dirty() && d.branch_of.is_none());
|
||||
if dirty {
|
||||
app.ui.find = None;
|
||||
app.toast(format!("Unsaved changes: save first, or close again with {} to discard", crate::toolbar::pretty_shortcut("Alt")));
|
||||
app.toast(trf(
|
||||
"Unsaved changes: save first, or close again with {key} to discard",
|
||||
&[("key", &crate::toolbar::pretty_shortcut("Alt"))],
|
||||
));
|
||||
if ui.input(|inp| inp.modifiers.alt) {
|
||||
app.run("file.close", json!({"index": i, "force": true}));
|
||||
}
|
||||
|
|
@ -148,9 +152,13 @@ fn window_buttons(ui: &mut Ui, bar: Rect) -> f32 {
|
|||
let t = Tokens::get(ui.ctx());
|
||||
let max = ui.input(|i| i.viewport().maximized.unwrap_or(false));
|
||||
let buttons = [
|
||||
(Icon::WinMinimize, "Minimize", egui::ViewportCommand::Minimized(true)),
|
||||
(if max { Icon::WinRestore } else { Icon::WinMaximize }, if max { "Restore" } else { "Maximize" }, egui::ViewportCommand::Maximized(!max)),
|
||||
(Icon::Close, "Close", egui::ViewportCommand::Close),
|
||||
(Icon::WinMinimize, tr("Minimize"), egui::ViewportCommand::Minimized(true)),
|
||||
(
|
||||
if max { Icon::WinRestore } else { Icon::WinMaximize },
|
||||
if max { tr("Restore") } else { tr("Maximize") },
|
||||
egui::ViewportCommand::Maximized(!max),
|
||||
),
|
||||
(Icon::Close, tr("Close"), egui::ViewportCommand::Close),
|
||||
];
|
||||
for (k, (icon, tip, cmd)) in buttons.into_iter().enumerate() {
|
||||
let r = Rect::from_min_size(pos2(bar.max.x - W * (3 - k) as f32, bar.min.y), vec2(W, bar.height()));
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use serde_json::json;
|
|||
|
||||
use crate::FigApp;
|
||||
use crate::a11y::{self, Nav, Region, Tip};
|
||||
use crate::i18n::{tr, tr_noop, trf};
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::theme::{ACCENT, Tokens, regular};
|
||||
|
||||
|
|
@ -18,15 +19,19 @@ struct Group {
|
|||
}
|
||||
|
||||
const GROUPS: &[Group] = &[
|
||||
Group { key: "move", name: "Move tools", tools: &[(ToolId::Move, Icon::Move), (ToolId::Hand, Icon::Hand), (ToolId::Scale, Icon::Scale)] },
|
||||
Group {
|
||||
key: "move",
|
||||
name: tr_noop("Move tools"),
|
||||
tools: &[(ToolId::Move, Icon::Move), (ToolId::Hand, Icon::Hand), (ToolId::Scale, Icon::Scale)],
|
||||
},
|
||||
Group {
|
||||
key: "frame",
|
||||
name: "Frame tools",
|
||||
name: tr_noop("Frame tools"),
|
||||
tools: &[(ToolId::Frame, Icon::Frame), (ToolId::Section, Icon::Section), (ToolId::Slice, Icon::Slice)],
|
||||
},
|
||||
Group {
|
||||
key: "shape",
|
||||
name: "Shape tools",
|
||||
name: tr_noop("Shape tools"),
|
||||
tools: &[
|
||||
(ToolId::Rectangle, Icon::Rectangle),
|
||||
(ToolId::Line, Icon::Line),
|
||||
|
|
@ -38,11 +43,11 @@ const GROUPS: &[Group] = &[
|
|||
},
|
||||
Group {
|
||||
key: "pen",
|
||||
name: "Drawing tools",
|
||||
name: tr_noop("Drawing tools"),
|
||||
tools: &[(ToolId::Pen, Icon::Pen), (ToolId::Pencil, Icon::Pencil), (ToolId::ShapeBuilder, Icon::ShapeBuilder)],
|
||||
},
|
||||
Group { key: "text", name: "Text", tools: &[(ToolId::Text, Icon::Text)] },
|
||||
Group { key: "comment", name: "Comment", tools: &[(ToolId::Comment, Icon::Comment)] },
|
||||
Group { key: "text", name: tr_noop("Text"), tools: &[(ToolId::Text, Icon::Text)] },
|
||||
Group { key: "comment", name: tr_noop("Comment"), tools: &[(ToolId::Comment, Icon::Comment)] },
|
||||
];
|
||||
|
||||
fn remembered(ui: &Ui, key: &str, default: ToolId) -> ToolId {
|
||||
|
|
@ -79,7 +84,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
|
|||
let shown = if in_group { current } else { remembered(ui, g.key, g.tools.first().map(|x| x.0).unwrap_or(ToolId::Move)) };
|
||||
let icon = g.tools.iter().find(|(tid, _)| *tid == shown).map(|(_, i)| *i).unwrap_or(Icon::Move);
|
||||
let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell));
|
||||
let resp = tool_cell(ui, &t, r, ("tool", g.key), icon, in_group, &format!("{} {}", shown.label(), pretty_shortcut(shown.shortcut())));
|
||||
let resp = tool_cell(ui, &t, r, ("tool", g.key), icon, in_group, &format!("{} {}", tr(shown.label()), pretty_shortcut(shown.shortcut())));
|
||||
if in_group {
|
||||
a11y::prefer(&mut app.ui.nav, Region::Toolbar, resp.id);
|
||||
}
|
||||
|
|
@ -92,7 +97,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
|
|||
let cr = Rect::from_center_size(pos2(x + 5.0, cy), vec2(10.0, cell));
|
||||
let cresp = ui.interact(cr, ui.id().with(("flyout", g.key)), Sense::click());
|
||||
let open = app.ui.tool_flyout == Some(g.key);
|
||||
a11y::menu_button(&cresp, g.name, open);
|
||||
a11y::menu_button(&cresp, tr(g.name), open);
|
||||
if open {
|
||||
a11y::popup(&mut app.ui.nav, cresp.id, flyout_id(g.key));
|
||||
}
|
||||
|
|
@ -112,8 +117,8 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
|
|||
}
|
||||
// Resources (components, plugins) opens the Assets panel; Actions palette.
|
||||
for (icon, tip, id) in [
|
||||
(Icon::Assets, crate::shortcuts::key_tip("Resources", "Shift+I"), "resources"),
|
||||
(Icon::Actions, crate::shortcuts::tip("Actions", "app.actions"), "actions"),
|
||||
(Icon::Assets, crate::shortcuts::key_tip(tr("Resources"), "Shift+I"), "resources"),
|
||||
(Icon::Actions, crate::shortcuts::tip(tr("Actions"), "app.actions"), "actions"),
|
||||
] {
|
||||
let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell));
|
||||
let resp = ui.interact(r, ui.id().with(id), Sense::click()).tip(tip);
|
||||
|
|
@ -134,7 +139,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
|
|||
divider(ui, &t, x, cy);
|
||||
x += 8.0;
|
||||
// Mode switcher: Draw · Design · Prototype · Inspect (code).
|
||||
let modes = [(Icon::Pencil, "Draw"), (Icon::Frame, "Design"), (Icon::Prototype, "Prototype"), (Icon::Code, "Inspect")];
|
||||
let modes = [(Icon::Pencil, tr("Draw")), (Icon::Frame, tr("Design")), (Icon::Prototype, tr("Prototype")), (Icon::Code, tr("Inspect"))];
|
||||
let active = match app.ui.right_tab {
|
||||
crate::RightTab::Design if app.session.tool.tool == ToolId::Pencil => 0,
|
||||
crate::RightTab::Design => 1,
|
||||
|
|
@ -173,12 +178,12 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
|
|||
}
|
||||
// Floating help button at the bottom right of the canvas.
|
||||
let hr = Rect::from_center_size(pos2(canvas.max.x - 32.0, canvas.max.y - 32.0), vec2(32.0, 32.0));
|
||||
let hresp = ui.interact(hr, ui.id().with("help"), Sense::click()).tip("Help and resources");
|
||||
let hresp = ui.interact(hr, ui.id().with("help"), Sense::click()).tip(tr("Help and resources"));
|
||||
ui.painter().circle_filled(hr.center() + vec2(0.0, 1.0), 17.0, Color32::from_black_alpha(20));
|
||||
ui.painter().circle_filled(hr.center(), 16.0, t.panel);
|
||||
ui.painter().text(hr.center(), Align2::CENTER_CENTER, "?", crate::theme::regular(15.0), t.text);
|
||||
crate::right::menu_popup(&hresp).show(|ui| {
|
||||
for (l, c) in [("Keyboard shortcuts", "help.shortcuts"), ("Help page", "help.page"), ("About FigCraft", "help.about")] {
|
||||
for (l, c) in [(tr("Keyboard shortcuts"), "help.shortcuts"), (tr("Help page"), "help.page"), (tr("About FigCraft"), "help.about")] {
|
||||
if crate::right::menu_item(ui, l, crate::shortcuts::shortcut(c).unwrap_or("")) {
|
||||
app.run(c, json!({}));
|
||||
ui.close();
|
||||
|
|
@ -195,7 +200,7 @@ fn view_only_toolbar(app: &mut FigApp, ui: &mut Ui, why: &'static str) {
|
|||
if !matches!(app.session.tool.tool, ToolId::Move | ToolId::Hand) {
|
||||
app.run("tool.set", json!({"tool": "move"}));
|
||||
}
|
||||
let label = if why == figcraft_engine::live::PUBLISHING { "Publishing…" } else { "View only" };
|
||||
let label = if why == figcraft_engine::live::PUBLISHING { tr("Publishing…") } else { tr("View only") };
|
||||
let cell = 32.0;
|
||||
let text = ui.painter().layout_no_wrap(label.to_string(), crate::theme::semibold(11.5), t.text);
|
||||
let badge_w = 12.0 + 14.0 + 6.0 + text.size().x + 12.0;
|
||||
|
|
@ -206,7 +211,7 @@ fn view_only_toolbar(app: &mut FigApp, ui: &mut Ui, why: &'static str) {
|
|||
for (tool, icon) in [(ToolId::Move, Icon::Move), (ToolId::Hand, Icon::Hand)] {
|
||||
let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell));
|
||||
let active = app.session.tool.tool == tool;
|
||||
let resp = tool_cell(ui, &t, r, ("tool", tool.id()), icon, active, &format!("{} {}", tool.label(), pretty_shortcut(tool.shortcut())));
|
||||
let resp = tool_cell(ui, &t, r, ("tool", tool.id()), icon, active, &format!("{} {}", tr(tool.label()), pretty_shortcut(tool.shortcut())));
|
||||
if active {
|
||||
a11y::prefer(&mut app.ui.nav, Region::Toolbar, resp.id);
|
||||
}
|
||||
|
|
@ -238,9 +243,9 @@ fn edit_toolbar(app: &mut FigApp, ui: &mut Ui, current: VTool) {
|
|||
for tool in VTool::ALL {
|
||||
let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell));
|
||||
let tip = if tool == VTool::Bend {
|
||||
format!("{} hold {}", tool.label(), pretty_shortcut(tool.shortcut()))
|
||||
trf("{tool} hold {key}", &[("tool", &tr(tool.label())), ("key", &pretty_shortcut(tool.shortcut()))])
|
||||
} else {
|
||||
format!("{} {}", tool.label(), pretty_shortcut(tool.shortcut()))
|
||||
format!("{} {}", tr(tool.label()), pretty_shortcut(tool.shortcut()))
|
||||
};
|
||||
let active = tool == current && !held || held && tool == VTool::Bend;
|
||||
let icon = match tool {
|
||||
|
|
@ -262,7 +267,7 @@ fn edit_toolbar(app: &mut FigApp, ui: &mut Ui, current: VTool) {
|
|||
}
|
||||
x += cell + 4.0;
|
||||
}
|
||||
if done_button(ui, &t, x, cy, "Done editing Esc") {
|
||||
if done_button(ui, &t, x, cy, &format!("{} Esc", tr("Done editing"))) {
|
||||
app.run("vector.edit.end", json!({}));
|
||||
}
|
||||
}
|
||||
|
|
@ -275,18 +280,18 @@ fn builder_toolbar(app: &mut FigApp, ui: &mut Ui) {
|
|||
let has_regions = figcraft_engine::shapebuilder::current(&app.session).is_some_and(|a| !a.regions.is_empty());
|
||||
let alt = pretty_shortcut("Alt");
|
||||
let hint = if !has_regions {
|
||||
"Select overlapping shapes".to_string()
|
||||
tr("Select overlapping shapes").to_string()
|
||||
} else if remove {
|
||||
format!("Click or drag to remove · {alt} merges")
|
||||
trf("Click or drag to remove · {alt} merges", &[("alt", &alt)])
|
||||
} else {
|
||||
format!("Click or drag to merge · {alt} removes")
|
||||
trf("Click or drag to merge · {alt} removes", &[("alt", &alt)])
|
||||
};
|
||||
let galley = ui.painter().layout_no_wrap(hint, regular(12.0), t.text_secondary);
|
||||
let width = 2.0 * (cell + 4.0) + 8.0 + 12.0 + galley.size().x + 12.0 + 12.0 + DONE_W + 8.0;
|
||||
let rect = pill(ui, &mut app.ui.nav, app.canvas.rect, width, &t);
|
||||
let cy = rect.center().y;
|
||||
let mut x = rect.min.x + 8.0;
|
||||
for (mode, icon, tip) in [("merge", Icon::ShapeBuilder, "Merge regions"), ("remove", Icon::ShapeBuilderRemove, "Remove regions")] {
|
||||
for (mode, icon, tip) in [("merge", Icon::ShapeBuilder, tr("Merge regions")), ("remove", Icon::ShapeBuilderRemove, tr("Remove regions"))] {
|
||||
let r = Rect::from_center_size(pos2(x + cell / 2.0, cy), vec2(cell, cell));
|
||||
let active = (mode == "remove") == remove;
|
||||
let resp = tool_cell(ui, &t, r, ("sbmode", mode), icon, active, tip);
|
||||
|
|
@ -303,7 +308,7 @@ fn builder_toolbar(app: &mut FigApp, ui: &mut Ui) {
|
|||
let w = galley.size().x;
|
||||
ui.painter().galley(pos2(x, cy - galley.size().y / 2.0), galley, t.text_secondary);
|
||||
x += w + 12.0;
|
||||
if done_button(ui, &t, x, cy, &format!("Done {}", pretty_shortcut("Enter"))) {
|
||||
if done_button(ui, &t, x, cy, &format!("{} {}", tr("Done"), pretty_shortcut("Enter"))) {
|
||||
app.run("tool.set", json!({"tool": "move"}));
|
||||
}
|
||||
}
|
||||
|
|
@ -354,7 +359,7 @@ fn done_button(ui: &mut Ui, t: &Tokens, x: f32, cy: f32, tip: &str) -> bool {
|
|||
let done = Rect::from_min_size(pos2(x + 12.0, cy - 14.0), vec2(DONE_W, 28.0));
|
||||
let resp = ui.interact(done, ui.id().with("tool_done"), Sense::click()).tip(tip);
|
||||
ui.painter().rect_filled(done, CornerRadius::same(6), if resp.hovered() { t.accent_hover } else { ACCENT });
|
||||
ui.painter().text(done.center(), Align2::CENTER_CENTER, "Done", crate::theme::semibold(11.5), Color32::WHITE);
|
||||
ui.painter().text(done.center(), Align2::CENTER_CENTER, tr("Done"), crate::theme::semibold(11.5), Color32::WHITE);
|
||||
resp.clicked()
|
||||
}
|
||||
|
||||
|
|
@ -371,7 +376,7 @@ fn flyout(app: &mut FigApp, ui: &mut Ui, g: &Group, bottom_left: egui::Pos2) {
|
|||
for (tid, icon) in g.tools {
|
||||
let r = Rect::from_min_size(pos2(rect.min.x + 8.0, y), vec2(w - 16.0, item_h));
|
||||
let resp = ui.interact(r, ui.id().with(("fly", tid.id())), Sense::click());
|
||||
a11y::menu_item(&resp, tid.label(), tid.shortcut(), Some(*tid == app.session.tool.tool));
|
||||
a11y::menu_item(&resp, tr(tid.label()), tid.shortcut(), Some(*tid == app.session.tool.tool));
|
||||
// The keyboard's item is highlighted like the hovered one.
|
||||
a11y::own_focus(&resp);
|
||||
if resp.hovered() || resp.has_focus() {
|
||||
|
|
@ -381,7 +386,7 @@ fn flyout(app: &mut FigApp, ui: &mut Ui, g: &Group, bottom_left: egui::Pos2) {
|
|||
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 10.0, r.center().y), vec2(12.0, 12.0)), Icon::Check, t.menu_text);
|
||||
}
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 32.0, r.center().y), vec2(16.0, 16.0)), *icon, t.menu_text);
|
||||
ui.painter().text(pos2(r.min.x + 48.0, r.center().y), Align2::LEFT_CENTER, tid.label(), regular(12.0), t.menu_text);
|
||||
ui.painter().text(pos2(r.min.x + 48.0, r.center().y), Align2::LEFT_CENTER, tr(tid.label()), regular(12.0), t.menu_text);
|
||||
ui.painter().text(pos2(r.max.x - 8.0, r.center().y), Align2::RIGHT_CENTER, pretty_shortcut(tid.shortcut()), regular(12.0), t.menu_dim);
|
||||
if resp.clicked() {
|
||||
chosen = Some(*tid);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ use figcraft_doc::*;
|
|||
use serde_json::{Value, json};
|
||||
|
||||
use crate::FigApp;
|
||||
use crate::i18n::{tr, tr_noop, trf};
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::right::{common, menu_item, menu_popup, row};
|
||||
use crate::theme::{Tokens, regular, semibold};
|
||||
|
|
@ -22,21 +23,25 @@ const SIZES: [f64; 15] = [10.0, 11.0, 12.0, 13.0, 14.0, 16.0, 20.0, 24.0, 32.0,
|
|||
|
||||
/// OpenType features offered in Type settings ▸ Details: (tag, label, on by default in fonts).
|
||||
const FEATURES: [(&str, &str, bool); 12] = [
|
||||
("liga", "Standard ligatures", true),
|
||||
("calt", "Contextual alternates", true),
|
||||
("tnum", "Tabular numbers", false),
|
||||
("frac", "Fractions", false),
|
||||
("case", "Case-sensitive forms", false),
|
||||
("zero", "Slashed zero", false),
|
||||
("ss01", "Stylistic set 1", false),
|
||||
("ss02", "Stylistic set 2", false),
|
||||
("ss03", "Stylistic set 3", false),
|
||||
("ss04", "Stylistic set 4", false),
|
||||
("ss05", "Stylistic set 5", false),
|
||||
("smcp", "Small capitals", false),
|
||||
("liga", tr_noop("Standard ligatures"), true),
|
||||
("calt", tr_noop("Contextual alternates"), true),
|
||||
("tnum", tr_noop("Tabular numbers"), false),
|
||||
("frac", tr_noop("Fractions"), false),
|
||||
("case", tr_noop("Case-sensitive forms"), false),
|
||||
("zero", tr_noop("Slashed zero"), false),
|
||||
("ss01", tr_noop("Stylistic set 1"), false),
|
||||
("ss02", tr_noop("Stylistic set 2"), false),
|
||||
("ss03", tr_noop("Stylistic set 3"), false),
|
||||
("ss04", tr_noop("Stylistic set 4"), false),
|
||||
("ss05", tr_noop("Stylistic set 5"), false),
|
||||
("smcp", tr_noop("Small capitals"), false),
|
||||
];
|
||||
|
||||
const MIXED: &str = "Mixed";
|
||||
/// Shown for differing values (translated where shown: `tr(MIXED)`).
|
||||
const MIXED: &str = tr_noop("Mixed");
|
||||
|
||||
/// The Type settings tabs (Variable only for variable fonts).
|
||||
const TABS: [&str; 3] = [tr_noop("Basics"), tr_noop("Details"), tr_noop("Variable")];
|
||||
|
||||
/// The value shared by every style, or None when they differ.
|
||||
fn uniform<T: PartialEq + Clone>(styles: &[TextStyle], f: impl Fn(&TextStyle) -> T) -> Option<T> {
|
||||
|
|
@ -85,14 +90,15 @@ pub fn typography(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId])
|
|||
let styles = target_styles(doc, &ids, range);
|
||||
let apply_style = |app: &mut FigApp, key: &str, v: Value| commit_text(app, &ids, range, json!({ key: v }));
|
||||
|
||||
let hdr = widgets::section_header(ui, "Typography", &[(Icon::Variable, "Apply variable to text"), (Icon::Styles, "Text styles")], false);
|
||||
let hdr =
|
||||
widgets::section_header(ui, tr("Typography"), &[(Icon::Variable, tr("Apply variable to text")), (Icon::Styles, tr("Text styles"))], false);
|
||||
if hdr == Some(0) {
|
||||
crate::variable_ui::open_at_pointer(ui, &ids, "characters", VariableType::String);
|
||||
}
|
||||
if hdr == Some(1) {
|
||||
crate::style_picker::open(app, ui, &ids, crate::style_picker::StyleSlot::Text);
|
||||
}
|
||||
crate::variable_ui::bound_row(app, ui, &ids, "characters", VariableType::String, Lead::Icon(Icon::Text, "Text"), f32::INFINITY);
|
||||
crate::variable_ui::bound_row(app, ui, &ids, "characters", VariableType::String, Lead::Icon(Icon::Text, tr("Text")), f32::INFINITY);
|
||||
// A text style: its chip stands for the font rows (they show again when the chars differ).
|
||||
crate::style_picker::chip(app, ui, doc, &ids, crate::style_picker::StyleSlot::Text);
|
||||
if !matches!(crate::style_picker::text_link(app, doc, &ids), Some(Some(_))) {
|
||||
|
|
@ -111,7 +117,7 @@ fn path_row(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], ids:
|
|||
let other = sel.iter().find(|i| !ids.contains(i)).and_then(|i| doc.node(*i));
|
||||
if ids.len() == 1 && sel.len() == 2 && other.is_some_and(|n| figcraft_doc::geometry::stroke_geometry(n).is_some()) {
|
||||
row(ui, 32.0, |ui| {
|
||||
if widgets::button(ui, "Put text on path", false, true).clicked() {
|
||||
if widgets::button(ui, tr("Put text on path"), false, true).clicked() {
|
||||
app.run("text.onPath", json!({ "ids": sel }));
|
||||
}
|
||||
});
|
||||
|
|
@ -121,7 +127,7 @@ fn path_row(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], ids:
|
|||
let start = paths.first().map(|p| p.0).filter(|s| paths.iter().all(|p| p.0 == *s));
|
||||
let flip = paths.iter().all(|p| p.1);
|
||||
row(ui, 32.0, |ui| {
|
||||
let lead = Lead::Icon(Icon::Pen, "Start on path");
|
||||
let lead = Lead::Icon(Icon::Pen, tr("Start on path"));
|
||||
match widgets::number_field(ui, Id::new("ty_path_start"), lead, start.map(|s| s * 100.0), "%", 96.0, 1.0) {
|
||||
Some(FieldEdit::Set(v)) => {
|
||||
app.run("text.pathOptions", json!({"ids": ids, "start": v / 100.0}));
|
||||
|
|
@ -129,10 +135,10 @@ fn path_row(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], ids:
|
|||
Some(FieldEdit::Scrub(v, done)) => live(app, "Start on path", "text.pathOptions", json!({"ids": ids, "start": v / 100.0}), done),
|
||||
None => {}
|
||||
}
|
||||
if icons::button(ui, Icon::FlipV, 24.0, flip, "Flip text to the other side").clicked() {
|
||||
if icons::button(ui, Icon::FlipV, 24.0, flip, tr("Flip text to the other side")).clicked() {
|
||||
app.run("text.pathOptions", json!({"ids": ids, "flip": !flip}));
|
||||
}
|
||||
if icons::button(ui, Icon::Detach, 24.0, false, "Remove text from path").clicked() {
|
||||
if icons::button(ui, Icon::Detach, 24.0, false, tr("Remove text from path")).clicked() {
|
||||
app.run("text.removeFromPath", json!({ "ids": ids }));
|
||||
}
|
||||
});
|
||||
|
|
@ -173,7 +179,7 @@ fn font_rows(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], styles: &[TextStyle]
|
|||
let fstyle = uniform(styles, |s| s.font_style.clone());
|
||||
row(ui, 32.0, |ui| {
|
||||
let w = ui.available_width();
|
||||
let resp = widgets::elided_dropdown(ui, family.as_deref().unwrap_or(MIXED), w);
|
||||
let resp = widgets::elided_dropdown(ui, family.as_deref().unwrap_or(tr(MIXED)), w);
|
||||
menu_popup(&resp).width(w.max(240.0)).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
if let Some(f) = font_picker(ui, family.as_deref(), resp.clicked()) {
|
||||
remember_family(ui.ctx(), &f);
|
||||
|
|
@ -186,13 +192,13 @@ fn font_rows(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], styles: &[TextStyle]
|
|||
// Style + size.
|
||||
let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0;
|
||||
row(ui, 32.0, |ui| {
|
||||
let resp = widgets::elided_dropdown(ui, fstyle.as_deref().unwrap_or(MIXED), half);
|
||||
let resp = widgets::elided_dropdown(ui, fstyle.as_deref().unwrap_or(tr(MIXED)), half);
|
||||
let fam = family.clone();
|
||||
menu_popup(&resp).width(200.0).show(|ui| match &fam {
|
||||
Some(fam) => {
|
||||
let list = figcraft_text::family_styles(fam);
|
||||
if list.is_empty() {
|
||||
widgets::label(ui, "Font not installed", t.text_tertiary);
|
||||
widgets::label(ui, tr("Font not installed"), t.text_tertiary);
|
||||
}
|
||||
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
|
||||
for s in list {
|
||||
|
|
@ -208,7 +214,7 @@ fn font_rows(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], styles: &[TextStyle]
|
|||
}
|
||||
});
|
||||
}
|
||||
None => widgets::label(ui, "Pick one font family first", t.text_tertiary),
|
||||
None => widgets::label(ui, tr("Pick one font family first"), t.text_tertiary),
|
||||
});
|
||||
let size = uniform(styles, |s| s.font_size);
|
||||
ui.spacing_mut().item_spacing.x = 0.0;
|
||||
|
|
@ -227,7 +233,7 @@ fn font_rows(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], styles: &[TextStyle]
|
|||
}
|
||||
None => {}
|
||||
}
|
||||
let more = icons::button(ui, Icon::Chevron, 20.0, false, "Font size");
|
||||
let more = icons::button(ui, Icon::Chevron, 20.0, false, tr("Font size"));
|
||||
menu_popup(&more).width(90.0).show(|ui| {
|
||||
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
|
||||
for v in SIZES {
|
||||
|
|
@ -244,16 +250,16 @@ fn font_rows(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], styles: &[TextStyle]
|
|||
// Line height + letter spacing.
|
||||
row(ui, 32.0, |ui| {
|
||||
let lh = uniform(styles, |s| s.line_height);
|
||||
let lh_label = lh.map(|l| l.label()).unwrap_or_else(|| MIXED.into());
|
||||
let field = SpacingField { field: "lineHeight", id: Id::new("ty_lh"), glyph: LeadGlyph::LineHeight, tip: "Line height" };
|
||||
let lh_label = lh.map(|l| l.label()).unwrap_or_else(|| tr(MIXED).into());
|
||||
let field = SpacingField { field: "lineHeight", id: Id::new("ty_lh"), glyph: LeadGlyph::LineHeight, tip: tr("Line height") };
|
||||
if let Some(v) = spacing_field(app, ui, ids, &field, &lh_label, half, editing)
|
||||
&& let Some(v) = parse_line_height(&v, lh)
|
||||
{
|
||||
apply_style(app, "lineHeight", v);
|
||||
}
|
||||
let ls = uniform(styles, |s| s.letter_spacing);
|
||||
let ls_label = ls.map(|l| l.label()).unwrap_or_else(|| MIXED.into());
|
||||
let field = SpacingField { field: "letterSpacing", id: Id::new("ty_ls"), glyph: LeadGlyph::LetterSpacing, tip: "Letter spacing" };
|
||||
let ls_label = ls.map(|l| l.label()).unwrap_or_else(|| tr(MIXED).into());
|
||||
let field = SpacingField { field: "letterSpacing", id: Id::new("ty_ls"), glyph: LeadGlyph::LetterSpacing, tip: tr("Letter spacing") };
|
||||
if let Some(v) = spacing_field(app, ui, ids, &field, &ls_label, half, editing)
|
||||
&& let Some(v) = parse_letter_spacing(&v, ls)
|
||||
{
|
||||
|
|
@ -276,13 +282,14 @@ fn paragraph_rows(
|
|||
let ps = common(doc, ids, |n| n.kind.text().map(|t| t.paragraph_spacing.to_bits())).flatten().map(f64::from_bits);
|
||||
let resize = common(doc, ids, |n| n.kind.text().map(|t| t.auto_resize)).flatten();
|
||||
row(ui, 32.0, |ui| {
|
||||
let ps_label = ps.map(trim_num).unwrap_or_else(|| MIXED.into());
|
||||
if let Some(v) = lead_field(ui, Id::new("ty_ps"), LeadGlyph::ParagraphSpacing, &ps_label, half, "Paragraph spacing")
|
||||
let ps_label = ps.map(trim_num).unwrap_or_else(|| tr(MIXED).into());
|
||||
if let Some(v) = lead_field(ui, Id::new("ty_ps"), LeadGlyph::ParagraphSpacing, &ps_label, half, tr("Paragraph spacing"))
|
||||
&& let Some(v) = widgets::eval(&v, ps)
|
||||
{
|
||||
app.set_props(ids, json!({"paragraphSpacing": v.clamp(0.0, 10_000.0)}));
|
||||
}
|
||||
let items = [(Icon::Hug, "Auto width"), (Icon::LayoutV, "Auto height"), (Icon::Fixed, "Fixed size"), (Icon::Dots, "Truncate text")];
|
||||
let items =
|
||||
[(Icon::Hug, tr("Auto width")), (Icon::LayoutV, tr("Auto height")), (Icon::Fixed, tr("Fixed size")), (Icon::Dots, tr("Truncate text"))];
|
||||
let active = resize.map(|r| match r {
|
||||
TextAutoResize::WidthAndHeight => 0,
|
||||
TextAutoResize::Height => 1,
|
||||
|
|
@ -299,10 +306,10 @@ fn paragraph_rows(
|
|||
let align = common(doc, ids, |n| n.kind.text().map(|t| (t.align_h, t.align_v))).flatten();
|
||||
row(ui, 32.0, |ui| {
|
||||
let items = [
|
||||
(Icon::TextAlignLeft, "Align left"),
|
||||
(Icon::TextAlignCenter, "Align center"),
|
||||
(Icon::TextAlignRight, "Align right"),
|
||||
(Icon::TextAlignJustify, "Justify"),
|
||||
(Icon::TextAlignLeft, tr("Align left")),
|
||||
(Icon::TextAlignCenter, tr("Align center")),
|
||||
(Icon::TextAlignRight, tr("Align right")),
|
||||
(Icon::TextAlignJustify, tr("Justify")),
|
||||
];
|
||||
let active = align.map(|(h, _)| match h {
|
||||
TextAlignH::Left => 0,
|
||||
|
|
@ -314,7 +321,7 @@ fn paragraph_rows(
|
|||
let v = ["left", "center", "right", "justified"].get(i).copied().unwrap_or("left");
|
||||
app.set_props(ids, json!({"textAlign": v}));
|
||||
}
|
||||
let items = [(Icon::TextTop, "Align top"), (Icon::TextMiddle, "Align middle"), (Icon::TextBottom, "Align bottom")];
|
||||
let items = [(Icon::TextTop, tr("Align top")), (Icon::TextMiddle, tr("Align middle")), (Icon::TextBottom, tr("Align bottom"))];
|
||||
let active = align.map(|(_, v)| match v {
|
||||
TextAlignV::Top => 0,
|
||||
TextAlignV::Middle => 1,
|
||||
|
|
@ -326,7 +333,7 @@ fn paragraph_rows(
|
|||
}
|
||||
let open_id = Id::new("ty_settings_open");
|
||||
let open = egui::Popup::is_id_open(ui.ctx(), open_id);
|
||||
let more = icons::button(ui, Icon::Dots, 24.0, open, "Type settings");
|
||||
let more = icons::button(ui, Icon::Dots, 24.0, open, tr("Type settings"));
|
||||
egui::Popup::from_toggle_button_response(&more)
|
||||
.id(open_id)
|
||||
.width(264.0)
|
||||
|
|
@ -373,12 +380,12 @@ fn type_settings(
|
|||
let t = Tokens::get(ui.ctx());
|
||||
let tab_id = Id::new("ty_settings_tab");
|
||||
let vf = variable_face(styles);
|
||||
let tabs: &[&str] = if vf.is_some() { &["Basics", "Details", "Variable"] } else { &["Basics", "Details"] };
|
||||
let tabs = &TABS[..if vf.is_some() { 3 } else { 2 }];
|
||||
let mut tab: usize = ui.data(|d| d.get_temp(tab_id)).filter(|i| *i < tabs.len()).unwrap_or(0);
|
||||
ui.horizontal(|ui| {
|
||||
for (i, l) in tabs.iter().enumerate() {
|
||||
let col = if tab == i { t.text } else { t.text_secondary };
|
||||
let r = ui.add(egui::Label::new(egui::RichText::new(*l).font(semibold(11.5)).color(col)).sense(Sense::click()));
|
||||
let r = ui.add(egui::Label::new(egui::RichText::new(tr(l)).font(semibold(11.5)).color(col)).sense(Sense::click()));
|
||||
if r.clicked() {
|
||||
tab = i;
|
||||
}
|
||||
|
|
@ -389,16 +396,17 @@ fn type_settings(
|
|||
let w = ui.available_width();
|
||||
if tab == 0 {
|
||||
let deco = uniform(styles, |s| s.decoration);
|
||||
caption(ui, "Decoration");
|
||||
caption(ui, tr("Decoration"));
|
||||
let active = deco.map(|d| match d {
|
||||
TextDecoration::None => 0,
|
||||
TextDecoration::Underline => 1,
|
||||
TextDecoration::Strikethrough => 2,
|
||||
});
|
||||
if let Some(i) = text_seg(ui, "deco", &[("—", "None"), ("U", "Underline"), ("S", "Strikethrough")], active, w) {
|
||||
let items = [("—", tr_noop("None")), ("U", tr_noop("Underline")), ("S", tr_noop("Strikethrough"))];
|
||||
if let Some(i) = text_seg(ui, "deco", &items, active, w) {
|
||||
apply_style(app, "textDecoration", json!((["none", "underline", "strikethrough"].get(i).copied().unwrap_or("none"))));
|
||||
}
|
||||
caption(ui, "Letter case");
|
||||
caption(ui, tr("Letter case"));
|
||||
let case = uniform(styles, |s| s.case);
|
||||
let active = case.map(|c| match c {
|
||||
TextCase::Original => 0,
|
||||
|
|
@ -407,24 +415,31 @@ fn type_settings(
|
|||
TextCase::Title => 3,
|
||||
TextCase::SmallCaps => 4,
|
||||
});
|
||||
let items = [("—", "As typed"), ("AG", "Uppercase"), ("ag", "Lowercase"), ("Ag", "Title case"), ("Aɢ", "Small caps")];
|
||||
let items = [
|
||||
("—", tr_noop("As typed")),
|
||||
("AG", tr_noop("Uppercase")),
|
||||
("ag", tr_noop("Lowercase")),
|
||||
("Ag", tr_noop("Title case")),
|
||||
("Aɢ", tr_noop("Small caps")),
|
||||
];
|
||||
if let Some(i) = text_seg(ui, "case", &items, active, w) {
|
||||
apply_style(app, "textCase", json!((["original", "upper", "lower", "title", "smallCaps"].get(i).copied().unwrap_or("original"))));
|
||||
}
|
||||
caption(ui, "List style");
|
||||
caption(ui, tr("List style"));
|
||||
let list = common(doc, ids, |n| n.kind.text().map(|t| t.list)).flatten();
|
||||
let active = list.map(|l| match l {
|
||||
ListStyle::None => 0,
|
||||
ListStyle::Bulleted => 1,
|
||||
ListStyle::Numbered => 2,
|
||||
});
|
||||
if let Some(i) = text_seg(ui, "list", &[("—", "No list"), ("•", "Bulleted list"), ("1.", "Numbered list")], active, w) {
|
||||
let items = [("—", tr_noop("No list")), ("•", tr_noop("Bulleted list")), ("1.", tr_noop("Numbered list"))];
|
||||
if let Some(i) = text_seg(ui, "list", &items, active, w) {
|
||||
app.set_props(ids, json!({"listStyle": (["none", "bulleted", "numbered"].get(i).copied().unwrap_or("none"))}));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
let indent = common(doc, ids, |n| n.kind.text().map(|t| t.paragraph_indent.to_bits())).flatten().map(f64::from_bits);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(vec2(110.0, FIELD_H), egui::Label::new(egui::RichText::new("Paragraph indent").font(regular(11.0)).color(t.text)));
|
||||
ui.add_sized(vec2(110.0, FIELD_H), egui::Label::new(egui::RichText::new(tr("Paragraph indent")).font(regular(11.0)).color(t.text)));
|
||||
if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) =
|
||||
widgets::number_field(ui, Id::new("ty_indent"), Lead::None, indent, "", w - 118.0, 1.0)
|
||||
{
|
||||
|
|
@ -432,9 +447,9 @@ fn type_settings(
|
|||
}
|
||||
});
|
||||
let resize = common(doc, ids, |n| n.kind.text().map(|t| t.auto_resize)).flatten();
|
||||
caption(ui, "Truncate text");
|
||||
caption(ui, tr("Truncate text"));
|
||||
let active = resize.map(|r| usize::from(r == TextAutoResize::Truncate));
|
||||
match text_seg(ui, "trunc", &[("Off", "Don't truncate"), ("…", "Truncate with an ellipsis")], active, w) {
|
||||
match text_seg(ui, "trunc", &[(tr("Off"), tr_noop("Don't truncate")), ("…", tr_noop("Truncate with an ellipsis"))], active, w) {
|
||||
Some(1) => app.set_props(ids, json!({"textAutoResize": "truncate"})),
|
||||
Some(_) if resize != Some(TextAutoResize::Height) && resize != Some(TextAutoResize::WidthAndHeight) => {
|
||||
app.set_props(ids, json!({"textAutoResize": "none"}))
|
||||
|
|
@ -446,9 +461,9 @@ fn type_settings(
|
|||
let max = common(doc, ids, |n| n.kind.text().map(|t| t.max_lines)).flatten();
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(vec2(110.0, FIELD_H), egui::Label::new(egui::RichText::new("Max lines").font(regular(11.0)).color(t.text)));
|
||||
ui.add_sized(vec2(110.0, FIELD_H), egui::Label::new(egui::RichText::new(tr("Max lines")).font(regular(11.0)).color(t.text)));
|
||||
let shown = max.map(f64::from).filter(|m| *m > 0.0);
|
||||
let empty = if max.is_some() { "Auto" } else { MIXED };
|
||||
let empty = if max.is_some() { tr("Auto") } else { tr(MIXED) };
|
||||
if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) =
|
||||
widgets::number_field_or(ui, Id::new("ty_maxlines"), shown, empty, w - 118.0, 1.0)
|
||||
{
|
||||
|
|
@ -461,14 +476,14 @@ fn type_settings(
|
|||
} else {
|
||||
// Which features the font has (first style shown).
|
||||
let face = uniform(styles, |s| (s.font_family.clone(), s.font_style.clone())).and_then(|(f, s)| figcraft_text::find_face(&f, &s));
|
||||
let face_name = face.as_ref().map(|f| format!("{} {}", f.family, f.style)).unwrap_or_else(|| "Mixed fonts".into());
|
||||
let face_name = face.as_ref().map(|f| format!("{} {}", f.family, f.style)).unwrap_or_else(|| tr("Mixed fonts").into());
|
||||
widgets::label(ui, &face_name, t.text_secondary);
|
||||
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
|
||||
for (tag, label, default_on) in FEATURES {
|
||||
let state = common(doc, ids, |n| n.kind.text().map(|t| t.features.get(tag).copied())).flatten();
|
||||
let on = state.map(|s| s.unwrap_or(default_on));
|
||||
let supported = face.as_ref().is_none_or(|f| f.has_feature(tag.as_bytes().try_into().unwrap_or(b" ")));
|
||||
if feature_row(ui, tag, label, on, supported) {
|
||||
if feature_row(ui, tag, tr(label), on, supported) {
|
||||
let want = !on.unwrap_or(false);
|
||||
// Back to the font's default clears the override.
|
||||
let v = if want == default_on { Value::Null } else { json!(want) };
|
||||
|
|
@ -518,13 +533,15 @@ fn variable_axes(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], styles: &[TextSt
|
|||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
let (lr, _) = ui.allocate_exact_size(vec2(label_w, FIELD_H), Sense::hover());
|
||||
let name = ui.painter().layout(axis.name.clone(), regular(11.0), t.text, label_w);
|
||||
let tip = format!(
|
||||
"{} ({}): {} – {}, default {}. Double-click the slider to go back to the style's value; Escape while dragging cancels.",
|
||||
axis.name,
|
||||
axis.tag,
|
||||
trim_num(axis.min),
|
||||
trim_num(axis.max),
|
||||
trim_num(axis.default)
|
||||
let tip = trf(
|
||||
"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels.",
|
||||
&[
|
||||
("name", &axis.name),
|
||||
("tag", &axis.tag),
|
||||
("min", &trim_num(axis.min)),
|
||||
("max", &trim_num(axis.max)),
|
||||
("default", &trim_num(axis.default)),
|
||||
],
|
||||
);
|
||||
ui.interact(lr, Id::new(("ty_axis_label", &axis.tag)), Sense::hover()).on_hover_text(tip);
|
||||
ui.painter().galley(pos2(lr.min.x, lr.center().y - name.size().y / 2.0), name, t.text);
|
||||
|
|
@ -568,8 +585,8 @@ fn variable_axes(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], styles: &[TextSt
|
|||
}
|
||||
if styles.iter().any(|s| !s.variations.is_empty()) {
|
||||
ui.add_space(8.0);
|
||||
let r = ui.add(egui::Label::new(egui::RichText::new("Reset to style").font(regular(11.0)).color(t.accent)).sense(Sense::click()));
|
||||
if r.on_hover_text("Back to the values the font style implies").clicked() {
|
||||
let r = ui.add(egui::Label::new(egui::RichText::new(tr("Reset to style")).font(regular(11.0)).color(t.accent)).sense(Sense::click()));
|
||||
if r.on_hover_text(tr("Back to the values the font style implies")).clicked() {
|
||||
app.run("text.setVariations", json!({"ids": ids, "variations": {}, "replace": true}));
|
||||
}
|
||||
}
|
||||
|
|
@ -591,14 +608,14 @@ fn named_instances(
|
|||
let current = uniform(styles, |s| (s.variations.is_empty(), s.font_style.clone()));
|
||||
let label = match ¤t {
|
||||
Some((true, st)) if face.instance(st).is_some() => st.clone(),
|
||||
Some(_) => "Custom".to_string(),
|
||||
None => MIXED.to_string(),
|
||||
Some(_) => tr("Custom").to_string(),
|
||||
None => tr(MIXED).to_string(),
|
||||
};
|
||||
ui.add_space(4.0);
|
||||
// An inline list (a menu would close the Type settings popover it opens from).
|
||||
let open_id = Id::new("ty_instances_open");
|
||||
let mut open = ui.data(|d| d.get_temp::<bool>(open_id)).unwrap_or(false);
|
||||
if widgets::elided_dropdown(ui, &label, ui.available_width()).on_hover_text("Named instance").clicked() {
|
||||
if widgets::elided_dropdown(ui, &label, ui.available_width()).on_hover_text(tr("Named instance")).clicked() {
|
||||
open = !open;
|
||||
}
|
||||
if open {
|
||||
|
|
@ -668,7 +685,8 @@ fn caption(ui: &mut Ui, s: &str) {
|
|||
ui.label(egui::RichText::new(s).font(regular(10.5)).color(t.text_secondary));
|
||||
}
|
||||
|
||||
/// A segmented control with short text labels (decoration, case, list…). Returns the clicked index.
|
||||
/// A segmented control with short text labels (decoration, case, list…); `tip`s are English keys
|
||||
/// (matched below), translated where shown. Returns the clicked index.
|
||||
fn text_seg(ui: &mut Ui, salt: &str, items: &[(&str, &str)], active: Option<usize>, width: f32) -> Option<usize> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover());
|
||||
|
|
@ -677,8 +695,8 @@ fn text_seg(ui: &mut Ui, salt: &str, items: &[(&str, &str)], active: Option<usiz
|
|||
let mut out = None;
|
||||
for (i, (label, tip)) in items.iter().enumerate() {
|
||||
let r = Rect::from_min_size(pos2(rect.min.x + i as f32 * cell, rect.min.y), vec2(cell, FIELD_H));
|
||||
let resp = ui.interact(r, Id::new(("ty_seg", salt, i)), Sense::click()).on_hover_text(*tip);
|
||||
crate::a11y::toggle(&resp, tip, active == Some(i));
|
||||
let resp = ui.interact(r, Id::new(("ty_seg", salt, i)), Sense::click()).on_hover_text(tr(tip));
|
||||
crate::a11y::toggle(&resp, tr(tip), active == Some(i));
|
||||
if active == Some(i) {
|
||||
ui.painter().rect_filled(r.shrink(2.0), CornerRadius::same(4), t.panel);
|
||||
ui.painter().rect_stroke(r.shrink(2.0), CornerRadius::same(4), Stroke::new(1.0, t.border), StrokeKind::Inside);
|
||||
|
|
@ -714,7 +732,7 @@ fn feature_row(ui: &mut Ui, tag: &str, label: &str, on: Option<bool>, supported:
|
|||
let t = Tokens::get(ui.ctx());
|
||||
let w = ui.available_width();
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(w, 24.0), Sense::click());
|
||||
let resp = if supported { resp } else { resp.on_hover_text("Not in this font") };
|
||||
let resp = if supported { resp } else { resp.on_hover_text(tr("Not in this font")) };
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, CornerRadius::same(4), t.hover);
|
||||
}
|
||||
|
|
@ -811,7 +829,7 @@ fn lead_field(ui: &mut Ui, id: Id, glyph: LeadGlyph, value: &str, width: f32, ti
|
|||
paint_lead(ui.painter(), lead, glyph, t.icon_secondary);
|
||||
let started = editing.is_none() && resp.clicked();
|
||||
if started {
|
||||
editing = Some(if value == MIXED { String::new() } else { value.to_string() });
|
||||
editing = Some(if value == tr(MIXED) { String::new() } else { value.to_string() });
|
||||
ui.memory_mut(|m| m.request_focus(id.with("te")));
|
||||
}
|
||||
let mut out = None;
|
||||
|
|
@ -839,7 +857,7 @@ fn lead_field(ui: &mut Ui, id: Id, glyph: LeadGlyph, value: &str, width: f32, ti
|
|||
}
|
||||
}
|
||||
None => {
|
||||
let col = if value == MIXED { t.text_tertiary } else { t.text };
|
||||
let col = if value == tr(MIXED) { t.text_tertiary } else { t.text };
|
||||
ui.painter().text(pos2(text_rect.min.x + 2.0, rect.center().y), Align2::LEFT_CENTER, value, regular(11.0), col);
|
||||
}
|
||||
}
|
||||
|
|
@ -918,7 +936,7 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool)
|
|||
if just_opened {
|
||||
q.clear();
|
||||
}
|
||||
let search = ui.add(egui::TextEdit::singleline(&mut q).hint_text("Search fonts").desired_width(ui.available_width()).font(regular(11.5)));
|
||||
let search = ui.add(egui::TextEdit::singleline(&mut q).hint_text(tr("Search fonts")).desired_width(ui.available_width()).font(regular(11.5)));
|
||||
if just_opened {
|
||||
search.request_focus();
|
||||
}
|
||||
|
|
@ -933,13 +951,13 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool)
|
|||
if ql.is_empty() {
|
||||
let recent: Vec<String> = recent_families(ui.ctx()).into_iter().filter(|f| all.iter().any(|a| a.eq_ignore_ascii_case(f))).collect();
|
||||
if !recent.is_empty() {
|
||||
rows.push(PickRow::Header("Recent"));
|
||||
rows.push(PickRow::Header(tr("Recent")));
|
||||
rows.extend(recent.into_iter().map(PickRow::Family));
|
||||
rows.push(PickRow::Header("All fonts"));
|
||||
rows.push(PickRow::Header(tr("All fonts")));
|
||||
}
|
||||
}
|
||||
if matches.is_empty() {
|
||||
widgets::label(ui, "No fonts match", t.text_tertiary);
|
||||
widgets::label(ui, tr("No fonts match"), t.text_tertiary);
|
||||
return None;
|
||||
}
|
||||
rows.extend(matches.into_iter().map(PickRow::Family));
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ use serde_json::json;
|
|||
|
||||
use crate::FigApp;
|
||||
use crate::a11y::Tip;
|
||||
use crate::i18n::tr;
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::theme::{Tokens, regular, semibold};
|
||||
use crate::widgets::{self, BindEvent, FieldEdit, Lead};
|
||||
|
|
@ -281,7 +282,7 @@ pub fn variable_button(ui: &mut Ui, rect: Rect, id: Id) -> bool {
|
|||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let b = Rect::from_center_size(pos2(rect.max.x - 11.0, rect.center().y), vec2(18.0, 18.0));
|
||||
let resp = ui.interact(b, id, Sense::click()).tip("Apply variable");
|
||||
let resp = ui.interact(b, id, Sense::click()).tip(tr("Apply variable"));
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(b, CornerRadius::same(4), t.hover);
|
||||
}
|
||||
|
|
@ -332,10 +333,10 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) {
|
|||
egui::Frame::NONE.fill(t.menu).corner_radius(CornerRadius::same(10)).inner_margin(egui::Margin::same(8)).show(ui, |ui| {
|
||||
ui.set_width(240.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("Variables").font(semibold(11.0)).color(t.menu_text));
|
||||
ui.label(egui::RichText::new(tr("Variables")).font(semibold(11.0)).color(t.menu_text));
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut query).hint_text("Search").font(regular(11.0)).desired_width(224.0));
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut query).hint_text(tr("Search")).font(regular(11.0)).desired_width(224.0));
|
||||
if !r.has_focus() && !crate::a11y::field_focused(ui.ctx()) {
|
||||
r.request_focus();
|
||||
}
|
||||
|
|
@ -352,18 +353,23 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) {
|
|||
}
|
||||
});
|
||||
if groups.is_empty() {
|
||||
let kind = match p.kind {
|
||||
Some(VariableType::Color) => "colour ",
|
||||
Some(VariableType::Number) => "number ",
|
||||
Some(VariableType::String) => "string ",
|
||||
Some(VariableType::Boolean) => "boolean ",
|
||||
None => "",
|
||||
};
|
||||
let msg = if groups_any_kind(&doc, &libs, p.kind) {
|
||||
format!("No {kind}variables fit here.")
|
||||
} else {
|
||||
format!("No {kind}variables yet. Create them in Local variables.")
|
||||
// Whole sentences per type: languages place the type differently.
|
||||
let (fit, none) = match p.kind {
|
||||
Some(VariableType::Color) => {
|
||||
(tr("No colour variables fit here."), tr("No colour variables yet. Create them in Local variables."))
|
||||
}
|
||||
Some(VariableType::Number) => {
|
||||
(tr("No number variables fit here."), tr("No number variables yet. Create them in Local variables."))
|
||||
}
|
||||
Some(VariableType::String) => {
|
||||
(tr("No string variables fit here."), tr("No string variables yet. Create them in Local variables."))
|
||||
}
|
||||
Some(VariableType::Boolean) => {
|
||||
(tr("No boolean variables fit here."), tr("No boolean variables yet. Create them in Local variables."))
|
||||
}
|
||||
None => (tr("No variables fit here."), tr("No variables yet. Create them in Local variables.")),
|
||||
};
|
||||
let msg = if groups_any_kind(&doc, &libs, p.kind) { fit } else { none };
|
||||
ui.label(egui::RichText::new(msg).font(regular(11.0)).color(t.menu_dim));
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ use serde_json::{Value, json};
|
|||
use crate::FigApp;
|
||||
use crate::a11y::Tip;
|
||||
use crate::colorpicker::PickerTarget;
|
||||
use crate::i18n::{tr, tr_noop, trf, trn};
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::right::menu_item;
|
||||
use crate::theme::{Tokens, regular, semibold};
|
||||
|
|
@ -144,7 +145,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
|
|||
let screen = ctx.content_rect();
|
||||
let size = vec2(880.0_f32.min(screen.width() - 80.0).max(480.0), 480.0_f32.min(screen.height() - 120.0).max(240.0));
|
||||
let mut open = true;
|
||||
egui::Window::new("Local variables")
|
||||
egui::Window::new(tr("Local variables"))
|
||||
.id(Id::new("variables_window"))
|
||||
.title_bar(false)
|
||||
.collapsible(false)
|
||||
|
|
@ -176,25 +177,25 @@ fn header(app: &mut FigApp, ui: &mut Ui, open: &mut bool) {
|
|||
// Import and export, left of Close.
|
||||
let bar = Rect::from_min_max(pos2(r.max.x - 280.0, r.min.y + 10.0), pos2(r.max.x - 40.0, r.max.y - 10.0));
|
||||
let mut bar = ui.new_child(UiBuilder::new().max_rect(bar).layout(egui::Layout::right_to_left(egui::Align::Center)));
|
||||
let export = widgets::button(&mut bar, "Export…", false, true);
|
||||
let export = widgets::button(&mut bar, tr("Export…"), false, true);
|
||||
crate::right::menu_popup(&export).show(|ui| {
|
||||
for (label, format) in [("Variables JSON…", "json"), ("CSS custom properties…", "css")] {
|
||||
for (label, format) in [(tr("Variables JSON…"), "json"), (tr("CSS custom properties…"), "css")] {
|
||||
if menu_item(ui, label, "") {
|
||||
export_dialog(app, format);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
if widgets::button(&mut bar, "Import…", false, true).on_hover_text("Add or update collections from a variables JSON").clicked() {
|
||||
if widgets::button(&mut bar, tr("Import…"), false, true).on_hover_text(tr("Add or update collections from a variables JSON")).clicked() {
|
||||
import_dialog(app);
|
||||
}
|
||||
ui.painter().text(pos2(r.min.x + 16.0, r.center().y), Align2::LEFT_CENTER, "Local variables", semibold(13.0), t.text);
|
||||
ui.painter().text(pos2(r.min.x + 16.0, r.center().y), Align2::LEFT_CENTER, tr("Local variables"), semibold(13.0), t.text);
|
||||
if widgets::icon_button_at(
|
||||
ui,
|
||||
Id::new("vars_close"),
|
||||
Rect::from_center_size(pos2(r.max.x - 22.0, r.center().y), vec2(24.0, 24.0)),
|
||||
Icon::Close,
|
||||
"Close",
|
||||
tr("Close"),
|
||||
) {
|
||||
*open = false;
|
||||
}
|
||||
|
|
@ -204,20 +205,24 @@ fn header(app: &mut FigApp, ui: &mut Ui, open: &mut bool) {
|
|||
/// Export this file's collections through a save dialog.
|
||||
fn export_dialog(app: &mut FigApp, format: &str) {
|
||||
let name = app.session.doc().map(|d| format!("{}.variables.{format}", d.name)).unwrap_or_else(|_| format!("variables.{format}"));
|
||||
let Some(p) = app.services.pick_save("Export variables", &name, &[format]) else { return };
|
||||
let Some(p) = app.services.pick_save(tr("Export variables"), &name, &[format]) else { return };
|
||||
if app.run("variable.export", json!({"format": format, "path": p.to_string_lossy()})).is_some() {
|
||||
app.toast(format!("Exported {}", p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default()));
|
||||
let file = p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default();
|
||||
app.toast(trf("Exported {file}", &[("file", &file)]));
|
||||
}
|
||||
}
|
||||
|
||||
/// Import a variables JSON picked in an open dialog.
|
||||
fn import_dialog(app: &mut FigApp) {
|
||||
let Some(p) = app.services.pick_open("Import variables", &["json"]) else { return };
|
||||
let Some(p) = app.services.pick_open(tr("Import variables"), &["json"]) else { return };
|
||||
let Some(r) = app.run("variable.import", json!({"variables": p.to_string_lossy()})) else { return };
|
||||
let (added, updated) = (r["variables"].as_u64().unwrap_or(0), r["updated"].as_u64().unwrap_or(0));
|
||||
let skipped = r["warnings"].as_array().map_or(0, Vec::len);
|
||||
let note = if skipped > 0 { format!(" ({skipped} warnings: some entries were skipped)") } else { String::new() };
|
||||
app.toast(format!("Imported {added} new variables, updated {updated}{note}"));
|
||||
let mut msg = trn("Imported {n} new variables, updated {updated}", added as usize, &[("updated", &updated)]);
|
||||
if skipped > 0 {
|
||||
msg = format!("{msg} {}", trn("({n} warnings: some entries were skipped)", skipped, &[]));
|
||||
}
|
||||
app.toast(msg);
|
||||
}
|
||||
|
||||
/// Extend library `lib`'s collection `c` and show the new collection.
|
||||
|
|
@ -258,13 +263,13 @@ fn unique(base: &str, taken: impl Fn(&str) -> bool) -> String {
|
|||
fn sidebar(app: &mut FigApp, ui: &mut Ui, views: &[View], local: &Variables, st: &mut Editor) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (hr, _) = ui.allocate_exact_size(vec2(SIDEBAR_W, 40.0), Sense::hover());
|
||||
ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, "Collections", semibold(11.0), t.text_secondary);
|
||||
ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, tr("Collections"), semibold(11.0), t.text_secondary);
|
||||
if widgets::icon_button_at(
|
||||
ui,
|
||||
Id::new("vars_add_coll"),
|
||||
Rect::from_center_size(pos2(hr.max.x - 20.0, hr.center().y), vec2(24.0, 24.0)),
|
||||
Icon::Plus,
|
||||
"Create collection",
|
||||
tr("Create collection"),
|
||||
) {
|
||||
create_collection(app, ui, local, st);
|
||||
}
|
||||
|
|
@ -290,7 +295,7 @@ fn library_title(ui: &mut Ui, name: &str) {
|
|||
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 22.0, r.center().y), vec2(12.0, 12.0)), Icon::Library, t.icon_secondary);
|
||||
let name_r = Rect::from_min_max(pos2(r.min.x + 34.0, r.min.y), pos2(r.max.x - 8.0, r.max.y));
|
||||
ui.painter().with_clip_rect(name_r).text(name_r.left_center(), Align2::LEFT_CENTER, name, semibold(11.0), t.text_secondary);
|
||||
resp.on_hover_text("Library: its variables are read-only here; edit them in the library file and publish it");
|
||||
resp.on_hover_text(tr("Library: its variables are read-only here; edit them in the library file and publish it"));
|
||||
}
|
||||
|
||||
/// One collection in the sidebar (with its groups while selected).
|
||||
|
|
@ -330,18 +335,18 @@ fn collection_row(app: &mut FigApp, ui: &mut Ui, view: &View, c: &Collection, st
|
|||
start_rename(ui, st, target.clone());
|
||||
}
|
||||
crate::right::context_popup(&resp).show(|ui| {
|
||||
if menu_item(ui, "Rename", "") {
|
||||
if menu_item(ui, tr("Rename"), "") {
|
||||
start_rename(ui, st, target.clone());
|
||||
ui.close();
|
||||
}
|
||||
if menu_item(ui, "Delete collection", "") {
|
||||
if menu_item(ui, tr("Delete collection"), "") {
|
||||
app.run("variable.deleteCollection", json!({"collection": c.id}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
} else if let Some(lib) = view.id() {
|
||||
crate::right::context_popup(&resp).show(|ui| {
|
||||
if menu_item(ui, "Extend collection", "") {
|
||||
if menu_item(ui, tr("Extend collection"), "") {
|
||||
extend(app, lib, &c.id, st);
|
||||
ui.close();
|
||||
}
|
||||
|
|
@ -390,7 +395,7 @@ fn group_rows(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st:
|
|||
}
|
||||
let depth = g.map_or(0, |g| g.matches('/').count());
|
||||
let x = r.min.x + 20.0 + depth as f32 * 12.0;
|
||||
let label = g.map_or("All variables", |g| g.rsplit('/').next().unwrap_or(g));
|
||||
let label = g.map_or(tr("All variables"), |g| g.rsplit('/').next().unwrap_or(g));
|
||||
let count = names.iter().filter(|n| g.is_none_or(|g| n.starts_with(&format!("{g}/")))).count();
|
||||
ui.painter().text(pos2(r.max.x - 8.0, r.center().y), Align2::RIGHT_CENTER, count.to_string(), regular(10.0), t.text_tertiary);
|
||||
let name_r = Rect::from_min_max(pos2(x, r.min.y + 3.0), pos2(r.max.x - 28.0, r.max.y - 3.0));
|
||||
|
|
@ -419,11 +424,11 @@ fn group_rows(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st:
|
|||
start_rename(ui, st, target.clone());
|
||||
}
|
||||
crate::right::context_popup(&resp).show(|ui| {
|
||||
if menu_item(ui, "Rename group", "") {
|
||||
if menu_item(ui, tr("Rename group"), "") {
|
||||
start_rename(ui, st, target.clone());
|
||||
ui.close();
|
||||
}
|
||||
if menu_item(ui, "Delete group", "") {
|
||||
if menu_item(ui, tr("Delete group"), "") {
|
||||
let steps: Vec<serde_json::Value> = members
|
||||
.iter()
|
||||
.filter(|v| v.name.starts_with(&format!("{g}/")))
|
||||
|
|
@ -493,11 +498,11 @@ fn table(app: &mut FigApp, ui: &mut Ui, view: &View, st: &mut Editor) {
|
|||
ui.vertical(|ui| {
|
||||
widgets::label(
|
||||
ui,
|
||||
"Variables store reusable values (colours, numbers, strings, booleans) with modes such as Light and Dark.",
|
||||
tr("Variables store reusable values (colours, numbers, strings, booleans) with modes such as Light and Dark."),
|
||||
t.text_secondary,
|
||||
);
|
||||
ui.add_space(12.0);
|
||||
if widgets::button(ui, "Create collection", true, true).clicked() {
|
||||
if widgets::button(ui, tr("Create collection"), true, true).clicked() {
|
||||
create_collection(app, ui, view.vars, st);
|
||||
}
|
||||
});
|
||||
|
|
@ -515,7 +520,7 @@ fn table(app: &mut FigApp, ui: &mut Ui, view: &View, st: &mut Editor) {
|
|||
child.add(
|
||||
egui::TextEdit::singleline(&mut st.query)
|
||||
.frame(egui::Frame::NONE)
|
||||
.hint_text("Search")
|
||||
.hint_text(tr("Search"))
|
||||
.font(regular(11.0))
|
||||
.vertical_align(egui::Align::Center),
|
||||
);
|
||||
|
|
@ -523,14 +528,14 @@ fn table(app: &mut FigApp, ui: &mut Ui, view: &View, st: &mut Editor) {
|
|||
let mut bar = ui.new_child(UiBuilder::new().max_rect(note).layout(egui::Layout::right_to_left(egui::Align::Center)));
|
||||
let text = match (view.library, coll.extends.as_deref().and_then(|p| view.vars.collection(p))) {
|
||||
(Some((lib, name)), _) => {
|
||||
let tip = "A collection of this file with these modes and variables: override their values per mode and add variables of its own";
|
||||
if widgets::button(&mut bar, "Extend collection", false, true).on_hover_text(tip).clicked() {
|
||||
let tip = tr("A collection of this file with these modes and variables: override their values per mode and add variables of its own");
|
||||
if widgets::button(&mut bar, tr("Extend collection"), false, true).on_hover_text(tip).clicked() {
|
||||
extend(app, lib, &coll.id, st);
|
||||
}
|
||||
bar.add_space(8.0);
|
||||
Some(format!("From the library \"{name}\": read-only"))
|
||||
Some(trf("From the library \"{name}\": read-only", &[("name", &name)]))
|
||||
}
|
||||
(None, Some(parent)) => Some(format!("Extends \"{}\": overridden values are marked", parent.name)),
|
||||
(None, Some(parent)) => Some(trf("Extends \"{name}\": overridden values are marked", &[("name", &parent.name)])),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(text) = text {
|
||||
|
|
@ -559,7 +564,7 @@ fn table(app: &mut FigApp, ui: &mut Ui, view: &View, st: &mut Editor) {
|
|||
}
|
||||
if list.is_empty() && !q.is_empty() {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(width, ROW_H), Sense::hover());
|
||||
ui.painter().text(pos2(r.min.x + 16.0, r.center().y), Align2::LEFT_CENTER, "No matching variables", regular(11.0), t.text_secondary);
|
||||
ui.painter().text(pos2(r.min.x + 16.0, r.center().y), Align2::LEFT_CENTER, tr("No matching variables"), regular(11.0), t.text_secondary);
|
||||
}
|
||||
if view.editable() {
|
||||
create_row(app, ui, view, coll, st);
|
||||
|
|
@ -572,7 +577,7 @@ fn header_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st:
|
|||
// An extended collection has the modes of the one it extends.
|
||||
let editable = view.editable() && coll.extends.is_none();
|
||||
let (r, _) = ui.allocate_exact_size(vec2(width, 32.0), Sense::hover());
|
||||
ui.painter().text(pos2(r.min.x + 16.0, r.center().y), Align2::LEFT_CENTER, "Name", regular(11.0), t.text_secondary);
|
||||
ui.painter().text(pos2(r.min.x + 16.0, r.center().y), Align2::LEFT_CENTER, tr("Name"), regular(11.0), t.text_secondary);
|
||||
for (i, m) in coll.modes.iter().enumerate() {
|
||||
let x = r.min.x + NAME_W + i as f32 * MODE_W;
|
||||
let cell = Rect::from_min_max(pos2(x + 4.0, r.min.y + 4.0), pos2(x + MODE_W - 8.0, r.max.y - 4.0));
|
||||
|
|
@ -597,11 +602,11 @@ fn header_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st:
|
|||
start_rename(ui, st, target.clone());
|
||||
}
|
||||
crate::right::context_popup(&resp).show(|ui| {
|
||||
if menu_item(ui, "Rename mode", "") {
|
||||
if menu_item(ui, tr("Rename mode"), "") {
|
||||
start_rename(ui, st, target.clone());
|
||||
ui.close();
|
||||
}
|
||||
if coll.modes.len() > 1 && menu_item(ui, "Delete mode", "") {
|
||||
if coll.modes.len() > 1 && menu_item(ui, tr("Delete mode"), "") {
|
||||
app.run("variable.removeMode", json!({"collection": coll.id, "mode": m.id}));
|
||||
ui.close();
|
||||
}
|
||||
|
|
@ -614,7 +619,7 @@ fn header_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st:
|
|||
Id::new("vars_add_mode"),
|
||||
Rect::from_center_size(pos2(x + 14.0, r.center().y), vec2(24.0, 24.0)),
|
||||
Icon::Plus,
|
||||
"Add mode",
|
||||
tr("Add mode"),
|
||||
)
|
||||
{
|
||||
let name = unique("Mode", |n| coll.modes.iter().any(|m| m.name == n));
|
||||
|
|
@ -657,23 +662,23 @@ fn var_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, v: &Va
|
|||
start_rename(ui, st, target.clone());
|
||||
}
|
||||
crate::right::context_popup(&resp).show(|ui| {
|
||||
if menu_item(ui, "Edit variable", "") {
|
||||
if menu_item(ui, tr("Edit variable"), "") {
|
||||
st.details = Some(v.id.clone());
|
||||
ui.close();
|
||||
}
|
||||
if !inherited && menu_item(ui, "Rename", "") {
|
||||
if !inherited && menu_item(ui, tr("Rename"), "") {
|
||||
start_rename(ui, st, target.clone());
|
||||
ui.close();
|
||||
}
|
||||
if !v.overrides.is_empty() && menu_item(ui, "Reset overrides", "") {
|
||||
if !v.overrides.is_empty() && menu_item(ui, tr("Reset overrides"), "") {
|
||||
app.run("variable.resetOverride", json!({"variable": v.id}));
|
||||
ui.close();
|
||||
}
|
||||
if menu_item(ui, "Duplicate", "") {
|
||||
if menu_item(ui, tr("Duplicate"), "") {
|
||||
app.run("variable.duplicate", json!({"variable": v.id}));
|
||||
ui.close();
|
||||
}
|
||||
if !inherited && menu_item(ui, "Delete variable", "") {
|
||||
if !inherited && menu_item(ui, tr("Delete variable"), "") {
|
||||
app.run("variable.delete", json!({"variable": v.id}));
|
||||
ui.close();
|
||||
}
|
||||
|
|
@ -737,17 +742,17 @@ fn value_cell(app: &mut FigApp, ui: &mut Ui, view: &View, v: &Variable, coll: &C
|
|||
let mark = Rect::from_min_size(pos2(rect.min.x - 4.0, rect.min.y + 3.0), vec2(2.0, rect.height() - 6.0));
|
||||
ui.painter().rect_filled(mark, CornerRadius::same(1), t.accent);
|
||||
ui.interact(mark.expand2(vec2(2.0, 0.0)), id.with("mark"), Sense::hover())
|
||||
.tip("Overridden here (the collection it extends has another value)");
|
||||
.tip(tr("Overridden here (the collection it extends has another value)"));
|
||||
if hovered
|
||||
&& widgets::icon_button_at(ui, id.with("reset"), alias_btn.translate(vec2(-24.0, 0.0)), Icon::Reset, "Reset to the inherited value")
|
||||
&& widgets::icon_button_at(ui, id.with("reset"), alias_btn.translate(vec2(-24.0, 0.0)), Icon::Reset, tr("Reset to the inherited value"))
|
||||
{
|
||||
app.run("variable.resetOverride", json!({"variable": v.id, "mode": mode}));
|
||||
}
|
||||
}
|
||||
if let VariableValue::Alias(target) = val {
|
||||
let full = view.vars.get(target).map(|x| x.name.as_str()).unwrap_or("Missing variable");
|
||||
let full = view.vars.get(target).map(|x| x.name.as_str()).unwrap_or(tr("Missing variable"));
|
||||
let resp = ui.interact(body, id.with("alias"), if editable { Sense::click() } else { Sense::hover() });
|
||||
let resp = if editable { resp.on_hover_text("Change alias") } else { resp };
|
||||
let resp = if editable { resp.on_hover_text(tr("Change alias")) } else { resp };
|
||||
ui.painter().rect_filled(body, CornerRadius::same(5), t.field);
|
||||
let chip = body.shrink2(vec2(3.0, 3.0));
|
||||
ui.painter().rect_filled(chip, CornerRadius::same(4), t.hover);
|
||||
|
|
@ -765,7 +770,7 @@ fn value_cell(app: &mut FigApp, ui: &mut Ui, view: &View, v: &Variable, coll: &C
|
|||
crate::variable_ui::open_alias(ui, &v.id, mode, v.kind, body.left_bottom());
|
||||
}
|
||||
// Detach: keep the value it resolves to now.
|
||||
if hovered && widgets::icon_button_at(ui, id.with("detach"), alias_btn, Icon::Detach, "Detach alias") {
|
||||
if hovered && widgets::icon_button_at(ui, id.with("detach"), alias_btn, Icon::Detach, tr("Detach alias")) {
|
||||
let modes = [(coll.id.clone(), mode.to_string())].into_iter().collect();
|
||||
if let Some(resolved) = view.vars.resolve(&v.id, &modes) {
|
||||
set(app, v, mode, value_json(&resolved));
|
||||
|
|
@ -782,7 +787,7 @@ fn value_cell(app: &mut FigApp, ui: &mut Ui, view: &View, v: &Variable, coll: &C
|
|||
ui.painter().rect_filled(body, CornerRadius::same(5), t.field);
|
||||
let sw = Rect::from_center_size(pos2(body.min.x + 13.0, body.center().y), vec2(14.0, 14.0));
|
||||
widgets::paint_swatch(ui.painter(), sw, &figcraft_color::Paint::solid(*c));
|
||||
if ui.interact(sw.expand(3.0), id.with("swatch"), Sense::click()).tip("Edit colour").clicked() {
|
||||
if ui.interact(sw.expand(3.0), id.with("swatch"), Sense::click()).tip(tr("Edit colour")).clicked() {
|
||||
app.ui.picker = Some(PickerTarget::Variable { id: v.id.clone(), mode: mode.to_string() });
|
||||
}
|
||||
let hex_r = Rect::from_min_max(pos2(sw.max.x + 8.0, body.min.y + 4.0), pos2(body.max.x - 44.0, body.max.y - 4.0));
|
||||
|
|
@ -813,13 +818,13 @@ fn value_cell(app: &mut FigApp, ui: &mut Ui, view: &View, v: &Variable, coll: &C
|
|||
}
|
||||
VariableValue::Boolean(b) => {
|
||||
let mut child = ui.new_child(UiBuilder::new().max_rect(body.shrink2(vec2(6.0, 0.0))));
|
||||
if widgets::checkbox(&mut child, *b, if *b { "True" } else { "False" }) {
|
||||
if widgets::checkbox(&mut child, *b, if *b { tr("True") } else { tr("False") }) {
|
||||
set(app, v, mode, json!(!b));
|
||||
}
|
||||
}
|
||||
VariableValue::Alias(_) => {}
|
||||
}
|
||||
if hovered && widgets::icon_button_at(ui, id.with("mkalias"), alias_btn, Icon::Variable, "Create alias") {
|
||||
if hovered && widgets::icon_button_at(ui, id.with("mkalias"), alias_btn, Icon::Variable, tr("Create alias")) {
|
||||
crate::variable_ui::open_alias(ui, &v.id, mode, v.kind, body.left_bottom());
|
||||
}
|
||||
}
|
||||
|
|
@ -843,7 +848,7 @@ fn read_only_value(ui: &Ui, val: &VariableValue, body: Rect) {
|
|||
alpha_text(ui, c, body);
|
||||
}
|
||||
let text = match val {
|
||||
VariableValue::Boolean(b) => (if *b { "True" } else { "False" }).to_string(),
|
||||
VariableValue::Boolean(b) => (if *b { tr("True") } else { tr("False") }).to_string(),
|
||||
other => other.display(),
|
||||
};
|
||||
let text_r = Rect::from_min_max(pos2(x, body.min.y), pos2(body.max.x - 44.0, body.max.y));
|
||||
|
|
@ -854,16 +859,17 @@ fn create_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st:
|
|||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(12.0);
|
||||
let resp = widgets::button(ui, "+ Create variable", false, true);
|
||||
let resp = widgets::button(ui, tr("+ Create variable"), false, true);
|
||||
crate::right::menu_popup(&resp).show(|ui| {
|
||||
for kind in TYPES {
|
||||
let label = match kind {
|
||||
VariableType::Color => "Color",
|
||||
VariableType::Number => "Number",
|
||||
VariableType::String => "String",
|
||||
VariableType::Boolean => "Boolean",
|
||||
// The English label also names the new variable (document content).
|
||||
VariableType::Color => tr_noop("Color"),
|
||||
VariableType::Number => tr_noop("Number"),
|
||||
VariableType::String => tr_noop("String"),
|
||||
VariableType::Boolean => tr_noop("Boolean"),
|
||||
};
|
||||
if menu_item(ui, label, "") {
|
||||
if menu_item(ui, tr(label), "") {
|
||||
let name = unique(label, |n| view.in_collection(&coll.id).any(|v| v.name == n));
|
||||
let params = json!({"collection": coll.id, "name": name, "type": kind.id()});
|
||||
if let Some(id) = app.run("variable.create", params).and_then(|r| r["id"].as_str().map(str::to_string)) {
|
||||
|
|
@ -887,7 +893,7 @@ fn details(app: &mut FigApp, ctx: &Context, vars: &Variables, st: &mut Editor) {
|
|||
};
|
||||
let t = Tokens::get(ctx);
|
||||
let mut open = true;
|
||||
egui::Window::new("Edit variable")
|
||||
egui::Window::new(tr("Edit variable"))
|
||||
.id(Id::new("variable_details"))
|
||||
.open(&mut open)
|
||||
.collapsible(false)
|
||||
|
|
@ -896,17 +902,17 @@ fn details(app: &mut FigApp, ctx: &Context, vars: &Variables, st: &mut Editor) {
|
|||
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(16)))
|
||||
.show(ctx, |ui| {
|
||||
ui.set_width(260.0);
|
||||
widgets::label(ui, "Name", t.text_secondary);
|
||||
widgets::label(ui, tr("Name"), t.text_secondary);
|
||||
if let Some(n) = widgets::text_field(ui, Id::new(("var_detail_name", &v.id)), &v.name, 260.0, "") {
|
||||
app.run("variable.update", json!({"variable": v.id, "name": n}));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
widgets::label(ui, "Description", t.text_secondary);
|
||||
if let Some(d) = widgets::text_field(ui, Id::new(("var_detail_desc", &v.id)), &v.description, 260.0, "What it's for") {
|
||||
widgets::label(ui, tr("Description"), t.text_secondary);
|
||||
if let Some(d) = widgets::text_field(ui, Id::new(("var_detail_desc", &v.id)), &v.description, 260.0, tr("What it's for")) {
|
||||
app.run("variable.update", json!({"variable": v.id, "description": d}));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
if widgets::checkbox(ui, v.hidden_from_publishing, "Hide from publishing") {
|
||||
if widgets::checkbox(ui, v.hidden_from_publishing, tr("Hide from publishing")) {
|
||||
app.run("variable.update", json!({"variable": v.id, "hiddenFromPublishing": !v.hidden_from_publishing}));
|
||||
}
|
||||
let scopes = scopes_for(v.kind);
|
||||
|
|
@ -914,9 +920,9 @@ fn details(app: &mut FigApp, ctx: &Context, vars: &Variables, st: &mut Editor) {
|
|||
return;
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
widgets::label(ui, "Scoping: where pickers offer it", t.text_secondary);
|
||||
widgets::label(ui, tr("Scoping: where pickers offer it"), t.text_secondary);
|
||||
// "All" off hides it everywhere; on, from everywhere it fits.
|
||||
if widgets::checkbox(ui, v.scopes.is_none(), "All supported properties") {
|
||||
if widgets::checkbox(ui, v.scopes.is_none(), tr("All supported properties")) {
|
||||
app.run("variable.update", json!({"variable": v.id, "scopes": if v.scopes.is_none() { json!([]) } else { Value::Null }}));
|
||||
}
|
||||
let on: Vec<&str> = scopes.iter().map(|(id, _)| *id).filter(|id| v.in_scope(id)).collect();
|
||||
|
|
@ -924,7 +930,7 @@ fn details(app: &mut FigApp, ctx: &Context, vars: &Variables, st: &mut Editor) {
|
|||
let checked = on.contains(sid);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(16.0);
|
||||
if widgets::checkbox(ui, checked, label) {
|
||||
if widgets::checkbox(ui, checked, tr(label)) {
|
||||
let mut list = on.clone();
|
||||
if checked {
|
||||
list.retain(|x| x != sid);
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ use figcraft_engine::vectoredit::{self, VectorEdit};
|
|||
use serde_json::{Value, json};
|
||||
|
||||
use crate::FigApp;
|
||||
use crate::i18n::tr;
|
||||
use crate::icons::Icon;
|
||||
use crate::right::{menu_item, menu_popup, row};
|
||||
use crate::widgets::{self, FieldEdit, Lead, PAD};
|
||||
|
|
@ -31,14 +32,14 @@ fn point_field(app: &mut FigApp, cmd: &str, edit: Option<FieldEdit>, params: imp
|
|||
|
||||
/// The Variable width tool's selected width point: where it is along the path and its width.
|
||||
fn width_point(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) {
|
||||
widgets::section_header(ui, "Width point", &[], false);
|
||||
widgets::section_header(ui, tr("Width point"), &[], false);
|
||||
let stroke = doc.node(ve.id).map(|n| &n.stroke);
|
||||
let sel = ve.width_point.and_then(|i| Some((i, *stroke?.profile()?.points.get(i)?)));
|
||||
let Some((i, p)) = sel else {
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(PAD);
|
||||
widgets::label(ui, "Drag the stroke to add a width point", t.text_secondary);
|
||||
widgets::label(ui, tr("Drag the stroke to add a width point"), t.text_secondary);
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
return;
|
||||
|
|
@ -46,18 +47,18 @@ fn width_point(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) {
|
|||
let weight = stroke.map_or(1.0, |s| s.weight);
|
||||
let col = widgets::col_width(ui.available_width());
|
||||
let round = |v: f64| (v * 100.0).round() / 100.0;
|
||||
widgets::sublabels(ui, "Position", Some("Width"));
|
||||
widgets::sublabels(ui, tr("Position"), Some(tr("Width")));
|
||||
row(ui, 32.0, |ui| {
|
||||
let e = widgets::number_field(ui, Id::new("wpt_t"), Lead::None, Some(round(p.t * 100.0)), "%", col, 1.0);
|
||||
point_field(app, "vector.edit.widthPoint", e, |v| json!({"index": i, "t": v / 100.0}));
|
||||
let e = widgets::number_field(ui, Id::new("wpt_w"), Lead::Icon(Icon::StrokeIcon, "Width"), Some(round(p.scale * weight)), "", col, 1.0);
|
||||
let e = widgets::number_field(ui, Id::new("wpt_w"), Lead::Icon(Icon::StrokeIcon, tr("Width")), Some(round(p.scale * weight)), "", col, 1.0);
|
||||
point_field(app, "vector.edit.widthPoint", e, |v| json!({"index": i, "width": v.max(0.0)}));
|
||||
});
|
||||
// Each side's reach from the centre line (Alt-drag a handle on the canvas for one side).
|
||||
let (l, r) = p.sides();
|
||||
widgets::sublabels(ui, "Left side", Some("Right side"));
|
||||
widgets::sublabels(ui, tr("Left side"), Some(tr("Right side")));
|
||||
row(ui, 32.0, |ui| {
|
||||
for (side, scale, name) in [("left", l, "Left side width"), ("right", r, "Right side width")] {
|
||||
for (side, scale, name) in [("left", l, tr("Left side width")), ("right", r, tr("Right side width"))] {
|
||||
let e = widgets::number_field(
|
||||
ui,
|
||||
Id::new(("wpt_side", side)),
|
||||
|
|
@ -80,12 +81,12 @@ pub fn points(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) {
|
|||
}
|
||||
let Some(net) = vectoredit::network(doc, ve.id) else { return };
|
||||
let pts = vectoredit::selected_points(net, ve);
|
||||
widgets::section_header(ui, if pts.len() > 1 { "Points" } else { "Point" }, &[], false);
|
||||
widgets::section_header(ui, if pts.len() > 1 { tr("Points") } else { tr("Point") }, &[], false);
|
||||
if pts.is_empty() {
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(PAD);
|
||||
widgets::label(ui, "Select points to edit their position, radius and mirroring", t.text_secondary);
|
||||
widgets::label(ui, tr("Select points to edit their position, radius and mirroring"), t.text_secondary);
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
return;
|
||||
|
|
@ -93,7 +94,7 @@ pub fn points(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) {
|
|||
let col = widgets::col_width(ui.available_width());
|
||||
let pos = vectoredit::selection_position(doc, ve);
|
||||
let round = |v: f64| (v * 100.0).round() / 100.0;
|
||||
widgets::sublabels(ui, "Position", None);
|
||||
widgets::sublabels(ui, tr("Position"), None);
|
||||
row(ui, 32.0, |ui| {
|
||||
let e = widgets::number_field(ui, Id::new("vpt_x"), Lead::Text("X"), pos.map(|p| round(p.x)), "", col, 1.0);
|
||||
point_field(app, "vector.edit.position", e, |v| json!({"x": v}));
|
||||
|
|
@ -101,7 +102,7 @@ pub fn points(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) {
|
|||
point_field(app, "vector.edit.position", e, |v| json!({"y": v}));
|
||||
});
|
||||
if let Some(b) = vectoredit::transform::point_box(doc, ve).filter(|b| b.points.len() > 1) {
|
||||
widgets::sublabels(ui, "Dimensions", None);
|
||||
widgets::sublabels(ui, tr("Dimensions"), None);
|
||||
row(ui, 32.0, |ui| {
|
||||
let e = widgets::number_field(ui, Id::new("vpt_w"), Lead::Text("W"), Some(round(b.width)), "", col, 1.0);
|
||||
point_field(app, "vector.edit.resize", e, |v| json!({"width": v.max(0.0)}));
|
||||
|
|
@ -120,19 +121,20 @@ pub fn points(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) {
|
|||
let first = it.next();
|
||||
first.filter(|m| it.all(|x| x == *m))
|
||||
};
|
||||
widgets::sublabels(ui, "Corner radius", None);
|
||||
widgets::sublabels(ui, tr("Corner radius"), None);
|
||||
row(ui, 32.0, |ui| {
|
||||
let e = widgets::number_field(ui, Id::new("vpt_radius"), Lead::Icon(Icon::CornerRadius, "Corner radius"), radius.map(round), "", col, 1.0);
|
||||
let e =
|
||||
widgets::number_field(ui, Id::new("vpt_radius"), Lead::Icon(Icon::CornerRadius, tr("Corner radius")), radius.map(round), "", col, 1.0);
|
||||
point_field(app, "vector.edit.cornerRadius", e, |v| json!({"radius": v.max(0.0)}));
|
||||
});
|
||||
widgets::sublabels(ui, "Mirroring", None);
|
||||
widgets::sublabels(ui, tr("Mirroring"), None);
|
||||
row(ui, 32.0, |ui| {
|
||||
let label = |m: HandleMirroring| match m {
|
||||
HandleMirroring::None => "No mirroring",
|
||||
HandleMirroring::Angle => "Mirror angle",
|
||||
HandleMirroring::AngleAndLength => "Mirror angle and length",
|
||||
HandleMirroring::None => tr("No mirroring"),
|
||||
HandleMirroring::Angle => tr("Mirror angle"),
|
||||
HandleMirroring::AngleAndLength => tr("Mirror angle and length"),
|
||||
};
|
||||
let resp = widgets::dropdown(ui, mirror.map(label).unwrap_or("Mixed"), col * 2.0 + 8.0, None);
|
||||
let resp = widgets::dropdown(ui, mirror.map(label).unwrap_or(tr("Mixed")), col * 2.0 + 8.0, None);
|
||||
menu_popup(&resp).show(|ui| {
|
||||
for m in [HandleMirroring::None, HandleMirroring::Angle, HandleMirroring::AngleAndLength] {
|
||||
if menu_item(ui, label(m), "") {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use serde_json::json;
|
|||
|
||||
use crate::FigApp;
|
||||
use crate::a11y::Tip;
|
||||
use crate::i18n::{tr, trf, trn};
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::theme::{Tokens, regular, semibold};
|
||||
use crate::widgets;
|
||||
|
|
@ -114,14 +115,14 @@ pub fn panel(app: &mut FigApp, ui: &mut Ui) {
|
|||
|
||||
// Header: title, save, close.
|
||||
let (head, _) = ui.allocate_exact_size(vec2(w, 48.0), Sense::hover());
|
||||
ui.painter().text(pos2(head.min.x + 16.0, head.center().y), Align2::LEFT_CENTER, "Version history", semibold(13.0), t.text);
|
||||
ui.painter().text(pos2(head.min.x + 16.0, head.center().y), Align2::LEFT_CENTER, tr("Version history"), semibold(13.0), t.text);
|
||||
let mut bar =
|
||||
ui.new_child(egui::UiBuilder::new().max_rect(head.shrink2(vec2(12.0, 12.0))).layout(egui::Layout::right_to_left(egui::Align::Center)));
|
||||
if icons::button(&mut bar, Icon::Close, 24.0, false, "Close version history").clicked() {
|
||||
if icons::button(&mut bar, Icon::Close, 24.0, false, tr("Close version history")).clicked() {
|
||||
action = Some(Action::Close);
|
||||
}
|
||||
let new_open = app.ui.versions.form.as_ref().is_some_and(|f| f.id.is_none() && !f.branch);
|
||||
let tip = format!("Save version {}", crate::toolbar::pretty_shortcut("Alt+Cmd+S"));
|
||||
let tip = trf("Save version {key}", &[("key", &crate::toolbar::pretty_shortcut("Alt+Cmd+S"))]);
|
||||
if icons::button(&mut bar, Icon::Plus, 24.0, new_open, &tip).clicked() {
|
||||
action = Some(Action::New);
|
||||
}
|
||||
|
|
@ -129,7 +130,7 @@ pub fn panel(app: &mut FigApp, ui: &mut Ui) {
|
|||
|
||||
if let Some(f) = app.ui.versions.form.as_mut().filter(|f| f.id.is_none() && !f.branch) {
|
||||
ui.add_space(12.0);
|
||||
if let Some(a) = form(ui, f, "Save", 16.0) {
|
||||
if let Some(a) = form(ui, f, tr("Save"), 16.0) {
|
||||
action = Some(a);
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
|
|
@ -156,11 +157,11 @@ pub fn panel(app: &mut FigApp, ui: &mut Ui) {
|
|||
.map(|m| m.branches.iter().map(|b| BranchRow { id: b.id.clone(), name: b.name.clone(), time: b.time, state: b.state }).collect())
|
||||
.unwrap_or_default();
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
if widgets::section_header(ui, "Branches", &[(Icon::Plus, "Create branch")], false).is_some() {
|
||||
if widgets::section_header(ui, tr("Branches"), &[(Icon::Plus, tr("Create branch"))], false).is_some() {
|
||||
action = Some(Action::NewBranch);
|
||||
}
|
||||
if let Some(f) = app.ui.versions.form.as_mut().filter(|f| f.branch) {
|
||||
if let Some(a) = form(ui, f, "Create", 16.0) {
|
||||
if let Some(a) = form(ui, f, tr("Create"), 16.0) {
|
||||
action = Some(a);
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
|
|
@ -174,7 +175,7 @@ pub fn panel(app: &mut FigApp, ui: &mut Ui) {
|
|||
if branches.is_empty() {
|
||||
widgets::note(
|
||||
ui,
|
||||
"No branches. A branch is a copy of the file to try changes in, then review and merge.",
|
||||
tr("No branches. A branch is a copy of the file to try changes in, then review and merge."),
|
||||
Tokens::get(ui.ctx()).text_secondary,
|
||||
);
|
||||
}
|
||||
|
|
@ -208,7 +209,7 @@ pub fn panel(app: &mut FigApp, ui: &mut Ui) {
|
|||
let a = if app.ui.versions.confirm_delete.as_deref() == Some(row.id.as_str()) {
|
||||
inline_row(ui, i == last, |ui| confirm_row(ui, row))
|
||||
} else if let Some(f) = app.ui.versions.form.as_mut().filter(|f| f.id.as_deref() == Some(row.id.as_str())) {
|
||||
inline_row(ui, i == last, |ui| form(ui, f, "Save", TEXT_X))
|
||||
inline_row(ui, i == last, |ui| form(ui, f, tr("Save"), TEXT_X))
|
||||
} else {
|
||||
version_row(ui, row, i == last, previewing.as_deref() == Some(row.id.as_str()))
|
||||
};
|
||||
|
|
@ -223,7 +224,7 @@ pub fn panel(app: &mut FigApp, ui: &mut Ui) {
|
|||
ui.add_space(16.0);
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
egui::RichText::new("No saved versions yet. Save a version to mark a point in your work you can come back to.")
|
||||
egui::RichText::new(tr("No saved versions yet. Save a version to mark a point in your work you can come back to."))
|
||||
.font(regular(11.0))
|
||||
.color(t.text_secondary),
|
||||
)
|
||||
|
|
@ -271,7 +272,7 @@ fn act(app: &mut FigApp, a: Action, rows: &[Row]) {
|
|||
Action::ReviewBranch(id) => crate::branch_ui::open(app, Some(&id)),
|
||||
Action::ArchiveBranch(id) => {
|
||||
if app.run("branch.archive", json!({"id": id})).is_some() {
|
||||
app.toast("Branch archived");
|
||||
app.toast(tr("Branch archived"));
|
||||
}
|
||||
}
|
||||
Action::CancelForm => st.form = None,
|
||||
|
|
@ -301,12 +302,12 @@ fn act(app: &mut FigApp, a: Action, rows: &[Row]) {
|
|||
}
|
||||
Action::Restore(id) => {
|
||||
if app.run("version.restore", json!({"id": id})).is_some() {
|
||||
app.toast(format!("Restored “{}”", name_of(&id)));
|
||||
app.toast(trf("Restored “{name}”", &[("name", &name_of(&id))]));
|
||||
}
|
||||
}
|
||||
Action::Duplicate(id) => {
|
||||
if app.run("version.duplicate", json!({"id": id})).is_some() {
|
||||
app.toast(format!("Opened “{}” in a new tab", name_of(&id)));
|
||||
app.toast(trf("Opened “{name}” in a new tab", &[("name", &name_of(&id))]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -324,7 +325,7 @@ fn form(ui: &mut Ui, f: &mut Form, submit: &str, indent: f32) -> Option<Action>
|
|||
ui.vertical(|ui| {
|
||||
ui.set_width(ui.available_width() - 16.0);
|
||||
let w = ui.available_width() - 16.0;
|
||||
let hint = if f.branch { "Branch name" } else { "Title" };
|
||||
let hint = if f.branch { tr("Branch name") } else { tr("Title") };
|
||||
let title = field(ui, &mut |ui| {
|
||||
ui.add(egui::TextEdit::singleline(&mut f.name).hint_text(hint).font(regular(11.0)).frame(egui::Frame::NONE).desired_width(w))
|
||||
});
|
||||
|
|
@ -339,7 +340,7 @@ fn form(ui: &mut Ui, f: &mut Form, submit: &str, indent: f32) -> Option<Action>
|
|||
field(ui, &mut |ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::multiline(&mut f.description)
|
||||
.hint_text("Describe what changed")
|
||||
.hint_text(tr("Describe what changed"))
|
||||
.font(regular(11.0))
|
||||
.frame(egui::Frame::NONE)
|
||||
.desired_rows(3)
|
||||
|
|
@ -397,8 +398,8 @@ fn current_row(ui: &mut Ui, more: bool, active: bool) -> bool {
|
|||
ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover);
|
||||
}
|
||||
timeline(ui, r, r.center().y, false, more, true);
|
||||
ui.painter().text(pos2(r.min.x + TEXT_X, r.center().y), Align2::LEFT_CENTER, "Current version", semibold(11.0), t.text);
|
||||
!active && resp.on_hover_text("Back to the current version").clicked()
|
||||
ui.painter().text(pos2(r.min.x + TEXT_X, r.center().y), Align2::LEFT_CENTER, tr("Current version"), semibold(11.0), t.text);
|
||||
!active && resp.on_hover_text(tr("Back to the current version")).clicked()
|
||||
}
|
||||
|
||||
fn version_row(ui: &mut Ui, row: &Row, last: bool, previewing: bool) -> Option<Action> {
|
||||
|
|
@ -439,13 +440,13 @@ fn version_row(ui: &mut Ui, row: &Row, last: bool, previewing: bool) -> Option<A
|
|||
}
|
||||
icons::paint(ui.painter(), br.shrink(5.0), Icon::Dots, t.icon);
|
||||
}
|
||||
let more = more.tip("More");
|
||||
let more = more.tip(tr("More"));
|
||||
let mut menu = |ui: &mut Ui| {
|
||||
for (label, a) in [
|
||||
("Restore this version", Action::Restore(row.id.clone())),
|
||||
("Rename", Action::Edit(row.id.clone())),
|
||||
("Duplicate", Action::Duplicate(row.id.clone())),
|
||||
("Delete", Action::AskDelete(row.id.clone())),
|
||||
(tr("Restore this version"), Action::Restore(row.id.clone())),
|
||||
(tr("Rename"), Action::Edit(row.id.clone())),
|
||||
(tr("Duplicate"), Action::Duplicate(row.id.clone())),
|
||||
(tr("Delete"), Action::AskDelete(row.id.clone())),
|
||||
] {
|
||||
if crate::right::menu_item(ui, label, "") {
|
||||
out = Some(a);
|
||||
|
|
@ -467,17 +468,17 @@ fn version_row(ui: &mut Ui, row: &Row, last: bool, previewing: bool) -> Option<A
|
|||
fn branch_row(ui: &mut Ui, b: &BranchRow) -> Option<Action> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::click());
|
||||
let resp = resp.tip("Open branch");
|
||||
crate::a11y::list_item(&resp, &format!("Branch {}", b.name), false);
|
||||
let resp = resp.tip(tr("Open branch"));
|
||||
crate::a11y::list_item(&resp, &trf("Branch {name}", &[("name", &b.name)]), false);
|
||||
let hovered = ui.rect_contains_pointer(r);
|
||||
if hovered {
|
||||
ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover);
|
||||
}
|
||||
let active = b.state == BranchState::Active;
|
||||
let state = match b.state {
|
||||
BranchState::Active => "Active",
|
||||
BranchState::Merged => "Merged",
|
||||
BranchState::Archived => "Archived",
|
||||
BranchState::Active => tr("Active"),
|
||||
BranchState::Merged => tr("Merged"),
|
||||
BranchState::Archived => tr("Archived"),
|
||||
};
|
||||
let clip = Rect::from_min_max(pos2(r.min.x + 16.0, r.min.y), pos2(r.max.x - 40.0, r.max.y));
|
||||
let p = ui.painter().with_clip_rect(clip);
|
||||
|
|
@ -494,11 +495,11 @@ fn branch_row(ui: &mut Ui, b: &BranchRow) -> Option<Action> {
|
|||
}
|
||||
icons::paint(ui.painter(), br.shrink(5.0), Icon::Dots, t.icon);
|
||||
}
|
||||
let more = more.tip("More");
|
||||
let more = more.tip(tr("More"));
|
||||
let mut menu = |ui: &mut Ui| {
|
||||
let mut items = vec![("Open", Action::OpenBranch(b.id.clone())), ("Review and merge", Action::ReviewBranch(b.id.clone()))];
|
||||
let mut items = vec![(tr("Open"), Action::OpenBranch(b.id.clone())), (tr("Review and merge"), Action::ReviewBranch(b.id.clone()))];
|
||||
if active {
|
||||
items.push(("Archive", Action::ArchiveBranch(b.id.clone())));
|
||||
items.push((tr("Archive"), Action::ArchiveBranch(b.id.clone())));
|
||||
}
|
||||
for (label, a) in items {
|
||||
if crate::right::menu_item(ui, label, "") {
|
||||
|
|
@ -525,13 +526,13 @@ pub fn preview_banner(app: &mut FigApp, ctx: &egui::Context, canvas: Rect) {
|
|||
let mut action = None;
|
||||
icons::paint(ui.painter(), Rect::from_center_size(ui.cursor().min + vec2(7.0, 12.0), vec2(14.0, 14.0)), Icon::Clock, t.icon_secondary);
|
||||
ui.add_space(20.0);
|
||||
ui.label(egui::RichText::new(format!("Viewing “{name}”")).font(semibold(11.0)).color(t.text));
|
||||
ui.label(egui::RichText::new(trf("Viewing “{name}”", &[("name", &name)])).font(semibold(11.0)).color(t.text));
|
||||
ui.label(egui::RichText::new(crate::comments::ago_long(time)).font(regular(11.0)).color(t.text_secondary));
|
||||
ui.add_space(4.0);
|
||||
if widgets::button(ui, "Back to current", false, true).clicked() {
|
||||
if widgets::button(ui, tr("Back to current"), false, true).clicked() {
|
||||
action = Some(false);
|
||||
}
|
||||
if widgets::button(ui, "Restore this version", true, true).clicked() {
|
||||
if widgets::button(ui, tr("Restore this version"), true, true).clicked() {
|
||||
action = Some(true);
|
||||
}
|
||||
action
|
||||
|
|
@ -539,7 +540,7 @@ pub fn preview_banner(app: &mut FigApp, ctx: &egui::Context, canvas: Rect) {
|
|||
match action {
|
||||
Some(true) => {
|
||||
if app.run("version.restore", json!({"id": id})).is_some() {
|
||||
app.toast(format!("Restored “{name}”"));
|
||||
app.toast(trf("Restored “{name}”", &[("name", &name)]));
|
||||
}
|
||||
}
|
||||
Some(false) => {
|
||||
|
|
@ -559,9 +560,9 @@ fn autosave_group(ui: &mut Ui, n: usize, open: bool, last: bool) -> bool {
|
|||
timeline(ui, r, r.center().y, true, !last, false);
|
||||
let chevron = Rect::from_center_size(pos2(r.min.x + TEXT_X + 6.0, r.center().y), vec2(12.0, 12.0));
|
||||
icons::paint(ui.painter(), chevron, if open { Icon::Chevron } else { Icon::ChevronRight }, t.icon_secondary);
|
||||
let label = format!("{n} autosave versions");
|
||||
let label = trn("{n} autosave versions", n, &[]);
|
||||
ui.painter().text(pos2(chevron.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, label, regular(11.0), t.text_secondary);
|
||||
resp.on_hover_text(if open { "Fold autosaves" } else { "Show autosaves" }).clicked()
|
||||
resp.on_hover_text(if open { tr("Fold autosaves") } else { tr("Show autosaves") }).clicked()
|
||||
}
|
||||
|
||||
/// A row holding a form (rename, delete confirmation), with the timeline running past it.
|
||||
|
|
@ -580,10 +581,13 @@ fn confirm_row(ui: &mut Ui, row: &Row) -> Option<Action> {
|
|||
let mut out = None;
|
||||
egui::Frame::NONE.inner_margin(egui::Margin { left: TEXT_X as i8, right: 16, top: 4, bottom: 0 }).show(ui, |ui| {
|
||||
ui.add(
|
||||
egui::Label::new(egui::RichText::new(format!("Delete “{}”? This can't be undone.", row.name)).font(regular(11.0)).color(t.text)).wrap(),
|
||||
egui::Label::new(
|
||||
egui::RichText::new(trf("Delete “{name}”? This can't be undone.", &[("name", &row.name)])).font(regular(11.0)).color(t.text),
|
||||
)
|
||||
.wrap(),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
out = buttons(ui, "Delete", true).map(|delete| if delete { Action::Delete(row.id.clone()) } else { Action::CancelDelete });
|
||||
out = buttons(ui, tr("Delete"), true).map(|delete| if delete { Action::Delete(row.id.clone()) } else { Action::CancelDelete });
|
||||
});
|
||||
out
|
||||
}
|
||||
|
|
@ -595,7 +599,7 @@ fn buttons(ui: &mut Ui, primary: &str, enabled: bool) -> Option<bool> {
|
|||
ui.allocate_ui_with_layout(size, egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
let ok = widgets::button(ui, primary, true, enabled).clicked();
|
||||
let cancel = widgets::button(ui, "Cancel", false, true).clicked();
|
||||
let cancel = widgets::button(ui, tr("Cancel"), false, true).clicked();
|
||||
if ok { Some(true) } else { cancel.then_some(false) }
|
||||
})
|
||||
.inner
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
use egui::{Align2, Color32, CornerRadius, Id, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
|
||||
|
||||
use crate::a11y::{self, Tip};
|
||||
use crate::i18n::tr;
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::theme::{Tokens, regular, semibold};
|
||||
|
||||
|
|
@ -26,8 +27,8 @@ impl Lead<'_> {
|
|||
/// The field's name for screen readers ("W" is "Width").
|
||||
pub fn name(&self) -> &str {
|
||||
match self {
|
||||
Lead::Text("W") => "Width",
|
||||
Lead::Text("H") => "Height",
|
||||
Lead::Text("W") => tr("Width"),
|
||||
Lead::Text("H") => tr("Height"),
|
||||
Lead::Text(s) => s,
|
||||
Lead::Icon(_, name) | Lead::Named(name) => name,
|
||||
Lead::None => "",
|
||||
|
|
@ -83,7 +84,7 @@ pub fn number_field_bindable(
|
|||
step: f64,
|
||||
binding: Option<Option<&str>>,
|
||||
) -> (Option<FieldEdit>, Option<BindEvent>) {
|
||||
number_field_impl(ui, id, lead, value, suffix, width, step, binding, "Mixed")
|
||||
number_field_impl(ui, id, lead, value, suffix, width, step, binding, tr("Mixed"))
|
||||
}
|
||||
|
||||
fn number_field_impl(
|
||||
|
|
@ -231,7 +232,7 @@ fn number_field_impl(
|
|||
}
|
||||
icons::paint(ui.painter(), br.shrink(3.0), Icon::Variable, t.icon_secondary);
|
||||
}
|
||||
let bresp = bresp.tip("Apply variable");
|
||||
let bresp = bresp.tip(tr("Apply variable"));
|
||||
if bresp.clicked() {
|
||||
bind = Some(BindEvent::Open(rect.left_bottom()));
|
||||
}
|
||||
|
|
@ -269,7 +270,7 @@ pub fn bound_field(ui: &mut Ui, id: Id, lead: Lead, rect: Rect, name: &str) -> O
|
|||
let short = name.rsplit('/').next().unwrap_or(name);
|
||||
let galley = ui.painter().layout_no_wrap(short.to_string(), regular(11.0), t.text);
|
||||
ui.painter().with_clip_rect(chip.shrink2(vec2(5.0, 0.0))).galley(pos2(chip.min.x + 5.0, chip.center().y - galley.size().y / 2.0), galley, t.text);
|
||||
let dresp = ui.interact(dr, id.with("detach"), Sense::click()).tip("Detach variable");
|
||||
let dresp = ui.interact(dr, id.with("detach"), Sense::click()).tip(tr("Detach variable"));
|
||||
if resp.hovered() || dresp.hovered() || dresp.has_focus() {
|
||||
if dresp.hovered() {
|
||||
ui.painter().rect_filled(dr, CornerRadius::same(4), t.hover);
|
||||
|
|
@ -376,7 +377,7 @@ pub fn combo_field(ui: &mut Ui, id: Id, value: &str, width: f32, options: &[Stri
|
|||
// The chevron sits on top of the field's right end.
|
||||
let chev = Rect::from_min_max(pos2(rect.max.x - 18.0, rect.min.y), rect.max);
|
||||
let resp = ui.interact(chev, id.with("menu"), Sense::click());
|
||||
a11y::combo(&resp, "Presets", value, a11y::menu_open(&resp));
|
||||
a11y::combo(&resp, tr("Presets"), value, a11y::menu_open(&resp));
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(chev.shrink(2.0), CornerRadius::same(4), t.hover);
|
||||
}
|
||||
|
|
@ -675,7 +676,7 @@ pub fn swatch(ui: &mut Ui, paint: &figcraft_color::Paint, size: f32) -> Response
|
|||
let (rect, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click());
|
||||
// The value is only spelled out while a screen reader reads the tree.
|
||||
let value = if a11y::active(&resp.ctx) { paint_name(paint) } else { String::new() };
|
||||
a11y::color(&resp, "Color", &value);
|
||||
a11y::color(&resp, tr("Color"), &value);
|
||||
paint_swatch(ui.painter(), rect, paint);
|
||||
resp
|
||||
}
|
||||
|
|
@ -689,9 +690,9 @@ pub fn paint_name(paint: &figcraft_color::Paint) -> String {
|
|||
let alpha = color.a * paint.opacity;
|
||||
if alpha < 0.999 { format!("{hex} {}%", (alpha * 100.0).round()) } else { hex }
|
||||
}
|
||||
PaintKind::Gradient(_) => "Gradient".into(),
|
||||
PaintKind::Image(_) => "Image".into(),
|
||||
PaintKind::Pattern(_) => "Pattern".into(),
|
||||
PaintKind::Gradient(_) => tr("Gradient").into(),
|
||||
PaintKind::Image(_) => tr("Image").into(),
|
||||
PaintKind::Pattern(_) => tr("Pattern").into(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -799,7 +800,7 @@ pub fn text_field_inline(ui: &mut Ui, id: Id, value: &str, rect: Rect, font: egu
|
|||
let focused = ui.memory(|m| m.has_focus(te_id));
|
||||
let mut buf: Option<String> = ui.data(|d| d.get_temp(buf_id));
|
||||
let resp = ui.interact(rect, id.with("click"), Sense::click());
|
||||
a11y::text(&resp, "Name", value);
|
||||
a11y::text(&resp, tr("Name"), value);
|
||||
let started = buf.is_none() && (focused || resp.clicked());
|
||||
if started {
|
||||
buf = Some(value.to_string());
|
||||
|
|
|
|||
22
docs/gaps.md
22
docs/gaps.md
|
|
@ -1,6 +1,6 @@
|
|||
# Where FigCraft falls short of Figma
|
||||
|
||||
> **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: gap 14 down to its tail) · **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: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from
|
||||
doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The
|
||||
|
|
@ -18,7 +18,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
| 2 | Multiplayer and sharing are a preview, without accounts (**beta blocker**) | Feature | 45–90 | [collaboration-parity](collaboration-parity.md) |
|
||||
| 3 | Nothing checked against real Figma files | Fidelity | 15–30 | [target-app-parity](target-app-parity.md) |
|
||||
| 4 | Interaction fidelity not compared tool by tool | UI/UX | 40–60 | [ui-parity](ui-parity.md) |
|
||||
| 5 | No localization layer; English only | Localization | 60–100 | [localization-parity](localization-parity.md) |
|
||||
| 5 | Localization: English and a Spanish draft only (the layer landed 2026-10-11) | Localization | 45–75 | [localization-parity](localization-parity.md) |
|
||||
| 6 | Plugins: no distribution or widgets; Figma's plugins don't run | Ecosystem | 15–40 | [target-app-parity](target-app-parity.md) |
|
||||
| 7 | CPU canvas where Figma uses the GPU | Hardware/performance | 30–60 | [hardware-parity](hardware-parity.md) |
|
||||
| 8 | Platform QA is mostly macOS | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) |
|
||||
|
|
@ -94,15 +94,18 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
- **Impact:** Figma users notice the feel within minutes; it decides whether they stay.
|
||||
- **Estimate:** 40–60 h. Detail in [`ui-parity.md`](ui-parity.md).
|
||||
|
||||
### 5. No localization layer; English only
|
||||
- **Missing:** externalized strings and catalogs. Figma ships English, Japanese, Korean, French,
|
||||
German, Spanish (Spain and Latin America) and Portuguese (Brazil).
|
||||
- **Evidence (measured from source):** ~800 distinct capitalised UI string literals in
|
||||
`crates/ui-egui/src` and 435 command labels, all inline; no catalog files.
|
||||
### 5. Localization: English and a Spanish draft only
|
||||
- **Missing:** Figma's other UI languages (Japanese, Korean, French, German, Portuguese (Brazil)), a
|
||||
native review of the Spanish draft, localized engine messages and number formatting.
|
||||
- **Evidence (measured from source):** the layer landed 2026-10-11: catalogs in
|
||||
`crates/ui-egui/locales/` (1,280 UI keys plus 501 command labels), Preferences ▸ Language,
|
||||
`pref.language`; `en.toml` is complete and `es.toml` is a complete machine draft. Engine error
|
||||
messages and Dev Mode values stay English.
|
||||
- **Impact:** keeps out non-English designers, including Figma's large Japanese and Korean user
|
||||
bases.
|
||||
- **Estimate:** 15–25 h for the layer and string extraction, then 3–5 h per language plus native
|
||||
review; 60–100 h for the 11 key languages. Detail in [`localization-parity.md`](localization-parity.md).
|
||||
- **Estimate:** 3–7 h per language plus native review, 4–6 h for engine messages and numbers;
|
||||
45–75 h for the 11 key languages. Detail in [`localization-parity.md`](localization-parity.md);
|
||||
how to add one in [`translating.md`](translating.md).
|
||||
|
||||
### 6. Plugins: no distribution or widgets; Figma's plugins don't run
|
||||
- **Landed (#101):** sandboxed WebAssembly plugins (wasmi, metered, memory cap, no WASI, also in
|
||||
|
|
@ -277,6 +280,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
|||
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
# Localization parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created; measured from source) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** major (localization layer; English catalog; Spanish as a draft) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft's user interface is English only: there is no localization layer yet. This page lists the
|
||||
twelve key languages of the craftrules standard, then the other languages Figma ships.
|
||||
FigCraft's interface has a localization layer with an English catalog and a complete Spanish draft
|
||||
(machine-drafted, waiting for a native speaker's review). This page lists the twelve key languages
|
||||
of the craftrules standard, then the other languages Figma ships. How to add a language:
|
||||
[`translating.md`](translating.md).
|
||||
|
||||
## What Figma ships
|
||||
|
||||
|
|
@ -14,49 +16,66 @@ browser's page translation. The `.lproj` folders in `/Applications/Figma.app` (6
|
|||
Electron/Chromium locale packs, not Figma's own UI languages. Text *content* in any script is
|
||||
supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no vertical text.
|
||||
|
||||
## What FigCraft has (measured 2026-10-10, from source)
|
||||
## What FigCraft has (measured 2026-10-11, from source)
|
||||
|
||||
- **Strings:** ~800 distinct capitalised string literals in `crates/ui-egui/src` (labels, tooltips,
|
||||
section titles, dialogs) and 435 command labels in `crates/engine/src` (menus, the Actions
|
||||
palette), all inline in the code. ~1,200–1,500 user-visible strings in all. No catalog, no
|
||||
translation function, no language preference.
|
||||
- **Layer:** one TOML catalog per language in `crates/ui-egui/locales/`, baked into the binary by
|
||||
the UI crate's build script (desktop and web; nothing read at run time). Keys are the English text
|
||||
(`tr("Fill")`, `trf` for `{named}` placeholders, `trn` for counts with one/other plural forms), so
|
||||
a missing translation shows English, never a raw key. One English text that needs two
|
||||
translations takes a context (`"Fill|sizing"`).
|
||||
- **Strings:** 1,280 UI keys in `en.toml` (35 with plural forms; ~110 are labels from engine and
|
||||
document data, such as tool names, blend modes and Dev Mode properties) plus 501 command labels
|
||||
and 29 menu wordings by command id. Command ids, the CLI, MCP and the control channel stay
|
||||
English. Accessibility names come from the same strings.
|
||||
- **Language preference:** Preferences ▸ Language (System, English, Español), applied at once
|
||||
without a restart; System follows the OS (Windows language list, macOS AppleLanguages, the
|
||||
locale variables on Linux and BSD) or the browser's language on the web. `pref.language
|
||||
{language}` sets it from the CLI, MCP and the control channel.
|
||||
- **Check:** `cargo test -p figcraft-ui-egui i18n` fails on keys the code uses that `en.toml`
|
||||
lacks, keys no code uses, unknown command ids, placeholders that differ from English and, for
|
||||
complete languages (Spanish), anything untranslated.
|
||||
- **Not yet localized:** messages the engine writes (errors, a few toasts, Dev Mode property values
|
||||
such as "inside"), number formatting (decimal points stay `.`), and plural rules beyond one/other
|
||||
(enough for English and Spanish).
|
||||
- **Script support in documents:** Unicode bidi with Arabic shaping and mirrored brackets in text
|
||||
layers; right-to-left list markers and indents; right-to-left names in the layers panel, tabs
|
||||
and canvas labels (visual order, joined Arabic); inline IME composition with the candidate window
|
||||
at the caret; system-font fallback for scripts the UI fonts lack. Devanagari shapes through
|
||||
harfrust. No vertical text (Figma has none either).
|
||||
- **Script support in the UI:** no mirrored (right-to-left) layout of panels.
|
||||
- **Script support in the UI:** menus widen to their longest label; no mirrored (right-to-left)
|
||||
layout of panels.
|
||||
|
||||
## Languages
|
||||
|
||||
| Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | Hours to full |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| English | en | ~1,300 / ~1,300 (100%, source) | yes | Latin | yes (source) | full | 0 |
|
||||
| Simplified Chinese (Mandarin) | zh-Hans | 0 (0%) | no | CJK text, IME and font fallback work in documents; UI font needs CJK coverage | no | none | 5–8 |
|
||||
| Spanish | es (es-ES, es-419) | 0 (0%) | no | Latin | no | none | 4–6 (both variants, as Figma ships) |
|
||||
| Hindi | hi | 0 (0%) | no | Devanagari shaping works in documents; UI font fallback to check | no | none | 5–8 |
|
||||
| Arabic | ar | 0 (0%) | no | Bidi, shaping and RTL names work; the UI does not mirror | no | none | 10–16 |
|
||||
| English | en | 1,810 / 1,810 (100%, source) | yes | Latin | yes (source) | full | 0 |
|
||||
| Simplified Chinese (Mandarin) | zh-Hans | 0 (0%) | no | CJK text, IME and font fallback work in documents; UI font needs CJK coverage | no | none | 4–7 |
|
||||
| Spanish | es | 1,810 / 1,810 (100%, draft) | yes | Latin | no | draft | 1–2 (es-419 differences, after native review) |
|
||||
| Hindi | hi | 0 (0%) | no | Devanagari shaping works in documents; UI font fallback to check | no | none | 4–7 |
|
||||
| Arabic | ar | 0 (0%) | no | Bidi, shaping and RTL names work; the UI does not mirror | no | none | 9–15 |
|
||||
| French | fr | 0 (0%) | no | Latin | no | none | 3–5 |
|
||||
| Portuguese | pt (pt-BR) | 0 (0%) | no | Latin | no | none | 3–5 |
|
||||
| Indonesian | id | 0 (0%) | no | Latin | no | none | 3–5 |
|
||||
| Japanese | ja | 0 (0%) | no | CJK text and IME work in documents; UI font needs CJK coverage | no | none | 5–8 |
|
||||
| Japanese | ja | 0 (0%) | no | CJK text and IME work in documents; UI font needs CJK coverage | no | none | 4–7 |
|
||||
| German | de | 0 (0%) | no | Latin; long words need wider panels | no | none | 3–5 |
|
||||
| Korean | ko | 0 (0%) | no | Hangul text and IME work in documents; UI font needs coverage | no | none | 4–6 |
|
||||
| Korean | ko | 0 (0%) | no | Hangul text and IME work in documents; UI font needs coverage | no | none | 3–5 |
|
||||
| Vietnamese | vi | 0 (0%) | no | Latin with stacked diacritics | no | none | 3–5 |
|
||||
|
||||
Other languages: none shipped (Figma ships none beyond the eight above).
|
||||
|
||||
Before any language: **15–25 h** to add the localization layer (string catalog format, a lookup
|
||||
used by `ui-egui` and the command registry, plural and argument handling, a language preference,
|
||||
a test that every string has a key, pseudo-localization to find truncation). Per-language hours
|
||||
above assume that layer exists and are agent hours; every language also needs a native speaker's
|
||||
review (human). Total for the eleven: **60–100 h** including the layer (48–77 h of languages).
|
||||
The layer is done. Per-language hours above are agent hours for a catalog of ~1,800 entries
|
||||
(a machine draft, then a pass over the panels for overflow), plus each language's plural rule where
|
||||
it isn't one/other; every language also needs a native speaker's review (human). Left before full
|
||||
parity besides the languages: localizing engine messages and number formatting (4–6 h);
|
||||
right-to-left mirroring of the panels is in Arabic's hours. Total: **45–75 h**.
|
||||
|
||||
Priority: Japanese, Korean, Spanish, Portuguese (Brazil), French and German first: those are
|
||||
Figma's own languages, so their users expect them.
|
||||
Priority: Japanese, Korean, Portuguese (Brazil), French and German next (with Spanish, Figma's own
|
||||
languages, so their users expect them), and a native review of the Spanish draft.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | major | Localization layer landed (gap 5): catalogs per language baked in at build time, English text as keys and fallback, `{named}` placeholders and one/other plurals, command labels by command id, Preferences ▸ Language (System follows the OS or the browser) applied live, `pref.language`, and a catalog check; English catalog (1,280 UI keys) and a complete Spanish draft (1,810 entries with command labels) waiting for native review; localization 10% → 20%, 60–100 h → 45–75 h |
|
||||
| 2026-10-10 | major | Created: English only, string counts measured from source, Figma's eight UI languages from its help center |
|
||||
|
|
|
|||
|
|
@ -182,6 +182,9 @@ on; `key` resolves keys the way the canvas does. Commands behind keys:
|
|||
along the toolbar and through the layer rows, and Escape returns to the canvas (no-ops headless).
|
||||
- `pref.reduceMotion {on?}`: reduced motion (the interface's own animations off; the system's
|
||||
accessibility setting turns it on too). Prototype transitions still play.
|
||||
- `pref.language {language: system|en|es|…}`: the app's interface language, applied live ("system":
|
||||
the OS or browser language; a language without a catalog shows English). Command ids, labels and
|
||||
every result stay English.
|
||||
|
||||
### Editing vectors
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# FigCraft roadmap: milestones and what's next
|
||||
|
||||
> **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%) · **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%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
|
||||
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
|
||||
|
|
@ -15,8 +15,8 @@ are one Opus 5.5 agent; most milestones split across 4–6 agents on disjoint cr
|
|||
exports, turn estimated percentages into measured ones: 15–30 h.
|
||||
3. **Tool-by-tool interaction pass** against the running Figma ([`ui-parity.md`](ui-parity.md)):
|
||||
40–60 h, parallel by tool.
|
||||
4. **Localization layer**, then Figma's own languages first (ja, ko, es, pt-BR, fr, de): 15–25 h
|
||||
for the layer, 3–5 h per language plus native review.
|
||||
4. **Languages** on the landed localization layer, Figma's own first (ja, ko, pt-BR, fr, de; es is
|
||||
a draft awaiting native review): 3–7 h per language plus native review.
|
||||
5. **Multiplayer M9**: accounts and teams on top of the live sessions and the standalone server (the WebSocket transport, hosting from the app, invite links that open the app, document sync, live cursors, selections, text carets, avatars and follow, per-user undo, reconnect with offline edits, collision-free ids for styles, variables, collections and comments, joining from the browser, and `figcraft-server` with persistence, publishing from the app and share links to edit or view landed), per [`collab-protocol.md`](collab-protocol.md) and [`server.md`](server.md): 45–90 h.
|
||||
|
||||
## Alpha gate
|
||||
|
|
@ -72,6 +72,7 @@ blockers.
|
|||
|
||||
| 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 |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# FigCraft parity with Figma
|
||||
|
||||
> **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%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-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%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
|
||||
|
||||
The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md)
|
||||
summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is
|
||||
|
|
@ -20,8 +20,8 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c
|
|||
| Ready for real work | **~47%** | estimated (dimension table below: written weights × written per-dimension scores) |
|
||||
| Essentials user | **~53%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.92 × 0.8) |
|
||||
| Mainstream practitioner | **~43%** | estimated ([below](#mainstream-practitioner): weekly areas 66.8% × discounts 0.9 × 0.9 × 0.8) |
|
||||
| Remaining to beta | **~200–365 h** one Opus 5.5 agent (~40–80 h wall-clock with 5 parallel agents) | estimated |
|
||||
| Remaining to full parity | **~363–697 h** one agent (~95–165 h wall-clock with 5 parallel agents) | estimated |
|
||||
| Remaining to beta | **~195–355 h** one Opus 5.5 agent (~40–80 h wall-clock with 5 parallel agents) | estimated |
|
||||
| Remaining to full parity | **~348–672 h** one agent (~92–160 h wall-clock with 5 parallel agents) | estimated |
|
||||
| Stage | **alpha**: the alpha gate passes ([roadmap](roadmap.md#alpha-gate)); `.fig` import and multiplayer are beta gaps (see [ROADMAP](../ROADMAP.md#stage)) | |
|
||||
|
||||
The previous figures (ROADMAP of 2026-10-10, "feature depth ~79%", "full parity 115–195 h") were
|
||||
|
|
@ -35,7 +35,7 @@ tool; presence was being graded as behaviour in places.
|
|||
|
||||
| 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) |
|
||||
|
||||
|
|
@ -129,11 +129,11 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|
|||
| Stability | 8 | 55% | 20–40 | Never-crash lints, command guard with rollback, recovery copies, 963 tests incl. junk-parameter sweeps and a seeded retained-render walk; but two days old, one release (v0.2.0), no field reports, QA mostly on macOS | [gaps](gaps.md) |
|
||||
| Performance | 6 | 60% | 30–60 | `cargo xtask bench`: 50k-layer page ~8 ms zoomed out, 10k-layer edits ~0.2 ms, 2,000-instance design system edits 4–64 ms; CPU (tiny-skia) canvas where Figma renders on the GPU; nothing timed against Figma on the same file | [hardware-parity](hardware-parity.md) |
|
||||
| Hardware | 3 | 55% | 13–27 | Trackpad pinch and HiDPI yes; pen pressure on Windows and from browser styluses (none from macOS/Linux tablets); no GPU canvas, no P3 on screen, one window | [hardware-parity](hardware-parity.md) |
|
||||
| Localization | 3 | 10% | 60–100 | English only; Figma ships 7 more UI languages | [localization-parity](localization-parity.md) |
|
||||
| Localization | 3 | 20% | 45–75 | The layer, an English catalog and a complete Spanish draft (unreviewed); Figma ships 7 more UI languages; engine messages stay English | [localization-parity](localization-parity.md) |
|
||||
| Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) |
|
||||
| Ecosystem/plugins | 3 | 28% | 15–40 | Own WASM plugins with a Rust SDK and two examples (#101), plugin panels and settings (ABI v2); no Figma plugin compatibility, no widgets, no distribution or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) |
|
||||
| AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) |
|
||||
| **Ready for real work** | 100 | **~47%** | **~363–697** | | |
|
||||
| **Ready for real work** | 100 | **~47%** | **~348–672** | | |
|
||||
|
||||
Dimension hours add up to the full-parity total without double counting: the feature-area rows
|
||||
for import/export (14), multiplayer and team libraries (17, 19), plugins (18) and AI (21) are
|
||||
|
|
@ -580,11 +580,12 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
|
|||
|
||||
| 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 `<textPath>` 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 `<pattern>`, 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 |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
# Translating FigCraft
|
||||
|
||||
FigCraft's interface strings live in one TOML catalog per language in
|
||||
[`crates/ui-egui/locales/`](../crates/ui-egui/locales). The build bakes every catalog into the
|
||||
binary (desktop and web); nothing is read at run time. Preferences ▸ Language picks the language
|
||||
(System follows the OS, or the browser on the web) and the interface switches at once. `pref.language
|
||||
{language}` does the same from the CLI, MCP and the control channel.
|
||||
|
||||
## The catalogs
|
||||
|
||||
- **`en.toml`** lists every key. A key is the English text as the code writes it, so the code stays
|
||||
readable: `tr("Fill")`, `trf("Delete {name}?", &[("name", &name)])`, `trn("{n} layers", count, &[])`.
|
||||
Values equal their keys except plurals, which give both forms:
|
||||
`"{n} layers" = { one = "{n} layer", other = "{n} layers" }`.
|
||||
- **`es.toml`** and other languages translate the same keys under `[ui]`, plus command labels by
|
||||
command id under `[commands]` (the Actions palette, the shortcuts panel and the menus) and, under
|
||||
`[menu]`, the few menu items the main menu words differently from the command (the Select matching
|
||||
submenu's "Fill" for "Select same fill"). Command ids, the CLI, MCP and control-channel output stay
|
||||
English.
|
||||
- `[meta] name` is the language's own name (the picker shows it untranslated); `[meta] status` marks
|
||||
a draft (the Preferences window says so).
|
||||
|
||||
A key a catalog lacks shows its English text, so a partial catalog is safe to ship.
|
||||
|
||||
## Adding a language
|
||||
|
||||
1. Copy `crates/ui-egui/locales/es.toml` to `<code>.toml` (a BCP 47 code: `fr`, `pt-BR`, `ja`) and
|
||||
set `[meta] name` and `status = "draft — needs native review"`.
|
||||
2. Translate every value; keep the keys. Keep `{placeholders}` exactly, "…" where English has it,
|
||||
brand names and keyboard glyphs. English labels of commands: `figcraft-cli commands`.
|
||||
3. Plurals: give `one` only if the language has a singular form (Japanese, Korean and Chinese give
|
||||
only `other`). `trn` picks `one` for a count of 1; a language with other rules (French: 0 and 1)
|
||||
needs its rule in `plural_one` in [`crates/ui-egui/src/i18n.rs`](../crates/ui-egui/src/i18n.rs).
|
||||
4. To require full coverage, add the code to `COMPLETE` in
|
||||
[`crates/ui-egui/src/i18n_tests.rs`](../crates/ui-egui/src/i18n_tests.rs).
|
||||
5. Run the check and look at the panels in the app (longer words must fit the right panel):
|
||||
|
||||
cargo test -p figcraft-ui-egui i18n
|
||||
|
||||
The check fails on keys the code uses that `en.toml` lacks (it prints the lines to paste), keys no
|
||||
code uses, unknown command ids, placeholders that don't match English, and, for complete languages,
|
||||
anything untranslated. Machine-drafted catalogs stay marked as drafts until a native speaker has
|
||||
reviewed them.
|
||||
|
||||
## Adding strings in code
|
||||
|
||||
Wrap every string a user sees or a screen reader hears: `tr` for plain text, `trf` for `{named}`
|
||||
placeholders, `trn` for counts, `tr_noop` for literals in `const` tables that are looked up later
|
||||
with `tr(label)`. The key must be a string literal as the first argument (the check reads the
|
||||
source). Don't wrap ids, command ids, JSON, log messages, file extensions or document content. Then
|
||||
add the key to `en.toml` and to each complete language.
|
||||
|
||||
Labels from the engine and document crates (tool names, blend modes, easing curves…) are looked up
|
||||
at display with `tr(x.label())`; they are listed in `dynamic_labels` in `i18n_tests.rs`.
|
||||
Loading…
Reference in New Issue