M11: plugin API 4: widget text input, property menu, hover, timers and richer layers in the engine

This commit is contained in:
bflatastic 2026-10-13 10:22:17 +07:00
parent f69dcfac9e
commit 71dabf7072
9 changed files with 1494 additions and 94 deletions

View File

@ -329,6 +329,11 @@ pub struct DocState {
pub guide: Option<(NodeId, usize)>,
/// The selection's rotation origin, when moved or shown (see [`rotation_origin`]; not saved).
pub(crate) rotation_origin: Option<rotation_origin::RotationOrigin>,
/// The widgets' pending timers (see [`widgets`]; not saved: closing the file cancels them).
pub(crate) widget_timers: Vec<widgets::Timer>,
/// The part of a widget shown in its hover style and the page point the pointer is at: `doc`
/// shows that look as a live preview (`text_preview` keeps the document itself).
pub(crate) widget_hover: Option<(NodeId, figcraft_geom::Point)>,
}
/// A version shown on the canvas in place of the document: read-only until it's restored or the
@ -384,6 +389,8 @@ impl DocState {
focus: None,
guide: None,
rotation_origin: None,
widget_timers: Vec::new(),
widget_hover: None,
};
st.zoom_to_fit();
st
@ -415,6 +422,22 @@ impl DocState {
&mut self.branches
}
/// Take a widget part's hover look off (see [`widgets::set_hover`]): the document shows as it
/// is. Returns the page point the pointer was at, to put the look back for.
pub(crate) fn end_widget_hover(&mut self) -> Option<figcraft_geom::Point> {
let (_, at) = self.widget_hover.take()?;
if let Some(base) = self.text_preview.take() {
self.doc = base;
self.revision += 1;
}
Some(at)
}
/// The document as shown, without a widget part's hover look: what a prototype of it plays.
pub fn without_widget_hover(&self) -> &Arc<Document> {
self.text_preview.as_ref().filter(|_| self.widget_hover.is_some()).unwrap_or(&self.doc)
}
/// The document itself, not a version or live text preview shown in its place.
pub fn document_itself(&self) -> &Arc<Document> {
self.preview.as_ref().map(|p| &p.doc).or(self.text_preview.as_ref()).unwrap_or(&self.doc)
@ -1074,6 +1097,12 @@ 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 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.grouped_as(Some(label), f)
}
/// Like [`grouped`](Self::grouped); without a `label` what `f` changes is no undo step of its
/// own (it joins the step before it).
pub(crate) fn grouped_as<T>(&mut self, label: Option<&str>, f: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
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.
@ -1089,7 +1118,7 @@ impl Session {
ds.selection = sel;
ds.page = page;
} else if !Arc::ptr_eq(&before, &ds.doc) {
self.record(uid, Some(label), before, sel, page);
self.record(uid, label, before, sel, page);
}
result
}
@ -1592,3 +1621,5 @@ mod version_tests;
mod view_behaviour_tests;
#[cfg(test)]
mod widgets_tests;
#[cfg(test)]
mod widgets_v4_tests;

View File

