Files
figcraft/crates/plugin/src/widget.rs
T

392 lines
16 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.
//! 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,
/// How the layer looks while the pointer is over it (hosts that don't know it draw the layer
/// as it is).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hover: Option<WidgetHover>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub children: Vec<WidgetNode>,
}
/// A widget layer's look while the pointer is over it: what it gives replaces the layer's own
/// fill, stroke, opacity or (text) colour, as the reference tool's widgets' hover style does.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct WidgetHover {
#[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(default, skip_serializing_if = "Option::is_none")]
pub opacity: Option<f64>,
/// A text layer's colour.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
}
impl WidgetHover {
/// Check and normalise it for a `kind` layer: hex colours, an opacity from 0 to 1, a colour for
/// text and a fill for the rest, and something to show.
pub fn check(&mut self, kind: WidgetKind) -> Result<(), String> {
colour("hover fill", &mut self.fill)?;
colour("hover stroke", &mut self.stroke)?;
colour("hover color", &mut self.color)?;
within("hover opacity", self.opacity, 0.0, 1.0)?;
let text = kind == WidgetKind::Text;
if self.color.is_some() && !text {
return Err("`hover.color` is for text layers (others take `fill`)".into());
}
if self.fill.is_some() && text {
return Err("a text layer's hover takes `color`, not `fill`".into());
}
if self.fill.is_some() && kind == WidgetKind::Image {
return Err("an image layer's hover takes no `fill`".into());
}
if *self == WidgetHover::default() {
return Err("`hover` needs a fill, stroke, opacity or color".into());
}
Ok(())
}
}
/// 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 let Some(h) = &mut self.hover {
h.check(kind)?;
}
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))
}
}