diff --git a/crates/doc/src/proto.rs b/crates/doc/src/proto.rs index a39e88c..cec1f0a 100644 --- a/crates/doc/src/proto.rs +++ b/crates/doc/src/proto.rs @@ -69,6 +69,41 @@ pub enum Easing { } impl Easing { + pub const ALL: [Easing; 11] = [ + Easing::Linear, + Easing::EaseIn, + Easing::EaseOut, + Easing::EaseInOut, + Easing::EaseInBack, + Easing::EaseOutBack, + Easing::EaseInOutBack, + Easing::Gentle, + Easing::Quick, + Easing::Bouncy, + Easing::Slow, + ]; + + pub fn label(self) -> &'static str { + match self { + Easing::Linear => "Linear", + Easing::EaseIn => "Ease in", + Easing::EaseOut => "Ease out", + Easing::EaseInOut => "Ease in and out", + Easing::EaseInBack => "Ease in back", + Easing::EaseOutBack => "Ease out back", + Easing::EaseInOutBack => "Ease in and out back", + Easing::Gentle => "Gentle", + Easing::Quick => "Quick", + Easing::Bouncy => "Bouncy", + Easing::Slow => "Slow", + } + } + + /// Spring presets run longer than the chosen duration in the incumbent; we keep the duration. + pub fn is_spring(self) -> bool { + matches!(self, Easing::Gentle | Easing::Quick | Easing::Bouncy | Easing::Slow) + } + /// Evaluate the curve at `t` (0..1). pub fn eval(self, t: f64) -> f64 { let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 1.0 }; @@ -120,23 +155,70 @@ pub enum Transition { Dissolve, SmartAnimate, MoveIn { + #[serde(default)] direction: Direction, }, MoveOut { + #[serde(default)] direction: Direction, }, Push { + #[serde(default)] direction: Direction, }, SlideIn { + #[serde(default)] direction: Direction, }, SlideOut { + #[serde(default)] direction: Direction, }, } impl Transition { + /// The transition's direction, if it has one. + pub fn direction(&self) -> Option { + match self { + Transition::MoveIn { direction } + | Transition::MoveOut { direction } + | Transition::Push { direction } + | Transition::SlideIn { direction } + | Transition::SlideOut { direction } => Some(*direction), + _ => None, + } + } + + /// The same transition with another direction (no-op for directionless ones). + pub fn with_direction(self, d: Direction) -> Transition { + match self { + Transition::MoveIn { .. } => Transition::MoveIn { direction: d }, + Transition::MoveOut { .. } => Transition::MoveOut { direction: d }, + Transition::Push { .. } => Transition::Push { direction: d }, + Transition::SlideIn { .. } => Transition::SlideIn { direction: d }, + Transition::SlideOut { .. } => Transition::SlideOut { direction: d }, + other => other, + } + } + + /// The opposite motion (used when a "while hovering/pressing" state reverts). + pub fn reversed(self) -> Transition { + let opp = |d: Direction| match d { + Direction::Left => Direction::Right, + Direction::Right => Direction::Left, + Direction::Top => Direction::Bottom, + Direction::Bottom => Direction::Top, + }; + match self { + Transition::MoveIn { direction } => Transition::MoveOut { direction: opp(direction) }, + Transition::MoveOut { direction } => Transition::MoveIn { direction: opp(direction) }, + Transition::Push { direction } => Transition::Push { direction: opp(direction) }, + Transition::SlideIn { direction } => Transition::SlideOut { direction: opp(direction) }, + Transition::SlideOut { direction } => Transition::SlideIn { direction: opp(direction) }, + other => other, + } + } + pub fn label(&self) -> &'static str { match self { Transition::Instant => "Instant", @@ -165,6 +247,32 @@ pub enum OverlayPosition { Manual, } +impl OverlayPosition { + pub const ALL: [OverlayPosition; 8] = [ + OverlayPosition::Center, + OverlayPosition::TopLeft, + OverlayPosition::TopCenter, + OverlayPosition::TopRight, + OverlayPosition::BottomLeft, + OverlayPosition::BottomCenter, + OverlayPosition::BottomRight, + OverlayPosition::Manual, + ]; + + pub fn label(self) -> &'static str { + match self { + OverlayPosition::Center => "Center", + OverlayPosition::TopLeft => "Top left", + OverlayPosition::TopCenter => "Top center", + OverlayPosition::TopRight => "Top right", + OverlayPosition::BottomLeft => "Bottom left", + OverlayPosition::BottomCenter => "Bottom center", + OverlayPosition::BottomRight => "Bottom right", + OverlayPosition::Manual => "Manual", + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum Action { @@ -179,8 +287,11 @@ pub enum Action { dest: NodeId, #[serde(default)] position: OverlayPosition, - #[serde(default)] + #[serde(default, alias = "closeOnOutside")] close_on_outside: bool, + /// Colour drawn behind the overlay (over the frame underneath); None = no background. + #[serde(default)] + background: Option, }, SwapOverlay { dest: NodeId, @@ -232,7 +343,7 @@ pub struct Interaction { pub actions: Vec, #[serde(default)] pub transition: Transition, - #[serde(default = "default_duration")] + #[serde(default = "default_duration", alias = "durationMs")] pub duration_ms: u32, #[serde(default)] pub easing: Easing, diff --git a/crates/engine/src/cmd/proto.rs b/crates/engine/src/cmd/proto.rs index 1165782..e2696d4 100644 --- a/crates/engine/src/cmd/proto.rs +++ b/crates/engine/src/cmd/proto.rs @@ -13,44 +13,80 @@ pub fn specs() -> Vec { spec!( "proto.interaction.add", "Add interaction", - "{id?, trigger? {type: onClick|onHover|onPress|onDrag|afterDelay|mouseEnter|mouseLeave|keyPress,..}, action {type: navigate|back|scrollTo|openOverlay|swapOverlay|closeOverlay|openUrl|changeTo|setVariable, dest?, url?}, transition? {type: instant|dissolve|smartAnimate|moveIn|push|slideIn,..}, durationMs?, easing?} — or {id, dest} for a click-navigate", + "{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key}}, action {type: navigate|back|scrollTo|openOverlay {position?, close_on_outside?, background?}|swapOverlay|closeOverlay|openUrl {url}|changeTo|setVariable {variable, value}, dest?}, actions? [..], transition? {type: instant|dissolve|smartAnimate|moveIn|moveOut|push|slideIn|slideOut, direction?: left|right|top|bottom}, durationMs?, easing? linear|easeIn|easeOut|easeInOut|easeInBack|easeOutBack|easeInOutBack|gentle|quick|bouncy|slow} — or {id, dest} for a click-navigate. Returns {index}", true, add ), spec!("proto.interaction.remove", "Remove interaction", "{id?, index}", true, |s, p| { - let k = req_num("proto.interaction.remove", p, "index")? as usize; - for i in req_targets("proto.interaction.remove", s, p)? { + let cmd = "proto.interaction.remove"; + let k = req_num(cmd, p, "index")?; + if k < 0.0 { + return Err(bad(cmd, "`index` must be ≥ 0")); + } + let k = k as usize; + let mut hit = false; + for i in req_targets(cmd, s, p)? { let n = s.doc_mut()?.get_mut(i)?; if k < n.interactions.len() { n.interactions.remove(k); + hit = true; } } + if !hit { + return Err(bad(cmd, format!("no interaction {k}"))); + } ok() }), - spec!("proto.interaction.set", "Edit interaction", "{id?, index, interaction {..}}", true, |s, p| { - let k = req_num("proto.interaction.set", p, "index")? as usize; - let it: Interaction = - parse("proto.interaction.set", p, "interaction")?.ok_or_else(|| bad("proto.interaction.set", "`interaction` required"))?; - for i in req_targets("proto.interaction.set", s, p)? { - let n = s.doc_mut()?.get_mut(i)?; - if let Some(slot) = n.interactions.get_mut(k) { - *slot = it.clone(); - } - } - ok() - }), - spec!("proto.flow.add", "Add flow starting point", "{start? (frame id; default selection), name?}", true, |s, p| { - let start = id(p, "start").or_else(|| s.selection().first().copied()).ok_or_else(|| bad("proto.flow.add", "select a top-level frame"))?; + spec!( + "proto.interaction.set", + "Edit interaction", + "{id?, index, interaction {..}} replaces it; or patch fields: {id?, index, trigger?, action? (first action), actions?, transition?, durationMs?, easing?}", + true, + set + ), + spec!("proto.flow.add", "Add flow starting point", "{start? (top-level frame id; default selection), name?}", true, |s, p| { + let cmd = "proto.flow.add"; + let start = id(p, "start").or_else(|| s.selection().first().copied()).ok_or_else(|| bad(cmd, "select a top-level frame"))?; let page = s.page()?; let doc = s.doc_mut()?; - let count = match &doc.get(page)?.kind { - NodeKind::Page { flows, .. } => flows.len(), - _ => 0, + if doc.top_level_frame(start) != Some(start) { + return Err(bad(cmd, "a flow starts at a top-level frame")); + } + let flows = match &doc.get(page)?.kind { + NodeKind::Page { flows, .. } => flows.clone(), + _ => vec![], }; - let name = string(p, "name").unwrap_or_else(|| format!("Flow {}", count + 1)); + if let Some(f) = flows.iter().find(|f| f.start == start) { + return Ok(json!({"name": f.name, "start": start})); + } + let name = string(p, "name").filter(|n| !n.trim().is_empty()).unwrap_or_else(|| { + let mut k = flows.len() + 1; + while flows.iter().any(|f| f.name == format!("Flow {k}")) { + k += 1; + } + format!("Flow {k}") + }); if let NodeKind::Page { flows, .. } = &mut doc.get_mut(page)?.kind { - flows.retain(|f| f.start != start); - flows.push(Flow { name, start, description: String::new() }); + flows.push(Flow { name: name.clone(), start, description: String::new() }); + } + Ok(json!({"name": name, "start": start})) + }), + spec!("proto.flow.rename", "Rename flow", "{start, name?, description?}", true, |s, p| { + let cmd = "proto.flow.rename"; + let start = req_id(cmd, p, "start")?; + let name = string(p, "name").map(|n| n.trim().to_string()); + if name.as_deref() == Some("") { + return Err(bad(cmd, "`name` can't be empty")); + } + let desc = string(p, "description"); + let page = s.page()?; + let NodeKind::Page { flows, .. } = &mut s.doc_mut()?.get_mut(page)?.kind else { return Err(bad(cmd, "not a page")) }; + let f = flows.iter_mut().find(|f| f.start == start).ok_or_else(|| bad(cmd, "no flow starts at that frame"))?; + if let Some(n) = name { + f.name = n; + } + if let Some(d) = desc { + f.description = d; } ok() }), @@ -95,25 +131,217 @@ pub fn specs() -> Vec { ] } +/// Every node an action points at must exist. +fn check_dests(cmd: &str, s: &Session, it: &Interaction) -> Result<()> { + let doc = s.doc()?; + for a in &it.actions { + if let Some(d) = a.dest() + && !doc.contains(d) + { + return Err(bad(cmd, format!("destination {d} doesn't exist"))); + } + } + Ok(()) +} + +fn duration(p: &Value) -> Option { + num(p, "durationMs").or_else(|| num(p, "duration_ms")).map(|d| d.clamp(0.0, 60_000.0) as u32) +} + fn add(s: &mut Session, p: &Value) -> Result { let cmd = "proto.interaction.add"; - let it = if let Some(d) = id(p, "dest") { + let it = if let Some(d) = id(p, "dest") + && get(p, "action").is_none() + { Interaction { - trigger: Trigger::OnClick, + trigger: parse(cmd, p, "trigger")?.unwrap_or_default(), actions: vec![Action::Navigate { dest: d }], - transition: Transition::Instant, - duration_ms: 300, - easing: Easing::EaseOut, + transition: parse(cmd, p, "transition")?.unwrap_or_default(), + duration_ms: duration(p).unwrap_or(300), + easing: parse(cmd, p, "easing")?.unwrap_or_default(), } } else { let trigger: Trigger = parse(cmd, p, "trigger")?.unwrap_or_default(); - let action: Action = parse(cmd, p, "action")?.ok_or_else(|| bad(cmd, "`action` required"))?; + let actions: Vec = match parse::>(cmd, p, "actions")? { + Some(a) if !a.is_empty() => a, + _ => vec![parse(cmd, p, "action")?.ok_or_else(|| bad(cmd, "`action` required"))?], + }; let transition: Transition = parse(cmd, p, "transition")?.unwrap_or_default(); let easing: Easing = parse(cmd, p, "easing")?.unwrap_or_default(); - Interaction { trigger, actions: vec![action], transition, duration_ms: num_or(p, "durationMs", 300.0).clamp(0.0, 60_000.0) as u32, easing } + Interaction { trigger, actions, transition, duration_ms: duration(p).unwrap_or(300), easing } }; + check_dests(cmd, s, &it)?; + let mut index = 0; for i in req_targets(cmd, s, p)? { - s.doc_mut()?.get_mut(i)?.interactions.push(it.clone()); + let n = s.doc_mut()?.get_mut(i)?; + n.interactions.push(it.clone()); + index = n.interactions.len() - 1; + } + Ok(json!({"index": index})) +} + +fn set(s: &mut Session, p: &Value) -> Result { + let cmd = "proto.interaction.set"; + let k = req_num(cmd, p, "index")?; + if k < 0.0 { + return Err(bad(cmd, "`index` must be ≥ 0")); + } + let k = k as usize; + let whole: Option = parse(cmd, p, "interaction")?; + let trigger: Option = parse(cmd, p, "trigger")?; + let action: Option = parse(cmd, p, "action")?; + let actions: Option> = parse(cmd, p, "actions")?; + let transition: Option = parse(cmd, p, "transition")?; + let easing: Option = parse(cmd, p, "easing")?; + let dur = duration(p); + let mut hit = false; + for i in req_targets(cmd, s, p)? { + let Some(cur) = s.doc()?.get(i)?.interactions.get(k).cloned() else { continue }; + let mut it = whole.clone().unwrap_or(cur); + if let Some(t) = &trigger { + it.trigger = t.clone(); + } + if let Some(a) = &actions { + it.actions = a.clone(); + } + if let Some(a) = &action { + match it.actions.first_mut() { + Some(slot) => *slot = a.clone(), + None => it.actions.push(a.clone()), + } + } + if let Some(t) = transition { + it.transition = t; + } + if let Some(e) = easing { + it.easing = e; + } + if let Some(d) = dur { + it.duration_ms = d; + } + if it.actions.is_empty() { + return Err(bad(cmd, "an interaction needs an action")); + } + check_dests(cmd, s, &it)?; + if let Some(slot) = s.doc_mut()?.get_mut(i)?.interactions.get_mut(k) { + *slot = it; + hit = true; + } + } + if !hit { + return Err(bad(cmd, format!("no interaction {k}"))); } ok() } + +#[cfg(test)] +mod tests { + use super::*; + use crate::params::id_of; + + fn frame(s: &mut Session, x: f64) -> NodeId { + let r = s.execute("node.create", &json!({"type": "frame", "x": x, "y": 0, "width": 100, "height": 200})).unwrap(); + id_of(&r["id"]).unwrap() + } + + fn page_flows(s: &Session) -> Vec { + let ds = s.ds().unwrap(); + match &ds.doc.node(ds.page).unwrap().kind { + NodeKind::Page { flows, .. } => flows.clone(), + _ => vec![], + } + } + + #[test] + fn add_set_patch_remove() { + let mut s = Session::new(); + let a = frame(&mut s, 0.0); + let b = frame(&mut s, 200.0); + let r = s.execute("proto.interaction.add", &json!({"id": a, "dest": b})).unwrap(); + assert_eq!(r["index"], 0); + let r = s + .execute( + "proto.interaction.add", + &json!({"id": a, "trigger": {"type": "keyPress", "key": "Space"}, "action": {"type": "back"}, "transition": {"type": "moveIn"}, "durationMs": 450, "easing": "bouncy"}), + ) + .unwrap(); + assert_eq!(r["index"], 1); + let it = s.doc().unwrap().node(a).unwrap().interactions[1].clone(); + assert_eq!(it.trigger, Trigger::KeyPress { key: "Space".into() }); + assert_eq!(it.transition, Transition::MoveIn { direction: Direction::Left }); + assert_eq!(it.duration_ms, 450); + assert_eq!(it.easing, Easing::Bouncy); + // Patch only the transition and easing; the action stays. + s.execute("proto.interaction.set", &json!({"id": a, "index": 0, "transition": {"type": "push", "direction": "top"}, "easing": "linear"})) + .unwrap(); + let it = s.doc().unwrap().node(a).unwrap().interactions[0].clone(); + assert_eq!(it.actions, vec![Action::Navigate { dest: b }]); + assert_eq!(it.transition, Transition::Push { direction: Direction::Top }); + assert_eq!(it.easing, Easing::Linear); + // Overlay action with settings; camelCase accepted. + s.execute( + "proto.interaction.set", + &json!({"id": a, "index": 0, "action": {"type": "openOverlay", "dest": b, "position": "bottomCenter", "closeOnOutside": true, "background": "#00000040"}}), + ) + .unwrap(); + let it = s.doc().unwrap().node(a).unwrap().interactions[0].clone(); + let Action::OpenOverlay { position, close_on_outside, background, .. } = &it.actions[0] else { panic!("{it:?}") }; + assert_eq!(*position, OverlayPosition::BottomCenter); + assert!(*close_on_outside); + assert!(background.is_some()); + // Whole replacement round-trips through serde. + let mut whole = it.clone(); + whole.trigger = Trigger::AfterDelay { ms: 800 }; + s.execute("proto.interaction.set", &json!({"id": a, "index": 0, "interaction": whole})).unwrap(); + assert_eq!(s.doc().unwrap().node(a).unwrap().interactions[0].trigger, Trigger::AfterDelay { ms: 800 }); + // Errors leave the document untouched. + assert!(s.execute("proto.interaction.set", &json!({"id": a, "index": 9, "easing": "linear"})).is_err()); + assert!(s.execute("proto.interaction.set", &json!({"id": a, "index": 0, "action": {"type": "navigate", "dest": 99999}})).is_err()); + assert!(s.execute("proto.interaction.add", &json!({"id": a, "action": {"type": "navigate", "dest": 99999}})).is_err()); + assert!(s.execute("proto.interaction.set", &json!({"id": a, "index": 0, "actions": []})).is_err()); + assert!(s.execute("proto.interaction.remove", &json!({"id": a, "index": -1})).is_err()); + assert_eq!(s.doc().unwrap().node(a).unwrap().interactions.len(), 2); + s.execute("proto.interaction.remove", &json!({"id": a, "index": 0})).unwrap(); + assert_eq!(s.doc().unwrap().node(a).unwrap().interactions.len(), 1); + assert!(s.undo().unwrap()); + assert_eq!(s.doc().unwrap().node(a).unwrap().interactions.len(), 2); + } + + #[test] + fn flows_add_rename_remove() { + let mut s = Session::new(); + let a = frame(&mut s, 0.0); + let b = frame(&mut s, 200.0); + let r = s.execute("proto.flow.add", &json!({"start": a})).unwrap(); + assert_eq!(r["name"], "Flow 1"); + // Adding again keeps the existing flow. + s.execute("proto.flow.add", &json!({"start": a, "name": "Other"})).unwrap(); + assert_eq!(page_flows(&s).len(), 1); + s.execute("proto.flow.add", &json!({"start": b})).unwrap(); + s.execute("proto.flow.rename", &json!({"start": b, "name": "Checkout", "description": "Pay"})).unwrap(); + let flows = page_flows(&s); + assert_eq!(flows.iter().map(|f| f.name.as_str()).collect::>(), ["Flow 1", "Checkout"]); + assert_eq!(flows[1].description, "Pay"); + assert!(s.execute("proto.flow.rename", &json!({"start": b, "name": " "})).is_err()); + assert!(s.execute("proto.flow.rename", &json!({"start": 4242, "name": "x"})).is_err()); + // Only top-level frames start flows. + let r = s.execute("node.create", &json!({"type": "rectangle", "parent": a, "x": 5, "y": 5, "width": 10, "height": 10})).unwrap(); + let child = id_of(&r["id"]).unwrap(); + assert!(s.execute("proto.flow.add", &json!({"start": child})).is_err()); + s.execute("proto.flow.remove", &json!({"start": a})).unwrap(); + assert_eq!(page_flows(&s).len(), 1); + // A new flow gets an unused default name. + let r = s.execute("proto.flow.add", &json!({"start": a})).unwrap(); + assert_eq!(r["name"], "Flow 2"); + } + + #[test] + fn scroll_props_via_node_set() { + let mut s = Session::new(); + let a = frame(&mut s, 0.0); + s.execute("node.set", &json!({"id": a, "props": {"overflow": "vertical", "scrollPosition": "fixed"}})).unwrap(); + let n = s.doc().unwrap().node(a).unwrap().clone(); + assert_eq!(n.overflow, Overflow::Vertical); + assert_eq!(n.scroll_position, ScrollPosition::Fixed); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index bf5a220..c6f6c88 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -192,6 +192,9 @@ fn request_render(app: &mut FigApp, worker: &mut Worker, ppp: f32) { } fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) { + if crate::proto_canvas::input(app, ui, resp) { + return; + } let ctx = ui.ctx().clone(); let (mods, scroll, zoom_delta, hover_pos, space) = ctx.input(|i| (i.modifiers, i.smooth_scroll_delta, i.zoom_delta(), i.pointer.hover_pos(), i.key_down(egui::Key::Space))); @@ -472,29 +475,7 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) { } } // Prototype connections. - if app.ui.right_tab == crate::RightTab::Prototype { - for id in doc.subtree(page) { - let Some(n) = doc.node(id) else { continue }; - for it in &n.interactions { - for a in &it.actions { - let Some(dest) = a.dest() else { continue }; - let (Some(from), Some(to)) = (doc.world_bounds(id), doc.world_bounds(dest)) else { continue }; - let a0 = app.to_screen(from.x1, from.center().y); - let b0 = app.to_screen(to.x0, to.center().y); - let mid = (b0.x - a0.x).abs().max(60.0) * 0.5; - let curve = egui::epaint::CubicBezierShape::from_points_stroke( - [a0, a0 + vec2(mid, 0.0), b0 - vec2(mid, 0.0), b0], - false, - Color32::TRANSPARENT, - stroke(ACCENT, 1.5), - ); - painter.add(curve); - painter.circle_filled(a0, 4.0, ACCENT); - painter.add(Shape::convex_polygon(vec![b0, b0 + vec2(-7.0, -4.0), b0 + vec2(-7.0, 4.0)], ACCENT, Stroke::NONE)); - } - } - } - } + crate::proto_canvas::draw(app, painter); // Hover outline. if let Some(h) = ds.hover && !sel.contains(&h) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index eec32a3..23ac6e4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -18,6 +18,7 @@ pub mod icons; pub mod layers; pub mod menus; pub mod present; +pub mod proto_canvas; pub mod proto_panel; pub mod render_worker; pub mod right; diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index b0e4f50..56aaa43 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -1,249 +1,1434 @@ -//! The prototype player: presents a frame full-window, follows interactions with transitions. +//! The prototype player: presents a frame full-window and follows its interactions. +//! +//! The player works on its own copy of the document (interactive components, variables and scroll +//! offsets change it without touching the user's file). Every frame is rendered through +//! `figcraft_render::render_node` into a texture. Smart animate interpolates a temporary document +//! between the outgoing and incoming states ([`smart_interpolate`]) and renders it each animation +//! frame; the other transitions move or fade the two cached textures ([`transition_frames`]). -use egui::{Align2, Color32, CornerRadius, Rect, Sense, Ui, pos2, vec2}; -use figcraft_doc::proto::{Action, Transition, Trigger}; -use figcraft_doc::{NodeId, NodeKind}; +use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; + +use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2}; +use figcraft_color::{Color, Paint, PaintKind}; +use figcraft_doc::proto::{Action, Direction, Easing, Overflow, OverlayPosition, ScrollPosition, Transition, Trigger}; +use figcraft_doc::{Document, Node, NodeId, NodeKind, VariableValue}; +use figcraft_geom::{Affine, Point}; use crate::FigApp; -use crate::theme::{regular, semibold}; +use crate::theme::{ACCENT, regular, semibold}; + +const BAR_H: f32 = 40.0; + +/// A page in the player's history (what Back returns to, and how). +#[derive(Clone, Debug)] +pub struct Visit { + pub frame: NodeId, + pub transition: Transition, + pub dur: f64, + pub easing: Easing, +} + +/// An open overlay. +#[derive(Clone, Debug)] +pub struct OverlayState { + pub id: NodeId, + pub position: OverlayPosition, + pub close_on_outside: bool, + pub background: Option, + /// Screen rect of the layer that opened it (manual position). + pub anchor: Option, + opened: f64, + transition: Transition, + dur: f64, + easing: Easing, +} + +#[derive(Clone)] +struct FrameTex { + key: (u64, u64), + tex: egui::TextureHandle, + /// Page-space rect the texture covers. + bounds: figcraft_geom::Rect, +} + +/// A transition in progress from a captured state to the current one. +struct Anim { + from_doc: Document, + from_frame: NodeId, + from_tex: Option, + transition: Transition, + start: f64, + dur: f64, + easing: Easing, +} + +struct ScrollAnim { + frame: NodeId, + from: (f64, f64), + to: (f64, f64), + start: f64, + dur: f64, + easing: Easing, +} + +#[derive(Clone)] +struct Snapshot { + current: NodeId, + doc: Document, + overlays: Vec, + history: Vec, + scroll: HashMap, +} + +/// A "while hovering / while pressing" state that reverts when the pointer leaves / lifts. +struct Temp { + rect: Rect, + press: bool, + snap: Snapshot, + transition: Transition, + dur: f64, + easing: Easing, +} pub struct Presenter { pub current: NodeId, - pub history: Vec, - /// Transition in progress: (from, kind, started at, duration s). - pub anim: Option<(NodeId, Transition, f64, f64, figcraft_doc::proto::Easing)>, - pub overlay: Option, - texture: Option<(NodeId, egui::TextureHandle, f64)>, - from_texture: Option, - pub delay_started: f64, + pub history: Vec, + pub overlays: Vec, + /// Scroll offsets of frames with overflow (page units). + pub scroll: HashMap, + /// Milliseconds the last smart-animate frame took to build and render. + pub render_ms: f64, + start: NodeId, + original: Option, + doc: Option, + rev: u64, + display: Option<(u64, Document)>, + scroll_anim: Option, + anim: Option, + anim_tex: Option, + temp: Option, + textures: HashMap, + arrived: f64, + delays_fired: HashSet<(NodeId, usize)>, + hovered: Vec, + press: Option, + dragged: bool, + flash_until: f64, + urls: Vec, } impl Presenter { pub fn new(start: NodeId) -> Self { - Self { current: start, history: Vec::new(), anim: None, overlay: None, texture: None, from_texture: None, delay_started: -1.0 } + Self { + current: start, + history: Vec::new(), + overlays: Vec::new(), + scroll: HashMap::new(), + render_ms: 0.0, + start, + original: None, + doc: None, + rev: 0, + display: None, + scroll_anim: None, + anim: None, + anim_tex: None, + temp: None, + textures: HashMap::new(), + arrived: -1.0, + delays_fired: HashSet::new(), + hovered: Vec::new(), + press: None, + dragged: false, + flash_until: 0.0, + urls: Vec::new(), + } + } + + /// The player's document with scroll offsets applied (cached per revision). + fn display(&mut self) -> Option { + if let Some((r, d)) = &self.display + && *r == self.rev + { + return Some(d.clone()); + } + let d = apply_scroll(self.doc.as_ref()?, &self.scroll); + self.display = Some((self.rev, d.clone())); + Some(d) + } + + fn snapshot(&self) -> Option { + Some(Snapshot { + current: self.current, + doc: self.doc.clone()?, + overlays: self.overlays.clone(), + history: self.history.clone(), + scroll: self.scroll.clone(), + }) + } + + /// Start a transition from the state as it is now; call before changing the state. + fn begin(&mut self, transition: Transition, dur: f64, easing: Easing, now: f64) { + let Some(from_doc) = self.display() else { return }; + let from_tex = self.textures.get(&self.current).cloned(); + self.anim = if matches!(transition, Transition::Instant) || dur <= 0.0 { + None + } else { + Some(Anim { from_doc, from_frame: self.current, from_tex, transition, start: now, dur, easing }) + }; + } + + fn arrive(&mut self, now: f64) { + self.arrived = now; + self.delays_fired.clear(); + self.hovered.clear(); + self.rev += 1; + } + + fn go(&mut self, frame: NodeId, transition: Transition, dur: f64, easing: Easing, now: f64, remember: bool) { + self.begin(transition, dur, easing, now); + if remember { + self.history.push(Visit { frame: self.current, transition, dur, easing }); + } + self.overlays.clear(); + self.scroll.clear(); + self.scroll_anim = None; + self.temp = None; + self.current = frame; + self.arrive(now); + } + + fn restart(&mut self, start: NodeId, now: f64) { + self.start = start; + self.current = start; + self.history.clear(); + self.overlays.clear(); + self.scroll.clear(); + self.scroll_anim = None; + self.anim = None; + self.temp = None; + self.doc = self.original.clone(); + self.arrive(now); + } + + fn revert(&mut self, now: f64) { + let Some(t) = self.temp.take() else { return }; + self.begin(t.transition.reversed(), t.dur, t.easing, now); + self.current = t.snap.current; + self.doc = Some(t.snap.doc); + self.overlays = t.snap.overlays; + self.history = t.snap.history; + self.scroll = t.snap.scroll; + self.hovered.clear(); + self.rev += 1; + } + + /// Run the actions of interaction `k` on `node`. `rect` is the layer's screen rect. + fn fire(&mut self, node: NodeId, k: usize, rect: Option, now: f64) -> bool { + let Some(it) = self.doc.as_ref().and_then(|d| d.node(node)).and_then(|n| n.interactions.get(k)).cloned() else { return false }; + let dur = f64::from(it.duration_ms) / 1000.0; + let temp = match it.trigger { + Trigger::OnHover => Some(false), + Trigger::OnPress => Some(true), + _ => None, + }; + let snap = if temp.is_some() && self.temp.is_none() { self.snapshot() } else { None }; + let mut did = false; + for a in &it.actions { + did |= self.perform(a, node, it.transition, dur, it.easing, rect, now); + } + if did && let (Some(press), Some(snap), Some(rect)) = (temp, snap, rect) { + self.temp = Some(Temp { rect, press, snap, transition: it.transition, dur, easing: it.easing }); + } + did + } + + fn perform(&mut self, a: &Action, node: NodeId, tr: Transition, dur: f64, easing: Easing, rect: Option, now: f64) -> bool { + let Some(doc) = self.doc.clone() else { return false }; + match a { + Action::Navigate { dest } => { + let top = doc.top_level_frame(*dest).unwrap_or(*dest); + if !doc.contains(top) || (top == self.current && self.overlays.is_empty()) { + return false; + } + self.go(top, tr, dur, easing, now, true); + true + } + Action::Back => { + if !self.overlays.is_empty() { + self.overlays.pop(); + return true; + } + match self.history.pop() { + Some(v) => { + self.go(v.frame, v.transition.reversed(), v.dur, v.easing, now, false); + true + } + None => false, + } + } + Action::ScrollTo { dest } => { + let Some(frame) = doc.ancestors(*dest).into_iter().find(|a| doc.node(*a).is_some_and(|n| n.overflow != Overflow::None)) else { + return false; + }; + let (Some(dn), Some(fnode)) = (doc.node(*dest), doc.node(frame)) else { return false }; + let rel = doc.world_transform(frame).inverse() * doc.world_transform(*dest); + let b = figcraft_geom::transform_rect_bbox(rel, dn.local_rect()); + let (rx, ry) = scroll_range(&doc, frame); + let hx = matches!(fnode.overflow, Overflow::Horizontal | Overflow::Both); + let vy = matches!(fnode.overflow, Overflow::Vertical | Overflow::Both); + let from = self.scroll.get(&frame).copied().unwrap_or((0.0, 0.0)); + let to = (if hx { b.x0.clamp(0.0, rx) } else { from.0 }, if vy { b.y0.clamp(0.0, ry) } else { from.1 }); + if matches!(tr, Transition::Instant) || dur <= 0.0 { + self.scroll.insert(frame, to); + self.rev += 1; + } else { + self.scroll_anim = Some(ScrollAnim { frame, from, to, start: now, dur, easing }); + } + true + } + Action::OpenOverlay { dest, position, close_on_outside, background } => { + if !doc.contains(*dest) { + return false; + } + self.overlays.push(OverlayState { + id: *dest, + position: *position, + close_on_outside: *close_on_outside, + background: *background, + anchor: rect, + opened: now, + transition: tr, + dur, + easing, + }); + self.hovered.clear(); + true + } + Action::SwapOverlay { dest } => { + if !doc.contains(*dest) { + return false; + } + match self.overlays.last_mut() { + Some(o) => { + o.id = *dest; + o.opened = now; + o.transition = tr; + o.dur = dur; + o.easing = easing; + } + None => self.overlays.push(OverlayState { + id: *dest, + position: OverlayPosition::Center, + close_on_outside: true, + background: None, + anchor: rect, + opened: now, + transition: tr, + dur, + easing, + }), + } + self.hovered.clear(); + true + } + Action::CloseOverlay => self.overlays.pop().is_some(), + Action::OpenUrl { url } => { + if !url.trim().is_empty() { + self.urls.push(url.clone()); + } + true + } + Action::ChangeTo { dest } => { + let inst = if matches!(doc.node(node).map(|n| &n.kind), Some(NodeKind::Instance { .. })) { + Some(node) + } else { + figcraft_engine::components::owning_instance(&doc, node) + }; + let Some(inst) = inst else { return false }; + if !doc.contains(*dest) { + return false; + } + self.begin(tr, dur, easing, now); + let Some(d) = self.doc.as_mut() else { return false }; + if let Some(n) = d.node_mut(inst) + && let NodeKind::Instance { component, .. } = &mut n.kind + { + *component = *dest; + } + figcraft_engine::components::sync_instance(d, inst); + self.rev += 1; + true + } + Action::SetVariable { variable, value } => { + let Some(d) = self.doc.as_mut() else { return false }; + let Some(var) = d.variables.get(variable).cloned() else { return false }; + let Some(v) = variable_value(&var.values, value) else { return false }; + if let Some(slot) = d.variables.variables.iter_mut().find(|x| x.id == var.id) { + for val in slot.values.values_mut() { + *val = v.clone(); + } + } + figcraft_engine::cmd::styles::apply_bindings(d); + self.rev += 1; + true + } + } + } + + fn texture(&mut self, ctx: &egui::Context, doc: &Document, id: NodeId, px_scale: f64) -> Option { + let key = (self.rev, px_scale.to_bits()); + if let Some(t) = self.textures.get(&id) + && t.key == key + { + return Some(t.clone()); + } + let bounds = figcraft_render::export_bounds(doc, id)?; + let pix = figcraft_render::render_node(doc, id, px_scale)?; + let img = egui::ColorImage::from_rgba_premultiplied([pix.width() as usize, pix.height() as usize], pix.data()); + let tex = ctx.load_texture(format!("present-{}", id.0), img, egui::TextureOptions::LINEAR); + let ft = FrameTex { key, tex, bounds }; + self.textures.insert(id, ft.clone()); + Some(ft) } } -fn frame_texture(app: &FigApp, ctx: &egui::Context, id: NodeId, scale: f64) -> Option { - let doc = app.session.doc().ok()?; - let pix = figcraft_render::render_node(doc, id, scale)?; - let img = egui::ColorImage::from_rgba_premultiplied([pix.width() as usize, pix.height() as usize], pix.data()); - Some(ctx.load_texture(format!("present-{}", id.0), img, egui::TextureOptions::LINEAR)) +/// Page → screen mapping for one presented frame. +#[derive(Clone, Copy, Debug)] +pub struct Place { + pub page_min: (f64, f64), + pub origin: Pos2, + pub scale: f64, } -fn navigate(p: &mut Presenter, dest: NodeId, tr: Transition, dur: f64, easing: figcraft_doc::proto::Easing, now: f64) { - if dest == p.current { - return; +impl Place { + fn centered(fb: figcraft_geom::Rect, center: Pos2, scale: f64) -> Self { + let size = vec2((fb.width() * scale) as f32, (fb.height() * scale) as f32); + Place { page_min: (fb.x0, fb.y0), origin: center - size / 2.0, scale } } - p.history.push(p.current); - p.anim = if matches!(tr, Transition::Instant) { None } else { Some((p.current, tr, now, dur.max(0.01), easing)) }; - p.from_texture = p.texture.take().map(|(_, t, _)| t); - p.current = dest; - p.delay_started = now; + pub fn pt(&self, x: f64, y: f64) -> Pos2 { + pos2(self.origin.x + ((x - self.page_min.0) * self.scale) as f32, self.origin.y + ((y - self.page_min.1) * self.scale) as f32) + } + pub fn rect(&self, r: figcraft_geom::Rect) -> Rect { + Rect::from_min_max(self.pt(r.x0, r.y0), self.pt(r.x1, r.y1)) + } +} + +// --------------------------------------------------------------------------------------------- +// Pure prototype logic (tested below). + +/// Layer names from below `root` down to `id`, joined (the smart-animate matching key). +pub fn name_path(doc: &Document, root: NodeId, id: NodeId) -> Option { + let mut parts: Vec<&str> = Vec::new(); + let mut cur = id; + for _ in 0..1024 { + if cur == root { + parts.reverse(); + return Some(parts.join("\u{1f}")); + } + let n = doc.node(cur)?; + parts.push(n.name.as_str()); + cur = n.parent?; + } + None +} + +/// Smart-animate pairs `(from layer, to layer)`: same name path below the two roots; repeated +/// paths pair up in layer order. +pub fn match_layers(from: &Document, from_root: NodeId, to: &Document, to_root: NodeId) -> Vec<(NodeId, NodeId)> { + let mut pool: HashMap> = HashMap::new(); + for id in from.subtree(from_root).into_iter().skip(1) { + if let Some(p) = name_path(from, from_root, id) { + pool.entry(p).or_default().push_back(id); + } + } + let mut out = Vec::new(); + for id in to.subtree(to_root).into_iter().skip(1) { + if let Some(p) = name_path(to, to_root, id) + && let Some(q) = pool.get_mut(&p) + && let Some(f) = q.pop_front() + { + out.push((f, id)); + } + } + out +} + +fn lerp(a: f64, b: f64, t: f64) -> f64 { + a + (b - a) * t +} + +/// Centre, rotation and scale of a node box `(w, h)` placed by `m`. +fn decompose(m: Affine, (w, h): (f64, f64)) -> Option<[f64; 5]> { + let [a, b, c, d, _, _] = m.as_coeffs(); + let sx = a.hypot(b); + if !sx.is_finite() || sx < 1e-9 { + return None; + } + let sy = (a * d - b * c) / sx; + let ctr = m * Point::new(w / 2.0, h / 2.0); + Some([ctr.x, ctr.y, b.atan2(a), sx, sy]) +} + +/// Interpolate two placements of a box: centre, rotation (shortest way round) and scale; the box +/// size is `size` (already interpolated). +pub fn lerp_transform(a: Affine, sa: (f64, f64), b: Affine, sb: (f64, f64), size: (f64, f64), t: f64) -> Affine { + match (decompose(a, sa), decompose(b, sb)) { + (Some(x), Some(y)) if (x[4] < 0.0) == (y[4] < 0.0) => { + let mut da = y[2] - x[2]; + while da > std::f64::consts::PI { + da -= std::f64::consts::TAU; + } + while da < -std::f64::consts::PI { + da += std::f64::consts::TAU; + } + let (cx, cy) = (lerp(x[0], y[0], t), lerp(x[1], y[1], t)); + let ang = x[2] + da * t; + let (sx, sy) = (lerp(x[3], y[3], t), lerp(x[4], y[4], t)); + Affine::translate((cx, cy)) * Affine::rotate(ang) * Affine::scale_non_uniform(sx, sy) * Affine::translate((-size.0 / 2.0, -size.1 / 2.0)) + } + _ => { + if t < 0.5 { + a + } else { + b + } + } + } +} + +fn lerp_paints(from: &[Paint], to: &mut [Paint], t: f64) { + for (f, p) in from.iter().zip(to.iter_mut()) { + if let (PaintKind::Solid { color: fc }, PaintKind::Solid { color: tc }) = (&f.kind, &mut p.kind) { + *tc = fc.lerp(*tc, t.clamp(0.0, 1.0) as f32); + p.opacity = lerp(f64::from(f.opacity), f64::from(p.opacity), t.clamp(0.0, 1.0)) as f32; + } + } +} + +/// The document as it looks at eased progress `t` of a smart-animate transition from +/// `from_root` (in `from`) to `to_root` (in `to`): matched layers tween position, size, rotation, +/// opacity, corner radius and solid fill colours; layers only in the destination fade in and +/// layers only in the source are copied in and fade out. Render `to_root` of the result. +pub fn smart_interpolate(from: &Document, from_root: NodeId, to: &Document, to_root: NodeId, t: f64) -> Document { + let mut out = to.clone(); + if !t.is_finite() || t >= 1.0 { + return out; + } + let t = t.max(-1.0); + let fade = t.clamp(0.0, 1.0); + let pairs = match_layers(from, from_root, to, to_root); + let src_of: HashMap = pairs.iter().map(|(f, d)| (*d, *f)).collect(); + let dst_of: HashMap = pairs.iter().copied().collect(); + let fw_inv = from.world_transform(from_root).inverse(); + let tw_inv = to.world_transform(to_root).inverse(); + if let (Some(fr), Some(m)) = (from.node(from_root), out.node_mut(to_root)) { + lerp_paints(&fr.fills, &mut m.fills, t); + } + let mut interp: HashMap = HashMap::new(); + interp.insert(to_root, Affine::IDENTITY); + let mut faded: HashSet = HashSet::new(); + for id in to.subtree(to_root).into_iter().skip(1) { + let Some(n) = to.node(id) else { continue }; + let parent = n.parent.unwrap_or(to_root); + let p_interp = interp.get(&parent).copied().unwrap_or(Affine::IDENTITY); + match src_of.get(&id).and_then(|s| from.node(*s).map(|sn| (*s, sn))) { + Some((s, sn)) => { + let rel_s = fw_inv * from.world_transform(s); + let rel_d = tw_inv * to.world_transform(id); + let (w, h) = (lerp(sn.width, n.width, t).max(0.0), lerp(sn.height, n.height, t).max(0.0)); + let xf = lerp_transform(rel_s, (sn.width, sn.height), rel_d, (n.width, n.height), (w, h), t); + interp.insert(id, xf); + if let Some(m) = out.node_mut(id) { + m.width = w; + m.height = h; + m.transform = p_interp.inverse() * xf; + m.opacity = lerp(f64::from(sn.opacity), f64::from(n.opacity), fade) as f32; + for (r, (a, b)) in m.corners.radii.iter_mut().zip(sn.corners.radii.iter().zip(n.corners.radii.iter())) { + *r = lerp(*a, *b, t).max(0.0); + } + lerp_paints(&sn.fills, &mut m.fills, t); + lerp_paints(&sn.strokes, &mut m.strokes, t); + } + } + None => { + interp.insert(id, p_interp * n.transform); + if !faded.contains(&parent) + && let Some(m) = out.node_mut(id) + { + m.opacity *= fade as f32; + } + faded.insert(id); + } + } + } + // Layers only in the source: copy the top-most ones in, fading out. + let tops: Vec = from + .subtree(from_root) + .into_iter() + .skip(1) + .filter(|id| !dst_of.contains_key(id)) + .filter(|id| from.node(*id).and_then(|n| n.parent).is_some_and(|p| p == from_root || dst_of.contains_key(&p))) + .collect(); + for id in tops { + let Some(n) = from.node(id) else { continue }; + let pf = n.parent.unwrap_or(from_root); + let matched_parent = dst_of.get(&pf).copied().filter(|p| out.node(*p).is_some_and(|pn| pn.kind.is_container())); + let (parent, local) = match matched_parent { + Some(p) if pf != from_root => (p, n.transform), + _ => (to_root, fw_inv * from.world_transform(id)), + }; + let nodes: Vec = from.subtree(id).into_iter().filter_map(|i| from.node(i).cloned()).collect(); + let mut map = BTreeMap::new(); + for nn in &nodes { + map.insert(nn.id, out.alloc_id()); + } + let index = from.index_in_parent(id); + if let Ok(new) = out.paste_nodes(&nodes, &map, id, parent, index) + && let Some(m) = out.node_mut(new) + { + m.transform = local; + m.opacity *= (1.0 - fade) as f32; + } + } + out +} + +/// How far `frame`'s content can scroll `(x, y)` (0 when it doesn't overflow or doesn't scroll). +pub fn scroll_range(doc: &Document, frame: NodeId) -> (f64, f64) { + let Some(n) = doc.node(frame) else { return (0.0, 0.0) }; + if n.overflow == Overflow::None { + return (0.0, 0.0); + } + let (mut x1, mut y1) = (0.0f64, 0.0f64); + for c in &n.children { + let Some(cn) = doc.node(*c) else { continue }; + if !cn.visible || cn.scroll_position == ScrollPosition::Fixed { + continue; + } + let b = figcraft_geom::transform_rect_bbox(cn.transform, cn.local_rect()); + x1 = x1.max(b.x1); + y1 = y1.max(b.y1); + } + if let Some(l) = &n.layout { + x1 += l.padding[1]; + y1 += l.padding[2]; + } + let rx = if matches!(n.overflow, Overflow::Horizontal | Overflow::Both) { (x1 - n.width).max(0.0) } else { 0.0 }; + let ry = if matches!(n.overflow, Overflow::Vertical | Overflow::Both) { (y1 - n.height).max(0.0) } else { 0.0 }; + (if rx.is_finite() { rx } else { 0.0 }, if ry.is_finite() { ry } else { 0.0 }) +} + +/// Shift scrolling frames' children by their scroll offsets. Fixed children stay put; sticky +/// children scroll until they reach the frame's top/left edge, then stay there. +pub fn apply_scroll(doc: &Document, scroll: &HashMap) -> Document { + let mut out = doc.clone(); + let sticky = |pos: f64, s: f64| if pos >= 0.0 && pos - s < 0.0 { -pos } else { -s }; + for (&frame, &(sx, sy)) in scroll { + if !sx.is_finite() || !sy.is_finite() || (sx == 0.0 && sy == 0.0) { + continue; + } + let Some(n) = doc.node(frame) else { continue }; + for &c in &n.children { + let Some(cn) = doc.node(c) else { continue }; + let (dx, dy) = match cn.scroll_position { + ScrollPosition::Fixed => continue, + ScrollPosition::Scrolls => (-sx, -sy), + ScrollPosition::Sticky => { + let b = figcraft_geom::transform_rect_bbox(cn.transform, cn.local_rect()); + (sticky(b.x0, sx), sticky(b.y0, sy)) + } + }; + if let Some(m) = out.node_mut(c) { + m.transform = Affine::translate((dx, dy)) * m.transform; + } + } + } + out +} + +/// Where the outgoing and incoming frames are drawn at eased progress `t`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FramePlacement { + /// Offsets as fractions of each frame's size. + pub from_off: (f32, f32), + pub from_alpha: f32, + pub to_off: (f32, f32), + pub to_alpha: f32, + pub from_on_top: bool, +} + +/// The unit vector a direction moves along (screen space, y down). +pub fn direction_vec(d: Direction) -> (f32, f32) { + match d { + Direction::Left => (-1.0, 0.0), + Direction::Right => (1.0, 0.0), + Direction::Top => (0.0, -1.0), + Direction::Bottom => (0.0, 1.0), + } +} + +/// Frame offsets/opacities for a transition. Directions name where things move: "Move in, left" +/// brings the new frame in from the right edge moving left. +pub fn transition_frames(tr: Transition, t: f32) -> FramePlacement { + let mul = |(x, y): (f32, f32), k: f32| (x * k, y * k); + let base = FramePlacement { from_off: (0.0, 0.0), from_alpha: 1.0, to_off: (0.0, 0.0), to_alpha: 1.0, from_on_top: false }; + match tr { + Transition::Instant => FramePlacement { from_alpha: 0.0, ..base }, + Transition::Dissolve | Transition::SmartAnimate => FramePlacement { to_alpha: t.clamp(0.0, 1.0), ..base }, + Transition::MoveIn { direction } => FramePlacement { to_off: mul(direction_vec(direction), -(1.0 - t)), ..base }, + Transition::MoveOut { direction } => FramePlacement { from_off: mul(direction_vec(direction), t), from_on_top: true, ..base }, + Transition::Push { direction } => { + let d = direction_vec(direction); + FramePlacement { from_off: mul(d, t), to_off: mul(d, -(1.0 - t)), ..base } + } + Transition::SlideIn { direction } => { + let d = direction_vec(direction); + FramePlacement { from_off: mul(d, 0.3 * t), to_off: mul(d, -(1.0 - t)), ..base } + } + Transition::SlideOut { direction } => { + let d = direction_vec(direction); + FramePlacement { from_off: mul(d, t), to_off: mul(d, -0.3 * (1.0 - t)), from_on_top: true, ..base } + } + } +} + +/// Screen rect of an overlay of `size` over the frame drawn at `base`. +pub fn overlay_rect(pos: OverlayPosition, base: Rect, size: Vec2, anchor: Option) -> Rect { + let cx = base.center().x - size.x / 2.0; + let cy = base.center().y - size.y / 2.0; + let (x, y) = match pos { + OverlayPosition::Center => (cx, cy), + OverlayPosition::TopLeft => (base.min.x, base.min.y), + OverlayPosition::TopCenter => (cx, base.min.y), + OverlayPosition::TopRight => (base.max.x - size.x, base.min.y), + OverlayPosition::BottomLeft => (base.min.x, base.max.y - size.y), + OverlayPosition::BottomCenter => (cx, base.max.y - size.y), + OverlayPosition::BottomRight => (base.max.x - size.x, base.max.y - size.y), + OverlayPosition::Manual => anchor.map(|a| (a.min.x, a.max.y)).unwrap_or((cx, cy)), + }; + Rect::from_min_size(pos2(x, y), size) +} + +fn variable_value(values: &BTreeMap, v: &serde_json::Value) -> Option { + let sample = values.values().next(); + Some(match (sample, v) { + (Some(VariableValue::Color(_)), _) => VariableValue::Color(figcraft_engine::params::color_of(v)?), + (Some(VariableValue::Number(_)), _) => VariableValue::Number(figcraft_engine::params::f64_of(v)?), + (Some(VariableValue::Boolean(_)), _) => VariableValue::Boolean(match v { + serde_json::Value::Bool(b) => *b, + serde_json::Value::String(s) => s == "true", + serde_json::Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0), + _ => return None, + }), + (_, serde_json::Value::String(s)) => VariableValue::String(s.clone()), + (_, serde_json::Value::Bool(b)) => VariableValue::Boolean(*b), + (_, serde_json::Value::Number(n)) => VariableValue::Number(n.as_f64()?), + _ => return None, + }) +} + +/// Layers with interactions under `root`, with their (clipped) screen rects, bottom-most first. +fn hotspots(doc: &Document, root: NodeId, place: &Place) -> Vec<(NodeId, Rect)> { + let mut out = Vec::new(); + let mut hidden: HashSet = HashSet::new(); + for id in doc.subtree(root) { + let Some(n) = doc.node(id) else { continue }; + if !n.visible || n.parent.is_some_and(|p| hidden.contains(&p)) { + hidden.insert(id); + continue; + } + if n.interactions.is_empty() { + continue; + } + let Some(b) = doc.world_bounds(id) else { continue }; + let mut r = place.rect(b); + let mut cur = n.parent; + if id != root { + while let Some(a) = cur { + if let Some(an) = doc.node(a) + && an.clip + && an.kind.is_frame_like() + && let Some(ab) = doc.world_bounds(a) + { + r = r.intersect(place.rect(ab)); + } + if a == root { + break; + } + cur = doc.node(a).and_then(|x| x.parent); + } + } + if r.is_positive() { + out.push((id, r)); + } + } + out +} + +/// The first interaction index on `id` whose trigger satisfies `want`. +fn find_trigger(doc: &Document, id: NodeId, want: impl Fn(&Trigger) -> bool) -> Option { + doc.node(id)?.interactions.iter().position(|it| want(&it.trigger)) +} + +// --------------------------------------------------------------------------------------------- +// The player UI. + +struct Layout { + disp: Document, + scale: f64, + place: Place, + target: Rect, + overlays: Vec<(NodeId, Place, Rect)>, +} + +fn layout(p: &mut Presenter, area: Rect) -> Option { + let disp = p.display()?; + let fb = disp.world_bounds(p.current)?; + let avail = area.shrink(32.0); + let scale = (f64::from(avail.width()) / fb.width().max(1.0)).min(f64::from(avail.height()) / fb.height().max(1.0)).clamp(0.01, 1.0); + let place = Place::centered(fb, area.center(), scale); + let target = place.rect(fb); + let mut overlays = Vec::new(); + for o in &p.overlays { + let Some(ob) = disp.world_bounds(o.id) else { continue }; + let size = vec2((ob.width() * scale) as f32, (ob.height() * scale) as f32); + let r = overlay_rect(o.position, target, size, o.anchor); + overlays.push((o.id, Place { page_min: (ob.x0, ob.y0), origin: r.min, scale }, r)); + } + Some(Layout { disp, scale, place, target, overlays }) } pub fn show(app: &mut FigApp, ui: &mut Ui) { let ctx = ui.ctx().clone(); let now = ctx.input(|i| i.time); let Some(mut pres) = app.ui.present.take() else { return }; - let bg = app - .session - .ds() - .ok() - .and_then(|ds| match ds.doc.node(ds.page).map(|n| &n.kind) { - Some(NodeKind::Page { prototype, .. }) => Some(crate::theme::c32(prototype.background)), + if pres.doc.is_none() { + let doc = app.session.doc().ok().cloned(); + pres.original = doc.clone(); + pres.doc = doc; + pres.arrived = now; + } + let bg = pres + .doc + .as_ref() + .and_then(|d| d.page_of(pres.current).and_then(|pg| d.node(pg))) + .and_then(|n| match &n.kind { + NodeKind::Page { prototype, .. } => Some(crate::theme::c32(prototype.background)), _ => None, }) .unwrap_or(Color32::from_rgb(0x1E, 0x1E, 0x1E)); let mut exit = false; egui::CentralPanel::default().frame(egui::Frame::NONE.fill(bg)).show(ui, |ui| { - let rect = ui.max_rect(); - let Ok(doc) = app.session.doc().cloned() else { return }; - let Some(frame) = doc.node(pres.current).cloned() else { - exit = true; - return; - }; - // Fit the frame (max 1:1 like Figma's "fit" for small frames). - let avail = rect.shrink(48.0); - let scale = (f64::from(avail.width()) / frame.width.max(1.0)).min(f64::from(avail.height()) / frame.height.max(1.0)).min(1.0); - let ppp = f64::from(ctx.pixels_per_point()); - let need = pres.texture.as_ref().is_none_or(|(id, _, s)| *id != pres.current || (*s - scale * ppp).abs() > 1e-6); - if need && let Some(tex) = frame_texture(app, &ctx, pres.current, scale * ppp) { - pres.texture = Some((pres.current, tex, scale * ppp)); - } - let size = vec2((frame.width * scale) as f32, (frame.height * scale) as f32); - let target = Rect::from_center_size(rect.center(), size); - let mut t_anim = 1.0; - if let Some((_, tr, start, dur, easing)) = pres.anim { - let raw = ((now - start) / dur).clamp(0.0, 1.0); - t_anim = easing.eval(raw) as f32; - if raw >= 1.0 { - pres.anim = None; - pres.from_texture = None; - } else { - ctx.request_repaint(); - } - let _ = tr; - } - let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); - let anim = pres.anim.as_ref().map(|a| a.1); - match (anim, &pres.from_texture, &pres.texture) { - (Some(tr), Some(from), Some((_, to, _))) => { - let w = target.width(); - match tr { - Transition::Dissolve | Transition::SmartAnimate => { - ui.painter().image(from.id(), target, uv, Color32::WHITE.gamma_multiply(1.0 - t_anim)); - ui.painter().image(to.id(), target, uv, Color32::WHITE.gamma_multiply(t_anim)); - } - Transition::Push { .. } | Transition::SlideIn { .. } | Transition::MoveIn { .. } => { - let dx = w * (1.0 - t_anim); - let painter = ui.painter_at(target); - if matches!(tr, Transition::Push { .. }) { - painter.image(from.id(), target.translate(vec2(dx - w, 0.0)), uv, Color32::WHITE); - } else { - painter.image(from.id(), target, uv, Color32::WHITE); - } - painter.image(to.id(), target.translate(vec2(dx, 0.0)), uv, Color32::WHITE); - } - _ => { - ui.painter().image(to.id(), target, uv, Color32::WHITE); - } - } - } - (_, _, Some((_, tex, _))) => { - ui.painter().image(tex.id(), target, uv, Color32::WHITE); - } - _ => {} - } - // Hotspots: clickable layers with interactions inside the frame. - let fw = doc.world_transform(pres.current); - let inv = fw.inverse(); - let mut hot: Vec<(NodeId, Rect)> = Vec::new(); - for id in doc.subtree(pres.current) { - let Some(n) = doc.node(id) else { continue }; - if n.interactions.is_empty() || !n.visible { - continue; - } - if let Some(b) = doc.world_bounds(id) { - let a = inv * figcraft_geom::Point::new(b.x0, b.y0); - let c = inv * figcraft_geom::Point::new(b.x1, b.y1); - let r = Rect::from_min_max( - pos2(target.min.x + (a.x * scale) as f32, target.min.y + (a.y * scale) as f32), - pos2(target.min.x + (c.x * scale) as f32, target.min.y + (c.y * scale) as f32), - ); - hot.push((id, r)); - } - } - let click = ui.interact(rect, ui.id().with("present_click"), Sense::click()); - let pointer = ctx.input(|i| i.pointer.hover_pos()); - let mut fired = false; - for (id, r) in hot.iter().rev() { - let Some(n) = doc.node(*id) else { continue }; - let over = pointer.is_some_and(|p| r.contains(p)); - if over { - ctx.set_cursor_icon(egui::CursorIcon::PointingHand); - } - for it in &n.interactions { - let trigger_hit = match it.trigger { - Trigger::OnClick | Trigger::MouseUp | Trigger::MouseDown | Trigger::OnPress => over && click.clicked(), - Trigger::OnHover | Trigger::MouseEnter => over, - Trigger::AfterDelay { ms } => { - *id == pres.current && pres.anim.is_none() && pres.delay_started >= 0.0 && now - pres.delay_started > f64::from(ms) / 1000.0 - } - _ => false, - }; - if !trigger_hit || fired { - continue; - } - for a in &it.actions { - match a { - Action::Navigate { dest } | Action::ChangeTo { dest } => { - let top = doc.top_level_frame(*dest).unwrap_or(*dest); - navigate(&mut pres, top, it.transition, f64::from(it.duration_ms) / 1000.0, it.easing, now); - fired = true; - } - Action::Back => { - if let Some(prev) = pres.history.pop() { - pres.from_texture = pres.texture.take().map(|(_, t, _)| t); - pres.anim = None; - pres.current = prev; - fired = true; - } - } - Action::OpenUrl { url } => { - app.services.open_url(url); - fired = true; - } - Action::OpenOverlay { dest, .. } | Action::SwapOverlay { dest } => { - pres.overlay = Some(*dest); - fired = true; - } - Action::CloseOverlay => { - pres.overlay = None; - fired = true; - } - _ => {} - } - } - } - } - if !fired && click.clicked() { - // Clicking empty space flashes hotspots (Figma behaviour): outline them briefly. - for (_, r) in &hot { - ui.painter().rect_stroke( - *r, - CornerRadius::same(2), - egui::Stroke::new(2.0, crate::theme::ACCENT.gamma_multiply(0.6)), - egui::StrokeKind::Outside, - ); - } - } - // Overlay frame on top. - if let Some(ov) = pres.overlay - && let Some(on) = doc.node(ov) - && let Some(tex) = frame_texture(app, &ctx, ov, scale * ppp) - { - let osize = vec2((on.width * scale) as f32, (on.height * scale) as f32); - let orect = Rect::from_center_size(target.center(), osize); - ui.painter().rect_filled(target, CornerRadius::ZERO, Color32::from_black_alpha(80)); - ui.painter().image(tex.id(), orect, uv, Color32::WHITE); - if click.clicked() && pointer.is_some_and(|p| !orect.contains(p)) { - pres.overlay = None; - } - } - // Top bar. - let bar = Rect::from_min_size(rect.min, vec2(rect.width(), 40.0)); - ui.painter().rect_filled(bar, CornerRadius::ZERO, Color32::from_black_alpha(140)); - ui.painter().text(pos2(bar.min.x + 16.0, bar.center().y), Align2::LEFT_CENTER, &frame.name, semibold(12.0), Color32::WHITE); - ui.painter().text( - pos2(bar.max.x - 16.0, bar.center().y), - Align2::RIGHT_CENTER, - "Esc to close · ← → for frames", - regular(11.0), - Color32::from_gray(200), - ); - if pres.delay_started < 0.0 { - pres.delay_started = now; - } - ctx.request_repaint_after(std::time::Duration::from_millis(100)); - // Arrow keys step through top-level frames. - let frames = doc.top_frames(doc.page_of(pres.current).unwrap_or(pres.current)); - let idx = frames.iter().position(|f| *f == pres.current); - let (left, right) = ctx.input(|i| (i.key_pressed(egui::Key::ArrowLeft), i.key_pressed(egui::Key::ArrowRight))); - if let Some(i) = idx { - if right && let Some(n) = frames.get(i + 1) { - navigate(&mut pres, *n, Transition::Instant, 0.0, Default::default(), now); - } - if left - && i > 0 - && let Some(n) = frames.get(i - 1) - { - navigate(&mut pres, *n, Transition::Instant, 0.0, Default::default(), now); - } - } + exit = player(&mut pres, ui, &ctx, now); }); + for u in pres.urls.drain(..) { + app.services.open_url(&u); + } if exit || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { return; } app.ui.present = Some(pres); } + +/// One frame of the player; returns true to close it. +fn player(p: &mut Presenter, ui: &mut Ui, ctx: &egui::Context, now: f64) -> bool { + let rect = ui.max_rect(); + let bar = Rect::from_min_size(rect.min, vec2(rect.width(), BAR_H)); + let area = Rect::from_min_max(pos2(rect.min.x, rect.min.y + BAR_H), rect.max); + if p.doc.as_ref().is_none_or(|d| !d.contains(p.current)) { + return true; + } + // Scroll animation (Scroll to). + if let Some(sa) = &p.scroll_anim { + let raw = ((now - sa.start) / sa.dur.max(1e-3)).clamp(0.0, 1.0); + let e = sa.easing.eval(raw); + p.scroll.insert(sa.frame, (lerp(sa.from.0, sa.to.0, e), lerp(sa.from.1, sa.to.1, e))); + p.rev += 1; + if raw >= 1.0 { + p.scroll_anim = None; + } + ctx.request_repaint(); + } + let resp = ui.interact(area, ui.id().with("present_area"), Sense::click_and_drag()); + let close = top_bar(p, ui, bar, now); + if let Some(l) = layout(p, area) { + input(p, ctx, &l, resp.contains_pointer(), now); + } + let Some(l) = layout(p, area) else { return true }; + draw(p, ui, ctx, &l, area, now); + // Timers: the soonest pending "after delay". + let mut next: Option = None; + for id in l.disp.subtree(p.current) { + let Some(n) = l.disp.node(id) else { continue }; + for (k, it) in n.interactions.iter().enumerate() { + if let Trigger::AfterDelay { ms } = it.trigger + && !p.delays_fired.contains(&(id, k)) + { + let due = p.arrived + f64::from(ms) / 1000.0 - now; + next = Some(next.map_or(due, |x: f64| x.min(due))); + } + } + } + match next { + Some(d) => ctx.request_repaint_after(std::time::Duration::from_secs_f64(d.clamp(0.0, 60.0))), + None => ctx.request_repaint_after(std::time::Duration::from_millis(250)), + } + close +} + +fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64) -> bool { + let painter = ui.painter(); + painter.rect_filled(bar, CornerRadius::ZERO, Color32::from_rgb(0x2C, 0x2C, 0x2C)); + let Some(doc) = p.doc.clone() else { return true }; + let name = doc.node(p.current).map(|n| n.name.clone()).unwrap_or_default(); + let flows = match doc.page_of(p.start).and_then(|pg| doc.node(pg)).map(|n| &n.kind) { + Some(NodeKind::Page { flows, .. }) => flows.clone(), + _ => vec![], + }; + let mut close = false; + // Right: close, restart. + let mut x = bar.max.x - 8.0; + let button = |ui: &mut Ui, label: &str, id: &str, x: &mut f32| -> bool { + let galley = ui.painter().layout_no_wrap(label.to_string(), regular(11.5), Color32::WHITE); + let r = Rect::from_min_size(pos2(*x - galley.size().x - 16.0, bar.center().y - 12.0), vec2(galley.size().x + 16.0, 24.0)); + *x = r.min.x - 4.0; + let resp = ui.interact(r, ui.id().with(("pbar", id)), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(5), Color32::from_white_alpha(28)); + } + ui.painter().galley(pos2(r.min.x + 8.0, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE); + resp.clicked() + }; + if button(ui, "Close", "close", &mut x) { + close = true; + } + if button(ui, "Restart", "restart", &mut x) { + p.restart(p.start, now); + } + // Left: flow picker and the frame's name. + let mut lx = bar.min.x + 12.0; + let flow_name = flows.iter().find(|f| f.start == p.start).map(|f| f.name.clone()); + if !flows.is_empty() { + let label = format!("{} ▾", flow_name.clone().unwrap_or_else(|| "Flows".into())); + let galley = ui.painter().layout_no_wrap(label, semibold(11.5), Color32::WHITE); + let r = Rect::from_min_size(pos2(lx, bar.center().y - 12.0), vec2(galley.size().x + 16.0, 24.0)); + let resp = ui.interact(r, ui.id().with("pflows"), Sense::click()).on_hover_text("Flows"); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(5), Color32::from_white_alpha(28)); + } + ui.painter().galley(pos2(r.min.x + 8.0, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE); + egui::Popup::menu(&resp).width(200.0).show(|ui| { + for f in &flows { + if crate::right::menu_item(ui, &f.name, "") { + p.restart(f.start, now); + ui.close(); + } + } + }); + lx = r.max.x + 8.0; + ui.painter().text(pos2(lx, bar.center().y), Align2::LEFT_CENTER, "/", regular(11.5), Color32::from_gray(140)); + lx += 14.0; + } + ui.painter().text(pos2(lx, bar.center().y), Align2::LEFT_CENTER, name, regular(11.5), Color32::from_gray(220)); + close +} + +fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, now: f64) { + let (pos, pressed, released, down, wheel, keys) = ctx.input(|i| { + let keys: Vec = i + .events + .iter() + .filter_map(|e| match e { + egui::Event::Key { key, pressed: true, repeat: false, .. } => Some(*key), + _ => None, + }) + .collect(); + ( + i.pointer.hover_pos(), + i.pointer.button_pressed(egui::PointerButton::Primary), + i.pointer.button_released(egui::PointerButton::Primary), + i.pointer.primary_down(), + i.smooth_scroll_delta, + keys, + ) + }); + let pos = pos.filter(|_| over_area || p.press.is_some()); + // The active layer: the top overlay, else the frame. + let (root, place) = match l.overlays.last() { + Some((id, pl, _)) => (*id, *pl), + None => (p.current, l.place), + }; + let doc = &l.disp; + let hot = hotspots(doc, root, &place); + // "While hovering / pressing" reverts. + if let Some(tmp) = &p.temp { + let leave = if tmp.press { released || !down } else { pos.is_none_or(|q| !tmp.rect.contains(q)) }; + if leave { + p.revert(now); + return; + } + } + // Wheel scrolls the innermost scrollable frame under the pointer. + if wheel != Vec2::ZERO + && let Some(q) = pos + && let Some(base) = p.doc.clone() + { + let mut frames: Vec = doc.subtree(root).into_iter().filter(|i| doc.node(*i).is_some_and(|n| n.overflow != Overflow::None)).collect(); + frames.reverse(); + for f in frames { + let Some(b) = doc.world_bounds(f) else { continue }; + if !place.rect(b).contains(q) { + continue; + } + let (rx, ry) = scroll_range(&base, f); + let cur = p.scroll.get(&f).copied().unwrap_or((0.0, 0.0)); + let next = ((cur.0 - f64::from(wheel.x) / l.scale).clamp(0.0, rx), (cur.1 - f64::from(wheel.y) / l.scale).clamp(0.0, ry)); + if (next.0 - cur.0).abs() > 1e-6 || (next.1 - cur.1).abs() > 1e-6 { + p.scroll.insert(f, next); + p.scroll_anim = None; + p.rev += 1; + break; + } + } + } + let hits: Vec<(NodeId, Rect)> = hot.iter().rev().filter(|(_, r)| pos.is_some_and(|q| r.contains(q))).copied().collect(); + if !hits.is_empty() { + ctx.set_cursor_icon(egui::CursorIcon::PointingHand); + } + // First (top-most) hit that has a matching trigger. + let pick = |want: &dyn Fn(&Trigger) -> bool| -> Option<(NodeId, usize, Rect)> { + hits.iter().find_map(|(id, r)| find_trigger(doc, *id, want).map(|k| (*id, k, *r))) + }; + // Enter / leave. + let ids: Vec = hits.iter().map(|h| h.0).collect(); + let entered: Vec<(NodeId, Rect)> = hits.iter().filter(|(id, _)| !p.hovered.contains(id)).copied().collect(); + let left: Vec = p.hovered.iter().filter(|id| !ids.contains(id)).copied().collect(); + p.hovered = ids; + for id in left { + if let Some(k) = find_trigger(doc, id, |t| *t == Trigger::MouseLeave) + && p.fire(id, k, None, now) + { + return; + } + } + for (id, r) in &entered { + if let Some(k) = find_trigger(doc, *id, |t| *t == Trigger::MouseEnter) + && p.fire(*id, k, Some(*r), now) + { + return; + } + } + if p.temp.is_none() + && !entered.is_empty() + && let Some((id, k, r)) = pick(&|t| *t == Trigger::OnHover) + && entered.iter().any(|e| e.0 == id) + && p.fire(id, k, Some(r), now) + { + return; + } + // Press / drag / release. + if pressed && pos.is_some() && over_area { + p.press = pos; + p.dragged = false; + if let (Some((_, _, orect)), Some(o), Some(q)) = (l.overlays.last(), p.overlays.last(), pos) + && !orect.contains(q) + { + if o.close_on_outside { + p.overlays.pop(); + p.press = None; + p.hovered.clear(); + } + return; + } + if let Some((id, k, r)) = pick(&|t| *t == Trigger::MouseDown) + && p.fire(id, k, Some(r), now) + { + return; + } + if let Some((id, k, r)) = pick(&|t| *t == Trigger::OnPress) + && p.fire(id, k, Some(r), now) + { + return; + } + } + if down + && !p.dragged + && let (Some(start), Some(q)) = (p.press, pos) + && start.distance(q) > 6.0 + { + p.dragged = true; + let at_start: Vec<(NodeId, Rect)> = hot.iter().rev().filter(|(_, r)| r.contains(start)).copied().collect(); + if let Some((id, k, r)) = at_start.iter().find_map(|(id, r)| find_trigger(doc, *id, |t| *t == Trigger::OnDrag).map(|k| (*id, k, *r))) + && p.fire(id, k, Some(r), now) + { + p.press = None; + return; + } + } + if released && p.press.is_some() { + let dragged = p.dragged; + p.press = None; + p.dragged = false; + if let Some((id, k, r)) = pick(&|t| *t == Trigger::MouseUp) + && p.fire(id, k, Some(r), now) + { + return; + } + if !dragged { + if let Some((id, k, r)) = pick(&|t| *t == Trigger::OnClick) + && p.fire(id, k, Some(r), now) + { + return; + } + // Clicking where nothing reacts flashes the hotspots. + p.flash_until = now + 0.5; + } + } + // Keys. + for key in &keys { + let name = key.name(); + let found = doc.subtree(root).into_iter().find_map(|id| { + find_trigger(doc, id, |t| matches!(t, Trigger::KeyPress { key } if key.eq_ignore_ascii_case(name) || key_alias(key) == Some(name))) + .map(|k| (id, k)) + }); + if let Some((id, k)) = found { + if p.fire(id, k, None, now) { + return; + } + continue; + } + // Arrow keys step through top-level frames when nothing else uses them. + if matches!(key, egui::Key::ArrowLeft | egui::Key::ArrowRight) + && p.overlays.is_empty() + && let Some(base) = p.doc.clone() + { + let frames = base.top_frames(base.page_of(p.current).unwrap_or(p.current)); + if let Some(i) = frames.iter().position(|f| *f == p.current) { + let next = if *key == egui::Key::ArrowRight { frames.get(i + 1) } else { i.checked_sub(1).and_then(|j| frames.get(j)) }; + if let Some(n) = next { + p.go(*n, Transition::Instant, 0.0, Easing::Linear, now, true); + return; + } + } + } + } + // After delay (timers start when the frame appears; not while animating). + if p.anim.is_none() { + let mut due: Option<(NodeId, usize)> = None; + for id in doc.subtree(root) { + let Some(n) = doc.node(id) else { continue }; + for (k, it) in n.interactions.iter().enumerate() { + if let Trigger::AfterDelay { ms } = it.trigger + && !p.delays_fired.contains(&(id, k)) + && now - p.arrived >= f64::from(ms) / 1000.0 + { + due = Some((id, k)); + break; + } + } + if due.is_some() { + break; + } + } + if let Some((id, k)) = due { + p.delays_fired.insert((id, k)); + p.fire(id, k, None, now); + } + } +} + +/// Alternative spellings people type for keys. +fn key_alias(k: &str) -> Option<&'static str> { + Some(match k.to_ascii_lowercase().as_str() { + "space" | " " => "Space", + "return" | "enter" => "Enter", + "esc" | "escape" => "Escape", + "left" | "arrowleft" | "←" => "ArrowLeft", + "right" | "arrowright" | "→" => "ArrowRight", + "up" | "arrowup" | "↑" => "ArrowUp", + "down" | "arrowdown" | "↓" => "ArrowDown", + "tab" => "Tab", + "backspace" => "Backspace", + "delete" => "Delete", + _ => return None, + }) +} + +fn draw(p: &mut Presenter, ui: &mut Ui, ctx: &egui::Context, l: &Layout, area: Rect, now: f64) { + let ppp = f64::from(ctx.pixels_per_point()); + let px = l.scale * ppp; + let painter = ui.painter_at(area); + let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); + let to_tex = p.texture(ctx, &l.disp, p.current, px); + // Transition progress. + let mut drawn = false; + if let Some(a) = &p.anim { + let raw = ((now - a.start) / a.dur.max(1e-3)).clamp(0.0, 1.0); + if raw >= 1.0 { + p.anim = None; + } else { + ctx.request_repaint(); + let e = a.easing.eval(raw); + let from_place = a.from_doc.world_bounds(a.from_frame).map(|fb| Place::centered(fb, area.center(), l.scale)); + if matches!(a.transition, Transition::SmartAnimate) { + #[cfg(not(target_arch = "wasm32"))] + let t0 = std::time::Instant::now(); + let tmp = smart_interpolate(&a.from_doc, a.from_frame, &l.disp, p.current, e); + let bounds = figcraft_render::export_bounds(&tmp, p.current); + if let (Some(b), Some(pix)) = (bounds, figcraft_render::render_node(&tmp, p.current, px)) { + let img = egui::ColorImage::from_rgba_premultiplied([pix.width() as usize, pix.height() as usize], pix.data()); + let tex = match &mut p.anim_tex { + Some(t) => { + t.set(img, egui::TextureOptions::LINEAR); + t.clone() + } + None => { + let t = ctx.load_texture("present-anim", img, egui::TextureOptions::LINEAR); + p.anim_tex = Some(t.clone()); + t + } + }; + painter.with_clip_rect(l.target).image(tex.id(), l.place.rect(b), uv, Color32::WHITE); + drawn = true; + } + #[cfg(not(target_arch = "wasm32"))] + { + p.render_ms = t0.elapsed().as_secs_f64() * 1000.0; + } + } else if let (Some(ft), Some(to), Some(fp)) = (&a.from_tex, &to_tex, from_place) { + let f = transition_frames(a.transition, e as f32); + let from_rect = fp.rect(ft.bounds); + let from_frame_rect = a.from_doc.world_bounds(a.from_frame).map(|b| fp.rect(b)).unwrap_or(from_rect); + let to_rect = l.place.rect(to.bounds); + let off = |r: Rect, o: (f32, f32)| vec2(r.width() * o.0, r.height() * o.1); + let clip = + painter.with_clip_rect(l.target.union(if matches!(a.transition, Transition::Dissolve) { from_frame_rect } else { l.target })); + let draw_from = |p: &egui::Painter| { + if f.from_alpha > 0.0 { + p.image(ft.tex.id(), from_rect.translate(off(from_frame_rect, f.from_off)), uv, Color32::WHITE.gamma_multiply(f.from_alpha)); + } + }; + let draw_to = |p: &egui::Painter| { + p.image(to.tex.id(), to_rect.translate(off(l.target, f.to_off)), uv, Color32::WHITE.gamma_multiply(f.to_alpha)); + }; + if f.from_on_top { + draw_to(&clip); + draw_from(&clip); + } else { + draw_from(&clip); + draw_to(&clip); + } + drawn = true; + } + } + } + if !drawn && let Some(to) = &to_tex { + painter.image(to.tex.id(), l.place.rect(to.bounds), uv, Color32::WHITE); + } + // Overlays (each with its background and opening transition). + let ovs = p.overlays.clone(); + for (o, (id, place, orect)) in ovs.iter().zip(l.overlays.iter()) { + let raw = if o.dur > 0.0 { ((now - o.opened) / o.dur).clamp(0.0, 1.0) } else { 1.0 }; + if raw < 1.0 { + ctx.request_repaint(); + } + let e = o.easing.eval(raw) as f32; + let f = if matches!(o.transition, Transition::Instant) { + transition_frames(Transition::Dissolve, 1.0) + } else { + transition_frames(o.transition, e) + }; + if let Some(bgc) = o.background { + let c = crate::theme::c32(bgc); + painter.rect_filled(l.target, CornerRadius::ZERO, c.gamma_multiply(raw as f32)); + } + if let Some(tex) = p.texture(ctx, &l.disp, *id, px) { + let r = place.rect(tex.bounds).translate(vec2(orect.width() * f.to_off.0, orect.height() * f.to_off.1)); + painter.with_clip_rect(l.target).image(tex.tex.id(), r, uv, Color32::WHITE.gamma_multiply(f.to_alpha)); + } + } + // Hotspot flash after a click on nothing. + if now < p.flash_until { + let (root, place) = match l.overlays.last() { + Some((id, pl, _)) => (*id, *pl), + None => (p.current, l.place), + }; + let a = ((p.flash_until - now) / 0.5) as f32; + for (_, r) in hotspots(&l.disp, root, &place) { + painter.rect_filled(r, CornerRadius::same(2), ACCENT.gamma_multiply(0.25 * a)); + painter.rect_stroke(r, CornerRadius::same(2), Stroke::new(1.5, ACCENT.gamma_multiply(a)), StrokeKind::Outside); + } + ctx.request_repaint(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use figcraft_doc::NodeKind; + + fn doc_with_two_frames() -> (Document, NodeId, NodeId) { + let mut d = Document::new("t"); + let page = d.add_page("Page 1"); + let a = d.create(page, NodeKind::Frame, "A", 0.0, 0.0, 200.0, 400.0).unwrap(); + let b = d.create(page, NodeKind::Frame, "B", 300.0, 0.0, 200.0, 400.0).unwrap(); + (d, a, b) + } + + #[test] + fn matching_by_name_path() { + let (mut d, a, b) = doc_with_two_frames(); + let ga = d.create(a, NodeKind::Group, "Card", 0.0, 0.0, 10.0, 10.0).unwrap(); + let ra = d.create(ga, NodeKind::Rectangle, "Box", 0.0, 0.0, 10.0, 10.0).unwrap(); + let ra2 = d.create(ga, NodeKind::Rectangle, "Box", 0.0, 0.0, 10.0, 10.0).unwrap(); + let only_a = d.create(a, NodeKind::Rectangle, "Gone", 0.0, 0.0, 10.0, 10.0).unwrap(); + let gb = d.create(b, NodeKind::Group, "Card", 0.0, 0.0, 10.0, 10.0).unwrap(); + let rb = d.create(gb, NodeKind::Rectangle, "Box", 0.0, 0.0, 10.0, 10.0).unwrap(); + let rb2 = d.create(gb, NodeKind::Rectangle, "Box", 0.0, 0.0, 10.0, 10.0).unwrap(); + // Same name, different parent: no match. + let _other = d.create(b, NodeKind::Rectangle, "Box", 0.0, 0.0, 10.0, 10.0).unwrap(); + let m = match_layers(&d, a, &d, b); + assert_eq!(m, vec![(ga, gb), (ra, rb), (ra2, rb2)]); + assert!(!m.iter().any(|(f, _)| *f == only_a)); + assert_eq!(name_path(&d, a, ra).as_deref(), Some("Card\u{1f}Box")); + assert_eq!(name_path(&d, b, ra), None); + } + + #[test] + fn smart_interpolate_tweens_matched_and_fades_the_rest() { + let (mut d, a, b) = doc_with_two_frames(); + let ra = d.create(a, NodeKind::Rectangle, "Box", 0.0, 0.0, 20.0, 20.0).unwrap(); + let rb = d.create(b, NodeKind::Rectangle, "Box", 100.0, 200.0, 60.0, 40.0).unwrap(); + { + let n = d.get_mut(ra).unwrap(); + n.fills = vec![Paint::solid(Color::new(1.0, 0.0, 0.0, 1.0))]; + n.corners.radii = [0.0; 4]; + n.opacity = 1.0; + } + { + let n = d.get_mut(rb).unwrap(); + n.fills = vec![Paint::solid(Color::new(0.0, 0.0, 1.0, 1.0))]; + n.corners.radii = [20.0; 4]; + n.opacity = 0.5; + // Rotate 90° about its centre. + let c = (130.0, 220.0); + n.transform = Affine::translate(c) * Affine::rotate(std::f64::consts::FRAC_PI_2) * Affine::translate((-30.0, -20.0)); + } + let only_b = d.create(b, NodeKind::Rectangle, "New", 0.0, 0.0, 5.0, 5.0); + let only_a = d.create(a, NodeKind::Rectangle, "Old", 0.0, 0.0, 5.0, 5.0).unwrap(); + let mid = smart_interpolate(&d, a, &d, b, 0.5); + let n = mid.node(rb).unwrap(); + assert!((n.width - 40.0).abs() < 1e-9 && (n.height - 30.0).abs() < 1e-9); + assert!((n.opacity - 0.75).abs() < 1e-6); + assert!((n.corners.radii[0] - 10.0).abs() < 1e-9); + let c = n.fills[0].solid_color().unwrap(); + assert!((c.r - 0.5).abs() < 0.02 && (c.b - 0.5).abs() < 0.02, "{c:?}"); + // Centre halfway between (10, 10) and (130, 220) in frame space; rotated 45°. + let ctr = mid.world_transform(b).inverse() * mid.world_transform(rb) * Point::new(n.width / 2.0, n.height / 2.0); + assert!((ctr.x - 70.0).abs() < 1e-6 && (ctr.y - 115.0).abs() < 1e-6, "{ctr:?}"); + let [aa, bb, ..] = n.transform.as_coeffs(); + assert!((bb.atan2(aa) - std::f64::consts::FRAC_PI_4).abs() < 1e-6); + if let Ok(ob) = only_b { + assert!((mid.node(ob).unwrap().opacity - 0.5).abs() < 1e-6); + } + // The source-only layer is copied into the destination frame, half faded. + let copies: Vec<_> = mid.node(b).unwrap().children.iter().filter_map(|c| mid.node(*c)).filter(|n| n.name == "Old").collect(); + assert_eq!(copies.len(), 1); + assert!((copies[0].opacity - 0.5).abs() < 1e-6); + assert_ne!(copies[0].id, only_a); + // At the end it is exactly the destination. + let end = smart_interpolate(&d, a, &d, b, 1.0); + assert_eq!(end.node(rb), d.node(rb)); + assert_eq!(end.node(b).unwrap().children, d.node(b).unwrap().children); + // At the start the matched layer sits where the source was. + let start = smart_interpolate(&d, a, &d, b, 0.0); + let s = start.world_bounds(rb).unwrap(); + let f = start.world_bounds(b).unwrap(); + assert!((s.x0 - f.x0).abs() < 1e-6 && (s.y0 - f.y0).abs() < 1e-6 && (s.width() - 20.0).abs() < 1e-6); + } + + #[test] + fn scroll_offsets_fixed_and_sticky() { + let (mut d, a, _) = doc_with_two_frames(); + d.get_mut(a).unwrap().overflow = Overflow::Vertical; + let long = d.create(a, NodeKind::Rectangle, "Long", 0.0, 0.0, 200.0, 1000.0).unwrap(); + let fixed = d.create(a, NodeKind::Rectangle, "Bar", 0.0, 350.0, 200.0, 50.0).unwrap(); + let sticky = d.create(a, NodeKind::Rectangle, "Header", 0.0, 100.0, 200.0, 40.0).unwrap(); + d.get_mut(fixed).unwrap().scroll_position = ScrollPosition::Fixed; + d.get_mut(sticky).unwrap().scroll_position = ScrollPosition::Sticky; + assert_eq!(scroll_range(&d, a), (0.0, 600.0)); + d.get_mut(a).unwrap().overflow = Overflow::Horizontal; + assert_eq!(scroll_range(&d, a), (0.0, 0.0)); + d.get_mut(a).unwrap().overflow = Overflow::Vertical; + let mut s = HashMap::new(); + s.insert(a, (0.0, 50.0)); + let out = apply_scroll(&d, &s); + let y = |doc: &Document, id| doc.world_bounds(id).unwrap().y0; + assert_eq!(y(&out, long), -50.0); + assert_eq!(y(&out, fixed), 350.0); + assert_eq!(y(&out, sticky), 50.0); + s.insert(a, (0.0, 300.0)); + let out = apply_scroll(&d, &s); + assert_eq!(y(&out, long), -300.0); + assert_eq!(y(&out, sticky), 0.0); + } + + #[test] + fn transitions_move_the_right_way() { + let left = transition_frames(Transition::MoveIn { direction: Direction::Left }, 0.0); + assert_eq!(left.to_off, (1.0, 0.0)); + assert_eq!(transition_frames(Transition::MoveIn { direction: Direction::Left }, 1.0).to_off, (0.0, 0.0)); + let push = transition_frames(Transition::Push { direction: Direction::Top }, 0.5); + assert_eq!(push.from_off, (0.0, -0.5)); + assert_eq!(push.to_off, (0.0, 0.5)); + let out = transition_frames(Transition::MoveOut { direction: Direction::Right }, 0.25); + assert!(out.from_on_top); + assert_eq!(out.from_off, (0.25, 0.0)); + assert_eq!(transition_frames(Transition::Dissolve, 0.3).to_alpha, 0.3); + assert_eq!(transition_frames(Transition::Instant, 0.0).from_alpha, 0.0); + let r = Transition::SlideIn { direction: Direction::Bottom }.reversed(); + assert_eq!(r, Transition::SlideOut { direction: Direction::Top }); + } + + #[test] + fn overlay_positions() { + let base = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 800.0)); + let size = vec2(200.0, 100.0); + assert_eq!(overlay_rect(OverlayPosition::Center, base, size, None).min, pos2(100.0, 350.0)); + assert_eq!(overlay_rect(OverlayPosition::BottomCenter, base, size, None).min, pos2(100.0, 700.0)); + assert_eq!(overlay_rect(OverlayPosition::TopRight, base, size, None).min, pos2(200.0, 0.0)); + let anchor = Rect::from_min_size(pos2(30.0, 40.0), vec2(50.0, 20.0)); + assert_eq!(overlay_rect(OverlayPosition::Manual, base, size, Some(anchor)).min, pos2(30.0, 60.0)); + } + + #[test] + fn variables_parse_by_type() { + let mut m = BTreeMap::new(); + m.insert("m1".to_string(), VariableValue::Number(1.0)); + assert_eq!(variable_value(&m, &serde_json::json!("4")), Some(VariableValue::Number(4.0))); + m.insert("m1".to_string(), VariableValue::Boolean(false)); + assert_eq!(variable_value(&m, &serde_json::json!(true)), Some(VariableValue::Boolean(true))); + assert_eq!(variable_value(&m, &serde_json::json!({})), None); + } + + /// Smart animate renders a temporary document per animation frame; keep that cheap. + #[test] + fn smart_animate_frame_cost() { + let (mut d, a, b) = doc_with_two_frames(); + for (f, dx) in [(a, 0.0), (b, 40.0)] { + for i in 0..40 { + let r = d.create(f, NodeKind::Rectangle, &format!("Row {i}"), dx + 10.0, 10.0 + f64::from(i) * 9.0, 120.0, 8.0).unwrap(); + d.get_mut(r).unwrap().corners.radii = [4.0; 4]; + } + } + let t0 = std::time::Instant::now(); + let n = 10; + for k in 0..n { + let tmp = smart_interpolate(&d, a, &d, b, f64::from(k) / f64::from(n)); + assert!(figcraft_render::render_node(&tmp, b, 2.0).is_some()); + } + let per = t0.elapsed().as_secs_f64() * 1000.0 / f64::from(n); + eprintln!("smart animate: {per:.2} ms per frame (80 layers, 400x800 px)"); + } +} diff --git a/crates/ui-egui/src/proto_canvas.rs b/crates/ui-egui/src/proto_canvas.rs new file mode 100644 index 0000000..9c1e5bf --- /dev/null +++ b/crates/ui-egui/src/proto_canvas.rs @@ -0,0 +1,271 @@ +//! Prototype connections on the canvas (Prototype tab): noodles from layers to the frames they +//! act on, a "+" connection handle on selected layers that drags out a new Navigate interaction, +//! and clicking a noodle to edit its interaction. + +use egui::{Color32, CornerRadius, Id, Pos2, Rect, Shape, Stroke, StrokeKind, pos2, vec2}; +use figcraft_doc::{Document, NodeId}; +use serde_json::{Value, json}; + +use crate::theme::ACCENT; +use crate::{FigApp, RightTab}; + +const HANDLE_R: f32 = 7.0; + +/// A connection being dragged out of a layer's handle. +#[derive(Clone, Copy)] +struct Drag { + source: NodeId, + from: Pos2, + pos: Pos2, +} + +fn drag_id() -> Id { + Id::new("proto_canvas_drag") +} + +/// One drawn connection. +struct Noodle { + source: NodeId, + index: usize, + pts: [Pos2; 4], +} + +fn active(app: &FigApp) -> bool { + app.ui.right_tab == RightTab::Prototype && app.ui.present.is_none() +} + +/// Screen rect of a node's axis-aligned page bounds. +fn screen_rect(app: &FigApp, doc: &Document, id: NodeId) -> Option { + let b = doc.world_bounds(id)?; + Some(Rect::from_min_max(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1))) +} + +/// Curve from `a` (a layer) to `b` (a frame): leaves the side facing the destination. +fn curve_between(a: Rect, b: Rect) -> [Pos2; 4] { + let right = b.center().x >= a.center().x; + let (p0, p3) = if right { (a.right_center(), b.left_center()) } else { (a.left_center(), b.right_center()) }; + let s = if right { 1.0 } else { -1.0 }; + let dx = ((p3.x - p0.x).abs() * 0.5).max(40.0) * s; + [p0, p0 + vec2(dx, 0.0), p3 - vec2(dx, 0.0), p3] +} + +fn noodles(app: &FigApp, doc: &Document, page: NodeId) -> Vec { + let mut out = Vec::new(); + for id in doc.subtree(page) { + let Some(n) = doc.node(id) else { continue }; + if n.interactions.is_empty() { + continue; + } + let Some(from) = screen_rect(app, doc, id) else { continue }; + for (k, it) in n.interactions.iter().enumerate() { + let Some(dest) = it.actions.iter().find_map(|a| a.dest()) else { continue }; + // Change-to targets are variants; draw to them as they are. + let Some(to) = screen_rect(app, doc, dest) else { continue }; + if doc.page_of(dest) != Some(page) { + continue; + } + out.push(Noodle { source: id, index: k, pts: curve_between(from, to) }); + } + } + out +} + +fn bezier(p: &[Pos2; 4], t: f32) -> Pos2 { + let u = 1.0 - t; + let w = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t]; + pos2(w[0] * p[0].x + w[1] * p[1].x + w[2] * p[2].x + w[3] * p[3].x, w[0] * p[0].y + w[1] * p[1].y + w[2] * p[2].y + w[3] * p[3].y) +} + +/// Distance from `q` to a cubic (sampled). +fn bezier_distance(p: &[Pos2; 4], q: Pos2) -> f32 { + let mut best = f32::INFINITY; + let mut prev = p[0]; + for i in 1..=32 { + let cur = bezier(p, i as f32 / 32.0); + best = best.min(segment_distance(prev, cur, q)); + prev = cur; + } + best +} + +fn segment_distance(a: Pos2, b: Pos2, q: Pos2) -> f32 { + let ab = b - a; + let len2 = ab.length_sq(); + let t = if len2 > 0.0 { ((q - a).dot(ab) / len2).clamp(0.0, 1.0) } else { 0.0 }; + (a + ab * t).distance(q) +} + +/// Handle centres of the selected layers (right edge, vertically centred). +fn handles(app: &FigApp, doc: &Document, sel: &[NodeId]) -> Vec<(NodeId, Pos2)> { + sel.iter() + .take(32) + .filter(|i| doc.node(**i).is_some_and(|n| !n.is_page())) + .filter_map(|i| screen_rect(app, doc, *i).map(|r| (*i, r.right_center() + vec2(HANDLE_R + 4.0, 0.0)))) + .collect() +} + +/// The top-level frame under a screen point (top-most first). +fn frame_at(app: &FigApp, doc: &Document, page: NodeId, p: Pos2) -> Option { + let pt = app.to_page(p); + doc.top_frames(page).into_iter().rev().find(|f| doc.world_bounds(*f).is_some_and(|b| b.contains(pt))) +} + +fn arrow(painter: &egui::Painter, pts: &[Pos2; 4], col: Color32) { + let tip = pts[3]; + let dir = (pts[3] - bezier(pts, 0.9)).normalized(); + let dir = if dir.is_finite() && dir.length() > 0.5 { dir } else { vec2(1.0, 0.0) }; + let n = vec2(-dir.y, dir.x); + painter.add(Shape::convex_polygon(vec![tip, tip - dir * 8.0 + n * 4.5, tip - dir * 8.0 - n * 4.5], col, Stroke::NONE)); +} + +fn plus_handle(painter: &egui::Painter, c: Pos2, hot: bool) { + painter.circle_filled(c, HANDLE_R + if hot { 1.0 } else { 0.0 }, ACCENT); + painter.circle_stroke(c, HANDLE_R + if hot { 1.0 } else { 0.0 }, Stroke::new(1.5, Color32::WHITE)); + painter.line_segment([c - vec2(3.5, 0.0), c + vec2(3.5, 0.0)], Stroke::new(1.5, Color32::WHITE)); + painter.line_segment([c - vec2(0.0, 3.5), c + vec2(0.0, 3.5)], Stroke::new(1.5, Color32::WHITE)); +} + +/// Draw connections, handles and a connection being dragged. +pub fn draw(app: &FigApp, painter: &egui::Painter) { + if !active(app) { + return; + } + let Ok(ds) = app.session.ds() else { return }; + let (doc, page, sel) = (&ds.doc, ds.page, &ds.selection); + let ctx = painter.ctx(); + let open = crate::proto_panel::editor(ctx); + let hover = ctx.input(|i| i.pointer.hover_pos()); + let dragging: Option = ctx.data(|d| d.get_temp(drag_id())); + for nd in noodles(app, doc, page) { + let selected = sel.contains(&nd.source); + let is_open = open == Some((nd.source, nd.index)); + let near = dragging.is_none() && hover.is_some_and(|q| bezier_distance(&nd.pts, q) < 5.0); + let col = if selected || is_open || near { ACCENT } else { ACCENT.gamma_multiply(0.55) }; + let w = if is_open || near { + 2.5 + } else if selected { + 2.0 + } else { + 1.5 + }; + // Destination outline for the selected layer's connections. + if (selected || is_open) + && let Some(dest) = doc.node(nd.source).and_then(|n| n.interactions.get(nd.index)).and_then(|it| it.actions.iter().find_map(|a| a.dest())) + && let Some(r) = screen_rect(app, doc, dest) + { + painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.0, ACCENT.gamma_multiply(0.7)), StrokeKind::Outside); + } + painter.add(egui::epaint::CubicBezierShape::from_points_stroke(nd.pts, false, Color32::TRANSPARENT, Stroke::new(w, col))); + painter.circle_filled(nd.pts[0], 3.5, col); + painter.circle_stroke(nd.pts[0], 3.5, Stroke::new(1.0, Color32::WHITE)); + arrow(painter, &nd.pts, col); + } + match dragging { + Some(d) => { + if let Some(f) = frame_at(app, doc, page, d.pos) + && doc.top_level_frame(d.source) != Some(f) + && let Some(r) = screen_rect(app, doc, f) + { + painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(2.0, ACCENT), StrokeKind::Outside); + } + let dx = ((d.pos.x - d.from.x).abs() * 0.5).max(30.0) * if d.pos.x >= d.from.x { 1.0 } else { -1.0 }; + let pts = [d.from, d.from + vec2(dx, 0.0), d.pos - vec2(dx, 0.0), d.pos]; + painter.add(egui::epaint::CubicBezierShape::from_points_stroke(pts, false, Color32::TRANSPARENT, Stroke::new(2.0, ACCENT))); + arrow(painter, &pts, ACCENT); + plus_handle(painter, d.from, true); + } + None => { + if ds.text_edit.is_none() { + for (_, c) in handles(app, doc, sel) { + plus_handle(painter, c, hover.is_some_and(|q| q.distance(c) <= HANDLE_R + 3.0)); + } + } + } + } +} + +/// Canvas input while the Prototype tab is open. Returns true when it consumed the gesture +/// (dragging from a connection handle, or clicking a noodle). +pub fn input(app: &mut FigApp, ui: &egui::Ui, resp: &egui::Response) -> bool { + let ctx = ui.ctx().clone(); + let drag: Option = ctx.data(|d| d.get_temp(drag_id())); + if !active(app) { + if drag.is_some() { + ctx.data_mut(|d| d.remove::(drag_id())); + } + return false; + } + let (pos, pressed, down, released) = ctx.input(|i| { + ( + i.pointer.hover_pos(), + i.pointer.button_pressed(egui::PointerButton::Primary), + i.pointer.primary_down(), + i.pointer.button_released(egui::PointerButton::Primary), + ) + }); + let Ok(ds) = app.session.ds() else { return false }; + let (doc, page, sel) = (ds.doc.clone(), ds.page, ds.selection.clone()); + if let Some(mut d) = drag { + if let Some(p) = pos { + d.pos = p; + } + ctx.request_repaint(); + if released || !down { + ctx.data_mut(|m| m.remove::(drag_id())); + if let Some(dest) = frame_at(app, &doc, page, d.pos) + && doc.top_level_frame(d.source) != Some(dest) + && let Some(r) = app.run("proto.interaction.add", json!({"ids": [d.source], "dest": dest})) + { + let k = r.get("index").and_then(Value::as_u64).unwrap_or(0) as usize; + crate::proto_panel::open_editor(&ctx, Some((d.source, k))); + } + } else { + ctx.data_mut(|m| m.insert_temp(drag_id(), d)); + } + return true; + } + let Some(pos) = pos.filter(|p| app.canvas.rect.contains(*p)) else { return false }; + if !resp.hovered() || ds.text_edit.is_some() { + return false; + } + let over_handle = handles(app, &doc, &sel).into_iter().find(|(_, c)| c.distance(pos) <= HANDLE_R + 3.0); + if over_handle.is_some() { + ctx.set_cursor_icon(egui::CursorIcon::Crosshair); + } + if !pressed { + return false; + } + if let Some((source, from)) = over_handle { + ctx.data_mut(|m| m.insert_temp(drag_id(), Drag { source, from, pos })); + return true; + } + let hit = noodles(app, &doc, page) + .into_iter() + .filter(|n| bezier_distance(&n.pts, pos) < 5.0) + .min_by(|a, b| bezier_distance(&a.pts, pos).total_cmp(&bezier_distance(&b.pts, pos))); + if let Some(n) = hit { + app.session.set_selection(vec![n.source]); + crate::proto_panel::open_editor(&ctx, Some((n.source, n.index))); + return true; + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn curves_leave_the_facing_side() { + let a = Rect::from_min_size(pos2(0.0, 0.0), vec2(50.0, 20.0)); + let b = Rect::from_min_size(pos2(200.0, 100.0), vec2(100.0, 200.0)); + let c = curve_between(a, b); + assert_eq!(c[0], pos2(50.0, 10.0)); + assert_eq!(c[3], pos2(200.0, 200.0)); + let back = curve_between(b, a); + assert_eq!(back[0], pos2(200.0, 200.0)); + assert_eq!(back[3], pos2(50.0, 10.0)); + assert!(bezier_distance(&c, bezier(&c, 0.5)) < 0.5); + assert!(bezier_distance(&c, pos2(0.0, 300.0)) > 50.0); + } +} diff --git a/crates/ui-egui/src/proto_panel.rs b/crates/ui-egui/src/proto_panel.rs index f1bf518..47f251a 100644 --- a/crates/ui-egui/src/proto_panel.rs +++ b/crates/ui-egui/src/proto_panel.rs @@ -1,146 +1,827 @@ -//! The Prototype tab. -#![allow(unused_imports)] +//! The Prototype tab: interactions (with an editor popover), flow starting points, scroll +//! behaviour, overlay settings and prototype settings. Every edit runs a `proto.*` command or +//! `node.set`. -use egui::{Align2, Color32, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; -use figcraft_color::{BlendMode, Paint, PaintKind}; +use egui::{Align2, Color32, CornerRadius, Id, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2}; +use figcraft_color::Paint; +use figcraft_doc::proto::{Action, Direction, Easing, Interaction, Overflow, OverlayPosition, ScrollPosition, Transition, Trigger}; use figcraft_doc::*; use serde_json::{Value, json}; use crate::FigApp; use crate::icons::{self, Icon}; -use crate::right::{common, menu_item, row}; +use crate::right::{menu_item, row}; use crate::theme::{ACCENT, Tokens, regular, semibold}; use crate::widgets::{self, FIELD_H, FieldEdit, Lead, PAD}; +/// The interaction whose editor popover is open: (layer, index). +fn editor_id() -> Id { + Id::new("proto_editor") +} + +pub fn open_editor(ctx: &egui::Context, target: Option<(NodeId, usize)>) { + ctx.data_mut(|d| match target { + Some(t) => { + d.insert_temp(editor_id(), t); + } + None => { + d.remove::<(NodeId, usize)>(editor_id()); + } + }); +} + +pub fn editor(ctx: &egui::Context) -> Option<(NodeId, usize)> { + ctx.data(|d| d.get_temp(editor_id())) +} + +const TRIGGERS: [&str; 10] = + ["On click", "On drag", "While hovering", "While pressing", "Key/Gamepad", "Mouse enter", "Mouse leave", "Mouse down", "Mouse up", "After delay"]; + +fn trigger_kind(t: &Trigger) -> &'static str { + match t { + Trigger::OnClick => "On click", + Trigger::OnDrag => "On drag", + Trigger::OnHover => "While hovering", + Trigger::OnPress => "While pressing", + Trigger::KeyPress { .. } => "Key/Gamepad", + Trigger::MouseEnter => "Mouse enter", + Trigger::MouseLeave => "Mouse leave", + Trigger::MouseDown => "Mouse down", + Trigger::MouseUp => "Mouse up", + Trigger::AfterDelay { .. } => "After delay", + } +} + +fn trigger_of(kind: &str, cur: &Trigger) -> Trigger { + match kind { + "On drag" => Trigger::OnDrag, + "While hovering" => Trigger::OnHover, + "While pressing" => Trigger::OnPress, + "Key/Gamepad" => match cur { + Trigger::KeyPress { key } => Trigger::KeyPress { key: key.clone() }, + _ => Trigger::KeyPress { key: String::new() }, + }, + "Mouse enter" => Trigger::MouseEnter, + "Mouse leave" => Trigger::MouseLeave, + "Mouse down" => Trigger::MouseDown, + "Mouse up" => Trigger::MouseUp, + "After delay" => match cur { + Trigger::AfterDelay { ms } => Trigger::AfterDelay { ms: *ms }, + _ => Trigger::AfterDelay { ms: 800 }, + }, + _ => Trigger::OnClick, + } +} + +/// The trigger as the list shows it ("Key/Gamepad Space", "After delay 800ms"). +fn trigger_summary(t: &Trigger) -> String { + match t { + Trigger::KeyPress { key } if key.is_empty() => "Key/Gamepad".into(), + other => other.label(), + } +} + +const ACTIONS: [&str; 9] = + ["Navigate to", "Change to", "Back", "Scroll to", "Open link", "Open overlay", "Swap overlay", "Close overlay", "Set variable"]; + +const TRANSITIONS: [&str; 8] = ["Instant", "Dissolve", "Smart animate", "Move in", "Move out", "Push", "Slide in", "Slide out"]; + +fn transition_of(label: &str, cur: Transition) -> Transition { + let d = cur.direction().unwrap_or_default(); + match label { + "Dissolve" => Transition::Dissolve, + "Smart animate" => Transition::SmartAnimate, + "Move in" => Transition::MoveIn { direction: d }, + "Move out" => Transition::MoveOut { direction: d }, + "Push" => Transition::Push { direction: d }, + "Slide in" => Transition::SlideIn { direction: d }, + "Slide out" => Transition::SlideOut { direction: d }, + _ => Transition::Instant, + } +} + +/// Destinations a "Change to" can pick: the variants next to the layer's component. +fn variants_for(doc: &Document, id: NodeId) -> Vec { + let comp = figcraft_engine::components::owning_component(doc, id).or_else(|| match doc.node(id).map(|n| &n.kind) { + Some(NodeKind::Instance { component, .. }) => Some(*component), + _ => None, + }); + let set = comp + .and_then(|c| doc.node(c)) + .and_then(|c| c.parent) + .filter(|p| doc.node(*p).is_some_and(|n| matches!(n.kind, NodeKind::ComponentSet { .. }))); + match set.and_then(|s| doc.node(s)) { + Some(s) => s.children.clone(), + None => comp.into_iter().collect(), + } +} + +/// Destinations for navigate/overlay actions: the page's top-level frames. +fn frames_for(doc: &Document, page: NodeId) -> Vec { + doc.top_frames(page) +} + +fn name_of(doc: &Document, id: NodeId) -> String { + doc.node(id).map(|n| n.name.clone()).unwrap_or_else(|| "Missing layer".into()) +} + +/// One-line action summary ("Navigate to Frame 2"). +fn action_summary(doc: &Document, a: &Action) -> String { + match a { + Action::OpenUrl { url } if !url.is_empty() => format!("Open link {url}"), + Action::SetVariable { variable, .. } => { + let name = doc.variables.get(variable).map(|v| v.name.clone()).unwrap_or_else(|| variable.clone()); + format!("Set variable {name}") + } + other => match other.dest() { + Some(d) => format!("{} {}", other.label(), name_of(doc, d)), + None => other.label().to_string(), + }, + } +} + +/// A default action of the kind labelled `label` (None when it needs a destination and there is +/// none). +fn action_of(label: &str, doc: &Document, page: NodeId, node: NodeId, cur: Option<&Action>) -> Option { + let own = doc.top_level_frame(node); + let frames = frames_for(doc, page); + let cur_dest = cur.and_then(|a| a.dest()).filter(|d| frames.contains(d)); + let frame_dest = cur_dest.or_else(|| frames.iter().copied().find(|f| Some(*f) != own)).or(frames.first().copied()); + Some(match label { + "Navigate to" => Action::Navigate { dest: frame_dest? }, + "Change to" => { + let vs = variants_for(doc, node); + let mine = figcraft_engine::components::owning_component(doc, node); + Action::ChangeTo { dest: vs.iter().copied().find(|v| Some(*v) != mine).or(vs.first().copied())? } + } + "Back" => Action::Back, + "Scroll to" => { + let root = own?; + let dest = doc.subtree(root).into_iter().skip(1).find(|i| *i != node)?; + Action::ScrollTo { dest } + } + "Open link" => Action::OpenUrl { url: String::new() }, + "Open overlay" => Action::OpenOverlay { dest: frame_dest?, position: OverlayPosition::Center, close_on_outside: true, background: None }, + "Swap overlay" => Action::SwapOverlay { dest: frame_dest? }, + "Close overlay" => Action::CloseOverlay, + "Set variable" => { + let v = doc.variables.variables.first()?; + let value = match v.values.values().next() { + Some(VariableValue::Color(c)) => json!(c.to_css_hex()), + Some(VariableValue::Number(n)) => json!(n), + Some(VariableValue::Boolean(b)) => json!(b), + Some(VariableValue::String(s)) => json!(s), + _ => Value::Null, + }; + Action::SetVariable { variable: v.id.clone(), value } + } + _ => return None, + }) +} + +fn patch(app: &mut FigApp, node: NodeId, index: usize, fields: Value) { + let mut p = json!({"ids": [node], "index": index}); + if let (Some(o), Value::Object(f)) = (p.as_object_mut(), fields) { + o.extend(f); + } + app.run("proto.interaction.set", p); +} + pub fn prototype(app: &mut FigApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); let Ok(ds) = app.session.ds() else { return }; let doc = ds.doc.clone(); let sel = ds.selection.clone(); let page = ds.page; if sel.is_empty() { - let flows = match doc.node(page).map(|n| &n.kind) { - Some(NodeKind::Page { flows, .. }) => flows.clone(), - _ => vec![], - }; - widgets::section_header(ui, "Flow starting points", &[], flows.is_empty()); - for f in &flows { - row(ui, 32.0, |ui| { - if icons::button(ui, Icon::Play, 24.0, false, "Present this flow").clicked() { - app.run("proto.present", json!({"start": f.start})); - } - widgets::label(ui, &f.name, t.text); - }); - } + open_editor(ui.ctx(), None); + flows_overview(app, ui, &doc, page); widgets::separator(ui); - widgets::section_header(ui, "Prototype settings", &[], false); - let (device, bg) = match doc.node(page).map(|n| &n.kind) { - Some(NodeKind::Page { prototype, .. }) => (prototype.device.clone(), prototype.background), - _ => ("None".into(), figcraft_color::Color::BLACK), - }; - row(ui, 32.0, |ui| { - let resp = widgets::dropdown(ui, &device, ui.available_width(), None); - egui::Popup::menu(&resp).show(|ui| { - for d in ["None", "iPhone 16", "iPhone 16 Pro Max", "Android Compact", "iPad mini", "MacBook Air", "Desktop"] { - if menu_item(ui, d, "") { - app.run("proto.settings", json!({"device": d})); - ui.close(); - } - } - }); - }); - row(ui, 32.0, |ui| { - widgets::swatch(ui, &Paint::solid(bg), 16.0); - if let Some(h) = widgets::text_field(ui, Id::new("proto_bg"), &bg.to_hex(), 96.0, "") - && let Some(c) = figcraft_color::Color::from_hex(&h) - { - app.run("proto.settings", json!({"background": c.to_css_hex()})); - } - }); + settings(app, ui, &doc, page); return; } - let ids = sel.clone(); let first = sel.first().copied().unwrap_or(NodeId(0)); - let interactions = doc.node(first).map(|n| n.interactions.clone()).unwrap_or_default(); - if widgets::section_header(ui, "Interactions", &[(Icon::Plus, "Add interaction")], interactions.is_empty()).is_some() { - // Default: click → navigate to the next top-level frame. - let frames = doc.top_frames(page); - let own = doc.top_level_frame(first); - let dest = frames.iter().copied().find(|f| Some(*f) != own).or(frames.first().copied()); - match dest { - Some(d) => { - app.run("proto.interaction.add", json!({"ids": ids, "dest": d})); + let Some(node) = doc.node(first).cloned() else { return }; + if let Some((n, _)) = editor(ui.ctx()) + && n != first + { + open_editor(ui.ctx(), None); + } + // Flow starting point (top-level frames). + if doc.top_level_frame(first) == Some(first) { + flow_section(app, ui, &doc, page, first); + widgets::separator(ui); + } + interactions(app, ui, &doc, page, &node); + widgets::separator(ui); + scroll_section(app, ui, &doc, &sel, &node); +} + +fn flows_of(doc: &Document, page: NodeId) -> Vec { + match doc.node(page).map(|n| &n.kind) { + Some(NodeKind::Page { flows, .. }) => flows.clone(), + _ => vec![], + } +} + +/// One flow row: play, editable name, remove. +fn flow_row(app: &mut FigApp, ui: &mut Ui, f: &proto::Flow) { + let t = Tokens::get(ui.ctx()); + let w = ui.available_width(); + let (r, _) = ui.allocate_exact_size(vec2(w, 32.0), Sense::hover()); + let play = Rect::from_center_size(pos2(r.min.x + PAD + 8.0, r.center().y), vec2(24.0, 24.0)); + let presp = ui.interact(play, ui.id().with(("flow_play", f.start)), Sense::click()).on_hover_text("Present this flow"); + if presp.hovered() { + ui.painter().rect_filled(play, CornerRadius::same(5), t.hover); + } + icons::paint(ui.painter(), play.shrink(5.0), Icon::Play, t.icon); + if presp.clicked() { + app.run("proto.present", json!({"start": f.start})); + } + let name_rect = Rect::from_min_max(pos2(play.max.x + 6.0, r.center().y - 9.0), pos2(r.max.x - 40.0, r.center().y + 9.0)); + if let Some(n) = widgets::text_field_inline(ui, Id::new(("flow_name", f.start)), &f.name, name_rect, regular(11.0)) { + app.run("proto.flow.rename", json!({"start": f.start, "name": n})); + } + let rm = Rect::from_center_size(pos2(r.max.x - 20.0, r.center().y), vec2(24.0, 24.0)); + let rresp = ui.interact(rm, ui.id().with(("flow_rm", f.start)), Sense::click()).on_hover_text("Remove starting point"); + if rresp.hovered() { + ui.painter().rect_filled(rm, CornerRadius::same(5), t.hover); + } + icons::paint(ui.painter(), rm.shrink(5.0), Icon::Minus, t.icon); + if rresp.clicked() { + app.run("proto.flow.remove", json!({"start": f.start})); + } +} + +fn flows_overview(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { + let t = Tokens::get(ui.ctx()); + let flows = flows_of(doc, page); + if widgets::section_header(ui, "Flow starting points", &[(Icon::Plus, "Add starting point")], flows.is_empty()).is_some() { + let free = doc.top_frames(page).into_iter().find(|f| !flows.iter().any(|x| x.start == *f)); + match free { + Some(f) => { + app.run("proto.flow.add", json!({"start": f})); } - None => app.toast("Add another frame to navigate to"), + None => app.toast("Every top-level frame already starts a flow"), } } - for (i, it) in interactions.iter().enumerate() { - let dest_name = it.actions.first().and_then(|a| a.dest()).and_then(|d| doc.node(d)).map(|n| n.name.clone()); - row(ui, 40.0, |ui| { - ui.vertical(|ui| { - widgets::label(ui, &it.trigger.label(), t.text); - let action = it.actions.first().map(|a| a.label()).unwrap_or("None"); - widgets::label(ui, &format!("{action} {}", dest_name.clone().unwrap_or_default()), t.text_secondary); - }); + if flows.is_empty() { + row(ui, 36.0, |ui| widgets::label(ui, "Select a frame and add a starting point to present it as a flow.", t.text_secondary)); + } + for f in &flows { + flow_row(app, ui, f); + } +} + +fn settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { + widgets::section_header(ui, "Prototype settings", &[], false); + let (device, bg) = match doc.node(page).map(|n| &n.kind) { + Some(NodeKind::Page { prototype, .. }) => (prototype.device.clone(), prototype.background), + _ => ("None".into(), figcraft_color::Color::BLACK), + }; + widgets::sublabels(ui, "Device", None); + row(ui, 32.0, |ui| { + let resp = widgets::dropdown(ui, &device, ui.available_width(), None); + egui::Popup::menu(&resp).show(|ui| { + for d in ["None", "iPhone 16", "iPhone 16 Pro Max", "Android Compact", "iPad mini", "MacBook Air", "Desktop"] { + if menu_item(ui, d, "") { + app.run("proto.settings", json!({"device": d})); + ui.close(); + } + } }); + }); + widgets::sublabels(ui, "Background", None); + row(ui, 32.0, |ui| { + widgets::swatch(ui, &Paint::solid(bg), 16.0); + if let Some(h) = widgets::text_field(ui, Id::new("proto_bg"), &bg.to_hex(), 96.0, "") + && let Some(c) = figcraft_color::Color::from_hex(&h) + { + app.run("proto.settings", json!({"background": c.to_css_hex()})); + } + }); +} + +fn flow_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, frame: NodeId) { + let flows = flows_of(doc, page); + let mine = flows.iter().find(|f| f.start == frame).cloned(); + let buttons: &[(Icon, &str)] = if mine.is_none() { &[(Icon::Plus, "Add starting point")] } else { &[] }; + if widgets::section_header(ui, "Flow starting point", buttons, mine.is_none()).is_some() { + app.run("proto.flow.add", json!({"start": frame})); + } + if let Some(f) = mine { + flow_row(app, ui, &f); row(ui, 32.0, |ui| { - let frames = doc.top_frames(page); - let resp = widgets::dropdown(ui, &dest_name.unwrap_or_else(|| "Choose destination".into()), ui.available_width() - 32.0, None); - let it2 = it.clone(); - egui::Popup::menu(&resp).show(|ui| { - for f in frames { - let name = doc.node(f).map(|n| n.name.clone()).unwrap_or_default(); - if menu_item(ui, &name, "") { - let mut ni = it2.clone(); - ni.actions = vec![proto::Action::Navigate { dest: f }]; - app.run("proto.interaction.set", json!({"ids": ids, "index": i, "interaction": ni})); + if let Some(d) = widgets::text_field(ui, Id::new(("flow_desc", frame)), &f.description, ui.available_width(), "Add a description") + && d != f.description + { + app.run("proto.flow.rename", json!({"start": frame, "description": d})); + } + }); + } +} + +fn interactions(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, node: &Node) { + let t = Tokens::get(ui.ctx()); + let id = node.id; + let list = node.interactions.clone(); + if widgets::section_header(ui, "Interactions", &[(Icon::Plus, "Add interaction")], list.is_empty()).is_some() { + // Click → navigate to the next frame (or Back when there's nowhere to go). + let action = action_of("Navigate to", doc, page, id, None).unwrap_or(Action::Back); + if let Some(r) = app.run("proto.interaction.add", json!({"ids": [id], "trigger": {"type": "onClick"}, "action": action})) { + let k = r.get("index").and_then(Value::as_u64).unwrap_or(0) as usize; + open_editor(ui.ctx(), Some((id, k))); + } + } + let open = editor(ui.ctx()); + let mut anchor: Option = None; + for (i, it) in list.iter().enumerate() { + let w = ui.available_width(); + let (r, _) = ui.allocate_exact_size(vec2(w, 44.0), Sense::hover()); + let body = Rect::from_min_max(pos2(r.min.x + PAD - 6.0, r.min.y + 2.0), pos2(r.max.x - 40.0, r.max.y - 2.0)); + let resp = ui.interact(body, ui.id().with(("proto_it", id, i)), Sense::click()); + let active = open == Some((id, i)); + if active { + ui.painter().rect_filled(body, CornerRadius::same(5), t.selected_row); + anchor = Some(r); + } else if resp.hovered() { + ui.painter().rect_filled(body, CornerRadius::same(5), t.hover); + } + // A small connection glyph. + let g = pos2(body.min.x + 14.0, body.center().y); + ui.painter().circle_stroke(g, 5.0, Stroke::new(1.2, ACCENT)); + ui.painter().circle_filled(g, 2.0, ACCENT); + let tx = body.min.x + 28.0; + let clip = ui.painter().with_clip_rect(body); + clip.text(pos2(tx, body.center().y - 7.0), Align2::LEFT_CENTER, trigger_summary(&it.trigger), semibold(11.0), t.text); + let summary = it.actions.iter().map(|a| action_summary(doc, a)).collect::>().join(", "); + clip.text(pos2(tx, body.center().y + 8.0), Align2::LEFT_CENTER, summary, regular(11.0), t.text_secondary); + if resp.clicked() { + open_editor(ui.ctx(), if active { None } else { Some((id, i)) }); + } + let rm = Rect::from_center_size(pos2(r.max.x - 20.0, r.center().y), vec2(24.0, 24.0)); + let rresp = ui.interact(rm, ui.id().with(("proto_rm", id, i)), Sense::click()).on_hover_text("Remove interaction"); + if rresp.hovered() { + ui.painter().rect_filled(rm, CornerRadius::same(5), t.hover); + } + icons::paint(ui.painter(), rm.shrink(5.0), Icon::Minus, t.icon); + if rresp.clicked() { + open_editor(ui.ctx(), None); + app.run("proto.interaction.remove", json!({"ids": [id], "index": i})); + return; + } + } + if let Some((n, k)) = open + && n == id + && let Some(it) = list.get(k) + { + let panel_left = ui.clip_rect().min.x; + let y = anchor.map(|a| a.min.y).unwrap_or(ui.clip_rect().min.y + 80.0); + editor_popover(app, ui.ctx(), doc, page, id, k, it, pos2(panel_left - 8.0, y)); + } else if open.is_some_and(|(n, _)| n == id) { + open_editor(ui.ctx(), None); + } +} + +/// The interaction editor, floating left of the panel (`at` is its top-right corner). +#[allow(clippy::too_many_arguments)] +fn editor_popover(app: &mut FigApp, ctx: &egui::Context, doc: &Document, page: NodeId, node: NodeId, k: usize, it: &Interaction, at: Pos2) { + let t = Tokens::get(ctx); + let width = 264.0; + let screen = ctx.content_rect(); + let pos = pos2(at.x - width, at.y.min(screen.max.y - 420.0).max(screen.min.y + 48.0)); + let mut close = false; + egui::Area::new(Id::new("proto_editor_area")).fixed_pos(pos).order(egui::Order::Foreground).show(ctx, |ui| { + egui::Frame::new() + .fill(t.panel) + .stroke(Stroke::new(1.0, t.border)) + .corner_radius(CornerRadius::same(8)) + .shadow(egui::epaint::Shadow { offset: [0, 4], blur: 16, spread: 0, color: t.shadow }) + .show(ui, |ui| { + ui.set_width(width); + ui.spacing_mut().item_spacing = vec2(8.0, 0.0); + // Header. + let (hr, _) = ui.allocate_exact_size(vec2(width, 40.0), Sense::hover()); + ui.painter().text(pos2(hr.min.x + PAD, hr.center().y), Align2::LEFT_CENTER, "Interaction", semibold(11.0), t.text); + let x = Rect::from_center_size(pos2(hr.max.x - 20.0, hr.center().y), vec2(24.0, 24.0)); + let xr = ui.interact(x, ui.id().with("proto_editor_close"), Sense::click()).on_hover_text("Close"); + if xr.hovered() { + ui.painter().rect_filled(x, CornerRadius::same(5), t.hover); + } + icons::paint(ui.painter(), x.shrink(6.0), Icon::Close, t.icon); + if xr.clicked() { + close = true; + } + widgets::separator(ui); + trigger_rows(app, ui, node, k, it); + widgets::separator(ui); + action_rows(app, ui, doc, page, node, k, it); + if !matches!(it.actions.first(), Some(Action::OpenUrl { .. } | Action::SetVariable { .. } | Action::CloseOverlay)) { + widgets::separator(ui); + animation_rows(app, ui, node, k, it); + } + ui.add_space(8.0); + }); + }); + if close || (ctx.input(|i| i.key_pressed(egui::Key::Escape)) && !capturing(ctx)) { + open_editor(ctx, None); + } +} + +fn capture_id() -> Id { + Id::new("proto_key_capture") +} + +fn capturing(ctx: &egui::Context) -> bool { + ctx.data(|d| d.get_temp::(capture_id())).unwrap_or(false) +} + +fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Interaction) { + let t = Tokens::get(ui.ctx()); + widgets::sublabels(ui, "Trigger", None); + row(ui, 32.0, |ui| { + let resp = widgets::dropdown(ui, trigger_kind(&it.trigger), ui.available_width(), None); + egui::Popup::menu(&resp).show(|ui| { + for (i, l) in TRIGGERS.iter().enumerate() { + if i == 4 || i == 5 || i == 9 { + ui.separator(); + } + if menu_item(ui, l, "") { + let tr = trigger_of(l, &it.trigger); + patch(app, node, k, json!({"trigger": tr})); + if matches!(tr, Trigger::KeyPress { ref key } if key.is_empty()) { + ui.ctx().data_mut(|d| d.insert_temp(capture_id(), true)); + } + ui.close(); + } + } + }); + }); + match &it.trigger { + Trigger::AfterDelay { ms } => { + row(ui, 32.0, |ui| { + widgets::label(ui, "Delay", t.text_secondary); + if let Some(e) = widgets::number_field(ui, Id::new(("proto_delay", node, k)), Lead::None, Some(f64::from(*ms)), "ms", 96.0, 10.0) { + let v = match e { + FieldEdit::Set(v) | FieldEdit::Scrub(v, _) => v, + }; + if v.is_finite() { + patch(app, node, k, json!({"trigger": {"type": "afterDelay", "ms": v.clamp(0.0, 600_000.0).round() as u32}})); + } + } + }); + } + Trigger::KeyPress { key } => { + row(ui, 32.0, |ui| { + let cap = capturing(ui.ctx()); + let label = if cap { + "Press a key…".to_string() + } else if key.is_empty() { + "Click to record a key".to_string() + } else { + key.clone() + }; + let w = ui.available_width(); + let (r, resp) = ui.allocate_exact_size(vec2(w, FIELD_H), Sense::click()); + let id = capture_id().with("field"); + ui.painter().rect_filled(r, CornerRadius::same(5), t.field); + if cap { + ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(1.0, t.accent), StrokeKind::Inside); + ui.memory_mut(|m| m.request_focus(id)); + let _ = ui.interact(r, id, Sense::focusable_noninteractive()); + let pressed = ui.input(|i| { + i.events.iter().find_map(|e| match e { + egui::Event::Key { key, pressed: true, .. } => Some(*key), + _ => None, + }) + }); + if let Some(kk) = pressed { + ui.ctx().data_mut(|d| d.remove::(capture_id())); + ui.memory_mut(|m| m.surrender_focus(id)); + if kk != egui::Key::Escape || key.is_empty() { + patch(app, node, k, json!({"trigger": {"type": "keyPress", "key": kk.name()}})); + } + } + } else if resp.hovered() { + ui.painter().rect_stroke(r, CornerRadius::same(5), Stroke::new(1.0, t.field_hover_border), StrokeKind::Inside); + } + ui.painter().text( + pos2(r.min.x + 8.0, r.center().y), + Align2::LEFT_CENTER, + label, + regular(11.0), + if cap || key.is_empty() { t.text_secondary } else { t.text }, + ); + if resp.clicked() && !cap { + ui.ctx().data_mut(|d| d.insert_temp(capture_id(), true)); + } + }); + } + _ => {} + } +} + +#[allow(clippy::too_many_arguments)] +fn dest_dropdown( + app: &mut FigApp, + ui: &mut Ui, + doc: &Document, + options: &[NodeId], + cur: Option, + label: &str, + make: impl Fn(NodeId) -> Action, + node: NodeId, + k: usize, +) { + widgets::sublabels(ui, label, None); + row(ui, 32.0, |ui| { + let text = cur.map(|d| name_of(doc, d)).unwrap_or_else(|| "None".into()); + let resp = widgets::dropdown(ui, &text, ui.available_width(), Some(Icon::Frame)); + egui::Popup::menu(&resp).show(|ui| { + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { + for d in options { + let n = name_of(doc, *d); + let mark = if Some(*d) == cur { format!("✓ {n}") } else { n }; + if menu_item(ui, &mark, "") { + patch(app, node, k, json!({"action": make(*d)})); ui.close(); } } }); - if icons::button(ui, Icon::Minus, 24.0, false, "Remove").clicked() { - app.run("proto.interaction.remove", json!({"ids": ids, "index": i})); + }); + }); +} + +fn action_rows(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, node: NodeId, k: usize, it: &Interaction) { + let t = Tokens::get(ui.ctx()); + let action = it.actions.first().cloned().unwrap_or(Action::Back); + widgets::sublabels(ui, "Action", None); + row(ui, 32.0, |ui| { + let resp = widgets::dropdown(ui, action.label(), ui.available_width(), None); + egui::Popup::menu(&resp).show(|ui| { + for (i, l) in ACTIONS.iter().enumerate() { + if i == 2 || i == 4 || i == 5 || i == 8 { + ui.separator(); + } + if menu_item(ui, l, "") { + match action_of(l, doc, page, node, Some(&action)) { + Some(a) => patch(app, node, k, json!({"action": a})), + None => app.toast(match *l { + "Change to" => "Change to needs a layer inside a variant", + "Set variable" => "Create a variable first", + _ => "There's no destination for that action", + }), + } + ui.close(); + } } }); + }); + let frames = frames_for(doc, page); + match &action { + Action::Navigate { dest } => dest_dropdown(app, ui, doc, &frames, Some(*dest), "Destination", |d| Action::Navigate { dest: d }, node, k), + Action::SwapOverlay { dest } => dest_dropdown(app, ui, doc, &frames, Some(*dest), "Overlay", |d| Action::SwapOverlay { dest: d }, node, k), + Action::ChangeTo { dest } => { + let vs = variants_for(doc, node); + dest_dropdown(app, ui, doc, &vs, Some(*dest), "Variant", |d| Action::ChangeTo { dest: d }, node, k); + } + Action::ScrollTo { dest } => { + let layers: Vec = doc.top_level_frame(node).map(|f| doc.subtree(f).into_iter().skip(1).take(400).collect()).unwrap_or_default(); + dest_dropdown(app, ui, doc, &layers, Some(*dest), "Scroll to layer", |d| Action::ScrollTo { dest: d }, node, k); + } + Action::OpenUrl { url } => { + widgets::sublabels(ui, "Link", None); + row(ui, 32.0, |ui| { + if let Some(u) = widgets::text_field(ui, Id::new(("proto_url", node, k)), url, ui.available_width(), "https://") + && &u != url + { + patch(app, node, k, json!({"action": Action::OpenUrl { url: u.trim().to_string() }})); + } + }); + } + Action::SetVariable { variable, value } => { + let vars = doc.variables.variables.clone(); + widgets::sublabels(ui, "Variable", None); + row(ui, 32.0, |ui| { + let name = doc.variables.get(variable).map(|v| v.name.clone()).unwrap_or_else(|| "Choose variable".into()); + let resp = widgets::dropdown(ui, &name, ui.available_width(), None); + egui::Popup::menu(&resp).show(|ui| { + for v in &vars { + if menu_item(ui, &v.name, "") { + patch(app, node, k, json!({"action": {"type": "setVariable", "variable": v.id, "value": value}})); + ui.close(); + } + } + }); + }); + row(ui, 32.0, |ui| { + widgets::label(ui, "Value", t.text_secondary); + let shown = match value { + Value::String(s) => s.clone(), + Value::Null => String::new(), + other => other.to_string(), + }; + if let Some(s) = widgets::text_field(ui, Id::new(("proto_varval", node, k)), &shown, ui.available_width(), "Value") { + let v = serde_json::from_str::(s.trim()).ok().filter(|v| !v.is_string()).unwrap_or(Value::String(s)); + patch(app, node, k, json!({"action": {"type": "setVariable", "variable": variable, "value": v}})); + } + }); + } + Action::OpenOverlay { dest, position, close_on_outside, background } => { + let (dest, position, close_on_outside, background) = (*dest, *position, *close_on_outside, *background); + let mk = move |d: NodeId| Action::OpenOverlay { dest: d, position, close_on_outside, background }; + dest_dropdown(app, ui, doc, &frames, Some(dest), "Overlay", mk, node, k); + widgets::sublabels(ui, "Overlay position", None); + row(ui, 32.0, |ui| { + let resp = widgets::dropdown(ui, position.label(), ui.available_width(), None); + egui::Popup::menu(&resp).show(|ui| { + for p in OverlayPosition::ALL { + if menu_item(ui, p.label(), "") { + patch(app, node, k, json!({"action": Action::OpenOverlay { dest, position: p, close_on_outside, background }})); + ui.close(); + } + } + }); + }); + row(ui, 28.0, |ui| { + if widgets::checkbox(ui, close_on_outside, "Close when clicking outside") { + patch(app, node, k, json!({"action": Action::OpenOverlay { dest, position, close_on_outside: !close_on_outside, background }})); + } + }); + row(ui, 28.0, |ui| { + if widgets::checkbox(ui, background.is_some(), "Add background behind overlay") { + let bg = if background.is_some() { None } else { Some(figcraft_color::Color::new(0.0, 0.0, 0.0, 0.25)) }; + patch(app, node, k, json!({"action": Action::OpenOverlay { dest, position, close_on_outside, background: bg }})); + } + }); + if let Some(bg) = background { + row(ui, 32.0, |ui| { + widgets::swatch(ui, &Paint::solid(bg), 16.0); + if let Some(h) = widgets::text_field(ui, Id::new(("proto_ovbg", node, k)), &bg.to_hex(), 96.0, "") + && let Some(c) = figcraft_color::Color::from_hex(&h) + { + patch(app, node, k, json!({"action": Action::OpenOverlay { dest, position, close_on_outside, background: Some(c) }})); + } + let mut alpha = (bg.a * 100.0).round(); + if let Some(e) = widgets::number_field(ui, Id::new(("proto_ovbg_a", node, k)), Lead::None, Some(f64::from(alpha)), "%", 64.0, 1.0) + { + let v = match e { + FieldEdit::Set(v) | FieldEdit::Scrub(v, _) => v, + }; + if v.is_finite() { + alpha = v.clamp(0.0, 100.0) as f32; + patch( + app, + node, + k, + json!({"action": Action::OpenOverlay { dest, position, close_on_outside, background: Some(bg.with_alpha(alpha / 100.0)) }}), + ); + } + } + }); + } + } + Action::Back | Action::CloseOverlay => {} + } +} + +fn animation_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Interaction) { + let t = Tokens::get(ui.ctx()); + widgets::sublabels(ui, "Animation", None); + row(ui, 32.0, |ui| { + let resp = widgets::dropdown(ui, it.transition.label(), ui.available_width(), None); + egui::Popup::menu(&resp).show(|ui| { + for (i, l) in TRANSITIONS.iter().enumerate() { + if i == 3 { + ui.separator(); + } + if menu_item(ui, l, "") { + patch(app, node, k, json!({"transition": transition_of(l, it.transition)})); + ui.close(); + } + } + }); + }); + if let Some(d) = it.transition.direction() { row(ui, 32.0, |ui| { - let resp = widgets::dropdown(ui, it.transition.label(), ui.available_width(), None); - let it2 = it.clone(); + let dirs = [Direction::Left, Direction::Right, Direction::Top, Direction::Bottom]; + let items = [(Icon::ChevronLeft, "Left"), (Icon::ChevronRight, "Right"), (Icon::ChevronUp, "Up"), (Icon::Chevron, "Down")]; + let active = dirs.iter().position(|x| *x == d); + if let Some(i) = widgets::segmented(ui, &items, active, 32.0) + && let Some(nd) = dirs.get(i) + { + patch(app, node, k, json!({"transition": it.transition.with_direction(*nd)})); + } + }); + } + if !matches!(it.transition, Transition::Instant) { + row(ui, 32.0, |ui| { + let resp = widgets::dropdown(ui, it.easing.label(), ui.available_width() - 80.0, None); + egui::Popup::menu(&resp).width(220.0).show(|ui| { + for (i, e) in Easing::ALL.iter().enumerate() { + if i == 7 { + ui.separator(); + } + let w = ui.available_width().max(200.0); + let (r, rr) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click()); + if rr.hovered() { + ui.painter().rect_filled(r, CornerRadius::same(5), ACCENT); + } + let col = if rr.hovered() { Color32::WHITE } else { t.text }; + curve(ui.painter(), Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 4.0), vec2(28.0, 18.0)), *e, col, None); + ui.painter().text(pos2(r.min.x + 42.0, r.center().y), Align2::LEFT_CENTER, e.label(), regular(11.5), col); + if rr.clicked() { + patch(app, node, k, json!({"easing": e})); + ui.close(); + } + } + }); + if let Some(e) = widgets::number_field(ui, Id::new(("proto_dur", node, k)), Lead::None, Some(f64::from(it.duration_ms)), "ms", 72.0, 10.0) + { + match e { + FieldEdit::Set(v) => { + if v.is_finite() { + patch(app, node, k, json!({"durationMs": v.clamp(1.0, 10_000.0).round()})); + } + } + FieldEdit::Scrub(v, done) => { + let _ = app.session.begin_interaction("Edit"); + let _ = app + .session + .execute("proto.interaction.set", &json!({"ids": [node], "index": k, "durationMs": v.clamp(1.0, 10_000.0).round()})); + if done { + let _ = app.session.commit_interaction(); + } + } + } + } + }); + // Curve preview, with a dot running along it. + let w = ui.available_width(); + let (r, _) = ui.allocate_exact_size(vec2(w, 72.0), Sense::hover()); + let box_r = Rect::from_min_max(pos2(r.min.x + PAD, r.min.y + 4.0), pos2(r.max.x - PAD, r.max.y - 4.0)); + ui.painter().rect_filled(box_r, CornerRadius::same(5), t.field); + let period = (f64::from(it.duration_ms) / 1000.0).max(0.05) + 0.6; + let phase = ((ui.input(|i| i.time) % period) / (period - 0.6)).min(1.0); + curve(ui.painter(), box_r.shrink2(vec2(10.0, 12.0)), it.easing, ACCENT, Some(phase)); + ui.ctx().request_repaint_after(std::time::Duration::from_millis(33)); + } +} + +/// Draw an easing curve in `r` (x = time, y = progress, up). `dot` marks a point in time. +pub fn curve(p: &egui::Painter, r: Rect, e: Easing, col: Color32, dot: Option) { + let n = 48; + let at = |x: f64| { + let y = e.eval(x); + pos2(r.min.x + r.width() * x as f32, r.max.y - r.height() * y as f32) + }; + let pts: Vec = (0..=n).map(|i| at(f64::from(i) / f64::from(n))).collect(); + p.with_clip_rect(r.expand(r.height() * 0.6)).add(Shape::line(pts, Stroke::new(1.5, col))); + if let Some(x) = dot { + p.circle_filled(at(x.clamp(0.0, 1.0)), 3.0, col); + } +} + +fn scroll_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], node: &Node) { + let t = Tokens::get(ui.ctx()); + let in_frame = node.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like()); + let is_frame = node.kind.is_frame_like(); + if !in_frame && !is_frame { + return; + } + widgets::section_header(ui, "Scroll behavior", &[], false); + if in_frame { + row(ui, 32.0, |ui| { + widgets::label(ui, "Position", t.text_secondary); + let cur = match node.scroll_position { + ScrollPosition::Scrolls => "Scroll with parent", + ScrollPosition::Fixed => "Fixed", + ScrollPosition::Sticky => "Sticky", + }; + let resp = widgets::dropdown(ui, cur, ui.available_width(), None); egui::Popup::menu(&resp).show(|ui| { - for (l, tr) in [ - ("Instant", proto::Transition::Instant), - ("Dissolve", proto::Transition::Dissolve), - ("Smart animate", proto::Transition::SmartAnimate), - ("Move in", proto::Transition::MoveIn { direction: proto::Direction::Left }), - ("Push", proto::Transition::Push { direction: proto::Direction::Left }), - ("Slide in", proto::Transition::SlideIn { direction: proto::Direction::Left }), - ] { + for (l, v) in [("Scroll with parent", "scrolls"), ("Fixed", "fixed"), ("Sticky", "sticky")] { if menu_item(ui, l, "") { - let mut ni = it2.clone(); - ni.transition = tr; - app.run("proto.interaction.set", json!({"ids": ids, "index": i, "interaction": ni})); + app.set_props(sel, json!({"scrollPosition": v})); ui.close(); } } }); }); } - if doc.top_level_frame(first) == Some(first) { - widgets::separator(ui); - widgets::section_header(ui, "Flow starting point", &[(Icon::Plus, "Add starting point")], false); + if is_frame { row(ui, 32.0, |ui| { - if ui.button("Set as flow starting point").clicked() { - app.run("proto.flow.add", json!({"start": first})); - } - }); - widgets::separator(ui); - widgets::section_header(ui, "Scroll behavior", &[], false); - let ov = doc.node(first).map(|n| n.overflow).unwrap_or_default(); - row(ui, 32.0, |ui| { - let resp = widgets::dropdown(ui, &format!("Overflow: {ov:?}"), ui.available_width(), None); + widgets::label(ui, "Overflow", t.text_secondary); + let cur = match node.overflow { + Overflow::None => "No scrolling", + Overflow::Horizontal => "Horizontal", + Overflow::Vertical => "Vertical", + Overflow::Both => "Both directions", + }; + let resp = widgets::dropdown(ui, cur, ui.available_width(), None); egui::Popup::menu(&resp).show(|ui| { for (l, v) in [("No scrolling", "none"), ("Horizontal", "horizontal"), ("Vertical", "vertical"), ("Both directions", "both")] { if menu_item(ui, l, "") { - app.set_props(&ids, json!({"overflow": v})); + app.set_props(sel, json!({"overflow": v})); ui.close(); } }