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..0d1723d83 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,7 +24,7 @@ jobs: with: app-name: figcraft app-title: FigCraft - craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 + craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc has-server: true version: ${{ inputs.version }} secrets: 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 0a2598984..ae4c9f24c 100644 --- a/README.md +++ b/README.md @@ -227,7 +227,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control - **Its own renderer:** tiny-skia on every core for the canvas and exports, with layer blending, effects and masks; booleans from linesweeper, geometry from kurbo, text from skrifa and HarfRust. - **Runs in the browser:** the whole engine and UI compile to WebAssembly. - **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes. -- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech and Indonesian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). +- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian and Georgian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). - **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and three example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md). - **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md). - **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md). diff --git a/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 11798f5b7..436df61f8 100644 --- a/apps/figcraft/src/args.rs +++ b/apps/figcraft/src/args.rs @@ -9,7 +9,7 @@ /// What the app was started with. #[derive(Debug, Default, PartialEq, Eq)] pub struct Args { - /// `--control `: the JSON-lines control server. + /// `--control ` (or `--control=`): the JSON-lines control server. pub control: Option, pub files: Vec, /// The last invite link given. @@ -71,8 +71,8 @@ pub fn parse(args: &[String]) -> Args { }; if let Some(roots) = roots { roots.push(inline.or_else(|| it.next().cloned()).unwrap_or_default()); - } else if a == "--control" { - out.control = it.next().and_then(|p| p.parse().ok()); + } else if flag == "--control" { + out.control = inline.or_else(|| it.next().cloned()).and_then(|p| p.parse().ok()); } else if is_invite(a) { out.join = Some(a.clone()); } else if let Some(path) = file_url_path(a) { diff --git a/apps/figcraft/src/args_tests.rs b/apps/figcraft/src/args_tests.rs index 9c23a3f4b..b274bedab 100644 --- a/apps/figcraft/src/args_tests.rs +++ b/apps/figcraft/src/args_tests.rs @@ -16,6 +16,14 @@ fn files_and_the_control_port() { assert_eq!(parse(&args(&["--control", "nope"])).control, None); } +#[test] +fn the_control_port_accepts_the_equals_form() { + let a = parse(&args(&["--control=7980", "a.figcraft"])); + assert_eq!(a, Args { control: Some(7980), files: args(&["a.figcraft"]), ..Args::default() }); + // A bad port is ignored, not taken as a file. + assert_eq!(parse(&args(&["--control=nope"])), Args::default()); +} + #[test] fn automation_roots_repeat_and_are_never_files() { let a = parse(&args(&["--automation-read-root", "in", "--automation-write-root=out", "--automation-read-root=more", "a.figcraft"])); diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 1bba81e81..00856c5d5 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -193,10 +193,8 @@ fn main() -> eframe::Result { // default, never a build from source unless asked). session.live.links = figcraft_collab_net::links::system(); session.invite_links_startup(); - // A first run (no settings kept yet, nothing to open) starts on the home screen, with its - // tips and templates; later runs start in a new file. 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 @@ -268,6 +266,14 @@ fn main() -> eframe::Result { result } +/// With nothing to open, whether the window starts in a new file rather than on the home screen. +/// A first run (no settings kept yet) shows the home screen's tips and templates, and later runs +/// start in a new file unless the preference asks for Home, which `--control` overrides: its +/// callers expect a document to act on. +fn opens_new_file(first_run: bool, start_on_home: bool, control: bool) -> bool { + !first_run && (!start_on_home || control) +} + fn desktop_title_bar() -> figcraft_ui_egui::TitleBar { use figcraft_ui_egui::TitleBar; if cfg!(target_os = "macos") { TitleBar::Inset } else { TitleBar::Custom } @@ -292,3 +298,16 @@ mod linux_window_tests { assert_eq!(viewport.decorations, Some(false)); } } + +#[cfg(test)] +mod start_tests { + use super::opens_new_file; + + #[test] + fn the_home_preference_starts_on_home_except_for_control_callers() { + assert!(opens_new_file(false, false, false), "a new file by default"); + assert!(!opens_new_file(false, true, false), "Home when the preference asks"); + assert!(opens_new_file(false, true, true), "--control still starts with a document"); + assert!(!opens_new_file(true, false, false), "a first run shows the home screen"); + } +} diff --git a/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/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/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 805b0afed..9db9975a9 100644 --- a/crates/engine/src/binding_lists_tests.rs +++ b/crates/engine/src/binding_lists_tests.rs @@ -97,3 +97,77 @@ fn effect_moves_and_removals_reject_junk() { ex(&mut s, "effect.remove", json!({"ids": [r], "index": 7})); assert_eq!(s.doc().unwrap().node(r).unwrap().effects.len(), 1); } + +fn fill_hex(s: &Session, r: NodeId) -> Vec { + s.doc().unwrap().node(r).unwrap().fills.iter().filter_map(|p| p.solid_color()).map(|c| c.to_hex()).collect() +} + +fn brand_id(s: &Session) -> String { + s.doc().unwrap().variables.get("brand").unwrap().id.clone() +} + +#[test] +fn explicit_paint_edits_detach_the_colour_binding() { + // An edit that reported success keeps its colour: the binding it overwrote lets go (as + // selection colours already do), instead of putting the variable's colour back. + let edits = [ + ("fill.set", json!({"color": "#00FF00"})), + ("node.set", json!({"props": {"fill": "#00FF00"}})), + ("node.set", json!({"props": {"fills": ["#00FF00"]}})), + ]; + for (cmd, mut p) in edits { + let (mut s, r) = setup(); + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"})); + p["ids"] = json!([r]); + ex(&mut s, cmd, p.clone()); + assert_eq!(fill_hex(&s, r), ["00FF00"], "{cmd} {p}"); + assert!(bound(&s, r).is_empty(), "{cmd} {p}"); + ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#123456"})); + assert_eq!(fill_hex(&s, r), ["00FF00"], "{cmd} {p}: no longer follows the variable"); + } + for (cmd, mut p) in [("stroke.set", json!({"color": "#00FF00"})), ("node.set", json!({"props": {"stroke": "#00FF00"}}))] { + let (mut s, r) = setup(); + ex(&mut s, "node.set", json!({"ids": [r], "props": {"strokes": ["#000000"]}})); + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "stroke", "variable": "brand"})); + p["ids"] = json!([r]); + ex(&mut s, cmd, p); + let n = s.doc().unwrap().node(r).unwrap(); + assert_eq!(n.strokes[0].solid_color().map(|c| c.to_hex()).as_deref(), Some("00FF00"), "{cmd}"); + assert!(n.bound.is_empty(), "{cmd}"); + } +} + +#[test] +fn clearing_bound_fills_adds_no_paint_back() { + let (mut s, r) = setup(); + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"})); + ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": []}})); + assert!(s.doc().unwrap().node(r).unwrap().fills.is_empty()); + assert!(bound(&s, r).is_empty()); + ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#123456"})); + assert!(s.doc().unwrap().node(r).unwrap().fills.is_empty(), "the variable doesn't bring the fill back"); +} + +#[test] +fn paint_opacity_edits_keep_the_colour_binding() { + let (mut s, r) = setup(); + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"})); + ex(&mut s, "fill.set", json!({"ids": [r], "opacity": 0.5})); + ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#0000FF"})); + assert_eq!(fill_hex(&s, r), ["0000FF"], "the colour still follows the variable"); + assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].opacity, 0.5); + // The same colour with another opacity, through node.set, keeps it too. + ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": [{"type": "solid", "color": "#0000FF", "opacity": 0.25}]}})); + assert_eq!(bound(&s, r).len(), 1); + assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].opacity, 0.25); +} + +#[test] +fn paints_set_with_their_binding_in_the_same_call_keep_it() { + let (mut s, r) = setup(); + ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"})); + let brand = brand_id(&s); + ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": ["#00FF00"], "boundVariables": {"fill": brand}}})); + assert_eq!(bound(&s, r), [("fill".to_string(), brand)]); + assert_eq!(fill_hex(&s, r), ["FF0000"], "the binding given with the paints applies"); +} diff --git a/crates/engine/src/booleans.rs b/crates/engine/src/booleans.rs index 87d901533..c3de51cbe 100644 --- a/crates/engine/src/booleans.rs +++ b/crates/engine/src/booleans.rs @@ -124,7 +124,7 @@ pub(crate) fn apply(s: &mut Session, cmd: &str, p: &Value, op: BoolOp) -> Result } return Ok(json!({"ids": ids, "operation": op})); } - let out = crate::cmd::structure::wrap_boolean(s, p, op)?; + let out = crate::cmd::structure::wrap_boolean(s, p, cmd, op)?; // Operands of a new group follow its operation. if let Some(g) = crate::params::id_of(&out["id"]) { let doc = s.doc_mut()?; diff --git a/crates/engine/src/cmd/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/edit.rs b/crates/engine/src/cmd/edit.rs index a4769a35c..7d107d0c3 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -261,7 +261,7 @@ pub fn specs() -> Vec { }), spec!("effect.set", "Set effect", "{ids?, index, effect {..}}", true, |s, p| { let i = req_index("effect.set", p, "index")?; - let e: Effect = parse("effect.set", p, "effect")?.ok_or_else(|| bad("effect.set", "`effect` required"))?; + let e = super::props::effect_from("effect.set", p, "effect")?.ok_or_else(|| bad("effect.set", "`effect` required"))?; for id in req_targets("effect.set", s, p)? { let n = s.doc_mut()?.get_mut(id)?; if let Some(slot) = n.effects.get_mut(i) { @@ -515,6 +515,13 @@ pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<( s.clipboard.source = Some(source); s.clipboard.source_path = source_path; s.clipboard.source_doc = uid; + s.clipboard.copied_components = nodes + .iter() + .filter(|(_, sub)| { + cmd == "edit.copy" && sub.first().is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. })) + }) + .map(|(root, _)| *root) + .collect(); s.clipboard.nodes = nodes; s.clipboard.images = images; s.clipboard.bounds = bounds; @@ -593,7 +600,8 @@ fn paste_design_layers(s: &mut Session, p: &Value) -> Result { let (nodes, images, bounds) = copied(&doc, &tops); let props = s.clipboard.props.take(); // No open file has id 0. So paste treats this copy as a copy from a different file. - s.clipboard = crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None }; + s.clipboard = + crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None, copied_components: BTreeSet::new() }; let mut out = paste(s, p)?; if !other.issues.is_empty() { out["warnings"] = json!(other.issues); @@ -623,6 +631,7 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re } // From another file: its instances follow the mains this file can have. let foreign = clip.source.as_ref().filter(|_| s.ds().is_ok_and(|d| d.uid != clip.source_doc)); + let copy_local = s.ds()?.uid == clip.source_doc; let doc = s.doc_mut()?; for (k, v) in &clip.images { doc.images.entry(k.clone()).or_insert_with(|| v.clone()); @@ -636,6 +645,16 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re let mut out = Vec::new(); let mut all = BTreeMap::new(); for (root, nodes) in &clip.nodes { + if copy_local + && clip.copied_components.contains(root) + && let Some(n) = nodes.first().filter(|n| matches!(n.kind, NodeKind::Component { .. })) + && doc.node(*root).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. })) + { + let instance = crate::components::create_instance(doc, *root, parent, 0.0, 0.0)?; + doc.get_mut(instance)?.transform = doc.world_transform(parent).inverse() * shift * n.transform; + out.push(instance); + continue; + } let mut map = BTreeMap::new(); for n in nodes { map.insert(n.id, doc.alloc_id()); @@ -661,10 +680,12 @@ fn paste(s: &mut Session, p: &Value) -> Result { // else in the frame's centre; otherwise in place on the page (or the viewport centre). let sel = s.selection(); let doc = s.doc()?; + let copy_local = s.ds()?.uid == s.clipboard.source_doc; let target = id(p, "parent").or_else(|| { sel.first() .copied() .filter(|i| sel.len() == 1 && doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && !matches!(n.kind, NodeKind::Instance { .. }))) + .filter(|i| !copy_local || !s.clipboard.copied_components.iter().any(|root| doc.is_ancestor(*root, *i))) }); let parent = target.unwrap_or(page); let at = match (num(p, "x"), num(p, "y")) { @@ -931,9 +952,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 +1206,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 +1369,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 +1403,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 bc13bc701..8e59b3031 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -92,6 +92,20 @@ pub fn specs() -> Vec { ), spec!("file.placeImage", "Place image", "{path | data, x?, y?}", true, super::create::place_image), spec!("file.close", "Close tab", "{index?, force? (discard unsaved changes)}", false, close), + spec!("file.moveTab", "Move tab", "{from, to} (indices among open documents)", false, |s, p| { + let from = req_index("file.moveTab", p, "from")?; + let to = req_index("file.moveTab", p, "to")?; + if from >= s.docs.len() || to >= s.docs.len() { + return Err(bad("file.moveTab", "no such document")); + } + if from != to { + let active_uid = s.docs.get(s.active).map(|d| d.uid); + let doc = s.docs.remove(from); + s.docs.insert(to, doc); + s.active = active_uid.and_then(|uid| s.docs.iter().position(|d| d.uid == uid)).unwrap_or(0); + } + Ok(json!({"index": to})) + }), spec!( "file.recover", "Recover file", @@ -1374,14 +1388,36 @@ fn close(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Disabled("file.close".into(), "the document has unsaved changes (pass force: true to discard)".into())); } let uid = ds.uid; + let active_uid = s.docs.get(s.active).map(|d| d.uid); s.docs.remove(i); - if s.active >= s.docs.len() { - s.active = s.docs.len().saturating_sub(1); - } + // Removing a tab before the active one shifts its index; keep the same document selected. + s.active = active_uid.and_then(|a| s.docs.iter().position(|d| d.uid == a)).unwrap_or(i.min(s.docs.len().saturating_sub(1))); crate::branches::close_tabs(s, uid, None); ok() } +#[cfg(test)] +mod tab_order_tests { + use super::*; + + #[test] + fn moving_and_closing_inactive_tabs_preserves_the_active_document() { + let mut session = Session::new(); + for name in ["Two", "Three", "Four"] { + session.execute("file.new", &json!({"name": name})).unwrap(); + } + session.execute("doc.switch", &json!({"index": 2})).unwrap(); + let active_uid = session.docs[session.active].uid; + let moved_uid = session.docs[0].uid; + session.execute("file.moveTab", &json!({"from": 0, "to": 2})).unwrap(); + assert_eq!(session.docs[2].uid, moved_uid); + assert_eq!(session.docs[session.active].uid, active_uid); + session.execute("file.close", &json!({"index": 0})).unwrap(); + assert_eq!(session.docs[session.active].uid, active_uid); + assert!(session.execute("file.moveTab", &json!({"from": 0, "to": 99})).is_err()); + } +} + /// Ctrl+Tab / Ctrl+Shift+Tab: the next or previous open document, wrapping around. fn cycle_doc(s: &mut Session, dir: i64) -> Result { let n = s.docs.len() as i64; diff --git a/crates/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 8a1f4e1cd..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, 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 adeb22506..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})) @@ -950,6 +960,104 @@ pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTr apply_bindings_matching(doc, only, |_| true); } +/// The layers `ids` before an explicit edit, for [`detach_edited`] (only those with bindings). +pub(crate) fn bound_before(doc: &Document, ids: &[NodeId]) -> Vec> { + ids.iter().filter_map(|i| doc.node_arc(*i)).filter(|n| !n.bound.is_empty()).cloned().collect() +} + +/// An explicit edit sticks: each layer's own bound field (not a list item's) whose value the edit +/// changed drops its binding, which would otherwise write the variable's value back (#809). +/// Compared by value, so keys written unchanged keep their bindings and fields moved along with +/// another (proportions, min/max) lose theirs. +pub(crate) fn detach_edited(doc: &mut Document, before: &[std::sync::Arc]) { + for old in before { + let Some(n) = doc.node(old.id) else { continue }; + let edited: Vec = n + .bound + .keys() + // A binding the edit itself made (`boundVariables`) stays. + .filter(|f| old.bound.get(*f) == n.bound.get(*f)) + .filter(|f| super::lists::list_binding(f).is_none() && !f.starts_with(crate::components::PROP_BINDING)) + .filter(|f| bound_field(old, f) != bound_field(n, f)) + .cloned() + .collect(); + if edited.is_empty() { + continue; + } + if let Ok(n) = doc.get_mut(old.id) { + for f in edited { + n.bound.remove(&f); + } + } + } +} + +/// The stored value a layer-level binding writes, comparable (`None`: the layer has no such field). +fn bound_field(n: &Node, field: &str) -> Option { + let text = |f: &dyn Fn(&TextStyle) -> String| match &n.kind { + NodeKind::Text(t) => Some(std::iter::once(&t.style).chain(t.runs.iter().map(|r| &r.style)).map(f).collect::>().join(",")), + _ => None, + }; + let layout = n.layout.as_ref(); + let cl = &n.child_layout; + let pad = |i: usize| layout.and_then(|l| l.padding.get(i).copied()); + Some(match field { + "width" => format!("{}", n.width), + "height" => format!("{}", n.height), + "minWidth" => format!("{:?}", cl.min_w), + "maxWidth" => format!("{:?}", cl.max_w), + "minHeight" => format!("{:?}", cl.min_h), + "maxHeight" => format!("{:?}", cl.max_h), + "opacity" => format!("{}", n.opacity), + "visible" => format!("{}", n.visible), + "gap" | "columnGap" => format!("{:?}", layout.map(|l| l.gap)), + "crossGap" | "rowGap" => format!("{:?}", layout.map(|l| l.cross_gap)), + "padding" => format!("{:?}", layout.map(|l| l.padding)), + "paddingTop" => format!("{:?}", pad(0)), + "paddingRight" => format!("{:?}", pad(1)), + "paddingBottom" => format!("{:?}", pad(2)), + "paddingLeft" => format!("{:?}", pad(3)), + "paddingHorizontal" => format!("{:?}", (pad(1), pad(3))), + "paddingVertical" => format!("{:?}", (pad(0), pad(2))), + "cornerRadius" => format!("{:?}", n.corners.radii), + "topLeftRadius" => format!("{:?}", n.corners.radii.first()), + "topRightRadius" => format!("{:?}", n.corners.radii.get(1)), + "bottomRightRadius" => format!("{:?}", n.corners.radii.get(2)), + "bottomLeftRadius" => format!("{:?}", n.corners.radii.get(3)), + "strokeWeight" => format!("{:?}", (n.stroke.weight, n.stroke.sides)), + "strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" => format!("{:?}", n.stroke.sides), + "arcStart" | "arcSweep" | "arcInner" => match &n.kind { + NodeKind::Ellipse { start, sweep, inner } => format!( + "{}", + if field == "arcStart" { + start + } else if field == "arcSweep" { + sweep + } else { + inner + } + ), + _ => return None, + }, + "characters" => match &n.kind { + NodeKind::Text(t) => t.characters.clone(), + _ => return None, + }, + "paragraphSpacing" | "paragraphIndent" => match &n.kind { + NodeKind::Text(t) => format!("{}", if field == "paragraphSpacing" { t.paragraph_spacing } else { t.paragraph_indent }), + _ => return None, + }, + "fontFamily" => text(&|s| s.font_family.clone())?, + "fontStyle" => text(&|s| s.font_style.clone())?, + "fontWeight" => text(&|s| s.weight().to_string())?, + "fontSize" => text(&|s| s.font_size.to_string())?, + "lineHeight" => text(&|s| format!("{:?}", s.line_height))?, + "letterSpacing" => text(&|s| format!("{:?}", s.letter_spacing))?, + // shortcut: other layer-level fields keep their binding on edits; add a reader when one is bound in practice. + _ => return None, + }) +} + /// Imported files (`.fig`, REST) keep a solid paint's alpha in its opacity, a bound paint's too, /// while the bound colour brings its own alpha: take the variable's alpha back out of the opacity /// (styles' paints and layers') before the bindings are first resolved, so it applies once. @@ -1007,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() { @@ -1021,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(); @@ -1030,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) { 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/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 6dd806462..0871f886d 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -259,6 +259,25 @@ fn stroke_color(n: &Node) -> Option { n.strokes.iter().filter(|p| p.visible).find_map(|p| p.solid_color()) } +/// The CSS borders a solid stroke makes: one `border`, or with individual stroke sides a border +/// on each side that has a weight. +fn css_borders(doc: &Document, n: &Node, t: &mut Tokens) -> Vec<(String, String)> { + let Some(c) = stroke_color(n) else { return Vec::new() }; + let Some(sides) = n.stroke.sides else { + let weight = tok(doc, n, &["strokeWeight"], format!("{}px", num(n.stroke.weight)), t); + return vec![("border".into(), format!("{weight} solid {}", tok(doc, n, &["stroke"], c.to_css(), t)))]; + }; + let color = tok(doc, n, &["stroke"], c.to_css(), t); + let named = ["top", "right", "bottom", "left"].into_iter().zip(crate::cmd::props::STROKE_SIDES); + named + .zip(sides) + .filter(|(_, weight)| *weight > 0.0) + .map(|((side, field), weight)| { + (format!("border-{side}"), format!("{} solid {color}", tok(doc, n, &[field], format!("{}px", num(weight)), t))) + }) + .collect() +} + fn drop_shadow(n: &Node) -> Option<&Shadow> { n.effects.iter().filter(|e| e.visible).find_map(|e| match &e.kind { EffectKind::DropShadow(s) => Some(s), @@ -452,9 +471,8 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo PaintKind::Pattern(_) => put("background", "url(pattern.png) repeat".into()), } } - if let Some(c) = stroke_color(n) { - let weight = tok(doc, n, &["strokeWeight"], px(n.stroke.weight), t); - put("border", format!("{weight} solid {}", tok(doc, n, &["stroke"], c.to_css(), t))); + for (key, value) in css_borders(doc, n, t) { + put(&key, value); } let shadows: Vec = n .effects @@ -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..ea67bb07c 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -285,6 +285,8 @@ pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId, root.dev_resources.clear(); // The main's interactions play on its instances in prototypes; the instance's own start empty. root.interactions.clear(); + // So do the variable modes the main sets (see `Document::inherited_modes`). + root.var_modes.clear(); doc.insert(root, parent, index)?; sync_instance(doc, id); Ok(id) diff --git a/crates/engine/src/components_behaviour_tests.rs b/crates/engine/src/components_behaviour_tests.rs index 15f660e1e..e068c6cff 100644 --- a/crates/engine/src/components_behaviour_tests.rs +++ b/crates/engine/src/components_behaviour_tests.rs @@ -82,6 +82,118 @@ fn text(n: &Node) -> &TextData { n.kind.text().unwrap() } +#[test] +fn copying_a_main_component_pastes_a_linked_instance() { + for command in ["edit.paste", "edit.pasteOver", "edit.pasteReplace"] { + let mut s = Session::new(); + let k = kit(&mut s); + ex(&mut s, "select.set", json!({"ids": [k.card]})); + ex(&mut s, "edit.copy", json!({})); + if command != "edit.paste" { + ex(&mut s, "select.set", json!({"ids": [k.inst]})); + } + let before = undo_len(&s); + let pasted = crate::params::id_of(&ex(&mut s, command, json!({}))["ids"][0]).unwrap(); + assert_eq!(component_of(&s, pasted), k.card, "{command}"); + assert_eq!(node(&s, pasted).parent, Some(s.page().unwrap())); + assert_eq!(node(&s, pasted).source, Some(k.card)); + assert_eq!(undo_len(&s), before + 1); + ex(&mut s, "edit.undo", json!({})); + assert!(s.doc().unwrap().node(pasted).is_none()); + ex(&mut s, "edit.redo", json!({})); + assert_eq!(component_of(&s, pasted), k.card); + ex(&mut s, "node.set", json!({"id": k.title, "props": {"characters": "Updated"}})); + assert_eq!(chars(&s, copy_of(&s, pasted, k.title)), "Updated"); + healthy(&s); + } +} + +#[test] +fn copying_a_variant_pastes_an_instance_but_copying_its_set_keeps_the_components() { + let mut s = Session::new(); + let (set, _, hover) = button_set(&mut s); + ex(&mut s, "select.set", json!({"ids": [hover]})); + ex(&mut s, "edit.copy", json!({})); + let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({}))["ids"][0]).unwrap(); + assert_eq!(component_of(&s, pasted), hover); + assert_eq!(node(&s, pasted).name, node(&s, set).name); + let NodeKind::Instance { props, .. } = &node(&s, pasted).kind else { panic!("pasted variant is not an instance") }; + assert_eq!(chars(&s, layer(&s, pasted, "Label").unwrap()), "Hover"); + assert_eq!(props["Show dot"], true); + assert_eq!(node(&s, pasted).parent, Some(s.page().unwrap())); + + ex(&mut s, "select.set", json!({"ids": [set]})); + ex(&mut s, "edit.copy", json!({})); + let copied_set = crate::params::id_of(&ex(&mut s, "edit.paste", json!({}))["ids"][0]).unwrap(); + assert!(matches!(node(&s, copied_set).kind, NodeKind::ComponentSet { .. })); + assert_eq!(node(&s, copied_set).parent, Some(s.page().unwrap())); + assert_eq!(kids(&s, copied_set).len(), 2); + for variant in kids(&s, copied_set) { + assert!(matches!(node(&s, variant).kind, NodeKind::Component { .. })); + assert!(!kids(&s, set).contains(&variant)); + } + healthy(&s); +} + +#[test] +fn cut_components_and_components_copied_to_another_file_remain_components() { + for (copy, undo_cut, new_file) in [("edit.cut", false, false), ("edit.cut", true, false), ("edit.copy", false, true)] { + let mut s = Session::new(); + let k = kit(&mut s); + ex(&mut s, copy, json!({"ids": [k.card]})); + if undo_cut { + ex(&mut s, "edit.undo", json!({})); + } + if new_file { + ex(&mut s, "file.new", json!({})); + } + let page = s.page().unwrap(); + let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": page}))["ids"][0]).unwrap(); + assert!(matches!(node(&s, pasted).kind, NodeKind::Component { .. }), "{copy}, undo={undo_cut}, new_file={new_file}"); + assert_eq!(kids(&s, pasted).len(), 3); + healthy(&s); + } +} + +#[test] +fn copying_a_component_does_not_link_to_an_id_reused_after_undo() { + let mut s = Session::new(); + let frame = idv(&ex(&mut s, "frame.create", json!({"width": 120, "height": 60, "name": "Copied main"}))); + let main = idv(&ex(&mut s, "component.create", json!({"ids": [frame]}))); + ex(&mut s, "edit.copy", json!({"ids": [main]})); + s.undo().unwrap(); + s.undo().unwrap(); + let other = idv(&ex(&mut s, "frame.create", json!({"width": 300, "height": 200, "name": "Unrelated main"}))); + ex(&mut s, "component.create", json!({"ids": [other]})); + assert_eq!(other, main, "undo makes the original id available again"); + let page = s.page().unwrap(); + let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": page}))["ids"][0]).unwrap(); + assert_eq!(node(&s, pasted).name, "Copied main"); + assert_eq!((node(&s, pasted).width, node(&s, pasted).height), (120.0, 60.0)); + assert!(matches!(node(&s, pasted).kind, NodeKind::Component { .. })); + healthy(&s); +} + +#[test] +fn pasted_component_instances_keep_the_copied_transform_in_the_target_frame() { + let mut s = Session::new(); + let k = kit(&mut s); + ex(&mut s, "node.set", json!({"id": k.card, "props": {"rotation": 30}})); + let original = s.doc().unwrap().world_transform(k.card); + let frame = idv(&ex(&mut s, "frame.create", json!({"x": -100, "y": -100, "width": 1000, "height": 1000}))); + ex(&mut s, "edit.copy", json!({"ids": [k.card]})); + let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": frame}))["ids"][0]).unwrap(); + assert_eq!(component_of(&s, pasted), k.card); + for (actual, expected) in s.doc().unwrap().world_transform(pasted).as_coeffs().into_iter().zip(original.as_coeffs()) { + assert!((actual - expected).abs() < 1e-9); + } + assert_eq!(node(&s, pasted).parent, Some(frame)); + let before = s.doc().unwrap().clone(); + assert!(s.execute("edit.paste", &json!({"parent": k.card})).is_err()); + assert_eq!(s.doc().unwrap(), &before, "pasting into the main is refused atomically"); + healthy(&s); +} + #[test] fn overridable_properties_stay_through_main_edits_and_reset() { // (what, layer, group, override on the instance, the same property edited on the main, read, diff --git a/crates/engine/src/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_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 312c2e878..f55bc1db0 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) } @@ -1652,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; @@ -1838,5 +1893,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..ed13de5f0 100644 --- a/crates/engine/src/live.rs +++ b/crates/engine/src/live.rs @@ -81,7 +81,7 @@ pub const PEOPLE: [&str; 8] = ["#0D99FF", "#9747FF", "#14AE5C", "#F24822", "#D97 /// `#RRGGBB` → (r, g, b); `None` for anything else. pub fn parse_color(s: &str) -> Option<(u8, u8, u8)> { - let hex = s.strip_prefix('#').filter(|h| h.len() == 6 && h.is_ascii())?; + let hex = s.strip_prefix('#').filter(|h| h.len() == 6 && h.bytes().all(|b| b.is_ascii_hexdigit()))?; let byte = |i: usize| hex.get(i..i + 2).and_then(|b| u8::from_str_radix(b, 16).ok()); Some((byte(0)?, byte(2)?, byte(4)?)) } diff --git a/crates/engine/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/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/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_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/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/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 17f6b4c5f..ccf833bea 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" @@ -1402,6 +1414,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 +1463,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" @@ -1667,6 +1681,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})" @@ -1720,6 +1735,7 @@ name = "English" "{n}m" = "{n}m" "{n}w" = "{n}w" "{page}, nothing selected" = "{page}, nothing selected" +"{percent}%" = "{percent}%" "{profile} (current)" = "{profile} (current)" "{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." "{server} can't say what became of your files (it is older than this app)." = "{server} can't say what became of your files (it is older than this app)." diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index d054e2223..1187c658f 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" @@ -1403,6 +1415,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 +1464,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" @@ -1668,6 +1682,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})" @@ -1721,6 +1736,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)." @@ -1763,6 +1779,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" @@ -1890,6 +1907,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" @@ -2032,6 +2050,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" @@ -2137,6 +2157,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" @@ -2145,7 +2166,9 @@ status = "draft — needs native review" "text.paragraphSpacing" = "Espacio entre párrafos" "text.pathOptions" = "Opciones de texto en trazado" "text.preview" = "Previsualizar cambio de texto" +"text.refit" = "Ajustar texto a las fuentes" "text.removeFromPath" = "Quitar texto del trazado" +"text.rescanFonts" = "Volver a buscar fuentes instaladas" "text.set" = "Establecer texto" "text.setFeatures" = "Funciones OpenType" "text.setVariations" = "Ejes de fuente variable" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 602574d79..205a9d294 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -321,6 +321,7 @@ status = "draft — needs native review" "Corner smoothing" = "Kulman pehmennys" "Couldn't download the variables" = "Muuttujia ei voitu ladata" "Couldn't reach {server}: showing what this computer remembers." = "Palvelimeen {server} ei saatu yhteyttä. Näytetään tämän tietokoneen muistamat tiedot." +"Couldn't read {name}." = "Tiedostoa {name} ei voitu lukea." "Couldn't save automatically: {error}" = "Automaattinen tallennus epäonnistui: {error}" "Count" = "Määrä" "Create" = "Luo" @@ -952,6 +953,7 @@ status = "draft — needs native review" "PDF print settings" = "PDF-tulostusasetukset" "Padding" = "Sisämarginaali" "Page" = "Sivu" +"Page deleted" = "Sivu poistettu" "Pages" = "Sivut" "Pages added to the document" = "Sivut lisätty asiakirjaan" "Pages: {pages}" = "Sivut: {pages}" @@ -1300,6 +1302,7 @@ status = "draft — needs native review" "Start from a blank canvas" = "Aloita tyhjältä piirtoalueelta" "Start from a template" = "Aloita mallista" "Start on path" = "Aloituskohta polulla" +"Start on the Home screen" = "Aloita Aloitus-näkymästä" "Start point" = "Alkupiste" "Start sharing" = "Aloita jakaminen" "Start sharing with the Share button, then pass on the code it gives" = "Aloita jakaminen Jaa-painikkeella ja välitä sen antama koodi" @@ -1676,6 +1679,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "{name} on unohdettu tällä tietokoneella. Tiedosto jää palvelimelle {server}." "{name} is on the server." = "{name} on palvelimella." "{name} is spotlighting" = "{name} nostaa näkymäänsä esiin" +"{name} is too large to open in the browser (over 512 MB)." = "{name} on liian suuri avattavaksi selaimessa (yli 512 Mt)." "{name} was removed from the server" = "{name} poistettiin palvelimelta" "{name} · deleted on main" = "{name} · poistettu päähaarassa" "{name} · deleted on the branch" = "{name} · poistettu haarassa" @@ -1891,6 +1895,7 @@ status = "draft — needs native review" "file.exportLayers" = "Vie tasot" "file.exportSvg" = "Vie valinta SVG-muodossa" "file.import" = "Tuo" +"file.moveTab" = "Siirrä välilehti" "file.new" = "Uusi suunnittelutiedosto" "file.newFromTemplate" = "Uusi mallista" "file.open" = "Avaa" @@ -2033,6 +2038,7 @@ status = "draft — needs native review" "pref.reduceMotion" = "Vähennä liikettä" "pref.renameDuplicates" = "Nimeä monistetut tasot uudelleen" "pref.showDimensions" = "Näytä objektien mitat" +"pref.startOnHome" = "Aloita Aloitus-näkymästä" "pref.userName" = "Nimesi kommenteissa" "proto.action.add" = "Lisää prototyyppitoiminto" "proto.action.move" = "Muuta prototyyppitoimintojen järjestystä" @@ -2139,6 +2145,7 @@ status = "draft — needs native review" "text.listIndent" = "Sisennä luettelo" "text.listOutdent" = "Vähennä luettelon sisennystä" "text.listStyle" = "Luettelotyyli" +"text.loadFont" = "Lataa fonttitiedosto" "text.maxLines" = "Rivien enimmäismäärä" "text.missingFonts" = "Puuttuvat fontit" "text.numberList" = "Numeroitu luettelo" @@ -2147,7 +2154,9 @@ status = "draft — needs native review" "text.paragraphSpacing" = "Kappaleväli" "text.pathOptions" = "Tekstipolun asetukset" "text.preview" = "Esikatsele tekstimuutosta" +"text.refit" = "Sovita teksti fontteihin" "text.removeFromPath" = "Poista teksti polulta" +"text.rescanFonts" = "Etsi asennetut fontit uudelleen" "text.set" = "Aseta teksti" "text.setFeatures" = "OpenType-ominaisuudet" "text.setVariations" = "Muuttuvan fontin akselit" 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..10d0c8e73 --- /dev/null +++ b/crates/ui-egui/locales/ka.toml @@ -0,0 +1,2343 @@ +# FigCraft interface strings: Georgian. +# +# Draft — needs native review: machine-drafted from the English catalog. 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 — 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" = "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 index 8d8cac680..54851043f 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -321,6 +321,7 @@ status = "draft — needs native review" "Corner smoothing" = "Hoekafvlakking" "Couldn't download the variables" = "De variabelen konden niet worden gedownload" "Couldn't reach {server}: showing what this computer remembers." = "Kon {server} niet bereiken: toont wat deze computer onthoudt." +"Couldn't read {name}." = "Kon {name} niet lezen." "Couldn't save automatically: {error}" = "Kan niet automatisch opslaan: {error}" "Count" = "Aantal" "Create" = "Maken" @@ -952,6 +953,7 @@ status = "draft — needs native review" "PDF print settings" = "PDF-afdrukinstellingen" "Padding" = "Padding" "Page" = "Pagina" +"Page deleted" = "Pagina verwijderd" "Pages" = "Pagina's" "Pages added to the document" = "Pagina's toegevoegd aan het document" "Pages: {pages}" = "Pagina's: {pages}" @@ -1300,6 +1302,7 @@ status = "draft — needs native review" "Start from a blank canvas" = "Begin vanaf een leeg canvas" "Start from a template" = "Begin met een sjabloon" "Start on path" = "Begin op het pad" +"Start on the Home screen" = "Starten op Home" "Start point" = "Startpunt" "Start sharing" = "Delen starten" "Start sharing with the Share button, then pass on the code it gives" = "Begin met delen via de knop Delen en geef daarna de code door die je krijgt" @@ -1673,6 +1676,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "{name} is op deze computer vergeten; het bestand blijft op {server}" "{name} is on the server." = "{name} staat op de server." "{name} is spotlighting" = "{name} staat in de spotlight" +"{name} is too large to open in the browser (over 512 MB)." = "{name} is te groot om in de browser te openen (meer dan 512 MB)." "{name} was removed from the server" = "{name} is van de server verwijderd" "{name} · deleted on main" = "{name} · verwijderd op main" "{name} · deleted on the branch" = "{name} · verwijderd op de branch" @@ -1890,6 +1894,7 @@ status = "draft — needs native review" "file.exportLayers" = "Lagen exporteren" "file.exportSvg" = "Selectie exporteren als SVG" "file.import" = "Importeren" +"file.moveTab" = "Tabblad verplaatsen" "file.new" = "Nieuw ontwerpbestand" "file.newFromTemplate" = "Nieuw vanuit sjabloon" "file.open" = "Openen" @@ -2032,6 +2037,7 @@ status = "draft — needs native review" "pref.reduceMotion" = "Beweging verminderen" "pref.renameDuplicates" = "Gedupliceerde lagen hernoemen" "pref.showDimensions" = "Afmetingen op objecten tonen" +"pref.startOnHome" = "Starten op Home" "pref.userName" = "Je naam in reacties" "proto.action.add" = "Prototypeactie toevoegen" "proto.action.move" = "Volgorde van prototypeactie wijzigen" diff --git a/crates/ui-egui/locales/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..b41d827a9 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" @@ -1785,6 +1789,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 +1927,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..6dd68a170 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -311,6 +311,7 @@ name = "Русский" "Corner radius" = "Радиус углов" "Corner smoothing" = "Сглаживание углов" "Couldn't reach {server}: showing what this computer remembers." = "Не удалось связаться с {server}: показаны данные, сохранённые на этом компьютере." +"Couldn't read {name}." = "Не удалось прочитать {name}." "Count" = "Количество" "Create" = "Создать" "Create a variable collection first" = "Сначала создайте коллекцию переменных" @@ -932,6 +933,7 @@ name = "Русский" "PDF print settings" = "Настройки печати PDF" "Padding" = "Внутренние отступы" "Page" = "Страница" +"Page deleted" = "Страница удалена" "Pages" = "Страницы" "Pages added to the document" = "Страницы добавлены в документ" "Placed on the current page" = "Размещено на текущей странице" @@ -1635,6 +1637,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})" @@ -1855,6 +1858,7 @@ name = "Русский" "file.exportLayers" = "Экспортировать слои" "file.exportSvg" = "Экспортировать выделение в SVG" "file.import" = "Импорт" +"file.moveTab" = "Переместить вкладку" "file.new" = "Новый файл дизайна" "file.newFromTemplate" = "Создать из шаблона" "file.open" = "Открыть" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index d57bb25a5..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 d8ae41ec2..b400e56da 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. @@ -951,20 +951,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 @@ -980,20 +980,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. @@ -1016,13 +1016,13 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ width_overlay(app, painter, &doc, &ve, zoom); } else if let Some(c) = app.session.vector_edit_box() { let label = format!("{} × {}", fmt((c[1] - c[0]).hypot()), fmt((c[3] - c[0]).hypot())); - size_label = transform_box(app, painter, c.map(|p| app.to_screen(p.x, p.y)), ACCENT, true, 4, label); + size_label = transform_box(app, painter, c.map(|p| app.to_screen(p.x, p.y)), accent(painter), true, 4, label); } } // Selection. let editing = ds.text_edit.clone(); if !sel.is_empty() && editing.is_none() && ds.vector_edit.is_none() { - let col = if sel.iter().any(|i| outline_colour(&doc, *i) == COMPONENT) { COMPONENT } else { ACCENT }; + let col = if sel.iter().any(|i| outline_colour(&doc, *i, accent(painter)) == COMPONENT) { COMPONENT } else { accent(painter) }; for id in &sel { draw_outline(app, painter, &doc, *id, stroke(col, 1.0)); } @@ -1091,12 +1091,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 { @@ -1127,8 +1127,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() { @@ -1143,7 +1143,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)); @@ -1155,11 +1155,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)); } } } @@ -1170,7 +1170,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 { @@ -1178,7 +1178,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); @@ -1212,7 +1212,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 } @@ -1269,7 +1269,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)] { @@ -1281,7 +1281,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) { @@ -1297,9 +1297,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 @@ -1318,7 +1318,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); @@ -1336,7 +1336,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; @@ -1344,7 +1344,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); @@ -1352,14 +1352,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)); } } @@ -1376,7 +1376,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)); } } @@ -1391,10 +1391,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); } @@ -1504,8 +1504,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 { @@ -1533,3 +1541,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 be1648359..d6aad31d7 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -1013,7 +1013,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); 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/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/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..98bc3d8f7 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -171,9 +171,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 +183,7 @@ fn plural(code: &str, n: usize) -> Plural { } // CLDR's million-count "many" in French, Spanish and Portuguese still reads their existing -// "other" wording. Czech supplies separate few forms; Russian supplies few/many forms. +// "other" wording. Czech supplies separate few forms; Russian and Polish supply few/many forms. /// Whether count `n` takes the "one" form: 0 and 1 in French and Brazilian Portuguese, never in /// Japanese, Korean and Chinese (no plural forms: always "other"), 1 elsewhere (CLDR's integer rules). diff --git a/crates/ui-egui/src/i18n_tests.rs b/crates/ui-egui/src/i18n_tests.rs index d4d81d7b9..fcf21fde9 100644 --- a/crates/ui-egui/src/i18n_tests.rs +++ b/crates/ui-egui/src/i18n_tests.rs @@ -400,7 +400,7 @@ fn french_and_portuguese_count_zero_as_singular() { for code in ["fr", "pt-BR"] { assert!(one(code, 0) && one(code, 1) && !one(code, 2), "{code}"); } - for code in ["en", "es", "de", "cs"] { + for code in ["en", "es", "de", "cs", "pl"] { for n in [0, 1, 2, 3, 4, 5, 21] { assert_eq!(one(code, n), n == 1, "{code}: {n}"); } @@ -421,6 +421,41 @@ fn japanese_korean_and_chinese_have_no_plural_forms() { } } +#[test] +fn georgian_is_a_draft_with_count_neutral_plurals() { + let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code; + for tag in ["ka", "ka-GE", "ka_GE.UTF-8"] { + assert_eq!(code("system", Some(tag)), "ka", "{tag}"); + } + let ka = LANGS.iter().position(|l| l.code == "ka").unwrap(); + assert!(LANGS[ka].draft && LANGS[ka].name == "ქართული"); + // A noun stays singular after a numeral: both forms read the same where English counts in both. + for (k, t) in LANGS[ka].ui { + if let (Text::Plural { one: Some(one), other, .. }, Some(Text::Plural { one: Some(en_one), .. })) = (t, find(english().ui, k)) { + assert!(!en_one.contains("{n}") || one == other, "ka: {k:?}"); + } + } +} + +/// Release builds embed craft-fonts' Noto Sans Georgian (`theme::install_fonts`' built-in faces), +/// since Inter and egui's fonts have no Georgian: it draws every Georgian letter the catalog uses. +#[test] +fn the_built_in_georgian_face_draws_the_georgian_catalog() { + let Some(face) = figcraft_text::find_face("Noto Sans Georgian", "Regular").filter(|f| f.family == "Noto Sans Georgian") else { + eprintln!("built without craft-fonts' Noto Sans Georgian; skipping"); + return; + }; + let ka = &LANGS[LANGS.iter().position(|l| l.code == "ka").unwrap()]; + let georgian = |c: &char| matches!(u32::from(*c), 0x10A0..=0x10FF | 0x1C90..=0x1CBF | 0x2D00..=0x2D2F); + let mut letters: BTreeSet = BTreeSet::new(); + for (_, t) in ka.ui.iter().chain(ka.commands).chain(ka.menu) { + letters.extend(forms(t).into_iter().flat_map(str::chars).filter(georgian)); + } + assert!(letters.len() > 30, "{letters:?}"); + let missing: String = letters.iter().filter(|c| !face.has_char(**c)).collect(); + assert!(missing.is_empty(), "no glyph for {missing:?}"); +} + #[test] fn chinese_reads_simplified_and_traditional_has_no_catalog() { let code = |pref: &str, system: Option<&str>| LANGS[resolve(pref, system)].code; @@ -493,6 +528,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 450317b43..84aedd6ab 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -61,6 +61,7 @@ pub mod tabs; pub mod theme; pub mod toolbar; pub mod typography; +pub mod ui_scale; pub mod variable_ui; pub mod variables_panel; pub mod vector_panel; @@ -156,6 +157,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; @@ -204,6 +207,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; @@ -236,6 +241,11 @@ pub trait Services { fn pick_open(&mut self, _title: &str, _exts: &[&str]) -> DialogFuture { Box::pin(std::future::ready(None)) } + /// Pick a file and read it, where the platform hands over a file's name and contents rather + /// than a path to it (the browser). `None`: the platform picks paths ([`Services::pick_open`]). + fn pick_open_contents(&mut self, _title: &str, _exts: &[&str]) -> Option { + None + } fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> DialogFuture { Box::pin(std::future::ready(None)) } @@ -290,14 +300,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, @@ -431,6 +488,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, @@ -459,6 +519,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, @@ -500,6 +563,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, @@ -595,6 +662,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, @@ -612,6 +681,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(), @@ -634,6 +704,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, @@ -780,6 +851,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); } @@ -1032,7 +1108,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); @@ -1099,16 +1175,11 @@ impl FigApp { pub fn open_dialog(&mut self) { // Opening needs no file in front: it goes ahead even if that file closed meanwhile. - self.start_dialog( - |services| services.pick_open(i18n::tr("Open"), OPEN_EXTENSIONS), - false, - |app, p| { - let ps = p.to_string_lossy().to_string(); - if app.run_user("file.open", json!({"path": ps})).is_some() { - app.ui.home = false; - } - }, - ); + self.pick_file(i18n::tr("Open"), OPEN_EXTENSIONS, false, |app, file| { + if app.run_user("file.open", file).is_some() { + app.ui.home = false; + } + }); } pub fn import_dialog(&mut self, place: bool) { @@ -1118,12 +1189,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) { @@ -1142,12 +1236,28 @@ impl FigApp { /// Open a native dialog from the window drawing now, for the file in front (`pin`). fn start_dialog(&mut self, start: impl FnOnce(&mut dyn Services) -> DialogFuture, pin: bool, done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { + self.start_pick( + |services| -> PickFuture { + let pick = start(services); + Box::pin(async move { pick.await.map(Picked::Path) }) + }, + pin, + move |app, picked| { + if let Picked::Path(p) = picked { + done(app, p); + } + }, + ); + } + + /// [`FigApp::start_dialog`] for any kind of pick. + fn start_pick(&mut self, start: impl FnOnce(&mut dyn Services) -> PickFuture, pin: bool, done: impl FnOnce(&mut FigApp, Picked) + 'static) { if self.pending_dialog.is_some() { return; } let doc = if pin { self.session.ds().ok().filter(|d| self.shows(d.uid)).map(|d| d.uid) } else { None }; let future = start(self.services.as_mut()); - self.pending_dialog = Some(PendingDialog { future, path: None, done: Box::new(done), doc, window: self.windows.current }); + self.pending_dialog = Some(PendingDialog { future, picked: None, done: Box::new(done), doc, window: self.windows.current }); if let Some(ctx) = &self.ui_context { ctx.request_repaint(); } @@ -1157,12 +1267,12 @@ impl FigApp { /// that file in front; then the tab in front before comes back (unless the dialog opened a file). fn poll_dialog(&mut self, ctx: &egui::Context) { let Some(d) = &mut self.pending_dialog else { return }; - if d.path.is_none() { + if d.picked.is_none() { let waker = Waker::from(Arc::new(RepaintWaker(ctx.clone()))); - let Poll::Ready(path) = d.future.as_mut().poll(&mut TaskContext::from_waker(&waker)) else { return }; - d.path = path; + let Poll::Ready(picked) = d.future.as_mut().poll(&mut TaskContext::from_waker(&waker)) else { return }; + d.picked = picked; } - let (doc, origin, picked) = (d.doc, d.window, d.path.is_some()); + let (doc, origin, picked) = (d.doc, d.window, d.picked.is_some()); let index = doc.map(|u| self.session.docs.iter().position(|ds| ds.uid == u)); // Cancelled, or its file closed meanwhile. if !picked || index == Some(None) { @@ -1178,12 +1288,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)) @@ -1197,11 +1307,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)); @@ -1261,6 +1371,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). @@ -1679,6 +1799,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 @@ -1693,17 +1815,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, @@ -1716,11 +1843,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/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 c0a1903bd..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| { @@ -1061,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 97b3f5ec4..b0e4286da 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 @@ -961,8 +961,8 @@ fn variable_modes(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) // The collection's name, then a wider mode picker ("Auto (Light)") across both field columns. let col = widgets::col_width(ui.available_width()); let (name_w, pick_w) = ((col * 0.7).round(), (col * 1.3).round()); - // Auto resolves from the parent of the first selected layer. - let parent_modes = sel.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent).map(|p| doc.modes_for(p)).unwrap_or_default(); + // Auto resolves as for the first selected layer: from its parent, or an instance's main. + let parent_modes = sel.first().map(|i| doc.inherited_modes(*i)).unwrap_or_default(); let mut group = None; for r in &rows { let c = r.coll; 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/style_picker.rs b/crates/ui-egui/src/style_picker.rs index 966ea4828..29c9c5a94 100644 --- a/crates/ui-egui/src/style_picker.rs +++ b/crates/ui-egui/src/style_picker.rs @@ -482,7 +482,7 @@ fn style_grid(ui: &mut Ui, list: &[(&Style, Key)], applied: Option<&Key>, editab None => preview(ui, r, s, StyleSlot::Fill), } if applied == Some(key) { - ui.painter().rect_stroke(r.expand(2.0), CornerRadius::same(5), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); + ui.painter().rect_stroke(r.expand(2.0), CornerRadius::same(5), Stroke::new(2.0, crate::theme::ACCENT), egui::StrokeKind::Inside); } else if resp.hovered() && !text { ui.painter().rect_stroke(r.expand(2.0), CornerRadius::same(4), Stroke::new(1.0, t.menu_text), egui::StrokeKind::Inside); } diff --git a/crates/ui-egui/src/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..777b04b16 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -1,6 +1,6 @@ //! Design tokens (UI3 light and dark) and fonts. -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use egui::{Color32, Context, FontData, FontDefinitions, FontFamily, FontId, Stroke, TextStyle, Visuals}; @@ -23,6 +23,7 @@ pub struct Tokens { pub field_hover_border: Color32, pub accent: Color32, pub accent_hover: Color32, + pub accent_on: Color32, pub accent_text: Color32, pub selected_row: Color32, pub selected_row_child: Color32, @@ -44,11 +45,38 @@ 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 c = |x: Color32| figcraft_color::Color::new(f32::from(x.r()) / 255.0, f32::from(x.g()) / 255.0, f32::from(x.b()) / 255.0, 1.0); + let pick = if c(BRAND).contrast(c(page)) > c(ACCENT).contrast(c(page)) { BRAND } else { ACCENT }; + ctx.data_mut(|d| d.insert_temp(egui::Id::new(CANVAS_ACCENT), pick)); +} + +/// The green for overlays drawn on the page ([`set_canvas_page`]); [`ACCENT`] before a page shows. +pub fn canvas_accent(ctx: &Context) -> Color32 { + ctx.data(|d| d.get_temp(egui::Id::new(CANVAS_ACCENT))).unwrap_or(ACCENT) +} + +/// A fill under white text: the darker green stands in for the bright one. +pub fn under_white(c: Color32) -> Color32 { + if c == BRAND { ACCENT } else { c } +} + pub const COMPONENT: Color32 = Color32::from_rgb(0x97, 0x47, 0xFF); /// Handoff status badges (white text on these, in both themes). -pub const DEV_READY: Color32 = Color32::from_rgb(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 { @@ -70,16 +98,17 @@ impl Tokens { pressed: Color32::from_rgb(0xE6, 0xE6, 0xE6), field: Color32::from_rgb(0xF5, 0xF5, 0xF5), field_hover_border: Color32::from_rgb(0xE6, 0xE6, 0xE6), - accent: ACCENT, - accent_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 +118,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), } } @@ -110,15 +139,16 @@ impl Tokens { pressed: Color32::from_rgb(0x44, 0x44, 0x44), field: Color32::from_rgb(0x38, 0x38, 0x38), field_hover_border: Color32::from_rgb(0x4D, 0x4D, 0x4D), - accent: ACCENT, - accent_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 +158,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 +216,7 @@ fn visuals(t: Tokens) -> Visuals { v.window_corner_radius = egui::CornerRadius::same(13); v.menu_corner_radius = egui::CornerRadius::same(13); v.selection.bg_fill = t.accent.gamma_multiply(0.3); - v.selection.stroke = Stroke::new(1.0, t.accent); + v.selection.stroke = Stroke::new(1.0, ACCENT); v.hyperlink_color = t.accent_text; for w in [&mut v.widgets.inactive, &mut v.widgets.hovered, &mut v.widgets.active, &mut v.widgets.open, &mut v.widgets.noninteractive] { w.corner_radius = egui::CornerRadius::same(5); @@ -202,10 +232,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 +256,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 +283,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 +382,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 +447,21 @@ pub fn doc_c32(ctx: &Context, c: figcraft_color::Color) -> Color32 { mod tests { use super::*; + #[test] + fn canvas_overlays_take_the_green_that_stands_out_on_the_page() { + let ctx = Context::default(); + assert_eq!(canvas_accent(&ctx), ACCENT, "before any page shows"); + set_canvas_page(&ctx, Color32::from_rgb(0x1E, 0x1E, 0x1E)); + assert_eq!(canvas_accent(&ctx), BRAND, "the bright green on a dark page"); + set_canvas_page(&ctx, Color32::WHITE); + assert_eq!(canvas_accent(&ctx), ACCENT, "the darker green on a light page"); + set_canvas_page(&ctx, Color32::from_rgb(0xF5, 0xF5, 0xF5)); + assert_eq!(canvas_accent(&ctx), ACCENT); + // White text never sits on the bright green. + assert_eq!(under_white(BRAND), ACCENT); + assert_eq!(under_white(COMPONENT), COMPONENT); + } + #[test] fn ui_font_draws_every_mac_shortcut_glyph() { figcraft_text::scan_system_fonts(); 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/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(" 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); } @@ -1056,9 +1056,9 @@ 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() @@ -1074,8 +1074,8 @@ pub fn button(ui: &mut Ui, label: &str, primary: bool, enabled: bool) -> Respons a11y::button(&resp, label); let hot = enabled && resp.hovered(); let (fill, text) = match (primary, hot) { - (true, false) => (t.accent, Color32::WHITE), - (true, true) => (t.accent_hover, Color32::WHITE), + (true, false) => (t.accent, t.accent_on), + (true, true) => (t.accent_hover, t.accent_on), (false, false) => (Color32::TRANSPARENT, t.text), (false, true) => (t.hover, t.text), }; @@ -1112,7 +1112,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/control-protocol.md b/docs/control-protocol.md index 858d24352..08c08b085 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -172,7 +172,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/mcp.md b/docs/mcp.md index c5ffe99f5..ca41cc33c 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. @@ -417,10 +426,17 @@ 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. diff --git a/docs/translating.md b/docs/translating.md index d8addcbc7..b7b23d0bf 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -16,8 +16,8 @@ Simplified. The catalogs: English (`en`, the source), and Spanish (`es`, with the Latin American overlay `es-419`), French (`fr`), German (`de`), Dutch (`nl`), Brazilian Portuguese (`pt-BR`), Japanese -(`ja`), Korean (`ko`), Simplified Chinese (`zh-Hans`), Czech (`cs`), Indonesian (`id`) and Finnish -(`fi`), machine-drafted and waiting for native speakers' review. Russian (`ru`, Русский) was +(`ja`), Korean (`ko`), Simplified Chinese (`zh-Hans`), Czech (`cs`), Indonesian (`id`), Finnish +(`fi`), Polish (`pl`), Georgian (`ka`) and Bengali (`bn`), machine-drafted and waiting for native speakers' review. Russian (`ru`, Русский) was reviewed and approved by VanTan; its catalog keeps Frame, Dev Mode and Smart animate as familiar tool names and uses « » quotes. Turkish (`tr`) was machine-drafted and reviewed by a native speaker, and Swedish (`sv`) translates every UI string, command label and menu wording. @@ -57,14 +57,19 @@ 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`. Web builds +keep it only when built with craft-fonts (`crates/text/build.rs`); the web release isn't, so its +picker leaves Georgian out. A new CJK or other non-Latin language also needs its script in +`cjk::language`, or a built-in craft-fonts face. ## Adding a language @@ -81,11 +86,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):