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