mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 05:46:46 -06:00
Merge main into #827
This commit is contained in:
Generated
+25
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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) |
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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(¶ms)))
|
||||
.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(¶ms)}))
|
||||
})
|
||||
.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(¶ms))).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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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"[..]));
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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)?;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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('&', "&").replace('<', "<");
|
||||
match quote {
|
||||
Some('\'') => e.replace('\'', "'"),
|
||||
_ => e.replace('"', """),
|
||||
}
|
||||
}
|
||||
|
||||
/// Literal text written into an HTML attribute opened with `quote`.
|
||||
fn html_attr_text(s: &str, quote: Option<char>) -> String {
|
||||
let s = s.replace('&', "&").replace('<', "<");
|
||||
match quote {
|
||||
Some('\'') => s.replace('\'', "'"),
|
||||
_ => s.replace('"', """),
|
||||
}
|
||||
}
|
||||
|
||||
/// Literal text in Vue template content: braces would start an interpolation, so they are escaped.
|
||||
fn html_text(s: &str) -> String {
|
||||
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('{', "{").replace('}', "}")
|
||||
}
|
||||
|
||||
/// `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 {
|
||||
|
||||
@@ -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=\""Buy"\" :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=\""Hello"\" />\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=\""Hover"\" />\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(""")).unwrap_or_default();
|
||||
assert!(line.starts_with("<Card :title=\""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 "hi" Say "hi"\" />\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
@@ -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('&', "&").replace('<', "<").replace('>', ">").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('{', "{").replace('}', "}"),
|
||||
(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()) {
|
||||
|
||||
@@ -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('&', "&").replace('<', "<").replace('>', ">")
|
||||
}
|
||||
|
||||
/// A Vue binding expression inside a double-quoted attribute.
|
||||
fn html_attr_expr(s: &str) -> String {
|
||||
s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """)
|
||||
}
|
||||
|
||||
/// 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=\""Say \\"hi\\" $5\\nnow"\" />\n");
|
||||
let vue = code(&mut s, i, "vue");
|
||||
assert!(vue.starts_with("<Card :showDot=\"false\" :title=\""Say \\"hi\\" $5\\nnow"\" />\n"), "{vue}");
|
||||
// A plain attribute keeps HTML entities intact: a literal ampersand is written as `&`,
|
||||
// and a literal `&` as `&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 & Chips\" />\n");
|
||||
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");
|
||||
}
|
||||
|
||||
#[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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(());
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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 > 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}");
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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!([
|
||||
|
||||
@@ -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
@@ -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") {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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)),
|
||||
(
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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(¶, 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
@@ -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)];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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" = "উইজেটসমূহ"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" = "幅"
|
||||
|
||||
@@ -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" = "ვიჯეტები"
|
||||
|
||||
@@ -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" = "너비"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" = "Виджеты"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" = "เลือกรูปภาพ"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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" = "宽度"
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user