Merge upstream/main into feat/vector-edit-point-xy-mixed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Trevor Brown
2026-11-11 11:39:31 -05:00
co-authored by Claude Opus 5.5
93 changed files with 2482 additions and 271 deletions
Generated
+1 -2
View File
@@ -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
View File
@@ -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" }
+4 -4
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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)]
+6 -1
View File
@@ -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")]
+4
View File
@@ -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)
}
+34 -4
View File
@@ -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");
}
}
}
+27
View File
@@ -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());
}
}
+1 -1
View File
@@ -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 -2
View File
@@ -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
+4
View File
@@ -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"),
+2 -2
View File
@@ -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)
),
+1 -1
View File
@@ -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
),
+1 -1
View File
@@ -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(),
+4
View File
@@ -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;
+210
View File
@@ -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");
}
}
+1
View File
@@ -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).
+89 -7
View File
@@ -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 {
+1 -1
View File
@@ -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");
+86
View File
@@ -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))])));
}
}
+109 -8
View File
@@ -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,
+39 -11
View File
@@ -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,
))
+3 -3
View File
@@ -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));
+52 -25
View File
@@ -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));
}
}
+12 -10
View File
@@ -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);
}
}
+28 -10
View File
@@ -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) {
+24 -25
View File
@@ -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
+458
View File
@@ -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);
}
}
+37 -1
View File
@@ -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]);
}
+55 -2
View File
@@ -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");
}
+144
View File
@@ -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);
}
}
+1 -1
View File
@@ -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 \
+187
View File
@@ -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());
}
+2 -2
View File
@@ -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%"
+1 -1
View File
@@ -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%" = "১০০%-এ ডিভাইস"
+3 -3
View File
@@ -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"
+1 -1
View File
@@ -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 %"
+2 -2
View File
@@ -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 %"
+2 -2
View File
@@ -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%"
+3 -3
View File
@@ -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"
+2 -2
View File
@@ -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 %"
+3 -3
View File
@@ -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"
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -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%"
+3 -3
View File
@@ -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" = "レイヤーパネル"
+2 -2
View File
@@ -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%-ზე"
+3 -3
View File
@@ -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" = "레이어 패널"
+3 -3
View File
@@ -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 -2
View File
@@ -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"
+3 -3
View File
@@ -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"
+4 -3
View File
@@ -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" = "Панель слоёв"
+3 -2
View File
@@ -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 %"
+3 -3
View File
@@ -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" = "แผงเลเยอร์"
+2 -1
View File
@@ -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"
+2 -2
View File
@@ -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"
+3 -3
View File
@@ -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" = "图层面板"
+3 -3
View File
@@ -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" = "取得檢視"
+16 -5
View File
@@ -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"),
}
}
+50 -1
View File
@@ -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);
+1 -1
View File
@@ -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, &params).map_err(|e| e.to_string())
}
+1 -1
View File
@@ -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;
+47 -8
View File
@@ -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));
}
}
+22 -2
View File
@@ -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 {
+31
View File
@@ -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");
}
+4
View File
@@ -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;
+107
View File
@@ -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
View File
@@ -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;
+11 -3
View File
@@ -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
+14 -1
View File
@@ -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())));
}
}
+55 -2
View File
@@ -289,6 +289,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));
@@ -316,12 +317,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;
+101
View File
@@ -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}");
}
}
+15
View File
@@ -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());
+49
View File
@@ -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");
}
+1 -1
View File
@@ -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 |
+1 -1
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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 |
+1 -1
View File
@@ -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 |
+3 -3
View File
@@ -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
+2 -1
View File
@@ -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) |
+11
View File
@@ -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.
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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
+6
View File
@@ -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