//! Counter: a widget on the canvas with − and + buttons around a count, a label you can edit on the
//! canvas, and a property menu (reset, steps of ten, a colour). Each click, edit or menu choice
//! calls the plugin back with the widget's state; it hands back the new state and the redrawn
//! widget. Its menu's "Set count…" opens a panel (API 5) whose Set button redraws the widget. An SVG
//! icon and a line (API 6) set off its label.
use figcraft_plugin_sdk::{Event, Input, Result, Value, Widget, close_ui, entry, json, render_widget, show_ui};
/// The count stays within ± this.
const LIMIT: i64 = 999_999;
/// The colours the menu offers for the count (the first is the default).
const COLORS: [&str; 4] = ["#1E1E1E", "#0D99FF", "#14AE5C", "#F24822"];
/// Shown in the label field while it's empty.
const PLACEHOLDER: &str = "Add a label";
/// Four tally marks and a stroke across them.
const ICON: &str = r##""##;
/// What the widget shows.
#[derive(Debug, PartialEq)]
struct Counter {
count: i64,
big: bool,
color: String,
}
impl Counter {
fn from_state(state: &Value) -> Counter {
let color = state.get("color").and_then(Value::as_str).filter(|c| COLORS.contains(c)).unwrap_or(COLORS[0]);
Counter {
count: state.get("count").and_then(Value::as_i64).unwrap_or(0),
big: state.get("big").and_then(Value::as_bool).unwrap_or(false),
color: color.into(),
}
}
/// After the click, edit or menu choice `w` brings (nothing: the widget was just inserted).
fn next(mut self, w: &Widget) -> Counter {
let step = if self.big { 10 } else { 1 };
match (w.target.as_deref(), w.menu.as_deref()) {
(Some("increment"), _) => self.count = self.count.saturating_add(step),
(Some("decrement"), _) => self.count = self.count.saturating_sub(step),
(_, Some("reset")) => self.count = 0,
(_, Some("big")) => self.big = w.value.as_bool().unwrap_or(!self.big),
(_, Some("color")) => {
if let Some(c) = w.value.as_str().filter(|c| COLORS.contains(c)) {
self.color = c.into();
}
}
_ => {}
}
self.count = self.count.clamp(-LIMIT, LIMIT);
self
}
}
/// A square button that calls back with `target`, darker while the pointer is over it.
fn button(target: &str, name: &str, label: &str) -> Value {
json!({"type": "frame", "name": name, "target": target, "layout": "horizontal", "justify": "center", "width": 32, "height": 32, "radius": 8, "fill": "#F0F0F0",
"hover": {"fill": "#E0E0E0"},
"children": [{"type": "text", "text": label, "size": 16, "bold": true, "color": "#1E1E1E"}]})
}
/// The widget showing `c` under `label`.
fn view(c: &Counter, label: &str) -> Value {
let (text, ink) = if label.trim().is_empty() { (PLACEHOLDER, "#B3B3B3") } else { (label, "#757575") };
let (minus, plus) = if c.big { ("−10", "+10") } else { ("−", "+") };
json!({"type": "frame", "name": "Counter", "layout": "vertical", "gap": 6, "padding": 12, "radius": 12, "align": "min",
"fill": "#FFFFFF", "stroke": "#D9D9D9", "children": [
{"type": "frame", "name": "Header", "layout": "horizontal", "gap": 6, "children": [
{"type": "svg", "name": "Icon", "src": ICON},
{"type": "text", "name": "Label", "field": "label", "text": text, "size": 12, "color": ink},
]},
{"type": "line", "name": "Rule", "width": 120, "stroke": "#E6E6E6"},
{"type": "frame", "name": "Row", "layout": "horizontal", "gap": 12, "children": [
button("decrement", "Decrease", minus),
{"type": "text", "name": "Count", "text": c.count.to_string(), "size": 24, "bold": true, "color": c.color},
button("increment", "Increase", plus),
]},
]})
}
/// The property menu for `c`.
fn menu(c: &Counter) -> Value {
json!([
{"item": "reset", "label": "Reset", "type": "action"},
{"item": "set", "label": "Set count…", "type": "action"},
{"item": "big", "label": "Steps of 10", "type": "toggle", "checked": c.big},
{"item": "color", "label": "Colour", "type": "color", "options": COLORS, "value": c.color},
])
}
/// The panel "Set count…" opens, showing `c`'s count.
fn set_panel(c: &Counter) -> Value {
json!({"title": "Set count", "controls": [
{"type": "number", "name": "count", "label": "Count", "min": -LIMIT, "max": LIMIT, "value": c.count},
{"type": "button", "name": "set", "label": "Set"},
{"type": "button", "name": "cancel", "label": "Cancel"},
]})
}
/// `c` after its panel's `e`: Set takes the count entered; Cancel changes nothing.
fn from_panel(mut c: Counter, e: &Event) -> Counter {
if e.control == "set"
&& let Some(n) = e.values.get("count").and_then(Value::as_f64).filter(|n| n.is_finite())
{
// Panel numbers arrive as decimals; the bounds keep the cast exact.
c.count = n.round().clamp(-LIMIT as f64, LIMIT as f64) as i64;
}
c
}
fn run(input: Input) -> Result {
let w = input.widget.ok_or("Insert the counter from Plugins ▸ Widgets")?;
let c = Counter::from_state(&w.state);
let c = match &input.event {
// The "Set count…" panel: Set redraws and closes it, Cancel only closes it, and typing in
// its field waits for them.
Some(e) if e.control == "set" => {
close_ui();
from_panel(c, e)
}
Some(e) => {
if e.control == "cancel" {
close_ui();
}
return Ok(json!({"count": c.count}));
}
None if w.menu.as_deref() == Some("set") => {
show_ui(&set_panel(&c))?;
return Ok(json!({"count": c.count}));
}
None => c.next(&w),
};
// An edited label is in the state already, under its field's name.
let label = w.state.get("label").and_then(Value::as_str).unwrap_or("Counter").to_string();
let state = json!({"count": c.count, "big": c.big, "color": c.color, "label": label});
render_widget(&json!({"state": state, "root": view(&c, &label), "menu": menu(&c)}))?;
Ok(json!({"count": c.count}))
}
entry!(run);
#[cfg(test)]
mod tests {
use super::*;
fn widget(target: Option<&str>, menu: Option<&str>, value: Value) -> Widget {
Widget { target: target.map(Into::into), menu: menu.map(Into::into), value, ..Widget::default() }
}
#[test]
fn counts_within_its_limit_and_follows_its_menu() {
let c = |count: i64| Counter { count, big: false, color: COLORS[0].into() };
let at = |n: i64, t: &str| c(n).next(&widget(Some(t), None, Value::Null)).count;
assert_eq!(c(0).next(&Widget::default()).count, 0);
assert_eq!((at(4, "increment"), at(4, "decrement")), (5, 3));
assert_eq!((at(LIMIT, "increment"), at(i64::MIN, "decrement"), at(2, "other")), (LIMIT, -LIMIT, 2));
assert_eq!(c(7).next(&widget(None, Some("reset"), Value::Null)).count, 0);
let big = c(1).next(&widget(None, Some("big"), json!(true)));
assert!(big.big);
assert_eq!(big.next(&widget(Some("increment"), None, Value::Null)).count, 11, "steps of ten");
assert_eq!(c(1).next(&widget(None, Some("color"), json!("#14AE5C"))).color, "#14AE5C");
assert_eq!(c(1).next(&widget(None, Some("color"), json!("#123456"))).color, COLORS[0], "only the colours offered");
assert_eq!(Counter::from_state(&json!({"count": 3, "color": "junk"})), c(3));
let v = view(&c(3), "Score");
assert_eq!((v["children"][0]["children"][1]["text"].clone(), v["children"][2]["children"][1]["text"].clone()), (json!("Score"), json!("3")));
assert_eq!(view(&c(3), " ")["children"][0]["children"][1]["text"], PLACEHOLDER);
assert_eq!(menu(&c(0))[2]["checked"], false);
assert_eq!(v["children"][2]["children"][0]["hover"], json!({"fill": "#E0E0E0"}), "buttons darken under the pointer");
assert!(run(Input::default()).is_err(), "it only runs as a widget");
let ev = |control: &str, count: Value| Event { control: control.into(), values: json!({"count": count}), ..Event::default() };
assert_eq!(from_panel(c(3), &ev("set", json!(42.0))).count, 42);
assert_eq!(from_panel(c(3), &ev("set", json!(5_000_000))).count, LIMIT);
assert_eq!(from_panel(c(3), &ev("cancel", json!(42))).count, 3);
assert_eq!(set_panel(&c(3))["controls"][0]["value"], 3);
assert_eq!(menu(&c(0))[1]["item"], "set");
}
}