mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-31 05:46:46 -06:00
M6: plugin widgets have hover styles (gap 6) (#1634)
This commit is contained in:
+2
-1
File diff suppressed because one or more lines are too long
@@ -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 };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user