mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-29 21:06:50 -06:00
* 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)
345 lines
14 KiB
Rust
345 lines
14 KiB
Rust
//! Widgets (ABI v3; editable text fields and a property menu since v4): a plugin draws a layer on
|
||
//! the canvas by describing it, and FigCraft builds the layers. The description comes from the plugin, so it is untrusted: [`WidgetRender::parse`]
|
||
//! checks its size, node count, depth, numbers, strings, colours and image sizes before the engine
|
||
//! sees it. The engine decides where the layers go (only inside the widget's own layer).
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::{Map, Value};
|
||
|
||
use crate::manifest::{check_id, clean};
|
||
|
||
/// Largest description a plugin may pass to `widget_render` (images included, as base64).
|
||
pub const MAX_WIDGET_BYTES: usize = 4 * 1024 * 1024;
|
||
/// Largest widget state (its JSON), and its longest key.
|
||
pub const MAX_STATE_BYTES: usize = 16 * 1024;
|
||
const MAX_STATE_KEY: usize = 128;
|
||
/// Most layers in one widget, and how deep they nest.
|
||
pub const MAX_NODES: usize = 256;
|
||
const MAX_DEPTH: usize = 16;
|
||
/// Longest text in a text layer.
|
||
const MAX_TEXT: usize = 1000;
|
||
const MAX_NAME: usize = 64;
|
||
/// Sizes, positions, gaps and paddings stay within this.
|
||
const MAX_SIZE: f64 = 4000.0;
|
||
/// Largest image (decoded bytes), and all images of one widget together.
|
||
pub const MAX_IMAGE_BYTES: usize = 1024 * 1024;
|
||
const MAX_IMAGES_BYTES: usize = 2 * 1024 * 1024;
|
||
/// Longest value of an editable text field (the same as any text layer's).
|
||
pub const MAX_FIELD_CHARS: usize = MAX_TEXT;
|
||
/// Most items in a widget's property menu, most colours in one colour item, longest item label.
|
||
const MAX_MENU: usize = 8;
|
||
const MAX_SWATCHES: usize = 8;
|
||
const MAX_MENU_LABEL: usize = 32;
|
||
|
||
/// What a widget is made of: FigCraft's own layer kinds.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "lowercase")]
|
||
pub enum WidgetKind {
|
||
#[default]
|
||
Frame,
|
||
Text,
|
||
Rectangle,
|
||
Ellipse,
|
||
/// A rectangle filled with an image (PNG, JPEG…) the plugin carries in its module.
|
||
Image,
|
||
}
|
||
|
||
/// Auto layout direction of a frame.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "lowercase")]
|
||
pub enum WidgetLayout {
|
||
Horizontal,
|
||
Vertical,
|
||
}
|
||
|
||
/// Alignment of an auto layout frame's children, along (`justify`) or across (`align`) it.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "lowercase")]
|
||
pub enum WidgetAlign {
|
||
Min,
|
||
Center,
|
||
Max,
|
||
}
|
||
|
||
/// One layer of a widget's description.
|
||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||
pub struct WidgetNode {
|
||
#[serde(rename = "type", default)]
|
||
pub kind: WidgetKind,
|
||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||
pub name: String,
|
||
/// Clicking this layer calls the plugin back with this name (empty: not clickable).
|
||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||
pub target: String,
|
||
/// API 4, text layers: the text is an editable field with this name; an edit calls the plugin
|
||
/// back with the field and its new value, which is also kept in the state under this name.
|
||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||
pub field: String,
|
||
/// Position in the parent frame (ignored inside auto layout and for the widget itself).
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub x: Option<f64>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub y: Option<f64>,
|
||
/// Size; a frame with auto layout hugs its contents on an axis without one.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub width: Option<f64>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub height: Option<f64>,
|
||
/// Hex colours (`#RRGGBB` or `#RRGGBBAA`); for text, `color` is the text's.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub fill: Option<String>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub stroke: Option<String>,
|
||
#[serde(rename = "strokeWeight", default, skip_serializing_if = "Option::is_none")]
|
||
pub stroke_weight: Option<f64>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub radius: Option<f64>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub opacity: Option<f64>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub layout: Option<WidgetLayout>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub gap: Option<f64>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub padding: Option<f64>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub align: Option<WidgetAlign>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub justify: Option<WidgetAlign>,
|
||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||
pub text: String,
|
||
/// Font size of a text layer.
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub size: Option<f64>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub color: Option<String>,
|
||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||
pub bold: bool,
|
||
/// An image layer's bytes, base64.
|
||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||
pub data: String,
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
pub children: Vec<WidgetNode>,
|
||
}
|
||
|
||
/// What a widget callback hands back with `widget_render`: the widget's new state (`None`: keep
|
||
/// it) and its layers, the root a frame.
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct WidgetRender {
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub state: Option<Map<String, Value>>,
|
||
pub root: WidgetNode,
|
||
/// API 4: the widget's property menu, shown while it's selected (empty: none).
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
pub menu: Vec<MenuItem>,
|
||
}
|
||
|
||
/// What a property menu item is.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "lowercase")]
|
||
pub enum MenuKind {
|
||
/// A button.
|
||
#[default]
|
||
Action,
|
||
/// On or off (`checked`).
|
||
Toggle,
|
||
/// One of a few colours (`options`; `value` is the current one).
|
||
Color,
|
||
}
|
||
|
||
/// One item of a widget's property menu; choosing it calls the plugin back with `item`.
|
||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||
pub struct MenuItem {
|
||
pub item: String,
|
||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||
pub label: String,
|
||
#[serde(rename = "type", default)]
|
||
pub kind: MenuKind,
|
||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||
pub checked: bool,
|
||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||
pub options: Vec<String>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub value: Option<String>,
|
||
}
|
||
|
||
/// What one description may still use while it's checked.
|
||
struct Budget {
|
||
nodes: usize,
|
||
image_bytes: usize,
|
||
}
|
||
|
||
impl WidgetRender {
|
||
/// Parse and validate a widget description.
|
||
pub fn parse(text: &str) -> Result<WidgetRender, String> {
|
||
if text.len() > MAX_WIDGET_BYTES {
|
||
return Err(format!("the widget is larger than {} MB", MAX_WIDGET_BYTES / (1024 * 1024)));
|
||
}
|
||
let mut w: WidgetRender = serde_json::from_str(text).map_err(|e| format!("widget: {e}"))?;
|
||
if let Some(state) = &w.state {
|
||
check_state(state)?;
|
||
}
|
||
if w.root.kind != WidgetKind::Frame {
|
||
return Err("widget: the root must be a frame".into());
|
||
}
|
||
w.root.check(0, &mut Budget { nodes: 0, image_bytes: 0 }).map_err(|e| format!("widget: {e}"))?;
|
||
check_menu(&mut w.menu).map_err(|e| format!("widget: {e}"))?;
|
||
Ok(w)
|
||
}
|
||
|
||
/// Whether it uses what plugin API 4 added (fields or a menu).
|
||
pub fn uses_api_4(&self) -> bool {
|
||
fn field(n: &WidgetNode) -> bool {
|
||
!n.field.is_empty() || n.children.iter().any(field)
|
||
}
|
||
!self.menu.is_empty() || field(&self.root)
|
||
}
|
||
}
|
||
|
||
/// Check and normalise a property menu: at most [`MAX_MENU`] items with distinct names and short
|
||
/// labels (an item's name when it has none); a colour item has 1–[`MAX_SWATCHES`] colours, its
|
||
/// value one of them.
|
||
pub fn check_menu(menu: &mut [MenuItem]) -> Result<(), String> {
|
||
if menu.len() > MAX_MENU {
|
||
return Err(format!("a menu has more than {MAX_MENU} items"));
|
||
}
|
||
for i in 0..menu.len() {
|
||
let Some(m) = menu.get_mut(i) else { continue };
|
||
check_id("menu item", &m.item, MAX_NAME)?;
|
||
m.label = clean("a menu label", &m.label, MAX_MENU_LABEL)?;
|
||
if m.label.is_empty() {
|
||
m.label.clone_from(&m.item);
|
||
}
|
||
let colours = m.kind == MenuKind::Color;
|
||
if m.checked && m.kind != MenuKind::Toggle {
|
||
return Err("`checked` is for toggles".into());
|
||
}
|
||
if colours == m.options.is_empty() || m.options.len() > MAX_SWATCHES {
|
||
return Err(format!("a colour item needs 1–{MAX_SWATCHES} `options` (and only it has them)"));
|
||
}
|
||
for o in &mut m.options {
|
||
let mut c = Some(std::mem::take(o));
|
||
colour("options", &mut c)?;
|
||
*o = c.unwrap_or_default();
|
||
}
|
||
colour("value", &mut m.value)?;
|
||
if m.value.as_ref().is_some_and(|v| !m.options.contains(v)) {
|
||
return Err("a colour item's `value` must be one of its `options`".into());
|
||
}
|
||
let item = m.item.clone();
|
||
if menu.get(..i).is_some_and(|before| before.iter().any(|b| b.item == item)) {
|
||
return Err(format!("two menu items are called `{item}`"));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// An editable field's new value, cleaned: line endings become `\n`, tabs spaces, and other control
|
||
/// characters are dropped; at most [`MAX_FIELD_CHARS`].
|
||
pub fn clean_field(value: &str) -> Result<String, String> {
|
||
let v: String = value
|
||
.replace("\r\n", "\n")
|
||
.chars()
|
||
.filter_map(|c| match c {
|
||
'\r' | '\n' => Some('\n'),
|
||
'\t' => Some(' '),
|
||
c if c.is_control() => None,
|
||
c => Some(c),
|
||
})
|
||
.collect();
|
||
if v.chars().count() > MAX_FIELD_CHARS {
|
||
return Err(format!("a field's value is longer than {MAX_FIELD_CHARS} characters"));
|
||
}
|
||
Ok(v)
|
||
}
|
||
|
||
/// A widget's state: a JSON object of at most [`MAX_STATE_BYTES`] with short keys.
|
||
pub fn check_state(state: &Map<String, Value>) -> Result<(), String> {
|
||
if state.keys().any(|k| k.is_empty() || k.chars().count() > MAX_STATE_KEY || k.chars().any(char::is_control)) {
|
||
return Err(format!("widget: a state key must be 1–{MAX_STATE_KEY} characters without control characters"));
|
||
}
|
||
if serde_json::to_string(state).map_or(usize::MAX, |s| s.len()) > MAX_STATE_BYTES {
|
||
return Err(format!("widget: the state is larger than {} KB", MAX_STATE_BYTES / 1024));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// `v` when it's finite and within `lo..=hi`.
|
||
fn within(what: &str, v: Option<f64>, lo: f64, hi: f64) -> Result<(), String> {
|
||
match v {
|
||
Some(n) if !n.is_finite() || n < lo || n > hi => Err(format!("`{what}` must be a number from {lo} to {hi}")),
|
||
_ => Ok(()),
|
||
}
|
||
}
|
||
|
||
/// A colour as `#RRGGBB(AA)`.
|
||
fn colour(what: &str, c: &mut Option<String>) -> Result<(), String> {
|
||
if let Some(s) = c {
|
||
let parsed = Some(s.as_str()).filter(|s| s.len() <= 9).and_then(figcraft_color::Color::from_hex);
|
||
*s = parsed.ok_or_else(|| format!("`{what}` must be a hex colour like #FF5A5F"))?.to_css_hex();
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
impl WidgetNode {
|
||
fn check(&mut self, depth: usize, budget: &mut Budget) -> Result<(), String> {
|
||
budget.nodes += 1;
|
||
if budget.nodes > MAX_NODES {
|
||
return Err(format!("more than {MAX_NODES} layers"));
|
||
}
|
||
if depth >= MAX_DEPTH {
|
||
return Err(format!("layers nest more than {MAX_DEPTH} deep"));
|
||
}
|
||
self.name = clean("name", &self.name, MAX_NAME)?;
|
||
if !self.target.is_empty() {
|
||
check_id("target", &self.target, MAX_NAME)?;
|
||
}
|
||
if !self.field.is_empty() {
|
||
check_id("field", &self.field, MAX_NAME)?;
|
||
}
|
||
for (what, v) in [("x", self.x), ("y", self.y)] {
|
||
within(what, v, -MAX_SIZE, MAX_SIZE)?;
|
||
}
|
||
for (what, v) in [("width", self.width), ("height", self.height), ("gap", self.gap), ("padding", self.padding), ("radius", self.radius)] {
|
||
within(what, v, 0.0, MAX_SIZE)?;
|
||
}
|
||
within("strokeWeight", self.stroke_weight, 0.0, 64.0)?;
|
||
within("opacity", self.opacity, 0.0, 1.0)?;
|
||
within("size", self.size, 1.0, 256.0)?;
|
||
colour("fill", &mut self.fill)?;
|
||
colour("stroke", &mut self.stroke)?;
|
||
colour("color", &mut self.color)?;
|
||
let kind = self.kind;
|
||
let only = |what: &str, used: bool, allowed: bool| {
|
||
if used && !allowed { Err(format!("`{what}` doesn't apply to a {kind:?} layer")) } else { Ok(()) }
|
||
};
|
||
only("children", !self.children.is_empty(), kind == WidgetKind::Frame)?;
|
||
only(
|
||
"layout",
|
||
self.layout.is_some() || self.gap.is_some() || self.padding.is_some() || self.align.is_some() || self.justify.is_some(),
|
||
kind == WidgetKind::Frame,
|
||
)?;
|
||
only("text", !self.text.is_empty() || self.size.is_some() || self.color.is_some() || self.bold, kind == WidgetKind::Text)?;
|
||
only("data", !self.data.is_empty(), kind == WidgetKind::Image)?;
|
||
only("field", !self.field.is_empty(), kind == WidgetKind::Text)?;
|
||
if self.text.chars().count() > MAX_TEXT {
|
||
return Err(format!("a text is longer than {MAX_TEXT} characters"));
|
||
}
|
||
if self.text.chars().any(|c| c.is_control() && c != '\n') {
|
||
return Err("a text contains control characters".into());
|
||
}
|
||
if kind == WidgetKind::Image {
|
||
// Base64: four characters for three bytes (checked again when decoded).
|
||
let bytes = self.data.trim().len() / 4 * 3;
|
||
if self.data.trim().is_empty() || bytes > MAX_IMAGE_BYTES + 3 {
|
||
return Err(format!("an image needs `data` (base64) of at most {} KB", MAX_IMAGE_BYTES / 1024));
|
||
}
|
||
budget.image_bytes += bytes;
|
||
if budget.image_bytes > MAX_IMAGES_BYTES + 3 {
|
||
return Err(format!("the images are larger than {} MB together", MAX_IMAGES_BYTES / (1024 * 1024)));
|
||
}
|
||
}
|
||
self.children.iter_mut().try_for_each(|c| c.check(depth + 1, budget))
|
||
}
|
||
}
|