mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-09 20:12:49 +00:00
* perf(engine): replace linear scans that made document-sized inputs quadratic enum_of re-classified the whole string for every character; widget names, comment mentions and devcompare's common siblings each searched a growing Vec; data.fill's export names and codegen's class names retried every suffix from 2 against a list, cubic in the number of copies of one name; and a batch rename template had no length cap although it is expanded again for every match. * address review: report the pattern in its own length error
607 lines
28 KiB
Rust
607 lines
28 KiB
Rust
//! Widgets: layers on the canvas that a plugin draws (plugin API 3). A widget is a frame owned by
|
|
//! its plugin: the plugin describes it ([`WidgetRender`]: frames with auto layout, text, shapes and
|
|
//! images) and FigCraft builds the layers inside it, locked, so the widget moves as one layer.
|
|
//! Each widget keeps its plugin's id and a small state (a JSON object) in its own layer data, so it
|
|
//! is saved, copied, undone, exported and synced in a live session like any layer. Clicking a part
|
|
//! of it the plugin named as a target calls the plugin back with that state, in a fresh sandbox
|
|
//! (deterministic: the state is all it remembers), as one undo step; the plugin hands back the new
|
|
//! state and layers. A callback can only rebuild its own widget; other layers change only through
|
|
//! the commands its manifest permits. Since API 4 a text layer can be an editable field (edited on
|
|
//! the canvas like any text, or with `widget.setText`: the value goes into the state under the
|
|
//! field's name and the plugin is called back), and a widget can have a small property menu
|
|
//! (actions, toggles, a colour from a few) shown while it's selected (`widget.menu`). Since API 5 a
|
|
//! callback can open the plugin's panel, whose callbacks get the widget and can redraw it
|
|
//! ([`panel_callback`]). See `docs/plugins.md`.
|
|
|
|
use figcraft_color::{ImageScaleMode, Paint};
|
|
use figcraft_doc::{Document, Node, NodeId, NodeKind, TextData};
|
|
use figcraft_geom::{Affine, Point};
|
|
use figcraft_plugin::widget::{MAX_IMAGE_BYTES, MAX_NODES, MAX_STATE_BYTES, check_menu, check_state, clean_field};
|
|
use figcraft_plugin::{Instance, Manifest, RunOutput, UiChange, WidgetHover, WidgetKind, WidgetNode, WidgetRender};
|
|
pub use figcraft_plugin::{MenuItem, MenuKind};
|
|
use serde_json::{Map, Value, json};
|
|
|
|
use crate::cmd::CommandSpec;
|
|
use crate::params::{id, num, string};
|
|
use crate::plugins::{InstalledPlugin, call_plugin, close_panel, installed, limits, module_of, open_panel, plugin_error};
|
|
use crate::{Result, Session, bad, spec};
|
|
|
|
/// Layer data FigCraft keeps for widgets starts with this; plugins can't write it.
|
|
pub const DATA_PREFIX: &str = "figcraft.widget";
|
|
/// On a widget's own layer: its plugin's id, and its state (JSON).
|
|
const PLUGIN: &str = "figcraft.widget";
|
|
const STATE: &str = "figcraft.widget.state";
|
|
/// On a widget's own layer: its property menu (JSON).
|
|
const MENU: &str = "figcraft.widget.menu";
|
|
/// On a layer inside a widget: the name its plugin gets when it's clicked.
|
|
const TARGET: &str = "figcraft.widget.target";
|
|
/// On a text layer inside a widget: the name of the editable field it is.
|
|
const FIELD: &str = "figcraft.widget.field";
|
|
/// On a widget or a layer inside one: how it looks while the pointer is over it (JSON).
|
|
const HOVER: &str = "figcraft.widget.hover";
|
|
/// Images in a widget are at most this many pixels on a side.
|
|
const MAX_IMAGE_SIDE: u32 = 4096;
|
|
|
|
/// Whether `n` is a widget's own layer.
|
|
pub fn is_widget(n: &Node) -> bool {
|
|
matches!(n.kind, NodeKind::Frame) && n.plugin_data.contains_key(PLUGIN)
|
|
}
|
|
|
|
/// The widget layer `id` is, or is inside (the innermost).
|
|
pub fn widget_of(doc: &Document, id: NodeId) -> Option<NodeId> {
|
|
std::iter::once(id).chain(doc.ancestors(id)).find(|a| doc.node(*a).is_some_and(is_widget))
|
|
}
|
|
|
|
/// The part of a widget at page point `p` marked with layer data `key`, given the layer a click
|
|
/// there hits: the widget, the part and its name. The layers inside a widget are locked, so the
|
|
/// hit stops at the widget itself; the front-most part whose box holds the point wins.
|
|
fn part_at(doc: &Document, hit: NodeId, p: Point, key: &str) -> Option<(NodeId, NodeId, String)> {
|
|
let w = widget_of(doc, hit)?;
|
|
doc.subtree(w)
|
|
.into_iter()
|
|
.filter(|i| doc.world_bounds(*i).is_some_and(|b| b.contains(p)))
|
|
.filter_map(|i| Some((w, i, doc.node(i).filter(|n| n.visible)?.plugin_data.get(key).cloned()?)))
|
|
.last()
|
|
}
|
|
|
|
/// The clickable part of a widget at page point `p`, given the layer a click there hits: the widget
|
|
/// and its target.
|
|
pub fn target_at(doc: &Document, hit: NodeId, p: Point) -> Option<(NodeId, String)> {
|
|
part_at(doc, hit, p, TARGET).map(|(w, _, t)| (w, t))
|
|
}
|
|
|
|
/// The editable text field of a widget at page point `p`, given the layer a click there hits: its
|
|
/// text layer.
|
|
pub fn field_at(doc: &Document, hit: NodeId, p: Point) -> Option<NodeId> {
|
|
part_at(doc, hit, p, FIELD).map(|(_, f, _)| f).filter(|f| field_of(doc, *f).is_some())
|
|
}
|
|
|
|
/// The widget and field name text layer `id` is the editable field of.
|
|
pub fn field_of(doc: &Document, id: NodeId) -> Option<(NodeId, String)> {
|
|
let field = doc.node(id).filter(|n| matches!(n.kind, NodeKind::Text(_)))?.plugin_data.get(FIELD)?.clone();
|
|
Some((widget_of(doc, id)?, field))
|
|
}
|
|
|
|
/// The layers of a widget under page point `p` that change while the pointer is over them (the
|
|
/// widget itself among them), given the layer the pointer hits, outermost first.
|
|
pub fn hovered_at(doc: &Document, hit: NodeId, p: Point) -> Vec<NodeId> {
|
|
let Some(w) = widget_of(doc, hit) else { return Vec::new() };
|
|
doc.subtree(w)
|
|
.into_iter()
|
|
.filter(|i| doc.node(*i).is_some_and(|n| n.visible && n.plugin_data.contains_key(HOVER)))
|
|
.filter(|i| doc.world_bounds(*i).is_some_and(|b| b.contains(p)))
|
|
.collect()
|
|
}
|
|
|
|
/// What layer `n` sets while the pointer is over it, as `node.set` properties (none when it has no
|
|
/// usable hover style: the layer data may come from a file).
|
|
fn hover_props(n: &Node) -> Option<Map<String, Value>> {
|
|
let text = n.plugin_data.get(HOVER).filter(|s| s.len() <= MAX_STATE_BYTES)?;
|
|
let mut h: WidgetHover = serde_json::from_str(text).ok()?;
|
|
h.check(if matches!(n.kind, NodeKind::Text(_)) { WidgetKind::Text } else { WidgetKind::Rectangle }).ok()?;
|
|
let mut p = Map::new();
|
|
if let Some(c) = h.fill.or(h.color) {
|
|
p.insert("fills".into(), json!([c]));
|
|
}
|
|
if let Some(c) = h.stroke {
|
|
p.insert("strokes".into(), json!([c]));
|
|
}
|
|
if let Some(o) = h.opacity {
|
|
p.insert("opacity".into(), json!(o));
|
|
}
|
|
Some(p)
|
|
}
|
|
|
|
impl Session {
|
|
/// Show the hover styles of widget layers `ids` as a live preview (none: end the one showing):
|
|
/// nothing is undone, saved or sent to a live session, and the next command puts the document
|
|
/// back first. Layers without a hover style are left as they are.
|
|
pub fn widget_hover(&mut self, ids: &[NodeId]) -> Result<bool> {
|
|
let cmd = "widget.hover";
|
|
crate::textpreview::end(self);
|
|
let doc = self.doc()?;
|
|
let changes: Vec<(NodeId, Map<String, Value>)> =
|
|
ids.iter().filter(|i| widget_of(doc, **i).is_some()).filter_map(|i| Some((*i, hover_props(doc.node(*i)?)?))).collect();
|
|
if changes.is_empty() {
|
|
return Ok(false);
|
|
}
|
|
crate::textpreview::show_change(self, cmd, |doc| changes.iter().try_for_each(|(id, p)| crate::cmd::props::apply(cmd, doc, *id, p)))?;
|
|
Ok(true)
|
|
}
|
|
}
|
|
|
|
/// Widget `w`'s property menu (empty when it has none or it's unusable: it may come from a file).
|
|
pub fn menu_of(doc: &Document, w: NodeId) -> Vec<MenuItem> {
|
|
doc.node(w)
|
|
.and_then(|n| n.plugin_data.get(MENU))
|
|
.filter(|s| s.len() <= MAX_STATE_BYTES)
|
|
.and_then(|s| serde_json::from_str::<Vec<MenuItem>>(s).ok())
|
|
.and_then(|mut m| check_menu(&mut m).is_ok().then_some(m))
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// A widget's state (an empty object when it has none or it's unusable).
|
|
fn state_of(doc: &Document, w: NodeId) -> Map<String, Value> {
|
|
doc.node(w)
|
|
.and_then(|n| n.plugin_data.get(STATE))
|
|
.filter(|s| s.len() <= MAX_STATE_BYTES)
|
|
.and_then(|s| serde_json::from_str::<Value>(s).ok())
|
|
.and_then(|v| match v {
|
|
Value::Object(m) => Some(m),
|
|
_ => None,
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// The names of the parts inside widget `w` marked with layer data `key` (its targets or its
|
|
/// fields), each once, in layer order.
|
|
fn names(doc: &Document, w: NodeId, key: &str) -> Vec<String> {
|
|
let mut out: Vec<String> = Vec::new();
|
|
let mut seen = std::collections::BTreeSet::new();
|
|
for t in doc.subtree(w).into_iter().filter_map(|i| doc.node(i)?.plugin_data.get(key).cloned()) {
|
|
if seen.insert(t.clone()) {
|
|
out.push(t);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Installed widget plugin `plugin`, its manifest checked again (the preferences may have been
|
|
/// edited by hand).
|
|
fn widget_plugin(s: &Session, cmd: &str, plugin: &str) -> Result<InstalledPlugin> {
|
|
let mut p = installed(s, cmd, plugin)?.clone();
|
|
p.manifest = Manifest::from_value(&json!(p.manifest)).map_err(|e| bad(cmd, e))?;
|
|
if !p.manifest.widget {
|
|
return Err(bad(cmd, format!("{} doesn't draw a widget", p.manifest.name)));
|
|
}
|
|
Ok(p)
|
|
}
|
|
|
|
/// Call widget `w`'s plugin `p` back (`event`: what happened, a click, an edit or a menu item;
|
|
/// none when it's inserted) and build what it hands back. Part of the caller's undo step; a failure
|
|
/// rolls the whole step back.
|
|
fn callback(s: &mut Session, p: &InstalledPlugin, w: NodeId, event: Option<Value>, fuel: Option<f64>) -> Result<RunOutput> {
|
|
let m = &p.manifest;
|
|
let wasm = module_of(s, p)?;
|
|
let inserting = event.is_none();
|
|
let state = state_of(s.doc()?, w);
|
|
let input =
|
|
json!({"api": m.api, "command": "widget", "params": {}, "selection": s.selection(), "widget": {"id": w, "state": state, "event": event}});
|
|
let mut instance = Instance::new(&wasm, &limits(fuel)).map_err(|e| plugin_error(&m.name, e))?;
|
|
let mut out = call_plugin(s, m, &m.name, &mut instance, &input.to_string()).map_err(|(e, _)| e)?;
|
|
match out.widget.take() {
|
|
Some(r) if m.api < 4 && r.uses_api_4() => return Err(bad("widget.render", format!("{}: fields and menus need plugin API 4", m.name))),
|
|
Some(r) if m.api < 6 && r.uses_api_6() => return Err(bad("widget.render", format!("{}: lines and SVG need plugin API 6", m.name))),
|
|
Some(r) => build(s, &m.id, w, &r)?,
|
|
None if inserting => return Err(bad("widget.insert", format!("{} drew no widget", m.name))),
|
|
None => {}
|
|
}
|
|
// API 5: a widget can open its plugin's panel (an older one's `ui_show` is ignored, as before);
|
|
// the module stays loaded for the panel's callbacks, which can redraw this widget.
|
|
match out.ui.take() {
|
|
Some(UiChange::Show(panel)) if m.api >= 5 => open_panel(s, m, ("widget", json!({}), m.name.clone()), panel, instance, Some(w)),
|
|
Some(UiChange::Close) if m.api >= 5 => close_panel(s, &m.id),
|
|
_ => {}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// A callback of a panel widget `w` opened: the input gets the widget (its id and state, no
|
|
/// `event` of its own), and what the plugin draws rebuilds it, all in one undo step.
|
|
pub(crate) fn panel_callback(
|
|
s: &mut Session,
|
|
m: &Manifest,
|
|
label: &str,
|
|
instance: &mut Instance,
|
|
mut input: Value,
|
|
w: NodeId,
|
|
) -> std::result::Result<RunOutput, (crate::EngineError, bool)> {
|
|
// Only a crash or a spent budget unloads the module; a failed redraw keeps the panel.
|
|
let mut usable = true;
|
|
s.grouped(label, |s| {
|
|
if let Some(o) = input.as_object_mut() {
|
|
o.insert("widget".into(), json!({"id": w, "state": state_of(s.doc()?, w)}));
|
|
}
|
|
let mut out = call_plugin(s, m, label, instance, &input.to_string()).map_err(|(e, u)| {
|
|
usable = u;
|
|
e
|
|
})?;
|
|
if let Some(r) = out.widget.take() {
|
|
build(s, &m.id, w, &r)?;
|
|
}
|
|
Ok(out)
|
|
})
|
|
.map_err(|e| (e, usable))
|
|
}
|
|
|
|
/// Rebuild widget `w` of `plugin` from `r`: its own look and state, and new locked layers inside.
|
|
fn build(s: &mut Session, plugin: &str, w: NodeId, r: &WidgetRender) -> Result<()> {
|
|
let cmd = "widget.render";
|
|
let ds = s.ds_mut()?;
|
|
let before = ds.doc.clone();
|
|
let doc = ds.doc_mut();
|
|
let n = doc.get(w).map_err(|_| bad(cmd, "the widget was removed while its plugin ran"))?;
|
|
if !is_widget(n) || n.plugin_data.get(PLUGIN).map(String::as_str) != Some(plugin) {
|
|
return Err(bad(cmd, "the layer is no longer this plugin's widget"));
|
|
}
|
|
doc.remove_children(w)?;
|
|
crate::cmd::props::apply(cmd, doc, w, &props(&r.root, true))?;
|
|
let n = doc.get_mut(w)?;
|
|
n.locked = false;
|
|
if !r.root.name.is_empty() {
|
|
n.name = r.root.name.clone();
|
|
}
|
|
if let Some(state) = &r.state {
|
|
n.plugin_data.insert(STATE.into(), Value::Object(state.clone()).to_string());
|
|
}
|
|
match serde_json::to_string(&r.menu) {
|
|
Ok(menu) if !r.menu.is_empty() => n.plugin_data.insert(MENU.into(), menu),
|
|
_ => n.plugin_data.remove(MENU),
|
|
};
|
|
set_hover(n, r.root.hover.as_ref());
|
|
for c in &r.root.children {
|
|
add(doc, w, c, cmd)?;
|
|
}
|
|
// An SVG brings as many layers as it describes.
|
|
if doc.subtree(w).len() > MAX_NODES {
|
|
return Err(bad(cmd, format!("the widget has more than {MAX_NODES} layers")));
|
|
}
|
|
crate::normalize(doc, &before);
|
|
Ok(())
|
|
}
|
|
|
|
/// Add layer `d` (and what it holds) inside `parent`, locked.
|
|
fn add(doc: &mut Document, parent: NodeId, d: &WidgetNode, cmd: &str) -> Result<()> {
|
|
let (kind, name) = match d.kind {
|
|
WidgetKind::Frame => (NodeKind::Frame, "Frame"),
|
|
WidgetKind::Text => (NodeKind::Text(TextData { characters: d.text.clone(), ..TextData::default() }), "Text"),
|
|
WidgetKind::Rectangle => (NodeKind::Rectangle, "Rectangle"),
|
|
WidgetKind::Ellipse => (NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "Ellipse"),
|
|
WidgetKind::Image => (NodeKind::Rectangle, "Image"),
|
|
WidgetKind::Line => (NodeKind::Line, "Line"),
|
|
WidgetKind::Svg => return add_svg(doc, parent, d, cmd),
|
|
};
|
|
let name = if d.name.is_empty() { name.to_string() } else { d.name.clone() };
|
|
let id = doc.alloc_id();
|
|
let height = if d.kind == WidgetKind::Line { 0.0 } else { d.height.unwrap_or(100.0) };
|
|
let mut n = Node::new(id, kind, name, d.width.unwrap_or(100.0), height);
|
|
mark(&mut n, d);
|
|
if d.kind == WidgetKind::Image {
|
|
use base64::Engine as _;
|
|
let bytes = base64::engine::general_purpose::STANDARD.decode(d.data.trim()).map_err(|e| bad(cmd, format!("an image's `data`: {e}")))?;
|
|
if bytes.len() > MAX_IMAGE_BYTES {
|
|
return Err(bad(cmd, format!("an image is larger than {} KB", MAX_IMAGE_BYTES / 1024)));
|
|
}
|
|
let (hash, iw, ih) = crate::cmd::create::add_readable_image(doc, bytes).map_err(|e| bad(cmd, e))?;
|
|
if iw > MAX_IMAGE_SIDE || ih > MAX_IMAGE_SIDE {
|
|
return Err(bad(cmd, format!("an image is larger than {MAX_IMAGE_SIDE} pixels on a side")));
|
|
}
|
|
n.width = d.width.unwrap_or(f64::from(iw));
|
|
n.height = d.height.unwrap_or(f64::from(ih));
|
|
n.fills = vec![Paint::image(hash, ImageScaleMode::Fill)];
|
|
}
|
|
doc.insert(n, parent, None)?;
|
|
crate::cmd::props::apply(cmd, doc, id, &props(d, false))?;
|
|
d.children.iter().try_for_each(|c| add(doc, id, c, cmd))
|
|
}
|
|
|
|
/// Add SVG layer `d` inside `parent`: a frame of the picture's own size holding its layers, locked
|
|
/// like the rest. A `width` or `height` scales the picture to fit, keeping its proportions.
|
|
fn add_svg(doc: &mut Document, parent: NodeId, d: &WidgetNode, cmd: &str) -> Result<()> {
|
|
let name = if d.name.is_empty() { "SVG" } else { &d.name };
|
|
let id = figcraft_svg::import_svg(doc, d.src.as_bytes(), parent, 0.0, 0.0, name).map_err(|e| bad(cmd, e.to_string()))?;
|
|
let n = doc.get(id)?;
|
|
let scales = [d.width.map(|w| w / n.width), d.height.map(|h| h / n.height)];
|
|
if let Some(k) = scales.into_iter().flatten().reduce(f64::min) {
|
|
crate::cmd::arrange::scale_node(doc, id, k)?;
|
|
}
|
|
for i in doc.subtree(id) {
|
|
doc.get_mut(i)?.locked = true;
|
|
}
|
|
mark(doc.get_mut(id)?, d);
|
|
crate::cmd::props::apply(cmd, doc, id, &props(d, false))
|
|
}
|
|
|
|
/// Give layer `n` what description `d` says of every kind of layer: its place, its lock and its
|
|
/// layer data.
|
|
fn mark(n: &mut Node, d: &WidgetNode) {
|
|
let turn = -d.rotation.unwrap_or(0.0).to_radians();
|
|
n.transform = Affine::translate((d.x.unwrap_or(0.0), d.y.unwrap_or(0.0))) * Affine::rotate(turn);
|
|
n.locked = true;
|
|
if !d.target.is_empty() {
|
|
n.plugin_data.insert(TARGET.into(), d.target.clone());
|
|
}
|
|
if !d.field.is_empty() {
|
|
n.plugin_data.insert(FIELD.into(), d.field.clone());
|
|
}
|
|
set_hover(n, d.hover.as_ref());
|
|
}
|
|
|
|
/// Keep layer `n`'s hover style (validated already) with it, or drop the one it had.
|
|
fn set_hover(n: &mut Node, hover: Option<&WidgetHover>) {
|
|
match hover.and_then(|h| serde_json::to_string(h).ok()) {
|
|
Some(h) => n.plugin_data.insert(HOVER.into(), h),
|
|
None => n.plugin_data.remove(HOVER),
|
|
};
|
|
}
|
|
|
|
/// `node.set` properties for described layer `d` (validated already); `root`: the widget itself,
|
|
/// which keeps its place and size unless the description gives one.
|
|
fn props(d: &WidgetNode, root: bool) -> Map<String, Value> {
|
|
let mut p = Map::new();
|
|
let mut set = |k: &str, v: Value| {
|
|
p.insert(k.into(), v);
|
|
};
|
|
match d.kind {
|
|
WidgetKind::Text => {
|
|
if let Some(c) = &d.color {
|
|
set("fills", json!([c]));
|
|
}
|
|
set("fontSize", json!(d.size.unwrap_or(14.0)));
|
|
if d.bold {
|
|
set("fontStyle", json!("Bold"));
|
|
}
|
|
// A text with a width wraps; one without grows with its text.
|
|
set("textAutoResize", json!(if d.width.is_some() { "height" } else { "widthAndHeight" }));
|
|
}
|
|
WidgetKind::Image | WidgetKind::Svg => {}
|
|
_ => set("fills", json!(d.fill.iter().collect::<Vec<_>>())),
|
|
}
|
|
let line = d.kind == WidgetKind::Line;
|
|
if let Some(c) = d.stroke.as_deref().or(line.then_some("#000000")) {
|
|
set("strokes", json!([c]));
|
|
set("strokeWeight", json!(d.stroke_weight.unwrap_or(1.0)));
|
|
} else if root {
|
|
set("strokes", json!([]));
|
|
}
|
|
if matches!(d.kind, WidgetKind::Frame | WidgetKind::Rectangle | WidgetKind::Image) {
|
|
set("cornerRadius", json!(d.radius.unwrap_or(0.0)));
|
|
}
|
|
set("opacity", json!(d.opacity.unwrap_or(1.0)));
|
|
if d.kind == WidgetKind::Frame {
|
|
match d.layout {
|
|
Some(l) => {
|
|
set("layout", json!(l));
|
|
set("gap", json!(d.gap.unwrap_or(0.0)));
|
|
set("padding", json!(d.padding.unwrap_or(0.0)));
|
|
set("crossAlign", json!(d.align.map_or(json!("center"), |a| json!(a))));
|
|
set("mainAlign", json!(d.justify.map_or(json!("min"), |a| json!(a))));
|
|
set("sizingH", json!(if d.width.is_some() { "fixed" } else { "hug" }));
|
|
set("sizingV", json!(if d.height.is_some() { "fixed" } else { "hug" }));
|
|
}
|
|
None => set("layout", Value::Null),
|
|
}
|
|
}
|
|
// A new layer got its size when it was made.
|
|
if root {
|
|
for (k, v) in [("width", d.width), ("height", d.height)] {
|
|
if let Some(v) = v {
|
|
set(k, json!(v));
|
|
}
|
|
}
|
|
}
|
|
p
|
|
}
|
|
|
|
/// What `widget.insert` and the callbacks return.
|
|
fn report(s: &Session, w: NodeId, m: &Manifest, out: RunOutput) -> Result<Value> {
|
|
let doc = s.doc()?;
|
|
let output = serde_json::from_str::<Value>(&out.output).unwrap_or(Value::String(out.output));
|
|
let (targets, fields, menu) = (names(doc, w, TARGET), names(doc, w, FIELD), menu_of(doc, w));
|
|
Ok(
|
|
json!({"id": w, "plugin": m.id, "state": state_of(doc, w), "targets": targets, "fields": fields, "menu": menu, "output": output, "logs": out.logs, "calls": out.calls, "fuel": out.fuel}),
|
|
)
|
|
}
|
|
|
|
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "widget.insert";
|
|
let fuel = num(p, "fuel");
|
|
let plugin = string(p, "plugin").ok_or_else(|| bad(cmd, "`plugin` required (a widget plugin's id; see plugin.list)"))?;
|
|
let wp = widget_plugin(s, cmd, &plugin)?;
|
|
let m = &wp.manifest;
|
|
let (x, y) = (num(p, "x").filter(|v| v.abs() < 1e7), num(p, "y").filter(|v| v.abs() < 1e7));
|
|
let page = s.page()?;
|
|
let (cx, cy) = {
|
|
let v = &s.ds()?.view;
|
|
(v.cx, v.cy)
|
|
};
|
|
let (w, out) = s.grouped(&m.name, |s| {
|
|
let doc = s.ds_mut()?.doc_mut();
|
|
let w = doc.alloc_id();
|
|
let mut n = Node::new(w, NodeKind::Frame, m.name.clone(), 100.0, 100.0);
|
|
n.transform = Affine::translate((x.unwrap_or(cx), y.unwrap_or(cy)));
|
|
n.fills.clear();
|
|
n.plugin_data.insert(PLUGIN.into(), m.id.clone());
|
|
doc.insert(n, page, None)?;
|
|
let out = callback(s, &wp, w, None, fuel)?;
|
|
let ds = s.ds_mut()?;
|
|
// Without a position, centred in the view on whole pixels.
|
|
if x.is_none() && y.is_none() {
|
|
let (ww, wh) = ds.doc.node(w).map_or((0.0, 0.0), |n| (n.width, n.height));
|
|
ds.doc_mut().get_mut(w)?.transform = Affine::translate(((cx - ww / 2.0).round(), (cy - wh / 2.0).round()));
|
|
}
|
|
ds.selection = vec![w];
|
|
Ok((w, out))
|
|
})?;
|
|
report(s, w, m, out)
|
|
}
|
|
|
|
/// The widget param `id` is, or is inside.
|
|
fn widget_param(s: &Session, cmd: &str, p: &Value) -> Result<NodeId> {
|
|
let nid = id(p, "id").ok_or_else(|| bad(cmd, "`id` required (a widget, or a layer in one)"))?;
|
|
widget_of(s.doc()?, nid).ok_or_else(|| bad(cmd, format!("{nid} isn't a widget or inside one")))
|
|
}
|
|
|
|
/// Call widget `w`'s plugin back with `ev`, after `prep` (which may change the widget first), as
|
|
/// one undo step.
|
|
fn event(s: &mut Session, cmd: &str, w: NodeId, ev: Value, p: &Value, prep: impl FnOnce(&mut Session) -> Result<()>) -> Result<Value> {
|
|
let plugin = s.doc()?.node(w).and_then(|n| n.plugin_data.get(PLUGIN)).cloned().unwrap_or_default();
|
|
let wp = widget_plugin(s, cmd, &plugin)?;
|
|
let out = s.grouped(&wp.manifest.name, |s| {
|
|
prep(s)?;
|
|
callback(s, &wp, w, Some(ev), num(p, "fuel"))
|
|
})?;
|
|
report(s, w, &wp.manifest, out)
|
|
}
|
|
|
|
fn click(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "widget.click";
|
|
let w = widget_param(s, cmd, p)?;
|
|
let target = string(p, "target").ok_or_else(|| bad(cmd, "`target` required (see widget.list)"))?;
|
|
let all = names(s.doc()?, w, TARGET);
|
|
if !all.contains(&target) {
|
|
return Err(bad(cmd, format!("the widget has no `{target}` to click (it has: {})", all.join(", "))));
|
|
}
|
|
event(s, cmd, w, json!({"target": target}), p, |_| Ok(()))
|
|
}
|
|
|
|
fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "widget.setText";
|
|
let w = widget_param(s, cmd, p)?;
|
|
let field = string(p, "field").ok_or_else(|| bad(cmd, "`field` required (see widget.list)"))?;
|
|
let all = names(s.doc()?, w, FIELD);
|
|
if !all.contains(&field) {
|
|
return Err(bad(cmd, format!("the widget has no field `{field}` (it has: {})", all.join(", "))));
|
|
}
|
|
let value = match p.get("value") {
|
|
Some(Value::String(v)) => clean_field(v).map_err(|e| bad(cmd, e))?,
|
|
_ => return Err(bad(cmd, "`value` required (text)")),
|
|
};
|
|
let ev = json!({"field": field, "value": value});
|
|
// The value goes into the state and the field's text before the plugin hears of it, so a
|
|
// plugin that doesn't redraw still keeps (and shows) it.
|
|
event(s, cmd, w, ev, p, |s| {
|
|
let ds = s.ds_mut()?;
|
|
let before = ds.doc.clone();
|
|
let mut state = state_of(&ds.doc, w);
|
|
state.insert(field.clone(), Value::String(value.clone()));
|
|
check_state(&state).map_err(|e| bad(cmd, e))?;
|
|
let doc = ds.doc_mut();
|
|
let text = Map::from_iter([("characters".to_string(), Value::String(value))]);
|
|
for l in doc.subtree(w) {
|
|
if doc.node(l).is_some_and(|n| n.plugin_data.get(FIELD) == Some(&field) && matches!(n.kind, NodeKind::Text(_))) {
|
|
crate::cmd::props::apply(cmd, doc, l, &text)?;
|
|
}
|
|
}
|
|
doc.get_mut(w)?.plugin_data.insert(STATE.into(), Value::Object(state).to_string());
|
|
crate::normalize(doc, &before);
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
fn menu(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "widget.menu";
|
|
let w = widget_param(s, cmd, p)?;
|
|
let item = string(p, "item").ok_or_else(|| bad(cmd, "`item` required (see widget.list)"))?;
|
|
let all = menu_of(s.doc()?, w);
|
|
let Some(m) = all.iter().find(|m| m.item == item) else {
|
|
let names: Vec<&str> = all.iter().map(|m| m.item.as_str()).collect();
|
|
return Err(bad(cmd, format!("the widget has no menu item `{item}` (it has: {})", names.join(", "))));
|
|
};
|
|
let value = match m.kind {
|
|
MenuKind::Action => Value::Null,
|
|
MenuKind::Toggle => json!(p.get("value").and_then(Value::as_bool).unwrap_or(!m.checked)),
|
|
MenuKind::Color => {
|
|
let hex = string(p, "value").filter(|v| v.len() <= 9).and_then(|v| figcraft_color::Color::from_hex(&v)).map(|c| c.to_css_hex());
|
|
json!(hex.filter(|h| m.options.contains(h)).ok_or_else(|| bad(cmd, format!("`value` must be one of {}", m.options.join(", "))))?)
|
|
}
|
|
};
|
|
event(s, cmd, w, json!({"menu": item, "value": value}), p, |_| Ok(()))
|
|
}
|
|
|
|
/// Click `target` of the widget `id` is (or is in) on `doc`, a document that isn't one of the
|
|
/// session's: the prototype player's copy. The same callback as on the canvas, with the session's
|
|
/// installed plugins and the same sandbox limits, but the widget's state and layers are the
|
|
/// copy's, nothing reaches the session's documents and there is no undo step. Returns the
|
|
/// document as the callback left it (on failure the caller keeps its copy as it was) and what
|
|
/// `widget.click` returns.
|
|
pub fn click_on(s: &Session, doc: Document, id: NodeId, target: &str) -> Result<(Document, Value)> {
|
|
let mut scratch = Session::with_document(doc);
|
|
scratch.prefs.plugins.clone_from(&s.prefs.plugins);
|
|
scratch.plugins.modules.clone_from(&s.plugins.modules);
|
|
let out = click(&mut scratch, &json!({"id": id, "target": target}))?;
|
|
let ds = scratch.docs.pop().ok_or(crate::EngineError::NoDocument)?;
|
|
Ok((std::sync::Arc::unwrap_or_clone(ds.doc), out))
|
|
}
|
|
|
|
fn list(s: &mut Session, _p: &Value) -> Result<Value> {
|
|
let doc = s.doc()?;
|
|
let widgets: Vec<Value> = doc
|
|
.ids()
|
|
.filter_map(|i| {
|
|
let n = doc.node(i).filter(|n| is_widget(n))?;
|
|
let state = Value::Object(state_of(doc, i));
|
|
let (targets, fields, menu) = (names(doc, i, TARGET), names(doc, i, FIELD), menu_of(doc, i));
|
|
Some(
|
|
json!({"id": i, "plugin": n.plugin_data.get(PLUGIN), "name": n.name, "page": doc.page_of(i), "state": state, "targets": targets, "fields": fields, "menu": menu}),
|
|
)
|
|
})
|
|
.collect();
|
|
Ok(json!({"widgets": widgets}))
|
|
}
|
|
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
vec![
|
|
spec!(
|
|
"widget.insert",
|
|
"Insert widget",
|
|
"{plugin (a widget plugin's id), x?, y? (top-left; default: centred in the view), fuel?} the plugin draws it; one undo step → {id, plugin, state, targets, output, logs, calls, fuel}",
|
|
false,
|
|
insert
|
|
),
|
|
spec!(
|
|
"widget.click",
|
|
"Click widget",
|
|
"{id (a widget or a layer in one), target (see widget.list), fuel?} calls the widget's plugin back, which redraws it; one undo step → like widget.insert",
|
|
false,
|
|
click
|
|
),
|
|
spec!(
|
|
"widget.setText",
|
|
"Edit widget field",
|
|
"{id (a widget or a layer in one), field (see widget.list), value (text, up to 1000 characters), fuel?} sets an editable field as typing in it would: the value goes into the state under the field's name and the plugin is called back; one undo step → like widget.insert",
|
|
false,
|
|
set_text
|
|
),
|
|
spec!(
|
|
"widget.menu",
|
|
"Widget menu",
|
|
"{id (a widget or a layer in one), item (see widget.list), value? (a toggle: on or off, default the opposite of now; a colour: one of its options), fuel?} picks an item of the widget's property menu: the plugin is called back; one undo step → like widget.insert",
|
|
false,
|
|
menu
|
|
),
|
|
spec!(
|
|
"widget.hover",
|
|
"Hover widget",
|
|
"{ids? [layers of widgets]} shows their hover styles, as the canvas does while the pointer is over a widget: a live preview without an undo step, which the next command (or no ids) ends → {hovering}",
|
|
false,
|
|
|s, p| {
|
|
let ids: Vec<NodeId> =
|
|
p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(crate::params::id_of).collect()).unwrap_or_default();
|
|
Ok(json!({"hovering": s.widget_hover(&ids)?}))
|
|
}
|
|
),
|
|
spec!("widget.list", "List widgets", "{} → {widgets: [{id, plugin, name, page, state, targets, fields, menu}]}", false, list),
|
|
]
|
|
}
|