Merge main into #827

This commit is contained in:
bflatastic
2026-10-27 01:06:45 +07:00
147 changed files with 7238 additions and 418 deletions
Generated
+25
View File
@@ -1113,6 +1113,12 @@ dependencies = [
"syn 3.0.9",
]
[[package]]
name = "deunicode"
version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04"
[[package]]
name = "digest"
version = "0.10.7"
@@ -1645,6 +1651,7 @@ version = "0.11.2"
dependencies = [
"base64 0.22.1",
"craft-docpath",
"deunicode",
"figcraft-collab",
"figcraft-color",
"figcraft-doc",
@@ -1657,6 +1664,7 @@ dependencies = [
"figcraft-render",
"figcraft-svg",
"figcraft-text",
"figcraft-vectordrawable",
"image",
"log",
"regex",
@@ -1680,6 +1688,7 @@ dependencies = [
"ruzstd",
"serde",
"serde_json",
"sha1",
"sha2",
"zip 2.6.1",
]
@@ -1856,10 +1865,25 @@ dependencies = [
"web-time",
]
[[package]]
name = "figcraft-vectordrawable"
version = "0.11.2"
dependencies = [
"figcraft-color",
"figcraft-doc",
"figcraft-geom",
"figcraft-pathops",
"figcraft-render",
"figcraft-text",
"roxmltree 0.21.2",
"thiserror 2.0.21",
]
[[package]]
name = "figcraft-web"
version = "0.11.2"
dependencies = [
"base64 0.22.1",
"eframe",
"egui",
"figcraft-collab-net",
@@ -1867,6 +1891,7 @@ dependencies = [
"figcraft-ui-egui",
"js-sys",
"log",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
+5
View File
@@ -23,6 +23,7 @@ figcraft-pathops = { path = "crates/pathops" }
figcraft-render = { path = "crates/render" }
figcraft-svg = { path = "crates/svg" }
figcraft-pdf = { path = "crates/pdf" }
figcraft-vectordrawable = { path = "crates/vectordrawable" }
figcraft-format = { path = "crates/format" }
figcraft-tools = { path = "crates/tools" }
figcraft-collab = { path = "crates/collab" }
@@ -68,11 +69,15 @@ skrifa = "0.47"
harfrust = "0.13"
unicode-bidi = "0.3"
unicode-segmentation = "1.13"
# Code export: layer names transliterated into ASCII identifiers (pure Rust data tables, zero
# dependencies, BSD-3-Clause, no_std so the web build keeps it too).
deunicode = "1.6"
linesweeper = "0.4"
zip = { version = "2", default-features = false, features = ["deflate"] }
# `.fig` import: raw deflate chunks (already built for zip) and zstd chunks (pure Rust, MIT, wasm-safe).
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
ruzstd = { version = "0.9.1", default-features = false, features = ["std"] }
sha1 = "0.11"
sha2 = "0.10"
base64 = "0.22"
crc32fast = "1"
+1 -1
View File
@@ -208,7 +208,7 @@ exposes to agents, and exported at 2x by FigCraft's own renderer. The source fil
</td>
<td width="50%" valign="top">
<h3>Prototype and hand off</h3>
Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like <code>count + 1</code>), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Leptos, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like <code>count + 1</code>), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Vue, Leptos, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF.
</td>
</tr>
</table>
+3 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (colour picker LCH and OKLCH models; branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -132,6 +132,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
## Progress log
- **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged.
- **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged.
- **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change.
- **2026-10-24** · Boards (M12): board pages get a dot grid under their layers (drawn by the canvas render after the page colour, thinned out when zoomed out); a selected connector shows its ends as handles, and dragging one onto another object re-attaches it there, onto empty canvas leaves it free (`board.reattach`, one undo step, also over MCP); connectors can be curved, and elbows go around the objects they join when the short way would cross them. Checked in the app, through MCP and in the web build ([boards-parity](docs/boards-parity.md)).
@@ -347,6 +348,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged |
| 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. |
| 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |
| 2026-10-24 | minor | Boards (M12): a dot grid on board pages under the layers, connector ends dragged onto other objects or empty canvas (`board.reattach`), curved connectors, elbows that go around what they join. Boards row ~35% → ~37%, ~20% → ~21%, 30–60 h → 28–56 h; headline numbers unchanged (boards are outside the weighted sum) |
+9
View File
@@ -91,6 +91,15 @@ impl Args {
Ok(figcraft_doc::PrintSettings { bleed, crop_marks: self.flag("crop-marks"), bleed_marks: self.flag("bleed-marks"), cmyk: self.flag("cmyk") })
}
/// `--vd-compat bestEffort|exact|ignore` (Android Vector Drawable exports; default bestEffort).
pub fn vd_compat(&self) -> CliResult<figcraft_doc::VdCompat> {
match self.value("vd-compat") {
None => Ok(figcraft_doc::VdCompat::default()),
Some(s) => figcraft_doc::VdCompat::from_key(&s)
.ok_or_else(|| CliError::Usage(format!("--vd-compat takes bestEffort, exact or ignore, got `{s}`"))),
}
}
pub fn scale(&self) -> CliResult<f64> {
match self.value("scale") {
None => Ok(1.0),
+12 -5
View File
@@ -4,7 +4,7 @@ use std::ffi::OsStr;
use std::path::Path;
use std::process::ExitCode;
use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings};
use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings, VdCompat};
use figcraft_engine::{BatchScope, BatchStep, Session};
use serde_json::{Value, json};
@@ -25,7 +25,7 @@ fn ext(path: &str) -> String {
fn image_format(e: &str) -> Option<ExportFormat> {
match e {
"png" | "jpg" | "jpeg" | "svg" | "webp" | "pdf" => ExportFormat::from_ext(e),
"png" | "jpg" | "jpeg" | "svg" | "webp" | "pdf" | "xml" => ExportFormat::from_ext(e),
_ => None,
}
}
@@ -225,6 +225,8 @@ pub struct Target {
pub node: Option<String>,
/// PDF only.
pub print: PrintSettings,
/// Android Vector Drawable (XML) only.
pub vd_compat: VdCompat,
}
pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
@@ -239,15 +241,20 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
let (scale, node, print) = (t.scale, t.node.as_deref(), &t.print);
let fmt = match t.format.as_deref() {
Some(f) => {
image_format(&f.to_ascii_lowercase()).ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|fig)")))?
image_format(&f.to_ascii_lowercase()).ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|xml|fig)")))?
}
None => image_format(&ext(out)).unwrap_or(ExportFormat::Png),
};
let target = node.map(|q| find_node(&doc, page, q)).transpose()?;
let single = image_format(&ext(out)).is_some();
let bytes_of = |ids: &[NodeId]| {
let setting = ExportSetting { format: fmt, print: *print, ..Default::default() };
figcraft_engine::cmd::file::export_bytes_with(&doc, ids, &setting, scale).map_err(|e| CliError::Failed(e.to_string()))
let setting = ExportSetting { format: fmt, print: *print, vd_compat: t.vd_compat, ..Default::default() };
let (bytes, warnings) = figcraft_engine::cmd::file::export_file(&doc, ids, &setting, scale).map_err(|e| CliError::Failed(e.to_string()))?;
// What an Android Vector Drawable changed or left out, like the .fig notes.
for w in warnings {
eprintln!("note: {w}");
}
Ok::<_, CliError>(bytes)
};
if single {
let bytes = match target {
+11 -5
View File
@@ -17,8 +17,8 @@ pub const USAGE: &str = "figcraft-cli — FigCraft without a window
USAGE:
figcraft-cli info <file> [--json]
figcraft-cli export <file> -o <out> [--format png|jpg|svg|webp|pdf|fig] [--scale N] [--page NAME] [--node ID|NAME]
[--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk]
figcraft-cli export <file> -o <out> [--format png|jpg|svg|webp|pdf|xml|fig] [--scale N] [--page NAME] [--node ID|NAME]
[--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk] [--vd-compat bestEffort|exact|ignore]
figcraft-cli convert <in> <out>
figcraft-cli render <file> -o <out.png> [--scale N] [--page NAME]
figcraft-cli run <script.json|script.jsonl> [--in <file>] [--out <file>]
@@ -144,12 +144,18 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
confined(&a, || files::info(&file, a.flag("json")))
}
"export" => {
let valued = [&["format", "scale", "page", "node", "o", "out", "bleed"][..], &ROOT_FLAGS[..]].concat();
let valued = [&["format", "scale", "page", "node", "o", "out", "bleed", "vd-compat"][..], &ROOT_FLAGS[..]].concat();
let a = parse_args(&rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?;
let file = a.one_positional("export <file> -o <out>")?;
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("export needs -o <out>".into()))?;
let target =
files::Target { format: a.value("format"), scale: a.scale()?, page: a.value("page"), node: a.value("node"), print: a.print()? };
let target = files::Target {
format: a.value("format"),
scale: a.scale()?,
page: a.value("page"),
node: a.value("node"),
print: a.print()?,
vd_compat: a.vd_compat()?,
};
confined(&a, || files::export(&file, &out, &target))
}
"convert" => {
+2
View File
@@ -10,3 +10,5 @@ mod fonts;
mod pdf;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod pdf_print;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod vector_drawable;
@@ -0,0 +1,41 @@
//! `figcraft-cli export … -o out.xml` (Android Vector Drawable) end to end.
use std::process::Command;
fn bin() -> Command {
Command::new(env!("CARGO_BIN_EXE_figcraft-cli"))
}
#[test]
fn xml_export_writes_a_drawable_and_prints_its_notes() {
let d = std::env::temp_dir().join(format!("figcraft-cli-vd-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
let script = d.join("s.jsonl");
std::fs::write(
&script,
r##"{"command":"shape.rectangle","params":{"x":0,"y":0,"width":24,"height":24,"name":"Dot","props":{"fill":"#ff0000","effects":[{"type":"dropShadow"}]}}}"##,
)
.unwrap();
let file = d.join("doc.figcraft");
let out = bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let xml = d.join("icon.xml");
let out = bin().arg("export").arg(&file).arg("-o").arg(&xml).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let text = std::fs::read_to_string(&xml).unwrap();
assert!(text.contains("<vector") && text.contains("android:fillColor=\"#FF0000\""), "{text}");
assert!(String::from_utf8_lossy(&out.stderr).contains("note: drop shadows were left out (Dot)"));
// --format xml into a folder; --vd-compat ignore prints nothing.
let dir = d.join("out");
let out = bin().arg("export").arg(&file).arg("-o").arg(&dir).args(["--format", "xml", "--vd-compat", "ignore"]).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
assert!(dir.join("Dot.xml").exists());
assert!(!String::from_utf8_lossy(&out.stderr).contains("note:"));
let out = bin().arg("export").arg(&file).arg("-o").arg(&xml).args(["--vd-compat", "sloppy"]).output().unwrap();
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("--vd-compat"));
let _ = std::fs::remove_dir_all(&d);
}
+2
View File
@@ -24,6 +24,8 @@ log = { workspace = true }
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
base64 = { workspace = true }
serde_json = { workspace = true }
js-sys = "0.3"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
+172
View File
@@ -0,0 +1,172 @@
//! `window.figcraft`: the control channel for the page, as promises.
//!
//! The same JSON methods as the desktop control server (`docs/control-protocol.md`), so an agent
//! driving FigCraft in a browser tab (or an `<iframe>` in another app) uses the same commands as
//! the CLI and the MCP server:
//!
//! ```js
//! await figcraft.execute("node.create", {kind: "frame", name: "Home"}) // engine command → its result
//! await figcraft.request("document.inspect") // control method → its result
//! await figcraft.commands() // command list
//! const {base64} = await figcraft.screenshot() // the canvas as PNG (base64)
//! await figcraft.openFile("home.fig", bytes) // `file.open` with the bytes
//! figcraft.captureDownloads(true) // saves stay in the page…
//! figcraft.files(); await figcraft.readFile("home.figcraft") // …to be read back
//! ```
//!
//! Results resolve with the method's `result`; errors reject with an `Error`.
use std::cell::{Cell, RefCell};
use std::sync::mpsc::{Receiver, TryRecvError};
use base64::Engine as _;
use figcraft_ui_egui::{ControlReply, ControlRequest};
use serde_json::{Value, json};
use wasm_bindgen::prelude::*;
use crate::saved_files::SavedFiles;
type Waiting = (Receiver<ControlReply>, js_sys::Function, js_sys::Function);
thread_local! {
static QUEUE: RefCell<Vec<ControlRequest>> = const { RefCell::new(Vec::new()) };
static WAITING: RefCell<Vec<Waiting>> = const { RefCell::new(Vec::new()) };
static NEXT_ID: Cell<u64> = const { Cell::new(0) };
static CTX: RefCell<Option<egui::Context>> = const { RefCell::new(None) };
static CAPTURE: Cell<bool> = const { Cell::new(false) };
static SAVED: RefCell<SavedFiles> = RefCell::new(SavedFiles::default());
}
pub fn connect(ctx: egui::Context) {
CTX.with(|c| *c.borrow_mut() = Some(ctx));
}
/// The page's requests since the last frame, for the app's control queue.
pub fn take_requests() -> Vec<ControlRequest> {
QUEUE.with(|q| std::mem::take(&mut *q.borrow_mut()))
}
/// Keep a save instead of downloading it, when the page asked for that. True when kept.
pub fn capture(name: &str, bytes: &[u8]) -> bool {
if !CAPTURE.with(Cell::get) {
return false;
}
SAVED.with(|s| s.borrow_mut().keep(name, bytes));
true
}
fn to_js(v: &Value) -> JsValue {
js_sys::JSON::parse(&v.to_string()).unwrap_or(JsValue::NULL)
}
fn from_js(v: &JsValue) -> Value {
if v.is_undefined() || v.is_null() {
return json!({});
}
js_sys::JSON::stringify(v).ok().and_then(|s| s.as_string()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}))
}
/// Settle the promises of answered requests (called every frame).
pub fn poll_replies() {
WAITING.with(|w| {
w.borrow_mut().retain(|(rx, resolve, reject)| match rx.try_recv() {
Ok(ControlReply { result: Ok(v), .. }) => {
let _ = resolve.call1(&JsValue::NULL, &to_js(&v));
false
}
Ok(ControlReply { result: Err(e), .. }) => {
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new(&e));
false
}
Err(TryRecvError::Empty) => true,
Err(TryRecvError::Disconnected) => {
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("request dropped"));
false
}
});
});
}
fn request(method: &str, params: Value) -> js_sys::Promise {
let method = method.to_string();
let p = js_sys::Promise::new(&mut |resolve, reject| {
let (reply, rx) = std::sync::mpsc::channel();
let id = NEXT_ID.with(|n| {
n.set(n.get() + 1);
n.get()
});
let req = ControlRequest { id: json!(id), method: method.clone(), params: params.clone(), reply, state: Default::default() };
QUEUE.with(|q| q.borrow_mut().push(req));
WAITING.with(|w| w.borrow_mut().push((rx, resolve, reject)));
});
CTX.with(|c| {
if let Some(ctx) = c.borrow().as_ref() {
ctx.request_repaint();
}
});
p
}
fn set_fn(obj: &js_sys::Object, name: &str, f: JsValue) -> Result<(), JsValue> {
js_sys::Reflect::set(obj, &name.into(), &f).map(|_| ())
}
/// Install `window.figcraft`.
pub fn install() -> Result<(), JsValue> {
let obj = js_sys::Object::new();
set_fn(
&obj,
"request",
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|method: String, params: JsValue| request(&method, from_js(&params)))
.into_js_value(),
)?;
set_fn(
&obj,
"execute",
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|command: String, params: JsValue| {
request("engine.execute", json!({"command": command, "params": from_js(&params)}))
})
.into_js_value(),
)?;
set_fn(&obj, "commands", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("engine.commands", json!({}))).into_js_value())?;
set_fn(&obj, "inspect", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("document.inspect", json!({}))).into_js_value())?;
set_fn(
&obj,
"screenshot",
Closure::<dyn Fn(JsValue) -> js_sys::Promise>::new(|params: JsValue| request("ui.screenshot", from_js(&params))).into_js_value(),
)?;
set_fn(
&obj,
"openFile",
Closure::<dyn Fn(String, js_sys::Uint8Array) -> js_sys::Promise>::new(|name: String, bytes: js_sys::Uint8Array| {
let data = base64::engine::general_purpose::STANDARD.encode(bytes.to_vec());
request("engine.execute", json!({"command": "file.open", "params": {"name": name, "data": data}}))
})
.into_js_value(),
)?;
set_fn(&obj, "captureDownloads", Closure::<dyn Fn(bool)>::new(|on: bool| CAPTURE.with(|c| c.set(on))).into_js_value())?;
set_fn(
&obj,
"files",
Closure::<dyn Fn() -> JsValue>::new(|| {
SAVED.with(|s| to_js(&Value::Array(s.borrow().list().map(|(n, size)| json!({"path": n, "size": size})).collect())))
})
.into_js_value(),
)?;
set_fn(
&obj,
"readFile",
Closure::<dyn Fn(String) -> Result<JsValue, JsValue>>::new(|name: String| {
SAVED.with(|s| {
s.borrow()
.get(&name)
.map(|b| js_sys::Uint8Array::from(b).into())
.ok_or_else(|| js_sys::Error::new(&format!("{name}: not found")).into())
})
})
.into_js_value(),
)?;
let w = web_sys::window().ok_or("no window")?;
js_sys::Reflect::set(&w, &"figcraft".into(), &obj)?;
Ok(())
}
+6 -1
View File
@@ -13,17 +13,22 @@
//! (browsers block `ws://` from secure pages); serve it over `http://` on a trusted network to
//! join from other machines.
//!
//! URL query flag: `?webgl` forces the WebGL2 backend instead of WebGPU.
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?control` installs
//! `window.figcraft`, the page's control channel (`api.rs`), which is off without it.
//!
//! URL fragment: `#join=<invite link or join code>` joins that live session once the page has
//! loaded, so a bookmark opens a shared file. `join` takes the rest of the fragment: put it last.
//! The link carries the session's secret; a fragment is never sent to the web host, but the address
//! bar and the browser history keep it, so the URL is as private as the link itself.
#[cfg(target_arch = "wasm32")]
mod api;
#[cfg(any(target_arch = "wasm32", test))]
mod query;
#[cfg(test)]
mod query_tests;
#[cfg(any(target_arch = "wasm32", test))]
mod saved_files;
#[cfg(target_arch = "wasm32")]
mod web;
+8 -1
View File
@@ -1,4 +1,4 @@
//! The page's URL: the invite link its fragment names.
//! The page's URL: the invite link its fragment names, and the flags its query sets.
/// The invite link in `#join=<link>` (`fragment` is `location.hash`, with or without its `#`), still
/// escaped. The fragment, unlike the query, never leaves the browser, so the link's secret stays out
@@ -13,3 +13,10 @@ pub fn join_link(fragment: &str) -> Option<&str> {
let start = if fragment.starts_with("join=") { 0 } else { fragment.find("&join=")? + 1 };
fragment.get(start + "join=".len()..).filter(|link| !link.is_empty() && link.len() <= MAX)
}
/// Whether `search` (`location.search`, with or without its `?`) has a `control` entry (`?control`,
/// or `?control=…` with any value): the page's control channel, `window.figcraft`, is installed only
/// when it is asked for.
pub fn control(search: &str) -> bool {
search.strip_prefix('?').unwrap_or(search).split('&').any(|entry| entry.split('=').next() == Some("control"))
}
+13 -2
View File
@@ -1,6 +1,6 @@
//! `#join=` in the page's URL.
//! `#join=` and `?control` in the page's URL.
use crate::query::join_link;
use crate::query::{control, join_link};
#[test]
fn join_takes_the_rest_of_the_fragment() {
@@ -27,3 +27,14 @@ fn an_overlong_link_is_ignored() {
assert_eq!(join_link(&long), None);
assert_eq!(join_link(&format!("#join={}", "é".repeat(4000))), None, "length counts bytes");
}
#[test]
fn control_is_opt_in() {
assert!(control("?control"));
assert!(control("?webgl&control=1"), "after other entries, with a value");
assert!(control("control"), "no leading ?");
assert!(!control(""));
assert!(!control("?"));
assert!(!control("?webgl"));
assert!(!control("?nocontrol&controls"), "only the whole entry name");
}
+70
View File
@@ -0,0 +1,70 @@
//! What the page keeps of the app's saves (`api.rs`): `readFile` reads them back.
/// Saved files kept (the oldest go first).
pub const MAX_SAVED: usize = 16;
/// The last component of a `/` or `\` path.
fn file_name(path: &str) -> &str {
path.rsplit(['/', '\\']).next().filter(|n| !n.is_empty()).unwrap_or(path)
}
/// Files by name, newest last: saving a name again replaces it.
#[derive(Default)]
pub struct SavedFiles(Vec<(String, Vec<u8>)>);
impl SavedFiles {
/// Keep `bytes` under the last component of `path`.
pub fn keep(&mut self, path: &str, bytes: &[u8]) {
let name = file_name(path).to_string();
self.0.retain(|(n, _)| *n != name);
self.0.push((name, bytes.to_vec()));
let extra = self.0.len().saturating_sub(MAX_SAVED);
self.0.drain(..extra);
}
/// A kept file by its name, or by a path ending in it.
pub fn get(&self, name: &str) -> Option<&[u8]> {
let wanted = file_name(name);
self.0.iter().find(|(n, _)| n == wanted).map(|(_, b)| b.as_slice())
}
/// `(name, size)` of each kept file, oldest first.
pub fn list(&self) -> impl Iterator<Item = (&str, usize)> {
self.0.iter().map(|(n, b)| (n.as_str(), b.len()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keeps_by_name_replaces_and_reads_back_by_path() {
let mut s = SavedFiles::default();
s.keep("exports/poster.png", b"one");
s.keep(r"C:\out\poster.png", b"two");
s.keep("notes.txt", b"three");
assert_eq!(s.list().collect::<Vec<_>>(), vec![("poster.png", 3), ("notes.txt", 5)]);
assert_eq!(s.get("poster.png"), Some(&b"two"[..]));
assert_eq!(s.get("/exports/poster.png"), Some(&b"two"[..]));
assert_eq!(s.get("missing.png"), None);
}
#[test]
fn drops_the_oldest_past_the_cap() {
let mut s = SavedFiles::default();
for i in 0..MAX_SAVED + 3 {
s.keep(&format!("f{i}.png"), &[0]);
}
assert_eq!(s.list().count(), MAX_SAVED);
assert_eq!(s.get("f0.png"), None);
assert!(s.get(&format!("f{}.png", MAX_SAVED + 2)).is_some());
}
#[test]
fn a_trailing_separator_keeps_the_whole_name() {
let mut s = SavedFiles::default();
s.keep("odd/", b"x");
assert_eq!(s.get("odd/"), Some(&b"x"[..]));
}
}
+12
View File
@@ -47,6 +47,13 @@ pub fn start() {
let link = js_sys::decode_uri_component(link).ok().and_then(|s| s.as_string()).unwrap_or_else(|| link.to_string());
figcraft_ui_egui::live::open_invite(&mut fig, &link);
}
// `?control`: `window.figcraft`, the page's control channel (crate::api), opt-in.
if crate::query::control(&query()) {
crate::api::connect(cc.egui_ctx.clone());
if let Err(e) = crate::api::install() {
log::error!("couldn't install window.figcraft: {e:?}");
}
}
Ok(Box::new(WebShell { fig }))
}),
)
@@ -79,7 +86,9 @@ impl eframe::App for WebShell {
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.fig.control_queue.extend(crate::api::take_requests());
self.fig.ui(ui);
crate::api::poll_replies();
}
}
@@ -109,6 +118,9 @@ impl Services for WebServices {
}
/// A Blob behind a temporary `<a download>` link, clicked and removed.
fn download(&mut self, name: &str, bytes: &[u8]) -> bool {
if crate::api::capture(name, bytes) {
return true;
}
match download(name, bytes) {
Ok(()) => true,
Err(e) => {
+8 -1
View File
@@ -956,11 +956,18 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimit
// Sessions and the operator may use the last connections; one-shot requests
// (hosting, libraries, browsing plugins) leave them free.
let session = matches!(first, ClientMsg::Hello(_) | ClientMsg::Manage(_));
// A publish uploads a whole file; a large one on a slow link takes the server a
// while to receive and answer. The manage/hosting connections that carry uploads
// wait the longer request-reply timeout, not the short request idle.
let idle = match first {
ClientMsg::Manage(_) | ClientMsg::OpenHosting(_) => limits.request_reply_timeout,
ClientMsg::Hello(_) => limits.idle_timeout,
_ => limits.request_idle,
};
let max = match &first {
ClientMsg::OpenPlugins(o) if o.key.is_empty() => limits.max_request,
_ => limits.max_client_message,
};
let idle = if session { limits.idle_timeout } else { limits.request_idle };
let reserved = !session && open.0.load(Ordering::Relaxed) > limits.max_connections.saturating_sub(limits.reserve_for_sessions);
// (In flight is only taken when the connection goes on: the hub gives it back.)
if reserved || !shared.in_flight.take(bytes, limits.max_in_flight) {
+1
View File
@@ -28,6 +28,7 @@ fn colourful() -> (Document, NodeId) {
close_on_outside: false,
background: Some(RED),
offset: None,
anchor: Default::default(),
}],
transition: Default::default(),
duration_ms: 300,
+2
View File
@@ -1270,6 +1270,8 @@ mod proto_expr_parse_tests;
#[cfg(test)]
mod proto_expr_tests;
#[cfg(test)]
mod proto_overlay_anchor_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod vector_regions_tests;
+51 -1
View File
@@ -1233,11 +1233,14 @@ pub enum ExportFormat {
Svg,
Pdf,
Webp,
/// Android Vector Drawable (an `.xml` drawable resource).
VectorDrawable,
}
impl ExportFormat {
/// Every format, in the order the export menus list them.
pub const ALL: [ExportFormat; 5] = [ExportFormat::Png, ExportFormat::Jpg, ExportFormat::Svg, ExportFormat::Pdf, ExportFormat::Webp];
pub const ALL: [ExportFormat; 6] =
[ExportFormat::Png, ExportFormat::Jpg, ExportFormat::Svg, ExportFormat::Pdf, ExportFormat::Webp, ExportFormat::VectorDrawable];
/// Pixel formats (their size depends on the export scale).
pub fn is_raster(self) -> bool {
@@ -1252,9 +1255,15 @@ impl ExportFormat {
ExportFormat::Svg => "SVG",
ExportFormat::Pdf => "PDF",
ExportFormat::Webp => "WebP",
ExportFormat::VectorDrawable => "XML",
}
}
/// Formats whose output is vector (one file whatever the scale).
pub fn is_vector(self) -> bool {
matches!(self, ExportFormat::Svg | ExportFormat::Pdf | ExportFormat::VectorDrawable)
}
pub fn ext(self) -> &'static str {
match self {
ExportFormat::Png => "png",
@@ -1262,6 +1271,7 @@ impl ExportFormat {
ExportFormat::Svg => "svg",
ExportFormat::Pdf => "pdf",
ExportFormat::Webp => "webp",
ExportFormat::VectorDrawable => "xml",
}
}
pub fn from_ext(s: &str) -> Option<Self> {
@@ -1271,6 +1281,7 @@ impl ExportFormat {
"svg" => Some(ExportFormat::Svg),
"pdf" => Some(ExportFormat::Pdf),
"webp" => Some(ExportFormat::Webp),
"xml" | "vd" | "vectordrawable" => Some(ExportFormat::VectorDrawable),
_ => None,
}
}
@@ -1439,6 +1450,44 @@ pub struct ExportSetting {
/// SVG: strokes as `stroke` attributes where that's exact (on), else as outlines.
#[serde(skip_serializing_if = "is_true")]
pub svg_simplify_stroke: bool,
/// Android Vector Drawable: what to do with what a drawable can't hold exactly.
#[serde(skip_serializing_if = "VdCompat::is_default")]
pub vd_compat: VdCompat,
}
/// How an Android Vector Drawable export handles what the format can't draw exactly (layer
/// opacity over overlapping shapes, even-odd fills, diamond gradients, alpha masks).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum VdCompat {
/// The closest drawing, with a warning for each approximation.
#[default]
BestEffort,
/// Never approximate: what can't be drawn exactly is left out, with a warning.
Exact,
/// Write what maps, leave out the rest, no warnings.
Ignore,
}
impl VdCompat {
pub const ALL: [VdCompat; 3] = [VdCompat::BestEffort, VdCompat::Exact, VdCompat::Ignore];
fn is_default(&self) -> bool {
*self == VdCompat::BestEffort
}
/// The name commands, files and the CLI use ("bestEffort").
pub fn key(self) -> &'static str {
match self {
VdCompat::BestEffort => "bestEffort",
VdCompat::Exact => "exact",
VdCompat::Ignore => "ignore",
}
}
pub fn from_key(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|c| c.key() == s)
}
}
impl Default for ExportSetting {
@@ -1453,6 +1502,7 @@ impl Default for ExportSetting {
svg_outline_text: true,
svg_id_attribute: false,
svg_simplify_stroke: true,
vd_compat: VdCompat::BestEffort,
}
}
}
+44
View File
@@ -507,6 +507,47 @@ impl OverlayPosition {
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum OverlayAnchor {
#[default]
Screen,
TriggerInside,
TriggerOutside,
}
impl OverlayAnchor {
/// The default (the screen places the overlay), which files and JSON leave out.
pub fn is_screen(&self) -> bool {
*self == OverlayAnchor::Screen
}
/// Gives None for Screen and for Manual.
pub fn offset(self, position: OverlayPosition, trigger: [f64; 2], overlay: [f64; 2]) -> Option<[f64; 2]> {
use OverlayPosition as P;
let ([tw, th], [ow, oh]) = (trigger, overlay);
let outside = match self {
OverlayAnchor::Screen => return None,
OverlayAnchor::TriggerInside => false,
OverlayAnchor::TriggerOutside => true,
};
let x = match position {
P::TopLeft | P::BottomLeft => 0.0,
P::Center | P::TopCenter | P::BottomCenter => (tw - ow) / 2.0,
P::TopRight | P::BottomRight => tw - ow,
P::Manual => return None,
};
let y = match position {
P::TopLeft | P::TopCenter | P::TopRight if outside => -oh,
P::TopLeft | P::TopCenter | P::TopRight => 0.0,
P::BottomLeft | P::BottomCenter | P::BottomRight if outside => th,
P::BottomLeft | P::BottomCenter | P::BottomRight => th - oh,
P::Center | P::Manual => (th - oh) / 2.0,
};
Some([x, y])
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum Action {
@@ -538,6 +579,9 @@ pub enum Action {
/// opened it (x, y). None = right below that layer.
#[serde(default, skip_serializing_if = "Option::is_none")]
offset: Option<[f64; 2]>,
/// The incumbent has no such option. The .fig export writes it as a manual offset.
#[serde(default, skip_serializing_if = "OverlayAnchor::is_screen")]
anchor: OverlayAnchor,
},
SwapOverlay {
dest: NodeId,
@@ -0,0 +1,61 @@
use serde_json::json;
use crate::NodeId;
use crate::proto::*;
const TRIGGER: [f64; 2] = [100.0, 40.0];
const OVERLAY: [f64; 2] = [60.0, 200.0];
fn at(anchor: OverlayAnchor, p: OverlayPosition) -> Option<[f64; 2]> {
anchor.offset(p, TRIGGER, OVERLAY)
}
#[test]
fn outside_sits_above_or_below_with_edges_lined_up() {
use OverlayPosition as P;
let out = OverlayAnchor::TriggerOutside;
assert_eq!(at(out, P::BottomLeft), Some([0.0, 40.0]));
assert_eq!(at(out, P::BottomCenter), Some([20.0, 40.0]));
assert_eq!(at(out, P::BottomRight), Some([40.0, 40.0]));
assert_eq!(at(out, P::TopLeft), Some([0.0, -200.0]));
assert_eq!(at(out, P::TopCenter), Some([20.0, -200.0]));
assert_eq!(at(out, P::TopRight), Some([40.0, -200.0]));
assert_eq!(at(out, P::Center), Some([20.0, -80.0]));
}
#[test]
fn inside_uses_the_presets_on_the_trigger() {
use OverlayPosition as P;
let ins = OverlayAnchor::TriggerInside;
assert_eq!(at(ins, P::TopLeft), Some([0.0, 0.0]));
assert_eq!(at(ins, P::TopRight), Some([40.0, 0.0]));
assert_eq!(at(ins, P::BottomLeft), Some([0.0, -160.0]));
assert_eq!(at(ins, P::BottomCenter), Some([20.0, -160.0]));
assert_eq!(at(ins, P::Center), Some([20.0, -80.0]));
}
#[test]
fn the_screen_and_manual_positions_have_no_preset_offset() {
assert_eq!(at(OverlayAnchor::Screen, OverlayPosition::BottomLeft), None);
assert_eq!(at(OverlayAnchor::TriggerOutside, OverlayPosition::Manual), None);
assert_eq!(at(OverlayAnchor::TriggerInside, OverlayPosition::Manual), None);
}
#[test]
fn json_keeps_the_anchor_and_old_actions_stay_on_the_screen() {
let a = Action::OpenOverlay {
dest: NodeId(7),
position: OverlayPosition::BottomLeft,
close_on_outside: true,
background: None,
offset: None,
anchor: OverlayAnchor::TriggerOutside,
};
let v = serde_json::to_value(&a).unwrap();
assert_eq!(v["anchor"], "triggerOutside");
assert_eq!(serde_json::from_value::<Action>(v).unwrap(), a);
let old: Action = serde_json::from_value(json!({"type": "openOverlay", "dest": 7, "position": "bottomLeft"})).unwrap();
assert!(matches!(old, Action::OpenOverlay { anchor: OverlayAnchor::Screen, .. }));
assert!(serde_json::to_value(&old).unwrap().get("anchor").is_none());
}
+65 -4
View File
@@ -120,23 +120,40 @@ impl TextCase {
}
/// Apply the case to `s`, which follows `prev` in the text (so title case only capitalises
/// the first letter of each word even when a word spans several styled runs).
/// the first letter of each word even when a word spans several styled runs). An apostrophe
/// as `prev` is taken to continue a word.
pub fn apply_after(self, s: &str, prev: Option<char>) -> String {
self.apply_impl(s, prev, prev.is_some_and(is_word_char))
}
/// Like [`apply_after`](Self::apply_after), also given the char `before` `prev`: an apostrophe
/// only continues a word when a letter or digit precedes it.
pub fn apply_between(self, s: &str, before: Option<char>, prev: Option<char>) -> String {
let continues = prev.is_some_and(|c| is_word_char(c) && (c.is_alphanumeric() || before.is_some_and(char::is_alphanumeric)));
self.apply_impl(s, prev, continues)
}
/// `in_word` says whether `prev` continues a word (rather than a new word starting here).
fn apply_impl(self, s: &str, prev: Option<char>, in_word: bool) -> String {
match self {
TextCase::Original | TextCase::SmallCaps => s.to_string(),
TextCase::Upper => s.to_uppercase(),
TextCase::Lower => s.to_lowercase(),
TextCase::Title => {
let mut out = String::with_capacity(s.len());
let mut start = prev.is_none_or(|c| !is_word_char(c));
let mut start = !in_word;
let mut last = prev;
for ch in s.chars() {
if start && ch.is_alphanumeric() {
out.extend(ch.to_uppercase());
start = false;
} else {
out.push(ch);
start = !is_word_char(ch);
// An apostrophe only continues a word right after a letter or digit.
let opening_apostrophe = matches!(ch, '\'' | '\u{2019}') && !last.is_some_and(char::is_alphanumeric);
start = !is_word_char(ch) || opening_apostrophe;
}
last = Some(ch);
}
out
}
@@ -417,7 +434,8 @@ pub struct TextData {
/// line's baseline).
#[serde(skip_serializing_if = "crate::node::is_default")]
pub leading_trim: LeadingTrim,
/// Max lines when truncating (0 = as many as fit).
/// Max lines when truncating, or before an auto-height text ends in an ellipsis (0 = as many
/// as fit).
#[serde(skip_serializing_if = "crate::node::is_default")]
pub max_lines: u32,
/// OpenType feature toggles (e.g. "liga": false).
@@ -510,6 +528,28 @@ impl TextData {
self.characters.chars().count()
}
/// Whether text that runs out of room ends in an ellipsis: a truncating box, or auto height
/// with a line limit (the box hugs up to `max_lines` lines).
pub fn truncates(&self) -> bool {
match self.auto_resize {
TextAutoResize::Truncate => true,
TextAutoResize::Height => self.max_lines > 0,
TextAutoResize::WidthAndHeight | TextAutoResize::None => false,
}
}
/// The resize mode once the box is given an explicit `width` and/or `height`: an explicit
/// height fixes the box, an explicit width turns auto width into auto height, and truncating
/// text keeps truncating.
pub fn fix_size(&mut self, width: bool, height: bool) {
self.auto_resize = match self.auto_resize {
_ if height && self.truncates() => TextAutoResize::Truncate,
_ if height => TextAutoResize::None,
TextAutoResize::WidthAndHeight if width => TextAutoResize::Height,
mode => mode,
};
}
/// The style at char index `i`.
pub fn style_at(&self, i: usize) -> &TextStyle {
self.runs.iter().find(|r| r.start <= i && i < r.end).map(|r| &r.style).unwrap_or(&self.style)
@@ -857,3 +897,24 @@ mod case_tests {
assert_eq!(t.para_list(0), ParaList::default());
}
}
#[cfg(test)]
mod title_case_tests {
use super::TextCase;
#[test]
fn title_case_after_opening_apostrophe() {
assert_eq!(TextCase::Title.apply("'hello world'"), "'Hello World'");
assert_eq!(TextCase::Title.apply("say 'hi'"), "Say 'Hi'");
assert_eq!(TextCase::Title.apply("don't"), "Don't");
assert_eq!(TextCase::Title.apply("\u{2019}hello"), "\u{2019}Hello");
}
#[test]
fn title_case_between_runs_sees_char_before_apostrophe() {
assert_eq!(TextCase::Title.apply_after("t", Some('\'')), "t");
assert_eq!(TextCase::Title.apply_between("hello", None, Some('\'')), "Hello");
assert_eq!(TextCase::Title.apply_between("t", Some('n'), Some('\'')), "t");
assert_eq!(TextCase::Title.apply_between("t", Some(' '), Some('\u{2019}')), "T");
}
}
+2
View File
@@ -20,6 +20,7 @@ figcraft-render = { workspace = true }
figcraft-format = { workspace = true }
figcraft-svg = { workspace = true }
figcraft-pdf = { workspace = true }
figcraft-vectordrawable = { workspace = true }
craft-docpath = { workspace = true }
figcraft-plugin = { workspace = true }
serde = { workspace = true }
@@ -28,6 +29,7 @@ thiserror = { workspace = true }
log = { workspace = true }
base64 = { workspace = true }
regex = { workspace = true }
deunicode = { workspace = true }
same-file = { workspace = true }
web-time = "1.1.0"
+25
View File
@@ -34,6 +34,31 @@ fn failed_group_closes_documents_opened_inside_it() {
assert_eq!(s.journal, before_journal);
}
#[test]
fn failed_group_reopens_documents_closed_inside_it() {
let mut s = Session::new();
s.execute("file.new", &json!({"name": "Second"})).unwrap();
let uids: Vec<_> = s.docs.iter().map(|ds| ds.uid).collect();
assert_eq!((s.docs.len(), s.active), (2, 1));
let result = s.execute(
"command.batch",
&json!({
"commands": [
{"command": "file.close", "params": {"index": 1, "force": true}},
{"command": "node.set", "params": {"id": 999999, "props": {"name": "must fail"}}}
]
}),
);
assert!(result.is_err());
assert_eq!(s.docs.len(), 2);
assert_eq!(s.active, 1);
assert_eq!(s.docs.iter().map(|ds| ds.uid).collect::<Vec<_>>(), uids);
assert!(s.ds().is_ok());
assert!(s.closed_in_group.is_empty());
}
#[test]
fn failed_plugin_run_restores_each_document_without_restoring_unrelated_state() {
let mut s = Session::new();
+1 -1
View File
@@ -194,7 +194,7 @@ pub fn tab_of(s: &Session, main: u64, id: &str) -> Option<usize> {
/// branch's). Their edits are already in the branches ([`sync`]).
pub(crate) fn close_tabs(s: &mut Session, main: u64, id: Option<&str>) {
let active = s.docs.get(s.active).map(|d| d.uid);
s.docs.retain(|d| !d.branch_of.as_ref().is_some_and(|(m, b)| *m == main && id.is_none_or(|i| i == b)));
s.remove_tabs(|d| d.branch_of.as_ref().is_some_and(|(m, b)| *m == main && id.is_none_or(|i| i == b)));
s.active = active.and_then(|a| s.docs.iter().position(|d| d.uid == a)).unwrap_or(s.active).min(s.docs.len().saturating_sub(1));
}
+5 -1
View File
@@ -562,7 +562,11 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
n.child_layout.sizing_v = Sizing::Fixed;
}
if let NodeKind::Text(t) = &mut n.kind {
t.auto_resize = if num(p, "height").is_some() { TextAutoResize::None } else { TextAutoResize::Height };
if num(p, "height").is_some() {
t.fix_size(num(p, "width").is_some(), true);
} else if t.auto_resize != TextAutoResize::Truncate {
t.auto_resize = TextAutoResize::Height;
}
}
super::props::restore_worlds(doc, &kept)?;
}
+2 -2
View File
@@ -317,7 +317,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"export.add",
"Add export setting",
"{ids?, setting? {size: 2x|512w|512h|{by: scale|width|height, value}, suffix?, format?: png|jpg|svg|pdf|webp, bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options)}} (without a setting: 1x, then one scale step up with an \"@2x\"-style suffix)",
"{ids?, setting? {size: 2x|512w|512h|{by: scale|width|height, value}, suffix?, format?: png|jpg|svg|pdf|webp|xml (Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore)}} (without a setting: 1x, then one scale step up with an \"@2x\"-style suffix)",
true,
|s, p| {
let cmd = "export.add";
@@ -335,7 +335,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"export.update",
"Edit export setting",
"{ids?, index, size? (2x|512w|512h|{by, value}), suffix?, format? (png|jpg|svg|pdf|webp), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options)}",
"{ids?, index, size? (2x|512w|512h|{by, value}), suffix?, format? (png|jpg|svg|pdf|webp|xml: Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore)}",
true,
|s, p| {
let cmd = "export.update";
+94 -26
View File
@@ -56,7 +56,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"file.save",
"Save",
"{path?} (programmatic saves need a path the first time); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview",
"{path?, overwrite? (replace what another app wrote to the file since it was opened or saved: without it that save is refused)} (programmatic saves need a path the first time); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview",
false,
save
),
@@ -77,14 +77,14 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"file.export",
"Export",
"{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp, or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options)}",
"{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings))}",
false,
export
),
spec!(
"file.exportLayers",
"Export layers",
"{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), zip? (no dir: the files come back together as one base64 zip `data` instead), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own} every layer once per export setting, named `<layer><suffix>.<format>` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data}], skipped (layers without settings), data? (zip)}",
"{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), zip? (no dir: the files come back together as one base64 zip `data` instead), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own, vdCompat? (bestEffort|exact|ignore: for every XML instead of each setting's own)} every layer once per export setting, named `<layer><suffix>.<format>` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data, warnings (XML only)}], skipped (layers without settings), data? (zip)}",
false,
export_layers
),
@@ -159,6 +159,8 @@ pub fn specs() -> Vec<CommandSpec> {
}),
spec!("file.revert", "Revert to saved", "{} leave the live session first, if any", false, |s, _| {
let path = s.ds()?.path.clone().ok_or_else(|| EngineError::Disabled("file.revert".into(), "the file was never saved".into()))?;
// Stamped before reading: a change made during the read makes the next save refuse.
let on_disk = crate::library::stat(&path).ok();
let (loaded, _) = load_path(&path, None)?;
let i = s.active;
let uid = s.ds()?.uid;
@@ -167,6 +169,7 @@ pub fn specs() -> Vec<CommandSpec> {
.with_versions(loaded.versions)
.with_branches(loaded.branches)
.with_images_left_in_file(&path, loaded.images_past_budget);
slot.on_disk = on_disk;
}
// Edits on its branch tabs are reverted too.
crate::branches::close_tabs(s, uid, None);
@@ -945,6 +948,8 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
remember(s, &path);
return Ok(json!({"index": i}));
}
// Stamped before reading: a change made during the read makes the next save refuse.
let on_disk = crate::library::stat(&path).ok();
let (loaded, rest) = load_path(&path, variables.as_ref())?;
remember(s, &path);
let native = !rest && matches!(ext(&path).as_str(), "figcraft" | "json");
@@ -958,12 +963,12 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
}
let summary = import_summary(&loaded.doc);
let images_left_out = if native { loaded.images_past_budget } else { 0 };
s.docs.push(
DocState::new(loaded.doc, native.then(|| path.clone()))
.with_versions(loaded.versions)
.with_branches(loaded.branches)
.with_images_left_in_file(&path, images_left_out),
);
let mut ds = DocState::new(loaded.doc, native.then(|| path.clone()))
.with_versions(loaded.versions)
.with_branches(loaded.branches)
.with_images_left_in_file(&path, images_left_out);
ds.on_disk = on_disk.filter(|_| native);
s.docs.push(ds);
s.active = s.docs.len() - 1;
let mut out = json!({"index": s.active, "summary": summary});
out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues);
@@ -1053,7 +1058,9 @@ impl FileSnapshot {
};
let path = self.path.as_deref().ok_or(EngineError::NoDocument)?;
refuse_dropping_images(ds, path)?;
refuse_overwriting_changes(ds, path)?;
write(path, bytes)?;
ds.note_on_disk();
// The history or branches may have changed since: then they still count as unsaved.
if ds.revision == self.revision {
ds.versions_changed = false;
@@ -1075,6 +1082,17 @@ fn refuse_dropping_images(ds: &DocState, path: &str) -> Result<()> {
}
}
/// Saving over the tab's own file would lose what another app wrote to it since (#1330).
fn refuse_overwriting_changes(ds: &DocState, path: &str) -> Result<()> {
match (&ds.on_disk, crate::library::stat(path)) {
(Some(was), Ok(now)) if now != *was => Err(EngineError::Other(format!(
"{} wasn't saved: the file changed on disk since it was opened or saved here (another app or figcraft-cli wrote to it), and saving would replace those changes. Revert to saved to load them, or Save as to keep this version as well.",
file_stem(path)
))),
_ => Ok(()),
}
}
/// The branch tabs of the file in tab `uid` were saved with it.
fn branch_tabs_saved(s: &mut Session, uid: u64) {
for tab in s.docs.iter_mut().filter(|d| d.branch_of.as_ref().is_some_and(|(m, _)| *m == uid)) {
@@ -1137,6 +1155,7 @@ fn save_to(s: &mut Session, path: Option<String>) -> Result<Value> {
write(&path, &bytes)?;
ds.doc = snapshot.doc;
ds.path = Some(path.clone());
ds.note_on_disk();
mark_saved(s, &json!({}))?;
remember(s, &path);
Ok(json!({"path": path, "bytes": bytes.len()}))
@@ -1157,13 +1176,19 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(path) = string(p, "path") {
return save_to(s, Some(path));
}
match s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?.path.clone() {
let ds = s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?;
match ds.path.clone() {
// Earlier builds wrote native data under a .fig name: never overwrite that file with an export.
Some(path) if ext(&path) == "fig" => {
s.request_ui("dialog.saveAs", json!({}));
Err(EngineError::Disabled("file.save".into(), "the saved path ends in .fig: choose Save as or pass `path`".into()))
}
Some(path) => save_to(s, Some(path)),
Some(path) => {
if p.get("overwrite").and_then(Value::as_bool) != Some(true) {
refuse_overwriting_changes(ds, &path)?;
}
save_to(s, Some(path))
}
None => {
s.request_ui("dialog.saveAs", json!({}));
Err(EngineError::Disabled("file.save".into(), "the file has no path yet: pass `path`".into()))
@@ -1246,14 +1271,20 @@ fn slice_container(doc: &Document, slice: NodeId) -> Option<NodeId> {
}
/// [`export_bytes`] with an export setting's format and options (its size is `scale`: see
/// [`export_scale`]): contents only, include bounding box, the SVG options and PDF print settings.
/// [`export_scale`]): contents only, include bounding box, the SVG, PDF and Vector Drawable options.
pub fn export_bytes_with(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scale: f64) -> Result<Vec<u8>> {
export_file(doc, ids, setting, scale).map(|(bytes, _)| bytes)
}
/// [`export_bytes_with`] and what the export changed or left out (Android Vector Drawable only;
/// empty for the other formats).
pub fn export_file(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scale: f64) -> Result<(Vec<u8>, Vec<String>)> {
let nothing = || EngineError::Disabled("file.export".into(), "nothing to export".into());
if ids.is_empty() {
return Err(nothing());
}
let drawn = drawn_layers(doc, ids, setting);
if matches!(setting.format, ExportFormat::Svg | ExportFormat::Pdf) {
if setting.format.is_vector() {
figcraft_render::check_export_depth(doc, &drawn).map_err(|e| EngineError::Other(e.to_string()))?;
}
let area = export_area(doc, ids, setting).ok_or_else(nothing)?;
@@ -1270,22 +1301,37 @@ pub fn export_bytes_with(doc: &Document, ids: &[NodeId], setting: &ExportSetting
simplify_stroke: setting.svg_simplify_stroke,
bounds: Some(area),
};
figcraft_svg::try_export_svg(&doc.in_srgb(), &drawn, &opts).map(String::into_bytes).map_err(|e| match e {
let svg = figcraft_svg::try_export_svg(&doc.in_srgb(), &drawn, &opts).map_err(|e| match e {
figcraft_svg::SvgError::Empty => nothing(),
other => EngineError::Other(other.to_string()),
})
})?;
Ok((svg.into_bytes(), Vec::new()))
}
// Android colours are sRGB too.
ExportFormat::VectorDrawable => {
let compat = match setting.vd_compat {
VdCompat::BestEffort => figcraft_vectordrawable::Compat::BestEffort,
VdCompat::Exact => figcraft_vectordrawable::Compat::Exact,
VdCompat::Ignore => figcraft_vectordrawable::Compat::Ignore,
};
let opts = figcraft_vectordrawable::VdOptions { bounds: Some(area), include_names: setting.svg_id_attribute, compat };
let out = figcraft_vectordrawable::try_export(&doc.in_srgb(), &drawn, &opts).map_err(|e| match e {
figcraft_vectordrawable::VdError::Empty => nothing(),
other => EngineError::Other(other.to_string()),
})?;
Ok((out.xml.into_bytes(), out.warnings))
}
ExportFormat::Pdf => {
let doc = &*doc.in_srgb();
// Frames export one page each; anything else shares one page.
let all_frames = own_area && ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like()));
let groups: Vec<Vec<NodeId>> = if all_frames { ids.iter().map(|i| vec![*i]).collect() } else { vec![drawn] };
pdf_bytes(doc, &groups, &setting.print, (!own_area).then_some(area))
Ok((pdf_bytes(doc, &groups, &setting.print, (!own_area).then_some(area))?, Vec::new()))
}
_ => {
let pix = figcraft_render::render_area(doc, &drawn, area, scale)
.ok_or_else(|| EngineError::Other("render failed (empty or too large)".into()))?;
figcraft_render::encode_export(&pix, setting.format, doc.color_profile).map_err(EngineError::Other)
Ok((figcraft_render::encode_export(&pix, setting.format, doc.color_profile).map_err(EngineError::Other)?, Vec::new()))
}
}
}
@@ -1322,14 +1368,19 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
None => num_or(p, "scale", 1.0).clamp(0.01, ExportSize::MAX_SCALE),
};
let format = setting.format;
let bytes = export_bytes_with(doc, &ids, &setting, scale)?;
match string(p, "path") {
let (bytes, warnings) = export_file(doc, &ids, &setting, scale)?;
let mut out = match string(p, "path") {
Some(path) => {
write(&path, &bytes)?;
Ok(json!({"path": path, "bytes": bytes.len(), "format": format.ext()}))
json!({"path": path, "bytes": bytes.len(), "format": format.ext()})
}
None => Ok(json!({"format": format.ext(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})),
None => json!({"format": format.ext(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)}),
};
// Android Vector Drawables say what they changed or left out (an empty list when nothing).
if format == ExportFormat::VectorDrawable {
out["warnings"] = json!(warnings);
}
Ok(out)
}
/// The whole document as a `.fig` file (experimental: only round trips through our own importer
@@ -1355,7 +1406,7 @@ pub fn fig_bytes(doc: &Document) -> Result<(Vec<u8>, Vec<String>)> {
pub fn export_scale(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> f64 {
// Size-based exports ask for bounds before encoding. Let encoding report the depth error
// without entering a recursive bounds walk here; SVG/PDF do not use this raster scale.
if matches!(setting.format, ExportFormat::Svg | ExportFormat::Pdf) && figcraft_render::check_export_depth(doc, ids).is_err() {
if setting.format.is_vector() && figcraft_render::check_export_depth(doc, ids).is_err() {
return 1.0;
}
export_area(doc, ids, setting).map_or(1.0, |b| setting.size.scale_for(b.width(), b.height()))
@@ -1370,7 +1421,10 @@ pub(super) fn export_setting(cmd: &str, mut e: ExportSetting, p: &Value) -> Resu
e.suffix = s.chars().filter(|c| !c.is_control()).take(200).collect();
}
if let Some(f) = string(p, "format") {
e.format = ExportFormat::from_ext(&f).ok_or_else(|| bad(cmd, "format: png|jpg|svg|pdf|webp"))?;
e.format = ExportFormat::from_ext(&f).ok_or_else(|| bad(cmd, "format: png|jpg|svg|pdf|webp|xml"))?;
}
if let Some(c) = vd_compat_param(cmd, p)? {
e.vd_compat = c;
}
e.print = print_param(cmd, p, e.print)?;
for (key, slot) in [
@@ -1388,6 +1442,14 @@ pub(super) fn export_setting(cmd: &str, mut e: ExportSetting, p: &Value) -> Resu
Ok(e)
}
/// The `vdCompat` `p` passes: bestEffort, exact or ignore (anything else is an error).
fn vd_compat_param(cmd: &str, p: &Value) -> Result<Option<VdCompat>> {
match get(p, "vdCompat") {
None | Some(Value::Null) => Ok(None),
Some(v) => v.as_str().and_then(VdCompat::from_key).map(Some).ok_or_else(|| bad(cmd, "vdCompat: bestEffort|exact|ignore")),
}
}
/// Whether `p` sets any PDF print option.
fn has_print_param(p: &Value) -> bool {
["bleed", "cropMarks", "bleedMarks", "cmyk"].iter().any(|k| get(p, k).is_some_and(|v| !v.is_null()))
@@ -1523,12 +1585,18 @@ fn export_layers(s: &mut Session, p: &Value) -> Result<Value> {
let mut members = Vec::new();
// Print options passed here stand in for every PDF setting's own.
let print = has_print_param(p).then(|| print_param(cmd, p, PrintSettings::default())).transpose()?;
// And a compatibility mode for every Android Vector Drawable.
let compat = vd_compat_param(cmd, p)?;
let mut files = Vec::with_capacity(jobs.len());
for job in &jobs {
let setting = ExportSetting { print: print.unwrap_or(job.setting.print), ..job.setting.clone() };
let setting =
ExportSetting { print: print.unwrap_or(job.setting.print), vd_compat: compat.unwrap_or(job.setting.vd_compat), ..job.setting.clone() };
let scale = export_scale(doc, &[job.id], &setting);
let bytes = export_bytes_with(doc, &[job.id], &setting, scale)?;
let (bytes, warnings) = export_file(doc, &[job.id], &setting, scale)?;
let mut f = json!({"name": job.file, "id": job.id, "bytes": bytes.len()});
if setting.format == ExportFormat::VectorDrawable {
f["warnings"] = json!(warnings);
}
match &dir {
Some(d) => {
let path = std::path::Path::new(d).join(&job.file);
@@ -1731,7 +1799,7 @@ fn close(s: &mut Session, p: &Value) -> Result<Value> {
}
let uid = ds.uid;
let active_uid = s.docs.get(s.active).map(|d| d.uid);
s.docs.remove(i);
s.remove_tabs(|d| d.uid == uid);
// Removing a tab before the active one shifts its index; keep the same document selected.
s.active = active_uid.and_then(|a| s.docs.iter().position(|d| d.uid == a)).unwrap_or(i.min(s.docs.len().saturating_sub(1)));
crate::branches::close_tabs(s, uid, None);
+2 -5
View File
@@ -461,11 +461,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
resize_node(doc, id, nw, nh)?;
// Explicit size makes text fixed in that dimension.
if let Some(t) = doc.get_mut(id)?.kind.text_mut() {
t.auto_resize = if is_w {
if t.auto_resize == TextAutoResize::WidthAndHeight { TextAutoResize::Height } else { t.auto_resize }
} else {
TextAutoResize::None
};
t.fix_size(is_w, !is_w);
}
fix_axis(doc, id, is_w)?;
}
@@ -1220,6 +1216,7 @@ fn set_sizing(cmd: &str, doc: &mut Document, id: NodeId, s: Sizing, horizontal:
t.auto_resize = match (n.child_layout.sizing_h, n.child_layout.sizing_v) {
(Sizing::Hug, _) => TextAutoResize::WidthAndHeight,
(_, Sizing::Hug) => TextAutoResize::Height,
_ if t.truncates() => TextAutoResize::Truncate,
_ => TextAutoResize::None,
};
}
+1 -1
View File
@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"proto.interaction.add",
"Add interaction",
"{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key (a key or a combination: Shift+K, Ctrl+Alt+Delete; or a controller button: Gamepad A|B|X|Y|LB|RB|LT|RT|View|Menu|LS|RS|Up|Down|Left|Right|Home)}}, action {type: navigate {preserveScroll?}|back|scrollTo {offset? [x, y] (where the layer ends up from the top-left)}|openOverlay {position?, offset? [x, y] (position manual: the overlay's top-left from the top-left of the layer that opens it; without it, right below that layer), close_on_outside? (default true), background?}|swapOverlay|closeOverlay|openUrl {url}|changeTo|setVariable {variable, value | expression (text, e.g. \"count + 1\")}|setVariableMode {collection, mode}|conditional {blocks [{condition? | expression? (text, e.g. \"count >= 3\"), actions}]}, dest?}, actions? [..], transition? {type: instant|dissolve|smartAnimate|moveIn|moveOut|push|slideIn|slideOut, direction?: left|right|top|bottom}, durationMs?, easing? linear|easeIn|easeOut|easeInOut|easeInBack|easeOutBack|easeInOutBack|gentle|quick|bouncy|slow|{customBezier {x1, y1, x2, y2}}|{customSpring {mass, stiffness, damping}}}, delayMs? (mouse enter, leave, down and up: how long after the gesture the actions run, up to 600000)} — or {id, dest} for a click-navigate. Returns {index}",
"{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key (a key or a combination: Shift+K, Ctrl+Alt+Delete; or a controller button: Gamepad A|B|X|Y|LB|RB|LT|RT|View|Menu|LS|RS|Up|Down|Left|Right|Home)}}, action {type: navigate {preserveScroll?}|back|scrollTo {offset? [x, y] (where the layer ends up from the top-left)}|openOverlay {position?, anchor? screen|triggerInside|triggerOutside (default screen; the trigger ones measure the position preset on the layer that opens the overlay: outside puts top presets above it and bottom presets below it, edges lined up), offset? [x, y] (position manual: the overlay's top-left from the top-left of the layer that opens it; without it, right below that layer), close_on_outside? (default true), background?}|swapOverlay|closeOverlay|openUrl {url}|changeTo|setVariable {variable, value | expression (text, e.g. \"count + 1\")}|setVariableMode {collection, mode}|conditional {blocks [{condition? | expression? (text, e.g. \"count >= 3\"), actions}]}, dest?}, actions? [..], transition? {type: instant|dissolve|smartAnimate|moveIn|moveOut|push|slideIn|slideOut, direction?: left|right|top|bottom}, durationMs?, easing? linear|easeIn|easeOut|easeInOut|easeInBack|easeOutBack|easeInOutBack|gentle|quick|bouncy|slow|{customBezier {x1, y1, x2, y2}}|{customSpring {mass, stiffness, damping}}}, delayMs? (mouse enter, leave, down and up: how long after the gesture the actions run, up to 600000)} — or {id, dest} for a click-navigate. Returns {index}",
true,
add
),
+15 -9
View File
@@ -291,15 +291,21 @@ pub fn specs() -> Vec<CommandSpec> {
let v = finite(req_num("text.paragraphSpacing", p, "spacing")?).clamp(0.0, 10_000.0);
para(s, p, |t| t.paragraph_spacing = v)
}),
spec!("text.maxLines", "Max lines", "{lines (0 = no limit; truncates), ids?}", true, |s, p| {
let v = finite(req_num("text.maxLines", p, "lines")?).clamp(0.0, 10_000.0) as u32;
para(s, p, |t| {
t.max_lines = v;
if v > 0 {
t.auto_resize = TextAutoResize::Truncate;
}
})
}),
spec!(
"text.maxLines",
"Max lines",
"{lines (0 = no limit; truncates: auto-height text hugs up to that many lines, other text becomes a truncating box), ids?}",
true,
|s, p| {
let v = finite(req_num("text.maxLines", p, "lines")?).clamp(0.0, 10_000.0) as u32;
para(s, p, |t| {
t.max_lines = v;
if v > 0 && t.auto_resize != TextAutoResize::Height {
t.auto_resize = TextAutoResize::Truncate;
}
})
}
),
spec!(
"text.setFeatures",
"OpenType features",
+142 -4
View File
@@ -1,6 +1,6 @@
//! Variables import and export: a file's collections as the local variables JSON (the shape the
//! REST importer reads, so exports round-trip) or as CSS custom properties, and such JSON read
//! back into a file.
//! REST importer reads, so exports round-trip), as CSS custom properties or as Dart constants for
//! Flutter, and such JSON read back into a file.
use std::collections::{BTreeMap, BTreeSet};
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"variable.export",
"Export variables",
"{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-<collection>=\"<mode>\"]; numbers in px unless layers or scopes use them as opacity or font weight | tailwind: a Tailwind CSS v4 stylesheet, the default mode as an @theme block named the way inspect.code's tailwind utilities read them: --color-*, --radius-*, --text-*, --spacing-*, --font-*, unique per file), path? (write it there)} → {text}, or {path} when written",
"{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-<collection>=\"<mode>\"]; numbers in px unless layers or scopes use them as opacity or font weight | tailwind: a Tailwind CSS v4 stylesheet, the default mode as an @theme block named the way inspect.code's tailwind utilities read them: --color-*, --radius-*, --text-*, --spacing-*, --font-*, unique per file) | dart: a Dart file for Flutter, a class of constants per collection and mode (Color, double, String, bool), aliases resolved), path? (write it there)} → {text}, or {path} when written",
false,
export
),
@@ -48,7 +48,8 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
.map_err(|e| EngineError::Other(e.to_string()))?,
"css" => css(doc, &colls, false),
"tailwind" => css(doc, &colls, true),
_ => return Err(bad(cmd, "format: json|css|tailwind")),
"dart" => dart(&doc.variables, &colls),
_ => return Err(bad(cmd, "format: json|css|tailwind|dart")),
};
match string(p, "path") {
Some(path) => {
@@ -108,6 +109,143 @@ fn css(doc: &Document, colls: &[&Collection], tailwind: bool) -> String {
out
}
/// Words Dart never takes as a name.
const DART_RESERVED: &[&str] = &[
"assert", "await", "break", "case", "catch", "class", "const", "continue", "default", "do", "else", "enum", "extends", "false", "final",
"finally", "for", "if", "in", "is", "new", "null", "rethrow", "return", "super", "switch", "this", "throw", "true", "try", "var", "void",
"while", "with", "yield",
];
/// Dart's built-in identifiers (no type may take them), and the types the constants use or name,
/// which a class or constant of that name would shadow.
const DART_BUILT_IN: &[&str] = &[
"abstract",
"as",
"bool",
"Color",
"covariant",
"deferred",
"double",
"dynamic",
"export",
"extension",
"external",
"factory",
"Function",
"get",
"implements",
"import",
"int",
"interface",
"late",
"library",
"List",
"mixin",
"num",
"Object",
"operator",
"part",
"required",
"set",
"static",
"String",
"typedef",
];
/// `name` as a Dart identifier: its words (anything but ASCII letters and digits separates them)
/// in camelCase, or PascalCase for a type; never empty, never starting with a digit or reserved.
fn dart_ident(name: &str, pascal: bool) -> String {
let mut out = String::new();
for w in name.split(|c: char| !c.is_ascii_alphanumeric()).filter(|w| !w.is_empty()) {
let mut cs = w.chars();
let Some(first) = cs.next() else { continue };
if out.is_empty() && !pascal {
out.push(first.to_ascii_lowercase());
} else {
out.push(first.to_ascii_uppercase());
}
out.extend(cs);
}
if out.is_empty() || out.starts_with(|c: char| c.is_ascii_digit()) {
out.insert(0, if pascal { 'V' } else { 'v' });
}
if DART_RESERVED.contains(&out.as_str()) || DART_BUILT_IN.contains(&out.as_str()) {
out.push('_');
}
out
}
/// `base`, or `base2`, `base3`… when `taken` has it already; the result is taken.
fn dart_unique(base: String, taken: &mut BTreeSet<String>) -> String {
let mut name = base.clone();
let mut n = 2;
while !taken.insert(name.clone()) {
name = format!("{base}{n}");
n += 1;
}
name
}
/// `text` inside a Dart single-quoted string.
fn dart_string(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
match c {
'\\' => out.push_str("\\\\"),
'\'' => out.push_str("\\'"),
'$' => out.push_str("\\$"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
c if c.is_control() => out.push_str(&format!("\\u{{{:x}}}", u32::from(c))),
c => out.push(c),
}
}
out
}
/// `x` at full precision as a Dart literal: whole numbers as integers (`4`), the rest in Rust's
/// shortest round-trip form (`0.125`, `1e21`), which Dart reads as the same double.
fn dart_number(x: f64) -> String {
if x.fract() == 0.0 && x.abs() < 1e15 { format!("{x}") } else { format!("{x:?}") }
}
/// `text` on one line of a Dart comment.
fn dart_comment(text: &str) -> String {
text.chars().map(|c| if c.is_control() { ' ' } else { c }).collect()
}
/// Each collection's variables as Dart constants for Flutter: a class per mode (the default mode's
/// named after the collection, the others after the collection and the mode) with a `static const`
/// per variable. Aliases are resolved to their value in that mode (a variable of another
/// collection in that collection's default mode), so each class stands alone.
fn dart(vars: &Variables, colls: &[&Collection]) -> String {
let mut out = String::from("// Variables exported from FigCraft.\n\nimport 'dart:ui';\n");
let mut classes = BTreeSet::new();
for c in colls {
let base = dart_ident(&c.name, true);
let mut members = BTreeSet::new();
let names: Vec<(&Variable, String)> =
vars.variables.iter().filter(|v| v.collection == c.id).map(|v| (v, dart_unique(dart_ident(&v.name, false), &mut members))).collect();
for (i, m) in c.modes.iter().enumerate() {
let class = dart_unique(if i == 0 { base.clone() } else { format!("{base}{}", dart_ident(&m.name, true)) }, &mut classes);
out.push_str(&format!("\n/// {} · {}\nabstract final class {class} {{\n", dart_comment(&c.name), dart_comment(&m.name)));
let modes = BTreeMap::from([(c.id.clone(), m.id.clone())]);
for (v, name) in &names {
let (ty, value) = match vars.resolve(&v.id, &modes) {
Some(VariableValue::Color(col)) => ("Color", crate::codegen::kotlin_color(col)),
Some(VariableValue::Number(x)) if x.is_finite() => ("double", dart_number(x)),
Some(VariableValue::String(t)) => ("String", format!("'{}'", dart_string(&t))),
Some(VariableValue::Boolean(b)) => ("bool", b.to_string()),
_ => continue,
};
out.push_str(&format!(" static const {ty} {name} = {value};\n"));
}
out.push_str("}\n");
}
}
out
}
fn import(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "variable.import";
let v =
+150 -9
View File
@@ -37,7 +37,8 @@ const MAX_WARNINGS: usize = 50;
/// The languages a component can be connected in.
pub fn language(s: &str) -> Option<CodeLang> {
CodeLang::from_id(s).filter(|l| matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi))
CodeLang::from_id(s)
.filter(|l| matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::Vue | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi))
}
/// The layer `id`'s links are stored on: its component set, else its component (an instance's
@@ -66,6 +67,8 @@ pub fn import_line(link: &CodeLink, lang: CodeLang) -> String {
_ if path.is_empty() => String::new(),
CodeLang::React | CodeLang::ArkUi => format!("import {{ {} }} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)),
CodeLang::Leptos => format!("use {path}::{};", link.name.split('.').next().unwrap_or_default()),
// A single-file component is a default export.
CodeLang::Vue => format!("import {} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)),
_ => format!("import {path}"),
}
}
@@ -266,6 +269,8 @@ fn expr(doc: &Document, c: &Component, p: &Param, v: &Value, lang: CodeLang) ->
match (swapped, lang) {
(Some(l), CodeLang::React) => format!("<{} />", l.name),
(Some(l), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", l.name),
// A Vue binding takes the component itself.
(Some(l), CodeLang::Vue) => l.name.clone(),
(Some(l), CodeLang::SwiftUi) => format!("{}()", l.name),
(Some(l), CodeLang::ArkUi) => format!("() => {{ {}() }}", l.name),
(Some(l), _) => format!("{{ {}() }}", l.name),
@@ -280,22 +285,155 @@ fn pieces(t: &str) -> Vec<Piece<'_>> {
checkable.then(|| parse(t).ok()).flatten().unwrap_or_else(|| vec![Piece::Text(t)])
}
/// Whether `s` ends inside an unclosed opening tag (no `>` outside quotes since the last `<`).
fn inside_open_tag(s: &str) -> bool {
let mut in_tag = false;
let mut quote = None;
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
match quote {
Some(q) => {
if c == q {
quote = None;
}
}
None => match c {
'<' if !in_tag && chars.peek().is_some_and(|ch| ch.is_ascii_alphabetic() || matches!(*ch, '/' | '!' | '?')) => {
in_tag = true;
}
'>' if in_tag => in_tag = false,
'"' | '\'' if in_tag => quote = Some(c),
_ => {}
},
}
}
in_tag
}
/// Where a placeholder sits in a Vue template: whether the attribute is bound, and the quote the
/// template already opened (if any). `None` when it isn't in an attribute at all.
fn vue_attribute(out: &str) -> Option<(bool, Option<char>)> {
let end = out.trim_end();
if !inside_open_tag(end) {
return None;
}
let (after, quote) = match end.chars().last() {
Some(q @ ('"' | '\'')) => (&end[..end.len() - q.len_utf8()], Some(q)),
_ => (end, None),
};
let after = after.trim_end();
let eq = after.rfind('=')?;
// The placeholder must follow the `=` (or its opening quote) directly; text after a closed
// attribute (`<Card title='Buy'>`) is template content, not an attribute value.
if !after[eq + 1..].trim().is_empty() {
return None;
}
let before = after[..eq].trim_end();
let name: String = before
.chars()
.rev()
.take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | ':' | '@' | '.' | '[' | ']'))
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
if name.is_empty() {
return None;
}
let bound = name.starts_with(':') || name.starts_with("v-bind:") || name.starts_with('@') || name.starts_with("v-on:") || name.starts_with("v-");
Some((bound, quote))
}
/// An expression written into an HTML attribute opened with `quote`: its own delimiter and markup
/// become entities, so Vue's parser hands the expression its string literal intact.
fn html_attr_expr(e: &str, quote: Option<char>) -> String {
let e = e.replace('&', "&amp;").replace('<', "&lt;");
match quote {
Some('\'') => e.replace('\'', "&#39;"),
_ => e.replace('"', "&quot;"),
}
}
/// Literal text written into an HTML attribute opened with `quote`.
fn html_attr_text(s: &str, quote: Option<char>) -> String {
let s = s.replace('&', "&amp;").replace('<', "&lt;");
match quote {
Some('\'') => s.replace('\'', "&#39;"),
_ => s.replace('"', "&quot;"),
}
}
/// Literal text in Vue template content: braces would start an interpolation, so they are escaped.
fn html_text(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('{', "&#123;").replace('}', "&#125;")
}
/// `link`'s template with `values` (one per parameter of `c`), every line prefixed by `pad`.
pub(crate) fn render(doc: &Document, c: &Component, values: &[(String, Value)], link: &CodeLink, lang: CodeLang, pad: &str) -> String {
let mut out = String::new();
// A quoted attribute stays open across substitutions: `title="{a} {b}"` must treat both
// placeholders as attribute values, not text after the first one.
let mut vue_attr: Option<(bool, Option<char>)> = None;
for piece in pieces(&link.template) {
match piece {
Piece::Text(t) => out.push_str(t),
Piece::Text(t) => {
out.push_str(t);
if let Some((_, Some(q))) = vue_attr
&& t.contains(q)
{
vue_attr = None;
}
}
Piece::Value { name, inner } => match find(c, values, name) {
Some((p, v)) => {
let e = expr(doc, c, p, v, lang);
// JSX and `view!`: a plain string reads best as an attribute value, anything
// else is an expression in braces (JSX attribute strings have no escapes).
let markup = matches!(lang, CodeLang::React | CodeLang::Leptos);
if !markup || (out.ends_with('=') && e.starts_with('"') && !e.contains('\\')) {
out.push_str(&e);
// Vue's bound attributes take an expression, so a string keeps its JS quotes
// inside the attribute (as entities); a static attribute takes plain text.
if lang == CodeLang::Vue {
match vue_attr.or_else(|| vue_attribute(&out)) {
Some((bound, quote)) => {
if bound {
let expr = html_attr_expr(&e, quote);
if quote.is_some() {
out.push_str(&expr);
} else {
out.push('"');
out.push_str(&expr);
out.push('"');
}
} else {
let text = match &p.kind {
ParamKind::Text | ParamKind::Variant(_) => html_attr_text(v.as_str().unwrap_or(""), quote),
_ => e.clone(),
};
if quote.is_some() {
out.push_str(&text);
} else {
out.push('"');
out.push_str(&text);
out.push('"');
}
}
vue_attr = quote.map(|q| (bound, Some(q)));
}
None => {
let text = match &p.kind {
ParamKind::Text | ParamKind::Variant(_) => html_text(v.as_str().unwrap_or("")),
_ => e.clone(),
};
out.push_str(&text);
vue_attr = None;
}
}
} else {
out.push_str(&format!("{{{e}}}"));
let markup = matches!(lang, CodeLang::React | CodeLang::Leptos);
if !markup || (out.ends_with('=') && e.starts_with('"') && !e.contains('\\')) {
out.push_str(&e);
} else {
out.push_str(&format!("{{{e}}}"));
}
}
}
None => out.push_str(&format!("{{{inner}}}")),
@@ -317,6 +455,7 @@ fn default_template(c: &Component, lang: CodeLang, name: &str) -> String {
CodeLang::Leptos => {
format!("<{name}{} />", params.iter().map(|p| format!(" {}={{{p}}}", crate::component_codegen::rust_ident(p))).collect::<String>())
}
CodeLang::Vue => format!("<{name}{} />", params.iter().map(|p| format!(" :{p}={{{p}}}")).collect::<String>()),
CodeLang::SwiftUi => format!("{name}({})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::<Vec<_>>().join(", ")),
CodeLang::ArkUi => format!("{name}({{ {} }})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::<Vec<_>>().join(", ")),
_ => format!("{name}({})", params.iter().map(|p| format!("{p} = {{{p}}}")).collect::<Vec<_>>().join(", ")),
@@ -338,6 +477,7 @@ pub fn check(c: &Component, link: &CodeLink, lang: CodeLang) -> std::result::Res
let path_ok = match lang {
CodeLang::React | CodeLang::ArkUi => !path.chars().any(|ch| ch.is_control() || ch == '"' || ch == '\\'),
CodeLang::Leptos => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == ':'),
CodeLang::Vue => path.chars().all(|ch| ch.is_ascii_alphanumeric() || "_./@-".contains(ch)),
_ => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.'),
};
if path.chars().count() > MAX_IMPORT || !path_ok {
@@ -345,6 +485,7 @@ pub fn check(c: &Component, link: &CodeLink, lang: CodeLang) -> std::result::Res
CodeLang::React => format!("importPath: a module path without quotes, up to {MAX_IMPORT} characters"),
CodeLang::ArkUi => format!("importPath: a module path without quotes, up to {MAX_IMPORT} characters"),
CodeLang::Leptos => format!("importPath: a Rust module path (letters, digits, _ and ::), up to {MAX_IMPORT} characters"),
CodeLang::Vue => format!("importPath: a module path (letters, digits, _ . / @ and -), up to {MAX_IMPORT} characters"),
_ => format!("importPath: a module or package name (letters, digits, _ and .), up to {MAX_IMPORT} characters"),
});
}
@@ -380,7 +521,7 @@ fn text(cmd: &str, p: &Value, key: &str) -> Result<Option<String>> {
/// The `language` parameter (default React).
fn lang_param(cmd: &str, p: &Value) -> Result<CodeLang> {
match text(cmd, p, "language")? {
Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|swiftui|compose|arkui")),
Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|vue|swiftui|compose|arkui")),
None => Ok(CodeLang::React),
}
}
@@ -410,7 +551,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"codeConnect.set",
"Connect to code",
"{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose|arkui (default react), importPath?, name? (default the generated name), template? (default a call passing every property)} links the component (its set, for a variant) to its code component; fields left out keep their current value. Placeholders: {property} (its name or parameter name) and {property ? \"a\" : \"b\"}; other {…} stay as written → {component, language, code (the snippet for the layer), unknown (placeholder names that aren't properties)}",
"{id? (component, set or instance; default the selection), language?: react|leptos|vue|swiftui|compose|arkui (default react), importPath?, name? (default the generated name), template? (default a call passing every property)} links the component (its set, for a variant) to its code component; fields left out keep their current value. Placeholders: {property} (its name or parameter name) and {property ? \"a\" : \"b\"}; other {…} stay as written → {component, language, code (the snippet for the layer), unknown (placeholder names that aren't properties)}",
true,
|s, p| {
let cmd = "codeConnect.set";
@@ -525,7 +666,7 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
let (Some(component), Some(lang), Some(name), Some(template)) =
(field("component"), field("language").and_then(language), field("name"), e.get("template").and_then(Value::as_str))
else {
warnings.push(format!("link {}: needs component, language (react|leptos|swiftui|compose|arkui), name and template strings", i + 1));
warnings.push(format!("link {}: needs component, language (react|leptos|vue|swiftui|compose|arkui), name and template strings", i + 1));
continue;
};
let link = CodeLink {
+76
View File
@@ -193,3 +193,79 @@ fn code_connect_commands_survive_junk() {
}
}
}
#[test]
fn vue_links_are_a_default_import_with_quoted_bindings() {
let mut s = Session::new();
let c = card(&mut s);
let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0})));
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Show dot": false, "Title": "Buy"}}));
// A single-file component is a default import. A bound attribute takes an expression, so a
// string keeps its JS quotes inside the attribute (as entities): `Buy` alone would be a
// variable, not the string.
let v = ex(
&mut s,
"codeConnect.set",
json!({"id": i, "language": "vue", "importPath": "@/components/Card.vue",
"template": "<Card :title={title} :dotted={Show dot} />"}),
);
let want = "import Card from \"@/components/Card.vue\";\n\n<Card :title=\"&quot;Buy&quot;\" :dotted=\"false\" />\n";
assert_eq!(v["code"], want);
assert_eq!(code(&mut s, i, "vue"), want);
assert_eq!(ex(&mut s, "inspect.code", json!({"id": i, "language": "vue"}))["connected"], true);
// No template given: the generated one binds a quoted value for every property.
ex(&mut s, "codeConnect.remove", json!({"id": c, "language": "vue"}));
ex(&mut s, "codeConnect.set", json!({"id": c, "language": "vue", "importPath": "./Card.vue"}));
assert_eq!(code(&mut s, c, "vue"), "import Card from \"./Card.vue\";\n\n<Card :showDot=\"true\" :title=\"&quot;Hello&quot;\" />\n");
// A plain string attribute (no `:`) takes the text itself, and a variant binding is a string
// expression like any other.
ex(&mut s, "codeConnect.set", json!({"id": i, "language": "vue", "template": "<Card title={title} :dotted={Show dot} />"}));
assert_eq!(code(&mut s, i, "vue"), "import Card from \"./Card.vue\";\n\n<Card title=\"Buy\" :dotted=\"false\" />\n");
let (set, hover) = button(&mut s);
let b = idv(&ex(&mut s, "instance.create", json!({"component": hover, "x": 0, "y": 700})));
ex(&mut s, "codeConnect.set", json!({"id": b, "language": "vue", "template": "<Button :state={state} />"}));
assert_eq!(code(&mut s, b, "vue"), "<Button :state=\"&quot;Hover&quot;\" />\n");
assert_eq!(id_of(&ex(&mut s, "codeConnect.set", json!({"id": set}))["component"]), Some(set));
// A string with escapes stays one value: the attribute delimiter is what gets escaped, so the
// binding stays a single well-formed attribute.
ex(&mut s, "codeConnect.set", json!({"id": i, "language": "vue", "template": "<Card :title={title} :dotted={Show dot} />"}));
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Say \"hi\""}}));
let escaped = code(&mut s, i, "vue");
let line = escaped.lines().find(|l| l.contains("&quot;")).unwrap_or_default();
assert!(line.starts_with("<Card :title=\"&quot;Say"), "{line}");
assert!(line.contains(":dotted=\"false\""), "{line}");
assert!(line.ends_with("\" />"), "{line}");
// A single-quoted attribute keeps its delimiter (the expression's double quotes stay), and a
// placeholder in template content is text, not a quoted string.
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Buy"}}));
ex(&mut s, "codeConnect.set", json!({"id": i, "language": "vue", "template": "<Card :title='{title}' :dotted='{Show dot}' />"}));
assert_eq!(code(&mut s, i, "vue"), "import Card from \"./Card.vue\";\n\n<Card :title='\"Buy\"' :dotted='false' />\n");
ex(&mut s, "codeConnect.set", json!({"id": i, "language": "vue", "template": "<Card title='{title}'>{title}</Card>"}));
assert_eq!(code(&mut s, i, "vue"), "import Card from \"./Card.vue\";\n\n<Card title='Buy'>Buy</Card>\n");
// A second placeholder in the same quoted attribute is still attribute text, and its value's
// quotes are escaped.
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Say \"hi\""}}));
ex(&mut s, "codeConnect.set", json!({"id": i, "language": "vue", "template": "<Card title=\"{title} {title}\" />"}));
assert_eq!(code(&mut s, i, "vue"), "import Card from \"./Card.vue\";\n\n<Card title=\"Say &quot;hi&quot; Say &quot;hi&quot;\" />\n");
// Text after a completed tag is text, even when it contains `=`.
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Buy"}}));
ex(&mut s, "codeConnect.set", json!({"id": i, "language": "vue", "template": "<p>Total = {title}</p>"}));
assert_eq!(code(&mut s, i, "vue"), "import Card from \"./Card.vue\";\n\n<p>Total = Buy</p>\n");
// Vue is linkable; a language that isn't stays refused.
assert!(s.execute("codeConnect.set", &json!({"id": c, "language": "css"})).is_err(), "not a code language");
assert!(s.execute("codeConnect.set", &json!({"id": c, "language": "vue", "importPath": "a b"})).is_err(), "no spaces in a path");
}
#[test]
fn vue_layer_trees_import_connected_components_in_script_setup() {
let mut s = Session::new();
let c = card(&mut s);
ex(&mut s, "codeConnect.set", json!({"id": c, "language": "vue", "importPath": "./Card.vue"}));
let screen = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 500, "width": 400, "height": 300, "name": "Screen"})));
ex(&mut s, "instance.create", json!({"component": c, "x": 20, "y": 30, "parent": screen}));
// A single-file component's imports belong in its `<script setup>`, not before the template.
let code = code(&mut s, screen, "vue");
let want = "<script setup lang=\"ts\">\n\nimport Card from \"./Card.vue\";\n\n</script>\n\n<template>\n";
assert!(code.starts_with(want), "{code}");
assert_eq!(code.matches("import Card").count(), 1, "{code}");
}
+188 -34
View File
@@ -18,13 +18,13 @@ pub fn specs() -> Vec<CommandSpec> {
vec![spec!(
"inspect.code",
"Code for layer",
"{id?, language?: css|html|react|leptos|tailwind|swiftui|compose|arkui (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead; tailwind is React with Tailwind CSS v4 utilities for pages: fluid auto layout boxes, instances inlined with their overrides, bound variables as theme utilities), mobile? (tailwind: a mobile frame with the same layer names; the code is mobile first with the desktop layer's classes behind lg:), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}",
"{id?, language?: css|html|react|leptos|vue|tailwind|swiftui|compose|arkui (default css; html, react, leptos and vue are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead; tailwind is React with Tailwind CSS v4 utilities for pages: fluid auto layout boxes, instances inlined with their overrides, bound variables as theme utilities), mobile? (tailwind: a mobile frame with the same layer names; the code is mobile first with the desktop layer's classes behind lg:), unit?: px|rem (CSS, HTML, React, Leptos and Vue; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}",
false,
|s, p| {
let cmd = "inspect.code";
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?;
let lang = match string(p, "language") {
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|tailwind|swiftui|compose|arkui"))?,
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|vue|tailwind|swiftui|compose|arkui"))?,
None => CodeLang::Css,
};
let rem = match string(p, "unit").map(|u| u.to_ascii_lowercase()).as_deref() {
@@ -68,6 +68,8 @@ pub enum CodeLang {
React,
/// A Leptos component (`view!` markup) with its stylesheet.
Leptos,
/// A Vue single-file component (`<template>`, `<script setup>`, `<style scoped>`).
Vue,
/// A React function component styled with Tailwind CSS v4 utilities (no stylesheet).
Tailwind,
SwiftUi,
@@ -77,14 +79,24 @@ pub enum CodeLang {
}
impl CodeLang {
pub const ALL: [CodeLang; 8] =
[CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::Tailwind, CodeLang::SwiftUi, CodeLang::Compose, CodeLang::ArkUi];
pub const ALL: [CodeLang; 9] = [
CodeLang::Css,
CodeLang::Html,
CodeLang::React,
CodeLang::Leptos,
CodeLang::Vue,
CodeLang::Tailwind,
CodeLang::SwiftUi,
CodeLang::Compose,
CodeLang::ArkUi,
];
pub fn id(self) -> &'static str {
match self {
CodeLang::Css => "css",
CodeLang::Html => "html",
CodeLang::React => "react",
CodeLang::Leptos => "leptos",
CodeLang::Vue => "vue",
CodeLang::Tailwind => "tailwind",
CodeLang::SwiftUi => "swiftui",
CodeLang::Compose => "compose",
@@ -97,6 +109,7 @@ impl CodeLang {
CodeLang::Html => "HTML + CSS",
CodeLang::React => "React",
CodeLang::Leptos => "Leptos",
CodeLang::Vue => "Vue",
CodeLang::Tailwind => "React + Tailwind",
CodeLang::SwiftUi => "SwiftUI",
CodeLang::Compose => "Compose",
@@ -106,12 +119,15 @@ impl CodeLang {
/// Languages whose lengths are CSS pixels that can be shown in rem (not Tailwind: its lengths
/// are utility classes).
pub fn is_web(self) -> bool {
matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos)
matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos | CodeLang::Vue)
}
pub fn from_id(s: &str) -> Option<CodeLang> {
if s.eq_ignore_ascii_case("tsx") || s.eq_ignore_ascii_case("jsx") {
return Some(CodeLang::React);
}
if s.eq_ignore_ascii_case("sfc") || s.eq_ignore_ascii_case("vue3") {
return Some(CodeLang::Vue);
}
if s.eq_ignore_ascii_case("arkts") {
return Some(CodeLang::ArkUi);
}
@@ -137,6 +153,12 @@ pub(crate) fn num(v: f64) -> String {
}
}
/// `v` without trailing zeros (6 decimals), for lengths that must convert back to the same pixels.
fn num6(v: f64) -> String {
let r = (v * 1_000_000.0).round() / 1_000_000.0;
if r.is_finite() && r != 0.0 { format!("{r}") } else { "0".into() }
}
/// `code` with every pixel length (`12px`) in rem of `root` pixels (`0.75rem`); names that end
/// in "px", markup lines (layer text), string literals and comments are left alone.
pub fn px_to_rem(code: &str, root: f64) -> String {
@@ -202,7 +224,7 @@ fn lengths_to_rem(code: &str, root: f64) -> String {
match number.parse::<f64>() {
Ok(v) if free && v.is_finite() => {
out.push_str(before);
out.push_str(&format!("{}rem", num(v / root)));
out.push_str(&format!("{}rem", num6(v / root)));
}
_ => {
out.push_str(head);
@@ -220,7 +242,9 @@ pub fn code_for(doc: &Document, id: NodeId, lang: CodeLang) -> String {
CodeLang::Css => css(doc, id),
CodeLang::Html => html(doc, id),
CodeLang::Tailwind => crate::tailwind::code(doc, id, None),
CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi => crate::component_codegen::code(doc, id, lang),
CodeLang::React | CodeLang::Leptos | CodeLang::Vue | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi => {
crate::component_codegen::code(doc, id, lang)
}
}
}
@@ -261,12 +285,13 @@ impl<'a> Ctx<'a> {
depth > self.base + MAX_DEPTH
}
/// A call for an instance below the root (`None` for other layers, or a missing main).
fn call(&self, n: &Node, lang: CodeLang, depth: usize) -> Option<String> {
/// A call for an instance below the root (`None` for other layers, or a missing main), with
/// the CSS its slot content generated.
fn call(&self, n: &Node, lang: CodeLang, depth: usize) -> Option<(String, String)> {
if n.id == self.root || !matches!(n.kind, NodeKind::Instance { .. }) {
return None;
}
crate::component_codegen::usage(self.doc, n.id, lang, depth)
crate::component_codegen::usage_with_styles(self.doc, n.id, lang, depth)
}
}
@@ -627,23 +652,94 @@ fn css_gradient(g: &figcraft_color::Gradient, w: f64, h: f64) -> String {
// ------------------------------------------------------------------ HTML and React markup
/// A CSS class name from a layer name ("Card / Title" → "card-title"), unique in `used`.
pub(crate) fn class_name(name: &str, used: &mut Vec<String>) -> String {
/// A word for the node's kind, naming layers whose name transliterates to nothing ("###" →
/// `.text`, `.frame`), instead of the anonymous `layer-`.
fn kind_word(n: &Node) -> &str {
match &n.kind {
NodeKind::Page { .. } => "page",
NodeKind::Frame => "frame",
NodeKind::Group => "group",
NodeKind::Section { .. } => "section",
NodeKind::Rectangle => "rectangle",
NodeKind::Ellipse { .. } => "ellipse",
NodeKind::Polygon { .. } => "polygon",
NodeKind::Star { .. } => "star",
NodeKind::Line => "line",
NodeKind::Vector { .. } => "vector",
NodeKind::Text(_) => "text",
NodeKind::BooleanOp { .. } => "boolean",
NodeKind::Component { .. } | NodeKind::ComponentSet { .. } => "component",
NodeKind::Instance { .. } => "instance",
NodeKind::Slice => "slice",
}
}
/// A CSS class name from a layer name ("Card / Title" → "card-title"), unique in `used`. The name
/// is transliterated to ASCII first, so non-Latin names stay readable: 로그인 화면 →
/// `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` (per-codepoint transliteration, the slug
/// libraries' way); a name left with nothing (symbols only) takes `kind`, and one starting with a
/// digit keeps the `layer-` prefix.
pub(crate) fn class_name(name: &str, kind: &str, used: &mut Vec<String>) -> String {
let mut base = String::new();
for c in name.chars().flat_map(char::to_lowercase) {
for c in deunicode::deunicode(name).chars().flat_map(char::to_lowercase) {
if c.is_ascii_alphanumeric() {
base.push(c);
} else if !base.ends_with('-') && !base.is_empty() {
base.push('-');
}
}
let base = base.trim_end_matches('-').chars().take(40).collect::<String>();
let base = if base.is_empty() || base.starts_with(|c: char| c.is_ascii_digit()) { format!("layer-{base}") } else { base };
// The 40-character cut lands on a word boundary when there is one past the first word.
let base = if base.chars().count() > 40 {
let cut: String = base.chars().take(40).collect();
match cut.rfind('-') {
Some(i) if i > 0 => cut[..i].to_string(),
_ => cut,
}
} else {
base
};
let base = base.trim_end_matches('-');
let base = if base.is_empty() {
kind.to_string()
} else if base.starts_with(|c: char| c.is_ascii_digit()) {
format!("layer-{base}")
} else {
base.to_string()
};
let name = (1..).map(|k| if k == 1 { base.clone() } else { format!("{base}-{k}") }).find(|n| !used.contains(n)).unwrap_or(base);
used.push(name.clone());
name
}
/// The index of `markup`'s first tag-closing `>`, skipping `>` inside quoted attribute values.
fn tag_end(markup: &str) -> Option<usize> {
let mut quote = None;
for (i, c) in markup.char_indices() {
match quote {
Some(q) => {
if c == q {
quote = None;
}
}
None => match c {
'"' | '\'' => quote = Some(c),
'>' => return Some(i),
_ => {}
},
}
}
None
}
/// `markup`'s first element with a Vue `v-if` attribute (a call with no placing wrapper).
fn with_v_if(markup: &str, guard: Option<&str>) -> String {
let Some(guard) = guard else { return markup.to_string() };
let Some(end) = tag_end(markup) else { return markup.to_string() };
let before = markup[..end].trim_end();
let at = if before.ends_with('/') { before.len() - 1 } else { end };
format!("{} v-if=\"{guard}\"{}", &markup[..at], &markup[at..])
}
fn html_escape(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('\n', "<br>")
}
@@ -675,12 +771,13 @@ fn html(doc: &Document, id: NodeId) -> String {
format!("{}\n<style>\n{}{}</style>\n", out.markup, out.tokens.root_rule(), out.styles)
}
/// Markup being built for a layer tree: HTML (`class`), React JSX (`className`) or Leptos `view!`
/// markup (text as Rust string literals); the last two with component parameters and instances
/// as calls. With its stylesheet.
/// Markup being built for a layer tree: HTML (`class`), React JSX (`className`), Vue
/// (`{{ }}` interpolation, `v-if`, `:attr` bindings) or Leptos `view!` markup (text as Rust
/// string literals); the last three with component parameters and instances as calls. With its
/// stylesheet.
pub(crate) struct Markup<'c, 'a> {
ctx: &'c Ctx<'a>,
/// Html, React or Leptos.
/// Html, React, Vue or Leptos.
lang: CodeLang,
used: Vec<String>,
pub markup: String,
@@ -689,6 +786,12 @@ pub(crate) struct Markup<'c, 'a> {
/// The root's class (default: from its name) and extra JSX attributes.
pub root_class: Option<String>,
pub root_attrs: String,
/// A Vue `v-if` guard for the element about to be written: Vue templates can't wrap an
/// element in an expression, so a boolean parameter's layer carries the condition itself.
v_if: Option<String>,
/// A prefix for every generated class name (slot content joins the caller's stylesheet, where
/// two slots' layer names may repeat).
pub class_prefix: Option<String>,
/// Each layer's path in the tree (names with sibling counts), class and declarations, so
/// variants can be compared layer by layer.
pub rules: Vec<(String, String, String)>,
@@ -713,6 +816,8 @@ impl<'c, 'a> Markup<'c, 'a> {
tokens: Tokens::default(),
root_class: None,
root_attrs: String::new(),
v_if: None,
class_prefix: None,
rules: Vec::new(),
inline: false,
fluid: false,
@@ -720,9 +825,9 @@ impl<'c, 'a> Markup<'c, 'a> {
}
}
/// JSX indents by two spaces, HTML and Leptos by four.
/// JSX and Vue templates indent by two spaces, HTML and Leptos by four.
fn pad(&self, depth: usize) -> String {
if self.lang == CodeLang::React { " ".repeat(depth) } else { indent(depth) }
if matches!(self.lang, CodeLang::React | CodeLang::Vue) { " ".repeat(depth) } else { indent(depth) }
}
/// Component code (React or Leptos): parameters and instances as calls.
@@ -742,6 +847,13 @@ impl<'c, 'a> Markup<'c, 'a> {
&& id != self.ctx.root
&& let Some(p) = self.ctx.param(n, "visible")
{
// A Vue template holds no arbitrary expressions, so the guard rides on the element.
if self.lang == CodeLang::Vue {
self.v_if = Some(self.ident(p));
self.element(n, parent, depth, path);
self.v_if = None;
return;
}
let (pad, p) = (self.pad(depth), self.ident(p));
let (open, close) =
if self.lang == CodeLang::Leptos { (format!("{{{p}.then(|| view! {{"), "})}") } else { (format!("{{{p} && ("), ")}") };
@@ -770,7 +882,13 @@ impl<'c, 'a> Markup<'c, 'a> {
self.used.push(c.clone());
c
}
None => class_name(&n.name, &mut self.used),
None => {
let class = class_name(&n.name, kind_word(n), &mut self.used);
match &self.class_prefix {
Some(prefix) => format!("{prefix}{class}"),
None => class,
}
}
};
let pad = self.pad(depth);
let attr = if self.lang == CodeLang::React { "className" } else { "class" };
@@ -804,15 +922,20 @@ impl<'c, 'a> Markup<'c, 'a> {
if self.code() && (!self.inline || connected) && !root && matches!(n.kind, NodeKind::Instance { .. }) {
let at = depth + usize::from(!decls.is_empty());
let run = if self.lang == CodeLang::Leptos { ".run()" } else { "" };
let inner = match self.ctx.param(n, "mainComponent") {
Some(p) => Some(format!("{}{{{}{run}}}\n", self.pad(at), self.ident(p))),
None => self.ctx.call(n, self.lang, at),
let (inner, css) = match self.ctx.param(n, "mainComponent") {
Some(p) if self.lang == CodeLang::Vue => (Some(format!("{}<component :is=\"{}\" />\n", self.pad(at), self.ident(p))), String::new()),
Some(p) => (Some(format!("{}{{{}{run}}}\n", self.pad(at), self.ident(p))), String::new()),
None => self.ctx.call(n, self.lang, at).map_or((None, String::new()), |(code, css)| (Some(code), css)),
};
self.styles.push_str(&css);
if let Some(inner) = inner {
// A Vue guard set by `node` rides on the call when there is no wrapper.
let guard = if self.lang == CodeLang::Vue { self.v_if.take() } else { None };
if decls.is_empty() {
self.markup.push_str(&inner);
self.markup.push_str(&with_v_if(&inner, guard.as_deref()));
} else {
self.markup.push_str(&format!("{pad}<div {attr}=\"{class}\">\n{inner}{pad}</div>\n"));
let guard = guard.map(|g| format!(" v-if=\"{g}\"")).unwrap_or_default();
self.markup.push_str(&format!("{pad}<div {attr}=\"{class}\"{guard}>\n{inner}{pad}</div>\n"));
self.rule(path, &class, decls);
}
return;
@@ -831,18 +954,27 @@ impl<'c, 'a> Markup<'c, 'a> {
}
decls.push_str(&own);
self.rule(path, &class, decls);
let attrs = if root { self.root_attrs.clone() } else { String::new() };
// The root's variant attributes, and a Vue `v-if` guard, ride on the open tag.
let mut attrs = if root { self.root_attrs.clone() } else { String::new() };
if self.lang == CodeLang::Vue
&& let Some(guard) = self.v_if.take()
{
attrs.push_str(&format!(" v-if=\"{guard}\""));
}
let open = |tag: &str| format!("{pad}<{tag} {attr}=\"{class}\"{attrs}>");
match &n.kind {
NodeKind::Text(t) => {
let body = match (self.ctx.param(n, "characters").filter(|_| self.code()), self.lang) {
(Some(p), CodeLang::Leptos) => format!("{{{}.clone()}}", self.ident(p)),
(Some(p), CodeLang::Vue) => format!("{{{{ {} }}}}", self.ident(p)),
(Some(p), _) => format!("{{{}}}", p.ident),
(None, CodeLang::React) => jsx_text(&t.characters),
// `view!` takes text as string literals and void elements self-closed.
(None, CodeLang::Leptos) => {
t.characters.chars().take(10_000).collect::<String>().lines().map(|l| format!("{l:?}")).collect::<Vec<_>>().join("<br/>")
}
// A Vue template reads `{{ }}` as interpolation, so braces are escaped.
(None, CodeLang::Vue) => html_escape(&t.characters).replace('{', "&#123;").replace('}', "&#125;"),
(None, _) => html_escape(&t.characters),
};
self.markup.push_str(&format!("{}{body}</p>\n", open("p")));
@@ -850,11 +982,26 @@ impl<'c, 'a> Markup<'c, 'a> {
_ => {
let kids = self.ctx.kids(n);
let slot = self.ctx.param(n, "slot").filter(|_| self.code());
let vue = self.lang == CodeLang::Vue;
let leptos = self.lang == CodeLang::Leptos;
let open = open("div");
let slot_ident = slot.map(|p| match self.ctx.comp {
Some(c) => crate::component_codegen::slot_name(c, p),
None => class_name(&p.ident, "slot", &mut Vec::new()),
});
if let Some(p) = slot.filter(|_| kids.is_empty()) {
let content = if leptos { format!("{}.as_ref().map(|c| c.run())", self.ident(p)) } else { p.ident.clone() };
self.markup.push_str(&format!("{open}{{{content}}}</div>\n"));
let content = if leptos {
format!("{{{}}}.as_ref().map(|c| c.run())", self.ident(p))
} else if vue {
// Vue's own slot element, named so an instance's content lands in the right one.
match slot_ident.clone() {
Some(name) => format!("<slot name=\"{name}\" />"),
None => "<slot />".into(),
}
} else {
format!("{{{}}}", p.ident)
};
self.markup.push_str(&format!("{open}{content}</div>\n"));
} else if kids.is_empty() || self.ctx.too_deep(depth) {
self.markup.push_str(&format!("{open}</div>\n"));
} else {
@@ -862,6 +1009,11 @@ impl<'c, 'a> Markup<'c, 'a> {
// A slot shows the instance's content, or else the main's.
let (p1, p2) = (self.pad(depth + 1), self.pad(depth + 2));
let inner = match slot {
Some(_) if vue => {
let name = slot_ident.clone().map(|n| format!(" name=\"{n}\"")).unwrap_or_default();
self.markup.push_str(&format!("{p1}<slot{name}>\n"));
depth + 2
}
Some(p) if leptos => {
self.markup.push_str(&format!("{p1}{{match &{} {{ Some(c) => c.run(), None => view! {{\n", self.ident(p)));
depth + 2
@@ -881,6 +1033,7 @@ impl<'c, 'a> Markup<'c, 'a> {
self.node(k, Some(n), inner, &format!("{path}/{}#{count}", kn.name));
}
match slot {
Some(_) if vue => self.markup.push_str(&format!("{p1}</slot>\n")),
Some(_) if leptos => self.markup.push_str(&format!("{p1}}}.into_any() }}}}\n")),
Some(_) => self.markup.push_str(&format!("{p2}</>\n{p1})}}\n")),
None => {}
@@ -965,7 +1118,7 @@ fn swift_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[Str
// Nested instances are calls; an instance swap parameter is a view.
let call = match ctx.param(n, "mainComponent").filter(|_| n.id != ctx.root) {
Some(p) => Some(format!("{pad}{}\n", p.ident)),
None => ctx.call(n, CodeLang::SwiftUi, depth),
None => ctx.call(n, CodeLang::SwiftUi, depth).map(|(code, _)| code),
};
if let Some(call) = call {
out.push_str(&call);
@@ -1083,7 +1236,8 @@ fn swift_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[Str
// ------------------------------------------------------------------ Jetpack Compose
fn kotlin_color(c: Color) -> String {
/// `Color(0xAARRGGBB)`, as Compose and Flutter both write a colour.
pub(crate) fn kotlin_color(c: Color) -> String {
let [r, g, b, a] = c.to_rgba8();
format!("Color(0x{a:02X}{r:02X}{g:02X}{b:02X})")
}
@@ -1138,7 +1292,7 @@ fn compose_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) {
// Nested instances are calls; an instance swap parameter is a composable.
let call = match ctx.param(n, "mainComponent").filter(|_| n.id != ctx.root) {
Some(p) => Some(format!("{pad}{}()\n", p.ident)),
None => ctx.call(n, CodeLang::Compose, depth),
None => ctx.call(n, CodeLang::Compose, depth).map(|(code, _)| code),
};
if let Some(call) = call {
out.push_str(&call);
@@ -1256,7 +1410,7 @@ fn arkui_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[Str
let swap = ctx.param(n, "mainComponent").filter(|_| n.id != ctx.root);
let call = match swap {
Some(p) => Some(format!("{pad}this.{}()\n", p.ident)),
None => ctx.call(n, CodeLang::ArkUi, depth),
None => ctx.call(n, CodeLang::ArkUi, depth).map(|(code, _)| code),
};
if let Some(call) = call {
if let Some(p) = swap.filter(|_| !extra.is_empty()) {
+174 -26
View File
@@ -1,4 +1,4 @@
//! Component code: a component (or component set) becomes a React, Leptos, SwiftUI, Jetpack
//! Component code: a component (or component set) becomes a React, Leptos, Vue, SwiftUI, Jetpack
//! Compose or ArkUI component whose properties are its parameters (a variant property → a union or
//! enum, boolean → a flag showing the layers bound to it, text → a string, instance swap → a view
//! parameter, slot → optional content), and an instance becomes a call passing the values it sets.
@@ -68,14 +68,14 @@ pub fn specs() -> Vec<CommandSpec> {
vec![spec!(
"inspect.componentCode",
"Component code",
"{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose|arkui (default react)} → {component, name, set, properties: [{name, param, type, default, value? (instances)}], definition, usage? (instances; the connected snippet when the component is connected to code), connected: {importPath, name, template, import, code} | null}",
"{id? (component, set or instance; default the selection), language?: react|leptos|vue|swiftui|compose|arkui (default react)} → {component, name, set, properties: [{name, param, type, default, value? (instances)}], definition, usage? (instances; the connected snippet when the component is connected to code), connected: {importPath, name, template, import, code} | null}",
false,
|s, p| {
let cmd = "inspect.componentCode";
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component or instance"))?;
let lang = match string(p, "language").map(|l| CodeLang::from_id(&l)) {
Some(Some(l @ (CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi))) => l,
Some(_) => return Err(bad(cmd, "language: react|leptos|swiftui|compose|arkui")),
let lang = match string(p, "language") {
// One list of code languages, shared with codeConnect (see `code_connect::language`).
Some(l) => crate::code_connect::language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|vue|swiftui|compose|arkui"))?,
None => CodeLang::React,
};
let doc = s.doc()?;
@@ -108,7 +108,7 @@ pub fn specs() -> Vec<CommandSpec> {
)]
}
/// Code for any layer in React, Leptos, SwiftUI, Compose or ArkUI: an instance's call and its
/// Code for any layer in React, Leptos, Vue, SwiftUI, Compose or ArkUI: an instance's call and its
/// component's definition, a component's (or set's) definition, or the layer tree (React and
/// Leptos wrap it in a function component), with the imports of the connected components it calls
/// at the top.
@@ -122,9 +122,13 @@ pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
(NodeKind::Instance { .. }, Some(c)) => format!("{}\n{}", usage(doc, id, lang, 0).unwrap_or_default(), definition(doc, &c, lang)),
(NodeKind::Component { .. } | NodeKind::ComponentSet { .. }, Some(c)) => definition(doc, &c, lang),
_ => match lang {
CodeLang::React | CodeLang::Leptos => {
CodeLang::React | CodeLang::Leptos | CodeLang::Vue => {
let c = Component { comp: id, set: None, type_name: component_type_name(&n.name), params: Vec::new() };
if lang == CodeLang::Leptos { leptos(doc, &c) } else { react(doc, &c) }
match lang {
CodeLang::Leptos => leptos(doc, &c),
CodeLang::Vue => vue(doc, &c),
_ => react(doc, &c),
}
}
CodeLang::SwiftUi => {
let mut out = String::new();
@@ -143,7 +147,29 @@ pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
}
},
};
crate::code_connect::with_imports(&crate::code_connect::tree_imports(doc, id, lang), code)
let imports = crate::code_connect::tree_imports(doc, id, lang);
if lang == CodeLang::Vue && !imports.is_empty() {
return vue_with_imports(&imports, code);
}
crate::code_connect::with_imports(&imports, code)
}
/// A Vue single-file component imports in its `<script setup>`, which is added when it has none.
fn vue_with_imports(imports: &[String], code: String) -> String {
const SETUP: &str = "<script setup lang=\"ts\">\n\n";
let lines = imports.join("\n");
// `find` returns char boundaries, so the splits below are safe.
if let Some(at) = code.find(SETUP).map(|at| at + SETUP.len()) {
let (head, tail) = code.split_at(at);
return format!("{head}{lines}\n\n{tail}");
}
match code.find("<template>") {
Some(at) => {
let (head, tail) = code.split_at(at);
format!("{head}{SETUP}{lines}\n\n</script>\n\n{tail}")
}
None => crate::code_connect::with_imports(imports, code),
}
}
fn is_component(doc: &Document, id: NodeId) -> bool {
@@ -466,6 +492,23 @@ pub(crate) fn js_string(s: &str) -> String {
serde_json::to_string(&s).unwrap_or_else(|_| "\"\"".into())
}
/// A JavaScript string literal safe inside a Vue single-file component: `<` is escaped, so a
/// default containing `</script>` can't close the `<script setup>` block early.
pub(crate) fn vue_string(s: &str) -> String {
js_string(s).replace('<', "\\u003C")
}
/// A plain Vue attribute value: its JS string quotes stay as the attribute delimiters, and HTML
/// entities inside are escaped so the value reads back unchanged.
fn html_attr_value(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
}
/// A Vue binding expression inside a double-quoted attribute.
fn html_attr_expr(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
/// The type name of a component (a variant's set) by id.
pub(crate) fn component_type(doc: &Document, comp: NodeId) -> Option<String> {
let n = doc.node(comp).filter(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. }))?;
@@ -479,8 +522,11 @@ fn enum_name(c: &Component, p: &Param, swift: bool) -> String {
/// `value` of a parameter as an expression in `lang`.
pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value, lang: CodeLang) -> String {
// React and Vue both take JavaScript: strings, and variant values as plain strings.
let js = matches!(lang, CodeLang::React | CodeLang::Vue);
match (&p.kind, lang) {
(ParamKind::Boolean, _) => value.as_bool().unwrap_or(false).to_string(),
(ParamKind::Text, CodeLang::Vue) => vue_string(value.as_str().unwrap_or("")),
(ParamKind::Text, CodeLang::React) => js_string(value.as_str().unwrap_or("")),
(ParamKind::Text, CodeLang::Leptos) => rust_string(value.as_str().unwrap_or("")),
(ParamKind::Variant(opts), CodeLang::Leptos) => {
@@ -490,6 +536,9 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
(ParamKind::Text, l) => literal(value.as_str().unwrap_or(""), l == CodeLang::Compose),
(ParamKind::Variant(opts), l) => {
let v = value.as_str().unwrap_or("");
if l == CodeLang::Vue {
return vue_string(v);
}
if l == CodeLang::React {
return js_string(v);
}
@@ -501,7 +550,9 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
(ParamKind::InstanceSwap, l) => {
let t = id_of(value).and_then(|i| component_type(doc, i));
match (t, l) {
(Some(t), CodeLang::React) => format!("<{t} />"),
// A Vue binding takes the component itself, not an element.
(Some(t), CodeLang::Vue) => t,
(Some(t), _) if js => format!("<{t} />"),
(Some(t), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", rust_type(&t)),
(None, CodeLang::Leptos) => "|| ()".into(),
(Some(t), CodeLang::SwiftUi) => format!("AnyView({t}())"),
@@ -509,7 +560,7 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
(Some(t), CodeLang::ArkUi) => format!("() => {{ {t}() }}"),
(None, CodeLang::ArkUi) => "() => {}".into(),
(Some(t), _) => format!("{{ {t}() }}"),
(None, CodeLang::React) => "null".into(),
(None, _) if js => "null".into(),
(None, CodeLang::SwiftUi) => "AnyView(EmptyView())".into(),
(None, _) => "{}".into(),
}
@@ -520,6 +571,19 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
// ------------------------------------------------------------------ instances
/// The `<slot name="…">` of a slot parameter: unique among the component's slot parameters, unlike
/// `class_name` called with a fresh list per parameter (two idents can lowercase alike).
pub(crate) fn slot_name(c: &Component, p: &Param) -> String {
let mut used = Vec::new();
for q in c.params.iter().filter(|q| matches!(q.kind, ParamKind::Slot)) {
let name = class_name(&q.ident, "slot", &mut used);
if q.ident == p.ident {
return name;
}
}
class_name(&p.ident, "slot", &mut Vec::new())
}
/// The values an instance sets for `c`'s parameters, in order (`None` for other layers): variant
/// values from the variant it shows, slots as whether they hold its own content.
pub(crate) fn instance_values(doc: &Document, id: NodeId, c: &Component) -> Option<Vec<(String, Value)>> {
@@ -544,22 +608,32 @@ pub(crate) fn instance_values(doc: &Document, id: NodeId, c: &Component) -> Opti
/// The call for instance `id` at `depth`, passing the values that differ from the defaults and the
/// content of slots it fills (`None` when it isn't an instance of an existing component).
pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option<String> {
usage_with_styles(doc, id, lang, depth).map(|(code, _)| code)
}
/// `usage` and the CSS generated for the slot content it carries (the caller's stylesheet needs
/// those rules; `usage` alone drops them at a snippet boundary).
pub(crate) fn usage_with_styles(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option<(String, String)> {
let c = main_of(doc, id).and_then(|m| describe(doc, m))?;
let values = instance_values(doc, id, &c)?;
let pad = if lang == CodeLang::React { " ".repeat(depth) } else { indent(depth) };
let pad = if matches!(lang, CodeLang::React | CodeLang::Vue) { " ".repeat(depth) } else { indent(depth) };
// A connected component: its real snippet (its import is at the top of the code: `code`).
if let Some(link) = crate::code_connect::link(doc, id, lang) {
return Some(crate::code_connect::render(doc, &c, &values, link, lang, &pad));
return Some((crate::code_connect::render(doc, &c, &values, link, lang, &pad), String::new()));
}
let slots = slots_of(doc, id);
// (parameter, expression, whether it's a plain string literal)
let mut args: Vec<(&str, String, bool)> = Vec::new();
let mut slot_css = String::new();
for (p, (_, v)) in c.params.iter().zip(&values) {
if let ParamKind::Slot = p.kind {
if v.as_bool() == Some(true)
&& let Some((layer, _)) = slots.iter().find(|(_, name)| *name == p.prop)
{
args.push((&p.ident, slot_content(doc, *layer, lang, depth), false));
let prefix = format!("slot-{}-", slot_name(&c, p));
let (content, css) = slot_content(doc, *layer, lang, depth, &prefix);
slot_css.push_str(&css);
args.push((&p.ident, content, false));
}
continue;
}
@@ -571,7 +645,7 @@ pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option
args.push((&p.ident, expr, plain));
}
let t = &c.type_name;
Some(match lang {
let code = match lang {
CodeLang::React | CodeLang::Leptos => {
let leptos = lang == CodeLang::Leptos;
// Plain strings read best as attribute values.
@@ -585,6 +659,31 @@ pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option
let t = if leptos { rust_type(t) } else { t.clone() };
format!("{pad}<{t}{attrs} />\n")
}
CodeLang::Vue => {
// A plain string is an attribute value and anything else a `:name` binding, named as
// the definition declares the prop; a slot is a `<template #name>` child instead,
// named as its `<slot>` element is.
let is_slot = |name: &str| c.params.iter().any(|p| p.ident == name && matches!(p.kind, ParamKind::Slot));
let attrs: String = args
.iter()
.filter(|(name, _, _)| !is_slot(name))
.map(
|(name, expr, plain)| {
if *plain { format!(" {name}={}", html_attr_value(expr)) } else { format!(" :{name}=\"{}\"", html_attr_expr(expr)) }
},
)
.collect();
let slots: String = args
.iter()
.filter(|(name, _, _)| is_slot(name))
.map(|(name, expr, _)| {
let slot = c.params.iter().find(|q| q.ident == *name);
let name = slot.map(|p| slot_name(&c, p)).unwrap_or_else(|| class_name(name, "slot", &mut Vec::new()));
format!("{pad} <template #{name}>\n{expr}{pad} </template>\n")
})
.collect();
if slots.is_empty() { format!("{pad}<{t}{attrs} />\n") } else { format!("{pad}<{t}{attrs}>\n{slots}{pad}</{t}>\n") }
}
CodeLang::SwiftUi => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::<Vec<_>>().join(", ")),
CodeLang::ArkUi => {
let list = args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::<Vec<_>>().join(", ");
@@ -592,50 +691,64 @@ pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option
if list.is_empty() { format!("{pad}{t}()\n") } else { format!("{pad}{t}({{ {list} }})\n") }
}
_ => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n} = {e}")).collect::<Vec<_>>().join(", ")),
})
};
Some((code, slot_css))
}
/// The content an instance put in one of its slots, as an expression spanning lines.
fn slot_content(doc: &Document, slot: NodeId, lang: CodeLang, depth: usize) -> String {
let Some(n) = doc.node(slot) else { return String::new() };
/// The content an instance put in one of its slots, as an expression spanning lines, and the CSS
/// its layers generated (so the caller's stylesheet keeps it). `prefix` keeps two slots' class
/// names apart when their layers share a name.
fn slot_content(doc: &Document, slot: NodeId, lang: CodeLang, depth: usize, prefix: &str) -> (String, String) {
let Some(n) = doc.node(slot) else { return (String::new(), String::new()) };
let ctx = Ctx { doc, root: slot, comp: None, base: depth };
let kids = ctx.kids(n);
match lang {
CodeLang::React => {
let mut m = Markup::new(&ctx, CodeLang::React);
m.class_prefix = Some(prefix.to_string());
for k in kids {
m.node(k, Some(n), depth + 2, "");
}
let (p1, p2) = (" ".repeat(depth), " ".repeat(depth + 1));
format!("(\n{p2}<>\n{}{p2}</>\n{p1})", m.markup)
(format!("(\n{p2}<>\n{}{p2}</>\n{p1})", m.markup), m.styles)
}
CodeLang::Leptos => {
let mut m = Markup::new(&ctx, CodeLang::Leptos);
m.class_prefix = Some(prefix.to_string());
for k in kids {
m.node(k, Some(n), depth + 1, "");
}
format!("|| view! {{\n{}{}}}", m.markup, indent(depth))
(format!("|| view! {{\n{}{}}}", m.markup, indent(depth)), m.styles)
}
CodeLang::Vue => {
// Just the markup: `usage` wraps it in the `<template #name>` of that slot.
let mut m = Markup::new(&ctx, CodeLang::Vue);
m.class_prefix = Some(prefix.to_string());
for k in kids {
m.node(k, Some(n), depth + 2, "");
}
(m.markup, m.styles)
}
CodeLang::SwiftUi => {
let mut out = String::new();
for k in kids {
swift(&ctx, k, depth + 2, &mut out, &[]);
}
format!("AnyView(Group {{\n{out}{}}})", indent(depth + 1))
(format!("AnyView(Group {{\n{out}{}}})", indent(depth + 1)), String::new())
}
CodeLang::ArkUi => {
let mut out = String::new();
for k in kids {
arkui(&ctx, k, depth + 2, &mut out, &[]);
}
format!("() => {{\n{out}{}}}", indent(depth + 1))
(format!("() => {{\n{out}{}}}", indent(depth + 1)), String::new())
}
_ => {
let mut out = String::new();
for k in kids {
compose(&ctx, k, depth + 2, &mut out);
}
format!("{{\n{out}{}}}", indent(depth + 1))
(format!("{{\n{out}{}}}", indent(depth + 1)), String::new())
}
}
}
@@ -648,6 +761,7 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String {
CodeLang::SwiftUi => swift_definition(doc, c),
CodeLang::Compose => compose_definition(doc, c),
CodeLang::Leptos => leptos(doc, c),
CodeLang::Vue => vue(doc, c),
CodeLang::ArkUi => arkui_definition(doc, c),
_ => react(doc, c),
}
@@ -656,7 +770,7 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String {
/// `data-*` attribute names for variant parameters, unique among them (in order).
fn data_attrs(variants: &[&Param]) -> Vec<String> {
let mut used = Vec::new();
variants.iter().map(|p| format!("data-{}", class_name(&p.prop, &mut used))).collect()
variants.iter().map(|p| format!("data-{}", class_name(&p.prop, "prop", &mut used))).collect()
}
/// `c`'s markup in React or Leptos (two levels in) and its stylesheet (variables as custom
@@ -664,7 +778,7 @@ fn data_attrs(variants: &[&Param]) -> Vec<String> {
fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String) {
let ctx = Ctx { doc, root: c.comp, comp: Some(c), base: 2 };
let root_name = c.set.and_then(|s| doc.node(s)).or_else(|| doc.node(c.comp)).map(|n| n.name.clone()).unwrap_or_default();
let root_class = class_name(&root_name, &mut Vec::new());
let root_class = class_name(&root_name, "component", &mut Vec::new());
let variants: Vec<&Param> = c.params.iter().filter(|p| matches!(p.kind, ParamKind::Variant(_))).collect();
let attr_names = data_attrs(&variants);
let pass = |root: NodeId| {
@@ -681,6 +795,7 @@ fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String
.zip(&attr_names)
.map(|(p, attr)| match lang {
CodeLang::Leptos => format!(" {attr}={{{}.as_str()}}", rust_ident(&p.ident)),
CodeLang::Vue => format!(" :{attr}=\"{}\"", p.ident),
_ => format!(" {attr}={{{}}}", p.ident),
})
.collect();
@@ -744,6 +859,39 @@ fn react(doc: &Document, c: &Component) -> String {
out
}
/// A Vue single-file component: `<script setup>` with typed props and their defaults, the
/// template, and a scoped stylesheet. Slots become Vue's own `<slot>` elements, so they are not
/// props. Variants switch on the `data-*` attributes of the root element, as on the web.
fn vue(doc: &Document, c: &Component) -> String {
// The stylesheet sits inline in the SFC, so the root class name isn't needed here.
let (_, markup, css) = web(doc, c, CodeLang::Vue);
// A slot layer is a `<slot>` element in the template, so it takes no prop.
let props: Vec<&Param> = c.params.iter().filter(|p| !matches!(p.kind, ParamKind::Slot)).collect();
let mut out = String::new();
if !props.is_empty() {
// `__props` can't be a generated prop name (idents are camelCase words), so a property
// named `Props` isn't shadowed by the binding.
out.push_str("<script setup lang=\"ts\">\n\nconst __props = withDefaults(\n defineProps<{\n");
for p in &props {
let ty = match &p.kind {
ParamKind::Variant(opts) => opts.iter().map(|o| vue_string(o)).collect::<Vec<_>>().join(" | "),
ParamKind::Boolean => "boolean".into(),
ParamKind::Text => "string".into(),
ParamKind::InstanceSwap | ParamKind::Slot => "unknown".into(),
};
out.push_str(&format!(" {}?: {ty};\n", p.ident));
}
out.push_str(" }>(),\n {\n");
for p in &props {
// An instance swap defaults to its component reference; a slot isn't in `props`.
out.push_str(&format!(" {}: {},\n", p.ident, value_expr(doc, c, p, &p.default, CodeLang::Vue)));
}
out.push_str(" },\n);\n\n</script>\n\n");
}
out.push_str(&format!("<template>\n{markup}</template>\n\n<style scoped>\n{css}</style>\n"));
out
}
/// A Leptos `#[component]` with its variant enums and props (defaults as `#[prop]` attributes),
/// and its stylesheet.
fn leptos(doc: &Document, c: &Component) -> String {
@@ -149,6 +149,17 @@ fn instances_are_calls_passing_the_values_they_set() {
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Say \\\"hi\\\" \\$5\\nnow\")\n");
assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Card({ showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\" })\n");
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Card show_dot={false} title={\"Say \\\"hi\\\" $5\\nnow\"} />\n");
// A quoted title becomes a binding whose attribute value is escaped: the expression itself
// keeps its JS quotes, and the attribute's own quotes are entities.
assert_eq!(component_code(&mut s, i, "vue")["usage"], "<Card :showDot=\"false\" :title=\"&quot;Say \\&quot;hi\\&quot; $5\\nnow&quot;\" />\n");
let vue = code(&mut s, i, "vue");
assert!(vue.starts_with("<Card :showDot=\"false\" :title=\"&quot;Say \\&quot;hi\\&quot; $5\\nnow&quot;\" />\n"), "{vue}");
// A plain attribute keeps HTML entities intact: a literal ampersand is written as `&amp;`,
// and a literal `&amp;` as `&amp;amp;`.
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Fish & Chips"}}));
assert_eq!(component_code(&mut s, i, "vue")["usage"], "<Card :showDot=\"false\" title=\"Fish &amp; Chips\" />\n");
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Fish &amp; Chips"}}));
assert_eq!(component_code(&mut s, i, "vue")["usage"], "<Card :showDot=\"false\" title=\"Fish &amp;amp; Chips\" />\n");
}
#[test]
@@ -252,6 +263,10 @@ fn instance_swap_properties_take_views() {
let leptos = code(&mut s, chip, "leptos");
assert!(leptos.contains(" #[prop(into, default = (|| view! { <HeartIcon /> }).into())] icon: ViewFn,"), "{leptos}");
assert!(leptos.contains("{icon.run()}"), "{leptos}");
let vue = code(&mut s, chip, "vue");
assert!(vue.contains(" icon?: unknown;"), "{vue}");
assert!(vue.contains(" icon: HeartIcon,"), "the swap defaults to its component: {vue}");
assert!(vue.contains("<component :is=\"icon\" />"), "a swap is a component, not an element: {vue}");
let i = idv(&ex(&mut s, "instance.create", json!({"component": chip, "x": 0, "y": 100})));
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Icon": star}}));
assert_eq!(component_code(&mut s, i, "react")["usage"], "<Chip icon={<StarIcon />} />\n");
@@ -259,6 +274,7 @@ fn instance_swap_properties_take_views() {
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Chip(icon = { StarIcon() })\n");
assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Chip({ icon: () => { StarIcon() } })\n");
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Chip icon={|| view! { <StarIcon /> }} />\n");
assert_eq!(component_code(&mut s, i, "vue")["usage"], "<Chip :icon=\"StarIcon\" />\n");
}
#[test]
+47 -1
View File
@@ -53,11 +53,57 @@ fn class_names_are_valid_and_deep_trees_are_bounded() {
parent = id(s.execute("frame.create", &json!({"x": 1, "y": 1, "width": 90, "height": 90, "parent": parent})).unwrap());
}
let code = code_for(s.doc().unwrap(), root, CodeLang::Html);
assert!(code.starts_with("<div class=\"layer-1-n-code-name\">"), "{}", code.lines().next().unwrap());
assert!(code.starts_with("<div class=\"layer-1-unicode-name\">"), "{}", code.lines().next().unwrap());
// Nesting stops at the depth limit instead of running away.
assert!(code.matches("<div").count() <= 14, "{}", code.matches("<div").count());
}
#[test]
fn non_ascii_layer_names_transliterate_into_readable_classes() {
let mut s = Session::new();
// Korean reads as its romanization instead of layer- (#1235), with duplicates numbered.
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 200})).unwrap());
s.execute("node.set", &json!({"id": f, "props": {"name": "로그인 화면"}})).unwrap();
let a = id(s.execute("text.create", &json!({"text": "Login", "x": 0, "y": 0, "parent": f})).unwrap());
let b = id(s.execute("text.create", &json!({"text": "Login", "x": 0, "y": 40, "parent": f})).unwrap());
s.execute("node.set", &json!({"id": a, "props": {"name": "로그인"}})).unwrap();
s.execute("node.set", &json!({"id": b, "props": {"name": "로그인"}})).unwrap();
let code = code_for(s.doc().unwrap(), f, CodeLang::Html);
assert!(code.contains("<div class=\"rogeuin-hwamyeon\">"), "{code}");
assert!(code.contains("<p class=\"rogeuin\">") && code.contains("<p class=\"rogeuin-2\">"), "{code}");
}
#[test]
fn other_scripts_and_symbol_names_transliterate_or_fall_back_to_the_kind() {
let mut s = Session::new();
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100, "name": "按钮"})).unwrap());
let k = id(s.execute("text.create", &json!({"text": "Ok", "x": 0, "y": 0, "parent": f})).unwrap());
s.execute("node.set", &json!({"id": k, "props": {"name": "кнопка"}})).unwrap();
let hash = id(s.execute("text.create", &json!({"text": "###", "x": 0, "y": 20, "parent": f})).unwrap());
s.execute("node.set", &json!({"id": hash, "props": {"name": "###"}})).unwrap();
let digit = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 40, "width": 10, "height": 10, "parent": f})).unwrap());
s.execute("node.set", &json!({"id": digit, "props": {"name": "4버튼"}})).unwrap();
let code = code_for(s.doc().unwrap(), f, CodeLang::Html);
assert!(code.contains("<div class=\"an-niu\">"), "Chinese: {code}");
assert!(code.contains("<p class=\"knopka\">"), "Cyrillic: {code}");
assert!(code.contains("<p class=\"text\">"), "symbols fall back to the kind: {code}");
assert!(code.contains("layer-4beoteun"), "a leading digit keeps the layer- prefix: {code}");
}
#[test]
fn long_names_cut_at_a_word_boundary() {
let mut s = Session::new();
let long = format!("{}-{}", "a".repeat(30), "b".repeat(30));
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 10, "height": 10, "name": long})).unwrap());
let code = code_for(s.doc().unwrap(), f, CodeLang::Html);
let word = "a".repeat(30);
assert!(code.contains(&format!("<div class=\"{word}\">")), "cut back to the first word: {code}");
let unbroken = "c".repeat(50);
let g = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 10, "height": 10, "name": unbroken})).unwrap());
let code = code_for(s.doc().unwrap(), g, CodeLang::Html);
assert!(code.contains(&format!("class=\"{}\"", "c".repeat(40))), "one long word cuts at 40: {code}");
}
#[test]
fn leptos_wraps_the_markup_in_a_component_with_text_as_string_literals() {
let mut s = Session::new();
+3
View File
@@ -188,6 +188,9 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
o.insert("letterSpacing".into(), json!(t.style.letter_spacing.label()));
o.insert("textAlign".into(), json!(t.align_h));
o.insert("textAutoResize".into(), json!(t.auto_resize));
if t.max_lines > 0 {
o.insert("maxLines".into(), json!(t.max_lines));
}
if let Some(tp) = &t.path {
o.insert("textPath".into(), json!(tp));
}
+8 -1
View File
@@ -340,6 +340,13 @@ fn rem_conversion_leaves_names_alone() {
let f = crate::codegen::px_to_rem;
// Lengths convert, names ending in "px" don't.
assert_eq!(f("width: 32px; --gap-8px: 1; margin: -8px 0px;", 16.0), "width: 2rem; --gap-8px: 1; margin: -0.5rem 0rem;");
assert_eq!(f("épx 1px px", 16.0), "épx 0.06rem px");
assert_eq!(f("épx 1px px", 16.0), "épx 0.0625rem px");
assert_eq!(f("", 16.0), "");
}
#[test]
fn rem_conversion_keeps_the_original_pixels() {
let f = crate::codegen::px_to_rem;
assert_eq!(f("padding: 3px;", 16.0), "padding: 0.1875rem;");
assert_eq!(f("gap: 0.5px;", 16.0), "gap: 0.03125rem;");
}
+50 -1
View File
@@ -375,6 +375,9 @@ pub struct DocState {
/// [`cmd::edit::Duplicated`]; not saved).
pub(crate) last_duplicate: Option<cmd::edit::Duplicated>,
images_left_in_file: Option<(String, usize)>,
/// The file on disk as this tab last read or wrote it: a save refuses to replace what another
/// app (figcraft-cli, say) wrote to it since (#1330).
on_disk: Option<library::Stamp>,
}
/// A version shown on the canvas in place of the document: read-only until it's restored or the
@@ -438,6 +441,7 @@ impl DocState {
view_from_prefs: false,
last_duplicate: None,
images_left_in_file: None,
on_disk: None,
};
st.zoom_to_fit();
st
@@ -448,6 +452,11 @@ impl DocState {
self
}
/// The tab's file now holds what this tab read or wrote.
pub(crate) fn note_on_disk(&mut self) {
self.on_disk = self.path.as_deref().and_then(|p| library::stat(p).ok());
}
pub fn images_lost_saving_to(&self, path: &str) -> Option<usize> {
self.images_left_in_file.as_ref().filter(|(file, _)| file_access::same_file(file, path)).map(|(_, count)| *count)
}
@@ -803,6 +812,10 @@ pub struct Session {
/// Command log (id + params) for actions/automation. File imports that carry the file bytes
/// (`file.import` with `data`) are not logged, so the log never holds a copy of a pasted file.
pub journal: Vec<(String, Value)>,
/// Nesting depth of [`Session::grouped`]; tabs closed while it is above zero are kept in
/// `closed_in_group` so a failed group can bring them back.
group_depth: usize,
closed_in_group: Vec<DocState>,
/// Recently used colours.
pub recent_colors: Vec<figcraft_color::Color>,
/// The last undoable command executed through [`Session::execute`], excluding "Repeat".
@@ -858,6 +871,8 @@ impl Session {
ui_requests: Vec::new(),
ui_dark: None,
journal: Vec::new(),
group_depth: 0,
closed_in_group: Vec::new(),
recent_colors: Vec::new(),
last_command: None,
message: None,
@@ -1297,6 +1312,16 @@ impl Session {
Ok(())
}
/// Take the tabs `pred` matches out of `docs`; inside a group they are kept so that a failed
/// group can reopen them.
pub(crate) fn remove_tabs(&mut self, mut pred: impl FnMut(&DocState) -> bool) {
let (gone, keep): (Vec<_>, Vec<_>) = std::mem::take(&mut self.docs).into_iter().partition(|d| pred(d));
self.docs = keep;
if self.group_depth > 0 {
self.closed_in_group.extend(gone);
}
}
/// Run `f` as one undo step called `label`: the commands it executes push no steps of their
/// own, and when it fails the documents stay at their group-start state.
pub fn grouped<T>(&mut self, label: &str, f: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
@@ -1315,13 +1340,23 @@ impl Session {
for (uid, _, _, _, _, _) in &before {
self.live_hold(*uid, true);
}
self.group_depth += 1;
let result = f(self);
self.group_depth -= 1;
for (uid, _, _, _, _, _) in &before {
self.live_hold(*uid, false);
}
if result.is_err() {
self.docs.retain(|ds| before.iter().any(|(uid, _, _, _, _, _)| *uid == ds.uid));
// Tabs the group closed come back from the stash, and the group-start order returns.
let mut current = std::mem::take(&mut self.docs);
for (uid, ..) in &before {
let tab = match current.iter().position(|d| d.uid == *uid) {
Some(i) => Some(current.swap_remove(i)),
None => self.closed_in_group.iter().position(|d| d.uid == *uid).map(|i| self.closed_in_group.swap_remove(i)),
};
self.docs.extend(tab);
}
for ((uid, doc, selection, page, undo_len, limit), redo) in before.iter().zip(redo_before) {
if let Some(ds) = self.docs.iter_mut().find(|ds| ds.uid == *uid) {
ds.history.redo = redo;
@@ -1334,8 +1369,14 @@ impl Session {
}
self.active = active;
self.journal.truncate(journal_len);
if self.group_depth == 0 {
self.closed_in_group.clear();
}
return result;
}
if self.group_depth == 0 {
self.closed_in_group.clear();
}
let changed: Vec<_> = before
.iter()
@@ -1854,6 +1895,8 @@ mod proto_delay_tests;
#[cfg(test)]
mod proto_multi_tests;
#[cfg(test)]
mod proto_overlay_anchor_tests;
#[cfg(test)]
mod proto_polish_tests;
#[cfg(test)]
mod proto_present_tests;
@@ -1874,6 +1917,8 @@ mod rotated_select_tests;
#[cfg(test)]
mod rotation_origin_tests;
#[cfg(test)]
mod save_disk_changes_tests;
#[cfg(test)]
mod save_format_tests;
#[cfg(test)]
mod scale_tool_tests;
@@ -1952,6 +1997,8 @@ mod variants_discovery_tests;
#[cfg(test)]
mod vector_behaviour_tests;
#[cfg(test)]
mod vector_drawable_export_tests;
#[cfg(test)]
mod vector_fill_rule_tests;
#[cfg(test)]
mod vector_multi_behaviour_tests;
@@ -1966,6 +2013,8 @@ mod version_tests;
#[cfg(test)]
mod view_behaviour_tests;
#[cfg(test)]
mod vue_codegen_tests;
#[cfg(test)]
mod widget_input_tests;
#[cfg(test)]
mod widgets_tests;
+4 -4
View File
@@ -439,9 +439,9 @@ pub fn publish(doc: &Document, prev: Option<&PublishedLibrary>, sel: &Selection,
Ok(PublishedLibrary { manifest, doc: Arc::new(snap) })
}
/// When a library file was last read.
/// When a file was last read: its length and modification time (also a document's own file, #1330).
#[derive(Clone, Debug, PartialEq)]
struct Stamp {
pub(crate) struct Stamp {
len: u64,
modified: Option<std::time::SystemTime>,
}
@@ -545,7 +545,7 @@ fn now() -> Option<std::time::SystemTime> {
}
#[cfg(not(target_arch = "wasm32"))]
fn stat(path: &str) -> std::result::Result<Stamp, String> {
pub(crate) fn stat(path: &str) -> std::result::Result<Stamp, String> {
let m = crate::file_access::metadata(path).map_err(|e| e.to_string())?;
if !m.is_file() {
return Err(format!("{path} is not a file"));
@@ -554,7 +554,7 @@ fn stat(path: &str) -> std::result::Result<Stamp, String> {
}
#[cfg(target_arch = "wasm32")]
fn stat(_path: &str) -> std::result::Result<Stamp, String> {
pub(crate) fn stat(_path: &str) -> std::result::Result<Stamp, String> {
Err(NO_FILES.into())
}
+2 -2
View File
@@ -52,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
vec![spec!(
"component.playground",
"Component playground",
"{id? (an instance, a component or a component set; default the selection), properties? {name: value} (variant, boolean, text and instance-swap properties, as instance.setProperties takes them), language? css|html|react|swiftui|compose|arkui (default react), path? (write the preview there as a PNG), scale? (the PNG's, default 2)} → {component, properties: [{name, type, options?, value}], language, code, width, height, path?} the component with those property values, tried on a copy: its code and its look. Nothing in the document changes",
"{id? (an instance, a component or a component set; default the selection), properties? {name: value} (variant, boolean, text and instance-swap properties, as instance.setProperties takes them), language? css|html|react|leptos|vue|swiftui|compose|arkui (default react), path? (write the preview there as a PNG), scale? (the PNG's, default 2)} → {component, properties: [{name, type, options?, value}], language, code, width, height, path?} the component with those property values, tried on a copy: its code and its look. Nothing in the document changes",
false,
|s, p| {
let cmd = "component.playground";
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
Some(_) => return Err(bad(cmd, "properties: {name: value}")),
};
let lang = match string(p, "language") {
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|swiftui|compose|arkui"))?,
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|vue|swiftui|compose|arkui"))?,
None => CodeLang::React,
};
let (copy, inst) = build(s.doc()?, target, &values)?;
@@ -0,0 +1,70 @@
use serde_json::{Value, json};
use crate::params::id_of;
use crate::{EngineError, Session};
use figcraft_doc::NodeId;
use figcraft_doc::proto::{Action, OverlayAnchor, OverlayPosition};
fn frame(s: &mut Session, x: f64, w: f64, h: f64, name: &str) -> NodeId {
id_of(&s.execute("frame.create", &json!({"x": x, "y": 0, "width": w, "height": h, "name": name})).unwrap()["id"]).unwrap()
}
fn button(s: &mut Session, parent: NodeId) -> NodeId {
let r = s.execute("node.create", &json!({"type": "rectangle", "x": 10, "y": 30, "width": 100, "height": 40, "parent": parent, "name": "Button"}));
id_of(&r.unwrap()["id"]).unwrap()
}
fn action(s: &Session, id: NodeId) -> Action {
s.doc().unwrap().node(id).unwrap().interactions[0].actions[0].clone()
}
fn dropdown(s: &mut Session) -> (NodeId, NodeId) {
let screen = frame(s, 0.0, 400.0, 800.0, "Screen");
let menu = frame(s, 600.0, 60.0, 200.0, "Menu");
let b = button(s, screen);
let open = json!({"type": "openOverlay", "dest": menu, "position": "bottomLeft", "anchor": "triggerOutside"});
s.execute("proto.interaction.add", &json!({"id": b, "action": open})).unwrap();
(b, menu)
}
#[test]
fn the_anchor_is_set_read_and_undone() {
let mut s = Session::new();
let (b, menu) = dropdown(&mut s);
assert!(matches!(action(&s, b), Action::OpenOverlay { anchor: OverlayAnchor::TriggerOutside, position: OverlayPosition::BottomLeft, .. }));
s.execute(
"proto.interaction.set",
&json!({"id": b, "index": 0, "action": {"type": "openOverlay", "dest": menu, "position": "topCenter", "anchor": "triggerInside"}}),
)
.unwrap();
assert!(matches!(action(&s, b), Action::OpenOverlay { anchor: OverlayAnchor::TriggerInside, position: OverlayPosition::TopCenter, .. }));
assert!(s.undo().unwrap());
assert!(matches!(action(&s, b), Action::OpenOverlay { anchor: OverlayAnchor::TriggerOutside, .. }));
}
#[test]
fn fig_writes_a_manual_offset_from_the_trigger() {
let mut s = Session::new();
let (b, _) = dropdown(&mut s);
let (bytes, _) = figcraft_format::fig::export(s.doc().unwrap(), None).unwrap();
let (back, _) = figcraft_format::fig::import(&bytes, "x").unwrap();
let hot = back.nodes().find(|n| n.name == "Button").unwrap();
assert!(
matches!(hot.interactions[0].actions[0], Action::OpenOverlay { position: OverlayPosition::Manual, offset: Some([0.0, 40.0]), .. }),
"{:?} (from {b:?})",
hot.interactions[0].actions
);
}
#[test]
fn junk_anchors_are_refused_not_fatal() {
let junk = [json!(true), json!(-1), json!("above"), json!([1, 2]), json!({"a": 1}), json!("x".repeat(10_000))];
for j in &junk {
let mut s = Session::new();
let (a, menu) = (frame(&mut s, 0.0, 100.0, 100.0, "A"), frame(&mut s, 200.0, 50.0, 50.0, "M"));
let p: Value = json!({"id": a, "action": {"type": "openOverlay", "dest": menu, "anchor": j}});
let r = s.execute("proto.interaction.add", &p);
assert!(!matches!(r, Err(EngineError::Internal { .. })), "{p}: {r:?}");
assert!(s.doc().unwrap().node(a).unwrap().interactions.is_empty(), "{j}");
}
}
@@ -0,0 +1,76 @@
//! Saving never silently replaces what another app wrote to the open file since it was opened or
//! saved here (#1330): Save and auto-save refuse, `overwrite` and Revert to saved settle it.
use serde_json::json;
use crate::Session;
use crate::cmd::file::FileSnapshot;
fn temp_file(name: &str) -> String {
let dir = std::env::temp_dir().join(format!("figcraft-disk-changes-{}-{name}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir.join("TESTE.figcraft").to_string_lossy().into_owned()
}
/// What the file on disk names its first page.
fn page_on_disk(path: &str) -> String {
let mut s = Session::new();
s.execute("file.open", &json!({"path": path})).unwrap();
let ds = s.ds().unwrap();
ds.doc.node(ds.page).unwrap().name.clone()
}
/// The app has `path` open with an unsaved edit, after figcraft-cli renamed its page on disk.
fn app_and_cli(path: &str) -> Session {
let mut setup = Session::new();
setup.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
setup.execute("file.save", &json!({"path": path})).unwrap();
let mut app = Session::new();
app.execute("file.open", &json!({"path": path})).unwrap();
let mut cli = Session::new();
cli.execute("file.open", &json!({"path": path})).unwrap();
cli.execute("page.rename", &json!({"name": "CLI VENCEU"})).unwrap();
cli.execute("file.save", &json!({})).unwrap();
app.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
app
}
#[test]
fn saving_over_changes_made_on_disk_is_refused_until_asked() {
let path = temp_file("save");
let mut app = app_and_cli(&path);
let err = app.execute("file.save", &json!({})).unwrap_err().to_string();
assert!(err.contains("changed on disk"), "{err}");
assert_eq!(page_on_disk(&path), "CLI VENCEU", "the CLI's change is still in the file");
assert!(app.ds().unwrap().is_dirty(), "the app's edit stays unsaved");
// Asked to, the save replaces it; from then on the file is the app's again.
app.execute("file.save", &json!({"overwrite": true})).unwrap();
assert_ne!(page_on_disk(&path), "CLI VENCEU");
app.execute("shape.rectangle", &json!({"x": 40, "y": 0, "width": 10, "height": 10})).unwrap();
app.execute("file.save", &json!({})).unwrap();
let _ = std::fs::remove_file(&path);
}
#[test]
fn reverting_loads_the_changes_made_on_disk_and_saves_again() {
let path = temp_file("revert");
let mut app = app_and_cli(&path);
app.execute("file.revert", &json!({})).unwrap();
let ds = app.ds().unwrap();
assert_eq!(ds.doc.node(ds.page).unwrap().name, "CLI VENCEU");
app.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
app.execute("file.save", &json!({})).unwrap();
assert_eq!(page_on_disk(&path), "CLI VENCEU", "saved on top of the CLI's change");
let _ = std::fs::remove_file(&path);
}
#[test]
fn auto_save_leaves_changes_made_on_disk_alone() {
let path = temp_file("auto");
let mut app = app_and_cli(&path);
let snapshot = FileSnapshot::of(app.ds().unwrap());
let bytes = snapshot.encode().unwrap();
assert!(snapshot.save(&mut app, &bytes).is_err());
assert_eq!(page_on_disk(&path), "CLI VENCEU");
let _ = std::fs::remove_file(&path);
}
@@ -391,6 +391,57 @@ fn a_drag_inside_the_selection_box_moves_it_where_nothing_is_hit() {
assert!((d.x - 10.0).abs() < 2.0 && (d.y - 10.0).abs() < 2.0, "it moves with the pointer: {d:?}");
}
#[test]
fn a_layer_stacked_below_the_selection_doesnt_take_a_drag_from_inside_it() {
// #1370: inside a selected unfilled shape, a filled layer behind it doesn't take the press (the
// selection drags); a filled layer above it still does.
let mut s = Session::new();
let at = |s: &Session, i: NodeId| s.doc().unwrap().world_bounds(i).unwrap().origin();
let frame = id(&ex(&mut s, "frame.create", json!({"x": 300, "y": 0, "width": 200, "height": 200})));
for (parent, dx, where_) in [(None, 0.0, "on the canvas"), (Some(frame), 300.0, "in a top-level frame")] {
// `back` (0,0) filled, `front` (20,20) stroke only, `top` (60,60) filled; each 100×100, so
// the presses are well clear of the stroke.
let square = |s: &mut Session, x: f64| {
let mut p = json!({"x": x, "y": x, "width": 100, "height": 100});
if let Some(parent) = parent {
p["parent"] = json!(parent);
}
id(&ex(s, "shape.rectangle", p))
};
let back = square(&mut s, 0.0);
let front = square(&mut s, 20.0);
ex(&mut s, "node.set", json!({"ids": [front], "props": {"fills": [], "strokes": ["#000000"], "strokeWeight": 1}}));
let top = square(&mut s, 60.0);
// (drag from, selection after (the layer that moves by (10, 10)), derivation)
let rows = [
((dx + 40.0, 40.0), front, "a filled layer behind the selected shape doesn't take the press: the selection drags"),
((dx + 90.0, 90.0), top, "a filled layer above the selected shape still takes the press"),
];
for (from, moved, why) in rows {
let home: Vec<Point> = [back, front, top].iter().map(|i| at(&s, *i)).collect();
ex(&mut s, "select.set", json!({"ids": [front]}));
drag(&mut s, from, (from.0 + 10.0, from.1 + 10.0), NONE);
assert_eq!(s.selection(), vec![moved], "{why} ({where_})");
for (i, h) in [back, front, top].iter().zip(&home) {
let d = if *i == moved { 10.0 } else { 0.0 };
assert_eq!(at(&s, *i), Point::new(h.x + d, h.y + d), "{why} ({where_})");
}
assert!(s.undo().unwrap(), "{why}: one undo step");
}
}
// A filled layer below two selected shapes, in the gap between them (inside the selection's
// box but neither shape's), is still hit there.
let rect_at = |s: &mut Session, x: f64, w: f64| id(&ex(s, "shape.rectangle", json!({"x": x, "y": 400, "width": w, "height": 100})));
let gap = rect_at(&mut s, 720.0, 60.0);
let (a, b) = (rect_at(&mut s, 600.0, 100.0), rect_at(&mut s, 800.0, 100.0));
ex(&mut s, "node.set", json!({"ids": [a, b], "props": {"fills": [], "strokes": ["#000000"], "strokeWeight": 1}}));
ex(&mut s, "select.set", json!({"ids": [a, b]}));
let home = at(&s, gap);
drag(&mut s, (750.0, 450.0), (760.0, 460.0), NONE);
assert_eq!(s.selection(), vec![gap], "a layer in the gap between selected layers takes the press");
assert_eq!(at(&s, gap), Point::new(home.x + 10.0, home.y + 10.0));
}
#[test]
fn alt_shift_drag_on_a_selected_layer_duplicates_it() {
let mut s = Session::new();
+60
View File
@@ -310,3 +310,63 @@ fn new_text_starts_in_the_builds_default_font() {
assert_eq!(serde_json::to_value(td).unwrap()["style"]["font_family"], figcraft_doc::DEFAULT_FONT);
}
}
fn resize_mode(s: &Session, t: NodeId) -> figcraft_doc::TextAutoResize {
s.doc().unwrap().node(t).unwrap().kind.text().unwrap().auto_resize
}
#[test]
fn a_truncated_text_keeps_truncating_when_its_box_is_resized() {
// #1376: a truncating text is already fixed both ways, so an explicit size changes its box only.
use figcraft_doc::TextAutoResize;
let mut s = Session::new();
let t = text(&mut s, "Lorem consectetur", json!({"textAutoResize": "truncate", "width": 100, "height": 24}));
assert_eq!(resize_mode(&s, t), TextAutoResize::Truncate);
for (cmd, p, why) in [
("node.set", json!({"id": t, "props": {"height": 24}}), "node.set height (the value it has)"),
("node.set", json!({"id": t, "props": {"width": 120, "height": 30}}), "node.set width and height"),
("transform.resize", json!({"ids": [t], "height": 40}), "transform.resize height"),
("transform.resize", json!({"ids": [t], "width": 90}), "transform.resize width"),
("node.set", json!({"id": t, "props": {"sizingV": "fixed"}}), "fixed vertical sizing"),
] {
s.execute(cmd, &p).unwrap();
assert_eq!(resize_mode(&s, t), TextAutoResize::Truncate, "{why}");
}
// Text that doesn't truncate still turns fixed size.
let plain = text(&mut s, "Lorem consectetur", json!({"textAutoResize": "height", "width": 100}));
s.execute("node.set", &json!({"id": plain, "props": {"height": 24}})).unwrap();
assert_eq!(resize_mode(&s, plain), TextAutoResize::None);
}
#[test]
fn max_lines_on_auto_height_text_hugs_that_many_lines() {
// #1377: an auto-height text with max lines grows up to that many lines and ends in an
// ellipsis; the layer reports its max lines.
use figcraft_doc::TextAutoResize;
figcraft_text::scan_system_fonts();
let mut s = Session::new();
let t = text(&mut s, "Lorem ipsum dolor sit amet consectetur", json!({"fontSize": 16, "lineHeight": 24}));
s.execute("node.set", &json!({"id": t, "props": {"textAutoResize": "height", "width": 100}})).unwrap();
assert!(crate::inspect::node_json(s.doc().unwrap(), t, 0).get("maxLines").is_none(), "no limit: not reported");
// Wrapping needs a font (CI may have none).
if size(&s, t).1 < 48.0 {
return;
}
s.execute("text.maxLines", &json!({"ids": [t], "lines": 1})).unwrap();
assert_eq!(resize_mode(&s, t), TextAutoResize::Height, "max lines keep auto height");
assert_eq!(size(&s, t), (100.0, 24.0), "one line tall");
assert_eq!(crate::inspect::node_json(s.doc().unwrap(), t, 0)["maxLines"], json!(1));
s.execute("node.set", &json!({"id": t, "props": {"maxLines": 2}})).unwrap();
assert_eq!(size(&s, t).1, 48.0, "two lines tall");
// No limit: it grows to all of its lines again.
s.execute("node.set", &json!({"id": t, "props": {"maxLines": 0}})).unwrap();
assert!(size(&s, t).1 > 48.0);
// An explicit height on a text that truncates makes a truncating box.
s.execute("node.set", &json!({"id": t, "props": {"maxLines": 1}})).unwrap();
s.execute("node.set", &json!({"id": t, "props": {"height": 30}})).unwrap();
assert_eq!((resize_mode(&s, t), size(&s, t).1), (TextAutoResize::Truncate, 30.0));
// Other text still becomes a truncating box with max lines.
let fixed = text(&mut s, "Lorem", json!({"textAutoResize": "none", "width": 100, "height": 100}));
s.execute("text.maxLines", &json!({"ids": [fixed], "lines": 2})).unwrap();
assert_eq!(resize_mode(&s, fixed), TextAutoResize::Truncate);
}
+2
View File
@@ -29,6 +29,8 @@ pub fn specs() -> Vec<CommandSpec> {
/// paragraph of its own after a hard break, which no size can fix).
fn measure(t: &TextData, k: f64, width: f64) -> (usize, f64, bool) {
let mut t = t.clone();
// Every line counts, not only those the layer's own line limit shows.
t.max_lines = 0;
let n = t.char_count();
t.restyle(0, n, |s| s.font_size = (s.font_size * k).clamp(1.0, 5000.0));
let l = figcraft_text::layout(&t, Some(width), None);
+31 -6
View File
@@ -747,6 +747,26 @@ pub fn locked_in(doc: &Document, id: NodeId) -> bool {
std::iter::once(id).chain(doc.ancestors(id)).any(|a| doc.node(a).is_some_and(|n| n.locked))
}
/// Whether layer `t` paints below layer `s`: it holds `s`, or it is behind `s` in the stack of the
/// nearest layer holding both.
fn paints_below(doc: &Document, t: NodeId, s: NodeId) -> bool {
let chain = |id: NodeId| {
let mut c: Vec<NodeId> = std::iter::once(id).chain(doc.ancestors(id)).collect();
c.reverse();
c
};
let (ct, cs) = (chain(t), chain(s));
let shared = ct.iter().zip(&cs).take_while(|(a, b)| a == b).count();
match (ct.get(shared), cs.get(shared)) {
(None, Some(_)) => true,
(Some(a), Some(b)) => shared.checked_sub(1).and_then(|i| ct.get(i)).and_then(|p| doc.node(*p)).is_some_and(|p| {
let order: Vec<NodeId> = p.paint_order().collect();
order.iter().position(|c| c == a) < order.iter().position(|c| c == b)
}),
_ => false,
}
}
/// Which handle (if any) is under `p`. A selection with a locked layer has none.
pub fn handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option<Handle> {
if sel.is_empty() || sel.iter().any(|i| locked_in(doc, *i)) {
@@ -1363,8 +1383,12 @@ impl Session {
.map(|leaf| select_target(&doc, leaf, &sel, ev.cmd))
});
// A press inside the selection's box that reaches nothing above it (an unfilled shape's
// inside, or the frame or canvas behind it) drags the selection.
let behind_sel = |t: Option<NodeId>| t.is_none_or(|t| sel.iter().any(|s| *s != t && doc.is_ancestor(t, *s)));
// inside, the frame or canvas behind it, or a layer stacked below the selected layer the
// press is in) drags the selection. In a gap between selected layers, what's there is hit.
let in_own_box = |s: NodeId| selection_box(&doc, &[s]).is_some_and(|q| in_quad(&q, ev.p()));
let behind_sel = |t: Option<NodeId>| {
t.is_none_or(|t| sel.iter().any(|s| *s != t && (doc.is_ancestor(t, *s) || (in_own_box(*s) && paints_below(&doc, t, *s)))))
};
let target = match sel.iter().copied().find(|s| !locked_in(&doc, *s)) {
Some(first) if label.is_none() && !ev.shift && behind_sel(target) && selection_box(&doc, &sel).is_some_and(|q| in_quad(&q, ev.p())) => {
Some(first)
@@ -1821,10 +1845,11 @@ impl Session {
n.child_layout.sizing_v = Sizing::Fixed;
}
if let NodeKind::Text(t) = &mut n.kind {
t.auto_resize = match handle {
Handle::Edge(1) | Handle::Edge(3) => TextAutoResize::Height,
_ => TextAutoResize::None,
};
match handle {
Handle::Edge(1) | Handle::Edge(3) if t.auto_resize == TextAutoResize::Truncate => {}
Handle::Edge(1) | Handle::Edge(3) => t.auto_resize = TextAutoResize::Height,
_ => t.fix_size(true, true),
}
}
return Ok(());
}
+81 -1
View File
@@ -1,5 +1,5 @@
//! Extended variable collections (overrides, library updates, reset), variables import and
//! export (JSON round trip, merging by name, CSS, junk), gradient stops bound to variables and
//! export (JSON round trip, merging by name, CSS, Dart, junk), gradient stops bound to variables and
//! variant properties driven by string variables.
use figcraft_color::{Color, ColorStop, Gradient, GradientKind, Paint, PaintKind};
@@ -372,6 +372,86 @@ fn variant_properties_follow_string_variables() {
healthy(&s);
}
#[test]
fn variables_export_as_dart_constants_for_flutter() {
let mut s = Session::new();
theme(&mut s);
let dart = ex(&mut s, "variable.export", json!({"format": "dart"}))["text"].as_str().unwrap().to_string();
for want in [
"import 'dart:ui';\n",
"/// Theme · Light\nabstract final class Theme {\n",
" static const Color colorPrimary = Color(0x800000FF);\n",
" static const double radius = 4;\n",
" static const String label = 'Say \"hi\"';\n",
" static const bool on = false;\n",
" static const Color accent = Color(0xFF000000);\n",
"/// Theme · Dark\nabstract final class ThemeDark {\n",
" static const double radius = 6.5;\n",
// An alias is its target's value in that mode.
" static const Color accent = Color(0xFFFFFFFF);\n",
] {
assert!(dart.contains(want), "{want:?} in\n{dart}");
}
// Every class lists every variable once.
assert_eq!(dart.matches("static const").count(), 10, "{dart}");
}
#[test]
fn dart_export_names_are_valid_dart() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "2 brand-tokens", "modes": ["Default", "high contrast"]}));
ex(&mut s, "variable.createCollection", json!({"name": "Brand tokens"}));
for name in ["space/2xl", "class", "a b", "a-b", "\u{65e5}\u{672c}", "it's $5\\"] {
ex(&mut s, "variable.create", json!({"collection": "2 brand-tokens", "name": name, "type": "string", "value": name}));
}
let dart = ex(&mut s, "variable.export", json!({"format": "dart"}))["text"].as_str().unwrap().to_string();
for want in [
"abstract final class V2BrandTokens {",
"abstract final class V2BrandTokensHighContrast {",
"abstract final class BrandTokens {",
"static const String space2xl = 'space/2xl';",
"static const String class_ = 'class';",
// Two names that read the same in Dart stay apart.
"static const String aB = 'a b';",
"static const String aB2 = 'a-b';",
// Nothing usable in a name: still a name.
"static const String v = '\u{65e5}\u{672c}';",
"static const String itS5 = 'it\\'s \\$5\\\\';",
] {
assert!(dart.contains(want), "{want:?} in\n{dart}");
}
}
#[test]
fn dart_export_names_never_shadow_dart_types() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Color"}));
ex(&mut s, "variable.createCollection", json!({"name": "String"}));
for name in ["double", "int", "bool", "num"] {
ex(&mut s, "variable.create", json!({"collection": "Color", "name": name, "type": "number", "value": 1}));
}
ex(&mut s, "variable.create", json!({"collection": "Color", "name": "ratio", "type": "number", "value": 0.125}));
ex(&mut s, "variable.create", json!({"collection": "Color", "name": "third", "type": "number", "value": 1.0 / 3.0}));
ex(&mut s, "variable.create", json!({"collection": "Color", "name": "huge", "type": "number", "value": 1.5e21}));
let dart = ex(&mut s, "variable.export", json!({"format": "dart"}))["text"].as_str().unwrap().to_string();
// A class `Color` would hide dart:ui's `Color`, a constant `double` the type of those after it.
for want in [
"abstract final class Color_ {",
"abstract final class String_ {",
"static const double double_ = 1;",
"static const double int_ = 1;",
"static const double bool_ = 1;",
"static const double num_ = 1;",
// Numbers keep their full precision.
"static const double ratio = 0.125;",
"static const double third = 0.3333333333333333;",
// Too big for an exact integer literal: an exponent, still a double.
"static const double huge = 1.5e21;",
] {
assert!(dart.contains(want), "{want:?} in\n{dart}");
}
}
#[test]
fn paints_replaced_through_node_set_keep_unchanged_stop_bindings() {
let mut s = Session::new();
@@ -0,0 +1,108 @@
//! Android Vector Drawable (XML) export through the commands: one-off exports with their
//! warnings, export settings with a compatibility mode (stored, saved, edited), export layers,
//! and bad values refused.
use base64::Engine as _;
use figcraft_doc::*;
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
/// A session with a 48 × 24 frame holding two overlapping rectangles at 50% opacity.
fn session() -> (Session, NodeId) {
let mut s = Session::new();
let r = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 48, "height": 24, "name": "Badge"})).unwrap();
let f = id_of(&r["id"]).unwrap();
for x in [0, 12] {
s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 24, "height": 24, "parent": f})).unwrap();
}
s.execute("node.set", &json!({"ids": [f], "props": {"opacity": 0.5}})).unwrap();
s.set_selection(vec![f]);
(s, f)
}
fn xml(r: &Value) -> String {
let bytes = base64::engine::general_purpose::STANDARD.decode(r["data"].as_str().unwrap()).unwrap();
String::from_utf8(bytes).unwrap()
}
#[test]
fn a_one_off_export_returns_the_drawable_and_its_warnings() {
let (mut s, f) = session();
let r = s.execute("file.export", &json!({"ids": [f], "format": "xml"})).unwrap();
assert_eq!(r["format"], "xml");
let x = xml(&r);
assert!(x.starts_with("<?xml") && x.contains("<vector") && x.contains("android:width=\"48dp\""), "{x}");
assert_eq!(r["warnings"], json!(["layer opacity was applied to each shape: where shapes overlap they look denser (Badge)"]));
// Exact leaves the faded frame out; ignore draws it opaque, without warnings.
let r = s.execute("file.export", &json!({"ids": [f], "format": "xml", "vdCompat": "exact"})).unwrap();
assert!(!xml(&r).contains("fillColor"), "{}", xml(&r));
// Still a valid (empty) drawable, and it says so.
assert_eq!(r["warnings"].as_array().map(Vec::len), Some(2));
assert_eq!(r["warnings"][1], "nothing in it could be drawn: the drawable is empty (Badge)");
let r = s.execute("file.export", &json!({"ids": [f], "format": "xml", "vdCompat": "ignore"})).unwrap();
assert_eq!(r["warnings"], json!([]));
// Names only when asked.
assert!(!xml(&r).contains("android:name"));
let r = s.execute("file.export", &json!({"ids": [f], "format": "xml", "svgIdAttribute": true})).unwrap();
assert!(xml(&r).contains("android:name=\"Badge\""), "{}", xml(&r));
// Other formats don't grow a `warnings` field.
assert!(s.execute("file.export", &json!({"ids": [f], "format": "svg"})).unwrap().get("warnings").is_none());
}
#[test]
fn a_path_ending_in_xml_picks_the_format() {
let (mut s, f) = session();
let dir = std::env::temp_dir().join(format!("figcraft-vd-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("badge.xml");
let r = s.execute("file.export", &json!({"ids": [f], "path": path.to_string_lossy()})).unwrap();
assert_eq!(r["format"], "xml");
assert!(std::fs::read_to_string(&path).unwrap().contains("<vector"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn export_settings_keep_their_compatibility_mode() {
let (mut s, f) = session();
s.execute("export.add", &json!({"ids": [f], "setting": {"format": "xml", "vdCompat": "exact"}})).unwrap();
let e = s.doc().unwrap().node(f).unwrap().exports[0].clone();
assert_eq!((e.format, e.vd_compat), (ExportFormat::VectorDrawable, VdCompat::Exact));
// Stored camelCase, the default left out.
let v = serde_json::to_value(&e).unwrap();
assert_eq!((v["format"].as_str(), v["vdCompat"].as_str()), (Some("vectorDrawable"), Some("exact")));
assert!(serde_json::to_value(ExportSetting::default()).unwrap().get("vdCompat").is_none());
// Saved and loaded.
let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
let (back, _) = figcraft_format::load_bytes(&bytes).unwrap();
assert_eq!(back.node(f).unwrap().exports[0], e);
// Edited and undone.
s.execute("export.update", &json!({"ids": [f], "index": 0, "vdCompat": "ignore"})).unwrap();
assert_eq!(s.doc().unwrap().node(f).unwrap().exports[0].vd_compat, VdCompat::Ignore);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().node(f).unwrap().exports[0].vd_compat, VdCompat::Exact);
// Export layers: one .xml file with its warnings; a vdCompat passed there wins.
let r = s.execute("file.exportLayers", &json!({"ids": [f]})).unwrap();
let file = &r["files"][0];
assert_eq!(file["name"], "Badge.xml");
assert_eq!(file["warnings"].as_array().map(Vec::len), Some(2), "exact: left out, so empty");
let r = s.execute("file.exportLayers", &json!({"ids": [f], "vdCompat": "ignore"})).unwrap();
assert_eq!(r["files"][0]["warnings"], json!([]));
}
#[test]
fn bad_values_are_refused_and_change_nothing() {
let (mut s, f) = session();
s.execute("export.add", &json!({"ids": [f], "setting": {"format": "xml"}})).unwrap();
for junk in [json!("loose"), json!(3), json!(true), json!("BESTEFFORT"), json!({})] {
assert!(s.execute("file.export", &json!({"ids": [f], "format": "xml", "vdCompat": junk})).is_err(), "{junk}");
assert!(s.execute("export.update", &json!({"ids": [f], "index": 0, "vdCompat": junk})).is_err(), "{junk}");
assert!(s.execute("export.add", &json!({"ids": [f], "setting": {"vdCompat": junk}})).is_err(), "{junk}");
assert!(s.execute("file.exportLayers", &json!({"ids": [f], "vdCompat": junk})).is_err(), "{junk}");
}
let n = s.doc().unwrap().node(f).unwrap();
assert_eq!((n.exports.len(), n.exports[0].vd_compat), (1, VdCompat::BestEffort));
let err = s.execute("file.export", &json!({"ids": [f], "format": "vml"})).unwrap_err().to_string();
assert!(err.contains("xml"), "{err}");
}
+305
View File
@@ -0,0 +1,305 @@
//! Vue code: `inspect.code {language: "vue"}` writes a single-file component — `<template>`, a
//! `<script setup>` with typed props, and a `<style scoped>` — for a layer tree, and a component
//! with its properties as props, its variants as `data-*` attributes and its slots as
//! `<slot>` elements. A Vue template holds no arbitrary expressions, so what React writes as a
//! braced expression (an interpolation, a `v-if`, a `:prop` binding) has its own spelling here.
use crate::Session;
use crate::codegen::CodeLang;
use crate::components::tests::{ex, idv};
use figcraft_doc::*;
use serde_json::json;
fn code(s: &mut Session, id: NodeId, lang: &str) -> String {
ex(s, "inspect.code", json!({"id": id, "language": lang}))["code"].as_str().unwrap_or_default().to_string()
}
fn vue(s: &mut Session, id: NodeId) -> String {
code(s, id, "vue")
}
fn component_code(s: &mut Session, id: NodeId, lang: &str) -> serde_json::Value {
ex(s, "inspect.componentCode", json!({"id": id, "language": lang}))
}
/// A "Card" component with a "Title" text (text property) and a "Dot" ellipse (boolean
/// property "Show dot"). Returns (card, title, dot).
fn card(s: &mut Session) -> (NodeId, NodeId, NodeId) {
let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})));
ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 100, "parent": f, "props": {"name": "Bg", "fill": "#eeeeee"}}));
let title = idv(&ex(s, "text.create", json!({"text": "Hello", "x": 10, "y": 10, "parent": f, "props": {"name": "Title"}})));
let dot = idv(&ex(s, "shape.ellipse", json!({"x": 170, "y": 10, "width": 20, "height": 20, "parent": f, "props": {"name": "Dot"}})));
let c = idv(&ex(s, "component.create", json!({"ids": [f]})));
ex(s, "component.addProperty", json!({"id": dot, "name": "Show dot", "type": "boolean", "default": true}));
ex(s, "component.addProperty", json!({"id": c, "name": "Title", "type": "text", "layer": title}));
(c, title, dot)
}
#[test]
fn a_layer_tree_becomes_a_single_file_component() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 200, "name": "Panel"})));
ex(&mut s, "text.create", json!({"text": "Hi", "x": 10, "y": 10, "parent": f, "props": {"name": "Label"}}));
let out = vue(&mut s, f);
// The three blocks of an SFC, in order, with the tree in the template and the CSS scoped.
assert!(out.starts_with("<template>\n"), "{out}");
assert!(out.contains("</template>\n\n<style scoped>\n"), "{out}");
assert!(out.ends_with("</style>\n"), "{out}");
assert!(out.contains("<div class=\"panel\">"), "{out}");
assert!(out.contains(">Hi</p>"), "{out}");
// A tree without properties needs no script block.
assert!(!out.contains("<script"), "{out}");
}
#[test]
fn components_take_their_properties_as_props() {
let mut s = Session::new();
let (c, _, _) = card(&mut s);
let out = vue(&mut s, c);
// Typed props with their defaults, and the template below.
assert!(out.starts_with("<script setup lang=\"ts\">\n"), "{out}");
assert!(out.contains("withDefaults(\n defineProps<{\n"), "{out}");
assert!(out.contains("showDot?: boolean;"), "{out}");
assert!(out.contains("title?: string;"), "{out}");
assert!(out.contains("showDot: true,"), "{out}");
assert!(out.contains("title: \"Hello\","), "{out}");
assert!(out.contains("</script>\n\n<template>\n"), "{out}");
// A text property is an interpolation, not React's braced expression.
assert!(out.contains("{{ title }}"), "{out}");
}
#[test]
fn a_boolean_property_guards_its_layer_with_v_if() {
let mut s = Session::new();
let (c, _, _) = card(&mut s);
let out = vue(&mut s, c);
// Vue can't wrap an element in an expression: the guard rides on the element itself.
assert!(out.contains("v-if=\"showDot\""), "{out}");
assert!(!out.contains("showDot &&"), "{out}");
}
#[test]
fn variants_switch_on_data_attributes() {
let mut s = Session::new();
let mut variants = Vec::new();
for (k, (state, color)) in [("Default", "#ff0000"), ("Hover", "#0000ff")].iter().enumerate() {
let f =
idv(&ex(&mut s, "frame.create", json!({"x": k as f64 * 150.0, "y": 0, "width": 100, "height": 40, "name": format!("State={state}")})));
ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 40, "parent": f, "props": {"name": "Bg", "fill": color}}));
variants.push(idv(&ex(&mut s, "component.create", json!({"ids": [f]}))));
}
let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": variants})));
let out = vue(&mut s, set);
// A variant property is a string union, bound to the root's data attribute.
assert!(out.contains("state?: \"Default\" | \"Hover\";"), "{out}");
assert!(out.contains(":data-state=\"state\""), "{out}");
// What the other variant changes is a rule on that attribute, as on the web.
assert!(out.contains("[data-state=\"Hover\"]"), "{out}");
}
#[test]
fn instances_are_calls_passing_the_values_they_set() {
let mut s = Session::new();
let (c, _, _) = card(&mut s);
let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 400, "y": 0})));
// Values left at the definition's default aren't passed.
assert_eq!(component_code(&mut s, i, "vue")["usage"], "<Card />\n");
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Show dot": false, "Title": "Buy"}}));
// A plain string is an attribute value; anything else is a `:name` binding.
assert_eq!(component_code(&mut s, i, "vue")["usage"], "<Card :showDot=\"false\" title=\"Buy\" />\n");
let out = code(&mut s, i, "vue");
assert!(out.starts_with("<Card :showDot=\"false\" title=\"Buy\" />\n"), "{out}");
assert!(out.contains("defineProps<{"), "{out}");
}
#[test]
fn a_layer_tree_with_an_instance_calls_it() {
let mut s = Session::new();
let (c, _, _) = card(&mut s);
let parent = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 500, "height": 300, "name": "Page"})));
ex(&mut s, "instance.create", json!({"component": c, "x": 10, "y": 10, "parent": parent}));
let out = vue(&mut s, parent);
assert!(out.contains("<Card "), "{out}");
}
#[test]
fn slots_become_slot_elements() {
let mut s = Session::new();
let frame = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Shell"})));
let body = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "parent": frame, "name": "Body"})));
let c = idv(&ex(&mut s, "component.create", json!({"ids": [frame]})));
ex(&mut s, "component.makeSlot", json!({"id": body, "name": "Content"}));
let out = vue(&mut s, c);
// An empty slot is Vue's own element, and a slot takes no prop.
assert!(out.contains("<slot name=\"content\" />"), "{out}");
assert!(!out.contains("content?: "), "{out}");
assert!(!out.contains("props"), "{out}");
// Content in the slot is its fallback, shown until an instance fills it.
let mut s = Session::new();
let frame = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Shell2"})));
let body = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "parent": frame, "name": "Body2"})));
ex(&mut s, "text.create", json!({"text": "Empty", "x": 0, "y": 0, "parent": body, "props": {"name": "Hint"}}));
let c = idv(&ex(&mut s, "component.create", json!({"ids": [frame]})));
ex(&mut s, "component.makeSlot", json!({"id": body, "name": "Content"}));
let out = vue(&mut s, c);
assert!(out.contains("<slot name=\"content\">"), "{out}");
assert!(out.contains("</slot>"), "{out}");
assert!(out.contains("Empty"), "{out}");
}
#[test]
fn identifiers_are_escaped_and_the_language_round_trips() {
let mut s = Session::new();
let (c, _, _) = card(&mut s);
// "Show dot" is not an identifier: it becomes camelCase in the props and the template alike.
let out = vue(&mut s, c);
assert!(out.contains("showDot"), "{out}");
assert!(!out.contains("Show dot"), "{out}");
// The id is discoverable from what the Inspect panel offers.
assert_eq!(CodeLang::from_id("vue"), Some(CodeLang::Vue));
assert_eq!(CodeLang::from_id("sfc"), Some(CodeLang::Vue));
assert!(CodeLang::Vue.is_web());
assert_eq!(CodeLang::Vue.id(), "vue");
assert!(CodeLang::ALL.contains(&CodeLang::Vue));
}
#[test]
fn rem_converts_lengths_in_the_vue_template_and_its_style() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 320, "height": 64, "name": "Bar"})));
let out = ex(&mut s, "inspect.code", json!({"id": f, "language": "vue", "unit": "rem"}))["code"].as_str().unwrap_or_default().to_string();
assert!(out.contains("20rem"), "{out}");
}
#[test]
fn a_keyword_property_name_stays_usable() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Row"})));
let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
// "default" is a JavaScript keyword: the prop is renamed, and the template follows.
ex(&mut s, "component.addProperty", json!({"id": c, "name": "default", "type": "text", "default": "x"}));
let out = vue(&mut s, c);
assert!(out.contains("defaultValue"), "{out}");
}
#[test]
fn a_property_named_props_is_not_shadowed() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})));
let text = idv(&ex(&mut s, "text.create", json!({"text": "Hello", "x": 0, "y": 0, "parent": f, "props": {"name": "Label"}})));
let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
ex(&mut s, "component.addProperty", json!({"id": c, "name": "Props", "type": "text", "layer": text}));
let out = vue(&mut s, c);
// No top-level `const props` that the template would resolve to instead of the property.
assert!(!out.contains("const props"), "{out}");
assert!(out.contains("props?: string;"), "{out}");
assert!(out.contains("{{ props }}"), "{out}");
}
#[test]
fn a_nested_instance_keeps_its_v_if_guard() {
let mut s = Session::new();
let (card, _, _) = card(&mut s);
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 200, "name": "Toolbar"})));
ex(&mut s, "layout.add", json!({"ids": [f], "direction": "vertical"}));
let nested = idv(&ex(&mut s, "instance.create", json!({"component": card, "x": 10, "y": 10, "parent": f})));
let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
ex(&mut s, "component.addProperty", json!({"id": c, "name": "Show card", "type": "boolean", "default": true, "layer": nested}));
ex(&mut s, "instance.setProperties", json!({"ids": [nested], "properties": {"Title": "a > b"}}));
let out = vue(&mut s, c);
// The call's early return must not swallow the guard `node` put on it, and the `>` inside a
// quoted value must not be mistaken for the tag's end.
assert!(out.contains("v-if=\"showCard\""), "{out}");
assert_eq!(out.matches("v-if=\"showCard\"").count(), 1, "{out}");
assert!(!out.contains("/ v-if"), "the guard must come before a self-closing slash: {out}");
assert!(out.contains("title=\"a &gt; b\"") || out.contains("title=\"a > b\""), "{out}");
}
#[test]
fn a_text_default_cannot_close_the_script_block() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})));
let text = idv(&ex(&mut s, "text.create", json!({"text": "Hello", "x": 0, "y": 0, "parent": f, "props": {"name": "Title"}})));
let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
ex(
&mut s,
"component.addProperty",
json!({"id": c, "name": "Title", "type": "text", "layer": text, "default": "</script><script>alert(1)</script>"}),
);
let out = vue(&mut s, c);
assert!(out.contains("\\u003C/script>"), "{out}");
assert_eq!(out.matches("</script>").count(), 1, "only the SFC's own closing tag: {out}");
}
#[test]
fn slot_names_stay_distinct_when_idents_lowercase_alike() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 200, "name": "Shell"})));
let a = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "parent": f, "name": "A"})));
let b = idv(&ex(&mut s, "frame.create", json!({"x": 100, "y": 0, "width": 100, "height": 100, "parent": f, "name": "B"})));
let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
ex(&mut s, "component.makeSlot", json!({"id": a, "name": "Foo Bar"}));
ex(&mut s, "component.makeSlot", json!({"id": b, "name": "Foobar"}));
let out = vue(&mut s, c);
assert!(out.contains("<slot name=\"foobar\""), "{out}");
assert!(out.contains("<slot name=\"foobar-2\""), "{out}");
// The instance's templates use the same names.
let g = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 20, "height": 20, "name": "Badge"})));
let badge = idv(&ex(&mut s, "component.create", json!({"ids": [g]})));
let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 400, "y": 0})));
ex(&mut s, "instance.slotInsert", json!({"id": i, "slot": "Foo Bar", "component": badge, "replace": true}));
ex(&mut s, "instance.slotInsert", json!({"id": i, "slot": "Foobar", "component": badge, "replace": true}));
let usage = component_code(&mut s, i, "vue")["usage"].as_str().unwrap_or_default().to_string();
assert!(usage.contains("<template #foobar>"), "{usage}");
assert!(usage.contains("<template #foobar-2>"), "{usage}");
}
#[test]
fn slot_content_css_reaches_the_parent_stylesheet() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 200, "name": "Panel"})));
let content = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "parent": f, "name": "Content"})));
let panel = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
ex(&mut s, "component.makeSlot", json!({"id": content, "name": "Content"}));
let screen = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 400, "width": 400, "height": 300, "name": "Screen"})));
let i = idv(&ex(&mut s, "instance.create", json!({"component": panel, "x": 10, "y": 10, "parent": screen})));
let slot = {
let doc = s.doc().unwrap();
crate::components::slots_of(doc, i).first().map(|(l, _)| *l).unwrap()
};
ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10, "parent": slot, "props": {"name": "Swatch", "fill": "#FF0000"}}));
// The layer's class rules belong to the tree that holds the instance, not to a discarded
// snippet: both web targets keep them in their stylesheet.
let out = vue(&mut s, screen);
assert!(out.contains(".slot-content-swatch {"), "{out}");
assert!(out.contains("#FF0000"), "{out}");
assert!(code(&mut s, screen, "react").contains(".slot-content-swatch {"));
}
#[test]
fn slot_content_classes_are_unique_across_slots() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 200, "name": "Panel"})));
let one = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "parent": f, "name": "One"})));
let two = idv(&ex(&mut s, "frame.create", json!({"x": 100, "y": 0, "width": 100, "height": 100, "parent": f, "name": "Two"})));
let panel = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
ex(&mut s, "component.makeSlot", json!({"id": one, "name": "One"}));
ex(&mut s, "component.makeSlot", json!({"id": two, "name": "Two"}));
let screen = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 400, "width": 400, "height": 300, "name": "Screen"})));
let i = idv(&ex(&mut s, "instance.create", json!({"component": panel, "x": 10, "y": 10, "parent": screen})));
let slots = {
let doc = s.doc().unwrap();
crate::components::slots_of(doc, i)
};
for (name, fill) in [("One", "#FF0000"), ("Two", "#0000FF")] {
let slot = slots.iter().find(|(_, n)| n.as_str() == name).map(|(l, _)| *l).unwrap();
ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10, "parent": slot, "props": {"name": "Swatch", "fill": fill}}));
}
let out = vue(&mut s, screen);
// Same layer name in two slots: each slot's rules keep their own class.
assert!(out.contains(".slot-one-swatch {"), "{out}");
assert!(out.contains(".slot-two-swatch {"), "{out}");
assert!(out.contains("#FF0000"), "{out}");
assert!(out.contains("#0000FF"), "{out}");
}
+1
View File
@@ -16,5 +16,6 @@ serde_json = { workspace = true }
zip = { workspace = true }
flate2 = { workspace = true }
ruzstd = { workspace = true }
sha1 = { workspace = true }
sha2 = { workspace = true }
image = { workspace = true }
+332 -52
View File
@@ -31,6 +31,7 @@ use figcraft_doc::{
VariableValue, VectorNetwork, WindingRule,
};
use serde_json::Value;
use sha1::Digest;
use super::kiwi::{Kv, Schema, encode};
use super::network;
@@ -104,7 +105,7 @@ enum ImageType PNG=0 JPEG=1 SVG=2 PDF=3
enum ExportConstraintType CONTENT_SCALE=0 CONTENT_WIDTH=1 CONTENT_HEIGHT=2
enum ComponentPropType BOOL=0 TEXT=1 INSTANCE_SWAP=3 VARIANT=4
enum VariableField MISSING=0 CORNER_RADIUS=1 PARAGRAPH_SPACING=2 PARAGRAPH_INDENT=3 STROKE_WEIGHT=4 STACK_SPACING=5 STACK_PADDING_LEFT=6 STACK_PADDING_TOP=7 STACK_PADDING_RIGHT=8 STACK_PADDING_BOTTOM=9 VISIBLE=10 TEXT_DATA=11 WIDTH=12 HEIGHT=13 RECTANGLE_TOP_LEFT_CORNER_RADIUS=14 RECTANGLE_TOP_RIGHT_CORNER_RADIUS=15 RECTANGLE_BOTTOM_LEFT_CORNER_RADIUS=16 RECTANGLE_BOTTOM_RIGHT_CORNER_RADIUS=17 BORDER_TOP_WEIGHT=18 BORDER_BOTTOM_WEIGHT=19 BORDER_LEFT_WEIGHT=20 BORDER_RIGHT_WEIGHT=21 STACK_COUNTER_SPACING=23 MIN_WIDTH=24 MAX_WIDTH=25 MIN_HEIGHT=26 MAX_HEIGHT=27 FONT_FAMILY=28 FONT_STYLE=29 OPACITY=31 FONT_SIZE=32 LETTER_SPACING=34 LINE_HEIGHT=36 OVERRIDDEN_SYMBOL_ID=37
enum VariableDataType BOOLEAN=0 FLOAT=1 STRING=2 ALIAS=3 COLOR=4 PROP_REF=13
enum VariableDataType BOOLEAN=0 FLOAT=1 STRING=2 ALIAS=3 COLOR=4 SYMBOL_ID=7 TEXT_DATA=9 PROP_REF=13
enum VariableScope ALL_SCOPES=0 TEXT_CONTENT=1 CORNER_RADIUS=2 WIDTH_HEIGHT=3 GAP=4 ALL_FILLS=5 FRAME_FILL=6 SHAPE_FILL=7 TEXT_FILL=8 STROKE=9 STROKE_FLOAT=10 EFFECT_FLOAT=11 EFFECT_COLOR=12 OPACITY=13 FONT_STYLE=14 FONT_FAMILY=15 FONT_SIZE=16 LINE_HEIGHT=17 LETTER_SPACING=18 PARAGRAPH_SPACING=19 PARAGRAPH_INDENT=20
enum StyleType NONE=0 FILL=1 STROKE=2 TEXT=3 EFFECT=4 EXPORT=5 GRID=6
enum InteractionType ON_CLICK=0 DRAG=9 ON_HOVER=4 ON_PRESS=7 MOUSE_IN=2 MOUSE_OUT=3 MOUSE_DOWN=5 MOUSE_UP=6 AFTER_TIMEOUT=1 ON_KEY_DOWN=10
@@ -148,10 +149,16 @@ message ExportSettings string suffix=1, ImageType imageType=2, ExportConstraint
message GUIDPath GUID[] guids=1
message SymbolData GUID symbolID=1, NodeChange[] symbolOverrides=2, float uniformScaleFactor=3
message ComponentPropValue bool boolValue=1, TextData textValue=2, GUID guidValue=3
message ComponentPropDef GUID id=1, string name=2, ComponentPropValue initialValue=3, ComponentPropType type=6
message ComponentPropAssignment GUID defID=1, ComponentPropValue value=2
message ComponentPropPreferredValues string[] stringValues=1
message ComponentPropDef GUID id=1, string name=2, ComponentPropValue initialValue=3, string sortPosition=4, GUID parentPropDefId=5, ComponentPropType type=6, ComponentPropPreferredValues preferredValues=8, VariableData varValue=9
message ComponentPropAssignment GUID defID=1, ComponentPropValue value=2, VariableData varValue=3
enum ComponentPropNodeField VISIBLE=0 TEXT_DATA=1 OVERRIDDEN_SYMBOL_ID=2
message ComponentPropRef GUID defID=2, ComponentPropNodeField componentPropNodeField=4
message VariantPropSpec GUID propDefId=1, string value=2
message StateGroupPropertyValueOrder string property=1, string[] values=2
message PropRefValue GUID defId=1
message VariableAnyValue bool boolValue=1, string textValue=2, float floatValue=3, VariableID alias=4, Color colorValue=5, PropRefValue propRefValue=13
message SymbolId GUID guid=1
message VariableAnyValue bool boolValue=1, string textValue=2, float floatValue=3, VariableID alias=4, Color colorValue=5, SymbolId symbolIdValue=8, TextData textDataValue=10, PropRefValue propRefValue=13
message VariableData VariableAnyValue value=1, VariableDataType dataType=2, VariableResolvedDataType resolvedDataType=3
message VariableDataMapEntry uint nodeField=1, VariableData variableData=2, VariableField variableField=3
message VariableDataMap VariableDataMapEntry[] entries=1
@@ -169,11 +176,11 @@ message OverlayBackgroundAppearance OverlayBackgroundType backgroundType=1, Colo
message GUIDPositionMapEntry GUID id=1, string position=2
message GUIDPositionMap GUIDPositionMapEntry[] entries=1
enum GridTrackSizingType FLEX=0 FIXED=1 HUG=2 UNKNOWN=3
message GridTrackSizeFunction GridTrackSizingType type=1, float value=2
message GridTrackSize GridTrackSizeFunction minSizing=1, GridTrackSizeFunction maxSizing=2
message GridTrackSizeEntry GUID id=1, GridTrackSize trackSize=2
message GridTrackSizeMap GridTrackSizeEntry[] entries=1
message NodeChange GUID guid=1, NodePhase phase=2, ParentIndex parentIndex=3, NodeType type=4, string name=5, bool visible=6, bool locked=7, float opacity=8, BlendMode blendMode=9, Vector size=11, Matrix transform=12, bool mask=16, MaskType maskType=317, float backgroundOpacity=19, bool backgroundEnabled=15, bool internalOnly=142, string description=318, StyleType styleType=163, string sortPosition=320, StyleId styleIdForFill=332, StyleId styleIdForStrokeFill=333, StyleId styleIdForText=334, StyleId styleIdForEffect=335, StyleId styleIdForGrid=336, VariableDataMap variableConsumptionMap=307, VariableModeBySetMap variableModeBySetMap=316, VariableSetMode[] variableSetModes=312, VariableSetID variableSetID=313, VariableResolvedDataType variableResolvedType=314, VariableDataValues variableDataValues=315, VariableScope[] variableScopes=353, DocumentColorProfile documentColorProfile=349, Paint[] fillPaints=38, Paint[] strokePaints=39, float strokeWeight=26, StrokeAlign strokeAlign=29, StrokeJoin strokeJoin=31, StrokeCap strokeCap=30, float[] dashPattern=13, float miterLimit=25, bool borderStrokeWeightsIndependent=299, float borderTopWeight=295, float borderRightWeight=298, float borderBottomWeight=296, float borderLeftWeight=297, Effect[] effects=43, float cornerRadius=20, float cornerSmoothing=160, bool rectangleCornerRadiiIndependent=149, float rectangleTopLeftCornerRadius=145, float rectangleTopRightCornerRadius=146, float rectangleBottomRightCornerRadius=148, float rectangleBottomLeftCornerRadius=147, ConstraintType horizontalConstraint=28, ConstraintType verticalConstraint=37, bool frameMaskDisabled=115, bool resizeToFit=117, bool isStateGroup=225, bool sectionContentsHidden=283, Color backgroundColor=50, StackMode stackMode=105, float stackSpacing=107, float stackCounterSpacing=324, StackWrap stackWrap=323, StackCounterAlignContent stackCounterAlignContent=343, float stackHorizontalPadding=209, float stackVerticalPadding=210, float stackPaddingRight=233, float stackPaddingBottom=234, StackJustify stackPrimaryAlignItems=230, StackAlign stackCounterAlignItems=231, StackSize stackPrimarySizing=229, StackSize stackCounterSizing=221, bool bordersTakeSpace=294, bool stackReverseZIndex=271, float gridColumnGap=438, float gridRowGap=437, GUIDPositionMap gridColumns=436, GUIDPositionMap gridRows=435, GridTrackSizeMap gridColumnsSizing=443, GridTrackSizeMap gridRowsSizing=444, float stackChildPrimaryGrow=232, StackCounterAlign stackChildAlignSelf=236, StackPositioning stackPositioning=269, OptionalVector minSize=325, OptionalVector maxSize=326, GUID gridColumnAnchor=440, GUID gridRowAnchor=439, uint gridColumnSpan=442, uint gridRowSpan=441, LayoutGrid[] layoutGrids=47, ExportSettings[] exportSettings=45, TextData textData=42, FontName fontName=41, float fontSize=21, Number lineHeight=40, Number letterSpacing=165, TextCase textCase=34, TextDecoration textDecoration=35, TextAlignHorizontal textAlignHorizontal=32, TextAlignVertical textAlignVertical=33, TextAutoResize textAutoResize=46, TextTruncation textTruncation=280, int maxLines=351, float paragraphSpacing=23, float paragraphIndent=22, Hyperlink hyperlink=223, bool autoRename=14, bool fontVariantCommonLigatures=127, bool fontVariantContextualLigatures=128, string fontVersion=202, uint textUserLayoutVersion=203, uint textExplicitLayoutVersion=396, uint textBidiVersion=279, float textTracking=27, bool textDecorationSkipInk=412, EmojiImageSet emojiImageSet=391, uint styleID=49, Path[] fillGeometry=51, Path[] strokeGeometry=52, VectorData vectorData=48, VectorMirror handleMirroring=44, uint count=10, float starInnerScale=24, ArcData arcData=195, BooleanOperation booleanOperation=36, string symbolDescription=144, VariableWidthPoint[] variableWidthPoints=447, PluginData[] pluginData=204, SymbolData symbolData=113, NodeChange[] derivedSymbolData=125, GUID overriddenSymbolID=143, ComponentPropDef[] componentPropDefs=266, ComponentPropAssignment[] componentPropAssignments=268, VariableDataMap parameterConsumptionMap=445, GUIDPath guidPath=111, PrototypeInteraction[] prototypeInteractions=226, PrototypeStartingPoint prototypeStartingPoint=249, ScrollDirection scrollDirection=159, ScrollBehavior scrollBehavior=186, OverlayPositionType overlayPositionType=198, OverlayBackgroundInteraction overlayBackgroundInteraction=200, OverlayBackgroundAppearance overlayBackgroundAppearance=201
message GridTrackSizingFunction GridTrackSizingType type=1, float value=2
message GridTrackSize GridTrackSizingFunction minSizing=1, GridTrackSizingFunction maxSizing=2
message GUIDGridTrackSizeMapEntry GUID id=1, GridTrackSize trackSize=2
message GUIDGridTrackSizeMap GUIDGridTrackSizeMapEntry[] entries=1
message NodeChange GUID guid=1, NodePhase phase=2, ParentIndex parentIndex=3, NodeType type=4, string name=5, bool visible=6, bool locked=7, float opacity=8, BlendMode blendMode=9, Vector size=11, Matrix transform=12, bool mask=16, MaskType maskType=317, float backgroundOpacity=19, bool backgroundEnabled=15, bool internalOnly=142, string description=318, StyleType styleType=163, string sortPosition=320, StyleId styleIdForFill=332, StyleId styleIdForStrokeFill=333, StyleId styleIdForText=334, StyleId styleIdForEffect=335, StyleId styleIdForGrid=336, VariableDataMap variableConsumptionMap=307, VariableModeBySetMap variableModeBySetMap=316, VariableSetMode[] variableSetModes=312, VariableSetID variableSetID=313, VariableResolvedDataType variableResolvedType=314, VariableDataValues variableDataValues=315, VariableScope[] variableScopes=353, DocumentColorProfile documentColorProfile=349, Paint[] fillPaints=38, Paint[] strokePaints=39, float strokeWeight=26, StrokeAlign strokeAlign=29, StrokeJoin strokeJoin=31, StrokeCap strokeCap=30, float[] dashPattern=13, float miterLimit=25, bool borderStrokeWeightsIndependent=299, float borderTopWeight=295, float borderRightWeight=298, float borderBottomWeight=296, float borderLeftWeight=297, Effect[] effects=43, float cornerRadius=20, float cornerSmoothing=160, bool rectangleCornerRadiiIndependent=149, float rectangleTopLeftCornerRadius=145, float rectangleTopRightCornerRadius=146, float rectangleBottomRightCornerRadius=148, float rectangleBottomLeftCornerRadius=147, ConstraintType horizontalConstraint=28, ConstraintType verticalConstraint=37, bool frameMaskDisabled=115, bool resizeToFit=117, bool isStateGroup=225, StateGroupPropertyValueOrder[] stateGroupPropertyValueOrders=238, bool sectionContentsHidden=283, Color backgroundColor=50, StackMode stackMode=105, float stackSpacing=107, float stackCounterSpacing=324, StackWrap stackWrap=323, StackCounterAlignContent stackCounterAlignContent=343, float stackHorizontalPadding=209, float stackVerticalPadding=210, float stackPaddingRight=233, float stackPaddingBottom=234, StackJustify stackPrimaryAlignItems=230, StackAlign stackCounterAlignItems=231, StackSize stackPrimarySizing=229, StackSize stackCounterSizing=221, bool bordersTakeSpace=294, bool stackReverseZIndex=271, float gridColumnGap=438, float gridRowGap=437, GUIDPositionMap gridColumns=436, GUIDPositionMap gridRows=435, GUIDGridTrackSizeMap gridColumnsSizing=474, GUIDGridTrackSizeMap gridRowsSizing=475, float stackChildPrimaryGrow=232, StackCounterAlign stackChildAlignSelf=236, StackPositioning stackPositioning=269, OptionalVector minSize=325, OptionalVector maxSize=326, GUID gridColumnAnchor=440, GUID gridRowAnchor=439, uint gridColumnSpan=442, uint gridRowSpan=441, LayoutGrid[] layoutGrids=47, ExportSettings[] exportSettings=45, TextData textData=42, FontName fontName=41, float fontSize=21, Number lineHeight=40, Number letterSpacing=165, TextCase textCase=34, TextDecoration textDecoration=35, TextAlignHorizontal textAlignHorizontal=32, TextAlignVertical textAlignVertical=33, TextAutoResize textAutoResize=46, TextTruncation textTruncation=280, int maxLines=351, float paragraphSpacing=23, float paragraphIndent=22, Hyperlink hyperlink=223, bool autoRename=14, bool fontVariantCommonLigatures=127, bool fontVariantContextualLigatures=128, string fontVersion=202, uint textUserLayoutVersion=203, uint textExplicitLayoutVersion=396, uint textBidiVersion=279, float textTracking=27, bool textDecorationSkipInk=412, EmojiImageSet emojiImageSet=391, uint styleID=49, Path[] fillGeometry=51, Path[] strokeGeometry=52, VectorData vectorData=48, VectorMirror handleMirroring=44, uint count=10, float starInnerScale=24, ArcData arcData=195, BooleanOperation booleanOperation=36, string symbolDescription=144, VariableWidthPoint[] variableWidthPoints=447, PluginData[] pluginData=204, SymbolData symbolData=113, NodeChange[] derivedSymbolData=125, GUID overriddenSymbolID=143, ComponentPropDef[] componentPropDefs=266, ComponentPropRef[] componentPropRefs=267, ComponentPropAssignment[] componentPropAssignments=268, VariantPropSpec[] variantPropSpecs=483, VariableDataMap parameterConsumptionMap=445, GUIDPath guidPath=111, PrototypeInteraction[] prototypeInteractions=226, PrototypeStartingPoint prototypeStartingPoint=249, ScrollDirection scrollDirection=159, ScrollBehavior scrollBehavior=186, OverlayPositionType overlayPositionType=198, OverlayBackgroundInteraction overlayBackgroundInteraction=200, OverlayBackgroundAppearance overlayBackgroundAppearance=201
struct Blob byte[] bytes
message Message MessageType type=1, uint sessionID=2, NodeChange[] nodeChanges=4, Blob[] blobs=6
";
@@ -442,12 +449,18 @@ struct Exporter<'d> {
images: BTreeMap<String, Option<String>>,
/// `images/<hash>` members.
files: Vec<(String, Arc<Vec<u8>>)>,
/// Component property (name, type) → local id of its definition's GUID (session [`AUX`]).
defs: BTreeMap<(String, &'static str), u32>,
/// Component property (owner, name, type) → local id of its definition's GUID (session [`AUX`]).
/// The owner is the component, or the set for a variant (variant properties included, type
/// `VARIANT`).
defs: BTreeMap<(NodeId, String, &'static str), u32>,
/// A variant's own definition of each of its set's properties (variant, name, type) → local
/// id (session [`AUX`]): what its layers and its instances refer to.
child_defs: BTreeMap<(NodeId, String, &'static str), u32>,
/// Grid tracks of written grid frames: (columns, rows) local ids (session [`AUX`]).
tracks: BTreeMap<NodeId, (Vec<u32>, Vec<u32>)>,
aux: u32,
overlays: BTreeMap<NodeId, Overlay>,
trigger_size: [f64; 2],
/// Flow starting points: layer → (order, name).
starts: BTreeMap<NodeId, (usize, String)>,
/// Shared styles, variable collections and variables → local id of their node's GUID (session
@@ -483,9 +496,11 @@ impl<'d> Exporter<'d> {
images: BTreeMap::new(),
files: Vec::new(),
defs: BTreeMap::new(),
child_defs: BTreeMap::new(),
tracks: BTreeMap::new(),
aux: 0,
overlays: BTreeMap::new(),
trigger_size: [0.0, 0.0],
starts: BTreeMap::new(),
style_ids: BTreeMap::new(),
set_ids: BTreeMap::new(),
@@ -510,6 +525,54 @@ impl<'d> Exporter<'d> {
self.ids.get(&id).map(|l| guid(LAYERS, *l))
}
/// The component set component `id` is a variant of.
fn set_of(&self, id: NodeId) -> Option<NodeId> {
let parent = self.doc.node(id)?.parent.and_then(|p| self.doc.node(p))?;
matches!(parent.kind, NodeKind::ComponentSet { .. }).then_some(parent.id)
}
/// The local id a layer of component `comp` (or an instance of it) uses for its property
/// `name` of type `ty`: a variant's own definition, else the component's.
fn def_for(&self, comp: NodeId, name: &str, ty: &'static str) -> Option<u32> {
let key = (comp, name.to_string(), ty);
let defs = if self.set_of(comp).is_some() { &self.child_defs } else { &self.defs };
defs.get(&key).copied()
}
/// The main component a layer belongs to (itself when it is one). A layer inside an instance
/// mirrors its main's layer (`source`, through nested instances): that layer's component, so
/// the instance's layers refer to what the main's do. An instance placed in a component is the
/// component's own layer (its `source` is its main, which it doesn't belong to).
fn component_of(&self, n: &Node) -> Option<NodeId> {
let in_instance = |x: &Node| {
let mut p = x.parent.and_then(|p| self.doc.node(p));
for _ in 0..MAX_DEPTH {
match p {
Some(a) if matches!(a.kind, NodeKind::Instance { .. }) => return true,
Some(a) => p = a.parent.and_then(|q| self.doc.node(q)),
None => return false,
}
}
false
};
let mut n = n;
for _ in 0..MAX_DEPTH {
match n.source.and_then(|s| self.doc.node(s)) {
Some(src) if in_instance(n) => n = src,
_ => break,
}
}
let mut cur = Some(n);
for _ in 0..MAX_DEPTH {
let c = cur?;
if matches!(c.kind, NodeKind::Component { .. }) {
return Some(c.id);
}
cur = c.parent.and_then(|p| self.doc.node(p));
}
None
}
/// The message: the document, its pages and their layers, and the blobs.
fn message(&mut self) -> Kv<'static> {
let doc = self.doc;
@@ -556,12 +619,27 @@ impl<'d> Exporter<'d> {
}
match &n.kind {
NodeKind::Component { props, .. } => {
let set = self.set_of(id);
let owner = set.unwrap_or(id);
for (name, d) in props {
if let Some(ty) = prop_type(d) {
let key = (name.clone(), ty);
let key = (owner, name.clone(), ty);
if !self.defs.contains_key(&key) {
let id = self.next_aux();
self.defs.insert(key, id);
let def = self.next_aux();
self.defs.insert(key, def);
}
if set.is_some() {
let child = self.next_aux();
self.child_defs.insert((id, name.clone(), ty), child);
}
}
}
if let Some(set) = set {
for (prop, _) in variant_pairs(&n.name) {
let key = (set, prop, "VARIANT");
if !self.defs.contains_key(&key) {
let def = self.next_aux();
self.defs.insert(key, def);
}
}
}
@@ -574,8 +652,9 @@ impl<'d> Exporter<'d> {
_ => {}
}
for a in n.interactions.iter().flat_map(|it| figcraft_doc::proto::walk(&it.actions)) {
if let Action::OpenOverlay { dest, position, close_on_outside, background, .. } = a {
let o = Overlay { position: *position, close_on_outside: *close_on_outside, background: *background };
if let Action::OpenOverlay { dest, position, close_on_outside, background, anchor, .. } = a {
let position = if !anchor.is_screen() && *position != OverlayPosition::Manual { OverlayPosition::Manual } else { *position };
let o = Overlay { position, close_on_outside: *close_on_outside, background: *background };
match self.overlays.get(dest) {
Some(old) if (old.position, old.close_on_outside, old.background) != (o.position, o.close_on_outside, o.background) => {
self.warn("overlays opened with different settings keep the first one's");
@@ -985,9 +1064,36 @@ impl<'d> Exporter<'d> {
}
}
NodeKind::Component { description, props } if !nested => {
let defs: Vec<Kv> = props.iter().filter_map(|(name, d)| self.prop_def(name, d)).collect();
if !defs.is_empty() {
f.push(("componentPropDefs", arr(defs)));
if let Some(set) = self.set_of(n.id) {
// A variant: its set holds the definitions; the variant has its own copy of
// each (pointing at the set's), and its values of the variant properties.
let own: Vec<Kv> = props
.iter()
.filter_map(|(name, d)| {
let ty = prop_type(d)?;
let id = *self.child_defs.get(&(n.id, name.clone(), ty))?;
let parent = *self.defs.get(&(set, name.clone(), ty))?;
Some(o(vec![("id", guid(AUX, id)), ("parentPropDefId", guid(AUX, parent))]))
})
.collect();
if !own.is_empty() {
f.push(("componentPropDefs", arr(own)));
}
let specs: Vec<Kv> = variant_pairs(&n.name)
.into_iter()
.filter_map(|(prop, value)| {
let id = *self.defs.get(&(set, prop, "VARIANT"))?;
Some(o(vec![("propDefId", guid(AUX, id)), ("value", s(&value))]))
})
.collect();
if !specs.is_empty() {
f.push(("variantPropSpecs", arr(specs)));
}
} else {
let defs: Vec<Kv> = props.iter().enumerate().filter_map(|(i, (name, d))| self.prop_def(n.id, name, d, i)).collect();
if !defs.is_empty() {
f.push(("componentPropDefs", arr(defs)));
}
}
if props.values().any(|d| matches!(d, PropDef::Slot { .. })) {
self.warn("slot properties were left out");
@@ -998,6 +1104,7 @@ impl<'d> Exporter<'d> {
}
NodeKind::ComponentSet { description } if !nested => {
f.push(("isStateGroup", b(true)));
self.set_props(n, f);
if !description.is_empty() {
f.push(("symbolDescription", s(description)));
}
@@ -1099,12 +1206,16 @@ impl<'d> Exporter<'d> {
if n.exports.iter().any(|x| x.format == figcraft_doc::ExportFormat::Webp) {
self.warn("WebP export settings were written as PNG");
}
if n.exports.iter().any(|x| x.format == figcraft_doc::ExportFormat::VectorDrawable) {
self.warn("Android Vector Drawable (XML) export settings were written as SVG");
}
f.push(("exportSettings", arr(n.exports.iter().map(export_setting).collect())));
}
self.prototype(n, f);
let refs: Vec<Kv> = n.prop_refs.iter().filter_map(|(field, prop)| self.prop_ref(field, prop)).collect();
if !refs.is_empty() {
f.push(("parameterConsumptionMap", o(vec![("entries", arr(refs))])));
let (entries, refs): (Vec<Kv>, Vec<Kv>) = n.prop_refs.iter().filter_map(|(field, prop)| self.prop_ref(n, field, prop)).unzip();
if !entries.is_empty() {
f.push(("parameterConsumptionMap", o(vec![("entries", arr(entries))])));
f.push(("componentPropRefs", arr(refs)));
}
let text = matches!(n.kind, NodeKind::Text(_));
let links = [
@@ -1254,7 +1365,7 @@ impl<'d> Exporter<'d> {
None => {
let h = match self.doc.images.get(key) {
Some(img) if img.bytes.len() as u64 <= MAX_IMAGE => {
let h = crate::image_hash(&img.bytes);
let h = sha1_hex(&img.bytes);
if !self.files.iter().any(|(x, _)| *x == h) {
self.files.push((h.clone(), img.bytes.clone()));
}
@@ -1484,9 +1595,12 @@ impl<'d> Exporter<'d> {
/// and are written whatever its parent is.
fn child_layout(&mut self, n: &Node, parent: Option<&Node>, f: &mut Fields) {
let cl = &n.child_layout;
for (key, w, h) in [("minSize", cl.min_w, cl.min_h), ("maxSize", cl.max_w, cl.max_h)] {
// An axis without a minimum is 0; one without a maximum is infinite (0 would be a maximum
// of 0, which collapses the layer in readers that apply it).
for (key, w, h, none) in [("minSize", cl.min_w, cl.min_h, 0.0), ("maxSize", cl.max_w, cl.max_h, f32::INFINITY)] {
if w.is_some() || h.is_some() {
f.push((key, o(vec![("value", xy(w.unwrap_or(0.0), h.unwrap_or(0.0)))])));
let axis = |x: Option<f64>| x.map_or(Kv::Float(none), fl);
f.push((key, o(vec![("value", o(vec![("x", axis(w)), ("y", axis(h))]))])));
}
}
let Some(pl) = parent.and_then(|p| p.layout.as_ref()) else { return };
@@ -1555,7 +1669,7 @@ impl<'d> Exporter<'d> {
("textTracking", fl(0.0)),
("emojiImageSet", e("APPLE")),
]);
if td.auto_resize == TextAutoResize::Truncate {
if td.truncates() {
f.push(("textTruncation", e("ENDING")));
}
if td.max_lines > 0 {
@@ -1854,26 +1968,105 @@ impl<'d> Exporter<'d> {
Some(i)
}
/// A component property definition (variant properties are implied by the variants' names).
fn prop_def(&mut self, name: &str, d: &PropDef) -> Option<Kv<'static>> {
/// A component property definition of `owner` (a component, or a set), `index`-th in its list.
/// The default value is written twice: as `initialValue` and as `varValue`, which is what
/// readers that know variables take.
fn prop_def(&mut self, owner: NodeId, name: &str, d: &PropDef, index: usize) -> Option<Kv<'static>> {
let ty = prop_type(d)?;
let id = *self.defs.get(&(name.to_string(), ty))?;
let value = match d {
PropDef::Boolean { default } => o(vec![("boolValue", b(*default))]),
PropDef::Text { default } => o(vec![("textValue", o(vec![("characters", s(default))]))]),
let id = *self.defs.get(&(owner, name.to_string(), ty))?;
let (value, var) = match d {
PropDef::Boolean { default } => (o(vec![("boolValue", b(*default))]), bool_data(*default)),
PropDef::Text { default } => (o(vec![("textValue", text_value(default))]), text_data(default)),
PropDef::InstanceSwap { default, preferred } => {
if !preferred.is_empty() {
self.warn("preferred values of instance swap properties were left out");
}
o(vec![("guidValue", self.guid_of(*default)?)])
let main = self.guid_of(*default)?;
(o(vec![("guidValue", main.clone())]), symbol_data(main))
}
PropDef::Variant { .. } | PropDef::Slot { .. } => return None,
};
Some(o(vec![("id", guid(AUX, id)), ("name", s(name)), ("initialValue", value), ("type", e(ty))]))
Some(o(vec![
("id", guid(AUX, id)),
("name", s(name)),
("initialValue", value),
("sortPosition", s(&sort_position(index))),
("type", e(ty)),
("preferredValues", o(Vec::new())),
("varValue", var),
]))
}
/// Which property controls a layer's `field`.
fn prop_ref(&mut self, field: &str, prop: &str) -> Option<Kv<'static>> {
/// A component set's property definitions: its variant properties (from the variants' names,
/// in the order they first appear, with their values in order) and the other properties its
/// variants share; written on the set, as the format keeps them.
fn set_props(&mut self, set: &Node, f: &mut Fields) {
let doc = self.doc;
let variants: Vec<&Node> =
set.children.iter().filter_map(|c| doc.node(*c)).filter(|c| matches!(c.kind, NodeKind::Component { .. })).collect();
let mut order: Vec<(String, Vec<String>)> = Vec::new();
for v in &variants {
for (prop, value) in variant_pairs(&v.name) {
let i = match order.iter().position(|(p, _)| *p == prop) {
Some(i) => i,
None => {
order.push((prop, Vec::new()));
order.len() - 1
}
};
if let Some((_, values)) = order.get_mut(i)
&& !values.contains(&value)
{
values.push(value);
}
}
}
let mut defs = Vec::new();
for (prop, _) in &order {
if let Some(&id) = self.defs.get(&(set.id, prop.clone(), "VARIANT")) {
defs.push(o(vec![
("id", guid(AUX, id)),
("name", s(prop)),
("initialValue", o(Vec::new())),
("sortPosition", s(&sort_position(defs.len()))),
("type", e("VARIANT")),
("preferredValues", o(Vec::new())),
("varValue", var_data(("textValue", s("")), "STRING", "STRING")),
]));
}
}
// The format keeps one default per property of a set (a variant's copy holds none): the first
// variant's, with a warning when another variant's differs.
// A name used with two types is two properties (their definitions have their own ids).
let mut seen: BTreeMap<(String, &'static str), PropDef> = BTreeMap::new();
for v in &variants {
let NodeKind::Component { props, .. } = &v.kind else { continue };
for (name, d) in props {
let Some(ty) = prop_type(d) else { continue };
match seen.get(&(name.clone(), ty)) {
Some(first) if first != d => self.warn("variants with different defaults for one property were written with the first variant's"),
Some(_) => {}
None => {
seen.insert((name.clone(), ty), d.clone());
let index = defs.len();
defs.extend(self.prop_def(set.id, name, d, index));
}
}
}
}
if !defs.is_empty() {
f.push(("componentPropDefs", arr(defs)));
}
if !order.is_empty() {
let orders = order.into_iter().map(|(p, values)| o(vec![("property", s(&p)), ("values", arr(values.iter().map(|x| s(x)).collect()))]));
f.push(("stateGroupPropertyValueOrders", arr(orders.collect())));
}
}
/// Which property controls a layer's `field`: the entry of its parameter consumption map and
/// its component property reference (readers take one or the other), both naming the
/// definition of the component the layer is in (a variant's own copy, in a set).
fn prop_ref(&mut self, n: &Node, field: &str, prop: &str) -> Option<(Kv<'static>, Kv<'static>)> {
let (var_field, ty, resolved) = match field {
"visible" => ("VISIBLE", "BOOL", "BOOLEAN"),
"characters" => ("TEXT_DATA", "TEXT", "TEXT_DATA"),
@@ -1883,19 +2076,24 @@ impl<'d> Exporter<'d> {
return None;
}
};
let id = *self.defs.get(&(prop.to_string(), ty))?;
let id = self.def_for(self.component_of(n)?, prop, ty)?;
let data = o(vec![
("value", o(vec![("propRefValue", o(vec![("defId", guid(AUX, id))]))])),
("dataType", e("PROP_REF")),
("resolvedDataType", e(resolved)),
]);
Some(o(vec![("variableData", data), ("variableField", e(var_field))]))
let entry = o(vec![("variableData", data), ("variableField", e(var_field))]);
let reference = o(vec![("defID", guid(AUX, id)), ("componentPropNodeField", e(var_field))]);
Some((entry, reference))
}
/// An instance: its main, the fields it and its layers override, the size, place and outline
/// its layers get from that (derived data), its property values. `f`: its own fields so far.
fn instance(&mut self, n: &Node, main: NodeId, props: &BTreeMap<String, Value>, f: &mut Fields) {
const OWN: [&str; 7] = ["guid", "phase", "parentIndex", "type", "transform", "fillGeometry", "strokeGeometry"];
// Which properties control the instance itself (a nested instance in a main component) is
// its own too, not an override of its main.
const OWN: [&str; 9] =
["guid", "phase", "parentIndex", "type", "transform", "fillGeometry", "strokeGeometry", "parameterConsumptionMap", "componentPropRefs"];
let doc = self.doc;
let (Some(m), Some(symbol)) = (doc.node(main), self.guid_of(main)) else { return };
// What the instance itself changes from its main (its size, its paints, …) is an override
@@ -1999,15 +2197,19 @@ impl<'d> Exporter<'d> {
}
let mut assignments = Vec::new();
for (name, v) in props {
let (ty, value) = match v {
Value::Bool(x) => ("BOOL", Some(o(vec![("boolValue", b(*x))]))),
Value::String(t) => ("TEXT", Some(o(vec![("textValue", o(vec![("characters", s(t))]))]))),
Value::Number(c) => ("INSTANCE_SWAP", c.as_u64().and_then(|c| self.guid_of(NodeId(c))).map(|g| o(vec![("guidValue", g)]))),
let (ty, value, var) = match v {
Value::Bool(x) => ("BOOL", o(vec![("boolValue", b(*x))]), bool_data(*x)),
Value::String(t) => ("TEXT", o(vec![("textValue", text_value(t))]), text_data(t)),
Value::Number(c) => {
let Some(g) = c.as_u64().and_then(|c| self.guid_of(NodeId(c))) else { continue };
("INSTANCE_SWAP", o(vec![("guidValue", g.clone())]), symbol_data(g))
}
_ => continue,
};
// Variant values have no definition of their own: the main they show says them.
if let (Some(&id), Some(value)) = (self.defs.get(&(name.clone(), ty)), value) {
assignments.push(o(vec![("defID", guid(AUX, id)), ("value", value)]));
// Variant values have no definition of their own: the main they show says them. A
// variant's instance names the variant's own copy of the definition.
if let Some(id) = self.def_for(main, name, ty) {
assignments.push(o(vec![("defID", guid(AUX, id)), ("value", value), ("varValue", var)]));
}
}
if !assignments.is_empty() {
@@ -2086,6 +2288,7 @@ impl<'d> Exporter<'d> {
/// Interactions, scrolling, flow starts and the overlay settings this layer is opened with.
fn prototype(&mut self, n: &Node, f: &mut Fields) {
self.trigger_size = [n.width, n.height];
let list: Vec<Kv> = n.interactions.iter().filter_map(|it| self.interaction(it)).collect();
if !list.is_empty() {
f.push(("prototypeInteractions", arr(list)));
@@ -2174,10 +2377,16 @@ impl<'d> Exporter<'d> {
}
Some(act)
}
Action::OpenOverlay { dest, position, offset, .. } => {
Action::OpenOverlay { dest, position, offset, anchor, .. } => {
let mut act = node(self, dest, "OVERLAY")?;
if let (Kv::Object(f), OverlayPosition::Manual, Some([x, y])) = (&mut act, position, offset) {
f.push(("overlayRelativePosition", o(vec![("x", fl(*x)), ("y", fl(*y))])));
let size = self.doc.node(*dest).map_or([0.0, 0.0], |d| [d.width, d.height]);
let offset = match anchor.offset(*position, self.trigger_size, size) {
Some(o) => Some(o),
None if *position == OverlayPosition::Manual => *offset,
None => None,
};
if let (Kv::Object(f), Some([x, y])) = (&mut act, offset) {
f.push(("overlayRelativePosition", o(vec![("x", fl(x)), ("y", fl(y))])));
}
Some(act)
}
@@ -2433,7 +2642,9 @@ fn unset(key: &str) -> Option<Kv<'static>> {
"cornerSmoothing" | "stackChildPrimaryGrow" => fl(0.0),
"stackMode" => e("NONE"),
"stackChildAlignSelf" | "stackPositioning" => e("AUTO"),
"minSize" | "maxSize" => o(vec![("value", xy(0.0, 0.0))]),
"minSize" => o(vec![("value", xy(0.0, 0.0))]),
// No maximum is infinite (a maximum of 0 collapses the layer).
"maxSize" => o(vec![("value", o(vec![("x", Kv::Float(f32::INFINITY)), ("y", Kv::Float(f32::INFINITY))]))]),
"gridColumnSpan" | "gridRowSpan" | "maxLines" => int(0),
"scrollDirection" => e("NONE"),
"scrollBehavior" => e("SCROLLS"),
@@ -2444,6 +2655,74 @@ fn unset(key: &str) -> Option<Kv<'static>> {
})
}
/// An image's name in the file: the SHA-1 of its bytes (lowercase hex). Paints refer to images by
/// these 20 bytes, and readers reject other hash lengths (the document's own SHA-256 key is not
/// used here).
pub(super) fn sha1_hex(bytes: &[u8]) -> String {
sha1::Sha1::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
}
/// A style name as readers look the face up: the standard name ([`super::style_name`]) with the
/// compound weights spelt the way most font families name their faces ("SemiBold", "ExtraBold",
/// "ExtraLight Italic"), except Inter, whose faces are named with a space ("Semi Bold"). A name
/// the family doesn't have reads as a missing font, and its text isn't drawn.
pub(super) fn face_style(family: &str, style: &str) -> String {
let standard = super::style_name(style);
if family == "Inter" || family.starts_with("Inter ") {
return standard;
}
["Extra", "Ultra", "Semi", "Demi"].iter().fold(standard, |acc, p| acc.replace(&format!("{p} "), p))
}
/// The pairs of a variant's name, `Property=Value, …` (parts without `=` are left out).
fn variant_pairs(name: &str) -> Vec<(String, String)> {
name.split(',')
.filter_map(|part| {
let (p, v) = part.split_once('=')?;
let (p, v) = (p.trim(), v.trim());
(!p.is_empty()).then(|| (p.to_string(), v.to_string()))
})
.collect()
}
/// The `index`-th sort position of a list: one printable character from `"` on, two past the end
/// of that range.
fn sort_position(index: usize) -> String {
const FIRST: u32 = 34;
const SPAN: usize = 93;
let ch = |i: usize| char::from_u32(FIRST + u32::try_from(i % SPAN).unwrap_or(0)).unwrap_or('"');
if index < SPAN { ch(index).to_string() } else { format!("~{}", ch(index - SPAN)) }
}
/// Text as a property value: its characters and one plain line per paragraph.
fn text_value(t: &str) -> Kv<'static> {
let line = || {
o(vec![
("lineType", e("PLAIN")),
("styleId", int(0)),
("indentationLevel", int(0)),
("sourceDirectionality", e("AUTO")),
("listStartOffset", int(0)),
("isFirstLineOfList", b(false)),
])
};
o(vec![("characters", s(t)), ("lines", arr(t.split('\n').map(|_| line()).collect()))])
}
/// A property value as variable data (`varValue`).
fn var_data(value: (&'static str, Kv<'static>), ty: &'static str, resolved: &'static str) -> Kv<'static> {
o(vec![("value", o(vec![value])), ("dataType", e(ty)), ("resolvedDataType", e(resolved))])
}
fn bool_data(x: bool) -> Kv<'static> {
var_data(("boolValue", b(x)), "BOOLEAN", "BOOLEAN")
}
fn text_data(t: &str) -> Kv<'static> {
var_data(("textDataValue", text_value(t)), "TEXT_DATA", "TEXT_DATA")
}
fn symbol_data(main: Kv<'static>) -> Kv<'static> {
var_data(("symbolIdValue", o(vec![("guid", main)])), "SYMBOL_ID", "SYMBOL_ID")
}
/// The format's type of a component property (`None`: not written).
fn prop_type(d: &PropDef) -> Option<&'static str> {
match d {
@@ -2499,7 +2778,7 @@ fn style_fields(st: &TextStyle, f: &mut Fields) {
TextDecoration::Strikethrough => "STRIKETHROUGH",
};
f.extend([
("fontName", o(vec![("family", s(&st.font_family)), ("style", s(&super::style_name(&st.font_style))), ("postscript", s(""))])),
("fontName", o(vec![("family", s(&st.font_family)), ("style", s(&face_style(&st.font_family, &st.font_style))), ("postscript", s(""))])),
("fontSize", fl(st.font_size)),
("lineHeight", line_height),
("letterSpacing", letter_spacing),
@@ -2548,7 +2827,8 @@ fn export_setting(x: &figcraft_doc::ExportSetting) -> Kv<'static> {
// The format has no WebP export setting.
figcraft_doc::ExportFormat::Png | figcraft_doc::ExportFormat::Webp => "PNG",
figcraft_doc::ExportFormat::Jpg => "JPEG",
figcraft_doc::ExportFormat::Svg => "SVG",
// Nor an Android Vector Drawable one: the nearest is the other vector format.
figcraft_doc::ExportFormat::Svg | figcraft_doc::ExportFormat::VectorDrawable => "SVG",
figcraft_doc::ExportFormat::Pdf => "PDF",
};
let (ty, value) = match x.size {
+339 -1
View File
@@ -7,7 +7,9 @@ use std::collections::{BTreeMap, BTreeSet};
use figcraft_doc::color::{BlendMode, Color, ColorStop, Gradient, GradientKind, ImageScaleMode, Paint, PaintKind};
use figcraft_doc::geom::{Affine, Point};
use figcraft_doc::proto::{Action, Direction, Easing, Flow, Interaction, KeyMods, Overflow, OverlayPosition, ScrollPosition, Transition, Trigger};
use figcraft_doc::proto::{
Action, Direction, Easing, Flow, Interaction, KeyMods, Overflow, OverlayAnchor, OverlayPosition, ScrollPosition, Transition, Trigger,
};
use figcraft_doc::*;
use serde_json::Value;
@@ -511,6 +513,7 @@ impl Gen {
close_on_outside: true,
background: Some(Color::new(0.0, 0.0, 0.0, 0.5)),
offset: None,
anchor: OverlayAnchor::Screen,
},
3 => Action::SwapOverlay { dest },
4 => Action::ChangeTo { dest },
@@ -635,6 +638,12 @@ fn normalize(n: &mut Node, ix: &dyn Fn(NodeId) -> NodeId) {
let d = AutoLayout::default();
(l.grid_columns, l.grid_rows) = (d.grid_columns, d.grid_rows);
}
// Equal (one-share) tracks say what no track list says.
for tracks in [&mut l.column_tracks, &mut l.row_tracks] {
if tracks.iter().all(|t| *t == GridTrack::default()) {
tracks.clear();
}
}
}
n.child_layout.grid_align_h = None;
n.child_layout.grid_align_v = None;
@@ -1178,3 +1187,332 @@ fn local_samples_round_trip() {
assert_same(&a, &b, &format!("{} (export warnings: {warnings:?})", p.display()));
}
}
#[test]
fn a_vector_drawable_export_setting_is_written_as_svg_with_a_warning() {
let mut doc = Document::new("vd");
let page = doc.first_page().unwrap();
let r = doc.create(page, NodeKind::Rectangle, "Icon", 0.0, 0.0, 24.0, 24.0).unwrap();
doc.get_mut(r).unwrap().exports = vec![ExportSetting { format: ExportFormat::VectorDrawable, ..Default::default() }];
let (bytes, warnings) = export(&doc, None).unwrap();
assert_eq!(warnings, ["Android Vector Drawable (XML) export settings were written as SVG"]);
let (back, _) = import(&bytes, "x").unwrap();
let icon = back.nodes().find(|n| n.name == "Icon").unwrap();
assert_eq!(icon.exports.iter().map(|x| x.format).collect::<Vec<_>>(), [ExportFormat::Svg]);
}
#[test]
fn images_are_named_by_the_sha1_of_their_bytes() {
// FIPS 180 test vectors.
assert_eq!(export::sha1_hex(b""), "da39a3ee5e6b4b0d3255bfef95601890afd80709");
assert_eq!(export::sha1_hex(b"abc"), "a9993e364706816aba3e25717850c26c9cd0d89d");
assert_eq!(export::sha1_hex(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"), "84983e441c3bd26ebaae4aa1f95129e5e54670f1");
assert_eq!(export::sha1_hex(&vec![b'a'; 1_000_000]), "34aa973cd4c4daa4f61eeb2bdbad27316534016f");
let (bytes, _) = export(&generate(3), None).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes[..])).unwrap();
let names: Vec<String> = z.file_names().filter(|n| n.starts_with("images/")).map(str::to_string).collect();
assert!(!names.is_empty());
for name in names {
let data = crate::read_member(&mut z, &name, 1 << 26).unwrap();
assert_eq!(name.strip_prefix("images/"), Some(export::sha1_hex(&data).as_str()));
}
// Paints name the image by its 20 bytes.
let archive = Archive::open(&bytes).unwrap();
let (schema, message) = decode_canvas(&archive.canvas).unwrap();
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
let mut hashes = 0;
for nc in msg.get("nodeChanges").unwrap().list() {
for p in nc.get("fillPaints").map(Kv::list).unwrap_or_default() {
if let Some(h) = p.get("image").and_then(|i| i.get("hash")).and_then(Kv::bytes) {
assert_eq!(h.len(), 20);
hashes += 1;
}
}
}
assert!(hashes > 0);
}
#[test]
fn an_axis_without_a_maximum_size_is_written_as_infinite() {
let mut doc = Document::new("File");
let page = doc.first_page().unwrap();
let id = doc.create(page, NodeKind::Frame, "Subject", 0.0, 0.0, 100.0, 100.0).unwrap();
let node = doc.node_mut(id).unwrap();
node.layout = Some(AutoLayout::default());
node.child_layout.max_w = Some(1200.0);
node.child_layout.min_h = Some(40.0);
let (bytes, _) = export(&doc, None).unwrap();
let archive = Archive::open(&bytes).unwrap();
let (schema, message) = decode_canvas(&archive.canvas).unwrap();
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
let nc = msg.get("nodeChanges").unwrap().list().iter().find(|n| n.get("name").and_then(Kv::str) == Some("Subject")).unwrap();
let axes = |k: &str| {
let v = nc.get(k).unwrap().get("value").unwrap();
(v.get("x").cloned().unwrap(), v.get("y").cloned().unwrap())
};
assert_eq!(axes("maxSize"), (Kv::Float(1200.0), Kv::Float(f32::INFINITY)));
assert_eq!(axes("minSize"), (Kv::Float(0.0), Kv::Float(40.0)));
let (back, _) = import(&bytes, "File").unwrap();
let cl = &back.nodes().find(|n| n.name == "Subject").unwrap().child_layout;
assert_eq!([cl.min_w, cl.max_w, cl.min_h, cl.max_h], [None, Some(1200.0), Some(40.0), None]);
}
#[test]
fn compound_weights_are_spelt_the_way_font_families_name_their_faces() {
assert_eq!(export::face_style("Libre Franklin", "Semi Bold"), "SemiBold");
assert_eq!(export::face_style("Fira Sans", "SemiBold"), "SemiBold");
assert_eq!(export::face_style("Roboto", "Extra Light Italic"), "ExtraLight Italic");
assert_eq!(export::face_style("Roboto", "Bold"), "Bold");
assert_eq!(export::face_style("Inter", "SemiBold"), "Semi Bold");
assert_eq!(export::face_style("Inter Display", "Extra Bold"), "Extra Bold");
}
/// A component set whose variants share a text and a boolean property, its layers bound to them,
/// and an instance of the second variant with its own label.
fn set_with_properties() -> (Document, NodeId) {
fn mirror(doc: &mut Document, src: NodeId, parent: NodeId) {
let mut n = doc.node(src).unwrap().clone();
let kids = std::mem::take(&mut n.children);
n.id = doc.alloc_id();
n.source = Some(src);
let id = doc.insert(n, parent, None).unwrap();
for k in kids {
mirror(doc, k, id);
}
}
let mut doc = Document::new("File");
let page = doc.first_page().unwrap();
let glyph =
doc.create(page, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, "Glyph", 400.0, 0.0, 16.0, 16.0).unwrap();
doc.create(glyph, NodeKind::Rectangle, "Shape", 0.0, 0.0, 16.0, 16.0).unwrap();
let set = doc.create(page, NodeKind::ComponentSet { description: String::new() }, "Button", 0.0, 0.0, 300.0, 120.0).unwrap();
let mut variants = Vec::new();
for (i, name) in ["Size=Small", "Size=Large"].into_iter().enumerate() {
let props = BTreeMap::from([
("Label".to_string(), PropDef::Text { default: "Button".into() }),
("Show icon".to_string(), PropDef::Boolean { default: true }),
]);
let v = doc.create(set, NodeKind::Component { description: String::new(), props }, name, 0.0, 60.0 * i as f64, 120.0, 40.0).unwrap();
let label = doc.create(v, NodeKind::Text(TextData::new("Button")), "Label", 0.0, 0.0, 60.0, 20.0).unwrap();
doc.node_mut(label).unwrap().prop_refs.insert("characters".into(), "Label".into());
// The icon is an instance placed in the variant: like the editor's, its `source` is its
// main, which the binding doesn't belong to.
let icon = doc.create(v, NodeKind::Instance { component: glyph, props: BTreeMap::new() }, "Icon", 70.0, 0.0, 16.0, 16.0).unwrap();
let n = doc.node_mut(icon).unwrap();
n.source = Some(glyph);
n.prop_refs.insert("visible".into(), "Show icon".into());
variants.push(v);
}
let large = variants[1];
let main = doc.node(large).unwrap().clone();
let id = doc.alloc_id();
let mut n = main.clone();
let props = BTreeMap::from([("Label".to_string(), Value::String("Hi".into()))]);
(n.id, n.kind, n.name, n.parent, n.source) = (id, NodeKind::Instance { component: large, props }, "Button".into(), None, None);
n.children.clear();
n.transform = Affine::translate((0.0, 200.0));
doc.insert(n, page, None).unwrap();
for k in main.children {
mirror(&mut doc, k, id);
}
let kids = doc.node(id).unwrap().children.clone();
for k in kids {
let n = doc.node_mut(k).unwrap();
if let NodeKind::Text(td) = &mut n.kind {
td.characters = "Hi".into();
td.runs.clear();
n.overrides.insert("characters".into());
}
}
(doc, id)
}
#[test]
fn set_properties_live_on_the_set_with_a_copy_per_variant() {
let (doc, _) = set_with_properties();
let (bytes, _) = export(&doc, None).unwrap();
let archive = Archive::open(&bytes).unwrap();
let (schema, message) = decode_canvas(&archive.canvas).unwrap();
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
let changes = msg.get("nodeChanges").unwrap().list();
let key = |g: &Kv| format!("{}:{}", g.get("sessionID").unwrap().f64().unwrap(), g.get("localID").unwrap().f64().unwrap());
let named = |name: &str| changes.iter().filter(|n| n.get("name").and_then(Kv::str) == Some(name)).collect::<Vec<_>>();
let id_of = |n: &Kv| key(n.get("guid").unwrap());
let parent_of = |n: &Kv| key(n.get("parentIndex").unwrap().get("guid").unwrap());
// The set: every definition, the variant property first, with its values in order.
let set = named("Button").into_iter().find(|n| n.get("isStateGroup").is_some()).unwrap();
let defs = set.get("componentPropDefs").unwrap().list();
let def = |name: &str| defs.iter().find(|d| d.get("name").and_then(Kv::str) == Some(name)).unwrap();
assert_eq!(def("Size").get("type").and_then(Kv::str), Some("VARIANT"));
assert_eq!(def("Label").get("type").and_then(Kv::str), Some("TEXT"));
assert_eq!(def("Show icon").get("type").and_then(Kv::str), Some("BOOL"));
assert_eq!(defs.first().and_then(|d| d.get("name")).and_then(Kv::str), Some("Size"));
for d in defs {
assert!(d.get("varValue").is_some() && d.get("sortPosition").is_some(), "{d:?}");
}
let label_default = def("Label").get("varValue").unwrap().get("value").unwrap().get("textDataValue").unwrap();
assert_eq!(label_default.get("characters").and_then(Kv::str), Some("Button"));
assert_eq!(label_default.get("lines").map(|l| l.list().len()), Some(1));
let orders = set.get("stateGroupPropertyValueOrders").unwrap().list();
assert_eq!(orders.len(), 1);
let values: Vec<&str> = orders[0].get("values").unwrap().list().iter().filter_map(Kv::str).collect();
assert_eq!((orders[0].get("property").and_then(Kv::str), values), (Some("Size"), vec!["Small", "Large"]));
// Each variant: its own copy of each definition and its variant value; its layers name the copy.
let set_ids: BTreeMap<String, String> =
defs.iter().map(|d| (key(d.get("id").unwrap()), d.get("name").and_then(Kv::str).unwrap().to_string())).collect();
let mut copies = BTreeSet::new();
let mut large_label = None;
for (name, value) in [("Size=Small", "Small"), ("Size=Large", "Large")] {
let v = named(name)[0];
let mut own = BTreeMap::new();
for d in v.get("componentPropDefs").unwrap().list() {
let parent = set_ids.get(&key(d.get("parentPropDefId").unwrap())).unwrap().clone();
assert!(copies.insert(key(d.get("id").unwrap())), "copies are the variant's own");
own.insert(parent, key(d.get("id").unwrap()));
}
assert_eq!(own.keys().map(String::as_str).collect::<Vec<_>>(), ["Label", "Show icon"]);
let specs = v.get("variantPropSpecs").unwrap().list();
assert_eq!(set_ids.get(&key(specs[0].get("propDefId").unwrap())).map(String::as_str), Some("Size"));
assert_eq!(specs[0].get("value").and_then(Kv::str), Some(value));
for (layer, prop, field) in [("Label", "Label", "TEXT_DATA"), ("Icon", "Show icon", "VISIBLE")] {
let l = named(layer).into_iter().find(|l| parent_of(l) == id_of(v)).unwrap();
let r = &l.get("componentPropRefs").unwrap().list()[0];
assert_eq!((key(r.get("defID").unwrap()), r.get("componentPropNodeField").and_then(Kv::str)), (own[prop].clone(), Some(field)));
let entry = &l.get("parameterConsumptionMap").unwrap().get("entries").unwrap().list()[0];
let def_id = entry.get("variableData").unwrap().get("value").unwrap().get("propRefValue").unwrap().get("defId").unwrap();
assert_eq!(key(def_id), own[prop]);
}
if value == "Large" {
large_label = Some(own["Label"].clone());
}
}
// The instance names the large variant's copy, with the value as variable data too.
let inst = named("Button").into_iter().find(|n| n.get("type").and_then(Kv::str) == Some("INSTANCE")).unwrap();
let a = &inst.get("componentPropAssignments").unwrap().list()[0];
assert_eq!(Some(key(a.get("defID").unwrap())), large_label);
let chars = a.get("varValue").unwrap().get("value").unwrap().get("textDataValue").unwrap().get("characters");
assert_eq!(chars.and_then(Kv::str), Some("Hi"));
}
#[test]
fn set_properties_round_trip() {
let (doc, inst) = set_with_properties();
let (bytes, warnings) = export(&doc, None).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
let (back, _) = import(&bytes, "File").unwrap();
for name in ["Size=Small", "Size=Large"] {
let v = back.nodes().find(|n| n.name == name).unwrap();
let NodeKind::Component { props, .. } = &v.kind else { panic!("{name} is a component") };
assert_eq!(props.get("Label"), Some(&PropDef::Text { default: "Button".into() }), "{name}");
assert_eq!(props.get("Show icon"), Some(&PropDef::Boolean { default: true }), "{name}");
let label = v.children.iter().filter_map(|c| back.node(*c)).find(|c| c.name == "Label").unwrap();
assert_eq!(label.prop_refs.get("characters").map(String::as_str), Some("Label"));
}
let name = &doc.node(inst).unwrap().name;
let i = back.nodes().find(|n| &n.name == name && matches!(n.kind, NodeKind::Instance { .. })).unwrap();
let NodeKind::Instance { props, .. } = &i.kind else { panic!() };
assert_eq!(props.get("Label"), Some(&Value::String("Hi".into())));
}
#[test]
fn an_instance_without_its_mains_maximum_size_overrides_it_with_infinity() {
let mut doc = Document::new("File");
let page = doc.first_page().unwrap();
let main = doc.create(page, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, "Box", 0.0, 0.0, 100.0, 40.0).unwrap();
doc.node_mut(main).unwrap().child_layout.max_w = Some(300.0);
doc.create(page, NodeKind::Instance { component: main, props: BTreeMap::new() }, "Box", 0.0, 100.0, 100.0, 40.0).unwrap();
let (bytes, _) = export(&doc, None).unwrap();
let archive = Archive::open(&bytes).unwrap();
let (schema, message) = decode_canvas(&archive.canvas).unwrap();
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
let inst = msg.get("nodeChanges").unwrap().list().iter().find(|n| n.get("type").and_then(Kv::str) == Some("INSTANCE")).unwrap();
let overrides = inst.get("symbolData").unwrap().get("symbolOverrides").unwrap().list();
let max = overrides.iter().find_map(|o| o.get("maxSize")).unwrap().get("value").unwrap();
assert_eq!((max.get("x"), max.get("y")), (Some(&Kv::Float(f32::INFINITY)), Some(&Kv::Float(f32::INFINITY))));
}
#[test]
fn grid_track_sizes_are_written_and_read_back() {
let mut doc = Document::new("File");
let page = doc.first_page().unwrap();
let id = doc.create(page, NodeKind::Frame, "Row", 0.0, 0.0, 720.0, 70.0).unwrap();
let columns = vec![GridTrack::fixed(320.0), GridTrack::hug(), GridTrack { sizing: Sizing::Fill, value: 2.0 }, GridTrack::fixed(34.0)];
doc.node_mut(id).unwrap().layout = Some(AutoLayout {
mode: LayoutMode::Grid,
grid_columns: 4,
grid_rows: 1,
column_tracks: columns.clone(),
row_tracks: vec![GridTrack::fixed(69.0)],
..AutoLayout::default()
});
let (bytes, warnings) = export(&doc, None).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
let archive = Archive::open(&bytes).unwrap();
let (schema, message) = decode_canvas(&archive.canvas).unwrap();
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
let row = msg.get("nodeChanges").unwrap().list().iter().find(|n| n.get("name").and_then(Kv::str) == Some("Row")).unwrap();
let key = |g: &Kv| format!("{}:{}", g.get("sessionID").unwrap().f64().unwrap(), g.get("localID").unwrap().f64().unwrap());
let order: Vec<String> = row.get("gridColumns").unwrap().get("entries").unwrap().list().iter().map(|e| key(e.get("id").unwrap())).collect();
let sizes = row.get("gridColumnsSizing").unwrap().get("entries").unwrap().list();
let written: Vec<(String, f64)> = order
.iter()
.map(|id| {
let size = sizes.iter().find(|e| key(e.get("id").unwrap()) == *id).unwrap().get("trackSize").unwrap();
let (min, max) = (size.get("minSizing").unwrap(), size.get("maxSizing").unwrap());
assert_eq!(min, max);
(min.get("type").and_then(Kv::str).unwrap().to_string(), min.get("value").unwrap().f64().unwrap())
})
.collect();
let types: Vec<(&str, f64)> = written.iter().map(|(t, v)| (t.as_str(), *v)).collect();
assert_eq!(types, [("FIXED", 320.0), ("HUG", 0.0), ("FLEX", 2.0), ("FIXED", 34.0)]);
let (back, _) = import(&bytes, "File").unwrap();
let l = back.nodes().find(|n| n.name == "Row").unwrap().layout.clone().unwrap();
assert_eq!(l.column_tracks, columns);
assert_eq!(l.row_tracks, vec![GridTrack::fixed(69.0)]);
}
#[test]
fn variants_that_disagree_on_a_default_are_a_warning() {
let (mut doc, _) = set_with_properties();
let large = doc.nodes().find(|n| n.name == "Size=Large").unwrap().id;
if let NodeKind::Component { props, .. } = &mut doc.node_mut(large).unwrap().kind {
props.insert("Label".into(), PropDef::Text { default: "Large".into() });
}
let (_, warnings) = export(&doc, None).unwrap();
assert!(warnings.iter().any(|w| w.contains("different defaults")), "{warnings:?}");
}
#[test]
fn a_name_used_with_two_types_in_a_set_writes_both_definitions() {
let (mut doc, _) = set_with_properties();
let large = doc.nodes().find(|n| n.name == "Size=Large").unwrap().id;
if let NodeKind::Component { props, .. } = &mut doc.node_mut(large).unwrap().kind {
props.insert("Label".into(), PropDef::Boolean { default: true });
}
let (bytes, _) = export(&doc, None).unwrap();
let archive = Archive::open(&bytes).unwrap();
let (schema, message) = decode_canvas(&archive.canvas).unwrap();
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
let changes = msg.get("nodeChanges").unwrap().list();
let key = |g: &Kv| format!("{}:{}", g.get("sessionID").unwrap().f64().unwrap(), g.get("localID").unwrap().f64().unwrap());
let set = changes.iter().find(|n| n.get("isStateGroup").is_some()).unwrap();
let written: BTreeSet<String> = set.get("componentPropDefs").unwrap().list().iter().map(|d| key(d.get("id").unwrap())).collect();
let labels: Vec<&str> = set
.get("componentPropDefs")
.unwrap()
.list()
.iter()
.filter(|d| d.get("name").and_then(Kv::str) == Some("Label"))
.filter_map(|d| d.get("type").and_then(Kv::str))
.collect();
assert_eq!(labels.len(), 2, "{labels:?}");
// Every variant copy names a definition the set holds.
for v in changes.iter().filter(|n| n.get("variantPropSpecs").is_some()) {
for d in v.get("componentPropDefs").unwrap().list() {
assert!(written.contains(&key(d.get("parentPropDefId").unwrap())));
}
}
}
+5 -1
View File
@@ -563,7 +563,11 @@ pub mod encode {
Type::Byte => w.byte(ranged(0, 255)? as u8),
Type::Int => w.var_int(ranged(i32::MIN.into(), i32::MAX.into())? as i32),
Type::Uint => w.var_uint(ranged(0, u32::MAX.into())? as u32),
Type::Float => w.var_float(v.f64().map(|x| x as f32).filter(|x| x.is_finite()).ok_or_else(bad)?),
// Infinity is a value of its own (an axis with no maximum size); NaN is not.
Type::Float => w.var_float(match v {
Kv::Float(f) if f.is_infinite() => *f,
_ => v.f64().map(|x| x as f32).filter(|x| x.is_finite()).ok_or_else(bad)?,
}),
Type::Str => w.string(v.str().ok_or_else(bad)?),
Type::Int64 => w.var_int64(ranged(i64::MIN.into(), i64::MAX.into())? as i64),
Type::Uint64 => w.var_uint64(ranged(0, u64::MAX.into())? as u64),
+212 -2
View File
@@ -254,14 +254,193 @@ fn image_index<S: Serializer>(images: &&BTreeMap<String, ImageData>, s: S) -> Re
s.collect_seq(images.iter().map(|(hash, i)| Entry { hash, width: i.width, height: i.height }))
}
/// A serializer adapter that writes `0` in place of every NaN or infinite float. JSON has no such
/// numbers (`serde_json` writes `null`, which the reader rejects), so one bad value must not stop
/// Save, autosave or recovery copies of the whole document. Everything else passes through.
struct Finite<S>(S);
/// Serializes a value through a [`Finite`] adapter around the serializer it is written with.
struct FiniteValue<'a, T: ?Sized>(&'a T);
impl<T: ?Sized + Serialize> Serialize for FiniteValue<'_, T> {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
self.0.serialize(Finite(s))
}
}
macro_rules! finite_pass {
($($name:ident($ty:ty)),* $(,)?) => {
$(fn $name(self, v: $ty) -> Result<S::Ok, S::Error> { self.0.$name(v) })*
};
}
impl<S: Serializer> Serializer for Finite<S> {
type Ok = S::Ok;
type Error = S::Error;
type SerializeSeq = Finite<S::SerializeSeq>;
type SerializeTuple = Finite<S::SerializeTuple>;
type SerializeTupleStruct = Finite<S::SerializeTupleStruct>;
type SerializeTupleVariant = Finite<S::SerializeTupleVariant>;
type SerializeMap = Finite<S::SerializeMap>;
type SerializeStruct = Finite<S::SerializeStruct>;
type SerializeStructVariant = Finite<S::SerializeStructVariant>;
finite_pass!(
serialize_bool(bool),
serialize_i8(i8),
serialize_i16(i16),
serialize_i32(i32),
serialize_i64(i64),
serialize_u8(u8),
serialize_u16(u16),
serialize_u32(u32),
serialize_u64(u64),
serialize_i128(i128),
serialize_u128(u128),
serialize_char(char),
serialize_str(&str),
serialize_bytes(&[u8])
);
fn serialize_f32(self, v: f32) -> Result<S::Ok, S::Error> {
self.0.serialize_f32(if v.is_finite() { v } else { 0.0 })
}
fn serialize_f64(self, v: f64) -> Result<S::Ok, S::Error> {
self.0.serialize_f64(if v.is_finite() { v } else { 0.0 })
}
fn serialize_none(self) -> Result<S::Ok, S::Error> {
self.0.serialize_none()
}
fn serialize_some<T: ?Sized + Serialize>(self, v: &T) -> Result<S::Ok, S::Error> {
self.0.serialize_some(&FiniteValue(v))
}
fn serialize_unit(self) -> Result<S::Ok, S::Error> {
self.0.serialize_unit()
}
fn serialize_unit_struct(self, name: &'static str) -> Result<S::Ok, S::Error> {
self.0.serialize_unit_struct(name)
}
fn serialize_unit_variant(self, name: &'static str, idx: u32, variant: &'static str) -> Result<S::Ok, S::Error> {
self.0.serialize_unit_variant(name, idx, variant)
}
fn serialize_newtype_struct<T: ?Sized + Serialize>(self, name: &'static str, v: &T) -> Result<S::Ok, S::Error> {
self.0.serialize_newtype_struct(name, &FiniteValue(v))
}
fn serialize_newtype_variant<T: ?Sized + Serialize>(self, name: &'static str, idx: u32, variant: &'static str, v: &T) -> Result<S::Ok, S::Error> {
self.0.serialize_newtype_variant(name, idx, variant, &FiniteValue(v))
}
fn serialize_seq(self, len: Option<usize>) -> Result<Self::SerializeSeq, S::Error> {
self.0.serialize_seq(len).map(Finite)
}
fn serialize_tuple(self, len: usize) -> Result<Self::SerializeTuple, S::Error> {
self.0.serialize_tuple(len).map(Finite)
}
fn serialize_tuple_struct(self, name: &'static str, len: usize) -> Result<Self::SerializeTupleStruct, S::Error> {
self.0.serialize_tuple_struct(name, len).map(Finite)
}
fn serialize_tuple_variant(
self,
name: &'static str,
idx: u32,
variant: &'static str,
len: usize,
) -> Result<Self::SerializeTupleVariant, S::Error> {
self.0.serialize_tuple_variant(name, idx, variant, len).map(Finite)
}
fn serialize_map(self, len: Option<usize>) -> Result<Self::SerializeMap, S::Error> {
self.0.serialize_map(len).map(Finite)
}
fn serialize_struct(self, name: &'static str, len: usize) -> Result<Self::SerializeStruct, S::Error> {
self.0.serialize_struct(name, len).map(Finite)
}
fn serialize_struct_variant(
self,
name: &'static str,
idx: u32,
variant: &'static str,
len: usize,
) -> Result<Self::SerializeStructVariant, S::Error> {
self.0.serialize_struct_variant(name, idx, variant, len).map(Finite)
}
fn is_human_readable(&self) -> bool {
self.0.is_human_readable()
}
}
macro_rules! finite_elements {
($($trait:ident::$method:ident),* $(,)?) => {
$(impl<S: serde::ser::$trait> serde::ser::$trait for Finite<S> {
type Ok = S::Ok;
type Error = S::Error;
fn $method<T: ?Sized + Serialize>(&mut self, v: &T) -> Result<(), S::Error> {
self.0.$method(&FiniteValue(v))
}
fn end(self) -> Result<S::Ok, S::Error> {
self.0.end()
}
})*
};
}
finite_elements!(
SerializeSeq::serialize_element,
SerializeTuple::serialize_element,
SerializeTupleStruct::serialize_field,
SerializeTupleVariant::serialize_field
);
impl<S: serde::ser::SerializeMap> serde::ser::SerializeMap for Finite<S> {
type Ok = S::Ok;
type Error = S::Error;
fn serialize_key<T: ?Sized + Serialize>(&mut self, k: &T) -> Result<(), S::Error> {
self.0.serialize_key(&FiniteValue(k))
}
fn serialize_value<T: ?Sized + Serialize>(&mut self, v: &T) -> Result<(), S::Error> {
self.0.serialize_value(&FiniteValue(v))
}
fn end(self) -> Result<S::Ok, S::Error> {
self.0.end()
}
}
impl<S: serde::ser::SerializeStruct> serde::ser::SerializeStruct for Finite<S> {
type Ok = S::Ok;
type Error = S::Error;
fn serialize_field<T: ?Sized + Serialize>(&mut self, key: &'static str, v: &T) -> Result<(), S::Error> {
self.0.serialize_field(key, &FiniteValue(v))
}
fn skip_field(&mut self, key: &'static str) -> Result<(), S::Error> {
self.0.skip_field(key)
}
fn end(self) -> Result<S::Ok, S::Error> {
self.0.end()
}
}
impl<S: serde::ser::SerializeStructVariant> serde::ser::SerializeStructVariant for Finite<S> {
type Ok = S::Ok;
type Error = S::Error;
fn serialize_field<T: ?Sized + Serialize>(&mut self, key: &'static str, v: &T) -> Result<(), S::Error> {
self.0.serialize_field(key, &FiniteValue(v))
}
fn skip_field(&mut self, key: &'static str) -> Result<(), S::Error> {
self.0.skip_field(key)
}
fn end(self) -> Result<S::Ok, S::Error> {
self.0.end()
}
}
/// Serialize the document (without images) to the JSON value stored in `document.json`.
/// NaN and infinite numbers are written as `0`.
pub fn to_json(doc: &Document) -> Result<Value, FormatError> {
serde_json::to_value(DocumentJson::new(doc)).map_err(io_err)
DocumentJson::new(doc).serialize(Finite(serde_json::value::Serializer)).map_err(io_err)
}
/// Write the document (without images) as `document.json` content to `w`.
/// NaN and infinite numbers are written as `0`.
pub fn write_json(doc: &Document, w: impl Write) -> Result<(), FormatError> {
serde_json::to_writer(w, &DocumentJson::new(doc)).map_err(io_err)
DocumentJson::new(doc).serialize(Finite(&mut serde_json::Serializer::new(w))).map_err(io_err)
}
/// Parse `document.json` content.
@@ -840,6 +1019,37 @@ mod tests {
assert!(load_bytes(&json).is_ok());
}
#[test]
fn non_finite_numbers_are_saved_as_zero() {
let mut d = Document::new("NaN");
let p = d.first_page().unwrap();
let r = d.create(p, NodeKind::Rectangle, "R", 1.0, 2.0, 3.0, 4.0).unwrap();
assert!(save_bytes(&d, None).is_ok());
d.get_mut(r).unwrap().width = f64::NAN;
d.get_mut(r).unwrap().opacity = f32::INFINITY;
// Saving never fails; non-finite numbers are written as 0 and the file reopens.
let bytes = save_bytes(&d, None).unwrap();
let (back, _) = load_bytes(&bytes).unwrap();
assert_eq!(back.node_count(), d.node_count());
let n = back.get(r).unwrap();
assert_eq!(n.width, 0.0);
assert_eq!(n.opacity, 0.0);
assert_eq!(n.height, 4.0);
let mut out = Vec::new();
write_json(&d, &mut out).unwrap();
let (back, _) = from_json(std::str::from_utf8(&out).unwrap()).unwrap();
let n = back.get(r).unwrap();
assert_eq!(n.width, 0.0);
assert_eq!(n.opacity, 0.0);
assert_eq!(n.height, 4.0);
let v = to_json(&d).unwrap();
let (back, _) = from_json(&v.to_string()).unwrap();
let n = back.get(r).unwrap();
assert_eq!(n.width, 0.0);
assert_eq!(n.opacity, 0.0);
assert_eq!(n.height, 4.0);
}
#[test]
fn garbage_is_an_error() {
assert!(load_bytes(b"not a zip").is_err());
+2 -2
View File
@@ -4,7 +4,7 @@
use figcraft_doc::proto::{
Action, ConditionalBlock, DeviceRotation, Direction, Easing, Expr, ExprOp, Interaction, KeyMods, MAX_ACTION_DEPTH, MAX_EXPR_DEPTH, Overflow,
OverlayPosition, PrototypeSettings, ScrollPosition, Transition, Trigger, gamepad_button,
OverlayAnchor, OverlayPosition, PrototypeSettings, ScrollPosition, Transition, Trigger, gamepad_button,
};
use figcraft_doc::{Node, NodeId, VariableType, VariableValue};
use serde_json::{Value, json};
@@ -322,7 +322,7 @@ impl Ctx<'_> {
.and_then(|v| Some([num(v, "x")?, num(v, "y")?]))
.filter(|o| o.iter().all(|c| c.is_finite()))
.map(|o| o.map(|c| c.clamp(-MAX_OFFSET, MAX_OFFSET)));
Action::OpenOverlay { dest, position, close_on_outside, background, offset }
Action::OpenOverlay { dest, position, close_on_outside, background, offset, anchor: OverlayAnchor::Screen }
}
Some(_) => {
self.warn("prototype navigations of unknown types were skipped");
+11
View File
@@ -365,6 +365,17 @@ fn text_styles_and_runs() {
assert!(!named(&doc, "NoFills").fills.is_empty(), "text keeps its default fill");
}
#[test]
fn max_lines_apply_only_while_truncating() {
let (doc, _) = one(json!([
{"id": "1", "name": "Auto", "type": "TEXT", "relativeTransform": at(0.0, 0.0), "size": {"x": 100, "y": 20}, "characters": "a",
"style": {"textAutoResize": "HEIGHT", "textTruncation": "DISABLED", "maxLines": 2}}
]));
let NodeKind::Text(td) = &named(&doc, "Auto").kind else { panic!() };
assert_eq!((td.auto_resize, td.max_lines), (TextAutoResize::Height, 0));
assert!(!td.truncates());
}
#[test]
fn auto_layout_constraints_and_grids() {
let (doc, _) = one(json!([
+5 -2
View File
@@ -69,10 +69,13 @@ fn paragraph(s: &Value, td: &mut TextData) {
// Missing means a fixed box.
_ => TextAutoResize::None,
};
if text(s, "textTruncation") == Some("ENDING") {
// A line limit applies only while truncation is on; a leftover `maxLines` is ignored.
let ending = text(s, "textTruncation") == Some("ENDING");
td.max_lines = num(s, "maxLines").filter(|n| ending && *n >= 1.0).map_or(0, |n| n.min(10_000.0) as u32);
// Auto height with a line limit keeps hugging its lines up to the limit.
if ending && !td.truncates() {
td.auto_resize = TextAutoResize::Truncate;
}
td.max_lines = num(s, "maxLines").filter(|n| *n >= 1.0).map_or(0, |n| n.min(10_000.0) as u32);
td.paragraph_spacing = len(s, "paragraphSpacing").unwrap_or(0.0);
td.paragraph_indent = len(s, "paragraphIndent").unwrap_or(0.0);
td.list_spacing = len(s, "listSpacing").unwrap_or(0.0).min(10_000.0);
+23 -7
View File
@@ -213,11 +213,11 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"export",
"Export",
"Export layers (default: the selection, else every top-level layer of the current page) as png, jpg, svg, webp or pdf at `scale`, or the whole file as `fig` (experimental: written from public format knowledge, with `warnings` for what was left out; other apps may not open every file) (PDF: with `bleed`, `cropMarks`, `bleedMarks` and `cmyk` for print; options `contentsOnly`, `includeBoundingBox`, and for SVG `svgOutlineText`, `svgIdAttribute`, `svgSimplifyStroke`). With `path` the file is written there (absolute path recommended) and the result says where. Without `path`: png/jpg/webp come back as an image content block, svg as text, other formats as base64 `data`.",
"Export layers (default: the selection, else every top-level layer of the current page) as png, jpg, svg, webp, pdf or xml (an Android Vector Drawable, with `warnings` for what it changed or left out, per `vdCompat`) at `scale`, or the whole file as `fig` (experimental: written from public format knowledge, with `warnings` for what was left out; other apps may not open every file) (PDF: with `bleed`, `cropMarks`, `bleedMarks` and `cmyk` for print; options `contentsOnly`, `includeBoundingBox`, and for SVG `svgOutlineText`, `svgIdAttribute`, `svgSimplifyStroke`; for XML `svgIdAttribute` and `vdCompat`). With `path` the file is written there (absolute path recommended) and the result says where. Without `path`: png/jpg/webp come back as an image content block, svg as text, xml as text followed by `{format, warnings}`, other formats as base64 `data`.",
obj(
json!({
"ids": ids_schema("Layers to export (several are composited into one image)"),
"format": {"type": "string", "enum": ["png", "jpg", "svg", "webp", "pdf", "fig"], "description": "Format (default png, or from the path's extension)"},
"format": {"type": "string", "enum": ["png", "jpg", "svg", "webp", "pdf", "xml", "fig"], "description": "Format (default png, or from the path's extension)"},
"scale": num("Pixel scale for raster formats (default 1, max 64)"),
"size": string("Instead of `scale`: `2x`, or a width or height in pixels like `512w` / `512h`"),
"path": string("Output file path"),
@@ -230,6 +230,7 @@ pub fn tool_definitions() -> Vec<Value> {
"svgOutlineText": boolean("SVG: text as outlines (default true); false keeps `<text>` elements"),
"svgIdAttribute": boolean("SVG: layer names as unique `id` attributes (default false)"),
"svgSimplifyStroke": boolean("SVG: strokes as `stroke` attributes where exact (default true); false writes outlines"),
"vdCompat": {"type": "string", "enum": ["bestEffort", "exact", "ignore"], "description": "XML: what a Vector Drawable can't draw exactly is approximated with a warning (bestEffort, default), left out with a warning (exact), or written as far as it maps without warnings (ignore: overlapping layer opacity dropped, even-odd fills kept as fillType=evenOdd, diamond gradients left out)"},
}),
&[],
),
@@ -879,8 +880,8 @@ fn export(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, Str
}
let format = s_arg(a, "format")?.map(str::to_ascii_lowercase);
if let Some(f) = &format {
if !["png", "jpg", "jpeg", "svg", "webp", "pdf", "fig"].contains(&f.as_str()) {
return Err(format!("unknown format `{f}` (png|jpg|svg|webp|pdf|fig)"));
if !["png", "jpg", "jpeg", "svg", "webp", "pdf", "xml", "fig"].contains(&f.as_str()) {
return Err(format!("unknown format `{f}` (png|jpg|svg|webp|pdf|xml|fig)"));
}
params.insert("format".into(), json!(f));
}
@@ -894,9 +895,18 @@ fn export(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, Str
params.insert("size".into(), json!(s));
}
// PDF print options and the export options, checked by the command.
for k in
["bleed", "cropMarks", "bleedMarks", "cmyk", "contentsOnly", "includeBoundingBox", "svgOutlineText", "svgIdAttribute", "svgSimplifyStroke"]
{
for k in [
"bleed",
"cropMarks",
"bleedMarks",
"cmyk",
"contentsOnly",
"includeBoundingBox",
"svgOutlineText",
"svgIdAttribute",
"svgSimplifyStroke",
"vdCompat",
] {
if let Some(v) = a.get(k).filter(|v| !v.is_null()) {
params.insert(k.into(), v.clone());
}
@@ -916,6 +926,12 @@ fn export(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, Str
"jpg" | "jpeg" => Ok(ToolResult::image(&bytes, "image/jpeg", &json!({"format": "jpg", "bytes": bytes.len()}))),
"webp" => Ok(ToolResult::image(&bytes, "image/webp", &json!({"format": "webp", "bytes": bytes.len()}))),
"svg" => Ok(ToolResult::text(String::from_utf8_lossy(&bytes).into_owned())),
"xml" => {
let info = json!({"format": "xml", "bytes": bytes.len(), "warnings": r.get("warnings").cloned().unwrap_or_else(|| json!([]))});
let mut out = ToolResult::text(String::from_utf8_lossy(&bytes).into_owned());
out.content.extend(ToolResult::json(&info).content);
Ok(out)
}
other => {
let mut out = json!({"format": other, "bytes": bytes.len(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)});
if let Some(w) = r.get("warnings") {
+27
View File
@@ -0,0 +1,27 @@
//! The `export` tool's Android Vector Drawable format: the XML as text, then its warnings.
use crate::common::Client;
use serde_json::{Value, json};
#[test]
fn xml_comes_back_as_text_and_its_warnings() {
let mut c = Client::new();
let id = c.create(json!({"type": "rectangle", "x": 0, "y": 0, "width": 24, "height": 16}));
let r = c.call("export", json!({"ids": [id], "format": "xml"}));
assert_eq!(r["isError"], false, "{r}");
let blocks = r["content"].as_array().unwrap();
let xml = blocks[0]["text"].as_str().unwrap();
assert!(xml.starts_with("<?xml") && xml.contains("android:width=\"24dp\"") && xml.contains("android:height=\"16dp\""), "{xml}");
let info: Value = serde_json::from_str(blocks[1]["text"].as_str().unwrap()).unwrap();
assert_eq!((info["format"].as_str(), info["warnings"].as_array().map(Vec::len)), (Some("xml"), Some(0)));
c.ok("set_properties", json!({"ids": [id], "props": {"effects": [{"type": "dropShadow"}]}}));
let r = c.call("export", json!({"ids": [id], "format": "xml"}));
let info: Value = serde_json::from_str(r["content"][1]["text"].as_str().unwrap()).unwrap();
assert_eq!(info["warnings"], json!(["drop shadows were left out (Rectangle)"]), "{info}");
let r = c.call("export", json!({"ids": [id], "format": "xml", "vdCompat": "ignore"}));
let info: Value = serde_json::from_str(r["content"][1]["text"].as_str().unwrap()).unwrap();
assert_eq!(info["warnings"], json!([]));
assert!(c.err("export", json!({"ids": [id], "format": "xml", "vdCompat": "loose"})).contains("vdCompat"));
}
+4
View File
@@ -11,10 +11,14 @@ mod common;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod export_hidden_roots;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod export_xml;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod garbage;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod inspect_response_limit;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod overlay_anchor;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod protocol;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod screenshot_pixel_limit;
+25
View File
@@ -0,0 +1,25 @@
use crate::common;
use common::Client;
use serde_json::json;
#[test]
fn an_agent_anchors_a_dropdown_to_its_button() {
let mut c = Client::new();
let listed = c.ok("list_commands", json!({"filter": "proto.interaction.add"}));
assert!(listed.as_str().unwrap().contains("triggerOutside"), "{listed}");
let screen = c.create(json!({"type": "frame", "name": "Screen", "x": 0, "y": 0, "width": 400, "height": 800}));
let menu = c.create(json!({"type": "frame", "name": "Menu", "x": 600, "y": 0, "width": 160, "height": 200}));
let button = c.create(json!({"type": "frame", "parent": screen, "name": "Button", "x": 20, "y": 40, "width": 160, "height": 40}));
let open = json!({"type": "openOverlay", "dest": menu, "position": "bottomLeft", "anchor": "triggerOutside"});
c.run("proto.interaction.add", json!({"id": button, "action": open}));
let action = &c.node(&button)["interactions"][0]["actions"][0];
assert_eq!((action["position"].as_str(), action["anchor"].as_str()), (Some("bottomLeft"), Some("triggerOutside")), "{action}");
let bad = json!({"type": "openOverlay", "dest": menu, "anchor": "nextToIt"});
let err = c.err("run_command", json!({"command": "proto.interaction.add", "params": {"id": button, "action": bad}}));
assert!(!err.is_empty());
assert_eq!(c.node(&button)["interactions"].as_array().unwrap().len(), 1);
}
+15 -3
View File
@@ -4,14 +4,14 @@ use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::{Arc, Mutex, OnceLock};
use figcraft_color::{BlendMode, Paint};
use figcraft_color::{BlendMode, Paint, PaintKind};
use figcraft_doc::{Document, EffectKind, Node, NodeId, NodeKind, StrokeAlign};
use figcraft_geom::{Affine, BezPath, Rect, Shape};
use tiny_skia::{FillRule, IntRect, Mask, MaskType, Pixmap, PixmapPaint, Transform};
use tiny_skia::{FillRule, FilterQuality, IntRect, Mask, MaskType, Pixmap, PixmapPaint, Transform};
use crate::backdrop::{self, Under};
use crate::boolean::{PathCache, locked};
use crate::paint::{composite, prepare_at, ts_blend};
use crate::paint::{Prepared, composite, prepare_at, ts_blend};
use crate::{blur, grain, lod, shadow};
/// Shared state for one render.
@@ -30,6 +30,7 @@ pub struct Ctx<'a, 'd> {
pub skip: &'a [NodeId],
/// Approximate what's too small to see in detail (canvas previews; see [`crate::lod`]).
pub lod: bool,
pub magnified_image_pixels: bool,
/// The surfaces under the one being drawn into, when it is an isolated layer (for backdrops).
pub under: Option<&'a Under<'a>>,
/// Seconds into animated GIFs (0: their first frame; only the presenter plays them).
@@ -116,10 +117,21 @@ pub fn ts_path(p: &BezPath) -> Option<tiny_skia::Path> {
b.finish()
}
const IMAGE_PIXELS_SHOWN_FROM_MAGNIFICATION: f64 = 2.0;
fn pixels_when_magnified(prep: Prepared, device_scale: f64) -> Prepared {
let Prepared::Pattern { pixmap, mode, quality, opacity, transform } = prep else { return prep };
let image_scale = f64::from(transform.sx * transform.sy - transform.kx * transform.ky).abs().sqrt();
let magnification = device_scale * image_scale;
let quality = if magnification >= IMAGE_PIXELS_SHOWN_FROM_MAGNIFICATION { FilterQuality::Nearest } else { quality };
Prepared::Pattern { pixmap, mode, quality, opacity, transform }
}
fn fill_with(pix: &mut Pixmap, path: &tiny_skia::Path, paints: &[Paint], w: f64, h: f64, xf: Affine, rule: FillRule, ctx: &Ctx, mask: Option<&Mask>) {
let scale = xf.determinant().abs().sqrt();
for p in paints {
let Some(prep) = prepare_at(p, w, h, scale, ctx.doc, ctx.time) else { continue };
let prep = if ctx.magnified_image_pixels && matches!(p.kind, PaintKind::Image(_)) { pixels_when_magnified(prep, scale) } else { prep };
let paint = tiny_skia::Paint { shader: prep.shader(), anti_alias: true, blend_mode: ts_blend(p.blend), ..Default::default() };
if p.blend == BlendMode::PlusDarker {
// No tiny-skia mode: the paint alone over its pixels, then blended in.
+240 -10
View File
@@ -6,9 +6,20 @@
//! where it faces `light_angle`, half as bright on the opposite side, spread wider by `splay`.
//! `frost` blurs the backdrop first. Distances to the outline come from an exact Euclidean
//! distance transform of the shape's coverage.
//!
//! The shape's coverage, distances and normals depend only on the shape and the zoom. Once a
//! later render asks for the same shape at the same zoom they are worked out for the whole shape,
//! in its own pixel frame, and kept in a cache of at most [`MAX_CACHED_BYTES`]: moving or editing
//! what lies under the glass, dragging its sliders, panning and patching the canvas reuse them.
//! Zooming doesn't (each zoom is new).
use std::collections::HashMap;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::sync::{Arc, Mutex, OnceLock};
use figcraft_doc::Glass;
use figcraft_geom::Affine;
use figcraft_geom::kurbo::Shape as _;
use tiny_skia::Pixmap;
use crate::fx;
@@ -188,6 +199,211 @@ fn light_reach(band: f64, intensity: f64) -> f64 {
/// zoom would otherwise need a huge margin of coverage around every cut).
const MAX_LIGHT_PX: f64 = 512.0;
/// A shape's coverage (alpha), distance to its edge and smoothed inward normals over a window of
/// pixels whose top-left pixel is (`x0`, `y0`).
struct Fields {
x0: i64,
y0: i64,
w: usize,
h: usize,
cov: Vec<u8>,
dist: Vec<f32>,
normals: Vec<(f32, f32)>,
}
/// Bytes a field pixel holds: coverage, distance and a normal.
const FIELD_PX_BYTES: u64 = 1 + 4 + 8;
impl Fields {
/// The fields of `cov`, the shape's coverage on the window at (`x0`, `y0`), normals smoothed
/// over `r` pixels.
fn new(cov: &Pixmap, x0: i64, y0: i64, r: usize) -> Fields {
FIELDS_COMPUTED.with(|n| n.set(n.get() + 1));
let dist = inside_distance(cov);
let (w, h) = (cov.width() as usize, cov.height() as usize);
let normals = smooth_normals(&dist, w, h, r);
let cov = cov.data().chunks_exact(4).map(|p| p.get(3).copied().unwrap_or(0)).collect();
Fields { x0, y0, w, h, cov, dist, normals }
}
/// Coverage (0–1), distance and inward normal at pixel (`x`, `y`); `None` outside the window.
fn at(&self, x: i64, y: i64) -> Option<(f32, f32, (f32, f32))> {
let (fx, fy) = (x.checked_sub(self.x0)?, y.checked_sub(self.y0)?);
if fx < 0 || fy < 0 || fx >= self.w as i64 || fy >= self.h as i64 {
return None;
}
let i = fy as usize * self.w + fx as usize;
Some((f32::from(*self.cov.get(i)?) / 255.0, *self.dist.get(i)?, *self.normals.get(i)?))
}
}
thread_local! {
static FIELDS_COMPUTED: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
}
/// How many times this thread has worked out a glass shape's fields: what a cache miss costs. For
/// tests and benchmarks.
pub fn glass_fields_computed() -> u64 {
FIELDS_COMPUTED.with(std::cell::Cell::get)
}
/// What a glass shape's fields depend on, with the transform's translation reduced to its fraction
/// of a pixel: the same shape at the same zoom has the same key wherever it is and however a render
/// cuts it.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct ShapeKey {
path: u64,
linear: [u64; 4],
frac: (i64, i64),
margin: u32,
r: usize,
}
/// The cached fields of a shape whose window holds at most this many pixels; bigger shapes (a
/// pane far larger than the view) are worked out for the part a render shows each time.
#[cfg(not(target_arch = "wasm32"))]
const MAX_SHAPE_PX: u64 = 4 << 20;
#[cfg(target_arch = "wasm32")]
const MAX_SHAPE_PX: u64 = 1 << 20;
/// Bytes the glass shape cache holds at most (less on the web, where memory never shrinks).
#[cfg(not(target_arch = "wasm32"))]
const MAX_CACHED_BYTES: u64 = 128 << 20;
#[cfg(target_arch = "wasm32")]
const MAX_CACHED_BYTES: u64 = 32 << 20;
/// A shape's fields once worked out; parallel bands wanting the same shape wait for one of them.
type Slot = Arc<OnceLock<Option<Arc<Fields>>>>;
/// The shapes' fields by key, at most `budget` bytes; the least recently used go first. A shape is
/// kept from the second render that asks for it: one asked for once (each step of a zoom, a pane
/// dragged by fractions of a pixel) is cheaper worked out for just the part a render shows.
struct ShapeCache {
map: HashMap<ShapeKey, (Slot, u64, u64)>,
/// Shapes asked for but not kept, by the render that first asked.
seen: HashMap<ShapeKey, u64>,
held: u64,
tick: u64,
budget: u64,
}
/// Shapes remembered as asked for once; past this many they are forgotten.
const MAX_SEEN: usize = 256;
impl ShapeCache {
fn new(budget: u64) -> Self {
Self { map: HashMap::new(), seen: HashMap::new(), held: 0, tick: 0, budget }
}
/// The slot for `key` (filled or about to be) asked for in render number `render`, making room
/// for `bytes`; `None` when it isn't kept (first asked for in this render, or too big).
fn slot(&mut self, key: &ShapeKey, bytes: u64, render: u64) -> Option<Slot> {
self.tick += 1;
if let Some((slot, _, used)) = self.map.get_mut(key) {
*used = self.tick;
return Some(slot.clone());
}
if bytes > self.budget {
return None;
}
match self.seen.get(key) {
Some(first) if *first < render => {
self.seen.remove(key);
}
Some(_) => return None,
None => {
if self.seen.len() >= MAX_SEEN {
self.seen.clear();
}
self.seen.insert(key.clone(), render);
return None;
}
}
while self.held + bytes > self.budget {
let oldest = self.map.iter().min_by_key(|(_, (_, _, used))| *used).map(|(k, _)| k.clone())?;
if let Some((_, b, _)) = self.map.remove(&oldest) {
self.held -= b;
}
}
let slot = Slot::default();
self.map.insert(key.clone(), (slot.clone(), bytes, self.tick));
self.held += bytes;
Some(slot)
}
}
fn shape_cache() -> &'static Mutex<ShapeCache> {
static C: OnceLock<Mutex<ShapeCache>> = OnceLock::new();
C.get_or_init(|| Mutex::new(ShapeCache::new(MAX_CACHED_BYTES)))
}
/// The fields `glass` reads for a `w` × `h` layer whose `shape` maps through `xf` (local → layer
/// pixels), exact for distances up to the margin `m`, normals smoothed over `r` pixels, and the
/// shift from layer pixels to the fields' pixels.
///
/// A shape asked for again in a later render, whose window fits [`MAX_SHAPE_PX`], is worked out
/// whole, in its own frame (the translation's fraction of a pixel), and kept: a band, a canvas
/// patch or the next frame of a drag reads the same fields. Otherwise it is worked out for the part
/// shown plus the margin.
fn shape_fields(shape: &Outline, xf: Affine, w: u32, h: u32, m: u32, r: usize) -> Option<(Arc<Fields>, (i64, i64))> {
if let Some(whole) = whole_shape(shape, xf, m, r) {
return Some(whole);
}
let cov = spread_alpha(std::slice::from_ref(shape), 0.0, Affine::translate((f64::from(m), f64::from(m))) * xf, w + 2 * m, h + 2 * m)?;
Some((Arc::new(Fields::new(&cov, -i64::from(m), -i64::from(m), r)), (0, 0)))
}
/// [`shape_fields`] for a whole shape, from the cache; `None` when it isn't kept (first asked for
/// in this render, too big, or its transform too far out).
fn whole_shape(shape: &Outline, xf: Affine, m: u32, r: usize) -> Option<(Arc<Fields>, (i64, i64))> {
let [a, b, c, d, e, f] = xf.as_coeffs();
// The translation in steps of 1/65536 px (far finer than the rasteriser's 1/64, coarse enough
// that rounding errors in where a render starts land on the same step), its whole pixels (the
// shift) and the fraction left; rounding first carries 99.9999999 over to 100.
const STEPS: f64 = 65536.0;
let q = |v: f64| {
let q = (v * STEPS).round();
(q.is_finite() && q.abs() < 1e15).then_some(q as i64)
};
let (qe, qf) = (q(e)?, q(f)?);
let shift = (qe.div_euclid(STEPS as i64), qf.div_euclid(STEPS as i64));
let frac = (qe.rem_euclid(STEPS as i64), qf.rem_euclid(STEPS as i64));
let own = Affine::new([a, b, c, d, frac.0 as f64 / STEPS, frac.1 as f64 / STEPS]);
let bb = (own * shape.0.clone()).bounding_box();
let (x0, y0, x1, y1) = (bb.x0.floor(), bb.y0.floor(), bb.x1.ceil(), bb.y1.ceil());
if ![x0, y0, x1, y1].iter().all(|v| v.is_finite() && v.abs() < 1e9) {
return None;
}
// The margin in steps of 32 px, so dragging a slider that widens it a little keeps the same
// fields (a wider margin only adds exact distances further out: the same pixels).
let margin = m.div_ceil(32).checked_mul(32)?;
let (rw, rh) = ((x1 - x0) as u64 + 2, (y1 - y0) as u64 + 2);
let (ww, wh) = (rw + 2 * u64::from(margin), rh + 2 * u64::from(margin));
if ww.checked_mul(wh)? > MAX_SHAPE_PX {
return None;
}
let mut hasher = DefaultHasher::new();
crate::draw::hash_path(&shape.0, &mut hasher);
matches!(shape.1, tiny_skia::FillRule::EvenOdd).hash(&mut hasher);
let key = ShapeKey { path: hasher.finish(), linear: [a, b, c, d].map(f64::to_bits), frac, margin, r };
let compute = || {
// Rastered against the shape's own bounds (not the window), so the margin can't move a
// coverage value.
let raster = spread_alpha(std::slice::from_ref(shape), 0.0, Affine::translate((1.0 - x0, 1.0 - y0)) * own, rw as u32, rh as u32)?;
let mut win = Pixmap::new(ww as u32, wh as u32)?;
let (row, at) = (raster.width() as usize * 4, (margin as usize, margin as usize));
let stride = win.width() as usize * 4;
for (y, src) in raster.data().chunks_exact(row).enumerate() {
let start = (y + at.1) * stride + at.0 * 4;
win.data_mut().get_mut(start..start + row)?.copy_from_slice(src);
}
let origin = (x0 as i64 - 1 - i64::from(margin), y0 as i64 - 1 - i64::from(margin));
Some(Fields::new(&win, origin.0, origin.1, r))
};
let slot =
shape_cache().lock().unwrap_or_else(std::sync::PoisonError::into_inner).slot(&key, ww * wh * FIELD_PX_BYTES, crate::render_number())?;
Some((slot.get_or_init(|| compute().map(Arc::new)).clone()?, shift))
}
/// The glass of a `w` × `h` layer whose `shape` maps through `xf` (local → layer pixels), from
/// `back`, its backdrop padded by `pad` pixels each way, at `scale` device pixels per unit.
#[allow(clippy::too_many_arguments)]
@@ -201,10 +417,7 @@ pub fn glass(g: &Glass, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u32
// exact: the bend's (within `depth`), the light's (within `reach`) and those the normals are
// smoothed from (2r + 1 further).
let m = depth.max(reach).ceil() as u32 + 4 * r as u32 + 4;
let cov = spread_alpha(std::slice::from_ref(shape), 0.0, Affine::translate((f64::from(m), f64::from(m))) * xf, w + 2 * m, h + 2 * m)?;
let dist = inside_distance(&cov);
let (cw, ch) = (cov.width() as usize, cov.height() as usize);
let normals = smooth_normals(&dist, cw, ch, r);
let (fields, shift) = shape_fields(shape, xf, w, h, m, r)?;
let mut frosted = back.clone();
fx::blur(&mut frosted, g.frost / 2.0 * scale);
let ang = g.light_angle.to_radians();
@@ -221,17 +434,12 @@ pub fn glass(g: &Glass, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u32
let depth = depth as f32;
let mut out = Pixmap::new(w, h)?;
let dst = out.data_mut();
let cd = cov.data();
let d_at = |x: usize, y: usize| dist.get(y * cw + x).copied().unwrap_or(0.0);
for y in 0..h as usize {
for x in 0..w as usize {
let (mx, my) = (x + m as usize, y + m as usize);
let c = f32::from(cd.get((my * cw + mx) * 4 + 3).copied().unwrap_or(0)) / 255.0;
let Some((c, d, inward)) = fields.at(x as i64 - shift.0, y as i64 - shift.1) else { continue };
if c <= 0.0 {
continue;
}
let d = d_at(mx, my);
let inward = normals.get(my * cw + mx).copied().unwrap_or((0.0, 0.0));
let t = (d / depth).min(1.0);
let bend = bend_max * (1.0 - t) * (1.0 - t);
let (bx, by) = (x as f32 + pad as f32 + 0.5, y as f32 + pad as f32 + 0.5);
@@ -284,6 +492,28 @@ mod tests {
assert!((d[10 * 21 + 5] - 0.5).abs() < 1e-4, "{}", d[10 * 21 + 5]);
}
#[test]
fn the_shape_cache_stays_within_its_bytes() {
let key = |i: u64| ShapeKey { path: i, linear: [0; 4], frac: (0, 0), margin: 0, r: 1 };
let mut c = ShapeCache::new(100);
// Kept from the second render that asks, not the first (nor again within the first).
assert!(c.slot(&key(1), 40, 1).is_none());
assert!(c.slot(&key(1), 40, 1).is_none());
assert!(c.slot(&key(1), 40, 2).is_some());
for k in [2, 3] {
assert!(c.slot(&key(k), 40, 2).is_none());
}
assert!(c.slot(&key(2), 40, 3).is_some());
// Using 1 again makes 2 the least recently used: 2 goes to make room for 3.
assert!(c.slot(&key(1), 40, 3).is_some());
assert!(c.slot(&key(3), 40, 3).is_some());
assert!(c.held <= 100, "{}", c.held);
assert!(c.map.contains_key(&key(1)) && c.map.contains_key(&key(3)) && !c.map.contains_key(&key(2)));
// More than it holds in all: never kept.
assert!(c.slot(&key(4), 101, 3).is_none() && c.slot(&key(4), 101, 4).is_none());
assert!(c.held <= 100 && !c.map.contains_key(&key(4)));
}
#[test]
fn light_reach_is_where_the_light_falls_under_half_a_level() {
let r = light_reach(10.0, 1.0);
+16
View File
@@ -80,6 +80,18 @@ fn set_glass(d: &mut Document, pane: NodeId, g: Glass) {
d.get_mut(pane).unwrap().effects = vec![Effect { kind: EffectKind::Glass(g), visible: true }];
}
/// Writes the bench scene's render (rendered twice, so a kept shape is read) to the PNG named by
/// `FIGCRAFT_GLASS_PNG`, to compare builds by eye.
#[test]
#[ignore = "writes a PNG: FIGCRAFT_GLASS_PNG=<path>"]
fn glass_bench_png() {
let Ok(path) = std::env::var("FIGCRAFT_GLASS_PNG") else { return };
let (d, p, _, _) = scene();
let _ = render_view(&d, p, &view((0.0, 0.0), 2.0), &Options::default());
let pix = render_view(&d, p, &view((0.0, 0.0), 2.0), &Options::default()).unwrap();
pix.save_png(path).unwrap();
}
#[test]
#[ignore = "timing: run in release with --ignored --nocapture"]
fn glass_render_timings() {
@@ -96,6 +108,10 @@ fn glass_render_timings() {
}),
),
("export: zoom, a new scale every frame", time(&mut d, p, render_view, |_, i| view((0.0, 0.0), 2.0 + 0.013 * f64::from(i)))),
(
"export: zoomed in, a corner of the pane in view, a new scale every frame",
time(&mut d, p, render_view, |_, i| view((700.0, 400.0), 3.0 + 0.013 * f64::from(i))),
),
("single: same view again", time(&mut d, p, render_view_single, |_, _| view((0.0, 0.0), 2.0))),
("canvas: drag the card under the glass", canvas(&d, p, 2.0, move_card)),
(
+82
View File
@@ -99,7 +99,10 @@ fn glass_digests() {
let (d, p, _) = pane_over_stripes(g.clone(), radius);
for scale in [1.0, 1.37, 2.0] {
let v = View { origin: (100.0, 100.0), scale, width: (1000.0 * scale) as u32, height: (700.0 * scale) as u32 };
// Rendered twice: the second reads the whole shape the first asked for.
let first = render_view_single(&d, p, &v, &Options::default()).unwrap();
let pix = render_view_single(&d, p, &v, &Options::default()).unwrap();
assert!(first.data() == pix.data(), "glass {i} radius {radius} scale {scale}: the kept shape renders differently");
let mut h = DefaultHasher::new();
pix.data().hash(&mut h);
println!("glass {i} radius {radius} scale {scale}: {:016x}", h.finish());
@@ -133,3 +136,82 @@ fn a_cut_through_the_pane_matches_the_whole_render() {
}
}
}
/// A `w` × `h` pane of glass `g` at (`x`, `y`) over stripes, alone on its page: (document, page,
/// pane). Each test gives its own size, so tests running at once don't share cached shapes.
fn pane(g: Glass, x: f64, y: f64, w: f64, h: f64) -> (Document, NodeId, NodeId) {
let mut d = Document::new("glass");
let p = d.first_page().unwrap();
for i in 0..8u32 {
let s = d.create(p, NodeKind::Rectangle, "Stripe", f64::from(i) * 40.0, 0.0, 20.0, 400.0).unwrap();
d.get_mut(s).unwrap().fills = vec![Paint::solid(Color::from_hex("2040E0").unwrap())];
}
let pane = d.create(p, NodeKind::Rectangle, "Glass", 0.0, 0.0, w, h).unwrap();
let n = d.get_mut(pane).unwrap();
n.transform = Affine::translate((x, y));
n.corners.radii = [12.0; 4];
n.effects = vec![Effect { kind: EffectKind::Glass(g), visible: true }];
(d, p, pane)
}
fn render(d: &Document, p: NodeId, origin: (f64, f64), scale: f64) -> Pixmap {
render_view_single(d, p, &View { origin, scale, width: 400, height: 300 }, &Options::default()).unwrap()
}
/// How many glass shapes `f` works out on this thread.
fn computed(f: impl FnOnce()) -> u64 {
let before = crate::glass::glass_fields_computed();
f();
crate::glass::glass_fields_computed() - before
}
#[test]
fn kept_fields_render_the_same_pixels_as_fresh_ones() {
let (d, p, _) = pane(Glass::default(), 40.0, 30.0, 211.0, 137.0);
let mut shots = Vec::new();
// The first render works out the part it shows, the next the whole shape (and keeps it), the
// ones after reuse it.
for want in [1, 1, 0] {
assert_eq!(computed(|| shots.push(render(&d, p, (0.0, 0.0), 1.5))), want, "render {}", shots.len() + 1);
}
assert!(shots.windows(2).all(|w| w[0].data() == w[1].data()));
}
#[test]
fn what_the_shape_depends_on_works_it_out_again_and_nothing_else_does() {
let (mut d, p, pane) = pane(Glass::default(), 40.0, 30.0, 223.0, 149.0);
let set = |d: &mut Document, g: Glass| d.get_mut(pane).unwrap().effects = vec![Effect { kind: EffectKind::Glass(g), visible: true }];
// Asked for twice: kept.
let _ = render(&d, p, (0.0, 0.0), 1.0);
let _ = render(&d, p, (0.0, 0.0), 1.0);
// Reused: the look apart from depth, what lies under the glass, panning by whole pixels and
// moving the pane by whole pixels.
let reused = computed(|| {
set(&mut d, Glass { light_angle: 30.0, refraction: 0.2, dispersion: 1.0, frost: 9.0, ..Glass::default() });
let _ = render(&d, p, (0.0, 0.0), 1.0);
let stripe = d.node(p).unwrap().children[0];
d.get_mut(stripe).unwrap().transform = Affine::translate((5.0, 0.0));
let _ = render(&d, p, (0.0, 0.0), 1.0);
let _ = render(&d, p, (7.0, -3.0), 1.0);
d.get_mut(pane).unwrap().transform = Affine::translate((52.0, 31.0));
let _ = render(&d, p, (0.0, 0.0), 1.0);
});
assert_eq!(reused, 0);
// Worked out again: another zoom, a move by part of a pixel, another shape, a much deeper edge.
assert_eq!(computed(|| drop(render(&d, p, (0.0, 0.0), 1.25))), 1, "zoom");
d.get_mut(pane).unwrap().transform = Affine::translate((52.5, 31.0));
assert_eq!(computed(|| drop(render(&d, p, (0.0, 0.0), 1.0))), 1, "part of a pixel");
d.get_mut(pane).unwrap().width = 230.0;
assert_eq!(computed(|| drop(render(&d, p, (0.0, 0.0), 1.0))), 1, "shape");
set(&mut d, Glass { depth: 70.0, ..Glass::default() });
assert_eq!(computed(|| drop(render(&d, p, (0.0, 0.0), 1.0))), 1, "depth");
}
#[test]
fn a_pane_too_big_to_keep_still_renders() {
// 400 × 300 units at 8x: far past what is kept whole, so each render works out its part.
let (d, p, _) = pane(Glass { frost: 0.0, ..Glass::default() }, 0.0, 0.0, 400.0, 300.0);
let shown = render(&d, p, (10.0, 10.0), 8.0);
assert_eq!(computed(|| drop(render(&d, p, (10.0, 10.0), 8.0))), 1);
assert!(shown.data().chunks_exact(4).any(|px| px[2] > 200), "the stripes show through");
}
+30 -4
View File
@@ -81,6 +81,7 @@ pub struct Options {
/// detail, see [`lod`]. Off for everything that must be exact (exports, thumbnails); no
/// effect in outline mode.
pub lod: bool,
pub magnified_image_pixels: bool,
/// A dot grid over the background, under the layers (a board's canvas; drawn only where the
/// background is).
pub dots: Option<Dots>,
@@ -104,8 +105,23 @@ pub(crate) fn backdrop_reach(radius: f64, scale: f64) -> f64 {
if radius > 0.0 { radius * 1.5 * scale + 3.0 } else { 0.0 }
}
/// Renders started (bands of one render count once): glass keeps the shapes a later render asks
/// for again.
static RENDERS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// The number of the render that just started.
fn start_render() -> u64 {
RENDERS.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1
}
/// The number of the latest render started.
pub(crate) fn render_number() -> u64 {
RENDERS.load(std::sync::atomic::Ordering::Relaxed)
}
/// Render a page region, splitting it into horizontal bands rendered in parallel (desktop).
pub fn render_view(doc: &Document, page: NodeId, view: &View, opts: &Options) -> Option<Pixmap> {
start_render();
#[cfg(not(target_arch = "wasm32"))]
{
let threads = rayon::current_num_threads().clamp(1, 16) as u32;
@@ -117,7 +133,7 @@ pub fn render_view(doc: &Document, page: NodeId, view: &View, opts: &Options) ->
return Some(p);
}
}
render_view_single(doc, page, view, opts)
render_one(doc, page, view, opts)
}
/// Render `view` as `bands` horizontal bands in parallel. Bands overlap by what background blurs
@@ -142,7 +158,7 @@ fn render_bands(doc: &Document, page: NodeId, view: &View, opts: &Options, bands
let top = y0.saturating_sub(overlap);
let bottom = (y0 + h + overlap).min(view.height).max(y0 + h);
let v = View { origin: (view.origin.0, view.origin.1 + f64::from(top) / s), scale: view.scale, width: view.width, height: bottom - top };
let part = render_view_single(doc, page, &v, opts);
let part = render_one(doc, page, &v, opts);
let own = tiny_skia::IntRect::from_xywh(0, (y0 - top) as i32, view.width.clamp(1, 16_384), h);
(y0, if overlap == 0 { part } else { part.zip(own).and_then(|(p, r)| p.clone_rect(r)) })
})
@@ -166,6 +182,12 @@ fn render_bands(doc: &Document, page: NodeId, view: &View, opts: &Options, bands
/// Render a page region on the calling thread.
pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Options) -> Option<Pixmap> {
start_render();
render_one(doc, page, view, opts)
}
/// [`render_view_single`] as part of a render already started (one of its bands).
fn render_one(doc: &Document, page: NodeId, view: &View, opts: &Options) -> Option<Pixmap> {
let mut pix = new_pixmap(view.width, view.height)?;
let bg = opts.background.or_else(|| match doc.node(page).map(|p| &p.kind) {
Some(NodeKind::Page { background, .. }) => Some(*background),
@@ -187,6 +209,7 @@ pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Opti
outlined: &opts.outlined,
skip: &opts.skip,
lod,
magnified_image_pixels: opts.magnified_image_pixels,
under: None,
time: 0.0,
bounds: &bounds,
@@ -220,6 +243,7 @@ fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, tim
outlined: &[],
skip: &[],
lod: false,
magnified_image_pixels: false,
under: None,
time,
bounds: &bounds,
@@ -402,7 +426,7 @@ pub fn encode_webp(p: &Pixmap) -> Result<Vec<u8>, String> {
/// Encode an export in `format` (PNG, JPEG over white, WebP) tagged with the document's colour
/// `profile`. An sRGB PNG gets an `sRGB` chunk; a Display P3 PNG gets the profile as `iCCP` plus
/// `cICP` code points; Display P3 JPEG and WebP files embed the ICC profile (untagged files are read
/// as sRGB). SVG and PDF are not pixel formats: an error.
/// as sRGB). SVG, PDF and Vector Drawable are not pixel formats: an error.
pub fn encode_export(p: &Pixmap, format: ExportFormat, profile: ColorProfile) -> Result<Vec<u8>, String> {
let icc = (!profile.is_srgb()).then(|| profile.icc());
match format {
@@ -412,7 +436,7 @@ pub fn encode_export(p: &Pixmap, format: ExportFormat, profile: ColorProfile) ->
}
ExportFormat::Jpg => jpeg_with(p, Color::WHITE, 90, icc),
ExportFormat::Webp => webp_with(p, icc),
ExportFormat::Svg | ExportFormat::Pdf => Err(format!("{} is not a pixel format", format.label())),
ExportFormat::Svg | ExportFormat::Pdf | ExportFormat::VectorDrawable => Err(format!("{} is not a pixel format", format.label())),
}
}
@@ -470,6 +494,8 @@ mod image_profile_tests;
#[cfg(test)]
mod lod_tests;
#[cfg(test)]
mod magnified_image_tests;
#[cfg(test)]
mod paint_behaviour_tests;
#[cfg(test)]
mod progressive_tests;
+1
View File
@@ -163,6 +163,7 @@ fn exports_never_use_lod() {
outlined: &[],
skip: &[],
lod: true,
magnified_image_pixels: false,
under: None,
time: 0.0,
bounds: &bounds,
@@ -0,0 +1,75 @@
use std::sync::Arc;
use figcraft_color::{ImageScaleMode, Paint};
use figcraft_doc::{Document, ImageData, NodeId, NodeKind};
use crate::{Options, Pixmap, Retained, View, encode_png, render_view};
fn checkerboard_doc() -> (Document, NodeId) {
let mut img = Pixmap::new(4, 4).unwrap();
for (i, c) in img.data_mut().chunks_exact_mut(4).enumerate() {
let black = (i % 4 + i / 4) % 2 == 0;
c.copy_from_slice(if black { &[0, 0, 0, 255] } else { &[255, 255, 255, 255] });
}
let mut d = Document::new("magnified");
let page = d.first_page().unwrap();
d.images.insert("checker".into(), ImageData { bytes: Arc::new(encode_png(&img).unwrap()), width: 4, height: 4 });
let r = d.create(page, NodeKind::Rectangle, "Image", 0.0, 0.0, 4.0, 4.0).unwrap();
d.get_mut(r).unwrap().fills = vec![Paint::image("checker", ImageScaleMode::Fill)];
(d, page)
}
fn view(scale: f64) -> View {
let side = (4.0 * scale).round() as u32;
View { origin: (0.0, 0.0), scale, width: side, height: side }
}
fn canvas() -> Options {
Options { lod: true, magnified_image_pixels: true, ..Default::default() }
}
fn in_between_greys(p: &Pixmap, side: u32) -> usize {
let row = p.width() as usize * 4;
p.data().chunks_exact(row).take(side as usize).flat_map(|r| r[..side as usize * 4].chunks_exact(4)).filter(|c| c[0] > 8 && c[0] < 247).count()
}
#[test]
fn a_magnified_image_on_the_canvas_shows_its_pixels_as_hard_edged_squares() {
let (d, page) = checkerboard_doc();
let v = view(8.0);
let pix = render_view(&d, page, &v, &canvas()).unwrap();
assert_eq!(in_between_greys(&pix, v.width), 0, "each image pixel is one flat 8 × 8 square");
for (x, y, black) in [(3, 3, true), (11, 3, false), (3, 11, false), (11, 11, true), (15, 20, false)] {
let i = ((y * v.width + x) * 4) as usize;
assert_eq!(pix.data()[i] == 0, black, "({x}, {y})");
}
}
#[test]
fn exports_keep_magnified_images_smooth() {
let (d, page) = checkerboard_doc();
let v = view(8.0);
let pix = render_view(&d, page, &v, &Options::default()).unwrap();
assert!(in_between_greys(&pix, v.width) > 0, "an export scaled up blends neighbouring pixels");
}
#[test]
fn below_twice_its_size_an_image_stays_smooth_on_the_canvas() {
let (d, page) = checkerboard_doc();
let v = view(1.5);
let canvas = render_view(&d, page, &v, &canvas()).unwrap();
let export = render_view(&d, page, &v, &Options::default()).unwrap();
assert_eq!(canvas.data(), export.data());
}
#[test]
fn turning_pixel_squares_on_redraws_a_retained_view() {
let (d, page) = checkerboard_doc();
let d = Arc::new(d);
let v = view(8.0);
let mut r = Retained::default();
let smooth = r.render(&d, page, &v, &Options { lod: true, ..Default::default() }).unwrap().1.clone();
let squares = r.render(&d, page, &v, &canvas()).unwrap().1.clone();
assert!(in_between_greys(&smooth, v.width) > 0);
assert_eq!(in_between_greys(&squares, v.width), 0);
}
+1
View File
@@ -55,6 +55,7 @@ pub fn cell_pixmap(doc: &Document, pt: &PatternPaint, w: f64, h: f64, scale: f64
outlined: &[],
skip: &[],
lod: false,
magnified_image_pixels: false,
under: None,
time,
bounds: &bounds,
+3
View File
@@ -45,6 +45,7 @@ struct Frame {
outline_extras: crate::Outline,
outlined: Vec<NodeId>,
lod: bool,
magnified_image_pixels: bool,
skip: Vec<NodeId>,
solo: Option<NodeId>,
background: Option<figcraft_color::Color>,
@@ -157,6 +158,7 @@ impl Retained {
&& !opts.outline.hidden
&& last.outlined == opts.outlined
&& last.lod == opts.lod
&& last.magnified_image_pixels == opts.magnified_image_pixels
&& last.skip == opts.skip
&& last.solo == opts.solo
&& last.background == opts.background
@@ -202,6 +204,7 @@ impl Retained {
outline_extras: opts.outline,
outlined: opts.outlined.clone(),
lod: opts.lod,
magnified_image_pixels: opts.magnified_image_pixels,
skip: opts.skip.clone(),
solo: opts.solo,
background: opts.background,
+4 -2
View File
@@ -626,7 +626,8 @@ impl W<'_> {
let end = glyphs.get(j).map_or(line.char_end, |g| g.char_start).max(start).min(chars.len());
let text: String = chars.get(start.min(end)..end).unwrap_or(&[]).iter().collect();
let prev = start.checked_sub(1).and_then(|k| chars.get(k)).copied();
let text = st.case.apply_after(text.trim_end_matches(['\n', '\r']), prev);
let before = start.checked_sub(2).and_then(|k| chars.get(k)).copied();
let text = st.case.apply_between(text.trim_end_matches(['\n', '\r']), before, prev);
let own = match layout.styles.get(style).and_then(|s| s.fills.as_ref()) {
Some(fills) => {
let a = fills.iter().find_map(|p| self.paint_attr(p, n.width, n.height)).unwrap_or_else(|| "fill=\"none\"".into());
@@ -642,7 +643,8 @@ impl W<'_> {
}
let text: String = chars.get(line.char_start..line.char_end.min(chars.len())).unwrap_or(&[]).iter().collect();
let prev = line.char_start.checked_sub(1).and_then(|k| chars.get(k)).copied();
let text = st.case.apply_after(text.trim_end_matches(['\n', '\r']), prev);
let before = line.char_start.checked_sub(2).and_then(|k| chars.get(k)).copied();
let text = st.case.apply_between(text.trim_end_matches(['\n', '\r']), before, prev);
let (x, y) = layout.glyphs.get(line.glyph_start).map_or((0.0, line.baseline), |g| (g.x, g.y));
let _ = write!(spans, "<tspan x=\"{}\" y=\"{}\">{}</tspan>", num(x), num(y), esc(&text));
}
+38
View File
@@ -162,6 +162,44 @@ fn resizing_auto_width_auto_height_fixed_and_truncate() {
assert!(two.truncated && two.lines.len() == 2);
}
#[test]
fn truncation_fills_the_last_line_before_the_ellipsis() {
// #1375: as with CSS `text-overflow: ellipsis`, the last line shown takes as much of the word
// wrapped after it as fits before the ellipsis ("Lorem consec…", not "Lorem…").
if face().is_none() {
return;
}
let s = "Lorem consectetur";
let full = layout(&text(s, TextAutoResize::WidthAndHeight), None, None);
let lorem = layout(&text("Lorem", TextAutoResize::WidthAndHeight), None, None).width;
// Room for "Lorem" and most of the next word, not all of it.
let w = lorem + (full.width - lorem) * 0.75;
let line_h = full.lines[0].height;
let mut max_one = text(s, TextAutoResize::Truncate);
max_one.max_lines = 1;
let mut hug_one = text(s, TextAutoResize::Height);
hug_one.max_lines = 1;
for (l, why) in [
(layout(&text(s, TextAutoResize::Truncate), Some(w), Some(line_h + 1.0)), "cut by the box height"),
(layout(&max_one, Some(w), Some(1000.0)), "cut by max lines"),
(layout(&hug_one, Some(w), None), "auto height with max lines"),
] {
assert!(l.truncated && l.lines.len() == 1, "{why}");
let last = l.glyphs.last().unwrap();
assert_eq!(last.char_start, last.char_end, "{why}: the ellipsis ends the line");
assert!(l.glyphs.iter().any(|g| g.char_start == 6), "{why}: the wrapped word starts on the line");
assert!(l.glyphs.iter().all(|g| g.x + g.advance <= w + EPS), "{why}: within the box");
assert!((l.height - line_h).abs() < EPS, "{why}: one line tall");
let g7 = l.glyphs.iter().find(|g| g.char_start == 7).unwrap();
assert_eq!(l.caret(7), (g7.x, 0), "{why}: a caret reaches the chars taken from the next line");
}
// A line that ends its paragraph keeps its words; the ellipsis follows them.
let mut para = text("Lorem\nconsectetur", TextAutoResize::Truncate);
para.max_lines = 1;
let l = layout(&para, Some(w), Some(1000.0));
assert!(l.truncated && l.glyphs.iter().all(|g| g.char_start < 5 || g.char_start == g.char_end));
}
#[test]
fn vertical_trim_cap_height_to_baseline() {
let Some(f) = face() else { return };
+57 -19
View File
@@ -532,7 +532,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
let mut out = TextLayout { styles: styles.clone(), ..Default::default() };
let mut y = 0.0;
let mut para_start_line = true;
let max_lines = if text.max_lines > 0 && matches!(text.auto_resize, TextAutoResize::Truncate) { text.max_lines as usize } else { usize::MAX };
let max_lines = if text.max_lines > 0 && text.truncates() { text.max_lines as usize } else { usize::MAX };
// A style's vertical metrics (ascent, descent, line height, cap height) come from its own
// font, never a fallback face, so lines mixing scripts keep the spacing of lines that don't.
let metrics = |st_idx: usize| -> [f64; 4] {
@@ -546,6 +546,25 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
None => [size * 0.8, size * 0.2, whole_px(st.line_height.resolve(size, 1.2)), size * 0.7],
}
};
// An item's glyph with its pen position at `pen` on the line at `baseline` (none for a space
// the font has no glyph for).
let glyph_at = |it: &Item, pen: f64, baseline: f64| {
let size = styles.get(it.style).map(|st| sanitize_size(st.font_size)).unwrap_or(12.0) * it.scale;
(it.gid != 0 || !it.is_space()).then(|| Glyph {
face: it.face.clone(),
coords: it.coords.clone(),
gid: it.gid,
x: pen + it.dx,
y: baseline - it.dy,
size,
advance: it.adv,
char_start: it.char_start,
char_end: it.char_end,
style: it.style,
rtl: it.level % 2 == 1,
warp: Affine::IDENTITY,
})
};
let mut first_cap = 0.0;
for (li, (s, e, para_end, para)) in lines.iter().enumerate() {
if li >= max_lines {
@@ -685,23 +704,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
if is_line_break(it.ch) {
continue;
}
let size = styles.get(it.style).map(|st| sanitize_size(st.font_size)).unwrap_or(12.0) * it.scale;
if it.gid != 0 || !it.is_space() {
out.glyphs.push(Glyph {
face: it.face.clone(),
coords: it.coords.clone(),
gid: it.gid,
x: pen + it.dx,
y: baseline - it.dy,
size,
advance: it.adv,
char_start: it.char_start,
char_end: it.char_end,
style: it.style,
rtl: it.level % 2 == 1,
warp: Affine::IDENTITY,
});
}
out.glyphs.extend(glyph_at(it, pen, baseline));
pen += it.adv;
if it.stretches() {
pen += justify_extra;
@@ -757,7 +760,40 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
}
}
if out.truncated {
// The last line shown runs on into the line cut after it, so the ellipsis fills the line
// ("Lorem consec…") instead of following the last whole word ("Lorem…").
let last = out.lines.len().checked_sub(1);
let wrapped_on = last.and_then(|li| Some((out.lines.get(li)?, lines.get(li)?, lines.get(li + 1)?)));
if let (Some(maxw), Some((line, &(_, e, para_end, _), &(ns, ne, _, _)))) = (wrap, wrapped_on)
&& !para_end
&& !break_after(e)
&& !line.rtl
{
let baseline = line.baseline;
let mut pen = out.glyphs.get(line.glyph_start..line.glyph_end).and_then(<[Glyph]>::last).map_or(line.width, |g| g.x + g.advance);
for it in items.get(ns..ne).unwrap_or(&[]) {
if pen > maxw {
break;
}
if !is_line_break(it.ch) {
out.glyphs.extend(glyph_at(it, pen, baseline));
pen += it.adv;
}
}
let gend = out.glyphs.len();
if let Some(l) = out.lines.last_mut() {
l.glyph_end = gend;
}
}
add_ellipsis(&mut out, wrap);
// Carets and selections reach every char the line shows, borrowed ones included.
if let Some(l) = out.lines.last() {
let shown =
out.glyphs.get(l.glyph_start..l.glyph_end).unwrap_or(&[]).iter().filter(|g| g.char_end > g.char_start).map(|g| g.char_end).max();
if let (Some(shown), Some(l)) = (shown, out.lines.last_mut()) {
l.char_end = l.char_end.max(shown);
}
}
}
// Horizontal alignment within the box.
@@ -1076,6 +1112,7 @@ fn shape_segment(chars: &[char], seg: &Segment, face: &Arc<Face>, features: &[Fe
// a word split across styled runs is capitalised once) and remember synthesised small caps.
let mut mapped: Vec<(char, usize, bool)> = Vec::with_capacity(end.saturating_sub(start));
let mut prev = start.checked_sub(1).and_then(|i| chars.get(i)).copied();
let mut before = start.checked_sub(2).and_then(|i| chars.get(i)).copied();
for (i, c) in chars.get(start..end).unwrap_or(&[]).iter().enumerate() {
let src = start + i;
if small_caps && !native_smcp && c.is_lowercase() {
@@ -1086,10 +1123,11 @@ fn shape_segment(chars: &[char], seg: &Segment, face: &Arc<Face>, features: &[Fe
mapped.push((lower_sigma(chars, src), src, false));
} else {
let mut buf = [0u8; 4];
for u in style.case.apply_after(c.encode_utf8(&mut buf), prev).chars() {
for u in style.case.apply_between(c.encode_utf8(&mut buf), before, prev).chars() {
mapped.push((u, src, false));
}
}
before = prev;
prev = Some(*c);
}
let mut small = vec![false; end.saturating_sub(start)];
+28 -1
View File
@@ -170,6 +170,31 @@ fn title_case_across_runs() {
assert_eq!(gid_at(6), Some(face.glyph_for('W')));
}
#[test]
fn title_case_after_opening_apostrophe_on_canvas() {
let Some(face) = inter() else { return };
let glyph_chars = |s: &str, split: Option<usize>| {
let mut t = TextData::new(s);
t.style.case = figcraft_doc::TextCase::Title;
if let Some(n) = split {
t.restyle(0, n, |st| st.font_style = "Bold".into());
}
let l = layout(&t, None, None);
let chars: Vec<char> = s.chars().collect();
(0..chars.len()).map(|i| l.glyphs.iter().find(|g| g.char_start == i).map(|g| g.gid)).collect::<Vec<_>>()
};
let expect = |s: &str, want: &str| {
let got: Vec<Option<u32>> = want.chars().map(|c| Some(face.glyph_for(c))).collect();
for split in [None, Some(1), Some(2)] {
assert_eq!(glyph_chars(s, split), got, "{s:?} split {split:?}");
}
};
expect("'hello world'", "'Hello World'");
expect("say 'hi'", "Say 'Hi'");
expect("don't", "Don't");
expect("\u{2019}hello", "\u{2019}Hello");
}
#[test]
fn lists_hang_and_number() {
if !have_fonts() {
@@ -232,7 +257,9 @@ fn truncate_max_lines_with_ellipsis() {
assert_eq!(l.lines.len(), 2);
let last = l.glyphs.last().unwrap();
assert_eq!(last.gid, face.glyph_for('…'));
// max_lines is ignored unless truncating.
// Auto height truncates at max_lines too; a fixed box shows every line.
t.auto_resize = figcraft_doc::TextAutoResize::Height;
assert_eq!(layout(&t, Some(300.0), None).lines.len(), 2);
t.auto_resize = figcraft_doc::TextAutoResize::None;
assert_eq!(layout(&t, Some(300.0), Some(1000.0)).lines.len(), 3);
}
+4 -1
View File
@@ -245,7 +245,7 @@ status = "draft — needs native review"
"Changes on main" = "প্রধান (main)-এ পরিবর্তন"
"Changes on the branch" = "ব্রাঞ্চে পরিবর্তন"
"Changes unknown" = "পরিবর্তন অজ্ঞাত"
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "এই কম্পোনেন্টটিকে কোডের সাথে যুক্ত করতে উপরে React, Leptos, SwiftUI, Compose বা ArkUI বেছে নিন।"
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "এই কম্পোনেন্টটিকে কোডের সাথে যুক্ত করতে উপরে React, Leptos, Vue, SwiftUI, Compose বা ArkUI বেছে নিন।"
"Choose another profile to change it." = "পরিবর্তন করতে অন্য একটি প্রোফাইল বেছে নিন।"
"Choose collection" = "সংগ্রহ (collection) বেছে নিন"
"Choose image" = "ছবি বেছে নিন"
@@ -1098,6 +1098,7 @@ status = "draft — needs native review"
"Reduce motion" = "মোশন কমান"
"Refraction" = "প্রতিসরণ"
"Regular expression" = "রেগুলার এক্সপ্রেশন"
"Relative to" = "এর সাপেক্ষে"
"Remove" = "সরান"
"Remove all" = "সব সরান"
"Remove annotation" = "অ্যানোটেশন সরান"
@@ -1467,6 +1468,7 @@ status = "draft — needs native review"
"Triangle" = "ত্রিভুজ"
"Triangle arrow" = "ত্রিভুজ তীর"
"Trigger" = "ট্রিগার"
"Trigger layer" = "ট্রিগার লেয়ার"
"True" = "সত্য (True)"
"Truncate text" = "টেক্সট ট্রাঙ্কেট করুন"
"Truncate with an ellipsis" = "উপবৃত্ত (ellipsis) দিয়ে ট্রাঙ্কেট করুন"
@@ -1567,6 +1569,7 @@ status = "draft — needs native review"
"While pressing" = "চেপে ধরে রাখার সময়"
"White" = "সাদা"
"Who runs it, its terms and how long it keeps files" = "এটি কে পরিচালনা করে, এর শর্তাবলী এবং এটি কতদিন ফাইল রাখে"
"Whole screen" = "সম্পূর্ণ স্ক্রিন"
"Why it changed" = "কেন এটি পরিবর্তিত হয়েছে"
"Widget" = "উইজেট"
"Widgets" = "উইজেটসমূহ"
+4 -1
View File
@@ -235,7 +235,7 @@ status = "draft — needs native review"
"Changes on main" = "Změny v hlavním souboru"
"Changes on the branch" = "Změny ve větvi"
"Changes unknown" = "Změny nejsou známy"
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Pro propojení této komponenty s jejím kódem vyberte nahoře React, Leptos, SwiftUI, Compose nebo ArkUI."
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Pro propojení této komponenty s jejím kódem vyberte nahoře React, Leptos, Vue, SwiftUI, Compose nebo ArkUI."
"Choose another profile to change it." = "Pro změnu vyberte jiný profil."
"Choose collection" = "Vybrat kolekci"
"Choose image" = "Vybrat obrázek"
@@ -1077,6 +1077,7 @@ status = "draft — needs native review"
"Reduce motion" = "Omezit pohyb"
"Refraction" = "Lom světla"
"Regular expression" = "Regulární výraz"
"Relative to" = "Vzhledem k"
"Remove" = "Odebrat"
"Remove all" = "Odebrat vše"
"Remove annotation" = "Odebrat anotaci"
@@ -1438,6 +1439,7 @@ status = "draft — needs native review"
"Triangle" = "Trojúhelník"
"Triangle arrow" = "Trojúhelníková šipka"
"Trigger" = "Spouštěč"
"Trigger layer" = "Spouštěcí vrstva"
"True" = "Pravda"
"Truncate text" = "Zkrátit text"
"Truncate with an ellipsis" = "Zkrátit výpustkou"
@@ -1534,6 +1536,7 @@ status = "draft — needs native review"
"While pressing" = "Při stisku"
"White" = "Bílá"
"Who runs it, its terms and how long it keeps files" = "Kdo ho provozuje, jeho podmínky a doba uchovávání souborů"
"Whole screen" = "Celá obrazovka"
"Why it changed" = "Důvod změny"
"Widgets" = "Widgety"
"Width" = "Šířka"
+4 -1
View File
@@ -230,7 +230,7 @@ status = "draft — needs native review"
"Changes on main" = "Änderungen in der Hauptdatei"
"Changes on the branch" = "Änderungen im Branch"
"Changes unknown" = "Änderungen unbekannt"
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Wählen Sie oben React, Leptos, SwiftUI, Compose oder ArkUI, um diese Komponente mit ihrem Code zu verbinden."
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Wählen Sie oben React, Leptos, Vue, SwiftUI, Compose oder ArkUI, um diese Komponente mit ihrem Code zu verbinden."
"Choose another profile to change it." = "Wählen Sie ein anderes Profil, um es zu ändern."
"Choose collection" = "Sammlung wählen"
"Choose image" = "Bild wählen"
@@ -1046,6 +1046,7 @@ status = "draft — needs native review"
"Reduce motion" = "Bewegung reduzieren"
"Refraction" = "Brechung"
"Regular expression" = "Regulärer Ausdruck"
"Relative to" = "Relativ zu"
"Remove" = "Entfernen"
"Remove all" = "Alle entfernen"
"Remove annotation" = "Anmerkung entfernen"
@@ -1400,6 +1401,7 @@ status = "draft — needs native review"
"Triangle" = "Dreieck"
"Triangle arrow" = "Dreieckspfeil"
"Trigger" = "Auslöser"
"Trigger layer" = "Auslösende Ebene"
"True" = "Wahr"
"Truncate text" = "Text kürzen"
"Truncate with an ellipsis" = "Mit Auslassungspunkten kürzen"
@@ -1496,6 +1498,7 @@ status = "draft — needs native review"
"While pressing" = "Beim Drücken"
"White" = "Weiß"
"Who runs it, its terms and how long it keeps files" = "Wer ihn betreibt, seine Bedingungen und wie lange er Dateien aufbewahrt"
"Whole screen" = "Ganzer Bildschirm"
"Why it changed" = "Warum es sich geändert hat"
"Widgets" = "Widgets"
"Width" = "Breite"
+12 -1
View File
@@ -109,6 +109,7 @@ name = "English"
"An invite link that opens FigCraft and joins" = "An invite link that opens FigCraft and joins"
"Anchor" = "Anchor"
"Android" = "Android"
"Android Vector Drawable" = "Android Vector Drawable"
"Angular" = "Angular"
"Animation" = "Animation"
"Annotations" = "Annotations"
@@ -166,6 +167,7 @@ name = "English"
"Basics" = "Basics"
"Before" = "Before"
"Bend" = "Bend"
"Best effort" = "Best effort"
"Between the selected layers" = "Between the selected layers"
"Bevel" = "Bevel"
"Binary assets added" = "Binary assets added"
@@ -224,10 +226,12 @@ name = "English"
"Code or invite link" = "Code or invite link"
"Color profile: {name}" = "Color profile: {name}"
"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Colour and text styles, Light and Dark variables, buttons, inputs and cards"
"Compatibility" = "Compatibility"
"Connector" = "Connector"
"Convert to frame" = "Convert to frame"
"Convert to section" = "Convert to section"
"CSS custom properties…" = "CSS custom properties…"
"Dart constants for Flutter…" = "Dart constants for Flutter…"
"Can edit" = "Can edit"
"Can't read this library: {e}" = "Can't read this library: {e}"
"Cancel" = "Cancel"
@@ -248,7 +252,7 @@ name = "English"
"Changes on main" = "Changes on main"
"Changes on the branch" = "Changes on the branch"
"Changes unknown" = "Changes unknown"
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code."
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code."
"Choose another profile to change it." = "Choose another profile to change it."
"Choose collection" = "Choose collection"
"Choose image" = "Choose image"
@@ -490,6 +494,7 @@ name = "English"
"Essential" = "Essential"
"Every conflict has a choice." = "Every conflict has a choice."
"Every top-level frame already starts a flow" = "Every top-level frame already starts a flow"
"Exact only" = "Exact only"
"Exact opacity (4 then 5: 45%)" = "Exact opacity (4 then 5: 45%)"
"Exclude" = "Exclude"
"Excluded" = "Excluded"
@@ -508,8 +513,10 @@ name = "English"
"Export {n} layers" = { one = "Export {n} layer", other = "Export {n} layers" }
"Exported {file}" = "Exported {file}"
"Exported {name}" = "Exported {name}"
"Exported {name}; {n} kinds of content were changed or left out" = { one = "Exported {name}; {n} kind of content was changed or left out", other = "Exported {name}; {n} kinds of content were changed or left out" }
"Exported {name}; {n} kinds of content were left out" = { one = "Exported {name}; {n} kind of content was left out", other = "Exported {name}; {n} kinds of content were left out" }
"Exported {n} files" = { one = "Exported {n} file", other = "Exported {n} files" }
"Exported {n} files; some content was changed or left out" = { one = "Exported {n} file; some content was changed or left out", other = "Exported {n} files; some content was changed or left out" }
"Export…" = "Export…"
"Expose properties to instances" = "Expose properties to instances"
"Exposure" = "Exposure"
@@ -653,6 +660,7 @@ name = "English"
"If {condition}" = "If {condition}"
"Ignore auto layout (absolute position)" = "Ignore auto layout (absolute position)"
"Ignore overlapping layers" = "Ignore overlapping layers"
"Ignore unsupported" = "Ignore unsupported"
"Image" = "Image"
"Images" = "Images"
"Import" = "Import"
@@ -1122,6 +1130,7 @@ name = "English"
"Reduce motion" = "Reduce motion"
"Refraction" = "Refraction"
"Regular expression" = "Regular expression"
"Relative to" = "Relative to"
"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Relative to your system's display scaling (100% is the system's own size). Document zoom is separate."
"Remove" = "Remove"
"Remove all" = "Remove all"
@@ -1506,6 +1515,7 @@ name = "English"
"Triangle" = "Triangle"
"Triangle arrow" = "Triangle arrow"
"Trigger" = "Trigger"
"Trigger layer" = "Trigger layer"
"True" = "True"
"Truncate text" = "Truncate text"
"Truncate with an ellipsis" = "Truncate with an ellipsis"
@@ -1610,6 +1620,7 @@ name = "English"
"While pressing" = "While pressing"
"White" = "White"
"Who runs it, its terms and how long it keeps files" = "Who runs it, its terms and how long it keeps files"
"Whole screen" = "Whole screen"
"Why it changed" = "Why it changed"
"Widget" = "Widget"
"Widgets" = "Widgets"
+13 -2
View File
@@ -8,26 +8,32 @@ name = "Español"
status = "draft — needs native review"
[ui]
"Add a minute" = "Añadir un minuto"
"Add column" = "Añadir columna"
"Add measurement" = "Añadir medición"
"Add row" = "Añadir fila"
"Add a minute" = "Añadir un minuto"
"All objects" = "Todos los objetos"
"Along the bottom" = "A lo largo del borde inferior"
"Along the left" = "A lo largo del borde izquierdo"
"Along the right" = "A lo largo del borde derecho"
"Along the top" = "A lo largo del borde superior"
"Android Vector Drawable" = "Android Vector Drawable"
"Arrow at the end" = "Flecha al final"
"Best effort" = "Mejor aproximación"
"Between the selected layers" = "Entre las capas seleccionadas"
"Brainstorm" = "Lluvia de ideas"
"Check" = "Visto"
"Chevron" = "Cheurón"
"Clear results" = "Borrar resultados"
"Click objects to vote; click your badge to take a vote back" = "Haz clic en los objetos para votar; haz clic en tu insignia para retirar un voto"
"Compatibility" = "Compatibilidad"
"Compatibility notes" = "Notas de compatibilidad"
"Connector" = "Conector"
"Couldn't download the variables" = "No se pudieron descargar las variables"
"Couldn't download {name}" = "No se pudo descargar {name}"
"Relative to" = "Relativa a"
"Trigger layer" = "Capa que la abre"
"Whole screen" = "Pantalla completa"
"% of all commits" = "% del total de commits"
"(no longer published)" = "(ya no publicado)"
@@ -227,6 +233,7 @@ status = "draft — needs native review"
"Color profile: {name}" = "Perfil de color: {name}"
"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Estilos de color y texto, variables Claro y Oscuro, botones, campos y tarjetas"
"CSS custom properties…" = "Propiedades personalizadas CSS…"
"Dart constants for Flutter…" = "Constantes de Dart para Flutter…"
"Can edit" = "Puede editar"
"Can't read this library: {e}" = "No se puede leer esta biblioteca: {e}"
"Cancel" = "Cancelar"
@@ -247,7 +254,7 @@ status = "draft — needs native review"
"Changes on main" = "Cambios en la principal"
"Changes on the branch" = "Cambios en la rama"
"Changes unknown" = "Cambios desconocidos"
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Elige React, Leptos, SwiftUI, Compose o ArkUI arriba para conectar este componente con su código."
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Elige React, Leptos, Vue, SwiftUI, Compose o ArkUI arriba para conectar este componente con su código."
"Choose another profile to change it." = "Elige otro perfil para cambiarlo."
"Choose collection" = "Elegir colección"
"Choose image" = "Elegir imagen"
@@ -486,6 +493,7 @@ status = "draft — needs native review"
"Essential" = "Esenciales"
"Every conflict has a choice." = "Todos los conflictos tienen una elección."
"Every top-level frame already starts a flow" = "Todos los marcos de nivel superior ya inician un flujo"
"Exact only" = "Solo exacto"
"Exact opacity (4 then 5: 45%)" = "Opacidad exacta (4 y luego 5: 45 %)"
"Exclude" = "Excluir"
"Excluded" = "Excluidos"
@@ -504,8 +512,10 @@ status = "draft — needs native review"
"Export {n} layers" = { one = "Exportar {n} capa", other = "Exportar {n} capas" }
"Exported {file}" = "Exportado: {file}"
"Exported {name}" = "{name} exportado"
"Exported {name}; {n} kinds of content were changed or left out" = { one = "{name} exportado; se cambió u omitió {n} tipo de contenido", other = "{name} exportado; se cambiaron u omitieron {n} tipos de contenido" }
"Exported {name}; {n} kinds of content were left out" = { one = "{name} exportado; se omitió {n} tipo de contenido", other = "{name} exportado; se omitieron {n} tipos de contenido" }
"Exported {n} files" = { one = "{n} archivo exportado", other = "{n} archivos exportados" }
"Exported {n} files; some content was changed or left out" = { one = "{n} archivo exportado; se cambió u omitió parte del contenido", other = "{n} archivos exportados; se cambió u omitió parte del contenido" }
"Export…" = "Exportar…"
"Expose properties to instances" = "Exponer propiedades a las instancias"
"Exposure" = "Exposición"
@@ -648,6 +658,7 @@ status = "draft — needs native review"
"If {condition}" = "Si {condition}"
"Ignore auto layout (absolute position)" = "Ignorar diseño automático (posición absoluta)"
"Ignore overlapping layers" = "Ignorar capas superpuestas"
"Ignore unsupported" = "Ignorar lo no admitido"
"Image" = "Imagen"
"Images" = "Imágenes"
"Import" = "Importar"
+4 -1
View File
@@ -236,7 +236,7 @@ status = "draft — needs native review"
"Check" = "Valintamerkki"
"Checking the fonts installed on this computer…" = "Tarkistetaan tämän tietokoneen asennettuja fontteja…"
"Checking the server…" = "Tarkistetaan palvelinta…"
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Valitse yllä React, Leptos, SwiftUI, Compose tai ArkUI yhdistääksesi tämän komponentin sen koodiin."
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Valitse yllä React, Leptos, Vue, SwiftUI, Compose tai ArkUI yhdistääksesi tämän komponentin sen koodiin."
"Chevron" = "Kulmanuoli"
"Choose another profile to change it." = "Vaihda valitsemalla toinen profiili."
"Choose collection" = "Valitse kokoelma"
@@ -1102,6 +1102,7 @@ status = "draft — needs native review"
"Reduce motion" = "Vähennä liikettä"
"Refraction" = "Taittuminen"
"Regular expression" = "Säännöllinen lauseke"
"Relative to" = "Suhteessa"
"Remove" = "Poista"
"Remove all" = "Poista kaikki"
"Remove annotation" = "Poista huomautus"
@@ -1472,6 +1473,7 @@ status = "draft — needs native review"
"Triangle" = "Kolmio"
"Triangle arrow" = "Kolmionuoli"
"Trigger" = "Laukaisin"
"Trigger layer" = "Laukaiseva taso"
"True" = "Tosi"
"Truncate text" = "Lyhennä tekstiä"
"Truncate with an ellipsis" = "Lyhennä kolmella pisteellä"
@@ -1572,6 +1574,7 @@ status = "draft — needs native review"
"While pressing" = "Painettaessa"
"White" = "Valkoinen"
"Who runs it, its terms and how long it keeps files" = "Ylläpitäjä, käyttöehdot ja tiedostojen säilytysaika"
"Whole screen" = "Koko näyttö"
"Why it changed" = "Muutoksen syy"
"Widget" = "Pienoisohjelma"
"Widgets" = "Pienoisohjelmat"
+4 -1
View File
@@ -239,7 +239,7 @@ status = "draft — needs native review"
"Changes on main" = "Modifications dans le fichier principal"
"Changes on the branch" = "Modifications dans la branche"
"Changes unknown" = "Modifications inconnues"
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Choisissez React, Leptos, SwiftUI, Compose ou ArkUI ci-dessus pour relier ce composant à son code."
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Choisissez React, Leptos, Vue, SwiftUI, Compose ou ArkUI ci-dessus pour relier ce composant à son code."
"Choose another profile to change it." = "Choisissez un autre profil pour le changer."
"Choose collection" = "Choisir une collection"
"Choose image" = "Choisir une image"
@@ -1101,6 +1101,7 @@ status = "draft — needs native review"
"Reduce motion" = "Réduire les animations"
"Refraction" = "Réfraction"
"Regular expression" = "Expression régulière"
"Relative to" = "Par rapport à"
"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Relative à la mise à l’échelle d’affichage de votre système (100 % est la taille du système). Le zoom du document est distinct."
"Remove" = "Retirer"
"Remove all" = "Tout retirer"
@@ -1473,6 +1474,7 @@ status = "draft — needs native review"
"Triangle" = "Triangle"
"Triangle arrow" = "Flèche triangulaire"
"Trigger" = "Déclencheur"
"Trigger layer" = "Calque déclencheur"
"True" = "Vrai"
"Truncate text" = "Tronquer le texte"
"Truncate with an ellipsis" = "Tronquer avec des points de suspension"
@@ -1575,6 +1577,7 @@ status = "draft — needs native review"
"While pressing" = "Pendant l’appui"
"White" = "Blanc"
"Who runs it, its terms and how long it keeps files" = "Qui le gère, ses conditions et combien de temps il conserve les fichiers"
"Whole screen" = "Écran entier"
"Why it changed" = "Pourquoi cela a changé"
"Widget" = "Widget"
"Widgets" = "Widgets"
+4 -1
View File
@@ -8,6 +8,9 @@ name = "Bahasa Indonesia"
status = "draft — needs native review"
[ui]
"Relative to" = "Relatif terhadap"
"Trigger layer" = "Layer pemicu"
"Whole screen" = "Seluruh layar"
"% of all commits" = "% dari semua commit"
"(no longer published)" = "(tidak lagi dipublikasikan)"
@@ -239,7 +242,7 @@ status = "draft — needs native review"
"Changes on main" = "Perubahan di main"
"Changes on the branch" = "Perubahan di branch"
"Changes unknown" = "Perubahan tidak diketahui"
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Pilih React, Leptos, SwiftUI, Compose, atau ArkUI di atas untuk menghubungkan component ini ke kodenya."
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Pilih React, Leptos, Vue, SwiftUI, Compose, atau ArkUI di atas untuk menghubungkan component ini ke kodenya."
"Choose another profile to change it." = "Pilih profil lain untuk mengubahnya."
"Choose collection" = "Pilih koleksi"
"Choose image" = "Pilih gambar"
+1 -1
View File
@@ -228,7 +228,7 @@ status = "draft — needs native review"
"Changes on main" = "Modifiche su main"
"Changes on the branch" = "Modifiche sul ramo"
"Changes unknown" = "Modifiche sconosciute"
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Scegli React, Leptos, SwiftUI, Compose o ArkUI qui sopra per collegare questo componente al suo codice."
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Scegli React, Leptos, Vue, SwiftUI, Compose o ArkUI qui sopra per collegare questo componente al suo codice."
"Choose another profile to change it." = "Scegli un altro profilo per cambiarlo."
"Choose collection" = "Scegli collezione"
"Choose image" = "Scegli immagine"
+4 -1
View File
@@ -240,7 +240,7 @@ status = "draft — needs native review"
"Changes on main" = "メインの変更"
"Changes on the branch" = "ブランチの変更"
"Changes unknown" = "変更内容は不明"
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "上で React、Leptos、SwiftUI、Compose、ArkUI のいずれかを選び、このコンポーネントをコードに接続。"
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "上で React、Leptos、Vue、SwiftUI、Compose、ArkUI のいずれかを選び、このコンポーネントをコードに接続。"
"Choose another profile to change it." = "変更するには別のプロファイルを選択。"
"Choose collection" = "コレクションを選択"
"Choose image" = "画像を選択"
@@ -1089,6 +1089,7 @@ status = "draft — needs native review"
"Reduce motion" = "動きを減らす"
"Refraction" = "屈折"
"Regular expression" = "正規表現"
"Relative to" = "基準"
"Remove" = "削除"
"Remove all" = "すべて削除"
"Remove annotation" = "注釈を削除"
@@ -1456,6 +1457,7 @@ status = "draft — needs native review"
"Triangle" = "三角形"
"Triangle arrow" = "三角矢印"
"Trigger" = "トリガー"
"Trigger layer" = "トリガーレイヤー"
"True" = "true"
"Truncate text" = "テキストを切り詰める"
"Truncate with an ellipsis" = "省略記号で切り詰める"
@@ -1557,6 +1559,7 @@ status = "draft — needs native review"
"While pressing" = "押している間"
"White" = "白"
"Who runs it, its terms and how long it keeps files" = "運営者、利用規約、ファイルの保管期間"
"Whole screen" = "画面全体"
"Why it changed" = "変更理由"
"Widgets" = "ウィジェット"
"Width" = "幅"
+4 -1
View File
@@ -247,7 +247,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Changes on main" = "ცვლილებები მთავარში"
"Changes on the branch" = "ცვლილებები განშტოებაში"
"Changes unknown" = "ცვლილებები უცნობია"
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "ამ კომპონენტის კოდთან დასაკავშირებლად ზემოთ აირჩიეთ React, Leptos, SwiftUI, Compose ან ArkUI."
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "ამ კომპონენტის კოდთან დასაკავშირებლად ზემოთ აირჩიეთ React, Leptos, Vue, SwiftUI, Compose ან ArkUI."
"Choose another profile to change it." = "მის შესაცვლელად აირჩიეთ სხვა პროფილი."
"Choose collection" = "კოლექციის არჩევა"
"Choose image" = "გამოსახულების არჩევა"
@@ -1100,6 +1100,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Reduce motion" = "მოძრაობის შემცირება"
"Refraction" = "გარდატეხა"
"Regular expression" = "რეგულარული გამოსახულება"
"Relative to" = "მიმართ"
"Remove" = "წაშლა"
"Remove all" = "ყველას წაშლა"
"Remove annotation" = "ანოტაციის წაშლა"
@@ -1469,6 +1470,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Triangle" = "სამკუთხედი"
"Triangle arrow" = "სამკუთხა ისარი"
"Trigger" = "გამშვები"
"Trigger layer" = "გამშვები შრე"
"True" = "ჭეშმარიტი"
"Truncate text" = "ტექსტის შეკვეცა"
"Truncate with an ellipsis" = "შეკვეცა მრავალწერტილით"
@@ -1569,6 +1571,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"While pressing" = "დაჭერისას"
"White" = "თეთრი"
"Who runs it, its terms and how long it keeps files" = "ვინ მართავს, მისი პირობები და რამდენ ხანს ინახავს ფაილებს"
"Whole screen" = "მთელი ეკრანი"
"Why it changed" = "რატომ შეიცვალა"
"Widget" = "ვიჯეტი"
"Widgets" = "ვიჯეტები"
+4 -1
View File
@@ -230,7 +230,7 @@ status = "draft — needs native review"
"Changes on main" = "메인의 변경 사항"
"Changes on the branch" = "브랜치의 변경 사항"
"Changes unknown" = "변경 내용 알 수 없음"
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "위에서 React, Leptos, SwiftUI, Compose 또는 ArkUI를 선택해 이 컴포넌트를 코드에 연결하세요."
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "위에서 React, Leptos, Vue, SwiftUI, Compose 또는 ArkUI를 선택해 이 컴포넌트를 코드에 연결하세요."
"Choose another profile to change it." = "변경하려면 다른 프로필을 선택하세요."
"Choose collection" = "컬렉션 선택"
"Choose image" = "이미지 선택"
@@ -1040,6 +1040,7 @@ status = "draft — needs native review"
"Reduce motion" = "동작 줄이기"
"Refraction" = "굴절"
"Regular expression" = "정규식"
"Relative to" = "기준"
"Remove" = "제거"
"Remove all" = "모두 제거"
"Remove annotation" = "주석 제거"
@@ -1391,6 +1392,7 @@ status = "draft — needs native review"
"Triangle" = "삼각형"
"Triangle arrow" = "삼각 화살표"
"Trigger" = "트리거"
"Trigger layer" = "트리거 레이어"
"True" = "참"
"Truncate text" = "텍스트 자르기"
"Truncate with an ellipsis" = "줄임표로 자르기"
@@ -1487,6 +1489,7 @@ status = "draft — needs native review"
"While pressing" = "누르는 동안"
"White" = "흰색"
"Who runs it, its terms and how long it keeps files" = "운영자, 약관, 파일 보관 기간"
"Whole screen" = "전체 화면"
"Why it changed" = "변경 이유"
"Widgets" = "위젯"
"Width" = "너비"
+4 -1
View File
@@ -236,7 +236,7 @@ status = "draft — needs native review"
"Check" = "Vinkje"
"Checking the fonts installed on this computer…" = "De lettertypen op deze computer worden gecontroleerd…"
"Checking the server…" = "Server controleren…"
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Kies hierboven React, Leptos, SwiftUI, Compose of ArkUI om dit component aan zijn code te koppelen."
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Kies hierboven React, Leptos, Vue, SwiftUI, Compose of ArkUI om dit component aan zijn code te koppelen."
"Chevron" = "Punthaak"
"Choose another profile to change it." = "Kies een ander profiel om dit te wijzigen."
"Choose collection" = "Kies een verzameling"
@@ -1102,6 +1102,7 @@ status = "draft — needs native review"
"Reduce motion" = "Beweging verminderen"
"Refraction" = "Breking"
"Regular expression" = "Reguliere expressie"
"Relative to" = "Ten opzichte van"
"Remove" = "Verwijderen"
"Remove all" = "Alles verwijderen"
"Remove annotation" = "Annotatie verwijderen"
@@ -1472,6 +1473,7 @@ status = "draft — needs native review"
"Triangle" = "Driehoek"
"Triangle arrow" = "Driehoekspijl"
"Trigger" = "Trigger"
"Trigger layer" = "Activerende laag"
"True" = "Waar"
"Truncate text" = "Tekst afkappen"
"Truncate with an ellipsis" = "Afkappen met een beletselteken"
@@ -1572,6 +1574,7 @@ status = "draft — needs native review"
"While pressing" = "Tijdens het indrukken"
"White" = "Wit"
"Who runs it, its terms and how long it keeps files" = "Wie het beheert, de voorwaarden en hoelang het bestanden bewaart"
"Whole screen" = "Hele scherm"
"Why it changed" = "Waarom het is veranderd"
"Widget" = "Widget"
"Widgets" = "Widgets"
+4 -1
View File
@@ -245,7 +245,7 @@ name = "Polski"
"Changes on main" = "Zmiany w gałęzi głównej"
"Changes on the branch" = "Zmiany w gałęzi"
"Changes unknown" = "Nieznane zmiany"
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Wybierz React, Leptos, SwiftUI, Compose lub ArkUI powyżej, aby połączyć ten komponent z jego kodem."
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Wybierz React, Leptos, Vue, SwiftUI, Compose lub ArkUI powyżej, aby połączyć ten komponent z jego kodem."
"Choose another profile to change it." = "Wybierz inny profil, aby go zmienić."
"Choose collection" = "Wybierz kolekcję"
"Choose image" = "Wybierz obraz"
@@ -1104,6 +1104,7 @@ name = "Polski"
"Reduce motion" = "Ogranicz ruch"
"Refraction" = "Załamanie światła (refrakcja)"
"Regular expression" = "Wyrażenie regularne"
"Relative to" = "Względem"
"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Względem skalowania ekranu Twojego systemu (100% to domyślny rozmiar systemu). Powiększenie dokumentu jest niezależne."
"Remove" = "Usuń"
"Remove all" = "Usuń wszystko"
@@ -1481,6 +1482,7 @@ name = "Polski"
"Triangle" = "Trójkąt"
"Triangle arrow" = "Strzałka trójkątna"
"Trigger" = "Wyzwalacz"
"Trigger layer" = "Warstwa wyzwalająca"
"True" = "Prawda"
"Truncate text" = "Utnij tekst"
"Truncate with an ellipsis" = "Utnij tekst z wielokropkiem (…)"
@@ -1582,6 +1584,7 @@ name = "Polski"
"While pressing" = "Podczas naciskania"
"White" = "Biały"
"Who runs it, its terms and how long it keeps files" = "Kto zarządza serwerem, jego regulamin i czas przechowywania plików"
"Whole screen" = "Cały ekran"
"Why it changed" = "Dlaczego to się zmieniło"
"Widget" = "Widżet"
"Widgets" = "Widżety"
+4 -1
View File
@@ -228,7 +228,7 @@ status = "draft — needs native review"
"Changes on main" = "Alterações no principal"
"Changes on the branch" = "Alterações na branch"
"Changes unknown" = "Alterações desconhecidas"
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Escolha React, Leptos, SwiftUI, Compose ou ArkUI acima para conectar este componente ao código dele."
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Escolha React, Leptos, Vue, SwiftUI, Compose ou ArkUI acima para conectar este componente ao código dele."
"Choose another profile to change it." = "Escolha outro perfil para alterá-lo."
"Choose collection" = "Escolher coleção"
"Choose image" = "Escolher imagem"
@@ -1039,6 +1039,7 @@ status = "draft — needs native review"
"Reduce motion" = "Reduzir movimento"
"Refraction" = "Refração"
"Regular expression" = "Expressão regular"
"Relative to" = "Relativa a"
"Remove" = "Remover"
"Remove all" = "Remover tudo"
"Remove annotation" = "Remover anotação"
@@ -1389,6 +1390,7 @@ status = "draft — needs native review"
"Triangle" = "Triângulo"
"Triangle arrow" = "Seta triangular"
"Trigger" = "Gatilho"
"Trigger layer" = "Camada que a abre"
"True" = "Verdadeiro"
"Truncate text" = "Truncar texto"
"Truncate with an ellipsis" = "Truncar com reticências"
@@ -1485,6 +1487,7 @@ status = "draft — needs native review"
"While pressing" = "Enquanto pressiona"
"White" = "Branco"
"Who runs it, its terms and how long it keeps files" = "Quem o mantém, os termos dele e por quanto tempo guarda os arquivos"
"Whole screen" = "Tela inteira"
"Why it changed" = "Por que mudou"
"Widgets" = "Widgets"
"Width" = "Largura"
+4 -1
View File
@@ -242,7 +242,7 @@ name = "Русский"
"Changes on main" = "Изменения в основной ветке"
"Changes on the branch" = "Изменения в ветке"
"Changes unknown" = "Изменения неизвестны"
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Выберите выше React, Leptos, SwiftUI, Compose или ArkUI, чтобы связать компонент с его кодом."
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Выберите выше React, Leptos, Vue, SwiftUI, Compose или ArkUI, чтобы связать компонент с его кодом."
"Choose another profile to change it." = "Для смены выберите другой профиль."
"Choose collection" = "Выбрать коллекцию"
"Choose image" = "Выбрать изображение"
@@ -1104,6 +1104,7 @@ name = "Русский"
"Reduce motion" = "Уменьшить движение"
"Refraction" = "Преломление"
"Regular expression" = "Регулярное выражение"
"Relative to" = "Относительно"
"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Относительно масштаба экрана в системе (100% — собственный размер системы). Масштаб документа задаётся отдельно."
"Remove" = "Удалить"
"Remove all" = "Удалить всё"
@@ -1481,6 +1482,7 @@ name = "Русский"
"Triangle" = "Треугольник"
"Triangle arrow" = "Треугольная стрелка"
"Trigger" = "Триггер"
"Trigger layer" = "Слой-триггер"
"True" = "Истина"
"Truncate text" = "Обрезать текст"
"Truncate with an ellipsis" = "Обрезать с многоточием"
@@ -1582,6 +1584,7 @@ name = "Русский"
"While pressing" = "При удержании"
"White" = "Белый"
"Who runs it, its terms and how long it keeps files" = "Оператор, условия и срок хранения файлов"
"Whole screen" = "Весь экран"
"Why it changed" = "Причина изменения"
"Widget" = "Виджет"
"Widgets" = "Виджеты"
+4 -1
View File
@@ -7,6 +7,9 @@
name = "Svenska"
[ui]
"Relative to" = "Relativt till"
"Trigger layer" = "Utlösande lager"
"Whole screen" = "Hela skärmen"
"% of all commits" = "% av alla commits"
"(no longer published)" = "(publiceras inte längre)"
@@ -242,7 +245,7 @@ name = "Svenska"
"Changes on main" = "Ändringar i main"
"Changes on the branch" = "Ändringar i grenen"
"Changes unknown" = "Okända ändringar"
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Välj React, Leptos, SwiftUI, Compose eller ArkUI ovan för att koppla den här komponenten till dess kod."
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Välj React, Leptos, Vue, SwiftUI, Compose eller ArkUI ovan för att koppla den här komponenten till dess kod."
"Choose another profile to change it." = "Välj en annan profil för att ändra den."
"Choose collection" = "Välj samling"
"Choose image" = "Välj bild"
+1 -1
View File
@@ -243,7 +243,7 @@ status = "draft — needs native review"
"Changes on main" = "การเปลี่ยนแปลงบนเมน"
"Changes on the branch" = "การเปลี่ยนแปลงบนแบรนช์"
"Changes unknown" = "ไม่ทราบการเปลี่ยนแปลง"
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "เลือก React, Leptos, SwiftUI, Compose หรือ ArkUI ด้านบนเพื่อเชื่อมคอมโพเนนต์นี้กับโค้ด"
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "เลือก React, Leptos, Vue, SwiftUI, Compose หรือ ArkUI ด้านบนเพื่อเชื่อมคอมโพเนนต์นี้กับโค้ด"
"Choose another profile to change it." = "เลือกโปรไฟล์อื่นเพื่อเปลี่ยน"
"Choose collection" = "เลือกคอลเลกชัน"
"Choose image" = "เลือกรูปภาพ"
+4 -1
View File
@@ -239,7 +239,7 @@ name = "Türkçe"
"Changes on main" = "Ana dosyadaki değişiklikler"
"Changes on the branch" = "Daldaki değişiklikler"
"Changes unknown" = "Değişiklikler bilinmiyor"
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Bu bileşeni koduna bağlamak için yukarıdan React, Leptos, SwiftUI, Compose veya ArkUI seçin."
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Bu bileşeni koduna bağlamak için yukarıdan React, Leptos, Vue, SwiftUI, Compose veya ArkUI seçin."
"Choose another profile to change it." = "Değiştirmek için başka bir profil seçin."
"Choose collection" = "Koleksiyon seç"
"Choose image" = "Görüntü seç"
@@ -1081,6 +1081,7 @@ name = "Türkçe"
"Reduce motion" = "Hareketi azalt"
"Refraction" = "Kırılma"
"Regular expression" = "Düzenli ifade"
"Relative to" = "Göre"
"Remove" = "Kaldır"
"Remove all" = "Tümünü kaldır"
"Remove annotation" = "Ek açıklamayı kaldır"
@@ -1442,6 +1443,7 @@ name = "Türkçe"
"Triangle" = "Üçgen"
"Triangle arrow" = "Üçgen ok"
"Trigger" = "Tetikleyici"
"Trigger layer" = "Tetikleyici katman"
"True" = "Doğru"
"Truncate text" = "Metni kısalt"
"Truncate with an ellipsis" = "Üç noktayla kısalt"
@@ -1538,6 +1540,7 @@ name = "Türkçe"
"While pressing" = "Basılıyken"
"White" = "Beyaz"
"Who runs it, its terms and how long it keeps files" = "Kimin işlettiği, koşulları ve dosyaları ne kadar sakladığı"
"Whole screen" = "Tüm ekran"
"Why it changed" = "Neden değişti"
"Widgets" = "Widget'lar"
"Width" = "Genişlik"
+1 -1
View File
@@ -223,7 +223,7 @@ status = "draft — needs native review"
"Changes unknown" = "Không rõ thay đổi"
"Checking the fonts installed on this computer…" = "Đang kiểm tra các phông chữ cài đặt trên máy tính này…"
"Checking the server…" = "Đang kiểm tra máy chủ…"
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Hãy chọn React, Leptos, SwiftUI, Compose hoặc ArkUI ở trên để kết nối thành phần này với mã nguồn."
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "Hãy chọn React, Leptos, Vue, SwiftUI, Compose hoặc ArkUI ở trên để kết nối thành phần này với mã nguồn."
"Choose another profile to change it." = "Hãy chọn hồ sơ khác để thay đổi."
"Choose collection" = "Chọn bộ sưu tập"
"Choose image" = "Chọn hình ảnh"
+4 -1
View File
@@ -231,7 +231,7 @@ status = "draft — needs native review"
"Changes on main" = "主文件上的更改"
"Changes on the branch" = "分支上的更改"
"Changes unknown" = "更改未知"
"Choose React, Leptos, SwiftUI, Compose or ArkUI above to connect this component to its code." = "在上方选择 React、Leptos、SwiftUI、Compose 或 ArkUI,将此组件关联到其代码。"
"Choose React, Leptos, Vue, SwiftUI, Compose or ArkUI above to connect this component to its code." = "在上方选择 React、Leptos、Vue、SwiftUI、Compose 或 ArkUI,将此组件关联到其代码。"
"Choose another profile to change it." = "选择其他配置文件以更改。"
"Choose collection" = "选择集合"
"Choose image" = "选择图片"
@@ -1048,6 +1048,7 @@ status = "draft — needs native review"
"Reduce motion" = "减少动态效果"
"Refraction" = "折射"
"Regular expression" = "正则表达式"
"Relative to" = "相对于"
"Remove" = "移除"
"Remove all" = "全部移除"
"Remove annotation" = "移除标注"
@@ -1399,6 +1400,7 @@ status = "draft — needs native review"
"Triangle" = "三角形"
"Triangle arrow" = "三角箭头"
"Trigger" = "触发器"
"Trigger layer" = "触发图层"
"True" = "真"
"Truncate text" = "截断文本"
"Truncate with an ellipsis" = "以省略号截断"
@@ -1495,6 +1497,7 @@ status = "draft — needs native review"
"While pressing" = "按住时"
"White" = "白"
"Who runs it, its terms and how long it keeps files" = "谁在运营、其条款以及文件保留多久"
"Whole screen" = "整个屏幕"
"Why it changed" = "更改原因"
"Widgets" = "小组件"
"Width" = "宽度"
+12 -4
View File
@@ -23,6 +23,8 @@ pub struct CanvasState {
tex_of: Option<(u64, NodeId)>,
/// The texture is a coarse preview (see [`crate::render_worker`]).
pub tex_coarse: bool,
/// The request the texture answers ([`crate::render_worker::Done::seq`]).
tex_seq: u64,
submitted: Option<JobKey>,
/// The view of the last render asked for.
want_view: Option<figcraft_render::View>,
@@ -58,6 +60,7 @@ impl Default for CanvasState {
tex_view: None,
tex_of: None,
tex_coarse: false,
tex_seq: 0,
submitted: None,
want_view: None,
render_work: None,
@@ -246,6 +249,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
app.canvas.tex_view = Some(done.view);
app.canvas.tex_of = Some((done.uid, done.page));
app.canvas.tex_coarse = done.coarse;
app.canvas.tex_seq = done.seq;
}
let painter = ui.painter_at(rect);
painter.rect_filled(rect, CornerRadius::ZERO, bg);
@@ -343,14 +347,18 @@ fn describe_box([x0, y0, x1, y1]: [f64; 4]) -> String {
}
/// Whether a finished render goes on screen: an image of the file and page shown (`shown`), and
/// not a coarse preview over a refined image that already shows the whole view asked for at its
/// zoom (the same zoom elsewhere on the page is no such image).
/// not a coarse preview over a refined image of the same or a newer request, or one that already
/// shows the whole view asked for at its zoom (the same zoom elsewhere on the page is no such
/// image).
fn shows(c: &CanvasState, done: &Done, shown: Option<(u64, NodeId)>) -> bool {
if shown != Some((done.uid, done.page)) {
return false;
}
let refined_here = !c.tex_coarse
&& c.tex_of == shown
let refined = !c.tex_coarse && c.tex_of == shown;
if done.coarse && refined && done.seq <= c.tex_seq {
return false;
}
let refined_here = refined
&& c.tex_view
.as_ref()
.zip(c.want_view.as_ref())

Some files were not shown because too many files have changed in this diff Show More