mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-01 14:26:49 -06:00
M6: plugin widgets have hover styles (gap 6) (#1634)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user