mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-25 19:06:47 -06:00
Sandboxed WebAssembly plugins whose API is the command registry: - figcraft-plugin (new crate, L2): the figcraft-plugin.json manifest (menu commands with parameters, read/write access, command allowlist) and the sandbox: wasmi (pure Rust, no JIT, also in the web build) with fuel metering, a memory cap, no WASI and seven host functions (call, selection, reply_len, reply_read, log, notify, set_output). - Engine: plugin.list / plugin.import / plugin.remove / plugin.run, plugins.runLast (Alt+Cmd+P; Ctrl+Alt+Shift+P on Windows/Linux) and plugins.manage. A run is one undo step (Session::grouped, which command.batch now uses too) and is rolled back when it fails. Files, other documents, the undo history, dialogs and plugins are off limits; read-only plugins can't change the document. Installed plugins live in the preferences; development plugins re-read their module every run. - UI: installed plugins in the Plugins menu, a parameters form drawn from the manifest, Manage plugins, Plugins > Development > Import plugin from manifest. - plugins/: a no_std Rust SDK and two example plugins (Tidy names, Swatch grid), built for wasm32 by `cargo xtask plugins` (part of `cargo xtask wasm`). - Docs: docs/plugins.md (manifest, permissions, limits, ABI, SDK), docs/mcp.md, docs/control-protocol.md, README, ROADMAP, parity. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
88 lines
3.0 KiB
Rust
88 lines
3.0 KiB
Rust
//! Tidy names: cleans up the selected layers' names (extra spaces, the "copy" left by duplicating)
|
|
//! and optionally their letter case, inside the selection too.
|
|
|
|
use figcraft_plugin_sdk::{Input, Result, Value, call, entry, json, notify};
|
|
|
|
/// Layers looked at in one run, at most.
|
|
const MAX_LAYERS: usize = 10_000;
|
|
|
|
/// `name` with single spaces, no trailing "copy" / "copy 2", in `case`.
|
|
fn tidy(name: &str, case: &str) -> String {
|
|
let mut words: Vec<&str> = name.split_whitespace().collect();
|
|
loop {
|
|
match words.as_slice() {
|
|
[_, .., "copy"] => {
|
|
words.pop();
|
|
}
|
|
[_, .., "copy", n] if n.chars().all(|c| c.is_ascii_digit()) => {
|
|
words.truncate(words.len() - 2);
|
|
}
|
|
_ => break,
|
|
}
|
|
}
|
|
let joined = words.join(" ");
|
|
match case {
|
|
"Title Case" => words.iter().map(|w| title(w)).collect::<Vec<_>>().join(" "),
|
|
"lower case" => joined.to_lowercase(),
|
|
"UPPER CASE" => joined.to_uppercase(),
|
|
_ => joined,
|
|
}
|
|
}
|
|
|
|
fn title(word: &str) -> String {
|
|
let mut chars = word.chars();
|
|
match chars.next() {
|
|
Some(first) => first.to_uppercase().chain(chars.flat_map(char::to_lowercase)).collect(),
|
|
None => String::new(),
|
|
}
|
|
}
|
|
|
|
fn run(input: Input) -> Result<Value> {
|
|
if input.selection.is_empty() {
|
|
return Err("Select the layers to tidy first".into());
|
|
}
|
|
let case = if input.command == "quick" { "Keep" } else { input.str("case").unwrap_or("Keep") };
|
|
let inside = input.command == "quick" || input.bool("children").unwrap_or(true);
|
|
let mut stack = input.selection.clone();
|
|
let (mut seen, mut renamed) = (0, 0);
|
|
while let Some(id) = stack.pop() {
|
|
seen += 1;
|
|
if seen > MAX_LAYERS {
|
|
break;
|
|
}
|
|
let node = call("node.get", &json!({"id": id}))?;
|
|
if inside && let Some(children) = node["children"].as_array() {
|
|
stack.extend(children.iter().filter_map(|c| c["id"].as_u64()));
|
|
}
|
|
let name = node["name"].as_str().unwrap_or_default();
|
|
let clean = tidy(name, case);
|
|
if !clean.is_empty() && clean != name {
|
|
call("object.rename", &json!({"ids": [id], "name": clean}))?;
|
|
renamed += 1;
|
|
}
|
|
}
|
|
notify(&match renamed {
|
|
0 => "The names were already tidy".to_string(),
|
|
1 => "Renamed 1 layer".to_string(),
|
|
n => format!("Renamed {n} layers"),
|
|
});
|
|
Ok(json!({"renamed": renamed}))
|
|
}
|
|
|
|
entry!(run);
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::tidy;
|
|
|
|
#[test]
|
|
fn names_are_tidied() {
|
|
assert_eq!(tidy(" Card title copy 2 ", "Keep"), "Card title");
|
|
assert_eq!(tidy("Button copy copy", "Keep"), "Button");
|
|
assert_eq!(tidy("copy", "Keep"), "copy", "a name that is only `copy` stays");
|
|
assert_eq!(tidy("primary BUTTON", "Title Case"), "Primary Button");
|
|
assert_eq!(tidy("Hero Image", "lower case"), "hero image");
|
|
assert_eq!(tidy("nav", "UPPER CASE"), "NAV");
|
|
}
|
|
}
|