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
+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");
}