diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..d39962e1c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,32 @@ +version: 2 +updates: + # Maintain Rust / Cargo dependencies + - package-ecosystem: "cargo" + directories: + - "/" + - "/plugins" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + # Consolidate updates into a single PR rather than one PR per crate + groups: + cargo-dependencies: + patterns: + - "*" + commit-message: + prefix: "chore(deps)" + + # Maintain GitHub Actions workflows + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + groups: + actions: + patterns: + - "*" + commit-message: + prefix: "ci(deps)" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e613617a1..9700a8e91 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,7 @@ jobs: name: test (ubuntu-latest) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Install Linux system dependencies (egui/winit/rfd) run: | @@ -74,7 +74,7 @@ jobs: name: wasm32 check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable with: targets: wasm32-unknown-unknown diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index b01af0524..ff15bfbc4 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -32,4 +32,4 @@ jobs: uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main with: run-package-check: true - craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 + craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 75f7a5fae..dd2aefddb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,8 +24,14 @@ jobs: with: app-name: figcraft app-title: FigCraft + # Needs fonts/latin-manifest.txt (Inter): crates/text/build.rs fails a release without it. Only + # craft-fonts' pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter. + # Re-pin once one commit has both (storytold/craft-fonts#42). craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 has-server: true + # The browser has no system fonts: without craft-fonts the web build can't draw text (#885). + # crates/text/build.rs keeps the web build to Inter. + web-craft-fonts: true version: ${{ inputs.version }} secrets: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 1f72aeb7c..96dff6a9e 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -25,6 +25,7 @@ Release builds embed fonts from the optional craft-fonts build input | Family | Licence | Upstream | |---|---|---| | Inter | SIL Open Font License 1.1 | https://github.com/rsms/inter | +| Archivo | SIL Open Font License 1.1 | [Google Fonts / Archivo](https://github.com/google/fonts/tree/6c70c829f09ea345d3590406693220ea35c6553f/ofl/archivo) | | Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Source Sans 3, Source Serif 4, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) | | Liberation Sans, Serif, Mono | SIL Open Font License 1.1 | https://github.com/liberationfonts | | BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) | diff --git a/README.md b/README.md index a072a06fe..ae4c9f24c 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

FigCraft

- Interface design; an open-source, clean-room reimplementation of Figma, rebuilt in pure Rust.
+ Interface design and prototyping: open source, collaborative and built in pure Rust.
Frames, auto layout, components and variants, vector networks, styles, variables and prototypes,
in a native app that opens instantly and works offline. Open source, and yours.

@@ -62,7 +62,7 @@

🎛️ Familiar by design

- The toolbar, layers panel, Design panel, menus and shortcuts sit where your hands expect them. If you know Figma, you already know FigCraft. + The toolbar, layers panel, Design panel, menus and shortcuts sit right where your hands expect them.

⚡ Native and fast

@@ -227,7 +227,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control - **Its own renderer:** tiny-skia on every core for the canvas and exports, with layer blending, effects and masks; booleans from linesweeper, geometry from kurbo, text from skrifa and HarfRust. - **Runs in the browser:** the whole engine and UI compile to WebAssembly. - **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes. -- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech and Indonesian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). +- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian and Georgian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). - **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and three example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md). - **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md). - **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md). @@ -262,7 +262,7 @@ New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs Installers for macOS (universal, signed and notarized), Windows (x64, x86 and arm64), Linux (AppImage, `.deb`, `.rpm`, Flatpak and tarball), FreeBSD and the web are attached to each [GitHub release](https://github.com/storytold/figcraft/releases). Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published. > [!IMPORTANT] -> **Status:** FigCraft is in **alpha**: about 49% of the way to replacing Figma for real work, by our own estimate. Nearly all of Figma's main menu is wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft opens `.fig` files only best effort, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins rather than Figma's, and hasn't been checked against real Figma documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft). +> **Status:** FigCraft is in **alpha**: about 49% of the way to replacing proprietary tools for real work, by our own estimate. Nearly all tracked interface design actions in the menu checklist are wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft opens `.fig` files only best effort, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins, and hasn't been checked against real design documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft). ## Privacy and online sharing @@ -348,8 +348,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif part of this repository and FigCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). Forks and modified versions must remove them. -Figma is a trademark of Figma, Inc. FigCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Figma, Inc.; the name is used only to describe the workflows FigCraft is compatible with. -

ArtCraft
Made by the ArtCraft team and community. diff --git a/ROADMAP.md b/ROADMAP.md index 8a587286d..4e0d088d3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -316,6 +316,11 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-20 | minor | Duplicating a main component makes an instance (#892): Duplicate and Alt-drag copied a main component as a second main; within its file the copy is now an instance of it, as Copy then Paste already was (a variant duplicated in its set stays a variant). Numbers unchanged. | +| 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged | +| 2026-10-20 | minor | Layer data reads back (#879): `node.get` (the command, the control method and MCP `get_node`) and `document.inspect` report a layer's or page's `pluginData` as `{key: value}` when it has any, so a tool that keeps state in a file with `node.set {props: {pluginData}}` can read it on its next run (`plugin_data_tests`). Numbers unchanged | +| 2026-10-20 | minor | Measuring while nudging (#894): with Alt held over another layer, the arrows nudge the selection (Shift: the big step) and the distances follow every nudge (Alt+arrows used to do nothing); while measuring, the selection's handles and size label hide and both layers keep their outlines (`measure_nudge_tests`). Numbers unchanged | +| 2026-10-20 | minor | Release fonts (#885): the web release embeds craft-fonts again (Inter), so the browser version draws text and lists fonts; it had none since releases moved to the shared workflow. The craft-fonts pin goes back to the commit with the Latin manifest (the Georgian change had moved it to one without Inter, which would have failed the next release). Numbers unchanged. | | 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged | | 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. | | 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. | diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 65b19ee46..462155998 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -29,9 +29,20 @@ fn image_format(e: &str) -> Option { } } +/// Writes through [`figcraft_engine::file_access`] when automation roots are in force, so a +/// confined run can only write inside a write root (#833). +/// +/// Unconfined it writes with `std::fs::write`, as it always did. `file_access::write` stages the +/// bytes and renames them into place, which **replaces** a symbolic link at `path` instead of +/// writing through it to its target -- a change of behaviour for anyone whose output path is a +/// link, and nothing to do with confinement. Inside a root the staged write is the right one: +/// `Roots::check` has already refused a link that leads out of the root. fn write(path: &str, bytes: &[u8]) -> CliResult<()> { if let Some(dir) = Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) { - std::fs::create_dir_all(dir).map_err(|e| CliError::Failed(format!("{}: {e}", dir.display())))?; + figcraft_engine::file_access::create_dir_all(dir).map_err(|e| CliError::Failed(e.to_string()))?; + } + if figcraft_engine::file_access::current().is_some() { + return figcraft_engine::file_access::write(path, bytes).map_err(|e| CliError::Failed(e.to_string())); } std::fs::write(path, bytes).map_err(|e| CliError::Failed(format!("{path}: {e}"))) } @@ -244,7 +255,7 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult { println!("{out}"); return Ok(ExitCode::SUCCESS); } - std::fs::create_dir_all(out).map_err(|e| CliError::Failed(format!("{out}: {e}")))?; + figcraft_engine::file_access::create_dir_all(out).map_err(|e| CliError::Failed(e.to_string()))?; let ids = match target { Some(t) => vec![t], None => { @@ -329,7 +340,8 @@ pub fn parse_script(text: &str) -> Result, String> { } pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliResult { - let text = std::fs::read_to_string(script).map_err(|e| CliError::Failed(format!("{script}: {e}")))?; + let bytes = figcraft_engine::file_access::read(script).map_err(|e| CliError::Failed(e.to_string()))?; + let text = String::from_utf8(bytes).map_err(|_| CliError::Failed(format!("{script}: not UTF-8")))?; let steps = parse_script(&text).map_err(|e| CliError::Failed(format!("{script}: {e}")))?; let mut s = match input { Some(path) => { diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs index 281d79ddc..badc5bc30 100644 --- a/apps/figcraft-cli/src/main.rs +++ b/apps/figcraft-cli/src/main.rs @@ -22,6 +22,7 @@ USAGE: figcraft-cli convert figcraft-cli render -o [--scale N] [--page NAME] figcraft-cli run [--in ] [--out ] + (info, export, convert, render and run also take --automation-read-root DIR / --automation-write-root DIR) figcraft-cli commands [--json] [--filter TEXT] figcraft-cli mcp [--attach PORT] [--automation-read-root DIR]... [--automation-write-root DIR]... figcraft-cli plugin publish --server ws://host:port/[?key=KEY] @@ -42,12 +43,33 @@ crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines) `plugin publish` sends a plugin folder (figcraft-plugin.json, its module, an optional assets/ folder) to a figcraft-server's plugin directory as a new version; the server's key comes from the address or the FIGCRAFT_SERVER_KEY environment variable (which keeps it out of the shell history). -`mcp --automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders -must exist) confine the files the agent's commands touch: reads only inside a read or write root, -writes only inside a write root, everything else refused. A running app confines itself: start it -with `figcraft --control PORT` and the same flags (`--attach` refuses them). +`--automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders +must exist) confine the files a subcommand touches: reads only inside a read or write root, writes +only inside a write root, everything else refused -- so giving no write root denies writing +altogether. They are taken by `mcp`, to confine the agent's commands, and by `info`, `export`, +`convert`, `render` and `run`, to confine that one run; without them a subcommand reads and writes +as the user who started it, as before. A running app confines itself: start it with +`figcraft --control PORT` and the same flags (`--attach` refuses them). See docs/cli.md and docs/mcp.md."; +/// The automation roots a file subcommand was given, if any. Omitting both leaves the run +/// unconfined, as before; giving either confines every file the subcommand touches (#833). +const ROOT_FLAGS: [&str; 2] = ["automation-read-root", "automation-write-root"]; + +fn roots_of(a: &Args) -> CliResult> { + let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root")); + if read.is_empty() && write.is_empty() { + return Ok(None); + } + Ok(Some(figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?)) +} + +/// Runs `f` confined to the roots `a` carries. +fn confined(a: &Args, f: impl FnOnce() -> CliResult) -> CliResult { + let roots = roots_of(a)?.map(std::sync::Arc::new); + figcraft_engine::file_access::confined(roots.as_ref(), f) +} + fn main() -> ExitCode { let argv: Vec = std::env::args().skip(1).collect(); match run(&argv) { @@ -98,35 +120,38 @@ fn run(argv: &[String]) -> CliResult { Ok(ExitCode::SUCCESS) } "info" => { - let a = Args::parse(rest, &[], &["json"])?; + let a = Args::parse(rest, &ROOT_FLAGS, &["json"])?; let file = a.one_positional("info ")?; - files::info(&file, a.flag("json")) + confined(&a, || files::info(&file, a.flag("json"))) } "export" => { - let a = Args::parse(rest, &["format", "scale", "page", "node", "o", "out", "bleed"], &["crop-marks", "bleed-marks", "cmyk"])?; + let valued = [&["format", "scale", "page", "node", "o", "out", "bleed"][..], &ROOT_FLAGS[..]].concat(); + let a = Args::parse(rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?; let file = a.one_positional("export -o ")?; let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("export needs -o ".into()))?; let target = files::Target { format: a.value("format"), scale: a.scale()?, page: a.value("page"), node: a.value("node"), print: a.print()? }; - files::export(&file, &out, &target) + confined(&a, || files::export(&file, &out, &target)) } "convert" => { - let a = Args::parse(rest, &[], &[])?; + let a = Args::parse(rest, &ROOT_FLAGS, &[])?; match a.positional.as_slice() { - [i, o] => files::convert(i, o), + [i, o] => confined(&a, || files::convert(i, o)), _ => Err(CliError::Usage("convert ".into())), } } "render" => { - let a = Args::parse(rest, &["o", "out", "scale", "page"], &[])?; + let valued = [&["o", "out", "scale", "page"][..], &ROOT_FLAGS[..]].concat(); + let a = Args::parse(rest, &valued, &[])?; let file = a.one_positional("render -o ")?; let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("render needs -o ".into()))?; - files::render(&file, &out, a.scale()?, a.value("page").as_deref()) + confined(&a, || files::render(&file, &out, a.scale()?, a.value("page").as_deref())) } "run" => { - let a = Args::parse(rest, &["in", "out"], &[])?; + let valued = [&["in", "out"][..], &ROOT_FLAGS[..]].concat(); + let a = Args::parse(rest, &valued, &[])?; let script = a.one_positional("run ")?; - files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref()) + confined(&a, || files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref())) } "commands" => { let a = Args::parse(rest, &["filter"], &["json"])?; @@ -137,23 +162,20 @@ fn run(argv: &[String]) -> CliResult { Ok(ExitCode::SUCCESS) } "mcp" => { - let a = Args::parse(rest, &["attach", "automation-read-root", "automation-write-root"], &[])?; + let valued = [&["attach"][..], &ROOT_FLAGS[..]].concat(); + let a = Args::parse(rest, &valued, &[])?; if !a.positional.is_empty() { return Err(CliError::Usage("mcp takes no positional arguments".into())); } - let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root")); - let roots = match (read.is_empty() && write.is_empty(), a.value("attach")) { - (true, _) => None, - // The app runs the commands: only its own roots can confine them. - (false, Some(_)) => { - return Err(CliError::Usage( - "--attach can't take automation roots: start the app with them instead (`figcraft --control PORT --automation-read-root DIR ...`)" - .into(), - )); - } - (false, None) => Some(figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?), - }; - mcp(a.value("attach").as_deref(), roots) + let attach = a.value("attach"); + // The app runs the commands: only its own roots can confine them. + if attach.is_some() && ROOT_FLAGS.iter().any(|f| !a.values(f).is_empty()) { + return Err(CliError::Usage( + "--attach can't take automation roots: start the app with them instead (`figcraft --control PORT --automation-read-root DIR ...`)" + .into(), + )); + } + mcp(attach.as_deref(), roots_of(&a)?) } "plugin" => { let a = Args::parse(rest, &["server"], &[])?; diff --git a/apps/figcraft-cli/tests/cli.rs b/apps/figcraft-cli/tests/cli.rs index 7eaf051f5..eb42b8d88 100644 --- a/apps/figcraft-cli/tests/cli.rs +++ b/apps/figcraft-cli/tests/cli.rs @@ -172,3 +172,98 @@ fn commands_json_lists_everything() { let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); assert!(v.as_array().unwrap().len() > 200); } + +/// The automation roots confine the file subcommands too, not just `mcp` (#833): a read outside a +/// root, and a write outside a write root, are refused and leave no file behind. Without the flags +/// the same commands behave as they always did. +#[test] +fn file_subcommands_are_confined_to_automation_roots() { + let d = tmp("file-roots"); + let _ = std::fs::remove_dir_all(&d); + for sub in ["in", "out"] { + std::fs::create_dir_all(d.join(sub)).unwrap(); + } + let p = |rel: &str| d.join(rel).to_string_lossy().into_owned(); + let out_of = |o: &std::process::Output| String::from_utf8_lossy(&o.stderr).into_owned(); + + // A document to work on, made unconfined. + std::fs::write(d.join("in/make.json"), br#"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":40,"height":20}}]"#).unwrap(); + let made = bin().args(["run", &p("in/make.json"), "--out", &p("in/doc.figcraft")]).output().unwrap(); + assert!(made.status.success(), "{}", out_of(&made)); + + // Inside the roots: renders as usual. + let ok = bin() + .args(["render", &p("in/doc.figcraft"), "-o", &p("out/ok.png"), "--automation-read-root", &p("in"), "--automation-write-root", &p("out")]) + .output() + .unwrap(); + assert!(ok.status.success(), "{}", out_of(&ok)); + assert!(d.join("out/ok.png").is_file()); + + // A write outside the write root is refused, and nothing is written. + let escaped = d.join("escaped.png"); + let refused = bin() + .args([ + "render", + &p("in/doc.figcraft"), + "-o", + &escaped.to_string_lossy(), + "--automation-read-root", + &p("in"), + "--automation-write-root", + &p("out"), + ]) + .output() + .unwrap(); + assert!(!refused.status.success()); + assert!(out_of(&refused).contains("may be written"), "{}", out_of(&refused)); + assert!(!escaped.exists(), "the refused render wrote a file anyway"); + + // No write root at all: writing is refused outright, reading still works. + let no_write = bin().args(["render", &p("in/doc.figcraft"), "-o", &p("out/no.png"), "--automation-read-root", &p("in")]).output().unwrap(); + assert!(!no_write.status.success()); + assert!(out_of(&no_write).contains("no folder may be written"), "{}", out_of(&no_write)); + + // A read outside every root is refused: `info` on the document with only `out` readable. + let read_refused = bin().args(["info", &p("in/doc.figcraft"), "--automation-read-root", &p("out")]).output().unwrap(); + assert!(!read_refused.status.success()); + assert!(out_of(&read_refused).contains("may be read"), "{}", out_of(&read_refused)); + + // `convert` and `run` take them as well, and a root that is not a folder is refused. + let missing = bin().args(["convert", &p("in/doc.figcraft"), &p("out/c.svg"), "--automation-read-root", &p("missing")]).output().unwrap(); + assert!(!missing.status.success()); + assert!(out_of(&missing).contains("automation-read-root"), "{}", out_of(&missing)); + + // Unconfined, the same render still writes wherever it is told. + let plain = bin().args(["render", &p("in/doc.figcraft"), "-o", &escaped.to_string_lossy()]).output().unwrap(); + assert!(plain.status.success(), "{}", out_of(&plain)); + assert!(escaped.is_file()); + let _ = std::fs::remove_dir_all(&d); +} + +/// An output path that is a symbolic link is written through, to its target, as `std::fs::write` +/// always did. Routing the CLI's writes through `file_access` for #833 would otherwise have +/// replaced the link with a regular file, because that write stages the bytes and renames them +/// into place. +#[cfg(unix)] +#[test] +fn an_unconfined_write_follows_a_symbolic_link() { + let d = tmp("symlink-out"); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(d.join("real")).unwrap(); + let p = |rel: &str| d.join(rel).to_string_lossy().into_owned(); + std::fs::write(d.join("in.json"), br#"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":40,"height":20}}]"#).unwrap(); + let made = bin().args(["run", &p("in.json"), "--out", &p("doc.figcraft")]).output().unwrap(); + assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr)); + + let target = d.join("real/target.png"); + std::fs::write(&target, b"placeholder").unwrap(); + let link = d.join("link.png"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + + let out = bin().args(["render", &p("doc.figcraft"), "-o", &link.to_string_lossy()]).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), "the link is still a link"); + let written = std::fs::read(&target).unwrap(); + assert!(written.starts_with(b"\x89PNG"), "the target holds the render, not the placeholder"); + let _ = std::fs::remove_dir_all(&d); +} diff --git a/apps/figcraft-web/Cargo.toml b/apps/figcraft-web/Cargo.toml index 2f1a8190f..ffd44f3bc 100644 --- a/apps/figcraft-web/Cargo.toml +++ b/apps/figcraft-web/Cargo.toml @@ -26,4 +26,4 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] } wasm-bindgen = "0.2" js-sys = "0.3" wasm-bindgen-futures = "0.4" -web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] } +web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] } diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index 292136212..f08ca90f2 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -1,7 +1,7 @@ //! The browser shell: web `Services` and the eframe web runner. use figcraft_engine::Session; -use figcraft_ui_egui::{FigApp, Services}; +use figcraft_ui_egui::{ContentsFuture, FigApp, Services}; use wasm_bindgen::JsCast as _; const CANVAS_ID: &str = "figcraft_canvas"; @@ -75,9 +75,10 @@ impl eframe::App for WebShell { } } -/// Browser services. Open dialogs return nothing for now (the trait's defaults); files are saved -/// as downloads. Copies go to the system clipboard through egui's output; pastes arrive as paste -/// events with their text (a page can't read the clipboard when it likes). +/// Browser services. Files are opened and imported through the browser's file picker, which hands +/// over their contents (pickers that need a path return nothing, the trait's defaults); files are +/// saved as downloads. Copies go to the system clipboard through egui's output; pastes arrive as +/// paste events with their text (a page can't read the clipboard when it likes). struct WebServices { /// `prefers-reduced-motion: reduce` (it follows the setting while the page is open). reduce_motion: Option, @@ -85,6 +86,16 @@ struct WebServices { } impl Services for WebServices { + /// An `` taking `exts`, clicked; its file is read once picked. + fn pick_open_contents(&mut self, _title: &str, exts: &[&str]) -> Option { + let accept = exts.iter().map(|e| format!(".{e}")).collect::>().join(","); + Some(Box::pin(async move { + pick_file(&accept).await.unwrap_or_else(|e| { + log::error!("couldn't open a file: {e:?}"); + None + }) + })) + } fn downloads(&self) -> bool { true } @@ -136,6 +147,90 @@ impl Services for WebServices { } } +/// The file picked in the browser's file picker (`accept`: the extensions it offers), with its +/// name; `None` when the picker is cancelled, `Some(Err)` with what to tell the user when the file +/// can't be read. +async fn pick_file(accept: &str) -> Result), String>>, wasm_bindgen::JsValue> { + let window = web_sys::window().ok_or("no window")?; + let document = window.document().ok_or("no document")?; + let body = document.body().ok_or("no body")?; + let input: web_sys::HtmlInputElement = document.create_element("input")?.dyn_into()?; + input.set_type("file"); + input.set_accept(accept); + input.set_hidden(true); + // Some browsers (older Safari) open the picker only for an input in the page. + body.append_child(&input)?; + let (closed, picker) = PickerClosed::new(&window, input.clone()); + input.click(); + let result = wasm_bindgen_futures::JsFuture::from(closed).await; + let file = input.files().and_then(|files| files.get(0)); + drop(picker); + result?; + let Some(file) = file else { return Ok(None) }; + let name = file.name(); + if let Err(why) = figcraft_ui_egui::browser_can_read(&name, file.size()) { + return Ok(Some(Err(why))); + } + Ok(Some(match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await { + Ok(buffer) => Ok((name, js_sys::Uint8Array::new(&buffer).to_vec())), + Err(e) => { + log::error!("couldn't read {name}: {e:?}"); + Err(figcraft_ui_egui::unreadable(&name)) + } + })) +} + +/// How long after the page gets focus back the file picker counts as closed (milliseconds): a +/// picked file's `change` can come just after the focus, and must win. A `change` later than this +/// (a slow cloud-backed file in a browser that never sends `cancel`) counts as a cancel. +const PICKER_FOCUS_SETTLE_MS: i32 = 1_000; + +/// Settles a file picker's promise once: on `change` (a file was picked), on `cancel` (closed +/// without one) or shortly after the page gets focus back, for browsers that never send `cancel` +/// (Safari before 16.4), where the pick would otherwise stay pending and block every later one. +/// Dropping it (also when the pick is abandoned) removes the input, the focus listener and its timer. +struct PickerClosed { + window: web_sys::Window, + input: web_sys::HtmlInputElement, + _settle: wasm_bindgen::closure::Closure, + on_focus: wasm_bindgen::closure::Closure, + timer: std::rc::Rc>>, +} + +impl PickerClosed { + fn new(window: &web_sys::Window, input: web_sys::HtmlInputElement) -> (js_sys::Promise, Self) { + let resolve = std::rc::Rc::new(std::cell::RefCell::new(None::)); + let slot = resolve.clone(); + let closed = js_sys::Promise::new(&mut |res, _rej| *slot.borrow_mut() = Some(res)); + let settle = wasm_bindgen::closure::Closure::::new(move || { + if let Some(res) = resolve.borrow_mut().take() { + let _ = res.call0(&wasm_bindgen::JsValue::NULL); + } + }); + let _ = input.add_event_listener_with_callback("change", settle.as_ref().unchecked_ref()); + let _ = input.add_event_listener_with_callback("cancel", settle.as_ref().unchecked_ref()); + let timer = std::rc::Rc::new(std::cell::Cell::new(None)); + let (w, t, f) = (window.clone(), timer.clone(), settle.as_ref().unchecked_ref::().clone()); + let on_focus = wasm_bindgen::closure::Closure::::new(move || { + if t.get().is_none() { + t.set(w.set_timeout_with_callback_and_timeout_and_arguments_0(&f, PICKER_FOCUS_SETTLE_MS).ok()); + } + }); + let _ = window.add_event_listener_with_callback("focus", on_focus.as_ref().unchecked_ref()); + (closed, Self { window: window.clone(), input, _settle: settle, on_focus, timer }) + } +} + +impl Drop for PickerClosed { + fn drop(&mut self) { + if let Some(t) = self.timer.take() { + self.window.clear_timeout_with_handle(t); + } + let _ = self.window.remove_event_listener_with_callback("focus", self.on_focus.as_ref().unchecked_ref()); + self.input.remove(); + } +} + fn download(name: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> { let window = web_sys::window().ok_or("no window")?; let document = window.document().ok_or("no document")?; diff --git a/apps/figcraft/src/args.rs b/apps/figcraft/src/args.rs index 3b4136d99..436df61f8 100644 --- a/apps/figcraft/src/args.rs +++ b/apps/figcraft/src/args.rs @@ -2,12 +2,14 @@ //! [--automation-write-root

]… [files…] [figcraft://join?…]`. An invite link //! (what the system passes when a `figcraft://` link is opened) joins that session once the window //! is up; everything else that isn't a flag is a file to open, as a path or a `file://` URL (the -//! Linux desktop entry passes URLs, `%U`, so that it can also hand over invite links). +//! Linux desktop entry passes URLs, `%U`, so that it can also hand over invite links). Any other +//! `--flag` is unknown and stops the app at startup (#836): a mistyped root flag must never leave +//! the app running unconfined. After a bare `--`, every argument is a file. /// What the app was started with. #[derive(Debug, Default, PartialEq, Eq)] pub struct Args { - /// `--control `: the JSON-lines control server. + /// `--control ` (or `--control=`): the JSON-lines control server. pub control: Option, pub files: Vec, /// The last invite link given. @@ -17,6 +19,8 @@ pub struct Args { /// which is refused at startup. pub read_roots: Vec, pub write_roots: Vec, + /// `--flags` the app doesn't know, as given. + pub unknown: Vec, } /// An invite link rather than a file (`figcraft:` links, in any case). @@ -55,6 +59,10 @@ pub fn parse(args: &[String]) -> Args { let mut out = Args::default(); let mut it = args.iter(); while let Some(a) = it.next() { + if a == "--" { + out.files.extend(it.by_ref().cloned()); + break; + } let (flag, inline) = a.split_once('=').map_or((a.as_str(), None), |(f, v)| (f, Some(v.to_string()))); let roots = match flag { "--automation-read-root" => Some(&mut out.read_roots), @@ -63,13 +71,15 @@ pub fn parse(args: &[String]) -> Args { }; if let Some(roots) = roots { roots.push(inline.or_else(|| it.next().cloned()).unwrap_or_default()); - } else if a == "--control" { - out.control = it.next().and_then(|p| p.parse().ok()); + } else if flag == "--control" { + out.control = inline.or_else(|| it.next().cloned()).and_then(|p| p.parse().ok()); } else if is_invite(a) { out.join = Some(a.clone()); } else if let Some(path) = file_url_path(a) { out.files.push(path); - } else if !a.starts_with("--") { + } else if a.starts_with("--") { + out.unknown.push(a.clone()); + } else { out.files.push(a.clone()); } } diff --git a/apps/figcraft/src/args_tests.rs b/apps/figcraft/src/args_tests.rs index 2ae29b3c8..b274bedab 100644 --- a/apps/figcraft/src/args_tests.rs +++ b/apps/figcraft/src/args_tests.rs @@ -9,13 +9,21 @@ fn args(a: &[&str]) -> Vec { #[test] fn files_and_the_control_port() { assert_eq!(parse(&args(&[])), Args::default()); - let a = parse(&args(&["--control", "4242", "a.figcraft", "--verbose", "b.svg"])); + let a = parse(&args(&["--control", "4242", "a.figcraft", "b.svg"])); assert_eq!(a, Args { control: Some(4242), files: args(&["a.figcraft", "b.svg"]), ..Args::default() }); // A missing or bad port is ignored, not taken as a file. assert_eq!(parse(&args(&["--control"])), Args::default()); assert_eq!(parse(&args(&["--control", "nope"])).control, None); } +#[test] +fn the_control_port_accepts_the_equals_form() { + let a = parse(&args(&["--control=7980", "a.figcraft"])); + assert_eq!(a, Args { control: Some(7980), files: args(&["a.figcraft"]), ..Args::default() }); + // A bad port is ignored, not taken as a file. + assert_eq!(parse(&args(&["--control=nope"])), Args::default()); +} + #[test] fn automation_roots_repeat_and_are_never_files() { let a = parse(&args(&["--automation-read-root", "in", "--automation-write-root=out", "--automation-read-root=more", "a.figcraft"])); @@ -26,6 +34,19 @@ fn automation_roots_repeat_and_are_never_files() { assert_eq!(parse(&args(&["--automation-write-root"])).write_roots, args(&[""])); } +#[test] +fn unknown_flags_are_refused_never_ignored() { + // #836: a mistyped root flag (or one an older release lacks) must stop the app, not start it + // unconfined. Both forms, with and without `=`. + let a = parse(&args(&["--automation-write-roots", "out", "--automation-write-dir=out", "--verbose", "a.figcraft"])); + assert_eq!(a.unknown, args(&["--automation-write-roots", "--automation-write-dir=out", "--verbose"])); + assert!(a.write_roots.is_empty()); + // Single-dash arguments are left as they were (files); after `--` everything is a file. + let a = parse(&args(&["--control", "1", "--", "--odd.figcraft", "--automation-write-root=x"])); + assert_eq!(a.files, args(&["--odd.figcraft", "--automation-write-root=x"])); + assert!(a.unknown.is_empty() && a.write_roots.is_empty()); +} + #[test] fn invite_links_join_instead_of_opening() { let link = "figcraft://join?host=192.168.1.20:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv"; diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index fe89d0a75..00856c5d5 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -10,7 +10,8 @@ //! `--automation-read-root ` / `--automation-write-root ` (each repeatable) confine the //! files the window and the control channel read and write (`figcraft_engine::file_access`); the //! app's own folders (settings, recovery copies, installed plugins) and opening the files given on -//! the command line are not. A root that isn't an existing folder stops the app at startup. +//! the command line are not. A root that isn't an existing folder stops the app at startup, and so +//! does any flag the app doesn't know (a mistyped root flag must not start the app unconfined). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] @@ -107,10 +108,13 @@ impl eframe::App for App { } fn on_exit(&mut self) { - with_app(|fig| services::save_state(fig)); - if let Some(r) = recovery::lock(&self.recovery).take() { - r.finish(); - } + // With the app out of reach (busy), its recovery copies stay for the next start. + let copies = recovery::lock(&self.recovery).take(); + with_app(|fig| services::shutdown(fig, services::config_dir().as_deref(), copies)); + // Everything is kept: end before the windows' teardown, where AppKit's Touch Bar support + // throws on the content view AccessKit restores (craft-libs#3, #781). + #[cfg(target_os = "macos")] + std::process::exit(0); } } @@ -126,6 +130,12 @@ fn main() -> eframe::Result { return Ok(()); } let args = args::parse(&args); + if let Some(flag) = args.unknown.first() { + eprintln!( + "FigCraft: unknown option {flag}\nUsage: figcraft [--control PORT] [--automation-read-root DIR]... [--automation-write-root DIR]... [FILE | figcraft://join?...]..." + ); + std::process::exit(2); + } let roots = match ( args.read_roots.is_empty() && args.write_roots.is_empty(), figcraft_engine::file_access::Roots::new(&args.read_roots, &args.write_roots), @@ -183,10 +193,8 @@ fn main() -> eframe::Result { // default, never a build from source unless asked). session.live.links = figcraft_collab_net::links::system(); session.invite_links_startup(); - // A first run (no settings kept yet, nothing to open) starts on the home screen, with its - // tips and templates; later runs start in a new file. let first_run = svc.prefs.is_none() && args.join.is_none(); - if session.docs.is_empty() && !first_run { + if session.docs.is_empty() && opens_new_file(first_run, session.prefs.start_on_home, port.is_some()) { let _ = session.execute("file.new", &json!({})); } // The interface language from the first frame on, and for the message when the window can't @@ -258,6 +266,14 @@ fn main() -> eframe::Result { result } +/// With nothing to open, whether the window starts in a new file rather than on the home screen. +/// A first run (no settings kept yet) shows the home screen's tips and templates, and later runs +/// start in a new file unless the preference asks for Home, which `--control` overrides: its +/// callers expect a document to act on. +fn opens_new_file(first_run: bool, start_on_home: bool, control: bool) -> bool { + !first_run && (!start_on_home || control) +} + fn desktop_title_bar() -> figcraft_ui_egui::TitleBar { use figcraft_ui_egui::TitleBar; if cfg!(target_os = "macos") { TitleBar::Inset } else { TitleBar::Custom } @@ -282,3 +298,16 @@ mod linux_window_tests { assert_eq!(viewport.decorations, Some(false)); } } + +#[cfg(test)] +mod start_tests { + use super::opens_new_file; + + #[test] + fn the_home_preference_starts_on_home_except_for_control_callers() { + assert!(opens_new_file(false, false, false), "a new file by default"); + assert!(!opens_new_file(false, true, false), "Home when the preference asks"); + assert!(opens_new_file(false, true, true), "--control still starts with a document"); + assert!(!opens_new_file(true, false, false), "a first run shows the home screen"); + } +} diff --git a/apps/figcraft/src/recovery.rs b/apps/figcraft/src/recovery.rs index b2614598f..06b0cbeb7 100644 --- a/apps/figcraft/src/recovery.rs +++ b/apps/figcraft/src/recovery.rs @@ -120,8 +120,7 @@ impl Recovery { (0..10).find_map(|n| { let dir = root.join(if n == 0 { pid.to_string() } else { format!("{pid}-{n}") }); std::fs::create_dir_all(&dir).ok()?; - let lock = File::options().write(true).create(true).truncate(false).open(dir.join(LOCK)).ok()?; - lock.try_lock().ok()?; + let lock = lock_folder(&dir).ok()?; Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0, waiting: Vec::new() }) }) } @@ -250,6 +249,17 @@ impl Recovery { self.written.insert(uid, Copy { done: None, flight: Some((vec![revision], writer)) }); } + /// Quitting: a normal quit ([`finish`](Self::finish)) when the unsaved-changes question was + /// `asked` or nothing is unsaved. Otherwise (macOS's Quit menu ends the app without asking) the + /// unsaved documents are copied and the copies stay for the next start, as after a crash. + pub fn quit(mut self, s: &Session, asked: bool) { + if asked || !s.docs.iter().any(|d| needs_copy(s, d)) { + return self.finish(); + } + self.write(s, true); + self.settle(s); + } + /// A normal quit: the documents were saved or their changes discarded on purpose. (Earlier /// runs' copies still waiting for their outcome stay for the next start.) pub fn finish(self) { @@ -282,6 +292,17 @@ fn remove_kept(w: Waiting) { /// The extension of the note beside the copy of a document that is one of your online files. const ONLINE: &str = "online"; +/// How long the startup waits, once, for folder locks that may free themselves (#334). +#[cfg(unix)] +const LOCK_WAIT: std::time::Duration = std::time::Duration::from_millis(100); + +/// Lock the folder `dir` (`WouldBlock` while another process holds it). +fn lock_folder(dir: &Path) -> Result { + let lock = File::options().write(true).create(true).truncate(false).open(dir.join(LOCK)).map_err(std::fs::TryLockError::Error)?; + lock.try_lock()?; + Ok(lock) +} + /// The copy at `file` is of one of your online files: join that file again as its owner, keeping /// the copy to compare with what arrives. The session, or `None` when the copy isn't one (or its /// file isn't recorded any more, or is open already): then it opens as any other copy. @@ -351,6 +372,7 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered { } } }; + let (mut free, mut held) = (Vec::new(), Vec::new()); for e in entries.flatten() { let path = e.path(); if !path.is_dir() { @@ -358,10 +380,22 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered { continue; } // Still running (another window of the app), or not ours to take. - let Ok(lock) = File::options().write(true).create(true).truncate(false).open(path.join(LOCK)) else { continue }; - if lock.try_lock().is_err() { - continue; + match lock_folder(&path) { + Ok(lock) => free.push((path, lock)), + Err(std::fs::TryLockError::WouldBlock) => held.push(path), + Err(_) => {} } + } + // A child between its `fork` and its `exec` shares the run's open descriptors, and a `flock` + // lasts while any of them is open, so an ended run's folder can look held for a moment. Wait + // once, however many folders are held, and try those again (#334). Windows doesn't pass the + // lock to child processes. + #[cfg(unix)] + if !held.is_empty() { + std::thread::sleep(LOCK_WAIT); + free.extend(held.into_iter().filter_map(|path| lock_folder(&path).ok().map(|lock| (path, lock)))); + } + for (path, lock) in free { let lock = Arc::new(lock); let files: Vec = std::fs::read_dir(&path).into_iter().flatten().flatten().map(|f| f.path()).collect(); for f in &files { diff --git a/apps/figcraft/src/recovery_tests.rs b/apps/figcraft/src/recovery_tests.rs index a3d608f9d..0322e2374 100644 --- a/apps/figcraft/src/recovery_tests.rs +++ b/apps/figcraft/src/recovery_tests.rs @@ -167,6 +167,23 @@ fn a_normal_quit_removes_the_recovered_copies_it_took_over() { let _ = std::fs::remove_dir_all(&root); } +#[test] +fn quitting_without_the_unsaved_question_keeps_copies_for_the_next_start() { + let root = root("quitunasked"); + // macOS's Quit menu: nothing asked, so the unsaved document comes back next time. + let s = edited(); + Recovery::start(&root).unwrap().quit(&s, false); + let mut next = Session::empty(); + assert_eq!(recover(&root, &mut next), 1); + let _ = std::fs::remove_dir_all(&root); + // Asked (saved or discarded on purpose), or nothing unsaved: nothing is left. + for (s, asked) in [(edited(), true), (Session::new(), false)] { + Recovery::start(&root).unwrap().quit(&s, asked); + assert!(std::fs::read_dir(&root).unwrap().next().is_none(), "asked: {asked}"); + } + let _ = std::fs::remove_dir_all(&root); +} + #[test] fn a_run_whose_folder_name_is_still_held_takes_another() { let root = root("pidreuse"); @@ -186,6 +203,29 @@ fn a_run_whose_folder_name_is_still_held_takes_another() { let _ = std::fs::remove_dir_all(&root); } +/// #334: a lock that frees itself is not a run that is still going. A child between its `fork` and +/// its `exec` inherits the run's open descriptors and a `flock` lasts while any descriptor for the +/// file is open, so a crashed run's copies must still come back once that child has exec'd. +#[cfg(unix)] +#[test] +fn a_lock_freed_a_moment_later_is_not_a_run_that_is_still_going() { + let root = root("fork-window"); + crashed(&edited(), &root); + // Hold the crashed run's folder lock on a second descriptor, the way a child forked before its + // exec holds it, and free it shortly after. + let lock = std::fs::File::options().write(true).create(true).truncate(false).open(root.join("crashed").join("lock")).unwrap(); + lock.try_lock().unwrap(); + let freed = std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(10)); + drop(lock); + }); + // The next start waits for that lock rather than reading the run as still going. + let mut next = Session::empty(); + assert_eq!(recover(&root, &mut next), 1, "the copies come back once the lock frees itself"); + freed.join().unwrap(); + let _ = std::fs::remove_dir_all(&root); +} + /// The rectangles in `doc`. fn rectangles(doc: &figcraft_engine::doc::Document) -> usize { doc.nodes().filter(|n| matches!(n.kind, figcraft_engine::doc::NodeKind::Rectangle)).count() diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index 6930f44d4..7ee41ada0 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -167,10 +167,18 @@ fn reveal(path: &Path) -> std::io::Result<()> { /// The preferences' file in the config folder. const STATE: &str = "state.json"; -pub fn save_state(app: &FigApp) { - if let Some(dir) = config_dir() { - write_prefs(&dir, &app.session.prefs); +/// Everything quitting keeps, all done when it returns (macOS then ends the process): the +/// preferences in `dir`, this run's recovery copies ([`Recovery::quit`](crate::recovery::Recovery::quit)) +/// and the live sessions' ends (one this app hosts tells everyone and stops; a joined one leaves). +pub fn shutdown(app: &mut FigApp, dir: Option<&Path>, recovery: Option) { + if let Some(dir) = dir { + write_prefs(dir, &app.session.prefs); } + // Before the sessions end: a recovered online file's copy waits for its session's outcome. + if let Some(r) = recovery { + r.quit(&app.session, app.ui.quit); + } + drop(std::mem::take(&mut app.session.live)); } /// Write `prefs` to `dir` atomically (a temporary file renamed over the old one: a crash leaves diff --git a/apps/figcraft/src/services_tests.rs b/apps/figcraft/src/services_tests.rs index 2390b92fc..2abec5bba 100644 --- a/apps/figcraft/src/services_tests.rs +++ b/apps/figcraft/src/services_tests.rs @@ -82,3 +82,38 @@ fn preferences_are_readable_by_their_owner_only() { assert_eq!(mode(&dir.join("state.json")), 0o600); let _ = std::fs::remove_dir_all(&dir); } + +/// A live link that only records the sessions it was told to leave. +struct Leaves(Arc>>); + +impl figcraft_engine::live::LiveLink for Leaves { + fn host(&mut self, _: u64, _: figcraft_engine::live::HostOptions) -> Result { + Err("not in this test".into()) + } + fn join(&mut self, _: u64, _: &str, _: &str) -> Result<(), String> { + Ok(()) + } + fn leave(&mut self, session: u64) { + self.0.lock().unwrap().push(session); + } + fn presence(&mut self, _: u64, _: &figcraft_engine::live::Presence) {} + fn send(&mut self, _: u64, _: figcraft_engine::figcraft_collab::message::ClientMsg) {} + fn poll(&mut self) -> Vec<(u64, figcraft_engine::live::Event)> { + Vec::new() + } +} + +#[test] +fn quitting_keeps_the_preferences_and_ends_the_live_sessions() { + let dir = root("shutdown"); + let mut fig = start(&dir); + let left = Arc::new(Mutex::new(Vec::new())); + fig.session.live.install(Box::new(Leaves(left.clone()))); + let joined = fig.run("collab.join", json!({"link": "ws://127.0.0.1:9/?file=f1&token=EditSecretEditSecret00"})).unwrap(); + fig.session.prefs.user_name = "Ada".into(); + crate::services::shutdown(&mut fig, Some(&dir), None); + assert_eq!(*left.lock().unwrap(), [joined["session"].as_u64().unwrap()], "the session was left"); + assert!(!fig.session.live.available(), "nothing is left to send on"); + assert_eq!(start(&dir).session.prefs.user_name, "Ada", "the preferences were kept"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/contributors/people.toml b/contributors/people.toml index 8b14373eb..b91728912 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -37,6 +37,11 @@ real_name = "Justin John" [people.Losminek] +[people.alrifatsabbir] +real_name = "Al Rifat Sabbir" +display_name = "Al Rifat Sabbir" +sync_github_name = true + [people.RevDonz] [people.Nael-Nathanael] @@ -56,3 +61,9 @@ sync_github_name = true [people.pechnikov2026-star] sync_github_name = true + +[people.MortenHusted] +sync_github_name = true + +[people.AganzeFelicite] +sync_github_name = true diff --git a/crates/collab-net/src/driver.rs b/crates/collab-net/src/driver.rs index b8f6c1541..4ebbec157 100644 --- a/crates/collab-net/src/driver.rs +++ b/crates/collab-net/src/driver.rs @@ -16,7 +16,7 @@ use figcraft_engine::live::{Event, Status}; use web_time::Instant; use crate::inbox::Inbox; -use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, unresolved}; +use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, too_large, unresolved}; use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest}; /// A WebSocket the driver opens, writes and closes; what it receives comes back through the @@ -114,6 +114,9 @@ impl Driver { /// A binary message from the host. pub(crate) fn message(&mut self, bytes: &[u8], now: Instant) { let Some(c) = self.conn.as_mut() else { return }; + if bytes.len() > self.cfg.limits.max_server_message { + return self.end(too_large(&self.cfg.limits), now); + } c.last_heard = now; let taken = match message::decode::(bytes, self.cfg.limits.max_server_message) { // The join code's answer: join the file it opens, on a new connection. diff --git a/crates/collab-net/src/driver_tests.rs b/crates/collab-net/src/driver_tests.rs index ca6563c5f..5e395b981 100644 --- a/crates/collab-net/src/driver_tests.rs +++ b/crates/collab-net/src/driver_tests.rs @@ -10,7 +10,7 @@ use std::time::{Duration, Instant}; use figcraft_collab::message::{self, ClientMsg, Presence, ServerMsg}; use figcraft_collab::{Client, Limits, Outgoing, Server}; use figcraft_doc::{Document, NodeId}; -use figcraft_engine::live::{Event, Status}; +use figcraft_engine::live::{Event, Status, TOO_LARGE_TO_JOIN}; use tungstenite::protocol::WebSocket; use tungstenite::{Bytes, Message}; @@ -262,6 +262,15 @@ fn refusals_and_the_end_of_the_session_are_final() { assert!(d.done()); assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { reason }) if reason.starts_with("The host refused"))); } + // A message over the size limit: the file is too large for this app, not refused by the host. + let inbox = Inbox::new(Arc::new(|| {})); + let mut cfg = GuestConfig::new(invite(), "Ada".into()); + cfg.limits.max_server_message = 2 << 20; + let mut d = Driver::start(Script::default(), cfg, inbox.clone(), t0); + d.opened(t0); + d.message(&vec![0; (2 << 20) + 1], t0); + assert!(d.done()); + assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { reason }) if *reason == TOO_LARGE_TO_JOIN.replace("{mb}", "2"))); } #[test] diff --git a/crates/collab-net/src/guest.rs b/crates/collab-net/src/guest.rs index eb899b8df..2e1d5046c 100644 --- a/crates/collab-net/src/guest.rs +++ b/crates/collab-net/src/guest.rs @@ -26,7 +26,7 @@ use tungstenite::{Bytes, Message}; use crate::inbox::Inbox; use crate::invite::{Endpoint, Invite}; pub use crate::join::GuestConfig; -use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, unresolved}; +use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, too_large, unresolved}; use crate::manage; use crate::net::{Duplex, NetLimits, config, open, shutdown}; use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest}; @@ -367,7 +367,7 @@ fn read_loop(mut ws: WebSocket, ctx: &ReaderCtx) { Ok(Message::Close(f)) => break f.map_or_else(|| closed(0, ""), |f| closed(f.code.into(), &f.reason)), Ok(Message::Text(_)) => break Outcome::Refused("the host sent a text message".into()), Ok(_) => {} - Err(tungstenite::Error::Capacity(_)) => break Outcome::Refused("the host sent a message over the size limit".into()), + Err(tungstenite::Error::Capacity(_)) => break too_large(&ctx.cfg.limits), Err(e) => break Outcome::Retry(e.to_string()), } }; diff --git a/crates/collab-net/src/invite.rs b/crates/collab-net/src/invite.rs index 617ad5187..e5d86bf29 100644 --- a/crates/collab-net/src/invite.rs +++ b/crates/collab-net/src/invite.rs @@ -220,7 +220,8 @@ fn valid_host(s: &str) -> bool { return true; } let Some((name, port)) = s.rsplit_once(':') else { return false }; - port.parse::().is_ok_and(|p| p != 0) + port.bytes().all(|b| b.is_ascii_digit()) + && port.parse::().is_ok_and(|p| p != 0) && (1..=253).contains(&name.len()) && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.') && !name.starts_with(['-', '.']) @@ -239,6 +240,9 @@ fn percent_decode(s: &str) -> Option { while let Some(&b) = bytes.get(i) { if b == b'%' { let hex = bytes.get(i + 1..i + 3)?; + if !hex.iter().all(u8::is_ascii_hexdigit) { + return None; + } out.push(u8::from_str_radix(std::str::from_utf8(hex).ok()?, 16).ok()?); i += 3; } else { diff --git a/crates/collab-net/src/invite_tests.rs b/crates/collab-net/src/invite_tests.rs index 02017517d..b7618dc28 100644 --- a/crates/collab-net/src/invite_tests.rs +++ b/crates/collab-net/src/invite_tests.rs @@ -123,3 +123,19 @@ proptest! { prop_assert_eq!(Invite::parse(&i.url()), Ok(i)); } } + +#[test] +fn plus_or_sign_inside_a_percent_escape_is_broken() { + assert!(Endpoint::parse("ws://example.org/a/?key=%41").is_ok()); + for bad in ["%+1", "%-1", "%1", "%+a"] { + assert!(Endpoint::parse(&format!("ws://example.org/a/?key={bad}")).is_err(), "{bad}"); + } +} + +#[test] +fn port_must_be_all_digits() { + assert!(Endpoint::parse("ws://localhost:80/").is_ok()); + for bad in ["ws://localhost:+80/", "ws://localhost:-80/", "ws://localhost:/"] { + assert!(Endpoint::parse(bad).is_err(), "accepted {bad}"); + } +} diff --git a/crates/collab-net/src/join.rs b/crates/collab-net/src/join.rs index 81cd2437c..cc3be839f 100644 --- a/crates/collab-net/src/join.rs +++ b/crates/collab-net/src/join.rs @@ -7,7 +7,7 @@ use std::time::Duration; use figcraft_collab::Client; use figcraft_collab::message::{ClientMsg, Peer, Resume, ServerMsg}; -use figcraft_engine::live::{Event, Role, SessionInfo, Status}; +use figcraft_engine::live::{Event, Role, SessionInfo, Status, TOO_LARGE_TO_JOIN}; use crate::inbox::Inbox; use crate::invite::Invite; @@ -95,6 +95,12 @@ pub(crate) fn unresolved(cfg: &GuestConfig, outcome: Outcome) -> Outcome { }) } +/// The host sent a message over this app's size limit (the file's welcome, usually): final, and +/// not the host's doing. +pub(crate) fn too_large(limits: &NetLimits) -> Outcome { + Outcome::Failed(TOO_LARGE_TO_JOIN.replace("{mb}", &(limits.max_server_message >> 20).to_string())) +} + /// The server's answer to the join code: the invite is whole now. pub(crate) fn resolved(cfg: &mut GuestConfig, file: String, token: String) -> Result<(), Outcome> { if !crate::invite::valid_id(&file, 1, 64) || !crate::invite::valid_id(&token, 16, 128) { diff --git a/crates/collab-net/src/loopback_tests.rs b/crates/collab-net/src/loopback_tests.rs index 32c86bd0c..a043d8c79 100644 --- a/crates/collab-net/src/loopback_tests.rs +++ b/crates/collab-net/src/loopback_tests.rs @@ -10,7 +10,7 @@ use std::time::{Duration, Instant}; use figcraft_collab::message::{self, ClientMsg, Hello, Presence, ServerMsg, Signal}; use figcraft_collab::{Client, Limits}; use figcraft_doc::{Document, NodeId}; -use figcraft_engine::live::{Event, PEOPLE, Status}; +use figcraft_engine::live::{Event, PEOPLE, Status, TOO_LARGE_TO_JOIN}; use tungstenite::protocol::WebSocket; use tungstenite::{Bytes, Message}; @@ -642,3 +642,31 @@ fn the_host_removes_someone_and_they_cant_come_back() { host.stop(); assert_eq!(host.connections(), 0); } + +#[test] +fn a_welcome_over_the_size_limit_says_the_file_is_too_large() { + let mut doc = Document::new("B".repeat(1 << 20)); + doc.repair(); + let probe = Probe::new(); + let cfg = HostConfig { + addr: SocketAddr::from(([127, 0, 0, 1], 0)), + any_port_if_taken: false, + file: FILE.into(), + token: TOKEN.into(), + doc, + name: "Hana".into(), + limits: fast_limits(), + core: Limits::default(), + }; + let mut host = Host::start(cfg, probe.inbox.clone()).unwrap(); + let invite = Invite::new(host.addr().to_string(), FILE, TOKEN); + + let mut guest_cfg = quick(GuestConfig::new(invite, "Ada".into())); + guest_cfg.limits = NetLimits { max_server_message: 1 << 20, ..fast_limits() }; + let mut gp = Probe::new(); + let _g = Guest::start(guest_cfg, gp.inbox.clone()).unwrap(); + let too_large = TOO_LARGE_TO_JOIN.replace("{mb}", "1"); + gp.wait("the file too large to join", |e| e.iter().any(|ev| matches!(ev, Event::Status(Status::Failed { reason }) if *reason == too_large))); + assert!(!welcomed(&gp.events)); + host.stop(); +} diff --git a/crates/collab/src/change.rs b/crates/collab/src/change.rs index 32a491843..0608ba67c 100644 --- a/crates/collab/src/change.rs +++ b/crates/collab/src/change.rs @@ -87,8 +87,9 @@ macro_rules! node_props { ($($variant:ident => $field:ident),* $(,)?) => { /// A node property: the tree position (`parent`, `position`) or one saved field of /// [`Node`]. List-valued fields (fills, text runs, vector networks…) are one property each, - /// replaced whole (three-way merges line fills, strokes and effects up item by item, see - /// [`merge`](crate::merge)). New properties go at the end (the binary form numbers them). + /// replaced whole (three-way merges line the lists whose items stand on their own, such as + /// fills, up item by item, see [`merge`](crate::merge)). New properties go at the end (the + /// binary form numbers them). #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum Prop { diff --git a/crates/collab/src/merge.rs b/crates/collab/src/merge.rs index bb18e661e..5132ce75f 100644 --- a/crates/collab/src/merge.rs +++ b/crates/collab/src/merge.rs @@ -4,12 +4,13 @@ //! //! A conflict is the same property (or document-level entry) changed on both sides to different //! values, or a layer deleted on one side while the other side edited it or something inside it -//! (including adding layers to it or moving layers into or out of it). Fills, strokes and effects -//! merge item by item: both sides changing different items of the list (or one adding items at its -//! end) is no conflict, only the same item changed both ways or the list shortened on a side -//! while the other changed it. Nothing that conflicts is -//! merged until it is resolved: each conflict is resolved for one side, and the other side's -//! changes to it are left out. Everything else from both sides is kept. +//! (including adding layers to it or moving layers into or out of it). The layer lists whose items +//! stand on their own (fills, strokes, effects, layout grids, export settings, guides, prototype +//! interactions, accessibility annotations and Dev Mode resources) merge item by item: both sides +//! changing different items of the list (or one adding items at its end) is no conflict, only the +//! same item changed both ways or the list shortened on a side while the other changed it. Nothing +//! that conflicts is merged until it is resolved: each conflict is resolved for one side, and the +//! other side's changes to it are left out. Everything else from both sides is kept. use std::collections::{BTreeMap, BTreeSet}; use std::fmt; @@ -159,8 +160,23 @@ fn by_key(changes: &[Change]) -> BTreeMap> { out } -/// List properties whose items merge one by one. -const PER_ITEM: &[Prop] = &[Prop::Fills, Prop::Strokes, Prop::Effects]; +/// List properties whose items merge one by one: the items stand on their own, so both sides +/// changing different ones (or one side adding at its end) is no conflict. Sets and keyed maps +/// (`bound`, `varModes`, `overrides`, `propRefs`, `pluginData`) are not here: their items line up +/// by key, not by position. +const PER_ITEM: &[Prop] = &[ + Prop::Fills, + Prop::Strokes, + Prop::Effects, + Prop::Grids, + Prop::Exports, + Prop::Guides, + Prop::Interactions, + Prop::Annotations, + // Not `CodeLinks`: there is one per language, and lining them up by position could leave two + // for one language (the second hidden), so they merge whole. + Prop::DevResources, +]; /// Both sides' writes `b` and `m` of list property `key` merged item by item against the base /// (see [`merge_items`]); `None` when `key` isn't such a list or the writes conflict. diff --git a/crates/collab/src/merge_list_tests.rs b/crates/collab/src/merge_list_tests.rs index 436e6b6f3..e92f6bbbf 100644 --- a/crates/collab/src/merge_list_tests.rs +++ b/crates/collab/src/merge_list_tests.rs @@ -1,8 +1,10 @@ -//! Fills, strokes and effects merge item by item: different items changed on each side merge, -//! the same item changed both ways conflicts. +//! The layer lists whose items stand on their own (fills, strokes, effects, layout grids, export +//! settings, guides, prototype interactions, accessibility annotations and Dev Mode resources) +//! merge item by item: different items changed on each side merge, the same item changed both ways +//! conflicts. use figcraft_color::{Color, Paint}; -use figcraft_doc::{Document, Effect, NodeId, NodeKind}; +use figcraft_doc::{Document, Effect, LayoutGrid, NodeId, NodeKind, proto}; use serde_json::{Value, json}; use crate::merge::{Key, Side, merge_items, plan}; @@ -100,19 +102,113 @@ fn the_same_item_changed_both_ways_conflicts() { } #[test] -fn other_lists_still_conflict_as_a_whole() { - // Docs silent: lists other than fills, strokes and effects (export settings here) still - // conflict whole when both sides change them. +fn the_other_item_lists_merge_one_by_one_too() { + // Layout grids, export settings, guides, accessibility annotations and Dev Mode resources are + // item lists like fills: both sides changing different items merges. let (mut base, _, _, r) = files(); let export = |suffix: &str| figcraft_doc::ExportSetting { suffix: suffix.into(), ..Default::default() }; - base.get_mut(r).unwrap().exports = vec![export("@1"), export("@2")]; + let guide = |vertical: bool, offset: f64| figcraft_doc::Guide { vertical, offset }; + let annot = |label: &str| figcraft_doc::Annotation { label: label.into(), properties: Vec::new(), category: None }; + let grid = |size: f64| LayoutGrid { size, ..Default::default() }; + let res = |url: &str| figcraft_doc::DevResource { url: url.into(), name: String::new() }; + let n = base.get_mut(r).unwrap(); + n.exports = vec![export("@1"), export("@2")]; + n.guides = vec![guide(true, 10.0), guide(false, 20.0)]; + n.annotations = vec![annot("a1"), annot("a2")]; + n.grids = vec![grid(10.0), grid(20.0)]; + n.dev_resources = vec![res("https://a.test"), res("https://b.test")]; let (mut branch, mut main) = (base.clone(), base.clone()); branch.set_client_id(base.highest_id_space() + 1); - branch.get_mut(r).unwrap().exports[0] = export("@b"); - main.get_mut(r).unwrap().exports[1] = export("@m"); + // The branch changes the first item of each list; main the second. + let b = branch.get_mut(r).unwrap(); + (b.exports[0].suffix, b.guides[0].offset, b.annotations[0].label, b.grids[0].size, b.dev_resources[0].url) = + ("@b".into(), 11.0, "b1".into(), 12.0, "https://branch.test".into()); + let m = main.get_mut(r).unwrap(); + (m.exports[1].suffix, m.guides[1].offset, m.annotations[1].label, m.grids[1].size, m.dev_resources[1].url) = + ("@m".into(), 22.0, "m2".into(), 24.0, "https://main.test".into()); + both_ways(&base, &branch, &main, |doc| { + let n = doc.node(r).unwrap(); + let suffixes: Vec<&str> = n.exports.iter().map(|e| e.suffix.as_str()).collect(); + assert_eq!(suffixes, ["@b", "@m"], "export settings"); + let offsets: Vec = n.guides.iter().map(|g| g.offset).collect(); + assert_eq!(offsets, [11.0, 22.0], "guides"); + let labels: Vec<&str> = n.annotations.iter().map(|a| a.label.as_str()).collect(); + assert_eq!(labels, ["b1", "m2"], "annotations"); + let sizes: Vec = n.grids.iter().map(|g| g.size).collect(); + assert_eq!(sizes, [12.0, 24.0], "layout grids"); + let urls: Vec<&str> = n.dev_resources.iter().map(|d| d.url.as_str()).collect(); + assert_eq!(urls, ["https://branch.test", "https://main.test"], "dev resources"); + }); +} + +#[test] +fn the_same_item_of_another_list_changed_both_ways_conflicts() { + let (mut base, _, _, r) = files(); + let link = |language: &str| figcraft_doc::CodeLink { language: language.into(), ..Default::default() }; + let n = base.get_mut(r).unwrap(); + n.exports = vec![ + figcraft_doc::ExportSetting { suffix: "@1".into(), ..Default::default() }, + figcraft_doc::ExportSetting { suffix: "@2".into(), ..Default::default() }, + ]; + n.code_links = vec![link("react"), link("vue")]; + let (mut branch, mut main) = (base.clone(), base.clone()); + branch.set_client_id(base.highest_id_space() + 1); + branch.get_mut(r).unwrap().exports[0].suffix = "@b".into(); + main.get_mut(r).unwrap().exports[0].suffix = "@m".into(); + // Code links are one per language, not an item list: different links changed still conflict. + branch.get_mut(r).unwrap().code_links[0].name = "B".into(); + main.get_mut(r).unwrap().code_links[1].name = "M".into(); let p = plan(&base, &branch, &main).unwrap(); - let keys: Vec = p.conflicts.iter().map(|c| c.key.to_string()).collect(); - assert_eq!(keys, [format!("{r}/exports")]); + let mut keys: Vec = p.conflicts.iter().map(|c| c.key.to_string()).collect(); + keys.sort(); + let mut want = [format!("{r}/codeLinks"), format!("{r}/exports")]; + want.sort(); + assert_eq!(keys, want); +} + +#[test] +fn interactions_merge_item_by_item() { + let ia = |duration_ms: u32| proto::Interaction { + trigger: Default::default(), + actions: vec![proto::Action::Back], + transition: Default::default(), + duration_ms, + easing: Default::default(), + delay_ms: 0, + }; + let durations = |doc: &Document, r: NodeId| doc.node(r).unwrap().interactions.iter().map(|i| i.duration_ms).collect::>(); + let (mut base, _, _, r) = files(); + base.get_mut(r).unwrap().interactions = vec![ia(100), ia(200)]; + let fork = || { + let (mut branch, main) = (base.clone(), base.clone()); + branch.set_client_id(base.highest_id_space() + 1); + (branch, main) + }; + // The branch changes the first interaction and adds one; main changes the second. + let (mut branch, mut main) = fork(); + branch.get_mut(r).unwrap().interactions[0].duration_ms = 150; + branch.get_mut(r).unwrap().interactions.push(ia(300)); + main.get_mut(r).unwrap().interactions[1].duration_ms = 250; + both_ways(&base, &branch, &main, |doc| assert_eq!(durations(doc, r), [150, 250, 300])); + // The branch deletes one while main leaves them alone. + let (mut branch, mut main) = fork(); + branch.get_mut(r).unwrap().interactions.remove(0); + main.get_mut(r).unwrap().fills[0] = red(); + both_ways(&base, &branch, &main, |doc| assert_eq!(durations(doc, r), [200])); + // The same interaction changed both ways, or one deleted while the other side changed the + // list, conflicts. + for delete in [false, true] { + let (mut branch, mut main) = fork(); + if delete { + branch.get_mut(r).unwrap().interactions.remove(0); + } else { + branch.get_mut(r).unwrap().interactions[1].duration_ms = 220; + } + main.get_mut(r).unwrap().interactions[1].duration_ms = 250; + let p = plan(&base, &branch, &main).unwrap(); + let keys: Vec = p.conflicts.iter().map(|c| c.key.to_string()).collect(); + assert_eq!(keys, [format!("{r}/interactions")], "delete: {delete}"); + } } #[test] diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index d6859a510..8e9f98d92 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -61,7 +61,7 @@ impl<'de> Deserialize<'de> for Color { } fn clamp01(v: f32) -> f32 { - if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 } + if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) } } impl Default for Color { diff --git a/crates/color/src/tests.rs b/crates/color/src/tests.rs index 68a838e05..4cf875bbf 100644 --- a/crates/color/src/tests.rs +++ b/crates/color/src/tests.rs @@ -80,7 +80,7 @@ fn blend_names() { #[test] fn nan_inputs_are_sanitized() { let c = Color::new(f32::NAN, 2.0, -1.0, f32::INFINITY); - assert_eq!(c.to_rgba8(), [0, 255, 0, 0]); + assert_eq!(c.to_rgba8(), [0, 255, 0, 255]); } #[test] @@ -106,3 +106,11 @@ fn crop_defaults_to_fill_and_roundtrips() { assert!((a - b).abs() < 1e-9); } } + +#[test] +fn infinite_channels_clamp() { + let c = Color::parse_css("rgb(1e40, 0, 0)").unwrap(); + assert_eq!(c.r, 1.0); + let c = Color::new(f32::INFINITY, f32::NEG_INFINITY, f32::NAN, f32::INFINITY); + assert_eq!((c.r, c.g, c.b, c.a), (1.0, 0.0, 0.0, 1.0)); +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 07700e8ce..d131b2f08 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -949,21 +949,39 @@ impl Document { self.styles.iter().find(|s| s.id == id || s.name == id) } - /// The effective variable modes for a node (walking up its ancestors). + /// The effective variable modes for a node: the ones it sets over [`Self::inherited_modes`]. pub fn modes_for(&self, id: NodeId) -> BTreeMap { - let mut out = BTreeMap::new(); - let mut chain = vec![id]; - chain.extend(self.ancestors(id)); - for c in chain.into_iter().rev() { - if let Some(n) = self.node(c) { - for (k, v) in &n.var_modes { - out.insert(k.clone(), v.clone()); - } - } + let mut out = self.inherited_modes(id); + if let Some(n) = self.node(id) { + out.extend(n.var_modes.iter().map(|(k, v)| (k.clone(), v.clone()))); } out } + /// The modes a node is on where it sets none (Auto): its ancestors', and for an instance the + /// ones its main component sets, which are closer than any container's. + pub fn inherited_modes(&self, id: NodeId) -> BTreeMap { + let mut out = BTreeMap::new(); + for c in self.ancestors(id).into_iter().rev() { + if let Some(n) = self.node(c) { + out.extend(self.main_modes(n).chain(&n.var_modes).map(|(k, v)| (k.clone(), v.clone()))); + } + } + if let Some(n) = self.node(id) { + out.extend(self.main_modes(n).map(|(k, v)| (k.clone(), v.clone()))); + } + out + } + + /// The modes an instance's main component sets (none for other layers). + fn main_modes<'a>(&'a self, n: &Node) -> impl Iterator { + let main = match n.kind { + NodeKind::Instance { component, .. } => self.node(component), + _ => None, + }; + main.into_iter().flat_map(|m| &m.var_modes) + } + /// Check structural invariants (used by tests and after loading untrusted files). pub fn validate(&self) -> Vec { let mut issues = Vec::new(); diff --git a/crates/engine/src/a11y_keys_tests.rs b/crates/engine/src/a11y_keys_tests.rs index 6687886cd..e4892bcae 100644 --- a/crates/engine/src/a11y_keys_tests.rs +++ b/crates/engine/src/a11y_keys_tests.rs @@ -55,3 +55,51 @@ fn reduce_motion_is_a_preference_that_persists() { let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap(); assert!(!old.reduce_motion); } + +#[test] +fn interface_size_is_relative_to_the_system_and_persists() { + let mut s = Session::new(); + assert_eq!(s.prefs.ui_scale, 1.0, "the system's own size by default"); + assert_eq!(s.execute("pref.uiScale", &json!({})).unwrap()["scale"], 1.0); + assert_eq!(s.execute("pref.uiScale", &json!({"scale": 1.25})).unwrap()["scale"], 1.25); + assert_eq!(s.prefs.ui_scale, 1.25); + // Reading changes nothing; "auto" (older scripts) is the system's own size. + assert_eq!(s.execute("pref.uiScale", &json!({})).unwrap()["scale"], 1.25); + assert_eq!(s.execute("pref.uiScale", &json!({"scale": "Auto"})).unwrap()["scale"], 1.0); + // Nothing to undo: the document didn't change. + assert!(!s.ds().map(|d| d.is_dirty()).unwrap_or(false)); + // Saved with the other preferences and read back. + s.prefs.ui_scale = 0.9; + let v = serde_json::to_value(&s.prefs).unwrap(); + assert_eq!(v["ui_scale"], 0.9); + let back: crate::Prefs = serde_json::from_value(v).unwrap(); + assert_eq!(back.ui_scale, 0.9); +} + +#[test] +fn interface_size_stops_at_the_ends_and_junk_never_changes_it() { + let mut s = Session::new(); + assert_eq!(s.execute("pref.uiScale", &json!({"scale": 0.01})).unwrap()["scale"], crate::UI_SCALE_MIN); + assert_eq!(s.execute("pref.uiScale", &json!({"scale": -4})).unwrap()["scale"], crate::UI_SCALE_MIN); + assert_eq!(s.execute("pref.uiScale", &json!({"scale": 1e308})).unwrap()["scale"], crate::UI_SCALE_MAX); + // The size is a factor (1.25), not a percentage: 125 is far past the end and stops there. + assert_eq!(s.execute("pref.uiScale", &json!({"scale": 125})).unwrap()["scale"], crate::UI_SCALE_MAX); + s.prefs.ui_scale = 1.5; + for junk in [json!({"scale": "NaN"}), json!({"scale": "big"}), json!({"scale": [1]}), json!({"scale": {"x": 1}})] { + assert!(s.execute("pref.uiScale", &junk).is_err(), "{junk}"); + assert_eq!(s.prefs.ui_scale, 1.5, "{junk}"); + } +} + +#[test] +fn a_settings_file_without_or_with_a_wrong_interface_size_reads_as_the_systems_size_and_keeps_the_rest() { + let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap(); + assert_eq!(old.ui_scale, 1.0); + for bad in [json!("big"), json!(null), json!([2]), json!(true)] { + let p: crate::Prefs = serde_json::from_value(json!({"theme": "dark", "ui_scale": bad})).unwrap(); + assert_eq!((p.ui_scale, p.theme.as_str()), (1.0, "dark"), "{bad}"); + } + // A number out of range is clamped rather than refused. + let p: crate::Prefs = serde_json::from_value(json!({"ui_scale": 99, "theme": "dark"})).unwrap(); + assert_eq!((p.ui_scale, p.theme.as_str()), (crate::UI_SCALE_MAX, "dark")); +} diff --git a/crates/engine/src/binding_lists_tests.rs b/crates/engine/src/binding_lists_tests.rs index 493c8f60d..9db9975a9 100644 --- a/crates/engine/src/binding_lists_tests.rs +++ b/crates/engine/src/binding_lists_tests.rs @@ -46,7 +46,7 @@ fn a_bound_fill_colour_keeps_the_other_fills() { let fills = s.doc().unwrap().node(r).unwrap().fills.clone(); assert_eq!(fills.len(), 2, "the second fill stays"); assert_eq!(fills[0].kind, PaintKind::Solid { color: Color::from_rgb8_const(0xFF, 0, 0) }); - assert_eq!(fills[0].opacity, 0.5, "the bound paint keeps its opacity"); + assert_eq!(fills[0].opacity, 1.0, "the variable brings the whole colour, alpha included"); assert_eq!(fills[1].kind, PaintKind::Solid { color: Color::from_rgb8_const(0, 0xFF, 0) }); // Removing the bound (first) fill takes the binding with it. ex(&mut s, "fill.remove", json!({"ids": [r], "index": 0})); @@ -97,3 +97,77 @@ fn effect_moves_and_removals_reject_junk() { ex(&mut s, "effect.remove", json!({"ids": [r], "index": 7})); assert_eq!(s.doc().unwrap().node(r).unwrap().effects.len(), 1); } + +fn fill_hex(s: &Session, r: NodeId) -> Vec { + s.doc().unwrap().node(r).unwrap().fills.iter().filter_map(|p| p.solid_color()).map(|c| c.to_hex()).collect() +} + +fn brand_id(s: &Session) -> String { + s.doc().unwrap().variables.get("brand").unwrap().id.clone() +} + +#[test] +fn explicit_paint_edits_detach_the_colour_binding() { + // An edit that reported success keeps its colour: the binding it overwrote lets go (as + // selection colours already do), instead of putting the variable's colour back. + let edits = [ + ("fill.set", json!({"color": "#00FF00"})), + ("node.set", json!({"props": {"fill": "#00FF00"}})), + ("node.set", json!({"props": {"fills": ["#00FF00"]}})), + ]; + for (cmd, mut p) in edits { + let (mut s, r) = setup(); + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"})); + p["ids"] = json!([r]); + ex(&mut s, cmd, p.clone()); + assert_eq!(fill_hex(&s, r), ["00FF00"], "{cmd} {p}"); + assert!(bound(&s, r).is_empty(), "{cmd} {p}"); + ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#123456"})); + assert_eq!(fill_hex(&s, r), ["00FF00"], "{cmd} {p}: no longer follows the variable"); + } + for (cmd, mut p) in [("stroke.set", json!({"color": "#00FF00"})), ("node.set", json!({"props": {"stroke": "#00FF00"}}))] { + let (mut s, r) = setup(); + ex(&mut s, "node.set", json!({"ids": [r], "props": {"strokes": ["#000000"]}})); + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "stroke", "variable": "brand"})); + p["ids"] = json!([r]); + ex(&mut s, cmd, p); + let n = s.doc().unwrap().node(r).unwrap(); + assert_eq!(n.strokes[0].solid_color().map(|c| c.to_hex()).as_deref(), Some("00FF00"), "{cmd}"); + assert!(n.bound.is_empty(), "{cmd}"); + } +} + +#[test] +fn clearing_bound_fills_adds_no_paint_back() { + let (mut s, r) = setup(); + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"})); + ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": []}})); + assert!(s.doc().unwrap().node(r).unwrap().fills.is_empty()); + assert!(bound(&s, r).is_empty()); + ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#123456"})); + assert!(s.doc().unwrap().node(r).unwrap().fills.is_empty(), "the variable doesn't bring the fill back"); +} + +#[test] +fn paint_opacity_edits_keep_the_colour_binding() { + let (mut s, r) = setup(); + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"})); + ex(&mut s, "fill.set", json!({"ids": [r], "opacity": 0.5})); + ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#0000FF"})); + assert_eq!(fill_hex(&s, r), ["0000FF"], "the colour still follows the variable"); + assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].opacity, 0.5); + // The same colour with another opacity, through node.set, keeps it too. + ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": [{"type": "solid", "color": "#0000FF", "opacity": 0.25}]}})); + assert_eq!(bound(&s, r).len(), 1); + assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].opacity, 0.25); +} + +#[test] +fn paints_set_with_their_binding_in_the_same_call_keep_it() { + let (mut s, r) = setup(); + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"})); + let brand = brand_id(&s); + ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": ["#00FF00"], "boundVariables": {"fill": brand}}})); + assert_eq!(bound(&s, r), [("fill".to_string(), brand)]); + assert_eq!(fill_hex(&s, r), ["FF0000"], "the binding given with the paints applies"); +} diff --git a/crates/engine/src/booleans.rs b/crates/engine/src/booleans.rs index 87d901533..c3de51cbe 100644 --- a/crates/engine/src/booleans.rs +++ b/crates/engine/src/booleans.rs @@ -124,7 +124,7 @@ pub(crate) fn apply(s: &mut Session, cmd: &str, p: &Value, op: BoolOp) -> Result } return Ok(json!({"ids": ids, "operation": op})); } - let out = crate::cmd::structure::wrap_boolean(s, p, op)?; + let out = crate::cmd::structure::wrap_boolean(s, p, cmd, op)?; // Operands of a new group follow its operation. if let Some(g) = crate::params::id_of(&out["id"]) { let doc = s.doc_mut()?; diff --git a/crates/engine/src/bound_alpha_tests.rs b/crates/engine/src/bound_alpha_tests.rs new file mode 100644 index 000000000..b7210f0f7 --- /dev/null +++ b/crates/engine/src/bound_alpha_tests.rs @@ -0,0 +1,131 @@ +//! A colour variable's alpha applies once to the paint it is bound to (#802): a `#RRGGBBAA` fill keeps +//! its alpha in the paint opacity, and binding a variable with alpha hands the variable the whole +//! colour, so the two alphas don't multiply (canvas, SVG, `.fig` round trips, mode switches). + +use figcraft_doc::NodeId; +use serde_json::json; + +use crate::Session; + +/// 0x80 / 255 and 0x40 / 255. +const HALF: f32 = 128.0 / 255.0; +const QUARTER: f32 = 64.0 / 255.0; + +fn ex(s: &mut Session, cmd: &str, p: serde_json::Value) { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")); +} + +/// A rectangle filled `#0D6EFD80` and a colour variable `tint` (Light `light`, Dark `dark`). +fn setup(light: &str, dark: &str) -> (Session, NodeId) { + let mut s = Session::new(); + let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10, "props": {"fill": "#0D6EFD80"}})).unwrap(); + let r = NodeId(r["id"].as_u64().unwrap()); + ex(&mut s, "variable.createCollection", json!({"name": "Tokens", "modes": ["Light", "Dark"]})); + ex(&mut s, "variable.create", json!({"collection": "Tokens", "name": "tint", "type": "color", "value": light})); + ex(&mut s, "variable.set", json!({"variable": "tint", "mode": "Dark", "value": dark})); + (s, r) +} + +/// The fill's effective alpha (colour alpha times paint opacity). +fn alpha(s: &Session, r: NodeId) -> f32 { + s.doc().unwrap().node(r).unwrap().fills[0].solid_color().unwrap().a +} + +/// The alpha byte of the rectangle rendered on its own (transparent backdrop). +fn pixel_alpha(s: &Session, r: NodeId) -> u8 { + let pix = figcraft_render::render_node(s.doc().unwrap(), r, 1.0).unwrap(); + let i = ((5 * pix.width() + 5) * 4 + 3) as usize; + pix.data()[i] +} + +fn near(a: f32, b: f32) -> bool { + (a - b).abs() < 2e-3 +} + +#[test] +fn a_hex_with_alpha_keeps_its_alpha_in_the_opacity() { + let (s, r) = setup("#0D6EFD80", "#0D6EFD40"); + let p = &s.doc().unwrap().node(r).unwrap().fills[0]; + assert_eq!(p.kind, figcraft_color::PaintKind::Solid { color: figcraft_color::Color::from_rgb8_const(0x0D, 0x6E, 0xFD) }); + assert!(near(p.opacity, HALF)); + assert_eq!(pixel_alpha(&s, r), 128); +} + +#[test] +fn a_bound_variable_alpha_applies_once() { + // The fill and the variable are both #0D6EFD80: the result is 50%, not 25%. + let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40"); + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"})); + assert!(near(alpha(&s, r), HALF), "{}", alpha(&s, r)); + assert_eq!(pixel_alpha(&s, r), 128); + let svg = figcraft_svg::export_svg(s.doc().unwrap(), &[r], &figcraft_svg::SvgOptions::default()).unwrap(); + assert!(svg.contains("fill=\"#0D6EFD\" fill-opacity=\"0.502\""), "{svg}"); + // Binding the same variable again changes nothing. + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"})); + assert!(near(alpha(&s, r), HALF)); +} + +#[test] +fn unbinding_keeps_the_colour_with_its_alpha_in_the_opacity() { + let (mut s, r) = setup("#0D6EFD40", "#0D6EFD"); + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"})); + assert_eq!(pixel_alpha(&s, r), 64); + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": null})); + let p = &s.doc().unwrap().node(r).unwrap().fills[0]; + assert_eq!(p.kind, figcraft_color::PaintKind::Solid { color: figcraft_color::Color::from_rgb8_const(0x0D, 0x6E, 0xFD) }); + assert!(near(p.opacity, QUARTER), "{}", p.opacity); + assert_eq!(pixel_alpha(&s, r), 64); + // Undo restores the binding and its paint. + ex(&mut s, "edit.undo", json!({})); + assert!(!s.doc().unwrap().node(r).unwrap().bound.is_empty()); + assert_eq!(pixel_alpha(&s, r), 64); +} + +#[test] +fn modes_switch_the_alpha_without_accumulating() { + let (mut s, r) = setup("#0D6EFD40", "#0D6EFD80"); + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"})); + for (mode, want) in [("Dark", HALF), ("Light", QUARTER), ("Dark", HALF), ("Light", QUARTER)] { + ex(&mut s, "variable.setMode", json!({"collection": "Tokens", "mode": mode})); + assert!(near(alpha(&s, r), want), "{mode}: {}", alpha(&s, r)); + } + // An opacity set while bound multiplies on top and survives mode switches. + ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.5})); + ex(&mut s, "variable.setMode", json!({"collection": "Tokens", "mode": "Dark"})); + assert!(near(alpha(&s, r), HALF * 0.5)); +} + +#[test] +fn node_set_and_style_bindings_apply_the_alpha_once() { + let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40"); + let var = s.doc().unwrap().variables.get("tint").unwrap().id.clone(); + ex(&mut s, "node.set", json!({"id": r.0, "props": {"boundVariables": {"fill": var}}})); + assert!(near(alpha(&s, r), HALF)); + ex(&mut s, "node.set", json!({"id": r.0, "props": {"boundVariables": {"fill": null}}})); + assert!(near(s.doc().unwrap().node(r).unwrap().fills[0].opacity, HALF)); + // A paint style made from a `#RRGGBBAA` colour, bound to the variable. + let st = s.execute("style.create", &json!({"type": "paint", "name": "Tint", "value": "#0D6EFD80"})).unwrap()["id"].clone(); + ex(&mut s, "style.bind", json!({"style": st, "field": "fills/0/color", "variable": "tint"})); + ex(&mut s, "style.apply", json!({"ids": [r.0], "style": st})); + assert!(near(alpha(&s, r), HALF), "{}", alpha(&s, r)); +} + +#[test] +fn a_fig_round_trip_keeps_the_alpha_once() { + let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40"); + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"})); + ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.5})); + let (bytes, _) = figcraft_format::fig::export(s.doc().unwrap(), None).unwrap(); + let back = crate::cmd::file::load_fig(&bytes, "x").unwrap().unwrap().doc; + let n = back.nodes().find(|n| !n.bound.is_empty()).unwrap(); + let a = n.fills[0].solid_color().unwrap().a; + assert!(near(a, HALF * 0.5), "{a}"); +} + +#[test] +fn code_gives_a_translucent_stroke_its_alpha_once() { + let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40"); + ex(&mut s, "stroke.add", json!({"ids": [r.0], "color": "#FF000080"})); + let code = s.execute("inspect.code", &json!({"id": r.0})).unwrap()["code"].as_str().unwrap_or_default().to_string(); + assert!(code.contains("solid rgba(255, 0, 0, 0.50)"), "{code}"); +} diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index ad6c21d12..10ad13e54 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -27,6 +27,13 @@ pub fn specs() -> Vec { spacing ), spec!("arrange.tidy", "Tidy up", "{ids?, spacing?}", true, tidy), + spec!( + "arrange.reorder", + "Reorder selection", + "{ids?, source, target, swap?, fromGesture?} (insert at target's visual slot; no hierarchy changes)", + true, + reorder + ), spec!("arrange.packH", "Pack horizontal", "{ids?}", true, |s, p| pack(s, p, true)), spec!("arrange.packV", "Pack vertical", "{ids?}", true, |s, p| pack(s, p, false)), spec!("arrange.roundToPixels", "Round to pixels", "{ids?}", true, round_px), @@ -528,6 +535,7 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result { let (col, row) = string(p, "anchor").and_then(|a| crate::tools::anchor_of(&a)).unwrap_or((0, 0)); let (ax, ay) = (f64::from(col) / 2.0, f64::from(row) / 2.0); let doc = s.doc_mut()?; + let before = super::styles::bound_before(doc, &ids); for i in ids { let n = doc.get(i)?; let (ow, oh) = (n.width, n.height); @@ -551,6 +559,49 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result { } super::props::restore_worlds(doc, &kept)?; } + super::styles::detach_edited(doc, &before); + ok() +} + +/// Reorder visual slots without rebuilding or reparenting layers. +fn reorder(s: &mut Session, p: &Value) -> Result { + let cmd = "arrange.reorder"; + let ids = req_targets(cmd, s, p)?; + let source = req_id(cmd, p, "source")?; + let target = req_id(cmd, p, "target")?; + let ds = s.ds()?; + let base = ds.interaction.as_ref().filter(|_| boolean(p, "fromGesture") == Some(true)).map(|i| i.doc.clone()).unwrap_or_else(|| ds.doc.clone()); + let layout = + crate::selection_reorder::ReorderLayout::new(&base, &ids).ok_or_else(|| bad(cmd, "select a regular row, column or complete aligned grid"))?; + let positions = layout + .positions(source, target, boolean(p, "swap") == Some(true)) + .ok_or_else(|| bad(cmd, "source and target must belong to the selection"))?; + // Validate every delta before editing; absolute targets always come from the gesture's + // snapshot so crossing several slots and returning to the start does not accumulate edits. + let doc = s.doc()?; + let mut moves = Vec::new(); + for (id, at) in positions { + let now = doc.world_bounds(id).ok_or_else(|| bad(cmd, "layer is no longer available"))?; + let (dx, dy) = (at.x - now.x0, at.y - now.y0); + if !dx.is_finite() || !dy.is_finite() { + return Err(bad(cmd, "non-finite reorder result")); + } + if dx.abs() > 1e-9 || dy.abs() > 1e-9 { + let node = doc.get(id)?; + let parent = node.parent.map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY); + let transform = parent.inverse() * Affine::translate((dx, dy)) * parent * node.transform; + if !transform.as_coeffs().iter().all(|v| v.is_finite()) { + return Err(bad(cmd, "non-finite local transform")); + } + moves.push((id, dx, dy)); + } + } + if !moves.is_empty() { + let doc = s.doc_mut()?; + for (id, dx, dy) in moves { + move_world(doc, id, dx, dy)?; + } + } ok() } diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index ff7a56df4..bf99c5920 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -472,6 +472,7 @@ fn detach(s: &mut Session, p: &Value) -> Result { // Own layers lose their link (slot content stays as it is); nested instances stay // instances of their own mains. let nested = unlink_layers(doc, i, Some(i)); + keep_main_modes(doc, i)?; let n = doc.get_mut(i)?; n.kind = NodeKind::Frame; n.source = None; @@ -491,6 +492,19 @@ fn detach(s: &mut Session, p: &Value) -> Result { Ok(json!({"ids": out})) } +/// Make the variable modes instance `i` takes from its main its own (it's being detached). +fn keep_main_modes(doc: &mut Document, i: NodeId) -> Result<()> { + let modes = match doc.get(i)?.kind { + NodeKind::Instance { component, .. } => doc.node(component).map(|m| m.var_modes.clone()).unwrap_or_default(), + _ => return Ok(()), + }; + let own = &mut doc.get_mut(i)?.var_modes; + for (c, m) in modes { + own.entry(c).or_insert(m); + } + Ok(()) +} + fn reset_instance(s: &mut Session, p: &Value) -> Result { let cmd = "component.resetInstance"; let ids = req_targets(cmd, s, p)?; @@ -608,7 +622,7 @@ fn make_slot(s: &mut Session, p: &Value) -> Result { let owner = Owner::resolve(doc, id).ok_or_else(|| bad(cmd, "the frame isn't inside a component"))?; let name = string(p, "name").unwrap_or_else(|| fresh_prop_name(doc, &owner, &n.name)); let preferred = get(p, "preferred").cloned().unwrap_or(Value::Null); - add_prop(s, &json!({"id": id, "layer": id, "name": name, "type": "slot", "preferred": preferred})) + add_prop_for(cmd, s, &json!({"id": id, "layer": id, "name": name, "type": "slot", "preferred": preferred})) } fn remove_slot(s: &mut Session, p: &Value) -> Result { @@ -1039,7 +1053,12 @@ fn valid_prop_name(cmd: &str, name: &str) -> Result { } fn add_prop(s: &mut Session, p: &Value) -> Result { - let cmd = "component.addProperty"; + add_prop_for("component.addProperty", s, p) +} + +/// Add a component property for command `cmd`, which errors name (`component.makeSlot` adds its +/// slot property here). +fn add_prop_for(cmd: &str, s: &mut Session, p: &Value) -> Result { let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component"))?; let name = valid_prop_name(cmd, &req_string(cmd, p, "name")?)?; let t = req_string(cmd, p, "type")?; diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 9b3f7067f..3a9f6d67f 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -73,9 +73,13 @@ pub fn specs() -> Vec { spec!("text.create", "Text", "{text, x, y, parent?, width? (fixed width wraps), props? (fontSize, fontFamily, …)}", true, |s, p| { create_kind(s, p, "text") }), - spec!("vector.create", "Vector", "{path (SVG path data) | points [[x,y],..], closed?, x?, y?, parent?, props?}", true, |s, p| create_kind( - s, p, "vector" - )), + spec!( + "vector.create", + "Vector", + "{path (SVG path data) | points [[x,y],..], closed?, x?, y?, width?, height? (scale the path; one keeps its proportion), parent?, props?}", + true, + |s, p| create_kind(s, p, "vector") + ), spec!( "image.place", "Place image", @@ -162,6 +166,37 @@ pub fn parse_path_data(d: &str) -> Option { figcraft_geom::BezPath::from_svg(d).ok() } +/// How much a vector's path is scaled to take the size asked for: with both sides, each scales +/// independently (as resizing a vector does); with one, the other keeps its proportion. A size on +/// an axis the geometry has none of is an error unless it is 0 (a line's height), and a size of 0 +/// or less on an axis it has is refused rather than flattening it. +fn vector_scale(width: Option, height: Option, bw: f64, bh: f64, what: &str) -> std::result::Result<(f64, f64), String> { + let fit = |asked: Option, have: f64, axis: &str| match asked { + None => Ok(1.0), + Some(a) if have <= 1e-9 => { + if a == 0.0 { + Ok(1.0) + } else { + Err(format!("`{axis}` can't size {what} with no {} extent", if axis == "width" { "horizontal" } else { "vertical" })) + } + } + Some(a) if a > 0.0 && (a / have).is_finite() => Ok(a / have), + Some(_) => Err(format!("`{axis}` must be a positive, finite size: 0 or less would flatten {what}")), + }; + Ok(match (width, height) { + (Some(_), Some(_)) => (fit(width, bw, "width")?, fit(height, bh, "height")?), + (Some(_), None) => { + let s = fit(width, bw, "width")?; + (s, s) + } + (None, Some(_)) => { + let s = fit(height, bh, "height")?; + (s, s) + } + (None, None) => (1.0, 1.0), + }) +} + pub fn create(s: &mut Session, p: &Value) -> Result { let cmd = "node.create"; let t = string(p, "type").ok_or_else(|| bad(cmd, "`type` is required"))?; @@ -187,20 +222,32 @@ pub fn create(s: &mut Session, p: &Value) -> Result { "Section" => (num_or(p, "width", 400.0), num_or(p, "height", 300.0)), _ => (num_or(p, "width", 100.0), num_or(p, "height", 100.0)), }; - // Vectors: path data or points define the shape and its box. + // Vectors: path data or points define the shape and its box. `width`/`height`, when asked + // for, scale the geometry: both sides independently, one side proportionally. let mut offset = (0.0, 0.0); + let asked = (num(p, "width"), num(p, "height")); if let NodeKind::Vector { network } = &mut kind { if let Some(d) = string(p, "path") { let mut path = parse_path_data(&d).ok_or_else(|| bad(cmd, "`path` is not valid SVG path data"))?; use figcraft_geom::Shape; let bb = path.bounding_box(); + if bb.width() <= 1e-9 && bb.height() <= 1e-9 { + return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area")); + } path.apply_affine(figcraft_geom::Affine::translate((-bb.x0, -bb.y0))); + let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height(), "a path").map_err(|e| bad(cmd, e))?; + if sx != 1.0 || sy != 1.0 { + path.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy)); + } *network = VectorNetwork::from_path(&path); + if network.segments.is_empty() { + return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area")); + } if boolean(p, "closed") == Some(false) { network.regions.clear(); } - w = bb.width(); - h = bb.height(); + w = bb.width() * sx; + h = bb.height() * sy; offset = (bb.x0, bb.y0); } else if let Some(Value::Array(pts)) = get(p, "points") { let pts: Vec = pts.iter().filter_map(|v| Some(Point::new(f64_of(v.get(0)?)?, f64_of(v.get(1)?)?))).collect(); @@ -208,10 +255,14 @@ pub fn create(s: &mut Session, p: &Value) -> Result { return Err(bad(cmd, "`points` needs at least two [x, y] pairs")); } let bb = figcraft_geom::points_bounds(pts.iter().copied()).unwrap_or_default(); - let local: Vec = pts.iter().map(|q| Point::new(q.x - bb.x0, q.y - bb.y0)).collect(); + if bb.width() <= 1e-9 && bb.height() <= 1e-9 { + return Err(bad(cmd, "`points` draws nothing: a vector needs a visible line or area")); + } + let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height(), "a point set").map_err(|e| bad(cmd, e))?; + let local: Vec = pts.iter().map(|q| Point::new((q.x - bb.x0) * sx, (q.y - bb.y0) * sy)).collect(); *network = VectorNetwork::from_points(&local, boolean(p, "closed").unwrap_or(false)); - w = bb.width(); - h = bb.height(); + w = bb.width() * sx; + h = bb.height() * sy; offset = (bb.x0, bb.y0); } } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a4769a35c..a16fbb386 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -18,13 +18,19 @@ pub fn specs() -> Vec { spec!( "edit.duplicate", "Duplicate", - "{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place)}", + "{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place)} a main component's copy is an instance of it (a variant stays a new variant in its set)", true, duplicate ), spec!("edit.copy", "Copy", "{ids?}", false, copy), spec!("edit.cut", "Cut", "{ids?}", true, cut), - spec!("edit.paste", "Paste", "{parent?, x?, y? (page coordinates of the pasted top-left)}", true, paste), + spec!( + "edit.paste", + "Paste", + "{parent?, x?, y? (page coordinates of the pasted top-left)} a copied main component pastes as an instance of it within its file (cut ones, and pastes into other files, keep the component)", + true, + paste + ), spec!( "edit.pasteDesignLayers", "Paste design layers", @@ -261,7 +267,7 @@ pub fn specs() -> Vec { }), spec!("effect.set", "Set effect", "{ids?, index, effect {..}}", true, |s, p| { let i = req_index("effect.set", p, "index")?; - let e: Effect = parse("effect.set", p, "effect")?.ok_or_else(|| bad("effect.set", "`effect` required"))?; + let e = super::props::effect_from("effect.set", p, "effect")?.ok_or_else(|| bad("effect.set", "`effect` required"))?; for id in req_targets("effect.set", s, p)? { let n = s.doc_mut()?.get_mut(id)?; if let Some(slot) = n.effects.get_mut(i) { @@ -466,7 +472,7 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { let top_frame = doc.node(parent).is_some_and(Node::is_page) && n.kind.is_frame_like(); let idx = doc.index_in_parent(i).map(|x| x + 1); let w = n.width; - let nid = doc.duplicate_into(i, parent, idx)?; + let nid = crate::components::copy_in_file(doc, i, parent, idx)?; let (dx, dy) = match (num(p, "dx"), num(p, "dy")) { (Some(dx), dy) => (dx, dy.unwrap_or(0.0)), (None, Some(dy)) => (0.0, dy), @@ -481,7 +487,8 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { nn.transform = Affine::translate((dx, dy)) * nn.transform; } } - if rename { + // An instance keeps the name instances get by default. + if rename && !matches!(doc.get(nid)?.kind, NodeKind::Instance { .. }) { let nn = doc.get_mut(nid)?; nn.name = bump_name(&nn.name); } @@ -515,6 +522,13 @@ pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<( s.clipboard.source = Some(source); s.clipboard.source_path = source_path; s.clipboard.source_doc = uid; + s.clipboard.copied_components = nodes + .iter() + .filter(|(_, sub)| { + cmd == "edit.copy" && sub.first().is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. })) + }) + .map(|(root, _)| *root) + .collect(); s.clipboard.nodes = nodes; s.clipboard.images = images; s.clipboard.bounds = bounds; @@ -593,7 +607,8 @@ fn paste_design_layers(s: &mut Session, p: &Value) -> Result { let (nodes, images, bounds) = copied(&doc, &tops); let props = s.clipboard.props.take(); // No open file has id 0. So paste treats this copy as a copy from a different file. - s.clipboard = crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None }; + s.clipboard = + crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None, copied_components: BTreeSet::new() }; let mut out = paste(s, p)?; if !other.issues.is_empty() { out["warnings"] = json!(other.issues); @@ -623,6 +638,7 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re } // From another file: its instances follow the mains this file can have. let foreign = clip.source.as_ref().filter(|_| s.ds().is_ok_and(|d| d.uid != clip.source_doc)); + let copy_local = s.ds()?.uid == clip.source_doc; let doc = s.doc_mut()?; for (k, v) in &clip.images { doc.images.entry(k.clone()).or_insert_with(|| v.clone()); @@ -636,6 +652,16 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re let mut out = Vec::new(); let mut all = BTreeMap::new(); for (root, nodes) in &clip.nodes { + if copy_local + && clip.copied_components.contains(root) + && let Some(n) = nodes.first().filter(|n| matches!(n.kind, NodeKind::Component { .. })) + && doc.node(*root).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. })) + { + let instance = crate::components::create_instance(doc, *root, parent, 0.0, 0.0)?; + doc.get_mut(instance)?.transform = doc.world_transform(parent).inverse() * shift * n.transform; + out.push(instance); + continue; + } let mut map = BTreeMap::new(); for n in nodes { map.insert(n.id, doc.alloc_id()); @@ -661,10 +687,12 @@ fn paste(s: &mut Session, p: &Value) -> Result { // else in the frame's centre; otherwise in place on the page (or the viewport centre). let sel = s.selection(); let doc = s.doc()?; + let copy_local = s.ds()?.uid == s.clipboard.source_doc; let target = id(p, "parent").or_else(|| { sel.first() .copied() .filter(|i| sel.len() == 1 && doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && !matches!(n.kind, NodeKind::Instance { .. }))) + .filter(|i| !copy_local || !s.clipboard.copied_components.iter().any(|root| doc.is_ancestor(*root, *i))) }); let parent = target.unwrap_or(page); let at = match (num(p, "x"), num(p, "y")) { @@ -931,9 +959,13 @@ pub fn opacity_from_digits(digits: &str) -> Option { fn opacity_keys(s: &mut Session, p: &Value) -> Result { let digits = req_string("object.opacityKeys", p, "digits")?; let o = opacity_from_digits(&digits).ok_or_else(|| bad("object.opacityKeys", "`digits` must be one or two digits"))?; - for i in req_targets("object.opacityKeys", s, p)? { - s.doc_mut()?.get_mut(i)?.opacity = o; + let ids = req_targets("object.opacityKeys", s, p)?; + let doc = s.doc_mut()?; + let before = super::styles::bound_before(doc, &ids); + for i in ids { + doc.get_mut(i)?.opacity = o; } + super::styles::detach_edited(doc, &before); Ok(json!({"opacity": o})) } @@ -1181,10 +1213,12 @@ fn set(s: &mut Session, p: &Value) -> Result { } } let doc = s.doc_mut()?; + let before = super::styles::bound_before(doc, &ids); for i in ids { let props = if skipped.contains(&i) { &without_layout } else { &props }; super::props::apply("node.set", doc, i, props)?; } + super::styles::detach_edited(doc, &before); if let Some(o) = pivot { for (i, q) in pinned { let d = o - doc.world_transform(i) * q; @@ -1342,7 +1376,7 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result { if let Some(b) = blend(p, "blend") { slot.blend = b; } - super::lists::remap_stop_bindings(n, fills, at, &old); + super::lists::rebind_edited_paint(n, fills, at, &old); if fills { n.styles.fill = None; } else { @@ -1376,8 +1410,8 @@ fn pattern_fill(s: &mut Session, p: &Value) -> Result { } fn effect_add(s: &mut Session, p: &Value) -> Result { - let e: Effect = match get(p, "effect") { - Some(v) => serde_json::from_value(v.clone()).map_err(|e| bad("effect.add", e.to_string()))?, + let e: Effect = match super::props::effect_from("effect.add", p, "effect")? { + Some(e) => e, None => { let ty = string(p, "type").unwrap_or_else(|| "dropShadow".into()); Effect::of_type(&ty).ok_or_else(|| bad("effect.add", format!("unknown effect type `{ty}`")))? diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 511e0fc6b..8e59b3031 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -92,6 +92,20 @@ pub fn specs() -> Vec { ), spec!("file.placeImage", "Place image", "{path | data, x?, y?}", true, super::create::place_image), spec!("file.close", "Close tab", "{index?, force? (discard unsaved changes)}", false, close), + spec!("file.moveTab", "Move tab", "{from, to} (indices among open documents)", false, |s, p| { + let from = req_index("file.moveTab", p, "from")?; + let to = req_index("file.moveTab", p, "to")?; + if from >= s.docs.len() || to >= s.docs.len() { + return Err(bad("file.moveTab", "no such document")); + } + if from != to { + let active_uid = s.docs.get(s.active).map(|d| d.uid); + let doc = s.docs.remove(from); + s.docs.insert(to, doc); + s.active = active_uid.and_then(|uid| s.docs.iter().position(|d| d.uid == uid)).unwrap_or(0); + } + Ok(json!({"index": to})) + }), spec!( "file.recover", "Recover file", @@ -548,6 +562,7 @@ pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option = @@ -1373,14 +1388,36 @@ fn close(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Disabled("file.close".into(), "the document has unsaved changes (pass force: true to discard)".into())); } let uid = ds.uid; + let active_uid = s.docs.get(s.active).map(|d| d.uid); s.docs.remove(i); - if s.active >= s.docs.len() { - s.active = s.docs.len().saturating_sub(1); - } + // Removing a tab before the active one shifts its index; keep the same document selected. + s.active = active_uid.and_then(|a| s.docs.iter().position(|d| d.uid == a)).unwrap_or(i.min(s.docs.len().saturating_sub(1))); crate::branches::close_tabs(s, uid, None); ok() } +#[cfg(test)] +mod tab_order_tests { + use super::*; + + #[test] + fn moving_and_closing_inactive_tabs_preserves_the_active_document() { + let mut session = Session::new(); + for name in ["Two", "Three", "Four"] { + session.execute("file.new", &json!({"name": name})).unwrap(); + } + session.execute("doc.switch", &json!({"index": 2})).unwrap(); + let active_uid = session.docs[session.active].uid; + let moved_uid = session.docs[0].uid; + session.execute("file.moveTab", &json!({"from": 0, "to": 2})).unwrap(); + assert_eq!(session.docs[2].uid, moved_uid); + assert_eq!(session.docs[session.active].uid, active_uid); + session.execute("file.close", &json!({"index": 0})).unwrap(); + assert_eq!(session.docs[session.active].uid, active_uid); + assert!(session.execute("file.moveTab", &json!({"from": 0, "to": 99})).is_err()); + } +} + /// Ctrl+Tab / Ctrl+Shift+Tab: the next or previous open document, wrapping around. fn cycle_doc(s: &mut Session, dir: i64) -> Result { let n = s.docs.len() as i64; diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 5643ac706..da1301ec0 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -149,6 +149,35 @@ mod tests { assert!(s.execute("node.set", &json!({"id": b, "props": {"absolute": "maybe"}})).is_err()); } + /// Each side key writes its own padding slot and leaves the other three alone. + #[test] + fn each_side_padding_sets_its_side_alone() { + let mut s = Session::new(); + let (f, _, _) = al_frame(&mut s); + // One side at a time: the other three stay 0 ([top, right, bottom, left]). + for (key, v, want) in [ + ("paddingTop", 8.0, [8.0, 0.0, 0.0, 0.0]), + ("paddingRight", 12.0, [0.0, 12.0, 0.0, 0.0]), + ("paddingBottom", 16.0, [0.0, 0.0, 16.0, 0.0]), + ("paddingLeft", 20.0, [0.0, 0.0, 0.0, 20.0]), + ] { + ex(&mut s, "node.set", json!({"id": f, "props": {"padding": 0}})); + ex(&mut s, "node.set", json!({"id": f, "props": {key: v}})); + assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, want, "{key}"); + } + // The [t, r, b, l] array and the axis pairs agree with the four sides. + ex(&mut s, "node.set", json!({"id": f, "props": {"padding": [8, 12, 16, 20]}})); + assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [8.0, 12.0, 16.0, 20.0]); + ex(&mut s, "node.set", json!({"id": f, "props": {"paddingHorizontal": 4}})); + assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [8.0, 4.0, 16.0, 4.0]); + ex(&mut s, "node.set", json!({"id": f, "props": {"paddingVertical": 2}})); + assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [2.0, 4.0, 2.0, 4.0]); + // Negatives clamp to 0; junk is refused. + ex(&mut s, "node.set", json!({"id": f, "props": {"paddingTop": -5}})); + assert_eq!(node(&s, f).layout.as_ref().unwrap().padding[0], 0.0); + assert!(s.execute("node.set", &json!({"id": f, "props": {"paddingLeft": "wide"}})).is_err()); + } + #[test] fn baseline_alignment_lines_up_text() { let mut s = Session::new(); diff --git a/crates/engine/src/cmd/lists.rs b/crates/engine/src/cmd/lists.rs index 769d0a29c..287c9ddd0 100644 --- a/crates/engine/src/cmd/lists.rs +++ b/crates/engine/src/cmd/lists.rs @@ -112,7 +112,8 @@ pub fn stop_binding(field: &str) -> Option { /// Keep the gradient stop bindings of paint `index` of `n`'s fills (or strokes) on their stops after /// the paint changed from `old`: one stop removed shifts the later ones down (the removed one's /// binding goes); a paint that is no longer a gradient drops them. Other edits keep stop indices. -pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) { +/// Returns the removed stop's index. +pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) -> Option { use figcraft_color::PaintKind; let list = if fills { BoundList::Fills } else { BoundList::Strokes }; let new = if fills { n.fills.get(index) } else { n.strokes.get(index) }; @@ -122,11 +123,11 @@ pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old: // The first stop that differs is the removed one when the rest moved down unchanged. let k = a.stops.iter().zip(&b.stops).position(|(x, y)| x != y).unwrap_or(b.stops.len()); if a.stops.get(k + 1..) != b.stops.get(k..) { - return; + return None; } Some(k) } - (_, Some(PaintKind::Gradient(_))) => return, + (_, Some(PaintKind::Gradient(_))) => return None, _ => None, }; let keys: Vec<(String, Option)> = n @@ -150,6 +151,42 @@ pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old: } } n.bound.extend(kept); + removed +} + +/// After an explicit edit changed paint `index` of `n`'s fills (or strokes) from `old`: its stop +/// bindings follow a removed stop, then each binding whose value the edit changed (the colour, the +/// opacity, a stop's colour) lets go, so the edit stays instead of being put back from the +/// variable. Bindings whose value the edit kept stay; a paint that is gone takes its bindings. +pub(crate) fn rebind_edited_paint(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) { + let removed = remap_stop_bindings(n, fills, index, old); + let mut old = old.clone(); + if let (Some(k), figcraft_color::PaintKind::Gradient(g)) = (removed, &mut old.kind) + && k < g.stops.len() + { + g.stops.remove(k); + } + let list = if fills { BoundList::Fills } else { BoundList::Strokes }; + let new = if fills { n.fills.get(index) } else { n.strokes.get(index) }.cloned(); + n.bound.retain(|key, _| match list_binding(key) { + Some((l, i, field)) if l == list && i == index => new.as_ref().is_some_and(|new| paint_field_kept(&old, new, field)), + _ => true, + }); +} + +/// Whether a paint's bound `field` (`color`, `opacity`, `stops//color`, …) has the same value in +/// `new` as in `old`. +fn paint_field_kept(old: &figcraft_color::Paint, new: &figcraft_color::Paint, field: &str) -> bool { + use figcraft_color::PaintKind; + match field { + "color" => matches!((&old.kind, &new.kind), (PaintKind::Solid { color: a }, PaintKind::Solid { color: b }) if a == b), + "opacity" => old.opacity == new.opacity, + _ => match (stop_binding(field), &old.kind, &new.kind) { + (Some(j), PaintKind::Gradient(a), PaintKind::Gradient(b)) => a.stops.get(j).map(|s| s.color) == b.stops.get(j).map(|s| s.color), + (Some(_), _, _) => false, + _ => old == new, + }, + } } /// Where item `k` lands when the item at `from` moves to `to`. diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index ca62962da..f180b53c8 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -61,6 +61,48 @@ pub fn snake(v: &Value) -> Value { } } +/// `v` as an effect list, refusing keys an effect doesn't read: serde dropped them without a word, +/// so a shadow given `offsetY` kept the default offset. +pub fn effects_from(cmd: &str, v: &Value) -> Result> { + let effects: Vec = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("effects: {e}")))?; + for (i, given) in v.as_array().into_iter().flatten().enumerate() { + refuse_ignored_effect_keys(cmd, &format!("effects[{i}]"), given, |v| serde_json::from_value(snake(v)).ok())?; + } + Ok(effects) +} + +/// `p[key]` as one effect, read as serde reads it (its aliases too), refusing keys it doesn't read. +pub fn effect_from(cmd: &str, p: &Value, key: &str) -> Result> { + let Some(v) = get(p, key) else { return Ok(None) }; + let effect: Effect = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("`{key}`: {e}")))?; + refuse_ignored_effect_keys(cmd, &format!("`{key}`"), v, |v| serde_json::from_value(v.clone()).ok())?; + Ok(Some(effect)) +} + +/// Refuse the keys of effect `given` that `parse` ignores, naming the keys the effect takes. Set to +/// null, a key it reads fails to parse or changes the effect; a key it ignores changes nothing. +fn refuse_ignored_effect_keys(cmd: &str, at: &str, given: &Value, parse: impl Fn(&Value) -> Option) -> Result<()> { + let Some(fields) = given.as_object() else { return Ok(()) }; + let parsed = parse(given); + let ignored: Vec<&str> = fields + .iter() + .filter(|(_, v)| !v.is_null()) + .filter(|(k, _)| { + let mut probe = fields.clone(); + probe.insert((*k).clone(), Value::Null); + parse(&Value::Object(probe)).is_some_and(|e| Some(e) == parsed) + }) + .map(|(k, _)| k.as_str()) + .collect(); + if ignored.is_empty() { + return Ok(()); + } + let takes = parsed.and_then(|e| serde_json::to_value(e).ok()).map(crate::inspect::camel).unwrap_or_default(); + let kind = takes.get("type").and_then(Value::as_str).unwrap_or("effect").to_string(); + let names: Vec<&str> = takes.as_object().into_iter().flatten().map(|(k, _)| k.as_str()).collect(); + Err(bad(cmd, format!("{at}: unknown properties: {} (a {kind} takes {})", ignored.join(", "), names.join(", ")))) +} + pub(crate) fn enum_of(v: &Value) -> Option { let s = v.as_str()?; // Accept camelCase, snake/upper-case and spaced variants. @@ -297,6 +339,12 @@ pub const LAYOUT_KEYS: &[&str] = &[ "gridRowTracks", ]; +/// Drop the bindings of auto layout fields (gap, padding…): they go with the auto layout, or +/// re-applying them would bring it back. +pub fn drop_layout_bindings(n: &mut Node) { + n.bound.retain(|k, _| !LAYOUT_KEYS.contains(&k.as_str())); +} + /// The auto layout of a frame-like node, created when it has none yet. Other nodes can't hold an /// auto layout: an auto layout key on them is refused, not stored where nothing reads it /// (`node.set` on several layers skips them instead, see `edit::set`). @@ -315,18 +363,32 @@ fn layout_mut<'a>(cmd: &str, k: &str, n: &'a mut Node) -> Result<&'a mut AutoLay Ok(ensure_layout(n)) } -/// Apply a property map to one node. `x`/`y` are relative to the parent. -pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map) -> Result<()> { - let mut unknown = Vec::new(); - // Geometry keys first so rotation applies to the final size. - let order = |k: &str| match k { +/// Replace a node's fills (or strokes). Bindings on the paints whose bound values the new list +/// changed let go, as an explicit edit does (see `lists::rebind_edited_paint`). +fn set_paints(n: &mut Node, fills: bool, paints: Vec) { + let old = std::mem::replace(if fills { &mut n.fills } else { &mut n.strokes }, paints); + for (i, p) in old.iter().enumerate() { + super::lists::rebind_edited_paint(n, fills, i, p); + } +} + +/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, variable +/// bindings last so paints set in the same call don't detach them. +fn apply_order(k: &str) -> u8 { + match k { "width" | "height" | "w" | "h" => 0, "rotation" => 2, "x" | "y" => 3, + "boundVariables" => 4, _ => 1, - }; + } +} + +/// Apply a property map to one node. `x`/`y` are relative to the parent. +pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map) -> Result<()> { + let mut unknown = Vec::new(); let mut keys: Vec<(&String, &Value)> = props.iter().collect(); - keys.sort_by_key(|(k, _)| order(k)); + keys.sort_by_key(|(k, _)| apply_order(k)); for (k, v) in keys { let k = k.as_str(); match k { @@ -398,15 +460,15 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map doc.get_mut(id)?.fills = paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?, + "fills" => set_paints(doc.get_mut(id)?, true, paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?), "fill" => { let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour"))?; - doc.get_mut(id)?.fills = vec![Paint::solid(c)]; + set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]); } - "strokes" => doc.get_mut(id)?.strokes = paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?, + "strokes" => set_paints(doc.get_mut(id)?, false, paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?), "stroke" => { let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour"))?; - doc.get_mut(id)?.strokes = vec![Paint::solid(c)]; + set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]); } "strokeWeight" => doc.get_mut(id)?.stroke.weight = f64_of(v).ok_or_else(|| bad(cmd, "`strokeWeight` must be a number"))?.max(0.0), "strokeAlign" => doc.get_mut(id)?.stroke.align = enum_of(v).ok_or_else(|| bad(cmd, "strokeAlign: inside|center|outside"))?, @@ -462,7 +524,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map doc.get_mut(id)?.corners.smoothing = f64_of(v).unwrap_or(0.0).clamp(0.0, 1.0), - "effects" => doc.get_mut(id)?.effects = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("effects: {e}")))?, + "effects" => doc.get_mut(id)?.effects = effects_from(cmd, v)?, "clip" | "clipsContent" => doc.get_mut(id)?.clip = v.as_bool().ok_or_else(|| bad(cmd, "`clip` must be a boolean"))?, "isMask" => doc.get_mut(id)?.is_mask = v.as_bool().unwrap_or(false), "devStatus" => { @@ -486,8 +548,14 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map n.layout = None, - Value::String(s) if s.eq_ignore_ascii_case("none") => n.layout = None, + Value::Null => { + n.layout = None; + drop_layout_bindings(n); + } + Value::String(s) if s.eq_ignore_ascii_case("none") => { + n.layout = None; + drop_layout_bindings(n); + } Value::String(s) => { let mode = match s.to_ascii_lowercase().as_str() { "horizontal" | "row" => LayoutMode::Horizontal, @@ -775,10 +843,15 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map { + if n.bound.get(field).map(String::as_str) != Some(vid) { + crate::cmd::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, true); + } n.bound.insert(field.clone(), vid.to_string()); } None => { - n.bound.remove(field); + if n.bound.remove(field).is_some() { + crate::cmd::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, false); + } } } } diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index 75ddac3d8..90e186113 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -10,11 +10,13 @@ use super::{CommandSpec, ok}; use crate::params::*; use crate::{EngineError, Result, Session, bad, spec}; +/// The layer structure commands: group, frame, mask, boolean operations, flatten, auto layout +/// add/remove, move to page and vector path edits. pub fn specs() -> Vec { vec![ - spec!("object.group", "Group selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Group)), + spec!("object.group", "Group selection", "{ids?, name?}", true, |s, p| wrap(s, p, "object.group", Wrap::Group)), spec!("object.ungroup", "Ungroup selection", "{ids?}", true, ungroup), - spec!("object.frameSelection", "Frame selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Frame)), + spec!("object.frameSelection", "Frame selection", "{ids?, name?}", true, |s, p| wrap(s, p, "object.frameSelection", Wrap::Frame)), spec!("object.removeFrame", "Remove frame", "{ids?}", true, ungroup), spec!( "object.convertToGroup", @@ -42,7 +44,7 @@ pub fn specs() -> Vec { "Wrap in new section", "{ids?, name?} (layers on the page or in a section; the section wraps them with some room)", true, - |s, p| wrap(s, p, Wrap::Section) + |s, p| wrap(s, p, "object.wrapInSection", Wrap::Section) ), spec!( "object.resizeToFit", @@ -71,7 +73,9 @@ pub fn specs() -> Vec { // Hugging needs auto layout and the children that filled have nothing to fill: both // become fixed (the frame keeps filling its own auto layout parent). let doc = s.doc_mut()?; - doc.get_mut(i)?.layout = None; + let n = doc.get_mut(i)?; + n.layout = None; + super::props::drop_layout_bindings(n); figcraft_layout::coherent_sizing(doc, i); } ok() @@ -128,26 +132,27 @@ const SECTION_PAD: f64 = 40.0; const BOOL_PARAMS: &str = "{ids?} — layers inside boolean groups: sets their own operation (how each combines with the operands below it); one boolean group: sets its operation (every operand follows it); otherwise wraps the layers in a new boolean group"; /// Wrap the selection in a new boolean group doing `op`. -pub(crate) fn wrap_boolean(s: &mut Session, p: &Value, op: BoolOp) -> Result { - wrap(s, p, Wrap::Bool(op)) +pub(crate) fn wrap_boolean(s: &mut Session, p: &Value, cmd: &str, op: BoolOp) -> Result { + wrap(s, p, cmd, Wrap::Bool(op)) } -/// Wrap the selection in a new container at the topmost selected layer's place. -fn wrap(s: &mut Session, p: &Value, w: Wrap) -> Result { - let ids = req_targets("object.group", s, p)?; +/// Wrap the selection in a new container at the topmost selected layer's place. Errors name `cmd`, +/// the command that asked. +fn wrap(s: &mut Session, p: &Value, cmd: &str, w: Wrap) -> Result { + let ids = req_targets(cmd, s, p)?; let doc = s.doc_mut()?; let ids = z_sorted(doc, &roots(doc, &ids)); - let top = *ids.last().ok_or_else(|| bad("object.group", "nothing selected"))?; - let parent = doc.get(top)?.parent.ok_or_else(|| bad("object.group", "can't group a page"))?; + let top = *ids.last().ok_or_else(|| bad(cmd, "nothing selected"))?; + let parent = doc.get(top)?.parent.ok_or_else(|| bad(cmd, "can't group a page"))?; // Sections sit on the page or in sections, never in frames or groups. let board = |doc: &Document, i: NodeId| { doc.node(i).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. })) }; if matches!(w, Wrap::Section) && !ids.iter().all(|i| board(doc, *i)) { - return Err(bad("object.wrapInSection", "only layers on the page or in a section can be wrapped in a section")); + return Err(bad(cmd, "only layers on the page or in a section can be wrapped in a section")); } if !matches!(w, Wrap::Section) && ids.iter().any(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }))) { - return Err(bad("object.group", "sections can't go in groups or frames")); + return Err(bad(cmd, "sections can't go in groups or frames")); } let index = doc.index_in_parent(top).map(|i| i + 1); let mut bounds = doc.bounds_of(&ids).unwrap_or_default(); @@ -285,7 +290,7 @@ fn convert_to_section(s: &mut Session, p: &Value) -> Result { n.grids.clear(); n.guides.clear(); n.styles.grid = None; - n.bound.retain(|k, _| !k.starts_with("padding") && !matches!(k.as_str(), "gap" | "counterAxisSpacing")); + super::props::drop_layout_bindings(n); let kids = n.children.clone(); for k in kids { // Out of the flow there is nothing to fill or to be absolute against. @@ -338,7 +343,7 @@ fn mask(s: &mut Session, p: &Value) -> Result { return ok(); } // Group, then the bottom-most child is the mask. - let r = wrap(s, &json!({"ids": ids, "name": "Mask group"}), Wrap::Group)?; + let r = wrap(s, &json!({"ids": ids, "name": "Mask group"}), "object.mask", Wrap::Group)?; let gid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("group failed".into()))?; let doc = s.doc_mut()?; if let Some(first) = doc.get(gid)?.children.first().copied() { @@ -472,7 +477,7 @@ fn outline_stroke(s: &mut Session, p: &Value) -> Result { let f = doc.get_mut(fill_id)?; f.strokes.clear(); } - let r = wrap(s, &json!({"ids": [fill_id, vid], "name": n.name}), Wrap::Group)?; + let r = wrap(s, &json!({"ids": [fill_id, vid], "name": n.name}), "vector.outlineStroke", Wrap::Group)?; if let Some(g) = id_of(&r["id"]) { out.push(g); } @@ -519,7 +524,7 @@ fn layout_add(s: &mut Session, p: &Value) -> Result { ids } else { // Wrap non-frames in a new frame without a fill, then add auto layout to it. - let r = wrap(s, &json!({"ids": ids}), Wrap::Frame)?; + let r = wrap(s, &json!({"ids": ids}), "layout.add", Wrap::Frame)?; let fid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("wrap failed".into()))?; let f = s.doc_mut()?.get_mut(fid)?; f.fills.clear(); diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index 25e36e3ad..af029e5f7 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -110,16 +110,26 @@ pub fn specs() -> Vec { )) }), spec!("variable.createCollection", "Create variable collection", "{name, modes? [names]}", true, |s, p| { - let name = req_string("variable.createCollection", p, "name")?; - let doc = s.doc_mut()?; - if doc.variables.collections.iter().any(|c| c.name == name) { - return Err(bad("variable.createCollection", format!("a collection named `{name}` already exists"))); + let cmd = "variable.createCollection"; + let name = req_string(cmd, p, "name")?.trim().to_string(); + if name.is_empty() { + return Err(bad(cmd, "name can't be empty")); } - let id = doc.new_collection_id(); let modes: Vec = match get(p, "modes") { - Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(str::to_string)).collect(), + Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(|m| m.trim().to_string())).collect(), _ => vec!["Mode 1".into()], }; + if modes.is_empty() { + return Err(bad(cmd, "a collection needs at least one mode")); + } + if modes.iter().any(String::is_empty) { + return Err(bad(cmd, "mode names can't be empty")); + } + let doc = s.doc_mut()?; + if doc.variables.collections.iter().any(|c| c.name == name) { + return Err(bad(cmd, format!("a collection named `{name}` already exists"))); + } + let id = doc.new_collection_id(); let modes = modes.into_iter().enumerate().map(|(i, m)| Mode { id: format!("{id}/mode{}", i + 1), name: m }).collect(); doc.variables.collections.push(Collection { id: id.clone(), name, modes, extends: None }); Ok(json!({"id": id})) @@ -458,10 +468,19 @@ fn bind_style(s: &mut Session, p: &Value) -> Result { match var { Some((_, kind)) if kind != want => return Err(bad(cmd, format!("`{field}` takes a {} variable, not a {} one", want.id(), kind.id()))), Some((id, _)) => { + if style.bound.get(&key) != Some(&id) + && let StyleKind::Paint { paints } = &mut style.kind + { + rebind_paint_alpha(paints, &mut [], &key, true); + } style.bound.insert(key, id); } None => { - style.bound.remove(&key); + if style.bound.remove(&key).is_some() + && let StyleKind::Paint { paints } = &mut style.kind + { + rebind_paint_alpha(paints, &mut [], &key, false); + } } } // The style takes its default-mode value, and each layer using it its own. @@ -770,16 +789,41 @@ pub fn bind(s: &mut Session, p: &Value) -> Result { let n = doc.get_mut(i)?; match &vid { Some(v) => { + if n.bound.get(&field) != Some(v) { + rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, true); + } n.bound.insert(field.clone(), v.clone()); } None => { - n.bound.remove(&field); + if n.bound.remove(&field).is_some() { + rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, false); + } } } } ok() } +/// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding restarts +/// the opacity (which holds the alpha a typed colour had, see `Paint::solid`) at 1, so the +/// variable's alpha applies once; unbinding moves the colour's alpha back into the opacity, where a +/// typed colour keeps it. An opacity set while bound multiplies on top and stays. +pub(crate) fn rebind_paint_alpha(fills: &mut [Paint], strokes: &mut [Paint], field: &str, bind: bool) { + use super::lists::{BoundList, list_binding}; + let Some((list, i, "color")) = list_binding(field) else { return }; + let paints = match list { + BoundList::Fills => fills, + BoundList::Strokes => strokes, + _ => return, + }; + if let Some(p) = paints.get_mut(i) + && let PaintKind::Solid { color } = &mut p.kind + { + p.opacity = if bind { 1.0 } else { p.opacity * color.a }; + color.a = 1.0; + } +} + /// `variable.setMode`: choose a collection's mode on frames (the page when none are given). pub fn set_mode(s: &mut Session, p: &Value) -> Result { let cmd = "variable.setMode"; @@ -916,6 +960,147 @@ pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTr apply_bindings_matching(doc, only, |_| true); } +/// The layers `ids` before an explicit edit, for [`detach_edited`] (only those with bindings). +pub(crate) fn bound_before(doc: &Document, ids: &[NodeId]) -> Vec> { + ids.iter().filter_map(|i| doc.node_arc(*i)).filter(|n| !n.bound.is_empty()).cloned().collect() +} + +/// An explicit edit sticks: each layer's own bound field (not a list item's) whose value the edit +/// changed drops its binding, which would otherwise write the variable's value back (#809). +/// Compared by value, so keys written unchanged keep their bindings and fields moved along with +/// another (proportions, min/max) lose theirs. +pub(crate) fn detach_edited(doc: &mut Document, before: &[std::sync::Arc]) { + for old in before { + let Some(n) = doc.node(old.id) else { continue }; + let edited: Vec = n + .bound + .keys() + // A binding the edit itself made (`boundVariables`) stays. + .filter(|f| old.bound.get(*f) == n.bound.get(*f)) + .filter(|f| super::lists::list_binding(f).is_none() && !f.starts_with(crate::components::PROP_BINDING)) + .filter(|f| bound_field(old, f) != bound_field(n, f)) + .cloned() + .collect(); + if edited.is_empty() { + continue; + } + if let Ok(n) = doc.get_mut(old.id) { + for f in edited { + n.bound.remove(&f); + } + } + } +} + +/// The stored value a layer-level binding writes, comparable (`None`: the layer has no such field). +fn bound_field(n: &Node, field: &str) -> Option { + let text = |f: &dyn Fn(&TextStyle) -> String| match &n.kind { + NodeKind::Text(t) => Some(std::iter::once(&t.style).chain(t.runs.iter().map(|r| &r.style)).map(f).collect::>().join(",")), + _ => None, + }; + let layout = n.layout.as_ref(); + let cl = &n.child_layout; + let pad = |i: usize| layout.and_then(|l| l.padding.get(i).copied()); + Some(match field { + "width" => format!("{}", n.width), + "height" => format!("{}", n.height), + "minWidth" => format!("{:?}", cl.min_w), + "maxWidth" => format!("{:?}", cl.max_w), + "minHeight" => format!("{:?}", cl.min_h), + "maxHeight" => format!("{:?}", cl.max_h), + "opacity" => format!("{}", n.opacity), + "visible" => format!("{}", n.visible), + "gap" | "columnGap" => format!("{:?}", layout.map(|l| l.gap)), + "crossGap" | "rowGap" => format!("{:?}", layout.map(|l| l.cross_gap)), + "padding" => format!("{:?}", layout.map(|l| l.padding)), + "paddingTop" => format!("{:?}", pad(0)), + "paddingRight" => format!("{:?}", pad(1)), + "paddingBottom" => format!("{:?}", pad(2)), + "paddingLeft" => format!("{:?}", pad(3)), + "paddingHorizontal" => format!("{:?}", (pad(1), pad(3))), + "paddingVertical" => format!("{:?}", (pad(0), pad(2))), + "cornerRadius" => format!("{:?}", n.corners.radii), + "topLeftRadius" => format!("{:?}", n.corners.radii.first()), + "topRightRadius" => format!("{:?}", n.corners.radii.get(1)), + "bottomRightRadius" => format!("{:?}", n.corners.radii.get(2)), + "bottomLeftRadius" => format!("{:?}", n.corners.radii.get(3)), + "strokeWeight" => format!("{:?}", (n.stroke.weight, n.stroke.sides)), + "strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" => format!("{:?}", n.stroke.sides), + "arcStart" | "arcSweep" | "arcInner" => match &n.kind { + NodeKind::Ellipse { start, sweep, inner } => format!( + "{}", + if field == "arcStart" { + start + } else if field == "arcSweep" { + sweep + } else { + inner + } + ), + _ => return None, + }, + "characters" => match &n.kind { + NodeKind::Text(t) => t.characters.clone(), + _ => return None, + }, + "paragraphSpacing" | "paragraphIndent" => match &n.kind { + NodeKind::Text(t) => format!("{}", if field == "paragraphSpacing" { t.paragraph_spacing } else { t.paragraph_indent }), + _ => return None, + }, + "fontFamily" => text(&|s| s.font_family.clone())?, + "fontStyle" => text(&|s| s.font_style.clone())?, + "fontWeight" => text(&|s| s.weight().to_string())?, + "fontSize" => text(&|s| s.font_size.to_string())?, + "lineHeight" => text(&|s| format!("{:?}", s.line_height))?, + "letterSpacing" => text(&|s| format!("{:?}", s.letter_spacing))?, + // shortcut: other layer-level fields keep their binding on edits; add a reader when one is bound in practice. + _ => return None, + }) +} + +/// Imported files (`.fig`, REST) keep a solid paint's alpha in its opacity, a bound paint's too, +/// while the bound colour brings its own alpha: take the variable's alpha back out of the opacity +/// (styles' paints and layers') before the bindings are first resolved, so it applies once. +pub(crate) fn unfold_imported_bound_alpha(doc: &mut Document) { + use super::lists::{BoundList, list_binding}; + fn alphas(vars: &Variables, bound: &BTreeMap, modes: &BTreeMap) -> Vec<(BoundList, usize, f32)> { + let found = bound.iter().filter_map(|(field, var)| match (list_binding(field)?, vars.resolve(var, modes)?) { + ((list @ (BoundList::Fills | BoundList::Strokes), i, "color"), VariableValue::Color(c)) if c.a > 0.0 && c.a < 1.0 => Some((list, i, c.a)), + _ => None, + }); + found.collect() + } + fn unfold(p: Option<&mut Paint>, a: f32) { + if let Some(p) = p + && matches!(p.kind, PaintKind::Solid { .. }) + { + p.opacity = (p.opacity / a).min(1.0); + } + } + let pending = doc.take_dirty(); + for i in 0..doc.styles.len() { + let found = doc.styles.get(i).map(|s| alphas(&doc.variables, &s.bound, &BTreeMap::new())).unwrap_or_default(); + if let Some(StyleKind::Paint { paints }) = doc.styles.get_mut(i).filter(|_| !found.is_empty()).map(|s| &mut s.kind) { + for (_, j, a) in found { + unfold(paints.get_mut(j), a); + } + } + } + let ids: Vec = doc.nodes().filter(|n| !n.bound.is_empty()).map(|n| n.id).collect(); + for id in ids { + let found = doc.node(id).map(|n| alphas(&doc.variables, &n.bound, &doc.modes_for(id))).unwrap_or_default(); + if found.is_empty() { + continue; + } + let Ok(n) = doc.get_mut(id) else { continue }; + for (list, j, a) in found { + unfold(if list == BoundList::Fills { n.fills.get_mut(j) } else { n.strokes.get_mut(j) }, a); + } + } + doc.take_dirty(); + doc.mark_dirty(pending); +} + /// Settle imported paint colours before the document is first shown. Other bindings can affect /// layout or component structure, which must be resolved through the normal edit pipeline. pub(crate) fn apply_paint_bindings_on_load(doc: &mut Document) { @@ -930,6 +1115,8 @@ pub(crate) fn apply_paint_bindings_on_load(doc: &mut Document) { doc.mark_dirty(pending); } +/// Re-apply the variable bindings whose field passes `include`, on the `only` nodes (every bound +/// node when `None`). Auto layout fields wait while their node has no auto layout. fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::BTreeSet>, include: impl Fn(&str) -> bool) { use super::lists::{BoundList, list_binding}; if doc.variables.variables.is_empty() { @@ -944,6 +1131,7 @@ fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::B let modes = doc.modes_for(id); let Some(n) = doc.node(id) else { continue }; let binds = n.bound.clone(); + let has_layout = n.layout.is_some(); let mut sets = serde_json::Map::new(); let mut effect_sets: Vec<(usize, String, VariableValue)> = Vec::new(); let mut paint_sets: Vec<(bool, usize, Option, Color)> = Vec::new(); @@ -953,6 +1141,10 @@ fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::B if !include(&field) { continue; } + // A binding never creates an auto layout: without one it waits for the next. + if !has_layout && super::props::LAYOUT_KEYS.contains(&field.as_str()) { + continue; + } let Some(val) = doc.variables.resolve(&var, &modes) else { continue }; // One list item's value: a paint's colour, an effect's or a layout grid's field. if let Some((list, i, prop)) = list_binding(&field) { @@ -1104,7 +1296,6 @@ pub fn document_colors(doc: &Document, page: NodeId) -> Vec<(Color, usize)> { stack.extend(n.children.iter().copied()); for paint in n.fills.iter().chain(n.strokes.iter()).filter(|p| p.visible) { if let Some(c) = paint.solid_color() { - let c = c.with_alpha(c.a * paint.opacity); counts.entry(c.to_rgba8()).or_insert((c, 0)).1 += 1; } } diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 3b861895a..a2f761155 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -18,6 +18,7 @@ fn style(s: &mut Session, p: &Value, f: impl Fn(&mut TextStyle)) -> Result Result = ids.into_iter().flat_map(|i| doc.subtree(i)).collect(); + let before = super::styles::bound_before(doc, &ids); + for d in ids { + if let Some(NodeKind::Text(t)) = doc.node_mut(d).map(|n| &mut n.kind) { + let n = t.char_count(); + t.restyle(0, n, &f); } } + super::styles::detach_edited(doc, &before); ok() } @@ -348,11 +351,13 @@ pub fn specs() -> Vec { let c = string(p, "characters").or_else(|| string(p, "text")).ok_or_else(|| bad("text.set", "`characters` required"))?; let ids = req_targets("text.set", s, p)?; let doc = s.doc_mut()?; + let before = super::styles::bound_before(doc, &ids); for i in ids { let mut m = Map::new(); m.insert("characters".into(), json!(c)); super::props::apply("text.set", doc, i, &m)?; } + super::styles::detach_edited(doc, &before); ok() }), spec!( @@ -364,6 +369,7 @@ pub fn specs() -> Vec { let ids = req_targets("text.style", s, p)?; let m = p.as_object().cloned().unwrap_or_default(); let doc = s.doc_mut()?; + let before = super::styles::bound_before(doc, &ids); for i in ids { let n = doc.get_mut(i)?; let NodeKind::Text(t) = &mut n.kind else { return Err(bad("text.style", format!("{i} is not text"))) }; @@ -377,6 +383,7 @@ pub fn specs() -> Vec { super::props::apply_text_style("text.style", t, a, b, k, v)?; } } + super::styles::detach_edited(doc, &before); ok() } ), @@ -474,5 +481,40 @@ pub fn specs() -> Vec { }) } ), + spec!( + "text.rescanFonts", + "Rescan installed fonts", + "{} walks the system font folders again, for fonts installed while the app runs; the open document's auto-sizing text in the families that gained faces (new ones, or styles installed since) fits them (text.refit) → {added: [families that were unknown], refitted: [ids]}", + true, + |s, _| { + let rescan = figcraft_text::rescan_system_fonts(); + let refitted = crate::fonts::refit_open(s, None, Some(&rescan.changed)); + Ok(json!({"added": rescan.added, "refitted": refitted})) + } + ), + spec!( + "text.loadFont", + "Load font file", + "{path | data (base64)} adds a font file (.ttf, .otf, .ttc, .otc; every face of a collection) to this session's fonts without installing it; the open document's auto-sizing text in its families fits it (text.refit) → {families, refitted: [ids]}", + true, + |s, p| { + let cmd = "text.loadFont"; + let bytes = if let Some(b64) = string(p, "data") { + use base64::Engine as _; + let b64 = b64.split_once(',').map(|(_, d)| d.to_string()).unwrap_or(b64); + base64::engine::general_purpose::STANDARD.decode(b64.trim()).map_err(|e| bad(cmd, format!("`data`: {e}")))? + } else if let Some(path) = string(p, "path") { + super::file::read_capped(&path, figcraft_text::fontdb::MAX_FONT_FILE)? + } else { + return Err(bad(cmd, "pass `path` or `data` (base64)")); + }; + let families = figcraft_text::load_font_bytes(bytes); + if families.is_empty() { + return Err(bad(cmd, "not a font file")); + } + let refitted = crate::fonts::refit_open(s, None, Some(&families)); + Ok(json!({"families": families, "refitted": refitted})) + } + ), ] } diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index 3e6dc69d6..53b62ee46 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -288,7 +288,11 @@ pub(crate) fn forget_variables(doc: &mut Document, gone: &BTreeSet) doc.variables.variables.retain(|v| !gone.contains(&v.id)); let users: Vec = doc.nodes().filter(|n| n.bound.values().any(|v| gone.contains(v))).map(|n| n.id).collect(); for id in users { - doc.get_mut(id)?.bound.retain(|_, v| !gone.contains(v)); + let n = doc.get_mut(id)?; + for (field, _) in n.bound.iter().filter(|(_, v)| gone.contains(*v)) { + super::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, false); + } + n.bound.retain(|_, v| !gone.contains(v)); } Ok(()) } diff --git a/crates/engine/src/cmd/variables_io.rs b/crates/engine/src/cmd/variables_io.rs index 99a786400..f3b9a820d 100644 --- a/crates/engine/src/cmd/variables_io.rs +++ b/crates/engine/src/cmd/variables_io.rs @@ -25,7 +25,7 @@ pub fn specs() -> Vec { spec!( "variable.import", "Import variables", - "{variables (the local variables JSON, as variable.export writes it: an object, JSON text or a path)} collections join this file's collection of the same name (values by mode name; new modes and variables are added, a variable of another type is skipped), others are added → {collections (added), variables (added), updated, warnings}", + "{variables (the local variables JSON, as variable.export writes it: an object, JSON text or a path; `variableCollections` and `variables` under `meta` or at the top level)} collections join this file's collection of the same name (values by mode name; new modes and variables are added, a variable of another type is skipped), others are added → {collections (added), variables (added), updated, warnings (also when it holds no variables, or fewer than its collections list)}", true, import ), diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 69b938e91..0592046eb 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -370,7 +370,25 @@ pub fn specs() -> Vec { spec!("pref.showDimensions", "Show dimensions on objects", "{on?}", false, |s, p| pref(s, p, |x| &mut x.show_dimensions)), spec!("pref.numberKeysOpacity", "Use number keys for opacity", "{on?}", false, |s, p| pref(s, p, |x| &mut x.number_keys_opacity)), spec!("pref.reduceMotion", "Reduce motion", "{on?}", false, |s, p| pref(s, p, |x| &mut x.reduce_motion)), + spec!( + "pref.uiScale", + "Interface size", + "{scale?: a size from 0.75 to 3 relative to the system's display scale, 1 being the system's own size, so 1.25 is 125 %, not 125 (outside the range it stops at the nearest end; \"auto\" is 1; omit: just read it)} → {scale}: how large the whole interface is drawn; the canvas zoom is separate", + false, + |s, p| { + match get(p, "scale") { + None => {} + Some(Value::String(v)) if v.trim().eq_ignore_ascii_case("auto") => s.prefs.ui_scale = 1.0, + Some(_) => { + let scale = req_num("pref.uiScale", p, "scale")?; + s.prefs.ui_scale = crate::clamp_ui_scale(scale).ok_or_else(|| bad("pref.uiScale", "`scale` must be a finite number"))?; + } + } + Ok(json!({"scale": s.prefs.ui_scale})) + } + ), spec!("pref.autoSave", "Save files automatically", "{on?}", false, |s, p| pref(s, p, |x| &mut x.auto_save)), + spec!("pref.startOnHome", "Start on the Home screen", "{on?}", false, |s, p| pref(s, p, |x| &mut x.start_on_home)), spec!("pref.nudge", "Nudge amount", "{small?, big?}", false, |s, p| { s.prefs.nudge_small = num(p, "small").unwrap_or(s.prefs.nudge_small).clamp(0.01, 1000.0); s.prefs.nudge_big = num(p, "big").unwrap_or(s.prefs.nudge_big).clamp(0.01, 10000.0); @@ -409,7 +427,7 @@ pub fn specs() -> Vec { spec!( "pref.language", "Interface language", - "{language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…; else English}", + "{language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|zh-Hans|…; else English}", false, |s, p| { let lang = req_string("pref.language", p, "language")?; @@ -522,3 +540,24 @@ pub fn specs() -> Vec { #[allow(dead_code)] fn unused(_: NodeId) {} + +#[cfg(test)] +mod start_on_home_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn start_on_home_is_a_preference_that_persists() { + let mut s = Session::new(); + assert!(!s.prefs.start_on_home); + assert_eq!(s.execute("pref.startOnHome", &json!({})).unwrap()["on"], true); + assert!(s.prefs.start_on_home); + // Saved with the other preferences and read back; older settings files without it read as off. + let saved: crate::Prefs = serde_json::from_value(serde_json::to_value(&s.prefs).unwrap()).unwrap(); + assert!(saved.start_on_home); + assert_eq!(s.execute("pref.startOnHome", &json!({"on": false})).unwrap()["on"], false); + let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap(); + assert!(!old.start_on_home); + } +} diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index 7d35229f1..0871f886d 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -256,7 +256,26 @@ fn stroke_color(n: &Node) -> Option { if !n.has_visible_stroke() { return None; } - n.strokes.iter().filter(|p| p.visible).find_map(|p| p.solid_color().map(|c| c.with_alpha(c.a * p.opacity))) + n.strokes.iter().filter(|p| p.visible).find_map(|p| p.solid_color()) +} + +/// The CSS borders a solid stroke makes: one `border`, or with individual stroke sides a border +/// on each side that has a weight. +fn css_borders(doc: &Document, n: &Node, t: &mut Tokens) -> Vec<(String, String)> { + let Some(c) = stroke_color(n) else { return Vec::new() }; + let Some(sides) = n.stroke.sides else { + let weight = tok(doc, n, &["strokeWeight"], format!("{}px", num(n.stroke.weight)), t); + return vec![("border".into(), format!("{weight} solid {}", tok(doc, n, &["stroke"], c.to_css(), t)))]; + }; + let color = tok(doc, n, &["stroke"], c.to_css(), t); + let named = ["top", "right", "bottom", "left"].into_iter().zip(crate::cmd::props::STROKE_SIDES); + named + .zip(sides) + .filter(|(_, weight)| *weight > 0.0) + .map(|((side, field), weight)| { + (format!("border-{side}"), format!("{} solid {color}", tok(doc, n, &[field], format!("{}px", num(weight)), t))) + }) + .collect() } fn drop_shadow(n: &Node) -> Option<&Shadow> { @@ -452,9 +471,8 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo PaintKind::Pattern(_) => put("background", "url(pattern.png) repeat".into()), } } - if let Some(c) = stroke_color(n) { - let weight = tok(doc, n, &["strokeWeight"], px(n.stroke.weight), t); - put("border", format!("{weight} solid {}", tok(doc, n, &["stroke"], c.to_css(), t))); + for (key, value) in css_borders(doc, n, t) { + put(&key, value); } let shadows: Vec = n .effects @@ -858,11 +876,16 @@ pub(crate) fn swift(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String, extra swift_layer(ctx, n, depth, out, extra); } +/// A layer name safe for a single-line `//` comment: line breaks become spaces. +fn one_line(name: &str) -> String { + name.replace(['\n', '\r', '\u{85}', '\u{2028}', '\u{2029}'], " ") +} + fn swift_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[String]) { let doc = ctx.doc; let pad = indent(depth); if ctx.too_deep(depth) { - out.push_str(&format!("{pad}// {} …\n", n.name)); + out.push_str(&format!("{pad}// {} …\n", one_line(&n.name))); return; } // Nested instances are calls; an instance swap parameter is a view. @@ -1035,7 +1058,7 @@ fn compose_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) { let doc = ctx.doc; let pad = indent(depth); if ctx.too_deep(depth) { - out.push_str(&format!("{pad}// {} …\n", n.name)); + out.push_str(&format!("{pad}// {} …\n", one_line(&n.name))); return; } // Nested instances are calls; an instance swap parameter is a composable. @@ -1168,4 +1191,26 @@ mod tests { assert!(kt.contains("Modifier.offset(7.dp, 9.dp)")); assert!(code_for(doc, NodeId(999_999), CodeLang::Css).is_empty()); } + + #[test] + fn too_deep_placeholder_keeps_a_multiline_layer_name_in_the_comment() { + let mut s = Session::new(); + let name = "deep\nevil()\r\u{2028}more()"; + let mut parent = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 50, "name": name})).unwrap()["id"]).unwrap(); + let root = parent; + for _ in 0..16 { + parent = + id_of(&s.execute("frame.create", &json!({"x": 1, "y": 1, "width": 40, "height": 40, "parent": parent, "name": name})).unwrap()["id"]) + .unwrap(); + } + let doc = s.doc().unwrap(); + for lang in [CodeLang::SwiftUi, CodeLang::Compose] { + let code = code_for(doc, root, lang); + assert!(code.contains("// deep evil() more() …\n"), "{code}"); + for line in code.lines() { + let t = line.trim_start(); + assert!(!t.starts_with("evil()") && !t.starts_with("more()"), "layer name leaked as code:\n{code}"); + } + } + } } diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs index 56efc5c9a..67259d030 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -285,11 +285,28 @@ pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId, root.dev_resources.clear(); // The main's interactions play on its instances in prototypes; the instance's own start empty. root.interactions.clear(); + // So do the variable modes the main sets (see `Document::inherited_modes`). + root.var_modes.clear(); doc.insert(root, parent, index)?; sync_instance(doc, id); Ok(id) } +/// Copy `id` within its own file under `parent` (at `index`), keeping its local transform. A file +/// has one main per component, so a main component's copy is an instance of it; a variant copied +/// into its set stays a new variant, and every other layer is deep-copied. +pub fn copy_in_file(doc: &mut Document, id: NodeId, parent: NodeId, index: Option) -> Result { + let n = doc.get(id)?; + let in_set = doc.node(parent).is_some_and(|p| matches!(p.kind, NodeKind::ComponentSet { .. })); + if !matches!(n.kind, NodeKind::Component { .. }) || in_set { + return doc.duplicate_into(id, parent, index); + } + let transform = n.transform; + let instance = create_instance_at(doc, id, parent, 0.0, 0.0, index)?; + doc.get_mut(instance)?.transform = transform; + Ok(instance) +} + /// Which part of the carried overrides to apply. #[derive(Clone, Copy, PartialEq)] enum Carry { diff --git a/crates/engine/src/components_behaviour_tests.rs b/crates/engine/src/components_behaviour_tests.rs index 15f660e1e..ff78f73b5 100644 --- a/crates/engine/src/components_behaviour_tests.rs +++ b/crates/engine/src/components_behaviour_tests.rs @@ -82,6 +82,179 @@ fn text(n: &Node) -> &TextData { n.kind.text().unwrap() } +#[test] +fn copying_a_main_component_pastes_a_linked_instance() { + for command in ["edit.paste", "edit.pasteOver", "edit.pasteReplace"] { + let mut s = Session::new(); + let k = kit(&mut s); + ex(&mut s, "select.set", json!({"ids": [k.card]})); + ex(&mut s, "edit.copy", json!({})); + if command != "edit.paste" { + ex(&mut s, "select.set", json!({"ids": [k.inst]})); + } + let before = undo_len(&s); + let pasted = crate::params::id_of(&ex(&mut s, command, json!({}))["ids"][0]).unwrap(); + assert_eq!(component_of(&s, pasted), k.card, "{command}"); + assert_eq!(node(&s, pasted).parent, Some(s.page().unwrap())); + assert_eq!(node(&s, pasted).source, Some(k.card)); + assert_eq!(undo_len(&s), before + 1); + ex(&mut s, "edit.undo", json!({})); + assert!(s.doc().unwrap().node(pasted).is_none()); + ex(&mut s, "edit.redo", json!({})); + assert_eq!(component_of(&s, pasted), k.card); + ex(&mut s, "node.set", json!({"id": k.title, "props": {"characters": "Updated"}})); + assert_eq!(chars(&s, copy_of(&s, pasted, k.title)), "Updated"); + healthy(&s); + } +} + +#[test] +fn copying_a_variant_pastes_an_instance_but_copying_its_set_keeps_the_components() { + let mut s = Session::new(); + let (set, _, hover) = button_set(&mut s); + ex(&mut s, "select.set", json!({"ids": [hover]})); + ex(&mut s, "edit.copy", json!({})); + let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({}))["ids"][0]).unwrap(); + assert_eq!(component_of(&s, pasted), hover); + assert_eq!(node(&s, pasted).name, node(&s, set).name); + let NodeKind::Instance { props, .. } = &node(&s, pasted).kind else { panic!("pasted variant is not an instance") }; + assert_eq!(chars(&s, layer(&s, pasted, "Label").unwrap()), "Hover"); + assert_eq!(props["Show dot"], true); + assert_eq!(node(&s, pasted).parent, Some(s.page().unwrap())); + + ex(&mut s, "select.set", json!({"ids": [set]})); + ex(&mut s, "edit.copy", json!({})); + let copied_set = crate::params::id_of(&ex(&mut s, "edit.paste", json!({}))["ids"][0]).unwrap(); + assert!(matches!(node(&s, copied_set).kind, NodeKind::ComponentSet { .. })); + assert_eq!(node(&s, copied_set).parent, Some(s.page().unwrap())); + assert_eq!(kids(&s, copied_set).len(), 2); + for variant in kids(&s, copied_set) { + assert!(matches!(node(&s, variant).kind, NodeKind::Component { .. })); + assert!(!kids(&s, set).contains(&variant)); + } + healthy(&s); +} + +#[test] +fn cut_components_and_components_copied_to_another_file_remain_components() { + for (copy, undo_cut, new_file) in [("edit.cut", false, false), ("edit.cut", true, false), ("edit.copy", false, true)] { + let mut s = Session::new(); + let k = kit(&mut s); + ex(&mut s, copy, json!({"ids": [k.card]})); + if undo_cut { + ex(&mut s, "edit.undo", json!({})); + } + if new_file { + ex(&mut s, "file.new", json!({})); + } + let page = s.page().unwrap(); + let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": page}))["ids"][0]).unwrap(); + assert!(matches!(node(&s, pasted).kind, NodeKind::Component { .. }), "{copy}, undo={undo_cut}, new_file={new_file}"); + assert_eq!(kids(&s, pasted).len(), 3); + healthy(&s); + } +} + +#[test] +fn copying_a_component_does_not_link_to_an_id_reused_after_undo() { + let mut s = Session::new(); + let frame = idv(&ex(&mut s, "frame.create", json!({"width": 120, "height": 60, "name": "Copied main"}))); + let main = idv(&ex(&mut s, "component.create", json!({"ids": [frame]}))); + ex(&mut s, "edit.copy", json!({"ids": [main]})); + s.undo().unwrap(); + s.undo().unwrap(); + let other = idv(&ex(&mut s, "frame.create", json!({"width": 300, "height": 200, "name": "Unrelated main"}))); + ex(&mut s, "component.create", json!({"ids": [other]})); + assert_eq!(other, main, "undo makes the original id available again"); + let page = s.page().unwrap(); + let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": page}))["ids"][0]).unwrap(); + assert_eq!(node(&s, pasted).name, "Copied main"); + assert_eq!((node(&s, pasted).width, node(&s, pasted).height), (120.0, 60.0)); + assert!(matches!(node(&s, pasted).kind, NodeKind::Component { .. })); + healthy(&s); +} + +#[test] +fn pasted_component_instances_keep_the_copied_transform_in_the_target_frame() { + let mut s = Session::new(); + let k = kit(&mut s); + ex(&mut s, "node.set", json!({"id": k.card, "props": {"rotation": 30}})); + let original = s.doc().unwrap().world_transform(k.card); + let frame = idv(&ex(&mut s, "frame.create", json!({"x": -100, "y": -100, "width": 1000, "height": 1000}))); + ex(&mut s, "edit.copy", json!({"ids": [k.card]})); + let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": frame}))["ids"][0]).unwrap(); + assert_eq!(component_of(&s, pasted), k.card); + for (actual, expected) in s.doc().unwrap().world_transform(pasted).as_coeffs().into_iter().zip(original.as_coeffs()) { + assert!((actual - expected).abs() < 1e-9); + } + assert_eq!(node(&s, pasted).parent, Some(frame)); + let before = s.doc().unwrap().clone(); + assert!(s.execute("edit.paste", &json!({"parent": k.card})).is_err()); + assert_eq!(s.doc().unwrap(), &before, "pasting into the main is refused atomically"); + healthy(&s); +} + +#[test] +fn duplicating_a_main_component_makes_a_linked_instance() { + for rename in [false, true] { + let mut s = Session::new(); + ex(&mut s, "pref.renameDuplicates", json!({"on": rename})); + let k = kit(&mut s); + let rect = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 300, "width": 50, "height": 50, "props": {"name": "Box"}}))); + let before = s.doc().unwrap().clone(); + let out = ex(&mut s, "edit.duplicate", json!({"ids": [k.card, rect]})); + let ids: Vec = out["ids"].as_array().unwrap().iter().filter_map(crate::params::id_of).collect(); + assert_eq!(s.selection(), ids); + let copy = *ids.iter().find(|i| matches!(node(&s, **i).kind, NodeKind::Instance { .. })).expect("the main's copy is an instance"); + assert_eq!(component_of(&s, copy), k.card); + assert_eq!(node(&s, copy).name, "Card", "instances keep their default name (rename={rename})"); + assert_eq!(node(&s, copy).parent, Some(s.page().unwrap())); + let (b, c) = (s.doc().unwrap().world_bounds(k.card).unwrap(), s.doc().unwrap().world_bounds(copy).unwrap()); + assert_eq!((c.x0, c.y0, c.width(), c.height()), (b.x1 + 100.0, b.y0, b.width(), b.height()), "a top-level frame's offset"); + let other = *ids.iter().find(|i| **i != copy).unwrap(); + assert!(matches!(node(&s, other).kind, NodeKind::Rectangle), "other layers are still copied"); + ex(&mut s, "node.set", json!({"id": k.title, "props": {"characters": "Updated"}})); + assert_eq!(chars(&s, copy_of(&s, copy, k.title)), "Updated", "the instance follows its main"); + ex(&mut s, "edit.undo", json!({})); + ex(&mut s, "edit.undo", json!({})); + assert_eq!(*s.doc().unwrap(), before, "undo restores exactly"); + healthy(&s); + } +} + +#[test] +fn alt_dragging_a_main_component_copies_an_instance() { + use crate::tools::{PointerEvent, PointerKind}; + let mut s = Session::new(); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "name": "Tile"}))); + let main = idv(&ex(&mut s, "component.create", json!({"ids": [f]}))); + let ev = |kind, x| PointerEvent { alt: true, ..PointerEvent::at(kind, x, 50.0, 1.0) }; + s.pointer(&ev(PointerKind::Down, 50.0)).unwrap(); + s.pointer(&ev(PointerKind::Move, 150.0)).unwrap(); + s.pointer(&ev(PointerKind::Move, 250.0)).unwrap(); + s.pointer(&ev(PointerKind::Up, 250.0)).unwrap(); + let [copy] = s.selection()[..] else { panic!("one copy selected") }; + assert_eq!(component_of(&s, copy), main); + assert_eq!(s.doc().unwrap().world_bounds(copy).unwrap().x0, 200.0); + assert_eq!(s.doc().unwrap().world_bounds(main).unwrap().x0, 0.0, "the main stays"); + healthy(&s); +} + +#[test] +fn duplicating_a_variant_adds_a_variant_to_its_set() { + let mut s = Session::new(); + let (set, _, hover) = button_set(&mut s); + let copy = crate::params::id_of(&ex(&mut s, "edit.duplicate", json!({"ids": [hover]}))["ids"][0]).unwrap(); + assert!(matches!(node(&s, copy).kind, NodeKind::Component { .. })); + assert_eq!(node(&s, copy).parent, Some(set)); + assert_eq!(kids(&s, set).len(), 3); + // A whole set duplicates as a new set of variants. + let set2 = crate::params::id_of(&ex(&mut s, "edit.duplicate", json!({"ids": [set]}))["ids"][0]).unwrap(); + assert!(matches!(node(&s, set2).kind, NodeKind::ComponentSet { .. })); + assert!(kids(&s, set2).iter().all(|v| matches!(node(&s, *v).kind, NodeKind::Component { .. }))); + healthy(&s); +} + #[test] fn overridable_properties_stay_through_main_edits_and_reset() { // (what, layer, group, override on the instance, the same property edited on the main, read, diff --git a/crates/engine/src/components_scale_tests.rs b/crates/engine/src/components_scale_tests.rs index c796dad8a..68465932b 100644 --- a/crates/engine/src/components_scale_tests.rs +++ b/crates/engine/src/components_scale_tests.rs @@ -747,3 +747,29 @@ fn alt_dropping_a_library_component_swaps_to_it() { assert!(doc.node(glyph).unwrap().overrides.is_empty()); let _ = std::fs::remove_dir_all(dir); } + +#[test] +fn a_fill_set_on_a_bound_instance_layer_detaches_it_as_an_override() { + // Without unbinding first: the explicit colour detaches the variable on the instance layer only, + // and stays through main edits until the override is reset. + let mut s = Session::new(); + let (chip, _) = bound_chip(&mut s); + let i = crate::params::id_of(&ex(&mut s, "instance.create", json!({"component": chip, "x": 200, "y": 0}))["id"]).unwrap(); + let bg = find_path(s.doc().unwrap(), i, &["Bg"]).unwrap(); + ex(&mut s, "node.set", json!({"ids": [bg], "props": {"fill": "#ff00ff"}})); + let n = s.doc().unwrap().node(bg).unwrap().clone(); + assert_eq!(n.fills[0].solid_color().unwrap().to_hex(), "FF00FF"); + assert!(!n.bound.contains_key("fill")); + assert!(n.overrides.contains("fills")); + let main_bg = find_path(s.doc().unwrap(), chip, &["Bg"]).unwrap(); + ex(&mut s, "node.set", json!({"ids": [main_bg], "props": {"height": 28}})); + ex(&mut s, "variable.set", json!({"variable": "Chip", "value": "#445566"})); + let doc = s.doc().unwrap(); + healthy(doc); + assert_eq!(doc.node(bg).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF00FF"); + assert!(doc.node(main_bg).unwrap().bound.contains_key("fill"), "the main keeps its binding"); + ex(&mut s, "component.resetOverride", json!({"id": bg, "group": "fills"})); + let n = s.doc().unwrap().node(bg).unwrap(); + assert!(n.bound.contains_key("fill")); + assert_eq!(n.fills[0].solid_color().unwrap().to_hex(), "445566"); +} diff --git a/crates/engine/src/convert_container_tests.rs b/crates/engine/src/convert_container_tests.rs index 620388d27..be338a82f 100644 --- a/crates/engine/src/convert_container_tests.rs +++ b/crates/engine/src/convert_container_tests.rs @@ -194,3 +194,26 @@ fn only_frames_on_the_page_or_in_a_section_become_sections() { assert!(err.contains("holds sections"), "{err}"); assert!(matches!(kind(&s, sec), NodeKind::Section { .. }), "nothing changed"); } + +#[test] +fn wrapping_errors_name_the_command_that_was_run() { + // Grouping, framing, auto layout, masks and booleans share one wrapping step; a refusal names + // the command that asked, not `object.group`. + let mut s = Session::new(); + let page = s.page().unwrap(); + for cmd in ["object.group", "object.frameSelection", "layout.add", "bool.union"] { + let err = s.execute(cmd, &json!({"ids": [page]})).unwrap_err().to_string(); + assert!(err.contains(&format!("`{cmd}`")), "{cmd}: {err}"); + } + let r = rect(&mut s, page, 0.0, 0.0); + let err = s.execute("object.mask", &json!({"ids": [page, r]})).unwrap_err().to_string(); + assert!(err.contains("`object.mask`"), "{err}"); + let section = id(ex(&mut s, "node.create", json!({"type": "section", "x": 300, "y": 0, "width": 100, "height": 100}))); + ex(&mut s, "node.set", json!({"ids": [section], "props": {"fills": ["#FFFFFF"], "strokes": ["#000000"], "strokeWeight": 1}})); + let err = s.execute("vector.outlineStroke", &json!({"ids": [section]})).unwrap_err().to_string(); + assert!(err.contains("`vector.outlineStroke`"), "{err}"); + let f = id(ex(&mut s, "frame.create", json!({"x": 100, "y": 0, "width": 100, "height": 100}))); + let inner = rect(&mut s, f, 10.0, 10.0); + let err = s.execute("object.wrapInSection", &json!({"ids": [inner, r]})).unwrap_err().to_string(); + assert!(err.contains("`object.wrapInSection`"), "{err}"); +} diff --git a/crates/engine/src/effects_tests.rs b/crates/engine/src/effects_tests.rs index be69fac20..607b77114 100644 --- a/crates/engine/src/effects_tests.rs +++ b/crates/engine/src/effects_tests.rs @@ -51,6 +51,32 @@ fn effect_set_edits_noise_texture_and_glass() { assert!(s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "glass", "depth": "deep"}})).is_err()); } +/// A key an effect doesn't have is refused, naming what it takes: serde dropped it without a word, +/// so a shadow given `offsetX`/`offsetY` (the names variable bindings use) kept the default offset. +#[test] +fn effects_refuse_keys_they_dont_have() { + let mut s = Session::new(); + let r = rect(&mut s); + let shadow = json!({"type": "dropShadow", "color": "#00000040", "offsetX": 0, "offsetY": 1, "radius": 3}); + let err = s.execute("node.set", &json!({"ids": [r], "props": {"effects": [shadow]}})).unwrap_err().to_string(); + assert!(err.contains("effects[0]") && err.contains("offsetX, offsetY") && err.contains("offset"), "{err}"); + assert!(s.doc().unwrap().node(r).unwrap().effects.is_empty(), "nothing stored"); + let err = s.execute("effect.add", &json!({"ids": [r], "effect": {"type": "layerBlur", "radious": 8}})).unwrap_err().to_string(); + assert!(err.contains("radious"), "{err}"); + s.execute("effect.add", &json!({"ids": [r], "type": "dropShadow"})).unwrap(); + let err = s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "dropShadow", "spred": 2}})).unwrap_err().to_string(); + assert!(err.contains("spred"), "{err}"); + // Every kind's own keys still go in, camelCase or snake_case, and so does what inspect reports. + for ty in ["innerShadow", "layerBlur", "backgroundBlur", "noise", "texture", "glass"] { + s.execute("effect.add", &json!({"ids": [r], "type": ty})).unwrap(); + } + let reported = crate::inspect::node_json(s.doc().unwrap(), r, 0)["effects"].clone(); + s.execute("node.set", &json!({"ids": [r], "props": {"effects": reported}})).unwrap(); + let camel = json!({"type": "dropShadow", "offset": [0, 1], "showBehind": true, "visible": true}); + let snake = json!({"type": "innerShadow", "offset": [0, 1], "show_behind": false}); + s.execute("node.set", &json!({"ids": [r], "props": {"effects": [camel, snake]}})).unwrap(); +} + #[test] fn scaling_scales_grain_and_glass() { let mut s = Session::new(); diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 8abeaee5d..709b34a71 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -62,7 +62,7 @@ impl Roots { Ok(resolved) if roots.iter().any(|r| resolved.starts_with(r)) => return Ok(resolved), Ok(_) => {} // Why a path can't be resolved says what exists there: only told for paths inside a root. - Err(why) if lexically_inside(path, &roots) => return Err(format!("automation path rejected: {asked}: {why}")), + Err(why) if inside_as_far_as_it_exists(path, &roots) => return Err(format!("automation path rejected: {asked}: {why}")), Err(_) => {} } if roots.is_empty() { @@ -79,9 +79,10 @@ impl Roots { } } -/// Whether `path`, made absolute with its `.` and `..` taken by name (no file system), lies in one -/// of `roots` (as resolved, or as shown without Windows' `\\?\` prefix). -fn lexically_inside(path: &Path, roots: &[PathBuf]) -> bool { +/// Whether `path` lies in one of `roots` once made absolute with its `.` and `..` taken by name and +/// its longest leading part that exists resolved, so links on the way are followed as the roots' +/// were (`/tmp/work/x` is inside root `/private/tmp/work`, a link out of a root leads out of it). +fn inside_as_far_as_it_exists(path: &Path, roots: &[PathBuf]) -> bool { let Ok(abs) = std::path::absolute(path) else { return false }; let mut lexical = PathBuf::new(); for c in abs.components() { @@ -93,7 +94,18 @@ fn lexically_inside(path: &Path, roots: &[PathBuf]) -> bool { c => lexical.push(c), } } - roots.iter().any(|r| lexical.starts_with(r) || lexical.starts_with(shown(r))) + let mut rest: Vec<&OsStr> = Vec::new(); + let mut at = lexical.as_path(); + let resolved = loop { + if let Ok(mut base) = std::fs::canonicalize(at) { + base.extend(rest.iter().rev()); + break base; + } + let (Some(name), Some(parent)) = (at.file_name(), at.parent()) else { return false }; + rest.push(name); + at = parent; + }; + roots.iter().any(|r| resolved.starts_with(r)) } /// Whether `path` lies in the app's own folder `folder`, both resolved (links and `..` followed); diff --git a/crates/engine/src/file_access_tests.rs b/crates/engine/src/file_access_tests.rs index a05ad88ce..ba55e1348 100644 --- a/crates/engine/src/file_access_tests.rs +++ b/crates/engine/src/file_access_tests.rs @@ -348,6 +348,10 @@ fn outside_paths_are_refused_alike_whatever_exists_there() { let mut outside = vec![f.p("outside/secret.txt"), f.p("outside/none.txt"), f.p("outside/missing/../x.txt"), f.p("nowhere/deeper/x.txt")]; if symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("outside/dangling.txt")) { outside.push(f.p("outside/dangling.txt")); + // Reached through a link in the root, it is outside all the same. + if symlink_dir(&f.0.join("outside"), &f.0.join("root/escape")) { + outside.push(f.p("root/escape/dangling.txt")); + } } for p in &outside { let e = refusal(p); @@ -359,6 +363,19 @@ fn outside_paths_are_refused_alike_whatever_exists_there() { } } +#[test] +fn a_root_given_through_a_link_tells_why_paths_in_it_are_refused() { + let f = Fixture::new("linked-root"); + if !symlink_dir(&f.0.join("root"), &f.0.join("via")) || !symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("root/dangling.txt")) { + eprintln!("skipped: this system doesn't let tests make symbolic links"); + return; + } + // Like `/tmp/work` on macOS, where `/tmp` leads to `/private/tmp`. + let r = Roots::new(&[], &[f.p("via")]).unwrap(); + let e = r.check(Path::new(&f.p("via/dangling.txt")), Access::Write).unwrap_err(); + assert!(e.contains("leads nowhere"), "{e}"); +} + #[test] fn only_resolved_paths_count_as_the_apps_own_folder() { let f = Fixture::new("own"); diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs index 3548124d5..672d4f2ca 100644 --- a/crates/engine/src/fonts.rs +++ b/crates/engine/src/fonts.rs @@ -1,13 +1,15 @@ //! Fonts a document names that this machine doesn't have (#752): text layers, their styled //! ranges, text styles and the string variables bound to a font family. Text in such a family -//! is drawn in a fallback font, so the user is told which ones are missing. +//! is drawn in a fallback font, so the user is told which ones are missing. Once such a font is +//! there, [`refit`] lets auto-sizing text measure its box in it again. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; -use figcraft_doc::{Document, NodeKind, StyleKind, VariableValue, Variables}; +use figcraft_doc::{Document, NodeId, NodeKind, StyleKind, VariableValue, Variables}; use serde_json::{Value, json}; use crate::cmd::CommandSpec; +use crate::params::{get, req_targets}; use crate::{Result, Session, spec}; /// A family the document names that no installed or built-in font provides. @@ -79,20 +81,78 @@ fn string_values(vars: &Variables, id: &str) -> Vec { out } +/// Auto-sizing text layers measure their boxes again: a box measured while its family was missing +/// (in a fallback font) follows the real face once that's installed or loaded. Only layers under +/// `under` (every layer: `None`) that use one of `families` (any family: `None`). Returns the +/// layers whose size changed. +pub fn refit(doc: &mut Document, under: Option<&[NodeId]>, families: Option<&[String]>) -> Vec { + let want: Option> = families.map(|f| f.iter().map(|x| x.trim().to_lowercase()).collect()); + let scope: Option> = under.map(|ids| ids.iter().flat_map(|i| doc.subtree(*i)).collect()); + let ids: Vec = doc + .nodes() + .filter(|n| scope.as_ref().is_none_or(|s| s.contains(&n.id))) + .filter(|n| { + let NodeKind::Text(t) = &n.kind else { return false }; + let uses = |w: &BTreeSet| { + w.contains(&t.style.font_family.trim().to_lowercase()) + || t.spans().iter().any(|(_, _, st)| w.contains(&st.font_family.trim().to_lowercase())) + }; + want.as_ref().is_none_or(uses) + && figcraft_text::auto_size(n).is_some_and(|(w, h)| (w - n.width).abs() > 1e-6 || (h - n.height).abs() > 1e-6) + }) + .map(|n| n.id) + .collect(); + if !ids.is_empty() { + let before = doc.clone(); + doc.mark_dirty(ids.iter().copied()); + crate::normalize(doc, &before); + } + ids +} + +/// [`refit`] in the open document, if there is one. +pub(crate) fn refit_open(s: &mut Session, under: Option<&[NodeId]>, families: Option<&[String]>) -> Vec { + match s.doc_mut() { + Ok(doc) => refit(doc, under, families), + Err(_) => Vec::new(), + } +} + +/// `text.refit`: the layers `ids` names (an `ids` that isn't a list of layers is refused), else the +/// whole document. +fn refit_text(s: &mut Session, p: &Value) -> Result { + let under = match get(p, "ids") { + None | Some(Value::Null) => None, + Some(_) => Some(req_targets("text.refit", s, p)?), + }; + s.doc()?; + Ok(json!({"refitted": refit_open(s, under.as_deref(), None)})) +} + fn missing_fonts(s: &mut Session, _: &Value) -> Result { let families: Vec = missing(s.doc()?).into_iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect(); s.request_ui("dialog.missingFonts", json!({})); Ok(json!({"families": families})) } +/// The font commands: `text.missingFonts` and `text.refit`. pub fn specs() -> Vec { - vec![spec!( - "text.missingFonts", - "Missing fonts", - "{} fonts the open document names that aren't installed (text layers, styled ranges, text styles, font family variables); the app shows them → {families: [{family, layers}]}", - false, - missing_fonts - )] + vec![ + spec!( + "text.missingFonts", + "Missing fonts", + "{} fonts the open document names that aren't installed (text layers, styled ranges, text styles, font family variables); the app shows them → {families: [{family, layers}]}", + false, + missing_fonts + ), + spec!( + "text.refit", + "Fit text to fonts", + "{ids? (these layers and the text in them; default: the whole document)} auto-sizing text measures its box again, e.g. a box measured in a fallback font while its family was missing → {refitted: [ids whose size changed]}", + true, + refit_text + ), + ] } #[cfg(test)] diff --git a/crates/engine/src/fonts_tests.rs b/crates/engine/src/fonts_tests.rs index a6e2d213f..0fe6dcb3f 100644 --- a/crates/engine/src/fonts_tests.rs +++ b/crates/engine/src/fonts_tests.rs @@ -124,3 +124,92 @@ fn needs_an_open_document() { let mut s = Session::empty(); assert!(s.execute("text.missingFonts", &json!({})).is_err()); } + +/// Make `id`'s box stale, as if it was measured in another font: `dw` wider than its content. +fn stale(s: &mut Session, id: figcraft_doc::NodeId, dw: f64) -> f64 { + let doc = s.doc_mut().unwrap(); + let n = doc.get_mut(id).unwrap(); + let want = figcraft_text::auto_size(n).unwrap().0; + n.width = want + dw; + doc.take_dirty(); + want +} + +/// The width of layer `id`. +fn width(s: &Session, id: figcraft_doc::NodeId) -> f64 { + s.doc().unwrap().node(id).unwrap().width +} + +/// The ids a refit reports. +fn refitted(r: &Value) -> Vec { + r["refitted"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect() +} + +/// Stale boxes fit their text again, by family, by layer or everywhere; fixed boxes keep theirs. +#[test] +fn refit_measures_stale_text_boxes_again() { + let mut s = Session::new(); + let a = text(&mut s, "FigCraft Refit A"); + let b = text(&mut s, "FigCraft Refit B"); + let fixed = text(&mut s, "FigCraft Refit A"); + if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(fixed).unwrap().kind { + t.auto_resize = figcraft_doc::TextAutoResize::None; + } + s.execute("select.none", &json!({})).unwrap(); + let want_a = stale(&mut s, a, -30.0); + let want_b = stale(&mut s, b, 25.0); + s.doc_mut().unwrap().get_mut(fixed).unwrap().width = 7.0; + s.doc_mut().unwrap().take_dirty(); + // Only the families asked for. + let only_a = super::refit(s.doc_mut().unwrap(), None, Some(&["figcraft refit a".to_string()])); + assert_eq!(only_a, vec![a]); + assert_eq!(width(&s, a), want_a); + assert_eq!(width(&s, b), want_b + 25.0); + // Only the layers asked for, then everything; a fixed box keeps its size. + stale(&mut s, a, 12.0); + assert!(refitted(&s.execute("text.refit", &json!({"ids": [b]})).unwrap()) == vec![b.0]); + assert_eq!((width(&s, a), width(&s, b)), (want_a + 12.0, want_b)); + assert!(refitted(&s.execute("text.refit", &json!({})).unwrap()) == vec![a.0]); + assert_eq!(width(&s, a), want_a); + assert_eq!(width(&s, fixed), 7.0); + // Nothing stale: nothing changes. + assert!(refitted(&s.execute("text.refit", &json!({})).unwrap()).is_empty()); + assert!(s.execute("text.refit", &json!({"ids": ["nope"]})).is_err()); + // An `ids` that isn't a list is refused, not taken as the whole document. + stale(&mut s, a, 9.0); + assert!(s.execute("text.refit", &json!({"ids": a.0.to_string()})).is_err()); + assert_eq!(width(&s, a), want_a + 9.0); + assert!(Session::empty().execute("text.refit", &json!({})).is_err()); +} + +/// Text inside an instance is refitted too. +#[test] +fn refit_reaches_text_in_instances() { + let mut s = Session::new(); + let t = text(&mut s, "FigCraft Refit C"); + let comp = id_of(&s.execute("component.create", &json!({"ids": [t]})).unwrap()["id"]).unwrap(); + let inst = id_of(&s.execute("instance.create", &json!({"component": comp, "x": 300, "y": 0})).unwrap()["id"]).unwrap(); + let copy = s.doc().unwrap().node(inst).unwrap().children[0]; + let want = stale(&mut s, copy, -20.0); + let r = s.execute("text.refit", &json!({"ids": [inst]})).unwrap(); + assert_eq!(refitted(&r), vec![copy.0]); + assert_eq!(width(&s, copy), want); +} + +/// `text.loadFont` refuses what isn't a font file; `text.rescanFonts` reports what it added. +#[test] +fn fonts_load_from_files_and_rescans_report_new_families() { + let mut s = Session::new(); + assert!(s.execute("text.loadFont", &json!({})).is_err()); + assert!(s.execute("text.loadFont", &json!({"data": "bm90IGEgZm9udA=="})).is_err(), "not a font file"); + assert!(s.execute("text.loadFont", &json!({"data": "%%%"})).is_err()); + assert!(s.execute("text.loadFont", &json!({"path": "no-such-dir/x.ttf"})).is_err()); + if let Some(f) = figcraft_text::CRAFT_FONTS.first() { + use base64::Engine as _; + let data = base64::engine::general_purpose::STANDARD.encode(f.bytes); + let r = s.execute("text.loadFont", &json!({"data": data})).unwrap(); + assert_eq!(r["families"].as_array().map(Vec::len), Some(1)); + } + let r = s.execute("text.rescanFonts", &json!({})).unwrap(); + assert!(r["added"].is_array()); +} diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 88bc015ad..a6973f013 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -165,6 +165,10 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { o.insert("operandOperation".into(), json!(op)); } } + // Layer data written with `node.set {props: {pluginData}}` (pages too). + if !n.plugin_data.is_empty() { + o.insert("pluginData".into(), json!(n.plugin_data)); + } match &n.kind { NodeKind::Text(t) => { o.insert("characters".into(), json!(t.characters)); diff --git a/crates/engine/src/inspect_behaviour_tests.rs b/crates/engine/src/inspect_behaviour_tests.rs index a52cac86f..c4bcc6be6 100644 --- a/crates/engine/src/inspect_behaviour_tests.rs +++ b/crates/engine/src/inspect_behaviour_tests.rs @@ -214,6 +214,26 @@ fn css_for_common_layers() { } } +/// Individual stroke sides are borders on those sides only: a header with a divider under it isn't +/// boxed in, and a side bound to a variable names it. +#[test] +fn css_borders_follow_individual_stroke_sides() { + let mut s = Session::new(); + let r = rect(&mut s, 0.0, 0.0, 100.0, 50.0, None); + set(&mut s, &[r], json!({"strokes": ["#000000"], "strokeWeight": 1, "strokeSides": [0, 0, 2, 0]})); + let c = css(&mut s, r); + assert!(c.contains("border-bottom: 2px solid #000000;"), "{c}"); + assert!(!c.contains("border:") && !c.contains("border-top") && !c.contains("border-right") && !c.contains("border-left"), "{c}"); + + let col = ex(&mut s, "variable.createCollection", json!({"name": "Borders"})); + let col = col["id"].as_str().unwrap().to_string(); + ex(&mut s, "variable.create", json!({"collection": col, "name": "border/top", "type": "number", "value": 3})); + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "strokeTopWeight", "variable": "border/top"})); + let c = css(&mut s, r); + assert!(c.contains("border-top: var(--border-top, 3px) solid #000000;"), "{c}"); + assert!(c.contains("border-bottom: 2px solid #000000;"), "{c}"); +} + #[test] fn native_code_for_a_simple_layer() { let mut s = Session::new(); diff --git a/crates/engine/src/instance_modes_tests.rs b/crates/engine/src/instance_modes_tests.rs new file mode 100644 index 000000000..79007622d --- /dev/null +++ b/crates/engine/src/instance_modes_tests.rs @@ -0,0 +1,128 @@ +//! Variable modes on instances: an instance is on the modes its main component sets, under the +//! ones set on the instance itself, so switching variants or changing the main's mode reaches it. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; +use figcraft_doc::NodeId; + +fn run(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn id(v: Value) -> NodeId { + id_of(&v["id"]).unwrap() +} + +fn fill(s: &Session, n: NodeId) -> String { + s.doc().unwrap().node(n).unwrap().fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default() +} + +fn set_mode(s: &mut Session, n: NodeId, mode: Value) { + run(s, "variable.setMode", json!({"ids": [n], "collection": "Tone", "mode": mode})); +} + +/// A "Tone" collection whose variable `c` is red in mode A and blue in mode B. +fn tone(s: &mut Session) { + run(s, "variable.createCollection", json!({"name": "Tone", "modes": ["A", "B"]})); + run(s, "variable.create", json!({"collection": "Tone", "name": "c", "type": "color", "values": {"A": "#FF0000", "B": "#0000FF"}})); +} + +/// A component filled from `c`, on `mode` when one is given. +fn swatch(s: &mut Session, name: &str, mode: Option<&str>) -> NodeId { + let f = id(run(s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 40, "name": name}))); + run(s, "variable.bind", json!({"ids": [f], "field": "fill", "variable": "c"})); + if let Some(m) = mode { + set_mode(s, f, json!(m)); + } + id(run(s, "component.create", json!({"ids": [f]}))) +} + +/// A set "Kind" whose variants A and B each set their own Tone mode. +fn kinds(s: &mut Session) -> (NodeId, NodeId) { + tone(s); + let a = swatch(s, "Kind=A", Some("A")); + let b = swatch(s, "Kind=B", Some("B")); + let set = id(run(s, "component.combineVariants", json!({"ids": [a, b]}))); + (set, b) +} + +fn instance(s: &mut Session, component: NodeId) -> NodeId { + id(run(s, "instance.create", json!({"component": component, "x": 200, "y": 0}))) +} + +fn switch(s: &mut Session, i: NodeId, kind: &str) { + run(s, "instance.setProperties", json!({"ids": [i], "properties": {"Kind": kind}})); +} + +#[test] +fn switching_variants_takes_the_new_variants_mode() { + let mut s = Session::new(); + let (set, _) = kinds(&mut s); + let i = instance(&mut s, set); + assert_eq!(fill(&s, i), "FF0000"); + switch(&mut s, i, "B"); + assert_eq!(fill(&s, i), "0000FF", "variant B sets mode B"); + switch(&mut s, i, "A"); + assert_eq!(fill(&s, i), "FF0000"); + assert!(s.doc().unwrap().node(i).unwrap().var_modes.is_empty(), "the instance sets no mode of its own"); +} + +#[test] +fn instances_follow_a_mode_their_main_sets_later() { + let mut s = Session::new(); + tone(&mut s); + let main = swatch(&mut s, "Chip", None); + let i = instance(&mut s, main); + assert_eq!(fill(&s, i), "FF0000", "no mode set: the collection's default"); + set_mode(&mut s, main, json!("B")); + assert_eq!(fill(&s, i), "0000FF"); + set_mode(&mut s, main, Value::Null); + assert_eq!(fill(&s, i), "FF0000", "the main back on Auto"); +} + +#[test] +fn a_mode_set_on_the_instance_wins_and_stays_through_variant_switches() { + let mut s = Session::new(); + let (set, _) = kinds(&mut s); + let i = instance(&mut s, set); + set_mode(&mut s, i, json!("B")); + assert_eq!(fill(&s, i), "0000FF"); + for kind in ["B", "A"] { + switch(&mut s, i, kind); + assert_eq!(fill(&s, i), "0000FF", "own mode B on variant {kind}"); + } + set_mode(&mut s, i, Value::Null); + assert_eq!(fill(&s, i), "FF0000", "back on Auto: variant A's mode"); +} + +#[test] +fn a_main_mode_is_closer_than_the_frame_around_the_instance() { + let mut s = Session::new(); + let (_, b) = kinds(&mut s); + let frame = id(run(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 200, "height": 100}))); + set_mode(&mut s, frame, json!("A")); + let i = id(run(&mut s, "instance.create", json!({"component": b, "x": 10, "y": 10, "parent": frame}))); + assert_eq!(fill(&s, i), "0000FF", "the main's mode B, not the frame's A"); + // Nested: an outer component on mode A holding an instance of B. + let outer_frame = id(run(&mut s, "frame.create", json!({"x": 0, "y": 500, "width": 100, "height": 100, "name": "Outer"}))); + run(&mut s, "instance.create", json!({"component": b, "x": 0, "y": 0, "parent": outer_frame})); + set_mode(&mut s, outer_frame, json!("A")); + let outer = id(run(&mut s, "component.create", json!({"ids": [outer_frame]}))); + let copy = instance(&mut s, outer); + let nested = s.doc().unwrap().node(copy).unwrap().children[0]; + assert_eq!(fill(&s, nested), "0000FF"); +} + +#[test] +fn detaching_keeps_the_mode_it_took_from_its_main() { + let mut s = Session::new(); + let (set, b) = kinds(&mut s); + let i = instance(&mut s, set); + switch(&mut s, i, "B"); + run(&mut s, "component.detach", json!({"ids": [i]})); + set_mode(&mut s, b, json!("A")); + assert_eq!(fill(&s, i), "0000FF", "the detached frame keeps mode B"); + assert_eq!(s.doc().unwrap().node(i).unwrap().var_modes.len(), 1); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index af0f555e6..0871eebf4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -49,6 +49,7 @@ pub mod proto_run; pub mod rename; pub mod rotation_origin; pub mod selection_colors; +pub mod selection_reorder; pub mod selection_spacing; pub mod shape_handles; pub mod shapebuilder; @@ -603,6 +604,23 @@ impl DocState { } } +/// The smallest and largest interface size, relative to the system's display scale (1.0 is the +/// system's own size). +pub const UI_SCALE_MIN: f64 = 0.75; +pub const UI_SCALE_MAX: f64 = 3.0; + +/// `scale` kept between [`UI_SCALE_MIN`] and [`UI_SCALE_MAX`]; not a finite number: `None`. +pub fn clamp_ui_scale(scale: f64) -> Option { + scale.is_finite().then(|| scale.clamp(UI_SCALE_MIN, UI_SCALE_MAX)) +} + +/// Reads the saved interface size. Anything but a number reads as 1 (the system's size) and a +/// number out of range is clamped, so a settings file edited by hand never costs the other +/// preferences. +fn read_ui_scale<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result { + Ok(Value::deserialize(d)?.as_f64().and_then(clamp_ui_scale).unwrap_or(1.0)) +} + /// App-wide preferences that commands read (nudge amounts, behaviours). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] @@ -630,9 +648,16 @@ pub struct Prefs { /// Reduce motion: the interface's own animations are off (the system's setting turns them off /// too). Prototype transitions still play. pub reduce_motion: bool, + /// How large the whole interface is drawn, relative to the system's display scale: 1.0 is the + /// system's own size, 1.25 a quarter larger (kept between [`UI_SCALE_MIN`] and + /// [`UI_SCALE_MAX`]). The canvas zoom is separate. + #[serde(deserialize_with = "read_ui_scale")] + pub ui_scale: f64, /// Files that have a path are saved by themselves a few seconds after they change (the app's /// timer; off: only File ▸ Save writes them). pub auto_save: bool, + /// The desktop app starts on the Home screen instead of in a new file. + pub start_on_home: bool, /// `figcraft://` invite links open this app (`collab.registerLinks`); `None`: on for installed /// builds, off for development builds. pub invite_links: Option, @@ -698,7 +723,9 @@ impl Default for Prefs { language: "system".into(), user_name: cmd::comment::DEFAULT_AUTHOR.into(), reduce_motion: false, + ui_scale: 1.0, auto_save: false, + start_on_home: false, invite_links: None, plugins: Vec::new(), plugin_settings: BTreeMap::new(), @@ -735,6 +762,8 @@ pub struct Clipboard { /// The page-space bounds of the copied selection. pub bounds: Option, pub source_doc: u64, + /// Copied component/set roots, excluding cuts and originals discarded by undo (ids are reused). + pub copied_components: std::collections::BTreeSet, /// The document copied from (pastes into other files follow its mains) and its file path. pub source: Option>, pub source_path: Option, @@ -1098,8 +1127,11 @@ impl Session { let Some(it) = ds.interaction.clone() else { return Err(EngineError::Other("no gesture in progress".into())) }; let mut doc = (*it.doc).clone(); let mut sel = it.selection.clone(); + // What the gesture changes on the selected layers is an explicit edit. + let before = cmd::styles::bound_before(&doc, &it.selection); let res = f(&mut doc, &mut sel); if res.is_ok() { + cmd::styles::detach_edited(&mut doc, &before); normalize(&mut doc, &it.doc); // The same preview again (a handle held still) keeps the document: a new one would // re-render the canvas for nothing. @@ -1166,6 +1198,13 @@ impl Session { } ds.clean_selection(); ds.revision += 1; + let (uid, doc) = (ds.uid, ds.doc.clone()); + if self.clipboard.source_doc == uid { + self.clipboard.copied_components.retain(|id| { + doc.node(*id) + .is_some_and(|n| matches!(n.kind, figcraft_doc::NodeKind::Component { .. } | figcraft_doc::NodeKind::ComponentSet { .. })) + }); + } Ok(true) } @@ -1460,21 +1499,35 @@ fn affects_layout(before: &Document, doc: &Document, id: NodeId) -> bool { } /// The layers whose variable bindings an edit may have changed: every bound layer when a variable -/// changed, else the touched layers and everything below those that moved, are new or chose -/// another variable mode (modes are inherited). +/// changed, else the touched layers and everything below those that moved, are new, chose +/// another variable mode or became an instance of another main (modes are inherited, an +/// instance's from its main too), and the instances of a main that chose another mode. fn binding_scope(doc: &Document, before: &Document, touched: &BTreeSet) -> Option> { if doc.variables != before.variables { return None; } + let main = |n: &Node| match n.kind { + figcraft_doc::NodeKind::Instance { component, .. } => Some(component), + _ => None, + }; let mut out = BTreeSet::new(); for &id in touched { let Some(n) = doc.node(id) else { continue }; - let inherited = before.node(id).is_none_or(|b| b.var_modes != n.var_modes || b.parent != n.parent); - if inherited { + let Some(b) = before.node(id) else { + out.extend(doc.subtree(id)); + continue; + }; + let modes = b.var_modes != n.var_modes; + if modes || b.parent != n.parent || main(b) != main(n) { out.extend(doc.subtree(id)); } else { out.insert(id); } + if modes && matches!(n.kind, figcraft_doc::NodeKind::Component { .. }) { + for i in components::instances_of(doc, id) { + out.extend(doc.subtree(i)); + } + } } Some(out) } @@ -1554,6 +1607,8 @@ mod binding_lists_tests; #[cfg(test)] mod booleans_tests; #[cfg(test)] +mod bound_alpha_tests; +#[cfg(test)] mod branch_tests; #[cfg(test)] mod branch_versions_tests; @@ -1650,6 +1705,8 @@ mod import_journal_tests; #[cfg(test)] mod inspect_behaviour_tests; #[cfg(test)] +mod instance_modes_tests; +#[cfg(test)] mod instance_resizing_tests; #[cfg(test)] mod interaction_rollback_tests; @@ -1718,6 +1775,8 @@ mod pixel_preview_tests; #[cfg(test)] mod playground_tests; #[cfg(test)] +mod plugin_data_tests; +#[cfg(test)] mod plugin_directory_tests; #[cfg(test)] mod plugin_panels_tests; @@ -1744,6 +1803,8 @@ mod proto_run_tests; #[cfg(test)] mod proto_tail_tests; #[cfg(test)] +mod rest_slot_tests; +#[cfg(test)] mod retained_walk_tests; #[cfg(test)] mod rotated_guides_tests; @@ -1816,6 +1877,8 @@ mod variables_behaviour_tests; #[cfg(test)] mod variables_editor_tests; #[cfg(test)] +mod variables_import_shapes_tests; +#[cfg(test)] mod variables_io_tests; #[cfg(test)] mod vector_behaviour_tests; @@ -1832,5 +1895,7 @@ mod view_behaviour_tests; #[cfg(test)] mod widgets_tests; +#[cfg(test)] +mod selection_reorder_tests; #[cfg(test)] mod selection_spacing_tests; diff --git a/crates/engine/src/live.rs b/crates/engine/src/live.rs index 4001b1563..cd8a0f03b 100644 --- a/crates/engine/src/live.rs +++ b/crates/engine/src/live.rs @@ -81,11 +81,15 @@ pub const PEOPLE: [&str; 8] = ["#0D99FF", "#9747FF", "#14AE5C", "#F24822", "#D97 /// `#RRGGBB` → (r, g, b); `None` for anything else. pub fn parse_color(s: &str) -> Option<(u8, u8, u8)> { - let hex = s.strip_prefix('#').filter(|h| h.len() == 6 && h.is_ascii())?; + let hex = s.strip_prefix('#').filter(|h| h.len() == 6 && h.bytes().all(|b| b.is_ascii_hexdigit()))?; let byte = |i: usize| hex.get(i..i + 2).and_then(|b| u8::from_str_radix(b, 16).ok()); Some((byte(0)?, byte(2)?, byte(4)?)) } +/// Why a join failed when a message from the host is over this app's size limit (`{mb}`: the +/// limit in MB). The UI translates it. +pub const TOO_LARGE_TO_JOIN: &str = "This file is too large to join here (up to {mb} MB)"; + /// The connection to a live session. #[derive(Clone, Debug, Default, PartialEq, Serialize)] #[serde(rename_all = "camelCase", tag = "state")] diff --git a/crates/engine/src/live_tests.rs b/crates/engine/src/live_tests.rs index cacc4f378..5cdccf698 100644 --- a/crates/engine/src/live_tests.rs +++ b/crates/engine/src/live_tests.rs @@ -507,3 +507,11 @@ fn hosting_commands_check_what_they_are_given() { assert!(e.contains("join a file"), "{e}"); assert!(s.execute("collab.report", &json!({"reason": " \n "})).unwrap_err().to_string().contains("few words")); } + +#[test] +fn parse_color_rejects_signs_and_non_hex() { + use crate::live::parse_color; + assert_eq!(parse_color("#+1+2+3"), None); + assert_eq!(parse_color("#-1-2-3"), None); + assert_eq!(parse_color("#0D99FF"), Some((13, 153, 255))); +} diff --git a/crates/engine/src/plugin_data_tests.rs b/crates/engine/src/plugin_data_tests.rs new file mode 100644 index 000000000..076786b79 --- /dev/null +++ b/crates/engine/src/plugin_data_tests.rs @@ -0,0 +1,30 @@ +//! Layer data (`pluginData`) reads back through `node.get` and `document.inspect` (#879): what +//! `node.set` writes on a layer or a page comes back as `{key: value}`, a removed key goes, and a +//! layer without any reports none. + +use crate::Session; +use crate::components::tests::{ex, idv}; +use serde_json::json; + +#[test] +fn node_get_reports_the_layer_data_node_set_wrote() { + let mut s = Session::new(); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100}))); + let r = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))); + let page = s.ds().unwrap().page; + assert!(ex(&mut s, "node.get", json!({"id": f})).get("pluginData").is_none(), "no data, no field"); + ex(&mut s, "node.set", json!({"ids": [f], "props": {"pluginData": {"com.example.sync": "{\"v\":1}", "n": 2}}})); + ex(&mut s, "node.set", json!({"ids": [page], "props": {"pluginData": {"com.example.sync": "page"}}})); + let got = ex(&mut s, "node.get", json!({"id": f}))["pluginData"].clone(); + assert_eq!(got, json!({"com.example.sync": "{\"v\":1}", "n": "2"}), "values read back as the strings stored"); + assert_eq!(ex(&mut s, "node.get", json!({"id": page}))["pluginData"], json!({"com.example.sync": "page"}), "pages keep data too"); + assert!(ex(&mut s, "node.get", json!({"id": r})).get("pluginData").is_none(), "other layers report none"); + // The document tree reports it as well (the page and its children). + let tree = crate::inspect::document(&s, 2, false); + assert_eq!(tree["pages"][0]["pluginData"], json!({"com.example.sync": "page"})); + // A null removes a key; undo brings it back. + ex(&mut s, "node.set", json!({"ids": [f], "props": {"pluginData": {"n": null}}})); + assert_eq!(ex(&mut s, "node.get", json!({"id": f}))["pluginData"], json!({"com.example.sync": "{\"v\":1}"})); + ex(&mut s, "edit.undo", json!({})); + assert_eq!(ex(&mut s, "node.get", json!({"id": f}))["pluginData"]["n"], "2"); +} diff --git a/crates/engine/src/rest_slot_tests.rs b/crates/engine/src/rest_slot_tests.rs new file mode 100644 index 000000000..e272c3a70 --- /dev/null +++ b/crates/engine/src/rest_slot_tests.rs @@ -0,0 +1,91 @@ +//! Slots opened from REST file JSON: instances keep their own slot content through edits and +//! moves of the mains, and through saving. + +use crate::Session; +use crate::components::tests::{ex, healthy, idv}; +use crate::components::*; +use base64::Engine as _; +use figcraft_doc::*; +use serde_json::{Value, json}; + +fn at(x: f64, y: f64) -> Value { + json!([[1, 0, x], [0, 1, y]]) +} + +fn text(id: &str, s: &str) -> Value { + json!({"id": id, "name": s, "type": "TEXT", "characters": s, "relativeTransform": at(0.0, 0.0), "size": {"x": 100, "y": 20}}) +} + +/// A Chip main, a Container main with an empty "Slot", and an instance of Container whose slot +/// holds a text and a chip. +fn file() -> Value { + let slot = |id: &str, kids: Value| json!({"id": id, "name": "Slot", "type": "SLOT", "relativeTransform": at(10.0, 40.0), "size": {"x": 280, "y": 100}, "children": kids}); + let guid = |l: u32| json!({"guid": {"sessionID": 5, "localID": l}}); + let chip = json!({"id": "2:3", "name": "Chip", "type": "INSTANCE", "componentId": "1:9", "relativeTransform": at(0.0, 30.0), "size": {"x": 40, "y": 20}, + "children": [{"id": "I2:3;1:10", "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}}]}); + json!({"name": "Slots", "document": {"id": "0:0", "type": "DOCUMENT", "children": [{"id": "0:1", "name": "Page", "type": "CANVAS", "children": [ + {"id": "1:9", "name": "Chip", "type": "COMPONENT", "relativeTransform": at(0.0, 400.0), "size": {"x": 40, "y": 20}, "children": [ + {"id": "1:10", "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}} + ]}, + {"id": "1:2", "name": "Container", "type": "COMPONENT", "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 160}, + "componentPropertyDefinitions": {"Slot#1:3": {"type": "SLOT", "defaultValue": guid(1)}}, + "children": [text("1:4", "Title"), slot("1:5", json!([]))]}, + {"id": "2:1", "name": "Container", "type": "INSTANCE", "componentId": "1:2", "relativeTransform": at(400.0, 0.0), "size": {"x": 300, "y": 160}, + "componentProperties": {"Slot#1:3": {"type": "SLOT", "value": guid(7)}}, + "children": [text("I2:1;1:4", "Title"), slot("I2:1;1:5", json!([text("2:2", "Reports in progress"), chip]))]} + ]}]}}) +} + +fn named(doc: &Document, kind: fn(&NodeKind) -> bool, name: &str) -> NodeId { + doc.nodes().find(|n| n.name == name && kind(&n.kind)).map(|n| n.id).unwrap_or_else(|| panic!("no {name}")) +} + +/// The instance's slot and the names of what it holds. +fn slot_content(s: &Session) -> (NodeId, Vec) { + let doc = s.doc().unwrap(); + let inst = named(doc, |k| matches!(k, NodeKind::Instance { .. }), "Container"); + let (slot, _) = slots_of(doc, inst).into_iter().next().expect("the instance has its slot"); + let names = doc.node(slot).unwrap().children.iter().map(|c| doc.node(*c).unwrap().name.clone()).collect(); + (slot, names) +} + +#[test] +fn rest_slot_content_survives_main_edits_moves_and_saving() { + let mut s = Session::new(); + let data = base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&file()).unwrap()); + ex(&mut s, "file.open", json!({"data": data, "name": "slots.json"})); + healthy(&s); + let want = vec!["Reports in progress".to_string(), "Chip".to_string()]; + let (slot, names) = slot_content(&s); + assert_eq!(names, want); + let doc = s.doc().unwrap(); + assert_eq!(slot_name(doc, slot).as_deref(), Some("Slot")); + assert!(slot_edited(doc, slot), "the instance's own content"); + let chip = named(doc, |k| matches!(k, NodeKind::Instance { .. }), "Chip"); + assert!(!inside_instance(doc, chip), "content stands alone"); + + // Edit and move both mains (the container's and the one used in the content). + let main = named(doc, |k| matches!(k, NodeKind::Component { .. }), "Container"); + let chip_main = named(doc, |k| matches!(k, NodeKind::Component { .. }), "Chip"); + let title = doc.node(main).unwrap().children[0]; + ex(&mut s, "node.set", json!({"id": title, "props": {"fill": "#ff0000"}})); + ex(&mut s, "node.set", json!({"id": main, "props": {"width": 320}})); + let holder = idv(&ex(&mut s, "frame.create", json!({"x": -1000, "y": 0, "width": 900, "height": 900, "name": "Design system"}))); + ex(&mut s, "node.reparent", json!({"ids": [main, chip_main], "parent": holder})); + healthy(&s); + assert_eq!(slot_content(&s).1, want, "kept across syncs"); + + // Saving and opening again keeps it. + let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap(); + let data = base64::engine::general_purpose::STANDARD.encode(bytes); + let mut again = Session::new(); + ex(&mut again, "file.open", json!({"data": data, "name": "slots.figcraft"})); + healthy(&again); + let (slot, names) = slot_content(&again); + assert_eq!(names, want); + assert!(slot_edited(again.doc().unwrap(), slot)); + + // Resetting the slot shows the main's (empty) default content. + ex(&mut again, "instance.resetSlot", json!({"id": slot})); + assert!(slot_content(&again).1.is_empty()); +} diff --git a/crates/engine/src/selection_colors.rs b/crates/engine/src/selection_colors.rs index e604c7986..875c6416b 100644 --- a/crates/engine/src/selection_colors.rs +++ b/crates/engine/src/selection_colors.rs @@ -9,7 +9,7 @@ use figcraft_doc::{Document, NodeId, NodeKind, StyleKind}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use crate::cmd::lists::{BoundList, list_binding}; +use crate::cmd::lists::{BoundList, list_binding, rebind_edited_paint}; use crate::params::*; use crate::{CommandSpec, Result, Session, bad, spec}; @@ -153,37 +153,6 @@ fn checked_targets(s: &Session, p: &Value, cmd: &str) -> Result Ok(ts) } -/// Detach variables only where the explicit edit changed their value. In particular, an -/// opacity edit keeps the color binding and a range edit keeps other characters' node bindings. -fn unbind_changed(n: &mut figcraft_doc::Node, t: &PaintTarget, old: &Paint, new: &Paint) { - // Range fills override the node's paints; the node binding still belongs to other chars. - if t.range.is_some() { - return; - } - let list = if t.fills { BoundList::Fills } else { BoundList::Strokes }; - n.bound.retain(|key, _| { - let Some((l, index, field)) = list_binding(key) else { return true }; - if l != list || index != t.index { - return true; - } - match field { - "color" => match (&old.kind, &new.kind) { - (PaintKind::Solid { color: a }, PaintKind::Solid { color: b }) => a == b, - _ => false, - }, - "opacity" => old.opacity == new.opacity, - _ => { - let stop = field.strip_prefix("stops/").and_then(|s| s.strip_suffix("/color")).and_then(|s| s.parse::().ok()); - match (stop, &old.kind, &new.kind) { - (Some(i), PaintKind::Gradient(a), PaintKind::Gradient(b)) => a.stops.get(i).map(|s| s.color) == b.stops.get(i).map(|s| s.color), - (Some(_), _, _) => false, - _ => old == new, - } - } - } - }); -} - fn set_paint(s: &mut Session, p: &Value) -> Result { const CMD: &str = "selection.setPaint"; let targets = checked_targets(s, p, CMD)?; @@ -197,18 +166,22 @@ fn set_paint(s: &mut Session, p: &Value) -> Result { let old = slot.clone(); *slot = paint.clone(); let n = s.doc_mut()?.get_mut(target.id)?; - unbind_changed(n, target, &old, &paint); + // Variables detach only where the edit changed their value; range fills override the node's + // paints, whose bindings still belong to the other characters. if let Some((start, end)) = target.range { if let NodeKind::Text(t) = &mut n.kind { t.restyle(start, end, |style| style.fills = Some(paints.clone())); } - } else if target.fills { + continue; + } + if target.fills { n.fills = paints; n.styles.fill = None; } else { n.strokes = paints; n.styles.stroke = None; } + rebind_edited_paint(n, target.fills, target.index, &old); } Ok(json!({"ok": true})) } diff --git a/crates/engine/src/selection_reorder.rs b/crates/engine/src/selection_reorder.rs new file mode 100644 index 000000000..d54109c79 --- /dev/null +++ b/crates/engine/src/selection_reorder.rs @@ -0,0 +1,106 @@ +//! Geometry for reordering a smart selection. Only regular, non-overlapping rows, columns +//! and complete top-left-aligned grids have unambiguous insertion slots. +//! Marking several items and irregular grids are out of scope. +use figcraft_doc::{Document, NodeId}; +use figcraft_geom::{Point, Rect}; + +use crate::selection_spacing::selection_spacing; + +#[derive(Clone, Debug)] +pub struct ReorderLayout { + /// Visual order, independent of selection order and layer stacking. + pub slots: Vec<(NodeId, Rect)>, + columns: usize, + rows: usize, + gap_x: f64, + gap_y: f64, +} + +impl ReorderLayout { + pub fn new(doc: &Document, ids: &[NodeId]) -> Option { + let layout = selection_spacing(doc, ids); + let h = layout.horizontal.as_ref(); + let v = layout.vertical.as_ref(); + let gap = |axis: Option<&crate::selection_spacing::Axis>| match axis { + Some(a) => a.value.filter(|v| *v >= 0.0), + None => Some(0.0), + }; + let (gap_x, gap_y) = (gap(h)?, gap(v)?); + let columns = h.map_or(1, |a| a.tracks.len()); + let rows = v.map_or(1, |a| a.tracks.len()); + if ids.len() < 2 || columns.checked_mul(rows)? != ids.len() { + return None; + } + let mut cells = std::collections::BTreeMap::new(); + for id in ids { + let col = match h { + Some(a) => a.tracks.iter().position(|t| t.ids.contains(id))?, + None => 0, + }; + let row = match v { + Some(a) => a.tracks.iter().position(|t| t.ids.contains(id))?, + None => 0, + }; + let b = doc.world_bounds(*id)?; + if columns > 1 && rows > 1 { + let x = h?.tracks.get(col)?.bounds.x0; + let y = v?.tracks.get(row)?.bounds.y0; + if (b.x0 - x).abs() > 0.01 || (b.y0 - y).abs() > 0.01 { + return None; + } + } + if cells.insert((row, col), (*id, b)).is_some() { + return None; + } + } + Some(Self { slots: cells.into_values().collect(), columns, rows, gap_x, gap_y }) + } + + /// Insertion shifts intervening items; swapping exchanges just two entries. Sizes and + /// original gaps are retained, so unequal-sized layers reflow instead of overlapping. + pub fn positions(&self, source: NodeId, target: NodeId, swap: bool) -> Option> { + let from = self.slots.iter().position(|(id, _)| *id == source)?; + let to = self.slots.iter().position(|(id, _)| *id == target)?; + if from == to { + return Some(self.slots.iter().map(|(id, b)| (*id, Point::new(b.x0, b.y0))).collect()); + } + let mut order = self.slots.clone(); + if swap { + order.swap(from, to); + } else { + let item = order.remove(from); + order.insert(to, item); + } + let mut widths = vec![0.0_f64; self.columns]; + let mut heights = vec![0.0_f64; self.rows]; + for (i, (_, b)) in order.iter().enumerate() { + let w = widths.get_mut(i % self.columns)?; + *w = w.max(b.width()); + let h = heights.get_mut(i / self.columns)?; + *h = h.max(b.height()); + } + let x0 = self.slots.iter().map(|(_, b)| b.x0).fold(f64::INFINITY, f64::min); + let y0 = self.slots.iter().map(|(_, b)| b.y0).fold(f64::INFINITY, f64::min); + let starts = |sizes: &[f64], mut at: f64, gap: f64| { + sizes + .iter() + .map(|size| { + let start = at; + at += size + gap; + start + }) + .collect::>() + }; + let xs = starts(&widths, x0, self.gap_x); + let ys = starts(&heights, y0, self.gap_y); + order + .iter() + .enumerate() + .map(|(i, (id, b))| { + let x = if self.columns == 1 { b.x0 } else { *xs.get(i % self.columns)? }; + let y = if self.rows == 1 { b.y0 } else { *ys.get(i / self.columns)? }; + Some((*id, Point::new(x, y))) + }) + .collect() + } +} diff --git a/crates/engine/src/selection_reorder_tests.rs b/crates/engine/src/selection_reorder_tests.rs new file mode 100644 index 000000000..83ae5b310 --- /dev/null +++ b/crates/engine/src/selection_reorder_tests.rs @@ -0,0 +1,152 @@ +use crate::{Session, selection_reorder::ReorderLayout}; +use figcraft_doc::NodeId; +use serde_json::json; + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64, parent: Option) -> NodeId { + NodeId(s.execute("shape.rectangle", &json!({"x":x,"y":y,"width":w,"height":h,"parent":parent})).unwrap()["id"].as_u64().unwrap()) +} +fn xy(s: &Session, id: NodeId) -> (f64, f64) { + let b = s.doc().unwrap().world_bounds(id).unwrap(); + (b.x0, b.y0) +} + +#[test] +fn insertion_reflows_unequal_sizes_with_original_gaps_and_hierarchy() { + for vertical in [false, true] { + let mut s = Session::new(); + let mut ids = vec![]; + for (at, size) in [(0., 40.), (50., 60.), (120., 20.)] { + ids.push(rect( + &mut s, + if vertical { 0. } else { at }, + if vertical { at } else { 0. }, + if vertical { 30. } else { size }, + if vertical { size } else { 30. }, + None, + )); + } + let before = s.doc().unwrap().clone(); + // Explicit selection order must not determine visual order. + s.execute("arrange.reorder", &json!({"ids":[ids[2],ids[0],ids[1]],"source":ids[0],"target":ids[2]})).unwrap(); + for (id, at) in [(ids[1], 0.), (ids[2], 70.), (ids[0], 100.)] { + assert_eq!(xy(&s, id), if vertical { (0., at) } else { (at, 0.) }); + let old = before.node(id).unwrap(); + let new = s.doc().unwrap().node(id).unwrap(); + assert_eq!((new.width, new.height, new.parent), (old.width, old.height, old.parent)); + } + assert_eq!(s.doc().unwrap().node(s.ds().unwrap().page).unwrap().children, before.node(s.ds().unwrap().page).unwrap().children); + s.undo().unwrap(); + assert_eq!(s.doc().unwrap(), &before); + s.redo().unwrap(); + assert_eq!(xy(&s, ids[0]), if vertical { (0., 100.) } else { (100., 0.) }); + } +} + +#[test] +fn grids_insert_across_rows_and_swap_only_the_two_entries() { + let mut s = Session::new(); + let mut ids = vec![]; + for row in 0..2 { + for col in 0..3 { + ids.push(rect(&mut s, col as f64 * 70., row as f64 * 60., 50., 40., None)); + } + } + let before = s.doc().unwrap().clone(); + s.execute("arrange.reorder", &json!({"ids":ids,"source":ids[0],"target":ids[4]})).unwrap(); + assert_eq!(xy(&s, ids[0]), (70., 60.)); + assert_eq!(xy(&s, ids[3]), (140., 0.)); + assert_eq!(xy(&s, ids[5]), (140., 60.)); + s.undo().unwrap(); + s.execute("arrange.reorder", &json!({"ids":ids,"source":ids[0],"target":ids[4],"swap":true})).unwrap(); + assert_eq!(xy(&s, ids[0]), (70., 60.)); + assert_eq!(xy(&s, ids[4]), (0., 0.)); + for id in [ids[1], ids[2], ids[3], ids[5]] { + assert_eq!(s.doc().unwrap().node(id), before.node(id)); + } +} + +#[test] +fn grid_reflow_keeps_gaps_with_different_sizes() { + let mut s = Session::new(); + let a = rect(&mut s, 0., 0., 40., 30., None); + let b = rect(&mut s, 50., 0., 60., 30., None); + let c = rect(&mut s, 0., 50., 40., 50., None); + let d = rect(&mut s, 50., 50., 60., 50., None); + s.execute("arrange.reorder", &json!({"ids":[a,b,c,d],"source":a,"target":d})).unwrap(); + assert_eq!(xy(&s, b), (0., 0.)); + assert_eq!(xy(&s, c), (70., 0.)); + assert_eq!(xy(&s, d), (0., 70.)); + assert_eq!(xy(&s, a), (70., 70.)); +} + +#[test] +fn previews_are_absolute_and_returning_to_start_is_a_noop() { + let mut s = Session::new(); + let ids: Vec<_> = (0..3).map(|i| rect(&mut s, i as f64 * 70., 0., 50., 40., None)).collect(); + s.execute("select.set", &json!({"ids":ids})).unwrap(); + let before = s.doc().unwrap().clone(); + let history = s.undo_depth().0; + s.begin_interaction("Reorder selection").unwrap(); + for target in [ids[2], ids[1], ids[0]] { + s.execute("arrange.reorder", &json!({"ids":ids,"source":ids[0],"target":target,"fromGesture":true})).unwrap(); + } + s.commit_interaction().unwrap(); + assert_eq!(s.doc().unwrap(), &before); + assert_eq!(s.undo_depth().0, history); + s.begin_interaction("Reorder selection").unwrap(); + for target in [ids[1], ids[2]] { + s.execute("arrange.reorder", &json!({"ids":ids,"source":ids[0],"target":target,"fromGesture":true})).unwrap(); + } + s.commit_interaction().unwrap(); + assert_eq!(s.undo_depth().0, history + 1); + assert_eq!(s.selection(), ids); + s.undo().unwrap(); + assert_eq!(s.doc().unwrap(), &before); +} + +#[test] +fn invalid_selections_and_targets_do_not_edit_the_document() { + let mut s = Session::new(); + let a = rect(&mut s, 0., 0., 50., 40., None); + let b = rect(&mut s, 70., 0., 50., 40., None); + let c = rect(&mut s, 160., 0., 50., 40., None); + for args in [ + json!({"ids":[a,b,c],"source":a,"target":b}), // Mixed gaps. + json!({"ids":[a,b],"source":c,"target":b}), + json!({"ids":[a,b],"source":a,"target":999999}), + json!({"ids":[a,a,b],"source":a,"target":b}), + json!({"ids":[a,b],"source":[],"target":b}), + ] { + let before = s.doc().unwrap().clone(); + assert!(s.execute("arrange.reorder", &args).is_err()); + assert_eq!(s.doc().unwrap(), &before); + } + for props in [json!({"locked":true}), json!({"visible":false})] { + s.execute("node.set", &json!({"id":b,"props":props})).unwrap(); + assert!(ReorderLayout::new(s.doc().unwrap(), &[a, b]).is_none()); + s.execute("node.set", &json!({"id":b,"props":{"locked":false,"visible":true}})).unwrap(); + } + let d = rect(&mut s, 0., 70., 50., 40., None); + assert!(ReorderLayout::new(s.doc().unwrap(), &[a, b, d]).is_none(), "an incomplete grid has no slots"); + let frame = + NodeId(s.execute("frame.create", &json!({"width":300,"height":100,"props":{"layout":"horizontal"}})).unwrap()["id"].as_u64().unwrap()); + let x = rect(&mut s, 0., 0., 50., 40., Some(frame)); + let y = rect(&mut s, 70., 0., 50., 40., Some(frame)); + assert!(ReorderLayout::new(s.doc().unwrap(), &[x, y]).is_none()); + assert!(ReorderLayout::new(s.doc().unwrap(), &[a, x]).is_none()); +} + +#[test] +fn reordering_children_of_a_rotated_parent_uses_page_coordinates() { + let mut s = Session::new(); + let frame = NodeId( + s.execute("frame.create", &json!({"x":200,"y":200,"width":300,"height":100,"props":{"rotation":90}})).unwrap()["id"].as_u64().unwrap(), + ); + let a = rect(&mut s, 0., 0., 50., 40., Some(frame)); + let b = rect(&mut s, 70., 0., 50., 40., Some(frame)); + let (pa, pb) = (xy(&s, a), xy(&s, b)); + s.execute("arrange.reorder", &json!({"ids":[a,b],"source":a,"target":b})).unwrap(); + let (qa, qb) = (xy(&s, a), xy(&s, b)); + assert!((qa.0 - pb.0).abs() < 1e-7 && (qa.1 - pb.1).abs() < 1e-7); + assert!((qb.0 - pa.0).abs() < 1e-7 && (qb.1 - pa.1).abs() < 1e-7); +} diff --git a/crates/engine/src/slot_tests.rs b/crates/engine/src/slot_tests.rs index 516cb9d6c..cbf824d9f 100644 --- a/crates/engine/src/slot_tests.rs +++ b/crates/engine/src/slot_tests.rs @@ -41,6 +41,17 @@ fn props_of(s: &mut Session, id: NodeId) -> Vec { ex(s, "component.properties", json!({"id": id}))["properties"].as_array().cloned().unwrap_or_default() } +/// A refused slot is reported by `component.makeSlot`, the command that was run, not by the +/// property command it adds its slot property through. +#[test] +fn a_refused_slot_names_make_slot() { + let mut s = Session::new(); + let (c, ..) = slot_card(&mut s); + let other = idv(&ex(&mut s, "frame.create", json!({"x": 150, "y": 10, "width": 40, "height": 30, "parent": c, "name": "Other"}))); + let err = s.execute("component.makeSlot", &json!({"id": other, "name": "Content"})).unwrap_err().to_string(); + assert!(err.contains("`component.makeSlot`") && err.contains("a property named `Content` already exists"), "{err}"); +} + #[test] fn slots_are_frames_of_a_main_with_a_slot_property() { let mut s = Session::new(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d8ebfc9e6..646c7c4b0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -376,6 +376,7 @@ fn pointer_draw_move_resize() { assert_eq!(s.selection(), vec![r]); } +/// No command panics on junk parameters. #[test] fn every_command_survives_junk_params() { // A junk-parameter sweep: no command may panic (guard turns panics into Internal errors, @@ -418,6 +419,9 @@ fn every_command_survives_junk_params() { // Selection color slots: malformed indexes, ranges and paints. json!({"targets": [{"id": 2, "fills": true, "index": -1, "range": [0, 1e308]}], "paint": {"type": "solid", "color": "invalid"}}), json!({"targets": [{"id": 2, "fills": "yes", "index": 0, "range": [4, 1]}], "style": 5}), + // Smart-selection reorder targets and modes as junk. + json!({"ids": [2, 3], "source": -1, "target": {"id": 3}, "swap": [], "fromGesture": true}), + json!({"source": 1e308, "target": "NaN", "swap": "yes", "fromGesture": []}), // Rotation origins and placing with a tool as junk. json!({"tool": [1], "x": 1e308, "y": "NaN", "reset": "maybe", "on": {"x": 1}}), json!({"tool": "frame", "x": -1e308, "y": 1e308, "reset": true, "on": "yes"}), @@ -434,6 +438,8 @@ fn every_command_survives_junk_params() { | "file.placeImage" | "image.place" | "file.revert" + // Takes no params and walks the font folders: one walk per junk is slow. + | "text.rescanFonts" ) { continue; } diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index 76b353a00..902ba1494 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -782,6 +782,7 @@ mod tests { assert!(r.is_null() || r["axes"].is_array()); } + /// No text command panics on junk parameters while a text is being edited. #[test] fn text_commands_survive_junk_while_editing() { let junk = [ @@ -793,7 +794,8 @@ mod tests { json!({"variations": {"wght": "heavy", "toolong": 1}, "text": "\u{0}\u{7}", "cursor": 1e300}), json!({"variations": {"wght": 1e300, "opsz": -1e300}, "replace": "yes", "text": "日本語", "cursor": -1}), ]; - for spec in crate::cmd::all_commands().iter().filter(|c| c.id.starts_with("text.")) { + // `text.rescanFonts` takes no params and walks the font folders: one walk per junk is slow. + for spec in crate::cmd::all_commands().iter().filter(|c| c.id.starts_with("text.") && c.id != "text.rescanFonts") { for j in &junk { let (mut s, id) = setup("Hi there\nyou"); begin(&mut s, id, Some(2), false).unwrap(); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index ad4d78d85..f0115e1f5 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1300,7 +1300,7 @@ impl Session { for i in &ids { let parent = doc.get(*i)?.parent.unwrap_or(page); let idx = doc.index_in_parent(*i).map(|x| x + 1); - copies.push(doc.duplicate_into(*i, parent, idx)?); + copies.push(crate::components::copy_in_file(doc, *i, parent, idx)?); } moving = copies; *sel = moving.clone(); diff --git a/crates/engine/src/variables_behaviour_tests.rs b/crates/engine/src/variables_behaviour_tests.rs index 5a868a637..3fe156f81 100644 --- a/crates/engine/src/variables_behaviour_tests.rs +++ b/crates/engine/src/variables_behaviour_tests.rs @@ -279,6 +279,34 @@ fn bindings_by_field_and_type() { } } +#[test] +fn auto_layout_bindings_go_with_the_auto_layout() { + // Docs silent; pinned (#810): removing a frame's auto layout drops its gap and padding bindings, + // and a binding never brings an auto layout back. One made without auto layout waits for it. + let mut s = Session::new(); + run(&mut s, "variable.createCollection", json!({"name": "C"})); + run(&mut s, "variable.create", json!({"collection": "C", "name": "g", "type": "number", "value": 12})); + let removals = + [("layout.remove", json!({})), ("node.set", json!({"props": {"layout": null}})), ("node.set", json!({"props": {"layout": "none"}}))]; + for (cmd, mut p) in removals { + let f = frame(&mut s, None); + run(&mut s, "layout.add", json!({"ids": [f]})); + for field in ["gap", "crossGap", "paddingTop"] { + run(&mut s, "variable.bind", json!({"ids": [f], "field": field, "variable": "g"})); + } + p["ids"] = json!([f]); + run(&mut s, cmd, p.clone()); + let n = node(&s, f); + assert!(n.layout.is_none(), "{cmd} {p}: auto layout stays removed"); + assert!(n.bound.is_empty(), "{cmd} {p}: layout bindings dropped"); + } + let f = frame(&mut s, None); + run(&mut s, "variable.bind", json!({"ids": [f], "field": "gap", "variable": "g"})); + assert!(node(&s, f).layout.is_none(), "binding gap adds no auto layout"); + run(&mut s, "layout.add", json!({"ids": [f]})); + assert_eq!(node(&s, f).layout.map(|l| l.gap), Some(12.0), "the waiting binding applies to the new auto layout"); +} + #[test] fn deleting_a_bound_variable_keeps_the_last_value() { // Documented: deleting a variable unbinds what used it, which keeps showing its last value. @@ -365,3 +393,90 @@ fn number_variables_are_pixels_and_edits_are_one_undo_step() { s.undo().unwrap(); assert_eq!(lh(&s), LineHeight::Pixels(20.0), "the value and the bound layer go back together"); } + +#[test] +fn create_collection_keeps_at_least_one_named_mode() { + // A collection keeps at least one mode (removeMode) and mode names can't be empty (renameMode), + // so createCollection refuses an empty mode list, blank mode names and a blank collection name. + let mut s = Session::new(); + for p in [ + json!({"name": "C", "modes": []}), + json!({"name": "C", "modes": [1]}), + json!({"name": "C", "modes": ["", " "]}), + json!({"name": "C", "modes": ["A", " "]}), + json!({"name": " "}), + json!({"name": ""}), + ] { + assert!(s.execute("variable.createCollection", &p).is_err(), "{p} should be refused"); + } + assert!(s.doc().unwrap().variables.collections.is_empty(), "a refused call creates nothing"); + run(&mut s, "variable.createCollection", json!({"name": "C", "modes": [" Light ", "Dark"]})); + let names: Vec = s.doc().unwrap().variables.collections[0].modes.iter().map(|m| m.name.clone()).collect(); + assert_eq!(names, ["Light", "Dark"]); +} + +#[test] +fn explicit_edits_of_bound_fields_stick_and_detach_that_binding() { + // #809: an edit of a bound field detaches its binding (as a paint edit does) instead of + // the variable's value coming back; bindings whose value the edit kept stay. + let mut s = Session::new(); + run(&mut s, "variable.createCollection", json!({"name": "C"})); + let w = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "w", "type": "number", "value": 48}))); + let o = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "o", "type": "number", "value": 40}))); + let r = rect(&mut s, None); + for (field, v) in [("width", &w), ("height", &w), ("opacity", &o)] { + run(&mut s, "variable.bind", json!({"ids": [r], "field": field, "variable": v})); + } + assert_eq!((node(&s, r).width, node(&s, r).opacity), (48.0, 0.4)); + + run(&mut s, "node.set", json!({"ids": [r], "width": 100, "opacity": 0.4})); + let n = node(&s, r); + assert_eq!(n.width, 100.0); + assert!(!n.bound.contains_key("width")); + assert!(n.bound.contains_key("height") && n.bound.contains_key("opacity"), "unchanged values keep their bindings"); + + run(&mut s, "transform.resize", json!({"ids": [r], "height": 30})); + assert_eq!(node(&s, r).height, 30.0); + assert!(!node(&s, r).bound.contains_key("height")); + + run(&mut s, "object.opacityKeys", json!({"ids": [r], "digits": "9"})); + assert_eq!(node(&s, r).opacity, 0.9); + assert!(node(&s, r).bound.is_empty()); + + // A value set with its binding in the same call keeps the new binding. + run(&mut s, "node.set", json!({"ids": [r], "width": 5, "boundVariables": {"width": w}})); + assert_eq!((node(&s, r).width, node(&s, r).bound.get("width")), (48.0, Some(&w))); +} + +#[test] +fn a_gesture_or_a_text_style_on_a_bound_field_detaches_it() { + let mut s = Session::new(); + run(&mut s, "variable.createCollection", json!({"name": "C"})); + let v = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "r", "type": "number", "value": 8}))); + let r = rect(&mut s, None); + run(&mut s, "variable.bind", json!({"ids": [r], "field": "cornerRadius", "variable": v})); + s.begin_interaction("Edit").unwrap(); + let props = serde_json::Map::from_iter([("cornerRadius".to_string(), json!(2))]); + s.preview(|d, _| crate::cmd::props::apply("node.set", d, r, &props)).unwrap(); + s.commit_interaction().unwrap(); + assert_eq!(node(&s, r).corners.radii, [2.0; 4]); + assert!(node(&s, r).bound.is_empty()); + + let t = id(run(&mut s, "text.create", json!({"x": 0, "y": 0, "text": "Hi"}))); + run(&mut s, "variable.bind", json!({"ids": [t], "field": "fontSize", "variable": v})); + run(&mut s, "text.style", json!({"id": t, "fontSize": 20})); + assert!(node(&s, t).bound.is_empty()); + assert_eq!(node(&s, t).kind.text().unwrap().style.font_size, 20.0); + + // The font size/weight/tracking shortcuts and text content edits detach too. + run(&mut s, "variable.bind", json!({"ids": [t], "field": "fontSize", "variable": v})); + assert_eq!(node(&s, t).kind.text().unwrap().style.font_size, 8.0); + run(&mut s, "text.sizeUp", json!({"ids": [t]})); + assert_eq!(node(&s, t).kind.text().unwrap().style.font_size, 9.0); + assert!(node(&s, t).bound.is_empty()); + let c = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "c", "type": "string", "value": "Hello"}))); + run(&mut s, "variable.bind", json!({"ids": [t], "field": "characters", "variable": c})); + run(&mut s, "text.set", json!({"id": t, "characters": "Bye"})); + assert_eq!(node(&s, t).kind.text().unwrap().characters, "Bye"); + assert!(node(&s, t).bound.is_empty()); +} diff --git a/crates/engine/src/variables_import_shapes_tests.rs b/crates/engine/src/variables_import_shapes_tests.rs new file mode 100644 index 000000000..067ccab23 --- /dev/null +++ b/crates/engine/src/variables_import_shapes_tests.rs @@ -0,0 +1,60 @@ +//! `variable.import` reads `variables` next to `variableCollections`, under `meta` or at the top +//! level, and never imports nothing without saying so (#790). + +use serde_json::{Value, json}; + +use crate::Session; + +fn collections() -> Value { + json!({"VC:1": {"id": "VC:1", "name": "Theme", "modes": [{"modeId": "1:0", "name": "Light"}], "defaultModeId": "1:0", + "variableIds": ["VariableID:1"]}}) +} + +fn variables() -> Value { + json!({"VariableID:1": {"id": "VariableID:1", "name": "bg", "variableCollectionId": "VC:1", "resolvedType": "COLOR", + "valuesByMode": {"1:0": {"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0}}}}) +} + +fn import(json: Value) -> Value { + let mut s = Session::new(); + match s.execute("variable.import", &json!({"variables": json})) { + Ok(r) => r, + Err(e) => panic!("{e}"), + } +} + +#[test] +fn variables_import_reads_every_shape() { + for shape in [ + json!({"meta": {"variableCollections": collections(), "variables": variables()}}), + json!({"variableCollections": collections(), "variables": variables()}), + json!({"meta": {"variableCollections": collections()}, "variables": variables()}), + json!({"variableCollections": collections(), "meta": {"variables": variables()}}), + ] { + let r = import(shape.clone()); + assert_eq!((r["collections"].as_u64(), r["variables"].as_u64()), (Some(1), Some(1)), "{shape}: {r}"); + assert_eq!(r["warnings"], json!([]), "{shape}: {r}"); + } +} + +#[test] +fn variables_import_never_imports_nothing_silently() { + // Collections without variables: they are added, with a warning saying where variables go. + let r = import(json!({"meta": {"variableCollections": collections()}})); + assert_eq!((r["collections"].as_u64(), r["variables"].as_u64()), (Some(1), Some(0)), "{r}"); + assert!(r["warnings"].to_string().contains("no variables found: expected `meta.variables`"), "{r}"); + // An empty file's export reads back with the same warning, not an error. + let mut s = Session::new(); + let text = s.execute("variable.export", &json!({})).unwrap()["text"].as_str().unwrap().to_string(); + let r = s.execute("variable.import", &json!({"variables": text})).unwrap(); + assert!(r["warnings"].to_string().contains("no variables found"), "{r}"); + // Collections listing variables that aren't there. + let mut colls = collections(); + colls["VC:1"]["variableIds"] = json!(["VariableID:1", "VariableID:2"]); + let r = import(json!({"meta": {"variableCollections": colls, "variables": variables()}})); + assert_eq!(r["variables"].as_u64(), Some(1), "{r}"); + assert!(r["warnings"].to_string().contains("1 of the 2 variables the collections list in `variableIds` weren't imported"), "{r}"); + // No collections anywhere: an error naming the expected shape. + let e = Session::new().execute("variable.import", &json!({"variables": {"variables": variables()}})).unwrap_err().to_string(); + assert!(e.contains("(`meta.variableCollections` and `meta.variables`)"), "{e}"); +} diff --git a/crates/engine/src/variables_io_tests.rs b/crates/engine/src/variables_io_tests.rs index 5c7305bb5..3a57d9eac 100644 --- a/crates/engine/src/variables_io_tests.rs +++ b/crates/engine/src/variables_io_tests.rs @@ -371,3 +371,29 @@ fn variant_properties_follow_string_variables() { assert_eq!(main(&s), variants[1]); healthy(&s); } + +#[test] +fn paints_replaced_through_node_set_keep_unchanged_stop_bindings() { + let mut s = Session::new(); + let r = gradient_rect(&mut s); + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fills/0/stops/2/color", "variable": "brand"})); + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fills/0/stops/1/color", "variable": "ink"})); + let cur = |s: &Session| s.doc().unwrap().node(r).unwrap().fills[0].clone(); + // Removing the first stop moves both bindings down with their stops. + let mut p = cur(&s); + if let PaintKind::Gradient(g) = &mut p.kind { + g.stops.remove(0); + } + ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": [p]}})); + assert_eq!(bound_keys(&s, r), ["fills/0/stops/0/color", "fills/0/stops/1/color"]); + // Recolouring one bound stop detaches that stop only. + let mut p = cur(&s); + if let PaintKind::Gradient(g) = &mut p.kind { + g.stops[0].color = Color::WHITE; + } + ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": [p]}})); + assert_eq!(bound_keys(&s, r), ["fills/0/stops/1/color"]); + ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#0000ff"})); + assert_eq!(stops(&s, r), ["FFFFFF", "0000FF"]); + healthy(&s); +} diff --git a/crates/engine/src/vector_behaviour_tests.rs b/crates/engine/src/vector_behaviour_tests.rs index 8899dc4e0..fb734085b 100644 --- a/crates/engine/src/vector_behaviour_tests.rs +++ b/crates/engine/src/vector_behaviour_tests.rs @@ -439,3 +439,61 @@ fn bend_paint_bucket_cut_join_and_eraser_tools() { assert_eq!(net(&s, id).segments.len(), 2, "the eraser removes what it passes over"); assert_eq!(s.doc().unwrap().node(page).unwrap().children, vec![id], "and the pieces stay one layer"); } + +#[test] +fn vector_paths_take_the_requested_size_and_refuse_empty_geometry() { + let mut s = Session::new(); + let path = "M0 0 L24 0 L24 24 L0 24 Z"; + let intrinsic = id_of(&ex(&mut s, "vector.create", json!({"path": path}))["id"]).unwrap(); + let n = s.doc().unwrap().node(intrinsic).unwrap(); + assert_eq!((n.width, n.height), (24.0, 24.0), "a path without a size keeps its intrinsic box"); + // Both sides: each scales independently, like resizing a vector. + let both = id_of(&ex(&mut s, "vector.create", json!({"path": path, "width": 16, "height": 8}))["id"]).unwrap(); + let n = s.doc().unwrap().node(both).unwrap(); + assert_eq!((n.width, n.height), (16.0, 8.0)); + let n = net(&s, both); + let max = n.vertices.iter().fold((0.0f64, 0.0f64), |(x, y), v| (x.max(v.x), y.max(v.y))); + assert!((max.0 - 16.0).abs() < 1e-9 && (max.1 - 8.0).abs() < 1e-9, "{max:?}: the geometry itself is scaled"); + // One side: the other keeps its proportion. + let width = id_of(&ex(&mut s, "vector.create", json!({"path": path, "width": 16}))["id"]).unwrap(); + let n = s.doc().unwrap().node(width).unwrap(); + assert_eq!((n.width, n.height), (16.0, 16.0)); + // A path or point set that draws nothing is an error, not an invisible zero-size layer. + let e = s.execute("vector.create", &json!({"path": "M5 5"})).unwrap_err().to_string(); + assert!(e.contains("draws nothing"), "{e}"); + let e = s.execute("vector.create", &json!({"points": [[5, 5], [5, 5]]})).unwrap_err().to_string(); + assert!(e.contains("draws nothing"), "{e}"); + // A size on an axis the geometry has none of is refused, not silently ignored. + let e = s.execute("vector.create", &json!({"path": "M0 0 L0 24", "width": 16})).unwrap_err().to_string(); + assert!(e.contains("horizontal extent"), "{e}"); + let e = s.execute("vector.create", &json!({"path": "M0 0 L24 0", "height": 16})).unwrap_err().to_string(); + assert!(e.contains("vertical extent"), "{e}"); + // But 0 on an axis it has none of is what it already is: a horizontal line with height 0. + let line = id_of(&ex(&mut s, "vector.create", json!({"path": "M0 0 L24 0", "width": 16, "height": 0}))["id"]).unwrap(); + let n = s.doc().unwrap().node(line).unwrap(); + assert_eq!((n.width, n.height), (16.0, 0.0)); + // A size of 0 or less on an axis it has would flatten it, so it is refused, alone or with the other. + for p in [json!({"path": path, "width": 0}), json!({"path": path, "width": 16, "height": 0}), json!({"path": path, "width": -4, "height": 8})] { + let e = s.execute("vector.create", &p).unwrap_err().to_string(); + assert!(e.contains("positive"), "{p}: {e}"); + } +} + +#[test] +fn vector_points_take_the_requested_size() { + let mut s = Session::new(); + let points = json!([[10, 20], [34, 20], [34, 32]]); + let corners = |s: &Session, id: NodeId| net(s, id).vertices.iter().map(|v| (v.x, v.y)).collect::>(); + // Both sides: each axis scales on its own; the vertices move with the box. + let both = id_of(&ex(&mut s, "vector.create", json!({"points": points, "x": 10, "y": 20, "width": 48, "height": 6}))["id"]).unwrap(); + let n = s.doc().unwrap().node(both).unwrap(); + assert_eq!((n.width, n.height), (48.0, 6.0)); + assert_eq!(corners(&s, both), vec![(0.0, 0.0), (48.0, 0.0), (48.0, 6.0)]); + // One side: the other keeps its proportion. + let one = id_of(&ex(&mut s, "vector.create", json!({"points": points, "height": 24}))["id"]).unwrap(); + let n = s.doc().unwrap().node(one).unwrap(); + assert_eq!((n.width, n.height), (48.0, 24.0)); + assert_eq!(corners(&s, one), vec![(0.0, 0.0), (48.0, 0.0), (48.0, 24.0)]); + let e = s.execute("vector.create", &json!({"points": points, "width": 16, "height": -1})).unwrap_err().to_string(); + assert!(e.contains("positive"), "{e}"); +} diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 7df4d9e21..8e4671511 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -368,6 +368,9 @@ struct Mapper<'a> { blobs: Vec<&'a [u8]>, /// Component property definitions: id → (REST name "Name#id", type). prop_defs: BTreeMap, + /// The copies of a set's definitions each variant keeps (no name or type of their own; the + /// variant's layers and its instances' assignments use them): copy id → the definition's id. + prop_parents: BTreeMap, /// File nodes by asset `key` and `version`: references can name a collection, variable or /// style as the asset instead of the guid, and the file carries the resources to resolve /// them (the collection node itself says the same `key`). @@ -402,6 +405,7 @@ impl<'a> Mapper<'a> { let mut nodes = BTreeMap::new(); let mut kids: BTreeMap> = BTreeMap::new(); let mut prop_defs = BTreeMap::new(); + let mut prop_parents = BTreeMap::new(); let changes = msg.get("nodeChanges").map(Kv::list).unwrap_or_default(); // The last change of a GUID wins; a REMOVED change deletes what came before it. for n in changes { @@ -423,10 +427,11 @@ impl<'a> Mapper<'a> { kids.entry(parent).or_default().push((p.get("position").and_then(Kv::str).unwrap_or(""), id.clone())); } for d in n.get("componentPropDefs").map(Kv::list).unwrap_or_default() { - if let (Some(did), Some(name), Some(ty)) = - (d.get("id").and_then(guid), d.get("name").and_then(Kv::str), d.get("type").and_then(Kv::str)) - { + let Some(did) = d.get("id").and_then(guid) else { continue }; + if let (Some(name), Some(ty)) = (d.get("name").and_then(Kv::str), d.get("type").and_then(Kv::str)) { prop_defs.insert(did.clone(), (format!("{name}#{did}"), ty)); + } else if let Some(parent) = d.get("parentPropDefId").and_then(guid) { + prop_parents.insert(did, parent); } } } @@ -450,6 +455,7 @@ impl<'a> Mapper<'a> { kids, blobs, prop_defs, + prop_parents, assets, styles: Obj::new(), components: Obj::new(), @@ -640,7 +646,7 @@ impl<'a> Mapper<'a> { targets.extend(data.and_then(|d| d.get("symbolID")).and_then(guid)); for assignment in row.get("componentPropAssignments").map(Kv::list).unwrap_or_default() { if assignment.get("defID").is_some_and(|def| self.is_instance_swap(def).is_some()) { - targets.extend(assignment.get("value").and_then(|v| v.get("guidValue")).and_then(guid)); + targets.extend(assigned(assignment).and_then(prop_guid)); } } let overrides = data.and_then(|d| d.get("symbolOverrides")).map(Kv::list).unwrap_or_default(); @@ -651,7 +657,31 @@ impl<'a> Mapper<'a> { } fn is_instance_swap(&self, def: &Kv) -> Option<()> { - matches!(self.prop_defs.get(&guid(def)?), Some((_, "INSTANCE_SWAP"))).then_some(()) + matches!(self.prop_def(&guid(def)?), Some((_, "INSTANCE_SWAP"))).then_some(()) + } + + /// The definition a copy of it was made from, followed to the original: the values an instance + /// sets are kept under it, since an assignment and the layers that read it may name different + /// copies of one definition. + fn prop_root(&self, def: &str) -> String { + let mut def = def; + for _ in 0..=MAX_DEPTH { + let Some(parent) = self.prop_parents.get(def) else { break }; + def = parent; + } + def.to_string() + } + + /// The definition `def` names: its own, or the set's for a variant's copy of it. + fn prop_def(&self, def: &str) -> Option<&(String, &'a str)> { + let mut def = def; + for _ in 0..=MAX_DEPTH { + if let Some(found) = self.prop_defs.get(def) { + return Some(found); + } + def = self.prop_parents.get(def)?; + } + None } /// The branches of the internal canvas `id` that hold the `required_mains` (with the frames @@ -908,7 +938,7 @@ impl<'a> Mapper<'a> { } self.prop_refs(&mut o, &view, inst); let slot_content = if own == "FRAME" { - prop_ref(&view, "SLOT_CONTENT_ID").and_then(|def| inst.and_then(|i| i.slot_contents.get(&def).cloned())) + prop_ref(&view, "SLOT_CONTENT_ID").and_then(|def| inst.and_then(|i| i.slot_contents.get(&self.prop_root(&def)).cloned())) } else { None }; @@ -1892,11 +1922,11 @@ impl<'a> Mapper<'a> { // Expanded frames must follow their main frame's source link. A direct slot reference // makes the engine resolve ownership against the instance, where the property is not defined. if !(field == "slot" && inst.is_some()) - && let Some((name, _)) = self.prop_defs.get(&def) + && let Some((name, _)) = self.prop_def(&def) { refs.insert(field.into(), json!(name)); } - let Some(val) = inst.and_then(|i| i.props.get(&def)) else { continue }; + let Some(val) = inst.and_then(|i| i.props.get(&self.prop_root(&def))) else { continue }; match field { "visible" => { if let Some(b) = val.get("boolValue").and_then(Kv::bool) { @@ -1911,7 +1941,7 @@ impl<'a> Mapper<'a> { if own_text(v) != Some(t) { o.remove("characterStyleOverrides"); o.remove("styleOverrideTable"); - if let Some(tv) = val.get("textValue") { + if let Some(tv) = prop_text_data(val) { let size = o.get("style").and_then(|s| s.get("fontSize")).and_then(Value::as_f64); char_styles(o, tv, size, &mut |w| self.warn(w)); } @@ -1933,7 +1963,7 @@ impl<'a> Mapper<'a> { // An instance swap property value picks the main. if let Some(i) = outer && let Some(def) = prop_ref(v, "OVERRIDDEN_SYMBOL_ID") - && let Some(g) = i.props.get(&def).and_then(|p| p.get("guidValue")).and_then(guid) + && let Some(g) = i.props.get(&self.prop_root(&def)).and_then(|p| prop_guid(p)) { sym = Some(g); } @@ -1945,12 +1975,12 @@ impl<'a> Mapper<'a> { let assignments = v.list("componentPropAssignments"); let mut props = Obj::new(); for a in assignments { - let Some((name, ty)) = a.get("defID").and_then(guid).and_then(|d| self.prop_defs.get(&d).cloned()) else { continue }; - let val = a.get("value"); + let Some((name, ty)) = a.get("defID").and_then(guid).and_then(|d| self.prop_def(&d).cloned()) else { continue }; + let val = assigned(a); let p = match ty { "BOOL" => val.and_then(|x| x.get("boolValue")).and_then(Kv::bool).map(|b| json!({"type": "BOOLEAN", "value": b})), "TEXT" => val.and_then(prop_text).map(|t| json!({"type": "TEXT", "value": t})), - "INSTANCE_SWAP" => val.and_then(|x| x.get("guidValue")).and_then(guid).map(|g| json!({"type": "INSTANCE_SWAP", "value": g})), + "INSTANCE_SWAP" => val.and_then(prop_guid).map(|g| json!({"type": "INSTANCE_SWAP", "value": g})), _ => None, }; if let Some(p) = p { @@ -2027,11 +2057,11 @@ impl<'a> Mapper<'a> { let mut all_props: BTreeMap> = outer.map(|i| (*i.props).clone()).unwrap_or_default(); let mut slot_contents: BTreeMap = outer.map(|i| (*i.slot_contents).clone()).unwrap_or_default(); for a in assignments { - if let (Some(d), Some(val)) = (a.get("defID").and_then(guid), a.get("value")) { - all_props.entry(d).or_insert(val); + if let (Some(d), Some(val)) = (a.get("defID").and_then(guid), assigned(a)) { + all_props.entry(self.prop_root(&d)).or_insert(val); } let Some(def) = a.get("defID").and_then(guid) else { continue }; - if self.prop_defs.get(&def).is_none_or(|(_, ty)| *ty != "SLOT") { + if self.prop_def(&def).is_none_or(|(_, ty)| *ty != "SLOT") { continue; } let var_value = a.get("varValue"); @@ -2052,7 +2082,7 @@ impl<'a> Mapper<'a> { self.warn("slot content assignment target is missing or is not slot content"); continue; } - slot_contents.entry(def).or_insert(target); + slot_contents.entry(self.prop_root(&def)).or_insert(target); } let mut symbols = outer.map(|i| i.symbols.clone()).unwrap_or_default(); symbols.push(sym.clone()); @@ -2777,7 +2807,23 @@ fn char_styles(o: &mut Obj, td: &Kv, size: Option, warn: &mut dyn FnMut(Str /// The text of a component property value. fn prop_text<'k>(v: &'k Kv) -> Option<&'k str> { - v.get("textValue").and_then(|t| t.get("characters")).and_then(Kv::str) + prop_text_data(v).and_then(|t| t.get("characters")).and_then(Kv::str) +} + +/// The text data of a component property value: `textValue`, or `textDataValue` in the variable form. +fn prop_text_data<'k, 's>(v: &'k Kv<'s>) -> Option<&'k Kv<'s>> { + v.get("textValue").or_else(|| v.get("textDataValue")) +} + +/// The main an instance swap value names: `guidValue`, or `symbolIdValue` in the variable form. +fn prop_guid(v: &Kv) -> Option { + v.get("guidValue").and_then(guid).or_else(|| v.get("symbolIdValue")?.get("guid").and_then(guid)) +} + +/// The value a component property assignment sets: `value`, or the variable form `varValue` holds +/// when `value` is empty, as current files write many booleans and instance swaps. +fn assigned<'k, 's>(assignment: &'k Kv<'s>) -> Option<&'k Kv<'s>> { + assignment.get("value").filter(|v| !matches!(v, Kv::Object(fields) if fields.is_empty())).or_else(|| assignment.get("varValue")?.get("value")) } /// The property a layer's `field` is bound to (`parameterConsumptionMap`). diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index fc9293d9e..4c103d088 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -26,6 +26,8 @@ pub mod kiwi; mod map; mod network; #[cfg(test)] +mod prop_value_tests; +#[cfg(test)] mod slot_tests; #[cfg(test)] mod source_identity_tests; diff --git a/crates/format/src/fig/prop_value_tests.rs b/crates/format/src/fig/prop_value_tests.rs new file mode 100644 index 000000000..24248d32a --- /dev/null +++ b/crates/format/src/fig/prop_value_tests.rs @@ -0,0 +1,188 @@ +//! Component properties as current `.fig` files write them, on synthetic messages: the copy of a +//! set's definitions each variant keeps (`parentPropDefId`, no name or type of its own), and +//! assignments that carry their value only in the variable form (`varValue`). + +use figcraft_doc::NodeKind; +use serde_json::Value; + +use super::kiwi::Kv; +use super::map::to_rest; + +fn g(local: u32) -> Kv<'static> { + Kv::Object(vec![("sessionID", Kv::Uint(9)), ("localID", Kv::Uint(u64::from(local)))]) +} + +fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + Kv::Object(fields) +} + +fn s(text: &str) -> Kv<'static> { + Kv::Str(text.into()) +} + +fn node(local: u32, kind: &'static str, name: &str, parent: Option, mut fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + let mut all = vec![("guid", g(local)), ("type", Kv::Enum(kind)), ("name", s(name))]; + if let Some(parent) = parent { + all.push(("parentIndex", o(vec![("guid", g(parent)), ("position", s("!"))]))); + } + all.append(&mut fields); + o(all) +} + +/// A layer's `field` bound to property definition `def`. +fn bound(field: &'static str, def: u32) -> (&'static str, Kv<'static>) { + let entry = o(vec![ + ("variableField", Kv::Enum(field)), + ("variableData", o(vec![("value", o(vec![("propRefValue", o(vec![("defId", g(def))]))])), ("dataType", Kv::Enum("PROP_REF"))])), + ]); + ("parameterConsumptionMap", o(vec![("entries", Kv::Array(vec![entry]))])) +} + +fn definition(id: u32, name: &str, kind: &'static str, initial: Kv<'static>) -> Kv<'static> { + o(vec![("id", g(id)), ("name", s(name)), ("type", Kv::Enum(kind)), ("initialValue", initial)]) +} + +/// An assignment of `def` that leaves `value` empty and says it in `varValue` only. +fn variable_assignment(def: u32, data_type: &'static str, value: Kv<'static>) -> Kv<'static> { + o(vec![ + ("defID", g(def)), + ("value", o(vec![])), + ("varValue", o(vec![("value", value), ("dataType", Kv::Enum(data_type)), ("resolvedDataType", Kv::Enum(data_type))])), + ]) +} + +fn message(nodes: Vec>) -> Kv<'static> { + let mut all = vec![node(1, "DOCUMENT", "Document", None, vec![]), node(2, "CANVAS", "Page", Some(1), vec![])]; + all.extend(nodes); + o(vec![("nodeChanges", Kv::Array(all))]) +} + +fn by_id<'v>(v: &'v Value, id: &str) -> &'v Value { + fn find<'v>(v: &'v Value, id: &str) -> Option<&'v Value> { + if v.get("id").and_then(Value::as_str) == Some(id) { + return Some(v); + } + v.get("children").and_then(Value::as_array).into_iter().flatten().find_map(|v| find(v, id)) + } + find(v, id).unwrap_or_else(|| panic!("no layer {id}")) +} + +fn instance_props(doc: &figcraft_doc::Document) -> &std::collections::BTreeMap { + let instance = doc.ids().filter_map(|id| doc.node(id)).find(|n| n.name == "Instance").unwrap(); + let NodeKind::Instance { props, .. } = &instance.kind else { panic!("not an instance") }; + props +} + +/// A "Field" set with a BOOL definition (9:70), a variant whose "Hint text" reads its copy (9:71), +/// and an instance of the variant that sets the property false through definition `assigned`. +fn field_set(assigned: u32) -> Kv<'static> { + message(vec![ + node( + 9, + "FRAME", + "Field", + Some(2), + vec![ + ("isStateGroup", Kv::Bool(true)), + ("componentPropDefs", Kv::Array(vec![definition(70, "Hint", "BOOL", o(vec![("boolValue", Kv::Bool(true))]))])), + ], + ), + node( + 10, + "SYMBOL", + "State=Default", + Some(9), + vec![("componentPropDefs", Kv::Array(vec![o(vec![("id", g(71)), ("parentPropDefId", g(70))])]))], + ), + node(11, "TEXT", "Hint text", Some(10), vec![bound("VISIBLE", 71)]), + node( + 20, + "INSTANCE", + "Instance", + Some(2), + vec![ + ("symbolData", o(vec![("symbolID", g(10))])), + ("componentPropAssignments", Kv::Array(vec![o(vec![("defID", g(assigned)), ("value", o(vec![("boolValue", Kv::Bool(false))]))])])), + ], + ), + ]) +} + +#[test] +fn variant_layers_bind_to_the_set_properties_their_copies_name() { + let msg = field_set(71); + let (rest, _, _) = to_rest(&msg, "Synthetic", crate::rest::MAX_NODES); + assert_eq!(by_id(&rest["document"], "9:9")["componentPropertyDefinitions"]["Hint#9:70"]["type"], "BOOLEAN"); + assert_eq!(by_id(&rest["document"], "9:11")["componentPropertyReferences"]["visible"], "Hint#9:70"); + assert_eq!(by_id(&rest["document"], "9:20")["componentProperties"]["Hint#9:70"]["value"], false); + + let (doc, _) = crate::rest::import_fig(&rest, &crate::rest::no_images).unwrap(); + let hint = doc.ids().filter_map(|id| doc.node(id)).find(|n| n.name == "Hint text" && n.source.is_none()).unwrap(); + assert_eq!(hint.prop_refs.get("visible").map(String::as_str), Some("Hint")); + assert_eq!(instance_props(&doc).get("Hint"), Some(&Value::Bool(false))); +} + +/// An assignment reaches layers that read another copy of its definition: here the instance sets +/// the set's own definition while the variant's layer reads the variant's copy. +#[test] +fn assignments_reach_layers_that_read_another_copy_of_their_definition() { + let (rest, _, _) = to_rest(&field_set(70), "Synthetic", crate::rest::MAX_NODES); + let instance = by_id(&rest["document"], "9:20"); + assert_eq!(instance["componentProperties"]["Hint#9:70"]["value"], false); + assert_eq!(by_id(instance, "I9:20;9:11")["visible"], false); +} + +#[test] +fn property_values_written_only_as_variables_are_applied() { + let msg = message(vec![ + node( + 10, + "SYMBOL", + "Field", + Some(2), + vec![( + "componentPropDefs", + Kv::Array(vec![ + definition(70, "Hint", "BOOL", o(vec![("boolValue", Kv::Bool(true))])), + definition(71, "Label", "TEXT", o(vec![("textValue", o(vec![("characters", s("Label"))]))])), + definition(72, "Icon", "INSTANCE_SWAP", o(vec![("guidValue", g(40))])), + ]), + )], + ), + node(11, "TEXT", "Hint text", Some(10), vec![bound("VISIBLE", 70)]), + node(12, "TEXT", "Label", Some(10), vec![("textData", o(vec![("characters", s("Label"))])), bound("TEXT_DATA", 71)]), + node(13, "INSTANCE", "Icon", Some(10), vec![("symbolData", o(vec![("symbolID", g(40))])), bound("OVERRIDDEN_SYMBOL_ID", 72)]), + node(40, "SYMBOL", "Mail", Some(2), vec![]), + node(41, "SYMBOL", "Card", Some(2), vec![]), + node( + 20, + "INSTANCE", + "Instance", + Some(2), + vec![ + ("symbolData", o(vec![("symbolID", g(10))])), + ( + "componentPropAssignments", + Kv::Array(vec![ + variable_assignment(70, "BOOLEAN", o(vec![("boolValue", Kv::Bool(false))])), + variable_assignment(71, "TEXT_DATA", o(vec![("textDataValue", o(vec![("characters", s("Name on card"))]))])), + variable_assignment(72, "SYMBOL_ID", o(vec![("symbolIdValue", o(vec![("guid", g(41))]))])), + ]), + ), + ], + ), + ]); + let (rest, _, _) = to_rest(&msg, "Synthetic", crate::rest::MAX_NODES); + let instance = by_id(&rest["document"], "9:20"); + assert_eq!(instance["componentProperties"]["Hint#9:70"]["value"], false); + assert_eq!(instance["componentProperties"]["Label#9:71"]["value"], "Name on card"); + assert_eq!(instance["componentProperties"]["Icon#9:72"]["value"], "9:41"); + assert_eq!(by_id(instance, "I9:20;9:11")["visible"], false); + assert_eq!(by_id(instance, "I9:20;9:12")["characters"], "Name on card"); + assert_eq!(by_id(instance, "I9:20;9:13")["componentId"], "9:41"); + + let (doc, _) = crate::rest::import_fig(&rest, &crate::rest::no_images).unwrap(); + let props = instance_props(&doc); + assert_eq!(props.get("Hint"), Some(&Value::Bool(false))); + assert_eq!(props.get("Label"), Some(&Value::String("Name on card".into()))); +} diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index be5c71576..78279e915 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -6,8 +6,8 @@ //! (`relativeTransform`, else `absoluteBoundingBox` + `rotation`), paints, strokes, effects, //! corners, constraints, auto layout, layout grids, export settings, text with styled runs, //! vectors from `fillGeometry` / `strokeGeometry`, components, component sets, instances and -//! component properties are imported. Image fills keep their `imageRef` as the image key and -//! show a grey placeholder unless an [`ImageResolver`] supplies the bytes (nothing is fetched). +//! component properties (slots included) are imported. Image fills keep their `imageRef` as the +//! image key and show a grey placeholder unless an [`ImageResolver`] supplies the bytes (nothing is fetched). //! Prototyping comes along: interactions (triggers, actions, transitions, easing), flows, //! scrolling and the prototype device. Variables come from the local variables response //! (`GET /v1/files/:key/variables/local`), embedded next to the file JSON or passed separately @@ -37,6 +37,8 @@ pub(crate) mod proto; #[cfg(test)] mod proto_variable_tests; #[cfg(test)] +pub(crate) mod slot_tests; +#[cfg(test)] mod tests; mod text; mod variables; @@ -106,12 +108,25 @@ pub fn is_variables_json(v: &Value) -> bool { } /// The collections and variables of a local variables response (or its `meta`), checked like a -/// file's, with the warnings about what was skipped; None when `v` isn't one. +/// file's, with the warnings about what was skipped (and when it holds no variables at all, or +/// fewer than its collections list); None when `v` isn't one. pub fn read_variables(v: &Value) -> Option<(figcraft_doc::Variables, Vec)> { - let src = variables::source(v)?; + variables::source(v)?; let mut cx = Ctx::new(Document::new("Variables"), &no_images); - cx.variables(src); - Some((std::mem::take(&mut cx.doc.variables), cx.warnings.finish())) + cx.variables(v); + let vars = std::mem::take(&mut cx.doc.variables); + let mut warnings = cx.warnings.finish(); + if vars.variables.is_empty() { + warnings.push("no variables found: expected `meta.variables` (an object of variables by id, next to `meta.variableCollections`)".into()); + } else { + let listed = variables::listed_ids(v); + let read: BTreeSet<&str> = vars.variables.iter().map(|x| x.id.as_str()).collect(); + let missing = listed.difference(&read).count(); + if missing > 0 { + warnings.push(format!("{missing} of the {} variables the collections list in `variableIds` weren't imported", listed.len())); + } + } + Some((vars, warnings)) } /// Import `bytes` when they are REST API file JSON; `None` when they are something else (the @@ -191,7 +206,7 @@ fn import_parts( cx.collect_meta(e); } // Variables first: layers link to them as they are built. - for src in [variables::source(v), variables.and_then(variables::source)].into_iter().flatten() { + for src in [Some(v), variables].into_iter().flatten() { cx.variables(src); } if variables.is_some_and(|x| variables::source(x).is_none()) { @@ -368,6 +383,10 @@ pub(crate) struct Ctx<'a> { style_meta: BTreeMap<&'a str, &'a Value>, /// Instances (imported as frames until their main is resolved) with their JSON. instances: Vec<(NodeId, &'a Value)>, + /// `SLOT` layers (imported as frames until their slot property is resolved) with their JSON. + slots: Vec<(NodeId, &'a Value)>, + /// Slot property defaults (the main's slot content id): (component or set, property) → value. + slot_defaults: BTreeMap<(NodeId, String), &'a Value>, /// Instance-swap property defaults: (component or set, property, REST id of the default). swap_defaults: Vec<(NodeId, String, String)>, /// Property definitions of component sets, shared by their variants. @@ -402,6 +421,8 @@ impl<'a> Ctx<'a> { set_meta: BTreeMap::new(), style_meta: BTreeMap::new(), instances: Vec::new(), + slots: Vec::new(), + slot_defaults: BTreeMap::new(), swap_defaults: Vec::new(), set_defs: BTreeMap::new(), flows: Vec::new(), @@ -502,8 +523,10 @@ impl<'a> Ctx<'a> { sources.record(id, j, primary); } } - if ty == "INSTANCE" { - self.instances.push((id, j)); + match ty { + "INSTANCE" => self.instances.push((id, j)), + "SLOT" => self.slots.push((id, j)), + _ => {} } let kids = children(j); if kids.is_empty() { @@ -526,8 +549,8 @@ impl<'a> Ctx<'a> { let point_count = || num(j, "pointCount").filter(|c| *c >= 3.0).map(|c| c.min(1000.0) as u32); let has_geometry = !list(j, "fillGeometry").is_empty() || !list(j, "strokeGeometry").is_empty(); Some(match ty { - // Instances start as frames; `finish` links them to their main. - "FRAME" | "INSTANCE" => NodeKind::Frame, + // Instances and slots start as frames; `finish` links them to their main and property. + "FRAME" | "INSTANCE" | "SLOT" => NodeKind::Frame, "GROUP" => NodeKind::Group, "SECTION" => NodeKind::Section { contents_hidden: flag(j, "sectionContentsHidden").unwrap_or(false) }, "RECTANGLE" => NodeKind::Rectangle, @@ -738,7 +761,7 @@ impl<'a> Ctx<'a> { } /// Component property definitions (variant ones are implied by the variant names). - fn prop_defs(&mut self, owner: NodeId, j: &Value) -> BTreeMap { + fn prop_defs(&mut self, owner: NodeId, j: &'a Value) -> BTreeMap { let mut out = BTreeMap::new(); for (name, d) in j.get("componentPropertyDefinitions").and_then(Value::as_object).into_iter().flatten().take(256) { let default = d.get("defaultValue"); @@ -752,7 +775,13 @@ impl<'a> Ctx<'a> { continue; } Some("VARIANT") => continue, - Some("SLOT") if self.fig_sources.is_some() => PropDef::Slot { preferred: Vec::new() }, + Some("SLOT") => { + if let Some(d) = default { + self.slot_defaults.insert((owner, name.clone()), d); + } + self.slot_notes(d); + PropDef::Slot { preferred: Vec::new() } + } _ => { self.warn("unknown component property types were skipped"); continue; @@ -763,6 +792,20 @@ impl<'a> Ctx<'a> { out } + /// Notes on what a slot property sets that the editor doesn't keep: preferred components (keys + /// of library components, not layers of the file) and limits on its content. + fn slot_notes(&mut self, d: &Value) { + if !list(d, "preferredValues").is_empty() { + self.warn("preferred slot component keys cannot be resolved to file nodes and were skipped"); + } + // Insertion and empty-slot display only change editing aids, not what the file shows. + let s = d.get("slotSettings"); + let limit = |k: &str| s.and_then(|s| num(s, k)).is_some_and(|n| n != 0.0); + if limit("minChildren") || limit("maxChildren") || s.and_then(|s| flag(s, "allowPreferredValuesOnly")) == Some(true) { + self.warn("non-default slot settings are not represented and were skipped"); + } + } + /// Shared styles with their values (`styleDefinitions`, written by the `.fig` import: a list of /// `{id, name, description, styleType, node}`, the node being a layer that shows the style), in /// the file's order. Layers then link to them; styles that no layer uses come across too. @@ -971,6 +1014,7 @@ impl<'a> Ctx<'a> { /// outside components, and instances that would nest a component inside itself (directly or /// through other components), stay frames. fn resolve_instances(&mut self) { + let frames: BTreeSet = self.instances.iter().map(|(i, _)| *i).collect(); let mut links: Vec<(NodeId, NodeId, &'a Value)> = Vec::new(); for (inst, j) in std::mem::take(&mut self.instances) { match text(j, "componentId").and_then(|r| self.component(r)) { @@ -1000,6 +1044,7 @@ impl<'a> Ctx<'a> { linked.push((inst, j)); } } + self.resolve_slots(&frames, &linked); // Overridden fields the file lists, on the instance and its layers. for (inst, j) in linked { for o in list(j, "overrides") { @@ -1015,6 +1060,70 @@ impl<'a> Ctx<'a> { } } + /// `SLOT` layers. In a main component a slot is bound to its slot property: the one the layer + /// references, else the one named like it, else the main's only slot property left. In an + /// instance it follows its main's slot, and holds the instance's own content (kept across syncs) + /// unless the instance's value of the property is the main's default content (without the + /// values to compare, a slot holding layers keeps them as its own). A slot without a property + /// stays a frame. `frames`: every instance + /// layer of the file, `linked`: those that became instances. + fn resolve_slots(&mut self, frames: &BTreeSet, linked: &[(NodeId, &'a Value)]) { + let linked: BTreeMap = linked.iter().copied().collect(); + let mut bound: BTreeSet<(NodeId, String)> = BTreeSet::new(); + for (id, j) in std::mem::take(&mut self.slots) { + // The nearest main component or instance above the slot owns it; in an instance whose + // main isn't in the file (a frame), the slot stays a frame too. + let owner = self.doc.ancestors(id).into_iter().find_map(|a| match self.doc.node(a).map(|n| &n.kind) { + Some(NodeKind::Instance { component, .. }) => Some(linked.get(&a).map(|j| (*component, Some(*j)))), + Some(NodeKind::Component { .. }) => Some(Some((a, None))), + _ => frames.contains(&a).then_some(None), + }); + let Some(owner) = owner else { + self.warn("slot layers without a matching slot property were imported as frames"); + continue; + }; + let Some((main, inst)) = owner else { continue }; + let Some(NodeKind::Component { props, .. }) = self.doc.node(main).map(|n| &n.kind) else { continue }; + let defs: Vec<&String> = props.iter().filter(|(_, d)| matches!(d, PropDef::Slot { .. })).map(|(k, _)| k).collect(); + let layer = text(j, "name").unwrap_or_default(); + let refs: Vec<&str> = + j.get("componentPropertyReferences").and_then(Value::as_object).into_iter().flatten().filter_map(|(_, v)| v.as_str()).collect(); + // An instance's slot is matched against all of its main's slot properties. + let free: Vec<&&String> = defs.iter().filter(|d| inst.is_some() || !bound.contains(&(main, (**d).clone()))).collect(); + let name = defs + .iter() + .find(|d| refs.contains(&d.as_str())) + .or_else(|| defs.iter().find(|d| short_prop_name(d) == layer)) + .or_else(|| if free.len() == 1 { free.first().copied() } else { None }) + .map(|d| (*d).clone()); + let Some(n) = self.doc.node(id) else { continue }; + let has_content = !n.children.is_empty(); + let Some(inst) = inst else { + match name.filter(|p| bound.insert((main, p.clone()))) { + Some(p) => { + if let Some(n) = self.doc.node_mut(id) { + n.prop_refs.insert("slot".into(), p); + } + } + None => self.warn("slot layers without a matching slot property were imported as frames"), + } + continue; + }; + let set = self.doc.node(main).and_then(|n| n.parent); + let default = + name.as_ref().and_then(|p| [Some(main), set].into_iter().flatten().find_map(|o| self.slot_defaults.get(&(o, p.clone())).copied())); + let value = name.as_ref().and_then(|p| inst.get("componentProperties")?.get(p)?.get("value")); + let own = name.is_some() + && match (value, default) { + (Some(v), Some(d)) => v != d, + _ => has_content, + }; + if own && let Some(n) = self.doc.node_mut(id) { + n.overrides.insert("slot".into()); + } + } + } + /// An instance's property values (variant values are implied by the main it shows). fn instance_props(&self, j: &Value) -> BTreeMap { let mut out = BTreeMap::new(); diff --git a/crates/format/src/rest/slot_tests.rs b/crates/format/src/rest/slot_tests.rs new file mode 100644 index 000000000..d85d15c62 --- /dev/null +++ b/crates/format/src/rest/slot_tests.rs @@ -0,0 +1,229 @@ +//! `SLOT` layers of the REST file JSON: bound to their slot property in a main component, and +//! holding each instance's own content (synthetic files). + +use super::*; +use serde_json::json; + +fn at(x: f64, y: f64) -> Value { + json!([[1, 0, x], [0, 1, y]]) +} + +pub(crate) fn file(children: Value) -> Value { + json!({"name": "Slots", "schemaVersion": 0, "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT", "children": [ + {"id": "0:1", "name": "Page", "type": "CANVAS", "children": children} + ]}}) +} + +fn content_id(local: u32) -> Value { + json!({"guid": {"sessionID": 5, "localID": local}}) +} + +fn slot(id: &str, name: &str, children: Value) -> Value { + json!({"id": id, "name": name, "type": "SLOT", "relativeTransform": at(10.0, 40.0), "size": {"x": 280, "y": 100}, "children": children}) +} + +fn label(id: &str, text: &str) -> Value { + json!({"id": id, "name": text, "type": "TEXT", "characters": text, "relativeTransform": at(0.0, 0.0), "size": {"x": 100, "y": 20}}) +} + +/// A "Chip" component (`1:9`), a "Container" component (`1:2`) with a "Title" text and an empty +/// "Slot" (property `Slot#1:3`, default content 5:1), and the given layers after them. +pub(crate) fn container_file(more: Vec) -> Value { + let mut layers = vec![ + json!({"id": "1:9", "name": "Chip", "type": "COMPONENT", "relativeTransform": at(0.0, 400.0), "size": {"x": 40, "y": 20}, "children": [ + {"id": "1:10", "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}} + ]}), + json!({"id": "1:2", "name": "Container", "type": "COMPONENT", "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 160}, + "componentPropertyDefinitions": {"Slot#1:3": {"type": "SLOT", "defaultValue": content_id(1)}}, + "children": [label("1:4", "Title"), slot("1:5", "Slot", json!([]))]}), + ]; + layers.extend(more); + file(Value::Array(layers)) +} + +/// An instance `id` of Container at `x` whose slot holds `content` (property value `value`). +pub(crate) fn container(id: &str, x: f64, value: Value, content: Value) -> Value { + json!({"id": id, "name": "Container", "type": "INSTANCE", "componentId": "1:2", "relativeTransform": at(x, 0.0), "size": {"x": 300, "y": 160}, + "componentProperties": {"Slot#1:3": {"type": "SLOT", "value": value}}, + "children": [label(&format!("I{id};1:4"), "Title"), slot(&format!("I{id};1:5"), "Slot", content)]}) +} + +pub(crate) fn chip(id: &str) -> Value { + json!({"id": id, "name": "Chip", "type": "INSTANCE", "componentId": "1:9", "relativeTransform": at(0.0, 30.0), "size": {"x": 40, "y": 20}, "children": [ + {"id": format!("I{id};1:10"), "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}} + ]}) +} + +fn by_rest<'d>(doc: &'d Document, v: &Value, rest: &str) -> &'d Node { + // Names repeat across copies: find by position in the file's tree instead. + fn path(v: &Value, rest: &str, out: &mut Vec) -> bool { + if v.get("id").and_then(Value::as_str) == Some(rest) { + return true; + } + for (i, c) in v.get("children").and_then(Value::as_array).into_iter().flatten().enumerate() { + out.push(i); + if path(c, rest, out) { + return true; + } + out.pop(); + } + false + } + let mut p = Vec::new(); + assert!(path(&v["document"]["children"][0], rest, &mut p), "no {rest} in the file"); + let mut id = doc.pages[0]; + for i in p { + id = doc.node(id).unwrap().children[i]; + } + doc.node(id).unwrap() +} + +fn names(doc: &Document, n: &Node) -> Vec { + n.children.iter().map(|c| doc.node(*c).unwrap().name.clone()).collect() +} + +#[test] +fn slot_layers_bind_to_their_property_and_keep_instance_content() { + let v = container_file(vec![ + container("2:1", 400.0, content_id(7), json!([label("2:2", "Reports in progress"), chip("2:3")])), + container("3:1", 800.0, content_id(1), json!([])), + ]); + let (doc, warnings) = import(&v, &no_images).unwrap(); + assert!(warnings.iter().all(|w| !w.contains("SLOT") && !w.contains("slot")), "{warnings:?}"); + + let main = by_rest(&doc, &v, "1:2"); + let NodeKind::Component { props, .. } = &main.kind else { panic!("{:?}", main.kind) }; + assert_eq!(props.get("Slot"), Some(&PropDef::Slot { preferred: Vec::new() }), "named without its #id suffix"); + let main_slot = by_rest(&doc, &v, "1:5"); + assert!(matches!(main_slot.kind, NodeKind::Frame)); + assert_eq!(main_slot.prop_refs.get("slot").map(String::as_str), Some("Slot")); + assert!(main_slot.children.is_empty(), "the main's slot stays empty"); + + let filled = by_rest(&doc, &v, "I2:1;1:5"); + assert!(filled.overrides.contains("slot"), "the instance's own content"); + assert!(filled.prop_refs.is_empty(), "an instance's slot follows its main's"); + assert_eq!(names(&doc, filled), ["Reports in progress", "Chip"]); + let chip = by_rest(&doc, &v, "2:3"); + assert!(matches!(chip.kind, NodeKind::Instance { .. }), "instances in slot content link to their own main"); + + let default = by_rest(&doc, &v, "I3:1;1:5"); + assert!(!default.overrides.contains("slot"), "the main's default content follows the main"); +} + +#[test] +fn slot_ownership_without_values_follows_the_content() { + let mut owned = container("2:1", 400.0, json!(null), json!([label("2:2", "Own")])); + owned["componentProperties"] = json!({}); + let mut empty = container("3:1", 800.0, json!(null), json!([])); + empty["componentProperties"] = json!({}); + let v = container_file(vec![owned, empty]); + let (doc, _) = import(&v, &no_images).unwrap(); + assert!(by_rest(&doc, &v, "I2:1;1:5").overrides.contains("slot")); + assert!(!by_rest(&doc, &v, "I3:1;1:5").overrides.contains("slot")); +} + +#[test] +fn slots_match_by_reference_then_name_then_the_one_left() { + let comp = json!({"id": "1:2", "name": "Card", "type": "COMPONENT", "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 300}, + "componentPropertyDefinitions": { + "Header#1:3": {"type": "SLOT", "defaultValue": content_id(1)}, + "Body#1:4": {"type": "SLOT", "defaultValue": content_id(2)}, + "Footer#1:5": {"type": "SLOT", "defaultValue": content_id(3)}, + "Visible#1:6": {"type": "BOOLEAN", "defaultValue": true} + }, + "children": [ + {"id": "1:7", "name": "Top", "type": "SLOT", "componentPropertyReferences": {"visible": "Visible#1:6", "slot": "Header#1:3"}, + "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 50}}, + {"id": "1:8", "name": "Body", "type": "SLOT", "relativeTransform": at(0.0, 50.0), "size": {"x": 300, "y": 50}}, + {"id": "1:9", "name": "Bottom", "type": "SLOT", "relativeTransform": at(0.0, 100.0), "size": {"x": 300, "y": 50}} + ]}); + let v = file(json!([comp])); + let (doc, warnings) = import(&v, &no_images).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let bound = |rest: &str| by_rest(&doc, &v, rest).prop_refs.get("slot").cloned(); + assert_eq!(bound("1:7").as_deref(), Some("Header")); + assert_eq!(by_rest(&doc, &v, "1:7").prop_refs.get("visible").map(String::as_str), Some("Visible")); + assert_eq!(bound("1:8").as_deref(), Some("Body")); + assert_eq!(bound("1:9").as_deref(), Some("Footer")); +} + +#[test] +fn variants_share_their_set_slot_property() { + let variant = |id: &str, name: &str, x: f64| { + json!({"id": id, "name": name, "type": "COMPONENT", "relativeTransform": at(x, 0.0), "size": {"x": 100, "y": 100}, + "children": [slot(&format!("{id}s"), "Content", json!([]))]}) + }; + let set = json!({"id": "1:1", "name": "Panel", "type": "COMPONENT_SET", "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 100}, + "componentPropertyDefinitions": {"Size": {"type": "VARIANT", "defaultValue": "Small"}, "Content#2:1": {"type": "SLOT", "defaultValue": content_id(1)}}, + "children": [variant("1:2", "Size=Small", 0.0), variant("1:3", "Size=Large", 150.0)]}); + let inst = json!({"id": "4:1", "name": "Panel", "type": "INSTANCE", "componentId": "1:3", "relativeTransform": at(0.0, 300.0), "size": {"x": 100, "y": 100}, + "componentProperties": {"Content#2:1": {"type": "SLOT", "value": content_id(9)}}, + "children": [slot("I4:1;1:3s", "Content", json!([label("4:2", "Mine")]))]}); + let v = file(json!([set, inst])); + let (doc, warnings) = import(&v, &no_images).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + for rest in ["1:2s", "1:3s"] { + assert_eq!(by_rest(&doc, &v, rest).prop_refs.get("slot").map(String::as_str), Some("Content"), "{rest}"); + } + assert!(by_rest(&doc, &v, "I4:1;1:3s").overrides.contains("slot")); +} + +#[test] +fn nested_instances_own_the_slots_of_their_main() { + // A "Screen" main places a Container whose slot holds a chip; an instance of Screen places + // other content in that nested slot. + let screen = json!({"id": "6:1", "name": "Screen", "type": "COMPONENT", "relativeTransform": at(0.0, 600.0), "size": {"x": 300, "y": 160}, + "children": [container("6:2", 0.0, content_id(7), json!([chip("6:3")]))]}); + let nested = container("I7:1;6:2", 0.0, content_id(8), json!([label("7:2", "Other")])); + let outer = json!({"id": "7:1", "name": "Screen", "type": "INSTANCE", "componentId": "6:1", "relativeTransform": at(400.0, 600.0), "size": {"x": 300, "y": 160}, + "children": [nested]}); + let v = container_file(vec![screen, outer]); + let (doc, _) = import(&v, &no_images).unwrap(); + let in_main = by_rest(&doc, &v, "I6:2;1:5"); + assert!(in_main.overrides.contains("slot") && in_main.prop_refs.is_empty()); + assert!(matches!(by_rest(&doc, &v, "6:3").kind, NodeKind::Instance { .. })); + let in_outer = by_rest(&doc, &v, "II7:1;6:2;1:5"); + assert!(in_outer.overrides.contains("slot")); + assert_eq!(names(&doc, in_outer), ["Other"]); +} + +#[test] +fn slots_outside_components_or_without_a_property_stay_frames() { + let mut no_def = container_file(Vec::new()); + no_def["document"]["children"][0]["children"][1].as_object_mut().unwrap().remove("componentPropertyDefinitions"); + let loose = slot("8:1", "Loose", json!([label("8:2", "Kept")])); + no_def["document"]["children"][0]["children"].as_array_mut().unwrap().push(loose); + let (doc, warnings) = import(&no_def, &no_images).unwrap(); + assert!(warnings.iter().any(|w| w.starts_with("slot layers without a matching slot property were imported as frames (×2)")), "{warnings:?}"); + let kept = by_rest(&doc, &no_def, "8:1"); + assert!(matches!(kept.kind, NodeKind::Frame) && kept.prop_refs.is_empty()); + assert_eq!(names(&doc, kept), ["Kept"]); + assert!(by_rest(&doc, &no_def, "1:5").prop_refs.is_empty()); +} + +#[test] +fn slot_settings_the_editor_cannot_keep_are_noted() { + let mut v = container_file(Vec::new()); + v["document"]["children"][0]["children"][1]["componentPropertyDefinitions"]["Slot#1:3"]["slotSettings"] = + json!({"stretchChildOnInsert": true, "displayEmptyByDefault": true, "minChildren": null, "maxChildren": 3}); + v["document"]["children"][0]["children"][1]["componentPropertyDefinitions"]["Slot#1:3"]["preferredValues"] = + json!([{"type": "COMPONENT", "key": "abc"}]); + let (_, warnings) = import(&v, &no_images).unwrap(); + assert!(warnings.iter().any(|w| w == "non-default slot settings are not represented and were skipped"), "{warnings:?}"); + assert!(warnings.iter().any(|w| w == "preferred slot component keys cannot be resolved to file nodes and were skipped"), "{warnings:?}"); + let mut plain = container_file(Vec::new()); + plain["document"]["children"][0]["children"][1]["componentPropertyDefinitions"]["Slot#1:3"]["slotSettings"] = + json!({"stretchChildOnInsert": true, "minChildren": null, "maxChildren": null, "allowPreferredValuesOnly": false}); + assert!(import(&plain, &no_images).unwrap().1.is_empty()); +} + +#[test] +fn junk_slot_json_never_panics() { + for junk in [json!(null), json!(1), json!("x"), json!([]), json!({"guid": null}), json!({"type": "SLOT"})] { + let mut v = container_file(vec![container("2:1", 400.0, junk.clone(), json!([label("2:2", "A")]))]); + v["document"]["children"][0]["children"][1]["componentPropertyDefinitions"]["Slot#1:3"] = + json!({"type": "SLOT", "defaultValue": junk.clone(), "slotSettings": junk.clone(), "preferredValues": junk.clone()}); + v["document"]["children"][0]["children"][1]["children"][1]["componentPropertyReferences"] = json!({"slot": junk}); + assert!(import(&v, &no_images).is_ok()); + } +} diff --git a/crates/format/src/rest/variables.rs b/crates/format/src/rest/variables.rs index 91d14001c..d58da409b 100644 --- a/crates/format/src/rest/variables.rs +++ b/crates/format/src/rest/variables.rs @@ -27,6 +27,16 @@ pub fn source(v: &Value) -> Option<&Value> { [v.get("meta"), Some(v)].into_iter().flatten().find(|m| m.get("variableCollections").is_some_and(Value::is_object)) } +/// The variable ids the collections of a local variables response list in `variableIds`. +pub(super) fn listed_ids(v: &Value) -> BTreeSet<&str> { + source(v) + .into_iter() + .flat_map(|src| objects(src, "variableCollections", MAX_COLLECTIONS)) + .flat_map(|(_, c)| list(c, "variableIds").iter().filter_map(Value::as_str).map(clip_str)) + .take(MAX_VARIABLES) + .collect() +} + pub(super) fn kind_of(t: &str) -> Option { Some(match t { "COLOR" => VariableType::Color, @@ -118,14 +128,18 @@ fn objects<'v>(v: &'v Value, key: &str, max: usize) -> impl Iterator { - /// Add the collections and variables of a local variables response (or its `meta`). - pub(super) fn variables(&mut self, src: &Value) { + /// Add the collections and variables of a local variables response (or its `meta`); nothing + /// when `v` isn't one. The `variables` map is read next to `variableCollections`, or from + /// `meta` or the top level when it sits on the other side. + pub(super) fn variables(&mut self, v: &Value) { + let Some(src) = source(v) else { return }; for (key, c) in objects(src, "variableCollections", MAX_COLLECTIONS) { self.collection(key, c); } + let holder = [Some(src), v.get("meta"), Some(v)].into_iter().flatten().find(|m| m.get("variables").is_some_and(Value::is_object)); let mut found: Vec = Vec::new(); let mut seen: BTreeSet = BTreeSet::new(); - for (key, v) in objects(src, "variables", MAX_VARIABLES) { + for (key, v) in holder.into_iter().flat_map(|h| objects(h, "variables", MAX_VARIABLES)) { if let Some(var) = self.variable(key, v) && seen.insert(var.id.clone()) { diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 79ef49458..7714da5a3 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -147,7 +147,7 @@ pub fn tool_definitions() -> Vec { "type": {"type": "string", "enum": ["frame", "rectangle", "ellipse", "polygon", "star", "line", "arrow", "text", "vector", "section", "component", "slice"], "description": "Layer type"}, "x": num("Left, relative to the parent frame (default 0)"), "y": num("Top, relative to the parent frame (default 0)"), - "width": num("Width (default 100; text hugs its content unless width is set, which makes it wrap)"), + "width": num("Width (default 100; text hugs its content unless width is set, which makes it wrap; for a vector with path, width/height scale the path, one alone keeping its proportion, and without them the path's own box is used)"), "height": num("Height (default 100; ignored for lines/arrows and auto-height text)"), "parent": id_schema("Parent frame/group/component id (default: the current page)"), "index": int("Z-index among the parent's children (default: on top)"), diff --git a/crates/text/build.rs b/crates/text/build.rs index d02defefe..05dfc6533 100644 --- a/crates/text/build.rs +++ b/crates/text/build.rs @@ -65,9 +65,10 @@ fn craft_fonts(dir: &std::path::Path, manifest_rel: &str) -> Result)>, /// System faces known by name but not loaded (lower-case family → entries). pending: BTreeMap)>, + /// System faces read from disk (file, face index), so a rescan only offers new ones. + loaded_files: std::collections::BTreeSet<(std::path::PathBuf, u32)>, + /// Font files added as bytes (hash and length → families), so loading one again adds nothing. + loaded_bytes: std::collections::HashMap<(u64, usize), Vec>, system_scanned: bool, } +/// What a later walk of the font folders found ([`rescan_system_fonts`]). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Rescan { + /// Families that were unknown before. + pub added: Vec, + /// Every family that gained faces: the new ones and those with styles installed since. + pub changed: Vec, +} + static DB: OnceLock> = OnceLock::new(); /// Set by [`scan_system_fonts_on_demand`]. static ON_DEMAND: AtomicBool = AtomicBool::new(false); @@ -580,6 +593,7 @@ impl FontDb { }; if let Some(face) = Face::from_bytes(FontBytes::Shared(bytes), e.index, None, Some(&e.style)) { self.add(face); + self.loaded_files.insert((e.path, e.index)); } } } @@ -670,6 +684,35 @@ impl FontDb { self.faces.len() } + /// Offer the families of a later walk of the font folders: new families become pending, a + /// family not loaded yet takes the walk's files, and a loaded family keeps its faces and gets + /// the files it hasn't read (styles installed since) as pending, read when it is next used. + fn merge_rescan(&mut self, found: BTreeMap)>) -> Rescan { + let mut out = Rescan::default(); + for (k, (display, entries)) in found { + if self.families.contains_key(&k) { + let waiting = self.pending.get(&k).map(|(_, v)| v.clone()).unwrap_or_default(); + let new: Vec = entries + .into_iter() + .filter(|e| { + !self.loaded_files.contains(&(e.path.clone(), e.index)) && !waiting.iter().any(|w| w.path == e.path && w.index == e.index) + }) + .collect(); + if !new.is_empty() { + out.changed.push(display.clone()); + self.pending.entry(k).or_insert_with(|| (display, Vec::new())).1.extend(new); + } + continue; + } + if !self.pending.contains_key(&k) { + out.added.push(display.clone()); + } + out.changed.push(display.clone()); + self.pending.insert(k, (display, entries)); + } + out + } + /// Index the system font directories (desktop only): family names are read now, faces loaded /// when first used. Safe to call more than once. pub fn scan_system(&mut self) { @@ -776,13 +819,19 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap MAX_FONT_FILE { + continue; + } + let Some(bytes) = usize::try_from(meta.len()).ok().and_then(|l| index_bytes(&path, l)) else { continue }; for (index, family, style) in faces_in(&bytes) { out.entry(family.to_lowercase()).or_insert_with(|| (family.clone(), Vec::new())).1.push(Pending { path: path.clone(), index, style }); } } } +/// The largest font file read (CJK collections run to ~120 MB, Apple Color Emoji ~180 MB). +pub const MAX_FONT_FILE: u64 = 256 * 1024 * 1024; + /// The parts of a font file the scan reads: headers, table directories and `name` tables, at their /// offsets in a zeroed buffer of the file's length (reading whole files was most of the scan). fn index_bytes(path: &std::path::Path, len: usize) -> Option> { @@ -887,6 +936,55 @@ pub fn scan_system_fonts() { }); } +/// Walk the system font folders again, for fonts installed while the app runs (the first scan is +/// once per process): the families that were unknown before, and every family that gained faces. +/// Before the first scan this is that scan; a no-op on the web or with `FIGCRAFT_NO_SYSTEM_FONTS`. +pub fn rescan_system_fonts() -> Rescan { + #[cfg(not(target_arch = "wasm32"))] + if !system_fonts_opted_out() { + if SCANNED.get().is_none() { + let before: std::collections::BTreeSet = + raw_db().read().unwrap_or_else(std::sync::PoisonError::into_inner).families().into_iter().collect(); + scan_system_fonts(); + let added: Vec = family_names().into_iter().filter(|f| !before.contains(f)).collect(); + return Rescan { changed: added.clone(), added }; + } + let mut found: BTreeMap)> = BTreeMap::new(); + for dir in system_font_dirs() { + scan_dir(&dir, 0, &mut found); + } + let rescan = raw_db().write().unwrap_or_else(std::sync::PoisonError::into_inner).merge_rescan(found); + if !rescan.changed.is_empty() { + crate::clear_layout_cache(); + } + return rescan; + } + Rescan::default() +} + +/// Load font file bytes (every face of a collection) into the global db, for a font that isn't +/// installed. Returns the families of its faces (none: not a font file); the same file again +/// returns them without adding its faces twice. +pub fn load_font_bytes(bytes: Vec) -> Vec { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + bytes.hash(&mut h); + let key = (h.finish(), bytes.len()); + let mut db = db().write().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(families) = db.loaded_bytes.get(&key) { + return families.clone(); + } + let faces = db.add_bytes(bytes); + let mut families: Vec = faces.iter().map(|f| f.family.clone()).collect(); + families.dedup(); + if !families.is_empty() { + db.loaded_bytes.insert(key, families.clone()); + drop(db); + crate::clear_layout_cache(); + } + families +} + /// `FIGCRAFT_NO_SYSTEM_FONTS` is set (and not `0`): renders use only the embedded fonts. #[cfg(not(target_arch = "wasm32"))] fn system_fonts_opted_out() -> bool { diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs index 96ce78426..f2456d28b 100644 --- a/crates/text/src/fontdb_tests.rs +++ b/crates/text/src/fontdb_tests.rs @@ -217,3 +217,49 @@ fn user_font_dirs_preserve_non_utf8_paths() { assert_eq!(dirs[0].as_os_str().as_bytes(), b"/home/\xff/.fonts"); assert_eq!(dirs[1].as_os_str().as_bytes(), b"/data/\xfe/fonts"); } + +/// A rescan offers new families and the styles installed since, and never a file twice. +#[test] +fn a_rescan_offers_fonts_installed_since() { + let entry = |s: &str| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: s.into() }; + let mut db = pending(&["Regular"]); + let found = |fams: &[(&str, &[&str])]| -> BTreeMap)> { + fams.iter().map(|(f, styles)| (f.to_lowercase(), ((*f).to_string(), styles.iter().map(|s| entry(s)).collect()))).collect() + }; + // A new family is added; a pending one takes the walk's files (a new style); both changed. + let r = db.merge_rescan(found(&[("CJK UI", &["Regular", "Bold"]), ("Brand Sans", &["Regular"])])); + assert_eq!(r.added, ["Brand Sans"]); + assert_eq!(r.changed, ["Brand Sans", "CJK UI"]); + assert!(db.has_family("brand sans")); + assert_eq!(left(&db), ["Regular", "Bold"]); + // A loaded family keeps its faces and gets only the files it hasn't read, once. + db.families.insert("brand sans".into(), ("Brand Sans".into(), Vec::new())); + db.pending.remove("brand sans"); + db.loaded_files.insert(("no-such-dir/Regular.ttf".into(), 0)); + let same = db.merge_rescan(found(&[("Brand Sans", &["Regular"])])); + assert_eq!(same, Rescan::default()); + assert!(!db.pending.contains_key("brand sans")); + let bold = db.merge_rescan(found(&[("Brand Sans", &["Regular", "Black"])])); + assert_eq!((bold.added.len(), bold.changed), (0, vec!["Brand Sans".to_string()])); + let waiting = |db: &FontDb| db.pending.get("brand sans").map(|(_, v)| v.iter().map(|e| e.style.clone()).collect::>()).unwrap_or_default(); + assert_eq!(waiting(&db), ["Black"]); + assert_eq!(db.merge_rescan(found(&[("Brand Sans", &["Regular", "Black"])])), Rescan::default()); + assert_eq!(waiting(&db), ["Black"]); +} + +/// Font file bytes add their faces once; junk adds nothing. +#[test] +fn font_bytes_load_into_the_db() { + assert!(load_font_bytes(b"not a font".to_vec()).is_empty()); + assert!(load_font_bytes(Vec::new()).is_empty()); + // With the embedded fonts (absent on CI builds without craft-fonts): a file's family is added, + // and loading the same file again returns it without adding its faces twice. + if let Some(f) = CRAFT_FONTS.first() { + let families = load_font_bytes(f.bytes.to_vec()); + assert_eq!(families.len(), 1); + assert!(families.iter().all(|f| has_family(f))); + let faces = db().read().unwrap().face_count(); + assert_eq!(load_font_bytes(f.bytes.to_vec()), families); + assert_eq!(db().read().unwrap().face_count(), faces); + } +} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 7fc6e11ad..26ae3d338 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -82,6 +82,9 @@ pub struct Line { pub x: f64, /// The line ends a paragraph (hard break or end of text). pub para_end: bool, + /// The line ends with a line break char: a newline, or a line separator (U+2028) that breaks + /// the line inside its paragraph. + pub break_after: bool, /// The paragraph runs right to left (its first strong character is right-to-left). pub rtl: bool, } @@ -157,6 +160,15 @@ fn deco_len(m: DecorationLength, size: f64, auto: f64) -> f64 { if v.is_finite() { v } else { auto } } +/// The line separator (U+2028): a line break inside a paragraph, as `.fig` files store a soft +/// break (imported text keeps it; typed text has newlines only). +const LINE_SEPARATOR: char = '\u{2028}'; + +/// A char that ends its line: a newline (which also ends the paragraph) or a line separator. +fn is_line_break(c: char) -> bool { + c == '\n' || c == LINE_SEPARATOR +} + /// Opening punctuation, which hangs outside a line's start. fn opens(c: char) -> bool { matches!(c, '"' | '\'' | '\u{201C}' | '\u{2018}' | '\u{201E}' | '\u{201A}' | '\u{00AB}' | '\u{2039}' | '(' | '[' | '{' | '\u{00BF}' | '\u{00A1}') @@ -332,7 +344,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) } // Colour emoji are their own, large faces (Apple Color Emoji is ~180 MB): they are read only // for a text with emoji, or with characters nothing above covers. - let covered = |c: char| c == '\n' || faces.iter().flatten().any(|f| f.has_char(c)) || fallbacks.iter().any(|f| f.has_char(c)); + let covered = |c: char| is_line_break(c) || faces.iter().flatten().any(|f| f.has_char(c)) || fallbacks.iter().any(|f| f.has_char(c)); let emoji = if chars.iter().any(|c| is_emoji(*c) || !covered(*c)) { dbw.emoji_fallbacks() } else { Vec::new() }; fallbacks.extend(emoji.iter().cloned()); drop(dbw); @@ -370,7 +382,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) { return f.clone(); } - if c == '\n' || primary.has_char(c) { + if is_line_break(c) || primary.has_char(c) { return primary.clone(); } let fallbacks = style_fallbacks.get(st_idx).unwrap_or(&fallbacks); @@ -382,13 +394,15 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) let mut seg_end = seg_start + 1; while seg_end < *end && chars.get(seg_end).is_some_and(|c| { - *c != '\n' && first != '\n' && (!cluster_start.get(seg_end).copied().unwrap_or(true) || face_for(*c).id == face.id) + !is_line_break(*c) + && !is_line_break(first) + && (!cluster_start.get(seg_end).copied().unwrap_or(true) || face_for(*c).id == face.id) }) && level_at(seg_end) == level { seg_end += 1; } - if first == '\n' { + if is_line_break(first) { items.push(Item { face: face.clone(), coords: no_coords(), @@ -401,7 +415,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) char_start: seg_start, char_end: seg_start + 1, style: st_idx, - ch: '\n', + ch: first, level, }); } else { @@ -465,6 +479,17 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) para += 1; continue; } + // A line separator (a soft break) breaks the line inside its paragraph: no + // paragraph spacing and no new list marker after it. + if it.ch == LINE_SEPARATOR { + lines.push((line_start, i + 1, false, para)); + i += 1; + line_start = i; + x = 0.0; + last_break = None; + para_first_line = false; + continue; + } let line_indent = line_indent_of(para, para_first_line, it.char_start); let w = it.adv; // Hanging punctuation takes no room inside the box. @@ -491,7 +516,8 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) } i += 1; } - if line_start < items.len() || lines.is_empty() || lines.last().is_some_and(|l| l.2) { + let break_after = |e: usize| e.checked_sub(1).and_then(|i| items.get(i)).is_some_and(|it| is_line_break(it.ch)); + if line_start < items.len() || lines.is_empty() || lines.last().is_some_and(|l| l.2 || break_after(l.1)) { lines.push((line_start, items.len(), true, para)); } @@ -546,7 +572,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) let baseline = y + (lh - (asc + desc)) / 2.0 + asc; // Trailing spaces don't count for width/alignment. let mut vis_end = *e; - while vis_end > *s && items.get(vis_end - 1).is_some_and(|it| it.is_space() || it.ch == '\n') { + while vis_end > *s && items.get(vis_end - 1).is_some_and(|it| it.is_space() || is_line_break(it.ch)) { vis_end -= 1; } // Letter spacing separates glyphs: none after the line's last visible one. @@ -563,7 +589,9 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) // Justify: extra space per gap. let gaps = items.get(*s..vis_end).unwrap_or(&[]).iter().filter(|it| it.is_space()).count(); let justify_extra = match (text.align_h, wrap) { - (TextAlignH::Justified, Some(maxw)) if !para_end && gaps > 0 => ((maxw - line_indent + hang_l + hang_r - width) / gaps as f64).max(0.0), + (TextAlignH::Justified, Some(maxw)) if !para_end && !break_after(*e) && gaps > 0 => { + ((maxw - line_indent + hang_l + hang_r - width) / gaps as f64).max(0.0) + } _ => 0.0, }; // Where the visible text ends once justified. @@ -574,7 +602,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) .get(vis_end..*e) .unwrap_or(&[]) .iter() - .filter(|it| it.ch != '\n') + .filter(|it| !is_line_break(it.ch)) .map(|it| it.adv + if it.is_space() { justify_extra } else { 0.0 }) .sum(); let mut pen = if rtl_line { -trailing } else { line_indent - hang_l }; @@ -649,7 +677,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) (0..line_items.len()).collect() }; for it in order.iter().filter_map(|k| line_items.get(*k)) { - if it.ch == '\n' { + if is_line_break(it.ch) { continue; } let size = styles.get(it.style).map(|st| sanitize_size(st.font_size)).unwrap_or(12.0) * it.scale; @@ -687,6 +715,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) width: width + line_indent - hang_l - hang_r, x: 0.0, para_end: *para_end, + break_after: break_after(*e), rtl: rtl_line, }); y += lh; @@ -1142,16 +1171,12 @@ impl TextLayout { pub fn caret(&self, index: usize) -> (f64, usize) { for (li, line) in self.lines.iter().enumerate() { let last_line = li + 1 == self.lines.len(); + // A caret at a line's end belongs to the next line, whether the line wraps or ends with a + // line break (before the break it is still on this line: past its glyphs). let in_line = index >= line.char_start && (index < line.char_end || (last_line && index <= line.char_end)); - // A caret at the end of a soft-wrapped line belongs to the next line. - if !(in_line || index == line.char_end && line.para_end && !last_line) { + if !in_line { continue; } - if index == line.char_end && line.para_end && !last_line { - // Before the newline: end of this line. - let x = self.line_end_x(line); - return (x, li); - } let gl = self.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]); for g in gl { if g.char_end == g.char_start { @@ -1179,8 +1204,8 @@ impl TextLayout { if !line.rtl && !gl.iter().any(|g| g.rtl) { return None; } - // The caret can't sit after a paragraph's newline on its own line. - let end = if line.para_end && li + 1 < self.lines.len() { line.char_end.saturating_sub(1) } else { line.char_end }; + // The caret can't sit after a line's break on its own line. + let end = if line.break_after && li + 1 < self.lines.len() { line.char_end.saturating_sub(1) } else { line.char_end }; let next = (line.char_start..=end.max(line.char_start)) .filter(|i| *i != index) .map(|i| (i, self.caret(i).0)) @@ -1247,7 +1272,7 @@ impl TextLayout { } } let last_line = li + 1 == self.lines.len(); - let end = if line.para_end && !last_line { line.char_end.saturating_sub(1).max(line.char_start) } else { line.char_end }; + let end = if line.break_after && !last_line { line.char_end.saturating_sub(1).max(line.char_start) } else { line.char_end }; // Right of a right-to-left line is its start. if line.rtl { line.char_start } else { end } } @@ -1274,12 +1299,11 @@ impl TextLayout { out.extend(Self::mixed_selection(gl, a, b, line)); continue; } - // From the line's own start: `caret(char_start)` after a hard break is the end of the - // line above. + // From the line's own start. let x0 = if a <= line.char_start { self.line_start_x(line) } else { self.caret(a).0 }; let x1 = if b >= line.char_end { // The last line has no paragraph break to show, so it ends at its glyphs. - self.line_end_x(line) + if line.para_end && !last && b > line.char_end.saturating_sub(1) { 4.0 } else { 0.0 } + self.line_end_x(line) + if line.break_after && !last && b > line.char_end.saturating_sub(1) { 4.0 } else { 0.0 } } else { self.caret(b).0 }; diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 683350f61..50cd28dd5 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -16,8 +16,9 @@ mod onpath; pub use breaks::grapheme_step; pub use color::{ColorBitmap, ColorInk, ColorLayer}; pub use fontdb::{ - Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, has_family, - installed_fonts_known, no_coords, scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned, + Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, Rescan, axis_label, db, face_with_char, family_names, family_styles, find_face, + has_family, installed_fonts_known, load_font_bytes, no_coords, rescan_system_fonts, scan_system_fonts, scan_system_fonts_on_demand, + system_fonts_scanned, }; pub use layout::{Decoration, Glyph, Line, TextLayout, layout}; pub use onpath::OnPath; @@ -150,6 +151,8 @@ mod behaviour_tests; #[cfg(test)] mod cache_tests; #[cfg(test)] +mod line_separator_tests; +#[cfg(test)] mod list_tests; #[cfg(test)] mod metrics_tests; diff --git a/crates/text/src/line_separator_tests.rs b/crates/text/src/line_separator_tests.rs new file mode 100644 index 000000000..13f91786c --- /dev/null +++ b/crates/text/src/line_separator_tests.rs @@ -0,0 +1,97 @@ +//! A line separator (U+2028: a soft break in `.fig` files, kept on import) breaks the +//! line inside its paragraph: no paragraph spacing or new list marker after it, and no glyph (#852). + +use super::*; +use figcraft_doc::{LineHeight, ListStyle, TextAutoResize, TextData}; + +const EPS: f64 = 1e-6; + +/// A 20 px text on 30 px lines in the default face, or None (skip) on a machine without fonts. +fn text(s: &str) -> Option { + scan_system_fonts(); + let f = db().write().unwrap().default_face()?; + let mut t = TextData::new(s); + (t.style.font_family, t.style.font_style) = (f.family.clone(), f.style.clone()); + t.style.font_size = 20.0; + t.style.line_height = LineHeight::Pixels(30.0); + t.auto_resize = TextAutoResize::WidthAndHeight; + Some(t) +} + +/// The chars of each line. +fn line_texts(t: &TextData, l: &TextLayout) -> Vec { + let chars: Vec = t.characters.chars().collect(); + l.lines.iter().map(|ln| chars.get(ln.char_start..ln.char_end).unwrap_or(&[]).iter().collect::()).collect() +} + +#[test] +fn breaks_the_line_but_not_the_paragraph() { + let Some(mut t) = text("one\u{2028}two\nthree") else { return }; + t.paragraph_spacing = 10.0; + let l = layout(&t, None, None); + assert_eq!(line_texts(&t, &l), ["one\u{2028}", "two\n", "three"]); + assert!(!l.lines[0].para_end && l.lines[0].break_after); + assert!(l.lines[1].para_end && l.lines[1].break_after); + assert!(!l.lines[2].break_after); + // Paragraph spacing after the newline only: 30, then 30 + 10. + assert!((l.lines[1].top - 30.0).abs() < EPS && (l.lines[2].top - 70.0).abs() < EPS, "{} {}", l.lines[1].top, l.lines[2].top); + // The separator draws nothing. + assert!(l.glyphs.iter().all(|g| !(g.char_start..g.char_end).contains(&3))); +} + +#[test] +fn before_a_newline_or_at_the_end_it_leaves_an_empty_line() { + // "Hi,⏎¶Season" in #852: an empty line between the two. + let Some(t) = text("Hi,\u{2028}\nSeason") else { return }; + let l = layout(&t, None, None); + assert_eq!(line_texts(&t, &l), ["Hi,\u{2028}", "\n", "Season"]); + let Some(t) = text("end\u{2028}") else { return }; + let l = layout(&t, None, None); + assert_eq!(line_texts(&t, &l), ["end\u{2028}", ""]); + assert!((l.height - 60.0).abs() < EPS, "{}", l.height); +} + +#[test] +fn a_list_item_continues_after_it_without_a_new_marker() { + let Some(mut t) = text("item\u{2028}more\nnext") else { return }; + t.list = ListStyle::Bulleted; + let l = layout(&t, None, None); + assert_eq!(line_texts(&t, &l), ["item\u{2028}", "more\n", "next"]); + // Markers are the glyphs that cover no char: one per paragraph. + assert_eq!(l.glyphs.iter().filter(|g| g.char_start == g.char_end).count(), 2); + // The line after the separator lines up with the item's text, past the bullet. + let text_x = |li: usize| l.glyphs.iter().find(|g| g.char_start == l.lines[li].char_start && g.char_end > g.char_start).map(|g| g.x); + let (first, cont) = (text_x(0).unwrap(), text_x(1).unwrap()); + assert!(first > 0.0 && (first - cont).abs() < 1e-3, "{first} {cont}"); +} + +#[test] +fn carets_and_clicks_treat_it_like_a_newline() { + let (Some(sep), Some(nl)) = (text("ab\u{2028}cd"), text("ab\ncd")) else { return }; + let (ls, ln) = (layout(&sep, None, None), layout(&nl, None, None)); + for i in 0..=5 { + assert_eq!(ls.caret(i), ln.caret(i), "caret before char {i}"); + } + for line in &ln.lines { + for x in [-10.0, 5.0, line.width + 50.0] { + assert_eq!(ls.hit(x, line.top + 5.0), ln.hit(x, line.top + 5.0), "click at {x} on the line at {}", line.top); + } + } + // Before the separator: the end of the first line; after it: the start of the second. + assert_eq!(ls.caret(2).1, 0); + assert!(ls.caret(2).0 > 0.0); + assert_eq!(ls.caret(3), (0.0, 1)); + // A click right of the first line lands before the separator, not after it. + assert_eq!(ls.hit(ls.lines[0].width + 50.0, ls.lines[0].top + 5.0), 2); +} + +#[test] +fn a_selection_across_it_shows_the_break_like_a_newline() { + let (Some(sep), Some(nl)) = (text("ab\u{2028}cd"), text("ab\ncd")) else { return }; + let (ls, ln) = (layout(&sep, None, None), layout(&nl, None, None)); + let (before, across) = (ls.selection_rects(0, 2), ls.selection_rects(0, 5)); + assert_eq!((before.len(), across.len()), (1, 2)); + // Reaching past the separator adds the 4 px break mark at the first line's end. + assert!((across[0].width() - before[0].width() - 4.0).abs() < EPS, "{} {}", across[0].width(), before[0].width()); + assert_eq!(across, ln.selection_rects(0, 5)); +} diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index f5eaab6bf..ebf5889ce 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -481,3 +481,19 @@ fn selection_after_a_line_break_starts_at_the_line_start() { // A break followed by text still doesn't highlight the next line's start. assert!(rect_on(&l, &l.selection_rects(0, 6), 1).is_none()); } + +#[test] +fn the_caret_after_a_line_break_sits_on_the_next_line() { + if !have_fonts() { + return; + } + // Before the break: the end of its line; after it: the start of the next (after Enter, the + // caret shows on the new line, and Up/Down count from there). + let l = layout(&TextData::new("ab\ncd"), None, None); + assert_eq!(l.caret(2).1, 0); + assert!(l.caret(2).0 > 0.0); + assert_eq!(l.caret(3), (0.0, 1)); + // A trailing break: the caret sits on the empty last line. + let l = layout(&TextData::new("ab\n"), None, None); + assert_eq!(l.caret(3), (0.0, 1)); +} diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml new file mode 100644 index 000000000..adedc983a --- /dev/null +++ b/crates/ui-egui/locales/bn.toml @@ -0,0 +1,1741 @@ +# FigCraft interface strings: বাংলা, ইংরেজি থেকে অনূদিত। +# +# কীগুলো কোডে ব্যবহৃত ইংরেজি টেক্সট (`tr("…")`, `trf`, `trn`, `tr_noop`); মানগুলো অনূদিত, +# বহুবচন এন্ট্রি ({ one, other }) ছাড়া। কমান্ডের লেবেল ইঞ্জিন থেকে আসে। +# `cargo test -p figcraft-ui-egui i18n` অনুপস্থিত ও অব্যবহৃত কী তালিকাভুক্ত করে। docs/translating.md দেখুন। + +[meta] +name = "বাংলা" +status = "draft — needs native review" + +[ui] +"% of all commits" = "সমস্ত কমিটের শতাংশ" +"(no longer published)" = "(প্রকাশিত নয়)" +"({n} warnings: some entries were skipped)" = { one = "({n}টি সতর্কতা: কিছু এন্ট্রি বাদ দেওয়া হয়েছে)", other = "({n}টি সতর্কতা: কিছু এন্ট্রি বাদ দেওয়া হয়েছে)" } +"+ Create variable" = "+ ভেরিয়েবল তৈরি করুন" +".fig (experimental)" = ".fig (পরীক্ষামূলক)" +"1…9, 0" = "১…৯, ০" +"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs}টি PR, {commits}টি কমিট, +{added} / −{deleted} লাইন (Δ {delta}), +{bin_added} / −{bin_deleted} বাইনারি অ্যাসেট, {first} – {last}" +"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "এই মোড এবং ভেরিয়েবল সহ এই ফাইলের একটি সংগ্রহ: প্রতি মোডে তাদের মান ওভাররাইড করুন এবং এর নিজস্ব ভেরিয়েবল যোগ করুন" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "এই নামের একটি .figcraft ফাইল ইতিমধ্যে বিদ্যমান। প্রতিস্থাপন নিশ্চিত করতে এর সম্পূর্ণ নাম বেছে নিন।" +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "একটি লেয়ার একা তার ফ্রেমের সাথে সারিবদ্ধ হয়: আরও লেয়ার নির্বাচন করুন, বা ফ্রেমে থাকা একটি লেয়ার নির্বাচন করুন" +"A responsive product page: hero, features, figures, a quote and a footer" = "একটি রেসপনসিভ প্রোডাক্ট পেজ: হিরো, ফিচার, ফিগার, একটি উদ্ধৃতি এবং একটি ফুটার" +"A spring plays until it settles: its mass, stiffness and damping set how long" = "একটি স্প্রিং স্থির হওয়া পর্যন্ত চলে: এর ভর, দৃঢ়তা (stiffness) এবং ড্যাম্পিং নির্ধারণ করে কতক্ষণ চলবে" +"A title, an agenda, a big figure, a chart and a closing slide" = "একটি শিরোনাম, একটি আলোচ্যসূচি, একটি বড় ফিগার, একটি চার্ট এবং একটি সমাপ্তি স্লাইড" +"A to Z" = "A থেকে Z" +"About" = "সম্পর্কিত" +"About FigCraft" = "FigCraft সম্পর্কে" +"About this server" = "এই সার্ভার সম্পর্কে" +"Action" = "অ্যাকশন" +"Actions" = "অ্যাকশনসমূহ" +"Actions for {name}" = "{name}-এর জন্য অ্যাকশন" +"Actions…" = "অ্যাকশনসমূহ..." +"Active" = "সক্রিয়" +"Actual size (100%)" = "প্রকৃত আকার (১০০%)" +"Add a category" = "একটি বিভাগ যোগ করুন" +"Add a comment" = "একটি মন্তব্য যোগ করুন" +"Add a description" = "একটি বিবরণ যোগ করুন" +"Add a frame (F) or a shape (R) to get started" = "শুরু করতে একটি ফ্রেম (F) বা শেপ (R) যোগ করুন" +"Add a layer or component to tile first" = "প্রথমে টাইল করার জন্য একটি লেয়ার বা কম্পোনেন্ট যোগ করুন" +"Add a note for developers" = "ডেভেলপারদের জন্য একটি নোট যোগ করুন" +"Add action" = "অ্যাকশন যোগ করুন" +"Add actions for when no condition holds" = "কোনো শর্ত পূর্ণ না হলে তার জন্য অ্যাকশন যোগ করুন" +"Add an annotation for developers" = "ডেভেলপারদের জন্য একটি অ্যানোটেশন যোগ করুন" +"Add another condition" = "আরেকটি শর্ত যোগ করুন" +"Add auto layout" = "অটো লেআউট যোগ করুন" +"Add background behind overlay" = "ওভারলে-এর পেছনে ব্যাকগ্রাউন্ড যোগ করুন" +"Add content to this slot" = "এই স্লটে কন্টেন্ট যোগ করুন" +"Add dev resource" = "ডেভ রিসোর্স যোগ করুন" +"Add effect" = "ইফেক্ট যোগ করুন" +"Add export setting" = "এক্সপোর্ট সেটিংস যোগ করুন" +"Add fill" = "ফিল যোগ করুন" +"Add interaction" = "ইন্টারঅ্যাকশন যোগ করুন" +"Add layout grid" = "লেআউট গ্রিড যোগ করুন" +"Add library" = "লাইব্রেরি যোগ করুন" +"Add library file…" = "লাইব্রেরি ফাইল যোগ করুন..." +"Add max height" = "সর্বোচ্চ উচ্চতা যোগ করুন" +"Add max width" = "সর্বোচ্চ প্রস্থ যোগ করুন" +"Add min height" = "সর্বনিম্ন উচ্চতা যোগ করুন" +"Add min width" = "সর্বনিম্ন প্রস্থ যোগ করুন" +"Add mode" = "মোড যোগ করুন" +"Add new page" = "নতুন পেজ যোগ করুন" +"Add or update collections from a variables JSON" = "একটি ভেরিয়েবল JSON থেকে সংগ্রহ যোগ বা আপডেট করুন" +"Add properties to make instances configurable" = "ইনস্ট্যান্সগুলোকে কনফিগারযোগ্য করতে প্রপার্টি যোগ করুন" +"Add reaction" = "রিঅ্যাকশন যোগ করুন" +"Add starting point" = "শুরু করার বিন্দু যোগ করুন" +"Add stroke" = "স্ট্রোক যোগ করুন" +"Add variant" = "ভ্যারিয়েন্ট যোগ করুন" +"Add variant with a new value" = "একটি নতুন মান সহ ভ্যারিয়েন্ট যোগ করুন" +"Added" = "যোগ করা হয়েছে" +"Added the library \"{name}\"" = "\"{name}\" লাইব্রেরি যোগ করা হয়েছে" +"Adding to {name}" = "{name}-এ যোগ করা হচ্ছে" +"Adjust" = "সামঞ্জস্য করুন" +"Adjustment presets" = "অ্যাডজাস্টমেন্ট প্রিসেট" +"Advanced" = "উন্নত" +"Advanced stroke" = "উন্নত স্ট্রোক" +"After delay" = "বিলম্বের পর" +"After delay {ms}ms" = "{ms}ms বিলম্বের পর" +"Align" = "সারিবদ্ধ করুন" +"Align bottom" = "নিচে সারিবদ্ধ করুন" +"Align center" = "কেন্দ্রে সারিবদ্ধ করুন" +"Align horizontal centers" = "অনুভূমিক কেন্দ্র বরাবর সারিবদ্ধ করুন" +"Align left" = "বামে সারিবদ্ধ করুন" +"Align middle" = "মাঝামাঝি সারিবদ্ধ করুন" +"Align right" = "ডানে সারিবদ্ধ করুন" +"Align text baselines" = "টেক্সট বেসলাইন সারিবদ্ধ করুন" +"Align top" = "উপরে সারিবদ্ধ করুন" +"Align vertical centers" = "উল্লম্ব কেন্দ্র বরাবর সারিবদ্ধ করুন" +"Alignment" = "সারিবদ্ধকরণ" +"All" = "সব" +"All categories" = "সব বিভাগ" +"All comments" = "সব মন্তব্য" +"All components" = "সব কম্পোনেন্ট" +"All fonts" = "সব ফন্ট" +"All supported properties" = "সমর্থিত সব প্রপার্টি" +"All the fonts this file uses are installed" = "এই ফাইলে ব্যবহৃত সব ফন্ট ইনস্টল করা আছে" +"All variables" = "সব ভেরিয়েবল" +"Alpha" = "আলফা" +"Alphabetical" = "বর্ণানুক্রমিক" +"Alt+drag" = "Alt+ড্র্যাগ" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "গ্রিডের উপর একটি অ্যানালিটিক্স স্ক্রিন: ফিগার, ভেক্টর থেকে আঁকা চার্ট এবং একটি টেবিল" +"An invite link that opens FigCraft and joins" = "একটি আমন্ত্রণ লিংক যা FigCraft খুলে যুক্ত হয়" +"Anchor" = "অ্যাঙ্কর" +"Android" = "অ্যান্ড্রয়েড" +"Angular" = "অ্যাঙ্গুলার" +"Animation" = "অ্যানিমেশন" +"Annotations" = "অ্যানোটেশন" +"Annotations shown on the canvas" = "ক্যানভাসে অ্যানোটেশনগুলো দেখানো হচ্ছে" +"Another server, or one that needs its key. The usual server is set in Preferences." = "অন্য একটি সার্ভার, অথবা যার কী প্রয়োজন। সাধারণ সার্ভারটি প্রেফারেন্সেসে সেট করা আছে।" +"Any status" = "যেকোনো স্ট্যাটাস" +"Anyone on your network with this code can edit the file, while this app stays open." = "এই অ্যাপ খোলা থাকা অবস্থায় আপনার নেটওয়ার্কে এই কোড জানা যেকোনো ব্যক্তি ফাইলটি এডিট করতে পারবেন।" +"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "এডিট লিংক থাকা যেকোনো ব্যক্তি ফাইলটি পরিবর্তন করতে পারবেন; ভিউ লিংক শুধু দেখতে দেয়। লিংকগুলো গোপন তথ্য বহন করে: পাসওয়ার্ডের মতো এগুলো শেয়ার করুন। `figcraft-server links` কমান্ড দিয়ে আরও তৈরি ও বাতিল করা যায়।" +"Anyone with this code can edit the file." = "এই কোড জানা যেকোনো ব্যক্তি ফাইলটি এডিট করতে পারবেন।" +"Anyone with this code can look at the file, not change it." = "এই কোড জানা যেকোনো ব্যক্তি ফাইলটি দেখতে পারবেন, পরিবর্তন করতে পারবেন না।" +"Appearance" = "চেহারা" +"Apply a component property" = "একটি কম্পোনেন্ট প্রপার্টি প্রয়োগ করুন" +"Apply variable" = "ভেরিয়েবল প্রয়োগ করুন" +"Apply variable to text" = "টেক্সটে ভেরিয়েবল প্রয়োগ করুন" +"Apply variable to visibility" = "দৃশ্যমানতায় ভেরিয়েবল প্রয়োগ করুন" +"Apps" = "অ্যাপস" +"Apr" = "এপ্রিল" +"Arc" = "আর্ক" +"Arc ratio" = "আর্ক অনুপাত" +"Arc start" = "আর্ক শুরু" +"Arc sweep" = "আর্ক সুইপ" +"Archive" = "আর্কাইভ" +"Archived" = "আর্কাইভ করা হয়েছে" +"Arrange" = "সাজান" +"Arrow" = "তীর" +"As typed" = "যেমনটি টাইপ করা হয়েছে" +"Asking the server…" = "সার্ভারকে জিজ্ঞাসা করা হচ্ছে..." +"Assets" = "অ্যাসেট" +"Aug" = "আগস্ট" +"Auto" = "অটো" +"Auto (space around)" = "অটো (চারপাশে স্পেস)" +"Auto (space between)" = "অটো (মাঝখানে স্পেস)" +"Auto (space evenly)" = "অটো (সমানভাবে স্পেস)" +"Auto ({mode})" = "অটো ({mode})" +"Auto columns" = "অটো কলাম" +"Auto gap" = "অটো গ্যাপ" +"Auto height" = "অটো উচ্চতা" +"Auto layout" = "অটো লেআউট" +"Auto rows" = "অটো সারি" +"Auto width" = "অটো প্রস্থ" +"Back to the FigCraft server" = "FigCraft সার্ভারে ফিরে যান" +"Back to the font's value" = "ফন্টের মানে ফিরে যান" +"Back" = "পেছনে" +"Back to Ready for dev" = "ডেভের জন্য প্রস্তুত অবস্থায় ফিরে যান" +"Back to Ready for dev: the next change compares with how it is now" = "ডেভের জন্য প্রস্তুত অবস্থায় ফিরে যান: পরবর্তী পরিবর্তনটি বর্তমান অবস্থার সাথে তুলনা করবে" +"Back to assets" = "অ্যাসেটে ফিরে যান" +"Back to current" = "বর্তমানে ফিরে যান" +"Back to styles" = "স্টাইলে ফিরে যান" +"Back to the canvas from a focused region" = "ফোকাসড অঞ্চল থেকে ক্যানভাসে ফিরে যান" +"Back to the current version" = "বর্তমান সংস্করণে ফিরে যান" +"Back to the values the font style implies" = "ফন্ট স্টাইল নির্দেশিত মানে ফিরে যান" +"Background" = "ব্যাকগ্রাউন্ড" +"Background blur" = "ব্যাকগ্রাউন্ড ব্লার" +"Basics" = "মৌলিক বিষয়" +"Before" = "আগে" +"Bend" = "বাঁক" +"Bevel" = "বেভেল" +"Binary assets added" = "বাইনারি অ্যাসেট যোগ করা হয়েছে" +"Binary assets removed" = "বাইনারি অ্যাসেট সরানো হয়েছে" +"Black & white" = "কালো ও সাদা" +"Bleed" = "ব্লিড" +"Bleed marks" = "ব্লিড মার্ক" +"Bleed marks need a bleed" = "ব্লিড মার্কের জন্য ব্লিড প্রয়োজন" +"Blend" = "ব্লেন্ড" +"Blend mode" = "ব্লেন্ড মোড" +"Blue" = "নীল" +"Blur" = "ব্লার" +"Boolean" = "বুলিয়ান" +"Boolean groups" = "বুলিয়ান গ্রুপ" +"Both directions" = "উভয় দিক" +"Bottom" = "নিচে" +"Bottom left radius" = "নিচের বাম ব্যাসার্ধ" +"Bottom right radius" = "নিচের ডান ব্যাসার্ধ" +"Branch" = "ব্রাঞ্চ" +"Branch archived" = "ব্রাঞ্চ আর্কাইভ করা হয়েছে" +"Branch merged" = "ব্রাঞ্চ মার্জ করা হয়েছে" +"Branch name" = "ব্রাঞ্চের নাম" +"Branch of {file}" = "{file}-এর ব্রাঞ্চ" +"Branch of {file} · merged or archived" = "{file}-এর ব্রাঞ্চ · মার্জ বা আর্কাইভ করা হয়েছে" +"Branch updated from main" = "প্রধান (main) থেকে ব্রাঞ্চ আপডেট করা হয়েছে" +"Branch {name}" = "ব্রাঞ্চ {name}" +"Branches" = "ব্রাঞ্চসমূহ" +"Bound to {name}. Click to detach the variable." = "{name}-এর সাথে আবদ্ধ। ভেরিয়েবলটি বিচ্ছিন্ন করতে ক্লিক করুন।" +"Bring forward" = "সামনে আনুন" +"Bring the main file's changes into the branch" = "প্রধান ফাইলের পরিবর্তনগুলো ব্রাঞ্চে নিয়ে আসুন" +"Bring to front" = "সবার সামনে আনুন" +"Bristle" = "ব্রিসল" +"Browse" = "ব্রাউজ করুন" +"Brush" = "ব্রাশ" +"Brush rotation jitter" = "ব্রাশ ঘূর্ণন জিটার" +"Brush scatter" = "ব্রাশ স্ক্যাটার" +"Brush size jitter" = "ব্রাশ সাইজ জিটার" +"Brush spacing" = "ব্রাশ স্পেসিং" +"Bulleted list" = "বুলেটেড তালিকা" +"C or Shift+C" = "C বা Shift+C" +"C or ⇧C" = "C বা ⇧C" +"CMYK colour" = "CMYK রঙ" +"Can view" = "দেখতে পারেন" +"Checking the fonts installed on this computer…" = "এই কম্পিউটারে ইনস্টল করা ফন্টগুলো চেক করা হচ্ছে..." +"Checking the server…" = "সার্ভার চেক করা হচ্ছে..." +"Clear from this list" = "এই তালিকা থেকে মুছে ফেলুন" +"Code or invite link" = "কোড বা আমন্ত্রণ লিংক" +"Color profile: {name}" = "কালার প্রোফাইল: {name}" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "রঙ ও টেক্সট স্টাইল, লাইট এবং ডার্ক ভেরিয়েবল, বোতাম, ইনপুট এবং কার্ড" +"Convert to frame" = "ফ্রেম রূপান্তর করুন" +"Convert to section" = "সেকশনে রূপান্তর করুন" +"CSS custom properties…" = "CSS কাস্টম প্রপার্টি..." +"Can edit" = "এডিট করতে পারেন" +"Can't read this library: {e}" = "এই লাইব্রেরি পড়া যাচ্ছে না: {e}" +"Cancel" = "বাতিল" +"Canvas" = "ক্যানভাস" +"Canvas stacking" = "ক্যানভাস স্ট্যাকিং" +"Cap height to baseline" = "বেসলাইনে ক্যাপ হাইট" +"Case" = "কেস" +"Case-sensitive forms" = "কেস-সেন্সিটিভ ফর্ম" +"Categories" = "বিভাগসমূহ" +"Center" = "কেন্দ্র" +"Change alias" = "অ্যালিয়াস পরিবর্তন করুন" +"Change resolved" = "পরিবর্তন সমাধান করা হয়েছে" +"Change style" = "স্টাইল পরিবর্তন করুন" +"Change to" = "এতে পরিবর্তন করুন" +"Change to needs a layer inside a variant" = "পরিবর্তন করার জন্য ভ্যারিয়েন্টের ভেতরে একটি লেয়ার প্রয়োজন" +"Changed" = "পরিবর্তিত" +"Changed on both sides: keep one" = "উভয় দিকেই পরিবর্তিত হয়েছে: একটি রাখুন" +"Changes on main" = "প্রধান (main)-এ পরিবর্তন" +"Changes on the branch" = "ব্রাঞ্চে পরিবর্তন" +"Changes unknown" = "পরিবর্তন অজ্ঞাত" +"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "এই কম্পোনেন্টটিকে কোডের সাথে যুক্ত করতে উপরে React, Leptos, SwiftUI বা Compose বেছে নিন।" +"Choose another profile to change it." = "পরিবর্তন করতে অন্য একটি প্রোফাইল বেছে নিন।" +"Choose collection" = "সংগ্রহ (collection) বেছে নিন" +"Choose image" = "ছবি বেছে নিন" +"Choose mode" = "মোড বেছে নিন" +"Choose variable" = "ভেরিয়েবল বেছে নিন" +"Circle" = "বৃত্ত" +"Clear recent" = "সাম্প্রতিকগুলো মুছে ফেলুন" +"Click + to replace mixed content" = "মিশ্র কন্টেন্ট প্রতিস্থাপন করতে + এ ক্লিক করুন" +"Click anywhere on the canvas to leave one." = "একটি রাখতে ক্যানভাসের যেকোনো জায়গায় ক্লিক করুন।" +"Click or drag to merge · {alt} removes" = "মার্জ করতে ক্লিক বা ড্র্যাগ করুন · {alt} সরায়" +"Click or drag to remove · {alt} merges" = "সরাতে ক্লিক বা ড্র্যাগ করুন · {alt} মার্জ করে" +"Click to record a key" = "একটি কি রেকর্ড করতে ক্লিক করুন" +"Clip content" = "ক্লিপ কন্টেন্ট" +"Clip to shape" = "শেপে ক্লিপ করুন" +"Close" = "বন্ধ করুন" +"Close overlay" = "ওভারলে বন্ধ করুন" +"Close preview" = "প্রিভিউ বন্ধ করুন" +"Close tab" = "ট্যাব বন্ধ করুন" +"Close version history" = "সংস্করণ ইতিহাস বন্ধ করুন" +"Close when clicking outside" = "বাইরে ক্লিক করলে বন্ধ করুন" +"Close without saving" = "সেভ না করেই বন্ধ করুন" +"Code" = "কোড" +"Code syntax" = "কোড সিনট্যাক্স" +"Collapse" = "সংকুচিত করুন" +"Collapse panel" = "প্যানেল সংকুচিত করুন" +"Collection" = "সংগ্রহ" +"Collections" = "সংগ্রহসমূহ" +"Color" = "রঙ" +"Color burn" = "কালার বার্ন" +"Color dodge" = "কালার ডজ" +"Color format" = "কালার ফরম্যাট" +"Color profile" = "কালার প্রোফাইল" +"Color styles" = "কালার স্টাইল" +"Color values in this file are in this color space." = "এই ফাইলের রঙের মানগুলো এই কালার স্পেসে রয়েছে।" +"Color variables" = "কালার ভেরিয়েবল" +"Colour" = "রঙ" +"Column" = "কলাম" +"Column gap" = "কলাম গ্যাপ" +"Column sizes" = "কলামের আকার" +"Column span" = "কলাম স্প্যান" +"Columns" = "কলামসমূহ" +"Columns {count}" = "কলাম {count}" +"Comment" = "মন্তব্য" +"Comments" = "মন্তব্যসমূহ" +"Commits" = "কমিটসমূহ" +"Company" = "কোম্পানি" +"Compare changes" = "পরিবর্তন তুলনা করুন" +"Compatibility notes" = "সঙ্গতি নোট (Compatibility notes)" +"Completed" = "সম্পন্ন" +"Component" = "কম্পোনেন্ট" +"Component name" = "কম্পোনেন্টের নাম" +"Component property" = "কম্পোনেন্ট প্রপার্টি" +"Component set" = "কম্পোনেন্ট সেট" +"Component sets" = "কম্পোনেন্ট সেটসমূহ" +"Components" = "কম্পোনেন্টস" +"Conditional" = "শর্তসাপেক্ষ" +"Confetti" = "কনফেটি" +"Conflicts" = "দ্বন্দ্ব (Conflicts)" +"Connect to code" = "কোডের সাথে সংযুক্ত করুন" +"Connected" = "সংযুক্ত" +"Connected to code: {from}" = "কোডের সাথে সংযুক্ত: {from}" +"Connection lost, reconnecting (attempt {attempt})…" = "সংযোগ বিচ্ছিন্ন হয়েছে, পুনরায় সংযোগ স্থাপন করা হচ্ছে (চেষ্টা {attempt})..." +"Constrain proportions" = "অনুপাত ঠিক রাখুন" +"Constraints" = "সীমাবদ্ধতা (Constraints)" +"Content" = "কন্টেন্ট" +"Contents hidden" = "কন্টেন্ট লুকানো আছে" +"Contents only" = "শুধু কন্টেন্ট" +"Contextual alternates" = "প্রসঙ্গভিত্তিক বিকল্প" +"Contrast" = "কনট্রাস্ট (Contrast)" +"Contributors" = "অবদানকারীগণ" +"Contributors: {people} · Commits: {commits}" = "অবদানকারী: {people} · কমিট: {commits}" +"Controller" = "কন্ট্রোলার" +"Cool" = "কুল" +"Copied as CSS" = "CSS হিসেবে কপি করা হয়েছে" +"Copied as PNG" = "PNG হিসেবে কপি করা হয়েছে" +"Copied as SVG" = "SVG হিসেবে কপি করা হয়েছে" +"Copied as text" = "টেক্সট হিসেবে কপি করা হয়েছে" +"Copied {value}" = "{value} কপি করা হয়েছে" +"Copy" = "কপি করুন" +"Copy as" = "যেভাবে কপি করবেন" +"Copy as CSS" = "CSS হিসেবে কপি করুন" +"Copy as PNG" = "PNG হিসেবে কপি করুন" +"Copy as SVG" = "SVG হিসেবে কপি করুন" +"Copy code" = "কোড কপি করুন" +"Copy link" = "লিংক কপি করুন" +"Copy the {label}" = "{label} কপি করুন" +"Copy {value}" = "{value} কপি করুন" +"Corner radius" = "কোণার ব্যাসার্ধ (Corner radius)" +"Corner smoothing" = "কোণা মসৃণকরণ" +"Couldn't download the variables" = "ভেরিয়েবলগুলো ডাউনলোড করা যায়নি" +"Couldn't reach {server}: showing what this computer remembers." = "{server}-এ পৌঁছানো যায়নি: এই কম্পিউটার যা মনে রেখেছে তা দেখানো হচ্ছে।" +"Couldn't save automatically: {error}" = "স্বয়ংক্রিয়ভাবে সেভ করা যায়নি: {error}" +"Count" = "গণনা" +"Create" = "তৈরি করুন" +"Create a variable collection first" = "প্রথমে একটি ভেরিয়েবল সংগ্রহ তৈরি করুন" +"Create a variable first" = "প্রথমে একটি ভেরিয়েবল তৈরি করুন" +"Create alias" = "অ্যালিয়াস তৈরি করুন" +"Create branch" = "ব্রাঞ্চ তৈরি করুন" +"Create collection" = "সংগ্রহ তৈরি করুন" +"Create component" = "কম্পোনেন্ট তৈরি করুন" +"Create component property" = "কম্পোনেন্ট প্রপার্টি তৈরি করুন" +"Create components, styles or variables to publish them as a library." = "লাইব্রেরি হিসেবে প্রকাশ করতে কম্পোনেন্ট, স্টাইল বা ভেরিয়েবল তৈরি করুন।" +"Create property…" = "প্রপার্টি তৈরি করুন..." +"Create style" = "স্টাইল তৈরি করুন" +"Create style from the selected text" = "নির্বাচিত টেক্সট থেকে স্টাইল তৈরি করুন" +"Created" = "তৈরি করা হয়েছে" +"Crop" = "ক্রপ" +"Crop marks" = "ক্রপ মার্ক" +"Ctrl+arrows" = "Ctrl+অ্যারো" +"Ctrl+click" = "Ctrl+ক্লিক" +"Ctrl+scroll" = "Ctrl+স্ক্রল" +"Current name" = "বর্তমান নাম" +"Current version" = "বর্তমান সংস্করণ" +"Cursor chat" = "কার্সার চ্যাট" +"Custom" = "কাস্টম" +"Custom size" = "কাস্টম আকার" +"Cut" = "কাট করুন" +"Dark" = "ডার্ক" +"Darken" = "ডার্কেন" +"Dash" = "ড্যাশ" +"Dashboard" = "ড্যাশবোর্ড" +"Date created" = "তৈরির তারিখ" +"Dec" = "ডিসেম্বর" +"Decoration style" = "সজ্জা স্টাইল (Decoration style)" +"Decoration" = "সজ্জা" +"Deep select" = "ডিপ সিলেক্ট" +"Default" = "ডিফল্ট" +"Default content" = "ডিফল্ট কন্টেন্ট" +"Default value (the top-left variant)" = "ডিফল্ট মান (উপরের-বাম ভ্যারিয়েন্ট)" +"Delay" = "বিলম্ব" +"Delete" = "ডিলিট করুন" +"Delete category (its annotations stay)" = "বিভাগ ডিলিট করুন (এর অ্যানোটেশনগুলো থেকে যাবে)" +"Delete collection" = "সংগ্রহ ডিলিট করুন" +"Delete group" = "গ্রুপ ডিলিট করুন" +"Delete mode" = "মোড ডিলিট করুন" +"Delete page" = "পেজ ডিলিট করুন" +"Delete preset" = "প্রিসেট ডিলিট করুন" +"Delete property" = "প্রপার্টি ডিলিট করুন" +"Delete the style (layers keep their values)" = "স্টাইল ডিলিট করুন (লেয়ারগুলো তাদের মান ধরে রাখবে)" +"Delete thread" = "থ্রেড ডিলিট করুন" +"Delete variable" = "ভেরিয়েবল ডিলিট করুন" +"Delete “{name}”? This can't be undone." = "“{name}” ডিলিট করবেন? এটি আর ফেরানো যাবে না।" +"Density" = "ঘনত্ব" +"Depth" = "গভীরতা" +"Describe how and when to use this component" = "এই কম্পোনেন্টটি কীভাবে এবং কখন ব্যবহার করতে হবে তা বর্ণনা করুন" +"Describe what changed" = "কী পরিবর্তিত হয়েছে তা বর্ণনা করুন" +"Description" = "বিবরণ" +"Description (optional)" = "বিবরণ (ঐচ্ছিক)" +"Design" = "ডিজাইন" +"Design system starter" = "ডিজাইন সিস্টেম স্টার্টার" +"Desktop" = "ডেস্কটপ" +"Destination" = "গন্তব্য" +"Detach alias" = "অ্যালিয়াস বিচ্ছিন্ন করুন" +"Detach instance" = "ইনস্ট্যান্স বিচ্ছিন্ন করুন" +"Detach property" = "প্রপার্টি বিচ্ছিন্ন করুন" +"Detach style" = "স্টাইল বিচ্ছিন্ন করুন" +"Detach styles" = "স্টাইলগুলো বিচ্ছিন্ন করুন" +"Detach variable" = "ভেরিয়েবল বিচ্ছিন্ন করুন" +"Details" = "বিস্তারিত" +"Dev Mode" = "ডেভ মোড" +"Dev resources" = "ডেভ রিসোর্স" +"Development" = "ডেভেলপমেন্ট" +"Device" = "ডিভাইস" +"Device at 100%" = "১০০%-এ ডিভাইস" +"Diamond" = "ডায়মন্ড" +"Difference" = "পার্থক্য" +"Dimensions" = "মাত্রা (Dimensions)" +"Direction" = "দিক" +"Disconnect from code" = "কোড থেকে সংযোগ বিচ্ছিন্ন করুন" +"Dispersion" = "বিচ্ছুরণ" +"Display name" = "প্রদর্শিত নাম" +"Dissolve" = "ডিসলভ" +"Distribute and tidy" = "বণ্টন ও পরিপাটি করুন" +"Distribute horizontal spacing" = "অনুভূমিক ব্যবধান বিতরণ করুন" +"Distribute vertical spacing" = "উল্লম্ব ব্যবধান বিতরণ করুন" +"Divider" = "ডিভাইডার" +"Don't truncate" = "ট্রাঙ্কেট করবেন না" +"Done" = "সম্পন্ন" +"Done editing" = "সম্পাদনা শেষ" +"Dots" = "বিন্দুসমূহ" +"Dotted" = "ডটেড" +"Down" = "নিচে" +"Drafts" = "ড্রাফট" +"Drag the stroke to add a width point" = "প্রস্থ বিন্দু যোগ করতে স্ট্রোকে ড্র্যাগ করুন" +"Draw" = "আঁকুন" +"Draw a frame" = "একটি ফ্রেম আঁকুন" +"Drawing tools" = "অঙ্কন টুলস" +"Drop a .figcraft, .fig, .json, .svg or image file." = "একটি .figcraft, .fig, .json, .svg বা ছবির ফাইল ড্রপ করুন।" +"Drop to import" = "ইম্পোর্ট করতে ড্রপ করুন" +"Drop to open" = "খুলতে ড্রপ করুন" +"Drop shadow" = "ড্রপ শ্যাডো" +"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "ডুয়াল-লাইসেন্সকৃত MIT বা Apache-2.0। ArtCraft টিম এবং কমিউনিটি দ্বারা তৈরি।" +"Duo" = "ডুও" +"Duplicate" = "প্রতিলিপি তৈরি করুন (Duplicate)" +"Duplicate by dragging" = "ড্র্যাগ করে ডুপ্লিকেট করুন" +"Duplicate mode" = "মোড ডুপ্লিকেট করুন" +"Duplicate page" = "পেজ ডুপ্লিকেট করুন" +"Edit" = "এডিট" +"Edit as an expression" = "এক্সপ্রেশন হিসেবে এডিট করুন" +"Edit colour" = "রঙ এডিট করুন" +"Edit comment" = "মন্তব্য এডিট করুন" +"Edit dev resource" = "ডেভ রিসোর্স এডিট করুন" +"Edit link" = "লিংক এডিট করুন" +"Edit link copied" = "এডিট লিংক কপি করা হয়েছে" +"Edit style" = "স্টাইল এডিট করুন" +"Edit variable" = "ভেরিয়েবল এডিট করুন" +"Edit with the builder" = "বিল্ডার দিয়ে এডিট করুন" +"Edited {when}" = "{when} এডিট করা হয়েছে" +"Effect settings" = "ইফেক্ট সেটিংস" +"Effect styles" = "ইফেক্ট স্টাইল" +"Effects" = "ইফেক্টসমূহ" +"Ellipse" = "উপবৃত্ত" +"Ellipses" = "উপবৃত্তসমূহ" +"Else" = "অন্যথায় (Else)" +"Else if" = "অন্যথায় যদি (Else if)" +"Embedded profile" = "এমবেডেড প্রোফাইল" +"Empty" = "খালি" +"End" = "শেষ" +"End point" = "শেষ বিন্দু" +"Ends the session; everyone keeps their copy" = "সেশন শেষ করে; সবাই তাদের কপি নিজের কাছে রাখে" +"Enter" = "এন্টার" +"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "একটি figcraft-সার্ভারের ঠিকানা দিন এবং এটি যে প্লাগইনগুলো অফার করে তা দেখতে ব্রাউজ টিপুন।" +"Enter to save · Esc to cancel" = "সেভ করতে Enter · বাতিল করতে Esc" +"Eraser" = "ইরেজার" +"Esc" = "Esc" +"Essential" = "অত্যাবশ্যকীয়" +"Every conflict has a choice." = "প্রতিটি দ্বন্দ্বেরই একটি সমাধান বা পছন্দ থাকে।" +"Every top-level frame already starts a flow" = "প্রতিটি শীর্ষ-স্তরের ফ্রেম ইতিমধ্যে একটি ফ্লো শুরু করে" +"Exact opacity (4 then 5: 45%)" = "সঠিক অস্বচ্ছতা (4 তারপর 5: 45%)" +"Exclude" = "বাদ দিন" +"Excluded" = "বাদ দেওয়া হয়েছে" +"Excluded from layout" = "লেআউট থেকে বাদ দেওয়া হয়েছে" +"Excludes" = "বাদ দেয়" +"Exclusion" = "এক্সক্লুশন" +"Exit focus view (Esc)" = "ফোকাস ভিউ থেকে বের হন (Esc)" +"Expand" = "প্রসারিত করুন" +"Export" = "এক্সপোর্ট" +"Export all" = "সব এক্সপোর্ট করুন" +"Export frames and layers as PNG, JPG, SVG or PDF" = "ফ্রেম এবং লেয়ারগুলোকে PNG, JPG, SVG বা PDF হিসেবে এক্সপোর্ট করুন" +"Export page as PNG" = "পেজ PNG হিসেবে এক্সপোর্ট করুন" +"Export settings" = "এক্সপোর্ট সেটিংস" +"Export variables" = "ভেরিয়েবল এক্সপোর্ট করুন" +"Export {name}" = "{name} এক্সপোর্ট করুন" +"Export {n} layers" = { one = "{n}টি লেয়ার এক্সপোর্ট করুন", other = "{n}টি লেয়ার এক্সপোর্ট করুন" } +"Exported {file}" = "{file} এক্সপোর্ট করা হয়েছে" +"Exported {name}" = "{name} এক্সপোর্ট করা হয়েছে" +"Exported {name}; {n} kinds of content were left out" = { one = "{name} এক্সপোর্ট করা হয়েছে; {n} ধরনের কন্টেন্ট বাদ দেওয়া হয়েছে", other = "{name} এক্সপোর্ট করা হয়েছে; {n} ধরনের কন্টেন্ট বাদ দেওয়া হয়েছে" } +"Exported {n} files" = { one = "{n}টি ফাইল এক্সপোর্ট করা হয়েছে", other = "{n}টি ফাইল এক্সপোর্ট করা হয়েছে" } +"Export…" = "এক্সপোর্ট..." +"Expose properties to instances" = "ইনস্ট্যান্সে প্রপার্টি উন্মুক্ত করুন" +"Exposure" = "এক্সপোজার" +"Expression" = "এক্সপ্রেশন" +"Extend collection" = "সংগ্রহ প্রসারিত করুন" +"Extends \"{name}\": overridden values are marked" = "\"{name}\"-কে প্রসারিত করে: ওভাররাইড করা মানগুলো চিহ্নিত করা আছে" +"Eye" = "চোখ" +"F" = "F" +"Fade" = "ফেড" +"False" = "মিথ্যা (False)" +"Feb" = "ফেব্রুয়ারি" +"Fetching from the server…" = "সার্ভার থেকে ডেটা আনা হচ্ছে..." +"FigCraft couldn't open its window: {error}" = "FigCraft এর উইন্ডো খুলতে পারেনি: {error}" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft এখনো এই ফাইলগুলোর সব ফিচার সমর্থন করে না। নোটগুলোতে ইম্পোর্ট করা কপিতে যা বাদ দেওয়া হয়েছে বা পরিবর্তন করা হয়েছে তার তালিকা রয়েছে; মূল ফাইলটি অপরিবর্তিত রয়েছে।" +"File" = "ফাইল" +"File name" = "ফাইলের নাম" +"File not found" = "ফাইল পাওয়া যায়নি" +"File published" = "ফাইল প্রকাশিত হয়েছে" +"Files aren't end-to-end encrypted: the server's operator can read them." = "ফাইলগুলো এন্ড-টু-এন্ড এনক্রিপ্টেড নয়: সার্ভারের অপারেটর এগুলো পড়তে পারেন।" +"Files nobody opens for {n} days are deleted." = { one = "যে ফাইল {n} দিন কেউ খোলে না তা ডিলিট করা হয়।", other = "যে ফাইলগুলো {n} দিন কেউ খোলে না সেগুলো ডিলিট করা হয়।" } +"Fill" = "ফিল" +"Fill (fr)" = "ফিল (fr)" +"Fill container" = "কন্টেইনার পূর্ণ করুন" +"Fill window" = "উইন্ডো পূর্ণ করুন" +"Fills" = "ফিলসমূহ" +"Fill|sizing" = "ফিল" +"Filter and sort" = "ফিল্টার ও সাজান" +"Find" = "খুঁজুন" +"Find and replace" = "খুঁজুন ও প্রতিস্থাপন করুন" +"Find layers" = "লেয়ার খুঁজুন" +"First" = "প্রথম" +"First commit" = "প্রথম কমিট" +"First on top" = "প্রথমে উপরে" +"Fit" = "ফিট" +"Fit device to window" = "উইন্ডোতে ডিভাইস ফিট করুন" +"Fit to contents" = "কন্টেন্ট অনুযায়ী ফিট করুন" +"Fit to window" = "উইন্ডো অনুযায়ী ফিট করুন" +"Fit width" = "প্রস্থ ফিট করুন" +"Fixed" = "নির্দিষ্ট (Fixed)" +"Fixed gap" = "নির্দিষ্ট গ্যাপ" +"Fixed size" = "নির্দিষ্ট আকার" +"Flatten" = "ফ্ল্যাট করুন" +"Flip" = "উল্টান (Flip)" +"Flip horizontal" = "অনুভূমিকভাবে উল্টান" +"Flip text to the other side" = "টেক্সট অন্য পাশে উল্টান" +"Flip vertical" = "উল্লম্বভাবে উল্টান" +"Flip width profile" = "প্রস্থ প্রোফাইল উল্টান" +"Flow starting point" = "ফ্লো শুরুর বিন্দু" +"Flow starting points" = "ফ্লো শুরুর বিন্দুসমূহ" +"Flows" = "ফ্লোসমূহ" +"Focus view" = "ফোকাস ভিউ" +"Fold autosaves" = "স্বয়ংক্রিয় সেভ ভাঁজ করুন" +"Folder: click to collapse" = "ফোল্ডার: সংকুচিত করতে ক্লিক করুন" +"Folder: click to expand" = "ফোল্ডার: প্রসারিত করতে ক্লিক করুন" +"Follow" = "অনুসরণ করুন" +"Follow {name}" = "{name}-কে অনুসরণ করুন" +"Following" = "অনুসরণ করা হচ্ছে" +"Following {name} · {key} to stop" = "{name}-কে অনুসরণ করা হচ্ছে · থামতে {key} টিপুন" +"Font" = "ফন্ট" +"Font not installed" = "ফন্ট ইনস্টল করা নেই" +"Font size" = "ফন্টের আকার" +"Font style" = "ফন্ট স্টাইল" +"Forget on this computer" = "এই কম্পিউটার থেকে মুছে ফেলুন" +"Four screens of a trail app with components and a prototype flow" = "কম্পোনেন্ট এবং প্রোটোটাইপ ফ্লো সহ ট্রেইল অ্যাপের চারটি স্ক্রিন" +"Fractions" = "ভগ্নাংশ" +"Frame" = "ফ্রেম" +"Frame selection" = "ফ্রেম নির্বাচন" +"Frame tools" = "ফ্রেম টুলস" +"Frames" = "ফ্রেমসমূহ" +"From the library \"{name}\": read-only" = "\"{name}\" লাইব্রেরি থেকে: শুধুমাত্র পড়ার উপযোগী (read-only)" +"From {name}" = "{name} থেকে" +"From {server}" = "{server} থেকে" +"Frost" = "ফ্রস্ট" +"Gap" = "গ্যাপ" +"Gap between columns" = "কলামের মধ্যবর্তী গ্যাপ" +"Gap between items" = "আইটেমের মধ্যবর্তী গ্যাপ" +"Gap between rows" = "সারির মধ্যবর্তী গ্যাপ" +"Getting started" = "শুরু করা" +"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "পাবলিকের জন্য উন্মুক্ত কোনো সার্ভারের ঠিকানা দিন, অথবা কী সহ সার্ভারের ঠিকানা দিন (`figcraft-server key` এটি প্রিন্ট করে)।" +"Glass" = "গ্লাস" +"Go to main component" = "প্রধান কম্পোনেন্টে যান" +"Go to {name}" = "{name}-এ যান" +"Grab bag" = "গ্র্যাব ব্যাগ" +"Gradient" = "গ্রেডিয়েন্ট" +"Gray" = "ধূসর" +"Green" = "সবুজ" +"Greyscale components and two example screens to sketch a flow with" = "একটি ফ্লো স্কেচ করার জন্য গ্রেস্কেল কম্পোনেন্ট এবং দুটি উদাহরণ স্ক্রিন" +"Grid" = "গ্রিড" +"Grid ({size})" = "গ্রিড ({size})" +"Grid styles" = "গ্রিড স্টাইল" +"Grid {size}" = "গ্রিড {size}" +"Group" = "গ্রুপ" +"Group selection" = "গ্রুপ নির্বাচন" +"Groups" = "গ্রুপসমূহ" +"Gutter" = "গাটার" +"Hand tool" = "হ্যান্ড টুল" +"Hanging list" = "হ্যাংগিং তালিকা" +"Hanging punctuation" = "হ্যাংগিং বিরামচিহ্ন" +"Hard light" = "হার্ড লাইট" +"Height" = "উচ্চতা" +"Help" = "সহায়তা" +"Help and resources" = "সহায়তা ও রিসোর্স" +"Help page" = "সহায়তা পেজ" +"Here: {names}" = "এখানে: {names}" +"Hidden" = "লুকানো" +"Hide contents" = "কন্টেন্ট লুকান" +"Hide from publishing" = "প্রকাশ করা থেকে লুকান" +"Hide section contents" = "সেকশনের কন্টেন্ট লুকান" +"Hide these tips (Getting started in the Help menu shows them again)" = "এই টিপসগুলো লুকান (সহায়তা মেনুর 'শুরু করা' অপশনটি এগুলো আবার দেখাবে)" +"Hide when publishing" = "প্রকাশ করার সময় লুকান" +"Highlight layers on hover" = "হোভারে লেয়ার হাইলাইট করুন" +"Highlights" = "হাইলাইটস" +"Hold Alt" = "Alt চেপে ধরে রাখুন" +"Hold Alt+Z" = "Alt+Z চেপে ধরে রাখুন" +"Hold Ctrl" = "Ctrl চেপে ধরে রাখুন" +"Hold Z" = "Z চেপে ধরে রাখুন" +"Hold ⌃ or ⌘" = "⌃ বা ⌘ চেপে ধরে রাখুন" +"Hold ⌥" = "⌥ চেপে ধরে রাখুন" +"Hold ⌥Z" = "⌥Z চেপে ধরে রাখুন" +"Home" = "হোম" +"Horizontal" = "অনুভূমিক" +"Horizontal align" = "অনুভূমিক সারিবদ্ধকরণ" +"Horizontal hex" = "অনুভূমিক হেক্স" +"Horizontal padding" = "অনুভূমিক প্যাডিং" +"Horizontal spacing" = "অনুভূমিক ব্যবধান" +"Hosted by {operator}" = "হোস্ট করেছেন: {operator}" +"Hover a layer to see its properties" = "প্রপার্টি দেখতে কোনো লেয়ারের উপর হোভার করুন" +"Hover other layers to measure the distance" = "দূরত্ব মাপতে অন্য লেয়ারগুলোর উপর হোভার করুন" +"Hue" = "হিউ (Hue)" +"Hug" = "হাগ" +"Hug contents" = "কন্টেন্ট অনুযায়ী হাগ করুন" +"Icon components on a 24 px grid with keylines and a size preview" = "কিলিন এবং সাইজ প্রিভিউ সহ ২৪ পিক্সেল গ্রিডে আইকন কম্পোনেন্ট" +"Icon set starter" = "আইকন সেট স্টার্টার" +"If it keeps happening, please report it at {link} with this message." = "যদি এটি বারবার ঘটতে থাকে, তবে অনুগ্রহ করে এই বার্তা সহ {link}-এ রিপোর্ট করুন।" +"If" = "যদি (If)" +"If {condition}" = "যদি {condition}" +"Ignore auto layout (absolute position)" = "অটো লেআউট উপেক্ষা করুন (অ্যাবসোলিউট পজিশন)" +"Image" = "ছবি" +"Images" = "ছবিসমূহ" +"Import" = "ইম্পোর্ট" +"Import path" = "ইম্পোর্ট পাথ" +"Import plugin from manifest" = "মেনিফেস্ট থেকে প্লাগইন ইম্পোর্ট করুন" +"Import plugin from manifest…" = "মেনিফেস্ট থেকে প্লাগইন ইম্পোর্ট করুন..." +"Import report" = "ইম্পোর্ট রিপোর্ট" +"Import variables" = "ভেরিয়েবল ইম্পোর্ট করুন" +"Imported document" = "ইম্পোর্ট করা ডকুমেন্ট" +"Imported {name}" = "{name} ইম্পোর্ট করা হয়েছে" +"Imported {n} new variables, updated {updated}" = { one = "{n}টি নতুন ভেরিয়েবল ইম্পোর্ট করা হয়েছে, {updated} আপডেট করা হয়েছে", other = "{n}টি নতুন ভেরিয়েবল ইম্পোর্ট করা হয়েছে, {updated} আপডেট করা হয়েছে" } +"Import…" = "ইম্পোর্ট..." +"In an instance, this comes from the main component" = "একটি ইনস্ট্যান্সে, এটি প্রধান কম্পোনেন্ট থেকে আসে" +"In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)." = "ব্রাউজারে আপনি ডেস্কটপ অ্যাপের হোস্ট করা সেশন এবং FigCraft সার্ভারের ফাইলে যুক্ত হতে পারবেন। https-এর মাধ্যমে খোলা কোনো পেজ শুধুমাত্র wss:// লিঙ্কের (TLS-এর পেছনের সার্ভার) মাধ্যমে অন্যান্য কম্পিউটারে পৌঁছায়।" +"Include bounding box" = "বাউন্ডিং বক্স অন্তর্ভুক্ত করুন" +"Include id attribute" = "id অ্যাট্রিবিউট অন্তর্ভুক্ত করুন" +"Included" = "অন্তর্ভুক্ত" +"Included in layout" = "লেআউটে অন্তর্ভুক্ত" +"Individual corners" = "পৃথক কোণা" +"Individual strokes" = "পৃথক স্ট্রোক" +"Ink" = "কালি (Ink)" +"Inner shadow" = "ইনার শ্যাডো" +"Inside" = "ভিতরে" +"Inspect" = "ইন্সপেক্ট" +"Inspect the layer under the pointer: size, colours, typography" = "পয়েন্টারের নিচের লেয়ারটি ইন্সপেক্ট করুন: আকার, রঙ, টাইপোগ্রাফি" +"Install plugin" = "প্লাগইন ইনস্টল করুন" +"Install" = "ইনস্টল করুন" +"Installed {name}" = "{name} ইনস্টল করা হয়েছে" +"Installed" = "ইনস্টল করা হয়েছে" +"Installing…" = "ইনস্টল হচ্ছে..." +"Instance" = "ইনস্ট্যান্স" +"Instance properties" = "ইনস্ট্যান্স প্রপার্টি" +"Instance swap" = "ইনস্ট্যান্স সোয়াপ" +"Instance swap (no other component)" = "ইনস্ট্যান্স সোয়াপ (অন্য কোনো কম্পোনেন্ট নেই)" +"Instances" = "ইনস্ট্যান্সসমূহ" +"Instant" = "তাৎক্ষণিক" +"Intensity" = "তীব্রতা" +"Interaction" = "ইন্টারঅ্যাকশন" +"Interactions" = "ইন্টারঅ্যাকশনসমূহ" +"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "ইন্টারফেস ডিজাইন এবং প্রোটোটাইপিং। Rust-এ শূন্য থেকে তৈরি একটি ফ্রি, ওপেন-সোর্স, ক্লিন-রুম ডিজাইন টুল, যা ArtCraft Crafting Apps-এর অংশ।" +"Intersect" = "ছেদ (Intersect)" +"Intersects" = "ছেদ করে" +"Invalid color" = "অবৈধ রঙ" +"Invite link" = "আমন্ত্রণ লিংক" +"Invite link copied" = "আমন্ত্রণ লিংক কপি করা হয়েছে" +"It asks permission to:" = "এটি অনুমতি চায়:" +"Jan" = "জানুয়ারি" +"Join" = "যুক্ত হোন" +"Join code copied" = "যুক্তি কোড (Join code) কপি করা হয়েছে" +"Join code or invite link" = "যুক্ত হওয়ার কোড বা আমন্ত্রণ লিংক" +"Join live session" = "লাইভ সেশনে যুক্ত হোন" +"Join us on Discord" = "ডিসকর্ডে আমাদের সাথে যুক্ত হোন" +"Join with a code" = "কোড দিয়ে যুক্ত হোন" +"Joined" = "যুক্ত হয়েছেন" +"Joined {file}" = "{file}-এ যুক্ত হয়েছেন" +"Joining…" = "যুক্ত হওয়া হচ্ছে..." +"Join|stroke" = "জয়েন" +"Jul" = "জুলাই" +"Jun" = "জুন" +"Just now" = "এইমাত্র" +"Justify" = "উভয়মুখী সারিবদ্ধকরণ (Justify)" +"Keep appearance" = "চেহারা অপরিবর্তিত রাখুন" +"Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "চেহারা অপরিবর্তিত রাখলে প্রতিটি রঙের মান {profile}-এ রূপান্তরিত হয় যাতে রঙগুলো একই দেখায়। মান অপরিবর্তিত রাখলে সংখ্যাগুলো যেমন আছে তেমনই থাকে, ফলে রঙ পরিবর্তিত হয়।" +"Keep tool selected after use" = "ব্যবহারের পরেও টুল নির্বাচিত রাখুন" +"Keep values" = "মান অপরিবর্তিত রাখুন" +"Key/Gamepad" = "কি/গেমপ্যাড" +"Key/Gamepad {key}" = "কি/গেমপ্যাড {key}" +"Keyboard shortcuts" = "কিবোর্ড শর্টকাট" +"Kits" = "কিটস" +"Landing page" = "ল্যান্ডিং পেজ" +"Language" = "ভাষা" +"Lasso" = "ল্যাসো" +"Last" = "শেষ" +"Last change: {note}" = "শেষ পরিবর্তন: {note}" +"Last commit" = "শেষ কমিট" +"Last modified" = "সর্বশেষ সংশোধিত" +"Last on top" = "প্রথমে নিচে" +"Layer blur" = "লেয়ার ব্লার" +"Layer name" = "লেয়ারের নাম" +"Layers" = "লেয়ারসমূহ" +"Layers in an instance take their position from the main component" = "ইনস্ট্যান্সের লেয়ারগুলো তাদের অবস্থান প্রধান কম্পোনেন্ট থেকে নেয়" +"Layout" = "লেআউট" +"Layout grid" = "লেআউট গ্রিড" +"Layout grid settings" = "লেআউট গ্রিড সেটিংস" +"Layout grids" = "লেআউট গ্রিডসমূহ" +"Leave session" = "সেশন ত্যাগ করুন" +"Leaves" = "ত্যাগ করে" +"Left" = "বামে" +"Left & right" = "বাম ও ডান" +"Left side" = "বাম পাশ" +"Left side width" = "বাম পাশের প্রস্থ" +"Letter case" = "লেটার কেস" +"Letter spacing" = "অক্ষরের ব্যবধান" +"Libraries" = "লাইব্রেরিসমূহ" +"Libraries in this file" = "এই ফাইলের লাইব্রেরিগুলো" +"Library updates available" = "লাইব্রেরি আপডেট উপলব্ধ আছে" +"Library: click to collapse" = "লাইব্রেরি: সংকুচিত করতে ক্লিক করুন" +"Library: click to expand" = "লাইব্রেরি: প্রসারিত করতে ক্লিক করুন" +"Library: its variables are read-only here; edit them in the library file and publish it" = "লাইব্রেরি: এর ভেরিয়েবলগুলো এখানে শুধুমাত্র পড়ার উপযোগী; লাইব্রেরি ফাইলে গিয়ে এগুলো এডিট করুন এবং প্রকাশ করুন" +"Light" = "লাইট" +"Lighten" = "লাইটেন" +"Light|glass" = "লাইট" +"Line" = "লাইন" +"Line arrow" = "লাইন তীর" +"Line delta" = "লাইন ডেল্টা" +"Line height" = "লাইনের উচ্চতা" +"Linear" = "লিনিয়ার" +"Lines" = "লাইনসমূহ" +"Lines +/−" = "লাইন +/−" +"Lines added" = "লাইন যোগ করা হয়েছে" +"Lines deleted" = "লাইন ডিলিট করা হয়েছে" +"Link" = "লিংক" +"Link copied" = "লিংক কপি করা হয়েছে" +"Link docs, code or tickets for developers." = "ডেভেলপারদের জন্য ডকুমেন্ট, কোড বা টিকিট লিংক করুন।" +"Link frames on the Prototype tab, then present the flow" = "প্রোটোটাইপ ট্যাবে ফ্রেমগুলো লিংক করুন, এরপর ফ্লো উপস্থাপন করুন" +"Link this component to its code component: instances then show your real snippet with their values." = "এই কম্পোনেন্টটিকে এর কোড কম্পোনেন্টের সাথে লিংক করুন: ইনস্ট্যান্সগুলো তখন তাদের মানসহ আপনার আসল স্নিপেট দেখাবে।" +"List spacing" = "তালিকায় ব্যবধান" +"List style" = "তালিকায় স্টাইল" +"Live session" = "লাইভ সেশন" +"Live · {people}" = "লাইভ · {people}" +"Local components" = "স্থানীয় কম্পোনেন্ট" +"Local network" = "স্থানীয় নেটওয়ার্ক" +"Local styles" = "স্থানীয় স্টাইল" +"Local variables" = "স্থানীয় ভেরিয়েবল" +"Lock/Unlock" = "লক/আনলক" +"Locked" = "লক করা" +"Lowercase" = "ছোট হাতের অক্ষর" +"Luminance" = "লুমিন্যান্স" +"Luminosity" = "উজ্জ্বলতা (Luminosity)" +"Main" = "প্রধান" +"Main component deleted" = "প্রধান কম্পোনেন্ট ডিলিট করা হয়েছে" +"Main menu" = "প্রধান মেনু" +"Make a component" = "একটি কম্পোনেন্ট তৈরি করুন" +"Make slot" = "স্লট তৈরি করুন" +"Mar" = "মার্চ" +"Margin" = "মার্জিন" +"Mark all as read" = "সব পঠিত হিসেবে চিহ্নিত করুন" +"Mark as resolved" = "সমাধানকৃত হিসেবে চিহ্নিত করুন" +"Mark as unread" = "অপঠিত হিসেবে চিহ্নিত করুন" +"Mark as unresolved" = "অমীমাংসিত হিসেবে চিহ্নিত করুন" +"Mask" = "মাস্ক" +"Mass · Stiffness · Damping" = "ভর · দৃঢ়তা · ড্যাম্পিং" +"Match (empty: the whole name)" = "মেলবন্ধন (খালি: সম্পূর্ণ নাম)" +"Max H" = "সর্বোচ্চ উচ্চতা" +"Max W" = "সর্বোচ্চ প্রস্থ" +"Max lines" = "সর্বোচ্চ লাইন" +"Maximize" = "বড় করুন" +"May" = "মে" +"Measure to the selection" = "নির্বাচন পর্যন্ত পরিমাপ করুন" +"Merge into main" = "প্রধান ফাইলের সাথে মার্জ করুন" +"Merge regions" = "অঞ্চলগুলো মার্জ করুন" +"Merge the branch into the main file (one undo step) and archive it" = "ব্রাঞ্চটিকে প্রধান ফাইলের সাথে মার্জ করুন (একটি আনডু ধাপ) এবং আর্কাইভ করুন" +"Merged" = "মার্জ করা হয়েছে" +"Merged PRs" = "মার্জ করা PR" +"Min H" = "সর্বনিম্ন উচ্চতা" +"Min W" = "সর্বনিম্ন প্রস্থ" +"Minimize" = "ছোট করুন" +"Mirror angle" = "মিরর কোণ" +"Mirror angle and length" = "মিরর কোণ ও দৈর্ঘ্য" +"Mirroring" = "মিররিং" +"Missing" = "অনুপস্থিত" +"Missing fonts" = "অনুপস্থিত ফন্ট" +"Missing layer" = "অনুপস্থিত লেয়ার" +"Missing style" = "অনুপস্থিত স্টাইল" +"Missing variable" = "অনুপস্থিত ভেরিয়েবল" +"Miter" = "মাইটার" +"Miter angle" = "মাইটার কোণ" +"Mixed" = "মিশ্রিত" +"Mixed fonts" = "মিশ্র ফন্ট" +"Mixed interactions: click + to replace them" = "মিশ্র ইন্টারঅ্যাকশন: প্রতিস্থাপন করতে + এ ক্লিক করুন" +"Mobile app" = "মোবাইল অ্যাপ" +"Mode" = "মোড" +"Model" = "মডেল" +"Models" = "মডেলসমূহ" +"Mono" = "মোনো" +"More" = "আরও" +"More instance actions" = "আরও ইনস্ট্যান্স অ্যাকশন" +"Mouse down" = "মাউস ডাউন" +"Mouse enter" = "মাউস এন্টার" +"Mouse leave" = "মাউস লিভ" +"Mouse up" = "মাউস আপ" +"Move along the toolbar or the layer rows" = "টুলবার বা লেয়ার সারির বরাবর সরান" +"Move" = "সরান" +"Move column left" = "কলাম বামে সরান" +"Move column right" = "কলাম ডানে সরান" +"Move down" = "নিচে সরান" +"Move in" = "ভিতরে সরান" +"Move out" = "বাইরে সরান" +"Move tools" = "সরানোর টুলস" +"Move up" = "উপরে সরান" +"Moved to {name}" = "{name}-এ সরানো হয়েছে" +"Multi" = "মাল্টি" +"Multiply" = "মাল্টিপ্লাই" +"Muted" = "মিউটেড" +"Name" = "নাম" +"Name (A–Z)" = "নাম (A–Z)" +"Named instance" = "নামকৃত ইনস্ট্যান্স" +"Navigate to" = "এতে যান" +"Nested instance" = "নেস্টেড ইনস্ট্যান্স" +"New design file" = "নতুন ডিজাইন ফাইল" +"Newest first" = "সবচেয়ে নতুন প্রথমে" +"Next or previous control in the focused region" = "ফোকাসড অঞ্চলের পরবর্তী বা পূর্ববর্তী কন্ট্রোল" +"Next frame" = "পরবর্তী ফ্রেম" +"Next ready for dev" = "ডেভের জন্য পরবর্তী প্রস্তুত" +"Next size option" = "পরবর্তী আকারের বিকল্প" +"No assets: no export settings, icons or images. Add export settings in the Design tab." = "কোনো অ্যাসেট নেই: কোনো এক্সপোর্ট সেটিংস, আইকন বা ছবি নেই। ডিজাইন ট্যাবে এক্সপোর্ট সেটিংস যোগ করুন।" +"No boolean variables fit here." = "এখানে কোনো বুলিয়ান ভেরিয়েবল মানানসই নয়।" +"No boolean variables yet. Create them in Local variables." = "এখনো কোনো বুলিয়ান ভেরিয়েবল নেই। স্থানীয় ভেরিয়েবলে (Local variables) এগুলো তৈরি করুন।" +"No branches. A branch is a copy of the file to try changes in, then review and merge." = "কোনো ব্রাঞ্চ নেই। ব্রাঞ্চ হলো পরিবর্তনগুলো পরীক্ষা করার জন্য ফাইলের একটি কপি, এরপর পর্যালোচনা করে মার্জ করা হয়।" +"No category" = "কোনো বিভাগ নেই" +"No changes" = "কোনো পরিবর্তন নেই" +"No color styles" = "কোনো কালার স্টাইল নেই" +"No colour variables fit here." = "এখানে কোনো রঙের ভেরিয়েবল মানানসই নয়।" +"No colour variables yet. Create them in Local variables." = "এখনো কোনো রঙের ভেরিয়েবল নেই। স্থানীয় ভেরিয়েবলে এগুলো তৈরি করুন।" +"No comments yet" = "এখনো কোনো মন্তব্য নেই" +"No compatibility warnings reported" = "কোনো সঙ্গতির সতর্কতা রিপোর্ট করা হয়নি" +"No components yet. Select layers and press {keys}." = "এখনো কোনো কম্পোনেন্ট নেই। লেয়ার নির্বাচন করুন এবং {keys} টিপুন।" +"No conflicts." = "কোনো দ্বন্দ্ব নেই।" +"No contributor data was built into this copy." = "এই কপিতে কোনো অবদানকারীর ডেটা যুক্ত করা হয়নি।" +"No device" = "কোনো ডিভাইস নেই" +"No fonts match" = "কোনো ফন্ট মেলে না" +"No files or templates match your search" = "আপনার অনুসন্ধানের সাথে কোনো ফাইল বা টেমপ্লেট মেলে না" +"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "এখানে কোনো যুক্ত হওয়ার কোড নেই (সার্ভারটি যুক্ত হওয়ার কোডের চেয়ে পুরনো, অথবা এই ঠিকানাকে কোডে রূপান্তর করা যায় না): পরিবর্তে লিংক শেয়ার করুন।" +"No layer changes to show." = "দেখানোর মতো কোনো লেয়ার পরিবর্তন নেই।" +"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "এখনো কোনো লাইব্রেরি নেই। লাইব্রেরি হিসেবে প্রকাশিত একটি .figcraft ফাইল যোগ করুন, অথবা টিম লাইব্রেরির লিংক পেস্ট করুন।" +"No list" = "কোনো তালিকা নেই" +"No matching comments" = "কোনো মিলছে এমন মন্তব্য নেই" +"No matching components" = "কোনো মিলছে এমন কম্পোনেন্ট নেই" +"No matching layers" = "কোনো মিলছে এমন লেয়ার নেই" +"No matching styles" = "কোনো মিলছে এমন স্টাইল নেই" +"No matching variables" = "কোনো মিলছে এমন ভেরিয়েবল নেই" +"No mirroring" = "কোনো মিররিং নেই" +"No model credits were built into this copy." = "এই কপিতে কোনো মডেল ক্রেডিট যুক্ত করা হয়নি।" +"No number variables fit here." = "এখানে কোনো নম্বরের ভেরিয়েবল মানানসই নয়।" +"No number variables yet. Create them in Local variables." = "এখনো কোনো নম্বরের ভেরিয়েবল নেই। স্থানীয় ভেরিয়েবলে এগুলো তৈরি করুন।" +"No other pages" = "অন্য কোনো পেজ নেই" +"No paints" = "কোনো পেইন্ট নেই" +"No plugins on {server}." = "{server}-এ কোনো প্লাগইন নেই।" +"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "এখনো কোনো প্লাগইন নেই। এর figcraft-plugin.json থেকে একটি ডেভেলপমেন্ট প্লাগইন ইম্পোর্ট করুন।" +"No preferred components" = "কোনো পছন্দের কম্পোনেন্ট নেই" +"No preset" = "কোনো প্রিসেট নেই" +"No saved versions yet. Save a version to mark a point in your work you can come back to." = "এখনো কোনো সংরক্ষিত সংস্করণ নেই। আপনার কাজের এমন একটি পয়েন্ট চিহ্নিত করতে একটি সংস্করণ সংরক্ষণ করুন যেখানে আপনি পরে ফিরে আসতে পারেন।" +"No scrolling" = "কোনো স্ক্রোলিং নেই" +"No shortcuts match" = "কোনো শর্টকাট মেলে না" +"No status" = "কোনো স্ট্যাটাস নেই" +"No string variables fit here." = "এখানে কোনো স্ট্রিং ভেরিয়েবল মানানসই নয়।" +"No string variables yet. Create them in Local variables." = "এখনো কোনো স্ট্রিং ভেরিয়েবল নেই। স্থানীয় ভেরিয়েবলে এগুলো তৈরি করুন।" +"No styles yet." = "এখনো কোনো স্টাইল নেই।" +"No styles yet. Click + to create one from the selection." = "এখনো কোনো স্টাইল নেই। নির্বাচন থেকে একটি তৈরি করতে + এ ক্লিক করুন।" +"No templates match your search" = "আপনার অনুসন্ধানের সাথে কোনো টেমপ্লেট মেলে না" +"No text styles yet. Click + to create one from the selected text." = "এখনো কোনো টেক্সট স্টাইল নেই। নির্বাচিত টেক্সট থেকে একটি তৈরি করতে + এ ক্লিক করুন।" +"No variables fit here." = "এখানে কোনো ভেরিয়েবল মানানসই নয়।" +"No variables yet. Create them in Local variables." = "এখনো কোনো ভেরিয়েবল নেই। স্থানীয় ভেরিয়েবলে এগুলো তৈরি করুন।" +"Nobody else is here yet" = "এখানে আর কেউ নেই" +"Noise" = "নয়েজ" +"None" = "কিছুই না" +"None." = "কিছুই না।" +"Normal" = "স্বাভাবিক" +"Not bound" = "আবদ্ধ নয়" +"Not in a live session" = "লাইভ সেশনে নেই" +"Not in this font" = "এই ফন্টে নেই" +"Not properties, kept as written: {names}" = "প্রপার্টি নয়, যেমনটি লেখা হয়েছে তেমনি রাখা হয়েছে: {names}" +"Not read yet" = "এখনো পড়া হয়নি" +"Not shared" = "শেয়ার করা হয়নি" +"Note {n}" = "নোট {n}" +"Nothing changed since this was marked ready for dev." = "এটি ডেভের জন্য প্রস্তুত হিসেবে চিহ্নিত করার পর থেকে কিছু পরিবর্তিত হয়নি।" +"Nothing here yet. Set a frame's status to list it." = "এখানে এখনো কিছু নেই। তালিকাভুক্ত করতে একটি ফ্রেমের স্ট্যাটাস সেট করুন।" +"Nothing online yet. Use Share on a file to put it online and it is listed here." = "অনলাইনে এখনো কিছু নেই। কোনো ফাইল অনলাইনে দিতে শেয়ার ব্যবহার করুন এবং এটি এখানে তালিকাভুক্ত হবে।" +"Nothing to export" = "এক্সপোর্ট করার মতো কিছু নেই" +"Nov" = "নভেম্বর" +"Now" = "এখন" +"Nudge (Shift: big nudge)" = "নাজ (Shift: বড় নাজ)" +"Nudge amount" = "নাজ পরিমাণ" +"Number" = "নম্বর" +"Number ↑" = "নম্বর ↑" +"Number ↓" = "নম্বর ↓" +"Numbered list" = "সাংখ্যিক তালিকা" +"Object" = "অবজেক্ট" +"Oct" = "অক্টোবর" +"Off" = "বন্ধ" +"Offered first when filling the slot (any content is allowed)" = "স্লট পূর্ণ করার সময় প্রথমে অফার করা হয় (যেকোনো কন্টেন্ট অনুমোদিত)" +"Offered first when swapping the instance (any component is allowed)" = "ইনস্ট্যান্স সোয়াপ করার সময় প্রথমে অফার করা হয় (যেকোনো কম্পোনেন্ট অনুমোদিত)" +"Offset" = "অফসেট" +"Oldest first" = "সবচেয়ে পুরনো প্রথমে" +"On click" = "ক্লিকে" +"On drag" = "ড্র্যাগে" +"On this page" = "এই পেজে" +"One announcement in five common sizes: square, portrait, story, link and banner" = "পাঁচটি সাধারণ আকারে একটি ঘোষণা: স্কয়ার, পোর্ট্রেট, স্টোরি, লিংক এবং ব্যানার" +"Online" = "অনলাইন" +"Online files" = "অনলাইন ফাইলসমূহ" +"Online sharing is off: files are shared on the local network only." = "অনলাইন শেয়ারিং বন্ধ আছে: ফাইলগুলো শুধুমাত্র স্থানীয় নেটওয়ার্কে শেয়ার করা হচ্ছে।" +"Only the first changed layers are listed." = "শুধুমাত্র প্রথম পরিবর্তিত লেয়ারগুলো তালিকাভুক্ত করা হয়েছে।" +"Only the first {n} changed layers are listed." = "শুধুমাত্র প্রথম {n}টি পরিবর্তিত লেয়ার তালিকাভুক্ত করা হয়েছে।" +"Only your threads" = "শুধুমাত্র আপনার থ্রেড" +"Opacity" = "অস্বচ্ছতা (Opacity)" +"Opacity & blend" = "অস্বচ্ছতা ও ব্লেন্ড" +"Opacity 10%…90%, 0: 100%" = "অস্বচ্ছতা ১০%…৯০%, ০: ১০০%" +"Open" = "খুলুন" +"Open .figcraft, .svg or images" = ".figcraft, .svg বা ছবি খুলুন" +"Open branch" = "ব্রাঞ্চ খুলুন" +"Open file" = "ফাইল খুলুন" +"Open files" = "ফাইলসমূহ খুলুন" +"Open in focus view" = "ফোকাস ভিউতে খুলুন" +"Open in new window" = "নতুন উইন্ডোতে খুলুন" +"Open invite links in FigCraft" = "FigCraft-এ আমন্ত্রণ লিংক খুলুন" +"Open link" = "লিংক খুলুন" +"Open link {url}" = "লিংক খুলুন {url}" +"Open overlay" = "ওভারলে খুলুন" +"Open prototype preview" = "প্রোটোটাইপ প্রিভিউ খুলুন" +"Open variables ({count})" = "ভেরিয়েবল খুলুন ({count})" +"Opened as a new document" = "নতুন ডকুমেন্ট হিসেবে খোলা হয়েছে" +"Opened “{name}” in a new tab" = "নতুন ট্যাবে “{name}” খোলা হয়েছে" +"Opening the file…" = "ফাইল খোলা হচ্ছে..." +"Opening {n} files as new files" = { one = "নতুন ফাইল হিসেবে খোলা হচ্ছে", other = "{n}টি ফাইল নতুন ফাইল হিসেবে খোলা হচ্ছে" } +"Open|comments" = "খুলুন" +"Options" = "বিকল্পসমূহ" +"Orange" = "কমলা" +"Order" = "ক্রম" +"Other apps may not open every .fig file exported here." = "অন্যান্য অ্যাপ এখানে এক্সপোর্ট করা প্রতিটি .fig ফাইল নাও খুলতে পারে।" +"Other" = "অন্যান্য" +"Others lose access and keep their copy; the file stays on the server for you" = "অন্যরা অ্যাক্সেস হারায় এবং তাদের কপি নিজের কাছে রাখে; ফাইলটি আপনার জন্য সার্ভারেই থেকে যায়" +"Others type this under Share ▸ Join with a code" = "অন্যরা 'Share ▸ Join with a code' এর নিচে এটি টাইপ করে" +"Outline stroke" = "আউটলাইন স্ট্রোক" +"Outline text" = "আউটলাইন টেক্সট" +"Outlines" = "আউটলাইনসমূহ" +"Outside" = "বাইরে" +"Overflow" = "ওভারফ্লো" +"Overlay" = "ওভারলে" +"Overlay position" = "ওভারলে অবস্থান" +"Overridden here (the collection it extends has another value)" = "এখানে ওভাররাইড করা হয়েছে (যে সংগ্রহটি এটি প্রসারিত করে তার অন্য একটি মান রয়েছে)" +"Overrides" = "ওভাররাইডসমূহ" +"Owner token" = "ওনার টোকেন" +"Owner token copied" = "ওনার টোকেন কপি করা হয়েছে" +"PDF print options" = "PDF প্রিন্ট বিকল্প" +"PDF print settings" = "PDF প্রিন্ট সেটিংস" +"Padding" = "প্যাডিং" +"Page" = "পেজ" +"Pages" = "পেজসমূহ" +"Pages added to the document" = "ডকুমেন্টে পেজ যোগ করা হয়েছে" +"Placed on the current page" = "বর্তমান পেজে রাখা হয়েছে" +"Pages: {pages}" = "পেজসমূহ: {pages}" +"Paint bucket" = "পেইন্ট বালতি" +"Pan the canvas" = "ক্যানভাস প্যান করুন" +"Pan with nothing selected (Shift: farther)" = "কিছু নির্বাচিত না করে প্যান করুন (Shift: দূরে)" +"Panels" = "প্যানেলসমূহ" +"Paper" = "কাগজ" +"Paragraph indent" = "প্যারাগ্রাফ ইনডেন্ট" +"Paragraph indent applies to left-aligned text" = "প্যারাগ্রাফ ইনডেন্ট বাম-সারিবদ্ধ টেক্সটের ক্ষেত্রে প্রযোজ্য" +"Paragraph spacing" = "প্যারাগ্রাফ ব্যবধান" +"Pass through" = "পাস থ্রু" +"Paste a link (https://…)" = "একটি লিংক পেস্ট করুন (https://…)" +"Paste a team library link (a view link)" = "একটি টিম লাইব্রেরি লিংক পেস্ট করুন (ভিউ লিংক)" +"Paste here" = "এখানে পেস্ট করুন" +"Paste to replace" = "প্রতিস্থাপন করতে পেস্ট করুন" +"Pasted design layers; {n} kinds of content were left out" = { one = "ডিজাইন লেয়ার পেস্ট করা হয়েছে; {n} ধরনের কন্টেন্ট বাদ দেওয়া হয়েছে", other = "ডিজাইন লেয়ার পেস্ট করা হয়েছে; {n} ধরনের কন্টেন্ট বাদ দেওয়া হয়েছে" } +"Pattern" = "প্যাটার্ন" +"Pattern alignment" = "প্যাটার্ন সারিবদ্ধকরণ" +"Pattern scale" = "প্যাটার্ন স্কেল" +"Pattern source" = "প্যাটার্ন উৎস" +"Pen" = "কলম" +"Pencil" = "পেন্সিল" +"People in this file" = "এই ফাইলের লোকজন" +"People join from anywhere with a code. The file is kept on {server} while you share it." = "কোডের মাধ্যমে যেকোনো স্থান থেকে লোকজন যুক্ত হতে পারে। ফাইলটি শেয়ার করার সময় {server}-এ রাখা হয়।" +"People on your network join with a code. Nothing goes through a server." = "আপনার নেটওয়ার্কের লোকজন কোডের মাধ্যমে যুক্ত হয়। কোনো সার্ভারের মাধ্যমে কিছু যায় না।" +"Phone" = "ফোন" +"Pick color" = "রঙ বাছুন" +"Pick color (I)" = "রঙ বাছুন (I)" +"Pick one font family first" = "প্রথমে একটি ফন্ট ফ্যামিলি বাছুন" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "ফ্রেম টুল বেছে নিন, এরপর ক্যানভাসে ড্র্যাগ করুন অথবা একটি ডিভাইসের আকার বেছে নিন" +"Pin a property" = "একটি প্রপার্টি পিন করুন" +"Pinch" = "পিন্চ" +"Pixel grid" = "পিক্সেল গ্রিড" +"Pixel preview 1x" = "পিক্সেল প্রিভিউ 1x" +"Pixel preview 2x" = "পিক্সেল প্রিভিউ 2x" +"Place a shape or frame (its tool chosen)" = "একটি শেপ বা ফ্রেম রাখুন (এর টুল নির্বাচিত)" +"Place automatically" = "স্বয়ংক্রিয়ভাবে রাখুন" +"Place image" = "ছবি রাখুন" +"Plugin" = "প্লাগইন" +"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "প্লাগইনগুলো ফাইল, নেটওয়ার্ক বা ঘড়ি ছাড়া FigCraft-এর স্যান্ডবক্সে চলে। শুধুমাত্র বিশ্বস্ত প্লাগইন ইনস্টল করুন।" +"Plugins" = "প্লাগইনসমূহ" +"Plugins & widgets" = "প্লাগইন ও উইজেট" +"Plus darker" = "প্লাস অন্ধকার" +"Plus lighter" = "প্লাস হালকা" +"Point" = "বিন্দু" +"Points" = "বিন্দুসমূহ" +"Polygon" = "বহুভুজ" +"Polygons" = "বহুভুজসমূহ" +"Port" = "পোর্ট" +"Position" = "অবস্থান" +"Post" = "পোস্ট" +"Preferences" = "প্রেফারেন্সেস" +"Preferred" = "পছন্দসই" +"Present" = "উপস্থাপন করুন" +"Present or preview" = "উপস্থাপন বা প্রিভিউ করুন" +"Present this flow" = "এই ফ্লো উপস্থাপন করুন" +"Presentation" = "উপস্থাপন" +"Presentation slides" = "উপস্থাপন স্লাইড" +"Presenting: comments" = "উপস্থাপন করা হচ্ছে: মন্তব্য" +"Presenting: cycle the size options" = "উপস্থাপন করা হচ্ছে: আকারের বিকল্পগুলো সাইকেল করুন" +"Presenting: full screen" = "উপস্থাপন করা হচ্ছে: ফুল স্ক্রিন" +"Presenting: next frame" = "উপস্থাপন করা হচ্ছে: পরবর্তী ফ্রেম" +"Presenting: previous frame" = "উপস্থাপন করা হচ্ছে: পূর্ববর্তী ফ্রেম" +"Presenting: restart" = "উপস্থাপন করা হচ্ছে: পুনরায় শুরু" +"Preserve scroll position" = "স্ক্রোল অবস্থান সংরক্ষণ করুন" +"Presets" = "প্রিসেটসমূহ" +"Press a key…" = "একটি কি টিপুন..." +"Playground" = "প্লেগ্রাউন্ড" +"Preview" = "প্রিভিউ" +"Preview this flow" = "এই ফ্লো প্রিভিউ করুন" +"Preview this frame" = "এই ফ্রেম প্রিভিউ করুন" +"Previous frame" = "পূর্ববর্তী ফ্রেম" +"Previous ready for dev" = "ডেভের জন্য পূর্ববর্তী প্রস্তুত" +"Progressive" = "প্রোগ্রেসিভ" +"Properties" = "প্রপার্টিজ" +"Prototype" = "প্রোটোটাইপ" +"Prototype preview" = "প্রোটোটাইপ প্রিভিউ" +"Prototype settings" = "প্রোটোটাইপ সেটিংস" +"Publish" = "প্রকাশ করুন" +"Publish changes" = "পরিবর্তন প্রকাশ করুন" +"Publish to another server…" = "অন্য সার্ভারে প্রকাশ করুন..." +"Publish to server" = "সার্ভারে প্রকাশ করুন" +"Published version {v}" = "প্রকাশিত সংস্করণ {v}" +"Published version {v} to the server" = "সংস্করণ {v} সার্ভারে প্রকাশ করা হয়েছে" +"Published version {v}: save the file so other files can use it" = "প্রকাশিত সংস্করণ {v}: ফাইলটি সেভ করুন যাতে অন্যান্য ফাইল এটি ব্যবহার করতে পারে" +"Published · {people}" = "প্রকাশিত · {people}" +"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "সার্ভারে প্রকাশ করতে ডেস্কটপ অ্যাপ প্রয়োজন; ব্রাউজারে আপনি সার্ভারের ফাইলে যুক্ত হতে পারবেন" +"Publishing with the server's key." = "সার্ভারের কী দিয়ে প্রকাশ করা হচ্ছে।" +"Publishing…" = "প্রকাশ করা হচ্ছে..." +"Push" = "পুশ" +"Push changes to main component" = "প্রধান কম্পোনেন্টে পরিবর্তন পুশ করুন" +"Put text on path" = "পাথে টেক্সট রাখুন" +"R" = "R" +"Radial" = "রেডিয়াল" +"Radius" = "ব্যাসার্ধ" +"Ratio" = "অনুপাত" +"Reading the library…" = "লাইব্রেরি পড়া হচ্ছে..." +"Ready for dev" = "ডেভের জন্য প্রস্তুত" +"Real name" = "আসল নাম" +"Recent" = "সাম্প্রতিক" +"Recent files" = "সাম্প্রতিক ফাইল" +"Recents" = "সাম্প্রতিকসমূহ" +"Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them" = { one = "অ্যাপটি অপ্রত্যাশিতভাবে বন্ধ হয়ে যাওয়ার পর একটি অসংরক্ষিত ফাইল পুনরুদ্ধার করা হয়েছে: এটি রাখতে সেভ করুন", other = "অ্যাপটি অপ্রত্যাশিতভাবে বন্ধ হয়ে যাওয়ার পর {n}টি অসংরক্ষিত ফাইল পুনরুদ্ধার করা হয়েছে: এগুলো রাখতে সেভ করুন" } +"Rectangle" = "আয়তক্ষেত্র" +"Rectangles" = "আয়তক্ষেত্রসমূহ" +"Red" = "লাল" +"Reduce motion" = "মোশন কমান" +"Refraction" = "প্রতিসরণ" +"Regular expression" = "রেগুলার এক্সপ্রেশন" +"Remove" = "সরান" +"Remove all" = "সব সরান" +"Remove annotation" = "অ্যানোটেশন সরান" +"Remove auto layout" = "অটো লেআউট সরান" +"Remove dev resource" = "ডেভ রিসোর্স সরান" +"Remove export setting" = "এক্সপোর্ট সেটিংস সরান" +"Remove from recents" = "সাম্প্রতিক থেকে সরান" +"Remove from server" = "সার্ভার থেকে সরান" +"Remove from server…" = "সার্ভার থেকে সরান..." +"Remove guide" = "গাইড সরান" +"Remove interaction" = "ইন্টারঅ্যাকশন সরান" +"Remove library from this file" = "এই ফাইল থেকে লাইব্রেরি সরান" +"Remove min and max" = "সর্বনিম্ন এবং সর্বোচ্চ সরান" +"Remove min and max height" = "সর্বনিম্ন এবং সর্বোচ্চ উচ্চতা সরান" +"Remove min and max width" = "সর্বনিম্ন এবং সর্বোচ্চ প্রস্থ সরান" +"Remove regions" = "অঞ্চলগুলো সরান" +"Remove slot" = "স্লট সরান" +"Remove starting point" = "শুরু করার বিন্দু সরান" +"Remove stop" = "স্টপ সরান" +"Remove text from path" = "পাথ থেকে টেক্সট সরান" +"Remove the else and its actions" = "else এবং এর অ্যাকশনগুলো সরান" +"Remove this condition and its actions" = "এই শর্ত এবং এর অ্যাকশনগুলো সরান" +"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "সার্ভার থেকে এই ফাইলটি সরাবেন? এটি সেখান থেকে আর শেয়ার বা খোলা যাবে না। আপনার কপিটি এখানে খোলা থাকবে।" +"Remove {name} from session" = "সেশন থেকে {name}-কে সরান" +"Remove {name} from {server}?" = "{server} থেকে {name}-কে সরাবেন?" +"Removed" = "সরে গেছে" +"Removed from the server" = "সার্ভার থেকে সরানো হয়েছে" +"Removed since it was marked" = "চিহ্নিত করার পর থেকে সরানো হয়েছে" +"Rename" = "নাম পরিবর্তন করুন" +"Rename duplicated layers" = "ডুপ্লিকেট করা লেয়ারের নাম পরিবর্তন করুন" +"Rename folder" = "ফোল্ডারের নাম পরিবর্তন করুন" +"Rename group" = "গ্রুপের নাম পরিবর্তন করুন" +"Rename layers" = "লেয়ারের নাম পরিবর্তন করুন" +"Rename mode" = "মোডের নাম পরিবর্তন করুন" +"Rename on the server" = "সার্ভারে নাম পরিবর্তন করুন" +"Rename on the server…" = "সার্ভারে নাম পরিবর্তন করুন..." +"Rename page" = "পেজের নাম পরিবর্তন করুন" +"Rename to" = "নাম পরিবর্তন করে রাখুন" +"Replace all" = "সব প্রতিস্থাপন করুন" +"Replace mixed export settings" = "মিশ্র এক্সপোর্ট সেটিংস প্রতিস্থাপন করুন" +"Replace with" = "এটি দিয়ে প্রতিস্থাপন করুন" +"Replace with a new interaction" = "একটি নতুন ইন্টারঅ্যাকশন দিয়ে প্রতিস্থাপন করুন" +"Reply" = "উত্তর দিন" +"Report" = "রিপোর্ট" +"Report this file…" = "এই ফাইলটি রিপোর্ট করুন..." +"Reset" = "রিসেট" +"Reset adjustments" = "অ্যাডজাস্টমেন্ট রিসেট করুন" +"Reset all changes" = "সব পরিবর্তন রিসেট করুন" +"Reset all overrides on this layer" = "এই লেয়ারের সব ওভাররাইড রিসেট করুন" +"Reset overrides" = "ওভাররাইড রিসেট করুন" +"Reset slot" = "স্লট রিসেট করুন" +"Reset this override" = "এই ওভাররাইড রিসেট করুন" +"Reset to style" = "স্টাইলে রিসেট করুন" +"Reset to the inherited value" = "উত্তরাধিকারসূত্রে প্রাপ্ত মানে রিসেট করুন" +"Resize by the nudge amount" = "নাজ পরিমাণ অনুযায়ী আকার পরিবর্তন করুন" +"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "ডিভাইসের স্ক্রিনে (বা ডিভাইস ছাড়া উইন্ডোতে) ফ্রেমের আকার পরিবর্তন করুন: তাদের সীমাবদ্ধতা এবং অটো লেআউট সাড়া দেয়" +"Resize to fit" = "ফিট করতে আকার পরিবর্তন করুন" +"Resizing" = "আকার পরিবর্তন করা হচ্ছে" +"Resolve" = "সমাধান করুন" +"Resolve change" = "পরিবর্তন সমাধান করুন" +"Resolved" = "সমাধান করা হয়েছে" +"Resources" = "রিসোর্সসমূহ" +"Responsive" = "রেসপনসিভ" +"Restart" = "পুনরায় শুরু করুন" +"Restore" = "পুনরুদ্ধার করুন" +"Restore main component" = "প্রধান কম্পোনেন্ট পুনরুদ্ধার করুন" +"Restore this version" = "এই সংস্করণ পুনরুদ্ধার করুন" +"Restored “{name}”" = "“{name}” পুনরুদ্ধার করা হয়েছে" +"Reverse the order" = "ক্রম উল্টে দিন" +"Reversed triangle" = "উল্টানো ত্রিভুজ" +"Review and merge" = "পর্যালোচনা ও মার্জ করুন" +"Ribbon" = "রিবন" +"Right" = "ডানে" +"Right side" = "ডান পাশ" +"Right side width" = "ডান পাশের প্রস্থ" +"Rotate 90°" = "৯০° ঘোরান" +"Rotate 90° right" = "ডানে ৯০° ঘোরান" +"Rotate to landscape" = "ল্যান্ডস্কেপে ঘোরান" +"Rotate to portrait" = "পোর্ট্রেটে ঘোরান" +"Rotation" = "ঘূর্ণন" +"Rotation jitter" = "ঘূর্ণন জিটার" +"Round" = "গোল" +"Rounded" = "গোল করা" +"Row" = "সারি" +"Row gap" = "সারির গ্যাপ" +"Row sizes" = "সারির আকার" +"Row span" = "সারি স্প্যান" +"Rows" = "সারিগুলো" +"Rows {count}" = "সারি {count}" +"Rulers" = "রুলার" +"Run" = "চালান" +"Run {label}" = "চালান {label}" +"Save files automatically" = "স্বয়ংক্রিয়ভাবে ফাইল সেভ করুন" +"Search templates" = "টেমপ্লেট খুঁজুন" +"See all templates" = "সব টেমপ্লেট দেখুন" +"Select layers and they space themselves as their content changes" = "লেয়ার নির্বাচন করুন এবং তাদের কন্টেন্ট পরিবর্তিত হওয়ার সাথে সাথে তারা নিজেদের মধ্যে দূরত্ব বজায় রাখবে" +"SHA-256: {sha}…" = "SHA-256: {sha}…" +"Same as layer" = "লেয়ারের মতোই" +"Saturation" = "স্যাচুরেশন" +"Save" = "সেভ করুন" +"Save as" = "এভাবে সেভ করুন" +"Save as preset…" = "প্রিসেট হিসেবে সেভ করুন..." +"Save version {key}" = "সংস্করণ সেভ করুন {key}" +"Saved" = "সেভ করা হয়েছে" +"Saved {name}" = "সেভ করা হয়েছে {name}" +"Say something" = "কিছু বলুন" +"Scale" = "স্কেল" +"Scatter" = "স্ক্যাটার" +"Scoping: where pickers offer it" = "স্কোপিং: যেখানে পিকার এটি অফার করে" +"Screen" = "স্ক্রিন" +"Scroll behavior" = "স্ক্রোল আচরণ" +"Scroll to" = "স্ক্রোল করে যান" +"Scroll to layer" = "লেয়ারে স্ক্রোল করুন" +"Scroll with parent" = "প্যারেন্টের সাথে স্ক্রোল করুন" +"Search plugins" = "প্লাগইন খুঁজুন" +"Search" = "অনুসন্ধান" +"Search actions, layers and tools" = "অ্যাকশন, লেয়ার এবং টুল খুঁজুন" +"Search assets" = "অ্যাসেট খুঁজুন" +"Search comments" = "মন্তব্য খুঁজুন" +"Search files and templates" = "ফাইল এবং টেমপ্লেট খুঁজুন" +"Search results" = "অনুসন্ধানের ফলাফল" +"Search fonts" = "ফন্ট খুঁজুন" +"Search layers" = "লেয়ার খুঁজুন" +"Search shortcuts" = "শর্টকাট খুঁজুন" +"Section" = "সেকশন" +"Section color" = "সেকশনের রঙ" +"Sections" = "সেকশনসমূহ" +"Select a frame and add a starting point to present it as a flow." = "একটি ফ্রেম নির্বাচন করুন এবং ফ্লো হিসেবে উপস্থাপন করতে একটি শুরুর বিন্দু যোগ করুন।" +"Select a frame to preview it" = "প্রিভিউ করতে একটি ফ্রেম নির্বাচন করুন" +"Select a layer to see its properties and code" = "এর প্রপার্টি এবং কোড দেখতে একটি লেয়ার নির্বাচন করুন" +"Select all" = "সব নির্বাচন করুন" +"Select all with same" = "একই রকম সব নির্বাচন করুন" +"Select layer" = "লেয়ার নির্বাচন করুন" +"Select layers to apply the style to" = "স্টাইল প্রয়োগ করার জন্য লেয়ার নির্বাচন করুন" +"Select layers with this color" = "এই রঙের লেয়ারগুলো নির্বাচন করুন" +"Select overlapping shapes" = "overlapping শেপগুলো নির্বাচন করুন" +"Select points to edit their position, radius and mirroring" = "অবস্থান, ব্যাসার্ধ এবং মিররিং এডিট করতে পয়েন্ট নির্বাচন করুন" +"Select slot" = "স্লট নির্বাচন করুন" +"Selection colors" = "নির্বাচনের রঙ" +"Selection" = "নির্বাচন" +"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "নির্বাচন বাক্স, {w} × {h}, অবস্থান {x}, {y}: অ্যারো কি দিয়ে সরানো যায়, Shift+arrows বা {cmd}+arrows দিয়ে আকার পরিবর্তন করা যায়, Enter দিয়ে নির্বাচন হয়, Escape দিয়ে বাতিল হয়" +"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "নির্বাচন বাক্স: অ্যারো কি দিয়ে সরানো যায়, Shift+arrows বা {cmd}+arrows দিয়ে আকার পরিবর্তন করা যায়, Enter দিয়ে নির্বাচন হয়, Escape দিয়ে বাতিল হয়" +"Send backward" = "পেছনে পাঠান" +"Send report" = "রিপোর্ট পাঠান" +"Send to back" = "সবার পেছনে পাঠান" +"Sep" = "সেপ্টেম্বর" +"Server address (ws://host:47900/)" = "সার্ভার ঠিকানা (ws://host:47900/)" +"Server address" = "সার্ভারের ঠিকানা" +"Server for online sharing" = "অনলাইন শেয়ারিংয়ের সার্ভার" +"Set as default" = "ডিফল্ট হিসেবে সেট করুন" +"Set variable" = "ভেরিয়েবল সেট করুন" +"Set variable mode" = "ভেরিয়েবল মোড সেট করুন" +"Set variable {name}" = "ভেরিয়েবল সেট করুন {name}" +"Set {name} to {value}" = "{name}-এর মান {value} সেট করুন" +"Shadows" = "ছায়াসমূহ (Shadows)" +"Shape" = "শেপ" +"Shape builder" = "শেপ বিল্ডার" +"Shape tools" = "শেপ টুলস" +"Share" = "শেয়ার" +"Share this file" = "এই ফাইলটি শেয়ার করুন" +"Share with a code" = "কোড দিয়ে শেয়ার করুন" +"Share: live session, export" = "শেয়ার: লাইভ সেশন, এক্সপোর্ট" +"Shared" = "শেয়ার করা হয়েছে" +"Shared: copy its code from the file's menu" = "শেয়ার করা হয়েছে: ফাইলের মেনু থেকে এর কোড কপি করুন" +"Sharing a file needs the desktop app; here you can join one." = "ফাইল শেয়ার করতে ডেস্কটপ অ্যাপ প্রয়োজন; এখানে আপনি একটিতে যুক্ত হতে পারবেন।" +"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "স্থানীয় নেটওয়ার্কে শেয়ার করা হচ্ছে (অনলাইন শেয়ারিং বন্ধ আছে: প্রেফারেন্সেসে কোনো সার্ভার সেট করা নেই)।" +"Sharing on your network · {people}" = "আপনার নেটওয়ার্কে শেয়ার করা হচ্ছে · {people}" +"Sharing online · {people}" = "অনলাইনে শেয়ার করা হচ্ছে · {people}" +"Sharing stopped: the file stays on the server, open only to you" = "শেয়ারিং বন্ধ করা হয়েছে: ফাইলটি সার্ভারেই আছে, শুধুমাত্র আপনার জন্য খোলা" +"Show" = "দেখান" +"Show all templates" = "সব টেমপ্লেট দেখান" +"Show as grid" = "গ্রিড হিসেবে দেখান" +"Show as list" = "তালিকা হিসেবে দেখান" +"Show autosaves" = "স্বয়ংক্রিয় সেভ দেখান" +"Show behind transparent areas" = "স্বচ্ছ এলাকার পেছনে দেখান" +"Show comments (Shift+C)" = "মন্তব্য দেখান (Shift+C)" +"Show device frame" = "ডিভাইস ফ্রেম দেখান" +"Show dimensions on objects" = "অবজেক্টে মাত্রা (dimensions) দেখান" +"Show hotspot hints" = "হটস্পট ইঙ্গিত দেখান" +"Show in Explorer" = "এক্সপ্লোরারে দেখান" +"Show in Finder" = "ফাইন্ডারে দেখান" +"Show in folder" = "ফোল্ডারে দেখান" +"Show only this on the canvas" = "ক্যানভাসে শুধুমাত্র এটি দেখান" +"Show open" = "খোলা দেখান" +"Show section contents" = "সেকশনের কন্টেন্ট দেখান" +"Show this layer" = "এই লেয়ারটি দেখান" +"Show usage" = "ব্যবহার দেখান" +"Show/Hide" = "দেখান/লুকান" +"Show/Hide UI" = "UI দেখান/লুকান" +"Side by side" = "পাশাপাশি" +"Sidebar" = "সাইডবার" +"Simplify stroke" = "স্ট্রোক সরল করুন" +"Size" = "আকার" +"Size jitter" = "আকার জিটার" +"Skip ink" = "কালি বাদ দিন" +"Slashed zero" = "স্ল্যাশড জিরো" +"Slice" = "স্লাইস" +"Slices" = "স্লাইসসমূহ" +"Slide in" = "স্লাইড ইন" +"Slide out" = "স্লাইড আউট" +"Slot" = "স্লট" +"Slot content" = "স্লট কন্টেন্ট" +"Small capitals" = "ছোট বড় হাতের অক্ষর" +"Small caps" = "স্মল ক্যাপস" +"Smart animate" = "স্মার্ট অ্যানিমেট" +"Snap to pixel grid" = "পিক্সেল গ্রিডে স্ন্যাপ করুন" +"Social and slides" = "সোশ্যাল ও স্লাইড" +"Social media" = "সোশ্যাল মিডিয়া" +"Social post sizes" = "সোশ্যাল পোস্টের আকার" +"Soft light" = "সফট লাইট" +"Solid" = "সলিড" +"Some variants have the same property values: give each one a unique combination" = "কিছু ভ্যারিয়েন্টের প্রপার্টি মান একই: প্রতিটিকে একটি অনন্য কম্বিনেশন দিন" +"Sort" = "সাজান" +"Sort by" = "যেভাবে সাজাবেন" +"Sort by date" = "তারিখ অনুযায়ী সাজান" +"Sort by page" = "পেজ অনুযায়ী সাজান" +"Sort by {column}" = "{column} অনুযায়ী সাজান" +"Space around" = "চারপাশে স্পেস" +"Space between" = "মাঝখানে স্পেস" +"Space drag" = "স্পেস ড্র্যাগ" +"Space evenly" = "সমানভাবে স্পেস" +"Space+drag" = "Space+ড্র্যাগ" +"Spacing" = "ব্যবধান" +"Spam, stolen work, abuse…" = "স্প্যাম, চুরি করা কাজ, অপব্যবহার..." +"Splay" = "সপ্লে" +"Spotlight me" = "আমাকে স্পটলাইট করুন" +"Spread" = "স্প্রেড" +"Square" = "বর্গক্ষেত্র" +"Standard" = "স্ট্যান্ডার্ড" +"Standard ligatures" = "স্ট্যান্ডার্ড লিগেচার" +"Star" = "তারকা" +"Stars" = "তারকাসমূহ" +"Start" = "শুরু" +"Start ascending from / stop descending at" = "এখান থেকে ওঠা শুরু করুন / এখানে নামা বন্ধ করুন" +"Start from a blank canvas" = "খালি ক্যানভাস থেকে শুরু করুন" +"Start from a template" = "টেমপ্লেট থেকে শুরু করুন" +"Start on path" = "পাথ থেকে শুরু করুন" +"Start point" = "শুরুর বিন্দু" +"Start sharing" = "শেয়ার করা শুরু করুন" +"Start sharing with the Share button, then pass on the code it gives" = "শেয়ার বোতাম দিয়ে শেয়ার করা শুরু করুন, তারপর এটি যে কোড দেয় তা দিন" +"Starting to share…" = "শেয়ার করা শুরু হচ্ছে..." +"Status" = "স্ট্যাটাস" +"Sticky" = "স্টিকি" +"Stop" = "থামুন" +"Stop following {name}" = "{name}-কে অনুসরণ করা বন্ধ করুন" +"Stop session" = "সেশন বন্ধ করুন" +"Stop sharing" = "শেয়ার করা বন্ধ করুন" +"Stop spotlighting" = "স্পটলাইটিং বন্ধ করুন" +"Stop {n} {hex}" = "স্টপ {n} {hex}" +"Stretch" = "টেনে বড় করা (Stretch)" +"Strikethrough" = "স্ট্রাইকথ্রু" +"String" = "স্ট্রিং" +"Stroke" = "স্ট্রোক" +"Stroke style" = "স্ট্রোক স্টাইল" +"Stroke weight" = "স্ট্রোকের ওজন" +"Strokes" = "স্ট্রোকসমূহ" +"Style" = "স্টাইল" +"Styles" = "স্টাইলসমূহ" +"Stylistic set 1" = "স্টাইলিস্টিক সেট ১" +"Stylistic set 2" = "স্টাইলিস্টিক সেট ২" +"Stylistic set 3" = "স্টাইলিস্টিক সেট ৩" +"Stylistic set 4" = "স্টাইলিস্টিক সেট ৪" +"Stylistic set 5" = "স্টাইলিস্টিক সেট ৫" +"Subtract" = "বিয়োগ করুন" +"Subtracts" = "বিয়োগ করে" +"Suffix" = "সাফিক্স" +"Suspend snapping while dragging" = "ড্র্যাগ করার সময় স্ন্যাপিং স্থগিত রাখুন" +"Swap instance" = "ইনস্ট্যান্স সোয়াপ করুন" +"Swap library: use another library's assets of the same names" = "লাইব্রেরি সোয়াপ করুন: একই নামের অন্য লাইব্রেরির অ্যাসেট ব্যবহার করুন" +"Swap orientation" = "ভিত্তিক বিন্যাস (orientation) সোয়াপ করুন" +"Swap overlay" = "ওভারলে সোয়াপ করুন" +"Swap to {name}" = "{name}-এ সোয়াপ করুন" +"Swap width and height" = "প্রস্থ ও উচ্চতা সোয়াপ করুন" +"Swapped instance" = "সোয়াপ করা ইনস্ট্যান্স" +"Swapped {n} assets" = { one = "{n}টি অ্যাসেট সোয়াপ করা হয়েছে", other = "{n}টি অ্যাসেট সোয়াপ করা হয়েছে" } +"Swapped {n} assets; {missed} had no match and still use the old library" = { one = "{n}টি অ্যাসেট সোয়াপ করা হয়েছে; {missed}-এর কোনো মিল পাওয়া যায়নি এবং এখনো পুরোনো লাইব্রেরি ব্যবহার করছে", other = "{n}টি অ্যাসেট সোয়াপ করা হয়েছে; {missed}-এর কোনো মিল পাওয়া যায়নি এবং এখনো পুরোনো লাইব্রেরি ব্যবহার করছে" } +"Sweep" = "সুইপ" +"System" = "সিস্টেম" +"Tab / Shift+Tab" = "Tab / Shift+Tab" +"Table" = "টেবিল" +"Tablet" = "ট্যাবলেট" +"Tabular numbers" = "টাবুলার সংখ্যা" +"Take the values from the first selected layer" = "প্রথমে নির্বাচিত লেয়ার থেকে মানগুলো নিন" +"Taper" = "ট্যাপার" +"Taper both ends" = "উভয় প্রান্ত ট্যাপার করুন" +"Tell the server's operator what is wrong with it" = "সার্ভারের অপারেটরকে জানান এতে কী সমস্যা রয়েছে" +"Temperature" = "তাপমাত্রা" +"Template" = "টেমপ্লেট" +"Templates" = "টেমপ্লেটসমূহ" +"Terms" = "শর্তাবলী" +"Text" = "টেক্সট" +"Text align" = "টেক্সট সারিবদ্ধকরণ" +"Text content" = "টেক্সট কন্টেন্ট" +"Text style" = "টেক্সট স্টাইল" +"Text styles" = "টেক্সট স্টাইলসমূহ" +"Texts" = "টেক্সটসমূহ" +"Texture" = "টেক্সচার" +"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "ক্যানভাস sRGB-তে রঙ প্রিভিউ করে; এক্সপোর্ট করা PNG, JPG এবং WebP ফাইলে Display P3 ট্যাগ করা থাকে।" +"The destination document is unavailable. Nothing was imported." = "গন্তব্য ডকুমেন্ট অনুপলব্ধ। কিছু ইম্পোর্ট করা হয়নি।" +"The destination document was closed. Nothing was imported." = "গন্তব্য ডকুমেন্ট বন্ধ করা হয়েছিল। কিছু ইম্পোর্ট করা হয়নি।" +"The destination page was removed. Nothing was imported." = "গন্তব্য পেজ সরানো হয়েছে। কিছু ইম্পোর্ট করা হয়নি।" +"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "ফাইলটি সার্ভার থেকে ডিলিট করা হয়েছে: কেউ আর সেখান থেকে এটি খুলতে বা যুক্ত হতে পারবে না, এবং এটি আর ফেরানো যাবে না। যে কপিগুলো খোলা আছে তা যার যার কাছে আছে তার সাথেই থাকবে।" +"The file now holds another library" = "ফাইলটিতে এখন অন্য একটি লাইব্রেরি রয়েছে" +"The file now holds another library (\"{name}\")" = "ফাইলটিতে এখন অন্য একটি লাইব্রেরি রয়েছে (\"{name}\")" +"The files you shared or published online from this computer. They stay on their server until you remove them." = "এই কম্পিউটার থেকে আপনি যে ফাইলগুলো অনলাইনে শেয়ার বা প্রকাশ করেছেন। আপনি না সরানো পর্যন্ত সেগুলো তাদের সার্ভারেই থাকে।" +"The link carries the session's secret: share it only with people you invite." = "লিংকটি সেশনের গোপনীয়তা বহন করে: শুধুমাত্র আপনার আমন্ত্রিত লোকদের সাথেই এটি শেয়ার করুন।" +"The name in your online files and on the server. The document inside keeps its own name." = "আপনার অনলাইন ফাইল এবং সার্ভারে থাকা নাম। ভেতরের ডকুমেন্টটি তার নিজস্ব নাম বজায় রাখে।" +"The selection uses several text styles: click to apply one" = "নির্বাচনে একাধিক টেক্সট স্টাইল ব্যবহার করা হয়েছে: একটি প্রয়োগ করতে ক্লিক করুন" +"The server deletes this file if nobody opens it before {date}." = "{date}-এর আগে কেউ না খুললে সার্ভার এই ফাইলটি ডিলিট করে দেবে।" +"The whole file" = "সম্পূর্ণ ফাইল" +"Theme" = "থিম" +"There's no destination for that action" = "সেই অ্যাকশনের জন্য কোনো গন্তব্য নেই" +"Thickness" = "পুরুত্ব" +"This branch can't be reviewed: {e}" = "এই ব্রাঞ্চটি পর্যালোচনা করা যাচ্ছে না: {e}" +"This branch was merged or archived." = "এই ব্রাঞ্চটি মার্জ বা আর্কাইভ করা হয়েছে।" +"This computer only" = "শুধুমাত্র এই কম্পিউটার" +"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "এই কম্পিউটার ওনার টোকেন মনে রাখে, তাই হোম স্ক্রিনের অনলাইন তালিকা থেকে ফাইলটি পরিচালনা করা যেতে পারে। অন্য কোথাও থেকে ফাইলটি পরিচালনা করতে এটি কপি করুন: এটি এই ফাইলের জন্য একটি পাসওয়ার্ড এবং আর কখনো দেখানো হবে না।" +"This condition is edited as an expression" = "এই শর্তটি এক্সপ্রেশন হিসেবে এডিট করা হয়েছে" +"This file" = "এই ফাইল" +"This file has unsaved changes" = "এই ফাইলে সেভ করা হয়নি এমন পরিবর্তন রয়েছে" +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "এই ফাইলে এমন ফন্ট ব্যবহার করা হয়েছে যা এই কম্পিউটারে ইনস্টল করা নেই। আপনি ইনস্টল না করা পর্যন্ত সেগুলোর টেক্সট একটি ফলব্যাক ফন্টে দেখানো হয়।" +"This file is on {server}, open only to you. Share it to let others in with a new code." = "এই ফাইলটি {server}-এ আছে, শুধুমাত্র আপনার জন্য খোলা। নতুন কোড দিয়ে অন্যদের প্রবেশ করাতে এটি শেয়ার করুন।" +"This frame is new on the branch." = "এই ফ্রেমটি ব্রাঞ্চে নতুন।" +"This frame isn't on the branch." = "এই ফ্রেমটি ব্রাঞ্চে নেই।" +"This server is open to the public: no key needed." = "এই সার্ভারটি সবার জন্য উন্মুক্ত: কোনো কী প্রয়োজন নেই।" +"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "এই সার্ভারের কী প্রয়োজন: ঠিকানায় ?key=… যোগ করুন (`figcraft-server key` এটি সার্ভারের মেশিনে প্রিন্ট করে)।" +"This server takes files only with its key: see Advanced." = "এই সার্ভার শুধুমাত্র এর কী সহ ফাইল নেয়: উন্নত (Advanced) দেখুন।" +"Tidy up" = "পরিপাটি করুন" +"Tile" = "টাইল" +"Tile type" = "টাইল টাইপ" +"Tint" = "টিন্ট" +"Title" = "শিরোনাম" +"Title case" = "টাইটেল কেস" +"To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "আপনার টিমের সাথে এই লাইব্রেরি শেয়ার করতে, ফাইলটি একটি সার্ভারে প্রকাশ করুন (Share ▸ Publish to server…), এরপর সেখানে লাইব্রেরি প্রকাশ করুন।" +"Toggle" = "টগল" +"Toggle light/dark theme" = "লাইট/ডার্ক থিম টগল করুন" +"Toggle visibility" = "দৃশ্যমানতা টগল করুন" +"Tools" = "টুলস" +"Top" = "উপরে" +"Top & bottom" = "উপরে ও নিচে" +"Top left radius" = "ওপরের বাম ব্যাসার্ধ" +"Top right radius" = "ওপরের ডান ব্যাসার্ধ" +"Transform" = "ট্রান্সফর্ম" +"Triangle arrow" = "ত্রিভুজ তীর" +"Trigger" = "ট্রিগার" +"True" = "সত্য (True)" +"Truncate text" = "টেক্সট ট্রাঙ্কেট করুন" +"Truncate with an ellipsis" = "উপবৃত্ত (ellipsis) দিয়ে ট্রাঙ্কেট করুন" +"Try again" = "আবার চেষ্টা করুন" +"Try another search or filter." = "অন্য অনুসন্ধান বা ফিল্টার ব্যবহার করে দেখুন।" +"Try another search, or browse templates." = "অন্য অনুসন্ধান ব্যবহার করুন, অথবা টেমপ্লেট ব্রাউজ করুন।" +"Try another word, or look at every kind." = "অন্য শব্দ ব্যবহার করুন, অথবা সব ধরণ দেখুন।" +"Turn a layer into a component, reuse it, and change every copy at once" = "একটি লেয়ারকে কম্পোনেন্টে রূপান্তর করুন, এটি পুনরায় ব্যবহার করুন এবং এক সাথে সব কপি পরিবর্তন করুন" +"Two digits" = "দুটি অঙ্ক" +"Type" = "ধরন (Type)" +"Type settings" = "টাইপ সেটিংস" +"Type the join code you were given, or paste an invite link." = "আপনাকে দেওয়া যুক্ত হওয়ার কোডটি টাইপ করুন, অথবা একটি আমন্ত্রণ লিংক পেস্ট করুন।" +"Typography" = "টাইপোগ্রাফি" +"Underline" = "আন্ডারলাইন" +"Undo takes back only your own changes; what others changed since stays." = "আনডু কেবল আপনার নিজস্ব পরিবর্তনগুলো ফিরিয়ে নেয়; এর মধ্যে অন্যরা যা পরিবর্তন করেছে তা থেকেই যায়।" +"Ungroup" = "আনগ্রুপ" +"Uniform" = "ইউনিফর্ম" +"Union" = "ইউনিয়ন" +"Unions" = "ইউনিয়নসমূহ" +"Unknown — treated as sRGB" = "অজানা — sRGB হিসেবে গণ্য করা হয়েছে" +"Unread" = "অপঠিত" +"Unresolve" = "অমীমাংসিত করুন" +"Unsaved changes" = "সংরক্ষিত হয়নি এমন পরিবর্তন" +"Unsaved changes in {names}. Close the window and lose them?" = "{names}-এ সংরক্ষিত হয়নি এমন পরিবর্তন রয়েছে। উইন্ডোটি বন্ধ করলে কি সেগুলো হারিয়ে যাবে?" +"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "সংরক্ষিত হয়নি এমন পরিবর্তন: প্রথমে সেভ করুন ({save}), অথবা বাতিল করতে {alt}-ক্লিক করে ট্যাব বন্ধ করুন" +"Unsaved changes: save first, or close again with {key} to discard" = "সংরক্ষিত হয়নি এমন পরিবর্তন: প্রথমে সেভ করুন, অথবা বাতিল করতে {key} দিয়ে আবার বন্ধ করুন" +"Up" = "উপরে" +"Update plugin" = "প্লাগইন আপডেট করুন" +"Update" = "আপডেট" +"Update from main" = "প্রধান (main) থেকে আপডেট করুন" +"Update from selection" = "নির্বাচন থেকে আপডেট করুন" +"Updated {components} and {styles}" = "{components} এবং {styles} আপডেট করা হয়েছে" +"Updated {name}" = "{name} আপডেট করা হয়েছে" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "গ্রাফিক্স ড্রাইভার আপডেট করা প্রায়ই সহায়ক হয়। শুধুমাত্র OpenGL ব্যবহার করতে, কমান্ড প্রম্পট থেকে FigCraft শুরু করুন:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "গ্রাফিক্স ড্রাইভার আপডেট করা প্রায়ই সহায়ক হয়। WGPU_BACKEND (vulkan, gl, metal) চেষ্টা করার জন্য অন্য একটি গ্রাফিক্স API এর নাম বলে।" +"Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "এই ফাইলটি একটি FigCraft সার্ভারে আপলোড করে এবং এই ট্যাবটিকে এর সাথে সিঙ্ক করে রাখে। আপনি এডিট করার জন্য একটি লিংক এবং দেখার জন্য একটি লিংক পান।" +"Uppercase" = "বড় হাতের অক্ষর" +"Use a fixed value" = "একটি নির্দিষ্ট মান ব্যবহার করুন" +"Use an expression, e.g. count + 1" = "একটি এক্সপ্রেশন ব্যবহার করুন, যেমন count + 1" +"Use as mask" = "মাস্ক হিসেবে ব্যবহার করুন" +"Use auto layout" = "অটো লেআউট ব্যবহার করুন" +"Use number keys for opacity" = "অস্বচ্ছতার জন্য নম্বর কি ব্যবহার করুন" +"Use the .figcraft or .fig extension." = ".figcraft বা .fig এক্সটেনশন ব্যবহার করুন।" +"Use the layer's fill" = "লেয়ারের ফিল ব্যবহার করুন" +"Used in {files} · {instances}" = "{files} · {instances}-এ ব্যবহৃত" +"Username" = "ব্যবহারকারী নাম" +"Value" = "মান" +"Uses variables: each layer shows them in its own mode" = "ভেরিয়েবল ব্যবহার করে: প্রতিটি লেয়ার তাদের নিজস্ব মোডে সেগুলো দেখায়" +"Variable" = "ভেরিয়েবল" +"Variable collections" = "ভেরিয়েবল সংগ্রহসমূহ" +"Variable modes" = "ভেরিয়েবল মোড" +"Variable width" = "পরিবর্তনশীল প্রস্থ (Variable width)" +"Variables" = "ভেরিয়েবলসমূহ" +"Variables JSON…" = "ভেরিয়েবল JSON..." +"Variables store reusable values (colours, numbers, strings, booleans) with modes such as Light and Dark." = "ভেরিয়েবলগুলো লাইট এবং ডার্কের মতো মোডের সাথে পুনঃব্যবহারযোগ্য মান (রঙ, নম্বর, স্ট্রিং, বুলিয়ান) সংরক্ষণ করে।" +"Variant" = "ভ্যারিয়েন্ট" +"Vector" = "ভেক্টর" +"Vectors" = "ভেক্টরসমূহ" +"Version" = "সংস্করণ" +"Version history" = "সংস্করণ ইতিহাস" +"Version {version}" = "সংস্করণ {version}" +"Version {v} · {components} · {styles} · {variables}" = "সংস্করণ {v} · {components} · {styles} · {variables}" +"Vertical" = "উল্লম্ব" +"Vertical align" = "উল্লম্ব সারিবদ্ধকরণ" +"Vertical hex" = "উল্লম্ব হেক্স" +"Vertical padding" = "উল্লম্ব প্যাডিং" +"Vertical spacing" = "উল্লম্ব ব্যবধান" +"Vertical trim" = "উল্লম্ব ট্রিম" +"View" = "ভিউ" +"View link" = "ভিউ লিংক" +"View link copied" = "ভিউ লিংক কপি করা হয়েছে" +"View only" = "শুধুমাত্র দেখার উপযোগী" +"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "শুধুমাত্র দেখার উপযোগী: আপনি প্রতিটি পরিবর্তন ঘটার সাথে সাথে দেখতে পান কিন্তু এডিট করতে পারেন না। আপনি এডিট করতে পারেন এমন একটি কপি রাখতে ত্যাগ করুন।" +"Viewing “{name}”" = "“{name}” দেখা হচ্ছে" +"Violet" = "বেগুনি" +"Visibility" = "দৃশ্যমানতা" +"Visible" = "দৃশ্যমান" +"Vivid" = "ভিভিড" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND সেট করা আছে এবং ব্যবহারের জন্য গ্রাফিক্স API-এর নাম দেয়; এটি আনসেট করলে FigCraft কে বেছে নিতে দেয়।" +"Warm" = "উষ্ণ" +"Watch" = "ওয়াচ" +"Wavy" = "ঢেউ খেলানো" +"Web" = "ওয়েব" +"Websites" = "ওয়েবসাইটসমূহ" +"WebSocket URL" = "WebSocket URL" +"WebSocket URL copied" = "WebSocket URL কপি করা হয়েছে" +"Wedge" = "ওয়েজ" +"Weight" = "ওজন" +"What you try here changes this preview and the code below, not the file" = "আপনি এখানে যা চেষ্টা করেন তা ফাইলটিকে নয়, কেবল এই প্রিভিউ এবং নিচের কোডটিকে পরিবর্তন করে" +"What changed since it was marked ready for dev" = "এটি ডেভের জন্য প্রস্তুত হিসেবে চিহ্নিত করার পর থেকে কী পরিবর্তিত হয়েছে" +"What is wrong with this file? The server's operator gets your report." = "এই ফাইলে কী সমস্যা রয়েছে? সার্ভারের অপারেটর আপনার রিপোর্ট পান।" +"What it's for" = "এটি কি জন্য" +"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "এটি কি থেকে পরিবর্তিত হয়েছে তা অজানা: FigCraft ডেভের জন্য প্রস্তুত ফ্রেমের একটি কপি রাখার আগেই এটি পরিবর্তিত হয়েছে। এখন থেকে তুলনা করতে পরিবর্তনটি সমাধান করুন (বা এটি আবার প্রস্তুত হিসেবে চিহ্নিত করুন)।" +"While hovering" = "হোভার করার সময়" +"While pressing" = "চেপে ধরে রাখার সময়" +"White" = "সাদা" +"Who runs it, its terms and how long it keeps files" = "এটি কে পরিচালনা করে, এর শর্তাবলী এবং এটি কতদিন ফাইল রাখে" +"Why it changed" = "কেন এটি পরিবর্তিত হয়েছে" +"Widget" = "উইজেট" +"Widgets" = "উইজেটসমূহ" +"Width" = "প্রস্থ" +"Width point" = "প্রস্থ বিন্দু" +"Width profile" = "প্রস্থ প্রোফাইল" +"Wireframe kit" = "ওয়্যারফ্রেম কিট" +"With the layers' export settings" = "লেয়ারের এক্সপোর্ট সেটিংস সহ" +"Wrap" = "র‍্যাপ" +"Wrap in new section" = "নতুন সেকশনে র‍্যাপ করুন" +"Yellow" = "হলুদ" +"Yesterday" = "গতকাল" +"You" = "আপনি" +"You'll get the shared file as a new tab and edit it together." = "আপনি একটি নতুন ট্যাব হিসেবে শেয়ার করা ফাইলটি পাবেন এবং একসাথে এডিট করবেন।" +"You're spotlighting: everyone follows your view" = "আপনি স্পটলাইট করছেন: সবাই আপনার ভিউ অনুসরণ করছে" +"Your copy of the file stays open" = "ফাইলের আপনার কপিটি খোলা থাকে" +"Your copy stays open; the file stays on the server" = "আপনার কপি খোলা থাকে; ফাইলটি সার্ভারেই থাকে" +"Your name" = "আপনার নাম" +"Your name in comments" = "মন্তব্যে আপনার নাম" +"Your system asks for reduced motion: animations stay off." = "আপনার সিস্টেম কম মোশনের অনুরোধ জানায়: অ্যানিমেশন বন্ধ থাকে।" +"Z" = "Z" +"Z to A" = "Z থেকে A" +"Zoom" = "জুম" +"Zoom around the pointer" = "পয়েন্টারের চারপাশে জুম করুন" +"Zoom device to fill window" = "উইন্ডো পূর্ণ করতে ডিভাইস জুম করুন" +"Zoom in" = "জুম ইন" +"Zoom out" = "জুম আউট" +"Zoom to 100%" = "১০০%-এ জুম করুন" +"Zoom to 200%" = "২০০%-এ জুম করুন" +"Zoom to 50%" = "৫০%-এ জুম করুন" +"Zoom to fit" = "ফিট করতে জুম করুন" +"Zoom to selection" = "নির্বাচনে জুম করুন" +"Zoom tool: click to zoom in, drag an area" = "জুম টুল: জুম ইন করতে ক্লিক করুন, একটি এলাকা ড্র্যাগ করুন" +"Zoom tool: zoom out" = "জুম টুল: জুম আউট" +"and {n} more" = { one = "এবং আরও {n}টি", other = "এবং আরও {n}টি" } +"bleed marks" = "ব্লিড মার্ক" +"by {author}" = "{author} দ্বারা" +"call any command its access allows" = "এর অ্যাক্সেস অনুমতি দেয় এমন যেকোনো কমান্ড কল করুন" +"call only: {commands}" = "শুধুমাত্র কল করুন: {commands}" +"can edit the document" = "ডকুমেন্ট এডিট করতে পারেন" +"conditional prototype actions nested too deeply were skipped" = "শর্তসাপেক্ষ প্রোটোটাইপ অ্যাকশনগুলো খুব গভীরভাবে নেস্ট করার কারণে বাদ দেওয়া হয়েছে" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "মূল্যায়ন করা যায় না এমন শর্তযুক্ত প্রোটোটাইপ অ্যাকশনগুলো বাদ দেওয়া হয়েছে" +"connector label background was skipped: style data is missing" = "কানেক্টর লেবেলের ব্যাকগ্রাউন্ড বাদ দেওয়া হয়েছে: স্টাইল ডেটা অনুপস্থিত" +"connector label was skipped: background bounds are invalid" = "কানেক্টর লেবেল বাদ দেওয়া হয়েছে: ব্যাকগ্রাউন্ড বাউন্ড অবৈধ" +"connector label was skipped: background transform is invalid" = "কানেক্টর লেবেল বাদ দেওয়া হয়েছে: ব্যাকগ্রাউন্ড ট্রান্সফর্ম অবৈধ" +"connector label was skipped: composed text transform is invalid" = "কানেক্টর লেবেল বাদ দেওয়া হয়েছে: কম্পোজড টেক্সট ট্রান্সফর্ম অবৈধ" +"connector label was skipped: derived background bounds are missing" = "কানেক্টর লেবেল বাদ দেওয়া হয়েছে: ডিরাইভড ব্যাকগ্রাউন্ড বাউন্ড অনুপস্থিত" +"connector label was skipped: derived text bounds are missing" = "কানেক্টর লেবেল বাদ দেওয়া হয়েছে: ডিরাইভড টেক্সট বাউন্ড অনুপস্থিত" +"connector label was skipped: text bounds are invalid" = "কানেক্টর লেবেল বাদ দেওয়া হয়েছে: টেক্সট বাউন্ড অবৈধ" +"connector label was skipped: text style data is missing" = "কানেক্টর লেবেল বাদ দেওয়া হয়েছে: টেক্সট স্টাইল ডেটা অনুপস্থিত" +"connector label was skipped: text transform is invalid" = "কানেক্টর লেবেল বাদ দেওয়া হয়েছে: টেক্সট ট্রান্সফর্ম অবৈধ" +"connector labels were skipped" = "কানেক্টর লেবেলগুলো বাদ দেওয়া হয়েছে" +"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "কানেক্টর লেবেল উপস্থিত থাকলে স্ট্যাটিক ফ্রেম/ভেক্টর স্ন্যাপশট হিসেবে ইম্পোর্ট হয়; লাইভ এন্ডপয়েন্ট বাইন্ডিং রাখা হয় না" +"connector snapshot vector data is missing; connector was left empty" = "কানেক্টর স্ন্যাপশট ভেক্টর ডেটা অনুপস্থিত; কানেক্টর খালি রাখা হয়েছে" +"connectors whose ends can't be placed are empty" = "যে কানেক্টরের প্রান্তগুলো রাখা যায় না সেগুলো খালি" +"crop marks" = "ক্রপ মার্ক" +"damaged vector geometry was skipped" = "ক্ষতিগ্রস্ত ভেক্টর জিওমেট্রি বাদ দেওয়া হয়েছে" +"damaged vector networks import as outlines" = "ক্ষতিগ্রস্ত ভেক্টর নেটওয়ার্ক আউটলাইন হিসেবে ইম্পোর্ট হয়" +"development: {path}" = "ডেভেলপমেন্ট: {path}" +"document children other than pages were skipped" = "পেজ ছাড়া ডকুমেন্টের অন্যান্য চিলড্রেন বাদ দেওয়া হয়েছে" +"duplicate grid track sizing entries use equal tracks" = "ডুপ্লিকেট গ্রিড ট্র্যাক সাইজিং এন্ট্রিগুলো সমান ট্র্যাক ব্যবহার করে" +"e.g. count + 1" = "যেমন count + 1" +"e.g. count >= 3 and not done" = "যেমন count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "সংরক্ষিত পাথ ছাড়া এলবো এবং কার্ভড কানেক্টর সরল রেখা হিসেবে ইম্পোর্ট হয়" +"entries without a node type were skipped" = "নোটের ধরন ছাড়া এন্ট্রিগুলো বাদ দেওয়া হয়েছে" +"figcraft:// links from a browser, chat or email open this app and join the session." = "ব্রাউজার, চ্যাট বা ইমেল থেকে figcraft:// লিংকগুলো এই অ্যাপ খুলে সেশনে যুক্ত হয়।" +"filled areas of a vector with their own fill use the layer's fill" = "ভেক্টরের নিজস্ব ফিলযুক্ত পূর্ণ এলাকাগুলো লেয়ারের ফিল ব্যবহার করে" +"from {server}" = "{server} থেকে" +"gamepad triggers without exactly one known controller button were skipped" = "সঠিকভাবে একটি পরিচিত কন্ট্রোলার বোতাম ছাড়া গেমপ্যাড ট্রিগার বাদ দেওয়া হয়েছে" +"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "গ্রিড ট্র্যাক তালিকা ১০০০ এন্ট্রি অতিক্রম করেছে; ট্র্যাকগুলো ডিফল্ট সমান সাইজিং ব্যবহার করে" +"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "গ্রিড ট্র্যাক সাইজিং অসমর্থিত বা ত্রুটিপূর্ণ; প্রভাবিত ট্র্যাকগুলো সমান সাইজিং ব্যবহার করে" +"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "গ্রিড ট্র্যাক সাইজিং তালিকা ১০০০ এন্ট্রি অতিক্রম করেছে; ট্র্যাকগুলো ডিফল্ট সমান সাইজিং ব্যবহার করে" +"hidden" = "লুকানো" +"iOS" = "iOS" +"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "ছবির ফিল্টারগুলো দ্ব্যর্থক কারণ উভয় ফিল্টার কন্টেইনার উপস্থিত রয়েছে; ফিল্টার রূপান্তর বাদ দেওয়া হয়েছে" +"image filters use FigCraft's approximate renderer" = "ছবির ফিল্টারগুলো FigCraft-এর অনুমানমূলক রেন্ডারার ব্যবহার করে" +"instance swap properties whose default component isn't in the file were skipped" = "ইনস্ট্যান্স সোয়াপ প্রপার্টি যার ডিফল্ট কম্পোনেন্ট ফাইলে নেই তা বাদ দেওয়া হয়েছে" +"instances inside their own main component were imported as frames" = "নিজের প্রধান কম্পোনেন্টের ভেতরের ইনস্ট্যান্সগুলো ফ্রেম হিসেবে ইম্পোর্ট করা হয়েছে" +"instances of components that aren't in the file show no layers" = "ফাইলে নেই এমন কম্পোনেন্টের ইনস্ট্যান্স কোনো লেয়ার দেখায় না" +"instances of components that aren't in the file were imported as frames" = "ফাইলে নেই এমন কম্পোনেন্টের ইনস্ট্যান্স ফ্রেম হিসেবে ইম্পোর্ট করা হয়েছে" +"instances that would nest a component inside itself were imported as frames" = "যে ইনস্ট্যান্সগুলো কোনো কম্পোনেন্টকে নিজের ভেতরে নেস্ট করত সেগুলোকে ফ্রেম হিসেবে ইম্পোর্ট করা হয়েছে" +"instances without a main component were imported as empty frames" = "প্রধান কম্পোনেন্ট ছাড়া ইনস্ট্যান্সগুলো খালি ফ্রেম হিসেবে ইম্পোর্ট করা হয়েছে" +"invalid internal .fig text path data was ignored" = "অবৈধ অভ্যন্তরীণ .fig টেক্সট পাথ ডেটা উপেক্ষা করা হয়েছে" +"invalid or oversized vector networks import as outlines" = "অবৈধ বা অতিরিক্ত বড় ভেক্টর নেটওয়ার্ক আউটলাইন হিসেবে ইম্পোর্ট হয়" +"invalid or oversized vector path data was skipped" = "অবৈধ বা অতিরিক্ত বড় ভেক্টর পাথ ডেটা বাদ দেওয়া হয়েছে" +"just now" = "এইমাত্র" +"key triggers without a supported key were skipped" = "সমর্থিত কি ছাড়া কি ট্রিগার বাদ দেওয়া হয়েছে" +"key up triggers import as key presses" = "কি আপ ট্রিগার কি প্রেস হিসেবে ইম্পোর্ট হয়" +"layers without a position were placed at their parent's origin" = "অবস্থান ছাড়া লেয়ারগুলো তাদের প্যারেন্টের মূল অবস্থানে রাখা হয়েছে" +"layers without a type were skipped" = "ধরন ছাড়া লেয়ারগুলো বাদ দেওয়া হয়েছে" +"legacy image filter container is unsupported; filter conversion was skipped" = "লিগ্যাসি ইমেজ ফিল্টার কন্টেইনার অসমর্থিত; ফিল্টার রূপান্তর বাদ দেওয়া হয়েছে" +"locked" = "লক করা" +"malformed image filters container was skipped" = "ত্রুটিপূর্ণ ইমেজ ফিল্টার কন্টেইনার বাদ দেওয়া হয়েছে" +"malformed image paintFilter container was skipped" = "ত্রুটিপূর্ণ ইমেজ paintFilter কন্টেইনার বাদ দেওয়া হয়েছে" +"malformed slot content assignment was skipped" = "ত্রুটিপূর্ণ স্লট কন্টেন্ট অ্যাসাইনমেন্ট বাদ দেওয়া হয়েছে" +"no bleed" = "কোনো ব্লিড নেই" +"nobody else here yet" = "এখানে আর কেউ নেই" +"non-default slot settings are not represented and were skipped" = "অ-ডিফল্ট স্লট সেটিংস উপস্থাপিত হয়নি এবং বাদ দেওয়া হয়েছে" +"now" = "এখন" +"only in a text style or variable" = "শুধুমাত্র টেক্সট স্টাইল বা ভেরিয়েবলে" +"or" = "অথবা" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "পয়েন্ট স্ট্রোক জয়েন্ট সংরক্ষিত হয়েছে; ড্যাশড বা পরিবর্তনশীল প্রস্থের স্ট্রোক লেয়ার জয়েন্ট দিয়ে রেন্ডার হয়" +"preferred slot component keys cannot be resolved to file nodes and were skipped" = "পছন্দসই স্লট কম্পোনেন্ট কি ফাইল নোডে সমাধান করা যায়নি এবং বাদ দেওয়া হয়েছে" +"prototype actions of unknown types were skipped" = "অজানা ধরনের প্রোটোটাইপ অ্যাকশন বাদ দেওয়া হয়েছে" +"prototype actions without a destination in the file were skipped" = "ফাইলে গন্তব্য নেই এমন প্রোটোটাইপ অ্যাকশন বাদ দেওয়া হয়েছে" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "ভেরিয়েবল JSON-এ নেই এমন ভেরিয়েবলের উপর প্রোটোটাইপ এক্সপ্রেশন বাদ দেওয়া হয়েছে" +"prototype expressions with unsupported functions were skipped" = "অসমর্থিত ফাংশনযুক্ত প্রোটোটাইপ এক্সপ্রেশন বাদ দেওয়া হয়েছে" +"prototype interactions without supported actions were skipped" = "সমর্থিত অ্যাকশন ছাড়া প্রোটোটাইপ ইন্টারঅ্যাকশন বাদ দেওয়া হয়েছে" +"prototype navigations of unknown types were skipped" = "অজানা ধরনের প্রোটোটাইপ নেভিগেশন বাদ দেওয়া হয়েছে" +"prototype transitions of unknown types play instantly" = "অজানা ধরনের প্রোটোটাইপ ট্রানজিশন তাৎক্ষণিকভাবে চলে" +"prototype triggers of unknown types were skipped" = "অজানা ধরনের প্রোটোটাইপ ট্রিগার বাদ দেওয়া হয়েছে" +"put a widget on the canvas" = "ক্যানভাসে একটি উইজেট রাখুন" +"read and edit the document" = "ডকুমেন্ট পড়ুন এবং এডিট করুন" +"read the document" = "ডকুমেন্ট পড়ুন" +"reads the document" = "ডকুমেন্ট পড়ে" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "ভেরিয়েবল JSON-এ নেই এমন ভেরিয়েবলের উপর set-variable অ্যাকশন বাদ দেওয়া হয়েছে" +"set-variable actions with values that can't be evaluated were skipped" = "মূল্যায়ন করা যায় না এমন মানযুক্ত set-variable অ্যাকশন বাদ দেওয়া হয়েছে" +"set-variable actions with values that don't fit the variable were skipped" = "ভেরিয়েবলের সাথে মেলে না এমন মানযুক্ত set-variable অ্যাকশন বাদ দেওয়া হয়েছে" +"set-variable actions without a value were skipped" = "মান ছাড়া set-variable অ্যাকশন বাদ দেওয়া হয়েছে" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "ভেরিয়েবল JSON-এ নেই এমন সংগ্রহ বা মোডের উপর set-variable-mode অ্যাকশন বাদ দেওয়া হয়েছে" +"slot content assignment target is missing or is not slot content" = "স্লট কন্টেন্ট অ্যাসাইনমেন্ট লক্ষ্য অনুপস্থিত অথবা স্লট কন্টেন্ট নয়" +"slot content assignment uses the empty GUID sentinel and was skipped" = "স্লট কন্টেন্ট অ্যাসাইনমেন্ট খালি GUID সেন্টিমেল ব্যবহার করে এবং বাদ দেওয়া হয়েছে" +"slot layers without a matching slot property were imported as frames" = "মিল রয়েছে এমন স্লট প্রপার্টি ছাড়া স্লট লেয়ারগুলো ফ্রেম হিসেবে ইম্পোর্ট করা হয়েছে" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "কিছু ভেক্টর পয়েন্ট ফাইল থেকে অনুপস্থিত পয়েন্ট সেটিংস উল্লেখ করে; সেগুলো লেয়ারের জয়েন্ট এবং সাধারণ কোণা ব্যবহার করে" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "কিছু ভেক্টর সেগমেন্ট ফাইল থেকে অনুপস্থিত স্টাইল সেটিংস উল্লেখ করে; সেগুলো লেয়ারের স্ট্রোক ব্যবহার করে" +"stale or invalid grid track sizing entries were ignored" = "পুরোনো বা অবৈধ গ্রিড ট্র্যাক সাইজিং এন্ট্রি উপেক্ষা করা হয়েছে" +"stars and polygons without point counts or geometry use the default point count" = "পয়েন্ট সংখ্যা বা জিওমেট্রি ছাড়া তারকা এবং বহুভুজ ডিফল্ট পয়েন্ট সংখ্যা ব্যবহার করে" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "কিছু ভেক্টর সেগমেন্টের স্টাইল সেটিংস সমর্থিত নয়; সেই সেগমেন্টগুলো লেয়ারের স্ট্রোক ব্যবহার করে" +"text path underline or strikethrough decoration is not rendered" = "টেক্সট পাথ আন্ডারলাইন বা স্ট্রাইকথ্রু সজ্জা রেন্ডার হয় না" +"text style overrides refer to missing table entries" = "টেক্সট স্টাইল ওভাররাইড অনুপস্থিত টেবিল এন্ট্রি নির্দেশ করে" +"the file has no pages" = "ফাইলে কোনো পেজ নেই" +"the plugin" = "প্লাগইনটি" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "ভেরিয়েবল JSON-এ কোনো `variableCollections` নেই (স্থানীয় ভেরিয়েবল প্রতিক্রিয়া প্রত্যাশিত ছিল) এবং উপেক্ষা করা হয়েছে" +"this session only" = "শুধুমাত্র এই সেশন" +"unknown component property types were skipped" = "অজানা কম্পোনেন্ট প্রপার্টি ধরন বাদ দেওয়া হয়েছে" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "ভেরিয়েবল JSON-এ নেই (বা অন্য ধরনের) এমন ভেরিয়েবলের ভেরিয়েবল অ্যালিয়াস বাদ দেওয়া হয়েছে" +"variable bindings to properties the layer doesn't have were skipped" = "লেয়ারের নেই এমন প্রপার্টির ভেরিয়েবল বাইন্ডিং বাদ দেওয়া হয়েছে" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "ভেরিয়েবল JSON-এ নেই (লাইব্রেরি ভেরিয়েবল) এমন ভেরিয়েবলের ভেরিয়েবল বাইন্ডিং বাদ দেওয়া হয়েছে" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "ভেরিয়েবল বাইন্ডিং বাদ দেওয়া হয়েছে: ফাইল JSON-এ কোনো ভেরিয়েবল সংজ্ঞা নেই (স্থানীয় ভেরিয়েবল JSON কে `variables` হিসেবে পাস করুন, অথবা ফাইল JSON এর পাশে এর `meta` এমবেড করুন)" +"variable bindings whose variable type doesn't fit the property were skipped" = "প্রপার্টির সাথে মানানসই নয় এমন ভেরিয়েবল ধরনের ভেরিয়েবল বাইন্ডিং বাদ দেওয়া হয়েছে" +"variable collections without modes were skipped" = "মোড ছাড়া ভেরিয়েবল সংগ্রহ বাদ দেওয়া হয়েছে" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "প্রোটোটাইপ এক্সপ্রেশনের ভেরিয়েবল মোড লুকআপ যা কোনো ভেরিয়েবল এবং এর একটি মোড নয় তা বাদ দেওয়া হয়েছে" +"variable modes of collections that aren't in the file were skipped" = "ফাইলে নেই এমন সংগ্রহের ভেরিয়েবল মোড বাদ দেওয়া হয়েছে" +"variable modes of collections that aren't in the variables JSON were skipped" = "ভেরিয়েবল JSON-এ নেই এমন সংগ্রহের ভেরিয়েবল মোড বাদ দেওয়া হয়েছে" +"variable values that don't match their type (or composed colours) were skipped" = "তাদের ধরনের সাথে মেলে না এমন ভেরিয়েবল মান বাদ দেওয়া হয়েছে" +"variables of unknown types were skipped" = "অজানা ধরনের ভেরিয়েবল বাদ দেওয়া হয়েছে" +"variables whose collection isn't in the variables JSON were skipped" = "সংগ্রহ ভেরিয়েবল JSON-এ নেই এমন ভেরিয়েবল বাদ দেওয়া হয়েছে" +"variables without readable values were skipped" = "পড়ার উপযোগী মান নেই এমন ভেরিয়েবল বাদ দেওয়া হয়েছে" +"vector points with unknown stroke joins use the layer join" = "অজানা স্ট্রোক জয়েন্টযুক্ত ভেক্টর পয়েন্ট লেয়ার জয়েন্ট ব্যবহার করে" +"vectors without geometry are empty" = "জিওমেট্রি ছাড়া ভেক্টর খালি" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "জিওমেট্রি ছাড়া ভেক্টর খালি (geometry=paths সহ ফাইল JSON এক্সপোর্ট করুন)" +"very long text was cut to 1,048,576 characters" = "খুব দীর্ঘ টেক্সট কেটে ১,০৪৮,৫৭৬ অক্ষরে ছোট করা হয়েছে" +"very long vector paths were skipped" = "খুব দীর্ঘ ভেক্টর পাথ বাদ দেওয়া হয়েছে" +"video actions are not supported and were skipped" = "ভিডিও অ্যাকশন সমর্থিত নয় এবং বাদ দেওয়া হয়েছে" +"video triggers are not supported and were skipped" = "ভিডিও ট্রিগার সমর্থিত নয় এবং বাদ দেওয়া হয়েছে" +"yesterday" = "গতকাল" +"{instances} in {files}" = "{files}-এ {instances}টি ইনস্ট্যান্স" +"{language}: draft translation, waiting for a native speaker's review." = "{language}: ড্রাফট অনুবাদ, স্থানীয় ভাষাভাষীর পর্যালোচনার অপেক্ষায়।" +"{length} bleed" = "{length} ব্লিড" +"{month} {day}, {year}" = "{day} {month}, {year}" +"{name} cleared from the list" = "তালিকা থেকে {name} মুছে ফেলা হয়েছে" +"{name} is forgotten on this computer; the file stays on {server}" = "এই কম্পিউটার থেকে {name} মুছে ফেলা হয়েছে; ফাইলটি {server}-এ রয়ে গেছে" +"{name} is on the server." = "{name} সার্ভারে আছে।" +"{name} is spotlighting" = "{name} স্পটলাইট করছে" +"{name}\nClick to apply to the selection" = "{name}\nনির্বাচনে প্রয়োগ করতে ক্লিক করুন" +"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nক্যানভাসে ড্র্যাগ করুন, অথবা ভিউর মাঝখানে রাখতে ক্লিক করুন।\nকোনো ইনস্ট্যান্সে ড্রপ করার সময় সোয়াপ করতে Alt (Option) চেপে ধরে রাখুন।" +"{name} (from {library})" = "{name} ({library} থেকে)" +"{name} (you)" = "{name} (আপনি)" +"{name} ({layers} layers)" = "{name} ({layers}টি লেয়ার)" +"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name} ({tag}): {min} – {max}, ডিফল্ট {default}. স্টাইলের মানে ফিরে যেতে স্লাইডারে ডাবল-ক্লিক করুন; ড্র্যাগ করার সময় Escape টিপলে বাতিল হবে।" +"{name} from {path}" = "{path} থেকে {name}" +"{name} was removed from the server" = "সার্ভার থেকে {name} সরানো হয়েছে" +"{name} · deleted on main" = "{name} · প্রধান (main)-এ ডিলিট করা হয়েছে" +"{name} · deleted on the branch" = "{name} · ব্রাঞ্চে ডিলিট করা হয়েছে" +"{name} · not published" = "{name} · প্রকাশিত নয়" +"{name} · on a server" = "{name} · একটি সার্ভারে" +"{name} · published version {v}" = "{name} · প্রকাশিত সংস্করণ {v}" +"{name} · version {v} on the server" = "{name} · সার্ভারে সংস্করণ {v}" +"{name}, in a live session" = "{name}, লাইভ সেশনে" +"{name}, online, open only to you" = "{name}, অনলাইনে, শুধুমাত্র আপনার জন্য খোলা" +"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, নির্বাচিত" +"{n} added" = { one = "{n}টি যোগ করা হয়েছে", other = "{n}টি যোগ করা হয়েছে" } +"{n} autosave versions" = { one = "{n}টি স্বয়ংক্রিয় সেভ সংস্করণ", other = "{n}টি স্বয়ংক্রিয় সেভ সংস্করণ" } +"{n} changed" = { one = "{n}টি পরিবর্তিত হয়েছে", other = "{n}টি পরিবর্তিত হয়েছে" } +"{n} columns" = { one = "{n}টি কলাম", other = "{n}টি কলাম" } +"{n} components" = { one = "{n}টি কম্পোনেন্ট", other = "{n}টি কম্পোনেন্ট" } +"{n} components and styles have newer versions" = { one = "{n}টি কম্পোনেন্ট বা স্টাইলের নতুন সংস্করণ রয়েছে", other = "{n}টি কম্পোনেন্ট এবং স্টাইলের নতুন সংস্করণ রয়েছে" } +"{n} conflicts to resolve" = { one = "{n}টি দ্বন্দ্ব সমাধান করতে হবে", other = "{n}টি দ্বন্দ্ব সমাধান করতে হবে" } +"{n} days ago" = { one = "{n} দিন আগে", other = "{n} দিন আগে" } +"{n} files" = { one = "{n}টি ফাইল", other = "{n}টি ফাইল" } +"{n} hours ago" = { one = "{n} ঘণ্টা আগে", other = "{n} ঘণ্টা আগে" } +"{n} instances" = { one = "{n}টি ইনস্ট্যান্স", other = "{n}টি ইনস্ট্যান্স" } +"{n} layers" = { one = "{n}টি লেয়ার", other = "{n}টি লেয়ার" } +"{n} layers as one file" = { one = "{n}টি লেয়ার একটি ফাইল হিসেবে", other = "{n}টি লেয়ার একটি ফাইল হিসেবে" } +"{n} layers selected" = { one = "{n}টি লেয়ার নির্বাচিত", other = "{n}টি লেয়ার নির্বাচিত" } +"{n} layers · {parts}" = { one = "{n}টি লেয়ার · {parts}", other = "{n}টি লেয়ার · {parts}" } +"{n} matches" = { one = "{n}টি মিল", other = "{n}টি মিল" } +"{n} minutes ago" = { one = "{n} মিনিট আগে", other = "{n} মিনিট আগে" } +"{n} months ago" = { one = "{n} মাস আগে", other = "{n} মাস আগে" } +"{n} more notes omitted from this report" = { one = "এই রিপোর্ট থেকে আরও {n}টি নোট বাদ দেওয়া হয়েছে", other = "এই রিপোর্ট থেকে আরও {n}টি নোট বাদ দেওয়া হয়েছে" } +"{n} of {total}" = "{total}-এর মধ্যে {n}" +"{n} people joined" = { one = "{n} জন যুক্ত হয়েছেন", other = "{n} জন যুক্ত হয়েছেন" } +"{n} preferred components" = { one = "{n}টি পছন্দের কম্পোনেন্ট", other = "{n}টি পছন্দের কম্পোনেন্ট" } +"{n} ready" = { one = "{n}টি প্রস্তুত", other = "{n}টি প্রস্তুত" } +"{n} removed" = { one = "{n}টি সরানো হয়েছে", other = "{n}টি সরানো হয়েছে" } +"{n} replies" = { one = "{n}টি উত্তর", other = "{n}টি উত্তর" } +"{n} rows" = { one = "{n}টি সারি", other = "{n}টি সারি" } +"{n} styles" = { one = "{n}টি স্টাইল", other = "{n}টি স্টাইল" } +"{n} text layers" = { one = "{n}টি টেক্সট লেয়ার", other = "{n}টি টেক্সট লেয়ার" } +"{n} variables" = { one = "{n}টি ভেরিয়েবল", other = "{n}টি ভেরিয়েবল" } +"{n} weeks ago" = { one = "{n} সপ্তাহ আগে", other = "{n} সপ্তাহ আগে" } +"{n} years ago" = { one = "{n} বছর আগে", other = "{n} বছর আগে" } +"{n}d" = "{n}d" +"{n}h" = "{n}h" +"{n}m" = "{n}m" +"{n}w" = "{n}w" +"{page}, nothing selected" = "{page}, কিছু নির্বাচিত নেই" +"{profile} (current)" = "{profile} (বর্তমান)" +"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} হলো এর মান; {property ? \"a\" : \"b\"} একটি স্ট্রিং বাছে। অন্যান্য {…} যেমন লেখা আছে তেমনই থাকে।" +"{server} can't say what became of your files (it is older than this app)." = "{server} বলতে পারছে না আপনার ফাইলের কী হয়েছে (এটি এই অ্যাপের চেয়ে পুরনো)।" +"{server} · No longer on the server" = "{server} · আর সার্ভারে নেই" +"{server} · {shared} · Opened {date}" = "{server} · {shared} · খোলা হয়েছে {date}" +"{tool} hold {key}" = "{tool} চেপে ধরে রাখুন {key}" +"{value} isn't a colour" = "{value} কোনো রঙ নয়" +"{when} · edited" = "{when} · এডিট করা হয়েছে" +"← ↑ → ↓" = "← ↑ → ↓" +"← → ↑ ↓" = "← → ↑ ↓" +"←" = "←" +"→, Space or N" = "→, স্পেস বা N" +"↵" = "↵" +"⇥ / ⇧⇥" = "⇥ / ⇧⇥" +"⌘ arrows" = "⌘ অ্যারো" +"⌘ click" = "⌘ ক্লিক" +"⌘ scroll" = "⌘ স্ক্রল" +"⌥ drag" = "⌥ ড্র্যাগ" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 980513149..eea0e8dcb 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -310,6 +310,7 @@ status = "draft — needs native review" "Corner radius" = "Poloměr rohu" "Corner smoothing" = "Vyhlazení rohů" "Couldn't reach {server}: showing what this computer remembers." = "Server {server} není dostupný: zobrazuje se stav uložený v tomto počítači." +"Couldn't read {name}." = "Soubor {name} se nepodařilo přečíst." "Count" = "Počet" "Create" = "Vytvořit" "Create a variable collection first" = "Nejprve vytvořte kolekci proměnných" @@ -929,6 +930,7 @@ status = "draft — needs native review" "PDF print settings" = "Nastavení tisku PDF" "Padding" = "Vnitřní odsazení" "Page" = "Stránka" +"Page deleted" = "Stránka smazána" "Pages" = "Stránky" "Pages added to the document" = "Stránky přidány do dokumentu" "Placed on the current page" = "Umístěno na aktuální stránku" @@ -1629,6 +1631,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "Zapomenuto v tomto počítači: {name}; soubor zůstává na serveru {server}" "{name} is on the server." = "{name} je na serveru." "{name} is spotlighting" = "{name} sdílí pohled" +"{name} is too large to open in the browser (over 512 MB)." = "Soubor {name} je příliš velký na otevření v prohlížeči (více než 512 MB)." "{name}\nClick to apply to the selection" = "{name}\nKliknutím použijete na výběr" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nPřetáhněte na plátno nebo kliknutím umístěte doprostřed zobrazení.\nPři puštění na instanci podržte Alt (Option) a vyměňte ji." "{name} (from {library})" = "{name} (z {library})" @@ -1849,6 +1852,7 @@ status = "draft — needs native review" "file.exportLayers" = "Exportovat vrstvy" "file.exportSvg" = "Exportovat výběr jako SVG" "file.import" = "Importovat" +"file.moveTab" = "Přesunout kartu" "file.new" = "Nový soubor návrhu" "file.newFromTemplate" = "Nový ze šablony" "file.open" = "Otevřít" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 07669c1a7..f61464b8f 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -306,6 +306,7 @@ status = "draft — needs native review" "Corner smoothing" = "Eckenglättung" "Couldn't download the variables" = "Die Variablen konnten nicht heruntergeladen werden" "Couldn't reach {server}: showing what this computer remembers." = "{server} war nicht erreichbar: Es wird angezeigt, was dieser Computer gespeichert hat." +"Couldn't read {name}." = "{name} konnte nicht gelesen werden." "Couldn't save automatically: {error}" = "Automatisches Speichern fehlgeschlagen: {error}" "Count" = "Anzahl" "Create" = "Erstellen" @@ -898,6 +899,7 @@ status = "draft — needs native review" "PDF print settings" = "PDF-Druckeinstellungen" "Padding" = "Innenabstand" "Page" = "Seite" +"Page deleted" = "Seite gelöscht" "Pages" = "Seiten" "Pages added to the document" = "Seiten zum Dokument hinzugefügt" "Placed on the current page" = "Auf der aktuellen Seite platziert" @@ -1224,6 +1226,7 @@ status = "draft — needs native review" "Start ascending from / stop descending at" = "Aufwärts zählen ab / abwärts zählen bis" "Start from a blank canvas" = "Mit einer leeren Arbeitsfläche beginnen" "Start on path" = "Anfang auf dem Pfad" +"Start on the Home screen" = "Mit Start beginnen" "Start point" = "Startpunkt" "Start sharing" = "Teilen starten" "Starting to share…" = "Teilen wird gestartet…" @@ -1583,6 +1586,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "{name} wurde auf diesem Computer vergessen; die Datei bleibt auf {server}" "{name} is on the server." = "{name} liegt auf dem Server." "{name} is spotlighting" = "{name} hat das Spotlight" +"{name} is too large to open in the browser (over 512 MB)." = "{name} ist zu groß, um im Browser geöffnet zu werden (über 512 MB)." "{name} was removed from the server" = "{name} wurde vom Server entfernt" "{name} · deleted on main" = "{name} · in der Hauptdatei gelöscht" "{name} · deleted on the branch" = "{name} · im Branch gelöscht" @@ -1795,6 +1799,7 @@ status = "draft — needs native review" "file.exportLayers" = "Ebenen exportieren" "file.exportSvg" = "Auswahl als SVG exportieren" "file.import" = "Importieren" +"file.moveTab" = "Tab verschieben" "file.new" = "Neue Designdatei" "file.open" = "Öffnen" "file.openRecent" = "Zuletzt verwendete öffnen" @@ -1932,6 +1937,7 @@ status = "draft — needs native review" "pref.reduceMotion" = "Bewegung reduzieren" "pref.renameDuplicates" = "Duplizierte Ebenen umbenennen" "pref.showDimensions" = "Abmessungen an Objekten anzeigen" +"pref.startOnHome" = "Mit Start beginnen" "pref.userName" = "Ihr Name in Kommentaren" "proto.action.add" = "Prototyp-Aktion hinzufügen" "proto.action.move" = "Prototyp-Aktion neu anordnen" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index c015e1b54..ae566fc81 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -172,6 +172,7 @@ name = "English" "Both directions" = "Both directions" "Bottom" = "Bottom" "Bottom left radius" = "Bottom left radius" +"Bottom padding" = "Bottom padding" "Bottom right radius" = "Bottom right radius" "Branch" = "Branch" "Branch archived" = "Branch archived" @@ -319,6 +320,7 @@ name = "English" "Corner smoothing" = "Corner smoothing" "Couldn't download the variables" = "Couldn't download the variables" "Couldn't reach {server}: showing what this computer remembers." = "Couldn't reach {server}: showing what this computer remembers." +"Couldn't read {name}." = "Couldn't read {name}." "Couldn't save automatically: {error}" = "Couldn't save automatically: {error}" "Count" = "Count" "Create" = "Create" @@ -632,6 +634,7 @@ name = "English" "Included" = "Included" "Included in layout" = "Included in layout" "Individual corners" = "Individual corners" +"Individual padding" = "Individual padding" "Individual strokes" = "Individual strokes" "Ink" = "Ink" "Inner shadow" = "Inner shadow" @@ -653,6 +656,7 @@ name = "English" "Interaction" = "Interaction" "Interactions" = "Interactions" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." +"Interface size" = "Interface size" "Intersect" = "Intersect" "Intersects" = "Intersects" "Invalid color" = "Invalid color" @@ -702,6 +706,7 @@ name = "English" "Leaves" = "Leaves" "Left" = "Left" "Left & right" = "Left & right" +"Left padding" = "Left padding" "Left side" = "Left side" "Left side width" = "Left side width" "Letter case" = "Letter case" @@ -950,6 +955,7 @@ name = "English" "PDF print settings" = "PDF print settings" "Padding" = "Padding" "Page" = "Page" +"Page deleted" = "Page deleted" "Pages" = "Pages" "Pages added to the document" = "Pages added to the document" "Placed on the current page" = "Placed on the current page" @@ -1061,6 +1067,7 @@ name = "English" "Reduce motion" = "Reduce motion" "Refraction" = "Refraction" "Regular expression" = "Regular expression" +"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." "Remove" = "Remove" "Remove all" = "Remove all" "Remove annotation" = "Remove annotation" @@ -1099,6 +1106,7 @@ name = "English" "Rename on the server…" = "Rename on the server…" "Rename page" = "Rename page" "Rename to" = "Rename to" +"Reorder selection" = "Reorder selection" "Replace all" = "Replace all" "Replace mixed export settings" = "Replace mixed export settings" "Replace with" = "Replace with" @@ -1134,6 +1142,7 @@ name = "English" "Review and merge" = "Review and merge" "Ribbon" = "Ribbon" "Right" = "Right" +"Right padding" = "Right padding" "Right side" = "Right side" "Right side width" = "Right side width" "Rotate 90°" = "Rotate 90°" @@ -1184,6 +1193,7 @@ name = "English" "Search results" = "Search results" "Search fonts" = "Search fonts" "Search layers" = "Search layers" +"Search pages" = "Search pages" "Search shortcuts" = "Search shortcuts" "Section" = "Section" "Section color" = "Section color" @@ -1298,6 +1308,7 @@ name = "English" "Start from a blank canvas" = "Start from a blank canvas" "Start from a template" = "Start from a template" "Start on path" = "Start on path" +"Start on the Home screen" = "Start on the Home screen" "Start point" = "Start point" "Start sharing" = "Start sharing" "Start sharing with the Share button, then pass on the code it gives" = "Start sharing with the Share button, then pass on the code it gives" @@ -1371,6 +1382,7 @@ name = "English" "The selection uses several text styles: click to apply one" = "The selection uses several text styles: click to apply one" "The server deletes this file if nobody opens it before {date}." = "The server deletes this file if nobody opens it before {date}." "The whole file" = "The whole file" +"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." "Theme" = "Theme" "There's no destination for that action" = "There's no destination for that action" "Thickness" = "Thickness" @@ -1381,6 +1393,7 @@ name = "English" "This condition is edited as an expression" = "This condition is edited as an expression" "This file" = "This file" "This file has unsaved changes" = "This file has unsaved changes" +"This file is too large to join here (up to {mb} MB)" = "This file is too large to join here (up to {mb} MB)" "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." "This file is on {server}, open only to you. Share it to let others in with a new code." = "This file is on {server}, open only to you. Share it to let others in with a new code." "This frame is new on the branch." = "This frame is new on the branch." @@ -1402,6 +1415,7 @@ name = "English" "Top" = "Top" "Top & bottom" = "Top & bottom" "Top left radius" = "Top left radius" +"Top padding" = "Top padding" "Top right radius" = "Top right radius" "Transform" = "Transform" "Triangle arrow" = "Triangle arrow" @@ -1450,6 +1464,7 @@ name = "English" "Use number keys for opacity" = "Use number keys for opacity" "Use the .figcraft or .fig extension." = "Use the .figcraft or .fig extension." "Use the layer's fill" = "Use the layer's fill" +"Use your browser's zoom to change the interface size." = "Use your browser's zoom to change the interface size." "Used in {files} · {instances}" = "Used in {files} · {instances}" "Username" = "Username" "Value" = "Value" @@ -1625,6 +1640,7 @@ name = "English" "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" "slot content assignment target is missing or is not slot content" = "slot content assignment target is missing or is not slot content" "slot content assignment uses the empty GUID sentinel and was skipped" = "slot content assignment uses the empty GUID sentinel and was skipped" +"slot layers without a matching slot property were imported as frames" = "slot layers without a matching slot property were imported as frames" "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" "some vector segments refer to style settings missing from the file; they use the layer's stroke" = "some vector segments refer to style settings missing from the file; they use the layer's stroke" "stale or invalid grid track sizing entries were ignored" = "stale or invalid grid track sizing entries were ignored" @@ -1666,6 +1682,7 @@ name = "English" "{name} is forgotten on this computer; the file stays on {server}" = "{name} is forgotten on this computer; the file stays on {server}" "{name} is on the server." = "{name} is on the server." "{name} is spotlighting" = "{name} is spotlighting" +"{name} is too large to open in the browser (over 512 MB)." = "{name} is too large to open in the browser (over 512 MB)." "{name}\nClick to apply to the selection" = "{name}\nClick to apply to the selection" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." "{name} (from {library})" = "{name} (from {library})" @@ -1719,6 +1736,7 @@ name = "English" "{n}m" = "{n}m" "{n}w" = "{n}w" "{page}, nothing selected" = "{page}, nothing selected" +"{percent}%" = "{percent}%" "{profile} (current)" = "{profile} (current)" "{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." "{server} can't say what became of your files (it is older than this app)." = "{server} can't say what became of your files (it is older than this app)." diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 479172678..596feda6e 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -175,6 +175,7 @@ status = "draft — needs native review" "Both directions" = "Ambas direcciones" "Bottom" = "Abajo" "Bottom left radius" = "Radio inferior izquierdo" +"Bottom padding" = "Margen interno inferior" "Bottom right radius" = "Radio inferior derecho" "Branch" = "Rama" "Branch archived" = "Rama archivada" @@ -320,6 +321,7 @@ status = "draft — needs native review" "Corner radius" = "Radio de esquina" "Corner smoothing" = "Suavizado de esquinas" "Couldn't reach {server}: showing what this computer remembers." = "No se pudo contactar con {server}: se muestra lo que este ordenador recuerda." +"Couldn't read {name}." = "No se pudo leer {name}." "Couldn't save automatically: {error}" = "No se pudo guardar automáticamente: {error}" "Count" = "Número" "Create" = "Crear" @@ -633,6 +635,7 @@ status = "draft — needs native review" "Included" = "Incluidos" "Included in layout" = "Incluidos en el diseño" "Individual corners" = "Esquinas individuales" +"Individual padding" = "Margen interno individual" "Individual strokes" = "Trazos individuales" "Ink" = "Tinta" "Inner shadow" = "Sombra interior" @@ -654,6 +657,7 @@ status = "draft — needs native review" "Interaction" = "Interacción" "Interactions" = "Interacciones" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Diseño de interfaces y prototipos. Una herramienta de diseño gratuita y de código abierto, de implementación independiente (clean-room), escrita desde cero en Rust, parte de las Crafting Apps de ArtCraft." +"Interface size" = "Tamaño de la interfaz" "Intersect" = "Intersecar" "Intersects" = "Intersecciones" "Invalid color" = "Color no válido" @@ -703,6 +707,7 @@ status = "draft — needs native review" "Leaves" = "Hojas" "Left" = "Izquierda" "Left & right" = "Izquierda y derecha" +"Left padding" = "Margen interno izquierdo" "Left side" = "Lado izquierdo" "Left side width" = "Ancho del lado izquierdo" "Letter case" = "Mayúsculas y minúsculas" @@ -951,6 +956,7 @@ status = "draft — needs native review" "PDF print settings" = "Ajustes de impresión PDF" "Padding" = "Margen interno" "Page" = "Página" +"Page deleted" = "Página eliminada" "Pages" = "Páginas" "Pages added to the document" = "Páginas añadidas al documento" "Placed on the current page" = "Colocado en la página actual" @@ -1062,6 +1068,7 @@ status = "draft — needs native review" "Reduce motion" = "Reducir movimiento" "Refraction" = "Refracción" "Regular expression" = "Expresión regular" +"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Relativo al escalado de pantalla del sistema (100% es el tamaño del propio sistema). El zoom del documento es independiente." "Remove" = "Quitar" "Remove all" = "Quitar todo" "Remove annotation" = "Quitar anotación" @@ -1100,6 +1107,7 @@ status = "draft — needs native review" "Rename on the server…" = "Cambiar el nombre en el servidor…" "Rename page" = "Cambiar nombre de página" "Rename to" = "Nuevo nombre" +"Reorder selection" = "Reordenar selección" "Replace all" = "Reemplazar todo" "Replace mixed export settings" = "Reemplazar ajustes de exportación mixtos" "Replace with" = "Reemplazar con" @@ -1135,6 +1143,7 @@ status = "draft — needs native review" "Review and merge" = "Revisar y fusionar" "Ribbon" = "Cinta" "Right" = "Derecha" +"Right padding" = "Margen interno derecho" "Right side" = "Lado derecho" "Right side width" = "Ancho del lado derecho" "Rotate 90°" = "Girar 90°" @@ -1185,6 +1194,7 @@ status = "draft — needs native review" "Search results" = "Resultados de búsqueda" "Search fonts" = "Buscar fuentes" "Search layers" = "Buscar capas" +"Search pages" = "Buscar páginas" "Search shortcuts" = "Buscar atajos" "Section" = "Sección" "Section color" = "Color de sección" @@ -1299,6 +1309,7 @@ status = "draft — needs native review" "Start from a blank canvas" = "Empieza con un lienzo en blanco" "Start from a template" = "Empieza con una plantilla" "Start on path" = "Inicio en el trazado" +"Start on the Home screen" = "Empezar en Inicio" "Start point" = "Punto inicial" "Start sharing" = "Empezar a compartir" "Start sharing with the Share button, then pass on the code it gives" = "Empieza a compartir con el botón Compartir y pasa el código que te da" @@ -1372,6 +1383,7 @@ status = "draft — needs native review" "The selection uses several text styles: click to apply one" = "La selección usa varios estilos de texto: haz clic para aplicar uno" "The server deletes this file if nobody opens it before {date}." = "El servidor elimina este archivo si nadie lo abre antes del {date}." "The whole file" = "El archivo completo" +"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "La ventana es demasiado pequeña para {size}%, así que la interfaz se dibuja al {drawn}%. Agranda la ventana para usar {size}%." "Theme" = "Tema" "There's no destination for that action" = "No hay destino para esa acción" "Thickness" = "Grosor" @@ -1382,6 +1394,7 @@ status = "draft — needs native review" "This condition is edited as an expression" = "Esta condición se edita como expresión" "This file" = "Este archivo" "This file has unsaved changes" = "Este archivo tiene cambios sin guardar" +"This file is too large to join here (up to {mb} MB)" = "Este archivo es demasiado grande para unirte aquí (hasta {mb} MB)" "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Este archivo usa fuentes que no están instaladas en este equipo. Su texto se dibuja con una fuente de reserva hasta que las instales." "This file is on {server}, open only to you. Share it to let others in with a new code." = "Este archivo está en {server}, abierto solo para ti. Compártelo para dejar entrar a otros con un código nuevo." "This frame is new on the branch." = "Este marco es nuevo en la rama." @@ -1403,6 +1416,7 @@ status = "draft — needs native review" "Top" = "Arriba" "Top & bottom" = "Arriba y abajo" "Top left radius" = "Radio superior izquierdo" +"Top padding" = "Margen interno superior" "Top right radius" = "Radio superior derecho" "Transform" = "Transformar" "Triangle arrow" = "Flecha triangular" @@ -1451,6 +1465,7 @@ status = "draft — needs native review" "Use number keys for opacity" = "Usar teclas numéricas para la opacidad" "Use the .figcraft or .fig extension." = "Usa la extensión .figcraft o .fig." "Use the layer's fill" = "Usar el relleno de la capa" +"Use your browser's zoom to change the interface size." = "Usa el zoom del navegador para cambiar el tamaño de la interfaz." "Used in {files} · {instances}" = "Usado en {files} · {instances}" "Username" = "Usuario" "Value" = "Valor" @@ -1626,6 +1641,7 @@ status = "draft — needs native review" "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "se omitieron las acciones «Establecer modo de variable» sobre colecciones o modos que no están en el JSON de variables" "slot content assignment target is missing or is not slot content" = "el destino de la asignación de contenido de ranura falta o no es contenido de ranura" "slot content assignment uses the empty GUID sentinel and was skipped" = "la asignación de contenido de ranura usa el GUID vacío como centinela y se omitió" +"slot layers without a matching slot property were imported as frames" = "las capas de ranura sin una propiedad de ranura correspondiente se importaron como marcos" "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "algunos puntos vectoriales hacen referencia a ajustes de punto que faltan en el archivo; usan las uniones de la capa y esquinas simples" "some vector segments refer to style settings missing from the file; they use the layer's stroke" = "algunos segmentos vectoriales hacen referencia a ajustes de estilo que faltan en el archivo; usan el trazo de la capa" "stale or invalid grid track sizing entries were ignored" = "se ignoraron las entradas de tamaño de pista de cuadrícula obsoletas o no válidas" @@ -1667,6 +1683,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "{name} se olvidó en este ordenador; el archivo se queda en {server}" "{name} is on the server." = "{name} está en el servidor." "{name} is spotlighting" = "{name} está en el foco" +"{name} is too large to open in the browser (over 512 MB)." = "{name} es demasiado grande para abrirlo en el navegador (más de 512 MB)." "{name}\nClick to apply to the selection" = "{name}\nHaz clic para aplicarlo a la selección" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nArrastra al lienzo o haz clic para colocarlo en el centro de la vista.\nMantén Alt (Opción) al soltar sobre una instancia para intercambiarla." "{name} (from {library})" = "{name} (de {library})" @@ -1720,6 +1737,7 @@ status = "draft — needs native review" "{n}m" = "{n} min" "{n}w" = "{n} sem" "{page}, nothing selected" = "{page}, nada seleccionado" +"{percent}%" = "{percent}%" "{profile} (current)" = "{profile} (actual)" "{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} es su valor; {property ? \"a\" : \"b\"} elige un texto. Cualquier otro {…} se queda tal cual." "{server} can't say what became of your files (it is older than this app)." = "{server} no puede decir qué fue de tus archivos (es anterior a esta aplicación)." @@ -1762,6 +1780,7 @@ status = "draft — needs native review" "arrange.front" = "Traer al frente" "arrange.packH" = "Agrupar en horizontal" "arrange.packV" = "Agrupar en vertical" +"arrange.reorder" = "Reordenar selección" "arrange.roundToPixels" = "Redondear a píxeles" "arrange.spacing" = "Espaciado" "arrange.tidy" = "Ordenar" @@ -1889,6 +1908,7 @@ status = "draft — needs native review" "file.exportLayers" = "Exportar capas" "file.exportSvg" = "Exportar selección como SVG" "file.import" = "Importar" +"file.moveTab" = "Mover pestaña" "file.new" = "Nuevo archivo de diseño" "file.newFromTemplate" = "Nuevo desde plantilla" "file.open" = "Abrir" @@ -2031,6 +2051,8 @@ status = "draft — needs native review" "pref.reduceMotion" = "Reducir movimiento" "pref.renameDuplicates" = "Renombrar capas duplicadas" "pref.showDimensions" = "Mostrar dimensiones en los objetos" +"pref.startOnHome" = "Empezar en Inicio" +"pref.uiScale" = "Tamaño de la interfaz" "pref.userName" = "Tu nombre en los comentarios" "proto.action.add" = "Añadir acción de prototipo" "proto.action.move" = "Reordenar acción de prototipo" @@ -2136,6 +2158,7 @@ status = "draft — needs native review" "text.listIndent" = "Aumentar sangría de la lista" "text.listOutdent" = "Reducir sangría de la lista" "text.listStyle" = "Estilo de lista" +"text.loadFont" = "Cargar archivo de fuente" "text.maxLines" = "Máximo de líneas" "text.missingFonts" = "Fuentes que faltan" "text.numberList" = "Lista numerada" @@ -2144,7 +2167,9 @@ status = "draft — needs native review" "text.paragraphSpacing" = "Espacio entre párrafos" "text.pathOptions" = "Opciones de texto en trazado" "text.preview" = "Previsualizar cambio de texto" +"text.refit" = "Ajustar texto a las fuentes" "text.removeFromPath" = "Quitar texto del trazado" +"text.rescanFonts" = "Volver a buscar fuentes instaladas" "text.set" = "Establecer texto" "text.setFeatures" = "Funciones OpenType" "text.setVariations" = "Ejes de fuente variable" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml new file mode 100644 index 000000000..205a9d294 --- /dev/null +++ b/crates/ui-egui/locales/fi.toml @@ -0,0 +1,2353 @@ +# Finnish interface catalog. Original translations; MIT OR Apache-2.0. +# AI-assisted draft; not reviewed by a human Finnish/native speaker. +# Sentence case, imperative commands. Integer plurals: 1 taso, 0/2/21 tasoa. +# Product names, shortcut notation, numeric glyphs and code examples retain their identity. +# Command ids and context suffixes are immutable keys; translated text is display-only. +# Completeness measured against the English UI and live command/menu source sets; see tests. + +[meta] +name = "Suomi" +status = "draft — needs native review" + +[ui] +"% of all commits" = "% kaikista muutoksista" +"(no longer published)" = "(ei enää julkaistu)" +"({n} warnings: some entries were skipped)" = { one = "({n} varoitus: joitakin kohteita ohitettiin)", other = "({n} varoitusta: joitakin kohteita ohitettiin)" } +"+ Create variable" = "+ Luo muuttuja" +".fig (experimental)" = ".fig (kokeellinen)" +"1…9, 0" = "1…9, 0" +"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR:t {prs}, muutokset {commits}, rivit +{added} / −{deleted} (Δ {delta}), binääriresurssit +{bin_added} / −{bin_deleted}, {first} – {last}" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Tämänniminen .figcraft-tiedosto on jo olemassa. Vahvista korvaaminen valitsemalla sen koko nimi." +"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Tämän tiedoston kokoelma, joka sisältää nämä tilat ja muuttujat. Voit ohittaa niiden arvot tilakohtaisesti ja lisätä omia muuttujia." +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Yksittäinen taso tasataan kehykseensä. Valitse useampia tasoja tai yksi kehyksen sisällä oleva taso." +"A responsive product page: hero, features, figures, a quote and a footer" = "Mukautuva tuotesivu: pääosio, ominaisuudet, tunnusluvut, lainaus ja alatunniste" +"A spring plays until it settles: its mass, stiffness and damping set how long" = "Jousi liikkuu, kunnes se asettuu. Sen massa, jäykkyys ja vaimennus määräävät keston." +"A title, an agenda, a big figure, a chart and a closing slide" = "Otsikko, esityslista, suuri tunnusluku, kaavio ja päätösdia" +"A to Z" = "A:sta Z:aan" +"About" = "Tietoja" +"About FigCraft" = "Tietoja FigCraftista" +"About this server" = "Tietoja tästä palvelimesta" +"Action" = "Toiminto" +"Actions" = "Toiminnot" +"Actions for {name}" = "Kohteen {name} toiminnot" +"Actions…" = "Toiminnot…" +"Active" = "Aktiivinen" +"Actual size (100%)" = "Todellinen koko (100 %)" +"Add a category" = "Lisää luokka" +"Add a comment" = "Lisää kommentti" +"Add a description" = "Lisää kuvaus" +"Add a frame (F) or a shape (R) to get started" = "Aloita lisäämällä kehys (F) tai muoto (R)" +"Add a layer or component to tile first" = "Lisää ensin laatoitettava taso tai komponentti" +"Add a note for developers" = "Lisää huomautus kehittäjille" +"Add action" = "Lisää toiminto" +"Add actions for when no condition holds" = "Lisää toimintoja tilanteeseen, jossa mikään ehto ei täyty" +"Add an annotation for developers" = "Lisää huomautus kehittäjille" +"Add another condition" = "Lisää toinen ehto" +"Add auto layout" = "Lisää automaattinen asettelu" +"Add background behind overlay" = "Lisää tausta peittokuvan taakse" +"Add content to this slot" = "Lisää sisältöä tähän sisältöpaikkaan" +"Add dev resource" = "Lisää kehittäjäresurssi" +"Add effect" = "Lisää tehoste" +"Add export setting" = "Lisää vientiasetus" +"Add fill" = "Lisää täyttö" +"Add interaction" = "Lisää vuorovaikutus" +"Add layout grid" = "Lisää asetteluruudukko" +"Add library" = "Lisää kirjasto" +"Add library file…" = "Lisää kirjastotiedosto…" +"Add max height" = "Lisää enimmäiskorkeus" +"Add max width" = "Lisää enimmäisleveys" +"Add min height" = "Lisää vähimmäiskorkeus" +"Add min width" = "Lisää vähimmäisleveys" +"Add mode" = "Lisää tila" +"Add new page" = "Lisää uusi sivu" +"Add or update collections from a variables JSON" = "Lisää tai päivitä kokoelmia muuttujien JSON-tiedostosta" +"Add properties to make instances configurable" = "Lisää ominaisuuksia, jotta ilmentymiä voi mukauttaa" +"Add reaction" = "Lisää reaktio" +"Add starting point" = "Lisää aloituspiste" +"Add stroke" = "Lisää reunaviiva" +"Add variant" = "Lisää variantti" +"Add variant with a new value" = "Lisää variantti uudella arvolla" +"Added" = "Lisätty" +"Added the library \"{name}\"" = "Kirjasto ”{name}” lisätty" +"Adding to {name}" = "Lisätään kohteeseen {name}" +"Adjust" = "Säädä" +"Adjustment presets" = "Säädön esiasetukset" +"Advanced" = "Lisäasetukset" +"Advanced stroke" = "Reunaviivan lisäasetukset" +"After delay" = "Viiveen jälkeen" +"After delay {ms}ms" = "Viiveen {ms}ms jälkeen" +"Align" = "Tasaa" +"Align bottom" = "Tasaa alas" +"Align center" = "Keskitä" +"Align horizontal centers" = "Keskitä vaakasuunnassa" +"Align left" = "Tasaa vasemmalle" +"Align middle" = "Tasaa keskelle" +"Align right" = "Tasaa oikealle" +"Align text baselines" = "Tasaa tekstin peruslinjat" +"Align top" = "Tasaa ylös" +"Align vertical centers" = "Keskitä pystysuunnassa" +"Alignment" = "Tasaus" +"All" = "Kaikki" +"All categories" = "Kaikki luokat" +"All comments" = "Kaikki kommentit" +"All components" = "Kaikki komponentit" +"All fonts" = "Kaikki fontit" +"All supported properties" = "Kaikki tuetut ominaisuudet" +"All the fonts this file uses are installed" = "Kaikki tämän tiedoston käyttämät fontit on asennettu" +"All variables" = "Kaikki muuttujat" +"Alpha" = "Alfa" +"Alphabetical" = "Aakkosjärjestys" +"Alt+drag" = "Alt+drag" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Analytiikkanäkymä ruudukossa: tunnuslukuja, vektoreista piirrettyjä kaavioita ja taulukko" +"An invite link that opens FigCraft and joins" = "Kutsulinkki, joka avaa FigCraftin ja liittyy istuntoon" +"Anchor" = "Ankkuri" +"Android" = "Android" +"Angular" = "Kulmaliukuväri" +"Animation" = "Animaatio" +"Annotations" = "Huomautukset" +"Annotations shown on the canvas" = "Piirtoalueella näytettävät huomautukset" +"Another server, or one that needs its key. The usual server is set in Preferences." = "Toinen palvelin tai avainta tarvitseva palvelin. Tavallinen palvelin asetetaan asetuksissa." +"Any status" = "Kaikki tilat" +"Anyone on your network with this code can edit the file, while this app stays open." = "Kuka tahansa lähiverkossasi voi muokata tiedostoa tällä koodilla niin kauan kuin sovellus on auki." +"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." = "Kuka tahansa muokkauslinkin saanut voi muuttaa tiedostoa. Katselulinkillä voi vain katsella. Linkit sisältävät salaiset tunnisteet: jaa niitä kuin salasanoja. `figcraft-server links` luo lisää linkkejä ja peruu niitä." +"Anyone with this code can edit the file." = "Kuka tahansa tämän koodin saanut voi muokata tiedostoa." +"Anyone with this code can look at the file, not change it." = "Kuka tahansa tämän koodin saanut voi katsella tiedostoa, mutta ei muuttaa sitä." +"Appearance" = "Ulkoasu" +"Apply a component property" = "Käytä komponentin ominaisuutta" +"Apply variable" = "Käytä muuttujaa" +"Apply variable to text" = "Käytä muuttujaa tekstiin" +"Apply variable to visibility" = "Käytä muuttujaa näkyvyyteen" +"Apps" = "Sovellukset" +"Apr" = "huhtik." +"Arc" = "Kaari" +"Arc ratio" = "Kaaren suhde" +"Arc start" = "Kaaren alku" +"Arc sweep" = "Kaaren pyyhkäisy" +"Archive" = "Arkistoi" +"Archived" = "Arkistoitu" +"Arrange" = "Järjestä" +"Arrow" = "Nuoli" +"As typed" = "Kirjoitetussa muodossa" +"Asking the server…" = "Kysytään palvelimelta…" +"Assets" = "Resurssit" +"Aug" = "elok." +"Auto" = "Automaattinen" +"Auto (space around)" = "Automaattinen (tila ympärillä)" +"Auto (space between)" = "Automaattinen (tila välissä)" +"Auto (space evenly)" = "Automaattinen (tasavälein)" +"Auto ({mode})" = "Automaattinen ({mode})" +"Auto columns" = "Automaattiset sarakkeet" +"Auto gap" = "Automaattinen väli" +"Auto height" = "Automaattinen korkeus" +"Auto layout" = "Automaattinen asettelu" +"Auto rows" = "Automaattiset rivit" +"Auto width" = "Automaattinen leveys" +"Back" = "Takaisin" +"Back to Ready for dev" = "Palauta kehitysvalmiiksi" +"Back to Ready for dev: the next change compares with how it is now" = "Palauta kehitysvalmiiksi: seuraavaa muutosta verrataan nykyiseen tilaan" +"Back to assets" = "Takaisin resursseihin" +"Back to current" = "Takaisin nykyiseen" +"Back to styles" = "Takaisin tyyleihin" +"Back to the FigCraft server" = "Takaisin FigCraft-palvelimelle" +"Back to the canvas from a focused region" = "Palaa kohdistetulta alueelta piirtoalueelle" +"Back to the current version" = "Takaisin nykyiseen versioon" +"Back to the font's value" = "Palauta fontin arvo" +"Back to the values the font style implies" = "Palauta fonttityylin mukaiset arvot" +"Background" = "Tausta" +"Background blur" = "Taustan sumennus" +"Basics" = "Perusteet" +"Before" = "Ennen" +"Bend" = "Taivutus" +"Bevel" = "Viiste" +"Binary assets added" = "Lisätyt binääriresurssit" +"Binary assets removed" = "Poistetut binääriresurssit" +"Black & white" = "Mustavalkoinen" +"Bleed" = "Leikkuuvara" +"Bleed marks" = "Leikkuuvaramerkit" +"Bleed marks need a bleed" = "Leikkuuvaramerkit edellyttävät leikkuuvaraa" +"Blend" = "Sekoitus" +"Blend mode" = "Sekoitustila" +"Blue" = "Sininen" +"Blur" = "Sumennus" +"Boolean" = "Totuusarvo" +"Boolean groups" = "Boolen ryhmät" +"Both directions" = "Molemmat suunnat" +"Bottom" = "Alhaalta" +"Bottom left radius" = "Vasemman alakulman säde" +"Bottom right radius" = "Oikean alakulman säde" +"Bound to {name}. Click to detach the variable." = "Sidottu muuttujaan {name}. Irrota muuttuja napsauttamalla." +"Branch" = "Haara" +"Branch archived" = "Haara arkistoitu" +"Branch merged" = "Haara yhdistetty" +"Branch name" = "Haaran nimi" +"Branch of {file}" = "Tiedoston {file} haara" +"Branch of {file} · merged or archived" = "Tiedoston {file} haara · yhdistetty tai arkistoitu" +"Branch updated from main" = "Haara päivitetty päähaarasta" +"Branch {name}" = "Haara {name}" +"Branches" = "Haarat" +"Bring forward" = "Tuo eteenpäin" +"Bring the main file's changes into the branch" = "Tuo päätiedoston muutokset haaraan" +"Bring to front" = "Tuo etummaiseksi" +"Bristle" = "Harjas" +"Browse" = "Selaa" +"Brush" = "Sivellin" +"Brush rotation jitter" = "Siveltimen kierron vaihtelu" +"Brush scatter" = "Siveltimen hajonta" +"Brush size jitter" = "Siveltimen koon vaihtelu" +"Brush spacing" = "Siveltimen välistys" +"Bulleted list" = "Luettelomerkitty luettelo" +"C or Shift+C" = "C or Shift+C" +"C or ⇧C" = "C or ⇧C" +"CMYK colour" = "CMYK-väri" +"CSS custom properties…" = "CSS:n mukautetut ominaisuudet…" +"Can edit" = "Voi muokata" +"Can view" = "Voi katsella" +"Can't read this library: {e}" = "Kirjastoa ei voi lukea: {e}" +"Cancel" = "Peruuta" +"Canvas" = "Piirtoalue" +"Canvas stacking" = "Piirtoalueen pinoamisjärjestys" +"Cap height to baseline" = "Versaalikorkeudesta peruslinjaan" +"Case" = "Kirjainkoko" +"Case-sensitive forms" = "Kirjainkokoon mukautuvat muodot" +"Categories" = "Luokat" +"Center" = "Keskelle" +"Change alias" = "Vaihda alias" +"Change resolved" = "Muutos ratkaistu" +"Change style" = "Vaihda tyyli" +"Change to" = "Vaihda kohteeksi" +"Change to needs a layer inside a variant" = "Vaihto edellyttää variantin sisällä olevaa tasoa" +"Changed" = "Muutettu" +"Changed on both sides: keep one" = "Muutettu molemmilla puolilla: säilytä toinen" +"Changes on main" = "Päähaaran muutokset" +"Changes on the branch" = "Haaran muutokset" +"Changes unknown" = "Muutokset tuntemattomia" +"Checking the fonts installed on this computer…" = "Tarkistetaan tämän tietokoneen asennettuja fontteja…" +"Checking the server…" = "Tarkistetaan palvelinta…" +"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Valitse yllä React, Leptos, SwiftUI tai Compose yhdistääksesi tämän komponentin sen koodiin." +"Choose another profile to change it." = "Vaihda valitsemalla toinen profiili." +"Choose collection" = "Valitse kokoelma" +"Choose image" = "Valitse kuva" +"Choose mode" = "Valitse tila" +"Choose variable" = "Valitse muuttuja" +"Circle" = "Ympyrä" +"Clear from this list" = "Poista tästä luettelosta" +"Clear recent" = "Tyhjennä viimeisimmät" +"Click + to replace mixed content" = "Korvaa vaihteleva sisältö napsauttamalla +" +"Click anywhere on the canvas to leave one." = "Lisää huomautus napsauttamalla mitä tahansa piirtoalueen kohtaa." +"Click or drag to merge · {alt} removes" = "Yhdistä napsauttamalla tai vetämällä · {alt} poistaa" +"Click or drag to remove · {alt} merges" = "Poista napsauttamalla tai vetämällä · {alt} yhdistää" +"Click to record a key" = "Tallenna näppäin napsauttamalla" +"Clip content" = "Leikkaa sisältö rajoihin" +"Clip to shape" = "Leikkaa muotoon" +"Close" = "Sulje" +"Close overlay" = "Sulje peittokuva" +"Close preview" = "Sulje esikatselu" +"Close tab" = "Sulje välilehti" +"Close version history" = "Sulje versiohistoria" +"Close when clicking outside" = "Sulje napsautettaessa ulkopuolelle" +"Close without saving" = "Sulje tallentamatta" +"Code" = "Koodi" +"Code or invite link" = "Koodi tai kutsulinkki" +"Code syntax" = "Koodin syntaksi" +"Collapse" = "Tiivistä muunnokset" +"Collapse panel" = "Supista paneeli" +"Collection" = "Kokoelma" +"Collections" = "Kokoelmat" +"Color" = "Väri" +"Color burn" = "Värivarjostus" +"Color dodge" = "Värilisävalotus" +"Color format" = "Värimuoto" +"Color profile" = "Väriprofiili" +"Color profile: {name}" = "Väriprofiili: {name}" +"Color styles" = "Värityylit" +"Color values in this file are in this color space." = "Tämän tiedoston väriarvot ovat tässä väriavaruudessa." +"Color variables" = "Värimuuttujat" +"Colour" = "Väri" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Väri- ja tekstityylejä, vaalean ja tumman tilan muuttujia, painikkeita, syötekenttiä ja kortteja" +"Column" = "Sarake" +"Column gap" = "Sarakkeiden väli" +"Column sizes" = "Sarakkeiden koot" +"Column span" = "Sarakeulottuvuus" +"Columns" = "Sarakkeet" +"Columns {count}" = "Sarakkeita: {count}" +"Comment" = "Kommentti" +"Comments" = "Kommentit" +"Commits" = "Muutokset" +"Company" = "Yritys" +"Compare changes" = "Vertaa muutoksia" +"Compatibility notes" = "Yhteensopivuushuomautukset" +"Completed" = "Valmis" +"Component" = "Komponentti" +"Component name" = "Komponentin nimi" +"Component property" = "Komponentin ominaisuus" +"Component set" = "Komponenttijoukko" +"Component sets" = "Komponenttijoukot" +"Components" = "Komponentit" +"Conditional" = "Ehdollinen" +"Confetti" = "Konfetti" +"Conflicts" = "Ristiriidat" +"Connect to code" = "Yhdistä koodiin" +"Connected" = "Yhdistetty" +"Connected to code: {from}" = "Yhdistetty koodiin: {from}" +"Connection lost, reconnecting (attempt {attempt})…" = "Yhteys katkesi, yhdistetään uudelleen (yritys {attempt})…" +"Constrain proportions" = "Säilytä mittasuhteet" +"Constraints" = "Rajoitteet" +"Content" = "Sisältö" +"Contents hidden" = "Sisältö kätketty" +"Contents only" = "Vain sisältö" +"Contextual alternates" = "Kontekstuaaliset vaihtoehdot" +"Contrast" = "Kontrasti" +"Contributors" = "Tekijät" +"Contributors: {people} · Commits: {commits}" = "Tekijät: {people} · Muutokset: {commits}" +"Controller" = "Ohjain" +"Convert to frame" = "Muunna kehykseksi" +"Convert to section" = "Muunna osioksi" +"Cool" = "Viileä" +"Copied as CSS" = "Kopioitu CSS-muodossa" +"Copied as PNG" = "Kopioitu PNG-muodossa" +"Copied as SVG" = "Kopioitu SVG-muodossa" +"Copied as text" = "Kopioitu tekstinä" +"Copied {value}" = "Kopioitu: {value}" +"Copy" = "Kopioi" +"Copy as" = "Kopioi muodossa" +"Copy as CSS" = "Kopioi CSS-muodossa" +"Copy as PNG" = "Kopioi PNG-muodossa" +"Copy as SVG" = "Kopioi SVG-muodossa" +"Copy code" = "Kopioi koodi" +"Copy link" = "Kopioi linkki" +"Copy the {label}" = "Kopioi: {label}" +"Copy {value}" = "Kopioi: {value}" +"Corner radius" = "Kulman säde" +"Corner smoothing" = "Kulman pehmennys" +"Couldn't download the variables" = "Muuttujia ei voitu ladata" +"Couldn't reach {server}: showing what this computer remembers." = "Palvelimeen {server} ei saatu yhteyttä. Näytetään tämän tietokoneen muistamat tiedot." +"Couldn't read {name}." = "Tiedostoa {name} ei voitu lukea." +"Couldn't save automatically: {error}" = "Automaattinen tallennus epäonnistui: {error}" +"Count" = "Määrä" +"Create" = "Luo" +"Create a variable collection first" = "Luo ensin muuttujakokoelma" +"Create a variable first" = "Luo ensin muuttuja" +"Create alias" = "Luo alias" +"Create branch" = "Luo haara" +"Create collection" = "Luo kokoelma" +"Create component" = "Luo komponentti" +"Create component property" = "Luo komponentin ominaisuus" +"Create components, styles or variables to publish them as a library." = "Luo komponentteja, tyylejä tai muuttujia, jotta voit julkaista ne kirjastona." +"Create property…" = "Luo ominaisuus…" +"Create style" = "Luo tyyli" +"Create style from the selected text" = "Luo tyyli valitusta tekstistä" +"Created" = "Luotu" +"Crop" = "Rajaa" +"Crop marks" = "Leikkausmerkit" +"Ctrl+arrows" = "Ctrl+arrows" +"Ctrl+click" = "Ctrl+click" +"Ctrl+scroll" = "Ctrl+scroll" +"Current name" = "Nykyinen nimi" +"Current version" = "Nykyinen versio" +"Cursor chat" = "Kohdistinkeskustelu" +"Custom" = "Mukautettu" +"Custom size" = "Mukautettu koko" +"Cut" = "Leikkaa" +"Dark" = "Tumma" +"Darken" = "Tummenna" +"Dash" = "Katkoviiva" +"Dashboard" = "Koontinäkymä" +"Date created" = "Luontipäivä" +"Dec" = "jouluk." +"Decoration" = "Koristelu" +"Decoration style" = "Koristelun tyyli" +"Deep select" = "Valitse sisäkkäinen kohde" +"Default" = "Oletus" +"Default content" = "Oletussisältö" +"Default value (the top-left variant)" = "Oletusarvo (vasemman yläkulman variantti)" +"Delay" = "Viive" +"Delete" = "Poista" +"Delete category (its annotations stay)" = "Poista luokka (sen huomautukset säilyvät)" +"Delete collection" = "Poista kokoelma" +"Delete group" = "Poista ryhmä" +"Delete mode" = "Poista tila" +"Delete page" = "Poista sivu" +"Delete preset" = "Poista esiasetus" +"Delete property" = "Poista ominaisuus" +"Delete the style (layers keep their values)" = "Poista tyyli (tasojen arvot säilyvät)" +"Delete thread" = "Poista keskustelu" +"Delete variable" = "Poista muuttuja" +"Delete “{name}”? This can't be undone." = "Poistetaanko ”{name}”? Tätä ei voi kumota." +"Density" = "Tiheys" +"Depth" = "Syvyys" +"Describe how and when to use this component" = "Kuvaile, miten ja milloin tätä komponenttia käytetään" +"Describe what changed" = "Kuvaile muutokset" +"Description" = "Kuvaus" +"Description (optional)" = "Kuvaus (valinnainen)" +"Design" = "Suunnittelu" +"Design system starter" = "Muotoilujärjestelmän aloituspohja" +"Desktop" = "Työpöytä" +"Destination" = "Kohde" +"Detach alias" = "Irrota alias" +"Detach instance" = "Irrota ilmentymä" +"Detach property" = "Irrota ominaisuus" +"Detach style" = "Irrota tyyli" +"Detach styles" = "Irrota tyylit" +"Detach variable" = "Irrota muuttuja" +"Details" = "Tiedot" +"Dev Mode" = "Kehittäjätila" +"Dev resources" = "Kehittäjäresurssit" +"Development" = "Kehitys" +"Device" = "Laite" +"Device at 100%" = "Laite koossa 100 %" +"Diamond" = "Vinoneliö" +"Difference" = "Erotus" +"Dimensions" = "Mitat" +"Direction" = "Suunta" +"Disconnect from code" = "Katkaise yhteys koodiin" +"Dispersion" = "Hajonta" +"Display name" = "Näyttönimi" +"Dissolve" = "Häivytys" +"Distribute and tidy" = "Jaa ja järjestä" +"Distribute horizontal spacing" = "Jaa vaakavälit tasan" +"Distribute vertical spacing" = "Jaa pystyvälit tasan" +"Divider" = "Erotin" +"Don't truncate" = "Älä lyhennä" +"Done" = "Valmis" +"Done editing" = "Lopeta muokkaus" +"Dots" = "Pisteet" +"Dotted" = "Pisteviiva" +"Down" = "Alas" +"Drafts" = "Luonnokset" +"Drag the stroke to add a width point" = "Lisää leveyspiste vetämällä reunaviivaa" +"Draw" = "Piirrä" +"Draw a frame" = "Piirrä kehys" +"Drawing tools" = "Piirtotyökalut" +"Drop a .figcraft, .fig, .json, .svg or image file." = "Pudota .figcraft-, .fig-, .json-, .svg- tai kuvatiedosto." +"Drop shadow" = "Heittovarjo" +"Drop to import" = "Tuo pudottamalla" +"Drop to open" = "Avaa pudottamalla" +"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Kaksoislisensoitu: MIT OR Apache-2.0. ArtCraft-tiimin ja yhteisön tekemä." +"Duo" = "Kaksivärinen" +"Duplicate" = "Monista" +"Duplicate by dragging" = "Monista vetämällä" +"Duplicate mode" = "Monista tila" +"Duplicate page" = "Monista sivu" +"Edit" = "Muokkaa" +"Edit as an expression" = "Muokkaa lausekkeena" +"Edit colour" = "Muokkaa väriä" +"Edit comment" = "Muokkaa kommenttia" +"Edit dev resource" = "Muokkaa kehittäjäresurssia" +"Edit link" = "Muokkaa linkkiä" +"Edit link copied" = "Muokkauslinkki kopioitu" +"Edit style" = "Muokkaa tyyliä" +"Edit variable" = "Muokkaa muuttujaa" +"Edit with the builder" = "Muokkaa rakentimella" +"Edited {when}" = "Muokattu {when}" +"Effect settings" = "Tehosteen asetukset" +"Effect styles" = "Tehostetyylit" +"Effects" = "Tehosteet" +"Ellipse" = "Ellipsi" +"Ellipses" = "Ellipsit" +"Else" = "Muuten" +"Else if" = "Muuten jos" +"Embedded profile" = "Upotettu profiili" +"Empty" = "Tyhjä" +"End" = "Loppu" +"End point" = "Loppupiste" +"Ends the session; everyone keeps their copy" = "Lopettaa istunnon. Kaikki säilyttävät oman kopionsa." +"Enter" = "Enter" +"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Anna figcraft-server-palvelimen osoite ja näytä sen tarjoamat liitännäiset painamalla Selaa." +"Enter to save · Esc to cancel" = "Tallenna Enterillä · peruuta Escillä" +"Eraser" = "Pyyhekumi" +"Esc" = "Esc" +"Essential" = "Olennainen" +"Every conflict has a choice." = "Jokaiselle ristiriidalle on valittu ratkaisu." +"Every top-level frame already starts a flow" = "Jokainen ylimmän tason kehys aloittaa jo kulun" +"Exact opacity (4 then 5: 45%)" = "Tarkka peittävyys (4 ja sitten 5: 45 %)" +"Exclude" = "Jätä pois" +"Excluded" = "Jätetty pois" +"Excluded from layout" = "Jätetty pois asettelusta" +"Excludes" = "Poissulkee" +"Exclusion" = "Poissulku" +"Exit focus view (Esc)" = "Poistu kohdistusnäkymästä (Esc)" +"Expand" = "Laajenna" +"Export" = "Vie" +"Export all" = "Vie kaikki" +"Export frames and layers as PNG, JPG, SVG or PDF" = "Vie kehyksiä ja tasoja PNG-, JPG-, SVG- tai PDF-muodossa" +"Export page as PNG" = "Vie sivu PNG-muodossa" +"Export settings" = "Vientiasetukset" +"Export variables" = "Vie muuttujat" +"Export {name}" = "Vie {name}" +"Export {n} layers" = { one = "Vie {n} taso", other = "Vie {n} tasoa" } +"Exported {file}" = "Viety: {file}" +"Exported {name}" = "Viety: {name}" +"Exported {name}; {n} kinds of content were left out" = { one = "Viety {name}; {n} sisältötyyppi jätettiin pois", other = "Viety {name}; {n} sisältötyyppiä jätettiin pois" } +"Exported {n} files" = { one = "Viety {n} tiedosto", other = "Viety {n} tiedostoa" } +"Export…" = "Vie…" +"Expose properties to instances" = "Tuo ominaisuudet ilmentymien käyttöön" +"Exposure" = "Valotus" +"Expression" = "Lauseke" +"Extend collection" = "Laajenna kokoelmaa" +"Extends \"{name}\": overridden values are marked" = "Laajentaa kokoelmaa ”{name}”: ohitetut arvot on merkitty" +"Eye" = "Silmä" +"F" = "F" +"Fade" = "Häivytys" +"False" = "Epätosi" +"Feb" = "helmik." +"Fetching from the server…" = "Haetaan palvelimelta…" +"FigCraft couldn't open its window: {error}" = "FigCraft ei voinut avata ikkunaansa: {error}" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft ei vielä tue näiden tiedostojen kaikkia ominaisuuksia. Huomautukset kertovat, mitä tuodusta kopiosta jätettiin pois tai muutettiin. Alkuperäinen tiedosto säilyy ennallaan." +"File" = "Tiedosto" +"File name" = "Tiedostonimi" +"File not found" = "Tiedostoa ei löydy" +"File published" = "Tiedosto julkaistu" +"Files aren't end-to-end encrypted: the server's operator can read them." = "Tiedostoja ei salata päästä päähän. Palvelimen ylläpitäjä voi lukea ne." +"Files nobody opens for {n} days are deleted." = { one = "Tiedostot, joita kukaan ei avaa {n} päivään, poistetaan.", other = "Tiedostot, joita kukaan ei avaa {n} päivään, poistetaan." } +"Fill" = "Täyttö" +"Fill (fr)" = "Täytä (fr)" +"Fill container" = "Täytä säilö" +"Fill window" = "Täytä ikkuna" +"Fills" = "Täytöt" +"Fill|sizing" = "Täytä" +"Filter and sort" = "Suodata ja lajittele" +"Find" = "Etsi" +"Find and replace" = "Etsi ja korvaa" +"Find layers" = "Etsi tasoja" +"First" = "Ensimmäinen" +"First commit" = "Ensimmäinen muutos" +"First on top" = "Ensimmäinen päällimmäisenä" +"Fit" = "Sovita" +"Fit device to window" = "Sovita laite ikkunaan" +"Fit to contents" = "Sovita sisältöön" +"Fit to window" = "Sovita ikkunaan" +"Fit width" = "Sovita leveyteen" +"Fixed" = "Kiinteä" +"Fixed gap" = "Kiinteä väli" +"Fixed size" = "Kiinteä koko" +"Flatten" = "Yhdistä vektorimuodoksi" +"Flip" = "Peilaa" +"Flip horizontal" = "Peilaa vaakasuunnassa" +"Flip text to the other side" = "Siirrä teksti toiselle puolelle" +"Flip vertical" = "Peilaa pystysuunnassa" +"Flip width profile" = "Peilaa leveysprofiili" +"Flow starting point" = "Kulun aloituspiste" +"Flow starting points" = "Kulkujen aloituspisteet" +"Flows" = "Kulut" +"Focus view" = "Kohdistusnäkymä" +"Fold autosaves" = "Tiivistä automaattiset tallennukset" +"Folder: click to collapse" = "Kansio: supista napsauttamalla" +"Folder: click to expand" = "Kansio: laajenna napsauttamalla" +"Follow" = "Seuraa" +"Follow {name}" = "Seuraa käyttäjää {name}" +"Following" = "Seurataan" +"Following {name} · {key} to stop" = "Seurataan käyttäjää {name} · lopeta painamalla {key}" +"Font" = "Fontti" +"Font not installed" = "Fonttia ei ole asennettu" +"Font size" = "Fonttikoko" +"Font style" = "Fonttityyli" +"Forget on this computer" = "Unohda tällä tietokoneella" +"Four screens of a trail app with components and a prototype flow" = "Retkeilysovelluksen neljä näkymää, komponentit ja prototyypin kulku" +"Fractions" = "Murtoluvut" +"Frame" = "Kehys" +"Frame selection" = "Kehystä valinta" +"Frame tools" = "Kehystyökalut" +"Frames" = "Kehykset" +"From the library \"{name}\": read-only" = "Kirjastosta ”{name}”: vain luku" +"From {name}" = "Lähteestä {name}" +"From {server}" = "Palvelimelta {server}" +"Frost" = "Huurre" +"Gap" = "Väli" +"Gap between columns" = "Sarakkeiden väli" +"Gap between items" = "Kohteiden väli" +"Gap between rows" = "Rivien väli" +"Getting started" = "Aloittaminen" +"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "Anna julkisen palvelimen osoite tai palvelimen osoite avaimineen (`figcraft-server key` tulostaa avaimen)." +"Glass" = "Lasi" +"Go to main component" = "Siirry pääkomponenttiin" +"Go to {name}" = "Siirry kohteeseen {name}" +"Grab bag" = "Sekalaiset" +"Gradient" = "Liukuväri" +"Gray" = "Harmaa" +"Green" = "Vihreä" +"Greyscale components and two example screens to sketch a flow with" = "Harmaasävykomponentit ja kaksi esimerkkinäkymää kulun luonnosteluun" +"Grid" = "Ruudukko" +"Grid ({size})" = "Ruudukko ({size})" +"Grid styles" = "Ruudukkotyylit" +"Grid {size}" = "Ruudukko {size}" +"Group" = "Ryhmä" +"Group selection" = "Ryhmittele valinta" +"Groups" = "Ryhmät" +"Gutter" = "Palstaväli" +"Hand tool" = "Käsityökalu" +"Hanging list" = "Riippuva luettelo" +"Hanging punctuation" = "Välimerkkien ulonnus" +"Hard light" = "Kova valo" +"Height" = "Korkeus" +"Help" = "Ohje" +"Help and resources" = "Ohje ja resurssit" +"Help page" = "Ohjesivu" +"Here: {names}" = "Paikalla: {names}" +"Hidden" = "Kätketty" +"Hide contents" = "Kätke sisältö" +"Hide from publishing" = "Jätä julkaisematta" +"Hide section contents" = "Kätke osion sisältö" +"Hide these tips (Getting started in the Help menu shows them again)" = "Kätke nämä vinkit (Ohje-valikon Aloittaminen näyttää ne uudelleen)" +"Hide when publishing" = "Kätke julkaistaessa" +"Highlight layers on hover" = "Korosta tasot osoitettaessa" +"Highlights" = "Huippuvalot" +"Hold Alt" = "Pidä Alt painettuna" +"Hold Alt+Z" = "Pidä Alt+Z painettuna" +"Hold Ctrl" = "Pidä Ctrl painettuna" +"Hold Z" = "Pidä Z painettuna" +"Hold ⌃ or ⌘" = "Pidä ⌃ tai ⌘ painettuna" +"Hold ⌥" = "Pidä ⌥ painettuna" +"Hold ⌥Z" = "Pidä ⌥Z painettuna" +"Home" = "Aloitus" +"Horizontal" = "Vaakasuunta" +"Horizontal align" = "Vaakatasaus" +"Horizontal hex" = "Vaakasuuntainen kuusikulmio" +"Horizontal padding" = "Vaakasuuntainen sisämarginaali" +"Horizontal spacing" = "Vaakavälistys" +"Hosted by {operator}" = "Ylläpitäjä: {operator}" +"Hover a layer to see its properties" = "Näytä tason ominaisuudet viemällä osoitin sen päälle" +"Hover other layers to measure the distance" = "Mittaa etäisyys viemällä osoitin muiden tasojen päälle" +"Hue" = "Sävy" +"Hug" = "Sovita sisältöön" +"Hug contents" = "Sovita sisältöön" +"Icon components on a 24 px grid with keylines and a size preview" = "Kuvakekomponentteja 24 px:n ruudukossa, apulinjat ja koon esikatselu" +"Icon set starter" = "Kuvakejoukon aloituspohja" +"If" = "Jos" +"If it keeps happening, please report it at {link} with this message." = "Jos ongelma jatkuu, ilmoita siitä osoitteessa {link} ja liitä tämä viesti mukaan." +"If {condition}" = "Jos {condition}" +"Ignore auto layout (absolute position)" = "Ohita automaattinen asettelu (absoluuttinen sijainti)" +"Image" = "Kuva" +"Images" = "Kuvat" +"Import" = "Tuo" +"Import path" = "Tuontipolku" +"Import plugin from manifest" = "Tuo liitännäinen manifestista" +"Import plugin from manifest…" = "Tuo liitännäinen manifestista…" +"Import report" = "Tuontiraportti" +"Import variables" = "Tuo muuttujat" +"Imported document" = "Tuotu asiakirja" +"Imported {name}" = "Tuotu: {name}" +"Imported {n} new variables, updated {updated}" = { one = "Tuotu {n} uusi muuttuja, päivitetty {updated}", other = "Tuotu {n} uutta muuttujaa, päivitetty {updated}" } +"Import…" = "Tuo…" +"In an instance, this comes from the main component" = "Ilmentymässä tämä arvo tulee pääkomponentista" +"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)." = "Selaimessa voit liittyä työpöytäsovelluksen isännöimiin istuntoihin ja FigCraft-palvelimen tiedostoihin. https-sivu tavoittaa muut tietokoneet vain wss://-linkeillä (TLS:n takana oleva palvelin)." +"Include bounding box" = "Sisällytä rajauslaatikko" +"Include id attribute" = "Sisällytä id-määrite" +"Included" = "Sisällytetty" +"Included in layout" = "Sisällytetty asetteluun" +"Individual corners" = "Kulmat erikseen" +"Individual strokes" = "Reunaviivat erikseen" +"Ink" = "Muste" +"Inner shadow" = "Sisävarjo" +"Inside" = "Sisäpuolella" +"Inspect" = "Tarkastele" +"Inspect the layer under the pointer: size, colours, typography" = "Tarkastele osoittimen alla olevaa tasoa: koko, värit ja typografia" +"Install" = "Asenna" +"Install plugin" = "Asenna liitännäinen" +"Installed" = "Asennettu" +"Installed {name}" = "Asennettu: {name}" +"Installing…" = "Asennetaan…" +"Instance" = "Ilmentymä" +"Instance properties" = "Ilmentymän ominaisuudet" +"Instance swap" = "Ilmentymän vaihto" +"Instance swap (no other component)" = "Ilmentymän vaihto (ei muuta komponenttia)" +"Instances" = "Ilmentymät" +"Instant" = "Välitön" +"Intensity" = "Vahvuus" +"Interaction" = "Vuorovaikutus" +"Interactions" = "Vuorovaikutukset" +"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Käyttöliittymien suunnittelu ja prototyypit. Ilmainen avoimen lähdekoodin suunnittelutyökalu, joka on toteutettu riippumattomasti alusta alkaen Rustilla. Osa ArtCraft-sovellusperhettä." +"Intersect" = "Leikkaus" +"Intersects" = "Leikkaa" +"Invalid color" = "Virheellinen väri" +"Invite link" = "Kutsulinkki" +"Invite link copied" = "Kutsulinkki kopioitu" +"It asks permission to:" = "Se pyytää oikeutta:" +"Jan" = "tammik." +"Join" = "Liity" +"Join code copied" = "Liittymiskoodi kopioitu" +"Join code or invite link" = "Liittymiskoodi tai kutsulinkki" +"Join live session" = "Liity reaaliaikaiseen istuntoon" +"Join us on Discord" = "Liity yhteisöömme Discordissa" +"Join with a code" = "Liity koodilla" +"Joined" = "Liitytty" +"Joined {file}" = "Liitytty tiedostoon {file}" +"Joining…" = "Liitytään…" +"Join|stroke" = "Liitos" +"Jul" = "heinäk." +"Jun" = "kesäk." +"Just now" = "Juuri nyt" +"Justify" = "Tasaa molemmat reunat" +"Keep appearance" = "Säilytä ulkoasu" +"Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Säilytä ulkoasu muuntaa kaikki väriarvot profiiliin {profile}, jotta värit näyttävät samoilta. Säilytä arvot jättää lukuarvot ennalleen, jolloin värit muuttuvat." +"Keep tool selected after use" = "Pidä työkalu valittuna käytön jälkeen" +"Keep values" = "Säilytä arvot" +"Key/Gamepad" = "Näppäin/peliohjain" +"Key/Gamepad {key}" = "Näppäin/peliohjain {key}" +"Keyboard shortcuts" = "Pikanäppäimet" +"Kits" = "Paketit" +"Landing page" = "Laskeutumissivu" +"Language" = "Kieli" +"Lasso" = "Lasso" +"Last" = "Viimeinen" +"Last change: {note}" = "Viimeisin muutos: {note}" +"Last commit" = "Viimeisin muutos" +"Last modified" = "Viimeksi muokattu" +"Last on top" = "Viimeinen päällimmäisenä" +"Layer blur" = "Tason sumennus" +"Layer name" = "Tason nimi" +"Layers" = "Tasot" +"Layers in an instance take their position from the main component" = "Ilmentymän tasot saavat sijaintinsa pääkomponentista" +"Layout" = "Asettelu" +"Layout grid" = "Asetteluruudukko" +"Layout grid settings" = "Asetteluruudukon asetukset" +"Layout grids" = "Asetteluruudukot" +"Leave session" = "Poistu istunnosta" +"Leaves" = "Lehdet" +"Left" = "Vasen" +"Left & right" = "Vasen ja oikea" +"Left side" = "Vasen puoli" +"Left side width" = "Vasemman puolen leveys" +"Letter case" = "Kirjainkoko" +"Letter spacing" = "Merkkiväli" +"Libraries" = "Kirjastot" +"Libraries in this file" = "Tämän tiedoston kirjastot" +"Library updates available" = "Kirjastopäivityksiä saatavilla" +"Library: click to collapse" = "Kirjasto: supista napsauttamalla" +"Library: click to expand" = "Kirjasto: laajenna napsauttamalla" +"Library: its variables are read-only here; edit them in the library file and publish it" = "Kirjasto: sen muuttujat ovat tässä vain luettavissa. Muokkaa niitä kirjastotiedostossa ja julkaise se." +"Light" = "Vaalea" +"Lighten" = "Vaalenna" +"Light|glass" = "Valo" +"Line" = "Viiva" +"Line arrow" = "Viivanuoli" +"Line delta" = "Rivimuutos" +"Line height" = "Rivikorkeus" +"Linear" = "Lineaarinen" +"Lines" = "Viivat" +"Lines +/−" = "Rivit +/−" +"Lines added" = "Lisätyt rivit" +"Lines deleted" = "Poistetut rivit" +"Link" = "Linkki" +"Link copied" = "Linkki kopioitu" +"Link docs, code or tickets for developers." = "Linkitä dokumentaatiota, koodia tai tehtäviä kehittäjille." +"Link frames on the Prototype tab, then present the flow" = "Linkitä kehykset Prototyyppi-välilehdellä ja esitä sitten kulku" +"Link this component to its code component: instances then show your real snippet with their values." = "Yhdistä tämä komponentti sen koodikomponenttiin. Ilmentymät näyttävät sitten todellisen koodikatkelmasi omilla arvoillaan." +"List spacing" = "Luettelon välistys" +"List style" = "Luettelotyyli" +"Live session" = "Reaaliaikainen istunto" +"Live · {people}" = "Reaaliaikainen · {people}" +"Local components" = "Paikalliset komponentit" +"Local network" = "Paikallisverkko" +"Local styles" = "Paikalliset tyylit" +"Local variables" = "Paikalliset muuttujat" +"Lock/Unlock" = "Lukitse/avaa lukitus" +"Locked" = "Lukittu" +"Lowercase" = "Pienet kirjaimet" +"Luminance" = "Luminanssi" +"Luminosity" = "Valoisuus" +"Main" = "Päähaara" +"Main component deleted" = "Pääkomponentti poistettu" +"Main menu" = "Päävalikko" +"Make a component" = "Luo komponentti" +"Make slot" = "Luo sisältöpaikka" +"Mar" = "maalisk." +"Margin" = "Marginaali" +"Mark all as read" = "Merkitse kaikki luetuiksi" +"Mark as resolved" = "Merkitse ratkaistuksi" +"Mark as unread" = "Merkitse lukemattomaksi" +"Mark as unresolved" = "Merkitse ratkaisemattomaksi" +"Mask" = "Maski" +"Mass · Stiffness · Damping" = "Massa · jäykkyys · vaimennus" +"Match (empty: the whole name)" = "Etsittävä (tyhjä: koko nimi)" +"Max H" = "Enimmäiskorkeus" +"Max W" = "Enimmäisleveys" +"Max lines" = "Rivien enimmäismäärä" +"Maximize" = "Suurenna" +"May" = "toukok." +"Measure to the selection" = "Mittaa valintaan" +"Merge into main" = "Yhdistä päähaaraan" +"Merge regions" = "Yhdistä alueet" +"Merge the branch into the main file (one undo step) and archive it" = "Yhdistä haara päätiedostoon (yksi kumottava toiminto) ja arkistoi se" +"Merged" = "Yhdistetty" +"Merged PRs" = "Yhdistetyt PR:t" +"Min H" = "Vähimmäiskorkeus" +"Min W" = "Vähimmäisleveys" +"Minimize" = "Pienennä" +"Mirror angle" = "Peilaa kulma" +"Mirror angle and length" = "Peilaa kulma ja pituus" +"Mirroring" = "Peilaus" +"Missing" = "Puuttuu" +"Missing fonts" = "Puuttuvat fontit" +"Missing layer" = "Puuttuva taso" +"Missing style" = "Puuttuva tyyli" +"Missing variable" = "Puuttuva muuttuja" +"Miter" = "Jiiri" +"Miter angle" = "Jiirikulma" +"Mixed" = "Useita arvoja" +"Mixed fonts" = "Useita fontteja" +"Mixed interactions: click + to replace them" = "Vaihtelevia vuorovaikutuksia: korvaa napsauttamalla +" +"Mobile app" = "Mobiilisovellus" +"Mode" = "Tila" +"Model" = "Malli" +"Models" = "Mallit" +"Mono" = "Yksivärinen" +"More" = "Lisää" +"More instance actions" = "Lisää ilmentymän toimintoja" +"Mouse down" = "Hiiren painallus" +"Mouse enter" = "Osoitin saapuu" +"Mouse leave" = "Osoitin poistuu" +"Mouse up" = "Hiiren vapautus" +"Move" = "Siirrä" +"Move along the toolbar or the layer rows" = "Siirry työkalurivillä tai tasoriveillä" +"Move column left" = "Siirrä sarake vasemmalle" +"Move column right" = "Siirrä sarake oikealle" +"Move down" = "Siirrä alas" +"Move in" = "Siirry sisään" +"Move out" = "Siirry ulos" +"Move tools" = "Siirtotyökalut" +"Move up" = "Siirrä ylös" +"Moved to {name}" = "Siirretty kohteeseen {name}" +"Multi" = "Monivärinen" +"Multiply" = "Kerro" +"Muted" = "Hillitty" +"Name" = "Nimi" +"Name (A–Z)" = "Nimi (A–Z)" +"Named instance" = "Nimetty ilmentymä" +"Navigate to" = "Siirry kohteeseen" +"Nested instance" = "Sisäkkäinen ilmentymä" +"New design file" = "Uusi suunnittelutiedosto" +"Newest first" = "Uusimmat ensin" +"Next frame" = "Seuraava kehys" +"Next or previous control in the focused region" = "Seuraava tai edellinen säädin kohdistetulla alueella" +"Next ready for dev" = "Seuraava kehitysvalmis" +"Next size option" = "Seuraava kokovaihtoehto" +"No assets: no export settings, icons or images. Add export settings in the Design tab." = "Ei resursseja: ei vientiasetuksia, kuvakkeita tai kuvia. Lisää vientiasetuksia Suunnittelu-välilehdellä." +"No boolean variables fit here." = "Tähän ei sovi totuusarvomuuttujia." +"No boolean variables yet. Create them in Local variables." = "Ei vielä totuusarvomuuttujia. Luo niitä Paikalliset muuttujat -näkymässä." +"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Ei haaroja. Haara on tiedoston kopio, jossa voi kokeilla muutoksia ja sitten tarkistaa ja yhdistää ne." +"No category" = "Ei luokkaa" +"No changes" = "Ei muutoksia" +"No color styles" = "Ei värityylejä" +"No colour variables fit here." = "Tähän ei sovi värimuuttujia." +"No colour variables yet. Create them in Local variables." = "Ei vielä värimuuttujia. Luo niitä Paikalliset muuttujat -näkymässä." +"No comments yet" = "Ei vielä kommentteja" +"No compatibility warnings reported" = "Yhteensopivuusvaroituksia ei ilmoitettu" +"No components yet. Select layers and press {keys}." = "Ei vielä komponentteja. Valitse tasoja ja paina {keys}." +"No conflicts." = "Ei ristiriitoja." +"No contributor data was built into this copy." = "Tähän versioon ei ole sisällytetty tekijätietoja." +"No device" = "Ei laitetta" +"No files or templates match your search" = "Hakua vastaavia tiedostoja tai malleja ei löydy" +"No fonts match" = "Vastaavia fontteja ei löydy" +"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Liittymiskoodia ei ole (palvelin on liittymiskoodeja vanhempi tai tätä osoitetta ei voi sisällyttää koodiin). Jaa linkki sen sijaan." +"No layer changes to show." = "Ei näytettäviä tasomuutoksia." +"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Ei vielä kirjastoja. Lisää kirjastona julkaistu .figcraft-tiedosto tai liitä tiimikirjaston linkki." +"No list" = "Ei luetteloa" +"No matching comments" = "Vastaavia kommentteja ei löydy" +"No matching components" = "Vastaavia komponentteja ei löydy" +"No matching layers" = "Vastaavia tasoja ei löydy" +"No matching styles" = "Vastaavia tyylejä ei löydy" +"No matching variables" = "Vastaavia muuttujia ei löydy" +"No mirroring" = "Ei peilausta" +"No model credits were built into this copy." = "Tähän versioon ei ole sisällytetty mallien tekijätietoja." +"No number variables fit here." = "Tähän ei sovi lukumuuttujia." +"No number variables yet. Create them in Local variables." = "Ei vielä lukumuuttujia. Luo niitä Paikalliset muuttujat -näkymässä." +"No other pages" = "Ei muita sivuja" +"No paints" = "Ei täyttöjä" +"No plugins on {server}." = "Palvelimella {server} ei ole liitännäisiä." +"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Ei vielä liitännäisiä. Tuo kehitysliitännäinen sen figcraft-plugin.json-tiedostosta." +"No preferred components" = "Ei ensisijaisia komponentteja" +"No preset" = "Ei esiasetusta" +"No saved versions yet. Save a version to mark a point in your work you can come back to." = "Ei vielä tallennettuja versioita. Tallenna versio, jotta voit palata tähän työvaiheeseen myöhemmin." +"No scrolling" = "Ei vieritystä" +"No shortcuts match" = "Vastaavia pikanäppäimiä ei löydy" +"No status" = "Ei tilaa" +"No string variables fit here." = "Tähän ei sovi merkkijonomuuttujia." +"No string variables yet. Create them in Local variables." = "Ei vielä merkkijonomuuttujia. Luo niitä Paikalliset muuttujat -näkymässä." +"No styles yet." = "Ei vielä tyylejä." +"No styles yet. Click + to create one from the selection." = "Ei vielä tyylejä. Luo tyyli valinnasta napsauttamalla +." +"No templates match your search" = "Hakua vastaavia malleja ei löydy" +"No text styles yet. Click + to create one from the selected text." = "Ei vielä tekstityylejä. Luo tyyli valitusta tekstistä napsauttamalla +." +"No variables fit here." = "Tähän ei sovi muuttujia." +"No variables yet. Create them in Local variables." = "Ei vielä muuttujia. Luo niitä Paikalliset muuttujat -näkymässä." +"Nobody else is here yet" = "Kukaan muu ei ole vielä paikalla" +"Noise" = "Kohina" +"None" = "Ei mitään" +"None." = "Ei mitään." +"Normal" = "Normaali" +"Not bound" = "Ei sidottu" +"Not in a live session" = "Ei reaaliaikaisessa istunnossa" +"Not in this font" = "Ei tässä fontissa" +"Not properties, kept as written: {names}" = "Eivät ole ominaisuuksia, säilytetään sellaisinaan: {names}" +"Not read yet" = "Ei vielä luettu" +"Not shared" = "Ei jaettu" +"Note {n}" = "Huomautus {n}" +"Nothing changed since this was marked ready for dev." = "Mikään ei ole muuttunut kehitysvalmiiksi merkitsemisen jälkeen." +"Nothing here yet. Set a frame's status to list it." = "Ei vielä sisältöä. Lisää kehys luetteloon asettamalla sen tila." +"Nothing online yet. Use Share on a file to put it online and it is listed here." = "Ei vielä verkkotiedostoja. Julkaise tiedosto verkkoon Jaa-toiminnolla, niin se näkyy tässä." +"Nothing to export" = "Ei vietävää" +"Nov" = "marrask." +"Now" = "Nyt" +"Nudge (Shift: big nudge)" = "Siirrä askel (Shift: suuri askel)" +"Nudge amount" = "Siirtoaskeleen koko" +"Number" = "Luku" +"Number ↑" = "Luku ↑" +"Number ↓" = "Luku ↓" +"Numbered list" = "Numeroitu luettelo" +"Object" = "Objekti" +"Oct" = "lokak." +"Off" = "Ei käytössä" +"Offered first when filling the slot (any content is allowed)" = "Ehdotetaan ensin sisältöpaikkaa täytettäessä (kaikki sisältö sallitaan)" +"Offered first when swapping the instance (any component is allowed)" = "Ehdotetaan ensin ilmentymää vaihdettaessa (kaikki komponentit sallitaan)" +"Offset" = "Siirtymä" +"Oldest first" = "Vanhimmat ensin" +"On click" = "Napsautettaessa" +"On drag" = "Vedettäessä" +"On this page" = "Tällä sivulla" +"One announcement in five common sizes: square, portrait, story, link and banner" = "Yksi ilmoitus viidessä tavallisessa koossa: neliö, pysty, tarina, linkki ja banneri" +"Online" = "Verkossa" +"Online files" = "Verkkotiedostot" +"Online sharing is off: files are shared on the local network only." = "Verkkojakaminen on pois käytöstä. Tiedostoja jaetaan vain lähiverkossa." +"Only the first changed layers are listed." = "Vain ensimmäiset muuttuneet tasot näytetään." +"Only the first {n} changed layers are listed." = "Vain ensimmäiset {n} muuttunutta tasoa näytetään." +"Only your threads" = "Vain omat keskustelut" +"Opacity" = "Peittävyys" +"Opacity & blend" = "Peittävyys ja sekoitus" +"Opacity 10%…90%, 0: 100%" = "Peittävyys 10 %…90 %, 0: 100 %" +"Open" = "Avaa" +"Open .figcraft, .svg or images" = "Avaa .figcraft-, .svg- tai kuvatiedostoja" +"Open branch" = "Avaa haara" +"Open file" = "Avaa tiedosto" +"Open files" = "Avaa tiedostoja" +"Open in focus view" = "Avaa kohdistusnäkymässä" +"Open in new window" = "Avaa uudessa ikkunassa" +"Open invite links in FigCraft" = "Avaa kutsulinkit FigCraftissa" +"Open link" = "Avaa linkki" +"Open link {url}" = "Avaa linkki {url}" +"Open overlay" = "Avaa peittokuva" +"Open prototype preview" = "Avaa prototyypin esikatselu" +"Open variables ({count})" = "Avaa muuttujat ({count})" +"Opened as a new document" = "Avattu uutena asiakirjana" +"Opened “{name}” in a new tab" = "”{name}” avattu uudessa välilehdessä" +"Opening the file…" = "Avataan tiedostoa…" +"Opening {n} files as new files" = { one = "Avataan uutena tiedostona", other = "Avataan uusia tiedostoja: {n}" } +"Open|comments" = "Avoin" +"Options" = "Asetukset" +"Orange" = "Oranssi" +"Order" = "Järjestys" +"Other" = "Muu" +"Other apps may not open every .fig file exported here." = "Muut sovellukset eivät välttämättä avaa kaikkia tässä vietyjä .fig-tiedostoja." +"Others lose access and keep their copy; the file stays on the server for you" = "Muiden käyttöoikeus päättyy, ja he säilyttävät kopionsa. Tiedosto jää palvelimelle käyttöösi." +"Others type this under Share ▸ Join with a code" = "Muut kirjoittavat tämän kohtaan Jaa ▸ Liity koodilla" +"Outline stroke" = "Muunna reunaviiva poluksi" +"Outline text" = "Muunna teksti poluiksi" +"Outlines" = "Ääriviivat" +"Outside" = "Ulkopuolella" +"Overflow" = "Ylivuoto" +"Overlay" = "Peittokuva" +"Overlay position" = "Peittokuvan sijainti" +"Overridden here (the collection it extends has another value)" = "Ohitettu tässä (laajennetussa kokoelmassa on toinen arvo)" +"Overrides" = "Ohitukset" +"Owner token" = "Omistajan tunniste" +"Owner token copied" = "Omistajan tunniste kopioitu" +"PDF print options" = "PDF-tulostusasetukset" +"PDF print settings" = "PDF-tulostusasetukset" +"Padding" = "Sisämarginaali" +"Page" = "Sivu" +"Page deleted" = "Sivu poistettu" +"Pages" = "Sivut" +"Pages added to the document" = "Sivut lisätty asiakirjaan" +"Pages: {pages}" = "Sivut: {pages}" +"Paint bucket" = "Maalipurkki" +"Pan the canvas" = "Siirrä piirtoaluetta" +"Pan with nothing selected (Shift: farther)" = "Siirrä näkymää ilman valintaa (Shift: pidemmälle)" +"Panels" = "Paneelit" +"Paper" = "Paperi" +"Paragraph indent" = "Kappaleen sisennys" +"Paragraph indent applies to left-aligned text" = "Kappaleen sisennys koskee vasemmalle tasattua tekstiä" +"Paragraph spacing" = "Kappaleväli" +"Pass through" = "Läpivienti" +"Paste a link (https://…)" = "Liitä linkki (https://…)" +"Paste a team library link (a view link)" = "Liitä tiimikirjaston linkki (katselulinkki)" +"Paste here" = "Liitä tähän" +"Paste to replace" = "Liitä korvaten" +"Pasted design layers; {n} kinds of content were left out" = { one = "Suunnittelutasot liitetty; {n} sisältötyyppi jätettiin pois", other = "Suunnittelutasot liitetty; {n} sisältötyyppiä jätettiin pois" } +"Pattern" = "Kuvio" +"Pattern alignment" = "Kuvion tasaus" +"Pattern scale" = "Kuvion skaalaus" +"Pattern source" = "Kuvion lähde" +"Pen" = "Kynä" +"Pencil" = "Lyijykynä" +"People in this file" = "Tämän tiedoston käyttäjät" +"People join from anywhere with a code. The file is kept on {server} while you share it." = "Käyttäjät voivat liittyä koodilla mistä tahansa. Tiedosto säilytetään palvelimella {server} jakamisen ajan." +"People on your network join with a code. Nothing goes through a server." = "Lähiverkkosi käyttäjät liittyvät koodilla. Mitään ei kulje palvelimen kautta." +"Phone" = "Puhelin" +"Pick color" = "Poimi väri" +"Pick color (I)" = "Poimi väri (I)" +"Pick one font family first" = "Valitse ensin yksi fonttiperhe" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "Valitse Kehys-työkalu ja vedä piirtoalueella tai valitse laitteen koko" +"Pin a property" = "Kiinnitä ominaisuus" +"Pinch" = "Nipistys" +"Pixel grid" = "Pikseliruudukko" +"Pixel preview 1x" = "Pikseliesikatselu 1x" +"Pixel preview 2x" = "Pikseliesikatselu 2x" +"Place a shape or frame (its tool chosen)" = "Sijoita muoto tai kehys (sen työkalu valittuna)" +"Place automatically" = "Sijoita automaattisesti" +"Place image" = "Sijoita kuva" +"Placed on the current page" = "Sijoitettu nykyiselle sivulle" +"Playground" = "Kokeilualue" +"Plugin" = "Liitännäinen" +"Plugins" = "Liitännäiset" +"Plugins & widgets" = "Liitännäiset ja pienoisohjelmat" +"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Liitännäiset toimivat FigCraftin eristetyssä ympäristössä ilman tiedostoja, verkkoa tai kelloa. Asenna vain luottamiasi liitännäisiä." +"Plus darker" = "Tummempi summa" +"Plus lighter" = "Vaaleampi summa" +"Point" = "Piste" +"Points" = "Pisteet" +"Polygon" = "Monikulmio" +"Polygons" = "Monikulmiot" +"Port" = "Portti" +"Position" = "Sijainti" +"Post" = "Lähetä" +"Preferences" = "Asetukset" +"Preferred" = "Ensisijainen" +"Present" = "Esitä" +"Present or preview" = "Esitä tai esikatsele" +"Present this flow" = "Esitä tämä kulku" +"Presentation" = "Esitys" +"Presentation slides" = "Esitysdiat" +"Presenting: comments" = "Esitys: kommentit" +"Presenting: cycle the size options" = "Esitys: kierrä kokovaihtoehtoja" +"Presenting: full screen" = "Esitys: koko näyttö" +"Presenting: next frame" = "Esitys: seuraava kehys" +"Presenting: previous frame" = "Esitys: edellinen kehys" +"Presenting: restart" = "Esitys: aloita uudelleen" +"Preserve scroll position" = "Säilytä vierityksen sijainti" +"Presets" = "Esiasetukset" +"Press a key…" = "Paina näppäintä…" +"Preview" = "Esikatselu" +"Preview this flow" = "Esikatsele tätä kulkua" +"Preview this frame" = "Esikatsele tätä kehystä" +"Previous frame" = "Edellinen kehys" +"Previous ready for dev" = "Edellinen kehitysvalmis" +"Progressive" = "Asteittainen" +"Properties" = "Ominaisuudet" +"Prototype" = "Prototyyppi" +"Prototype preview" = "Prototyypin esikatselu" +"Prototype settings" = "Prototyypin asetukset" +"Publish" = "Julkaise" +"Publish changes" = "Julkaise muutokset" +"Publish to another server…" = "Julkaise toiselle palvelimelle…" +"Publish to server" = "Julkaise palvelimelle" +"Published version {v}" = "Julkaistu versio {v}" +"Published version {v} to the server" = "Versio {v} julkaistu palvelimelle" +"Published version {v}: save the file so other files can use it" = "Versio {v} julkaistu: tallenna tiedosto, jotta muut tiedostot voivat käyttää sitä" +"Published · {people}" = "Julkaistu · {people}" +"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Palvelimelle julkaiseminen vaatii työpöytäsovelluksen. Selaimessa voit liittyä palvelimen tiedostoihin." +"Publishing with the server's key." = "Julkaistaan palvelimen avaimella." +"Publishing…" = "Julkaistaan…" +"Push" = "Työntö" +"Push changes to main component" = "Siirrä muutokset pääkomponenttiin" +"Put text on path" = "Sijoita teksti polulle" +"R" = "R" +"Radial" = "Säteittäinen" +"Radius" = "Säde" +"Ratio" = "Suhde" +"Reading the library…" = "Luetaan kirjastoa…" +"Ready for dev" = "Kehitysvalmis" +"Real name" = "Oikea nimi" +"Recent" = "Viimeisimmät" +"Recent files" = "Viimeksi avatut tiedostot" +"Recents" = "Viimeisimmät" +"Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them" = { one = "Tallentamaton tiedosto palautettiin sovelluksen sulkeuduttua odottamatta. Tallenna se säilyttääksesi sen.", other = "Palautettu {n} tallentamatonta tiedostoa sovelluksen sulkeuduttua odottamatta. Tallenna ne säilyttääksesi ne." } +"Rectangle" = "Suorakulmio" +"Rectangles" = "Suorakulmiot" +"Red" = "Punainen" +"Reduce motion" = "Vähennä liikettä" +"Refraction" = "Taittuminen" +"Regular expression" = "Säännöllinen lauseke" +"Remove" = "Poista" +"Remove all" = "Poista kaikki" +"Remove annotation" = "Poista huomautus" +"Remove auto layout" = "Poista automaattinen asettelu" +"Remove dev resource" = "Poista kehittäjäresurssi" +"Remove export setting" = "Poista vientiasetus" +"Remove from recents" = "Poista viimeisimmistä" +"Remove from server" = "Poista palvelimelta" +"Remove from server…" = "Poista palvelimelta…" +"Remove guide" = "Poista apuviiva" +"Remove interaction" = "Poista vuorovaikutus" +"Remove library from this file" = "Poista kirjasto tästä tiedostosta" +"Remove min and max" = "Poista vähimmäis- ja enimmäisrajat" +"Remove min and max height" = "Poista korkeuden vähimmäis- ja enimmäisrajat" +"Remove min and max width" = "Poista leveyden vähimmäis- ja enimmäisrajat" +"Remove regions" = "Poista alueet" +"Remove slot" = "Poista sisältöpaikka" +"Remove starting point" = "Poista aloituspiste" +"Remove stop" = "Poista liukuvärin piste" +"Remove text from path" = "Poista teksti polulta" +"Remove the else and its actions" = "Poista muuten-haara ja sen toiminnot" +"Remove this condition and its actions" = "Poista tämä ehto ja sen toiminnot" +"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Poistetaanko tämä tiedosto palvelimelta? Sitä ei voi enää jakaa tai avata sieltä. Oma kopiosi pysyy auki tässä." +"Remove {name} from session" = "Poista {name} istunnosta" +"Remove {name} from {server}?" = "Poistetaanko {name} palvelimelta {server}?" +"Removed" = "Poistettu" +"Removed from the server" = "Poistettu palvelimelta" +"Removed since it was marked" = "Poistettu merkitsemisen jälkeen" +"Rename" = "Nimeä uudelleen" +"Rename duplicated layers" = "Nimeä monistetut tasot uudelleen" +"Rename folder" = "Nimeä kansio uudelleen" +"Rename group" = "Nimeä ryhmä uudelleen" +"Rename layers" = "Nimeä tasot uudelleen" +"Rename mode" = "Nimeä tila uudelleen" +"Rename on the server" = "Nimeä uudelleen palvelimella" +"Rename on the server…" = "Nimeä uudelleen palvelimella…" +"Rename page" = "Nimeä sivu uudelleen" +"Rename to" = "Uusi nimi" +"Replace all" = "Korvaa kaikki" +"Replace mixed export settings" = "Korvaa vaihtelevat vientiasetukset" +"Replace with" = "Korvaa kohteella" +"Replace with a new interaction" = "Korvaa uudella vuorovaikutuksella" +"Reply" = "Vastaa" +"Report" = "Ilmoita" +"Report this file…" = "Ilmoita tästä tiedostosta…" +"Reset" = "Palauta" +"Reset adjustments" = "Palauta säädöt" +"Reset all changes" = "Palauta kaikki muutokset" +"Reset all overrides on this layer" = "Palauta kaikki tämän tason ohitukset" +"Reset overrides" = "Palauta ohitukset" +"Reset slot" = "Palauta sisältöpaikka" +"Reset this override" = "Palauta tämä ohitus" +"Reset to style" = "Palauta tyylin arvo" +"Reset to the inherited value" = "Palauta peritty arvo" +"Resize by the nudge amount" = "Muuta kokoa siirtoaskeleen verran" +"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Sovita kehysten koko laitteen näyttöön (tai ikkunaan ilman laitetta). Rajoitteet ja automaattinen asettelu mukautuvat." +"Resize to fit" = "Sovita koko" +"Resizing" = "Koon muuttaminen" +"Resolve" = "Ratkaise" +"Resolve change" = "Ratkaise muutos" +"Resolved" = "Ratkaistu" +"Resources" = "Resurssit" +"Responsive" = "Mukautuva" +"Restart" = "Aloita uudelleen" +"Restore" = "Palauta" +"Restore main component" = "Palauta pääkomponentti" +"Restore this version" = "Palauta tämä versio" +"Restored “{name}”" = "”{name}” palautettu" +"Reverse the order" = "Käännä järjestys" +"Reversed triangle" = "Käänteinen kolmio" +"Review and merge" = "Tarkista ja yhdistä" +"Ribbon" = "Valintanauha" +"Right" = "Oikea" +"Right side" = "Oikea puoli" +"Right side width" = "Oikean puolen leveys" +"Rotate 90°" = "Kierrä 90°" +"Rotate 90° right" = "Kierrä 90° oikealle" +"Rotate to landscape" = "Käännä vaakasuuntaan" +"Rotate to portrait" = "Käännä pystysuuntaan" +"Rotation" = "Kierto" +"Rotation jitter" = "Kierron vaihtelu" +"Round" = "Pyöreä" +"Rounded" = "Pyöristetty" +"Row" = "Rivi" +"Row gap" = "Rivien väli" +"Row sizes" = "Rivien koot" +"Row span" = "Riviulottuvuus" +"Rows" = "Rivit" +"Rows {count}" = "Rivejä: {count}" +"Rulers" = "Viivaimet" +"Run" = "Suorita" +"Run {label}" = "Suorita {label}" +"SHA-256: {sha}…" = "SHA-256: {sha}…" +"Same as layer" = "Sama kuin tasolla" +"Saturation" = "Kylläisyys" +"Save" = "Tallenna" +"Save as" = "Tallenna nimellä" +"Save as preset…" = "Tallenna esiasetuksena…" +"Save files automatically" = "Tallenna tiedostot automaattisesti" +"Save version {key}" = "Tallenna versio {key}" +"Saved" = "Tallennettu" +"Saved {name}" = "Tallennettu: {name}" +"Say something" = "Kirjoita viesti" +"Scale" = "Skaalaus" +"Scatter" = "Hajonta" +"Scoping: where pickers offer it" = "Käyttöalue: missä valitsimet tarjoavat tätä" +"Screen" = "Rasteri" +"Scroll behavior" = "Vierityksen toiminta" +"Scroll to" = "Vieritä kohteeseen" +"Scroll to layer" = "Vieritä tasoon" +"Scroll with parent" = "Vieritä emotason mukana" +"Search" = "Etsi" +"Search actions, layers and tools" = "Etsi toimintoja, tasoja ja työkaluja" +"Search assets" = "Etsi resursseja" +"Search comments" = "Etsi kommentteja" +"Search files and templates" = "Etsi tiedostoja ja malleja" +"Search fonts" = "Etsi fontteja" +"Search layers" = "Etsi tasoja" +"Search plugins" = "Etsi liitännäisiä" +"Search results" = "Hakutulokset" +"Search shortcuts" = "Etsi pikanäppäimiä" +"Search templates" = "Etsi malleja" +"Section" = "Osio" +"Section color" = "Osion väri" +"Sections" = "Osiot" +"See all templates" = "Näytä kaikki mallit" +"Select a frame and add a starting point to present it as a flow." = "Valitse kehys ja lisää aloituspiste, jotta voit esittää sen kulkuna." +"Select a frame to preview it" = "Valitse kehys esikatseltavaksi" +"Select a layer to see its properties and code" = "Valitse taso, jotta näet sen ominaisuudet ja koodin" +"Select all" = "Valitse kaikki" +"Select all with same" = "Valitse kaikki samalla ominaisuudella" +"Select layer" = "Valitse taso" +"Select layers and they space themselves as their content changes" = "Valitse tasoja, niin niiden välit mukautuvat sisällön muuttuessa" +"Select layers to apply the style to" = "Valitse tasot, joihin tyyliä käytetään" +"Select layers with this color" = "Valitse tämänväriset tasot" +"Select overlapping shapes" = "Valitse päällekkäiset muodot" +"Select points to edit their position, radius and mirroring" = "Valitse pisteitä muokataksesi niiden sijaintia, sädettä ja peilausta" +"Select slot" = "Valitse sisältöpaikka" +"Selection" = "Valinta" +"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Valintalaatikko, {w} × {h} kohdassa {x}, {y}: nuolet siirtävät, Shift+nuolet tai {cmd}+nuolet muuttavat kokoa, Enter valitsee, Escape peruuttaa" +"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Valintalaatikko: nuolet siirtävät, Shift+nuolet tai {cmd}+nuolet muuttavat kokoa, Enter valitsee, Escape peruuttaa" +"Selection colors" = "Valinnan värit" +"Send backward" = "Vie taaksepäin" +"Send report" = "Lähetä ilmoitus" +"Send to back" = "Vie takimmaiseksi" +"Sep" = "syysk." +"Server address" = "Palvelimen osoite" +"Server address (ws://host:47900/)" = "Palvelimen osoite (ws://host:47900/)" +"Server for online sharing" = "Verkkojakamisen palvelin" +"Set as default" = "Aseta oletukseksi" +"Set variable" = "Aseta muuttuja" +"Set variable mode" = "Aseta muuttujan tila" +"Set variable {name}" = "Aseta muuttuja {name}" +"Set {name} to {value}" = "Aseta {name} arvoon {value}" +"Shadows" = "Varjot" +"Shape" = "Muoto" +"Shape builder" = "Muodonrakennin" +"Shape tools" = "Muototyökalut" +"Share" = "Jaa" +"Share this file" = "Jaa tämä tiedosto" +"Share with a code" = "Jaa koodilla" +"Share: live session, export" = "Jaa: reaaliaikainen istunto, vienti" +"Shared" = "Jaettu" +"Shared: copy its code from the file's menu" = "Jaettu: kopioi koodi tiedoston valikosta" +"Sharing a file needs the desktop app; here you can join one." = "Tiedoston jakaminen vaatii työpöytäsovelluksen. Tässä voit liittyä jaettuun tiedostoon." +"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Jaetaan lähiverkossa (verkkojakaminen on pois käytöstä: asetuksissa ei ole palvelinta)." +"Sharing on your network · {people}" = "Jaetaan lähiverkossasi · {people}" +"Sharing online · {people}" = "Jaetaan verkossa · {people}" +"Sharing stopped: the file stays on the server, open only to you" = "Jakaminen lopetettu: tiedosto jää palvelimelle vain sinun käyttöösi" +"Show" = "Näytä" +"Show all templates" = "Näytä kaikki mallit" +"Show as grid" = "Näytä ruudukkona" +"Show as list" = "Näytä luettelona" +"Show autosaves" = "Näytä automaattiset tallennukset" +"Show behind transparent areas" = "Näytä läpinäkyvien alueiden takana" +"Show comments (Shift+C)" = "Näytä kommentit (Shift+C)" +"Show device frame" = "Näytä laitteen kehys" +"Show dimensions on objects" = "Näytä objektien mitat" +"Show hotspot hints" = "Näytä aktiivisten kohtien vihjeet" +"Show in Explorer" = "Näytä Resurssienhallinnassa" +"Show in Finder" = "Näytä Finderissa" +"Show in folder" = "Näytä kansiossa" +"Show only this on the canvas" = "Näytä piirtoalueella vain tämä" +"Show open" = "Näytä avoimet" +"Show section contents" = "Näytä osion sisältö" +"Show this layer" = "Näytä tämä taso" +"Show usage" = "Näytä käyttö" +"Show/Hide" = "Näytä/kätke" +"Show/Hide UI" = "Näytä/kätke käyttöliittymä" +"Side by side" = "Vierekkäin" +"Sidebar" = "Sivupalkki" +"Simplify stroke" = "Yksinkertaista reunaviiva" +"Size" = "Koko" +"Size jitter" = "Koon vaihtelu" +"Skip ink" = "Väistä merkkien viivoja" +"Slashed zero" = "Viivallinen nolla" +"Slice" = "Viipale" +"Slices" = "Viipaleet" +"Slide in" = "Liu'u sisään" +"Slide out" = "Liu'u ulos" +"Slot" = "Sisältöpaikka" +"Slot content" = "Sisältöpaikan sisältö" +"Small capitals" = "Kapiteelit" +"Small caps" = "Kapiteelit" +"Smart animate" = "Älykäs animointi" +"Snap to pixel grid" = "Tartu pikseliruudukkoon" +"Social and slides" = "Some ja diat" +"Social media" = "Sosiaalinen media" +"Social post sizes" = "Somejulkaisujen koot" +"Soft light" = "Pehmeä valo" +"Solid" = "Yksivärinen" +"Some variants have the same property values: give each one a unique combination" = "Joillakin varianteilla on samat ominaisuusarvot. Anna kullekin yksilöllinen yhdistelmä." +"Sort" = "Lajittele" +"Sort by" = "Lajitteluperuste" +"Sort by date" = "Lajittele päivämäärän mukaan" +"Sort by page" = "Lajittele sivun mukaan" +"Sort by {column}" = "Lajittele sarakkeen {column} mukaan" +"Space around" = "Tila ympärillä" +"Space between" = "Tila välissä" +"Space drag" = "Space drag" +"Space evenly" = "Tasavälein" +"Space+drag" = "Space+drag" +"Spacing" = "Välistys" +"Spam, stolen work, abuse…" = "Roskaposti, varastettu työ, väärinkäyttö…" +"Splay" = "Harotus" +"Spotlight me" = "Nosta minut esiin" +"Spread" = "Leviäminen" +"Square" = "Neliö" +"Standard" = "Vakio" +"Standard ligatures" = "Tavalliset ligatuurit" +"Star" = "Tähti" +"Stars" = "Tähdet" +"Start" = "Alku" +"Start ascending from / stop descending at" = "Nousevan osan alku / laskevan osan loppu" +"Start from a blank canvas" = "Aloita tyhjältä piirtoalueelta" +"Start from a template" = "Aloita mallista" +"Start on path" = "Aloituskohta polulla" +"Start on the Home screen" = "Aloita Aloitus-näkymästä" +"Start point" = "Alkupiste" +"Start sharing" = "Aloita jakaminen" +"Start sharing with the Share button, then pass on the code it gives" = "Aloita jakaminen Jaa-painikkeella ja välitä sen antama koodi" +"Starting to share…" = "Aloitetaan jakamista…" +"Status" = "Tila" +"Sticky" = "Kiinnittyvä" +"Stop" = "Pysäytä" +"Stop following {name}" = "Lopeta käyttäjän {name} seuraaminen" +"Stop session" = "Lopeta istunto" +"Stop sharing" = "Lopeta jakaminen" +"Stop spotlighting" = "Lopeta esiin nostaminen" +"Stop {n} {hex}" = "Piste {n} {hex}" +"Stretch" = "Venytys" +"Strikethrough" = "Yliviivaus" +"String" = "Merkkijono" +"Stroke" = "Reunaviiva" +"Stroke style" = "Reunaviivan tyyli" +"Stroke weight" = "Reunaviivan paksuus" +"Strokes" = "Reunaviivat" +"Style" = "Tyyli" +"Styles" = "Tyylit" +"Stylistic set 1" = "Tyylijoukko 1" +"Stylistic set 2" = "Tyylijoukko 2" +"Stylistic set 3" = "Tyylijoukko 3" +"Stylistic set 4" = "Tyylijoukko 4" +"Stylistic set 5" = "Tyylijoukko 5" +"Subtract" = "Vähennä" +"Subtracts" = "Vähentää" +"Suffix" = "Jälkiliite" +"Suspend snapping while dragging" = "Keskeytä tarttuminen vetämisen ajaksi" +"Swap instance" = "Vaihda ilmentymä" +"Swap library: use another library's assets of the same names" = "Vaihda kirjasto: käytä toisen kirjaston samannimisiä resursseja" +"Swap orientation" = "Vaihda suunta" +"Swap overlay" = "Vaihda peittokuva" +"Swap to {name}" = "Vaihda kohteeseen {name}" +"Swap width and height" = "Vaihda leveys ja korkeus keskenään" +"Swapped instance" = "Ilmentymä vaihdettu" +"Swapped {n} assets" = { one = "Vaihdettu {n} resurssi", other = "Vaihdettu {n} resurssia" } +"Swapped {n} assets; {missed} had no match and still use the old library" = { one = "Vaihdettu {n} resurssi; ilman vastinetta jäi {missed}, ja ne käyttävät yhä vanhaa kirjastoa", other = "Vaihdettu {n} resurssia; ilman vastinetta jäi {missed}, ja ne käyttävät yhä vanhaa kirjastoa" } +"Sweep" = "Pyyhkäisy" +"System" = "Järjestelmä" +"Tab / Shift+Tab" = "Tab / Shift+Tab" +"Table" = "Taulukko" +"Tablet" = "Tabletti" +"Tabular numbers" = "Tasalevyiset numerot" +"Take the values from the first selected layer" = "Ota arvot ensimmäisestä valitusta tasosta" +"Taper" = "Kapeneminen" +"Taper both ends" = "Kavenna molemmat päät" +"Tell the server's operator what is wrong with it" = "Kerro palvelimen ylläpitäjälle, mikä siinä on vikana" +"Temperature" = "Lämpötila" +"Template" = "Malli" +"Templates" = "Mallit" +"Terms" = "Ehdot" +"Text" = "Teksti" +"Text align" = "Tekstin tasaus" +"Text content" = "Tekstisisältö" +"Text style" = "Tekstityyli" +"Text styles" = "Tekstityylit" +"Texts" = "Tekstit" +"Texture" = "Tekstuuri" +"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "Piirtoalue esikatselee värit sRGB-muodossa. Vietäviin PNG-, JPG- ja WebP-tiedostoihin merkitään Display P3 -profiili." +"The destination document is unavailable. Nothing was imported." = "Kohdeasiakirja ei ole käytettävissä. Mitään ei tuotu." +"The destination document was closed. Nothing was imported." = "Kohdeasiakirja suljettiin. Mitään ei tuotu." +"The destination page was removed. Nothing was imported." = "Kohdesivu poistettiin. Mitään ei tuotu." +"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "Tiedosto poistetaan palvelimelta. Kukaan ei voi enää avata sitä tai liittyä siihen sieltä, eikä tätä voi kumota. Avoimet kopiot säilyvät käyttäjillään." +"The file now holds another library" = "Tiedosto sisältää nyt toisen kirjaston" +"The file now holds another library (\"{name}\")" = "Tiedosto sisältää nyt toisen kirjaston (”{name}”)" +"The files you shared or published online from this computer. They stay on their server until you remove them." = "Tältä tietokoneelta verkossa jakamasi tai julkaisemasi tiedostot. Ne säilyvät palvelimellaan, kunnes poistat ne." +"The link carries the session's secret: share it only with people you invite." = "Linkki sisältää istunnon salaisen tunnisteen. Jaa se vain kutsumillesi henkilöille." +"The name in your online files and on the server. The document inside keeps its own name." = "Nimi verkkotiedostoissasi ja palvelimella. Sisällä oleva asiakirja säilyttää oman nimensä." +"The selection uses several text styles: click to apply one" = "Valinta käyttää useita tekstityylejä. Käytä yhtä niistä napsauttamalla." +"The server deletes this file if nobody opens it before {date}." = "Palvelin poistaa tämän tiedoston, ellei kukaan avaa sitä ennen päivää {date}." +"The whole file" = "Koko tiedosto" +"Theme" = "Teema" +"There's no destination for that action" = "Toiminnolla ei ole kohdetta" +"Thickness" = "Paksuus" +"This branch can't be reviewed: {e}" = "Tätä haaraa ei voi tarkistaa: {e}" +"This branch was merged or archived." = "Tämä haara on yhdistetty tai arkistoitu." +"This computer only" = "Vain tämä tietokone" +"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Tämä tietokone muistaa omistajan tunnisteen, joten tiedostoa voi hallita aloitusnäkymän Verkossa-luettelosta. Kopioi tunniste hallitaksesi tiedostoa muualla. Se on tämän tiedoston salasana, eikä sitä näytetä uudelleen." +"This condition is edited as an expression" = "Tätä ehtoa muokataan lausekkeena" +"This file" = "Tämä tiedosto" +"This file has unsaved changes" = "Tässä tiedostossa on tallentamattomia muutoksia" +"This file is on {server}, open only to you. Share it to let others in with a new code." = "Tämä tiedosto on palvelimella {server} vain sinun käytössäsi. Jaa se, jotta muut voivat liittyä uudella koodilla." +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Tämä tiedosto käyttää fontteja, joita ei ole asennettu tälle tietokoneelle. Teksti piirretään korvaavalla fontilla, kunnes asennat puuttuvat fontit." +"This frame is new on the branch." = "Tämä kehys on uusi haarassa." +"This frame isn't on the branch." = "Tätä kehystä ei ole haarassa." +"This server is open to the public: no key needed." = "Tämä palvelin on julkinen: avainta ei tarvita." +"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Tämä palvelin tarvitsee avaimen. Lisää osoitteeseen ?key=… (`figcraft-server key` tulostaa avaimen palvelimen koneella)." +"This server takes files only with its key: see Advanced." = "Tämä palvelin ottaa tiedostoja vastaan vain avaimellaan. Katso Lisäasetukset." +"Tidy up" = "Järjestä tasavälein" +"Tile" = "Laatoitus" +"Tile type" = "Laatan tyyppi" +"Tint" = "Sävytys" +"Title" = "Otsikko" +"Title case" = "Sanojen isot alkukirjaimet" +"To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Jaa tämä kirjasto tiimillesi julkaisemalla tiedosto palvelimelle (Jaa ▸ Julkaise palvelimelle…) ja julkaisemalla kirjasto siellä." +"Toggle" = "Vaihda tila" +"Toggle light/dark theme" = "Vaihda vaalea/tumma teema" +"Toggle visibility" = "Vaihda näkyvyys" +"Tools" = "Työkalut" +"Top" = "Ylhäältä" +"Top & bottom" = "Ylä- ja alareuna" +"Top left radius" = "Vasemman yläkulman säde" +"Top right radius" = "Oikean yläkulman säde" +"Transform" = "Muunna" +"Triangle arrow" = "Kolmionuoli" +"Trigger" = "Laukaisin" +"True" = "Tosi" +"Truncate text" = "Lyhennä tekstiä" +"Truncate with an ellipsis" = "Lyhennä kolmella pisteellä" +"Try again" = "Yritä uudelleen" +"Try another search or filter." = "Kokeile toista hakua tai suodatinta." +"Try another search, or browse templates." = "Kokeile toista hakua tai selaa malleja." +"Try another word, or look at every kind." = "Kokeile toista sanaa tai katso kaikki tyypit." +"Turn a layer into a component, reuse it, and change every copy at once" = "Muunna taso komponentiksi, käytä sitä uudelleen ja muuta kaikkia kopioita kerralla" +"Two digits" = "Kaksi numeroa" +"Type" = "Teksti" +"Type settings" = "Tekstiasetukset" +"Type the join code you were given, or paste an invite link." = "Kirjoita saamasi liittymiskoodi tai liitä kutsulinkki." +"Typography" = "Typografia" +"Underline" = "Alleviivaus" +"Undo takes back only your own changes; what others changed since stays." = "Kumoaminen peruu vain omat muutoksesi. Muiden sen jälkeen tekemät muutokset säilyvät." +"Ungroup" = "Pura ryhmittely" +"Uniform" = "Tasainen" +"Union" = "Yhdiste" +"Unions" = "Yhdistää" +"Unknown — treated as sRGB" = "Tuntematon — käsitellään sRGB:nä" +"Unread" = "Lukematon" +"Unresolve" = "Merkitse ratkaisemattomaksi" +"Unsaved changes" = "Tallentamattomat muutokset" +"Unsaved changes in {names}. Close the window and lose them?" = "Tallentamattomia muutoksia kohteissa {names}. Suljetaanko ikkuna ja hylätään ne?" +"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Tallentamattomia muutoksia: tallenna ensin ({save}) tai hylkää sulkemalla välilehti {alt}-napsautuksella" +"Unsaved changes: save first, or close again with {key} to discard" = "Tallentamattomia muutoksia: tallenna ensin tai hylkää sulkemalla uudelleen näppäimellä {key}" +"Up" = "Ylös" +"Update" = "Päivitä" +"Update from main" = "Päivitä päähaarasta" +"Update from selection" = "Päivitä valinnasta" +"Update plugin" = "Päivitä liitännäinen" +"Updated {components} and {styles}" = "Päivitetty {components} ja {styles}" +"Updated {name}" = "Päivitetty: {name}" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Näytönohjainajurin päivittäminen auttaa usein. Käytä vain OpenGL:ää käynnistämällä FigCraft komentokehotteesta näin:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Näytönohjainajurin päivittäminen auttaa usein. WGPU_BACKEND (vulkan, gl, metal) määrittää toisen kokeiltavan grafiikkarajapinnan." +"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." = "Lataa tämän tiedoston FigCraft-palvelimelle ja pitää tämän välilehden synkronoituna sen kanssa. Saat muokkauslinkin ja katselulinkin." +"Uppercase" = "Isot kirjaimet" +"Use a fixed value" = "Käytä kiinteää arvoa" +"Use an expression, e.g. count + 1" = "Käytä lauseketta, esim. count + 1" +"Use as mask" = "Käytä maskina" +"Use auto layout" = "Käytä automaattista asettelua" +"Use number keys for opacity" = "Säädä peittävyyttä numeronäppäimillä" +"Use the .figcraft or .fig extension." = "Käytä .figcraft- tai .fig-tiedostopäätettä." +"Use the layer's fill" = "Käytä tason täyttöä" +"Used in {files} · {instances}" = "Käytössä tiedostoissa {files} · {instances}" +"Username" = "Käyttäjänimi" +"Uses variables: each layer shows them in its own mode" = "Käyttää muuttujia: kukin taso näyttää ne omassa tilassaan" +"Value" = "Arvo" +"Variable" = "Muuttuja" +"Variable collections" = "Muuttujakokoelmat" +"Variable modes" = "Muuttujatilat" +"Variable width" = "Vaihteleva leveys" +"Variables" = "Muuttujat" +"Variables JSON…" = "Muuttujat JSON…" +"Variables store reusable values (colours, numbers, strings, booleans) with modes such as Light and Dark." = "Muuttujat tallentavat uudelleenkäytettäviä arvoja (värejä, lukuja, merkkijonoja ja totuusarvoja). Niillä voi olla tiloja, kuten vaalea ja tumma." +"Variant" = "Variantti" +"Vector" = "Vektori" +"Vectors" = "Vektorit" +"Version" = "Versio" +"Version history" = "Versiohistoria" +"Version {version}" = "Versio {version}" +"Version {v} · {components} · {styles} · {variables}" = "Versio {v} · {components} · {styles} · {variables}" +"Vertical" = "Pystysuunta" +"Vertical align" = "Pystytasaus" +"Vertical hex" = "Pystysuuntainen kuusikulmio" +"Vertical padding" = "Pystysuuntainen sisämarginaali" +"Vertical spacing" = "Pystyvälistys" +"Vertical trim" = "Pystysuuntainen rajaus" +"View" = "Näytä" +"View link" = "Katselulinkki" +"View link copied" = "Katselulinkki kopioitu" +"View only" = "Vain katselu" +"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "Vain katselu: näet kaikki muutokset reaaliajassa, mutta et voi muokata. Poistu säilyttääksesi muokattavan kopion." +"Viewing “{name}”" = "Katsellaan tiedostoa ”{name}”" +"Violet" = "Violetti" +"Visibility" = "Näkyvyys" +"Visible" = "Näkyvä" +"Vivid" = "Kirkas" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND on asetettu ja määrittää käytettävän grafiikkarajapinnan. Poista se käytöstä, jotta FigCraft voi valita." +"Warm" = "Lämmin" +"Watch" = "Seuraa" +"Wavy" = "Aaltoileva" +"Web" = "Verkko" +"WebSocket URL" = "WebSocket-URL" +"WebSocket URL copied" = "WebSocket-URL kopioitu" +"Websites" = "Verkkosivustot" +"Wedge" = "Kiila" +"Weight" = "Paksuus" +"What changed since it was marked ready for dev" = "Muutokset kehitysvalmiiksi merkitsemisen jälkeen" +"What is wrong with this file? The server's operator gets your report." = "Mikä tässä tiedostossa on vikana? Palvelimen ylläpitäjä saa ilmoituksesi." +"What it's for" = "Käyttötarkoitus" +"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." = "Aiempi tila ei ole tiedossa: muutos tehtiin ennen kuin FigCraft tallensi kopioita kehitysvalmiista kehyksistä. Ratkaise muutos tai merkitse kehys uudelleen valmiiksi, jotta jatkossa vertailu onnistuu." +"What you try here changes this preview and the code below, not the file" = "Tässä kokeilemasi asiat muuttavat esikatselua ja alla olevaa koodia, eivät tiedostoa" +"While hovering" = "Osoitettaessa" +"While pressing" = "Painettaessa" +"White" = "Valkoinen" +"Who runs it, its terms and how long it keeps files" = "Ylläpitäjä, käyttöehdot ja tiedostojen säilytysaika" +"Why it changed" = "Muutoksen syy" +"Widget" = "Pienoisohjelma" +"Widgets" = "Pienoisohjelmat" +"Width" = "Leveys" +"Width point" = "Leveyspiste" +"Width profile" = "Leveysprofiili" +"Wireframe kit" = "Rautalankapaketti" +"With the layers' export settings" = "Tasojen vientiasetuksilla" +"Wrap" = "Rivitys" +"Wrap in new section" = "Ympäröi uudella osiolla" +"Yellow" = "Keltainen" +"Yesterday" = "Eilen" +"You" = "Sinä" +"You'll get the shared file as a new tab and edit it together." = "Jaettu tiedosto avautuu uuteen välilehteen, ja voitte muokata sitä yhdessä." +"You're spotlighting: everyone follows your view" = "Olet esillä: kaikki seuraavat näkymääsi" +"Your copy of the file stays open" = "Oma kopiosi tiedostosta pysyy auki" +"Your copy stays open; the file stays on the server" = "Oma kopiosi pysyy auki, ja tiedosto jää palvelimelle" +"Your name" = "Nimesi" +"Your name in comments" = "Nimesi kommenteissa" +"Your system asks for reduced motion: animations stay off." = "Järjestelmäsi pyytää vähennettyä liikettä. Animaatiot pidetään pois käytöstä." +"Z" = "Z" +"Z to A" = "Z:sta A:han" +"Zoom" = "Zoomaus" +"Zoom around the pointer" = "Zoomaa osoittimen ympärillä" +"Zoom device to fill window" = "Zoomaa laite ikkunan kokoiseksi" +"Zoom in" = "Lähennä" +"Zoom out" = "Loitonna" +"Zoom to 100%" = "Zoomaa kokoon 100 %" +"Zoom to 200%" = "Zoomaa kokoon 200 %" +"Zoom to 50%" = "Zoomaa kokoon 50 %" +"Zoom to fit" = "Sovita näkymään" +"Zoom to selection" = "Zoomaa valintaan" +"Zoom tool: click to zoom in, drag an area" = "Zoomaustyökalu: lähennä napsauttamalla tai vedä alue" +"Zoom tool: zoom out" = "Zoomaustyökalu: loitonna" +"and {n} more" = { one = "ja vielä {n}", other = "ja vielä {n}" } +"bleed marks" = "leikkuuvaramerkit" +"by {author}" = "tekijä: {author}" +"call any command its access allows" = "kutsua kaikkia käyttöoikeuksiensa sallimia komentoja" +"call only: {commands}" = "kutsua vain: {commands}" +"can edit the document" = "voi muokata asiakirjaa" +"conditional prototype actions nested too deeply were skipped" = "liian syvälle sisäkkäiset ehdolliset prototyyppitoiminnot ohitettiin" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "ehdolliset prototyyppitoiminnot ohitettiin, koska niiden ehtoja ei voi laskea" +"connector label background was skipped: style data is missing" = "yhdistimen tekstin tausta ohitettiin: tyylitiedot puuttuvat" +"connector label was skipped: background bounds are invalid" = "yhdistimen teksti ohitettiin: taustan rajat ovat virheelliset" +"connector label was skipped: background transform is invalid" = "yhdistimen teksti ohitettiin: taustan muunnos on virheellinen" +"connector label was skipped: composed text transform is invalid" = "yhdistimen teksti ohitettiin: yhdistetty tekstimuunnos on virheellinen" +"connector label was skipped: derived background bounds are missing" = "yhdistimen teksti ohitettiin: johdetut taustan rajat puuttuvat" +"connector label was skipped: derived text bounds are missing" = "yhdistimen teksti ohitettiin: johdetut tekstin rajat puuttuvat" +"connector label was skipped: text bounds are invalid" = "yhdistimen teksti ohitettiin: tekstin rajat ovat virheelliset" +"connector label was skipped: text style data is missing" = "yhdistimen teksti ohitettiin: tekstin tyylitiedot puuttuvat" +"connector label was skipped: text transform is invalid" = "yhdistimen teksti ohitettiin: tekstin muunnos on virheellinen" +"connector labels were skipped" = "yhdistimien tekstit ohitettiin" +"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "yhdistintasot tuodaan staattisina kehys- ja vektorikuvina mahdollisine teksteineen; päiden reaaliaikaisia sidoksia ei säilytetä" +"connector snapshot vector data is missing; connector was left empty" = "yhdistimen vektorikuvatiedot puuttuvat; yhdistin jätettiin tyhjäksi" +"connectors whose ends can't be placed are empty" = "yhdistimet, joiden päitä ei voi sijoittaa, ovat tyhjiä" +"crop marks" = "leikkausmerkit" +"damaged vector geometry was skipped" = "vaurioitunut vektorigeometria ohitettiin" +"damaged vector networks import as outlines" = "vaurioituneet vektoriverkot tuodaan ääriviivoina" +"development: {path}" = "kehitys: {path}" +"document children other than pages were skipped" = "asiakirjan muut kuin sivuina olevat alikohteet ohitettiin" +"duplicate grid track sizing entries use equal tracks" = "päällekkäiset ruudukon kokomääritykset käyttävät tasakokoisia osia" +"e.g. count + 1" = "esim. count + 1" +"e.g. count >= 3 and not done" = "esim. count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "mutkaiset ja kaarevat yhdistimet, joilla ei ole tallennettua polkua, tuodaan suorina viivoina" +"entries without a node type were skipped" = "kohteet, joilta puuttuu solmutyyppi, ohitettiin" +"figcraft:// links from a browser, chat or email open this app and join the session." = "selaimen, keskustelun tai sähköpostin figcraft://-linkit avaavat tämän sovelluksen ja liittyvät istuntoon." +"filled areas of a vector with their own fill use the layer's fill" = "vektorin omalla täytöllä määritetyt alueet käyttävät tason täyttöä" +"from {server}" = "palvelimelta {server}" +"gamepad triggers without exactly one known controller button were skipped" = "peliohjainlaukaisimet, joilla ei ole täsmälleen yhtä tunnettua ohjainpainiketta, ohitettiin" +"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "ruudukon osien luettelo ylittää 1000 kohdetta; osat käyttävät oletuksen mukaista tasakokoa" +"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "ruudukon kokomääritys on virheellinen tai sitä ei tueta; kyseiset osat käyttävät tasakokoa" +"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "ruudukon kokomääritysten luettelo ylittää 1000 kohdetta; osat käyttävät oletuksen mukaista tasakokoa" +"hidden" = "kätketty" +"iOS" = "iOS" +"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "kuvasuotimet ovat monitulkintaisia, koska molemmat suodinsäilöt ovat läsnä; suotimien muunnos ohitettiin" +"image filters use FigCraft's approximate renderer" = "kuvasuotimet käyttävät FigCraftin likimääräistä renderöijää" +"instance swap properties whose default component isn't in the file were skipped" = "ilmentymän vaihto-ominaisuudet ohitettiin, koska niiden oletuskomponenttia ei ole tiedostossa" +"instances inside their own main component were imported as frames" = "oman pääkomponenttinsa sisällä olevat ilmentymät tuotiin kehyksinä" +"instances of components that aren't in the file show no layers" = "tiedostosta puuttuvien komponenttien ilmentymät eivät näytä tasoja" +"instances of components that aren't in the file were imported as frames" = "tiedostosta puuttuvien komponenttien ilmentymät tuotiin kehyksinä" +"instances that would nest a component inside itself were imported as frames" = "ilmentymät, jotka sijoittaisivat komponentin itsensä sisään, tuotiin kehyksinä" +"instances without a main component were imported as empty frames" = "ilmentymät, joilta puuttuu pääkomponentti, tuotiin tyhjinä kehyksinä" +"invalid internal .fig text path data was ignored" = "virheelliset sisäiset .fig-tekstipolun tiedot ohitettiin" +"invalid or oversized vector networks import as outlines" = "virheelliset tai liian suuret vektoriverkot tuodaan ääriviivoina" +"invalid or oversized vector path data was skipped" = "virheelliset tai liian suuret vektoripolun tiedot ohitettiin" +"just now" = "juuri nyt" +"key triggers without a supported key were skipped" = "näppäinlaukaisimet, joilla ei ole tuettua näppäintä, ohitettiin" +"key up triggers import as key presses" = "näppäimen vapautuslaukaisimet tuodaan näppäimen painalluksina" +"layers without a position were placed at their parent's origin" = "tasot, joilta puuttuu sijainti, sijoitettiin emotasonsa origoon" +"layers without a type were skipped" = "tasot, joilta puuttuu tyyppi, ohitettiin" +"legacy image filter container is unsupported; filter conversion was skipped" = "vanhaa kuvasuodinsäilöä ei tueta; suotimien muunnos ohitettiin" +"locked" = "lukittu" +"malformed image filters container was skipped" = "virheellinen image filters -säilö ohitettiin" +"malformed image paintFilter container was skipped" = "virheellinen image paintFilter -säilö ohitettiin" +"malformed slot content assignment was skipped" = "virheellinen sisältöpaikan sisällön määritys ohitettiin" +"no bleed" = "ei leikkuuvaraa" +"nobody else here yet" = "kukaan muu ei ole vielä paikalla" +"non-default slot settings are not represented and were skipped" = "oletuksesta poikkeavia sisältöpaikan asetuksia ei esitetä, ja ne ohitettiin" +"now" = "nyt" +"only in a text style or variable" = "vain tekstityylissä tai muuttujassa" +"or" = "tai" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "pisteiden viivaliitokset säilytettiin; katko- ja vaihtelevanlevyiset viivat käyttävät tason liitostyyliä" +"preferred slot component keys cannot be resolved to file nodes and were skipped" = "sisältöpaikan ensisijaisten komponenttien avaimia ei voitu yhdistää tiedoston solmuihin, ja ne ohitettiin" +"prototype actions of unknown types were skipped" = "tuntemattoman tyyppiset prototyyppitoiminnot ohitettiin" +"prototype actions without a destination in the file were skipped" = "prototyyppitoiminnot, joiden kohdetta ei ole tiedostossa, ohitettiin" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "muuttujiin viittaavat prototyyppilausekkeet ohitettiin, koska muuttujia ei ole muuttujien JSON-tiedostossa" +"prototype expressions with unsupported functions were skipped" = "prototyyppilausekkeet, joissa on tukemattomia funktioita, ohitettiin" +"prototype interactions without supported actions were skipped" = "prototyypin vuorovaikutukset, joissa ei ole tuettuja toimintoja, ohitettiin" +"prototype navigations of unknown types were skipped" = "tuntemattoman tyyppiset prototyypin siirtymät kohteeseen ohitettiin" +"prototype transitions of unknown types play instantly" = "tuntemattoman tyyppiset prototyypin siirtymäanimaatiot toistuvat välittömästi" +"prototype triggers of unknown types were skipped" = "tuntemattoman tyyppiset prototyypin laukaisimet ohitettiin" +"put a widget on the canvas" = "lisätä pienoisohjelman piirtoalueelle" +"read and edit the document" = "lukea ja muokata asiakirjaa" +"read the document" = "lukea asiakirjan" +"reads the document" = "lukee asiakirjan" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "muuttujan asetustoiminnot ohitettiin, koska muuttujia ei ole muuttujien JSON-tiedostossa" +"set-variable actions with values that can't be evaluated were skipped" = "muuttujan asetustoiminnot ohitettiin, koska arvoja ei voi laskea" +"set-variable actions with values that don't fit the variable were skipped" = "muuttujan asetustoiminnot ohitettiin, koska arvot eivät sovi muuttujaan" +"set-variable actions without a value were skipped" = "muuttujan asetustoiminnot, joilta puuttuu arvo, ohitettiin" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "muuttujatilan asetustoiminnot ohitettiin, koska kokoelmia tai tiloja ei ole muuttujien JSON-tiedostossa" +"slot content assignment target is missing or is not slot content" = "sisältöpaikan määrityksen kohde puuttuu tai ei ole sisältöpaikan sisältöä" +"slot content assignment uses the empty GUID sentinel and was skipped" = "sisältöpaikan määritys käyttää tyhjää GUID-erikoisarvoa, ja se ohitettiin" +"slot layers without a matching slot property were imported as frames" = "sisältöpaikkatasot, joilla ei ole vastaavaa sisältöpaikkaominaisuutta, tuotiin kehyksinä" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "jotkin vektoripisteet viittaavat tiedostosta puuttuviin pisteasetuksiin; ne käyttävät tason liitostyyliä ja tavallisia kulmia" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "jotkin vektorisegmentit viittaavat tiedostosta puuttuviin tyyliasetuksiin; ne käyttävät tason reunaviivaa" +"stale or invalid grid track sizing entries were ignored" = "vanhentuneet tai virheelliset ruudukon kokomääritykset ohitettiin" +"stars and polygons without point counts or geometry use the default point count" = "tähdet ja monikulmiot, joilta puuttuu pistemäärä tai geometria, käyttävät oletuspistemäärää" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "joidenkin vektorisegmenttien tyyliasetuksia ei tueta; nämä segmentit käyttävät tason reunaviivaa" +"text path underline or strikethrough decoration is not rendered" = "tekstipolun alleviivausta tai yliviivausta ei renderöidä" +"text style overrides refer to missing table entries" = "tekstityylin ohitukset viittaavat puuttuviin taulukkokohteisiin" +"the file has no pages" = "tiedostossa ei ole sivuja" +"the plugin" = "liitännäinen" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "muuttujien JSON-tiedostosta puuttuu `variableCollections` (odotettiin paikallisten muuttujien vastausta), ja tiedosto ohitettiin" +"this session only" = "vain tämä istunto" +"unknown component property types were skipped" = "tuntemattoman tyyppiset komponenttiominaisuudet ohitettiin" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "muuttuja-aliakset ohitettiin, koska kohdemuuttujia ei ole muuttujien JSON-tiedostossa tai ne ovat eri tyyppiä" +"variable bindings to properties the layer doesn't have were skipped" = "muuttujasidokset ominaisuuksiin, joita tasolla ei ole, ohitettiin" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "muuttujasidokset muuttujiin, joita ei ole muuttujien JSON-tiedostossa (kirjastomuuttujat), ohitettiin" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "muuttujasidokset ohitettiin: tiedoston JSON ei määritä muuttujia (anna paikallisten muuttujien JSON arvona `variables` tai sisällytä sen `meta` tiedoston JSONin viereen)" +"variable bindings whose variable type doesn't fit the property were skipped" = "muuttujasidokset, joiden muuttujatyyppi ei sovi ominaisuuteen, ohitettiin" +"variable collections without modes were skipped" = "muuttujakokoelmat, joilla ei ole tiloja, ohitettiin" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "prototyyppilausekkeiden muuttujatilahaut, jotka eivät ole muuttuja ja jokin sen tiloista, ohitettiin" +"variable modes of collections that aren't in the file were skipped" = "tiedostosta puuttuvien kokoelmien muuttujatilat ohitettiin" +"variable modes of collections that aren't in the variables JSON were skipped" = "muuttujien JSON-tiedostosta puuttuvien kokoelmien muuttujatilat ohitettiin" +"variable values that don't match their type (or composed colours) were skipped" = "muuttujien arvot, jotka eivät vastaa tyyppiään (tai yhdistettyjä värejä), ohitettiin" +"variables of unknown types were skipped" = "tuntemattoman tyyppiset muuttujat ohitettiin" +"variables whose collection isn't in the variables JSON were skipped" = "muuttujat, joiden kokoelmaa ei ole muuttujien JSON-tiedostossa, ohitettiin" +"variables without readable values were skipped" = "muuttujat, joiden arvoja ei voi lukea, ohitettiin" +"vector points with unknown stroke joins use the layer join" = "vektoripisteet, joilla on tuntematon liitostyyli, käyttävät tason liitostyyliä" +"vectors without geometry are empty" = "vektorit ilman geometriaa ovat tyhjiä" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "vektorit ilman geometriaa ovat tyhjiä (vie tiedoston JSON asetuksella geometry=paths)" +"very long text was cut to 1,048,576 characters" = "erittäin pitkä teksti katkaistiin 1 048 576 merkkiin" +"very long vector paths were skipped" = "erittäin pitkät vektoripolut ohitettiin" +"video actions are not supported and were skipped" = "videotoimintoja ei tueta, ja ne ohitettiin" +"video triggers are not supported and were skipped" = "videolaukaisimia ei tueta, ja ne ohitettiin" +"yesterday" = "eilen" +"{instances} in {files}" = "{instances} tiedostoissa {files}" +"{language}: draft translation, waiting for a native speaker's review." = "{language}: käännösluonnos, joka odottaa äidinkielisen puhujan tarkistusta." +"{length} bleed" = "Leikkuuvara {length}" +"{month} {day}, {year}" = "{day}. {month} {year}" +"{name}\nClick to apply to the selection" = "{name}\nKäytä valintaan napsauttamalla" +"{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}\nVedä piirtoalueelle tai sijoita näkymän keskelle napsauttamalla.\nVaihda ilmentymä pudottamalla sen päälle Alt (Option) painettuna." +"{name} (from {library})" = "{name} (kirjastosta {library})" +"{name} (you)" = "{name} (sinä)" +"{name} ({layers} layers)" = "{name} (tasoja: {layers})" +"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name} ({tag}): {min} – {max}, oletus {default}. Palauta tyylin arvo kaksoisnapsauttamalla liukusäädintä. Escape peruuttaa vetämisen." +"{name} cleared from the list" = "{name} poistettu luettelosta" +"{name} from {path}" = "{name} polusta {path}" +"{name} is forgotten on this computer; the file stays on {server}" = "{name} on unohdettu tällä tietokoneella. Tiedosto jää palvelimelle {server}." +"{name} is on the server." = "{name} on palvelimella." +"{name} is spotlighting" = "{name} nostaa näkymäänsä esiin" +"{name} is too large to open in the browser (over 512 MB)." = "{name} on liian suuri avattavaksi selaimessa (yli 512 Mt)." +"{name} was removed from the server" = "{name} poistettiin palvelimelta" +"{name} · deleted on main" = "{name} · poistettu päähaarassa" +"{name} · deleted on the branch" = "{name} · poistettu haarassa" +"{name} · not published" = "{name} · ei julkaistu" +"{name} · on a server" = "{name} · palvelimella" +"{name} · published version {v}" = "{name} · julkaistu versio {v}" +"{name} · version {v} on the server" = "{name} · versio {v} palvelimella" +"{name}, in a live session" = "{name}, reaaliaikaisessa istunnossa" +"{name}, online, open only to you" = "{name}, verkossa, vain sinun käytössäsi" +"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, valittu" +"{n} added" = { one = "Lisätty: {n}", other = "Lisätty: {n}" } +"{n} autosave versions" = { one = "{n} automaattisesti tallennettu versio", other = "{n} automaattisesti tallennettua versiota" } +"{n} changed" = { one = "Muutettu: {n}", other = "Muutettu: {n}" } +"{n} columns" = { one = "{n} sarake", other = "{n} saraketta" } +"{n} components" = { one = "{n} komponentti", other = "{n} komponenttia" } +"{n} components and styles have newer versions" = { one = "Uudempi versio saatavilla: {n} komponentti tai tyyli", other = "Uudempia versioita saatavilla: {n} komponenttia ja tyyliä" } +"{n} conflicts to resolve" = { one = "{n} ratkaistava ristiriita", other = "{n} ratkaistavaa ristiriitaa" } +"{n} days ago" = { one = "{n} päivä sitten", other = "{n} päivää sitten" } +"{n} files" = { one = "{n} tiedosto", other = "{n} tiedostoa" } +"{n} hours ago" = { one = "{n} tunti sitten", other = "{n} tuntia sitten" } +"{n} instances" = { one = "{n} ilmentymä", other = "{n} ilmentymää" } +"{n} layers" = { one = "{n} taso", other = "{n} tasoa" } +"{n} layers as one file" = { one = "{n} taso yhtenä tiedostona", other = "{n} tasoa yhtenä tiedostona" } +"{n} layers selected" = { one = "{n} taso valittu", other = "{n} tasoa valittu" } +"{n} layers · {parts}" = { one = "{n} taso · {parts}", other = "{n} tasoa · {parts}" } +"{n} matches" = { one = "{n} osuma", other = "{n} osumaa" } +"{n} minutes ago" = { one = "{n} minuutti sitten", other = "{n} minuuttia sitten" } +"{n} months ago" = { one = "{n} kuukausi sitten", other = "{n} kuukautta sitten" } +"{n} more notes omitted from this report" = { one = "Tästä raportista on jätetty pois vielä {n} huomautus", other = "Tästä raportista on jätetty pois vielä {n} huomautusta" } +"{n} of {total}" = "{n} / {total}" +"{n} people joined" = { one = "{n} henkilö liittyi", other = "{n} henkilöä liittyi" } +"{n} preferred components" = { one = "{n} ensisijainen komponentti", other = "{n} ensisijaista komponenttia" } +"{n} ready" = { one = "{n} valmis", other = "{n} valmista" } +"{n} removed" = { one = "Poistettu: {n}", other = "Poistettu: {n}" } +"{n} replies" = { one = "{n} vastaus", other = "{n} vastausta" } +"{n} rows" = { one = "{n} rivi", other = "{n} riviä" } +"{n} styles" = { one = "{n} tyyli", other = "{n} tyyliä" } +"{n} text layers" = { one = "{n} tekstitaso", other = "{n} tekstitasoa" } +"{n} variables" = { one = "{n} muuttuja", other = "{n} muuttujaa" } +"{n} weeks ago" = { one = "{n} viikko sitten", other = "{n} viikkoa sitten" } +"{n} years ago" = { one = "{n} vuosi sitten", other = "{n} vuotta sitten" } +"{n}d" = "{n}pv" +"{n}h" = "{n}t" +"{n}m" = "{n}min" +"{n}w" = "{n}vk" +"{page}, nothing selected" = "{page}, ei valintaa" +"{profile} (current)" = "{profile} (nykyinen)" +"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} on sen arvo. {property ? \"a\" : \"b\"} valitsee merkkijonon. Muut {…}-ilmaukset säilyvät sellaisinaan." +"{server} can't say what became of your files (it is older than this app)." = "{server} ei pysty kertomaan tiedostojesi tilaa (se on tätä sovellusta vanhempi)." +"{server} · No longer on the server" = "{server} · Ei enää palvelimella" +"{server} · {shared} · Opened {date}" = "{server} · {shared} · Avattu {date}" +"{tool} hold {key}" = "{tool} pidä {key} painettuna" +"{value} isn't a colour" = "{value} ei ole väri" +"{when} · edited" = "{when} · muokattu" +"←" = "←" +"← ↑ → ↓" = "← ↑ → ↓" +"← → ↑ ↓" = "← → ↑ ↓" +"→, Space or N" = "→, Space or N" +"↵" = "↵" +"⇥ / ⇧⇥" = "⇥ / ⇧⇥" +"⌘ arrows" = "⌘ arrows" +"⌘ click" = "⌘ click" +"⌘ scroll" = "⌘ scroll" +"⌥ drag" = "⌥ drag" + +[commands] +"align.bottom" = "Tasaa alas" +"align.hcenter" = "Keskitä vaakasuunnassa" +"align.left" = "Tasaa vasemmalle" +"align.right" = "Tasaa oikealle" +"align.top" = "Tasaa ylös" +"align.vcenter" = "Keskitä pystysuunnassa" +"annotation.add" = "Lisää huomautus" +"annotation.addCategory" = "Lisää huomautusluokka" +"annotation.categories" = "Huomautusten luokat" +"annotation.edit" = "Muokkaa huomautusta" +"annotation.editCategory" = "Muokkaa huomautusluokkaa" +"annotation.list" = "Luettele huomautukset" +"annotation.remove" = "Poista huomautus" +"annotation.removeCategory" = "Poista huomautusluokka" +"app.actions" = "Toiminnot" +"app.preferences" = "Asetukset" +"arrange.back" = "Vie takimmaiseksi" +"arrange.backward" = "Vie taaksepäin" +"arrange.forward" = "Tuo eteenpäin" +"arrange.front" = "Tuo etummaiseksi" +"arrange.packH" = "Pakkaa vaakasuunnassa" +"arrange.packV" = "Pakkaa pystysuunnassa" +"arrange.roundToPixels" = "Pyöristä pikseleihin" +"arrange.spacing" = "Välistys" +"arrange.tidy" = "Järjestä tasavälein" +"bool.exclude" = "Sulje valinnan päällekkäinen alue pois" +"bool.flatten" = "Yhdistä Boolen muoto vektoriksi" +"bool.intersect" = "Leikkaa valinnan yhteinen alue" +"bool.subtract" = "Vähennä valinta" +"bool.union" = "Yhdistä valinta" +"branch.archive" = "Arkistoi haara" +"branch.create" = "Luo haara" +"branch.list" = "Luettele haarat" +"branch.merge" = "Yhdistä haara" +"branch.open" = "Avaa haara" +"branch.review" = "Tarkista haara" +"branch.updateFromMain" = "Päivitä päähaarasta" +"codeConnect.export" = "Vie koodiyhteydet" +"codeConnect.import" = "Tuo koodiyhteydet" +"codeConnect.remove" = "Katkaise yhteys koodiin" +"codeConnect.set" = "Yhdistä koodiin" +"collab.cursor" = "Näytä kohdistin kohdassa" +"collab.cursorChat" = "Kohdistinkeskustelu" +"collab.diff" = "Näytä synkronoinnin muutokset" +"collab.files" = "Omat verkkotiedostot" +"collab.follow" = "Seuraa" +"collab.forgetFile" = "Unohda verkkotiedosto" +"collab.host" = "Aloita reaaliaikainen istunto" +"collab.join" = "Liity reaaliaikaiseen istuntoon" +"collab.leave" = "Poistu reaaliaikaisesta istunnosta" +"collab.manageFile" = "Hallitse julkaistua tiedostoa" +"collab.openFile" = "Avaa verkkotiedosto" +"collab.peers" = "Tämän tiedoston käyttäjät" +"collab.publish" = "Julkaise palvelimelle" +"collab.recoverFile" = "Palauta verkkotiedosto" +"collab.registerLinks" = "Avaa kutsulinkit FigCraftissa" +"collab.removePerson" = "Poista istunnosta" +"collab.report" = "Ilmoita tiedostosta palvelimen ylläpitäjälle" +"collab.serverInfo" = "Palvelimen tiedot" +"collab.share" = "Jaa liittymiskoodilla" +"collab.spotlight" = "Nosta minut esiin" +"collab.stop" = "Lopeta reaaliaikainen istunto" +"command.batch" = "Suorita useita komentoja" +"comment.add" = "Lisää kommentti" +"comment.delete" = "Poista kommentti" +"comment.edit" = "Muokkaa kommenttia" +"comment.link" = "Kopioi linkki kommenttiin" +"comment.list" = "Luettele kommentit" +"comment.markRead" = "Merkitse kommentti luetuksi" +"comment.move" = "Siirrä kommentti" +"comment.next" = "Seuraava kommentti" +"comment.open" = "Avaa kommentti" +"comment.people" = "Kommenttien osallistujat" +"comment.react" = "Reagoi kommenttiin" +"comment.reply" = "Vastaa kommenttiin" +"comment.resolve" = "Ratkaise kommentti" +"comment.zoom" = "Zoomaa kommentteihin" +"component.addProperty" = "Lisää komponentin ominaisuus" +"component.addVariant" = "Lisää variantti" +"component.bindProperty" = "Sido taso ominaisuuteen" +"component.combineVariants" = "Yhdistä varianteiksi" +"component.create" = "Luo komponentti" +"component.createMultiple" = "Luo useita komponentteja" +"component.deleteProperty" = "Poista komponentin ominaisuus" +"component.detach" = "Irrota ilmentymä" +"component.editProperty" = "Muokkaa komponentin ominaisuutta" +"component.exposeInstance" = "Tuo sisäkkäinen ilmentymä käyttöön" +"component.goToMain" = "Siirry pääkomponenttiin" +"component.hideWhenPublishing" = "Kätke julkaistaessa" +"component.list" = "Luettele komponentit" +"component.makeSlot" = "Luo sisältöpaikka" +"component.playground" = "Komponenttien kokeilualue" +"component.properties" = "Komponentin ominaisuudet" +"component.pushChanges" = "Siirrä muutokset pääkomponenttiin" +"component.removeSlot" = "Poista sisältöpaikka" +"component.resetInstance" = "Palauta kaikki muutokset" +"component.resetOverride" = "Palauta ohitus" +"component.restoreMain" = "Palauta pääkomponentti" +"dev.compare" = "Vertaa muutoksia" +"dev.focus" = "Kohdistusnäkymä" +"dev.resolveChange" = "Ratkaise muutos" +"dev.unfocus" = "Poistu kohdistusnäkymästä" +"devResource.add" = "Lisää kehittäjäresurssi" +"devResource.edit" = "Muokkaa kehittäjäresurssia" +"devResource.list" = "Luettele kehittäjäresurssit" +"devResource.remove" = "Poista kehittäjäresurssi" +"distribute.horizontal" = "Jaa vaakavälit tasan" +"distribute.vertical" = "Jaa pystyvälit tasan" +"doc.home" = "Aloitus" +"doc.list" = "Luettele asiakirjat" +"doc.nextTab" = "Seuraava välilehti" +"doc.previousTab" = "Edellinen välilehti" +"doc.switch" = "Vaihda asiakirjaa" +"edit.copy" = "Kopioi" +"edit.copyAsCss" = "Kopioi CSS-muodossa" +"edit.copyAsPng" = "Kopioi PNG-muodossa" +"edit.copyAsSvg" = "Kopioi SVG-muodossa" +"edit.copyAsText" = "Kopioi tekstinä" +"edit.copyLink" = "Kopioi linkki valintaan" +"edit.copyProperties" = "Kopioi ominaisuudet" +"edit.cut" = "Leikkaa" +"edit.delete" = "Poista" +"edit.duplicate" = "Monista" +"edit.findReplace" = "Etsi ja korvaa" +"edit.paste" = "Liitä" +"edit.pasteDesignLayers" = "Liitä suunnittelutasoja" +"edit.pasteOver" = "Liitä valinnan päälle" +"edit.pasteProperties" = "Liitä ominaisuudet" +"edit.pasteReplace" = "Liitä korvaten" +"edit.pickColor" = "Poimi väri" +"edit.redo" = "Tee uudelleen" +"edit.repeat" = "Toista viimeisin komento" +"edit.setDefaultProperties" = "Aseta oletusominaisuudet" +"edit.undo" = "Kumoa" +"effect.add" = "Lisää tehoste" +"effect.move" = "Siirrä tehoste" +"effect.remove" = "Poista tehoste" +"effect.set" = "Aseta tehoste" +"export.add" = "Lisää vientiasetus" +"export.remove" = "Poista vientiasetus" +"export.update" = "Muokkaa vientiasetusta" +"file.close" = "Sulje välilehti" +"file.colorProfile" = "Väriprofiili" +"file.duplicate" = "Monista tiedosto" +"file.export" = "Vie" +"file.exportFramesPdf" = "Vie kehykset PDF-muodossa" +"file.exportLayers" = "Vie tasot" +"file.exportSvg" = "Vie valinta SVG-muodossa" +"file.import" = "Tuo" +"file.moveTab" = "Siirrä välilehti" +"file.new" = "Uusi suunnittelutiedosto" +"file.newFromTemplate" = "Uusi mallista" +"file.open" = "Avaa" +"file.openRecent" = "Avaa viimeksi avattu" +"file.placeImage" = "Sijoita kuva" +"file.recover" = "Palauta tiedosto" +"file.rename" = "Nimeä tiedosto uudelleen" +"file.revert" = "Palauta tallennettu" +"file.save" = "Tallenna" +"file.saveAs" = "Tallenna nimellä" +"file.saveVersion" = "Tallenna versiohistoriaan" +"file.versionHistory" = "Näytä versiohistoria" +"fill.add" = "Lisää täyttö" +"fill.move" = "Siirrä täyttö" +"fill.pattern" = "Kuviotäyttö" +"fill.remove" = "Poista täyttö" +"fill.set" = "Aseta täyttö" +"frame.create" = "Luo kehys" +"frame.presets" = "Luettele kehysten esiasetukset" +"frame.swapOrientation" = "Vaihda suunta" +"grid.add" = "Lisää asetteluruudukko" +"grid.move" = "Siirrä asetteluruudukko" +"grid.remove" = "Poista asetteluruudukko" +"guide.add" = "Lisää apuviiva" +"guide.clear" = "Poista kaikki apuviivat" +"guide.move" = "Siirrä apuviiva" +"guide.remove" = "Poista apuviiva" +"guide.select" = "Valitse apuviiva" +"help.about" = "Tietoja FigCraftista" +"help.gettingStarted" = "Aloittaminen" +"help.page" = "Ohjesivu" +"help.shortcuts" = "Pikanäppäimet" +"image.adjust" = "Säädä kuvaa" +"image.deletePreset" = "Poista kuvan esiasetus" +"image.place" = "Sijoita kuva" +"image.presets" = "Kuvan esiasetukset" +"image.savePreset" = "Tallenna kuvan esiasetus" +"inspect.assets" = "Tarkastele resursseja" +"inspect.code" = "Tason koodi" +"inspect.componentCode" = "Komponentin koodi" +"inspect.properties" = "Tarkastele ominaisuuksia" +"inspect.readyForDev" = "Kehitysvalmis" +"instance.create" = "Luo ilmentymä" +"instance.exposed" = "Käyttöön tuodut sisäkkäiset ilmentymät" +"instance.place" = "Sijoita ilmentymä" +"instance.resetSlot" = "Palauta sisältöpaikka" +"instance.setProperties" = "Aseta ilmentymän ominaisuudet" +"instance.slotInsert" = "Lisää sisältöpaikkaan" +"instance.swap" = "Vaihda ilmentymä" +"instance.swapAt" = "Vaihda ilmentymä pisteessä" +"layers.collapseAll" = "Supista tasot" +"layout.add" = "Lisää automaattinen asettelu" +"layout.remove" = "Poista automaattinen asettelu" +"layout.setTrack" = "Aseta ruudukon osan koko" +"layout.suggest" = "Ehdota automaattista asettelua" +"libraries.open" = "Kirjastot" +"libraries.publish" = "Julkaise kirjasto…" +"library.acceptUpdates" = "Hyväksy kirjaston päivitykset" +"library.add" = "Lisää kirjasto" +"library.aliasVariable" = "Luo alias kirjastomuuttujaan" +"library.applyStyle" = "Käytä kirjastotyyliä" +"library.bindVariable" = "Sido kirjastomuuttuja" +"library.extendCollection" = "Laajenna kirjastokokoelmaa" +"library.import" = "Tuo kirjastokomponentti" +"library.importVariable" = "Tuo kirjastomuuttuja" +"library.list" = "Luettele kirjastot" +"library.place" = "Sijoita kirjastokomponentti" +"library.publish" = "Julkaise kirjasto" +"library.publishToServer" = "Julkaise kirjasto palvelimelle" +"library.remove" = "Poista kirjasto" +"library.setVariableMode" = "Aseta kirjastomuuttujan tila" +"library.swap" = "Vaihda kirjasto" +"library.swapAt" = "Vaihda ilmentymä kirjastokomponenttiin" +"library.updates" = "Kirjaston päivitykset" +"library.usage" = "Kirjaston käyttö" +"measure.distances" = "Mittaa etäisyyksiä" +"measure.padding" = "Mittaa automaattisen asettelun sisämarginaali" +"node.create" = "Luo taso" +"node.get" = "Hae taso" +"node.reorder" = "Muuta tason järjestystä" +"node.reparent" = "Siirrä taso kohteeseen" +"node.set" = "Aseta ominaisuudet" +"object.batchRename" = "Nimeä joukko uudelleen" +"object.convertToFrame" = "Muunna kehykseksi" +"object.convertToGroup" = "Muunna ryhmäksi" +"object.convertToSection" = "Muunna osioksi" +"object.flipH" = "Peilaa vaakasuunnassa" +"object.flipV" = "Peilaa pystysuunnassa" +"object.frameSelection" = "Kehystä valinta" +"object.group" = "Ryhmittele valinta" +"object.hideOthers" = "Kätke muut tasot" +"object.mask" = "Käytä maskina" +"object.moveToPage" = "Siirrä sivulle" +"object.opacityKeys" = "Peittävyys numeronäppäimillä" +"object.rasterize" = "Rasteroi valinta" +"object.releaseMask" = "Vapauta maski" +"object.removeFill" = "Poista täyttö" +"object.removeFrame" = "Poista kehys" +"object.removeStroke" = "Poista reunaviiva" +"object.rename" = "Nimeä uudelleen" +"object.resizeToFit" = "Sovita koko" +"object.rotate180" = "Kierrä 180°" +"object.rotateLeft" = "Kierrä 90° vasemmalle" +"object.rotateRight" = "Kierrä 90° oikealle" +"object.setThumbnail" = "Aseta pikkukuvaksi" +"object.swapFillStroke" = "Vaihda täyttö ja reunaviiva keskenään" +"object.toggleLock" = "Lukitse/avaa valinnan lukitus" +"object.toggleVisible" = "Näytä/kätke valinta" +"object.ungroup" = "Pura valinnan ryhmittely" +"object.wrapInSection" = "Ympäröi uudella osiolla" +"page.add" = "Lisää sivu" +"page.delete" = "Poista sivu" +"page.duplicate" = "Monista sivu" +"page.list" = "Luettele sivut" +"page.move" = "Siirrä sivu" +"page.rename" = "Nimeä sivu uudelleen" +"page.select" = "Siirry sivulle" +"page.setBackground" = "Sivun tausta" +"paint.documentColors" = "Asiakirjan värit" +"plugin.close" = "Sulje liitännäispaneeli" +"plugin.directory.install" = "Asenna liitännäinen hakemistosta" +"plugin.directory.list" = "Selaa liitännäishakemistoa" +"plugin.directory.publish" = "Julkaise liitännäinen hakemistoon" +"plugin.directory.unpublish" = "Peru liitännäisen julkaisu hakemistosta" +"plugin.event" = "Käytä liitännäispaneelin säädintä" +"plugin.import" = "Tuo liitännäinen manifestista…" +"plugin.list" = "Luettele liitännäiset" +"plugin.remove" = "Poista liitännäinen" +"plugin.run" = "Suorita liitännäinen" +"plugin.ui" = "Liitännäispaneeli" +"plugins.manage" = "Hallitse liitännäisiä…" +"plugins.runLast" = "Suorita viimeisin liitännäinen" +"pref.autoSave" = "Tallenna tiedostot automaattisesti" +"pref.highlightHover" = "Korosta tasot osoitettaessa" +"pref.keepTool" = "Pidä työkalu valittuna käytön jälkeen" +"pref.language" = "Käyttöliittymän kieli" +"pref.nudge" = "Siirtoaskeleen koko" +"pref.numberKeysOpacity" = "Säädä peittävyyttä numeronäppäimillä" +"pref.publishServer" = "Verkkojakamisen palvelin" +"pref.reduceMotion" = "Vähennä liikettä" +"pref.renameDuplicates" = "Nimeä monistetut tasot uudelleen" +"pref.showDimensions" = "Näytä objektien mitat" +"pref.startOnHome" = "Aloita Aloitus-näkymästä" +"pref.userName" = "Nimesi kommenteissa" +"proto.action.add" = "Lisää prototyyppitoiminto" +"proto.action.move" = "Muuta prototyyppitoimintojen järjestystä" +"proto.action.remove" = "Poista prototyyppitoiminto" +"proto.action.set" = "Muokkaa prototyyppitoimintoa" +"proto.closePreview" = "Sulje prototyypin esikatselu" +"proto.conditional.addBlock" = "Lisää muuten jos / muuten" +"proto.conditional.removeBlock" = "Poista jos / muuten jos / muuten" +"proto.conditional.setCondition" = "Muokkaa ehtoa" +"proto.evaluate" = "Laske prototyyppilauseke" +"proto.flow.add" = "Lisää kulun aloituspiste" +"proto.flow.remove" = "Poista kulku" +"proto.flow.rename" = "Nimeä kulku uudelleen" +"proto.interaction.add" = "Lisää vuorovaikutus" +"proto.interaction.clear" = "Poista kaikki vuorovaikutukset" +"proto.interaction.remove" = "Poista vuorovaikutus" +"proto.interaction.set" = "Muokkaa vuorovaikutusta" +"proto.present" = "Esitä" +"proto.presentOptions" = "Esityksen asetukset" +"proto.preview" = "Avaa prototyypin esikatselu" +"proto.settings" = "Prototyypin asetukset" +"section.toggleContents" = "Kätke/näytä osion sisältö" +"select.add" = "Lisää valintaan" +"select.all" = "Valitse kaikki" +"select.area" = "Valitse alue" +"select.byName" = "Valitse tasoja nimen perusteella" +"select.children" = "Valitse alikohteet" +"select.inverse" = "Käännä valinta" +"select.layersAt" = "Pisteessä olevat tasot" +"select.matching" = "Valitse vastaavat tasot" +"select.next" = "Valitse seuraava rinnakkaiskohde" +"select.none" = "Poista valinta" +"select.parent" = "Valitse emotaso" +"select.previous" = "Valitse edellinen rinnakkaiskohde" +"select.remove" = "Poista valinnasta" +"select.sameEffect" = "Valitse sama tehoste" +"select.sameFill" = "Valitse sama täyttö" +"select.sameFont" = "Valitse sama fontti" +"select.sameInstance" = "Valitse sama ilmentymä" +"select.sameProperties" = "Valitse samat ominaisuudet" +"select.sameStroke" = "Valitse sama reunaviiva" +"select.sameText" = "Valitse samat tekstiominaisuudet" +"select.set" = "Valitse" +"selection.applyColorStyle" = "Käytä valinnan värityyliä" +"selection.colors" = "Valinnan värit" +"selection.selectColor" = "Valitse tämänväriset tasot" +"selection.setPaint" = "Muuta valinnan väriä" +"shape.arrow" = "Nuoli" +"shape.ellipse" = "Ellipsi" +"shape.line" = "Viiva" +"shape.polygon" = "Monikulmio" +"shape.rectangle" = "Suorakulmio" +"shape.star" = "Tähti" +"shapeBuilder.merge" = "Yhdistä alueet" +"shapeBuilder.mode" = "Muodonrakentimen tila" +"shapeBuilder.regions" = "Muodonrakentimen alueet" +"shapeBuilder.remove" = "Poista alueet" +"stroke.add" = "Lisää reunaviiva" +"stroke.brush" = "Sivellin" +"stroke.move" = "Siirrä reunaviiva" +"stroke.remove" = "Poista reunaviiva" +"stroke.set" = "Aseta reunaviiva" +"stroke.widthProfile" = "Leveysprofiili" +"style.apply" = "Käytä tyyliä" +"style.bind" = "Sido muuttuja tyylissä" +"style.create" = "Luo tyyli" +"style.delete" = "Poista tyyli" +"style.detach" = "Irrota tyyli" +"style.duplicate" = "Monista tyyli" +"style.list" = "Luettele tyylit" +"style.move" = "Siirrä tyylejä" +"style.renameFolder" = "Nimeä tyylikansio uudelleen" +"style.update" = "Muokkaa tyyliä" +"template.list" = "Luettele mallit" +"text.alignCenter" = "Keskitä" +"text.alignJustify" = "Tasaa molemmat reunat" +"text.alignLeft" = "Tasaa vasemmalle" +"text.alignRight" = "Tasaa oikealle" +"text.bold" = "Lihavointi" +"text.bulletList" = "Luettelomerkitty luettelo" +"text.case" = "Kirjainkoko" +"text.caseLower" = "Pienet kirjaimet" +"text.caseOriginal" = "Alkuperäinen kirjainkoko" +"text.caseSmallCaps" = "Kapiteelit" +"text.caseTitle" = "Sanojen isot alkukirjaimet" +"text.caseUpper" = "Isot kirjaimet" +"text.create" = "Teksti" +"text.decoration" = "Koristelu" +"text.edit.begin" = "Muokkaa tekstiä" +"text.edit.caret" = "Aseta kohdistin" +"text.edit.end" = "Lopeta tekstin muokkaus" +"text.edit.insert" = "Kirjoita tekstiä" +"text.edit.key" = "Tekstinäppäin" +"text.edit.preedit" = "Koosta tekstiä (IME)" +"text.edit.selectLine" = "Valitse rivi" +"text.edit.selectWord" = "Valitse sana" +"text.edit.selection" = "Tekstivalinta" +"text.endPreview" = "Lopeta tekstin esikatselu" +"text.fonts" = "Luettele fontit" +"text.italic" = "Kursivointi" +"text.lineHeightDown" = "Pienennä rivikorkeutta" +"text.lineHeightUp" = "Suurenna rivikorkeutta" +"text.link" = "Luo linkki" +"text.listIndent" = "Sisennä luettelo" +"text.listOutdent" = "Vähennä luettelon sisennystä" +"text.listStyle" = "Luettelotyyli" +"text.loadFont" = "Lataa fonttitiedosto" +"text.maxLines" = "Rivien enimmäismäärä" +"text.missingFonts" = "Puuttuvat fontit" +"text.numberList" = "Numeroitu luettelo" +"text.onPath" = "Teksti polulla" +"text.paragraphIndent" = "Kappaleen sisennys" +"text.paragraphSpacing" = "Kappaleväli" +"text.pathOptions" = "Tekstipolun asetukset" +"text.preview" = "Esikatsele tekstimuutosta" +"text.refit" = "Sovita teksti fontteihin" +"text.removeFromPath" = "Poista teksti polulta" +"text.rescanFonts" = "Etsi asennetut fontit uudelleen" +"text.set" = "Aseta teksti" +"text.setFeatures" = "OpenType-ominaisuudet" +"text.setVariations" = "Muuttuvan fontin akselit" +"text.sizeDown" = "Pienennä fonttikokoa" +"text.sizeUp" = "Suurenna fonttikokoa" +"text.strikethrough" = "Yliviivaus" +"text.style" = "Muotoile tekstialue" +"text.trackingDown" = "Pienennä merkkiväliä" +"text.trackingUp" = "Suurenna merkkiväliä" +"text.underline" = "Alleviivaus" +"text.weightDown" = "Vähennä fontin paksuutta" +"text.weightUp" = "Lisää fontin paksuutta" +"tool.arrow" = "Nuoli" +"tool.comment" = "Kommentti" +"tool.ellipse" = "Ellipsi" +"tool.frame" = "Kehys" +"tool.hand" = "Käsi" +"tool.line" = "Viiva" +"tool.move" = "Siirrä" +"tool.pen" = "Kynä" +"tool.pencil" = "Lyijykynä" +"tool.pencilBrush" = "Lyijykynäsivellin" +"tool.place" = "Sijoita työkalulla" +"tool.polygon" = "Monikulmio" +"tool.rectangle" = "Suorakulmio" +"tool.scale" = "Skaalaus" +"tool.scaleAnchor" = "Skaalauksen ankkuri" +"tool.section" = "Osio" +"tool.set" = "Valitse työkalu" +"tool.shapeBuilder" = "Muodonrakennin" +"tool.slice" = "Viipale" +"tool.star" = "Tähti" +"tool.text" = "Teksti" +"transform.move" = "Siirrä" +"transform.moveTo" = "Siirrä kohteeseen" +"transform.nudge" = "Siirrä askelittain" +"transform.nudgeSize" = "Muuta kokoa askelittain" +"transform.resize" = "Muuta kokoa" +"transform.rotate" = "Kierrä" +"transform.rotationOrigin" = "Siirrä kiertokeskipistettä" +"transform.scale" = "Skaalaus" +"transform.setRotationOrigin" = "Aseta kiertokeskipiste" +"variable.addMode" = "Lisää tila" +"variable.bind" = "Sido muuttuja" +"variable.create" = "Luo muuttuja" +"variable.createCollection" = "Luo muuttujakokoelma" +"variable.delete" = "Poista muuttuja" +"variable.deleteCollection" = "Poista kokoelma" +"variable.duplicate" = "Monista muuttuja" +"variable.export" = "Vie muuttujat" +"variable.import" = "Tuo muuttujat" +"variable.list" = "Luettele muuttujat" +"variable.moveMode" = "Siirrä tila" +"variable.removeMode" = "Poista tila" +"variable.renameCollection" = "Nimeä kokoelma uudelleen" +"variable.renameMode" = "Nimeä tila uudelleen" +"variable.resetOverride" = "Palauta muuttujan ohitus" +"variable.set" = "Aseta muuttujan arvo" +"variable.setMode" = "Aseta muuttujan tila" +"variable.update" = "Muokkaa muuttujaa" +"vector.create" = "Vektori" +"vector.deleteHeal" = "Poista ja korjaa valinta" +"vector.edit.addVertex" = "Lisää piste" +"vector.edit.begin" = "Muokkaa vektoria" +"vector.edit.bend" = "Taivuta segmenttiä" +"vector.edit.bounds" = "Valittujen pisteiden rajat" +"vector.edit.connect" = "Yhdistä pisteet" +"vector.edit.cornerRadius" = "Pisteen kulmasäde" +"vector.edit.cut" = "Leikkaa polku" +"vector.edit.delete" = "Poista pisteet" +"vector.edit.end" = "Lopeta muokkaus" +"vector.edit.erase" = "Pyyhi" +"vector.edit.lasso" = "Valitse lassolla" +"vector.edit.mirroring" = "Kahvojen peilaus" +"vector.edit.moveHandles" = "Siirrä kahvoja" +"vector.edit.moveVertices" = "Siirrä pisteitä" +"vector.edit.nudge" = "Siirrä pisteitä askelittain" +"vector.edit.paint" = "Maalipurkki" +"vector.edit.pen" = "Kynän piste" +"vector.edit.penEnd" = "Päätä kynäpolku" +"vector.edit.position" = "Pisteen sijainti" +"vector.edit.resize" = "Muuta pisteiden kokoa" +"vector.edit.rotate" = "Kierrä pisteitä" +"vector.edit.select" = "Valitse pisteitä" +"vector.edit.selectAll" = "Valitse kaikki pisteet" +"vector.edit.setHandle" = "Aseta kahva" +"vector.edit.smooth" = "Pehmennä tai terävöitä pisteitä" +"vector.edit.state" = "Vektorimuokkauksen tila" +"vector.edit.strokeCap" = "Pisteen viivanpää" +"vector.edit.strokeJoin" = "Pisteen viivaliitos" +"vector.edit.tool" = "Vektorimuokkauksen työkalu" +"vector.edit.transform" = "Muunna pisteitä" +"vector.edit.widthPoint" = "Leveyspiste" +"vector.flatten" = "Yhdistä vektorimuodoksi" +"vector.join" = "Yhdistä valinta" +"vector.offset" = "Siirrä polun reunaa" +"vector.outlineStroke" = "Muunna reunaviiva poluksi" +"vector.reverse" = "Käännä polun suunta" +"vector.simplify" = "Yksinkertaista vektori" +"vector.smoothJoin" = "Yhdistä valinta pehmeästi" +"version.autosave" = "Tallenna versio automaattisesti" +"version.delete" = "Poista versio" +"version.duplicate" = "Monista versio" +"version.exitPreview" = "Takaisin nykyiseen versioon" +"version.list" = "Luettele versiot" +"version.preview" = "Katsele versiota" +"version.rename" = "Nimeä versio uudelleen" +"version.restore" = "Palauta versio" +"view.assets" = "Resurssipaneeli" +"view.comments" = "Kommentit" +"view.contextMenu" = "Näytä pikavalikko" +"view.design" = "Suunnittelupaneeli" +"view.designPrototype" = "Vaihda suunnittelun ja prototyypin välillä" +"view.devMode" = "Kehittäjätila" +"view.frameOutlines" = "Kehysten ääriviivat" +"view.fullscreen" = "Koko näyttö" +"view.get" = "Hae näkymä" +"view.layers" = "Tasopaneeli" +"view.layoutGrids" = "Asetteluruudukot" +"view.leftSidebar" = "Näytä/kätke vasen sivupalkki" +"view.maskOutlines" = "Maskien ääriviivat" +"view.multiplayerCursors" = "Yhteiskäytön kohdistimet" +"view.nextFrame" = "Zoomaa seuraavaan kehykseen" +"view.nextPage" = "Seuraava sivu" +"view.nextRegion" = "Kohdista seuraava alue" +"view.outlines" = "Ääriviivat" +"view.outlinesHiddenLayers" = "Sisällytä kätketyt tasot ääriviivoihin" +"view.outlinesObjectBounds" = "Sisällytä objektien rajat ääriviivoihin" +"view.outlinesSelection" = "Valinnan ääriviivat" +"view.pan" = "Siirrä näkymää" +"view.panelWidths" = "Palauta paneelien leveydet" +"view.pixelGrid" = "Pikseliruudukko" +"view.pixelPreview" = "Pikseliesikatselu" +"view.prevFrame" = "Zoomaa edelliseen kehykseen" +"view.prevPage" = "Edellinen sivu" +"view.previousRegion" = "Kohdista edellinen alue" +"view.proofSrgb" = "sRGB-vedossimulointi" +"view.prototype" = "Prototyyppipaneeli" +"view.rulers" = "Viivaimet" +"view.set" = "Aseta näkymä" +"view.slices" = "Viipaleet" +"view.snapGeometry" = "Tartu geometriaan" +"view.snapObjects" = "Tartu objekteihin" +"view.snapPixel" = "Tartu pikseliruudukkoon" +"view.themeDark" = "Tumma teema" +"view.themeLight" = "Vaalea teema" +"view.themeSystem" = "Seuraa järjestelmän teemaa" +"view.toggleUi" = "Näytä/kätke käyttöliittymä" +"view.zoom100" = "Zoomaa kokoon 100 %" +"view.zoom200" = "Zoomaa kokoon 200 %" +"view.zoom50" = "Zoomaa kokoon 50 %" +"view.zoomAt" = "Zoomaustyökalu" +"view.zoomFit" = "Sovita näkymään" +"view.zoomIn" = "Lähennä" +"view.zoomOut" = "Loitonna" +"view.zoomSelection" = "Zoomaa valintaan" +"widget.click" = "Napsauta pienoisohjelmaa" +"widget.insert" = "Lisää pienoisohjelma" +"widget.list" = "Luettele pienoisohjelmat" +"window.focus" = "Tuo ikkuna etummaiseksi" +"window.new" = "Uusi ikkuna" + +[menu] +"bool.exclude" = "Jätä pois" +"bool.flatten" = "Yhdistä vektorimuodoksi" +"bool.intersect" = "Leikkaus" +"bool.subtract" = "Vähennä" +"bool.union" = "Yhdiste" +"file.duplicate" = "Monista" +"file.import" = "Tuo tiedostosta…" +"file.rename" = "Nimeä uudelleen" +"libraries.open" = "Avaa kirjastot" +"select.sameEffect" = "Tehoste" +"select.sameFill" = "Täyttö" +"select.sameFont" = "Fontti" +"select.sameInstance" = "Ilmentymä" +"select.sameProperties" = "Ominaisuudet" +"select.sameStroke" = "Reunaviiva" +"select.sameText" = "Tekstiominaisuudet" +"text.alignCenter" = "Keskitä teksti" +"text.alignJustify" = "Tasaa tekstin molemmat reunat" +"text.alignLeft" = "Tasaa teksti vasemmalle" +"text.alignRight" = "Tasaa teksti oikealle" +"view.assets" = "Resurssit" +"view.design" = "Suunnittelu" +"view.layers" = "Tasot" +"view.outlines" = "Näytä ääriviivat" +"view.outlinesHiddenLayers" = "Sisällytä kätketyt tasot" +"view.outlinesObjectBounds" = "Sisällytä objektien rajat" +"view.outlinesSelection" = "Näytä valinnan ääriviivat" +"view.prototype" = "Prototyyppi" +"view.themeDark" = "Tumma" +"view.themeLight" = "Vaalea" +"view.themeSystem" = "Seuraa järjestelmää" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 5edf1a624..b038b3356 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -318,6 +318,7 @@ status = "draft — needs native review" "Corner smoothing" = "Lissage des angles" "Couldn't download the variables" = "Impossible de télécharger les variables" "Couldn't reach {server}: showing what this computer remembers." = "Impossible de joindre {server} : affichage de ce dont cet ordinateur se souvient." +"Couldn't read {name}." = "Impossible de lire {name}." "Couldn't save automatically: {error}" = "Impossible d’enregistrer automatiquement : {error}" "Count" = "Nombre" "Create" = "Créer" @@ -652,6 +653,7 @@ status = "draft — needs native review" "Interaction" = "Interaction" "Interactions" = "Interactions" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Design d’interfaces et prototypage. Un outil de design libre et gratuit, développé en salle blanche (clean-room) et écrit de zéro en Rust, membre des Crafting Apps d’ArtCraft." +"Interface size" = "Taille de l’interface" "Intersect" = "Intersection" "Intersects" = "Intersections" "Invalid color" = "Couleur non valide" @@ -947,6 +949,7 @@ status = "draft — needs native review" "PDF print settings" = "Réglages d’impression PDF" "Padding" = "Marge intérieure" "Page" = "Page" +"Page deleted" = "Page supprimée" "Pages" = "Pages" "Pages added to the document" = "Pages ajoutées au document" "Pick the Frame tool, then drag on the canvas or choose a device size" = "Choisissez l’outil Cadre, puis faites glisser sur le canevas ou choisissez une taille d’appareil" @@ -1058,6 +1061,7 @@ status = "draft — needs native review" "Reduce motion" = "Réduire les animations" "Refraction" = "Réfraction" "Regular expression" = "Expression régulière" +"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Relative à la mise à l’échelle d’affichage de votre système (100 % est la taille du système). Le zoom du document est distinct." "Remove" = "Retirer" "Remove all" = "Tout retirer" "Remove annotation" = "Retirer l’annotation" @@ -1293,6 +1297,7 @@ status = "draft — needs native review" "Start from a blank canvas" = "Commencer avec un canevas vide" "Start from a template" = "Commencer avec un modèle" "Start on path" = "Début sur le tracé" +"Start on the Home screen" = "Démarrer sur l’Accueil" "Start point" = "Point de départ" "Start sharing" = "Commencer le partage" "Start sharing with the Share button, then pass on the code it gives" = "Commencez à partager avec le bouton Partager, puis communiquez le code qu’il donne" @@ -1366,6 +1371,7 @@ status = "draft — needs native review" "The selection uses several text styles: click to apply one" = "La sélection utilise plusieurs styles de texte : cliquez pour en appliquer un" "The server deletes this file if nobody opens it before {date}." = "Le serveur supprime ce fichier si personne ne l’ouvre avant le {date}." "The whole file" = "Le fichier entier" +"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "La fenêtre est trop petite pour {size} %, l’interface est donc affichée à {drawn} %. Agrandissez la fenêtre pour utiliser {size} %." "Theme" = "Thème" "There's no destination for that action" = "Cette action n’a pas de destination" "Thickness" = "Épaisseur" @@ -1443,6 +1449,7 @@ status = "draft — needs native review" "Use number keys for opacity" = "Utiliser les touches numériques pour l’opacité" "Use the .figcraft or .fig extension." = "Utilisez l’extension .figcraft ou .fig." "Use the layer's fill" = "Utiliser le remplissage du calque" +"Use your browser's zoom to change the interface size." = "Utilisez le zoom de votre navigateur pour modifier la taille de l’interface." "Used in {files} · {instances}" = "Utilisé dans {files} · {instances}" "Username" = "Nom d’utilisateur" "Uses variables: each layer shows them in its own mode" = "Utilise des variables : chaque calque les affiche dans son propre mode" @@ -1665,6 +1672,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "{name} est oublié sur cet ordinateur ; le fichier reste sur {server}" "{name} is on the server." = "{name} est sur le serveur." "{name} is spotlighting" = "{name} est sous les projecteurs" +"{name} is too large to open in the browser (over 512 MB)." = "{name} est trop volumineux pour être ouvert dans le navigateur (plus de 512 Mo)." "{name} was removed from the server" = "{name} a été retiré du serveur" "{name} · deleted on main" = "{name} · supprimé dans le principal" "{name} · deleted on the branch" = "{name} · supprimé dans la branche" @@ -1710,6 +1718,7 @@ status = "draft — needs native review" "{n}m" = "{n} min" "{n}w" = "{n} sem." "{page}, nothing selected" = "{page}, aucune sélection" +"{percent}%" = "{percent} %" "{profile} (current)" = "{profile} (actuel)" "{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} donne sa valeur ; {property ? \"a\" : \"b\"} choisit un texte. Tout autre {…} reste tel quel." "{server} can't say what became of your files (it is older than this app)." = "{server} ne peut pas dire ce que sont devenus vos fichiers (il est plus ancien que cette application)." @@ -1879,6 +1888,7 @@ status = "draft — needs native review" "file.exportLayers" = "Exporter les calques" "file.exportSvg" = "Exporter la sélection en SVG" "file.import" = "Importer" +"file.moveTab" = "Déplacer l’onglet" "file.new" = "Nouveau fichier de design" "file.newFromTemplate" = "Nouveau à partir d’un modèle" "file.open" = "Ouvrir" @@ -2021,6 +2031,8 @@ status = "draft — needs native review" "pref.reduceMotion" = "Réduire les animations" "pref.renameDuplicates" = "Renommer les calques dupliqués" "pref.showDimensions" = "Afficher les dimensions sur les objets" +"pref.startOnHome" = "Démarrer sur l’Accueil" +"pref.uiScale" = "Taille de l’interface" "pref.userName" = "Votre nom dans les commentaires" "proto.action.add" = "Ajouter une action de prototype" "proto.action.move" = "Réordonner l’action de prototype" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 65a9939cb..f21e1ca3c 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -314,6 +314,7 @@ status = "draft — needs native review" "Corner radius" = "Radius sudut" "Corner smoothing" = "Penghalusan sudut" "Couldn't reach {server}: showing what this computer remembers." = "Tidak dapat terhubung ke {server}: menampilkan data yang diingat komputer ini." +"Couldn't read {name}." = "Tidak dapat membaca {name}." "Count" = "Jumlah" "Create" = "Buat" "Create a variable collection first" = "Buat koleksi variabel terlebih dahulu" @@ -933,6 +934,7 @@ status = "draft — needs native review" "PDF print settings" = "Pengaturan cetak PDF" "Padding" = "Padding" "Page" = "Halaman" +"Page deleted" = "Halaman dihapus" "Pages" = "Halaman" "Pages added to the document" = "Halaman ditambahkan ke dokumen" "Placed on the current page" = "Ditempatkan di halaman saat ini" @@ -1633,6 +1635,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "{name} dilupakan di komputer ini; file tetap ada di {server}" "{name} is on the server." = "{name} ada di server." "{name} is spotlighting" = "{name} sedang spotlight" +"{name} is too large to open in the browser (over 512 MB)." = "{name} terlalu besar untuk dibuka di browser (lebih dari 512 MB)." "{name}\nClick to apply to the selection" = "{name}\nKlik untuk menerapkan ke pilihan" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nSeret ke kanvas, atau klik untuk menaruh di tengah tampilan.\nTahan Alt (Option) saat melepas di atas instance untuk menukarnya." "{name} (from {library})" = "{name} (dari {library})" @@ -1853,6 +1856,7 @@ status = "draft — needs native review" "file.exportLayers" = "Ekspor layer" "file.exportSvg" = "Ekspor pilihan sebagai SVG" "file.import" = "Impor" +"file.moveTab" = "Pindahkan tab" "file.new" = "File desain baru" "file.newFromTemplate" = "Baru dari templat" "file.open" = "Buka" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index def8ebc47..c42790f35 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -316,6 +316,7 @@ status = "draft — needs native review" "Corner radius" = "角の半径" "Corner smoothing" = "角のスムージング" "Couldn't reach {server}: showing what this computer remembers." = "{server} に接続できない:このコンピューターが覚えている内容を表示中。" +"Couldn't read {name}." = "{name} を読み込めませんでした。" "Couldn't save automatically: {error}" = "自動保存できませんでした:{error}" "Count" = "数" "Create" = "作成" @@ -938,6 +939,7 @@ status = "draft — needs native review" "PDF print settings" = "PDF 印刷設定" "Padding" = "パディング" "Page" = "ページ" +"Page deleted" = "ページは削除済み" "Pages" = "ページ" "Pages added to the document" = "ページをドキュメントに追加した" "Placed on the current page" = "現在のページに配置した" @@ -1282,6 +1284,7 @@ status = "draft — needs native review" "Start from a blank canvas" = "空白のキャンバスから始める" "Start from a template" = "テンプレートから始める" "Start on path" = "パス上の開始位置" +"Start on the Home screen" = "起動時にホームを表示" "Start point" = "始点" "Start sharing" = "共有を開始" "Start sharing with the Share button, then pass on the code it gives" = "共有ボタンで共有を開始し、表示されたコードを相手に伝える" @@ -1650,6 +1653,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "{name} の記録をこのコンピューターから消去済み。ファイルは {server} に残る" "{name} is on the server." = "{name} はサーバーにある。" "{name} is spotlighting" = "{name} がスポットライト中" +"{name} is too large to open in the browser (over 512 MB)." = "{name} は大きすぎるため、ブラウザーで開けません(512 MB 超)。" "{name} was removed from the server" = "{name} をサーバーから削除済み" "{name} · deleted on main" = "{name} · メインで削除" "{name} · deleted on the branch" = "{name} · ブランチで削除" @@ -1864,6 +1868,7 @@ status = "draft — needs native review" "file.exportLayers" = "レイヤーを書き出し" "file.exportSvg" = "選択範囲を SVG で書き出し" "file.import" = "インポート" +"file.moveTab" = "タブを移動" "file.new" = "新規デザインファイル" "file.newFromTemplate" = "テンプレートから新規作成" "file.open" = "開く" @@ -2005,6 +2010,7 @@ status = "draft — needs native review" "pref.reduceMotion" = "動きを減らす" "pref.renameDuplicates" = "複製したレイヤーの名前を変更" "pref.showDimensions" = "オブジェクトに寸法を表示" +"pref.startOnHome" = "起動時にホームを表示" "pref.userName" = "コメントに表示する名前" "proto.action.add" = "プロトタイプのアクションを追加" "proto.action.move" = "プロトタイプのアクションの順序を変更" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml new file mode 100644 index 000000000..5b73b4fbc --- /dev/null +++ b/crates/ui-egui/locales/ka.toml @@ -0,0 +1,2345 @@ +# FigCraft interface strings: Georgian. +# +# Written with AI assistance (Claude) from the meaning of each English string, following the glossary +# the Craft apps share, and reviewed by a native Georgian speaker; kept as a draft until a maintainer +# changes its status. Brand names and keyboard glyphs stay as they are. Georgian has no letter case, +# and a noun after a numeral stays singular, so counts read "შრე: {n}" and plural entries give the +# same text for one and other. See docs/translating.md. + +[meta] +name = "ქართული" +status = "draft — reviewed by a native speaker, awaiting a maintainer's approval" + +[ui] +"% of all commits" = "ყველა კომიტის %" +"(no longer published)" = "(აღარ არის გამოქვეყნებული)" +"({n} warnings: some entries were skipped)" = { one = "(გაფრთხილება: {n}; ზოგი ჩანაწერი გამოტოვებულია)", other = "(გაფრთხილება: {n}; ზოგი ჩანაწერი გამოტოვებულია)" } +"+ Create variable" = "+ ცვლადის შექმნა" +".fig (experimental)" = ".fig (ექსპერიმენტული)" +"1…9, 0" = "1…9, 0" +"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR: {prs}, კომიტი: {commits}, +{added} / −{deleted} სტრიქონი (Δ {delta}), +{bin_added} / −{bin_deleted} ბინარული რესურსი, {first} – {last}" +"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "ამ ფაილის კოლექცია ამ რეჟიმებითა და ცვლადებით: შეცვალეთ მათი მნიშვნელობები თითოეული რეჟიმისთვის და დაამატეთ საკუთარი ცვლადები" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "ამ სახელით .figcraft ფაილი უკვე არსებობს. ჩანაცვლების დასადასტურებლად აირჩიეთ მისი სრული სახელი." +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "ცალკე შრე თავის ჩარჩოსთან სწორდება: მონიშნეთ მეტი შრე ან ჩარჩოში მყოფი შრე" +"A responsive product page: hero, features, figures, a quote and a footer" = "პროდუქტის ადაპტური გვერდი: მთავარი ბლოკი, შესაძლებლობები, ციფრები, ციტატა და ქვედა ზოლი" +"A spring plays until it settles: its mass, stiffness and damping set how long" = "ზამბარა მოძრაობს, სანამ არ დამშვიდდება: მისი მასა, სიხისტე და მილევა განსაზღვრავს ხანგრძლივობას" +"A title, an agenda, a big figure, a chart and a closing slide" = "სათაური, დღის წესრიგი, დიდი ციფრი, დიაგრამა და დასკვნითი სლაიდი" +"A to Z" = "ა-დან ჰ-მდე" +"About" = "პროგრამის შესახებ" +"About FigCraft" = "FigCraft-ის შესახებ" +"About this server" = "ამ სერვერის შესახებ" +"Action" = "მოქმედება" +"Actions" = "მოქმედებები" +"Actions for {name}" = "მოქმედებები: {name}" +"Actions…" = "მოქმედებები…" +"Active" = "აქტიური" +"Actual size (100%)" = "რეალური ზომა (100%)" +"Add a category" = "კატეგორიის დამატება" +"Add a comment" = "კომენტარის დამატება" +"Add a description" = "აღწერის დამატება" +"Add a frame (F) or a shape (R) to get started" = "დასაწყებად დაამატეთ ჩარჩო (F) ან ფიგურა (R)" +"Add a layer or component to tile first" = "ჯერ დაამატეთ შრე ან კომპონენტი ფილებად დასაწყობად" +"Add a note for developers" = "შენიშვნის დამატება დეველოპერებისთვის" +"Add action" = "მოქმედების დამატება" +"Add actions for when no condition holds" = "მოქმედებების დამატება იმ შემთხვევისთვის, როცა არცერთი პირობა არ სრულდება" +"Add an annotation for developers" = "ანოტაციის დამატება დეველოპერებისთვის" +"Add another condition" = "კიდევ ერთი პირობის დამატება" +"Add auto layout" = "ავტოგანლაგების დამატება" +"Add background behind overlay" = "ფონის დამატება გადაფარვის უკან" +"Add content to this slot" = "შიგთავსის დამატება ამ სლოტში" +"Add dev resource" = "დეველოპერის რესურსის დამატება" +"Add effect" = "ეფექტის დამატება" +"Add export setting" = "ექსპორტის პარამეტრის დამატება" +"Add fill" = "შევსების დამატება" +"Add interaction" = "ურთიერთქმედების დამატება" +"Add layout grid" = "განლაგების ბადის დამატება" +"Add library" = "ბიბლიოთეკის დამატება" +"Add library file…" = "ბიბლიოთეკის ფაილის დამატება…" +"Add max height" = "მაქსიმალური სიმაღლის დამატება" +"Add max width" = "მაქსიმალური სიგანის დამატება" +"Add min height" = "მინიმალური სიმაღლის დამატება" +"Add min width" = "მინიმალური სიგანის დამატება" +"Add mode" = "რეჟიმის დამატება" +"Add new page" = "ახალი გვერდის დამატება" +"Add or update collections from a variables JSON" = "კოლექციების დამატება ან განახლება ცვლადების JSON-იდან" +"Add properties to make instances configurable" = "დაამატეთ თვისებები, რომ ინსტანსები კონფიგურირებადი გახდეს" +"Add reaction" = "რეაქციის დამატება" +"Add starting point" = "საწყისი წერტილის დამატება" +"Add stroke" = "კონტურის დამატება" +"Add variant" = "ვარიანტის დამატება" +"Add variant with a new value" = "ვარიანტის დამატება ახალი მნიშვნელობით" +"Added" = "დამატებულია" +"Added the library \"{name}\"" = "ბიბლიოთეკა „{name}“ დამატებულია" +"Adding to {name}" = "დამატება: {name}" +"Adjust" = "კორექცია" +"Adjustment presets" = "კორექციის პრესეტები" +"Advanced" = "დამატებითი" +"Advanced stroke" = "კონტურის დამატებითი პარამეტრები" +"After delay" = "დაყოვნების შემდეგ" +"After delay {ms}ms" = "დაყოვნების შემდეგ: {ms} მწმ" +"Align" = "სწორება" +"Align bottom" = "ქვემოთ სწორება" +"Align center" = "ცენტრში სწორება" +"Align horizontal centers" = "ჰორიზონტალური ცენტრების სწორება" +"Align left" = "მარცხნივ სწორება" +"Align middle" = "შუაში სწორება" +"Align right" = "მარჯვნივ სწორება" +"Align text baselines" = "ტექსტის საბაზისო ხაზების სწორება" +"Align top" = "ზემოთ სწორება" +"Align vertical centers" = "ვერტიკალური ცენტრების სწორება" +"Alignment" = "სწორება" +"All" = "ყველა" +"All categories" = "ყველა კატეგორია" +"All comments" = "ყველა კომენტარი" +"All components" = "ყველა კომპონენტი" +"All fonts" = "ყველა შრიფტი" +"All supported properties" = "ყველა მხარდაჭერილი თვისება" +"All the fonts this file uses are installed" = "ამ ფაილის ყველა შრიფტი დაინსტალირებულია" +"All variables" = "ყველა ცვლადი" +"Alpha" = "ალფა" +"Alphabetical" = "ანბანის მიხედვით" +"Alt+drag" = "Alt+გადათრევა" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "ანალიტიკის ეკრანი ბადეზე: ციფრები, ვექტორებით დახატული დიაგრამები და ცხრილი" +"An invite link that opens FigCraft and joins" = "მოსაწვევი ბმული, რომელიც ხსნის FigCraft-ს და უერთდება სესიას" +"Anchor" = "საყრდენი" +"Android" = "Android" +"Angular" = "კუთხური" +"Animation" = "ანიმაცია" +"Annotations" = "ანოტაციები" +"Annotations shown on the canvas" = "ტილოზე ნაჩვენები ანოტაციები" +"Another server, or one that needs its key. The usual server is set in Preferences." = "სხვა სერვერი ან სერვერი, რომელსაც გასაღები სჭირდება. ჩვეულებრივი სერვერი პარამეტრებში ირჩევა." +"Any status" = "ნებისმიერი სტატუსი" +"Anyone on your network with this code can edit the file, while this app stays open." = "თქვენს ქსელში ნებისმიერს, ვისაც ეს კოდი აქვს, შეუძლია ფაილის რედაქტირება, სანამ ეს აპი ღიაა." +"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "რედაქტირების ბმულის მქონე ნებისმიერს შეუძლია ფაილის შეცვლა; ნახვის ბმული მხოლოდ დათვალიერების საშუალებას იძლევა. ბმულები საიდუმლო გასაღებებს შეიცავს: გააზიარეთ ისინი პაროლების მსგავსად. `figcraft-server links` ქმნის ახალ ბმულებს და აუქმებს მათ." +"Anyone with this code can edit the file." = "ნებისმიერს, ვისაც ეს კოდი აქვს, შეუძლია ფაილის რედაქტირება." +"Anyone with this code can look at the file, not change it." = "ნებისმიერს, ვისაც ეს კოდი აქვს, შეუძლია ფაილის ნახვა, მაგრამ არა შეცვლა." +"Appearance" = "იერსახე" +"Apply a component property" = "კომპონენტის თვისების გამოყენება" +"Apply variable" = "ცვლადის გამოყენება" +"Apply variable to text" = "ცვლადის გამოყენება ტექსტზე" +"Apply variable to visibility" = "ცვლადის გამოყენება ხილვადობაზე" +"Apps" = "აპები" +"Apr" = "აპრ" +"Arc" = "რკალი" +"Arc ratio" = "რკალის თანაფარდობა" +"Arc start" = "რკალის დასაწყისი" +"Arc sweep" = "რკალის სიგრძე" +"Archive" = "დაარქივება" +"Archived" = "დაარქივებულია" +"Arrange" = "დალაგება" +"Arrow" = "ისარი" +"As typed" = "როგორც აიკრიფა" +"Asking the server…" = "სერვერთან მოთხოვნა…" +"Assets" = "რესურსები" +"Aug" = "აგვ" +"Auto" = "ავტომატური" +"Auto (space around)" = "ავტო (დაშორება გარშემო)" +"Auto (space between)" = "ავტო (დაშორება შორის)" +"Auto (space evenly)" = "ავტო (თანაბარი დაშორება)" +"Auto ({mode})" = "ავტო ({mode})" +"Auto columns" = "ავტომატური სვეტები" +"Auto gap" = "ავტომატური შუალედი" +"Auto height" = "ავტომატური სიმაღლე" +"Auto layout" = "ავტოგანლაგება" +"Auto rows" = "ავტომატური სტრიქონები" +"Auto width" = "ავტომატური სიგანე" +"Back to the FigCraft server" = "FigCraft-ის სერვერზე დაბრუნება" +"Back to the font's value" = "შრიფტის მნიშვნელობაზე დაბრუნება" +"Back" = "უკან" +"Back to Ready for dev" = "„მზადაა დეველოპერისთვის“ სტატუსზე დაბრუნება" +"Back to Ready for dev: the next change compares with how it is now" = "„მზადაა დეველოპერისთვის“ სტატუსზე დაბრუნება: შემდეგი ცვლილება შედარდება ამჟამინდელ მდგომარეობას" +"Back to assets" = "რესურსებზე დაბრუნება" +"Back to current" = "მიმდინარეზე დაბრუნება" +"Back to styles" = "სტილებზე დაბრუნება" +"Back to the canvas from a focused region" = "ფოკუსირებული არიდან ტილოზე დაბრუნება" +"Back to the current version" = "მიმდინარე ვერსიაზე დაბრუნება" +"Back to the values the font style implies" = "შრიფტის სტილით განსაზღვრულ მნიშვნელობებზე დაბრუნება" +"Background" = "ფონი" +"Background blur" = "ფონის გაბუნდოვნება" +"Basics" = "საფუძვლები" +"Before" = "მანამდე" +"Bend" = "მოხრა" +"Bevel" = "წაკვეთილი" +"Binary assets added" = "დამატებული ბინარული ფაილები" +"Binary assets removed" = "წაშლილი ბინარული ფაილები" +"Black & white" = "შავ-თეთრი" +"Bleed" = "ჭრისპირა არე" +"Bleed marks" = "ჭრისპირა არის ნიშნები" +"Bleed marks need a bleed" = "ჭრისპირა არის ნიშნებს ჭრისპირა არე სჭირდება" +"Blend" = "შერევა" +"Blend mode" = "შერევის რეჟიმი" +"Blue" = "ლურჯი" +"Blur" = "გაბუნდოვნება" +"Boolean" = "ლოგიკური" +"Boolean groups" = "ლოგიკური ოპერაციების ჯგუფები" +"Both directions" = "ორივე მიმართულება" +"Bottom" = "ქვემოთ" +"Bottom left radius" = "ქვედა მარცხენა რადიუსი" +"Bottom right radius" = "ქვედა მარჯვენა რადიუსი" +"Branch" = "განშტოება" +"Branch archived" = "განშტოება დაარქივებულია" +"Branch merged" = "განშტოება შერწყმულია" +"Branch name" = "განშტოების სახელი" +"Branch of {file}" = "{file}-ის განშტოება" +"Branch of {file} · merged or archived" = "{file}-ის განშტოება · შერწყმული ან დაარქივებული" +"Branch updated from main" = "განშტოება განახლდა მთავარიდან" +"Branch {name}" = "განშტოება {name}" +"Branches" = "განშტოებები" +"Bound to {name}. Click to detach the variable." = "მიბმულია: {name}. დაწკაპეთ ცვლადის მოსაწყვეტად." +"Bring forward" = "წინ გადმოტანა" +"Bring the main file's changes into the branch" = "მთავარი ფაილის ცვლილებების განშტოებაში გადმოტანა" +"Bring to front" = "წინა პლანზე გადმოტანა" +"Bristle" = "ჯაგრისი" +"Browse" = "დათვალიერება" +"Brush" = "ფუნჯი" +"Brush rotation jitter" = "ფუნჯის შემობრუნების ცვალებადობა" +"Brush scatter" = "ფუნჯის გაბნევა" +"Brush size jitter" = "ფუნჯის ზომის ცვალებადობა" +"Brush spacing" = "ფუნჯის ბიჯი" +"Bulleted list" = "ბურთულებიანი სია" +"C or Shift+C" = "C ან Shift+C" +"C or ⇧C" = "C ან ⇧C" +"CMYK colour" = "CMYK ფერი" +"Can view" = "შეუძლია ნახვა" +"Checking the fonts installed on this computer…" = "ამ კომპიუტერზე დაინსტალირებული შრიფტების შემოწმება…" +"Checking the server…" = "სერვერის შემოწმება…" +"Clear from this list" = "ამ სიიდან ამოღება" +"Code or invite link" = "კოდი ან მოსაწვევი ბმული" +"Color profile: {name}" = "ფერის პროფილი: {name}" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "ფერისა და ტექსტის სტილები, ღია და მუქი ცვლადები, ღილაკები, შეყვანის ველები და ბარათები" +"Convert to frame" = "ჩარჩოდ გარდაქმნა" +"Convert to section" = "სექციად გარდაქმნა" +"CSS custom properties…" = "CSS-ის მორგებული თვისებები…" +"Can edit" = "შეუძლია რედაქტირება" +"Can't read this library: {e}" = "ამ ბიბლიოთეკის წაკითხვა ვერ მოხერხდა: {e}" +"Cancel" = "გაუქმება" +"Canvas" = "ტილო" +"Canvas stacking" = "ტილოზე დალაგების რიგი" +"Cap height to baseline" = "მთავრული ასოს სიმაღლიდან საბაზისო ხაზამდე" +"Case" = "რეგისტრი" +"Case-sensitive forms" = "რეგისტრზე დამოკიდებული ფორმები" +"Categories" = "კატეგორიები" +"Center" = "ცენტრში" +"Change alias" = "ალიასის შეცვლა" +"Change resolved" = "ცვლილება გადაწყვეტილია" +"Change style" = "სტილის შეცვლა" +"Change to" = "შეცვლა:" +"Change to needs a layer inside a variant" = "„ვარიანტის შეცვლას“ ვარიანტის შიგნით შრე სჭირდება" +"Changed" = "შეცვლილია" +"Changed on both sides: keep one" = "შეცვლილია ორივე მხარეს: დატოვეთ ერთი" +"Changes on main" = "ცვლილებები მთავარში" +"Changes on the branch" = "ცვლილებები განშტოებაში" +"Changes unknown" = "ცვლილებები უცნობია" +"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "ამ კომპონენტის კოდთან დასაკავშირებლად ზემოთ აირჩიეთ React, Leptos, SwiftUI ან Compose." +"Choose another profile to change it." = "მის შესაცვლელად აირჩიეთ სხვა პროფილი." +"Choose collection" = "კოლექციის არჩევა" +"Choose image" = "გამოსახულების არჩევა" +"Choose mode" = "რეჟიმის არჩევა" +"Choose variable" = "ცვლადის არჩევა" +"Circle" = "წრე" +"Clear recent" = "ბოლოდროინდელის გასუფთავება" +"Click + to replace mixed content" = "დაწკაპეთ +, რომ შერეული შიგთავსი ჩაანაცვლოთ" +"Click anywhere on the canvas to leave one." = "მის დასატოვებლად დაწკაპეთ ტილოს ნებისმიერ ადგილას." +"Click or drag to merge · {alt} removes" = "დაწკაპეთ ან გადაათრიეთ შესარწყმელად · {alt} შლის" +"Click or drag to remove · {alt} merges" = "დაწკაპეთ ან გადაათრიეთ წასაშლელად · {alt} შერწყამს" +"Click to record a key" = "დაწკაპეთ კლავიშის ჩასაწერად" +"Clip content" = "შიგთავსის მოჭრა" +"Clip to shape" = "ფიგურაზე მოჭრა" +"Close" = "დახურვა" +"Close overlay" = "გადაფარვის დახურვა" +"Close preview" = "წინასწარი ნახვის დახურვა" +"Close tab" = "ჩანართის დახურვა" +"Close version history" = "ვერსიების ისტორიის დახურვა" +"Close when clicking outside" = "დახურვა გარეთ დაწკაპუნებისას" +"Close without saving" = "დახურვა შენახვის გარეშე" +"Code" = "კოდი" +"Code syntax" = "კოდის სინტაქსი" +"Collapse" = "ჩაკეცვა" +"Collapse panel" = "პანელის ჩაკეცვა" +"Collection" = "კოლექცია" +"Collections" = "კოლექციები" +"Color" = "ფერი" +"Color burn" = "ფერით ჩამუქება" +"Color dodge" = "ფერით გაღიავება" +"Color format" = "ფერის ფორმატი" +"Color profile" = "ფერის პროფილი" +"Color styles" = "ფერის სტილები" +"Color values in this file are in this color space." = "ამ ფაილში ფერის მნიშვნელობები ამ ფერთა სივრცეშია." +"Color variables" = "ფერის ცვლადები" +"Colour" = "ფერი" +"Column" = "სვეტი" +"Column gap" = "სვეტებს შორის შუალედი" +"Column sizes" = "სვეტების ზომები" +"Column span" = "სვეტების გაერთიანება" +"Columns" = "სვეტები" +"Columns {count}" = "სვეტები {count}" +"Comment" = "კომენტარი" +"Comments" = "კომენტარები" +"Commits" = "კომიტები" +"Company" = "კომპანია" +"Compare changes" = "ცვლილებების შედარება" +"Compatibility notes" = "თავსებადობის შენიშვნები" +"Completed" = "დასრულებული" +"Component" = "კომპონენტი" +"Component name" = "კომპონენტის სახელი" +"Component property" = "კომპონენტის თვისება" +"Component set" = "კომპონენტების ნაკრები" +"Component sets" = "კომპონენტების ნაკრებები" +"Components" = "კომპონენტები" +"Conditional" = "პირობითი" +"Confetti" = "კონფეტი" +"Conflicts" = "კონფლიქტები" +"Connect to code" = "კოდთან დაკავშირება" +"Connected" = "დაკავშირებულია" +"Connected to code: {from}" = "დაკავშირებულია კოდთან: {from}" +"Connection lost, reconnecting (attempt {attempt})…" = "კავშირი გაწყდა, ხელახლა დაკავშირება (მცდელობა {attempt})…" +"Constrain proportions" = "პროპორციების შენარჩუნება" +"Constraints" = "შეზღუდვები" +"Content" = "შიგთავსი" +"Contents hidden" = "შიგთავსი დამალულია" +"Contents only" = "მხოლოდ შიგთავსი" +"Contextual alternates" = "კონტექსტური ალტერნატივები" +"Contrast" = "კონტრასტი" +"Contributors" = "მონაწილეები" +"Contributors: {people} · Commits: {commits}" = "მონაწილეები: {people} · კომიტები: {commits}" +"Controller" = "კონტროლერი" +"Cool" = "ცივი" +"Copied as CSS" = "დაკოპირდა როგორც CSS" +"Copied as PNG" = "დაკოპირდა როგორც PNG" +"Copied as SVG" = "დაკოპირდა როგორც SVG" +"Copied as text" = "დაკოპირდა როგორც ტექსტი" +"Copied {value}" = "დაკოპირდა: {value}" +"Copy" = "კოპირება" +"Copy as" = "კოპირება როგორც" +"Copy as CSS" = "კოპირება როგორც CSS" +"Copy as PNG" = "კოპირება როგორც PNG" +"Copy as SVG" = "კოპირება როგორც SVG" +"Copy code" = "კოდის კოპირება" +"Copy link" = "ბმულის კოპირება" +"Copy the {label}" = "კოპირება: {label}" +"Copy {value}" = "კოპირება: {value}" +"Corner radius" = "კუთხის რადიუსი" +"Corner smoothing" = "კუთხეების დარბილება" +"Couldn't download the variables" = "ცვლადების ჩამოტვირთვა ვერ მოხერხდა" +"Couldn't reach {server}: showing what this computer remembers." = "{server}-თან დაკავშირება ვერ მოხერხდა: ნაჩვენებია ის, რაც ამ კომპიუტერს ახსოვს." +"Couldn't save automatically: {error}" = "ავტომატურად შენახვა ვერ მოხერხდა: {error}" +"Count" = "რაოდენობა" +"Create" = "შექმნა" +"Create a variable collection first" = "ჯერ შექმენით ცვლადების კოლექცია" +"Create a variable first" = "ჯერ შექმენით ცვლადი" +"Create alias" = "ალიასის შექმნა" +"Create branch" = "განშტოების შექმნა" +"Create collection" = "კოლექციის შექმნა" +"Create component" = "კომპონენტის შექმნა" +"Create component property" = "კომპონენტის თვისების შექმნა" +"Create components, styles or variables to publish them as a library." = "შექმენით კომპონენტები, სტილები ან ცვლადები, რომ ბიბლიოთეკად გამოაქვეყნოთ." +"Create property…" = "თვისების შექმნა…" +"Create style" = "სტილის შექმნა" +"Create style from the selected text" = "სტილის შექმნა მონიშნული ტექსტიდან" +"Created" = "შექმნილია" +"Crop" = "ჩამოჭრა" +"Crop marks" = "ჭრის ნიშნები" +"Ctrl+arrows" = "Ctrl+ისრები" +"Ctrl+click" = "Ctrl+დაწკაპუნება" +"Ctrl+scroll" = "Ctrl+გადახვევა" +"Current name" = "მიმდინარე სახელი" +"Current version" = "მიმდინარე ვერსია" +"Cursor chat" = "კურსორის ჩატი" +"Custom" = "მორგებული" +"Custom size" = "მორგებული ზომა" +"Cut" = "ამოჭრა" +"Dark" = "ბნელი" +"Darken" = "ჩამუქება" +"Dash" = "ტირე" +"Dashboard" = "სამართავი დაფა" +"Date created" = "შექმნის თარიღი" +"Dec" = "დეკ" +"Decoration style" = "გაფორმების სტილი" +"Decoration" = "გაფორმება" +"Deep select" = "ღრმა მონიშვნა" +"Default" = "ნაგულისხმევი" +"Default content" = "ნაგულისხმევი შიგთავსი" +"Default value (the top-left variant)" = "ნაგულისხმევი მნიშვნელობა (ზედა მარცხენა ვარიანტი)" +"Delay" = "დაყოვნება" +"Delete" = "წაშლა" +"Delete category (its annotations stay)" = "კატეგორიის წაშლა (მისი ანოტაციები რჩება)" +"Delete collection" = "კოლექციის წაშლა" +"Delete group" = "ჯგუფის წაშლა" +"Delete mode" = "რეჟიმის წაშლა" +"Delete page" = "გვერდის წაშლა" +"Delete preset" = "პრესეტის წაშლა" +"Delete property" = "თვისების წაშლა" +"Delete the style (layers keep their values)" = "სტილის წაშლა (შრეები ინარჩუნებს მნიშვნელობებს)" +"Delete thread" = "კომენტარისა და პასუხების წაშლა" +"Delete variable" = "ცვლადის წაშლა" +"Delete “{name}”? This can't be undone." = "წაიშალოს „{name}“? ამის დაბრუნება შეუძლებელია." +"Density" = "სიმკვრივე" +"Depth" = "სიღრმე" +"Describe how and when to use this component" = "აღწერეთ, როგორ და როდის გამოიყენება ეს კომპონენტი" +"Describe what changed" = "აღწერეთ, რა შეიცვალა" +"Description" = "აღწერა" +"Description (optional)" = "აღწერა (არასავალდებულო)" +"Design" = "დიზაინი" +"Design system starter" = "დიზაინ-სისტემის საწყისი ნაკრები" +"Desktop" = "დესკტოპი" +"Destination" = "დანიშნულება" +"Detach alias" = "ალიასის გამოყოფა" +"Detach instance" = "ინსტანსის გამოყოფა" +"Detach property" = "თვისების გამოყოფა" +"Detach style" = "სტილის გამოყოფა" +"Detach styles" = "სტილების გამოყოფა" +"Detach variable" = "ცვლადის გამოყოფა" +"Details" = "დეტალები" +"Dev Mode" = "დეველოპერის რეჟიმი" +"Dev resources" = "დეველოპერის რესურსები" +"Development" = "დეველოპმენტი" +"Device" = "მოწყობილობა" +"Device at 100%" = "მოწყობილობა 100%-ზე" +"Diamond" = "ბრილიანტი" +"Difference" = "სხვაობა" +"Dimensions" = "ზომები" +"Direction" = "მიმართულება" +"Disconnect from code" = "კოდთან კავშირის გაწყვეტა" +"Dispersion" = "დისპერსია" +"Display name" = "საჩვენებელი სახელი" +"Dissolve" = "გადადნობა" +"Distribute and tidy" = "განაწილება და მოწესრიგება" +"Distribute horizontal spacing" = "ჰორიზონტალური შუალედების განაწილება" +"Distribute vertical spacing" = "ვერტიკალური შუალედების განაწილება" +"Divider" = "გამყოფი" +"Don't truncate" = "შეკვეცის გარეშე" +"Done" = "მზადაა" +"Done editing" = "რედაქტირების დასრულება" +"Dots" = "წერტილები" +"Dotted" = "პუნქტირი" +"Down" = "ქვემოთ" +"Drafts" = "მონახაზები" +"Drag the stroke to add a width point" = "გადაათრიეთ კონტური სიგანის წერტილის დასამატებლად" +"Draw" = "ხატვა" +"Draw a frame" = "დახატეთ ჩარჩო" +"Drawing tools" = "ხატვის ხელსაწყოები" +"Drop a .figcraft, .fig, .json, .svg or image file." = "ჩააგდეთ .figcraft, .fig, .json, .svg ან გამოსახულების ფაილი." +"Drop to import" = "ჩააგდეთ იმპორტისთვის" +"Drop to open" = "ჩააგდეთ გასახსნელად" +"Drop shadow" = "ჩრდილი" +"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "ორმაგი ლიცენზია: MIT OR Apache-2.0. შექმნილია ArtCraft-ის გუნდისა და საზოგადოების მიერ." +"Duo" = "დუო" +"Duplicate" = "დუბლირება" +"Duplicate by dragging" = "დუბლირება გადათრევით" +"Duplicate mode" = "რეჟიმის დუბლირება" +"Duplicate page" = "გვერდის დუბლირება" +"Edit" = "რედაქტირება" +"Edit as an expression" = "ექსპრესიად რედაქტირება" +"Edit colour" = "ფერის რედაქტირება" +"Edit comment" = "კომენტარის რედაქტირება" +"Edit dev resource" = "დეველოპერის რესურსის რედაქტირება" +"Edit link" = "რედაქტირების ბმული" +"Edit link copied" = "რედაქტირების ბმული დაკოპირდა" +"Edit style" = "სტილის რედაქტირება" +"Edit variable" = "ცვლადის რედაქტირება" +"Edit with the builder" = "კონსტრუქტორით რედაქტირება" +"Edited {when}" = "რედაქტირებულია {when}" +"Effect settings" = "ეფექტის პარამეტრები" +"Effect styles" = "ეფექტის სტილები" +"Effects" = "ეფექტები" +"Ellipse" = "ელიფსი" +"Ellipses" = "ელიფსები" +"Else" = "სხვა შემთხვევაში" +"Else if" = "სხვა შემთხვევაში, თუ" +"Embedded profile" = "ჩაშენებული პროფილი" +"Empty" = "ცარიელი" +"End" = "ბოლო" +"End point" = "საბოლოო წერტილი" +"Ends the session; everyone keeps their copy" = "ასრულებს სესიას; ყველას თავისი ასლი რჩება" +"Enter" = "Enter" +"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "შეიყვანეთ figcraft-server-ის მისამართი და დააჭირეთ „დათვალიერებას“, რომ ნახოთ მის მიერ შემოთავაზებული პლაგინები." +"Enter to save · Esc to cancel" = "Enter — შენახვა · Esc — გაუქმება" +"Eraser" = "საშლელი" +"Esc" = "Esc" +"Essential" = "ძირითადი" +"Every conflict has a choice." = "ყველა კონფლიქტს არჩევანი აქვს." +"Every top-level frame already starts a flow" = "ყველა ზედა დონის ჩარჩო უკვე იწყებს ნაკადს" +"Exact opacity (4 then 5: 45%)" = "ზუსტი გაუმჭვირვალობა (4, შემდეგ 5: 45%)" +"Exclude" = "გამორიცხვა" +"Excluded" = "გამორიცხული" +"Excluded from layout" = "განლაგებიდან გამორიცხული" +"Excludes" = "გამორიცხვები" +"Exclusion" = "გამორიცხვა" +"Exit focus view (Esc)" = "ფოკუსის ხედიდან გასვლა (Esc)" +"Expand" = "გაშლა" +"Export" = "ექსპორტირება" +"Export all" = "ყველაფრის ექსპორტირება" +"Export frames and layers as PNG, JPG, SVG or PDF" = "ჩარჩოებისა და შრეების ექსპორტირება PNG, JPG, SVG ან PDF ფორმატში" +"Export page as PNG" = "გვერდის PNG ფაილად ექსპორტირება" +"Export settings" = "ექსპორტის პარამეტრები" +"Export variables" = "ცვლადების ექსპორტირება" +"Export {name}" = "{name}-ის ექსპორტირება" +"Export {n} layers" = { one = "შრის ექსპორტირება: {n}", other = "შრის ექსპორტირება: {n}" } +"Exported {file}" = "ექსპორტირებულია: {file}" +"Exported {name}" = "ექსპორტირებულია: {name}" +"Exported {name}; {n} kinds of content were left out" = { one = "ექსპორტირებულია: {name}; გამოტოვებული შიგთავსის სახეობა: {n}", other = "ექსპორტირებულია: {name}; გამოტოვებული შიგთავსის სახეობა: {n}" } +"Exported {n} files" = { one = "ექსპორტირებულია ფაილი: {n}", other = "ექსპორტირებულია ფაილი: {n}" } +"Export…" = "ექსპორტირება…" +"Expose properties to instances" = "თვისებების ჩვენება ინსტანსებში" +"Exposure" = "ექსპოზიცია" +"Expression" = "ექსპრესია" +"Extend collection" = "კოლექციის გაფართოება" +"Extends \"{name}\": overridden values are marked" = "აფართოებს „{name}“-ს: შეცვლილი მნიშვნელობები მონიშნულია" +"Eye" = "თვალი" +"F" = "F" +"Fade" = "მილევა" +"False" = "მცდარი" +"Feb" = "თებ" +"Fetching from the server…" = "სერვერიდან მიღება…" +"FigCraft couldn't open its window: {error}" = "FigCraft-მა ფანჯრის გახსნა ვერ შეძლო: {error}" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft ჯერ ამ ფაილების ყველა შესაძლებლობას არ უჭერს მხარს. შენიშვნებში ჩამოთვლილია, რა გამოტოვდა ან შეიცვალა იმპორტირებულ ასლში; ორიგინალი ფაილი უცვლელია." +"File" = "ფაილი" +"File name" = "ფაილის სახელი" +"File not found" = "ფაილი ვერ მოიძებნა" +"File published" = "ფაილი გამოქვეყნდა" +"Files aren't end-to-end encrypted: the server's operator can read them." = "ფაილები ბოლომდე არ არის დაშიფრული: სერვერის ოპერატორს შეუძლია მათი წაკითხვა." +"Files nobody opens for {n} days are deleted." = { one = "ფაილები, რომლებსაც არავინ ხსნის, იშლება. დღე: {n}.", other = "ფაილები, რომლებსაც არავინ ხსნის, იშლება. დღე: {n}." } +"Fill" = "შევსება" +"Fill (fr)" = "შევსება (fr)" +"Fill container" = "კონტეინერის შევსება" +"Fill window" = "ფანჯრის შევსება" +"Fills" = "შევსებები" +"Fill|sizing" = "შევსება" +"Filter and sort" = "ფილტრი და სორტირება" +"Find" = "ძებნა" +"Find and replace" = "ძებნა და ჩანაცვლება" +"Find layers" = "შრეების ძებნა" +"First" = "პირველი" +"First commit" = "პირველი კომიტი" +"First on top" = "პირველი ზემოთ" +"Fit" = "მორგება" +"Fit device to window" = "მოწყობილობის ფანჯარაში ჩატევა" +"Fit to contents" = "შიგთავსზე მორგება" +"Fit to window" = "ფანჯარაში ჩატევა" +"Fit width" = "სიგანეში ჩატევა" +"Fixed" = "ფიქსირებული" +"Fixed gap" = "ფიქსირებული შუალედი" +"Fixed size" = "ფიქსირებული ზომა" +"Flatten" = "გაბრტყელება" +"Flip" = "არეკვლა" +"Flip horizontal" = "ჰორიზონტალურად არეკვლა" +"Flip text to the other side" = "ტექსტის მეორე მხარეს გადატანა" +"Flip vertical" = "ვერტიკალურად არეკვლა" +"Flip width profile" = "სიგანის პროფილის არეკვლა" +"Flow starting point" = "ნაკადის საწყისი წერტილი" +"Flow starting points" = "ნაკადის საწყისი წერტილები" +"Flows" = "ნაკადები" +"Focus view" = "ფოკუსის ხედი" +"Fold autosaves" = "ავტოშენახვების ჩაკეცვა" +"Folder: click to collapse" = "საქაღალდე: დაწკაპეთ ჩასაკეცად" +"Folder: click to expand" = "საქაღალდე: დაწკაპეთ გასაშლელად" +"Follow" = "მიყოლა" +"Follow {name}" = "{name}-ის მიყოლა" +"Following" = "მიყოლა" +"Following {name} · {key} to stop" = "მიჰყვებით: {name} · შესაწყვეტად {key}" +"Font" = "შრიფტი" +"Font not installed" = "შრიფტი არ არის დაინსტალირებული" +"Font size" = "შრიფტის ზომა" +"Font style" = "შრიფტის სტილი" +"Forget on this computer" = "ამ კომპიუტერზე დავიწყება" +"Four screens of a trail app with components and a prototype flow" = "ლაშქრობის აპის ოთხი ეკრანი კომპონენტებითა და პროტოტიპის ნაკადით" +"Fractions" = "წილადები" +"Frame" = "ჩარჩო" +"Frame selection" = "მონიშნულის ჩარჩოში მოქცევა" +"Frame tools" = "ჩარჩოს ხელსაწყოები" +"Frames" = "ჩარჩოები" +"From the library \"{name}\": read-only" = "ბიბლიოთეკიდან „{name}“: მხოლოდ წასაკითხად" +"From {name}" = "წყარო: {name}" +"From {server}" = "წყარო: {server}" +"Frost" = "ჭირხლი" +"Gap" = "შუალედი" +"Gap between columns" = "სვეტებს შორის შუალედი" +"Gap between items" = "ელემენტებს შორის შუალედი" +"Gap between rows" = "სტრიქონებს შორის შუალედი" +"Getting started" = "პირველი ნაბიჯები" +"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "მიუთითეთ საჯარო სერვერის მისამართი ან სერვერის მისამართი გასაღებთან ერთად (`figcraft-server key` გამოიტანს მას)." +"Glass" = "მინა" +"Go to main component" = "მთავარ კომპონენტზე გადასვლა" +"Go to {name}" = "{name}-ზე გადასვლა" +"Grab bag" = "სხვადასხვა" +"Gradient" = "გრადიენტი" +"Gray" = "ნაცრისფერი" +"Green" = "მწვანე" +"Greyscale components and two example screens to sketch a flow with" = "ნაცრისფერი კომპონენტები და ორი სანიმუშო ეკრანი ნაკადის ჩანახატისთვის" +"Grid" = "ბადე" +"Grid ({size})" = "ბადე ({size})" +"Grid styles" = "ბადის სტილები" +"Grid {size}" = "ბადე {size}" +"Group" = "ჯგუფი" +"Group selection" = "ჯგუფის მონიშვნა" +"Groups" = "ჯგუფები" +"Gutter" = "შუალედი" +"Hand tool" = "ხელის ხელსაწყო" +"Hanging list" = "გამოწეული სია" +"Hanging punctuation" = "გამოწეული პუნქტუაცია" +"Hard light" = "მკვეთრი სინათლე" +"Height" = "სიმაღლე" +"Help" = "დახმარება" +"Help and resources" = "დახმარება და რესურსები" +"Help page" = "დახმარების გვერდი" +"Here: {names}" = "აქ: {names}" +"Hidden" = "დამალული" +"Hide contents" = "შიგთავსის დამალვა" +"Hide from publishing" = "გამოქვეყნებისას დამალვა" +"Hide section contents" = "სექციის შიგთავსის დამალვა" +"Hide these tips (Getting started in the Help menu shows them again)" = "ამ რჩევების დამალვა („პირველი ნაბიჯები“ დახმარების მენიუში ისევ აჩვენებს მათ)" +"Hide when publishing" = "გამოქვეყნებისას დამალვა" +"Highlight layers on hover" = "შრეების გამოკვეთა მაჩვენებლის მიტანისას" +"Highlights" = "ღია ტონები" +"Hold Alt" = "Alt-ის დაჭერა" +"Hold Alt+Z" = "Alt+Z-ის დაჭერა" +"Hold Ctrl" = "Ctrl-ის დაჭერა" +"Hold Z" = "Z-ის დაჭერა" +"Hold ⌃ or ⌘" = "⌃-ის ან ⌘-ის დაჭერა" +"Hold ⌥" = "⌥-ის დაჭერა" +"Hold ⌥Z" = "⌥Z-ის დაჭერა" +"Home" = "საწყისი ეკრანი" +"Horizontal" = "ჰორიზონტალური" +"Horizontal align" = "ჰორიზონტალური სწორება" +"Horizontal hex" = "ჰორიზონტალური ექვსკუთხედი" +"Horizontal padding" = "ჰორიზონტალური შიდა დაშორება" +"Horizontal spacing" = "ჰორიზონტალური დაშორება" +"Hosted by {operator}" = "მასპინძელი: {operator}" +"Hover a layer to see its properties" = "მიიტანეთ მაჩვენებელი შრეზე მისი თვისებების სანახავად" +"Hover other layers to measure the distance" = "მიიტანეთ მაჩვენებელი სხვა შრეებზე მანძილის გასაზომად" +"Hue" = "ფერის ტონი" +"Hug" = "შიგთავსზე მორგება" +"Hug contents" = "შიგთავსზე მორგება" +"Icon components on a 24 px grid with keylines and a size preview" = "ხატულების კომპონენტები 24 px ბადეზე საორიენტაციო ხაზებითა და ზომების გადახედვით" +"Icon set starter" = "ხატულების ნაკრების საწყისი" +"If it keeps happening, please report it at {link} with this message." = "თუ ეს განმეორდება, გთხოვთ, ეს შეტყობინება გამოგვიგზავნეთ აქ: {link}" +"If" = "თუ" +"If {condition}" = "თუ {condition}" +"Ignore auto layout (absolute position)" = "ავტოგანლაგების იგნორირება (აბსოლუტური პოზიცია)" +"Image" = "გამოსახულება" +"Images" = "გამოსახულებები" +"Import" = "იმპორტი" +"Import path" = "იმპორტის გზა" +"Import plugin from manifest" = "პლაგინის იმპორტი მანიფესტიდან" +"Import plugin from manifest…" = "პლაგინის იმპორტი მანიფესტიდან…" +"Import report" = "იმპორტის ანგარიში" +"Import variables" = "ცვლადების იმპორტი" +"Imported document" = "იმპორტირებული დოკუმენტი" +"Imported {name}" = "იმპორტირებულია: {name}" +"Imported {n} new variables, updated {updated}" = { one = "იმპორტირებულია ახალი ცვლადი: {n}; განახლდა: {updated}", other = "იმპორტირებულია ახალი ცვლადი: {n}; განახლდა: {updated}" } +"Import…" = "იმპორტი…" +"In an instance, this comes from the main component" = "ინსტანსში ეს მთავარი კომპონენტიდან მოდის" +"In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)." = "ბრაუზერში შეგიძლიათ შეუერთდეთ სესიებს, რომლებსაც სამუშაო მაგიდის აპი მასპინძლობს, და FigCraft-ის სერვერზე არსებულ ფაილებს. https-ით გახსნილი გვერდი სხვა კომპიუტერებს მხოლოდ wss:// ბმულებით აღწევს (TLS-ის მიღმა მყოფი სერვერი)." +"Include bounding box" = "შემომსაზღვრელი ჩარჩოს ჩართვა" +"Include id attribute" = "id ატრიბუტის ჩართვა" +"Included" = "ჩართული" +"Included in layout" = "განლაგებაში ჩართული" +"Individual corners" = "ცალკეული კუთხეები" +"Individual strokes" = "ცალკეული კონტურები" +"Ink" = "მელანი" +"Inner shadow" = "შიდა ჩრდილი" +"Inside" = "შიგნით" +"Inspect" = "ინსპექტირება" +"Inspect the layer under the pointer: size, colours, typography" = "მაჩვენებლის ქვეშ მყოფი შრის ინსპექტირება: ზომა, ფერები, ტიპოგრაფია" +"Install plugin" = "პლაგინის ინსტალაცია" +"Install" = "ინსტალაცია" +"Installed {name}" = "დაინსტალირდა: {name}" +"Installed" = "დაინსტალირებულია" +"Installing…" = "ინსტალაცია…" +"Instance" = "ინსტანსი" +"Instance properties" = "ინსტანსის თვისებები" +"Instance swap" = "ინსტანსის ჩანაცვლება" +"Instance swap (no other component)" = "ინსტანსის ჩანაცვლება (სხვა კომპონენტი არ არის)" +"Instances" = "ინსტანსები" +"Instant" = "მყისიერი" +"Intensity" = "ძალა" +"Interaction" = "ურთიერთქმედება" +"Interactions" = "ურთიერთქმედებები" +"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "ინტერფეისის დიზაინი და პროტოტიპირება. უფასო, ღია კოდის, დამოუკიდებლად (clean-room) შექმნილი დიზაინის ხელსაწყო, ნულიდან დაწერილი Rust-ზე, ArtCraft Crafting Apps-ის ნაწილი." +"Intersect" = "გადაკვეთა" +"Intersects" = "გადაკვეთები" +"Invalid color" = "არასწორი ფერი" +"Invite link" = "მოსაწვევი ბმული" +"Invite link copied" = "მოსაწვევი ბმული დაკოპირდა" +"It asks permission to:" = "ის ითხოვს ნებართვას:" +"Jan" = "იან" +"Join" = "შეერთება" +"Join code copied" = "შეერთების კოდი დაკოპირდა" +"Join code or invite link" = "შეერთების კოდი ან მოსაწვევი ბმული" +"Join live session" = "ცოცხალ სესიასთან შეერთება" +"Join us on Discord" = "შემოგვიერთდით Discord-ზე" +"Join with a code" = "კოდით შეერთება" +"Joined" = "შეერთებულია" +"Joined {file}" = "შეერთებულია: {file}" +"Joining…" = "შეერთება…" +"Join|stroke" = "შეერთება" +"Jul" = "ივლ" +"Jun" = "ივნ" +"Just now" = "ახლახან" +"Justify" = "ორმხრივი სწორება" +"Keep appearance" = "იერსახის შენარჩუნება" +"Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "„იერსახის შენარჩუნება“ ყველა ფერის მნიშვნელობას {profile}-ში გარდაქმნის, რომ ფერები იგივენაირად გამოჩნდეს. „მნიშვნელობების შენარჩუნება“ რიცხვებს უცვლელად ტოვებს, ამიტომ ფერები იცვლება." +"Keep tool selected after use" = "ხელსაწყოს არჩეულად დატოვება გამოყენების შემდეგ" +"Keep values" = "მნიშვნელობების შენარჩუნება" +"Key/Gamepad" = "კლავიში/გეიმპადი" +"Key/Gamepad {key}" = "კლავიში/გეიმპადი {key}" +"Keyboard shortcuts" = "კლავიატურის მალსახმობები" +"Kits" = "ნაკრებები" +"Landing page" = "ლენდინგი" +"Language" = "ენა" +"Lasso" = "ლასო" +"Last" = "ბოლო" +"Last change: {note}" = "ბოლო ცვლილება: {note}" +"Last commit" = "ბოლო კომიტი" +"Last modified" = "ბოლო ცვლილება" +"Last on top" = "ბოლო ზემოთ" +"Layer blur" = "შრის გაბუნდოვნება" +"Layer name" = "შრის სახელი" +"Layers" = "შრეები" +"Layers in an instance take their position from the main component" = "ინსტანსის შრეები პოზიციას მთავარი კომპონენტიდან იღებს" +"Layout" = "განლაგება" +"Layout grid" = "განლაგების ბადე" +"Layout grid settings" = "განლაგების ბადის პარამეტრები" +"Layout grids" = "განლაგების ბადეები" +"Leave session" = "სესიიდან გასვლა" +"Leaves" = "ფოთლები" +"Left" = "მარცხნივ" +"Left & right" = "მარცხნივ და მარჯვნივ" +"Left side" = "მარცხენა მხარე" +"Left side width" = "მარცხენა მხარის სიგანე" +"Letter case" = "ასოთა რეგისტრი" +"Letter spacing" = "ასოებს შორის დაშორება" +"Libraries" = "ბიბლიოთეკები" +"Libraries in this file" = "ამ ფაილის ბიბლიოთეკები" +"Library updates available" = "ხელმისაწვდომია ბიბლიოთეკის განახლებები" +"Library: click to collapse" = "ბიბლიოთეკა: დაწკაპეთ ჩასაკეცად" +"Library: click to expand" = "ბიბლიოთეკა: დაწკაპეთ გასაშლელად" +"Library: its variables are read-only here; edit them in the library file and publish it" = "ბიბლიოთეკა: მისი ცვლადები აქ მხოლოდ წასაკითხია; შეცვალეთ ისინი ბიბლიოთეკის ფაილში და გამოაქვეყნეთ" +"Light" = "ღია" +"Lighten" = "გაღიავება" +"Light|glass" = "ღია" +"Line" = "ხაზი" +"Line arrow" = "ხაზოვანი ისარი" +"Line delta" = "სტრიქონების სხვაობა" +"Line height" = "სტრიქონის სიმაღლე" +"Linear" = "წრფივი" +"Lines" = "სტრიქონები" +"Lines +/−" = "სტრიქონები +/−" +"Lines added" = "დამატებული სტრიქონები" +"Lines deleted" = "წაშლილი სტრიქონები" +"Link" = "ბმული" +"Link copied" = "ბმული დაკოპირდა" +"Link docs, code or tickets for developers." = "მიაბით დოკუმენტაცია, კოდი ან ტიკეტები დეველოპერებისთვის." +"Link frames on the Prototype tab, then present the flow" = "დააკავშირეთ ჩარჩოები „პროტოტიპის“ ჩანართზე, შემდეგ წარადგინეთ ნაკადი" +"Link this component to its code component: instances then show your real snippet with their values." = "დააკავშირეთ ეს კომპონენტი მის კოდის კომპონენტთან: ინსტანსები მაშინ თქვენს რეალურ ფრაგმენტს აჩვენებს მათი მნიშვნელობებით." +"List spacing" = "სიის დაშორება" +"List style" = "სიის სტილი" +"Live session" = "ცოცხალი სესია" +"Live · {people}" = "ცოცხლად · {people}" +"Local components" = "ლოკალური კომპონენტები" +"Local network" = "ლოკალური ქსელი" +"Local styles" = "ლოკალური სტილები" +"Local variables" = "ლოკალური ცვლადები" +"Lock/Unlock" = "ჩაკეტვა/განბლოკვა" +"Locked" = "ჩაკეტილი" +"Lowercase" = "პატარა ასოები" +"Luminance" = "სიკაშკაშე" +"Luminosity" = "ნათება" +"Main" = "მთავარი" +"Main component deleted" = "მთავარი კომპონენტი წაიშალა" +"Main menu" = "მთავარი მენიუ" +"Make a component" = "შექმენით კომპონენტი" +"Make slot" = "სლოტად გადაქცევა" +"Mar" = "მარ" +"Margin" = "ველი" +"Mark all as read" = "ყველას წაკითხულად მარკირება" +"Mark as resolved" = "გადაწყვეტილად მარკირება" +"Mark as unread" = "წაუკითხავად მარკირება" +"Mark as unresolved" = "გადაუწყვეტლად მარკირება" +"Mask" = "ნიღაბი" +"Mass · Stiffness · Damping" = "მასა · სიხისტე · მილევა" +"Match (empty: the whole name)" = "დამთხვევა (ცარიელი: მთელი სახელი)" +"Max H" = "მაქს. H" +"Max W" = "მაქს. W" +"Max lines" = "მაქს. სტრიქონები" +"Maximize" = "გაშლა" +"May" = "მაი" +"Measure to the selection" = "მონიშნულამდე გაზომვა" +"Merge into main" = "მთავარში შერწყმა" +"Merge regions" = "არეების შერწყმა" +"Merge the branch into the main file (one undo step) and archive it" = "განშტოების მთავარ ფაილთან შერწყმა (დაბრუნების ერთი ნაბიჯი) და მისი დაარქივება" +"Merged" = "შერწყმულია" +"Merged PRs" = "გაერთიანებული PR-ები" +"Min H" = "მინ. H" +"Min W" = "მინ. W" +"Minimize" = "ჩაკეცვა" +"Mirror angle" = "კუთხის არეკვლა" +"Mirror angle and length" = "კუთხისა და სიგრძის არეკვლა" +"Mirroring" = "არეკვლა" +"Missing" = "აკლია" +"Missing fonts" = "მიუწვდომელი შრიფტები" +"Missing layer" = "შრე ვერ მოიძებნა" +"Missing style" = "სტილი ვერ მოიძებნა" +"Missing variable" = "ცვლადი ვერ მოიძებნა" +"Miter" = "მახვილი" +"Miter angle" = "მახვილის კუთხე" +"Mixed" = "შერეული" +"Mixed fonts" = "შერეული შრიფტები" +"Mixed interactions: click + to replace them" = "შერეული ურთიერთქმედებები: დაწკაპეთ +, რომ ჩაანაცვლოთ" +"Mobile app" = "მობილური აპი" +"Mode" = "რეჟიმი" +"Model" = "მოდელი" +"Models" = "მოდელები" +"Mono" = "მონო" +"More" = "მეტი" +"More instance actions" = "ინსტანსის სხვა მოქმედებები" +"Mouse down" = "თაგუნას დაჭერა" +"Mouse enter" = "თაგუნას შესვლა" +"Mouse leave" = "მაჩვენებლის გასვლა" +"Mouse up" = "თაგუნას აშვება" +"Move along the toolbar or the layer rows" = "ხელსაწყოთა ზოლში ან შრეების სტრიქონებში გადაადგილება" +"Move" = "გადაადგილება" +"Move column left" = "სვეტის მარცხნივ გადატანა" +"Move column right" = "სვეტის მარჯვნივ გადატანა" +"Move down" = "ჩამოწევა" +"Move in" = "შიგნით გადატანა" +"Move out" = "გარეთ გადატანა" +"Move tools" = "გადაადგილების ხელსაწყოები" +"Move up" = "აწევა" +"Moved to {name}" = "გადატანილია: {name}" +"Multi" = "მრავალი" +"Multiply" = "გამრავლება" +"Muted" = "მკრთალი" +"Name" = "სახელი" +"Name (A–Z)" = "სახელი (ა–ჰ)" +"Named instance" = "დასახელებული ინსტანსი" +"Navigate to" = "გადასვლა" +"Nested instance" = "ჩადგმული ინსტანსი" +"New design file" = "ახალი დიზაინის ფაილი" +"Newest first" = "ჯერ უახლესი" +"Next or previous control in the focused region" = "ფოკუსირებულ არეში შემდეგი ან წინა მართვის ელემენტი" +"Next frame" = "შემდეგი ჩარჩო" +"Next ready for dev" = "შემდეგი მზად დეველოპერისთვის" +"Next size option" = "ზომის შემდეგი ვარიანტი" +"No assets: no export settings, icons or images. Add export settings in the Design tab." = "რესურსები არ არის: არც ექსპორტის პარამეტრები, არც ხატულები, არც გამოსახულებები. ექსპორტის პარამეტრები დაამატეთ „დიზაინის“ ჩანართზე." +"No boolean variables fit here." = "აქ არცერთი ლოგიკური ცვლადი არ ერგება." +"No boolean variables yet. Create them in Local variables." = "ლოგიკური ცვლადები ჯერ არ არის. შექმენით ისინი ლოკალურ ცვლადებში." +"No branches. A branch is a copy of the file to try changes in, then review and merge." = "განშტოებები არ არის. განშტოება ფაილის ასლია, რომელშიც ცვლილებებს ცდით, შემდეგ კი განიხილავთ და შერწყამთ." +"No category" = "კატეგორიის გარეშე" +"No changes" = "ცვლილებები არ არის" +"No color styles" = "ფერის სტილები არ არის" +"No colour variables fit here." = "აქ არცერთი ფერის ცვლადი არ ერგება." +"No colour variables yet. Create them in Local variables." = "ფერის ცვლადები ჯერ არ არის. შექმენით ისინი ლოკალურ ცვლადებში." +"No comments yet" = "კომენტარები ჯერ არ არის" +"No compatibility warnings reported" = "თავსებადობის გაფრთხილებები არ დაფიქსირებულა" +"No components yet. Select layers and press {keys}." = "კომპონენტები ჯერ არ არის. მონიშნეთ შრეები და დააჭირეთ {keys}." +"No conflicts." = "კონფლიქტები არ არის." +"No contributor data was built into this copy." = "ამ ასლში მონაწილეების მონაცემები არ არის ჩაშენებული." +"No device" = "მოწყობილობის გარეშე" +"No fonts match" = "შესაბამისი შრიფტი არ მოიძებნა" +"No files or templates match your search" = "ძიების შესაბამისი ფაილი ან შაბლონი არ მოიძებნა" +"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "აქ შეერთების კოდი არ არის (სერვერი შეერთების კოდებზე ძველია, ან ეს მისამართი კოდში ვერ ეტევა): ამის ნაცვლად გააზიარეთ ბმული." +"No layer changes to show." = "საჩვენებელი შრეების ცვლილებები არ არის." +"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "ბიბლიოთეკები ჯერ არ არის. დაამატეთ ბიბლიოთეკად გამოქვეყნებული .figcraft ფაილი ან ჩასვით გუნდის ბიბლიოთეკის ბმული." +"No list" = "სიის გარეშე" +"No matching comments" = "შესაბამისი კომენტარი არ მოიძებნა" +"No matching components" = "შესაბამისი კომპონენტი არ მოიძებნა" +"No matching layers" = "შესაბამისი შრე არ მოიძებნა" +"No matching styles" = "შესაბამისი სტილი არ მოიძებნა" +"No matching variables" = "შესაბამისი ცვლადი არ მოიძებნა" +"No mirroring" = "არეკვლის გარეშე" +"No model credits were built into this copy." = "ამ ასლში მოდელების მონაცემები არ არის ჩაშენებული." +"No number variables fit here." = "აქ არცერთი რიცხვითი ცვლადი არ ერგება." +"No number variables yet. Create them in Local variables." = "რიცხვითი ცვლადები ჯერ არ არის. შექმენით ისინი ლოკალურ ცვლადებში." +"No other pages" = "სხვა გვერდები არ არის" +"No paints" = "საღებავების გარეშე" +"No plugins on {server}." = "{server}-ზე პლაგინები არ არის." +"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "პლაგინები ჯერ არ არის. დეველოპერის პლაგინის იმპორტი მოახდინეთ მისი figcraft-plugin.json-იდან." +"No preferred components" = "სასურველი კომპონენტების გარეშე" +"No preset" = "პრესეტის გარეშე" +"No saved versions yet. Save a version to mark a point in your work you can come back to." = "შენახული ვერსიები ჯერ არ არის. შეინახეთ ვერსია, რომ მონიშნოთ თქვენი სამუშაოს წერტილი, სადაც შეგიძლიათ დაბრუნდეთ." +"No scrolling" = "გადახვევის გარეშე" +"No shortcuts match" = "შესაბამისი მალსახმობი არ მოიძებნა" +"No status" = "სტატუსის გარეშე" +"No string variables fit here." = "აქ არცერთი სტრიქონული ცვლადი არ ერგება." +"No string variables yet. Create them in Local variables." = "სტრიქონული ცვლადები ჯერ არ არის. შექმენით ისინი ლოკალურ ცვლადებში." +"No styles yet." = "სტილები ჯერ არ არის." +"No styles yet. Click + to create one from the selection." = "სტილები ჯერ არ არის. დაწკაპეთ +, რომ მონიშნულიდან შექმნათ." +"No templates match your search" = "ძიების შესაბამისი შაბლონი არ მოიძებნა" +"No text styles yet. Click + to create one from the selected text." = "ტექსტის სტილები ჯერ არ არის. დაწკაპეთ +, რომ მონიშნული ტექსტიდან შექმნათ." +"No variables fit here." = "აქ არცერთი ცვლადი არ ერგება." +"No variables yet. Create them in Local variables." = "ცვლადები ჯერ არ არის. შექმენით ისინი ლოკალურ ცვლადებში." +"Nobody else is here yet" = "აქ ჯერ სხვა არავინ არის" +"Noise" = "ხმაური" +"None" = "არცერთი" +"None." = "არაფერი." +"Normal" = "ჩვეულებრივი" +"Not bound" = "მიბმული არ არის" +"Not in a live session" = "ცოცხალ სესიაში არ ხართ" +"Not in this font" = "ამ შრიფტში არ არის" +"Not properties, kept as written: {names}" = "არ არის თვისებები, დარჩა დაწერილი სახით: {names}" +"Not read yet" = "ჯერ არ არის წაკითხული" +"Not shared" = "არ არის გაზიარებული" +"Note {n}" = "შენიშვნა {n}" +"Nothing changed since this was marked ready for dev." = "მას შემდეგ, რაც მზად დეველოპერისთვის მოინიშნა, არაფერი შეცვლილა." +"Nothing here yet. Set a frame's status to list it." = "აქ ჯერ არაფერია. ჩარჩოს სიაში გამოსაჩენად მიანიჭეთ სტატუსი." +"Nothing online yet. Use Share on a file to put it online and it is listed here." = "ონლაინ ჯერ არაფერია. ფაილის ონლაინ განსათავსებლად გამოიყენეთ „გაზიარება“ და ის აქ გამოჩნდება." +"Nothing to export" = "საექსპორტო არაფერია" +"Nov" = "ნოე" +"Now" = "ახლა" +"Nudge (Shift: big nudge)" = "მცირედ გადაწევა (Shift: დიდი ბიჯით)" +"Nudge amount" = "მცირედ გადაწევის ბიჯი" +"Number" = "რიცხვი" +"Number ↑" = "რიცხვი ↑" +"Number ↓" = "რიცხვი ↓" +"Numbered list" = "დანომრილი სია" +"Object" = "ობიექტი" +"Oct" = "ოქტ" +"Off" = "გამორთული" +"Offered first when filling the slot (any content is allowed)" = "სლოტის შევსებისას პირველად შემოთავაზებული (ნებისმიერი შიგთავსი დასაშვებია)" +"Offered first when swapping the instance (any component is allowed)" = "ინსტანსის ჩანაცვლებისას პირველად შემოთავაზებული (ნებისმიერი კომპონენტი დასაშვებია)" +"Offset" = "წანაცვლება" +"Oldest first" = "ჯერ უძველესი" +"On click" = "დაწკაპუნებით" +"On drag" = "გადათრევისას" +"On this page" = "ამ გვერდზე" +"One announcement in five common sizes: square, portrait, story, link and banner" = "ერთი განცხადება ხუთ ხშირად გამოყენებულ ზომაში: კვადრატი, ვერტიკალური, სთორი, ბმული და ბანერი" +"Online" = "ონლაინ" +"Online files" = "ონლაინ ფაილები" +"Online sharing is off: files are shared on the local network only." = "ონლაინ გაზიარება გამორთულია: ფაილები მხოლოდ ლოკალურ ქსელში ზიარდება." +"Only the first changed layers are listed." = "ნაჩვენებია მხოლოდ პირველი შეცვლილი შრეები." +"Only the first {n} changed layers are listed." = "ნაჩვენებია მხოლოდ პირველი შეცვლილი შრეები. შრე: {n}." +"Only your threads" = "მხოლოდ თქვენი მიმოწერები" +"Opacity" = "გაუმჭვირვალობა" +"Opacity & blend" = "გაუმჭვირვალობა და შერევა" +"Opacity 10%…90%, 0: 100%" = "გაუმჭვირვალობა 10%…90%, 0: 100%" +"Open" = "გახსნა" +"Open .figcraft, .svg or images" = ".figcraft, .svg ან გამოსახულებების გახსნა" +"Open branch" = "განშტოების გახსნა" +"Open file" = "ფაილის გახსნა" +"Open files" = "გახსნილი ფაილები" +"Open in focus view" = "ფოკუსის ხედში გახსნა" +"Open in new window" = "გახსნა ახალ ფანჯარაში" +"Open invite links in FigCraft" = "მოსაწვევი ბმულების FigCraft-ში გახსნა" +"Open link" = "ბმულის გახსნა" +"Open link {url}" = "ბმულის გახსნა {url}" +"Open overlay" = "გადაფარვის გახსნა" +"Open prototype preview" = "პროტოტიპის წინასწარი ნახვის გახსნა" +"Open variables ({count})" = "ცვლადების გახსნა ({count})" +"Opened as a new document" = "გაიხსნა ახალ დოკუმენტად" +"Opened “{name}” in a new tab" = "„{name}“ გაიხსნა ახალ ჩანართში" +"Opening the file…" = "ფაილის გახსნა…" +"Opening {n} files as new files" = { one = "ახალ ფაილად გახსნა", other = "ახალ ფაილებად გახსნა: {n}" } +"Open|comments" = "გახსნა" +"Options" = "პარამეტრები" +"Orange" = "ნარინჯისფერი" +"Order" = "რიგი" +"Other apps may not open every .fig file exported here." = "სხვა აპებმა შეიძლება ვერ გახსნან აქ ექსპორტირებული ყველა .fig ფაილი." +"Other" = "სხვა" +"Others lose access and keep their copy; the file stays on the server for you" = "სხვები კარგავენ წვდომას და ინარჩუნებენ თავიანთ ასლს; ფაილი სერვერზე თქვენთვის რჩება" +"Others type this under Share ▸ Join with a code" = "სხვები ამას შეიყვანენ აქ: გაზიარება ▸ კოდით შეერთება" +"Outline stroke" = "კონტურის ფიგურად გარდაქმნა" +"Outline text" = "ტექსტის კონტურებად გარდაქმნა" +"Outlines" = "კონტურები" +"Outside" = "გარეთ" +"Overflow" = "გადავსება" +"Overlay" = "ზედდება" +"Overlay position" = "გადაფარვის პოზიცია" +"Overridden here (the collection it extends has another value)" = "აქ შეცვლილია (კოლექციას, რომელსაც აფართოებს, სხვა მნიშვნელობა აქვს)" +"Overrides" = "გადაფარვები" +"Owner token" = "მფლობელის ტოკენი" +"Owner token copied" = "მფლობელის ტოკენი დაკოპირდა" +"PDF print options" = "PDF-ის ბეჭდვის პარამეტრები" +"PDF print settings" = "PDF-ის ბეჭდვის პარამეტრები" +"Padding" = "შიდა დაშორება" +"Page" = "გვერდი" +"Pages" = "გვერდები" +"Pages added to the document" = "გვერდები დაემატა დოკუმენტს" +"Placed on the current page" = "განთავსდა მიმდინარე გვერდზე" +"Pages: {pages}" = "გვერდი: {pages}" +"Paint bucket" = "საღებავის ვედრო" +"Pan the canvas" = "ტილოს გადაადგილება" +"Pan with nothing selected (Shift: farther)" = "გადაადგილება, როცა არაფერია მონიშნული (Shift: უფრო შორს)" +"Panels" = "პანელები" +"Paper" = "ქაღალდი" +"Paragraph indent" = "აბზაცის შეწევა" +"Paragraph indent applies to left-aligned text" = "აბზაცის შეწევა ვრცელდება მარცხნივ სწორებულ ტექსტზე" +"Paragraph spacing" = "აბზაცთა ინტერვალი" +"Pass through" = "გატარება" +"Paste a link (https://…)" = "ჩასვით ბმული (https://…)" +"Paste a team library link (a view link)" = "ჩასვით გუნდის ბიბლიოთეკის ბმული (ნახვის ბმული)" +"Paste here" = "აქ ჩასმა" +"Paste to replace" = "ჩასმა ჩასანაცვლებლად" +"Pasted design layers; {n} kinds of content were left out" = { one = "ჩასმულია დიზაინის შრეები; გამოტოვებული შიგთავსის სახეობა: {n}", other = "ჩასმულია დიზაინის შრეები; გამოტოვებული შიგთავსის სახეობა: {n}" } +"Pattern" = "პატერნი" +"Pattern alignment" = "პატერნის სწორება" +"Pattern scale" = "პატერნის მასშტაბი" +"Pattern source" = "პატერნის წყარო" +"Pen" = "კალამი" +"Pencil" = "ფანქარი" +"People in this file" = "ამ ფაილში მყოფი ადამიანები" +"People join from anywhere with a code. The file is kept on {server} while you share it." = "ადამიანები ნებისმიერი ადგილიდან კოდით უერთდებიან. სანამ აზიარებთ, ფაილი {server}-ზე ინახება." +"People on your network join with a code. Nothing goes through a server." = "თქვენი ქსელის ადამიანები კოდით უერთდებიან. სერვერზე არაფერი გადის." +"Phone" = "ტელეფონი" +"Pick color" = "ფერის არჩევა" +"Pick color (I)" = "ფერის აღება (I)" +"Pick one font family first" = "ჯერ აირჩიეთ ერთი შრიფტის ოჯახი" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "აირჩიეთ ჩარჩოს ხელსაწყო, შემდეგ გადაათრიეთ ტილოზე ან აირჩიეთ მოწყობილობის ზომა" +"Pin a property" = "თვისების მიმაგრება" +"Pinch" = "მოჭყლეტა" +"Pixel grid" = "პიქსელური ბადე" +"Pixel preview 1x" = "პიქსელების წინასწარი ნახვა 1x" +"Pixel preview 2x" = "პიქსელების წინასწარი ნახვა 2x" +"Place a shape or frame (its tool chosen)" = "ფიგურის ან ჩარჩოს განთავსება (არჩეული ხელსაწყოთი)" +"Place automatically" = "ავტომატურად განთავსება" +"Place image" = "გამოსახულების განთავსება" +"Plugin" = "პლაგინი" +"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "პლაგინები FigCraft-ის იზოლირებულ გარემოში მუშაობს, ფაილების, ქსელისა და საათის გარეშე. დააინსტალირეთ მხოლოდ სანდო პლაგინები." +"Plugins" = "პლაგინები" +"Plugins & widgets" = "პლაგინები და ვიჯეტები" +"Plus darker" = "პლუს მუქი" +"Plus lighter" = "პლუს ღია" +"Point" = "წერტილი" +"Points" = "წერტილები" +"Polygon" = "მრავალკუთხედი" +"Polygons" = "მრავალკუთხედები" +"Port" = "პორტი" +"Position" = "პოზიცია" +"Post" = "გაგზავნა" +"Preferences" = "პარამეტრები" +"Preferred" = "სასურველი" +"Present" = "წარდგენა" +"Present or preview" = "წარდგენა ან წინასწარი ნახვა" +"Present this flow" = "ამ ნაკადის წარდგენა" +"Presentation" = "პრეზენტაცია" +"Presentation slides" = "პრეზენტაციის სლაიდები" +"Presenting: comments" = "წარდგენა: კომენტარები" +"Presenting: cycle the size options" = "წარდგენა: ზომის ვარიანტების მონაცვლეობა" +"Presenting: full screen" = "წარდგენა: სრული ეკრანი" +"Presenting: next frame" = "წარდგენა: შემდეგი ჩარჩო" +"Presenting: previous frame" = "წარდგენა: წინა ჩარჩო" +"Presenting: restart" = "წარდგენა: თავიდან დაწყება" +"Preserve scroll position" = "გადახვევის პოზიციის შენარჩუნება" +"Presets" = "პრესეტები" +"Press a key…" = "დააჭირეთ კლავიშს…" +"Playground" = "საცდელი სივრცე" +"Preview" = "წინასწარი ნახვა" +"Preview this flow" = "ამ ნაკადის წინასწარი ნახვა" +"Preview this frame" = "ამ ჩარჩოს წინასწარი ნახვა" +"Previous frame" = "წინა ჩარჩო" +"Previous ready for dev" = "წინა მზად დეველოპერისთვის" +"Progressive" = "პროგრესული" +"Properties" = "თვისებები" +"Prototype" = "პროტოტიპი" +"Prototype preview" = "პროტოტიპის წინასწარი ნახვა" +"Prototype settings" = "პროტოტიპის პარამეტრები" +"Publish" = "გამოქვეყნება" +"Publish changes" = "ცვლილებების გამოქვეყნება" +"Publish to another server…" = "სხვა სერვერზე გამოქვეყნება…" +"Publish to server" = "სერვერზე გამოქვეყნება" +"Published version {v}" = "გამოქვეყნდა ვერსია {v}" +"Published version {v} to the server" = "ვერსია {v} გამოქვეყნდა სერვერზე" +"Published version {v}: save the file so other files can use it" = "გამოქვეყნდა ვერსია {v}: შეინახეთ ფაილი, რომ სხვა ფაილებმა შეძლონ მისი გამოყენება" +"Published · {people}" = "გამოქვეყნებულია · {people}" +"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "სერვერზე გამოსაქვეყნებლად სამუშაო მაგიდის აპია საჭირო; ბრაუზერში შეგიძლიათ სერვერის ფაილებს შეუერთდეთ" +"Publishing with the server's key." = "ქვეყნდება სერვერის გასაღებით." +"Publishing…" = "გამოქვეყნება…" +"Push" = "ბიძგი" +"Push changes to main component" = "ცვლილებების მთავარ კომპონენტში გადაგზავნა" +"Put text on path" = "ტექსტის ტრაექტორიაზე განთავსება" +"R" = "R" +"Radial" = "რადიალური" +"Radius" = "რადიუსი" +"Ratio" = "თანაფარდობა" +"Reading the library…" = "ბიბლიოთეკის წაკითხვა…" +"Ready for dev" = "მზადაა დეველოპერისთვის" +"Real name" = "ნამდვილი სახელი" +"Recent" = "ბოლოდროინდელი" +"Recent files" = "ბოლოს გახსნილი ფაილები" +"Recents" = "ბოლოდროინდელი" +"Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them" = { one = "აპის მოულოდნელი დახურვის შემდეგ აღდგა შეუნახავი ფაილი: შესანარჩუნებლად შეინახეთ", other = "აპის მოულოდნელი დახურვის შემდეგ აღდგა შეუნახავი ფაილი: {n}. შესანარჩუნებლად შეინახეთ" } +"Rectangle" = "მართკუთხედი" +"Rectangles" = "მართკუთხედები" +"Red" = "წითელი" +"Reduce motion" = "მოძრაობის შემცირება" +"Refraction" = "გარდატეხა" +"Regular expression" = "რეგულარული გამოსახულება" +"Remove" = "წაშლა" +"Remove all" = "ყველას წაშლა" +"Remove annotation" = "ანოტაციის წაშლა" +"Remove auto layout" = "ავტოგანლაგების მოხსნა" +"Remove dev resource" = "დეველოპერის რესურსის წაშლა" +"Remove export setting" = "ექსპორტის პარამეტრის წაშლა" +"Remove from recents" = "ბოლოდროინდელებიდან ამოშლა" +"Remove from server" = "სერვერიდან წაშლა" +"Remove from server…" = "სერვერიდან წაშლა…" +"Remove guide" = "დამხმარე ხაზის მოცილება" +"Remove interaction" = "ურთიერთქმედების წაშლა" +"Remove library from this file" = "ბიბლიოთეკის ამ ფაილიდან ამოშლა" +"Remove min and max" = "მინ. და მაქს. მოხსნა" +"Remove min and max height" = "მინ. და მაქს. სიმაღლის მოხსნა" +"Remove min and max width" = "მინ. და მაქს. სიგანის მოხსნა" +"Remove regions" = "არეების წაშლა" +"Remove slot" = "სლოტის მოხსნა" +"Remove starting point" = "საწყისი წერტილის წაშლა" +"Remove stop" = "გადასვლის წერტილის წაშლა" +"Remove text from path" = "ტექსტის ტრაექტორიიდან მოხსნა" +"Remove the else and its actions" = "„სხვა შემთხვევაში“ და მისი მოქმედებების წაშლა" +"Remove this condition and its actions" = "ამ პირობისა და მისი მოქმედებების წაშლა" +"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "წაიშალოს ეს ფაილი სერვერიდან? იქიდან მისი გაზიარება ან გახსნა აღარ იქნება შესაძლებელი. თქვენი ასლი აქ ღია რჩება." +"Remove {name} from session" = "სესიიდან მოცილება: {name}" +"Remove {name} from {server}?" = "წაიშალოს {name} {server}-იდან?" +"Removed" = "წაშლილია" +"Removed from the server" = "წაიშალა სერვერიდან" +"Removed since it was marked" = "წაიშალა მონიშვნის შემდეგ" +"Rename" = "სახელის შეცვლა" +"Rename duplicated layers" = "დუბლირებული შრეების სახელის შეცვლა" +"Rename folder" = "საქაღალდის სახელის შეცვლა" +"Rename group" = "ჯგუფის სახელის შეცვლა" +"Rename layers" = "შრეების სახელის შეცვლა" +"Rename mode" = "რეჟიმის სახელის შეცვლა" +"Rename on the server" = "სახელის შეცვლა სერვერზე" +"Rename on the server…" = "სახელის შეცვლა სერვერზე…" +"Rename page" = "გვერდის სახელის შეცვლა" +"Rename to" = "ახალი სახელი" +"Replace all" = "ყველას ჩანაცვლება" +"Replace mixed export settings" = "შერეული ექსპორტის პარამეტრების ჩანაცვლება" +"Replace with" = "ჩანაცვლება:" +"Replace with a new interaction" = "ახალი ურთიერთქმედებით ჩანაცვლება" +"Reply" = "პასუხი" +"Report" = "საჩივარი" +"Report this file…" = "ამ ფაილზე საჩივარი…" +"Reset" = "ჩამოყრა" +"Reset adjustments" = "კორექციების ჩამოყრა" +"Reset all changes" = "ყველა ცვლილების ჩამოყრა" +"Reset all overrides on this layer" = "ამ შრის ყველა გადაფარვის ჩამოყრა" +"Reset overrides" = "გადაფარვების ჩამოყრა" +"Reset slot" = "სლოტის ჩამოყრა" +"Reset this override" = "ამ გადაფარვის ჩამოყრა" +"Reset to style" = "სტილზე ჩამოყრა" +"Reset to the inherited value" = "მემკვიდრეობით მიღებულ მნიშვნელობაზე ჩამოყრა" +"Resize by the nudge amount" = "ზომის შეცვლა მცირედ გადაწევის ბიჯით" +"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "ჩარჩოების ზომის მორგება მოწყობილობის ეკრანზე (ან ფანჯარაზე, თუ მოწყობილობა არ არის): მათი შეზღუდვები და ავტოგანლაგება რეაგირებს" +"Resize to fit" = "ზომის მორგება" +"Resizing" = "ზომის შეცვლა" +"Resolve" = "გადაწყვეტა" +"Resolve change" = "ცვლილების გადაწყვეტა" +"Resolved" = "გადაწყვეტილია" +"Resources" = "რესურსები" +"Responsive" = "ადაპტური" +"Restart" = "გადატვირთვა" +"Restore" = "აღდგენა" +"Restore main component" = "მთავარი კომპონენტის აღდგენა" +"Restore this version" = "ამ ვერსიის აღდგენა" +"Restored “{name}”" = "„{name}“ აღდგა" +"Reverse the order" = "რიგის შებრუნება" +"Reversed triangle" = "შებრუნებული სამკუთხედი" +"Review and merge" = "განხილვა და შერწყმა" +"Ribbon" = "ლენტი" +"Right" = "მარჯვნივ" +"Right side" = "მარჯვენა მხარე" +"Right side width" = "მარჯვენა მხარის სიგანე" +"Rotate 90°" = "90°-ით შემობრუნება" +"Rotate 90° right" = "90°-ით მარჯვნივ შემობრუნება" +"Rotate to landscape" = "ჰორიზონტალურად შემობრუნება" +"Rotate to portrait" = "ვერტიკალურად შემობრუნება" +"Rotation" = "შემობრუნება" +"Rotation jitter" = "შემობრუნების ცვალებადობა" +"Round" = "მომრგვალებული" +"Rounded" = "მომრგვალებული" +"Row" = "სტრიქონი" +"Row gap" = "სტრიქონებს შორის შუალედი" +"Row sizes" = "სტრიქონების ზომები" +"Row span" = "სტრიქონების გაერთიანება" +"Rows" = "სტრიქონები" +"Rows {count}" = "სტრიქონები {count}" +"Rulers" = "სახაზავები" +"Run" = "გაშვება" +"Run {label}" = "გაშვება: {label}" +"Save files automatically" = "ფაილების ავტომატურად შენახვა" +"Search templates" = "შაბლონების ძიება" +"See all templates" = "ყველა შაბლონის ნახვა" +"Select layers and they space themselves as their content changes" = "მონიშნეთ შრეები და შიგთავსის ცვლილებისას ისინი ავტომატურად დაშორდება ერთმანეთს" +"SHA-256: {sha}…" = "SHA-256: {sha}…" +"Same as layer" = "შრის მსგავსად" +"Saturation" = "გაჯერება" +"Save" = "შენახვა" +"Save as" = "შენახვა როგორც" +"Save as preset…" = "პრესეტად შენახვა…" +"Save version {key}" = "ვერსიის შენახვა {key}" +"Saved" = "შენახულია" +"Saved {name}" = "შენახულია {name}" +"Say something" = "დაწერეთ რამე" +"Scale" = "მასშტაბი" +"Scatter" = "გაბნევა" +"Scoping: where pickers offer it" = "არე: სად სთავაზობენ ამომრჩევები" +"Screen" = "ეკრანი" +"Scroll behavior" = "გადახვევის ქცევა" +"Scroll to" = "გადახვევა:" +"Scroll to layer" = "შრემდე გადახვევა" +"Scroll with parent" = "მშობელთან ერთად გადახვევა" +"Search plugins" = "პლაგინების ძიება" +"Search" = "ძიება" +"Search actions, layers and tools" = "მოქმედებების, შრეებისა და ხელსაწყოების ძიება" +"Search assets" = "რესურსების ძიება" +"Search comments" = "კომენტარების ძიება" +"Search files and templates" = "ფაილებისა და შაბლონების ძიება" +"Search results" = "ძიების შედეგები" +"Search fonts" = "შრიფტების ძიება" +"Search layers" = "შრეების ძიება" +"Search shortcuts" = "მალსახმობების ძიება" +"Section" = "სექცია" +"Section color" = "სექციის ფერი" +"Sections" = "სექციები" +"Select a frame and add a starting point to present it as a flow." = "მონიშნეთ ჩარჩო და დაამატეთ საწყისი წერტილი, რომ ნაკადად წარადგინოთ." +"Select a frame to preview it" = "მონიშნეთ ჩარჩო მისი წინასწარი ნახვისთვის" +"Select a layer to see its properties and code" = "მონიშნეთ შრე მისი თვისებებისა და კოდის სანახავად" +"Select all" = "ყველაფრის მონიშვნა" +"Select all with same" = "ყველაფრის მონიშვნა იგივე" +"Select layer" = "შრის მონიშვნა" +"Select layers to apply the style to" = "მონიშნეთ შრეები, რომლებზეც სტილი უნდა გამოიყენოთ" +"Select layers with this color" = "ამ ფერის შრეების მონიშვნა" +"Select overlapping shapes" = "გადაფარული ფიგურების მონიშვნა" +"Select points to edit their position, radius and mirroring" = "მონიშნეთ წერტილები მათი პოზიციის, რადიუსისა და არეკვლის შესაცვლელად" +"Select slot" = "სლოტის მონიშვნა" +"Selection colors" = "მონიშნულის ფერები" +"Selection" = "მონიშნული" +"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "მონიშვნის ჩარჩო, {w} × {h}, მდებარეობა {x}, {y}: ისრები გადააადგილებს, Shift+ისრები ან {cmd}+ისრები ზომას ცვლის, Enter მონიშნავს, Escape გააუქმებს" +"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "მონიშვნის ჩარჩო: ისრები გადააადგილებს, Shift+ისრები ან {cmd}+ისრები ზომას ცვლის, Enter მონიშნავს, Escape გააუქმებს" +"Send backward" = "უკან გადატანა" +"Send report" = "საჩივრის გაგზავნა" +"Send to back" = "უკანა პლანზე გადატანა" +"Sep" = "სექ" +"Server address (ws://host:47900/)" = "სერვერის მისამართი (ws://host:47900/)" +"Server address" = "სერვერის მისამართი" +"Server for online sharing" = "ონლაინ გაზიარების სერვერი" +"Set as default" = "ნაგულისხმევად დაყენება" +"Set variable" = "ცვლადის დაყენება" +"Set variable mode" = "ცვლადის რეჟიმის დაყენება" +"Set variable {name}" = "ცვლადის დაყენება: {name}" +"Set {name} to {value}" = "{name}-ისთვის {value}-ის დაყენება" +"Shadows" = "ჩრდილები" +"Shape" = "ფიგურა" +"Shape builder" = "ფიგურების კონსტრუქტორი" +"Shape tools" = "ფიგურის ხელსაწყოები" +"Share" = "გაზიარება" +"Share this file" = "ამ ფაილის გაზიარება" +"Share with a code" = "კოდით გაზიარება" +"Share: live session, export" = "გაზიარება: ცოცხალი სესია, ექსპორტირება" +"Shared" = "გაზიარებულია" +"Shared: copy its code from the file's menu" = "გაზიარებულია: მისი კოდი დააკოპირეთ ფაილის მენიუდან" +"Sharing a file needs the desktop app; here you can join one." = "ფაილის გასაზიარებლად სამუშაო მაგიდის აპია საჭირო; აქ შეგიძლიათ შეუერთდეთ." +"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "ზიარდება ლოკალურ ქსელში (ონლაინ გაზიარება გამორთულია: პარამეტრებში სერვერი არ არის მითითებული)." +"Sharing on your network · {people}" = "ზიარდება თქვენს ქსელში · {people}" +"Sharing online · {people}" = "ზიარდება ონლაინ · {people}" +"Sharing stopped: the file stays on the server, open only to you" = "გაზიარება შეწყდა: ფაილი სერვერზე რჩება და მხოლოდ თქვენთვისაა ღია" +"Show" = "ჩვენება" +"Show all templates" = "ყველა შაბლონის ჩვენება" +"Show as grid" = "ბადის სახით ჩვენება" +"Show as list" = "სიის სახით ჩვენება" +"Show autosaves" = "ავტოშენახვების ჩვენება" +"Show behind transparent areas" = "გამჭვირვალე არეების მიღმა ჩვენება" +"Show comments (Shift+C)" = "კომენტარების ჩვენება (Shift+C)" +"Show device frame" = "მოწყობილობის კორპუსის ჩვენება" +"Show dimensions on objects" = "ზომების ჩვენება ობიექტებზე" +"Show hotspot hints" = "აქტიური არეების მინიშნებების ჩვენება" +"Show in Explorer" = "Explorer-ში ჩვენება" +"Show in Finder" = "Finder-ში ჩვენება" +"Show in folder" = "საქაღალდეში ჩვენება" +"Show only this on the canvas" = "ტილოზე მხოლოდ ამის ჩვენება" +"Show open" = "გახსნილის ჩვენება" +"Show section contents" = "სექციის შიგთავსის ჩვენება" +"Show this layer" = "ამ შრის ჩვენება" +"Show usage" = "გამოყენების ჩვენება" +"Show/Hide" = "ჩვენება/დამალვა" +"Show/Hide UI" = "ინტერფეისის ჩვენება/დამალვა" +"Side by side" = "გვერდიგვერდ" +"Sidebar" = "გვერდითი ზოლი" +"Simplify stroke" = "კონტურის გამარტივება" +"Size" = "ზომა" +"Size jitter" = "ზომის ცვალებადობა" +"Skip ink" = "ხაზგასმის გაწყვეტა ასოებთან" +"Slashed zero" = "გადახაზული ნული" +"Slice" = "ნაჭერი" +"Slices" = "ნაჭრები" +"Slide in" = "შემოსრიალება" +"Slide out" = "გასრიალება" +"Slot" = "სლოტი" +"Slot content" = "სლოტის შიგთავსი" +"Small capitals" = "პატარა მთავრული" +"Small caps" = "პატარა მთავრული" +"Smart animate" = "ჭკვიანი ანიმაცია" +"Snap to pixel grid" = "პიქსელურ ბადეზე მიბმა" +"Social and slides" = "სოციალური ქსელები და სლაიდები" +"Social media" = "სოციალური ქსელები" +"Social post sizes" = "სოციალური ქსელების პოსტების ზომები" +"Soft light" = "რბილი ღია" +"Solid" = "ერთფეროვანი" +"Some variants have the same property values: give each one a unique combination" = "ზოგ ვარიანტს თვისებების ერთნაირი მნიშვნელობები აქვს: მიეცით თითოეულს უნიკალური კომბინაცია" +"Sort" = "სორტირება" +"Sort by" = "სორტირება:" +"Sort by date" = "თარიღის მიხედვით სორტირება" +"Sort by page" = "გვერდის მიხედვით სორტირება" +"Sort by {column}" = "სორტირება: {column}" +"Space around" = "დაშორება გარშემო" +"Space between" = "დაშორება შორის" +"Space drag" = "Space და გადათრევა" +"Space evenly" = "თანაბარი დაშორება" +"Space+drag" = "Space+გადათრევა" +"Spacing" = "ინტერვალი" +"Spam, stolen work, abuse…" = "სპამი, მოპარული ნამუშევარი, შეურაცხყოფა…" +"Splay" = "გაშლილობა" +"Spotlight me" = "ჩემი ხედის ჩვენება ყველასთვის" +"Spread" = "გაფართოება" +"Square" = "კვადრატი" +"Standard" = "სტანდარტული" +"Standard ligatures" = "სტანდარტული ლიგატურები" +"Star" = "ვარსკვლავი" +"Stars" = "ვარსკვლავები" +"Start" = "დასაწყისი" +"Start ascending from / stop descending at" = "ზრდადობით დაწყება / კლებადობით დასრულება" +"Start from a blank canvas" = "ცარიელი ტილოთი დაწყება" +"Start from a template" = "შაბლონით დაწყება" +"Start on path" = "დასაწყისი ტრაექტორიაზე" +"Start point" = "საწყისი წერტილი" +"Start sharing" = "გაზიარების დაწყება" +"Start sharing with the Share button, then pass on the code it gives" = "გაზიარება დაიწყეთ ღილაკით „გაზიარება“, შემდეგ გადაეცით მის მიერ მოცემული კოდი" +"Starting to share…" = "გაზიარების დაწყება…" +"Status" = "სტატუსი" +"Sticky" = "მიმაგრებული" +"Stop" = "შეწყვეტა" +"Stop following {name}" = "{name}-ის მიყოლის შეწყვეტა" +"Stop session" = "სესიის შეწყვეტა" +"Stop sharing" = "გაზიარების შეწყვეტა" +"Stop spotlighting" = "ჩემი ხედის ჩვენების შეწყვეტა" +"Stop {n} {hex}" = "გადასვლის წერტილი {n} {hex}" +"Stretch" = "გაწელვა" +"Strikethrough" = "გადახაზული" +"String" = "ტექსტი" +"Stroke" = "კონტური" +"Stroke style" = "კონტურის სტილი" +"Stroke weight" = "კონტურის სისქე" +"Strokes" = "კონტურები" +"Style" = "სტილი" +"Styles" = "სტილები" +"Stylistic set 1" = "სტილისტური ნაკრები 1" +"Stylistic set 2" = "სტილისტური ნაკრები 2" +"Stylistic set 3" = "სტილისტური ნაკრები 3" +"Stylistic set 4" = "სტილისტური ნაკრები 4" +"Stylistic set 5" = "სტილისტური ნაკრები 5" +"Subtract" = "გამოკლება" +"Subtracts" = "გამოკლებები" +"Suffix" = "სუფიქსი" +"Suspend snapping while dragging" = "მიბმის შეჩერება გადათრევისას" +"Swap instance" = "ინსტანსის ჩანაცვლება" +"Swap library: use another library's assets of the same names" = "ბიბლიოთეკის ჩანაცვლება: სხვა ბიბლიოთეკის იმავე სახელის რესურსების გამოყენება" +"Swap orientation" = "ორიენტაციის შეცვლა" +"Swap overlay" = "გადაფარვის ჩანაცვლება" +"Swap to {name}" = "ჩანაცვლება: {name}" +"Swap width and height" = "სიგანისა და სიმაღლის გაცვლა" +"Swapped instance" = "ჩანაცვლებული ინსტანსი" +"Swapped {n} assets" = { one = "ჩანაცვლებული რესურსი: {n}", other = "ჩანაცვლებული რესურსი: {n}" } +"Swapped {n} assets; {missed} had no match and still use the old library" = { one = "ჩანაცვლებული რესურსი: {n}; {missed} ვერ დაემთხვა და ისევ ძველ ბიბლიოთეკას იყენებს", other = "ჩანაცვლებული რესურსი: {n}; {missed} ვერ დაემთხვა და ისევ ძველ ბიბლიოთეკას იყენებს" } +"Sweep" = "სიგრძე" +"System" = "სისტემა" +"Tab / Shift+Tab" = "Tab / Shift+Tab" +"Table" = "ცხრილი" +"Tablet" = "პლანშეტი" +"Tabular numbers" = "ცხრილური ციფრები" +"Take the values from the first selected layer" = "მნიშვნელობების აღება პირველი მონიშნული შრიდან" +"Taper" = "წაწვეტება" +"Taper both ends" = "ორივე ბოლოს წაწვეტება" +"Tell the server's operator what is wrong with it" = "აცნობეთ სერვერის ოპერატორს, რა არის მასში არასწორი" +"Temperature" = "ტემპერატურა" +"Template" = "შაბლონი" +"Templates" = "შაბლონები" +"Terms" = "პირობები" +"Text" = "ტექსტი" +"Text align" = "ტექსტის სწორება" +"Text content" = "ტექსტის შიგთავსი" +"Text style" = "ტექსტის სტილი" +"Text styles" = "ტექსტის სტილები" +"Texts" = "ტექსტები" +"Texture" = "ტექსტურა" +"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "ტილო ფერებს sRGB-ში აჩვენებს; ექსპორტირებულ PNG, JPG და WebP ფაილებს Display P3 ჭდე აქვს." +"The destination document is unavailable. Nothing was imported." = "დანიშნულების დოკუმენტი მიუწვდომელია. იმპორტი არ შესრულდა." +"The destination document was closed. Nothing was imported." = "დანიშნულების დოკუმენტი დაიხურა. იმპორტი არ შესრულდა." +"The destination page was removed. Nothing was imported." = "დანიშნულების გვერდი წაიშალა. იმპორტი არ შესრულდა." +"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "ფაილი სერვერიდან წაიშლება: იქიდან მის გახსნას ან შეერთებას ვეღარავინ შეძლებს და ამის დაბრუნება შეუძლებელია. ღია ასლები მათ მფლობელებთან რჩება." +"The file now holds another library" = "ფაილი ახლა სხვა ბიბლიოთეკას შეიცავს" +"The file now holds another library (\"{name}\")" = "ფაილი ახლა სხვა ბიბლიოთეკას შეიცავს („{name}“)" +"The files you shared or published online from this computer. They stay on their server until you remove them." = "ფაილები, რომლებიც ამ კომპიუტერიდან ონლაინ გააზიარეთ ან გამოაქვეყნეთ. ისინი თავიანთ სერვერზე რჩება, სანამ არ წაშლით." +"The link carries the session's secret: share it only with people you invite." = "ბმული სესიის საიდუმლო გასაღებს შეიცავს: გააზიარეთ მხოლოდ მოწვეულ ადამიანებთან." +"The name in your online files and on the server. The document inside keeps its own name." = "სახელი თქვენს ონლაინ ფაილებსა და სერვერზე. შიგნით არსებული დოკუმენტი თავის სახელს ინარჩუნებს." +"The selection uses several text styles: click to apply one" = "მონიშნული ტექსტის რამდენიმე სტილს იყენებს: დაწკაპეთ ერთის გამოსაყენებლად" +"The server deletes this file if nobody opens it before {date}." = "სერვერი ამ ფაილს წაშლის, თუ მას {date}-მდე არავინ გახსნის." +"The whole file" = "მთელი ფაილი" +"Theme" = "თემა" +"There's no destination for that action" = "ამ მოქმედებას დანიშნულება არ აქვს" +"Thickness" = "სისქე" +"This branch can't be reviewed: {e}" = "ამ განშტოების განხილვა შეუძლებელია: {e}" +"This branch was merged or archived." = "ეს განშტოება შერწყმულია ან დაარქივებულია." +"This computer only" = "მხოლოდ ეს კომპიუტერი" +"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "ეს კომპიუტერი იმახსოვრებს მფლობელის ტოკენს, ამიტომ ფაილის მართვა საწყისი ეკრანის „ონლაინ“ სიიდან შეიძლება. ფაილის სხვა ადგილიდან სამართავად დააკოპირეთ ის: ეს ამ ფაილის პაროლია და აღარ გამოჩნდება." +"This condition is edited as an expression" = "ეს პირობა ექსპრესიად რედაქტირდება" +"This file" = "ეს ფაილი" +"This file has unsaved changes" = "ამ ფაილს შეუნახავი ცვლილებები აქვს" +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "ეს ფაილი იყენებს შრიფტებს, რომლებიც ამ კომპიუტერზე დაინსტალირებული არ არის. მათი ტექსტი სათადარიგო შრიფტით გამოისახება, სანამ მათ არ დააინსტალირებთ." +"This file is on {server}, open only to you. Share it to let others in with a new code." = "ეს ფაილი {server}-ზეა და მხოლოდ თქვენთვისაა ღია. გააზიარეთ, რომ სხვები ახალი კოდით შემოუშვათ." +"This frame is new on the branch." = "ეს ჩარჩო განშტოებაში ახალია." +"This frame isn't on the branch." = "ეს ჩარჩო განშტოებაში არ არის." +"This server is open to the public: no key needed." = "ეს სერვერი საჯაროა: გასაღები საჭირო არ არის." +"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "ამ სერვერს გასაღები სჭირდება: მისამართს დაუმატეთ ?key=… (`figcraft-server key` გამოიტანს მას სერვერის კომპიუტერზე)." +"This server takes files only with its key: see Advanced." = "ეს სერვერი ფაილებს მხოლოდ გასაღებით იღებს: იხილეთ „დამატებითი“." +"Tidy up" = "მოწესრიგება" +"Tile" = "ფილებად" +"Tile type" = "ფილის ტიპი" +"Tint" = "ტონირება" +"Title" = "დასახელება" +"Title case" = "სათაურის რეგისტრი" +"To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "ამ ბიბლიოთეკის გუნდთან გასაზიარებლად გამოაქვეყნეთ ფაილი სერვერზე (გაზიარება ▸ სერვერზე გამოქვეყნება…), შემდეგ იქ გამოაქვეყნეთ ბიბლიოთეკა." +"Toggle" = "გადართვა" +"Toggle light/dark theme" = "ღია/ბნელი თემის გადართვა" +"Toggle visibility" = "ხილვადობის ჩართვა/გამორთვა" +"Tools" = "ხელსაწყოები" +"Top" = "ზემოთ" +"Top & bottom" = "ზემოთ და ქვემოთ" +"Top left radius" = "ზედა მარცხენა რადიუსი" +"Top right radius" = "ზედა მარჯვენა რადიუსი" +"Transform" = "გარდაქმნა" +"Triangle arrow" = "სამკუთხა ისარი" +"Trigger" = "გამშვები" +"True" = "ჭეშმარიტი" +"Truncate text" = "ტექსტის შეკვეცა" +"Truncate with an ellipsis" = "შეკვეცა მრავალწერტილით" +"Try again" = "ხელახლა ცდა" +"Try another search or filter." = "სცადეთ სხვა ძიება ან ფილტრი." +"Try another search, or browse templates." = "სცადეთ სხვა ძიება ან დაათვალიერეთ შაბლონები." +"Try another word, or look at every kind." = "სცადეთ სხვა სიტყვა ან ნახეთ ყველა სახეობა." +"Turn a layer into a component, reuse it, and change every copy at once" = "შრე აქციეთ კომპონენტად, გამოიყენეთ ხელახლა და შეცვალეთ ყველა ასლი ერთდროულად" +"Two digits" = "ორი ციფრი" +"Type" = "ტიპი" +"Type settings" = "ტექსტის პარამეტრები" +"Type the join code you were given, or paste an invite link." = "შეიყვანეთ მოცემული შეერთების კოდი ან ჩასვით მოსაწვევი ბმული." +"Typography" = "ტიპოგრაფია" +"Underline" = "ხაზგასმული" +"Undo takes back only your own changes; what others changed since stays." = "დაბრუნება მხოლოდ თქვენს ცვლილებებს აუქმებს; რაც სხვებმა ამასობაში შეცვალეს, რჩება." +"Ungroup" = "დაჯგუფების მოხსნა" +"Uniform" = "ერთგვაროვანი" +"Union" = "გაერთიანება" +"Unions" = "გაერთიანებები" +"Unknown — treated as sRGB" = "უცნობი — განიხილება როგორც sRGB" +"Unread" = "წაუკითხავი" +"Unresolve" = "ხელახლა გახსნა" +"Unsaved changes" = "შეუნახავი ცვლილებები" +"Unsaved changes in {names}. Close the window and lose them?" = "შეუნახავი ცვლილებები: {names}. დაიხუროს ფანჯარა და დაიკარგოს ისინი?" +"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "შეუნახავი ცვლილებები: ჯერ შეინახეთ ({save}) ან გასაუქმებლად დახურეთ ჩანართი {alt}+დაწკაპუნებით" +"Unsaved changes: save first, or close again with {key} to discard" = "შეუნახავი ცვლილებები: ჯერ შეინახეთ ან გასაუქმებლად ხელახლა დახურეთ {key}-ით" +"Up" = "ზემოთ" +"Update plugin" = "პლაგინის განახლება" +"Update" = "განახლება" +"Update from main" = "მთავარიდან განახლება" +"Update from selection" = "მონიშნულიდან განახლება" +"Updated {components} and {styles}" = "განახლდა {components} და {styles}" +"Updated {name}" = "განახლდა: {name}" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "ხშირად გრაფიკის დრაივერის განახლება შველის. მხოლოდ OpenGL-ის გამოსაყენებლად FigCraft გაუშვით ბრძანების სტრიქონიდან (Command Prompt) ასე:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "ხშირად გრაფიკის დრაივერის განახლება შველის. WGPU_BACKEND-ით (vulkan, gl, metal) შეგიძლიათ სხვა გრაფიკული API სცადოთ." +"Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "ტვირთავს ამ ფაილს FigCraft-ის სერვერზე და ამ ჩანართს მასთან სინქრონში ინახავს. მიიღებთ რედაქტირებისა და ნახვის ბმულებს." +"Uppercase" = "მთავრული" +"Use a fixed value" = "ფიქსირებული მნიშვნელობის გამოყენება" +"Use an expression, e.g. count + 1" = "ექსპრესიის გამოყენება, მაგ. count + 1" +"Use as mask" = "ნიღბად გამოყენება" +"Use auto layout" = "ავტოგანლაგების გამოყენება" +"Use number keys for opacity" = "გაუმჭვირვალობისთვის ციფრული კლავიშების გამოყენება" +"Use the .figcraft or .fig extension." = "გამოიყენეთ .figcraft ან .fig გაფართოება." +"Use the layer's fill" = "შრის შევსების გამოყენება" +"Used in {files} · {instances}" = "გამოიყენება: {files} · {instances}" +"Username" = "მომხმარებლის სახელი" +"Value" = "მნიშვნელობა" +"Uses variables: each layer shows them in its own mode" = "იყენებს ცვლადებს: თითოეული შრე მათ თავის რეჟიმში აჩვენებს" +"Variable" = "ცვლადი" +"Variable collections" = "ცვლადების კოლექციები" +"Variable modes" = "ცვლადების რეჟიმები" +"Variable width" = "ცვლადი სიგანე" +"Variables" = "ცვლადები" +"Variables JSON…" = "ცვლადების JSON…" +"Variables store reusable values (colours, numbers, strings, booleans) with modes such as Light and Dark." = "ცვლადები ინახავს ხელახლა გამოსაყენებელ მნიშვნელობებს (ფერები, რიცხვები, სტრიქონები, ლოგიკური მნიშვნელობები) ისეთი რეჟიმებით, როგორიცაა ღია და მუქი." +"Variant" = "ვარიანტი" +"Vector" = "ვექტორი" +"Vectors" = "ვექტორები" +"Version" = "ვერსია" +"Version history" = "ვერსიების ისტორია" +"Version {version}" = "ვერსია {version}" +"Version {v} · {components} · {styles} · {variables}" = "ვერსია {v} · {components} · {styles} · {variables}" +"Vertical" = "ვერტიკალური" +"Vertical align" = "ვერტიკალური სწორება" +"Vertical hex" = "ვერტიკალური ექვსკუთხედი" +"Vertical padding" = "ვერტიკალური შიდა დაშორება" +"Vertical spacing" = "ვერტიკალური დაშორება" +"Vertical trim" = "ვერტიკალური ჩამოჭრა" +"View" = "ხედი" +"View link" = "ნახვის ბმული" +"View link copied" = "ნახვის ბმული დაკოპირდა" +"View only" = "მხოლოდ ნახვა" +"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "მხოლოდ ნახვა: ყოველ ცვლილებას ხედავთ მისი მოხდენისთანავე, მაგრამ რედაქტირება არ შეგიძლიათ. რედაქტირებადი ასლის შესანარჩუნებლად გადით სესიიდან." +"Viewing “{name}”" = "დათვალიერება: „{name}“" +"Violet" = "იისფერი" +"Visibility" = "ხილვადობა" +"Visible" = "ხილული" +"Vivid" = "ცოცხალი" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND დაყენებულია და გამოსაყენებელ გრაფიკულ API-ს განსაზღვრავს; მისი მოხსნის შემდეგ FigCraft თავად აირჩევს." +"Warm" = "თბილი" +"Watch" = "საათი" +"Wavy" = "ტალღოვანი" +"Web" = "ვები" +"Websites" = "ვებსაიტები" +"WebSocket URL" = "WebSocket URL" +"WebSocket URL copied" = "WebSocket URL დაკოპირდა" +"Wedge" = "სოლი" +"Weight" = "სისქე" +"What you try here changes this preview and the code below, not the file" = "რასაც აქ ცდით, ცვლის ამ გადახედვას და ქვემოთ კოდს და არა ფაილს" +"What changed since it was marked ready for dev" = "რა შეიცვალა მზად დეველოპერისთვის მონიშვნის შემდეგ" +"What is wrong with this file? The server's operator gets your report." = "რა არის ამ ფაილში არასწორი? სერვერის ოპერატორი მიიღებს თქვენს საჩივარს." +"What it's for" = "რისთვისაა" +"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "არ არის ცნობილი, რისგან შეიცვალა: ცვლილება მოხდა მანამ, სანამ FigCraft მზად დეველოპერისთვის ჩარჩოების ასლებს შეინახავდა. გადაწყვიტეთ ცვლილება (ან ხელახლა მონიშნეთ მზადად), რომ ამიერიდან შედარდეს." +"While hovering" = "მაჩვენებლის მიტანისას" +"While pressing" = "დაჭერისას" +"White" = "თეთრი" +"Who runs it, its terms and how long it keeps files" = "ვინ მართავს, მისი პირობები და რამდენ ხანს ინახავს ფაილებს" +"Why it changed" = "რატომ შეიცვალა" +"Widget" = "ვიჯეტი" +"Widgets" = "ვიჯეტები" +"Width" = "სიგანე" +"Width point" = "სიგანის წერტილი" +"Width profile" = "სიგანის პროფილი" +"Wireframe kit" = "კარკასების ნაკრები" +"With the layers' export settings" = "შრეების ექსპორტის პარამეტრებით" +"Wrap" = "გადატანა" +"Wrap in new section" = "ახალ სექციაში მოქცევა" +"Yellow" = "ყვითელი" +"Yesterday" = "გუშინ" +"You" = "თქვენ" +"You'll get the shared file as a new tab and edit it together." = "გაზიარებულ ფაილს ახალ ჩანართად მიიღებთ და ერთად დაარედაქტირებთ." +"You're spotlighting: everyone follows your view" = "ყურადღება თქვენზეა: ყველა თქვენს ხედს მიჰყვება" +"Your copy of the file stays open" = "თქვენი ფაილის ასლი ღია რჩება" +"Your copy stays open; the file stays on the server" = "თქვენი ასლი ღია რჩება; ფაილი სერვერზე რჩება" +"Your name" = "თქვენი სახელი" +"Your name in comments" = "თქვენი სახელი კომენტარებში" +"Your system asks for reduced motion: animations stay off." = "თქვენი სისტემა ითხოვს მოძრაობის შემცირებას: ანიმაციები გამორთული რჩება." +"Z" = "Z" +"Z to A" = "ჰ-დან ა-მდე" +"Zoom" = "მასშტაბი" +"Zoom around the pointer" = "მასშტაბირება მაჩვენებლის გარშემო" +"Zoom device to fill window" = "მოწყობილობის მასშტაბირება ფანჯრის შესავსებად" +"Zoom in" = "გადიდება" +"Zoom out" = "დაპატარავება" +"Zoom to 100%" = "მასშტაბი 100%" +"Zoom to 200%" = "მასშტაბი 200%" +"Zoom to 50%" = "მასშტაბი 50%" +"Zoom to fit" = "ეკრანში ჩატევა" +"Zoom to selection" = "მონიშნულის მასშტაბირება" +"Zoom tool: click to zoom in, drag an area" = "მასშტაბის ხელსაწყო: დაწკაპეთ გასადიდებლად, მონიშნეთ არე გადათრევით" +"Zoom tool: zoom out" = "მასშტაბის ხელსაწყო: დაპატარავება" +"and {n} more" = { one = "და კიდევ {n}", other = "და კიდევ {n}" } +"bleed marks" = "ჭრისპირა არის ნიშნები" +"by {author}" = "ავტორი: {author}" +"call any command its access allows" = "ნებისმიერი ბრძანების გამოძახება, რასაც მისი წვდომა უშვებს" +"call only: {commands}" = "მხოლოდ გამოძახება: {commands}" +"can edit the document" = "შეუძლია დოკუმენტის რედაქტირება" +"conditional prototype actions nested too deeply were skipped" = "ზედმეტად ღრმად ჩადგმული პირობითი მოქმედებები პროტოტიპში გამოტოვებულია" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "პროტოტიპის პირობითი მოქმედებები, რომელთა პირობების გამოთვლა ვერ ხერხდება, გამოტოვებულია" +"connector label background was skipped: style data is missing" = "შემაერთებლის იარლიყის ფონი გამოტოვებულია: სტილის მონაცემები აკლია" +"connector label was skipped: background bounds are invalid" = "შემაერთებლის იარლიყი გამოტოვებულია: ფონის საზღვრები არასწორია" +"connector label was skipped: background transform is invalid" = "შემაერთებლის იარლიყი გამოტოვებულია: ფონის გარდაქმნა არასწორია" +"connector label was skipped: composed text transform is invalid" = "შემაერთებლის იარლიყი გამოტოვებულია: ტექსტის შედგენილი გარდაქმნა არასწორია" +"connector label was skipped: derived background bounds are missing" = "შემაერთებლის იარლიყი გამოტოვებულია: ფონის გამოთვლილი საზღვრები აკლია" +"connector label was skipped: derived text bounds are missing" = "შემაერთებლის იარლიყი გამოტოვებულია: ტექსტის გამოთვლილი საზღვრები აკლია" +"connector label was skipped: text bounds are invalid" = "შემაერთებლის იარლიყი გამოტოვებულია: ტექსტის საზღვრები არასწორია" +"connector label was skipped: text style data is missing" = "შემაერთებლის იარლიყი გამოტოვებულია: ტექსტის სტილის მონაცემები აკლია" +"connector label was skipped: text transform is invalid" = "შემაერთებლის იარლიყი გამოტოვებულია: ტექსტის გარდაქმნა არასწორია" +"connector labels were skipped" = "შემაერთებლების იარლიყები გამოტოვებულია" +"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "შემაერთებლის შრეები იმპორტირდება ჩარჩოს ან ვექტორის სტატიკური სნეპშოტების სახით, იარლიყებით (თუ არის); ბოლოების ცოცხალი მიბმები არ ნარჩუნდება" +"connector snapshot vector data is missing; connector was left empty" = "შემაერთებლის სნეპშოტის ვექტორული მონაცემები აკლია; შემაერთებელი ცარიელი დარჩა" +"connectors whose ends can't be placed are empty" = "შემაერთებლები, რომელთა ბოლოების განთავსება ვერ ხერხდება, ცარიელია" +"crop marks" = "ჭრის ნიშნები" +"damaged vector geometry was skipped" = "დაზიანებული ვექტორული გეომეტრია გამოტოვებულია" +"damaged vector networks import as outlines" = "დაზიანებული ვექტორული ქსელები კონტურებად იმპორტირდება" +"development: {path}" = "დეველოპმენტი: {path}" +"document children other than pages were skipped" = "დოკუმენტის ქვეელემენტები, რომლებიც გვერდები არ არის, გამოტოვებულია" +"duplicate grid track sizing entries use equal tracks" = "ბადის ტრეკის ზომის დუბლირებული ჩანაწერებისას ტრეკები თანაბარია" +"e.g. count + 1" = "მაგ. count + 1" +"e.g. count >= 3 and not done" = "მაგ. count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "შენახული ტრაექტორიის გარეშე კუთხოვანი და მრუდი შემაერთებლები სწორ ხაზებად იმპორტირდება" +"entries without a node type were skipped" = "კვანძის ტიპის გარეშე ჩანაწერები გამოტოვებულია" +"figcraft:// links from a browser, chat or email open this app and join the session." = "ბრაუზერიდან, ჩატიდან ან ელფოსტიდან figcraft:// ბმულები ხსნის ამ აპს და უერთდება სესიას." +"filled areas of a vector with their own fill use the layer's fill" = "ვექტორის შევსებული არეები საკუთარი შევსებით შრის შევსებას იყენებს" +"from {server}" = "წყარო: {server}" +"gamepad triggers without exactly one known controller button were skipped" = "გეიმპადის გამშვებები, რომლებსაც ზუსტად ერთი ცნობილი კონტროლერის ღილაკი არ აქვს, გამოტოვებულია" +"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "ბადის ტრეკების სია 1000 ჩანაწერს აღემატება; ტრეკები ნაგულისხმევ თანაბარ ზომებს იყენებს" +"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "ბადის ტრეკის ზომა მხარდაუჭერელი ან დაზიანებულია; შესაბამისი ტრეკები თანაბარ ზომებს იყენებს" +"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "ბადის ტრეკების ზომების სია 1000 ჩანაწერს აღემატება; ტრეკები ნაგულისხმევ თანაბარ ზომებს იყენებს" +"hidden" = "დამალული" +"iOS" = "iOS" +"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "გამოსახულების ფილტრები ორაზროვანია, რადგან ფილტრების ორივე კონტეინერია მოცემული; ფილტრების გარდაქმნა გამოტოვებულია" +"image filters use FigCraft's approximate renderer" = "გამოსახულების ფილტრები FigCraft-ის მიახლოებითი რენდერით გამოისახება" +"instance swap properties whose default component isn't in the file were skipped" = "ინსტანსის ჩანაცვლების თვისებები, რომელთა ნაგულისხმევი კომპონენტი ფაილში არ არის, გამოტოვებულია" +"instances inside their own main component were imported as frames" = "საკუთარი მთავარი კომპონენტის შიგნით მყოფი ინსტანსები ჩარჩოებად იმპორტირდა" +"instances of components that aren't in the file show no layers" = "იმ კომპონენტების ინსტანსები, რომლებიც ფაილში არ არის, შრეებს არ აჩვენებს" +"instances of components that aren't in the file were imported as frames" = "იმ კომპონენტების ინსტანსები, რომლებიც ფაილში არ არის, ჩარჩოებად იმპორტირდა" +"instances that would nest a component inside itself were imported as frames" = "ინსტანსები, რომლებიც კომპონენტს საკუთარ თავში ჩადგამდა, ჩარჩოებად იმპორტირდა" +"instances without a main component were imported as empty frames" = "მთავარი კომპონენტის გარეშე ინსტანსები ცარიელ ჩარჩოებად იმპორტირდა" +"invalid internal .fig text path data was ignored" = ".fig-ის შიდა ტექსტის ტრაექტორიის არასწორი მონაცემები იგნორირებულია" +"invalid or oversized vector networks import as outlines" = "არასწორი ან ზედმეტად დიდი ვექტორული ქსელები კონტურებად იმპორტირდება" +"invalid or oversized vector path data was skipped" = "ვექტორული ტრაექტორიის არასწორი ან ზედმეტად დიდი მონაცემები გამოტოვებულია" +"just now" = "ახლახან" +"key triggers without a supported key were skipped" = "მხარდაჭერილი კლავიშის გარეშე კლავიშის გამშვებები გამოტოვებულია" +"key up triggers import as key presses" = "კლავიშის აშვების გამშვებები კლავიშის დაჭერებად იმპორტირდება" +"layers without a position were placed at their parent's origin" = "პოზიციის გარეშე შრეები მშობლის საწყის წერტილში განთავსდა" +"layers without a type were skipped" = "ტიპის გარეშე შრეები გამოტოვებულია" +"legacy image filter container is unsupported; filter conversion was skipped" = "გამოსახულების ფილტრების ძველი კონტეინერი მხარდაუჭერელია; ფილტრების გარდაქმნა გამოტოვებულია" +"locked" = "ჩაკეტილი" +"malformed image filters container was skipped" = "გამოსახულების ფილტრების დაზიანებული კონტეინერი გამოტოვებულია" +"malformed image paintFilter container was skipped" = "გამოსახულების paintFilter-ის დაზიანებული კონტეინერი გამოტოვებულია" +"malformed slot content assignment was skipped" = "სლოტის შიგთავსის დაზიანებული მინიჭება გამოტოვებულია" +"no bleed" = "ჭრისპირა არის გარეშე" +"nobody else here yet" = "აქ ჯერ სხვა არავინაა" +"non-default slot settings are not represented and were skipped" = "სლოტის არანაგულისხმევი პარამეტრები არ არის წარმოდგენილი და გამოტოვებულია" +"now" = "ახლა" +"only in a text style or variable" = "მხოლოდ ტექსტის სტილში ან ცვლადში" +"or" = "ან" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "წერტილების კონტურის შეერთებები შენარჩუნებულია; ტირეებიანი ან ცვლადი სისქის კონტურები შრის შეერთებით გამოისახება" +"preferred slot component keys cannot be resolved to file nodes and were skipped" = "სლოტის სასურველი კომპონენტების გასაღებები ფაილის კვანძებთან ვერ დაკავშირდა და გამოტოვებულია" +"prototype actions of unknown types were skipped" = "უცნობი ტიპის პროტოტიპის მოქმედებები გამოტოვებულია" +"prototype actions without a destination in the file were skipped" = "პროტოტიპის მოქმედებები, რომელთა დანიშნულება ფაილში არ არის, გამოტოვებულია" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "პროტოტიპის ექსპრესიები იმ ცვლადებზე, რომლებიც ცვლადების JSON-ში არ არის, გამოტოვებულია" +"prototype expressions with unsupported functions were skipped" = "მხარდაუჭერელი ფუნქციების შემცველი პროტოტიპის ექსპრესიები გამოტოვებულია" +"prototype interactions without supported actions were skipped" = "მხარდაჭერილი მოქმედებების გარეშე პროტოტიპის ურთიერთქმედებები გამოტოვებულია" +"prototype navigations of unknown types were skipped" = "უცნობი ტიპის პროტოტიპის ნავიგაციები გამოტოვებულია" +"prototype transitions of unknown types play instantly" = "უცნობი ტიპის პროტოტიპის გადასვლები მყისიერად სრულდება" +"prototype triggers of unknown types were skipped" = "უცნობი ტიპის პროტოტიპის გამშვებები გამოტოვებულია" +"put a widget on the canvas" = "ვიჯეტის ტილოზე განთავსება" +"read and edit the document" = "დოკუმენტის წაკითხვა და რედაქტირება" +"read the document" = "დოკუმენტის წაკითხვა" +"reads the document" = "კითხულობს დოკუმენტს" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "ცვლადის დაყენების მოქმედებები იმ ცვლადებზე, რომლებიც ცვლადების JSON-ში არ არის, გამოტოვებულია" +"set-variable actions with values that can't be evaluated were skipped" = "ცვლადის დაყენების მოქმედებები, რომელთა მნიშვნელობის გამოთვლა ვერ ხერხდება, გამოტოვებულია" +"set-variable actions with values that don't fit the variable were skipped" = "ცვლადის დაყენების მოქმედებები, რომელთა მნიშვნელობა ცვლადს არ შეესაბამება, გამოტოვებულია" +"set-variable actions without a value were skipped" = "მნიშვნელობის გარეშე ცვლადის დაყენების მოქმედებები გამოტოვებულია" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "ცვლადის რეჟიმის დაყენების მოქმედებები იმ კოლექციებზე ან რეჟიმებზე, რომლებიც ცვლადების JSON-ში არ არის, გამოტოვებულია" +"slot content assignment target is missing or is not slot content" = "სლოტის შიგთავსის მინიჭების სამიზნე აკლია ან სლოტის შიგთავსი არ არის" +"slot content assignment uses the empty GUID sentinel and was skipped" = "სლოტის შიგთავსის მინიჭება ცარიელ GUID მარკერს იყენებს და გამოტოვებულია" +"slot layers without a matching slot property were imported as frames" = "სლოტის შრეები, რომლებსაც შესაბამისი სლოტის თვისება არ აქვს, ჩარჩოებად იმპორტირდა" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "ზოგი ვექტორული წერტილი მიუთითებს წერტილის პარამეტრებზე, რომლებიც ფაილს აკლია; ისინი შრის შეერთებებსა და ჩვეულებრივ კუთხეებს იყენებს" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "ზოგი ვექტორული სეგმენტი მიუთითებს სტილის პარამეტრებზე, რომლებიც ფაილს აკლია; ისინი შრის კონტურს იყენებს" +"stale or invalid grid track sizing entries were ignored" = "ბადის ტრეკის ზომის მოძველებული ან არასწორი ჩანაწერები იგნორირებულია" +"stars and polygons without point counts or geometry use the default point count" = "ვარსკვლავები და მრავალკუთხედები წვეროების რაოდენობის ან გეომეტრიის გარეშე წვეროების ნაგულისხმევ რაოდენობას იყენებს" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "ზოგი ვექტორული სეგმენტის სტილის პარამეტრები მხარდაუჭერელია; ეს სეგმენტები შრის კონტურს იყენებს" +"text path underline or strikethrough decoration is not rendered" = "ტრაექტორიაზე განთავსებული ტექსტის ხაზგასმა ან გადახაზვა არ გამოისახება" +"text style overrides refer to missing table entries" = "ტექსტის სტილის გადაფარვები ცხრილის არარსებულ ჩანაწერებზე მიუთითებს" +"the file has no pages" = "ფაილს გვერდები არ აქვს" +"the plugin" = "პლაგინი" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "ცვლადების JSON-ს `variableCollections` არ აქვს (მოსალოდნელი იყო ლოკალური ცვლადების პასუხი) და იგნორირებულია" +"this session only" = "მხოლოდ ეს სესია" +"unknown component property types were skipped" = "კომპონენტის თვისებების უცნობი ტიპები გამოტოვებულია" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "ცვლადის ალიასები იმ ცვლადებზე, რომლებიც ცვლადების JSON-ში არ არის (ან სხვა ტიპისაა), გამოტოვებულია" +"variable bindings to properties the layer doesn't have were skipped" = "ცვლადების მიბმები იმ თვისებებზე, რომლებიც შრეს არ აქვს, გამოტოვებულია" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "ცვლადების მიბმები იმ ცვლადებზე, რომლებიც ცვლადების JSON-ში არ არის (ბიბლიოთეკის ცვლადები), გამოტოვებულია" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "ცვლადების მიბმები გამოტოვებულია: ფაილის JSON-ში ცვლადების განსაზღვრებები არ არის (გადაეცით ლოკალური ცვლადების JSON როგორც `variables`, ან ჩააშენეთ მისი `meta` ფაილის JSON-ის გვერდით)" +"variable bindings whose variable type doesn't fit the property were skipped" = "ცვლადების მიბმები, რომელთა ცვლადის ტიპი თვისებას არ შეესაბამება, გამოტოვებულია" +"variable collections without modes were skipped" = "რეჟიმების გარეშე ცვლადების კოლექციები გამოტოვებულია" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "პროტოტიპის ექსპრესიებში ცვლადის რეჟიმის მოძიებები, რომლებიც ცვლადსა და მის ერთ-ერთ რეჟიმს არ წარმოადგენს, გამოტოვებულია" +"variable modes of collections that aren't in the file were skipped" = "იმ კოლექციების ცვლადის რეჟიმები, რომლებიც ფაილში არ არის, გამოტოვებულია" +"variable modes of collections that aren't in the variables JSON were skipped" = "იმ კოლექციების ცვლადის რეჟიმები, რომლებიც ცვლადების JSON-ში არ არის, გამოტოვებულია" +"variable values that don't match their type (or composed colours) were skipped" = "ცვლადის მნიშვნელობები, რომლებიც თავიანთ ტიპს არ შეესაბამება (ან შედგენილი ფერები), გამოტოვებულია" +"variables of unknown types were skipped" = "უცნობი ტიპის ცვლადები გამოტოვებულია" +"variables whose collection isn't in the variables JSON were skipped" = "ცვლადები, რომელთა კოლექცია ცვლადების JSON-ში არ არის, გამოტოვებულია" +"variables without readable values were skipped" = "წაკითხვადი მნიშვნელობების გარეშე ცვლადები გამოტოვებულია" +"vector points with unknown stroke joins use the layer join" = "უცნობი კონტურის შეერთების მქონე ვექტორული წერტილები შრის შეერთებას იყენებს" +"vectors without geometry are empty" = "გეომეტრიის გარეშე ვექტორები ცარიელია" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "გეომეტრიის გარეშე ვექტორები ცარიელია (ფაილის JSON-ის ექსპორტი გააკეთეთ geometry=paths პარამეტრით)" +"very long text was cut to 1,048,576 characters" = "ძალიან გრძელი ტექსტი 1 048 576 სიმბოლომდე შემოკლდა" +"very long vector paths were skipped" = "ძალიან გრძელი ვექტორული ტრაექტორიები გამოტოვებულია" +"video actions are not supported and were skipped" = "ვიდეოს მოქმედებები მხარდაუჭერელია და გამოტოვებულია" +"video triggers are not supported and were skipped" = "ვიდეოს გამშვებები მხარდაუჭერელია და გამოტოვებულია" +"yesterday" = "გუშინ" +"{instances} in {files}" = "{instances} — {files}" +"{language}: draft translation, waiting for a native speaker's review." = "{language}: თარგმანის მონახაზი, ელოდება მშობლიური ენის მცოდნის განხილვას." +"{length} bleed" = "ჭრისპირა არე: {length}" +"{month} {day}, {year}" = "{day} {month}, {year}" +"{name} cleared from the list" = "{name} ამოიშალა სიიდან" +"{name} is forgotten on this computer; the file stays on {server}" = "{name} დავიწყებულია ამ კომპიუტერზე; ფაილი {server}-ზე რჩება" +"{name} is on the server." = "{name} სერვერზეა." +"{name} is spotlighting" = "ყურადღება {name}-ზეა" +"{name}\nClick to apply to the selection" = "{name}\nდაწკაპეთ მონიშნულზე გამოსაყენებლად" +"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nგადაათრიეთ ტილოზე ან დაწკაპეთ ხედის შუაში განსათავსებლად.\nინსტანსის ჩასანაცვლებლად მასზე ჩაგდებისას დააჭირეთ Alt-ს (Option)." +"{name} (from {library})" = "{name} ({library}-იდან)" +"{name} (you)" = "{name} (თქვენ)" +"{name} ({layers} layers)" = "{name} (შრე: {layers})" +"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name} ({tag}): {min} – {max}, ნაგულისხმევი {default}. სტილის მნიშვნელობაზე დასაბრუნებლად ორჯერ დაწკაპეთ სლაიდერზე; Escape გადათრევისას აუქმებს." +"{name} from {path}" = "{name} — {path}" +"{name} was removed from the server" = "{name} წაიშალა სერვერიდან" +"{name} · deleted on main" = "{name} · წაშლილია მთავარში" +"{name} · deleted on the branch" = "{name} · წაშლილია განშტოებაში" +"{name} · not published" = "{name} · არ არის გამოქვეყნებული" +"{name} · on a server" = "{name} · სერვერზე" +"{name} · published version {v}" = "{name} · გამოქვეყნებული ვერსია {v}" +"{name} · version {v} on the server" = "{name} · ვერსია {v} სერვერზე" +"{name}, in a live session" = "{name}, ცოცხალ სესიაში" +"{name}, online, open only to you" = "{name}, ონლაინ, ღიაა მხოლოდ თქვენთვის" +"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, მონიშნულია" +"{n} added" = { one = "დამატებულია: {n}", other = "დამატებულია: {n}" } +"{n} autosave versions" = { one = "ავტოშენახული ვერსია: {n}", other = "ავტოშენახული ვერსია: {n}" } +"{n} changed" = { one = "შეცვლილია: {n}", other = "შეცვლილია: {n}" } +"{n} columns" = { one = "სვეტი: {n}", other = "სვეტი: {n}" } +"{n} components" = { one = "კომპონენტი: {n}", other = "კომპონენტი: {n}" } +"{n} components and styles have newer versions" = { one = "ახალი ვერსია აქვს კომპონენტს ან სტილს: {n}", other = "ახალი ვერსია აქვს კომპონენტს ან სტილს: {n}" } +"{n} conflicts to resolve" = { one = "გადასაწყვეტი კონფლიქტი: {n}", other = "გადასაწყვეტი კონფლიქტი: {n}" } +"{n} days ago" = { one = "{n} დღის წინ", other = "{n} დღის წინ" } +"{n} files" = { one = "ფაილი: {n}", other = "ფაილი: {n}" } +"{n} hours ago" = { one = "{n} საათის წინ", other = "{n} საათის წინ" } +"{n} instances" = { one = "ინსტანსი: {n}", other = "ინსტანსი: {n}" } +"{n} layers" = { one = "შრე: {n}", other = "შრე: {n}" } +"{n} layers as one file" = { one = "შრე: {n}, ერთ ფაილად", other = "შრე: {n}, ერთ ფაილად" } +"{n} layers selected" = { one = "მონიშნული შრე: {n}", other = "მონიშნული შრე: {n}" } +"{n} layers · {parts}" = { one = "შრე: {n} · {parts}", other = "შრე: {n} · {parts}" } +"{n} matches" = { one = "დამთხვევა: {n}", other = "დამთხვევა: {n}" } +"{n} minutes ago" = { one = "{n} წუთის წინ", other = "{n} წუთის წინ" } +"{n} months ago" = { one = "{n} თვის წინ", other = "{n} თვის წინ" } +"{n} more notes omitted from this report" = { one = "ამ ანგარიშიდან გამოტოვებული სხვა შენიშვნები: {n}", other = "ამ ანგარიშიდან გამოტოვებული სხვა შენიშვნები: {n}" } +"{n} of {total}" = "{n} / {total}" +"{n} people joined" = { one = "შემოუერთდა ადამიანი: {n}", other = "შემოუერთდა ადამიანი: {n}" } +"{n} preferred components" = { one = "სასურველი კომპონენტი: {n}", other = "სასურველი კომპონენტი: {n}" } +"{n} ready" = { one = "მზადაა: {n}", other = "მზადაა: {n}" } +"{n} removed" = { one = "წაშლილია: {n}", other = "წაშლილია: {n}" } +"{n} replies" = { one = "პასუხი: {n}", other = "პასუხი: {n}" } +"{n} rows" = { one = "სტრიქონი: {n}", other = "სტრიქონი: {n}" } +"{n} styles" = { one = "სტილი: {n}", other = "სტილი: {n}" } +"{n} text layers" = { one = "ტექსტის შრე: {n}", other = "ტექსტის შრე: {n}" } +"{n} variables" = { one = "ცვლადი: {n}", other = "ცვლადი: {n}" } +"{n} weeks ago" = { one = "{n} კვირის წინ", other = "{n} კვირის წინ" } +"{n} years ago" = { one = "{n} წლის წინ", other = "{n} წლის წინ" } +"{n}d" = "{n} დღ" +"{n}h" = "{n} სთ" +"{n}m" = "{n} წთ" +"{n}w" = "{n} კვ" +"{page}, nothing selected" = "{page}, არაფერია მონიშნული" +"{profile} (current)" = "{profile} (მიმდინარე)" +"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} მისი მნიშვნელობაა; {property ? \"a\" : \"b\"} ირჩევს სტრიქონს. ნებისმიერი სხვა {…} რჩება დაწერილი სახით." +"{server} can't say what became of your files (it is older than this app)." = "{server} ვერ გეტყვით, რა მოუვიდა თქვენს ფაილებს (ის ამ აპზე ძველია)." +"{server} · No longer on the server" = "{server} · სერვერზე აღარ არის" +"{server} · {shared} · Opened {date}" = "{server} · {shared} · გაიხსნა {date}" +"{tool} hold {key}" = "{tool} დააჭირეთ {key}" +"{value} isn't a colour" = "{value} ფერი არ არის" +"{when} · edited" = "{when} · რედაქტირებულია" +"← ↑ → ↓" = "← ↑ → ↓" +"← → ↑ ↓" = "← → ↑ ↓" +"←" = "←" +"→, Space or N" = "→, Space ან N" +"↵" = "↵" +"⇥ / ⇧⇥" = "⇥ / ⇧⇥" +"⌘ arrows" = "⌘ ისრები" +"⌘ click" = "⌘ დაწკაპუნება" +"⌘ scroll" = "⌘ გადახვევა" +"⌥ drag" = "⌥ გადათრევა" + +[commands] +"align.bottom" = "ქვემოთ სწორება" +"align.hcenter" = "ჰორიზონტალური ცენტრების სწორება" +"align.left" = "მარცხნივ სწორება" +"align.right" = "მარჯვნივ სწორება" +"align.top" = "ზემოთ სწორება" +"align.vcenter" = "ვერტიკალური ცენტრების სწორება" +"annotation.add" = "ანოტაციის დამატება" +"annotation.addCategory" = "ანოტაციის კატეგორიის დამატება" +"annotation.categories" = "ანოტაციის კატეგორიები" +"annotation.edit" = "ანოტაციის რედაქტირება" +"annotation.editCategory" = "ანოტაციის კატეგორიის რედაქტირება" +"annotation.list" = "ანოტაციების სია" +"annotation.remove" = "ანოტაციის წაშლა" +"annotation.removeCategory" = "ანოტაციის კატეგორიის წაშლა" +"app.actions" = "მოქმედებები" +"app.preferences" = "პარამეტრები" +"arrange.back" = "უკანა პლანზე გადატანა" +"arrange.backward" = "უკან გადატანა" +"arrange.forward" = "წინ გადმოტანა" +"arrange.front" = "წინა პლანზე გადმოტანა" +"arrange.packH" = "ჰორიზონტალურად შეკვრა" +"arrange.packV" = "ვერტიკალურად შეკვრა" +"arrange.roundToPixels" = "პიქსელებამდე დამრგვალება" +"arrange.spacing" = "ინტერვალი" +"arrange.tidy" = "მოწესრიგება" +"bool.exclude" = "მონიშნულის გამორიცხვა" +"bool.flatten" = "ლოგიკური ოპერაციის გაბრტყელება" +"bool.intersect" = "მონიშნულის გადაკვეთა" +"bool.subtract" = "მონიშნულის გამოკლება" +"bool.union" = "მონიშნულის გაერთიანება" +"branch.archive" = "განშტოების დაარქივება" +"branch.create" = "განშტოების შექმნა" +"branch.list" = "განშტოებების სია" +"branch.merge" = "განშტოების შერწყმა" +"branch.open" = "განშტოების გახსნა" +"branch.review" = "განშტოების განხილვა" +"branch.updateFromMain" = "მთავარიდან განახლება" +"codeConnect.export" = "კოდთან კავშირების ექსპორტირება" +"codeConnect.import" = "კოდთან კავშირების იმპორტი" +"codeConnect.remove" = "კოდთან კავშირის გაწყვეტა" +"codeConnect.set" = "კოდთან დაკავშირება" +"collab.cursor" = "კურსორის ჩვენება მდებარეობაზე" +"collab.cursorChat" = "კურსორის ჩატი" +"collab.diff" = "სინქრონიზებული ცვლილებების ჩვენება" +"collab.files" = "თქვენი ონლაინ ფაილები" +"collab.follow" = "მიყოლა" +"collab.forgetFile" = "ონლაინ ფაილის დავიწყება" +"collab.host" = "ცოცხალი სესიის დაწყება" +"collab.join" = "ცოცხალ სესიასთან შეერთება" +"collab.leave" = "ცოცხალი სესიიდან გასვლა" +"collab.manageFile" = "გამოქვეყნებული ფაილის მართვა" +"collab.openFile" = "ონლაინ ფაილის გახსნა" +"collab.peers" = "ამ ფაილში მყოფი ადამიანები" +"collab.publish" = "სერვერზე გამოქვეყნება" +"collab.recoverFile" = "ონლაინ ფაილის აღდგენა" +"collab.registerLinks" = "მოსაწვევი ბმულების FigCraft-ში გახსნა" +"collab.removePerson" = "სესიიდან მოცილება" +"collab.report" = "ფაილზე საჩივარი სერვერის ოპერატორთან" +"collab.serverInfo" = "სერვერის ინფორმაცია" +"collab.share" = "შეერთების კოდით გაზიარება" +"collab.spotlight" = "ჩემზე ყურადღების გამახვილება" +"collab.stop" = "ცოცხალი სესიის შეწყვეტა" +"command.batch" = "რამდენიმე ბრძანების გაშვება" +"comment.add" = "კომენტარის დამატება" +"comment.delete" = "კომენტარის წაშლა" +"comment.edit" = "კომენტარის რედაქტირება" +"comment.link" = "კომენტარის ბმულის კოპირება" +"comment.list" = "კომენტარების სია" +"comment.markRead" = "კომენტარის წაკითხულად მარკირება" +"comment.move" = "კომენტარის გადატანა" +"comment.next" = "შემდეგი კომენტარი" +"comment.open" = "კომენტარის გახსნა" +"comment.people" = "კომენტარებში მონაწილე ადამიანები" +"comment.react" = "კომენტარზე რეაგირება" +"comment.reply" = "კომენტარზე პასუხი" +"comment.resolve" = "კომენტარის გადაწყვეტა" +"comment.zoom" = "კომენტარების მასშტაბირება" +"component.addProperty" = "კომპონენტის თვისების დამატება" +"component.addVariant" = "ვარიანტის დამატება" +"component.bindProperty" = "შრის თვისებაზე მიბმა" +"component.combineVariants" = "ვარიანტებად გაერთიანება" +"component.create" = "კომპონენტის შექმნა" +"component.createMultiple" = "რამდენიმე კომპონენტის შექმნა" +"component.deleteProperty" = "კომპონენტის თვისების წაშლა" +"component.detach" = "ინსტანსის გამოყოფა" +"component.editProperty" = "კომპონენტის თვისების რედაქტირება" +"component.exposeInstance" = "ჩადგმული ინსტანსის გამოტანა" +"component.goToMain" = "მთავარ კომპონენტზე გადასვლა" +"component.hideWhenPublishing" = "გამოქვეყნებისას დამალვა" +"component.list" = "კომპონენტების სია" +"component.makeSlot" = "სლოტად გადაქცევა" +"component.playground" = "კომპონენტის სავარჯიშო მოედანი" +"component.properties" = "კომპონენტის თვისებები" +"component.pushChanges" = "ცვლილებების მთავარ კომპონენტში გადაგზავნა" +"component.removeSlot" = "სლოტის მოხსნა" +"component.resetInstance" = "ყველა ცვლილების ჩამოყრა" +"component.resetOverride" = "გადაფარვის ჩამოყრა" +"component.restoreMain" = "მთავარი კომპონენტის აღდგენა" +"dev.compare" = "ცვლილებების შედარება" +"dev.focus" = "ფოკუსის ხედი" +"dev.resolveChange" = "ცვლილების გადაწყვეტა" +"dev.unfocus" = "ფოკუსის ხედიდან გასვლა" +"devResource.add" = "დეველოპერის რესურსის დამატება" +"devResource.edit" = "დეველოპერის რესურსის რედაქტირება" +"devResource.list" = "დეველოპერის რესურსების სია" +"devResource.remove" = "დეველოპერის რესურსის წაშლა" +"distribute.horizontal" = "ჰორიზონტალური შუალედების განაწილება" +"distribute.vertical" = "ვერტიკალური შუალედების განაწილება" +"doc.home" = "საწყისი ეკრანი" +"doc.list" = "დოკუმენტების სია" +"doc.nextTab" = "შემდეგი ჩანართი" +"doc.previousTab" = "წინა ჩანართი" +"doc.switch" = "დოკუმენტის გადართვა" +"edit.copy" = "კოპირება" +"edit.copyAsCss" = "კოპირება როგორც CSS" +"edit.copyAsPng" = "კოპირება როგორც PNG" +"edit.copyAsSvg" = "კოპირება როგორც SVG" +"edit.copyAsText" = "კოპირება როგორც ტექსტი" +"edit.copyLink" = "მონიშნულის ბმულის კოპირება" +"edit.copyProperties" = "თვისებების კოპირება" +"edit.cut" = "ამოჭრა" +"edit.delete" = "წაშლა" +"edit.duplicate" = "დუბლირება" +"edit.findReplace" = "ძებნა და ჩანაცვლება" +"edit.paste" = "ჩასმა" +"edit.pasteDesignLayers" = "დიზაინის შრეების ჩასმა" +"edit.pasteOver" = "მონიშნულზე ჩასმა" +"edit.pasteProperties" = "თვისებების ჩასმა" +"edit.pasteReplace" = "ჩასმა ჩასანაცვლებლად" +"edit.pickColor" = "ფერის არჩევა" +"edit.redo" = "ხელახლა შესრულება" +"edit.repeat" = "ბოლო ბრძანების გამეორება" +"edit.setDefaultProperties" = "ნაგულისხმევი თვისებების დაყენება" +"edit.undo" = "დაბრუნება" +"effect.add" = "ეფექტის დამატება" +"effect.move" = "ეფექტის გადატანა" +"effect.remove" = "ეფექტის წაშლა" +"effect.set" = "ეფექტის დაყენება" +"export.add" = "ექსპორტის პარამეტრის დამატება" +"export.remove" = "ექსპორტის პარამეტრის წაშლა" +"export.update" = "ექსპორტის პარამეტრის რედაქტირება" +"file.close" = "ჩანართის დახურვა" +"file.colorProfile" = "ფერის პროფილი" +"file.duplicate" = "ფაილის დუბლირება" +"file.export" = "ექსპორტირება" +"file.exportFramesPdf" = "ჩარჩოების PDF-ად ექსპორტირება" +"file.exportLayers" = "შრეების ექსპორტირება" +"file.exportSvg" = "მონიშნულის SVG-ად ექსპორტირება" +"file.import" = "იმპორტი" +"file.new" = "ახალი დიზაინის ფაილი" +"file.newFromTemplate" = "შაბლონიდან შექმნა" +"file.open" = "გახსნა" +"file.openRecent" = "ბოლოს გახსნილი ფაილების გახსნა" +"file.placeImage" = "გამოსახულების განთავსება" +"file.recover" = "ფაილის აღდგენა" +"file.rename" = "ფაილის სახელის შეცვლა" +"file.revert" = "წინა ვერსიაზე დაბრუნება" +"file.save" = "შენახვა" +"file.saveAs" = "შენახვა როგორც" +"file.saveVersion" = "ვერსიების ისტორიაში შენახვა" +"file.versionHistory" = "ვერსიების ისტორიის ჩვენება" +"fill.add" = "შევსების დამატება" +"fill.move" = "შევსების გადატანა" +"fill.pattern" = "პატერნით შევსება" +"fill.remove" = "შევსების წაშლა" +"fill.set" = "შევსების დაყენება" +"frame.create" = "ჩარჩოს შექმნა" +"frame.presets" = "ჩარჩოს პრესეტების სია" +"frame.swapOrientation" = "ორიენტაციის შეცვლა" +"grid.add" = "განლაგების ბადის დამატება" +"grid.move" = "განლაგების ბადის გადატანა" +"grid.remove" = "განლაგების ბადის წაშლა" +"guide.add" = "დამხმარე ხაზის დამატება" +"guide.clear" = "ყველა დამხმარე ხაზის წაშლა" +"guide.move" = "დამხმარე ხაზის გადაადგილება" +"guide.remove" = "დამხმარე ხაზის მოცილება" +"guide.select" = "დამხმარე ხაზის მონიშვნა" +"help.about" = "FigCraft-ის შესახებ" +"help.gettingStarted" = "პირველი ნაბიჯები" +"help.page" = "დახმარების გვერდი" +"help.shortcuts" = "კლავიატურის მალსახმობები" +"image.adjust" = "გამოსახულების კორექცია" +"image.deletePreset" = "გამოსახულების პრესეტის წაშლა" +"image.place" = "გამოსახულების განთავსება" +"image.presets" = "გამოსახულების პრესეტები" +"image.savePreset" = "გამოსახულების პრესეტის შენახვა" +"inspect.assets" = "რესურსების ინსპექტირება" +"inspect.code" = "შრის კოდი" +"inspect.componentCode" = "კომპონენტის კოდი" +"inspect.properties" = "თვისებების ინსპექტირება" +"inspect.readyForDev" = "მზადაა დეველოპერისთვის" +"instance.create" = "ინსტანსის შექმნა" +"instance.exposed" = "გამოტანილი ჩადგმული ინსტანსები" +"instance.place" = "ინსტანსის განთავსება" +"instance.resetSlot" = "სლოტის ჩამოყრა" +"instance.setProperties" = "ინსტანსის თვისებების დაყენება" +"instance.slotInsert" = "სლოტში ჩასმა" +"instance.swap" = "ინსტანსის ჩანაცვლება" +"instance.swapAt" = "ინსტანსის ჩანაცვლება წერტილში" +"layers.collapseAll" = "შრეების ჩაკეცვა" +"layout.add" = "ავტოგანლაგების დამატება" +"layout.remove" = "ავტოგანლაგების მოხსნა" +"layout.setTrack" = "ბადის ტრეკის ზომის დაყენება" +"layout.suggest" = "ავტოგანლაგების შეთავაზება" +"libraries.open" = "ბიბლიოთეკები" +"libraries.publish" = "ბიბლიოთეკის გამოქვეყნება…" +"library.acceptUpdates" = "ბიბლიოთეკის განახლებების მიღება" +"library.add" = "ბიბლიოთეკის დამატება" +"library.aliasVariable" = "ბიბლიოთეკის ცვლადის ალიასი" +"library.applyStyle" = "ბიბლიოთეკის სტილის გამოყენება" +"library.bindVariable" = "ბიბლიოთეკის ცვლადის მიბმა" +"library.extendCollection" = "ბიბლიოთეკის კოლექციის გაფართოება" +"library.import" = "ბიბლიოთეკის კომპონენტის იმპორტი" +"library.importVariable" = "ბიბლიოთეკის ცვლადის იმპორტი" +"library.list" = "ბიბლიოთეკების სია" +"library.place" = "ბიბლიოთეკის კომპონენტის განთავსება" +"library.publish" = "ბიბლიოთეკის გამოქვეყნება" +"library.publishToServer" = "ბიბლიოთეკის სერვერზე გამოქვეყნება" +"library.remove" = "ბიბლიოთეკის წაშლა" +"library.setVariableMode" = "ბიბლიოთეკის ცვლადის რეჟიმის დაყენება" +"library.swap" = "ბიბლიოთეკის ჩანაცვლება" +"library.swapAt" = "ინსტანსის ბიბლიოთეკის კომპონენტით ჩანაცვლება" +"library.updates" = "ბიბლიოთეკის განახლებები" +"library.usage" = "ბიბლიოთეკის გამოყენება" +"measure.distances" = "მანძილების გაზომვა" +"measure.padding" = "ავტოგანლაგების შიდა დაშორების გაზომვა" +"node.create" = "შრის შექმნა" +"node.get" = "შრის მიღება" +"node.reorder" = "შრის გადალაგება" +"node.reparent" = "შრის შიგნით გადატანა" +"node.set" = "თვისებების დაყენება" +"object.batchRename" = "ჯგუფური სახელის შეცვლა" +"object.convertToFrame" = "ჩარჩოდ გარდაქმნა" +"object.convertToGroup" = "ჯგუფად გარდაქმნა" +"object.convertToSection" = "სექციად გარდაქმნა" +"object.flipH" = "ჰორიზონტალურად არეკვლა" +"object.flipV" = "ვერტიკალურად არეკვლა" +"object.frameSelection" = "მონიშნულის ჩარჩოში მოქცევა" +"object.group" = "ჯგუფის მონიშვნა" +"object.hideOthers" = "სხვა შრეების დამალვა" +"object.mask" = "ნიღბად გამოყენება" +"object.moveToPage" = "გვერდზე გადატანა" +"object.opacityKeys" = "გაუმჭვირვალობა ციფრული კლავიშებით" +"object.rasterize" = "მონიშნულის რასტრირება" +"object.releaseMask" = "ნიღბის მოხსნა" +"object.removeFill" = "შევსების წაშლა" +"object.removeFrame" = "ჩარჩოს მოხსნა" +"object.removeStroke" = "კონტურის წაშლა" +"object.rename" = "სახელის შეცვლა" +"object.resizeToFit" = "შიგთავსზე ზომის მორგება" +"object.rotate180" = "180°-ით შემობრუნება" +"object.rotateLeft" = "90°-ით მარცხნივ შემობრუნება" +"object.rotateRight" = "90°-ით მარჯვნივ შემობრუნება" +"object.setThumbnail" = "ესკიზად დაყენება" +"object.swapFillStroke" = "შევსებისა და კონტურის გაცვლა" +"object.toggleLock" = "მონიშნულის ჩაკეტვა/განბლოკვა" +"object.toggleVisible" = "მონიშნულის ჩვენება/დამალვა" +"object.ungroup" = "მონიშნულის დაჯგუფების მოხსნა" +"object.wrapInSection" = "ახალ სექციაში მოქცევა" +"page.add" = "გვერდის დამატება" +"page.delete" = "გვერდის წაშლა" +"page.duplicate" = "გვერდის დუბლირება" +"page.list" = "გვერდების სია" +"page.move" = "გვერდის გადატანა" +"page.rename" = "გვერდის სახელის შეცვლა" +"page.select" = "გვერდზე გადასვლა" +"page.setBackground" = "გვერდის ფონი" +"paint.documentColors" = "დოკუმენტის ფერები" +"plugin.close" = "პლაგინის პანელის დახურვა" +"plugin.directory.install" = "პლაგინის ინსტალაცია კატალოგიდან" +"plugin.directory.list" = "პლაგინების კატალოგის დათვალიერება" +"plugin.directory.publish" = "პლაგინის კატალოგში გამოქვეყნება" +"plugin.directory.unpublish" = "პლაგინის კატალოგიდან მოხსნა" +"plugin.event" = "პლაგინის პანელის მართვის ელემენტის გამოყენება" +"plugin.import" = "პლაგინის იმპორტი მანიფესტიდან…" +"plugin.list" = "პლაგინების სია" +"plugin.remove" = "პლაგინის წაშლა" +"plugin.run" = "პლაგინის გაშვება" +"plugin.ui" = "პლაგინის პანელი" +"plugins.manage" = "პლაგინების მართვა…" +"plugins.runLast" = "ბოლო პლაგინის გაშვება" +"pref.autoSave" = "ფაილების ავტომატურად შენახვა" +"pref.highlightHover" = "შრეების გამოკვეთა მაჩვენებლის მიტანისას" +"pref.keepTool" = "ხელსაწყოს არჩეულად დატოვება გამოყენების შემდეგ" +"pref.language" = "ინტერფეისის ენა" +"pref.nudge" = "მცირედ გადაწევის ბიჯი" +"pref.numberKeysOpacity" = "გაუმჭვირვალობისთვის ციფრული კლავიშების გამოყენება" +"pref.publishServer" = "ონლაინ გაზიარების სერვერი" +"pref.reduceMotion" = "მოძრაობის შემცირება" +"pref.renameDuplicates" = "დუბლირებული შრეების სახელის შეცვლა" +"pref.showDimensions" = "ზომების ჩვენება ობიექტებზე" +"pref.userName" = "თქვენი სახელი კომენტარებში" +"proto.action.add" = "პროტოტიპის მოქმედების დამატება" +"proto.action.move" = "პროტოტიპის მოქმედების გადალაგება" +"proto.action.remove" = "პროტოტიპის მოქმედების წაშლა" +"proto.action.set" = "პროტოტიპის მოქმედების რედაქტირება" +"proto.closePreview" = "პროტოტიპის წინასწარი ნახვის დახურვა" +"proto.conditional.addBlock" = "„სხვა შემთხვევაში, თუ“ / „სხვა შემთხვევაში“-ს დამატება" +"proto.conditional.removeBlock" = "„თუ“ / „სხვა შემთხვევაში, თუ“ / „სხვა შემთხვევაში“-ს წაშლა" +"proto.conditional.setCondition" = "პირობის რედაქტირება" +"proto.evaluate" = "პროტოტიპის ექსპრესიის გამოთვლა" +"proto.flow.add" = "ნაკადის საწყისი წერტილის დამატება" +"proto.flow.remove" = "ნაკადის წაშლა" +"proto.flow.rename" = "ნაკადის სახელის შეცვლა" +"proto.interaction.add" = "ურთიერთქმედების დამატება" +"proto.interaction.clear" = "ყველა ურთიერთქმედების წაშლა" +"proto.interaction.remove" = "ურთიერთქმედების წაშლა" +"proto.interaction.set" = "ურთიერთქმედების რედაქტირება" +"proto.present" = "წარდგენა" +"proto.presentOptions" = "წარდგენის პარამეტრები" +"proto.preview" = "პროტოტიპის წინასწარი ნახვის გახსნა" +"proto.settings" = "პროტოტიპის პარამეტრები" +"section.toggleContents" = "სექციის შიგთავსის დამალვა/ჩვენება" +"select.add" = "მონიშნულზე დამატება" +"select.all" = "ყველაფრის მონიშვნა" +"select.area" = "არის მონიშვნა" +"select.byName" = "შრეების სახელით მონიშვნა" +"select.children" = "შვილობილების მონიშვნა" +"select.inverse" = "მონიშვნის შებრუნება" +"select.layersAt" = "შრეები წერტილში" +"select.matching" = "შესაბამისი შრეების მონიშვნა" +"select.next" = "შემდეგი მეზობლის მონიშვნა" +"select.none" = "მონიშვნის მოხსნა" +"select.parent" = "მშობლის მონიშვნა" +"select.previous" = "წინა მეზობლის მონიშვნა" +"select.remove" = "მონიშნულიდან ამოღება" +"select.sameEffect" = "იგივე ეფექტის მონიშვნა" +"select.sameFill" = "იგივე შევსების მონიშვნა" +"select.sameFont" = "იგივე შრიფტის მონიშვნა" +"select.sameInstance" = "იგივე ინსტანსის მონიშვნა" +"select.sameProperties" = "იგივე თვისებების მონიშვნა" +"select.sameStroke" = "იგივე კონტურის მონიშვნა" +"select.sameText" = "ტექსტის იგივე თვისებების მონიშვნა" +"select.set" = "მონიშვნა" +"selection.applyColorStyle" = "მონიშნულის ფერის სტილის გამოყენება" +"selection.colors" = "მონიშნულის ფერები" +"selection.selectColor" = "ამ ფერის შრეების მონიშვნა" +"selection.setPaint" = "მონიშნულის ფერის შეცვლა" +"shape.arrow" = "ისარი" +"shape.ellipse" = "ელიფსი" +"shape.line" = "ხაზი" +"shape.polygon" = "მრავალკუთხედი" +"shape.rectangle" = "მართკუთხედი" +"shape.star" = "ვარსკვლავი" +"shapeBuilder.merge" = "არეების შერწყმა" +"shapeBuilder.mode" = "ფიგურების კონსტრუქტორის რეჟიმი" +"shapeBuilder.regions" = "ფიგურების კონსტრუქტორის არეები" +"shapeBuilder.remove" = "არეების წაშლა" +"stroke.add" = "კონტურის დამატება" +"stroke.brush" = "ფუნჯი" +"stroke.move" = "კონტურის გადატანა" +"stroke.remove" = "კონტურის წაშლა" +"stroke.set" = "კონტურის დაყენება" +"stroke.widthProfile" = "სიგანის პროფილი" +"style.apply" = "სტილის გამოყენება" +"style.bind" = "ცვლადის სტილში მიბმა" +"style.create" = "სტილის შექმნა" +"style.delete" = "სტილის წაშლა" +"style.detach" = "სტილის გამოყოფა" +"style.duplicate" = "სტილის დუბლირება" +"style.list" = "სტილების სია" +"style.move" = "სტილების გადატანა" +"style.renameFolder" = "სტილების საქაღალდის სახელის შეცვლა" +"style.update" = "სტილის რედაქტირება" +"template.list" = "შაბლონების სია" +"text.alignCenter" = "ცენტრში სწორება" +"text.alignJustify" = "ორმხრივი სწორება" +"text.alignLeft" = "მარცხნივ სწორება" +"text.alignRight" = "მარჯვნივ სწორება" +"text.bold" = "მუქი" +"text.bulletList" = "ბურთულებიანი სია" +"text.case" = "ასოთა რეგისტრი" +"text.caseLower" = "პატარა ასოები" +"text.caseOriginal" = "საწყისი რეგისტრი" +"text.caseSmallCaps" = "პატარა მთავრული" +"text.caseTitle" = "სათაურის რეგისტრი" +"text.caseUpper" = "მთავრული" +"text.create" = "ტექსტი" +"text.decoration" = "გაფორმება" +"text.edit.begin" = "ტექსტის რედაქტირება" +"text.edit.caret" = "კურსორის დაყენება" +"text.edit.end" = "ტექსტის რედაქტირების დასრულება" +"text.edit.insert" = "ტექსტის აკრეფა" +"text.edit.key" = "ტექსტის კლავიში" +"text.edit.preedit" = "ტექსტის აწყობა (IME)" +"text.edit.selectLine" = "სტრიქონის მონიშვნა" +"text.edit.selectWord" = "სიტყვის მონიშვნა" +"text.edit.selection" = "ტექსტის მონიშვნა" +"text.endPreview" = "ტექსტის გადახედვის დასრულება" +"text.fonts" = "შრიფტების სია" +"text.italic" = "დახრილი" +"text.lineHeightDown" = "სტრიქონის სიმაღლის შემცირება" +"text.lineHeightUp" = "სტრიქონის სიმაღლის გაზრდა" +"text.link" = "ბმულის შექმნა" +"text.listIndent" = "სიის შეწევის გაზრდა" +"text.listOutdent" = "სიის შეწევის შემცირება" +"text.listStyle" = "სიის სტილი" +"text.maxLines" = "მაქს. სტრიქონები" +"text.missingFonts" = "მიუწვდომელი შრიფტები" +"text.numberList" = "დანომრილი სია" +"text.onPath" = "ტექსტი ტრაექტორიაზე" +"text.paragraphIndent" = "აბზაცის შეწევა" +"text.paragraphSpacing" = "აბზაცთა ინტერვალი" +"text.pathOptions" = "ტრაექტორიაზე ტექსტის პარამეტრები" +"text.preview" = "ტექსტის ცვლილების გადახედვა" +"text.removeFromPath" = "ტექსტის ტრაექტორიიდან მოხსნა" +"text.set" = "ტექსტის დაყენება" +"text.setFeatures" = "OpenType შესაძლებლობები" +"text.setVariations" = "ცვლადი შრიფტის ღერძები" +"text.sizeDown" = "შრიფტის ზომის შემცირება" +"text.sizeUp" = "შრიფტის ზომის გაზრდა" +"text.strikethrough" = "გადახაზული" +"text.style" = "ტექსტის დიაპაზონის სტილის მინიჭება" +"text.trackingDown" = "ასოებს შორის დაშორების შემცირება" +"text.trackingUp" = "ასოებს შორის დაშორების გაზრდა" +"text.underline" = "ხაზგასმული" +"text.weightDown" = "შრიფტის სისქის შემცირება" +"text.weightUp" = "შრიფტის სისქის გაზრდა" +"tool.arrow" = "ისარი" +"tool.comment" = "კომენტარი" +"tool.ellipse" = "ელიფსი" +"tool.frame" = "ჩარჩო" +"tool.hand" = "ხელი" +"tool.line" = "ხაზი" +"tool.move" = "გადაადგილება" +"tool.pen" = "კალამი" +"tool.pencil" = "ფანქარი" +"tool.pencilBrush" = "ფანქრის ფუნჯი" +"tool.place" = "ხელსაწყოთი განთავსება" +"tool.polygon" = "მრავალკუთხედი" +"tool.rectangle" = "მართკუთხედი" +"tool.scale" = "მასშტაბირება" +"tool.scaleAnchor" = "მასშტაბირების საყრდენი წერტილი" +"tool.section" = "სექცია" +"tool.set" = "ხელსაწყოს არჩევა" +"tool.shapeBuilder" = "ფიგურების კონსტრუქტორი" +"tool.slice" = "ნაჭერი" +"tool.star" = "ვარსკვლავი" +"tool.text" = "ტექსტი" +"transform.move" = "გადაადგილება" +"transform.moveTo" = "გადატანა" +"transform.nudge" = "მცირედ გადაწევა" +"transform.nudgeSize" = "ზომის შეცვლა მცირედ გადაწევით" +"transform.resize" = "ზომის შეცვლა" +"transform.rotate" = "შემობრუნება" +"transform.rotationOrigin" = "შემობრუნების ცენტრის გადატანა" +"transform.scale" = "მასშტაბირება" +"transform.setRotationOrigin" = "შემობრუნების ცენტრის დაყენება" +"variable.addMode" = "რეჟიმის დამატება" +"variable.bind" = "ცვლადის მიბმა" +"variable.create" = "ცვლადის შექმნა" +"variable.createCollection" = "ცვლადების კოლექციის შექმნა" +"variable.delete" = "ცვლადის წაშლა" +"variable.deleteCollection" = "კოლექციის წაშლა" +"variable.duplicate" = "ცვლადის დუბლირება" +"variable.export" = "ცვლადების ექსპორტირება" +"variable.import" = "ცვლადების იმპორტი" +"variable.list" = "ცვლადების სია" +"variable.moveMode" = "რეჟიმის გადატანა" +"variable.removeMode" = "რეჟიმის წაშლა" +"variable.renameCollection" = "კოლექციის სახელის შეცვლა" +"variable.renameMode" = "რეჟიმის სახელის შეცვლა" +"variable.resetOverride" = "ცვლადის გადაფარვის ჩამოყრა" +"variable.set" = "ცვლადის მნიშვნელობის დაყენება" +"variable.setMode" = "ცვლადის რეჟიმის დაყენება" +"variable.update" = "ცვლადის რედაქტირება" +"vector.create" = "ვექტორი" +"vector.deleteHeal" = "მონიშნულის წაშლა ხარვეზის შევსებით" +"vector.edit.addVertex" = "წერტილის დამატება" +"vector.edit.begin" = "ვექტორის რედაქტირება" +"vector.edit.bend" = "სეგმენტის მოხრა" +"vector.edit.bounds" = "მონიშნული წერტილების საზღვრები" +"vector.edit.connect" = "წერტილების შეერთება" +"vector.edit.cornerRadius" = "წერტილის კუთხის რადიუსი" +"vector.edit.cut" = "ტრაექტორიის გაჭრა" +"vector.edit.delete" = "წერტილების წაშლა" +"vector.edit.end" = "რედაქტირების დასრულება" +"vector.edit.erase" = "წაშლა" +"vector.edit.lasso" = "ლასოთი მონიშვნა" +"vector.edit.mirroring" = "სახელურების არეკვლა" +"vector.edit.moveHandles" = "სახელურების გადატანა" +"vector.edit.moveVertices" = "წერტილების გადატანა" +"vector.edit.nudge" = "წერტილების მცირედ გადაწევა" +"vector.edit.paint" = "საღებავის ვედრო" +"vector.edit.pen" = "კალმის წერტილი" +"vector.edit.penEnd" = "კალმის ტრაექტორიის დასრულება" +"vector.edit.position" = "წერტილის პოზიცია" +"vector.edit.resize" = "წერტილების ზომის შეცვლა" +"vector.edit.rotate" = "წერტილების შემობრუნება" +"vector.edit.select" = "წერტილების მონიშვნა" +"vector.edit.selectAll" = "ყველა წერტილის მონიშვნა" +"vector.edit.setHandle" = "სახელურის დაყენება" +"vector.edit.smooth" = "წერტილების მოგლუვება ან გამახვილება" +"vector.edit.state" = "ვექტორის რედაქტირების მდგომარეობა" +"vector.edit.strokeCap" = "კონტურის დაბოლოება წერტილში" +"vector.edit.strokeJoin" = "კონტურის შეერთება წერტილში" +"vector.edit.tool" = "ვექტორის რედაქტირების ხელსაწყო" +"vector.edit.transform" = "წერტილების გარდაქმნა" +"vector.edit.widthPoint" = "სიგანის წერტილი" +"vector.flatten" = "გაბრტყელება" +"vector.join" = "მონიშნულის შეერთება" +"vector.offset" = "ტრაექტორიის წანაცვლება" +"vector.outlineStroke" = "კონტურის ფიგურად გარდაქმნა" +"vector.reverse" = "ტრაექტორიის მიმართულების შებრუნება" +"vector.simplify" = "ვექტორის გამარტივება" +"vector.smoothJoin" = "მონიშნულის გლუვი შეერთება" +"version.autosave" = "ვერსიის ავტოშენახვა" +"version.delete" = "ვერსიის წაშლა" +"version.duplicate" = "ვერსიის დუბლირება" +"version.exitPreview" = "მიმდინარე ვერსიაზე დაბრუნება" +"version.list" = "ვერსიების სია" +"version.preview" = "ვერსიის ნახვა" +"version.rename" = "ვერსიის სახელის შეცვლა" +"version.restore" = "ვერსიის აღდგენა" +"view.assets" = "რესურსების პანელი" +"view.comments" = "კომენტარები" +"view.contextMenu" = "კონტექსტური მენიუს ჩვენება" +"view.design" = "დიზაინის პანელი" +"view.designPrototype" = "გადართვა დიზაინსა და პროტოტიპს შორის" +"view.devMode" = "დეველოპერის რეჟიმი" +"view.frameOutlines" = "ჩარჩოების კონტურები" +"view.fullscreen" = "სრული ეკრანი" +"view.get" = "ხედის მიღება" +"view.layers" = "შრეების პანელი" +"view.layoutGrids" = "განლაგების ბადეები" +"view.leftSidebar" = "მარცხენა გვერდითი ზოლის ჩვენება/დამალვა" +"view.maskOutlines" = "ნიღბების კონტურები" +"view.multiplayerCursors" = "სხვა მონაწილეების კურსორები" +"view.nextFrame" = "შემდეგი ჩარჩოს მასშტაბირება" +"view.nextPage" = "შემდეგი გვერდი" +"view.nextRegion" = "შემდეგ არეზე ფოკუსირება" +"view.outlines" = "კონტურები" +"view.outlinesHiddenLayers" = "კონტურებში დამალული შრეების ჩართვა" +"view.outlinesObjectBounds" = "კონტურებში ობიექტების საზღვრების ჩართვა" +"view.outlinesSelection" = "მონიშნულის კონტურები" +"view.pan" = "ხედის გადაადგილება" +"view.panelWidths" = "პანელების სიგანის ჩამოყრა" +"view.pixelGrid" = "პიქსელური ბადე" +"view.pixelPreview" = "პიქსელების წინასწარი ნახვა" +"view.prevFrame" = "წინა ჩარჩოს მასშტაბირება" +"view.prevPage" = "წინა გვერდი" +"view.previousRegion" = "წინა არეზე ფოკუსირება" +"view.proofSrgb" = "ეკრანზე sRGB-ის სიმულაცია" +"view.prototype" = "პროტოტიპის პანელი" +"view.rulers" = "სახაზავები" +"view.set" = "ხედის დაყენება" +"view.slices" = "ნაჭრები" +"view.snapGeometry" = "გეომეტრიაზე მიბმა" +"view.snapObjects" = "ობიექტებზე მიბმა" +"view.snapPixel" = "პიქსელურ ბადეზე მიბმა" +"view.themeDark" = "ბნელი თემა" +"view.themeLight" = "ღია თემა" +"view.themeSystem" = "სისტემის თემის მიყოლა" +"view.toggleUi" = "ინტერფეისის ჩვენება/დამალვა" +"view.zoom100" = "მასშტაბი 100%" +"view.zoom200" = "მასშტაბი 200%" +"view.zoom50" = "მასშტაბი 50%" +"view.zoomAt" = "მასშტაბის ხელსაწყო" +"view.zoomFit" = "ეკრანში ჩატევა" +"view.zoomIn" = "გადიდება" +"view.zoomOut" = "დაპატარავება" +"view.zoomSelection" = "მონიშნულის მასშტაბირება" +"widget.click" = "ვიჯეტზე დაწკაპუნება" +"widget.insert" = "ვიჯეტის ჩასმა" +"widget.list" = "ვიჯეტების სია" +"window.focus" = "ფანჯრის წინა პლანზე გადმოტანა" +"window.new" = "ახალი ფანჯარა" + +[menu] +"bool.exclude" = "გამორიცხვა" +"bool.flatten" = "გაბრტყელება" +"bool.intersect" = "გადაკვეთა" +"bool.subtract" = "გამოკლება" +"bool.union" = "გაერთიანება" +"file.duplicate" = "დუბლირება" +"file.import" = "ფაილიდან იმპორტი…" +"file.rename" = "სახელის შეცვლა" +"libraries.open" = "ბიბლიოთეკების გახსნა" +"select.sameEffect" = "ეფექტი" +"select.sameFill" = "შევსება" +"select.sameFont" = "შრიფტი" +"select.sameInstance" = "ინსტანსი" +"select.sameProperties" = "თვისებები" +"select.sameStroke" = "კონტური" +"select.sameText" = "ტექსტის თვისებები" +"text.alignCenter" = "ტექსტის ცენტრში სწორება" +"text.alignJustify" = "ტექსტის ორმხრივი სწორება" +"text.alignLeft" = "ტექსტის მარცხნივ სწორება" +"text.alignRight" = "ტექსტის მარჯვნივ სწორება" +"view.assets" = "რესურსები" +"view.design" = "დიზაინი" +"view.layers" = "შრეები" +"view.outlines" = "კონტურების ჩვენება" +"view.outlinesHiddenLayers" = "დამალული შრეების ჩართვა" +"view.outlinesObjectBounds" = "ობიექტების საზღვრების ჩართვა" +"view.outlinesSelection" = "მონიშნულის კონტურების ჩვენება" +"view.prototype" = "პროტოტიპი" +"view.themeDark" = "ბნელი" +"view.themeLight" = "ღია" +"view.themeSystem" = "სისტემის მიყოლა" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 3e1916233..b33b36b95 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -305,6 +305,7 @@ status = "draft — needs native review" "Corner radius" = "모서리 반경" "Corner smoothing" = "모서리 스무딩" "Couldn't reach {server}: showing what this computer remembers." = "{server}에 연결할 수 없음: 이 컴퓨터가 기억하는 내용을 표시함." +"Couldn't read {name}." = "{name}을(를) 읽을 수 없습니다." "Couldn't save automatically: {error}" = "자동으로 저장할 수 없음: {error}" "Count" = "개수" "Create" = "만들기" @@ -892,6 +893,7 @@ status = "draft — needs native review" "PDF print settings" = "PDF 인쇄 설정" "Padding" = "패딩" "Page" = "페이지" +"Page deleted" = "페이지가 삭제됨" "Pages" = "페이지" "Pages added to the document" = "문서에 페이지 추가됨" "Placed on the current page" = "현재 페이지에 배치됨" @@ -1218,6 +1220,7 @@ status = "draft — needs native review" "Start ascending from / stop descending at" = "오름차순 시작 값 / 내림차순 끝 값" "Start from a blank canvas" = "빈 캔버스로 시작" "Start on path" = "패스 시작 위치" +"Start on the Home screen" = "홈에서 시작" "Start point" = "시작점" "Start sharing" = "공유 시작" "Starting to share…" = "공유를 시작하는 중…" @@ -1574,6 +1577,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "{name}의 기록을 이 컴퓨터에서 지움. 파일은 {server}에 남음" "{name} is on the server." = "{name}이(가) 서버에 있음." "{name} is spotlighting" = "{name} 님이 스포트라이트 중" +"{name} is too large to open in the browser (over 512 MB)." = "{name}은(는) 너무 커서 브라우저에서 열 수 없습니다(512MB 초과)." "{name} was removed from the server" = "{name}을(를) 서버에서 제거함" "{name} · deleted on main" = "{name} · 메인에서 삭제됨" "{name} · deleted on the branch" = "{name} · 브랜치에서 삭제됨" @@ -1786,6 +1790,7 @@ status = "draft — needs native review" "file.exportLayers" = "레이어 내보내기" "file.exportSvg" = "선택 항목을 SVG로 내보내기" "file.import" = "가져오기" +"file.moveTab" = "탭 이동" "file.new" = "새 디자인 파일" "file.open" = "열기" "file.openRecent" = "최근 파일 열기" @@ -1923,6 +1928,7 @@ status = "draft — needs native review" "pref.reduceMotion" = "동작 줄이기" "pref.renameDuplicates" = "복제한 레이어 이름 바꾸기" "pref.showDimensions" = "개체에 치수 표시" +"pref.startOnHome" = "홈에서 시작" "pref.userName" = "댓글에 표시할 이름" "proto.action.add" = "프로토타입 액션 추가" "proto.action.move" = "프로토타입 액션 순서 변경" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml new file mode 100644 index 000000000..54851043f --- /dev/null +++ b/crates/ui-egui/locales/nl.toml @@ -0,0 +1,2348 @@ +# FigCraft interface strings: Dutch (Nederlands). +# +# Draft — needs native review: machine-drafted from the English catalog. Established tool terms +# (frame, auto layout, component, variant, prototype, overlay, widget, plugin, branch, constraints) +# and blend-mode, brush-preset and boolean names stay as they are. See docs/translating.md. + +[meta] +name = "Nederlands" +status = "draft — needs native review" + +[ui] + +"% of all commits" = "% van alle commits" +"(no longer published)" = "(niet langer gepubliceerd)" +"({n} warnings: some entries were skipped)" = { one = "({n} waarschuwing: sommige items zijn overgeslagen)", other = "({n} waarschuwingen: sommige items zijn overgeslagen)" } +"+ Create variable" = "+ Variabele maken" +".fig (experimental)" = ".fig (experimental)" +"1…9, 0" = "1…9, 0" +"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} regels (Δ {delta}), +{bin_added} / −{bin_deleted} binaire assets, {first} – {last}" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Er bestaat al een .figcraft-bestand met deze naam. Kies de volledige naam om de vervanging te bevestigen." +"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Een verzameling van dit bestand met deze modi en variabelen: overschrijf hun waarden per modus en voeg er eigen variabelen toe" +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Een losse laag wordt uitgelijnd op zijn frame: selecteer meer lagen, of één in een frame" +"A responsive product page: hero, features, figures, a quote and a footer" = "Een responsive productpagina: hero, features, afbeeldingen, een citaat en een footer" +"A spring plays until it settles: its mass, stiffness and damping set how long" = "Een veer speelt tot hij tot rust komt: zijn massa, stijfheid en demping bepalen hoe lang" +"A title, an agenda, a big figure, a chart and a closing slide" = "Een titel, een agenda, een grote afbeelding, een grafiek en een afsluitende dia" +"A to Z" = "A tot Z" +"About" = "Over" +"About FigCraft" = "Over FigCraft" +"About this server" = "Over deze server" +"Action" = "Actie" +"Actions" = "Acties" +"Actions for {name}" = "Acties voor {name}" +"Actions…" = "Acties…" +"Active" = "Actief" +"Actual size (100%)" = "Werkelijke grootte (100%)" +"Add a category" = "Categorie toevoegen" +"Add a comment" = "Reactie toevoegen" +"Add a description" = "Beschrijving toevoegen" +"Add a frame (F) or a shape (R) to get started" = "Voeg een frame (F) of een vorm (R) toe om te beginnen" +"Add a layer or component to tile first" = "Voeg eerst een laag of component toe om als tegel te gebruiken" +"Add a note for developers" = "Notitie voor ontwikkelaars toevoegen" +"Add action" = "Actie toevoegen" +"Add actions for when no condition holds" = "Voeg acties toe voor het geval geen enkele voorwaarde geldt" +"Add an annotation for developers" = "Annotatie voor developers toevoegen" +"Add another condition" = "Nog een voorwaarde toevoegen" +"Add auto layout" = "Auto layout toevoegen" +"Add background behind overlay" = "Achtergrond achter overlay toevoegen" +"Add content to this slot" = "Voeg inhoud toe aan dit slot" +"Add dev resource" = "Dev-resource toevoegen" +"Add effect" = "Effect toevoegen" +"Add export setting" = "Exportinstelling toevoegen" +"Add fill" = "Vulling toevoegen" +"Add interaction" = "Interactie toevoegen" +"Add layout grid" = "Layoutraster toevoegen" +"Add library" = "Bibliotheek toevoegen" +"Add library file…" = "Bibliotheekbestand toevoegen…" +"Add max height" = "Maximale hoogte toevoegen" +"Add max width" = "Maximale breedte toevoegen" +"Add min height" = "Minimale hoogte toevoegen" +"Add min width" = "Minimale breedte toevoegen" +"Add mode" = "Modus toevoegen" +"Add new page" = "Nieuwe pagina toevoegen" +"Add or update collections from a variables JSON" = "Voeg verzamelingen toe of werk ze bij vanuit een variables-JSON" +"Add properties to make instances configurable" = "Voeg eigenschappen toe om instances configureerbaar te maken" +"Add reaction" = "Reactie toevoegen" +"Add starting point" = "Startpunt toevoegen" +"Add stroke" = "Lijn toevoegen" +"Add variant" = "Variant toevoegen" +"Add variant with a new value" = "Variant met een nieuwe waarde toevoegen" +"Added" = "Toegevoegd" +"Added the library \"{name}\"" = "Bibliotheek \"{name}\" toegevoegd" +"Adding to {name}" = "Toevoegen aan {name}" +"Adjust" = "Aanpassen" +"Adjustment presets" = "Aanpassingspresets" +"Advanced" = "Geavanceerd" +"Advanced stroke" = "Geavanceerde lijn" +"After delay" = "Na vertraging" +"After delay {ms}ms" = "Na {ms}ms vertraging" +"Align" = "Uitlijnen" +"Align bottom" = "Onderaan uitlijnen" +"Align center" = "Midden uitlijnen" +"Align horizontal centers" = "Horizontale middens uitlijnen" +"Align left" = "Links uitlijnen" +"Align middle" = "Midden uitlijnen" +"Align right" = "Rechts uitlijnen" +"Align text baselines" = "Tekstbasislijnen uitlijnen" +"Align top" = "Bovenaan uitlijnen" +"Align vertical centers" = "Verticale middens uitlijnen" +"Alignment" = "Uitlijning" +"All" = "Alle" +"All categories" = "Alle categorieën" +"All comments" = "Alle reacties" +"All components" = "Alle components" +"All fonts" = "Alle lettertypen" +"All supported properties" = "Alle ondersteunde eigenschappen" +"All the fonts this file uses are installed" = "Alle lettertypen die dit bestand gebruikt zijn geïnstalleerd" +"All variables" = "Alle variabelen" +"Alpha" = "Alpha" +"Alphabetical" = "Alfabetisch" +"Alt+drag" = "Alt+drag" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Een analysescherm op een raster: cijfers, diagrammen getekend met vectoren en een tabel" +"An invite link that opens FigCraft and joins" = "Een uitnodigingslink die FigCraft opent en waarmee je deelneemt" +"Anchor" = "Anker" +"Android" = "Android" +"Angular" = "Angular" +"Animation" = "Animatie" +"Annotations" = "Annotaties" +"Annotations shown on the canvas" = "Annotaties die op het canvas worden weergegeven" +"Another server, or one that needs its key. The usual server is set in Preferences." = "Een andere server, of een server waarvoor je een sleutel nodig hebt. De gebruikelijke server stel je in bij Voorkeuren." +"Any status" = "Alle statussen" +"Anyone on your network with this code can edit the file, while this app stays open." = "Iedereen in je netwerk met deze code kan het bestand bewerken, zolang deze app open blijft." +"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." = "Iedereen met de bewerklink kan het bestand wijzigen; de weergavelink laat mensen alleen kijken. De links bevatten geheimen: deel ze alsof het wachtwoorden zijn. Met `figcraft-server links` maak je er meer en trek je ze in." +"Anyone with this code can edit the file." = "Iedereen met deze code kan het bestand bewerken." +"Anyone with this code can look at the file, not change it." = "Iedereen met deze code kan het bestand bekijken, maar niet wijzigen." +"Appearance" = "Weergave" +"Apply a component property" = "Een componenteigenschap toepassen" +"Apply variable" = "Variabele toepassen" +"Apply variable to text" = "Variabele toepassen op tekst" +"Apply variable to visibility" = "Variabele toepassen op zichtbaarheid" +"Apps" = "Apps" +"Apr" = "apr." +"Arc" = "Boog" +"Arc ratio" = "Boogverhouding" +"Arc start" = "Start van de boog" +"Arc sweep" = "Boogdoorloop" +"Archive" = "Archiveren" +"Archived" = "Gearchiveerd" +"Arrange" = "Schikken" +"Arrow" = "Pijl" +"As typed" = "Zoals getypt" +"Asking the server…" = "Bezig met vragen aan de server…" +"Assets" = "Assets" +"Aug" = "aug." +"Auto" = "Auto" +"Auto (space around)" = "Auto (ruimte eromheen)" +"Auto (space between)" = "Auto (afstand ertussen)" +"Auto (space evenly)" = "Auto (gelijkmatig verdelen)" +"Auto ({mode})" = "Auto ({mode})" +"Auto columns" = "Automatische kolommen" +"Auto gap" = "Automatische afstand" +"Auto height" = "Automatische hoogte" +"Auto layout" = "Auto layout" +"Auto rows" = "Automatische rijen" +"Auto width" = "Automatische breedte" +"Back" = "Terug" +"Back to Ready for dev" = "Terug naar Ready for dev" +"Back to Ready for dev: the next change compares with how it is now" = "Terug naar Gereed voor dev: de volgende wijziging wordt vergeleken met hoe het nu is" +"Back to assets" = "Terug naar assets" +"Back to current" = "Terug naar huidige" +"Back to styles" = "Terug naar stijlen" +"Back to the FigCraft server" = "Terug naar de FigCraft-server" +"Back to the canvas from a focused region" = "Terug naar het canvas vanuit een focusgebied" +"Back to the current version" = "Terug naar de huidige versie" +"Back to the font's value" = "Terug naar de waarde van het lettertype" +"Back to the values the font style implies" = "Terug naar de waarden die bij de lettertypestijl horen" +"Background" = "Achtergrond" +"Background blur" = "Achtergrondvervaging" +"Basics" = "Basis" +"Before" = "Voor" +"Bend" = "Buiging" +"Bevel" = "Afgeschuind" +"Binary assets added" = "Binaire assets toegevoegd" +"Binary assets removed" = "Verwijderde binaire assets" +"Black & white" = "Black & white" +"Bleed" = "Afloop" +"Bleed marks" = "Aansnijmarkeringen" +"Bleed marks need a bleed" = "Aansnijtekens vereisen een aansnijrand" +"Blend" = "Mengmodus" +"Blend mode" = "Blendmodus" +"Blue" = "Blauw" +"Blur" = "Vervaging" +"Boolean" = "Boolean" +"Boolean groups" = "Booleaanse groepen" +"Both directions" = "Beide richtingen" +"Bottom" = "Onder" +"Bottom left radius" = "Radius linksonder" +"Bottom right radius" = "Radius rechtsonder" +"Bound to {name}. Click to detach the variable." = "Gekoppeld aan {name}. Klik om de variabele los te koppelen." +"Branch" = "Branch" +"Branch archived" = "Branch gearchiveerd" +"Branch merged" = "Branch samengevoegd" +"Branch name" = "Branchnaam" +"Branch of {file}" = "Branch van {file}" +"Branch of {file} · merged or archived" = "Branch van {file} · samengevoegd of gearchiveerd" +"Branch updated from main" = "Branch bijgewerkt vanaf main" +"Branch {name}" = "Branch {name}" +"Branches" = "Branches" +"Bring forward" = "Naar voren brengen" +"Bring the main file's changes into the branch" = "De wijzigingen uit het hoofdbestand in de branch brengen" +"Bring to front" = "Op de voorgrond plaatsen" +"Bristle" = "Bristle" +"Browse" = "Bladeren" +"Brush" = "Penseel" +"Brush rotation jitter" = "Penseelrotatiejitter" +"Brush scatter" = "Penseelverstrooiing" +"Brush size jitter" = "Penseelgrootte-jitter" +"Brush spacing" = "Penseelafstand" +"Bulleted list" = "Lijst met opsommingstekens" +"C or Shift+C" = "C of Shift+C" +"C or ⇧C" = "C of ⇧C" +"CMYK colour" = "CMYK-kleur" +"CSS custom properties…" = "CSS-variabelen…" +"Can edit" = "Kan bewerken" +"Can view" = "Kan bekijken" +"Can't read this library: {e}" = "Kan deze bibliotheek niet lezen: {e}" +"Cancel" = "Annuleren" +"Canvas" = "Canvas" +"Canvas stacking" = "Stapeling op canvas" +"Cap height to baseline" = "Hoofdletterhoogte tot basislijn" +"Case" = "Hoofdlettergebruik" +"Case-sensitive forms" = "Hoofdlettergevoelige vormen" +"Categories" = "Categorieën" +"Center" = "Midden" +"Change alias" = "Alias wijzigen" +"Change resolved" = "Wijziging opgelost" +"Change style" = "Stijl wijzigen" +"Change to" = "Wijzigen naar" +"Change to needs a layer inside a variant" = "Wijzigen naar vereist een laag binnen een variant" +"Changed" = "Gewijzigd" +"Changed on both sides: keep one" = "Aan beide kanten gewijzigd: één behouden" +"Changes on main" = "Wijzigingen op main" +"Changes on the branch" = "Wijzigingen op de branch" +"Changes unknown" = "Onbekende wijzigingen" +"Checking the fonts installed on this computer…" = "De lettertypen op deze computer worden gecontroleerd…" +"Checking the server…" = "Server controleren…" +"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Kies hierboven React, Leptos, SwiftUI of Compose om dit component aan zijn code te koppelen." +"Choose another profile to change it." = "Kies een ander profiel om dit te wijzigen." +"Choose collection" = "Kies een verzameling" +"Choose image" = "Afbeelding kiezen" +"Choose mode" = "Modus kiezen" +"Choose variable" = "Variabele kiezen" +"Circle" = "Cirkel" +"Clear from this list" = "Verwijderen uit deze lijst" +"Clear recent" = "Recenten wissen" +"Click + to replace mixed content" = "Klik op + om gemengde inhoud te vervangen" +"Click anywhere on the canvas to leave one." = "Klik ergens op het canvas om er een achter te laten." +"Click or drag to merge · {alt} removes" = "Klik of sleep om samen te voegen · {alt} verwijdert" +"Click or drag to remove · {alt} merges" = "Klik of sleep om te verwijderen · {alt} voegt samen" +"Click to record a key" = "Klik om een toets op te nemen" +"Clip content" = "Inhoud bijsnijden" +"Clip to shape" = "Bijsnijden op vorm" +"Close" = "Sluiten" +"Close overlay" = "Overlay sluiten" +"Close preview" = "Voorvertoning sluiten" +"Close tab" = "Tabblad sluiten" +"Close version history" = "Versiegeschiedenis sluiten" +"Close when clicking outside" = "Sluiten bij klikken erbuiten" +"Close without saving" = "Sluiten zonder opslaan" +"Code" = "Code" +"Code or invite link" = "Code of uitnodigingslink" +"Code syntax" = "Code-syntaxis" +"Collapse" = "Inklappen" +"Collapse panel" = "Paneel samenvouwen" +"Collection" = "Verzameling" +"Collections" = "Verzamelingen" +"Color" = "Kleur" +"Color burn" = "Color burn" +"Color dodge" = "Color dodge" +"Color format" = "Kleurnotatie" +"Color profile" = "Kleurprofiel" +"Color profile: {name}" = "Kleurprofiel: {name}" +"Color styles" = "Kleurstijlen" +"Color values in this file are in this color space." = "Kleurwaarden in dit bestand gebruiken deze kleurruimte." +"Color variables" = "Kleurvariabelen" +"Colour" = "Kleur" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Kleur- en tekststijlen, lichte en donkere variabelen, knoppen, invoervelden en kaarten" +"Column" = "Kolom" +"Column gap" = "Kolomafstand" +"Column sizes" = "Kolomgroottes" +"Column span" = "Kolomspan" +"Columns" = "Kolommen" +"Columns {count}" = "Kolommen {count}" +"Comment" = "Reactie" +"Comments" = "Reacties" +"Commits" = "Commits" +"Company" = "Bedrijf" +"Compare changes" = "Wijzigingen vergelijken" +"Compatibility notes" = "Compatibiliteitsopmerkingen" +"Completed" = "Voltooid" +"Component" = "Component" +"Component name" = "Componentnaam" +"Component property" = "Componenteigenschap" +"Component set" = "Componentenset" +"Component sets" = "Componentensets" +"Components" = "Components" +"Conditional" = "Voorwaardelijk" +"Confetti" = "Confetti" +"Conflicts" = "Conflicten" +"Connect to code" = "Verbinden met code" +"Connected" = "Verbonden" +"Connected to code: {from}" = "Verbonden met code: {from}" +"Connection lost, reconnecting (attempt {attempt})…" = "Verbinding verbroken, opnieuw verbinden (poging {attempt})…" +"Constrain proportions" = "Verhoudingen vergrendelen" +"Constraints" = "Constraints" +"Content" = "Inhoud" +"Contents hidden" = "Inhoud verborgen" +"Contents only" = "Alleen de inhoud" +"Contextual alternates" = "Contextuele alternatieven" +"Contrast" = "Contrast" +"Contributors" = "Bijdragers" +"Contributors: {people} · Commits: {commits}" = "Bijdragers: {people} · Commits: {commits}" +"Controller" = "Controller" +"Convert to frame" = "Converteren naar frame" +"Convert to section" = "Converteren naar sectie" +"Cool" = "Cool" +"Copied as CSS" = "Gekopieerd als CSS" +"Copied as PNG" = "Gekopieerd als PNG" +"Copied as SVG" = "Gekopieerd als SVG" +"Copied as text" = "Gekopieerd als tekst" +"Copied {value}" = "{value} gekopieerd" +"Copy" = "Kopiëren" +"Copy as" = "Kopiëren als" +"Copy as CSS" = "Kopiëren als CSS" +"Copy as PNG" = "Kopiëren als PNG" +"Copy as SVG" = "Kopiëren als SVG" +"Copy code" = "Code kopiëren" +"Copy link" = "Link kopiëren" +"Copy the {label}" = "Kopieer de {label}" +"Copy {value}" = "{value} kopiëren" +"Corner radius" = "Hoekradius" +"Corner smoothing" = "Hoekafvlakking" +"Couldn't download the variables" = "De variabelen konden niet worden gedownload" +"Couldn't reach {server}: showing what this computer remembers." = "Kon {server} niet bereiken: toont wat deze computer onthoudt." +"Couldn't read {name}." = "Kon {name} niet lezen." +"Couldn't save automatically: {error}" = "Kan niet automatisch opslaan: {error}" +"Count" = "Aantal" +"Create" = "Maken" +"Create a variable collection first" = "Maak eerst een variabeleverzameling" +"Create a variable first" = "Maak eerst een variabele" +"Create alias" = "Alias maken" +"Create branch" = "Branch aanmaken" +"Create collection" = "Verzameling maken" +"Create component" = "Component maken" +"Create component property" = "Componenteigenschap maken" +"Create components, styles or variables to publish them as a library." = "Maak componenten, stijlen of variabelen om ze als bibliotheek te publiceren." +"Create property…" = "Eigenschap maken…" +"Create style" = "Stijl maken" +"Create style from the selected text" = "Stijl maken van de geselecteerde tekst" +"Created" = "Gemaakt" +"Crop" = "Bijsnijden" +"Crop marks" = "Snijtekens" +"Ctrl+arrows" = "Ctrl+pijltjes" +"Ctrl+click" = "Ctrl+click" +"Ctrl+scroll" = "Ctrl+scroll" +"Current name" = "Huidige naam" +"Current version" = "Huidige versie" +"Cursor chat" = "Cursorchat" +"Custom" = "Aangepast" +"Custom size" = "Aangepaste grootte" +"Cut" = "Knippen" +"Dark" = "Donker" +"Darken" = "Darken" +"Dash" = "Streep" +"Dashboard" = "Dashboard" +"Date created" = "Aanmaakdatum" +"Dec" = "dec." +"Decoration" = "Decoratie" +"Decoration style" = "Decoratiestijl" +"Deep select" = "Diep selecteren" +"Default" = "Standaard" +"Default content" = "Standaardinhoud" +"Default value (the top-left variant)" = "Standaardwaarde (de variant linksboven)" +"Delay" = "Vertraging" +"Delete" = "Verwijderen" +"Delete category (its annotations stay)" = "Categorie verwijderen (de annotaties blijven staan)" +"Delete collection" = "Verzameling verwijderen" +"Delete group" = "Groep verwijderen" +"Delete mode" = "Modus verwijderen" +"Delete page" = "Pagina verwijderen" +"Delete preset" = "Preset verwijderen" +"Delete property" = "Eigenschap verwijderen" +"Delete the style (layers keep their values)" = "Verwijdert de stijl (lagen behouden hun waarden)" +"Delete thread" = "Gesprek verwijderen" +"Delete variable" = "Variabele verwijderen" +"Delete “{name}”? This can't be undone." = "“{name}” verwijderen? Dit kan niet ongedaan worden gemaakt." +"Density" = "Dichtheid" +"Depth" = "Diepte" +"Describe how and when to use this component" = "Beschrijf hoe en wanneer je dit component gebruikt" +"Describe what changed" = "Beschrijf wat er is gewijzigd" +"Description" = "Beschrijving" +"Description (optional)" = "Beschrijving (optioneel)" +"Design" = "Design" +"Design system starter" = "Designsysteem-starter" +"Desktop" = "Desktop" +"Destination" = "Bestemming" +"Detach alias" = "Alias loskoppelen" +"Detach instance" = "Instance loskoppelen" +"Detach property" = "Eigenschap loskoppelen" +"Detach style" = "Stijl loskoppelen" +"Detach styles" = "Stijlen loskoppelen" +"Detach variable" = "Variabele loskoppelen" +"Details" = "Details" +"Dev Mode" = "Dev Mode" +"Dev resources" = "Dev-bronnen" +"Development" = "Ontwikkeling" +"Device" = "Apparaat" +"Device at 100%" = "Apparaat op 100%" +"Diamond" = "Ruit" +"Difference" = "Difference" +"Dimensions" = "Afmetingen" +"Direction" = "Richting" +"Disconnect from code" = "Loskoppelen van code" +"Dispersion" = "Dispersie" +"Display name" = "Weergavenaam" +"Dissolve" = "Dissolve" +"Distribute and tidy" = "Verdelen en opruimen" +"Distribute horizontal spacing" = "Horizontale afstand verdelen" +"Distribute vertical spacing" = "Verticale afstand verdelen" +"Divider" = "Scheidslijn" +"Don't truncate" = "Niet afkappen" +"Done" = "Klaar" +"Done editing" = "Klaar met bewerken" +"Dots" = "Dots" +"Dotted" = "Gestippeld" +"Down" = "Onder" +"Drafts" = "Concepten" +"Drag the stroke to add a width point" = "Sleep de lijn om een breedtepunt toe te voegen" +"Draw" = "Tekenen" +"Draw a frame" = "Teken een frame" +"Drawing tools" = "Tekengereedschap" +"Drop a .figcraft, .fig, .json, .svg or image file." = "Laat een .figcraft-, .fig-, .json-, .svg- of afbeeldingsbestand los." +"Drop shadow" = "Slagschaduw" +"Drop to import" = "Loslaten om te importeren" +"Drop to open" = "Loslaten om te openen" +"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." +"Duo" = "Duo" +"Duplicate" = "Dupliceren" +"Duplicate by dragging" = "Dupliceren door te slepen" +"Duplicate mode" = "Modus dupliceren" +"Duplicate page" = "Pagina dupliceren" +"Edit" = "Bewerken" +"Edit as an expression" = "Bewerken als expressie" +"Edit colour" = "Kleur bewerken" +"Edit comment" = "Reactie bewerken" +"Edit dev resource" = "Dev-resource bewerken" +"Edit link" = "Link bewerken" +"Edit link copied" = "Link om te bewerken gekopieerd" +"Edit style" = "Stijl bewerken" +"Edit variable" = "Variabele bewerken" +"Edit with the builder" = "Bewerken met de builder" +"Edited {when}" = "Bewerkt {when}" +"Effect settings" = "Effectinstellingen" +"Effect styles" = "Effectstijlen" +"Effects" = "Effecten" +"Ellipse" = "Ellips" +"Ellipses" = "Ellipsen" +"Else" = "Anders" +"Else if" = "Anders als" +"Embedded profile" = "Ingebed profiel" +"Empty" = "Leeg" +"End" = "End" +"End point" = "Eindpunt" +"Ends the session; everyone keeps their copy" = "Beëindigt de sessie; iedereen houdt zijn eigen kopie" +"Enter" = "Enter" +"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Voer het adres van een figcraft-server in en druk op Bladeren om te zien welke plugins het aanbiedt." +"Enter to save · Esc to cancel" = "Enter om op te slaan · Esc om te annuleren" +"Eraser" = "Gum" +"Esc" = "Esc" +"Essential" = "Essentieel" +"Every conflict has a choice." = "Elk conflict heeft een keuze." +"Every top-level frame already starts a flow" = "Elk frame op het hoogste niveau begint al een flow" +"Exact opacity (4 then 5: 45%)" = "Exacte dekking (4 dan 5: 45%)" +"Exclude" = "Exclude" +"Excluded" = "Uitgesloten" +"Excluded from layout" = "Uitgesloten van de layout" +"Excludes" = "Sluit uit" +"Exclusion" = "Exclusion" +"Exit focus view (Esc)" = "Focusweergave afsluiten (Esc)" +"Expand" = "Uitvouwen" +"Export" = "Exporteren" +"Export all" = "Alles exporteren" +"Export frames and layers as PNG, JPG, SVG or PDF" = "Frames en lagen exporteren als PNG, JPG, SVG of PDF" +"Export page as PNG" = "Pagina exporteren als PNG" +"Export settings" = "Exportinstellingen" +"Export variables" = "Variabelen exporteren" +"Export {name}" = "{name} exporteren" +"Export {n} layers" = { one = "{n} laag exporteren", other = "{n} lagen exporteren" } +"Exported {file}" = "{file} geëxporteerd" +"Exported {name}" = "{name} geëxporteerd" +"Exported {name}; {n} kinds of content were left out" = { one = "{name} geëxporteerd; {n} soort content is niet meegenomen", other = "{name} geëxporteerd; {n} soorten content zijn niet meegenomen" } +"Exported {n} files" = { one = "{n} bestand geëxporteerd", other = "{n} bestanden geëxporteerd" } +"Export…" = "Exporteren…" +"Expose properties to instances" = "Eigenschappen beschikbaar maken voor instances" +"Exposure" = "Belichting" +"Expression" = "Expressie" +"Extend collection" = "Verzameling uitbreiden" +"Extends \"{name}\": overridden values are marked" = "Erft van “{name}”: overschreven waarden zijn gemarkeerd" +"Eye" = "Oog" +"F" = "F" +"Fade" = "Fade" +"False" = "Onwaar" +"Feb" = "feb." +"Fetching from the server…" = "Bezig met ophalen van de server…" +"FigCraft couldn't open its window: {error}" = "FigCraft kon zijn venster niet openen: {error}" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft ondersteunt nog niet elke functie van deze bestanden. De notities vermelden wat er in de geïmporteerde kopie is weggelaten of gewijzigd; het originele bestand is ongewijzigd." +"File" = "Bestand" +"File name" = "Bestandsnaam" +"File not found" = "Bestand niet gevonden" +"File published" = "Bestand gepubliceerd" +"Files aren't end-to-end encrypted: the server's operator can read them." = "Bestanden zijn niet end-to-end versleuteld: de beheerder van de server kan ze lezen." +"Files nobody opens for {n} days are deleted." = { one = "Na {n} dag zonder openen wordt een bestand verwijderd.", other = "Na {n} dagen zonder openen wordt een bestand verwijderd." } +"Fill" = "Vulling" +"Fill (fr)" = "Vullen (fr)" +"Fill container" = "Container vullen" +"Fill window" = "Venster vullen" +"Fills" = "Vullingen" +"Fill|sizing" = "Vullen" +"Filter and sort" = "Filteren en sorteren" +"Find" = "Zoeken" +"Find and replace" = "Zoeken en vervangen" +"Find layers" = "Lagen zoeken" +"First" = "Eerste" +"First commit" = "Eerste commit" +"First on top" = "Eerste bovenaan" +"Fit" = "Inpassen" +"Fit device to window" = "Apparaat inpassen in het venster" +"Fit to contents" = "Aanpassen aan inhoud" +"Fit to window" = "Inpassen in venster" +"Fit width" = "Breedte inpassen" +"Fixed" = "Vast" +"Fixed gap" = "Vaste afstand" +"Fixed size" = "Vaste grootte" +"Flatten" = "Samenvoegen tot vector" +"Flip" = "Spiegelen" +"Flip horizontal" = "Horizontaal spiegelen" +"Flip text to the other side" = "Tekst naar de andere kant spiegelen" +"Flip vertical" = "Verticaal spiegelen" +"Flip width profile" = "Breedteprofiel spiegelen" +"Flow starting point" = "Flow-startpunt" +"Flow starting points" = "Flow-startpunten" +"Flows" = "Flows" +"Focus view" = "Focusweergave" +"Fold autosaves" = "Automatisch opgeslagen versies inklappen" +"Folder: click to collapse" = "Map: klik om samen te vouwen" +"Folder: click to expand" = "Map: klik om uit te klappen" +"Follow" = "Volgen" +"Follow {name}" = "{name} volgen" +"Following" = "Volgend" +"Following {name} · {key} to stop" = "Volgt {name} · {key} om te stoppen" +"Font" = "Lettertype" +"Font not installed" = "Lettertype niet geïnstalleerd" +"Font size" = "Lettertypegrootte" +"Font style" = "Lettertypestijl" +"Forget on this computer" = "Vergeten op deze computer" +"Four screens of a trail app with components and a prototype flow" = "Vier schermen van een wandelroute-app met componenten en een prototype-flow" +"Fractions" = "Breuken" +"Frame" = "Frame" +"Frame selection" = "Selectie in frame plaatsen" +"Frame tools" = "Frame-hulpmiddelen" +"Frames" = "Frames" +"From the library \"{name}\": read-only" = "Uit de bibliotheek \"{name}\": alleen-lezen" +"From {name}" = "Van {name}" +"From {server}" = "Van {server}" +"Frost" = "Frost" +"Gap" = "Afstand" +"Gap between columns" = "Afstand tussen kolommen" +"Gap between items" = "Afstand tussen items" +"Gap between rows" = "Afstand tussen rijen" +"Getting started" = "Aan de slag" +"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "Geef het adres van een server die openbaar bereikbaar is, of het adres van een server met bijbehorende key (`figcraft-server key` toont die)." +"Glass" = "Glass" +"Go to main component" = "Naar hoofdcomponent gaan" +"Go to {name}" = "Ga naar {name}" +"Grab bag" = "Grab bag" +"Gradient" = "Verloop" +"Gray" = "Grijs" +"Green" = "Groen" +"Greyscale components and two example screens to sketch a flow with" = "Grijswaardencomponenten en twee voorbeeldschermen om een flow mee te schetsen" +"Grid" = "Raster" +"Grid ({size})" = "Raster ({size})" +"Grid styles" = "Rasterstijlen" +"Grid {size}" = "Raster {size}" +"Group" = "Groep" +"Group selection" = "Selectie groeperen" +"Groups" = "Groepen" +"Gutter" = "Tussenruimte" +"Hand tool" = "Handgereedschap" +"Hanging list" = "Lijst met hangende inspringing" +"Hanging punctuation" = "Uithangende leestekens" +"Hard light" = "Hard light" +"Height" = "Hoogte" +"Help" = "Help" +"Help and resources" = "Help en bronnen" +"Help page" = "Helppagina" +"Here: {names}" = "Hier: {names}" +"Hidden" = "Verborgen" +"Hide contents" = "Inhoud verbergen" +"Hide from publishing" = "Verbergen bij publiceren" +"Hide section contents" = "Sectie-inhoud verbergen" +"Hide these tips (Getting started in the Help menu shows them again)" = "Deze tips verbergen (Aan de slag in het menu Help toont ze opnieuw)" +"Hide when publishing" = "Verbergen bij publiceren" +"Highlight layers on hover" = "Lagen uitlichten bij aanwijzen" +"Highlights" = "Hooglichten" +"Hold Alt" = "Houd Alt ingedrukt" +"Hold Alt+Z" = "Houd Alt+Z ingedrukt" +"Hold Ctrl" = "Ctrl ingedrukt houden" +"Hold Z" = "Houd Z ingedrukt" +"Hold ⌃ or ⌘" = "Houd ⌃ of ⌘ ingedrukt" +"Hold ⌥" = "Houd ⌥ ingedrukt" +"Hold ⌥Z" = "Houd ⌥Z ingedrukt" +"Home" = "Home" +"Horizontal" = "Horizontaal" +"Horizontal align" = "Horizontaal uitlijnen" +"Horizontal hex" = "Horizontaal hex" +"Horizontal padding" = "Horizontale padding" +"Horizontal spacing" = "Horizontale afstand" +"Hosted by {operator}" = "Gehost door {operator}" +"Hover a layer to see its properties" = "Houd de muis boven een laag om de eigenschappen te zien" +"Hover other layers to measure the distance" = "Beweeg over andere lagen om de afstand te meten" +"Hue" = "Hue" +"Hug" = "Hug" +"Hug contents" = "Aangepast aan inhoud" +"Icon components on a 24 px grid with keylines and a size preview" = "Iconcomponents op een raster van 24 px met keylines en een voorvertoning van de grootte" +"Icon set starter" = "Iconenset-starter" +"If" = "Als" +"If it keeps happening, please report it at {link} with this message." = "Als dit blijft gebeuren, meld het dan op {link} met dit bericht." +"If {condition}" = "Als {condition}" +"Ignore auto layout (absolute position)" = "Auto layout negeren (absolute positie)" +"Image" = "Afbeelding" +"Images" = "Afbeeldingen" +"Import" = "Importeren" +"Import path" = "Importpad" +"Import plugin from manifest" = "Plugin importeren vanuit een manifest" +"Import plugin from manifest…" = "Plugin importeren vanuit manifest…" +"Import report" = "Importrapport" +"Import variables" = "Variabelen importeren" +"Imported document" = "Geïmporteerd document" +"Imported {name}" = "{name} geïmporteerd" +"Imported {n} new variables, updated {updated}" = { one = "{n} nieuwe variabele geïmporteerd, {updated} bijgewerkt", other = "{n} nieuwe variabelen geïmporteerd, {updated} bijgewerkt" } +"Import…" = "Importeren…" +"In an instance, this comes from the main component" = "In een instance komt dit uit de hoofdcomponent" +"In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)." = "In de browser kun je deelnemen aan sessies die de desktop-app host en aan bestanden die op een FigCraft-server staan. Een pagina die via https is geopend, bereikt andere computers alleen via wss://-links (een server achter TLS)." +"Include bounding box" = "Bounding box meenemen" +"Include id attribute" = "id-attribuut opnemen" +"Included" = "Inbegrepen" +"Included in layout" = "Inbegrepen in de layout" +"Individual corners" = "Individuele hoeken" +"Individual strokes" = "Afzonderlijke lijnen" +"Ink" = "Ink" +"Inner shadow" = "Binnenschaduw" +"Inside" = "Binnen" +"Inspect" = "Inspecteren" +"Inspect the layer under the pointer: size, colours, typography" = "Inspecteer de laag onder de muisaanwijzer: grootte, kleuren, typografie" +"Install" = "Installeren" +"Install plugin" = "Plugin installeren" +"Installed" = "Geïnstalleerd" +"Installed {name}" = "{name} geïnstalleerd" +"Installing…" = "Installeren…" +"Instance" = "Instance" +"Instance properties" = "Instance-eigenschappen" +"Instance swap" = "Instance verwisselen" +"Instance swap (no other component)" = "Instance verwisselen (geen andere component)" +"Instances" = "Instances" +"Instant" = "Direct" +"Intensity" = "Intensiteit" +"Interaction" = "Interactie" +"Interactions" = "Interacties" +"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Interface-ontwerp en prototyping. Een gratis, open-source, clean-room ontwerptool, helemaal van nul geschreven in Rust, onderdeel van de ArtCraft Crafting Apps." +"Intersect" = "Intersect" +"Intersects" = "Overlapt" +"Invalid color" = "Ongeldige kleur" +"Invite link" = "Uitnodigingslink" +"Invite link copied" = "Uitnodigingslink gekopieerd" +"It asks permission to:" = "Het vraagt toestemming om:" +"Jan" = "jan." +"Join" = "Deelnemen" +"Join code copied" = "Toegangscode gekopieerd" +"Join code or invite link" = "Deelnamecode of uitnodigingslink" +"Join live session" = "Deelnemen aan live-sessie" +"Join us on Discord" = "Chat met ons op Discord" +"Join with a code" = "Deelnemen met een code" +"Joined" = "Erbij gekomen" +"Joined {file}" = "Deelgenomen aan {file}" +"Joining…" = "Bezig met deelnemen…" +"Join|stroke" = "Hoek" +"Jul" = "jul." +"Jun" = "jun." +"Just now" = "Zojuist" +"Justify" = "Uitvullen" +"Keep appearance" = "Uiterlijk behouden" +"Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Kleur behouden converteert elke kleurwaarde naar {profile}, zodat kleuren er hetzelfde uitzien. Waarden behouden laat de getallen staan, waardoor kleuren verschuiven." +"Keep tool selected after use" = "Tool na gebruik geselecteerd houden" +"Keep values" = "Waarden behouden" +"Key/Gamepad" = "Toets/Gamepad" +"Key/Gamepad {key}" = "Toets/Gamepad {key}" +"Keyboard shortcuts" = "Sneltoetsen" +"Kits" = "Kits" +"Landing page" = "Landingspagina" +"Language" = "Taal" +"Lasso" = "Lasso" +"Last" = "Laatst" +"Last change: {note}" = "Laatste wijziging: {note}" +"Last commit" = "Laatste commit" +"Last modified" = "Laatst gewijzigd" +"Last on top" = "Laatste bovenaan" +"Layer blur" = "Laagvervaging" +"Layer name" = "Laagnaam" +"Layers" = "Lagen" +"Layers in an instance take their position from the main component" = "Lagen in een instance nemen hun positie over van de hoofdcomponent" +"Layout" = "Layout" +"Layout grid" = "Layoutraster" +"Layout grid settings" = "Instellingen voor layoutraster" +"Layout grids" = "Layout-rasters" +"Leave session" = "Sessie verlaten" +"Leaves" = "Leaves" +"Left" = "Links" +"Left & right" = "Links en rechts" +"Left side" = "Linkerkant" +"Left side width" = "Breedte linkerkant" +"Letter case" = "Letterkast" +"Letter spacing" = "Letterafstand" +"Libraries" = "Bibliotheken" +"Libraries in this file" = "Bibliotheken in dit bestand" +"Library updates available" = "Bibliotheekupdates beschikbaar" +"Library: click to collapse" = "Bibliotheek: klik om samen te vouwen" +"Library: click to expand" = "Bibliotheek: klik om uit te klappen" +"Library: its variables are read-only here; edit them in the library file and publish it" = "Bibliotheek: de variabelen zijn hier alleen-lezen; bewerk ze in het bibliotheekbestand en publiceer dat" +"Light" = "Licht" +"Lighten" = "Lighten" +"Light|glass" = "Light" +"Line" = "Lijn" +"Line arrow" = "Lijnpijl" +"Line delta" = "Regeldelta" +"Line height" = "Regelafstand" +"Linear" = "Lineair" +"Lines" = "Lijnen" +"Lines +/−" = "Regels +/−" +"Lines added" = "Toegevoegde regels" +"Lines deleted" = "Regels verwijderd" +"Link" = "Koppeling" +"Link copied" = "Link gekopieerd" +"Link docs, code or tickets for developers." = "Koppel documentatie, code of tickets voor ontwikkelaars." +"Link frames on the Prototype tab, then present the flow" = "Koppel frames op het Prototype-tabblad en presenteer daarna de flow" +"Link this component to its code component: instances then show your real snippet with their values." = "Koppel deze component aan zijn codecomponent: instances laten dan je echte snippet zien met hun waarden." +"List spacing" = "Lijstafstand" +"List style" = "Lijststijl" +"Live session" = "Live sessie" +"Live · {people}" = "Live · {people}" +"Local components" = "Lokale componenten" +"Local network" = "Lokaal netwerk" +"Local styles" = "Lokale stijlen" +"Local variables" = "Lokale variabelen" +"Lock/Unlock" = "Vergrendelen/ontgrendelen" +"Locked" = "Vergrendeld" +"Lowercase" = "Kleine letters" +"Luminance" = "Luminantie" +"Luminosity" = "Luminosity" +"Main" = "Main" +"Main component deleted" = "Hoofdcomponent verwijderd" +"Main menu" = "Hoofdmenu" +"Make a component" = "Maak een component" +"Make slot" = "Slot maken" +"Mar" = "mrt." +"Margin" = "Marge" +"Mark all as read" = "Markeer alles als gelezen" +"Mark as resolved" = "Markeren als opgelost" +"Mark as unread" = "Markeren als ongelezen" +"Mark as unresolved" = "Markeren als onopgelost" +"Mask" = "Masker" +"Mass · Stiffness · Damping" = "Massa · Stijfheid · Demping" +"Match (empty: the whole name)" = "Overeenkomst (leeg: de hele naam)" +"Max H" = "Max H" +"Max W" = "Max B" +"Max lines" = "Max. regels" +"Maximize" = "Maximaliseren" +"May" = "mei" +"Measure to the selection" = "Meten tot de selectie" +"Merge into main" = "Samenvoegen in main" +"Merge regions" = "Gebieden samenvoegen" +"Merge the branch into the main file (one undo step) and archive it" = "Voeg de branch samen met het hoofdbestand (één keer ongedaan maken) en archiveer hem" +"Merged" = "Samengevoegd" +"Merged PRs" = "Samengevoegde PRs" +"Min H" = "Min H" +"Min W" = "Min. B" +"Minimize" = "Minimaliseren" +"Mirror angle" = "Hoek spiegelen" +"Mirror angle and length" = "Spiegel hoek en lengte" +"Mirroring" = "Spiegeling" +"Missing" = "Ontbreekt" +"Missing fonts" = "Ontbrekende lettertypen" +"Missing layer" = "Ontbrekende laag" +"Missing style" = "Ontbrekende stijl" +"Missing variable" = "Ontbrekende variabele" +"Miter" = "Punt" +"Miter angle" = "Verstekhoek" +"Mixed" = "Gemengd" +"Mixed fonts" = "Gemengde lettertypen" +"Mixed interactions: click + to replace them" = "Gemengde interacties: klik op + om ze te vervangen" +"Mobile app" = "Mobiele app" +"Mode" = "Modus" +"Model" = "Model" +"Models" = "Modellen" +"Mono" = "Mono" +"More" = "Meer" +"More instance actions" = "Meer instance-acties" +"Mouse down" = "Muisknop ingedrukt" +"Mouse enter" = "Muis erover" +"Mouse leave" = "Muis verlaat" +"Mouse up" = "Muis loslaten" +"Move" = "Verplaatsen" +"Move along the toolbar or the layer rows" = "Beweeg langs de werkbalk of de rijen met lagen" +"Move column left" = "Kolom naar links verplaatsen" +"Move column right" = "Kolom naar rechts verplaatsen" +"Move down" = "Omlaag verplaatsen" +"Move in" = "Naar binnen bewegen" +"Move out" = "Naar buiten verplaatsen" +"Move tools" = "Verplaatsgereedschap" +"Move up" = "Omhoog verplaatsen" +"Moved to {name}" = "Verplaatst naar {name}" +"Multi" = "Multi" +"Multiply" = "Multiply" +"Muted" = "Muted" +"Name" = "Naam" +"Name (A–Z)" = "Naam (A–Z)" +"Named instance" = "Benoemde instance" +"Navigate to" = "Ga naar" +"Nested instance" = "Geneste instance" +"New design file" = "Nieuw ontwerpbestand" +"Newest first" = "Nieuwste eerst" +"Next frame" = "Volgend frame" +"Next or previous control in the focused region" = "Volgende of vorige bedieningselement in het gebied met focus" +"Next ready for dev" = "Volgende klaar voor dev" +"Next size option" = "Volgende grootteoptie" +"No assets: no export settings, icons or images. Add export settings in the Design tab." = "Geen assets: geen exportinstellingen, pictogrammen of afbeeldingen. Voeg exportinstellingen toe op het Design-tabblad." +"No boolean variables fit here." = "Er passen hier geen booleaanse variabelen." +"No boolean variables yet. Create them in Local variables." = "Nog geen booleaanse variabelen. Maak ze in Lokale variabelen." +"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Geen branches. Een branch is een kopie van het bestand om wijzigingen in uit te proberen, daarna te beoordelen en samen te voegen." +"No category" = "Geen categorie" +"No changes" = "Geen wijzigingen" +"No color styles" = "Geen kleurstijlen" +"No colour variables fit here." = "Hier passen geen kleurvariabelen." +"No colour variables yet. Create them in Local variables." = "Nog geen kleurvariabelen. Maak ze in Lokale variabelen." +"No comments yet" = "Nog geen reacties" +"No compatibility warnings reported" = "Geen compatibiliteitswaarschuwingen gerapporteerd" +"No components yet. Select layers and press {keys}." = "Nog geen components. Selecteer lagen en druk op {keys}." +"No conflicts." = "Geen conflicten." +"No contributor data was built into this copy." = "In deze kopie zijn geen bijdragersgegevens ingebouwd." +"No device" = "Geen apparaat" +"No files or templates match your search" = "Geen bestanden of sjablonen komen overeen met je zoekopdracht" +"No fonts match" = "Geen lettertypen gevonden" +"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Hier is geen toegangscode (de server is ouder dan toegangscodes, of dit adres past niet in een code): deel in plaats daarvan de link." +"No layer changes to show." = "Geen lagenwijzigingen om te tonen." +"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Nog geen bibliotheken. Voeg een .figcraft-bestand toe dat als bibliotheek is gepubliceerd, of plak de link van een teambibliotheek." +"No list" = "Geen lijst" +"No matching comments" = "Geen overeenkomende reacties" +"No matching components" = "Geen overeenkomende components" +"No matching layers" = "Geen bijpassende lagen" +"No matching styles" = "Geen bijpassende stijlen" +"No matching variables" = "Geen passende variabelen" +"No mirroring" = "Geen spiegeling" +"No model credits were built into this copy." = "Er zijn geen modelcredits in deze kopie ingebouwd." +"No number variables fit here." = "Hier passen geen getalvariabelen." +"No number variables yet. Create them in Local variables." = "Nog geen getalvariabelen. Maak ze in Lokale variabelen." +"No other pages" = "Geen andere pagina's" +"No paints" = "Geen vullingen" +"No plugins on {server}." = "Geen plugins op {server}." +"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Nog geen plugins. Importeer een development-plugin via zijn figcraft-plugin.json." +"No preferred components" = "Geen voorkeurscomponenten" +"No preset" = "Geen preset" +"No saved versions yet. Save a version to mark a point in your work you can come back to." = "Nog geen opgeslagen versies. Sla een versie op om een moment in je werk vast te leggen waar je later naartoe kunt terugkeren." +"No scrolling" = "Geen scrollen" +"No shortcuts match" = "Er komen geen sneltoetsen overeen" +"No status" = "Geen status" +"No string variables fit here." = "Er passen hier geen string-variabelen." +"No string variables yet. Create them in Local variables." = "Nog geen tekenreeksvariabelen. Maak ze aan in Lokale variabelen." +"No styles yet." = "Nog geen stijlen." +"No styles yet. Click + to create one from the selection." = "Nog geen stijlen. Klik op + om er een te maken vanuit de selectie." +"No templates match your search" = "Geen sjablonen gevonden voor je zoekopdracht" +"No text styles yet. Click + to create one from the selected text." = "Nog geen tekststijlen. Klik op + om er een te maken vanuit de geselecteerde tekst." +"No variables fit here." = "Er passen hier geen variabelen." +"No variables yet. Create them in Local variables." = "Nog geen variabelen. Maak ze in Lokale variabelen." +"Nobody else is here yet" = "Er is nog niemand anders hier" +"Noise" = "Ruis" +"None" = "Geen" +"None." = "Geen." +"Normal" = "Normal" +"Not bound" = "Niet gekoppeld" +"Not in a live session" = "Niet in een live sessie" +"Not in this font" = "Niet in dit lettertype" +"Not properties, kept as written: {names}" = "Geen eigenschappen, ongewijzigd overgenomen: {names}" +"Not read yet" = "Nog niet gelezen" +"Not shared" = "Niet gedeeld" +"Note {n}" = "Notitie {n}" +"Nothing changed since this was marked ready for dev." = "Er is niets gewijzigd sinds dit was gemarkeerd als ready for dev." +"Nothing here yet. Set a frame's status to list it." = "Nog niets hier. Stel de status van een frame in om het hier te tonen." +"Nothing online yet. Use Share on a file to put it online and it is listed here." = "Er staat nog niets online. Gebruik Delen bij een bestand om het online te zetten, dan wordt het hier vermeld." +"Nothing to export" = "Niets te exporteren" +"Nov" = "nov." +"Now" = "Nu" +"Nudge (Shift: big nudge)" = "Verschuiven (Shift: grote stap)" +"Nudge amount" = "Stapafstand" +"Number" = "Getal" +"Number ↑" = "Getal ↑" +"Number ↓" = "Nummer ↓" +"Numbered list" = "Genummerde lijst" +"Object" = "Object" +"Oct" = "okt." +"Off" = "Uit" +"Offered first when filling the slot (any content is allowed)" = "Wordt het eerst aangeboden bij het vullen van de slot (elke inhoud is toegestaan)" +"Offered first when swapping the instance (any component is allowed)" = "Wordt als eerste aangeboden bij het verwisselen van de instance (elk component is toegestaan)" +"Offset" = "Verschuiving" +"Oldest first" = "Oudste eerst" +"On click" = "Bij klik" +"On drag" = "Bij slepen" +"On this page" = "Op deze pagina" +"One announcement in five common sizes: square, portrait, story, link and banner" = "Eén aankondiging in vijf gangbare formaten: vierkant, staand, story, link en banner" +"Online" = "Online" +"Online files" = "Online bestanden" +"Online sharing is off: files are shared on the local network only." = "Online delen staat uit: bestanden worden alleen op het lokale netwerk gedeeld." +"Only the first changed layers are listed." = "Alleen de eerste gewijzigde lagen worden weergegeven." +"Only the first {n} changed layers are listed." = "Er worden alleen de eerste {n} gewijzigde lagen weergegeven." +"Only your threads" = "Alleen jouw gesprekken" +"Opacity" = "Dekking" +"Opacity & blend" = "Dekking & blend" +"Opacity 10%…90%, 0: 100%" = "Dekking 10%…90%, 0: 100%" +"Open" = "Openen" +"Open .figcraft, .svg or images" = "Open .figcraft, .svg of afbeeldingen" +"Open branch" = "Branch openen" +"Open file" = "Bestand openen" +"Open files" = "Geopende bestanden" +"Open in focus view" = "Openen in focusweergave" +"Open in new window" = "Openen in een nieuw venster" +"Open invite links in FigCraft" = "Uitnodigingslinks openen in FigCraft" +"Open link" = "Link openen" +"Open link {url}" = "Link {url} openen" +"Open overlay" = "Overlay openen" +"Open prototype preview" = "Voorvertoning van prototype openen" +"Open variables ({count})" = "Variabelen openen ({count})" +"Opened as a new document" = "Geopend als nieuw document" +"Opened “{name}” in a new tab" = "“{name}” geopend in een nieuw tabblad" +"Opening the file…" = "Bestand openen…" +"Opening {n} files as new files" = { one = "Openen als nieuw bestand", other = "{n} bestanden openen als nieuwe bestanden" } +"Open|comments" = "Open" +"Options" = "Opties" +"Orange" = "Oranje" +"Order" = "Volgorde" +"Other" = "Overige" +"Other apps may not open every .fig file exported here." = "Het kan zijn dat andere apps niet elk .fig-bestand openen dat hier wordt geëxporteerd." +"Others lose access and keep their copy; the file stays on the server for you" = "Anderen verliezen de toegang en behouden hun kopie; het bestand blijft voor jou op de server" +"Others type this under Share ▸ Join with a code" = "Anderen typen dit onder Delen ▸ Deelnemen met een code" +"Outline stroke" = "Lijn omzetten naar contour" +"Outline text" = "Tekst omzetten in outlines" +"Outlines" = "Contouren" +"Outside" = "Buiten" +"Overflow" = "Overloop" +"Overlay" = "Overlay" +"Overlay position" = "Overlaypositie" +"Overridden here (the collection it extends has another value)" = "Hier overschreven (de verzameling die het uitbreidt heeft een andere waarde)" +"Overrides" = "Overschrijvingen" +"Owner token" = "Eigenaartoken" +"Owner token copied" = "Eigenaartoken gekopieerd" +"PDF print options" = "PDF-afdrukopties" +"PDF print settings" = "PDF-afdrukinstellingen" +"Padding" = "Padding" +"Page" = "Pagina" +"Page deleted" = "Pagina verwijderd" +"Pages" = "Pagina's" +"Pages added to the document" = "Pagina's toegevoegd aan het document" +"Pages: {pages}" = "Pagina's: {pages}" +"Paint bucket" = "Vulemmertje" +"Pan the canvas" = "Verschuif het canvas" +"Pan with nothing selected (Shift: farther)" = "Verschuiven zonder selectie (Shift: verder)" +"Panels" = "Panelen" +"Paper" = "Paper" +"Paragraph indent" = "Alinea-inspringen" +"Paragraph indent applies to left-aligned text" = "Alineainspringing geldt voor links uitgelijnde tekst" +"Paragraph spacing" = "Alinea-afstand" +"Pass through" = "Pass through" +"Paste a link (https://…)" = "Plak een link (https://…)" +"Paste a team library link (a view link)" = "Plak een link naar een teambibliotheek (een weergavelink)" +"Paste here" = "Hier plakken" +"Paste to replace" = "Plakken om te vervangen" +"Pasted design layers; {n} kinds of content were left out" = { one = "Ontwerplagen geplakt; {n} soort inhoud is weggelaten", other = "Ontwerplagen geplakt; {n} soorten inhoud zijn weggelaten" } +"Pattern" = "Patroon" +"Pattern alignment" = "Patroonuitlijning" +"Pattern scale" = "Patroonschaal" +"Pattern source" = "Patroonbron" +"Pen" = "Pen" +"Pencil" = "Potlood" +"People in this file" = "Personen in dit bestand" +"People join from anywhere with a code. The file is kept on {server} while you share it." = "Mensen kunnen met een code van overal deelnemen. Het bestand blijft op {server} staan zolang je het deelt." +"People on your network join with a code. Nothing goes through a server." = "Mensen op je netwerk nemen deel met een code. Er gaat niets via een server." +"Phone" = "Telefoon" +"Pick color" = "Kleur opnemen" +"Pick color (I)" = "Kleur kiezen (I)" +"Pick one font family first" = "Kies eerst één lettertypefamilie" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "Kies de Frame-tool en sleep daarna op het canvas of kies een apparaatgrootte" +"Pin a property" = "Eigenschap vastspelden" +"Pinch" = "Pinch" +"Pixel grid" = "Pixelraster" +"Pixel preview 1x" = "Pixelvoorvertoning 1x" +"Pixel preview 2x" = "Pixelvoorvertoning 2x" +"Place a shape or frame (its tool chosen)" = "Plaats een vorm of frame (het bijbehorende gereedschap is gekozen)" +"Place automatically" = "Automatisch plaatsen" +"Place image" = "Afbeelding plaatsen" +"Placed on the current page" = "Geplaatst op de huidige pagina" +"Playground" = "Playground" +"Plugin" = "Plugin" +"Plugins" = "Plugins" +"Plugins & widgets" = "Plugins & widgets" +"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Plugins draaien in de sandbox van FigCraft, zonder bestanden, netwerk of klok. Installeer alleen plugins die je vertrouwt." +"Plus darker" = "Plus darker" +"Plus lighter" = "Plus lighter" +"Point" = "Punt" +"Points" = "Punten" +"Polygon" = "Veelhoek" +"Polygons" = "Veelhoeken" +"Port" = "Poort" +"Position" = "Positie" +"Post" = "Plaatsen" +"Preferences" = "Voorkeuren" +"Preferred" = "Voorkeur" +"Present" = "Presenteren" +"Present or preview" = "Presenteren of voorvertonen" +"Present this flow" = "Deze flow presenteren" +"Presentation" = "Presentatie" +"Presentation slides" = "Presentatiedia's" +"Presenting: comments" = "Presenteren: reacties" +"Presenting: cycle the size options" = "Presenteren: door de grootteopties bladeren" +"Presenting: full screen" = "Presenteren: volledig scherm" +"Presenting: next frame" = "Presenteren: volgende frame" +"Presenting: previous frame" = "Presenteren: vorig frame" +"Presenting: restart" = "Presenteren: opnieuw starten" +"Preserve scroll position" = "Scrollpositie behouden" +"Presets" = "Presets" +"Press a key…" = "Druk op een toets…" +"Preview" = "Voorvertoning" +"Preview this flow" = "Voorvertoning van deze flow" +"Preview this frame" = "Dit frame voorvertonen" +"Previous frame" = "Vorige frame" +"Previous ready for dev" = "Vorige klaar voor developers" +"Progressive" = "Progressive" +"Properties" = "Eigenschappen" +"Prototype" = "Prototype" +"Prototype preview" = "Prototypevoorvertoning" +"Prototype settings" = "Prototype-instellingen" +"Publish" = "Publiceren" +"Publish changes" = "Wijzigingen publiceren" +"Publish to another server…" = "Publiceren op een andere server…" +"Publish to server" = "Publiceren naar de server" +"Published version {v}" = "Gepubliceerde versie {v}" +"Published version {v} to the server" = "Versie {v} naar de server gepubliceerd" +"Published version {v}: save the file so other files can use it" = "Gepubliceerde versie {v}: sla het bestand op zodat andere bestanden het kunnen gebruiken" +"Published · {people}" = "Gepubliceerd · {people}" +"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Publiceren naar een server vraagt om de desktop-app; in de browser kun je deelnemen aan de bestanden van een server" +"Publishing with the server's key." = "Publiceren met de sleutel van de server." +"Publishing…" = "Bezig met publiceren…" +"Push" = "Pushen" +"Push changes to main component" = "Wijzigingen naar hoofdcomponent doorvoeren" +"Put text on path" = "Tekst op pad plaatsen" +"R" = "R" +"Radial" = "Radiaal" +"Radius" = "Radius" +"Ratio" = "Verhouding" +"Reading the library…" = "Bibliotheek wordt gelezen…" +"Ready for dev" = "Klaar voor dev" +"Real name" = "Echte naam" +"Recent" = "Onlangs geopend" +"Recent files" = "Onlangs geopende bestanden" +"Recents" = "Onlangs geopend" +"Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them" = { one = "Na een onverwachte afsluiting van de app is één niet-opgeslagen bestand hersteld: sla het op om het te bewaren", other = "Na een onverwachte afsluiting van de app zijn {n} niet-opgeslagen bestanden hersteld: sla ze op om ze te bewaren" } +"Rectangle" = "Rechthoek" +"Rectangles" = "Rechthoeken" +"Red" = "Rood" +"Reduce motion" = "Beweging verminderen" +"Refraction" = "Breking" +"Regular expression" = "Reguliere expressie" +"Remove" = "Verwijderen" +"Remove all" = "Alles verwijderen" +"Remove annotation" = "Annotatie verwijderen" +"Remove auto layout" = "Auto layout verwijderen" +"Remove dev resource" = "Dev-resource verwijderen" +"Remove export setting" = "Exportinstelling verwijderen" +"Remove from recents" = "Verwijderen uit onlangs geopend" +"Remove from server" = "Van de server verwijderen" +"Remove from server…" = "Van server verwijderen…" +"Remove guide" = "Hulplijn verwijderen" +"Remove interaction" = "Interactie verwijderen" +"Remove library from this file" = "Bibliotheek uit dit bestand verwijderen" +"Remove min and max" = "Min en max verwijderen" +"Remove min and max height" = "Minimale en maximale hoogte verwijderen" +"Remove min and max width" = "Minimale en maximale breedte verwijderen" +"Remove regions" = "Gebieden verwijderen" +"Remove slot" = "Vak verwijderen" +"Remove starting point" = "Startpunt verwijderen" +"Remove stop" = "Stop verwijderen" +"Remove text from path" = "Tekst losmaken van pad" +"Remove the else and its actions" = "De else en de bijbehorende acties verwijderen" +"Remove this condition and its actions" = "Deze voorwaarde en de bijbehorende acties verwijderen" +"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Dit bestand van de server verwijderen? Het kan daarna niet meer worden gedeeld of geopend vanaf de server. Jouw kopie blijft hier open." +"Remove {name} from session" = "{name} verwijderen uit de sessie" +"Remove {name} from {server}?" = "{name} van {server} verwijderen?" +"Removed" = "Verwijderd" +"Removed from the server" = "Van de server verwijderd" +"Removed since it was marked" = "Verwijderd sinds het was gemarkeerd" +"Rename" = "Hernoemen" +"Rename duplicated layers" = "Gedupliceerde lagen hernoemen" +"Rename folder" = "Map hernoemen" +"Rename group" = "Groep hernoemen" +"Rename layers" = "Lagen hernoemen" +"Rename mode" = "Modus hernoemen" +"Rename on the server" = "Hernoemen op de server" +"Rename on the server…" = "Hernoemen op de server…" +"Rename page" = "Pagina hernoemen" +"Rename to" = "Hernoemen naar" +"Replace all" = "Alles vervangen" +"Replace mixed export settings" = "Gemengde exportinstellingen vervangen" +"Replace with" = "Vervangen door" +"Replace with a new interaction" = "Vervangen door een nieuwe interactie" +"Reply" = "Antwoorden" +"Report" = "Melden" +"Report this file…" = "Dit bestand melden…" +"Reset" = "Herstellen" +"Reset adjustments" = "Aanpassingen herstellen" +"Reset all changes" = "Alle wijzigingen terugdraaien" +"Reset all overrides on this layer" = "Alle overschrijvingen op deze laag herstellen" +"Reset overrides" = "Overschrijvingen terugzetten" +"Reset slot" = "Slot terugzetten" +"Reset this override" = "Deze overschrijving terugzetten" +"Reset to style" = "Terugzetten naar stijl" +"Reset to the inherited value" = "Terugzetten naar de overgeërfde waarde" +"Resize by the nudge amount" = "Formaat wijzigen met de stapafstand" +"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Pas het formaat van frames aan naar het scherm van het apparaat (of het venster zonder apparaat): hun constraints en auto layout reageren" +"Resize to fit" = "Formaat aanpassen aan inhoud" +"Resizing" = "Formaat wijzigen" +"Resolve" = "Oplossen" +"Resolve change" = "Wijziging oplossen" +"Resolved" = "Opgelost" +"Resources" = "Bronnen" +"Responsive" = "Responsive" +"Restart" = "Opnieuw starten" +"Restore" = "Terugzetten" +"Restore main component" = "Hoofdcomponent terugzetten" +"Restore this version" = "Deze versie terugzetten" +"Restored “{name}”" = "“{name}” teruggezet" +"Reverse the order" = "Volgorde omdraaien" +"Reversed triangle" = "Omgekeerde driehoek" +"Review and merge" = "Controleren en samenvoegen" +"Ribbon" = "Ribbon" +"Right" = "Rechts" +"Right side" = "Rechterkant" +"Right side width" = "Breedte rechterkant" +"Rotate 90°" = "90° draaien" +"Rotate 90° right" = "90° naar rechts draaien" +"Rotate to landscape" = "Draai naar liggend" +"Rotate to portrait" = "Draaien naar staand" +"Rotation" = "Rotatie" +"Rotation jitter" = "Verspreide rotatie" +"Round" = "Rond" +"Rounded" = "Rounded" +"Row" = "Rij" +"Row gap" = "Rijafstand" +"Row sizes" = "Rijgroottes" +"Row span" = "Rijbereik" +"Rows" = "Rijen" +"Rows {count}" = "Rijen {count}" +"Rulers" = "Linialen" +"Run" = "Uitvoeren" +"Run {label}" = "{label} uitvoeren" +"SHA-256: {sha}…" = "SHA-256: {sha}…" +"Same as layer" = "Zoals de laag" +"Saturation" = "Verzadiging" +"Save" = "Opslaan" +"Save as" = "Opslaan als" +"Save as preset…" = "Opslaan als preset…" +"Save files automatically" = "Bestanden automatisch opslaan" +"Save version {key}" = "Versie opslaan {key}" +"Saved" = "Opgeslagen" +"Saved {name}" = "{name} opgeslagen" +"Say something" = "Zeg iets" +"Scale" = "Schalen" +"Scatter" = "Scatter" +"Scoping: where pickers offer it" = "Bereik: waar keuzeschermen hem aanbieden" +"Screen" = "Screen" +"Scroll behavior" = "Scrollgedrag" +"Scroll to" = "Scrollen naar" +"Scroll to layer" = "Naar laag scrollen" +"Scroll with parent" = "Scrollen met bovenliggend" +"Search" = "Zoeken" +"Search actions, layers and tools" = "Zoek naar acties, lagen en tools" +"Search assets" = "Zoek assets" +"Search comments" = "Zoeken in reacties" +"Search files and templates" = "Bestanden en sjablonen zoeken" +"Search fonts" = "Lettertypen zoeken" +"Search layers" = "Lagen doorzoeken" +"Search plugins" = "Zoeken naar plugins" +"Search results" = "Zoekresultaten" +"Search shortcuts" = "Sneltoetsen zoeken" +"Search templates" = "Sjablonen zoeken" +"Section" = "Sectie" +"Section color" = "Sectiekleur" +"Sections" = "Secties" +"See all templates" = "Alle sjablonen bekijken" +"Select a frame and add a starting point to present it as a flow." = "Selecteer een frame en voeg een startpunt toe om het als flow te presenteren." +"Select a frame to preview it" = "Kies een frame voor een voorvertoning" +"Select a layer to see its properties and code" = "Selecteer een laag om de eigenschappen en de code te zien" +"Select all" = "Alles selecteren" +"Select all with same" = "Alles met hetzelfde selecteren" +"Select layer" = "Laag selecteren" +"Select layers and they space themselves as their content changes" = "Selecteer lagen en ze passen hun afstand automatisch aan als de inhoud verandert" +"Select layers to apply the style to" = "Selecteer lagen om de stijl op toe te passen" +"Select layers with this color" = "Lagen met deze kleur selecteren" +"Select overlapping shapes" = "Overlappende vormen selecteren" +"Select points to edit their position, radius and mirroring" = "Selecteer punten om hun positie, radius en spiegeling te bewerken" +"Select slot" = "Kies een slot" +"Selection" = "Selectie" +"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Selectiekader, {w} bij {h} op {x}, {y}: pijlen verplaatsen het, Shift+pijlen of {cmd}+pijlen wijzigen het formaat, Enter selecteert, Escape annuleert" +"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Selectiekader: pijltjes verplaatsen het, Shift+pijltjes of {cmd}+pijltjes vergroten of verkleinen het, Enter selecteert, Escape annuleert" +"Selection colors" = "Kleuren in selectie" +"Send backward" = "Naar achteren sturen" +"Send report" = "Rapport verzenden" +"Send to back" = "Naar achterste brengen" +"Sep" = "sep." +"Server address" = "Serveradres" +"Server address (ws://host:47900/)" = "Serveradres (ws://host:47900/)" +"Server for online sharing" = "Server voor online delen" +"Set as default" = "Als standaard instellen" +"Set variable" = "Variabele instellen" +"Set variable mode" = "Variabelemodus instellen" +"Set variable {name}" = "Variabele {name} instellen" +"Set {name} to {value}" = "{name} instellen op {value}" +"Shadows" = "Schaduwen" +"Shape" = "Vorm" +"Shape builder" = "Shape builder" +"Shape tools" = "Vormgereedschap" +"Share" = "Delen" +"Share this file" = "Dit bestand delen" +"Share with a code" = "Delen met een code" +"Share: live session, export" = "Delen: live-sessie, exporteren" +"Shared" = "Gedeeld" +"Shared: copy its code from the file's menu" = "Gedeeld: kopieer de code uit het menu van het bestand" +"Sharing a file needs the desktop app; here you can join one." = "Een bestand delen vereist de desktop-app; hier kun je aan één deelnemen." +"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Delen op het lokale netwerk (online delen staat uit: er is geen server ingesteld in Voorkeuren)." +"Sharing on your network · {people}" = "Delen via je netwerk · {people}" +"Sharing online · {people}" = "Delen online · {people}" +"Sharing stopped: the file stays on the server, open only to you" = "Delen gestopt: het bestand blijft op de server staan, alleen voor jou toegankelijk" +"Show" = "Weergeven" +"Show all templates" = "Alle sjablonen tonen" +"Show as grid" = "Als raster weergeven" +"Show as list" = "Weergeven als lijst" +"Show autosaves" = "Automatisch opgeslagen versies tonen" +"Show behind transparent areas" = "Weergeven achter transparante gebieden" +"Show comments (Shift+C)" = "Reacties tonen (Shift+C)" +"Show device frame" = "Apparaatframe weergeven" +"Show dimensions on objects" = "Toon afmetingen op objecten" +"Show hotspot hints" = "Hints voor hotspots tonen" +"Show in Explorer" = "Weergeven in Verkenner" +"Show in Finder" = "In Finder weergeven" +"Show in folder" = "In map tonen" +"Show only this on the canvas" = "Alleen dit op het canvas weergeven" +"Show open" = "Openstaande tonen" +"Show section contents" = "Sectie-inhoud weergeven" +"Show this layer" = "Deze laag tonen" +"Show usage" = "Gebruik weergeven" +"Show/Hide" = "Tonen/Verbergen" +"Show/Hide UI" = "UI tonen/verbergen" +"Side by side" = "Naast elkaar" +"Sidebar" = "Zijbalk" +"Simplify stroke" = "Lijn vereenvoudigen" +"Size" = "Grootte" +"Size jitter" = "Groottevariatie" +"Skip ink" = "Skip ink" +"Slashed zero" = "Nul met schuine streep" +"Slice" = "Slice" +"Slices" = "Slices" +"Slide in" = "Inschuiven" +"Slide out" = "Uitschuiven" +"Slot" = "Slot" +"Slot content" = "Slotinhoud" +"Small capitals" = "Kapitelen" +"Small caps" = "Kleine kapitalen" +"Smart animate" = "Smart animate" +"Snap to pixel grid" = "Uitlijnen op pixelraster" +"Social and slides" = "Sociale media en dia's" +"Social media" = "Sociale media" +"Social post sizes" = "Maten voor sociale posts" +"Soft light" = "Soft light" +"Solid" = "Effen" +"Some variants have the same property values: give each one a unique combination" = "Sommige varianten hebben dezelfde eigenschapswaarden: geef elke variant een unieke combinatie" +"Sort" = "Sorteren" +"Sort by" = "Sorteren op" +"Sort by date" = "Op datum sorteren" +"Sort by page" = "Sorteren op pagina" +"Sort by {column}" = "Sorteren op {column}" +"Space around" = "Ruimte rondom" +"Space between" = "Ruimte ertussen" +"Space drag" = "Slepen met de spatiebalk" +"Space evenly" = "Gelijkmatig verdelen" +"Space+drag" = "Space+drag" +"Spacing" = "Afstand" +"Spam, stolen work, abuse…" = "Spam, gestolen werk, misbruik…" +"Splay" = "Splay" +"Spotlight me" = "Spotlight mij" +"Spread" = "Spread" +"Square" = "Vierkant" +"Standard" = "Standaard" +"Standard ligatures" = "Standaardligaturen" +"Star" = "Ster" +"Stars" = "Stars" +"Start" = "Start" +"Start ascending from / stop descending at" = "Begin oplopend vanaf / stop aflopend bij" +"Start from a blank canvas" = "Begin vanaf een leeg canvas" +"Start from a template" = "Begin met een sjabloon" +"Start on path" = "Begin op het pad" +"Start on the Home screen" = "Starten op Home" +"Start point" = "Startpunt" +"Start sharing" = "Delen starten" +"Start sharing with the Share button, then pass on the code it gives" = "Begin met delen via de knop Delen en geef daarna de code door die je krijgt" +"Starting to share…" = "Bezig met delen…" +"Status" = "Status" +"Sticky" = "Sticky" +"Stop" = "Stoppen" +"Stop following {name}" = "Stop met {name} te volgen" +"Stop session" = "Sessie stoppen" +"Stop sharing" = "Stoppen met delen" +"Stop spotlighting" = "Stop met uitlichten" +"Stop {n} {hex}" = "Stop {n} {hex}" +"Stretch" = "Uitrekken" +"Strikethrough" = "Doorstrepen" +"String" = "Tekenreeks" +"Stroke" = "Lijn" +"Stroke style" = "Lijnstijl" +"Stroke weight" = "Lijndikte" +"Strokes" = "Lijnen" +"Style" = "Stijl" +"Styles" = "Stijlen" +"Stylistic set 1" = "Stilistische set 1" +"Stylistic set 2" = "Stylistische set 2" +"Stylistic set 3" = "Stylistische set 3" +"Stylistic set 4" = "Stilistische set 4" +"Stylistic set 5" = "Stilistische set 5" +"Subtract" = "Subtract" +"Subtracts" = "Trekt af" +"Suffix" = "Achtervoegsel" +"Suspend snapping while dragging" = "Uitlijning opschorten tijdens het slepen" +"Swap instance" = "Instance verwisselen" +"Swap library: use another library's assets of the same names" = "Bibliotheek verwisselen: assets met dezelfde namen uit een andere bibliotheek gebruiken" +"Swap orientation" = "Oriëntatie verwisselen" +"Swap overlay" = "Overlay verwisselen" +"Swap to {name}" = "Wisselen naar {name}" +"Swap width and height" = "Breedte en hoogte omwisselen" +"Swapped instance" = "Verwisselde instance" +"Swapped {n} assets" = { one = "{n} asset verwisseld", other = "{n} assets verwisseld" } +"Swapped {n} assets; {missed} had no match and still use the old library" = { one = "{n} asset verwisseld; {missed} had geen overeenkomst en gebruikt nog de oude bibliotheek", other = "{n} assets verwisseld; {missed} hadden geen overeenkomst en gebruiken nog de oude bibliotheek" } +"Sweep" = "Sweep" +"System" = "Systeem" +"Tab / Shift+Tab" = "Tab / Shift+Tab" +"Table" = "Tabel" +"Tablet" = "Tablet" +"Tabular numbers" = "Tabulaire cijfers" +"Take the values from the first selected layer" = "Waarden van de eerste geselecteerde laag overnemen" +"Taper" = "Taper" +"Taper both ends" = "Beide uiteinden versmallen" +"Tell the server's operator what is wrong with it" = "Vertel de beheerder van de server wat er mis mee is" +"Temperature" = "Temperatuur" +"Template" = "Sjabloon" +"Templates" = "Sjablonen" +"Terms" = "Voorwaarden" +"Text" = "Tekst" +"Text align" = "Tekstuitlijning" +"Text content" = "Tekstinhoud" +"Text style" = "Tekststijl" +"Text styles" = "Tekststijlen" +"Texts" = "Teksten" +"Texture" = "Texture" +"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "Het canvas toont de kleuren als voorvertoning in sRGB; geëxporteerde PNG-, JPG- en WebP-bestanden worden gelabeld als Display P3." +"The destination document is unavailable. Nothing was imported." = "Het doeldocument is niet beschikbaar. Er is niets geïmporteerd." +"The destination document was closed. Nothing was imported." = "Het doeldocument is gesloten. Er is niets geïmporteerd." +"The destination page was removed. Nothing was imported." = "De doelpagina is verwijderd. Er is niets geïmporteerd." +"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "Het bestand wordt van de server verwijderd: niemand kan het daar nog openen of eraan deelnemen, en dit kan niet ongedaan worden gemaakt. Kopieën die open zijn, blijven bij wie ze heeft." +"The file now holds another library" = "Het bestand bevat nu een andere bibliotheek" +"The file now holds another library (\"{name}\")" = "Het bestand bevat nu een andere bibliotheek (\"{name}\")" +"The files you shared or published online from this computer. They stay on their server until you remove them." = "De bestanden die je vanaf deze computer online hebt gedeeld of gepubliceerd. Ze blijven op hun server totdat je ze verwijdert." +"The link carries the session's secret: share it only with people you invite." = "De link bevat het geheim van de sessie: deel hem alleen met mensen die je uitnodigt." +"The name in your online files and on the server. The document inside keeps its own name." = "De naam in je online bestanden en op de server. Het document erin behoudt zijn eigen naam." +"The selection uses several text styles: click to apply one" = "De selectie gebruikt meerdere tekststijlen: klik om er een toe te passen" +"The server deletes this file if nobody opens it before {date}." = "De server verwijdert dit bestand als niemand het vóór {date} opent." +"The whole file" = "Het hele bestand" +"Theme" = "Thema" +"There's no destination for that action" = "Er is geen doel voor die actie" +"Thickness" = "Dikte" +"This branch can't be reviewed: {e}" = "Deze branch kan niet worden beoordeeld: {e}" +"This branch was merged or archived." = "Deze branch is samengevoegd of gearchiveerd." +"This computer only" = "Alleen op deze computer" +"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Deze computer onthoudt het eigenaartoken, zodat het bestand beheerd kan worden vanuit de Online-lijst op het beginscherm. Kopieer het om het bestand vanaf een andere plek te beheren: het is het wachtwoord voor dit bestand en wordt niet opnieuw weergegeven." +"This condition is edited as an expression" = "Deze voorwaarde wordt als expressie bewerkt" +"This file" = "Dit bestand" +"This file has unsaved changes" = "Dit bestand heeft niet-opgeslagen wijzigingen" +"This file is on {server}, open only to you. Share it to let others in with a new code." = "Dit bestand staat op {server}, alleen toegankelijk voor jou. Deel het om anderen binnen te laten met een nieuwe code." +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Dit bestand gebruikt lettertypen die niet op deze computer zijn geïnstalleerd. Hun tekst wordt in een vervangend lettertype getoond tot je ze installeert." +"This frame is new on the branch." = "Dit frame is nieuw op de branch." +"This frame isn't on the branch." = "Dit frame staat niet op de branch." +"This server is open to the public: no key needed." = "Deze server is voor iedereen toegankelijk: geen sleutel nodig." +"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Deze server heeft een sleutel nodig: voeg ?key=… toe aan het adres (`figcraft-server key` toont die op de computer van de server)." +"This server takes files only with its key: see Advanced." = "Deze server accepteert bestanden alleen met de bijbehorende sleutel: zie Geavanceerd." +"Tidy up" = "Opruimen" +"Tile" = "Tegelen" +"Tile type" = "Tegeltype" +"Tint" = "Tint" +"Title" = "Titel" +"Title case" = "Hoofdletter per woord" +"To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Om deze bibliotheek met je team te delen, publiceer het bestand naar een server (Delen ▸ Publiceren naar server…), publiceer daarna de bibliotheek." +"Toggle" = "Omschakelen" +"Toggle light/dark theme" = "Wisselen tussen licht/donker thema" +"Toggle visibility" = "Zichtbaarheid omschakelen" +"Tools" = "Gereedschappen" +"Top" = "Boven" +"Top & bottom" = "Boven & onder" +"Top left radius" = "Radius linksboven" +"Top right radius" = "Hoekradius rechtsboven" +"Transform" = "Transformeren" +"Triangle arrow" = "Driehoekspijl" +"Trigger" = "Trigger" +"True" = "Waar" +"Truncate text" = "Tekst afkappen" +"Truncate with an ellipsis" = "Afkappen met een beletselteken" +"Try again" = "Probeer het opnieuw" +"Try another search or filter." = "Probeer een andere zoekopdracht of filter." +"Try another search, or browse templates." = "Probeer een andere zoekopdracht of bekijk de sjablonen." +"Try another word, or look at every kind." = "Probeer een ander woord, of bekijk alle soorten." +"Turn a layer into a component, reuse it, and change every copy at once" = "Maak van een laag een component, hergebruik die en verander alle kopieën tegelijk" +"Two digits" = "Twee cijfers" +"Type" = "Type" +"Type settings" = "Tekstinstellingen" +"Type the join code you were given, or paste an invite link." = "Typ de toegangscode die je hebt gekregen, of plak een uitnodigingslink." +"Typography" = "Typografie" +"Underline" = "Onderstrepen" +"Undo takes back only your own changes; what others changed since stays." = "Ongedaan maken zet alleen je eigen wijzigingen terug; wat anderen sindsdien hebben gewijzigd, blijft staan." +"Ungroup" = "Groep opheffen" +"Uniform" = "Gelijkmatig" +"Union" = "Union" +"Unions" = "Unies" +"Unknown — treated as sRGB" = "Onbekend — behandeld als sRGB" +"Unread" = "Ongelezen" +"Unresolve" = "Oplossing ongedaan maken" +"Unsaved changes" = "Niet-opgeslagen wijzigingen" +"Unsaved changes in {names}. Close the window and lose them?" = "Niet-opgeslagen wijzigingen in {names}. Het venster sluiten en ze verliezen?" +"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Niet-opgeslagen wijzigingen: sla eerst op ({save}), of sluit het tabblad met {alt}-klik om ze te verwerpen" +"Unsaved changes: save first, or close again with {key} to discard" = "Niet-opgeslagen wijzigingen: sla eerst op, of sluit nogmaals met {key} om te verwerpen" +"Up" = "Omhoog" +"Update" = "Bijwerken" +"Update from main" = "Bijwerken vanaf main" +"Update from selection" = "Bijwerken vanuit de selectie" +"Update plugin" = "Plugin bijwerken" +"Updated {components} and {styles}" = "{components} en {styles} bijgewerkt" +"Updated {name}" = "{name} bijgewerkt" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Het bijwerken van de grafische driver helpt vaak. Om alleen OpenGL te gebruiken, start je FigCraft vanaf de Opdrachtprompt met:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Het bijwerken van de grafische driver helpt vaak. WGPU_BACKEND (vulkan, gl, metal) noemt een andere grafische API om te proberen." +"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." = "Uploadt dit bestand naar een FigCraft-server en houdt dit tabblad daarmee gesynchroniseerd. Je krijgt een link om te bewerken en een link om te bekijken." +"Uppercase" = "Hoofdletters" +"Use a fixed value" = "Gebruik een vaste waarde" +"Use an expression, e.g. count + 1" = "Gebruik een expressie, bijv. count + 1" +"Use as mask" = "Gebruiken als masker" +"Use auto layout" = "Auto layout gebruiken" +"Use number keys for opacity" = "Gebruik cijfertoetsen voor dekking" +"Use the .figcraft or .fig extension." = "Gebruik de extensie .figcraft of .fig." +"Use the layer's fill" = "De vulling van de laag gebruiken" +"Used in {files} · {instances}" = "Gebruikt in {files} · {instances}" +"Username" = "Gebruikersnaam" +"Uses variables: each layer shows them in its own mode" = "Gebruikt variabelen: elke laag toont ze in zijn eigen modus" +"Value" = "Waarde" +"Variable" = "Variabele" +"Variable collections" = "Verzamelingen van variabelen" +"Variable modes" = "Variabelemodi" +"Variable width" = "Variabele breedte" +"Variables" = "Variabelen" +"Variables JSON…" = "Variabelen-JSON…" +"Variables store reusable values (colours, numbers, strings, booleans) with modes such as Light and Dark." = "Variabelen slaan herbruikbare waarden (kleuren, getallen, tekenreeksen, booleans) op met modi zoals Light en Dark." +"Variant" = "Variant" +"Vector" = "Vector" +"Vectors" = "Vectoren" +"Version" = "Versie" +"Version history" = "Versiegeschiedenis" +"Version {version}" = "Versie {version}" +"Version {v} · {components} · {styles} · {variables}" = "Versie {v} · {components} · {styles} · {variables}" +"Vertical" = "Verticaal" +"Vertical align" = "Verticaal uitlijnen" +"Vertical hex" = "Verticale hex" +"Vertical padding" = "Verticale padding" +"Vertical spacing" = "Verticale afstand" +"Vertical trim" = "Verticaal bijsnijden" +"View" = "Weergave" +"View link" = "Weergavelink" +"View link copied" = "Weergavelink gekopieerd" +"View only" = "Alleen weergeven" +"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "Alleen weergeven: je ziet elke wijziging live terwijl die plaatsvindt, maar je kunt niet bewerken. Verlaat de sessie om een kopie te bewaren die je kunt bewerken." +"Viewing “{name}”" = "“{name}” bekijken" +"Violet" = "Violet" +"Visibility" = "Zichtbaarheid" +"Visible" = "Zichtbaar" +"Vivid" = "Vivid" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND is ingesteld en geeft de te gebruiken grafische API aan; als je het uitzet, kiest FigCraft zelf." +"Warm" = "Warm" +"Watch" = "Watch" +"Wavy" = "Golvend" +"Web" = "Web" +"WebSocket URL" = "WebSocket-URL" +"WebSocket URL copied" = "WebSocket-URL gekopieerd" +"Websites" = "Websites" +"Wedge" = "Wedge" +"Weight" = "Dikte" +"What changed since it was marked ready for dev" = "Wat er veranderd is sinds het is gemarkeerd als klaar voor Dev Mode" +"What is wrong with this file? The server's operator gets your report." = "Wat klopt er niet aan dit bestand? Je melding gaat naar de beheerder van de server." +"What it's for" = "Waarvoor het dient" +"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." = "Vanwaar dit is veranderd, is niet bekend: het veranderde voordat FigCraft een kopie bewaarde van dev-klare frames. Los de wijziging op (of markeer het weer als te vergelijken) om vanaf nu te kunnen vergelijken." +"What you try here changes this preview and the code below, not the file" = "Wat je hier probeert, wijzigt deze voorvertoning en de code hieronder, niet het bestand" +"While hovering" = "Tijdens het aanwijzen" +"While pressing" = "Tijdens het indrukken" +"White" = "Wit" +"Who runs it, its terms and how long it keeps files" = "Wie het beheert, de voorwaarden en hoelang het bestanden bewaart" +"Why it changed" = "Waarom het is veranderd" +"Widget" = "Widget" +"Widgets" = "Widgets" +"Width" = "Breedte" +"Width point" = "Breedtepunt" +"Width profile" = "Breedteprofiel" +"Wireframe kit" = "Wireframekit" +"With the layers' export settings" = "Met de exportinstellingen van de lagen" +"Wrap" = "Omloop" +"Wrap in new section" = "Inpakken in een nieuwe sectie" +"Yellow" = "Geel" +"Yesterday" = "Gisteren" +"You" = "Jij" +"You'll get the shared file as a new tab and edit it together." = "Je krijgt het gedeelde bestand als nieuw tabblad en bewerkt het samen." +"You're spotlighting: everyone follows your view" = "Je bent in de spotlight: iedereen volgt jouw weergave" +"Your copy of the file stays open" = "Je kopie van het bestand blijft open" +"Your copy stays open; the file stays on the server" = "Jouw kopie blijft open; het bestand blijft op de server" +"Your name" = "Jouw naam" +"Your name in comments" = "Je naam in reacties" +"Your system asks for reduced motion: animations stay off." = "Jouw systeem vraagt om minder beweging: animaties blijven uit." +"Z" = "Z" +"Z to A" = "Z tot A" +"Zoom" = "Zoom" +"Zoom around the pointer" = "Inzoomen rond de aanwijzer" +"Zoom device to fill window" = "Apparaat inzoomen om het venster te vullen" +"Zoom in" = "Inzoomen" +"Zoom out" = "Uitzoomen" +"Zoom to 100%" = "Zoomen naar 100%" +"Zoom to 200%" = "Inzoomen op 200%" +"Zoom to 50%" = "Zoomen naar 50%" +"Zoom to fit" = "Inpassen" +"Zoom to selection" = "Inzoomen op selectie" +"Zoom tool: click to zoom in, drag an area" = "Zoomgereedschap: klik om in te zoomen, sleep om een gebied te selecteren" +"Zoom tool: zoom out" = "Zoomgereedschap: uitzoomen" +"and {n} more" = { one = "en {n} meer", other = "en {n} meer" } +"bleed marks" = "afloopmarkeringen" +"by {author}" = "door {author}" +"call any command its access allows" = "elke opdracht aanroepen die diens toegang toestaat" +"call only: {commands}" = "alleen aanroepbaar: {commands}" +"can edit the document" = "kan het document bewerken" +"conditional prototype actions nested too deeply were skipped" = "Voorwaardelijke prototype-acties die te diep genest zijn, zijn overgeslagen" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "voorwaardelijke prototypeacties met voorwaarden die niet konden worden geëvalueerd zijn overgeslagen" +"connector label background was skipped: style data is missing" = "achtergrond van connectorlabel overgeslagen: stijldata ontbreekt" +"connector label was skipped: background bounds are invalid" = "connectorlabel is overgeslagen: achtergrondgrenzen zijn ongeldig" +"connector label was skipped: background transform is invalid" = "connector labels overgeslagen: achtergrondtransformatie is ongeldig" +"connector label was skipped: composed text transform is invalid" = "connectorlabel is overgeslagen: samengestelde teksttransformatie is ongeldig" +"connector label was skipped: derived background bounds are missing" = "connectorlabel is overgeslagen: afgeleide achtergrondgrenzen ontbreken" +"connector label was skipped: derived text bounds are missing" = "connectorlabel is overgeslagen: afgeleide tekstgrenzen ontbreken" +"connector label was skipped: text bounds are invalid" = "connectorlabel overgeslagen: de tekstafmetingen zijn ongeldig" +"connector label was skipped: text style data is missing" = "connectorlabel is overgeslagen: tekststijlgegevens ontbreken" +"connector label was skipped: text transform is invalid" = "Het label van de verbinding is overgeslagen: de teksttransformatie is ongeldig" +"connector labels were skipped" = "labels van connectoren zijn overgeslagen" +"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "connectorlagen worden geïmporteerd als statische frame-/vectormomentopnames met labels indien aanwezig; live eindpuntkoppelingen worden niet behouden" +"connector snapshot vector data is missing; connector was left empty" = "vectordata in de connector-snapshot ontbreekt; connector is leeggelaten" +"connectors whose ends can't be placed are empty" = "connectors waarvan de uiteinden niet geplaatst kunnen worden zijn leeg" +"crop marks" = "snijmarkeringen" +"damaged vector geometry was skipped" = "beschadigde vectorgeometrie is overgeslagen" +"damaged vector networks import as outlines" = "beschadigde vectornetwerken worden geïmporteerd als outlines" +"development: {path}" = "ontwikkeling: {path}" +"document children other than pages were skipped" = "documentkinderen anders dan pagina's zijn overgeslagen" +"duplicate grid track sizing entries use equal tracks" = "Dubbele vermeldingen voor trackgroottes gebruiken gelijke tracks" +"e.g. count + 1" = "bijv. count + 1" +"e.g. count >= 3 and not done" = "bijv. aantal >= 3 en niet klaar" +"elbowed and curved connectors without a saved path import as straight lines" = "haakse en gebogen connectors zonder opgeslagen pad worden geïmporteerd als rechte lijnen" +"entries without a node type were skipped" = "items zonder node-type zijn overgeslagen" +"figcraft:// links from a browser, chat or email open this app and join the session." = "figcraft://-links uit een browser, chat of e-mail openen deze app en laten je deelnemen aan de sessie." +"filled areas of a vector with their own fill use the layer's fill" = "gevulde gebieden van een vector met een eigen vulling gebruiken de vulling van de laag" +"from {server}" = "van {server}" +"gamepad triggers without exactly one known controller button were skipped" = "gamepad-triggers zonder precies één bekende controllerknop zijn overgeslagen" +"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "de lijst met rastertracks overschrijdt 1000 items; de tracks gebruiken de standaard gelijke grootte" +"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "Trackgrootte wordt niet ondersteund of is ongeldig opgemaakt; de betreffende tracks gebruiken gelijke groottes" +"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "de lijst met maten voor rastertracks bevat meer dan 1000 items; de tracks gebruiken de standaard gelijke maat" +"hidden" = "verborgen" +"iOS" = "iOS" +"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "afbeeldingsfilters zijn dubbelzinnig omdat beide filtercontainers aanwezig zijn; filterconversie is overgeslagen" +"image filters use FigCraft's approximate renderer" = "afbeeldingsfilters gebruiken de benaderende renderer van FigCraft" +"instance swap properties whose default component isn't in the file were skipped" = "instance-swap-eigenschappen waarvan de standaardcomponent niet in het bestand zit, zijn overgeslagen" +"instances inside their own main component were imported as frames" = "instances in hun eigen hoofdcomponent zijn geïmporteerd als frames" +"instances of components that aren't in the file show no layers" = "instances van components die niet in het bestand staan, tonen geen lagen" +"instances of components that aren't in the file were imported as frames" = "instances van componenten die het bestand niet bevat zijn als frames geïmporteerd" +"instances that would nest a component inside itself were imported as frames" = "Instanties die een component in zichzelf zouden nesten, zijn als frames geïmporteerd" +"instances without a main component were imported as empty frames" = "instances zonder hoofdcomponent zijn geïmporteerd als lege frames" +"invalid internal .fig text path data was ignored" = "ongeldige padgegevens van interne .fig-tekst zijn genegeerd" +"invalid or oversized vector networks import as outlines" = "ongeldige of te grote vectornetwerken worden geïmporteerd als contouren" +"invalid or oversized vector path data was skipped" = "ongeldige of te grote vectorpadgegevens zijn overgeslagen" +"just now" = "zojuist" +"key triggers without a supported key were skipped" = "key-triggers zonder een ondersteunde toets zijn overgeslagen" +"key up triggers import as key presses" = "key-up-triggers worden geïmporteerd als toetsaanslagen" +"layers without a position were placed at their parent's origin" = "lagen zonder positie zijn op de oorsprong van het bovenliggende element geplaatst" +"layers without a type were skipped" = "lagen zonder type zijn overgeslagen" +"legacy image filter container is unsupported; filter conversion was skipped" = "Verouderde container voor beeldfilters wordt niet ondersteund; filterconversie is overgeslagen" +"locked" = "vergrendeld" +"malformed image filters container was skipped" = "misvormde container met afbeeldingsfilters is overgeslagen" +"malformed image paintFilter container was skipped" = "onjuist gevormde image paintFilter-container is overgeslagen" +"malformed slot content assignment was skipped" = "onjuiste toewijzing van slot-inhoud is overgeslagen" +"no bleed" = "geen afloop" +"nobody else here yet" = "nog niemand anders hier" +"non-default slot settings are not represented and were skipped" = "niet-standaard slot-instellingen zijn niet overgenomen en zijn overgeslagen" +"now" = "nu" +"only in a text style or variable" = "alleen in een tekststijl of variabele" +"or" = "of" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "Lijnverbindingen op punten zijn behouden; gestreepte lijnen of lijnen met variabele breedte worden weergegeven met de verbinding van de laag" +"preferred slot component keys cannot be resolved to file nodes and were skipped" = "voorkeurssleutels van slotcomponenten konden niet naar bestandsknooppunten worden herleid en zijn overgeslagen" +"prototype actions of unknown types were skipped" = "prototypeacties van onbekende typen zijn overgeslagen" +"prototype actions without a destination in the file were skipped" = "prototype-acties zonder bestemming in het bestand zijn overgeslagen" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "prototype-expressies over variabelen die niet in de variables-JSON staan zijn overgeslagen" +"prototype expressions with unsupported functions were skipped" = "prototype-expressies met niet-ondersteunde functies zijn overgeslagen" +"prototype interactions without supported actions were skipped" = "prototype-interacties zonder ondersteunde acties zijn overgeslagen" +"prototype navigations of unknown types were skipped" = "prototype-navigaties van onbekende typen zijn overgeslagen" +"prototype transitions of unknown types play instantly" = "prototype-overgangen van onbekende typen worden direct afgespeeld" +"prototype triggers of unknown types were skipped" = "prototype-triggers van onbekende typen zijn overgeslagen" +"put a widget on the canvas" = "een widget op het canvas zetten" +"read and edit the document" = "het document lezen en bewerken" +"read the document" = "lees het document" +"reads the document" = "leest het document" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "set-variable-acties op variabelen die niet in de variables-JSON staan zijn overgeslagen" +"set-variable actions with values that can't be evaluated were skipped" = "set-variable-acties met waarden die niet kunnen worden geëvalueerd zijn overgeslagen" +"set-variable actions with values that don't fit the variable were skipped" = "set-variable-acties met waarden die niet in de variabele passen, zijn overgeslagen" +"set-variable actions without a value were skipped" = "set-variable-acties zonder waarde zijn overgeslagen" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "set-variable-mode-acties op verzamelingen of modi die niet in de variables JSON staan, zijn overgeslagen" +"slot content assignment target is missing or is not slot content" = "het toewijzingsdoel van slotcontent ontbreekt of is geen slotcontent" +"slot content assignment uses the empty GUID sentinel and was skipped" = "Toewijzing van slot-inhoud gebruikt de lege GUID-sentinelwaarde en is overgeslagen" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "sommige vectorpunten verwijzen naar puntinstellingen die ontbreken in het bestand; ze gebruiken de verbindingen van de laag en gewone hoeken" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "sommige vectorsegmenten verwijzen naar stijlinstellingen die in het bestand ontbreken; ze gebruiken de lijn van de laag" +"stale or invalid grid track sizing entries were ignored" = "verouderde of ongeldige items voor de trackgroottes van het raster zijn genegeerd" +"stars and polygons without point counts or geometry use the default point count" = "sterren en veelhoeken zonder puntaantallen of geometrie gebruiken het standaardpuntaantal" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "stijlinstellingen van sommige vectorsegmenten worden niet ondersteund; die segmenten gebruiken de lijn van de laag" +"text path underline or strikethrough decoration is not rendered" = "onderstreep- of doorhaaldecoratie op een tekstpad wordt niet weergegeven" +"text style overrides refer to missing table entries" = "tekststijl-overschrijvingen verwijzen naar ontbrekende tabelitems" +"the file has no pages" = "het bestand heeft geen pagina's" +"the plugin" = "de plugin" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "De variabelen-JSON bevat geen `variableCollections` (de respons van lokale variabelen was verwacht) en is genegeerd" +"this session only" = "alleen voor deze sessie" +"unknown component property types were skipped" = "componenteigenschappen van onbekende typen zijn overgeslagen" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "variabele-aliassen naar variabelen die niet in de variables JSON zitten (of van een ander type zijn) zijn overgeslagen" +"variable bindings to properties the layer doesn't have were skipped" = "variabelekoppelingen naar eigenschappen die de laag niet heeft zijn overgeslagen" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "variabelekoppelingen naar variabelen die niet in de variabelen-JSON zitten (bibliotheekvariabelen) zijn overgeslagen" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "variabelekoppelingen zijn overgeslagen: de bestands-JSON bevat geen variabelendefinities (geef de JSON van lokale variabelen mee als `variables`, of voeg de `meta` ervan naast de bestands-JSON in)" +"variable bindings whose variable type doesn't fit the property were skipped" = "variabelekoppelingen waarvan het variabeletype niet bij de eigenschap past zijn overgeslagen" +"variable collections without modes were skipped" = "variabeleverzamelingen zonder modi zijn overgeslagen" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "opzoekingen in prototype-expressies naar andere combinaties dan een variabele en een van zijn modi zijn overgeslagen" +"variable modes of collections that aren't in the file were skipped" = "Variabelemodi van verzamelingen die niet in het bestand staan, zijn overgeslagen" +"variable modes of collections that aren't in the variables JSON were skipped" = "variabelemodi van verzamelingen die niet in de variables-JSON staan zijn overgeslagen" +"variable values that don't match their type (or composed colours) were skipped" = "waarden van variabelen die niet bij hun type passen (of samengestelde kleuren) zijn overgeslagen" +"variables of unknown types were skipped" = "variabelen van onbekende typen zijn overgeslagen" +"variables whose collection isn't in the variables JSON were skipped" = "variabelen waarvan de verzameling niet in de variables-JSON zit zijn overgeslagen" +"variables without readable values were skipped" = "variabelen zonder leesbare waarde zijn overgeslagen" +"vector points with unknown stroke joins use the layer join" = "vectorpunten met onbekende lijnverbindingen gebruiken de verbinding van de laag" +"vectors without geometry are empty" = "vectoren zonder geometrie zijn leeg" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "vectoren zonder geometrie zijn leeg (exporteer de bestands-JSON met geometry=paths)" +"very long text was cut to 1,048,576 characters" = "zeer lange tekst is ingekort tot 1.048.576 tekens" +"very long vector paths were skipped" = "Zeer lange vectorpaden zijn overgeslagen" +"video actions are not supported and were skipped" = "videoacties worden niet ondersteund en zijn overgeslagen" +"video triggers are not supported and were skipped" = "videotriggers worden niet ondersteund en zijn overgeslagen" +"yesterday" = "gisteren" +"{instances} in {files}" = "{instances} in {files}" +"{language}: draft translation, waiting for a native speaker's review." = "{language}: voorlopige vertaling, wacht op controle door een moedertaalspreker." +"{length} bleed" = "{length} afloop" +"{month} {day}, {year}" = "{day} {month} {year}" +"{name} (from {library})" = "{name} (uit {library})" +"{name} (you)" = "{name} (jij)" +"{name} ({layers} layers)" = "{name} ({layers} lagen)" +"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name} ({tag}): {min} – {max}, standaard {default}. Dubbelklik op de schuifregelaar om terug te gaan naar de waarde van de stijl; Escape tijdens het slepen annuleert." +"{name} cleared from the list" = "{name} uit de lijst verwijderd" +"{name} from {path}" = "{name} uit {path}" +"{name} is forgotten on this computer; the file stays on {server}" = "{name} is op deze computer vergeten; het bestand blijft op {server}" +"{name} is on the server." = "{name} staat op de server." +"{name} is spotlighting" = "{name} staat in de spotlight" +"{name} is too large to open in the browser (over 512 MB)." = "{name} is te groot om in de browser te openen (meer dan 512 MB)." +"{name} was removed from the server" = "{name} is van de server verwijderd" +"{name} · deleted on main" = "{name} · verwijderd op main" +"{name} · deleted on the branch" = "{name} · verwijderd op de branch" +"{name} · not published" = "{name} · niet gepubliceerd" +"{name} · on a server" = "{name} · op een server" +"{name} · published version {v}" = "{name} · gepubliceerde versie {v}" +"{name} · version {v} on the server" = "{name} · versie {v} op de server" +"{name}, in a live session" = "{name}, in een live sessie" +"{name}, online, open only to you" = "{name}, online, alleen voor jou toegankelijk" +"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} bij {h}, geselecteerd" +"{name}\nClick to apply to the selection" = "{name}\nKlik om op de selectie toe te passen" +"{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}\nSleep naar het canvas, of klik om in het midden van de weergave te plaatsen.\nHoud Alt (Option) ingedrukt terwijl je op een instance loslaat om die te verwisselen." +"{n} added" = { one = "{n} toegevoegd", other = "{n} toegevoegd" } +"{n} autosave versions" = { one = "{n} automatisch opgeslagen versie", other = "{n} automatisch opgeslagen versies" } +"{n} changed" = { one = "{n} gewijzigd", other = "{n} gewijzigd" } +"{n} columns" = { one = "{n} kolom", other = "{n} kolommen" } +"{n} components" = { one = "{n} component", other = "{n} componenten" } +"{n} components and styles have newer versions" = { one = "{n} component of stijl heeft een nieuwere versie", other = "{n} components en stijlen hebben nieuwere versies" } +"{n} conflicts to resolve" = { one = "{n} conflict om op te lossen", other = "{n} conflicten om op te lossen" } +"{n} days ago" = { one = "{n} dag geleden", other = "{n} dagen geleden" } +"{n} files" = { one = "{n} bestand", other = "{n} bestanden" } +"{n} hours ago" = { one = "{n} uur geleden", other = "{n} uur geleden" } +"{n} instances" = { one = "{n} instance", other = "{n} instances" } +"{n} layers" = { one = "{n} laag", other = "{n} lagen" } +"{n} layers as one file" = { one = "{n} laag als één bestand", other = "{n} lagen als één bestand" } +"{n} layers selected" = { one = "{n} laag geselecteerd", other = "{n} lagen geselecteerd" } +"{n} layers · {parts}" = { one = "{n} laag · {parts}", other = "{n} lagen · {parts}" } +"{n} matches" = { one = "{n} overeenkomst", other = "{n} overeenkomsten" } +"{n} minutes ago" = { one = "{n} minuut geleden", other = "{n} minuten geleden" } +"{n} months ago" = { one = "{n} maand geleden", other = "{n} maanden geleden" } +"{n} more notes omitted from this report" = { one = "{n} extra notitie weggelaten uit dit rapport", other = "{n} extra notities weggelaten uit dit rapport" } +"{n} of {total}" = "{n} van {total}" +"{n} people joined" = { one = "{n} persoon is erbij gekomen", other = "{n} personen zijn erbij gekomen" } +"{n} preferred components" = { one = "{n} voorkeurscomponent", other = "{n} voorkeurscomponenten" } +"{n} ready" = { one = "{n} klaar", other = "{n} klaar" } +"{n} removed" = { one = "{n} verwijderd", other = "{n} verwijderd" } +"{n} replies" = { one = "{n} antwoord", other = "{n} antwoorden" } +"{n} rows" = { one = "{n} rij", other = "{n} rijen" } +"{n} styles" = { one = "{n} stijl", other = "{n} stijlen" } +"{n} text layers" = { one = "{n} tekstlaag", other = "{n} tekstlagen" } +"{n} variables" = { one = "{n} variabele", other = "{n} variabelen" } +"{n} weeks ago" = { one = "{n} week geleden", other = "{n} weken geleden" } +"{n} years ago" = { one = "{n} jaar geleden", other = "{n} jaar geleden" } +"{n}d" = "{n}d" +"{n}h" = "{n}h" +"{n}m" = "{n}m" +"{n}w" = "{n}w" +"{page}, nothing selected" = "{page}, niets geselecteerd" +"{profile} (current)" = "{profile} (huidig)" +"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} is de waarde ervan; {property ? \"a\" : \"b\"} kiest een string. Andere {…} blijven letterlijk staan." +"{server} can't say what became of your files (it is older than this app)." = "{server} kan niet zeggen wat er met je bestanden is gebeurd (deze is ouder dan deze app)." +"{server} · No longer on the server" = "{server} · Niet meer op de server" +"{server} · {shared} · Opened {date}" = "{server} · {shared} · Geopend op {date}" +"{tool} hold {key}" = "{tool} {key} ingedrukt houden" +"{value} isn't a colour" = "{value} is geen kleur" +"{when} · edited" = "{when} · bewerkt" +"←" = "←" +"← ↑ → ↓" = "← ↑ → ↓" +"← → ↑ ↓" = "← → ↑ ↓" +"→, Space or N" = "→, Spatie of N" +"↵" = "↵" +"⇥ / ⇧⇥" = "⇥ / ⇧⇥" +"⌘ arrows" = "⌘ arrows" +"⌘ click" = "⌘ click" +"⌘ scroll" = "⌘ scroll" +"⌥ drag" = "⌥ drag" + +[commands] +"align.bottom" = "Onder uitlijnen" +"align.hcenter" = "Horizontale middens uitlijnen" +"align.left" = "Links uitlijnen" +"align.right" = "Rechts uitlijnen" +"align.top" = "Boven uitlijnen" +"align.vcenter" = "Verticale middens uitlijnen" +"annotation.add" = "Annotatie toevoegen" +"annotation.addCategory" = "Annotatiecategorie toevoegen" +"annotation.categories" = "Annotatiecategorieën" +"annotation.edit" = "Annotatie bewerken" +"annotation.editCategory" = "Annotatiecategorie bewerken" +"annotation.list" = "Annotaties weergeven" +"annotation.remove" = "Annotatie verwijderen" +"annotation.removeCategory" = "Annotatiecategorie verwijderen" +"app.actions" = "Acties" +"app.preferences" = "Voorkeuren" +"arrange.back" = "Achteraan plaatsen" +"arrange.backward" = "Naar achteren sturen" +"arrange.forward" = "Naar voren brengen" +"arrange.front" = "Vooraan plaatsen" +"arrange.packH" = "Horizontaal samenpakken" +"arrange.packV" = "Verticaal stapelen" +"arrange.roundToPixels" = "Afronden op pixels" +"arrange.spacing" = "Afstand" +"arrange.tidy" = "Opruimen" +"bool.exclude" = "Exclude toepassen op selectie" +"bool.flatten" = "Boolean afvlakken" +"bool.intersect" = "Intersect toepassen op selectie" +"bool.subtract" = "Selectie aftrekken" +"bool.union" = "Union toepassen op selectie" +"branch.archive" = "Branch archiveren" +"branch.create" = "Branch maken" +"branch.list" = "Branches weergeven" +"branch.merge" = "Branch samenvoegen" +"branch.open" = "Branch openen" +"branch.review" = "Branch beoordelen" +"branch.updateFromMain" = "Bijwerken vanaf main" +"codeConnect.export" = "Codeverbindingen exporteren" +"codeConnect.import" = "Codekoppelingen importeren" +"codeConnect.remove" = "Koppeling met code verbreken" +"codeConnect.set" = "Aan code koppelen" +"collab.cursor" = "Cursor weergeven op" +"collab.cursorChat" = "Cursorchat" +"collab.diff" = "Syncwijzigingen weergeven" +"collab.files" = "Jouw online bestanden" +"collab.follow" = "Volgen" +"collab.forgetFile" = "Online bestand vergeten" +"collab.host" = "Live sessie starten" +"collab.join" = "Deelnemen aan live sessie" +"collab.leave" = "Live sessie verlaten" +"collab.manageFile" = "Gepubliceerd bestand beheren" +"collab.openFile" = "Online bestand openen" +"collab.peers" = "Mensen in dit bestand" +"collab.publish" = "Publiceren op server" +"collab.recoverFile" = "Online bestand herstellen" +"collab.registerLinks" = "Uitnodigingslinks openen in FigCraft" +"collab.removePerson" = "Uit sessie verwijderen" +"collab.report" = "Bestand melden bij serverbeheerder" +"collab.serverInfo" = "Serverinformatie" +"collab.share" = "Delen met een deelnamecode" +"collab.spotlight" = "Mij in de spotlight zetten" +"collab.stop" = "Livesessie stoppen" +"command.batch" = "Meerdere opdrachten uitvoeren" +"comment.add" = "Reactie toevoegen" +"comment.delete" = "Reactie verwijderen" +"comment.edit" = "Reactie bewerken" +"comment.link" = "Link naar reactie kopiëren" +"comment.list" = "Reacties weergeven" +"comment.markRead" = "Reactie als gelezen markeren" +"comment.move" = "Reactie verplaatsen" +"comment.next" = "Volgende reactie" +"comment.open" = "Reactie openen" +"comment.people" = "Personen in reacties" +"comment.react" = "Op reactie reageren" +"comment.reply" = "Op reactie antwoorden" +"comment.resolve" = "Reactie oplossen" +"comment.zoom" = "Inzoomen op reacties" +"component.addProperty" = "Componenteigenschap toevoegen" +"component.addVariant" = "Variant toevoegen" +"component.bindProperty" = "Laag aan eigenschap koppelen" +"component.combineVariants" = "Combineren als varianten" +"component.create" = "Component maken" +"component.createMultiple" = "Meerdere componenten maken" +"component.deleteProperty" = "Componenteigenschap verwijderen" +"component.detach" = "Instance loskoppelen" +"component.editProperty" = "Componenteigenschap bewerken" +"component.exposeInstance" = "Geneste instance zichtbaar maken" +"component.goToMain" = "Naar hoofdcomponent gaan" +"component.hideWhenPublishing" = "Verbergen bij publiceren" +"component.list" = "Componenten weergeven" +"component.makeSlot" = "Slot maken" +"component.playground" = "Componentplayground" +"component.properties" = "Componenteigenschappen" +"component.pushChanges" = "Wijzigingen naar hoofdcomponent pushen" +"component.removeSlot" = "Slot verwijderen" +"component.resetInstance" = "Alle wijzigingen herstellen" +"component.resetOverride" = "Overschrijving resetten" +"component.restoreMain" = "Hoofdcomponent terugzetten" +"dev.compare" = "Wijzigingen vergelijken" +"dev.focus" = "Focusweergave" +"dev.resolveChange" = "Wijziging oplossen" +"dev.unfocus" = "Focusweergave verlaten" +"devResource.add" = "Dev-resource toevoegen" +"devResource.edit" = "Dev-resource bewerken" +"devResource.list" = "Dev-resources weergeven" +"devResource.remove" = "Dev-resource verwijderen" +"distribute.horizontal" = "Horizontale afstand verdelen" +"distribute.vertical" = "Verticale afstand verdelen" +"doc.home" = "Home" +"doc.list" = "Documenten weergeven" +"doc.nextTab" = "Volgend tabblad" +"doc.previousTab" = "Vorig tabblad" +"doc.switch" = "Document wisselen" +"edit.copy" = "Kopiëren" +"edit.copyAsCss" = "Kopiëren als CSS" +"edit.copyAsPng" = "Kopiëren als PNG" +"edit.copyAsSvg" = "Kopiëren als SVG" +"edit.copyAsText" = "Kopiëren als tekst" +"edit.copyLink" = "Link naar selectie kopiëren" +"edit.copyProperties" = "Eigenschappen kopiëren" +"edit.cut" = "Knippen" +"edit.delete" = "Verwijderen" +"edit.duplicate" = "Dupliceren" +"edit.findReplace" = "Zoeken en vervangen" +"edit.paste" = "Plakken" +"edit.pasteDesignLayers" = "Ontwerplagen plakken" +"edit.pasteOver" = "Plakken over selectie" +"edit.pasteProperties" = "Eigenschappen plakken" +"edit.pasteReplace" = "Plakken ter vervanging" +"edit.pickColor" = "Kleur oppikken" +"edit.redo" = "Opnieuw" +"edit.repeat" = "Laatste opdracht herhalen" +"edit.setDefaultProperties" = "Standaardeigenschappen instellen" +"edit.undo" = "Ongedaan maken" +"effect.add" = "Effect toevoegen" +"effect.move" = "Effect verplaatsen" +"effect.remove" = "Effect verwijderen" +"effect.set" = "Effect instellen" +"export.add" = "Exportinstelling toevoegen" +"export.remove" = "Exportinstelling verwijderen" +"export.update" = "Exportinstelling bewerken" +"file.close" = "Tabblad sluiten" +"file.colorProfile" = "Kleurprofiel" +"file.duplicate" = "Bestand dupliceren" +"file.export" = "Exporteren" +"file.exportFramesPdf" = "Frames exporteren naar PDF" +"file.exportLayers" = "Lagen exporteren" +"file.exportSvg" = "Selectie exporteren als SVG" +"file.import" = "Importeren" +"file.moveTab" = "Tabblad verplaatsen" +"file.new" = "Nieuw ontwerpbestand" +"file.newFromTemplate" = "Nieuw vanuit sjabloon" +"file.open" = "Openen" +"file.openRecent" = "Onlangs geopende bestanden" +"file.placeImage" = "Afbeelding plaatsen" +"file.recover" = "Bestand herstellen" +"file.rename" = "Bestand hernoemen" +"file.revert" = "Terugdraaien naar opgeslagen versie" +"file.save" = "Opslaan" +"file.saveAs" = "Opslaan als" +"file.saveVersion" = "Opslaan in versiegeschiedenis" +"file.versionHistory" = "Versiegeschiedenis weergeven" +"fill.add" = "Vulling toevoegen" +"fill.move" = "Vulling verplaatsen" +"fill.pattern" = "Patroonvulling" +"fill.remove" = "Vulling verwijderen" +"fill.set" = "Vulling instellen" +"frame.create" = "Frame maken" +"frame.presets" = "Frame-voorinstellingen weergeven" +"frame.swapOrientation" = "Oriëntatie omwisselen" +"grid.add" = "Layoutraster toevoegen" +"grid.move" = "Layoutraster verplaatsen" +"grid.remove" = "Layoutraster verwijderen" +"guide.add" = "Hulplijn toevoegen" +"guide.clear" = "Alle hulplijnen verwijderen" +"guide.move" = "Hulplijn verplaatsen" +"guide.remove" = "Hulplijn verwijderen" +"guide.select" = "Hulplijn selecteren" +"help.about" = "Over FigCraft" +"help.gettingStarted" = "Aan de slag" +"help.page" = "Helppagina" +"help.shortcuts" = "Sneltoetsen" +"image.adjust" = "Afbeelding aanpassen" +"image.deletePreset" = "Afbeeldingspreset verwijderen" +"image.place" = "Afbeelding plaatsen" +"image.presets" = "Afbeeldingsvoorinstellingen" +"image.savePreset" = "Voorinstelling voor afbeelding opslaan" +"inspect.assets" = "Assets inspecteren" +"inspect.code" = "Code voor laag" +"inspect.componentCode" = "Componentcode" +"inspect.properties" = "Eigenschappen inspecteren" +"inspect.readyForDev" = "Ready for dev" +"instance.create" = "Instance maken" +"instance.exposed" = "Zichtbare geneste instances" +"instance.place" = "Instance plaatsen" +"instance.resetSlot" = "Slot herstellen" +"instance.setProperties" = "Eigenschappen van instance instellen" +"instance.slotInsert" = "In slot invoegen" +"instance.swap" = "Instance verwisselen" +"instance.swapAt" = "Instance op punt verwisselen" +"layers.collapseAll" = "Lagen inklappen" +"layout.add" = "Auto layout toevoegen" +"layout.remove" = "Auto layout verwijderen" +"layout.setTrack" = "Grootte van rastertrack instellen" +"layout.suggest" = "Auto layout voorstellen" +"libraries.open" = "Bibliotheken" +"libraries.publish" = "Bibliotheek publiceren…" +"library.acceptUpdates" = "Bibliotheekupdates accepteren" +"library.add" = "Bibliotheek toevoegen" +"library.aliasVariable" = "Alias van bibliotheekvariabele maken" +"library.applyStyle" = "Bibliotheekstijl toepassen" +"library.bindVariable" = "Bibliotheekvariabele koppelen" +"library.extendCollection" = "Bibliotheekverzameling uitbreiden" +"library.import" = "Bibliotheekcomponent importeren" +"library.importVariable" = "Bibliotheekvariabele importeren" +"library.list" = "Bibliotheken weergeven" +"library.place" = "Bibliotheekcomponent plaatsen" +"library.publish" = "Bibliotheek publiceren" +"library.publishToServer" = "Bibliotheek naar server publiceren" +"library.remove" = "Bibliotheek verwijderen" +"library.setVariableMode" = "Modus van bibliotheekvariabele instellen" +"library.swap" = "Bibliotheek verwisselen" +"library.swapAt" = "Instance verwisselen voor bibliotheekcomponent" +"library.updates" = "Bibliotheekupdates" +"library.usage" = "Bibliotheekgebruik" +"measure.distances" = "Afstanden meten" +"measure.padding" = "Padding van auto layout meten" +"node.create" = "Laag maken" +"node.get" = "Laag ophalen" +"node.reorder" = "Laag herordenen" +"node.reparent" = "Laag verplaatsen naar" +"node.set" = "Eigenschappen instellen" +"object.batchRename" = "Batch hernoemen" +"object.convertToFrame" = "Converteren naar frame" +"object.convertToGroup" = "Converteren naar groep" +"object.convertToSection" = "Converteren naar sectie" +"object.flipH" = "Horizontaal spiegelen" +"object.flipV" = "Verticaal spiegelen" +"object.frameSelection" = "Selectie framen" +"object.group" = "Selectie groeperen" +"object.hideOthers" = "Overige lagen verbergen" +"object.mask" = "Als masker gebruiken" +"object.moveToPage" = "Verplaatsen naar pagina" +"object.opacityKeys" = "Dekking via cijfertoetsen" +"object.rasterize" = "Selectie rasteriseren" +"object.releaseMask" = "Masker opheffen" +"object.removeFill" = "Vulling verwijderen" +"object.removeFrame" = "Frame verwijderen" +"object.removeStroke" = "Lijn verwijderen" +"object.rename" = "Hernoemen" +"object.resizeToFit" = "Formaat aanpassen aan inhoud" +"object.rotate180" = "180° draaien" +"object.rotateLeft" = "90° naar links draaien" +"object.rotateRight" = "90° naar rechts draaien" +"object.setThumbnail" = "Instellen als miniatuur" +"object.swapFillStroke" = "Vulling en lijn omwisselen" +"object.toggleLock" = "Selectie vergrendelen/ontgrendelen" +"object.toggleVisible" = "Selectie tonen/verbergen" +"object.ungroup" = "Selectie ontgroeperen" +"object.wrapInSection" = "In nieuwe sectie plaatsen" +"page.add" = "Pagina toevoegen" +"page.delete" = "Pagina verwijderen" +"page.duplicate" = "Pagina dupliceren" +"page.list" = "Pagina's weergeven" +"page.move" = "Pagina verplaatsen" +"page.rename" = "Pagina hernoemen" +"page.select" = "Naar pagina gaan" +"page.setBackground" = "Pagina-achtergrond" +"paint.documentColors" = "Documentkleuren" +"plugin.close" = "Pluginpaneel sluiten" +"plugin.directory.install" = "Plugin installeren vanuit map" +"plugin.directory.list" = "Plugincatalogus doorbladeren" +"plugin.directory.publish" = "Plugin naar catalogus publiceren" +"plugin.directory.unpublish" = "Publicatie van plugin in catalogus ongedaan maken" +"plugin.event" = "Bedieningselement in pluginpaneel gebruiken" +"plugin.import" = "Plugin importeren vanuit manifest…" +"plugin.list" = "Plugins weergeven" +"plugin.remove" = "Plugin verwijderen" +"plugin.run" = "Plugin uitvoeren" +"plugin.ui" = "Pluginpaneel" +"plugins.manage" = "Plugins beheren…" +"plugins.runLast" = "Laatste plugin uitvoeren" +"pref.autoSave" = "Bestanden automatisch opslaan" +"pref.highlightHover" = "Lagen markeren bij aanwijzen" +"pref.keepTool" = "Gereedschap geselecteerd houden na gebruik" +"pref.language" = "Interfacetaal" +"pref.nudge" = "Stapafstand" +"pref.numberKeysOpacity" = "Cijfertoetsen gebruiken voor dekking" +"pref.publishServer" = "Server voor online delen" +"pref.reduceMotion" = "Beweging verminderen" +"pref.renameDuplicates" = "Gedupliceerde lagen hernoemen" +"pref.showDimensions" = "Afmetingen op objecten tonen" +"pref.startOnHome" = "Starten op Home" +"pref.userName" = "Je naam in reacties" +"proto.action.add" = "Prototypeactie toevoegen" +"proto.action.move" = "Volgorde van prototypeactie wijzigen" +"proto.action.remove" = "Prototype-actie verwijderen" +"proto.action.set" = "Prototype-actie bewerken" +"proto.closePreview" = "Prototype-voorvertoning sluiten" +"proto.conditional.addBlock" = "Else if / else toevoegen" +"proto.conditional.removeBlock" = "if / else if / else verwijderen" +"proto.conditional.setCondition" = "Voorwaarde bewerken" +"proto.evaluate" = "Prototype-expressie evalueren" +"proto.flow.add" = "Startpunt van flow toevoegen" +"proto.flow.remove" = "Flow verwijderen" +"proto.flow.rename" = "Flow hernoemen" +"proto.interaction.add" = "Interactie toevoegen" +"proto.interaction.clear" = "Alle interacties verwijderen" +"proto.interaction.remove" = "Interactie verwijderen" +"proto.interaction.set" = "Interactie bewerken" +"proto.present" = "Presenteren" +"proto.presentOptions" = "Presentatieopties" +"proto.preview" = "Prototypevoorvertoning openen" +"proto.settings" = "Prototype-instellingen" +"section.toggleContents" = "Sectie-inhoud verbergen/weergeven" +"select.add" = "Toevoegen aan selectie" +"select.all" = "Alles selecteren" +"select.area" = "Gebied selecteren" +"select.byName" = "Lagen op naam selecteren" +"select.children" = "Onderliggende items selecteren" +"select.inverse" = "Selectie omkeren" +"select.layersAt" = "Lagen op een punt" +"select.matching" = "Overeenkomende lagen selecteren" +"select.next" = "Volgende laag op hetzelfde niveau selecteren" +"select.none" = "Selectie opheffen" +"select.parent" = "Bovenliggende selecteren" +"select.previous" = "Vorige laag op hetzelfde niveau selecteren" +"select.remove" = "Uit selectie verwijderen" +"select.sameEffect" = "Zelfde effect selecteren" +"select.sameFill" = "Dezelfde vulling selecteren" +"select.sameFont" = "Lagen met hetzelfde lettertype selecteren" +"select.sameInstance" = "Zelfde instance selecteren" +"select.sameProperties" = "Dezelfde eigenschappen selecteren" +"select.sameStroke" = "Lagen met dezelfde lijn selecteren" +"select.sameText" = "Dezelfde teksteigenschappen selecteren" +"select.set" = "Selecteren" +"selection.applyColorStyle" = "Kleurstijl op selectie toepassen" +"selection.colors" = "Selectiekleuren" +"selection.selectColor" = "Lagen op kleur selecteren" +"selection.setPaint" = "Selectiekleur wijzigen" +"shape.arrow" = "Pijl" +"shape.ellipse" = "Ellips" +"shape.line" = "Lijn" +"shape.polygon" = "Veelhoek" +"shape.rectangle" = "Rechthoek" +"shape.star" = "Ster" +"shapeBuilder.merge" = "Gebieden samenvoegen" +"shapeBuilder.mode" = "Shape builder-modus" +"shapeBuilder.regions" = "Shape builder-gebieden" +"shapeBuilder.remove" = "Gebieden verwijderen" +"stroke.add" = "Lijn toevoegen" +"stroke.brush" = "Penseel" +"stroke.move" = "Lijn verplaatsen" +"stroke.remove" = "Lijn verwijderen" +"stroke.set" = "Lijn instellen" +"stroke.widthProfile" = "Breedteprofiel" +"style.apply" = "Stijl toepassen" +"style.bind" = "Variabele aan stijl koppelen" +"style.create" = "Stijl maken" +"style.delete" = "Stijl verwijderen" +"style.detach" = "Stijl loskoppelen" +"style.duplicate" = "Stijl dupliceren" +"style.list" = "Stijlen weergeven" +"style.move" = "Stijlen verplaatsen" +"style.renameFolder" = "Stijlmap hernoemen" +"style.update" = "Stijl bewerken" +"template.list" = "Sjablonen weergeven" +"text.alignCenter" = "Midden uitlijnen" +"text.alignJustify" = "Uitvullen" +"text.alignLeft" = "Links uitlijnen" +"text.alignRight" = "Rechts uitlijnen" +"text.bold" = "Vet" +"text.bulletList" = "Lijst met opsommingstekens" +"text.case" = "Hoofdlettergebruik" +"text.caseLower" = "Kleine letters" +"text.caseOriginal" = "Oorspronkelijk hoofdlettergebruik" +"text.caseSmallCaps" = "Kleine hoofdletters" +"text.caseTitle" = "Hoofdletters per woord" +"text.caseUpper" = "Hoofdletters" +"text.create" = "Tekst" +"text.decoration" = "Decoratie" +"text.edit.begin" = "Tekst bewerken" +"text.edit.caret" = "Invoegpunt instellen" +"text.edit.end" = "Tekst bewerken stoppen" +"text.edit.insert" = "Tekst typen" +"text.edit.key" = "Tekstoets" +"text.edit.preedit" = "Tekst samenstellen (IME)" +"text.edit.selectLine" = "Regel selecteren" +"text.edit.selectWord" = "Woord selecteren" +"text.edit.selection" = "Tekstselectie" +"text.endPreview" = "Tekstvoorvertoning beëindigen" +"text.fonts" = "Lettertypen weergeven" +"text.italic" = "Cursief" +"text.lineHeightDown" = "Regelafstand verkleinen" +"text.lineHeightUp" = "Regelafstand vergroten" +"text.link" = "Link maken" +"text.listIndent" = "Lijst inspringen" +"text.listOutdent" = "Inspringing lijst verkleinen" +"text.listStyle" = "Lijststijl" +"text.maxLines" = "Maximaal aantal regels" +"text.numberList" = "Genummerde lijst" +"text.onPath" = "Tekst op pad" +"text.paragraphIndent" = "Alinea-inspringing" +"text.paragraphSpacing" = "Alinea-afstand" +"text.pathOptions" = "Opties voor tekstpad" +"text.preview" = "Tekstwijziging voorvertonen" +"text.removeFromPath" = "Tekst van pad verwijderen" +"text.set" = "Tekst instellen" +"text.setFeatures" = "OpenType-functies" +"text.setVariations" = "Assen van variabele lettertypen" +"text.sizeDown" = "Lettergrootte verkleinen" +"text.sizeUp" = "Lettertypegrootte vergroten" +"text.strikethrough" = "Doorstrepen" +"text.style" = "Tekstbereik opmaken" +"text.trackingDown" = "Letterafstand verkleinen" +"text.trackingUp" = "Letterafstand vergroten" +"text.underline" = "Onderstrepen" +"text.weightDown" = "Lettertypedikte verkleinen" +"text.weightUp" = "Letterdikte vergroten" +"tool.arrow" = "Pijl" +"tool.comment" = "Reactie" +"tool.ellipse" = "Ellips" +"tool.frame" = "Frame" +"tool.hand" = "Hand" +"tool.line" = "Lijn" +"tool.move" = "Verplaatsen" +"tool.pen" = "Pen" +"tool.pencil" = "Potlood" +"tool.pencilBrush" = "Potloodpenseel" +"tool.place" = "Plaatsen met de tool" +"tool.polygon" = "Veelhoek" +"tool.rectangle" = "Rechthoek" +"tool.scale" = "Schalen" +"tool.scaleAnchor" = "Schaalanker" +"tool.section" = "Sectie" +"tool.set" = "Gereedschap kiezen" +"tool.shapeBuilder" = "Shape builder" +"tool.slice" = "Slice" +"tool.star" = "Ster" +"tool.text" = "Tekst" +"transform.move" = "Verplaatsen" +"transform.moveTo" = "Verplaatsen naar" +"transform.nudge" = "Verschuiven" +"transform.nudgeSize" = "Formaat wijzigen in stappen" +"transform.resize" = "Formaat wijzigen" +"transform.rotate" = "Draaien" +"transform.rotationOrigin" = "Rotatiepunt verplaatsen" +"transform.scale" = "Schalen" +"transform.setRotationOrigin" = "Rotatiepunt instellen" +"variable.addMode" = "Modus toevoegen" +"variable.bind" = "Variabele koppelen" +"variable.create" = "Variabele maken" +"variable.createCollection" = "Variabeleverzameling maken" +"variable.delete" = "Variabele verwijderen" +"variable.deleteCollection" = "Verzameling verwijderen" +"variable.duplicate" = "Variabele dupliceren" +"variable.export" = "Variabelen exporteren" +"variable.import" = "Variabelen importeren" +"variable.list" = "Variabelen weergeven" +"variable.moveMode" = "Modus verplaatsen" +"variable.removeMode" = "Modus verwijderen" +"variable.renameCollection" = "Verzameling hernoemen" +"variable.renameMode" = "Modus hernoemen" +"variable.resetOverride" = "Overschrijving van variabele resetten" +"variable.set" = "Variabelewaarde instellen" +"variable.setMode" = "Variabelemodus instellen" +"variable.update" = "Variabele bewerken" +"vector.create" = "Vector" +"vector.deleteHeal" = "Selectie verwijderen en herstellen" +"vector.edit.addVertex" = "Punt toevoegen" +"vector.edit.begin" = "Vector bewerken" +"vector.edit.bend" = "Segment buigen" +"vector.edit.bounds" = "Grenzen van geselecteerde punten" +"vector.edit.connect" = "Punten verbinden" +"vector.edit.cornerRadius" = "Hoekradius van punt" +"vector.edit.cut" = "Pad knippen" +"vector.edit.delete" = "Punten verwijderen" +"vector.edit.end" = "Klaar met bewerken" +"vector.edit.erase" = "Wissen" +"vector.edit.lasso" = "Met lasso selecteren" +"vector.edit.mirroring" = "Handgrepen spiegelen" +"vector.edit.moveHandles" = "Handgrepen verplaatsen" +"vector.edit.moveVertices" = "Punten verplaatsen" +"vector.edit.nudge" = "Punten verschuiven" +"vector.edit.paint" = "Verfemmer" +"vector.edit.pen" = "Penpunt" +"vector.edit.penEnd" = "Penpad beëindigen" +"vector.edit.position" = "Puntpositie" +"vector.edit.resize" = "Formaat van punten wijzigen" +"vector.edit.rotate" = "Punten draaien" +"vector.edit.select" = "Punten selecteren" +"vector.edit.selectAll" = "Alle punten selecteren" +"vector.edit.setHandle" = "Handgreep instellen" +"vector.edit.smooth" = "Punten glad of scherp maken" +"vector.edit.state" = "Status van vectorbewerking" +"vector.edit.strokeCap" = "Lijneinde van punt" +"vector.edit.strokeJoin" = "Lijnaansluiting van punt" +"vector.edit.tool" = "Tool voor vectorbewerking" +"vector.edit.transform" = "Punten transformeren" +"vector.edit.widthPoint" = "Breedtepunt" +"vector.flatten" = "Afvlakken" +"vector.join" = "Selectie samenvoegen" +"vector.offset" = "Pad offsetten" +"vector.outlineStroke" = "Lijn omzetten naar contouren" +"vector.reverse" = "Padrichting omkeren" +"vector.simplify" = "Vector vereenvoudigen" +"vector.smoothJoin" = "Selectie vloeiend samenvoegen" +"version.autosave" = "Versie automatisch opslaan" +"version.delete" = "Versie verwijderen" +"version.duplicate" = "Versie dupliceren" +"version.exitPreview" = "Terug naar huidige versie" +"version.list" = "Versies weergeven" +"version.preview" = "Versie bekijken" +"version.rename" = "Versie hernoemen" +"version.restore" = "Versie terugzetten" +"view.assets" = "Assetpaneel" +"view.comments" = "Reacties" +"view.contextMenu" = "Contextmenu tonen" +"view.design" = "Ontwerppaneel" +"view.designPrototype" = "Wisselen tussen Design en Prototype" +"view.devMode" = "Dev Mode" +"view.frameOutlines" = "Framecontouren" +"view.fullscreen" = "Volledig scherm" +"view.get" = "Weergave ophalen" +"view.layers" = "Lagenpaneel" +"view.layoutGrids" = "Layoutrasters" +"view.leftSidebar" = "Linkerzijbalk tonen/verbergen" +"view.maskOutlines" = "Maskercontouren" +"view.multiplayerCursors" = "Multiplayer-cursors" +"view.nextFrame" = "Inzoomen op volgend frame" +"view.nextPage" = "Volgende pagina" +"view.nextRegion" = "Volgend gebied focussen" +"view.outlines" = "Contouren" +"view.outlinesHiddenLayers" = "Verborgen lagen opnemen in contouren" +"view.outlinesObjectBounds" = "Objectgrenzen opnemen in contouren" +"view.outlinesSelection" = "Contouren voor selectie" +"view.pan" = "Weergave verschuiven" +"view.panelWidths" = "Paneelbreedtes herstellen" +"view.pixelGrid" = "Pixelraster" +"view.pixelPreview" = "Pixelvoorvertoning" +"view.prevFrame" = "Inzoomen op vorig frame" +"view.prevPage" = "Vorige pagina" +"view.previousRegion" = "Vorige regio focussen" +"view.proofSrgb" = "sRGB-proefweergave" +"view.prototype" = "Prototypepaneel" +"view.rulers" = "Linialen" +"view.set" = "Weergave instellen" +"view.slices" = "Slices" +"view.snapGeometry" = "Uitlijnen op geometrie" +"view.snapObjects" = "Uitlijnen op objecten" +"view.snapPixel" = "Uitlijnen op pixelraster" +"view.themeDark" = "Donker thema" +"view.themeLight" = "Licht thema" +"view.themeSystem" = "Systeemthema volgen" +"view.toggleUi" = "UI tonen/verbergen" +"view.zoom100" = "Inzoomen op 100%" +"view.zoom200" = "Zoomen naar 200%" +"view.zoom50" = "Zoom instellen op 50%" +"view.zoomAt" = "Zoomgereedschap" +"view.zoomFit" = "Inpassen" +"view.zoomIn" = "Inzoomen" +"view.zoomOut" = "Uitzoomen" +"view.zoomSelection" = "Inzoomen op selectie" +"widget.click" = "Op widget klikken" +"widget.insert" = "Widget invoegen" +"widget.list" = "Widgets weergeven" +"window.focus" = "Venster naar voren brengen" +"window.new" = "Nieuw venster" + +[menu] +"bool.exclude" = "Exclude" +"bool.flatten" = "Flatten" +"bool.intersect" = "Intersect" +"bool.subtract" = "Subtract" +"bool.union" = "Union" +"file.duplicate" = "Dupliceren" +"file.import" = "Importeren uit bestand…" +"file.rename" = "Hernoemen" +"libraries.open" = "Bibliotheken openen" +"select.sameEffect" = "Effect" +"select.sameFill" = "Vulling" +"select.sameFont" = "Lettertype" +"select.sameInstance" = "Instance" +"select.sameProperties" = "Eigenschappen" +"select.sameStroke" = "Lijn" +"select.sameText" = "Teksteigenschappen" +"text.alignCenter" = "Tekst centreren" +"text.alignJustify" = "Tekst uitvullen" +"text.alignLeft" = "Tekst links uitlijnen" +"text.alignRight" = "Tekst rechts uitlijnen" +"view.assets" = "Assets" +"view.design" = "Ontwerp" +"view.layers" = "Lagen" +"view.outlines" = "Omtrekken weergeven" +"view.outlinesHiddenLayers" = "Verborgen lagen meenemen" +"view.outlinesObjectBounds" = "Objectgrenzen meenemen" +"view.outlinesSelection" = "Omtrekken van selectie weergeven" +"view.prototype" = "Prototype" +"view.themeDark" = "Donker" +"view.themeLight" = "Licht" +"view.themeSystem" = "Systeem volgen" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml new file mode 100644 index 000000000..23f7dee8e --- /dev/null +++ b/crates/ui-egui/locales/pl.toml @@ -0,0 +1,2343 @@ +# FigCraft interface strings: Polish. +# +# Draft: machine-drafted, waiting for a native speaker's review. + +[meta] +name = "Polski" +status = "draft — needs native review" + +[ui] +"% of all commits" = "% wszystkich commitów" +"(no longer published)" = "(już niepublikowane)" +"({n} warnings: some entries were skipped)" = { one = "({n} ostrzeżenie: niektóre wpisy zostały pominięte)", few = "({n} ostrzeżenia: niektóre wpisy zostały pominięte)", many = "({n} ostrzeżeń: niektóre wpisy zostały pominięte)", other = "({n} ostrzeżeń: niektóre wpisy zostały pominięte)" } +"+ Create variable" = "+ Utwórz zmienną" +".fig (experimental)" = ".fig (eksperymentalny)" +"1…9, 0" = "1…9, 0" +"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR: {prs}, commitów: {commits}, +{added} / −{deleted} wierszy (Δ {delta}), +{bin_added} / −{bin_deleted} zasobów binarnych, {first} – {last}" +"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Kolekcja tego pliku z tymi trybami i zmiennymi: nadpisuj ich wartości dla poszczególnych trybów i dodawaj własne zmienne" +"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Plik .figcraft o tej nazwie już istnieje. Wybierz pełną nazwę, aby potwierdzić zastąpienie." +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Pojedyncza warstwa wyrównuje się do swojej ramki: zaznacz więcej warstw lub warstwę w ramce" +"A responsive product page: hero, features, figures, a quote and a footer" = "Responsywna strona produktu: sekcja hero, funkcje, statystyki, cytat i stopka" +"A spring plays until it settles: its mass, stiffness and damping set how long" = "Animacja sprężynowa trwa do ustabilizowania: jej masa, sztywność i tłumienie określają czas" +"A title, an agenda, a big figure, a chart and a closing slide" = "Tytuł, agenda, wyróżniona liczba, wykres i slajd końcowy" +"A to Z" = "Od A do Z" +"About" = "O programie" +"About FigCraft" = "O programie FigCraft" +"About this server" = "O tym serwerze" +"Action" = "Akcja" +"Actions" = "Akcje" +"Actions for {name}" = "Akcje dla: {name}" +"Actions…" = "Akcje…" +"Active" = "Aktywny" +"Actual size (100%)" = "Rozmiar rzeczywisty (100%)" +"Add a category" = "Dodaj kategorię" +"Add a comment" = "Dodaj komentarz" +"Add a description" = "Dodaj opis" +"Add a frame (F) or a shape (R) to get started" = "Dodaj ramkę (F) lub kształt (R), aby rozpocząć" +"Add a layer or component to tile first" = "Najpierw dodaj warstwę lub komponent do powielenia" +"Add a note for developers" = "Dodaj notatkę dla programistów" +"Add action" = "Dodaj akcję" +"Add actions for when no condition holds" = "Dodaj akcje wykonywane, gdy żaden warunek nie jest spełniony" +"Add an annotation for developers" = "Dodaj adnotację dla programistów" +"Add another condition" = "Dodaj kolejny warunek" +"Add auto layout" = "Dodaj Auto layout" +"Add background behind overlay" = "Dodaj tło za nakładką" +"Add content to this slot" = "Dodaj zawartość do tego gniazda" +"Add dev resource" = "Dodaj zasób programistyczny" +"Add effect" = "Dodaj efekt" +"Add export setting" = "Dodaj ustawienie eksportu" +"Add fill" = "Dodaj wypełnienie" +"Add interaction" = "Dodaj interakcję" +"Add layout grid" = "Dodaj siatkę układu" +"Add library" = "Dodaj bibliotekę" +"Add library file…" = "Dodaj plik biblioteki…" +"Add max height" = "Dodaj maksymalną wysokość" +"Add max width" = "Dodaj maksymalną szerokość" +"Add min height" = "Dodaj minimalną wysokość" +"Add min width" = "Dodaj minimalną szerokość" +"Add mode" = "Dodaj tryb" +"Add new page" = "Dodaj nową stronę" +"Add or update collections from a variables JSON" = "Dodaj lub zaktualizuj kolekcje z pliku JSON ze zmiennymi" +"Add properties to make instances configurable" = "Dodaj właściwości, aby umożliwić konfigurację instancji" +"Add reaction" = "Dodaj reakcję" +"Add starting point" = "Dodaj punkt startowy" +"Add stroke" = "Dodaj obrys" +"Add variant" = "Dodaj wariant" +"Add variant with a new value" = "Dodaj wariant z nową wartością" +"Added" = "Dodano" +"Added the library \"{name}\"" = "Dodano bibliotekę „{name}”" +"Adding to {name}" = "Dodawanie do: {name}" +"Adjust" = "Dopasowanie" +"Adjustment presets" = "Presety korekt" +"Advanced" = "Zaawansowane" +"Advanced stroke" = "Zaawansowany obrys" +"After delay" = "Po opóźnieniu" +"After delay {ms}ms" = "Po opóźnieniu {ms} ms" +"Align" = "Wyrównaj" +"Align bottom" = "Wyrównaj do dołu" +"Align center" = "Wyrównaj do środka" +"Align horizontal centers" = "Wyrównaj poziomo do środka" +"Align left" = "Wyrównaj do lewej" +"Align middle" = "Wyrównaj do środka pionowo" +"Align right" = "Wyrównaj do prawej" +"Align text baselines" = "Wyrównaj do linii bazowych tekstu" +"Align top" = "Wyrównaj do góry" +"Align vertical centers" = "Wyrównaj pionowo do środka" +"Alignment" = "Wyrównanie" +"All" = "Wszystko" +"All categories" = "Wszystkie kategorie" +"All comments" = "Wszystkie komentarze" +"All components" = "Wszystkie komponenty" +"All fonts" = "Wszystkie czcionki" +"All supported properties" = "Wszystkie obsługiwane właściwości" +"All the fonts this file uses are installed" = "Wszystkie czcionki używane w tym pliku są zainstalowane" +"All variables" = "Wszystkie zmienne" +"Alpha" = "Alfa" +"Alphabetical" = "Alfabetycznie" +"Alt+drag" = "Alt+przeciągnięcie" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Ekran analityczny na siatce: wykresy wektorowe, liczby i tabela" +"An invite link that opens FigCraft and joins" = "Link z zaproszeniem otwierający FigCraft i dołączający do sesji" +"Anchor" = "Punkt zaczepienia" +"Android" = "Android" +"Angular" = "Kątowy" +"Animation" = "Animacja" +"Annotations" = "Adnotacje" +"Annotations shown on the canvas" = "Adnotacje widoczne na płótnie" +"Another server, or one that needs its key. The usual server is set in Preferences." = "Inny serwer lub serwer wymagający klucza. Domyślny serwer konfiguruje się w Preferencjach." +"Any status" = "Dowolny status" +"Anyone on your network with this code can edit the file, while this app stays open." = "Każdy w Twojej sieci z tym kodem może edytować plik, dopóki ta aplikacja pozostaje otwarta." +"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Każdy z linkiem do edycji może modyfikować plik; link do podglądu pozwala tylko oglądać. Linki zawierają tokeny: traktuj je jak hasła. Polecenie `figcraft-server links` tworzy i unieważnia linki." +"Anyone with this code can edit the file." = "Każdy z tym kodem może edytować plik." +"Anyone with this code can look at the file, not change it." = "Każdy z tym kodem może przeglądać plik bez możliwości zmian." +"Appearance" = "Wygląd" +"Apply a component property" = "Zastosuj właściwość komponentu" +"Apply variable" = "Zastosuj zmienną" +"Apply variable to text" = "Zastosuj zmienną do tekstu" +"Apply variable to visibility" = "Zastosuj zmienną do widoczności" +"Apps" = "Aplikacje" +"Apr" = "Kwi" +"Arc" = "Łuk" +"Arc ratio" = "Proporcja łuku" +"Arc start" = "Początek łuku" +"Arc sweep" = "Rozpiętość łuku" +"Archive" = "Zarchiwizuj" +"Archived" = "Zarchiwizowano" +"Arrange" = "Ułóż" +"Arrow" = "Strzałka" +"As typed" = "Zgodnie z wpisanym" +"Asking the server…" = "Odpytywanie serwera…" +"Assets" = "Zasoby" +"Aug" = "Sie" +"Auto" = "Automatycznie" +"Auto (space around)" = "Automatycznie (odstęp dookoła)" +"Auto (space between)" = "Automatycznie (odstęp między)" +"Auto (space evenly)" = "Automatycznie (równy odstęp)" +"Auto ({mode})" = "Automatycznie ({mode})" +"Auto columns" = "Automatyczne kolumny" +"Auto gap" = "Automatyczny odstęp" +"Auto height" = "Automatyczna wysokość" +"Auto layout" = "Automatyczny układ" +"Auto rows" = "Automatyczne wiersze" +"Auto width" = "Automatyczna szerokość" +"Back to the FigCraft server" = "Powrót do serwera FigCraft" +"Back to the font's value" = "Powrót do wartości czcionki" +"Back" = "Tył" +"Back to Ready for dev" = "Powrót do Gotowe do wdrożenia" +"Back to Ready for dev: the next change compares with how it is now" = "Powrót do Gotowe do wdrożenia: kolejna zmiana zostanie porównana ze stanem obecnym" +"Back to assets" = "Powrót do zasobów" +"Back to current" = "Powrót do bieżącej" +"Back to styles" = "Powrót do stylów" +"Back to the canvas from a focused region" = "Powrót do płótna z wybranego obszaru" +"Back to the current version" = "Powrót do bieżącej wersji" +"Back to the values the font style implies" = "Powrót do wartości wynikających ze stylu czcionki" +"Background" = "Tło" +"Background blur" = "Rozmycie tła" +"Basics" = "Podstawy" +"Before" = "Przed" +"Bend" = "Wygnij" +"Bevel" = "Ścięte" +"Binary assets added" = "Dodane pliki binarne" +"Binary assets removed" = "Usunięte pliki binarne" +"Black & white" = "Czerń i biel" +"Bleed" = "Spad" +"Bleed marks" = "Znaczniki spadu" +"Bleed marks need a bleed" = "Znaczniki spadu wymagają zdefiniowania spadu" +"Blend" = "Mieszanie" +"Blend mode" = "Tryb mieszania" +"Blue" = "Niebieski" +"Blur" = "Rozmycie" +"Boolean" = "Wartość logiczna" +"Boolean groups" = "Grupy logiczne (Boolean)" +"Both directions" = "W obu kierunkach" +"Bottom" = "Dół" +"Bottom left radius" = "Promień lewego dolnego narożnika" +"Bottom right radius" = "Promień prawego dolnego narożnika" +"Branch" = "Gałąź" +"Branch archived" = "Gałąź zarchiwizowana" +"Branch merged" = "Gałąź scalona" +"Branch name" = "Nazwa gałęzi" +"Branch of {file}" = "Gałąź pliku {file}" +"Branch of {file} · merged or archived" = "Gałąź pliku {file} · scalona lub zarchiwizowana" +"Branch updated from main" = "Gałąź zaktualizowana z głównej" +"Branch {name}" = "Gałąź {name}" +"Branches" = "Gałęzie" +"Bound to {name}. Click to detach the variable." = "Powiązano z: {name}. Kliknij, aby odłączyć zmienną." +"Bring forward" = "Przenieś do przodu" +"Bring the main file's changes into the branch" = "Pobierz zmiany z pliku głównego do gałęzi" +"Bring to front" = "Przenieś na wierzch" +"Bristle" = "Włosiankowy" +"Browse" = "Przeglądaj" +"Brush" = "Pędzel" +"Brush rotation jitter" = "Fluktuacja obrotu pędzla" +"Brush scatter" = "Rozrzut pędzla" +"Brush size jitter" = "Fluktuacja rozmiaru pędzla" +"Brush spacing" = "Odstęp pędzla" +"Bulleted list" = "Lista punktowana" +"C or Shift+C" = "C lub Shift+C" +"C or ⇧C" = "C lub ⇧C" +"CMYK colour" = "Kolor CMYK" +"Can view" = "Może przeglądać" +"Checking the fonts installed on this computer…" = "Sprawdzanie czcionek zainstalowanych na tym komputerze…" +"Checking the server…" = "Sprawdzanie serwera…" +"Clear from this list" = "Usuń z tej listy" +"Code or invite link" = "Kod lub link z zaproszeniem" +"Color profile: {name}" = "Profil kolorów: {name}" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Style kolorów i tekstu, zmienne Jasne i Ciemne, przyciski, pola wprowadzania i karty" +"Convert to frame" = "Konwertuj na ramkę" +"Convert to section" = "Konwertuj na sekcję" +"CSS custom properties…" = "Właściwości niestandardowe CSS…" +"Can edit" = "Może edytować" +"Can't read this library: {e}" = "Nie można odczytać tej biblioteki: {e}" +"Cancel" = "Anuluj" +"Canvas" = "Płótno" +"Canvas stacking" = "Kolejność na płótnie" +"Cap height to baseline" = "Wysokość wersalików do linii bazowej" +"Case" = "Wielkość liter" +"Case-sensitive forms" = "Formy zależne od wielkości liter" +"Categories" = "Kategorie" +"Center" = "Środek" +"Change alias" = "Zmień alias" +"Change resolved" = "Zmiana rozwiązana" +"Change style" = "Zmień styl" +"Change to" = "Zmień na" +"Change to needs a layer inside a variant" = "Opcja Zmień na wymaga warstwy wewnątrz wariantu" +"Changed" = "Zmieniono" +"Changed on both sides: keep one" = "Zmieniono po obu stronach: zachowaj jedną wersję" +"Changes on main" = "Zmiany w gałęzi głównej" +"Changes on the branch" = "Zmiany w gałęzi" +"Changes unknown" = "Nieznane zmiany" +"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "Wybierz React, Leptos, SwiftUI lub Compose powyżej, aby połączyć ten komponent z jego kodem." +"Choose another profile to change it." = "Wybierz inny profil, aby go zmienić." +"Choose collection" = "Wybierz kolekcję" +"Choose image" = "Wybierz obraz" +"Choose mode" = "Wybierz tryb" +"Choose variable" = "Wybierz zmienną" +"Circle" = "Koło" +"Clear recent" = "Wyczyść ostatnie" +"Click + to replace mixed content" = "Kliknij +, aby zastąpić zawartość mieszaną" +"Click anywhere on the canvas to leave one." = "Kliknij w dowolnym miejscu na płótnie, aby zostawić komentarz." +"Click or drag to merge · {alt} removes" = "Kliknij lub przeciągnij, aby scalić · {alt} usuwa" +"Click or drag to remove · {alt} merges" = "Kliknij lub przeciągnij, aby usunąć · {alt} scala" +"Click to record a key" = "Kliknij, aby zarejestrować klawisz" +"Clip content" = "Przycinaj zawartość" +"Clip to shape" = "Przytnij do kształtu" +"Close" = "Zamknij" +"Close overlay" = "Zamknij nakładkę" +"Close preview" = "Zamknij podgląd" +"Close tab" = "Zamknij kartę" +"Close version history" = "Zamknij historię wersji" +"Close when clicking outside" = "Zamknij przy kliknięciu poza obszarem" +"Close without saving" = "Zamknij bez zapisywania" +"Code" = "Kod" +"Code syntax" = "Składnia kodu" +"Collapse" = "Zwiń" +"Collapse panel" = "Zwiń panel" +"Collection" = "Kolekcja" +"Collections" = "Kolekcje" +"Color" = "Kolor" +"Color burn" = "Ściemnianie" +"Color dodge" = "Rozjaśnianie" +"Color format" = "Format koloru" +"Color profile" = "Profil kolorów" +"Color styles" = "Style kolorów" +"Color values in this file are in this color space." = "Wartości kolorów w tym pliku są w tej przestrzeni kolorów." +"Color variables" = "Zmienne koloru" +"Colour" = "Kolor" +"Column" = "Kolumna" +"Column gap" = "Odstęp kolumn" +"Column sizes" = "Rozmiary kolumn" +"Column span" = "Rozpiętość kolumn" +"Columns" = "Kolumny" +"Columns {count}" = "Kolumny: {count}" +"Comment" = "Komentarz" +"Comments" = "Komentarze" +"Commits" = "Commity" +"Company" = "Firma" +"Compare changes" = "Porównaj zmiany" +"Compatibility notes" = "Uwagi dotyczące zgodności" +"Completed" = "Ukończone" +"Component" = "Komponent" +"Component name" = "Nazwa komponentu" +"Component property" = "Właściwość komponentu" +"Component set" = "Zestaw komponentów" +"Component sets" = "Zestawy komponentów" +"Components" = "Komponenty" +"Conditional" = "Warunek (Conditional)" +"Confetti" = "Konfetti" +"Conflicts" = "Konflikty" +"Connect to code" = "Połącz z kodem" +"Connected" = "Połączono" +"Connected to code: {from}" = "Połączono z kodem: {from}" +"Connection lost, reconnecting (attempt {attempt})…" = "Utracono połączenie, ponowne łączenie (próba {attempt})…" +"Constrain proportions" = "Zachowaj proporcje" +"Constraints" = "Więzy" +"Content" = "Zawartość" +"Contents hidden" = "Zawartość ukryta" +"Contents only" = "Tylko zawartość" +"Contextual alternates" = "Alternatywy kontekstowe" +"Contrast" = "Kontrast" +"Contributors" = "Współtwórcy" +"Contributors: {people} · Commits: {commits}" = "Współtwórcy: {people} · Commity: {commits}" +"Controller" = "Kontroler" +"Cool" = "Chłodny" +"Copied as CSS" = "Skopiowano jako CSS" +"Copied as PNG" = "Skopiowano jako PNG" +"Copied as SVG" = "Skopiowano jako SVG" +"Copied as text" = "Skopiowano jako tekst" +"Copied {value}" = "Skopiowano {value}" +"Copy" = "Kopiuj" +"Copy as" = "Kopiuj jako" +"Copy as CSS" = "Kopiuj jako CSS" +"Copy as PNG" = "Kopiuj jako PNG" +"Copy as SVG" = "Kopiuj jako SVG" +"Copy code" = "Kopiuj kod" +"Copy link" = "Kopiuj link" +"Copy the {label}" = "Kopiuj {label}" +"Copy {value}" = "Kopiuj {value}" +"Corner radius" = "Zaokrąglenie rogów" +"Corner smoothing" = "Wygładzanie narożników (Corner smoothing)" +"Couldn't download the variables" = "Nie udało się pobrać zmiennych" +"Couldn't reach {server}: showing what this computer remembers." = "Nie udało się połączyć z {server}: pokazywanie zawartości zapamiętanej przez ten komputer." +"Couldn't read {name}." = "Nie udało się odczytać pliku {name}." +"Couldn't save automatically: {error}" = "Nie udało się zapisać automatycznie: {error}" +"Count" = "Licznik" +"Create" = "Utwórz" +"Create a variable collection first" = "Najpierw utwórz kolekcję zmiennych" +"Create a variable first" = "Najpierw utwórz zmienną" +"Create alias" = "Utwórz alias" +"Create branch" = "Utwórz gałąź" +"Create collection" = "Utwórz kolekcję" +"Create component" = "Utwórz komponent" +"Create component property" = "Utwórz właściwość komponentu" +"Create components, styles or variables to publish them as a library." = "Twórz komponenty, style lub zmienne, aby opublikować je jako bibliotekę." +"Create property…" = "Utwórz właściwość…" +"Create style" = "Utwórz styl" +"Create style from the selected text" = "Utwórz styl z zaznaczonego tekstu" +"Created" = "Utworzono" +"Crop" = "Przytnij" +"Crop marks" = "Znaczniki przycięcia" +"Ctrl+arrows" = "Ctrl+strzałki" +"Ctrl+click" = "Ctrl+kliknięcie" +"Ctrl+scroll" = "Ctrl+przewijanie" +"Current name" = "Bieżąca nazwa" +"Current version" = "Bieżąca wersja" +"Cursor chat" = "Czat kursora" +"Custom" = "Niestandardowy" +"Custom size" = "Rozmiar niestandardowy" +"Cut" = "Wytnij" +"Dark" = "Ciemny" +"Darken" = "Ciemniej" +"Dash" = "Kreska" +"Dashboard" = "Pulpit" +"Date created" = "Data utworzenia" +"Dec" = "Gru" +"Decoration style" = "Styl dekoracji" +"Decoration" = "Dekoracja tekstu" +"Deep select" = "Głębokie zaznaczanie" +"Default" = "Domyślne" +"Default content" = "Domyślna zawartość" +"Default value (the top-left variant)" = "Wartość domyślna (lewy górny wariant)" +"Delay" = "Opóźnienie" +"Delete" = "Usuń" +"Delete category (its annotations stay)" = "Usuń kategorię (jej adnotacje pozostaną)" +"Delete collection" = "Usuń kolekcję" +"Delete group" = "Usuń grupę" +"Delete mode" = "Usuń tryb" +"Delete page" = "Usuń stronę" +"Delete preset" = "Usuń ustawienie" +"Delete property" = "Usuń właściwość" +"Delete the style (layers keep their values)" = "Usuń styl (warstwy zachowają swoje wartości)" +"Delete thread" = "Usuń wątek" +"Delete variable" = "Usuń zmienną" +"Delete “{name}”? This can't be undone." = "Usunąć „{name}”? Tej operacji nie można cofnąć." +"Density" = "Gęstość" +"Depth" = "Głębia" +"Describe how and when to use this component" = "Opisz sposób i warunki użycia tego komponentu" +"Describe what changed" = "Opisz wprowadzone zmiany" +"Description" = "Opis" +"Description (optional)" = "Opis (opcjonalnie)" +"Design" = "Projektowanie" +"Design system starter" = "Pakiet startowy systemu projektowego (Design System)" +"Desktop" = "Pulpit" +"Destination" = "Miejsce docelowe" +"Detach alias" = "Odłącz alias" +"Detach instance" = "Odłącz instancję" +"Detach property" = "Odłącz właściwość" +"Detach style" = "Odłącz styl" +"Detach styles" = "Odłącz style" +"Detach variable" = "Odłącz zmienną" +"Details" = "Szczegóły" +"Dev Mode" = "Tryb deweloperski" +"Dev resources" = "Zasoby programistyczne" +"Development" = "Programowanie" +"Device" = "Urządzenie" +"Device at 100%" = "Urządzenie w 100%" +"Diamond" = "Rombowy" +"Difference" = "Różnica" +"Dimensions" = "Wymiary" +"Direction" = "Kierunek" +"Disconnect from code" = "Odłącz od kodu" +"Dispersion" = "Dyspersja" +"Display name" = "Nazwa wyświetlana" +"Dissolve" = "Rozpuszczanie" +"Distribute and tidy" = "Rozmieść i uporządkuj" +"Distribute horizontal spacing" = "Rozmieść poziomo" +"Distribute vertical spacing" = "Rozmieść pionowo" +"Divider" = "Rozdzielacz" +"Don't truncate" = "Nie obcinaj tekstu" +"Done" = "Gotowe" +"Done editing" = "Zakończ edycję" +"Dots" = "Kropki" +"Dotted" = "Kropkowany" +"Down" = "W dół" +"Drafts" = "Szkice" +"Drag the stroke to add a width point" = "Przeciągnij obrys, aby dodać punkt szerokości" +"Draw" = "Rysuj" +"Draw a frame" = "Narysuj ramkę" +"Drawing tools" = "Narzędzia rysowania" +"Drop a .figcraft, .fig, .json, .svg or image file." = "Upuść plik .figcraft, .fig, .json, .svg lub plik obrazu." +"Drop to import" = "Upuść, aby zaimportować" +"Drop to open" = "Upuść, aby otworzyć" +"Drop shadow" = "Cień" +"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Podwójna licencja MIT LUB Apache-2.0. Stworzone przez zespół ArtCraft i społeczność." +"Duo" = "Duet" +"Duplicate" = "Powiel" +"Duplicate by dragging" = "Powiel przez przeciągnięcie" +"Duplicate mode" = "Powiel tryb" +"Duplicate page" = "Powiel stronę" +"Edit" = "Edycja" +"Edit as an expression" = "Edytuj jako wyrażenie" +"Edit colour" = "Edytuj kolor" +"Edit comment" = "Edytuj komentarz" +"Edit dev resource" = "Edytuj zasób programistyczny" +"Edit link" = "Edytuj link" +"Edit link copied" = "Skopiowano link do edycji" +"Edit style" = "Edytuj styl" +"Edit variable" = "Edytuj zmienną" +"Edit with the builder" = "Edytuj za pomocą kreatora" +"Edited {when}" = "Edytowano {when}" +"Effect settings" = "Ustawienia efektów" +"Effect styles" = "Style efektów" +"Effects" = "Efekty" +"Ellipse" = "Elipsa" +"Ellipses" = "Elipsy" +"Else" = "W przeciwnym razie" +"Else if" = "W przeciwnym razie jeśli" +"Embedded profile" = "Wbudowany profil" +"Empty" = "Puste" +"End" = "End" +"End point" = "Punkt końcowy" +"Ends the session; everyone keeps their copy" = "Kończy sesję; każdy zachowuje swoją kopię" +"Enter" = "Enter" +"Enter the address of a figcraft-server and press Browse to see the plugins it offers." = "Wprowadź adres figcraft-server i kliknij Przeglądaj, aby zobaczyć dostępne wtyczki." +"Enter to save · Esc to cancel" = "Enter, aby zapisać · Esc, aby anulować" +"Eraser" = "Gumka" +"Esc" = "Esc" +"Essential" = "Podstawowe" +"Every conflict has a choice." = "Każdy konflikt ma rozwiązanie do wyboru." +"Every top-level frame already starts a flow" = "Każda ramka najwyższego poziomu rozpoczyna już przepływ" +"Exact opacity (4 then 5: 45%)" = "Dokładna nieprzezroczystość (4, potem 5: 45%)" +"Exclude" = "Wyklucz" +"Excluded" = "Wykluczono" +"Excluded from layout" = "Wykluczono z układu" +"Excludes" = "Wyklucza" +"Exclusion" = "Wykluczenie" +"Exit focus view (Esc)" = "Wyjdź z widoku skupienia (Esc)" +"Expand" = "Rozwiń" +"Export" = "Eksportuj" +"Export all" = "Eksportuj wszystko" +"Export frames and layers as PNG, JPG, SVG or PDF" = "Eksportuj ramki i warstwy jako PNG, JPG, SVG lub PDF" +"Export page as PNG" = "Eksportuj stronę jako PNG" +"Export settings" = "Ustawienia eksportu" +"Export variables" = "Eksportuj zmienne" +"Export {name}" = "Eksportuj {name}" +"Export {n} layers" = { one = "Eksportuj {n} warstwę", few = "Eksportuj {n} warstwy", many = "Eksportuj {n} warstw", other = "Eksportuj {n} warstw" } +"Exported {file}" = "Wyeksportowano {file}" +"Exported {name}" = "Wyeksportowano {name}" +"Exported {name}; {n} kinds of content were left out" = { one = "Wyeksportowano {name}; pominięto {n} rodzaj zawartości", few = "Wyeksportowano {name}; pominięto {n} rodzaje zawartości", many = "Wyeksportowano {name}; pominięto {n} rodzajów zawartości", other = "Wyeksportowano {name}; pominięto {n} rodzajów zawartości" } +"Exported {n} files" = { one = "Wyeksportowano {n} plik", few = "Wyeksportowano {n} pliki", many = "Wyeksportowano {n} plików", other = "Wyeksportowano {n} plików" } +"Export…" = "Eksport…" +"Expose properties to instances" = "Udostępnij właściwości instancjom" +"Exposure" = "Czas naświetlania" +"Expression" = "Wyrażenie" +"Extend collection" = "Rozszerz kolekcję" +"Extends \"{name}\": overridden values are marked" = "Rozszerza „{name}”: nadpisane wartości są oznaczone" +"Eye" = "Oko" +"F" = "F" +"Fade" = "Zanikanie" +"False" = "Fałsz" +"Feb" = "Lut" +"Fetching from the server…" = "Pobieranie z serwera…" +"FigCraft couldn't open its window: {error}" = "FigCraft nie mógł otworzyć okna: {error}" +"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft nie obsługuje jeszcze wszystkich funkcji tych plików. Uwagi wymieniają elementy pominięte lub zmienione w zaimportowanej kopii; oryginalny plik pozostaje niezmieniony." +"File" = "Plik" +"File name" = "Nazwa pliku" +"File not found" = "Nie znaleziono pliku" +"File published" = "Plik został opublikowany" +"Files aren't end-to-end encrypted: the server's operator can read them." = "Pliki nie są szyfrowane end-to-end: administrator serwera może je odczytać." +"Files nobody opens for {n} days are deleted." = { one = "Pliki, których nikt nie otwiera przez {n} dzień, są usuwane.", few = "Pliki, których nikt nie otwiera przez {n} dni, są usuwane.", many = "Pliki, których nikt nie otwiera przez {n} dni, są usuwane.", other = "Pliki, których nikt nie otwiera przez {n} dni, są usuwane." } +"Fill" = "Wypełnienie" +"Fill (fr)" = "Wypełnienie (fr)" +"Fill container" = "Wypełnij kontener" +"Fill window" = "Wypełnij okno" +"Fills" = "Wypełnienia" +"Fill|sizing" = "Fill|sizing" +"Filter and sort" = "Filtruj i sortuj" +"Find" = "Znajdź" +"Find and replace" = "Znajdź i zamień" +"Find layers" = "Znajdź warstwy" +"First" = "Pierwszy" +"First commit" = "Pierwszy commit" +"First on top" = "Pierwszy na wierzchu" +"Fit" = "Dopasuj" +"Fit device to window" = "Dopasuj urządzenie do okna" +"Fit to contents" = "Dopasuj do zawartości" +"Fit to window" = "Dopasuj do okna" +"Fit width" = "Dopasuj szerokość" +"Fixed" = "Stałe" +"Fixed gap" = "Stały odstęp" +"Fixed size" = "Stały rozmiar" +"Flatten" = "Spłaszcz" +"Flip" = "Odbij" +"Flip horizontal" = "Odbij w poziomie" +"Flip text to the other side" = "Przerzuć tekst na drugą stronę" +"Flip vertical" = "Odbij w pionie" +"Flip width profile" = "Odwróć profil szerokości" +"Flow starting point" = "Punkt startowy przepływu" +"Flow starting points" = "Punkty startowe przepływu" +"Flows" = "Przepływy" +"Focus view" = "Widok skupienia" +"Fold autosaves" = "Zwiń automatyczne zapisy" +"Folder: click to collapse" = "Folder: kliknij, aby zwinąć" +"Folder: click to expand" = "Folder: kliknij, aby rozwinąć" +"Follow" = "Obserwuj" +"Follow {name}" = "Obserwuj: {name}" +"Following" = "Obserwowanie" +"Following {name} · {key} to stop" = "Obserwowanie {name} · {key}, aby zatrzymać" +"Font" = "Czcionka" +"Font not installed" = "Czcionka niezainstalowana" +"Font size" = "Rozmiar czcionki" +"Font style" = "Styl czcionki" +"Forget on this computer" = "Zapomnij na tym komputerze" +"Four screens of a trail app with components and a prototype flow" = "Cztery ekrany aplikacji z komponentami i przepływem prototypu" +"Fractions" = "Ułamki" +"Frame" = "Ramka" +"Frame selection" = "Utwórz ramkę z zaznaczenia" +"Frame tools" = "Narzędzia ramek" +"Frames" = "Ramki" +"From the library \"{name}\": read-only" = "Z biblioteki „{name}”: tylko do odczytu" +"From {name}" = "Od {name}" +"From {server}" = "Z serwera {server}" +"Frost" = "Szron" +"Gap" = "Odstęp" +"Gap between columns" = "Odstęp między kolumnami" +"Gap between items" = "Odstęp między elementami" +"Gap between rows" = "Odstęp między wierszami" +"Getting started" = "Pierwsze kroki" +"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "Podaj adres serwera publicznego lub adres serwera z jego kluczem (polecenie `figcraft-server key` go wyświetla)." +"Glass" = "Szkło" +"Go to main component" = "Przejdź do głównego komponentu" +"Go to {name}" = "Przejdź do: {name}" +"Grab bag" = "Lista dowolna" +"Gradient" = "Gradient" +"Gray" = "Szary" +"Green" = "Zielony" +"Greyscale components and two example screens to sketch a flow with" = "Komponenty w odcieniach szarości i dwa przykładowe ekrany do naszkicowania przepływu" +"Grid" = "Siatka" +"Grid ({size})" = "Siatka ({size})" +"Grid styles" = "Style siatki" +"Grid {size}" = "Siatka {size}" +"Group" = "Grupa" +"Group selection" = "Grupuj zaznaczenie" +"Groups" = "Grupy" +"Gutter" = "Odstęp" +"Hand tool" = "Narzędzie Rączka" +"Hanging list" = "Lista z wysunięciem" +"Hanging punctuation" = "Wiszące znaki interpunkcyjne" +"Hard light" = "Mocne światło" +"Height" = "Wysokość" +"Help" = "Pomoc" +"Help and resources" = "Pomoc i zasoby" +"Help page" = "Strona pomocy" +"Here: {names}" = "Tutaj: {names}" +"Hidden" = "Ukryty" +"Hide contents" = "Ukryj zawartość" +"Hide from publishing" = "Ukryj przed publikacją" +"Hide section contents" = "Ukryj zawartość sekcji" +"Hide these tips (Getting started in the Help menu shows them again)" = "Ukryj te wskazówki (Pierwsze kroki w menu Pomoc wyświetli je ponownie)" +"Hide when publishing" = "Ukryj podczas publikacji" +"Highlight layers on hover" = "Wyróżniaj warstwy po najechaniu kursorem" +"Highlights" = "Światła" +"Hold Alt" = "Przytrzymaj Alt" +"Hold Alt+Z" = "Przytrzymaj Alt+Z" +"Hold Ctrl" = "Przytrzymaj Ctrl" +"Hold Z" = "Przytrzymaj Z" +"Hold ⌃ or ⌘" = "Przytrzymaj ⌃ lub ⌘" +"Hold ⌥" = "Przytrzymaj ⌥" +"Hold ⌥Z" = "Przytrzymaj ⌥Z" +"Home" = "Start" +"Horizontal" = "Poziomo" +"Horizontal align" = "Wyrównanie poziome" +"Horizontal hex" = "Poziomy sześciokąt" +"Horizontal padding" = "Marginesy poziome" +"Horizontal spacing" = "Odstępy poziome" +"Hosted by {operator}" = "Hostowane przez {operator}" +"Hover a layer to see its properties" = "Najedź na warstwę, aby zobaczyć jej właściwości" +"Hover other layers to measure the distance" = "Najedź na inne warstwy, aby zmierzyć odległość" +"Hue" = "Odcień" +"Hug" = "Dopasuj" +"Hug contents" = "Dopasuj do zawartości" +"Icon components on a 24 px grid with keylines and a size preview" = "Komponenty ikon na siatce 24 px z liniami pomocniczymi i podglądem rozmiaru" +"Icon set starter" = "Zestaw początkowy ikon" +"If it keeps happening, please report it at {link} with this message." = "Jeśli problem będzie się powtarzał, zgłoś go pod adresem {link}, dołączając tę wiadomość." +"If" = "Jeżeli" +"If {condition}" = "Jeżeli {condition}" +"Ignore auto layout (absolute position)" = "Ignoruj automatyczny układ (pozycja bezwzględna)" +"Image" = "Obraz" +"Images" = "Obrazy" +"Import" = "Importuj" +"Import path" = "Ścieżka importu" +"Import plugin from manifest" = "Zaimportuj wtyczkę z manifestu" +"Import plugin from manifest…" = "Zaimportuj wtyczkę z manifestu…" +"Import report" = "Raport importu" +"Import variables" = "Importuj zmienne" +"Imported document" = "Zaimportowany dokument" +"Imported {name}" = "Zaimportowano {name}" +"Imported {n} new variables, updated {updated}" = { one = "Zaimportowano {n} nową zmienną, zaktualizowano {updated}", few = "Zaimportowano {n} nowe zmienne, zaktualizowano {updated}", many = "Zaimportowano {n} nowych zmiennych, zaktualizowano {updated}", other = "Zaimportowano {n} nowych zmiennych, zaktualizowano {updated}" } +"Import…" = "Importuj…" +"In an instance, this comes from the main component" = "W instancji pochodzi to z głównego komponentu" +"In the browser you can join sessions the desktop app hosts and files on a FigCraft server. A page opened over https reaches other computers only through wss:// links (a server behind TLS)." = "W przeglądarce możesz dołączać do sesji hostowanych przez aplikację desktopową oraz do plików na serwerze FigCraft. Strona otwarta przez https łączy się z innymi komputerami tylko przez linki wss:// (serwer za TLS)." +"Include bounding box" = "Dołącz ramkę otaczającą" +"Include id attribute" = "Dołącz atrybut id" +"Included" = "Uwzględniono" +"Included in layout" = "Uwzględniono w układzie" +"Individual corners" = "Niezależne narożniki" +"Individual strokes" = "Niezależne obrysy" +"Ink" = "Farba" +"Inner shadow" = "Cień wewnętrzny" +"Inside" = "Wewnątrz" +"Inspect" = "Inspekcja" +"Inspect the layer under the pointer: size, colours, typography" = "Sprawdź warstwę pod kursorem: rozmiar, kolory, typografia" +"Install plugin" = "Zainstaluj wtyczkę" +"Install" = "Zainstaluj" +"Installed {name}" = "Zainstalowano {name}" +"Installed" = "Zainstalowano" +"Installing…" = "Instalowanie…" +"Instance" = "Instancja" +"Instance properties" = "Właściwości instancji" +"Instance swap" = "Podmiana instancji" +"Instance swap (no other component)" = "Podmiana instancji (brak innego komponentu)" +"Instances" = "Instancje" +"Instant" = "Natychmiastowo" +"Intensity" = "Siła" +"Interaction" = "Interakcja" +"Interactions" = "Interakcje" +"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Projektowanie interfejsów i prototypowanie. Darmowe, otwartoźródłowe, czysto implementowane narzędzie projektowe napisane od podstaw w Rust, część ArtCraft Crafting Apps." +"Intersect" = "Przetnij" +"Intersects" = "Przecina" +"Invalid color" = "Nieprawidłowy kolor" +"Invite link" = "Link z zaproszeniem" +"Invite link copied" = "Skopiowano link z zaproszeniem" +"It asks permission to:" = "Wymaga uprawnień do:" +"Jan" = "Sty" +"Join" = "Połącz" +"Join code copied" = "Skopiowano kod dołączenia" +"Join code or invite link" = "Kod dołączenia lub link z zaproszeniem" +"Join live session" = "Dołącz do sesji na żywo" +"Join us on Discord" = "Dołącz do nas na Discordzie" +"Join with a code" = "Dołącz za pomocą kodu" +"Joined" = "Dołączono" +"Joined {file}" = "Dołączono do: {file}" +"Joining…" = "Dołączanie…" +"Join|stroke" = "Join|stroke" +"Jul" = "Lip" +"Jun" = "Cze" +"Just now" = "Przed chwilą" +"Justify" = "Wyjustuj" +"Keep appearance" = "Zachowaj wygląd" +"Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Zachowanie wyglądu konwertuje każdą wartość koloru na {profile}, aby kolory wyglądały tak samo. Zachowanie wartości pozostawia liczby bez zmian, co może zmienić odcienie barw." +"Keep tool selected after use" = "Pozostaw narzędzie wybrane po użyciu" +"Keep values" = "Zachowaj wartości" +"Key/Gamepad" = "Klawisz/Kontroler" +"Key/Gamepad {key}" = "Klawisz/Kontroler {key}" +"Keyboard shortcuts" = "Skróty klawiaturowe" +"Kits" = "Zestawy" +"Landing page" = "Strona docelowa (Landing page)" +"Language" = "Język" +"Lasso" = "Lasso" +"Last" = "Ostatni" +"Last change: {note}" = "Ostatnia zmiana: {note}" +"Last commit" = "Ostatni commit" +"Last modified" = "Ostatnia modyfikacja" +"Last on top" = "Ostatni na wierzchu" +"Layer blur" = "Rozmycie warstwy" +"Layer name" = "Nazwa warstwy" +"Layers" = "Warstwy" +"Layers in an instance take their position from the main component" = "Warstwy w instancji przyjmują pozycję z głównego komponentu" +"Layout" = "Układ" +"Layout grid" = "Siatka układu" +"Layout grid settings" = "Ustawienia siatki układu" +"Layout grids" = "Siatki układu" +"Leave session" = "Opuść sesję" +"Leaves" = "Opuszcza" +"Left" = "Do lewej" +"Left & right" = "Lewa i prawa" +"Left side" = "Lewa strona" +"Left side width" = "Szerokość lewej strony" +"Letter case" = "Wielkość liter" +"Letter spacing" = "Światło tekstu" +"Libraries" = "Biblioteki" +"Libraries in this file" = "Biblioteki w tym pliku" +"Library updates available" = "Dostępne aktualizacje biblioteki" +"Library: click to collapse" = "Biblioteka: kliknij, aby zwinąć" +"Library: click to expand" = "Biblioteka: kliknij, aby rozwinąć" +"Library: its variables are read-only here; edit them in the library file and publish it" = "Biblioteka: jej zmienne są tutaj tylko do odczytu; edytuj je w pliku biblioteki i opublikuj" +"Light" = "Lekki" +"Lighten" = "Jaśniej" +"Light|glass" = "Light|glass" +"Line" = "Linia" +"Line arrow" = "Strzałka liniowa" +"Line delta" = "Różnica wierszy" +"Line height" = "Interlinia" +"Linear" = "Liniowy" +"Lines" = "Linie" +"Lines +/−" = "Wiersze +/−" +"Lines added" = "Dodane wiersze" +"Lines deleted" = "Usunięte wiersze" +"Link" = "Łącze" +"Link copied" = "Skopiowano link" +"Link docs, code or tickets for developers." = "Dołącz dokumentację, kod lub zgłoszenia dla programistów." +"Link frames on the Prototype tab, then present the flow" = "Połącz ramki na karcie Prototyp, a następnie zaprezentuj przepływ" +"Link this component to its code component: instances then show your real snippet with their values." = "Połącz ten komponent z jego odpowiednikiem w kodzie: instancje wyświetlą wtedy rzeczywisty fragment kodu z ich wartościami." +"List spacing" = "Odstępy listy" +"List style" = "Styl listy" +"Live session" = "Sesja na żywo" +"Live · {people}" = "Na żywo · {people}" +"Local components" = "Komponenty lokalne" +"Local network" = "Sieć lokalna" +"Local styles" = "Style lokalne" +"Local variables" = "Zmienne lokalne" +"Lock/Unlock" = "Zablokuj/Odblokuj" +"Locked" = "Zablokowane" +"Lowercase" = "Małe litery" +"Luminance" = "Luminancja" +"Luminosity" = "Jasność" +"Main" = "Główny" +"Main component deleted" = "Usunięto główny komponent" +"Main menu" = "Menu główne" +"Make a component" = "Utwórz komponent" +"Make slot" = "Utwórz gniazdo (slot)" +"Mar" = "Mar" +"Margin" = "Margines" +"Mark all as read" = "Oznacz wszystkie jako przeczytane" +"Mark as resolved" = "Oznacz jako rozwiązane" +"Mark as unread" = "Oznacz jako nieprzeczytane" +"Mark as unresolved" = "Oznacz jako nierozwiązane" +"Mask" = "Maska" +"Mass · Stiffness · Damping" = "Masa · Sztywność · Tłumienie" +"Match (empty: the whole name)" = "Dopasowanie (puste: cała nazwa)" +"Max H" = "Maks. wys." +"Max W" = "Maks. szer." +"Max lines" = "Maks. linii" +"Maximize" = "Maksymalizuj" +"May" = "maja" +"Measure to the selection" = "Zmierz odległość do zaznaczenia" +"Merge into main" = "Scal z gałęzią główną" +"Merge regions" = "Scal obszary" +"Merge the branch into the main file (one undo step) and archive it" = "Scal gałąź z plikiem głównym (jeden krok cofnięcia) i zarchiwizuj ją" +"Merged" = "Scalone" +"Merged PRs" = "Scalone PR" +"Min H" = "Min. wys." +"Min W" = "Min. szer." +"Minimize" = "Minimalizuj" +"Mirror angle" = "Kąt odbicia lustrzanego" +"Mirror angle and length" = "Kąt i długość odbicia lustrzanego" +"Mirroring" = "Odbicie lustrzane" +"Missing" = "Brak" +"Missing fonts" = "Brakujące czcionki" +"Missing layer" = "Brakująca warstwa" +"Missing style" = "Brakujący styl" +"Missing variable" = "Brakująca zmienna" +"Miter" = "Ostre" +"Miter angle" = "Kąt ścięcia (miter)" +"Mixed" = "Różne wartości" +"Mixed fonts" = "Różne czcionki" +"Mixed interactions: click + to replace them" = "Różne interakcje: kliknij +, aby je zastąpić" +"Mobile app" = "Aplikacja mobilna" +"Mode" = "Tryb" +"Model" = "Model" +"Models" = "Modele" +"Mono" = "Mono" +"More" = "Więcej" +"More instance actions" = "Więcej akcji instancji" +"Mouse down" = "Naciśnięcie myszy" +"Mouse enter" = "Wskazanie myszą (Mouse enter)" +"Mouse leave" = "Opuszczenie kursora myszy" +"Mouse up" = "Zwolnienie przycisku myszy" +"Move along the toolbar or the layer rows" = "Przechodź wzdłuż paska narzędzi lub wierszy warstw" +"Move" = "Przesuń" +"Move column left" = "Przesuń kolumnę w lewo" +"Move column right" = "Przesuń kolumnę w prawo" +"Move down" = "Przesuń w dół" +"Move in" = "Wsuń" +"Move out" = "Wysuń" +"Move tools" = "Narzędzia przesuwania" +"Move up" = "Przesuń w górę" +"Moved to {name}" = "Przeniesiono do: {name}" +"Multi" = "Wiele" +"Multiply" = "Mnożenie" +"Muted" = "Stonowany" +"Name" = "Nazwa" +"Name (A–Z)" = "Nazwa (A–Z)" +"Named instance" = "Nazwana instancja" +"Navigate to" = "Przejdź do" +"Nested instance" = "Zagnieżdżona instancja" +"New design file" = "Nowy plik projektu" +"Newest first" = "Najpierw najnowsze" +"Next or previous control in the focused region" = "Następny lub poprzedni element w aktywnym obszarze" +"Next frame" = "Następna klatka" +"Next ready for dev" = "Następny element gotowy do wdrożenia" +"Next size option" = "Następna opcja rozmiaru" +"No assets: no export settings, icons or images. Add export settings in the Design tab." = "Brak zasobów: brak ustawień eksportu, ikon lub obrazów. Dodaj ustawienia eksportu na karcie Projekt." +"No boolean variables fit here." = "Żadna zmienna logiczna (boolean) tutaj nie pasuje." +"No boolean variables yet. Create them in Local variables." = "Brak zmiennych logicznych. Utwórz je w Zmiennych lokalnych." +"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Brak gałęzi. Gałąź to kopia pliku do testowania zmian, a następnie ich przeglądu i scalenia." +"No category" = "Bez kategorii" +"No changes" = "Brak zmian" +"No color styles" = "Brak stylów kolorów" +"No colour variables fit here." = "Żadna zmienna koloru tutaj nie pasuje." +"No colour variables yet. Create them in Local variables." = "Brak zmiennych koloru. Utwórz je w Zmiennych lokalnych." +"No comments yet" = "Brak komentarzy" +"No compatibility warnings reported" = "Nie zgłoszono ostrzeżeń o zgodności" +"No components yet. Select layers and press {keys}." = "Brak komponentów. Zaznacz warstwy i naciśnij {keys}." +"No conflicts." = "Brak konfliktów." +"No contributor data was built into this copy." = "Ta kopia nie zawiera danych o współtwórcach." +"No device" = "Brak urządzenia" +"No fonts match" = "Brak pasujących czcionek" +"No files or templates match your search" = "Żaden plik ani szablon nie pasuje do wyszukiwania" +"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Brak kodu dołączenia (serwer jest starszy niż kody dołączenia lub ten adres nie może być zakodowany): zamiast tego udostępnij link." +"No layer changes to show." = "Brak zmian warstw do wyświetlenia." +"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Brak bibliotek. Dodaj plik .figcraft opublikowany jako biblioteka lub wklej link do biblioteki zespołu." +"No list" = "Brak listy" +"No matching comments" = "Brak pasujących komentarzy" +"No matching components" = "Brak pasujących komponentów" +"No matching layers" = "Brak pasujących warstw" +"No matching styles" = "Brak pasujących stylów" +"No matching variables" = "Brak pasujących zmiennych" +"No mirroring" = "Brak odbicia lustrzanego" +"No model credits were built into this copy." = "Ta kopia nie zawiera informacji o modelach." +"No number variables fit here." = "Żadna zmienna liczbowa tutaj nie pasuje." +"No number variables yet. Create them in Local variables." = "Brak zmiennych liczbowych. Utwórz je w Zmiennych lokalnych." +"No other pages" = "Brak innych stron" +"No paints" = "Brak wypełnień/obrysów" +"No plugins on {server}." = "Brak wtyczek na {server}." +"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Brak wtyczek. Zaimportuj wtyczkę deweloperską z pliku figcraft-plugin.json." +"No preferred components" = "Brak preferowanych komponentów" +"No preset" = "Brak presetu" +"No saved versions yet. Save a version to mark a point in your work you can come back to." = "Brak zapisanych wersji. Zapisz wersję, aby oznaczyć punkt w pracy, do którego możesz wrócić." +"No scrolling" = "Brak przewijania" +"No shortcuts match" = "Brak pasujących skrótów" +"No status" = "Brak statusu" +"No string variables fit here." = "Żadna zmienna tekstowa tutaj nie pasuje." +"No string variables yet. Create them in Local variables." = "Brak zmiennych tekstowych. Utwórz je w Zmiennych lokalnych." +"No styles yet." = "Brak stylów." +"No styles yet. Click + to create one from the selection." = "Brak stylów. Kliknij +, aby utworzyć styl z zaznaczenia." +"No templates match your search" = "Żaden szablon nie pasuje do wyszukiwania" +"No text styles yet. Click + to create one from the selected text." = "Brak stylów tekstu. Kliknij +, aby utworzyć styl z zaznaczonego tekstu." +"No variables fit here." = "Żadne zmienne tutaj nie pasują." +"No variables yet. Create them in Local variables." = "Brak zmiennych. Utwórz je w Zmiennych lokalnych." +"Nobody else is here yet" = "Nikogo innego tu jeszcze nie ma" +"Noise" = "Szum" +"None" = "Brak" +"None." = "Brak." +"Normal" = "Zwykły" +"Not bound" = "Nieprzypisane" +"Not in a live session" = "Nie jesteś w sesji na żywo" +"Not in this font" = "Brak w tej czcionce" +"Not properties, kept as written: {names}" = "Nie są to właściwości, zachowano bez zmian: {names}" +"Not read yet" = "Jeszcze nieprzeczytane" +"Not shared" = "Nieudostępnione" +"Note {n}" = "Notatka {n}" +"Nothing changed since this was marked ready for dev." = "Nic się nie zmieniło od czasu oznaczenia jako gotowe do wdrożenia." +"Nothing here yet. Set a frame's status to list it." = "Nic tu jeszcze nie ma. Ustaw status ramki, aby ją wyświetlić." +"Nothing online yet. Use Share on a file to put it online and it is listed here." = "Brak plików online. Użyj Udostępnij na pliku, aby umieścić go online i wyświetlić tutaj." +"Nothing to export" = "Nic do wyeksportowania" +"Nov" = "Lis" +"Now" = "Teraz" +"Nudge (Shift: big nudge)" = "Krok przesunięcia (Shift: duży krok)" +"Nudge amount" = "Wielkość kroku przesunięcia" +"Number" = "Liczba" +"Number ↑" = "Liczba ↑" +"Number ↓" = "Liczba ↓" +"Numbered list" = "Lista numerowana" +"Object" = "Obiekt" +"Oct" = "Paź" +"Off" = "Wyłączone" +"Offered first when filling the slot (any content is allowed)" = "Proponowane w pierwszej kolejności przy wypełnianiu gniazda (dowolna zawartość jest dozwolona)" +"Offered first when swapping the instance (any component is allowed)" = "Proponowane w pierwszej kolejności przy podmianie instancji (dowolny komponent jest dozwolony)" +"Offset" = "Przesunięcie" +"Oldest first" = "Najpierw najstarsze" +"On click" = "Przy kliknięciu" +"On drag" = "Podczas przeciągania" +"On this page" = "Na tej stronie" +"One announcement in five common sizes: square, portrait, story, link and banner" = "Jedno ogłoszenie w pięciu popularnych rozmiarach: kwadrat, pion, relacja, link i baner" +"Online" = "Online" +"Online files" = "Pliki online" +"Online sharing is off: files are shared on the local network only." = "Udostępnianie online jest wyłączone: pliki są udostępniane tylko w sieci lokalnej." +"Only the first changed layers are listed." = "Wymieniono tylko pierwsze zmienione warstwy." +"Only the first {n} changed layers are listed." = "Wymieniono tylko pierwsze {n} zmienionych warstw." +"Only your threads" = "Tylko Twoje wątki" +"Opacity" = "Krycie" +"Opacity & blend" = "Nieprzezroczystość i mieszanie" +"Opacity 10%…90%, 0: 100%" = "Nieprzezroczystość 10%…90%, 0: 100%" +"Open" = "Otwórz" +"Open .figcraft, .svg or images" = "Otwórz .figcraft, .svg lub obrazy" +"Open branch" = "Otwórz gałąź" +"Open file" = "Otwórz plik" +"Open files" = "Otwórz pliki" +"Open in focus view" = "Otwórz w widoku skupienia" +"Open in new window" = "Otwórz w nowym oknie" +"Open invite links in FigCraft" = "Otwieraj linki z zaproszeniem w FigCraft" +"Open link" = "Otwórz łącze" +"Open link {url}" = "Otwórz link: {url}" +"Open overlay" = "Otwórz nakładkę" +"Open prototype preview" = "Otwórz podgląd prototypu" +"Open variables ({count})" = "Otwórz zmienne ({count})" +"Opened as a new document" = "Otwarto jako nowy dokument" +"Opened “{name}” in a new tab" = "Otwarto „{name}” w nowej karcie" +"Opening the file…" = "Otwieranie pliku…" +"Opening {n} files as new files" = { one = "Otwieranie jako nowy plik", few = "Otwieranie {n} plików jako nowe pliki", many = "Otwieranie {n} plików jako nowe pliki", other = "Otwieranie {n} plików jako nowe pliki" } +"Open|comments" = "Open|comments" +"Options" = "Opcje" +"Orange" = "Pomarańczowy" +"Order" = "Kolejność" +"Other apps may not open every .fig file exported here." = "Inne aplikacje mogą nie otworzyć każdego pliku .fig wyeksportowanego tutaj." +"Other" = "Inne" +"Others lose access and keep their copy; the file stays on the server for you" = "Inni tracą dostęp i zachowują swoją kopię; plik pozostaje na serwerze dla Ciebie" +"Others type this under Share ▸ Join with a code" = "Inni wpisują to w menu Udostępnij ▸ Dołącz za pomocą kodu" +"Outline stroke" = "Przekształć obrys w ścieżkę" +"Outline text" = "Kontury tekstu" +"Outlines" = "Kontury" +"Outside" = "Na zewnątrz" +"Overflow" = "Przepełnienie (overflow)" +"Overlay" = "Nakładka" +"Overlay position" = "Pozycja nakładki" +"Overridden here (the collection it extends has another value)" = "Nadpisane tutaj (kolekcja, którą rozszerza, ma inną wartość)" +"Overrides" = "Nadpisania" +"Owner token" = "Token właściciela" +"Owner token copied" = "Skopiowano token właściciela" +"PDF print options" = "Opcje druku PDF" +"PDF print settings" = "Ustawienia druku PDF" +"Padding" = "Marginesy wewnętrzne" +"Page" = "Strona" +"Pages" = "Strony" +"Pages added to the document" = "Strony dodane do dokumentu" +"Placed on the current page" = "Umieszczono na bieżącej stronie" +"Pages: {pages}" = "Strony: {pages}" +"Paint bucket" = "Wiadro z farbą" +"Pan the canvas" = "Przesuwaj płótno" +"Pan with nothing selected (Shift: farther)" = "Przesuwaj przy braku zaznaczenia (Shift: dalej)" +"Panels" = "Panele" +"Paper" = "Papier" +"Paragraph indent" = "Wcięcie akapitu" +"Paragraph indent applies to left-aligned text" = "Wcięcie akapitu dotyczy tekstu wyrównanego do lewej" +"Paragraph spacing" = "Odstęp akapitu" +"Pass through" = "Przepuszczanie" +"Paste a link (https://…)" = "Wklej link (https://…)" +"Paste a team library link (a view link)" = "Wklej link do biblioteki zespołu (link do podglądu)" +"Paste here" = "Wklej tutaj" +"Paste to replace" = "Wklej, aby zastąpić" +"Pasted design layers; {n} kinds of content were left out" = { one = "Wklejono warstwy projektu; pominięto {n} rodzaj zawartości", few = "Wklejono warstwy projektu; pominięto {n} rodzaje zawartości", many = "Wklejono warstwy projektu; pominięto {n} rodzajów zawartości", other = "Wklejono warstwy projektu; pominięto {n} rodzajów zawartości" } +"Pattern" = "Wzór" +"Pattern alignment" = "Wyrównanie wzoru" +"Pattern scale" = "Skala wzoru" +"Pattern source" = "Źródło wzoru" +"Pen" = "Pióro" +"Pencil" = "Ołówek" +"People in this file" = "Osoby w tym pliku" +"People join from anywhere with a code. The file is kept on {server} while you share it." = "Użytkownicy dołączają z dowolnego miejsca za pomocą kodu. Plik jest przechowywany na {server} podczas udostępniania." +"People on your network join with a code. Nothing goes through a server." = "Użytkownicy w Twojej sieci dołączają za pomocą kodu. Nic nie przechodzi przez serwer zewnętrzny." +"Phone" = "Telefon" +"Pick color" = "Wybierz kolor" +"Pick color (I)" = "Wybierz kolor (I)" +"Pick one font family first" = "Najpierw wybierz jedną rodzinę czcionek" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "Wybierz narzędzie Ramka, a następnie przeciągnij po płótnie lub wybierz rozmiar urządzenia" +"Pin a property" = "Przypnij właściwość" +"Pinch" = "Uszczypnięcie" +"Pixel grid" = "Siatka pikseli" +"Pixel preview 1x" = "Podgląd pikselowy 1x" +"Pixel preview 2x" = "Podgląd pikselowy 2x" +"Place a shape or frame (its tool chosen)" = "Umieść kształt lub ramkę (wybrane narzędzie)" +"Place automatically" = "Umieść automatycznie" +"Place image" = "Umieść obraz" +"Plugin" = "Wtyczka" +"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Wtyczki działają w piaskownicy FigCraft, bez dostępu do plików, sieci czy zegara. Instaluj tylko zaufane wtyczki." +"Plugins" = "Wtyczki" +"Plugins & widgets" = "Wtyczki i widżety" +"Plus darker" = "Plus ciemniejszy" +"Plus lighter" = "Plus jaśniejszy" +"Point" = "Punkt" +"Points" = "Punkty" +"Polygon" = "Wielokąt" +"Polygons" = "Wielokąty" +"Port" = "Port" +"Position" = "Położenie" +"Post" = "Opublikuj" +"Preferences" = "Preferencje" +"Preferred" = "Preferowane" +"Present" = "Prezentuj" +"Present or preview" = "Prezentuj lub wyświetl podgląd" +"Present this flow" = "Prezentuj ten przepływ" +"Presentation" = "Prezentacja" +"Presentation slides" = "Slajdy prezentacji" +"Presenting: comments" = "Prezentacja: komentarze" +"Presenting: cycle the size options" = "Prezentacja: przełączaj opcje rozmiaru" +"Presenting: full screen" = "Prezentacja: pełny ekran" +"Presenting: next frame" = "Prezentacja: następna ramka" +"Presenting: previous frame" = "Prezentacja: poprzednia ramka" +"Presenting: restart" = "Prezentacja: uruchom ponownie" +"Preserve scroll position" = "Zachowaj pozycję przewijania" +"Presets" = "Ustawienia wstępne" +"Press a key…" = "Naciśnij klawisz…" +"Playground" = "Piaskownica (Playground)" +"Preview" = "Podgląd" +"Preview this flow" = "Podgląd tego przepływu" +"Preview this frame" = "Podgląd tej ramki" +"Previous frame" = "Poprzednia klatka" +"Previous ready for dev" = "Poprzedni gotowy do wdrożenia" +"Progressive" = "Progresywny" +"Properties" = "Właściwości" +"Prototype" = "Prototyp" +"Prototype preview" = "Podgląd prototypu" +"Prototype settings" = "Ustawienia prototypu" +"Publish" = "Publikuj" +"Publish changes" = "Publikuj zmiany" +"Publish to another server…" = "Publikuj na innym serwerze…" +"Publish to server" = "Publikuj na serwerze" +"Published version {v}" = "Opublikowano wersję {v}" +"Published version {v} to the server" = "Opublikowano wersję {v} na serwerze" +"Published version {v}: save the file so other files can use it" = "Opublikowano wersję {v}: zapisz plik, aby inne pliki mogły go używać" +"Published · {people}" = "Opublikowano · {people}" +"Publishing to a server needs the desktop app; in the browser you can join a server's files" = "Publikowanie na serwerze wymaga aplikacji desktopowej; w przeglądarce możesz dołączać do plików serwera" +"Publishing with the server's key." = "Publikowanie przy użyciu klucza serwera." +"Publishing…" = "Publikowanie…" +"Push" = "Wypychanie" +"Push changes to main component" = "Wypchnij zmiany do głównego komponentu" +"Put text on path" = "Umieść tekst na ścieżce" +"R" = "R" +"Radial" = "Promienisty" +"Radius" = "Promień" +"Ratio" = "Proporcje" +"Reading the library…" = "Odczytywanie biblioteki…" +"Ready for dev" = "Gotowe do wdrożenia" +"Real name" = "Imię i nazwisko" +"Recent" = "Ostatnie" +"Recent files" = "Ostatnie pliki" +"Recents" = "Ostatnie" +"Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them" = { one = "Odzyskano niezapisany plik po nieoczekiwanym zamknięciu programu: zapisz go, aby go zachować", few = "Odzyskano {n} niezapisane pliki po nieoczekiwanym zamknięciu programu: zapisz je, aby je zachować", many = "Odzyskano {n} niezapisanych plików po nieoczekiwanym zamknięciu programu: zapisz je, aby je zachować", other = "Odzyskano {n} niezapisanych plików po nieoczekiwanym zamknięciu programu: zapisz je, aby je zachować" } +"Rectangle" = "Prostokąt" +"Rectangles" = "Prostokąty" +"Red" = "Czerwony" +"Reduce motion" = "Ogranicz ruch" +"Refraction" = "Załamanie światła (refrakcja)" +"Regular expression" = "Wyrażenie regularne" +"Remove" = "Usuń" +"Remove all" = "Usuń wszystko" +"Remove annotation" = "Usuń adnotację" +"Remove auto layout" = "Usuń Auto layout" +"Remove dev resource" = "Usuń zasób programistyczny" +"Remove export setting" = "Usuń ustawienie eksportu" +"Remove from recents" = "Usuń z ostatnich" +"Remove from server" = "Usuń z serwera" +"Remove from server…" = "Usuń z serwera…" +"Remove guide" = "Usuń linię pomocniczą" +"Remove interaction" = "Usuń interakcję" +"Remove library from this file" = "Usuń bibliotekę z tego pliku" +"Remove min and max" = "Usuń min. i maks." +"Remove min and max height" = "Usuń minimalną i maksymalną wysokość" +"Remove min and max width" = "Usuń minimalną i maksymalną szerokość" +"Remove regions" = "Usuń obszary" +"Remove slot" = "Usuń gniazdo" +"Remove starting point" = "Usuń punkt startowy" +"Remove stop" = "Usuń punkt" +"Remove text from path" = "Usuń tekst ze ścieżki" +"Remove the else and its actions" = "Usuń warunek przeciwny (else) i jego akcje" +"Remove this condition and its actions" = "Usuń ten warunek i jego akcje" +"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Usunąć ten plik z serwera? Nie będzie można go już stamtąd udostępnić ani otworzyć. Twoja kopia pozostanie otwarta tutaj." +"Remove {name} from session" = "Usuń {name} z sesji" +"Remove {name} from {server}?" = "Usunąć {name} z {server}?" +"Removed" = "Usunięto" +"Removed from the server" = "Usunięto z serwera" +"Removed since it was marked" = "Usunięto od momentu oznaczenia" +"Rename" = "Zmień nazwę" +"Rename duplicated layers" = "Zmień nazwę powielonych warstw" +"Rename folder" = "Zmień nazwę folderu" +"Rename group" = "Zmień nazwę grupy" +"Rename layers" = "Zmień nazwę warstw" +"Rename mode" = "Zmień nazwę trybu" +"Rename on the server" = "Zmień nazwę na serwerze" +"Rename on the server…" = "Zmień nazwę na serwerze…" +"Rename page" = "Zmień nazwę strony" +"Rename to" = "Zmień nazwę na" +"Replace all" = "Zamień wszystko" +"Replace mixed export settings" = "Zastąp zróżnicowane ustawienia eksportu" +"Replace with" = "Zamień na" +"Replace with a new interaction" = "Zastąp nową interakcją" +"Reply" = "Odpowiedz" +"Report" = "Raport" +"Report this file…" = "Zgłoś ten plik…" +"Reset" = "Resetuj" +"Reset adjustments" = "Zresetuj korekty" +"Reset all changes" = "Zresetuj wszystkie zmiany" +"Reset all overrides on this layer" = "Zresetuj wszystkie nadpisania na tej warstwie" +"Reset overrides" = "Zresetuj nadpisania" +"Reset slot" = "Zresetuj gniazdo" +"Reset this override" = "Zresetuj to nadpisanie" +"Reset to style" = "Przywróć styl" +"Reset to the inherited value" = "Przywróć wartość dziedziczoną" +"Resize by the nudge amount" = "Zmień rozmiar o wielkość kroku przesunięcia" +"Resize frames to the device's screen (or the window without a device): their constraints and auto layout respond" = "Dopasuj ramki do ekranu urządzenia (lub okna bez urządzenia): ich ograniczenia i automatyczny układ odpowiednio zareagują" +"Resize to fit" = "Dopasuj rozmiar" +"Resizing" = "Zmiana rozmiaru" +"Resolve" = "Rozwiąż" +"Resolve change" = "Rozwiąż zmianę" +"Resolved" = "Rozwiązany" +"Resources" = "Zasoby" +"Responsive" = "Responsywny" +"Restart" = "Uruchom ponownie" +"Restore" = "Przywróć" +"Restore main component" = "Przywróć główny komponent" +"Restore this version" = "Przywróć tę wersję" +"Restored “{name}”" = "Przywrócono „{name}”" +"Reverse the order" = "Odwróć kolejność" +"Reversed triangle" = "Odwrócony trójkąt" +"Review and merge" = "Przejrzyj i scal" +"Ribbon" = "Wstążka" +"Right" = "Do prawej" +"Right side" = "Prawa strona" +"Right side width" = "Szerokość prawej strony" +"Rotate 90°" = "Obróć o 90°" +"Rotate 90° right" = "Obróć o 90° w prawo" +"Rotate to landscape" = "Obróć do orientacji poziomej" +"Rotate to portrait" = "Obróć do orientacji pionowej" +"Rotation" = "Obrót" +"Rotation jitter" = "Drżenie obrotu (jitter)" +"Round" = "Okrągłe" +"Rounded" = "Zaokrąglone" +"Row" = "Wiersz" +"Row gap" = "Odstęp między wierszami" +"Row sizes" = "Rozmiary wierszy" +"Row span" = "Rozpiętość wierszy" +"Rows" = "Wiersze" +"Rows {count}" = "Wiersze: {count}" +"Rulers" = "Linijki" +"Run" = "Uruchom" +"Run {label}" = "Uruchom {label}" +"Save files automatically" = "Zapisuj pliki automatycznie" +"Search templates" = "Szukaj szablonów" +"See all templates" = "Zobacz wszystkie szablony" +"Select layers and they space themselves as their content changes" = "Zaznacz warstwy, a będą automatycznie zachowywać odstępy przy zmianie zawartości" +"SHA-256: {sha}…" = "SHA-256: {sha}…" +"Same as layer" = "Taki sam jak warstwa" +"Saturation" = "Nasycenie" +"Save" = "Zapisz" +"Save as" = "Zapisz jako" +"Save as preset…" = "Zapisz jako preset…" +"Save version {key}" = "Zapisz wersję {key}" +"Saved" = "Zapisano" +"Saved {name}" = "Zapisano: {name}" +"Say something" = "Napisz coś" +"Scale" = "Skaluj" +"Scatter" = "Rozproszenie" +"Scoping: where pickers offer it" = "Zakres: gdzie próbniki go oferują" +"Screen" = "Ekran" +"Scroll behavior" = "Zachowanie przewijania" +"Scroll to" = "Przewiń do" +"Scroll to layer" = "Przewiń do warstwy" +"Scroll with parent" = "Przewijaj z rodzicem" +"Search plugins" = "Szukaj wtyczek" +"Search" = "Szukaj" +"Search actions, layers and tools" = "Szukaj akcji, warstw i narzędzi" +"Search assets" = "Szukaj zasobów" +"Search comments" = "Szukaj komentarzy" +"Search files and templates" = "Szukaj plików i szablonów" +"Search results" = "Wyniki wyszukiwania" +"Search fonts" = "Szukaj czcionek" +"Search layers" = "Szukaj warstw" +"Search shortcuts" = "Szukaj skrótów" +"Section" = "Sekcja" +"Section color" = "Kolor sekcji" +"Sections" = "Sekcje" +"Select a frame and add a starting point to present it as a flow." = "Wybierz ramkę i dodaj punkt początkowy, aby zaprezentować ją jako przepływ." +"Select a frame to preview it" = "Wybierz ramkę, aby wyświetlić jej podgląd" +"Select a layer to see its properties and code" = "Wybierz warstwę, aby zobaczyć jej właściwości i kod" +"Select all" = "Zaznacz wszystko" +"Select all with same" = "Zaznacz wszystko z takim samym" +"Select layer" = "Zaznacz warstwę" +"Select layers to apply the style to" = "Zaznacz warstwy, do których chcesz zastosować styl" +"Select layers with this color" = "Zaznacz warstwy z tym kolorem" +"Select overlapping shapes" = "Zaznacz nakładające się kształty" +"Select points to edit their position, radius and mirroring" = "Zaznacz punkty, aby edytować ich pozycję, promień i odbicie lustrzane" +"Select slot" = "Zaznacz gniazdo (slot)" +"Selection colors" = "Kolory zaznaczenia" +"Selection" = "Zaznaczenie" +"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Ramka zaznaczenia, {w} × {h} w ({x}, {y}): strzałki przesuwają, Shift+strzałki lub {cmd}+strzałki zmieniają rozmiar, Enter zatwierdza, Escape anuluje" +"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Ramka zaznaczenia: strzałki przesuwają, Shift+strzałki lub {cmd}+strzałki zmieniają rozmiar, Enter zatwierdza, Escape anuluje" +"Send backward" = "Przenieś do tyłu" +"Send report" = "Wyślij zgłoszenie" +"Send to back" = "Przenieś na spód" +"Sep" = "Wrz" +"Server address (ws://host:47900/)" = "Adres serwera (ws://host:47900/)" +"Server address" = "Adres serwera" +"Server for online sharing" = "Serwer do udostępniania online" +"Set as default" = "Ustaw jako domyślne" +"Set variable" = "Ustaw zmienną" +"Set variable mode" = "Ustaw tryb zmiennej" +"Set variable {name}" = "Ustaw zmienną {name}" +"Set {name} to {value}" = "Ustaw {name} na {value}" +"Shadows" = "Cienie" +"Shape" = "Kształt" +"Shape builder" = "Kreator kształtów" +"Shape tools" = "Narzędzia kształtów" +"Share" = "Udostępnij" +"Share this file" = "Udostępnij ten plik" +"Share with a code" = "Udostępnij za pomocą kodu" +"Share: live session, export" = "Udostępnianie: sesja na żywo, eksport" +"Shared" = "Udostępniono" +"Shared: copy its code from the file's menu" = "Udostępniono: skopiuj kod z menu pliku" +"Sharing a file needs the desktop app; here you can join one." = "Udostępnianie pliku wymaga aplikacji desktopowej; tutaj możesz tylko dołączyć." +"Sharing on the local network (online sharing is off: no server is set in Preferences)." = "Udostępnianie w sieci lokalnej (udostępnianie online wyłączone: brak serwera w Preferencjach)." +"Sharing on your network · {people}" = "Udostępnianie w Twojej sieci · {people}" +"Sharing online · {people}" = "Udostępnianie online · {people}" +"Sharing stopped: the file stays on the server, open only to you" = "Zatrzymano udostępnianie: plik pozostaje na serwerze, otwarty tylko dla Ciebie" +"Show" = "Pokaż" +"Show all templates" = "Pokaż wszystkie szablony" +"Show as grid" = "Pokaż jako siatkę" +"Show as list" = "Pokaż jako listę" +"Show autosaves" = "Pokaż automatyczne zapisy" +"Show behind transparent areas" = "Pokaż za przezroczystymi obszarami" +"Show comments (Shift+C)" = "Pokaż komentarze (Shift+C)" +"Show device frame" = "Pokaż ramkę urządzenia" +"Show dimensions on objects" = "Pokaż wymiary na obiektach" +"Show hotspot hints" = "Pokaż podpowiedzi obszarów klikalnych (hotspots)" +"Show in Explorer" = "Pokaż w Eksploratorze" +"Show in Finder" = "Pokaż w Finderze" +"Show in folder" = "Pokaż w folderze" +"Show only this on the canvas" = "Pokaż tylko ten element na płótnie" +"Show open" = "Pokaż otwarte" +"Show section contents" = "Pokaż zawartość sekcji" +"Show this layer" = "Pokaż tę warstwę" +"Show usage" = "Pokaż użycie" +"Show/Hide" = "Pokaż/Ukryj" +"Show/Hide UI" = "Pokaż/Ukryj interfejs" +"Side by side" = "Obok siebie" +"Sidebar" = "Panel boczny" +"Simplify stroke" = "Uprość obrys" +"Size" = "Rozmiar" +"Size jitter" = "Rozrzut rozmiaru" +"Skip ink" = "Pomiń atrament" +"Slashed zero" = "Przekreślone zero" +"Slice" = "Wycinek" +"Slices" = "Plasterki" +"Slide in" = "Wsuń slajd" +"Slide out" = "Wysuń slajd" +"Slot" = "Gniazdo (slot)" +"Slot content" = "Zawartość gniazda" +"Small capitals" = "Kapitaliki" +"Small caps" = "Kapitaliki" +"Smart animate" = "Inteligentna animacja (Smart animate)" +"Snap to pixel grid" = "Przyciągaj do siatki pikseli" +"Social and slides" = "Social media i slajdy" +"Social media" = "Media społecznościowe" +"Social post sizes" = "Rozmiary postów w mediach społecznościowych" +"Soft light" = "Łagodne światło" +"Solid" = "Jednolity" +"Some variants have the same property values: give each one a unique combination" = "Niektóre warianty mają takie same wartości właściwości: nadaj każdemu unikalną kombinację" +"Sort" = "Sortuj" +"Sort by" = "Sortuj według" +"Sort by date" = "Sortuj według daty" +"Sort by page" = "Sortuj według strony" +"Sort by {column}" = "Sortuj według: {column}" +"Space around" = "Równe odstępy z marginesem (Space around)" +"Space between" = "Równe odstępy między elementami (Space between)" +"Space drag" = "Przesuwanie spacją" +"Space evenly" = "Rozmieść równomiernie" +"Space+drag" = "Spacja+przeciągnięcie" +"Spacing" = "Odstępy" +"Spam, stolen work, abuse…" = "Spam, plagiat, naruszenie zasad…" +"Splay" = "Rozszerzenie" +"Spotlight me" = "Wyróżnij mój widok (Spotlight me)" +"Spread" = "Rozszerzenie" +"Square" = "Kwadratowe" +"Standard" = "Standardowy" +"Standard ligatures" = "Ligatury standardowe" +"Star" = "Gwiazda" +"Stars" = "Gwiazdy" +"Start" = "Uruchom" +"Start ascending from / stop descending at" = "Początek narastania od / koniec opadania w" +"Start from a blank canvas" = "Zacznij od pustego płótna" +"Start from a template" = "Zacznij od szablonu" +"Start on path" = "Początek na ścieżce" +"Start point" = "Punkt początkowy" +"Start sharing" = "Rozpocznij udostępnianie" +"Start sharing with the Share button, then pass on the code it gives" = "Rozpocznij udostępnianie przyciskiem Udostępnij, a następnie przekaż wygenerowany kod" +"Starting to share…" = "Rozpoczynanie udostępniania…" +"Status" = "Stan" +"Sticky" = "Przyklejony (sticky)" +"Stop" = "Zatrzymaj" +"Stop following {name}" = "Przestań obserwować: {name}" +"Stop session" = "Zatrzymaj sesję" +"Stop sharing" = "Zatrzymaj udostępnianie" +"Stop spotlighting" = "Zatrzymaj wyróżnianie widoku" +"Stop {n} {hex}" = "Punkt przejścia {n} {hex}" +"Stretch" = "Rozciągnięcie" +"Strikethrough" = "Przekreślenie" +"String" = "Tekst (string)" +"Stroke" = "Obrys" +"Stroke style" = "Styl obrysu" +"Stroke weight" = "Grubość obrysu" +"Strokes" = "Obrysy" +"Style" = "Styl" +"Styles" = "Style" +"Stylistic set 1" = "Zestaw stylistyczny 1" +"Stylistic set 2" = "Zestaw stylistyczny 2" +"Stylistic set 3" = "Zestaw stylistyczny 3" +"Stylistic set 4" = "Zestaw stylistyczny 4" +"Stylistic set 5" = "Zestaw stylistyczny 5" +"Subtract" = "Odejmij" +"Subtracts" = "Odejmuje" +"Suffix" = "Przyrostek" +"Suspend snapping while dragging" = "Zawieś przyciąganie podczas przeciągania" +"Swap instance" = "Podmień instancję" +"Swap library: use another library's assets of the same names" = "Podmień bibliotekę: użyj zasobów innej biblioteki o tych samych nazwach" +"Swap orientation" = "Zmień orientację" +"Swap overlay" = "Podmień nakładkę" +"Swap to {name}" = "Podmień na {name}" +"Swap width and height" = "Zamień szerokość i wysokość" +"Swapped instance" = "Podmieniono instancję" +"Swapped {n} assets" = { one = "Podmieniono {n} zasób", few = "Podmieniono {n} zasoby", many = "Podmieniono {n} zasobów", other = "Podmieniono {n} zasobów" } +"Swapped {n} assets; {missed} had no match and still use the old library" = { one = "Podmieniono {n} zasób; {missed} nie ma dopasowania i nadal używa starej biblioteki", few = "Podmieniono {n} zasoby; {missed} nie ma dopasowania i nadal używa starej biblioteki", many = "Podmieniono {n} zasobów; {missed} nie ma dopasowania i nadal używa starej biblioteki", other = "Podmieniono {n} zasobów; {missed} nie ma dopasowania i nadal używa starej biblioteki" } +"Sweep" = "Przemiatanie (sweep)" +"System" = "Systemowy" +"Tab / Shift+Tab" = "Tab / Shift+Tab" +"Table" = "Tabela" +"Tablet" = "Tablet" +"Tabular numbers" = "Cyfry tabelaryczne" +"Take the values from the first selected layer" = "Pobierz wartości z pierwszej zaznaczonej warstwy" +"Taper" = "Zwężanie" +"Taper both ends" = "Zwężaj oba końce" +"Tell the server's operator what is wrong with it" = "Poinformuj administratora serwera, co jest nie tak" +"Temperature" = "Temperatura" +"Template" = "Szablon" +"Templates" = "Szablony" +"Terms" = "Warunki" +"Text" = "Tekst" +"Text align" = "Wyrównanie tekstu" +"Text content" = "Zawartość tekstu" +"Text style" = "Styl tekstu" +"Text styles" = "Style tekstu" +"Texts" = "Teksty" +"Texture" = "Tekstura" +"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "Płótno wyświetla podgląd kolorów w sRGB; wyeksportowane pliki PNG, JPG i WebP są oznaczane profilem Display P3." +"The destination document is unavailable. Nothing was imported." = "Dokument docelowy jest niedostępny. Nic nie zostało zaimportowane." +"The destination document was closed. Nothing was imported." = "Dokument docelowy został zamknięty. Nic nie zostało zaimportowane." +"The destination page was removed. Nothing was imported." = "Strona docelowa została usunięta. Nic nie zostało zaimportowane." +"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "Plik został usunięty z serwera: nikt nie może go już stamtąd otworzyć ani do niego dołączyć, a tej operacji nie można cofnąć. Otwarte kopie pozostają u ich posiadaczy." +"The file now holds another library" = "Plik zawiera teraz inną bibliotekę" +"The file now holds another library (\"{name}\")" = "Plik zawiera teraz inną bibliotekę („{name}”)" +"The files you shared or published online from this computer. They stay on their server until you remove them." = "Pliki udostępnione lub opublikowane online z tego komputera. Pozostają na serwerze do momentu ich usunięcia." +"The link carries the session's secret: share it only with people you invite." = "Link zawiera sekret sesji: udostępniaj go tylko osobom, które zapraszasz." +"The name in your online files and on the server. The document inside keeps its own name." = "Nazwa w Twoich plikach online i na serwerze. Dokument wewnątrz zachowuje własną nazwę." +"The selection uses several text styles: click to apply one" = "Zaznaczenie używa kilku stylów tekstu: kliknij, aby zastosować jeden" +"The server deletes this file if nobody opens it before {date}." = "Serwer usunie ten plik, jeśli nikt go nie otworzy przed {date}." +"The whole file" = "Cały plik" +"Theme" = "Motyw" +"There's no destination for that action" = "Brak miejsca docelowego dla tej akcji" +"Thickness" = "Grubość" +"This branch can't be reviewed: {e}" = "Tej gałęzi nie można przejrzeć: {e}" +"This branch was merged or archived." = "Ta gałąź została scalona lub zarchiwizowana." +"This computer only" = "Tylko ten komputer" +"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Ten komputer pamięta token właściciela, więc plik może być zarządzany z listy Online na ekranie głównym. Skopiuj go, aby zarządzać plikiem z innego miejsca: jest to hasło do tego pliku i nie zostanie ponownie wyświetlone." +"This condition is edited as an expression" = "Ten warunek jest edytowany jako wyrażenie" +"This file" = "Ten plik" +"This file has unsaved changes" = "Ten plik ma niezapisane zmiany" +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Ten plik używa czcionek, które nie są zainstalowane na tym komputerze. Do czasu ich zainstalowania tekst jest wyświetlany czcionką zastępczą." +"This file is on {server}, open only to you. Share it to let others in with a new code." = "Ten plik znajduje się na {server}, otwarty tylko dla Ciebie. Udostępnij go, aby wpuścić innych nowym kodem." +"This frame is new on the branch." = "Ta ramka jest nowa w gałęzi." +"This frame isn't on the branch." = "Tej ramki nie ma w gałęzi." +"This server is open to the public: no key needed." = "Ten serwer jest otwarty publicznie: klucz nie jest wymagany." +"This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Ten serwer wymaga klucza: dodaj ?key=… do adresu (polecenie `figcraft-server key` wyświetla go na maszynie serwera)." +"This server takes files only with its key: see Advanced." = "Ten serwer przyjmuje pliki tylko z kluczem: zobacz Zaawansowane." +"Tidy up" = "Uporządkuj" +"Tile" = "Sąsiadująco" +"Tile type" = "Typ kafelka" +"Tint" = "Odcień barwny" +"Title" = "Tytuł" +"Title case" = "Jak W Tytule" +"To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Aby udostępnić tę bibliotekę zespołowi, opublikuj plik na serwerze (Udostępnij ▸ Publikuj na serwerze…), a następnie opublikuj tam bibliotekę." +"Toggle" = "Przełącz" +"Toggle light/dark theme" = "Przełącz motyw jasny/ciemny" +"Toggle visibility" = "Przełącz widoczność" +"Tools" = "Narzędzia" +"Top" = "Góra" +"Top & bottom" = "Góra i dół" +"Top left radius" = "Promień górnego lewego narożnika" +"Top right radius" = "Promień górnego prawego narożnika" +"Transform" = "Przekształć" +"Triangle arrow" = "Strzałka trójkątna" +"Trigger" = "Wyzwalacz" +"True" = "Prawda" +"Truncate text" = "Utnij tekst" +"Truncate with an ellipsis" = "Utnij tekst z wielokropkiem (…)" +"Try again" = "Spróbuj ponownie" +"Try another search or filter." = "Wypróbuj inne wyszukiwanie lub filtr." +"Try another search, or browse templates." = "Spróbuj innego wyszukiwania lub przejrzyj szablony." +"Try another word, or look at every kind." = "Wypróbuj inne słowo lub przejrzyj wszystkie typy." +"Turn a layer into a component, reuse it, and change every copy at once" = "Przekształć warstwę w komponent, używaj jej ponownie i modyfikuj wszystkie kopie naraz" +"Two digits" = "Dwie cyfry" +"Type" = "Tekst" +"Type settings" = "Ustawienia tekstu" +"Type the join code you were given, or paste an invite link." = "Wpisz otrzymany kod dołączenia lub wklej link z zaproszeniem." +"Typography" = "Typografia" +"Underline" = "Podkreślenie" +"Undo takes back only your own changes; what others changed since stays." = "Cofanie odwołuje tylko Twoje własne zmiany; modyfikacje wprowadzone przez innych pozostają." +"Ungroup" = "Rozgrupuj" +"Uniform" = "Jednolity" +"Union" = "Suma zbiorów" +"Unions" = "Sumy" +"Unknown — treated as sRGB" = "Nieznany — traktowany jako sRGB" +"Unread" = "Nieprzeczytane" +"Unresolve" = "Cofnij rozwiązanie" +"Unsaved changes" = "Niezapisane zmiany" +"Unsaved changes in {names}. Close the window and lose them?" = "Niezapisane zmiany w: {names}. Zamknąć okno i utracić je?" +"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Niezapisane zmiany: najpierw zapisz ({save}) lub zamknij kartę z {alt}-kliknięciem, aby odrzucić" +"Unsaved changes: save first, or close again with {key} to discard" = "Niezapisane zmiany: najpierw zapisz lub naciśnij ponownie {key}, aby odrzucić" +"Up" = "W górę" +"Update plugin" = "Zaktualizuj wtyczkę" +"Update" = "Aktualizuj" +"Update from main" = "Zaktualizuj z gałęzi głównej" +"Update from selection" = "Zaktualizuj z zaznaczenia" +"Updated {components} and {styles}" = "Zaktualizowano {components} i {styles}" +"Updated {name}" = "Zaktualizowano {name}" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Aktualizacja sterownika graficznego często pomaga. Aby używać tylko OpenGL, uruchom FigCraft z wiersza poleceń za pomocą:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Aktualizacja sterownika graficznego często pomaga. Zmienna WGPU_BACKEND (vulkan, gl, metal) pozwala wybrać inne graficzne API do wypróbowania." +"Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Przesyła ten plik na serwer FigCraft i synchronizuje tę kartę na bieżąco. Otrzymujesz link do edycji oraz link do podglądu." +"Uppercase" = "WIELKIE LITERY" +"Use a fixed value" = "Użyj stałej wartości" +"Use an expression, e.g. count + 1" = "Użyj wyrażenia, np. count + 1" +"Use as mask" = "Użyj jako maski" +"Use auto layout" = "Użyj automatycznego układu" +"Use number keys for opacity" = "Użyj klawiszy numerycznych dla nieprzezroczystości" +"Use the .figcraft or .fig extension." = "Użyj rozszerzenia .figcraft lub .fig." +"Use the layer's fill" = "Użyj wypełnienia warstwy" +"Used in {files} · {instances}" = "Używane w {files} · {instances}" +"Username" = "Nazwa użytkownika" +"Value" = "Wartość" +"Uses variables: each layer shows them in its own mode" = "Używa zmiennych: każda warstwa wyświetla je we własnym trybie" +"Variable" = "Zmienna" +"Variable collections" = "Kolekcje zmiennych" +"Variable modes" = "Tryby zmiennych" +"Variable width" = "Zmienna szerokość" +"Variables" = "Zmienne" +"Variables JSON…" = "JSON ze zmiennymi…" +"Variables store reusable values (colours, numbers, strings, booleans) with modes such as Light and Dark." = "Zmienne przechowują wartości wielokrotnego użytku (kolory, liczby, teksty, wartości logiczne) z trybami takimi jak Jasny i Ciemny." +"Variant" = "Wariant" +"Vector" = "Wektor" +"Vectors" = "Wektory" +"Version" = "Wersja" +"Version history" = "Historia wersji" +"Version {version}" = "Wersja {version}" +"Version {v} · {components} · {styles} · {variables}" = "Wersja {v} · {components} · {styles} · {variables}" +"Vertical" = "Pionowo" +"Vertical align" = "Wyrównanie pionowe" +"Vertical hex" = "Pionowy sześciokąt" +"Vertical padding" = "Marginesy pionowe" +"Vertical spacing" = "Odstępy pionowe" +"Vertical trim" = "Przycinanie pionowe (vertical trim)" +"View" = "Widok" +"View link" = "Link do podglądu" +"View link copied" = "Skopiowano link do podglądu" +"View only" = "Tylko podgląd" +"View only: you see every change as it happens but can't edit. Leave to keep a copy you can edit." = "Tylko podgląd: widzisz każdą zmianę w czasie rzeczywistym, ale nie możesz edytować. Opuść sesję, aby zachować kopię do edycji." +"Viewing “{name}”" = "Podglądanie „{name}”" +"Violet" = "Fiolet" +"Visibility" = "Widoczność" +"Visible" = "Widoczne" +"Vivid" = "Żywe kolory" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "Zmienna WGPU_BACKEND jest ustawiona i określa używane API graficzne; jej usunięcie pozwala FigCraft dokonać wyboru." +"Warm" = "Ciepły" +"Watch" = "Zegarek" +"Wavy" = "Falisty" +"Web" = "Internet" +"Websites" = "Strony internetowe" +"WebSocket URL" = "Adres WebSocket URL" +"WebSocket URL copied" = "Skopiowano adres WebSocket URL" +"Wedge" = "Klin" +"Weight" = "Grubość" +"What you try here changes this preview and the code below, not the file" = "To, co wypróbujesz tutaj, zmienia ten podgląd i kod poniżej, a nie plik" +"What changed since it was marked ready for dev" = "Co się zmieniło od czasu oznaczenia jako gotowe do wdrożenia" +"What is wrong with this file? The server's operator gets your report." = "Co jest nie tak z tym plikiem? Administrator serwera otrzyma Twoje zgłoszenie." +"What it's for" = "Do czego to służy" +"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "Nie wiadomo, w stosunku do czego zaszła zmiana: ramka zmieniła się, zanim FigCraft zaczął zapisywać kopię ramek gotowych do wdrożenia. Rozwiąż zmianę (lub oznacz ponownie jako gotowe), aby móc porównywać od teraz." +"While hovering" = "Podczas najechania" +"While pressing" = "Podczas naciskania" +"White" = "Biały" +"Who runs it, its terms and how long it keeps files" = "Kto zarządza serwerem, jego regulamin i czas przechowywania plików" +"Why it changed" = "Dlaczego to się zmieniło" +"Widget" = "Widżet" +"Widgets" = "Widżety" +"Width" = "Szerokość" +"Width point" = "Punkt szerokości" +"Width profile" = "Profil szerokości" +"Wireframe kit" = "Zestaw makiet (Wireframe kit)" +"With the layers' export settings" = "Z ustawieniami eksportu warstw" +"Wrap" = "Owinięcie" +"Wrap in new section" = "Zawiń w nową sekcję" +"Yellow" = "Żółty" +"Yesterday" = "Wczoraj" +"You" = "Ty" +"You'll get the shared file as a new tab and edit it together." = "Otrzymasz udostępniony plik jako nową kartę i będziecie go edytować wspólnie." +"You're spotlighting: everyone follows your view" = "Wyróżniasz swój widok (spotlight): wszyscy podążają za Twoim widokiem" +"Your copy of the file stays open" = "Twoja kopia pliku pozostaje otwarta" +"Your copy stays open; the file stays on the server" = "Twoja kopia pozostaje otwarta; plik pozostaje na serwerze" +"Your name" = "Twoje imię i nazwisko" +"Your name in comments" = "Twoje imię w komentarzach" +"Your system asks for reduced motion: animations stay off." = "Twój system preferuje ograniczony ruch: animacje pozostają wyłączone." +"Z" = "Z" +"Z to A" = "Od Z do A" +"Zoom" = "Powiększenie" +"Zoom around the pointer" = "Powiększaj wokół kursora" +"Zoom device to fill window" = "Dopasuj urządzenie do okna z powiększeniem" +"Zoom in" = "Powiększ" +"Zoom out" = "Pomniejsz" +"Zoom to 100%" = "Powiększ do 100%" +"Zoom to 200%" = "Powiększ do 200%" +"Zoom to 50%" = "Zmniejsz do 50%" +"Zoom to fit" = "Dopasuj do okna" +"Zoom to selection" = "Powiększaj do zaznaczenia" +"Zoom tool: click to zoom in, drag an area" = "Narzędzie powiększenia: kliknij, aby przybliżyć, przeciągnij obszar" +"Zoom tool: zoom out" = "Narzędzie powiększenia: oddal" +"and {n} more" = { one = "i jeszcze {n}", few = "i jeszcze {n}", many = "i jeszcze {n}", other = "i jeszcze {n}" } +"bleed marks" = "znaczniki spadu" +"by {author}" = "autor: {author}" +"call any command its access allows" = "wywołaj dowolne polecenie, na które zezwala dostęp" +"call only: {commands}" = "wywołaj tylko: {commands}" +"can edit the document" = "może edytować dokument" +"conditional prototype actions nested too deeply were skipped" = "zbyt głęboko zagnieżdżone warunkowe akcje prototypu zostały pominięte" +"conditional prototype actions with conditions that can't be evaluated were skipped" = "warunkowe akcje prototypu z warunkami niemożliwymi do obliczenia zostały pominięte" +"connector label background was skipped: style data is missing" = "tło etykiety łącznika zostało pominięte: brak danych stylu" +"connector label was skipped: background bounds are invalid" = "etykieta łącznika została pominięta: nieprawidłowe granice tła" +"connector label was skipped: background transform is invalid" = "etykieta łącznika została pominięta: nieprawidłowe przekształcenie tła" +"connector label was skipped: composed text transform is invalid" = "etykieta łącznika została pominięta: nieprawidłowe przekształcenie tekstu złożonego" +"connector label was skipped: derived background bounds are missing" = "etykieta łącznika została pominięta: brak wyznaczonych granic tła" +"connector label was skipped: derived text bounds are missing" = "etykieta łącznika została pominięta: brak wyznaczonych granic tekstu" +"connector label was skipped: text bounds are invalid" = "etykieta łącznika została pominięta: nieprawidłowe granice tekstu" +"connector label was skipped: text style data is missing" = "etykieta łącznika została pominięta: brak danych stylu tekstu" +"connector label was skipped: text transform is invalid" = "etykieta łącznika została pominięta: nieprawidłowe przekształcenie tekstu" +"connector labels were skipped" = "etykiety łączników zostały pominięte" +"connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained" = "warstwy łączników są importowane jako statyczne ramki/wektory z etykietami; aktywne punkty końcowe nie są zachowywane" +"connector snapshot vector data is missing; connector was left empty" = "brak danych wektorowych łącznika; łącznik pozostał pusty" +"connectors whose ends can't be placed are empty" = "łączniki, których końców nie można umieścić, pozostają puste" +"crop marks" = "Znaczniki przycięcia" +"damaged vector geometry was skipped" = "uszkodzona geometria wektorowa została pominięta" +"damaged vector networks import as outlines" = "uszkodzone sieci wektorowe są importowane jako kontury" +"development: {path}" = "wersja rozwojowa: {path}" +"document children other than pages were skipped" = "elementy podrzędne dokumentu inne niż strony zostały pominięte" +"duplicate grid track sizing entries use equal tracks" = "zduplikowane wpisy rozmiaru siatki używają równych pasów" +"e.g. count + 1" = "np. count + 1" +"e.g. count >= 3 and not done" = "np. count >= 3 and not done" +"elbowed and curved connectors without a saved path import as straight lines" = "złamane i zakrzywione łączniki bez zapisanej ścieżki są importowane jako linie proste" +"entries without a node type were skipped" = "wpisy bez typu węzła zostały pominięte" +"figcraft:// links from a browser, chat or email open this app and join the session." = "linki figcraft:// z przeglądarki, czatu lub poczty e-mail otwierają tę aplikację i dołączają do sesji." +"filled areas of a vector with their own fill use the layer's fill" = "wypełnione obszary wektora z własnym wypełnieniem używają wypełnienia warstwy" +"from {server}" = "z serwera {server}" +"gamepad triggers without exactly one known controller button were skipped" = "wyzwalacze kontrolera bez dokładnie jednego znanego przycisku zostały pominięte" +"grid track list exceeds 1000 entries; the tracks use the default equal sizing" = "lista ścieżek siatki przekracza 1000 pozycji; ścieżki używają domyślnego równego rozmiaru" +"grid track sizing is unsupported or malformed; affected tracks use equal sizing" = "rozmiar ścieżek siatki jest nieobsługiwany lub nieprawidłowy; dotknięte ścieżki używają równego rozmiaru" +"grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "lista rozmiarów ścieżek siatki przekracza 1000 pozycji; ścieżki używają domyślnego równego rozmiaru" +"hidden" = "ukryty" +"iOS" = "iOS" +"image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "filtry obrazu są niejednoznaczne, ponieważ obecne są oba kontenery filtrów; konwersja filtrów została pominięta" +"image filters use FigCraft's approximate renderer" = "filtry obrazu używają przybliżonego renderera FigCraft" +"instance swap properties whose default component isn't in the file were skipped" = "właściwości podmiany instancji, których domyślny komponent nie znajduje się w pliku, zostały pominięte" +"instances inside their own main component were imported as frames" = "instancje wewnątrz własnego głównego komponentu zostały zaimportowane jako ramki" +"instances of components that aren't in the file show no layers" = "instancje komponentów, których nie ma w pliku, nie pokazują żadnych warstw" +"instances of components that aren't in the file were imported as frames" = "instancje komponentów, których nie ma w pliku, zostały zaimportowane jako ramki" +"instances that would nest a component inside itself were imported as frames" = "instancje zagnieżdżające komponent wewnątrz samego siebie zostały zaimportowane jako ramki" +"instances without a main component were imported as empty frames" = "instancje bez głównego komponentu zostały zaimportowane jako puste ramki" +"invalid internal .fig text path data was ignored" = "nieprawidłowe wewnętrzne dane ścieżki tekstu .fig zostały zignorowane" +"invalid or oversized vector networks import as outlines" = "nieprawidłowe lub przewymiarowane sieci wektorowe są importowane jako kontury" +"invalid or oversized vector path data was skipped" = "nieprawidłowe lub przewymiarowane dane ścieżki wektorowej zostały pominięte" +"just now" = "przed chwilą" +"key triggers without a supported key were skipped" = "wyzwalacze klawiszy z nieobsługiwanym klawiszem zostały pominięte" +"key up triggers import as key presses" = "wyzwalacze zwolnienia klawisza są importowane jako naciśnięcia klawiszy" +"layers without a position were placed at their parent's origin" = "warstwy bez określonej pozycji zostały umieszczone w punkcie początkowym rodzica" +"layers without a type were skipped" = "warstwy bez typu zostały pominięte" +"legacy image filter container is unsupported; filter conversion was skipped" = "przestarzały kontener filtrów obrazu jest nieobsługiwany; konwersja filtrów została pominięta" +"locked" = "Zablokowane" +"malformed image filters container was skipped" = "nieprawidłowy kontener filtrów obrazu został pominięty" +"malformed image paintFilter container was skipped" = "nieprawidłowy kontener filtrów malowania (paintFilter) został pominięty" +"malformed slot content assignment was skipped" = "nieprawidłowe przypisanie zawartości gniazda zostało pominięte" +"no bleed" = "brak spadu" +"nobody else here yet" = "nikogo innego tu jeszcze nie ma" +"non-default slot settings are not represented and were skipped" = "niedomyślne ustawienia gniazd nie są reprezentowane i zostały pominięte" +"now" = "teraz" +"only in a text style or variable" = "tylko w stylu tekstu lub zmiennej" +"or" = "lub" +"point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "połączenia punktów obrysu zostały zachowane; obrysy przerywane lub o zmiennej szerokości renderują się z łączeniem warstwy" +"preferred slot component keys cannot be resolved to file nodes and were skipped" = "klucze preferowanych komponentów gniazda nie mogą zostać dopasowane do węzłów pliku i zostały pominięte" +"prototype actions of unknown types were skipped" = "akcje prototypu o nieznanych typach zostały pominięte" +"prototype actions without a destination in the file were skipped" = "akcje prototypu bez miejsca docelowego w pliku zostały pominięte" +"prototype expressions on variables that aren't in the variables JSON were skipped" = "wyrażenia prototypu operujące na zmiennych, których nie ma w pliku JSON ze zmiennymi, zostały pominięte" +"prototype expressions with unsupported functions were skipped" = "wyrażenia prototypu z nieobsługiwanymi funkcjami zostały pominięte" +"prototype interactions without supported actions were skipped" = "interakcje prototypu bez obsługiwanych akcji zostały pominięte" +"prototype navigations of unknown types were skipped" = "nawigacje prototypu o nieznanych typach zostały pominięte" +"prototype transitions of unknown types play instantly" = "przejścia prototypu o nieznanych typach odtwarzają się natychmiast" +"prototype triggers of unknown types were skipped" = "wyzwalacze prototypu o nieznanych typach zostały pominięte" +"put a widget on the canvas" = "umieść widżet na płótnie" +"read and edit the document" = "odczytuj i edytuj dokument" +"read the document" = "odczytuj dokument" +"reads the document" = "odczytuje dokument" +"set-variable actions on variables that aren't in the variables JSON were skipped" = "akcje set-variable operujące na zmiennych, których nie ma w pliku JSON ze zmiennymi, zostały pominięte" +"set-variable actions with values that can't be evaluated were skipped" = "akcje set-variable z wartościami, których nie można obliczyć, zostały pominięte" +"set-variable actions with values that don't fit the variable were skipped" = "akcje set-variable z wartościami niepasującymi do zmiennej zostały pominięte" +"set-variable actions without a value were skipped" = "akcje set-variable bez wartości zostały pominięte" +"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "akcje set-variable-mode dla kolekcji lub trybów, których nie ma w pliku JSON ze zmiennymi, zostały pominięte" +"slot content assignment target is missing or is not slot content" = "cel przypisania zawartości gniazda nie istnieje lub nie jest zawartością gniazda" +"slot content assignment uses the empty GUID sentinel and was skipped" = "przypisanie zawartości gniazda używa pustego identyfikatora GUID i zostało pominięte" +"slot layers without a matching slot property were imported as frames" = "warstwy gniazd bez pasującej właściwości gniazda zostały zaimportowane jako ramki" +"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "niektóre punkty wektorowe odwołują się do ustawień punktów brakujących w pliku; używają połączeń warstwy i zwykłych narożników" +"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "niektóre segmenty wektorowe odwołują się do ustawień stylu brakujących w pliku; używają obrysu warstwy" +"stale or invalid grid track sizing entries were ignored" = "nieaktualne lub nieprawidłowe wpisy rozmiarów ścieżek siatki zostały zignorowane" +"stars and polygons without point counts or geometry use the default point count" = "gwiazdy i wielokąty bez określonej liczby wierzchołków lub geometrii używają domyślnej liczby punktów" +"style settings of some vector segments aren't supported; those segments use the layer's stroke" = "ustawienia stylu niektórych segmentów wektorowych nie są obsługiwane; segmenty te używają obrysu warstwy" +"text path underline or strikethrough decoration is not rendered" = "podkreślenie lub przekreślenie tekstu na ścieżce nie jest renderowane" +"text style overrides refer to missing table entries" = "nadpisania stylu tekstu odwołują się do brakujących wpisów w tabeli" +"the file has no pages" = "plik nie zawiera stron" +"the plugin" = "wtyczka" +"the variables JSON has no `variableCollections` (expected the local variables response) and was ignored" = "plik JSON ze zmiennymi nie zawiera `variableCollections` (oczekiwano odpowiedzi ze zmiennymi lokalnymi) i został zignorowany" +"this session only" = "tylko ta sesja" +"unknown component property types were skipped" = "nieznane typy właściwości komponentu zostały pominięte" +"variable aliases to variables that aren't in the variables JSON (or of another type) were skipped" = "aliasy zmiennych wskazujące na zmienne nieobecne w pliku JSON ze zmiennymi (lub innego typu) zostały pominięte" +"variable bindings to properties the layer doesn't have were skipped" = "powiązania zmiennych z właściwościami, których warstwa nie posiada, zostały pominięte" +"variable bindings to variables that aren't in the variables JSON (library variables) were skipped" = "powiązania zmiennych ze zmiennymi nieobecnymi w pliku JSON ze zmiennymi (zmienne biblioteki) zostały pominięte" +"variable bindings were skipped: the file JSON has no variable definitions (pass the local variables JSON as `variables`, or embed its `meta` next to the file JSON)" = "powiązania zmiennych zostały pominięte: plik JSON nie zawiera definicji zmiennych (przekaż JSON zmiennych lokalnych jako `variables` lub osadź jego `meta` obok pliku JSON)" +"variable bindings whose variable type doesn't fit the property were skipped" = "powiązania zmiennych, których typ nie pasuje do właściwości, zostały pominięte" +"variable collections without modes were skipped" = "kolekcje zmiennych bez trybów zostały pominięte" +"variable mode lookups in prototype expressions that aren't a variable and one of its modes were skipped" = "odwołania do trybu zmiennej w wyrażeniach prototypu niebędące zmienną i jednym z jej trybów zostały pominięte" +"variable modes of collections that aren't in the file were skipped" = "tryby zmiennych dla kolekcji nieobecnych w pliku zostały pominięte" +"variable modes of collections that aren't in the variables JSON were skipped" = "tryby zmiennych dla kolekcji nieobecnych w pliku JSON ze zmiennymi zostały pominięte" +"variable values that don't match their type (or composed colours) were skipped" = "wartości zmiennych niezgodne ze swoim typem (lub złożone kolory) zostały pominięte" +"variables of unknown types were skipped" = "zmienne o nieznanych typach zostały pominięte" +"variables whose collection isn't in the variables JSON were skipped" = "zmienne, których kolekcji nie ma w pliku JSON ze zmiennymi, zostały pominięte" +"variables without readable values were skipped" = "zmienne bez czytelnych wartości zostały pominięte" +"vector points with unknown stroke joins use the layer join" = "punkty wektorowe z nieznanymi połączeniami obrysu używają połączenia warstwy" +"vectors without geometry are empty" = "wektory bez geometrii są puste" +"vectors without geometry are empty (export the file JSON with geometry=paths)" = "wektory bez geometrii są puste (wyeksportuj plik JSON z opcją geometry=paths)" +"very long text was cut to 1,048,576 characters" = "bardzo długi tekst został przycięty do 1 048 576 znaków" +"very long vector paths were skipped" = "bardzo długie ścieżki wektorowe zostały pominięte" +"video actions are not supported and were skipped" = "akcje wideo nie są obsługiwane i zostały pominięte" +"video triggers are not supported and were skipped" = "wyzwalacze wideo nie są obsługiwane i zostały pominięte" +"yesterday" = "wczoraj" +"{instances} in {files}" = "{instances} w {files}" +"{language}: draft translation, waiting for a native speaker's review." = "{language}: wersja robocza tłumaczenia, oczekuje na weryfikację przez native speakera." +"{length} bleed" = "spad {length}" +"{month} {day}, {year}" = "{day} {month} {year}" +"{name} cleared from the list" = "Usunięto {name} z listy" +"{name} is forgotten on this computer; the file stays on {server}" = "{name} usunięto z pamięci tego komputera; plik pozostaje na {server}" +"{name} is on the server." = "{name} znajduje się na serwerze." +"{name} is spotlighting" = "{name} prezentuje widok (spotlight)" +"{name} is too large to open in the browser (over 512 MB)." = "Plik {name} jest za duży, aby otworzyć go w przeglądarce (ponad 512 MB)." +"{name}\nClick to apply to the selection" = "{name}\nKliknij, aby zastosować do zaznaczenia" +"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nPrzeciągnij na płótno lub kliknij, aby umieścić na środku widoku.\nPrzytrzymaj klawisz Alt (Option) podczas upuszczania na instancję, aby ją zamienić." +"{name} (from {library})" = "{name} (z biblioteki {library})" +"{name} (you)" = "{name} (Ty)" +"{name} ({layers} layers)" = "{name} ({layers} warstw)" +"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels." = "{name} ({tag}): {min} – {max}, domyślnie {default}. Kliknij dwukrotnie suwak, aby przywrócić wartość stylu; naciśnięcie Escape podczas przeciągania anuluje." +"{name} from {path}" = "{name} z {path}" +"{name} was removed from the server" = "Plik {name} został usunięty z serwera" +"{name} · deleted on main" = "{name} · usunięto w gałęzi głównej" +"{name} · deleted on the branch" = "{name} · usunięto w gałęzi" +"{name} · not published" = "{name} · nieopublikowane" +"{name} · on a server" = "{name} · na serwerze" +"{name} · published version {v}" = "{name} · opublikowana wersja {v}" +"{name} · version {v} on the server" = "{name} · wersja {v} na serwerze" +"{name}, in a live session" = "{name}, w sesji na żywo" +"{name}, online, open only to you" = "{name}, online, otwarty tylko dla Ciebie" +"{name}, {kind}, {w} by {h}, selected" = "{name}, {kind}, {w} × {h}, zaznaczono" +"{n} added" = { one = "Dodano {n}", few = "Dodano {n}", many = "Dodano {n}", other = "Dodano {n}" } +"{n} autosave versions" = { one = "{n} wersja autozapisu", few = "{n} wersje autozapisu", many = "{n} wersji autozapisu", other = "{n} wersji autozapisu" } +"{n} changed" = { one = "Zmieniono {n}", few = "Zmieniono {n}", many = "Zmieniono {n}", other = "Zmieniono {n}" } +"{n} columns" = { one = "{n} kolumna", few = "{n} kolumny", many = "{n} kolumn", other = "{n} kolumn" } +"{n} components" = { one = "{n} komponent", few = "{n} komponenty", many = "{n} komponentów", other = "{n} komponentów" } +"{n} components and styles have newer versions" = { one = "{n} komponent lub styl ma nowszą wersję", few = "{n} komponenty i style mają nowsze wersje", many = "{n} komponentów i stylów ma nowsze wersje", other = "{n} komponentów i stylów ma nowsze wersje" } +"{n} conflicts to resolve" = { one = "{n} konflikt do rozwiązania", few = "{n} konflikty do rozwiązania", many = "{n} konfliktów do rozwiązania", other = "{n} konfliktów do rozwiązania" } +"{n} days ago" = { one = "{n} dzień temu", few = "{n} dni temu", many = "{n} dni temu", other = "{n} dni temu" } +"{n} files" = { one = "{n} plik", few = "{n} pliki", many = "{n} plików", other = "{n} plików" } +"{n} hours ago" = { one = "{n} godzinę temu", few = "{n} godziny temu", many = "{n} godzin temu", other = "{n} godzin temu" } +"{n} instances" = { one = "{n} instancja", few = "{n} instancje", many = "{n} instancji", other = "{n} instancji" } +"{n} layers" = { one = "{n} warstwa", few = "{n} warstwy", many = "{n} warstw", other = "{n} warstw" } +"{n} layers as one file" = { one = "{n} warstwa jako jeden plik", few = "{n} warstwy jako jeden plik", many = "{n} warstw jako jeden plik", other = "{n} warstw jako jeden plik" } +"{n} layers selected" = { one = "Zaznaczono {n} warstwę", few = "Zaznaczono {n} warstwy", many = "Zaznaczono {n} warstw", other = "Zaznaczono {n} warstw" } +"{n} layers · {parts}" = { one = "{n} warstwa · {parts}", few = "{n} warstwy · {parts}", many = "{n} warstw · {parts}", other = "{n} warstw · {parts}" } +"{n} matches" = { one = "{n} dopasowanie", few = "{n} dopasowania", many = "{n} dopasowań", other = "{n} dopasowań" } +"{n} minutes ago" = { one = "{n} minutę temu", few = "{n} minuty temu", many = "{n} minut temu", other = "{n} minut temu" } +"{n} months ago" = { one = "{n} miesiąc temu", few = "{n} miesiące temu", many = "{n} miesięcy temu", other = "{n} miesięcy temu" } +"{n} more notes omitted from this report" = { one = "Pominięto jeszcze {n} notatkę w tym raporcie", few = "Pominięto jeszcze {n} notatki w tym raporcie", many = "Pominięto jeszcze {n} notatek w tym raporcie", other = "Pominięto jeszcze {n} notatek w tym raporcie" } +"{n} of {total}" = "{n} z {total}" +"{n} people joined" = { one = "Dołączyła {n} osoba", few = "Dołączyły {n} osoby", many = "Dołączyło {n} osób", other = "Dołączyło {n} osób" } +"{n} preferred components" = { one = "{n} preferowany komponent", few = "{n} preferowane komponenty", many = "{n} preferowanych komponentów", other = "{n} preferowanych komponentów" } +"{n} ready" = { one = "{n} gotowy", few = "{n} gotowe", many = "{n} gotowych", other = "{n} gotowych" } +"{n} removed" = { one = "Usunięto {n}", few = "Usunięto {n}", many = "Usunięto {n}", other = "Usunięto {n}" } +"{n} replies" = { one = "{n} odpowiedź", few = "{n} odpowiedzi", many = "{n} odpowiedzi", other = "{n} odpowiedzi" } +"{n} rows" = { one = "{n} wiersz", few = "{n} wiersze", many = "{n} wierszy", other = "{n} wierszy" } +"{n} styles" = { one = "{n} styl", few = "{n} style", many = "{n} stylów", other = "{n} stylów" } +"{n} text layers" = { one = "{n} warstwa tekstowa", few = "{n} warstwy tekstowe", many = "{n} warstw tekstowych", other = "{n} warstw tekstowych" } +"{n} variables" = { one = "{n} zmienna", few = "{n} zmienne", many = "{n} zmiennych", other = "{n} zmiennych" } +"{n} weeks ago" = { one = "{n} tydzień temu", few = "{n} tygodnie temu", many = "{n} tygodni temu", other = "{n} tygodni temu" } +"{n} years ago" = { one = "{n} rok temu", few = "{n} lata temu", many = "{n} lat temu", other = "{n} lat temu" } +"{n}d" = "{n} d" +"{n}h" = "{n} godz." +"{n}m" = "{n} min" +"{n}w" = "{n} tyg." +"{page}, nothing selected" = "{page}, nic nie zaznaczono" +"{profile} (current)" = "{profile} (bieżący)" +"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} to jej wartość; {property ? \"a\" : \"b\"} wybiera tekst. Pozostałe wyrażenia {…} pozostają bez zmian." +"{server} can't say what became of your files (it is older than this app)." = "{server} nie może określić stanu Twoich plików (jest starszy niż ta aplikacja)." +"{server} · No longer on the server" = "{server} · Brak na serwerze" +"{server} · {shared} · Opened {date}" = "{server} · {shared} · Otwarto {date}" +"{tool} hold {key}" = "{tool} przytrzymaj {key}" +"{value} isn't a colour" = "Wartość {value} nie jest kolorem" +"{when} · edited" = "{when} · edytowano" +"← ↑ → ↓" = "← ↑ → ↓" +"← → ↑ ↓" = "← → ↑ ↓" +"←" = "←" +"→, Space or N" = "→, Spacja lub N" +"↵" = "↵" +"⇥ / ⇧⇥" = "⇥ / ⇧⇥" +"⌘ arrows" = "⌘ strzałki" +"⌘ click" = "⌘ kliknięcie" +"⌘ scroll" = "⌘ przewijanie" +"⌥ drag" = "⌥ przeciągnięcie" + +[commands] +"align.bottom" = "Wyrównaj do dołu" +"align.hcenter" = "Wyrównaj poziomo do środka" +"align.left" = "Wyrównaj do lewej" +"align.right" = "Wyrównaj do prawej" +"align.top" = "Wyrównaj do góry" +"align.vcenter" = "Wyrównaj pionowo do środka" +"annotation.add" = "Dodaj adnotację" +"annotation.addCategory" = "Dodaj kategorię adnotacji" +"annotation.categories" = "Kategorie adnotacji" +"annotation.edit" = "Edytuj adnotację" +"annotation.editCategory" = "Edytuj kategorię adnotacji" +"annotation.list" = "Lista adnotacji" +"annotation.remove" = "Usuń adnotację" +"annotation.removeCategory" = "Usuń kategorię adnotacji" +"app.actions" = "Akcje" +"app.preferences" = "Preferencje" +"arrange.back" = "Przesuń na sam spód" +"arrange.backward" = "Przesuń do tyłu" +"arrange.forward" = "Przesuń do przodu" +"arrange.front" = "Przesuń na sam wierzch" +"arrange.packH" = "Upakuj w poziomie" +"arrange.packV" = "Upakuj w pionie" +"arrange.roundToPixels" = "Zaokrąglij do pikseli" +"arrange.spacing" = "Odstępy" +"arrange.tidy" = "Uporządkuj" +"bool.exclude" = "Wyklucz zaznaczenie" +"bool.flatten" = "Spłaszcz operację logiczną" +"bool.intersect" = "Część wspólna zaznaczenia" +"bool.subtract" = "Odejmij zaznaczenie" +"bool.union" = "Połącz zaznaczenie" +"branch.archive" = "Zarchiwizuj gałąź" +"branch.create" = "Utwórz gałąź" +"branch.list" = "Lista gałęzi" +"branch.merge" = "Scal gałąź" +"branch.open" = "Otwórz gałąź" +"branch.review" = "Przejrzyj gałąź" +"branch.updateFromMain" = "Zaktualizuj z gałęzi głównej" +"codeConnect.export" = "Eksportuj połączenia kodu" +"codeConnect.import" = "Importuj połączenia kodu" +"codeConnect.remove" = "Odłącz od kodu" +"codeConnect.set" = "Połącz z kodem" +"collab.cursor" = "Pokaż kursor na pozycji" +"collab.cursorChat" = "Czat kursora" +"collab.diff" = "Pokaż zmiany synchronizacji" +"collab.files" = "Twoje pliki online" +"collab.follow" = "Obserwuj" +"collab.forgetFile" = "Zapomnij plik online" +"collab.host" = "Rozpocznij sesję na żywo" +"collab.join" = "Dołącz do sesji na żywo" +"collab.leave" = "Opuść sesję na żywo" +"collab.manageFile" = "Zarządzaj opublikowanym plikiem" +"collab.openFile" = "Otwórz plik online" +"collab.peers" = "Osoby w tym pliku" +"collab.publish" = "Opublikuj na serwerze" +"collab.recoverFile" = "Odzyskaj plik online" +"collab.registerLinks" = "Otwieraj linki z zaproszeniem w FigCraft" +"collab.removePerson" = "Usuń z sesji" +"collab.report" = "Zgłoś plik operatorowi serwera" +"collab.serverInfo" = "Informacje o serwerze" +"collab.share" = "Udostępnij z kodem dołączenia" +"collab.spotlight" = "Wyróżnij mój widok" +"collab.stop" = "Zatrzymaj sesję na żywo" +"command.batch" = "Wykonaj serię poleceń" +"comment.add" = "Dodaj komentarz" +"comment.delete" = "Usuń komentarz" +"comment.edit" = "Edytuj komentarz" +"comment.link" = "Kopiuj link do komentarza" +"comment.list" = "Lista komentarzy" +"comment.markRead" = "Oznacz komentarz jako przeczytany" +"comment.move" = "Przenieś komentarz" +"comment.next" = "Następny komentarz" +"comment.open" = "Otwórz komentarz" +"comment.people" = "Osoby w komentarzach" +"comment.react" = "Zareaguj na komentarz" +"comment.reply" = "Odpowiedz na komentarz" +"comment.resolve" = "Rozwiąż komentarz" +"comment.zoom" = "Powiększ do komentarzy" +"component.addProperty" = "Dodaj właściwość komponentu" +"component.addVariant" = "Dodaj wariant" +"component.bindProperty" = "Powiąż warstwę z właściwością" +"component.combineVariants" = "Połącz jako warianty" +"component.create" = "Utwórz komponent" +"component.createMultiple" = "Utwórz wiele komponentów" +"component.deleteProperty" = "Usuń właściwość komponentu" +"component.detach" = "Odłącz instancję" +"component.editProperty" = "Edytuj właściwość komponentu" +"component.exposeInstance" = "Udostępnij zagnieżdżoną instancję" +"component.goToMain" = "Przejdź do głównego komponentu" +"component.hideWhenPublishing" = "Ukryj podczas publikowania" +"component.list" = "Lista komponentów" +"component.makeSlot" = "Utwórz gniazdo (Slot)" +"component.playground" = "Środowisko testowe komponentu (Playground)" +"component.properties" = "Właściwości komponentu" +"component.pushChanges" = "Zastosuj zmiany w głównym komponencie" +"component.removeSlot" = "Usuń gniazdo" +"component.resetInstance" = "Zresetuj wszystkie zmiany" +"component.resetOverride" = "Zresetuj zmianę" +"component.restoreMain" = "Przywróć główny komponent" +"dev.compare" = "Porównaj zmiany" +"dev.focus" = "Widok skupienia" +"dev.resolveChange" = "Rozwiąż zmianę" +"dev.unfocus" = "Wyjdź z widoku skupienia" +"devResource.add" = "Dodaj zasób programistyczny" +"devResource.edit" = "Edytuj zasób programistyczny" +"devResource.list" = "Lista zasobów programistycznych" +"devResource.remove" = "Usuń zasób programistyczny" +"distribute.horizontal" = "Rozmieść równe odstępy poziome" +"distribute.vertical" = "Rozmieść równe odstępy pionowe" +"doc.home" = "Ekran główny" +"doc.list" = "Lista dokumentów" +"doc.nextTab" = "Następna karta" +"doc.previousTab" = "Poprzednia karta" +"doc.switch" = "Przełącz dokument" +"edit.copy" = "Kopiuj" +"edit.copyAsCss" = "Kopiuj jako CSS" +"edit.copyAsPng" = "Kopiuj jako PNG" +"edit.copyAsSvg" = "Kopiuj jako SVG" +"edit.copyAsText" = "Kopiuj jako tekst" +"edit.copyLink" = "Kopiuj łącze do zaznaczenia" +"edit.copyProperties" = "Kopiuj właściwości" +"edit.cut" = "Wytnij" +"edit.delete" = "Usuń" +"edit.duplicate" = "Powiel" +"edit.findReplace" = "Znajdź i zamień…" +"edit.paste" = "Wklej" +"edit.pasteDesignLayers" = "Wklej warstwy projektu" +"edit.pasteOver" = "Wklej na zaznaczenie" +"edit.pasteProperties" = "Wklej właściwości" +"edit.pasteReplace" = "Wklej, aby zastąpić" +"edit.pickColor" = "Wybierz kolor" +"edit.redo" = "Ponów" +"edit.repeat" = "Powtórz ostatnie polecenie" +"edit.setDefaultProperties" = "Ustaw właściwości domyślne" +"edit.undo" = "Cofnij" +"effect.add" = "Dodaj efekt" +"effect.move" = "Przenieś efekt" +"effect.remove" = "Usuń efekt" +"effect.set" = "Ustaw efekt" +"export.add" = "Dodaj ustawienie eksportu" +"export.remove" = "Usuń ustawienie eksportu" +"export.update" = "Edytuj ustawienie eksportu" +"file.close" = "Zamknij kartę" +"file.colorProfile" = "Profil kolorów" +"file.duplicate" = "Powiel plik" +"file.export" = "Eksportuj…" +"file.exportFramesPdf" = "Eksportuj ramki do PDF" +"file.exportLayers" = "Eksportuj warstwy" +"file.exportSvg" = "Eksportuj zaznaczenie jako SVG" +"file.import" = "Importuj z pliku…" +"file.new" = "Nowy plik projektu" +"file.newFromTemplate" = "Nowy z szablonu" +"file.open" = "Otwórz…" +"file.openRecent" = "Otwórz ostatnie" +"file.placeImage" = "Umieść obraz" +"file.recover" = "Odzyskaj plik" +"file.rename" = "Zmień nazwę pliku" +"file.revert" = "Przywróć do zapisanego" +"file.save" = "Zapisz" +"file.saveAs" = "Zapisz jako…" +"file.saveVersion" = "Zapisz w historii wersji…" +"file.versionHistory" = "Pokaż historię wersji" +"fill.add" = "Dodaj wypełnienie" +"fill.move" = "Przenieś wypełnienie" +"fill.pattern" = "Wypełnienie wzorem" +"fill.remove" = "Usuń wypełnienie" +"fill.set" = "Ustaw wypełnienie" +"frame.create" = "Utwórz ramkę" +"frame.presets" = "Lista presetów ramek" +"frame.swapOrientation" = "Zmień orientację ramki" +"grid.add" = "Dodaj siatkę układu" +"grid.move" = "Przenieś siatkę układu" +"grid.remove" = "Usuń siatkę układu" +"guide.add" = "Dodaj prowadnicę" +"guide.clear" = "Wyczyść prowadnice" +"guide.move" = "Przesuń prowadnicę" +"guide.remove" = "Usuń prowadnicę" +"guide.select" = "Zaznacz prowadnicę" +"help.about" = "O programie FigCraft" +"help.gettingStarted" = "Wprowadzenie" +"help.page" = "Strona pomocy" +"help.shortcuts" = "Skróty klawiszowe" +"image.adjust" = "Dostosuj obraz" +"image.deletePreset" = "Usuń preset obrazu" +"image.place" = "Umieść obraz" +"image.presets" = "Presety obrazów" +"image.savePreset" = "Zapisz preset obrazu" +"inspect.assets" = "Zasoby do inspekcji" +"inspect.code" = "Kod warstwy" +"inspect.componentCode" = "Kod komponentu" +"inspect.properties" = "Właściwości do inspekcji" +"inspect.readyForDev" = "Gotowe do wdrożenia" +"instance.create" = "Utwórz instancję" +"instance.exposed" = "Udostępnione zagnieżdżone instancje" +"instance.place" = "Umieść instancję" +"instance.resetSlot" = "Zresetuj gniazdo" +"instance.setProperties" = "Ustaw właściwości instancji" +"instance.slotInsert" = "Wstaw do gniazda" +"instance.swap" = "Podmień instancję" +"instance.swapAt" = "Podmień instancję w punkcie" +"layers.collapseAll" = "Zwiń wszystkie warstwy" +"layout.add" = "Dodaj automatyczny układ" +"layout.remove" = "Usuń automatyczny układ" +"layout.setTrack" = "Ustaw rozmiar linii siatki" +"layout.suggest" = "Sugeruj automatyczny układ" +"libraries.open" = "Biblioteki" +"libraries.publish" = "Publikuj bibliotekę…" +"library.acceptUpdates" = "Zaakceptuj aktualizacje biblioteki" +"library.add" = "Dodaj bibliotekę" +"library.aliasVariable" = "Utwórz alias zmiennej biblioteki" +"library.applyStyle" = "Zastosuj styl biblioteki" +"library.bindVariable" = "Powiąż zmienną biblioteki" +"library.extendCollection" = "Rozszerz kolekcję biblioteki" +"library.import" = "Importuj komponent biblioteki" +"library.importVariable" = "Importuj zmienną biblioteki" +"library.list" = "Lista bibliotek" +"library.place" = "Umieść komponent biblioteki" +"library.publish" = "Opublikuj bibliotekę" +"library.publishToServer" = "Opublikuj bibliotekę na serwerze" +"library.remove" = "Usuń bibliotekę" +"library.setVariableMode" = "Ustaw tryb zmiennej biblioteki" +"library.swap" = "Podmień bibliotekę" +"library.swapAt" = "Podmień instancję na komponent biblioteki" +"library.updates" = "Aktualizacje biblioteki" +"library.usage" = "Użycie biblioteki" +"measure.distances" = "Zmierz odległości" +"measure.padding" = "Zmierz marginesy automatycznego układu" +"node.create" = "Utwórz warstwę" +"node.get" = "Pobierz warstwę" +"node.reorder" = "Zmień kolejność warstwy" +"node.reparent" = "Przenieś warstwę do" +"node.set" = "Ustaw właściwości" +"object.batchRename" = "Zmień nazwy wsadowo…" +"object.convertToFrame" = "Konwertuj na ramkę" +"object.convertToGroup" = "Konwertuj na grupę" +"object.convertToSection" = "Konwertuj na sekcję" +"object.flipH" = "Odbij w poziomie" +"object.flipV" = "Odbij w pionie" +"object.frameSelection" = "Umieść zaznaczenie w ramce" +"object.group" = "Grupuj zaznaczenie" +"object.hideOthers" = "Ukryj pozostałe warstwy" +"object.mask" = "Użyj jako maski" +"object.moveToPage" = "Przenieś na stronę" +"object.opacityKeys" = "Krycie z klawiatury numerycznej" +"object.rasterize" = "Rasteryzuj zaznaczenie" +"object.releaseMask" = "Zwolnij maskę" +"object.removeFill" = "Usuń wypełnienie" +"object.removeFrame" = "Usuń ramkę" +"object.removeStroke" = "Usuń obrys" +"object.rename" = "Zmień nazwę" +"object.resizeToFit" = "Dopasuj rozmiar do zawartości" +"object.rotate180" = "Obróć o 180°" +"object.rotateLeft" = "Obróć o 90° w lewo" +"object.rotateRight" = "Obróć o 90° w prawo" +"object.setThumbnail" = "Ustaw jako miniaturę" +"object.swapFillStroke" = "Zamień wypełnienie i obrys" +"object.toggleLock" = "Zablokuj/Odblokuj zaznaczenie" +"object.toggleVisible" = "Pokaż/Ukryj zaznaczenie" +"object.ungroup" = "Rozgrupuj zaznaczenie" +"object.wrapInSection" = "Umieść w nowej sekcji" +"page.add" = "Dodaj stronę" +"page.delete" = "Usuń stronę" +"page.duplicate" = "Powiel stronę" +"page.list" = "Lista stron" +"page.move" = "Przenieś stronę" +"page.rename" = "Zmień nazwę strony" +"page.select" = "Przejdź do strony" +"page.setBackground" = "Tło strony" +"paint.documentColors" = "Kolory dokumentu" +"plugin.close" = "Zamknij panel wtyczki" +"plugin.directory.install" = "Zainstaluj wtyczkę z katalogu" +"plugin.directory.list" = "Przeglądaj katalog wtyczek" +"plugin.directory.publish" = "Opublikuj wtyczkę w katalogu" +"plugin.directory.unpublish" = "Wycofaj wtyczkę z katalogu" +"plugin.event" = "Użyj kontrolki panelu wtyczki" +"plugin.import" = "Importuj wtyczkę z manifestu…" +"plugin.list" = "Lista wtyczek" +"plugin.remove" = "Usuń wtyczkę" +"plugin.run" = "Uruchom wtyczkę" +"plugin.ui" = "Panel wtyczki" +"plugins.manage" = "Zarządzaj wtyczkami…" +"plugins.runLast" = "Uruchom ponownie ostatnią wtyczkę" +"pref.autoSave" = "Zapisuj pliki automatycznie" +"pref.highlightHover" = "Podświetlaj warstwy przy najechaniu myszą" +"pref.keepTool" = "Zachowaj wybrane narzędzie po użyciu" +"pref.language" = "Język interfejsu" +"pref.nudge" = "Wartości przesunięcia (Nudge)" +"pref.numberKeysOpacity" = "Klawisze numeryczne zmieniają krycie" +"pref.publishServer" = "Serwer do udostępniania online" +"pref.reduceMotion" = "Ogranicz animacje" +"pref.renameDuplicates" = "Zmieniaj nazwy duplikatów" +"pref.showDimensions" = "Pokazuj wymiary przy zaznaczeniu" +"pref.userName" = "Twoja nazwa w komentarzach" +"proto.action.add" = "Dodaj akcję prototypu" +"proto.action.move" = "Zmień kolejność akcji prototypu" +"proto.action.remove" = "Usuń akcję prototypu" +"proto.action.set" = "Edytuj akcję prototypu" +"proto.closePreview" = "Zamknij podgląd prototypu" +"proto.conditional.addBlock" = "Dodaj else if / else" +"proto.conditional.removeBlock" = "Usuń if / else if / else" +"proto.conditional.setCondition" = "Edytuj warunek" +"proto.evaluate" = "Oblicz wyrażenie prototypu" +"proto.flow.add" = "Dodaj punkt początkowy przepływu" +"proto.flow.remove" = "Usuń przepływ" +"proto.flow.rename" = "Zmień nazwę przepływu" +"proto.interaction.add" = "Dodaj interakcję" +"proto.interaction.clear" = "Usuń wszystkie interakcje" +"proto.interaction.remove" = "Usuń interakcję" +"proto.interaction.set" = "Edytuj interakcję" +"proto.present" = "Prezentuj" +"proto.presentOptions" = "Opcje prezentacji" +"proto.preview" = "Otwórz podgląd prototypu" +"proto.settings" = "Ustawienia prototypu" +"section.toggleContents" = "Ukryj/Pokaż zawartość sekcji" +"select.add" = "Dodaj do zaznaczenia" +"select.all" = "Zaznacz wszystko" +"select.area" = "Zaznacz obszar" +"select.byName" = "Zaznacz warstwy po nazwie" +"select.children" = "Zaznacz elementy podrzędne" +"select.inverse" = "Odwróć zaznaczenie" +"select.layersAt" = "Warstwy w punkcie" +"select.matching" = "Zaznacz pasujące warstwy" +"select.next" = "Zaznacz następną warstwę" +"select.none" = "Odznacz wszystko" +"select.parent" = "Zaznacz element nadrzędny" +"select.previous" = "Zaznacz poprzednią warstwę" +"select.remove" = "Usuń z zaznaczenia" +"select.sameEffect" = "Zaznacz elementy z takim samym efektem" +"select.sameFill" = "Zaznacz elementy z takim samym wypełnieniem" +"select.sameFont" = "Zaznacz elementy z taką samą czcionką" +"select.sameInstance" = "Zaznacz taką samą instancję" +"select.sameProperties" = "Zaznacz elementy z takimi samymi właściwościami" +"select.sameStroke" = "Zaznacz elementy z takim samym obrysem" +"select.sameText" = "Zaznacz elementy z takimi samymi właściwościami tekstu" +"select.set" = "Zaznacz" +"selection.applyColorStyle" = "Zastosuj styl koloru do zaznaczenia" +"selection.colors" = "Kolory zaznaczenia" +"selection.selectColor" = "Zaznacz warstwy z kolorem" +"selection.setPaint" = "Zmień kolor zaznaczenia" +"shape.arrow" = "Strzałka" +"shape.ellipse" = "Elipsa" +"shape.line" = "Linia" +"shape.polygon" = "Wielokąt" +"shape.rectangle" = "Prostokąt" +"shape.star" = "Gwiazda" +"shapeBuilder.merge" = "Połącz obszary kształtów" +"shapeBuilder.mode" = "Tryb generatora kształtów" +"shapeBuilder.regions" = "Obszary generatora kształtów" +"shapeBuilder.remove" = "Usuń obszary" +"stroke.add" = "Dodaj obrys" +"stroke.brush" = "Pędzel" +"stroke.move" = "Przenieś obrys" +"stroke.remove" = "Usuń obrys" +"stroke.set" = "Ustaw obrys" +"stroke.widthProfile" = "Profil grubości" +"style.apply" = "Zastosuj styl" +"style.bind" = "Powiąż zmienną w stylu" +"style.create" = "Utwórz styl" +"style.delete" = "Usuń styl" +"style.detach" = "Odłącz styl" +"style.duplicate" = "Powiel styl" +"style.list" = "Lista stylów" +"style.move" = "Przenieś style" +"style.renameFolder" = "Zmień nazwę folderu stylów" +"style.update" = "Edytuj styl" +"template.list" = "Lista szablonów" +"text.alignCenter" = "Wyrównaj tekst do środka" +"text.alignJustify" = "Wyjustuj tekst" +"text.alignLeft" = "Wyrównaj tekst do lewej" +"text.alignRight" = "Wyrównaj tekst do prawej" +"text.bold" = "Pogrubienie" +"text.bulletList" = "Lista punktowana" +"text.case" = "Wielkość liter" +"text.caseLower" = "Małe litery" +"text.caseOriginal" = "Oryginalna wielkość liter" +"text.caseSmallCaps" = "Kapitaliki" +"text.caseTitle" = "Jak nazwy własne" +"text.caseUpper" = "Wielkie litery" +"text.create" = "Tekst" +"text.decoration" = "Dekoracja tekstu" +"text.edit.begin" = "Edytuj tekst" +"text.edit.caret" = "Ustaw kursor tekstu" +"text.edit.end" = "Zakończ edycję tekstu" +"text.edit.insert" = "Wpisz tekst" +"text.edit.key" = "Klawisz tekstu" +"text.edit.preedit" = "Redaguj tekst" +"text.edit.selectLine" = "Zaznacz wiersz" +"text.edit.selectWord" = "Zaznacz słowo" +"text.edit.selection" = "Zaznaczenie tekstu" +"text.endPreview" = "Zakończ podgląd tekstu" +"text.fonts" = "Lista czcionek" +"text.italic" = "Kursywa" +"text.lineHeightDown" = "Zmniejsz interlinię" +"text.lineHeightUp" = "Zwiększ interlinię" +"text.link" = "Wstaw łącze" +"text.listIndent" = "Zwiększ wcięcie listy" +"text.listOutdent" = "Zmniejsz wcięcie listy" +"text.listStyle" = "Styl listy" +"text.maxLines" = "Maksymalna liczba wierszy" +"text.missingFonts" = "Brakujące czcionki" +"text.numberList" = "Lista numerowana" +"text.onPath" = "Tekst na ścieżce" +"text.paragraphIndent" = "Wcięcie akapitu" +"text.paragraphSpacing" = "Odstęp między akapitami" +"text.pathOptions" = "Opcje ścieżki tekstu" +"text.preview" = "Podgląd zmiany tekstu" +"text.removeFromPath" = "Usuń tekst ze ścieżki" +"text.set" = "Ustaw tekst" +"text.setFeatures" = "Funkcje OpenType" +"text.setVariations" = "Osie czcionki zmiennej (Variable Font)" +"text.sizeDown" = "Zmniejsz rozmiar czcionki" +"text.sizeUp" = "Zwiększ rozmiar czcionki" +"text.strikethrough" = "Przekreślenie" +"text.style" = "Zastosuj styl do fragmentu tekstu" +"text.trackingDown" = "Zmniejsz odstęp między literami" +"text.trackingUp" = "Zwiększ odstęp między literami" +"text.underline" = "Podkreślenie" +"text.weightDown" = "Zmniejsz grubość czcionki" +"text.weightUp" = "Zwiększ grubość czcionki" +"tool.arrow" = "Strzałka" +"tool.comment" = "Komentarz" +"tool.ellipse" = "Elipsa" +"tool.frame" = "Ramka" +"tool.hand" = "Rączka" +"tool.line" = "Linia" +"tool.move" = "Wskaźnik" +"tool.pen" = "Pióro" +"tool.pencil" = "Ołówek" +"tool.pencilBrush" = "Pędzel ołówka" +"tool.place" = "Umieść za pomocą narzędzia" +"tool.polygon" = "Wielokąt" +"tool.rectangle" = "Prostokąt" +"tool.scale" = "Skalowanie" +"tool.scaleAnchor" = "Punkt zaczepienia skalowania" +"tool.section" = "Sekcja" +"tool.set" = "Wybierz narzędzie" +"tool.shapeBuilder" = "Generator kształtów" +"tool.slice" = "Plaster" +"tool.star" = "Gwiazda" +"tool.text" = "Tekst" +"transform.move" = "Przenieś" +"transform.moveTo" = "Przenieś do" +"transform.nudge" = "Przesuń o krok (Nudge)" +"transform.nudgeSize" = "Zmień rozmiar o krok" +"transform.resize" = "Zmień rozmiar" +"transform.rotate" = "Obróć" +"transform.rotationOrigin" = "Przesuń punkt obrotu" +"transform.scale" = "Skaluj" +"transform.setRotationOrigin" = "Ustaw punkt obrotu" +"variable.addMode" = "Dodaj tryb" +"variable.bind" = "Powiąż zmienną" +"variable.create" = "Utwórz zmienną" +"variable.createCollection" = "Utwórz kolekcję zmiennych" +"variable.delete" = "Usuń zmienną" +"variable.deleteCollection" = "Usuń kolekcję" +"variable.duplicate" = "Powiel zmienną" +"variable.export" = "Eksportuj zmienne" +"variable.import" = "Importuj zmienne" +"variable.list" = "Lista zmiennych" +"variable.moveMode" = "Przenieś tryb" +"variable.removeMode" = "Usuń tryb" +"variable.renameCollection" = "Zmień nazwę kolekcji" +"variable.renameMode" = "Zmień nazwę trybu" +"variable.resetOverride" = "Zresetuj nadpisanie zmiennej" +"variable.set" = "Ustaw wartość zmiennej" +"variable.setMode" = "Ustaw tryb zmiennej" +"variable.update" = "Edytuj zmienną" +"vector.create" = "Wektor" +"vector.deleteHeal" = "Usuń punkt i napraw zaznaczenie" +"vector.edit.addVertex" = "Dodaj punkt" +"vector.edit.begin" = "Edytuj wektor" +"vector.edit.bend" = "Zegnij segment" +"vector.edit.bounds" = "Granice zaznaczonych punktów" +"vector.edit.connect" = "Połącz punkty" +"vector.edit.cornerRadius" = "Promień zaokrąglenia punktu" +"vector.edit.cut" = "Rozetnij ścieżkę" +"vector.edit.delete" = "Usuń punkty" +"vector.edit.end" = "Zakończ edycję" +"vector.edit.erase" = "Wymaż" +"vector.edit.lasso" = "Zaznacz lasso" +"vector.edit.mirroring" = "Symetria uchwytów" +"vector.edit.moveHandles" = "Przesuń uchwyty" +"vector.edit.moveVertices" = "Przesuń punkty" +"vector.edit.nudge" = "Przesuń punkty o krok" +"vector.edit.paint" = "Wiadro z farbą" +"vector.edit.pen" = "Punkt pióra" +"vector.edit.penEnd" = "Zakończ ścieżkę pióra" +"vector.edit.position" = "Pozycja punktu" +"vector.edit.resize" = "Zmień rozmiar punktów" +"vector.edit.rotate" = "Obróć punkty" +"vector.edit.select" = "Zaznacz punkty" +"vector.edit.selectAll" = "Zaznacz wszystkie punkty" +"vector.edit.setHandle" = "Ustaw uchwyt" +"vector.edit.smooth" = "Wygładź lub wyostrz punkty" +"vector.edit.state" = "Stan edycji wektora" +"vector.edit.strokeCap" = "Zakończenie obrysu punktu" +"vector.edit.strokeJoin" = "Połączenie obrysu punktu" +"vector.edit.tool" = "Narzędzie edycji wektora" +"vector.edit.transform" = "Przekształć punkty" +"vector.edit.widthPoint" = "Punkt szerokości" +"vector.flatten" = "Spłaszcz do wektora" +"vector.join" = "Połącz ścieżki" +"vector.offset" = "Przesunięcie ścieżki (Offset)" +"vector.outlineStroke" = "Przekształć obrys w ścieżkę" +"vector.reverse" = "Odwróć kierunek ścieżki" +"vector.simplify" = "Uprość wektor" +"vector.smoothJoin" = "Płynne połączenie zaznaczenia" +"version.autosave" = "Wersja autozapisu" +"version.delete" = "Usuń wersję" +"version.duplicate" = "Powiel wersję" +"version.exitPreview" = "Powrót do bieżącej wersji" +"version.list" = "Historia wersji" +"version.preview" = "Wyświetl wersję" +"version.rename" = "Zmień nazwę wersji" +"version.restore" = "Przywróć wersję" +"view.assets" = "Panel zasobów" +"view.comments" = "Pokaż komentarze" +"view.contextMenu" = "Pokaż menu kontekstowe" +"view.design" = "Panel projektu" +"view.designPrototype" = "Przełącz między Projektem a Prototypem" +"view.devMode" = "Tryb programisty" +"view.frameOutlines" = "Kontury ramek" +"view.fullscreen" = "Tryb pełnoekranowy" +"view.get" = "Pobierz widok" +"view.layers" = "Panel warstw" +"view.layoutGrids" = "Siatki układu" +"view.leftSidebar" = "Pokaż/Ukryj lewy pasek boczny" +"view.maskOutlines" = "Kontury masek" +"view.multiplayerCursors" = "Kursory innych użytkowników" +"view.nextFrame" = "Przejdź do następnej ramki" +"view.nextPage" = "Następna strona" +"view.nextRegion" = "Przejdź do następnego obszaru" +"view.outlines" = "Pokaż kontury" +"view.outlinesHiddenLayers" = "Pokaż kontury ukrytych warstw" +"view.outlinesObjectBounds" = "Pokaż granice obiektów" +"view.outlinesSelection" = "Pokaż kontury zaznaczenia" +"view.pan" = "Przesuń widok" +"view.panelWidths" = "Zresetuj szerokości paneli" +"view.pixelGrid" = "Siatka pikseli" +"view.pixelPreview" = "Podgląd pikseli" +"view.prevFrame" = "Przejdź do poprzedniej ramki" +"view.prevPage" = "Poprzednia strona" +"view.previousRegion" = "Przejdź do poprzedniego obszaru" +"view.proofSrgb" = "Symulacja sRGB (Soft-proof)" +"view.prototype" = "Panel prototypu" +"view.rulers" = "Linijki" +"view.set" = "Ustaw widok" +"view.slices" = "Plastry" +"view.snapGeometry" = "Przyciągaj do geometrii" +"view.snapObjects" = "Przyciągaj do obiektów" +"view.snapPixel" = "Przyciągaj do pikseli" +"view.themeDark" = "Ciemny motyw" +"view.themeLight" = "Jasny motyw" +"view.themeSystem" = "Motyw zgodny z systemem" +"view.toggleUi" = "Pokaż/Ukryj interfejs" +"view.zoom100" = "Powiększenie 100%" +"view.zoom200" = "Powiększenie 200%" +"view.zoom50" = "Powiększenie 50%" +"view.zoomAt" = "Narzędzie lupy" +"view.zoomFit" = "Dopasuj do ekranu" +"view.zoomIn" = "Powiększ" +"view.zoomOut" = "Pomniejsz" +"view.zoomSelection" = "Powiększ do zaznaczenia" +"widget.click" = "Kliknij widżet" +"widget.insert" = "Wstaw widżet" +"widget.list" = "Lista widżetów" +"window.focus" = "Przesuń okno na wierzch" +"window.new" = "Nowe okno" + +[menu] +"bool.exclude" = "Wyklucz" +"bool.flatten" = "Spłaszcz" +"bool.intersect" = "Część wspólna" +"bool.subtract" = "Odejmij" +"bool.union" = "Połącz" +"file.duplicate" = "Powiel" +"file.import" = "Importuj z pliku…" +"file.rename" = "Zmień nazwę" +"libraries.open" = "Otwórz biblioteki" +"select.sameEffect" = "Efekt" +"select.sameFill" = "Wypełnienie" +"select.sameFont" = "Czcionka" +"select.sameInstance" = "Instancja" +"select.sameProperties" = "Właściwości" +"select.sameStroke" = "Obrys" +"select.sameText" = "Właściwości tekstu" +"text.alignCenter" = "Wyrównaj do środka" +"text.alignJustify" = "Wyjustuj" +"text.alignLeft" = "Wyrównaj do lewej" +"text.alignRight" = "Wyrównaj do prawej" +"view.assets" = "Zasoby" +"view.design" = "Projekt" +"view.layers" = "Warstwy" +"view.outlines" = "Pokaż kontury" +"view.outlinesHiddenLayers" = "Uwzględnij ukryte warstwy" +"view.outlinesObjectBounds" = "Uwzględnij granice obiektów" +"view.outlinesSelection" = "Pokaż kontury zaznaczenia" +"view.prototype" = "Prototyp" +"view.themeDark" = "Ciemny" +"view.themeLight" = "Jasny" +"view.themeSystem" = "Zgodny z systemem" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index d970db144..d8f857e0f 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -304,6 +304,7 @@ status = "draft — needs native review" "Corner smoothing" = "Suavização de cantos" "Couldn't download the variables" = "Não foi possível baixar as variáveis" "Couldn't reach {server}: showing what this computer remembers." = "Não foi possível alcançar {server}: mostrando o que este computador lembra." +"Couldn't read {name}." = "Não foi possível ler {name}." "Couldn't save automatically: {error}" = "Não foi possível salvar automaticamente: {error}" "Count" = "Quantidade" "Create" = "Criar" @@ -891,6 +892,7 @@ status = "draft — needs native review" "PDF print settings" = "Configurações de impressão em PDF" "Padding" = "Espaçamento interno" "Page" = "Página" +"Page deleted" = "Página excluída" "Pages" = "Páginas" "Pages added to the document" = "Páginas adicionadas ao documento" "Placed on the current page" = "Colocado na página atual" @@ -1217,6 +1219,7 @@ status = "draft — needs native review" "Start ascending from / stop descending at" = "Contar para cima a partir de / para baixo até" "Start from a blank canvas" = "Comece com uma tela em branco" "Start on path" = "Início no caminho" +"Start on the Home screen" = "Começar no Início" "Start point" = "Ponto inicial" "Start sharing" = "Começar a compartilhar" "Starting to share…" = "Começando a compartilhar…" @@ -1573,6 +1576,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "{name} foi esquecido neste computador; o arquivo continua em {server}" "{name} is on the server." = "{name} está no servidor." "{name} is spotlighting" = "{name} está em destaque" +"{name} is too large to open in the browser (over 512 MB)." = "{name} é grande demais para abrir no navegador (mais de 512 MB)." "{name} was removed from the server" = "{name} foi removido do servidor" "{name} · deleted on main" = "{name} · excluído no principal" "{name} · deleted on the branch" = "{name} · excluído na branch" @@ -1636,6 +1640,120 @@ status = "draft — needs native review" "⌘ click" = "⌘ clique" "⌘ scroll" = "⌘ rolar" "⌥ drag" = "⌥ arrastar" +"A responsive product page: hero, features, figures, a quote and a footer" = "Uma página de produto responsiva: destaque, recursos, dados, depoimento e rodapé" +"A title, an agenda, a big figure, a chart and a closing slide" = "Um título, uma pauta, um número em destaque, um gráfico e um slide de encerramento" +"A to Z" = "De A a Z" +"Adding to {name}" = "Adicionando a {name}" +"All the fonts this file uses are installed" = "Todas as fontes usadas neste arquivo estão instaladas" +"Alphabetical" = "Alfabética" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Uma tela de análise em grade: dados, gráficos vetoriais e uma tabela" +"Apps" = "Aplicativos" +"Apr" = "abr." +"Aug" = "ago." +"Bottom padding" = "Espaçamento interno inferior" +"Bound to {name}. Click to detach the variable." = "Vinculada a {name}. Clique para desvincular a variável." +"Checking the fonts installed on this computer…" = "Verificando as fontes instaladas neste computador…" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Estilos de cor e texto, variáveis Claro e Escuro, botões, campos e cartões" +"Convert to frame" = "Converter em quadro" +"Convert to section" = "Converter em seção" +"Created" = "Criado" +"Dashboard" = "Painel" +"Date created" = "Data de criação" +"Dec" = "dez." +"Design system starter" = "Ponto de partida para sistema de design" +"Draw a frame" = "Desenhe um quadro" +"Drop a .figcraft, .fig, .json, .svg or image file." = "Solte um arquivo .figcraft, .fig, .json, .svg ou uma imagem." +"Drop to import" = "Solte para importar" +"Drop to open" = "Solte para abrir" +"Edited {when}" = "Editado {when}" +"Export frames and layers as PNG, JPG, SVG or PDF" = "Exporte quadros e camadas como PNG, JPG, SVG ou PDF" +"Feb" = "fev." +"File not found" = "Arquivo não encontrado" +"Four screens of a trail app with components and a prototype flow" = "Quatro telas de um aplicativo de trilhas com componentes e um fluxo de protótipo" +"Getting started" = "Primeiros passos" +"Greyscale components and two example screens to sketch a flow with" = "Componentes em escala de cinza e duas telas de exemplo para esboçar um fluxo" +"Hide these tips (Getting started in the Help menu shows them again)" = "Ocultar estas dicas (Primeiros passos no menu Ajuda as exibe novamente)" +"Hide when publishing" = "Ocultar ao publicar" +"Icon components on a 24 px grid with keylines and a size preview" = "Componentes de ícone em uma grade de 24 px, com linhas-guia e prévia de tamanho" +"Icon set starter" = "Ponto de partida para conjunto de ícones" +"Individual padding" = "Espaçamento interno individual" +"Interface size" = "Tamanho da interface" +"Jan" = "jan." +"Jul" = "jul." +"Jun" = "jun." +"Kits" = "Kits" +"Landing page" = "Página de apresentação" +"Last modified" = "Modificado por último" +"Left padding" = "Espaçamento interno à esquerda" +"Link frames on the Prototype tab, then present the flow" = "Vincule os quadros na aba Protótipo e apresente o fluxo" +"Make a component" = "Criar um componente" +"Mar" = "mar." +"May" = "mai." +"Missing fonts" = "Fontes ausentes" +"Mobile app" = "Aplicativo para celular" +"Newest first" = "Mais recentes primeiro" +"No files or templates match your search" = "Nenhum arquivo ou modelo corresponde à sua busca" +"No other pages" = "Não há outras páginas" +"No templates match your search" = "Nenhum modelo corresponde à sua busca" +"Nov" = "nov." +"Oct" = "out." +"Oldest first" = "Mais antigos primeiro" +"One announcement in five common sizes: square, portrait, story, link and banner" = "Um anúncio em cinco tamanhos comuns: quadrado, retrato, story, link e banner" +"Opening the file…" = "Abrindo o arquivo…" +"Opening {n} files as new files" = { one = "Abrindo como um novo arquivo", other = "Abrindo {n} arquivos como novos arquivos" } +"Order" = "Ordem" +"Other" = "Outro" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "Selecione a ferramenta Quadro e arraste na tela ou escolha o tamanho de um dispositivo" +"Plugin" = "Plugin" +"Plugins & widgets" = "Plugins e widgets" +"Presentation slides" = "Slides de apresentação" +"Playground" = "Área de testes" +"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Em relação à escala de exibição do sistema (100% corresponde ao tamanho definido pelo sistema). O zoom do documento é independente." +"Remove from recents" = "Remover dos recentes" +"Reorder selection" = "Reordenar seleção" +"Right padding" = "Espaçamento interno à direita" +"Search templates" = "Buscar modelos" +"See all templates" = "Ver todos os modelos" +"Select layers and they space themselves as their content changes" = "Selecione camadas e elas ajustarão o espaçamento conforme o conteúdo mudar" +"Search files and templates" = "Buscar arquivos e modelos" +"Search results" = "Resultados da busca" +"Search pages" = "Buscar páginas" +"Sep" = "set." +"Share with a code" = "Compartilhar com um código" +"Show all templates" = "Mostrar todos os modelos" +"Show in Explorer" = "Mostrar no Explorador de Arquivos" +"Show in Finder" = "Mostrar no Finder" +"Show in folder" = "Mostrar na pasta" +"Social and slides" = "Redes sociais e slides" +"Social post sizes" = "Tamanhos de publicação para redes sociais" +"Sort by" = "Ordenar por" +"Sort by {column}" = "Ordenar por {column}" +"Start from a template" = "Começar com um modelo" +"Start sharing with the Share button, then pass on the code it gives" = "Comece a compartilhar pelo botão Compartilhar e envie o código gerado" +"Templates" = "Modelos" +"The destination document is unavailable. Nothing was imported." = "O documento de destino está indisponível. Nada foi importado." +"The destination document was closed. Nothing was imported." = "O documento de destino foi fechado. Nada foi importado." +"The destination page was removed. Nothing was imported." = "A página de destino foi removida. Nada foi importado." +"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "A janela é pequena demais para {size}%, então a interface é exibida em {drawn}%. Aumente a janela para usar {size}%." +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Este arquivo usa fontes que não estão instaladas neste computador. O texto será exibido com uma fonte substituta até que você as instale." +"Top padding" = "Espaçamento interno superior" +"Try another search, or browse templates." = "Tente outra busca ou explore os modelos." +"Try another word, or look at every kind." = "Tente outra palavra ou veja todos os tipos." +"Turn a layer into a component, reuse it, and change every copy at once" = "Transforme uma camada em componente, reutilize-a e altere todas as cópias de uma só vez" +"Use your browser's zoom to change the interface size." = "Use o zoom do navegador para alterar o tamanho da interface." +"Uses variables: each layer shows them in its own mode" = "Usa variáveis: cada camada as exibe em seu próprio modo" +"Websites" = "Sites" +"What you try here changes this preview and the code below, not the file" = "O que você experimentar aqui altera esta prévia e o código abaixo, não o arquivo" +"Widget" = "Widget" +"Wireframe kit" = "Kit de wireframe" +"Z to A" = "De Z a A" +"just now" = "agora mesmo" +"only in a text style or variable" = "somente em um estilo de texto ou variável" +"slot layers without a matching slot property were imported as frames" = "camadas de slot sem uma propriedade de slot correspondente foram importadas como quadros" +"yesterday" = "ontem" +"{month} {day}, {year}" = "{day} de {month} de {year}" +"{n} text layers" = { one = "{n} camada de texto", other = "{n} camadas de texto" } +"{percent}%" = "{percent}%" [commands] "align.bottom" = "Alinhar embaixo" @@ -1785,6 +1903,7 @@ status = "draft — needs native review" "file.exportLayers" = "Exportar camadas" "file.exportSvg" = "Exportar seleção como SVG" "file.import" = "Importar" +"file.moveTab" = "Mover aba" "file.new" = "Novo arquivo de design" "file.open" = "Abrir" "file.openRecent" = "Abrir recente" @@ -1922,6 +2041,7 @@ status = "draft — needs native review" "pref.reduceMotion" = "Reduzir movimento" "pref.renameDuplicates" = "Renomear camadas duplicadas" "pref.showDimensions" = "Mostrar dimensões nos objetos" +"pref.startOnHome" = "Começar no Início" "pref.userName" = "Seu nome nos comentários" "proto.action.add" = "Adicionar ação de protótipo" "proto.action.move" = "Reordenar ação de protótipo" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 8d371cbed..6e173a925 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -1,5 +1,6 @@ # FigCraft interface strings: Russian. # Wording reviewed and approved by VanTan. +# Strings added since that review (2026-10-20) are a machine draft awaiting review. # Keep Frame, Dev Mode and Smart animate as familiar tool names. # See docs/translating.md; plural forms follow Russian integer rules. @@ -16,6 +17,7 @@ name = "Русский" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: PR: {prs}, коммитов: {commits}, +{added} / −{deleted} строк (Δ {delta}), +{bin_added} / −{bin_deleted} бинарных ресурсов, {first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Коллекция этого файла с этими режимами и переменными: переопределяйте значения для каждого режима и добавляйте собственные переменные" "A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Файл .figcraft с таким именем уже существует. Чтобы подтвердить замену, выберите его полное имя." +"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Отдельный слой выравнивается по своему фрейму: выделите больше слоёв или слой внутри фрейма." "A responsive product page: hero, features, figures, a quote and a footer" = "Адаптивная страница продукта: главный блок, возможности, показатели, цитата и подвал" "A spring plays until it settles: its mass, stiffness and damping set how long" = "Пружинная анимация длится до затухания: её время задают масса, жёсткость и демпфирование" "A title, an agenda, a big figure, a chart and a closing slide" = "Заголовок, повестка, крупный показатель, диаграмма и заключительный слайд" @@ -65,6 +67,7 @@ name = "Русский" "Add variant with a new value" = "Добавить вариант с новым значением" "Added" = "Добавлено" "Added the library \"{name}\"" = "Добавлена библиотека «{name}»" +"Adding to {name}" = "Добавление в {name}" "Adjust" = "Коррекция" "Adjustment presets" = "Пресеты коррекции" "Advanced" = "Дополнительно" @@ -88,6 +91,7 @@ name = "Русский" "All components" = "Все компоненты" "All fonts" = "Все шрифты" "All supported properties" = "Все поддерживаемые свойства" +"All the fonts this file uses are installed" = "Все шрифты, которые использует этот файл, установлены" "All variables" = "Все переменные" "Alpha" = "Альфа" "Alphabetical" = "По алфавиту" @@ -168,6 +172,7 @@ name = "Русский" "Both directions" = "Оба направления" "Bottom" = "Снизу" "Bottom left radius" = "Радиус нижнего левого угла" +"Bottom padding" = "Нижний внутренний отступ" "Bottom right radius" = "Радиус нижнего правого угла" "Branch" = "Ветка" "Branch archived" = "Ветка архивирована" @@ -194,6 +199,7 @@ name = "Русский" "C or ⇧C" = "C или ⇧C" "CMYK colour" = "Цвет CMYK" "Can view" = "Может просматривать" +"Checking the fonts installed on this computer…" = "Проверка шрифтов, установленных на этом компьютере…" "Checking the server…" = "Проверка сервера…" "Clear from this list" = "Убрать из этого списка" "Code or invite link" = "Код или ссылка приглашения" @@ -293,6 +299,8 @@ name = "Русский" "Contributors" = "Участники" "Contributors: {people} · Commits: {commits}" = "Участники: {people} · Коммиты: {commits}" "Controller" = "Контроллер" +"Convert to frame" = "Преобразовать во Frame" +"Convert to section" = "Преобразовать в секцию" "Cool" = "Холодный" "Copied as CSS" = "Скопировано как CSS" "Copied as PNG" = "Скопировано как PNG" @@ -310,7 +318,10 @@ name = "Русский" "Copy {value}" = "Копировать {value}" "Corner radius" = "Радиус углов" "Corner smoothing" = "Сглаживание углов" +"Couldn't download the variables" = "Не удалось загрузить переменные" "Couldn't reach {server}: showing what this computer remembers." = "Не удалось связаться с {server}: показаны данные, сохранённые на этом компьютере." +"Couldn't read {name}." = "Не удалось прочитать {name}." +"Couldn't save automatically: {error}" = "Не удалось сохранить автоматически: {error}" "Count" = "Количество" "Create" = "Создать" "Create a variable collection first" = "Сначала создайте коллекцию переменных" @@ -406,7 +417,10 @@ name = "Русский" "Draw" = "Рисование" "Draw a frame" = "Нарисовать Frame" "Drawing tools" = "Инструменты рисования" +"Drop a .figcraft, .fig, .json, .svg or image file." = "Перетащите файл .figcraft, .fig, .json, .svg или изображение." "Drop shadow" = "Внешняя тень" +"Drop to import" = "Отпустите, чтобы импортировать" +"Drop to open" = "Отпустите, чтобы открыть" "Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Двойная лицензия MIT OR Apache-2.0. Создано командой и сообществом ArtCraft." "Duo" = "Дуо" "Duplicate" = "Дублировать" @@ -476,6 +490,7 @@ name = "Русский" "False" = "Ложь" "Feb" = "февр." "Fetching from the server…" = "Загрузка с сервера…" +"FigCraft couldn't open its window: {error}" = "FigCraft не смог открыть своё окно: {error}" "FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft пока поддерживает не все возможности этих файлов. В замечаниях перечислено, что было пропущено или изменено в импортированной копии; исходный файл не меняется." "File" = "Файл" "File name" = "Имя файла" @@ -597,6 +612,7 @@ name = "Русский" "Icon components on a 24 px grid with keylines and a size preview" = "Компоненты иконок на сетке 24 px с направляющими и предпросмотром размеров" "Icon set starter" = "Основа набора иконок" "If" = "Если" +"If it keeps happening, please report it at {link} with this message." = "Если это повторяется, сообщите об этом по адресу {link}, приложив это сообщение." "If {condition}" = "Если {condition}" "Ignore auto layout (absolute position)" = "Игнорировать автолейаут (абсолютная позиция)" "Image" = "Изображение" @@ -618,6 +634,7 @@ name = "Русский" "Included" = "Включено" "Included in layout" = "Участвует в компоновке" "Individual corners" = "Углы по отдельности" +"Individual padding" = "Внутренние отступы по отдельности" "Individual strokes" = "Обводки по отдельности" "Ink" = "Чернила" "Inner shadow" = "Внутренняя тень" @@ -639,6 +656,7 @@ name = "Русский" "Interaction" = "Взаимодействие" "Interactions" = "Взаимодействия" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Дизайн интерфейсов и прототипирование. Бесплатный инструмент с открытым исходным кодом, независимо созданный с нуля на Rust, из семейства ArtCraft Crafting Apps." +"Interface size" = "Размер интерфейса" "Intersect" = "Пересечение" "Intersects" = "Пересечения" "Invalid color" = "Недопустимый цвет" @@ -688,6 +706,7 @@ name = "Русский" "Leaves" = "Листья" "Left" = "Слева" "Left & right" = "Слева и справа" +"Left padding" = "Левый внутренний отступ" "Left side" = "Левая сторона" "Left side width" = "Ширина левой стороны" "Letter case" = "Регистр букв" @@ -760,6 +779,7 @@ name = "Русский" "Mirror angle and length" = "Отражать угол и длину" "Mirroring" = "Зеркальность" "Missing" = "Отсутствует" +"Missing fonts" = "Отсутствующие шрифты" "Missing layer" = "Слой отсутствует" "Missing style" = "Стиль отсутствует" "Missing variable" = "Переменная отсутствует" @@ -910,10 +930,13 @@ name = "Русский" "Open variables ({count})" = "Открыть переменные ({count})" "Opened as a new document" = "Открыт как новый документ" "Opened “{name}” in a new tab" = "«{name}» открыт в новой вкладке" +"Opening the file…" = "Открытие файла…" +"Opening {n} files as new files" = { one = "Открытие {n} файла как нового", few = "Открытие {n} файлов как новых", many = "Открытие {n} файлов как новых", other = "Открытие {n} файлов как новых" } "Open|comments" = "Открытые" "Options" = "Параметры" "Orange" = "Оранжевый" "Order" = "Порядок" +"Other" = "Другое" "Other apps may not open every .fig file exported here." = "Другие приложения могут открыть не каждый экспортированный здесь файл .fig." "Others lose access and keep their copy; the file stays on the server for you" = "Другие участники потеряют доступ, сохранив свои копии; файл останется на сервере для вас" "Others type this under Share ▸ Join with a code" = "Другие участники вводят это в «Поделиться» ▸ «Подключиться по коду»" @@ -932,6 +955,7 @@ name = "Русский" "PDF print settings" = "Настройки печати PDF" "Padding" = "Внутренние отступы" "Page" = "Страница" +"Page deleted" = "Страница удалена" "Pages" = "Страницы" "Pages added to the document" = "Страницы добавлены в документ" "Placed on the current page" = "Размещено на текущей странице" @@ -972,6 +996,8 @@ name = "Русский" "Place a shape or frame (its tool chosen)" = "Разместить фигуру или Frame (выбранным инструментом)" "Place automatically" = "Разместить автоматически" "Place image" = "Разместить изображение" +"Plugin" = "Плагин" +"Plugins & widgets" = "Плагины и виджеты" "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Плагины работают в песочнице FigCraft без доступа к файлам, сети и часам. Устанавливайте только плагины, которым доверяете." "Plugins" = "Плагины" "Plus darker" = "Линейное затемнение" @@ -1041,6 +1067,7 @@ name = "Русский" "Reduce motion" = "Уменьшить движение" "Refraction" = "Преломление" "Regular expression" = "Регулярное выражение" +"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Относительно масштаба экрана в системе (100% — собственный размер системы). Масштаб документа задаётся отдельно." "Remove" = "Удалить" "Remove all" = "Удалить всё" "Remove annotation" = "Удалить аннотацию" @@ -1064,6 +1091,7 @@ name = "Русский" "Remove the else and its actions" = "Удалить «Иначе» и его действия" "Remove this condition and its actions" = "Удалить это условие и его действия" "Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Удалить файл с сервера? Его больше нельзя будет открывать и публиковать оттуда. Ваша копия останется открытой здесь." +"Remove {name} from session" = "Убрать {name} из сессии" "Remove {name} from {server}?" = "Удалить {name} с {server}?" "Removed" = "Удалено" "Removed from the server" = "Удалено с сервера" @@ -1078,6 +1106,7 @@ name = "Русский" "Rename on the server…" = "Переименовать на сервере…" "Rename page" = "Переименовать страницу" "Rename to" = "Новое имя" +"Reorder selection" = "Изменить порядок выделения" "Replace all" = "Заменить всё" "Replace mixed export settings" = "Заменить разные настройки экспорта" "Replace with" = "Заменить на" @@ -1113,6 +1142,7 @@ name = "Русский" "Review and merge" = "Проверить и объединить" "Ribbon" = "Лента" "Right" = "Справа" +"Right padding" = "Правый внутренний отступ" "Right side" = "Правая сторона" "Right side width" = "Ширина правой стороны" "Rotate 90°" = "Повернуть на 90°" @@ -1132,6 +1162,8 @@ name = "Русский" "Rulers" = "Линейки" "Run" = "Запустить" "Run {label}" = "Запустить {label}" +"Save files automatically" = "Автоматически сохранять файлы" +"Search pages" = "Поиск страниц" "Search templates" = "Поиск шаблонов" "See all templates" = "Все шаблоны" "Select layers and they space themselves as their content changes" = "Выделите слои: интервалы между ними будут подстраиваться при изменении содержимого" @@ -1276,6 +1308,7 @@ name = "Русский" "Start from a blank canvas" = "Начать с пустого холста" "Start from a template" = "Начать с шаблона" "Start on path" = "Начало на пути" +"Start on the Home screen" = "Начинать с главной" "Start point" = "Начальная точка" "Start sharing" = "Включить общий доступ" "Start sharing with the Share button, then pass on the code it gives" = "Включите общий доступ кнопкой «Поделиться», затем передайте полученный код" @@ -1337,6 +1370,9 @@ name = "Русский" "Texts" = "Тексты" "Texture" = "Текстура" "The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "На холсте цвета отображаются в sRGB; экспортированные PNG, JPG и WebP помечены профилем Display P3." +"The destination document is unavailable. Nothing was imported." = "Документ назначения недоступен. Ничего не импортировано." +"The destination document was closed. Nothing was imported." = "Документ назначения закрыт. Ничего не импортировано." +"The destination page was removed. Nothing was imported." = "Страница назначения удалена. Ничего не импортировано." "The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "Файл удаляется с сервера: его больше нельзя будет открыть или подключиться к нему оттуда. Отменить это нельзя. Уже открытые копии остаются у участников." "The file now holds another library" = "Теперь файл содержит другую библиотеку" "The file now holds another library (\"{name}\")" = "Теперь файл содержит другую библиотеку («{name}»)" @@ -1346,6 +1382,7 @@ name = "Русский" "The selection uses several text styles: click to apply one" = "В выделении несколько текстовых стилей: нажмите, чтобы применить один" "The server deletes this file if nobody opens it before {date}." = "Сервер удалит файл, если никто не откроет его до {date}." "The whole file" = "Весь файл" +"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "Окно слишком мало для {size}%, поэтому интерфейс отрисовывается в масштабе {drawn}%. Увеличьте окно, чтобы использовать {size}%." "Theme" = "Тема" "There's no destination for that action" = "Для этого действия не задано назначение" "Thickness" = "Толщина" @@ -1357,6 +1394,8 @@ name = "Русский" "This file" = "Этот файл" "This file has unsaved changes" = "В файле есть несохранённые изменения" "This file is on {server}, open only to you. Share it to let others in with a new code." = "Файл находится на {server} и доступен только вам. Включите общий доступ, чтобы пригласить других по новому коду." +"This file is too large to join here (up to {mb} MB)" = "Этот файл слишком большой, чтобы подключиться здесь (до {mb} МБ)" +"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "В этом файле используются шрифты, не установленные на этом компьютере. Их текст отрисовывается запасным шрифтом, пока вы их не установите." "This frame is new on the branch." = "Этот Frame добавлен в ветке." "This frame isn't on the branch." = "Этого Frame нет в ветке." "This server is open to the public: no key needed." = "Сервер общедоступен: ключ не нужен." @@ -1376,6 +1415,7 @@ name = "Русский" "Top" = "Сверху" "Top & bottom" = "Сверху и снизу" "Top left radius" = "Радиус верхнего левого угла" +"Top padding" = "Верхний внутренний отступ" "Top right radius" = "Радиус верхнего правого угла" "Transform" = "Трансформация" "Triangle arrow" = "Треугольная стрелка" @@ -1413,6 +1453,8 @@ name = "Русский" "Update from selection" = "Обновить из выделения" "Updated {components} and {styles}" = "Обновлено: {components} и {styles}" "Updated {name}" = "Обновлено: {name}" +"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Часто помогает обновление драйвера видеокарты. Чтобы использовать только OpenGL, запустите FigCraft из командной строки с параметром:" +"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Часто помогает обновление драйвера видеокарты. Переменная WGPU_BACKEND (vulkan, gl, metal) задаёт другой графический API, который можно попробовать." "Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Загружает файл на сервер FigCraft и синхронизирует эту вкладку с ним. Вы получите ссылки для редактирования и просмотра." "Uppercase" = "Прописные" "Use a fixed value" = "Использовать фиксированное значение" @@ -1422,6 +1464,7 @@ name = "Русский" "Use number keys for opacity" = "Задавать непрозрачность цифровыми клавишами" "Use the .figcraft or .fig extension." = "Используйте расширение .figcraft или .fig." "Use the layer's fill" = "Использовать заливку слоя" +"Use your browser's zoom to change the interface size." = "Изменяйте размер интерфейса масштабом браузера." "Used in {files} · {instances}" = "Используется в {files} · {instances}" "Username" = "Имя пользователя" "Value" = "Значение" @@ -1456,6 +1499,7 @@ name = "Русский" "Visibility" = "Видимость" "Visible" = "Видимый" "Vivid" = "Яркий" +"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "Переменная WGPU_BACKEND задана и указывает используемый графический API; если её убрать, FigCraft выберет сам." "Warm" = "Тёплый" "Watch" = "Часы" "Wavy" = "Волнистый" @@ -1475,6 +1519,7 @@ name = "Русский" "White" = "Белый" "Who runs it, its terms and how long it keeps files" = "Оператор, условия и срок хранения файлов" "Why it changed" = "Причина изменения" +"Widget" = "Виджет" "Widgets" = "Виджеты" "Width" = "Ширина" "Width point" = "Точка ширины" @@ -1572,6 +1617,7 @@ name = "Русский" "nobody else here yet" = "здесь пока больше никого нет" "non-default slot settings are not represented and were skipped" = "нестандартные настройки слотов не передаются и пропущены" "now" = "сейчас" +"only in a text style or variable" = "только в текстовом стиле или переменной" "or" = "или" "point stroke joins were preserved; dashed or variable-width strokes render with the layer join" = "соединения обводки в точках сохранены; пунктирные обводки и обводки переменной ширины рисуются с соединением слоя" "preferred slot component keys cannot be resolved to file nodes and were skipped" = "ключи предпочтительных компонентов слотов не удалось сопоставить со слоями файла, они пропущены" @@ -1594,6 +1640,7 @@ name = "Русский" "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "действия смены режима переменных для коллекций или режимов, которых нет в JSON переменных, пропущены" "slot content assignment target is missing or is not slot content" = "цель назначения содержимого слота отсутствует или не является содержимым слота" "slot content assignment uses the empty GUID sentinel and was skipped" = "назначение содержимого слота использует пустой GUID-маркер и пропущено" +"slot layers without a matching slot property were imported as frames" = "слои слотов без подходящего свойства слота импортированы как фреймы" "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "некоторые векторные точки ссылаются на настройки точек, которых нет в файле; они используют соединения слоя и обычные углы" "some vector segments refer to style settings missing from the file; they use the layer's stroke" = "некоторые векторные сегменты ссылаются на настройки стиля, которых нет в файле; они используют обводку слоя" "stale or invalid grid track sizing entries were ignored" = "устаревшие или недопустимые записи размеров треков сетки проигнорированы" @@ -1635,6 +1682,7 @@ name = "Русский" "{name} is forgotten on this computer; the file stays on {server}" = "{name} забыт на этом компьютере; файл остаётся на {server}" "{name} is on the server." = "{name} находится на сервере." "{name} is spotlighting" = "{name} показывает экран всем" +"{name} is too large to open in the browser (over 512 MB)." = "Файл {name} слишком велик для открытия в браузере (больше 512 МБ)." "{name}\nClick to apply to the selection" = "{name}\nНажмите, чтобы применить к выделению" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nПеретащите на холст или нажмите, чтобы разместить в центре вида.\nУдерживайте Alt (Option) при переносе на экземпляр, чтобы заменить его." "{name} (from {library})" = "{name} (из {library})" @@ -1679,6 +1727,7 @@ name = "Русский" "{n} replies" = { one = "{n} ответ", few = "{n} ответа", many = "{n} ответов", other = "{n} ответа" } "{n} rows" = { one = "{n} строка", few = "{n} строки", many = "{n} строк", other = "{n} строки" } "{n} styles" = { one = "{n} стиль", few = "{n} стиля", many = "{n} стилей", other = "{n} стиля" } +"{n} text layers" = { one = "{n} текстовый слой", few = "{n} текстовых слоя", many = "{n} текстовых слоёв", other = "{n} текстовых слоя" } "{n} variables" = { one = "{n} переменная", few = "{n} переменные", many = "{n} переменных", other = "{n} переменной" } "{n} weeks ago" = { one = "{n} неделю назад", few = "{n} недели назад", many = "{n} недель назад", other = "{n} недели назад" } "{n} years ago" = { one = "{n} год назад", few = "{n} года назад", many = "{n} лет назад", other = "{n} года назад" } @@ -1687,6 +1736,7 @@ name = "Русский" "{n}m" = "{n} мин" "{n}w" = "{n} нед" "{page}, nothing selected" = "{page}, ничего не выделено" +"{percent}%" = "{percent}%" "{profile} (current)" = "{profile} (текущий)" "{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} — значение свойства; {property ? \"a\" : \"b\"} выбирает строку. Другие {…} остаются как есть." "{server} can't say what became of your files (it is older than this app)." = "{server} не может сообщить, что стало с вашими файлами (версия сервера старее приложения)." @@ -1729,6 +1779,7 @@ name = "Русский" "arrange.front" = "На передний план" "arrange.packH" = "Уплотнить по горизонтали" "arrange.packV" = "Уплотнить по вертикали" +"arrange.reorder" = "Изменить порядок выделения" "arrange.roundToPixels" = "Округлить до пикселей" "arrange.spacing" = "Интервал" "arrange.tidy" = "Упорядочить" @@ -1763,6 +1814,7 @@ name = "Русский" "collab.publish" = "Опубликовать на сервере" "collab.recoverFile" = "Восстановить онлайн-файл" "collab.registerLinks" = "Открывать ссылки приглашения в FigCraft" +"collab.removePerson" = "Убрать из сессии" "collab.report" = "Пожаловаться на файл оператору сервера" "collab.serverInfo" = "Информация о сервере" "collab.share" = "Поделиться по коду подключения" @@ -1855,6 +1907,7 @@ name = "Русский" "file.exportLayers" = "Экспортировать слои" "file.exportSvg" = "Экспортировать выделение в SVG" "file.import" = "Импорт" +"file.moveTab" = "Переместить вкладку" "file.new" = "Новый файл дизайна" "file.newFromTemplate" = "Создать из шаблона" "file.open" = "Открыть" @@ -1940,6 +1993,7 @@ name = "Русский" "object.batchRename" = "Массовое переименование" "object.convertToFrame" = "Преобразовать во Frame" "object.convertToGroup" = "Преобразовать в группу" +"object.convertToSection" = "Преобразовать в секцию" "object.flipH" = "Отразить по горизонтали" "object.flipV" = "Отразить по вертикали" "object.frameSelection" = "Поместить выделение во Frame" @@ -1986,6 +2040,7 @@ name = "Русский" "plugin.ui" = "Панель плагина" "plugins.manage" = "Управление плагинами…" "plugins.runLast" = "Запустить последний плагин" +"pref.autoSave" = "Автоматически сохранять файлы" "pref.highlightHover" = "Подсвечивать слои при наведении" "pref.keepTool" = "Оставлять инструмент выбранным после использования" "pref.language" = "Язык интерфейса" @@ -1995,6 +2050,8 @@ name = "Русский" "pref.reduceMotion" = "Уменьшить движение" "pref.renameDuplicates" = "Переименовывать дубликаты слоёв" "pref.showDimensions" = "Показать размеры объектов" +"pref.startOnHome" = "Начинать с главной" +"pref.uiScale" = "Размер интерфейса" "pref.userName" = "Ваше имя в комментариях" "proto.action.add" = "Добавить действие прототипа" "proto.action.move" = "Изменить порядок действия прототипа" @@ -2100,14 +2157,18 @@ name = "Русский" "text.listIndent" = "Увеличить отступ списка" "text.listOutdent" = "Уменьшить отступ списка" "text.listStyle" = "Стиль списка" +"text.loadFont" = "Загрузить файл шрифта" "text.maxLines" = "Максимум строк" +"text.missingFonts" = "Отсутствующие шрифты" "text.numberList" = "Нумерованный список" "text.onPath" = "Текст на пути" "text.paragraphIndent" = "Абзацный отступ" "text.paragraphSpacing" = "Интервал между абзацами" "text.pathOptions" = "Параметры текста на пути" "text.preview" = "Предпросмотр изменения текста" +"text.refit" = "Подогнать текст под шрифты" "text.removeFromPath" = "Снять текст с пути" +"text.rescanFonts" = "Повторно найти установленные шрифты" "text.set" = "Задать текст" "text.setFeatures" = "Функции OpenType" "text.setVariations" = "Оси вариативного шрифта" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index d57bb25a5..d86779e02 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -315,6 +315,7 @@ name = "Svenska" "Corner smoothing" = "Hörnutjämning" "Couldn't download the variables" = "Det gick inte att hämta variablerna" "Couldn't reach {server}: showing what this computer remembers." = "Det gick inte att nå {server}: visar det som den här datorn minns." +"Couldn't read {name}." = "Det gick inte att läsa {name}." "Couldn't save automatically: {error}" = "Det gick inte att spara automatiskt: {error}" "Count" = "Antal" "Create" = "Skapa" @@ -942,6 +943,7 @@ name = "Svenska" "PDF print settings" = "PDF-utskriftsinställningar" "Padding" = "Utfyllnad" "Page" = "Sida" +"Page deleted" = "Sidan har raderats" "Pages" = "Sidor" "Pages added to the document" = "Sidor tillagda i dokumentet" "Placed on the current page" = "Placerat på den aktuella sidan" @@ -1286,6 +1288,7 @@ name = "Svenska" "Start from a blank canvas" = "Börja med en tom arbetsyta" "Start from a template" = "Börja med en mall" "Start on path" = "Början på banan" +"Start on the Home screen" = "Börja på Start" "Start point" = "Startpunkt" "Start sharing" = "Börja dela" "Start sharing with the Share button, then pass on the code it gives" = "Börja dela med knappen Dela och skicka sedan vidare koden du får" @@ -1650,6 +1653,7 @@ name = "Svenska" "{name} is forgotten on this computer; the file stays on {server}" = "{name} har glömts på den här datorn; filen finns kvar på {server}" "{name} is on the server." = "{name} finns på servern." "{name} is spotlighting" = "{name} har spotlight" +"{name} is too large to open in the browser (over 512 MB)." = "{name} är för stor för att öppnas i webbläsaren (över 512 MB)." "{name}\nClick to apply to the selection" = "{name}\nKlicka för att använda på markeringen" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nDra till arbetsytan, eller klicka för att placera i mitten av vyn.\nHåll ned Alt (Option) när du släpper på en instans för att byta ut den." "{name} (from {library})" = "{name} (från {library})" @@ -1872,6 +1876,7 @@ name = "Svenska" "file.exportLayers" = "Exportera lager" "file.exportSvg" = "Exportera som SVG" "file.import" = "Importera" +"file.moveTab" = "Flytta flik" "file.new" = "Nytt" "file.newFromTemplate" = "Nytt från mall" "file.open" = "Öppna" @@ -2013,6 +2018,7 @@ name = "Svenska" "pref.reduceMotion" = "Minska rörelse" "pref.renameDuplicates" = "Byt namn på duplicerade lager" "pref.showDimensions" = "Visa dimensioner på objekt" +"pref.startOnHome" = "Börja på Start" "pref.userName" = "Ditt namn i kommentarer" "proto.action.add" = "Lägg till prototypåtgärd" "proto.action.move" = "Ändra ordning på prototypåtgärd" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index a9da62ef1..bd8bb7c9d 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -314,6 +314,7 @@ name = "Türkçe" "Corner radius" = "Köşe yarıçapı" "Corner smoothing" = "Köşe yumuşatma" "Couldn't reach {server}: showing what this computer remembers." = "{server} sunucusuna ulaşılamadı: bu bilgisayarın hatırladıkları gösteriliyor." +"Couldn't read {name}." = "{name} okunamadı." "Count" = "Sayı" "Create" = "Oluştur" "Create a variable collection first" = "Önce bir değişken koleksiyonu oluşturun" @@ -933,6 +934,7 @@ name = "Türkçe" "PDF print settings" = "PDF baskı ayarları" "Padding" = "İç boşluk" "Page" = "Sayfa" +"Page deleted" = "Sayfa silindi" "Pages" = "Sayfalar" "Pages added to the document" = "Sayfalar belgeye eklendi" "Placed on the current page" = "Geçerli sayfaya yerleştirildi" @@ -1633,6 +1635,7 @@ name = "Türkçe" "{name} is forgotten on this computer; the file stays on {server}" = "{name} bu bilgisayarda unutuldu; dosya {server} üzerinde kalır" "{name} is on the server." = "{name} sunucuda." "{name} is spotlighting" = "{name} öne çıkarıyor" +"{name} is too large to open in the browser (over 512 MB)." = "{name}, tarayıcıda açılamayacak kadar büyük (512 MB'tan fazla)." "{name}\nClick to apply to the selection" = "{name}\nSeçime uygulamak için tıklayın" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nTuvale sürükleyin veya görünümün ortasına yerleştirmek için tıklayın.\nBir örneği değiştirmek için üzerine bırakırken Alt (Option) tuşunu basılı tutun." "{name} (from {library})" = "{name} ({library} kitaplığından)" @@ -1853,6 +1856,7 @@ name = "Türkçe" "file.exportLayers" = "Katmanları dışa aktar" "file.exportSvg" = "Seçimi SVG olarak dışa aktar" "file.import" = "İçe aktar" +"file.moveTab" = "Sekmeyi taşı" "file.new" = "Yeni tasarım dosyası" "file.newFromTemplate" = "Şablondan yeni" "file.open" = "Aç" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index a90c1c033..94c919425 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -306,6 +306,7 @@ status = "draft — needs native review" "Corner radius" = "圆角半径" "Corner smoothing" = "圆角平滑" "Couldn't reach {server}: showing what this computer remembers." = "无法连接 {server}:显示的是这台电脑记录的内容。" +"Couldn't read {name}." = "无法读取 {name}。" "Couldn't save automatically: {error}" = "无法自动保存:{error}" "Count" = "数量" "Create" = "创建" @@ -893,6 +894,7 @@ status = "draft — needs native review" "PDF print settings" = "PDF 打印设置" "Padding" = "内边距" "Page" = "页面" +"Page deleted" = "页面已删除" "Pages" = "页面" "Pages added to the document" = "页面已添加到文档" "Placed on the current page" = "已放置在当前页面" @@ -1219,6 +1221,7 @@ status = "draft — needs native review" "Start ascending from / stop descending at" = "升序起点 / 降序终点" "Start from a blank canvas" = "从空白画布开始" "Start on path" = "路径起点" +"Start on the Home screen" = "启动时显示主页" "Start point" = "起点" "Start sharing" = "开始分享" "Starting to share…" = "正在开始分享…" @@ -1568,6 +1571,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "已在这台电脑上忘记 {name};文件仍保留在 {server} 上" "{name} is on the server." = "{name} 已在服务器上。" "{name} is spotlighting" = "{name} 正在聚焦" +"{name} is too large to open in the browser (over 512 MB)." = "{name} 太大,无法在浏览器中打开(超过 512 MB)。" "{name}\nClick to apply to the selection" = "{name}\n点击应用于所选项" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\n拖到画布上,或点击放置在视图中央。\n拖放到实例上时按住 Alt(Option)可替换该实例。" "{name} (from {library})" = "{name}(来自 {library})" @@ -1787,6 +1791,7 @@ status = "draft — needs native review" "file.exportLayers" = "导出图层" "file.exportSvg" = "将所选项导出为 SVG" "file.import" = "导入" +"file.moveTab" = "移动标签页" "file.new" = "新建设计文件" "file.open" = "打开" "file.openRecent" = "打开最近的文件" @@ -1924,6 +1929,7 @@ status = "draft — needs native review" "pref.reduceMotion" = "减少动态效果" "pref.renameDuplicates" = "重命名副本图层" "pref.showDimensions" = "在对象上显示尺寸" +"pref.startOnHome" = "启动时显示主页" "pref.userName" = "评论中显示的名字" "proto.action.add" = "添加原型操作" "proto.action.move" = "调整原型操作顺序" diff --git a/crates/ui-egui/src/assets_panel.rs b/crates/ui-egui/src/assets_panel.rs index f8ea18e03..36636c3de 100644 --- a/crates/ui-egui/src/assets_panel.rs +++ b/crates/ui-egui/src/assets_panel.rs @@ -316,7 +316,7 @@ fn search_row(app: &mut FigApp, ui: &mut Ui, w: f32) -> String { ); crate::widgets::select_all_on_focus(ui, &r, &filter); if r.has_focus() { - ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, crate::theme::ACCENT), egui::StrokeKind::Inside); if ui.input(|i| i.key_pressed(egui::Key::Escape)) { filter.clear(); } diff --git a/crates/ui-egui/src/async_dialog_tests.rs b/crates/ui-egui/src/async_dialog_tests.rs index 26808fe5d..f38ac8242 100644 --- a/crates/ui-egui/src/async_dialog_tests.rs +++ b/crates/ui-egui/src/async_dialog_tests.rs @@ -2,7 +2,7 @@ use std::path::PathBuf; use figcraft_engine::Session; -use crate::{DialogFuture, FigApp, Services}; +use crate::{ContentsFuture, DialogFuture, FigApp, Services}; struct OpenFile(PathBuf); @@ -79,6 +79,74 @@ fn save_as_saves_the_file_it_was_opened_for_after_another_tab_comes_to_the_front let _ = std::fs::remove_dir_all(dir); } +/// The browser: the picker hands over a file's name and contents, never a path. +struct Contents(&'static str, Vec); + +impl Services for Contents { + fn pick_open(&mut self, _title: &str, _exts: &[&str]) -> DialogFuture { + panic!("a platform that picks contents is never asked for a path") + } + fn pick_open_contents(&mut self, _title: &str, _exts: &[&str]) -> Option { + Some(Box::pin(std::future::ready(Some(Ok((self.0.to_string(), self.1.clone())))))) + } +} + +/// The browser, with a picked file it won't read. +struct Unreadable(String); + +impl Services for Unreadable { + fn pick_open_contents(&mut self, _title: &str, _exts: &[&str]) -> Option { + Some(Box::pin(std::future::ready(Some(Err(self.0.clone()))))) + } +} + +#[test] +fn a_picked_file_that_cannot_be_read_says_why_and_opens_nothing() { + let why = crate::browser_can_read("Huge.fig", 600.0 * 1024.0 * 1024.0).unwrap_err(); + let mut app = FigApp::new(Session::empty(), Box::new(Unreadable(why.clone()))); + app.open_dialog(); + app.poll_dialog(&egui::Context::default()); + assert!(app.session.docs.is_empty()); + assert_eq!(app.ui.last_error.as_deref(), Some(why.as_str())); + assert!(why.contains("Huge.fig"), "{why}"); +} + +#[test] +fn the_browser_reads_files_up_to_512_mib() { + let max = (512u64 << 20) as f64; + assert!(crate::browser_can_read("a", 0.0).is_ok()); + assert!(crate::browser_can_read("a", max).is_ok()); + assert!(crate::browser_can_read("a", max + 1.0).is_err()); + assert!(crate::browser_can_read("a", f64::NAN).is_err()); + assert!(crate::browser_can_read("a", f64::INFINITY).is_err()); +} + +#[test] +fn open_dialog_opens_picked_contents_as_a_new_unsaved_document() { + let bytes = figcraft_format::save_bytes(&figcraft_doc::Document::new("Picked"), None).unwrap(); + let mut app = FigApp::new(Session::empty(), Box::new(Contents("Picked.figcraft", bytes))); + app.ui.home = true; + app.open_dialog(); + assert!(app.session.docs.is_empty(), "opening waits for the picker result"); + app.poll_dialog(&egui::Context::default()); + assert_eq!(app.session.docs.len(), 1); + assert_eq!(app.session.docs[0].path, None, "no path to save over"); + assert_eq!(app.session.docs[0].doc.name, "Picked"); + assert!(!app.ui.home, "and the editor shows it"); +} + +#[test] +fn import_dialog_imports_picked_contents_into_the_file_in_front() { + let svg = br#""#.to_vec(); + let mut app = FigApp::new(Session::new(), Box::new(Contents("Logo.svg", svg))); + let rectangles = |app: &FigApp| app.session.doc().unwrap().nodes().filter(|n| matches!(n.kind, figcraft_doc::NodeKind::Rectangle)).count(); + assert_eq!(rectangles(&app), 0); + app.import_dialog(false); + app.poll_dialog(&egui::Context::default()); + assert_eq!(app.session.docs.len(), 1, "imported, not opened"); + assert_eq!(rectangles(&app), 1, "the SVG's content"); +} + #[test] fn a_dialog_whose_file_closed_meanwhile_does_nothing() { let (mut app, dir, path) = two_files("closed"); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9e0c1d797..491639869 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -12,7 +12,7 @@ use serde_json::json; use crate::FigApp; use crate::i18n::{tr, trf, trn}; use crate::render_worker::{Done, Job, Worker}; -use crate::theme::{ACCENT, COMPONENT, Tokens, doc_c32, regular, semibold}; +use crate::theme::{COMPONENT, Tokens, doc_c32, regular, semibold}; pub struct CanvasState { pub rect: Rect, @@ -233,6 +233,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { }) .unwrap_or(t.canvas); painter.rect_filled(rect, CornerRadius::ZERO, bg); + crate::theme::set_canvas_page(&ctx, bg); if let (Some(tex), Some(v), true) = (&app.canvas.texture, &app.canvas.tex_view, shown.is_some() && app.canvas.tex_of == shown) { // Re-project the last render onto the current view. let tl = app.to_screen(v.origin.0, v.origin.1); @@ -247,8 +248,8 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { if let (Some(start), Some(p)) = (app.canvas.zoom_drag, ctx.input(|i| i.pointer.hover_pos())) { let area = Rect::from_two_pos(start, p); if area.width() > 4.0 || area.height() > 4.0 { - painter.rect_filled(area, CornerRadius::ZERO, ACCENT.gamma_multiply(0.1)); - painter.rect_stroke(area, CornerRadius::ZERO, Stroke::new(1.0, ACCENT), StrokeKind::Inside); + painter.rect_filled(area, CornerRadius::ZERO, accent(&painter).gamma_multiply(0.1)); + painter.rect_stroke(area, CornerRadius::ZERO, Stroke::new(1.0, accent(&painter)), StrokeKind::Inside); } } crate::dev_mode::draw(app, &painter); @@ -490,6 +491,8 @@ fn zoom_tool(app: &mut FigApp, ui: &Ui, resp: &egui::Response, pos: Pos2, mods: /// then doesn't show. fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { if crate::proto_canvas::input(app, ui, resp) { + // A prototype gesture is a press on the canvas too: the Pages list gives up the keyboard. + app.ui.pages_focus = None; return true; } let ctx = ui.ctx().clone(); @@ -507,21 +510,17 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { let path = f.path().to_path_buf(); let pt = hover_pos.map(|h| app.to_page(h)).unwrap_or_default(); let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped.png".into()); - let params = if path.exists() { - json!({"path": path.to_string_lossy(), "x": pt.x, "y": pt.y}) - } else if let Some(bytes) = crate::dropped_bytes(&f) { - use base64::Engine as _; - let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); - json!({"data": b64, "name": name, "x": pt.x, "y": pt.y}) + let (params, read) = if path.exists() { + (json!({"path": path.to_string_lossy(), "x": pt.x, "y": pt.y}), None) } else { - continue; + (json!({"name": name, "x": pt.x, "y": pt.y}), Some(crate::dropped_bytes(&f, &name))) }; let Some(target) = app.session.ds().ok().map(|doc| crate::pending_open::ImportTarget { uid: doc.uid, page: doc.page, name: doc.doc.name.clone() }) else { continue; }; - app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.import", target: Some(target) }); + app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.import", target: Some(target), read }); } let Some(pos) = hover_pos.filter(|p| app.canvas.rect.contains(*p)) else { // A drag released off the canvas, or off the window, ends where the pointer was last seen. @@ -544,6 +543,7 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { // A press on the canvas gives the keyboard back to it (arrows nudge again). if resp.hovered() && ctx.input(|i| i.pointer.any_pressed()) { app.ui.layers_focus = false; + app.ui.pages_focus = None; app.ui.key_box = None; } // Zoom (pinch, Cmd/Ctrl+scroll) about the pointer; plain scroll pans. @@ -809,11 +809,11 @@ fn swap_highlight(app: &FigApp, painter: &egui::Painter, doc: &Document, id: Nod } /// The outline colour for a layer: purple for components, sets and instances and every layer in -/// them, blue otherwise. -fn outline_colour(doc: &Document, id: NodeId) -> Color32 { +/// them, the page's overlay `green` otherwise. +fn outline_colour(doc: &Document, id: NodeId, green: Color32) -> Color32 { use figcraft_engine::components::{owning_component, owning_instance}; let set = matches!(doc.node(id).map(|n| &n.kind), Some(NodeKind::ComponentSet { .. })); - if set || owning_component(doc, id).is_some() || owning_instance(doc, id).is_some() { COMPONENT } else { ACCENT } + if set || owning_component(doc, id).is_some() || owning_instance(doc, id).is_some() { COMPONENT } else { green } } /// The outline path of shapes (ellipses, vectors) for hover/selection outlines. @@ -946,20 +946,20 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ && app.session.prefs.highlight_on_hover && app.session.tool.overlay.measure_box.is_none() { - draw_outline(app, painter, &doc, h, stroke(outline_colour(&doc, h), 1.5)); + draw_outline(app, painter, &doc, h, stroke(outline_colour(&doc, h, accent(painter)), 1.5)); } // Inside a boolean group (an operand selected or in vector edit mode): every operand's outline, // so the shapes making the live result stay visible while one of them changes. let operands = figcraft_engine::booleans::outlined_operands(&doc, &sel, ds.vector_edit.as_ref().map(|ve| ve.id)); for id in operands.iter().filter(|o| !sel.contains(o)) { - draw_outline(app, painter, &doc, *id, stroke(outline_colour(&doc, *id).gamma_multiply(0.6), 1.0)); + draw_outline(app, painter, &doc, *id, stroke(outline_colour(&doc, *id, accent(painter)).gamma_multiply(0.6), 1.0)); } // Mask outlines (a mask doesn't draw itself), frame outlines and slices, as the View menu shows. if view.mask_outlines || view.frame_outlines || view.show_slices { for id in doc.subtree(page) { let Some(n) = doc.node(id).filter(|n| n.visible) else { continue }; if view.mask_outlines && n.is_mask { - draw_outline(app, painter, &doc, id, stroke(ACCENT, 1.0)); + draw_outline(app, painter, &doc, id, stroke(accent(painter), 1.0)); } else if view.frame_outlines && n.kind.is_frame_like() { draw_outline(app, painter, &doc, id, stroke(t.text_secondary, 1.0)); } else if view.show_slices @@ -975,20 +975,20 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ if let Some(d) = ov.drop_target && let Some(q) = node_quad(app, &doc, d) { - quad(painter, q, stroke(if figcraft_engine::components::is_slot(&doc, d) { COMPONENT } else { ACCENT }, 1.0)); + quad(painter, q, stroke(if figcraft_engine::components::is_slot(&doc, d) { COMPONENT } else { accent(painter) }, 1.0)); } slot_badges(app, painter, &doc, &sel, ov.drop_target); if let Some(t) = ov.swap_target { swap_highlight(app, painter, &doc, t); } if let Some([x0, y0, x1, y1]) = ov.insert_line { - painter.line_segment([app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(ACCENT, 2.0)); + painter.line_segment([app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(accent(painter), 2.0)); } if let Some([x0, y0, x1, y1]) = ov.drop_cell { let r = Rect::from_two_pos(app.to_screen(x0, y0), app.to_screen(x1, y1)); - painter.rect_filled(r, CornerRadius::same(2), ACCENT.gamma_multiply(0.12)); + painter.rect_filled(r, CornerRadius::same(2), accent(painter).gamma_multiply(0.12)); let pts = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()]; - painter.add(Shape::dashed_line(&pts, stroke(ACCENT, 1.5), 4.0, 3.0)); + painter.add(Shape::dashed_line(&pts, stroke(accent(painter), 1.5), 4.0, 3.0)); } // Vector edit mode, with the transform box around several selected points. // The selection's size label, which measurement labels keep clear of. @@ -1011,13 +1011,13 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ width_overlay(app, painter, &doc, &ve, zoom); } else if let Some(c) = app.session.vector_edit_box() { let label = format!("{} × {}", fmt((c[1] - c[0]).hypot()), fmt((c[3] - c[0]).hypot())); - size_label = transform_box(app, painter, c.map(|p| app.to_screen(p.x, p.y)), ACCENT, true, 4, label); + size_label = transform_box(app, painter, c.map(|p| app.to_screen(p.x, p.y)), accent(painter), true, 4, Some(label)); } } // Selection. let editing = ds.text_edit.clone(); if !sel.is_empty() && editing.is_none() && ds.vector_edit.is_none() { - let col = if sel.iter().any(|i| outline_colour(&doc, *i) == COMPONENT) { COMPONENT } else { ACCENT }; + let col = if sel.iter().any(|i| outline_colour(&doc, *i, accent(painter)) == COMPONENT) { COMPONENT } else { accent(painter) }; for id in &sel { draw_outline(app, painter, &doc, *id, stroke(col, 1.0)); } @@ -1035,8 +1035,10 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ // A layer smaller than its handles on screen shows just its outline (they still answer // just outside it). let tiny = figcraft_engine::tools::box_sides_px(&b, zoom).1 < figcraft_engine::tools::SMALL_BOX_PX; + // Measuring to another layer (Alt held) shows just the outlines: no handles or size. + let measuring = app.session.tool.overlay.measure_box.is_some(); // Locked layers (selected in the layers panel) can't be resized: no handles either. - let handles = if sel.iter().any(|i| figcraft_engine::tools::locked_in(&doc, *i)) { + let handles = if measuring || sel.iter().any(|i| figcraft_engine::tools::locked_in(&doc, *i)) { 0 } else if is_line { 2 @@ -1045,9 +1047,11 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ } else { 4 }; - size_label = transform_box(app, painter, b.map(|p| app.to_screen(p.x, p.y)), col, sel.len() > 1, handles, label); - arc_overlay(app, painter, col); - shape_overlay(app, painter, col); + size_label = transform_box(app, painter, b.map(|p| app.to_screen(p.x, p.y)), col, sel.len() > 1, handles, (!measuring).then_some(label)); + if !measuring { + arc_overlay(app, painter, col); + shape_overlay(app, painter, col); + } if app.session.tool.tool == ToolId::Scale { // The point that stays put: the dragged pivot, else the anchor. let pivot = scale @@ -1086,12 +1090,12 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ { let layout = &text.layout; if let Some(q) = node_quad(app, edoc, te.id) { - quad(painter, q, stroke(ACCENT, 1.0)); + quad(painter, q, stroke(accent(painter), 1.0)); } let (a, b) = (te.caret.min(te.anchor), te.caret.max(te.anchor)); if a != b && preedit.is_none() { for q in text.selection(app, a, b) { - painter.add(Shape::convex_polygon(q.to_vec(), ACCENT.gamma_multiply(0.3), Stroke::NONE)); + painter.add(Shape::convex_polygon(q.to_vec(), accent(painter).gamma_multiply(0.3), Stroke::NONE)); } } if let Some(p) = preedit { @@ -1122,8 +1126,8 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ // Marquee. if let Some([x0, y0, x1, y1]) = ov.marquee.or(app.ui.key_box) { let r = Rect::from_two_pos(app.to_screen(x0, y0), app.to_screen(x1, y1)); - painter.rect_filled(r, CornerRadius::ZERO, ACCENT.gamma_multiply(0.1)); - painter.rect_stroke(r, CornerRadius::ZERO, stroke(ACCENT, 1.0), StrokeKind::Inside); + painter.rect_filled(r, CornerRadius::ZERO, accent(painter).gamma_multiply(0.1)); + painter.rect_stroke(r, CornerRadius::ZERO, stroke(accent(painter), 1.0), StrokeKind::Inside); } // Pen path. if !ov.pen.is_empty() { @@ -1138,7 +1142,7 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ } for p in &pts { painter.circle_filled(*p, 3.5, Color32::WHITE); - painter.circle_stroke(*p, 3.5, stroke(ACCENT, 1.0)); + painter.circle_stroke(*p, 3.5, stroke(accent(painter), 1.0)); } if let Some([x0, y0, x1, y1]) = ov.rubber { let (a, b) = (app.to_screen(x0, y0), app.to_screen(x1, y1)); @@ -1150,11 +1154,11 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ [a, h, b, b], false, Color32::TRANSPARENT, - stroke(ACCENT, 1.0), + stroke(accent(painter), 1.0), )); } None => { - painter.line_segment([a, b], stroke(ACCENT, 1.0)); + painter.line_segment([a, b], stroke(accent(painter), 1.0)); } } } @@ -1165,7 +1169,7 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ let c = app.to_screen(x, y); let rs = (r * app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0)) as f32; painter.circle_stroke(c, rs, stroke(Color32::WHITE, 3.0)); - painter.circle_stroke(c, rs, stroke(ACCENT, 1.5)); + painter.circle_stroke(c, rs, stroke(accent(painter), 1.5)); } // Lasso (vector edit mode). if ov.lasso.len() > 1 { @@ -1173,7 +1177,7 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ if let Some(first) = pts.first().copied() { pts.push(first); } - painter.add(Shape::dashed_line(&pts, stroke(ACCENT, 1.0), 4.0, 3.0)); + painter.add(Shape::dashed_line(&pts, stroke(accent(painter), 1.0), 4.0, 3.0)); } // Guides (under the rulers). crate::guides::draw(app, painter); @@ -1185,8 +1189,16 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ } /// A transform box (screen corners tl, tr, br, bl): its outline when `outline`, the first `handles` -/// corner handles and, when dimensions are shown, the size `label` under it (its box returned). -fn transform_box(app: &FigApp, painter: &egui::Painter, q: [Pos2; 4], col: Color32, outline: bool, handles: usize, label: String) -> Option { +/// corner handles and, when dimensions are shown, the size `label` (if any) under it (its box returned). +fn transform_box( + app: &FigApp, + painter: &egui::Painter, + q: [Pos2; 4], + col: Color32, + outline: bool, + handles: usize, + label: Option, +) -> Option { if outline { quad(painter, q, stroke(col, 1.0)); } @@ -1195,7 +1207,7 @@ fn transform_box(app: &FigApp, painter: &egui::Painter, q: [Pos2; 4], col: Color painter.rect_filled(r, CornerRadius::same(1), Color32::WHITE); painter.rect_stroke(r, CornerRadius::same(1), stroke(col, 1.0), StrokeKind::Inside); } - if app.session.prefs.show_dimensions { + if let Some(label) = label.filter(|_| app.session.prefs.show_dimensions) { let bottom = q.iter().map(|p| p.y).fold(f32::MIN, f32::max); let cx = q.iter().map(|p| p.x).sum::() / 4.0; return Some(pill(painter, pos2(cx, bottom + 9.0), Align2::CENTER_TOP, label, col)); @@ -1207,7 +1219,7 @@ fn transform_box(app: &FigApp, painter: &egui::Painter, q: [Pos2; 4], col: Color fn pill(painter: &egui::Painter, at: Pos2, align: Align2, text: String, col: Color32) -> Rect { let galley = painter.layout_no_wrap(text, semibold(11.0), Color32::WHITE); let r = align.anchor_size(at, galley.size() + vec2(8.0, 4.0)); - painter.rect_filled(r, CornerRadius::same(3), col); + painter.rect_filled(r, CornerRadius::same(3), crate::theme::under_white(col)); painter.galley(r.min + vec2(4.0, 2.0), galley, Color32::WHITE); r } @@ -1264,7 +1276,7 @@ fn width_overlay(app: &FigApp, painter: &egui::Painter, doc: &Document, ve: &fig let sp = |p: &[f64; 2]| app.to_screen(p[0], p[1]); let draw = |h: &figcraft_engine::vectoredit::WidthHandle, ghost: bool| { let (c, a, b) = (sp(&h.at), sp(&h.sides[0]), sp(&h.sides[1])); - let col = if ghost { ACCENT.gamma_multiply(0.5) } else { ACCENT }; + let col = if ghost { accent(painter).gamma_multiply(0.5) } else { accent(painter) }; painter.line_segment([a, b], stroke(Color32::from_white_alpha(160), 3.0)); painter.line_segment([a, b], stroke(col, 1.0)); for (e, side) in [(a, WidthSide::Right), (b, WidthSide::Left)] { @@ -1276,7 +1288,7 @@ fn width_overlay(app: &FigApp, painter: &egui::Painter, doc: &Document, ve: &fig let on = !ghost && ve.width_point == Some(h.index); let hovered = !ghost && hover == Some(WHit::Point { index: h.index }); let r = if hovered { 5.0 } else { 4.0 }; - painter.circle_filled(c, r, if on { ACCENT } else { Color32::WHITE }); + painter.circle_filled(c, r, if on { accent(painter) } else { Color32::WHITE }); painter.circle_stroke(c, r, stroke(col, 1.0)); }; for h in width_handles(doc, ve, zoom) { @@ -1292,9 +1304,9 @@ fn width_overlay(app: &FigApp, painter: &egui::Painter, doc: &Document, ve: &fig /// A bezier handle from its anchor `v` to its end `e` (screen points): the line, and a dot of /// radius `r` filled with `fill` (vector edit mode and the Pen draw them alike). fn bezier_handle(painter: &egui::Painter, v: Pos2, e: Pos2, r: f32, fill: Color32) { - painter.line_segment([v, e], stroke(ACCENT, 1.0)); + painter.line_segment([v, e], stroke(accent(painter), 1.0)); painter.circle_filled(e, r, fill); - painter.circle_stroke(e, r, stroke(ACCENT, 1.0)); + painter.circle_stroke(e, r, stroke(accent(painter), 1.0)); } /// Vector edit mode: a layer's segments, handles and points (`points`) with its `hover`ed part, and @@ -1313,7 +1325,7 @@ fn vector_overlay( // Paint bucket target: hatched like a fill to come. if active && !ov.face.is_empty() { let polys: Vec> = ov.face.iter().map(|poly| poly.iter().map(sp).collect()).collect(); - hatched(painter, &polys, ACCENT); + hatched(painter, &polys, accent(painter)); } if active { region_hatch(app, painter); @@ -1331,7 +1343,7 @@ fn vector_overlay( } else { 1.5 }; - painter.add(egui::epaint::CubicBezierShape::from_points_stroke([a, b, c, d], false, Color32::TRANSPARENT, stroke(ACCENT, w))); + painter.add(egui::epaint::CubicBezierShape::from_points_stroke([a, b, c, d], false, Color32::TRANSPARENT, stroke(accent(painter), w))); } if !points { return; @@ -1339,7 +1351,7 @@ fn vector_overlay( for h in &o.handles { let hovered = hover == Some(VHit::Handle { seg: h.seg, start: h.start }); let on = o.selected_handles.contains(&(h.seg, h.start)); - bezier_handle(painter, sp(&h.vertex), sp(&h.handle), if hovered { 4.5 } else { 3.5 }, if on { ACCENT } else { Color32::WHITE }); + bezier_handle(painter, sp(&h.vertex), sp(&h.handle), if hovered { 4.5 } else { 3.5 }, if on { accent(painter) } else { Color32::WHITE }); } for (i, v) in o.vertices.iter().enumerate() { let p = sp(v); @@ -1347,14 +1359,14 @@ fn vector_overlay( let size = if hovered { 9.0 } else { 7.0 }; let r = Rect::from_center_size(p, vec2(size, size)); let on = o.selected_vertices.contains(&i); - painter.rect_filled(r, CornerRadius::same(5), if on { ACCENT } else { Color32::WHITE }); - painter.rect_stroke(r, CornerRadius::same(5), stroke(ACCENT, 1.0), StrokeKind::Inside); + painter.rect_filled(r, CornerRadius::same(5), if on { accent(painter) } else { Color32::WHITE }); + painter.rect_stroke(r, CornerRadius::same(5), stroke(accent(painter), 1.0), StrokeKind::Inside); if o.anchor == Some(i) { - painter.circle_stroke(p, 7.5, stroke(ACCENT, 1.0)); + painter.circle_stroke(p, 7.5, stroke(accent(painter), 1.0)); } } if let Some([x0, y0, x1, y1]) = ov.rubber.filter(|_| active) { - painter.add(Shape::dashed_line(&[app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(ACCENT, 1.0), 4.0, 3.0)); + painter.add(Shape::dashed_line(&[app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(accent(painter), 1.0), 4.0, 3.0)); } } @@ -1371,7 +1383,7 @@ fn region_hatch(app: &FigApp, painter: &egui::Painter) { let r = painter.clip_rect(); let clip = figcraft_geom::Rect::new(f64::from(r.min.x), f64::from(r.min.y), f64::from(r.max.x), f64::from(r.max.y)); for [a, b] in app.session.vector_region_hatch(view, Some(clip)) { - painter.line_segment([pos2(a.x as f32, a.y as f32), pos2(b.x as f32, b.y as f32)], stroke(ACCENT, REGION_HATCH_WIDTH)); + painter.line_segment([pos2(a.x as f32, a.y as f32), pos2(b.x as f32, b.y as f32)], stroke(accent(painter), REGION_HATCH_WIDTH)); } } @@ -1386,10 +1398,10 @@ fn builder_overlay(app: &FigApp, painter: &egui::Painter, t: &Tokens, arr: &figc for r in &arr.regions { for poly in screen(r) { painter.add(Shape::closed_line(poly.clone(), stroke(Color32::from_white_alpha(150), 2.5))); - painter.add(Shape::closed_line(poly, stroke(ACCENT, 1.0))); + painter.add(Shape::closed_line(poly, stroke(accent(painter), 1.0))); } } - let col = if ov.builder_remove { t.danger } else { ACCENT }; + let col = if ov.builder_remove { t.danger } else { accent(painter) }; for r in ov.builder_regions.iter().filter_map(|i| arr.regions.get(*i)) { hatched(painter, &screen(r), col); } @@ -1499,8 +1511,16 @@ fn rulers(app: &FigApp, painter: &egui::Painter, t: &Tokens, doc: &Document, sel // Highlight the selection's span. if let Some(b) = doc.bounds_of(sel) { let (a, bb) = (app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1)); - painter.rect_filled(Rect::from_min_max(pos2(a.x, r.min.y), pos2(bb.x, r.min.y + size)), CornerRadius::ZERO, ACCENT.gamma_multiply(0.15)); - painter.rect_filled(Rect::from_min_max(pos2(r.min.x, a.y), pos2(r.min.x + size, bb.y)), CornerRadius::ZERO, ACCENT.gamma_multiply(0.15)); + painter.rect_filled( + Rect::from_min_max(pos2(a.x, r.min.y), pos2(bb.x, r.min.y + size)), + CornerRadius::ZERO, + accent(painter).gamma_multiply(0.15), + ); + painter.rect_filled( + Rect::from_min_max(pos2(r.min.x, a.y), pos2(r.min.x + size, bb.y)), + CornerRadius::ZERO, + accent(painter).gamma_multiply(0.15), + ); } let mut x = (p0.x / step).floor() * step; while x <= p1.x { @@ -1528,3 +1548,8 @@ pub fn fmt(v: f64) -> String { #[cfg(test)] #[path = "zoom_preview_tests.rs"] mod zoom_preview_tests; + +/// The overlay green for the page this painter draws on ([`crate::theme::canvas_accent`]). +fn accent(p: &egui::Painter) -> Color32 { + crate::theme::canvas_accent(p.ctx()) +} diff --git a/crates/ui-egui/src/cjk.rs b/crates/ui-egui/src/cjk.rs index fd3ecf8cb..35323efe7 100644 --- a/crates/ui-egui/src/cjk.rs +++ b/crates/ui-egui/src/cjk.rs @@ -1,8 +1,9 @@ -//! Chinese, Japanese and Korean text in the UI. egui's own fonts and Inter have no CJK glyphs, so -//! the UI adds a face per script it needs: the UI language's, the scripts in the open file's names +//! Chinese, Japanese, Korean and Bengali text in the UI. egui's own fonts and Inter have no glyphs +//! for them, so the UI adds a face per script it needs: the UI language's, the scripts in the open file's names //! and comments, the language picker's and typed text. Desktop finds them among the installed //! fonts (the system font scan), only once one is needed, so English users load none; the web -//! build has only the CJK faces built in from craft-fonts (`FIGCRAFT_EMBED_CJK`), else none. +//! build has only the CJK faces built in from craft-fonts (`FIGCRAFT_EMBED_CJK`), else none (no +//! Bengali face is built in, so the web leaves Bengali out). use std::sync::Arc; @@ -12,6 +13,13 @@ pub const KANA: u8 = 1; pub const HANGUL: u8 = 2; /// Han characters (and CJK punctuation and full-width forms). pub const HAN: u8 = 4; +/// Bengali script. +pub const BENGALI: u8 = 8; +/// Every script bit: nothing more to look for once all have a face. +pub const ALL: u8 = KANA | HANGUL | HAN | BENGALI; + +/// Bengali faces: cross-platform, Windows, macOS, then Linux names. +const BENGALI_FONTS: &[&str] = &["Noto Sans Bengali", "Nirmala UI", "Bangla Sangam MN", "Kalpurush", "SolaimanLipi"]; /// Japanese faces, the built-in one first: Windows, macOS, then Linux names. const JAPANESE: &[&str] = &[ @@ -46,36 +54,47 @@ const CHINESE: &[&str] = &[ "Droid Sans Fallback", ]; -/// The CJK scripts in `s`. +/// The CJK and Bengali scripts in `s`. pub fn scripts(s: &str) -> u8 { s.chars().fold(0, |m, c| { m | match u32::from(c) { 0x3040..=0x30FF | 0x31F0..=0x31FF | 0xFF66..=0xFF9F => KANA, 0x1100..=0x11FF | 0x3130..=0x318F | 0xA960..=0xA97F | 0xAC00..=0xD7FF | 0xFFA0..=0xFFDC => HANGUL, 0x3000..=0x303F | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0xFF01..=0xFF65 | 0x20000..=0x3FFFF => HAN, + 0x0980..=0x09FF => BENGALI, _ => 0, } }) } -/// The CJK scripts a UI language (a catalog code) is written in. +/// The scripts a UI language (a catalog code) is written in. pub fn language(code: &str) -> u8 { match code.split('-').next() { Some("ja") => KANA | HAN, Some("ko") => HANGUL, Some("zh") => HAN, + Some("bn") => BENGALI, _ => 0, } } /// Whether the UI can draw language `code`: always on desktop (a system face is found when it's -/// needed; tofu at worst), on the web only with built-in faces for its scripts. The language -/// picker and the system language skip a language it can't. +/// needed; tofu at worst), on the web only with built-in faces for its scripts (Georgian: craft-fonts' +/// Noto Sans Georgian). The language picker and the system language skip a language it can't. pub fn can_show(code: &str) -> bool { + shows(code, cfg!(target_arch = "wasm32")) +} + +/// [`can_show`] in a desktop or (`web`) a web build. +fn shows(code: &str, web: bool) -> bool { + if !web { + return true; + } + if code.split('-').next() == Some("ka") { + return figcraft_text::face_with_char('ა', &["Noto Sans Georgian"]).is_some(); + } let need = language(code); - need == 0 - || !cfg!(target_arch = "wasm32") - || samples(need, 0).iter().all(|(c, _)| figcraft_text::face_with_char(*c, &["BIZ UDPGothic", "Noto Sans CJK SC"]).is_some()) + need == 0 || samples(need, 0).iter().all(|(c, _)| figcraft_text::face_with_char(*c, &["BIZ UDPGothic", "Noto Sans CJK SC"]).is_some()) } /// A character standing for each script in `need`, with the families to look for it in, the UI @@ -89,10 +108,16 @@ fn samples(need: u8, ui: u8) -> Vec<(char, Vec<&'static str>)> { (false, HANGUL) => ('漢', [KOREAN, JAPANESE, CHINESE].concat()), (false, _) => ('漢', [JAPANESE, KOREAN, CHINESE].concat()), }; - let mut v = vec![(KANA, ('あ', JAPANESE.to_vec())), (HAN, han), (HANGUL, ('한', KOREAN.to_vec()))]; + let mut v = vec![(KANA, ('あ', JAPANESE.to_vec())), (HAN, han), (HANGUL, ('한', KOREAN.to_vec())), (BENGALI, ('অ', BENGALI_FONTS.to_vec()))]; match ui { HANGUL => v.reverse(), HAN => v.swap(0, 1), + BENGALI => { + if let Some(pos) = v.iter().position(|(bit, _)| *bit == BENGALI) { + let item = v.remove(pos); + v.insert(0, item); + } + } _ => {} } v.into_iter().filter(|(bit, _)| need & bit != 0).map(|(_, s)| s).collect() @@ -124,20 +149,32 @@ mod tests { assert_eq!(scripts("ボタン"), KANA); assert_eq!(scripts("버튼"), HANGUL); assert_eq!(scripts("按钮"), HAN); + assert_eq!(scripts("বাংলা"), BENGALI); assert_eq!(scripts("日本語のレイヤー(1)"), KANA | HAN); assert_eq!(language("ja"), KANA | HAN); assert_eq!(language("ko"), HANGUL); assert_eq!(language("zh-Hant"), HAN); assert_eq!(language("zh-Hans"), HAN); + assert_eq!(language("bn"), BENGALI); assert_eq!(language("fr"), 0); assert!(["en", "es", "es-419", "fr", "de", "pt-BR", "tr", "id"].iter().all(|c| can_show(c))); } + #[test] + fn the_web_offers_georgian_only_with_its_face() { + assert!(shows("ka", false), "desktop always lists Georgian"); + let face = figcraft_text::face_with_char('ა', &["Noto Sans Georgian"]).is_some(); + assert_eq!(shows("ka", true), face); + assert_eq!(shows("ka-GE", true), face); + assert!(shows("en", true) && shows("tr", true)); + } + #[test] fn the_ui_language_script_comes_first() { let order = |need, ui| samples(need, ui).into_iter().map(|(c, _)| c).collect::(); let first_face = |need, ui| samples(need, ui).first().and_then(|(_, f)| f.first()).copied(); assert_eq!(order(KANA | HAN | HANGUL, 0), "あ漢한"); + assert_eq!(order(KANA | HAN | HANGUL | BENGALI, language("bn")), "অあ漢한"); assert_eq!(order(KANA | HAN | HANGUL, KANA | HAN), "あ漢한"); assert_eq!(order(KANA | HAN | HANGUL, HANGUL), "한漢あ"); assert_eq!(order(HANGUL, 0), "한"); @@ -150,6 +187,6 @@ mod tests { assert_eq!(order(KANA | HAN, language("zh-Hans")), "们あ"); assert!(["Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC"].iter().all(|f| samples(HAN, HAN)[0].1[..4].contains(f))); assert!(faces(0, "en", &|_| false).is_empty()); - assert!(faces(KANA | HANGUL | HAN, "ja", &|_| true).is_empty(), "nothing is looked up for scripts the UI draws"); + assert!(faces(KANA | HANGUL | HAN | BENGALI, "ja", &|_| true).is_empty(), "nothing is looked up for scripts the UI draws"); } } diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index 13fb16b9c..f6dadced6 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -121,8 +121,7 @@ fn current_paint(app: &FigApp, target: &PickerTarget) -> Option { /// Write `paint` to the target. `live` edits are part of an ongoing drag. fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) { if let PickerTarget::PluginField { plugin, control, .. } = target { - let c = paint.solid_color().unwrap_or(Color::BLACK); - let hex = c.with_alpha(c.a * paint.opacity).to_css_hex(); + let hex = paint.solid_color().unwrap_or(Color::BLACK).to_css_hex(); let (plugin, control) = (plugin.clone(), control.clone()); if !live { crate::plugins_ui::set_field(app, &plugin, &control, &hex); @@ -154,7 +153,7 @@ fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) { } PickerTarget::Variable { id, mode } => { let c = paint.solid_color().unwrap_or(Color::BLACK); - app.session.execute("variable.set", &json!({"variable": id, "mode": mode, "value": c.with_alpha(c.a * paint.opacity).to_css_hex()})) + app.session.execute("variable.set", &json!({"variable": id, "mode": mode, "value": c.to_css_hex()})) } PickerTarget::PluginField { .. } => return, PickerTarget::Style { id, index } => { @@ -1027,7 +1026,7 @@ fn saved_preset_row(ui: &mut Ui, name: &str) -> Option { let del_resp = ui.interact(del, ui.id().with(("cp_preset_del", name)), Sense::click()).tip(tr("Delete preset")); let hot = resp.hovered() || del_resp.hovered(); if hot { - ui.painter().rect_filled(r, CornerRadius::same(5), crate::theme::ACCENT); + ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); } let col = if hot { Color32::WHITE } else { t.text }; ui.painter().text(pos2(r.min.x + 8.0, r.center().y), Align2::LEFT_CENTER, name, regular(11.5), col); @@ -1194,6 +1193,22 @@ mod selection_color_tests { assert_eq!(app.session.doc().unwrap().node(a).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF0000"); } + #[test] + fn writing_a_variable_keeps_its_alpha() { + // The picker holds a colour variable's alpha as the paint opacity; writing it back applies it once. + let (mut app, _, _) = setup(); + app.session.execute("variable.createCollection", &json!({"name":"T"})).unwrap(); + let v = app.session.execute("variable.create", &json!({"collection":"T","name":"tint","type":"color","value":"#0D6EFD80"})).unwrap(); + let var = app.session.doc().unwrap().variables.get(v["id"].as_str().unwrap()).unwrap().clone(); + let target = PickerTarget::Variable { id: var.id.clone(), mode: var.values.keys().next().unwrap().clone() }; + for _ in 0..2 { + let paint = current_paint(&app, &target).unwrap(); + write(&mut app, &target, &paint, false); + } + let c = current_paint(&app, &target).unwrap().solid_color().unwrap(); + assert_eq!(c.to_css_hex().to_uppercase(), "#0D6EFD80"); + } + #[test] fn picker_does_not_follow_selection_or_document_switches() { let (mut app, _, _) = setup(); diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index 5918dfc03..d33c81ee0 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -189,10 +189,10 @@ pub fn cluster(points: &[Pos2], r: f32) -> Vec> { } /// A message's text with its @mentions of `people` picked out, wrapped at `width`. -pub fn rich_text(text: &str, people: &[&str], color: Color32, width: f32) -> LayoutJob { +pub fn rich_text(text: &str, people: &[&str], color: Color32, mention_color: Color32, width: f32) -> LayoutJob { let mut job = LayoutJob::default(); let plain = TextFormat { font_id: regular(12.0), color, ..Default::default() }; - let mention = TextFormat { font_id: semibold(12.0), color: ACCENT, ..Default::default() }; + let mention = TextFormat { font_id: semibold(12.0), color: mention_color, ..Default::default() }; let mut at = 0; for r in mention_ranges(text, people) { job.append(text.get(at..r.start).unwrap_or(""), 0.0, plain.clone()); @@ -400,7 +400,7 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { if let Some((x, y, _)) = &app.ui.comments.draft { let r = pin_rect(app.to_screen(*x, *y)); painter.rect_filled(r, BUBBLE, ACCENT); - painter.circle_filled(r.center(), 3.0, Color32::WHITE); + painter.circle_filled(r.center(), 3.0, crate::theme::ACCENT_ON); } } @@ -729,7 +729,7 @@ fn message( ui.add_space(indent); let mut names: Vec<&str> = cx.people.iter().map(String::as_str).collect(); names.push(cx.me); - ui.add(egui::Label::new(rich_text(&m.text, &names, t.text, ui.available_width())).wrap()); + ui.add(egui::Label::new(rich_text(&m.text, &names, t.text, t.accent_text, ui.available_width())).wrap()); }); } } @@ -1062,7 +1062,8 @@ pub fn panel(app: &mut FigApp, ui: &mut Ui) { ui.add_space(16.0); ui.label(egui::RichText::new(tr(app.ui.comments.filter.label())).font(regular(11.0)).color(t.text_secondary)); ui.add_space(4.0); - if ui.add(egui::Label::new(egui::RichText::new(tr("Show open")).font(regular(11.0)).color(ACCENT)).sense(Sense::click())).clicked() { + if ui.add(egui::Label::new(egui::RichText::new(tr("Show open")).font(regular(11.0)).color(t.accent_text)).sense(Sense::click())).clicked() + { app.ui.comments.filter = Filter::Open; } }); diff --git a/crates/ui-egui/src/control_roots_tests.rs b/crates/ui-egui/src/control_roots_tests.rs index cc0065061..35454bb5d 100644 --- a/crates/ui-egui/src/control_roots_tests.rs +++ b/crates/ui-egui/src/control_roots_tests.rs @@ -57,6 +57,24 @@ fn a_confined_app_writes_only_inside_its_write_root() { assert!(app.session.execute("file.export", &json!({"format": "svg", "path": p("outside/own.svg")})).is_ok()); } +#[test] +fn a_confined_app_saves_only_inside_its_write_root() { + // #836: saving over the control channel, both as a new file and to the current one. + let (mut app, d) = confined_app("save"); + let p = |rel: &str| d.0.join(rel).to_string_lossy().into_owned(); + for cmd in ["file.saveAs", "file.save"] { + let e = handle(&mut app, "engine.execute", json!({"command": cmd, "params": {"path": p("outside/a.figcraft")}})).unwrap_err(); + assert!(e.contains("automation path rejected"), "{cmd}: {e}"); + let e = handle(&mut app, "engine.execute", json!({"command": cmd, "params": {"path": p("out/../outside/b.figcraft")}})).unwrap_err(); + assert!(e.contains("automation path rejected"), "{cmd}: {e}"); + } + assert_eq!(std::fs::read_dir(d.0.join("outside")).unwrap().count(), 0, "nothing written outside the root"); + handle(&mut app, "engine.execute", json!({"command": "file.saveAs", "params": {"path": p("out/in.figcraft")}})).unwrap(); + assert!(d.0.join("out/in.figcraft").is_file()); + // Saving again goes to the same file, inside the root. + handle(&mut app, "engine.execute", json!({"command": "file.save", "params": {}})).unwrap(); +} + #[test] fn window_captures_are_written_through_the_roots_or_returned() { let (app, d) = confined_app("capture"); diff --git a/crates/ui-egui/src/dev_mode.rs b/crates/ui-egui/src/dev_mode.rs index d34560885..0b429820d 100644 --- a/crates/ui-egui/src/dev_mode.rs +++ b/crates/ui-egui/src/dev_mode.rs @@ -628,7 +628,7 @@ pub fn dev_resources_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: } icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + PAD + 7.0, r.center().y), vec2(14.0, 14.0)), Icon::Link, t.icon_secondary); let x = r.min.x + PAD + 22.0; - ui.painter().text(pos2(x, r.center().y - 7.0), Align2::LEFT_CENTER, widgets::elide(ui, name, text_w), semibold(11.0), ACCENT); + ui.painter().text(pos2(x, r.center().y - 7.0), Align2::LEFT_CENTER, widgets::elide(ui, name, text_w), semibold(11.0), t.accent_text); let below = match item.from.and_then(|f| doc.node(f)) { Some(m) => trf("From {name}", &[("name", &m.name)]), None => url.clone(), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 448501abf..bf9f1b299 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3,7 +3,7 @@ use std::path::Path; -use egui::{Align2, Color32, Context, CornerRadius, RichText, Sense, Stroke, vec2}; +use egui::{Align2, Context, CornerRadius, RichText, Sense, Stroke, vec2}; use figcraft_color::ColorProfile; use figcraft_doc::{ExportFormat, ExportSetting, ExportSize, NodeId}; use figcraft_engine::cmd::file::{export_jobs, exportable_layers}; @@ -12,8 +12,7 @@ use serde_json::{Value, json}; use crate::FigApp; use crate::export_panel::{export_layers, save_one, suggested_name}; use crate::i18n::{self, tr, tr_noop, trf, trn}; -use crate::icons::{self, Icon}; -use crate::theme::{ACCENT, Tokens, regular, semibold}; +use crate::theme::{Tokens, regular, semibold}; pub const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -122,10 +121,10 @@ pub(crate) fn tab_strip(ui: &mut egui::Ui, t: &Tokens, tabs fn about_body(ui: &mut egui::Ui, t: &Tokens) { ui.horizontal(|ui| { let (r, _) = ui.allocate_exact_size(vec2(48.0, 48.0), Sense::hover()); - ui.painter().rect_filled(r, CornerRadius::same(10), ACCENT); - icons::paint(ui.painter(), r.shrink(10.0), Icon::Logo, Color32::WHITE); + crate::theme::paint_app_icon(ui, r); ui.vertical(|ui| { - ui.label(RichText::new("FigCraft").font(semibold(18.0))); + let (wordmark, _) = ui.allocate_exact_size(vec2(130.0, 23.0), Sense::hover()); + crate::theme::paint_wordmark(ui.painter(), egui::pos2(wordmark.min.x, wordmark.center().y), 18.0, t); ui.label(RichText::new(trf("Version {version}", &[("version", &VERSION)])).color(t.text_secondary)); }); }); @@ -143,8 +142,12 @@ fn about_body(ui: &mut egui::Ui, t: &Tokens) { } /// Escape closes the topmost simple dialog (one without menus of its own), before the canvas's -/// shortcuts see it (it doesn't deselect), like a system dialog: from one of its fields too. +/// shortcuts see it (it doesn't deselect), like a system dialog: from one of its fields too. A menu +/// open over the dialog (Preferences ▸ Interface size) takes the first Escape and closes alone. pub fn keys(app: &mut FigApp, ctx: &Context) { + if egui::Popup::is_any_open(ctx) { + return; + } let u = &mut app.ui; let open = u.color_profile.is_some() || u.menu_input.is_some() @@ -223,6 +226,9 @@ pub fn show(app: &mut FigApp, ctx: &Context) { if !cfg!(target_arch = "wasm32") && crate::widgets::checkbox(ui, prefs.auto_save, tr("Save files automatically")) { toggles.push("pref.autoSave"); } + if !cfg!(target_arch = "wasm32") && crate::widgets::checkbox(ui, prefs.start_on_home, tr("Start on the Home screen")) { + toggles.push("pref.startOnHome"); + } for c in toggles { app.run(c, json!({})); } @@ -273,6 +279,8 @@ pub fn show(app: &mut FigApp, ctx: &Context) { } }); ui.add_space(8.0); + interface_size(app, ui, prefs.ui_scale); + ui.add_space(8.0); ui.horizontal(|ui| { ui.label(tr("Theme")); for (l, c) in [(tr_noop("Light"), "view.themeLight"), (tr_noop("Dark"), "view.themeDark"), (tr_noop("System"), "view.themeSystem")] { @@ -473,6 +481,46 @@ pub(crate) fn batch_rename_params(b: &BatchRenameUi) -> serde_json::Value { p } +/// Preferences ▸ Interface size: a size relative to the system's display scale (100 % is the +/// system's own), as a drop-down that takes effect at once. The browser has its own zoom, so there +/// the row only says so. +fn interface_size(app: &mut FigApp, ui: &mut egui::Ui, chosen: f64) { + let t = Tokens::get(ui.ctx()); + if cfg!(target_arch = "wasm32") { + ui.label(tr("Interface size")); + crate::widgets::label(ui, tr("Use your browser's zoom to change the interface size."), t.text_secondary); + return; + } + let percent = |size: f64| trf("{percent}%", &[("percent", &crate::ui_scale::percent(size as f32))]); + let shown = percent(chosen); + ui.horizontal(|ui| { + ui.label(tr("Interface size")); + let resp = crate::widgets::dropdown(ui, &shown, 120.0, None); + crate::a11y::combo(&resp, tr("Interface size"), &shown, crate::a11y::menu_open(&resp)); + crate::right::menu_popup(&resp).show(|ui| { + for size in crate::ui_scale::SIZES { + if crate::right::menu_check(ui, (chosen - size).abs() < 1e-6, &percent(size), "") { + app.run("pref.uiScale", json!({"scale": size})); + ui.close(); + } + } + }); + }); + crate::widgets::label( + ui, + tr("Relative to your system's display scaling (100% is the system's own size). Document zoom is separate."), + t.text_secondary, + ); + // Said in words, not only by the menu's value: the window is too small for the size chosen. + if app.ui.ui_scale.limited { + let note = trf( + "The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%.", + &[("size", &crate::ui_scale::percent(chosen as f32)), ("drawn", &crate::ui_scale::percent(app.ui.ui_scale.zoom))], + ); + crate::widgets::label(ui, ¬e, t.text_secondary); + } +} + /// Rename layers: Match, Rename to with buttons for the current name and numbers, where numbers /// start, a preview of the first new names, then one `object.batchRename`. fn batch_rename_dialog(app: &mut FigApp, ctx: &Context) { diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index 9ddc48e52..fea0be8ea 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -549,3 +549,88 @@ fn a_label_too_long_for_its_field_ends_in_an_ellipsis_before_the_value() { assert!(field.contains_rect(*l) && field.contains_rect(*v), "both inside the field {field:?}: {l:?} {v:?}"); assert!(l.max.x < v.min.x, "the label stops before the value: {l:?} {v:?}"); } + +/// The middle of the field with response `id`. +fn at(u: &Ui, id: Id) -> Pos2 { + u.ctx.read_response(id.with("field")).unwrap_or_else(|| panic!("no field {id:?}")).rect.center() +} + +/// Type `text` into the field whose response is `id` and press Enter. +fn type_at(u: &mut Ui, id: Id, text: &str) { + let p = at(u, id); + click(u, p); + u.frame(vec![Event::Text(text.into())]); + key(u, Key::Enter, Modifiers::NONE); + u.frame(vec![]); +} + +/// A frame with one layout grid, its settings open in the Design panel. +fn grid_ui(grid: serde_json::Value) -> (Ui, NodeId) { + let mut s = Session::new(); + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 400, "height": 300})).unwrap()); + s.execute("node.set", &json!({"ids": [f], "props": {"grids": [grid]}})).unwrap(); + s.execute("select.set", &json!({"ids": [f]})).unwrap(); + let mut u = Ui::new(s); + u.ctx.data_mut(|d| d.insert_temp(Id::new(("grid_open", 0usize)), true)); + u.frame(vec![]); + (u, f) +} + +/// Scroll the Design panel (over its own field `id`) until that field is on the test screen: the +/// grid settings sit below the fold. +fn scroll_to(u: &mut Ui, id: Id) { + let wheel = Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -300.0), + phase: egui::TouchPhase::Move, + modifiers: Modifiers::NONE, + }; + for _ in 0..10 { + let p = at(u, id); + if p.y < 860.0 { + break; + } + u.frame(vec![Event::PointerMoved(pos2(p.x, 500.0)), wheel.clone()]); + // Let the scroll settle. + for _ in 0..30 { + u.frame(vec![]); + } + } +} + +/// The word for Auto types 0 only where 0 *is* the automatic value: a fixed layout grid's count +/// does, an auto layout's gap (Auto there is "Space between") does not. Keying it on the field's +/// placeholder said 0 everywhere, which turned the gap's mode off. +#[test] +fn typing_auto_is_zero_only_where_zero_is_the_automatic_value() { + let count_id = Id::new(("grid_field", "count", 0usize)); + let (mut u, f) = grid_ui(json!({"kind": "columns", "align": "min", "count": 3, "size": 8, "gutter": 10})); + let count = |u: &Ui| u.app.session.doc().unwrap().node(f).unwrap().grids[0].count; + scroll_to(&mut u, count_id); + type_at(&mut u, count_id, "Auto"); + assert_eq!(count(&u), 0, "a fixed grid's count: Auto is 0"); + type_at(&mut u, count_id, "4"); + assert_eq!(count(&u), 4, "and a number is that count"); + // A stretched grid works its width out from the frame: the field shows Auto and takes no edit. + let (mut u, f) = grid_ui(json!({"kind": "columns", "align": "stretch", "count": 3, "size": 0})); + let size_id = Id::new(("grid_field", "size", 0usize)); + scroll_to(&mut u, size_id); + type_at(&mut u, size_id, "40"); + assert_eq!(u.app.session.doc().unwrap().node(f).unwrap().grids[0].size, 0.0, "a stretched grid's width isn't set"); + // Nor its count: a stretched grid has at least one band, so the word commits nothing. + type_at(&mut u, count_id, "Auto"); + assert_eq!(u.app.session.doc().unwrap().node(f).unwrap().grids[0].count, 3, "a stretched grid keeps its count"); + + // The auto layout's gap: its placeholder says Auto, but Auto is a mode, not 0. + let mut s = Session::new(); + let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 400, "height": 300})).unwrap()); + s.execute("layout.add", &json!({"ids": [f], "direction": "vertical"})).unwrap(); + s.execute("node.set", &json!({"ids": [f], "props": {"mainAlign": "spaceBetween"}})).unwrap(); + s.execute("select.set", &json!({"ids": [f]})).unwrap(); + let mut u = Ui::new(s); + u.frame(vec![]); + let gap = |u: &Ui| u.app.session.doc().unwrap().node(f).unwrap().layout.as_ref().unwrap().gap; + let before = gap(&u); + type_at(&mut u, Id::new("al_gap"), "Auto"); + assert_eq!(gap(&u), before, "the word isn't a value where Auto is a mode"); +} diff --git a/crates/ui-egui/src/frame_labels.rs b/crates/ui-egui/src/frame_labels.rs index 79557141e..9e8b2e7ab 100644 --- a/crates/ui-egui/src/frame_labels.rs +++ b/crates/ui-egui/src/frame_labels.rs @@ -103,7 +103,7 @@ pub fn draw(app: &FigApp, painter: &Painter, t: &Tokens, doc: &Document, id: Nod let col = if is_comp { COMPONENT } else if selected || hovered { - ACCENT + crate::theme::canvas_accent(painter.ctx()) } else { t.text_secondary }; diff --git a/crates/ui-egui/src/handles.rs b/crates/ui-egui/src/handles.rs index ca38bee71..ec489bf82 100644 --- a/crates/ui-egui/src/handles.rs +++ b/crates/ui-egui/src/handles.rs @@ -11,7 +11,7 @@ use figcraft_geom::Point; use serde_json::json; use crate::colorpicker::PickerTarget; -use crate::theme::{ACCENT, semibold}; +use crate::theme::semibold; use crate::{FigApp, RightTab}; pub(crate) const PINK: Color32 = Color32::from_rgb(0xF2, 0x48, 0x8C); @@ -293,7 +293,12 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { painter.rect_filled(r, CornerRadius::same(2), crate::theme::doc_c32(painter.ctx(), s.color.with_alpha(1.0))); painter.rect_stroke(r, CornerRadius::same(2), Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside); if i == selected { - painter.rect_stroke(r.expand(2.0), CornerRadius::same(3), Stroke::new(1.5, ACCENT), StrokeKind::Outside); + painter.rect_stroke( + r.expand(2.0), + CornerRadius::same(3), + Stroke::new(1.5, crate::theme::canvas_accent(painter.ctx())), + StrokeKind::Outside, + ); } } for (i, p) in [a, b, c].iter().enumerate() { @@ -301,7 +306,7 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { continue; } painter.circle_filled(*p, 5.0, Color32::WHITE); - painter.circle_stroke(*p, 5.0, Stroke::new(1.5, ACCENT)); + painter.circle_stroke(*p, 5.0, Stroke::new(1.5, crate::theme::canvas_accent(painter.ctx()))); } return; } @@ -311,8 +316,8 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { painter.line_segment([a, b], Stroke::new(1.0, Color32::from_black_alpha(120))); // The start is hollow (least blur), the end filled (most blur by default). painter.circle_filled(a, 5.0, Color32::WHITE); - painter.circle_stroke(a, 5.0, Stroke::new(1.5, ACCENT)); - painter.circle_filled(b, 5.0, ACCENT); + painter.circle_stroke(a, 5.0, Stroke::new(1.5, crate::theme::canvas_accent(painter.ctx()))); + painter.circle_filled(b, 5.0, crate::theme::canvas_accent(painter.ctx())); painter.circle_stroke(b, 5.0, Stroke::new(1.5, Color32::WHITE)); return; } @@ -320,11 +325,11 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { let Some(c) = crop_corners(app, id, r) else { return }; let outline = vec![c[0], c[1], c[2], c[3], c[0]]; painter.add(Shape::line(outline.clone(), Stroke::new(2.0, Color32::WHITE))); - painter.add(Shape::line(outline, Stroke::new(1.0, ACCENT))); + painter.add(Shape::line(outline, Stroke::new(1.0, crate::theme::canvas_accent(painter.ctx())))); for p in c { let hr = Rect::from_center_size(p, vec2(8.0, 8.0)); painter.rect_filled(hr, CornerRadius::ZERO, Color32::WHITE); - painter.rect_stroke(hr, CornerRadius::ZERO, Stroke::new(1.0, ACCENT), StrokeKind::Inside); + painter.rect_stroke(hr, CornerRadius::ZERO, Stroke::new(1.0, crate::theme::canvas_accent(painter.ctx())), StrokeKind::Inside); } return; } diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 236e27861..c174242a3 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -187,24 +187,20 @@ fn dropped(app: &mut FigApp, ctx: &egui::Context) { } // `opens` saw an extension, so there is a file name to tell the format by. let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); - let params = if path.exists() { - json!({"path": path.to_string_lossy()}) - } else if let Some(bytes) = crate::dropped_bytes(&f) { - use base64::Engine as _; - let data = base64::engine::general_purpose::STANDARD.encode(&bytes); - json!({"data": data, "name": name}) + let (params, read) = if path.exists() { + (json!({"path": path.to_string_lossy()}), None) } else { - continue; + (json!({"name": name}), Some(crate::dropped_bytes(&f, &name))) }; - app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.open", target: None }); + app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.open", target: None, read }); } } fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) { let mut y = left.min.y + 20.0; - icons::paint(ui.painter(), Rect::from_min_size(pos2(left.min.x + 16.0, y), vec2(20.0, 20.0)), Icon::Logo, ACCENT); - ui.painter().text(pos2(left.min.x + 44.0, y + 10.0), Align2::LEFT_CENTER, "FigCraft", semibold(14.0), t.text); - y += 48.0; + crate::theme::paint_app_icon(ui, Rect::from_min_size(pos2(left.min.x + 12.0, y - 4.0), vec2(32.0, 32.0))); + crate::theme::paint_wordmark(ui.painter(), pos2(left.min.x + 52.0, y + 12.0), 16.0, t); + y += 52.0; let search = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(SIDEBAR - 16.0, widgets::FIELD_H)); ui.scope_builder(egui::UiBuilder::new().max_rect(search), |ui| { let response = @@ -324,8 +320,8 @@ fn action_card(ui: &mut Ui, t: &Tokens, icon: Icon, title: &str, sub: &str) -> b } card_frame(ui, t, r, &resp); let ir = Rect::from_min_size(pos2(r.min.x + 16.0, r.center().y - 16.0), vec2(32.0, 32.0)); - ui.painter().rect_filled(ir, CornerRadius::same(6), ACCENT); - icons::paint(ui.painter(), ir.shrink(8.0), icon, Color32::WHITE); + ui.painter().rect_filled(ir, CornerRadius::same(6), t.accent); + icons::paint(ui.painter(), ir.shrink(8.0), icon, t.accent_on); ui.painter().text(pos2(r.min.x + 60.0, r.center().y - 8.0), Align2::LEFT_CENTER, title_shown, semibold(12.0), t.text); ui.painter().text(pos2(r.min.x + 60.0, r.center().y + 9.0), Align2::LEFT_CENTER, sub_shown, regular(11.0), t.text_secondary); resp.clicked() diff --git a/crates/ui-egui/src/home_recents.rs b/crates/ui-egui/src/home_recents.rs index aeeb0f276..81d682406 100644 --- a/crates/ui-egui/src/home_recents.rs +++ b/crates/ui-egui/src/home_recents.rs @@ -738,8 +738,8 @@ fn file_icon(ui: &Ui, t: &Tokens, center: egui::Pos2, path: &str) { let r = Rect::from_center_size(center, vec2(16.0, 16.0)); match FileKind::of(Path::new(path)) { FileKind::FigCraft | FileKind::Fig => { - ui.painter().rect_filled(r, CornerRadius::same(4), ACCENT); - icons::paint(ui.painter(), r.shrink(3.0), Icon::Logo, Color32::WHITE); + ui.painter().rect_filled(r, CornerRadius::same(4), t.accent); + icons::paint(ui.painter(), r.shrink(3.0), Icon::Logo, t.accent_on); } kind => { ui.painter().rect_filled(r, CornerRadius::same(4), t.field); diff --git a/crates/ui-egui/src/home_recents_tests.rs b/crates/ui-egui/src/home_recents_tests.rs index 1c3235ddb..e49866ab4 100644 --- a/crates/ui-egui/src/home_recents_tests.rs +++ b/crates/ui-egui/src/home_recents_tests.rs @@ -256,9 +256,11 @@ fn rects_of(s: &[Shape], c: egui::Color32) -> usize { s.iter().filter(|s| matches!(s, Shape::Rect(r) if r.fill == c)).count() } -/// Images drawn (meshes with a texture of their own). +/// Preview images in the main content area, excluding the branded sidebar icon. fn images(s: &[Shape]) -> usize { - s.iter().filter(|s| matches!(s, Shape::Mesh(m) if m.texture_id != egui::TextureId::default())).count() + s.iter() + .filter(|s| matches!(s, Shape::Mesh(m) if m.texture_id != egui::TextureId::default() && m.vertices.iter().any(|v| v.pos.x > 240.0))) + .count() } #[test] diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs index c493a0a1d..c74c35bff 100644 --- a/crates/ui-egui/src/home_tests.rs +++ b/crates/ui-egui/src/home_tests.rs @@ -573,6 +573,71 @@ fn dropping_a_file_without_one_on_disk_opens_it_as_a_new_unsaved_document() { assert!(!path.exists(), "nothing is written"); } +/// What a drop's read gives, once it has arrived. +type Arrival = std::rc::Rc, String>>>>; + +/// A drop whose contents arrive later, as every drop in the browser does: pending until `arrived` +/// holds them. +fn later(arrived: &Arrival) -> crate::ReadFuture { + let arrived = arrived.clone(); + Box::pin(std::future::poll_fn(move |_| match arrived.borrow_mut().take() { + Some(bytes) => std::task::Poll::Ready(bytes), + None => std::task::Poll::Pending, + })) +} + +#[test] +fn a_dropped_file_whose_contents_arrive_later_opens_once_they_do() { + let svg = br#""#.to_vec(); + let arrived = std::rc::Rc::new(std::cell::RefCell::new(None)); + let mut u = first_run(); + u.app.ui.home = true; + u.app.ui.pending_opens.push(crate::pending_open::PendingOpen { + label: "Browser drop.svg".into(), + params: json!({"name": "Browser drop.svg"}), + command: "file.open", + target: None, + read: Some(later(&arrived)), + }); + // egui lays a new card out invisibly on its first frame. + u.frame(vec![]); + for _ in 0..5 { + let s = flat(&u.frame_output(vec![]).shapes); + assert!(has_text(&s, "Browser drop.svg"), "the card shows while the contents are read"); + } + assert!(u.app.session.docs.is_empty(), "nothing opens before the contents arrive"); + *arrived.borrow_mut() = Some(Ok(svg)); + for _ in 0..3 { + u.frame(vec![]); + } + assert!(u.app.ui.pending_opens.is_empty()); + assert_eq!(u.app.ui.last_error, None); + assert_eq!(u.app.session.docs.len(), 1, "the contents open"); + assert_eq!(u.app.session.docs[0].path, None); + assert!(u.app.session.docs[0].doc.nodes().any(|n| matches!(n.kind, figcraft_doc::NodeKind::Rectangle)), "the SVG's content"); + assert!(!u.app.ui.home); +} + +#[test] +fn a_dropped_file_that_cannot_be_read_says_so_and_opens_nothing() { + let arrived = std::rc::Rc::new(std::cell::RefCell::new(Some(Err(crate::unreadable("Unreadable.figcraft"))))); + let mut u = first_run(); + u.app.ui.home = true; + u.app.ui.pending_opens.push(crate::pending_open::PendingOpen { + label: "Unreadable.figcraft".into(), + params: json!({"name": "Unreadable.figcraft"}), + command: "file.open", + target: None, + read: Some(later(&arrived)), + }); + for _ in 0..3 { + u.frame(vec![]); + } + assert!(u.app.ui.pending_opens.is_empty()); + assert!(u.app.session.docs.is_empty()); + assert_eq!(u.app.ui.last_error.as_deref(), Some("Couldn't read Unreadable.figcraft.")); +} + #[test] fn dropping_a_native_file_on_the_home_screen_opens_it() { let dir = Temp::new(); @@ -634,6 +699,33 @@ fn dropping_a_native_file_on_canvas_keeps_its_receiving_document_after_tab_switc assert_eq!(u.app.session.docs.iter().map(|doc| doc.doc.pages.len()).collect::>(), [2, 1]); } +#[test] +fn dropping_several_files_on_canvas_places_them_side_by_side() { + let dir = Temp::new(); + let svg = r##""##; + let paths: Vec<_> = ["a.svg", "b.svg", "c.svg"].iter().map(|n| dir.path(n)).collect(); + for path in &paths { + std::fs::write(path, svg).unwrap(); + } + let mut u = Ui::new(Session::new()); + u.app.ui.home = false; + u.frame(vec![]); + let pos = u.app.canvas.rect.center(); + let files: Vec = paths.iter().map(|p| std::sync::Arc::new(Dropped(p.clone())) as egui::DroppedFileHandle).collect(); + u.frame_raw(egui::RawInput { events: vec![Event::PointerMoved(pos)], dropped_files: files, ..Default::default() }); + for _ in 0..4 { + u.frame(vec![]); + } + assert!(u.app.ui.pending_opens.is_empty()); + let doc = u.app.session.doc().unwrap(); + let page = u.app.session.page().unwrap(); + let rects: Vec<_> = doc.node(page).unwrap().children.iter().map(|c| doc.world_bounds(*c).unwrap()).collect(); + assert_eq!(rects.len(), 3); + for pair in rects.windows(2) { + assert!(pair[1].x0 > pair[0].x1, "left to right with a gap: {rects:?}"); + } +} + #[test] fn canvas_import_uses_queued_page_and_restores_the_page_selected_while_waiting() { let dir = Temp::new(); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 22ea1a728..9faff0016 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -27,7 +27,8 @@ 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`). + /// A draft (`[meta] status` set): waiting for a native speaker's review, or reviewed and waiting for + /// a maintainer's approval. pub draft: bool, /// Index into [`LANGS`] of the catalog a regional one overlays ("es" under "es-419": it lists /// only what differs), else 0 (English). @@ -171,9 +172,10 @@ fn plural(code: &str, n: usize) -> Plural { "cs" if n == 1 => Plural::One, "cs" if (2..=4).contains(&n) => Plural::Few, "cs" => Plural::Other, + "pl" if n == 1 => Plural::One, "ru" if n % 10 == 1 && n % 100 != 11 => Plural::One, - "ru" if (2..=4).contains(&(n % 10)) && !(12..=14).contains(&(n % 100)) => Plural::Few, - "ru" => Plural::Many, + "ru" | "pl" if (2..=4).contains(&(n % 10)) && !(12..=14).contains(&(n % 100)) => Plural::Few, + "ru" | "pl" => Plural::Many, "ja" | "ko" | "zh-Hans" => Plural::Other, "fr" | "pt-BR" if n <= 1 => Plural::One, _ if n == 1 => Plural::One, @@ -182,7 +184,7 @@ fn plural(code: &str, n: usize) -> Plural { } // CLDR's million-count "many" in French, Spanish and Portuguese still reads their existing -// "other" wording. Czech supplies separate few forms; Russian supplies few/many forms. +// "other" wording. Czech supplies separate few forms; Russian and Polish supply few/many forms. /// Whether count `n` takes the "one" form: 0 and 1 in French and Brazilian Portuguese, never in /// Japanese, Korean and Chinese (no plural forms: always "other"), 1 elsewhere (CLDR's integer rules). diff --git a/crates/ui-egui/src/i18n_tests.rs b/crates/ui-egui/src/i18n_tests.rs index 5bdf2094a..046212c16 100644 --- a/crates/ui-egui/src/i18n_tests.rs +++ b/crates/ui-egui/src/i18n_tests.rs @@ -135,6 +135,7 @@ fn dynamic_labels() -> Vec<&'static str> { 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.push(figcraft_engine::live::TOO_LARGE_TO_JOIN); // The starter templates' names, descriptions and kinds. v.extend(figcraft_engine::templates::TEMPLATES.iter().flat_map(|t| [t.name, t.description, t.kind.label()])); v @@ -327,6 +328,14 @@ fn lookups_fall_back_to_english_and_fill_placeholders() { assert!(matches!(lookup(es, k), Some(x) if std::ptr::eq(x, t))); } +#[test] +fn a_live_failure_the_ui_knows_is_read_back_for_translation() { + use figcraft_engine::live::TOO_LARGE_TO_JOIN; + let reason = TOO_LARGE_TO_JOIN.replace("{mb}", "1024"); + assert!(matches!(crate::live::failure_text(&reason), std::borrow::Cow::Owned(t) if t == reason)); + assert!(matches!(crate::live::failure_text("Couldn't reach the server"), std::borrow::Cow::Borrowed(_))); +} + #[test] fn system_languages_pick_the_first_one_with_a_catalog() { let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code; @@ -343,11 +352,12 @@ fn system_languages_pick_the_first_one_with_a_catalog() { // Portugal's Portuguese and a bare "pt" read the Brazilian catalog. assert_eq!(code("system", Some("pt-PT")), "pt-BR"); assert_eq!(code("pt", None), "pt-BR"); - // An OS list: the first language there is a catalog for (English counts). - assert_eq!(code("system", Some("nl-NL,de-DE,en-US")), "de"); - assert_eq!(code("system", Some("nl-NL, en-US,fr-FR")), "en"); - assert_eq!(code("system", Some("nl,da")), "en"); - assert_eq!(code("system", Some("nl,sv")), "sv"); + // An OS list: the first language there is a catalog for (English counts). Hindi has no + // catalog (Dutch gained one; `hi` keeps exercising the no-catalog path). + assert_eq!(code("system", Some("hi-IN,de-DE,en-US")), "de"); + assert_eq!(code("system", Some("hi-IN, en-US,fr-FR")), "en"); + assert_eq!(code("system", Some("hi,da")), "en"); + assert_eq!(code("system", Some("hi,sv")), "sv"); assert_eq!(code("fr", Some("de-DE")), "fr", "a chosen language beats the system's"); } @@ -399,7 +409,7 @@ fn french_and_portuguese_count_zero_as_singular() { for code in ["fr", "pt-BR"] { assert!(one(code, 0) && one(code, 1) && !one(code, 2), "{code}"); } - for code in ["en", "es", "de", "cs"] { + for code in ["en", "es", "de", "cs", "pl"] { for n in [0, 1, 2, 3, 4, 5, 21] { assert_eq!(one(code, n), n == 1, "{code}: {n}"); } @@ -420,6 +430,41 @@ fn japanese_korean_and_chinese_have_no_plural_forms() { } } +#[test] +fn georgian_is_a_draft_with_count_neutral_plurals() { + let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code; + for tag in ["ka", "ka-GE", "ka_GE.UTF-8"] { + assert_eq!(code("system", Some(tag)), "ka", "{tag}"); + } + let ka = LANGS.iter().position(|l| l.code == "ka").unwrap(); + assert!(LANGS[ka].draft && LANGS[ka].name == "ქართული"); + // A noun stays singular after a numeral: both forms read the same where English counts in both. + for (k, t) in LANGS[ka].ui { + if let (Text::Plural { one: Some(one), other, .. }, Some(Text::Plural { one: Some(en_one), .. })) = (t, find(english().ui, k)) { + assert!(!en_one.contains("{n}") || one == other, "ka: {k:?}"); + } + } +} + +/// Release builds embed craft-fonts' Noto Sans Georgian (`theme::install_fonts`' built-in faces), +/// since Inter and egui's fonts have no Georgian: it draws every Georgian letter the catalog uses. +#[test] +fn the_built_in_georgian_face_draws_the_georgian_catalog() { + let Some(face) = figcraft_text::find_face("Noto Sans Georgian", "Regular").filter(|f| f.family == "Noto Sans Georgian") else { + eprintln!("built without craft-fonts' Noto Sans Georgian; skipping"); + return; + }; + let ka = &LANGS[LANGS.iter().position(|l| l.code == "ka").unwrap()]; + let georgian = |c: &char| matches!(u32::from(*c), 0x10A0..=0x10FF | 0x1C90..=0x1CBF | 0x2D00..=0x2D2F); + let mut letters: BTreeSet = BTreeSet::new(); + for (_, t) in ka.ui.iter().chain(ka.commands).chain(ka.menu) { + letters.extend(forms(t).into_iter().flat_map(str::chars).filter(georgian)); + } + assert!(letters.len() > 30, "{letters:?}"); + let missing: String = letters.iter().filter(|c| !face.has_char(**c)).collect(); + assert!(missing.is_empty(), "no glyph for {missing:?}"); +} + #[test] fn chinese_reads_simplified_and_traditional_has_no_catalog() { let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code; @@ -472,7 +517,7 @@ fn russian_language_tags_and_integer_plurals() { assert_eq!(resolve(tag, None), ru, "{tag}"); assert_eq!(resolve("system", Some(tag)), ru, "{tag}"); } - assert_eq!(resolve("system", Some("nl,ru-RU,en-US")), ru); + assert_eq!(resolve("system", Some("hi,ru-RU,en-US")), ru); for n in [1, 21, 31, 101, 1_001] { assert_eq!(plural("ru", n), Plural::One, "{n}"); assert_eq!(fill(counted(ru, "{n} layers", n), &[("n", &n)]), format!("{n} слой")); @@ -492,6 +537,27 @@ fn russian_language_tags_and_integer_plurals() { } } +#[test] +fn polish_integer_plurals() { + let pl = LANGS.iter().position(|l| l.code == "pl").unwrap(); + assert_eq!(LANGS[pl].name, "Polski"); + assert_eq!(plural("pl", 1), Plural::One); + assert_eq!(fill(counted(pl, "{n} layers", 1), &[("n", &1)]), "1 warstwa"); + for n in [2, 3, 4, 22, 23, 24, 102, 104, 1_002] { + assert_eq!(plural("pl", n), Plural::Few, "{n}"); + assert_eq!(fill(counted(pl, "{n} layers", n), &[("n", &n)]), format!("{n} warstwy")); + } + for n in [0, 5, 10, 11, 12, 13, 14, 19, 20, 21, 25, 31, 100, 101, 111, 112, 114, 1_000_000] { + assert_eq!(plural("pl", n), Plural::Many, "{n}"); + assert_eq!(fill(counted(pl, "{n} layers", n), &[("n", &n)]), format!("{n} warstw")); + } + for (key, text) in LANGS[pl].ui { + if let Text::Plural { one, few, many, .. } = text { + assert!(one.is_some() && few.is_some() && many.is_some(), "{key}: missing Polish plural forms"); + } + } +} + #[test] fn plural_fallback_uses_the_language_of_its_catalog() { let la = LANGS.iter().position(|l| l.code == "es-419").unwrap(); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index fa4fb66ea..5eb7d2092 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -85,6 +85,8 @@ icons! { CornerRadius, Angle, IndependentCorners, + /// Individual padding: a frame with one inward tick per side, each set alone. + IndependentPadding, LinkOn, LinkOff, LayoutH, @@ -139,6 +141,11 @@ icons! { Fixed, PaddingH, PaddingV, + /// Individual padding sides: a frame with the controlled side filled. + PaddingTop, + PaddingRight, + PaddingBottom, + PaddingLeft, Gap, Reset, Detach, @@ -421,6 +428,7 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { draw(&p, icon); } +/// Draw `icon` on the 16-unit grid through `p`, which maps grid units to the target box. fn draw(p: &P, icon: Icon) { use Icon::*; match icon { @@ -446,18 +454,23 @@ fn draw(p: &P, icon: Icon) { p.path(&h, false); } Frame => { - p.line(5.5, 2.0, 5.5, 14.0); - p.line(10.5, 2.0, 10.5, 14.0); - p.line(2.0, 5.5, 14.0, 5.5); - p.line(2.0, 10.5, 14.0, 10.5); + // Inset a unit from the box: four long hinted strokes read heavier than the + // anti-aliased outlines beside them at full size (#737). + p.line(5.5, 3.0, 5.5, 13.0); + p.line(10.5, 3.0, 10.5, 13.0); + p.line(3.0, 5.5, 13.0, 5.5); + p.line(3.0, 10.5, 13.0, 10.5); } Section | Section2 => { p.rect(2.5, 5.5, 13.5, 13.5, 1.0); p.fill_rect(2.0, 2.0, 9.0, 6.0, 1.0); } Slice => { - p.rect(2.5, 2.5, 13.5, 13.5, 0.0); - p.line(2.5, 13.5, 13.5, 2.5); + // A knife, one silhouette (#739): a slim blade to the top right, outlined like the + // rest of the row, and a solid handle whose end overlaps the blade's base so the two + // join at every size. + p.poly(&[(13.5, 2.5), (5.0, 8.0), (8.0, 11.0)], true); + p.fill(&capsule((2.8, 13.2), (5.9, 10.1), 1.3)); } Bend => { // A curve with a smooth point and its handle line. @@ -523,11 +536,12 @@ fn draw(p: &P, icon: Icon) { p.line(9.0, 4.5, 11.5, 7.0); } Text => { - p.line(2.0, 3.5, 13.0, 3.5); - p.line(7.5, 3.0, 7.5, 13.0); - p.line(2.5, 3.0, 2.5, 6.0); - p.line(12.5, 3.0, 12.5, 6.0); - p.line(5.0, 12.5, 10.0, 12.5); + // Inset like the Frame mark, so its long strokes don't outweigh the outlines (#737). + p.line(3.0, 3.5, 12.0, 3.5); + p.line(7.5, 3.0, 7.5, 12.5); + p.line(3.5, 3.0, 3.5, 5.5); + p.line(11.5, 3.0, 11.5, 5.5); + p.line(5.5, 12.5, 9.5, 12.5); } Comment => { let mut b: Vec<(f32, f32)> = arc_pts(8.5, 7.5, 5.0, 2.75, TAU + 1.95).collect(); @@ -706,6 +720,14 @@ fn draw(p: &P, icon: Icon) { p.corner(x, y, dx, dy, 4.0, 2.0); } } + IndependentPadding => { + // A frame outline with one inward tick per side: each side's padding, set alone. + p.rect(2.5, 2.5, 13.5, 13.5, 2.0); + p.line(8.0, 2.5, 8.0, 5.0); + p.line(13.5, 8.0, 11.0, 8.0); + p.line(8.0, 13.5, 8.0, 11.0); + p.line(2.5, 8.0, 5.0, 8.0); + } LinkOn | Link => { p.path(&capsule((4.0, 12.0), (6.5, 9.5), 2.4), true); p.path(&capsule((9.5, 6.5), (12.0, 4.0), 2.4), true); @@ -923,12 +945,32 @@ fn draw(p: &P, icon: Icon) { p.line(3.0, 7.5, 13.0, 7.5); } PaddingH => { - p.rect(2.5, 3.5, 13.5, 12.5, 1.0); - p.fill_rect(5.0, 6.0, 11.0, 10.0, 0.0); + // Horizontal padding: bars down the padded left and right sides in their frame. + p.rect(2.5, 2.5, 13.5, 13.5, 1.0); + p.fill_rect(2.5, 2.5, 5.5, 13.5, 1.0); + p.fill_rect(10.5, 2.5, 13.5, 13.5, 1.0); } PaddingV => { - p.rect(3.5, 2.5, 12.5, 13.5, 1.0); - p.fill_rect(6.0, 5.0, 10.0, 11.0, 0.0); + // Vertical padding: bars across the padded top and bottom in their frame. + p.rect(2.5, 2.5, 13.5, 13.5, 1.0); + p.fill_rect(2.5, 2.5, 13.5, 5.5, 1.0); + p.fill_rect(2.5, 10.5, 13.5, 13.5, 1.0); + } + PaddingTop => { + p.rect(2.5, 2.5, 13.5, 13.5, 1.0); + p.fill_rect(2.5, 2.5, 13.5, 5.5, 1.0); + } + PaddingRight => { + p.rect(2.5, 2.5, 13.5, 13.5, 1.0); + p.fill_rect(10.5, 2.5, 13.5, 13.5, 1.0); + } + PaddingBottom => { + p.rect(2.5, 2.5, 13.5, 13.5, 1.0); + p.fill_rect(2.5, 10.5, 13.5, 13.5, 1.0); + } + PaddingLeft => { + p.rect(2.5, 2.5, 13.5, 13.5, 1.0); + p.fill_rect(2.5, 2.5, 5.5, 13.5, 1.0); } Online => { p.circle(8.0, 8.0, 5.5); @@ -1226,4 +1268,59 @@ mod tests { assert!(!rotation.is_empty() && !radius.is_empty(), "both draw an icon"); assert_ne!(rotation, radius); } + + /// The shapes `icon` paints in a 32-point box at the origin: two points per grid unit. + fn shapes_at_32(icon: Icon) -> Vec { + let ctx = egui::Context::default(); + let mut out = + ctx.run_ui(egui::RawInput::default(), |ui| paint(ui.painter(), Rect::from_min_size(Pos2::ZERO, Vec2::splat(32.0)), icon, Color32::WHITE)); + out.textures_delta.clear(); + out.shapes.into_iter().map(|c| c.shape).collect() + } + + /// Frame and Text stay a grid unit inside the box, so their long hinted strokes don't + /// outweigh the outlines beside them in the toolbar (#737). + #[test] + fn frame_and_text_marks_are_inset() { + for icon in [Icon::Frame, Icon::Text] { + let pts: Vec = shapes_at_32(icon) + .iter() + .flat_map(|s| match s { + Shape::LineSegment { points, .. } => points.to_vec(), + other => panic!("{icon:?} draws straight strokes only: {other:?}"), + }) + .collect(); + assert!(!pts.is_empty()); + for p in pts { + // Grid units 3..13, two points each. + assert!((6.0..=26.0).contains(&p.x) && (6.0..=26.0).contains(&p.y), "{icon:?} reaches {p:?}"); + } + } + } + + /// Slice is one knife (#739): an outlined blade and a solid handle that covers the middle of + /// the blade's base, so the two read as one mark rather than separate pieces. + #[test] + fn slice_is_one_connected_knife() { + let shapes = shapes_at_32(Icon::Slice); + let paths: Vec<_> = shapes + .iter() + .map(|s| match s { + Shape::Path(p) => p, + other => panic!("Slice draws paths only: {other:?}"), + }) + .collect(); + let blade = paths.iter().find(|p| p.closed && p.fill == Color32::TRANSPARENT).expect("an outlined blade"); + let handle = paths.iter().find(|p| p.fill != Color32::TRANSPARENT).expect("a solid handle"); + assert_eq!((paths.len(), blade.points.len()), (2, 3), "a blade and a handle, nothing else"); + // The blade's base runs between its second and third points; the handle is convex. + let base = blade.points[1] + (blade.points[2] - blade.points[1]) / 2.0; + let h = &handle.points; + let side = |i: usize| { + let (a, b) = (h[i], h[(i + 1) % h.len()]); + (b - a).x * (base - a).y - (b - a).y * (base - a).x + }; + let inside = (0..h.len()).all(|i| side(i) >= 0.0) || (0..h.len()).all(|i| side(i) <= 0.0); + assert!(inside, "the handle covers the blade's base at {base:?}"); + } } diff --git a/crates/ui-egui/src/import_report_tests.rs b/crates/ui-egui/src/import_report_tests.rs index a24115823..8c10c7f3e 100644 --- a/crates/ui-egui/src/import_report_tests.rs +++ b/crates/ui-egui/src/import_report_tests.rs @@ -177,6 +177,7 @@ fn a_dropped_file_shows_its_report_once_it_opens() { params: json!({"path": path.to_string_lossy()}), command: "file.open", target: None, + read: None, }); for _ in 0..6 { u.frame(vec![]); diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 32f66f80e..dfab115aa 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -184,13 +184,18 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { app.ui.pages_open = !app.ui.pages_open; } let sr = Rect::from_center_size(pos2(hr.max.x - 50.0, hr.center().y), vec2(24.0, 24.0)); - let sresp = ui.interact(sr, ui.id().with("lsearch"), Sense::click()).tip(crate::shortcuts::tip(tr("Find"), "edit.findReplace")); - if sresp.hovered() { + let sresp = ui.interact(sr, ui.id().with("lsearch"), Sense::click()).tip(tr("Search pages")); + if sresp.hovered() || app.ui.page_search { ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover); } icons::paint(ui.painter(), sr.shrink(5.0), Icon::Search, t.icon); + let search_id = Id::new("page_search_field"); if sresp.clicked() { - app.ui.find = Some((String::new(), String::new())); + app.ui.page_search = !app.ui.page_search; + app.ui.page_filter.clear(); + if app.ui.page_search { + app.ui.pages_open = true; + } } let ar = Rect::from_center_size(pos2(hr.max.x - 22.0, hr.center().y), vec2(24.0, 24.0)); let aresp = ui.interact(ar, ui.id().with("addpage"), Sense::click()).tip(tr("Add new page")); @@ -199,15 +204,22 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } icons::paint(ui.painter(), ar.shrink(5.0), Icon::Plus, t.icon); if aresp.clicked() { + app.ui.pages_focus = app.session.ds().ok().map(|d| d.uid); app.run("page.add", json!({})); } if !app.ui.pages_open { return; } + if app.ui.page_search { + search_field(ui, &t, w, search_id, tr("Search pages"), &mut app.ui.page_filter, &mut app.ui.page_search, sresp.clicked()); + } let Ok(ds) = app.session.ds() else { return }; let pages: Vec<(NodeId, String, bool)> = ds.doc.pages.iter().filter_map(|p| ds.doc.node(*p).map(|n| (*p, n.name.clone(), ds.doc.is_divider(*p)))).collect(); + let filter = app.ui.page_filter.trim().to_lowercase(); + let filtering = !filter.is_empty(); let cur = ds.page; + let uid = ds.uid; let sel = ds.selection.clone(); let mut select = None; let mut rename_done: Option<(NodeId, String)> = None; @@ -221,8 +233,14 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { let max_h = pages_list_height(app.ui.pages_height, ui.available_height()); let mut moved = egui::ScrollArea::vertical().id_salt("pages_list").max_height(max_h).show(ui, |ui| { for (k, (id, name, divider)) in pages.iter().cloned().enumerate() { - let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click_and_drag()); - reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() }); + if filtering && (divider || !name.to_lowercase().contains(&filter)) { + continue; + } + let sense = if filtering { Sense::click() } else { Sense::click_and_drag() }; + let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), sense); + if !filtering { + reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() }); + } a11y::list_item(&resp, if divider { tr("Divider") } else { &name }, id == cur); let r = r.shrink2(vec2(8.0, 0.0)); a11y::ring_around(ui, r, &resp); @@ -257,6 +275,8 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } if resp.clicked() && !divider { select = Some(id); + app.ui.pages_focus = Some(uid); + app.ui.layers_focus = false; } if resp.double_clicked() { app.ui.rename_page = Some((id, name.clone())); @@ -274,7 +294,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { ui.close(); } if ui.button(tr("Delete page")).clicked() { - app.run("page.delete", json!({"id": id})); + delete_page(app, id); ui.close(); } }); @@ -311,6 +331,20 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { pages_edge(app, ui, moved.inner_rect.height().min(moved.content_size.y)); } +/// The Pages list has the keyboard: a page row (or the add-page button) of the open file was +/// clicked last, and the list is still on screen. +pub(crate) fn pages_have_keyboard(app: &FigApp) -> bool { + let Ok(ds) = app.session.ds() else { return false }; + app.ui.pages_focus == Some(ds.uid) && app.ui.pages_open && !app.ui.left_collapsed && !ds.view.ui_hidden && app.ui.left_tab == LeftTab::File +} + +/// Delete `page` (no confirmation: Undo brings it back) and say so. +pub(crate) fn delete_page(app: &mut FigApp, page: NodeId) { + if app.run("page.delete", json!({"id": page})).is_some() { + app.toast(tr("Page deleted")); + } +} + /// The pages list's height in a panel with `avail` below the Pages header: the height asked for /// (by default 40% of it), at least three rows, and leaving the layers their header and three rows. pub(crate) fn pages_list_height(asked: Option, avail: f32) -> f32 { @@ -332,7 +366,7 @@ fn pages_edge(app: &mut FigApp, ui: &mut Ui, shown: f32) { if active { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); } - let (width, colour) = if active { (2.0, t.accent) } else { (1.0, t.border) }; + let (width, colour) = if active { (2.0, ACCENT) } else { (1.0, t.border) }; ui.painter().line_segment([line.left_center(), line.right_center()], Stroke::new(width, colour)); let asked = if resp.double_clicked() { Some(None) @@ -623,8 +657,10 @@ fn set_expanded_deep(doc: &Document, id: NodeId, open: bool, expanded: &mut BTre } } -/// The search row under the Layers header (live filter; Escape closes it). -fn search_field(app: &mut FigApp, ui: &mut Ui, t: &Tokens, w: f32, id: Id) { +/// The search row under the Pages or Layers header: a live filter `text`, taking the keyboard when +/// `focus` (just opened). Escape in it clears it and closes the row (`open`), and goes no further +/// (the canvas keeps its selection). +fn search_field(ui: &mut Ui, t: &Tokens, w: f32, id: Id, hint: &str, text: &mut String, open: &mut bool, focus: bool) { let (fr, _) = ui.allocate_exact_size(vec2(w, 32.0), Sense::hover()); let field = Rect::from_min_max(pos2(fr.min.x + 12.0, fr.min.y + 2.0), pos2(fr.max.x - 12.0, fr.max.y - 6.0)); ui.painter().rect_filled(field, CornerRadius::same(5), t.field); @@ -632,21 +668,25 @@ fn search_field(app: &mut FigApp, ui: &mut Ui, t: &Tokens, w: f32, id: Id) { let te_rect = Rect::from_min_max(pos2(field.min.x + 24.0, field.center().y - 7.0), pos2(field.max.x - 6.0, field.center().y + 7.0)); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(te_rect)); let r = child.add( - egui::TextEdit::singleline(&mut app.ui.layer_filter) + egui::TextEdit::singleline(text) .id(id) .frame(egui::Frame::NONE) .font(regular(11.0)) - .hint_text(tr("Find layers")) + .hint_text(hint) .vertical_align(egui::Align::Center) .desired_width(te_rect.width()), ); - crate::widgets::select_all_on_focus(ui, &r, &app.ui.layer_filter); - if r.has_focus() { - ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + // After the field: the click that opened it ends elsewhere, which would take the focus back. + if focus { + r.request_focus(); } - if (r.has_focus() || r.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)) { - app.ui.layer_search = false; - app.ui.layer_filter.clear(); + crate::widgets::select_all_on_focus(ui, &r, text); + if r.has_focus() { + ui.painter().rect_stroke(field, CornerRadius::same(5), Stroke::new(1.0, ACCENT), egui::StrokeKind::Inside); + } + if (r.has_focus() || r.lost_focus()) && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { + *open = false; + text.clear(); } } @@ -665,12 +705,9 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { if sresp.clicked() { app.ui.layer_search = !app.ui.layer_search; app.ui.layer_filter.clear(); - if app.ui.layer_search { - ui.memory_mut(|m| m.request_focus(search_id)); - } } if app.ui.layer_search { - search_field(app, ui, &t, w, search_id); + search_field(ui, &t, w, search_id, tr("Find layers"), &mut app.ui.layer_filter, &mut app.ui.layer_search, sresp.clicked()); } let Ok(ds) = app.session.ds() else { return }; let doc = ds.doc.clone(); @@ -1064,6 +1101,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { } if let Some((id, m)) = clicked { app.ui.layers_focus = true; + app.ui.pages_focus = None; let order: Vec = rows.iter().map(|r| r.id).collect(); let pos = |x: NodeId| order.iter().position(|o| *o == x); let anchor = app.ui.layer_anchor.filter(|a| sel.contains(a)).and_then(pos); diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs index d4ef4f8ac..b963ee827 100644 --- a/crates/ui-egui/src/layers_behaviour_tests.rs +++ b/crates/ui-egui/src/layers_behaviour_tests.rs @@ -199,6 +199,81 @@ fn page_row(u: &mut Ui, page: NodeId, back: NodeId) -> Pos2 { panic!("no row for page {page:?}"); } +#[test] +fn pages_search_button_opens_the_filter_and_escape_clears_it() { + let s = Session::new(); + let _ = s.page().unwrap(); + let mut u = Ui::new(s); + + // The search icon in the Pages header opens the page-name field, not Find and Replace. + click(&mut u, Pos2::new(246.0, 124.0), Modifiers::NONE); + assert!(u.app.ui.page_search, "the Pages search is open"); + assert!(u.app.ui.find.is_none(), "page search does not open Find and Replace"); + u.frame(vec![Event::Text("COMPON".into())]); + assert_eq!(u.app.ui.page_filter, "COMPON", "typing can start immediately"); + u.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + assert!(!u.app.ui.page_search, "Escape closes page search"); + assert!(u.app.ui.page_filter.is_empty(), "Escape clears the filter"); +} + +#[test] +fn pages_search_leaves_escape_to_the_canvas_when_the_field_is_unfocused() { + let mut s = Session::new(); + let r = rect(&mut s, "r", 0.0, None); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = Ui::new(s); + u.app.ui.page_search = true; + u.app.ui.page_filter = "COMPON".into(); + u.frame(vec![]); + + u.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + + assert!(u.app.session.selection().is_empty(), "Escape deselects on the canvas"); + assert!(u.app.ui.page_search, "the page search stays open"); + assert_eq!(u.app.ui.page_filter, "COMPON", "and keeps its filter"); +} + +#[test] +fn escape_in_the_pages_search_does_not_reach_the_canvas() { + let mut s = Session::new(); + let r = rect(&mut s, "r", 0.0, None); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = Ui::new(s); + click(&mut u, Pos2::new(246.0, 124.0), Modifiers::NONE); + assert!(u.app.ui.page_search); + u.frame(vec![Event::Text("x".into())]); + u.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + assert!(!u.app.ui.page_search, "Escape closes the page search"); + assert_eq!(u.app.session.selection(), vec![r], "and leaves the selection alone"); +} + +#[test] +fn pages_search_filters_page_names_case_insensitively() { + let mut s = Session::new(); + let _overview = s.page().unwrap(); + let components = id(s.execute("page.add", &json!({"name": "Components"})).unwrap()); + let library = id(s.execute("page.add", &json!({"name": "Library"})).unwrap()); + s.execute("page.select", &json!({"id": library})).unwrap(); + let mut u = Ui::new(s); + u.app.ui.page_search = true; + u.app.ui.page_filter = "COMPON".into(); + let shapes = u.frame_output(vec![]).shapes; + let text = crate::live_tests::texts(&crate::live_tests::flat(&shapes)); + assert!(text.iter().any(|(label, _, _)| label == "Components"), "the matching page remains visible"); + assert!(!text.iter().any(|(label, _, _)| label == "Library"), "nonmatching pages are filtered out"); + + // Search leaves only Components; the active page starts as Library. + for y in (150..400).step_by(4) { + let p = Pos2::new(150.0, y as f32); + u.button(p, true); + u.button(p, false); + if u.app.session.page().unwrap() == components { + break; + } + } + assert_eq!(u.app.session.page().unwrap(), components, "matching is case-insensitive and opens the named page"); +} + #[test] fn pages_drag_to_reorder_and_dividers_open_nothing() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/layers_keys_tests.rs b/crates/ui-egui/src/layers_keys_tests.rs index b78890a89..63f8ce74c 100644 --- a/crates/ui-egui/src/layers_keys_tests.rs +++ b/crates/ui-egui/src/layers_keys_tests.rs @@ -1,8 +1,8 @@ //! Arrow keys in the layers panel once a row has been clicked: up/down through the rows (Shift //! extends), left/right to collapse, expand, and go to the parent or first child; a press on the -//! canvas gives arrows back to nudging. +//! canvas gives arrows back to nudging. Delete after a click in the Pages list deletes the page. -use egui::{Event, Key, Modifiers, pos2}; +use egui::{Event, Key, Modifiers, Pos2, pos2, vec2}; use figcraft_doc::NodeId; use figcraft_engine::Session; use serde_json::json; @@ -107,3 +107,125 @@ fn a_canvas_press_gives_arrows_back_to_nudging() { let doc = u.app.session.doc().unwrap(); assert_eq!(doc.node(c).unwrap().transform.translation().x, 201.0); } + +/// The middle of `page`'s row in the Pages list, found by clicking down the list until it opens; +/// then back to `back`. +fn page_row(u: &mut Ui, page: NodeId, back: NodeId) -> Pos2 { + for y in (90..400).step_by(4) { + let p = pos2(150.0, y as f32); + u.button(p, true); + u.button(p, false); + if u.app.session.page().unwrap() == page { + u.app.session.execute("page.select", &json!({"id": back})).unwrap(); + u.frame(vec![]); + // Rows are 32 high: aim at the middle, past the hit tolerance at its edge. + return p + vec2(0.0, 12.0); + } + } + panic!("no row for page {page:?}"); +} + +fn pages(u: &Ui) -> usize { + u.app.session.doc().unwrap().pages.len() +} + +#[test] +fn delete_after_clicking_a_page_deletes_it_without_asking() { + // #859: Delete on a page picked in the Pages list said "nothing is selected". + let (mut u, ..) = setup(); + let first = u.app.session.page().unwrap(); + let second = id(u.app.session.execute("page.add", &json!({})).unwrap()); + u.app.session.execute("page.select", &json!({"id": first})).unwrap(); + u.frame(vec![]); + let row = page_row(&mut u, second, first); + u.button(row, true); + u.button(row, false); + assert_eq!(u.app.session.page().unwrap(), second); + key(&mut u, Key::Delete, false); + // Gone at once, no confirmation: a toast says so and Undo brings it back. + assert_eq!(pages(&u), 1); + assert!(!u.app.session.doc().unwrap().pages.contains(&second)); + assert_eq!(u.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Page deleted")); + u.app.run("edit.undo", json!({})); + assert_eq!(pages(&u), 2); + // Backspace (the Mac's delete key) does the same. + let row = page_row(&mut u, second, first); + u.button(row, true); + u.button(row, false); + key(&mut u, Key::Backspace, false); + assert_eq!(pages(&u), 1); + // The last page stays. + key(&mut u, Key::Delete, false); + assert_eq!(pages(&u), 1); +} + +#[test] +fn selected_layers_or_a_canvas_press_take_delete_back_from_the_pages_list() { + let (mut u, a, ..) = setup(); + let first = u.app.session.page().unwrap(); + let second = id(u.app.session.execute("page.add", &json!({})).unwrap()); + u.frame(vec![]); + let row = page_row(&mut u, first, second); + u.button(row, true); + u.button(row, false); + assert_eq!(u.app.session.page().unwrap(), first); + assert!(u.app.ui.pages_focus.is_some()); + // Layers selected after the click (Select all, say): Delete deletes them, not the page. + u.app.run("select.set", json!({"ids": [a]})); + key(&mut u, Key::Delete, false); + assert_eq!(pages(&u), 2); + assert!(u.app.session.doc().unwrap().node(a).is_none()); + // Nothing is selected now, but the list gave up the keyboard: a second Delete keeps the page. + assert!(u.app.ui.pages_focus.is_none()); + key(&mut u, Key::Delete, false); + assert_eq!(pages(&u), 2, "a second Delete keeps the page"); + // A press on the canvas gives Delete back to it. + u.button(row, true); + u.button(row, false); + assert!(u.app.ui.pages_focus.is_some()); + let p = pos2(900.0, 700.0); + u.button(p, true); + u.button(p, false); + assert!(u.app.ui.pages_focus.is_none()); + u.app.run("select.none", json!({})); + key(&mut u, Key::Delete, false); + assert_eq!(pages(&u), 2, "Delete with nothing selected on the canvas deletes no page"); +} + +#[test] +fn delete_deletes_no_page_once_the_pages_list_is_off_screen_or_another_file_is_open() { + let (mut u, ..) = setup(); + let first = u.app.session.page().unwrap(); + let second = id(u.app.session.execute("page.add", &json!({})).unwrap()); + u.app.session.execute("page.select", &json!({"id": first})).unwrap(); + u.frame(vec![]); + let row = page_row(&mut u, second, first); + let click = |u: &mut Ui| { + u.button(row, true); + u.button(row, false); + assert!(u.app.ui.pages_focus.is_some()); + }; + // The Assets tab, a collapsed Pages list, a collapsed left panel. + let hide: [fn(&mut Ui, bool); 3] = [ + |u, on| u.app.ui.left_tab = if on { crate::LeftTab::Assets } else { crate::LeftTab::File }, + |u, on| u.app.ui.pages_open = !on, + |u, on| u.app.ui.left_collapsed = on, + ]; + for (k, hide) in hide.iter().enumerate() { + click(&mut u); + hide(&mut u, true); + u.frame(vec![]); + key(&mut u, Key::Delete, false); + assert_eq!(pages(&u), 2, "case {k}: the list is off screen"); + hide(&mut u, false); + u.frame(vec![]); + } + // Another file open: its pages are safe. + click(&mut u); + u.app.run("file.new", json!({})); + u.app.run("page.add", json!({})); + u.frame(vec![]); + assert_eq!(pages(&u), 2); + key(&mut u, Key::Delete, false); + assert_eq!(pages(&u), 2, "the other file keeps its pages"); +} diff --git a/crates/ui-egui/src/layout_panel.rs b/crates/ui-egui/src/layout_panel.rs index 11d303693..b5ff1d45f 100644 --- a/crates/ui-egui/src/layout_panel.rs +++ b/crates/ui-egui/src/layout_panel.rs @@ -401,35 +401,66 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { } } +/// The Spacing row: the H/V padding pair, or, with Individual padding on, two rows of one field +/// per side (like Individual corners). fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { let col = widgets::col_width(ui.available_width()); let [pt, pr, pb, pl] = l.padding; - row(ui, 32.0, |ui| { - let e = crate::variable_ui::number_field( - app, - ui, - ids, - "paddingHorizontal", - Id::new("al_padh"), - Lead::Icon(Icon::PaddingH, tr("Horizontal padding")), - (pl == pr).then_some(pl), - "", - col, - ); - app.field(ids, "paddingHorizontal", e, |v| json!(v.max(0.0))); - let e = crate::variable_ui::number_field( - app, - ui, - ids, - "paddingVertical", - Id::new("al_padv"), - Lead::Icon(Icon::PaddingV, tr("Vertical padding")), - (pt == pb).then_some(pt), - "", - col, - ); - app.field(ids, "paddingVertical", e, |v| json!(v.max(0.0))); - }); + let open = ui.data(|d| d.get_temp::(Id::new("indep_padding"))).unwrap_or(false); + let toggle = |ui: &mut Ui| { + if icons::button(ui, Icon::IndependentPadding, 24.0, open, tr("Individual padding")).clicked() { + ui.data_mut(|d| d.insert_temp(Id::new("indep_padding"), !open)); + } + }; + if open { + // `l` is the selection's common layout, so its sides are common too. `k` indexes + // [top, right, bottom, left]. + let sides = [ + ("paddingLeft", tr("Left padding"), Icon::PaddingLeft, pl, 3), + ("paddingTop", tr("Top padding"), Icon::PaddingTop, pt, 0), + ("paddingRight", tr("Right padding"), Icon::PaddingRight, pr, 1), + ("paddingBottom", tr("Bottom padding"), Icon::PaddingBottom, pb, 2), + ]; + for (r, pair) in sides.chunks(2).enumerate() { + row(ui, 32.0, |ui| { + for (key, name, icon, v, k) in pair { + let e = crate::variable_ui::number_field(app, ui, ids, key, Id::new(("padding", *k)), Lead::Icon(*icon, name), Some(*v), "", col); + app.field(ids, key, e, |v| json!(v.max(0.0))); + } + if r == 0 { + toggle(ui); + } + }); + } + } else { + row(ui, 32.0, |ui| { + let e = crate::variable_ui::number_field( + app, + ui, + ids, + "paddingHorizontal", + Id::new("al_padh"), + Lead::Icon(Icon::PaddingH, tr("Horizontal padding")), + (pl == pr).then_some(pl), + "", + col, + ); + app.field(ids, "paddingHorizontal", e, |v| json!(v.max(0.0))); + let e = crate::variable_ui::number_field( + app, + ui, + ids, + "paddingVertical", + Id::new("al_padv"), + Lead::Icon(Icon::PaddingV, tr("Vertical padding")), + (pt == pb).then_some(pt), + "", + col, + ); + app.field(ids, "paddingVertical", e, |v| json!(v.max(0.0))); + toggle(ui); + }); + } if l.mode != LayoutMode::Grid && l.wrap { // Wrapped rows (horizontal) or columns (vertical): the gap between them. let vertical = l.mode == LayoutMode::Vertical; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9b8ee845d..5ec5eb04c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -62,6 +62,7 @@ pub mod tabs; pub mod theme; pub mod toolbar; pub mod typography; +pub mod ui_scale; pub mod variable_ui; pub mod variables_panel; pub mod vector_panel; @@ -147,6 +148,8 @@ mod live_tests; #[cfg(test)] mod live_text_tests; #[cfg(test)] +mod measure_nudge_tests; +#[cfg(test)] mod menu_aim_tests; #[cfg(test)] mod menu_input_tests; @@ -157,6 +160,8 @@ mod online_tests; #[cfg(test)] mod open_issues_tests; #[cfg(test)] +mod padding_individual_tests; +#[cfg(test)] mod palette_tests; #[cfg(test)] mod panel_remove_tests; @@ -207,6 +212,8 @@ mod text_enter_tests; #[cfg(test)] mod titlebar_tests; #[cfg(test)] +mod ui_scale_tests; +#[cfg(test)] mod variables_export_ui_tests; #[cfg(test)] mod variables_rename_tests; @@ -239,6 +246,11 @@ pub trait Services { fn pick_open(&mut self, _title: &str, _exts: &[&str]) -> DialogFuture { Box::pin(std::future::ready(None)) } + /// Pick a file and read it, where the platform hands over a file's name and contents rather + /// than a path to it (the browser). `None`: the platform picks paths ([`Services::pick_open`]). + fn pick_open_contents(&mut self, _title: &str, _exts: &[&str]) -> Option { + None + } fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> DialogFuture { Box::pin(std::future::ready(None)) } @@ -293,14 +305,61 @@ pub trait Services { } pub type DialogFuture = Pin>>>; -type DialogDone = Box; +/// A picked file's name and contents ([`Services::pick_open_contents`]); `None` when the picker +/// was cancelled, `Err` with what to tell the user when the file couldn't be read. +pub type ContentsFuture = Pin), String>>>>>; +/// A dropped file's contents, read once they arrive (`Err`: what to tell the user when they +/// couldn't be read). +pub type ReadFuture = Pin, String>>>>; +type PickFuture = Pin>>>; +type DialogDone = Box; + +/// What a dialog picked: a path, or a file's name and contents where there are no paths, or why +/// the picked file couldn't be read. +enum Picked { + Path(PathBuf), + Contents(String, Vec), + Unreadable(String), +} + +impl Picked { + /// The file as `file.open` and `file.import` take it (`Err`: what to tell the user instead). + fn file_params(self) -> Result { + match self { + Picked::Path(p) => Ok(json!({"path": p.to_string_lossy()})), + Picked::Contents(name, bytes) => { + use base64::Engine as _; + Ok(json!({"data": base64::engine::general_purpose::STANDARD.encode(&bytes), "name": name})) + } + Picked::Unreadable(why) => Err(why), + } + } +} + +/// What the user is told about a file whose contents couldn't be read. +pub fn unreadable(name: &str) -> String { + i18n::trf("Couldn't read {name}.", &[("name", &name)]) +} + +/// The largest file read in the browser: wasm has at most 4 GiB of memory, and a file's contents +/// are held about three times on their way to `file.open` (the bytes, base64, decoded again). +/// As large as one member of a `.figcraft` file may be. +const MAX_BROWSER_FILE: f64 = (512u64 << 20) as f64; + +/// Checks a file of `size` bytes before the browser reads it (`Err`: what to tell the user). +pub fn browser_can_read(name: &str, size: f64) -> Result<(), String> { + if size.is_nan() || size > MAX_BROWSER_FILE { + return Err(i18n::trf("{name} is too large to open in the browser (over 512 MB).", &[("name", &name)])); + } + Ok(()) +} /// A native dialog in flight. The app keeps working while it is open, so its result goes to the /// file and window it was opened for, not to whichever is in front when it closes. struct PendingDialog { - future: DialogFuture, - /// The path picked, kept until the window that holds `doc` draws. - path: Option, + future: PickFuture, + /// What was picked, kept until the window that holds `doc` draws. + picked: Option, done: DialogDone, /// The file it acts on (`None`: none, such as Open). doc: Option, @@ -434,6 +493,9 @@ pub struct UiState { pub last_right_tab: RightTab, pub left_tab: LeftTab, pub pages_open: bool, + /// Pages panel search: open, and the case-insensitive name filter. + pub page_search: bool, + pub page_filter: String, /// The height asked for the pages list (`None`: a share of the panel). pub pages_height: Option, pub expanded: BTreeSet, @@ -462,6 +524,9 @@ pub struct UiState { pub menu_input: Option, /// File ▸ Rename: the file name in the left panel starts its inline rename on the next frame. pub rename_file: bool, + /// What the interface size drew last frame and whether the window held it back. Set by + /// [`FigApp::logic`] for the main window. + pub ui_scale: ui_scale::Plan, pub shortcuts_panel: bool, /// The Keyboard shortcuts panel's tab and search text. pub shortcuts_tab: figcraft_engine::keymap::Group, @@ -508,6 +573,10 @@ pub struct UiState { /// The layers panel has the keyboard: arrow keys move through its rows instead of nudging /// (set by a click on a row, cleared by a press on the canvas). pub layers_focus: bool, + /// The file (its `uid`) whose pages list has the keyboard: Delete with no layers selected deletes + /// its current page (set by a click on a page row or the add-page button, cleared by a click on a + /// layer row or a press on the canvas). + pub pages_focus: Option, pub layers_seen_sel: Vec, pub dark: bool, pub follow_system: bool, @@ -605,6 +674,8 @@ impl Default for UiState { last_right_tab: RightTab::Design, left_tab: LeftTab::File, pages_open: true, + page_search: false, + page_filter: String::new(), pages_height: None, expanded: BTreeSet::new(), rename: None, @@ -622,6 +693,7 @@ impl Default for UiState { preferences: false, menu_input: None, rename_file: false, + ui_scale: ui_scale::plan(1.0, None, None), shortcuts_panel: false, shortcuts_tab: figcraft_engine::keymap::Group::Essential, shortcuts_query: String::new(), @@ -646,6 +718,7 @@ impl Default for UiState { layer_filter: String::new(), layer_anchor: None, layers_focus: false, + pages_focus: None, layers_seen_sel: Vec::new(), dark: false, follow_system: false, @@ -793,6 +866,11 @@ impl FigApp { fn failed(&mut self, id: &str, e: &figcraft_engine::EngineError) { let msg = e.to_string(); log::warn!("{id}: {msg}"); + self.show_error(msg); + } + + /// Show an error as a toast, kept as the last error. + pub(crate) fn show_error(&mut self, msg: String) { self.toast(msg.clone()); self.ui.last_error = Some(msg); } @@ -1045,7 +1123,7 @@ impl FigApp { let resp = ui.interact(rect, id, egui::Sense::click_and_drag()); if resp.hovered() || resp.dragged() { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); - ui.painter().vline(x, rows, egui::Stroke::new(2.0, theme::Tokens::get(ui.ctx()).accent)); + ui.painter().vline(x, rows, egui::Stroke::new(2.0, theme::ACCENT)); } if resp.double_clicked() { return Some(PANEL_DEFAULT); @@ -1112,16 +1190,11 @@ impl FigApp { pub fn open_dialog(&mut self) { // Opening needs no file in front: it goes ahead even if that file closed meanwhile. - self.start_dialog( - |services| services.pick_open(i18n::tr("Open"), OPEN_EXTENSIONS), - false, - |app, p| { - let ps = p.to_string_lossy().to_string(); - if app.run_user("file.open", json!({"path": ps})).is_some() { - app.ui.home = false; - } - }, - ); + self.pick_file(i18n::tr("Open"), OPEN_EXTENSIONS, false, |app, file| { + if app.run_user("file.open", file).is_some() { + app.ui.home = false; + } + }); } pub fn import_dialog(&mut self, place: bool) { @@ -1131,12 +1204,35 @@ impl FigApp { &["svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff", "figcraft", "fig"] }; let title = if place { i18n::tr("Place image") } else { i18n::tr("Import") }; - self.pick_open(title, exts, move |app, p| { - let ps = p.to_string_lossy().to_string(); - app.run_user("file.import", json!({"path": ps})); + self.pick_file(title, exts, true, |app, file| { + app.run_user("file.import", file); }); } + /// Pick a file to open or import; `done` gets it as `file.open` and `file.import` take it (a + /// path, or the name and contents where the platform has no paths). `pin`: as + /// [`FigApp::pick_open`]'s file in front. + fn pick_file(&mut self, title: &str, exts: &[&str], pin: bool, done: impl FnOnce(&mut FigApp, Value) + 'static) { + self.start_pick( + |services| -> PickFuture { + match services.pick_open_contents(title, exts) { + Some(read) => { + Box::pin(async move { read.await.map(|r| r.map_or_else(Picked::Unreadable, |(name, bytes)| Picked::Contents(name, bytes))) }) + } + None => { + let pick = services.pick_open(title, exts); + Box::pin(async move { pick.await.map(Picked::Path) }) + } + } + }, + pin, + move |app, picked| match picked.file_params() { + Ok(file) => done(app, file), + Err(why) => app.show_error(why), + }, + ); + } + /// Pick a file to open. `done` runs with the file in front now back in front (and not at all /// if that file closed meanwhile). pub fn pick_open(&mut self, title: &str, exts: &[&str], done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { @@ -1155,12 +1251,28 @@ impl FigApp { /// Open a native dialog from the window drawing now, for the file in front (`pin`). fn start_dialog(&mut self, start: impl FnOnce(&mut dyn Services) -> DialogFuture, pin: bool, done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { + self.start_pick( + |services| -> PickFuture { + let pick = start(services); + Box::pin(async move { pick.await.map(Picked::Path) }) + }, + pin, + move |app, picked| { + if let Picked::Path(p) = picked { + done(app, p); + } + }, + ); + } + + /// [`FigApp::start_dialog`] for any kind of pick. + fn start_pick(&mut self, start: impl FnOnce(&mut dyn Services) -> PickFuture, pin: bool, done: impl FnOnce(&mut FigApp, Picked) + 'static) { if self.pending_dialog.is_some() { return; } let doc = if pin { self.session.ds().ok().filter(|d| self.shows(d.uid)).map(|d| d.uid) } else { None }; let future = start(self.services.as_mut()); - self.pending_dialog = Some(PendingDialog { future, path: None, done: Box::new(done), doc, window: self.windows.current }); + self.pending_dialog = Some(PendingDialog { future, picked: None, done: Box::new(done), doc, window: self.windows.current }); if let Some(ctx) = &self.ui_context { ctx.request_repaint(); } @@ -1170,12 +1282,12 @@ impl FigApp { /// that file in front; then the tab in front before comes back (unless the dialog opened a file). fn poll_dialog(&mut self, ctx: &egui::Context) { let Some(d) = &mut self.pending_dialog else { return }; - if d.path.is_none() { + if d.picked.is_none() { let waker = Waker::from(Arc::new(RepaintWaker(ctx.clone()))); - let Poll::Ready(path) = d.future.as_mut().poll(&mut TaskContext::from_waker(&waker)) else { return }; - d.path = path; + let Poll::Ready(picked) = d.future.as_mut().poll(&mut TaskContext::from_waker(&waker)) else { return }; + d.picked = picked; } - let (doc, origin, picked) = (d.doc, d.window, d.path.is_some()); + let (doc, origin, picked) = (d.doc, d.window, d.picked.is_some()); let index = doc.map(|u| self.session.docs.iter().position(|ds| ds.uid == u)); // Cancelled, or its file closed meanwhile. if !picked || index == Some(None) { @@ -1191,12 +1303,12 @@ impl FigApp { ctx.request_repaint_of(windows::viewport(window)); return; } - let Some(PendingDialog { path: Some(path), done, .. }) = self.pending_dialog.take() else { return }; + let Some(PendingDialog { picked: Some(picked), done, .. }) = self.pending_dialog.take() else { return }; let before = self.session.ds().ok().map(|d| d.uid); if let Some(Some(i)) = index { self.session.active = i; } - done(self, path); + done(self, picked); if doc.is_some() && self.session.ds().ok().map(|d| d.uid) == doc && let Some(i) = before.and_then(|u| self.session.docs.iter().position(|d| d.uid == u)) @@ -1210,11 +1322,11 @@ impl FigApp { self.session.prefs.reduce_motion || self.services.reduce_motion() } - /// The CJK scripts the UI shows: the language's, the language picker's (Preferences open), + /// The CJK and Bengali scripts the UI shows: the language's, the language picker's (Preferences open), /// typed text's, and those in the open file's names and comments (read when it changes, not /// mid-drag). Nothing is checked once every script has a face. fn cjk_needed(&mut self, ctx: &egui::Context, installed: u8) -> u8 { - if installed == cjk::KANA | cjk::HANGUL | cjk::HAN { + if installed == cjk::ALL { return installed; } let mut need = i18n::current().map_or(0, |l| cjk::language(l.code)); @@ -1274,6 +1386,16 @@ impl FigApp { theme::install_fonts(ctx, true, cjk); } } + // egui's zoom is for the whole app and multiplies each window's own system scale, so the + // size is the same relative to every screen; the main window, whose size can hold a size + // back, sets it. The browser keeps its own zoom. + if ctx.viewport_id() == egui::ViewportId::ROOT { + let window_px = ctx.content_rect().size() * ctx.pixels_per_point(); + let size = if cfg!(target_arch = "wasm32") { 1.0 } else { self.session.prefs.ui_scale }; + let plan = ui_scale::plan(size, ctx.native_pixels_per_point(), Some((window_px.x, window_px.y))); + ctx.set_zoom_factor(plan.zoom); + self.ui.ui_scale = plan; + } let dark = match self.session.prefs.theme.as_str() { "dark" => true, // The active visuals were set by us last frame (possibly for a manual theme). @@ -1403,7 +1525,7 @@ impl FigApp { // What happened in a live session (the network woke us for it). if self.session.live_poll() { if let Some(m) = self.session.message.take() { - self.toast(m); + self.toast(live::failure_text(&m)); } self.background_done(&ctx); } @@ -1701,6 +1823,8 @@ impl FigApp { "importReport": self.ui.import_report.as_ref().map(import_report::Report::inspect), }); state["homeSearch"] = json!(self.ui.home_view.file_query); + // Set apart from the literal above, which is at the macro recursion limit. + state["uiScale"] = json!({"choice": self.session.prefs.ui_scale, "zoom": self.ui.ui_scale.zoom, "drawn": self.ui.ui_scale.drawn, "limited": self.ui.ui_scale.limited}); state["pendingOpens"] = json!(self.ui.pending_opens.len()); state["missingFonts"] = json!(self.ui.missing_fonts.as_ref().map(missing_fonts::Shown::inspect)); state @@ -1715,17 +1839,22 @@ pub(crate) fn opens(path: &Path) -> bool { path.extension().and_then(|e| e.to_str()).is_some_and(|e| OPEN_EXTENSIONS.iter().any(|x| x.eq_ignore_ascii_case(e))) } -/// The contents of a dropped file that has no file on disk to read by path. +/// Reads the contents of dropped file `name` that has no file on disk to read by path. #[cfg(not(target_arch = "wasm32"))] -pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle) -> Option> { - f.bytes().ok() +pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle, name: &str) -> ReadFuture { + Box::pin(std::future::ready(f.bytes().map_err(|_| unreadable(name)))) } -/// The contents of a dropped file that has no file on disk to read by path. +/// Reads the contents of dropped file `name`; the browser hands them over asynchronously. #[cfg(target_arch = "wasm32")] -pub(crate) fn dropped_bytes(_f: &egui::DroppedFileHandle) -> Option> { - // Browser drops arrive asynchronously; not supported yet. - None +pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle, name: &str) -> ReadFuture { + let (f, name) = (f.clone(), name.to_string()); + Box::pin(async move { + if let Some(file) = f.web_file() { + browser_can_read(&name, file.size())?; + } + f.bytes_async().await.map_err(|_| unreadable(&name)) + }) } /// While files are dragged over the window: a tinted, outlined `rect` with `label` in its middle, @@ -1738,11 +1867,11 @@ pub(crate) fn drop_overlay(ctx: &egui::Context, rect: egui::Rect, label: &str) { let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("drop_overlay"))); let area = rect.shrink(4.0); painter.rect_filled(area, egui::CornerRadius::same(8), t.accent.gamma_multiply(0.12)); - painter.rect_stroke(area, egui::CornerRadius::same(8), egui::Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); + painter.rect_stroke(area, egui::CornerRadius::same(8), egui::Stroke::new(2.0, theme::ACCENT), egui::StrokeKind::Inside); let galley = painter.layout_no_wrap(label.to_owned(), theme::semibold(14.0), t.text); let pill = egui::Rect::from_center_size(area.center(), galley.size() + egui::vec2(32.0, 16.0)); painter.rect_filled(pill, egui::CornerRadius::same(8), t.panel); - painter.rect_stroke(pill, egui::CornerRadius::same(8), egui::Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + painter.rect_stroke(pill, egui::CornerRadius::same(8), egui::Stroke::new(1.0, theme::ACCENT), egui::StrokeKind::Inside); painter.galley(pill.center() - galley.size() / 2.0, galley, t.text); // Hovering sends no pointer events on every platform: keep the overlay current. ctx.request_repaint(); diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index 513060aba..a4baa3034 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -13,10 +13,11 @@ //! and it fades a few seconds after the last change (frames are asked for only until then). //! Spotlight: your avatar's menu asks everyone to follow you; a banner says so and stops it. +use std::borrow::Cow; use std::collections::BTreeMap; use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Shape, Stroke, Ui, pos2, vec2}; -use figcraft_engine::live::{Access, PEOPLE, Role, SessionId, Share, Signal, Status, Via, initials, parse_color, presence_page}; +use figcraft_engine::live::{Access, PEOPLE, Role, SessionId, Share, Signal, Status, TOO_LARGE_TO_JOIN, Via, initials, parse_color, presence_page}; use serde_json::{Value, json}; use crate::FigApp; @@ -595,7 +596,7 @@ pub fn share_popover(app: &mut FigApp, anchor: &egui::Response) { (Status::Reconnecting { attempt }, _) => { (trf("Connection lost, reconnecting (attempt {attempt})…", &[("attempt", attempt)]), t.text_secondary) } - (Status::Failed { .. }, _) => (status.label(), t.danger), + (Status::Failed { reason }, _) => (failure_text(reason).into_owned(), t.danger), _ => (tr(&status.label()).to_string(), t.text_secondary), }; if !line.is_empty() { @@ -695,11 +696,18 @@ pub fn join_field(app: &mut FigApp, ui: &mut Ui, typed: &mut String, id: egui::I pub fn join_status(app: &FigApp, ui: &mut Ui, t: &Tokens, session: SessionId) { match app.session.live.get(session).map(|l| &l.status) { Some(Status::Connecting) => widgets::label(ui, tr("Joining…"), t.text_secondary), - Some(Status::Failed { reason }) => widgets::label(ui, reason, t.danger), + Some(Status::Failed { reason }) => widgets::label(ui, &failure_text(reason), t.danger), _ => {} } } +/// Why a live session failed, in the UI language when it is a reason the UI knows (the transport's +/// English otherwise). +pub(crate) fn failure_text(reason: &str) -> Cow<'_, str> { + let mb = TOO_LARGE_TO_JOIN.split_once("{mb}").and_then(|(before, after)| reason.strip_prefix(before)?.strip_suffix(after)); + mb.map_or(Cow::Borrowed(reason), |mb| Cow::Owned(trf(TOO_LARGE_TO_JOIN, &[("mb", &mb)]))) +} + /// Not in a session: your name, how to share (online or on the local network), start; or join /// one with a code. Everything else is under Advanced. fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { diff --git a/crates/ui-egui/src/measure_nudge_tests.rs b/crates/ui-egui/src/measure_nudge_tests.rs new file mode 100644 index 000000000..b4a5a2475 --- /dev/null +++ b/crates/ui-egui/src/measure_nudge_tests.rs @@ -0,0 +1,64 @@ +//! Nudging while measuring (#894), driven through egui headless: with Alt held over another layer +//! the arrows still nudge the selection (Shift: the big step), the distance follows every nudge, +//! and the selection's handles and size label hide while measuring (its outline stays). + +use egui::{Event, Key, Modifiers, Pos2, Shape}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; +use crate::live_tests::flat; +use crate::present_tests::id; + +fn labels(u: &Ui) -> Vec { + u.app.session.tool.overlay.guides.iter().filter_map(|g| g.label.clone()).collect() +} + +fn top(u: &Ui, i: NodeId) -> f64 { + u.app.session.doc().unwrap().world_bounds(i).unwrap().y0 +} + +/// Press and release `key` with `m` held, then a settled frame (the pointer still over the canvas). +fn press(u: &mut Ui, key: Key, m: Modifiers, at: Pos2) { + let ev = |pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: m }; + u.frame_with(vec![ev(true), ev(false)], m); + u.frame_with(vec![Event::PointerMoved(at)], m); +} + +/// Whether the frame drew corner handles or a text `label`. +fn drew(shapes: &[Shape], label: &str) -> (bool, bool) { + let handle = shapes.iter().any(|s| matches!(s, Shape::Rect(r) if (r.rect.width() - 8.0).abs() < 0.1 && (r.rect.height() - 8.0).abs() < 0.1)); + let text = shapes.iter().any(|s| matches!(s, Shape::Text(t) if t.galley.text() == label)); + (handle, text) +} + +#[test] +fn arrows_nudge_while_measuring_and_the_distance_follows() { + let mut s = Session::new(); + let upper = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 40})).unwrap()); + let lower = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 140, "width": 100, "height": 40})).unwrap()); + s.execute("select.set", &json!({"ids": [lower]})).unwrap(); + let mut u = Ui::new(s); + let over = u.app.to_screen(50.0, 20.0); + let (alt, shift_alt) = (Modifiers { alt: true, ..Modifiers::NONE }, Modifiers { alt: true, shift: true, ..Modifiers::NONE }); + + // Not measuring: the selection shows its handles and size. + let shapes = flat(&u.frame_with(vec![Event::PointerMoved(over)], Modifiers::NONE).shapes); + assert_eq!(drew(&shapes, "100 × 40"), (true, true), "handles and size label show"); + + u.frame_with(vec![Event::PointerMoved(over)], alt); + let shapes = flat(&u.frame_with(vec![Event::PointerMoved(over)], alt).shapes); + assert!(u.app.session.tool.overlay.measure_box.is_some(), "Alt over the other layer measures to it"); + assert!(labels(&u).contains(&"100".to_string()), "the gap: {:?}", labels(&u)); + assert_eq!(drew(&shapes, "100 × 40"), (false, false), "measuring hides the handles and size label"); + + press(&mut u, Key::ArrowUp, alt, over); + assert_eq!(top(&u, lower), 139.0, "Alt+Up nudges by the small step"); + assert!(labels(&u).contains(&"99".to_string()), "the gap follows the nudge: {:?}", labels(&u)); + + press(&mut u, Key::ArrowUp, shift_alt, over); + assert_eq!(top(&u, lower), 129.0, "Shift+Alt+Up nudges by the big step"); + assert!(labels(&u).contains(&"89".to_string()), "the gap follows the big nudge: {:?}", labels(&u)); + assert_eq!(top(&u, upper), 0.0, "the measured layer stays put"); +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e32f50c88..e81a7c493 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -112,11 +112,26 @@ pub fn record_recent(app: &mut FigApp, key: &str) { r.truncate(figcraft_engine::MAX_RECENT_ACTIONS); } +/// Run a menu, shortcut or palette action by id: actions that need the UI (dialogs, panels, the +/// clipboard) are handled here, the rest go to the engine. pub fn invoke(app: &mut FigApp, id: &str) { match id { // Edit ▸ Delete (and the Delete binding) with an interaction open in the Prototype tab // removes that interaction, not the layer (#767). "edit.delete" if app.ui_context.clone().is_some_and(|ctx| crate::proto_panel::delete_selected_interaction(app, &ctx)) => {} + // After a click in the Pages list, while the list is on screen and its file open, with no + // layers selected, it deletes the current page (#859). + "edit.delete" if crate::layers::pages_have_keyboard(app) && app.session.selection().is_empty() => { + if let Ok(page) = app.session.page() { + crate::layers::delete_page(app, page); + } + } + // Otherwise the list gives up the keyboard: once Delete has taken layers selected since the + // click (Select all, say), a second Delete must not take the page. + "edit.delete" if app.ui.pages_focus.is_some() => { + app.ui.pages_focus = None; + app.run(id, json!({})); + } "file.open" => app.open_dialog(), "plugin.import" => crate::plugins_ui::import_dialog(app), "file.import" => app.import_dialog(false), @@ -206,12 +221,13 @@ pub fn invoke(app: &mut FigApp, id: &str) { "collab.publish" => crate::live::open_publish(app), "edit.copy" | "edit.cut" => { let ids = app.session.selection(); - // SVG for other apps, taken before a cut removes the layers. - let svg = app.session.doc().ok().and_then(|d| figcraft_svg_export(d, &ids)); + // For other apps: text layers' text, other layers as SVG; taken before a cut removes them. + let doc = app.session.doc().ok(); + let mirror = doc.and_then(|d| text_layers_text(d, &ids).or_else(|| figcraft_svg_export(d, &ids))); app.run(id, json!({})); - // Mirror to the system clipboard (SVG text, else a marker) and remember it, so a later - // paste knows whether the clipboard still holds our layers. - let text = svg.unwrap_or_else(|| format!("figcraft://clipboard/{}", ids.len())); + // Mirror to the system clipboard (text or SVG, else a marker) and remember it, so a + // later paste knows whether the clipboard still holds our layers. + let text = mirror.unwrap_or_else(|| format!("figcraft://clipboard/{}", ids.len())); app.services.clipboard_set_text(&text); app.ui.last_copy = Some(text); } @@ -239,6 +255,14 @@ fn export_frames_pdf(app: &mut FigApp) { }); } +/// The text of a selection made only of text layers, one layer per line (None for any other +/// selection, or when there is no text). +fn text_layers_text(doc: &figcraft_doc::Document, ids: &[figcraft_doc::NodeId]) -> Option { + let texts: Option> = ids.iter().map(|i| doc.node(*i).and_then(|n| n.kind.text()).map(|t| t.characters.as_str())).collect(); + texts.map(|t| t.join("\n")).filter(|t| !t.is_empty()) +} + +/// The layers as SVG text for other apps (None when the export fails). fn figcraft_svg_export(doc: &figcraft_doc::Document, ids: &[figcraft_doc::NodeId]) -> Option { let bytes = figcraft_engine::cmd::file::export_bytes(doc, ids, figcraft_doc::ExportFormat::Svg, 1.0).ok()?; String::from_utf8(bytes).ok() diff --git a/crates/ui-egui/src/padding_individual_tests.rs b/crates/ui-egui/src/padding_individual_tests.rs new file mode 100644 index 000000000..bc95886e9 --- /dev/null +++ b/crates/ui-egui/src/padding_individual_tests.rs @@ -0,0 +1,114 @@ +//! Individual padding in the Design panel, driven headless: the four side fields only +//! appear once the toggle opens, and typing in one sets that side alone. + +use egui::accesskit::Role; +use egui::{Event, Id, Key, Modifiers, Pos2, pos2}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +/// An auto layout frame with uniform padding 10, selected. +fn scene() -> (Ui, NodeId) { + let mut s = Session::new(); + let f = + NodeId( + s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 200, "props": {"layout": "horizontal", "padding": 10}})) + .unwrap()["id"] + .as_u64() + .unwrap(), + ); + s.execute("select.set", &json!({"ids": [f]})).unwrap(); + (Ui::new(s), f) +} + +/// The frame's padding as `[top, right, bottom, left]`. +fn padding(u: &Ui, f: NodeId) -> [f64; 4] { + u.app.session.doc().unwrap().node(f).unwrap().layout.clone().unwrap().padding +} + +/// A full press-and-release at `p`, then settling one frame. +fn click(u: &mut Ui, p: Pos2) { + u.button(p, true); + u.button(p, false); + u.frame(vec![]); +} + +/// A press-and-release of Enter, committing the focused number field. +fn enter(u: &mut Ui) { + u.frame(vec![ + Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }, + Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }, + ]); +} + +/// The middle of the toggle button named `label`, found in the AccessKit tree. +fn named_button(u: &mut Ui, label: &str) -> Pos2 { + u.ctx.enable_accesskit(); + u.frame(vec![]); + let out = u.frame_output(vec![]); + let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes; + let n = nodes.iter().map(|(_, n)| n).find(|n| n.role() == Role::Button && n.label() == Some(label)).expect(label); + let b = n.bounds().expect("bounds"); + pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) +} + +/// The side fields stay hidden until the toggle opens, then typing in one sets +/// that side alone in a single undo step. +#[test] +fn side_fields_show_only_once_the_toggle_opens_and_set_one_side() { + let (mut u, f) = scene(); + assert_eq!(padding(&u, f), [10.0; 4]); + // Shut: no per-side fields. + assert!(u.ctx.read_response(Id::new(("padding", 0)).with("field")).is_none(), "top field hidden until toggled"); + // Click the toggle: the four sides appear. + let t = named_button(&mut u, "Individual padding"); + click(&mut u, t); + let top = u.ctx.read_response(Id::new(("padding", 0)).with("field")).expect("top field shows").rect.center(); + assert!(u.ctx.read_response(Id::new(("padding", 3)).with("field")).is_some(), "left field shows"); + // Typing in the top field sets only the top, in one undo step. + let history = u.app.session.undo_depth().0; + click(&mut u, top); + u.frame(vec![Event::Text("8".into())]); + enter(&mut u); + u.frame(vec![]); + assert_eq!(padding(&u, f), [8.0, 10.0, 10.0, 10.0]); + assert_eq!(u.app.session.undo_depth().0, history + 1); + u.app.run("edit.undo", json!({})); + assert_eq!(padding(&u, f), [10.0; 4]); +} + +/// The middle of the shut pair field with accessible name `label`. +fn named_field(u: &mut Ui, label: &str) -> Pos2 { + u.ctx.enable_accesskit(); + u.frame(vec![]); + let out = u.frame_output(vec![]); + let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes; + let n = nodes.iter().map(|(_, n)| n).find(|n| n.role() != Role::Button && n.label() == Some(label)).expect(label); + let b = n.bounds().expect("bounds"); + pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) +} + +/// The shut pair fields read Mixed when their sides disagree and edit their own axis: horizontal +/// touches left and right only, vertical touches top and bottom only. +#[test] +fn shut_pair_fields_edit_their_own_axis() { + let (mut u, f) = scene(); + u.app.run("node.set", json!({"ids": [f], "props": {"paddingLeft": 16.0, "paddingRight": 54.0}})); + assert_eq!(padding(&u, f), [10.0, 54.0, 10.0, 16.0]); + let shown = crate::live_tests::texts(&crate::live_tests::flat(&u.frame_output(vec![]).shapes)); + assert!(shown.iter().any(|(t, _, _)| t == "Mixed"), "sides that disagree read Mixed: {shown:?}"); + let h = named_field(&mut u, "Horizontal padding"); + click(&mut u, h); + u.frame(vec![Event::Text("20".into())]); + enter(&mut u); + u.frame(vec![]); + assert_eq!(padding(&u, f), [10.0, 20.0, 10.0, 20.0]); + let v = named_field(&mut u, "Vertical padding"); + click(&mut u, v); + u.frame(vec![Event::Text("7".into())]); + enter(&mut u); + u.frame(vec![]); + assert_eq!(padding(&u, f), [7.0, 20.0, 7.0, 20.0]); +} diff --git a/crates/ui-egui/src/pending_open.rs b/crates/ui-egui/src/pending_open.rs index 9d19b9420..501367c0c 100644 --- a/crates/ui-egui/src/pending_open.rs +++ b/crates/ui-egui/src/pending_open.rs @@ -1,13 +1,17 @@ //! Files dropped on the home screen or the canvas open a frame later, behind a card that says what //! is happening: opening a large `.fig` blocks the UI thread for a while, and the card is on screen -//! before it does (rather than a window that freezes without a word). +//! before it does (rather than a window that freezes without a word). A file with no path to read +//! (every drop in the browser) waits behind the card until its contents arrive. + +use std::sync::Arc; +use std::task::{Context as TaskContext, Poll, Waker}; use figcraft_doc::NodeId; -use serde_json::Value; +use serde_json::{Value, json}; -use crate::FigApp; use crate::i18n::{tr, trf, trn}; use crate::theme::{Tokens, semibold}; +use crate::{FigApp, ReadFuture, RepaintWaker}; /// A dropped file waiting to be opened (`file.open`, a document of its own) or imported /// (`file.import`, into the current document). @@ -18,6 +22,8 @@ pub struct PendingOpen { pub command: &'static str, /// Canvas imports stay with the document and page that received the drop. pub target: Option, + /// The file's contents still on their way; they go into `params` as `data` once read. + pub read: Option, } pub struct ImportTarget { @@ -66,33 +72,83 @@ fn run_pending(app: &mut FigApp, pending: PendingOpen) -> Option { /// An import that has no destination left: nothing is imported, and the user is told why. fn cancel(app: &mut FigApp, message: String) { - app.ui.last_error = Some(message.clone()); - app.toast(message); + app.show_error(message); } +/// Space between files dropped together on the canvas. +const IMPORT_GAP: f64 = 20.0; + /// Frames the card is drawn before the files open: egui lays a new card out invisibly on its first /// frame, so the second one is the first the window shows. const FRAMES_BEFORE_OPENING: u8 = 2; -/// The card while files wait, then (once it has been on screen) open them. Called before the import -/// report, so a report for these files shows after them. +/// Polls the contents still being read: those that arrived go into their file's parameters, files +/// that couldn't be read are dropped with a message. Whether any are still on their way. +fn read_contents(app: &mut FigApp, ctx: &egui::Context) -> bool { + let waker = Waker::from(Arc::new(RepaintWaker(ctx.clone()))); + let mut cx = TaskContext::from_waker(&waker); + let mut reading = false; + let mut unread = Vec::new(); + app.ui.pending_opens.retain_mut(|pending| { + let Some(read) = pending.read.as_mut() else { return true }; + match read.as_mut().poll(&mut cx) { + Poll::Pending => { + reading = true; + true + } + Poll::Ready(Ok(bytes)) => { + use base64::Engine as _; + pending.params["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&bytes)); + pending.read = None; + true + } + Poll::Ready(Err(why)) => { + unread.push(why); + false + } + } + }); + for why in unread { + app.show_error(why); + } + reading +} + +/// The card while files wait, then (once it has been on screen and their contents are read) open +/// them. Called before the import report, so a report for these files shows after them. pub fn pending_opens(app: &mut FigApp, ctx: &egui::Context) { + let reading = read_contents(app, ctx); if app.ui.pending_opens.is_empty() { app.ui.pending_open_frames = 0; return; } ctx.request_repaint(); - if app.ui.pending_open_frames >= FRAMES_BEFORE_OPENING { + if app.ui.pending_open_frames >= FRAMES_BEFORE_OPENING && !reading { app.ui.pending_open_frames = 0; - for pending in std::mem::take(&mut app.ui.pending_opens) { + // Files dropped together land side by side: each starts where the previous one ends. + let mut next_x: Option = None; + for mut pending in std::mem::take(&mut app.ui.pending_opens) { let command = pending.command; - if run_pending(app, pending).is_some() && command == "file.open" { + let canvas = command == "file.import" && pending.params.get("x").is_some(); + if canvas && let Some(x) = next_x { + pending.params["x"] = json!(x); + } + let page = pending.target.as_ref().map(|t| (t.uid, t.page)); + let result = run_pending(app, pending); + if result.is_some() && command == "file.open" { app.ui.home = false; } + if canvas { + let right = result.as_ref().and_then(|r| r.get("id")?.as_u64()).and_then(|id| { + let doc = &app.session.docs.iter().find(|d| Some(d.uid) == page.map(|p| p.0))?.doc; + doc.world_bounds(NodeId(id)).map(|b| b.x1) + }); + next_x = right.map(|r| r + IMPORT_GAP).or(next_x); + } } return; } - app.ui.pending_open_frames += 1; + app.ui.pending_open_frames = app.ui.pending_open_frames.saturating_add(1); let title = match app.ui.pending_opens.first().map(|p| p.command) { Some("file.import") => { let name = app diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index 36eceb86b..9c0f1608d 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -2223,7 +2223,7 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome) } let on = (*act == BarAction::Comments && p.view.comments) || (*act == BarAction::Inspect && p.view.inspect); if on { - ui.painter().rect_filled(br, CornerRadius::same(5), ACCENT); + ui.painter().rect_filled(br, CornerRadius::same(5), t.accent); } else if resp.hovered() { ui.painter().rect_filled(br, CornerRadius::same(5), hover); } @@ -2234,9 +2234,9 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome) resp = resp.tip(tr("Inspect the layer under the pointer: size, colours, typography")); } if chrome.pane { - icons::paint(ui.painter(), br.shrink(5.0), *icon, t.icon); + icons::paint(ui.painter(), br.shrink(5.0), *icon, if on { t.accent_on } else { t.icon }); } else { - ui.painter().text(br.center(), Align2::CENTER_CENTER, shown, regular(11.5), text); + ui.painter().text(br.center(), Align2::CENTER_CENTER, shown, regular(11.5), if on { t.accent_on } else { text }); } if *act == BarAction::Options { let menu = crate::right::menu_popup(&resp).width(220.0).show(|ui| options_menu(p, ui, device, settings.responsive, now)); diff --git a/crates/ui-egui/src/proto_canvas.rs b/crates/ui-egui/src/proto_canvas.rs index 1187944df..956523ee9 100644 --- a/crates/ui-egui/src/proto_canvas.rs +++ b/crates/ui-egui/src/proto_canvas.rs @@ -8,7 +8,6 @@ use egui::{Color32, CornerRadius, Id, Pos2, Rect, Shape, Stroke, StrokeKind, pos use figcraft_doc::{Document, NodeId}; use serde_json::{Value, json}; -use crate::theme::ACCENT; use crate::{FigApp, RightTab}; const HANDLE_R: f32 = 7.0; @@ -140,7 +139,7 @@ fn arrow(painter: &egui::Painter, pts: &[Pos2; 4], col: Color32) { } fn plus_handle(painter: &egui::Painter, c: Pos2, hot: bool) { - painter.circle_filled(c, HANDLE_R + if hot { 1.0 } else { 0.0 }, ACCENT); + painter.circle_filled(c, HANDLE_R + if hot { 1.0 } else { 0.0 }, crate::theme::canvas_accent(painter.ctx())); painter.circle_stroke(c, HANDLE_R + if hot { 1.0 } else { 0.0 }, Stroke::new(1.5, Color32::WHITE)); painter.line_segment([c - vec2(3.5, 0.0), c + vec2(3.5, 0.0)], Stroke::new(1.5, Color32::WHITE)); painter.line_segment([c - vec2(0.0, 3.5), c + vec2(0.0, 3.5)], Stroke::new(1.5, Color32::WHITE)); @@ -161,7 +160,11 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { let selected = sel.contains(&nd.source); let is_open = open == Some((nd.source, nd.index)); let near = dragging.is_none() && hover.is_some_and(|q| bezier_distance(&nd.pts, q) < 5.0); - let col = if selected || is_open || near { ACCENT } else { ACCENT.gamma_multiply(0.55) }; + let col = if selected || is_open || near { + crate::theme::canvas_accent(painter.ctx()) + } else { + crate::theme::canvas_accent(painter.ctx()).gamma_multiply(0.55) + }; let w = if is_open || near { 2.5 } else if selected { @@ -177,7 +180,12 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { .and_then(|it| figcraft_doc::proto::walk(&it.actions).find_map(|a| a.dest())) && let Some(r) = screen_rect(app, doc, dest) { - painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.0, ACCENT.gamma_multiply(0.7)), StrokeKind::Outside); + painter.rect_stroke( + r, + CornerRadius::ZERO, + Stroke::new(1.0, crate::theme::canvas_accent(painter.ctx()).gamma_multiply(0.7)), + StrokeKind::Outside, + ); } painter.add(egui::epaint::CubicBezierShape::from_points_stroke(nd.pts, false, Color32::TRANSPARENT, Stroke::new(w, col))); painter.circle_filled(nd.pts[0], 3.5, col); @@ -190,12 +198,17 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { && doc.top_level_frame(d.source) != Some(f) && let Some(r) = screen_rect(app, doc, f) { - painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(2.0, ACCENT), StrokeKind::Outside); + painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(2.0, crate::theme::canvas_accent(painter.ctx())), StrokeKind::Outside); } let dx = ((d.pos.x - d.from.x).abs() * 0.5).max(30.0) * if d.pos.x >= d.from.x { 1.0 } else { -1.0 }; let pts = [d.from, d.from + vec2(dx, 0.0), d.pos - vec2(dx, 0.0), d.pos]; - painter.add(egui::epaint::CubicBezierShape::from_points_stroke(pts, false, Color32::TRANSPARENT, Stroke::new(2.0, ACCENT))); - arrow(painter, &pts, ACCENT); + painter.add(egui::epaint::CubicBezierShape::from_points_stroke( + pts, + false, + Color32::TRANSPARENT, + Stroke::new(2.0, crate::theme::canvas_accent(painter.ctx())), + )); + arrow(painter, &pts, crate::theme::canvas_accent(painter.ctx())); plus_handle(painter, d.from, true); } None => { @@ -282,6 +295,26 @@ pub fn input(app: &mut FigApp, ui: &egui::Ui, resp: &egui::Response) -> bool { mod tests { use super::*; + /// A press on a connection handle takes the keyboard from the Pages list, as a press anywhere + /// else on the canvas does, so a later Delete can't take the page (#859). + #[test] + fn a_press_on_a_connection_handle_ends_the_pages_list_focus() { + let mut s = figcraft_engine::Session::new(); + let a = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 150})).unwrap(); + let a = crate::present_tests::id(&a); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let mut u = crate::present_tests::Ui::new(s); + u.app.ui.right_tab = RightTab::Prototype; + u.run("view.zoomSelection", json!({})); + u.frames(2, Vec::new); + u.app.ui.pages_focus = u.app.session.ds().ok().map(|d| d.uid); + let doc = u.app.session.doc().unwrap().clone(); + let (_, at) = *handles(&u.app, &doc, &[a]).first().unwrap(); + u.button(at, true); + assert!(u.app.ui.pages_focus.is_none()); + u.button(at, false); + } + #[test] fn curves_leave_the_facing_side() { let a = Rect::from_min_size(pos2(0.0, 0.0), vec2(50.0, 20.0)); diff --git a/crates/ui-egui/src/proto_editor_tests.rs b/crates/ui-egui/src/proto_editor_tests.rs index c63e62c60..80d49616d 100644 --- a/crates/ui-egui/src/proto_editor_tests.rs +++ b/crates/ui-egui/src/proto_editor_tests.rs @@ -304,3 +304,26 @@ fn escape_closes_prototype_editor_without_deselecting_layer() { u.key(pos2(100.0, 100.0), egui::Key::Escape); assert!(u.app.session.selection().is_empty(), "selection cleared on third escape"); } + +/// After a click in the Pages list, Delete that removes an interaction ends the list's hold on the +/// keyboard: with the layer deselected afterwards, Delete doesn't take the page (#859). +#[test] +fn deleting_an_interaction_ends_the_pages_list_focus() { + let (s, _a, _b, btn) = setup(); + let mut u = Ui::new(s); + let first = u.app.session.page().unwrap(); + u.run("page.add", json!({})); + u.run("page.select", json!({"id": first})); + // As after a click on the page's row (layers_keys_tests clicks the real row). + u.app.ui.pages_focus = u.app.session.ds().ok().map(|d| d.uid); + u.app.ui.right_tab = crate::RightTab::Prototype; + u.run("select.set", json!({"ids": [btn]})); + crate::proto_panel::open_editor(&u.ctx, Some((btn, 0))); + u.frames(1, Vec::new); + u.key(pos2(100.0, 100.0), egui::Key::Delete); + assert_eq!(u.app.session.doc().unwrap().node(btn).unwrap().interactions.len(), 0); + assert!(u.app.ui.pages_focus.is_none()); + u.run("select.none", json!({})); + u.key(pos2(100.0, 100.0), egui::Key::Delete); + assert_eq!(u.app.session.doc().unwrap().pages.len(), 2, "the page stays"); +} diff --git a/crates/ui-egui/src/proto_panel.rs b/crates/ui-egui/src/proto_panel.rs index 85af66c59..747dbaf6a 100644 --- a/crates/ui-egui/src/proto_panel.rs +++ b/crates/ui-egui/src/proto_panel.rs @@ -53,6 +53,8 @@ pub fn delete_selected_interaction(app: &mut FigApp, ctx: &egui::Context) -> boo return false; } open_editor(ctx, None); + // Delete went to the interaction, so a later one doesn't go to the Pages list. + app.ui.pages_focus = None; let ids = targets_for(app, node); app.run("proto.interaction.remove", json!({"ids": ids, "index": index})); true @@ -763,7 +765,7 @@ fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Inte let id = capture_id().with("field"); ui.painter().rect_filled(r, CornerRadius::same(5), t.field); if cap { - ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(1.0, t.accent), StrokeKind::Inside); + ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(1.0, ACCENT), StrokeKind::Inside); ui.memory_mut(|m| m.request_focus(id)); let _ = ui.interact(r, id, Sense::focusable_noninteractive()); let pressed = ui.input(|i| { @@ -924,7 +926,20 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: let w = ((ui.available_width() - 6.0) / 2.0).max(36.0); for (i, (name, key)) in [("X", "proto_ov_x"), ("Y", "proto_ov_y")].into_iter().enumerate() { let shown = offset.and_then(|o| o.get(i).copied()); - let Some(e) = widgets::number_field_bindable(ui, slot.id(key), Lead::Text(name), shown, "", w, 1.0, None, tr("Auto")).0 + // "Auto" is no offset (right below the layer), not 0: the word isn't taken + // as a value here. + let Some(e) = widgets::number_field_bindable( + ui, + slot.id(key), + Lead::Text(name), + shown, + "", + w, + 1.0, + None, + widgets::Empty::Text(tr("Auto")), + ) + .0 else { continue; }; @@ -1048,7 +1063,7 @@ fn animation_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &In let w = ui.available_width().max(200.0); let (r, rr) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click()); if rr.hovered() { - ui.painter().rect_filled(r, CornerRadius::same(5), ACCENT); + ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); } let col = if rr.hovered() { Color32::WHITE } else { t.text }; curve(ui.painter(), Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 4.0), vec2(28.0, 18.0)), *e, col, None); diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 85cc9ec73..b40506d4c 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -11,7 +11,7 @@ use crate::i18n::{tr, trf, trn}; use crate::icons::{self, Icon}; use crate::list_drag::{ListDrag, move_flags, remove_flags}; use crate::style_picker::{self, StyleSlot}; -use crate::theme::{ACCENT, Tokens, regular, semibold}; +use crate::theme::{Tokens, regular, semibold}; use crate::widgets::{self, Edit, FIELD_H, FieldEdit, Lead, PAD}; use crate::{FigApp, RightTab}; @@ -84,12 +84,12 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { // Top bar: people (you and the others in a live session), present, share. let (top, _) = ui.allocate_exact_size(vec2(w, 48.0), Sense::hover()); // As wide as its label needs (longer in some languages). - let label = ui.painter().layout_no_wrap(tr("Share").to_string(), semibold(11.5), Color32::WHITE); + let label = ui.painter().layout_no_wrap(tr("Share").to_string(), semibold(11.5), t.accent_on); let sw = (label.size().x + 24.0).max(56.0); let share = Rect::from_min_size(pos2(top.max.x - 14.0 - sw, top.center().y - 16.0), vec2(sw, 32.0)); let sresp = ui.interact(share, ui.id().with("share"), Sense::click()).tip(tr("Share: live session, export")); - ui.painter().rect_filled(share, CornerRadius::same(6), if sresp.hovered() { t.accent_hover } else { ACCENT }); - ui.painter().galley(share.center() - label.size() / 2.0, label, Color32::WHITE); + ui.painter().rect_filled(share, CornerRadius::same(6), if sresp.hovered() { t.accent_hover } else { t.accent }); + ui.painter().galley(share.center() - label.size() / 2.0, label, t.accent_on); crate::live::share_popover(app, &sresp); // Play presents; its chevron opens the play menu (present, or preview in a pane). let play = Rect::from_min_size(pos2(share.min.x - 50.0, top.center().y - 14.0), vec2(42.0, 28.0)); @@ -284,13 +284,13 @@ pub fn menu_row(ui: &mut Ui, label: &str, shortcut: &str, art_w: f32, art: impl a11y::own_focus(&resp); let hovered = resp.hovered() || resp.has_focus(); if hovered { - ui.painter().rect_filled(r, CornerRadius::same(5), ACCENT); + ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); } - let col = if hovered { Color32::WHITE } else { t.text }; + let col = t.menu_text; ui.painter().galley(pos2(r.min.x + MENU_PAD, r.center().y - text.size().y / 2.0), text, col); if let Some(k) = keys { let at = pos2(r.max.x - MENU_PAD - k.size().x, r.center().y - k.size().y / 2.0); - ui.painter().galley(at, k, if hovered { Color32::WHITE } else { t.text_secondary }); + ui.painter().galley(at, k, if hovered { t.menu_text } else { t.menu_dim }); } art(ui.painter(), r, col); resp @@ -964,8 +964,8 @@ fn variable_modes(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) // The collection's name, then a wider mode picker ("Auto (Light)") across both field columns. let col = widgets::col_width(ui.available_width()); let (name_w, pick_w) = ((col * 0.7).round(), (col * 1.3).round()); - // Auto resolves from the parent of the first selected layer. - let parent_modes = sel.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent).map(|p| doc.modes_for(p)).unwrap_or_default(); + // Auto resolves as for the first selected layer: from its parent, or an instance's main. + let parent_modes = sel.first().map(|i| doc.inherited_modes(*i)).unwrap_or_default(); let mut group = None; for r in &rows { let c = r.coll; @@ -2064,8 +2064,13 @@ fn grid_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], list: &[LayoutGr // 0 is Auto for fixed-size columns (rows); a stretched grid has at least one. let count = (!auto_count(g)).then(|| f64::from(g.count.max(1))); let key = format!("layoutGrids/{i}/count"); - let e = - crate::variable_ui::number_field_or(app, ui, ids, &key, Id::new(("grid_field", "count", i)), count, col, tr("Auto"), Lead::None, ""); + let id = Id::new(("grid_field", "count", i)); + // A stretched grid has at least one band, so 0 (Auto) isn't a count it can take. + let e = if g.align == GridAlign::Stretch { + crate::variable_ui::number_field_or(app, ui, ids, &key, id, count, col, "", Lead::None, "") + } else { + crate::variable_ui::number_field_auto(app, ui, ids, &key, id, count, col, Lead::None, "") + }; app.field_run(e, "node.set", |v| { set(&|x| x.count = if x.align == GridAlign::Stretch { v.round().clamp(1.0, 1000.0) } else { v.round().clamp(0.0, 1000.0) } as u32) }); @@ -2098,11 +2103,17 @@ fn grid_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], list: &[LayoutGr let span = if cols { tr("Width") } else { tr("Height") }; match g.align { GridAlign::Stretch => { - widgets::sublabels(ui, tr("Margin"), Some(tr("Gutter"))); + widgets::sublabels(ui, span, Some(tr("Margin"))); row(ui, 32.0, |ui| { + // The frame, divided by the count: shown as Auto and not editable. + ui.add_enabled_ui(false, |ui| { + let auto = widgets::Empty::Text(tr("Auto")); + widgets::number_field_bindable(ui, Id::new(("grid_field", "size", i)), Lead::Named(span), None, "", col, 1.0, None, auto) + }); field(app, ui, "offset", g.offset, &offset); - field(app, ui, "gutter", g.gutter, &gutter); }); + widgets::sublabels(ui, tr("Gutter"), None); + row(ui, 32.0, |ui| field(app, ui, "gutter", g.gutter, &gutter)); } GridAlign::Center => { widgets::sublabels(ui, span, Some(tr("Gutter"))); diff --git a/crates/ui-egui/src/selection_spacing.rs b/crates/ui-egui/src/selection_spacing.rs index 5110be1c2..3a39955ac 100644 --- a/crates/ui-egui/src/selection_spacing.rs +++ b/crates/ui-egui/src/selection_spacing.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use egui::{CursorIcon, Id, Pos2, Rect, Stroke, Ui, vec2}; use figcraft_doc::{Document, NodeId}; +use figcraft_engine::selection_reorder::ReorderLayout; use figcraft_engine::selection_spacing::{Axis, SelectionSpacing, selection_spacing}; use serde_json::json; @@ -20,6 +21,7 @@ struct LayoutCache { ids: Vec, frame: u64, layout: Arc, + reorder: Option>, } fn layout(app: &FigApp, ctx: &egui::Context, ids: &[NodeId]) -> Arc { @@ -41,25 +43,68 @@ fn layout(app: &FigApp, ctx: &egui::Context, ids: &[NodeId]) -> Arc Option> { + layout(app, ctx, ids); + ctx.data(|d| d.get_temp::(Id::new("selection_spacing_layout"))).and_then(|c| c.reorder) +} + fn drag_id() -> Id { Id::new("selection_spacing_drag") } +/// The reorder ring of a layer with world bounds `b`: its centre on screen and hit radius. The +/// radius is 8 px, at most a quarter of the layer's smaller side on screen, so most of a small +/// layer still drags the selection; a layer under 16 px on screen has no ring. +fn ring(app: &FigApp, b: figcraft_geom::Rect) -> Option<(Pos2, f32)> { + let zoom = app.session.ds().map_or(1.0, |d| d.view.zoom); + let side = ((b.x1 - b.x0).min(b.y1 - b.y0) * zoom) as f32; + (side >= 16.0).then(|| (app.to_screen((b.x0 + b.x1) * 0.5, (b.y0 + b.y1) * 0.5), (side / 4.0).min(8.0))) +} + +#[derive(Clone)] +enum Action { + Spacing { axis: Axis, value: f64, last_value: Option }, + Reorder { layout: Arc, source: NodeId, target: NodeId, swap: bool }, +} + #[derive(Clone)] struct Drag { ids: Vec, - axis: Axis, + action: Action, start: Pos2, - value: f64, uid: u64, page: NodeId, moved: bool, - last_value: Option, +} + +fn start_drag(app: &mut FigApp, ctx: &egui::Context, ids: Vec, p: Pos2, action: Action) { + let label = match action { + Action::Spacing { .. } => tr("Spacing"), + Action::Reorder { .. } => tr("Reorder selection"), + }; + if app.session.begin_interaction(label).is_ok() + && let Ok(ds) = app.session.ds() + { + let drag = Drag { ids, action, start: p, uid: ds.uid, page: ds.page, moved: false }; + app.ui.layers_focus = false; + app.ui.pages_focus = None; + app.ui.key_box = None; + ctx.memory_mut(|m| { + if let Some(f) = m.focused() { + m.surrender_focus(f); + } + }); + ctx.data_mut(|d| d.insert_temp(drag_id(), drag)); + } } pub fn panel(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId]) { @@ -146,7 +191,7 @@ pub fn cancel(app: &mut FigApp, ctx: &egui::Context) -> bool { /// Retire handles when another document, page, tool or editing context takes over. pub fn sync(app: &mut FigApp, ctx: &egui::Context) { if let Some(drag) = ctx.data(|d| d.get_temp::(drag_id())) { - let same = app.session.ds().is_ok_and(|d| d.uid == drag.uid && d.page == drag.page && d.selection == drag.ids); + let same = app.session.ds().is_ok_and(|d| d.uid == drag.uid && d.page == drag.page && d.selection == drag.ids && d.interaction.is_some()); if !same || !available(app) { cancel(app, ctx); } @@ -157,39 +202,77 @@ pub fn sync(app: &mut FigApp, ctx: &egui::Context) { pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Option, hovered: bool, space: bool) -> bool { let dragging = ctx.data(|d| d.get_temp::(drag_id())); if let Some(mut drag) = dragging { - let same = app.session.ds().is_ok_and(|d| d.uid == drag.uid && d.page == drag.page && d.selection == drag.ids); + let same = app.session.ds().is_ok_and(|d| d.uid == drag.uid && d.page == drag.page && d.selection == drag.ids && d.interaction.is_some()); if !same { cancel(app, ctx); return true; } let down = ctx.input(|i| i.pointer.primary_down()); if let Some(p) = pos { - let zoom = app.session.ds().map_or(1.0, |d| d.view.zoom).max(1e-6); - let distance = if drag.axis.vertical { p.y - drag.start.y } else { p.x - drag.start.x }; - drag.moved |= distance.abs() >= 3.0; - if drag.moved { - let value = ctx - .input(|i| { - let quantum = if i.modifiers.alt { - 0.1 - } else if i.modifiers.shift { - 10.0 - } else { - 1.0 - }; - ((drag.value + f64::from(distance) / zoom) / quantum).round() * quantum - }) - .clamp(-1e6, 1e6); - let key = if drag.axis.vertical { "vertical" } else { "horizontal" }; - if drag.last_value != Some(value) { - match app.session.execute("arrange.spacing", &json!({"ids":drag.ids,key:value,"fromGesture":true})) { - Ok(_) => drag.last_value = Some(value), - Err(e) => app.toast(e.to_string()), + match &mut drag.action { + Action::Spacing { axis, value: original, last_value } => { + let zoom = app.session.ds().map_or(1.0, |d| d.view.zoom).max(1e-6); + let distance = if axis.vertical { p.y - drag.start.y } else { p.x - drag.start.x }; + drag.moved |= distance.abs() >= 3.0; + if drag.moved { + let value = ctx + .input(|i| { + let quantum = if i.modifiers.alt { + 0.1 + } else if i.modifiers.shift { + 10.0 + } else { + 1.0 + }; + ((*original + f64::from(distance) / zoom) / quantum).round() * quantum + }) + .clamp(-1e6, 1e6); + let key = if axis.vertical { "vertical" } else { "horizontal" }; + if *last_value != Some(value) { + match app.session.execute("arrange.spacing", &json!({"ids":drag.ids,key:value,"fromGesture":true})) { + Ok(_) => *last_value = Some(value), + Err(e) => app.toast(e.to_string()), + } + } } } + Action::Reorder { layout, source, target, swap } if app.canvas.rect.contains(p) => { + drag.moved |= p.distance(drag.start) >= 3.0; + // Hit original slots, never the moving preview: otherwise a stationary + // pointer could alternate between two permutations on successive frames. + let nearest = layout + .slots + .iter() + .min_by(|a, b| { + let center = |b: figcraft_geom::Rect| app.to_screen((b.x0 + b.x1) * 0.5, (b.y0 + b.y1) * 0.5); + center(a.1).distance_sq(p).total_cmp(¢er(b.1).distance_sq(p)) + }) + .map(|(id, _)| *id) + .unwrap_or(*source); + let swapping = ctx.input(|i| i.modifiers.command); + if drag.moved && (nearest != *target || swapping != *swap) { + match app + .session + .execute("arrange.reorder", &json!({"ids":drag.ids,"source":source,"target":nearest,"swap":swapping,"fromGesture":true})) + { + Ok(_) => { + *target = nearest; + *swap = swapping; + } + Err(e) => app.toast(e.to_string()), + } + } + } + // A release over a panel or outside the window commits the last canvas + // preview; the release's sentinel coordinates must not select a new slot. + Action::Reorder { .. } => {} } } - ctx.set_cursor_icon(if drag.axis.vertical { CursorIcon::ResizeVertical } else { CursorIcon::ResizeHorizontal }); + ctx.set_cursor_icon(match &drag.action { + Action::Spacing { axis, .. } if axis.vertical => CursorIcon::ResizeVertical, + Action::Spacing { .. } => CursorIcon::ResizeHorizontal, + Action::Reorder { .. } => CursorIcon::Grabbing, + }); if !down { let _ = app.session.commit_interaction(); ctx.data_mut(|d| d.remove::(drag_id())); @@ -198,11 +281,26 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Option, hovered: } return true; } - if !hovered || space || !available(app) || ctx.input(|i| i.modifiers.command) { + if !hovered || space || !available(app) { return false; } let Some(p) = pos else { return false }; let ids = app.session.selection(); + if let Some(reorder) = reorder_layout(app, ctx, &ids) { + for (id, b) in &reorder.slots { + if ring(app, *b).is_some_and(|(center, r)| center.distance(p) <= r) { + ctx.set_cursor_icon(CursorIcon::Grab); + if ctx.input(|i| i.pointer.primary_pressed()) { + let source = *id; + start_drag(app, ctx, ids, p, Action::Reorder { layout: reorder, source, target: source, swap: false }); + } + return true; + } + } + } + if ctx.input(|i| i.modifiers.command) { + return false; + } let layout = layout(app, ctx, &ids); for axis in [layout.horizontal.as_ref(), layout.vertical.as_ref()].into_iter().flatten() { for h in handles(app, axis) { @@ -210,20 +308,8 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Option, hovered: continue; } ctx.set_cursor_icon(if axis.vertical { CursorIcon::ResizeVertical } else { CursorIcon::ResizeHorizontal }); - if ctx.input(|i| i.pointer.primary_pressed()) - && app.session.begin_interaction(tr("Spacing")).is_ok() - && let Ok(ds) = app.session.ds() - { - let drag = - Drag { ids, axis: axis.clone(), start: p, value: h.value, uid: ds.uid, page: ds.page, moved: false, last_value: axis.value }; - app.ui.layers_focus = false; - app.ui.key_box = None; - ctx.memory_mut(|m| { - if let Some(f) = m.focused() { - m.surrender_focus(f); - } - }); - ctx.data_mut(|d| d.insert_temp(drag_id(), drag)); + if ctx.input(|i| i.pointer.primary_pressed()) { + start_drag(app, ctx, ids, p, Action::Spacing { axis: axis.clone(), value: h.value, last_value: axis.value }); } return true; } @@ -244,8 +330,32 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) { if drag.is_none() && !hover.is_some_and(|p| rect.contains(p)) { return; } - let axes = if let Some(d) = &drag { - vec![d.axis.clone()] + let reorder = match drag.as_ref().map(|d| &d.action) { + Some(Action::Reorder { layout, .. }) => Some(layout.clone()), + Some(Action::Spacing { .. }) => None, + None => reorder_layout(app, ctx, &ds.selection), + }; + if let Some(layout) = reorder { + for (id, _) in &layout.slots { + let Some((center, r)) = ds.doc.world_bounds(*id).and_then(|b| ring(app, b)) else { continue }; + let hot = hover.is_some_and(|p| center.distance(p) <= r); + let active = matches!(drag.as_ref().map(|d| &d.action), Some(Action::Reorder { source, .. }) if source == id); + if active { + painter.circle_filled(center, 4.5, PINK); + } else { + painter.circle_stroke(center, 4.5, Stroke::new(if hot { 2.5 } else { 1.5 }, PINK)); + } + } + } + if let Some(Drag { action: Action::Reorder { layout, target, .. }, moved: true, .. }) = &drag { + if let Some((_, b)) = layout.slots.iter().find(|(id, _)| id == target) { + let rect = Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1)).expand(3.0); + painter.rect_stroke(rect, 0, Stroke::new(2.0, crate::theme::ACCENT), egui::StrokeKind::Outside); + } + return; + } + let axes = if let Some(Drag { action: Action::Spacing { axis, .. }, .. }) = &drag { + vec![axis.clone()] } else { let layout = layout(app, ctx, &ds.selection); [layout.horizontal.clone(), layout.vertical.clone()].into_iter().flatten().collect() diff --git a/crates/ui-egui/src/selection_spacing_tests.rs b/crates/ui-egui/src/selection_spacing_tests.rs index 453bd60dc..2ee44742f 100644 --- a/crates/ui-egui/src/selection_spacing_tests.rs +++ b/crates/ui-egui/src/selection_spacing_tests.rs @@ -205,3 +205,157 @@ fn stationary_gap_drag_does_not_change_the_document_again() { u.button(p + vec2(20.2 * z, 0.), false); assert_eq!(xy(&u, ids[1]), (200., 100.)); } + +#[test] +fn reorder_ring_drag_inserts_across_grid_rows_as_one_undo_step() { + let (mut u, ids) = scene(true); + let before = u.app.session.undo_depth().0; + let p = u.app.to_screen(125., 130.); + let target = u.app.to_screen(205., 230.); + let output = u.frame_output(vec![Event::PointerMoved(p)]); + assert_eq!(output.platform_output.cursor_icon, egui::CursorIcon::Grab); + drag(&mut u, p, target - p, true); + assert_eq!(xy(&u, ids[0]), (180., 200.)); + assert_eq!(xy(&u, ids[3]), (260., 100.)); + assert_eq!(u.app.session.selection(), ids); + assert_eq!(u.app.session.undo_depth().0, before + 1); + u.app.run("edit.undo", json!({})); + assert_eq!(xy(&u, ids[0]), (100., 100.)); + u.app.run("edit.redo", json!({})); + assert_eq!(xy(&u, ids[0]), (180., 200.)); +} + +#[test] +fn reorder_ring_escape_click_and_returning_home_make_no_undo_step() { + for action in [0, 1, 2] { + let (mut u, ids) = scene(false); + let history = u.app.session.undo_depth().0; + let before = u.app.session.doc().unwrap().clone(); + let p = u.app.to_screen(125., 130.); + let target = u.app.to_screen(285., 130.); + u.frame(vec![Event::PointerMoved(p)]); + if action == 0 { + u.button(p, true); + } else { + drag(&mut u, p, target - p, false); + assert_eq!(xy(&u, ids[0]), (260., 100.)); + if action == 1 { + key(&mut u, Key::Escape); + } else { + u.frame(vec![Event::PointerMoved(p)]); + } + } + u.button(p, false); + u.frame(vec![]); + assert_eq!(u.app.session.doc().unwrap(), &before); + assert_eq!(u.app.session.undo_depth().0, history); + assert_eq!(u.app.session.selection(), ids); + assert!(u.app.session.ds().unwrap().interaction.is_none()); + } +} + +#[test] +fn command_drag_swaps_and_a_stationary_pointer_keeps_the_preview() { + let (mut u, ids) = scene(true); + let p = u.app.to_screen(125., 130.); + let target = u.app.to_screen(205., 230.); + // The modifier can already be down when the handle is pressed. + let m = Modifiers::COMMAND; + u.frame_with(vec![Event::PointerMoved(p)], m); + u.frame_with(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: m }], m); + u.frame_with(vec![Event::PointerMoved(target)], m); + assert_eq!(xy(&u, ids[0]), (180., 200.)); + assert_eq!(xy(&u, ids[4]), (100., 100.)); + assert_eq!(xy(&u, ids[1]), (180., 100.)); + let snapshot = u.app.session.ds().unwrap().doc.clone(); + for _ in 0..5 { + u.frame_with(vec![], m); + } + assert!(std::sync::Arc::ptr_eq(&snapshot, &u.app.session.ds().unwrap().doc)); + // Changing modifiers without moving the pointer switches to insertion. + u.frame(vec![]); + assert_eq!(xy(&u, ids[1]), (100., 100.)); + assert_eq!(xy(&u, ids[4]), (100., 200.)); + u.button(target, false); +} + +#[test] +fn switching_documents_during_reordering_rolls_back_its_owner() { + let (mut u, ids) = scene(false); + let before = u.app.session.doc().unwrap().clone(); + let p = u.app.to_screen(125., 130.); + let target = u.app.to_screen(285., 130.); + drag(&mut u, p, target - p, false); + u.app.run("file.new", json!({})); + u.app.session.begin_interaction("Unrelated").unwrap(); + u.frame(vec![]); + assert!(u.app.session.ds().unwrap().interaction.is_some()); + u.app.session.active = 0; + assert_eq!(u.app.session.doc().unwrap(), &before); + assert_eq!(u.app.session.selection(), ids); +} + +#[test] +fn reorder_release_outside_window_commits_and_undo_while_dragging_cancels() { + for undo in [false, true] { + let (mut u, ids) = scene(false); + let history = u.app.session.undo_depth().0; + let p = u.app.to_screen(125., 130.); + let target = u.app.to_screen(285., 130.); + drag(&mut u, p, target - p, false); + if undo { + let m = Modifiers::COMMAND; + u.frame_with(vec![Event::Key { key: Key::Z, physical_key: None, pressed: true, repeat: false, modifiers: m }], m); + } + u.frame(vec![Event::PointerGone]); + u.button(Pos2::new(-1., -1.), false); + u.frame(vec![]); + assert_eq!(u.app.session.undo_depth().0, history + usize::from(!undo)); + assert_eq!(xy(&u, ids[0]), if undo { (100., 100.) } else { (260., 100.) }); + assert!(u.app.session.ds().unwrap().interaction.is_none()); + } +} + +#[test] +fn an_externally_finished_reorder_cannot_resume_on_later_pointer_motion() { + let (mut u, ids) = scene(false); + let p = u.app.to_screen(125., 130.); + let target = u.app.to_screen(285., 130.); + drag(&mut u, p, target - p, false); + u.app.session.commit_interaction().unwrap(); + let history = u.app.session.undo_depth().0; + let committed = u.app.session.doc().unwrap().clone(); + let middle = u.app.to_screen(205., 130.); + u.frame(vec![Event::PointerMoved(middle)]); + u.button(middle, false); + u.frame(vec![]); + assert_eq!(u.app.session.doc().unwrap(), &committed); + assert_eq!(u.app.session.undo_depth().0, history); + assert_eq!(xy(&u, ids[0]), (260., 100.)); +} + +#[test] +fn small_layers_on_screen_have_no_reorder_ring_and_drag_the_selection_from_their_middle() { + let mut u = Ui::new(Session::new()); + let z = u.app.session.ds().unwrap().view.zoom; + // 12 px squares 36 px apart on screen: a regular row, too small for rings. + let ids: Vec = (0..3) + .map(|col| { + let p = json!({"x": (100. + f64::from(col) * 36.) / z, "y": 100. / z, "width": 12. / z, "height": 12. / z}); + NodeId(u.app.session.execute("shape.rectangle", &p).unwrap()["id"].as_u64().unwrap()) + }) + .collect(); + u.app.session.execute("select.set", &json!({"ids": ids})).unwrap(); + let before: Vec<_> = ids.iter().map(|id| xy(&u, *id)).collect(); + let p = u.app.to_screen(before[0].0 + 6. / z, before[0].1 + 6. / z); + let output = u.frame_output(vec![Event::PointerMoved(p)]); + assert_ne!(output.platform_output.cursor_icon, egui::CursorIcon::Grab); + drag(&mut u, p, vec2(0., 60.), true); + let after: Vec<_> = ids.iter().map(|id| xy(&u, *id)).collect(); + assert!(after[0].1 > before[0].1, "the selection moved: {before:?} -> {after:?}"); + assert!( + before.iter().zip(&after).all(|(b, a)| (a.0 - b.0).abs() < 1e-6 && (a.1 - b.1 - (after[0].1 - before[0].1)).abs() < 1e-6), + "all together, in order" + ); + assert_eq!(u.app.session.selection(), ids); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 1c0f3d49f..c6d04d302 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -284,7 +284,8 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option, m: Modifiers, re if app.ui.layers_focus && !m.command && !m.alt && crate::layers::key_nav(app, key, m.shift) { return true; } - // Arrow nudges; with nothing selected the arrows pan the view. + // Arrow nudges, Alt held too (measuring stays on while the selection moves); with nothing + // selected the arrows pan the view. let (dx, dy) = match key { Key::ArrowLeft => (-1.0, 0.0), Key::ArrowRight => (1.0, 0.0), @@ -292,7 +293,7 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option, m: Modifiers, re Key::ArrowDown => (0.0, 1.0), _ => (0.0, 0.0), }; - if (dx, dy) != (0.0, 0.0) && !m.alt { + if (dx, dy) != (0.0, 0.0) { if app.session.selection().is_empty() { app.run("view.pan", json!({"dx": dx, "dy": dy, "big": m.shift})); } else if m.command { diff --git a/crates/ui-egui/src/style_picker.rs b/crates/ui-egui/src/style_picker.rs index cb66d7ab8..278935f62 100644 --- a/crates/ui-egui/src/style_picker.rs +++ b/crates/ui-egui/src/style_picker.rs @@ -489,7 +489,7 @@ fn style_grid(ui: &mut Ui, list: &[(&Style, Key)], applied: Option<&Key>, editab None => preview(ui, r, s, StyleSlot::Fill), } if applied == Some(key) { - ui.painter().rect_stroke(r.expand(2.0), CornerRadius::same(5), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); + ui.painter().rect_stroke(r.expand(2.0), CornerRadius::same(5), Stroke::new(2.0, crate::theme::ACCENT), egui::StrokeKind::Inside); } else if resp.hovered() && !text { ui.painter().rect_stroke(r.expand(2.0), CornerRadius::same(4), Stroke::new(1.0, t.menu_text), egui::StrokeKind::Inside); } diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index 381b09dd1..c10edcfd8 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -12,22 +12,93 @@ use crate::theme::{Tokens, regular}; use crate::{FigApp, TitleBar}; pub const HEIGHT: f32 = 40.0; +const MIN_TAB_WIDTH: f32 = 96.0; +const OVERFLOW_WIDTH: f32 = 32.0; +const OPEN_TIME: f64 = 0.18; + +/// Which tabs were open last frame and when the newly opened ones appeared. It is updated in place +/// in egui's memory; it only allocates when the set of tabs changes. +#[derive(Clone, Default)] +struct TabMotion { + initialized: bool, + known: Vec, + opening: Vec<(u64, f64)>, +} + +impl TabMotion { + /// Track tabs added since the previous frame and expire their animations. + fn begin_frame(&mut self, current: impl Iterator + Clone, now: f64, reduce_motion: bool) { + if !self.known.iter().copied().eq(current.clone()) { + if self.initialized && !reduce_motion { + for uid in current.clone().filter(|uid| !self.known.contains(uid)) { + self.opening.push((uid, now)); + } + } + self.known = current.collect(); + } + self.initialized = true; + if reduce_motion { + self.opening.clear(); + } else { + let known = &self.known; + self.opening.retain(|(uid, started)| known.contains(uid) && now - *started < OPEN_TIME); + } + } +} + +/// The entering tab's progress among `opening` (from [`TabMotion`]), or 1 when it is not animating. +fn open_progress(opening: &[(u64, f64)], uid: u64, now: f64) -> f32 { + opening.iter().find(|(u, _)| *u == uid).map_or(1.0, |(_, started)| ((now - started) / OPEN_TIME).clamp(0.0, 1.0) as f32) +} + +struct TabLayout { + first: usize, + widths: Vec, + overflow: bool, +} + +/// Shrink open tabs before moving the excess into the overflow menu. +fn tab_layout(preferred: &[f32], available: f32, active: Option) -> TabLayout { + let count = preferred.len(); + let overflow = count as f32 * MIN_TAB_WIDTH > available; + let visible = if overflow { ((available - OVERFLOW_WIDTH).max(0.0) / MIN_TAB_WIDTH).floor() as usize } else { count }.min(count); + let first = active.unwrap_or(0).saturating_sub(visible / 2).min(count - visible); + let mut widths = preferred[first..first + visible].to_vec(); + let budget = (available - if overflow { OVERFLOW_WIDTH } else { 0.0 }).max(0.0); + let mut excess = (widths.iter().sum::() - budget).max(0.0); + while excess > 0.01 { + let flexible = widths.iter().filter(|w| **w > MIN_TAB_WIDTH + 0.01).count(); + if flexible == 0 { + break; + } + let share = excess / flexible as f32; + let mut removed = 0.0; + for width in &mut widths { + let shrink = (*width - MIN_TAB_WIDTH).max(0.0).min(share); + *width -= shrink; + removed += shrink; + } + excess -= removed; + } + TabLayout { first, widths, overflow } +} + +/// Final document index and insertion marker for a tab dropped among the visible tabs. +fn tab_drop_position(visible: &[(usize, Rect)], from: usize, pointer_x: f32) -> Option<(usize, f32)> { + let others: Vec<_> = visible.iter().copied().filter(|(index, _)| *index != from).collect(); + let slot = others.partition_point(|(_, rect)| rect.center().x < pointer_x); + if let Some(&(index, rect)) = others.get(slot) { + Some((index - usize::from(from < index), rect.min.x)) + } else { + let &(index, rect) = others.last()?; + Some((index + usize::from(from > index), rect.max.x)) + } +} pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); egui::Panel::top("tab_bar").exact_size(HEIGHT).frame(egui::Frame::NONE.fill(t.tab_bar)).show(ui, |ui| { let rect = ui.max_rect(); - // Empty space moves the window (first, so the tabs and buttons on top take their clicks). - if app.ui.title_bar != TitleBar::Native { - let drag = ui.interact(rect, ui.id().with("titlebar_drag"), Sense::click_and_drag()); - if drag.drag_started() { - ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); - } - if drag.double_clicked() { - let max = ui.input(|i| i.viewport().maximized.unwrap_or(false)); - ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max)); - } - } let buttons_w = if app.ui.title_bar == TitleBar::Custom { window_buttons(app, ui, rect) } else { 0.0 }; let mut x = rect.min.x + if app.ui.title_bar == TitleBar::Inset { 76.0 } else { 8.0 }; // Home. @@ -49,28 +120,70 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { } x += 44.0; ui.painter().line_segment([pos2(x, rect.min.y), pos2(x, rect.max.y)], Stroke::new(1.0, t.border)); + let document_tabs: Vec = app.session.docs.iter().enumerate().filter_map(|(i, d)| app.shows(d.uid).then_some(i)).collect(); + let now = ui.input(|input| input.time); + let motion_id = ui.id().with("tab_motion"); + let current_uids = document_tabs.iter().filter_map(|&i| app.session.docs.get(i).map(|d| d.uid)); + let reduce_motion = app.reduce_motion(); + // Cloning `opening` is free while nothing animates (an empty Vec). + let opening = ui.ctx().data_mut(|data| { + let motion = data.get_temp_mut_or_default::(motion_id); + motion.begin_frame(current_uids, now, reduce_motion); + motion.opening.clone() + }); + let preferred: Vec = document_tabs + .iter() + .map(|&i| { + let d = &app.session.docs[i]; + let mark_w = if live_mark(app, d.uid).is_some() { 18.0 } else { 0.0 }; + let dot_w = if d.is_dirty() && mark_w > 0.0 { 12.0 } else { 0.0 }; + (regular(12.0).size * 0.6 * d.doc.name.chars().count() as f32 + 64.0 + mark_w + dot_w).clamp(120.0, 220.0) + }) + .collect(); + // Leave the new-tab button, theme toggle, window controls and a draggable gap free. + let present_w = if app.ui.present.is_some() || app.ui.paused_present.is_some() { 128.0 } else { 0.0 }; + let available = (rect.max.x - buttons_w - 70.0 - x - 34.0 - present_w).max(0.0); + let active_tab = document_tabs.iter().position(|&i| i == app.session.active); + let layout = tab_layout(&preferred, available, active_tab); + let mut tab_rects = Vec::with_capacity(layout.widths.len()); + let mut tab_x = x; + for &width in &layout.widths { + tab_rects.push(Rect::from_min_size(pos2(tab_x, rect.min.y), vec2(width, HEIGHT))); + tab_x += width; + } let mut switch = None; let mut close = None; // A tab dragged off the strip, or "Open in new window", moves to a window of its own. let mut detach = None; + let mut dragging = None; + let mut dropped = None; let several = !ui.ctx().embed_viewports(); - for (i, d) in app.session.docs.iter().enumerate().filter(|(_, d)| app.shows(d.uid)) { + for (slot, &w) in layout.widths.iter().enumerate() { + let i = document_tabs[layout.first + slot]; + let d = &app.session.docs[i]; let active = i == app.session.active && !app.ui.home && app.ui.present.is_none(); - let name = d.doc.name.clone(); + let name = &d.doc.name; // In a live session: a mark after the name says whether others get in. let live = live_mark(app, d.uid); let mark_w = if live.is_some() { 18.0 } else { 0.0 }; - // (A file in a session is rarely saved here: its unsaved dot gets room beside the mark.) let dirty = d.is_dirty(); - let dot_w = if dirty && live.is_some() { 12.0 } else { 0.0 }; - let w = (regular(12.0).size * 0.6 * name.chars().count() as f32 + 64.0 + mark_w + dot_w).clamp(120.0, 220.0); - let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(w, HEIGHT)); - let resp = ui.interact(r, ui.id().with(("tab", d.uid)), if several { Sense::click_and_drag() } else { Sense::click() }); - if resp.drag_stopped() + let r = tab_rects[slot]; + let body_r = Rect::from_min_max(r.min, pos2(r.max.x - 32.0, r.max.y)); + let close_r = Rect::from_min_max(body_r.right_top(), r.max); + let resp = ui.interact(body_r, ui.id().with(("tab", d.uid)), Sense::click_and_drag()); + let cresp = ui.interact(close_r, ui.id().with(("tabclose", d.uid)), Sense::click()).tip(tr("Close tab")); + if (resp.dragged() || resp.drag_stopped()) && let Some(p) = ui.input(|inp| inp.pointer.latest_pos()) - && (p.y - rect.center().y).abs() > HEIGHT * 2.0 { - detach = Some((i, Some(p))); + if resp.drag_stopped() { + if several && (p.y - rect.center().y).abs() > HEIGHT * 2.0 { + detach = Some((i, Some(p))); + } else if (rect.min.y - 12.0..=rect.max.y + 12.0).contains(&p.y) { + dropped = Some((i, p.x)); + } + } else { + dragging = Some((i, p)); + } } if several { resp.context_menu(|ui| { @@ -82,49 +195,52 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { }); } let label = match live { - Some(true) => trf("{name}, in a live session", &[("name", &name)]), - Some(false) => trf("{name}, online, open only to you", &[("name", &name)]), + Some(true) => trf("{name}, in a live session", &[("name", name)]), + Some(false) => trf("{name}, online, open only to you", &[("name", name)]), None => name.clone(), }; let resp = if live.is_some() { resp.on_hover_text(&label) } else { resp }; crate::a11y::tab(&resp, &label, active); + let progress = open_progress(&opening, d.uid, now); + let paint_r = r.translate(vec2(-28.0 * (1.0 - progress).powi(2), 0.0)); + let painter = ui.painter().with_clip_rect(r); if active { - ui.painter().rect_filled(r, CornerRadius::ZERO, t.tab_active); - } else if resp.hovered() { - ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover); + painter.rect_filled(paint_r, CornerRadius::ZERO, t.tab_active.linear_multiply(progress)); + } else if resp.hovered() || cresp.hovered() { + painter.rect_filled(paint_r, CornerRadius::ZERO, t.hover.linear_multiply(progress)); } icons::paint( - ui.painter(), - Rect::from_center_size(pos2(r.min.x + 18.0, r.center().y), vec2(14.0, 14.0)), + &painter, + Rect::from_center_size(pos2(paint_r.min.x + 18.0, paint_r.center().y), vec2(14.0, 14.0)), Icon::Logo, - crate::theme::ACCENT, + crate::theme::ACCENT.linear_multiply(progress), ); // The unsaved-changes dot follows the name on screen, whichever way the name reads. let shown = crate::bidi::paint_name( - ui.painter(), - pos2(r.min.x + 32.0, r.center().y), - &name, + &painter, + pos2(paint_r.min.x + 32.0, paint_r.center().y), + name, regular(12.0), - t.text, - w - mark_w - if dirty { 72.0 } else { 60.0 }, + t.text.linear_multiply(progress), + (w - mark_w - if dirty { 72.0 } else { 60.0 }).max(0.0), ); if let Some(shared) = live { - let at = Rect::from_center_size(pos2(r.max.x - 38.0, r.center().y), vec2(14.0, 14.0)); - icons::paint(ui.painter(), at, crate::online::mark(shared), if shared { t.success } else { t.icon_secondary }); + let at = Rect::from_center_size(pos2(paint_r.max.x - 38.0, paint_r.center().y), vec2(14.0, 14.0)); + icons::paint(&painter, at, crate::online::mark(shared), if shared { t.success } else { t.icon_secondary }.linear_multiply(progress)); } if dirty { - ui.painter().text(pos2(shown.max.x + 4.0, r.center().y), Align2::LEFT_CENTER, "•", regular(12.0), t.text); + painter.text(pos2(shown.max.x + 4.0, paint_r.center().y), Align2::LEFT_CENTER, "•", regular(12.0), t.text.linear_multiply(progress)); } - let cr = Rect::from_center_size(pos2(r.max.x - 16.0, r.center().y), vec2(18.0, 18.0)); - if resp.hovered() || active { - let cresp = ui.interact(cr, ui.id().with(("tabclose", d.uid)), Sense::click()).tip(tr("Close tab")); + let cr = Rect::from_center_size(close_r.center() + vec2(paint_r.min.x - r.min.x, 0.0), vec2(18.0, 18.0)); + // The close target never overlaps the draggable body, including on inactive tabs. + if resp.hovered() || cresp.hovered() || active { if cresp.hovered() { - ui.painter().rect_filled(cr, CornerRadius::same(4), t.pressed); - } - icons::paint(ui.painter(), cr.shrink(4.0), Icon::Close, t.icon_secondary); - if cresp.clicked() { - close = Some(i); + painter.rect_filled(cr, CornerRadius::same(4), t.pressed.linear_multiply(progress)); } + icons::paint(&painter, cr.shrink(4.0), Icon::Close, t.icon_secondary.linear_multiply(progress)); + } + if cresp.clicked() { + close = Some(i); } if resp.clicked() && close.is_none() { switch = Some(i); @@ -135,35 +251,66 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { x += w; ui.painter().line_segment([pos2(x, rect.min.y + 10.0), pos2(x, rect.max.y - 10.0)], Stroke::new(1.0, t.border)); } + let visible: Vec<(usize, Rect)> = tab_rects.iter().copied().enumerate().map(|(slot, r)| (document_tabs[layout.first + slot], r)).collect(); + if let Some((from, p)) = dragging + && (rect.min.y - 12.0..=rect.max.y + 12.0).contains(&p.y) + && let Some((_, at)) = tab_drop_position(&visible, from, p.x) + { + ui.painter().line_segment([pos2(at, rect.min.y + 5.0), pos2(at, rect.max.y - 5.0)], Stroke::new(3.0, crate::theme::ACCENT)); + } + let reorder = dropped.and_then(|(from, at)| tab_drop_position(&visible, from, at).map(|(to, _)| (from, to))); + if layout.overflow { + let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(OVERFLOW_WIDTH, HEIGHT)); + let resp = ui.interact(r, ui.id().with("tab_overflow"), Sense::click()).tip(tr("Open files")); + crate::a11y::menu_button(&resp, tr("Open files"), crate::a11y::menu_open(&resp)); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover); + } + icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), Icon::Chevron, t.icon); + crate::right::menu_popup(&resp).show(|ui| { + egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| { + for &i in &document_tabs { + let Some(d) = app.session.docs.get(i) else { continue }; + let on = i == app.session.active && !app.ui.home && app.ui.present.is_none(); + let label = if d.is_dirty() { format!("{} •", d.doc.name) } else { d.doc.name.clone() }; + if crate::right::menu_check(ui, on, &label, "") { + switch = Some(i); + ui.close(); + } + } + }); + }); + x += OVERFLOW_WIDTH; + } // Presentations live in their own tab. Switching back to the design keeps the player // state (current screen, navigation history and scroll position) for the return trip. if app.ui.present.is_some() || app.ui.paused_present.is_some() { let w = 128.0; let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(w, HEIGHT)); - let resp = ui.interact(r, ui.id().with("presentation_tab"), Sense::click()).tip(tr("Present")); + let body_r = Rect::from_min_max(r.min, pos2(r.max.x - 32.0, r.max.y)); + let close_r = Rect::from_min_max(body_r.right_top(), r.max); + let resp = ui.interact(body_r, ui.id().with("presentation_tab"), Sense::click()).tip(tr("Present")); + let close_resp = ui.interact(close_r, ui.id().with("presentation_close"), Sense::click()).tip(tr("Close preview")); let active = app.ui.present.is_some(); crate::a11y::tab(&resp, tr("Present"), active); if active { ui.painter().rect_filled(r, CornerRadius::ZERO, t.tab_active); - } else if resp.hovered() { + } else if resp.hovered() || close_resp.hovered() { ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover); } icons::paint(ui.painter(), Rect::from_center_size(pos2(x + 19.0, r.center().y), vec2(14.0, 14.0)), Icon::Play, t.icon); ui.painter().text(pos2(x + 34.0, r.center().y), Align2::LEFT_CENTER, tr("Present"), regular(12.0), t.text); - let cr = Rect::from_center_size(pos2(r.max.x - 16.0, r.center().y), vec2(18.0, 18.0)); - if resp.hovered() || active { - let close_resp = ui.interact(cr, ui.id().with("presentation_close"), Sense::click()).tip(tr("Close preview")); + let cr = Rect::from_center_size(close_r.center(), vec2(18.0, 18.0)); + if resp.hovered() || close_resp.hovered() || active { if close_resp.hovered() { ui.painter().rect_filled(cr, CornerRadius::same(4), t.pressed); } icons::paint(ui.painter(), cr.shrink(4.0), Icon::Close, t.icon_secondary); - if close_resp.clicked() { - app.ui.present = None; - app.ui.paused_present = None; - app.ui.present_uid = None; - } else if resp.clicked() && !active { - resume_presentation(app); - } + } + if close_resp.clicked() { + app.ui.present = None; + app.ui.paused_present = None; + app.ui.present_uid = None; } else if resp.clicked() && !active { resume_presentation(app); } @@ -195,6 +342,18 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { let cmd = if app.ui.dark { "view.themeLight" } else { "view.themeDark" }; app.run(cmd, json!({})); } + // Only the free space drags the window. A full-bar drag target steals tab drags. + if app.ui.title_bar != TitleBar::Native && x + 34.0 < theme_r.min.x { + let free = Rect::from_min_max(pos2(x + 34.0, rect.min.y), pos2(theme_r.min.x, rect.max.y)); + let drag = ui.interact(free, ui.id().with("titlebar_drag"), Sense::click_and_drag()); + if drag.drag_started() { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); + } + if drag.double_clicked() { + let max = ui.input(|i| i.viewport().maximized.unwrap_or(false)); + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max)); + } + } if let Some(i) = switch { if let Some(p) = app.ui.present.take() { app.ui.paused_present = Some(p); @@ -209,6 +368,11 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { app.toast(e); } } + if let Some((from, to)) = reorder + && from != to + { + app.run("file.moveTab", json!({"from": from, "to": to})); + } if let Some(i) = close { // A branch tab's edits live on in its main file: closing it loses nothing. let dirty = app.session.docs.get(i).is_some_and(|d| d.is_dirty() && d.branch_of.is_none()); @@ -230,8 +394,9 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { app.ui.present_uid = None; } } - let _ = Color32::TRANSPARENT; - let _ = Align2::LEFT_TOP; + if !opening.is_empty() { + ui.ctx().request_repaint(); + } }); } @@ -325,3 +490,53 @@ pub fn resize_edges(ctx: &egui::Context) { } }); } + +#[cfg(test)] +mod layout_tests { + use super::{MIN_TAB_WIDTH, OPEN_TIME, OVERFLOW_WIDTH, TabMotion, open_progress, tab_drop_position, tab_layout}; + use egui::{Rect, pos2}; + + #[test] + fn tabs_shrink_to_fit_before_overflowing() { + let preferred = vec![120.0; 10]; + let layout = tab_layout(&preferred, 1100.0, Some(5)); + assert!(!layout.overflow); + assert_eq!(layout.first, 0); + assert_eq!(layout.widths.len(), preferred.len()); + assert!(layout.widths.iter().all(|&width| (width - 110.0).abs() < 0.01)); + } + + #[test] + fn excess_tabs_leave_room_for_overflow_and_keep_the_active_tab_visible() { + let preferred = vec![120.0; 24]; + let available = 1100.0; + let layout = tab_layout(&preferred, available, Some(23)); + assert!(layout.overflow); + assert!(layout.first <= 23 && 23 < layout.first + layout.widths.len()); + assert!(layout.widths.iter().all(|&width| width >= MIN_TAB_WIDTH)); + assert!(layout.widths.iter().sum::() + OVERFLOW_WIDTH <= available + 0.01); + } + + #[test] + fn drop_position_places_a_tab_between_its_neighbors() { + let visible = (0..4) + .map(|index| (index, Rect::from_min_max(pos2(index as f32 * 120.0, 0.0), pos2((index + 1) as f32 * 120.0, 40.0)))) + .collect::>(); + assert_eq!(tab_drop_position(&visible, 0, 330.0).map(|(to, _)| to), Some(2)); + assert_eq!(tab_drop_position(&visible, 3, 30.0).map(|(to, _)| to), Some(0)); + } + + #[test] + fn new_tabs_animate_unless_motion_is_reduced() { + let mut motion = TabMotion::default(); + motion.begin_frame([1].into_iter(), 0.0, false); + assert!(motion.opening.is_empty(), "the tabs open at start-up don't animate"); + motion.begin_frame([1, 2].into_iter(), 1.0, false); + assert_eq!(open_progress(&motion.opening, 2, 1.0), 0.0); + assert_eq!(open_progress(&motion.opening, 1, 1.0), 1.0); + motion.begin_frame([1, 2].into_iter(), 1.0 + 2.0 * OPEN_TIME, false); + assert!(motion.opening.is_empty(), "the animation ends"); + motion.begin_frame([1, 2, 3].into_iter(), 2.0, true); + assert!(motion.opening.is_empty(), "Reduce motion opens tabs at once"); + } +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index a4fa87544..611371cec 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -1,6 +1,6 @@ //! Design tokens (UI3 light and dark) and fonts. -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use egui::{Color32, Context, FontData, FontDefinitions, FontFamily, FontId, Stroke, TextStyle, Visuals}; @@ -14,6 +14,8 @@ pub struct Tokens { pub border_strong: Color32, pub text: Color32, pub text_secondary: Color32, + /// Dim text: disabled controls, placeholders, hints. At least 3:1 against the panels, fields + /// and selected rows, yet dimmer than `text_secondary`. pub text_tertiary: Color32, pub icon: Color32, pub icon_secondary: Color32, @@ -23,6 +25,7 @@ pub struct Tokens { pub field_hover_border: Color32, pub accent: Color32, pub accent_hover: Color32, + pub accent_on: Color32, pub accent_text: Color32, pub selected_row: Color32, pub selected_row_child: Color32, @@ -44,11 +47,42 @@ pub struct Tokens { pub focus: Color32, } -pub const ACCENT: Color32 = Color32::from_rgb(0x0D, 0x99, 0xFF); +/// FigCraft's brand green for solid surfaces. +pub const BRAND: Color32 = Color32::from_rgb(0x02, 0xDA, 0x96); +/// Contrast-safe green for thin selection outlines and white-on-green labels. +/// It reaches 4.5:1 on white and 3:1 on the dark canvas. +pub const ACCENT: Color32 = Color32::from_rgb(0x00, 0x87, 0x5F); +/// Ink on the bright green brand surface (white would not have enough contrast). +pub const ACCENT_ON: Color32 = Color32::from_rgb(0x07, 0x35, 0x2A); +/// Id of the canvas overlays' green for this frame ([`set_canvas_page`]). +const CANVAS_ACCENT: &str = "figcraft-canvas-accent"; + +/// Remember the page colour the canvas shows. Its overlays (outlines, handles, arrows) take +/// whichever green contrasts more with it: the bright one on a dark page, the darker on a light one. +pub fn set_canvas_page(ctx: &Context, page: Color32) { + let pick = if c(BRAND).contrast(c(page)) > c(ACCENT).contrast(c(page)) { BRAND } else { ACCENT }; + ctx.data_mut(|d| d.insert_temp(egui::Id::new(CANVAS_ACCENT), pick)); +} + +/// `x` (opaque) as a document colour, for its contrast with another. +fn c(x: Color32) -> figcraft_color::Color { + figcraft_color::Color::new(f32::from(x.r()) / 255.0, f32::from(x.g()) / 255.0, f32::from(x.b()) / 255.0, 1.0) +} + +/// The green for overlays drawn on the page ([`set_canvas_page`]); [`ACCENT`] before a page shows. +pub fn canvas_accent(ctx: &Context) -> Color32 { + ctx.data(|d| d.get_temp(egui::Id::new(CANVAS_ACCENT))).unwrap_or(ACCENT) +} + +/// A fill under white text: the darker green stands in for the bright one. +pub fn under_white(c: Color32) -> Color32 { + if c == BRAND { ACCENT } else { c } +} + pub const COMPONENT: Color32 = Color32::from_rgb(0x97, 0x47, 0xFF); /// Handoff status badges (white text on these, in both themes). -pub const DEV_READY: Color32 = Color32::from_rgb(0x14, 0xAE, 0x5C); -pub const DEV_CHANGED: Color32 = Color32::from_rgb(0xD9, 0x7A, 0x06); +pub const DEV_READY: Color32 = Color32::from_rgb(0x08, 0x7D, 0x4F); +pub const DEV_CHANGED: Color32 = Color32::from_rgb(0xA7, 0x5B, 0x00); pub const DEV_COMPLETED: Color32 = Color32::from_rgb(0x6E, 0x6E, 0x73); impl Tokens { @@ -63,23 +97,24 @@ impl Tokens { text: Color32::from_rgb(0x1E, 0x1E, 0x1E), // 4.7:1 on fields and hovered rows (#F5F5F5), 4.6:1 on selected rows: WCAG AA. text_secondary: Color32::from_rgb(0x6E, 0x6E, 0x6E), - text_tertiary: Color32::from_rgb(0xB3, 0xB3, 0xB3), + text_tertiary: Color32::from_rgb(0x8A, 0x8A, 0x8A), icon: Color32::from_rgb(0x1E, 0x1E, 0x1E), icon_secondary: Color32::from_rgb(0x75, 0x75, 0x75), hover: Color32::from_rgb(0xF5, 0xF5, 0xF5), pressed: Color32::from_rgb(0xE6, 0xE6, 0xE6), field: Color32::from_rgb(0xF5, 0xF5, 0xF5), field_hover_border: Color32::from_rgb(0xE6, 0xE6, 0xE6), - accent: ACCENT, - accent_hover: Color32::from_rgb(0x00, 0x7B, 0xE5), - // 5.2:1 on white, 4.6:1 on selected rows: WCAG AA for text. - accent_text: Color32::from_rgb(0x00, 0x6B, 0xD1), - selected_row: Color32::from_rgb(0xE5, 0xF4, 0xFF), - selected_row_child: Color32::from_rgb(0xF2, 0xF9, 0xFF), + accent: BRAND, + accent_hover: Color32::from_rgb(0x00, 0xAD, 0x78), + accent_on: ACCENT_ON, + // Darker green keeps links and labels legible on white and selected rows. + accent_text: Color32::from_rgb(0x00, 0x7A, 0x56), + selected_row: Color32::from_rgb(0xE0, 0xFB, 0xF1), + selected_row_child: Color32::from_rgb(0xF1, 0xFC, 0xF7), component: COMPONENT, component_row: Color32::from_rgb(0xF1, 0xE9, 0xFF), danger: Color32::from_rgb(0xF2, 0x48, 0x22), - success: Color32::from_rgb(0x14, 0xAE, 0x5C), + success: DEV_READY, warning: Color32::from_rgb(0xFF, 0xCD, 0x29), guide: Color32::from_rgb(0xF2, 0x48, 0x22), tab_bar: Color32::from_rgb(0xE8, 0xE8, 0xE8), @@ -89,7 +124,7 @@ impl Tokens { menu_text: Color32::from_rgb(0xFF, 0xFF, 0xFF), menu_hover: ACCENT, menu_dim: Color32::from_rgb(0x9A, 0x9A, 0x9A), - focus: Color32::from_rgb(0x00, 0x6B, 0xD1), + focus: Color32::from_rgb(0x00, 0x7A, 0x56), } } @@ -103,22 +138,23 @@ impl Tokens { border_strong: Color32::from_rgb(0x55, 0x55, 0x55), text: Color32::from_rgb(0xFF, 0xFF, 0xFF), text_secondary: Color32::from_rgb(0xB3, 0xB3, 0xB3), - text_tertiary: Color32::from_rgb(0x75, 0x75, 0x75), + text_tertiary: Color32::from_rgb(0x88, 0x88, 0x88), icon: Color32::from_rgb(0xF5, 0xF5, 0xF5), icon_secondary: Color32::from_rgb(0xB3, 0xB3, 0xB3), hover: Color32::from_rgb(0x38, 0x38, 0x38), pressed: Color32::from_rgb(0x44, 0x44, 0x44), field: Color32::from_rgb(0x38, 0x38, 0x38), field_hover_border: Color32::from_rgb(0x4D, 0x4D, 0x4D), - accent: ACCENT, - accent_hover: Color32::from_rgb(0x3D, 0xAD, 0xFF), - accent_text: Color32::from_rgb(0x7C, 0xC4, 0xF8), - selected_row: Color32::from_rgb(0x2A, 0x45, 0x5C), - selected_row_child: Color32::from_rgb(0x2D, 0x37, 0x41), + accent: BRAND, + accent_hover: Color32::from_rgb(0x52, 0xE9, 0xB7), + accent_on: ACCENT_ON, + accent_text: Color32::from_rgb(0x52, 0xE9, 0xB7), + selected_row: Color32::from_rgb(0x16, 0x42, 0x36), + selected_row_child: Color32::from_rgb(0x12, 0x37, 0x2D), component: Color32::from_rgb(0xD2, 0x7C, 0xFF), component_row: Color32::from_rgb(0x3E, 0x30, 0x52), danger: Color32::from_rgb(0xF2, 0x48, 0x22), - success: Color32::from_rgb(0x14, 0xAE, 0x5C), + success: DEV_READY, warning: Color32::from_rgb(0xFF, 0xCD, 0x29), guide: Color32::from_rgb(0xF2, 0x48, 0x22), tab_bar: Color32::from_rgb(0x1E, 0x1E, 0x1E), @@ -128,7 +164,7 @@ impl Tokens { menu_text: Color32::from_rgb(0xFF, 0xFF, 0xFF), menu_hover: ACCENT, menu_dim: Color32::from_rgb(0x9A, 0x9A, 0x9A), - focus: ACCENT, + focus: BRAND, } } @@ -186,7 +222,9 @@ fn visuals(t: Tokens) -> Visuals { v.window_corner_radius = egui::CornerRadius::same(13); v.menu_corner_radius = egui::CornerRadius::same(13); v.selection.bg_fill = t.accent.gamma_multiply(0.3); - v.selection.stroke = Stroke::new(1.0, t.accent); + // Also the colour of selected text on that highlight (and a focused text box's outline): the + // theme's readable green, not the darker one that sinks into the dark theme's highlight. + v.selection.stroke = Stroke::new(1.0, t.accent_text); v.hyperlink_color = t.accent_text; for w in [&mut v.widgets.inactive, &mut v.widgets.hovered, &mut v.widgets.active, &mut v.widgets.open, &mut v.widgets.noninteractive] { w.corner_radius = egui::CornerRadius::same(5); @@ -202,10 +240,10 @@ fn visuals(t: Tokens) -> Visuals { v.widgets.hovered.bg_stroke = Stroke::new(1.0, t.field_hover_border); v.widgets.active.bg_fill = t.pressed; v.widgets.active.weak_bg_fill = t.pressed; - v.widgets.active.bg_stroke = Stroke::new(1.0, t.accent); + v.widgets.active.bg_stroke = Stroke::new(1.0, ACCENT); v.popup_shadow = egui::Shadow { offset: [0, 4], blur: 16, spread: 0, color: t.shadow }; v.window_shadow = egui::Shadow { offset: [0, 8], blur: 24, spread: 0, color: t.shadow }; - v.text_cursor.stroke = Stroke::new(1.0, t.accent); + v.text_cursor.stroke = Stroke::new(1.0, ACCENT); v } @@ -226,7 +264,8 @@ pub fn on_dark(ui: &mut egui::Ui, f: impl FnOnce(&mut egui::Ui) -> R) -> R { r } -/// UI fonts: Inter (from the craft-fonts build input) when present, else egui's defaults; with +/// UI fonts: Inter and the Archivo wordmark (from the craft-fonts build input) when present; +/// otherwise egui's defaults. With /// `system`, also system faces for scripts those lack (Hebrew, joined Arabic) so names in them show, /// and faces for the CJK scripts in `cjk` ([`crate::cjk`]). pub fn install_fonts(ctx: &Context, system: bool, cjk: u8) { @@ -252,8 +291,20 @@ pub fn install_fonts(ctx: &Context, system: bool, cjk: u8) { } semi.extend(fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default()); fonts.families.insert(FontFamily::Name("semibold".into()), semi); + // Keep the logo's typeface independent of UI text. System Archivo is used when available; + // releases with craft-fonts embed it so the wordmark does not depend on installation. + let archivo = find("Archivo", "SemiBold").map(FontData::from_static).or_else(|| { + system.then(|| figcraft_text::find_face("Archivo", "SemiBold")).flatten().filter(|face| face.family == "Archivo").map(|face| font_data(&face)) + }); + let mut brand = Vec::new(); + if let Some(data) = archivo { + fonts.font_data.insert("archivo".into(), Arc::new(data)); + brand.push("archivo".into()); + } + brand.extend(fonts.families.get(&FontFamily::Name("semibold".into())).cloned().unwrap_or_default()); + fonts.families.insert(FontFamily::Name("brand".into()), brand); // CJK and other scripts from craft-fonts as fallbacks (Noto Sans CJK only with FIGCRAFT_EMBED_CJK). - const BUILT_IN: [&str; 4] = ["Inter", "BIZ UDPGothic", "Noto Sans CJK SC", "Noto Sans Arabic"]; + const BUILT_IN: [&str; 5] = ["Inter", "BIZ UDPGothic", "Noto Sans CJK SC", "Noto Sans Arabic", "Noto Sans Georgian"]; let lang = crate::i18n::current().map_or("en", |l| l.code); // A Chinese UI draws Han in a Chinese face: the built-in Japanese one goes after the system faces. let late = |family: &str| family == "BIZ UDPGothic" && crate::cjk::language(lang) == crate::cjk::HAN; @@ -339,6 +390,40 @@ pub fn semibold(size: f32) -> FontId { FontId::new(size, FontFamily::Name("semibold".into())) } +pub fn brand(size: f32) -> FontId { + FontId::new(size, FontFamily::Name("brand".into())) +} + +/// The two-tone Archivo wordmark used in the home sidebar and About window. +pub fn paint_wordmark(painter: &egui::Painter, left_center: egui::Pos2, size: f32, t: &Tokens) { + let font = brand(size); + let fig = painter.layout_no_wrap("Fig".into(), font.clone(), t.text); + let fig_width = fig.size().x; + painter.galley(egui::pos2(left_center.x, left_center.y - fig.size().y / 2.0), fig, t.text); + painter.text(egui::pos2(left_center.x + fig_width, left_center.y), egui::Align2::LEFT_CENTER, "Craft", font, t.accent_text); +} + +/// Paint the owner's original chameleon icon from the existing app asset. +pub fn paint_app_icon(ui: &egui::Ui, rect: egui::Rect) { + static ICON: OnceLock> = OnceLock::new(); + let image = ICON.get_or_init(|| { + let rgba = image::load_from_memory(include_bytes!("../../../assets/app-icon/figcraft-64.png")).ok()?.to_rgba8(); + Some(egui::ColorImage::from_rgba_unmultiplied([rgba.width() as usize, rgba.height() as usize], rgba.as_raw())) + }); + let Some(image) = image else { + ui.painter().rect_filled(rect, egui::CornerRadius::same(5), BRAND); + crate::icons::paint(ui.painter(), rect.shrink(rect.width() * 0.22), crate::icons::Icon::Logo, ACCENT_ON); + return; + }; + let id = egui::Id::new("figcraft_original_app_icon"); + let texture = ui.ctx().data(|data| data.get_temp::(id)).unwrap_or_else(|| { + let texture = ui.ctx().load_texture("figcraft-original-app-icon", image.clone(), egui::TextureOptions::LINEAR); + ui.ctx().data_mut(|data| data.insert_temp(id, texture.clone())); + texture + }); + ui.painter().image(texture.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE); +} + pub fn regular(size: f32) -> FontId { FontId::new(size, FontFamily::Proportional) } @@ -370,6 +455,49 @@ pub fn doc_c32(ctx: &Context, c: figcraft_color::Color) -> Color32 { mod tests { use super::*; + #[test] + fn canvas_overlays_take_the_green_that_stands_out_on_the_page() { + let ctx = Context::default(); + assert_eq!(canvas_accent(&ctx), ACCENT, "before any page shows"); + set_canvas_page(&ctx, Color32::from_rgb(0x1E, 0x1E, 0x1E)); + assert_eq!(canvas_accent(&ctx), BRAND, "the bright green on a dark page"); + set_canvas_page(&ctx, Color32::WHITE); + assert_eq!(canvas_accent(&ctx), ACCENT, "the darker green on a light page"); + set_canvas_page(&ctx, Color32::from_rgb(0xF5, 0xF5, 0xF5)); + assert_eq!(canvas_accent(&ctx), ACCENT); + // White text never sits on the bright green. + assert_eq!(under_white(BRAND), ACCENT); + assert_eq!(under_white(COMPONENT), COMPONENT); + } + + /// `fill` (premultiplied, maybe translucent) painted over the opaque `bg`. + fn over(fill: Color32, bg: Color32) -> Color32 { + let k = 1.0 - f32::from(fill.a()) / 255.0; + let mix = |f: u8, b: u8| (f32::from(f) + f32::from(b) * k).round().min(255.0) as u8; + Color32::from_rgb(mix(fill.r(), bg.r()), mix(fill.g(), bg.g()), mix(fill.b(), bg.b())) + } + + #[test] + fn disabled_and_selected_text_stay_readable_in_both_themes() { + for t in [Tokens::light(), Tokens::dark()] { + let theme = if t.dark { "dark" } else { "light" }; + // Disabled text (a disabled button's label, placeholders) on panels, fields and + // selected rows, still dimmer than secondary text. + for bg in [t.panel, t.field, t.hover, t.selected_row] { + let ratio = c(t.text_tertiary).contrast(c(bg)); + assert!(ratio >= 3.0, "{theme}: dim text {:?} on {bg:?} is {ratio:.2}:1", t.text_tertiary); + assert!(ratio < c(t.text_secondary).contrast(c(bg)), "{theme}: dim text reads dimmer than secondary text"); + } + // Selected text (renaming a page or layer, any text box) on the selection highlight. + let v = visuals(t); + for bg in [t.panel, t.field] { + let highlight = over(v.selection.bg_fill, bg); + let ratio = c(v.selection.stroke.color).contrast(c(highlight)); + assert!(ratio >= 3.0, "{theme}: selected text on {highlight:?} is {ratio:.2}:1"); + } + } + } + #[test] fn ui_font_draws_every_mac_shortcut_glyph() { figcraft_text::scan_system_fonts(); diff --git a/crates/ui-egui/src/titlebar_tests.rs b/crates/ui-egui/src/titlebar_tests.rs index e9224404d..9f1916c17 100644 --- a/crates/ui-egui/src/titlebar_tests.rs +++ b/crates/ui-egui/src/titlebar_tests.rs @@ -158,6 +158,81 @@ fn tabs_and_new_tab_keep_their_clicks() { assert!(opened, "the new-tab button took no click"); } +#[test] +fn many_tabs_do_not_cover_the_window_buttons() { + let mut u = Ui::new(TitleBar::Custom); + for _ in 0..24 { + u.app.session.execute("file.new", &json!({})).unwrap(); + } + u.frame(vec![]); + let before = u.app.session.docs.len(); + u.click(pos2(SIZE.x - 222.0, 20.0)); + assert_eq!(u.app.session.docs.len(), before + 1, "the new-tab button must stay reachable"); + assert!(u.click(window_button(0)).contains(&ViewportCommand::Minimized(true))); + assert!(u.click(window_button(1)).contains(&ViewportCommand::Maximized(true))); + u.click(window_button(2)); + assert!(u.frame(vec![]).contains(&ViewportCommand::Close)); +} + +#[test] +fn close_button_on_inactive_tab_closes_it_without_switching() { + let mut u = Ui::new(TitleBar::Custom); + u.app.session.execute("file.new", &json!({})).unwrap(); + u.app.session.execute("file.new", &json!({})).unwrap(); + let active_uid = u.app.session.docs[u.app.session.active].uid; + let inactive_uid = u.app.session.docs[1].uid; + let close_button = pos2(270.0, 20.0); + u.frame(vec![Event::PointerMoved(close_button)]); + u.click(close_button); + assert!( + !u.app.session.docs.iter().any(|doc| doc.uid == inactive_uid), + "docs={}, active={}, last_error={:?}, target_dirty={}", + u.app.session.docs.len(), + u.app.session.active, + u.app.ui.last_error, + u.app.session.docs.iter().find(|doc| doc.uid == inactive_uid).is_some_and(|doc| doc.is_dirty()) + ); + assert_eq!(u.app.session.docs[u.app.session.active].uid, active_uid); +} + +#[test] +fn closing_a_tab_before_the_active_one_keeps_the_active_file() { + let mut u = Ui::new(TitleBar::Custom); + for _ in 0..3 { + u.app.session.execute("file.new", &json!({})).unwrap(); + } + u.app.session.execute("doc.switch", &json!({"index": 2})).unwrap(); + let active_uid = u.app.session.docs[u.app.session.active].uid; + let closed_uid = u.app.session.docs[0].uid; + let close_button = pos2(156.0, 20.0); + u.frame(vec![Event::PointerMoved(close_button)]); + u.click(close_button); + assert!(!u.app.session.docs.iter().any(|doc| doc.uid == closed_uid)); + assert_eq!(u.app.session.docs[u.app.session.active].uid, active_uid); +} + +#[test] +fn dragging_a_tab_along_the_bar_reorders_it_without_detaching() { + let mut u = Ui::new(TitleBar::Custom); + for name in ["Two", "Three", "Four"] { + u.app.session.execute("file.new", &json!({"name": name})).unwrap(); + } + let moved_uid = u.app.session.docs[0].uid; + let active_uid = u.app.session.docs[u.app.session.active].uid; + u.frame(vec![]); + let commands = u.drag(pos2(100.0, 20.0), vec2(310.0, 0.0)); + assert!(!commands.contains(&ViewportCommand::StartDrag), "{commands:?}"); + assert_eq!( + u.app.session.docs[2].uid, + moved_uid, + "order={:?}, active={}, error={:?}", + u.app.session.docs.iter().map(|d| d.uid).collect::>(), + u.app.session.active, + u.app.ui.last_error + ); + assert_eq!(u.app.session.docs[u.app.session.active].uid, active_uid); +} + #[test] fn a_system_title_bar_leaves_the_window_alone() { let mut u = Ui::new(TitleBar::Native); diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 6da9badb6..d92cdf063 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -9,7 +9,19 @@ use crate::FigApp; use crate::a11y::{self, Nav, Region, Tip}; use crate::i18n::{tr, tr_noop, trf}; use crate::icons::{self, Icon}; -use crate::theme::{ACCENT, Tokens, regular}; +use crate::theme::{Tokens, regular}; + +/// Every mark in the toolbar row is painted in a box this size, so they share one optical size +/// and one stroke width (#737, #738). +const GLYPH: f32 = 18.0; +/// The flyout carets: smaller than the marks, but on a box whose stroke rounds to the same width +/// as theirs at 1×, 1.5× and 2× (a 10-point box drew them one physical pixel thinner). +const CARET: f32 = 14.0; + +/// `GLYPH`'s box centred in `r`. +fn glyph_rect(r: Rect) -> Rect { + Rect::from_center_size(r.center(), vec2(GLYPH, GLYPH)) +} struct Group { key: &'static str, @@ -82,6 +94,8 @@ pub(crate) fn main_width() -> f32 { GROUPS.len() as f32 * (cell + gap) + gap * 10.0 + 2.0 * (cell + gap) + 16.0 + 4.0 * 34.0 + 24.0 } +/// The main toolbar pill: the tool groups with their flyouts, Resources and Actions, then the +/// Draw · Design · Prototype · Inspect mode switcher. fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { let (cell, gap) = (32.0, 4.0); let width = main_width(); @@ -121,7 +135,7 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { if cresp.hovered() { ui.painter().rect_filled(cr, CornerRadius::same(4), t.hover); } - icons::paint(ui.painter(), Rect::from_center_size(cr.center(), vec2(10.0, 10.0)), Icon::Chevron, t.icon_secondary); + icons::paint(ui.painter(), Rect::from_center_size(cr.center(), vec2(CARET, CARET)), Icon::Chevron, t.icon_secondary); if cresp.clicked() { app.ui.tool_flyout = if app.ui.tool_flyout == Some(g.key) { None } else { Some(g.key) }; } @@ -145,11 +159,11 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { // The Actions button reads on while its panel is open; clicking it again closes it. let actions_open = id == "actions" && app.ui.palette.is_some(); if actions_open { - ui.painter().rect_filled(r, CornerRadius::same(5), ACCENT); + ui.painter().rect_filled(r, CornerRadius::same(5), t.accent); } else if resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); } - icons::paint(ui.painter(), r.shrink(7.0), icon, if actions_open { Color32::WHITE } else { t.icon }); + icons::paint(ui.painter(), glyph_rect(r), icon, if actions_open { t.accent_on } else { t.icon }); if resp.clicked() { if id == "actions" { crate::menus::toggle_palette(&mut app.ui); @@ -181,7 +195,7 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { } else if resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(6), t.hover); } - icons::paint(ui.painter(), r.shrink(9.0), *icon, if i == active { t.accent_text } else { t.icon_secondary }); + icons::paint(ui.painter(), glyph_rect(r), *icon, if i == active { t.accent_text } else { t.icon_secondary }); if resp.clicked() { match i { 0 => { @@ -372,14 +386,14 @@ fn tool_cell(ui: &mut Ui, t: &Tokens, r: Rect, id: impl egui::AsIdSalt, icon: Ic }); a11y::toggle(&resp, tip, active); let bg = if active { - ACCENT + t.accent } else if resp.hovered() { t.hover } else { Color32::TRANSPARENT }; ui.painter().rect_filled(r, CornerRadius::same(5), bg); - icons::paint(ui.painter(), r.shrink(7.0), icon, if active { Color32::WHITE } else { t.icon }); + icons::paint(ui.painter(), glyph_rect(r), icon, if active { t.accent_on } else { t.icon }); resp } @@ -395,8 +409,8 @@ fn done_button(ui: &mut Ui, t: &Tokens, x: f32, cy: f32, tip: &str) -> bool { divider(ui, t, x, cy); let done = Rect::from_min_size(pos2(x + 12.0, cy - 14.0), vec2(DONE_W, 28.0)); let resp = ui.interact(done, ui.id().with("tool_done"), Sense::click()).tip(tip); - ui.painter().rect_filled(done, CornerRadius::same(6), if resp.hovered() { t.accent_hover } else { ACCENT }); - ui.painter().text(done.center(), Align2::CENTER_CENTER, tr("Done"), crate::theme::semibold(11.5), Color32::WHITE); + ui.painter().rect_filled(done, CornerRadius::same(6), if resp.hovered() { t.accent_hover } else { t.accent }); + ui.painter().text(done.center(), Align2::CENTER_CENTER, tr("Done"), crate::theme::semibold(11.5), t.accent_on); resp.clicked() } @@ -452,3 +466,52 @@ fn flyout_id(key: &str) -> egui::Id { pub fn pretty_shortcut(s: &str) -> String { figcraft_engine::keymap::display(s, crate::shortcuts::platform()) } + +#[cfg(test)] +mod tests { + use super::*; + use egui::Shape; + + /// The stroke widths `icon` paints with in a `size` box at `ppp` pixels per point. + fn widths(ppp: f32, icon: Icon, size: f32) -> Vec { + let ctx = egui::Context::default(); + let input = || { + let mut i = egui::RawInput::default(); + i.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(ppp); + i + }; + ctx.run_ui(input(), |_| {}).textures_delta.clear(); + let mut out = + ctx.run_ui(input(), |ui| icons::paint(ui.painter(), Rect::from_center_size(pos2(50.0, 50.0), vec2(size, size)), icon, Color32::WHITE)); + out.textures_delta.clear(); + assert_eq!(ctx.pixels_per_point(), ppp); + let mut w: Vec = out + .shapes + .iter() + .filter_map(|c| match &c.shape { + Shape::LineSegment { stroke, .. } => Some(stroke.width), + Shape::Path(p) => Some(p.stroke.width), + Shape::Rect(r) => Some(r.stroke.width), + _ => None, + }) + .filter(|w| *w > 0.0) + .collect(); + w.dedup(); + w + } + + /// The flyout carets and the marks beside them draw with one stroke width (#737), and the + /// mode switcher's marks are the tool marks' size (#738). + #[test] + fn toolbar_marks_share_one_stroke_width() { + for ppp in [1.0, 1.5, 2.0] { + let mark = widths(ppp, Icon::Rectangle, GLYPH); + assert_eq!(mark.len(), 1, "one width at {ppp}×: {mark:?}"); + assert_eq!(widths(ppp, Icon::Chevron, CARET), mark, "caret at {ppp}×"); + assert_eq!(widths(ppp, Icon::Code, GLYPH), mark, "mode mark at {ppp}×"); + for icon in [Icon::Frame, Icon::Text] { + assert_eq!(widths(ppp, icon, GLYPH), mark, "{icon:?} at {ppp}×"); + } + } + } +} diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index 7740a9e02..06185d0cd 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -587,7 +587,7 @@ fn decoration_details(app: &mut FigApp, ui: &mut Ui, styles: &[TextStyle], apply if !auto && ui .add( - egui::Label::new(egui::RichText::new(tr("Auto")).font(regular(11.0)).color(Tokens::get(ui.ctx()).accent)) + egui::Label::new(egui::RichText::new(tr("Auto")).font(regular(11.0)).color(Tokens::get(ui.ctx()).accent_text)) .sense(Sense::click()), ) .on_hover_text(tip) @@ -713,7 +713,7 @@ fn variable_axes(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], styles: &[TextSt } if styles.iter().any(|s| !s.variations.is_empty()) { ui.add_space(8.0); - let r = ui.add(egui::Label::new(egui::RichText::new(tr("Reset to style")).font(regular(11.0)).color(t.accent)).sense(Sense::click())); + let r = ui.add(egui::Label::new(egui::RichText::new(tr("Reset to style")).font(regular(11.0)).color(t.accent_text)).sense(Sense::click())); if r.on_hover_text(tr("Back to the values the font style implies")).clicked() { app.run("text.setVariations", json!({"ids": ids, "variations": {}, "replace": true})); } @@ -789,7 +789,7 @@ fn axis_slider(ui: &mut Ui, id: Id, axis: &figcraft_text::Axis, value: Option, supported: match on { Some(true) => { ui.painter().rect_filled(b, CornerRadius::same(3), t.accent); - icons::paint(ui.painter(), b.shrink(1.5), Icon::Check, Color32::WHITE); + icons::paint(ui.painter(), b.shrink(1.5), Icon::Check, t.accent_on); } Some(false) => { ui.painter().rect_stroke(b, CornerRadius::same(3), Stroke::new(1.0, t.border_strong), StrokeKind::Inside); } None => { ui.painter().rect_filled(b, CornerRadius::same(3), t.accent); - ui.painter().line_segment([pos2(b.min.x + 3.5, b.center().y), pos2(b.max.x - 3.5, b.center().y)], Stroke::new(1.5, Color32::WHITE)); + ui.painter().line_segment([pos2(b.min.x + 3.5, b.center().y), pos2(b.max.x - 3.5, b.center().y)], Stroke::new(1.5, t.accent_on)); } } let col = if supported { t.text } else { t.text_tertiary }; @@ -1025,7 +1025,7 @@ fn lead_field(ui: &mut Ui, id: Id, glyph: LeadGlyph, value: &str, width: f32, ti crate::a11y::text(&resp, tip, value); ui.painter().rect_filled(rect, CornerRadius::same(5), t.field); if editing.is_some() { - ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.accent), StrokeKind::Inside); + ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, crate::theme::ACCENT), StrokeKind::Inside); } else if resp.hovered() { ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.field_hover_border), StrokeKind::Inside); } @@ -1274,7 +1274,7 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) if hovered { ui.painter().rect_filled(rect, CornerRadius::same(5), t.accent); } - let col = if hovered { Color32::WHITE } else { t.text }; + let col = if hovered { t.accent_on } else { t.text }; if current.is_some_and(|c| c.eq_ignore_ascii_case(f)) { icons::paint( ui.painter(), diff --git a/crates/ui-egui/src/ui_scale.rs b/crates/ui-egui/src/ui_scale.rs new file mode 100644 index 000000000..6b275a078 --- /dev/null +++ b/crates/ui-egui/src/ui_scale.rs @@ -0,0 +1,116 @@ +//! The interface size (Preferences ▸ Interface size, `pref.uiScale`): how the saved choice becomes +//! egui's zoom factor. +//! +//! egui draws at `pixels_per_point = zoom factor × the system's scale`. The size is relative to the +//! system's scale, so it is the zoom itself: 100 % is the system's own size on every screen (native +//! on a Retina Mac too), 125 % a quarter larger. A size above 100 % is held back while the window is +//! too small to lay the interface out at it; the saved choice never changes. + +/// The smallest window, in points, the interface is laid out for: the main window's minimum size. +pub const FIT_POINTS: (f32, f32) = (800.0, 500.0); + +/// The zoom factors egui's own settings window allows. +const ZOOM_RANGE: (f32, f32) = (0.1, 10.0); + +/// What to give egui for a saved choice on this screen and window. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Plan { + /// For `Context::set_zoom_factor`: the size drawn, relative to the system's scale. + pub zoom: f32, + /// Physical pixels per point once drawn (the zoom times the system's scale). + pub drawn: f32, + /// The window is too small for the size chosen, so a smaller one is drawn. + pub limited: bool, +} + +/// The plan for `choice` (relative to the system's scale), the system's scale `native` and the +/// window's size in physical pixels `window_px`. An unknown, zero or not finite value reads as no +/// information: a size of 1, a system scale of 1 and a window of any size. +pub fn plan(choice: f64, native: Option, window_px: Option<(f32, f32)>) -> Plan { + let native = native.filter(|n| n.is_finite() && *n > 0.0).unwrap_or(1.0); + let want = Some(choice as f32).filter(|c| c.is_finite() && *c > 0.0).unwrap_or(1.0); + // The largest zoom at which the window still holds the smallest layout. + let fit = window_px + .filter(|(w, h)| w.is_finite() && h.is_finite() && *w > 0.0 && *h > 0.0) + .map(|(w, h)| (w / FIT_POINTS.0).min(h / FIT_POINTS.1) / native); + // Never below 100 % (or the size asked, when that is smaller): a tiny window doesn't shrink the interface further. + let zoom = match fit { + Some(fit) if want > fit => fit.max(want.min(1.0)), + _ => want, + } + .clamp(ZOOM_RANGE.0, ZOOM_RANGE.1); + Plan { zoom, drawn: zoom * native, limited: zoom < want - 0.005 } +} + +/// A size as the percentage Preferences shows (1.25 → 125). +pub fn percent(scale: f32) -> u32 { + (scale * 100.0).round().clamp(0.0, 100_000.0) as u32 +} + +/// The sizes Preferences offers, relative to the system's scale (`pref.uiScale` takes up to 3). +pub const SIZES: [f64; 8] = [0.75, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0]; + +#[cfg(test)] +mod tests { + use super::*; + + const BIG: Option<(f32, f32)> = Some((20000.0, 12000.0)); + + #[test] + fn a_size_is_relative_to_the_systems_scale_on_every_screen() { + // Windows 100/125/150/175 %, Retina 2×, a bare X11 window manager's guessed 2×, a 3× screen. + for native in [1.0, 1.25, 1.5, 1.75, 2.0, 3.0] { + let p = plan(1.0, Some(native), BIG); + assert_eq!((p.zoom, p.drawn, p.limited), (1.0, native, false), "100 % is the system's own size on {native}"); + for size in SIZES { + let p = plan(size, Some(native), BIG); + assert!((p.zoom - size as f32).abs() < 1e-5, "{size} on {native}: zoom {}", p.zoom); + assert!((p.drawn - size as f32 * native).abs() < 1e-4, "{size} on {native}: drawn {}", p.drawn); + assert!(!p.limited, "{size} on {native}"); + } + } + } + + #[test] + fn a_missing_or_unusable_scale_reads_as_1_and_never_gives_egui_a_bad_zoom() { + for native in [None, Some(0.0), Some(-2.0), Some(f32::NAN), Some(f32::INFINITY)] { + for choice in [1.0, 1.5, f64::NAN, f64::INFINITY, -1.0, 0.0, 1e300] { + let p = plan(choice, native, Some((f32::NAN, 0.0))); + assert!(p.zoom.is_finite() && (ZOOM_RANGE.0..=ZOOM_RANGE.1).contains(&p.zoom), "{choice:?} {native:?}: {p:?}"); + assert!(p.drawn.is_finite() && p.drawn > 0.0, "{choice:?} {native:?}: {p:?}"); + } + } + // A choice that isn't a usable number is the system's own size. + assert_eq!(plan(f64::NAN, Some(2.0), BIG), plan(1.0, Some(2.0), BIG)); + } + + #[test] + fn a_small_window_holds_a_big_size_back_without_going_below_100() { + // The minimum window on a Retina Mac: 800 × 500 points is 1600 × 1000 pixels, which holds 100 % only. + let min = Some((1600.0, 1000.0)); + let p = plan(1.5, Some(2.0), min); + assert_eq!((p.zoom, p.drawn, p.limited), (1.0, 2.0, true)); + assert!(!plan(1.0, Some(2.0), min).limited); + // A larger window on a 1× screen: 1200 × 750 pixels hold 150 %. + let p = plan(2.0, Some(1.0), Some((1200.0, 750.0))); + assert_eq!((p.zoom, p.limited), (1.5, true)); + // A window smaller than the minimum (a tiling window manager): never below 100 %, and never + // below a smaller size the person chose. + assert_eq!(plan(2.0, Some(1.0), Some((400.0, 250.0))).zoom, 1.0); + let p = plan(0.75, Some(1.0), Some((400.0, 250.0))); + assert_eq!((p.zoom, p.limited), (0.75, false)); + } + + #[test] + fn the_limit_does_not_depend_on_the_zoom_so_it_cannot_flicker() { + // The window's size in pixels is the same whatever zoom is drawn now, so the plan for it is too. + let px = Some((1700.0, 1100.0)); + assert_eq!(plan(2.5, Some(2.0), px), plan(2.5, Some(2.0), px)); + } + + #[test] + fn percentages_round_like_the_menu_shows_them() { + assert_eq!([percent(0.75), percent(0.9), percent(1.1), percent(1.25), percent(2.0), percent(1.7999)], [75, 90, 110, 125, 200, 180]); + assert_eq!([percent(f32::NAN), percent(-1.0)], [0, 0]); + } +} diff --git a/crates/ui-egui/src/ui_scale_tests.rs b/crates/ui-egui/src/ui_scale_tests.rs new file mode 100644 index 000000000..018c963a1 --- /dev/null +++ b/crates/ui-egui/src/ui_scale_tests.rs @@ -0,0 +1,211 @@ +//! The interface size, driven through egui headless on screens of different system scales: 100 % +//! leaves the system's scale alone, another size is relative to it on every screen, and the +//! Preferences drop-down tells a screen reader its name and value. +//! +//! The test window's size is fixed in points, so it grows with the zoom here; the limit for a small +//! window is tested on its own in `ui_scale::tests`, and the sizes below stay inside it. + +use egui::accesskit::Role; +use egui::{RawInput, ViewportId, ViewportInfo}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +/// One frame on a screen whose system scale is `native` (a real window reports it every frame). +fn frame_at(u: &mut Ui, native: f32) -> egui::FullOutput { + let mut raw = RawInput::default(); + raw.viewports.insert(ViewportId::ROOT, ViewportInfo { native_pixels_per_point: Some(native), ..Default::default() }); + u.frame_raw(raw) +} + +/// Frames on that screen, enough for a new zoom to take effect. +fn settle(u: &mut Ui, native: f32) { + for _ in 0..4 { + frame_at(u, native); + } +} + +const SCREENS: [f32; 5] = [1.0, 1.25, 1.5, 2.0, 3.0]; + +#[test] +fn by_default_the_interface_is_the_systems_own_size() { + for native in SCREENS { + let mut u = Ui::new(Session::new()); + settle(&mut u, native); + assert_eq!(u.ctx.zoom_factor(), 1.0, "{native}"); + assert_eq!(u.ctx.pixels_per_point(), native, "{native}"); + } +} + +#[test] +fn a_size_is_relative_to_the_systems_scale_on_every_screen_and_auto_is_100() { + for native in SCREENS { + let mut u = Ui::new(Session::new()); + for size in [0.75_f64, 0.9, 1.25, 1.5] { + assert!(u.app.run("pref.uiScale", json!({"scale": size})).is_some()); + settle(&mut u, native); + let drawn = u.ctx.pixels_per_point(); + assert!((drawn - size as f32 * native).abs() < 1e-4, "{size} on a {native}× screen drew at {drawn}"); + assert!((u.ctx.zoom_factor() - size as f32).abs() < 1e-5, "{size} on {native}"); + assert!(!u.app.ui.ui_scale.limited, "{size} on {native}"); + } + assert!(u.app.run("pref.uiScale", json!({"scale": "auto"})).is_some()); + settle(&mut u, native); + assert_eq!((u.ctx.zoom_factor(), u.ctx.pixels_per_point()), (1.0, native), "{native}"); + } +} + +#[test] +fn the_interface_size_menu_has_a_name_a_value_and_a_state_for_a_screen_reader() { + let mut u = Ui::new(Session::new()); + u.app.ui.preferences = true; + u.ctx.enable_accesskit(); + let combo = |u: &mut Ui| { + settle(u, 2.0); + let out = frame_at(u, 2.0); + let nodes: Vec<_> = out.platform_output.accesskit_update.expect("an AccessKit update").nodes.into_iter().map(|(_, n)| n).collect(); + let n = nodes.iter().find(|n| n.role() == Role::ComboBox && n.label() == Some("Interface size")).expect("an Interface size menu"); + (n.value().map(str::to_owned), n.is_expanded()) + }; + // 100 % is the system's own size, whatever it is (2× here). + assert_eq!(combo(&mut u), (Some("100%".to_owned()), Some(false))); + u.app.run("pref.uiScale", json!({"scale": 1.25})); + assert_eq!(combo(&mut u), (Some("125%".to_owned()), Some(false))); +} + +/// Every window size, system scale and interface size, drawn the way a real window is: the window is +/// a fixed number of physical pixels, and its size in points is that divided by the scale drawn now +/// (egui-winit does the same each frame), so a bigger size shrinks the window in points. +/// +/// What a fixed size promises: whatever it draws at above 100 %, the window still holds the smallest +/// layout the interface is made for (`ui_scale::FIT_POINTS`), and the panels leave room for a canvas. +#[test] +fn every_window_size_system_scale_and_interface_size_lays_out_inside_the_window() { + use crate::ui_scale::{FIT_POINTS, SIZES}; + use crate::{FigApp, NoServices}; + use egui::{Pos2, Rect, vec2}; + + // Window sizes in points: the main window's minimum, common laptop and desktop screens up to 5K, + // and a short wide strip. + let windows: [(f32, f32); 10] = [ + (800.0, 500.0), + (1024.0, 640.0), + (1280.0, 720.0), + (1366.0, 768.0), + (1440.0, 900.0), + (1920.0, 1080.0), + (2560.0, 1440.0), + (3840.0, 2160.0), + (5120.0, 2880.0), + (2560.0, 520.0), + ]; + + for native in [1.0_f32, 1.25, 1.5, 1.75, 2.0, 3.0] { + for (w, h) in windows { + let px = vec2(w * native, h * native); + for want in SIZES { + let case = format!("{w}x{h} pt at {native}x, {want}"); + let mut app = FigApp::new(Session::new(), Box::new(NoServices)); + assert!(app.run("pref.uiScale", json!({"scale": want})).is_some(), "{case}"); + let ctx = egui::Context::default(); + let mut time = 0.0; + let mut seen = Vec::new(); + for _ in 0..8 { + time += 1.0 / 60.0; + let ppp = ctx.pixels_per_point(); + let mut raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, px / ppp)), time: Some(time), ..Default::default() }; + raw.viewports.insert(ViewportId::ROOT, ViewportInfo { native_pixels_per_point: Some(native), ..Default::default() }); + let mut out = ctx.run_ui(raw, |ui| app.ui(ui)); + out.textures_delta.clear(); + seen.push((ctx.pixels_per_point(), ctx.content_rect().size())); + } + // It settles within a few frames and then stays put: no flicker between two sizes. + let (ppp, content) = seen[seen.len() - 1]; + assert_eq!(seen[seen.len() - 2].0, ppp, "{case}: still changing"); + assert!(ppp.is_finite() && ppp > 0.0 && content.x.is_finite() && content.y.is_finite(), "{case}: {ppp} {content:?}"); + let plan = app.ui.ui_scale; + assert!((ppp - plan.drawn).abs() < 1e-3, "{case}: egui draws at {ppp}, the plan said {}", plan.drawn); + // The size drawn, relative to the system's scale. + let zoom = ppp / native; + let want = want as f32; + assert_eq!(plan.limited, zoom < want - 0.005, "{case}: limited flag"); + // Above 100 % the window holds the smallest layout; at or below it there is nothing more to give up. + if zoom > 1.001 { + assert!(content.x >= FIT_POINTS.0 - 0.5 && content.y >= FIT_POINTS.1 - 0.5, "{case}: window is {content:?} points at {ppp}"); + } + // It never draws bigger than asked, and only a window too small for it draws smaller. + assert!(zoom <= want + 1e-3, "{case}"); + if !plan.limited { + assert!((zoom - want).abs() < 1e-3, "{case}"); + } + let panels = &app.inspect()["panels"]; + let side = panels["left"].as_f64().unwrap_or(0.0) + panels["right"].as_f64().unwrap_or(0.0); + assert!(side < f64::from(content.x) - 100.0, "{case}: the panels ({side} pt) leave no canvas in {content:?}"); + } + } + } +} + +/// The interface size is reachable and changeable without a pointer: Tab reaches the menu, Enter +/// opens it, the arrows and Tab move through its entries, Enter picks one and closes the menu, and +/// Escape closes it alone, without changing anything and without closing Preferences (a second +/// Escape does). +#[test] +fn the_interface_size_menu_works_from_the_keyboard() { + use crate::a11y::focused_name; + use egui::{Event, Key, Modifiers, Popup}; + + // A key press, then two quiet frames for the focus (and the name a screen reader reads) to settle. + let press = |u: &mut Ui, key: Key| { + u.frame(vec![ + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }, + Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }, + ]); + u.frame(vec![]); + u.frame(vec![]); + }; + let open = |u: &mut Ui| { + // The Preferences dialog is not modal for the keyboard: Tab crosses the main window first. + let mut tabs = 0; + while focused_name(&u.ctx) != "Interface size" { + assert!(tabs < 140, "Tab never reaches the Interface size menu (focus: {:?})", focused_name(&u.ctx)); + press(u, Key::Tab); + tabs += 1; + } + press(u, Key::Enter); + assert!(Popup::is_any_open(&u.ctx), "Enter opens the menu"); + }; + let chosen = |u: &Ui| u.app.inspect()["uiScale"]["choice"].clone(); + + let mut u = Ui::new(Session::new()); + u.app.ui.preferences = true; + u.ctx.enable_accesskit(); + settle(&mut u, 1.0); + assert_eq!(chosen(&u), json!(1.0)); + + // Escape closes the menu and nothing else: the size is unchanged and Preferences stays open. + open(&mut u); + press(&mut u, Key::Escape); + u.frame(vec![]); + assert!(!Popup::is_any_open(&u.ctx)); + assert!(u.app.ui.preferences, "the first Escape closes the menu only"); + assert_eq!(chosen(&u), json!(1.0)); + + // The arrows enter the menu on its first size and go down through the sizes (75, 90, 100, 110, + // 125); Enter picks the one that has the keyboard and closes the menu. + open(&mut u); + press(&mut u, Key::ArrowDown); + assert_eq!(focused_name(&u.ctx), "75%"); + for _ in 0..4 { + press(&mut u, Key::ArrowDown); + } + assert_eq!(focused_name(&u.ctx), "125%"); + press(&mut u, Key::Enter); + assert_eq!(chosen(&u), json!(1.25)); + assert!(!Popup::is_any_open(&u.ctx), "picking a size closes the menu"); + assert!(u.app.ui.preferences, "picking a size leaves Preferences open"); + // With no menu open, Escape closes Preferences as before. + press(&mut u, Key::Escape); + assert!(!u.app.ui.preferences); +} diff --git a/crates/ui-egui/src/variable_ui.rs b/crates/ui-egui/src/variable_ui.rs index e6cc6860e..39c5396e9 100644 --- a/crates/ui-egui/src/variable_ui.rs +++ b/crates/ui-egui/src/variable_ui.rs @@ -17,7 +17,7 @@ use crate::a11y::Tip; use crate::i18n::tr; use crate::icons::{self, Icon}; use crate::theme::{Tokens, regular, semibold}; -use crate::widgets::{self, BindEvent, Edit, FieldEdit, Lead}; +use crate::widgets::{self, BindEvent, Edit, Empty, FieldEdit, Lead}; /// What picking a variable does. #[derive(Clone)] @@ -258,7 +258,23 @@ pub fn number_field_or( lead: Lead, suffix: &str, ) -> Option { - number_field_edit(app, ui, ids, field, id, value, width, empty, lead, suffix)?.settled() + number_field_edit(app, ui, ids, field, id, value, width, Empty::Text(empty), lead, suffix)?.settled() +} + +/// [`number_field_or`] for a field where 0 is its automatic value (a layout grid's width or +/// count): typing the word for Auto sets 0 ([`Empty::Auto`]). +pub fn number_field_auto( + app: &mut FigApp, + ui: &mut Ui, + ids: &[NodeId], + field: &str, + id: Id, + value: Option, + width: f32, + lead: Lead, + suffix: &str, +) -> Option { + number_field_edit(app, ui, ids, field, id, value, width, Empty::Auto, lead, suffix)?.settled() } /// [`number_field`] over several layers: a relative edit of mixed values comes back as one for @@ -274,7 +290,7 @@ pub fn number_field_mixed( suffix: &str, width: f32, ) -> Option { - number_field_edit(app, ui, ids, field, id, value, width, tr("Mixed"), lead, suffix) + number_field_edit(app, ui, ids, field, id, value, width, Empty::Text(tr("Mixed")), lead, suffix) } fn number_field_edit( @@ -285,7 +301,7 @@ fn number_field_edit( id: Id, value: Option, width: f32, - empty: &str, + empty: Empty<'_>, lead: Lead, suffix: &str, ) -> Option { diff --git a/crates/ui-egui/src/web_clipboard_tests.rs b/crates/ui-egui/src/web_clipboard_tests.rs index 26389ff48..bda57373b 100644 --- a/crates/ui-egui/src/web_clipboard_tests.rs +++ b/crates/ui-egui/src/web_clipboard_tests.rs @@ -141,6 +141,24 @@ fn text_copied_elsewhere_after_our_copy_still_pastes_as_text() { assert_eq!(p.count(text), 1, "newer text from elsewhere becomes a text layer"); } +#[test] +fn copying_text_layers_puts_their_text_on_the_clipboard() { + // #808: other apps get a text layer's text, not its SVG; FigCraft still pastes the layers. + let mut p = Page::new(); + let a = p.app.session.execute("text.create", &json!({"text": "Hello", "x": 0, "y": 200})).unwrap(); + let b = p.app.session.execute("text.create", &json!({"text": "World", "x": 0, "y": 300})).unwrap(); + p.app.session.execute("select.set", &json!({"ids": [a["id"], b["id"]]})).unwrap(); + p.frame(vec![]); + p.copy(); + assert_eq!(p.clipboard, "Hello\nWorld", "one line per text layer"); + p.paste(); + assert_eq!(p.count(text), 4, "the copied text layers were pasted, not one layer of their text"); + let r = p.app.session.doc().unwrap().nodes().find(|n| rect(&n.kind)).map(|n| n.id.0).unwrap(); + p.app.session.execute("select.set", &json!({"ids": [r, a["id"]]})).unwrap(); + p.copy(); + assert!(p.clipboard.contains(" { } } +/// What a number field shows when it has no single value, and whether that word types 0. +#[derive(Clone, Copy)] +pub enum Empty<'a> { + /// Dim text that is only a label ("Mixed", or "Auto" where 0 means something else). + Text(&'a str), + /// Auto, in the UI's language: 0 is the field's automatic value, so the word types 0. + Auto, +} + +impl<'a> Empty<'a> { + /// The text shown when the field has no value. + fn text(self) -> &'a str { + match self { + Empty::Text(s) => s, + Empty::Auto => tr("Auto"), + } + } +} + /// Result of a field edit. pub enum FieldEdit { /// Committed a new value (Enter, Tab or focus loss; an arrow key's step). @@ -157,18 +176,19 @@ pub fn number_field(ui: &mut Ui, id: Id, lead: Lead, value: Option, suffix: /// [`number_field`] over several layers: without a single value (`value` None, "Mixed") a relative /// edit comes back as one for each layer ([`Edit::Each`]). pub fn number_field_mixed(ui: &mut Ui, id: Id, lead: Lead, value: Option, suffix: &str, width: f32, step: f64) -> Option { - number_field_bindable(ui, id, lead, value, suffix, width, step, None, tr("Mixed")).0 + number_field_bindable(ui, id, lead, value, suffix, width, step, None, Empty::Text(tr("Mixed"))).0 } /// [`number_field`] that shows `empty` (dim) for no value instead of "Mixed" (e.g. "Auto"): there /// is no value to edit relative to, so `+4` is 4. pub fn number_field_or(ui: &mut Ui, id: Id, value: Option, empty: &str, width: f32, step: f64) -> Option { - number_field_impl(ui, id, Lead::None, value, "", width, step, None, empty).0?.settled() + number_field_impl(ui, id, Lead::None, value, "", width, step, None, Empty::Text(empty)).0?.settled() } /// [`number_field`] that can bind a variable: `binding` None = not bindable, `Some(None)` = shows /// a variable button on hover, `Some(Some(name))` = bound (shows the name; click to change, the -/// detach button unbinds). No value shows `empty` ("Mixed", "Auto"). +/// detach button unbinds). No value shows `empty` ("Mixed", "Auto"); [`Empty::Auto`] also takes +/// the word for Auto as 0. pub fn number_field_bindable( ui: &mut Ui, id: Id, @@ -178,7 +198,7 @@ pub fn number_field_bindable( width: f32, step: f64, binding: Option>, - empty: &str, + empty: Empty<'_>, ) -> (Option, Option) { number_field_impl(ui, id, lead, value, suffix, width, step, binding, empty) } @@ -192,7 +212,7 @@ fn number_field_impl( width: f32, step: f64, binding: Option>, - empty: &str, + empty: Empty<'_>, ) -> (Option, Option) { let t = Tokens::get(ui.ctx()); let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover()); @@ -210,14 +230,14 @@ fn number_field_impl( let editing_id = id.with("editing"); let mut editing: Option = ui.data(|d| d.get_temp(editing_id)); let field_resp = ui.interact(edit_rect, id.with("field"), Sense::click()); - a11y::number(&field_resp, lead.name(), value, suffix, empty); + a11y::number(&field_resp, lead.name(), value, suffix, empty.text()); a11y::ring_around(ui, rect, &field_resp); // The label scrubs with the pointer; the keyboard stops at the value only. let lead_resp = ui.interact(lead_rect, id.with("lead"), Sense::CLICK | Sense::DRAG); let hovered = field_resp.hovered() || lead_resp.hovered() || editing.is_some(); ui.painter().rect_filled(rect, CornerRadius::same(5), t.field); if editing.is_some() { - ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.accent), StrokeKind::Inside); + ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, crate::theme::ACCENT), StrokeKind::Inside); } else if hovered { ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.field_hover_border), StrokeKind::Inside); } @@ -259,10 +279,21 @@ fn number_field_impl( }; // A relative edit of no single value goes to each layer's own. let set = |v: f64| Edit::All(FieldEdit::Set(v)); - let typed = |text: &str| match (parse(text, suffix == "%")?, value) { - (Typed::Value(v), _) => Some(set(v)), - (Typed::Rel(r), Some(cur)) => r.apply(cur).map(set), - (Typed::Rel(r), None) => Some(Edit::Each(r)), + let typed = |text: &str| { + // A field where 0 is its automatic value takes the word for Auto as input too: the + // placeholder's own text (so it is what the field shows, in the UI's language) or the + // English word. + if let Empty::Auto = empty { + let t = text.trim(); + if t == empty.text() || t.eq_ignore_ascii_case("auto") { + return Some(set(0.0)); + } + } + match (parse(text, suffix == "%")?, value) { + (Typed::Value(v), _) => Some(set(v)), + (Typed::Rel(r), Some(cur)) => r.apply(cur).map(set), + (Typed::Rel(r), None) => Some(Edit::Each(r)), + } }; // With the keyboard on the field (Tab) and nothing typed yet, the arrows step it as it is. if editing.is_none() @@ -349,7 +380,7 @@ fn number_field_impl( None => { let s = match value { Some(v) => format!("{}{}", fmt_num(v), suffix), - None => empty.to_string(), + None => empty.text().to_string(), }; let col = if value.is_some() { t.text } else { t.text_secondary }; // A value too long for its room ends in "…", so a cut number never reads as another. @@ -507,7 +538,7 @@ fn text_field_in(ui: &mut Ui, id: Id, value: &str, rect: Rect, placeholder: &str a11y::text(&resp, name, value); ui.painter().rect_filled(rect, CornerRadius::same(5), t.field); if editing.is_some() { - ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.accent), StrokeKind::Inside); + ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, crate::theme::ACCENT), StrokeKind::Inside); } else if resp.hovered() { ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.field_hover_border), StrokeKind::Inside); } @@ -570,7 +601,7 @@ pub fn text_input(ui: &mut Ui, id: Id, text: &mut String, width: f32, hint: &str ui.painter().rect_filled(rect, CornerRadius::same(5), t.field); let r = field_editor(ui, rect, id, text, hint); if r.has_focus() { - ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.accent), StrokeKind::Inside); + ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, crate::theme::ACCENT), StrokeKind::Inside); } else if ui.rect_contains_pointer(rect) { ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.field_hover_border), StrokeKind::Inside); } @@ -974,7 +1005,7 @@ pub fn checkbox(ui: &mut Ui, on: bool, label: &str) -> bool { let b = Rect::from_min_size(pos2(rect.min.x, rect.center().y - 7.0), vec2(14.0, 14.0)); if on { ui.painter().rect_filled(b, CornerRadius::same(3), t.accent); - icons::paint(ui.painter(), b.shrink(1.5), Icon::Check, Color32::WHITE); + icons::paint(ui.painter(), b.shrink(1.5), Icon::Check, t.accent_on); } else { ui.painter().rect_stroke(b, CornerRadius::same(3), Stroke::new(1.0, t.border_strong), StrokeKind::Inside); } @@ -990,35 +1021,35 @@ pub fn radio(ui: &mut Ui, on: bool, label: &str) -> bool { let c = pos2(rect.min.x + 7.0, rect.center().y); if on { ui.painter().circle_filled(c, 7.0, t.accent); - ui.painter().circle_filled(c, 2.5, Color32::WHITE); + ui.painter().circle_filled(c, 2.5, t.accent_on); } else { - ui.painter().circle_stroke(c, 6.5, Stroke::new(1.0, if resp.hovered() { t.accent } else { t.border_strong })); + ui.painter().circle_stroke(c, 6.5, Stroke::new(1.0, if resp.hovered() { crate::theme::ACCENT } else { t.border_strong })); } ui.painter().text(pos2(rect.min.x + 22.0, rect.center().y), Align2::LEFT_CENTER, label, regular(11.0), t.text); resp.clicked() } /// A text button: `primary` is filled with the accent colour, otherwise outlined. A disabled -/// button is dimmed and never reports clicks. +/// button is grey with dim text (still readable) and never reports clicks. pub fn button(ui: &mut Ui, label: &str, primary: bool, enabled: bool) -> Response { let t = Tokens::get(ui.ctx()); let galley = ui.painter().layout_no_wrap(label.to_string(), regular(11.0), t.text); let sense = if enabled { Sense::click() } else { Sense::hover() }; let (rect, resp) = ui.allocate_exact_size(vec2(galley.size().x + 24.0, FIELD_H), sense); a11y::button(&resp, label); - let hot = enabled && resp.hovered(); - let (fill, text) = match (primary, hot) { - (true, false) => (t.accent, Color32::WHITE), - (true, true) => (t.accent_hover, Color32::WHITE), - (false, false) => (Color32::TRANSPARENT, t.text), - (false, true) => (t.hover, t.text), + let (fill, text) = match (enabled, primary, resp.hovered()) { + (false, true, _) => (t.field, t.text_tertiary), + (false, false, _) => (Color32::TRANSPARENT, t.text_tertiary), + (true, true, false) => (t.accent, t.accent_on), + (true, true, true) => (t.accent_hover, t.accent_on), + (true, false, false) => (Color32::TRANSPARENT, t.text), + (true, false, true) => (t.hover, t.text), }; - let dim = |c: Color32| if enabled { c } else { c.gamma_multiply(0.4) }; - ui.painter().rect_filled(rect, CornerRadius::same(5), dim(fill)); + ui.painter().rect_filled(rect, CornerRadius::same(5), fill); if !primary { ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.border_strong), StrokeKind::Inside); } - ui.painter().text(rect.center(), Align2::CENTER_CENTER, label, regular(11.0), dim(text)); + ui.painter().text(rect.center(), Align2::CENTER_CENTER, label, regular(11.0), text); resp } @@ -1046,7 +1077,7 @@ pub fn text_field_inline(ui: &mut Ui, id: Id, value: &str, rect: Rect, font: egu match buf { Some(mut text) => { ui.painter().rect_filled(rect.expand(2.0), CornerRadius::same(4), t.panel); - ui.painter().rect_stroke(rect.expand(2.0), CornerRadius::same(4), Stroke::new(1.0, t.accent), StrokeKind::Inside); + ui.painter().rect_stroke(rect.expand(2.0), CornerRadius::same(4), Stroke::new(1.0, crate::theme::ACCENT), StrokeKind::Inside); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect)); let r = crate::bidi::single_line(&mut child, te_id, &mut text, font, rect.width()); a11y::own_focus(&r); diff --git a/docs/cli.md b/docs/cli.md index 7af73fd60..699b6ba27 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -153,6 +153,28 @@ figcraft-cli run start.json --out dashboard.figcraft figcraft-cli export dashboard.figcraft -o dashboard.png ``` +### Confining a run: `--automation-read-root DIR` / `--automation-write-root DIR` + +`info`, `export`, `convert`, `render` and `run` take these two flags, with the same meaning they +have on [`mcp`](#mcp---attach-port---automation-read-root-dir---automation-write-root-dir): each is +repeatable, each folder must exist, and together they confine every file that subcommand touches. +Reads are allowed inside a read or write root, writes only inside a write root, and everything else +is refused — so a run given only a read root cannot write at all. Without either flag a subcommand +reads and writes as the user who started it. + +```sh +# Renders in/doc.figcraft to out/ok.png and can touch nothing else. +figcraft-cli render in/doc.figcraft -o out/ok.png \ + --automation-read-root in --automation-write-root out + +figcraft-cli render in/doc.figcraft -o ./escaped.png \ + --automation-read-root in --automation-write-root out +# error: automation path rejected: . is outside the folders that may be written: …/out +``` + +This is for driving the CLI from a script or an agent that should not reach the rest of the disk; +the flags leave the output identical when nothing strays outside the roots. + ### `commands [--json] [--filter TEXT]` Every command with its label, shortcut, undoable flag (`*`) and parameter docs; `--json` for diff --git a/docs/components-parity.md b/docs/components-parity.md index 3b9793b03..dcd93305f 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -1,6 +1,6 @@ # Components, variants, styles and variables parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300 @@ -17,6 +17,7 @@ accounts). | What an instance overrides: fills, strokes, effects, visibility, layer names, text content and every text property (resizing included), auto layout padding, gap and alignment, layout grids, export settings, prototype interactions, nested swaps and property values; the size and resizing (hug, fill, fixed, min and max) of layers inside it | done | each kept when the main changes that property, reset per group; opacity and corner radius too (documentation silent); size and resizing are kept through relayout, undo, save, `.fig` and REST import and detach, and stay with the instance on push | | What it doesn't: position, rotation, constraints and absolute or grid placement of layers inside it, its auto layout direction and other settings, its structure | done | refused with a message, the fields greyed out; the instance itself resizes and its layers follow the main's constraints | | Component sets / variants, add variant, switch | done | | +| A main's copy within its file is an instance of it: Duplicate, Alt-drag, Copy then Paste | done | a variant duplicated in its set stays a new variant and a whole set copies as a new set; only the copied roots become instances (a main inside a copied frame is copied as a main); Cut then Paste and pastes into other files keep the component (#892) | | Switching variants keeps overrides where names match and both variants had the same value; text layers keep theirs | done | | | Default variant: the top-left one (new instances, Assets thumbnail, generated code) | done | | | Variant property order (names rewritten), duplicate combinations reported | done | drag a property in the Properties section to reorder it, one undo step (#679), or `component.editProperty {index}` | @@ -65,17 +66,20 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Modes: add, duplicate, rename, delete, set as default / reorder | done | layers on Auto follow a new default; explicit modes stay | | Deleting a bound variable or its collection | done | layers keep their last value and are unbound; aliases to it take its values | | Code syntax per platform (Web, iOS, Android) | done | generated CSS uses the Web name; in the variables JSON; SwiftUI/Compose code doesn't name variables yet | -| Bindings: fills, strokes, effects, opacity, padding, gaps, sizes, radius, font size/weight, line height, letter spacing, paragraph spacing/indent, text, visibility, layout grids, instance properties | done | type-checked per field; number variables fill text content | +| Bindings: fills, strokes, effects, opacity, padding, gaps, sizes, radius, font size/weight, line height, letter spacing, paragraph spacing/indent, text, visibility, layout grids, instance properties | done | type-checked per field; number variables fill text content; a colour variable bound to a solid paint brings its whole colour, alpha included, once (the paint's opacity restarts at 100% on binding, an opacity set while bound multiplies on top; unbinding moves the alpha back into the opacity) | | Explicit modes per frame, library collections' modes | done | | | Gradient stops and variant properties bound to variables | done | a stop's binding follows it when earlier stops are removed | | Extended collections | done | library collections, including a library's own extended collections (chains through libraries); overrides per mode with reset; follow library updates, variables new to the library collection being an update of their own; modes inherited. Checked with synthetic libraries only ([gaps](gaps.md) #14) | -| Variables import/export (JSON) | done | the REST local variables shape (round-trips; merges by collection, mode and variable name); CSS custom properties export; in the browser the export arrives as a download (import there still needs a file picker) | +| Variables import/export (JSON) | done | the REST local variables shape (round-trips; merges by collection, mode and variable name; `variableCollections` and `variables` read under `meta` or at the top level, a warning when no variables are found or fewer than the collections list); CSS custom properties export; in the browser the export arrives as a download (import there still needs a file picker) | | Expressions in prototype set-variable actions | done | see [prototyping-parity](prototyping-parity.md) | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-20 | minor | A main component's copy within its file is an instance of it (#892): Duplicate (`edit.duplicate`) and Alt-drag on the canvas made a second main; they now place an instance where the copy went (same offset, the instance's default name, selected), as Copy then Paste already did. A variant duplicated in its set stays a new variant. | +| 2026-10-18 | minor | A bound colour variable's alpha applies once (#802): a `#RRGGBBAA` paint keeps its alpha in the paint opacity, and binding a variable with alpha left that opacity in place, so a 50% fill bound to a 50% variable drew at 25%. Binding (`variable.bind`, `node.set` `boundVariables`, `style.bind`) now restarts the paint's opacity at 100% and unbinding (also deleting the variable) moves the colour's alpha back into the opacity; `.fig` and REST imports take the variable's alpha back out of a bound paint's opacity, so `.fig` round trips no longer square it. Colour-picker edits of a colour variable or a plugin colour field, generated CSS strokes and the document colours list counted the paint opacity twice and now count it once (`bound_alpha_tests`). Numbers unchanged | +| 2026-10-18 | minor | Variables import (#790): a `variables` map outside `meta` (or `meta.variables` next to top-level collections) is read, and an import that finds no variables, or fewer than the collections' `variableIds` list, says so in `warnings` instead of succeeding silently | | 2026-10-17 | minor | Variant properties reorder by dragging them in the Properties section, one undo step (#679; was "no drag in the panel yet"); a library file saved again right after it was read is read again, so a quick republish isn't missed (#652); the Hidden from publishing row no longer says the Hide when publishing item is missing (it landed 2026-10-12). Numbers unchanged | | 2026-10-17 | minor | Size and resizing overrides of layers inside instances (#731): a layer in an instance can hug, fill or keep a size of its own (group `resizing`: hug/fill/fixed with min and max; `size`), from the Layout panel, the canvas handles and `node.set`; they were refused, and imported ones were dropped at the first relayout. Kept through main edits of other properties, rebuilds, undo, save, `.fig` and REST import (sizes a `.fig` instance gives its layers and the REST `overrides` fields), `.fig` export and detach; reset per group and with Reset all changes (`instance_resizing_tests`). The row that listed them as not overridable was wrong. Numbers unchanged. | | 2026-10-14 | minor | Variables export in the browser: Export… ▸ Variables JSON / CSS custom properties did nothing in the web build, which has no save dialogs; it now hands the file over as a download (`Services::downloads` / `download`, `variables_export_ui_tests`). Import in the browser still needs a file picker, as do the other exports there. Numbers unchanged. | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3ae9d287b..c9ae53313 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -71,7 +71,10 @@ and what a person does in the window are held to the same folders. Files are rea read or write root and written only inside a write root; anything else is an ordinary error reply (`automation path rejected: …`), and recent files outside the roots aren't listed. Paths are resolved as `docs/mcp.md` › Confining file access describes. A root that isn't an existing folder -stops the app before its window opens. Without the flags nothing changes. +stops the app before its window opens, and so does any option the app doesn't know (a mistyped +flag such as `--automation-write-roots` exits with `unknown option`, never starts unconfined). +Without the flags nothing changes. The flags arrived in FigCraft 0.7.0: 0.6.0 and earlier ignore +them and confine nothing. Not confined: opening the files given on the command line when the app starts and the documents the system hands over (Finder, Open With; saving them later is confined like any write), and the @@ -128,7 +131,9 @@ component/instance links, overrides, `slot` (a slot layer's property name), boun `devStatus` (handoff status), `devChanged` (changed since it was marked ready for dev), `annotations` (Dev Mode notes: `[{label, properties, category?}]`), `codeLinks` (a component's links to code: `[{language, importPath, name, template}]`), `devResources` (links for developers, -its own and inherited: `[{name, url, index?, from?}]`). The auto layout props +its own and inherited: `[{name, url, index?, from?}]`), `pluginData` (layer data written with +`node.set {props: {pluginData: {key: value}}}`, pages included: `{key: value}`, values as strings; +only when there is some). The auto layout props that `node.set` accepts are listed in [mcp.md](mcp.md#conventions-agents-must-know). ### `ui.inspect {}` @@ -169,7 +174,10 @@ instance, a dragged axis slider; see `text.preview` in [mcp.md](mcp.md)). `canvas` or `properties`), whether `keyboard` mode is on (F6 or Tab moved the focus since the last pointer press, so the focus ring shows) and the name of the `control` with the keyboard (what a screen reader announces; empty when nothing has it). `reduceMotion` is true when the preference or -the system's accessibility setting turns the interface's animations off. `live` is the live +the system's accessibility setting turns the interface's animations off. `uiScale` is the interface +size: the `choice` (relative to the system's display scale, `pref.uiScale`), the `zoom` drawn +(relative too), what that is in physical pixels per point (`drawn`) and whether the window is too +small for the choice (`limited`, so `zoom` is smaller). `live` is the live session of the window's active file as the window shows it (sessions are per file: another tab shows its own, or `status: "off"`): `session` (its id, null when the file isn't in one), `sessions` (how many the app has running), `status`, `role`, the number of `peers`, who you `follow`, the diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b65f77292..adf0b0f2c 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -19,7 +19,7 @@ and prototype interactions are still unchecked there. |---|---|---|---|---|---| | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | | `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | -| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | +| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — | | Local library files | team libraries in the cloud | yes (enable a `.figcraft` as a library) | yes (publish) | per-asset versions, updates with overrides kept | see [collaboration-parity](collaboration-parity.md) | @@ -165,6 +165,7 @@ each. | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | REST file JSON import keeps `SLOT` layers (#792): they were dropped with everything placed in them, which emptied every instance's slot. A slot in a main component becomes a slot frame bound to its `SLOT` property (the one it references, else the one named like it, else the only one left), and an instance's slot holds the instance's own content, kept across syncs, moves of the mains and saving, unless the instance's property value is the main's default content (without values, a slot that holds layers keeps them). Instances in slot content link to their own mains. Slot content limits, preferred-only and preferred components are warnings. Synthetic files only. Numbers unchanged | | 2026-10-17 | minor | REST file JSON import reads grid track sizes from the CSS track strings `gridColumnsSizing` / `gridRowsSizing` (fixed px, fill fr, hug `auto`, `minmax(0, 1fr)`, `repeat()`; other tracks stay equal) (#465). Numbers unchanged | | 2026-10-17 | minor | The community batches #727 and #728 in the `.fig` row: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments (also REST), images in the message; budgets from the file's size; the journal keeps no file bytes; the import report. Not read cut to what's left. A `.fig` copy from Save as. Pasted content: layers copied from another design app, best effort (tested only with clipboard HTML made from our own export), and images under Wayland (not run there). All on synthetic files: summary and hours unchanged | | 2026-10-17 | minor | Hug on import (#732): auto layout frames a `.fig` records no primary-axis sizing for hug on that axis (auto layout sized that axis by its content before it could be fixed) instead of turning fixed, instances that record no auto layout take their main's (they came in without auto layout and fixed, so longer text overflowed them), REST frames whose sizing modes are left out hug (the documented default), and text hugs by its resize mode (auto width both axes, auto height its height; it came in fixed). Numbers unchanged | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index bf945c12a..df8229818 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware and platform parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out @@ -39,7 +39,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import). | Platform | Figma | FigCraft | Notes | |---|---|---|---| -| macOS | desktop app (Intel and Apple silicon) | universal `.dmg`, signed and notarized (v0.2.0) | where nearly all QA happens | +| macOS | desktop app (Intel and Apple silicon) | universal `.dmg`, signed and notarized (v0.2.0) | where nearly all QA happens. Quitting ends the process once the preferences, recovery copies and live sessions are taken care of: the window's teardown crashed on Macs with a Touch Bar (#781); Quit from the menu bar, which asks nothing, keeps unsaved files' recovery copies for the next start | | Windows x64 / arm64 | desktop app | `.msi` and portable zip for x64, x86 and arm64 | built in CI; Windows: QA pass done 2026-10-11, findings in PR | | Linux | none (browser only) | AppImage, `.deb`, `.rpm`, Flatpak, tarball, x86_64 and aarch64; riscv64 tarball | CI tests run here. Clipboard (also the BSDs): reads run on a helper thread that gives up after 2 s, and images copied in other apps paste under Wayland through the data-control protocol (#693). Under Wayland the app can't tell its window is minimised, so control requests that need a drawn frame may still wait there (#720). None of this run on a Linux desktop here. On a Wayland compositor without server-side decorations (GNOME) the window gets winit's Adwaita controls (#592: checked by its author in a GNOME Wayland session; KDE not run) | | FreeBSD | none | tarball | | @@ -50,6 +50,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import). | Date | Change | Summary | |---|---|---| +| 2026-10-18 | minor | macOS quit crash (#781): on Macs with a Touch Bar the app could crash as it quit (an exception in AppKit's Touch Bar support while the window's content view, which the accessibility adapter subclasses, was torn down). Quitting now keeps everything (preferences, this run's recovery copies, live sessions ended) and then ends the process before that teardown. Quit from the menu bar ends the app without the unsaved-changes question, so unsaved files' recovery copies now stay and reopen at the next start instead of being removed. Numbers unchanged | | 2026-10-17 | minor | Linux row: clipboard reads on Linux and the BSDs time out after 2 s on a helper thread, images copied in other apps paste under Wayland (arboard's data-control feature, #693), a minimised window can't be detected under Wayland, so frame-only control requests may still wait there (#720), and Wayland compositors without server-side decorations give the window Adwaita controls (#592, checked by its author on GNOME, not by the maintainers; KDE not run); compiled in CI, not run on a Linux desktop. HiDPI row: native-density canvas images placed on whole device pixels (#359, geometry tests; not judged by eye). Numbers unchanged | | 2026-10-14 | minor | Copy and paste in the browser: the web build never wrote to the system clipboard, so Cmd+V after Cmd+C pasted whatever text the clipboard held before (as a text layer) instead of the copied layers. Copies (layers, Copy as SVG/CSS/text/PNG, links) now go out through egui's output, which eframe writes during the copy event (`web_clipboard_tests`). Numbers unchanged. | | 2026-10-11 | minor | Windows QA pass (gap 8): everyday workflows on Windows 11 through the desktop app and MCP; fixed: unsaved changes lost when the app was killed or crashed (recovery copies every few seconds, reopened at the next start), Ctrl+arrows/Backspace/Delete acting on the whole line in text (now by word; Ctrl+Home/End to the text's ends), scrubbed fields snapping back on release, typing on a field Tab reached, Escape not closing dialogs, skewed or vanishing linear gradients, hints cut off at the panel edge, the window-close question in English in the Spanish interface. Percentages and hours unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index a84bf05b2..dfea95cc2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -234,7 +234,12 @@ Or in a project's `.mcp.json`: `text.setVariations {variations, replace?}` merges instead. `text.fonts {family, style?, axes: true}` lists a font's axes and named instances (named instances are also valid `fontStyle` values). `text.missingFonts` lists the families the document names that aren't installed - (`{families: [{family, layers}]}`); their text draws in a fallback font. + (`{families: [{family, layers}]}`); their text draws in a fallback font. Fonts are indexed once + per process: after installing one, `text.rescanFonts` picks it up (`{added: [families]}`), and + `text.loadFont {path | data (base64)}` adds a font file without installing it (`{families}`). + Text boxes keep the size they were measured at: both refit the open document's auto-sizing text + in the new families (`refitted: [ids]`), and `text.refit {ids?}` does it for any text, e.g. a + file saved while its fonts were missing. OpenType features: `features {"liga": false, "tnum": true}`. Decorations: `textDecorationStyle` (`solid`, `dotted`, `wavy`), `textDecorationThickness` and `textDecorationOffset` (`auto`, px, or `"10%"` of the font size; the offset runs from the baseline @@ -267,7 +272,11 @@ Or in a project's `.mcp.json`: `style.duplicate {style}` adds " copy" right below, `style.renameFolder {folder, name}`. - **Variables**: `variable.bind` refuses a variable of a type the field doesn't take (a colour on `width`); scopes only filter pickers. `variable.delete` / `variable.deleteCollection` unbind the - layers using them (they keep their last value) and give aliases to them their values. + layers using them (they keep their last value) and give aliases to them their values. An + explicit edit that changes a bound field's value (`node.set`, `transform.resize`, + `object.opacityKeys`, `text.style`, `text.set`, the text size, weight and + tracking shortcuts, a canvas drag) detaches that field's variable; fields the edit + left as they were stay bound. `variable.addMode {from}` duplicates a mode, `variable.moveMode {collection, mode, to}` reorders (index 0 is the default mode, which layers on Auto follow). `variable.update {codeSyntax: {WEB?, iOS?, ANDROID?}}` sets code names (`""` clears); generated CSS uses the Web one. @@ -290,7 +299,7 @@ Or in a project's `.mcp.json`: | `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. | | `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`; marking again clears "Changed"), a section's `sectionContentsHidden`, text…); returns the updated layers. | | `inspect_document {depth, allPages}` | The document as JSON (layer tree, styles, selection, history). | -| `get_node {id, depth}` | One layer as JSON. | +| `get_node {id, depth}` | One layer as JSON; `pluginData` (`{key: value}`, values as strings) when the layer or page has layer data written with `set_properties {props: {pluginData}}`. | | `select {ids}` | Replace the selection (`[]` deselects). | | `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}` | png, jpg, svg, webp, pdf, or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`. The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it); `contentsOnly: false` also draws every other layer showing in that area, `includeBoundingBox` exports the layers' own boxes; SVG: `svgOutlineText: false` keeps ``, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline. The same options go in export settings (`export.add` / `export.update`). PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?, assets?}` (every layer once per setting, named `.`, a slash in a layer name making a folder; `assets: true` exports the Inspect tab's assets instead: icons as SVG, images as PNG). | | `screenshot {ids?, scale?, maxSize?, window?}` | PNG of the page (with background) or of layers, scaled to fit `maxSize` (default 1600). `window: true` captures the app window in front when attached, a window id (see Windows below) that window. | @@ -417,15 +426,28 @@ on; `key` resolves keys the way the canvas does. Commands behind keys: nested layers), Escape or a click drops it. - `pref.reduceMotion {on?}`: reduced motion (the interface's own animations off; the system's accessibility setting turns it on too). Prototype transitions still play. +- `pref.uiScale {scale?}`: how large the whole interface is drawn, relative to the system's display + scale: 1 (the default) is the system's own size on every screen, 1.25 means 125 %, not 125; from + 0.75 to 3 (outside the range it stops at the nearest end; `"auto"` is 1). Omit `scale` to read it. + The canvas zoom is separate. A window too small for a size above 100 % draws a smaller one until + it is larger; the choice is kept. In the browser the interface always follows the browser's zoom. - `pref.autoSave {on?}`: files that have a path save themselves a few seconds after they change (off by default; the desktop app's timer does it, so scripts still call `file.save`). -- `pref.language {language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…}`: the app's interface language, applied live ("system": -- `pref.language {language: system|en|es|es-419|fr|de|pt-BR|ja|ko|sv|zh-Hans|…}`: the app's interface language, applied live ("system": +- `pref.startOnHome {on?}`: the desktop app starts on the Home screen instead of in a new file + (off by default; files named on the command line still open, and `--control` starts in a new + file anyway, so control callers have a document). +- `pref.language {language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|sv|zh-Hans|…}`: the app's interface language, applied live ("system": the OS or browser language; a language without a catalog shows English). Command ids, labels and every result stay English. ### Editing vectors +`vector.create {path | points}` makes the layer's box from the geometry; `width`/`height` scale +the geometry to the size asked for (with both, each side independently; with one, the other keeps +its proportion). A path or point set that draws nothing is refused instead of making an invisible +0×0 layer, as is a size on an axis the geometry has none of (except 0, such as a horizontal line's +height) and a size of 0 or less on an axis it has, which would flatten it. + Vector edit mode is driven with `run_command` (or `pointer`/`key` for gestures): `vector.edit.begin {id? | ids?}` (default the selection; converts shapes to vectors; several layers are edited at once, the first active; `key` Enter with shapes selected does the same), @@ -883,10 +905,13 @@ path?}` writes this file's collections (default: all but library copies) as a lo response (`format: "json"`, the default) or as CSS custom properties (`format: "css"`: the default mode on `:root`, each other mode on `[data-=""]`, aliases as `var(--name)`), and returns `{text}` (or writes `path` and returns `{path}`). `variable.import {variables}` (an object, -JSON text or a path) reads one back: a collection joins this file's collection of the same name +JSON text or a path; `variableCollections` and `variables` under `meta` or at the top level) reads +one back: a collection joins this file's collection of the same name (values matched by mode name, new modes and variables added, a same-named variable of another type skipped with a warning; library copies and extended collections are never targets), others are -added; aliases are checked for loops; it returns `{collections, variables, updated, warnings}`. The +added; aliases are checked for loops; it returns `{collections, variables, updated, warnings}`, +with a warning when the JSON holds no variables (`no variables found: expected meta.variables …`) +or fewer than its collections list in `variableIds`. The Variables editor's Import… and Export… buttons do the same through file dialogs. Strokes take colour styles and variables like fills: `style.apply {style, target: "stroke"}` links @@ -969,10 +994,11 @@ branches). Driven with `run_command`: name, property, branch, main, deletedBy?}]}`. `layers` are the changed layers with their property changes formatted like Inspect (`fill #2563EB → #DC2626`); `document` lists styles, variables, comments and file settings. A conflict is the same property changed on both sides to different - values (key `/`, e.g. `4/fills`; fills, strokes and effects compare item by - item, so changing different items of one list on each side, or adding items at its end on one - side, merges without a conflict; a layer's parent and position are one - property, `parent`), a layer deleted on one side while the other edited it or what's inside it + values (key `/`, e.g. `4/fills`; fills, strokes, effects, layout grids, + export settings, guides, prototype interactions, annotations and Dev Mode resources compare item + by item, so changing different items of one list on each side, or adding items at its end on one + side, merges without a conflict; other lists, such as code links, compare whole; a layer's parent + and position are one property, `parent`), a layer deleted on one side while the other edited it or what's inside it (`/layer`, with `deletedBy`), or a document-level entry or setting changed on both sides (`style/`, `variable/`, `comment/`, `doc/`…). - `branch.merge {id?, resolutions?}` merges the branch into the main file as one undo step, diff --git a/docs/plugins.md b/docs/plugins.md index 5867bd25c..9b1a8f4ef 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -460,4 +460,6 @@ import what you need from `figcraft`. vectors, components or variables). - Panels: no images, sliders or free layout inside a panel. - Data in the document: plugins keep data on layers (pages included) with `node.set {props: - {pluginData: {key: value}}}`; there is no separate per-document store. + {pluginData: {key: value}}}` (a `null` value removes a key) and read it back with `node.get`, which + reports `pluginData: {key: value}` (values as strings) when a layer has any; there is no separate + per-document store. diff --git a/docs/releasing.md b/docs/releasing.md index 7e9220ff0..07492f6cd 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -95,13 +95,17 @@ examples from `packaging/server/` (systemd unit, Caddyfile, nginx; the Dockerfil the sources). Linux only for now; elsewhere operators build it with `cargo build --release -p figcraft-server`. -**Fonts.** Every build job checks out [craft-fonts](https://github.com/storytold/craft-fonts) at -`CRAFT_FONTS_REF` (top of `release.yml`) and builds with `CRAFT_FONTS_DIR` and -`CRAFT_FONTS_REQUIRED=1`, so releases embed its fonts and fail rather than ship without them. +**Fonts.** Every build job, the web one included (`web-craft-fonts`), checks out +[craft-fonts](https://github.com/storytold/craft-fonts) at `craft-fonts-ref` (in `release.yml`) and +builds with `CRAFT_FONTS_DIR` and `CRAFT_FONTS_REQUIRED=1`, so releases embed its fonts and fail +rather than ship without them. The browser has no system fonts, so a web build without craft-fonts +can't draw text. `crates/text/build.rs` reads both `fonts/manifest.txt` and `fonts/latin-manifest.txt`, so the pin must be a commit that has the Latin manifest (today `4391305`, on craft-fonts' -`pr/latin-presentation-fonts` branch; re-pin to `main` once that lands). The web build embeds -only Inter. Packages carry each embedded font's licence as `OFL-.txt` +`pr/latin-presentation-fonts` branch; re-pin to `main` once that lands). That commit has no +Noto Sans Georgian (craft-fonts' main has it but no Latin manifest), so desktop Georgian uses an +installed font; re-pin when one commit has both (craft-fonts#42). The web build embeds only Inter, +Noto Sans Georgian and the Archivo SemiBold wordmark, when the pin has them. Packages carry each embedded font's licence as `OFL-.txt` (`copy_font_licences` in `packaging/env.sh`; `Contents/Resources/Licenses` in the macOS app). ### macOS diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 16a5af69b..a1a5bf128 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -111,7 +111,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 15 | Dev Mode / inspect | 2 | 96% | 72% | Import lines for connected instances inside generated layer trees; an editor extension is left to the MCP server (inspect behaviour suite with colour formats, rem and icon and image assets, code connect, plugins in Dev Mode and dev resources links landed) ([dev-mode-parity](dev-mode-parity.md)) | 0–1 | | 16 | Comments, versions, find, misc | 3 | 87% | 63% | Comments are local only; comparing two versions; branches in live sessions; text runs and other lists besides fills, strokes and effects merge whole (branches with review and merge, their own version history and per-item list merges, and a comments and version history behaviour suite from the documentation landed) | 1–3 | | 17 | Multiplayer & sharing | 8 | 17% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, cursor chat and spotlight, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view, which can be open to the public (owner tokens, limits per address, expiry, moderation). Left: accounts and teams, audio ([collaboration-parity](collaboration-parity.md)) | 42–84 | -| 18 | Plugins & widgets | 4 | 60% | 35% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK), with log byte/count limits checked before allocation and host forwarding ([logging limits](plugins.md#logging-limits)), plugin panels called back per button or changed value and per-plugin settings (ABI v2), then widgets (API 3: plugin-drawn layers on the canvas with state kept in the document, redrawn on clicks; a Counter example), panels side by side and a colour picker for plugin colour fields, then a plugin directory on `figcraft-server` (publish with the server key, browse, install after a permissions confirmation, hash-checked downloads, updates by version). Left: a public catalogue, publisher accounts and signatures, ratings, background update checks, the directory in the browser; widgets take clicks only (no text input, property menus, hover or timers) and draw frames, text, shapes and images only; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 8–26 | +| 18 | Plugins & widgets | 4 | 60% | 35% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK), with log byte/count limits checked before allocation and host forwarding ([logging limits](plugins.md#logging-limits)), plugin panels called back per button or changed value and per-plugin settings (ABI v2), then widgets (API 3: plugin-drawn layers on the canvas with state kept in the document, redrawn on clicks; a Counter example), panels side by side and a colour picker for plugin colour fields, then a plugin directory on `figcraft-server` (publish with the server key, browse, install after a permissions confirmation, hash-checked downloads, updates by version); layer data written with `node.set {props: {pluginData}}` reads back through `node.get` (#879). Left: a public catalogue, publisher accounts and signatures, ratings, background update checks, the directory in the browser; widgets take clicks only (no text input, property menus, hover or timers) and draw frames, text, shapes and images only; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 8–26 | | 19 | Team libraries & organization | 2 | 70% | 40% | Libraries kept on `figcraft-server` under their file and enabled by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names. Left: team-wide library lists, accounts and permissions, analytics over time, pushed update notices | 4–8 | | 20 | Figma Draw extras | 2 | 88% | 60% | Have: variable width, lasso, shape builder, noise/texture, text on a path, pattern fills, stretch and scatter brushes, pen pressure (Windows pens, browser styluses), multi-edit (several vector layers in edit mode at once). Missing: pressure from macOS/Linux tablets | 1–3 | | 21 | AI features | 3 | 0% | 0% | First draft, rename layers, image edit/remove background, visual search, Make: needs local models (owner decision) | 30–60 | @@ -638,6 +638,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-20 | minor | Layer data reads back (#879): `node.get` (the command, the control method and MCP `get_node`) and `document.inspect` report a layer's or page's `pluginData` as `{key: value}` when it has any, so a tool that keeps state in a file with `node.set {props: {pluginData}}` can read it on its next run (`plugin_data_tests`). Numbers unchanged | | 2026-10-17 | minor | Menu breadth re-run with `cargo xtask parity`: 212 / 212 (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Rows for the 2026-10-17 landings: import/export (area 14) and the file formats dimension name the `.fig` import's new fidelity (instance identity, overrides by key, text style precedence, grid tracks, slots, connector labels, per-point joins, text on a path, image adjustments, list start numbers, vertical trim), budgets from the file's size, the import report, a `.fig` copy from Save as and paste from another design app; the localization evidence takes the re-measured catalog counts (2,272 entries, drafts at 95–99%, Indonesian and Czech drafts, Swedish and Turkish catalogs); the Automation line adds control requests answered while the window is minimised (#720); batch #753's Selection colors, smart-selection spacing, Present tab and auto layout on empty frames are in ui-parity, prototyping-parity and auto-layout-parity. Numbers unchanged: every `.fig` and paste change is checked on synthetic files or clipboard HTML made from our own export, and real files are what gap 1's blocker and the file-exchange discounts wait for; the UI changes (menus, the Actions palette, editor fixes) are not compared side by side, so UI/UX stays at 58%; the localization grade and hours stay (Indonesian's draft is offset by the drafts' new gaps) | | 2026-10-17 | minor | Automation roots (M8, #715, #481): `figcraft-cli mcp --automation-read-root DIR --automation-write-root DIR` (each repeatable, existing folders) and the desktop app with the same flags confine every file a command reads to the read and write roots and every file it writes to the write roots; paths are resolved (links followed, `..`, Windows `\\?\` and UNC paths, case) and compared by whole folder names, anything else is an ordinary error. The engine reaches the file system through one module (enforced by `crates/engine/clippy.toml`), and a sweep runs every command with paths outside the roots. `--attach` refuses the flags (the app confines itself); `ui.screenshot` and `ui.render` return PNG data without a `path`, so the attached MCP server writes no temporary files. Numbers unchanged. | | 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. | diff --git a/docs/translating.md b/docs/translating.md index 0e94d20d2..eaa59755b 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -15,14 +15,14 @@ Simplified. `pref.language {language}` does the same from the CLI, MCP and the control channel. The catalogs: English (`en`, the source), and Spanish (`es`, with the Latin American overlay -`es-419`), French (`fr`), German (`de`), Brazilian Portuguese (`pt-BR`), Japanese (`ja`), Korean (`ko`), -Simplified Chinese (`zh-Hans`), Czech (`cs`) and Indonesian (`id`), machine-drafted and waiting for -native speakers' review. Russian (`ru`, Русский) was reviewed and approved by VanTan; its catalog -keeps Frame, Dev Mode and Smart animate as familiar tool names and uses « » quotes. Turkish (`tr`) -was machine-drafted and reviewed by a native speaker. -`es-419`), French (`fr`), German (`de`), Brazilian Portuguese (`pt-BR`), Japanese (`ja`), Korean (`ko`) -and Simplified Chinese (`zh-Hans`), machine-drafted and waiting for native speakers' review, and -Swedish (`sv`), which translates every UI string, command label and menu wording. +`es-419`), French (`fr`), German (`de`), Dutch (`nl`), Brazilian Portuguese (`pt-BR`), Japanese +(`ja`), Korean (`ko`), Simplified Chinese (`zh-Hans`), Czech (`cs`), Indonesian (`id`), Finnish +(`fi`), Polish (`pl`) and Bengali (`bn`), machine-drafted and waiting for native speakers' review. Georgian +(`ka`) was written with AI assistance and reviewed by a native speaker, and stays a draft until a +maintainer changes its status. Russian (`ru`, Русский) was +reviewed and approved by VanTan; its catalog keeps Frame, Dev Mode and Smart animate as familiar +tool names and uses « » quotes. Turkish (`tr`) was machine-drafted and reviewed by a native speaker, +and Swedish (`sv`) translates every UI string, command label and menu wording. ## The catalogs @@ -59,14 +59,20 @@ komponenta, instance, varianta, automatické rozvržení, omezení, výplň, tah proměnná, styl). Indonesian uses bare imperatives for buttons and menu items (Simpan, Hapus), the formal Anda (never kamu), keeps established design terms in English (frame, layer, component, instance, auto layout, plugin, file) and keeps a noun unchanged after a count, so its plural entries -give only `other`. +give only `other`. Georgian has no letter case (never write Mtavruli for capitals), uses verbal nouns +for commands (გვერდის დამატება) and keeps a noun singular after a numeral, so counts read +"შრე: {n}" and its plural entries give the same text for `one` and `other`. CJK text needs a CJK font, which egui and Inter lack: the desktop app adds an installed one when something on screen needs it (see `crates/ui-egui/src/cjk.rs`). The web build has no system fonts, so Japanese, Korean and Chinese appear there only in a build with craft-fonts and `FIGCRAFT_EMBED_CJK=1` (Noto Sans CJK SC, +16 MB); otherwise they are left out of the picker. A -Chinese UI puts its Chinese face first so Han takes Simplified Chinese shapes. A new CJK or other non-Latin -language also needs its script in `cjk::language`. +Chinese UI puts its Chinese face first so Han takes Simplified Chinese shapes. Georgian is drawn in +craft-fonts' Noto Sans Georgian, one of the built-in faces in `theme::install_fonts`, or else in an +installed font on the desktop. Web builds keep it only when the craft-fonts they're built with has +it (`crates/text/build.rs`); the commit releases pin doesn't yet (`docs/releasing.md`), so the web +picker leaves Georgian out. A new CJK or other non-Latin language also needs its script in +`cjk::language`, or a built-in craft-fonts face. ## Adding a language @@ -83,11 +89,13 @@ language also needs its script in `cjk::language`. a missing key uses the fallback catalog's own plural rule. Czech uses `one` for 1, `few` for 2–4 and `other` for 0 and 5+ (e.g. `{n} vrstva`, `{n} vrstvy`, `{n} vrstev`). Keep `{n}` in all three forms. Czech's CLDR `many` applies only to decimals; `trn` takes integer counts. + Polish uses `one` for 1 only, `few` like Russian (2–4, 22–24… except 12–14) and `many` for + the other integers, including 0 and 21 (e.g. `{n} warstwa`, `{n} warstwy`, `{n} warstw`). 4. To require full coverage, add the code to `COMPLETE` in [`crates/ui-egui/src/i18n_tests.rs`](../crates/ui-egui/src/i18n_tests.rs). Every later PR that adds a string then has to translate it into each complete language, so only Spanish is there: - French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech and Indonesian - are registered as drafts, Russian and Turkish are reviewed but not required, and a string added + French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian and + Georgian are registered as drafts, Russian and Turkish are reviewed but not required, and a string added later shows English in them until it is translated. To list what a draft lacks, add its code to `COMPLETE` locally and run the check. 5. Run the check and look at the panels in the app (longer words must fit the right panel): @@ -100,7 +108,7 @@ language also needs its script in `cjk::language`. 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. +reviewed them; a reviewed catalog stays a draft until a maintainer approves it (its `status` says which). ## Adding strings in code diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 766284f1d..d87103f07 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -64,7 +64,7 @@ interaction feel not yet compared. | Nudge 1 / Shift 10 (preference), Cmd+arrows resize | done | arrows reorder auto layout children | | Smart guides: edges, centres, equal spacing (between two layers or continuing a row's gap, labelled), spacing labels, parent, layout grid, ruler guides | done | red, `snap.rs`; thresholds not yet compared with Figma's | | Ctrl to suspend snapping while dragging (macOS) | done | Control on macOS (Cmd keeps working), Ctrl elsewhere; moving, resizing and drawing | -| Measure (Alt hover, Cmd+Alt nested, Shift+Alt padding/gaps) | done | labels never overlap | +| Measure (Alt hover, Cmd+Alt nested, Shift+Alt padding/gaps) | done | labels never overlap; the arrows still nudge while measuring (Shift: the big step) and the distances follow; the selection's handles and size label hide while measuring (`measure_nudge_tests`, #894) | | Pixel grid snapping, pixel preview, 2x preview | done | moves, resizes and new shapes land on whole pixels; the pixel grid shows from 400% (`view_behaviour_tests`) | | Numeric fields: arithmetic, scrubbing by dragging the label, mixed values | done | Numeric panels use `widgets::number_field`; Typography line height, letter spacing and paragraph spacing share its leading-icon scrub logic (#373), retaining their existing unit parsing, Auto/Mixed values, variable chips and selected-character targeting (`typography_scrub_tests`). Scrubbing the label: a step a point (1 px; 1% of opacity, 1° of rotation), ten with Shift, a tenth with Alt, each stretch at the rate held while it was dragged, one undo step when let go; past the edge of the screen it goes on where the platform reports the mouse's own motion (desktop), otherwise it stops there. Arrow keys: Up/Down step a pixel field by the small nudge and Shift by the big nudge (the preferences), other fields by their own step and ten of it; they work while typing (from what is typed) and with the keyboard on the field. Typing: `+ - * /`, brackets, units (`px`, `°`, `%`, `x`, `ms`), a decimal comma; `+10`, `*2`, `/4` are relative to the value, and `50%`, `+10%`, `-10%` too in a field that isn't a percentage. Enter, Tab or a click elsewhere commits (Tab and Shift+Tab move to the next and previous control), Escape restores; a field left as it was commits nothing (no undo step, and a rounded display isn't written back). Mixed values show "Mixed": a number sets them all, a relative edit or an arrow key goes to each layer from its own value in one undo step, for X, Y, W, H, rotation, opacity, corner radius and stroke weight (elsewhere `+10` on Mixed sets 10 and `*2` does nothing) (`field_tests`). Left: scrubbing a Mixed field (nothing happens), `x2`-style suffix edits, the pointer isn't wrapped to the other side of the screen; rates not compared with Figma's | | Transform box at small sizes | done | a press inside a layer smaller than its handles moves it, the nearest handle wins, handles answer just outside and are hidden under 16 px on screen; cursors follow each handle and the box's rotation | @@ -165,13 +165,15 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Keyboard navigation: F6 regions, Tab within, focus ring | done | | | Layers panel from the keyboard: arrows, F2 / Cmd+R rename (the row gets the keyboard back), Shift+F10 context menu; 10k-layer lists | done | revealing a layer, expanding, collapsing and arrow keys timed in `cargo xtask bench` | | Reduced motion | done | system setting or preference | -| WCAG AA contrast of secondary and accent text | done | | +| WCAG AA contrast of secondary and accent text | done | disabled and dim text at least 3:1 against panels, fields and selected rows, selected text at least 3:1 on its highlight, in both themes (`theme::tests`, #890) | | Tested with VoiceOver, NVDA, Narrator, Orca | missing | human QA | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged | +| 2026-10-20 | minor | Measuring while nudging (#894): with Alt held over another layer, the arrows nudge the selection (Shift: the big step) and the distances follow every nudge (Alt+arrows used to do nothing); while measuring, the selection's handles and size label hide and both layers keep their outlines (`measure_nudge_tests`). Numbers unchanged | | 2026-10-18 | minor | A rename (layers double-click, F2, File ▸ Rename, a page) selects the whole name in the frame its field first draws, so the first key replaces the name instead of being appended. Checked in the app on Windows over the control channel | | 2026-10-17 | minor | Escape or Enter while the pen draws ends the path and keeps its vector in edit mode with the pen, so the next path is drawn into the same layer (unconnected); another Escape leaves edit mode (#760). Entering edit mode on a layer that already is a vector no longer records an empty undo step. Numbers unchanged | | 2026-10-17 | minor | Text layers keep the platform IME on for every frame of an edit, not only frames with input (an idle frame turned it off and macOS dropped each composition, #747); the candidate window opens at the caret instead of the canvas corner; keys pressed while composing go to the IME |