mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 01:11:11 +00:00
Merge upstream/main into fix/submenu-opens-without-delay
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
a85bd83bc7
93 files changed
+2482
-271
No files matched your search
Generated
+1
-2
@@ -1134,7 +1134,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -7020,7 +7020,6 @@ dependencies = [
|
||||
"nix",
|
||||
"typed-path",
|
||||
"windows-sys 0.61.2",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l
|
||||
# pull a second egui family version).
|
||||
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
|
||||
# The suite's font store: exact lookup of a missing font in the pinned Google Fonts and Noto
|
||||
# indexes, and checksum-verified installs into the one folder every Craft app reads (craft-libs).
|
||||
craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
|
||||
|
||||
@@ -103,8 +103,8 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/images/shot-4-devmode.png" alt="Dev Mode on the Ridgeline home screen: the frame carries a Ready for dev badge, a Development annotation callout points at the featured trail card, a trail row instance is selected and the Inspect panel shows a Playground preview of it and generated React code with a TrailRow props interface and the instance's title and details values" width="100%">
|
||||
<p align="center"><sub><b>Dev Mode</b>: ready-for-dev status, annotations and React code where component properties become props · <code>examples/ridgeline-app.figcraft</code></sub></p>
|
||||
<img src="docs/images/shot-4-devmode.png" alt="Developer Mode on the Ridgeline home screen: the frame carries a Ready for dev badge, a Development annotation callout points at the featured trail card, a trail row instance is selected and the Inspect panel shows a Playground preview of it and generated React code with a TrailRow props interface and the instance's title and details values" width="100%">
|
||||
<p align="center"><sub><b>Developer Mode</b>: ready-for-dev status, annotations and React code where component properties become props · <code>examples/ridgeline-app.figcraft</code></sub></p>
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/images/shot-5-vector.png" alt="Vector edit mode on a wave path with a gradient, tapered variable-width stroke: its anchor points, Bezier handles and the transform box of the selected points are visible, with the vector edit toolbar at the bottom; above it are a boolean-built mountain mark and an angular-gradient knot of excluded rings" width="100%">
|
||||
@@ -127,8 +127,8 @@
|
||||
<p align="center"><sub><b>Boards</b>: a whiteboard file kind with sticky notes, templates, connectors, a shared timer and voting, in the same live sessions</sub></p>
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
<img src="docs/images/shot-9-devmode-measure.png" alt="Dev Mode on the Ridgeline home screen with the featured trail card selected: red redlines kept on the canvas show 118.3 from the card to the search field, 62.1 from the card to the first trail row and 256.3 along the card's left side, and the Inspect panel's Measurements section lists the three with their values under an Add measurement menu" width="100%">
|
||||
<p align="center"><sub><b>Dev Mode measurements</b>: redlines placed between layers or along a side, saved in the file and following the layers · <code>examples/ridgeline-app.figcraft</code></sub></p>
|
||||
<img src="docs/images/shot-9-devmode-measure.png" alt="Developer Mode on the Ridgeline home screen with the featured trail card selected: red redlines kept on the canvas show 118.3 from the card to the search field, 62.1 from the card to the first trail row and 256.3 along the card's left side, and the Inspect panel's Measurements section lists the three with their values under an Add measurement menu" width="100%">
|
||||
<p align="center"><sub><b>Developer Mode measurements</b>: redlines placed between layers or along a side, saved in the file and following the layers · <code>examples/ridgeline-app.figcraft</code></sub></p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
+3
-2
@@ -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-11-06 · **Change:** minor (the left rail's and tab strip's icons are larger, 20 pt (#553); Suggest auto layout adds nested auto layout frames by rows and columns and keeps the placement of the design, as the help center describes (#1823); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); the desktop UI takes a system face for Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as it did for Hebrew and Arabic, so a String variable's Amharic value draws in the panels instead of boxes (#2014); a layer's effect binding takes only a field its effect has; ; the boolean operation menu leads each entry with an original icon (#2003); glass takes one sample instead of its dispersion window where the colours split under 1/256 px; glass bends towards its core, and rounded rectangles work it out in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, itsLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** patch (the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; the left rail's and tab strip's icons are larger, 20 pt (#553); Suggest auto layout adds nested auto layout frames by rows and columns and keeps the placement of the design, as the help center describes (#1823); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); the desktop UI takes a system face for Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as it did for Hebrew and Arabic, so a String variable's Amharic value draws in the panels instead of boxes (#2014); a layer's effect binding takes only a field its effect has; ; the boolean operation menu leads each entry with an original icon (#2003); glass takes one sample instead of its dispersion window where the colours split under 1/256 px; glass bends towards its core, and rounded rectangles work it out in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15Line truncated
|
||||
|
||||
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,6 +13,7 @@ start with [`docs/gaps.md`](docs/gaps.md).
|
||||
|
||||
| | Value | Kind |
|
||||
|---|---|---|
|
||||
| 2026-11-07 | patch | Naming: the handoff mode is called Developer Mode in the interface, its shortcut row, its toasts, the control channel's refusal, the board refusal, the measurement parameter doc, the `.fig` export note, the CLI help and every catalog (24 languages, translations awaiting native review), since Figma holds a US registration for Dev Mode (7574348); command ids (`view.devMode`, `devMode.*`), preference keys and saved-document fields are unchanged, and the Actions palette still finds the command by the old name |
|
||||
| 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) |
|
||||
@@ -44,7 +45,7 @@ lines, a typical feature PR 1–2.5 h). Details and what needs a human:
|
||||
## Stage
|
||||
|
||||
**Alpha.** Core workflows exist end to end (frames, shapes, vectors, text, auto layout, components
|
||||
with variants and overrides, styles, variables, prototyping, Dev Mode, SVG/PNG/JPG/PDF), but
|
||||
with variants and overrides, styles, variables, prototyping, Developer Mode, SVG/PNG/JPG/PDF), but
|
||||
FigCraft opens `.fig` files only best effort, has only a preview of multiplayer, and nothing has been checked
|
||||
against real Figma documents. Ready-for-real-work is ~53%, inside alpha's ~40–75% band, and the alpha gate passes: every daily core workflow (design screens, build a design system, prototype, hand off to developers) works end to end on FigCraft's own files, which save and reopen; review works only asynchronously and `.fig` import is unproven on real files, both beta gaps ([alpha gate](docs/roadmap.md#alpha-gate)). Beta needs
|
||||
~75% **and** reliable `.fig` import: the `.fig` import's tail (5–10 h), a real-file corpus with measured
|
||||
|
||||
@@ -243,7 +243,7 @@ pub enum ConnectCommand {
|
||||
|
||||
/// Link a design file's components to their code as a figcraft.connect.json says, and save it
|
||||
///
|
||||
/// The mappings are what Dev Mode's Export code connections writes, kept in the code repository:
|
||||
/// The mappings are what Developer Mode's Export code connections writes, kept in the code repository:
|
||||
/// each links a component (or component set) of that name to its code component for a language.
|
||||
/// Entries that don't fit (an unknown component, a bad template) are reported and skipped.
|
||||
#[derive(Args)]
|
||||
|
||||
@@ -126,7 +126,12 @@ impl eframe::App for App {
|
||||
fn on_exit(&mut self) {
|
||||
// With the app out of reach (busy), its recovery copies stay for the next start.
|
||||
let copies = recovery::lock(&self.recovery).take();
|
||||
with_app(|fig| services::shutdown(fig, services::config_dir().as_deref(), copies));
|
||||
with_app(|fig| {
|
||||
services::shutdown(fig, services::config_dir().as_deref(), copies);
|
||||
// An update that waits for the app to close installs now: the app lives in a
|
||||
// thread-local, and the process can end without dropping it.
|
||||
fig.start_pending_update();
|
||||
});
|
||||
// Everything is kept: end before the windows' teardown, where AppKit's Touch Bar support
|
||||
// throws on the content view AccessKit restores (craft-libs#3, #781).
|
||||
#[cfg(target_os = "macos")]
|
||||
|
||||
@@ -220,6 +220,7 @@ impl LiveLink for NetLink {
|
||||
let (server, query) = Endpoint::parse(&o.server)?;
|
||||
// Without a key: as anyone may, when the server is open to the public (it says if not).
|
||||
let key = requests::server_key(o.key, query);
|
||||
requests::key_over_tls(&server, key.as_deref())?;
|
||||
// Until the upload is done the invite names only the server.
|
||||
let cfg = self.guest_config(Invite { server: server.clone(), file: String::new(), token: String::new() }, None, &o.me);
|
||||
let inbox = self.sessions.inbox();
|
||||
@@ -342,6 +343,7 @@ fn upload(o: PublishOptions, limits: &NetLimits) -> Result<Published, String> {
|
||||
let (server, query) = Endpoint::parse(&o.server)?;
|
||||
let key = requests::server_key(o.key, query)
|
||||
.ok_or("the server address needs the server's key (ws://host:port/?key=…; `figcraft-server key` prints it)")?;
|
||||
requests::key_over_tls(&server, Some(&key))?;
|
||||
upload_file(&server, Some(&key), &o.name, &o.doc, limits, CONNECT).map(|(_, published, _)| published)
|
||||
}
|
||||
|
||||
@@ -358,6 +360,7 @@ fn hosting(server: &str, file: &str, owner: &str, req: Option<Admin>, limits: &N
|
||||
fn share(server: &str, file: &str, owner: &str, revoke: &[String], create: bool, limits: &NetLimits) -> Result<Option<Shared>, String> {
|
||||
let (endpoint, query) = Endpoint::parse(server)?;
|
||||
let key = requests::server_key(None, query);
|
||||
requests::key_over_tls(&endpoint, key.as_deref())?;
|
||||
manage::share(&endpoint, key.as_deref(), file, owner, revoke, create, limits, CONNECT)
|
||||
}
|
||||
|
||||
@@ -365,6 +368,7 @@ fn share(server: &str, file: &str, owner: &str, revoke: &[String], create: bool,
|
||||
fn plugins(server: &str, req: PluginReq, limits: &NetLimits) -> Result<PluginReply, String> {
|
||||
let (server, query) = Endpoint::parse(server)?;
|
||||
let key = query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v).unwrap_or_default();
|
||||
requests::key_over_tls(&server, Some(&key))?;
|
||||
manage::plugins(&server, key.trim(), req, limits, CONNECT)
|
||||
}
|
||||
|
||||
|
||||
@@ -242,18 +242,30 @@ pub fn shutdown(s: &TcpStream) {
|
||||
/// Connect to `server` (through TLS for `wss://`): the reader's and the writer's handles, and the
|
||||
/// socket to shut it down with.
|
||||
pub fn open(server: &Endpoint, timeout: Duration, limits: &NetLimits) -> Result<(Duplex, Duplex, TcpStream), String> {
|
||||
let stream = dial(&server.host, timeout, limits)?;
|
||||
// A plain `ws://localhost` is trusted as this machine (a key may go over it), so only loopback
|
||||
// addresses are connected to: a resolver mapping the name elsewhere must not carry it off-machine.
|
||||
let stream = dial(&server.host, !server.secure && server.loopback(), timeout, limits)?;
|
||||
let tls = if server.secure { Some(tls::handshake(&stream, &server.host)?) } else { None };
|
||||
Duplex::split(stream, tls).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Connect to `host` (`name:port`), trying each of its addresses within `timeout`, with Nagle off
|
||||
/// and reads and writes bounded by `limits`' hello and write timeouts.
|
||||
fn dial(host: &str, timeout: Duration, limits: &NetLimits) -> Result<TcpStream, String> {
|
||||
/// Whether `a` may be connected to: any address, or with `loopback_only` just this machine's.
|
||||
fn dialable(a: &std::net::SocketAddr, loopback_only: bool) -> bool {
|
||||
!loopback_only || a.ip().is_loopback()
|
||||
}
|
||||
|
||||
/// Connect to `host` (`name:port`), trying each of its addresses within `timeout` (only loopback
|
||||
/// ones with `loopback_only`), with Nagle off and reads and writes bounded by `limits`' hello and
|
||||
/// write timeouts.
|
||||
fn dial(host: &str, loopback_only: bool, timeout: Duration, limits: &NetLimits) -> Result<TcpStream, String> {
|
||||
use std::net::ToSocketAddrs;
|
||||
let addrs = host.to_socket_addrs().map_err(|e| format!("can't find {host}: {e}"))?;
|
||||
let mut last = format!("can't find {host}");
|
||||
for a in addrs {
|
||||
if !dialable(&a, loopback_only) {
|
||||
last = format!("{host} resolves to {a}, which is not this machine");
|
||||
continue;
|
||||
}
|
||||
match TcpStream::connect_timeout(&a, timeout) {
|
||||
Ok(s) => {
|
||||
let _ = s.set_nodelay(true);
|
||||
@@ -318,3 +330,21 @@ impl Bucket {
|
||||
self.tokens = (self.tokens + n).min(self.burst);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod dial_tests {
|
||||
use super::dialable;
|
||||
use std::net::SocketAddr;
|
||||
|
||||
#[test]
|
||||
fn a_loopback_only_dial_skips_addresses_off_this_machine() {
|
||||
let at = |s: &str| s.parse::<SocketAddr>().unwrap();
|
||||
for a in ["127.0.0.1:9", "[::1]:9", "127.5.5.5:9"] {
|
||||
assert!(dialable(&at(a), true), "{a}");
|
||||
}
|
||||
for a in ["10.0.0.5:9", "203.0.113.7:9", "[2001:db8::1]:9"] {
|
||||
assert!(!dialable(&at(a), true), "{a}");
|
||||
assert!(dialable(&at(a), false), "{a} when anywhere is allowed");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,15 @@ pub(crate) fn server_key(given: Option<String>, query: Vec<(String, String)>) ->
|
||||
given.or_else(|| query.into_iter().find(|(k, _)| k == "key").map(|(_, v)| v)).filter(|k| !k.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Refuse to send the server's key (its whole administrative authority, reusable) in the clear:
|
||||
/// `ws://` is only for a server on this machine; anywhere else needs `wss://`.
|
||||
pub(crate) fn key_over_tls(server: &Endpoint, key: Option<&str>) -> Result<(), String> {
|
||||
if key.is_some_and(|k| !k.trim().is_empty()) && !server.secure && !server.loopback() {
|
||||
return Err("the server's key is only sent over wss:// (or ws:// to this machine): it would be readable on the network".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Why asking `server` about its hosting failed with `e`: a server older than open hosting can't
|
||||
/// read the question.
|
||||
pub(crate) fn hosting_error(server: &Endpoint, e: String) -> String {
|
||||
@@ -145,3 +154,21 @@ pub(crate) fn hosted(server: &Endpoint, reply: Option<AdminReply>) -> Option<Adm
|
||||
other => other,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod key_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_key_never_goes_over_plain_tcp_to_another_machine() {
|
||||
let at = |url: &str| Endpoint::parse(url).unwrap().0;
|
||||
assert!(key_over_tls(&at("ws://share.example:9000/"), Some("k")).is_err());
|
||||
assert!(key_over_tls(&at("ws://10.0.0.5:9000/"), Some("k")).is_err());
|
||||
assert!(key_over_tls(&at("wss://share.example/"), Some("k")).is_ok());
|
||||
assert!(key_over_tls(&at("ws://127.0.0.1:9000/"), Some("k")).is_ok());
|
||||
assert!(key_over_tls(&at("ws://localhost:9000/"), Some("k")).is_ok());
|
||||
// Browsing or publishing as anyone sends no key, so plain is fine.
|
||||
assert!(key_over_tls(&at("ws://share.example:9000/"), None).is_ok());
|
||||
assert!(key_over_tls(&at("ws://share.example:9000/"), Some(" ")).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -76,7 +76,7 @@ pub use reshape::selected_connector_handles;
|
||||
/// Why a board command refuses on a design file.
|
||||
pub const NEEDS_BOARD: &str = "this is for boards: make one with File ▸ New board";
|
||||
/// Why prototyping and Dev Mode refuse on a board.
|
||||
pub const NOT_ON_BOARDS: &str = "boards have no prototypes or Dev Mode: use a design file for those";
|
||||
pub const NOT_ON_BOARDS: &str = "boards have no prototypes or Developer Mode: use a design file for those";
|
||||
|
||||
/// A sticky note's side (it grows taller for long notes).
|
||||
pub const STICKY: f64 = 200.0;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//! Arc handles: the on-canvas handles of a selected ellipse's arc (as public documentation of
|
||||
//! Figma-class tools describes them).
|
||||
//! Arc handles: on-canvas controls for a selected ellipse's arc.
|
||||
//!
|
||||
//! - **Sweep** starts on the ellipse's right edge, on its centre line. Dragging it around the
|
||||
//! ellipse opens it into an arc: the sweep is a signed share of a full turn, positive dragging up
|
||||
|
||||
@@ -18,6 +18,10 @@ macro_rules! m {
|
||||
|
||||
pub const SEP: &str = "-";
|
||||
|
||||
/// Extra search terms the Actions palette reads, by command id: a name a feature had before it was
|
||||
/// renamed, so people who know the old name still find the command. Never shown.
|
||||
pub static SEARCH_ALIASES: &[(&str, &str)] = &[("view.devMode", "Dev Mode")];
|
||||
|
||||
pub static MENU: &[MenuItem] = &[
|
||||
// File
|
||||
m!("File", "New design file", "file.new"),
|
||||
|
||||
@@ -347,7 +347,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("view.design", "Design panel", "{}", false, |s, _| ui(s, "panel.design")),
|
||||
spec!("view.prototype", "Prototype panel", "{}", false, |s, _| ui(s, "panel.prototype")),
|
||||
spec!("view.designPrototype", "Switch between Design and Prototype", "{}", false, |s, _| ui(s, "panel.designPrototype")),
|
||||
spec!("view.devMode", "Dev Mode", "{}", false, |s, _| ui(s, "panel.devMode")),
|
||||
spec!("view.devMode", "Developer Mode", "{}", false, |s, _| ui(s, "panel.devMode")),
|
||||
spec!("view.leftSidebar", "Show/Hide left sidebar", "{}", false, |s, _| ui(s, "panel.leftSidebar")),
|
||||
spec!(
|
||||
"view.panelWidths",
|
||||
@@ -431,7 +431,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"pref.uiScaleUp",
|
||||
"Larger interface",
|
||||
"{} the next of the Preferences sizes above the current one (the interface size keys of Accessibility at Figma); at the largest it stays → {scale}",
|
||||
"{} the next of the Preferences sizes above the current one; at the largest it stays → {scale}",
|
||||
false,
|
||||
|s, _| ui_scale_step(s, 1)
|
||||
),
|
||||
|
||||
@@ -200,7 +200,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"devMode.addMeasurement",
|
||||
"Add measurement",
|
||||
"{from? (default the first selected layer), to? (a layer on the same page), side? (top|right|bottom|left)} keeps a measurement on the canvas: between `from` and `to`, across one side of `from`, or (neither) to the layer around it; Dev Mode draws it and it follows the layers → {from, index, lines}",
|
||||
"{from? (default the first selected layer), to? (a layer on the same page), side? (top|right|bottom|left)} keeps a measurement on the canvas: between `from` and `to`, across one side of `from`, or (neither) to the layer around it; Developer Mode draws it and it follows the layers → {from, index, lines}",
|
||||
true,
|
||||
add
|
||||
),
|
||||
|
||||
@@ -224,7 +224,7 @@ pub static KEYS: &[Binding] = &[
|
||||
k(View, "view.design", &["Alt+8"]).label("Design panel"),
|
||||
k(View, "view.prototype", &["Alt+9"]).label("Prototype panel"),
|
||||
k(View, "view.designPrototype", &["Shift+E"]),
|
||||
k(View, "view.devMode", &["Shift+D"]).label("Dev Mode on/off"),
|
||||
k(View, "view.devMode", &["Shift+D"]).label("Developer Mode on/off"),
|
||||
k(View, "view.minimize", &["Shift+Cmd+\\"]),
|
||||
k(View, "view.nextRegion", &["F6"]).win(&["F6", "Ctrl+F6"]).label("Focus the next region (toolbar, layers, canvas, properties)").both(),
|
||||
k(View, "view.previousRegion", &["Shift+F6"]).label("Focus the previous region").both(),
|
||||
|
||||
@@ -38,6 +38,8 @@ mod paste_style_tests;
|
||||
#[cfg(test)]
|
||||
mod prop_value_tests;
|
||||
#[cfg(test)]
|
||||
mod scale_factor_tests;
|
||||
#[cfg(test)]
|
||||
mod slot_tests;
|
||||
#[cfg(test)]
|
||||
mod source_identity_tests;
|
||||
@@ -56,6 +58,8 @@ mod text_style_tests;
|
||||
mod text_wrap_tests;
|
||||
#[cfg(test)]
|
||||
mod value_budget_tests;
|
||||
#[cfg(test)]
|
||||
mod vector_scale_tests;
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -1035,3 +1035,213 @@ fn local_samples_keep_their_variables_and_styles() {
|
||||
assert_eq!(styles(&a), styles(&b), "{}", p.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// #2091 item 1, round trip: the Scale tool's result (radius 10 -> 15) must survive export and import.
|
||||
#[test]
|
||||
fn a_scaled_instance_keeps_its_radius_through_a_round_trip() {
|
||||
let (mut doc, insts) = sized();
|
||||
let main_box = doc.nodes().find(|n| n.name == "Box" && n.parent.is_some_and(|p| doc.node(p).unwrap().name == "Card")).unwrap().id;
|
||||
doc.node_mut(main_box).unwrap().corners.radii = [10.0; 4];
|
||||
let kid = doc.node(insts[2]).unwrap().children[0];
|
||||
doc.node_mut(kid).unwrap().corners.radii = [15.0; 4];
|
||||
let (bytes, _) = export(&doc, None).unwrap();
|
||||
let (back, _) = import(&bytes, "x").unwrap();
|
||||
let inst = back.nodes().find(|n| n.name == "Scaled").unwrap();
|
||||
let b = inst.children.iter().map(|c| back.node(*c).unwrap()).find(|n| n.name == "Box").unwrap();
|
||||
// The 40 px box at the instance's 1.5 scale. The exporter writes every scaled value of a layer
|
||||
// in a derived row, so there is no main-layer value left unwritten here to round-trip; the
|
||||
// scaling of values with no row stays covered by `scale_factor_tests`.
|
||||
assert_eq!((b.width, b.height), (60.0, 60.0));
|
||||
assert_eq!(b.corners.radii, [15.0; 4]);
|
||||
}
|
||||
|
||||
/// Factors outside the importer's range use explicit values, not an ignored scale factor.
|
||||
#[test]
|
||||
fn exported_instance_scale_factors_stay_within_the_importers_range() {
|
||||
for (factor, written_factor) in [(0.0005, 1.0), (0.001, 0.001), (1.0, 1.0), (1000.0, 1000.0), (2000.0, 1.0)] {
|
||||
let (mut doc, insts) = sized();
|
||||
let ids: Vec<_> = std::iter::once(insts[2]).chain(doc.node(insts[2]).unwrap().children.clone()).collect();
|
||||
for id in ids {
|
||||
let n = doc.node(id).unwrap();
|
||||
let source = match n.kind {
|
||||
NodeKind::Instance { component, .. } => component,
|
||||
_ => n.source.unwrap(),
|
||||
};
|
||||
let src = doc.node(source).unwrap().clone();
|
||||
let n = doc.node_mut(id).unwrap();
|
||||
(n.width, n.height) = (src.width * factor, src.height * factor);
|
||||
n.stroke.weight = src.stroke.weight * factor;
|
||||
n.corners.radii = [10.0 * factor; 4];
|
||||
if id != insts[2] {
|
||||
let t = src.transform.translation();
|
||||
n.transform = Affine::translate((t.x * factor, t.y * factor));
|
||||
}
|
||||
if let NodeKind::Text(td) = &mut n.kind {
|
||||
td.style.font_size = src.kind.text().unwrap().style.font_size * factor;
|
||||
}
|
||||
}
|
||||
let nodes = written(&doc);
|
||||
assert_eq!(named(&nodes, "Scaled")["symbolData"]["uniformScaleFactor"], f64::from(written_factor as f32), "factor {factor}");
|
||||
let (bytes, _) = export(&doc, None).unwrap();
|
||||
let (back, _) = import(&bytes, "scale range").unwrap();
|
||||
let instance = back.nodes().find(|n| n.name == "Scaled").unwrap();
|
||||
let child = back.node(instance.children[0]).unwrap();
|
||||
assert!((child.width - 40.0 * factor).abs() < 1e-6 * factor.max(1.0), "factor {factor}: {}", child.width);
|
||||
assert!((child.corners.radii[0] - 10.0 * factor).abs() < 1e-6 * factor.max(1.0), "factor {factor}: {:?}", child.corners.radii);
|
||||
}
|
||||
}
|
||||
|
||||
/// A scaled child may override its radius back to the unscaled main's value.
|
||||
#[test]
|
||||
fn a_scaled_instance_keeps_a_radius_reset_to_its_main_through_a_round_trip() {
|
||||
let (mut doc, insts) = sized();
|
||||
let main_box = doc.nodes().find(|n| n.name == "Box" && n.parent.is_some_and(|p| doc.node(p).unwrap().name == "Card")).unwrap().id;
|
||||
doc.node_mut(main_box).unwrap().corners.radii = [10.0; 4];
|
||||
let kid = doc.node(insts[2]).unwrap().children[0];
|
||||
doc.node_mut(kid).unwrap().corners.radii = [10.0; 4];
|
||||
let (bytes, _) = export(&doc, None).unwrap();
|
||||
let (back, _) = import(&bytes, "x").unwrap();
|
||||
let inst = back.nodes().find(|n| n.name == "Scaled").unwrap();
|
||||
let b = inst.children.iter().map(|c| back.node(*c).unwrap()).find(|n| n.name == "Box").unwrap();
|
||||
// Its geometry is still scaled; only the authored corner radius was reset.
|
||||
assert_eq!((b.width, b.height), (60.0, 60.0));
|
||||
assert_eq!(b.corners.radii, [10.0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scaled_instance_keeps_main_equal_layout_and_grid_lengths() {
|
||||
let (mut doc, insts) = sized();
|
||||
let main = doc.nodes().find(|n| n.name == "Card").unwrap().id;
|
||||
let boxes: Vec<_> = doc.nodes().filter(|n| n.name == "Box").map(|n| n.id).collect();
|
||||
for id in [main, insts[0], insts[1], insts[2]].into_iter().chain(boxes) {
|
||||
let n = doc.node_mut(id).unwrap();
|
||||
if n.name == "Box" {
|
||||
n.kind = NodeKind::Frame;
|
||||
}
|
||||
n.corners.radii = [4.0, 8.0, 12.0, 16.0];
|
||||
n.layout = Some(AutoLayout { gap: 8.0, cross_gap: 6.0, padding: [2.0, 4.0, 6.0, 8.0], ..AutoLayout::default() });
|
||||
n.grids = vec![LayoutGrid { kind: GridKind::Columns, count: 4, gutter: 8.0, ..LayoutGrid::default() }];
|
||||
n.child_layout.min_w = Some(20.0);
|
||||
n.child_layout.max_h = Some(200.0);
|
||||
}
|
||||
let nodes = written(&doc);
|
||||
assert_eq!(named(&nodes, "Scaled")["symbolData"]["uniformScaleFactor"], 1.5);
|
||||
same_after_round_trip(&doc, "scaled main-equal layout and grid lengths");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scaled_instance_keeps_main_equal_stroke_and_effect_lengths() {
|
||||
let (mut doc, insts) = sized();
|
||||
let child = doc.node(insts[2]).unwrap().children[0];
|
||||
let source = doc.node(child).unwrap().source.unwrap();
|
||||
for id in [source, child] {
|
||||
let n = doc.node_mut(id).unwrap();
|
||||
n.corners.radii = [2.0, 4.0, 6.0, 8.0];
|
||||
n.stroke.dashes = vec![4.0, 2.0];
|
||||
n.effects = vec![Effect {
|
||||
kind: EffectKind::DropShadow(Shadow {
|
||||
color: Color::new(0.0, 0.0, 0.0, 0.25),
|
||||
offset: (2.0, 4.0),
|
||||
radius: 8.0,
|
||||
spread: 2.0,
|
||||
blend: BlendMode::Normal,
|
||||
show_behind: false,
|
||||
}),
|
||||
visible: true,
|
||||
}];
|
||||
}
|
||||
let nodes = written(&doc);
|
||||
assert_eq!(named(&nodes, "Scaled")["symbolData"]["uniformScaleFactor"], 1.5);
|
||||
same_after_round_trip(&doc, "scaled main-equal stroke and effect lengths");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scaled_instance_keeps_main_equal_text_lengths_and_runs() {
|
||||
let (mut doc, insts) = sized();
|
||||
let child = doc.node(insts[2]).unwrap().children[1];
|
||||
let source = doc.node(child).unwrap().source.unwrap();
|
||||
for id in [source, child] {
|
||||
let NodeKind::Text(td) = &mut doc.node_mut(id).unwrap().kind else { panic!("text fixture") };
|
||||
td.style.font_size = 16.0;
|
||||
td.style.line_height = LineHeight::Pixels(24.0);
|
||||
td.style.letter_spacing = LetterSpacing::Pixels(2.0);
|
||||
td.paragraph_spacing = 6.0;
|
||||
td.paragraph_indent = 4.0;
|
||||
td.runs = vec![StyleRun { start: 0, end: 2, style: TextStyle { font_size: 20.0, ..td.style.clone() } }];
|
||||
}
|
||||
let nodes = written(&doc);
|
||||
assert_eq!(named(&nodes, "Scaled")["symbolData"]["uniformScaleFactor"], 1.5);
|
||||
same_after_round_trip(&doc, "scaled main-equal text lengths and runs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scaled_instance_keeps_a_main_equal_text_length_above_its_shared_style() {
|
||||
let (mut doc, insts) = sized();
|
||||
let child = doc.node(insts[2]).unwrap().children[1];
|
||||
let source = doc.node(child).unwrap().source.unwrap();
|
||||
let base = TextStyle { font_size: 12.0, line_height: LineHeight::Pixels(24.0), ..TextStyle::default() };
|
||||
doc.styles.push(Style {
|
||||
id: "label-style".into(),
|
||||
name: "Label style".into(),
|
||||
description: String::new(),
|
||||
kind: StyleKind::Text { style: base.clone(), paragraph: Default::default() },
|
||||
bound: Default::default(),
|
||||
});
|
||||
for id in [source, child] {
|
||||
let n = doc.node_mut(id).unwrap();
|
||||
n.styles.text = Some("label-style".into());
|
||||
let NodeKind::Text(td) = &mut n.kind else { panic!("text fixture") };
|
||||
td.style = base.clone();
|
||||
if id == child {
|
||||
td.style.line_height = LineHeight::Pixels(36.0);
|
||||
}
|
||||
}
|
||||
let nodes = written(&doc);
|
||||
assert_eq!(named(&nodes, "Scaled")["symbolData"]["uniformScaleFactor"], 1.5);
|
||||
same_after_round_trip(&doc, "one text length reset above an inherited shared style");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scaled_instance_keeps_reset_lists_above_a_changed_style_link() {
|
||||
for (scaled, value) in [false, true].into_iter().flat_map(|scaled| [Some(10.0), Some(20.0), Some(15.0), None].map(|value| (scaled, value))) {
|
||||
let (mut doc, insts) = sized();
|
||||
let inst = insts[if scaled { 2 } else { 0 }];
|
||||
let child = doc.node(inst).unwrap().children[0];
|
||||
let source = doc.node(child).unwrap().source.unwrap();
|
||||
let boxes: Vec<_> = doc.nodes().filter(|n| n.name == "Box").map(|n| n.id).collect();
|
||||
for id in boxes {
|
||||
doc.node_mut(id).unwrap().kind = NodeKind::Frame;
|
||||
}
|
||||
let effects = |radius| vec![Effect { kind: EffectKind::DropShadow(Shadow { radius, ..Shadow::default() }), visible: true }];
|
||||
let grids = |gutter| vec![LayoutGrid { kind: GridKind::Columns, count: 4, gutter, ..LayoutGrid::default() }];
|
||||
for (id, kind) in [
|
||||
("effect-a", StyleKind::Effect { effects: effects(10.0) }),
|
||||
("effect-b", StyleKind::Effect { effects: effects(20.0) }),
|
||||
("grid-a", StyleKind::Grid { grids: grids(10.0) }),
|
||||
("grid-b", StyleKind::Grid { grids: grids(20.0) }),
|
||||
] {
|
||||
doc.styles.push(Style { id: id.into(), name: id.into(), description: String::new(), kind, bound: Default::default() });
|
||||
}
|
||||
for id in [source, child] {
|
||||
let n = doc.node_mut(id).unwrap();
|
||||
let value = if id == source { Some(10.0) } else { value };
|
||||
n.effects = value.map_or_else(Vec::new, effects);
|
||||
n.grids = value.map_or_else(Vec::new, grids);
|
||||
n.styles.effect = Some(if id == source { "effect-a" } else { "effect-b" }.into());
|
||||
n.styles.grid = Some(if id == source { "grid-a" } else { "grid-b" }.into());
|
||||
}
|
||||
let nodes = written(&doc);
|
||||
let name = if scaled { "Scaled" } else { "Same" };
|
||||
assert_eq!(named(&nodes, name)["symbolData"]["uniformScaleFactor"], if scaled { 1.5 } else { 1.0 });
|
||||
if value == Some(20.0) {
|
||||
let inst = named(&nodes, name);
|
||||
for rows in [&inst["symbolData"]["symbolOverrides"], &inst["derivedSymbolData"]] {
|
||||
assert!(
|
||||
rows.as_array().into_iter().flatten().all(|row| row.get("effects").is_none() && row.get("layoutGrids").is_none()),
|
||||
"values supplied by the new style remain linked, not overridden"
|
||||
);
|
||||
}
|
||||
}
|
||||
same_after_round_trip(&doc, "explicit list values above a changed style link");
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ mod component;
|
||||
mod layer;
|
||||
mod paint;
|
||||
mod prototype;
|
||||
mod scale;
|
||||
mod shape;
|
||||
|
||||
/// Most layers written, and deepest nesting followed (the import's limits).
|
||||
|
||||
@@ -1,7 +1,34 @@
|
||||
use super::super::map::{MAX_SCALE_FACTOR, MIN_SCALE_FACTOR};
|
||||
use super::common::{
|
||||
bool_data, changed, prop_type, resolved_alike, scaled, sort_position, symbol_data, text_data, text_value, var_data, variant_pairs,
|
||||
bool_data, changed, layout_grid, prop_type, resolved_alike, scaled, sort_position, symbol_data, text_data, text_value, var_data, variant_pairs,
|
||||
};
|
||||
use super::{AUX, BTreeMap, Exporter, Fields, Kv, MAX_NODES, Node, NodeId, NodeKind, PropDef, Value, arr, b, e, fl, guid, o, s};
|
||||
use super::scale::main_equal_lengths;
|
||||
use super::{AUX, BTreeMap, Bound, Exporter, Fields, Kv, MAX_NODES, Node, NodeId, NodeKind, PropDef, StyleKind, Value, arr, b, e, fl, guid, o, s};
|
||||
|
||||
/// A changed effect/grid style, and a payload only when it differs from that style's values.
|
||||
struct ListStyleOverride {
|
||||
link: &'static str,
|
||||
field: &'static str,
|
||||
value: Option<Kv<'static>>,
|
||||
}
|
||||
|
||||
/// Keep a changed style link below its deliberate payload. Putting both on one row makes the
|
||||
/// style beat the payload as a stale copy. Values equal to the new style need no override.
|
||||
fn separate_style_rows(of: &mut Fields, df: &mut Fields, styles: Vec<ListStyleOverride>) -> Fields {
|
||||
let Some(path) = of.iter().find(|(k, _)| *k == "guidPath").cloned() else { return Vec::new() };
|
||||
let mut links = vec![path];
|
||||
for style in styles {
|
||||
let Some(link) = of.iter().find(|(k, _)| *k == style.link).cloned() else { continue };
|
||||
of.retain(|(k, _)| *k != style.field);
|
||||
df.retain(|(k, _)| *k != style.field);
|
||||
if let Some(value) = style.value {
|
||||
of.retain(|(k, _)| *k != style.link);
|
||||
links.push(link);
|
||||
of.push((style.field, value));
|
||||
}
|
||||
}
|
||||
links
|
||||
}
|
||||
|
||||
impl<'d> Exporter<'d> {
|
||||
/// A component property definition of `owner` (a component, or a set), `index`-th in its list.
|
||||
@@ -142,12 +169,17 @@ impl<'d> Exporter<'d> {
|
||||
let set = m.parent.and_then(|p| doc.node(p)).filter(|p| matches!(p.kind, NodeKind::ComponentSet { .. }));
|
||||
let named = set.is_none_or(|s| s.name != n.name);
|
||||
let root_mine: Fields = f.iter().filter(|(k, _)| !OWN.contains(k) && (named || *k != "name")).cloned().collect();
|
||||
let scale_candidate = Some(n.width / m.width)
|
||||
.filter(|k| (MIN_SCALE_FACTOR..=MAX_SCALE_FACTOR).contains(k) && (k - 1.0).abs() > 1e-6)
|
||||
.filter(|k| (n.height / m.height - k).abs() <= 1e-6 * k.max(1.0));
|
||||
// Each layer below: its id path, the fields that differ from the main's layer, that layer's
|
||||
// fields, whether its size is its own (a nested instance, a size override) and the
|
||||
// component a nested instance was swapped for.
|
||||
struct Row {
|
||||
path: Kv<'static>,
|
||||
changed: Fields,
|
||||
main_equal_lengths: Fields,
|
||||
styles: Vec<ListStyleOverride>,
|
||||
theirs: Fields,
|
||||
sized: bool,
|
||||
swap: Option<Kv<'static>>,
|
||||
@@ -178,21 +210,27 @@ impl<'d> Exporter<'d> {
|
||||
_ => None,
|
||||
};
|
||||
let sized = matches!(d.kind, NodeKind::Instance { .. }) || d.overrides.contains("size");
|
||||
rows.push(Row { path: o(vec![("guids", arr(guids))]), changed: changed(mine, &theirs), theirs, sized, swap });
|
||||
rows.push(Row {
|
||||
path: o(vec![("guids", arr(guids))]),
|
||||
main_equal_lengths: if scale_candidate.is_some() { main_equal_lengths(&mine, &theirs) } else { Vec::new() },
|
||||
styles: self.changed_list_styles(d, &mine, &theirs),
|
||||
changed: changed(mine, &theirs),
|
||||
theirs,
|
||||
sized,
|
||||
swap,
|
||||
});
|
||||
}
|
||||
// An instance scaled as a whole (the Scale tool) is `factor` times its main in every length:
|
||||
// sizes, places and stroke weights, its own and its layers'. A resized one keeps its weights.
|
||||
let field = |fields: &Fields, key: &str| fields.iter().find(|(k, _)| *k == key).map(|(_, v)| v.clone());
|
||||
let factor = Some(n.width / m.width).filter(|k| k.is_finite() && *k > 0.0 && (k - 1.0).abs() > 1e-6).filter(|k| {
|
||||
let factor = scale_candidate.filter(|k| {
|
||||
let grown = |mine: &Fields, theirs: &Fields| {
|
||||
let weight = field(theirs, "strokeWeight").and_then(|w| w.f64()).unwrap_or(0.0);
|
||||
(weight == 0.0 || field(mine, "strokeWeight").is_some_and(|w| scaled(&w, &fl(weight), *k, 0.0)))
|
||||
&& field(mine, "size").zip(field(theirs, "size")).is_some_and(|(a, b)| scaled(&a, &b, *k, 1.0))
|
||||
&& field(mine, "transform").zip(field(theirs, "transform")).is_none_or(|(a, b)| scaled(&a, &b, *k, 1.0))
|
||||
};
|
||||
(n.height / m.height - k).abs() <= 1e-6 * k.max(1.0)
|
||||
&& grown(&changed(root_mine.clone(), &root_theirs), &root_theirs)
|
||||
&& rows.iter().all(|r| grown(&r.changed, &r.theirs))
|
||||
grown(&changed(root_mine.clone(), &root_theirs), &root_theirs) && rows.iter().all(|r| grown(&r.changed, &r.theirs))
|
||||
});
|
||||
// What follows from the overrides goes with the derived data: a layer's size, place and
|
||||
// outline (a vector's network is the main's, drawn at the layer's size), the colours its
|
||||
@@ -205,12 +243,20 @@ impl<'d> Exporter<'d> {
|
||||
let mut overrides = Vec::new();
|
||||
let mut derived = Vec::new();
|
||||
let mut of: Fields = vec![("guidPath", o(vec![("guids", arr(vec![symbol.clone()]))]))];
|
||||
// A main-equal length is an override of the scaled main, not a derived scaled value.
|
||||
// Keep it out of `follows`, whose permissive equality also accepts dimensionless numbers.
|
||||
if factor.is_some() {
|
||||
of.extend(main_equal_lengths(&root_mine, &root_theirs));
|
||||
}
|
||||
of.extend(changed(root_mine, &root_theirs).into_iter().filter(|(k, v)| *k == "size" || !follows(k, v, &root_theirs)));
|
||||
if of.len() > 1 {
|
||||
overrides.push(o(of));
|
||||
}
|
||||
for r in rows {
|
||||
let (mut of, mut df): (Fields, Fields) = (vec![("guidPath", r.path.clone())], vec![("guidPath", r.path)]);
|
||||
if factor.is_some() {
|
||||
of.extend(r.main_equal_lengths);
|
||||
}
|
||||
for (k, v) in r.changed {
|
||||
let follows = follows(k, &v, &r.theirs);
|
||||
// A nested instance's size is its own to set, as is a size the layer overrides.
|
||||
@@ -219,6 +265,10 @@ impl<'d> Exporter<'d> {
|
||||
}
|
||||
if follows { &mut df } else { &mut of }.push((k, v));
|
||||
}
|
||||
let links = separate_style_rows(&mut of, &mut df, r.styles);
|
||||
if links.len() > 1 {
|
||||
overrides.push(o(links));
|
||||
}
|
||||
if df.len() > 1 {
|
||||
derived.push(o(df));
|
||||
}
|
||||
@@ -256,6 +306,38 @@ impl<'d> Exporter<'d> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A changed list-style link is at the instance row's scale (1), not the inherited main's.
|
||||
/// Compare with the same payload and bindings the asset writer emits. Preserve differing
|
||||
/// effective values, including empty clears, but do not freeze values supplied by the style.
|
||||
fn changed_list_styles(&mut self, n: &Node, mine: &Fields, theirs: &Fields) -> Vec<ListStyleOverride> {
|
||||
let mut out = Vec::new();
|
||||
let doc = self.doc;
|
||||
for (link, field, id) in
|
||||
[("styleIdForEffect", "effects", n.styles.effect.as_deref()), ("styleIdForGrid", "layoutGrids", n.styles.grid.as_deref())]
|
||||
{
|
||||
let current_link = mine.iter().find(|(k, _)| *k == link).map(|(_, v)| v);
|
||||
if current_link.is_none() || current_link == theirs.iter().find(|(k, _)| *k == link).map(|(_, v)| v) {
|
||||
continue;
|
||||
}
|
||||
let Some(style) = id.and_then(|id| doc.styles.iter().find(|style| style.id == id)) else { continue };
|
||||
let bound = if style.bound.is_empty() {
|
||||
Bound::default()
|
||||
} else {
|
||||
let mut sample = Node::new(NodeId(0), NodeKind::Rectangle, "", 0.0, 0.0);
|
||||
sample.bound.clone_from(&style.bound);
|
||||
self.bound(&sample)
|
||||
};
|
||||
let target = match (&style.kind, field) {
|
||||
(StyleKind::Effect { effects }, "effects") => arr(effects.iter().enumerate().map(|(i, e)| self.effect(e, i, &bound)).collect()),
|
||||
(StyleKind::Grid { grids }, "layoutGrids") => arr(grids.iter().enumerate().map(|(i, g)| layout_grid(g, i, &bound)).collect()),
|
||||
_ => continue,
|
||||
};
|
||||
let current = mine.iter().find(|(k, _)| *k == field).map_or_else(|| arr(Vec::new()), |(_, v)| v.clone());
|
||||
out.push(ListStyleOverride { link, field, value: (current != target).then_some(current) });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The main component layer an instance or instance layer mirrors (through nested instances),
|
||||
/// or `id` itself.
|
||||
pub(super) fn main_layer(&self, id: NodeId) -> NodeId {
|
||||
|
||||
@@ -304,7 +304,7 @@ impl<'d> Exporter<'d> {
|
||||
|| !n.measurements.is_empty()
|
||||
|| !n.dev_status.is_none()
|
||||
{
|
||||
self.warn("Dev Mode annotations, statuses, resources, measurements and code links were left out");
|
||||
self.warn("Developer Mode annotations, statuses, resources, measurements and code links were left out");
|
||||
}
|
||||
if !n.plugin_data.is_empty() {
|
||||
self.warn("plugin data was left out");
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
use super::{Fields, Kv};
|
||||
|
||||
/// Main-equal fields which are not equal to the scaled main. Keep these separately until the
|
||||
/// instance's factor is known: the ordinary diff would discard a length reset to its main's value.
|
||||
pub(super) fn main_equal_lengths(mine: &Fields, theirs: &Fields) -> Fields {
|
||||
mine.iter().filter(|(k, v)| has_length(k, v) && theirs.iter().any(|(tk, tv)| tk == k && tv == v)).cloned().collect()
|
||||
}
|
||||
|
||||
/// Whether a field contains a finite, nonzero length read at its supplying row's scale by the
|
||||
/// importer. Ratios, IDs, colours, grid counts and unit-box coordinates are not lengths.
|
||||
fn has_length(key: &str, v: &Kv) -> bool {
|
||||
match key {
|
||||
"strokeWeight"
|
||||
| "borderTopWeight"
|
||||
| "borderRightWeight"
|
||||
| "borderBottomWeight"
|
||||
| "borderLeftWeight"
|
||||
| "cornerRadius"
|
||||
| "rectangleTopLeftCornerRadius"
|
||||
| "rectangleTopRightCornerRadius"
|
||||
| "rectangleBottomRightCornerRadius"
|
||||
| "rectangleBottomLeftCornerRadius"
|
||||
| "stackSpacing"
|
||||
| "stackCounterSpacing"
|
||||
| "stackHorizontalPadding"
|
||||
| "stackVerticalPadding"
|
||||
| "stackPaddingRight"
|
||||
| "stackPaddingBottom"
|
||||
| "gridColumnGap"
|
||||
| "gridRowGap"
|
||||
| "fontSize"
|
||||
| "paragraphSpacing"
|
||||
| "paragraphIndent"
|
||||
| "listSpacing" => nonzero(v),
|
||||
"size" => numbers(v, &["x", "y"]),
|
||||
"transform" => numbers(v, &["m02", "m12"]),
|
||||
"minSize" | "maxSize" => v.get("value").is_some_and(|p| numbers(p, &["x", "y"])),
|
||||
"dashPattern" => v.list().iter().any(nonzero),
|
||||
"lineHeight" | "letterSpacing" => v.get("units").and_then(Kv::str) == Some("PIXELS") && numbers(v, &["value"]),
|
||||
"effects" => v.list().iter().any(|e| {
|
||||
numbers(e, &["radius", "spread", "startRadius"])
|
||||
|| ["offset", "noiseSize"].iter().any(|k| e.get(k).is_some_and(|p| numbers(p, &["x", "y"])))
|
||||
}),
|
||||
"layoutGrids" => v.list().iter().any(|g| numbers(g, &["sectionSize", "gutterSize", "offset"])),
|
||||
"textData" => v.get("styleOverrideTable").map(Kv::list).unwrap_or_default().iter().any(|style| {
|
||||
numbers(style, &["fontSize", "paragraphSpacing", "paragraphIndent", "listSpacing"])
|
||||
|| ["lineHeight", "letterSpacing"].iter().any(|k| style.get(k).is_some_and(|v| has_length(k, v)))
|
||||
}),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Zero is unchanged by every scale; nonfinite sentinels (an absent maximum) are unchanged too.
|
||||
fn nonzero(v: &Kv) -> bool {
|
||||
v.f64().is_some_and(|n| n != 0.0)
|
||||
}
|
||||
|
||||
/// Test only the named length members, not other numbers in a composite field.
|
||||
fn numbers(v: &Kv, keys: &[&str]) -> bool {
|
||||
keys.iter().any(|key| v.get(key).is_some_and(nonzero))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::{arr, e, fl, o};
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dimensionless_values_and_zero_do_not_create_scale_overrides() {
|
||||
for (key, value) in [
|
||||
("opacity", fl(0.5)),
|
||||
("cornerRadius", fl(0.0)),
|
||||
("cornerSmoothing", fl(0.5)),
|
||||
("letterSpacing", o(vec![("units", e("PERCENT")), ("value", fl(10.0))])),
|
||||
("lineHeight", o(vec![("units", e("RAW")), ("value", fl(1.5))])),
|
||||
("transform", o(vec![("m00", fl(1.0)), ("m11", fl(1.0))])),
|
||||
("layoutGrids", arr(vec![o(vec![("numSections", fl(12.0))])])),
|
||||
("effects", arr(vec![o(vec![("density", fl(0.5)), ("startOffset", o(vec![("x", fl(0.5))]))])])),
|
||||
("maxSize", o(vec![("value", o(vec![("x", Kv::Float(f32::INFINITY))]))])),
|
||||
] {
|
||||
assert!(!has_length(key, &value), "{key}");
|
||||
}
|
||||
assert!(has_length("paragraphIndent", &fl(-4.0)));
|
||||
assert!(has_length("lineHeight", &o(vec![("units", e("PIXELS")), ("value", fl(24.0))])));
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,10 @@ const MAX_DEPTH: usize = 256;
|
||||
const MAX_COMMANDS: usize = 200_000;
|
||||
const MAX_GRID_TRACKS: usize = 1_000;
|
||||
pub(super) const MAX_GRID_TRACK_VALUE: f64 = 1e6;
|
||||
/// Smallest and largest scale factor of a scaled instance (`symbolData.uniformScaleFactor`, times
|
||||
/// those of the instances around it) that is followed: a junk factor changes nothing.
|
||||
pub(super) const MIN_SCALE_FACTOR: f64 = 1e-3;
|
||||
pub(super) const MAX_SCALE_FACTOR: f64 = 1e3;
|
||||
/// Id of "no style".
|
||||
const NO_STYLE: &str = "4294967295:4294967295";
|
||||
/// Longest asset `key` or `version` kept for resolving references given as assets.
|
||||
@@ -147,6 +151,22 @@ fn copy_num(o: &mut Obj, n: &View, from: &str, to: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`copy_num`] for a length: the layers of a scaled instance give it at the instance's scale.
|
||||
fn copy_len(o: &mut Obj, n: &View, from: &str, to: &str) {
|
||||
if let Some(x) = n.len(from) {
|
||||
o.insert(to.into(), json!(x));
|
||||
}
|
||||
}
|
||||
|
||||
/// `x` at scale `k`; `None` when the result is not finite.
|
||||
fn scaled(x: f64, k: f64) -> Option<f64> {
|
||||
if k == 1.0 {
|
||||
return Some(x);
|
||||
}
|
||||
let r = x * k;
|
||||
r.is_finite().then_some(r)
|
||||
}
|
||||
|
||||
fn copy_str(o: &mut Obj, n: &View, from: &str, to: &str) {
|
||||
if let Some(s) = n.str(from) {
|
||||
o.insert(to.into(), json!(s));
|
||||
@@ -157,29 +177,75 @@ fn copy_str(o: &mut Obj, n: &View, from: &str, to: &str) {
|
||||
#[derive(Clone)]
|
||||
struct View<'a> {
|
||||
layers: Vec<&'a Kv<'a>>,
|
||||
/// The factor each layer's lengths take (`scales[i]` is for `layers[i]`): the layers of a main
|
||||
/// inside a scaled instance have the instance's, the override and derived rows of the instance
|
||||
/// 1, being at the instance's scale already (#2091).
|
||||
scales: Vec<f64>,
|
||||
/// Size and transform worked out from the constraints, over the layers' own.
|
||||
fitted: Option<((f64, f64), Affine)>,
|
||||
}
|
||||
|
||||
impl<'a> View<'a> {
|
||||
fn of(n: &'a Kv<'a>) -> Self {
|
||||
Self { layers: vec![n], fitted: None }
|
||||
Self::of_scaled(n, 1.0)
|
||||
}
|
||||
|
||||
/// A node of a main inside an instance scaled by `k`: its lengths read `k` times as long.
|
||||
fn of_scaled(n: &'a Kv<'a>, k: f64) -> Self {
|
||||
Self { layers: vec![n], scales: vec![k], fitted: None }
|
||||
}
|
||||
|
||||
/// This view with the layers of `upper` over it (`upper`'s fitted size and place).
|
||||
fn over(&self, upper: &Self) -> Self {
|
||||
let mut v = self.clone();
|
||||
v.layers.extend(upper.layers.iter().copied());
|
||||
v.scales.extend(upper.scales.iter().copied());
|
||||
v.fitted = upper.fitted;
|
||||
v
|
||||
}
|
||||
|
||||
/// The factor of the lowest layer (the layer's own main), for values a style gives.
|
||||
fn base_scale(&self) -> f64 {
|
||||
self.scales.first().copied().unwrap_or(1.0)
|
||||
}
|
||||
|
||||
fn size(&self) -> Option<(f64, f64)> {
|
||||
self.fitted.map(|f| f.0).or_else(|| self.get("size").and_then(size))
|
||||
self.fitted.map(|f| f.0).or_else(|| {
|
||||
let (v, k) = self.find("size")?;
|
||||
let (x, y) = size(v)?;
|
||||
Some((scaled(x, k)?, scaled(y, k)?))
|
||||
})
|
||||
}
|
||||
|
||||
fn transform(&self) -> Option<Affine> {
|
||||
self.fitted.map(|f| f.1).or_else(|| self.get("transform").and_then(matrix))
|
||||
self.fitted.map(|f| f.1).or_else(|| {
|
||||
let (v, k) = self.find("transform")?;
|
||||
let m = matrix(v)?;
|
||||
if k == 1.0 {
|
||||
return Some(m);
|
||||
}
|
||||
let [a, b, c, d, e, f] = m.as_coeffs();
|
||||
let m = Affine::new([a, b, c, d, scaled(e, k)?, scaled(f, k)?]);
|
||||
Some(m)
|
||||
})
|
||||
}
|
||||
|
||||
fn with(&self, top: impl IntoIterator<Item = &'a Kv<'a>>) -> Self {
|
||||
/// This view with `top` over it, each layer with the factor its lengths take.
|
||||
fn with(&self, top: impl IntoIterator<Item = (&'a Kv<'a>, f64)>) -> Self {
|
||||
let mut v = self.clone();
|
||||
v.layers.extend(top);
|
||||
for (layer, scale) in top {
|
||||
v.layers.push(layer);
|
||||
v.scales.push(scale);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// The factor of the layer that links a style in `key` (or `legacy`), the lowest layer's when
|
||||
/// none does: what the linked style's lengths take.
|
||||
fn link_scale(&self, key: &str, legacy: &str) -> f64 {
|
||||
self.find(key).or_else(|| self.find(legacy)).map_or_else(|| self.base_scale(), |f| f.1)
|
||||
}
|
||||
|
||||
/// This view with `style` just above the layer that links it in `key` (or `legacy`): the style
|
||||
/// beats that layer's and lower layers' copies of its fields (stale when the style changed
|
||||
/// since, or written for the style linked before), and override rows above the link beat it.
|
||||
@@ -188,15 +254,50 @@ impl<'a> View<'a> {
|
||||
if let Some(s) = style {
|
||||
let linked_in = |k: &str| v.layers.iter().rposition(|l| l.get(k).is_some());
|
||||
let at = linked_in(key).or_else(|| linked_in(legacy)).map_or(1, |i| i + 1);
|
||||
v.layers.insert(at.min(v.layers.len()), s);
|
||||
let at = at.min(v.layers.len());
|
||||
// The style is at the scale of the layer that links it.
|
||||
let k = at.checked_sub(1).and_then(|i| v.scales.get(i)).copied().unwrap_or(1.0);
|
||||
v.layers.insert(at, s);
|
||||
v.scales.insert(at, k);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// An effect/grid list follows the same precedence as text styles: an override above the
|
||||
/// style link wins, while the style beats stale copies on and below its linking row. A style
|
||||
/// without the list is empty; it must not expose a stale list from a lower row.
|
||||
fn styled_list(&self, style: &'a Kv<'a>, list: &str, key: &str, legacy: &str) -> (&'a [Kv<'a>], f64) {
|
||||
let linked_in = |k: &str| self.layers.iter().rposition(|l| l.get(k).is_some());
|
||||
let above = linked_in(key).or_else(|| linked_in(legacy)).map_or(1, |i| i + 1);
|
||||
self.layers
|
||||
.iter()
|
||||
.zip(&self.scales)
|
||||
.skip(above)
|
||||
.rev()
|
||||
.find_map(|(row, scale)| row.get(list).map(|v| (v.list(), *scale)))
|
||||
.unwrap_or_else(|| (style.get(list).map(Kv::list).unwrap_or_default(), self.link_scale(key, legacy)))
|
||||
}
|
||||
|
||||
fn get(&self, k: &str) -> Option<&'a Kv<'a>> {
|
||||
self.layers.iter().rev().find_map(|l| l.get(k))
|
||||
}
|
||||
|
||||
/// [`Self::get`], and the factor of the layer that has it.
|
||||
fn find(&self, k: &str) -> Option<(&'a Kv<'a>, f64)> {
|
||||
self.layers.iter().zip(&self.scales).rev().find_map(|(l, s)| l.get(k).map(|v| (v, *s)))
|
||||
}
|
||||
|
||||
/// A length: the number `k` holds at the scale of the layer that has it.
|
||||
fn len(&self, k: &str) -> Option<f64> {
|
||||
let (v, s) = self.find(k)?;
|
||||
scaled(v.f64()?, s)
|
||||
}
|
||||
|
||||
/// [`Self::list`], and the factor of the layer that has it.
|
||||
fn list_scaled(&self, k: &str) -> (&'a [Kv<'a>], f64) {
|
||||
self.find(k).map_or((&[], 1.0), |(v, s)| (v.list(), s))
|
||||
}
|
||||
|
||||
fn num(&self, k: &str) -> Option<f64> {
|
||||
self.get(k).and_then(Kv::f64)
|
||||
}
|
||||
@@ -255,8 +356,8 @@ struct Mapper<'a> {
|
||||
/// holding it) name it by `vectorNetworkRef`. Kept out of the JSON, where a large file's
|
||||
/// networks took gigabytes.
|
||||
networks: Vec<Option<VectorNetwork>>,
|
||||
/// Index in `networks` by what a network is made from: its blob, its scale and its style table.
|
||||
network_ids: HashMap<(usize, u64, u64, usize), usize>,
|
||||
/// Index in `networks` by its blob, position scale, style table and point-radius scale.
|
||||
network_ids: HashMap<(usize, u64, u64, usize, u64), usize>,
|
||||
/// Region paint elements (paints and gradient stops) read so far: one budget for the whole
|
||||
/// file, since layers with region paints don't share their network.
|
||||
region_paint_elements: usize,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::layer::own_text;
|
||||
use super::text::char_styles;
|
||||
use super::{BTreeMap, BTreeSet, Kv, MAX_DEPTH, Mapper, Obj, Rc, Value, View, guid, json};
|
||||
use super::{BTreeMap, BTreeSet, Kv, MAX_DEPTH, MAX_SCALE_FACTOR, MIN_SCALE_FACTOR, Mapper, Obj, Rc, Value, View, guid, json};
|
||||
|
||||
/// An override row names a layer of the main by its current GUID or by the `overrideKey` the
|
||||
/// layer had when the row was written: the key becomes the current GUID, unless the layer is
|
||||
@@ -41,6 +41,10 @@ pub(super) struct OverrideRow<'a> {
|
||||
level: usize,
|
||||
/// Position among the rows of the instance that wrote it; later rows win.
|
||||
ordinal: usize,
|
||||
/// The factor the row's lengths take: 1 for the rows of an instance in the document, the
|
||||
/// factor of the layer of the enclosing main that holds the rows for a nested instance's own
|
||||
/// (they are written in that main's space, #2091).
|
||||
pub(super) scale: f64,
|
||||
}
|
||||
|
||||
/// The nested level an override row restates: its `overrideLevel`, capped; 0 when it has none.
|
||||
@@ -84,13 +88,19 @@ pub(super) struct Inst<'a> {
|
||||
pub(super) root: String,
|
||||
pub(super) path: Vec<String>,
|
||||
pub(super) overrides: Rc<Overrides<'a>>,
|
||||
props: Rc<Chain<&'a Kv<'a>>>,
|
||||
/// Property values with the factor their lengths take (the layer of the writing instance's
|
||||
/// node that holds the assignment, as for override rows).
|
||||
props: Rc<Chain<(&'a Kv<'a>, f64)>>,
|
||||
/// Slot property GUID → private slot-content node GUID.
|
||||
pub(super) slot_contents: Rc<Chain<String>>,
|
||||
/// Layers imported from a slot's independent content subtree.
|
||||
pub(super) slot_content: bool,
|
||||
/// Mains being expanded (an instance inside its own main stops there).
|
||||
symbols: Vec<String>,
|
||||
/// The factor the lengths of the expanded layers take: the instance's `uniformScaleFactor`
|
||||
/// times those of the instances around it (#2091). Override and derived rows are already at
|
||||
/// the instance's scale and take none.
|
||||
pub(super) scale: f64,
|
||||
}
|
||||
|
||||
impl<'a> Mapper<'a> {
|
||||
@@ -260,7 +270,7 @@ impl<'a> Mapper<'a> {
|
||||
{
|
||||
refs.insert(field.into(), json!(name));
|
||||
}
|
||||
let Some(val) = inst.and_then(|i| i.props.get(&self.prop_root(&def))) else { continue };
|
||||
let Some(&(val, val_scale)) = inst.and_then(|i| i.props.get(&self.prop_root(&def))) else { continue };
|
||||
match field {
|
||||
"visible" => {
|
||||
if let Some(b) = val.get("boolValue").and_then(Kv::bool) {
|
||||
@@ -278,7 +288,7 @@ impl<'a> Mapper<'a> {
|
||||
o.remove("_figLineStarts");
|
||||
if let Some(tv) = prop_text_data(val) {
|
||||
let size = o.get("style").and_then(|s| s.get("fontSize")).and_then(Value::as_f64);
|
||||
char_styles(o, tv, size, &mut |w| self.warn(w));
|
||||
char_styles(o, tv, val_scale, size, &mut |w| self.warn(w));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -298,7 +308,7 @@ impl<'a> Mapper<'a> {
|
||||
// An instance swap property value picks the main.
|
||||
if let Some(i) = outer
|
||||
&& let Some(def) = prop_ref(v, "OVERRIDDEN_SYMBOL_ID")
|
||||
&& let Some(g) = i.props.get(&self.prop_root(&def)).and_then(|p| prop_guid(p))
|
||||
&& let Some(g) = i.props.get(&self.prop_root(&def)).and_then(|(p, _)| prop_guid(p))
|
||||
{
|
||||
sym = Some(g);
|
||||
}
|
||||
@@ -353,17 +363,25 @@ impl<'a> Mapper<'a> {
|
||||
}
|
||||
// A nested instance's derived layout is for the size it has in its main: resized here, the
|
||||
// sizes and places in it no longer hold and its layers follow their constraints instead.
|
||||
let differs = |a: f64, b: f64| (a - b).abs() > 1e-6 * a.abs().max(b.abs()).max(1.0);
|
||||
// A scaled size is the factor's product, rounded to a float: a looser match.
|
||||
let tolerance = if outer.is_some_and(|i| i.scale != 1.0) { 1e-4 } else { 1e-6 };
|
||||
let differs = |a: f64, b: f64| (a - b).abs() > tolerance * a.abs().max(b.abs()).max(1.0);
|
||||
let outer_scale = outer.map_or(1.0, |i| i.scale);
|
||||
let resized = outer.is_some()
|
||||
&& match (self.nodes.get(id).and_then(|b| View::of(b).size()), v.size()) {
|
||||
&& match (self.nodes.get(id).and_then(|b| View::of_scaled(b, outer_scale).size()), v.size()) {
|
||||
(Some(a), Some(b)) => differs(a.0, b.0) || differs(a.1, b.1),
|
||||
(a, b) => a.is_some() != b.is_some(),
|
||||
};
|
||||
let mut local: Overrides<'a> = BTreeMap::new();
|
||||
let lists = [sd.and_then(|s| s.get("symbolOverrides")).map(Kv::list).unwrap_or_default(), v.list("derivedSymbolData")];
|
||||
// The lists are at the scale of the layer of this node that holds them.
|
||||
let scale_of = |k: &str| v.find(k).map_or(1.0, |f| f.1);
|
||||
let lists = [
|
||||
(sd.and_then(|s| s.get("symbolOverrides")).map(Kv::list).unwrap_or_default(), scale_of("symbolData")),
|
||||
(v.list("derivedSymbolData"), scale_of("derivedSymbolData")),
|
||||
];
|
||||
let source_depth = outer.map_or(0, |i| i.symbols.len());
|
||||
let mut ordinal = 0;
|
||||
for (layer, list) in lists.into_iter().enumerate() {
|
||||
for (layer, (list, row_scale)) in lists.into_iter().enumerate() {
|
||||
for ov in list {
|
||||
let row_ordinal = ordinal;
|
||||
ordinal += 1;
|
||||
@@ -385,6 +403,7 @@ impl<'a> Mapper<'a> {
|
||||
source_depth,
|
||||
level: override_level(ov),
|
||||
ordinal: row_ordinal,
|
||||
scale: row_scale,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -398,11 +417,12 @@ impl<'a> Mapper<'a> {
|
||||
rows.sort_by_key(|row| (std::cmp::Reverse(row.source_depth + row.level), std::cmp::Reverse(row.source_depth), row.ordinal));
|
||||
}
|
||||
// This instance's own property values and slot contents; the outer instances' win.
|
||||
let mut all_props: BTreeMap<String, &'a Kv<'a>> = BTreeMap::new();
|
||||
let assignment_scale = v.find("componentPropAssignments").map_or(1.0, |f| f.1);
|
||||
let mut all_props: BTreeMap<String, (&'a Kv<'a>, f64)> = BTreeMap::new();
|
||||
let mut slot_contents: BTreeMap<String, String> = BTreeMap::new();
|
||||
for a in assignments {
|
||||
if let (Some(d), Some(val)) = (a.get("defID").and_then(guid), assigned(a)) {
|
||||
all_props.entry(self.prop_root(&d)).or_insert(val);
|
||||
all_props.entry(self.prop_root(&d)).or_insert((val, assignment_scale));
|
||||
}
|
||||
let Some(def) = a.get("defID").and_then(guid) else { continue };
|
||||
if self.prop_def(&def).is_none_or(|(_, ty)| *ty != "SLOT") {
|
||||
@@ -431,6 +451,13 @@ impl<'a> Mapper<'a> {
|
||||
let mut symbols = outer.map(|i| i.symbols.clone()).unwrap_or_default();
|
||||
symbols.push(sym.clone());
|
||||
let root = outer.map_or_else(|| rest_id.to_string(), |i| i.root.clone());
|
||||
// The scale the Scale tool gave the instance; a junk factor changes nothing.
|
||||
let factor = sd
|
||||
.and_then(|s| s.get("uniformScaleFactor"))
|
||||
.and_then(Kv::f64)
|
||||
.filter(|k| (MIN_SCALE_FACTOR..=MAX_SCALE_FACTOR).contains(k))
|
||||
.unwrap_or(1.0);
|
||||
let scale = (outer_scale * factor).clamp(MIN_SCALE_FACTOR, MAX_SCALE_FACTOR);
|
||||
Some((
|
||||
Inst {
|
||||
root,
|
||||
@@ -440,6 +467,7 @@ impl<'a> Mapper<'a> {
|
||||
slot_contents: Chain::over(outer.map(|i| &i.slot_contents), slot_contents),
|
||||
slot_content: false,
|
||||
symbols,
|
||||
scale,
|
||||
},
|
||||
sym,
|
||||
))
|
||||
|
||||
@@ -285,7 +285,7 @@ impl<'a> Mapper<'a> {
|
||||
if let Some(name) = v.str("name") {
|
||||
vector.insert("name".into(), json!(if nested { format!("{name} vector") } else { name.to_string() }));
|
||||
}
|
||||
let line_view = View::of(line).with([line_style]);
|
||||
let line_view = View::of(line).with([(line_style, 1.0)]);
|
||||
self.common(&mut vector, &line_view, "VECTOR", space, None);
|
||||
self.geometry(&mut vector, &line_view);
|
||||
for (from, to) in [("connectorStartCap", "strokeCapStart"), ("connectorEndCap", "strokeCapEnd")] {
|
||||
@@ -328,7 +328,7 @@ impl<'a> Mapper<'a> {
|
||||
}
|
||||
return children;
|
||||
};
|
||||
let text_view = View::of(text_frame).with([text_style]);
|
||||
let text_view = View::of(text_frame).with([(text_style, 1.0)]);
|
||||
let Some(characters) = text_view.get("textData").and_then(|d| d.get("characters")).and_then(Kv::str).filter(|s| !s.trim().is_empty()) else {
|
||||
return children;
|
||||
};
|
||||
@@ -359,7 +359,7 @@ impl<'a> Mapper<'a> {
|
||||
self.add_connector_text(&mut children, rest_id, characters, text_size, composed_text_transform, &text_view);
|
||||
return children;
|
||||
};
|
||||
let background_view = View::of(background).with([background_style]);
|
||||
let background_view = View::of(background).with([(background_style, 1.0)]);
|
||||
if !background_view.list("fillPaints").is_empty() {
|
||||
if self.count >= self.budget() {
|
||||
self.warn(format!("the file has more than {} layers; connector label background was skipped", self.max_nodes));
|
||||
|
||||
@@ -3,8 +3,8 @@ use super::connector::{ConnectorShape, connector_has_saved_label};
|
||||
use super::layout::{layout_grid, tracks};
|
||||
use super::paint::{effect, export_setting, has_bound_variables, paint, paint_bound, stroke};
|
||||
use super::{
|
||||
Affine, BTreeMap, BTreeSet, Kv, MAX_COMMANDS, MAX_DEPTH, Mapper, Obj, Value, View, copy_num, copy_str, generated_record, guid, json, matrix,
|
||||
network, num, rows, size,
|
||||
Affine, BTreeMap, BTreeSet, Kv, MAX_COMMANDS, MAX_DEPTH, Mapper, Obj, Value, View, copy_len, copy_num, copy_str, generated_record, guid, json,
|
||||
matrix, network, num, rows, scaled, size,
|
||||
};
|
||||
|
||||
/// How the layers of a resized container follow it when the file gives no derived layout for
|
||||
@@ -126,23 +126,39 @@ impl<'a> Mapper<'a> {
|
||||
(Some(i), Some(p)) => i.overrides.get(p).map(Vec::as_slice).unwrap_or_default(),
|
||||
_ => &[],
|
||||
};
|
||||
let mut view = View::of(base).with(entries.iter().map(|e| e.row));
|
||||
// Inside a scaled instance the main's lengths are the factor times as long; the rows of
|
||||
// the instance are at its scale already (#2091).
|
||||
let scale = inst.map_or(1.0, |i| i.scale);
|
||||
let mut view = View::of_scaled(base, scale).with(entries.iter().map(|e| (e.row, e.scale)));
|
||||
// A layer of a resized instance the file gives no derived layout for follows its
|
||||
// constraints from the main's geometry, then takes the size and place overridden on it
|
||||
// (auto layout places the others, as in the editor).
|
||||
let derived = entries.iter().any(|e| e.entry == Entry::Derived && (e.row.get("size").is_some() || e.row.get("transform").is_some()));
|
||||
let stacked = parent.and_then(|p| p.str("stackMode")).is_some_and(|m| matches!(m, "HORIZONTAL" | "VERTICAL" | "GRID"))
|
||||
&& view.str("stackPositioning") != Some("ABSOLUTE");
|
||||
let main = View::of(base);
|
||||
let set = |k: &str| entries.iter().rev().filter(|e| e.entry != Entry::Stale).find_map(|e| e.row.get(k));
|
||||
let main = View::of_scaled(base, scale);
|
||||
// The size and place a row gives, at the scale of the row (a nested instance's own rows
|
||||
// are at its enclosing main's scale).
|
||||
let set = |k: &str| entries.iter().rev().filter(|e| e.entry != Entry::Stale).find_map(|e| e.row.get(k).map(|r| (r, e.scale)));
|
||||
let set_size = || {
|
||||
let (r, k) = set("size")?;
|
||||
let (x, y) = size(r)?;
|
||||
Some((scaled(x, k)?, scaled(y, k)?))
|
||||
};
|
||||
let set_transform = || {
|
||||
let (r, k) = set("transform")?;
|
||||
let m = matrix(r)?;
|
||||
let [a, b, c, d, e, f] = m.as_coeffs();
|
||||
Some(Affine::new([a, b, c, d, scaled(e, k)?, scaled(f, k)?]))
|
||||
};
|
||||
if let (Some(f), false, false, Some((w, h))) = (fit, derived, stacked, main.size()) {
|
||||
let t = main.transform().unwrap_or_default();
|
||||
let (fw, ft) = f.apply(w, h, t, view.str("horizontalConstraint"), view.str("verticalConstraint"));
|
||||
view.fitted = Some((set("size").and_then(size).unwrap_or(fw), set("transform").and_then(matrix).unwrap_or(ft)));
|
||||
view.fitted = Some((set_size().unwrap_or(fw), set_transform().unwrap_or(ft)));
|
||||
} else if entries.iter().any(|e| e.entry == Entry::Stale && (e.row.get("size").is_some() || e.row.get("transform").is_some()))
|
||||
&& let Some(s) = set("size").and_then(size).or_else(|| main.size())
|
||||
&& let Some(s) = set_size().or_else(|| main.size())
|
||||
{
|
||||
view.fitted = Some((s, set("transform").and_then(matrix).or_else(|| main.transform()).unwrap_or_default()));
|
||||
view.fitted = Some((s, set_transform().or_else(|| main.transform()).unwrap_or_default()));
|
||||
}
|
||||
let rest_id = match (inst, &path) {
|
||||
(Some(i), Some(p)) => format!("I{};{}", i.root, p.join(";")),
|
||||
@@ -280,12 +296,18 @@ impl<'a> Mapper<'a> {
|
||||
// The saved vector is flattened into the connector node when no child slot remains.
|
||||
} else if ty == "INSTANCE" {
|
||||
if let Some((sub, sym)) = self.instance(o, view, id, inst, rest_id) {
|
||||
let main = self.nodes.get(&sym).map(|m| View::of(m));
|
||||
let fit = Fit::frame(main.as_ref().and_then(View::size), view.size());
|
||||
let main = self.nodes.get(&sym).map(|m| View::of_scaled(m, sub.scale));
|
||||
// A scaled main's size is the factor's product, rounded to a float: it matches an
|
||||
// instance of that size without a fit that would round the layers again.
|
||||
let near = |a: f64, b: f64| (a - b).abs() <= 1e-4 * a.abs().max(b.abs()).max(1.0);
|
||||
let fit = match (main.as_ref().and_then(View::size), view.size()) {
|
||||
(Some(a), Some(b)) if sub.scale != 1.0 && near(a.0, b.0) && near(a.1, b.1) => None,
|
||||
(a, b) => Fit::frame(a, b),
|
||||
};
|
||||
// An instance that records no auto layout of its own has its main's.
|
||||
let view = match main {
|
||||
Some(m) if view.get("stackMode").is_none() && m.get("stackMode").is_some() => {
|
||||
let layered = View { fitted: view.fitted, ..m.with(view.layers.iter().copied()) };
|
||||
let layered = m.over(view);
|
||||
self.auto_layout(o, &layered);
|
||||
layered
|
||||
}
|
||||
@@ -309,8 +331,8 @@ impl<'a> Mapper<'a> {
|
||||
// size to its own.
|
||||
let fit = match (inst, ty) {
|
||||
(None, _) => None,
|
||||
(Some(_), "GROUP" | "BOOLEAN_OPERATION") => Fit::scale(View::of(base).size(), view.size()),
|
||||
(Some(_), _) => Fit::frame(View::of(base).size(), view.size()),
|
||||
(Some(i), "GROUP" | "BOOLEAN_OPERATION") => Fit::scale(View::of_scaled(base, i.scale).size(), view.size()),
|
||||
(Some(i), _) => Fit::frame(View::of_scaled(base, i.scale).size(), view.size()),
|
||||
};
|
||||
for c in self.children(id) {
|
||||
kids.extend(self.layer_filtered(&c, inst, child_space, Some(view), fit, depth + 1, included));
|
||||
@@ -361,7 +383,7 @@ impl<'a> Mapper<'a> {
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let child = View::of(style).with([bounds]);
|
||||
let child = View::of(style).with([(bounds, 1.0)]);
|
||||
let chars = own_text(&child);
|
||||
if local_id != 0 && chars.is_none() {
|
||||
continue;
|
||||
@@ -509,14 +531,16 @@ impl<'a> Mapper<'a> {
|
||||
.unwrap_or_else(|| stroke_style.map_or_else(|| v.list("strokePaints"), |s| s.get("fillPaints").map(Kv::list).unwrap_or_default()));
|
||||
o.insert("strokes".into(), Value::Array(strokes.iter().filter_map(|p| paint(p, &mut |w| self.warn(w))).collect()));
|
||||
let effect_style = self.style(v, "styleIdForEffect", "inheritEffectStyleID", "effect", &mut styles);
|
||||
let effects = effect_style.map_or_else(|| v.list("effects"), |s| s.get("effects").map(Kv::list).unwrap_or_default());
|
||||
let (effects, effects_scale) =
|
||||
effect_style.map_or_else(|| v.list_scaled("effects"), |s| v.styled_list(s, "effects", "styleIdForEffect", "inheritEffectStyleID"));
|
||||
if !effects.is_empty() {
|
||||
o.insert("effects".into(), Value::Array(effects.iter().filter_map(effect).collect()));
|
||||
o.insert("effects".into(), Value::Array(effects.iter().filter_map(|e| effect(e, effects_scale)).collect()));
|
||||
}
|
||||
let grid_style = self.style(v, "styleIdForGrid", "inheritGridStyleID", "grid", &mut styles);
|
||||
let grids = grid_style.map_or_else(|| v.list("layoutGrids"), |s| s.get("layoutGrids").map(Kv::list).unwrap_or_default());
|
||||
let (grids, grids_scale) =
|
||||
grid_style.map_or_else(|| v.list_scaled("layoutGrids"), |s| v.styled_list(s, "layoutGrids", "styleIdForGrid", "inheritGridStyleID"));
|
||||
if !grids.is_empty() {
|
||||
o.insert("layoutGrids".into(), Value::Array(grids.iter().map(layout_grid).collect()));
|
||||
o.insert("layoutGrids".into(), Value::Array(grids.iter().map(|g| layout_grid(g, grids_scale)).collect()));
|
||||
}
|
||||
if ty == "TEXT" {
|
||||
self.style(v, "styleIdForText", "inheritTextStyleID", "text", &mut styles);
|
||||
@@ -525,10 +549,10 @@ impl<'a> Mapper<'a> {
|
||||
o.insert("styles".into(), Value::Object(styles));
|
||||
}
|
||||
stroke(o, v);
|
||||
copy_num(o, v, "cornerRadius", "cornerRadius");
|
||||
copy_len(o, v, "cornerRadius", "cornerRadius");
|
||||
copy_num(o, v, "cornerSmoothing", "cornerSmoothing");
|
||||
if v.flag("rectangleCornerRadiiIndependent") == Some(true) {
|
||||
let r = |k: &str| v.num(k).unwrap_or(0.0);
|
||||
let r = |k: &str| v.len(k).unwrap_or(0.0);
|
||||
o.insert(
|
||||
"rectangleCornerRadii".into(),
|
||||
json!([
|
||||
@@ -604,7 +628,7 @@ impl<'a> Mapper<'a> {
|
||||
/// scaled to the layer's size, with per-point corner radii, handle mirroring and end caps.
|
||||
/// False when there's none or it's damaged (the outlines stand in).
|
||||
fn network(&mut self, o: &mut Obj, v: &View<'a>) -> bool {
|
||||
let Some(vd) = v.get("vectorData") else { return false };
|
||||
let Some((vd, radius_scale)) = v.find("vectorData") else { return false };
|
||||
let Some(blob_id) = num(vd, "vectorNetworkBlob").map(|i| i as usize) else { return false };
|
||||
let Some(blob) = self.blobs.get(blob_id).copied() else { return false };
|
||||
let Some(net) = network::decode(blob) else {
|
||||
@@ -650,7 +674,9 @@ impl<'a> Mapper<'a> {
|
||||
.map(|p| {
|
||||
let mut out = json!({"x": p.x * sx, "y": p.y * sy});
|
||||
let st = style(p.style);
|
||||
if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).filter(|r| *r > 0.0) {
|
||||
// Point radii are lengths in this row's space, unlike the normalized positions.
|
||||
// An override/derived row already carries the instance's scale.
|
||||
if let Some(r) = st.and_then(|s| num(s, "cornerRadius")).and_then(|r| scaled(r, radius_scale)).filter(|r| *r > 0.0) {
|
||||
out["radius"] = json!(r);
|
||||
}
|
||||
match st.and_then(|s| s.get("handleMirroring")).and_then(Kv::str) {
|
||||
@@ -766,7 +792,8 @@ impl<'a> Mapper<'a> {
|
||||
o.insert("vectorNetwork".into(), entry);
|
||||
return true;
|
||||
}
|
||||
let key = (blob_id, sx.to_bits(), sy.to_bits(), std::ptr::from_ref(vd) as usize);
|
||||
// A resized instance and a K-scaled one can have the same box but different radii.
|
||||
let key = (blob_id, sx.to_bits(), sy.to_bits(), std::ptr::from_ref(vd) as usize, radius_scale.to_bits());
|
||||
let id = match self.network_ids.get(&key) {
|
||||
Some(id) => *id,
|
||||
None => {
|
||||
@@ -834,9 +861,9 @@ fn child_layout(o: &mut Obj, v: &View, parent: Option<&View>) {
|
||||
o.insert("layoutPositioning".into(), json!("ABSOLUTE"));
|
||||
}
|
||||
for (k, w, h) in [("minSize", "minWidth", "minHeight"), ("maxSize", "maxWidth", "maxHeight")] {
|
||||
if let Some(val) = v.get(k).and_then(|s| s.get("value")) {
|
||||
if let Some((val, scale)) = v.find(k).and_then(|(s, scale)| Some((s.get("value")?, scale))) {
|
||||
for (axis, to) in [("x", w), ("y", h)] {
|
||||
if let Some(x) = num(val, axis).filter(|x| *x > 0.0 && *x < 1e9) {
|
||||
if let Some(x) = num(val, axis).and_then(|x| scaled(x, scale)).filter(|x| *x > 0.0 && *x < 1e9) {
|
||||
o.insert(to.into(), json!(x));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
use super::paint::{bound, color};
|
||||
use super::{BTreeMap, BTreeSet, Kv, MAX_GRID_TRACK_VALUE, MAX_GRID_TRACKS, Mapper, Obj, Value, View, copy_num, guid, json, num};
|
||||
use super::{BTreeMap, BTreeSet, Kv, MAX_GRID_TRACK_VALUE, MAX_GRID_TRACKS, Mapper, Obj, Value, View, copy_len, guid, json, num, scaled};
|
||||
|
||||
impl<'a> Mapper<'a> {
|
||||
pub(super) fn auto_layout(&mut self, o: &mut Obj, v: &View<'a>) {
|
||||
let Some(mode) = v.str("stackMode").filter(|m| matches!(*m, "HORIZONTAL" | "VERTICAL" | "GRID")) else { return };
|
||||
o.insert("layoutMode".into(), json!(mode));
|
||||
copy_num(o, v, "stackSpacing", "itemSpacing");
|
||||
copy_num(o, v, "stackCounterSpacing", "counterAxisSpacing");
|
||||
copy_len(o, v, "stackSpacing", "itemSpacing");
|
||||
copy_len(o, v, "stackCounterSpacing", "counterAxisSpacing");
|
||||
if v.str("stackWrap") == Some("WRAP") {
|
||||
o.insert("layoutWrap".into(), json!("WRAP"));
|
||||
}
|
||||
if v.str("stackCounterAlignContent") == Some("SPACE_BETWEEN") {
|
||||
o.insert("counterAxisAlignContent".into(), json!("SPACE_BETWEEN"));
|
||||
}
|
||||
let (h, vert) = (v.num("stackHorizontalPadding").unwrap_or(0.0), v.num("stackVerticalPadding").unwrap_or(0.0));
|
||||
let (h, vert) = (v.len("stackHorizontalPadding").unwrap_or(0.0), v.len("stackVerticalPadding").unwrap_or(0.0));
|
||||
o.insert("paddingLeft".into(), json!(h));
|
||||
o.insert("paddingTop".into(), json!(vert));
|
||||
o.insert("paddingRight".into(), json!(v.num("stackPaddingRight").unwrap_or(h)));
|
||||
o.insert("paddingBottom".into(), json!(v.num("stackPaddingBottom").unwrap_or(vert)));
|
||||
o.insert("paddingRight".into(), json!(v.len("stackPaddingRight").unwrap_or(h)));
|
||||
o.insert("paddingBottom".into(), json!(v.len("stackPaddingBottom").unwrap_or(vert)));
|
||||
let main = match v.str("stackPrimaryAlignItems").or_else(|| v.str("stackJustify")) {
|
||||
Some("CENTER") => "CENTER",
|
||||
Some("MAX") => "MAX",
|
||||
@@ -48,8 +48,8 @@ impl<'a> Mapper<'a> {
|
||||
o.insert("itemReverseZIndex".into(), json!(b));
|
||||
}
|
||||
if mode == "GRID" {
|
||||
copy_num(o, v, "gridColumnGap", "gridColumnGap");
|
||||
copy_num(o, v, "gridRowGap", "gridRowGap");
|
||||
copy_len(o, v, "gridColumnGap", "gridColumnGap");
|
||||
copy_len(o, v, "gridRowGap", "gridRowGap");
|
||||
o.insert("gridColumnCount".into(), json!(tracks(v, "gridColumns").len()));
|
||||
o.insert("gridRowCount".into(), json!(tracks(v, "gridRows").len()));
|
||||
let track_data = [("columnTracks", "gridColumns", "gridColumnsSizing"), ("rowTracks", "gridRows", "gridRowsSizing")]
|
||||
@@ -162,7 +162,8 @@ fn grid_track_sizing(size: Option<&Kv<'_>>) -> Option<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn layout_grid(g: &Kv) -> Value {
|
||||
/// A layout grid; `k`: the factor its lengths take (a scaled instance's layer).
|
||||
pub(super) fn layout_grid(g: &Kv, k: f64) -> Value {
|
||||
let pattern = match (g.get("pattern").and_then(Kv::str), g.get("axis").and_then(Kv::str)) {
|
||||
(Some("GRID"), _) => "GRID",
|
||||
(_, Some("Y")) => "ROWS",
|
||||
@@ -170,7 +171,8 @@ pub(super) fn layout_grid(g: &Kv) -> Value {
|
||||
};
|
||||
let mut o = json!({"pattern": pattern, "alignment": g.get("type").and_then(Kv::str).unwrap_or("STRETCH")});
|
||||
for (from, to) in [("sectionSize", "sectionSize"), ("numSections", "count"), ("gutterSize", "gutterSize"), ("offset", "offset")] {
|
||||
if let Some(x) = num(g, from) {
|
||||
let length = from != "numSections";
|
||||
if let Some(x) = num(g, from).and_then(|x| scaled(x, if length { k } else { 1.0 })) {
|
||||
o[to] = json!(x);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::layer::cap_name;
|
||||
use super::variables::alias;
|
||||
use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, copy_num, copy_str, guid, json, matrix, num, rows, vec2};
|
||||
use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, copy_len, copy_str, guid, json, matrix, num, rows, scaled, vec2};
|
||||
|
||||
fn filter_number(value: &Kv) -> Option<f64> {
|
||||
let number = match value {
|
||||
@@ -55,7 +55,7 @@ impl<'a> Mapper<'a> {
|
||||
}
|
||||
|
||||
pub(super) fn stroke(o: &mut Obj, v: &View) {
|
||||
copy_num(o, v, "strokeWeight", "strokeWeight");
|
||||
copy_len(o, v, "strokeWeight", "strokeWeight");
|
||||
if let Some(a) = v.str("strokeAlign").filter(|a| *a != "OFFSET") {
|
||||
o.insert("strokeAlign".into(), json!(a));
|
||||
}
|
||||
@@ -84,7 +84,8 @@ pub(super) fn stroke(o: &mut Obj, v: &View) {
|
||||
if !profile.is_empty() {
|
||||
o.insert("strokeWidthProfile".into(), Value::Array(profile));
|
||||
}
|
||||
let dashes: Vec<f64> = v.list("dashPattern").iter().filter_map(Kv::f64).collect();
|
||||
let (dash_list, dash_scale) = v.list_scaled("dashPattern");
|
||||
let dashes: Vec<f64> = dash_list.iter().filter_map(Kv::f64).filter_map(|d| scaled(d, dash_scale)).collect();
|
||||
if !dashes.is_empty() {
|
||||
o.insert("strokeDashes".into(), json!(dashes));
|
||||
}
|
||||
@@ -94,7 +95,7 @@ pub(super) fn stroke(o: &mut Obj, v: &View) {
|
||||
}
|
||||
if v.flag("borderStrokeWeightsIndependent") == Some(true) {
|
||||
// A side that isn't written has no border.
|
||||
let side = |k: &str| v.num(k).unwrap_or(0.0);
|
||||
let side = |k: &str| v.len(k).unwrap_or(0.0);
|
||||
o.insert(
|
||||
"individualStrokeWeights".into(),
|
||||
json!({"top": side("borderTopWeight"), "right": side("borderRightWeight"), "bottom": side("borderBottomWeight"), "left": side("borderLeftWeight")}),
|
||||
@@ -254,7 +255,8 @@ pub(super) fn bound(v: Option<&Kv>, fields: &[(&str, &str)]) -> Option<Value> {
|
||||
(!found.is_empty()).then_some(Value::Object(found))
|
||||
}
|
||||
|
||||
pub(super) fn effect(e: &Kv) -> Option<Value> {
|
||||
/// A layer effect; `k`: the factor its lengths take (a scaled instance's layer).
|
||||
pub(super) fn effect(e: &Kv, k: f64) -> Option<Value> {
|
||||
let ty = match e.get("type").and_then(Kv::str)? {
|
||||
"FOREGROUND_BLUR" => "LAYER_BLUR",
|
||||
"GRAIN" => "TEXTURE",
|
||||
@@ -262,9 +264,10 @@ pub(super) fn effect(e: &Kv) -> Option<Value> {
|
||||
};
|
||||
let mut o = Obj::new();
|
||||
o.insert("type".into(), json!(ty));
|
||||
for k in ["radius", "spread", "startRadius", "density", "opacity"] {
|
||||
if let Some(x) = num(e, k) {
|
||||
o.insert(k.into(), json!(x));
|
||||
for key in ["radius", "spread", "startRadius", "density", "opacity"] {
|
||||
let length = matches!(key, "radius" | "spread" | "startRadius");
|
||||
if let Some(x) = num(e, key).and_then(|x| scaled(x, if length { k } else { 1.0 })) {
|
||||
o.insert(key.into(), json!(x));
|
||||
}
|
||||
}
|
||||
for k in ["visible", "showShadowBehindNode", "clipToShape"] {
|
||||
@@ -282,9 +285,15 @@ pub(super) fn effect(e: &Kv) -> Option<Value> {
|
||||
o.insert(k.into(), color(c));
|
||||
}
|
||||
}
|
||||
for (from, to) in [("offset", "offset"), ("startOffset", "startOffset"), ("endOffset", "endOffset"), ("noiseSize", "noiseSizeVector")] {
|
||||
// `startOffset` and `endOffset` are positions in the layer's unit box, not lengths: they stay.
|
||||
for (from, to) in [("offset", "offset"), ("noiseSize", "noiseSizeVector")] {
|
||||
if let Some(p) = e.get(from).and_then(vec2) {
|
||||
o.insert(to.into(), p);
|
||||
o.insert(to.into(), scale_vec2(p, k));
|
||||
}
|
||||
}
|
||||
for key in ["startOffset", "endOffset"] {
|
||||
if let Some(p) = e.get(key).and_then(vec2) {
|
||||
o.insert(key.into(), p);
|
||||
}
|
||||
}
|
||||
if e.get("blurOpType").and_then(Kv::str) == Some("PROGRESSIVE") {
|
||||
@@ -304,6 +313,15 @@ pub(super) fn effect(e: &Kv) -> Option<Value> {
|
||||
Some(Value::Object(o))
|
||||
}
|
||||
|
||||
/// A `{"x", "y"}` vector at scale `k` (unchanged when that is not finite).
|
||||
fn scale_vec2(p: Value, k: f64) -> Value {
|
||||
let axis = |name: &str| p.get(name).and_then(Value::as_f64).and_then(|x| scaled(x, k));
|
||||
match (axis("x"), axis("y")) {
|
||||
(Some(x), Some(y)) => json!({"x": x, "y": y}),
|
||||
_ => p,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn export_setting(e: &Kv) -> Value {
|
||||
let c = e.get("constraint");
|
||||
let ty = match c.and_then(|c| c.get("type")).and_then(Kv::str) {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
use super::paint::paint;
|
||||
use super::{Kv, Mapper, Obj, Value, View, copy_num, copy_str, json, network, num};
|
||||
use super::{Kv, Mapper, Obj, Value, View, copy_len, copy_str, json, network, num, scaled};
|
||||
|
||||
/// The value from the last override layer holding a valid one for an optional text setting; invalid instance rows fall through.
|
||||
fn text_num(v: &View, key: &str) -> Option<f64> {
|
||||
v.layers.iter().rev().find_map(|layer| {
|
||||
/// `length`: the setting is a length, at the scale of the layer that has it.
|
||||
fn text_num(v: &View, key: &str, length: bool) -> Option<f64> {
|
||||
v.layers.iter().zip(&v.scales).rev().find_map(|(layer, k)| {
|
||||
layer.get(key).and_then(|value| match value {
|
||||
Kv::Bool(_) => None,
|
||||
_ => value.f64(),
|
||||
_ => scaled(value.f64()?, if length { *k } else { 1.0 }),
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -16,7 +17,7 @@ fn text_flag(v: &View, key: &str) -> Option<bool> {
|
||||
}
|
||||
|
||||
fn insert_text_options(style: &mut Obj, v: &View) {
|
||||
if let Some(value) = text_num(v, "listSpacing") {
|
||||
if let Some(value) = text_num(v, "listSpacing", true) {
|
||||
style.insert("listSpacing".into(), json!(value));
|
||||
}
|
||||
for k in ["hangingList", "hangingPunctuation"] {
|
||||
@@ -79,7 +80,8 @@ impl<'a> Mapper<'a> {
|
||||
let style_node = self.style_link(v, key, legacy).and_then(|s| self.nodes.get(&s).copied());
|
||||
// Overrides above the one that links the style beat it; it beats the copies below.
|
||||
let sv = v.styled(style_node.filter(|s| s.get("styleType").and_then(Kv::str) == Some("TEXT")), key, legacy);
|
||||
let td = v.get("textData");
|
||||
let found = v.find("textData");
|
||||
let (td, td_scale) = (found.map(|f| f.0), found.map_or(1.0, |f| f.1));
|
||||
let chars = td.and_then(|t| t.get("characters")).and_then(Kv::str).unwrap_or("");
|
||||
o.insert("characters".into(), json!(chars));
|
||||
// Where the file's own layout wrapped the lines (not a REST field).
|
||||
@@ -105,19 +107,19 @@ impl<'a> Mapper<'a> {
|
||||
}
|
||||
// Paragraph settings belong to the effective text layer, including explicit zero clears.
|
||||
for k in ["paragraphSpacing", "paragraphIndent"] {
|
||||
copy_num(&mut style, v, k, k);
|
||||
copy_len(&mut style, v, k, k);
|
||||
}
|
||||
insert_text_options(&mut style, v);
|
||||
if let Some(n) = v.num("maxLines").filter(|n| *n >= 1.0) {
|
||||
style.insert("maxLines".into(), json!(n));
|
||||
}
|
||||
let size = sv.num("fontSize");
|
||||
let size = sv.len("fontSize");
|
||||
if let Some(b) = o.get_mut("style").and_then(|s| s.get_mut("boundVariables")).map(Value::take) {
|
||||
style.insert("boundVariables".into(), b);
|
||||
}
|
||||
o.insert("style".into(), Value::Object(style));
|
||||
let Some(td) = td else { return };
|
||||
char_styles(o, td, size, &mut |w| self.warn(w));
|
||||
char_styles(o, td, td_scale, size, &mut |w| self.warn(w));
|
||||
let raw = td.get("lines").map(Kv::list).unwrap_or_default();
|
||||
let lines: Vec<&str> = raw
|
||||
.iter()
|
||||
@@ -184,11 +186,7 @@ impl<'a> Mapper<'a> {
|
||||
fail(self, "has invalid normalized size");
|
||||
return;
|
||||
};
|
||||
let Some(size) = v.get("size") else {
|
||||
fail(self, "has invalid dimensions");
|
||||
return;
|
||||
};
|
||||
let (Some(nx), Some(ny), Some(w), Some(h)) = (num(norm, "x"), num(norm, "y"), num(size, "x"), num(size, "y")) else {
|
||||
let (Some(nx), Some(ny), Some((w, h))) = (num(norm, "x"), num(norm, "y"), v.size()) else {
|
||||
fail(self, "has invalid dimensions");
|
||||
return;
|
||||
};
|
||||
@@ -238,8 +236,9 @@ impl<'a> Mapper<'a> {
|
||||
}
|
||||
|
||||
/// Per-character styles of text data `td` (`characterStyleIDs` + `styleOverrideTable`) as REST
|
||||
/// `characterStyleOverrides` + `styleOverrideTable`; `size`: the base font size.
|
||||
pub(super) fn char_styles(o: &mut Obj, td: &Kv, size: Option<f64>, warn: &mut dyn FnMut(String)) {
|
||||
/// `characterStyleOverrides` + `styleOverrideTable`; `scale`: the factor the lengths of `td` take
|
||||
/// (a scaled instance's layer); `size`: the base font size.
|
||||
pub(super) fn char_styles(o: &mut Obj, td: &Kv, scale: f64, size: Option<f64>, warn: &mut dyn FnMut(String)) {
|
||||
let ids: Vec<u64> = td.get("characterStyleIDs").map(Kv::list).unwrap_or_default().iter().filter_map(Kv::f64).map(|i| i as u64).collect();
|
||||
if ids.iter().all(|i| *i == 0) {
|
||||
return;
|
||||
@@ -247,7 +246,7 @@ pub(super) fn char_styles(o: &mut Obj, td: &Kv, size: Option<f64>, warn: &mut dy
|
||||
let mut table = Obj::new();
|
||||
for e in td.get("styleOverrideTable").map(Kv::list).unwrap_or_default() {
|
||||
let Some(sid) = num(e, "styleID") else { continue };
|
||||
let mut s = text_style(&View::of(e), size);
|
||||
let mut s = text_style(&View::of_scaled(e, scale), size);
|
||||
if let Some(fills) = e.get("fillPaints") {
|
||||
s.insert("fills".into(), Value::Array(fills.list().iter().filter_map(|p| paint(p, warn)).collect()));
|
||||
}
|
||||
@@ -269,9 +268,9 @@ fn text_style(v: &View, size: Option<f64>) -> Obj {
|
||||
}
|
||||
}
|
||||
}
|
||||
copy_num(&mut s, v, "fontSize", "fontSize");
|
||||
copy_num(&mut s, v, "paragraphSpacing", "paragraphSpacing");
|
||||
copy_num(&mut s, v, "paragraphIndent", "paragraphIndent");
|
||||
copy_len(&mut s, v, "fontSize", "fontSize");
|
||||
copy_len(&mut s, v, "paragraphSpacing", "paragraphSpacing");
|
||||
copy_len(&mut s, v, "paragraphIndent", "paragraphIndent");
|
||||
insert_text_options(&mut s, v);
|
||||
copy_str(&mut s, v, "textCase", "textCase");
|
||||
copy_str(&mut s, v, "textDecoration", "textDecoration");
|
||||
@@ -279,11 +278,11 @@ fn text_style(v: &View, size: Option<f64>) -> Obj {
|
||||
s.insert("textDecorationSkipInk".into(), json!(skip_ink));
|
||||
}
|
||||
copy_str(&mut s, v, "leadingTrim", "leadingTrim");
|
||||
let font_size = v.num("fontSize").or(size).unwrap_or(12.0);
|
||||
if let Some(ls) = v.get("letterSpacing") {
|
||||
let font_size = v.len("fontSize").or(size).unwrap_or(12.0);
|
||||
if let Some((ls, k)) = v.find("letterSpacing") {
|
||||
let val = num(ls, "value").unwrap_or(0.0);
|
||||
let px = match ls.get("units").and_then(Kv::str) {
|
||||
Some("PIXELS") => val,
|
||||
Some("PIXELS") => scaled(val, k).unwrap_or(val),
|
||||
Some("PERCENT") => {
|
||||
// Private (read by `rest/text.rs`): REST spacing is in pixels; keep the percent.
|
||||
s.insert("_letterSpacingPercent".into(), json!(val));
|
||||
@@ -293,12 +292,12 @@ fn text_style(v: &View, size: Option<f64>) -> Obj {
|
||||
};
|
||||
s.insert("letterSpacing".into(), json!(px));
|
||||
}
|
||||
if let Some(lh) = v.get("lineHeight") {
|
||||
if let Some((lh, k)) = v.find("lineHeight") {
|
||||
let val = num(lh, "value").unwrap_or(100.0);
|
||||
match lh.get("units").and_then(Kv::str) {
|
||||
Some("PIXELS") => {
|
||||
s.insert("lineHeightUnit".into(), json!("PIXELS"));
|
||||
s.insert("lineHeightPx".into(), json!(val));
|
||||
s.insert("lineHeightPx".into(), json!(scaled(val, k).unwrap_or(val)));
|
||||
}
|
||||
// shortcut: percent units count from the font's own line height (100 % is "auto"), which
|
||||
// the document can only say for 100 %; other values read as % of the font size. Upgrade
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
//! An instance scaled with the Scale tool stores `symbolData.uniformScaleFactor` (#2091 item 1):
|
||||
//! its layers draw the main's lengths times the factor (composed through nested instances),
|
||||
//! unless an override or derived row gives the value, which is at the instance's scale already.
|
||||
//! Fixtures built in code with the parent module's helpers.
|
||||
|
||||
use figcraft_doc::{Document, EffectKind, LetterSpacing, LineHeight, Node, NodeKind};
|
||||
|
||||
use super::kiwi::{Kv, Schema};
|
||||
use super::tests::{arr, at, canvas, e, f, g, node, o, s, xy};
|
||||
use super::*;
|
||||
|
||||
const SCHEMA: &str = "
|
||||
enum NodeType NONE DOCUMENT CANVAS FRAME ROUNDED_RECTANGLE TEXT SYMBOL INSTANCE
|
||||
enum StackMode NONE HORIZONTAL VERTICAL
|
||||
enum EffectType DROP_SHADOW FOREGROUND_BLUR
|
||||
enum BlurOpType NORMAL PROGRESSIVE
|
||||
enum StyleType NONE FILL TEXT EFFECT GRID
|
||||
enum NumberUnits RAW PIXELS PERCENT
|
||||
struct GUID uint sessionID, uint localID
|
||||
struct Vector float x, float y
|
||||
struct Matrix float m00, float m01, float m02, float m10, float m11, float m12
|
||||
message Number float value, NumberUnits units
|
||||
message ParentIndex GUID guid, string position
|
||||
message FontName string family, string style, string postscript
|
||||
message TextData string characters
|
||||
message Effect EffectType type, float radius, float spread, Vector offset, float startRadius, Vector startOffset, Vector endOffset, BlurOpType blurOpType
|
||||
message StyleId GUID guid
|
||||
message LayoutGrid float sectionSize, float gutterSize, float offset, float numSections
|
||||
message GUIDPath GUID[] guids
|
||||
message SymbolData GUID symbolID, NodeChange[] symbolOverrides, float uniformScaleFactor
|
||||
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, float cornerRadius, float strokeWeight, float[] dashPattern, float fontSize, FontName fontName, TextData textData, SymbolData symbolData, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, float stackCounterSpacing, Effect[] effects, Number lineHeight, Number letterSpacing, NodeChange[] derivedSymbolData, GUIDPath guidPath, StyleType styleType, StyleId styleIdForEffect, StyleId styleIdForGrid, LayoutGrid[] layoutGrids
|
||||
message Blob byte[] bytes
|
||||
message Message NodeChange[] nodeChanges, Blob[] blobs
|
||||
";
|
||||
|
||||
fn schema() -> Schema {
|
||||
Schema::parse(SCHEMA).unwrap()
|
||||
}
|
||||
|
||||
fn import_nodes(nodes: Vec<Kv<'static>>) -> Document {
|
||||
import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap().0
|
||||
}
|
||||
|
||||
fn page() -> Vec<Kv<'static>> {
|
||||
vec![node(0, None, "DOCUMENT", "D", vec![]), node(1, Some((0, "!")), "CANVAS", "P", vec![])]
|
||||
}
|
||||
|
||||
/// A copy of `n` with field `key` set to `value`.
|
||||
fn set(n: Kv<'static>, key: &'static str, value: Kv<'static>) -> Kv<'static> {
|
||||
let Kv::Object(mut fields) = n else { panic!("not a node") };
|
||||
fields.retain(|(k, _)| *k != key);
|
||||
fields.push((key, value));
|
||||
Kv::Object(fields)
|
||||
}
|
||||
|
||||
/// An instance of main `main` with `factor` (none: the field is missing) and extra fields.
|
||||
fn instance(
|
||||
id: u64,
|
||||
parent: (u64, &str),
|
||||
name: &str,
|
||||
main: u64,
|
||||
size: (f64, f64),
|
||||
factor: Option<f64>,
|
||||
mut more: Vec<(&'static str, Kv<'static>)>,
|
||||
) -> Kv<'static> {
|
||||
let mut symbol = vec![("symbolID", g(main))];
|
||||
if let Some(k) = factor {
|
||||
symbol.push(("uniformScaleFactor", f(k)));
|
||||
}
|
||||
let mut fields = vec![("size", xy(size.0, size.1)), ("transform", at(300.0, 0.0)), ("symbolData", o(symbol))];
|
||||
fields.append(&mut more);
|
||||
node(id, Some(parent), "INSTANCE", name, fields)
|
||||
}
|
||||
|
||||
/// A 256 px component "Icon" holding a 200 px rectangle at (20, 40) with radius 64 and a stroke,
|
||||
/// dashes and a shadow.
|
||||
fn icon() -> Vec<Kv<'static>> {
|
||||
let shadow = o(vec![("type", e("DROP_SHADOW")), ("radius", f(20.0)), ("spread", f(4.0)), ("offset", xy(10.0, 6.0))]);
|
||||
vec![
|
||||
node(5, Some((1, "!")), "SYMBOL", "Icon", vec![("size", xy(256.0, 256.0)), ("transform", at(0.0, 0.0))]),
|
||||
node(
|
||||
6,
|
||||
Some((5, "!")),
|
||||
"ROUNDED_RECTANGLE",
|
||||
"Mark",
|
||||
vec![
|
||||
("size", xy(200.0, 200.0)),
|
||||
("transform", at(20.0, 40.0)),
|
||||
("cornerRadius", f(64.0)),
|
||||
("strokeWeight", f(8.0)),
|
||||
("dashPattern", arr(vec![f(16.0), f(8.0)])),
|
||||
("effects", arr(vec![shadow])),
|
||||
],
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn find<'d>(doc: &'d Document, name: &str) -> &'d Node {
|
||||
doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer {name}"))
|
||||
}
|
||||
|
||||
/// The first child of the layer named `name`.
|
||||
fn first_child<'d>(doc: &'d Document, name: &str) -> &'d Node {
|
||||
doc.node(find(doc, name).children[0]).unwrap()
|
||||
}
|
||||
|
||||
fn mark(doc: &Document, name: &str) -> ([f64; 2], [f64; 2], [f64; 4]) {
|
||||
let m = first_child(doc, name);
|
||||
let t = m.transform.translation();
|
||||
([m.width, m.height], [t.x, t.y], m.corners.radii)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scaled_instance_scales_the_lengths_of_a_layer_it_gives_no_row_for() {
|
||||
let mut nodes = page();
|
||||
nodes.extend(icon());
|
||||
nodes.push(instance(9, (1, "\""), "Half", 5, (128.0, 128.0), Some(0.5), vec![]));
|
||||
let doc = import_nodes(nodes);
|
||||
let (size, place, radii) = mark(&doc, "Half");
|
||||
assert_eq!((size, place, radii), ([100.0, 100.0], [10.0, 20.0], [32.0; 4]));
|
||||
let m = first_child(&doc, "Half");
|
||||
assert_eq!(m.stroke.weight, 4.0);
|
||||
assert_eq!(m.stroke.dashes, [8.0, 4.0]);
|
||||
let EffectKind::DropShadow(sh) = &m.effects[0].kind else { panic!("not a drop shadow") };
|
||||
assert_eq!((sh.radius, sh.spread, sh.offset), (10.0, 2.0, (5.0, 3.0)));
|
||||
// The instance itself is at the size the file gives it.
|
||||
let inst = find(&doc, "Half");
|
||||
assert_eq!([inst.width, inst.height], [128.0, 128.0]);
|
||||
// The main is untouched.
|
||||
assert_eq!(first_child(&doc, "Icon").corners.radii, [64.0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_value_a_derived_row_gives_is_not_scaled_twice() {
|
||||
let mut nodes = page();
|
||||
nodes.extend(icon());
|
||||
let row = o(vec![("guidPath", o(vec![("guids", arr(vec![g(6)]))])), ("size", xy(100.0, 100.0))]);
|
||||
nodes.push(instance(9, (1, "\""), "Half", 5, (128.0, 128.0), Some(0.5), vec![("derivedSymbolData", arr(vec![row]))]));
|
||||
let doc = import_nodes(nodes);
|
||||
let (size, place, radii) = mark(&doc, "Half");
|
||||
// The derived size is taken as written; the radius and place, which no row gives, scale.
|
||||
assert_eq!((size, place, radii), ([100.0, 100.0], [10.0, 20.0], [32.0; 4]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_override_row_is_not_scaled_either() {
|
||||
let mut nodes = page();
|
||||
nodes.extend(icon());
|
||||
let row = o(vec![("guidPath", o(vec![("guids", arr(vec![g(6)]))])), ("cornerRadius", f(7.0)), ("strokeWeight", f(3.0))]);
|
||||
let symbol = o(vec![("symbolID", g(5)), ("uniformScaleFactor", f(0.5)), ("symbolOverrides", arr(vec![row]))]);
|
||||
nodes.push(node(9, Some((1, "\"")), "INSTANCE", "Half", vec![("size", xy(128.0, 128.0)), ("transform", at(300.0, 0.0)), ("symbolData", symbol)]));
|
||||
let doc = import_nodes(nodes);
|
||||
let m = first_child(&doc, "Half");
|
||||
assert_eq!(m.corners.radii, [7.0; 4]);
|
||||
assert_eq!(m.stroke.weight, 3.0);
|
||||
// What the override leaves alone still scales.
|
||||
assert_eq!(m.stroke.dashes, [8.0, 4.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_scaled_auto_layout_instance_scales_its_gap_padding_and_text() {
|
||||
let mut nodes = page();
|
||||
nodes.push(node(
|
||||
5,
|
||||
Some((1, "!")),
|
||||
"SYMBOL",
|
||||
"Row",
|
||||
vec![
|
||||
("size", xy(200.0, 60.0)),
|
||||
("transform", at(0.0, 0.0)),
|
||||
("stackMode", e("HORIZONTAL")),
|
||||
("stackSpacing", f(20.0)),
|
||||
("stackCounterSpacing", f(6.0)),
|
||||
("stackHorizontalPadding", f(10.0)),
|
||||
("stackVerticalPadding", f(10.0)),
|
||||
],
|
||||
));
|
||||
let font = o(vec![("family", s("Inter")), ("style", s("Regular")), ("postscript", s(""))]);
|
||||
let px = |v: f64| o(vec![("value", f(v)), ("units", e("PIXELS"))]);
|
||||
nodes.push(node(
|
||||
6,
|
||||
Some((5, "!")),
|
||||
"TEXT",
|
||||
"Label",
|
||||
vec![
|
||||
("size", xy(40.0, 20.0)),
|
||||
("transform", at(10.0, 10.0)),
|
||||
("fontSize", f(16.0)),
|
||||
("fontName", font),
|
||||
("lineHeight", px(24.0)),
|
||||
("letterSpacing", px(2.0)),
|
||||
("textData", o(vec![("characters", s("Hi"))])),
|
||||
],
|
||||
));
|
||||
nodes.push(instance(9, (1, "\""), "RowHalf", 5, (100.0, 30.0), Some(0.5), vec![]));
|
||||
let doc = import_nodes(nodes);
|
||||
let row = find(&doc, "RowHalf");
|
||||
let layout = row.layout.as_ref().expect("the instance has its main's auto layout");
|
||||
assert_eq!((layout.gap, layout.cross_gap, layout.padding), (10.0, 3.0, [5.0; 4]));
|
||||
let NodeKind::Text(t) = &first_child(&doc, "RowHalf").kind else { panic!("not text") };
|
||||
assert_eq!(t.style.font_size, 8.0);
|
||||
assert_eq!(t.style.line_height, LineHeight::Pixels(12.0));
|
||||
assert_eq!(t.style.letter_spacing, LetterSpacing::Pixels(1.0));
|
||||
// The main keeps its own.
|
||||
let NodeKind::Text(main) = &first_child(&doc, "Row").kind else { panic!("not text") };
|
||||
assert_eq!(main.style.font_size, 16.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_scaled_instances_compose_their_factors() {
|
||||
let mut nodes = page();
|
||||
// Inner: 200 px with a 200 px rectangle of radius 40.
|
||||
nodes.push(node(5, Some((1, "!")), "SYMBOL", "Inner", vec![("size", xy(200.0, 200.0)), ("transform", at(0.0, 0.0))]));
|
||||
nodes.push(node(
|
||||
6,
|
||||
Some((5, "!")),
|
||||
"ROUNDED_RECTANGLE",
|
||||
"Dot",
|
||||
vec![("size", xy(200.0, 200.0)), ("transform", at(0.0, 0.0)), ("cornerRadius", f(40.0))],
|
||||
));
|
||||
// Outer: 200 px, holding Inner scaled to 100 px (factor 0.5) at (20, 20).
|
||||
nodes.push(node(7, Some((1, "\"")), "SYMBOL", "Outer", vec![("size", xy(200.0, 200.0)), ("transform", at(300.0, 0.0))]));
|
||||
nodes.push(set(instance(8, (7, "!"), "Kid", 5, (100.0, 100.0), Some(0.5), vec![]), "transform", at(20.0, 20.0)));
|
||||
// The outer one scaled to 100 px (factor 0.5).
|
||||
nodes.push(instance(9, (1, "#"), "Half", 7, (100.0, 100.0), Some(0.5), vec![]));
|
||||
let doc = import_nodes(nodes);
|
||||
let kid = first_child(&doc, "Half");
|
||||
// The nested instance itself is the outer factor times its stored size and place.
|
||||
assert_eq!(([kid.width, kid.height], [kid.transform.translation().x, kid.transform.translation().y]), ([50.0, 50.0], [10.0, 10.0]));
|
||||
// Its layers take both factors: 0.5 x 0.5 of the main's.
|
||||
let dot = doc.node(kid.children[0]).unwrap();
|
||||
assert_eq!(([dot.width, dot.height], dot.corners.radii), ([50.0, 50.0], [10.0; 4]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_factor_of_one_or_none_changes_nothing() {
|
||||
let mut nodes = page();
|
||||
nodes.extend(icon());
|
||||
nodes.push(instance(9, (1, "\""), "One", 5, (256.0, 256.0), Some(1.0), vec![]));
|
||||
nodes.push(instance(10, (1, "#"), "None", 5, (256.0, 256.0), None, vec![]));
|
||||
let doc = import_nodes(nodes);
|
||||
for name in ["One", "None"] {
|
||||
let (size, place, radii) = mark(&doc, name);
|
||||
assert_eq!((size, place, radii), ([200.0, 200.0], [20.0, 40.0], [64.0; 4]), "{name}");
|
||||
assert_eq!(first_child(&doc, name).stroke.weight, 8.0, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_junk_factor_is_ignored() {
|
||||
// The file encoder refuses NaN and infinities; the range check turns those away too.
|
||||
for factor in [-0.5, 0.0, 1e30, 3e38, 1e-30] {
|
||||
let mut nodes = page();
|
||||
nodes.extend(icon());
|
||||
nodes.push(instance(9, (1, "\""), "Junk", 5, (256.0, 256.0), Some(factor), vec![]));
|
||||
let doc = import_nodes(nodes);
|
||||
let (size, place, radii) = mark(&doc, "Junk");
|
||||
assert_eq!((size, place, radii), ([200.0, 200.0], [20.0, 40.0], [64.0; 4]), "factor {factor}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nested_instances_own_rows_are_at_its_enclosing_mains_scale() {
|
||||
let mut nodes = page();
|
||||
// Inner: 200 px with a 200 px rectangle "Dot" of radius 40.
|
||||
nodes.push(node(5, Some((1, "!")), "SYMBOL", "Inner", vec![("size", xy(200.0, 200.0)), ("transform", at(0.0, 0.0))]));
|
||||
nodes.push(node(
|
||||
6,
|
||||
Some((5, "!")),
|
||||
"ROUNDED_RECTANGLE",
|
||||
"Dot",
|
||||
vec![("size", xy(200.0, 200.0)), ("transform", at(0.0, 0.0)), ("cornerRadius", f(40.0))],
|
||||
));
|
||||
// Outer: 200 px, holding Kid, an instance of Inner at 100 px (factor 0.5). Kid has the derived
|
||||
// row Figma writes for every nested instance and an override of its own.
|
||||
nodes.push(node(7, Some((1, "\"")), "SYMBOL", "Outer", vec![("size", xy(200.0, 200.0)), ("transform", at(300.0, 0.0))]));
|
||||
let dot = |more: Vec<(&'static str, Kv<'static>)>| {
|
||||
let mut fields = vec![("guidPath", o(vec![("guids", arr(vec![g(6)]))]))];
|
||||
fields.extend(more);
|
||||
o(fields)
|
||||
};
|
||||
let symbol = o(vec![("symbolID", g(5)), ("uniformScaleFactor", f(0.5)), ("symbolOverrides", arr(vec![dot(vec![("cornerRadius", f(7.0))])]))]);
|
||||
let kid = node(
|
||||
8,
|
||||
Some((7, "!")),
|
||||
"INSTANCE",
|
||||
"Kid",
|
||||
vec![
|
||||
("size", xy(100.0, 100.0)),
|
||||
("transform", at(20.0, 20.0)),
|
||||
("symbolData", symbol),
|
||||
("derivedSymbolData", arr(vec![dot(vec![("size", xy(100.0, 100.0))])])),
|
||||
],
|
||||
);
|
||||
nodes.push(kid);
|
||||
// Half: Outer at factor 0.5.
|
||||
nodes.push(instance(9, (1, "#"), "Half", 7, (100.0, 100.0), Some(0.5), vec![]));
|
||||
// Whole: Outer at its own size, for the unscaled reading.
|
||||
nodes.push(instance(10, (1, "$"), "Whole", 7, (200.0, 200.0), None, vec![]));
|
||||
let doc = import_nodes(nodes);
|
||||
let dot_of = |name: &str| {
|
||||
let kid = first_child(&doc, name);
|
||||
doc.node(kid.children[0]).unwrap()
|
||||
};
|
||||
// Both rows are in Outer's space: Half draws them at half.
|
||||
let half = dot_of("Half");
|
||||
assert_eq!(([half.width, half.height], half.corners.radii), ([50.0, 50.0], [3.5; 4]));
|
||||
let whole = dot_of("Whole");
|
||||
assert_eq!(([whole.width, whole.height], whole.corners.radii), ([100.0, 100.0], [7.0; 4]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_progressive_blurs_positions_are_not_lengths() {
|
||||
let mut nodes = page();
|
||||
let blur = o(vec![
|
||||
("type", e("FOREGROUND_BLUR")),
|
||||
("radius", f(8.0)),
|
||||
("blurOpType", e("PROGRESSIVE")),
|
||||
("startRadius", f(2.0)),
|
||||
("startOffset", xy(0.5, 0.0)),
|
||||
("endOffset", xy(0.5, 1.0)),
|
||||
]);
|
||||
nodes.push(node(5, Some((1, "!")), "SYMBOL", "Icon", vec![("size", xy(256.0, 256.0)), ("transform", at(0.0, 0.0))]));
|
||||
nodes.push(node(
|
||||
6,
|
||||
Some((5, "!")),
|
||||
"ROUNDED_RECTANGLE",
|
||||
"Mark",
|
||||
vec![("size", xy(200.0, 200.0)), ("transform", at(0.0, 0.0)), ("effects", arr(vec![blur]))],
|
||||
));
|
||||
nodes.push(instance(9, (1, "\""), "Half", 5, (128.0, 128.0), Some(0.5), vec![]));
|
||||
let doc = import_nodes(nodes);
|
||||
let EffectKind::LayerBlur(b) = &first_child(&doc, "Half").effects[0].kind else { panic!("not a layer blur") };
|
||||
// The radii are lengths and scale; the ramp's ends are fractions of the layer and do not.
|
||||
assert_eq!((b.radius, b.start_radius, b.start, b.end), (4.0, 1.0, (0.5, 0.0), (0.5, 1.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_effect_style_takes_the_scale_of_the_layer_that_links_it() {
|
||||
let mut nodes = page();
|
||||
let shadow = |radius: f64| o(vec![("type", e("DROP_SHADOW")), ("radius", f(radius))]);
|
||||
let link = |id: u64| o(vec![("guid", g(id))]);
|
||||
nodes.push(node(30, Some((1, "~")), "ROUNDED_RECTANGLE", "Soft", vec![("styleType", e("EFFECT")), ("effects", arr(vec![shadow(10.0)]))]));
|
||||
nodes.push(node(31, Some((1, "}")), "ROUNDED_RECTANGLE", "Hard", vec![("styleType", e("EFFECT")), ("effects", arr(vec![shadow(10.0)]))]));
|
||||
nodes.push(node(5, Some((1, "!")), "SYMBOL", "Icon", vec![("size", xy(256.0, 256.0)), ("transform", at(0.0, 0.0))]));
|
||||
nodes.push(node(
|
||||
6,
|
||||
Some((5, "!")),
|
||||
"ROUNDED_RECTANGLE",
|
||||
"Mark",
|
||||
vec![("size", xy(200.0, 200.0)), ("transform", at(0.0, 0.0)), ("styleIdForEffect", link(30))],
|
||||
));
|
||||
// Kept: the main's own layer links the style, so the instance's factor applies.
|
||||
nodes.push(instance(9, (1, "\""), "Kept", 5, (128.0, 128.0), Some(0.5), vec![]));
|
||||
// Swapped: a row of the instance links another style, which is at the instance's scale.
|
||||
let row = o(vec![("guidPath", o(vec![("guids", arr(vec![g(6)]))])), ("styleIdForEffect", link(31))]);
|
||||
let symbol = o(vec![("symbolID", g(5)), ("uniformScaleFactor", f(0.5)), ("symbolOverrides", arr(vec![row]))]);
|
||||
nodes.push(node(
|
||||
10,
|
||||
Some((1, "#")),
|
||||
"INSTANCE",
|
||||
"Swapped",
|
||||
vec![("size", xy(128.0, 128.0)), ("transform", at(600.0, 0.0)), ("symbolData", symbol)],
|
||||
));
|
||||
let doc = import_nodes(nodes);
|
||||
let radius = |name: &str| {
|
||||
let EffectKind::DropShadow(sh) = &first_child(&doc, name).effects[0].kind else { panic!("not a drop shadow") };
|
||||
sh.radius
|
||||
};
|
||||
assert_eq!((radius("Kept"), radius("Swapped")), (5.0, 10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_scaled_style_overrides_keep_their_values_and_links_through_export() {
|
||||
let mut nodes = page();
|
||||
let shadow = |radius| o(vec![("type", e("DROP_SHADOW")), ("radius", f(radius))]);
|
||||
let grid = |size| o(vec![("sectionSize", f(size)), ("gutterSize", f(size)), ("numSections", f(4.0))]);
|
||||
let link = |id| o(vec![("guid", g(id))]);
|
||||
nodes.push(node(30, Some((1, "~")), "ROUNDED_RECTANGLE", "Soft", vec![("styleType", e("EFFECT")), ("effects", arr(vec![shadow(10.0)]))]));
|
||||
nodes.push(node(31, Some((1, "}")), "FRAME", "Grid", vec![("styleType", e("GRID")), ("layoutGrids", arr(vec![grid(20.0)]))]));
|
||||
nodes.push(node(5, Some((1, "!")), "SYMBOL", "Inner", vec![("size", xy(200.0, 200.0)), ("transform", at(0.0, 0.0))]));
|
||||
nodes.push(node(
|
||||
6,
|
||||
Some((5, "!")),
|
||||
"FRAME",
|
||||
"Mark",
|
||||
vec![("size", xy(200.0, 200.0)), ("transform", at(0.0, 0.0)), ("styleIdForEffect", link(30)), ("styleIdForGrid", link(31))],
|
||||
));
|
||||
nodes.push(node(7, Some((1, "\"")), "SYMBOL", "Outer", vec![("size", xy(200.0, 200.0)), ("transform", at(300.0, 0.0))]));
|
||||
nodes.push(set(instance(8, (7, "!"), "Kid", 5, (100.0, 100.0), Some(0.5), vec![]), "transform", at(0.0, 0.0)));
|
||||
let row =
|
||||
o(vec![("guidPath", o(vec![("guids", arr(vec![g(8), g(6)]))])), ("effects", arr(vec![shadow(5.0)])), ("layoutGrids", arr(vec![grid(10.0)]))]);
|
||||
let symbol = o(vec![("symbolID", g(7)), ("uniformScaleFactor", f(0.5)), ("symbolOverrides", arr(vec![row]))]);
|
||||
nodes.push(node(9, Some((1, "#")), "INSTANCE", "Reset", vec![("size", xy(100.0, 100.0)), ("transform", at(600.0, 0.0)), ("symbolData", symbol)]));
|
||||
nodes.push(instance(10, (1, "$"), "Scaled", 7, (100.0, 100.0), Some(0.5), vec![]));
|
||||
let check = |doc: &Document| {
|
||||
for (name, radius, gap) in [("Reset", 5.0, 10.0), ("Scaled", 2.5, 5.0)] {
|
||||
let kid = first_child(doc, name);
|
||||
let mark = doc.node(kid.children[0]).unwrap();
|
||||
let EffectKind::DropShadow(sh) = &mark.effects[0].kind else { panic!("shadow fixture") };
|
||||
assert_eq!(sh.radius, radius, "{name}");
|
||||
assert_eq!(mark.grids[0].gutter, gap, "{name}");
|
||||
assert_eq!(mark.grids[0].count, 4, "dimensionless grid count");
|
||||
let effect = doc.styles.iter().find(|s| s.name == "Soft").unwrap();
|
||||
let grid = doc.styles.iter().find(|s| s.name == "Grid").unwrap();
|
||||
assert_eq!(mark.styles.effect.as_ref(), Some(&effect.id));
|
||||
assert_eq!(mark.styles.grid.as_ref(), Some(&grid.id));
|
||||
}
|
||||
};
|
||||
let doc = import_nodes(nodes);
|
||||
check(&doc);
|
||||
let (bytes, _) = export(&doc, None).unwrap();
|
||||
check(&import(&bytes, "round trip").unwrap().0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_effect_or_grid_style_and_explicit_clears_stay_empty() {
|
||||
for empty in [false, true] {
|
||||
let mut nodes = page();
|
||||
let effects = arr(vec![o(vec![("type", e("DROP_SHADOW")), ("radius", f(10.0))])]);
|
||||
let grids = arr(vec![o(vec![("gutterSize", f(20.0)), ("numSections", f(4.0))])]);
|
||||
let link = |id| o(vec![("guid", g(id))]);
|
||||
let mut effect_style = vec![("styleType", e("EFFECT"))];
|
||||
let mut grid_style = vec![("styleType", e("GRID"))];
|
||||
if !empty {
|
||||
effect_style.push(("effects", effects.clone()));
|
||||
grid_style.push(("layoutGrids", grids.clone()));
|
||||
}
|
||||
nodes.push(node(30, Some((1, "~")), "ROUNDED_RECTANGLE", "Soft", effect_style));
|
||||
nodes.push(node(31, Some((1, "}")), "FRAME", "Grid", grid_style));
|
||||
nodes.push(node(5, Some((1, "!")), "SYMBOL", "Main", vec![("size", xy(100.0, 100.0))]));
|
||||
nodes.push(node(
|
||||
6,
|
||||
Some((5, "!")),
|
||||
"FRAME",
|
||||
"Mark",
|
||||
vec![
|
||||
("size", xy(100.0, 100.0)),
|
||||
("styleIdForEffect", link(30)),
|
||||
("styleIdForGrid", link(31)),
|
||||
("effects", effects),
|
||||
("layoutGrids", grids),
|
||||
],
|
||||
));
|
||||
nodes.push(instance(9, (1, "\""), "Kept", 5, (50.0, 50.0), Some(0.5), vec![]));
|
||||
let row = o(vec![("guidPath", o(vec![("guids", arr(vec![g(6)]))])), ("effects", arr(vec![])), ("layoutGrids", arr(vec![]))]);
|
||||
let symbol = o(vec![("symbolID", g(5)), ("uniformScaleFactor", f(0.5)), ("symbolOverrides", arr(vec![row]))]);
|
||||
nodes.push(node(10, Some((1, "#")), "INSTANCE", "Cleared", vec![("size", xy(50.0, 50.0)), ("symbolData", symbol)]));
|
||||
let doc = import_nodes(nodes);
|
||||
let kept = first_child(&doc, "Kept");
|
||||
assert_eq!(kept.effects.is_empty(), empty);
|
||||
assert_eq!(kept.grids.is_empty(), empty);
|
||||
let cleared = first_child(&doc, "Cleared");
|
||||
assert!(cleared.effects.is_empty() && cleared.grids.is_empty());
|
||||
assert_eq!(cleared.styles.effect, kept.styles.effect);
|
||||
assert_eq!(cleared.styles.grid, kept.styles.grid);
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ message FontName string family, string style, string postscript
|
||||
message TextData string characters, uint[] characterStyleIDs, NodeChange[] styleOverrideTable
|
||||
message Path WindingRule windingRule, uint commandsBlob
|
||||
message GUIDPath GUID[] guids
|
||||
message SymbolData GUID symbolID, NodeChange[] symbolOverrides
|
||||
message SymbolData GUID symbolID, NodeChange[] symbolOverrides, float uniformScaleFactor
|
||||
message StyleId GUID guid, AssetRef assetRef
|
||||
message PropValue bool boolValue, TextData textValue
|
||||
message PropDef GUID id, string name, PropValue initialValue, PropType type
|
||||
@@ -1825,3 +1825,39 @@ fn same_version_library_copies_resolve_deterministically_and_leave_no_table() {
|
||||
}
|
||||
assert_eq!(picked, ["1:6", "1:6"]);
|
||||
}
|
||||
|
||||
/// #2091 item 1: an instance scaled with the Scale tool (K) has `symbolData.uniformScaleFactor`.
|
||||
#[test]
|
||||
fn a_scaled_instance_draws_its_layers_at_the_factor() {
|
||||
let nodes = vec![
|
||||
node(0, None, "DOCUMENT", "D", vec![]),
|
||||
node(1, Some((0, "!")), "CANVAS", "P", vec![]),
|
||||
node(5, Some((1, "!")), "SYMBOL", "Icon", vec![("size", xy(256.0, 256.0)), ("transform", at(0.0, 0.0))]),
|
||||
node(
|
||||
6,
|
||||
Some((5, "!")),
|
||||
"ROUNDED_RECTANGLE",
|
||||
"Mark",
|
||||
vec![("size", xy(256.0, 256.0)), ("transform", at(0.0, 0.0)), ("cornerRadius", f(64.0))],
|
||||
),
|
||||
node(
|
||||
9,
|
||||
Some((1, "\"")),
|
||||
"INSTANCE",
|
||||
"Half",
|
||||
vec![
|
||||
("size", xy(128.0, 128.0)),
|
||||
("transform", at(300.0, 0.0)),
|
||||
("symbolData", o(vec![("symbolID", g(5)), ("uniformScaleFactor", f(0.5))])),
|
||||
],
|
||||
),
|
||||
];
|
||||
let (doc, _) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap();
|
||||
let inst = named(&doc, "Half");
|
||||
let mark = doc.node(inst.children[0]).unwrap();
|
||||
let t = mark.transform.translation();
|
||||
eprintln!("instance {}x{}; child {}x{} at ({}, {}) radii {:?}", inst.width, inst.height, mark.width, mark.height, t.x, t.y, mark.corners.radii);
|
||||
assert_eq!([mark.width, mark.height], [128.0, 128.0]);
|
||||
assert_eq!(mark.corners.radii, [32.0; 4]);
|
||||
assert_eq!([t.x, t.y], [0.0, 0.0]);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
use super::kiwi::Kv;
|
||||
|
||||
const SCHEMA: &str = "
|
||||
enum NodeType DOCUMENT CANVAS TEXT TEXT_PATH
|
||||
enum NodeType DOCUMENT CANVAS TEXT TEXT_PATH SYMBOL INSTANCE
|
||||
enum StyleType NONE TEXT
|
||||
enum TextDecoration NONE UNDERLINE STRIKETHROUGH
|
||||
struct GUID uint sessionID, uint localID
|
||||
@@ -17,7 +17,8 @@ message TextData string characters, uint[] characterStyleIDs, TextOverride[] sty
|
||||
message TextPathStart float tValue, bool forward
|
||||
message VectorData uint vectorNetworkBlob, Vector normalizedSize
|
||||
message Blob byte[] bytes
|
||||
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, TextData textData, float fontSize, FontName fontName, TextPathStart textPathStart, VectorData vectorData, StyleType styleType, StyleId styleIdForText, TextDecoration textDecoration
|
||||
message SymbolData GUID symbolID, float uniformScaleFactor
|
||||
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, TextData textData, float fontSize, FontName fontName, TextPathStart textPathStart, VectorData vectorData, StyleType styleType, StyleId styleIdForText, TextDecoration textDecoration, SymbolData symbolData
|
||||
message Message NodeChange[] nodeChanges, Blob[] blobs
|
||||
";
|
||||
use figcraft_doc::{NodeKind, TextPath};
|
||||
@@ -209,3 +210,55 @@ fn linked_text_style_decoration_warns_from_effective_mapped_style() {
|
||||
assert_eq!(json["document"]["children"][0]["children"][0]["style"]["textDecoration"], "UNDERLINE");
|
||||
assert!(warnings.iter().any(|w| w == "text path underline or strikethrough decoration is not rendered"), "{warnings:?}");
|
||||
}
|
||||
|
||||
/// A component holding a text on a path (path 100 long) and a half-scale instance of it.
|
||||
fn scaled_instance_message() -> Kv<'static> {
|
||||
let identity = || o(vec![("m00", f(1.0)), ("m01", f(0.0)), ("m02", f(0.0)), ("m10", f(0.0)), ("m11", f(1.0)), ("m12", f(0.0))]);
|
||||
let nodes = vec![
|
||||
node(0, None, "DOCUMENT", vec![]),
|
||||
node(1, Some(0), "CANVAS", vec![]),
|
||||
node(5, Some(1), "SYMBOL", vec![("name", s("Main")), ("size", point(100.0, 100.0)), ("transform", identity())]),
|
||||
node(
|
||||
6,
|
||||
Some(5),
|
||||
"TEXT_PATH",
|
||||
vec![
|
||||
("name", s("arc")),
|
||||
("size", point(100.0, 0.0)),
|
||||
("transform", identity()),
|
||||
("textData", o(vec![("characters", s("alpha"))])),
|
||||
("fontSize", f(24.0)),
|
||||
("fontName", o(vec![("family", s("Inter")), ("style", s("Regular"))])),
|
||||
("textPathStart", o(vec![("tValue", f(0.5)), ("forward", Kv::Bool(true))])),
|
||||
("vectorData", o(vec![("vectorNetworkBlob", u(0)), ("normalizedSize", point(100.0, 0.0))])),
|
||||
],
|
||||
),
|
||||
node(
|
||||
9,
|
||||
Some(1),
|
||||
"INSTANCE",
|
||||
vec![
|
||||
("name", s("Half")),
|
||||
("size", point(50.0, 50.0)),
|
||||
("transform", identity()),
|
||||
("symbolData", o(vec![("symbolID", guid(5)), ("uniformScaleFactor", f(0.5))])),
|
||||
],
|
||||
),
|
||||
];
|
||||
o(vec![("nodeChanges", a(nodes)), ("blobs", a(vec![o(vec![("bytes", Kv::Bytes(blob()))])]))])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_on_a_path_in_a_scaled_instance_has_its_path_scaled() {
|
||||
let (json, warnings, _) = super::map::to_rest(&scaled_instance_message(), "Scaled path", crate::rest::MAX_NODES);
|
||||
assert!(!warnings.iter().any(|w| w.contains("text path")), "{warnings:?}");
|
||||
let (doc, _) = crate::rest::import_fig(&json, &|_| None).unwrap();
|
||||
let path_of = |parent: &str| {
|
||||
let parent = doc.nodes().find(|n| n.name == parent).unwrap();
|
||||
let node = doc.node(parent.children[0]).unwrap();
|
||||
let NodeKind::Text(text) = &node.kind else { panic!("expected text") };
|
||||
text.path.as_deref().unwrap().d.clone()
|
||||
};
|
||||
assert_eq!(path_of("Main"), "M0,0 L100,0");
|
||||
assert_eq!(path_of("Half"), "M0,0 L50,0");
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//! Per-point radii follow the scale of their vector-data row, not the resized layer's box.
|
||||
|
||||
use figcraft_doc::{Document, Node, NodeKind, VectorNetwork};
|
||||
|
||||
use super::kiwi::Kv;
|
||||
use super::network::{self, Network, Region, Segment, Vertex};
|
||||
use super::tests::{arr, at, canvas, e, f, g, node, o, schema, u, xy};
|
||||
|
||||
fn vector_data(radius: f64) -> Kv<'static> {
|
||||
o(vec![
|
||||
("vectorNetworkBlob", u(0)),
|
||||
("normalizedSize", xy(100.0, 100.0)),
|
||||
(
|
||||
"styleOverrideTable",
|
||||
arr(vec![o(vec![("styleID", u(1)), ("cornerRadius", f(radius))]), o(vec![("styleID", u(2)), ("cornerRadius", f(0.0))])]),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
fn source() -> Vec<Kv<'static>> {
|
||||
vec![
|
||||
node(0, None, "DOCUMENT", "D", vec![]),
|
||||
node(1, Some((0, "!")), "CANVAS", "P", vec![]),
|
||||
node(5, Some((1, "!")), "SYMBOL", "Main", vec![("size", xy(100.0, 100.0))]),
|
||||
node(
|
||||
6,
|
||||
Some((5, "!")),
|
||||
"VECTOR",
|
||||
"Triangle",
|
||||
vec![
|
||||
("size", xy(100.0, 100.0)),
|
||||
("vectorData", vector_data(8.0)),
|
||||
("horizontalConstraint", e("SCALE")),
|
||||
("verticalConstraint", e("SCALE")),
|
||||
],
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn instance(id: u64, parent: u64, main: u64, name: &str, size: f64, factor: Option<f64>, rows: Vec<Kv<'static>>) -> Kv<'static> {
|
||||
let mut symbol = vec![("symbolID", g(main)), ("symbolOverrides", arr(rows))];
|
||||
if let Some(factor) = factor {
|
||||
symbol.push(("uniformScaleFactor", f(factor)));
|
||||
}
|
||||
node(id, Some((parent, "!")), "INSTANCE", name, vec![("size", xy(size, size)), ("transform", at(200.0, 0.0)), ("symbolData", o(symbol))])
|
||||
}
|
||||
|
||||
fn row(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
|
||||
let mut values = vec![("guidPath", o(vec![("guids", arr(vec![g(6)]))]))];
|
||||
values.extend(fields);
|
||||
o(values)
|
||||
}
|
||||
|
||||
fn import(nodes: Vec<Kv<'static>>) -> Document {
|
||||
let network = Network {
|
||||
vertices: vec![Vertex { style: 1, x: 0.0, y: 0.0 }, Vertex { style: 1, x: 100.0, y: 0.0 }, Vertex { style: 2, x: 0.0, y: 100.0 }],
|
||||
segments: (0..3).map(|a| Segment { style: 0, a, b: (a + 1) % 3, ta: (0.0, 0.0), tb: (0.0, 0.0) }).collect(),
|
||||
regions: vec![Region { style: 0, non_zero: false, loops: vec![vec![0, 1, 2]] }],
|
||||
};
|
||||
let message = o(vec![("nodeChanges", arr(nodes)), ("blobs", arr(vec![o(vec![("bytes", Kv::Bytes(network::encode(&network)))])]))]);
|
||||
super::import(&canvas(&schema(), &message), "Scaled vectors").unwrap().0
|
||||
}
|
||||
|
||||
fn child<'a>(doc: &'a Document, name: &str) -> &'a Node {
|
||||
let parent = doc.nodes().find(|node| node.name == name).unwrap();
|
||||
doc.node(parent.children[0]).unwrap()
|
||||
}
|
||||
|
||||
fn network(node: &Node) -> &VectorNetwork {
|
||||
let NodeKind::Vector { network } = &node.kind else { panic!("not a vector") };
|
||||
network
|
||||
}
|
||||
|
||||
fn assert_vector(node: &Node, size: f64, radius: f64) {
|
||||
assert_eq!((node.width, node.height), (size, size));
|
||||
let vector = network(node);
|
||||
assert_eq!((vector.vertices[1].x, vector.vertices[2].y), (size, size));
|
||||
assert_eq!(vector.vertices.iter().map(|point| point.radius).collect::<Vec<_>>(), [radius, radius, 0.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_corner_radii_follow_uniform_scale_without_changing_the_main() {
|
||||
for (factor, size, radius) in [(None, 100.0, 8.0), (Some(1.0), 100.0, 8.0), (Some(0.5), 50.0, 4.0), (Some(2.0), 200.0, 16.0)] {
|
||||
let mut nodes = source();
|
||||
nodes.push(instance(9, 1, 5, "Copy", size, factor, vec![]));
|
||||
let doc = import(nodes);
|
||||
assert_vector(child(&doc, "Main"), 100.0, 8.0);
|
||||
assert_vector(child(&doc, "Copy"), size, radius);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_corner_radii_from_override_and_derived_rows_are_not_scaled_twice() {
|
||||
for derived in [false, true] {
|
||||
let mut nodes = source();
|
||||
let rows = vec![row(vec![("vectorData", vector_data(3.0))])];
|
||||
let mut copy = instance(9, 1, 5, "Copy", 50.0, Some(0.5), if derived { vec![] } else { rows.clone() });
|
||||
if derived {
|
||||
let Kv::Object(fields) = &mut copy else { panic!() };
|
||||
fields.push(("derivedSymbolData", arr(rows)));
|
||||
}
|
||||
nodes.push(copy);
|
||||
let doc = import(nodes);
|
||||
assert_vector(child(&doc, "Copy"), 50.0, 3.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_corner_radii_still_scale_when_only_the_size_is_derived() {
|
||||
let mut nodes = source();
|
||||
let mut copy = instance(9, 1, 5, "Copy", 50.0, Some(0.5), vec![]);
|
||||
let Kv::Object(fields) = &mut copy else { panic!() };
|
||||
fields.push(("derivedSymbolData", arr(vec![row(vec![("size", xy(50.0, 50.0))])])));
|
||||
nodes.push(copy);
|
||||
let doc = import(nodes);
|
||||
assert_vector(child(&doc, "Copy"), 50.0, 4.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_vector_corner_radii_take_the_scale_of_their_own_row() {
|
||||
for own_row in [false, true] {
|
||||
let mut nodes = source();
|
||||
nodes.push(node(20, Some((1, "!")), "SYMBOL", "Wrapper", vec![("size", xy(50.0, 50.0))]));
|
||||
let rows = if own_row { vec![row(vec![("vectorData", vector_data(6.0))])] } else { vec![] };
|
||||
nodes.push(instance(21, 20, 5, "Inner", 50.0, Some(0.5), rows));
|
||||
nodes.push(instance(30, 1, 20, "Outer", 25.0, Some(0.5), vec![]));
|
||||
let doc = import(nodes);
|
||||
let inner = child(&doc, "Outer");
|
||||
assert_vector(doc.node(inner.children[0]).unwrap(), 25.0, if own_row { 3.0 } else { 2.0 });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equally_sized_vectors_with_different_scale_factors_do_not_share_radii() {
|
||||
for scaled_first in [false, true] {
|
||||
let mut nodes = source();
|
||||
// Equal sort positions are ordered by GUID, so vary IDs to exercise both cache orders.
|
||||
nodes.push(instance(if scaled_first { 8 } else { 9 }, 1, 5, "Scaled", 50.0, Some(0.5), vec![]));
|
||||
nodes.push(instance(if scaled_first { 9 } else { 8 }, 1, 5, "Resized", 50.0, None, vec![]));
|
||||
let doc = import(nodes);
|
||||
assert_vector(child(&doc, "Scaled"), 50.0, 4.0);
|
||||
assert_vector(child(&doc, "Resized"), 50.0, 8.0);
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ pub const RESOURCE_NOT_FOUND: i64 = -32002;
|
||||
const MAX_LINE: usize = 32 * 1024 * 1024;
|
||||
|
||||
/// The `instructions` string sent on initialize.
|
||||
pub const INSTRUCTIONS: &str = "FigCraft is an interface design tool (Figma-class): pages hold frames, \
|
||||
pub const INSTRUCTIONS: &str = "FigCraft is an interface design tool: pages hold frames, \
|
||||
groups, shapes, text, components and instances, with auto layout, styles, variables and prototyping. \
|
||||
Coordinates are page units (1 unit = 1 px at 100% zoom), x right and y DOWN. create_node and set_properties \
|
||||
take x/y RELATIVE TO THE PARENT (the page when there is no parent, so page coordinates); inspect_document \
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
//! Guard the production display surfaces without banning compatibility identifiers or notices.
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const BANNED: &[&str] = &["Figma-class", "Accessibility at Figma"];
|
||||
const ALLOWED: &[&str] = &[];
|
||||
const ROOTS: &[&str] = &["src", "../engine/src/cmd"];
|
||||
|
||||
fn is_word(c: char) -> bool {
|
||||
c.is_alphanumeric() || c == '_'
|
||||
}
|
||||
|
||||
fn banned_display(text: &str) -> bool {
|
||||
if ALLOWED.contains(&text) {
|
||||
return false;
|
||||
}
|
||||
BANNED.iter().any(|banned| {
|
||||
text.match_indices(banned)
|
||||
.any(|(at, _)| !text[..at].chars().next_back().is_some_and(is_word) && !text[at + banned.len()..].chars().next().is_some_and(is_word))
|
||||
})
|
||||
}
|
||||
|
||||
// Read ordinary and raw Rust string literals, ignoring comments. No compiler dependency is
|
||||
// needed, so this guard can also run by itself when a full application build is unavailable.
|
||||
#[allow(clippy::cognitive_complexity)] // This small test-only lexer keeps raw-string/comment state together.
|
||||
fn literals(source: &str) -> Vec<String> {
|
||||
let chars: Vec<char> = source.chars().collect();
|
||||
let mut out = Vec::new();
|
||||
let mut at = 0;
|
||||
let mut test_module = false;
|
||||
let mut test_depth = 0_usize;
|
||||
while at < chars.len() {
|
||||
if chars.get(at..at + 12) == Some(&['#', '[', 'c', 'f', 'g', '(', 't', 'e', 's', 't', ')', ']']) {
|
||||
at += 12;
|
||||
let rest: String = chars[at..].iter().collect();
|
||||
if rest.trim_start().starts_with("mod ") {
|
||||
test_module = true;
|
||||
}
|
||||
} else if chars.get(at..at + 2) == Some(&['/', '/']) {
|
||||
while at < chars.len() && chars[at] != '\n' {
|
||||
at += 1;
|
||||
}
|
||||
} else if chars.get(at..at + 2) == Some(&['/', '*']) {
|
||||
at += 2;
|
||||
let mut depth = 1;
|
||||
while at < chars.len() && depth > 0 {
|
||||
if chars.get(at..at + 2) == Some(&['/', '*']) {
|
||||
depth += 1;
|
||||
at += 2;
|
||||
} else if chars.get(at..at + 2) == Some(&['*', '/']) {
|
||||
depth -= 1;
|
||||
at += 2;
|
||||
} else {
|
||||
at += 1;
|
||||
}
|
||||
}
|
||||
} else if chars[at] == 'r' && matches!(chars.get(at + 1), Some('"' | '#')) {
|
||||
let start = at;
|
||||
at += 1;
|
||||
while chars.get(at) == Some(&'#') {
|
||||
at += 1;
|
||||
}
|
||||
let hashes = at - start - 1;
|
||||
if chars.get(at) != Some(&'"') {
|
||||
continue;
|
||||
}
|
||||
at += 1;
|
||||
let begin = at;
|
||||
while at < chars.len() {
|
||||
if chars[at] == '"' && (1..=hashes).all(|n| chars.get(at + n) == Some(&'#')) {
|
||||
break;
|
||||
}
|
||||
at += 1;
|
||||
}
|
||||
if !test_module {
|
||||
out.push(chars[begin..at].iter().collect());
|
||||
}
|
||||
at += 1 + hashes;
|
||||
} else if chars[at] == '"' {
|
||||
at += 1;
|
||||
let mut text = String::new();
|
||||
while at < chars.len() {
|
||||
let c = chars[at];
|
||||
at += 1;
|
||||
if c == '"' {
|
||||
break;
|
||||
}
|
||||
if c == '\\' && at < chars.len() {
|
||||
text.push(chars[at]);
|
||||
at += 1;
|
||||
} else {
|
||||
text.push(c);
|
||||
}
|
||||
}
|
||||
if !test_module {
|
||||
out.push(text);
|
||||
}
|
||||
} else if chars[at] == '\'' && chars.get(at + 2) == Some(&'\'') {
|
||||
at += 3; // A character literal such as '"', not the start of a string.
|
||||
} else {
|
||||
if test_module {
|
||||
match chars[at] {
|
||||
'{' => test_depth += 1,
|
||||
'}' => {
|
||||
test_depth = test_depth.saturating_sub(1);
|
||||
if test_depth == 0 {
|
||||
test_module = false;
|
||||
}
|
||||
}
|
||||
';' if test_depth == 0 => test_module = false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
at += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn check_tree(path: &Path, failures: &mut Vec<String>) -> std::io::Result<()> {
|
||||
if path.is_dir() {
|
||||
for entry in std::fs::read_dir(path)? {
|
||||
let path = entry?.path();
|
||||
if path.file_name().is_some_and(|name| name == "tests") {
|
||||
continue;
|
||||
}
|
||||
check_tree(&path, failures)?;
|
||||
}
|
||||
} else if path.extension().is_some_and(|ext| ext == "rs") {
|
||||
let name = path.file_name().ok_or_else(|| std::io::Error::other("source filename missing"))?.to_string_lossy();
|
||||
if name == "tests.rs" || name.ends_with("_tests.rs") {
|
||||
return Ok(());
|
||||
}
|
||||
let source = std::fs::read_to_string(path)?;
|
||||
// Skip only inline test modules, not later production following test-only imports.
|
||||
for text in literals(&source) {
|
||||
if banned_display(&text) {
|
||||
failures.push(format!("{}: {text:?}", path.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_display_names_stay_neutral() -> std::io::Result<()> {
|
||||
let base = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let mut failures = Vec::new();
|
||||
for root in ROOTS {
|
||||
let root = base.join(root);
|
||||
assert!(root.exists(), "missing display source: {}", root.display());
|
||||
check_tree(&root, &mut failures)?;
|
||||
}
|
||||
assert!(failures.is_empty(), "Banned display names:\n{}", failures.join("\n"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guard_distinguishes_labels_from_identifiers_and_comments() {
|
||||
for name in BANNED {
|
||||
assert!(banned_display(name), "{name}");
|
||||
}
|
||||
for name in ALLOWED {
|
||||
assert!(!banned_display(name), "{name}");
|
||||
}
|
||||
assert!(!banned_display("filter.cameraRaw"));
|
||||
assert!(!banned_display("tool.guidedUpright"));
|
||||
assert!(!banned_display("insert.smartArt"));
|
||||
assert_eq!(literals("// \"ignored\"\n/* \"ignored\" */ \"label\""), ["label"]);
|
||||
assert_eq!(literals("r#\"raw label\"#"), ["raw label"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guard_scans_production_after_test_only_items() {
|
||||
let source = r###"#[cfg(test)] mod tests { let _ = "ignored"; }
|
||||
#[cfg(test)] use fixtures::Helper;
|
||||
fn production() { let _ = r#"live label"#; }
|
||||
"###;
|
||||
assert_eq!(literals(source), ["live label"]);
|
||||
assert_eq!(literals("#[cfg(test)] mod tests; fn live() { let _ = \"live label\"; }"), ["live label"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_read_failures_are_reported() {
|
||||
// A file cannot be traversed as a directory. A failed scan must fail, not silently pass.
|
||||
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/display_names.rs/not-a-directory.rs");
|
||||
assert!(check_tree(&path, &mut Vec::new()).is_err());
|
||||
}
|
||||
@@ -433,9 +433,9 @@ status = "draft — needs native review"
|
||||
"Detach styles" = "فصل الأنماط"
|
||||
"Detach variable" = "فصل المتغير"
|
||||
"Details" = "التفاصيل"
|
||||
"Dev Mode" = "وضع المطور"
|
||||
"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "وضع المطور للفحص فقط؛ انتقل إلى التصميم (Shift+D) للتحرير"
|
||||
"Dev resources" = "موارد المطورين"
|
||||
"Developer Mode" = "وضع المطور"
|
||||
"Developer Mode only inspects: switch to Design (Shift+D) to edit" = "وضع المطور للفحص فقط؛ انتقل إلى التصميم (Shift+D) للتحرير"
|
||||
"Development" = "التطوير"
|
||||
"Device" = "الجهاز"
|
||||
"Device at 100%" = "الجهاز بحجم 100%"
|
||||
|
||||
@@ -415,8 +415,8 @@ status = "draft — needs native review"
|
||||
"Detach styles" = "স্টাইলগুলো বিচ্ছিন্ন করুন"
|
||||
"Detach variable" = "ভেরিয়েবল বিচ্ছিন্ন করুন"
|
||||
"Details" = "বিস্তারিত"
|
||||
"Dev Mode" = "ডেভ মোড"
|
||||
"Dev resources" = "ডেভ রিসোর্স"
|
||||
"Developer Mode" = "ডেভেলপার মোড"
|
||||
"Development" = "ডেভেলপমেন্ট"
|
||||
"Device" = "ডিভাইস"
|
||||
"Device at 100%" = "১০০%-এ ডিভাইস"
|
||||
|
||||
@@ -436,9 +436,9 @@ status = "draft — needs native review"
|
||||
"Detach styles" = "Odvoji stilove"
|
||||
"Detach variable" = "Odvoji varijablu"
|
||||
"Details" = "Detalji"
|
||||
"Dev Mode" = "Dev Mode"
|
||||
"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "Dev Mode služi samo za pregled: prebacite se na Dizajn (Shift+D) za uređivanje"
|
||||
"Dev resources" = "Resursi za programere"
|
||||
"Developer Mode" = "Razvojni režim"
|
||||
"Developer Mode only inspects: switch to Design (Shift+D) to edit" = "Razvojni režim služi samo za pregled: prebacite se na Dizajn (Shift+D) za uređivanje"
|
||||
"Development" = "Razvoj"
|
||||
"Device" = "Uređaj"
|
||||
"Device at 100%" = "Uređaj na 100%"
|
||||
@@ -2513,7 +2513,7 @@ status = "draft — needs native review"
|
||||
"view.contextMenu" = "Prikaži kontekstni meni"
|
||||
"view.design" = "Panel dizajna"
|
||||
"view.designPrototype" = "Prebacuj između Dizajna i Prototipa"
|
||||
"view.devMode" = "Dev Mode"
|
||||
"view.devMode" = "Razvojni režim"
|
||||
"view.frameOutlines" = "Konture okvira"
|
||||
"view.fullscreen" = "Preko cijelog ekrana"
|
||||
"view.get" = "Dohvati prikaz"
|
||||
|
||||
@@ -405,8 +405,8 @@ status = "draft — needs native review"
|
||||
"Detach styles" = "Odpojit styly"
|
||||
"Detach variable" = "Odpojit proměnnou"
|
||||
"Details" = "Podrobnosti"
|
||||
"Dev Mode" = "Režim pro vývojáře"
|
||||
"Dev resources" = "Zdroje pro vývojáře"
|
||||
"Developer Mode" = "Režim pro vývojáře"
|
||||
"Development" = "Vývoj"
|
||||
"Device" = "Zařízení"
|
||||
"Device at 100%" = "Zařízení ve 100 %"
|
||||
|
||||
@@ -404,9 +404,9 @@ status = "draft — needs native review"
|
||||
"Detach styles" = "Stile lösen"
|
||||
"Detach variable" = "Variable lösen"
|
||||
"Details" = "Details"
|
||||
"Dev Mode" = "Entwicklermodus"
|
||||
"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "Der Entwicklermodus prüft nur: Wechseln Sie zu Design (Shift+D), um zu bearbeiten"
|
||||
"Dev resources" = "Entwicklerressourcen"
|
||||
"Developer Mode" = "Entwicklermodus"
|
||||
"Developer Mode only inspects: switch to Design (Shift+D) to edit" = "Der Entwicklermodus prüft nur: Wechseln Sie zu Design (Shift+D), um zu bearbeiten"
|
||||
"Development" = "Entwicklung"
|
||||
"Device" = "Gerät"
|
||||
"Device at 100%" = "Gerät bei 100 %"
|
||||
|
||||
@@ -479,9 +479,9 @@ name = "English"
|
||||
"Detach styles" = "Detach styles"
|
||||
"Detach variable" = "Detach variable"
|
||||
"Details" = "Details"
|
||||
"Dev Mode" = "Dev Mode"
|
||||
"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "Dev Mode only inspects: switch to Design (Shift+D) to edit"
|
||||
"Dev resources" = "Dev resources"
|
||||
"Developer Mode" = "Developer Mode"
|
||||
"Developer Mode only inspects: switch to Design (Shift+D) to edit" = "Developer Mode only inspects: switch to Design (Shift+D) to edit"
|
||||
"Development" = "Development"
|
||||
"Device" = "Device"
|
||||
"Device at 100%" = "Device at 100%"
|
||||
|
||||
@@ -438,9 +438,9 @@ status = "draft — needs native review"
|
||||
"Detach styles" = "Separar estilos"
|
||||
"Detach variable" = "Separar variable"
|
||||
"Details" = "Detalles"
|
||||
"Dev Mode" = "Modo desarrollo"
|
||||
"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "El modo desarrollo solo inspecciona: cambia a Diseño (Shift+D) para editar"
|
||||
"Dev resources" = "Recursos de desarrollo"
|
||||
"Developer Mode" = "Modo de desarrollo"
|
||||
"Developer Mode only inspects: switch to Design (Shift+D) to edit" = "El modo de desarrollo solo inspecciona: cambia a Diseño (Shift+D) para editar"
|
||||
"Development" = "Desarrollo"
|
||||
"Device" = "Dispositivo"
|
||||
"Device at 100%" = "Dispositivo al 100%"
|
||||
@@ -2476,7 +2476,7 @@ status = "draft — needs native review"
|
||||
"view.contextMenu" = "Mostrar menú contextual"
|
||||
"view.design" = "Panel de diseño"
|
||||
"view.designPrototype" = "Alternar entre Diseño y Prototipo"
|
||||
"view.devMode" = "Modo desarrollo"
|
||||
"view.devMode" = "Modo de desarrollo"
|
||||
"view.frameOutlines" = "Contornos de marcos"
|
||||
"view.get" = "Obtener vista"
|
||||
"view.layers" = "Panel de capas"
|
||||
|
||||
@@ -439,9 +439,9 @@ status = "draft — needs native review"
|
||||
"Detach styles" = "Irrota tyylit"
|
||||
"Detach variable" = "Irrota muuttuja"
|
||||
"Details" = "Tiedot"
|
||||
"Dev Mode" = "Kehittäjätila"
|
||||
"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "Kehittäjätilassa voi vain tarkastella: vaihda Suunnittelu-tilaan (Shift+D) muokataksesi"
|
||||
"Dev resources" = "Kehittäjäresurssit"
|
||||
"Developer Mode" = "Kehittäjätila"
|
||||
"Developer Mode only inspects: switch to Design (Shift+D) to edit" = "Kehittäjätilassa voi vain tarkastella: vaihda Suunnittelu-tilaan (Shift+D) muokataksesi"
|
||||
"Development" = "Kehitys"
|
||||
"Device" = "Laite"
|
||||
"Device at 100%" = "Laite koossa 100 %"
|
||||
|
||||
@@ -415,9 +415,9 @@ status = "draft — needs native review"
|
||||
"Detach styles" = "Détacher les styles"
|
||||
"Detach variable" = "Détacher la variable"
|
||||
"Details" = "Détails"
|
||||
"Dev Mode" = "Mode Dev"
|
||||
"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "Le mode Dev sert seulement à inspecter : passez en Design (Shift+D) pour modifier"
|
||||
"Dev resources" = "Ressources de développement"
|
||||
"Developer Mode" = "Mode développeur"
|
||||
"Developer Mode only inspects: switch to Design (Shift+D) to edit" = "Le mode développeur sert seulement à inspecter : passez en Design (Shift+D) pour modifier"
|
||||
"Development" = "Développement"
|
||||
"Device" = "Appareil"
|
||||
"Device at 100%" = "Appareil à 100 %"
|
||||
@@ -2413,7 +2413,7 @@ status = "draft — needs native review"
|
||||
"view.contextMenu" = "Afficher le menu contextuel"
|
||||
"view.design" = "Panneau Design"
|
||||
"view.designPrototype" = "Basculer entre Design et Prototype"
|
||||
"view.devMode" = "Mode Dev"
|
||||
"view.devMode" = "Mode développeur"
|
||||
"view.frameOutlines" = "Contours des cadres"
|
||||
"view.get" = "Obtenir l’affichage"
|
||||
"view.layers" = "Panneau Calques"
|
||||
|
||||
@@ -419,8 +419,8 @@ status = "draft — needs native review"
|
||||
"Detach styles" = "Lepas gaya"
|
||||
"Detach variable" = "Lepas variabel"
|
||||
"Details" = "Detail"
|
||||
"Dev Mode" = "Dev Mode"
|
||||
"Dev resources" = "Sumber daya dev"
|
||||
"Developer Mode" = "Mode Pengembang"
|
||||
"Development" = "Pengembangan"
|
||||
"Device" = "Perangkat"
|
||||
"Device at 100%" = "Perangkat pada 100%"
|
||||
@@ -2367,7 +2367,7 @@ status = "draft — needs native review"
|
||||
"view.contextMenu" = "Tampilkan menu konteks"
|
||||
"view.design" = "Panel desain"
|
||||
"view.designPrototype" = "Beralih antara Desain dan Prototype"
|
||||
"view.devMode" = "Dev Mode"
|
||||
"view.devMode" = "Mode Pengembang"
|
||||
"view.frameOutlines" = "Outline frame"
|
||||
"view.get" = "Ambil tampilan"
|
||||
"view.layers" = "Panel layer"
|
||||
|
||||
@@ -398,8 +398,8 @@ status = "draft — needs native review"
|
||||
"Detach styles" = "Scollega stili"
|
||||
"Detach variable" = "Scollega variabile"
|
||||
"Details" = "Dettagli"
|
||||
"Dev Mode" = "Modalità sviluppo"
|
||||
"Dev resources" = "Risorse per sviluppatori"
|
||||
"Developer Mode" = "Modalità sviluppo"
|
||||
"Development" = "Sviluppo"
|
||||
"Device" = "Dispositivo"
|
||||
"Device at 100%" = "Dispositivo al 100%"
|
||||
|
||||
@@ -413,9 +413,9 @@ status = "draft — needs native review"
|
||||
"Detach styles" = "スタイルを切り離す"
|
||||
"Detach variable" = "変数を切り離す"
|
||||
"Details" = "詳細"
|
||||
"Dev Mode" = "開発モード"
|
||||
"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "開発モードは検査専用です。編集するにはデザイン (Shift+D) に切り替えてください"
|
||||
"Dev resources" = "開発リソース"
|
||||
"Developer Mode" = "開発者モード"
|
||||
"Developer Mode only inspects: switch to Design (Shift+D) to edit" = "開発者モードは検査専用です。編集するにはデザイン (Shift+D) に切り替えてください"
|
||||
"Development" = "開発"
|
||||
"Device" = "デバイス"
|
||||
"Device at 100%" = "デバイス(100%)"
|
||||
@@ -2391,7 +2391,7 @@ status = "draft — needs native review"
|
||||
"view.contextMenu" = "コンテキストメニューを表示"
|
||||
"view.design" = "デザインパネル"
|
||||
"view.designPrototype" = "デザインとプロトタイプを切り替え"
|
||||
"view.devMode" = "開発モード"
|
||||
"view.devMode" = "開発者モード"
|
||||
"view.frameOutlines" = "フレームのアウトライン"
|
||||
"view.get" = "ビューを取得"
|
||||
"view.layers" = "レイヤーパネル"
|
||||
|
||||
@@ -426,9 +426,9 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
|
||||
"Detach styles" = "სტილების გამოყოფა"
|
||||
"Detach variable" = "ცვლადის გამოყოფა"
|
||||
"Details" = "დეტალები"
|
||||
"Dev Mode" = "დეველოპერის რეჟიმი"
|
||||
"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "დეველოპერის რეჟიმში მხოლოდ ინსპექტირებაა შესაძლებელი: რედაქტირებისთვის გადაერთეთ დიზაინზე (Shift+D)"
|
||||
"Dev resources" = "დეველოპერის რესურსები"
|
||||
"Developer Mode" = "დეველოპერის რეჟიმი"
|
||||
"Developer Mode only inspects: switch to Design (Shift+D) to edit" = "დეველოპერის რეჟიმში მხოლოდ ინსპექტირებაა შესაძლებელი: რედაქტირებისთვის გადაერთეთ დიზაინზე (Shift+D)"
|
||||
"Development" = "დეველოპმენტი"
|
||||
"Device" = "მოწყობილობა"
|
||||
"Device at 100%" = "მოწყობილობა 100%-ზე"
|
||||
|
||||
@@ -397,9 +397,9 @@ status = "draft — needs native review"
|
||||
"Detach styles" = "스타일 분리"
|
||||
"Detach variable" = "변수 분리"
|
||||
"Details" = "세부 정보"
|
||||
"Dev Mode" = "개발 모드"
|
||||
"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "개발 모드는 검사 전용입니다. 편집하려면 디자인(Shift+D)으로 전환하세요"
|
||||
"Dev resources" = "개발 리소스"
|
||||
"Developer Mode" = "개발자 모드"
|
||||
"Developer Mode only inspects: switch to Design (Shift+D) to edit" = "개발자 모드는 검사 전용입니다. 편집하려면 디자인(Shift+D)으로 전환하세요"
|
||||
"Development" = "개발"
|
||||
"Device" = "기기"
|
||||
"Device at 100%" = "기기 100%"
|
||||
@@ -2423,7 +2423,7 @@ status = "draft — needs native review"
|
||||
"view.contextMenu" = "컨텍스트 메뉴 표시"
|
||||
"view.design" = "디자인 패널"
|
||||
"view.designPrototype" = "디자인과 프로토타입 전환"
|
||||
"view.devMode" = "개발 모드"
|
||||
"view.devMode" = "개발자 모드"
|
||||
"view.frameOutlines" = "프레임 윤곽선"
|
||||
"view.get" = "보기 가져오기"
|
||||
"view.layers" = "레이어 패널"
|
||||
|
||||
@@ -417,8 +417,8 @@ status = "draft — needs native review"
|
||||
"Detach styles" = "Stijlen loskoppelen"
|
||||
"Detach variable" = "Variabele loskoppelen"
|
||||
"Details" = "Details"
|
||||
"Dev Mode" = "Dev Mode"
|
||||
"Dev resources" = "Dev-bronnen"
|
||||
"Developer Mode" = "Ontwikkelaarsmodus"
|
||||
"Development" = "Ontwikkeling"
|
||||
"Device" = "Apparaat"
|
||||
"Device at 100%" = "Apparaat op 100%"
|
||||
@@ -1602,7 +1602,7 @@ status = "draft — needs native review"
|
||||
"Wedge" = "Wedge"
|
||||
"Weekly planner" = "Weekplanner"
|
||||
"Weight" = "Dikte"
|
||||
"What changed since it was marked ready for dev" = "Wat er veranderd is sinds het is gemarkeerd als klaar voor Dev Mode"
|
||||
"What changed since it was marked ready for dev" = "Wat er veranderd is sinds het is gemarkeerd als klaar voor ontwikkeling"
|
||||
"What is wrong with this file? The server's operator gets your report." = "Wat klopt er niet aan dit bestand? Je melding gaat naar de beheerder van de server."
|
||||
"What it's for" = "Waarvoor het dient"
|
||||
"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "Vanwaar dit is veranderd, is niet bekend: het veranderde voordat FigCraft een kopie bewaarde van dev-klare frames. Los de wijziging op (of markeer het weer als te vergelijken) om vanaf nu te kunnen vergelijken."
|
||||
@@ -2408,7 +2408,7 @@ status = "draft — needs native review"
|
||||
"view.contextMenu" = "Contextmenu tonen"
|
||||
"view.design" = "Ontwerppaneel"
|
||||
"view.designPrototype" = "Wisselen tussen Design en Prototype"
|
||||
"view.devMode" = "Dev Mode"
|
||||
"view.devMode" = "Ontwikkelaarsmodus"
|
||||
"view.frameOutlines" = "Framecontouren"
|
||||
"view.fullscreen" = "Volledig scherm"
|
||||
"view.get" = "Weergave ophalen"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
# Wording reviewed and approved by Lucas (@lukasz-sklad).
|
||||
# Complete Polish localization for FigCraft.
|
||||
# See docs/translating.md; plural forms follow Polish CLDR integer rules.
|
||||
# The Developer Mode label (2026-11-07) is machine-translated and awaits review.
|
||||
|
||||
[meta]
|
||||
name = "Polski"
|
||||
@@ -416,8 +417,8 @@ name = "Polski"
|
||||
"Detach styles" = "Odłącz style"
|
||||
"Detach variable" = "Odłącz zmienną"
|
||||
"Details" = "Szczegóły"
|
||||
"Dev Mode" = "Tryb deweloperski"
|
||||
"Dev resources" = "Zasoby programistyczne"
|
||||
"Developer Mode" = "Tryb deweloperski"
|
||||
"Development" = "Programowanie"
|
||||
"Device" = "Urządzenie"
|
||||
"Device at 100%" = "Urządzenie w 100%"
|
||||
@@ -2426,7 +2427,7 @@ name = "Polski"
|
||||
"view.contextMenu" = "Pokaż menu kontekstowe"
|
||||
"view.design" = "Panel projektu"
|
||||
"view.designPrototype" = "Przełącz między Projektem a Prototypem"
|
||||
"view.devMode" = "Tryb programisty"
|
||||
"view.devMode" = "Tryb deweloperski"
|
||||
"view.frameOutlines" = "Kontury ramek"
|
||||
"view.fullscreen" = "Tryb pełnoekranowy"
|
||||
"view.get" = "Pobierz widok"
|
||||
|
||||
@@ -396,9 +396,9 @@ status = "draft — needs native review"
|
||||
"Detach styles" = "Desvincular estilos"
|
||||
"Detach variable" = "Desvincular variável"
|
||||
"Details" = "Detalhes"
|
||||
"Dev Mode" = "Modo Dev"
|
||||
"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "O modo Dev só inspeciona: mude para Design (Shift+D) para editar"
|
||||
"Dev resources" = "Recursos de desenvolvimento"
|
||||
"Developer Mode" = "Modo do desenvolvedor"
|
||||
"Developer Mode only inspects: switch to Design (Shift+D) to edit" = "O modo do desenvolvedor só inspeciona: mude para Design (Shift+D) para editar"
|
||||
"Development" = "Desenvolvimento"
|
||||
"Device" = "Dispositivo"
|
||||
"Device at 100%" = "Dispositivo a 100%"
|
||||
@@ -2422,7 +2422,7 @@ status = "draft — needs native review"
|
||||
"view.contextMenu" = "Mostrar menu de contexto"
|
||||
"view.design" = "Painel de design"
|
||||
"view.designPrototype" = "Alternar entre Design e Protótipo"
|
||||
"view.devMode" = "Modo Dev"
|
||||
"view.devMode" = "Modo do desenvolvedor"
|
||||
"view.frameOutlines" = "Contornos dos quadros"
|
||||
"view.get" = "Obter visualização"
|
||||
"view.layers" = "Painel de camadas"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# FigCraft interface strings: Russian.
|
||||
# Wording reviewed and approved by VanTan.
|
||||
# Strings added since that review (2026-10-20) are a machine draft awaiting review.
|
||||
# Keep Frame, Dev Mode and Smart animate as familiar tool names.
|
||||
# Keep Frame and Smart animate as familiar tool names.
|
||||
# See docs/translating.md; plural forms follow Russian integer rules.
|
||||
# The Developer Mode label (2026-11-07) is machine-translated and awaits review.
|
||||
|
||||
[meta]
|
||||
name = "Русский"
|
||||
@@ -416,8 +417,8 @@ name = "Русский"
|
||||
"Detach styles" = "Отвязать стили"
|
||||
"Detach variable" = "Отвязать переменную"
|
||||
"Details" = "Подробности"
|
||||
"Dev Mode" = "Dev Mode"
|
||||
"Dev resources" = "Ресурсы для разработки"
|
||||
"Developer Mode" = "Режим разработчика"
|
||||
"Development" = "Разработка"
|
||||
"Device" = "Устройство"
|
||||
"Device at 100%" = "Устройство в масштабе 100%"
|
||||
@@ -2427,7 +2428,7 @@ name = "Русский"
|
||||
"view.contextMenu" = "Показать контекстное меню"
|
||||
"view.design" = "Панель дизайна"
|
||||
"view.designPrototype" = "Переключить дизайн/прототип"
|
||||
"view.devMode" = "Dev Mode"
|
||||
"view.devMode" = "Режим разработчика"
|
||||
"view.frameOutlines" = "Контуры Frame"
|
||||
"view.get" = "Получить вид"
|
||||
"view.layers" = "Панель слоёв"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#
|
||||
# Written from the English catalog with ordinary Swedish software vocabulary; no other catalog and no
|
||||
# proprietary localisation was translated from. Brand names and keyboard glyphs stay as they are.
|
||||
# The Developer Mode label (2026-11-07) is machine-translated and awaits review.
|
||||
|
||||
[meta]
|
||||
name = "Svenska"
|
||||
@@ -442,9 +443,9 @@ name = "Svenska"
|
||||
"Detach styles" = "Koppla bort stilar"
|
||||
"Detach variable" = "Koppla bort variabel"
|
||||
"Details" = "Detaljer"
|
||||
"Dev Mode" = "Utvecklarläge"
|
||||
"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "Utvecklarläget kan bara granska: växla till Design (Shift+D) för att redigera"
|
||||
"Dev resources" = "Utvecklarresurser"
|
||||
"Developer Mode" = "Utvecklarläge"
|
||||
"Developer Mode only inspects: switch to Design (Shift+D) to edit" = "Utvecklarläget kan bara granska: växla till Design (Shift+D) för att redigera"
|
||||
"Development" = "Utveckling"
|
||||
"Device" = "Enhet"
|
||||
"Device at 100%" = "Enhet i 100 %"
|
||||
|
||||
@@ -414,9 +414,9 @@ status = "draft — needs native review"
|
||||
"Detach styles" = "ถอดสไตล์"
|
||||
"Detach variable" = "ถอดตัวแปร"
|
||||
"Details" = "รายละเอียด"
|
||||
"Dev Mode" = "Dev Mode"
|
||||
"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "Dev Mode ใช้ตรวจสอบเท่านั้น: สลับไปที่ดีไซน์ (Shift+D) เพื่อแก้ไข"
|
||||
"Dev resources" = "แหล่งข้อมูลสำหรับนักพัฒนา"
|
||||
"Developer Mode" = "โหมดนักพัฒนา"
|
||||
"Developer Mode only inspects: switch to Design (Shift+D) to edit" = "โหมดนักพัฒนาใช้ตรวจสอบเท่านั้น: สลับไปที่ดีไซน์ (Shift+D) เพื่อแก้ไข"
|
||||
"Development" = "การพัฒนา"
|
||||
"Device" = "อุปกรณ์"
|
||||
"Device at 100%" = "อุปกรณ์ที่ 100%"
|
||||
@@ -2385,7 +2385,7 @@ status = "draft — needs native review"
|
||||
"view.contextMenu" = "แสดงเมนูบริบท"
|
||||
"view.design" = "แผงดีไซน์"
|
||||
"view.designPrototype" = "สลับระหว่างดีไซน์และโปรโตไทป์"
|
||||
"view.devMode" = "Dev Mode"
|
||||
"view.devMode" = "โหมดนักพัฒนา"
|
||||
"view.frameOutlines" = "เค้าร่างเฟรม"
|
||||
"view.get" = "ดึงมุมมอง"
|
||||
"view.layers" = "แผงเลเยอร์"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
# address the user politely (-in/-ın: "seçin", "tıklayın"). The percent sign goes before the number
|
||||
# (%50). Turkish nouns stay singular after a count, so plural entries give only `other`. See
|
||||
# docs/translating.md.
|
||||
# The Developer Mode label (2026-11-07) is machine-translated and awaits review.
|
||||
|
||||
[meta]
|
||||
name = "Türkçe"
|
||||
@@ -409,8 +410,8 @@ name = "Türkçe"
|
||||
"Detach styles" = "Stilleri ayır"
|
||||
"Detach variable" = "Değişkeni ayır"
|
||||
"Details" = "Ayrıntılar"
|
||||
"Dev Mode" = "Geliştirici Modu"
|
||||
"Dev resources" = "Geliştirici kaynakları"
|
||||
"Developer Mode" = "Geliştirici Modu"
|
||||
"Development" = "Geliştirme"
|
||||
"Device" = "Cihaz"
|
||||
"Device at 100%" = "Cihaz %100'de"
|
||||
|
||||
@@ -399,8 +399,8 @@ status = "draft — needs native review"
|
||||
"Detach styles" = "Tách các kiểu"
|
||||
"Detach variable" = "Tách biến"
|
||||
"Details" = "Chi tiết"
|
||||
"Dev Mode" = "Dev Mode"
|
||||
"Dev resources" = "Tài nguyên dev"
|
||||
"Developer Mode" = "Chế độ nhà phát triển"
|
||||
"Development" = "Phát triển"
|
||||
"Device" = "Thiết bị"
|
||||
"Device at 100%" = "Thiết bị ở mức 100%"
|
||||
@@ -2306,7 +2306,7 @@ status = "draft — needs native review"
|
||||
"view.contextMenu" = "Hiện trình đơn ngữ cảnh"
|
||||
"view.design" = "Bảng Thiết kế"
|
||||
"view.designPrototype" = "Chuyển đổi giữa Thiết kế và Nguyên mẫu"
|
||||
"view.devMode" = "Dev Mode"
|
||||
"view.devMode" = "Chế độ nhà phát triển"
|
||||
"view.frameOutlines" = "Đường viền khung"
|
||||
"view.fullscreen" = "Toàn màn hình"
|
||||
"view.get" = "Lấy khung nhìn"
|
||||
|
||||
@@ -401,9 +401,9 @@ status = "draft — needs native review"
|
||||
"Detach styles" = "分离样式"
|
||||
"Detach variable" = "分离变量"
|
||||
"Details" = "详细"
|
||||
"Dev Mode" = "开发模式"
|
||||
"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "开发模式仅用于检查:切换到设计 (Shift+D) 进行编辑"
|
||||
"Dev resources" = "开发资源"
|
||||
"Developer Mode" = "开发者模式"
|
||||
"Developer Mode only inspects: switch to Design (Shift+D) to edit" = "开发者模式仅用于检查:切换到设计 (Shift+D) 进行编辑"
|
||||
"Development" = "开发"
|
||||
"Device" = "设备"
|
||||
"Device at 100%" = "设备 100%"
|
||||
@@ -2322,7 +2322,7 @@ status = "draft — needs native review"
|
||||
"view.contextMenu" = "显示上下文菜单"
|
||||
"view.design" = "设计面板"
|
||||
"view.designPrototype" = "在设计和原型之间切换"
|
||||
"view.devMode" = "开发模式"
|
||||
"view.devMode" = "开发者模式"
|
||||
"view.frameOutlines" = "画框轮廓"
|
||||
"view.get" = "获取视图"
|
||||
"view.layers" = "图层面板"
|
||||
|
||||
@@ -453,9 +453,9 @@ status = "draft — needs native review"
|
||||
"Detach styles" = "分離樣式"
|
||||
"Detach variable" = "分離變數"
|
||||
"Details" = "詳細資料"
|
||||
"Dev Mode" = "Dev Mode"
|
||||
"Dev Mode only inspects: switch to Design (Shift+D) to edit" = "Dev Mode 僅供檢視:切換至「設計」(Shift+D) 以編輯"
|
||||
"Dev resources" = "開發資源"
|
||||
"Developer Mode" = "開發者模式"
|
||||
"Developer Mode only inspects: switch to Design (Shift+D) to edit" = "開發者模式僅供檢視:切換至「設計」(Shift+D) 以編輯"
|
||||
"Development" = "開發"
|
||||
"Device" = "裝置"
|
||||
"Device at 100%" = "裝置 100%"
|
||||
@@ -2572,7 +2572,7 @@ status = "draft — needs native review"
|
||||
"view.contextMenu" = "顯示內容選單"
|
||||
"view.design" = "設計面板"
|
||||
"view.designPrototype" = "在設計與原型之間切換"
|
||||
"view.devMode" = "Dev Mode"
|
||||
"view.devMode" = "開發者模式"
|
||||
"view.frameOutlines" = "框架外框"
|
||||
"view.fullscreen" = "全螢幕"
|
||||
"view.get" = "取得檢視"
|
||||
|
||||
@@ -268,10 +268,16 @@ impl FigApp {
|
||||
return;
|
||||
}
|
||||
if !self.has_files() || self.ui.home {
|
||||
let docs = &self.session.docs;
|
||||
self.canvas.drop_closed(|uid| docs.iter().any(|d| d.uid == uid));
|
||||
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;
|
||||
// The keymap runs with the canvas; here the one shortcut that still applies is read directly.
|
||||
if ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::Comma)) {
|
||||
self.run("app.preferences", serde_json::json!({}));
|
||||
}
|
||||
dialogs::keys(self, &ctx);
|
||||
dialogs::show(self, &ctx);
|
||||
updates_ui::show(self, &ctx);
|
||||
@@ -358,11 +364,7 @@ impl FigApp {
|
||||
// each side of the panel (240 pt by default). The edge is drawn in `right_panel`.
|
||||
.frame(egui::Frame::NONE.fill(t.panel))
|
||||
.show(ui, |ui| {
|
||||
let label = match self.ui.right_tab {
|
||||
RightTab::Design => i18n::tr("Design"),
|
||||
RightTab::Prototype => i18n::tr("Prototype"),
|
||||
RightTab::Inspect => i18n::tr("Dev Mode"),
|
||||
};
|
||||
let label = right_tab_label(self.ui.right_tab);
|
||||
a11y::region(&mut self.ui.nav, ui, a11y::Region::Properties, egui::accesskit::Role::Pane, label);
|
||||
right::right_panel(self, ui)
|
||||
});
|
||||
@@ -443,3 +445,12 @@ impl FigApp {
|
||||
self.observe_ime(&ctx, !typing && !self.field_focus && self.ui.palette.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
/// The right panel's tab name, which its accessibility region reads.
|
||||
pub(crate) fn right_tab_label(tab: RightTab) -> &'static str {
|
||||
match tab {
|
||||
RightTab::Design => i18n::tr("Design"),
|
||||
RightTab::Prototype => i18n::tr("Prototype"),
|
||||
RightTab::Inspect => i18n::tr("Developer Mode"),
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,10 @@ pub struct CanvasState {
|
||||
pub tex_coarse: bool,
|
||||
/// The request the texture answers ([`crate::render_worker::Done::seq`]).
|
||||
tex_seq: u64,
|
||||
/// Refined images of the files and pages shown before, newest first: switching back to one
|
||||
/// shows its image at once while it renders again, instead of a blank canvas (and, where
|
||||
/// renders are slow, a coarse preview).
|
||||
kept: Vec<Kept>,
|
||||
submitted: Option<JobKey>,
|
||||
/// The view of the last render asked for.
|
||||
want_view: Option<figcraft_render::View>,
|
||||
@@ -98,6 +102,7 @@ impl Default for CanvasState {
|
||||
tex_of: None,
|
||||
tex_coarse: false,
|
||||
tex_seq: 0,
|
||||
kept: Vec::new(),
|
||||
submitted: None,
|
||||
want_view: None,
|
||||
render_work: None,
|
||||
@@ -119,6 +124,45 @@ impl Default for CanvasState {
|
||||
}
|
||||
}
|
||||
|
||||
/// At most this many images of files and pages not shown are kept (each is a canvas-sized texture).
|
||||
const KEPT: usize = 4;
|
||||
|
||||
/// A refined canvas image of a file and page not shown now ([`CanvasState::kept`]).
|
||||
struct Kept {
|
||||
texture: egui::TextureHandle,
|
||||
view: figcraft_render::View,
|
||||
of: (u64, NodeId),
|
||||
seq: u64,
|
||||
}
|
||||
|
||||
impl CanvasState {
|
||||
/// When the file or page shown is no longer the one the texture is of, set the texture aside
|
||||
/// (if refined) and bring back the image kept for `shown`, if any. Images of files that are no
|
||||
/// longer open (`open` says which are) are dropped every frame, not only on a switch: a
|
||||
/// background tab can close while the file shown stays the same.
|
||||
fn switch_texture(&mut self, shown: Option<(u64, NodeId)>, open: impl Fn(u64) -> bool) {
|
||||
if shown.is_some() && self.tex_of != shown {
|
||||
let back = self.kept.iter().position(|k| Some(k.of) == shown).map(|i| self.kept.remove(i));
|
||||
let (texture, view, of) = (self.texture.take(), self.tex_view.take(), self.tex_of.take());
|
||||
if let (Some(texture), Some(view), Some(of), false) = (texture, view, of, self.tex_coarse) {
|
||||
self.kept.insert(0, Kept { texture, view, of, seq: self.tex_seq });
|
||||
}
|
||||
self.tex_coarse = false;
|
||||
if let Some(k) = back {
|
||||
(self.texture, self.tex_view, self.tex_of, self.tex_seq) = (Some(k.texture), Some(k.view), Some(k.of), k.seq);
|
||||
}
|
||||
}
|
||||
self.drop_closed(open);
|
||||
self.kept.truncate(KEPT);
|
||||
}
|
||||
|
||||
/// Drop the kept images of files that are no longer open (`open` says which are). Home calls
|
||||
/// this too: the canvas isn't shown there, and closing the last file leads to it.
|
||||
pub(crate) fn drop_closed(&mut self, open: impl Fn(u64) -> bool) {
|
||||
self.kept.retain(|k| open(k.of.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// What the canvas shows while an IME composition is in progress: the document with the
|
||||
/// composition inserted at the caret (the real document only gets it on commit).
|
||||
pub struct PreeditView {
|
||||
@@ -235,7 +279,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
|
||||
let label = if documents {
|
||||
tr("Drop to open")
|
||||
} else if app.ui.right_tab == crate::RightTab::Inspect {
|
||||
tr("Dev Mode only inspects: switch to Design (Shift+D) to edit")
|
||||
tr("Developer Mode only inspects: switch to Design (Shift+D) to edit")
|
||||
} else {
|
||||
tr("Drop to import")
|
||||
};
|
||||
@@ -264,6 +308,8 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
|
||||
// Every finished render is shown, even when a newer one is already on its way: renders finish
|
||||
// in order, and an edit every frame (a drag) would otherwise never show until it stopped.
|
||||
let shown = app.session.ds().map(|ds| (ds.uid, ds.page)).ok();
|
||||
let docs = &app.session.docs;
|
||||
app.canvas.switch_texture(shown, |uid| docs.iter().any(|d| d.uid == uid));
|
||||
if let Some(done) = worker.poll()
|
||||
&& shows(&app.canvas, &done, shown)
|
||||
{
|
||||
@@ -408,3 +454,6 @@ mod wheel_zoom_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod zoom_preview_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tab_switch_tests;
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Switching tabs: the canvas shows the image a file had when it was last on screen from the first
|
||||
//! frame back, instead of a blank page (and, where renders are slow, a coarse preview) while the
|
||||
//! file renders again.
|
||||
|
||||
use egui::{Pos2, RawInput, Rect, vec2};
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::idle_tests::wait_for_render;
|
||||
use crate::{FigApp, NoServices};
|
||||
|
||||
/// The whole UI, driven headless, with files of their own names open.
|
||||
struct Harness {
|
||||
ctx: egui::Context,
|
||||
app: FigApp,
|
||||
time: f64,
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
/// The UI with a file named after each of `names` (each with a rectangle); the last is shown.
|
||||
fn new(names: &[&str]) -> Self {
|
||||
figcraft_text::scan_system_fonts();
|
||||
let mut s = Session::empty();
|
||||
for n in names {
|
||||
s.execute("file.new", &json!({"name": n})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 200, "height": 120})).unwrap();
|
||||
}
|
||||
Self { ctx: egui::Context::default(), app: FigApp::new(s, Box::new(NoServices)), time: 0.0 }
|
||||
}
|
||||
|
||||
/// One frame with no input, 1/60 s after the last.
|
||||
fn frame(&mut self) {
|
||||
self.time += 1.0 / 60.0;
|
||||
self.app.clock.set_test_now(Some(self.time));
|
||||
let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), ..Default::default() };
|
||||
let app = &mut self.app;
|
||||
let mut out = self.ctx.run_ui(input, |ui| app.ui(ui));
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
/// Frames until the canvas shows a refined image of the file and page on screen.
|
||||
fn settle(&mut self) {
|
||||
for _ in 0..200 {
|
||||
self.frame();
|
||||
wait_for_render(&self.app);
|
||||
let ds = self.app.session.ds().unwrap();
|
||||
let c = &self.app.canvas;
|
||||
if c.tex_of == Some((ds.uid, ds.page)) && !c.tex_coarse && c.texture.is_some() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
panic!("the canvas never showed a refined image");
|
||||
}
|
||||
|
||||
/// Show the file named `name`, as clicking its tab does.
|
||||
fn switch(&mut self, name: &str) {
|
||||
let i = self.app.session.docs.iter().position(|d| d.doc.name == name).unwrap();
|
||||
self.app.run("doc.switch", json!({"index": i}));
|
||||
}
|
||||
|
||||
/// The file and page on screen.
|
||||
fn shown(&self) -> (u64, figcraft_doc::NodeId) {
|
||||
let ds = self.app.session.ds().unwrap();
|
||||
(ds.uid, ds.page)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_back_to_a_tab_shows_its_image_on_the_first_frame() {
|
||||
let mut h = Harness::new(&["A", "B"]);
|
||||
h.switch("A");
|
||||
h.settle();
|
||||
let a = (h.shown(), h.app.canvas.tex_seq, h.app.canvas.texture.as_ref().map(egui::TextureHandle::id));
|
||||
h.switch("B");
|
||||
h.settle();
|
||||
let b = (h.shown(), h.app.canvas.tex_seq);
|
||||
assert_ne!(h.app.canvas.tex_of, Some(a.0));
|
||||
|
||||
// The image brought back is the one A had when it was last shown. Looked at without a frame:
|
||||
// in one, a render of A asked for in it could already have come back and replaced it.
|
||||
let c = &mut h.app.canvas;
|
||||
c.switch_texture(Some(a.0), |_| true);
|
||||
assert_eq!((c.tex_of, c.tex_seq, c.texture.as_ref().map(egui::TextureHandle::id)), (Some(a.0), a.1, a.2));
|
||||
c.switch_texture(Some(b.0), |_| true);
|
||||
assert_eq!((c.tex_of, c.tex_seq), (Some(b.0), b.1), "B's image was kept in turn");
|
||||
|
||||
h.switch("A");
|
||||
h.frame();
|
||||
let c = &h.app.canvas;
|
||||
assert_eq!(c.tex_of, Some(a.0), "A's image is on screen at once");
|
||||
assert!(!c.tex_coarse, "and it is the refined one, not a preview");
|
||||
assert!(c.kept.iter().any(|k| k.of == b.0), "B's image is kept in turn");
|
||||
h.settle();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_back_to_a_page_shows_its_image_on_the_first_frame() {
|
||||
let mut h = Harness::new(&["A"]);
|
||||
h.settle();
|
||||
let first = (h.shown(), h.app.canvas.tex_seq, h.app.canvas.texture.as_ref().map(egui::TextureHandle::id));
|
||||
h.app.run("page.add", json!({}));
|
||||
h.app.run("shape.ellipse", json!({"x": 40, "y": 40, "width": 120, "height": 120}));
|
||||
h.settle();
|
||||
let second = (h.shown(), h.app.canvas.tex_seq);
|
||||
assert_ne!(first.0, second.0);
|
||||
|
||||
// Looked at without a frame, as for tabs: a render asked for in one could replace the image.
|
||||
let c = &mut h.app.canvas;
|
||||
c.switch_texture(Some(first.0), |_| true);
|
||||
assert_eq!((c.tex_of, c.tex_seq, c.texture.as_ref().map(egui::TextureHandle::id)), (Some(first.0), first.1, first.2));
|
||||
c.switch_texture(Some(second.0), |_| true);
|
||||
assert_eq!((c.tex_of, c.tex_seq), (Some(second.0), second.1), "the second page's image was kept in turn");
|
||||
|
||||
h.app.run("page.select", json!({"index": 0}));
|
||||
h.frame();
|
||||
let c = &h.app.canvas;
|
||||
assert_eq!(c.tex_of, Some(first.0), "the first page's image is on screen at once");
|
||||
assert!(!c.tex_coarse);
|
||||
assert!(c.kept.iter().any(|k| k.of == second.0), "the second page's image is kept in turn");
|
||||
h.settle();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_images_are_bounded_and_dropped_with_their_files() {
|
||||
let names = ["A", "B", "C", "D", "E", "F", "G"];
|
||||
let mut h = Harness::new(&names);
|
||||
for n in names {
|
||||
h.switch(n);
|
||||
h.settle();
|
||||
}
|
||||
assert_eq!(h.app.canvas.kept.len(), super::KEPT);
|
||||
|
||||
let f = h.app.session.docs.iter().position(|d| d.doc.name == "F").unwrap();
|
||||
let f_uid = h.app.session.docs[f].uid;
|
||||
assert!(h.app.canvas.kept.iter().any(|k| k.of.0 == f_uid));
|
||||
// A tab closed in the background: the file shown stays the same.
|
||||
let before = h.shown();
|
||||
h.app.run("file.close", json!({"index": f, "force": true}));
|
||||
h.frame();
|
||||
assert_eq!(h.shown(), before);
|
||||
assert!(h.app.canvas.kept.iter().all(|k| k.of.0 != f_uid), "a closed file's image goes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_the_last_file_drops_its_kept_images() {
|
||||
let mut h = Harness::new(&["A", "B"]);
|
||||
h.switch("A");
|
||||
h.settle();
|
||||
h.switch("B");
|
||||
h.settle();
|
||||
assert_eq!(h.app.canvas.kept.len(), 1, "A's image is kept");
|
||||
// Both close before the next frame, which then shows Home, not the canvas.
|
||||
let a = h.app.session.docs.iter().position(|d| d.doc.name == "A").unwrap();
|
||||
h.app.run("file.close", json!({"index": a, "force": true}));
|
||||
h.app.run("file.close", json!({"index": 0, "force": true}));
|
||||
assert!(h.app.session.docs.is_empty());
|
||||
h.frame();
|
||||
assert!(h.app.canvas.kept.is_empty(), "no closed file's image stays behind Home");
|
||||
}
|
||||
@@ -23,8 +23,12 @@ use crate::render_worker::{
|
||||
Done, Inline, Job, PREVIEW_AFTER_MS, PREVIEW_FACTOR, Refined, Renders, SETTLE_MS, Worker, better, coarse_view, preview_with, render_now,
|
||||
};
|
||||
|
||||
/// A page of ellipses with a rectangle on top; the session and the rectangle.
|
||||
/// A page of ellipses with a rectangle on top; the session and the rectangle. The system fonts are
|
||||
/// scanned first (once per process, and a concurrent caller waits for it): the scan another test
|
||||
/// starts bumps the global font generation, and a render kept from before it is stale, so a pan
|
||||
/// or an edit meanwhile would redraw in full instead of patching.
|
||||
fn document() -> (Session, NodeId) {
|
||||
figcraft_text::scan_system_fonts();
|
||||
let mut s = Session::new();
|
||||
for k in 0..12 {
|
||||
let k = f64::from(k);
|
||||
|
||||
@@ -1062,7 +1062,7 @@ fn ui_key(app: &mut FigApp, ctx: &Context, p: &Value) -> Result<Value, String> {
|
||||
};
|
||||
// Dev Mode only inspects, as the window's key path says.
|
||||
if crate::menus::dev_mode_refuses(app, cmd) {
|
||||
return Err("Dev Mode only inspects: switch to Design to edit".into());
|
||||
return Err("Developer Mode only inspects: switch to Design to edit".into());
|
||||
}
|
||||
app.session.execute(cmd, ¶ms).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -319,7 +319,7 @@ fn drop_on_canvas(u: &mut crate::layers_drag_tests::Ui, names: &[&str], bytes: &
|
||||
#[test]
|
||||
fn dev_mode_imports_no_dropped_file_into_the_design_and_says_why() {
|
||||
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><rect width="40" height="20" fill="red"/></svg>"#;
|
||||
let refusal = "Dev Mode only inspects: switch to Design (Shift+D) to edit";
|
||||
let refusal = "Developer Mode only inspects: switch to Design (Shift+D) to edit";
|
||||
let mut u = crate::layers_drag_tests::Ui::new(Session::new());
|
||||
let layers = |u: &crate::layers_drag_tests::Ui| u.app.session.doc().unwrap().node(u.app.session.page().unwrap()).unwrap().children.len();
|
||||
u.app.ui.right_tab = crate::RightTab::Inspect;
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::FigApp;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use crate::i18n::tr;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use craft_update::updater::{Action, Updater};
|
||||
use craft_update::updater::{Action, Status, Updater};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use craft_update::{CheckResultDialog, CheckResultResponse};
|
||||
|
||||
@@ -21,6 +21,7 @@ pub(crate) struct Runtime {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
repaint: Option<egui::Context>,
|
||||
/// The result dialog of a manual check is open.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) manual_window_open: bool,
|
||||
/// The web build has no updater; its only result is that checks are unsupported.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -38,6 +39,7 @@ impl Default for Runtime {
|
||||
check_requested: false,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
repaint: None,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
manual_window_open: false,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
status_message: None,
|
||||
@@ -51,13 +53,19 @@ impl FigApp {
|
||||
pub fn set_startup_update_check(&mut self, on: bool) {
|
||||
self.updates_rt.startup_check = on;
|
||||
}
|
||||
|
||||
/// Start the install of an update that waits for the app to close. The desktop app calls this
|
||||
/// when it exits, before it ends the process; later calls do nothing.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn start_pending_update(&mut self) {
|
||||
self.updates_rt.updater.start_pending_update();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn check(app: &mut FigApp) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
app.updates_rt.check_requested = true;
|
||||
app.updates_rt.manual_window_open = true;
|
||||
if let Some(ctx) = &app.updates_rt.repaint {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
@@ -85,10 +93,18 @@ pub(crate) fn tick(app: &mut FigApp, ctx: &egui::Context) {
|
||||
let _ = (app, ctx);
|
||||
}
|
||||
|
||||
/// Whether the check result window shows: for every status but idle (a requested check, and the
|
||||
/// first start after an update, which reports that it installed or why it failed), and never over
|
||||
/// the startup prompt.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn result_window_shows(status: &Status, prompt_open: bool) -> bool {
|
||||
!prompt_open && !matches!(status, Status::Idle)
|
||||
}
|
||||
|
||||
pub(crate) fn show(app: &mut FigApp, ctx: &egui::Context) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
// The restart helper is scheduled before the app closes, so no update starts over unsaved work.
|
||||
// The updater asks to close only when no document has unsaved changes; the install starts when the app exits.
|
||||
let clean = !app.session.docs.iter().any(|d| d.is_dirty());
|
||||
match app.updates_rt.updater.show_if(ctx, clean) {
|
||||
Action::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)),
|
||||
@@ -97,14 +113,13 @@ pub(crate) fn show(app: &mut FigApp, ctx: &egui::Context) {
|
||||
Action::None => {}
|
||||
}
|
||||
let rt = &mut app.updates_rt;
|
||||
if rt.manual_window_open {
|
||||
if result_window_shows(rt.updater.status(), rt.updater.prompt_open()) {
|
||||
match CheckResultDialog::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")).show(ctx, rt.updater.status()) {
|
||||
CheckResultResponse::None => {}
|
||||
CheckResultResponse::Close => {
|
||||
rt.manual_window_open = false;
|
||||
rt.updater.dismiss_status();
|
||||
}
|
||||
CheckResultResponse::Close => rt.updater.dismiss_status(),
|
||||
CheckResultResponse::CheckAgain => rt.check_requested = true,
|
||||
// The next `show_if` asks the window to close once no document has unsaved changes.
|
||||
CheckResultResponse::RestartToUpdate => rt.updater.restart_to_update(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,3 +150,27 @@ pub(crate) fn show(app: &mut FigApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn result_window_shows_every_status_but_idle() {
|
||||
let shown = [
|
||||
Status::Checking,
|
||||
Status::UpToDate,
|
||||
Status::Failed("no network".into()),
|
||||
Status::Installing,
|
||||
Status::Ready,
|
||||
Status::Managed,
|
||||
Status::Updated("0.2.0".into()),
|
||||
Status::InstallFailed("disk full".into()),
|
||||
];
|
||||
for status in &shown {
|
||||
assert!(result_window_shows(status, false), "{status:?}");
|
||||
assert!(!result_window_shows(status, true), "{status:?} over the prompt");
|
||||
}
|
||||
assert!(!result_window_shows(&Status::Idle, false));
|
||||
}
|
||||
}
|
||||
@@ -190,8 +190,8 @@ fn dropped(app: &mut FigApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the Home sidebar: app icon and wordmark, file/template search (Escape clears it)
|
||||
/// and section tabs; choosing a section clears the file query.
|
||||
/// Draw the Home sidebar: app icon and wordmark, file/template search (Escape clears it), section
|
||||
/// tabs (choosing one clears the file query) and Preferences at the bottom.
|
||||
fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) {
|
||||
let mut y = left.min.y + 20.0;
|
||||
crate::theme::paint_app_icon(ui, Rect::from_min_size(pos2(left.min.x + 12.0, y - 4.0), vec2(32.0, 32.0)));
|
||||
@@ -229,6 +229,26 @@ fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) {
|
||||
}
|
||||
y += 34.0;
|
||||
}
|
||||
let pref_y = (left.max.y - 44.0).max(y + 12.0);
|
||||
if pref_y > y + 12.0 {
|
||||
ui.painter().line_segment([pos2(left.min.x + 12.0, pref_y - 6.0), pos2(left.max.x - 12.0, pref_y - 6.0)], Stroke::new(1.0, t.border));
|
||||
}
|
||||
let r = Rect::from_min_size(pos2(left.min.x + 8.0, pref_y), vec2(SIDEBAR - 16.0, 32.0));
|
||||
let mut resp = ui.interact(r, ui.scope_id().with("home_preferences"), Sense::click());
|
||||
let current = app.ui.preferences;
|
||||
a11y::button(&resp, tr("Preferences"));
|
||||
if current || resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), if current { t.selected_row } else { t.hover });
|
||||
}
|
||||
focus_ring(ui, t, r, &resp, 5);
|
||||
icons::paint(ui.painter(), Rect::from_min_size(pos2(r.min.x + 10.0, r.center().y - 8.0), vec2(16.0, 16.0)), Icon::Adjust, t.icon);
|
||||
ui.painter().text(pos2(r.min.x + 36.0, r.center().y), Align2::LEFT_CENTER, tr("Preferences"), regular(12.0), t.text);
|
||||
let key_label = figcraft_engine::keymap::display("Cmd+,", crate::shortcuts::platform());
|
||||
ui.painter().text(pos2(r.max.x - 8.0, r.center().y), Align2::RIGHT_CENTER, &key_label, regular(11.0), t.text_secondary);
|
||||
resp = resp.on_hover_text(format!("{} ({})", tr("Preferences"), key_label));
|
||||
if resp.clicked() {
|
||||
app.ui.preferences = !app.ui.preferences;
|
||||
}
|
||||
}
|
||||
|
||||
fn file_matches(name: &str, location: &str, query: &str) -> bool {
|
||||
|
||||
@@ -917,3 +917,34 @@ fn a_jam_file_is_a_document_open_and_drops_take() {
|
||||
assert!(crate::open::opens(path) && crate::open::is_document(path), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The Preferences dialog can be opened and toggled from the home screen via
|
||||
/// the `Cmd/Ctrl + ,` shortcut as well as by clicking Preferences in the sidebar.
|
||||
#[test]
|
||||
fn home_screen_preferences_shortcut_and_sidebar_access() {
|
||||
let mut u = first_run();
|
||||
u.app.ui.home = true;
|
||||
u.frame(vec![]);
|
||||
assert!(!u.app.ui.preferences);
|
||||
|
||||
// Ctrl/Cmd + Comma opens Preferences on the Home screen.
|
||||
u.frame(vec![
|
||||
Event::Key { key: Key::Comma, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND },
|
||||
Event::Key { key: Key::Comma, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::COMMAND },
|
||||
]);
|
||||
assert!(u.app.ui.preferences, "Ctrl/Cmd+, should open Preferences from the Home screen");
|
||||
|
||||
// Escape closes Preferences.
|
||||
press(&mut u, Key::Escape);
|
||||
assert!(!u.app.ui.preferences, "Escape should close Preferences");
|
||||
|
||||
// Clicking Preferences in the sidebar opens Preferences.
|
||||
let s = shapes(&mut u);
|
||||
click(&mut u, text_center(&s, "Preferences"));
|
||||
assert!(u.app.ui.preferences, "Clicking Preferences in sidebar should open Preferences");
|
||||
|
||||
// Clicking it again toggles it closed.
|
||||
let s = shapes(&mut u);
|
||||
click(&mut u, text_center(&s, "Preferences"));
|
||||
assert!(!u.app.ui.preferences, "Clicking Preferences in sidebar again should close it");
|
||||
}
|
||||
@@ -180,10 +180,14 @@ mod menu_contrast_tests;
|
||||
#[cfg(test)]
|
||||
mod menu_input_tests;
|
||||
#[cfg(test)]
|
||||
mod menu_overflow_tests;
|
||||
#[cfg(test)]
|
||||
mod mirror_tests;
|
||||
#[cfg(test)]
|
||||
mod missing_fonts_tests;
|
||||
#[cfg(test)]
|
||||
mod naming_tests;
|
||||
#[cfg(test)]
|
||||
mod online_tests;
|
||||
#[cfg(test)]
|
||||
mod open_issues_tests;
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
//! The main menu and its submenus stay inside the window (#2217): a submenu that would run past
|
||||
//! the bottom moves up to fit, and one taller than the window scrolls.
|
||||
|
||||
use egui::{Event, Id, MouseWheelUnit, Rect, TouchPhase, pos2, vec2};
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::layers_drag_tests::Ui;
|
||||
use crate::live_tests::{flat, text_center};
|
||||
use crate::menus::{HOVER_DELAY, main_menu_id};
|
||||
use crate::present_tests::id;
|
||||
|
||||
/// A window `h` tall with the main menu open and the Object submenu showing.
|
||||
fn object_menu_open(s: Session, h: f32) -> Ui {
|
||||
let mut u = Ui::new(s);
|
||||
u.screen = vec2(1280.0, h);
|
||||
u.app.ui.main_menu = true;
|
||||
u.frame(vec![Event::PointerMoved(pos2(1000.0, 10.0))]);
|
||||
u.frame(vec![]);
|
||||
let m = u.ctx.memory(|m| m.area_rect(main_menu_id())).expect("main menu drawn");
|
||||
// Frame margin, the Actions row and the space under it, then three rows down to Object.
|
||||
let y = m.min.y + 8.0 + 30.0 + 6.0 + 28.0 * 3.0 + 14.0;
|
||||
u.frame(vec![Event::PointerMoved(pos2(m.min.x + 60.0, y))]);
|
||||
for _ in 0..((HOVER_DELAY + 0.3) * 60.0) as usize {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
u
|
||||
}
|
||||
|
||||
fn object_panel(u: &Ui) -> Rect {
|
||||
u.ctx.memory(|m| m.area_rect(Id::unique(("menu", "Object")))).expect("the Object submenu is drawn")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_object_submenu_stays_inside_a_short_window() {
|
||||
let u = object_menu_open(Session::new(), 600.0);
|
||||
let panel = object_panel(&u);
|
||||
let window = Rect::from_min_size(pos2(0.0, 0.0), vec2(1280.0, 600.0));
|
||||
assert!(window.contains_rect(panel), "the Object submenu {panel:?} runs outside the window {window:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_menu_taller_than_the_window_scrolls_to_its_last_command() {
|
||||
let mut s = Session::new();
|
||||
let a = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 30})).unwrap());
|
||||
let b = id(&s.execute("shape.rectangle", &json!({"x": 100, "y": 30, "width": 20, "height": 20})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [b]})).unwrap();
|
||||
let mut u = object_menu_open(s, 420.0);
|
||||
let panel = object_panel(&u);
|
||||
assert!(Rect::from_min_size(pos2(0.0, 0.0), vec2(1280.0, 420.0)).contains_rect(panel), "{panel:?} outside the window");
|
||||
// Turn the wheel over the panel until the end of the list is in view.
|
||||
for _ in 0..4 {
|
||||
let wheel = Event::MouseWheel {
|
||||
unit: MouseWheelUnit::Point,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
delta: vec2(0.0, -1500.0),
|
||||
phase: TouchPhase::Move,
|
||||
modifiers: Default::default(),
|
||||
};
|
||||
u.frame(vec![Event::PointerMoved(panel.center()), wheel]);
|
||||
for _ in 0..20 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
}
|
||||
let shapes = flat(&u.frame_output(vec![]).shapes);
|
||||
let at = text_center(&shapes, "Move to page");
|
||||
assert!(panel.contains(at), "the last commands are out of reach: at {at:?} outside {panel:?}");
|
||||
// And a command above it, reached by scrolling back up, runs on click.
|
||||
let wheel = Event::MouseWheel {
|
||||
unit: MouseWheelUnit::Point,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
delta: vec2(0.0, 1.0e5),
|
||||
phase: TouchPhase::Move,
|
||||
modifiers: Default::default(),
|
||||
};
|
||||
u.frame(vec![Event::PointerMoved(panel.center()), wheel]);
|
||||
for _ in 0..20 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
// Scroll down in small steps until "Send to back" shows inside the panel, then click it.
|
||||
let mut hit = None;
|
||||
for _ in 0..40 {
|
||||
let shapes = flat(&u.frame_output(vec![]).shapes);
|
||||
let at = text_center(&shapes, "Send to back");
|
||||
if panel.contains(at) {
|
||||
hit = Some(at);
|
||||
break;
|
||||
}
|
||||
let wheel = Event::MouseWheel {
|
||||
unit: MouseWheelUnit::Point,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
delta: vec2(0.0, -120.0),
|
||||
phase: TouchPhase::Move,
|
||||
modifiers: Default::default(),
|
||||
};
|
||||
u.frame(vec![Event::PointerMoved(panel.center()), wheel]);
|
||||
for _ in 0..10 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
}
|
||||
let at = hit.expect("Send to back scrolls into view");
|
||||
crate::component_swap_default_tests::click(&mut u, at);
|
||||
let doc = u.app.session.doc().unwrap();
|
||||
let page = u.app.session.page().unwrap();
|
||||
let kids = &doc.node(page).unwrap().children;
|
||||
assert_eq!((kids.first().copied(), kids.last().copied()), (Some(b), Some(a)), "Send to back ran");
|
||||
}
|
||||
+41
-32
@@ -53,7 +53,7 @@ pub const UI_COMMANDS: &[&str] = &[
|
||||
pub fn dev_mode_refuses(app: &mut FigApp, id: &str) -> bool {
|
||||
let refused = app.ui.right_tab == crate::RightTab::Inspect && !figcraft_engine::devinspect::dev_mode_allows(id);
|
||||
if refused {
|
||||
app.toast(tr("Dev Mode only inspects: switch to Design (Shift+D) to edit"));
|
||||
app.toast(tr("Developer Mode only inspects: switch to Design (Shift+D) to edit"));
|
||||
}
|
||||
refused
|
||||
}
|
||||
@@ -382,40 +382,49 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) {
|
||||
// Main menu.
|
||||
if app.ui.main_menu {
|
||||
let mut close = false;
|
||||
let area = egui::Area::new(main_menu_id()).fixed_pos(pos2(8.0, crate::tabs::HEIGHT + 48.0)).order(egui::Order::Foreground).show(ctx, |ui| {
|
||||
menu_frame(ui, &t, At { root: main_menu_id(), path: String::new() }, |ui| {
|
||||
// Quick actions search row.
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click());
|
||||
plain_row_hover(ui, &resp);
|
||||
a11y::menu_item(&resp, tr("Actions…"), crate::shortcuts::shortcut("app.actions").unwrap_or(""), None);
|
||||
ui.painter().rect_filled(r.shrink(2.0), CornerRadius::same(5), Color32::from_white_alpha(18));
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 16.0, r.center().y), vec2(14.0, 14.0)), Icon::Search, t.menu_dim);
|
||||
ui.painter().text(pos2(r.min.x + 32.0, r.center().y), Align2::LEFT_CENTER, tr("Actions…"), regular(12.0), t.menu_dim);
|
||||
ui.painter().text(
|
||||
pos2(r.max.x - 10.0, r.center().y),
|
||||
Align2::RIGHT_CENTER,
|
||||
pretty_shortcut(crate::shortcuts::shortcut("app.actions").unwrap_or("")),
|
||||
regular(12.0),
|
||||
t.menu_dim,
|
||||
);
|
||||
if resp.clicked() {
|
||||
set_palette(&mut app.ui, true);
|
||||
close = true;
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
for top in TOP_MENUS {
|
||||
if submenu_at(
|
||||
ui,
|
||||
SubmenuOptions { area: Id::unique(("menu", *top)), key: top, label: tr(top), gap: 12.0, constrain: false, tokens: &t },
|
||||
|ui| menu_level(app, ui, top).then_some(()),
|
||||
)
|
||||
.is_some()
|
||||
{
|
||||
let area = egui::Area::new(main_menu_id())
|
||||
.fixed_pos(pos2(8.0, crate::tabs::HEIGHT + 48.0))
|
||||
.order(egui::Order::Foreground)
|
||||
.constrain_to(tree::vertical_bounds(ctx))
|
||||
.show(ctx, |ui| {
|
||||
menu_frame(ui, &t, At { root: main_menu_id(), path: String::new() }, |ui| {
|
||||
// Quick actions search row.
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click());
|
||||
plain_row_hover(ui, &resp);
|
||||
a11y::menu_item(&resp, tr("Actions…"), crate::shortcuts::shortcut("app.actions").unwrap_or(""), None);
|
||||
ui.painter().rect_filled(r.shrink(2.0), CornerRadius::same(5), Color32::from_white_alpha(18));
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(pos2(r.min.x + 16.0, r.center().y), vec2(14.0, 14.0)),
|
||||
Icon::Search,
|
||||
t.menu_dim,
|
||||
);
|
||||
ui.painter().text(pos2(r.min.x + 32.0, r.center().y), Align2::LEFT_CENTER, tr("Actions…"), regular(12.0), t.menu_dim);
|
||||
ui.painter().text(
|
||||
pos2(r.max.x - 10.0, r.center().y),
|
||||
Align2::RIGHT_CENTER,
|
||||
pretty_shortcut(crate::shortcuts::shortcut("app.actions").unwrap_or("")),
|
||||
regular(12.0),
|
||||
t.menu_dim,
|
||||
);
|
||||
if resp.clicked() {
|
||||
set_palette(&mut app.ui, true);
|
||||
close = true;
|
||||
}
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
for top in TOP_MENUS {
|
||||
if submenu_at(
|
||||
ui,
|
||||
SubmenuOptions { area: Id::unique(("menu", *top)), key: top, label: tr(top), gap: 12.0, constrain: false, tokens: &t },
|
||||
|ui| menu_level(app, ui, top).then_some(()),
|
||||
)
|
||||
.is_some()
|
||||
{
|
||||
close = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
let clicked_outside = ctx.input(|i| i.pointer.any_pressed()) && !over_menu(ctx) || ctx.input(|i| i.key_pressed(egui::Key::Escape));
|
||||
if close || (clicked_outside && !area.response.hovered()) {
|
||||
app.ui.main_menu = false;
|
||||
|
||||
@@ -7,8 +7,8 @@ use crate::i18n::trf;
|
||||
/// Paste: the system clipboard wins when it holds something newer than our own copy (layers copied
|
||||
/// from another design app, an image, SVG or text from another app); otherwise our copied layers.
|
||||
/// `event_text` is the text the platform handed over with the paste event (the browser has no
|
||||
/// other way to read it). `at`: the page point to paste at (Paste here); `None` pastes in the
|
||||
/// middle of the view.
|
||||
/// other way to read it; the desktop's clipboard bridge has already read it). `at`: the page
|
||||
/// point to paste at (Paste here); `None` pastes in the middle of the view.
|
||||
/// In Dev Mode only a comment's link pastes (it opens the thread); everything else would edit.
|
||||
pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) {
|
||||
// A gesture injected through the control channel pastes only our own copied layers: it must not
|
||||
@@ -47,7 +47,15 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>)
|
||||
}
|
||||
return;
|
||||
}
|
||||
let sys_text = app.services.clipboard_get_text().or_else(|| event_text.map(str::to_string)).filter(|t| !t.is_empty());
|
||||
// The text the platform handed over with the paste event is the system clipboard's text: the
|
||||
// browser delivers it, and the desktop's async clipboard bridge read it while the window
|
||||
// thread was free to serve the transfer. Never re-read the clipboard synchronously for it —
|
||||
// on the desktop that read blocks the window thread for the read timeout, and when this app
|
||||
// itself owns the clipboard (a self-paste) its own source can only be served by that very
|
||||
// thread, so the read waits the full timeout and the window freezes for seconds (#2021).
|
||||
// A paste the platform didn't hand text over (a chord, an image, the menu) reads it, as before.
|
||||
let sys_text =
|
||||
if let Some(t) = event_text { (!t.is_empty()).then(|| t.to_string()) } else { app.services.clipboard_get_text().filter(|t| !t.is_empty()) };
|
||||
// The text matching `last_copy` means the system clipboard still holds what we mirrored only
|
||||
// while the engine clipboard is the one we mirrored. A control-channel copy changes the engine
|
||||
// clipboard without writing the system clipboard, so its generation no longer matches and the
|
||||
|
||||
@@ -241,6 +241,19 @@ fn draw_row(ui: &mut Ui, t: &Tokens, row: &ActionRow, highlighted: bool) -> egui
|
||||
resp
|
||||
}
|
||||
|
||||
/// The text a search reads besides the label in the UI's language: the English label, the id, and
|
||||
/// a name the feature had before it was renamed ([`figcraft_engine::catalog::SEARCH_ALIASES`]).
|
||||
fn search_text(english: &str, id: &str) -> String {
|
||||
let mut text = format!("{english} {id}");
|
||||
for (alias_id, alias) in figcraft_engine::catalog::SEARCH_ALIASES {
|
||||
if *alias_id == id {
|
||||
text.push(' ');
|
||||
text.push_str(alias);
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// Whether `label` (or `extra`) matches the query, in any case.
|
||||
fn matches(q: &str, label: &str, extra: &str) -> bool {
|
||||
q.is_empty() || label.to_lowercase().contains(q) || extra.to_lowercase().contains(q)
|
||||
@@ -295,7 +308,7 @@ pub(crate) fn command_sections(q: &str) -> Vec<(String, Vec<ActionRow>)> {
|
||||
} else if menu_labels.contains(&same) {
|
||||
continue;
|
||||
}
|
||||
if matches(&ql, &label, &format!("{english} {id}")) {
|
||||
if matches(&ql, &label, &search_text(english, id)) {
|
||||
hits.push((group, command_row(id, label.into_owned())));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,6 +291,7 @@ pub(super) fn submenu_at<R>(ui: &mut Ui, options: SubmenuOptions<'_>, add: impl
|
||||
.fixed_pos(pos)
|
||||
.order(egui::Order::Foreground)
|
||||
.constrain(constrain || cramped)
|
||||
.constrain_to(if constrain || cramped { screen } else { vertical_bounds(ui.ctx()) })
|
||||
.show(ui.ctx(), |ui| menu_frame(ui, t, child, add));
|
||||
let panel = shown.response.rect;
|
||||
with_level(ui.ctx(), &at, |l| l.panel = Some(panel));
|
||||
@@ -318,12 +319,64 @@ pub(super) fn menu_frame<R>(ui: &mut Ui, t: &Tokens, at: At, add: impl FnOnce(&m
|
||||
ui.set_width(w);
|
||||
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
|
||||
let key = at_key(ui);
|
||||
ui.data_mut(|d| d.insert_temp(key, at));
|
||||
add(ui)
|
||||
ui.data_mut(|d| d.insert_temp(key, at.clone()));
|
||||
// Taller than the window (as measured last frame): its rows scroll. Otherwise they
|
||||
// draw straight into the panel, so a menu that fits behaves as it always did.
|
||||
let max_h = (ui.ctx().content_rect().height() - 2.0 * EDGE_MARGIN - 16.0).max(MENU_ROW_H);
|
||||
let height_key = at_key(ui).with("content_h");
|
||||
let last_h = ui.data(|d| d.get_temp::<f32>(height_key)).unwrap_or(0.0);
|
||||
if last_h <= max_h {
|
||||
let top = ui.cursor().min.y;
|
||||
let r = add(ui);
|
||||
let h = ui.min_rect().max.y - top;
|
||||
ui.data_mut(|d| d.insert_temp(height_key, h));
|
||||
if h > max_h {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
return r;
|
||||
}
|
||||
// The scroll area's ui has its own scope, so it gets the level and width state of this
|
||||
// one, and hands back the width its rows asked for.
|
||||
let outer_w = width_key(ui);
|
||||
let asked = ui.data(|d| d.get_temp::<f32>(outer_w));
|
||||
let shown = egui::ScrollArea::vertical()
|
||||
.max_height(max_h)
|
||||
.auto_shrink([false, true])
|
||||
.scroll_source(egui::scroll_area::ScrollSource::MOUSE_WHEEL | egui::scroll_area::ScrollSource::SCROLL_BAR)
|
||||
.show(ui, |ui| {
|
||||
let (inner_at, inner_w) = (at_key(ui), width_key(ui));
|
||||
ui.data_mut(|d| {
|
||||
d.insert_temp(inner_at, at);
|
||||
if let Some(a) = asked {
|
||||
d.insert_temp(inner_w, a);
|
||||
}
|
||||
});
|
||||
let r = add(ui);
|
||||
(r, ui.data(|d| d.get_temp::<f32>(inner_w)))
|
||||
});
|
||||
ui.data_mut(|d| d.insert_temp(height_key, shown.content_size.y));
|
||||
let (r, wanted) = shown.inner;
|
||||
if let Some(wanted) = wanted.filter(|w| asked.is_none_or(|a| *w > a)) {
|
||||
ui.data_mut(|d| d.insert_temp(outer_w, wanted));
|
||||
}
|
||||
r
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
||||
/// The gap a menu keeps from the window's top and bottom edges.
|
||||
pub(super) const EDGE_MARGIN: f32 = 8.0;
|
||||
|
||||
/// A menu row's height.
|
||||
const MENU_ROW_H: f32 = 28.0;
|
||||
|
||||
/// Where a menu may sit: anywhere across, within the window's height less the margins. A menu that
|
||||
/// would run past the bottom moves up (never above the top); one taller than that scrolls.
|
||||
pub(super) fn vertical_bounds(ctx: &egui::Context) -> Rect {
|
||||
let s = ctx.content_rect();
|
||||
Rect::from_min_max(pos2(-1.0e5, s.min.y + EDGE_MARGIN), pos2(1.0e5, s.max.y - EDGE_MARGIN))
|
||||
}
|
||||
|
||||
/// A menu's narrowest width.
|
||||
const MENU_W: f32 = 240.0;
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//! The handoff mode is named "Developer Mode" in every label a person reads. Figma holds a US
|
||||
//! registration for DEV MODE (US 7574348) and sent a cease-and-desist over it in 2025, so neither
|
||||
//! our labels nor a translation may call it that again.
|
||||
//!
|
||||
//! Identifiers keep their names (`view.devMode`, `devMode.*`, `dev_mode`, the module and file
|
||||
//! names), and the Actions palette keeps the old name as a search alias so people who know it still
|
||||
//! find the command (`palette_tests::the_old_name_still_finds_the_mode`).
|
||||
//!
|
||||
//! The labels are read where they are published: the engine's command labels and parameter docs,
|
||||
//! the menu catalog, and the baked-in catalogs. A `tr("Dev Mode")` added to the code would need an
|
||||
//! English entry, which this rejects.
|
||||
|
||||
use figcraft_engine::catalog::MENU;
|
||||
|
||||
use crate::i18n::{LANGS, Text};
|
||||
|
||||
/// The retired name, as a label would read it.
|
||||
const RETIRED: &str = "Dev Mode";
|
||||
|
||||
fn is_word(c: char) -> bool {
|
||||
c.is_alphanumeric() || c == '_'
|
||||
}
|
||||
|
||||
/// `text` reads the retired name: whole words, so "Developer Mode" and "devMode" don't.
|
||||
fn retired(text: &str) -> bool {
|
||||
text.match_indices(RETIRED)
|
||||
.any(|(at, _)| !text[..at].chars().next_back().is_some_and(is_word) && !text[at + RETIRED.len()..].chars().next().is_some_and(is_word))
|
||||
}
|
||||
|
||||
/// The strings one catalog entry shows.
|
||||
fn entry_texts(text: &Text) -> Vec<&str> {
|
||||
match text {
|
||||
Text::Plain(value) => vec![*value],
|
||||
Text::Plural(forms) => [forms.one, forms.few, forms.many].into_iter().flatten().chain([forms.other]).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every label the app publishes, with where it comes from.
|
||||
fn labels() -> Vec<(String, &'static str)> {
|
||||
let mut out: Vec<(String, &'static str)> =
|
||||
figcraft_engine::all_commands().iter().flat_map(|c| [(format!("{} label", c.id), c.label), (format!("{} params", c.id), c.params)]).collect();
|
||||
out.extend(MENU.iter().map(|m| (format!("menu {}", m.id), m.label)));
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_labels_and_parameter_docs_read_developer_mode() {
|
||||
let bad: Vec<String> = labels().into_iter().filter(|(_, text)| retired(text)).map(|(what, text)| format!("{what}: {text:?}")).collect();
|
||||
assert!(bad.is_empty(), "a label still names the retired mode:\n{}", bad.join("\n"));
|
||||
let id = figcraft_engine::find_command("view.devMode").expect("the mode's command exists");
|
||||
assert_eq!(id.label, "Developer Mode", "the mode's command label");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_catalogs_name_developer_mode() {
|
||||
let mut bad = Vec::new();
|
||||
for lang in LANGS {
|
||||
for (key, text) in lang.ui {
|
||||
if retired(key) {
|
||||
bad.push(format!("{}: key {key:?}", lang.code));
|
||||
}
|
||||
for value in entry_texts(text).into_iter().filter(|value| retired(value)) {
|
||||
bad.push(format!("{}: {key:?} = {value:?}", lang.code));
|
||||
}
|
||||
}
|
||||
for (id, text) in lang.commands {
|
||||
for value in entry_texts(text).into_iter().filter(|value| retired(value)) {
|
||||
bad.push(format!("{}: {id} = {value:?}", lang.code));
|
||||
}
|
||||
}
|
||||
for (id, text) in lang.menu {
|
||||
for value in entry_texts(text).into_iter().filter(|value| retired(value)) {
|
||||
bad.push(format!("{}: {id} = {value:?}", lang.code));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(bad.is_empty(), "a catalog still names the retired mode:\n{}", bad.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_inspect_tab_and_its_refusal_read_developer_mode() {
|
||||
assert_eq!(crate::app::right_tab_label(crate::RightTab::Inspect), "Developer Mode", "the panel's name");
|
||||
// The engine's refusal on a board, and the toast the UI shows where the mode refuses an edit.
|
||||
let board = figcraft_engine::board::NOT_ON_BOARDS;
|
||||
assert!(!retired(board), "{board:?}");
|
||||
assert_eq!(crate::i18n::LANGS.first().map(|l| l.code), Some("en"), "English is baked in first");
|
||||
assert!(
|
||||
LANGS.first().is_some_and(|l| l.ui.iter().any(|(k, _)| *k == "Developer Mode only inspects: switch to Design (Shift+D) to edit")),
|
||||
"the refusal toast is in the catalog"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_guard_reads_words_not_identifiers() {
|
||||
for label in ["Dev Mode", "Dev Mode on/off", "the Dev Mode panel"] {
|
||||
assert!(retired(label), "{label}");
|
||||
}
|
||||
for label in ["Developer Mode", "Developer Mode on/off", "view.devMode", "devMode.addMeasurement", "Development", "Ready for dev"] {
|
||||
assert!(!retired(label), "{label}");
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,21 @@ fn query_filters_to_matches() {
|
||||
assert!(crate::menus::command_sections("zzz-no-such-action").iter().all(|(_, r)| r.is_empty()));
|
||||
}
|
||||
|
||||
/// The handoff mode is "Developer Mode", and the name it had before is still a search term: a
|
||||
/// person who knows "Dev Mode" finds the command, and its row reads the new name.
|
||||
#[test]
|
||||
fn the_old_name_still_finds_the_mode() {
|
||||
let rows: Vec<(String, String)> =
|
||||
crate::menus::command_sections("dev mode").iter().flat_map(|(_, rows)| rows.iter().map(|r| (r.key.clone(), r.label.clone()))).collect();
|
||||
assert!(rows.iter().any(|(key, _)| key == "command:view.devMode"), "'dev mode' finds the mode: {rows:?}");
|
||||
assert!(
|
||||
rows.iter().filter(|(key, _)| key == "command:view.devMode").all(|(_, label)| label == "Developer Mode"),
|
||||
"and reads the new name: {rows:?}"
|
||||
);
|
||||
// Without the alias the words don't match the label or the id.
|
||||
assert!(!rows.iter().any(|(key, label)| key != "command:view.devMode" && label.contains("Developer Mode")), "only that row: {rows:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gone_entries_drop_out_of_recents() {
|
||||
let u = Ui::new(Session::new());
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
//! whatever the system clipboard has. A copy that never reached the clipboard pasted the text that
|
||||
//! was there before as a text layer instead of the copied layers.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use egui::{Event, Key, Modifiers, OutputCommand, Pos2, RawInput, Rect, vec2};
|
||||
use figcraft_doc::NodeKind;
|
||||
use figcraft_engine::Session;
|
||||
@@ -223,3 +226,49 @@ fn a_mismatched_image_buffer_is_dropped() {
|
||||
assert!(out.platform_output.commands.is_empty(), "{w}x{h}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The system clipboard's text read, counted so a test can assert when it ran. The desktop's
|
||||
/// arboard read blocks the window thread up to `CLIPBOARD_TIMEOUT`; against the app's own Wayland
|
||||
/// source (a self-paste) it waits the full timeout — the whole UI freezes for seconds (#2021) —
|
||||
/// and its result is discarded in favour of the paste event's text. A paste whose platform event
|
||||
/// already carries the clipboard text must never perform that synchronous re-read.
|
||||
struct CountingReads {
|
||||
/// How often the synchronous text read was asked for.
|
||||
reads: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl Services for CountingReads {
|
||||
fn clipboard_get_text(&mut self) -> Option<String> {
|
||||
self.reads.fetch_add(1, Ordering::Relaxed);
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paste_event_its_own_text_never_triggers_the_synchronous_text_read() {
|
||||
// The platform (the desktop's async clipboard bridge, or the browser) already read the
|
||||
// clipboard and handed the text with the paste event.
|
||||
let reads = Arc::new(AtomicUsize::new(0));
|
||||
let mut s = Session::new();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
|
||||
let mut p = Page {
|
||||
ctx: egui::Context::default(),
|
||||
app: FigApp::new(s, Box::new(CountingReads { reads: reads.clone() })),
|
||||
time: 0.0,
|
||||
clipboard: String::new(),
|
||||
};
|
||||
for _ in 0..3 {
|
||||
p.frame(vec![]);
|
||||
}
|
||||
|
||||
// The desktop delivers the copied layers' SVG as the paste event's text.
|
||||
let text = "<svg width=\"100\" height=\"50\"><rect/></svg>";
|
||||
p.frame(vec![Page::key(Key::V, true), Event::Paste(text.to_string())]);
|
||||
p.frame(vec![Page::key(Key::V, false)]);
|
||||
assert_eq!(reads.load(Ordering::Relaxed), 0, "the text came with the paste event: no blocking re-read of the system clipboard");
|
||||
|
||||
// Even a second paste must not re-read: the clipboard still holds the pasted text.
|
||||
p.frame(vec![Page::key(Key::V, true), Event::Paste(text.to_string())]);
|
||||
p.frame(vec![Page::key(Key::V, false)]);
|
||||
assert_eq!(reads.load(Ordering::Relaxed), 0, "still the event's text: no blocking re-read");
|
||||
}
|
||||
@@ -27,7 +27,7 @@ replaced.
|
||||
| L4 | `figcraft-svg` | ~3.7k | SVG export and import (usvg) |
|
||||
| L4 | `figcraft-pdf` | ~2.3k | PDF export (krilla) with print options |
|
||||
| L4 | `figcraft-vectordrawable` | ~1.2k | Android Vector Drawable (XML) export: baked path data, gradients through `aapt:attr`, clip groups, compatibility modes and warnings |
|
||||
| L5 | `figcraft-engine` | ~39.3k | Sessions, undo history (`Arc` snapshots), the command registry (`cmd/*`, `spec!`), pointer tools and vector edit mode, snapping and measuring, components sync, libraries, prototype runtime, Dev Mode code generation, the menu catalog (`catalog.rs`) and the keymap (`keymap.rs`), a panic guard |
|
||||
| L5 | `figcraft-engine` | ~39.3k | Sessions, undo history (`Arc` snapshots), the command registry (`cmd/*`, `spec!`), pointer tools and vector edit mode, snapping and measuring, components sync, libraries, prototype runtime, Developer Mode code generation, the menu catalog (`catalog.rs`) and the keymap (`keymap.rs`), a panic guard |
|
||||
| L6 | `figcraft-ui-egui` | ~31.9k | The UI3-style frontend: canvas (render worker), toolbar, layers, Design/Prototype/Inspect panels, colour picker, menus, dialogs, presenter, comments, accessibility (AccessKit), the control channel server |
|
||||
| L6 | `figcraft-mcp` | ~2.4k | MCP server (JSON-RPC over stdio), headless or bridged to a running app |
|
||||
| L6 | `figcraft-collab-net` | ~3.0k | Live sessions over the network: the WebSocket transport (tungstenite, a reader and a writer thread per connection), the hub that runs a collaboration core server per open file for the session host the app embeds and for `figcraft-server` (behind a `Backend`), the joining and managing clients (publishing), invite links, presence relay and limits; the browser client; the only crate allowed networking dependencies |
|
||||
|
||||
@@ -29,7 +29,7 @@ all end with the same geometry.
|
||||
| Board files: File ▸ New board, the home screen's New board card, a light page | done | `file.newBoard`; the kind shows in `inspect_document`, tabs and file rows (a note mark; design files a pen nib) |
|
||||
| Board toolbar at the bottom | done | Move/Hand, Marker/Highlighter/Eraser, Sticky (colour flyout), Shapes (flyout), Connector, Text, Section, Stamp (flyout), Comment, Actions, then the templates and the Design panel's buttons — on the same 32 pt grid as a design file's (no Resources button, as there) |
|
||||
| Design panel hidden by default; a contextual bar above the selection | done | colour, shape, text size (S/M/L/XL); for connectors straight/elbow/curved, the start and end points (none, round, square, line arrow, triangle arrow, reversed triangle, circle, diamond), solid or dashed, the weight, and any colour in the colour picker beside the board colours (#1596); each control says what it is on hover; the Design panel opens with its button or View ▸ Design panel |
|
||||
| Prototyping and Dev Mode refuse on boards | done | `proto.*`, `dev.*`, `view.prototype`, `view.devMode` say why |
|
||||
| Prototyping and Developer Mode refuse on boards | done | `proto.*`, `dev.*`, `view.prototype`, `view.devMode` say why |
|
||||
| Sticky notes | done | a square frame (200 wide, growing taller through auto layout), 8 soft colours, a soft shadow, the note's text size shrinking as it grows (24 → 11, by characters and lines), the author's name at the bottom; a click places one and starts typing; Enter or a double-click edits; Shift+Cmd/Ctrl+Enter adds the next one beside it |
|
||||
| Sticky options: hide the author, change size presets, sticky stacks | partial | `board.sticky {author: false}`; no size presets or stacks |
|
||||
| Shapes with text | done | rectangle, rounded rectangle, ellipse, diamond, triangle, speech bubble, chevron; text centred and wrapping inside the outline; shape type and text size from the contextual bar |
|
||||
|
||||
+2
-2
@@ -126,7 +126,7 @@ page 1 "Screens": 380 nodes, 4 top-level
|
||||
written from public format notes, including variables with their modes and bindings and shared
|
||||
styles with their layer links. Checked by opening it again in FigCraft and, on the example
|
||||
documents and small test files, in other software; other apps may not open every file. What was
|
||||
left out (comments, Dev Mode data, some bindings and settings…) is printed as `note:` lines;
|
||||
left out (comments, Developer Mode data, some bindings and settings…) is printed as `note:` lines;
|
||||
`docs/file-format-parity.md` lists what was checked and what is unchecked.
|
||||
- `--format xml` (or an `<out>` ending in `.xml`) writes an Android Vector Drawable, e.g.
|
||||
`figcraft-cli export app.figcraft --node Icon -o ic_icon.xml`. What the format can't draw is
|
||||
@@ -299,7 +299,7 @@ lists); it is never printed. The bundle is checked before it is sent and again b
|
||||
### `connect publish <file.figcraft> [--mappings FILE] [--dry-run]`
|
||||
|
||||
Publishes a code repository's component mappings to a design file: each link in the mappings file
|
||||
(default `figcraft.connect.json` in the current folder, the file Dev Mode's **Export code
|
||||
(default `figcraft.connect.json` in the current folder, the file Developer Mode's **Export code
|
||||
connections** and `codeConnect.export` write) connects every component or component set of that
|
||||
name to its code component for a language, replacing its link for that language, and the file is
|
||||
saved in place, written whole or not at all, with its version history. Links that don't fit (no
|
||||
|
||||
@@ -17,8 +17,8 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|
||||
| 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, comment and view links with short codes; local network: the app hosts and the code carries its address, with an edit code and a view-only code, picked before or while sharing, whose holders see every change and can't make one; one field joins with a code or a link, in the popover and on the home screen, and the Join dialog then shows the join until its file is ready (Cancel), or why it failed (Try again, #1381); 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), and shares online and publishes to a server as the desktop app does, though not on the local network (#917; the owner token of a file it puts on an open server lasts as long as the page)); `figcraft-server` share links per file with edit, comment or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; a commenter also adds, replies to, reacts to and resolves comment threads, with a "Can comment" badge and the comment tool, #1525; the server refuses their other 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; a server only a team reaches can also list the files shared from it now for everyone (`team_files`): Home ▸ Online shows that team list for the server in Preferences, a row joins its file, and its links (`figcraft://join?server=…&file=…&team=1`, or the browser build's) carry no secret and stop working with the share. 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 (an AI agent attached over MCP comments as its own name, marked Agent); 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. `comment.list` (CLI, MCP) gives each message its `mentions` (`{name, known, you}`, by the highlighting rule). 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, also in live sessions**: 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; entries for its creation and updates from main); fills, strokes, effects, layout grids, export settings, guides, interactions, annotations and Dev Mode resources merge item by item. Auto layout settings (a frame's and a child's) merge field by field, each field changed both ways a conflict of its own (`<id>/layout.padding`), and sizes and places auto layout works out (hugging, filling, a child's place) or that an instance takes from its main are no conflict: the merged file lays them out again. In a live session a branch stays with its author (its ids in their own client space, far above the main file's) and merging it is one edit everyone gets; peers' edits merge against the branch's own base and conflicts resolve as offline; a view link refuses merging. Left: others in the session can't see or open your branches (the server would have to keep them); text runs conflict as a whole; a branch made offline that adds layers merges only outside a session | 1–2 |
|
||||
| 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 (Developer Mode compares a ready-for-dev layer with its state when marked) | — |
|
||||
| Branching and merging | yes (paid plans) | **done, also in live sessions**: 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; entries for its creation and updates from main); fills, strokes, effects, layout grids, export settings, guides, interactions, annotations and Developer Mode resources merge item by item. Auto layout settings (a frame's and a child's) merge field by field, each field changed both ways a conflict of its own (`<id>/layout.padding`), and sizes and places auto layout works out (hugging, filling, a child's place) or that an instance takes from its main are no conflict: the merged file lays them out again. In a live session a branch stays with its author (its ids in their own client space, far above the main file's) and merging it is one edit everyone gets; peers' edits merge against the branch's own base and conflicts resolve as offline; a view link refuses merging. Left: others in the session can't see or open your branches (the server would have to keep them); text runs conflict as a whole; a branch made offline that adds layers merges only outside a session | 1–2 |
|
||||
| Team / organization libraries | yes | **partial**: a file's library is published to `figcraft-server` under the file and enabled in other files by a share link (per-asset versions, background update checks, overrides kept), usage counts per component and file, swapping a library by names ([components-parity](components-parity.md), [server.md](server.md#team-libraries)). No team-wide library list, accounts or analytics over time | 4–8 |
|
||||
| Notifications, activity | yes | no | with sharing |
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|
||||
| 2026-11-02 | minor | M3, #1885: glass effects take variables, as the reference tool's do: its light angle, intensity, refraction, depth, dispersion, frost and splay bind to number variables on a layer (`variable.bind`, `effects/<i>/<lightAngle\|lightIntensity\|refraction\|depth\|dispersion\|radius\|splay>`, `radius` being the frost) and in an effect style (`style.bind`), and the Design panel's glass fields take a variable like the shadow and blur fields. A variable reads as the field shows it: intensity, refraction, dispersion and splay in percent. Tests: `binding_lists_tests::glass_fields_take_number_variables`, `effects_tests::glass_fields_take_bound_numbers_as_the_panel_shows_them`. Grade unchanged. |
|
||||
| 2026-11-01 | patch | M3: Delete on a layer inside an instance hides it (an override) instead of being refused ("You can't delete a layer or object from an instance. If you try, Figma will only toggle the layer's visibility instead of removing it", Guide to auto layout); `edit.delete` reports those ids under `hidden`, the structure and the main component stay as they were, one undo step; deleting the instance itself still removes it. Found by a documentation audit of the auto layout page. Test: `components_behaviour_tests::deleting_a_layer_inside_an_instance_hides_it_instead` (the refusal row for it is gone from `what_instances_cannot_override_is_refused`). Numbers unchanged |
|
||||
| 2026-10-31 | minor | M3, gap 10: library analytics cover styles and variables. A file reports how many of its layers use each of a library's styles and variables, and Show usage (`library.usage`) lists them beside the components, by a Type choice, with their layers and files. Layers inside instances count for their component. Insertions, detachments and modes aren't counted for them yet. Numbers unchanged. Tests: `library_assets_tests`. |
|
||||
| 2026-10-31 | minor | M6: as the help center describes code syntax, Dev Mode's SwiftUI and Compose code now writes a bound variable's iOS or Android code syntax in place of its value, as CSS already wrote the Web one: fills, strokes and their weight, sizes, corner radius, gaps, padding, opacity, the font family and size and the characters. A variable with no code syntax for the platform keeps the value, since a name made up here wouldn't exist in the code it lands in. ArkUI and Flutter have no code syntax platform and are unchanged. Test: `variables_behaviour_tests::swiftui_and_compose_name_variables_by_their_code_syntax`. Numbers unchanged. Grade unchanged |
|
||||
| 2026-10-31 | minor | M6: as the help center describes code syntax, Developer Mode's SwiftUI and Compose code now writes a bound variable's iOS or Android code syntax in place of its value, as CSS already wrote the Web one: fills, strokes and their weight, sizes, corner radius, gaps, padding, opacity, the font family and size and the characters. A variable with no code syntax for the platform keeps the value, since a name made up here wouldn't exist in the code it lands in. ArkUI and Flutter have no code syntax platform and are unchanged. Test: `variables_behaviour_tests::swiftui_and_compose_name_variables_by_their_code_syntax`. Numbers unchanged. Grade unchanged |
|
||||
| 2026-10-31 | minor | M3, gap 10: library analytics over time. A file counts the instances of library components it inserts (placed, created, pasted or duplicated, Alt-dragged copies too; not those nested in another instance) and reports them like its detaches (`ReportInserted`). The server counts what a file's insertion and detach totals grow past their highest in the current week, a Monday at 00:00 UTC, so an undo and a redo count once, and keeps a year of weeks in `actions.json` (`Actions`). `library.usage {days?}` adds `insertions`, `detachments` and `weeks`, and per component `insertions` and `detachments`; Show usage has a 30 days, 90 days or 1 year choice, the period's totals, and each component's counts on a line of their own. The new variants are appended, so older messages keep their bytes; an older server refuses them and Show usage says it doesn't count insertions. A file's first report after this counts its total in that week. Tests: `library_actions_tests`, `inserting_library_instances_counts_them`. Grade unchanged |
|
||||
| 2026-10-30 | minor | M3, gap 14: a collection of this file can be extended, as the help center shows (right-click a collection, Extend collection); before, only a library's could. `variable.extendCollection {collection, name?}` makes "<name> (extended)" with its modes and one inheriting variable per variable, and the Variables editor's collection menu offers it beside Rename and Delete collection. Edits of the collection it extends reach it at once (values in modes it doesn't override, new variables and modes, renames, mode order), so normalising keeps extensions in step whenever variables change, not only on library updates; undo takes both back. An extended collection extends again (parents settle first; an extension doesn't take its parent's overrides as its own), and a loop of extensions from a hostile file comes apart into plain collections keeping their values. Deleting the parent leaves a plain collection with the values it showed, as for libraries. Tests: `extended_chain_tests` (engine), `variables_rename_tests::a_collection_of_this_file_extends_from_its_context_menu` (headless UI). Grade unchanged |
|
||||
| 2026-10-30 | minor | Library analytics count detaches: a file that detaches an instance of a library component counts it (kept with the file) and its app reports the counts to the library's server beside its usage; **Show usage** and `library.usage` list them per component and per file (`detached`, `detachesKnown`). Older servers and apps are unaffected: the reports are new messages and the old ones keep their encoding. Still nothing over time. Numbers unchanged |
|
||||
|
||||
@@ -194,9 +194,9 @@ layout (including grid `columnTracks` / `rowTracks`), sizing, `minWidth` / `maxW
|
||||
children), `gridCell` (a grid auto layout child's column, row and spans), clip, text properties,
|
||||
component/instance links, overrides, `slot` (a slot layer's property name), boundVariables, interactions, exports,
|
||||
`devStatus` (handoff status), `devChanged` (changed since it was marked ready for dev),
|
||||
`annotations` (Dev Mode notes: `[{label, properties, category?}]`), `codeLinks` (a component's
|
||||
`annotations` (Developer Mode notes: `[{label, properties, category?}]`), `codeLinks` (a component's
|
||||
links to code: `[{language, importPath, name, template}]`), `devResources` (links for developers,
|
||||
its own and inherited: `[{name, url, index?, from?}]`), `measurements` (Dev Mode measurements
|
||||
its own and inherited: `[{name, url, index?, from?}]`), `measurements` (Developer Mode measurements
|
||||
starting from the layer: `[{to} | {side}]`), `pluginData` (layer data written with
|
||||
`node.set {props: {pluginData: {key: value}}}`, pages included: `{key: value}`, values as strings;
|
||||
only when there is some). The auto layout props
|
||||
@@ -379,7 +379,7 @@ closes it), `commentDraft` (`{x, y}`: start a new comment there), `commentFilter
|
||||
(`date|page`), `commentSearch` (the panel's search text), `commentReply` (the open thread's reply
|
||||
text, focused with the cursor at its end: an `@` there shows the mention picker), `commentEdit` (a
|
||||
message index of the open thread to edit, 0 for the first; `null` stops), `presentComments` (bool:
|
||||
the presenter shows the presented frame's comments, also Shift+C there). Dev Mode: `devCompare` (a
|
||||
the presenter shows the presented frame's comments, also Shift+C there). Developer Mode: `devCompare` (a
|
||||
frame or section id opens the Compare changes view, like `dev.compare {id, show:
|
||||
true}`; `null` closes it) with `devCompareVersion` (a version id to compare with; `null`: when it
|
||||
was marked ready for dev), `devCompareMode` (`sideBySide|overlay`) and `devCompareSplit` (the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Dev Mode parity
|
||||
|
||||
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-26 · **Change:** minor (class names transliterate non-ASCII layer names (#1235); measurements kept on the canvas; a read-only layers panel in Dev Mode; earlier: import lines in generated layer trees; compare with any saved version; Dev Mode behaviour suite: an inspect-only canvas, Changed in the ready list, statuses only on frames and sections; plugin panels from the Inspect tab) · **Target:** Figma Design Dev Mode (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-07 · **Change:** patch (the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; class names transliterate non-ASCII layer names (#1235); measurements kept on the canvas; a read-only layers panel in Dev Mode; earlier: import lines in generated layer trees; compare with any saved version; Dev Mode behaviour suite: an inspect-only canvas, Changed in the ready list, statuses only on frames and sections; plugin panels from the Inspect tab) · **Target:** Figma Design Dev Mode (desktop app 126.10.7)
|
||||
|
||||
**Breadth ~98%, ready ~78% (estimated).** The inspect surface is broad, components connect
|
||||
to the code components of a repository (with their imports, also inside generated layer trees),
|
||||
@@ -10,6 +10,7 @@ repository, third-party integrations. Remaining: 1–2 h.
|
||||
|
||||
| Feature | Status | Notes |
|
||||
|---|---|---|
|
||||
| 2026-11-07 | patch | Naming: the handoff mode is called Developer Mode in the interface, its shortcut row, its toasts, the control channel's refusal, the board refusal, the measurement parameter doc, the `.fig` export note, the CLI help and every catalog (24 languages, translations awaiting native review), since Figma holds a US registration for Dev Mode (7574348); command ids (`view.devMode`, `devMode.*`), preference keys and saved-document fields are unchanged, and the Actions palette still finds the command by the old name |
|
||||
| Dev Mode toggle (Shift+D), Inspect panel | done | the canvas only inspects: presses select, pan and comment but never move, resize, draw or edit text and vectors; menus and shortcuts refuse design edits with a note (Dev Mode's annotations, statuses, dev resources, measurements, code connections and comments still work); a drawing tool chosen in the toolbar goes back to the design. The layers panel only selects and expands (no renaming, reordering, hiding or locking; a double click says why), and the Pages list only opens pages (no adding, renaming, reordering, duplicating or deleting). Files dropped on the canvas aren't imported (a dropped document still opens as a file of its own), and the Assets panel places, swaps and applies nothing, doesn't change Hide when publishing, and doesn't accept library updates or add, remove or swap libraries; each says why |
|
||||
| Properties with their styles and variables; redlines on hover | done | positions from the frame around the layer (groups aren't frames), rotation, radius (CSS corner order), opacity, blend; colours in hex, RGB, CSS, HSL or HSB (`inspect.properties {colorFormat}`, the Inspect tab's Color format) |
|
||||
| Code: CSS, iOS (SwiftUI), Android (Compose), Flutter | done | plus HTML, React, Vue and Leptos (ours; Leptos: `#[component]` functions with `#[prop]` defaults, variant enums and `view!` markup; Flutter is ours too: a `StatelessWidget` for a component, a widget tree for a layer); components take properties as parameters, instances are calls; CSS in px or rem (`inspect.code {unit, rootFontSize}`), with rotation, blend modes, gradient directions and kinds (linear, radial, conic), text alignment, case and decoration; class names from layer names transliterate non-ASCII scripts into readable ASCII (로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber`; symbols-only names take the node's kind, `.text`), and the 40-character cut lands on a word boundary (#1235) |
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Feature naming
|
||||
|
||||
FigCraft describes itself as an **interface design tool**, without the comparative Figma-class tag. Interface-size command help describes the size steps directly. Command IDs, file-format interoperability references, behavioral documentation and attribution remain unchanged.
|
||||
|
||||
Regression tests prevent the retired comparative display phrases from returning to MCP instructions or command help. This copy change makes no legal claim about the comparative terms and does not change behavior.
|
||||
|
||||
## Developer Mode
|
||||
|
||||
**Old label:** "Dev Mode". **New label:** "Developer Mode", since #2135.
|
||||
|
||||
Figma holds a US registration for DEV MODE ([US 7574348](https://tsdr.uspto.gov/statusview/sn98045640), registered 2024-11-19, owner Figma, Inc.), so neither a label nor a translation calls the mode by the old name. The identifiers keep their names (`view.devMode`, `devMode.*`), and so do the preference keys and saved document fields; the Actions palette still finds the command by the old name. Some translations had a generic "dev mode" where the proper name belongs (French "Mode Dev", Portuguese "Modo Dev", Dutch "Dev Mode"); they now read "Mode développeur", "Modo do desenvolvedor" and "Ontwikkelaarsmodus", so every language names the mode the same way. `crates/ui-egui/src/naming_tests.rs` keeps the retired name out of the labels and the catalogs.
|
||||
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (a color profile per PNG, JPG and WebP export setting (Same as file, sRGB or Display P3), the pixels converted; a `.jam` connector end with an `AUTO` or `CENTER` magnet leaves the side facing the other end, not the side its stale saved point named (#1909); a FigJam elbow connector saved with its path (`connectorControlPoints`) keeps it on import, as a board connector reshaped by hand does, instead of being routed again (#1910); JPG exports take a quality, 1 to 100 with 90 the default, and embed the file's colour profile when it is sRGB too (#2004); a FigJam connector without a saved snapshot took no stroke and was invisible; its line's paint, weight, cap and dashes now come from the file's generated line record (#1807); a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.fig` export writes colour calculations as the colour they show in each mode and leaves out, with a warning, one that resolves to nothing (#1280); `.fig` import budgets four decoded values per compressed byte instead of one, still within 20M–128M, so dense files within that budget open instead of being refused as having too many values (#574); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; 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; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the impLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (`.fig` import scales per-point vector corner radii with the row that supplies their network and keeps different instance scales separate in the network cache (#2097); `.fig` export preserves scaled-instance lengths reset to their unscaled main values, including nested linked-style overrides (#2097); `.fig` import: the layers of an instance scaled with the Scale tool draw at its scale factor, not at their main's lengths (#2091); a color profile per PNG, JPG and WebP export setting (Same as file, sRGB or Display P3), the pixels converted; a `.jam` connector end with an `AUTO` or `CENTER` magnet leaves the side facing the other end, not the side its stale saved point named (#1909); a FigJam elbow connector saved with its path (`connectorControlPoints`) keeps it on import, as a board connector reshaped by hand does, instead of being routed again (#1910); JPG exports take a quality, 1 to 100 with 90 the default, and embed the file's colour profile when it is sRGB too (#2004); a FigJam connector without a saved snapshot took no stroke and was invisible; its line's paint, weight, cap and dashes now come from the file's generated line record (#1807); a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.fig` export writes colour calculations as the colour they show in each mode and leaves out, with a warning, one that resolves to nothing (#1280); `.fig` import budgets four decoded values per compressed byte instead of one, still within 20M–128M, so dense files within that budget open instead of being refused as having too many values (#574); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; 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: editaLine truncated
|
||||
|
||||
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,13 +17,13 @@ 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 with region-specific fill paints (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 and shapes with text keep their generated backgrounds, styled text, text paints, saved child bounds and sticky author lines as editable frame children, including saved vector networks without geometry paths, outer fill/stroke outlines when generated geometry is damaged, and outer body text when its generated child is unusable even if an author survives (#1341, #1397); 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; since 2026-10-29, style links that name a library style by asset key and version (the file's own copy of it), with override rows above the linking one still winning, and override rows restating a nested level's overrides applied at that level, below the instance's own (#1234), and the line breaks the file saved with a text, followed until an edit or a resize could move one, or where a line would come out more than 3% wider than its box in the fonts at hand (#1236). Budgets come from the file's own size (four decoded values per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows the file's size too), so a 15.4 MB synthetic file with 300,000 layers opens whole; ZIP image reads have a separate total budget of 4 GiB (1 GiB on 32-bit targets) of decompressed ZIP-member bytes containing encoded images, with the 256 MiB per-image cap kept: a member that cannot fit is skipped before its output buffer is allocated, and the import report names the budget and counts skipped images (#1076). Failed reads and invalid image contents still consume the bytes read; a reference used by several layers is read once. This is not a limit on decoded pixels or total import memory; images carried inside the message are unchanged. The file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Elbowed and curved connectors without a saved path are routed as FigCraft's board connectors are (right angles, or one curve, from the sides of the layers they join); an elbowed one saved with its path (`connectorControlPoints`: a point and an axis per run between the first and the last) keeps that path when it leaves and arrives by the ends' sides (#1910), and a curved one's saved `connectorBezierControlPoints` are not read; a connector routed here takes its line's paint, weight, cap and dashes from the file's generated line record, as one with a saved snapshot does (#1807). A connector's label without a saved snapshot is kept as text centred on its line, without its background. Not read: table cells beyond their own layers, live style and variable links for vector region 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), `crates/engine/src/shapebuilder_region_paints_tests.rs`, and by reading back what the export writes; the FigJam report sample from #1341 checked locally only; no representative real-world corpus yet | read tail 5–10, write: checLine truncated
|
||||
| `.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 with region-specific fill paints (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; an instance scaled with the Scale tool, which stores `symbolData.uniformScaleFactor`, draws its layers at the main's lengths times that factor, composed through nested instances: sizes and places, corner radii, stroke weights and dashes, effect radii, spreads and offsets, auto layout gaps and paddings, grid gaps, min and max sizes, layout grids, font sizes, pixel line heights and letter spacings, paragraph spacing and indent, except where an override or derived row gives the value, which is already at the instance's scale; per-point vector corner radii also take their vector-data row's factor; the vector outline paths a layer carries (a network's points follow the layer's size), fixed grid tracks, image tile scales and glass effects are not scaled by the factor, #2091), 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 and shapes with text keep their generated backgrounds, styled text, text paints, saved child bounds and sticky author lines as editable frame children, including saved vector networks without geometry paths, outer fill/stroke outlines when generated geometry is damaged, and outer body text when its generated child is unusable even if an author survives (#1341, #1397); 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; since 2026-10-29, style links that name a library style by asset key and version (the file's own copy of it), with override rows above the linking one still winning, and override rows restating a nested level's overrides applied at that level, below the instance's own (#1234), and the line breaks the file saved with a text, followed until an edit or a resize could move one, or where a line would come out more than 3% wider than its box in the fonts at hand (#1236). Budgets come from the file's own size (four decoded values per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows the file's size too), so a 15.4 MB synthetic file with 300,000 layers opens whole; ZIP image reads have a separate total budget of 4 GiB (1 GiB on 32-bit targets) of decompressed ZIP-member bytes containing encoded images, with the 256 MiB per-image cap kept: a member that cannot fit is skipped before its output buffer is allocated, and the import report names the budget and counts skipped images (#1076). Failed reads and invalid image contents still consume the bytes read; a reference used by several layers is read once. This is not a limit on decoded pixels or total import memory; images carried inside the message are unchanged. The file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Elbowed and curved connectors without a saved path are routed as FigCraft's board connectors are (right angles, or one curve, from the sides of the layers they join); an elbowed one saved with its path (`connectorControlPoints`: a point and an axis per run between the first and the last) keeps that path when it leaves and arrives by the ends' sides (#1910), and a curved one's saved `connectorBezierControlPoints` are not read; a connector routLine truncated
|
||||
| `.penpot` (Penpot 2.x ZIP export) | — | **yes, offline** (open, import, drop, CLI, MCP) | **yes, offline** (export, CLI convert) | Editable layers, supported flex/grid layout, components, tokens and prototypes using the existing model. Unsupported layouts retain saved positions and bounded metadata; toggle-overlay and embedded font binaries are reported. Font references remain editable; no downloads. Synthetic regression coverage; live Penpot and cross-platform QA remain open. See [Penpot reference](penpot.md). | further live Penpot and cross-platform QA |
|
||||
| `.sketch` (Sketch 48 and newer) | import (file browser ▸ Import or drop, desktop app too; converted to a Figma file, symbols as components; per the help center) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP; Open With from Finder, Explorer and Linux file managers once installed; recent files show its `previews/preview.png`) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, stretching filling every item across, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides; from Sketch 64, before which a group's fill draws nothing), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); operands' own borders ignored as Sketch does; open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (their image under their style's fills; colour adjustments as image filters with Sketch's tone curve, contrast and then brightness added), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, gradients mixed in OKLab or OKLCH as sRGB stops along the same colours, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer, motion and background blurs (Sketch's radius is the standard deviation), opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does, and its opacity fades it and what it clips together; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, also on shapes' image fills, layer and text styles, nested paths), clipping only where the master clips (`clippingBehavior` 1), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only when the master includes them in both instances and exports, as on Sketch's canvas and renders; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open, an auto-width one growing from its aligned edge. Warnings: noise fills, OKLab and OKLCH gradients, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 68 and 83 (70,000 layers open in ~3 s, debug build; its edition saved by Sketch 47 is refused, as it should be); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 69 1.1% on average and at most 4.4% (text in IBM Plex, which the file embeds and FigCraft doesn't use yet), a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcaseLine truncated
|
||||
| `.jam` (whiteboards) | native to FigJam | **yes, best effort** (`file.open`, `file.import`, File ▸ Open, drop, CLI, MCP) | no | the same archive as a `.fig` (its canvas header reads `fig-jam.`) through the same reader (#1342): it opens as a board (the file kind; a `.fig` stays a design file), with generated sticky and shape backgrounds and text kept (#1341) as ordinary layers rather than native board objects (see [boards-parity](boards-parity.md)); tables, stamps, code blocks and the like come in as frames with their text and connectors as vectors (an elbowed connector keeps the path it was saved with, #1910), with an import note; a layer type the reader doesn't know is left out with a note naming it; writing `.jam` is not supported | — |
|
||||
| `.deck` (Slides) | native to other Figma products | no | no | out of scope | — |
|
||||
| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — |
|
||||
| `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`); image members are `images/<key>`, and a key that can't be one — path text, a space, over 128 chars — is left out with a complaint instead of loaded (`image_tests`); saves write a unique staging file and rename it into place (#284); optional auto-save of files with a path (`pref.autoSave`, #305); Save and auto-save refuse to write over a file another app (figcraft-cli, say) changed since it was opened or saved here, judged by its size and modification time: Revert to saved loads those changes, Save as keeps both, `file.save {overwrite: true}` replaces them (#1330); `kind: "board"` marks a board (left out for design files, so files from before boards are design files; the home screen reads it for the file's mark); board objects keep their `board` metadata on the layer; Dev Mode measurements are kept on the layer they start from (`measurements: [{to} | {side}]`, left out when there are none, so older files open with none) | — |
|
||||
| `.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`); image members are `images/<key>`, and a key that can't be one — path text, a space, over 128 chars — is left out with a complaint instead of loaded (`image_tests`); saves write a unique staging file and rename it into place (#284); optional auto-save of files with a path (`pref.autoSave`, #305); Save and auto-save refuse to write over a file another app (figcraft-cli, say) changed since it was opened or saved here, judged by its size and modification time: Revert to saved loads those changes, Save as keeps both, `file.save {overwrite: true}` replaces them (#1330); `kind: "board"` marks a board (left out for design files, so files from before boards are design files; the home screen reads it for the file's mark); board objects keep their `board` metadata on the layer; Developer Mode measurements are kept on the layer they start from (`measurements: [{to} | {side}]`, left out when there are none, so older files open with none) | — |
|
||||
| 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) |
|
||||
|
||||
## Import (place / paste / drop)
|
||||
@@ -85,6 +85,13 @@ string that orders it among its siblings; vector networks and outlines go in blo
|
||||
each. `.fig` export warns and drops vector region paint stacks; import and editing preserve their
|
||||
source paint coordinates.
|
||||
|
||||
Scaled instances retain an explicit override when a length is reset to the unscaled main's value,
|
||||
rather than losing it to the field diff. This covers corners, strokes, layout and grid lengths,
|
||||
min/max bounds, effects and text, including styled runs. Effect and grid payload overrides above
|
||||
an inherited style link keep their own values and the link; stale copies on the linking row
|
||||
still yield to the style. These cases have synthetic export/import regressions, including nested
|
||||
scales and empty-style clears; they have not been checked in other software.
|
||||
|
||||
- **Written:** pages (name, background); frames, groups, sections, rectangles (as rounded
|
||||
rectangles, with their four radii), ellipses (arcs), polygons, stars, lines, vectors (editable
|
||||
network blobs with per-point corner radius, handle mirroring, caps and joins, plus the outline of
|
||||
@@ -128,7 +135,7 @@ source paint coordinates.
|
||||
missing, or more than 32 variables to read), variable bindings the format has no field for (component
|
||||
properties, arc fields, font weight: written as the values they give), text styles on part of a
|
||||
text (written as values), variables' publishing, code syntax and inheritance settings, extended
|
||||
collections (written as collections of their own), comments, Dev Mode annotations, statuses,
|
||||
collections (written as collections of their own), comments, Developer Mode annotations, statuses,
|
||||
resources, measurements and code links, plugin data, ruler
|
||||
guides, prototype device settings, slot properties and slot content, brush
|
||||
strokes (written plain), strokes wider on one side of their path (written as filled outlines, or
|
||||
@@ -189,6 +196,9 @@ source paint coordinates.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-08 | minor | M6, #2097 follow-up: per-point vector corner radii take the scale of the row supplying `vectorData`, including nested instance rows; override and derived rows are not scaled twice. Network caching distinguishes the instance scale even when resizing gives two vectors the same final box. Synthetic tests cover missing/unit factors, half/double scale, derived size alone, overridden and derived vector data, nested scale and equal-size cache separation. Layer-radius fallback remains the separate #2115 change. Numbers unchanged. |
|
||||
| 2026-11-08 | patch | #2097 / #2100: scaled-instance lengths reset to the unscaled main value survive export/import as explicit overrides. Dimensionless values and zero do not gain scale overrides. Exported factors stay within the importer's 0.001–1000 range; larger or smaller ratios use explicit values instead. Effect/grid payload rows above an inherited style link take precedence at their own scale, including nested instances and empty-list clears, without detaching styles. Synthetic regressions cover corners, dashes/shadows, layout/grids and bounds, typography and styled runs, linked text and nested styles. Numbers unchanged. |
|
||||
| 2026-11-07 | minor | `.fig` import, #2091 item 1: an instance scaled with the Scale tool stores `symbolData.uniformScaleFactor`, which the import never read, so the layers of a 128 px instance of a 256 px component came in at 256 px with the main's radii, strokes and spacing. The layers of an instance now take the main's lengths times the factor (composed through nested instances, so 0.5 inside 0.5 is 0.25 for the inner layers), the lengths the Scale tool scales: sizes and places, corner radii, stroke weights and dashes, effect radii, spreads and offsets, auto layout gaps and paddings, grid gaps, min and max sizes, layout grids, font sizes, pixel line heights and letter spacings, paragraph spacing and indent. A value an override or derived row gives is taken as written, already at the instance's scale; the instance's own size is the file's; a missing, non-finite, non-positive or out-of-range factor (outside 0.001 to 1000) changes nothing. Not scaled: vector outline paths (a network's points follow the layer's size), per-point corner radii, fixed grid tracks, image tile scales and glass effects. Tests: `fig::tests::a_scaled_instance_draws_its_layers_at_the_factor` and `fig::scale_factor_tests`. Numbers unchanged |
|
||||
| 2026-11-03 | patch | M6, #1909: `.jam` import: a connector end whose magnet is `AUTO` (FigJam picks the side when it draws) or `CENTER` (the end aims at the layer's middle) left the side its stale `relativePosition` named, the left one in the reporter's file, so the connectors looped behind their shape or crossed it. It now leaves the side facing the other end, as a board connector drawn here does (`Side::Auto`), for straight, elbowed and curved connectors alike; a named magnet (`TOP`, `RIGHT`, `BOTTOM`, `LEFT`) is unchanged, and an end without a magnet still takes the edge its point lies on. The Plugin API lists the magnets (`ConnectorEndpoint.magnet`: "Which side of the node it attaches to"; NONE, AUTO, TOP, LEFT, BOTTOM, RIGHT, CENTER; "Straight connectors may use only CENTER or NONE magnets"). Test: `connectors_with_an_automatic_side_leave_the_side_facing_the_target`. Numbers unchanged. |
|
||||
| 2026-11-04 | minor | JPG export: `quality` (1 to 100, default 90) on `file.export`, `export.add` / `export.update` and MCP `export`, kept in the export setting; an sRGB file's JPGs embed the sRGB ICC profile, as Display P3 files' JPGs already embedded theirs, since an untagged JPG is only assumed to be sRGB (#2004). |
|
||||
| 2026-11-04 | minor | `.fig` and `.jam` import, #1910: an elbowed connector saved with its path keeps it. FigJam stores `connectorControlPoints`, one per run between the first and the last, each a point on its run (in the connector's parent's coordinates) with the run's axis; the first run leaves the start's side and the last arrives at the end's. They become the `ConnectorPath::Runs` a board connector reshaped by hand has (`figcraft_doc::route_along`), so a connector drawn around a layer in FigJam stays around it instead of being routed through it. Points that don't make such a path (off both axes, two runs in a row the same way, a first or last run sideways to its side or going back into its object, more runs than a hand could make) leave the connector to the router as before, and only connectors routed here count in the "follow FigCraft's own routing" note. A curved connector's `connectorBezierControlPoints` are still not read. Tests: `tail_tests::an_elbow_connector_saved_with_its_path_keeps_it`, `tail_tests::an_elbow_whose_saved_path_doesnt_fit_its_sides_is_routed_here`. |
|
||||
|
||||
@@ -26,7 +26,7 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
|
||||
a missing translation shows English, never a raw key. One English text that needs two
|
||||
translations takes a context (`"Fill|sizing"`).
|
||||
- **Strings:** 1,747 UI keys in `en.toml` (42 with plural forms; ~110 are labels from engine and
|
||||
document data, such as tool names, blend modes and Dev Mode properties) plus 574 command labels
|
||||
document data, such as tool names, blend modes and Developer Mode properties) plus 574 command labels
|
||||
and 31 menu wordings by command id: 2,352 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.
|
||||
@@ -51,7 +51,7 @@ supported by both apps (CJK, Arabic and Devanagari shaping, IME); Figma has no v
|
||||
added later shows English until someone translates it, and `cargo xtask i18n-report` (part of
|
||||
`cargo xtask ci`) lists what each language lacks. 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
|
||||
- **Not yet localized:** messages the engine writes (errors, a few toasts, Developer Mode property values
|
||||
such as "inside"), number formatting (decimal points stay `.`), and decimal plural rules
|
||||
(`trn` takes integers: Czech and Bosnian use one/few/other and Russian one/few/many; French and Brazilian
|
||||
Portuguese take the singular for 0 as well; Japanese, Korean, Chinese and Vietnamese have one form;
|
||||
|
||||
+8
-8
@@ -373,7 +373,7 @@ a later step can use `{"$step": 0, "field": "ids/0"}` to target the first match.
|
||||
| Tool | What it does |
|
||||
|---|---|
|
||||
| `list_commands {filter?}` | Every command: id, label, menu, shortcut, undoable, params doc. |
|
||||
| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, React, Leptos, Vue, SwiftUI, Jetpack Compose, ArkUI or Flutter for a layer; components take their properties as parameters and instances are calls, see Dev Mode below), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names; `colorFormat`: hex, rgb, css, hsl or hsb), `inspect.assets` (layers under the selection with export settings, icons and images, each with its `kind`), `inspect.code` takes `unit: rem` and `rootFontSize` for CSS, HTML, React, Leptos and Vue, `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}` or a colour calculation (`{alpha}`, `{mix}`), `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`, `codeConnect.set` (components linked to real code, see Dev Mode below)). |
|
||||
| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, React, Leptos, Vue, SwiftUI, Jetpack Compose, ArkUI or Flutter for a layer; components take their properties as parameters and instances are calls, see Developer Mode below), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names; `colorFormat`: hex, rgb, css, hsl or hsb), `inspect.assets` (layers under the selection with export settings, icons and images, each with its `kind`), `inspect.code` takes `unit: rem` and `rootFontSize` for CSS, HTML, React, Leptos and Vue, `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}` or a colour calculation (`{alpha}`, `{mix}`), `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`, `codeConnect.set` (components linked to real code, see Developer Mode below)). |
|
||||
| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`), which keeps one copy of the document however many steps there are (an `edit.undo` step undoes the batch's edits so far together); all-or-nothing for open documents and their document state. Failed inner commands are removed from the journal; preferences, clipboard, tool state and UI requests are not rolled back. A step can use what an earlier step returned: name it with `"as": "card"` and `{"$ref": "card"}` anywhere in a later step's params becomes the `id` in its result (`{"$step": n}` does the same by zero-based position); `"field"` takes another key or a slash path such as `ids/0` (see Step references below). |
|
||||
| `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. |
|
||||
| `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`; marking again clears "Changed"), a section's `sectionContentsHidden`, text…); returns the updated layers. |
|
||||
@@ -1179,7 +1179,7 @@ import uses: the layer tree, geometry, paints with image bytes, strokes, effects
|
||||
runs, vector networks, auto layout, constraints, components, sets, instances with their overrides
|
||||
and property values, variable collections with their modes, variables and bindings, shared styles
|
||||
with their layer links, and prototype interactions. `warnings` lists what was left out (comments,
|
||||
Dev Mode data, some bindings and settings and the like). Round trips through FigCraft's own
|
||||
Developer Mode data, some bindings and settings and the like). Round trips through FigCraft's own
|
||||
importer are tested, and the example documents and small test files were opened in other software
|
||||
(`docs/file-format-parity.md` says what was checked); other apps may not open every file. Without
|
||||
`path` the file comes back as base64 `data`. Check one by opening it again:
|
||||
@@ -1365,7 +1365,7 @@ branches). Driven with `run_command`:
|
||||
changes formatted like Inspect (`fill #2563EB → #DC2626`); `document` lists styles, variables,
|
||||
comments and file settings. A conflict is the same property changed on both sides to different
|
||||
values (key `<layer id>/<property>`, e.g. `4/fills`; fills, strokes, effects, layout grids,
|
||||
export settings, guides, prototype interactions, annotations and Dev Mode resources compare item
|
||||
export settings, guides, prototype interactions, annotations and Developer Mode resources compare item
|
||||
by item, so changing different items of one list on each side, or adding items at its end on one
|
||||
side, merges without a conflict; other lists, such as code links, compare whole; a layer's parent
|
||||
and position are one property, `parent`; auto layout settings, `layout` and `childLayout`, compare
|
||||
@@ -1690,7 +1690,7 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path
|
||||
{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"plugin.directory.install","params":{"id":"org.figcraft.examples.swatch-grid","confirm":true}}}}
|
||||
```
|
||||
|
||||
### Dev Mode
|
||||
### Developer Mode
|
||||
|
||||
- **Pages as React + Tailwind:** `inspect.code {id, language: "tailwind"}` writes a layer tree as a
|
||||
React function component styled with Tailwind CSS v4 utilities and no stylesheet. Auto layout
|
||||
@@ -1791,11 +1791,11 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path
|
||||
saved with the file and synced; the same two layers are measured once (either way round), a
|
||||
layer starts at most 64. One whose other layer was deleted or moved to another page isn't listed
|
||||
or drawn (an undo brings it back); a copy keeps the side measurements and those to layers copied
|
||||
with it. In the app, Dev Mode draws them with the hover redlines, and the Inspect tab lists them
|
||||
with it. In the app, Developer Mode draws them with the hover redlines, and the Inspect tab lists them
|
||||
(with nothing selected, every one on the page), selects a measurement's layer on a click, removes
|
||||
one with −, and places new ones from its Add measurement menu.
|
||||
- **Annotations:** notes for developers on any layer, each with optional pinned properties that
|
||||
Dev Mode shows with their current values (and the style or variable behind them).
|
||||
Developer Mode shows with their current values (and the style or variable behind them).
|
||||
`annotation.add {id?, label?, properties?}` (a note, pinned properties or both; returns `{id,
|
||||
index}`), `annotation.edit {id?, index, label?, properties?}` (`properties` replaces the list),
|
||||
`annotation.remove {id?, index?}` (no index: all of the layer's), all undoable, and
|
||||
@@ -1827,9 +1827,9 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path
|
||||
(`changed`: ready for dev and edited since). The Inspect tab sets the status of the selected
|
||||
frames and, with nothing selected, lists them with a status filter (Ready for dev, Changed,
|
||||
Completed, Any status; changed ones carry a Changed badge; click one to go to it, or its focus
|
||||
button to open it in the focus view). In the app's Dev Mode the canvas only inspects: presses
|
||||
button to open it in the focus view). In the app's Developer Mode the canvas only inspects: presses
|
||||
select (and pan, and comment), never move, resize, draw or edit text and vectors, and the menus
|
||||
and shortcuts refuse commands that edit the design (Dev Mode's own annotations, statuses, dev
|
||||
and shortcuts refuse commands that edit the design (Developer Mode's own annotations, statuses, dev
|
||||
resources, measurements, code connections and comments still work); choosing a drawing tool in
|
||||
the toolbar goes back to the design, and the layers panel only selects and expands. Commands sent over MCP or the control channel aren't affected.
|
||||
- **Compare changes:** when a layer marked Ready for dev first changes (it turns "Changed"), the
|
||||
|
||||
+1
-1
@@ -256,7 +256,7 @@ plugin directory requests (`plugin.directory.*` says so). Import plugins with `p
|
||||
| `api` | The plugin API version the plugin was written for: `1` (the default), `2` (panels and settings), `3` (widgets), `4` (saving files, widget fields and property menus) or `5` (panels from widgets). A manifest asking for a newer one than FigCraft runs is refused, so a plugin that needs panels, widgets or `saveFile` fails clearly on an older FigCraft. |
|
||||
| `permissions.saveFile` | API 4: `true` lets the plugin offer a file to save (an exporter's output). FigCraft asks the user where it goes with its own Save dialog (a download in the browser); the plugin never gets a path. Manage plugins lists it ("can save files"). A plugin directory's entries don't carry it (their wire format stays as it is), so installing any API 4 plugin from one says it may offer files to save. |
|
||||
| `widget` | API 3: `true` when the plugin draws a widget (it's listed in Plugins ▸ Widgets). |
|
||||
| `permissions.access` | `read` (the default): commands that leave the document as it is; such plugins also run from Dev Mode's Inspect tab. `write`: edits too. |
|
||||
| `permissions.access` | `read` (the default): commands that leave the document as it is; such plugins also run from Developer Mode's Inspect tab. `write`: edits too. |
|
||||
| `permissions.commands` | Optional allowlist: exact command ids or prefixes ending in `*` (`node.*`). Empty: any command the access allows. |
|
||||
| `commands` | The menu commands, each `{id, label, params?}`. Without any, the plugin has one, `run`, unless it's a widget (a widget needs no command). |
|
||||
| `params[]` | `{name, label?, type, default?, options?, min?, max?}`; `type` is `text`, `number` (clamped to `min`/`max`), `boolean`, `select` (one of `options`, a menu), `list` (one of `options`, shown as a list), `slider` (a number between `min` and `max`, both required, shown as a slider with its value beside it) or `color` (hex, `#RRGGBB` or `#RRGGBBAA`). A default that doesn't fit is dropped. |
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
# FigCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; compact layer trees with fields and outline (M0.9, part of #996); while a drag only moves a large selection, its outlines, box, sLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** patch (the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; compact layer trees with fields and oLine truncated
|
||||
|
||||
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
|
||||
@@ -29,6 +29,7 @@ builders' side-by-side checks with synthetic files; nothing has been run on real
|
||||
|
||||
| Workflow | Works end to end? | Evidence | Hours to pass |
|
||||
|---|---|---|---|
|
||||
| 2026-11-07 | patch | Naming: the handoff mode is called Developer Mode in the interface, its shortcut row, its toasts, the control channel's refusal, the board refusal, the measurement parameter doc, the `.fig` export note, the CLI help and every catalog (24 languages, translations awaiting native review), since Figma holds a US registration for Dev Mode (7574348); command ids (`view.devMode`, `devMode.*`), preference keys and saved-document fields are unchanged, and the Actions palette still finds the command by the old name |
|
||||
| Design screens: frames, shapes, vectors, text, auto layout, styles; save, reopen, export assets | yes | tools and panels in `crates/engine` and `crates/ui-egui`; `.figcraft` round trips (`crates/format/src/large_tests.rs`); PNG/JPG/SVG/PDF export tests | — |
|
||||
| Build and use a design system: components, variants, properties, variables and modes, libraries | yes | `components_scale_tests.rs` (300 components, 2,000 instances), `library_tests.rs`, `library_variables_tests.rs`; libraries are local files, not shared | — |
|
||||
| Prototype and present: interactions, flows, overlays, smart animate, variables | yes | `proto_*_tests.rs`, `present_*_tests.rs` | — |
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ The catalogs: English (`en`, the source), and Spanish (`es`, with the Latin Amer
|
||||
(`fi`), Bengali (`bn`), Italian (`it`), Vietnamese (`vi`), Thai (`th`) and Bosnian (`bs`), machine-drafted and waiting for native speakers' review. Polish (`pl`,
|
||||
Polski) was reviewed and approved by Lucas (@lukasz-sklad). Georgian (`ka`) was written with AI
|
||||
assistance and reviewed by a native speaker, and stays a draft until a maintainer changes its status.
|
||||
Russian (`ru`, Русский) was reviewed and approved by VanTan; its catalog keeps Frame, Dev Mode and
|
||||
Russian (`ru`, Русский) was reviewed and approved by VanTan; its catalog keeps Frame and
|
||||
Smart animate as familiar tool names and uses « » quotes. Turkish (`tr`) was machine-drafted and
|
||||
reviewed by a native speaker, and Swedish (`sv`) translates every UI string, command label and menu wording.
|
||||
|
||||
|
||||
+5
-4
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged;Line truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (the main menu and its submenus stay inside the window, moving up to fit and scrolling when taller (#2217); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon andLine truncated
|
||||
|
||||
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:
|
||||
@@ -25,7 +25,7 @@ interaction feel not yet compared.
|
||||
| Actions palette (Cmd+K, Cmd+/) | done | tabs: All (every command, grouped by menu, or Other; a command that needs parameters only through its menu item, and none twice under one label, #1574), 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 or the keyboard's Menu key opens them from the keyboard (at the focused layer row or below the canvas selection) and the menu takes the keyboard (`layers_keyboard_tests`) |
|
||||
| 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. Submenus open on the first frame their row is hovered (a click or the keyboard opens one at once; Figma's main menu measured 2026-11-07 with synthetic pointer events: median 11 ms to open, 13 ms to switch rows) 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; the open delay is measured; the safe triangle and its 0.3 s rest are still tuned by feel, and Figma's main menu showed no triangle at all in that measurement, so the aim is kept pending the maintainer's decision). 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 |
|
||||
| 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). A menu or submenu that would run past the window's bottom moves up to fit (never above the top), and one taller than the window scrolls with the wheel, the keyboard's focus kept in view; the context menu's submenus share this (#2217, `menu_overflow_tests`on the first frame their row is hovered (a click or the keyboard opens one at once; Figma's main menu measured 2026-11-07 with synthetic pointer events: median 11 ms to open, 13 ms to switch rows) 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; the open delay is measured; the safe triangle and its 0.3 s rest are still tuned by feel, and Figma's main menu showed no triangle at all in that measurement, so the aim is kept pending the maintainer's decision). 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 |
|
||||
@@ -97,7 +97,7 @@ interaction feel not yet compared.
|
||||
| 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 | a group past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold) draws without its result, with a warning, and Flatten refuses it (#1104); the Design panel's Boolean groups menu leads Union, Subtract, Intersect and Exclude with their own icons, two offset squares with the result filled at reduced alpha (#2003) |
|
||||
| Text: point, auto height, fixed; caret, words, lines, IME | done | Backspace or Delete at the start of a list item takes its marker and keeps its level, the paragraph starting where the item's text did ("delete the counter, but keep the same level of indentation", Create bulleted and numbered lists; `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests`); max lines on auto-width text keep it auto width, hugging up to that many lines and growing with its longest line ("Text resizing is set to auto height or auto width", Explore text properties; `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines`); Alt+Enter while editing opens the link at the caret, web and mail links only (`text.openLink`; "press ⌥Option Return/Alt Enter to activate a focused link", Accessibility at Figma; `text_link_tests`); the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); 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; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check |
|
||||
| Text: wrap style (Auto, Balance, Pretty) | done | per text layer (`wrapStyle` on `node.set`, `text.wrapStyle`, the Type settings control under Vertical trim, inspect, CSS `text-wrap: balance` or `pretty`): Auto breaks greedily; Balance narrows each paragraph as far as it goes without another line, so its lines come out close to the same length (what browsers do for `text-wrap: balance`); Pretty keeps lines full and, when the last line is one word, narrows one of the last four lines just enough to give a word to the lines after it (Explore text properties: "Auto: Figma fits as many words as possible on each line before wrapping to the next", "Balance: Figma distributes text as evenly as possible across every line, so each line is close to the same length", "Pretty: Figma keeps each line full, but adjusts the last four lines to avoid an orphan"). Saved line breaks from a file still win. Not yet included: per-paragraph settings, text-style support and the `.fig` import/export mapping (#1827). Test: `text_behaviour_tests::balance_and_pretty_wrap_styles_break_lines_as_documented` |
|
||||
| Text: direction override (Text ▸ Text direction) | done | per paragraph (`ParaList::direction`: auto, ltr, rtl): Text ▸ Text direction ▸ Auto / Left to right / Right to left and `text.direction {direction, ids?}` set the edited paragraphs while editing, else every paragraph of the selected texts; `node.set textDirection` the layer's (or the chars `start`/`end` name); the Type settings control appears once a right-to-left script is in the text or a direction is forced; inspect reports `textDirection` when the paragraphs agree and `lineDirections` otherwise; Dev Mode's CSS writes `direction: ltr` or `rtl` when every paragraph is forced the same way (with `text-align: left` beside `rtl`, as Left is absolute on the canvas); `.fig` import and export carry each line's `sourceDirectionality`, read up to the paragraph count. Layout gives the paragraph its base level, so a forced right-to-left paragraph of Latin letters starts at the right, mirrors its list gutter and indent and orders its runs right to left, and a forced left-to-right paragraph of Hebrew the other way ("By default, Figma automatically handles text direction based on language detection. However, controls are available to override text direction per paragraph", "Go to Text > Text direction. Select Left to right or Right to left", Add right-to-left text). Not included: a direction in text styles (the article names none). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests` |
|
||||
| Text: direction override (Text ▸ Text direction) | done | per paragraph (`ParaList::direction`: auto, ltr, rtl): Text ▸ Text direction ▸ Auto / Left to right / Right to left and `text.direction {direction, ids?}` set the edited paragraphs while editing, else every paragraph of the selected texts; `node.set textDirection` the layer's (or the chars `start`/`end` name); the Type settings control appears once a right-to-left script is in the text or a direction is forced; inspect reports `textDirection` when the paragraphs agree and `lineDirections` otherwise; Developer Mode's CSS writes `direction: ltr` or `rtl` when every paragraph is forced the same way (with `text-align: left` beside `rtl`, as Left is absolute on the canvas); `.fig` import and export carry each line's `sourceDirectionality`, read up to the paragraph count. Layout gives the paragraph its base level, so a forced right-to-left paragraph of Latin letters starts at the right, mirrors its list gutter and indent and orders its runs right to left, and a forced left-to-right paragraph of Hebrew the other way ("By default, Figma automatically handles text direction based on language detection. However, controls are available to override text direction per paragraph", "Go to Text > Text direction. Select Left to right or Right to left", Add right-to-left text). Not included: a direction in text styles (the article names none). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests` |
|
||||
| Text: superscript and subscript | done | `sups` / `subs` use the font's own glyphs where it has the feature and every letter or digit of the run gets one; otherwise the standard glyphs are drawn shrunk and raised or lowered by the font's OS/2 superscript and subscript metrics (0.65 em at 0.35 em up or 0.14 em down without them), and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says ("Figma draws a synthesized version by using the font's standard version of the character, shrinking it, and positioning it accordingly"; mixed glyphs "all update to the synthesized form for consistency"), #1828; tests `text::tests::superscript_native_or_synthesised_and_never_mixed`, `text_behaviour_tests::superscript_and_subscript_are_synthesised_without_glyphs_for_them` |
|
||||
| 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`); with Cmd/Ctrl+Shift held it makes a colour style or variable of it instead, as the reference app's eyedropper does: a Create from color window asks which, its name (the next free "Color n") and a variable's collection (the file's first, or a new one made with it in the same undo step), and leaves the selection's fills alone (`eyedropper_create_tests`, `sampled_color_tests`) |
|
||||
| Comment (C), Shift+C to show/hide | done | local only |
|
||||
@@ -188,6 +188,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-07 | patch | Main menu: a submenu opens on the first frame its row is hovered instead of after 0.15 s (Figma's main menu measured with synthetic pointer events and 1 ms polling: median 11 ms to open, 13 ms to switch rows, no hover delay); the safe-triangle aim is unchanged, though the same measurement saw no aim logic in Figma, left for the maintainer to decide |
|
||||
| 2026-11-10 | patch | #2217: the main menu's submenus (Object, say) ran past the window's bottom, behind the taskbar, with the lowest commands out of reach, because submenus were not constrained to the window (to keep them beside their row). Every menu panel now scrolls when taller than the window less 8 pt margins, and a submenu is held within the window's height (moved up, never above the top) while keeping its place beside its row, so menu-aim is unchanged. Tests: `menu_overflow_tests.rs`. Numbers unchanged. |
|
||||
| 2026-11-02 | patch | Shortcuts: a Cmd/Ctrl-click on a padding field (the horizontal or vertical pair, or a side with Individual padding open) makes the value typed go to all four sides, in one undo step (Guide to auto layout, Keyboard shortcut guide); a plain click still edits that axis or side alone. A documented gesture row: 134 documented, 133 bound. Test: `padding_individual_tests` |
|
||||
| 2026-11-01 | patch | Number fields: dragging the field itself with Alt held scrubs it ("You can also hover the input field itself and hold down the modifier key: Mac: ⌥ Option Windows: Alt", Adjust alignment, rotation, position, and dimensions), with the resize cursor and the label's rates (Alt is the enabler there, so it isn't the fine rate); a click with Alt doesn't open the editor. Found by a documentation audit of the ui-parity rows. Test: `field_tests::alt_held_over_a_field_scrubs_it`. Counts unchanged |
|
||||
| 2026-11-02 | patch | M6, #1839: with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it by the nudge amounts (`tool.crosshair`), Enter places the object there (`tool.place`), Escape or a tool change takes it away; the canvas draws the crosshair and `ui.inspect` reports it (`tool.crosshair`), the Keyboard shortcuts panel lists the gesture ("Use the arrow keys to activate the crosshair cursor...", Accessibility at Figma). The board Shapes tool places on it too (its 160 px shape centred, as the tool centres a press), and the view pans by the overshoot when the crosshair is nudged past its edge. Tests: `place_crosshair_tests`, `shortcut_keys_tests::arrows_show_and_move_the_placement_crosshair_with_a_shape_tool`. |
|
||||
@@ -206,7 +207,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
|
||||
| 2026-11-05 | patch | M1, #1758: a mouse's side buttons (4 and 5) pan the canvas as the middle button does, the pan ending on release over the canvas or off it; the grab cursor shows while they're held. |
|
||||
| 2026-11-04 | patch | M1, #1852: a press on one of the open gradient's handles or stops on the canvas is the colour picker's own, not a press outside it (Figma keeps the picker and the handles up while a handle or stop is dragged; the stops are made to be dragged: "You can click and drag a stop to change its placement on the gradient", Use gradients as a fill or stroke). The canvas took the press and began the drag, then the picker, drawn later in the same frame, saw a press outside its card and closed, committing the drag's interaction at once; the handles vanished with it and the drag went on without an undo step of its own. Now the picker stays while a gradient handle or stop drags, and the drag is one undo step; a press elsewhere on the canvas still closes it. A handle drag whose button goes up off the canvas (over a panel or the picker) ends there too, instead of waiting, uncommitted, to move the handle to the next press on the canvas. Tests: `colorpicker_selection_tests::dragging_a_gradient_handle_on_the_canvas_keeps_the_picker_open`, `colorpicker_selection_tests::a_gradient_drag_released_off_the_canvas_ends_there`. |
|
||||
| 2026-11-02 | patch | M8, #1875: a click with the Bend tool on a sharp point between two segments gives it handles along the line between its neighbours, a third of the way to each ("Click on a point or path where you want to add a curve", Edit vector layers); a click on a curved point still makes it sharp, a drag still sets the handles by hand, an endpoint is left alone. Between a handle's end and a point both within reach of the pointer, the nearer one on the page is hit (the handle on a tie), so a short path's point (its handles a few pixels long) can be clicked again. Before, only a drag curved a point, and a short one left two tiny handles in its direction. Tests: `vectoredit::tests::short_handles_leave_their_point_clickable`, `vectoredit::tests::a_bend_click_curves_a_sharp_point_between_its_neighbours`, `bend_tool_smooths_and_sharpens_points`. Numbers unchanged. |
|
||||
| 2026-11-02 | minor | M4, #1827: wrap style on a text layer (`WrapStyle`: Auto, Balance, Pretty): Balance re-breaks each paragraph at the narrowest width that keeps its line count (bisection over the greedy breaker, as browsers balance), Pretty narrows one of the last four lines just enough so the last line isn't one word; `text.wrapStyle`, `node.set wrapStyle` (`textWrap` too), inspect `wrapStyle`, Dev Mode CSS `text-wrap` (Tailwind `text-balance` / `text-pretty`), the Type settings control under Vertical trim. Per-paragraph settings, text-style support and the `.fig` import/export mapping stay open. Test: `text_behaviour_tests::balance_and_pretty_wrap_styles_break_lines_as_documented`. |
|
||||
| 2026-11-02 | minor | M4, #1827: wrap style on a text layer (`WrapStyle`: Auto, Balance, Pretty): Balance re-breaks each paragraph at the narrowest width that keeps its line count (bisection over the greedy breaker, as browsers balance), Pretty narrows one of the last four lines just enough so the last line isn't one word; `text.wrapStyle`, `node.set wrapStyle` (`textWrap` too), inspect `wrapStyle`, Developer Mode CSS `text-wrap` (Tailwind `text-balance` / `text-pretty`), the Type settings control under Vertical trim. Per-paragraph settings, text-style support and the `.fig` import/export mapping stay open. Test: `text_behaviour_tests::balance_and_pretty_wrap_styles_break_lines_as_documented`. |
|
||||
| 2026-11-02 | patch | M4: Backspace or Delete at the start of a list item takes its marker and keeps its level ("Use Backspace or Delete at the beginning of a list item to delete the counter, but keep the same level of indentation", Create bulleted and numbered lists): a plain paragraph may now carry a list level, and the text layout starts it where that level's item text starts (the gutters stay even when no list is left). Backspace used to drop the level too, and Delete at an item's start deleted the next character. Tab on such a paragraph types a tab, as outside a list. Tests: `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests::a_plain_paragraph_at_a_list_level_starts_where_the_items_text_does` |
|
||||
| 2026-11-02 | patch | M4: max lines on an auto-width text keep it auto width: the box hugs up to that many lines and still grows with its longest line ("Max lines is available only if truncate text is enabled, and: Text resizing is set to auto height or auto width", Explore text properties). `text.maxLines` turned such a text into a fixed truncating box with a stale height, so later typing no longer widened it; fixed-size text still becomes a truncating box, auto-height text hugs as before. Test: `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines` |
|
||||
| 2026-11-02 | patch | M4, #1828: superscript and subscript synthesised where the font has no `sups` / `subs` glyphs (the standard glyph shrunk and shifted by the OS/2 metrics, the usual 0.65 em and 0.35 / 0.14 em offsets without them), and a run mixing available and unavailable glyphs synthesised whole, as Explore text properties says. Tests: `text::tests::superscript_native_or_synthesised_and_never_mixed`, `text_behaviour_tests::superscript_and_subscript_are_synthesised_without_glyphs_for_them`. |
|
||||
|
||||
@@ -27,7 +27,7 @@ fi
|
||||
# Size gate: Cloudflare Pages/Workers reject any single file over 25 MiB, and other
|
||||
# hosts and CDNs have similar caps. Fail well before that so a regression shows up here, not at
|
||||
# upload time. Override with FIGCRAFT_WASM_MAX_BYTES only to investigate.
|
||||
MAX_WASM_BYTES="${FIGCRAFT_WASM_MAX_BYTES:-25165824}" # 24 MiB
|
||||
MAX_WASM_BYTES="${FIGCRAFT_WASM_MAX_BYTES:-26214400}" # 25 MiB (Cloudflare per-file cap)
|
||||
WASM_COUNT=0
|
||||
for wasm in "$SITE"/*.wasm; do
|
||||
[[ -f "$wasm" ]] || continue
|
||||
|
||||
@@ -1,2 +1,8 @@
|
||||
sonar.projectKey=storytold_figcraft
|
||||
sonar.organization=storytold
|
||||
|
||||
# S4790 (weak hash): a .fig file names each image by the SHA-1 of its bytes (20 bytes, the only
|
||||
# length readers accept). It is an identifier the format fixes, not a security choice.
|
||||
sonar.issue.ignore.multicriteria=fig_image_sha1
|
||||
sonar.issue.ignore.multicriteria.fig_image_sha1.ruleKey=rust:S4790
|
||||
sonar.issue.ignore.multicriteria.fig_image_sha1.resourceKey=crates/format/src/fig/export.rs
|
||||
Reference in new issue
Block a user