Compare commits
2 Commits
main
...
docs/commu
| Author | SHA1 | Date |
|---|---|---|
|
|
4f35bd0097 | |
|
|
4bb43ab11e |
|
|
@ -1667,7 +1667,6 @@ dependencies = [
|
|||
"figcraft-pathops",
|
||||
"figcraft-render",
|
||||
"figcraft-text",
|
||||
"flate2",
|
||||
"thiserror 2.0.21",
|
||||
"usvg",
|
||||
]
|
||||
|
|
@ -3925,9 +3924,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "ruzstd"
|
||||
version = "0.9.1"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5"
|
||||
checksum = "a252f5e20f038fe7b4ea53e073e65398d652c864cc162fc77c56c2f13717b888"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ 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"] }
|
||||
ruzstd = { version = "0.9", default-features = false, features = ["std"] }
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
crc32fast = "1"
|
||||
|
|
|
|||
|
|
@ -191,7 +191,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, 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>
|
||||
|
|
@ -251,8 +251,6 @@ Fonts for the UI and the Text tool come from [craft-fonts](https://github.com/st
|
|||
git clone https://github.com/storytold/craft-fonts ../craft-fonts
|
||||
```
|
||||
|
||||
If you clone it after a first build, run `cargo clean -p figcraft-text` once so the next build embeds the fonts.
|
||||
|
||||
**Start from a template.** The home screen's Templates tab has eight starter files (a mobile app, a landing page, a dashboard, a design system starter, a wireframe kit, social post sizes, presentation slides and an icon set starter), also under File ▸ New from template…. They are built in code from original, synthetic content, so scripts and agents can open them too: `file.newFromTemplate {template}` ([`docs/mcp.md`](docs/mcp.md#starter-templates)).
|
||||
|
||||
For the web build, install [trunk](https://trunkrs.dev/) and run `cargo xtask wasm`.
|
||||
|
|
|
|||
|
|
@ -235,21 +235,6 @@ fn a_file_cant_grow_past_its_cap_through_batches() {
|
|||
// Its snapshot holds the document and the recent batches kept for reconnecting clients (at
|
||||
// most a quarter of the cap), and the last batch let in may finish what the cap allows.
|
||||
assert!(on_disk <= max + max / 4 + 210_000, "{on_disk} bytes on disk");
|
||||
drop(raw);
|
||||
server.stop();
|
||||
// Everything confirmed opens again with the same settings, after compacting on the way out.
|
||||
let kept = client.document().nodes().count();
|
||||
let mut server = crate::start(&cfg).unwrap();
|
||||
let mut raw = Raw::connect(server.addr());
|
||||
raw.send(&Client::hello(&file.id, &edit.token, "Grower", ""));
|
||||
let welcome = loop {
|
||||
match raw.next() {
|
||||
w @ ServerMsg::Welcome { .. } => break w,
|
||||
ServerMsg::Refused { reason } => panic!("the file doesn't open again: {reason}"),
|
||||
_ => {}
|
||||
}
|
||||
};
|
||||
assert_eq!(Client::join(welcome).unwrap().document().nodes().count(), kept);
|
||||
server.stop();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -426,7 +426,7 @@ impl Store {
|
|||
let meta: Meta = serde_json::from_slice(&disk::read_capped(&path.join(META), 1 << 20)?).map_err(|e| format!("{META} is damaged ({e})"))?;
|
||||
let links: Vec<Link> =
|
||||
serde_json::from_slice(&disk::read_capped(&path.join(LINKS), 16 << 20)?).map_err(|e| format!("{LINKS} is damaged ({e})"))?;
|
||||
let generation = disk::check_snapshot(&disk::read_capped(&path.join(SNAPSHOT), self.snapshot_cap())?)?;
|
||||
let generation = disk::check_snapshot(&disk::read_capped(&path.join(SNAPSHOT), self.cfg.max_file_bytes)?)?;
|
||||
// A damaged log only loses its end; it is reported when the file opens.
|
||||
if let Ok(bytes) = disk::read_capped(&path.join(LOG), self.cfg.max_file_bytes)
|
||||
&& let Ok(log) = disk::read_log(&bytes)
|
||||
|
|
@ -1086,7 +1086,7 @@ impl Store {
|
|||
fn load(&mut self, file: &str) -> Result<Server, String> {
|
||||
self.touch(file);
|
||||
let dir = self.dir(file);
|
||||
let (generation, state) = disk::decode_snapshot(&disk::read_capped(&dir.join(SNAPSHOT), self.snapshot_cap())?)?;
|
||||
let (generation, state) = disk::decode_snapshot(&disk::read_capped(&dir.join(SNAPSHOT), self.cfg.max_file_bytes)?)?;
|
||||
let mut server = Server::restore(state, "", self.cfg.core).map_err(|e| format!("the snapshot can't be used: {e}"))?;
|
||||
let mut issues = Vec::new();
|
||||
let mut fresh_log = true;
|
||||
|
|
@ -1130,22 +1130,11 @@ impl Store {
|
|||
Ok(server)
|
||||
}
|
||||
|
||||
/// The largest snapshot read back: a file's cap plus room for what its state keeps beside the
|
||||
/// document (recent batches for reconnecting clients, deletions), which may take it over.
|
||||
fn snapshot_cap(&self) -> u64 {
|
||||
self.cfg.max_file_bytes.saturating_mul(2)
|
||||
}
|
||||
|
||||
/// Write `server`'s state as snapshot `generation` and start an empty log of it.
|
||||
fn rewrite(&mut self, file: &str, generation: u64, server: &Server) -> Result<Log, String> {
|
||||
let dir = self.dir(file);
|
||||
let state = server.state().map_err(|e| e.to_string())?;
|
||||
let bytes = disk::encode_snapshot(generation, &state)?;
|
||||
// Never replace a readable snapshot with one that can't be opened again: the old snapshot
|
||||
// and log still hold everything.
|
||||
if bytes.len() as u64 > self.snapshot_cap() {
|
||||
return Err(format!("the snapshot would be {} MiB, more than can be opened again", bytes.len() >> 20));
|
||||
}
|
||||
if bytes.len() as u64 > self.cfg.max_file_bytes {
|
||||
log::warn!("file {file} is {} MiB, over the {} MiB cap (kept anyway)", bytes.len() >> 20, self.cfg.max_file_bytes >> 20);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
//! Which graphics API draws the window.
|
||||
//!
|
||||
//! eframe offers wgpu every backend it was built with, and between equal GPUs wgpu takes Vulkan
|
||||
//! first, on Windows too. A Vulkan driver that faults there takes the app down as the window opens:
|
||||
//! an access violation inside the vendor's driver, which no guard of ours can catch (seen on a laptop
|
||||
//! with two AMD GPUs whose drivers were years apart). Direct3D 12 is part of Windows, so on Windows
|
||||
//! wgpu is offered Direct3D 12, and OpenGL for hardware without a Direct3D 12 driver.
|
||||
//!
|
||||
//! `WGPU_BACKEND` (`dx12`, `vulkan`, `gl`, `metal`) still names the backend on every system, and
|
||||
//! systems other than Windows keep eframe's defaults.
|
||||
|
||||
use eframe::egui_wgpu::{WgpuConfiguration, WgpuSetup};
|
||||
use eframe::wgpu::Backends;
|
||||
|
||||
/// Direct3D 12, then OpenGL: wgpu ranks a hardware GPU on either ahead of Direct3D's software one.
|
||||
const WINDOWS: Backends = Backends::DX12.union(Backends::GL);
|
||||
|
||||
/// The backends wgpu chooses from: the ones `WGPU_BACKEND` asks for (`requested`), else [`WINDOWS`]
|
||||
/// on Windows, else eframe's `defaults`.
|
||||
pub fn backends(requested: Option<Backends>, windows: bool, defaults: Backends) -> Backends {
|
||||
match requested {
|
||||
Some(requested) => requested,
|
||||
None if windows => WINDOWS,
|
||||
None => defaults,
|
||||
}
|
||||
}
|
||||
|
||||
/// Offer wgpu [`backends`] instead of eframe's defaults.
|
||||
pub fn configure(options: &mut WgpuConfiguration) {
|
||||
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_setup {
|
||||
let offered = &mut create.instance_descriptor.backends;
|
||||
*offered = backends(Backends::from_env(), cfg!(windows), *offered);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
//! The backends offered to wgpu: no Vulkan on Windows unless it is asked for (a faulting Vulkan
|
||||
//! driver crashed the app as its window opened), and nothing changed anywhere else.
|
||||
|
||||
use eframe::wgpu::Backends;
|
||||
|
||||
use crate::graphics::backends;
|
||||
|
||||
/// What eframe offers when `WGPU_BACKEND` isn't set.
|
||||
const DEFAULTS: Backends = Backends::PRIMARY.union(Backends::GL);
|
||||
|
||||
#[test]
|
||||
fn windows_is_offered_direct3d_and_not_vulkan() {
|
||||
let offered = backends(None, true, DEFAULTS);
|
||||
assert!(offered.contains(Backends::DX12), "Direct3D 12 draws the window");
|
||||
assert!(offered.contains(Backends::GL), "OpenGL is there for hardware without a Direct3D 12 driver");
|
||||
assert!(!offered.contains(Backends::VULKAN), "a Vulkan driver that faults can't be picked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wgpu_backend_names_the_backend_on_every_system() {
|
||||
for windows in [true, false] {
|
||||
for asked in [Backends::VULKAN, Backends::DX12, Backends::GL, Backends::VULKAN | Backends::GL] {
|
||||
assert_eq!(backends(Some(asked), windows, DEFAULTS), asked, "windows: {windows}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_systems_keep_eframes_defaults() {
|
||||
assert_eq!(backends(None, false, DEFAULTS), DEFAULTS);
|
||||
assert_eq!(backends(None, false, Backends::METAL), Backends::METAL);
|
||||
}
|
||||
|
|
@ -12,15 +12,12 @@
|
|||
mod args;
|
||||
mod control_server;
|
||||
mod gamepad;
|
||||
mod graphics;
|
||||
mod recovery;
|
||||
mod services;
|
||||
|
||||
#[cfg(test)]
|
||||
mod args_tests;
|
||||
#[cfg(test)]
|
||||
mod graphics_tests;
|
||||
#[cfg(test)]
|
||||
mod recovery_online_tests;
|
||||
#[cfg(test)]
|
||||
mod recovery_tests;
|
||||
|
|
@ -185,8 +182,7 @@ fn main() -> eframe::Result {
|
|||
if let Some(icon) = icon {
|
||||
viewport = viewport.with_icon(icon);
|
||||
}
|
||||
let mut options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() };
|
||||
graphics::configure(&mut options.wgpu_options);
|
||||
let options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() };
|
||||
figcraft_ui_egui::windows::install(fig);
|
||||
eframe::run_native("FigCraft", options, Box::new(move |_cc| Ok(Box::new(App { queue, ctx_slot, title: String::new(), recovery }))))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -141,31 +141,17 @@ pub fn save_state(app: &FigApp) {
|
|||
}
|
||||
|
||||
/// Write `prefs` to `dir` atomically (a temporary file renamed over the old one: a crash leaves
|
||||
/// either version whole). They hold online owner tokens, so on Unix only the owner can read them.
|
||||
pub(crate) fn write_prefs(dir: &Path, prefs: &Prefs) {
|
||||
/// either version whole).
|
||||
fn write_prefs(dir: &Path, prefs: &Prefs) {
|
||||
let tmp = dir.join("state.json.tmp");
|
||||
let written = std::fs::create_dir_all(dir)
|
||||
.and_then(|()| write_private(&tmp, json!({"prefs": prefs}).to_string().as_bytes()))
|
||||
.and_then(|()| std::fs::write(&tmp, json!({"prefs": prefs}).to_string()))
|
||||
.and_then(|()| std::fs::rename(&tmp, dir.join(STATE)));
|
||||
if let Err(e) = written {
|
||||
log::warn!("preferences: couldn't save them in {}: {e}", dir.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path`, owner read/write only on Unix from before the first byte (a leftover
|
||||
/// file from a crash is narrowed too: the mode given at open only applies to new files).
|
||||
fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
let mut o = std::fs::OpenOptions::new();
|
||||
o.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::OpenOptionsExt::mode(&mut o, 0o600);
|
||||
let mut f = o.open(path)?;
|
||||
#[cfg(unix)]
|
||||
f.set_permissions(std::os::unix::fs::PermissionsExt::from_mode(0o600))?;
|
||||
f.write_all(bytes)
|
||||
}
|
||||
|
||||
impl Services for Desktop {
|
||||
fn pick_open(&mut self, title: &str, exts: &[&str]) -> Option<PathBuf> {
|
||||
rfd::FileDialog::new().set_title(title).add_filter("Supported files", exts).pick_file()
|
||||
|
|
|
|||
|
|
@ -66,19 +66,3 @@ fn plugins_are_kept_when_the_app_is_killed() {
|
|||
assert!(!dir.join("state.json.tmp").exists(), "no temporary file is left behind");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn preferences_are_readable_by_their_owner_only() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = root("private");
|
||||
let mode = |p: &std::path::Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
|
||||
// An older world-readable file and a leftover temporary one from a crash.
|
||||
for name in ["state.json", "state.json.tmp"] {
|
||||
std::fs::write(dir.join(name), "{}").unwrap();
|
||||
std::fs::set_permissions(dir.join(name), std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
}
|
||||
crate::services::write_prefs(&dir, &figcraft_engine::Prefs::default());
|
||||
assert_eq!(mode(&dir.join("state.json")), 0o600);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -939,11 +939,7 @@ impl Document {
|
|||
if n.id != *id {
|
||||
issues.push(format!("node key {id} has id {}", n.id));
|
||||
}
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for c in &n.children {
|
||||
if !seen.insert(*c) {
|
||||
issues.push(format!("child {c} listed twice in {id}"));
|
||||
}
|
||||
match self.node(*c) {
|
||||
Some(cn) if cn.parent == Some(*id) => {}
|
||||
Some(_) => issues.push(format!("child {c} of {id} has another parent")),
|
||||
|
|
@ -960,32 +956,10 @@ impl Document {
|
|||
if n.is_page() != listed {
|
||||
issues.push(format!("page list mismatch for {id}"));
|
||||
}
|
||||
if n.is_page() && n.parent.is_some() {
|
||||
issues.push(format!("page {id} has a parent"));
|
||||
}
|
||||
}
|
||||
let reachable = self.reachable();
|
||||
for id in self.nodes.keys().filter(|k| !reachable.contains(*k)) {
|
||||
issues.push(format!("{id} is not under a page (cycle or detached)"));
|
||||
}
|
||||
issues
|
||||
}
|
||||
|
||||
/// Every node reachable from a page through reciprocal parent/child links, each once (cycles
|
||||
/// and repeated children are walked once, so this is linear in the node count).
|
||||
fn reachable(&self) -> std::collections::BTreeSet<NodeId> {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mut stack: Vec<NodeId> = self.nodes.iter().filter(|(_, n)| n.is_page() && n.parent.is_none()).map(|(k, _)| *k).collect();
|
||||
while let Some(id) = stack.pop() {
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
let Some(n) = self.nodes.get(&id) else { continue };
|
||||
stack.extend(n.children.iter().filter(|c| !seen.contains(*c) && self.nodes.get(*c).is_some_and(|cn| cn.parent == Some(id))));
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
/// Repair what [`validate`](Self::validate) reports (lenient loading of untrusted files).
|
||||
pub fn repair(&mut self) {
|
||||
let ids: Vec<NodeId> = self.nodes.keys().copied().collect();
|
||||
|
|
@ -993,30 +967,34 @@ impl Document {
|
|||
if let Some(n) = self.nodes.get_mut(id) {
|
||||
let n = Arc::make_mut(n);
|
||||
n.id = *id;
|
||||
// Pages are roots.
|
||||
if n.is_page() {
|
||||
n.parent = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Drop dangling children, children claimed by another parent and repeats.
|
||||
// Drop dangling children and children claimed by another parent.
|
||||
let parents: BTreeMap<NodeId, Option<NodeId>> = self.nodes.iter().map(|(k, n)| (*k, n.parent)).collect();
|
||||
for id in &ids {
|
||||
let Some(n) = self.nodes.get(id) else { continue };
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let keep: Vec<NodeId> =
|
||||
n.children.iter().copied().filter(|c| parents.get(c).copied().flatten() == Some(*id) && seen.insert(*c)).collect();
|
||||
let keep: Vec<NodeId> = n.children.iter().copied().filter(|c| parents.get(c).copied().flatten() == Some(*id)).collect();
|
||||
if keep != n.children
|
||||
&& let Some(n) = self.nodes.get_mut(id)
|
||||
{
|
||||
Arc::make_mut(n).children = keep;
|
||||
}
|
||||
}
|
||||
// Nodes not under a page (orphans, detached subtrees, cycles) are removed.
|
||||
let reachable = self.reachable();
|
||||
let gone: Vec<NodeId> = self.nodes.keys().filter(|k| !reachable.contains(*k)).copied().collect();
|
||||
for k in gone {
|
||||
self.nodes.remove(&k);
|
||||
// Orphans (parent missing or not listing them) are removed.
|
||||
loop {
|
||||
let orphan = self.nodes.iter().find_map(|(k, n)| {
|
||||
if n.is_page() {
|
||||
return None;
|
||||
}
|
||||
match n.parent {
|
||||
Some(p) if self.nodes.get(&p).is_some_and(|pn| pn.children.contains(k)) => None,
|
||||
_ => Some(*k),
|
||||
}
|
||||
});
|
||||
let Some(o) = orphan else { break };
|
||||
for d in self.subtree(o) {
|
||||
self.nodes.remove(&d);
|
||||
}
|
||||
}
|
||||
// Snapshots of layers that are gone or no longer changed since marked ready for dev, or that
|
||||
// don't start with their layer, are dropped.
|
||||
|
|
|
|||
|
|
@ -281,30 +281,6 @@ fn serde_roundtrip_and_repair() {
|
|||
assert!(bad.validate().is_empty(), "{:?}", bad.validate());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_drops_cycles_and_repeated_children() {
|
||||
let mut d = Document::new("x");
|
||||
let p = page(&d);
|
||||
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
let g = d.create(f, NodeKind::Frame, "G", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
let r = d.create(p, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
let mut v = serde_json::to_value(&d).unwrap();
|
||||
// F and G each other's parent and child (G listing F twice), and R listed twice by the page.
|
||||
v["nodes"][f.to_string()]["parent"] = serde_json::json!(g);
|
||||
v["nodes"][g.to_string()]["children"] = serde_json::json!([f, f]);
|
||||
v["nodes"][p.to_string()]["children"].as_array_mut().unwrap().push(serde_json::json!(r));
|
||||
let mut bad: Document = serde_json::from_value(v).unwrap();
|
||||
let issues = bad.validate();
|
||||
for want in ["listed twice", "not under a page"] {
|
||||
assert!(issues.iter().any(|i| i.contains(want)), "{want}: {issues:?}");
|
||||
}
|
||||
bad.repair();
|
||||
assert!(bad.validate().is_empty(), "{:?}", bad.validate());
|
||||
assert!(bad.node(f).is_none() && bad.node(g).is_none());
|
||||
assert_eq!(bad.node(p).unwrap().children, vec![r]);
|
||||
assert_eq!(bad.subtree(p), vec![p, r]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_layout_loads_files_without_grid_tracks() {
|
||||
// Saved before grid tracks, cells and spans existed.
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
use serde_json::json;
|
||||
|
||||
use crate::plugins_tests::{OnRefusal, install, manifest, script};
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
fn id(value: &serde_json::Value) -> crate::NodeId {
|
||||
crate::params::id_of(&value["id"]).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_group_closes_documents_opened_inside_it() {
|
||||
let mut s = Session::new();
|
||||
let original = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
let before_journal = s.journal.clone();
|
||||
let before_history = s.ds().unwrap().history.undo.len();
|
||||
|
||||
let result = s.execute(
|
||||
"command.batch",
|
||||
&json!({
|
||||
"label": "Open then fail",
|
||||
"commands": [
|
||||
{"command": "file.new", "params": {"name": "Temporary"}},
|
||||
{"command": "shape.rectangle", "params": {"x": 20, "y": 20, "width": 10, "height": 10}},
|
||||
{"command": "node.set", "params": {"id": 999999, "props": {"name": "must fail"}}}
|
||||
]
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(s.docs.len(), 1);
|
||||
assert_eq!(s.active, 0);
|
||||
assert!(s.doc().unwrap().contains(original));
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), before_history);
|
||||
assert_eq!(s.journal, before_journal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_plugin_run_restores_each_document_without_restoring_unrelated_state() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"name": "Second"})).unwrap();
|
||||
let before_history: Vec<_> = s.docs.iter().map(|ds| ds.history.undo.len()).collect();
|
||||
let calls = [
|
||||
("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})),
|
||||
("doc.nextTab", json!({})),
|
||||
("shape.rectangle", json!({"x": 20, "y": 0, "width": 10, "height": 10})),
|
||||
("node.set", json!({"id": 999999, "props": {"name": "must fail"}})),
|
||||
];
|
||||
install(&mut s, manifest("org.test.rollback", "write", &[]), &script(&calls, OnRefusal::Fail, ""));
|
||||
s.message = Some("keep this".into());
|
||||
let before_journal = s.journal.clone();
|
||||
let before_message = s.message.clone();
|
||||
// The new file is the one in front when the plugin starts.
|
||||
let before_active = s.active;
|
||||
|
||||
let result = s.execute("plugin.run", &json!({"id": "org.test.rollback"}));
|
||||
|
||||
assert!(matches!(result, Err(EngineError::Other(_))));
|
||||
assert_eq!(s.docs.iter().map(|ds| ds.doc.node(ds.page).map_or(0, |page| page.children.len())).collect::<Vec<_>>(), vec![0, 0]);
|
||||
assert_eq!(s.active, before_active, "the tab in front is put back");
|
||||
assert_eq!(s.docs.iter().map(|ds| ds.history.undo.len()).collect::<Vec<_>>(), before_history);
|
||||
assert_eq!(s.journal, before_journal);
|
||||
assert_eq!(s.message, before_message);
|
||||
}
|
||||
|
|
@ -104,8 +104,6 @@ pub static MENU: &[MenuItem] = &[
|
|||
m!("View", "Soft-proof sRGB", "view.proofSrgb"),
|
||||
m!("View", SEP, ""),
|
||||
m!("View/Panels", "Show/Hide UI", "view.toggleUi"),
|
||||
// FigCraft's own: the window fills the screen (F11), for setups that hide the taskbar then.
|
||||
m!("View", "Full screen", "view.fullscreen"),
|
||||
m!("View/Panels", "Reset panel widths", "view.panelWidths"),
|
||||
m!("View/Panels", "Layers", "view.layers"),
|
||||
m!("View/Panels", "Assets", "view.assets"),
|
||||
|
|
|
|||
|
|
@ -662,13 +662,6 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
remember(s, &path);
|
||||
let native = !rest && matches!(ext(&path).as_str(), "figcraft" | "json");
|
||||
let warnings = rest || ext(&path) == "svg";
|
||||
// A native file that lost something (a damaged image, say): the app says so, not only the result.
|
||||
if native && !loaded.issues.is_empty() {
|
||||
s.message = Some(match loaded.issues.as_slice() {
|
||||
[one] => format!("{}: {one}", file_stem(&path)),
|
||||
more => format!("{}: {} problems were repaired or left out while opening it, e.g. {}", file_stem(&path), more.len(), more[0]),
|
||||
});
|
||||
}
|
||||
s.docs.push(DocState::new(loaded.doc, native.then_some(path)).with_versions(loaded.versions).with_branches(loaded.branches));
|
||||
s.active = s.docs.len() - 1;
|
||||
let mut out = json!({"index": s.active});
|
||||
|
|
|
|||
|
|
@ -268,10 +268,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
spec!("view.snapObjects", "Snap to objects", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_objects)),
|
||||
spec!("view.snapPixel", "Snap to pixel grid", "{on?}", false, |s, p| flag(s, p, |v| &mut v.snap_pixel)),
|
||||
spec!("view.toggleUi", "Show/Hide UI", "{on?}", false, |s, p| flag(s, p, |v| &mut v.ui_hidden)),
|
||||
spec!("view.fullscreen", "Full screen", "{on?} the window fills the screen (the desktop app; a no-op headless)", false, |s, p| {
|
||||
s.request_ui("window.fullscreen", json!({"on": boolean(p, "on")}));
|
||||
ok()
|
||||
}),
|
||||
spec!("view.layers", "Layers panel", "{}", false, |s, _| ui(s, "panel.layers")),
|
||||
spec!("view.assets", "Assets panel", "{}", false, |s, _| ui(s, "panel.assets")),
|
||||
spec!("view.design", "Design panel", "{}", false, |s, _| ui(s, "panel.design")),
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ 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::from_id(s).filter(|l| matches!(l, CodeLang::React | CodeLang::SwiftUi | CodeLang::Compose))
|
||||
}
|
||||
|
||||
/// The layer `id`'s links are stored on: its component set, else its component (an instance's
|
||||
|
|
@ -65,7 +65,6 @@ pub fn import_line(link: &CodeLink, lang: CodeLang) -> String {
|
|||
match lang {
|
||||
_ if path.is_empty() => String::new(),
|
||||
CodeLang::React => 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()),
|
||||
_ => format!("import {path}"),
|
||||
}
|
||||
}
|
||||
|
|
@ -173,7 +172,6 @@ fn expr(doc: &Document, c: &Component, p: &Param, v: &Value, lang: CodeLang) ->
|
|||
let swapped = (p.kind == ParamKind::InstanceSwap).then(|| id_of(v)).flatten().and_then(|i| link(doc, i, lang));
|
||||
match (swapped, lang) {
|
||||
(Some(l), CodeLang::React) => format!("<{} />", l.name),
|
||||
(Some(l), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", l.name),
|
||||
(Some(l), CodeLang::SwiftUi) => format!("{}()", l.name),
|
||||
(Some(l), _) => format!("{{ {}() }}", l.name),
|
||||
(None, _) => value_expr(doc, c, p, v, lang),
|
||||
|
|
@ -193,10 +191,9 @@ pub(crate) fn render(doc: &Document, c: &Component, values: &[(String, Value)],
|
|||
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('\\')) {
|
||||
// JSX: a plain string reads best as an attribute value, anything else is an
|
||||
// expression in braces (JSX attribute strings have no escapes).
|
||||
if lang != CodeLang::React || (out.ends_with('=') && e.starts_with('"') && !e.contains('\\')) {
|
||||
out.push_str(&e);
|
||||
} else {
|
||||
out.push_str(&format!("{{{e}}}"));
|
||||
|
|
@ -218,9 +215,6 @@ fn default_template(c: &Component, lang: CodeLang, name: &str) -> String {
|
|||
let params: Vec<&str> = c.params.iter().filter(|p| p.kind != ParamKind::Slot).map(|p| p.ident.as_str()).collect();
|
||||
match lang {
|
||||
CodeLang::React => format!("<{name}{} />", params.iter().map(|p| format!(" {p}={{{p}}}")).collect::<String>()),
|
||||
CodeLang::Leptos => {
|
||||
format!("<{name}{} />", params.iter().map(|p| format!(" {}={{{p}}}", crate::component_codegen::rust_ident(p))).collect::<String>())
|
||||
}
|
||||
CodeLang::SwiftUi => format!("{name}({})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::<Vec<_>>().join(", ")),
|
||||
_ => format!("{name}({})", params.iter().map(|p| format!("{p} = {{{p}}}")).collect::<Vec<_>>().join(", ")),
|
||||
}
|
||||
|
|
@ -240,13 +234,11 @@ pub fn check(c: &Component, link: &CodeLink, lang: CodeLang) -> std::result::Res
|
|||
let path = &link.import_path;
|
||||
let path_ok = match lang {
|
||||
CodeLang::React => !path.chars().any(|ch| ch.is_control() || ch == '"' || ch == '\\'),
|
||||
CodeLang::Leptos => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == ':'),
|
||||
_ => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.'),
|
||||
};
|
||||
if path.chars().count() > MAX_IMPORT || !path_ok {
|
||||
return Err(match lang {
|
||||
CodeLang::React => 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"),
|
||||
_ => format!("importPath: a module or package name (letters, digits, _ and .), up to {MAX_IMPORT} characters"),
|
||||
});
|
||||
}
|
||||
|
|
@ -282,7 +274,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")),
|
||||
Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|swiftui|compose")),
|
||||
None => Ok(CodeLang::React),
|
||||
}
|
||||
}
|
||||
|
|
@ -312,7 +304,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 (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|swiftui|compose (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";
|
||||
|
|
@ -427,7 +419,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), name and template strings", i + 1));
|
||||
warnings.push(format!("link {}: needs component, language (react|swiftui|compose), name and template strings", i + 1));
|
||||
continue;
|
||||
};
|
||||
let link = CodeLink {
|
||||
|
|
|
|||
|
|
@ -65,15 +65,6 @@ fn connected_instances_show_the_real_snippet() {
|
|||
);
|
||||
assert_eq!(code(&mut s, i, "swiftui"), "import AcmeUI\n\nCard(title: \"Say \\\"hi\\\"\", style: .plain)\n");
|
||||
assert!(code(&mut s, i, "compose").contains("@Composable"), "other languages stay generated");
|
||||
// Leptos: a `use` line and `view!` markup (strings with escapes in braces).
|
||||
ex(
|
||||
&mut s,
|
||||
"codeConnect.set",
|
||||
json!({"id": c, "language": "leptos", "importPath": "crate::ui", "template": "<Card title={title} dotted={Show dot} />"}),
|
||||
);
|
||||
assert_eq!(code(&mut s, i, "leptos"), "use crate::ui::Card;\n\n<Card title={\"Say \\\"hi\\\"\"} dotted={false} />\n");
|
||||
assert!(s.execute("codeConnect.set", &json!({"id": c, "language": "leptos", "importPath": "crate/ui"})).is_err(), "a Rust path");
|
||||
ex(&mut s, "codeConnect.remove", json!({"id": c, "language": "leptos"}));
|
||||
// inspect.componentCode: the snippet as the usage, and the link.
|
||||
let v = ex(&mut s, "inspect.componentCode", json!({"id": i, "language": "swiftui"}));
|
||||
assert_eq!(v["usage"], "Card(title: \"Say \\\"hi\\\"\", style: .plain)\n");
|
||||
|
|
|
|||
|
|
@ -18,13 +18,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
vec![spec!(
|
||||
"inspect.code",
|
||||
"Code for layer",
|
||||
"{id?, language?: css|html|react|leptos|swiftui|compose (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), 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|swiftui|compose (default css; html and react 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), unit?: px|rem (CSS, HTML and React; 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|swiftui|compose"))?,
|
||||
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|swiftui|compose"))?,
|
||||
None => CodeLang::Css,
|
||||
};
|
||||
let rem = match string(p, "unit").map(|u| u.to_ascii_lowercase()).as_deref() {
|
||||
|
|
@ -42,7 +42,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
let connected = crate::code_connect::link(doc, id, lang).is_some();
|
||||
let code = code_for(doc, id, lang);
|
||||
let code = match rem {
|
||||
Some(root) if lang.is_web() => px_to_rem(&code, root),
|
||||
Some(root) if matches!(lang, CodeLang::Css | CodeLang::Html | CodeLang::React) => px_to_rem(&code, root),
|
||||
_ => code,
|
||||
};
|
||||
Ok(json!({"code": code, "language": lang.id(), "connected": connected}))
|
||||
|
|
@ -59,20 +59,17 @@ pub enum CodeLang {
|
|||
Html,
|
||||
/// A React function component (TSX) with its stylesheet.
|
||||
React,
|
||||
/// A Leptos component (`view!` markup) with its stylesheet.
|
||||
Leptos,
|
||||
SwiftUi,
|
||||
Compose,
|
||||
}
|
||||
|
||||
impl CodeLang {
|
||||
pub const ALL: [CodeLang; 6] = [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::SwiftUi, CodeLang::Compose];
|
||||
pub const ALL: [CodeLang; 5] = [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::SwiftUi, CodeLang::Compose];
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
CodeLang::Css => "css",
|
||||
CodeLang::Html => "html",
|
||||
CodeLang::React => "react",
|
||||
CodeLang::Leptos => "leptos",
|
||||
CodeLang::SwiftUi => "swiftui",
|
||||
CodeLang::Compose => "compose",
|
||||
}
|
||||
|
|
@ -82,15 +79,10 @@ impl CodeLang {
|
|||
CodeLang::Css => "CSS",
|
||||
CodeLang::Html => "HTML + CSS",
|
||||
CodeLang::React => "React",
|
||||
CodeLang::Leptos => "Leptos",
|
||||
CodeLang::SwiftUi => "SwiftUI",
|
||||
CodeLang::Compose => "Compose",
|
||||
}
|
||||
}
|
||||
/// Languages whose lengths are CSS pixels (they can be shown in rem).
|
||||
pub fn is_web(self) -> bool {
|
||||
matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos)
|
||||
}
|
||||
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);
|
||||
|
|
@ -144,7 +136,7 @@ pub fn code_for(doc: &Document, id: NodeId, lang: CodeLang) -> String {
|
|||
match lang {
|
||||
CodeLang::Css => css(doc, id),
|
||||
CodeLang::Html => html(doc, id),
|
||||
CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose => crate::component_codegen::code(doc, id, lang),
|
||||
CodeLang::React | CodeLang::SwiftUi | CodeLang::Compose => crate::component_codegen::code(doc, id, lang),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -569,18 +561,16 @@ fn jsx_text(s: &str) -> String {
|
|||
/// flexboxes, other children are placed absolutely in their parent, text becomes paragraphs.
|
||||
fn html(doc: &Document, id: NodeId) -> String {
|
||||
let ctx = Ctx::new(doc, id);
|
||||
let mut out = Markup::new(&ctx, CodeLang::Html);
|
||||
let mut out = Markup::new(&ctx, false);
|
||||
out.node(id, None, 0, "");
|
||||
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`) or React JSX (`className`, component
|
||||
/// parameters, instances as calls), with its stylesheet.
|
||||
pub(crate) struct Markup<'c, 'a> {
|
||||
ctx: &'c Ctx<'a>,
|
||||
/// Html, React or Leptos.
|
||||
lang: CodeLang,
|
||||
jsx: bool,
|
||||
used: Vec<String>,
|
||||
pub markup: String,
|
||||
pub styles: String,
|
||||
|
|
@ -594,10 +584,10 @@ pub(crate) struct Markup<'c, 'a> {
|
|||
}
|
||||
|
||||
impl<'c, 'a> Markup<'c, 'a> {
|
||||
pub fn new(ctx: &'c Ctx<'a>, lang: CodeLang) -> Self {
|
||||
pub fn new(ctx: &'c Ctx<'a>, jsx: bool) -> Self {
|
||||
Markup {
|
||||
ctx,
|
||||
lang,
|
||||
jsx,
|
||||
used: Vec::new(),
|
||||
markup: String::new(),
|
||||
styles: String::new(),
|
||||
|
|
@ -608,34 +598,22 @@ impl<'c, 'a> Markup<'c, 'a> {
|
|||
}
|
||||
}
|
||||
|
||||
/// JSX indents by two spaces, HTML and Leptos by four.
|
||||
/// JSX indents by two spaces, HTML by four.
|
||||
fn pad(&self, depth: usize) -> String {
|
||||
if self.lang == CodeLang::React { " ".repeat(depth) } else { indent(depth) }
|
||||
if self.jsx { " ".repeat(depth) } else { indent(depth) }
|
||||
}
|
||||
|
||||
/// Component code (React or Leptos): parameters and instances as calls.
|
||||
fn code(&self) -> bool {
|
||||
self.lang != CodeLang::Html
|
||||
}
|
||||
|
||||
/// A parameter's name in the generated code.
|
||||
fn ident(&self, p: &Param) -> String {
|
||||
if self.lang == CodeLang::Leptos { crate::component_codegen::rust_ident(&p.ident) } else { p.ident.clone() }
|
||||
}
|
||||
|
||||
/// A layer, shown only while its boolean parameter is on (component code).
|
||||
/// A layer, shown only while its boolean parameter is on (JSX).
|
||||
pub fn node(&mut self, id: NodeId, parent: Option<&Node>, depth: usize, path: &str) {
|
||||
let Some(n) = self.ctx.doc.node(id) else { return };
|
||||
if self.code()
|
||||
if self.jsx
|
||||
&& id != self.ctx.root
|
||||
&& let Some(p) = self.ctx.param(n, "visible")
|
||||
{
|
||||
let (pad, p) = (self.pad(depth), self.ident(p));
|
||||
let (open, close) =
|
||||
if self.lang == CodeLang::Leptos { (format!("{{{p}.then(|| view! {{"), "})}") } else { (format!("{{{p} && ("), ")}") };
|
||||
self.markup.push_str(&format!("{pad}{open}\n"));
|
||||
let pad = self.pad(depth);
|
||||
self.markup.push_str(&format!("{pad}{{{} && (\n", p.ident));
|
||||
self.element(n, parent, depth + 1, path);
|
||||
self.markup.push_str(&format!("{pad}{close}\n"));
|
||||
self.markup.push_str(&format!("{pad})}}\n"));
|
||||
return;
|
||||
}
|
||||
self.element(n, parent, depth, path);
|
||||
|
|
@ -661,7 +639,7 @@ impl<'c, 'a> Markup<'c, 'a> {
|
|||
None => class_name(&n.name, &mut self.used),
|
||||
};
|
||||
let pad = self.pad(depth);
|
||||
let attr = if self.lang == CodeLang::React { "className" } else { "class" };
|
||||
let attr = if self.jsx { "className" } else { "class" };
|
||||
// How it sits in its parent: in the flex flow, or placed at its position.
|
||||
let mut decls = String::new();
|
||||
let in_flow = parent.is_some_and(|p| p.layout.is_some()) && !n.child_layout.absolute;
|
||||
|
|
@ -685,13 +663,12 @@ impl<'c, 'a> Markup<'c, 'a> {
|
|||
decls.push_str(&format!("transform: rotate({}deg);\ntransform-origin: 0 0;\n", num(-rot)));
|
||||
}
|
||||
}
|
||||
// Nested instances are calls (component code), in a wrapper that places them when needed.
|
||||
if self.code() && !root && matches!(n.kind, NodeKind::Instance { .. }) {
|
||||
// Nested instances are calls (JSX), in a wrapper that places them when needed.
|
||||
if self.jsx && !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),
|
||||
Some(p) => Some(format!("{}{{{}}}\n", self.pad(at), p.ident)),
|
||||
None => self.ctx.call(n, CodeLang::React, at),
|
||||
};
|
||||
if let Some(inner) = inner {
|
||||
if decls.is_empty() {
|
||||
|
|
@ -715,26 +692,19 @@ impl<'c, 'a> Markup<'c, 'a> {
|
|||
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), _) => 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/>")
|
||||
}
|
||||
(None, _) => html_escape(&t.characters),
|
||||
let body = match self.ctx.param(n, "characters").filter(|_| self.jsx) {
|
||||
Some(p) => format!("{{{}}}", p.ident),
|
||||
None if self.jsx => jsx_text(&t.characters),
|
||||
None => html_escape(&t.characters),
|
||||
};
|
||||
self.markup.push_str(&format!("{}{body}</p>\n", open("p")));
|
||||
}
|
||||
_ => {
|
||||
let kids = self.ctx.kids(n);
|
||||
let slot = self.ctx.param(n, "slot").filter(|_| self.code());
|
||||
let leptos = self.lang == CodeLang::Leptos;
|
||||
let slot = self.ctx.param(n, "slot").filter(|_| self.jsx);
|
||||
let open = open("div");
|
||||
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"));
|
||||
self.markup.push_str(&format!("{open}{{{}}}</div>\n", p.ident));
|
||||
} else if kids.is_empty() || self.ctx.too_deep(depth) {
|
||||
self.markup.push_str(&format!("{open}</div>\n"));
|
||||
} else {
|
||||
|
|
@ -742,10 +712,6 @@ 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(p) if leptos => {
|
||||
self.markup.push_str(&format!("{p1}{{match &{} {{ Some(c) => c.run(), None => view! {{\n", self.ident(p)));
|
||||
depth + 2
|
||||
}
|
||||
Some(p) => {
|
||||
self.markup.push_str(&format!("{p1}{{{} ?? (\n{p2}<>\n", p.ident));
|
||||
depth + 3
|
||||
|
|
@ -760,10 +726,8 @@ impl<'c, 'a> Markup<'c, 'a> {
|
|||
seen.push(&kn.name);
|
||||
self.node(k, Some(n), inner, &format!("{path}/{}#{count}", kn.name));
|
||||
}
|
||||
match slot {
|
||||
Some(_) if leptos => self.markup.push_str(&format!("{p1}}}.into_any() }}}}\n")),
|
||||
Some(_) => self.markup.push_str(&format!("{p2}</>\n{p1})}}\n")),
|
||||
None => {}
|
||||
if slot.is_some() {
|
||||
self.markup.push_str(&format!("{p2}</>\n{p1})}}\n"));
|
||||
}
|
||||
self.markup.push_str(&format!("{pad}</div>\n"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
//! Component code: a component (or component set) becomes a React, Leptos, SwiftUI or Jetpack
|
||||
//! Compose component whose properties are its parameters (a variant property → a union or enum, boolean →
|
||||
//! Component code: a component (or component set) becomes a React, SwiftUI or Jetpack Compose
|
||||
//! 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. Variants of a set
|
||||
//! share one definition (the set's default variant); in React and Leptos their differences become CSS
|
||||
//! rules on `data-*` attributes. Returned by `inspect.componentCode` and used by `inspect.code`. A component
|
||||
//! share one definition (the set's default variant); in React their differences become CSS rules on
|
||||
//! `data-*` attributes. Returned by `inspect.componentCode` and used by `inspect.code`. A component
|
||||
//! connected to code (see [`crate::code_connect`]) shows its real snippet instead.
|
||||
|
||||
use figcraft_doc::*;
|
||||
|
|
@ -67,14 +67,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 (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|swiftui|compose (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))) => l,
|
||||
Some(_) => return Err(bad(cmd, "language: react|leptos|swiftui|compose")),
|
||||
Some(Some(l @ (CodeLang::React | CodeLang::SwiftUi | CodeLang::Compose))) => l,
|
||||
Some(_) => return Err(bad(cmd, "language: react|swiftui|compose")),
|
||||
None => CodeLang::React,
|
||||
};
|
||||
let doc = s.doc()?;
|
||||
|
|
@ -107,9 +107,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
)]
|
||||
}
|
||||
|
||||
/// Code for any layer in React, Leptos, SwiftUI or Compose: 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).
|
||||
/// Code for any layer in React, SwiftUI or Compose: an instance's call and its component's
|
||||
/// definition, a component's (or set's) definition, or the layer tree (React wraps it in a
|
||||
/// function component).
|
||||
pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
|
||||
let Some(n) = doc.node(id) else { return String::new() };
|
||||
if let Some(code) = crate::code_connect::link(doc, id, lang).and_then(|l| crate::code_connect::connected_code(doc, id, lang, l)) {
|
||||
|
|
@ -120,10 +120,7 @@ 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 => {
|
||||
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) }
|
||||
}
|
||||
CodeLang::React => react(doc, &Component { comp: id, set: None, type_name: component_type_name(&n.name), params: Vec::new() }),
|
||||
CodeLang::SwiftUi => {
|
||||
let mut out = String::new();
|
||||
swift(&Ctx::new(doc, id), id, 0, &mut out, &[]);
|
||||
|
|
@ -361,52 +358,6 @@ fn ident(name: &str, used: &mut Vec<String>) -> String {
|
|||
out
|
||||
}
|
||||
|
||||
/// Rust's keywords, and `children` (Leptos passes a component's children by that name).
|
||||
const RUST_RESERVED: &[&str] = &[
|
||||
"abstract", "as", "async", "await", "become", "box", "break", "children", "const", "continue", "crate", "do", "dyn", "else", "enum", "extern",
|
||||
"false", "final", "fn", "for", "gen", "if", "impl", "in", "let", "loop", "macro", "match", "mod", "move", "mut", "override", "priv", "pub",
|
||||
"ref", "return", "self", "static", "struct", "super", "trait", "true", "try", "type", "typeof", "unsafe", "unsized", "use", "virtual", "where",
|
||||
"while", "yield",
|
||||
];
|
||||
|
||||
/// A parameter's Rust name: its camelCase name in snake_case (`showIcon` → `show_icon`), with a
|
||||
/// trailing `_` when Rust reserves it (`type_`; camelCase names have no `_`, so it stays unique).
|
||||
pub(crate) fn rust_ident(ident: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for (i, ch) in ident.chars().enumerate() {
|
||||
if ch.is_ascii_uppercase() {
|
||||
if i > 0 {
|
||||
out.push('_');
|
||||
}
|
||||
out.push(ch.to_ascii_lowercase());
|
||||
} else {
|
||||
out.push(ch);
|
||||
}
|
||||
}
|
||||
if RUST_RESERVED.contains(&out.as_str()) {
|
||||
out.push('_');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A Rust type name: `Self` is taken.
|
||||
fn rust_type(t: &str) -> String {
|
||||
if t == "Self" { "SelfComponent".into() } else { t.to_string() }
|
||||
}
|
||||
|
||||
/// A Leptos variant enum's name: the component's name and the property's, never the props struct
|
||||
/// `#[component]` makes (`ButtonProps`).
|
||||
fn leptos_enum(c: &Component, p: &Param) -> String {
|
||||
let t = rust_type(&c.type_name);
|
||||
let name = format!("{t}{}", p.enum_name);
|
||||
if name == format!("{t}Props") { format!("{name}Variant") } else { name }
|
||||
}
|
||||
|
||||
/// Rust enum variants for variant values: [`cases`] in PascalCase, `Self` taken.
|
||||
fn rust_cases(values: &[String]) -> Vec<String> {
|
||||
cases(values, false).into_iter().map(|c| if c == "Self" { "SelfValue".into() } else { c }).collect()
|
||||
}
|
||||
|
||||
/// Enum case names for variant values, unique: Swift's lowerCamel (`.small`, `` .`default` ``),
|
||||
/// Kotlin's PascalCase (`Small`).
|
||||
fn cases(values: &[String], swift: bool) -> Vec<String> {
|
||||
|
|
@ -432,11 +383,6 @@ fn cases(values: &[String], swift: bool) -> Vec<String> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// A Rust string literal.
|
||||
pub(crate) fn rust_string(s: &str) -> String {
|
||||
format!("{:?}", s.chars().take(10_000).collect::<String>())
|
||||
}
|
||||
|
||||
/// A JavaScript string literal.
|
||||
pub(crate) fn js_string(s: &str) -> String {
|
||||
let s: String = s.chars().take(10_000).collect();
|
||||
|
|
@ -459,11 +405,6 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
|
|||
match (&p.kind, lang) {
|
||||
(ParamKind::Boolean, _) => value.as_bool().unwrap_or(false).to_string(),
|
||||
(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) => {
|
||||
let i = opts.iter().position(|o| Some(o.as_str()) == value.as_str()).unwrap_or(0);
|
||||
format!("{}::{}", leptos_enum(c, p), rust_cases(opts).get(i).cloned().unwrap_or_else(|| "Unknown".into()))
|
||||
}
|
||||
(ParamKind::Text, l) => literal(value.as_str().unwrap_or(""), l == CodeLang::Compose),
|
||||
(ParamKind::Variant(opts), l) => {
|
||||
let v = value.as_str().unwrap_or("");
|
||||
|
|
@ -479,8 +420,6 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
|
|||
let t = id_of(value).and_then(|i| component_type(doc, i));
|
||||
match (t, l) {
|
||||
(Some(t), CodeLang::React) => format!("<{t} />"),
|
||||
(Some(t), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", rust_type(&t)),
|
||||
(None, CodeLang::Leptos) => "|| ()".into(),
|
||||
(Some(t), CodeLang::SwiftUi) => format!("AnyView({t}())"),
|
||||
(Some(t), _) => format!("{{ {t}() }}"),
|
||||
(None, CodeLang::React) => "null".into(),
|
||||
|
|
@ -547,17 +486,10 @@ pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option
|
|||
}
|
||||
let t = &c.type_name;
|
||||
Some(match lang {
|
||||
CodeLang::React | CodeLang::Leptos => {
|
||||
let leptos = lang == CodeLang::Leptos;
|
||||
CodeLang::React => {
|
||||
// Plain strings read best as attribute values.
|
||||
let attrs: String = args
|
||||
.iter()
|
||||
.map(|(name, expr, plain)| {
|
||||
let name = if leptos { rust_ident(name) } else { name.to_string() };
|
||||
if *plain { format!(" {name}={expr}") } else { format!(" {name}={{{expr}}}") }
|
||||
})
|
||||
.collect();
|
||||
let t = if leptos { rust_type(t) } else { t.clone() };
|
||||
let attrs: String =
|
||||
args.iter().map(|(name, expr, plain)| if *plain { format!(" {name}={expr}") } else { format!(" {name}={{{expr}}}") }).collect();
|
||||
format!("{pad}<{t}{attrs} />\n")
|
||||
}
|
||||
CodeLang::SwiftUi => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::<Vec<_>>().join(", ")),
|
||||
|
|
@ -572,20 +504,13 @@ fn slot_content(doc: &Document, slot: NodeId, lang: CodeLang, depth: usize) -> S
|
|||
let kids = ctx.kids(n);
|
||||
match lang {
|
||||
CodeLang::React => {
|
||||
let mut m = Markup::new(&ctx, CodeLang::React);
|
||||
let mut m = Markup::new(&ctx, true);
|
||||
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)
|
||||
}
|
||||
CodeLang::Leptos => {
|
||||
let mut m = Markup::new(&ctx, CodeLang::Leptos);
|
||||
for k in kids {
|
||||
m.node(k, Some(n), depth + 1, "");
|
||||
}
|
||||
format!("|| view! {{\n{}{}}}", m.markup, indent(depth))
|
||||
}
|
||||
CodeLang::SwiftUi => {
|
||||
let mut out = String::new();
|
||||
for k in kids {
|
||||
|
|
@ -610,7 +535,6 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String {
|
|||
match lang {
|
||||
CodeLang::SwiftUi => swift_definition(doc, c),
|
||||
CodeLang::Compose => compose_definition(doc, c),
|
||||
CodeLang::Leptos => leptos(doc, c),
|
||||
_ => react(doc, c),
|
||||
}
|
||||
}
|
||||
|
|
@ -621,29 +545,23 @@ fn data_attr(p: &Param) -> String {
|
|||
format!("data-{}", class_name(&p.prop, &mut used))
|
||||
}
|
||||
|
||||
/// `c`'s markup in React or Leptos (two levels in) and its stylesheet (variables as custom
|
||||
/// properties, other variants as rules on their `data-*` attributes): (root class, markup, CSS).
|
||||
fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String) {
|
||||
/// A TSX function component with its props interface, and its stylesheet (variables as custom
|
||||
/// properties, other variants as rules on their `data-*` attributes).
|
||||
fn react(doc: &Document, c: &Component) -> 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 variants: Vec<&Param> = c.params.iter().filter(|p| matches!(p.kind, ParamKind::Variant(_))).collect();
|
||||
let pass = |root: NodeId| {
|
||||
let vctx = Ctx { doc, root, comp: Some(c), base: 2 };
|
||||
let mut m = Markup::new(&vctx, lang);
|
||||
let mut m = Markup::new(&vctx, true);
|
||||
m.root_class = Some(root_class.clone());
|
||||
m.node(root, None, 2, "");
|
||||
m.rules
|
||||
};
|
||||
let mut m = Markup::new(&ctx, lang);
|
||||
let mut m = Markup::new(&ctx, true);
|
||||
m.root_class = Some(root_class.clone());
|
||||
m.root_attrs = variants
|
||||
.iter()
|
||||
.map(|p| match lang {
|
||||
CodeLang::Leptos => format!(" {}={{{}.as_str()}}", data_attr(p), rust_ident(&p.ident)),
|
||||
_ => format!(" {}={{{}}}", data_attr(p), p.ident),
|
||||
})
|
||||
.collect();
|
||||
m.root_attrs = variants.iter().map(|p| format!(" {}={{{}}}", data_attr(p), p.ident)).collect();
|
||||
m.node(c.comp, None, 2, "");
|
||||
// Other variants: what differs from this one, layer by layer.
|
||||
let mut variant_rules = String::new();
|
||||
|
|
@ -666,13 +584,6 @@ fn web(doc: &Document, c: &Component, lang: CodeLang) -> (String, String, String
|
|||
variant_rules.push_str(&format!("{sel} {{\n{}}}\n", diff.iter().map(|l| format!(" {l}\n")).collect::<String>()));
|
||||
}
|
||||
}
|
||||
let css = format!("{}{}{variant_rules}", m.tokens.root_rule(), m.styles);
|
||||
(root_class, m.markup, css)
|
||||
}
|
||||
|
||||
/// A TSX function component with its props interface, and its stylesheet.
|
||||
fn react(doc: &Document, c: &Component) -> String {
|
||||
let (root_class, markup, css) = web(doc, c, CodeLang::React);
|
||||
let t = &c.type_name;
|
||||
let mut out = format!("import \"./{root_class}.css\";\n\n");
|
||||
if c.params.is_empty() {
|
||||
|
|
@ -699,46 +610,7 @@ fn react(doc: &Document, c: &Component) -> String {
|
|||
.collect();
|
||||
out.push_str(&format!("export function {t}({{\n{}}}: {t}Props) {{\n", args.iter().map(|a| format!(" {a},\n")).collect::<String>()));
|
||||
}
|
||||
out.push_str(&format!(" return (\n{markup} );\n}}\n\n/* {root_class}.css */\n{css}"));
|
||||
out
|
||||
}
|
||||
|
||||
/// A Leptos `#[component]` with its variant enums and props (defaults as `#[prop]` attributes),
|
||||
/// and its stylesheet.
|
||||
fn leptos(doc: &Document, c: &Component) -> String {
|
||||
let (root_class, markup, css) = web(doc, c, CodeLang::Leptos);
|
||||
let mut out = String::from("use leptos::prelude::*;\n\n");
|
||||
for p in &c.params {
|
||||
let ParamKind::Variant(opts) = &p.kind else { continue };
|
||||
let (e, cases) = (leptos_enum(c, p), rust_cases(opts));
|
||||
let default = opts.iter().position(|o| Some(o.as_str()) == p.default.as_str()).unwrap_or(0);
|
||||
out.push_str(&format!("#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]\npub enum {e} {{\n"));
|
||||
for (i, case) in cases.iter().enumerate() {
|
||||
out.push_str(&format!("{} {case},\n", if i == default { " #[default]\n" } else { "" }));
|
||||
}
|
||||
out.push_str(&format!("}}\n\nimpl {e} {{\n /// The variant's name, as its `data-*` attribute.\n pub fn as_str(self) -> &'static str {{\n match self {{\n"));
|
||||
for (o, case) in opts.iter().zip(&cases) {
|
||||
out.push_str(&format!(" Self::{case} => {},\n", rust_string(o)));
|
||||
}
|
||||
out.push_str(" }\n }\n}\n\n");
|
||||
}
|
||||
let t = rust_type(&c.type_name);
|
||||
out.push_str(&format!("#[component]\npub fn {t}("));
|
||||
if !c.params.is_empty() {
|
||||
out.push('\n');
|
||||
for p in &c.params {
|
||||
let default = value_expr(doc, c, p, &p.default, CodeLang::Leptos);
|
||||
let (attr, ty) = match &p.kind {
|
||||
ParamKind::Variant(_) => ("optional".to_string(), leptos_enum(c, p)),
|
||||
ParamKind::Boolean => (format!("default = {default}"), "bool".into()),
|
||||
ParamKind::Text => (format!("into, default = {default}.to_string()"), "String".into()),
|
||||
ParamKind::InstanceSwap => (format!("into, default = ({default}).into()"), "ViewFn".into()),
|
||||
ParamKind::Slot => ("optional, into".to_string(), "Option<ViewFn>".into()),
|
||||
};
|
||||
out.push_str(&format!(" #[prop({attr})] {}: {ty},\n", rust_ident(&p.ident)));
|
||||
}
|
||||
}
|
||||
out.push_str(&format!(") -> impl IntoView {{\n view! {{\n{markup} }}\n}}\n\n/* {root_class}.css */\n{css}"));
|
||||
out.push_str(&format!(" return (\n{} );\n}}\n\n/* {root_class}.css */\n{}{}{variant_rules}", m.markup, m.tokens.root_rule(), m.styles));
|
||||
out
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! Component code: components become React / Leptos / SwiftUI / Compose components whose
|
||||
//! properties are parameters, and instances become calls passing the values they set.
|
||||
//! Component code: components become React / SwiftUI / Compose components whose properties are
|
||||
//! parameters, and instances become calls passing the values they set.
|
||||
|
||||
use crate::Session;
|
||||
use crate::codegen::{CodeLang, code_for};
|
||||
|
|
@ -71,18 +71,6 @@ fn components_take_their_properties_as_parameters() {
|
|||
for want in ["@Composable\nfun Card(", " showDot: Boolean = true,", " title: String = \"Hello\",", "if (showDot) {", "text = title,"] {
|
||||
assert!(kt.contains(want), "Compose has {want:?}:\n{kt}");
|
||||
}
|
||||
let leptos = code(&mut s, c, "leptos");
|
||||
for want in [
|
||||
"use leptos::prelude::*;\n\n#[component]\npub fn Card(\n",
|
||||
" #[prop(default = true)] show_dot: bool,\n",
|
||||
" #[prop(into, default = \"Hello\".to_string())] title: String,\n",
|
||||
") -> impl IntoView {\n view! {\n",
|
||||
"{show_dot.then(|| view! {",
|
||||
">{title.clone()}</p>",
|
||||
"/* card.css */",
|
||||
] {
|
||||
assert!(leptos.contains(want), "Leptos has {want:?}:\n{leptos}");
|
||||
}
|
||||
// The command describes the properties.
|
||||
let v = component_code(&mut s, c, "react");
|
||||
assert_eq!(v["name"], "Card");
|
||||
|
|
@ -117,13 +105,11 @@ fn instances_are_calls_passing_the_values_they_set() {
|
|||
assert!(react.contains("export function Card({"));
|
||||
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Buy\")\n");
|
||||
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Buy\")\n");
|
||||
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Card show_dot={false} title=\"Buy\" />\n");
|
||||
// Text that needs escaping is an expression in JSX and escaped per language.
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Say \"hi\" $5\nnow"}}));
|
||||
assert_eq!(component_code(&mut s, i, "react")["usage"], "<Card showDot={false} title={\"Say \\\"hi\\\" $5\\nnow\"} />\n");
|
||||
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\")\n");
|
||||
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, "leptos")["usage"], "<Card show_dot={false} title={\"Say \\\"hi\\\" $5\\nnow\"} />\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -139,9 +125,6 @@ fn nested_instances_in_a_layer_tree_are_calls() {
|
|||
assert!(!react.contains("Hello"), "the instance's layers aren't spelled out: {react}");
|
||||
assert!(code(&mut s, screen, "swiftui").contains("Card(title: \"Inbox\")"));
|
||||
assert!(code(&mut s, screen, "compose").contains("Card(title = \"Inbox\")"));
|
||||
let leptos = code(&mut s, screen, "leptos");
|
||||
assert!(leptos.contains("pub fn HomeScreen() -> impl IntoView {") && leptos.contains("<Card title=\"Inbox\" />"), "{leptos}");
|
||||
assert!(!leptos.contains("Hello"), "{leptos}");
|
||||
// HTML and CSS stay plain markup.
|
||||
assert!(code(&mut s, screen, "html").contains("Inbox"));
|
||||
}
|
||||
|
|
@ -173,23 +156,12 @@ fn variant_sets_share_one_definition() {
|
|||
let kt = code(&mut s, set, "compose");
|
||||
assert!(kt.contains("enum class ButtonState { Default, Hover }"), "{kt}");
|
||||
assert!(kt.contains(" state: ButtonState = ButtonState.Default,"), "{kt}");
|
||||
let leptos = code(&mut s, set, "leptos");
|
||||
for want in [
|
||||
"#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]\npub enum ButtonState {\n #[default]\n Default,\n Hover,\n}",
|
||||
" Self::Hover => \"Hover\",",
|
||||
" #[prop(optional)] state: ButtonState,",
|
||||
"data-state={state.as_str()}",
|
||||
".button[data-state=\"Hover\"] .bg {\n background: #0000FF;",
|
||||
] {
|
||||
assert!(leptos.contains(want), "Leptos has {want:?}:\n{leptos}");
|
||||
}
|
||||
// An instance showing the hover variant passes it.
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 600})));
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"State": "Hover"}}));
|
||||
assert_eq!(component_code(&mut s, i, "react")["usage"], "<Button state=\"Hover\" />\n");
|
||||
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Button(state: .hover)\n");
|
||||
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Button(state = ButtonState.Hover)\n");
|
||||
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Button state=ButtonState::Hover />\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -212,15 +184,11 @@ fn instance_swap_properties_take_views() {
|
|||
assert!(swift.contains(" var icon: AnyView = AnyView(HeartIcon())"), "{swift}");
|
||||
let kt = code(&mut s, chip, "compose");
|
||||
assert!(kt.contains(" icon: @Composable () -> Unit = { HeartIcon() },") && kt.contains("icon()"), "{kt}");
|
||||
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 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");
|
||||
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Chip(icon: AnyView(StarIcon()))\n");
|
||||
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Chip(icon = { StarIcon() })\n");
|
||||
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Chip icon={|| view! { <StarIcon /> }} />\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -235,9 +203,6 @@ fn slots_take_optional_content() {
|
|||
assert!(react.contains(" content?: React.ReactNode;") && react.contains("{content ?? ("), "{react}");
|
||||
assert!(code(&mut s, panel, "swiftui").contains("var content: AnyView? = nil"));
|
||||
assert!(code(&mut s, panel, "compose").contains("content: (@Composable () -> Unit)? = null,"));
|
||||
let leptos = code(&mut s, panel, "leptos");
|
||||
assert!(leptos.contains(" #[prop(optional, into)] content: Option<ViewFn>,"), "{leptos}");
|
||||
assert!(leptos.contains("{match &content { Some(c) => c.run(), None => view! {\n") && leptos.contains("}.into_any() }}\n"), "{leptos}");
|
||||
// An instance filling the slot passes its content.
|
||||
let g = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 400, "width": 40, "height": 20, "name": "Badge"})));
|
||||
let badge = idv(&ex(&mut s, "component.create", json!({"ids": [g]})));
|
||||
|
|
@ -246,8 +211,6 @@ fn slots_take_optional_content() {
|
|||
ex(&mut s, "instance.slotInsert", json!({"id": i, "slot": "Content", "component": badge, "replace": true}));
|
||||
let usage = component_code(&mut s, i, "react")["usage"].as_str().unwrap_or_default().to_string();
|
||||
assert!(usage.starts_with("<Panel content={(\n") && usage.contains("<Badge />"), "{usage}");
|
||||
let usage = component_code(&mut s, i, "leptos")["usage"].as_str().unwrap_or_default().to_string();
|
||||
assert!(usage.starts_with("<Panel content={|| view! {\n") && usage.contains("<Badge />") && usage.ends_with("}} />\n"), "{usage}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -262,8 +225,6 @@ fn identifiers_are_escaped_and_unique() {
|
|||
("label", "text", json!("b")),
|
||||
("2 columns", "boolean", json!(false)),
|
||||
("Color", "variant", json!("Red")),
|
||||
("type", "text", json!("c")),
|
||||
("Props", "variant", json!("Self")),
|
||||
] {
|
||||
ex(&mut s, "component.addProperty", json!({"id": c, "name": name, "type": ty, "default": default}));
|
||||
}
|
||||
|
|
@ -281,20 +242,6 @@ fn identifiers_are_escaped_and_unique() {
|
|||
assert_eq!(color.as_deref(), Some("ColorVariant"), "never shadows SwiftUI's Color");
|
||||
let swift = code_for(doc, c, CodeLang::SwiftUi);
|
||||
assert!(swift.contains("struct TextComponent: View {") && swift.contains("enum ColorVariant: String, CaseIterable {"), "{swift}");
|
||||
// Rust: snake_case, keywords suffixed, never the props struct's name or `Self`.
|
||||
let leptos = code_for(doc, c, CodeLang::Leptos);
|
||||
for want in [
|
||||
"pub fn TextComponent(",
|
||||
"] default_value: bool,",
|
||||
"] show_icon: bool,",
|
||||
"] p2_columns: bool,",
|
||||
"] type_: String,",
|
||||
"pub enum TextComponentPropsVariant {",
|
||||
" SelfValue,",
|
||||
"Self::SelfValue => \"Self\",",
|
||||
] {
|
||||
assert!(leptos.contains(want), "Leptos has {want:?}:\n{leptos}");
|
||||
}
|
||||
// Errors.
|
||||
let r = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})));
|
||||
assert!(s.execute("inspect.componentCode", &json!({"id": r})).is_err(), "not a component");
|
||||
|
|
|
|||
|
|
@ -57,23 +57,3 @@ fn class_names_are_valid_and_deep_trees_are_bounded() {
|
|||
// Nesting stops at the depth limit instead of running away.
|
||||
assert!(code.matches("<div").count() <= 14, "{}", code.matches("<div").count());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leptos_wraps_the_markup_in_a_component_with_text_as_string_literals() {
|
||||
let mut s = Session::new();
|
||||
let card = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 120})).unwrap());
|
||||
s.execute("node.set", &json!({"id": card, "props": {"name": "Card"}})).unwrap();
|
||||
let t = id(s.execute("text.create", &json!({"text": "Say \"hi\" {x}\nسلام", "x": 0, "y": 60})).unwrap());
|
||||
s.execute("node.reparent", &json!({"ids": [t], "parent": card})).unwrap();
|
||||
|
||||
let code = code_for(s.doc().unwrap(), card, CodeLang::Leptos);
|
||||
assert!(
|
||||
code.starts_with("use leptos::prelude::*;\n\n#[component]\npub fn Card() -> impl IntoView {\n view! {\n <div class=\"card\">"),
|
||||
"{code}"
|
||||
);
|
||||
// Quotes and braces stay inside Rust string literals; lines break with a self-closed tag.
|
||||
assert!(code.contains(r#">"Say \"hi\" {x}"<br/>"سلام"</p>"#), "{code}");
|
||||
assert!(code.contains("/* card.css */\n") && code.contains(".card {\n position: relative;\n"), "{code}");
|
||||
let out = s.execute("inspect.code", &json!({"id": card, "language": "leptos", "unit": "rem"})).unwrap();
|
||||
assert!(out["code"].as_str().unwrap().contains("width: 12.5rem;"), "{}", out["code"]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -162,7 +162,6 @@ pub static KEYS: &[Binding] = &[
|
|||
k(Essential, "app.actions", &["Cmd+K", "Cmd+/"]).label("Actions…"),
|
||||
k(Essential, "help.shortcuts", &["Ctrl+Shift+?"]).both(),
|
||||
k(Essential, "view.toggleUi", &["Cmd+\\"]),
|
||||
k(Essential, "view.fullscreen", &["Ctrl+Cmd+F"]).win(&["F11"]),
|
||||
k(Essential, "edit.pickColor", &["I", "Ctrl+C"]).win(&["I"]),
|
||||
k(Essential, "app.preferences", &["Cmd+,"]),
|
||||
k(Essential, "file.new", &["Cmd+N"]),
|
||||
|
|
|
|||
|
|
@ -1066,57 +1066,24 @@ impl Session {
|
|||
}
|
||||
|
||||
/// 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.
|
||||
/// own, and when it fails the document, selection, page and history are put back.
|
||||
pub fn grouped<T>(&mut self, label: &str, f: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
|
||||
self.ds()?;
|
||||
let before: Vec<_> =
|
||||
self.docs.iter().map(|ds| (ds.uid, ds.doc.clone(), ds.selection.clone(), ds.page, ds.history.undo.len(), ds.history.limit)).collect();
|
||||
let active = self.active;
|
||||
let journal_len = self.journal.len();
|
||||
|
||||
let ds = self.ds_mut()?;
|
||||
let (before, sel, page, saved_len, uid) = (ds.doc.clone(), ds.selection.clone(), ds.page, ds.history.undo.len(), ds.uid);
|
||||
// The steps inside are dropped again below, so they mustn't push the oldest ones out.
|
||||
for ds in &mut self.docs {
|
||||
ds.history.limit = usize::MAX;
|
||||
}
|
||||
for (uid, _, _, _, _, _) in &before {
|
||||
self.live_hold(*uid, true);
|
||||
}
|
||||
let limit = std::mem::replace(&mut ds.history.limit, usize::MAX);
|
||||
self.live_hold(uid, true);
|
||||
let result = f(self);
|
||||
for (uid, _, _, _, _, _) in &before {
|
||||
self.live_hold(*uid, false);
|
||||
}
|
||||
|
||||
self.live_hold(uid, false);
|
||||
let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) else { return result };
|
||||
ds.history.limit = limit;
|
||||
ds.history.undo.truncate(saved_len);
|
||||
if result.is_err() {
|
||||
self.docs.retain(|ds| before.iter().any(|(uid, _, _, _, _, _)| *uid == ds.uid));
|
||||
for (uid, doc, selection, page, undo_len, limit) in &before {
|
||||
if let Some(ds) = self.docs.iter_mut().find(|ds| ds.uid == *uid) {
|
||||
ds.doc = doc.clone();
|
||||
ds.selection = selection.clone();
|
||||
ds.page = *page;
|
||||
ds.history.undo.truncate(*undo_len);
|
||||
ds.history.limit = *limit;
|
||||
}
|
||||
}
|
||||
self.active = active;
|
||||
self.journal.truncate(journal_len);
|
||||
return result;
|
||||
}
|
||||
|
||||
let changed: Vec<_> = before
|
||||
.iter()
|
||||
.filter_map(|(uid, doc, selection, page, _, _)| {
|
||||
let ds = self.docs.iter().find(|d| d.uid == *uid)?;
|
||||
(!Arc::ptr_eq(doc, &ds.doc) && ds.interaction.is_none()).then(|| (*uid, doc.clone(), selection.clone(), *page))
|
||||
})
|
||||
.collect();
|
||||
for ds in &mut self.docs {
|
||||
if let Some((_, _, _, _, undo_len, limit)) = before.iter().find(|before| before.0 == ds.uid) {
|
||||
ds.history.limit = *limit;
|
||||
ds.history.undo.truncate(*undo_len);
|
||||
}
|
||||
}
|
||||
for (uid, doc, selection, page) in changed {
|
||||
self.record(uid, Some(label), doc, selection, page);
|
||||
ds.doc = before;
|
||||
ds.selection = sel;
|
||||
ds.page = page;
|
||||
} else if !Arc::ptr_eq(&before, &ds.doc) {
|
||||
self.record(uid, Some(label), before, sel, page);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
|
@ -1380,8 +1347,6 @@ mod arc_handles_tests;
|
|||
#[cfg(test)]
|
||||
mod autosave_tests;
|
||||
#[cfg(test)]
|
||||
mod batch_rollback_tests;
|
||||
#[cfg(test)]
|
||||
mod binding_lists_tests;
|
||||
#[cfg(test)]
|
||||
mod booleans_tests;
|
||||
|
|
@ -1494,8 +1459,6 @@ mod nested_override_tests;
|
|||
#[cfg(test)]
|
||||
mod online_tests;
|
||||
#[cfg(test)]
|
||||
mod open_issues_tests;
|
||||
#[cfg(test)]
|
||||
mod outline_options_tests;
|
||||
#[cfg(test)]
|
||||
mod paint_reorder_tests;
|
||||
|
|
@ -1514,7 +1477,7 @@ mod plugin_directory_tests;
|
|||
#[cfg(test)]
|
||||
mod plugin_panels_tests;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod plugins_tests;
|
||||
mod plugins_tests;
|
||||
#[cfg(test)]
|
||||
mod progressive_blur_tests;
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
//! Opening a native file that lost something (a damaged image): the session says so, with the
|
||||
//! reason, and the file still opens.
|
||||
|
||||
use figcraft_doc::Document;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A 1×1 PNG.
|
||||
const PNG: [u8; 67] = [
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x60, 0x00, 0x00, 0x02,
|
||||
0x00, 0x00, 0x01, 0xe2, 0x21, 0xbc, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
];
|
||||
|
||||
fn file(name: &str, damage: bool) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-open-issues-{}-{name}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut d = Document::new("Pics");
|
||||
figcraft_format::add_image(&mut d, PNG.to_vec()).unwrap();
|
||||
let mut bytes = figcraft_format::save_bytes(&d, None).unwrap();
|
||||
if damage {
|
||||
// Images are stored as they are: change one byte of the PNG, not its checksum.
|
||||
let at = bytes.windows(PNG.len()).position(|w| w == PNG).unwrap();
|
||||
bytes[at + 30] ^= 0xff;
|
||||
}
|
||||
let path = dir.join("pics.figcraft");
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_with_a_damaged_image_opens_and_says_what_was_lost() {
|
||||
let path = file("damaged", true);
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert!(r["repairs"].as_array().is_some_and(|a| a.len() == 1), "{r}");
|
||||
let m = s.message.take().expect("the session says so");
|
||||
assert!(m.starts_with("pics: image ") && m.contains("couldn't be read") && m.contains("drops it"), "{m}");
|
||||
let _ = std::fs::remove_dir_all(path.parent().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_intact_file_opens_without_a_message() {
|
||||
let path = file("intact", false);
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert!(r["repairs"].as_array().is_some_and(Vec::is_empty), "{r}");
|
||||
assert!(s.message.is_none());
|
||||
let _ = std::fs::remove_dir_all(path.parent().unwrap());
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ use figcraft_doc::NodeId;
|
|||
/// What a test plugin does when a call is refused: fail the run (status 1), or carry on and report
|
||||
/// the refusal as its output.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
pub(crate) enum OnRefusal {
|
||||
enum OnRefusal {
|
||||
Fail,
|
||||
Report,
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@ pub(crate) fn lit(s: &str) -> String {
|
|||
|
||||
/// A module that reads the selection, makes `calls` in order and outputs the selection it read
|
||||
/// (or the first refusal). `tail` runs after the calls (an endless loop, a trap…).
|
||||
pub(crate) fn script(calls: &[(&str, Value)], on_refusal: OnRefusal, tail: &str) -> Vec<u8> {
|
||||
fn script(calls: &[(&str, Value)], on_refusal: OnRefusal, tail: &str) -> Vec<u8> {
|
||||
let mut data = String::new();
|
||||
let mut body = String::from("(local.set $sel (call $reply_read (i32.const 60000) (call $selection)))\n");
|
||||
let mut at = 0usize;
|
||||
|
|
@ -114,7 +114,7 @@ fn a_run_reads_the_selection_and_its_edits_are_one_undo_step() {
|
|||
let (layers, undo) = (layer_count(&s), undo_len(&s));
|
||||
let out = ex(&mut s, "plugin.run", json!({"id": "org.test.tidy"}));
|
||||
assert_eq!(out["output"], json!([a.0]), "the plugin read the selection");
|
||||
assert_eq!(out["calls"], json!(6));
|
||||
assert_eq!(out["calls"], json!(4));
|
||||
assert_eq!(node(&s, a).name, "Tidy");
|
||||
assert_eq!(layer_count(&s), layers + 1);
|
||||
assert_eq!(undo_len(&s), undo + 1, "one undo step for the whole run");
|
||||
|
|
@ -197,11 +197,9 @@ fn traps_endless_loops_and_memory_hogs_roll_back() {
|
|||
let a = rect(&mut s, 0.0);
|
||||
ex(&mut s, "select.set", json!({"ids": [a]}));
|
||||
let edit = [("shape.rectangle", json!({"x": 90, "y": 0, "width": 5, "height": 5})), ("object.rename", json!({"name": "Half done"}))];
|
||||
let bulk_output = "(call $set_output (i32.const 0) (i32.const 65536))".repeat(32);
|
||||
let cases = [
|
||||
("org.test.trap", "unreachable", "crashed"),
|
||||
("org.test.loop", "(loop $l (br $l))", "instruction budget"),
|
||||
("org.test.bulk", bulk_output.as_str(), "instruction budget"),
|
||||
("org.test.grow", "(if (i32.eq (memory.grow (i32.const 2000)) (i32.const -1)) (then unreachable))", "crashed"),
|
||||
];
|
||||
for (id, tail, want) in cases {
|
||||
|
|
|
|||
|
|
@ -91,7 +91,6 @@ static ROWS: &[Row] = &[
|
|||
doc(Essential, "Save to version history", &["Alt+Cmd+S"], &["Ctrl+Alt+S"], Bound("file.saveVersion"), ""),
|
||||
doc(Essential, "Run the last plugin", &["Alt+Cmd+P"], &["Ctrl+Alt+P"], Moved("plugins.runLast", &["Alt+Cmd+P"], &["Ctrl+Alt+Shift+P"]),
|
||||
"conflict: the documentation also gives Ctrl+Alt+P to pixel preview on Windows; pixel preview keeps it, the plugin takes Ctrl+Alt+Shift+P"),
|
||||
pin(Essential, "view.fullscreen", &["Ctrl+Cmd+F"], &["F11"]),
|
||||
pin(Essential, "app.preferences", &["Cmd+,"], &[]),
|
||||
pin(Essential, "file.new", &["Cmd+N"], &[]).reserved(BROWSER_TAB),
|
||||
pin(Essential, "file.open", &["Cmd+O"], &[]),
|
||||
|
|
@ -398,7 +397,7 @@ fn counts_match_the_docs() {
|
|||
let reserved = documented.iter().filter(|r| !r.reserved.is_empty()).count();
|
||||
let pinned = ROWS.len() - documented.len();
|
||||
// docs/ui-parity.md quotes these.
|
||||
assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (129, 128, 1, 0, 2, 49));
|
||||
assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (129, 128, 1, 0, 2, 48));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -76,36 +76,6 @@ fn node_set_and_errors_roll_back() {
|
|||
assert!(s.execute("no.such", &json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_batch_restores_all_documents_and_history() {
|
||||
let mut s = sess();
|
||||
let original = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
s.execute("file.new", &json!({"name": "Second"})).unwrap();
|
||||
s.execute("doc.nextTab", &json!({})).unwrap();
|
||||
let before_journal = s.journal.clone();
|
||||
let before_history: Vec<_> = s.docs.iter().map(|d| d.history.undo.len()).collect();
|
||||
|
||||
let result = s.execute(
|
||||
"command.batch",
|
||||
&json!({
|
||||
"label": "Broken batch",
|
||||
"commands": [
|
||||
{"command": "doc.nextTab"},
|
||||
{"command": "shape.rectangle", "params": {"x": 20, "y": 20, "width": 10, "height": 10}},
|
||||
{"command": "node.set", "params": {"id": 999999, "props": {"name": "must fail"}}}
|
||||
]
|
||||
}),
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
assert_eq!(s.active, 0, "the active document is part of the transaction");
|
||||
assert_eq!(s.docs.len(), 2, "a failed batch cannot leave a newly opened tab behind");
|
||||
assert_eq!(s.doc().unwrap().node(original).unwrap().name, "Rectangle");
|
||||
assert_eq!(s.docs[1].doc.node(s.docs[1].page).map_or(0, |page| page.children.len()), 0);
|
||||
assert_eq!(s.docs.iter().map(|d| d.history.undo.len()).collect::<Vec<_>>(), before_history);
|
||||
assert_eq!(s.journal, before_journal, "failed inner commands are not journaled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_ungroup_and_frame() {
|
||||
let mut s = sess();
|
||||
|
|
|
|||
|
|
@ -1,88 +0,0 @@
|
|||
//! Images a native file can't give back: damaged or missing members are reported, not dropped
|
||||
//! without a word.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use figcraft_doc::{Document, NodeKind};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn png() -> Vec<u8> {
|
||||
let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([9, 0, 0, 255]));
|
||||
let mut out = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
fn doc_with_image() -> (Document, String) {
|
||||
let mut d = Document::new("File");
|
||||
let p = d.first_page().unwrap();
|
||||
d.create(p, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
let (hash, ..) = add_image(&mut d, png()).unwrap();
|
||||
(d, hash)
|
||||
}
|
||||
|
||||
/// The archive of `d` rebuilt with `edit` applied to each member (None drops it).
|
||||
fn rebuild(bytes: &[u8], edit: impl Fn(&str, Vec<u8>) -> Option<Vec<u8>>) -> Vec<u8> {
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let names: Vec<String> = z.file_names().map(str::to_string).collect();
|
||||
let mut buf = std::io::Cursor::new(Vec::new());
|
||||
let mut w = zip::ZipWriter::new(&mut buf);
|
||||
for n in names {
|
||||
let data = read_member(&mut z, &n, u64::MAX).unwrap();
|
||||
if let Some(data) = edit(&n, data) {
|
||||
w.start_file(n, zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored)).unwrap();
|
||||
w.write_all(&data).unwrap();
|
||||
}
|
||||
}
|
||||
w.finish().unwrap();
|
||||
buf.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_intact_file_loads_without_image_issues() {
|
||||
let (d, hash) = doc_with_image();
|
||||
let (loaded, issues) = load_bytes(&save_bytes(&d, None).unwrap()).unwrap();
|
||||
assert!(issues.is_empty(), "{issues:?}");
|
||||
assert!(loaded.images.contains_key(&hash));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_image_member_is_reported() {
|
||||
let (d, hash) = doc_with_image();
|
||||
let bytes = rebuild(&save_bytes(&d, None).unwrap(), |n, data| (!n.starts_with("images/")).then_some(data));
|
||||
let (loaded, issues) = load_bytes(&bytes).unwrap();
|
||||
assert!(!loaded.images.contains_key(&hash));
|
||||
assert!(issues.iter().any(|i| i.contains(&hash) && i.contains("missing") && i.contains("drops it")), "{issues:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_image_member_is_reported_with_its_reason() {
|
||||
let (d, hash) = doc_with_image();
|
||||
let bytes = save_bytes(&d, None).unwrap();
|
||||
// The member's bytes change but not its checksum: reading it fails.
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
let name = format!("images/{hash}");
|
||||
let stored = read_member(&mut z, &name, u64::MAX).unwrap();
|
||||
let at = bytes.windows(stored.len()).position(|w| w == stored).unwrap();
|
||||
let mut damaged = bytes.clone();
|
||||
damaged[at + stored.len() / 2] ^= 0xff;
|
||||
let (loaded, issues) = load_bytes(&damaged).unwrap();
|
||||
assert!(!loaded.images.contains_key(&hash));
|
||||
assert!(issues.iter().any(|i| i.contains(&hash) && i.contains("couldn't be read") && i.contains("drops it")), "{issues:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_lost_images_are_counted_not_listed_one_by_one() {
|
||||
let mut d = Document::new("File");
|
||||
for k in 0..40u8 {
|
||||
let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([k, 1, 0, 255]));
|
||||
let mut out = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
|
||||
add_image(&mut d, out).unwrap();
|
||||
}
|
||||
let bytes = rebuild(&save_bytes(&d, None).unwrap(), |n, data| (!n.starts_with("images/")).then_some(data));
|
||||
let (_, issues) = load_bytes(&bytes).unwrap();
|
||||
assert_eq!(issues.len(), MAX_IMAGE_ISSUES + 1, "{issues:?}");
|
||||
assert_eq!(issues.last().map(String::as_str), Some("20 more images couldn't be loaded"));
|
||||
}
|
||||
|
|
@ -465,17 +465,11 @@ pub fn load_bytes_with_versions(bytes: &[u8]) -> Result<Loaded, FormatError> {
|
|||
let (mut doc, mut issues, own_images) = parse_json(&text)?;
|
||||
let names: Vec<String> = z.file_names().filter(|n| n.starts_with("images/")).map(str::to_string).collect();
|
||||
let mut images = BTreeMap::new();
|
||||
let mut unread = Vec::new();
|
||||
for name in names {
|
||||
let Ok(data) = read_member(&mut z, &name, MAX_MEMBER) else { continue };
|
||||
let hash = name.trim_start_matches("images/").to_string();
|
||||
match read_member(&mut z, &name, MAX_MEMBER) {
|
||||
Ok(data) => {
|
||||
images.insert(hash, image_data(data));
|
||||
}
|
||||
Err(e) => unread.push((hash, e)),
|
||||
}
|
||||
images.insert(hash, image_data(data));
|
||||
}
|
||||
report_lost_images(&mut issues, &unread, own_images.as_ref(), &images);
|
||||
match library::read(&mut z, &images, u64::MAX) {
|
||||
Ok(lib) => doc.published = lib.map(Arc::new),
|
||||
Err(e) => issues.push(format!("the published library was skipped: {e}")),
|
||||
|
|
@ -496,42 +490,6 @@ pub fn load_bytes_with_versions(bytes: &[u8]) -> Result<Loaded, FormatError> {
|
|||
Ok(Loaded { doc, issues, versions, branches })
|
||||
}
|
||||
|
||||
/// Most lost images reported one by one (the rest as a count).
|
||||
const MAX_IMAGE_ISSUES: usize = 20;
|
||||
|
||||
/// Report the images a file lost on loading: members that couldn't be read (damaged, too large)
|
||||
/// and images its document lists but the archive doesn't hold. They are left out, so saving the
|
||||
/// file again would drop them for good.
|
||||
fn report_lost_images(
|
||||
issues: &mut Vec<String>,
|
||||
unread: &[(String, FormatError)],
|
||||
listed: Option<&BTreeSet<String>>,
|
||||
images: &BTreeMap<String, ImageData>,
|
||||
) {
|
||||
let mut lost: Vec<String> = unread
|
||||
.iter()
|
||||
.map(|(hash, e)| {
|
||||
let why = match e {
|
||||
FormatError::Corrupt(m) | FormatError::Io(m) => m.clone(),
|
||||
FormatError::Newer(v) => format!("format version {v}"),
|
||||
};
|
||||
format!("image {hash} couldn't be read ({why}): it is left out, and saving this file again drops it")
|
||||
})
|
||||
.collect();
|
||||
let unread_hashes: BTreeSet<&str> = unread.iter().map(|(h, _)| h.as_str()).collect();
|
||||
for hash in listed.into_iter().flatten() {
|
||||
if !images.contains_key(hash) && !unread_hashes.contains(hash.as_str()) {
|
||||
lost.push(format!("image {hash} is used by the file but its data is missing: it is left out, and saving this file again drops it"));
|
||||
}
|
||||
}
|
||||
let more = lost.len().saturating_sub(MAX_IMAGE_ISSUES);
|
||||
lost.truncate(MAX_IMAGE_ISSUES);
|
||||
issues.extend(lost);
|
||||
if more > 0 {
|
||||
issues.push(format!("{more} more images couldn't be loaded"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The version history kept under `dir` in an archive (none when it has no index), reading at
|
||||
/// most `budget` bytes of snapshots. Damaged entries are skipped and reported in `issues`; nothing
|
||||
/// here fails the load.
|
||||
|
|
@ -672,8 +630,6 @@ pub fn load_file(path: &std::path::Path) -> Result<(Document, Vec<String>), Form
|
|||
#[cfg(test)]
|
||||
mod branch_tests;
|
||||
#[cfg(test)]
|
||||
mod image_tests;
|
||||
#[cfg(test)]
|
||||
mod large_tests;
|
||||
#[cfg(test)]
|
||||
mod version_tests;
|
||||
|
|
|
|||
|
|
@ -63,8 +63,7 @@ const IMPORTS: &[(&str, usize, usize)] = &[
|
|||
/// What a run may use.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Limits {
|
||||
/// Instruction budget (wasmi fuel). Each host call costs [`Limits::call_fuel`] more,
|
||||
/// plus one fuel per byte copied out of or into guest memory.
|
||||
/// Instruction budget (wasmi fuel). Each host call costs [`Limits::call_fuel`] more.
|
||||
pub fuel: u64,
|
||||
pub call_fuel: u64,
|
||||
/// Linear memory cap in bytes.
|
||||
|
|
@ -282,10 +281,10 @@ fn compile(engine: &Engine, bytes: &[u8], limits: &Limits) -> Result<Module, Plu
|
|||
}
|
||||
}
|
||||
let export = |name: &str| module.exports().find(|e| e.name() == name).map(|e| e.ty().clone());
|
||||
if export("memory").is_none_or(|t| t.memory().is_none()) {
|
||||
if !export("memory").is_some_and(|t| t.memory().is_some()) {
|
||||
return Err(PluginError::InvalidModule("it doesn't export its `memory`".into()));
|
||||
}
|
||||
if export(ENTRY).is_none_or(|t| t.func() != Some(&func_type(1, 1))) {
|
||||
if !export(ENTRY).is_some_and(|t| t.func() == Some(&func_type(1, 1))) {
|
||||
return Err(PluginError::InvalidModule(format!("it doesn't export `{ENTRY}(input_len: i32) -> i32`")));
|
||||
}
|
||||
Ok(module)
|
||||
|
|
@ -400,7 +399,11 @@ fn ask(caller: &mut Ctx<'_>, request: Request) -> wasmi::Error {
|
|||
/// Charge a host call: fuel and the call count.
|
||||
fn charge(caller: &mut Ctx<'_>) -> Result<(), wasmi::Error> {
|
||||
let cost = caller.data().limits.call_fuel;
|
||||
spend_fuel(caller, cost)?;
|
||||
let left = caller.get_fuel()?;
|
||||
if left < cost {
|
||||
return Err(abort(caller, PluginError::OutOfFuel));
|
||||
}
|
||||
caller.set_fuel(left - cost)?;
|
||||
let st = caller.data_mut();
|
||||
st.out.calls = st.out.calls.saturating_add(1);
|
||||
if st.out.calls > st.limits.max_calls {
|
||||
|
|
@ -410,21 +413,12 @@ fn charge(caller: &mut Ctx<'_>) -> Result<(), wasmi::Error> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Reserve work before copying or allocating, including when call overhead is disabled.
|
||||
fn spend_fuel(caller: &mut Ctx<'_>, cost: u64) -> Result<(), wasmi::Error> {
|
||||
let Some(left) = caller.get_fuel()?.checked_sub(cost) else {
|
||||
return Err(abort(caller, PluginError::OutOfFuel));
|
||||
};
|
||||
caller.set_fuel(left)
|
||||
}
|
||||
|
||||
/// The bytes at `(ptr, len)` in the plugin's memory.
|
||||
fn read_bytes(caller: &mut Ctx<'_>, ptr: i32, len: i32) -> Result<Vec<u8>, wasmi::Error> {
|
||||
let (ptr, len) = (ptr as u32 as usize, len as u32 as usize);
|
||||
if len > caller.data().limits.max_message {
|
||||
return Err(abort(caller, PluginError::Limit(format!("a {len}-byte string is too long"))));
|
||||
}
|
||||
spend_fuel(caller, len as u64)?;
|
||||
let Some(mem) = caller.get_export("memory").and_then(Extern::into_memory) else {
|
||||
return Err(abort(caller, PluginError::InvalidModule("no memory".into())));
|
||||
};
|
||||
|
|
@ -478,20 +472,14 @@ fn linker(engine: &Engine) -> Result<Linker<State>, PluginError> {
|
|||
Err(ask(&mut c, Request::Selection))
|
||||
})
|
||||
.map_err(err)?;
|
||||
l.func_wrap(IMPORT_MODULE, "reply_len", |mut c: Ctx<'_>| -> Result<i32, wasmi::Error> {
|
||||
charge(&mut c)?;
|
||||
Ok(i32::try_from(c.data().reply.len()).unwrap_or(i32::MAX))
|
||||
})
|
||||
.map_err(err)?;
|
||||
l.func_wrap(IMPORT_MODULE, "reply_len", |c: Ctx<'_>| -> i32 { i32::try_from(c.data().reply.len()).unwrap_or(i32::MAX) }).map_err(err)?;
|
||||
l.func_wrap(IMPORT_MODULE, "reply_read", |mut c: Ctx<'_>, ptr: i32, len: i32| -> Result<i32, wasmi::Error> {
|
||||
charge(&mut c)?;
|
||||
let (ptr, want) = (ptr as u32 as usize, len as u32 as usize);
|
||||
let n = want.min(c.data().reply.len());
|
||||
spend_fuel(&mut c, n as u64)?;
|
||||
let Some(mem) = c.get_export("memory").and_then(Extern::into_memory) else {
|
||||
return Err(abort(&mut c, PluginError::InvalidModule("no memory".into())));
|
||||
};
|
||||
let (data, st) = mem.data_and_store_mut(&mut c);
|
||||
let n = want.min(st.reply.len());
|
||||
let src = st.reply.get(..n).ok_or(TrapCode::MemoryOutOfBounds)?;
|
||||
let dst = ptr.checked_add(n).and_then(|end| data.get_mut(ptr..end)).ok_or(TrapCode::MemoryOutOfBounds)?;
|
||||
dst.copy_from_slice(src);
|
||||
|
|
@ -512,7 +500,6 @@ fn linker(engine: &Engine) -> Result<Linker<State>, PluginError> {
|
|||
})
|
||||
.map_err(err)?;
|
||||
l.func_wrap(IMPORT_MODULE, "set_output", |mut c: Ctx<'_>, ptr: i32, len: i32| -> Result<(), wasmi::Error> {
|
||||
charge(&mut c)?;
|
||||
let out = read_str(&mut c, ptr, len)?;
|
||||
c.data_mut().out.output = out;
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ fn echoes_input_calls_commands_and_reads_replies() {
|
|||
assert_eq!(host.logs, out.logs);
|
||||
assert_eq!(host.calls, vec![("shape.rectangle".into(), r#"{"x":1}"#.into())]);
|
||||
assert_eq!(host.notes, vec!["made it".to_string()]);
|
||||
assert_eq!(out.calls, 7);
|
||||
assert_eq!(out.calls, 3);
|
||||
assert!(out.fuel > 0);
|
||||
}
|
||||
|
||||
|
|
@ -142,9 +142,7 @@ fn logs_over_the_default_byte_budget_fail_before_forwarding() {
|
|||
(local.set $i (i32.add (local.get $i) (i32.const 1)))
|
||||
(br_if $logs (i32.lt_u (local.get $i) (i32.const 129)))) (i32.const 0)",
|
||||
);
|
||||
// The default fuel: copying a megabyte of log bytes out of the plugin is itself charged.
|
||||
let mut host = Mock::default();
|
||||
let result = run(&m, "{}", &Limits::default(), &mut host);
|
||||
let (result, host) = go(&m, "{}");
|
||||
assert!(matches!(result, Err(PluginError::Limit(_))), "logs over 1 MiB must fail the run");
|
||||
assert_eq!(host.logs.len(), 128);
|
||||
assert_eq!(host.logs.iter().map(String::len).sum::<usize>(), 1024 * 1024);
|
||||
|
|
@ -256,41 +254,6 @@ fn logs_still_observe_the_general_message_limit() {
|
|||
assert!(host.logs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_and_output_imports_obey_host_call_limit() {
|
||||
for call in
|
||||
["(drop (call $reply_len))", "(drop (call $reply_read (i32.const 0) (i32.const 2)))", "(call $set_output (i32.const 0) (i32.const 2))"]
|
||||
{
|
||||
let m = module(r#"(data (i32.const 0) "ok")"#, &format!("{call} {call} (i32.const 0)"));
|
||||
let limits = Limits { max_calls: 1, ..small() };
|
||||
assert!(matches!(run(&m, "{}", &limits, &mut Mock::default()), Err(PluginError::Limit(_))), "{call}");
|
||||
let limits = Limits { max_calls: 2, ..small() };
|
||||
assert_eq!(run(&m, "{}", &limits, &mut Mock::default()).unwrap().calls, 2, "{call}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_bulk_imports_spend_fuel_for_bytes_even_without_call_overhead() {
|
||||
for call in ["(drop (call $reply_read (i32.const 0) (i32.const 4096)))", "(call $set_output (i32.const 0) (i32.const 4096))"] {
|
||||
let m = module("", &format!("{} (i32.const 0)", call.repeat(16)));
|
||||
let limits = Limits { fuel: 40_000, call_fuel: 0, ..small() };
|
||||
assert_eq!(run(&m, &"x".repeat(4096), &limits, &mut Mock::default()), Err(PluginError::OutOfFuel), "{call}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_copy_charges_actual_bytes_and_each_callback_gets_fresh_limits() {
|
||||
let m = module("", "(call $set_output (i32.const 0) (call $reply_read (i32.const 0) (i32.const 2147483647))) (i32.const 0)");
|
||||
let limits = Limits { max_calls: 2, fuel: 1000, call_fuel: 0, ..small() };
|
||||
let mut instance = crate::Instance::new(&m, &limits).unwrap();
|
||||
assert_eq!(instance.call(&"x".repeat(2048), &mut Mock::default()), Err(PluginError::OutOfFuel));
|
||||
for input in ["hello", "again"] {
|
||||
let out = instance.call(input, &mut Mock::default()).unwrap();
|
||||
assert_eq!(out.output, input);
|
||||
assert_eq!(out.calls, 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_is_capped() {
|
||||
// Declared larger than the cap: refused at instantiation.
|
||||
|
|
|
|||
|
|
@ -133,23 +133,3 @@ fn one_sided_widths_paint_one_side() {
|
|||
let b = d.world_render_bounds(l).unwrap();
|
||||
assert!(b.y1 >= 120.0 - 1e-6, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_boolean_nesting_stops_at_the_outline_depth_limit() {
|
||||
// Booleans directly inside booleans used to restart the depth count at every level.
|
||||
let mut d = Document::new("t");
|
||||
let mut parent = d.first_page().unwrap();
|
||||
let top = d.create(parent, NodeKind::BooleanOp { op: BoolOp::Union }, "B", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
parent = top;
|
||||
for _ in 0..20_000 {
|
||||
parent = d.create(parent, NodeKind::BooleanOp { op: BoolOp::Union }, "B", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
}
|
||||
d.create(parent, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
// Too deep: no stack overflow, and the unreachable square contributes nothing.
|
||||
assert!(outline(&d, top).area().abs() < 1e-9);
|
||||
// Within the limit, nested booleans still combine.
|
||||
let (mut d, _, b, _) = row_of_squares(BoolOp::Union, 1);
|
||||
let inner = d.create(b, NodeKind::BooleanOp { op: BoolOp::Union }, "B", 0.0, 0.0, 0.0, 0.0).unwrap();
|
||||
d.create(inner, NodeKind::Rectangle, "R", 40.0, 10.0, 20.0, 20.0).unwrap();
|
||||
assert!((outline(&d, b).area().abs() - 2.0 * 20.0 * 20.0).abs() < 0.5);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -203,11 +203,6 @@ pub(crate) fn stroke_rule(n: &Node, closed: bool) -> FillRule {
|
|||
/// The geometry a node fills, in local space: shapes, booleans (computed from children) and text
|
||||
/// (glyph outlines). Groups and pages have none.
|
||||
pub fn node_outline(doc: &Document, n: &Node) -> Option<(BezPath, FillRule)> {
|
||||
node_outline_at(doc, n, 0)
|
||||
}
|
||||
|
||||
/// [`node_outline`] `depth` levels into a boolean or group nesting (one budget for both).
|
||||
fn node_outline_at(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, FillRule)> {
|
||||
match &n.kind {
|
||||
NodeKind::BooleanOp { op } => {
|
||||
let operands: Vec<(BezPath, figcraft_doc::BoolOp)> = n
|
||||
|
|
@ -216,7 +211,7 @@ fn node_outline_at(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, F
|
|||
.filter_map(|c| doc.node(*c))
|
||||
.filter(|c| c.visible)
|
||||
.filter_map(|c| {
|
||||
let (mut p, _) = subtree_outline(doc, c, depth + 1)?;
|
||||
let (mut p, _) = subtree_outline(doc, c, 0)?;
|
||||
p.apply_affine(c.transform);
|
||||
Some((p, c.bool_op.unwrap_or(*op)))
|
||||
})
|
||||
|
|
@ -263,7 +258,7 @@ pub fn subtree_outline(doc: &Document, n: &Node, depth: usize) -> Option<(BezPat
|
|||
}
|
||||
Some((out, FillRule::Winding))
|
||||
}
|
||||
_ => node_outline_at(doc, n, depth),
|
||||
_ => node_outline(doc, n),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,5 +17,4 @@ figcraft-text = { workspace = true }
|
|||
figcraft-pathops = { workspace = true }
|
||||
usvg = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
flate2 = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -1,108 +0,0 @@
|
|||
//! Resource budgets for what an SVG expands into while it is parsed: compressed (SVGZ) input,
|
||||
//! and SVG documents embedded in `<image>` data URIs (themselves possibly compressed or nested).
|
||||
//! usvg decompresses with no cap and parses embedded SVGs with no shared depth or byte limit, so
|
||||
//! both are done here: a bounded gunzip before usvg sees the bytes, and our own data resolver.
|
||||
|
||||
use std::io::Read;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use usvg::{ImageHrefResolver, ImageKind, Options};
|
||||
|
||||
/// Largest document, once decompressed.
|
||||
const MAX_DOCUMENT_BYTES: usize = 64 << 20;
|
||||
/// Largest total of all embedded SVG documents, once decompressed.
|
||||
const MAX_EMBEDDED_BYTES: usize = 32 << 20;
|
||||
/// Most SVG documents embedded inside one another.
|
||||
const MAX_EMBEDDED_DEPTH: usize = 4;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct Limits {
|
||||
pub document: usize,
|
||||
pub embedded: usize,
|
||||
pub depth: usize,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Limits { document: MAX_DOCUMENT_BYTES, embedded: MAX_EMBEDDED_BYTES, depth: MAX_EMBEDDED_DEPTH }
|
||||
}
|
||||
}
|
||||
|
||||
fn gzipped(data: &[u8]) -> bool {
|
||||
data.starts_with(&[0x1f, 0x8b])
|
||||
}
|
||||
|
||||
/// `data` as plain text bytes: gunzipped when compressed, never past `limit` bytes.
|
||||
fn inflate(data: &[u8], limit: usize) -> Result<Vec<u8>, String> {
|
||||
if !gzipped(data) {
|
||||
return if data.len() > limit { Err(format!("SVG larger than {} MiB", limit >> 20)) } else { Ok(data.to_vec()) };
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let cap = u64::try_from(limit).unwrap_or(u64::MAX).saturating_add(1);
|
||||
flate2::read::GzDecoder::new(data).take(cap).read_to_end(&mut out).map_err(|_| "malformed compressed SVG".to_string())?;
|
||||
if out.len() > limit {
|
||||
return Err(format!("compressed SVG expands past {} MiB", limit >> 20));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The document `data` (SVG or SVGZ) as plain bytes, within the document budget.
|
||||
pub(super) fn document(data: &[u8], limits: &Limits) -> Result<Vec<u8>, String> {
|
||||
// The caller already holds a plain document; only what compression expands to is bounded.
|
||||
if gzipped(data) { inflate(data, limits.document) } else { Ok(data.to_vec()) }
|
||||
}
|
||||
|
||||
/// What the embedded SVGs of one import have used so far.
|
||||
struct Used {
|
||||
bytes: AtomicUsize,
|
||||
depth: AtomicUsize,
|
||||
}
|
||||
|
||||
fn raster(data: &[u8]) -> bool {
|
||||
data.starts_with(b"\x89PNG")
|
||||
|| data.starts_with(&[0xff, 0xd8, 0xff])
|
||||
|| data.starts_with(b"GIF8")
|
||||
|| (data.len() > 12 && data.get(8..12) == Some(b"WEBP"))
|
||||
}
|
||||
|
||||
/// An embedded SVG document, if the budgets allow it.
|
||||
fn embedded(used: &Used, limits: &Limits, data: &[u8], opts: &Options) -> Option<ImageKind> {
|
||||
if used.depth.load(Ordering::Relaxed) >= limits.depth {
|
||||
return None;
|
||||
}
|
||||
let left = limits.embedded.saturating_sub(used.bytes.load(Ordering::Relaxed));
|
||||
let plain = inflate(data, left).ok()?;
|
||||
used.bytes.fetch_add(plain.len(), Ordering::Relaxed);
|
||||
if super::nesting(&plain, super::MAX_NESTING) > super::MAX_NESTING {
|
||||
return None;
|
||||
}
|
||||
used.depth.fetch_add(1, Ordering::Relaxed);
|
||||
let tree = usvg::Tree::from_data_nested(&plain, opts);
|
||||
used.depth.fetch_sub(1, Ordering::Relaxed);
|
||||
tree.ok().map(ImageKind::SVG)
|
||||
}
|
||||
|
||||
/// Resolve `<image>` data URIs: rasters as they are, SVGs within `limits`; nothing else.
|
||||
pub(super) fn resolver(limits: Limits) -> ImageHrefResolver<'static> {
|
||||
let used = Arc::new(Used { bytes: AtomicUsize::new(0), depth: AtomicUsize::new(0) });
|
||||
ImageHrefResolver {
|
||||
resolve_data: Box::new(move |mime, data, opts| match mime {
|
||||
"image/jpg" | "image/jpeg" => Some(ImageKind::JPEG(data)),
|
||||
"image/png" => Some(ImageKind::PNG(data)),
|
||||
"image/gif" => Some(ImageKind::GIF(data)),
|
||||
"image/webp" => Some(ImageKind::WEBP(data)),
|
||||
"image/svg+xml" => embedded(&used, &limits, &data, opts),
|
||||
"text/plain" if raster(&data) => match data.first() {
|
||||
Some(0x89) => Some(ImageKind::PNG(data)),
|
||||
Some(0xff) => Some(ImageKind::JPEG(data)),
|
||||
Some(b'G') => Some(ImageKind::GIF(data)),
|
||||
_ => Some(ImageKind::WEBP(data)),
|
||||
},
|
||||
"text/plain" => embedded(&used, &limits, &data, opts),
|
||||
_ => None,
|
||||
}),
|
||||
// Linked files are never read.
|
||||
resolve_string: Box::new(|_, _| None),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
//! Budgets on what SVGZ and embedded SVG documents expand into, with reduced limits so the
|
||||
//! fixtures stay small.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use base64::Engine as _;
|
||||
use figcraft_doc::Document;
|
||||
|
||||
use super::budget::Limits;
|
||||
use super::{import_svg, import_within};
|
||||
|
||||
fn gzip(data: &[u8]) -> Vec<u8> {
|
||||
let mut e = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
e.write_all(data).unwrap();
|
||||
e.finish().unwrap()
|
||||
}
|
||||
|
||||
fn svg(body: &str) -> String {
|
||||
format!(r##"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="100">{body}</svg>"##)
|
||||
}
|
||||
|
||||
fn uri(svg: &[u8]) -> String {
|
||||
format!("data:image/svg+xml;base64,{}", base64::engine::general_purpose::STANDARD.encode(svg))
|
||||
}
|
||||
|
||||
/// `levels` SVGs, each embedding the next, the innermost holding one rectangle.
|
||||
fn nested(levels: usize) -> String {
|
||||
let mut s = svg(r##"<rect width="10" height="10" fill="red"/>"##);
|
||||
for _ in 1..levels {
|
||||
s = svg(&format!(r##"<image width="100" height="100" xlink:href="{}"/>"##, uri(s.as_bytes())));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// The layers an import makes (None when it is refused).
|
||||
fn layers(data: &[u8], limits: Limits) -> Result<usize, String> {
|
||||
let mut d = Document::new("budget");
|
||||
let p = d.first_page().unwrap();
|
||||
let f = import_within(&mut d, data, p, 0.0, 0.0, "x", limits).map_err(|e| e.0)?.frame;
|
||||
assert!(d.validate().is_empty(), "{:?}", d.validate());
|
||||
Ok(d.subtree(f).len())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_svgz_is_unpacked_and_imported() {
|
||||
let z = gzip(svg(r##"<rect width="10" height="10"/>"##).as_bytes());
|
||||
let mut d = Document::new("z");
|
||||
let p = d.first_page().unwrap();
|
||||
assert!(import_svg(&mut d, &z, p, 0.0, 0.0, "z").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_svgz_that_expands_past_the_budget_is_refused_early() {
|
||||
let limits = Limits { document: 4096, ..Limits::default() };
|
||||
// Tiny compressed, far past 4 KiB expanded.
|
||||
let z = gzip(svg(&" ".repeat(1 << 20)).as_bytes());
|
||||
assert!(z.len() < 4096, "the bomb is small: {}", z.len());
|
||||
let e = layers(&z, limits).unwrap_err();
|
||||
assert!(e.contains("expands past"), "{e}");
|
||||
// The same document is fine within a bigger budget.
|
||||
assert!(layers(&z, Limits { document: 2 << 20, ..Limits::default() }).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_compressed_document_is_checked_for_nesting_once_unpacked() {
|
||||
let deep = format!("{}{}", "<g>".repeat(600), "</g>".repeat(600));
|
||||
let z = gzip(svg(&deep).as_bytes());
|
||||
assert!(layers(&z, Limits::default()).unwrap_err().contains("nested more than"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_svgs_nest_up_to_the_depth_budget_and_no_further() {
|
||||
let data = nested(4).into_bytes();
|
||||
let full = layers(&data, Limits::default()).unwrap();
|
||||
let shallow = layers(&data, Limits { depth: 1, ..Limits::default() }).unwrap();
|
||||
assert!(shallow < full, "depth 1 drops the inner documents: {shallow} < {full}");
|
||||
// Far deeper than the budget: still imports, bounded.
|
||||
let deep = nested(40).into_bytes();
|
||||
assert!(layers(&deep, Limits::default()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_svgs_share_one_byte_budget() {
|
||||
let inner = svg(r##"<rect width="10" height="10"/>"##);
|
||||
let one = format!(r##"<image width="10" height="10" xlink:href="{}"/>"##, uri(inner.as_bytes()));
|
||||
let data = svg(&one.repeat(8)).into_bytes();
|
||||
let all = layers(&data, Limits::default()).unwrap();
|
||||
let limits = Limits { embedded: inner.len() * 3, ..Limits::default() };
|
||||
let some = layers(&data, limits).unwrap();
|
||||
assert!(some < all, "the budget runs out part way: {some} < {all}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_embedded_compressed_bomb_is_dropped_not_expanded() {
|
||||
let bomb = gzip(svg(&" ".repeat(1 << 20)).as_bytes());
|
||||
let data = svg(&format!(
|
||||
r##"<image width="10" height="10" xlink:href="data:image/svg+xml;base64,{}"/>"##,
|
||||
base64::engine::general_purpose::STANDARD.encode(&bomb)
|
||||
))
|
||||
.into_bytes();
|
||||
let limits = Limits { embedded: 4096, ..Limits::default() };
|
||||
let with_bomb = layers(&data, limits).unwrap();
|
||||
let empty = layers(svg("").as_bytes(), limits).unwrap();
|
||||
assert_eq!(with_bomb, empty, "the oversized embedded document left nothing behind");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_files_and_other_mime_types_stay_unresolved() {
|
||||
let data = svg(
|
||||
r##"<image width="10" height="10" xlink:href="/etc/passwd"/><image width="10" height="10" xlink:href="data:application/x-foo;base64,AAAA"/>"##,
|
||||
);
|
||||
assert_eq!(layers(data.as_bytes(), Limits::default()).unwrap(), layers(svg("").as_bytes(), Limits::default()).unwrap());
|
||||
}
|
||||
|
|
@ -16,14 +16,11 @@
|
|||
//! What can't be represented (filters other than shadows and blurs, rotated repeating patterns,
|
||||
//! nested clip paths) is left out and reported in the import notes. Linked files are never read: only data URIs.
|
||||
|
||||
mod budget;
|
||||
mod fx;
|
||||
mod paint;
|
||||
mod shape;
|
||||
mod text;
|
||||
|
||||
#[cfg(test)]
|
||||
mod budget_tests;
|
||||
#[cfg(test)]
|
||||
mod fx_tests;
|
||||
#[cfg(test)]
|
||||
|
|
@ -595,10 +592,10 @@ pub(crate) fn nesting(data: &[u8], limit: usize) -> usize {
|
|||
max
|
||||
}
|
||||
|
||||
fn options(data: &[u8], limits: budget::Limits) -> usvg::Options<'static> {
|
||||
// Only data URIs, within budgets: an opened or pasted SVG never pulls other files off the disk
|
||||
// and never expands without limit.
|
||||
let mut o = usvg::Options { image_href_resolver: budget::resolver(limits), ..Default::default() };
|
||||
fn options(data: &[u8]) -> usvg::Options<'static> {
|
||||
let mut o = usvg::Options::default();
|
||||
// Only data URIs: an opened or pasted SVG never pulls other files off the disk.
|
||||
o.image_href_resolver.resolve_string = Box::new(|_, _| None);
|
||||
if data.windows(4).any(|w| w == b"text") {
|
||||
text::prepare(&mut o);
|
||||
}
|
||||
|
|
@ -637,24 +634,10 @@ pub fn import_svg(doc: &mut Document, data: &[u8], parent: NodeId, x: f64, y: f6
|
|||
|
||||
/// [`import_svg`], with notes on what couldn't be represented.
|
||||
pub fn import_svg_with_notes(doc: &mut Document, data: &[u8], parent: NodeId, x: f64, y: f64, name: &str) -> Result<Imported, ImportError> {
|
||||
import_within(doc, data, parent, x, y, name, budget::Limits::default())
|
||||
}
|
||||
|
||||
fn import_within(
|
||||
doc: &mut Document,
|
||||
data: &[u8],
|
||||
parent: NodeId,
|
||||
x: f64,
|
||||
y: f64,
|
||||
name: &str,
|
||||
limits: budget::Limits,
|
||||
) -> Result<Imported, ImportError> {
|
||||
// SVGZ is unpacked here, within a budget, so every check below sees the real document.
|
||||
let data = budget::document(data, &limits).map_err(ImportError)?;
|
||||
if nesting(&data, MAX_NESTING) > MAX_NESTING {
|
||||
if nesting(data, MAX_NESTING) > MAX_NESTING {
|
||||
return Err(ImportError(format!("elements nested more than {MAX_NESTING} deep")));
|
||||
}
|
||||
let tree = usvg::Tree::from_data(&data, &options(&data, limits)).map_err(|e| ImportError(e.to_string()))?;
|
||||
let tree = usvg::Tree::from_data(data, &options(data)).map_err(|e| ImportError(e.to_string()))?;
|
||||
let size = tree.size();
|
||||
let frame =
|
||||
doc.create(parent, NodeKind::Frame, name, x, y, f64::from(size.width()), f64::from(size.height())).map_err(|e| ImportError(e.to_string()))?;
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ fn main() {
|
|||
}
|
||||
}
|
||||
if !ok {
|
||||
println!("cargo::warning=building without craft-fonts (once it is there, `cargo clean -p figcraft-text` makes the next build use it)");
|
||||
println!("cargo::warning=building without craft-fonts");
|
||||
}
|
||||
}
|
||||
src.push_str("];\n");
|
||||
|
|
@ -35,23 +35,9 @@ fn main() {
|
|||
}
|
||||
}
|
||||
|
||||
/// Rebuild when `manifest` changes or, while it is missing, when it is added to its folder.
|
||||
///
|
||||
/// Cargo takes a watched path that doesn't exist as changed on every build, and then rebuilds this
|
||||
/// crate and every crate that depends on it each time. So a missing manifest is never watched
|
||||
/// itself: its folder is, when there is one (a craft-fonts checkout without the Latin manifest).
|
||||
/// With no craft-fonts at all there is nothing to watch, so the build can't notice it arriving:
|
||||
/// the warning in `main` says how to pick it up.
|
||||
fn watch(manifest: &std::path::Path) {
|
||||
let watched = if manifest.exists() { Some(manifest) } else { manifest.parent().filter(|folder| folder.is_dir()) };
|
||||
if let Some(path) = watched {
|
||||
println!("cargo::rerun-if-changed={}", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
fn craft_fonts(dir: &std::path::Path, manifest_rel: &str) -> Result<String, String> {
|
||||
let manifest = dir.join(manifest_rel);
|
||||
watch(&manifest);
|
||||
println!("cargo::rerun-if-changed={}", manifest.display());
|
||||
let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
|
||||
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
|
||||
let mut out = String::new();
|
||||
|
|
|
|||
|
|
@ -211,7 +211,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 or Compose above to connect this component to its code." = "Wählen Sie oben React, Leptos, SwiftUI oder Compose, um diese Komponente mit ihrem Code zu verbinden."
|
||||
"Choose React, SwiftUI or Compose above to connect this component to its code." = "Wählen Sie oben React, SwiftUI oder Compose, 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"
|
||||
|
|
@ -2034,7 +2034,6 @@ status = "draft — needs native review"
|
|||
"view.prevPage" = "Vorherige Seite"
|
||||
"view.previousRegion" = "Vorherigen Bereich fokussieren"
|
||||
"view.proofSrgb" = "Softproof sRGB"
|
||||
"view.fullscreen" = "Vollbild"
|
||||
"view.prototype" = "Bereich Prototyp"
|
||||
"view.rulers" = "Lineale"
|
||||
"view.set" = "Ansicht festlegen"
|
||||
|
|
|
|||
|
|
@ -216,7 +216,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 or Compose above to connect this component to its code." = "Choose React, Leptos, SwiftUI or Compose above to connect this component to its code."
|
||||
"Choose React, SwiftUI or Compose above to connect this component to its code." = "Choose React, SwiftUI or Compose 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"
|
||||
|
|
|
|||
|
|
@ -217,7 +217,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 or Compose above to connect this component to its code." = "Elige React, Leptos, SwiftUI o Compose arriba para conectar este componente con su código."
|
||||
"Choose React, SwiftUI or Compose above to connect this component to its code." = "Elige React, SwiftUI o Compose 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"
|
||||
|
|
@ -2087,7 +2087,6 @@ status = "draft — needs native review"
|
|||
"view.prevPage" = "Página anterior"
|
||||
"view.previousRegion" = "Enfocar la región anterior"
|
||||
"view.proofSrgb" = "Prueba en pantalla sRGB"
|
||||
"view.fullscreen" = "Pantalla completa"
|
||||
"view.prototype" = "Panel de prototipo"
|
||||
"view.rulers" = "Reglas"
|
||||
"view.set" = "Establecer vista"
|
||||
|
|
|
|||
|
|
@ -211,7 +211,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 or Compose above to connect this component to its code." = "Choisissez React, Leptos, SwiftUI ou Compose ci-dessus pour relier ce composant à son code."
|
||||
"Choose React, SwiftUI or Compose above to connect this component to its code." = "Choisissez React, SwiftUI ou Compose 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"
|
||||
|
|
@ -2034,7 +2034,6 @@ status = "draft — needs native review"
|
|||
"view.prevPage" = "Page précédente"
|
||||
"view.previousRegion" = "Activer la région précédente"
|
||||
"view.proofSrgb" = "Épreuve écran sRGB"
|
||||
"view.fullscreen" = "Plein écran"
|
||||
"view.prototype" = "Panneau Prototype"
|
||||
"view.rulers" = "Règles"
|
||||
"view.set" = "Définir l’affichage"
|
||||
|
|
|
|||
|
|
@ -213,7 +213,7 @@ status = "draft — needs native review"
|
|||
"Changes on main" = "メインの変更"
|
||||
"Changes on the branch" = "ブランチの変更"
|
||||
"Changes unknown" = "変更内容は不明"
|
||||
"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "上で React、Leptos、SwiftUI、Compose のいずれかを選び、このコンポーネントをコードに接続。"
|
||||
"Choose React, SwiftUI or Compose above to connect this component to its code." = "上で React、SwiftUI、Compose のいずれかを選び、このコンポーネントをコードに接続。"
|
||||
"Choose another profile to change it." = "変更するには別のプロファイルを選択。"
|
||||
"Choose collection" = "コレクションを選択"
|
||||
"Choose image" = "画像を選択"
|
||||
|
|
@ -2036,7 +2036,6 @@ status = "draft — needs native review"
|
|||
"view.prevPage" = "前のページ"
|
||||
"view.previousRegion" = "前の領域にフォーカス"
|
||||
"view.proofSrgb" = "sRGB でソフトプルーフ"
|
||||
"view.fullscreen" = "全画面表示"
|
||||
"view.prototype" = "プロトタイプパネル"
|
||||
"view.rulers" = "定規"
|
||||
"view.set" = "ビューを設定"
|
||||
|
|
|
|||
|
|
@ -212,7 +212,7 @@ status = "draft — needs native review"
|
|||
"Changes on main" = "메인의 변경 사항"
|
||||
"Changes on the branch" = "브랜치의 변경 사항"
|
||||
"Changes unknown" = "변경 내용 알 수 없음"
|
||||
"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "위에서 React, Leptos, SwiftUI 또는 Compose를 선택해 이 컴포넌트를 코드에 연결하세요."
|
||||
"Choose React, SwiftUI or Compose above to connect this component to its code." = "위에서 React, SwiftUI 또는 Compose를 선택해 이 컴포넌트를 코드에 연결하세요."
|
||||
"Choose another profile to change it." = "변경하려면 다른 프로필을 선택하세요."
|
||||
"Choose collection" = "컬렉션 선택"
|
||||
"Choose image" = "이미지 선택"
|
||||
|
|
@ -2035,7 +2035,6 @@ status = "draft — needs native review"
|
|||
"view.prevPage" = "이전 페이지"
|
||||
"view.previousRegion" = "이전 영역에 포커스"
|
||||
"view.proofSrgb" = "sRGB 교정 미리 보기"
|
||||
"view.fullscreen" = "전체 화면"
|
||||
"view.prototype" = "프로토타입 패널"
|
||||
"view.rulers" = "눈금자"
|
||||
"view.set" = "보기 설정"
|
||||
|
|
|
|||
|
|
@ -210,7 +210,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 or Compose above to connect this component to its code." = "Escolha React, Leptos, SwiftUI ou Compose acima para conectar este componente ao código dele."
|
||||
"Choose React, SwiftUI or Compose above to connect this component to its code." = "Escolha React, SwiftUI ou Compose 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"
|
||||
|
|
@ -2033,7 +2033,6 @@ status = "draft — needs native review"
|
|||
"view.prevPage" = "Página anterior"
|
||||
"view.previousRegion" = "Focar a região anterior"
|
||||
"view.proofSrgb" = "Prova em tela sRGB"
|
||||
"view.fullscreen" = "Tela cheia"
|
||||
"view.prototype" = "Painel de protótipo"
|
||||
"view.rulers" = "Réguas"
|
||||
"view.set" = "Definir visualização"
|
||||
|
|
|
|||
|
|
@ -213,7 +213,7 @@ status = "draft — needs native review"
|
|||
"Changes on main" = "主文件上的更改"
|
||||
"Changes on the branch" = "分支上的更改"
|
||||
"Changes unknown" = "更改未知"
|
||||
"Choose React, Leptos, SwiftUI or Compose above to connect this component to its code." = "在上方选择 React、Leptos、SwiftUI 或 Compose,将此组件关联到其代码。"
|
||||
"Choose React, SwiftUI or Compose above to connect this component to its code." = "在上方选择 React、SwiftUI 或 Compose,将此组件关联到其代码。"
|
||||
"Choose another profile to change it." = "选择其他配置文件以更改。"
|
||||
"Choose collection" = "选择集合"
|
||||
"Choose image" = "选择图片"
|
||||
|
|
@ -2036,7 +2036,6 @@ status = "draft — needs native review"
|
|||
"view.prevPage" = "上一页"
|
||||
"view.previousRegion" = "聚焦上一个区域"
|
||||
"view.proofSrgb" = "sRGB 软打样"
|
||||
"view.fullscreen" = "全屏"
|
||||
"view.prototype" = "原型面板"
|
||||
"view.rulers" = "标尺"
|
||||
"view.set" = "设置视图"
|
||||
|
|
|
|||
|
|
@ -782,8 +782,8 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
|||
ui.add_space(16.0);
|
||||
widgets::label(ui, tr("On this page"), t.text_secondary);
|
||||
});
|
||||
// Inset on both sides: `add_space` would only indent the first row of a wrap.
|
||||
inset(ui, |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.add_space(16.0);
|
||||
for c in &doc_colors {
|
||||
if widgets::swatch(ui, &Paint::solid(*c), 14.0).on_hover_text(c.to_hex()).clicked() {
|
||||
set_color(app, &target, &mut paint, *c, sel_stop_id, ui);
|
||||
|
|
@ -798,7 +798,8 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
|||
ui.add_space(16.0);
|
||||
widgets::label(ui, tr("Recent"), t.text_secondary);
|
||||
});
|
||||
inset(ui, |ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.add_space(16.0);
|
||||
for c in recent.iter().take(14) {
|
||||
if widgets::swatch(ui, &Paint::solid(*c), 14.0).clicked() {
|
||||
set_color(app, &target, &mut paint, *c, sel_stop_id, ui);
|
||||
|
|
@ -815,11 +816,6 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
|
|||
let _ = Pos2::ZERO;
|
||||
}
|
||||
|
||||
/// Swatches wrapped in rows inset 16 points from both sides of the picker.
|
||||
fn inset(ui: &mut Ui, add: impl FnOnce(&mut Ui)) {
|
||||
egui::Frame::NONE.inner_margin(egui::Margin::symmetric(16, 0)).show(ui, |ui| ui.horizontal_wrapped(add));
|
||||
}
|
||||
|
||||
/// The colour picker's note under the fields for a colour outside sRGB.
|
||||
fn gamut_warning(ui: &mut Ui, t: &Tokens) {
|
||||
use crate::a11y::Tip;
|
||||
|
|
|
|||
|
|
@ -396,7 +396,7 @@ pub fn connect_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId
|
|||
let text = if connectable {
|
||||
tr("Link this component to its code component: instances then show your real snippet with their values.")
|
||||
} else {
|
||||
tr("Choose React, Leptos, SwiftUI or Compose above to connect this component to its code.")
|
||||
tr("Choose React, SwiftUI or Compose above to connect this component to its code.")
|
||||
};
|
||||
widgets::note(ui, text, t.text_secondary);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -113,8 +113,6 @@ mod live_text_tests;
|
|||
#[cfg(test)]
|
||||
mod online_tests;
|
||||
#[cfg(test)]
|
||||
mod open_issues_tests;
|
||||
#[cfg(test)]
|
||||
mod panel_reorder_tests;
|
||||
#[cfg(test)]
|
||||
mod panel_width_tests;
|
||||
|
|
@ -737,7 +735,7 @@ impl FigApp {
|
|||
"dialog.unsaved" => self.toast(i18n::tr("This file has unsaved changes")),
|
||||
"dialog.plugins" => self.ui.plugins_window = true,
|
||||
"dialog.cursorChat" => live::open_chat(self),
|
||||
"window.new" | "window.focus" | "window.fullscreen" => self.window_requests.push(r),
|
||||
"window.new" | "window.focus" => self.window_requests.push(r),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,37 +0,0 @@
|
|||
//! Opening a native file with a damaged image: the app says what was lost.
|
||||
|
||||
use base64::Engine as _;
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::layers_drag_tests::Ui;
|
||||
|
||||
/// A 1×1 PNG.
|
||||
const PNG: [u8; 67] = [
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x60, 0x00, 0x00, 0x02,
|
||||
0x00, 0x00, 0x01, 0xe2, 0x21, 0xbc, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn opening_a_file_with_a_damaged_image_toasts_what_was_lost() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-ui-open-issues-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("pics.figcraft");
|
||||
let mut s = Session::new();
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(PNG);
|
||||
s.execute("file.import", &json!({"data": data, "name": "dot.png"})).unwrap();
|
||||
s.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
// Images are stored as they are: change one byte of the PNG, not its checksum.
|
||||
let mut bytes = std::fs::read(&path).unwrap();
|
||||
let at = bytes.windows(PNG.len()).position(|w| w == PNG).unwrap();
|
||||
bytes[at + 30] ^= 0xff;
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
|
||||
let mut u = Ui::new(Session::new());
|
||||
u.app.run("file.open", json!({"path": path.to_string_lossy()}));
|
||||
u.frame(vec![]);
|
||||
let toast = u.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default();
|
||||
assert!(toast.starts_with("pics: image ") && toast.contains("couldn't be read"), "{toast}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
|
@ -2238,7 +2238,7 @@ fn inspect(app: &mut FigApp, ui: &mut Ui) {
|
|||
let copy = widgets::section_header(ui, tr("Code"), &[(Icon::Export, tr("Copy"))], false).is_some();
|
||||
// Pixels or rem (of a 16 px root) for the web languages.
|
||||
let rem_id = Id::new("inspect_rem");
|
||||
let web = lang.is_web();
|
||||
let web = matches!(lang, CodeLang::Css | CodeLang::Html | CodeLang::React);
|
||||
let rem = web && ui.data(|d| d.get_temp::<bool>(rem_id)).unwrap_or(false);
|
||||
row(ui, 32.0, |ui| {
|
||||
let resp = widgets::dropdown(ui, lang.label(), 120.0, Some(Icon::Code));
|
||||
|
|
|
|||
|
|
@ -189,25 +189,3 @@ fn enter_with_a_shape_tool_places_one_in_the_middle_of_the_view() {
|
|||
press(&mut u, Key::Enter, Modifiers::NONE);
|
||||
assert!(u.app.session.ds().unwrap().vector_edit.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_screen_key_and_command_toggle_the_window() {
|
||||
let mut u = Ui::new(Session::new());
|
||||
let fullscreen = |out: &egui::FullOutput| -> Vec<ViewportCommand> {
|
||||
out.viewport_output.get(&ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default()
|
||||
};
|
||||
// F11 (Windows and Linux) or Ctrl+Cmd+F (macOS) asks the window to fill the screen.
|
||||
let out = if crate::shortcuts::platform() == Platform::Mac {
|
||||
press(&mut u, Key::F, Modifiers::MAC_CMD | Modifiers::COMMAND | Modifiers::CTRL)
|
||||
} else {
|
||||
press(&mut u, Key::F11, Modifiers::NONE)
|
||||
};
|
||||
assert!(fullscreen(&out).contains(&ViewportCommand::Fullscreen(true)), "full screen key, {:?}", fullscreen(&out));
|
||||
// `on: false` leaves it, whatever the window reports.
|
||||
u.app.run("view.fullscreen", json!({"on": false}));
|
||||
let out = u.frame_output(vec![]);
|
||||
assert!(fullscreen(&out).contains(&ViewportCommand::Fullscreen(false)), "{:?}", fullscreen(&out));
|
||||
// Junk parameters are ignored, not fatal.
|
||||
u.app.run("view.fullscreen", json!({"on": "yes"}));
|
||||
u.frame(vec![]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -242,12 +242,6 @@ pub fn focus(app: &mut FigApp, ctx: &Context, id: u64) -> Result<Value, String>
|
|||
pub(crate) fn requests(app: &mut FigApp, ctx: &Context) {
|
||||
for r in std::mem::take(&mut app.window_requests) {
|
||||
let n = |k: &str| r.data.get(k).and_then(Value::as_u64);
|
||||
if r.kind == "window.fullscreen" {
|
||||
let full = ctx.input(|i| i.viewport().fullscreen.unwrap_or(false));
|
||||
let on = r.data.get("on").and_then(Value::as_bool).unwrap_or(!full);
|
||||
ctx.send_viewport_cmd(ViewportCommand::Fullscreen(on));
|
||||
continue;
|
||||
}
|
||||
let res = if r.kind == "window.new" {
|
||||
open(app, ctx, n("index").map(|i| i as usize), r.data.get("path").and_then(Value::as_str), None).map(|_| ())
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -10,13 +10,13 @@ inside generated layer trees. Remaining: 0–1 h.
|
|||
|---|---|---|
|
||||
| Dev Mode toggle (Shift+D), Inspect panel | done | |
|
||||
| Properties with their styles and variables; redlines on hover | done | positions from the frame around the layer (groups aren't frames), rotation, radius (CSS corner order), opacity, blend; colours in hex, RGB, CSS, HSL or HSB (`inspect.properties {colorFormat}`, the Inspect tab's Color format) |
|
||||
| Code: CSS, iOS (SwiftUI), Android (Compose) | done | plus HTML, React and Leptos (ours; Leptos: `#[component]` functions with `#[prop]` defaults, variant enums and `view!` markup); components take properties as parameters, instances are calls; CSS in px or rem (`inspect.code {unit, rootFontSize}`), with rotation, blend modes, gradient directions and kinds (linear, radial, conic), text alignment, case and decoration |
|
||||
| Code: CSS, iOS (SwiftUI), Android (Compose) | done | plus HTML and React (ours); components take properties as parameters, instances are calls; CSS in px or rem (`inspect.code {unit, rootFontSize}`), with rotation, blend modes, gradient directions and kinds (linear, radial, conic), text alignment, case and decoration |
|
||||
| Assets list with export | done | layers with export settings, icons (small, vector shapes only: SVG) and images (PNG); the icon rule is ours |
|
||||
| Annotations: pinned properties, categories, coloured callouts, filter | done | |
|
||||
| Ready for dev list, statuses, "Changed" after edits | done | |
|
||||
| Focus view (one frame, previous / next) | done | |
|
||||
| Compare changes (before/after side by side or overlaid, property diffs) | done | only against the snapshot taken when a frame was marked ready; not against any named version |
|
||||
| Code connect (map components to repository code) | done | per language (React, Leptos, SwiftUI, Compose): import path, name and a template with `{property}` and `{property ? "a" : "b"}` placeholders; instances (also inside generated layer trees) show the snippet with their values; edited in the Inspect panel with a live preview; `figcraft.connect.json` export/import. Inside a generated layer tree, connected instances' imports aren't collected |
|
||||
| Code connect (map components to repository code) | done | per language (React, SwiftUI, Compose): import path, name and a template with `{property}` and `{property ? "a" : "b"}` placeholders; instances (also inside generated layer trees) show the snippet with their values; edited in the Inspect panel with a live preview; `figcraft.connect.json` export/import. Inside a generated layer tree, connected instances' imports aren't collected |
|
||||
| Plugins in Dev Mode (Jira, GitHub, etc.) | partial | FigCraft's own WASM plugins that only read run from the Inspect tab; no plugin UI at run time, no third-party integrations |
|
||||
| VS Code extension | out of scope | FigCraft's MCP server and control channel already serve design data, code and connected snippets to agents and editors |
|
||||
| Dev resources links on layers | done | a URL and a name per link on any layer (`devResource.*`, saved, synced, undoable); instances list their main component's (and its set's) read-only; URLs checked as http/https and never fetched (no page titles: a link without a name is named after its URL); the Inspect tab lists, adds, edits and removes them; `get_node` returns them |
|
||||
|
|
|
|||
40
docs/mcp.md
40
docs/mcp.md
|
|
@ -40,9 +40,6 @@ built-in fonts. Attached, the app's own fonts apply. The browser build has no sy
|
|||
Build the CLI first (`cargo build --release -p figcraft-cli`) and use the absolute path to
|
||||
`target/release/figcraft-cli`.
|
||||
|
||||
The Linux AppImage bundles the CLI: `FigCraft.AppImage cli <args>` runs `figcraft-cli <args>`, so
|
||||
`/path/to/FigCraft.AppImage cli mcp` works wherever `/path/to/figcraft-cli mcp` appears below.
|
||||
|
||||
### Claude Code
|
||||
|
||||
```sh
|
||||
|
|
@ -218,8 +215,8 @@ Or in a project's `.mcp.json`:
|
|||
| Tool | What it does |
|
||||
|---|---|
|
||||
| `list_commands {filter?}` | Every command: id, label, menu, shortcut, undoable, params doc. |
|
||||
| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, React, Leptos, SwiftUI or Jetpack Compose for a layer; components take their properties as parameters and instances are calls, see Dev Mode below), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names; `colorFormat`: hex, rgb, css, hsl or hsb), `inspect.assets` (layers under the selection with export settings, icons and images, each with its `kind`), `inspect.code` takes `unit: rem` and `rootFontSize` for CSS, HTML, React and Leptos, `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`, `codeConnect.set` (components linked to real code, see Dev Mode below)). |
|
||||
| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing for open documents and their document state. Failed inner commands are removed from the journal; preferences, clipboard, tool state and UI requests are not rolled back. |
|
||||
| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, React, SwiftUI or Jetpack Compose for a layer; components take their properties as parameters and instances are calls, see Dev Mode below), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names; `colorFormat`: hex, rgb, css, hsl or hsb), `inspect.assets` (layers under the selection with export settings, icons and images, each with its `kind`), `inspect.code` takes `unit: rem` and `rootFontSize` for CSS, HTML and React, `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`, `codeConnect.set` (components linked to real code, see Dev Mode below)). |
|
||||
| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing. |
|
||||
| `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. |
|
||||
| `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`; marking again clears "Changed"), a section's `sectionContentsHidden`, text…); returns the updated layers. |
|
||||
| `inspect_document {depth, allPages}` | The document as JSON (layer tree, styles, selection, history). |
|
||||
|
|
@ -1171,30 +1168,27 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path
|
|||
|
||||
### Dev Mode
|
||||
|
||||
- **Component code:** `inspect.code {id, language: react|leptos|swiftui|compose}` on a component,
|
||||
a component set or a variant writes the component as a reusable unit: React (TSX) with a props
|
||||
interface, Leptos with a `#[component]` function (`#[prop]` defaults, `view!` markup), SwiftUI
|
||||
with a `View` struct, Compose with a `@Composable` function. Its component properties are the
|
||||
parameters, with their defaults: variant properties become string unions (React) or enums,
|
||||
booleans become flags around the layers they show, text properties strings, instance swaps views
|
||||
(`React.ReactNode`, Leptos `ViewFn`, `AnyView`, a composable lambda) and slots optional content.
|
||||
Every variant of a set shares one definition; in React and Leptos the other variants'
|
||||
differences become CSS rules on `data-*` attributes. On an instance (or a layer tree containing
|
||||
instances) each instance is a call passing only the values it changes, e.g. `<Button
|
||||
state="Hover" label="Buy" />`, `<Button state=ButtonState::Hover label="Buy" />`, `Button(state:
|
||||
.hover, label: "Buy")`, `Button(state = ButtonState.Hover, label = "Buy")`. Names are escaped and
|
||||
unique (keywords get a suffix, `default` → `defaultValue`, Rust's `type` → `type_`; Leptos names
|
||||
are snake_case; names that would shadow framework types get one too, `Text` → `TextComponent`). `inspect.componentCode
|
||||
- **Component code:** `inspect.code {id, language: react|swiftui|compose}` on a component, a
|
||||
component set or a variant writes the component as a reusable unit: React (TSX) with a props
|
||||
interface, SwiftUI with a `View` struct, Compose with a `@Composable` function. Its component
|
||||
properties are the parameters, with their defaults: variant properties become string unions
|
||||
(React) or enums, booleans become flags around the layers they show, text properties strings,
|
||||
instance swaps views (`React.ReactNode`, `AnyView`, a composable lambda) and slots optional
|
||||
content. Every variant of a set shares one definition; in React the other variants' differences
|
||||
become CSS rules on `data-*` attributes. On an instance (or a layer tree containing instances)
|
||||
each instance is a call passing only the values it changes, e.g. `<Button state="Hover"
|
||||
label="Buy" />`, `Button(state: .hover, label: "Buy")`, `Button(state = ButtonState.Hover, label =
|
||||
"Buy")`. Names are escaped and unique (keywords get a suffix, `default` → `defaultValue`; names
|
||||
that would shadow framework types get one too, `Text` → `TextComponent`). `inspect.componentCode
|
||||
{id?, language?}` returns the parts: `{component, name, set, properties: [{name, param, type,
|
||||
default, value? (instances)}], definition, usage? (instances)}`.
|
||||
- **Code connect:** link a component (a variant links its set) to the code component it is built
|
||||
as, per language (`react`, `leptos`, `swiftui`, `compose`): `codeConnect.set {id?, language?, importPath?,
|
||||
as, per language (`react`, `swiftui`, `compose`): `codeConnect.set {id?, language?, importPath?,
|
||||
name?, template?}` (undoable; fields left out keep their value; the defaults are the generated
|
||||
name and a call passing every property) → `{component, language, code, unknown}`. Templates are
|
||||
text, never executed: `{property}` (its name or parameter name) is the instance's value escaped
|
||||
for the language (React and Leptos keep the markup's braces, so `size={size}` gives
|
||||
`size="Large"` and `disabled={disabled}` gives `disabled={true}`; Leptos imports with `use
|
||||
<importPath>::<name>;`), `{property ? "a" : "b"}` inserts one of two
|
||||
for the language (React keeps JSX's braces, so `size={size}` gives `size="Large"` and
|
||||
`disabled={disabled}` gives `disabled={true}`), `{property ? "a" : "b"}` inserts one of two
|
||||
strings, any other `{…}` stays as written (`unknown` lists placeholder names that aren't
|
||||
properties). Then `inspect.code` on an instance or the component returns the import line and the
|
||||
snippet instead of generated code (`connected: true`), connected instances inside a generated
|
||||
|
|
|
|||
|
|
@ -251,17 +251,12 @@ it can handle):
|
|||
|
||||
Limits per run, and again per panel callback: 1,000,000,000 fuel (about a second or two of plain
|
||||
WebAssembly in a debug build, far less in a release build), 64 MB of memory, 100,000 host calls
|
||||
(each also costs 10,000 fuel, including `reply_len`, `reply_read` and `set_output`), 16 MB per string either way, 1,000 log lines, and modules up to
|
||||
(each also costs 10,000 fuel), 16 MB per string either way, 1,000 log lines, and modules up to
|
||||
16 MB. A panel spec is at most 64 KB with 64 controls; settings are at most 64 KB per plugin (keys
|
||||
and JSON values), keys 1–128 characters. A widget callback gets the same limits; its description is
|
||||
at most 4 MB with 256 layers nested 16 deep, its state at most 16 KB (keys 1–128 characters), and
|
||||
its images 1 MB each, 2 MB together and 4096 pixels on a side.
|
||||
|
||||
Copying a byte across the guest-memory boundary costs one additional fuel, reserved before the
|
||||
allocation or copy. This also applies when the configured per-call overhead is zero. A reply
|
||||
read is charged for the bytes actually returned, not the requested buffer size. `calls` counts
|
||||
every ABI import, including calls which only query the reply length.
|
||||
|
||||
## ABI (version 3)
|
||||
|
||||
Version 2 adds panels and settings to version 1, and version 3 adds widgets; a version 1 or 2
|
||||
|
|
|
|||
|
|
@ -148,8 +148,7 @@ shared-mime-info file for `.figcraft`) and builds every format from it: `.tar.gz
|
|||
`.rpm` with [nfpm](https://nfpm.goreleaser.com/) (`nfpm.yaml` lists the dlopen'd runtime
|
||||
libraries as dependencies); and an `.AppImage` with update information and a `.zsync`, whose
|
||||
`AppRun` registers the desktop entry and icons on launch so Wayland docks show the right icon
|
||||
(`FIGCRAFT_NO_DESKTOP_INTEGRATION=1` opts out; covered by `apprun-test.sh`) and runs the bundled
|
||||
`figcraft-cli` for `FigCraft.AppImage cli <args>`.
|
||||
(`FIGCRAFT_NO_DESKTOP_INTEGRATION=1` opts out; covered by `apprun-test.sh`).
|
||||
`flatpak-bundle.sh` repackages the tarball into a `.flatpak` with
|
||||
`flatpak/ai.storyteller.figcraft.bundle.yml` (freedesktop 26.08). `flatpak/ai.storyteller.figcraft.yml`
|
||||
is the from-source manifest for a Flathub submission (not built in CI; vendor crates with
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
|
|||
| Bound on other keys (documented conflict) | 1 | Run last plugin: the documentation also gives Ctrl+Alt+P to pixel preview on Windows; the plugin takes Ctrl+Alt+Shift+P there |
|
||||
| Missing | 0 | moving the rotation origin (Alt+R, `transform.rotationOrigin`) and placing an object with Enter after choosing its tool (`tool.place`) were the last two |
|
||||
| Documented keys a platform keeps | 2 | Ctrl+Space (Windows/Linux keyboard box selection) switches some CJK input methods; F6 goes to a browser's address bar in the web build |
|
||||
| Undocumented bindings pinned | 49 | of which Cmd/Ctrl+W, Cmd/Ctrl+N and Ctrl+Tab are taken by browsers (web build) and Ctrl+Alt+T / Ctrl+Alt+L by some Linux desktops |
|
||||
| Undocumented bindings pinned | 48 | of which Cmd/Ctrl+W, Cmd/Ctrl+N and Ctrl+Tab are taken by browsers (web build) and Ctrl+Alt+T / Ctrl+Alt+L by some Linux desktops |
|
||||
|
||||
- **Newly bound:** A for the Frame tool, Shift+' for the pixel grid (Cmd+' kept: the articles
|
||||
disagree), Alt+Cmd+\ / Ctrl+Alt+\ for others' cursors (`view.multiplayerCursors`, new), Ctrl+F6
|
||||
|
|
@ -165,7 +165,6 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
|
|||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | Home screen: a Templates tab (eight starter templates as cards with thumbnails, search and a kind filter; click or Enter opens a new document) replaces the Community placeholder, the Drafts placeholder is removed, Recents offers four templates while there are no recent files and shows a Getting started card (closable; Help ▸ Getting started brings it back), File ▸ New from template…, and a first run opens on the home screen |
|
||||
| 2026-10-13 | minor | Full screen (#227): View ▸ Full screen (`view.fullscreen {on?}`, F11 on Windows and Linux, Ctrl+Cmd+F on macOS) makes the window fill the screen, taskbar and title bar included, and leaves it again; FigCraft's own item (not in the incumbent's menu). Percentages and hours unchanged |
|
||||
| 2026-10-12 | minor | Everyday editing partials: number fields (scrub a step a point, ×10 with Shift, ×0.1 with Alt, on past the screen edge where the platform reports it; arrow keys by the nudge preferences, opacity and rotation by 1 and 10; brackets and units in arithmetic; `50%` relative outside percentage fields; Tab commits and moves on; an untouched field commits nothing; relative edits and arrow keys on Mixed go to each layer in one undo step) done; outline mode's Include hidden layers and Include object bounds, drawn and clickable, done; Hide when publishing from the Assets panel done (the name's leading `.`); cursors per tool and modifier done (a table in the engine, drawn rotate, pen, eyedropper and comment cursors, not-allowed on refused edits; guides and handles keep their cursors); resizable panels done (limits, double-click reset, kept per window and in the preferences, the canvas keeps its room in a small window); a file or page made in the dark theme has a #1E1E1E page (the light theme and sessions without an interface: #F5F5F5) and text drawn on a dark page is white; Enter on a text selects all of it (test added). Menu items: View ▸ Outlines ▸ Include hidden layers / Include object bounds, View ▸ Panels ▸ Reset panel widths, Libraries ▸ Hide when publishing |
|
||||
| 2026-10-12 | minor | Drag threshold and double-clicks: the second press of a double-click began a move or resize that was never finished, so double-clicking text inside a group often didn't start editing (from the middle of the text) or dragged and resized it when the hand moved a pixel or two; a move or resize now starts only after 3 px of travel on screen and a double-click drops the press's gesture first |
|
||||
| 2026-10-12 | minor | Rotation origin and placing with Enter: Alt+R shows a rotation origin handle to drag (snapping to the box's corners, edge middles and centre; double-click resets; Escape hides; kept while the selection lasts, not saved), which the corner handles, the rotation field, Rotate 90°/180° and `transform.rotate` turn about (`transform.rotationOrigin`, `transform.setRotationOrigin`); Enter with a shape or frame tool chosen, on the canvas or the toolbar's tool, places a click-sized one in the middle of the view (`tool.place`). Documented shortcuts: 128 bound, 0 missing. The keyboard box selection's toast and screen-reader text name Cmd/Ctrl+arrows too; the shortcuts panel cuts a label that doesn't fit before its keycaps with "…" (measured in the font it's drawn in; long labels ran into the keycaps) and shows it in full on hover |
|
||||
|
|
|
|||
|
|
@ -8,9 +8,6 @@
|
|||
# (and whenever the AppImage's path changes) install the .desktop entry and the
|
||||
# hicolor icons into $XDG_DATA_HOME, then launch. Every failure here must leave
|
||||
# the launch untouched. Opt out with FIGCRAFT_NO_DESKTOP_INTEGRATION=1.
|
||||
#
|
||||
# `FigCraft.AppImage cli <args>` runs the bundled headless figcraft-cli with <args>
|
||||
# (e.g. `cli mcp`) instead of the app, without touching the desktop.
|
||||
|
||||
set -u
|
||||
|
||||
|
|
@ -71,17 +68,5 @@ integrate() {
|
|||
done
|
||||
}
|
||||
|
||||
# The CLI is headless and its stdout may be a protocol stream (`cli mcp`): no
|
||||
# desktop integration, straight to the binary.
|
||||
if [ "${1:-}" = cli ]; then
|
||||
shift
|
||||
cli="$APPDIR/usr/bin/figcraft-cli"
|
||||
if [ ! -x "$cli" ]; then
|
||||
echo "figcraft: this AppImage was built without figcraft-cli" >&2
|
||||
exit 127
|
||||
fi
|
||||
exec "$cli" "$@"
|
||||
fi
|
||||
|
||||
integrate
|
||||
exec "$APPDIR/usr/bin/figcraft" "$@"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
# Smoke test for packaging/linux/AppRun: the AppImage's desktop integration writes
|
||||
# and refreshes the host .desktop entry and icons, is idempotent, honours the opt-out
|
||||
# and an unwritable HOME, and always launches the app; `cli` runs figcraft-cli instead.
|
||||
# and an unwritable HOME, and always launches the app.
|
||||
#
|
||||
# Runs in seconds with no build (CI: .github/workflows/packaging-lint.yml).
|
||||
set -euo pipefail
|
||||
|
|
@ -95,19 +95,4 @@ out="$(APPIMAGE="$odd" "$APPDIR/AppRun" x)" || fail "launch with an odd path exi
|
|||
[ "$out" = "stub: x" ] || fail "odd path: $out"
|
||||
[ ! -e "$XDG_DATA_HOME" ] || fail "integrated an AppImage path that needs escaping"
|
||||
|
||||
# 10. `cli <args>` runs figcraft-cli with the remaining arguments and integrates nothing.
|
||||
rm -rf "$XDG_DATA_HOME"
|
||||
out="$("$APPDIR/AppRun" cli mcp --attach 7979 2>&1)" && fail "cli ran without figcraft-cli in the AppDir"
|
||||
[ "$out" = "figcraft: this AppImage was built without figcraft-cli" ] || fail "missing cli: $out"
|
||||
printf '#!/bin/sh\necho "cli stub: $*"\n' >"$APPDIR/usr/bin/figcraft-cli"
|
||||
chmod +x "$APPDIR/usr/bin/figcraft-cli"
|
||||
out="$("$APPDIR/AppRun" cli mcp --attach 7979)" || fail "cli launch exited non-zero"
|
||||
[ "$out" = "cli stub: mcp --attach 7979" ] || fail "cli arguments not forwarded: $out"
|
||||
out="$("$APPDIR/AppRun" cli)" || fail "bare cli launch exited non-zero"
|
||||
[ "$out" = "cli stub: " ] || fail "bare cli output: $out"
|
||||
[ ! -e "$XDG_DATA_HOME" ] || fail "cli launch integrated the desktop"
|
||||
# Only the first argument selects the CLI.
|
||||
out="$("$APPDIR/AppRun" file.figcraft cli)" || fail "app launch with a later cli argument exited non-zero"
|
||||
[ "$out" = "stub: file.figcraft cli" ] || fail "later cli argument hijacked the launch: $out"
|
||||
|
||||
echo "AppRun desktop integration ok"
|
||||
|
|
|
|||
Loading…
Reference in New Issue