Files
figcraft/plugins/examples/counter/src/lib.rs
T
BFlat 8d60360173 M11: widgets take text input and a property menu (plugin API 4) (#1365)
* M8: widgets take text input and a property menu

Plugin API 4 (gap 6). A widget's text layer can be an editable field:
a double-click edits it like any text, and when editing ends the value
(cleaned, at most 1,000 characters) goes into the widget's state under
the field's name and its plugin redraws, as one undo step. A widget can
also carry a property menu of up to 8 actions, toggles and colour
choices, shown in a small bar over it while it's selected.

- widget.setText and widget.menu drive the same paths from scripts,
  the CLI and MCP; widget.insert/list also report fields and menu
- descriptions, values and stored menus are validated as untrusted;
  an API 3 plugin drawing a field or menu is refused, older hosts
  refuse API 4 manifests; no new host imports
- SDK reads the new events; the Counter example gains a label field
  and a menu (reset, steps of ten, a colour)
- tests for fields, menus, canvas editing, rollback, forged data and
  live sync; junk sweep extended; labels in every command catalog
- docs: plugins, mcp, gaps, target-app-parity, ROADMAP, roadmap

* M8: widget property menu on egui 0.37

Id::new and Ui::id are deprecated in egui 0.37: the widget bar's area uses Id::unique and its buttons' interaction ids come from ui.scope_id().

* M11: a double-click a board object or a widget field takes, in a function of its own (Sonar)
2026-10-26 13:31:53 +07:00

124 lines
5.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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.
use figcraft_plugin_sdk::{Input, Result, Value, Widget, entry, json, render_widget};
/// 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";
/// 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`.
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",
"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": "text", "name": "Label", "field": "label", "text": text, "size": 12, "color": ink},
{"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": "big", "label": "Steps of 10", "type": "toggle", "checked": c.big},
{"item": "color", "label": "Colour", "type": "color", "options": COLORS, "value": c.color},
])
}
fn run(input: Input) -> Result<Value> {
let w = input.widget.ok_or("Insert the counter from Plugins ▸ Widgets")?;
let c = Counter::from_state(&w.state).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]["text"].clone(), v["children"][1]["children"][1]["text"].clone()), (json!("Score"), json!("3")));
assert_eq!(view(&c(3), " ")["children"][0]["text"], PLACEHOLDER);
assert_eq!(menu(&c(0))[1]["checked"], false);
assert!(run(Input::default()).is_err(), "it only runs as a widget");
}
}