Compare commits

..

2 Commits

Author SHA1 Message Date
bflatastic 4f35bd0097 Docs: the header lists all five community fixes 2026-10-14 07:32:02 +07:00
bflatastic 4bb43ab11e Docs: roadmap entries for the community fixes landed in #211, #213, #214, #216 and #218
Their ROADMAP.md lines were left out when the five PRs were rebased (all five edited the
same header line); the wording is the contributors' own.
2026-10-14 07:31:41 +07:00
61 changed files with 170 additions and 1378 deletions

5
Cargo.lock generated
View File

@ -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"

View File

@ -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"

View File

@ -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`.

View File

@ -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();
}

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -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 }))))
}

View File

@ -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()

View 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);
}

View File

@ -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.

View File

@ -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.

View File

@ -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);
}

View File

@ -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"),

View File

@ -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});

View File

@ -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")),

View File

@ -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 {

View File

@ -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");

View File

@ -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"));
}

View File

@ -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
}

View File

@ -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");

View File

@ -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"]);
}

View File

@ -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"]),

View File

@ -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)]

View File

@ -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());
}

View File

@ -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 {

View File

@ -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]

View File

@ -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();

View File

@ -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"));
}

View File

@ -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;

View File

@ -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(())

View File

@ -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.

View File

@ -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);
}

View File

@ -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),
}
}

View File

@ -17,5 +17,4 @@ figcraft-text = { workspace = true }
figcraft-pathops = { workspace = true }
usvg = { workspace = true }
base64 = { workspace = true }
flate2 = { workspace = true }
thiserror = { workspace = true }

View File

@ -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),
}
}

View File

@ -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());
}

View File

@ -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()))?;

View File

@ -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();

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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" = "ビューを設定"

View File

@ -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" = "보기 설정"

View File

@ -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"

View File

@ -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" = "设置视图"

View File

@ -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;

View File

@ -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;

View File

@ -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),
_ => {}
}
}

View File

@ -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);
}

View File

@ -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));

View File

@ -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![]);
}

View File

@ -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 {

View File

@ -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 |

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 |

View File

@ -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" "$@"

View File

@ -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"