M5: prototyping depth: on-canvas connections with drag-to-connect, interaction editor, flows, smart animate presenter, overlays, scroll behaviour

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-09 14:16:27 -04:00
parent b943e12f3d
commit 07e56aed48
7 changed files with 2830 additions and 372 deletions

View File

@ -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<Direction> {
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<figcraft_color::Color>,
},
SwapOverlay {
dest: NodeId,
@ -232,7 +343,7 @@ pub struct Interaction {
pub actions: Vec<Action>,
#[serde(default)]
pub transition: Transition,
#[serde(default = "default_duration")]
#[serde(default = "default_duration", alias = "durationMs")]
pub duration_ms: u32,
#[serde(default)]
pub easing: Easing,

View File

@ -13,44 +13,80 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
]
}
/// 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<u32> {
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<Value> {
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<Action> = match parse::<Vec<Action>>(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<Value> {
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<Interaction> = parse(cmd, p, "interaction")?;
let trigger: Option<Trigger> = parse(cmd, p, "trigger")?;
let action: Option<Action> = parse(cmd, p, "action")?;
let actions: Option<Vec<Action>> = parse(cmd, p, "actions")?;
let transition: Option<Transition> = parse(cmd, p, "transition")?;
let easing: Option<Easing> = 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<Flow> {
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::<Vec<_>>(), ["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);
}
}

View File

@ -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)

View File

@ -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;

File diff suppressed because it is too large Load Diff

View File

@ -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<Rect> {
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<Noodle> {
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<NodeId> {
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<Drag> = 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<Drag> = ctx.data(|d| d.get_temp(drag_id()));
if !active(app) {
if drag.is_some() {
ctx.data_mut(|d| d.remove::<Drag>(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>(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);
}
}

View File

@ -1,43 +1,284 @@
//! 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<NodeId> {
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<NodeId> {
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<Action> {
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) {
open_editor(ui.ctx(), None);
flows_overview(app, ui, &doc, page);
widgets::separator(ui);
settings(app, ui, &doc, page);
return;
}
let first = sel.first().copied().unwrap_or(NodeId(0));
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<proto::Flow> {
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() {
}
}
/// 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}));
}
widgets::label(ui, &f.name, t.text);
});
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}));
}
widgets::separator(ui);
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("Every top-level frame already starts a flow"),
}
}
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| {
@ -49,6 +290,7 @@ pub fn prototype(app: &mut FigApp, ui: &mut Ui) {
}
});
});
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, "")
@ -57,90 +299,529 @@ pub fn prototype(app: &mut FigApp, ui: &mut Ui) {
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| {
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<Rect> = 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::<Vec<_>>().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;
}
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}));
}
None => app.toast("Add another frame to navigate to"),
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);
}
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);
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::<bool>(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 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();
let resp = widgets::dropdown(ui, trigger_kind(&it.trigger), ui.available_width(), None);
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}));
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();
}
}
});
if icons::button(ui, Icon::Minus, 24.0, false, "Remove").clicked() {
app.run("proto.interaction.remove", json!({"ids": ids, "index": i}));
});
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::<bool>(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<NodeId>,
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();
}
}
});
});
});
}
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<NodeId> = 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::<Value>(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);
let it2 = it.clone();
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 (i, l) in TRANSITIONS.iter().enumerate() {
if i == 3 {
ui.separator();
}
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}));
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 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<f64>) {
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<Pos2> = (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, v) in [("Scroll with parent", "scrolls"), ("Fixed", "fixed"), ("Sticky", "sticky")] {
if menu_item(ui, l, "") {
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();
}
}