@ -871,6 +871,8 @@ impl Session {
/// stay where they can, and the undo history isn't touched.
pub fn apply_remote(&mut self, uid: u64, doc: Arc<Document>) {
let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) else { return };
// A widget's hover look isn't the document: it goes (the next pointer move shows it again).
ds.end_widget_hover();
ds.doc = doc;
if !ds.doc.pages.contains(&ds.page) {
ds.page = ds.doc.first_page().unwrap_or(ds.page);
@ -1320,6 +1322,8 @@ impl Session {
fn live_deferred(&mut self, id: SessionId) {
let Some(live) = self.live.sessions.get(&id).filter(|l| !l.deferred.is_empty() && l.syncing()) else { return };
let Some(uid) = live.doc_uid else { return };
// A widget's hover look is no reason to wait: it steps aside and comes back after.
let hover = self.docs.iter_mut().find(|d| d.uid == uid).and_then(DocState::end_widget_hover);
if self.docs.iter().find(|d| d.uid == uid).is_none_or(busy) {
return;
}
@ -1343,6 +1347,9 @@ impl Session {
}
self.live.send(id, out);
self.apply_remote(uid, doc);
if self.tab() == Some(uid) {
crate::widgets::rehover(self, hover);
}
}
/// Show the peer followed in session `id`: their viewport (and their page, in the session's

View File

@ -17,7 +17,7 @@ use crate::cmd::{CommandSpec, find_command};
use crate::params::{get, num, string};
use crate::{EngineError, Result, Session, bad, spec};
pub use figcraft_plugin::{Access, MANIFEST_FILE, Manifest, Panel, Param, ParamKind};
pub use figcraft_plugin::{Access, MANIFEST_FILE, Manifest, Panel, Param, ParamKind, WidgetFit};
/// What one plugin may keep in its settings (keys and JSON values, in bytes), and its longest key.
const MAX_SETTINGS_BYTES: usize = 64 * 1024;
@ -286,9 +286,10 @@ pub(crate) fn module_of(s: &Session, p: &InstalledPlugin) -> Result<Arc<Vec<u8>>
return Err(EngineError::Other(format!("{path}: there are no files in the browser")));
}
/// The sandbox limits for a run, with `fuel` lowering the instruction budget.
pub(crate) fn limits(fuel: Option<f64>) -> Limits {
let mut limits = Limits::default();
/// The sandbox limits for a run of the plugin with manifest `m` (the plugin API it was written
/// for decides what it may use), with `fuel` lowering the instruction budget.
pub(crate) fn limits(fuel: Option<f64>, m: &Manifest) -> Limits {
let mut limits = Limits { api: m.api, ..Limits::default() };
if let Some(f) = fuel.filter(|f| f.is_finite()) {
limits.fuel = (f.max(1000.0) as u64).min(limits.fuel);
}
@ -394,7 +395,7 @@ pub fn run(s: &mut Session, id: &str, command: Option<&str>, params: &Value, fue
let c = m.command(command).ok_or_else(|| bad(cmd, format!("{} has no command `{}`", m.name, command.unwrap_or_default())))?;
let params = c.resolve_params(params).map_err(|e| bad(cmd, e))?;
let wasm = module_of(s, &p)?;
let limits = limits(fuel);
let limits = limits(fuel, m);
// The API the plugin was written for: a v1 plugin gets the input it always got.
let input = json!({"api": m.api, "command": c.id, "params": params, "selection": s.selection()}).to_string();
s.plugins.last = Some((m.id.clone(), c.id.clone(), params.clone()));
@ -465,8 +466,11 @@ fn event(s: &mut Session, p: &Value) -> Result<Value> {
let c = open
.panel
.control(&control)
.filter(|c| c.kind != ParamKind::Note)
.filter(|c| c.is_field() || c.kind == ParamKind::Button)
.ok_or_else(|| bad(cmd, format!("the panel has no control `{control}`")))?;
if c.disabled {
return Err(EngineError::Disabled(cmd.into(), format!("`{control}` is disabled")));
}
let field = c.is_field();
let value = if field { c.coerce(get(p, "value").unwrap_or(&Value::Null)).map_err(|e| bad(cmd, e))? } else { Value::Null };
let mut open = s.plugins.panels.remove(at);

View File

@ -32,6 +32,21 @@ pub fn begin(s: &mut Session, id: NodeId, caret: Option<usize>, created: bool) -
pub fn end(s: &mut Session) -> Result<()> {
let Some(te) = s.ds()?.text_edit.clone() else { return Ok(()) };
s.ds_mut()?.text_edit = None;
// A widget's editable text isn't the user's to change: what was typed goes to the widget's
// plugin, which redraws it (one undo step), and the layer itself is put back.
if let Some(input) = crate::widgets::input_of(s.doc()?, te.id) {
let typed = text_of(s, te.id)?.characters;
s.cancel_interaction()?;
let changed = text_of(s, te.id).is_ok_and(|was| was.characters != typed);
let widget = crate::widgets::widget_of(s.doc()?, te.id);
if changed && let Err(e) = s.execute("widget.setText", &serde_json::json!({"id": te.id, "input": input.name, "text": typed})) {
s.message = Some(e.to_string());
}
let ds = s.ds_mut()?;
ds.selection = widget.into_iter().collect();
ds.revision += 1;
return Ok(());
}
let empty = text_of(s, te.id).map(|t| t.characters.trim().is_empty()).unwrap_or(false);
if empty {
let doc = s.doc_mut()?;
@ -88,13 +103,24 @@ pub fn insert(s: &mut Session, text: &str) -> Result<()> {
.chars()
.filter(|c| !c.is_control() || matches!(c, '\n' | '\t'))
.collect();
// A widget's editable text takes what its plugin allows: one line unless it's multiline, and
// no more characters than its maximum.
let input = s.ds()?.text_edit.as_ref().and_then(|te| crate::widgets::input_of(&s.ds().ok()?.doc, te.id));
let text = match &input {
Some(input) => {
let te = s.ds()?.text_edit.clone();
let (kept, replaced) = te.map_or((0, 0), |te| (text_of(s, te.id).map_or(0, |t| t.char_count()), range(&te).1 - range(&te).0));
input.clean(&text).chars().take(input.max.saturating_sub(kept.saturating_sub(replaced))).collect()
}
None => text,
};
if text.is_empty() {
return Ok(());
}
edit(s, |t, te| {
let (a, b) = range(te);
// "- ", "* " or "1. " typed at the start of a plain paragraph starts a list.
if text == " " && a == b && t.para_list(t.para_of(a)).list == ListStyle::None {
if input.is_none() && text == " " && a == b && t.para_list(t.para_of(a)).list == ListStyle::None {
let chars: Vec<char> = t.characters.chars().collect();
let start = line_start(&chars, a);
let typed: String = chars.get(start..a).unwrap_or(&[]).iter().collect();
@ -218,7 +244,12 @@ pub fn key(s: &mut Session, key: &str, shift: bool, word: bool, line: bool) -> R
let t = text_of(s, te.id)?;
let chars: Vec<char> = t.characters.chars().collect();
let n = chars.len();
if matches!(key, "Tab" | "Backspace" | "Enter") && !word && !line && list_key(s, key, shift)? {
// In a widget's one-line text, Enter and Tab commit.
let input = crate::widgets::input_of(s.doc()?, te.id);
if matches!(key, "Enter" | "Tab") && input.as_ref().is_some_and(|i| !i.multiline || key == "Tab") {
return end(s);
}
if input.is_none() && matches!(key, "Tab" | "Backspace" | "Enter") && !word && !line && list_key(s, key, shift)? {
return Ok(());
}
match key {

View File

@ -56,6 +56,7 @@ pub fn show(s: &mut Session, command: &str, params: &Value) -> Result<()> {
pub fn end(s: &mut Session) -> bool {
let Ok(ds) = s.ds_mut() else { return false };
let Some(base) = ds.text_preview.take() else { return false };
ds.widget_hover = None;
ds.doc = base;
ds.revision += 1;
true

View File

@ -301,6 +301,8 @@ pub struct Overlay {
/// while one is dragged (see [`crate::shape_handles`]).
pub shape_hover: Option<ShapeHandle>,
pub shape: Option<ShapeHint>,
/// The tooltip of the widget part under the pointer.
pub widget_tip: Option<String>,
}
/// The Scale tool's live readout while dragging a handle.
@ -978,6 +980,8 @@ impl Session {
cursor = Cursor::Text;
}
let mut hover = None;
// The widget part under the pointer that has a hover style, and the one with a tooltip.
let (mut widget_part, mut widget_tip) = (None, None);
let mut guides = Vec::new();
let mut measure_box = None;
let arc_hover = if self.arc_handles_shown() { crate::arc_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h) } else { None };
@ -991,9 +995,14 @@ impl Session {
cursor = resize_cursor(&doc, &sel, h);
} else {
let leaf = self.hit(&doc, page, ev.p(), ev.tol() * 2.0);
if leaf.and_then(|l| crate::widgets::target_at(&doc, l, ev.p())).is_some() {
cursor = Cursor::Pointer;
// A widget's parts: a hand over what's clickable, an I-beam over a text to edit.
match leaf.and_then(|l| crate::widgets::part_at(&doc, l, ev.p())) {
Some((_, crate::widgets::Part::Target(_))) => cursor = Cursor::Pointer,
Some((_, crate::widgets::Part::Input(_))) => cursor = Cursor::Text,
None => {}
}
widget_part = leaf.and_then(|l| crate::widgets::hover_at(&doc, l, ev.p())).map(|part| (part, ev.p()));
widget_tip = leaf.and_then(|l| crate::widgets::tooltip_at(&doc, l, ev.p()));
hover = label_at(&doc, page, &self.tool.labels, ev.p()).or_else(|| leaf.map(|leaf| select_target(&doc, leaf, &sel, ev.cmd)));
// Alt over a selected layer: a drag from here duplicates it.
if ev.alt && tool == ToolId::Move && hover.is_some_and(|h| sel.contains(&h) && !locked_in(&doc, h)) {
@ -1031,6 +1040,8 @@ impl Session {
}
}
self.tool.overlay.guides = guides;
self.tool.overlay.widget_tip = widget_tip;
crate::widgets::set_hover(self, widget_part);
let ds = self.ds_mut()?;
if ds.hover != hover {
ds.hover = hover;
@ -1779,7 +1790,7 @@ impl Session {
// Quick clicks on a widget's clickable part are clicks, one by one.
if matches!(self.tool.tool, ToolId::Move | ToolId::Scale)
&& !(ev.shift || ev.cmd || ev.alt)
&& self.hit(&doc, page, ev.p(), ev.tol() * 2.0).and_then(|hit| crate::widgets::target_at(&doc, hit, ev.p())).is_some()
&& self.hit(&doc, page, ev.p(), ev.tol() * 2.0).and_then(|hit| crate::widgets::part_at(&doc, hit, ev.p())).is_some()
{
self.tool.gesture = Gesture::None;
self.cancel_interaction()?;
@ -1824,11 +1835,17 @@ impl Session {
}
/// A plain click at `p` on a clickable part of a widget calls its plugin back (a failure is
/// shown as a message).
/// shown as a message); one on a text of it that can be edited starts editing it in place,
/// all of it selected (committing calls the plugin back: see [`textedit::end`]).
fn widget_click(&mut self, p: Point, tol: f64) -> Result<()> {
use crate::widgets::Part;
let (doc, page) = (self.doc()?.clone(), self.page()?);
let Some((id, target)) = self.hit(&doc, page, p, tol).and_then(|hit| crate::widgets::target_at(&doc, hit, p)) else { return Ok(()) };
if let Err(e) = self.execute("widget.click", &serde_json::json!({"id": id, "target": target})) {
let result = match self.hit(&doc, page, p, tol).and_then(|hit| crate::widgets::part_at(&doc, hit, p)) {
Some((id, Part::Target(target))) => self.execute("widget.click", &serde_json::json!({"id": id, "target": target})).map(|_| ()),
Some((_, Part::Input(text))) => textedit::begin(self, text, None, false),
None => Ok(()),
};
if let Err(e) = result {
self.message = Some(e.to_string());
}
Ok(())

View File

@ -7,28 +7,105 @@
//! (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. See `docs/plugins.md`.
//!
//! Plugin API 4 adds more events, each a callback like a click: a text the user edited in place
//! ([`Input`], `widget.setText`), an item of the widget's property menu (`widget.menuAction`) and a
//! timer the widget asked for (`widget.fireTimer`; [`run_timers`] in the app). Hover styles and
//! tooltips are FigCraft's to show ([`set_hover`]): the plugin isn't called for pointer moves.
//! Everything read back from layer data is checked again: a file or a peer may hold anything.
//!
//! **Timers** are kept with the open document, not in it: closing the file cancels them, and they
//! run only while the document is in front. When a callback asks for one, the widget's layer gets
//! a stamp only this session knows, and the timer fires only while that stamp is still on the
//! layer. So in a live session a widget's timer runs for whoever started it last and for nobody
//! else (a later click by someone else replaces the stamp, which stops the earlier timer), two
//! people's timers never take turns rewriting a widget, and undoing past the callback that
//! started a timer stops it. A file you may only view runs no timers.
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_STATE_BYTES};
use figcraft_plugin::{Instance, Manifest, RunOutput, WidgetKind, WidgetNode, WidgetRender};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use figcraft_color::{Color, ColorStop, Gradient, GradientKind, ImageScaleMode, Paint};
use figcraft_doc::{Document, Effect, EffectKind, Node, NodeId, NodeKind, Shadow, TextData, VectorNetwork};
use figcraft_geom::{Affine, Point, Shape};
use figcraft_plugin::widget::{GradientShape, MAX_IMAGE_BYTES, MAX_SIZE, MAX_STATE_BYTES, MAX_TEXT, WidgetHover, check_menu};
use figcraft_plugin::{Instance, Manifest, RunOutput, WidgetFit, WidgetKind, WidgetNode, WidgetRender};
pub use figcraft_plugin::{MenuItem, MenuKind};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use web_time::{Duration, Instant};
use crate::cmd::CommandSpec;
use crate::params::{id, num, string};
use crate::params::{get, id, num, string};
use crate::plugins::{InstalledPlugin, call_plugin, installed, limits, module_of, plugin_error};
use crate::{Result, Session, bad, spec};
use crate::{EngineError, 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).
/// On a widget's own layer: its plugin's id, its state (JSON), its property menu (JSON) and the
/// stamp of the session whose timer for it is running.
const PLUGIN: &str = "figcraft.widget";
const STATE: &str = "figcraft.widget.state";
/// On a layer inside a widget: the name its plugin gets when it's clicked.
const MENU: &str = "figcraft.widget.menu";
const TIMER: &str = "figcraft.widget.timer";
/// On a layer inside a widget: the name its plugin gets when it's clicked, the editable text it
/// is ([`Input`] as JSON), its hover style (JSON) and its tooltip.
const TARGET: &str = "figcraft.widget.target";
const INPUT: &str = "figcraft.widget.input";
const HOVER: &str = "figcraft.widget.hover";
const TOOLTIP: &str = "figcraft.widget.tooltip";
/// Images in a widget are at most this many pixels on a side.
const MAX_IMAGE_SIDE: u32 = 4096;
/// The stored menu and hover style are read back only up to this size.
const MAX_DATA_BYTES: usize = 64 * 1024;
/// While the document is busy (a drag, text being typed) a due timer waits this long.
const TIMER_RETRY: Duration = Duration::from_millis(250);
/// A text in a widget the user can edit in place: its name for the plugin, the most characters
/// it takes and whether it may have several lines.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Input {
pub name: String,
pub max: usize,
#[serde(default)]
pub multiline: bool,
}
impl Input {
/// `text` as this input takes it: no control characters, one line unless it's multiline, and
/// no longer than its maximum.
pub fn clean(&self, text: &str) -> String {
let line_break = |c: char| matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}');
text.replace("\r\n", "\n")
.chars()
.map(|c| match c {
c if line_break(c) && self.multiline => '\n',
c if line_break(c) || c == '\t' => ' ',
c => c,
})
.filter(|c| !c.is_control() || *c == '\n')
.take(self.max.min(MAX_TEXT))
.collect()
}
}
/// A widget's pending timer.
#[derive(Clone, Debug)]
pub struct Timer {
widget: NodeId,
due: Instant,
/// What [`TIMER`] on the widget's layer must still say when it fires.
stamp: String,
}
/// What the pointer is over in a widget.
#[derive(Clone, Debug, PartialEq)]
pub enum Part {
/// A clickable part: its target.
Target(String),
/// An editable text: its layer.
Input(NodeId),
}
/// Whether `n` is a widget's own layer.
pub fn is_widget(n: &Node) -> bool {
@ -40,18 +117,102 @@ 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 clickable part of a widget at page point `p`, given the layer a click there hits: the widget
/// and its target. The layers inside a widget are locked, so the hit stops at the widget itself;
/// the front-most target whose box holds the point wins.
pub fn target_at(doc: &Document, hit: NodeId, p: Point) -> Option<(NodeId, String)> {
/// The visible layers of the widget `hit` is (or is in) whose box holds page point `p`, back to
/// front, and the widget. The layers inside a widget are locked, so a hit stops at the widget.
fn layers_at(doc: &Document, hit: NodeId, p: Point) -> Option<(NodeId, Vec<&Node>)> {
let w = widget_of(doc, hit)?;
let target = doc
.subtree(w)
.into_iter()
.filter(|i| doc.world_bounds(*i).is_some_and(|b| b.contains(p)))
.filter_map(|i| doc.node(i).filter(|n| n.visible)?.plugin_data.get(TARGET).cloned())
.last()?;
Some((w, target))
let at =
doc.subtree(w).into_iter().filter(|i| doc.world_bounds(*i).is_some_and(|b| b.contains(p))).filter_map(|i| doc.node(i).filter(|n| n.visible));
Some((w, at.collect()))
}
/// The part of a widget at page point `p` that takes a click, given the layer a click there hits:
/// the widget, and the front-most clickable part or editable text whose box holds the point.
pub fn part_at(doc: &Document, hit: NodeId, p: Point) -> Option<(NodeId, Part)> {
let (w, layers) = layers_at(doc, hit, p)?;
let part = layers.iter().rev().find_map(|n| match n.plugin_data.get(TARGET) {
Some(t) => Some(Part::Target(t.clone())),
None => input_of(doc, n.id).map(|_| Part::Input(n.id)),
})?;
Some((w, part))
}
/// The clickable part of a widget at page point `p`: the widget and its target.
pub fn target_at(doc: &Document, hit: NodeId, p: Point) -> Option<(NodeId, String)> {
match part_at(doc, hit, p)? {
(w, Part::Target(t)) => Some((w, t)),
_ => None,
}
}
/// The front-most layer with a hover style at page point `p` of the widget `hit` is (or is in).
pub fn hover_at(doc: &Document, hit: NodeId, p: Point) -> Option<NodeId> {
layers_at(doc, hit, p)?.1.iter().rev().find(|n| n.plugin_data.contains_key(HOVER)).map(|n| n.id)
}
/// The tooltip of the front-most layer that has one at page point `p` of the widget `hit` is in.
pub fn tooltip_at(doc: &Document, hit: NodeId, p: Point) -> Option<String> {
let tip = layers_at(doc, hit, p)?.1.iter().rev().find_map(|n| n.plugin_data.get(TOOLTIP))?;
Some(tip.chars().filter(|c| !c.is_control()).take(200).collect::<String>()).filter(|t| !t.trim().is_empty())
}
/// The editable text layer `id` is, if it is one (in a widget).
pub fn input_of(doc: &Document, id: NodeId) -> Option<Input> {
let n = doc.node(id).filter(|n| matches!(n.kind, NodeKind::Text(_)))?;
let mut input: Input = serde_json::from_str(n.plugin_data.get(INPUT).filter(|s| s.len() <= 1024)?).ok()?;
let named =
!input.name.is_empty() && input.name.len() <= 64 && input.name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'));
input.max = input.max.clamp(1, MAX_TEXT);
(named && widget_of(doc, id).is_some_and(|w| w != id)).then_some(input)
}
/// `doc` with layer `part` in its hover style, if it has one.
pub fn hovered(doc: &Document, part: NodeId) -> Option<Document> {
let style = doc.node(part)?.plugin_data.get(HOVER).filter(|s| s.len() <= MAX_DATA_BYTES)?;
let h: WidgetHover = serde_json::from_str(style).ok()?;
let colour = |c: &Option<String>| c.as_deref().filter(|c| c.len() <= 9).and_then(Color::from_hex).map(Paint::solid);
let mut look = doc.clone();
let n = look.get_mut(part).ok()?;
if let Some(fill) = colour(&h.fill) {
n.fills = vec![fill];
}
if let Some(stroke) = colour(&h.stroke) {
n.strokes = vec![stroke];
n.stroke.weight = n.stroke.weight.max(1.0);
}
if let Some(o) = h.opacity.filter(|o| o.is_finite()) {
n.opacity = o.clamp(0.0, 1.0) as f32;
}
Some(look)
}
/// Show the widget part `part` (with the page point the pointer is at) in its hover style, or no
/// part. The look is a live preview: the document itself, its undo history and what a live
/// session syncs stay as they are, and any command ends it.
pub fn set_hover(s: &mut Session, part: Option<(NodeId, Point)>) {
let Ok(ds) = s.ds_mut() else { return };
if ds.widget_hover.map(|h| h.0) == part.map(|h| h.0) {
ds.widget_hover = part;
return;
}
ds.end_widget_hover();
let Some((id, _)) = part else { return };
if ds.interaction.is_some() || ds.text_edit.is_some() || ds.preview.is_some() || ds.text_preview.is_some() {
return;
}
let Some(look) = hovered(&ds.doc, id) else { return };
ds.text_preview = Some(std::mem::replace(&mut ds.doc, Arc::new(look)));
ds.widget_hover = part;
ds.revision += 1;
}
/// Put the hover look back after a change that wasn't the pointer's (a timer's callback, an edit
/// from a live session): on what is now under page point `at`, where the pointer still is.
pub(crate) fn rehover(s: &mut Session, at: Option<Point>) {
let (Some(p), Ok(ds)) = (at, s.ds()) else { return };
let (doc, page) = (ds.doc.clone(), ds.page);
let part = s.hit(&doc, page, p, 0.0).and_then(|hit| hover_at(&doc, hit, p));
set_hover(s, part.map(|part| (part, p)));
}
/// A widget's state (an empty object when it has none or it's unusable).
@ -78,6 +239,100 @@ fn targets(doc: &Document, w: NodeId) -> Vec<String> {
out
}
/// The editable texts inside widget `w` (each name once), in layer order.
fn inputs(doc: &Document, w: NodeId) -> Vec<(NodeId, Input)> {
let mut out: Vec<(NodeId, Input)> = Vec::new();
for (i, input) in doc.subtree(w).into_iter().filter_map(|i| Some((i, input_of(doc, i)?))) {
if !out.iter().any(|(_, o)| o.name == input.name) {
out.push((i, input));
}
}
out
}
/// Widget `w`'s property menu (empty when it has none or it's unusable): what a selected widget
/// shows near it and in the Design panel.
pub fn menu_of(doc: &Document, w: NodeId) -> Vec<MenuItem> {
let stored = doc.node(w).filter(|n| is_widget(n)).and_then(|n| n.plugin_data.get(MENU)).filter(|s| s.len() <= MAX_DATA_BYTES);
let mut menu: Vec<MenuItem> = stored.and_then(|s| serde_json::from_str(s).ok()).unwrap_or_default();
let fine = check_menu(&mut menu).is_ok() && menu.iter().all(|i| i.kind != MenuKind::Link || crate::dev_resources::check_url(&i.url).is_ok());
if fine { menu } else { Vec::new() }
}
/// A stamp no other session makes: this process's start in nanoseconds and a count.
fn stamp() -> String {
static START: std::sync::OnceLock<u128> = std::sync::OnceLock::new();
static COUNT: AtomicU64 = AtomicU64::new(1);
let start = START.get_or_init(|| web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map_or(0, |d| d.as_nanos()));
format!("{start:x}-{}", COUNT.fetch_add(1, Ordering::Relaxed))
}
/// Widget `w`'s pending timer in the active document: one this session started that nobody
/// replaced since.
fn timer_of(s: &Session, w: NodeId) -> Option<&Timer> {
let ds = s.ds().ok()?;
let on_layer = ds.doc.node(w).filter(|n| is_widget(n))?.plugin_data.get(TIMER)?;
ds.widget_timers.iter().find(|t| t.widget == w && t.stamp == *on_layer)
}
/// Start (or with 0 milliseconds, stop) widget `w`'s timer: at most one is pending.
fn arm(s: &mut Session, w: NodeId, ms: u32) -> Result<()> {
let ds = s.ds_mut()?;
ds.widget_timers.retain(|t| t.widget != w);
if ms > 0 {
let stamp = stamp();
ds.doc_mut().get_mut(w)?.plugin_data.insert(TIMER.into(), stamp.clone());
ds.widget_timers.push(Timer { widget: w, due: Instant::now() + Duration::from_millis(u64::from(ms)), stamp });
}
Ok(())
}
/// How long until the active document's next widget timer is due, in seconds (`None`: none is
/// pending). The app sleeps until then; nothing else wakes it for timers.
pub fn next_timer(s: &Session) -> Option<f64> {
let ds = s.ds().ok()?;
let now = Instant::now();
ds.widget_timers.iter().map(|t| t.due.saturating_duration_since(now).as_secs_f64()).reduce(f64::min)
}
/// Fire the active document's widget timers that are due: each calls its widget's plugin back
/// (`widget.fireTimer`). Timers whose widget is gone, was drawn again by someone else or undone
/// past are dropped; while the document is busy (a drag, text being typed) they wait; a document
/// you can't edit runs none. Returns what went wrong, to show.
pub fn run_timers(s: &mut Session) -> Vec<String> {
let now = Instant::now();
let Ok(ds) = s.ds() else { return Vec::new() };
if !ds.widget_timers.iter().any(|t| t.due <= now) {
return Vec::new();
}
if s.active_read_only().is_some() || ds.preview.is_some() {
if let Ok(ds) = s.ds_mut() {
ds.widget_timers.clear();
}
return Vec::new();
}
// The hover look steps aside for the callback and comes back where the pointer still is.
let Ok(ds) = s.ds_mut() else { return Vec::new() };
let hover = ds.end_widget_hover();
if ds.interaction.is_some() || ds.text_edit.is_some() || ds.text_preview.is_some() {
ds.widget_timers.iter_mut().filter(|t| t.due <= now).for_each(|t| t.due = now + TIMER_RETRY);
return Vec::new();
}
let due: Vec<NodeId> = ds.widget_timers.iter().filter(|t| t.due <= now).map(|t| t.widget).collect();
let mut errors = Vec::new();
for w in due {
if timer_of(s, w).is_none() {
if let Ok(ds) = s.ds_mut() {
ds.widget_timers.retain(|t| t.widget != w);
}
} else if let Err(e) = s.execute("widget.fireTimer", &json!({"id": w})) {
errors.push(e.to_string());
}
}
rehover(s, hover);
errors
}
/// 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> {
@ -89,22 +344,29 @@ fn widget_plugin(s: &Session, cmd: &str, plugin: &str) -> Result<InstalledPlugin
Ok(p)
}
/// Call widget `w`'s plugin `p` back (`target`: what was clicked; 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, target: Option<&str>, fuel: Option<f64>) -> Result<RunOutput> {
/// Call widget `w`'s plugin `p` back with `event` (what happened to it: a click's `target`, an
/// edited text, a menu item, a timer; null 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: &Value, fuel: Option<f64>) -> Result<RunOutput> {
let m = &p.manifest;
// A file you may only view calls no plugin: nothing it drew could be kept.
if let Some(why) = s.active_read_only() {
return Err(EngineError::Disabled(format!("{}'s widget", m.name), why.into()));
}
let wasm = module_of(s, p)?;
let event = target.map(|t| json!({"target": t}));
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 instance = Instance::new(&wasm, &limits(fuel, m)).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) => build(s, &m.id, w, &r)?,
None if target.is_none() => return Err(bad("widget.insert", format!("{} drew no widget", m.name))),
None if event.is_null() => return Err(bad("widget.insert", format!("{} drew no widget", m.name))),
None => {}
}
if let Some(ms) = out.timer {
arm(s, w, ms)?;
}
Ok(out)
}
@ -122,6 +384,7 @@ fn build(s: &mut Session, plugin: &str, w: NodeId, r: &WidgetRender) -> Result<(
doc.remove(c)?;
}
crate::cmd::props::apply(cmd, doc, w, &props(&r.root, true))?;
dress(cmd, doc, w, &r.root)?;
let n = doc.get_mut(w)?;
n.locked = false;
if !r.root.name.is_empty() {
@ -130,6 +393,12 @@ fn build(s: &mut Session, plugin: &str, w: NodeId, r: &WidgetRender) -> Result<(
if let Some(state) = &r.state {
n.plugin_data.insert(STATE.into(), Value::Object(state.clone()).to_string());
}
if let Some(menu) = &r.menu {
for link in menu.iter().filter(|i| i.kind == MenuKind::Link) {
crate::dev_resources::check_url(&link.url).map_err(|e| bad(cmd, format!("menu item `{}`: {e}", link.name)))?;
}
n.plugin_data.insert(MENU.into(), json!(menu).to_string());
}
for c in &r.root.children {
add(doc, w, c, cmd)?;
}
@ -139,21 +408,40 @@ fn build(s: &mut Session, plugin: &str, w: NodeId, r: &WidgetRender) -> Result<(
/// Add layer `d` (and what it holds) inside `parent`, locked.
fn add(doc: &mut Document, parent: NodeId, d: &WidgetNode, cmd: &str) -> Result<()> {
let (mut width, mut height) = (d.width.unwrap_or(100.0), d.height.unwrap_or(100.0));
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 => {
height = 0.0;
(NodeKind::Line, "Line")
}
WidgetKind::Vector => {
let mut path = crate::cmd::create::parse_path_data(&d.path).ok_or_else(|| bad(cmd, "a vector's `path` isn't valid SVG path data"))?;
let bb = path.bounding_box();
let finite = [bb.x0, bb.y0, bb.x1, bb.y1].iter().all(|v| v.is_finite() && v.abs() <= MAX_SIZE);
if !finite || path.elements().is_empty() {
return Err(bad(cmd, format!("a vector's `path` must stay within ±{MAX_SIZE}")));
}
// In its own box, scaled to the size asked for.
let fit = |want: Option<f64>, have: f64| want.filter(|_| have > 1e-9).map_or(1.0, |w| w / have);
path.apply_affine(Affine::scale_non_uniform(fit(d.width, bb.width()), fit(d.height, bb.height())) * Affine::translate((-bb.x0, -bb.y0)));
(width, height) = (d.width.unwrap_or(bb.width()), d.height.unwrap_or(bb.height()));
(NodeKind::Vector { network: VectorNetwork::from_path(&path) }, "Vector")
}
};
// Cut after `maxLines`: a fixed box that many lines high unless a height is given.
if let (WidgetKind::Text, Some(lines), None) = (d.kind, d.max_lines, d.height) {
height = (lines * d.size.unwrap_or(14.0) * 1.3).ceil();
}
let name = if d.name.is_empty() { name.to_string() } else { d.name.clone() };
let id = doc.alloc_id();
let mut n = Node::new(id, kind, name, d.width.unwrap_or(100.0), d.height.unwrap_or(100.0));
let mut n = Node::new(id, kind, name, width, height);
n.transform = Affine::translate((d.x.unwrap_or(0.0), d.y.unwrap_or(0.0)));
n.locked = true;
if !d.target.is_empty() {
n.plugin_data.insert(TARGET.into(), d.target.clone());
}
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}")))?;
@ -166,13 +454,65 @@ fn add(doc: &mut Document, parent: NodeId, d: &WidgetNode, cmd: &str) -> Result<
}
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)];
let mode = match d.fit {
None | Some(WidgetFit::Fill) => ImageScaleMode::Fill,
Some(WidgetFit::Fit) => ImageScaleMode::Fit,
Some(WidgetFit::Crop) => ImageScaleMode::Crop,
Some(WidgetFit::Tile) => ImageScaleMode::Tile,
};
n.fills = vec![Paint::image(hash, mode)];
}
doc.insert(n, parent, None)?;
crate::cmd::props::apply(cmd, doc, id, &props(d, false))?;
dress(cmd, doc, id, d)?;
d.children.iter().try_for_each(|c| add(doc, id, c, cmd))
}
/// What `node.set` has no plain property for, on built layer `id` (the widget itself too): its
/// gradient and shadow, and the layer data that makes it a part (target, input, hover, tooltip).
fn dress(cmd: &str, doc: &mut Document, id: NodeId, d: &WidgetNode) -> Result<()> {
let colour = |c: &str| Color::from_hex(c).ok_or_else(|| bad(cmd, "a colour isn't hex"));
let n = doc.get_mut(id)?;
if let Some(g) = &d.gradient {
let stops = g.stops.iter().map(|s| Ok(ColorStop { position: s.at as f32, color: colour(&s.color)? })).collect::<Result<Vec<_>>>()?;
let mut gradient = match g.kind {
GradientShape::Linear => {
let (sin, cos) = g.angle.unwrap_or(90.0).to_radians().sin_cos();
let mut l = Gradient::default_for(GradientKind::Linear, Color::BLACK);
(l.from, l.to) = ((0.5 - cos / 2.0, 0.5 - sin / 2.0), (0.5 + cos / 2.0, 0.5 + sin / 2.0));
l.square_linear_width(n.width, n.height);
l
}
GradientShape::Radial => Gradient::default_for(GradientKind::Radial, Color::BLACK),
};
gradient.stops = stops;
n.fills = vec![Paint::gradient(gradient)];
}
n.effects = match &d.shadow {
Some(s) => {
let color = s.color.as_deref().map(colour).transpose()?.unwrap_or(Shadow::default().color);
let shadow = Shadow { color, offset: (s.x, s.y), radius: s.blur, spread: s.spread, ..Shadow::default() };
vec![Effect { kind: EffectKind::DropShadow(shadow), visible: true }]
}
None => Vec::new(),
};
let input = (!d.input.is_empty())
.then(|| json!(Input { name: d.input.clone(), max: d.max_length.map_or(MAX_TEXT, |m| m as usize), multiline: d.multiline }).to_string());
let data = [
(TARGET, (!d.target.is_empty()).then(|| d.target.clone())),
(INPUT, input),
(HOVER, d.hover.as_ref().map(|h| json!(h).to_string())),
(TOOLTIP, (!d.tooltip.is_empty()).then(|| d.tooltip.clone())),
];
for (key, value) in data {
match value {
Some(v) => n.plugin_data.insert(key.into(), v),
None => n.plugin_data.remove(key),
};
}
Ok(())
}
/// `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> {
@ -186,23 +526,36 @@ fn props(d: &WidgetNode, root: bool) -> Map<String, Value> {
set("fills", json!([c]));
}
set("fontSize", json!(d.size.unwrap_or(14.0)));
if d.bold {
set("fontStyle", json!("Bold"));
match d.weight {
Some(w) => set("fontWeight", json!(w)),
None if d.bold => set("fontStyle", json!("Bold")),
None => {}
}
// A text with a width wraps; one without grows with its text.
set("textAutoResize", json!(if d.width.is_some() { "height" } else { "widthAndHeight" }));
if let Some(a) = d.text_align {
set("textAlign", json!(a));
}
// A text with a width wraps (and is cut after `maxLines`); one without grows with its text.
let resize = match (d.width, d.max_lines) {
(Some(_), Some(lines)) => {
set("maxLines", json!(lines));
"truncate"
}
(Some(_), None) => "height",
(None, _) => "widthAndHeight",
};
set("textAutoResize", json!(resize));
}
WidgetKind::Image => {}
_ => set("fills", json!(d.fill.iter().collect::<Vec<_>>())),
}
if let Some(c) = &d.stroke {
if let Some(c) = d.stroke.as_deref().or((d.kind == WidgetKind::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("cornerRadius", json!(d.radius.map_or([0.0; 4], figcraft_plugin::Sides::each)));
}
set("opacity", json!(d.opacity.unwrap_or(1.0)));
if d.kind == WidgetKind::Frame {
@ -210,7 +563,7 @@ fn props(d: &WidgetNode, root: bool) -> Map<String, Value> {
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("padding", json!(d.padding.map_or([0.0; 4], figcraft_plugin::Sides::each)));
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" }));
@ -219,6 +572,12 @@ fn props(d: &WidgetNode, root: bool) -> Map<String, Value> {
None => set("layout", Value::Null),
}
}
// What fills is in an auto layout frame with room on that axis (the description was checked).
for (k, fill) in [("sizingH", d.fill_width), ("sizingV", d.fill_height)] {
if fill {
set(k, json!("fill"));
}
}
// A new layer got its size when it was made.
if root {
for (k, v) in [("width", d.width), ("height", d.height)] {
@ -230,15 +589,31 @@ fn props(d: &WidgetNode, root: bool) -> Map<String, Value> {
p
}
/// What `widget.insert` and `widget.click` return.
fn report(s: &Session, w: NodeId, m: &Manifest, out: RunOutput) -> Result<Value> {
/// A widget as `widget.list` describes it, without where it is: its state, what can be done to
/// it (targets to click, texts to edit, its property menu) and its pending timer.
fn describe(s: &Session, w: NodeId) -> Result<Value> {
let doc = s.doc()?;
let output = serde_json::from_str::<Value>(&out.output).unwrap_or(Value::String(out.output));
let texts: Vec<Value> = inputs(doc, w)
.into_iter()
.map(|(i, input)| {
let text = doc.node(i).and_then(|n| n.kind.text()).map(|t| t.characters.clone()).unwrap_or_default();
json!({"name": input.name, "text": text, "maxLength": input.max, "multiline": input.multiline})
})
.collect();
let timer = timer_of(s, w).map(|t| t.due.saturating_duration_since(Instant::now()).as_millis() as u64);
Ok(
json!({"id": w, "plugin": m.id, "state": state_of(doc, w), "targets": targets(doc, w), "output": output, "logs": out.logs, "calls": out.calls, "fuel": out.fuel}),
json!({"id": w, "plugin": doc.get(w)?.plugin_data.get(PLUGIN), "state": state_of(doc, w), "targets": targets(doc, w), "inputs": texts, "menu": menu_of(doc, w), "timer": timer}),
)
}
/// What the commands that call a widget's plugin back return.
fn report(s: &Session, w: NodeId, out: RunOutput) -> Result<Value> {
let mut v = describe(s, w)?;
v["output"] = serde_json::from_str::<Value>(&out.output).unwrap_or(Value::String(out.output));
(v["logs"], v["calls"], v["fuel"]) = (json!(out.logs), json!(out.calls), json!(out.fuel));
Ok(v)
}
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "widget.insert";
let fuel = num(p, "fuel");
@ -259,7 +634,7 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
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 out = callback(s, &wp, w, &Value::Null, fuel)?;
let ds = s.ds_mut()?;
// Without a position, centred in the view on whole pixels.
if x.is_none() && y.is_none() {
@ -269,49 +644,159 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
ds.selection = vec![w];
Ok((w, out))
})?;
report(s, w, m, out)
report(s, w, out)
}
/// The widget command `cmd` is about: the one its `id` is (or is in), else the one selected.
fn widget_id(s: &Session, cmd: &str, p: &Value) -> Result<NodeId> {
let doc = s.doc()?;
let nid = match (id(p, "id"), s.selection().as_slice()) {
(Some(nid), _) => nid,
(None, [only]) => *only,
(None, _) => return Err(bad(cmd, "`id` required (a widget, or a layer in one; without it, the one selected layer)")),
};
widget_of(doc, nid).ok_or_else(|| bad(cmd, format!("{nid} isn't a widget or inside one")))
}
/// The widget command `cmd` is about (see [`widget_id`]), and its plugin.
fn widget_for(s: &Session, cmd: &str, p: &Value) -> Result<(NodeId, InstalledPlugin)> {
let w = widget_id(s, cmd, p)?;
let plugin = s.doc()?.node(w).and_then(|n| n.plugin_data.get(PLUGIN)).cloned().unwrap_or_default();
Ok((w, widget_plugin(s, cmd, &plugin)?))
}
/// Call widget `w`'s plugin back with `event`, as the one undo step `label` (none: it joins the
/// step before it), and report.
fn fire(s: &mut Session, wp: &InstalledPlugin, w: NodeId, event: &Value, label: Option<&str>, fuel: Option<f64>) -> Result<Value> {
let out = s.grouped_as(label, |s| callback(s, wp, w, event, fuel))?;
report(s, w, out)
}
fn click(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "widget.click";
let nid = id(p, "id").ok_or_else(|| bad(cmd, "`id` required (a widget, or a layer in one)"))?;
let (w, wp) = widget_for(s, cmd, p)?;
let target = string(p, "target").ok_or_else(|| bad(cmd, "`target` required (see widget.list)"))?;
let doc = s.doc()?;
let w = widget_of(doc, nid).ok_or_else(|| bad(cmd, format!("{nid} isn't a widget or inside one")))?;
let all = targets(doc, w);
let all = targets(s.doc()?, w);
if !all.contains(&target) {
return Err(bad(cmd, format!("the widget has no `{target}` to click (it has: {})", all.join(", "))));
}
let plugin = doc.node(w).and_then(|n| n.plugin_data.get(PLUGIN)).cloned().unwrap_or_default();
let fuel = num(p, "fuel");
let wp = widget_plugin(s, cmd, &plugin)?;
let out = s.grouped(&wp.manifest.name, |s| callback(s, &wp, w, Some(&target), fuel))?;
report(s, w, &wp.manifest, out)
fire(s, &wp, w, &json!({"target": target}), Some(&wp.manifest.name), num(p, "fuel"))
}
/// 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)> {
fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "widget.setText";
let (w, wp) = widget_for(s, cmd, p)?;
let name = string(p, "input").ok_or_else(|| bad(cmd, "`input` required (an editable text's name; see widget.list)"))?;
let text = get(p, "text").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`text` required (the new text)"))?;
let all = inputs(s.doc()?, w);
let Some((_, input)) = all.iter().find(|(_, i)| i.name == name) else {
let names: Vec<&str> = all.iter().map(|(_, i)| i.name.as_str()).collect();
return Err(bad(cmd, format!("the widget has no text `{name}` to edit (it has: {})", names.join(", "))));
};
fire(s, &wp, w, &json!({"input": name, "text": input.clean(text)}), Some(&wp.manifest.name), num(p, "fuel"))
}
fn menu(s: &mut Session, p: &Value) -> Result<Value> {
let w = widget_id(s, "widget.menu", p)?;
let doc = s.doc()?;
Ok(json!({"id": w, "plugin": doc.get(w)?.plugin_data.get(PLUGIN), "items": menu_of(doc, w)}))
}
fn menu_action(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "widget.menuAction";
let (w, wp) = widget_for(s, cmd, p)?;
let name = string(p, "item").ok_or_else(|| bad(cmd, "`item` required (a menu item's name; see widget.menu)"))?;
let mut items = menu_of(s.doc()?, w);
let Some(item) = items.iter_mut().find(|i| i.kind != MenuKind::Separator && i.name == name) else {
let names: Vec<&str> = items.iter().filter(|i| i.kind != MenuKind::Separator).map(|i| i.name.as_str()).collect();
return Err(bad(cmd, format!("the widget's menu has no `{name}` (it has: {})", names.join(", "))));
};
// A link is the app's to open (on a click only); the plugin isn't called.
if item.kind == MenuKind::Link {
let url = crate::dev_resources::check_url(&item.url).map_err(|e| bad(cmd, e))?;
return Ok(json!({"id": w, "item": name, "url": url}));
}
let value = item.coerce(get(p, "value")).map_err(|e| bad(cmd, e))?;
if !value.is_null() {
item.value = Some(value.clone());
}
let (label, fuel) = (wp.manifest.name.clone(), num(p, "fuel"));
let out = s.grouped(&label, |s| {
// The menu shows the choice even when the plugin doesn't hand a new menu back.
s.ds_mut()?.doc_mut().get_mut(w)?.plugin_data.insert(MENU.into(), json!(items).to_string());
callback(s, &wp, w, &json!({"menu": name, "value": value}), fuel)
})?;
report(s, w, out)
}
fn fire_timer(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "widget.fireTimer";
let (w, wp) = widget_for(s, cmd, p)?;
if timer_of(s, w).is_none() {
return Err(EngineError::Disabled(cmd.into(), "the widget has no timer pending (its plugin asks for one; see widget.list)".into()));
}
let ds = s.ds_mut()?;
ds.widget_timers.retain(|t| t.widget != w);
// A run of ticks is one undo step: a tick right after one joins it.
let label = format!("{} timer", wp.manifest.name);
let joins = s.undo_labels(1).first() == Some(&label);
let r = fire(s, &wp, w, &json!({"timer": true}), (!joins).then_some(label.as_str()), num(p, "fuel"))?;
if joins {
s.ds_mut()?.history.redo.clear();
}
Ok(r)
}
/// Run widget command `cmd` (`widget.click`, `widget.setText`, `widget.menuAction` or
/// `widget.fireTimer`) 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. The player keeps the clock for timers: the
/// result's `timerChanged` says whether the callback started or stopped the widget's timer, and
/// then `timer` is the delay it asked for in milliseconds (null: none); `widget.fireTimer` fires
/// whenever the player says the timer is due.
/// Returns the document as the callback left it (on failure the caller keeps its copy as it was)
/// and what the command returns.
pub fn run_on(s: &Session, doc: Document, cmd: &str, params: &Value) -> Result<(Document, Value)> {
if !matches!(cmd, "widget.click" | "widget.setText" | "widget.menuAction" | "widget.fireTimer") {
return Err(bad(cmd, "not a widget event"));
}
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))
// The timer the player holds for the widget, as the layer's stamp tells: due for
// `widget.fireTimer`, else there only to see whether the callback replaces it.
let held = id(params, "id").and_then(|i| widget_of(scratch.doc().ok()?, i)).and_then(|w| {
let ds = scratch.ds_mut().ok()?;
let stamp = ds.doc.node(w)?.plugin_data.get(TIMER)?.clone();
ds.widget_timers.push(Timer { widget: w, due: Instant::now(), stamp: stamp.clone() });
Some((w, stamp))
});
let mut out = scratch.execute(cmd, params)?;
let kept = held.is_some_and(|(w, stamp)| cmd != "widget.fireTimer" && timer_of(&scratch, w).is_some_and(|t| t.stamp == stamp));
out["timerChanged"] = json!(!kept);
if kept {
out["timer"] = Value::Null;
}
let ds = scratch.docs.pop().ok_or(EngineError::NoDocument)?;
Ok((Arc::unwrap_or_clone(ds.doc), out))
}
/// Click `target` of the widget `id` is (or is in) on the prototype player's copy `doc` (see
/// [`run_on`]).
pub fn click_on(s: &Session, doc: Document, id: NodeId, target: &str) -> Result<(Document, Value)> {
run_on(s, doc, "widget.click", &json!({"id": id, "target": target}))
}
fn list(s: &mut Session, _p: &Value) -> Result<Value> {
let doc = s.doc()?;
let widgets: Vec<Value> = doc
.ids()
.filter(|i| doc.node(*i).is_some_and(is_widget))
.filter_map(|i| {
let n = doc.node(i).filter(|n| is_widget(n))?;
let state = Value::Object(state_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(doc, i)}))
let mut v = describe(s, i).ok()?;
(v["name"], v["page"]) = (json!(doc.node(i)?.name), json!(doc.page_of(i)));
Some(v)
})
.collect();
Ok(json!({"widgets": widgets}))
@ -322,17 +807,51 @@ pub fn specs() -> Vec<CommandSpec> {
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}",
"{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, inputs, menu, timer, 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",
"{id? (a widget or a layer in one; default: the selected 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.list", "List widgets", "{} → {widgets: [{id, plugin, name, page, state, targets}]}", false, list),
spec!(
"widget.setText",
"Edit widget text",
"{id? (a widget or a layer in one; default: the selected one), input (an editable text's name; see widget.list), text, fuel?} commits the text as editing it in place does (cut to its maximum length; one line unless it's multiline): the plugin is called back and redraws; one undo step → like widget.insert",
false,
set_text
),
spec!(
"widget.menu",
"Widget property menu",
"{id? (a widget or a layer in one; default: the selected one)} → {id, plugin, items: [{type: action|toggle|dropdown|color|separator|link, name, label, value, options, url}]}",
false,
menu
),
spec!(
"widget.menuAction",
"Use widget menu item",
"{id?, item (see widget.menu), value? (a toggle: true|false, default flipped; a dropdown or colour: one of its options), fuel?} calls the widget's plugin back; one undo step → like widget.insert. A link isn't opened: → {id, item, url}",
false,
menu_action
),
spec!(
"widget.fireTimer",
"Fire widget timer",
"{id?, fuel?} fires the widget's pending timer now (for scripts and tests; the app fires it when it's due): the plugin is called back; a run of timer callbacks is one undo step → like widget.insert",
false,
fire_timer
),
spec!(
"widget.list",
"List widgets",
"{} → {widgets: [{id, plugin, name, page, state, targets, inputs: [{name, text, maxLength, multiline}], menu, timer (milliseconds left, or null)}]}",
false,
list
),
]
}

View File

@ -17,13 +17,15 @@ const REPLY: usize = 200_000;
/// Lays out a test plugin's strings and builds its module.
#[derive(Default)]
struct W {
pub(crate) struct W {
data: String,
at: usize,
/// More imports than the widget tests' four (WAT).
pub(crate) imports: String,
}
impl W {
fn s(&mut self, text: &str) -> (usize, usize) {
pub(crate) fn s(&mut self, text: &str) -> (usize, usize) {
let a = 1000 + self.at;
self.data += &format!("(data (i32.const {a}) \"{}\")\n", lit(text));
self.at += text.len();
@ -32,7 +34,7 @@ impl W {
}
/// Draw `v`; when it's refused, output why and fail.
fn render(&mut self, v: &Value) -> String {
pub(crate) fn render(&mut self, v: &Value) -> String {
let (a, l) = self.s(&v.to_string());
format!(
"(if (call $render (i32.const {a}) (i32.const {l})) (then (call $set_output (i32.const {REPLY}) (call $reply_read (i32.const {REPLY}) (i32.const 50000))) (return (i32.const 1))))"
@ -40,13 +42,13 @@ impl W {
}
/// Whether the input contains `needle`.
fn has(&mut self, needle: &str) -> String {
pub(crate) fn has(&mut self, needle: &str) -> String {
let (a, l) = self.s(needle);
format!("(call $has (i32.const {a}) (i32.const {l}) (local.get $n))")
}
/// Call `cmd`; a refusal becomes the output and the run stops (successfully).
fn call(&mut self, cmd: &str, params: &Value) -> String {
pub(crate) fn call(&mut self, cmd: &str, params: &Value) -> String {
let ((a, l), (b, m)) = (self.s(cmd), self.s(&params.to_string()));
format!(
"(if (call $call (i32.const {a}) (i32.const {l}) (i32.const {b}) (i32.const {m}))
@ -55,13 +57,14 @@ impl W {
}
/// The module: it reads and outputs its input, then runs `body`.
fn build(self, body: &str) -> Vec<u8> {
pub(crate) fn build(self, body: &str) -> Vec<u8> {
let wat = format!(
r#"(module
(import "figcraft" "call" (func $call (param i32 i32 i32 i32) (result i32)))
(import "figcraft" "reply_read" (func $reply_read (param i32 i32) (result i32)))
(import "figcraft" "set_output" (func $set_output (param i32 i32)))
(import "figcraft" "widget_render" (func $render (param i32 i32) (result i32)))
{}
(memory (export "memory") 4)
{}
(func $has (param $p i32) (param $l i32) (param $n i32) (result i32) (local $i i32) (local $j i32)
@ -82,7 +85,7 @@ impl W {
(call $set_output (i32.const {INPUT}) (local.get $n))
{body}
(i32.const 0)))"#,
self.data
self.imports, self.data
);
wat::parse_str(&wat).unwrap_or_else(|e| panic!("{e}\n{wat}"))
}
@ -108,7 +111,7 @@ fn tally(extra: impl FnOnce(&mut W) -> String) -> Vec<u8> {
w.build(&format!("{pre} (if {inc} (then {two} (return (i32.const 0)))) (if {dec} (then {zero} (return (i32.const 0)))) {one}"))
}
fn widget_manifest(id: &str, access: &str) -> Value {
pub(crate) fn widget_manifest(id: &str, access: &str) -> Value {
json!({"id": id, "name": "Tally", "version": "1.0.0", "main": "w.wasm", "api": 3, "widget": true, "permissions": {"access": access}})
}
@ -128,7 +131,7 @@ fn click(s: &mut Session, w: NodeId, target: &str) -> Value {
ex(s, "widget.click", json!({"id": w, "target": target}))
}
fn state(s: &mut Session, w: NodeId) -> Value {
pub(crate) fn state(s: &mut Session, w: NodeId) -> Value {
let list = ex(s, "widget.list", json!({}));
list["widgets"].as_array().unwrap().iter().find(|x| x["id"] == json!(w)).map(|x| x["state"].clone()).unwrap_or(Value::Null)
}
@ -139,7 +142,7 @@ fn label(s: &Session, w: NodeId) -> String {
node(s, first).kind.text().unwrap().characters.clone()
}
fn err(s: &mut Session, cmd: &str, p: Value) -> String {
pub(crate) fn err(s: &mut Session, cmd: &str, p: Value) -> String {
match s.execute(cmd, &p) {
Ok(v) => panic!("{cmd} accepted {p}: {v}"),
Err(e) => {

View File

@ -0,0 +1,787 @@
//! Plugin API 4: widgets take text typed in place, items of their property menu and timers, each
//! one callback and one undo step (a run of timer ticks is one); hover styles and tooltips show
//! without the plugin or the undo history; lines, vectors, gradients, shadows, text styles and
//! fit modes are built; panels get sliders, images, progress bars, dividers, sections, disabled
//! controls and validation messages. Everything is kept through save, reload, duplicating and
//! export, syncs in a live session (where a timer runs for whoever started it last), works on a
//! prototype's copy, is refused for plugins written for API 1–3, and junk never panics.
//! Test plugins are WAT (see `widgets_tests`): they output their input, then draw what it asks.
use std::sync::{Arc, Mutex};
use figcraft_collab::message::{ClientMsg, ServerMsg};
use figcraft_collab::{Client, Outgoing, Server};
use figcraft_color::{ImageScaleMode, PaintKind};
use figcraft_doc::{EffectKind, NodeId, NodeKind};
use serde_json::{Value, json};
use crate::components::tests::{ex, idv, node};
use crate::live::{Access, Event, Role};
use crate::live_tests::{Script, info, push};
use crate::plugins_tests::{install, undo_len};
use crate::tools::{Cursor, PointerEvent, PointerKind};
use crate::widgets::{next_timer, run_on, run_timers};
use crate::widgets_tests::{W, err, state, widget_manifest};
use crate::{EngineError, Session};
const CARD: &str = "org.test.card";
const TIMER: &str = r#"(import "figcraft" "widget_timer" (func $timer (param i32) (result i32)))"#;
/// A 1×1 PNG.
const PIXEL: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
fn menu() -> Value {
json!([
{"type": "action", "name": "reset", "label": "Reset"},
{"type": "toggle", "name": "pinned", "label": "Pinned"},
{"type": "dropdown", "name": "size", "label": "Size", "options": ["S", "M", "L"], "value": "M"},
{"type": "color", "name": "tint", "label": "Tint", "options": ["#ff0000", "#00FF00"], "value": "#00ff00"},
{"type": "separator"},
{"type": "link", "name": "docs", "label": "Docs", "url": "https://example.org/docs"}
])
}
/// A widget using everything API 4 added, in state `n`, labelled `label`.
fn look(n: i64, label: &str) -> Value {
json!({"state": {"n": n}, "menu": menu(), "root": {"type": "frame", "name": "Card", "layout": "vertical", "gap": 6, "padding": [4, 8, 12, 16],
"radius": [1, 2, 3, 4], "width": 200, "align": "min",
"gradient": {"type": "linear", "angle": 0, "stops": [{"at": 0, "color": "#ffffff"}, {"at": 1, "color": "#dddddd"}]},
"shadow": {"x": 1, "y": 2, "blur": 6, "color": "#00000040"}, "children": [
{"type": "text", "name": "Title", "text": label, "input": "title", "maxLength": 10, "width": 120, "weight": 700, "textAlign": "center",
"maxLines": 2, "size": 12, "tooltip": "Rename", "hover": {"fill": "#FF0000"}},
{"type": "text", "name": "Notes", "text": "notes", "input": "notes", "maxLength": 40, "multiline": true, "width": 120, "size": 12},
{"type": "rectangle", "name": "Start", "target": "start", "height": 20, "fill": "#0D99FF", "fillWidth": true, "tooltip": "Start",
"hover": {"fill": "#00FF00", "stroke": "#000000", "opacity": 0.5}},
{"type": "ellipse", "name": "Stop", "target": "stop", "width": 20, "height": 20, "fill": "#F24822"},
{"type": "line", "width": 50},
{"type": "vector", "path": "M0 0 L10 0 L5 8 Z", "fill": "#111111", "width": 20},
{"type": "image", "data": PIXEL, "width": 16, "height": 16, "fit": "tile"}
]}})
}
/// The test widget: it draws "new" (state 1) when inserted; `start` asks for a 100 ms timer and
/// draws 2, `stop` cancels it without drawing, a timer draws 3 and asks again, text typed into
/// `title` draws 4 (44 when it is "Hello ther") and into `notes` nothing, the menu's `reset`
/// draws 0 and its other items draw nothing. `extra` runs first on every call.
fn card(extra: impl FnOnce(&mut W) -> String) -> Vec<u8> {
let mut w = W::default();
w.imports = TIMER.into();
let pre = extra(&mut w);
let on = |w: &mut W, needle: &str, then: String| format!("(if {} (then {then} (return (i32.const 0))))", w.has(needle));
let again = "(drop (call $timer (i32.const 100)))";
let (tick, started, exact, typed, reset, new) = (
w.render(&look(3, "tick")),
w.render(&look(2, "started")),
w.render(&look(44, "exact")),
w.render(&look(4, "typed")),
w.render(&look(0, "reset")),
w.render(&look(1, "new")),
);
let body = [
on(&mut w, r#""timer":true"#, format!("{tick} {again}")),
on(&mut w, r#""target":"start""#, format!("{started} {again}")),
on(&mut w, r#""target":"stop""#, "(drop (call $timer (i32.const 0)))".into()),
on(&mut w, r#""text":"Hello ther""#, exact),
on(&mut w, r#""input":"title""#, typed),
on(&mut w, r#""input":"notes""#, String::new()),
on(&mut w, r#""menu":"reset""#, reset),
on(&mut w, r#""menu":""#, String::new()),
]
.join("\n");
w.build(&format!("{pre} {body} {new}"))
}
fn manifest(api: u32) -> Value {
let mut m = widget_manifest(CARD, "read");
m["api"] = json!(api);
m
}
fn session() -> Session {
let mut s = Session::new();
install(&mut s, manifest(4), &card(|_| String::new()));
s
}
fn insert(s: &mut Session) -> (NodeId, Value) {
let r = ex(s, "widget.insert", json!({"plugin": CARD, "x": 40, "y": 40}));
(idv(&r), r)
}
/// The layer called `name` inside widget `w`.
fn part(s: &Session, w: NodeId, name: &str) -> NodeId {
node(s, w).children.iter().copied().find(|c| node(s, *c).name == name).unwrap_or_else(|| panic!("no {name}"))
}
fn text_of(s: &Session, id: NodeId) -> String {
node(s, id).kind.text().unwrap().characters.clone()
}
fn pointer(s: &mut Session, kind: PointerKind, id: NodeId) {
let c = s.doc().unwrap().world_bounds(id).unwrap().center();
s.pointer(&PointerEvent::at(kind, c.x, c.y, 1.0)).unwrap();
}
/// A pointer event over the layer called `name` of widget `w`.
fn point(s: &mut Session, kind: PointerKind, w: NodeId, name: &str) {
let id = part(s, w, name);
pointer(s, kind, id);
}
fn listed(s: &mut Session, w: NodeId) -> Value {
let list = ex(s, "widget.list", json!({}));
list["widgets"].as_array().unwrap().iter().find(|x| x["id"] == json!(w)).cloned().unwrap()
}
#[test]
fn text_typed_in_place_or_set_by_command_calls_the_plugin_back_as_one_undo_step() {
let mut s = session();
let (w, r) = insert(&mut s);
assert_eq!(r["output"]["api"], json!(4));
assert_eq!(
r["inputs"],
json!([{"name": "title", "text": "new", "maxLength": 10, "multiline": false}, {"name": "notes", "text": "notes", "maxLength": 40, "multiline": true}])
);
// By command: cut to the maximum, one line; the plugin gets the name and the text and redraws.
let undo = undo_len(&s);
let r = ex(&mut s, "widget.setText", json!({"id": w, "input": "title", "text": "A\tlong\nline of text"}));
assert_eq!(r["output"]["widget"]["event"], json!({"input": "title", "text": "A long lin"}));
assert_eq!((r["state"].clone(), undo_len(&s), text_of(&s, part(&s, w, "Title"))), (json!({"n": 4}), undo + 1, "typed".into()));
let inside = part(&s, w, "Start");
let r = ex(&mut s, "widget.setText", json!({"id": inside, "input": "notes", "text": "two\r\nlines\u{7}"}));
assert_eq!(r["output"]["widget"]["event"], json!({"input": "notes", "text": "two\nlines"}), "a multiline text keeps its line breaks");
assert_eq!(undo_len(&s), undo + 1, "a callback that changes nothing is no undo step");
ex(&mut s, "edit.undo", json!({}));
assert_eq!((state(&mut s, w), text_of(&s, part(&s, w, "Title"))), (json!({"n": 1}), "new".into()));
ex(&mut s, "edit.redo", json!({}));
assert_eq!(state(&mut s, w), json!({"n": 4}));
ex(&mut s, "edit.undo", json!({}));
assert!(err(&mut s, "widget.setText", json!({"id": w, "input": "nope", "text": "x"})).contains("it has: title, notes"));
assert!(err(&mut s, "widget.setText", json!({"id": w, "input": "title"})).contains("`text` required"));
assert!(err(&mut s, "widget.setText", json!({"id": w, "input": "title", "text": 5})).contains("`text` required"));
// On the canvas: an I-beam over the text, a click edits it in place with everything selected.
s.execute("select.set", &json!({"ids": []})).unwrap();
let title = part(&s, w, "Title");
pointer(&mut s, PointerKind::Move, title);
assert_eq!(s.tool.overlay.cursor, Cursor::Text);
let undo = undo_len(&s);
pointer(&mut s, PointerKind::Down, title);
pointer(&mut s, PointerKind::Up, title);
let te = s.ds().unwrap().text_edit.clone().unwrap();
assert_eq!((te.id, te.anchor, te.caret), (title, 0, 3));
// Typing replaces it, shows at once, and stops at the maximum; Enter is no line break.
crate::textedit::insert(&mut s, "Hello\nthere, world").unwrap();
assert_eq!(text_of(&s, title), "Hello ther");
crate::textedit::insert(&mut s, "!").unwrap();
assert_eq!((text_of(&s, title), undo_len(&s)), ("Hello ther".into(), undo));
crate::textedit::key(&mut s, "Enter", false, false, false).unwrap();
// Committed: the plugin saw exactly that text and redrew; one undo step; the widget is selected.
assert!(s.ds().unwrap().text_edit.is_none() && s.ds().unwrap().interaction.is_none());
assert_eq!((state(&mut s, w), undo_len(&s), s.selection()), (json!({"n": 44}), undo + 1, vec![w]));
ex(&mut s, "edit.undo", json!({}));
assert_eq!((state(&mut s, w), text_of(&s, part(&s, w, "Title"))), (json!({"n": 1}), "new".into()));
// Leaving it as it was calls nobody; a multiline text takes Enter and commits on Escape.
let title = part(&s, w, "Title");
pointer(&mut s, PointerKind::Down, title);
pointer(&mut s, PointerKind::Up, title);
crate::textedit::key(&mut s, "Escape", false, false, false).unwrap();
assert_eq!((state(&mut s, w), undo_len(&s)), (json!({"n": 1}), undo));
let notes = part(&s, w, "Notes");
crate::textedit::begin(&mut s, notes, None, false).unwrap();
crate::textedit::insert(&mut s, "a").unwrap();
crate::textedit::key(&mut s, "Enter", false, false, false).unwrap();
crate::textedit::insert(&mut s, "- b").unwrap();
assert_eq!(text_of(&s, notes), "a\n- b", "no lists in a widget's text");
crate::textedit::key(&mut s, "Escape", false, false, false).unwrap();
assert_eq!((text_of(&s, part(&s, w, "Notes")), undo_len(&s)), ("notes".into(), undo), "the plugin drew nothing: the layer is as it was");
}
#[test]
fn the_property_menu_is_listed_and_its_items_call_the_plugin_back() {
let mut s = session();
let (w, r) = insert(&mut s);
let inside = part(&s, w, "Start");
let m = ex(&mut s, "widget.menu", json!({"id": inside}));
assert_eq!((m["id"].clone(), m["plugin"].clone(), m["items"].clone()), (json!(w), json!(CARD), r["menu"].clone()));
let items = m["items"].as_array().unwrap();
assert_eq!(items.len(), 6);
assert_eq!(items[1], json!({"type": "toggle", "name": "pinned", "label": "Pinned", "value": false}));
assert_eq!(items[3], json!({"type": "color", "name": "tint", "label": "Tint", "value": "#00FF00", "options": ["#FF0000", "#00FF00"]}));
// An action: no value; the plugin redraws; one undo step.
ex(&mut s, "widget.click", json!({"id": w, "target": "stop"}));
let undo = undo_len(&s);
let r = ex(&mut s, "widget.menuAction", json!({"id": w, "item": "reset", "value": "ignored"}));
assert_eq!(r["output"]["widget"]["event"], json!({"menu": "reset", "value": null}));
assert_eq!((r["state"].clone(), undo_len(&s)), (json!({"n": 0}), undo + 1));
// A toggle flips unless told; a dropdown and a colour take one of their options. The menu
// shows the choice though this plugin draws nothing for them.
let value = |s: &mut Session, item: &str, v: Value| {
let mut p = json!({"id": w, "item": item});
if !v.is_null() {
p["value"] = v;
}
ex(s, "widget.menuAction", p)["output"]["widget"]["event"]["value"].clone()
};
assert_eq!(value(&mut s, "pinned", Value::Null), json!(true));
assert_eq!(value(&mut s, "pinned", Value::Null), json!(false));
assert_eq!(value(&mut s, "pinned", json!(true)), json!(true));
assert_eq!(value(&mut s, "size", json!("L")), json!("L"));
assert_eq!(value(&mut s, "tint", json!("#ff0000")), json!("#FF0000"));
let now = ex(&mut s, "widget.menu", json!({"id": w}))["items"].clone();
assert_eq!((now[1]["value"].clone(), now[2]["value"].clone(), now[3]["value"].clone()), (json!(true), json!("L"), json!("#FF0000")));
ex(&mut s, "edit.undo", json!({}));
assert_eq!(ex(&mut s, "widget.menu", json!({"id": w}))["items"][3]["value"], json!("#00FF00"), "choosing is an undo step");
// A link is the app's to open: the plugin isn't called and nothing changes.
let undo = undo_len(&s);
assert_eq!(
ex(&mut s, "widget.menuAction", json!({"id": w, "item": "docs"})),
json!({"id": w, "item": "docs", "url": "https://example.org/docs"})
);
assert_eq!(undo_len(&s), undo);
for (p, why) in [
(json!({"id": w, "item": "nope"}), "it has: reset, pinned, size, tint, docs"),
(json!({"id": w}), "`item` required"),
(json!({"id": w, "item": "size", "value": "XL"}), "must be one of: S, M, L"),
(json!({"id": w, "item": "size"}), "must be one of"),
(json!({"id": w, "item": "tint", "value": "#123456"}), "must be one of"),
(json!({"id": w, "item": "pinned", "value": "yes"}), "true or false"),
] {
assert!(err(&mut s, "widget.menuAction", p.clone()).contains(why), "{p}");
}
let r = crate::plugins_tests::rect(&mut s, 900.0);
assert!(err(&mut s, "widget.menu", json!({"id": r})).contains("isn't a widget"));
}
#[test]
fn hover_styles_and_tooltips_show_without_the_plugin_or_an_undo_step() {
let mut s = session();
let (w, _) = insert(&mut s);
s.execute("select.set", &json!({"ids": []})).unwrap();
let (start, stop) = (part(&s, w, "Start"), part(&s, w, "Stop"));
let itself = s.ds().unwrap().doc.clone();
let undo = undo_len(&s);
let shown = |s: &Session| {
let n = node(s, start);
(n.fills[0].solid_color().unwrap().to_css_hex(), n.strokes.len(), n.opacity)
};
assert_eq!(shown(&s), ("#0D99FF".into(), 0, 1.0));
pointer(&mut s, PointerKind::Move, start);
assert_eq!(shown(&s), ("#00FF00".into(), 1, 0.5), "the hover style shows");
assert_eq!((s.tool.overlay.cursor, s.tool.overlay.widget_tip.as_deref()), (Cursor::Pointer, Some("Start")));
let ds = s.ds().unwrap();
assert!(Arc::ptr_eq(ds.document_itself(), &itself) && undo_len(&s) == undo, "the document and its history are as they were");
// The same part again changes nothing; another part, or none, puts it back.
let revision = ds.revision;
pointer(&mut s, PointerKind::Move, start);
assert_eq!(s.ds().unwrap().revision, revision);
pointer(&mut s, PointerKind::Move, stop);
assert!(Arc::ptr_eq(&s.ds().unwrap().doc, &itself) && s.tool.overlay.widget_tip.is_none(), "a part without a hover style or a tooltip");
assert_eq!(s.tool.overlay.cursor, Cursor::Pointer);
// A text's hover colours it; any command ends the look, and a click still works.
point(&mut s, PointerKind::Move, w, "Title");
assert_eq!(node(&s, part(&s, w, "Title")).fills[0].solid_color().unwrap().to_css_hex(), "#FF0000");
assert_eq!(s.tool.overlay.widget_tip.as_deref(), Some("Rename"));
ex(&mut s, "widget.list", json!({}));
assert!(Arc::ptr_eq(&s.ds().unwrap().doc, &itself));
pointer(&mut s, PointerKind::Move, start);
pointer(&mut s, PointerKind::Down, start);
pointer(&mut s, PointerKind::Up, start);
assert_eq!((state(&mut s, w), undo_len(&s)), (json!({"n": 2}), undo + 1));
assert!(s.ds().unwrap().text_preview.is_none());
// Saving while a part is hovered saves the document itself.
point(&mut s, PointerKind::Move, w, "Start");
assert!(s.ds().unwrap().text_preview.is_some());
let fill = |d: &figcraft_doc::Document| d.node(part(&s, w, "Start")).unwrap().fills[0].solid_color().unwrap().to_css_hex();
assert_eq!(fill(s.ds().unwrap().document_itself()), "#0D99FF");
}
#[test]
fn a_timer_calls_back_once_due_and_a_run_of_ticks_is_one_undo_step() {
let mut s = session();
let (w, r) = insert(&mut s);
assert_eq!((r["timer"].clone(), next_timer(&s)), (Value::Null, None));
assert!(matches!(s.execute("widget.fireTimer", &json!({"id": w})), Err(EngineError::Disabled(..))), "no timer is pending");
assert!(run_timers(&mut s).is_empty());
// A callback asks for one: it is pending, and nothing happens before it is due.
let r = ex(&mut s, "widget.click", json!({"id": w, "target": "start"}));
let left = r["timer"].as_u64().unwrap();
assert!(left <= 100 && next_timer(&s).is_some_and(|t| t <= 0.1), "{left}");
assert_eq!(listed(&mut s, w)["timer"].as_u64().map(|t| t <= 100), Some(true));
let undo = undo_len(&s);
assert!(run_timers(&mut s).is_empty());
assert_eq!((state(&mut s, w), undo_len(&s)), (json!({"n": 2}), undo));
// Due: the plugin is called with the timer, redraws and asks again.
std::thread::sleep(std::time::Duration::from_millis(130));
assert!(run_timers(&mut s).is_empty());
assert_eq!((state(&mut s, w), undo_len(&s)), (json!({"n": 3}), undo + 1));
assert_eq!(s.undo_labels(1), vec!["Tally timer".to_string()]);
assert!(next_timer(&s).is_some(), "it asked for the next one");
// More ticks join that step (fired by command, as a script would).
let r = ex(&mut s, "widget.fireTimer", json!({"id": w}));
assert_eq!(r["output"]["widget"]["event"], json!({"timer": true}));
ex(&mut s, "widget.fireTimer", json!({"id": w}));
assert_eq!(undo_len(&s), undo + 1);
// Undoing the ticks goes back to before the first; undoing the click that started the timer
// stops it (the layer no longer carries this session's stamp).
ex(&mut s, "edit.undo", json!({}));
assert_eq!(state(&mut s, w), json!({"n": 2}));
ex(&mut s, "edit.undo", json!({}));
assert_eq!((state(&mut s, w), listed(&mut s, w)["timer"].clone()), (json!({"n": 1}), Value::Null));
std::thread::sleep(std::time::Duration::from_millis(130));
assert!(run_timers(&mut s).is_empty());
assert_eq!((state(&mut s, w), next_timer(&s)), (json!({"n": 1}), None), "the dead timer is dropped, not fired");
// Cancelling, and a busy document: the timer waits for a drag or typing to end.
ex(&mut s, "widget.click", json!({"id": w, "target": "start"}));
ex(&mut s, "widget.click", json!({"id": w, "target": "stop"}));
assert_eq!(next_timer(&s), None);
ex(&mut s, "widget.click", json!({"id": w, "target": "start"}));
let notes = part(&s, w, "Notes");
crate::textedit::begin(&mut s, notes, None, false).unwrap();
std::thread::sleep(std::time::Duration::from_millis(130));
assert!(run_timers(&mut s).is_empty());
assert_eq!(state(&mut s, w), json!({"n": 2}));
assert!(next_timer(&s).is_some_and(|t| t > 0.1), "it tries again a little later");
crate::textedit::key(&mut s, "Escape", false, false, false).unwrap();
assert_eq!(ex(&mut s, "widget.fireTimer", json!({"id": w}))["state"], json!({"n": 3}));
// A copy has no timer of its own; a deleted widget's goes with it.
s.execute("select.set", &json!({"ids": [w]})).unwrap();
ex(&mut s, "edit.duplicate", json!({}));
let copy = s.selection()[0];
assert!(listed(&mut s, copy)["timer"].is_null() && listed(&mut s, w)["timer"].is_u64());
assert!(matches!(s.execute("widget.fireTimer", &json!({"id": copy})), Err(EngineError::Disabled(..))));
ex(&mut s, "edit.delete", json!({"ids": [w]}));
std::thread::sleep(std::time::Duration::from_millis(130));
assert!(run_timers(&mut s).is_empty());
assert_eq!(next_timer(&s), None);
}
#[test]
fn timers_are_bounded_and_need_api_4() {
let ask = |ms: i64| {
move |w: &mut W| {
let _ = w;
format!(
"(if (call $timer (i32.const {ms})) (then (call $set_output (i32.const 200000) (call $reply_read (i32.const 200000) (i32.const 5000))) (return (i32.const 1))))"
)
}
};
for (ms, api, why) in [
(99, 4, "100 to 3600000"),
(3_600_001, 4, "100 to 3600000"),
(-1, 4, "100 to 3600000"),
(i64::from(i32::MIN), 4, "100 to 3600000"),
(1000, 3, "need plugin API 4"),
] {
let mut s = Session::new();
install(&mut s, manifest(api), &card(ask(ms)));
let e = err(&mut s, "widget.insert", json!({"plugin": CARD}));
assert!(e.contains(why), "{ms}: {e}");
assert_eq!(crate::plugins_tests::layer_count(&s), 0);
}
for ms in [100, 3_600_000, 0] {
let mut s = Session::new();
install(&mut s, manifest(4), &card(ask(ms)));
let r = ex(&mut s, "widget.insert", json!({"plugin": CARD}));
assert_eq!(r["timer"].is_u64(), ms > 0, "{ms}");
}
}
#[test]
fn richer_layers_are_built() {
let mut s = session();
let (w, r) = insert(&mut s);
assert_eq!(r["targets"], json!(["start", "stop"]));
let root = node(&s, w);
let PaintKind::Gradient(g) = &root.fills[0].kind else { panic!("{:?}", root.fills) };
assert_eq!((g.stops.len(), g.from, g.to), (2, (0.0, 0.5), (1.0, 0.5)), "0° runs left to right");
let EffectKind::DropShadow(shadow) = &root.effects[0].kind else { panic!("{:?}", root.effects) };
assert_eq!((shadow.offset, shadow.radius), ((1.0, 2.0), 6.0));
assert_eq!(root.corners.radii, [1.0, 2.0, 3.0, 4.0]);
assert_eq!(root.layout.as_ref().unwrap().padding, [4.0, 8.0, 12.0, 16.0]);
let title = node(&s, part(&s, w, "Title"));
let t = title.kind.text().unwrap();
assert_eq!(
(t.max_lines, format!("{:?}", t.align_h).to_lowercase(), format!("{:?}", t.auto_resize).to_lowercase()),
(2, "center".into(), "truncate".into())
);
assert!(serde_json::to_string(t).unwrap().contains("Bold"), "weight 700 is bold");
let start = node(&s, part(&s, w, "Start"));
assert!((start.width - (200.0 - 8.0 - 16.0)).abs() < 1e-6, "it fills the frame's width: {}", start.width);
let line = node(&s, part(&s, w, "Line"));
assert!(matches!(line.kind, NodeKind::Line) && line.width == 50.0 && line.strokes.len() == 1);
let vector = node(&s, part(&s, w, "Vector"));
let NodeKind::Vector { network } = &vector.kind else { panic!("{:?}", vector.kind) };
assert!(
network.vertices.len() >= 3 && (vector.width - 20.0).abs() < 1e-6 && (vector.height - 8.0).abs() < 1e-6,
"{} × {}",
vector.width,
vector.height
);
let PaintKind::Image(image) = &node(&s, part(&s, w, "Image")).fills[0].kind else { panic!() };
assert_eq!(image.scale_mode, ImageScaleMode::Tile);
assert!(node(&s, w).children.iter().all(|c| node(&s, *c).locked));
// Drawn again, nothing piles up.
ex(&mut s, "widget.menuAction", json!({"id": w, "item": "reset"}));
assert_eq!((node(&s, w).effects.len(), node(&s, w).fills.len(), node(&s, w).children.len()), (1, 1, 7));
// A path that isn't one, or that leaves the limits, is refused and nothing is left behind.
for path in ["M0 0 L1e9 0", "M 0 0 X 5"] {
let mut b = W::default();
let body = b.render(&json!({"root": {"type": "frame", "children": [{"type": "vector", "path": path}]}}));
let mut s = Session::new();
install(&mut s, manifest(4), &b.build(&body));
let e = err(&mut s, "widget.insert", json!({"plugin": CARD}));
assert!(e.contains("path"), "{path}: {e}");
assert_eq!(crate::plugins_tests::layer_count(&s), 0);
}
}
#[test]
fn what_api_4_added_survives_save_reload_duplicating_and_export() {
let mut s = session();
let (w, _) = insert(&mut s);
ex(&mut s, "widget.click", json!({"id": w, "target": "start"}));
ex(&mut s, "widget.menuAction", json!({"id": w, "item": "size", "value": "S"}));
let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
let (back, _) = figcraft_format::load_bytes(&bytes).unwrap();
let mut t = session();
t.ds_mut().unwrap().doc = Arc::new(back);
let l = listed(&mut t, w);
assert_eq!((l["menu"][2]["value"].clone(), l["inputs"].as_array().unwrap().len(), l["state"].clone()), (json!("S"), 2, json!({"n": 2})));
assert!(l["timer"].is_null() && next_timer(&t).is_none(), "a reopened file has no timers");
assert!(matches!(t.execute("widget.fireTimer", &json!({"id": w})), Err(EngineError::Disabled(..))));
assert_eq!(ex(&mut t, "widget.setText", json!({"id": w, "input": "title", "text": "again"}))["state"], json!({"n": 4}), "and still works");
point(&mut t, PointerKind::Move, w, "Start");
assert_eq!(t.tool.overlay.widget_tip.as_deref(), Some("Start"));
// A duplicate has its own menu values.
s.execute("select.set", &json!({"ids": [w]})).unwrap();
ex(&mut s, "edit.duplicate", json!({}));
let copy = s.selection()[0];
ex(&mut s, "widget.menuAction", json!({"id": copy, "item": "size", "value": "L"}));
assert_eq!((listed(&mut s, copy)["menu"][2]["value"].clone(), listed(&mut s, w)["menu"][2]["value"].clone()), (json!("L"), json!("S")));
// So has a pasted copy, with the texts, the parts and the menu of the one copied, and no timer.
ex(&mut s, "edit.copy", json!({"ids": [w]}));
let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({}))["ids"][0]).unwrap();
let l = listed(&mut s, pasted);
assert_eq!(
(l["inputs"].as_array().unwrap().len(), l["targets"].clone(), l["menu"][2]["value"].clone(), l["timer"].clone()),
(2, json!(["start", "stop"]), json!("S"), Value::Null)
);
assert_eq!(ex(&mut s, "widget.setText", json!({"id": pasted, "input": "title", "text": "pasted"}))["state"], json!({"n": 4}));
assert_eq!(state(&mut s, w), json!({"n": 2}), "the original keeps its own state");
let svg = crate::cmd::file::export_bytes(s.doc().unwrap(), &[w], figcraft_doc::ExportFormat::Svg, 1.0).unwrap();
let svg = String::from_utf8(svg).unwrap().to_ascii_lowercase();
assert!(svg.contains("lineargradient") && svg.contains("dddddd"), "{svg}");
}
#[test]
fn plugins_written_for_api_3_get_what_they_always_got() {
// The same description under API 3: what API 4 added is ignored where API 3 ignored it.
let plain = |mut v: Value| {
let root = &mut v["root"];
root["padding"] = json!(8);
root["radius"] = json!(4);
root["children"].as_array_mut().unwrap().retain(|c| c["type"] != "line" && c["type"] != "vector");
v
};
let mut b = W::default();
let body = b.render(&plain(look(1, "old")));
let mut s = Session::new();
install(&mut s, manifest(3), &b.build(&body));
let (w, r) = insert(&mut s);
assert_eq!(
(r["inputs"].clone(), r["menu"].clone(), r["timer"].clone(), r["targets"].clone()),
(json!([]), json!([]), Value::Null, json!(["start", "stop"]))
);
let root = node(&s, w);
assert!(root.effects.is_empty() && root.fills.is_empty(), "no shadow, no gradient");
let (title, start) = (node(&s, part(&s, w, "Title")), node(&s, part(&s, w, "Start")));
assert!(title.plugin_data.is_empty() && start.plugin_data.len() == 1, "only the target: {:?}", start.plugin_data);
assert_eq!((title.kind.text().unwrap().max_lines, start.width), (0, 100.0));
point(&mut s, PointerKind::Move, w, "Start");
assert!(s.ds().unwrap().text_preview.is_none() && s.tool.overlay.widget_tip.is_none());
point(&mut s, PointerKind::Move, w, "Title");
assert_eq!(s.tool.overlay.cursor, Cursor::Default);
assert!(err(&mut s, "widget.setText", json!({"id": w, "input": "title", "text": "x"})).contains("no text `title`"));
assert!(err(&mut s, "widget.menuAction", json!({"id": w, "item": "reset"})).contains("no `reset`"));
// And refused where API 3 refused it: kinds and shapes it didn't have.
for (root, why) in [
(json!({"type": "frame", "children": [{"type": "line"}]}), "needs plugin API 4"),
(json!({"type": "frame", "children": [{"type": "vector", "path": "M0 0 L1 1"}]}), "needs plugin API 4"),
(json!({"type": "frame", "layout": "vertical", "padding": [1, 2, 3, 4]}), "need plugin API 4"),
] {
let mut b = W::default();
let body = b.render(&json!({ "root": root }));
let mut s = Session::new();
install(&mut s, manifest(3), &b.build(&body));
assert!(err(&mut s, "widget.insert", json!({"plugin": CARD})).contains(why), "{root}");
}
}
#[test]
fn junk_layer_data_and_parameters_never_panic() {
let mut s = session();
let (w, _) = insert(&mut s);
// A file (or a peer) may hold anything in a widget's data: it is read as nothing.
let mut seed = 0x2545_F491_4F6C_DD1Du64;
let mut noise = |n: usize| -> String {
(0..n)
.map(|_| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
char::from_u32((seed % 0x2FF) as u32).unwrap_or('\u{0}')
})
.collect()
};
let junk = [
"null".to_string(),
"[]".into(),
"{".into(),
r#"{"name": "", "max": 0}"#.into(),
r#"{"name": "a b", "max": 1e308, "multiline": 3}"#.into(),
r#"{"name": "title", "max": 18446744073709551615}"#.into(),
r#"{"fill": 5, "stroke": [1], "opacity": "x"}"#.into(),
r##"{"fill": "#12", "opacity": 1e308}"##.into(),
r#"[{"type": "link", "name": "x", "url": "javascript:alert(1)"}]"#.into(),
r#"[{"type": "dropdown", "name": "x"}]"#.into(),
format!("[{}]", vec![r#"{"type": "action", "name": "a"}"#; 40].join(",")),
"x".repeat(200_000),
noise(300),
noise(5000),
];
for j in &junk {
let (title, start) = (part(&s, w, "Title"), part(&s, w, "Start"));
let doc = s.ds_mut().unwrap().doc_mut();
for (id, key) in
[(title, "figcraft.widget.input"), (start, "figcraft.widget.hover"), (start, "figcraft.widget.tooltip"), (w, "figcraft.widget.menu")]
{
doc.get_mut(id).unwrap().plugin_data.insert(key.into(), j.clone());
}
doc.get_mut(w).unwrap().plugin_data.insert("figcraft.widget.timer".into(), j.clone());
doc.get_mut(w).unwrap().plugin_data.insert("figcraft.widget.state".into(), j.clone());
let l = listed(&mut s, w);
assert_eq!((l["menu"].clone(), l["timer"].clone()), (json!([]), Value::Null), "{}", &j[..j.len().min(60)]);
assert!(l["inputs"].as_array().unwrap().iter().all(|i| i["maxLength"].as_u64().is_some_and(|m| (1..=1000).contains(&m))));
// Used as a pointer would: the text first (its junk says it isn't one), then a click that
// draws the widget anew, then its text for real.
for name in ["Title", "Start", "Title"] {
for kind in [PointerKind::Move, PointerKind::Down, PointerKind::Up] {
point(&mut s, kind, w, name);
}
if s.ds().unwrap().text_edit.is_some() {
crate::textedit::insert(&mut s, "typed").unwrap();
crate::textedit::key(&mut s, "Enter", false, false, false).unwrap();
}
}
assert!(run_timers(&mut s).is_empty());
}
// The new commands with junk parameters: an error each, never a panic.
let rows = [
json!({}),
json!(null),
json!([1, 2]),
json!({"id": "x", "input": 5, "text": [1], "item": {"a": 1}, "value": 1e308, "fuel": "NaN"}),
json!({"id": w, "input": "\u{0}", "text": "\u{0}".repeat(5000), "item": "\u{202e}", "value": {"deep": [[[]]]}, "fuel": -1e308}),
json!({"id": part(&s, w, "Title"), "input": "title", "text": "x".repeat(100_000), "item": "tint", "value": null, "fuel": 1}),
json!({"id": 99999, "input": "title", "text": "", "item": "reset"}),
json!({"id": -1, "target": [1], "input": null, "item": null}),
];
for cmd in ["widget.setText", "widget.menu", "widget.menuAction", "widget.fireTimer", "widget.click", "widget.list", "widget.insert"] {
for p in &rows {
if let Err(e) = s.execute(cmd, p) {
assert!(!matches!(e, EngineError::Internal { .. }), "{cmd} {p}: {e}");
}
}
}
}
/// A session in the room `server` keeps, with the card plugin: (session, its link's script, id).
type Peer = (Session, Arc<Mutex<Script>>, u32);
fn join(server: &mut Server, access: Access) -> Peer {
let (mut s, script) = crate::live_tests::session();
install(&mut s, manifest(4), &card(|_| String::new()));
s.execute("collab.join", &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap();
let ClientMsg::Hello(h) = Client::hello("f", "", "Me", "") else { panic!("no hello") };
let (id, outs) = server.join_as(h, access).unwrap();
let welcome = outs.into_iter().find_map(|o| if let Outgoing::To(_, m @ ServerMsg::Welcome { .. }) = o { Some(m) } else { None }).unwrap();
let mut me = info(Role::Guest, id);
me.access = access;
push(&script, vec![Event::Welcome { info: me, peers: vec![] }, Event::Sync(welcome)]);
s.live_poll();
(s, script, id)
}
/// Send what each peer did through the server until nobody has anything left to send.
fn pump(server: &mut Server, peers: &mut [Peer]) {
for _ in 0..8 {
let mut sent = false;
for i in 0..peers.len() {
peers[i].0.live_sync();
let (msgs, from) = (std::mem::take(&mut peers[i].1.lock().unwrap().msgs), peers[i].2);
for out in msgs.into_iter().flat_map(|m| server.receive(from, m)) {
sent = true;
for (_, script, id) in peers.iter() {
let m = match &out {
Outgoing::To(to, m) if to == id => m,
Outgoing::Others(but, m) if but != id => m,
Outgoing::All(m) => m,
_ => continue,
};
if !matches!(m, ServerMsg::Presence { .. }) {
push(script, vec![Event::Sync(m.clone())]);
}
}
}
}
peers.iter_mut().for_each(|p| {
p.0.live_poll();
});
if !sent {
return;
}
}
}
#[test]
fn in_a_live_session_updates_sync_and_a_timer_runs_for_whoever_started_it_last() {
let mut server = Server::new(Session::new().doc().unwrap().clone(), "", figcraft_collab::Limits::default());
let mut peers = [join(&mut server, Access::Edit), join(&mut server, Access::Edit), join(&mut server, Access::View)];
let (a, b, viewer) = (0, 1, 2);
let (w, _) = insert(&mut peers[a].0);
pump(&mut server, &mut peers);
assert!(peers.iter().all(|p| p.0.doc().unwrap().node(w).is_some()), "the widget reached everyone");
// Typed text and a menu choice by one show for the other.
ex(&mut peers[b].0, "widget.setText", json!({"id": w, "input": "title", "text": "Hi"}));
pump(&mut server, &mut peers);
ex(&mut peers[a].0, "widget.menuAction", json!({"id": w, "item": "size", "value": "L"}));
pump(&mut server, &mut peers);
for p in &mut peers {
let l = listed(&mut p.0, w);
assert_eq!((l["state"].clone(), l["menu"][2]["value"].clone()), (json!({"n": 4}), json!("L")));
}
// A starts the timer; B starts it after: only B's fires, and its tick reaches A.
ex(&mut peers[a].0, "widget.click", json!({"id": w, "target": "start"}));
pump(&mut server, &mut peers);
assert!(next_timer(&peers[a].0).is_some() && next_timer(&peers[b].0).is_none(), "the timer is its starter's");
ex(&mut peers[b].0, "widget.click", json!({"id": w, "target": "start"}));
pump(&mut server, &mut peers);
std::thread::sleep(std::time::Duration::from_millis(130));
assert!(run_timers(&mut peers[a].0).is_empty());
assert_eq!((state(&mut peers[a].0, w), next_timer(&peers[a].0)), (json!({"n": 2}), None), "A's was replaced: dropped without a callback");
assert!(run_timers(&mut peers[b].0).is_empty());
assert_eq!(state(&mut peers[b].0, w), json!({"n": 3}));
pump(&mut server, &mut peers);
assert_eq!(state(&mut peers[a].0, w), json!({"n": 3}));
assert_eq!(server.document().node(w).unwrap().plugin_data.get("figcraft.widget.state").map(String::as_str), Some(r#"{"n":3}"#));
// The hover look never syncs, and an update arriving under it shows.
point(&mut peers[a].0, PointerKind::Move, w, "Start");
assert!(peers[a].0.ds().unwrap().text_preview.is_some());
ex(&mut peers[b].0, "widget.menuAction", json!({"id": w, "item": "reset"}));
pump(&mut server, &mut peers);
assert_eq!(state(&mut peers[a].0, w), json!({"n": 0}));
let start = part(&peers[b].0, w, "Start");
assert_eq!(server.document().node(start).unwrap().fills[0].solid_color().unwrap().to_css_hex(), "#0D99FF");
// Someone who may only view can't use the widget and runs no timers.
let v = &mut peers[viewer].0;
assert!(v.execute("widget.click", &json!({"id": w, "target": "start"})).is_err());
assert!(v.execute("widget.setText", &json!({"id": w, "input": "title", "text": "no"})).is_err());
assert!(run_timers(v).is_empty() && next_timer(v).is_none());
assert_eq!(state(v, w), json!({"n": 0}));
}
#[test]
fn a_prototypes_copy_takes_text_menu_items_and_timers_too() {
let mut s = session();
let (w, _) = insert(&mut s);
let editor = s.doc().unwrap().clone();
let undo = undo_len(&s);
let (copy, out) = run_on(&s, editor.clone(), "widget.setText", &json!({"id": w, "input": "title", "text": "Hello there"})).unwrap();
assert_eq!((out["state"].clone(), out["output"]["widget"]["event"]["text"].clone()), (json!({"n": 44}), json!("Hello ther")));
let (copy, out) = run_on(&s, copy, "widget.menuAction", &json!({"id": w, "item": "reset"})).unwrap();
assert_eq!(out["state"], json!({"n": 0}));
// A callback that asks for a timer says so; the player keeps the clock and fires it.
let (copy, out) = run_on(&s, copy, "widget.click", &json!({"id": w, "target": "start"})).unwrap();
assert!(out["timer"].as_u64().is_some_and(|t| t <= 100) && out["timerChanged"] == json!(true), "{}", out["timer"]);
// An event that leaves the timer alone says so; one that stops it, too.
let (copy, out) = run_on(&s, copy, "widget.menuAction", &json!({"id": w, "item": "pinned"})).unwrap();
assert_eq!((out["timerChanged"].clone(), out["timer"].clone()), (json!(false), Value::Null));
let (copy, out) = run_on(&s, copy, "widget.fireTimer", &json!({"id": w})).unwrap();
assert_eq!((out["state"].clone(), out["timer"].is_u64(), out["timerChanged"].clone()), (json!({"n": 3}), true, json!(true)));
let (copy, out) = run_on(&s, copy, "widget.click", &json!({"id": w, "target": "stop"})).unwrap();
assert_eq!((out["timerChanged"].clone(), out["timer"].clone()), (json!(true), Value::Null));
assert_eq!(copy.node(w).unwrap().plugin_data.get("figcraft.widget.state").map(String::as_str), Some(r#"{"n":3}"#));
// The file, its history and its timers are as they were.
assert_eq!((state(&mut s, w), undo_len(&s), next_timer(&s)), (json!({"n": 1}), undo, None));
assert!(run_on(&s, copy.clone(), "node.delete", &json!({"ids": [w]})).is_err(), "only widget events");
assert!(run_on(&s, copy, "widget.setText", &json!({"id": w, "input": "nope", "text": ""})).is_err());
}
/// A plugin that shows panel `spec` on every call.
fn panel_plugin(spec: &Value) -> Vec<u8> {
let mut w = W::default();
w.imports = r#"(import "figcraft" "ui_show" (func $ui_show (param i32 i32) (result i32)))"#.into();
let (a, l) = w.s(&spec.to_string());
w.build(&format!(
"(if (call $ui_show (i32.const {a}) (i32.const {l})) (then (call $set_output (i32.const 200000) (call $reply_read (i32.const 200000) (i32.const 5000))) (return (i32.const 1))))"
))
}
#[test]
fn panels_take_sliders_images_progress_sections_and_disabled_controls() {
let spec = json!({"title": "Dots", "controls": [
{"type": "slider", "name": "count", "label": "Count", "min": 2, "max": 12, "step": 2, "value": 7},
{"type": "slider", "name": "plain"},
{"type": "image", "label": "Preview", "data": PIXEL, "height": 80, "fit": "fit"},
{"type": "progress", "label": "Half", "value": 0.5},
{"type": "divider"},
{"type": "section", "label": "More", "value": false},
{"type": "text", "name": "name", "error": "Give it a name"},
{"type": "button", "name": "go", "label": "Go", "disabled": true},
{"type": "button", "name": "close", "label": "Close"}
]});
let manifest = |api: u32| json!({"id": "org.test.panel", "name": "Panel", "main": "p.wasm", "api": api, "permissions": {"access": "read"}});
let mut s = Session::new();
install(&mut s, manifest(4), &panel_plugin(&spec));
let r = ex(&mut s, "plugin.run", json!({"id": "org.test.panel"}));
let c = &r["panel"]["controls"];
assert_eq!(r["panel"]["values"], json!({"count": 8.0, "plain": 0.0, "name": ""}), "a slider's value sits on a step");
assert_eq!((c[1]["min"].clone(), c[1]["max"].clone()), (json!(0.0), json!(100.0)));
assert_eq!(
(c[3]["default"].clone(), c[5]["default"].clone(), c[6]["error"].clone(), c[7]["disabled"].clone()),
(json!(0.5), json!(false), json!("Give it a name"), json!(true))
);
// A slider's event is clamped and stepped; display controls and disabled ones can't be used.
let r = ex(&mut s, "plugin.event", json!({"id": "org.test.panel", "control": "count", "value": 99}));
assert_eq!(r["output"]["event"], json!({"control": "count", "value": 12.0}));
let r = ex(&mut s, "plugin.event", json!({"id": "org.test.panel", "control": "count", "value": 4.9}));
assert_eq!(r["output"]["event"]["value"], json!(4.0));
assert!(err(&mut s, "plugin.event", json!({"id": "org.test.panel", "control": "go"})).contains("disabled"));
assert!(err(&mut s, "plugin.event", json!({"id": "org.test.panel", "control": "count", "value": "x"})).contains("number"));
ex(&mut s, "plugin.event", json!({"id": "org.test.panel", "control": "close"}));
// Under API 2 the same panel is refused: it didn't know these controls.
let mut s = Session::new();
install(&mut s, manifest(2), &panel_plugin(&spec));
assert!(err(&mut s, "plugin.run", json!({"id": "org.test.panel"})).contains("needs plugin API 4"));
// And what API 4 added to old controls is ignored there, as unknown fields always were.
let old = json!({"controls": [{"type": "number", "name": "n", "min": 0, "max": 10, "step": 5, "value": 3, "disabled": true, "error": "x"}]});
install(&mut s, manifest(2), &panel_plugin(&old));
let r = ex(&mut s, "plugin.run", json!({"id": "org.test.panel"}));
assert_eq!(r["panel"]["controls"][0], json!({"name": "n", "type": "number", "default": 3.0, "min": 0.0, "max": 10.0}));
// Junk panels are refused whole.
for (controls, why) in [
(json!([{"type": "slider", "name": "s", "step": 0}]), "bad step"),
(json!([{"type": "slider", "name": "s", "min": 5, "max": 1}]), "bad bounds"),
(json!([{"type": "image", "label": "No data"}]), "`data`"),
(json!([{"type": "text", "name": "t", "data": "AAAA"}]), "`data`"),
(json!([{"type": "image", "data": PIXEL, "height": 4000}]), "high"),
(json!([{"type": "text", "name": "t", "error": "x".repeat(300)}]), "longer than"),
(json!([{"type": "slider"}]), "parameter name"),
] {
let mut s = Session::new();
install(&mut s, manifest(4), &panel_plugin(&json!({ "controls": controls })));
let e = err(&mut s, "plugin.run", json!({"id": "org.test.panel"}));
assert!(e.contains(why), "{e}");
}
}