diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 086fab1..89d3505 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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, + /// The widgets' pending timers (see [`widgets`]; not saved: closing the file cancels them). + pub(crate) widget_timers: Vec, + /// 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 { + 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 { + 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 { 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(&mut self, label: &str, f: impl FnOnce(&mut Self) -> Result) -> Result { + 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(&mut self, label: Option<&str>, f: impl FnOnce(&mut Self) -> Result) -> Result { 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; diff --git a/crates/engine/src/live.rs b/crates/engine/src/live.rs index 09c5831..68322a2 100644 --- a/crates/engine/src/live.rs +++ b/crates/engine/src/live.rs @@ -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) { 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 diff --git a/crates/engine/src/plugins.rs b/crates/engine/src/plugins.rs index f1c4075..3541bfb 100644 --- a/crates/engine/src/plugins.rs +++ b/crates/engine/src/plugins.rs @@ -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> 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) -> 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, 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 { 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); diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index 6ba9421..05eb062 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -32,6 +32,21 @@ pub fn begin(s: &mut Session, id: NodeId, caret: Option, 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 = 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 = 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 { diff --git a/crates/engine/src/textpreview.rs b/crates/engine/src/textpreview.rs index 507eccc..20da1bc 100644 --- a/crates/engine/src/textpreview.rs +++ b/crates/engine/src/textpreview.rs @@ -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 diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index cfb9176..1cd9dd9 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -301,6 +301,8 @@ pub struct Overlay { /// while one is dragged (see [`crate::shape_handles`]). pub shape_hover: Option, pub shape: Option, + /// The tooltip of the widget part under the pointer. + pub widget_tip: Option, } /// 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(()) diff --git a/crates/engine/src/widgets.rs b/crates/engine/src/widgets.rs index c1ccd2a..dc53fed 100644 --- a/crates/engine/src/widgets.rs +++ b/crates/engine/src/widgets.rs @@ -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 { 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 { + 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 { + 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::()).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 { + 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 { + 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| 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) { + 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 { 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 { + 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 = 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 = 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 { + 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 { + 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 = 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 { @@ -89,22 +344,29 @@ fn widget_plugin(s: &Session, cmd: &str, plugin: &str) -> Result, fuel: Option) -> Result { +/// 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) -> Result { 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, 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::>>()?; + 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 { @@ -186,23 +526,36 @@ fn props(d: &WidgetNode, root: bool) -> Map { 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::>())), } - 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 { 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 { 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 { p } -/// What `widget.insert` and `widget.click` return. -fn report(s: &Session, w: NodeId, m: &Manifest, out: RunOutput) -> Result { +/// 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 { let doc = s.doc()?; - let output = serde_json::from_str::(&out.output).unwrap_or(Value::String(out.output)); + let texts: Vec = 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 { + let mut v = describe(s, w)?; + v["output"] = serde_json::from_str::(&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 { let cmd = "widget.insert"; let fuel = num(p, "fuel"); @@ -259,7 +634,7 @@ fn insert(s: &mut Session, p: &Value) -> Result { 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 { 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 { + 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) -> Result { + 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 { 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 { + 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 { + 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 { + 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 { + 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 { let doc = s.doc()?; let widgets: Vec = 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 { 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 + ), ] } diff --git a/crates/engine/src/widgets_tests.rs b/crates/engine/src/widgets_tests.rs index 7079905..efe8ed5 100644 --- a/crates/engine/src/widgets_tests.rs +++ b/crates/engine/src/widgets_tests.rs @@ -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(¶ms.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 { + pub(crate) fn build(self, body: &str) -> Vec { 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 { 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) => { diff --git a/crates/engine/src/widgets_v4_tests.rs b/crates/engine/src/widgets_v4_tests.rs new file mode 100644 index 0000000..2e4693a --- /dev/null +++ b/crates/engine/src/widgets_v4_tests.rs @@ -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 { + 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>, 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 { + 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}"); + } +}