M6: plugin widgets have hover styles (gap 6) (#1634)

This commit is contained in:
BFlat
2026-10-30 17:33:04 +07:00
committed by GitHub
parent 9099f1d5ae
commit f0abe51a84
15 changed files with 342 additions and 11 deletions
+2 -1
View File
File diff suppressed because one or more lines are too long
+22 -1
View File
@@ -12,7 +12,7 @@ use serde_json::Value;
use crate::{EngineError, Result, Session, bad, find_command, guard, normalize};
/// The commands that show or end a preview (they don't end the one showing).
pub const COMMANDS: [&str; 2] = ["text.preview", "text.endPreview"];
pub const COMMANDS: [&str; 3] = ["text.preview", "text.endPreview", "widget.hover"];
/// What can be previewed: text styles, font styles and axis values, layers' properties and the
/// blend of a paint on a layer, a selection or a style.
@@ -70,6 +70,27 @@ pub fn show(s: &mut Session, command: &str, params: &Value) -> Result<()> {
}
}
/// Show what `change` does to the document as a live preview (in place of an earlier one), like
/// [`show`] does with a command's: no undo step, and the next command puts the document back first.
pub(crate) fn show_change(s: &mut Session, cmd: &str, change: impl FnOnce(&mut figcraft_doc::Document) -> Result<()>) -> Result<()> {
end(s);
let ds = s.ds_mut()?;
if ds.preview.is_some() {
return Err(EngineError::Disabled(cmd.into(), crate::PREVIEW_READ_ONLY.into()));
}
let base = ds.doc.clone();
let mut doc = (*base).clone();
guard::run(cmd, || {
change(&mut doc)?;
normalize(&mut doc, &base);
Ok(())
})?;
ds.doc = Arc::new(doc);
ds.text_preview = Some(base);
ds.revision += 1;
Ok(())
}
/// End the live preview, if any: the document is back as it was. Returns whether one was showing.
pub fn end(s: &mut Session) -> bool {
let Ok(ds) = s.ds_mut() else { return false };
+2
View File
@@ -578,6 +578,8 @@ pub struct ToolState {
pub pick_only: bool,
/// The colour the eyedropper sampled for a colour picker, until the picker takes it.
pub picked: Option<figcraft_color::Color>,
/// The widget layers whose hover styles show while the pointer is over them (a live preview).
pub(crate) widget_hover: Vec<NodeId>,
}
impl ToolState {
+21
View File
@@ -13,6 +13,24 @@ impl Session {
self.tool.overlay.measure_box = None;
}
self.vector_pointer_left();
self.follow_widget_hover(Vec::new());
}
/// Show the hover styles of the widget layers `ids` the pointer is over (none: end them), unless
/// another live preview (a hovered font, say) is showing.
fn follow_widget_hover(&mut self, ids: Vec<NodeId>) {
let ours = !self.tool.widget_hover.is_empty() && crate::textpreview::active(self);
if ids.is_empty() {
if ours {
crate::textpreview::end(self);
}
self.tool.widget_hover.clear();
return;
}
if ours && self.tool.widget_hover == ids || !ours && crate::textpreview::active(self) {
return;
}
self.tool.widget_hover = if self.widget_hover(&ids).unwrap_or(false) { ids } else { Vec::new() };
}
/// What a marquee starting at page point `p` selects inside: the top-level frame it starts in
@@ -194,6 +212,7 @@ impl Session {
cursor = Cursor::Text;
}
let mut hover = None;
let mut widget_hover = Vec::new();
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 };
@@ -214,6 +233,7 @@ impl Session {
if leaf.and_then(|l| crate::widgets::target_at(&doc, l, ev.p())).is_some() {
cursor = Cursor::Pointer;
}
widget_hover = leaf.map(|l| crate::widgets::hovered_at(&doc, l, ev.p())).unwrap_or_default();
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 && !self.tool.dev_mode && tool == ToolId::Move && hover.is_some_and(|h| sel.contains(&h) && !locked_in(&doc, h)) {
@@ -260,6 +280,7 @@ impl Session {
if tool == ToolId::Connector {
self.connector_hover(ev);
}
self.follow_widget_hover(widget_hover);
let ds = self.ds_mut()?;
if ds.hover != hover {
ds.hover = hover;
+72 -1
View File
@@ -16,7 +16,7 @@ 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, check_menu, check_state, clean_field};
use figcraft_plugin::{Instance, Manifest, RunOutput, WidgetKind, WidgetNode, WidgetRender};
use figcraft_plugin::{Instance, Manifest, RunOutput, WidgetHover, WidgetKind, WidgetNode, WidgetRender};
pub use figcraft_plugin::{MenuItem, MenuKind};
use serde_json::{Map, Value, json};
@@ -36,6 +36,8 @@ const MENU: &str = "figcraft.widget.menu";
const TARGET: &str = "figcraft.widget.target";
/// On a text layer inside a widget: the name of the editable field it is.
const FIELD: &str = "figcraft.widget.field";
/// On a widget or a layer inside one: how it looks while the pointer is over it (JSON).
const HOVER: &str = "figcraft.widget.hover";
/// Images in a widget are at most this many pixels on a side.
const MAX_IMAGE_SIDE: u32 = 4096;
@@ -79,6 +81,54 @@ pub fn field_of(doc: &Document, id: NodeId) -> Option<(NodeId, String)> {
Some((widget_of(doc, id)?, field))
}
/// The layers of a widget under page point `p` that change while the pointer is over them (the
/// widget itself among them), given the layer the pointer hits, outermost first.
pub fn hovered_at(doc: &Document, hit: NodeId, p: Point) -> Vec<NodeId> {
let Some(w) = widget_of(doc, hit) else { return Vec::new() };
doc.subtree(w)
.into_iter()
.filter(|i| doc.node(*i).is_some_and(|n| n.visible && n.plugin_data.contains_key(HOVER)))
.filter(|i| doc.world_bounds(*i).is_some_and(|b| b.contains(p)))
.collect()
}
/// What layer `n` sets while the pointer is over it, as `node.set` properties (none when it has no
/// usable hover style: the layer data may come from a file).
fn hover_props(n: &Node) -> Option<Map<String, Value>> {
let text = n.plugin_data.get(HOVER).filter(|s| s.len() <= MAX_STATE_BYTES)?;
let mut h: WidgetHover = serde_json::from_str(text).ok()?;
h.check(if matches!(n.kind, NodeKind::Text(_)) { WidgetKind::Text } else { WidgetKind::Rectangle }).ok()?;
let mut p = Map::new();
if let Some(c) = h.fill.or(h.color) {
p.insert("fills".into(), json!([c]));
}
if let Some(c) = h.stroke {
p.insert("strokes".into(), json!([c]));
}
if let Some(o) = h.opacity {
p.insert("opacity".into(), json!(o));
}
Some(p)
}
impl Session {
/// Show the hover styles of widget layers `ids` as a live preview (none: end the one showing):
/// nothing is undone, saved or sent to a live session, and the next command puts the document
/// back first. Layers without a hover style are left as they are.
pub fn widget_hover(&mut self, ids: &[NodeId]) -> Result<bool> {
let cmd = "widget.hover";
crate::textpreview::end(self);
let doc = self.doc()?;
let changes: Vec<(NodeId, Map<String, Value>)> =
ids.iter().filter(|i| widget_of(doc, **i).is_some()).filter_map(|i| Some((*i, hover_props(doc.node(*i)?)?))).collect();
if changes.is_empty() {
return Ok(false);
}
crate::textpreview::show_change(self, cmd, |doc| changes.iter().try_for_each(|(id, p)| crate::cmd::props::apply(cmd, doc, *id, p)))?;
Ok(true)
}
}
/// Widget `w`'s property menu (empty when it has none or it's unusable: it may come from a file).
pub fn menu_of(doc: &Document, w: NodeId) -> Vec<MenuItem> {
doc.node(w)
@@ -172,6 +222,7 @@ fn build(s: &mut Session, plugin: &str, w: NodeId, r: &WidgetRender) -> Result<(
Ok(menu) if !r.menu.is_empty() => n.plugin_data.insert(MENU.into(), menu),
_ => n.plugin_data.remove(MENU),
};
set_hover(n, r.root.hover.as_ref());
for c in &r.root.children {
add(doc, w, c, cmd)?;
}
@@ -199,6 +250,7 @@ fn add(doc: &mut Document, parent: NodeId, d: &WidgetNode, cmd: &str) -> Result<
if !d.field.is_empty() {
n.plugin_data.insert(FIELD.into(), d.field.clone());
}
set_hover(&mut n, d.hover.as_ref());
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}")))?;
@@ -218,6 +270,14 @@ fn add(doc: &mut Document, parent: NodeId, d: &WidgetNode, cmd: &str) -> Result<
d.children.iter().try_for_each(|c| add(doc, id, c, cmd))
}
/// Keep layer `n`'s hover style (validated already) with it, or drop the one it had.
fn set_hover(n: &mut Node, hover: Option<&WidgetHover>) {
match hover.and_then(|h| serde_json::to_string(h).ok()) {
Some(h) => n.plugin_data.insert(HOVER.into(), h),
None => n.plugin_data.remove(HOVER),
};
}
/// `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<String, Value> {
@@ -462,6 +522,17 @@ pub fn specs() -> Vec<CommandSpec> {
false,
menu
),
spec!(
"widget.hover",
"Hover widget",
"{ids? [layers of widgets]} shows their hover styles, as the canvas does while the pointer is over a widget: a live preview without an undo step, which the next command (or no ids) ends → {hovering}",
false,
|s, p| {
let ids: Vec<NodeId> =
p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(crate::params::id_of).collect()).unwrap_or_default();
Ok(json!({"hovering": s.widget_hover(&ids)?}))
}
),
spec!("widget.list", "List widgets", "{} → {widgets: [{id, plugin, name, page, state, targets, fields, menu}]}", false, list),
]
}
+2
View File
@@ -597,6 +597,8 @@ mod view_behaviour_tests;
#[cfg(test)]
mod vue_codegen_tests;
#[cfg(test)]
mod widget_hover_tests;
#[cfg(test)]
mod widget_input_tests;
#[cfg(test)]
mod widgets_tests;
+129
View File
@@ -0,0 +1,129 @@
//! Widgets' hover styles: while the pointer is over a widget layer that has one, the canvas shows
//! it as a live preview (no undo step, nothing saved or synced); moving away, leaving the canvas or
//! any command puts the layer back, and a click on a hovered part acts on the document itself.
use serde_json::{Value, json};
use crate::components::tests::{ex, idv, node};
use crate::plugins_tests::{install, undo_len};
use crate::tools::{PointerEvent, PointerKind};
use crate::widgets_tests::{W, widget_manifest};
use crate::{Session, textpreview};
use figcraft_doc::NodeId;
use figcraft_geom::Point;
const BLUE: &str = "0D99FF";
const DARK: &str = "0B7FD6";
/// A widget whose button darkens and whose label turns white while hovered; the widget fades a
/// little. A click on the button counts.
fn button() -> Value {
json!({"root": {"type": "frame", "name": "Hover", "layout": "horizontal", "padding": 10, "fill": "#FFFFFF", "hover": {"opacity": 0.9}, "children": [
{"type": "rectangle", "name": "Button", "target": "go", "width": 40, "height": 20, "fill": format!("#{BLUE}"), "hover": {"fill": format!("#{DARK}"), "stroke": "#000000"}},
{"type": "text", "name": "Label", "text": "Go", "color": "#111111", "hover": {"color": "#FFFFFF"}}]}})
}
fn session() -> (Session, NodeId) {
let mut s = Session::new();
let mut w = W::default();
let body = w.render(&button());
install(&mut s, widget_manifest("org.test.hover", "read"), &w.build(&body));
let id = idv(&ex(&mut s, "widget.insert", json!({"plugin": "org.test.hover", "x": 50, "y": 60})));
ex(&mut s, "select.set", json!({"ids": []}));
(s, id)
}
fn fill(s: &Session, id: NodeId) -> String {
node(s, id).fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default()
}
fn hover(s: &mut Session, p: Point) {
s.pointer(&PointerEvent::at(PointerKind::Hover, p.x, p.y, 1.0)).unwrap();
}
fn hover_over(s: &mut Session, id: NodeId) {
let p = centre(s, id);
hover(s, p);
}
fn centre(s: &Session, id: NodeId) -> Point {
s.doc().unwrap().world_bounds(id).unwrap().center()
}
#[test]
fn hovering_a_widget_shows_its_hover_styles_without_an_edit() {
let (mut s, w) = session();
let kids = node(&s, w).children.clone();
let (btn, label) = (kids[0], kids[1]);
let undo = undo_len(&s);
hover_over(&mut s, btn);
assert_eq!(fill(&s, btn), DARK, "the hovered button");
assert!(!node(&s, btn).strokes.is_empty());
assert!((node(&s, w).opacity - 0.9).abs() < 1e-6, "the widget the pointer is in");
assert_eq!(fill(&s, label), "111111", "the label isn't under the pointer");
let itself = s.ds().unwrap().document_itself().clone();
assert_eq!(itself.node(btn).unwrap().fills[0].solid_color().unwrap().to_hex(), BLUE, "the document itself is unchanged");
assert_eq!(undo_len(&s), undo, "no undo step");
// Over the label: the label's style, not the button's.
hover_over(&mut s, label);
assert_eq!((fill(&s, btn), fill(&s, label)), (BLUE.into(), "FFFFFF".into()));
// Away from the widget, and leaving the canvas: as it was.
hover(&mut s, Point::new(-400.0, -400.0));
assert_eq!((fill(&s, btn), fill(&s, label)), (BLUE.into(), "111111".into()));
assert!((node(&s, w).opacity - 1.0).abs() < 1e-6);
hover_over(&mut s, btn);
s.pointer_left();
assert_eq!(fill(&s, btn), BLUE);
assert!(!textpreview::active(&s));
}
#[test]
fn a_click_on_a_hovered_part_edits_the_document_itself() {
let (mut s, w) = session();
let btn = node(&s, w).children[0];
let at = centre(&s, btn);
hover(&mut s, at);
assert_eq!(fill(&s, btn), DARK);
let undo = undo_len(&s);
s.pointer(&PointerEvent::at(PointerKind::Down, at.x, at.y, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, at.x, at.y, 1.0)).unwrap();
assert_eq!(undo_len(&s), undo + 1, "the click is one undo step");
// The widget was redrawn from the document itself: no hover style was kept in it.
let btn = node(&s, w).children[0];
assert_eq!(fill(&s, btn), BLUE);
ex(&mut s, "edit.undo", json!({}));
let btn = node(&s, w).children[0];
assert_eq!(fill(&s, btn), BLUE, "undo brings back the document, not a hovered one");
// Hovering again shows it again.
hover(&mut s, at);
assert_eq!(fill(&s, btn), DARK);
}
#[test]
fn widget_hover_is_a_command_any_other_command_ends() {
let (mut s, w) = session();
let btn = node(&s, w).children[0];
assert_eq!(ex(&mut s, "widget.hover", json!({"ids": [btn]}))["hovering"], true);
assert_eq!(fill(&s, btn), DARK);
ex(&mut s, "select.set", json!({"ids": [w]}));
assert_eq!(fill(&s, btn), BLUE, "another command puts the document back first");
// Layers without a hover style, and layers outside widgets, show nothing.
let r = idv(&ex(&mut s, "shape.rectangle", json!({"x": 400, "y": 0, "width": 10, "height": 10})));
assert_eq!(ex(&mut s, "widget.hover", json!({"ids": [r]}))["hovering"], false);
assert_eq!(ex(&mut s, "widget.hover", json!({}))["hovering"], false);
// A hover style damaged in a file is ignored.
let doc = s.ds_mut().unwrap().doc_mut();
doc.get_mut(btn).unwrap().plugin_data.insert("figcraft.widget.hover".into(), "{\"fill\": \"not a colour\"}".into());
assert_eq!(ex(&mut s, "widget.hover", json!({"ids": [btn]}))["hovering"], false);
}
#[test]
fn a_widget_doesnt_replace_another_live_preview() {
let (mut s, w) = session();
let btn = node(&s, w).children[0];
let other = idv(&ex(&mut s, "shape.rectangle", json!({"x": 400, "y": 0, "width": 10, "height": 10})));
ex(&mut s, "text.preview", json!({"command": "node.set", "params": {"ids": [other], "props": {"opacity": 0.5}}}));
hover_over(&mut s, btn);
assert!((node(&s, other).opacity - 0.5).abs() < 1e-6, "the preview showing stays");
assert_eq!(fill(&s, btn), BLUE);
}
+1 -1
View File
@@ -11,7 +11,7 @@ pub mod widget;
pub use manifest::{API_VERSION, Access, MANIFEST_FILE, Manifest, Panel, Param, ParamKind, Permissions, PluginCommand};
pub use sandbox::{ENTRY, Host, IMPORT_MODULE, Instance, Limits, PluginError, RunOutput, UiChange, check_module, run};
pub use widget::{MenuItem, MenuKind, WidgetKind, WidgetNode, WidgetRender};
pub use widget::{MenuItem, MenuKind, WidgetHover, WidgetKind, WidgetNode, WidgetRender};
#[cfg(test)]
mod bundle_tests;
+47
View File
@@ -118,10 +118,54 @@ pub struct WidgetNode {
/// 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)]
@@ -322,6 +366,9 @@ impl WidgetNode {
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"));
}
+23
View File
@@ -158,3 +158,26 @@ fn field_values_are_cleaned_and_bounded() {
assert!(clean_field(&"x".repeat(MAX_FIELD_CHARS + 1)).is_err());
assert_eq!(clean_field("").unwrap(), "");
}
#[test]
fn hover_styles_are_checked_and_normalised() {
let w = parse(json!({"root": {"type": "frame", "hover": {"opacity": 0.8}, "children": [
{"type": "rectangle", "target": "go", "fill": "#0D99FF", "hover": {"fill": "0b7fd6", "stroke": "#000"}},
{"type": "text", "text": "Go", "hover": {"color": "#fff"}}]}}))
.unwrap();
assert_eq!(w.root.hover.as_ref().and_then(|h| h.opacity), Some(0.8));
let rect = w.root.children[0].hover.clone().unwrap();
assert_eq!((rect.fill.as_deref(), rect.stroke.as_deref()), (Some("#0B7FD6"), Some("#000000")));
assert_eq!(w.root.children[1].hover.as_ref().and_then(|h| h.color.as_deref()), Some("#FFFFFF"));
for (bad, why) in [
(json!({"type": "rectangle", "hover": {}}), "needs a fill"),
(json!({"type": "rectangle", "hover": {"fill": "red"}}), "hex colour"),
(json!({"type": "rectangle", "hover": {"opacity": 1.5}}), "from 0 to 1"),
(json!({"type": "rectangle", "hover": {"color": "#fff"}}), "for text layers"),
(json!({"type": "text", "text": "x", "hover": {"fill": "#fff"}}), "takes `color`"),
] {
let e = parse(json!({"root": {"type": "frame", "children": [bad]}})).unwrap_err();
assert!(e.contains(why), "{e}");
}
assert!(!w.uses_api_4(), "a hover style needs no new API: older hosts draw the layer as it is");
}
+8 -4
View File
File diff suppressed because one or more lines are too long
+6
View File
@@ -150,6 +150,7 @@ plugins: import them in the script.
| `widget.click` | `{id, target, fuel?}` | Clicks part `target` of widget `id` (or of the widget a layer `id` is in), as a click on the canvas would: the plugin is called back and redraws it. One undo step; returns like `widget.insert`. |
| `widget.setText` | `{id, field, value, fuel?}` | Sets editable field `field` of widget `id` (or of the widget a layer `id` is in) to the text `value`, as editing it on the canvas would: the value (at most 1,000 characters, cleaned) goes into the state under the field's name and into the field's text, and the plugin is called back. One undo step; returns like `widget.insert`. |
| `widget.menu` | `{id, item, value?, fuel?}` | Uses item `item` of widget `id`'s property menu, as its bar would: an action takes no `value`; a toggle takes `true` or `false` (default: the opposite of now); a colour takes one of its options (hex, any case). The plugin is called back. One undo step; returns like `widget.insert`. |
| `widget.hover` | `{ids?}` | Shows the hover styles of widget layers `ids`, as the canvas does while the pointer is over them: a live preview with no undo step, which the next command (or no `ids`) ends. Returns `{hovering}`. |
| `widget.list` | `{}` | `{widgets: [{id, plugin, name, page, state, targets, fields, menu}]}`: every widget in the file. |
| `plugin.directory.list` | `{server?, query?}` | The plugin directory at `server` (`ws://host:port/`; default: the one browsed last): `{server, plugins: [{id, name, version, description, author, api, access, commands, widget, bytes, sha256, published, versions, installed, update}]}`, the latest version of each plugin whose id, name, description or author contains `query`. `installed` is the installed version (or null), `update` whether the listed one is newer. |
| `plugin.directory.install` | `{id, version?, confirm, server?, sha256?}` | Downloads version `version` (default: the latest) and installs it, or updates the installed one. `confirm` must be `true` (installing runs the plugin's code: show its permissions first); without it nothing is downloaded. The download must match the SHA-256 the directory lists (and `sha256`, the one shown, when given). Returns `{installed, name, version, permissions, sha256, path, source, replaced}`. |
@@ -402,6 +403,11 @@ with auto layout it hugs its contents on an axis without a size. A text has `tex
`color` and `bold` (with a `width` it wraps), and with API 4 may have a `field` (a name like a
target's): the text is then an editable field showing its current value. An image has `data`, the image's bytes (PNG, JPEG…)
as base64, usually carried in the module itself; without a size it takes the image's.
Any layer, the widget itself too, may have a `hover`: how it looks while the pointer is over
it on the canvas, with `fill` (a text: `color`), `stroke` and `opacity` replacing its own,
for example `"hover": {"fill": "#E0E0E0"}` on a button. It is shown as a live preview, never
saved or sent to a live session, and needs no new API (a FigCraft that doesn't know it draws the
layer as it is).
FigCraft checks the whole description (kinds, sizes, numbers, colours, lengths, counts, depth,
image sizes) and refuses it if anything is off.
+2 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -50,9 +50,10 @@ impl Counter {
}
}
/// A square button that calls back with `target`.
/// 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"}]})
}
@@ -118,6 +119,7 @@ mod tests {
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_eq!(v["children"][1]["children"][0]["hover"], json!({"fill": "#E0E0E0"}), "buttons darken under the pointer");
assert!(run(Input::default()).is_err(), "it only runs as a widget");
}
}