mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-29 12:56:47 -06:00
M7: WASM plugins (#101)
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>
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
//! Swatch grid: a grid of colour swatches from a palette (hex colours), centred in the view, each
|
||||
//! named after its colour and optionally labelled.
|
||||
|
||||
use figcraft_plugin_sdk::{Input, Result, Value, call, entry, json, log, notify};
|
||||
|
||||
/// Swatches made in one run, at most.
|
||||
const MAX_SWATCHES: usize = 256;
|
||||
|
||||
/// `#RGB` or `#RRGGBB` (the `#` optional) as `#RRGGBB`.
|
||||
fn hex(s: &str) -> Option<String> {
|
||||
let h = s.trim().trim_start_matches('#');
|
||||
if !h.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
match h.len() {
|
||||
3 => Some(h.chars().flat_map(|c| [c, c]).collect::<String>()),
|
||||
6 => Some(h.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
.map(|h| format!("#{}", h.to_uppercase()))
|
||||
}
|
||||
|
||||
/// The palette's valid colours, logging the rest.
|
||||
fn palette(text: &str) -> Vec<String> {
|
||||
text.split([',', ' ', ';', '\n'])
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.filter_map(|s| {
|
||||
let c = hex(s);
|
||||
if c.is_none() {
|
||||
log(&format!("skipped `{s}`: not a hex colour"));
|
||||
}
|
||||
c
|
||||
})
|
||||
.take(MAX_SWATCHES)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn run(input: Input) -> Result<Value> {
|
||||
let colors = palette(input.str("palette").unwrap_or_default());
|
||||
if colors.is_empty() {
|
||||
return Err("The palette has no hex colours (like #FF5A5F, #00A699)".into());
|
||||
}
|
||||
let columns = input.f64("columns").unwrap_or(3.0).clamp(1.0, 24.0) as usize;
|
||||
let size = input.f64("size").unwrap_or(64.0).clamp(8.0, 512.0);
|
||||
let gap = input.f64("gap").unwrap_or(16.0).clamp(0.0, 256.0);
|
||||
let labels = input.bool("labels").unwrap_or(true);
|
||||
let rows = colors.len().div_ceil(columns);
|
||||
let cell_h = if labels { size + 20.0 } else { size };
|
||||
let (w, h) = (columns as f64 * (size + gap) - gap, rows as f64 * (cell_h + gap) - gap);
|
||||
// Centred in the view, on whole pixels.
|
||||
let view = call("view.get", &json!({}))?;
|
||||
let (x0, y0) = ((view["cx"].as_f64().unwrap_or(0.0) - w / 2.0).round(), (view["cy"].as_f64().unwrap_or(0.0) - h / 2.0).round());
|
||||
let mut made = Vec::new();
|
||||
for (i, color) in colors.iter().enumerate() {
|
||||
let (x, y) = (x0 + (i % columns) as f64 * (size + gap), y0 + (i / columns) as f64 * (cell_h + gap));
|
||||
let r = call("shape.rectangle", &json!({"x": x, "y": y, "width": size, "height": size, "props": {"name": color, "fill": color}}))?;
|
||||
made.extend(r["id"].as_u64());
|
||||
if labels {
|
||||
let t = call("text.create", &json!({"text": color, "x": x, "y": y + size + 4.0, "props": {"fontSize": 11}}))?;
|
||||
made.extend(t["id"].as_u64());
|
||||
}
|
||||
}
|
||||
call("select.set", &json!({"ids": made}))?;
|
||||
notify(&format!("Made {} swatches", colors.len()));
|
||||
Ok(json!({"swatches": colors.len(), "ids": made}))
|
||||
}
|
||||
|
||||
entry!(run);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{hex, palette};
|
||||
|
||||
#[test]
|
||||
fn palettes_parse() {
|
||||
assert_eq!(hex("#f0a"), Some("#FF00AA".into()));
|
||||
assert_eq!(hex(" 00a699 "), Some("#00A699".into()));
|
||||
assert_eq!(hex("#12345"), None);
|
||||
assert_eq!(hex("red"), None);
|
||||
assert_eq!(palette("#FF5A5F, nope;#0f0\n00A699"), vec!["#FF5A5F", "#00FF00", "#00A699"]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user