mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-05 01:59:40 +00:00
Merge remote-tracking branch 'upstream/main' into feat/752-report-missing-fonts
# Conflicts: # ROADMAP.md # docs/file-format-parity.md
This commit is contained in:
70 files changed
+3302
-208
No files matched your search
Generated
+45
-5
@@ -1422,11 +1422,14 @@ dependencies = [
|
||||
"fmv-macos-events",
|
||||
"image",
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"wat",
|
||||
"wayland-client",
|
||||
"webbrowser",
|
||||
"windows",
|
||||
"winit",
|
||||
"winresource",
|
||||
]
|
||||
|
||||
@@ -1600,7 +1603,7 @@ dependencies = [
|
||||
"krilla",
|
||||
"log",
|
||||
"thiserror 2.0.21",
|
||||
"tiny-skia",
|
||||
"tiny-skia 0.12.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1631,7 +1634,7 @@ dependencies = [
|
||||
"moxcms",
|
||||
"png",
|
||||
"rayon",
|
||||
"tiny-skia",
|
||||
"tiny-skia 0.12.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2570,7 +2573,7 @@ dependencies = [
|
||||
"skrifa 0.42.1",
|
||||
"smallvec",
|
||||
"subsetter",
|
||||
"tiny-skia-path",
|
||||
"tiny-skia-path 0.12.0",
|
||||
"xmp-writer",
|
||||
"yoke",
|
||||
"zune-jpeg",
|
||||
@@ -4018,6 +4021,17 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sctk-adwaita"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
|
||||
dependencies = [
|
||||
"log",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"tiny-skia 0.11.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "self_cell"
|
||||
version = "1.3.0"
|
||||
@@ -4477,6 +4491,20 @@ dependencies = [
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"log",
|
||||
"tiny-skia-path 0.11.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia"
|
||||
version = "0.12.0"
|
||||
@@ -4489,7 +4517,18 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"log",
|
||||
"png",
|
||||
"tiny-skia-path",
|
||||
"tiny-skia-path 0.12.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia-path"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"bytemuck",
|
||||
"strict-num",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4778,7 +4817,7 @@ dependencies = [
|
||||
"skrifa 0.44.0",
|
||||
"strict-num",
|
||||
"svgtypes",
|
||||
"tiny-skia-path",
|
||||
"tiny-skia-path 0.12.0",
|
||||
"unicode-bidi",
|
||||
"unicode-script",
|
||||
"unicode-vo",
|
||||
@@ -5731,6 +5770,7 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"redox_syscall 0.4.1",
|
||||
"rustix 0.38.44",
|
||||
"sctk-adwaita",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"smol_str",
|
||||
"tracing",
|
||||
|
||||
@@ -20,3 +20,34 @@ licence texts.
|
||||
Rust crate dependencies keep their own licenses (MIT, Apache-2.0, BSD, ISC, Zlib, OFL for egui's
|
||||
default fonts, CDLA-Permissive-2.0 for the root certificate list webpki-roots compiles in, and
|
||||
similar permissive licenses); see each crate's metadata.
|
||||
|
||||
Linux Wayland window decorations
|
||||
-------------------------------
|
||||
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
|
||||
commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
|
||||
for vector window controls. Its title-font features are disabled: no Cantarell
|
||||
font is embedded by this dependency and no font file is added to this repo.
|
||||
|
||||
sctk-adwaita's MIT licence:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Bartłomiej Maryńczak
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+13
-12
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
@@ -13,7 +13,7 @@ start with [`docs/gaps.md`](docs/gaps.md).
|
||||
|
||||
| | Value | Kind |
|
||||
|---|---|---|
|
||||
| Menu/command breadth | **100%** (211 / 211) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
|
||||
| Menu/command breadth | **100%** (212 / 212) | measured, [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`) |
|
||||
| Feature breadth | **~85%** (core editor ~94%) | estimated, [`docs/target-app-parity.md`](docs/target-app-parity.md) |
|
||||
| Ready for real work | **~53%** | estimated, same doc (written dimension weights and scores) |
|
||||
| Essentials user | **~59%** | estimated: core features 77.8% × 0.92 launch/stability × 0.94 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
|
||||
@@ -99,21 +99,21 @@ native speakers' reviews of the six language drafts need people.
|
||||
| Language | Code | Strings | Status |
|
||||
|---|---|---|---|
|
||||
| English | en | 100% | full |
|
||||
| Simplified Chinese | zh-Hans | 100% | draft (needs native review) |
|
||||
| Spanish | es, es-419 | 100% (es-419: an overlay of 98 entries) | draft (needs native review) |
|
||||
| Simplified Chinese | zh-Hans | 95% | draft (needs native review) |
|
||||
| Spanish | es, es-419 | 100% (es-419: an overlay of 101 entries) | draft (needs native review) |
|
||||
| Hindi | hi | 0% | none |
|
||||
| Arabic | ar | 0% | none |
|
||||
| French | fr | 100% | draft (needs native review) |
|
||||
| Portuguese (Brazil) | pt-BR | 100% | draft (needs native review) |
|
||||
| Indonesian | id | 0% | none |
|
||||
| Japanese | ja | 100% | draft (needs native review) |
|
||||
| German | de | 100% | draft (needs native review) |
|
||||
| Korean | ko | 100% | draft (needs native review) |
|
||||
| French | fr | 95% | draft (needs native review) |
|
||||
| Portuguese (Brazil) | pt-BR | 95% | draft (needs native review) |
|
||||
| Indonesian | id | 99% | draft (needs native review) |
|
||||
| Japanese | ja | 99% | draft (needs native review) |
|
||||
| German | de | 96% | draft (needs native review) |
|
||||
| Korean | ko | 95% | draft (needs native review) |
|
||||
| Vietnamese | vi | 0% | none |
|
||||
|
||||
Also shipped: Swedish (`sv`: every UI string, 120 of 588 command labels and menu wordings; native review welcome). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
|
||||
Percentages measured 2026-10-17 over 2,272 entries (strings added since a draft show English). Also shipped: Russian (`ru`, 99%, reviewed by VanTan), Swedish (`sv`, 99.6%, command labels since 2026-10-17; native review welcome), Turkish (`tr`, 99%) and Czech (`cs`, 99%, draft). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
|
||||
(Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America),
|
||||
French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts (Preferences ▸
|
||||
French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and Indonesian drafts (Preferences ▸
|
||||
Language; Traditional Chinese has no catalog; CJK UI
|
||||
text uses an installed CJK font, loaded only when needed; adding a language:
|
||||
[`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping,
|
||||
@@ -318,6 +318,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-17 | minor | Missing fonts (M7, #752): fonts a file names that aren't installed are listed after the user opens it, from Text ▸ Missing fonts…, by `text.missingFonts` and by `figcraft-cli info`. Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged |
|
||||
| 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. |
|
||||
|
||||
@@ -43,3 +43,9 @@ wat = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
winresource = "0.1"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Use vector window controls without a title-font renderer or embedded Cantarell.
|
||||
winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] }
|
||||
raw-window-handle = "0.6"
|
||||
wayland-client = "0.31"
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Request winit's frame only on Wayland compositors without xdg-decoration.
|
||||
//! Desktop/session names cannot identify the backend of an individual window.
|
||||
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use raw_window_handle::{HasDisplayHandle, RawDisplayHandle};
|
||||
use wayland_client::globals::{GlobalListContents, registry_queue_init};
|
||||
use wayland_client::protocol::wl_registry;
|
||||
use wayland_client::{Connection, Dispatch, QueueHandle};
|
||||
|
||||
/// Capture the socket path before winit consumes an inherited WAYLAND_SOCKET.
|
||||
/// Never consume, duplicate or query that inherited connection: there may be no
|
||||
/// independently addressable display, or WAYLAND_DISPLAY may name another server.
|
||||
pub fn display_socket() -> Option<PathBuf> {
|
||||
if std::env::var_os("WAYLAND_SOCKET").is_some_and(|s| !s.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
let display = std::env::var_os("WAYLAND_DISPLAY").filter(|s| !s.is_empty())?;
|
||||
let path = PathBuf::from(display);
|
||||
if path.is_absolute() {
|
||||
Some(path)
|
||||
} else {
|
||||
std::env::var_os("XDG_RUNTIME_DIR").filter(|s| !s.is_empty()).map(|dir| PathBuf::from(dir).join(path))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn needs_native_frame(cc: &eframe::CreationContext<'_>, socket: Option<PathBuf>) -> bool {
|
||||
let wayland = cc.display_handle().is_ok_and(|h| matches!(h.as_raw(), RawDisplayHandle::Wayland(_)));
|
||||
if !wayland {
|
||||
log::debug!("window controls: non-Wayland window, retaining custom controls");
|
||||
return false;
|
||||
}
|
||||
let server_decorations = socket.and_then(probe);
|
||||
log::debug!("window controls: Wayland xdg-decoration available: {server_decorations:?}");
|
||||
native_frame(wayland, server_decorations)
|
||||
}
|
||||
|
||||
fn native_frame(wayland: bool, server_decorations: Option<bool>) -> bool {
|
||||
wayland && server_decorations == Some(false)
|
||||
}
|
||||
|
||||
fn probe(socket: PathBuf) -> Option<bool> {
|
||||
// The compositor must not be able to block startup on a registry roundtrip.
|
||||
// On failure/timeout retain FigCraft's own working title bar and buttons.
|
||||
let (send, receive) = mpsc::sync_channel(1);
|
||||
std::thread::Builder::new()
|
||||
.name("wayland-decorations".into())
|
||||
.spawn(move || {
|
||||
let _ = send.send(registry_has_decorations(socket));
|
||||
})
|
||||
.ok()?;
|
||||
receive.recv_timeout(Duration::from_millis(500)).ok().flatten()
|
||||
}
|
||||
|
||||
fn registry_has_decorations(socket: PathBuf) -> Option<bool> {
|
||||
// A fresh connection leaves winit's event queue and inherited descriptors alone.
|
||||
let stream = UnixStream::connect(socket).ok()?;
|
||||
let connection = Connection::from_socket(stream).ok()?;
|
||||
let (globals, _queue) = registry_queue_init::<Registry>(&connection).ok()?;
|
||||
Some(globals.contents().with_list(|list| list.iter().any(|g| g.interface == "zxdg_decoration_manager_v1")))
|
||||
}
|
||||
|
||||
struct Registry;
|
||||
|
||||
impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for Registry {
|
||||
fn event(
|
||||
_state: &mut Self,
|
||||
_registry: &wl_registry::WlRegistry,
|
||||
_event: wl_registry::Event,
|
||||
_data: &GlobalListContents,
|
||||
_connection: &Connection,
|
||||
_queue: &QueueHandle<Self>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn only_wayland_without_server_decorations_uses_the_frame() {
|
||||
for (wayland, server_decorations, expected) in [
|
||||
(false, None, false),
|
||||
(false, Some(false), false),
|
||||
(false, Some(true), false),
|
||||
(true, Some(true), false),
|
||||
(true, Some(false), true),
|
||||
(true, None, false),
|
||||
] {
|
||||
assert_eq!(super::native_frame(wayland, server_decorations), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unavailable_display_keeps_the_custom_controls() {
|
||||
assert_eq!(super::probe("/nonexistent/figcraft-wayland-test".into()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unresponsive_display_cannot_block_window_startup() -> std::io::Result<()> {
|
||||
let socket = std::env::temp_dir().join(format!("figcraft-decoration-timeout-{}", std::process::id()));
|
||||
let listener = std::os::unix::net::UnixListener::bind(&socket)?;
|
||||
let start = std::time::Instant::now();
|
||||
assert_eq!(super::probe(socket.clone()), None);
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(2));
|
||||
drop(listener);
|
||||
let _ = std::fs::remove_file(socket);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ mod args;
|
||||
mod control_server;
|
||||
mod gamepad;
|
||||
mod graphics;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux_window;
|
||||
mod recovery;
|
||||
mod services;
|
||||
mod startup;
|
||||
@@ -212,21 +214,18 @@ fn main() -> eframe::Result {
|
||||
.with_inner_size([1440.0, 900.0])
|
||||
.with_min_inner_size([800.0, 500.0])
|
||||
.with_drag_and_drop(true);
|
||||
// The tab strip is the title bar: macOS keeps its window buttons inset into it; elsewhere the
|
||||
// window is frameless and FigCraft draws the buttons and resize edges.
|
||||
if cfg!(target_os = "macos") {
|
||||
viewport = viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false);
|
||||
fig.ui.title_bar = figcraft_ui_egui::TitleBar::Inset;
|
||||
} else {
|
||||
viewport = viewport.with_decorations(false);
|
||||
fig.ui.title_bar = figcraft_ui_egui::TitleBar::Custom;
|
||||
}
|
||||
// Start frameless on Linux; the app creator checks the actual display backend
|
||||
// before enabling a client-side frame. Other windows inherit that decision.
|
||||
fig.ui.title_bar = desktop_title_bar();
|
||||
viewport = window_chrome(viewport, fig.ui.title_bar);
|
||||
if let Some(icon) = icon {
|
||||
viewport = viewport.with_icon(icon);
|
||||
}
|
||||
let mut options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() };
|
||||
graphics::configure(&mut options.wgpu_options);
|
||||
figcraft_ui_egui::windows::install(fig);
|
||||
#[cfg(target_os = "linux")]
|
||||
let linux_display = linux_window::display_socket();
|
||||
// Registered before the event loop starts, so it catches the Finder event that launched the app
|
||||
// as well as later ones. Lives until the event loop returns; the app creator only borrows it.
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -237,6 +236,11 @@ fn main() -> eframe::Result {
|
||||
"FigCraft",
|
||||
options,
|
||||
Box::new(move |_cc| {
|
||||
#[cfg(target_os = "linux")]
|
||||
if linux_window::needs_native_frame(_cc, linux_display) {
|
||||
let _ = with_app(|fig| fig.ui.title_bar = figcraft_ui_egui::TitleBar::Native);
|
||||
_cc.egui_ctx.send_viewport_cmd(egui::ViewportCommand::Decorations(true));
|
||||
}
|
||||
Ok(Box::new(App {
|
||||
queue,
|
||||
ctx_slot,
|
||||
@@ -253,3 +257,28 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn desktop_title_bar() -> figcraft_ui_egui::TitleBar {
|
||||
use figcraft_ui_egui::TitleBar;
|
||||
if cfg!(target_os = "macos") { TitleBar::Inset } else { TitleBar::Custom }
|
||||
}
|
||||
|
||||
fn window_chrome(viewport: egui::ViewportBuilder, title_bar: figcraft_ui_egui::TitleBar) -> egui::ViewportBuilder {
|
||||
use figcraft_ui_egui::TitleBar;
|
||||
match title_bar {
|
||||
TitleBar::Inset => viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false),
|
||||
TitleBar::Native => viewport.with_decorations(true),
|
||||
TitleBar::Custom => viewport.with_decorations(false),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod linux_window_tests {
|
||||
#[test]
|
||||
fn linux_starts_with_the_existing_frameless_title_bar() {
|
||||
let bar = super::desktop_title_bar();
|
||||
assert_eq!(bar, figcraft_ui_egui::TitleBar::Custom);
|
||||
let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar);
|
||||
assert_eq!(viewport.decorations, Some(false));
|
||||
}
|
||||
}
|
||||
+92
-1
@@ -860,7 +860,35 @@ pub struct Device {
|
||||
|
||||
impl PrototypeSettings {
|
||||
/// The devices to pick from ("None" presents the frame at its own size).
|
||||
pub const DEVICES: [Device; 6] = [
|
||||
pub const DEVICES: [Device; 19] = [
|
||||
Device {
|
||||
name: "iPhone 17",
|
||||
width: 402.0,
|
||||
height: 874.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x38, 0x39, 0x3D), c("White", 0xE9, 0xE9, 0xE5)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 17 Pro",
|
||||
width: 402.0,
|
||||
height: 874.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x38, 0x39, 0x3D), c("Silver", 0xD7, 0xD7, 0xD5)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 17 Pro Max",
|
||||
width: 440.0,
|
||||
height: 956.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x38, 0x39, 0x3D), c("Silver", 0xD7, 0xD7, 0xD5)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone Air",
|
||||
width: 420.0,
|
||||
height: 912.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x38, 0x39, 0x3D), c("Silver", 0xD7, 0xD7, 0xD5)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 16",
|
||||
width: 393.0,
|
||||
@@ -874,6 +902,13 @@ impl PrototypeSettings {
|
||||
c("Blue", 0x72, 0x84, 0xD4),
|
||||
],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 16 Pro",
|
||||
width: 402.0,
|
||||
height: 874.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x3B, 0x3A, 0x38), c("White", 0xEC, 0xEB, 0xE6)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 16 Pro Max",
|
||||
width: 440.0,
|
||||
@@ -881,6 +916,55 @@ impl PrototypeSettings {
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x3B, 0x3A, 0x38), c("White", 0xEC, 0xEB, 0xE6), c("Natural", 0xBF, 0xB9, 0xAF), c("Sand", 0xC6, 0xAC, 0x8F)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 16 Plus",
|
||||
width: 430.0,
|
||||
height: 932.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x3A, 0x3B, 0x3F), c("White", 0xEE, 0xED, 0xE8)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 15 Pro",
|
||||
width: 393.0,
|
||||
height: 852.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x3B, 0x3A, 0x38), c("Silver", 0xD7, 0xD7, 0xD5)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 15 Pro Max",
|
||||
width: 430.0,
|
||||
height: 932.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x3B, 0x3A, 0x38), c("Silver", 0xD7, 0xD7, 0xD5)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 15 Plus",
|
||||
width: 430.0,
|
||||
height: 932.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x3A, 0x3B, 0x3F), c("White", 0xEE, 0xED, 0xE8)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 14 Pro",
|
||||
width: 393.0,
|
||||
height: 852.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x3B, 0x3A, 0x38), c("Silver", 0xD7, 0xD7, 0xD5)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 14 Pro Max",
|
||||
width: 430.0,
|
||||
height: 932.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x3B, 0x3A, 0x38), c("Silver", 0xD7, 0xD7, 0xD5)],
|
||||
},
|
||||
Device {
|
||||
name: "iPhone 14",
|
||||
width: 390.0,
|
||||
height: 844.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x3A, 0x3B, 0x3F), c("White", 0xEE, 0xED, 0xE8)],
|
||||
},
|
||||
Device {
|
||||
name: "Android Compact",
|
||||
width: 412.0,
|
||||
@@ -888,6 +972,13 @@ impl PrototypeSettings {
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x2E, 0x2F, 0x33), c("Silver", 0xD6, 0xD8, 0xDB), c("Green", 0xA6, 0xC0, 0xA3)],
|
||||
},
|
||||
Device {
|
||||
name: "Android Medium",
|
||||
width: 700.0,
|
||||
height: 840.0,
|
||||
rotates: true,
|
||||
colors: &[c("Black", 0x2E, 0x2F, 0x33), c("Silver", 0xD6, 0xD8, 0xDB)],
|
||||
},
|
||||
Device {
|
||||
name: "iPad mini",
|
||||
width: 744.0,
|
||||
|
||||
@@ -180,6 +180,23 @@ fn vertical_wrap_columns_take_arrows_and_drops() {
|
||||
assert_eq!(line, Some([30.0, 48.0, 50.0, 48.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_auto_layout_to_a_frame_keeps_its_size() {
|
||||
// An existing frame with content hugs it (padding inferred, so the size is preserved);
|
||||
// an empty one keeps its size instead of collapsing to its padding.
|
||||
let mut s = Session::new();
|
||||
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 200})).unwrap());
|
||||
rect(&mut s, f, 40.0, 20.0, "#FF0000");
|
||||
s.execute("layout.add", &json!({"ids": [f]})).unwrap();
|
||||
assert_eq!(sizing(&s, f), (Sizing::Hug, Sizing::Hug));
|
||||
assert_eq!(size(&s, f), (300.0, 200.0));
|
||||
// An empty frame keeps its size too.
|
||||
let e = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 200})).unwrap());
|
||||
s.execute("layout.add", &json!({"ids": [e]})).unwrap();
|
||||
assert_eq!(sizing(&s, e), (Sizing::Fixed, Sizing::Fixed));
|
||||
assert_eq!(size(&s, e), (300.0, 200.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canvas_stacking_paints_and_exports_the_first_child_on_top() {
|
||||
// Documented: the last child is on top by default; "first on top" flips which one is drawn
|
||||
|
||||
@@ -19,6 +19,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("align.bottom", "Align bottom", "{ids?}", true, |s, p| align(s, p, Al::Bottom)),
|
||||
spec!("distribute.horizontal", "Distribute horizontal spacing", "{ids?, spacing?}", true, |s, p| distribute(s, p, true)),
|
||||
spec!("distribute.vertical", "Distribute vertical spacing", "{ids?, spacing?}", true, |s, p| distribute(s, p, false)),
|
||||
spec!(
|
||||
"arrange.spacing",
|
||||
"Spacing",
|
||||
"{ids?, horizontal?, vertical?, fromGesture?} (page units; fromGesture uses the interaction snapshot)",
|
||||
true,
|
||||
spacing
|
||||
),
|
||||
spec!("arrange.tidy", "Tidy up", "{ids?, spacing?}", true, tidy),
|
||||
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)),
|
||||
@@ -546,3 +553,53 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Set the distances between smart-selection tracks without rebuilding/reparenting the layers.
|
||||
fn spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("arrange.spacing", s, p)?;
|
||||
// A scrub is absolute from the interaction snapshot, even if tracks overlap or cross.
|
||||
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_spacing::selection_spacing(&base, &ids);
|
||||
let mut offsets = std::collections::BTreeMap::<NodeId, (Option<f64>, Option<f64>)>::new();
|
||||
let mut any = false;
|
||||
for (key, axis, vertical) in [("horizontal", &layout.horizontal, false), ("vertical", &layout.vertical, true)] {
|
||||
if get(p, key).is_none() {
|
||||
continue;
|
||||
}
|
||||
let value = req_num("arrange.spacing", p, key)?;
|
||||
if value.abs() > 1_000_000.0 {
|
||||
return Err(bad("arrange.spacing", "spacing out of range"));
|
||||
}
|
||||
let axis = axis.as_ref().ok_or_else(|| bad("arrange.spacing", "selection has no spacing on this axis"))?;
|
||||
any = true;
|
||||
for (id, delta) in axis.offsets(value) {
|
||||
if !delta.is_finite() {
|
||||
return Err(bad("arrange.spacing", "non-finite spacing result"));
|
||||
}
|
||||
let pair = offsets.entry(id).or_default();
|
||||
if vertical {
|
||||
pair.1 = Some(delta);
|
||||
} else {
|
||||
pair.0 = Some(delta);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !any {
|
||||
return Err(bad("arrange.spacing", "pass horizontal or vertical spacing"));
|
||||
}
|
||||
let doc = s.doc_mut()?;
|
||||
let mut moves = Vec::new();
|
||||
for (id, (dx, dy)) in offsets {
|
||||
let (Some(before), Some(now)) = (base.world_bounds(id), doc.world_bounds(id)) else { continue };
|
||||
let (dx, dy) = (dx.map_or(0.0, |d| before.x0 + d - now.x0), dy.map_or(0.0, |d| before.y0 + d - now.y0));
|
||||
if !dx.is_finite() || !dy.is_finite() {
|
||||
return Err(bad("arrange.spacing", "non-finite spacing result"));
|
||||
}
|
||||
moves.push((id, dx, dy));
|
||||
}
|
||||
for (id, dx, dy) in moves {
|
||||
move_world(doc, id, dx, dy)?;
|
||||
}
|
||||
ok()
|
||||
}
|
||||
@@ -1156,9 +1156,34 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
let mut warnings = font_style_warnings(&s.ds()?.doc, &ids, &props);
|
||||
// Auto layout keys go to the layers that can have auto layout; with none among the targets,
|
||||
// the first refuses them (`props::layout_mut`).
|
||||
let layout_keys: Vec<&str> = props.keys().map(String::as_str).filter(|k| super::props::LAYOUT_KEYS.contains(k)).collect();
|
||||
let mut without_layout = props.clone();
|
||||
let mut skipped = BTreeSet::new();
|
||||
if !layout_keys.is_empty() {
|
||||
let doc = &s.ds()?.doc;
|
||||
let frame_like = |i: &NodeId| doc.node(*i).is_some_and(|n| n.kind.is_frame_like());
|
||||
if ids.iter().any(frame_like) {
|
||||
skipped = ids.iter().copied().filter(|i| !frame_like(i)).collect();
|
||||
without_layout.retain(|k, _| !layout_keys.contains(&k.as_str()));
|
||||
}
|
||||
if !skipped.is_empty() {
|
||||
let names: Vec<String> =
|
||||
skipped.iter().take(10).filter_map(|i| doc.node(*i)).map(|n| format!("{} “{}” ({})", n.id, n.name, n.kind.type_name())).collect();
|
||||
let more = if skipped.len() > names.len() { format!(" and {} more", skipped.len() - names.len()) } else { String::new() };
|
||||
warnings.push(format!(
|
||||
"{} skipped on {}{more}: only frames, components and instances have auto layout",
|
||||
layout_keys.iter().map(|k| format!("`{k}`")).collect::<Vec<_>>().join(", "),
|
||||
names.join(", ")
|
||||
));
|
||||
}
|
||||
}
|
||||
let doc = s.doc_mut()?;
|
||||
for i in ids {
|
||||
super::props::apply("node.set", doc, i, &props)?;
|
||||
let props = if skipped.contains(&i) { &without_layout } else { &props };
|
||||
super::props::apply("node.set", doc, i, props)?;
|
||||
}
|
||||
if let Some(o) = pivot {
|
||||
for (i, q) in pinned {
|
||||
@@ -1168,7 +1193,43 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
ok()
|
||||
if warnings.is_empty() { ok() } else { Ok(json!({"ok": true, "warnings": warnings})) }
|
||||
}
|
||||
|
||||
/// A `fontStyle` draws in the family's face nearest to it, as a text layer resolves it. When that
|
||||
/// face has another weight or slant than the name asks for ("Black" on a family whose heaviest is
|
||||
/// Bold), say which face it is. Families that aren't installed, and names that match a style or a
|
||||
/// named instance (spelled any way: "SemiBold", "Semi Bold"), say nothing.
|
||||
fn font_style_warnings(doc: &figcraft_doc::Document, ids: &[NodeId], props: &serde_json::Map<String, Value>) -> Vec<String> {
|
||||
let Some(style) = props.get("fontStyle").and_then(Value::as_str) else { return Vec::new() };
|
||||
let mut families: Vec<String> = match props.get("fontFamily").and_then(Value::as_str) {
|
||||
Some(f) => vec![f.to_string()],
|
||||
None => ids
|
||||
.iter()
|
||||
.filter_map(|i| doc.node(*i))
|
||||
.filter_map(|n| n.kind.text())
|
||||
.flat_map(|t| t.spans().into_iter().map(|(_, _, st)| st.font_family.clone()).collect::<Vec<_>>())
|
||||
.collect(),
|
||||
};
|
||||
families.sort();
|
||||
families.dedup();
|
||||
let norm = |s: &str| s.to_lowercase().replace([' ', '-', '_'], "");
|
||||
let (want_w, want_i) = (i32::from(figcraft_doc::style_weight(style)), style.to_lowercase().contains("italic"));
|
||||
families
|
||||
.into_iter()
|
||||
.filter_map(|family| {
|
||||
let styles = figcraft_text::family_styles(&family);
|
||||
if styles.is_empty() || styles.iter().any(|x| norm(x) == norm(style)) {
|
||||
return None;
|
||||
}
|
||||
let face = figcraft_text::find_face(&family, style).filter(|f| f.family.eq_ignore_ascii_case(&family))?;
|
||||
let w = face.axis("wght").map_or(i32::from(face.weight), |a| a.clamp(f64::from(want_w)).round() as i32);
|
||||
if face.instance(style).is_some() || (w == want_w && face.italic == want_i) {
|
||||
return None;
|
||||
}
|
||||
Some(format!("{family} has no `{style}` style: it draws in {}, the nearest (it has: {})", face.style, styles.join(", ")))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `stroke.widthProfile`: a preset or points (through `node.set`'s `widthProfile`, so instances
|
||||
|
||||
@@ -71,6 +71,7 @@ fn registry() -> &'static Registry {
|
||||
list.extend(create::specs());
|
||||
list.extend(edit::specs());
|
||||
list.extend(lists::specs());
|
||||
list.extend(crate::selection_colors::specs());
|
||||
list.extend(arrange::specs());
|
||||
list.extend(structure::specs());
|
||||
list.extend(section::specs());
|
||||
|
||||
@@ -265,6 +265,56 @@ fn ensure_layout(n: &mut Node) -> &mut AutoLayout {
|
||||
n.layout.get_or_insert_with(|| AutoLayout { padding: [0.0; 4], gap: 0.0, ..Default::default() })
|
||||
}
|
||||
|
||||
/// The keys that set a frame's auto layout (through [`layout_mut`]).
|
||||
pub const LAYOUT_KEYS: &[&str] = &[
|
||||
"gap",
|
||||
"itemSpacing",
|
||||
"columnGap",
|
||||
"crossGap",
|
||||
"counterAxisSpacing",
|
||||
"rowGap",
|
||||
"padding",
|
||||
"paddingTop",
|
||||
"paddingRight",
|
||||
"paddingBottom",
|
||||
"paddingLeft",
|
||||
"paddingHorizontal",
|
||||
"paddingVertical",
|
||||
"mainAlign",
|
||||
"primaryAxisAlignItems",
|
||||
"crossAlign",
|
||||
"counterAxisAlignItems",
|
||||
"contentAlign",
|
||||
"counterAxisAlignContent",
|
||||
"wrap",
|
||||
"gridColumns",
|
||||
"gridRows",
|
||||
"strokesIncluded",
|
||||
"reverseZ",
|
||||
"columnTracks",
|
||||
"gridColumnTracks",
|
||||
"rowTracks",
|
||||
"gridRowTracks",
|
||||
];
|
||||
|
||||
/// 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`).
|
||||
fn layout_mut<'a>(cmd: &str, k: &str, n: &'a mut Node) -> Result<&'a mut AutoLayout> {
|
||||
if !n.kind.is_frame_like() {
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!(
|
||||
"`{k}` is an auto layout property, and layer {} “{}” is a {} (only frames, components and instances have auto layout)",
|
||||
n.id,
|
||||
n.name,
|
||||
n.kind.type_name()
|
||||
),
|
||||
));
|
||||
}
|
||||
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<String, Value>) -> Result<()> {
|
||||
let mut unknown = Vec::new();
|
||||
@@ -452,56 +502,56 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
}
|
||||
}
|
||||
"gap" | "itemSpacing" | "columnGap" => {
|
||||
ensure_layout(doc.get_mut(id)?).gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?
|
||||
layout_mut(cmd, k, doc.get_mut(id)?)?.gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?
|
||||
}
|
||||
"crossGap" | "counterAxisSpacing" | "rowGap" => {
|
||||
ensure_layout(doc.get_mut(id)?).cross_gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?
|
||||
layout_mut(cmd, k, doc.get_mut(id)?)?.cross_gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?
|
||||
}
|
||||
"padding" => {
|
||||
let p = four(v).ok_or_else(|| bad(cmd, "padding: n or [t,r,b,l]"))?;
|
||||
ensure_layout(doc.get_mut(id)?).padding = p.map(|x| x.max(0.0));
|
||||
layout_mut(cmd, k, doc.get_mut(id)?)?.padding = p.map(|x| x.max(0.0));
|
||||
}
|
||||
"paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" => {
|
||||
let i = ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"].iter().position(|x| *x == k).unwrap_or(0);
|
||||
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0);
|
||||
if let Some(slot) = ensure_layout(doc.get_mut(id)?).padding.get_mut(i) {
|
||||
if let Some(slot) = layout_mut(cmd, k, doc.get_mut(id)?)?.padding.get_mut(i) {
|
||||
*slot = val;
|
||||
}
|
||||
}
|
||||
"paddingHorizontal" => {
|
||||
let val = f64_of(v).unwrap_or(0.0).max(0.0);
|
||||
let l = ensure_layout(doc.get_mut(id)?);
|
||||
let l = layout_mut(cmd, k, doc.get_mut(id)?)?;
|
||||
l.padding[1] = val;
|
||||
l.padding[3] = val;
|
||||
}
|
||||
"paddingVertical" => {
|
||||
let val = f64_of(v).unwrap_or(0.0).max(0.0);
|
||||
let l = ensure_layout(doc.get_mut(id)?);
|
||||
let l = layout_mut(cmd, k, doc.get_mut(id)?)?;
|
||||
l.padding[0] = val;
|
||||
l.padding[2] = val;
|
||||
}
|
||||
"mainAlign" | "primaryAxisAlignItems" => {
|
||||
ensure_layout(doc.get_mut(id)?).main_align =
|
||||
layout_mut(cmd, k, doc.get_mut(id)?)?.main_align =
|
||||
enum_of(v).ok_or_else(|| bad(cmd, "mainAlign: min|center|max|spaceBetween|spaceAround|spaceEvenly"))?
|
||||
}
|
||||
"crossAlign" | "counterAxisAlignItems" => {
|
||||
ensure_layout(doc.get_mut(id)?).cross_align = enum_of(v).ok_or_else(|| bad(cmd, "crossAlign: min|center|max|baseline"))?
|
||||
layout_mut(cmd, k, doc.get_mut(id)?)?.cross_align = enum_of(v).ok_or_else(|| bad(cmd, "crossAlign: min|center|max|baseline"))?
|
||||
}
|
||||
"contentAlign" | "counterAxisAlignContent" => {
|
||||
ensure_layout(doc.get_mut(id)?).content_align = enum_of(v).ok_or_else(|| bad(cmd, "contentAlign: auto|spaceBetween"))?
|
||||
layout_mut(cmd, k, doc.get_mut(id)?)?.content_align = enum_of(v).ok_or_else(|| bad(cmd, "contentAlign: auto|spaceBetween"))?
|
||||
}
|
||||
"wrap" => ensure_layout(doc.get_mut(id)?).wrap = v.as_bool().unwrap_or(false),
|
||||
"wrap" => layout_mut(cmd, k, doc.get_mut(id)?)?.wrap = v.as_bool().unwrap_or(false),
|
||||
"gridColumns" | "gridRows" => {
|
||||
let count = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.clamp(1.0, 1000.0) as u32;
|
||||
let l = ensure_layout(doc.get_mut(id)?);
|
||||
let l = layout_mut(cmd, k, doc.get_mut(id)?)?;
|
||||
// Tracks past the new count go away (new tracks are 1fr).
|
||||
let (n, tracks) =
|
||||
if k == "gridColumns" { (&mut l.grid_columns, &mut l.column_tracks) } else { (&mut l.grid_rows, &mut l.row_tracks) };
|
||||
*n = count;
|
||||
tracks.truncate(count as usize);
|
||||
}
|
||||
"strokesIncluded" => ensure_layout(doc.get_mut(id)?).strokes_included = v.as_bool().unwrap_or(false),
|
||||
"reverseZ" => ensure_layout(doc.get_mut(id)?).reverse_z = v.as_bool().unwrap_or(false),
|
||||
"strokesIncluded" => layout_mut(cmd, k, doc.get_mut(id)?)?.strokes_included = v.as_bool().unwrap_or(false),
|
||||
"reverseZ" => layout_mut(cmd, k, doc.get_mut(id)?)?.reverse_z = v.as_bool().unwrap_or(false),
|
||||
"sizingH" | "layoutSizingHorizontal" => {
|
||||
let s = sizing(v).ok_or_else(|| bad(cmd, "sizing: fixed|hug|fill"))?;
|
||||
set_sizing(cmd, doc, id, s, true)?;
|
||||
@@ -533,7 +583,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
"columnTracks" | "gridColumnTracks" | "rowTracks" | "gridRowTracks" => {
|
||||
let a = v.as_array().ok_or_else(|| bad(cmd, format!("`{k}` must be an array of tracks")))?;
|
||||
let tracks = a.iter().map(track_of).collect::<Option<Vec<_>>>().ok_or_else(|| bad(cmd, TRACK_HELP))?;
|
||||
let l = ensure_layout(doc.get_mut(id)?);
|
||||
let l = layout_mut(cmd, k, doc.get_mut(id)?)?;
|
||||
if k.contains("olumn") {
|
||||
l.grid_columns = (tracks.len() as u32).clamp(1, 1000);
|
||||
l.column_tracks = tracks;
|
||||
|
||||
@@ -544,11 +544,16 @@ fn layout_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pb = doc.node(*b).map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect())).unwrap_or_default();
|
||||
if vertical { pa.y0.total_cmp(&pb.y0) } else { pa.x0.total_cmp(&pb.x0) }
|
||||
});
|
||||
let empty = doc.node(*f).is_some_and(|n| n.children.iter().filter_map(|c| doc.node(*c)).all(|c| !c.visible || c.child_layout.absolute));
|
||||
let n = doc.get_mut(*f)?;
|
||||
n.children = kids;
|
||||
n.layout = Some(al);
|
||||
n.child_layout.sizing_h = Sizing::Hug;
|
||||
n.child_layout.sizing_v = Sizing::Hug;
|
||||
if !all_frames || !empty {
|
||||
// A frame wrapped around the selection hugs it; an existing frame with no flow
|
||||
// children keeps its size instead of collapsing to its padding.
|
||||
n.child_layout.sizing_h = Sizing::Hug;
|
||||
n.child_layout.sizing_v = Sizing::Hug;
|
||||
}
|
||||
}
|
||||
s.set_selection(targets.clone());
|
||||
Ok(json!({"ids": targets}))
|
||||
|
||||
@@ -47,6 +47,8 @@ pub mod plugins;
|
||||
pub mod proto_run;
|
||||
pub mod rename;
|
||||
pub mod rotation_origin;
|
||||
pub mod selection_colors;
|
||||
pub mod selection_spacing;
|
||||
pub mod shape_handles;
|
||||
pub mod shapebuilder;
|
||||
pub mod snap;
|
||||
@@ -1661,6 +1663,8 @@ mod menu_gaps_tests;
|
||||
#[cfg(test)]
|
||||
mod nested_override_tests;
|
||||
#[cfg(test)]
|
||||
mod node_set_refusals_tests;
|
||||
#[cfg(test)]
|
||||
mod online_tests;
|
||||
#[cfg(test)]
|
||||
mod open_issues_tests;
|
||||
@@ -1802,3 +1806,6 @@ mod version_tests;
|
||||
mod view_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod widgets_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod selection_spacing_tests;
|
||||
@@ -0,0 +1,95 @@
|
||||
//! `node.set` doesn't store what can't apply: auto layout keys go only to layers that can have auto
|
||||
//! layout (the others are named in `warnings`, and with none of them the call is refused), and a
|
||||
//! `fontStyle` the family lacks says which face draws instead.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn id(v: &Value) -> crate::NodeId {
|
||||
crate::params::id_of(v).unwrap()
|
||||
}
|
||||
|
||||
fn bad_msg(r: crate::Result<Value>) -> String {
|
||||
match r {
|
||||
Err(EngineError::BadParams { msg, .. }) => msg,
|
||||
other => panic!("expected BadParams, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_layout_keys_on_layers_without_auto_layout_are_refused() {
|
||||
let mut s = Session::new();
|
||||
let t = id(&ex(&mut s, "node.create", json!({"type": "text", "text": "Hi"}))["id"]);
|
||||
let r = id(&ex(&mut s, "node.create", json!({"type": "rectangle"}))["id"]);
|
||||
for (target, key, value) in [(t, "gap", json!(8)), (r, "padding", json!(4)), (t, "mainAlign", json!("center")), (r, "wrap", json!(true))] {
|
||||
let msg = bad_msg(s.execute("node.set", &json!({"ids": [target], "props": {key: value}})));
|
||||
assert!(msg.contains(key) && msg.contains("auto layout"), "{msg}");
|
||||
assert!(s.doc().unwrap().node(target).unwrap().layout.is_none(), "no hidden auto layout on {target}");
|
||||
}
|
||||
// Frames still take them, with or without a layout mode first.
|
||||
let f = id(&ex(&mut s, "node.create", json!({"type": "frame"}))["id"]);
|
||||
let out = ex(&mut s, "node.set", json!({"ids": [f], "props": {"gap": 8, "padding": 4}}));
|
||||
assert_eq!(s.doc().unwrap().node(f).unwrap().layout.as_ref().map(|l| l.gap), Some(8.0));
|
||||
assert!(out.get("warnings").is_none(), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_several_layers_the_frames_take_auto_layout_keys_and_the_others_are_named() {
|
||||
let mut s = Session::new();
|
||||
let f = id(&ex(&mut s, "node.create", json!({"type": "frame", "name": "Card"}))["id"]);
|
||||
let t = id(&ex(&mut s, "node.create", json!({"type": "text", "text": "Hi", "name": "Title"}))["id"]);
|
||||
let out = ex(&mut s, "node.set", json!({"ids": [f, t], "props": {"gap": 8, "opacity": 0.5}}));
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.node(f).unwrap().layout.as_ref().map(|l| l.gap), Some(8.0));
|
||||
assert!(doc.node(t).unwrap().layout.is_none(), "no hidden auto layout on the text");
|
||||
assert_eq!(doc.node(t).unwrap().opacity, 0.5, "the text still gets the other keys");
|
||||
let w = out["warnings"][0].as_str().unwrap();
|
||||
assert!(w.contains("`gap`") && w.contains(&format!("{t} “Title”")) && w.contains("TEXT"), "{w}");
|
||||
// One undo step for all of it.
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc().unwrap().node(f).unwrap().layout.as_ref().is_none_or(|l| l.gap != 8.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_font_style_draws_in_the_nearest_face_and_says_so_when_it_differs() {
|
||||
figcraft_text::scan_system_fonts();
|
||||
// Any installed family with a regular and a bold face and nothing heavier (CI machines differ).
|
||||
let pick = figcraft_text::family_names().into_iter().find_map(|f| {
|
||||
let st = figcraft_text::family_styles(&f);
|
||||
let has = |w: u16| st.iter().any(|x| figcraft_doc::style_weight(x) == w && !x.to_lowercase().contains("italic"));
|
||||
(has(400) && has(700) && !st.iter().any(|x| figcraft_doc::style_weight(x) > 700)).then_some((f, st))
|
||||
});
|
||||
let Some((family, styles)) = pick else { return };
|
||||
let mut s = Session::new();
|
||||
let t = id(&ex(&mut s, "node.create", json!({"type": "text", "text": "Hi", "props": {"fontFamily": family}}))["id"]);
|
||||
let set = |s: &mut Session, style: &str| ex(s, "node.set", json!({"ids": [t], "props": {"fontStyle": style}}));
|
||||
// Any spelling of a style it has, and names that resolve to the weight they ask for, say nothing.
|
||||
let bold = styles.iter().find(|x| figcraft_doc::style_weight(x) == 700).unwrap().clone();
|
||||
for style in [bold.clone(), bold.to_uppercase(), bold.replace(' ', ""), "Roman".into(), "Normal".into()] {
|
||||
let out = set(&mut s, &style);
|
||||
assert!(out.get("warnings").is_none(), "{style}: {out}");
|
||||
}
|
||||
// A heavier weight than the family has applies (as a text layer resolves it) and says which face draws.
|
||||
let out = set(&mut s, "Black");
|
||||
let w = out["warnings"][0].as_str().unwrap();
|
||||
assert!(w.contains(&family) && w.contains("`Black`") && w.contains("nearest"), "{w}");
|
||||
assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().spans()[0].2.font_style, "Black");
|
||||
// A family that isn't installed keeps any style name without a word (files from elsewhere name theirs).
|
||||
let out = ex(&mut s, "node.set", json!({"ids": [t], "props": {"fontFamily": "No Such Family 7f3a", "fontStyle": "Hairline"}}));
|
||||
assert!(out.get("warnings").is_none(), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_call_changes_nothing() {
|
||||
let mut s = Session::new();
|
||||
let t = id(&ex(&mut s, "node.create", json!({"type": "text", "text": "Hi"}))["id"]);
|
||||
let before = s.doc().unwrap().node(t).unwrap().fills.clone();
|
||||
// `fill` comes before `gap` (keys apply in order), so this checks the rollback.
|
||||
assert!(s.execute("node.set", &json!({"ids": [t], "props": {"fill": "#FF0000", "gap": 8}})).is_err());
|
||||
assert_eq!(s.doc().unwrap().node(t).unwrap().fills, before);
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
//! Paints used by the selection, including descendants and effective text-range fills.
|
||||
//! Targets name paint slots rather than colors: a live edit must not capture other slots when
|
||||
//! its color happens to become equal to another selection color.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use figcraft_color::{Paint, PaintKind};
|
||||
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::params::*;
|
||||
use crate::{CommandSpec, Result, Session, bad, spec};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub struct PaintTarget {
|
||||
pub id: NodeId,
|
||||
pub fills: bool,
|
||||
pub index: usize,
|
||||
/// Character offsets, not byte offsets. None means the node's fill or stroke list.
|
||||
pub range: Option<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl PaintTarget {
|
||||
pub fn paints(&self, doc: &Document) -> Option<Vec<Paint>> {
|
||||
let n = doc.node(self.id)?;
|
||||
if let Some((start, end)) = self.range {
|
||||
let t = n.kind.text()?;
|
||||
if !self.fills || start > end || end > t.char_count() || (start == end && end != 0) {
|
||||
return None;
|
||||
}
|
||||
Some(t.style_at(start).fills.clone().unwrap_or_else(|| n.fills.clone()))
|
||||
} else {
|
||||
Some(if self.fills { n.fills.clone() } else { n.strokes.clone() })
|
||||
}
|
||||
}
|
||||
|
||||
pub fn paint(&self, doc: &Document) -> Option<Paint> {
|
||||
self.paints(doc)?.get(self.index).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct SelectionColor {
|
||||
pub paint: Paint,
|
||||
pub style: Option<String>,
|
||||
pub variable: Option<String>,
|
||||
pub targets: Vec<PaintTarget>,
|
||||
}
|
||||
|
||||
/// Deterministic order, one target per slot even when a parent and child are both selected.
|
||||
/// Images and patterns are assets rather than selection colors. Hidden paints are omitted.
|
||||
pub fn collect(doc: &Document, ids: &[NodeId]) -> Vec<SelectionColor> {
|
||||
let mut groups: Vec<SelectionColor> = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut by_paint: BTreeMap<(Option<String>, Option<String>, String), usize> = BTreeMap::new();
|
||||
let mut pending: Vec<_> = ids.iter().rev().copied().collect();
|
||||
while let Some(id) = pending.pop() {
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
let Some(n) = doc.node(id) else { continue };
|
||||
if n.is_mask {
|
||||
continue;
|
||||
}
|
||||
if !matches!(n.kind, NodeKind::BooleanOp { .. }) {
|
||||
pending.extend(n.children.iter().rev().copied());
|
||||
}
|
||||
let mut add = |paints: &[Paint], fills: bool, range: Option<(usize, usize)>| {
|
||||
for (index, paint) in paints.iter().enumerate() {
|
||||
if !paint.visible || !matches!(paint.kind, PaintKind::Solid { .. } | PaintKind::Gradient(_)) {
|
||||
continue;
|
||||
}
|
||||
let target = PaintTarget { id, fills, index, range };
|
||||
let inherited = range.is_none_or(|(start, _)| n.kind.text().is_some_and(|t| t.style_at(start).fills.is_none()));
|
||||
let style = inherited.then(|| if fills { n.styles.fill.clone() } else { n.styles.stroke.clone() }).flatten();
|
||||
let list = if fills { BoundList::Fills } else { BoundList::Strokes };
|
||||
let variable = inherited
|
||||
.then(|| {
|
||||
n.bound.iter().find_map(|(key, var)| {
|
||||
list_binding(key).filter(|(l, i, field)| *l == list && *i == index && *field == "color").map(|_| var.clone())
|
||||
})
|
||||
})
|
||||
.flatten();
|
||||
let Ok(value) = serde_json::to_string(paint) else { continue };
|
||||
let key = (style.clone(), variable.clone(), value);
|
||||
if let Some(index) = by_paint.get(&key).copied() {
|
||||
if let Some(g) = groups.get_mut(index) {
|
||||
g.targets.push(target);
|
||||
}
|
||||
} else {
|
||||
by_paint.insert(key, groups.len());
|
||||
groups.push(SelectionColor { paint: paint.clone(), style, variable, targets: vec![target] });
|
||||
}
|
||||
}
|
||||
};
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
let spans = t.spans();
|
||||
if spans.iter().all(|(_, _, style)| style.fills.is_none()) {
|
||||
add(&n.fills, true, None);
|
||||
} else {
|
||||
for (start, end, style) in spans {
|
||||
add(style.fills.as_deref().unwrap_or(&n.fills), true, Some((start, end)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
add(&n.fills, true, None);
|
||||
}
|
||||
add(&n.strokes, false, None);
|
||||
}
|
||||
groups
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!(
|
||||
"selection.colors",
|
||||
"Selection colors",
|
||||
"{ids?} → {colors: [{paint, targets}]}; includes descendants and text ranges",
|
||||
false,
|
||||
|s, p| { Ok(json!({"colors": collect(s.doc()?, &targets(s, p))})) }
|
||||
),
|
||||
spec!("selection.setPaint", "Change selection color", "{targets: [{id, fills, index, range: [start,end] | null}], paint}", true, set_paint),
|
||||
spec!(
|
||||
"selection.applyColorStyle",
|
||||
"Apply selection color style",
|
||||
"{targets, style}; applies the color style to matching fill/stroke lists (text ranges keep typography)",
|
||||
true,
|
||||
apply_style
|
||||
),
|
||||
spec!(
|
||||
"selection.selectColor",
|
||||
"Select layers with color",
|
||||
"{targets} → {ids}; selects the layers owning these paint slots",
|
||||
false,
|
||||
select_color
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn checked_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<PaintTarget>> {
|
||||
let ts: Vec<PaintTarget> = parse(cmd, p, "targets")?.ok_or_else(|| bad(cmd, "`targets` is required"))?;
|
||||
if ts.is_empty() {
|
||||
return Err(bad(cmd, "`targets` must not be empty"));
|
||||
}
|
||||
for t in &ts {
|
||||
if t.paint(s.doc()?).is_none() {
|
||||
return Err(bad(cmd, "a selection color target no longer exists"));
|
||||
}
|
||||
}
|
||||
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::<usize>().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<Value> {
|
||||
const CMD: &str = "selection.setPaint";
|
||||
let targets = checked_targets(s, p, CMD)?;
|
||||
let paint = get(p, "paint").and_then(paint_of).ok_or_else(|| bad(CMD, "`paint` must be a paint"))?;
|
||||
for target in &targets {
|
||||
let mut paints = target.paints(s.doc()?).ok_or_else(|| bad(CMD, "invalid target"))?;
|
||||
let Some(slot) = paints.get_mut(target.index) else { return Err(bad(CMD, "invalid paint index")) };
|
||||
if *slot == paint {
|
||||
continue;
|
||||
}
|
||||
let old = slot.clone();
|
||||
*slot = paint.clone();
|
||||
let n = s.doc_mut()?.get_mut(target.id)?;
|
||||
unbind_changed(n, target, &old, &paint);
|
||||
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 {
|
||||
n.fills = paints;
|
||||
n.styles.fill = None;
|
||||
} else {
|
||||
n.strokes = paints;
|
||||
n.styles.stroke = None;
|
||||
}
|
||||
}
|
||||
Ok(json!({"ok": true}))
|
||||
}
|
||||
|
||||
fn apply_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "selection.applyColorStyle";
|
||||
let targets = checked_targets(s, p, CMD)?;
|
||||
let name = req_string(CMD, p, "style")?;
|
||||
let style = s.doc()?.style(&name).cloned().ok_or_else(|| bad(CMD, "no such color style"))?;
|
||||
if !matches!(style.kind, StyleKind::Paint { .. }) {
|
||||
return Err(bad(CMD, "a color style is required"));
|
||||
}
|
||||
for target in &targets {
|
||||
if let Some((start, end)) = target.range {
|
||||
// Text ranges have typography-style links, not paint-style links. Copy resolved paint
|
||||
// values just as an explicit range fill does; never replace its font or size.
|
||||
let StyleKind::Paint { paints } = crate::style_vars::kind_for(s.doc()?, &style, target.id).into_owned() else { continue };
|
||||
let n = s.doc_mut()?.get_mut(target.id)?;
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
t.restyle(start, end, |st| st.fills = Some(paints.clone()));
|
||||
}
|
||||
} else {
|
||||
crate::cmd::styles::apply_style_ref(s.doc_mut()?, target.id, if target.fills { "fillStyle" } else { "strokeStyle" }, Some(&style.id))?;
|
||||
}
|
||||
}
|
||||
Ok(json!({"ok": true}))
|
||||
}
|
||||
|
||||
fn select_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ts = checked_targets(s, p, "selection.selectColor")?;
|
||||
let ids: Vec<_> = ts.iter().map(|t| t.id).collect::<BTreeSet<_>>().into_iter().collect();
|
||||
crate::cmd::edit::select_and_page(s, ids.clone())?;
|
||||
Ok(json!({"ids": ids}))
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Smart-selection spacing in page coordinates. Tracks contain aligned siblings (columns for
|
||||
//! horizontal spacing, rows for vertical spacing); moving a track preserves its internal layout.
|
||||
use figcraft_doc::{Document, NodeId, NodeKind};
|
||||
use figcraft_geom::Rect;
|
||||
|
||||
const EPS: f64 = 0.01;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Track {
|
||||
pub ids: Vec<NodeId>,
|
||||
pub bounds: Rect,
|
||||
anchor: Rect,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Axis {
|
||||
pub tracks: Vec<Track>,
|
||||
pub vertical: bool,
|
||||
/// None means the existing distances are mixed; setting a value makes them uniform.
|
||||
pub value: Option<f64>,
|
||||
}
|
||||
|
||||
impl Axis {
|
||||
pub fn start(&self, b: Rect) -> f64 {
|
||||
if self.vertical { b.y0 } else { b.x0 }
|
||||
}
|
||||
pub fn end(&self, b: Rect) -> f64 {
|
||||
if self.vertical { b.y1 } else { b.x1 }
|
||||
}
|
||||
pub fn gap(&self, a: &Track, b: &Track) -> f64 {
|
||||
self.start(b.bounds) - self.end(a.bounds)
|
||||
}
|
||||
/// Desired page-space translations, keeping the first track fixed.
|
||||
pub fn offsets(&self, value: f64) -> Vec<(NodeId, f64)> {
|
||||
let mut pos = self.tracks.first().map_or(0.0, |t| self.start(t.bounds));
|
||||
let mut out = Vec::new();
|
||||
for t in &self.tracks {
|
||||
let delta = pos - self.start(t.bounds);
|
||||
out.extend(t.ids.iter().map(|id| (*id, delta)));
|
||||
pos += self.end(t.bounds) - self.start(t.bounds) + value;
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct SelectionSpacing {
|
||||
pub horizontal: Option<Axis>,
|
||||
pub vertical: Option<Axis>,
|
||||
}
|
||||
|
||||
/// Available only for movable, visible siblings outside normal auto-layout flow. Parent/child
|
||||
/// selections, instance internals and locked layers must not be moved indirectly by a gap edit.
|
||||
pub fn selection_spacing(doc: &Document, ids: &[NodeId]) -> SelectionSpacing {
|
||||
let Some(first) = ids.first().and_then(|id| doc.node(*id)) else { return SelectionSpacing::default() };
|
||||
if ids.len() < 2 || ids.len() > 2000 {
|
||||
return SelectionSpacing::default();
|
||||
}
|
||||
let mut items = Vec::new();
|
||||
for id in ids {
|
||||
let Some(n) = doc.node(*id) else { return SelectionSpacing::default() };
|
||||
if n.parent != first.parent
|
||||
|| !n.visible
|
||||
|| doc.ancestors(*id).into_iter().any(|a| doc.node(a).is_some_and(|n| !n.visible))
|
||||
|| crate::tools::locked_in(doc, *id)
|
||||
|| crate::components::inside_instance(doc, *id)
|
||||
|| matches!(n.kind, NodeKind::Page { .. })
|
||||
|| n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some() && !n.child_layout.absolute)
|
||||
{
|
||||
return SelectionSpacing::default();
|
||||
}
|
||||
let determinant = n.parent.map(|p| doc.world_transform(p).determinant()).unwrap_or(1.0);
|
||||
if !determinant.is_finite() || determinant.abs() < 1e-12 {
|
||||
return SelectionSpacing::default();
|
||||
}
|
||||
let Some(b) = doc.world_bounds(*id) else { return SelectionSpacing::default() };
|
||||
if ![b.x0, b.y0, b.x1, b.y1].into_iter().all(f64::is_finite) || b.width() <= EPS || b.height() <= EPS {
|
||||
return SelectionSpacing::default();
|
||||
}
|
||||
if !items.iter().any(|(i, _)| i == id) {
|
||||
items.push((*id, b));
|
||||
}
|
||||
}
|
||||
SelectionSpacing { horizontal: axis(&items, false), vertical: axis(&items, true) }
|
||||
}
|
||||
|
||||
fn axis(items: &[(NodeId, Rect)], vertical: bool) -> Option<Axis> {
|
||||
let edges = |b: Rect| if vertical { (b.y0, b.y1) } else { (b.x0, b.x1) };
|
||||
let mut items = items.to_vec();
|
||||
items.sort_by(|a, b| edges(a.1).0.total_cmp(&edges(b.1).0).then(a.0.cmp(&b.0)));
|
||||
let mut tracks: Vec<Track> = Vec::new();
|
||||
for (id, b) in items {
|
||||
let (lo, hi) = edges(b);
|
||||
let aligned = tracks.iter_mut().find(|t| {
|
||||
let (a, z) = edges(t.anchor);
|
||||
(lo - a).abs() <= EPS || (hi - z).abs() <= EPS || ((lo + hi - a - z) * 0.5).abs() <= EPS
|
||||
});
|
||||
if let Some(t) = aligned {
|
||||
t.ids.push(id);
|
||||
t.bounds = t.bounds.union(b);
|
||||
} else {
|
||||
tracks.push(Track { ids: vec![id], bounds: b, anchor: b });
|
||||
}
|
||||
}
|
||||
if tracks.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
tracks.sort_by(|a, b| edges(a.bounds).0.total_cmp(&edges(b.bounds).0));
|
||||
// Diagonally scattered layers aren't a row/column/grid selection.
|
||||
let cross = |b: Rect| if vertical { (b.x0, b.x1) } else { (b.y0, b.y1) };
|
||||
let low = tracks.iter().map(|t| cross(t.bounds).0).fold(f64::NEG_INFINITY, f64::max);
|
||||
let high = tracks.iter().map(|t| cross(t.bounds).1).fold(f64::INFINITY, f64::min);
|
||||
if high - low <= EPS {
|
||||
return None;
|
||||
}
|
||||
let mut result = Axis { tracks, vertical, value: None };
|
||||
let gaps: Vec<f64> = result.tracks.windows(2).filter_map(|p| p.first().zip(p.get(1)).map(|(a, b)| result.gap(a, b))).collect();
|
||||
result.value = gaps.first().copied().filter(|v| gaps.iter().all(|g| (g - v).abs() <= EPS));
|
||||
Some(result)
|
||||
}
|
||||
|
||||
/// A spacing gesture never changes selection. Roll it back in its owning document, preserving
|
||||
/// a subsequent tab/page/selection change and leaving another document's gesture alone.
|
||||
pub fn cancel_gesture(session: &mut crate::Session, uid: u64) {
|
||||
if let Some(ds) = session.docs.iter_mut().find(|d| d.uid == uid)
|
||||
&& let Some(it) = ds.interaction.take()
|
||||
{
|
||||
ds.doc = it.doc;
|
||||
ds.revision += 1;
|
||||
ds.clean_selection();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
use crate::{Session, selection_spacing::selection_spacing};
|
||||
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 {
|
||||
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 rows_columns_and_grids_keep_track_contents_and_sizes() {
|
||||
let mut s = Session::new();
|
||||
// Unequal widths, left-aligned columns. The column widths are 50 and 80; gaps 30 and 20.
|
||||
let a = rect(&mut s, 100., 100., 50., 40., None);
|
||||
let b = rect(&mut s, 180., 100., 80., 40., None);
|
||||
let c = rect(&mut s, 100., 160., 30., 60., None);
|
||||
let d = rect(&mut s, 180., 160., 40., 60., None);
|
||||
let ids = [a, b, c, d];
|
||||
let layout = selection_spacing(s.doc().unwrap(), &ids);
|
||||
assert_eq!(layout.horizontal.unwrap().value, Some(30.));
|
||||
assert_eq!(layout.vertical.unwrap().value, Some(20.));
|
||||
s.execute("arrange.spacing", &json!({"ids":ids,"horizontal":12,"vertical":25})).unwrap();
|
||||
assert_eq!(xy(&s, a), (100., 100.));
|
||||
assert_eq!(xy(&s, b), (162., 100.));
|
||||
assert_eq!(xy(&s, c), (100., 165.));
|
||||
assert_eq!(xy(&s, d), (162., 165.));
|
||||
|
||||
assert_eq!(s.doc().unwrap().node(d).unwrap().width, 40.);
|
||||
s.undo().unwrap();
|
||||
assert_eq!(xy(&s, d), (180., 160.));
|
||||
s.redo().unwrap();
|
||||
assert_eq!(xy(&s, d), (162., 165.));
|
||||
s.begin_interaction("Spacing").unwrap();
|
||||
s.execute("arrange.spacing", &json!({"ids":ids,"horizontal":22,"fromGesture":true})).unwrap();
|
||||
s.execute("arrange.spacing", &json!({"ids":ids,"vertical":35,"fromGesture":true})).unwrap();
|
||||
assert_eq!(xy(&s, d), (172., 175.), "the unrequested axis keeps the earlier edit");
|
||||
s.commit_interaction().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_gaps_normalize_and_fractional_negative_gaps_are_supported() {
|
||||
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);
|
||||
assert_eq!(selection_spacing(s.doc().unwrap(), &[a, b, c]).horizontal.unwrap().value, None);
|
||||
s.execute("arrange.spacing", &json!({"ids":[a,b,c],"horizontal":-5.5})).unwrap();
|
||||
assert_eq!(xy(&s, b), (44.5, 0.));
|
||||
assert_eq!(xy(&s, c), (89., 0.));
|
||||
assert_eq!(selection_spacing(s.doc().unwrap(), &[a, b, c]).horizontal.unwrap().value, Some(-5.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scrub_uses_the_original_tracks_after_crossing_and_makes_one_undo_step() {
|
||||
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, 140., 0., 50., 40., None);
|
||||
let before = s.undo_depth().0;
|
||||
s.begin_interaction("Spacing").unwrap();
|
||||
for v in [-100., 0., 30.] {
|
||||
s.execute("arrange.spacing", &json!({"ids":[a,b,c],"horizontal":v,"fromGesture":true})).unwrap();
|
||||
}
|
||||
assert_eq!(xy(&s, b), (80., 0.));
|
||||
assert_eq!(xy(&s, c), (160., 0.));
|
||||
s.commit_interaction().unwrap();
|
||||
assert_eq!(s.undo_depth().0, before + 1);
|
||||
s.undo().unwrap();
|
||||
assert_eq!(xy(&s, c), (140., 0.));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsafe_selections_and_invalid_axes_are_rejected_without_changes() {
|
||||
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);
|
||||
for props in [json!({"locked":true}), json!({"visible":false})] {
|
||||
s.execute("node.set", &json!({"id":b,"props":props})).unwrap();
|
||||
assert!(s.execute("arrange.spacing", &json!({"ids":[a,b],"horizontal":5})).is_err());
|
||||
s.execute("node.set", &json!({"id":b,"props":{"locked":false,"visible":true}})).unwrap();
|
||||
}
|
||||
for args in [
|
||||
json!({"ids":[a,b],"vertical":5}),
|
||||
json!({"ids":[a,b],"horizontal":5,"vertical":5}),
|
||||
json!({"ids":[a,b],"horizontal":"bad"}),
|
||||
json!({"ids":[a,b],"horizontal":1e300}),
|
||||
] {
|
||||
assert!(s.execute("arrange.spacing", &args).is_err());
|
||||
assert_eq!(xy(&s, b), (70., 0.));
|
||||
}
|
||||
let f = NodeId(
|
||||
s.execute("frame.create", &json!({"x":0,"y":0,"width":300,"height":100,"props":{"layout":"horizontal"}})).unwrap()["id"].as_u64().unwrap(),
|
||||
);
|
||||
let x = rect(&mut s, 0., 0., 50., 40., Some(f));
|
||||
let y = rect(&mut s, 70., 0., 50., 40., Some(f));
|
||||
assert!(selection_spacing(s.doc().unwrap(), &[x, y]).horizontal.is_none());
|
||||
assert!(selection_spacing(s.doc().unwrap(), &[a, x]).horizontal.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_parents_use_page_space_and_cancellation_stays_with_its_document() {
|
||||
let mut s = Session::new();
|
||||
let f = NodeId(
|
||||
s.execute("frame.create", &json!({"x":100,"y":100,"width":300,"height":200,"props":{"rotation":90}})).unwrap()["id"].as_u64().unwrap(),
|
||||
);
|
||||
let a = rect(&mut s, 0., 0., 50., 40., Some(f));
|
||||
let b = rect(&mut s, 70., 0., 50., 40., Some(f));
|
||||
let before = xy(&s, b);
|
||||
let uid = s.ds().unwrap().uid;
|
||||
s.begin_interaction("Spacing").unwrap();
|
||||
s.execute("arrange.spacing", &json!({"ids":[a,b],"vertical":40,"fromGesture":true})).unwrap();
|
||||
assert!(((xy(&s, b).1 - xy(&s, a).1).abs() - 90.0).abs() < 1e-7, "50-high rotated rectangles must have a 40-point gap");
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.begin_interaction("Unrelated").unwrap();
|
||||
crate::selection_spacing::cancel_gesture(&mut s, uid);
|
||||
assert!(s.ds().unwrap().interaction.is_some());
|
||||
s.active = 0;
|
||||
assert_eq!(xy(&s, b), before);
|
||||
assert!(s.ds().unwrap().interaction.is_none());
|
||||
}
|
||||
@@ -415,6 +415,9 @@ fn every_command_survives_junk_params() {
|
||||
// Online files: ids, servers, refreshes, actions and names as junk.
|
||||
json!({"file": [1], "server": 7, "refresh": "maybe", "action": "share", "name": {"x": 1}}),
|
||||
json!({"file": "../x", "server": "wss://\u{0}", "refresh": true, "action": "unshare", "name": 5}),
|
||||
// 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}),
|
||||
// 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"}),
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use figcraft_color::Paint;
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_engine::{
|
||||
Session,
|
||||
selection_colors::{SelectionColor, collect},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap()
|
||||
}
|
||||
fn rectangle(s: &mut Session, color: &str) -> NodeId {
|
||||
NodeId(run(s, "shape.rectangle", json!({"width":40,"height":40,"props":{"fill":color}}))["id"].as_u64().unwrap())
|
||||
}
|
||||
fn group(s: &Session, ids: &[NodeId], hex: &str) -> SelectionColor {
|
||||
collect(s.doc().unwrap(), ids).into_iter().find(|g| g.paint.solid_color().is_some_and(|c| c.to_hex() == hex)).unwrap()
|
||||
}
|
||||
fn fill(s: &Session, id: NodeId) -> String {
|
||||
s.doc().unwrap().node(id).unwrap().fills[0].solid_color().unwrap().to_hex()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregate_descendants_deduplicate_roots_and_ignore_images_and_hidden_paints() {
|
||||
let mut s = Session::new();
|
||||
let a = rectangle(&mut s, "#ff0000");
|
||||
let b = rectangle(&mut s, "#00ff00");
|
||||
run(&mut s, "stroke.add", json!({"id":b,"color":"#ff0000"}));
|
||||
let parent = NodeId(run(&mut s, "object.group", json!({"ids":[a,b]}))["id"].as_u64().unwrap());
|
||||
let colors = collect(s.doc().unwrap(), &[parent, a]);
|
||||
assert_eq!(colors.len(), 2);
|
||||
assert_eq!(group(&s, &[parent, a], "FF0000").targets.len(), 2);
|
||||
run(&mut s, "fill.add", json!({"id":a,"paint":Paint::image("synthetic",figcraft_color::ImageScaleMode::Fill)}));
|
||||
run(&mut s, "fill.add", json!({"id":a,"paint":{"type":"solid","color":"#0000ff","visible":false}}));
|
||||
assert_eq!(collect(s.doc().unwrap(), &[parent]).len(), 2);
|
||||
run(&mut s, "select.set", json!({"ids":[parent]}));
|
||||
assert_eq!(run(&mut s, "selection.colors", json!({}))["colors"].as_array().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_only_matching_slots_is_one_undo_step_and_keeps_selection() {
|
||||
let mut s = Session::new();
|
||||
let a = rectangle(&mut s, "#ff0000");
|
||||
let b = rectangle(&mut s, "#0000ff");
|
||||
let c = rectangle(&mut s, "#00ff00");
|
||||
run(&mut s, "stroke.add", json!({"id":b,"color":"#ff0000"}));
|
||||
run(&mut s, "select.set", json!({"ids":[a,b]}));
|
||||
let red = group(&s, &[a, b], "FF0000");
|
||||
let before = s.undo_depth();
|
||||
run(&mut s, "selection.setPaint", json!({"targets":red.targets,"paint":"#ffff00"}));
|
||||
assert_eq!(fill(&s, a), "FFFF00");
|
||||
assert_eq!(fill(&s, b), "0000FF");
|
||||
assert_eq!(fill(&s, c), "00FF00");
|
||||
assert_eq!(s.doc().unwrap().node(b).unwrap().strokes[0].solid_color().unwrap().to_hex(), "FFFF00");
|
||||
assert_eq!(s.selection(), vec![a, b]);
|
||||
assert_eq!(s.undo_depth().0, before.0 + 1);
|
||||
s.undo().unwrap();
|
||||
assert_eq!(fill(&s, a), "FF0000");
|
||||
s.redo().unwrap();
|
||||
assert_eq!(fill(&s, a), "FFFF00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_picker_targets_do_not_expand_when_colors_merge() {
|
||||
let mut s = Session::new();
|
||||
let a = rectangle(&mut s, "#ff0000");
|
||||
let b = rectangle(&mut s, "#0000ff");
|
||||
let red = group(&s, &[a, b], "FF0000");
|
||||
let before = s.undo_depth();
|
||||
s.begin_interaction("Color").unwrap();
|
||||
for hex in ["#0000ff", "#00ff00"] {
|
||||
run(&mut s, "selection.setPaint", json!({"targets":red.targets,"paint":hex}));
|
||||
}
|
||||
s.commit_interaction().unwrap();
|
||||
assert_eq!(fill(&s, a), "00FF00");
|
||||
assert_eq!(fill(&s, b), "0000FF");
|
||||
assert_eq!(s.undo_depth().0, before.0 + 1);
|
||||
s.undo().unwrap();
|
||||
assert_eq!(fill(&s, a), "FF0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_range_colors_use_character_offsets_and_preserve_typography() {
|
||||
let mut s = Session::new();
|
||||
let id = NodeId(run(&mut s, "text.create", json!({"text":"Яab","props":{"fill":"#ff0000"}}))["id"].as_u64().unwrap());
|
||||
run(&mut s, "text.style", json!({"id":id,"start":1,"end":2,"fontSize":30,"textFills":["#0000ff"]}));
|
||||
let blue = group(&s, &[id], "0000FF");
|
||||
assert_eq!(blue.targets[0].range, Some((1, 2)));
|
||||
run(&mut s, "selection.setPaint", json!({"targets":blue.targets,"paint":"#00ff00"}));
|
||||
let n = s.doc().unwrap().node(id).unwrap();
|
||||
let t = n.kind.text().unwrap();
|
||||
assert_eq!(t.characters, "Яab");
|
||||
assert_eq!(t.style_at(1).font_size, 30.0);
|
||||
assert_eq!(t.style_at(1).fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(), "00FF00");
|
||||
assert_eq!(t.style_at(0).fills.as_deref().unwrap_or(&n.fills)[0].solid_color().unwrap().to_hex(), "FF0000");
|
||||
assert_eq!(t.style_at(2).fills.as_deref().unwrap_or(&n.fills)[0].solid_color().unwrap().to_hex(), "FF0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_color_selects_its_unique_owners_without_document_history() {
|
||||
let mut s = Session::new();
|
||||
let a = rectangle(&mut s, "#ff0000");
|
||||
let b = rectangle(&mut s, "#0000ff");
|
||||
run(&mut s, "stroke.add", json!({"id":a,"color":"#ff0000"}));
|
||||
let red = group(&s, &[a, b], "FF0000");
|
||||
let before = s.undo_depth();
|
||||
run(&mut s, "selection.selectColor", json!({"targets":red.targets}));
|
||||
assert_eq!(s.selection(), vec![a]);
|
||||
assert_eq!(s.undo_depth(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applying_color_styles_links_matching_fill_and_stroke_lists_only() {
|
||||
let mut s = Session::new();
|
||||
let a = rectangle(&mut s, "#ff0000");
|
||||
let b = rectangle(&mut s, "#0000ff");
|
||||
run(&mut s, "stroke.add", json!({"id":b,"color":"#ff0000"}));
|
||||
let red = group(&s, &[a, b], "FF0000");
|
||||
let style = run(&mut s, "style.create", json!({"type":"paint","name":"Brand","value":"#00ff00"}))["id"].clone();
|
||||
let before = s.undo_depth();
|
||||
run(&mut s, "selection.applyColorStyle", json!({"targets":red.targets,"style":style}));
|
||||
assert_eq!(fill(&s, a), "00FF00");
|
||||
assert_eq!(fill(&s, b), "0000FF");
|
||||
assert_eq!(s.doc().unwrap().node(a).unwrap().styles.fill.as_deref(), style.as_str());
|
||||
assert_eq!(s.doc().unwrap().node(b).unwrap().styles.stroke.as_deref(), style.as_str());
|
||||
assert_eq!(s.undo_depth().0, before.0 + 1);
|
||||
run(&mut s, "style.update", json!({"style":style,"value":"#ffff00"}));
|
||||
assert_eq!(fill(&s, a), "FFFF00");
|
||||
assert_eq!(fill(&s, b), "0000FF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_color_detaches_its_binding_but_keeps_other_bindings() {
|
||||
let mut s = Session::new();
|
||||
let a = rectangle(&mut s, "#ff0000");
|
||||
run(&mut s, "variable.createCollection", json!({"name":"Theme"}));
|
||||
let v = run(&mut s, "variable.create", json!({"collection":"Theme","name":"Brand","type":"color","value":"#ff0000"}))["id"].clone();
|
||||
run(&mut s, "variable.bind", json!({"id":a,"field":"fill","variable":v}));
|
||||
let red = group(&s, &[a], "FF0000");
|
||||
run(&mut s, "selection.setPaint", json!({"targets":red.targets,"paint":"#0000ff"}));
|
||||
assert_eq!(fill(&s, a), "0000FF");
|
||||
assert!(s.doc().unwrap().node(a).unwrap().bound.is_empty());
|
||||
s.undo().unwrap();
|
||||
assert_eq!(fill(&s, a), "FF0000");
|
||||
assert!(!s.doc().unwrap().node(a).unwrap().bound.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_or_stale_targets_roll_back_atomically() {
|
||||
let mut s = Session::new();
|
||||
let a = rectangle(&mut s, "#ff0000");
|
||||
let mut red = group(&s, &[a], "FF0000");
|
||||
let mut bad = red.targets[0].clone();
|
||||
bad.index = 999;
|
||||
red.targets.push(bad);
|
||||
let before = s.undo_depth();
|
||||
assert!(s.execute("selection.setPaint", &json!({"targets":red.targets,"paint":"#0000ff"})).is_err());
|
||||
assert_eq!(fill(&s, a), "FF0000");
|
||||
assert_eq!(s.undo_depth(), before);
|
||||
for command in ["selection.setPaint", "selection.selectColor", "selection.applyColorStyle"] {
|
||||
for params in [json!({}), json!({"targets":[]}), json!({"targets":[{"id":a,"fills":true,"index":0,"range":[0,1]}]})] {
|
||||
assert!(s.execute(command, ¶ms).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_resolved_colors_from_different_styles_or_variables_stay_separate() {
|
||||
let mut s = Session::new();
|
||||
let a = rectangle(&mut s, "#ff0000");
|
||||
let b = rectangle(&mut s, "#ff0000");
|
||||
let c = rectangle(&mut s, "#ff0000");
|
||||
let style = run(&mut s, "style.create", json!({"type":"paint","name":"Brand","value":"#ff0000"}))["id"].clone();
|
||||
run(&mut s, "style.apply", json!({"id":a,"style":style,"target":"fill"}));
|
||||
run(&mut s, "variable.createCollection", json!({"name":"Theme"}));
|
||||
let variable = run(&mut s, "variable.create", json!({"collection":"Theme","name":"Accent","type":"color","value":"#ff0000"}))["id"].clone();
|
||||
run(&mut s, "variable.bind", json!({"id":b,"field":"fill","variable":variable}));
|
||||
let colors = collect(s.doc().unwrap(), &[a, b, c]);
|
||||
assert_eq!(colors.len(), 3);
|
||||
assert_eq!(colors[0].style.as_deref(), style.as_str());
|
||||
assert_eq!(colors[1].variable.as_deref(), variable.as_str());
|
||||
assert!(colors[2].style.is_none() && colors[2].variable.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradients_and_multiple_paints_are_edited_without_replacing_other_slots() {
|
||||
use figcraft_color::{Color, Gradient, GradientKind};
|
||||
let mut s = Session::new();
|
||||
let a = rectangle(&mut s, "#ff0000");
|
||||
let paint = Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::BLACK));
|
||||
run(&mut s, "fill.add", json!({"id":a,"paint":paint}));
|
||||
let colors = collect(s.doc().unwrap(), &[a]);
|
||||
assert_eq!(colors.len(), 2);
|
||||
let gradient = colors.iter().find(|g| g.paint == paint).unwrap();
|
||||
run(&mut s, "selection.setPaint", json!({"targets":gradient.targets,"paint":"#00ff00"}));
|
||||
let paints = &s.doc().unwrap().node(a).unwrap().fills;
|
||||
assert_eq!(paints.len(), 2);
|
||||
assert_eq!(paints[0].solid_color().unwrap().to_hex(), "FF0000");
|
||||
assert_eq!(paints[1].solid_color().unwrap().to_hex(), "00FF00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_and_boolean_operands_are_not_selection_colors() {
|
||||
let mut s = Session::new();
|
||||
let a = rectangle(&mut s, "#ff0000");
|
||||
let b = rectangle(&mut s, "#00ff00");
|
||||
let boolean = NodeId(run(&mut s, "bool.union", json!({"ids":[a,b]}))["id"].as_u64().unwrap());
|
||||
run(&mut s, "fill.set", json!({"id":boolean,"color":"#0000ff"}));
|
||||
let colors = collect(s.doc().unwrap(), &[boolean]);
|
||||
assert_eq!(colors.len(), 1);
|
||||
assert_eq!(colors[0].paint.solid_color().unwrap().to_hex(), "0000FF");
|
||||
s.doc_mut().unwrap().get_mut(boolean).unwrap().is_mask = true;
|
||||
assert!(collect(s.doc().unwrap(), &[boolean]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_edit_keeps_a_node_binding_that_other_characters_inherit() {
|
||||
let mut s = Session::new();
|
||||
let id = NodeId(run(&mut s, "text.create", json!({"text":"abc","props":{"fill":"#ff0000"}}))["id"].as_u64().unwrap());
|
||||
run(&mut s, "variable.createCollection", json!({"name":"Theme"}));
|
||||
let v = run(&mut s, "variable.create", json!({"collection":"Theme","name":"Brand","type":"color","value":"#ff0000"}))["id"].clone();
|
||||
run(&mut s, "variable.bind", json!({"id":id,"field":"fill","variable":v}));
|
||||
run(&mut s, "text.style", json!({"id":id,"start":1,"end":2,"textFills":["#0000ff"]}));
|
||||
let blue = group(&s, &[id], "0000FF");
|
||||
run(&mut s, "selection.setPaint", json!({"targets":blue.targets,"paint":"#00ff00"}));
|
||||
assert!(!s.doc().unwrap().node(id).unwrap().bound.is_empty());
|
||||
assert_eq!(fill(&s, id), "FF0000");
|
||||
assert_eq!(
|
||||
s.doc().unwrap().node(id).unwrap().kind.text().unwrap().style_at(1).fills.as_ref().unwrap()[0].solid_color().unwrap().to_hex(),
|
||||
"00FF00"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_color_edits_respect_read_only_version_previews() {
|
||||
let mut s = Session::new();
|
||||
let a = rectangle(&mut s, "#ff0000");
|
||||
run(&mut s, "file.saveVersion", json!({"name":"Before"}));
|
||||
run(&mut s, "version.preview", json!({"index":0}));
|
||||
let red = group(&s, &[a], "FF0000");
|
||||
let before = s.undo_depth();
|
||||
assert!(s.execute("selection.setPaint", &json!({"targets":red.targets,"paint":"#0000ff"})).is_err());
|
||||
assert_eq!(fill(&s, a), "FF0000");
|
||||
assert_eq!(s.undo_depth(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opacity_edits_keep_the_color_variable_binding() {
|
||||
let mut s = Session::new();
|
||||
let a = rectangle(&mut s, "#ff0000");
|
||||
run(&mut s, "variable.createCollection", json!({"name":"Theme"}));
|
||||
let v = run(&mut s, "variable.create", json!({"collection":"Theme","name":"Brand","type":"color","value":"#ff0000"}))["id"].clone();
|
||||
run(&mut s, "variable.bind", json!({"id":a,"field":"fill","variable":v}));
|
||||
let mut red = group(&s, &[a], "FF0000");
|
||||
red.paint.opacity = 0.5;
|
||||
run(&mut s, "selection.setPaint", json!({"targets":red.targets,"paint":red.paint}));
|
||||
let n = s.doc().unwrap().node(a).unwrap();
|
||||
assert_eq!(n.fills[0].opacity, 0.5);
|
||||
assert!(!n.bound.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_text_fill_keeps_a_color_style_link() {
|
||||
let mut s = Session::new();
|
||||
let id = NodeId(run(&mut s, "text.create", json!({"text":"Text","props":{"fill":"#ff0000"}}))["id"].as_u64().unwrap());
|
||||
let red = group(&s, &[id], "FF0000");
|
||||
assert_eq!(red.targets[0].range, None);
|
||||
let style = run(&mut s, "style.create", json!({"type":"paint","name":"Brand","value":"#0000ff"}))["id"].clone();
|
||||
run(&mut s, "selection.applyColorStyle", json!({"targets":red.targets,"style":style}));
|
||||
assert_eq!(s.doc().unwrap().node(id).unwrap().styles.fill.as_deref(), style.as_str());
|
||||
run(&mut s, "style.update", json!({"style":style,"value":"#00ff00"}));
|
||||
assert_eq!(fill(&s, id), "00FF00");
|
||||
}
|
||||
@@ -163,7 +163,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"set_properties",
|
||||
"Set properties",
|
||||
"Set properties on one or more layers as one undo step (engine `node.set`). Keys: name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), fill (colour) / fills (array of paints: \"#hex\" or {\"type\":\"solid\",\"color\":\"#hex\"} or {\"type\":\"linearGradient\",\"stops\":[\"#hex\",\"#hex\"]} or stops [{\"position\":0..1,\"color\":\"#hex\"}], optional from/to handles in the unit box; radialGradient too), stroke / strokes, strokeWeight, strokeAlign (inside|center|outside), cornerRadius (n or [tl,tr,br,bl]), effects (array, e.g. {\"type\":\"dropShadow\",\"color\":\"#00000040\",\"offset\":[0,4],\"radius\":8}), clip, layout (null|horizontal|vertical|grid), gap, padding (n or [t,r,b,l]), mainAlign (min|center|max|spaceBetween|spaceAround|spaceEvenly), crossAlign, wrap, contentAlign (auto|spaceBetween: wrapped lines), sizingH / sizingV (fixed|hug|fill), constraints {horizontal, vertical}, characters, fontFamily, fontStyle, fontSize, lineHeight, letterSpacing, textAlign, textAutoResize. Run list_commands with filter \"node.set\" for the full list. Unknown keys are an error. Returns the updated layers.",
|
||||
"Set properties on one or more layers as one undo step (engine `node.set`). Keys: name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), fill (colour) / fills (array of paints: \"#hex\" or {\"type\":\"solid\",\"color\":\"#hex\"} or {\"type\":\"linearGradient\",\"stops\":[\"#hex\",\"#hex\"]} or stops [{\"position\":0..1,\"color\":\"#hex\"}], optional from/to handles in the unit box; radialGradient too), stroke / strokes, strokeWeight, strokeAlign (inside|center|outside), cornerRadius (n or [tl,tr,br,bl]), effects (array, e.g. {\"type\":\"dropShadow\",\"color\":\"#00000040\",\"offset\":[0,4],\"radius\":8}), clip, layout (null|horizontal|vertical|grid), gap, padding (n or [t,r,b,l]), mainAlign (min|center|max|spaceBetween|spaceAround|spaceEvenly), crossAlign, wrap, contentAlign (auto|spaceBetween: wrapped lines), sizingH / sizingV (fixed|hug|fill), constraints {horizontal, vertical}, characters, fontFamily, fontStyle, fontSize, lineHeight, letterSpacing, textAlign, textAutoResize. Auto layout keys (gap, padding, mainAlign, crossAlign, wrap, …) apply to frames, components and instances only: other layers among the ids are skipped and named in `warnings`, and with none that can take them the call is an error. A fontStyle draws in the family's nearest face; `warnings` says when that face has another weight or slant than the name asks for. Run list_commands with filter \"node.set\" for the full list. Unknown keys are an error. Returns the updated layers.",
|
||||
obj(
|
||||
json!({"ids": ids_schema("Layer ids (default: the selection)"), "props": {"type": "object", "description": "Property values to set"}}),
|
||||
&["props"],
|
||||
@@ -544,7 +544,7 @@ fn set_properties(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolRes
|
||||
}
|
||||
params["ids"] = json!(ids);
|
||||
}
|
||||
exec(b, "node.set", params)?;
|
||||
let warnings = exec(b, "node.set", params)?.get("warnings").cloned();
|
||||
let ids = match ids {
|
||||
Some(i) => i,
|
||||
None => b
|
||||
@@ -555,7 +555,11 @@ fn set_properties(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolRes
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
let nodes: Vec<Value> = ids.iter().take(20).filter_map(|i| b.call("node.get", json!({"id": i, "depth": 0})).ok()).collect();
|
||||
Ok(ToolResult::json(&json!({"ok": true, "nodes": nodes})))
|
||||
let mut out = json!({"ok": true, "nodes": nodes});
|
||||
if let Some(w) = warnings {
|
||||
out["warnings"] = w;
|
||||
}
|
||||
Ok(ToolResult::json(&out))
|
||||
}
|
||||
|
||||
fn inspect_document(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, String> {
|
||||
|
||||
@@ -208,3 +208,14 @@ fn serve_accepts_a_limit_sized_frame_and_a_final_unterminated_frame() {
|
||||
assert_eq!(lines[0]["result"], json!({}));
|
||||
assert_eq!(lines[1]["id"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_properties_passes_on_what_it_skipped() {
|
||||
let mut c = Client::new();
|
||||
let f = c.ok("create_node", json!({"type": "frame"}))["id"].clone();
|
||||
let t = c.ok("create_node", json!({"type": "text", "text": "Hi"}))["id"].clone();
|
||||
let r = c.ok("set_properties", json!({"ids": [f, t], "props": {"gap": 8}}));
|
||||
assert!(r["warnings"][0].as_str().is_some_and(|w| w.contains("`gap`") && w.contains("TEXT")), "{r}");
|
||||
assert!(c.ok("set_properties", json!({"ids": [f], "props": {"gap": 4}})).get("warnings").is_none());
|
||||
assert!(c.err("set_properties", json!({"ids": [t], "props": {"gap": 4}})).contains("auto layout"));
|
||||
}
|
||||
@@ -333,6 +333,7 @@ status = "draft — needs native review"
|
||||
"Current version" = "Aktuální verze"
|
||||
"Cursor chat" = "Chat u kurzoru"
|
||||
"Custom" = "Vlastní"
|
||||
"Custom size" = "Vlastní velikost"
|
||||
"Cut" = "Vyjmout"
|
||||
"Dark" = "Tmavý"
|
||||
"Darken" = "Ztmavit"
|
||||
@@ -638,6 +639,7 @@ status = "draft — needs native review"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Návrh rozhraní a prototypování. Svobodný nástroj s otevřeným zdrojovým kódem, napsaný od základu v jazyce Rust nezávisle na kódu jiných produktů. Součást sady ArtCraft Crafting Apps."
|
||||
"Intersect" = "Průnik"
|
||||
"Intersects" = "Průniky"
|
||||
"Invalid color" = "Neplatná barva"
|
||||
"Invite link" = "Odkaz s pozvánkou"
|
||||
"Invite link copied" = "Odkaz s pozvánkou zkopírován"
|
||||
"It asks permission to:" = "Žádá o oprávnění:"
|
||||
@@ -795,6 +797,7 @@ status = "draft — needs native review"
|
||||
"Nested instance" = "Vnořená instance"
|
||||
"New design file" = "Nový soubor návrhu"
|
||||
"Newest first" = "Od nejnovějších"
|
||||
"Next frame" = "Další rámec"
|
||||
"Next or previous control in the focused region" = "Další nebo předchozí ovládací prvek v zaměřené oblasti"
|
||||
"Next ready for dev" = "Další připravené pro vývoj"
|
||||
"Next size option" = "Další možnost velikosti"
|
||||
@@ -804,6 +807,7 @@ status = "draft — needs native review"
|
||||
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Žádné větve. Větev je kopie souboru pro vyzkoušení změn, které pak zkontrolujete a sloučíte."
|
||||
"No category" = "Bez kategorie"
|
||||
"No changes" = "Žádné změny"
|
||||
"No color styles" = "Žádné barevné styly"
|
||||
"No colour variables fit here." = "Žádné vhodné barevné proměnné."
|
||||
"No colour variables yet. Create them in Local variables." = "Zatím žádné barevné proměnné. Vytvořte je v Místních proměnných."
|
||||
"No comments yet" = "Zatím žádné komentáře"
|
||||
@@ -811,6 +815,7 @@ status = "draft — needs native review"
|
||||
"No components yet. Select layers and press {keys}." = "Zatím žádné komponenty. Vyberte vrstvy a stiskněte {keys}."
|
||||
"No conflicts." = "Žádné konflikty."
|
||||
"No contributor data was built into this copy." = "Toto sestavení neobsahuje údaje o přispěvatelích."
|
||||
"No device" = "Bez zařízení"
|
||||
"No fonts match" = "Žádná odpovídající písma"
|
||||
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Kód pro připojení není dostupný (server kódy nepodporuje nebo tuto adresu nelze do kódu vložit): sdílejte odkaz."
|
||||
"No layer changes to show." = "Žádné změny vrstev k zobrazení."
|
||||
@@ -994,6 +999,7 @@ status = "draft — needs native review"
|
||||
"Preview" = "Náhled"
|
||||
"Preview this flow" = "Náhled tohoto toku"
|
||||
"Preview this frame" = "Náhled tohoto rámce"
|
||||
"Previous frame" = "Předchozí rámec"
|
||||
"Previous ready for dev" = "Předchozí připravené pro vývoj"
|
||||
"Progressive" = "Postupné"
|
||||
"Properties" = "Vlastnosti"
|
||||
@@ -1161,12 +1167,14 @@ status = "draft — needs native review"
|
||||
"Select all with same" = "Vybrat vše se stejným"
|
||||
"Select layer" = "Vybrat vrstvu"
|
||||
"Select layers to apply the style to" = "Vyberte vrstvy, na které chcete použít styl"
|
||||
"Select layers with this color" = "Vybrat vrstvy s touto barvou"
|
||||
"Select overlapping shapes" = "Vyberte překrývající se tvary"
|
||||
"Select points to edit their position, radius and mirroring" = "Vyberte body a upravte jejich polohu, poloměr a zrcadlení"
|
||||
"Select slot" = "Vybrat slot"
|
||||
"Selection" = "Výběr"
|
||||
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Rámeček výběru, {w} × {h} na pozici {x}, {y}: šipky posouvají, Shift+šipky nebo {cmd}+šipky mění velikost, Enter vybere, Escape zruší"
|
||||
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Rámeček výběru: šipky posouvají, Shift+šipky nebo {cmd}+šipky mění velikost, Enter vybere, Escape zruší"
|
||||
"Selection colors" = "Barvy výběru"
|
||||
"Send backward" = "Přesunout o úroveň níž"
|
||||
"Send report" = "Odeslat hlášení"
|
||||
"Send to back" = "Přesunout do pozadí"
|
||||
@@ -1685,6 +1693,7 @@ status = "draft — needs native review"
|
||||
"arrange.packH" = "Rozmístit těsně vodorovně"
|
||||
"arrange.packV" = "Rozmístit těsně svisle"
|
||||
"arrange.roundToPixels" = "Zaokrouhlit na pixely"
|
||||
"arrange.spacing" = "Rozestupy"
|
||||
"arrange.tidy" = "Uspořádat"
|
||||
"bool.exclude" = "Vyloučit výběr"
|
||||
"bool.flatten" = "Sloučit booleovskou skupinu do vektoru"
|
||||
@@ -1992,6 +2001,10 @@ status = "draft — needs native review"
|
||||
"select.sameStroke" = "Vybrat stejný tah"
|
||||
"select.sameText" = "Vybrat stejné vlastnosti textu"
|
||||
"select.set" = "Vybrat"
|
||||
"selection.applyColorStyle" = "Použít barevný styl na výběr"
|
||||
"selection.colors" = "Barvy výběru"
|
||||
"selection.selectColor" = "Vybrat vrstvy s touto barvou"
|
||||
"selection.setPaint" = "Změnit barvu výběru"
|
||||
"shape.arrow" = "Šipka"
|
||||
"shape.ellipse" = "Elipsa"
|
||||
"shape.line" = "Čára"
|
||||
|
||||
@@ -329,6 +329,7 @@ status = "draft — needs native review"
|
||||
"Current version" = "Aktuelle Version"
|
||||
"Cursor chat" = "Cursor-Chat"
|
||||
"Custom" = "Benutzerdefiniert"
|
||||
"Custom size" = "Benutzerdefinierte Größe"
|
||||
"Cut" = "Ausschneiden"
|
||||
"Dark" = "Dunkel"
|
||||
"Darken" = "Abdunkeln"
|
||||
@@ -623,6 +624,7 @@ status = "draft — needs native review"
|
||||
"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 und Prototyping. Ein kostenloses Open-Source-Designwerkzeug, als Clean-Room-Entwicklung von Grund auf in Rust geschrieben, Teil der ArtCraft Crafting Apps."
|
||||
"Intersect" = "Schnittmenge"
|
||||
"Intersects" = "Schnittmengen"
|
||||
"Invalid color" = "Ungültige Farbe"
|
||||
"Invite link" = "Einladungslink"
|
||||
"Invite link copied" = "Einladungslink kopiert"
|
||||
"It asks permission to:" = "Es möchte:"
|
||||
@@ -768,6 +770,7 @@ status = "draft — needs native review"
|
||||
"Navigate to" = "Navigieren zu"
|
||||
"Nested instance" = "Verschachtelte Instanz"
|
||||
"New design file" = "Neue Designdatei"
|
||||
"Next frame" = "Nächster Rahmen"
|
||||
"Next or previous control in the focused region" = "Nächstes oder vorheriges Steuerelement im fokussierten Bereich"
|
||||
"Next ready for dev" = "Nächstes bereit für Entwicklung"
|
||||
"Next size option" = "Nächste Größenoption"
|
||||
@@ -777,6 +780,7 @@ status = "draft — needs native review"
|
||||
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Keine Branches. Ein Branch ist eine Kopie der Datei, in der Sie Änderungen ausprobieren, prüfen und dann zusammenführen."
|
||||
"No category" = "Keine Kategorie"
|
||||
"No changes" = "Keine Änderungen"
|
||||
"No color styles" = "Keine Farbstile"
|
||||
"No colour variables fit here." = "Hier passen keine Farbvariablen."
|
||||
"No colour variables yet. Create them in Local variables." = "Noch keine Farbvariablen. Erstellen Sie sie unter Lokale Variablen."
|
||||
"No comments yet" = "Noch keine Kommentare"
|
||||
@@ -784,6 +788,7 @@ status = "draft — needs native review"
|
||||
"No components yet. Select layers and press {keys}." = "Noch keine Komponenten. Wählen Sie Ebenen aus und drücken Sie {keys}."
|
||||
"No conflicts." = "Keine Konflikte."
|
||||
"No contributor data was built into this copy." = "Diese Kopie enthält keine Daten zu Mitwirkenden."
|
||||
"No device" = "Kein Gerät"
|
||||
"No fonts match" = "Keine passenden Schriften"
|
||||
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Hier gibt es keinen Beitrittscode (der Server ist älter als Beitrittscodes, oder diese Adresse passt in keinen): Teilen Sie stattdessen den Link."
|
||||
"No layer changes to show." = "Keine Ebenenänderungen vorhanden."
|
||||
@@ -960,6 +965,7 @@ status = "draft — needs native review"
|
||||
"Preview" = "Vorschau"
|
||||
"Preview this flow" = "Vorschau dieses Ablaufs"
|
||||
"Preview this frame" = "Vorschau dieses Rahmens"
|
||||
"Previous frame" = "Vorheriger Rahmen"
|
||||
"Previous ready for dev" = "Vorheriges bereit für Entwicklung"
|
||||
"Progressive" = "Progressiv"
|
||||
"Properties" = "Eigenschaften"
|
||||
@@ -1125,12 +1131,14 @@ status = "draft — needs native review"
|
||||
"Select all with same" = "Alle auswählen mit gleicher"
|
||||
"Select layer" = "Ebene auswählen"
|
||||
"Select layers to apply the style to" = "Wählen Sie die Ebenen aus, auf die der Stil angewendet werden soll"
|
||||
"Select layers with this color" = "Ebenen mit dieser Farbe auswählen"
|
||||
"Select overlapping shapes" = "Wählen Sie überlappende Formen aus"
|
||||
"Select points to edit their position, radius and mirroring" = "Wählen Sie Punkte aus, um Position, Radius und Spiegelung zu bearbeiten"
|
||||
"Select slot" = "Slot auswählen"
|
||||
"Selection" = "Auswahl"
|
||||
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Auswahlrahmen, {w} × {h} bei {x}, {y}: Pfeiltasten verschieben ihn, Umschalt+Pfeiltasten oder {cmd}+Pfeiltasten ändern die Größe, Eingabe wählt aus, Esc bricht ab"
|
||||
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Auswahlrahmen: Pfeiltasten verschieben ihn, Umschalt+Pfeiltasten oder {cmd}+Pfeiltasten ändern die Größe, Eingabe wählt aus, Esc bricht ab"
|
||||
"Selection colors" = "Auswahlfarben"
|
||||
"Send backward" = "Eine Ebene nach hinten"
|
||||
"Send report" = "Meldung senden"
|
||||
"Send to back" = "In den Hintergrund"
|
||||
@@ -1632,6 +1640,7 @@ status = "draft — needs native review"
|
||||
"arrange.packH" = "Horizontal zusammenschieben"
|
||||
"arrange.packV" = "Vertikal zusammenschieben"
|
||||
"arrange.roundToPixels" = "Auf Pixel runden"
|
||||
"arrange.spacing" = "Abstand"
|
||||
"arrange.tidy" = "Aufräumen"
|
||||
"bool.exclude" = "Auswahl ausschließen"
|
||||
"bool.flatten" = "Boolesche Gruppe zusammenfügen"
|
||||
@@ -1935,6 +1944,10 @@ status = "draft — needs native review"
|
||||
"select.sameStroke" = "Gleiche Kontur auswählen"
|
||||
"select.sameText" = "Gleiche Texteigenschaften auswählen"
|
||||
"select.set" = "Auswählen"
|
||||
"selection.applyColorStyle" = "Farbstil auf Auswahl anwenden"
|
||||
"selection.colors" = "Auswahlfarben"
|
||||
"selection.selectColor" = "Ebenen mit dieser Farbe auswählen"
|
||||
"selection.setPaint" = "Farbe der Auswahl ändern"
|
||||
"shape.arrow" = "Pfeil"
|
||||
"shape.ellipse" = "Ellipse"
|
||||
"shape.line" = "Linie"
|
||||
|
||||
@@ -343,6 +343,7 @@ name = "English"
|
||||
"Current version" = "Current version"
|
||||
"Cursor chat" = "Cursor chat"
|
||||
"Custom" = "Custom"
|
||||
"Custom size" = "Custom size"
|
||||
"Cut" = "Cut"
|
||||
"Dark" = "Dark"
|
||||
"Darken" = "Darken"
|
||||
@@ -653,6 +654,7 @@ name = "English"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps."
|
||||
"Intersect" = "Intersect"
|
||||
"Intersects" = "Intersects"
|
||||
"Invalid color" = "Invalid color"
|
||||
"Invite link" = "Invite link"
|
||||
"Invite link copied" = "Invite link copied"
|
||||
"It asks permission to:" = "It asks permission to:"
|
||||
@@ -812,6 +814,7 @@ name = "English"
|
||||
"New design file" = "New design file"
|
||||
"Newest first" = "Newest first"
|
||||
"Next or previous control in the focused region" = "Next or previous control in the focused region"
|
||||
"Next frame" = "Next frame"
|
||||
"Next ready for dev" = "Next ready for dev"
|
||||
"Next size option" = "Next size option"
|
||||
"No assets: no export settings, icons or images. Add export settings in the Design tab." = "No assets: no export settings, icons or images. Add export settings in the Design tab."
|
||||
@@ -820,6 +823,7 @@ name = "English"
|
||||
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "No branches. A branch is a copy of the file to try changes in, then review and merge."
|
||||
"No category" = "No category"
|
||||
"No changes" = "No changes"
|
||||
"No color styles" = "No color styles"
|
||||
"No colour variables fit here." = "No colour variables fit here."
|
||||
"No colour variables yet. Create them in Local variables." = "No colour variables yet. Create them in Local variables."
|
||||
"No comments yet" = "No comments yet"
|
||||
@@ -827,6 +831,7 @@ name = "English"
|
||||
"No components yet. Select layers and press {keys}." = "No components yet. Select layers and press {keys}."
|
||||
"No conflicts." = "No conflicts."
|
||||
"No contributor data was built into this copy." = "No contributor data was built into this copy."
|
||||
"No device" = "No device"
|
||||
"No fonts match" = "No fonts match"
|
||||
"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 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 layer changes to show."
|
||||
@@ -1016,6 +1021,7 @@ name = "English"
|
||||
"Preview" = "Preview"
|
||||
"Preview this flow" = "Preview this flow"
|
||||
"Preview this frame" = "Preview this frame"
|
||||
"Previous frame" = "Previous frame"
|
||||
"Previous ready for dev" = "Previous ready for dev"
|
||||
"Progressive" = "Progressive"
|
||||
"Properties" = "Properties"
|
||||
@@ -1185,9 +1191,11 @@ name = "English"
|
||||
"Select all with same" = "Select all with same"
|
||||
"Select layer" = "Select layer"
|
||||
"Select layers to apply the style to" = "Select layers to apply the style to"
|
||||
"Select layers with this color" = "Select layers with this color"
|
||||
"Select overlapping shapes" = "Select overlapping shapes"
|
||||
"Select points to edit their position, radius and mirroring" = "Select points to edit their position, radius and mirroring"
|
||||
"Select slot" = "Select slot"
|
||||
"Selection colors" = "Selection colors"
|
||||
"Selection" = "Selection"
|
||||
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels"
|
||||
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels"
|
||||
@@ -1693,4 +1701,4 @@ name = "English"
|
||||
"⌘ arrows" = "⌘ arrows"
|
||||
"⌘ click" = "⌘ click"
|
||||
"⌘ scroll" = "⌘ scroll"
|
||||
"⌥ drag" = "⌥ drag"
|
||||
"⌥ drag" = "⌥ drag"
|
||||
@@ -344,6 +344,7 @@ status = "draft — needs native review"
|
||||
"Current version" = "Versión actual"
|
||||
"Cursor chat" = "Chat del cursor"
|
||||
"Custom" = "Personalizado"
|
||||
"Custom size" = "Tamaño personalizado"
|
||||
"Cut" = "Cortar"
|
||||
"Dark" = "Oscuro"
|
||||
"Darken" = "Oscurecer"
|
||||
@@ -654,6 +655,7 @@ status = "draft — needs native review"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Diseño de interfaces y prototipos. Una herramienta de diseño gratuita y de código abierto, de implementación independiente (clean-room), escrita desde cero en Rust, parte de las Crafting Apps de ArtCraft."
|
||||
"Intersect" = "Intersecar"
|
||||
"Intersects" = "Intersecciones"
|
||||
"Invalid color" = "Color no válido"
|
||||
"Invite link" = "Enlace de invitación"
|
||||
"Invite link copied" = "Enlace de invitación copiado"
|
||||
"It asks permission to:" = "Pide permiso para:"
|
||||
@@ -813,6 +815,7 @@ status = "draft — needs native review"
|
||||
"New design file" = "Nuevo archivo de diseño"
|
||||
"Newest first" = "Más recientes primero"
|
||||
"Next or previous control in the focused region" = "Control siguiente o anterior de la región enfocada"
|
||||
"Next frame" = "Marco siguiente"
|
||||
"Next ready for dev" = "Siguiente listo para desarrollo"
|
||||
"Next size option" = "Siguiente opción de tamaño"
|
||||
"No assets: no export settings, icons or images. Add export settings in the Design tab." = "No hay recursos: ni ajustes de exportación, ni iconos, ni imágenes. Añade ajustes de exportación en la pestaña Diseño."
|
||||
@@ -821,6 +824,7 @@ status = "draft — needs native review"
|
||||
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "No hay ramas. Una rama es una copia del archivo para probar cambios y luego revisarlos y fusionarlos."
|
||||
"No category" = "Sin categoría"
|
||||
"No changes" = "Sin cambios"
|
||||
"No color styles" = "No hay estilos de color"
|
||||
"No colour variables fit here." = "Ninguna variable de color encaja aquí."
|
||||
"No colour variables yet. Create them in Local variables." = "Aún no hay variables de color. Créalas en Variables locales."
|
||||
"No comments yet" = "Aún no hay comentarios"
|
||||
@@ -828,6 +832,7 @@ status = "draft — needs native review"
|
||||
"No components yet. Select layers and press {keys}." = "Aún no hay componentes. Selecciona capas y pulsa {keys}."
|
||||
"No conflicts." = "Sin conflictos."
|
||||
"No contributor data was built into this copy." = "Esta copia no incluye datos de colaboradores."
|
||||
"No device" = "Sin dispositivo"
|
||||
"No fonts match" = "Ninguna fuente coincide"
|
||||
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Aquí no hay código para unirse (el servidor es anterior a los códigos, o esta dirección no cabe en uno): comparte el enlace."
|
||||
"No layer changes to show." = "No hay cambios de capas que mostrar."
|
||||
@@ -1017,6 +1022,7 @@ status = "draft — needs native review"
|
||||
"Preview" = "Vista previa"
|
||||
"Preview this flow" = "Vista previa de este flujo"
|
||||
"Preview this frame" = "Vista previa de este marco"
|
||||
"Previous frame" = "Marco anterior"
|
||||
"Previous ready for dev" = "Anterior listo para desarrollo"
|
||||
"Progressive" = "Progresivo"
|
||||
"Properties" = "Propiedades"
|
||||
@@ -1186,9 +1192,11 @@ status = "draft — needs native review"
|
||||
"Select all with same" = "Seleccionar todo con el mismo"
|
||||
"Select layer" = "Seleccionar capa"
|
||||
"Select layers to apply the style to" = "Selecciona capas para aplicarles el estilo"
|
||||
"Select layers with this color" = "Seleccionar capas con este color"
|
||||
"Select overlapping shapes" = "Selecciona formas superpuestas"
|
||||
"Select points to edit their position, radius and mirroring" = "Selecciona puntos para editar su posición, radio y reflejo"
|
||||
"Select slot" = "Seleccionar ranura"
|
||||
"Selection colors" = "Colores de la selección"
|
||||
"Selection" = "Selección"
|
||||
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Cuadro de selección, {w} por {h} en {x}, {y}: las flechas lo mueven, Mayús+flechas o {cmd}+flechas cambian su tamaño, Intro selecciona, Esc cancela"
|
||||
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Cuadro de selección: las flechas lo mueven, Mayús+flechas o {cmd}+flechas cambian su tamaño, Intro selecciona, Esc cancela"
|
||||
@@ -1720,6 +1728,7 @@ status = "draft — needs native review"
|
||||
"arrange.packH" = "Agrupar en horizontal"
|
||||
"arrange.packV" = "Agrupar en vertical"
|
||||
"arrange.roundToPixels" = "Redondear a píxeles"
|
||||
"arrange.spacing" = "Espaciado"
|
||||
"arrange.tidy" = "Ordenar"
|
||||
"bool.exclude" = "Excluir selección"
|
||||
"bool.flatten" = "Acoplar booleana"
|
||||
@@ -2030,6 +2039,10 @@ status = "draft — needs native review"
|
||||
"select.sameStroke" = "Seleccionar mismo trazo"
|
||||
"select.sameText" = "Seleccionar mismas propiedades de texto"
|
||||
"select.set" = "Seleccionar"
|
||||
"selection.applyColorStyle" = "Aplicar estilo de color a la selección"
|
||||
"selection.colors" = "Colores de la selección"
|
||||
"selection.selectColor" = "Seleccionar capas con este color"
|
||||
"selection.setPaint" = "Cambiar color de la selección"
|
||||
"shape.arrow" = "Flecha"
|
||||
"shape.ellipse" = "Elipse"
|
||||
"shape.line" = "Línea"
|
||||
|
||||
@@ -328,6 +328,7 @@ status = "draft — needs native review"
|
||||
"Current version" = "Version actuelle"
|
||||
"Cursor chat" = "Chat au curseur"
|
||||
"Custom" = "Personnalisé"
|
||||
"Custom size" = "Taille personnalisée"
|
||||
"Cut" = "Couper"
|
||||
"Dark" = "Sombre"
|
||||
"Darken" = "Assombrir"
|
||||
@@ -619,6 +620,7 @@ status = "draft — needs native review"
|
||||
"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."
|
||||
"Intersect" = "Intersection"
|
||||
"Intersects" = "Intersections"
|
||||
"Invalid color" = "Couleur non valide"
|
||||
"Invite link" = "Lien d’invitation"
|
||||
"Invite link copied" = "Lien d’invitation copié"
|
||||
"It asks permission to:" = "Il demande l’autorisation de :"
|
||||
@@ -764,6 +766,7 @@ status = "draft — needs native review"
|
||||
"Navigate to" = "Naviguer vers"
|
||||
"Nested instance" = "Instance imbriquée"
|
||||
"New design file" = "Nouveau fichier de design"
|
||||
"Next frame" = "Cadre suivant"
|
||||
"Next or previous control in the focused region" = "Contrôle suivant ou précédent dans la région active"
|
||||
"Next ready for dev" = "Prêt pour le dev suivant"
|
||||
"Next size option" = "Option de taille suivante"
|
||||
@@ -773,6 +776,7 @@ status = "draft — needs native review"
|
||||
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Aucune branche. Une branche est une copie du fichier pour essayer des modifications, puis les relire et les fusionner."
|
||||
"No category" = "Sans catégorie"
|
||||
"No changes" = "Aucune modification"
|
||||
"No color styles" = "Aucun style de couleur"
|
||||
"No colour variables fit here." = "Aucune variable de couleur ne convient ici."
|
||||
"No colour variables yet. Create them in Local variables." = "Aucune variable de couleur pour l’instant. Créez-en dans Variables locales."
|
||||
"No comments yet" = "Aucun commentaire pour l’instant"
|
||||
@@ -780,6 +784,7 @@ status = "draft — needs native review"
|
||||
"No components yet. Select layers and press {keys}." = "Aucun composant pour l’instant. Sélectionnez des calques et appuyez sur {keys}."
|
||||
"No conflicts." = "Aucun conflit."
|
||||
"No contributor data was built into this copy." = "Cette copie ne contient aucune donnée de contributeurs."
|
||||
"No device" = "Aucun appareil"
|
||||
"No fonts match" = "Aucune police ne correspond"
|
||||
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Pas de code d’accès ici (le serveur est antérieur aux codes d’accès, ou cette adresse ne tient pas dans un code) : partagez plutôt le lien."
|
||||
"No layer changes to show." = "Aucune modification de calque à afficher."
|
||||
@@ -954,6 +959,7 @@ status = "draft — needs native review"
|
||||
"Preview" = "Aperçu"
|
||||
"Preview this flow" = "Prévisualiser ce flux"
|
||||
"Preview this frame" = "Prévisualiser ce cadre"
|
||||
"Previous frame" = "Cadre précédent"
|
||||
"Previous ready for dev" = "Prêt pour le dev précédent"
|
||||
"Progressive" = "Progressif"
|
||||
"Properties" = "Propriétés"
|
||||
@@ -1119,12 +1125,14 @@ status = "draft — needs native review"
|
||||
"Select all with same" = "Sélectionner tout ce qui a le même"
|
||||
"Select layer" = "Sélectionner le calque"
|
||||
"Select layers to apply the style to" = "Sélectionnez les calques auxquels appliquer le style"
|
||||
"Select layers with this color" = "Sélectionner les calques de cette couleur"
|
||||
"Select overlapping shapes" = "Sélectionnez des formes qui se chevauchent"
|
||||
"Select points to edit their position, radius and mirroring" = "Sélectionnez des points pour modifier leur position, leur rayon et leur symétrie"
|
||||
"Select slot" = "Sélectionner l’emplacement"
|
||||
"Selection" = "Sélection"
|
||||
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Zone de sélection, {w} sur {h} à {x}, {y} : les flèches la déplacent, Maj+flèches ou {cmd}+flèches la redimensionnent, Entrée sélectionne, Échap annule"
|
||||
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Zone de sélection : les flèches la déplacent, Maj+flèches ou {cmd}+flèches la redimensionnent, Entrée sélectionne, Échap annule"
|
||||
"Selection colors" = "Couleurs de la sélection"
|
||||
"Send backward" = "Reculer"
|
||||
"Send report" = "Envoyer le signalement"
|
||||
"Send to back" = "Mettre à l’arrière-plan"
|
||||
@@ -1623,6 +1631,7 @@ status = "draft — needs native review"
|
||||
"arrange.packH" = "Tasser horizontalement"
|
||||
"arrange.packV" = "Tasser verticalement"
|
||||
"arrange.roundToPixels" = "Arrondir aux pixels"
|
||||
"arrange.spacing" = "Espacement"
|
||||
"arrange.tidy" = "Ranger"
|
||||
"bool.exclude" = "Exclure la sélection"
|
||||
"bool.flatten" = "Aplatir le booléen"
|
||||
@@ -1926,6 +1935,10 @@ status = "draft — needs native review"
|
||||
"select.sameStroke" = "Sélectionner le même contour"
|
||||
"select.sameText" = "Sélectionner les mêmes propriétés de texte"
|
||||
"select.set" = "Sélectionner"
|
||||
"selection.applyColorStyle" = "Appliquer un style de couleur à la sélection"
|
||||
"selection.colors" = "Couleurs de la sélection"
|
||||
"selection.selectColor" = "Sélectionner les calques de cette couleur"
|
||||
"selection.setPaint" = "Changer la couleur de la sélection"
|
||||
"shape.arrow" = "Flèche"
|
||||
"shape.ellipse" = "Ellipse"
|
||||
"shape.line" = "Ligne"
|
||||
|
||||
@@ -337,6 +337,7 @@ status = "draft — needs native review"
|
||||
"Current version" = "Versi saat ini"
|
||||
"Cursor chat" = "Chat kursor"
|
||||
"Custom" = "Kustom"
|
||||
"Custom size" = "Ukuran kustom"
|
||||
"Cut" = "Potong"
|
||||
"Dark" = "Gelap"
|
||||
"Darken" = "Gelapkan"
|
||||
@@ -642,6 +643,7 @@ status = "draft — needs native review"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Desain antarmuka dan prototyping. Alat desain clean-room gratis dan open-source yang ditulis dari nol dengan Rust, bagian dari ArtCraft Crafting Apps."
|
||||
"Intersect" = "Irisan"
|
||||
"Intersects" = "Beririsan"
|
||||
"Invalid color" = "Warna tidak valid"
|
||||
"Invite link" = "Link undangan"
|
||||
"Invite link copied" = "Link undangan disalin"
|
||||
"It asks permission to:" = "Plugin ini meminta izin untuk:"
|
||||
@@ -799,6 +801,7 @@ status = "draft — needs native review"
|
||||
"Nested instance" = "Instance bersarang"
|
||||
"New design file" = "File desain baru"
|
||||
"Newest first" = "Terbaru dulu"
|
||||
"Next frame" = "Frame berikutnya"
|
||||
"Next or previous control in the focused region" = "Kontrol berikutnya atau sebelumnya di wilayah yang difokuskan"
|
||||
"Next ready for dev" = "Siap dev berikutnya"
|
||||
"Next size option" = "Opsi ukuran berikutnya"
|
||||
@@ -808,6 +811,7 @@ status = "draft — needs native review"
|
||||
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Tidak ada branch. Branch adalah salinan file untuk mencoba perubahan, lalu ditinjau dan di-merge."
|
||||
"No category" = "Tanpa kategori"
|
||||
"No changes" = "Tidak ada perubahan"
|
||||
"No color styles" = "Tidak ada gaya warna"
|
||||
"No colour variables fit here." = "Tidak ada variabel warna yang cocok di sini."
|
||||
"No colour variables yet. Create them in Local variables." = "Belum ada variabel warna. Buat di Variabel lokal."
|
||||
"No comments yet" = "Belum ada komentar"
|
||||
@@ -815,6 +819,7 @@ status = "draft — needs native review"
|
||||
"No components yet. Select layers and press {keys}." = "Belum ada component. Pilih layer lalu tekan {keys}."
|
||||
"No conflicts." = "Tidak ada konflik."
|
||||
"No contributor data was built into this copy." = "Tidak ada data kontributor yang disertakan dalam salinan ini."
|
||||
"No device" = "Tanpa perangkat"
|
||||
"No fonts match" = "Tidak ada font yang cocok"
|
||||
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Tidak ada kode gabung di sini (versi server lebih lama dari fitur kode gabung, atau alamat ini tidak dapat dimasukkan ke kode): bagikan link-nya saja."
|
||||
"No layer changes to show." = "Tidak ada perubahan layer untuk ditampilkan."
|
||||
@@ -998,6 +1003,7 @@ status = "draft — needs native review"
|
||||
"Preview" = "Pratinjau"
|
||||
"Preview this flow" = "Pratinjau alur ini"
|
||||
"Preview this frame" = "Pratinjau frame ini"
|
||||
"Previous frame" = "Frame sebelumnya"
|
||||
"Previous ready for dev" = "Versi siap dev sebelumnya"
|
||||
"Progressive" = "Progresif"
|
||||
"Properties" = "Properti"
|
||||
@@ -1165,12 +1171,14 @@ status = "draft — needs native review"
|
||||
"Select all with same" = "Pilih semua yang sama"
|
||||
"Select layer" = "Pilih layer"
|
||||
"Select layers to apply the style to" = "Pilih layer untuk menerapkan gaya"
|
||||
"Select layers with this color" = "Pilih layer dengan warna ini"
|
||||
"Select overlapping shapes" = "Pilih bentuk yang tumpang tindih"
|
||||
"Select points to edit their position, radius and mirroring" = "Pilih titik untuk mengedit posisi, radius, dan pencerminannya"
|
||||
"Select slot" = "Pilih slot"
|
||||
"Selection" = "Pilihan"
|
||||
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Kotak pilihan, {w} x {h} di {x}, {y}: panah untuk memindahkan, Shift+panah atau {cmd}+panah untuk mengubah ukuran, Enter untuk memilih, Escape untuk membatalkan"
|
||||
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Kotak pilihan: panah untuk memindahkan, Shift+panah atau {cmd}+panah untuk mengubah ukuran, Enter untuk memilih, Escape untuk membatalkan"
|
||||
"Selection colors" = "Warna pilihan"
|
||||
"Send backward" = "Bawa mundur"
|
||||
"Send report" = "Kirim laporan"
|
||||
"Send to back" = "Bawa ke paling belakang"
|
||||
@@ -1689,6 +1697,7 @@ status = "draft — needs native review"
|
||||
"arrange.packH" = "Rapatkan horizontal"
|
||||
"arrange.packV" = "Rapatkan vertikal"
|
||||
"arrange.roundToPixels" = "Bulatkan ke piksel"
|
||||
"arrange.spacing" = "Spasi"
|
||||
"arrange.tidy" = "Rapikan"
|
||||
"bool.exclude" = "Kecualikan pilihan"
|
||||
"bool.flatten" = "Satukan boolean"
|
||||
@@ -1996,6 +2005,10 @@ status = "draft — needs native review"
|
||||
"select.sameStroke" = "Pilih stroke yang sama"
|
||||
"select.sameText" = "Pilih properti teks yang sama"
|
||||
"select.set" = "Pilih"
|
||||
"selection.applyColorStyle" = "Terapkan gaya warna ke pilihan"
|
||||
"selection.colors" = "Warna pilihan"
|
||||
"selection.selectColor" = "Pilih layer dengan warna ini"
|
||||
"selection.setPaint" = "Ubah warna pilihan"
|
||||
"shape.arrow" = "Panah"
|
||||
"shape.ellipse" = "Elips"
|
||||
"shape.line" = "Garis"
|
||||
|
||||
@@ -340,6 +340,7 @@ status = "draft — needs native review"
|
||||
"Current version" = "現在のバージョン"
|
||||
"Cursor chat" = "カーソルチャット"
|
||||
"Custom" = "カスタム"
|
||||
"Custom size" = "カスタムサイズ"
|
||||
"Cut" = "カット"
|
||||
"Dark" = "ダーク"
|
||||
"Darken" = "比較(暗)"
|
||||
@@ -647,6 +648,7 @@ status = "draft — needs native review"
|
||||
"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" = "交差"
|
||||
"Intersects" = "交差"
|
||||
"Invalid color" = "無効なカラー"
|
||||
"Invite link" = "招待リンク"
|
||||
"Invite link copied" = "招待リンクをコピー済み"
|
||||
"It asks permission to:" = "次の権限を求めている:"
|
||||
@@ -804,6 +806,7 @@ status = "draft — needs native review"
|
||||
"Nested instance" = "ネストされたインスタンス"
|
||||
"New design file" = "新規デザインファイル"
|
||||
"Newest first" = "新しい順"
|
||||
"Next frame" = "次のフレーム"
|
||||
"Next or previous control in the focused region" = "フォーカス中の領域で次または前のコントロール"
|
||||
"Next ready for dev" = "次の開発準備完了"
|
||||
"Next size option" = "次のサイズオプション"
|
||||
@@ -813,6 +816,7 @@ status = "draft — needs native review"
|
||||
"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" = "コメントはまだない"
|
||||
@@ -820,6 +824,7 @@ status = "draft — needs native review"
|
||||
"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 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." = "表示するレイヤーの変更なし。"
|
||||
@@ -1003,6 +1008,7 @@ status = "draft — needs native review"
|
||||
"Preview" = "プレビュー"
|
||||
"Preview this flow" = "このフローをプレビュー"
|
||||
"Preview this frame" = "このフレームをプレビュー"
|
||||
"Previous frame" = "前のフレーム"
|
||||
"Previous ready for dev" = "前の開発準備完了"
|
||||
"Progressive" = "プログレッシブ"
|
||||
"Properties" = "プロパティ"
|
||||
@@ -1172,12 +1178,14 @@ status = "draft — needs native review"
|
||||
"Select layer" = "レイヤーを選択"
|
||||
"Select layers and they space themselves as their content changes" = "レイヤーを選択すると、内容の変化に合わせて間隔を自動調整"
|
||||
"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" = "選択範囲"
|
||||
"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 でキャンセル"
|
||||
"Selection colors" = "選択範囲のカラー"
|
||||
"Send backward" = "背面へ"
|
||||
"Send report" = "報告を送信"
|
||||
"Send to back" = "最背面へ"
|
||||
@@ -1699,6 +1707,7 @@ status = "draft — needs native review"
|
||||
"arrange.packH" = "水平方向に詰める"
|
||||
"arrange.packV" = "垂直方向に詰める"
|
||||
"arrange.roundToPixels" = "ピクセルに丸める"
|
||||
"arrange.spacing" = "間隔"
|
||||
"arrange.tidy" = "整列"
|
||||
"bool.exclude" = "選択範囲を除外"
|
||||
"bool.flatten" = "ブーリアンを平坦化"
|
||||
@@ -2008,6 +2017,10 @@ status = "draft — needs native review"
|
||||
"select.sameStroke" = "同じ線を選択"
|
||||
"select.sameText" = "同じテキストプロパティを選択"
|
||||
"select.set" = "選択"
|
||||
"selection.applyColorStyle" = "選択範囲にカラースタイルを適用"
|
||||
"selection.colors" = "選択範囲のカラー"
|
||||
"selection.selectColor" = "このカラーのレイヤーを選択"
|
||||
"selection.setPaint" = "選択範囲のカラーを変更"
|
||||
"shape.arrow" = "矢印"
|
||||
"shape.ellipse" = "楕円"
|
||||
"shape.line" = "線"
|
||||
|
||||
@@ -328,6 +328,7 @@ status = "draft — needs native review"
|
||||
"Current version" = "현재 버전"
|
||||
"Cursor chat" = "커서 채팅"
|
||||
"Custom" = "사용자 지정"
|
||||
"Custom size" = "사용자 지정 크기"
|
||||
"Cut" = "잘라내기"
|
||||
"Dark" = "다크"
|
||||
"Darken" = "어둡게 하기"
|
||||
@@ -619,6 +620,7 @@ status = "draft — needs native review"
|
||||
"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" = "교차"
|
||||
"Intersects" = "교차"
|
||||
"Invalid color" = "잘못된 색상"
|
||||
"Invite link" = "초대 링크"
|
||||
"Invite link copied" = "초대 링크 복사됨"
|
||||
"It asks permission to:" = "다음 권한을 요청함:"
|
||||
@@ -764,6 +766,7 @@ status = "draft — needs native review"
|
||||
"Navigate to" = "이동 대상"
|
||||
"Nested instance" = "중첩된 인스턴스"
|
||||
"New design file" = "새 디자인 파일"
|
||||
"Next frame" = "다음 프레임"
|
||||
"Next or previous control in the focused region" = "포커스된 영역의 다음 또는 이전 컨트롤"
|
||||
"Next ready for dev" = "다음 개발 준비 완료"
|
||||
"Next size option" = "다음 크기 옵션"
|
||||
@@ -773,6 +776,7 @@ status = "draft — needs native review"
|
||||
"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" = "아직 댓글 없음"
|
||||
@@ -780,6 +784,7 @@ status = "draft — needs native review"
|
||||
"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 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." = "표시할 레이어 변경 사항 없음."
|
||||
@@ -954,6 +959,7 @@ status = "draft — needs native review"
|
||||
"Preview" = "미리 보기"
|
||||
"Preview this flow" = "이 플로우 미리 보기"
|
||||
"Preview this frame" = "이 프레임 미리 보기"
|
||||
"Previous frame" = "이전 프레임"
|
||||
"Previous ready for dev" = "이전 개발 준비 완료"
|
||||
"Progressive" = "점진적"
|
||||
"Properties" = "속성"
|
||||
@@ -1119,12 +1125,14 @@ status = "draft — needs native review"
|
||||
"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" = "선택 항목"
|
||||
"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로 취소"
|
||||
"Selection colors" = "선택 항목 색상"
|
||||
"Send backward" = "뒤로 보내기"
|
||||
"Send report" = "신고 보내기"
|
||||
"Send to back" = "맨 뒤로 보내기"
|
||||
@@ -1623,6 +1631,7 @@ status = "draft — needs native review"
|
||||
"arrange.packH" = "가로로 붙이기"
|
||||
"arrange.packV" = "세로로 붙이기"
|
||||
"arrange.roundToPixels" = "픽셀에 맞춰 반올림"
|
||||
"arrange.spacing" = "간격"
|
||||
"arrange.tidy" = "정돈"
|
||||
"bool.exclude" = "선택 항목 제외"
|
||||
"bool.flatten" = "불리언 평면화"
|
||||
@@ -1926,6 +1935,10 @@ status = "draft — needs native review"
|
||||
"select.sameStroke" = "같은 선 선택"
|
||||
"select.sameText" = "같은 텍스트 속성 선택"
|
||||
"select.set" = "선택"
|
||||
"selection.applyColorStyle" = "선택 항목에 색상 스타일 적용"
|
||||
"selection.colors" = "선택 항목 색상"
|
||||
"selection.selectColor" = "이 색상의 레이어 선택"
|
||||
"selection.setPaint" = "선택 항목 색상 변경"
|
||||
"shape.arrow" = "화살표"
|
||||
"shape.ellipse" = "타원"
|
||||
"shape.line" = "선"
|
||||
|
||||
@@ -327,6 +327,7 @@ status = "draft — needs native review"
|
||||
"Current version" = "Versão atual"
|
||||
"Cursor chat" = "Chat do cursor"
|
||||
"Custom" = "Personalizado"
|
||||
"Custom size" = "Tamanho personalizado"
|
||||
"Cut" = "Recortar"
|
||||
"Dark" = "Escuro"
|
||||
"Darken" = "Escurecer"
|
||||
@@ -618,6 +619,7 @@ status = "draft — needs native review"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Design de interfaces e prototipagem. Uma ferramenta de design gratuita e de código aberto, com implementação independente (clean-room), escrita do zero em Rust, parte dos Crafting Apps da ArtCraft."
|
||||
"Intersect" = "Interseção"
|
||||
"Intersects" = "Interseções"
|
||||
"Invalid color" = "Cor inválida"
|
||||
"Invite link" = "Link de convite"
|
||||
"Invite link copied" = "Link de convite copiado"
|
||||
"It asks permission to:" = "Ele pede permissão para:"
|
||||
@@ -763,6 +765,7 @@ status = "draft — needs native review"
|
||||
"Navigate to" = "Navegar para"
|
||||
"Nested instance" = "Instância aninhada"
|
||||
"New design file" = "Novo arquivo de design"
|
||||
"Next frame" = "Próximo quadro"
|
||||
"Next or previous control in the focused region" = "Controle seguinte ou anterior na região em foco"
|
||||
"Next ready for dev" = "Próximo pronto para dev"
|
||||
"Next size option" = "Próxima opção de tamanho"
|
||||
@@ -772,6 +775,7 @@ status = "draft — needs native review"
|
||||
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Nenhuma branch. Uma branch é uma cópia do arquivo para testar alterações e depois revisá-las e mesclá-las."
|
||||
"No category" = "Sem categoria"
|
||||
"No changes" = "Nenhuma alteração"
|
||||
"No color styles" = "Nenhum estilo de cor"
|
||||
"No colour variables fit here." = "Nenhuma variável de cor se encaixa aqui."
|
||||
"No colour variables yet. Create them in Local variables." = "Ainda não há variáveis de cor. Crie-as em Variáveis locais."
|
||||
"No comments yet" = "Ainda não há comentários"
|
||||
@@ -779,6 +783,7 @@ status = "draft — needs native review"
|
||||
"No components yet. Select layers and press {keys}." = "Ainda não há componentes. Selecione camadas e pressione {keys}."
|
||||
"No conflicts." = "Nenhum conflito."
|
||||
"No contributor data was built into this copy." = "Esta cópia não inclui dados de colaboradores."
|
||||
"No device" = "Nenhum dispositivo"
|
||||
"No fonts match" = "Nenhuma fonte corresponde"
|
||||
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Não há código de acesso aqui (o servidor é anterior aos códigos de acesso, ou este endereço não cabe em um): compartilhe o link."
|
||||
"No layer changes to show." = "Nenhuma alteração de camada para mostrar."
|
||||
@@ -953,6 +958,7 @@ status = "draft — needs native review"
|
||||
"Preview" = "Visualização"
|
||||
"Preview this flow" = "Visualizar este fluxo"
|
||||
"Preview this frame" = "Visualizar este quadro"
|
||||
"Previous frame" = "Quadro anterior"
|
||||
"Previous ready for dev" = "Anterior pronto para dev"
|
||||
"Progressive" = "Progressivo"
|
||||
"Properties" = "Propriedades"
|
||||
@@ -1118,12 +1124,14 @@ status = "draft — needs native review"
|
||||
"Select all with same" = "Selecionar tudo com o mesmo"
|
||||
"Select layer" = "Selecionar camada"
|
||||
"Select layers to apply the style to" = "Selecione as camadas às quais aplicar o estilo"
|
||||
"Select layers with this color" = "Selecionar camadas com esta cor"
|
||||
"Select overlapping shapes" = "Selecione formas sobrepostas"
|
||||
"Select points to edit their position, radius and mirroring" = "Selecione pontos para editar posição, raio e espelhamento"
|
||||
"Select slot" = "Selecionar slot"
|
||||
"Selection" = "Seleção"
|
||||
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Caixa de seleção, {w} por {h} em {x}, {y}: as setas a movem, Shift+setas ou {cmd}+setas a redimensionam, Enter seleciona, Esc cancela"
|
||||
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Caixa de seleção: as setas a movem, Shift+setas ou {cmd}+setas a redimensionam, Enter seleciona, Esc cancela"
|
||||
"Selection colors" = "Cores da seleção"
|
||||
"Send backward" = "Enviar para trás"
|
||||
"Send report" = "Enviar denúncia"
|
||||
"Send to back" = "Enviar para o plano de fundo"
|
||||
@@ -1622,6 +1630,7 @@ status = "draft — needs native review"
|
||||
"arrange.packH" = "Compactar na horizontal"
|
||||
"arrange.packV" = "Compactar na vertical"
|
||||
"arrange.roundToPixels" = "Arredondar para pixels"
|
||||
"arrange.spacing" = "Espaçamento"
|
||||
"arrange.tidy" = "Organizar"
|
||||
"bool.exclude" = "Excluir sobreposição da seleção"
|
||||
"bool.flatten" = "Nivelar booleano"
|
||||
@@ -1925,6 +1934,10 @@ status = "draft — needs native review"
|
||||
"select.sameStroke" = "Selecionar mesmo traço"
|
||||
"select.sameText" = "Selecionar mesmas propriedades de texto"
|
||||
"select.set" = "Selecionar"
|
||||
"selection.applyColorStyle" = "Aplicar estilo de cor à seleção"
|
||||
"selection.colors" = "Cores da seleção"
|
||||
"selection.selectColor" = "Selecionar camadas com esta cor"
|
||||
"selection.setPaint" = "Alterar cor da seleção"
|
||||
"shape.arrow" = "Seta"
|
||||
"shape.ellipse" = "Elipse"
|
||||
"shape.line" = "Linha"
|
||||
|
||||
@@ -334,6 +334,7 @@ name = "Русский"
|
||||
"Current version" = "Текущая версия"
|
||||
"Cursor chat" = "Чат у курсора"
|
||||
"Custom" = "Пользовательский"
|
||||
"Custom size" = "Пользовательский размер"
|
||||
"Cut" = "Вырезать"
|
||||
"Dark" = "Тёмная"
|
||||
"Darken" = "Затемнение"
|
||||
@@ -639,6 +640,7 @@ name = "Русский"
|
||||
"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" = "Пересечение"
|
||||
"Intersects" = "Пересечения"
|
||||
"Invalid color" = "Недопустимый цвет"
|
||||
"Invite link" = "Ссылка приглашения"
|
||||
"Invite link copied" = "Ссылка приглашения скопирована"
|
||||
"It asks permission to:" = "Запрашивает разрешение на:"
|
||||
@@ -796,6 +798,7 @@ name = "Русский"
|
||||
"Nested instance" = "Вложенный экземпляр"
|
||||
"New design file" = "Новый файл дизайна"
|
||||
"Newest first" = "Сначала новые"
|
||||
"Next frame" = "Следующий Frame"
|
||||
"Next or previous control in the focused region" = "Следующий или предыдущий элемент в активной области"
|
||||
"Next ready for dev" = "Следующий готовый к разработке"
|
||||
"Next size option" = "Следующий вариант размера"
|
||||
@@ -805,6 +808,7 @@ name = "Русский"
|
||||
"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" = "Комментариев пока нет"
|
||||
@@ -812,6 +816,7 @@ name = "Русский"
|
||||
"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 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." = "Нет изменений слоёв для показа."
|
||||
@@ -996,6 +1001,7 @@ name = "Русский"
|
||||
"Preview" = "Предпросмотр"
|
||||
"Preview this flow" = "Предпросмотр этого сценария"
|
||||
"Preview this frame" = "Предпросмотр этого Frame"
|
||||
"Previous frame" = "Предыдущий Frame"
|
||||
"Previous ready for dev" = "Предыдущий готовый к разработке"
|
||||
"Progressive" = "Прогрессивный"
|
||||
"Properties" = "Свойства"
|
||||
@@ -1163,12 +1169,14 @@ name = "Русский"
|
||||
"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" = "Выделение"
|
||||
"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 — отменить"
|
||||
"Selection colors" = "Цвета выделения"
|
||||
"Send backward" = "На уровень назад"
|
||||
"Send report" = "Отправить жалобу"
|
||||
"Send to back" = "На задний план"
|
||||
@@ -1687,6 +1695,7 @@ name = "Русский"
|
||||
"arrange.packH" = "Уплотнить по горизонтали"
|
||||
"arrange.packV" = "Уплотнить по вертикали"
|
||||
"arrange.roundToPixels" = "Округлить до пикселей"
|
||||
"arrange.spacing" = "Интервал"
|
||||
"arrange.tidy" = "Упорядочить"
|
||||
"bool.exclude" = "Исключить выделение"
|
||||
"bool.flatten" = "Свести булеву группу"
|
||||
@@ -1994,6 +2003,10 @@ name = "Русский"
|
||||
"select.sameStroke" = "Выделить с одинаковой обводкой"
|
||||
"select.sameText" = "Выделить с одинаковыми свойствами текста"
|
||||
"select.set" = "Выделить"
|
||||
"selection.applyColorStyle" = "Применить цветовой стиль к выделению"
|
||||
"selection.colors" = "Цвета выделения"
|
||||
"selection.selectColor" = "Выделить слои с этим цветом"
|
||||
"selection.setPaint" = "Изменить цвет выделения"
|
||||
"shape.arrow" = "Стрелка"
|
||||
"shape.ellipse" = "Эллипс"
|
||||
"shape.line" = "Линия"
|
||||
|
||||
@@ -339,6 +339,7 @@ name = "Svenska"
|
||||
"Current version" = "Aktuell version"
|
||||
"Cursor chat" = "Markörchatt"
|
||||
"Custom" = "Anpassad"
|
||||
"Custom size" = "Anpassad storlek"
|
||||
"Cut" = "Klipp ut"
|
||||
"Dark" = "Mörk"
|
||||
"Darken" = "Mörkare"
|
||||
@@ -649,6 +650,7 @@ name = "Svenska"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Gränssnittsdesign och prototyper. Ett fritt clean-room-designverktyg med öppen källkod, skrivet från grunden i Rust och en del av ArtCraft Crafting Apps."
|
||||
"Intersect" = "Skärning"
|
||||
"Intersects" = "Skär"
|
||||
"Invalid color" = "Ogiltig färg"
|
||||
"Invite link" = "Inbjudningslänk"
|
||||
"Invite link copied" = "Inbjudningslänken har kopierats"
|
||||
"It asks permission to:" = "Den ber om behörighet att:"
|
||||
@@ -806,6 +808,7 @@ name = "Svenska"
|
||||
"Nested instance" = "Kapslad instans"
|
||||
"New design file" = "Ny designfil"
|
||||
"Newest first" = "Nyaste först"
|
||||
"Next frame" = "Nästa ram"
|
||||
"Next or previous control in the focused region" = "Nästa eller föregående kontroll i den fokuserade regionen"
|
||||
"Next ready for dev" = "Nästa redo för utveckling"
|
||||
"Next size option" = "Nästa storleksalternativ"
|
||||
@@ -815,6 +818,7 @@ name = "Svenska"
|
||||
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Inga grenar. En gren är en kopia av filen där du kan prova ändringar och sedan granska och sammanfoga dem."
|
||||
"No category" = "Ingen kategori"
|
||||
"No changes" = "Inga ändringar"
|
||||
"No color styles" = "Inga färgformatmallar"
|
||||
"No colour variables fit here." = "Inga färgvariabler passar här."
|
||||
"No colour variables yet. Create them in Local variables." = "Inga färgvariabler än. Skapa dem i Lokala variabler."
|
||||
"No comments yet" = "Inga kommentarer än"
|
||||
@@ -822,6 +826,7 @@ name = "Svenska"
|
||||
"No components yet. Select layers and press {keys}." = "Inga komponenter än. Markera lager och tryck på {keys}."
|
||||
"No conflicts." = "Inga konflikter."
|
||||
"No contributor data was built into this copy." = "Inga uppgifter om bidragsgivare finns inbyggda i den här kopian."
|
||||
"No device" = "Ingen enhet"
|
||||
"No fonts match" = "Inga typsnitt matchar"
|
||||
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Ingen anslutningskod här (servern är äldre än anslutningskoder, eller så kan den här adressen inte göras om till en): dela länken i stället."
|
||||
"No layer changes to show." = "Inga lagerändringar att visa."
|
||||
@@ -1007,6 +1012,7 @@ name = "Svenska"
|
||||
"Preview" = "Förhandsgranskning"
|
||||
"Preview this flow" = "Förhandsgranska det här flödet"
|
||||
"Preview this frame" = "Förhandsgranska den här ramen"
|
||||
"Previous frame" = "Föregående ram"
|
||||
"Previous ready for dev" = "Föregående redo för utveckling"
|
||||
"Progressive" = "Progressiv"
|
||||
"Properties" = "Egenskaper"
|
||||
@@ -1176,12 +1182,14 @@ name = "Svenska"
|
||||
"Select all with same" = "Markera alla med samma"
|
||||
"Select layer" = "Markera lager"
|
||||
"Select layers to apply the style to" = "Markera de lager som formatmallen ska användas på"
|
||||
"Select layers with this color" = "Markera lager med den här färgen"
|
||||
"Select overlapping shapes" = "Markera överlappande former"
|
||||
"Select points to edit their position, radius and mirroring" = "Markera punkter för att redigera deras position, radie och spegling"
|
||||
"Select slot" = "Markera plats"
|
||||
"Selection" = "Markering"
|
||||
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Markeringsruta, {w} gånger {h} vid {x}, {y}: piltangenterna flyttar den, Shift+pilar eller {cmd}+pilar ändrar storleken, Enter markerar, Escape avbryter"
|
||||
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Markeringsruta: piltangenterna flyttar den, Shift+pilar eller {cmd}+pilar ändrar storleken, Enter markerar, Escape avbryter"
|
||||
"Selection colors" = "Markeringsfärger"
|
||||
"Send backward" = "Flytta bakåt"
|
||||
"Send report" = "Skicka rapport"
|
||||
"Send to back" = "Flytta längst bak"
|
||||
@@ -1707,6 +1715,7 @@ name = "Svenska"
|
||||
"arrange.packH" = "Packa horisontellt"
|
||||
"arrange.packV" = "Packa vertikalt"
|
||||
"arrange.roundToPixels" = "Avrunda till pixlar"
|
||||
"arrange.spacing" = "Avstånd"
|
||||
"arrange.tidy" = "Snygga till"
|
||||
"bool.exclude" = "Uteslut markering"
|
||||
"bool.flatten" = "Platta till boolesk form"
|
||||
@@ -2016,6 +2025,10 @@ name = "Svenska"
|
||||
"select.sameStroke" = "Markera samma kontur"
|
||||
"select.sameText" = "Markera samma textegenskaper"
|
||||
"select.set" = "Markera"
|
||||
"selection.applyColorStyle" = "Använd färgformatmall på markeringen"
|
||||
"selection.colors" = "Markeringsfärger"
|
||||
"selection.selectColor" = "Markera lager med den här färgen"
|
||||
"selection.setPaint" = "Ändra markeringens färg"
|
||||
"shape.arrow" = "Pil"
|
||||
"shape.ellipse" = "Ellips"
|
||||
"shape.line" = "Linje"
|
||||
|
||||
@@ -337,6 +337,7 @@ name = "Türkçe"
|
||||
"Current version" = "Geçerli sürüm"
|
||||
"Cursor chat" = "İmleç sohbeti"
|
||||
"Custom" = "Özel"
|
||||
"Custom size" = "Özel boyut"
|
||||
"Cut" = "Kes"
|
||||
"Dark" = "Koyu"
|
||||
"Darken" = "Koyulaştır"
|
||||
@@ -642,6 +643,7 @@ name = "Türkçe"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Arayüz tasarımı ve prototipleme. Rust ile sıfırdan yazılmış, ücretsiz, açık kaynaklı, temiz oda yöntemiyle geliştirilmiş bir tasarım aracı; ArtCraft Crafting Apps ailesinin bir parçası."
|
||||
"Intersect" = "Kesiştir"
|
||||
"Intersects" = "Kesişir"
|
||||
"Invalid color" = "Geçersiz renk"
|
||||
"Invite link" = "Davet bağlantısı"
|
||||
"Invite link copied" = "Davet bağlantısı kopyalandı"
|
||||
"It asks permission to:" = "Şunlar için izin istiyor:"
|
||||
@@ -799,6 +801,7 @@ name = "Türkçe"
|
||||
"Nested instance" = "İç içe örnek"
|
||||
"New design file" = "Yeni tasarım dosyası"
|
||||
"Newest first" = "Önce en yeni"
|
||||
"Next frame" = "Sonraki çerçeve"
|
||||
"Next or previous control in the focused region" = "Odaklanılan bölgedeki sonraki veya önceki denetim"
|
||||
"Next ready for dev" = "Sonraki geliştirmeye hazır"
|
||||
"Next size option" = "Sonraki boyut seçeneği"
|
||||
@@ -808,6 +811,7 @@ name = "Türkçe"
|
||||
"No branches. A branch is a copy of the file to try changes in, then review and merge." = "Dal yok. Dal, değişiklikleri denemek, sonra gözden geçirip birleştirmek için dosyanın bir kopyasıdır."
|
||||
"No category" = "Kategori yok"
|
||||
"No changes" = "Değişiklik yok"
|
||||
"No color styles" = "Renk stili yok"
|
||||
"No colour variables fit here." = "Buraya uyan renk değişkeni yok."
|
||||
"No colour variables yet. Create them in Local variables." = "Henüz renk değişkeni yok. Onları Yerel değişkenler'de oluşturun."
|
||||
"No comments yet" = "Henüz yorum yok"
|
||||
@@ -815,6 +819,7 @@ name = "Türkçe"
|
||||
"No components yet. Select layers and press {keys}." = "Henüz bileşen yok. Katmanları seçip {keys} tuşlarına basın."
|
||||
"No conflicts." = "Çakışma yok."
|
||||
"No contributor data was built into this copy." = "Bu kopyaya katkıda bulunan verisi eklenmemiş."
|
||||
"No device" = "Cihaz yok"
|
||||
"No fonts match" = "Eşleşen yazı tipi yok"
|
||||
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Burada katılma kodu yok (sunucu katılma kodlarından eski veya bu adres bir koda sığmıyor): bunun yerine bağlantıyı paylaşın."
|
||||
"No layer changes to show." = "Gösterilecek katman değişikliği yok."
|
||||
@@ -998,6 +1003,7 @@ name = "Türkçe"
|
||||
"Preview" = "Önizleme"
|
||||
"Preview this flow" = "Bu akışı önizle"
|
||||
"Preview this frame" = "Bu çerçeveyi önizle"
|
||||
"Previous frame" = "Önceki çerçeve"
|
||||
"Previous ready for dev" = "Önceki geliştirmeye hazır"
|
||||
"Progressive" = "Aşamalı"
|
||||
"Properties" = "Özellikler"
|
||||
@@ -1165,12 +1171,14 @@ name = "Türkçe"
|
||||
"Select all with same" = "Aynı olanların tümünü seç"
|
||||
"Select layer" = "Katman seç"
|
||||
"Select layers to apply the style to" = "Stilin uygulanacağı katmanları seçin"
|
||||
"Select layers with this color" = "Bu renkteki katmanları seç"
|
||||
"Select overlapping shapes" = "Çakışan şekilleri seç"
|
||||
"Select points to edit their position, radius and mirroring" = "Konumlarını, yarıçaplarını ve yansıtmalarını düzenlemek için noktaları seçin"
|
||||
"Select slot" = "Yuvayı seç"
|
||||
"Selection" = "Seçim"
|
||||
"Selection box, {w} by {h} at {x}, {y}: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Seçim kutusu, {x}, {y} konumunda {w} × {h}: oklar onu taşır, Shift+oklar veya {cmd}+oklar boyutlandırır, Enter seçer, Escape iptal eder"
|
||||
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "Seçim kutusu: oklar onu taşır, Shift+oklar veya {cmd}+oklar boyutlandırır, Enter seçer, Escape iptal eder"
|
||||
"Selection colors" = "Seçim renkleri"
|
||||
"Send backward" = "Geriye gönder"
|
||||
"Send report" = "Raporu gönder"
|
||||
"Send to back" = "En arkaya gönder"
|
||||
@@ -1689,6 +1697,7 @@ name = "Türkçe"
|
||||
"arrange.packH" = "Yatay sıkıştır"
|
||||
"arrange.packV" = "Dikey sıkıştır"
|
||||
"arrange.roundToPixels" = "Piksellere yuvarla"
|
||||
"arrange.spacing" = "Aralık"
|
||||
"arrange.tidy" = "Toparla"
|
||||
"bool.exclude" = "Seçimi hariç tut"
|
||||
"bool.flatten" = "Boolean'ı düzleştir"
|
||||
@@ -1996,6 +2005,10 @@ name = "Türkçe"
|
||||
"select.sameStroke" = "Aynı konturu seç"
|
||||
"select.sameText" = "Aynı metin özelliklerini seç"
|
||||
"select.set" = "Seç"
|
||||
"selection.applyColorStyle" = "Seçime renk stili uygula"
|
||||
"selection.colors" = "Seçim renkleri"
|
||||
"selection.selectColor" = "Bu renkteki katmanları seç"
|
||||
"selection.setPaint" = "Seçimin rengini değiştir"
|
||||
"shape.arrow" = "Ok"
|
||||
"shape.ellipse" = "Elips"
|
||||
"shape.line" = "Çizgi"
|
||||
|
||||
@@ -329,6 +329,7 @@ status = "draft — needs native review"
|
||||
"Current version" = "当前版本"
|
||||
"Cursor chat" = "光标聊天"
|
||||
"Custom" = "自定义"
|
||||
"Custom size" = "自定义大小"
|
||||
"Cut" = "剪切"
|
||||
"Dark" = "深色"
|
||||
"Darken" = "变暗"
|
||||
@@ -620,6 +621,7 @@ status = "draft — needs native review"
|
||||
"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" = "相交"
|
||||
"Intersects" = "相交"
|
||||
"Invalid color" = "无效颜色"
|
||||
"Invite link" = "邀请链接"
|
||||
"Invite link copied" = "已复制邀请链接"
|
||||
"It asks permission to:" = "它请求以下权限:"
|
||||
@@ -765,6 +767,7 @@ status = "draft — needs native review"
|
||||
"Navigate to" = "导航到"
|
||||
"Nested instance" = "嵌套实例"
|
||||
"New design file" = "新建设计文件"
|
||||
"Next frame" = "下一个画框"
|
||||
"Next or previous control in the focused region" = "聚焦区域中的下一个或上一个控件"
|
||||
"Next ready for dev" = "下一个可供开发"
|
||||
"Next size option" = "下一个尺寸选项"
|
||||
@@ -774,6 +777,7 @@ status = "draft — needs native review"
|
||||
"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" = "还没有评论"
|
||||
@@ -781,6 +785,7 @@ status = "draft — needs native review"
|
||||
"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 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." = "没有可显示的图层更改。"
|
||||
@@ -955,6 +960,7 @@ status = "draft — needs native review"
|
||||
"Preview" = "预览"
|
||||
"Preview this flow" = "预览此流程"
|
||||
"Preview this frame" = "预览此画框"
|
||||
"Previous frame" = "上一个画框"
|
||||
"Previous ready for dev" = "上一个可供开发"
|
||||
"Progressive" = "渐进"
|
||||
"Properties" = "属性"
|
||||
@@ -1120,12 +1126,14 @@ status = "draft — needs native review"
|
||||
"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" = "所选项"
|
||||
"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 选择,Esc 取消"
|
||||
"Selection box: arrows move it, Shift+arrows or {cmd}+arrows size it, Enter selects, Escape cancels" = "选框:方向键移动,Shift+方向键或 {cmd}+方向键调整大小,Enter 选择,Esc 取消"
|
||||
"Selection colors" = "所选项颜色"
|
||||
"Send backward" = "下移一层"
|
||||
"Send report" = "发送举报"
|
||||
"Send to back" = "置于底层"
|
||||
@@ -1624,6 +1632,7 @@ status = "draft — needs native review"
|
||||
"arrange.packH" = "水平紧凑排列"
|
||||
"arrange.packV" = "垂直紧凑排列"
|
||||
"arrange.roundToPixels" = "取整到像素"
|
||||
"arrange.spacing" = "间距"
|
||||
"arrange.tidy" = "整理"
|
||||
"bool.exclude" = "排除所选项"
|
||||
"bool.flatten" = "拼合布尔组"
|
||||
@@ -1927,6 +1936,10 @@ status = "draft — needs native review"
|
||||
"select.sameStroke" = "选择相同描边"
|
||||
"select.sameText" = "选择相同文本属性"
|
||||
"select.set" = "选择"
|
||||
"selection.applyColorStyle" = "将颜色样式应用于所选项"
|
||||
"selection.colors" = "所选项颜色"
|
||||
"selection.selectColor" = "选择使用此颜色的图层"
|
||||
"selection.setPaint" = "更改所选项颜色"
|
||||
"shape.arrow" = "箭头"
|
||||
"shape.ellipse" = "椭圆"
|
||||
"shape.line" = "直线"
|
||||
|
||||
@@ -237,9 +237,9 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
|
||||
// Re-project the last render onto the current view.
|
||||
let tl = app.to_screen(v.origin.0, v.origin.1);
|
||||
let zoom = app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0);
|
||||
let w = (f64::from(v.width) / v.scale * zoom) as f32;
|
||||
let h = (f64::from(v.height) / v.scale * zoom) as f32;
|
||||
painter.image(tex.id(), Rect::from_min_size(tl, vec2(w, h)), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
let pixel_preview = app.session.ds().ok().and_then(|ds| ds.view.pixel_preview_scale()) == Some(v.scale);
|
||||
let image_rect = presented_image_rect(tl, v, zoom, ppp, pixel_preview);
|
||||
painter.image(tex.id(), image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
app.session.tool.labels = overlays(app, &painter, &t);
|
||||
// The zoom tool's area.
|
||||
@@ -252,6 +252,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
|
||||
}
|
||||
crate::dev_mode::draw(app, &painter);
|
||||
crate::handles::draw(app, &painter);
|
||||
crate::selection_spacing::draw(app, &painter);
|
||||
crate::comments::draw(app, &painter);
|
||||
crate::live_text::draw(app, &painter);
|
||||
crate::live::draw(app, &painter, &ctx);
|
||||
@@ -271,6 +272,22 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The image's screen rect; align native-density renders to device pixels to avoid linear filtering
|
||||
/// blending neighbouring texels. Stale and pixel-preview renders retain their original projection.
|
||||
pub(super) fn presented_image_rect(tl: Pos2, view: &figcraft_render::View, zoom: f64, ppp: f32, pixel_preview: bool) -> Rect {
|
||||
let w = (f64::from(view.width) / view.scale * zoom) as f32;
|
||||
let h = (f64::from(view.height) / view.scale * zoom) as f32;
|
||||
let native_density = !pixel_preview
|
||||
&& ppp.is_finite()
|
||||
&& ppp > 0.0
|
||||
&& zoom.is_finite()
|
||||
&& view.scale.is_finite()
|
||||
&& view.scale > 0.0
|
||||
&& (view.scale - zoom * f64::from(ppp)).abs() <= view.scale.abs().max(1.0) * 1e-9;
|
||||
let min = if native_density { pos2((tl.x * ppp).round() / ppp, (tl.y * ppp).round() / ppp) } else { tl };
|
||||
Rect::from_min_size(min, vec2(w, h))
|
||||
}
|
||||
|
||||
/// What the canvas says to screen readers: the selected layer ("Rectangle 1, Rectangle, 120 by 80,
|
||||
/// selected"), how many are selected, or the page with nothing selected.
|
||||
pub fn describe(s: &figcraft_engine::Session) -> String {
|
||||
@@ -480,6 +497,9 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool {
|
||||
let editing_text = app.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false);
|
||||
let typing_elsewhere = crate::a11y::field_focused(&ctx);
|
||||
app.canvas.space_pan = space && !editing_text && !typing_elsewhere;
|
||||
if !app.canvas.dragging && crate::selection_spacing::input(app, &ctx, hover_pos, resp.hovered(), app.canvas.space_pan) {
|
||||
return false;
|
||||
}
|
||||
// Dropped files import onto the canvas, behind a card while they do ([`crate::pending_open`]).
|
||||
let dropped = ctx.input(|i| i.raw.dropped_files.clone());
|
||||
for f in dropped {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
use super::canvas::presented_image_rect;
|
||||
use egui::{Pos2, Rect, pos2, vec2};
|
||||
use figcraft_render::View;
|
||||
|
||||
#[test]
|
||||
fn native_density_rect_aligns_origin_and_preserves_pixel_extent() {
|
||||
for ppp in [1.0_f32, 1.25, 1.5, 2.0] {
|
||||
for zoom in [0.37_f64, 0.67, 1.0, 1.3, 2.0] {
|
||||
let view = View { origin: (-31.25, 18.75), scale: zoom * f64::from(ppp), width: 375, height: 240 };
|
||||
let tl = pos2(149.5, 91.25);
|
||||
let rect = presented_image_rect(tl, &view, zoom, ppp, false);
|
||||
assert!(((rect.min.x * ppp) - (rect.min.x * ppp).round()).abs() < 1e-4, "left edge: ppp={ppp}, zoom={zoom}, rect={rect:?}");
|
||||
assert!(((rect.min.y * ppp) - (rect.min.y * ppp).round()).abs() < 1e-4, "top edge: ppp={ppp}, zoom={zoom}, rect={rect:?}");
|
||||
assert!((rect.min.x - tl.x).abs() * ppp <= 0.5001, "x shift exceeds half a device pixel: ppp={ppp}, rect={rect:?}");
|
||||
assert!((rect.min.y - tl.y).abs() * ppp <= 0.5001, "y shift exceeds half a device pixel: ppp={ppp}, rect={rect:?}");
|
||||
assert!((rect.width() * ppp - view.width as f32).abs() < 1e-3, "width changed: ppp={ppp}, rect={rect:?}");
|
||||
assert!((rect.height() * ppp - view.height as f32).abs() < 1e-3, "height changed: ppp={ppp}, rect={rect:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_pixel_preview_and_invalid_density_keep_unsnapped_origin() {
|
||||
let tl = Pos2::new(149.5, 91.25);
|
||||
let view = View { origin: (0.0, 0.0), scale: 1.95, width: 300, height: 180 };
|
||||
for (zoom, ppp, pixel_preview) in [(1.2, 1.5, false), (1.3, 1.5, true)] {
|
||||
let rect = presented_image_rect(tl, &view, zoom, ppp, pixel_preview);
|
||||
let original =
|
||||
Rect::from_min_size(tl, vec2((f64::from(view.width) / view.scale * zoom) as f32, (f64::from(view.height) / view.scale * zoom) as f32));
|
||||
assert_eq!(rect, original);
|
||||
}
|
||||
for ppp in [0.0, -1.0, f32::NAN, f32::INFINITY] {
|
||||
assert_eq!(presented_image_rect(tl, &view, 1.3, ppp, false).min, tl);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,11 @@ use crate::widgets;
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum PickerTarget {
|
||||
PageBackground,
|
||||
SelectionColor {
|
||||
targets: Vec<figcraft_engine::selection_colors::PaintTarget>,
|
||||
selection: Vec<NodeId>,
|
||||
document: u64,
|
||||
},
|
||||
Paint {
|
||||
fills: bool,
|
||||
index: usize,
|
||||
@@ -72,6 +77,13 @@ pub fn range_fills(doc: &figcraft_doc::Document, id: NodeId, start: usize) -> Op
|
||||
fn current_paint(app: &FigApp, target: &PickerTarget) -> Option<Paint> {
|
||||
let doc = app.session.doc().ok()?;
|
||||
match target {
|
||||
PickerTarget::SelectionColor { targets, selection, document } => {
|
||||
let ds = app.session.ds().ok()?;
|
||||
if ds.uid != *document || ds.selection != *selection {
|
||||
return None;
|
||||
}
|
||||
targets.first()?.paint(doc)
|
||||
}
|
||||
PickerTarget::PageBackground => match &doc.node(app.session.page().ok()?)?.kind {
|
||||
NodeKind::Page { background, .. } => Some(Paint::solid(*background)),
|
||||
_ => None,
|
||||
@@ -121,6 +133,7 @@ fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) {
|
||||
let _ = app.session.begin_interaction("Color");
|
||||
}
|
||||
let r = match target {
|
||||
PickerTarget::SelectionColor { targets, .. } => app.session.execute("selection.setPaint", &json!({"targets": targets, "paint": paint})),
|
||||
PickerTarget::PageBackground => {
|
||||
let c = paint.solid_color().unwrap_or(Color::DEFAULT_PAGE);
|
||||
app.session.execute("page.setBackground", &json!({"color": c.to_css_hex()}))
|
||||
@@ -434,6 +447,9 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
||||
};
|
||||
let current = if stale { None } else { current_paint(app, &target) };
|
||||
let Some(mut paint) = current else {
|
||||
if matches!(target, PickerTarget::SelectionColor { .. }) {
|
||||
finish(app);
|
||||
}
|
||||
app.ui.picker = None;
|
||||
return;
|
||||
};
|
||||
@@ -872,6 +888,7 @@ fn pattern_sources(app: &FigApp, target: &PickerTarget) -> Vec<(NodeId, String)>
|
||||
let Ok(doc) = app.session.doc() else { return Vec::new() };
|
||||
let painted = match target {
|
||||
PickerTarget::Paint { ids, .. } => ids.clone(),
|
||||
PickerTarget::SelectionColor { targets, .. } => targets.iter().map(|t| t.id).collect(),
|
||||
PickerTarget::TextRange { id, .. } => vec![*id],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
@@ -1129,6 +1146,54 @@ impl WithStops for Gradient {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod selection_color_tests {
|
||||
use super::*;
|
||||
use crate::NoServices;
|
||||
use figcraft_engine::{Session, selection_colors::collect};
|
||||
|
||||
fn setup() -> (FigApp, NodeId, NodeId) {
|
||||
let mut s = Session::new();
|
||||
let a = NodeId(s.execute("shape.rectangle", &json!({"width":40,"height":40,"props":{"fill":"#ff0000"}})).unwrap()["id"].as_u64().unwrap());
|
||||
let b = NodeId(s.execute("shape.rectangle", &json!({"width":40,"height":40,"props":{"fill":"#0000ff"}})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids":[a,b]})).unwrap();
|
||||
let mut app = FigApp::new(s, Box::new(NoServices));
|
||||
let targets = collect(app.session.doc().unwrap(), &[a, b])[0].targets.clone();
|
||||
crate::selection_colors::open_picker(&mut app, targets);
|
||||
(app, a, b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_live_changes_keep_frozen_targets_and_commit_once() {
|
||||
let (mut app, a, b) = setup();
|
||||
let target = app.ui.picker.clone().unwrap();
|
||||
let before = app.session.undo_depth();
|
||||
for color in ["#0000ff", "#00ff00"] {
|
||||
write(&mut app, &target, &Paint::solid(Color::from_hex(color).unwrap()), true);
|
||||
}
|
||||
assert!(app.session.recent_colors.is_empty(), "previews must not be recorded");
|
||||
finish(&mut app);
|
||||
assert_eq!(app.session.recent_colors, vec![Color::from_hex("#00ff00").unwrap()]);
|
||||
assert_eq!(app.session.undo_depth().0, before.0 + 1);
|
||||
assert_eq!(current_paint(&app, &target).unwrap().solid_color().unwrap().to_hex(), "00FF00");
|
||||
assert_eq!(app.session.doc().unwrap().node(b).unwrap().fills[0].solid_color().unwrap().to_hex(), "0000FF");
|
||||
app.session.undo().unwrap();
|
||||
assert_eq!(app.session.doc().unwrap().node(a).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_does_not_follow_selection_or_document_switches() {
|
||||
let (mut app, _, _) = setup();
|
||||
let target = app.ui.picker.clone().unwrap();
|
||||
app.session.execute("select.none", &json!({})).unwrap();
|
||||
assert!(current_paint(&app, &target).is_none());
|
||||
let (mut app, _, _) = setup();
|
||||
let target = app.ui.picker.clone().unwrap();
|
||||
app.session.execute("file.new", &json!({})).unwrap();
|
||||
assert!(current_paint(&app, &target).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -213,7 +213,7 @@ fn the_presenter_shows_the_frames_comments() {
|
||||
key(&mut u, Key::C, Modifiers::SHIFT);
|
||||
assert_eq!(u.app.inspect()["presenter"]["comments"], true);
|
||||
// The frame is presented at 100% (Fit never scales up), centred under the bar.
|
||||
let screen = egui::Rect::from_min_size(pos2(0.0, 40.0), vec2(1440.0, 860.0));
|
||||
let screen = egui::Rect::from_min_size(pos2(0.0, 80.0), vec2(1440.0, 820.0));
|
||||
let frame_min = screen.center() - vec2(200.0, 150.0);
|
||||
let pin = frame_min + vec2(100.0, 100.0) + vec2(14.0, -14.0);
|
||||
click(&mut u, pin);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! The credits tabs as drawn, through egui headless with FigCraft's fonts and theme: every column
|
||||
//! fits the About window, switching the Contributors view keeps its size, clicking a header sorts
|
||||
//! keeps its measured width and scrolls when needed, switching the Contributors view keeps its size, clicking a header sorts
|
||||
//! by it, and empty tables (no contributors.json) draw a note instead.
|
||||
|
||||
use egui::{Event, Pos2, RawInput, Rect, vec2};
|
||||
@@ -100,17 +100,73 @@ impl Ui {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep measured columns and the name minimum, using horizontal scrolling when the font
|
||||
/// needs more than the About window's width. No font is required to have Inter's metrics.
|
||||
fn assert_table_columns_fit_or_scroll(u: &mut Ui, widths: &[Option<f32>]) {
|
||||
let mut columns = Vec::new();
|
||||
let mut minimum = 0.0;
|
||||
let mut scroll = 0.0;
|
||||
let mut drawn = Rect::NOTHING;
|
||||
for _ in 0..3 {
|
||||
columns.clear();
|
||||
drawn = u.frame(vec![], |ui| {
|
||||
scroll = ui.spacing().scroll.allocated_width();
|
||||
minimum = widths.iter().flatten().sum::<f32>() + scroll + NAME_MIN_W;
|
||||
credits_table(
|
||||
ui,
|
||||
"credits_width_test",
|
||||
widths,
|
||||
NAME_MIN_W,
|
||||
LIST_HEIGHT,
|
||||
|mut header| {
|
||||
for i in 0..widths.len() {
|
||||
header.col(|ui| {
|
||||
columns.push((ui.max_rect(), ui.clip_rect()));
|
||||
if i + 1 == widths.len() {
|
||||
ui.scroll_to_rect_animation(ui.max_rect(), Some(egui::Align::Max), egui::style::ScrollAnimation::none());
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|_| {},
|
||||
);
|
||||
});
|
||||
}
|
||||
assert!(drawn.width() <= ABOUT_WIDTH + 0.5, "table viewport grew to {} pt", drawn.width());
|
||||
assert_eq!(columns.len(), widths.len());
|
||||
for ((rect, _), width) in columns.iter().zip(widths) {
|
||||
if let Some(width) = width {
|
||||
assert!((rect.width() - width).abs() < 0.5, "measured column {width} became {} pt", rect.width());
|
||||
} else {
|
||||
assert!(rect.width() >= NAME_MIN_W - 0.5, "name column shrank to {} pt", rect.width());
|
||||
}
|
||||
}
|
||||
let content_width = columns.iter().map(|(rect, _)| rect.width()).sum::<f32>() + scroll;
|
||||
assert!((content_width - ABOUT_WIDTH.max(minimum)).abs() < 0.5, "content needs {minimum} pt, got {content_width}");
|
||||
let (last, clip) = columns.last().unwrap();
|
||||
assert!((last.intersect(*clip).width() - last.width()).abs() < 0.5, "last column cannot be scrolled fully into view: {last:?} in {clip:?}");
|
||||
if minimum <= ABOUT_WIDTH {
|
||||
let (first, clip) = columns.first().unwrap();
|
||||
assert!((first.intersect(*clip).width() - first.width()).abs() < 0.5, "a fitting table scrolled its first column out of view");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_column_fits_the_about_window() {
|
||||
let mut u = Ui::new();
|
||||
let list = people(8);
|
||||
let refs: Vec<&Contributor> = list.iter().collect();
|
||||
let (fixed, scroll) = u.measure(|ui| (contributor_widths(ui, &refs).iter().flatten().sum::<f32>(), ui.spacing().scroll.allocated_width()));
|
||||
assert!(fixed + scroll + NAME_MIN_W <= ABOUT_WIDTH, "contributor columns need {} pt of {ABOUT_WIDTH}", fixed + scroll + NAME_MIN_W);
|
||||
let models: Vec<Model> = (0..8).map(|i| model(leak(format!("Claude Model {i}")), [228, 45, 6, 2, 2, 1, 1, 1][i])).collect();
|
||||
let rows: Vec<[String; 6]> = models.iter().map(|m| model_cells(m, 482)).collect();
|
||||
let fixed = u.measure(|ui| model_widths(ui, &rows).iter().flatten().sum::<f32>());
|
||||
assert!(fixed + scroll + NAME_MIN_W <= ABOUT_WIDTH, "model columns need {} pt of {ABOUT_WIDTH}", fixed + scroll + NAME_MIN_W);
|
||||
fn columns_keep_their_measured_widths_with_different_font_sizes() {
|
||||
for scale in [0.75, 1.0, 1.5] {
|
||||
let mut u = Ui::new();
|
||||
u.ctx.global_style_mut(|style| {
|
||||
style.text_styles.get_mut(&egui::TextStyle::Body).unwrap().size *= scale;
|
||||
});
|
||||
let list = people(8);
|
||||
let refs: Vec<&Contributor> = list.iter().collect();
|
||||
let widths = u.measure(|ui| contributor_widths(ui, &refs));
|
||||
assert_table_columns_fit_or_scroll(&mut u, &widths);
|
||||
let models: Vec<Model> = (0..8).map(|i| model(leak(format!("Claude Model {i}")), [228, 45, 6, 2, 2, 1, 1, 1][i])).collect();
|
||||
let rows: Vec<[String; 6]> = models.iter().map(|m| model_cells(m, 482)).collect();
|
||||
let widths = u.measure(|ui| model_widths(ui, &rows));
|
||||
assert_table_columns_fit_or_scroll(&mut u, &widths);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -14,7 +14,14 @@ use crate::colorpicker::PickerTarget;
|
||||
use crate::theme::{ACCENT, semibold};
|
||||
use crate::{FigApp, RightTab};
|
||||
|
||||
const PINK: Color32 = Color32::from_rgb(0xF2, 0x48, 0x8C);
|
||||
pub(crate) const PINK: Color32 = Color32::from_rgb(0xF2, 0x48, 0x8C);
|
||||
|
||||
pub(crate) fn pink_label(painter: &egui::Painter, label: String, center: Pos2) {
|
||||
let galley = painter.layout_no_wrap(label, semibold(10.0), Color32::WHITE);
|
||||
let rect = Rect::from_center_size(center, galley.size() + vec2(8.0, 4.0));
|
||||
painter.rect_filled(rect, CornerRadius::same(3), PINK);
|
||||
painter.galley(rect.min + vec2(4.0, 2.0), galley, Color32::WHITE);
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Drag {
|
||||
@@ -338,11 +345,7 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) {
|
||||
let hot = hover.is_some_and(|h| r.contains(h)) || active_side == Some(*side);
|
||||
if hot {
|
||||
painter.rect_filled(*r, CornerRadius::ZERO, PINK.gamma_multiply(0.25));
|
||||
let label = crate::canvas::fmt(*v);
|
||||
let galley = painter.layout_no_wrap(label, semibold(10.0), Color32::WHITE);
|
||||
let lr = Rect::from_center_size(r.center(), galley.size() + vec2(8.0, 4.0));
|
||||
painter.rect_filled(lr, CornerRadius::same(3), PINK);
|
||||
painter.galley(lr.min + vec2(4.0, 2.0), galley, Color32::WHITE);
|
||||
pink_label(painter, crate::canvas::fmt(*v), r.center());
|
||||
}
|
||||
}
|
||||
let drag_key = match &dragging {
|
||||
@@ -356,10 +359,7 @@ pub fn draw(app: &FigApp, painter: &egui::Painter) {
|
||||
painter.rect_filled(r, CornerRadius::ZERO, PINK.gamma_multiply(0.3));
|
||||
let c = r.center();
|
||||
painter.line_segment([pos2(c.x - 3.0, c.y), pos2(c.x + 3.0, c.y)], Stroke::new(1.0, PINK));
|
||||
let galley = painter.layout_no_wrap(crate::canvas::fmt(g.value), semibold(10.0), Color32::WHITE);
|
||||
let lr = Rect::from_center_size(c, galley.size() + vec2(8.0, 4.0));
|
||||
painter.rect_filled(lr, CornerRadius::same(3), PINK);
|
||||
painter.galley(lr.min + vec2(4.0, 2.0), galley, Color32::WHITE);
|
||||
pink_label(painter, crate::canvas::fmt(g.value), c);
|
||||
}
|
||||
}
|
||||
let _ = Align2::CENTER_CENTER;
|
||||
|
||||
@@ -62,6 +62,7 @@ pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node
|
||||
.response
|
||||
.on_disabled_hover_text(from_main);
|
||||
}
|
||||
crate::selection_spacing::panel(app, ui, sel);
|
||||
let grid_parent = sel.iter().all(|i| parent_of(doc, *i).and_then(|p| p.layout.as_ref()).is_some_and(|l| l.mode == LayoutMode::Grid));
|
||||
if grid_parent && common(doc, sel, |n| n.child_layout.absolute) == Some(false) {
|
||||
ui.add_enabled_ui(!nested, |ui| grid_cell(app, ui, doc, sel)).response.on_disabled_hover_text(from_main);
|
||||
|
||||
@@ -52,6 +52,8 @@ pub mod render_worker;
|
||||
pub mod right;
|
||||
pub mod scale_panel;
|
||||
pub mod section_panel;
|
||||
pub mod selection_colors;
|
||||
mod selection_spacing;
|
||||
pub mod shortcuts;
|
||||
pub mod shortcuts_panel;
|
||||
pub mod style_picker;
|
||||
@@ -82,6 +84,8 @@ mod canvas_drag_tests;
|
||||
#[cfg(test)]
|
||||
mod canvas_fixes_tests;
|
||||
#[cfg(test)]
|
||||
mod canvas_image_rect_tests;
|
||||
#[cfg(test)]
|
||||
mod colorpicker_selection_tests;
|
||||
#[cfg(test)]
|
||||
mod comments_tests;
|
||||
@@ -465,6 +469,10 @@ pub struct UiState {
|
||||
/// A message at the bottom of the window and how long it has been showing.
|
||||
pub toast: Option<(String, ToastClock)>,
|
||||
pub present: Option<present::Presenter>,
|
||||
/// A presentation kept alive while its design tab or Home is in front.
|
||||
pub paused_present: Option<present::Presenter>,
|
||||
/// The file that owns the presentation tab.
|
||||
pub present_uid: Option<u64>,
|
||||
/// The full-window presenter's options (how it sizes the prototype), kept between
|
||||
/// presentations.
|
||||
pub present_view: present::PresentView,
|
||||
@@ -607,6 +615,8 @@ impl Default for UiState {
|
||||
icon_gallery: false,
|
||||
toast: None,
|
||||
present: None,
|
||||
paused_present: None,
|
||||
present_uid: None,
|
||||
present_view: present::PresentView::default(),
|
||||
preview: None,
|
||||
picker: None,
|
||||
@@ -925,6 +935,9 @@ impl FigApp {
|
||||
let mut p = present::Presenter::new(start);
|
||||
p.view = self.ui.present_view;
|
||||
self.ui.present = Some(p);
|
||||
self.ui.paused_present = None;
|
||||
self.ui.present_uid = self.session.docs.get(self.session.active).map(|d| d.uid);
|
||||
self.ui.home = false;
|
||||
}
|
||||
}
|
||||
"present.options" => {
|
||||
@@ -1404,6 +1417,7 @@ impl FigApp {
|
||||
// Control requests run first so their results reflect this frame.
|
||||
control::drain(self, &ctx);
|
||||
windows::requests(self, &ctx);
|
||||
selection_spacing::sync(self, &ctx);
|
||||
// Before any widget: the navigation keys (F6, Tab, arrows, Escape) move the keyboard.
|
||||
a11y::begin(self, &ctx);
|
||||
if self.ui.title_bar == TitleBar::Custom {
|
||||
@@ -1417,15 +1431,23 @@ impl FigApp {
|
||||
// The Inspect tab measures on hover without Alt.
|
||||
self.session.tool.redlines = self.ui.right_tab == RightTab::Inspect;
|
||||
self.end_pen_outside_design();
|
||||
tabs::tab_bar(self, ui);
|
||||
if self.ui.present.is_some() {
|
||||
// A close request can arrive while the presenter fills the window. Let its unsaved
|
||||
// changes dialog take Escape and draw above the player just as it does in the editor.
|
||||
dialogs::keys(self, &ctx);
|
||||
present::show(self, ui);
|
||||
dialogs::show(self, &ctx);
|
||||
// What went wrong while presenting (a widget's plugin failing) shows over it.
|
||||
self.toast_ui(&ctx);
|
||||
windows::requests(self, &ctx);
|
||||
return;
|
||||
}
|
||||
if !self.has_files() || self.ui.home {
|
||||
tabs::tab_bar(self, ui);
|
||||
home::show(self, ui);
|
||||
// The shortcuts panel belongs to the canvas; it doesn't hang over the home screen
|
||||
// (cleared after the home widgets, which may have just asked for it).
|
||||
self.ui.shortcuts_panel = false;
|
||||
dialogs::keys(self, &ctx);
|
||||
dialogs::show(self, &ctx);
|
||||
self.toast_ui(&ctx);
|
||||
@@ -1433,7 +1455,6 @@ impl FigApp {
|
||||
return;
|
||||
}
|
||||
let hidden = self.session.ds().map(|d| d.view.ui_hidden).unwrap_or(false);
|
||||
tabs::tab_bar(self, ui);
|
||||
if !hidden {
|
||||
egui::Panel::left("left_rail")
|
||||
.exact_size(56.0)
|
||||
@@ -1596,7 +1617,7 @@ impl FigApp {
|
||||
"focus": {"region": self.ui.nav.region.id(), "keyboard": self.ui.nav.keyboard},
|
||||
"reduceMotion": self.reduce_motion(),
|
||||
"presenting": self.ui.present.is_some(),
|
||||
"presenter": self.ui.present.as_ref().map(present::Presenter::inspect),
|
||||
"presenter": self.ui.present.as_ref().or(self.ui.paused_present.as_ref()).map(present::Presenter::inspect),
|
||||
"presentOptions": {"scale": self.ui.present_view.scale.id(), "deviceFrame": self.ui.present_view.device_frame, "comments": self.ui.present_view.comments, "hotspotHints": self.ui.present_view.hotspot_hints, "inspect": self.ui.present_view.inspect},
|
||||
"preview": self.ui.preview.as_ref().map(|p| p.player.inspect()),
|
||||
"mainMenu": self.ui.main_menu,
|
||||
@@ -1692,5 +1713,8 @@ pub(crate) fn drop_overlay(ctx: &egui::Context, rect: egui::Rect, label: &str) {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod selection_spacing_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod typography_scrub_tests;
|
||||
@@ -163,7 +163,8 @@ impl Win {
|
||||
}
|
||||
}
|
||||
|
||||
const CMD: Modifiers = Modifiers::COMMAND.plus(Modifiers::CTRL);
|
||||
// egui sets `command` for Cmd on macOS and for Ctrl elsewhere; macOS Ctrl is a separate key.
|
||||
const CMD: Modifiers = if cfg!(target_os = "macos") { Modifiers::COMMAND } else { Modifiers::COMMAND.plus(Modifiers::CTRL) };
|
||||
|
||||
fn key(key: Key, pressed: bool, modifiers: Modifiers) -> Event {
|
||||
Event::Key { key, physical_key: Some(key), pressed, repeat: false, modifiers }
|
||||
|
||||
+290
-13
@@ -270,6 +270,8 @@ pub struct Presenter {
|
||||
anim_tex: Option<egui::TextureHandle>,
|
||||
temp: Option<Temp>,
|
||||
textures: HashMap<NodeId, FrameTex>,
|
||||
/// Small current-screen preview in the full-window bar, cached until its content changes.
|
||||
bar_thumb: Option<(NodeId, usize, egui::TextureHandle)>,
|
||||
arrived: f64,
|
||||
delays_fired: HashSet<(NodeId, usize)>,
|
||||
/// Mouse triggers waiting out their delay.
|
||||
@@ -348,6 +350,7 @@ impl Presenter {
|
||||
anim_tex: None,
|
||||
temp: None,
|
||||
textures: HashMap::new(),
|
||||
bar_thumb: None,
|
||||
arrived: -1.0,
|
||||
delays_fired: HashSet::new(),
|
||||
waiting: Vec::new(),
|
||||
@@ -390,6 +393,18 @@ impl Presenter {
|
||||
self.stop_scrolling();
|
||||
}
|
||||
|
||||
/// A new device changes the viewport and may relayout every frame. Start at its top instead
|
||||
/// of carrying scroll offsets or a drag from the old screen into the new one.
|
||||
fn reset_for_device(&mut self) {
|
||||
self.pan = None;
|
||||
self.scroll.clear();
|
||||
self.scroll_anim = None;
|
||||
self.press = None;
|
||||
self.dragged = false;
|
||||
self.stop_scrolling();
|
||||
self.rev += 1;
|
||||
}
|
||||
|
||||
/// The current offset of `t` (a frame's scroll, else the view's pan).
|
||||
fn offset(&self, t: Scroller) -> (f64, f64) {
|
||||
match t {
|
||||
@@ -1651,6 +1666,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| {
|
||||
action = player(&mut pres, ui, ui.max_rect(), now, Chrome { keys: !typing, ..FULL }).or(action);
|
||||
});
|
||||
let picked_device = navigation_bar(&mut pres, &ctx, now);
|
||||
// Keys the prototype left alone: C shows the comments, F fills the screen.
|
||||
if !typing {
|
||||
if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::C)) {
|
||||
@@ -1679,12 +1695,132 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
|
||||
if let Some(BarAction::Responsive(on)) = action {
|
||||
app.run("proto.settings", json!({"responsive": on}));
|
||||
}
|
||||
if let Some(device) = picked_device {
|
||||
choose_device(app, &mut pres, &device);
|
||||
}
|
||||
if action == Some(BarAction::Close) || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
app.ui.present_uid = None;
|
||||
return;
|
||||
}
|
||||
app.ui.present = Some(pres);
|
||||
}
|
||||
|
||||
/// Device choices in the presenter use the screen's real dimensions and reflow a copy of the
|
||||
/// design through constraints and auto layout. "No device" returns to the original frame size.
|
||||
fn choose_device(app: &mut FigApp, p: &mut Presenter, device: &str) {
|
||||
let responsive = device != "None";
|
||||
if app.run("proto.settings", json!({"device": device, "responsive": responsive})).is_some() {
|
||||
p.reset_for_device();
|
||||
}
|
||||
}
|
||||
|
||||
/// Controls that stay in reach while viewing a prototype, including screen stepping, device and
|
||||
/// scale. The selected device is returned to the caller so it can go through the normal document
|
||||
/// command (and undo/save) rather than changing only the player's copy.
|
||||
fn navigation_bar(p: &mut Presenter, ctx: &egui::Context, now: f64) -> Option<String> {
|
||||
let t = Tokens::get(ctx);
|
||||
let doc = p.doc.as_ref()?;
|
||||
let frames = doc.top_frames(doc.page_of(p.current).unwrap_or(p.current));
|
||||
let index = frames.iter().position(|id| *id == p.current);
|
||||
let settings = page_settings(doc, p.current);
|
||||
let mut navigate = None;
|
||||
let mut device_choice = None;
|
||||
egui::Area::new(Id::new("present_navigation")).anchor(Align2::CENTER_BOTTOM, vec2(0.0, -18.0)).order(egui::Order::Foreground).show(ctx, |ui| {
|
||||
egui::Frame::NONE
|
||||
.fill(t.panel)
|
||||
.stroke(Stroke::new(1.0, t.border))
|
||||
.corner_radius(CornerRadius::same(9))
|
||||
.inner_margin(egui::Margin::symmetric(8, 5))
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let prev = index.and_then(|i| i.checked_sub(1)).and_then(|i| frames.get(i)).copied();
|
||||
let next = index.and_then(|i| frames.get(i + 1)).copied();
|
||||
let previous =
|
||||
ui.add_enabled(prev.is_some(), egui::Button::new("").min_size(vec2(26.0, 24.0))).on_hover_text(tr("Previous frame"));
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(previous.rect.center(), vec2(21.0, 21.0)),
|
||||
Icon::ChevronLeft,
|
||||
if prev.is_some() { t.icon } else { t.icon_secondary },
|
||||
);
|
||||
if previous.clicked() {
|
||||
navigate = prev;
|
||||
}
|
||||
ui.label(egui::RichText::new(format!("{} / {}", index.map_or(0, |i| i + 1), frames.len())).color(t.text));
|
||||
let following = ui.add_enabled(next.is_some(), egui::Button::new("").min_size(vec2(26.0, 24.0))).on_hover_text(tr("Next frame"));
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(following.rect.center(), vec2(21.0, 21.0)),
|
||||
Icon::ChevronRight,
|
||||
if next.is_some() { t.icon } else { t.icon_secondary },
|
||||
);
|
||||
if following.clicked() {
|
||||
navigate = next;
|
||||
}
|
||||
ui.separator();
|
||||
let model = if settings.device.is_empty() || settings.device == "None" { tr("No device") } else { &settings.device };
|
||||
let device_menu = ui.menu_button(format!("{model} "), |ui| {
|
||||
navigation_menu(ui, |ui| {
|
||||
egui::ScrollArea::vertical().max_height(410.0).show(ui, |ui| {
|
||||
if ui.selectable_label(settings.device.is_empty() || settings.device == "None", tr("No device")).clicked() {
|
||||
device_choice = Some("None".to_owned());
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
for device in PrototypeSettings::DEVICES {
|
||||
let label = format!("{} {}×{}", device.name, device.width as u32, device.height as u32);
|
||||
if ui.selectable_label(settings.device == device.name, label).clicked() {
|
||||
device_choice = Some(device.name.to_owned());
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if ui.selectable_label(settings.device == PrototypeSettings::CUSTOM_DEVICE, tr("Custom size")).clicked() {
|
||||
device_choice = Some(PrototypeSettings::CUSTOM_DEVICE.to_owned());
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
paint_menu_chevron(ui.painter(), device_menu.response.rect, t.icon_secondary);
|
||||
let scale_menu = ui.menu_button(format!("{} ", tr(p.view.scale.label(settings.device().is_some()))), |ui| {
|
||||
navigation_menu(ui, |ui| {
|
||||
for scale in PresentScale::options(settings.device().is_some()) {
|
||||
let label = egui::RichText::new(tr(scale.label(settings.device().is_some()))).color(t.menu_text);
|
||||
if ui.selectable_label(p.view.scale == *scale, label).clicked() {
|
||||
p.set_scale(*scale);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
paint_menu_chevron(ui.painter(), scale_menu.response.rect, t.icon_secondary);
|
||||
});
|
||||
});
|
||||
});
|
||||
if let Some(id) = navigate {
|
||||
p.go(id, Transition::Instant, 0.0, Easing::Linear, now, true, false);
|
||||
}
|
||||
device_choice
|
||||
}
|
||||
|
||||
fn paint_menu_chevron(painter: &egui::Painter, button: Rect, color: Color32) {
|
||||
let icon = Rect::from_center_size(pos2(button.right() - 11.0, button.center().y), vec2(17.0, 17.0));
|
||||
icons::paint(painter, icon, Icon::Chevron, color);
|
||||
}
|
||||
|
||||
fn navigation_menu(ui: &mut Ui, add: impl FnOnce(&mut Ui)) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.visuals_mut().override_text_color = Some(t.menu_text);
|
||||
egui::Frame::NONE.fill(t.menu).stroke(Stroke::new(1.0, t.border)).corner_radius(CornerRadius::same(7)).inner_margin(egui::Margin::same(6)).show(
|
||||
ui,
|
||||
|ui| {
|
||||
ui.set_min_width(212.0);
|
||||
add(ui);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// How often the player polls the controllers while its prototype has gamepad triggers (seconds).
|
||||
const PAD_POLL_SECS: f64 = 1.0 / 30.0;
|
||||
|
||||
@@ -2045,16 +2181,17 @@ fn inspect_hover(p: &mut Presenter, painter: &egui::Painter, ctx: &egui::Context
|
||||
/// the right (text on the dark full-window bar, icons in the pane, which follows the theme).
|
||||
fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome) -> Option<BarAction> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (fill, text, dim, hover) = if chrome.pane {
|
||||
(t.panel, t.text, t.text_secondary, t.hover)
|
||||
} else {
|
||||
(Color32::from_rgb(0x2C, 0x2C, 0x2C), Color32::WHITE, Color32::from_gray(140), Color32::from_white_alpha(28))
|
||||
};
|
||||
let (fill, text, dim, hover) = (t.panel, t.text, t.text_secondary, t.hover);
|
||||
let r = chrome.round;
|
||||
ui.painter().rect_filled(bar, CornerRadius { nw: r, ne: r, sw: 0, se: 0 }, fill);
|
||||
if chrome.pane {
|
||||
ui.painter().line_segment([bar.left_bottom(), bar.right_bottom()], Stroke::new(1.0, t.border));
|
||||
}
|
||||
let source_key = p.source.as_ref().map_or(0, |s| Arc::as_ptr(s) as usize);
|
||||
if !chrome.pane && p.bar_thumb.as_ref().is_none_or(|(id, source, _)| *id != p.current || *source != source_key) {
|
||||
p.bar_thumb =
|
||||
p.source.as_ref().and_then(|d| crate::assets_panel::thumbnail(ui.ctx(), d, p.current, 38.0)).map(|tex| (p.current, source_key, tex));
|
||||
}
|
||||
let doc = p.doc.as_ref()?;
|
||||
let name = doc.node(p.current).map(|n| n.name.clone()).unwrap_or_default();
|
||||
let flows = match doc.page_of(p.start).and_then(|pg| doc.node(pg)).map(|n| &n.kind) {
|
||||
@@ -2117,19 +2254,33 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome)
|
||||
// Left: the flow picker and the frame's name, clipped before the buttons.
|
||||
let left = ui.painter().with_clip_rect(Rect::from_min_max(bar.min, pos2(x, bar.max.y)));
|
||||
let mut lx = bar.min.x + if chrome.pane { 8.0 } else { 12.0 };
|
||||
if !chrome.pane {
|
||||
let thumb = Rect::from_center_size(pos2(lx + 15.0, bar.center().y), vec2(28.0, 28.0));
|
||||
left.rect_filled(thumb, CornerRadius::same(4), t.field);
|
||||
if let Some((_, _, tex)) = &p.bar_thumb {
|
||||
let size = tex.size_vec2();
|
||||
if size.x > 0.0 && size.y > 0.0 {
|
||||
let scaled = size * (26.0 / size.x.max(size.y));
|
||||
let dst = Rect::from_center_size(thumb.center(), scaled);
|
||||
left.image(tex.id(), dst, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
}
|
||||
lx += 38.0;
|
||||
}
|
||||
// The flow playing: its name on the picker, its description on hover; the menu lists each
|
||||
// flow with its description.
|
||||
let playing = flows.iter().find(|f| f.start == p.start);
|
||||
if !flows.is_empty() {
|
||||
let label = format!("{} ▾", playing.map_or(tr("Flows"), |f| f.name.as_str()));
|
||||
let label = playing.map_or(tr("Flows"), |f| f.name.as_str()).to_owned();
|
||||
let tip = playing.map(|f| f.description.trim()).filter(|d| !d.is_empty()).unwrap_or(tr("Flows")).to_string();
|
||||
let galley = left.layout_no_wrap(label, semibold(11.5), text);
|
||||
let fr = Rect::from_min_size(pos2(lx, bar.center().y - 12.0), vec2(galley.size().x + 16.0, 24.0));
|
||||
let fr = Rect::from_min_size(pos2(lx, bar.center().y - 12.0), vec2(galley.size().x + 30.0, 24.0));
|
||||
let resp = ui.interact(fr.intersect(left.clip_rect()), ui.id().with("pflows"), Sense::click()).tip(tip);
|
||||
if resp.hovered() {
|
||||
left.rect_filled(fr, CornerRadius::same(5), hover);
|
||||
}
|
||||
left.galley(pos2(fr.min.x + 8.0, fr.center().y - galley.size().y / 2.0), galley, text);
|
||||
paint_menu_chevron(&left, fr, t.icon_secondary);
|
||||
crate::right::menu_popup(&resp).width(240.0).show(|ui| {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
for f in &flows {
|
||||
@@ -2149,7 +2300,7 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome)
|
||||
left.text(pos2(lx, bar.center().y), Align2::LEFT_CENTER, "/", regular(11.5), dim);
|
||||
lx += 14.0;
|
||||
}
|
||||
left.text(pos2(lx, bar.center().y), Align2::LEFT_CENTER, name, regular(11.5), if chrome.pane { text } else { Color32::from_gray(220) });
|
||||
left.text(pos2(lx, bar.center().y), Align2::LEFT_CENTER, name, regular(11.5), text);
|
||||
action
|
||||
}
|
||||
|
||||
@@ -2418,14 +2569,21 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, ke
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Otherwise the drag scrolls the innermost frame under it that scrolls, else pans a view
|
||||
// bigger than the window.
|
||||
// A drag begun inside a mobile screen moves its content, even if the pointer leaves the
|
||||
// screen. The device body stays in place even when the drag begins outside its screen.
|
||||
// Panning the whole view remains available only without a mobile device.
|
||||
if let Some(base) = p.doc.clone() {
|
||||
let target = scrollers_at(p, &base, doc, root, &place, start)
|
||||
.first()
|
||||
let horizontal = d.x.abs() >= d.y.abs();
|
||||
let scrolls = scrollers_at(p, &base, doc, root, &place, start);
|
||||
let on_axis = |range: (f64, f64)| if horizontal { range.0 > 0.0 } else { range.1 > 0.0 };
|
||||
let mobile = page_settings(&base, root).device().is_some_and(|device| device.rotates);
|
||||
let target = scrolls
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|f| on_axis(p.range(&base, *f)))
|
||||
.or_else(|| scrolls.first().copied())
|
||||
.map(Scroller::Frame)
|
||||
.or((p.pan_range != (0.0, 0.0)).then_some(Scroller::View));
|
||||
.or((!mobile && p.pan_range != (0.0, 0.0)).then_some(Scroller::View));
|
||||
if let Some(t) = target {
|
||||
p.fling = None;
|
||||
p.drag_scroll = Some((t, clamp_scroll(p.offset(t), p.limits(&base, t))));
|
||||
@@ -2685,6 +2843,125 @@ mod tests {
|
||||
use super::*;
|
||||
use figcraft_doc::NodeKind;
|
||||
|
||||
use crate::present_tests::{Ui, id};
|
||||
|
||||
#[test]
|
||||
fn device_picker_reflows_without_resizing_the_saved_frame_and_can_revert() {
|
||||
let mut session = figcraft_engine::Session::new();
|
||||
let frame = id(&session.execute("node.create", &json!({"type": "frame", "width": 894, "height": 844})).unwrap());
|
||||
let right = id(&session
|
||||
.execute("node.create", &json!({"type": "rectangle", "parent": frame, "x": 804, "y": 20, "width": 80, "height": 40}))
|
||||
.unwrap());
|
||||
session.execute("node.set", &json!({"id": right, "props": {"constraints": {"horizontal": "right", "vertical": "top"}}})).unwrap();
|
||||
let mut ui = Ui::new(session);
|
||||
ui.run("proto.present", json!({"start": frame}));
|
||||
|
||||
let mut presenter = ui.app.ui.present.take().unwrap();
|
||||
presenter.pan = Some((100.0, 200.0));
|
||||
presenter.scroll.insert(frame, (75.0, 50.0));
|
||||
choose_device(&mut ui.app, &mut presenter, "iPhone 17");
|
||||
assert!(presenter.pan.is_none() && presenter.scroll.is_empty());
|
||||
ui.app.ui.present = Some(presenter);
|
||||
ui.frame(vec![]);
|
||||
|
||||
let shown = &ui.app.ui.present.as_ref().unwrap().doc.as_ref().unwrap();
|
||||
assert_eq!((shown.node(frame).unwrap().width, shown.node(frame).unwrap().height), (402.0, 874.0));
|
||||
assert_eq!(shown.world_bounds(right).unwrap().x0, 312.0, "right constraint follows the phone edge");
|
||||
assert_eq!(ui.app.inspect()["presenter"]["viewport"], json!([402.0, 874.0]));
|
||||
assert_eq!(ui.app.inspect()["presenter"]["scrollRange"], json!([0.0, 0.0]));
|
||||
assert_eq!(ui.app.session.doc().unwrap().node(frame).unwrap().width, 894.0, "the saved design keeps its geometry");
|
||||
|
||||
let area = Rect::from_min_max(pos2(0.0, 40.0), pos2(1440.0, 900.0));
|
||||
let screen_before = layout(ui.app.ui.present.as_mut().unwrap(), area, FULL).unwrap().screen;
|
||||
ui.button(pos2(720.0, 440.0), true);
|
||||
ui.frame(vec![egui::Event::PointerMoved(pos2(300.0, 300.0))]);
|
||||
ui.button(pos2(300.0, 300.0), false);
|
||||
assert_eq!(ui.app.inspect()["presenter"]["scroll"], json!([0.0, 0.0]), "fitted content does not move");
|
||||
assert_eq!(layout(ui.app.ui.present.as_mut().unwrap(), area, FULL).unwrap().screen, screen_before);
|
||||
|
||||
let mut presenter = ui.app.ui.present.take().unwrap();
|
||||
choose_device(&mut ui.app, &mut presenter, "None");
|
||||
ui.app.ui.present = Some(presenter);
|
||||
ui.frame(vec![]);
|
||||
assert_eq!(ui.app.inspect()["presenter"]["frameSize"], json!([894.0, 844.0]));
|
||||
assert_eq!(ui.app.inspect()["presenter"]["responsive"], Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_presets_show_their_screen_at_the_real_resolution_in_their_body() {
|
||||
let mut session = figcraft_engine::Session::new();
|
||||
let frame = id(&session.execute("node.create", &json!({"type": "frame", "width": 894, "height": 844})).unwrap());
|
||||
let mut ui = Ui::new(session);
|
||||
ui.run("proto.present", json!({"start": frame}));
|
||||
let area = Rect::from_min_max(pos2(0.0, 40.0), pos2(1440.0, 860.0));
|
||||
|
||||
for (name, width, height) in
|
||||
[("iPad mini", 744.0, 1133.0), ("MacBook Air", 1280.0, 832.0), ("Desktop", 1440.0, 1024.0), ("iPhone 17", 402.0, 874.0)]
|
||||
{
|
||||
let mut presenter = ui.app.ui.present.take().unwrap();
|
||||
choose_device(&mut ui.app, &mut presenter, name);
|
||||
ui.app.ui.present = Some(presenter);
|
||||
ui.frame(vec![]);
|
||||
|
||||
let presenter = ui.app.ui.present.as_mut().unwrap();
|
||||
let laid = layout(presenter, area, FULL).unwrap();
|
||||
assert!(laid.color.is_some(), "{name}: drawn in its body");
|
||||
assert!((f64::from(laid.screen.width()) / laid.scale - width).abs() < 0.1, "{name}: width");
|
||||
assert!((f64::from(laid.screen.height()) / laid.scale - height).abs() < 0.1, "{name}: height");
|
||||
assert_eq!(ui.app.session.doc().unwrap().node(frame).unwrap().width, 894.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A phone shows a frame's content past its screen only as far as the frame's overflow lets it
|
||||
/// scroll: with "No scrolling" it's clipped, like on every other device.
|
||||
#[test]
|
||||
fn a_phone_scrolls_only_what_the_frames_overflow_allows() {
|
||||
let on_phone = |overflow: &str| {
|
||||
let mut session = figcraft_engine::Session::new();
|
||||
let frame = id(&session.execute("node.create", &json!({"type": "frame", "width": 894, "height": 844})).unwrap());
|
||||
session.execute("node.create", &json!({"type": "rectangle", "parent": frame, "x": 650, "y": 1050, "width": 80, "height": 80})).unwrap();
|
||||
session.execute("node.set", &json!({"id": frame, "props": {"overflow": overflow}})).unwrap();
|
||||
let mut ui = Ui::new(session);
|
||||
ui.run("proto.present", json!({"start": frame}));
|
||||
let mut presenter = ui.app.ui.present.take().unwrap();
|
||||
choose_device(&mut ui.app, &mut presenter, "iPhone 17");
|
||||
ui.app.ui.present = Some(presenter);
|
||||
ui.frame(vec![]);
|
||||
(ui, frame)
|
||||
};
|
||||
let start = pos2(720.0, 440.0);
|
||||
let drag = |ui: &mut Ui, to: Pos2| {
|
||||
ui.button(start, true);
|
||||
ui.frame(vec![egui::Event::PointerMoved(to)]);
|
||||
ui.button(to, false);
|
||||
ui.app.inspect()["presenter"].clone()
|
||||
};
|
||||
|
||||
let (mut ui, frame) = on_phone("none");
|
||||
assert_eq!(ui.app.inspect()["presenter"]["scrollRange"], json!([0.0, 0.0]));
|
||||
assert_eq!(drag(&mut ui, start - vec2(100.0, 120.0))["scroll"], json!([0.0, 0.0]), "no scrolling stays clipped");
|
||||
assert_eq!(ui.app.session.doc().unwrap().node(frame).unwrap().width, 894.0);
|
||||
|
||||
let (mut ui, frame) = on_phone("both");
|
||||
let range = &ui.app.inspect()["presenter"]["scrollRange"];
|
||||
assert!(range[0].as_f64().unwrap() > 0.0 && range[1].as_f64().unwrap() > 0.0, "{range}");
|
||||
let scroll = drag(&mut ui, start - vec2(100.0, 120.0))["scroll"].clone();
|
||||
assert!(scroll[0].as_f64().unwrap() > 0.0 && scroll[1].as_f64().unwrap() > 0.0, "{scroll}");
|
||||
assert_eq!(ui.app.session.doc().unwrap().node(frame).unwrap().width, 894.0);
|
||||
|
||||
// The content keeps following a drag after the pointer leaves the screen, while the
|
||||
// device itself stays fixed on the stage.
|
||||
ui.frames(60, Vec::new);
|
||||
let area = Rect::from_min_max(pos2(0.0, 40.0), pos2(1440.0, 900.0));
|
||||
let screen_before = layout(ui.app.ui.present.as_mut().unwrap(), area, FULL).unwrap().screen;
|
||||
ui.button(start, true);
|
||||
ui.frame(vec![egui::Event::PointerMoved(pos2(300.0, 300.0))]);
|
||||
let during = ui.app.inspect()["presenter"].clone();
|
||||
assert!(during["scroll"][0].as_f64().unwrap() > 0.0, "{during}");
|
||||
assert_eq!(layout(ui.app.ui.present.as_mut().unwrap(), area, FULL).unwrap().screen, screen_before);
|
||||
ui.button(pos2(300.0, 300.0), false);
|
||||
}
|
||||
|
||||
fn doc_with_two_frames() -> (Document, NodeId, NodeId) {
|
||||
let mut d = Document::new("t");
|
||||
let page = d.add_page("Page 1");
|
||||
|
||||
@@ -95,6 +95,28 @@ fn scale_options_match_the_command_and_cycle() {
|
||||
assert!(!PresentScale::options(true).contains(&PresentScale::FitWidth));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presentation_tab_keeps_the_current_screen_when_returning_to_the_editor() {
|
||||
let (mut u, a, b, _) = setup();
|
||||
u.run("proto.present", json!({"start": a}));
|
||||
u.key(pos2(720.0, 470.0), Key::ArrowRight);
|
||||
assert_eq!(presenter(&u)["current"], json!(b));
|
||||
|
||||
let design_tab = pos2(100.0, 20.0);
|
||||
u.button(design_tab, true);
|
||||
u.button(design_tab, false);
|
||||
assert!(u.app.ui.present.is_none());
|
||||
assert_eq!(u.app.ui.paused_present.as_ref().map(|p| p.current), Some(b));
|
||||
|
||||
let name_len = u.app.session.doc().unwrap().name.chars().count() as f32;
|
||||
let design_w = (12.0 * 0.6 * name_len + 64.0).clamp(120.0, 220.0);
|
||||
let preview_tab = pos2(8.0 + 44.0 + design_w + 40.0, 20.0);
|
||||
u.button(preview_tab, true);
|
||||
u.button(preview_tab, false);
|
||||
assert!(u.app.ui.paused_present.is_none());
|
||||
assert_eq!(presenter(&u)["current"], json!(b));
|
||||
}
|
||||
|
||||
/// No device: a big screen (2000×3000) and a small one, presenting the big one.
|
||||
fn big() -> (Ui, NodeId) {
|
||||
let mut s = Session::new();
|
||||
@@ -117,10 +139,10 @@ fn num(v: &Value) -> f64 {
|
||||
fn presenter_scales_by_its_options_and_pans_what_overflows() {
|
||||
let (mut u, big) = big();
|
||||
let at = pos2(720.0, 470.0);
|
||||
// Fit: scaled down to the 1376×796 inside the padding; nothing pans.
|
||||
// Fit: the tab strip leaves 1376×756 inside the presenter's padding; nothing pans.
|
||||
let p = presenter(&u);
|
||||
assert_eq!(p["scale"], "fit");
|
||||
assert!((num(&p["zoom"]) - 796.0 / 3000.0).abs() < 1e-3, "{p}");
|
||||
assert!((num(&p["zoom"]) - 756.0 / 3000.0).abs() < 1e-3, "{p}");
|
||||
assert_eq!(p["panRange"], json!([0.0, 0.0]));
|
||||
// Z: fit width (the whole 1440 width; the rest of the height pans).
|
||||
u.key(at, Key::Z);
|
||||
@@ -128,14 +150,14 @@ fn presenter_scales_by_its_options_and_pans_what_overflows() {
|
||||
assert_eq!(p["scale"], "fitWidth");
|
||||
assert!((num(&p["zoom"]) - 0.72).abs() < 1e-3, "{p}");
|
||||
assert_eq!(p["panRange"][0], 0.0);
|
||||
assert!((num(&p["panRange"][1]) - (2160.0 - 860.0)).abs() < 1.0, "{p}");
|
||||
assert!((num(&p["panRange"][1]) - (2160.0 - 820.0)).abs() < 1.0, "{p}");
|
||||
// Z: fill (here the width decides too), then 100%: both ways overflow, centred across at the top.
|
||||
u.key(at, Key::Z);
|
||||
assert_eq!(presenter(&u)["scale"], "fill");
|
||||
u.key(at, Key::Z);
|
||||
let p = presenter(&u);
|
||||
assert_eq!((p["scale"].as_str(), num(&p["zoom"])), (Some("actual"), 1.0));
|
||||
assert_eq!(p["panRange"], json!([624.0, 2204.0]));
|
||||
assert_eq!(p["panRange"], json!([624.0, 2244.0]));
|
||||
assert_eq!(p["pan"], json!([312.0, 0.0]));
|
||||
// The wheel pans down; a drag pans with the pointer.
|
||||
let wheel = move || {
|
||||
@@ -192,7 +214,7 @@ fn devices_turn_and_their_frame_can_hide() {
|
||||
let with_body = num(&presenter(&u)["zoom"]);
|
||||
u.run("proto.presentOptions", json!({"deviceFrame": false}));
|
||||
let bare = num(&presenter(&u)["zoom"]);
|
||||
assert!(bare > with_body && (bare - 796.0 / 852.0).abs() < 1e-3, "{with_body} {bare}");
|
||||
assert!(bare > with_body && (bare - 756.0 / 852.0).abs() < 1e-3, "{with_body} {bare}");
|
||||
// A device offers no fit width: Z goes fit → fill → 100%.
|
||||
u.key(pos2(720.0, 470.0), Key::Z);
|
||||
assert_eq!(presenter(&u)["scale"], "fill");
|
||||
|
||||
@@ -69,8 +69,8 @@ fn without_a_device_a_responsive_frame_fills_the_window_at_100_percent() {
|
||||
let mut u = Ui::new(s);
|
||||
u.run("proto.present", json!({"start": w}));
|
||||
let p = presenter(&u);
|
||||
// The window is 1440×900; the bar takes 40.
|
||||
assert_eq!(size(&p["frameSize"]), (1440.0, 860.0), "{p}");
|
||||
// The window is 1440×900; the title and tab bars take 80.
|
||||
assert_eq!(size(&p["frameSize"]), (1440.0, 820.0), "{p}");
|
||||
assert_eq!(p["zoom"], json!(1.0));
|
||||
assert_eq!(size(&p["panRange"]), (0.0, 0.0));
|
||||
// Z has nothing to cycle: the size options don't apply.
|
||||
|
||||
@@ -380,6 +380,7 @@ fn design(app: &mut FigApp, ui: &mut Ui) {
|
||||
widgets::separator(ui);
|
||||
effects(app, ui, &doc, &sel);
|
||||
widgets::separator(ui);
|
||||
crate::selection_colors::section(app, ui, &doc, &sel);
|
||||
if sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like())) {
|
||||
grids(app, ui, &doc, &sel);
|
||||
widgets::separator(ui);
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
//! Selection colors lives below Effects. A picker edits frozen paint slots, so color groups
|
||||
//! merging while scrubbing cannot accidentally recolor other objects.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{Id, Ui};
|
||||
use figcraft_color::Color;
|
||||
use figcraft_doc::{Document, NodeId, StyleKind};
|
||||
use figcraft_engine::selection_colors::{PaintTarget, SelectionColor, collect};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::a11y::Tip;
|
||||
use crate::colorpicker::PickerTarget;
|
||||
use crate::i18n::tr;
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::theme::Tokens;
|
||||
use crate::{FigApp, widgets};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Cache {
|
||||
document: u64,
|
||||
doc: Arc<Document>,
|
||||
ids: Vec<NodeId>,
|
||||
colors: Arc<Vec<SelectionColor>>,
|
||||
}
|
||||
|
||||
fn colors(ui: &Ui, document: u64, doc: &Arc<Document>, ids: &[NodeId], interacting: bool) -> Arc<Vec<SelectionColor>> {
|
||||
let key = Id::new("selection_colors_cache");
|
||||
if let Some(cache) = ui.data(|d| d.get_temp::<Cache>(key))
|
||||
&& cache.document == document
|
||||
&& cache.ids == ids
|
||||
&& (interacting || Arc::ptr_eq(&cache.doc, doc))
|
||||
{
|
||||
return cache.colors;
|
||||
}
|
||||
// A drag replaces the document snapshot every frame. Keep its existing rows until the
|
||||
// gesture ends; a cold cache or a changed document/selection waits too. Engine commands
|
||||
// still discover and change the complete selection, independently of this panel cache.
|
||||
if interacting {
|
||||
return Arc::new(Vec::new());
|
||||
}
|
||||
let colors = Arc::new(collect(doc, ids));
|
||||
ui.data_mut(|d| d.insert_temp(key, Cache { document, doc: doc.clone(), ids: ids.to_vec(), colors: colors.clone() }));
|
||||
colors
|
||||
}
|
||||
|
||||
pub(crate) fn open_picker(app: &mut FigApp, targets: Vec<PaintTarget>) {
|
||||
let Ok(ds) = app.session.ds() else { return };
|
||||
app.ui.picker = Some(PickerTarget::SelectionColor { targets, selection: ds.selection.clone(), document: ds.uid });
|
||||
}
|
||||
|
||||
pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, ids: &[NodeId]) {
|
||||
let Ok(ds) = app.session.ds() else { return };
|
||||
let interacting = ds.interaction.is_some();
|
||||
let colors = colors(ui, ds.uid, doc, ids, interacting);
|
||||
if colors.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
widgets::section_header(ui, tr("Selection colors"), &[], false);
|
||||
// Bound the panel's height; a document with many distinct colors stays scrollable.
|
||||
ui.add_enabled_ui(!interacting, |ui| {
|
||||
egui::ScrollArea::vertical().id_salt("selection_colors_rows").max_height(240.0).show_rows(ui, 32.0, colors.len(), |ui, range| {
|
||||
for group in &colors[range] {
|
||||
ui.push_id((&group.targets, "selection_color"), |ui| {
|
||||
crate::right::row(ui, 32.0, |ui| {
|
||||
let swatch = widgets::swatch(ui, &group.paint, 24.0).tip(tr("Edit colour"));
|
||||
if swatch.clicked() {
|
||||
open_picker(app, group.targets.clone());
|
||||
}
|
||||
let width = (ui.available_width() - 116.0).max(40.0);
|
||||
let linked_name = group
|
||||
.style
|
||||
.as_ref()
|
||||
.and_then(|id| doc.style(id).map(|s| s.name.as_str()))
|
||||
.or_else(|| group.variable.as_ref().and_then(|id| doc.variables.get(id).map(|v| v.name.as_str())));
|
||||
if let Some(name) = linked_name {
|
||||
if widgets::dropdown(ui, name, width, None).clicked() {
|
||||
open_picker(app, group.targets.clone());
|
||||
}
|
||||
} else if let Some(color) = group.paint.solid_color() {
|
||||
if let Some(hex) = widgets::text_field_named(ui, ui.id().with("hex"), &color.to_hex(), width, tr("Color")) {
|
||||
if let Some(color) = Color::from_hex(&hex) {
|
||||
let mut paint = group.paint.clone();
|
||||
paint.kind = figcraft_color::PaintKind::Solid { color: color.with_alpha(1.0) };
|
||||
if color.a < 1.0 {
|
||||
paint.opacity = color.a;
|
||||
}
|
||||
app.run("selection.setPaint", json!({"targets": group.targets, "paint": paint}));
|
||||
} else {
|
||||
app.toast(tr("Invalid color"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let response = widgets::dropdown(ui, &widgets::paint_name(&group.paint), width, None);
|
||||
if response.clicked() {
|
||||
open_picker(app, group.targets.clone());
|
||||
}
|
||||
}
|
||||
let alpha = format!("{}%", figcraft_doc::trim_num(f64::from(group.paint.opacity) * 100.0));
|
||||
if let Some(value) = widgets::text_field_named(ui, ui.id().with("opacity"), &alpha, 44.0, tr("Opacity"))
|
||||
&& let Ok(value) = value.trim().trim_end_matches('%').parse::<f32>()
|
||||
&& value.is_finite()
|
||||
{
|
||||
let mut paint = group.paint.clone();
|
||||
paint.opacity = (value / 100.0).clamp(0.0, 1.0);
|
||||
app.run("selection.setPaint", json!({"targets": group.targets, "paint": paint}));
|
||||
}
|
||||
let styles = icons::button(ui, Icon::Styles, 24.0, false, tr("Color styles"));
|
||||
crate::right::menu_popup(&styles).show(|ui| {
|
||||
let mut any = false;
|
||||
for style in &doc.styles {
|
||||
if !matches!(style.kind, StyleKind::Paint { .. }) {
|
||||
continue;
|
||||
}
|
||||
any = true;
|
||||
if crate::right::menu_item(ui, &style.name, "") {
|
||||
app.run("selection.applyColorStyle", json!({"targets": group.targets, "style": style.id}));
|
||||
app.ui.picker = None;
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
if !any {
|
||||
widgets::label(ui, tr("No color styles"), t.text_secondary);
|
||||
}
|
||||
});
|
||||
if icons::button(ui, Icon::Move, 24.0, false, tr("Select layers with this color")).clicked() {
|
||||
app.run("selection.selectColor", json!({"targets": group.targets}));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
widgets::separator(ui);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::NoServices;
|
||||
use egui::{Event, Pos2, RawInput, Rect, Shape, vec2};
|
||||
use figcraft_engine::Session;
|
||||
|
||||
struct Panel {
|
||||
ctx: egui::Context,
|
||||
app: FigApp,
|
||||
time: f64,
|
||||
}
|
||||
|
||||
impl Panel {
|
||||
fn new() -> (Self, NodeId, NodeId) {
|
||||
let mut s = Session::new();
|
||||
let mut ids = Vec::new();
|
||||
for color in ["#ff0000", "#0000ff"] {
|
||||
let value = s.execute("shape.rectangle", &json!({"width":40,"height":40,"props":{"fill":color}})).unwrap();
|
||||
ids.push(NodeId(value["id"].as_u64().unwrap()));
|
||||
}
|
||||
s.execute("select.set", &json!({"ids":ids})).unwrap();
|
||||
let mut p = Self { ctx: egui::Context::default(), app: FigApp::new(s, Box::new(NoServices)), time: 0.0 };
|
||||
crate::theme::install_fonts(&p.ctx, false, 0);
|
||||
crate::theme::apply(&p.ctx, crate::theme::Tokens::light());
|
||||
for _ in 0..3 {
|
||||
p.frame(vec![]);
|
||||
}
|
||||
(p, ids[0], ids[1])
|
||||
}
|
||||
fn frame(&mut self, events: Vec<Event>) -> Vec<(String, Pos2)> {
|
||||
self.time += 1.0 / 60.0;
|
||||
let input = RawInput {
|
||||
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 600.0))),
|
||||
time: Some(self.time),
|
||||
events,
|
||||
..Default::default()
|
||||
};
|
||||
let mut output = self.ctx.run_ui(input, |ui| {
|
||||
let ds = self.app.session.ds().unwrap();
|
||||
let doc = ds.doc.clone();
|
||||
let ids = ds.selection.clone();
|
||||
section(&mut self.app, ui, &doc, &ids);
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
fn texts(shape: &Shape, out: &mut Vec<(String, Pos2)>) {
|
||||
match shape {
|
||||
Shape::Text(t) => out.push((t.galley.text().to_string(), t.pos + t.galley.size() / 2.0)),
|
||||
Shape::Vec(v) => {
|
||||
for s in v {
|
||||
texts(s, out)
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut text = Vec::new();
|
||||
for s in &output.shapes {
|
||||
texts(&s.shape, &mut text);
|
||||
}
|
||||
text
|
||||
}
|
||||
fn click(&mut self, pos: Pos2) {
|
||||
for pressed in [true, false] {
|
||||
self.frame(vec![
|
||||
Event::PointerMoved(pos),
|
||||
Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() },
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_colors_panel_opens_the_existing_picker_and_hides_for_one_color() {
|
||||
let (mut p, a, b) = Panel::new();
|
||||
let texts = p.frame(vec![]);
|
||||
assert!(texts.iter().any(|(s, _)| s == "Selection colors"));
|
||||
let red = texts.iter().find(|(s, _)| s == "FF0000").unwrap().1;
|
||||
p.click(Pos2::new(28.0, red.y));
|
||||
let Some(PickerTarget::SelectionColor { targets, .. }) = &p.app.ui.picker else { panic!("the swatch must open its picker") };
|
||||
assert_eq!(targets.iter().map(|t| t.id).collect::<Vec<_>>(), [a]);
|
||||
p.app.run("fill.set", json!({"id":b,"color":"#ff0000"}));
|
||||
assert!(!p.frame(vec![]).iter().any(|(s, _)| s == "Selection colors"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_keeps_cached_colors_during_interaction_and_refreshes_after_commit() {
|
||||
let (mut p, a, _) = Panel::new();
|
||||
let key = Id::new("selection_colors_cache");
|
||||
let before = p.ctx.data(|d| d.get_temp::<Cache>(key)).unwrap();
|
||||
p.app.session.begin_interaction("Color").unwrap();
|
||||
for hex in ["#00ff00", "#ffff00", "#00ffff"] {
|
||||
p.app.run("fill.set", json!({"id":a,"color":hex}));
|
||||
p.frame(vec![]);
|
||||
let during = p.ctx.data(|d| d.get_temp::<Cache>(key)).unwrap();
|
||||
assert!(Arc::ptr_eq(&before.colors, &during.colors), "a live edit must not rediscover paints");
|
||||
}
|
||||
p.app.session.commit_interaction().unwrap();
|
||||
let texts = p.frame(vec![]);
|
||||
assert!(texts.iter().any(|(s, _)| s == "00FFFF"));
|
||||
let after = p.ctx.data(|d| d.get_temp::<Cache>(key)).unwrap();
|
||||
assert!(!Arc::ptr_eq(&before.colors, &after.colors));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_defers_discovery_without_a_matching_cache_during_interaction() {
|
||||
let (mut p, a, _) = Panel::new();
|
||||
let key = Id::new("selection_colors_cache");
|
||||
p.ctx.data_mut(|d| d.remove::<Cache>(key));
|
||||
p.app.session.begin_interaction("Color").unwrap();
|
||||
p.app.run("fill.set", json!({"id":a,"color":"#00ff00"}));
|
||||
assert!(!p.frame(vec![]).iter().any(|(s, _)| s == "Selection colors"));
|
||||
assert!(p.ctx.data(|d| d.get_temp::<Cache>(key)).is_none());
|
||||
p.app.session.cancel_interaction().unwrap();
|
||||
assert!(p.frame(vec![]).iter().any(|(s, _)| s == "FF0000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_color_button_selects_only_the_matching_layers() {
|
||||
let (mut p, a, _) = Panel::new();
|
||||
let red = p.frame(vec![]).into_iter().find(|(s, _)| s == "FF0000").unwrap().1;
|
||||
p.click(Pos2::new(252.0, red.y));
|
||||
assert_eq!(p.app.session.selection(), [a]);
|
||||
assert!(p.app.ui.last_error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_displays_the_linked_style_name_and_the_other_color() {
|
||||
let (mut p, a, b) = Panel::new();
|
||||
p.app.run("style.create", json!({"type":"paint","name":"Brand","value":"#ff0000"}));
|
||||
p.app.run("style.apply", json!({"id":a,"style":"Brand","target":"fill"}));
|
||||
let texts = p.frame(vec![]);
|
||||
assert!(texts.iter().any(|(s, _)| s == "Brand"));
|
||||
assert!(texts.iter().any(|(s, _)| s == "0000FF"));
|
||||
let colors = collect(p.app.session.doc().unwrap(), &[a, b]);
|
||||
assert_eq!(colors.len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
//! Smart-selection fields and handles. Geometry and document edits belong to the engine.
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{CursorIcon, Id, Pos2, Rect, Stroke, Ui, vec2};
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_engine::selection_spacing::{Axis, SelectionSpacing, selection_spacing};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::FigApp;
|
||||
use crate::handles::{PINK, pink_label};
|
||||
use crate::i18n::tr;
|
||||
use crate::icons::Icon;
|
||||
use crate::widgets::{self, Lead};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct LayoutCache {
|
||||
document: u64,
|
||||
page: NodeId,
|
||||
doc: Arc<Document>,
|
||||
ids: Vec<NodeId>,
|
||||
frame: u64,
|
||||
layout: Arc<SelectionSpacing>,
|
||||
}
|
||||
|
||||
fn layout(app: &FigApp, ctx: &egui::Context, ids: &[NodeId]) -> Arc<SelectionSpacing> {
|
||||
let Ok(ds) = app.session.ds() else { return Arc::new(SelectionSpacing::default()) };
|
||||
let key = Id::new("selection_spacing_layout");
|
||||
let frame = ctx.cumulative_frame_nr();
|
||||
if let Some(mut cache) = ctx.data(|d| d.get_temp::<LayoutCache>(key))
|
||||
&& cache.document == ds.uid
|
||||
&& cache.page == ds.page
|
||||
&& cache.ids == ids
|
||||
&& (cache.frame == frame || Arc::ptr_eq(&cache.doc, &ds.doc))
|
||||
{
|
||||
if cache.frame != frame {
|
||||
cache.frame = frame;
|
||||
ctx.data_mut(|d| d.insert_temp(key, cache.clone()));
|
||||
}
|
||||
return cache.layout;
|
||||
}
|
||||
// Panel, input and drawing share one calculation per frame. If a command changes the
|
||||
// document during this frame, refresh next frame; unchanged snapshots also reuse it then.
|
||||
let layout = Arc::new(selection_spacing(&ds.doc, ids));
|
||||
ctx.data_mut(|d| {
|
||||
d.insert_temp(key, LayoutCache { document: ds.uid, page: ds.page, doc: ds.doc.clone(), ids: ids.to_vec(), frame, layout: layout.clone() });
|
||||
});
|
||||
layout
|
||||
}
|
||||
fn drag_id() -> Id {
|
||||
Id::new("selection_spacing_drag")
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Drag {
|
||||
ids: Vec<NodeId>,
|
||||
axis: Axis,
|
||||
start: Pos2,
|
||||
value: f64,
|
||||
uid: u64,
|
||||
page: NodeId,
|
||||
moved: bool,
|
||||
last_value: Option<f64>,
|
||||
}
|
||||
|
||||
pub fn panel(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId]) {
|
||||
let layout = layout(app, ui.ctx(), ids);
|
||||
if layout.horizontal.is_none() && layout.vertical.is_none() {
|
||||
return;
|
||||
}
|
||||
widgets::sublabels(ui, tr("Spacing"), None);
|
||||
let col = widgets::col_width(ui.available_width());
|
||||
crate::right::row(ui, 32.0, |ui| {
|
||||
for (axis, key, icon, tip) in [
|
||||
(&layout.horizontal, "horizontal", Icon::DistributeH, tr("Horizontal spacing")),
|
||||
(&layout.vertical, "vertical", Icon::DistributeV, tr("Vertical spacing")),
|
||||
] {
|
||||
let Some(axis) = axis else { continue };
|
||||
let edit = widgets::number_field(ui, Id::new(("selection_spacing", key)), Lead::Icon(icon, tip), axis.value, "", col, 1.0);
|
||||
app.field_run(edit, "arrange.spacing", |v| json!({"ids":ids,key:v.clamp(-1e6,1e6),"fromGesture":true}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Handle {
|
||||
value: f64,
|
||||
center: Pos2,
|
||||
hit: Rect,
|
||||
}
|
||||
|
||||
fn bounds(doc: &Document, ids: &[NodeId]) -> Option<figcraft_geom::Rect> {
|
||||
ids.iter().filter_map(|id| doc.world_bounds(*id)).reduce(|a, b| a.union(b))
|
||||
}
|
||||
|
||||
fn handles(app: &FigApp, axis: &Axis) -> Vec<Handle> {
|
||||
let Ok(doc) = app.session.doc() else { return vec![] };
|
||||
let cross = |b: figcraft_geom::Rect| if axis.vertical { (b.x0, b.x1) } else { (b.y0, b.y1) };
|
||||
let mut out = Vec::new();
|
||||
for pair in axis.tracks.windows(2) {
|
||||
let Some((a, b)) = pair.first().zip(pair.get(1)) else { continue };
|
||||
let (Some(ab), Some(bb)) = (bounds(doc, &a.ids), bounds(doc, &b.ids)) else { continue };
|
||||
let gap = axis.start(bb) - axis.end(ab);
|
||||
let middle = (axis.start(bb) + axis.end(ab)) * 0.5;
|
||||
// Intersect neighbouring tracks' cross-axis spans in a sweep: one handle per row/cell,
|
||||
// without comparing every object in a large column to every object in the next one.
|
||||
let mut aa: Vec<_> = a.ids.iter().filter_map(|id| doc.world_bounds(*id)).map(cross).collect();
|
||||
let mut bs: Vec<_> = b.ids.iter().filter_map(|id| doc.world_bounds(*id)).map(cross).collect();
|
||||
aa.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
bs.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let (mut i, mut j) = (0, 0);
|
||||
while let (Some(a), Some(b)) = (aa.get(i), bs.get(j)) {
|
||||
let (lo, hi) = (a.0.max(b.0), a.1.min(b.1));
|
||||
if hi > lo {
|
||||
let c = (lo + hi) * 0.5;
|
||||
let center = if axis.vertical { app.to_screen(c, middle) } else { app.to_screen(middle, c) };
|
||||
let size = if axis.vertical { vec2(24.0, 10.0) } else { vec2(10.0, 24.0) };
|
||||
out.push(Handle { value: gap, center, hit: Rect::from_center_size(center, size) });
|
||||
}
|
||||
if a.1 <= b.1 {
|
||||
i += 1;
|
||||
} else {
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn available(app: &FigApp) -> bool {
|
||||
app.ui.right_tab == crate::RightTab::Design
|
||||
&& app.ui.picker.is_none()
|
||||
&& app.session.tool.tool == figcraft_engine::ToolId::Move
|
||||
&& !app.session.tool.busy()
|
||||
&& app.session.ds().is_ok_and(|d| d.text_edit.is_none() && d.vector_edit.is_none())
|
||||
}
|
||||
|
||||
pub fn cancel(app: &mut FigApp, ctx: &egui::Context) -> bool {
|
||||
let drag = ctx.data(|d| d.get_temp::<Drag>(drag_id()));
|
||||
if let Some(drag) = &drag {
|
||||
figcraft_engine::selection_spacing::cancel_gesture(&mut app.session, drag.uid);
|
||||
ctx.data_mut(|d| d.remove::<Drag>(drag_id()));
|
||||
}
|
||||
drag.is_some()
|
||||
}
|
||||
|
||||
/// 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>(drag_id())) {
|
||||
let same = app.session.ds().is_ok_and(|d| d.uid == drag.uid && d.page == drag.page && d.selection == drag.ids);
|
||||
if !same || !available(app) {
|
||||
cancel(app, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Called before canvas pointer dispatch, also when the pointer leaves the canvas/window.
|
||||
pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Option<Pos2>, hovered: bool, space: bool) -> bool {
|
||||
let dragging = ctx.data(|d| d.get_temp::<Drag>(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);
|
||||
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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.set_cursor_icon(if drag.axis.vertical { CursorIcon::ResizeVertical } else { CursorIcon::ResizeHorizontal });
|
||||
if !down {
|
||||
let _ = app.session.commit_interaction();
|
||||
ctx.data_mut(|d| d.remove::<Drag>(drag_id()));
|
||||
} else {
|
||||
ctx.data_mut(|d| d.insert_temp(drag_id(), drag));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if !hovered || space || !available(app) || ctx.input(|i| i.modifiers.command) {
|
||||
return false;
|
||||
}
|
||||
let Some(p) = pos else { return false };
|
||||
let ids = app.session.selection();
|
||||
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) {
|
||||
if !h.hit.contains(p) {
|
||||
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));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn draw(app: &FigApp, painter: &egui::Painter) {
|
||||
let ctx = painter.ctx();
|
||||
let drag = ctx.data(|d| d.get_temp::<Drag>(drag_id()));
|
||||
if !available(app) && drag.is_none() {
|
||||
return;
|
||||
}
|
||||
let Ok(ds) = app.session.ds() else { return };
|
||||
let hover = ctx.input(|i| i.pointer.hover_pos());
|
||||
let Some(b) = bounds(&ds.doc, &ds.selection) else { return };
|
||||
let rect = Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1)).expand(12.0);
|
||||
if drag.is_none() && !hover.is_some_and(|p| rect.contains(p)) {
|
||||
return;
|
||||
}
|
||||
let axes = if let Some(d) = &drag {
|
||||
vec![d.axis.clone()]
|
||||
} else {
|
||||
let layout = layout(app, ctx, &ds.selection);
|
||||
[layout.horizontal.clone(), layout.vertical.clone()].into_iter().flatten().collect()
|
||||
};
|
||||
for axis in axes {
|
||||
for h in handles(app, &axis) {
|
||||
let hot = hover.is_some_and(|p| h.hit.contains(p)) || drag.is_some();
|
||||
let by = if axis.vertical { vec2(10.0, 0.0) } else { vec2(0.0, 10.0) };
|
||||
painter.line_segment([h.center - by, h.center + by], Stroke::new(if hot { 3.0 } else { 2.0 }, PINK));
|
||||
if hot {
|
||||
let at = h.center + if axis.vertical { vec2(18.0, 0.0) } else { vec2(0.0, -22.0) };
|
||||
pink_label(painter, widgets::fmt_num(h.value), at);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cache_tests {
|
||||
use super::*;
|
||||
use crate::NoServices;
|
||||
use figcraft_engine::Session;
|
||||
|
||||
fn setup() -> (FigApp, egui::Context, Vec<NodeId>) {
|
||||
let mut session = Session::new();
|
||||
let mut ids = Vec::new();
|
||||
for x in [0, 80, 160] {
|
||||
ids.push(NodeId(session.execute("shape.rectangle", &json!({"x":x,"width":50,"height":50})).unwrap()["id"].as_u64().unwrap()));
|
||||
}
|
||||
session.execute("select.set", &json!({"ids":ids})).unwrap();
|
||||
(FigApp::new(session, Box::new(NoServices)), egui::Context::default(), ids)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_is_shared_within_a_frame_and_refreshed_after_a_document_edit() {
|
||||
let (mut app, ctx, ids) = setup();
|
||||
let mut cached = None;
|
||||
let mut output = ctx.run_ui(Default::default(), |_| {
|
||||
let first = layout(&app, &ctx, &ids);
|
||||
assert_eq!(first.horizontal.as_ref().unwrap().value, Some(30.0));
|
||||
assert!(Arc::ptr_eq(&first, &layout(&app, &ctx, &ids)));
|
||||
cached = Some(first);
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
let mut output = ctx.run_ui(Default::default(), |_| {
|
||||
let first = layout(&app, &ctx, &ids);
|
||||
assert!(Arc::ptr_eq(cached.as_ref().unwrap(), &first), "unchanged frames reuse the calculation");
|
||||
app.set_props(&[ids[1]], json!({"x":90}));
|
||||
assert!(Arc::ptr_eq(&first, &layout(&app, &ctx, &ids)), "a mid-frame edit waits until the next frame");
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
let mut output = ctx.run_ui(Default::default(), |_| {
|
||||
let next = layout(&app, &ctx, &ids);
|
||||
assert!(!Arc::ptr_eq(cached.as_ref().unwrap(), &next));
|
||||
assert_eq!(next.horizontal.as_ref().unwrap().value, None);
|
||||
assert!(Arc::ptr_eq(&next, &layout(&app, &ctx, &ids)));
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selection_or_document_switch_invalidates_the_layout() {
|
||||
let (mut app, ctx, ids) = setup();
|
||||
let mut output = ctx.run_ui(Default::default(), |_| {
|
||||
let all = layout(&app, &ctx, &ids);
|
||||
let subset = layout(&app, &ctx, &ids[..2]);
|
||||
assert!(!Arc::ptr_eq(&all, &subset));
|
||||
assert_eq!(subset.horizontal.as_ref().unwrap().tracks.len(), 2);
|
||||
app.session.execute("file.new", &json!({})).unwrap();
|
||||
let other = layout(&app, &ctx, &ids[..2]);
|
||||
assert!(!Arc::ptr_eq(&subset, &other));
|
||||
assert!(other.horizontal.is_none() && other.vertical.is_none());
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
//! Real field and canvas gestures for #402; distances are checked in the engine document.
|
||||
use crate::layers_drag_tests::Ui;
|
||||
use egui::{Event, Id, Key, Modifiers, Pos2, vec2};
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
fn scene(grid: bool) -> (Ui, Vec<NodeId>) {
|
||||
let mut s = Session::new();
|
||||
let mut ids = vec![];
|
||||
for row in 0..if grid { 2 } else { 1 } {
|
||||
for col in 0..3 {
|
||||
let id = NodeId(
|
||||
s.execute("shape.rectangle", &json!({"x":100+col*80,"y":100+row*100,"width":50,"height":60})).unwrap()["id"].as_u64().unwrap(),
|
||||
);
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
s.execute("select.set", &json!({"ids":ids})).unwrap();
|
||||
let u = Ui::new(s);
|
||||
(u, ids)
|
||||
}
|
||||
fn xy(u: &Ui, id: NodeId) -> (f64, f64) {
|
||||
let b = u.app.session.doc().unwrap().world_bounds(id).unwrap();
|
||||
(b.x0, b.y0)
|
||||
}
|
||||
fn key(u: &mut Ui, k: Key) {
|
||||
u.frame(vec![
|
||||
Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE },
|
||||
Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE },
|
||||
]);
|
||||
u.frame(vec![]);
|
||||
}
|
||||
fn drag(u: &mut Ui, p: Pos2, by: egui::Vec2, release: bool) {
|
||||
u.frame(vec![Event::PointerMoved(p)]);
|
||||
u.button(p, true);
|
||||
for i in 1..=4 {
|
||||
u.frame(vec![Event::PointerMoved(p + by * i as f32 / 4.)]);
|
||||
}
|
||||
if release {
|
||||
u.button(p + by, false);
|
||||
u.frame(vec![]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spacing_fields_type_scrub_and_keep_one_undo_step() {
|
||||
let (mut u, ids) = scene(true);
|
||||
let field = Id::new(("selection_spacing", "horizontal"));
|
||||
let p = u.ctx.read_response(field.with("field")).unwrap().rect.center();
|
||||
let history = u.app.session.undo_depth().0;
|
||||
u.button(p, true);
|
||||
u.button(p, false);
|
||||
u.frame(vec![]);
|
||||
u.frame(vec![Event::Text("45.5".into())]);
|
||||
key(&mut u, Key::Enter);
|
||||
assert_eq!(xy(&u, ids[1]), (195.5, 100.));
|
||||
assert_eq!(xy(&u, ids[4]), (195.5, 200.));
|
||||
assert_eq!(u.app.session.undo_depth().0, history + 1);
|
||||
u.app.run("edit.undo", json!({}));
|
||||
u.frame(vec![]);
|
||||
let lead = u.ctx.read_response(field.with("lead")).unwrap().rect.center();
|
||||
drag(&mut u, lead, vec2(16., 0.), true);
|
||||
assert_eq!(xy(&u, ids[1]), (196., 100.));
|
||||
assert_eq!(u.app.session.undo_depth().0, history + 1);
|
||||
u.app.run("edit.undo", json!({}));
|
||||
assert_eq!(xy(&u, ids[1]), (180., 100.));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canvas_gap_drag_keeps_selection_and_undo_redo_for_both_grid_axes() {
|
||||
for (vertical, point) in [(false, (165., 130.)), (true, (125., 180.))] {
|
||||
let (mut u, ids) = scene(true);
|
||||
let before = u.app.session.undo_depth().0;
|
||||
let p = u.app.to_screen(point.0, point.1);
|
||||
let output = u.frame_output(vec![Event::PointerMoved(p)]);
|
||||
assert_eq!(output.platform_output.cursor_icon, if vertical { egui::CursorIcon::ResizeVertical } else { egui::CursorIcon::ResizeHorizontal });
|
||||
let z = u.app.session.ds().unwrap().view.zoom as f32;
|
||||
drag(&mut u, p, if vertical { vec2(0., 20. * z) } else { vec2(20. * z, 0.) }, true);
|
||||
assert_eq!(u.app.session.selection(), ids);
|
||||
assert_eq!(xy(&u, ids[1]), if vertical { (180., 100.) } else { (200., 100.) });
|
||||
assert_eq!(xy(&u, ids[3]), if vertical { (100., 220.) } else { (100., 200.) });
|
||||
assert_eq!(u.app.session.undo_depth().0, before + 1);
|
||||
u.app.run("edit.undo", json!({}));
|
||||
assert_eq!(xy(&u, ids[4]), (180., 200.));
|
||||
u.app.run("edit.redo", json!({}));
|
||||
assert_eq!(xy(&u, ids[4]), if vertical { (180., 220.) } else { (200., 200.) });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_rolls_back_and_an_outside_release_finishes_the_gesture() {
|
||||
let (mut u, ids) = scene(false);
|
||||
let before = u.app.session.undo_depth().0;
|
||||
// A canvas handle takes the keyboard from a previously focused panel field.
|
||||
let field = u.ctx.read_response(Id::new(("selection_spacing", "horizontal")).with("field")).unwrap().rect.center();
|
||||
u.button(field, true);
|
||||
u.button(field, false);
|
||||
u.frame(vec![]);
|
||||
let p = u.app.to_screen(165., 130.);
|
||||
let z = u.app.session.ds().unwrap().view.zoom as f32;
|
||||
drag(&mut u, p, vec2(20. * z, 0.), false);
|
||||
assert_eq!(xy(&u, ids[1]), (200., 100.));
|
||||
key(&mut u, Key::Escape);
|
||||
u.button(p + vec2(20. * z, 0.), false);
|
||||
u.frame(vec![]);
|
||||
assert_eq!(xy(&u, ids[1]), (180., 100.));
|
||||
assert_eq!(u.app.session.undo_depth().0, before);
|
||||
assert_eq!(u.app.session.selection(), ids);
|
||||
drag(&mut u, p, vec2(20. * z, 0.), false);
|
||||
// Leave the window while held; releasing with no pointer position still commits once.
|
||||
u.frame(vec![Event::PointerGone]);
|
||||
u.frame(vec![Event::PointerButton {
|
||||
pos: Pos2::new(-1., -1.),
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: false,
|
||||
modifiers: Modifiers::NONE,
|
||||
}]);
|
||||
u.frame(vec![]);
|
||||
assert_eq!(u.app.session.undo_depth().0, before + 1);
|
||||
assert!(u.app.session.ds().unwrap().interaction.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_documents_cancels_only_the_spacing_documents_gesture() {
|
||||
let (mut u, ids) = scene(false);
|
||||
let p = u.app.to_screen(165., 130.);
|
||||
let z = u.app.session.ds().unwrap().view.zoom as f32;
|
||||
drag(&mut u, p, vec2(20. * z, 0.), 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!(xy(&u, ids[1]), (180., 100.));
|
||||
assert!(u.app.session.ds().unwrap().interaction.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_click_is_a_noop_and_modifiers_can_be_held_before_pressing() {
|
||||
let (mut u, ids) = scene(false);
|
||||
let p = u.app.to_screen(165., 130.);
|
||||
let before = u.app.session.undo_depth().0;
|
||||
u.frame(vec![Event::PointerMoved(p)]);
|
||||
u.button(p, true);
|
||||
u.button(p, false);
|
||||
u.frame(vec![]);
|
||||
assert_eq!(u.app.session.undo_depth().0, before);
|
||||
for (modifiers, delta, gap) in [(Modifiers::SHIFT, 17., 50.), (Modifiers::ALT, 17.25, 47.3)] {
|
||||
let (mut u, ids) = scene(false);
|
||||
let p = u.app.to_screen(165., 130.);
|
||||
let z = u.app.session.ds().unwrap().view.zoom as f32;
|
||||
u.frame_with(vec![Event::PointerMoved(p)], modifiers);
|
||||
u.frame_with(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers }], modifiers);
|
||||
for k in 1..=4 {
|
||||
u.frame_with(vec![Event::PointerMoved(p + vec2(delta * z * k as f32 / 4., 0.))], modifiers);
|
||||
}
|
||||
u.frame_with(
|
||||
vec![Event::PointerButton { pos: p + vec2(delta * z, 0.), button: egui::PointerButton::Primary, pressed: false, modifiers }],
|
||||
modifiers,
|
||||
);
|
||||
u.frame(vec![]);
|
||||
assert!((xy(&u, ids[1]).0 - 150. - gap).abs() < 1e-6);
|
||||
}
|
||||
assert_eq!(xy(&u, ids[1]), (180., 100.));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_while_dragging_cancels_the_gesture_without_undoing_an_earlier_edit() {
|
||||
let (mut u, ids) = scene(false);
|
||||
let before = u.app.session.undo_depth().0;
|
||||
let p = u.app.to_screen(165., 130.);
|
||||
let z = u.app.session.ds().unwrap().view.zoom as f32;
|
||||
drag(&mut u, p, vec2(20. * z, 0.), false);
|
||||
let m = Modifiers::COMMAND;
|
||||
u.frame_with(
|
||||
vec![
|
||||
Event::Key { key: Key::Z, physical_key: None, pressed: true, repeat: false, modifiers: m },
|
||||
Event::Key { key: Key::Z, physical_key: None, pressed: false, repeat: false, modifiers: m },
|
||||
],
|
||||
m,
|
||||
);
|
||||
u.button(p + vec2(20. * z, 0.), false);
|
||||
u.frame(vec![]);
|
||||
assert_eq!(u.app.session.undo_depth().0, before);
|
||||
assert_eq!(xy(&u, ids[1]), (180., 100.));
|
||||
assert_eq!(u.app.session.selection(), ids);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stationary_gap_drag_does_not_change_the_document_again() {
|
||||
let (mut u, ids) = scene(false);
|
||||
let p = u.app.to_screen(165., 130.);
|
||||
let z = u.app.session.ds().unwrap().view.zoom as f32;
|
||||
drag(&mut u, p, vec2(20. * z, 0.), false);
|
||||
assert_eq!(xy(&u, ids[1]), (200., 100.));
|
||||
let revision = u.app.session.ds().unwrap().revision;
|
||||
let doc = u.app.session.ds().unwrap().doc.clone();
|
||||
for _ in 0..5 {
|
||||
u.frame(vec![]);
|
||||
u.frame(vec![Event::PointerMoved(p + vec2(20.2 * z, 0.))]);
|
||||
}
|
||||
assert_eq!(u.app.session.ds().unwrap().revision, revision);
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &u.app.session.ds().unwrap().doc));
|
||||
u.button(p + vec2(20.2 * z, 0.), false);
|
||||
assert_eq!(xy(&u, ids[1]), (200., 100.));
|
||||
}
|
||||
@@ -228,6 +228,9 @@ pub fn run_binding(app: &mut FigApp, id: &str) {
|
||||
}
|
||||
|
||||
fn key_event(app: &mut FigApp, key: Key, physical: Option<Key>, m: Modifiers, repeat: bool, now: f64, ctx: &Context) -> bool {
|
||||
if key == Key::Escape && crate::selection_spacing::cancel(app, ctx) {
|
||||
return true;
|
||||
}
|
||||
if key_box(app, key, m) {
|
||||
return true;
|
||||
}
|
||||
@@ -251,6 +254,9 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option<Key>, m: Modifiers, re
|
||||
return true;
|
||||
}
|
||||
if !repeat || matches!(b.id, "edit.undo" | "edit.redo" | "view.zoomIn" | "view.zoomOut") {
|
||||
if matches!(b.id, "edit.undo" | "edit.redo") && crate::selection_spacing::cancel(app, ctx) {
|
||||
return true;
|
||||
}
|
||||
run_binding(app, b.id);
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -188,3 +188,12 @@ fn shortcuts_panel_opens_switches_tabs_and_searches() {
|
||||
u.app.run("help.shortcuts", json!({}));
|
||||
assert!(!u.app.ui.shortcuts_panel, "the command toggles it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shortcuts_panel_does_not_show_on_the_home_screen() {
|
||||
let mut u = Ui::new(Session::empty());
|
||||
u.app.ui.home = true;
|
||||
u.app.ui.shortcuts_panel = true;
|
||||
u.frame(vec![]);
|
||||
assert!(!u.app.ui.shortcuts_panel, "the panel closes on the home screen");
|
||||
}
|
||||
@@ -28,12 +28,12 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max));
|
||||
}
|
||||
}
|
||||
let buttons_w = if app.ui.title_bar == TitleBar::Custom { window_buttons(ui, rect) } else { 0.0 };
|
||||
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.
|
||||
let hr = Rect::from_min_size(pos2(x, rect.min.y), vec2(44.0, HEIGHT));
|
||||
let hresp = ui.interact(hr, ui.id().with("home"), Sense::click()).tip(tr("Home"));
|
||||
let home_active = app.ui.home || !app.has_files();
|
||||
let home_active = (app.ui.home || !app.has_files()) && app.ui.present.is_none();
|
||||
crate::a11y::tab(&hresp, tr("Home"), home_active);
|
||||
if home_active {
|
||||
ui.painter().rect_filled(hr, CornerRadius::ZERO, t.tab_active);
|
||||
@@ -42,6 +42,9 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
|
||||
}
|
||||
icons::paint(ui.painter(), Rect::from_center_size(hr.center(), vec2(16.0, 16.0)), Icon::Home, t.icon);
|
||||
if hresp.clicked() {
|
||||
if let Some(p) = app.ui.present.take() {
|
||||
app.ui.paused_present = Some(p);
|
||||
}
|
||||
app.ui.home = true;
|
||||
}
|
||||
x += 44.0;
|
||||
@@ -52,7 +55,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
|
||||
let mut detach = None;
|
||||
let several = !ui.ctx().embed_viewports();
|
||||
for (i, d) in app.session.docs.iter().enumerate().filter(|(_, d)| app.shows(d.uid)) {
|
||||
let active = i == app.session.active && !app.ui.home;
|
||||
let active = i == app.session.active && !app.ui.home && app.ui.present.is_none();
|
||||
let name = d.doc.name.clone();
|
||||
// In a live session: a mark after the name says whether others get in.
|
||||
let live = live_mark(app, d.uid);
|
||||
@@ -132,6 +135,41 @@ 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));
|
||||
}
|
||||
// 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 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() {
|
||||
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"));
|
||||
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);
|
||||
}
|
||||
} else if resp.clicked() && !active {
|
||||
resume_presentation(app);
|
||||
}
|
||||
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));
|
||||
}
|
||||
// New tab.
|
||||
let nr = Rect::from_center_size(pos2(x + 20.0, rect.center().y), vec2(28.0, 28.0));
|
||||
let nresp = ui.interact(nr, ui.id().with("newtab"), Sense::click()).tip(crate::shortcuts::tip(tr("New design file"), "file.new"));
|
||||
@@ -140,6 +178,9 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
|
||||
}
|
||||
icons::paint(ui.painter(), nr.shrink(7.0), Icon::Plus, t.icon);
|
||||
if nresp.clicked() {
|
||||
if let Some(p) = app.ui.present.take() {
|
||||
app.ui.paused_present = Some(p);
|
||||
}
|
||||
app.run("file.new", json!({}));
|
||||
app.ui.home = false;
|
||||
}
|
||||
@@ -155,6 +196,9 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
|
||||
app.run(cmd, json!({}));
|
||||
}
|
||||
if let Some(i) = switch {
|
||||
if let Some(p) = app.ui.present.take() {
|
||||
app.ui.paused_present = Some(p);
|
||||
}
|
||||
app.run("doc.switch", json!({"index": i}));
|
||||
app.ui.home = false;
|
||||
}
|
||||
@@ -180,12 +224,29 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
|
||||
} else {
|
||||
app.run("file.close", json!({"index": i}));
|
||||
}
|
||||
if app.ui.present_uid.is_some_and(|uid| !app.session.docs.iter().any(|d| d.uid == uid)) {
|
||||
app.ui.present = None;
|
||||
app.ui.paused_present = None;
|
||||
app.ui.present_uid = None;
|
||||
}
|
||||
}
|
||||
let _ = Color32::TRANSPARENT;
|
||||
let _ = Align2::LEFT_TOP;
|
||||
});
|
||||
}
|
||||
|
||||
fn resume_presentation(app: &mut FigApp) {
|
||||
let Some(uid) = app.ui.present_uid else { return };
|
||||
let Some(index) = app.session.docs.iter().position(|d| d.uid == uid) else {
|
||||
app.ui.paused_present = None;
|
||||
app.ui.present_uid = None;
|
||||
return;
|
||||
};
|
||||
app.run("doc.switch", json!({"index": index}));
|
||||
app.ui.home = false;
|
||||
app.ui.present = app.ui.paused_present.take();
|
||||
}
|
||||
|
||||
/// What the tab of document `uid` says about its live session: others get in (`true`: a session
|
||||
/// this app hosts or joined, a file shared online), only you do (`false`: a file of yours on a
|
||||
/// server, not shared now), or it has none.
|
||||
@@ -195,7 +256,7 @@ pub fn live_mark(app: &FigApp, uid: u64) -> Option<bool> {
|
||||
}
|
||||
|
||||
/// Minimise, maximise/restore and close at the right end of the title bar; returns their width.
|
||||
fn window_buttons(ui: &mut Ui, bar: Rect) -> f32 {
|
||||
fn window_buttons(app: &mut FigApp, ui: &mut Ui, bar: Rect) -> f32 {
|
||||
const W: f32 = 46.0;
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let max = ui.input(|i| i.viewport().maximized.unwrap_or(false));
|
||||
@@ -223,7 +284,11 @@ fn window_buttons(ui: &mut Ui, bar: Rect) -> f32 {
|
||||
}
|
||||
icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), icon, col);
|
||||
if resp.clicked() {
|
||||
ui.ctx().send_viewport_cmd(cmd);
|
||||
if close {
|
||||
crate::windows::request_close(app, ui.ctx());
|
||||
} else {
|
||||
ui.ctx().send_viewport_cmd(cmd);
|
||||
}
|
||||
}
|
||||
}
|
||||
W * 3.0
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
use egui::{Event, PointerButton, Pos2, RawInput, Rect, ResizeDirection, ViewportCommand, ViewportId, pos2, vec2};
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{FigApp, NoServices, TitleBar};
|
||||
|
||||
@@ -76,7 +77,23 @@ fn window_buttons_minimise_maximise_and_close() {
|
||||
let mut u = Ui::new(TitleBar::Custom);
|
||||
assert!(u.click(window_button(0)).contains(&ViewportCommand::Minimized(true)));
|
||||
assert!(u.click(window_button(1)).contains(&ViewportCommand::Maximized(true)));
|
||||
assert!(u.click(window_button(2)).contains(&ViewportCommand::Close));
|
||||
u.click(window_button(2));
|
||||
assert!(u.frame(vec![]).contains(&ViewportCommand::Close));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn titlebar_close_asks_over_the_presenter_when_a_file_is_unsaved() {
|
||||
let mut u = Ui::new(TitleBar::Custom);
|
||||
let frame = u.app.session.execute("node.create", &json!({"type": "frame", "width": 402, "height": 874})).unwrap()["id"].clone();
|
||||
u.app.session.execute("proto.present", &json!({"start": frame})).unwrap();
|
||||
u.app.handle_ui_requests();
|
||||
u.frame(vec![]);
|
||||
assert!(u.app.ui.present.is_some());
|
||||
|
||||
let commands = u.click(window_button(2));
|
||||
assert!(!commands.contains(&ViewportCommand::Close));
|
||||
assert!(u.app.ui.confirm_close);
|
||||
assert!(u.ctx.memory(|memory| memory.area_rect(egui::Id::new(Some(crate::i18n::tr("Unsaved changes")))).is_some()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -304,7 +304,12 @@ pub fn on_close_request(app: &mut FigApp, ctx: &Context) {
|
||||
if app.windows.current == 0 && (unsaved || !app.windows.list.is_empty()) {
|
||||
ctx.send_viewport_cmd(ViewportCommand::CancelClose);
|
||||
}
|
||||
if unsaved {
|
||||
request_close(app, ctx);
|
||||
}
|
||||
|
||||
/// The custom title bar's Close button uses the same unsaved-file check as a system close event.
|
||||
pub(crate) fn request_close(app: &mut FigApp, ctx: &Context) {
|
||||
if !app.unsaved().is_empty() {
|
||||
app.ui.confirm_close = true;
|
||||
ctx.request_repaint();
|
||||
} else {
|
||||
@@ -329,7 +334,7 @@ pub(crate) fn confirm_close(app: &mut FigApp, ctx: &Context) {
|
||||
}
|
||||
let t = crate::theme::Tokens::get(ctx);
|
||||
let (mut open, mut discard, mut cancel) = (true, false, false);
|
||||
crate::dialogs::window(tr("Unsaved changes"), ctx).open(&mut open).show(ctx, |ui| {
|
||||
crate::dialogs::window(tr("Unsaved changes"), ctx).order(egui::Order::Foreground).open(&mut open).show(ctx, |ui| {
|
||||
ui.set_width(340.0);
|
||||
crate::widgets::label(ui, &trf("Unsaved changes in {names}. Close the window and lose them?", &[("names", &names.join(", "))]), t.text);
|
||||
ui.add_space(12.0);
|
||||
|
||||
@@ -45,6 +45,25 @@ fn names(app: &FigApp) -> Vec<String> {
|
||||
app.session.docs.iter().map(|d| d.doc.name.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_a_window_in_the_presenter_shows_the_unsaved_changes_dialog() {
|
||||
let mut session = Session::new();
|
||||
let frame_id = session.execute("node.create", &json!({"type": "frame", "width": 402, "height": 874})).unwrap()["id"].clone();
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = FigApp::new(session, Box::new(NoServices));
|
||||
frame(&ctx, &mut app, false);
|
||||
app.session.execute("proto.present", &json!({"start": frame_id})).unwrap();
|
||||
app.handle_ui_requests();
|
||||
frame(&ctx, &mut app, false);
|
||||
assert!(app.ui.present.is_some());
|
||||
|
||||
frame(&ctx, &mut app, true);
|
||||
assert!(app.ui.confirm_close);
|
||||
assert!(ctx.memory(|memory| memory.area_rect(egui::Id::new(Some(crate::i18n::tr("Unsaved changes")))).is_some()), "dialog must be on screen");
|
||||
frame(&ctx, &mut app, false);
|
||||
assert!(app.ui.present.is_some(), "asking to close does not dismiss the preview");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_hold_tabs_and_close_with_their_files() {
|
||||
let mut s = Session::empty();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Auto layout and constraints parity
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-17 · **Change:** minor (adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys only to frames, with warnings (#603); core editor leftovers: auto gap handles, wrapped-line alignment; behaviour suite written from the public documentation, every rule's status; fixes and the missing settings it found) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Auto layout is the most used layout feature in Figma and the one where small differences (a
|
||||
hugging frame one pixel off, text wrapping at another width) break real files. **Breadth ~98%,
|
||||
@@ -69,7 +69,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver
|
||||
| Arrow keys move children one place (flows, rows, columns, grid cells, into neighbours) | done | `al_keys_tests.rs` |
|
||||
| Constraints: left, right, left and right, centre, scale; top/bottom likewise | done | |
|
||||
| Layout grids: columns, rows, grid; stretch/left/centre/right; variables bound | done | |
|
||||
| Suggest auto layout / "Add auto layout" on a selection | done | |
|
||||
| Suggest auto layout / "Add auto layout" on a selection | done | adding auto layout to an empty frame keeps its size, Fixed (it collapsed to 20×20); a frame with content still hugs it, as does a new frame wrapped around a selection (#369). `node.set` with auto layout keys on a mix of layers applies them to the frames and warns about the rest instead of failing the whole call (#603) |
|
||||
| Real Figma files laid out identically | **not checked** | needs `.fig` or REST import of real files ([gaps](gaps.md) #1, #3) |
|
||||
| Performance: re-flow only where sizes or places changed | done | measured in `cargo xtask bench` (components at scale) |
|
||||
|
||||
@@ -77,6 +77,7 @@ Status: **pass** (a case in the suite holds), **fixed** (the suite found a diver
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-17 | minor | Community batch #753: adding auto layout to an empty frame keeps its size, Fixed, while a frame with content hugs it (#369); `node.set` applies auto layout keys to the frames among its targets and warns about the others, and a font style the family lacks warns with the nearest face (#603). Checked over MCP. Numbers unchanged |
|
||||
| 2026-10-13 | minor | Auto layout, on-canvas handles of automatic gaps (M2): the handle between spread rows or columns (and between items with an auto gap) shows the distance the layout gives the gap instead of the stored value, and dragging it makes the gap a fixed one from there, as typing a gap in the panel does; one undo step (`gap_handle_tests`). Before, the drag changed a value that had no effect. The documentation is silent on dragging an automatic gap. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Auto layout, wrapped-line alignment (M2): a wrapping layout's lines (rows of a horizontal one, columns of a vertical one) are packed where the cross alignment says (start, centre, end; before, they always sat at the start), stretch when every child fills across the flow, or take the free space between them (`contentAlign: spaceBetween`, the Design panel's Row/Column gap mode, the alignment pad lighting the whole cross axis). Read from REST JSON and `.fig`, written to `.fig`, `align-content` in CSS, an instance override with the other alignment settings. 13 hand-computed layout cases and command tests. Numbers unchanged. |
|
||||
| 2026-10-11 | minor | Behaviour suite from the public documentation (64 layout cases, 8 command tests). Fixed: padding floor, inside-only included strokes (and included by default), auto gaps stop at 0, grid `fr` like CSS `fr`, canvas stacking actually drawn and hit. Added: auto gaps evenly/around, vertical wrap, grid cell alignment, strokes and canvas stacking controls. Breadth 95% → 98%, ready 68% → 74%, remaining 6–12 h → 3–6 h |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Collaboration parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version
|
||||
history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model,
|
||||
@@ -12,10 +12,10 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|
||||
|
||||
| Feature | Figma | FigCraft | Hours |
|
||||
|---|---|---|---|
|
||||
| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Left: server archives beyond Linux | 25–55 |
|
||||
| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Left: server archives beyond Linux | 25–55 |
|
||||
| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors, selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 |
|
||||
| Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 |
|
||||
| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 |
|
||||
| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen; the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser)); `figcraft-server` share links per file with edit or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; the server refuses its edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 |
|
||||
| Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences; a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) |
|
||||
| Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Dev Mode compares a ready-for-dev layer with its state when marked) | — |
|
||||
| Branching and merging | yes (paid plans) | **done, local**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived); fills, strokes and effects merge item by item. Left: branches in live sessions; other lists (text runs, export settings) conflict as a whole | 1–2 |
|
||||
@@ -26,6 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-17 | minor | From community batch #735: server message limits follow `max_file_mb`, so files above 64 MiB publish and open, the client takes at most 1 GiB and the server warns about large limits with open publishing (#705); a damaged generation field no longer drops a log's flushed edits (#687); the web build joins a session from a `#join=` link in its URL (#712, unit-tested and built for wasm, not run in a browser). Percentages and hours unchanged |
|
||||
| 2026-10-14 | minor | Removing someone from a live session (M9, from the owner's use: whoever joined with the invite couldn't be put out short of ending the session for everyone): right-click a person's avatar ▸ "Remove <name> from session", or `collab.removePerson {client}`, when you host the session or own the file on a server (owner token). The hub closes their connection as the session's end for them (they keep their copy and don't reconnect) and bars their address from that file while the session or server runs, so the code or link they hold no longer gets them in; everyone else stays. Protocol addition within version 2 (`Admin removePeer`; older servers say they can't). Addresses are the only identity without accounts: people behind one address are barred together. Percentages and hours unchanged |
|
||||
| 2026-10-13 | minor | Live-session revert guard (M9, #198): `file.revert` is refused while its document belongs to a live session, including publishing, reconnecting and hosting before the welcome. The document, previews, branch tabs and session stay intact; leave the session before reverting a local save. Offline revert is unchanged. Scripted access and sync regressions in `live_access_tests`. Numbers unchanged. |
|
||||
| 2026-10-12 | minor | Your online files (M9): a file put on a server without its key stays its owner's after its tab closes or the app quits. The app records each one with the preferences (server, id, name, owner token, the owner's links, the codes others join with; at most 200; the token never in a command result, `ui.inspect`, a log or debug output) and Home ▸ **Online** lists them: open one as its owner (a new tab, or its tab comes to the front), Start / Stop sharing, Copy code / Copy link, Rename on the server, Remove from server (confirmed), Forget (the file stays on the server). Nothing is asked of a server until the section opens; then one request per server (`Admin owned`, a compatible addition within protocol version 2) says which files are still there, and a server that is offline or older leaves the list as recorded. Tabs of files in a live session carry a mark (shared, or open only to you), toasts about a file that isn't in front name it, and after a crash a recorded file reopens from its server as its owner, its recovery copy kept until its unsent changes are back on the server (or it is opened beside the file). `collab.files`, `collab.openFile`, `collab.forgetFile`; `collab.manageFile` takes its server and token from the record and gains `share` / `unshare`. Files are known per computer, not per person (no accounts): percentages and hours unchanged |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Components, variants, styles and variables parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and
|
||||
variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300
|
||||
@@ -19,14 +19,14 @@ accounts).
|
||||
| Component sets / variants, add variant, switch | done | |
|
||||
| Switching variants keeps overrides where names match and both variants had the same value; text layers keep theirs | done | |
|
||||
| Default variant: the top-left one (new instances, Assets thumbnail, generated code) | done | |
|
||||
| Variant property order (names rewritten), duplicate combinations reported | done | reordering through `component.editProperty {index}`; no drag in the panel yet |
|
||||
| Variant property order (names rewritten), duplicate combinations reported | done | drag a property in the Properties section to reorder it, one undo step (#679), or `component.editProperty {index}` |
|
||||
| Properties: boolean, text, instance swap (preferred values), variant | done | |
|
||||
| Slots (preferred components, fill menu, kept across updates) | done | Figma 2025 feature |
|
||||
| Exposed nested instances | done | hidden ones hide their properties |
|
||||
| Descriptions | done | |
|
||||
| Instance swap: panel, Alt-drop from Assets | done | the panel keeps overrides on same-named layers, an Alt-drop from Assets only text layers' |
|
||||
| Assets panel: folders by page, frame and slash names (fold away), thumbnails, search, drag into frames and auto layout slots | done | virtualized; `component.list` gives each one's `folder` |
|
||||
| Hidden from publishing: a leading `.` or `_` in the name | done | the name decides both ways (adding the prefix takes a published component out, removing it brings it back); no Hide when publishing menu item yet |
|
||||
| Hidden from publishing: a leading `.` or `_` in the name | done | the name decides both ways (adding the prefix takes a published component out, removing it brings it back); Hide when publishing in the Assets panel's context menu and Libraries ▸ Hide when publishing (see [ui-parity](ui-parity.md)) |
|
||||
| Instances pasted into other files follow their mains | done | missing main restorable |
|
||||
| Component playground (try properties without placing) | partial | variant, boolean and text properties in the Inspect tab and `component.playground`; instance swaps and slots have no control there yet (0.5–1 h) |
|
||||
| Real-world libraries QA | **not checked** | [gaps](gaps.md) #3 |
|
||||
@@ -48,7 +48,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|
||||
|---|---|---|
|
||||
| Publish a file's components, sets, styles and variables (per-asset versions) | done | as local library files |
|
||||
| Enable a library in a file; read-only linked copies | done | |
|
||||
| Library updates available; overrides kept | done | |
|
||||
| Library updates available; overrides kept | done | a library file saved again within two seconds of being read, at the same length, is read again (its time stamp can't prove it unchanged; #652) |
|
||||
| Team libraries: publish to a `figcraft-server` under the file, enable by a share link (a view link reads it), background update checks | done | [server.md](server.md#team-libraries); no team-wide library list or accounts ([gaps](gaps.md) #10) |
|
||||
| Library analytics | partial | instances per component and the files using them, as apps report them; nothing over time, no detach counts |
|
||||
| Swap library (instances, styles and variables to another library's of the same names, overrides kept) | done | what has no match stays and is listed; one undo step |
|
||||
@@ -76,6 +76,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-17 | minor | Variant properties reorder by dragging them in the Properties section, one undo step (#679; was "no drag in the panel yet"); a library file saved again right after it was read is read again, so a quick republish isn't missed (#652); the Hidden from publishing row no longer says the Hide when publishing item is missing (it landed 2026-10-12). Numbers unchanged |
|
||||
| 2026-10-17 | minor | Size and resizing overrides of layers inside instances (#731): a layer in an instance can hug, fill or keep a size of its own (group `resizing`: hug/fill/fixed with min and max; `size`), from the Layout panel, the canvas handles and `node.set`; they were refused, and imported ones were dropped at the first relayout. Kept through main edits of other properties, rebuilds, undo, save, `.fig` and REST import (sizes a `.fig` instance gives its layers and the REST `overrides` fields), `.fig` export and detach; reset per group and with Reset all changes (`instance_resizing_tests`). The row that listed them as not overridable was wrong. Numbers unchanged. |
|
||||
| 2026-10-14 | minor | Variables export in the browser: Export… ▸ Variables JSON / CSS custom properties did nothing in the web build, which has no save dialogs; it now hands the file over as a download (`Services::downloads` / `download`, `variables_export_ui_tests`). Import in the browser still needs a file picker, as do the other exports there. Numbers unchanged. |
|
||||
| 2026-10-13 | minor | Component playground (M3): with an instance, a component or a component set selected, the Inspect tab shows a Playground: a preview of the component and a control for each variant, boolean and text property. What is tried there changes the preview and the code in the Code section, never the file (no undo step, the canvas instance stays as it is); Reset goes back to the component's or the instance's own values. `component.playground {id?, properties?, language?, path?}` gives the same to scripts: the properties with their values, the code, and a PNG on request (`playground_tests`). Left: instance-swap and slot properties in the playground (the command takes an instance swap; the panel has no control for it), a code connect template's snippet with the tried values. Numbers unchanged. |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (missing fonts listed on open (#752); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (missing fonts listed on open (#752); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of
|
||||
`/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's
|
||||
@@ -17,9 +17,9 @@ and prototype interactions are still unchecked there.
|
||||
|
||||
| Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours |
|
||||
|---|---|---|---|---|---|
|
||||
| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Not read: connector labels, table cells beyond their own layers, filled areas with their own fills, per-point stroke joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs`, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 |
|
||||
| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 |
|
||||
| `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — |
|
||||
| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — |
|
||||
| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — |
|
||||
| `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — |
|
||||
| Local library files | team libraries in the cloud | yes (enable a `.figcraft` as a library) | yes (publish) | per-asset versions, updates with overrides kept | see [collaboration-parity](collaboration-parity.md) |
|
||||
|
||||
@@ -36,7 +36,7 @@ and prototype interactions are still unchecked there.
|
||||
| BMP | — | yes | | — |
|
||||
| MP4, MOV, WebM (video fills, paid plans) | yes | **no** | shortcut: decoding H.264/HEVC/VP9 needs non-Rust libraries (FFmpeg or each platform's media framework); presenter playback would reuse the animated GIF clock | 4–8 |
|
||||
| Images over 4096 px | downscaled to 4096 | kept | | — |
|
||||
| Pasted content from other apps | images, SVG, text, Figma clipboard | images, SVG, text (newest clipboard content wins) | no Figma clipboard (it carries Figma's own binary) | with `.fig` |
|
||||
| Pasted content from other apps | images, SVG, text, Figma clipboard | images, SVG, text (newest clipboard content wins), **layers copied from another design app, best effort** | Cmd/Ctrl+V reads an image copied in another app (under Wayland through the data-control protocol, not run on Linux here; Linux and BSD clipboard reads give up after 2 s). Copied design layers: the `.fig` canvas in the clipboard's HTML is read like a `.fig` file (with its warnings) and pasted as Paste pastes, in place or into the selected frame (`edit.pasteDesignLayers {html \| data}`, refused over 256 MB before decoding); text copied later pastes as text again. Tested with clipboard HTML made from our own `.fig` export (`design_paste_tests`), never with a real clipboard from other software | with `.fig` |
|
||||
|
||||
## Export
|
||||
|
||||
@@ -56,13 +56,16 @@ export limit, not a native document nesting limit.
|
||||
| WebP | — | yes (ours) | | — |
|
||||
| Copy as CSS / SVG / PNG / text / link | yes | yes | Copy as PNG: the whole selection at 2x | — |
|
||||
| Dev Mode code (CSS, iOS SwiftUI, Android Compose) | yes | yes, plus HTML and React | see [dev-mode-parity](dev-mode-parity.md) | — |
|
||||
| `.fig` ("Save local copy") | yes | **experimental** | the whole file; see [Export to `.fig`](#export-to-fig-experimental). Round trips through our own importer; written files checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width | 5–15 (non-colour bindings as bindings, checks of effects, images and prototypes, and against real files) |
|
||||
| `.fig` ("Save local copy") | yes | **experimental** | the whole file, also as a copy from File ▸ Save as… (the document keeps its `.figcraft` path and unsaved state); see [Export to `.fig`](#export-to-fig-experimental). Round trips through our own importer; written files checked by opening them in other software: layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft; resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width | 5–15 (non-colour bindings as bindings, checks of effects, images and prototypes, and against real files) |
|
||||
|
||||
### Export to `.fig` (experimental)
|
||||
|
||||
`file.export {format: "fig"}` (or a `path` ending in `.fig`), File ▸ Export… ▸ .fig (experimental),
|
||||
`figcraft-cli export <file> -o <out>.fig` (and `convert`) and MCP's `export {format: "fig"}` write
|
||||
the whole document. The writer (`crates/format/src/fig/export.rs`) works from the same public format
|
||||
the whole document; so do File ▸ Save as… and `file.save` / `file.saveAs` with a `.fig` path, as a
|
||||
copy: the document keeps its `.figcraft` path, unsaved changes and version preview, and a file an
|
||||
earlier build saved under a `.fig` name is never overwritten by Save (`save_format_tests`,
|
||||
`save_as_tests`). The writer (`crates/format/src/fig/export.rs`) works from the same public format
|
||||
notes as the import and inverts its mapping: the zip holds `canvas.fig` (the fig-kiwi header, then a
|
||||
raw-deflate binary schema and a raw-deflate message), `meta.json`, `thumbnail.png` and
|
||||
`images/<hash>`; `meta.json` names the file and gives the page colour and the thumbnail size. The
|
||||
@@ -163,6 +166,7 @@ each.
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-17 | minor | Missing fonts (#752): the fonts a file names that aren't installed (text layers, styled ranges, text styles, font family variables in any mode) are listed when the user opens it (once installed fonts are indexed and any import report is closed), from Text ▸ Missing fonts…, by `text.missingFonts` and by `figcraft-cli info`. The row claimed REST import warnings no code produced. |
|
||||
| 2026-10-17 | minor | The community batches #727 and #728 in the `.fig` row: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments (also REST), images in the message; budgets from the file's size; the journal keeps no file bytes; the import report. Not read cut to what's left. A `.fig` copy from Save as. Pasted content: layers copied from another design app, best effort (tested only with clipboard HTML made from our own export), and images under Wayland (not run there). All on synthetic files: summary and hours unchanged |
|
||||
| 2026-10-17 | minor | Hug on import (#732): auto layout frames a `.fig` records no primary-axis sizing for hug on that axis (auto layout sized that axis by its content before it could be fixed) instead of turning fixed, instances that record no auto layout take their main's (they came in without auto layout and fixed, so longer text overflowed them), REST frames whose sizing modes are left out hug (the documented default), and text hugs by its resize mode (auto width both axes, auto height its height; it came in fixed). Numbers unchanged |
|
||||
| 2026-10-17 | minor | Instance layers' own size and resizing (#731): a `.fig` import keeps the sizes an instance gives its layers and their hug/fill/fixed and min/max as overrides, a REST import reads the resizing fields of `overrides`, and a `.fig` export writes a layer's own size as an override (it went with the derived layout, which a relayout replaces). Numbers unchanged |
|
||||
| 2026-10-14 | minor | SVG/PDF depth overflow (#208) is checked before bounds and drawing; public checked APIs return the offending root/layer, commands and CLI report it, and no incomplete export replaces an existing file. Tested at depths 255/256/257, with inner roots, hidden branches, masks, raster fallbacks and multiple roots/pages. Numbers unchanged. |
|
||||
|
||||
+69
-24
@@ -1,6 +1,6 @@
|
||||
# Where FigCraft falls short of Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from
|
||||
doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The
|
||||
@@ -14,11 +14,11 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
||||
|
||||
| # | Gap | Kind | Hours | Doc |
|
||||
|---|---|---|---|---|
|
||||
| 1 | `.fig` files: best-effort import with editable vector networks and stand-ins for non-design layers landed, and an experimental export (round trips through our own importer; written files opened in other software, where layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft, instances keep their size and variables with modes and colour styles come across); real-file checks and the small tail left (**beta blocker** until checked on real files, gap 3) | File format | 5–10 | [file-format-parity](file-format-parity.md) |
|
||||
| 1 | `.fig` files: best-effort import with editable vector networks and stand-ins for non-design layers landed, then (2026-10-17) more fidelity on synthetic files, budgets for large files, an import report and paste from another design app; an experimental export, also as a copy from Save as (round trips through our own importer; written files opened in other software, where layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft, instances keep their size and variables with modes and colour styles come across); real-file checks and the small tail left (**beta blocker** until checked on real files, gap 3) | File format | 5–10 | [file-format-parity](file-format-parity.md) |
|
||||
| 2 | Multiplayer and sharing are a preview, without accounts (**beta blocker**) | Feature | 42–84 | [collaboration-parity](collaboration-parity.md) |
|
||||
| 3 | Nothing checked against real Figma files | Fidelity | 15–30 | [target-app-parity](target-app-parity.md) |
|
||||
| 4 | Interaction fidelity not compared tool by tool | UI/UX | 40–60 | [ui-parity](ui-parity.md) |
|
||||
| 5 | Localization: English, reviewed Russian, plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese drafts | Localization | 29–51 | [localization-parity](localization-parity.md) |
|
||||
| 5 | Localization: English, reviewed Russian, plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; Turkish and Swedish catalogs | Localization | 29–51 | [localization-parity](localization-parity.md) |
|
||||
| 6 | Plugins: widgets take clicks only, a self-hosted directory but no public catalogue; Figma's plugins don't run | Ecosystem | 8–26 | [target-app-parity](target-app-parity.md) |
|
||||
| 7 | CPU canvas where Figma uses the GPU | Hardware/performance | 30–60 | [hardware-parity](hardware-parity.md) |
|
||||
| 8 | Platform QA: Linux and the browser left (Windows pass done 2026-10-11) | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) |
|
||||
@@ -60,20 +60,43 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
||||
derived data for their layers, a scale factor), variables with modes and bindings, shared styles
|
||||
and variable-width strokes are written and read back; checked there: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there),
|
||||
like effects such as glass and noise, images and prototype interactions.
|
||||
- **Missing:** connector labels, elbowed or curved connectors without a saved path (drawn straight),
|
||||
table cells beyond their own layers, filled areas of a vector with their own fills, per-point stroke
|
||||
joins, variables bound to fields the editor can't bind, fixed or content-sized grid tracks and video
|
||||
fills aren't read; text lays out again with
|
||||
local fonts. Nothing has been checked against real-world files yet (gap 3). The export is checked
|
||||
only against our own importer: whether other apps open its files is unknown.
|
||||
- **Landed (2026-10-17, community batches #727 and #728, then #745):** the import keeps an
|
||||
instance's own identity and the cached copies of library components a file carries, applies
|
||||
instance overrides by key and order, lets local text values and instance overrides win over a
|
||||
shared text style, and reads grid track sizes (fixed, content-sized and fill), slot content,
|
||||
labels of connectors saved with their drawn path (text and background, as a static snapshot:
|
||||
the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers,
|
||||
vertical trim, per-point stroke joins, editable text on a path, image adjustments (the tint
|
||||
sign shared with the REST import) and images carried in the file's message; layers inside
|
||||
instances keep their own size and resizing as overrides (#731), and auto layout and text that
|
||||
record no sizing hug (#732). Large files open within budgets set from the file's own size (one
|
||||
value per compressed byte, 20M–128M values, lower ceilings on 32-bit targets; a 15.4 MB
|
||||
synthetic file with 300,000 layers opens whole). After a `.fig`, REST JSON or SVG import an
|
||||
import report lists what was left out or changed (translated where the catalog has the note;
|
||||
`file.open {data}` returns a `summary`), and imported file bytes stay out of the command
|
||||
journal. File ▸ Save as… to a `.fig` name writes an export copy and the document keeps its
|
||||
`.figcraft` path. Layers copied from another design app paste (`edit.pasteDesignLayers`, and
|
||||
Cmd/Ctrl+V when the clipboard's HTML holds a `.fig` canvas): read like a `.fig` file, with its
|
||||
warnings, size-checked (256 MB) before decoding.
|
||||
- **Missing:** elbowed or curved connectors without a saved path (drawn straight), labels of
|
||||
connectors without a saved snapshot, table cells beyond their own layers, filled areas of a
|
||||
vector with their own fills (#634, held for an owner decision on the document model), variables
|
||||
bound to fields the editor can't bind and video fills aren't read; text lays out again with
|
||||
local fonts. Nothing has been checked against real-world files yet (gap 3): the 2026-10-17
|
||||
changes were tested on files built in code, and paste from another design app on clipboard HTML
|
||||
made from our own export, never on a real clipboard. The export was checked in other software
|
||||
on our example documents and small test files only.
|
||||
- **Evidence:** fixtures generated in code (`crates/format/src/fig/tests.rs` and `tail_tests.rs`: kiwi
|
||||
round trips, every mapped feature, vector network blobs, truncation, bit flips and junk never panic); `crates/engine/src/fig_import_tests.rs`.
|
||||
2026-10-17: `crates/format/src/fig/{connector_label,slot,source_identity,text_style,text_list,text_paragraph,text_path,text_skip_ink,image_filter,auto_layout_sizing}_tests.rs`,
|
||||
`map_connector_snapshot_budget_tests.rs`, `crates/engine/src/{save_format,design_paste,instance_resizing}_tests.rs`,
|
||||
`crates/ui-egui/src/{import_report,save_as}_tests.rs`.
|
||||
Export: 60 generated documents round trip through the importer layer by layer, written files
|
||||
decode with the generic decoder and list parents first, the encoder agrees with the decoder,
|
||||
junk documents never panic (`crates/format/src/fig/export_tests.rs`,
|
||||
`crates/engine/src/fig_export_tests.rs`).
|
||||
- **Impact:** existing work can be brought across, but only real files will show how much of it.
|
||||
- **Estimate:** 5–10 h for the read tail (real-file fixes, connector labels, table cells); checking the export against real files a further 5–15 h (with gap 3).
|
||||
- **Estimate:** 5–10 h for the read tail (real-file fixes, table cells, connectors without a saved path); checking the export against real files a further 5–15 h (with gap 3). Unchanged on 2026-10-17: most of the read tail is real-file fixes, which nothing has measured yet.
|
||||
|
||||
### 2. Multiplayer and sharing are a preview, without accounts
|
||||
- **Missing:** accounts, teams and projects, per-person permissions, audio,
|
||||
@@ -93,8 +116,11 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
||||
`figcraft-server` keeps many files (snapshot plus log, compacted; restarts lose nothing
|
||||
confirmed), takes files published from the app and hands out share links that let people edit
|
||||
or only view; the desktop app and the browser reach it over `wss://` through a TLS reverse
|
||||
proxy, and releases ship it with a deployment guide (`docs/server.md`). But there are no
|
||||
accounts (a link is the access) (`docs/collab-protocol.md`).
|
||||
proxy, and releases ship it with a deployment guide (`docs/server.md`); server message limits
|
||||
follow `max_file_mb`, so files above 64 MiB publish and open (#705), a damaged generation field
|
||||
no longer drops a log's flushed edits (#687), and the browser build joins from a `#join=` link
|
||||
(#712: unit-tested, not run in a browser). But there are no accounts (a link is the access)
|
||||
(`docs/collab-protocol.md`).
|
||||
- **Impact:** Figma's defining feature; most teams use it every day. Solo designers can work
|
||||
without it.
|
||||
- **Estimate:** 42–84 h: accounts, teams and per-person permissions, shared comments and versions
|
||||
@@ -138,29 +164,43 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
||||
cursor for every tool and modifier, panel widths, dark pages (`field_tests`, `cursor_tests`,
|
||||
`cursors_tests`, `outline_options_tests`, `hide_publishing_tests`, `panel_width_tests`,
|
||||
`theme_pages_tests`).
|
||||
- **Landed (2026-10-17, community batches #733, #735, #736 and #729):** submenus open after a
|
||||
short hover, keep their place while the pointer heads for them (a safe triangle) and reopen
|
||||
closed (`menu_aim_tests`; timings tuned by feel, not measured); the Actions palette has tabs,
|
||||
ranked search over every command, a keyboard highlight and recents (`palette_tests`); menu items
|
||||
that need a value ask for it; live font preview on hover; the colour picker opens on a new fill
|
||||
or stroke; Shift-drag on a selected layer moves the selection; a double-click near a thin stroke
|
||||
enters vector edit; points dropped on a point merge; a draggable divider between Pages and
|
||||
Layers; Paste over and Paste properties from the keyboard; then (#753) Selection colors for mixed
|
||||
selections and smart-selection spacing fields and canvas handles (no reorder rings). Each was checked in the app (on
|
||||
Windows; the submenus by their author), none side by side with Figma, none on macOS.
|
||||
- **Evidence:** ROADMAP item 7 ("Left: tool by tool, panel by panel against the incumbent").
|
||||
- **Impact:** Figma users notice the feel within minutes; it decides whether they stay.
|
||||
- **Estimate:** 40–60 h. Detail in [`ui-parity.md`](ui-parity.md).
|
||||
|
||||
### 5. Localization: English, reviewed Russian, plus unreviewed drafts
|
||||
- **Missing:** native reviews of the Spanish, French, German, Brazilian Portuguese, Japanese,
|
||||
Korean and Simplified Chinese drafts and the Latin American Spanish overlay, the 25 newest strings
|
||||
in five drafts, Hindi, Arabic, Indonesian and Vietnamese, localized engine messages and number
|
||||
formatting; CJK UI text on the web without the craft-fonts CJK build.
|
||||
Korean, Simplified Chinese, Indonesian and Czech drafts and the Latin American Spanish
|
||||
overlay, the strings added since each catalog was drafted (1–5% of each), Hindi, Arabic and
|
||||
Vietnamese, localized engine messages and number formatting; CJK UI text on the web without the
|
||||
craft-fonts CJK build.
|
||||
- **Evidence (measured from source):** the layer landed 2026-10-11: catalogs in
|
||||
`crates/ui-egui/locales/` (1,371 UI keys plus 524 command labels and 29 menu wordings),
|
||||
Preferences ▸ Language, `pref.language`; `en.toml` is complete, and `es.toml`, `fr.toml`,
|
||||
`de.toml`, `pt-BR.toml`, `ja.toml`, `ko.toml` and `zh-Hans.toml` (2026-10-12) are machine drafts
|
||||
(all but `zh-Hans.toml` lack the 25 newest strings), with `es-419.toml` a Latin American overlay
|
||||
listing only what differs from Spanish (Spanish and Russian are held to completeness by the check; the
|
||||
others show English for strings added later). Russian adds all 2,165 current entries,
|
||||
reviewed and approved by VanTan, with one/few/many integer plurals.
|
||||
`crates/ui-egui/locales/` (measured 2026-10-17: 1,680 UI keys plus 561 command labels and 31
|
||||
menu wordings, 2,272 entries), Preferences ▸ Language, `pref.language`; `en.toml` is complete,
|
||||
Spanish is held to completeness by the check, and the French, German, Brazilian Portuguese,
|
||||
Japanese, Korean and Simplified Chinese machine drafts cover 95–99% (strings added since they
|
||||
were drafted show English), with `es-419.toml` a Latin American overlay listing only what
|
||||
differs from Spanish. Russian (reviewed and approved by VanTan, one/few/many integer plurals)
|
||||
covers 98.8%; Indonesian and Czech drafts, a Turkish catalog whose header says a native speaker
|
||||
reviewed it, and a Swedish catalog (with its command labels since 2026-10-17) cover 98.7–99.6% ([localization-parity](localization-parity.md#languages)).
|
||||
CJK UI text uses an installed CJK face the desktop app loads when something on screen needs it
|
||||
(`crates/ui-egui/src/cjk.rs`). Engine error messages and Dev Mode values stay English.
|
||||
- **Impact:** non-English designers get machine drafts nobody has reviewed yet, and English
|
||||
errors.
|
||||
- **Estimate:** 3–7 h per language plus native review, 4–6 h for engine messages and numbers;
|
||||
29–51 h for the key languages left and the drafts' follow-ups. Detail in
|
||||
29–51 h for the key languages left and the drafts' follow-ups (kept on 2026-10-17: the
|
||||
Indonesian draft takes 2–3 h off, about what bringing the drafts that slipped to 95% back to
|
||||
every string adds). Detail in
|
||||
[`localization-parity.md`](localization-parity.md); how to add one in
|
||||
[`translating.md`](translating.md).
|
||||
|
||||
@@ -215,7 +255,11 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
||||
PR (Windows 11, the desktop app driven over the control channel, the same workflow through MCP):
|
||||
eight bugs fixed, among them unsaved changes lost when the app was killed or crashed,
|
||||
Ctrl+arrows and Ctrl+Backspace acting on the whole line in text, scrubbed fields snapping back
|
||||
and skewed linear gradients.
|
||||
and skewed linear gradients. Linux and the BSDs (2026-10-17, #693): clipboard reads run on a
|
||||
helper thread that gives up after 2 s, and images copied in other apps paste under Wayland
|
||||
through the data-control protocol; compiled in CI, not run on a Linux desktop here. Wayland
|
||||
compositors without server-side decorations (GNOME) give the window winit's Adwaita controls
|
||||
(#592: checked by its author on GNOME; KDE not run).
|
||||
- **Impact:** unknown bugs on the platforms where FigCraft is most attractive (Linux has no Figma
|
||||
desktop app).
|
||||
- **Estimate:** 10–20 h, partly human (hardware).
|
||||
@@ -400,6 +444,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-17 | minor | The 2026-10-17 landings. Gap 1: a new Landed entry (community batches #727 and #728, then #745: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments, images in the message, instance layers' size and resizing overrides, Hug where nothing is recorded; budgets from the file's size; the import report; a `.fig` copy from Save as; paste from another design app), its Missing list cut to what is still left, and the wrong "whether other apps open its files is unknown" replaced by what was checked; all on synthetic files, so the beta blocker and hours stand. Gap 2: server limits, log recovery and web join links in the evidence. Gap 4: menus, the Actions palette and the editor fixes (#733, #735, #736, #729), then Selection colors and smart-selection spacing (#753) landed, none compared side by side. Gap 5: catalog counts re-measured (2,272 entries; drafts at 95–99%; Indonesian, Czech, Turkish and Swedish listed). Gap 8: the Linux and BSD clipboard changes and Wayland window controls, not run there by the maintainers. Ranking and hours unchanged |
|
||||
| 2026-10-15 | minor | Gap 7: bounded canvas overscan for smoother panning. Estimates and ranking unchanged. |
|
||||
| 2026-10-14 | minor | Russian catalog (M9), reviewed and approved by VanTan: all 2,165 current entries, Русский in Preferences and regional ru tags. Russian integer plurals use one/few/many; contributor display name VanTan added. Existing estimates unchanged. |
|
||||
| 2026-10-13 | minor | Gap 1: the experimental `.fig` export writes instance sizes (resized, filling an auto layout, scaled), variables with modes and bindings, shared styles and variable-width strokes, and the import reads them back. Checked by opening written files in other software: resized and scaled instances keep their size and lay their layers out for it, variables with their modes and colour bindings resolve there (a frame set to another mode shows that mode's colours, also inside instances), colour, text, effect and grid styles are listed, and variable-width strokes that are the same on both sides of the path draw with their width; number, string and boolean bindings and bindings on effects, layout guides and gradient stops come across as the values they give but did not show as bound there (their entries now also name the layer field they set, not yet checked there), and strokes wider on one side of the path are written as filled outlines, no longer editable strokes there (the stroke itself is kept as plugin data and our own import puts it back; not yet checked there). Hours and the ranking unchanged |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Hardware and platform parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is
|
||||
Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out
|
||||
@@ -14,7 +14,7 @@ since 2025). FigCraft renders the canvas on the CPU and presents it through wgpu
|
||||
|---|---|---|---|
|
||||
| Canvas rendering | GPU | CPU (tiny-skia), banded across cores, retained (re-renders only what an edit or pan touches), levels of detail below 50% zoom; shown through wgpu (WebGPU or WebGL2 in the browser) | 30–60 for a GPU path |
|
||||
| Effects (blurs, glass, noise) | GPU | CPU | in the above |
|
||||
| HiDPI / Retina | yes | yes (all) | — |
|
||||
| HiDPI / Retina | yes | yes (all); a canvas image rendered at the screen's density is placed on whole device pixels, so fractional pans, zooms and display scales don't blur it (#359; geometry tests, the snapping not judged by eye) | — |
|
||||
| Wide-gamut display (Display P3) | yes, on capable displays | colours stored and exported in P3; the screen shows sRGB: eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no colour space option, although wgpu 30 can present Display P3 or extended sRGB on Metal, DX12, Vulkan and WebGPU (see gaps #18). The colour picker warns about colours outside sRGB and View ▸ Soft-proof sRGB stripes them on the canvas | 2–5 |
|
||||
| Trackpad pinch zoom and two-finger pan | yes | yes (egui zoom delta, smooth scroll) | — |
|
||||
| Pen tablet pressure (pencil, Figma Draw brushes) | yes (forum reports of regressions in 2025) | Windows pens (winit pointer input) and browser styluses that send touch events with a force (an Apple Pencil in Safari); the Pencil records it as the stroke's width profile. A mouse, macOS and Linux tablets and pens sending only pointer events in a browser report none: constant width | 1–3 |
|
||||
@@ -41,7 +41,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import).
|
||||
|---|---|---|---|
|
||||
| macOS | desktop app (Intel and Apple silicon) | universal `.dmg`, signed and notarized (v0.2.0) | where nearly all QA happens |
|
||||
| Windows x64 / arm64 | desktop app | `.msi` and portable zip for x64, x86 and arm64 | built in CI; Windows: QA pass done 2026-10-11, findings in PR |
|
||||
| Linux | none (browser only) | AppImage, `.deb`, `.rpm`, Flatpak, tarball, x86_64 and aarch64; riscv64 tarball | CI tests run here |
|
||||
| Linux | none (browser only) | AppImage, `.deb`, `.rpm`, Flatpak, tarball, x86_64 and aarch64; riscv64 tarball | CI tests run here. Clipboard (also the BSDs): reads run on a helper thread that gives up after 2 s, and images copied in other apps paste under Wayland through the data-control protocol (#693). Under Wayland the app can't tell its window is minimised, so control requests that need a drawn frame may still wait there (#720). None of this run on a Linux desktop here. On a Wayland compositor without server-side decorations (GNOME) the window gets winit's Adwaita controls (#592: checked by its author in a GNOME Wayland session; KDE not run) |
|
||||
| FreeBSD | none | tarball | |
|
||||
| Web | primary (figma.com) | `figcraft-web` (WASM, WebGPU/WebGL2), web zip | no accessibility tree; file access through the browser; copy and paste through the system clipboard (copies written from the copy event, pastes read from the paste event; no pasting images from other apps) |
|
||||
| iOS / Android | apps: view, comment, mirror prototypes | none (the web build is not touch-first) | 20–40 h for a viewer |
|
||||
@@ -50,6 +50,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-17 | minor | Linux row: clipboard reads on Linux and the BSDs time out after 2 s on a helper thread, images copied in other apps paste under Wayland (arboard's data-control feature, #693), a minimised window can't be detected under Wayland, so frame-only control requests may still wait there (#720), and Wayland compositors without server-side decorations give the window Adwaita controls (#592, checked by its author on GNOME, not by the maintainers; KDE not run); compiled in CI, not run on a Linux desktop. HiDPI row: native-density canvas images placed on whole device pixels (#359, geometry tests; not judged by eye). Numbers unchanged |
|
||||
| 2026-10-14 | minor | Copy and paste in the browser: the web build never wrote to the system clipboard, so Cmd+V after Cmd+C pasted whatever text the clipboard held before (as a text layer) instead of the copied layers. Copies (layers, Copy as SVG/CSS/text/PNG, links) now go out through egui's output, which eframe writes during the copy event (`web_clipboard_tests`). Numbers unchanged. |
|
||||
| 2026-10-11 | minor | Windows QA pass (gap 8): everyday workflows on Windows 11 through the desktop app and MCP; fixed: unsaved changes lost when the app was killed or crashed (recovery copies every few seconds, reopened at the next start), Ctrl+arrows/Backspace/Delete acting on the whole line in text (now by word; Ctrl+Home/End to the text's ends), scrubbed fields snapping back on release, typing on a field Tab reached, Escape not closing dialogs, skewed or vanishing linear gradients, hints cut off at the panel edge, the window-close question in English in the Spanish interface. Percentages and hours unchanged |
|
||||
| 2026-10-11 | minor | Display P3 honest minimum (gap 18): the colour picker of a Display P3 file warns when a colour is outside sRGB (Outside sRGB, with what the canvas and exports do), and View ▸ Soft-proof sRGB (`view.proofSrgb`) stripes the canvas pixels sRGB can't show. Why the canvas still shows sRGB: wgpu 30 can present Display P3 or extended-range sRGB surfaces (Metal, DX12, Vulkan, WebGPU canvases), but eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no option for the format or colour space, and egui's renderer writes sRGB-encoded UI colours; showing P3 needs those two upstream (or a patched egui-wgpu). Hardware 13–27 h → 12–26 h; gap 18 6–12 h → 5–11 h; percentages, feature breadth (~79%) and ready for real work (~47%) unchanged at this rounding; full parity ~363–697 h → ~362–696 h |
|
||||
|
||||
+37
-36
@@ -1,10 +1,11 @@
|
||||
# Localization parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft's interface has a localization layer with an English catalog and Spanish (with a Latin
|
||||
American overlay), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese
|
||||
drafts (machine-drafted, waiting for native speakers' review). This page lists the twelve key
|
||||
FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed
|
||||
by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian
|
||||
Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts (machine-drafted,
|
||||
waiting for native speakers' review), plus Swedish and Turkish catalogs. This page lists the twelve key
|
||||
languages of the craftrules standard, then the other languages Figma ships. How to add a
|
||||
language: [`translating.md`](translating.md).
|
||||
|
||||
@@ -17,19 +18,21 @@ browser's page translation. The `.lproj` folders in `/Applications/Figma.app` (6
|
||||
Electron/Chromium locale packs, not Figma's own UI languages. Text *content* in any script is
|
||||
supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no vertical text.
|
||||
|
||||
## What FigCraft has (measured 2026-10-12, from source)
|
||||
## What FigCraft has (measured 2026-10-17, from source)
|
||||
|
||||
- **Layer:** one TOML catalog per language in `crates/ui-egui/locales/`, baked into the binary by
|
||||
the UI crate's build script (desktop and web; nothing read at run time). Keys are the English text
|
||||
(`tr("Fill")`, `trf` for `{named}` placeholders, `trn` for counts with one/few/many/other plural forms), so
|
||||
a missing translation shows English, never a raw key. One English text that needs two
|
||||
translations takes a context (`"Fill|sizing"`).
|
||||
- **Strings:** 1,417 UI keys in `en.toml` (36 with plural forms; ~110 are labels from engine and
|
||||
document data, such as tool names, blend modes and Dev Mode properties) plus 536 command labels
|
||||
and 29 menu wordings by command id: 1,982 entries. Command ids, the CLI, MCP and the control
|
||||
- **Strings:** 1,680 UI keys in `en.toml` (41 with plural forms; ~110 are labels from engine and
|
||||
document data, such as tool names, blend modes and Dev Mode properties) plus 561 command labels
|
||||
and 31 menu wordings by command id: 2,272 entries (counted against `en.toml` and the complete
|
||||
Spanish catalog's command and menu tables). Command ids, the CLI, MCP and the control
|
||||
channel stay English. Accessibility names come from the same strings.
|
||||
- **Language preference:** Preferences ▸ Language (System, English, Deutsch, Español, Español
|
||||
(Latinoamérica), Français, 日本語, 한국어, Português (Brasil), 简体中文), applied at once without a
|
||||
- **Language preference:** Preferences ▸ Language (System, then each catalog by its own name:
|
||||
English, Čeština, Deutsch, Español, Español (Latinoamérica), Français, Bahasa Indonesia, 日本語,
|
||||
한국어, Português (Brasil), Русский, Svenska, Türkçe, 简体中文), applied at once without a
|
||||
restart; System takes the first of the OS's preferred languages that has a catalog (on Windows
|
||||
only the first preferred language counts, since that list also holds the languages added for a
|
||||
keyboard; macOS AppleLanguages, `LANGUAGE` then the locale variables on Linux and BSD; a regional
|
||||
@@ -37,13 +40,6 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
|
||||
other Latin American and US Spanish tags to es-419; zh, zh-CN, zh-SG and zh-Hans read zh-Hans,
|
||||
while Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and reads English or the
|
||||
next OS language, never Simplified) or the browser's language on the web.
|
||||
(Latinoamérica), Français, 日本語, 한국어, Português (Brasil), Svenska, 简体中文), applied at once without a
|
||||
restart; System takes the first of the OS's preferred languages that has a catalog (Windows'
|
||||
language list, macOS AppleLanguages, `LANGUAGE` then the locale variables on Linux and BSD; a regional tag
|
||||
falls back to its language, pt-PT or pt to pt-BR, and es-419, es-MX, es-AR, es-CO and the other
|
||||
Latin American and US Spanish tags to es-419; zh, zh-CN, zh-SG and zh-Hans read zh-Hans, while
|
||||
Traditional Chinese (zh-Hant, zh-TW, zh-HK, zh-MO) has no catalog and reads English or the next OS
|
||||
language, never Simplified) or the browser's language on the web.
|
||||
`pref.language {language}` sets it from the CLI, MCP and the control channel.
|
||||
- **Regional overlays:** a regional catalog whose language has a catalog lists only what differs
|
||||
(`es-419.toml` over `es.toml`); its UI strings, command labels and menu wordings read the
|
||||
@@ -51,9 +47,9 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
|
||||
- **Check:** `cargo test -p figcraft-ui-egui i18n` fails on keys the code uses that `en.toml`
|
||||
lacks, keys no code uses, unknown command ids, placeholders that differ from English, overlay
|
||||
entries that read as in their base language and, for complete languages (Spanish), anything
|
||||
untranslated. The other drafts are not held to it: a string added later shows English there
|
||||
until someone translates it. Every draft translates every string as of 2026-10-12 (checked by
|
||||
adding each to `COMPLETE` locally and running the check).
|
||||
untranslated. The other catalogs are not held to it: a string added later shows English there
|
||||
until someone translates it. Every draft translated every string on 2026-10-12; by 2026-10-17
|
||||
the strings added since left them at 95–99% (table below).
|
||||
- **Not yet localized:** messages the engine writes (errors, a few toasts, Dev Mode property values
|
||||
such as "inside"), number formatting (decimal points stay `.`), and decimal plural rules
|
||||
(`trn` takes integers: Czech uses one/few/other and Russian one/few/many; French and Brazilian
|
||||
@@ -87,37 +83,42 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
|
||||
|
||||
| Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | Hours to full |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| English | en | 2,076 / 2,076 (100%, source) | yes | Latin | yes (source) | full | 0 |
|
||||
| Simplified Chinese (Mandarin) | zh-Hans | 2,076 / 2,076 (100%, draft) | yes | CJK text and IME in documents; UI text in an installed Chinese face, ahead of Japanese ones (web: the `FIGCRAFT_EMBED_CJK` build); no Traditional Chinese catalog (zh-TW, zh-HK read English) | no | draft | 1–2 (after native review) |
|
||||
| Spanish | es, es-419 | 2,076 / 2,076 (100%, draft); es-419 overlays 98 Latin American wordings (draft) | yes | Latin | no | draft | 0–1 (after native review) |
|
||||
| English | en | 2,272 / 2,272 (100%, source) | yes | Latin | yes (source) | full | 0 |
|
||||
| Simplified Chinese (Mandarin) | zh-Hans | 2,166 / 2,272 (95.3%, draft) | yes | CJK text and IME in documents; UI text in an installed Chinese face, ahead of Japanese ones (web: the `FIGCRAFT_EMBED_CJK` build); no Traditional Chinese catalog (zh-TW, zh-HK read English) | no | draft | 1–2 (after native review) |
|
||||
| Spanish | es, es-419 | 2,272 / 2,272 (100%, draft); es-419 overlays 101 Latin American wordings (draft) | yes | Latin | no | draft | 0–1 (after native review) |
|
||||
| Hindi | hi | 0 (0%) | no | Devanagari shaping works in documents; UI font fallback to check | no | none | 4–7 |
|
||||
| Arabic | ar | 0 (0%) | no | Bidi, shaping and RTL names work; the UI does not mirror | no | none | 9–15 |
|
||||
| French | fr | 2,076 / 2,076 (100%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
|
||||
| Portuguese | pt-BR | 2,076 / 2,076 (100%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
|
||||
| Indonesian | id | 0 (0%) | no | Latin | no | none | 3–5 |
|
||||
| Japanese | ja | 2,076 / 2,076 (100%, draft) | yes | CJK text and IME in documents; UI text in an installed Japanese face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) |
|
||||
| German | de | 2,076 / 2,076 (100%, draft) | yes | Latin; long words elide in narrow fields | no | draft | 1–2 (after native review) |
|
||||
| Korean | ko | 2,076 / 2,076 (100%, draft) | yes | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) |
|
||||
| French | fr | 2,167 / 2,272 (95.4%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
|
||||
| Portuguese | pt-BR | 2,167 / 2,272 (95.4%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
|
||||
| Indonesian | id | 2,244 / 2,272 (98.8%, draft) | yes | Latin | no | draft | 1–2 (after native review) |
|
||||
| Japanese | ja | 2,254 / 2,272 (99.2%, draft) | yes | CJK text and IME in documents; UI text in an installed Japanese face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) |
|
||||
| German | de | 2,176 / 2,272 (95.8%, draft) | yes | Latin; long words elide in narrow fields | no | draft | 1–2 (after native review) |
|
||||
| Korean | ko | 2,166 / 2,272 (95.3%, draft) | yes | Hangul text and IME in documents; UI text in an installed Korean face (web: the `FIGCRAFT_EMBED_CJK` build) | no | draft | 1–2 (after native review) |
|
||||
| Vietnamese | vi | 0 (0%) | no | Latin with stacked diacritics | no | none | 3–5 |
|
||||
|
||||
Other languages: Swedish (`sv`): every UI string translated, and 120 of the 588 command labels and
|
||||
menu wordings (the rest show in English); native review welcome. Figma ships none beyond the eight
|
||||
above.
|
||||
Other languages (measured 2026-10-17): Russian (`ru`) 2,244 / 2,272 (98.8%), reviewed and approved
|
||||
by VanTan, one/few/many integer plurals; Swedish (`sv`) 2,264 (99.6%), its command labels and menu
|
||||
wordings added 2026-10-17 (#599), native review welcome; Turkish (`tr`) 2,243 (98.7%), whose header
|
||||
says it was machine-drafted and reviewed by a native speaker; Czech (`cs`) 2,244 (98.8%), a draft
|
||||
with one/few/other plurals. Figma ships none beyond the eight above.
|
||||
|
||||
The layer is done. Per-language hours above are agent hours for a catalog of ~1,800 entries
|
||||
(a machine draft, then a pass over the panels for overflow), plus each language's plural rule where
|
||||
it isn't one/other; every language also needs a native speaker's review (human). Left before full
|
||||
parity besides the languages: localizing engine messages and number formatting (4–6 h);
|
||||
right-to-left mirroring of the panels is in Arabic's hours. Total: **29–51 h**.
|
||||
right-to-left mirroring of the panels is in Arabic's hours. Total: **29–51 h** (kept on
|
||||
2026-10-17: Indonesian's draft takes 2–3 h off, about what bringing the drafts back from 95% to
|
||||
every string adds).
|
||||
|
||||
Priority: native reviews of the seven drafts and the es-419 overlay (every one of Figma's languages
|
||||
and variants has one, plus Simplified Chinese, which Figma doesn't ship), then Hindi, Indonesian,
|
||||
Vietnamese and Arabic. Traditional Chinese is not planned (no catalog; its users see English).
|
||||
Priority: native reviews of the drafts and the es-419 overlay (every one of Figma's languages
|
||||
and variants has one, plus Simplified Chinese, Indonesian and Czech, which Figma doesn't ship), then
|
||||
Hindi, Vietnamese and Arabic. Traditional Chinese is not planned (no catalog; its users see English).
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-17 | minor | String counts re-measured from the catalogs: 1,680 UI keys, 561 command labels and 31 menu wordings (2,272 entries); Spanish complete, the other drafts at 95.3–99.2% (strings added since 2026-10-12 show English), es-419 101 entries. Indonesian has a draft (98.8%; it was listed as none); Russian (98.8%, reviewed), Swedish (99.6%, command labels and menu wordings from #599), Turkish (98.7%) and Czech (98.8%) listed with counts; the language picker's list updated; a paragraph repeated by a merge removed. Grade and hours unchanged (Indonesian's draft is offset by the drafts' new gaps) |
|
||||
| 2026-10-14 | minor | Windows UI language (#291): the interface language comes from Windows' first preferred language alone; that list is also the input-language list (a German keyboard on a Polish system adds Deutsch), and taking the first entry with a catalog turned the interface German there. A first preferred language without a catalog reads English; macOS AppleLanguages and gettext's `LANGUAGE` still take the first language with a catalog |
|
||||
| 2026-10-12 | minor | Drafts catch up again, and the counts with them: French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese translate the 71 UI strings and 5 command labels they lacked since sharing with a join code and open hosting (the Share popover, join codes, owner tokens, reports, the .fig export notes), and with Spanish the 26 strings and 3 command labels of your online files (Home ▸ Online, its menu and dialogs, the marks on tabs); machine-drafted in each file's conventions, still drafts. The Latin American overlay adds computadora and agrega where the new Spanish says ordenador and añade (98 entries). 2,076 entries per catalog (1,503 UI strings, 544 command labels, 29 menu wordings) |
|
||||
| 2026-10-12 | minor | Draft catalogs catch up: French, German, Brazilian Portuguese, Japanese and Korean translate the 66 UI strings and 17 command labels added since they were drafted (cursor chat and spotlight, the plugin directory, Rename layers, presentation and snapping keys, list levels, the rotation origin, placing with Enter…), Simplified Chinese the 46 and 12 it lacked; machine-drafted from the English in each file's conventions, still drafts (not in `COMPLETE`). The Latin American overlay adds Control for a gamepad controller and presiona in the plugin directory hint (90 entries, with Enter for the keycap). String counts re-measured: 1,417 UI keys, 536 command labels and 29 menu wordings (1,982 entries), every draft at 100%. Localization grade unchanged |
|
||||
|
||||
+33
@@ -1420,6 +1420,27 @@ through the outlines of its layers.
|
||||
clears it; no `preset` reads it). The desktop app reads pressure from pen input on Windows; in the
|
||||
browser from stylus touch events that report a force; macOS and Linux tablets report none.
|
||||
|
||||
### Selection colors
|
||||
|
||||
`selection.colors {ids?}` returns `{colors: [{paint, style, variable, targets}]}` across the
|
||||
selection and its descendants, deduplicating overlapping roots. Only visible solid and gradient
|
||||
paints are listed; masks and boolean operands are omitted. Styles and variables distinguish
|
||||
otherwise equal paints. Each target is `{id, fills, index, range}`; `range` is null for a node
|
||||
paint or `[start, end]` in character offsets for an effective text-range fill.
|
||||
|
||||
Pass the returned target list to:
|
||||
|
||||
- `selection.setPaint {targets, paint}` to replace only those slots (same paint syntax as `fill.set`).
|
||||
- `selection.selectColor {targets}` to select their distinct owning layers, without a document undo step.
|
||||
- `selection.applyColorStyle {targets, style}` to apply a document paint style. As with `style.apply`,
|
||||
this replaces the matching node's entire fill/stroke list and links the style. Text ranges copy
|
||||
resolved paints and keep their typography, without a paint-style link.
|
||||
|
||||
Keep the targets fixed throughout a live interaction. A color becoming equal to another color
|
||||
must not add slots to the gesture. Invalid or missing targets fail atomically. Direct paint edits
|
||||
detach bindings on changed paint values (opacity alone keeps the color binding); a text-range edit preserves the node bindings other
|
||||
characters still inherit. Each mutating command is one undo step (an interaction can group them).
|
||||
|
||||
### Paint, effect and layout grid lists
|
||||
|
||||
A layer's fills, strokes, effects and layout grids are lists kept **bottom first**: index 0 paints
|
||||
@@ -1586,6 +1607,18 @@ the instance's text layer id with `get_node`):
|
||||
{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"type_text","arguments":{"id":7,"text":"Grace Hopper"}}}
|
||||
```
|
||||
|
||||
Smart-selection spacing (movable siblings in rows, columns or aligned grids):
|
||||
|
||||
```json
|
||||
{"jsonrpc":"2.0","id":"spacing","method":"tools/call","params":{"name":"run_command","arguments":{"command":"arrange.spacing","params":{"ids":[3,4,5],"horizontal":24}}}}
|
||||
```
|
||||
|
||||
`horizontal` and `vertical` are optional page-unit gaps (at least one is required, magnitude at
|
||||
most 1,000,000). The first column/row stays fixed; subsequent tracks move without changing sizes,
|
||||
parents or offsets inside each track. Nonuniform gaps become uniform. Normal auto-layout flow
|
||||
children, locked/hidden layers and instance internals are excluded. Interactive callers may pass
|
||||
`fromGesture: true` inside an interaction to keep using its original tracks during a scrub.
|
||||
|
||||
Align and distribute as one undo step:
|
||||
|
||||
```json
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Parity checklist
|
||||
|
||||
> **Generated:** 2026-10-13 by `cargo xtask parity`. Do not edit by hand.
|
||||
> **Generated:** 2026-10-17 by `cargo xtask parity`. Do not edit by hand.
|
||||
|
||||
FigCraft commands registered for the incumbent's main menu and toolbar (547 commands in the engine).
|
||||
FigCraft commands registered for the incumbent's main menu and toolbar (561 commands in the engine).
|
||||
|
||||
**211 / 211 menu items (100.0%)**
|
||||
**212 / 212 menu items (100.0%)**
|
||||
|
||||
Every catalog item has a command.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Prototyping parity
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-17 · **Change:** minor (a Present tab with device and scale controls (#537); core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
**Breadth ~92%, ready ~74% (estimated).** Every Figma trigger except media, every action except
|
||||
media control, every transition and easing exist; key/gamepad triggers take controller buttons,
|
||||
@@ -86,7 +86,7 @@ case pins the current behaviour).
|
||||
| Widgets react to clicks in a presented prototype | done | clicks only, like on the canvas |
|
||||
| Interactive components: instances play their variant's interactions, overridden per trigger | done | |
|
||||
| Drag-driven animations that follow the pointer, in their direction | done | |
|
||||
| Presenter: device frames and colours, rotation, custom sizes, fit/fill/100%, responsive frames | done | device bodies drawn in code |
|
||||
| Presenter: device frames and colours, rotation, custom sizes, fit/fill/100%, responsive frames | done | device bodies drawn in code. Presenting opens its own Present tab, which keeps its screen and history while you go back to the design, with a thumbnail of the current frame, previous / next, device presets and scale choices; the frame is laid out again for the device's screen on the prototype's copy (constraints and auto layout; the file is untouched); on phones and tablets overflowing content drags while the device stays put, as the frame's overflow setting allows ("No scrolling" frames clip); closing the window asks about unsaved changes above the presentation (#537). Devices not compared with Figma's |
|
||||
| Hotspot hints, inspect panel in the presenter, comments in the presenter | done | |
|
||||
| Preview pane following edits live | done | |
|
||||
| Edit interactions on several layers at once | done | |
|
||||
@@ -100,6 +100,7 @@ case pins the current behaviour).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-17 | minor | Community batch #753: a Present tab (#537): playback in its own tab that keeps its screen and history, a current-frame thumbnail, previous / next, device presets and scale; the frame reflows to the device's screen on the prototype's copy; phone and tablet content drags inside a fixed device as its overflow allows ("No scrolling" frames clip; iPad mini, MacBook Air and Desktop keep their bodies); the unsaved-changes question shows above the presentation. Checked in the app on Windows. Percentages and hours unchanged |
|
||||
| 2026-10-13 | minor | Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. |
|
||||
| 2026-10-12 | minor | Controller buttons on key/gamepad triggers (Controller menu, player polling on Windows and in the browser, REST import instead of skipping, control channel injection), widgets reacting to clicks in presented prototypes on the prototype's own copy, `proto.evaluate`; the mode lookup and set-variable-mode rules written as cases (they already held). Breadth 90% → 92%, ready unchanged at 74% (controllers on Windows and the browser only; no real prototype QA yet), 3–6 h → 2–5 h |
|
||||
| 2026-10-11 | minor | Behaviour suite from the public documentation (7 model tests, 12 player tests with 21 gestures) and its fixes: on drag follows its animation's direction and the pointer, key combinations, overlay timers from when they open, interactive components play their variant's interactions and change the right instance, physical springs that play until they settle, smart animate overshoots with the easing, fixed layers above scrolling content; added scroll to offsets. Breadth 88% → 90%, ready 70% → 74%, 5–9 h → 3–6 h |
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
# FigCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; community fixes from #478 up and a control-channel fix (#589); recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading, SVG import budgets, Leptos code, full screen, server snapshots, owner-only preferences, ruzstd 0.9.1, layer cycle repair, batch rollback, Direct3D 12 on Windows, plugin metering; vector edit mode regions: hover hatch, selection and drag; repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; `.fig` import, best effort; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~79%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and
|
||||
[`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours
|
||||
@@ -73,6 +73,7 @@ blockers.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. |
|
||||
| 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged |
|
||||
| 2026-10-14 | minor | M7: the Small caps button in Type settings showed a box where the UI font has no small-capital `ɢ` (builds without Inter use egui's fonts); its sample is now a capital and a smaller capital, drawn from ordinary letters every UI font has. Numbers unchanged |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# FigCraft parity with Figma
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS)
|
||||
|
||||
The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md)
|
||||
summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is
|
||||
@@ -15,7 +15,7 @@ summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu c
|
||||
|
||||
| Number | Value | Kind |
|
||||
|---|---|---|
|
||||
| Menu/command breadth | **100%** (211 / 211 catalogued menu items have a live command) | **measured** (`cargo xtask parity`, 2026-10-12) |
|
||||
| Menu/command breadth | **100%** (212 / 212 catalogued menu items have a live command) | **measured** (`cargo xtask parity`, 2026-10-17) |
|
||||
| Feature breadth (does each Figma Design feature exist, weighted) | **~85%** (core editor areas 1–16: ~94%) | estimated from the area table below |
|
||||
| Ready for real work | **~53%** | estimated (dimension table below: written weights × written per-dimension scores) |
|
||||
| Essentials user | **~59%** | estimated ([below](#essentials-user): core features 77.8% × discounts 0.92 × 0.94 × 0.88) |
|
||||
@@ -107,7 +107,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|
||||
| 11 | Components & variants | 9 | 96% | 72% | Real (not synthetic) libraries, `.fig` libraries (a component playground for variant, boolean and text properties, a behaviour suite from the documentation, overrides of padding, gap, grids, exports and interactions, refused geometry overrides, variant-switch and Assets-swap override rules, top-left default variant landed) ([components-parity](components-parity.md)) | 4–9 |
|
||||
| 12 | Styles & variables | 5 | 95% | 76% | Real-library QA (variables inside styles, behaviour suite from the documentation, style folders and order, mode order, code syntax, extended collections with their tail (new library variables as updates, chains through libraries) and variables import/export landed) ([components-parity](components-parity.md)) | 2–4 |
|
||||
| 13 | Prototyping | 6 | 92% | 74% | Video triggers and media actions, controllers on macOS, Linux and FreeBSD, real prototype QA (behaviour suite from the documentation, delays on mouse triggers, manual overlay offsets, key combinations, controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes, interactive components playing their variants, drag-driven animations, physical springs landed) ([prototyping-parity](prototyping-parity.md)) | 2–5 |
|
||||
| 14 | Import/export | 6 | 86% | 58% | `.fig` import's tail (best-effort import, editable vector networks, stand-ins for non-design layers and variable prototype actions landed; no real-file checks yet), checking the rest of the experimental `.fig` export (landed; written files open and draw in other software) on effects, images, prototypes and real files, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 5–10 |
|
||||
| 14 | Import/export | 6 | 86% | 58% | `.fig` import's tail (best-effort import, editable vector networks, stand-ins for non-design layers and variable prototype actions landed, then (2026-10-17) grid tracks, slots, labels of connectors saved with their path, per-point joins, text on a path, image adjustments, budgets for large files, an import report and paste from another design app; all on synthetic files, no real-file checks yet), checking the rest of the experimental `.fig` export (landed; written files open and draw in other software) on effects, images, prototypes and real files, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 5–10 |
|
||||
| 15 | Dev Mode / inspect | 2 | 96% | 72% | Import lines for connected instances inside generated layer trees; an editor extension is left to the MCP server (inspect behaviour suite with colour formats, rem and icon and image assets, code connect, plugins in Dev Mode and dev resources links landed) ([dev-mode-parity](dev-mode-parity.md)) | 0–1 |
|
||||
| 16 | Comments, versions, find, misc | 3 | 87% | 63% | Comments are local only; comparing two versions; branches in live sessions; text runs and other lists besides fills, strokes and effects merge whole (branches with review and merge, their own version history and per-item list merges, and a comments and version history behaviour suite from the documentation landed) | 1–3 |
|
||||
| 17 | Multiplayer & sharing | 8 | 17% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, cursor chat and spotlight, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view, which can be open to the public (owner tokens, limits per address, expiry, moderation). Left: accounts and teams, audio ([collaboration-parity](collaboration-parity.md)) | 42–84 |
|
||||
@@ -124,12 +124,12 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects
|
||||
|---|---|---|---|---|---|
|
||||
| Features (depth) | 35 | 63% | 28–67 (areas 1–13, 15, 16, 20) | The table above; core editor ~72% | this file |
|
||||
| UI/UX fidelity | 15 | 58% | 40–60 | Look & feel compared side by side with UI3 (~65% by the builders); tool-by-tool interaction (modifiers while dragging, hover states, popovers) not yet compared (a cursor for every tool and modifier, number fields and panel widths built from the documentation, not compared); accessibility names and keyboard navigation landed | [ui-parity](ui-parity.md) |
|
||||
| File formats | 12 | 60% | 5–10 | `.fig` read, best effort, with editable vector networks (tested on generated fixtures, not real files), `.fig` write experimental (round trips through our own importer; written files open and draw in other software); REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid, with a behaviour suite from the documentation | [file-format-parity](file-format-parity.md) |
|
||||
| File formats | 12 | 60% | 5–10 | `.fig` read, best effort, with editable vector networks and (2026-10-17) more fidelity, budgets for large files and an import report (tested on generated fixtures, not real files); layers copied from another design app paste the same way (tested only with clipboard HTML made from our own export), `.fig` write experimental (round trips through our own importer; written files open and draw in other software); REST JSON import, SVG in/out, PNG/JPG/WebP/PDF export solid, with a behaviour suite from the documentation | [file-format-parity](file-format-parity.md) |
|
||||
| Collaboration | 10 | 13% | 52–104 | Local comments, version history and branches with review and merge; the multiplayer core (sync model, server ordering, client reconciliation, convergence tests) landed in #103, and live sessions (preview) co-edit a file hosted by a desktop app or kept on a self-hosted `figcraft-server` with edit and view links, joined from the app or the browser, with cursor chat and spotlight (`docs/collab-protocol.md`) | [collaboration-parity](collaboration-parity.md) |
|
||||
| Stability | 8 | 55% | 20–40 | Never-crash lints, command guard with rollback, recovery copies, 963 tests incl. junk-parameter sweeps and a seeded retained-render walk; but two days old, one release (v0.2.0), no field reports, QA mostly on macOS | [gaps](gaps.md) |
|
||||
| Performance | 6 | 60% | 30–60 | `cargo xtask bench`: 50k-layer page ~8 ms zoomed out, 10k-layer edits ~0.2 ms, 2,000-instance design system edits 4–64 ms; CPU (tiny-skia) canvas where Figma renders on the GPU; nothing timed against Figma on the same file | [hardware-parity](hardware-parity.md) |
|
||||
| Hardware | 3 | 60% | 10–21 | Trackpad pinch and HiDPI yes; pen pressure on Windows and from browser styluses (none from macOS/Linux tablets); several windows (screen-reader tree in the main one only); no GPU canvas, no P3 on screen | [hardware-parity](hardware-parity.md) |
|
||||
| Localization | 3 | 39% | 29–51 | The layer, English, Russian reviewed by VanTan, and machine drafts of Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean and Simplified Chinese (none reviewed by a native speaker; every draft covers all current strings), CJK UI text in installed faces loaded on demand (web: only the craft-fonts CJK build); engine messages stay English | [localization-parity](localization-parity.md) |
|
||||
| Localization | 3 | 39% | 29–51 | The layer, English, Russian reviewed by VanTan, and machine drafts of Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech (none reviewed by a native speaker; measured 2026-10-17, the drafts cover 95–99% of the 2,272 current strings), Swedish and Turkish catalogs, CJK UI text in installed faces loaded on demand (web: only the craft-fonts CJK build); engine messages stay English | [localization-parity](localization-parity.md) |
|
||||
| Platforms | 3 | 75% | 10–20 | macOS, Windows (x64/x86/arm64), Linux, FreeBSD, web all packaged (v0.2.0); Figma also has iOS/Android apps; non-macOS QA thin | [hardware-parity](hardware-parity.md) |
|
||||
| Ecosystem/plugins | 3 | 38% | 8–26 | Own WASM plugins with a Rust SDK and three examples (#101), plugin panels and settings (ABI v2), widgets that take clicks (API 3), a self-hosted plugin directory on `figcraft-server`; no Figma plugin compatibility, no public catalogue or community files; the CLI, control channel and MCP server are ahead of Figma for agents | [gaps](gaps.md) |
|
||||
| AI features | 2 | 0% | 30–60 | None | [gaps](gaps.md) |
|
||||
@@ -423,7 +423,11 @@ Hours in this narrative are the builders' estimates; the tables above supersede
|
||||
JPG, WebP, SVG and PDF, suffix, advanced options: contents only, include bounding box, and for
|
||||
SVG outline text, id attributes and simplified strokes), a preview, and "Export N layers" (`file.exportLayers`) in the
|
||||
panel and the Export dialog. Paste from other apps: images, SVG and text (the newest clipboard
|
||||
content wins over our own copy; pasted SVG lands centred in the view). SVG import is editable and
|
||||
content wins over our own copy; pasted SVG lands centred in the view), and layers copied from
|
||||
another design app (`edit.pasteDesignLayers`: the `.fig` canvas in the clipboard's HTML read
|
||||
like a `.fig` file, best effort; tested with clipboard HTML made from our own export only).
|
||||
File ▸ Save as… to a `.fig` name writes an experimental export copy and the document keeps its
|
||||
`.figcraft` path. SVG import is editable and
|
||||
checked against a corpus of synthetic files (expected pixels worked out by hand, layer structure,
|
||||
export and re-import): groups named from their ids with transforms baked into the layers;
|
||||
rectangles (corner radius), ellipses and lines kept as those shapes; gradients with exact
|
||||
@@ -623,13 +627,18 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th
|
||||
- **Automation:** desktop control channel (`--control <port>`: execute commands, inspect document/UI,
|
||||
synthetic pointer/keys/typing, menu invocation, screenshots, renders). What agents read and write
|
||||
on disk can be confined to chosen folders (`--automation-read-root` / `--automation-write-root` on
|
||||
`figcraft-cli mcp` and the desktop app, #715).
|
||||
`figcraft-cli mcp` and the desktop app, #715). A minimised or covered window answers every
|
||||
request that acts on the app's state without drawing (about 100 ms each on Windows), and the
|
||||
ones that need a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) fail at once instead of
|
||||
waiting 60 s (#720; not tested on macOS, and under Wayland a minimised window can't be told
|
||||
apart).
|
||||
- **Tests:** 963 `#[test]` functions (2026-10-10) including junk-parameter sweeps over every command (and every vector-edit command while editing), importer fuzzing and render regressions.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-17 | minor | Menu breadth re-run with `cargo xtask parity`: 212 / 212 (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Rows for the 2026-10-17 landings: import/export (area 14) and the file formats dimension name the `.fig` import's new fidelity (instance identity, overrides by key, text style precedence, grid tracks, slots, connector labels, per-point joins, text on a path, image adjustments, list start numbers, vertical trim), budgets from the file's size, the import report, a `.fig` copy from Save as and paste from another design app; the localization evidence takes the re-measured catalog counts (2,272 entries, drafts at 95–99%, Indonesian and Czech drafts, Swedish and Turkish catalogs); the Automation line adds control requests answered while the window is minimised (#720); batch #753's Selection colors, smart-selection spacing, Present tab and auto layout on empty frames are in ui-parity, prototyping-parity and auto-layout-parity. Numbers unchanged: every `.fig` and paste change is checked on synthetic files or clipboard HTML made from our own export, and real files are what gap 1's blocker and the file-exchange discounts wait for; the UI changes (menus, the Actions palette, editor fixes) are not compared side by side, so UI/UX stays at 58%; the localization grade and hours stay (Indonesian's draft is offset by the drafts' new gaps) |
|
||||
| 2026-10-17 | minor | Automation roots (M8, #715, #481): `figcraft-cli mcp --automation-read-root DIR --automation-write-root DIR` (each repeatable, existing folders) and the desktop app with the same flags confine every file a command reads to the read and write roots and every file it writes to the write roots; paths are resolved (links followed, `..`, Windows `\\?\` and UNC paths, case) and compared by whole folder names, anything else is an ordinary error. The engine reaches the file system through one module (enforced by `crates/engine/clippy.toml`), and a sweep runs every command with paths outside the roots. `--attach` refuses the flags (the app confines itself); `ui.screenshot` and `ui.render` return PNG data without a `path`, so the attached MCP server writes no temporary files. Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. |
|
||||
|
||||
+18
-11
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7)
|
||||
|
||||
Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys,
|
||||
drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status:
|
||||
@@ -15,19 +15,20 @@ interaction feel not yet compared.
|
||||
|
||||
| Item | Status | Notes |
|
||||
|---|---|---|
|
||||
| UI3 layout: tab strip as title bar, icon rail, file header, pages + layers, floating bottom toolbar with flyouts, Design / Prototype / Inspect panels | done | frameless window on Windows/Linux with drawn buttons |
|
||||
| UI3 layout: tab strip as title bar, icon rail, file header, pages + layers, floating bottom toolbar with flyouts, Design / Prototype / Inspect panels | done | frameless window on Windows/Linux with drawn buttons; on a Wayland compositor that offers no server-side decorations (GNOME) the window gets winit's Adwaita controls instead (#592; checked by its author on GNOME, KDE not run) |
|
||||
| Light and dark themes | done | colours from `theme::Tokens`; a file or a page made while the dark theme shows gets a `#1E1E1E` page (`#F5F5F5` in the light theme), its own from then on: changing the theme repaints no page and opened files keep theirs; the file the app opens with takes the theme once it is known; the CLI, MCP and exports have no theme and keep `#F5F5F5`; text drawn with the Text tool is white on a dark page and black on a light one (so is text a command makes in the app without naming a fill; headless sessions keep black) (`theme_pages_tests`, `dark_page_text_tests`) |
|
||||
| Minimize UI (Cmd+\\) | done | |
|
||||
| Resizable panels | done | each panel's edge drags its width (left 200–480, right 240–480, 240 by default; a double-click on the edge or View ▸ Panels ▸ Reset panel widths puts the default back); the widths are each window's own, the main window's are kept in the preferences when a drag ends and come back at the next start, a new window opens with those of the window it comes from; Minimize UI hides the panels and brings them back as they were; the view keeps its centre and zoom while a panel is resized; in a window too small for them the panels give way so the canvas keeps 160 px (the left one first), down to the smallest window (800×500) (`panel_width_tests`). Not compared with Figma's widths; no snap points |
|
||||
| Actions palette (Cmd+K, Cmd+/) | done | commands; no assets search parity |
|
||||
| Context menus | done | canvas and layers; Shift+F10 opens them from the keyboard (at the focused layer row or below the canvas selection) and the menu takes the keyboard; the Menu key isn't delivered by the windowing layer (egui 0.36 has no key for it) |
|
||||
| Actions palette (Cmd+K, Cmd+/) | done | tabs: All (every command, grouped by menu, or Other), Assets (components and styles) and Plugins & widgets; results ranked prefix first, then shortest, at most 60 rows; Up/Down move a highlight and Enter runs it; Recents list what was run from the palette (matching translated labels); one open/toggle path from every entry point (`palette_tests`). Ranking and the Assets results not compared with Figma's |
|
||||
| Context menus | done | canvas and layers; submenus as in the main menu below, and a right-click in a new spot starts a fresh menu (Select layer closed); Shift+F10 opens them from the keyboard (at the focused layer row or below the canvas selection) and the menu takes the keyboard; the Menu key isn't delivered by the windowing layer (egui 0.36 has no key for it) |
|
||||
| Home screen: Recents, Online and Templates | done (ours) | The sidebar switches between the three (`ui.set {homeSection}`). Online: your online files (see the collaboration checklist). Recents: new file, open, join with a code, open files, recent files (cards with the picture each file carries on its page colour, the file's icon, name and "Edited ... ago" or "File not found", full path on hover; or a Name, Last modified and Created table with sortable headers; sorted by name, creation or last change either way; grid or list and the sort kept in the preferences, `ui.set {homeRecentsView, homeRecentsSort, homeRecentsOrder}`; a click selects, a double-click or Enter opens, arrow keys move the selection; Open, Show in folder and Remove from recents on right-click), a Getting started card (six tips with their shortcuts from the keymap; its close button is remembered in the preferences and Help ▸ Getting started brings it back) and, while there are no recent files, four templates to start from. Templates: the eight starter templates as cards (thumbnail rendered from the document, cached per theme; name, description, kind and size), a search field and a kind filter, an empty state; Tab reaches every card and Enter opens it; cards and tabs are named for screen readers. A first run opens here. No Community files or drafts folder |
|
||||
| Main menu from the logo | done | generated from `catalog.rs`; popups sized to content |
|
||||
| Keyboard shortcuts panel | done | docked, tabbed by group like the documentation, searchable, per-platform keycaps; held keys, gestures and presentation keys listed beside the bindings; a label too long for its column ends with "…" and shows in full on hover |
|
||||
| Main menu from the logo | done | generated from `catalog.rs`; popups sized to content. Submenus open after 0.15 s on their row (a click or the keyboard opens one at once) and close at once when another row is hovered; while the pointer heads for an open submenu inside the triangle to its near edge, the rows it crosses don't take over, and resting 0.3 s on one hands over; closing the menu, however it closes, clears which submenus were open, so it reopens with none (`menu_aim_tests`, 10 tests; timings tuned by feel, not measured against Figma). Menu and palette items that need a value ask for it first: Create link, Offset path and Move to page in a dialog of the shared controls, the language, user name, server and nudge items in Preferences, Open recent on the home screen's Recents; File ▸ Rename edits the file name in place |
|
||||
| Keyboard shortcuts panel | done | closes when the home screen shows (#548); docked, tabbed by group like the documentation, searchable, per-platform keycaps; held keys, gestures and presentation keys listed beside the bindings; a label too long for its column ends with "…" and shows in full on hover |
|
||||
| Several windows | partial | File ▸ New window, Open in new window on a tab, a tab dragged off the strip; screen-reader tree in the main window only; positions not restored |
|
||||
| Multiplayer avatars, Share button | missing | waits for multiplayer |
|
||||
| Colour names in hex fields | done | every hex field (fills, strokes, text ranges, effects, layout grids, colour variables, page and prototype backgrounds, the colour picker's Hex) takes a CSS colour name as well: `red` becomes `FF0000`; case, spaces and hyphens are ignored (`Light Blue`); names are opaque and keep the colour's opacity; hex wins where both could match. The picker's CSS field takes every CSS name too (`Color::from_hex_or_name`, `color::named`) |
|
||||
| Fields select their text on focus | done | a click or Tab into a one-line field selects all of its text, so typing replaces the value: hex and colour fields, number fields, inline fields (a fill's hex, variable values, renames), names, search boxes and dialog fields; a drag from the press selects only what it covers; multi-line fields (descriptions, comments) keep the caret where clicked. The first click into an inline field (a fill's hex) now takes the keyboard; it took a second click (`widgets::select_all_on_focus`, `select_all_tests`) |
|
||||
| Colour picker on a new fill or stroke | done | + opens the picker on the new paint when the selection shares one paint list; on a mixed selection + adds a paint to each layer and no picker opens (#404) |
|
||||
| Blend mode menus: the layer's (Design panel) and each paint's (colour picker) | done | Pass through and Normal are distinct (Normal isolates a group's or frame's children); paints have no pass-through; plus darker drawn exactly on the canvas |
|
||||
|
||||
## Layers and Assets panels
|
||||
@@ -40,15 +41,15 @@ interaction feel not yet compared.
|
||||
| Eye and padlock: click, or drag across rows to set several in one step; layers in a locked frame show a faint padlock | done | the toggles no longer vanish under the pointer (clicks on them could miss) |
|
||||
| Rename: double click, F2 / Cmd+R, Tab to the next row; several layers open Rename layers (match, regular expressions, current name and number codes, start value, preview) | done | numbered in stacking order, pinned |
|
||||
| Find layers (filter by name) | done | not covered by the documentation |
|
||||
| Pages: add, rename, drag to reorder, duplicate ("Copy of"), delete, dividers (a blank page named with a hyphen) | done | |
|
||||
| Assets panel: folders from page, frames and slash names (fold away), search, thumbnails, drag onto the canvas or into frames and auto layout, Alt-drop swap, enabled libraries | done | |
|
||||
| Pages: add, rename, drag to reorder, duplicate ("Copy of"), delete, dividers (a blank page named with a hyphen) | done | the line between Pages and Layers drags to size the pages list, kept in the preferences (`layers_behaviour_tests`) |
|
||||
| Assets panel: folders from page, frames and slash names (fold away), search, thumbnails, drag onto the canvas or into frames and auto layout, Alt-drop swap, enabled libraries | done | Shift+I (Quick insert) and the toolbar's Resources open it (they didn't, #710) |
|
||||
| Hide when publishing from the Assets panel's context menu | done | a right-click on one of this file's components (also Libraries ▸ Hide when publishing for the selection, `component.hideWhenPublishing {ids?, on?}`): the model's sign of it is the name, so hiding puts a `.` before the name and showing takes the leading `.` and `_` away (one undo step, saved and synced like a rename; a variant or a layer inside hides its whole component or set); a hidden component shows a crossed eye on its tile and a dimmer name, the menu item its check mark; the next publish leaves it out and brings it back when shown again (`hide_publishing_tests`). Library components (read-only here) have no such menu |
|
||||
|
||||
## Selection and transform
|
||||
|
||||
| Item | Status | Notes |
|
||||
|---|---|---|
|
||||
| Click, Shift-click, marquee, Shift-marquee toggle | done | a marquee selects what it touches; from a top-level frame's background, the layers inside it; locked and hidden layers are skipped (`selection_behaviour_tests`) |
|
||||
| Click, Shift-click, marquee, Shift-marquee toggle | done | Shift-drag on a selected layer moves the selection, kept to one axis (#498); a marquee selects what it touches; from a top-level frame's background, the layers inside it; locked and hidden layers are skipped (`selection_behaviour_tests`) |
|
||||
| Deep select (Cmd/Ctrl-click, Cmd/Ctrl-marquee) | done | |
|
||||
| Select layer menu (every layer under the click) | done | |
|
||||
| Enter / Shift+Enter (children / parent), Tab / Shift+Tab (siblings) | done | Tab renames the next layer while renaming |
|
||||
@@ -68,6 +69,8 @@ interaction feel not yet compared.
|
||||
| Numeric fields: arithmetic, scrubbing by dragging the label, mixed values | done | Numeric panels use `widgets::number_field`; Typography line height, letter spacing and paragraph spacing share its leading-icon scrub logic (#373), retaining their existing unit parsing, Auto/Mixed values, variable chips and selected-character targeting (`typography_scrub_tests`). Scrubbing the label: a step a point (1 px; 1% of opacity, 1° of rotation), ten with Shift, a tenth with Alt, each stretch at the rate held while it was dragged, one undo step when let go; past the edge of the screen it goes on where the platform reports the mouse's own motion (desktop), otherwise it stops there. Arrow keys: Up/Down step a pixel field by the small nudge and Shift by the big nudge (the preferences), other fields by their own step and ten of it; they work while typing (from what is typed) and with the keyboard on the field. Typing: `+ - * /`, brackets, units (`px`, `°`, `%`, `x`, `ms`), a decimal comma; `+10`, `*2`, `/4` are relative to the value, and `50%`, `+10%`, `-10%` too in a field that isn't a percentage. Enter, Tab or a click elsewhere commits (Tab and Shift+Tab move to the next and previous control), Escape restores; a field left as it was commits nothing (no undo step, and a rounded display isn't written back). Mixed values show "Mixed": a number sets them all, a relative edit or an arrow key goes to each layer from its own value in one undo step, for X, Y, W, H, rotation, opacity, corner radius and stroke weight (elsewhere `+10` on Mixed sets 10 and `*2` does nothing) (`field_tests`). Left: scrubbing a Mixed field (nothing happens), `x2`-style suffix edits, the pointer isn't wrapped to the other side of the screen; rates not compared with Figma's |
|
||||
| Transform box at small sizes | done | a press inside a layer smaller than its handles moves it, the nearest handle wins, handles answer just outside and are hidden under 16 px on screen; cursors follow each handle and the box's rotation |
|
||||
| Locked layers | done | selectable from the layers panel; no handles, not dragged with the rest of a selection |
|
||||
| Smart selection spacing | partial | with layers in a row, a column or an aligned grid selected, the Layout section shows horizontal and vertical spacing ("Mixed" until set; typed, scrubbed, fractional and negative) and pink handles in the gaps on the canvas drag them, one undo step, Escape cancels (`arrange.spacing`; sizes, parents and offsets within each track kept; at most 2,000 layers; flow children, locked layers and layers inside instances left out). No reorder rings for swapping layers; checked by its author on macOS and in the app on Windows (#402) |
|
||||
| Selection colors | partial | for a mixed selection the Design panel lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart): editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a colour style of the document (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.selectColor`). No library styles or variable binding from there (#403) |
|
||||
| Keyboard box selection | done | Alt+Space on macOS, Ctrl+Space on Windows and Linux, or F8 (`select.area`): a box around the selection or in the middle of the view; arrows move it, Cmd/Ctrl+arrows or Shift+arrows size it (Shift with Cmd/Ctrl: farther), Enter selects like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter nested), Escape closes |
|
||||
|
||||
## Tools
|
||||
@@ -79,10 +82,10 @@ interaction feel not yet compared.
|
||||
| Rectangle, ellipse (arc handles and Design panel arc fields), polygon, star, line, arrow; Shift/Alt | done | Alt draws lines from the centre too (`shapes_behaviour_tests`) |
|
||||
| Corner radius handles (inside each corner of a rectangle or frame, a polygon's or star's top point; Alt: one corner), star ratio handle; Count and Ratio fields | done | handles hide under 48 px on screen; no count handle on the canvas (the Count field instead); corner smoothing rounds rectangles and frames only |
|
||||
| Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only; outside edit mode the Pen closes on its first point, connects to its other points and continues a selected open path from an end point (`vector_multi_behaviour_tests`) |
|
||||
| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). Not yet: the transform box around points of several layers; areas of other layers edited at once |
|
||||
| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). Not yet: the transform box around points of several layers; areas of other layers edited at once |
|
||||
| Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | |
|
||||
| Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | |
|
||||
| Text: point, auto height, fixed; caret, words, lines, IME | done | carets step over whole emoji and accented letters; CJK words by script run (no dictionary); vertical trim in Type settings; no spell check |
|
||||
| Text: point, auto height, fixed; caret, words, lines, IME | done | the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; CJK words by script run (no dictionary); vertical trim in Type settings; no spell check |
|
||||
| Eyedropper (I) | done | picks the colour seen under the pointer (opaque) into the selection's fill and the recent colours, one undo step (`fx_behaviour_tests`) |
|
||||
| Comment (C), Shift+C to show/hide | done | local only |
|
||||
| Text on a path (Figma Draw) | done | put on from the Design panel or `text.onPath`, not by clicking a path with the Text tool; the path is edited by resizing only |
|
||||
@@ -137,6 +140,9 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
|
||||
Ctrl+Alt+Shift+K to go to the main component, the arrows panning the view when nothing is
|
||||
selected (`view.pan`, new), Control suspending snapping on macOS, and Space, N, C and F while
|
||||
presenting. The paint bucket's tooltip shows Shift+B (B still works).
|
||||
- **Fixed (2026-10-17):** Shift+Cmd/Ctrl+V (Paste over selection) and Alt+Cmd/Ctrl+V (Paste
|
||||
properties) work on the desktop (they were bound but didn't act there, #716); Cmd/Ctrl+V pastes an
|
||||
image copied in another app (#663). Counts above unchanged (the suite checks bindings).
|
||||
- **Where the documentation disagrees with itself** (noted per row): pixel grid (Shift+' or
|
||||
Cmd+'), letter spacing (Alt or Cmd with < >; Cmd would be the font size keys, Alt bound),
|
||||
Windows remove fill (Alt+/ or Alt+,; Alt+, is letter spacing), the Layers panel on Windows (Alt+1
|
||||
@@ -166,6 +172,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-17 | minor | The community batches #733, #735, #736, #729 and #753: submenus open after a short hover, keep their place along a safe triangle and reopen closed (main and context menus); the Actions palette's tabs, ranked search, keyboard highlight and recents; menu items that need a value ask for it, File ▸ Rename edits in place; Shift+I and Resources open Assets; a draggable Pages/Layers divider; Shift-drag moves the selection; a double-click near a thin stroke, merging dropped points and Flatten keeping open paths in vector editing; live font preview and the selection highlight after line breaks; the colour picker on a new fill or stroke; Paste over and Paste properties from the keyboard; Selection colors (partial: no library styles or variables there) and smart-selection spacing fields and canvas handles (partial: no reorder rings), both also checked by their author on macOS; the keyboard shortcuts panel closes on the home screen; Adwaita window controls on Wayland compositors without server decorations (checked by its author on GNOME; KDE not run). Checked in the app on Windows (submenus by their author), not side by side with Figma. Summary and hours unchanged |
|
||||
| 2026-10-15 | minor | Hex fields take CSS colour names (`red` → `FF0000`): fills, strokes, effects, grids, variables, backgrounds and the colour picker |
|
||||
| 2026-10-15 | minor | Typography: drag the line-height, letter-spacing and paragraph-spacing icons to scrub their values; shared numeric-field rates, resize cursor and one Undo step per gesture (#373). |
|
||||
| 2026-10-15 | minor | A click or Tab into a one-line field selects all of its text (hex, inline, name, search and dialog fields); the first click into a fill's hex takes the keyboard |
|
||||
|
||||
Reference in new issue
Block a user