mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-25 02:47:02 -06:00
Widgets for FigCraft's own WebAssembly plugins (plugin API 3; API 1 and 2 plugins run unchanged), plus the panel tail.
- **Widgets.** A manifest with `"api": 3, "widget": true` makes a widget plugin. Plugins > Widgets (or `widget.insert {plugin, x?, y?}`) puts its widget on the page: the plugin is called with `widget: {id, state, event}` and hands back a description through the new `widget_render` import (`{state?, root}`: frames with auto layout, text, rectangles, ellipses and images it carries as base64). FigCraft validates the description (4 MB, 256 layers, 16 deep, finite numbers within ranges, hex colours, text length and control characters, a 16 KB state, images 1 MB each / 2 MB together / 4096 px a side) and builds it as a frame whose layers are locked, so the widget moves as one layer.
- **Clicks.** A plain click with the Move tool on a part the plugin named as a `target` (the cursor turns into a pointing hand there; quick repeated clicks each count) calls the plugin back in a fresh, metered sandbox (same fuel, memory and call limits as runs; nothing kept between calls, so the state and the click alone decide the result) as one undo step, and the widget is redrawn. Dragging moves it; Shift/Cmd/Alt clicks only select. Agents use `widget.click {id, target}` and `widget.list`.
- **State in the document.** The plugin id and the state live in the widget's own layer data (`figcraft.widget*`), so widgets survive save and reload, duplicate/copy and paste (the copy is a widget of its own), undo/redo, export (the built layers draw) and live sessions (synced like any layer data). No plugin can write `figcraft.widget*` data or call `widget.*`; a callback can only rebuild its own widget, and touches other layers only through the commands its manifest permits. A widget whose plugin is missing stays as drawn.
- **Example.** `plugins/examples/counter`: a counter between - and + buttons (read access, no assets). The SDK gained `Input::widget` and `render_widget`.
- **Panel tail.** Panels of several plugins are open at once, side by side (`plugin.ui` without an id returns the one opened last); a colour field's swatch opens FigCraft's colour picker beside the plugin's window (the plugin hears of the colour when a drag ends; also for parameters forms). Swatch grid's palette panel gained a label colour field.
- Docs: plugins.md (ABI v3, widget description, commands, limits), mcp.md, target-app-parity area 18, ROADMAP, gaps gap 6, roadmap M11; Spanish labels for the new commands.
157 lines
6.4 KiB
Rust
157 lines
6.4 KiB
Rust
//! Swatch grid: a grid of colour swatches, centred in the view, each named after its colour and
|
|
//! optionally labelled. Two commands: a panel to pick one of the built-in palettes and Generate
|
|
//! (the palette picked last is remembered for next time), and a form taking hex colours.
|
|
|
|
use figcraft_plugin_sdk::{Event, Input, Result, Value, call, close_ui, entry, json, log, notify, set_setting, setting, show_ui};
|
|
|
|
/// The labels' colour unless the panel picks another.
|
|
const LABEL: &str = "#000000";
|
|
|
|
/// Swatches made in one run, at most.
|
|
const MAX_SWATCHES: usize = 256;
|
|
|
|
/// The panel's palettes: (name, colours).
|
|
const PALETTES: &[(&str, &str)] = &[
|
|
("Sunset", "#FF5A5F, #FFB400, #00A699, #007A87, #7B0051, #3D3D3D"),
|
|
("Ocean", "#0A2463, #1E5AA8, #3E92CC, #8CC7E8, #D8F1FF"),
|
|
("Forest", "#183A2B, #2F6B4F, #4E9F6E, #8CCB9B, #D3EED8"),
|
|
("Greys", "#111111, #444444, #777777, #AAAAAA, #DDDDDD"),
|
|
];
|
|
|
|
/// `#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()
|
|
}
|
|
|
|
/// The colours of the built-in palette `name` (empty when there's none).
|
|
fn named(name: &str) -> &'static str {
|
|
PALETTES.iter().find(|(n, _)| *n == name).map_or("", |(_, c)| c)
|
|
}
|
|
|
|
/// Swatches of `colors` in a grid centred in the view (labelled in colour `labels` when given),
|
|
/// then selected.
|
|
fn grid(colors: &[String], columns: f64, size: f64, gap: f64, labels: Option<&str>) -> Result<Value> {
|
|
if colors.is_empty() {
|
|
return Err("The palette has no hex colours (like #FF5A5F, #00A699)".into());
|
|
}
|
|
let columns = columns.clamp(1.0, 24.0) as usize;
|
|
let (size, gap) = (size.clamp(8.0, 512.0), gap.clamp(0.0, 256.0));
|
|
let rows = colors.len().div_ceil(columns);
|
|
let cell_h = if labels.is_some() { 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 let Some(label) = labels {
|
|
let t = call("text.create", &json!({"text": color, "x": x, "y": y + size + 4.0, "props": {"fontSize": 11, "fill": label}}))?;
|
|
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}))
|
|
}
|
|
|
|
/// Show the panel with palette `name` picked and the other fields as in `values` (null: their
|
|
/// defaults).
|
|
fn panel(name: &str, values: &Value) -> Result<Value> {
|
|
let names: Vec<&str> = PALETTES.iter().map(|(n, _)| *n).collect();
|
|
show_ui(&json!({"title": "Swatch grid", "controls": [
|
|
{"type": "list", "name": "palette", "label": "Palette", "options": names, "value": name},
|
|
{"type": "note", "label": named(name)},
|
|
{"type": "number", "name": "columns", "label": "Columns", "min": 1, "max": 24, "value": values["columns"].as_f64().unwrap_or(3.0)},
|
|
{"type": "boolean", "name": "labels", "label": "Label each swatch", "value": values["labels"].as_bool().unwrap_or(true)},
|
|
{"type": "color", "name": "labelColor", "label": "Label colour", "value": values["labelColor"].as_str().unwrap_or(LABEL)},
|
|
{"type": "button", "name": "generate", "label": "Generate"},
|
|
{"type": "button", "name": "close", "label": "Close"}
|
|
]}))?;
|
|
Ok(Value::Null)
|
|
}
|
|
|
|
/// A button or a changed value in the panel.
|
|
fn on_event(e: Event) -> Result<Value> {
|
|
let name = e.values["palette"].as_str().unwrap_or("Sunset");
|
|
match e.control.as_str() {
|
|
// Show the picked palette's colours, and remember it for next time.
|
|
"palette" => {
|
|
set_setting("palette", &json!(name))?;
|
|
panel(name, &e.values)
|
|
}
|
|
"generate" => {
|
|
let labels = e.values["labels"].as_bool().unwrap_or(true).then(|| e.values["labelColor"].as_str().unwrap_or(LABEL));
|
|
grid(&palette(named(name)), e.values["columns"].as_f64().unwrap_or(3.0), 64.0, 16.0, labels)
|
|
}
|
|
"close" => {
|
|
close_ui();
|
|
Ok(Value::Null)
|
|
}
|
|
_ => Ok(Value::Null),
|
|
}
|
|
}
|
|
|
|
fn run(input: Input) -> Result<Value> {
|
|
if let Some(e) = input.event {
|
|
return on_event(e);
|
|
}
|
|
if input.command == "panel" {
|
|
let last = setting("palette").and_then(|v| v.as_str().map(str::to_string)).filter(|n| !named(n).is_empty());
|
|
return panel(last.as_deref().unwrap_or("Sunset"), &Value::Null);
|
|
}
|
|
let colors = palette(input.str("palette").unwrap_or_default());
|
|
grid(
|
|
&colors,
|
|
input.f64("columns").unwrap_or(3.0),
|
|
input.f64("size").unwrap_or(64.0),
|
|
input.f64("gap").unwrap_or(16.0),
|
|
input.bool("labels").unwrap_or(true).then_some(LABEL),
|
|
)
|
|
}
|
|
|
|
entry!(run);
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{PALETTES, 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"]);
|
|
for (name, colors) in PALETTES {
|
|
assert_eq!(palette(colors).len(), colors.split(',').count(), "{name}");
|
|
}
|
|
}
|
|
}
|