From 77a0e33b618ddb7750f6c25871cc7b31195ba8fe Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 10 Oct 2026 06:54:45 +0700 Subject: [PATCH] M5: scrolling inside device frames and a prototype preview pane (#48) * M5: scrolling inside device frames and a prototype preview pane On a device, the presented top-level frame is sized to the device's screen: what doesn't fit is clipped, or scrolls (wheel and drag) when its overflow allows it. Fixed layers stay on screen, pinned to the edges their constraints name; "Scroll to" moves every scrolling frame around the layer; navigating starts at the top unless the new "Preserve scroll position" option keeps it. The preview pane is a resizable window over the canvas running the same player (present.rs now shares layout, drawing and input between the full-window presenter and the pane). It follows edits live, keeps the current screen while it exists, shows a newly selected top-level frame, and takes the keys its prototype uses only while hovered. Opened from the play menu, the Prototype tab (flows, settings, frame) and `proto.preview {start?}` / `proto.closePreview`; the control channel gets `ui.set {preview, presentScroll}` and the player's state in `ui.inspect`. Alt+Cmd+Enter now presents. Co-Authored-By: Claude Opus 5.5 * M5: reconcile with main: one device list, REST navigate keeps preserveScrollPosition Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- README.md | 2 +- ROADMAP.md | 8 +- crates/doc/src/lib.rs | 2 +- crates/doc/src/proto.rs | 39 +- crates/doc/src/tests.rs | 4 +- crates/engine/src/cmd/proto.rs | 97 +- crates/engine/src/import_export_tests.rs | 2 +- crates/format/src/rest/proto.rs | 6 +- .../format/src/rest/proto_variable_tests.rs | 6 +- crates/ui-egui/src/control.rs | 22 +- crates/ui-egui/src/icons.rs | 8 + crates/ui-egui/src/lib.rs | 15 + crates/ui-egui/src/present.rs | 905 ++++++++++++++---- crates/ui-egui/src/present_tests.rs | 193 ++++ crates/ui-egui/src/proto_panel.rs | 77 +- crates/ui-egui/src/right.rs | 20 +- crates/ui-egui/src/shortcuts.rs | 7 + docs/control-protocol.md | 12 +- docs/mcp.md | 13 + 19 files changed, 1168 insertions(+), 270 deletions(-) create mode 100644 crates/ui-egui/src/present_tests.rs diff --git a/README.md b/README.md index 41fdd3701..b409af7fe 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@

Prototype and hand off

- Connections, triggers, actions and Smart Animate-style transitions, flows and a presentation view. Inspect mode with measurements, CSS and SVG. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF. + Connections, triggers, actions and Smart Animate-style transitions, flows, a presentation view with device frames and a live preview pane. Inspect mode with measurements, CSS and SVG. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF. diff --git a/ROADMAP.md b/ROADMAP.md index b554fcdb6..48263d4ca 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -75,7 +75,11 @@ dimension measured side by side. Custom bezier and custom spring easings with editor fields; device frames (phone, tablet, laptop, desktop bodies drawn in code) around the presented frame; overlays animate out with their opening transition reversed; several selected layers edit their interactions together - (or replace mixed ones). *Left:* scrolling inside device frames. 3–5 h. + (or replace mixed ones); scrolling inside device frames (the frame sized to the device's screen, + clipped or scrolling by wheel and drag, fixed layers pinned to the screen by their constraints, + nested "Scroll to", preserve scroll position); a resizable preview pane over the canvas that + plays the selected frame and follows edits live. *Left:* device rotation and presentation + scaling options, scroll momentum. 1–3 h. 6. **Interop**: *landed:* image crop handles; REST API file JSON import (`file.open` / `file.import` / CLI: pages, layer tree, geometry, paints, strokes, effects, text runs, vectors from geometry paths, auto layout, constraints, grids, components, sets, instances with overrides, component @@ -166,7 +170,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent). | Text | 8 | 67% | right-to-left layer names in the panels, text style picker depth, axis hover preview | 5–8 | | Components & variants | 9 | 65% | slots, nested override edge cases, library variables | 7–11 | | Styles & variables | 5 | 72% | binding effects, line height and letter spacing, variable groups in the editor sidebar | 3–5 | -| Prototyping | 6 | 70% | scrolling inside device frames, prototype preview pane in the editor | 3–5 | +| Prototyping | 6 | 76% | device rotation, presentation scaling options, scroll momentum | 1–3 | | Import/export | 6 | 58% | PDF (in progress), .fig import, REST import of conditional and variable-mode prototype actions | 13–25 | | Dev mode / inspect | 2 | 72% | code for whole frames as components | 1–3 | | Comments, versions, misc | 3 | 45% | comment polish, plugins (WASM) | 9–15 | diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 5ddf68bb1..bf019120d 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -756,7 +756,7 @@ impl Document { fn remap_action(a: &mut proto::Action, map: &BTreeMap) { use proto::Action::*; match a { - Navigate { dest } | ScrollTo { dest } | OpenOverlay { dest, .. } | SwapOverlay { dest } | ChangeTo { dest } => { + Navigate { dest, .. } | ScrollTo { dest } | OpenOverlay { dest, .. } | SwapOverlay { dest } | ChangeTo { dest } => { if let Some(m) = map.get(dest) { *dest = *m; } diff --git a/crates/doc/src/proto.rs b/crates/doc/src/proto.rs index 1b3a0c199..5ceb23ca0 100644 --- a/crates/doc/src/proto.rs +++ b/crates/doc/src/proto.rs @@ -350,6 +350,9 @@ impl OverlayPosition { pub enum Action { Navigate { dest: NodeId, + /// Keep the scroll position of the frame being left (else the destination starts at the top). + #[serde(default, alias = "preserveScroll", skip_serializing_if = "is_false")] + preserve_scroll: bool, }, Back, ScrollTo { @@ -398,7 +401,7 @@ impl Action { } pub fn dest(&self) -> Option { match self { - Action::Navigate { dest } + Action::Navigate { dest, .. } | Action::ScrollTo { dest } | Action::OpenOverlay { dest, .. } | Action::SwapOverlay { dest } @@ -425,6 +428,10 @@ fn default_duration() -> u32 { 300 } +fn is_false(b: &bool) -> bool { + !*b +} + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub enum Overflow { @@ -461,13 +468,33 @@ pub struct PrototypeSettings { pub background: figcraft_color::Color, } -impl PrototypeSettings { - /// The devices the presenter draws a frame for ("None" = no device). - pub const DEVICES: [&'static str; 7] = ["None", "iPhone 16", "iPhone 16 Pro Max", "Android Compact", "iPad mini", "MacBook Air", "Desktop"]; -} - impl Default for PrototypeSettings { fn default() -> Self { Self { device: "None".into(), background: figcraft_color::Color::from_rgb8_const(0x1E, 0x1E, 0x1E) } } } + +/// A device prototypes are presented on: its name and screen size in points. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Device { + pub name: &'static str, + pub width: f64, + pub height: f64, +} + +impl PrototypeSettings { + /// The devices to pick from ("None" presents the frame at its own size). + pub const DEVICES: [Device; 6] = [ + Device { name: "iPhone 16", width: 393.0, height: 852.0 }, + Device { name: "iPhone 16 Pro Max", width: 440.0, height: 956.0 }, + Device { name: "Android Compact", width: 412.0, height: 917.0 }, + Device { name: "iPad mini", width: 744.0, height: 1133.0 }, + Device { name: "MacBook Air", width: 1280.0, height: 832.0 }, + Device { name: "Desktop", width: 1440.0, height: 1024.0 }, + ]; + + /// The chosen device (None for "None" or a name we don't know). + pub fn device(&self) -> Option { + Self::DEVICES.iter().find(|d| d.name == self.device).copied() + } +} diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index cb028e023..3cfd8b4f4 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -93,7 +93,7 @@ fn duplicate_remaps_ids_and_interactions() { let b = d.create(f, NodeKind::Rectangle, "B", 20.0, 0.0, 10.0, 10.0).unwrap(); d.get_mut(a).unwrap().interactions.push(proto::Interaction { trigger: proto::Trigger::OnClick, - actions: vec![proto::Action::Navigate { dest: b }], + actions: vec![proto::Action::Navigate { dest: b, preserve_scroll: false }], transition: proto::Transition::Instant, duration_ms: 300, easing: proto::Easing::EaseOut, @@ -103,7 +103,7 @@ fn duplicate_remaps_ids_and_interactions() { assert_eq!(kids.len(), 2); assert!(!kids.contains(&a)); let a2 = d.node(kids[0]).unwrap(); - let proto::Action::Navigate { dest } = a2.interactions[0].actions[0] else { panic!() }; + let proto::Action::Navigate { dest, .. } = a2.interactions[0].actions[0] else { panic!() }; assert_eq!(dest, kids[1]); assert!(d.validate().is_empty()); } diff --git a/crates/engine/src/cmd/proto.rs b/crates/engine/src/cmd/proto.rs index 1d9759eee..e3c71a69f 100644 --- a/crates/engine/src/cmd/proto.rs +++ b/crates/engine/src/cmd/proto.rs @@ -13,7 +13,7 @@ pub fn specs() -> Vec { spec!( "proto.interaction.add", "Add interaction", - "{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|{customBezier {x1, y1, x2, y2}}|{customSpring {mass, stiffness, damping}}} — or {id, dest} for a click-navigate. Returns {index}", + "{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key}}, action {type: navigate {preserveScroll?}|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|{customBezier {x1, y1, x2, y2}}|{customSpring {mass, stiffness, damping}}} — or {id, dest} for a click-navigate. Returns {index}", true, add ), @@ -120,25 +120,49 @@ pub fn specs() -> Vec { } ok() }), - spec!("proto.present", "Present", "{start? (frame id)}", false, |s, p| { - let start = id(p, "start").or_else(|| { - let ds = s.ds().ok()?; - ds.selection - .first() - .and_then(|i| ds.doc.top_level_frame(*i)) - .or_else(|| match &ds.doc.node(ds.page)?.kind { - NodeKind::Page { flows, .. } => flows.first().map(|f| f.start), - _ => None, - }) - .or_else(|| ds.doc.top_frames(ds.page).first().copied()) - }); - let start = start.ok_or_else(|| bad("proto.present", "no frame to present"))?; - s.request_ui("present", json!({"start": start})); - Ok(json!({"start": start})) + spec!( + "proto.present", + "Present", + "{start? (frame id; default: the selected frame, else the first flow, else the first frame)}", + false, + |s, p| { open_player(s, p, "proto.present", "present") } + ), + spec!( + "proto.preview", + "Open prototype preview", + "{start? (frame id; default as proto.present)} a floating preview pane over the canvas that follows edits live", + false, + |s, p| open_player(s, p, "proto.preview", "preview") + ), + spec!("proto.closePreview", "Close prototype preview", "{}", false, |s, _| { + s.request_ui("preview.close", json!({})); + ok() }), ] } +/// Ask the UI to present (full screen or in the preview pane) from `start`, else the selected +/// top-level frame, the page's first flow or its first frame. +fn open_player(s: &mut Session, p: &Value, cmd: &str, ui: &str) -> Result { + let ds = s.ds()?; + let start = match id(p, "start") { + // A layer inside a frame presents its top-level frame. + Some(f) => ds.doc.top_level_frame(f).ok_or_else(|| bad(cmd, "`start` isn't a frame or inside one"))?, + None => ds + .selection + .first() + .and_then(|i| ds.doc.top_level_frame(*i)) + .or_else(|| match &ds.doc.node(ds.page)?.kind { + NodeKind::Page { flows, .. } => flows.first().map(|f| f.start), + _ => None, + }) + .or_else(|| ds.doc.top_frames(ds.page).first().copied()) + .ok_or_else(|| bad(cmd, "no frame to present"))?, + }; + s.request_ui(ui, json!({"start": start})); + Ok(json!({"start": start})) +} + /// Every node an action points at must exist. fn check_dests(cmd: &str, s: &Session, it: &Interaction) -> Result<()> { let doc = s.doc()?; @@ -163,7 +187,7 @@ fn add(s: &mut Session, p: &Value) -> Result { { Interaction { trigger: parse(cmd, p, "trigger")?.unwrap_or_default(), - actions: vec![Action::Navigate { dest: d }], + actions: vec![Action::Navigate { dest: d, preserve_scroll: false }], transition: parse(cmd, p, "transition")?.unwrap_or_default(), duration_ms: duration(p).unwrap_or(300), easing: parse(cmd, p, "easing")?.unwrap_or_default(), @@ -283,7 +307,7 @@ mod tests { 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.actions, vec![Action::Navigate { dest: b, preserve_scroll: false }]); assert_eq!(it.transition, Transition::Push { direction: Direction::Top }); assert_eq!(it.easing, Easing::Linear); // Overlay action with settings; camelCase accepted. @@ -343,6 +367,43 @@ mod tests { assert_eq!(r["name"], "Flow 2"); } + #[test] + fn present_and_preview_pick_a_top_level_frame() { + let mut s = Session::new(); + let a = frame(&mut s, 0.0); + let b = frame(&mut s, 200.0); + let r = s.execute("node.create", &json!({"type": "rectangle", "parent": b, "x": 205, "y": 5, "width": 10, "height": 10})).unwrap(); + let child = id_of(&r["id"]).unwrap(); + let last = |s: &mut Session| { + let r = s.ui_requests.pop().unwrap(); + s.ui_requests.clear(); + (r.kind, id_of(&r.data["start"])) + }; + // A layer inside a frame previews its frame; the selection is the default. + assert_eq!(s.execute("proto.preview", &json!({"start": child})).unwrap()["start"], json!(b)); + assert_eq!(last(&mut s), ("preview".to_string(), Some(b))); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + s.execute("proto.present", &json!({})).unwrap(); + assert_eq!(last(&mut s), ("present".to_string(), Some(a))); + // A start that isn't in a frame is an error, not a silent fallback. + assert!(s.execute("proto.preview", &json!({"start": 987_654})).is_err()); + s.execute("proto.closePreview", &json!({})).unwrap(); + assert_eq!(last(&mut s).0, "preview.close"); + } + + #[test] + fn navigate_can_preserve_scroll() { + let mut s = Session::new(); + let a = frame(&mut s, 0.0); + let b = frame(&mut s, 200.0); + s.execute("proto.interaction.add", &json!({"id": a, "action": {"type": "navigate", "dest": b, "preserveScroll": true}})).unwrap(); + let it = s.doc().unwrap().node(a).unwrap().interactions[0].clone(); + assert_eq!(it.actions, vec![Action::Navigate { dest: b, preserve_scroll: true }]); + // Off is the default and isn't written out. + let off = serde_json::to_value(Action::Navigate { dest: b, preserve_scroll: false }).unwrap(); + assert!(off.get("preserve_scroll").is_none(), "{off}"); + } + #[test] fn scroll_props_via_node_set() { let mut s = Session::new(); diff --git a/crates/engine/src/import_export_tests.rs b/crates/engine/src/import_export_tests.rs index 2027d225b..7867cebce 100644 --- a/crates/engine/src/import_export_tests.rs +++ b/crates/engine/src/import_export_tests.rs @@ -68,7 +68,7 @@ fn open_reads_variables_from_param_or_next_to_the_file() { let button = named(&s, "Button"); assert_eq!(button.bound.get("fill").map(String::as_str), Some(BRAND)); let details = named(&s, "Details").id; - assert_eq!(button.interactions[0].actions, [Action::Navigate { dest: details }]); + assert_eq!(button.interactions[0].actions, [Action::Navigate { dest: details, preserve_scroll: false }]); assert_eq!(button.interactions[0].trigger, Trigger::OnClick); // The binding is live: switching the mode recolours the button. s.execute("variable.setMode", &json!({"collection": "Theme", "mode": "Dark"})).unwrap(); diff --git a/crates/format/src/rest/proto.rs b/crates/format/src/rest/proto.rs index 15e25990f..6f38d56ec 100644 --- a/crates/format/src/rest/proto.rs +++ b/crates/format/src/rest/proto.rs @@ -113,7 +113,7 @@ impl Ctx<'_> { let ms = num(j, "transitionDuration").map(|d| d.clamp(0.0, MAX_MS) as u32).unwrap_or(0); Some(Interaction { trigger: Trigger::OnClick, - actions: vec![Action::Navigate { dest }], + actions: vec![Action::Navigate { dest, preserve_scroll: false }], transition: if ms > 0 { Transition::Dissolve } else { Transition::Instant }, duration_ms: if ms > 0 { ms } else { DEFAULT_MS }, easing: easing_type(text(j, "transitionEasing"), None), @@ -233,7 +233,9 @@ impl Ctx<'_> { return None; }; Some(match text(a, "navigation") { - Some("NAVIGATE") | None => Action::Navigate { dest }, + Some("NAVIGATE") | None => { + Action::Navigate { dest, preserve_scroll: a.get("preserveScrollPosition").and_then(Value::as_bool).unwrap_or(false) } + } Some("SWAP") => Action::SwapOverlay { dest }, Some("SCROLL_TO") => Action::ScrollTo { dest }, Some("CHANGE_TO") => Action::ChangeTo { dest }, diff --git a/crates/format/src/rest/proto_variable_tests.rs b/crates/format/src/rest/proto_variable_tests.rs index 7efa6ae3d..2f3db251e 100644 --- a/crates/format/src/rest/proto_variable_tests.rs +++ b/crates/format/src/rest/proto_variable_tests.rs @@ -213,7 +213,7 @@ fn interactions_triggers_actions_and_transitions() { assert_eq!(its.len(), 7, "{its:#?}"); assert_eq!(its[0].trigger, Trigger::OnClick); - assert_eq!(its[0].actions, [Action::Navigate { dest: id("Details") }]); + assert_eq!(its[0].actions, [Action::Navigate { dest: id("Details"), preserve_scroll: false }]); assert_eq!((its[0].transition, its[0].duration_ms), (Transition::SmartAnimate, 450)); assert_eq!(its[0].easing, Easing::CustomBezier { x1: 0.1, y1: 0.2, x2: 0.3, y2: 1.4 }); @@ -239,7 +239,7 @@ fn interactions_triggers_actions_and_transitions() { let details = named(&doc, "Details"); assert_eq!(details.interactions.len(), 1); let legacy = &details.interactions[0]; - assert_eq!((&legacy.trigger, &legacy.actions[..]), (&Trigger::OnClick, &[Action::Navigate { dest: id("Home") }][..])); + assert_eq!((&legacy.trigger, &legacy.actions[..]), (&Trigger::OnClick, &[Action::Navigate { dest: id("Home"), preserve_scroll: false }][..])); assert_eq!((legacy.transition, legacy.duration_ms, legacy.easing), (Transition::Dissolve, 200, Easing::EaseIn)); for w in ["change variable modes", "video triggers", "modifier keys", "without a destination"] { @@ -286,7 +286,7 @@ fn scrolling_flows_and_prototype_device() { ] { let got = device(d); assert_eq!(got, want); - assert!(PrototypeSettings::DEVICES.contains(&got.as_str())); + assert!(got == "None" || PrototypeSettings::DEVICES.iter().any(|d| d.name == got), "{got}"); } } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 633693348..ef72b5c10 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -10,7 +10,7 @@ //! - `ui.key {key, shift?, cmd?, alt?}` — a shortcut as if typed on the canvas //! - `ui.type {text}` — type into the text being edited //! - `ui.menu.invoke {id}` — a menu item exactly like clicking it (may open a dialog) -//! - `ui.set {rightTab?, leftTab?, theme?, home?, palette?}` +//! - `ui.set {rightTab?, leftTab?, theme?, home?, palette?, preview?, presentScroll?, …}` //! - `ui.screenshot {path}` — PNG of the window (needs a visible window) //! - `ui.render {path, scale?, ids?}` — PNG of the document (no UI chrome; works without a window) //! - `app.quit {}` @@ -252,6 +252,26 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul if p.get("collapseLayers").and_then(Value::as_bool) == Some(true) { app.ui.expanded.clear(); } + // The prototype preview pane: true / a frame id opens it, false or null closes it. + match p.get("preview") { + Some(Value::Bool(false) | Value::Null) => app.ui.preview = None, + Some(v @ (Value::Bool(true) | Value::Number(_))) => { + let params = if v.is_number() { json!({"start": v}) } else { json!({}) }; + app.session.execute("proto.preview", ¶ms).map_err(|e| e.to_string())?; + app.handle_ui_requests(); + } + _ => {} + } + // Scroll the presented screen (the full-window player, else the preview pane) to [x, y]. + if let Some(s) = p.get("presentScroll").and_then(Value::as_array) { + let at = |i: usize| s.get(i).and_then(Value::as_f64).unwrap_or(0.0); + let player = match (app.ui.present.as_mut(), app.ui.preview.as_mut()) { + (Some(p), _) => Some(p), + (None, Some(pane)) => Some(&mut pane.player), + _ => None, + }; + player.ok_or("nothing is presented")?.scroll_to(at(0), at(1)); + } if let Some(v) = p.get("exportPreview").and_then(Value::as_bool) { app.ui.export_thumb = v; } diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index e1ae03196..8ba707bbd 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -123,6 +123,8 @@ pub enum Icon { Swap, Link, Prototype, + /// The prototype preview pane: a small window playing a frame. + Preview, Section2, Logo, /// Ignore auto layout: a layer pinned at an offset inside its frame. @@ -701,6 +703,12 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { p.poly(&[(6.0, 10.0), (9.0, 6.0), (12.5, 4.0)], false); p.poly(&[(10.0, 3.0), (12.8, 3.8), (12.0, 6.6)], false); } + Preview => { + // A window with a title strip and a play triangle in it. + p.rect(2.5, 2.5, 13.5, 13.5, 2.0); + p.line(2.5, 5.0, 13.5, 5.0); + p.fill_poly(&[(6.5, 7.0), (10.5, 9.25), (6.5, 11.5)]); + } AbsolutePosition => { // The frame, a small layer near its top-right corner, and the two offsets pinning it. p.rect(2.5, 2.5, 13.5, 13.5, 1.5); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fa993e2e4..1268b44c2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -43,6 +43,8 @@ pub mod widgets; mod layers_drag_tests; #[cfg(test)] mod perf_tests; +#[cfg(test)] +mod present_tests; use std::collections::BTreeSet; use std::path::PathBuf; @@ -122,6 +124,8 @@ pub struct UiState { pub home: bool, pub toast: Option<(String, f64)>, pub present: Option, + /// The prototype preview pane floating over the canvas, when open. + pub preview: Option, pub picker: Option, pub context_menu: Option<(egui::Pos2, Vec)>, /// Layers panel search: open, the filter text, the shift-click range anchor and the selection @@ -177,6 +181,7 @@ impl Default for UiState { home: false, toast: None, present: None, + preview: None, picker: None, context_menu: None, layer_search: false, @@ -279,6 +284,12 @@ impl FigApp { self.ui.present = Some(present::Presenter::new(start)); } } + "preview" => { + if let Some(start) = r.data.get("start").and_then(figcraft_engine::params::id_of) { + self.ui.preview = Some(present::Pane::new(start, &self.session)); + } + } + "preview.close" => self.ui.preview = None, "open.help" => self.services.open_url("https://getartcraft.com/apps/figcraft"), "layers.collapseAll" => self.ui.expanded.clear(), "comment.new" => { @@ -431,6 +442,8 @@ impl FigApp { toolbar::show(self, ui); } }); + // Before the shortcuts: keys the hovered pane's prototype uses don't reach the editor. + present::show_pane(self, &ctx); shortcuts::handle(self, &ctx); menus::popups(self, &ctx); variable_ui::picker(self, &ctx); @@ -476,6 +489,8 @@ impl FigApp { "home": self.ui.home, "dark": self.ui.dark, "presenting": self.ui.present.is_some(), + "presenter": self.ui.present.as_ref().map(present::Presenter::inspect), + "preview": self.ui.preview.as_ref().map(|p| p.player.inspect()), "mainMenu": self.ui.main_menu, "palette": self.ui.palette.is_some(), "variables": self.ui.variables.is_some(), diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index cefbebec0..040abc6f6 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -1,23 +1,28 @@ -//! The prototype player: presents a frame full-window and follows its interactions. +//! The prototype player: presents a frame full-window ([`show`]) or in the preview pane floating +//! over the canvas ([`show_pane`]), and follows its interactions. Both run the same [`player`]. //! //! 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`]). +//! offsets change it without touching the user's file) and copies the editor's document again +//! whenever it changes, so the preview pane follows edits live. On a device, the presented frame +//! is sized to the device's screen ([`fit_viewport`]): what doesn't fit is clipped, or scrolls when +//! the frame's overflow allows it. 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 std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; +use std::sync::Arc; -use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2}; +use egui::{Align2, Color32, CornerRadius, Id, 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_doc::proto::{Action, Device, Direction, Easing, Overflow, OverlayPosition, PrototypeSettings, ScrollPosition, Transition, Trigger}; +use figcraft_doc::{Document, HConstraint, Node, NodeId, NodeKind, VConstraint, VariableValue}; use figcraft_geom::{Affine, Point}; +use serde_json::{Value, json}; use crate::FigApp; -use crate::theme::{ACCENT, regular, semibold}; - -const BAR_H: f32 = 40.0; +use crate::icons::{self, Icon}; +use crate::theme::{ACCENT, Tokens, regular, semibold}; /// A page in the player's history (what Back returns to, and how). #[derive(Clone, Debug)] @@ -62,10 +67,12 @@ struct Anim { easing: Easing, } +/// A frame scrolling from one offset to another. +type ScrollMove = (NodeId, (f64, f64), (f64, f64)); + +/// "Scroll to" in progress: each moving frame with its offsets from and to. struct ScrollAnim { - frame: NodeId, - from: (f64, f64), - to: (f64, f64), + frames: Vec, start: f64, dur: f64, easing: Easing, @@ -117,6 +124,10 @@ pub struct Presenter { dragged: bool, flash_until: f64, urls: Vec, + /// The editor's document as last copied (the player copies it again when it changes). + source: Option>, + /// Scrolling by dragging: the frame and its offset when the drag began. + drag_scroll: Option<(NodeId, (f64, f64))>, } impl Presenter { @@ -157,21 +168,121 @@ impl Presenter { dragged: false, flash_until: 0.0, urls: Vec::new(), + source: None, + drag_scroll: None, } } - /// The player's document with scroll offsets applied (cached per revision). + /// Follow the editor's document: copy it the first time and whenever it changed, keeping the + /// screen, the overlays and the scroll offsets that still exist (a deleted screen goes back to + /// the start). Runtime state (variables set, variants changed) starts over from the edit. + pub fn sync(&mut self, doc: &Arc, now: f64) { + if self.source.as_ref().is_some_and(|s| Arc::ptr_eq(s, doc)) { + return; + } + let first = self.source.is_none(); + self.source = Some(doc.clone()); + let copy = Document::clone(doc); + self.original = Some(copy.clone()); + self.doc = Some(copy); + if first { + self.arrived = now; + return; + } + if !doc.contains(self.current) { + if doc.contains(self.start) { + self.restart(self.start, now); + } + return; + } + self.history.retain(|v| doc.contains(v.frame)); + self.overlays.retain(|o| doc.contains(o.id)); + self.closing.retain(|(_, o)| doc.contains(o.id)); + // These hold states of the document before the edit. + self.temp = None; + self.anim = None; + self.scroll_anim = None; + self.drag_scroll = None; + self.scroll.retain(|f, _| doc.contains(*f)); + let clamped: Vec<(NodeId, (f64, f64))> = self.scroll.iter().map(|(f, s)| (*f, clamp_scroll(*s, self.range(doc, *f)))).collect(); + self.scroll.extend(clamped); + self.rev += 1; + } + + /// The player's document with scroll offsets applied and the presented frame sized to the + /// device's screen (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); + let doc = self.doc.as_ref()?; + let mut d = apply_scroll(doc, &self.scroll); + if let Some(vp) = viewport(doc, self.current) { + fit_viewport(&mut d, self.current, vp); + } self.display = Some((self.rev, d.clone())); Some(d) } + /// How far `frame` scrolls (in `doc`, without scroll offsets): the presented frame within the + /// device's screen, other frames within themselves. + fn range(&self, doc: &Document, frame: NodeId) -> (f64, f64) { + let vp = if frame == self.current { viewport(doc, frame) } else { None }; + scroll_range_in(doc, frame, vp) + } + + /// What "Scroll to" `dest` moves: every scrolling frame around it, innermost first, brings the + /// layer (then the frame holding it) to its top-left as far as it can scroll. Each entry is + /// (frame, offset now, offset after). + fn scroll_to_layer(&self, doc: &Document, dest: NodeId) -> Vec { + let mut out = Vec::new(); + let mut target = dest; + for f in doc.ancestors(dest) { + let (rx, ry) = self.range(doc, f); + let Some(tn) = doc.node(target) else { break }; + if rx <= 0.0 && ry <= 0.0 { + continue; + } + let rel = doc.world_transform(f).inverse() * doc.world_transform(target); + let b = figcraft_geom::transform_rect_bbox(rel, tn.local_rect()); + let from = self.scroll.get(&f).copied().unwrap_or((0.0, 0.0)); + let to = clamp_scroll((if rx > 0.0 { b.x0 } else { from.0 }, if ry > 0.0 { b.y0 } else { from.1 }), (rx, ry)); + out.push((f, from, to)); + target = f; + } + out + } + + /// Scroll the presented frame to `(x, y)` (page units, clamped to what it can scroll). + pub fn scroll_to(&mut self, x: f64, y: f64) { + let Some(doc) = self.doc.as_ref() else { return }; + if !x.is_finite() || !y.is_finite() { + return; + } + let to = clamp_scroll((x, y), self.range(doc, self.current)); + self.scroll.insert(self.current, to); + self.scroll_anim = None; + self.rev += 1; + } + + /// The player's state for `ui.inspect`. + pub fn inspect(&self) -> Value { + let doc = self.doc.as_ref(); + json!({ + "current": self.current, + "name": doc.and_then(|d| d.node(self.current)).map(|n| n.name.as_str()), + "start": self.start, + "history": self.history.iter().map(|v| v.frame).collect::>(), + "overlays": self.overlays.iter().map(|o| o.id).collect::>(), + "scroll": self.scroll.get(&self.current).map_or([0.0, 0.0], |s| [s.0, s.1]), + "scrollRange": doc.map(|d| { let r = self.range(d, self.current); [r.0, r.1] }), + "device": doc.and_then(|d| page_settings(d, self.current).device()).map(|d| d.name), + "viewport": doc.and_then(|d| viewport(d, self.current)).map(|v| [v.0, v.1]), + }) + } + fn snapshot(&self) -> Option { Some(Snapshot { current: self.current, @@ -200,26 +311,38 @@ impl Presenter { self.rev += 1; } - fn go(&mut self, frame: NodeId, transition: Transition, dur: f64, easing: Easing, now: f64, remember: bool) { + /// Show `frame`. It starts scrolled to the top unless `preserve` keeps the scroll position of + /// the frame being left (as far as `frame` can scroll). + fn go(&mut self, frame: NodeId, transition: Transition, dur: f64, easing: Easing, now: f64, remember: bool, preserve: bool) { self.begin(transition, dur, easing, now); if remember { self.history.push(Visit { frame: self.current, transition, dur, easing }); } + let kept = self.scroll.get(&self.current).copied().filter(|_| preserve); self.overlays.clear(); self.scroll.clear(); self.scroll_anim = None; + self.drag_scroll = None; self.temp = None; self.current = frame; + if let Some(s) = kept + && let Some(doc) = self.doc.as_ref() + { + let to = clamp_scroll(s, self.range(doc, frame)); + self.scroll.insert(frame, to); + } self.arrive(now); } - fn restart(&mut self, start: NodeId, now: f64) { + /// Start over at `start` with the document as the editor has it. + pub 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.drag_scroll = None; self.anim = None; self.temp = None; self.doc = self.original.clone(); @@ -234,6 +357,7 @@ impl Presenter { self.overlays = t.snap.overlays; self.history = t.snap.history; self.scroll = t.snap.scroll; + self.drag_scroll = None; self.hovered.clear(); self.rev += 1; } @@ -261,12 +385,12 @@ impl Presenter { 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 } => { + Action::Navigate { dest, preserve_scroll } => { 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); + self.go(top, tr, dur, easing, now, true, *preserve_scroll); true } Action::Back => { @@ -275,29 +399,22 @@ impl Presenter { } match self.history.pop() { Some(v) => { - self.go(v.frame, v.transition.reversed(), v.dur, v.easing, now, false); + self.go(v.frame, v.transition.reversed(), v.dur, v.easing, now, false, 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 { + let frames = self.scroll_to_layer(&doc, *dest); + if frames.is_empty() { 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.scroll.extend(frames.iter().map(|(f, _, to)| (*f, *to))); self.rev += 1; } else { - self.scroll_anim = Some(ScrollAnim { frame, from, to, start: now, dur, easing }); + self.scroll_anim = Some(ScrollAnim { frames, start: now, dur, easing }); } true } @@ -610,6 +727,14 @@ pub fn smart_interpolate(from: &Document, from_root: NodeId, to: &Document, to_r /// 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) { + scroll_range_in(doc, frame, None) +} + +/// How far `frame`'s content can scroll when `viewport` (page units, from its top-left) of it +/// shows, as a device's screen shows a top-level frame: the frame itself is content then, so a +/// frame taller than the screen scrolls through its whole height. Without a viewport the frame +/// shows at its own size and what overflows it scrolls. Only the axes its overflow allows scroll. +pub fn scroll_range_in(doc: &Document, frame: NodeId, viewport: Option<(f64, f64)>) -> (f64, f64) { let Some(n) = doc.node(frame) else { return (0.0, 0.0) }; if n.overflow == Overflow::None { return (0.0, 0.0); @@ -628,11 +753,77 @@ pub fn scroll_range(doc: &Document, frame: NodeId) -> (f64, f64) { 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 }; + let (vw, vh) = match viewport { + Some(v) => { + x1 = x1.max(n.width); + y1 = y1.max(n.height); + v + } + None => (n.width, n.height), + }; + let rx = if matches!(n.overflow, Overflow::Horizontal | Overflow::Both) { (x1 - vw).max(0.0) } else { 0.0 }; + let ry = if matches!(n.overflow, Overflow::Vertical | Overflow::Both) { (y1 - vh).max(0.0) } else { 0.0 }; (if rx.is_finite() { rx } else { 0.0 }, if ry.is_finite() { ry } else { 0.0 }) } +/// `(x, y)` kept within `0..=range` on each axis (0 for non-finite values). +fn clamp_scroll((x, y): (f64, f64), (rx, ry): (f64, f64)) -> (f64, f64) { + let c = |v: f64, r: f64| if v.is_finite() && r.is_finite() { v.clamp(0.0, r.max(0.0)) } else { 0.0 }; + (c(x, rx), c(y, ry)) +} + +/// The prototype settings of the page holding `id` (defaults when there's none). +pub fn page_settings(doc: &Document, id: NodeId) -> PrototypeSettings { + doc.page_of(id) + .and_then(|pg| doc.node(pg)) + .and_then(|n| match &n.kind { + NodeKind::Page { prototype, .. } => Some(prototype.clone()), + _ => None, + }) + .unwrap_or_default() +} + +/// The part of top-level `frame` its page's device shows (page units): the frame clipped to the +/// device's screen. None without a device: the frame then shows whole. +pub fn viewport(doc: &Document, frame: NodeId) -> Option<(f64, f64)> { + let d = page_settings(doc, frame).device()?; + let n = doc.node(frame)?; + Some((n.width.min(d.width), n.height.min(d.height))) +} + +/// Size `frame` to the `(w, h)` of it a screen shows. The content keeps its place (what doesn't +/// fit is clipped or scrolls); fixed layers follow their constraints to the screen's edges, so a +/// tab bar pinned to the frame's bottom sits at the bottom of the screen (stretching constraints +/// keep the layer's size). +pub fn fit_viewport(doc: &mut Document, frame: NodeId, (w, h): (f64, f64)) { + let Some(n) = doc.node(frame) else { return }; + let (dw, dh) = (w - n.width, h - n.height); + if !dw.is_finite() || !dh.is_finite() || (dw == 0.0 && dh == 0.0) { + return; + } + let fixed: Vec = + n.children.iter().copied().filter(|c| doc.node(*c).is_some_and(|c| c.scroll_position == ScrollPosition::Fixed)).collect(); + for c in fixed { + if let Some(m) = doc.node_mut(c) { + let dx = match m.constraints.horizontal { + HConstraint::Right => dw, + HConstraint::Center => dw / 2.0, + _ => 0.0, + }; + let dy = match m.constraints.vertical { + VConstraint::Bottom => dh, + VConstraint::Center => dh / 2.0, + _ => 0.0, + }; + m.transform = Affine::translate((dx, dy)) * m.transform; + } + } + if let Some(m) = doc.node_mut(frame) { + m.width = w; + m.height = h; + } +} + /// 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 { @@ -742,8 +933,9 @@ fn variable_value(values: &BTreeMap, v: &serde_json::Valu }) } -/// Layers with interactions under `root`, with their (clipped) screen rects, bottom-most first. -fn hotspots(doc: &Document, root: NodeId, place: &Place) -> Vec<(NodeId, Rect)> { +/// Layers with interactions under `root`, with their screen rects (clipped by frames that clip +/// content and by `screen`), bottom-most first. +fn hotspots(doc: &Document, root: NodeId, place: &Place, screen: Rect) -> Vec<(NodeId, Rect)> { let mut out = Vec::new(); let mut hidden: HashSet = HashSet::new(); for id in doc.subtree(root) { @@ -756,7 +948,7 @@ fn hotspots(doc: &Document, root: NodeId, place: &Place) -> Vec<(NodeId, Rect)> continue; } let Some(b) = doc.world_bounds(id) else { continue }; - let mut r = place.rect(b); + let mut r = place.rect(b).intersect(screen); let mut cur = n.parent; if id != root { while let Some(a) = cur { @@ -786,94 +978,217 @@ fn find_trigger(doc: &Document, id: NodeId, want: impl Fn(&Trigger) -> bool) -> } // --------------------------------------------------------------------------------------------- -// The player UI. +// The player UI, shared by the full-window presenter and the preview pane. struct Layout { disp: Document, scale: f64, place: Place, - target: Rect, + /// What shows the prototype: the device's screen, else the presented frame itself. + screen: Rect, + /// What the presented frame may draw into: the device's screen, else the whole area (shadows + /// and overflowing content show around a frame presented without a device). + clip: Rect, + device: Option, overlays: Vec<(NodeId, Place, Rect)>, /// Overlays animating out (same order as `Presenter::closing`). closing: Vec<(NodeId, Place, Rect)>, } -/// The page's prototype device ("None" when unset). -fn device_of(p: &Presenter) -> String { - p.doc - .as_ref() - .and_then(|d| d.page_of(p.current).and_then(|pg| d.node(pg))) - .and_then(|n| match &n.kind { - NodeKind::Page { prototype, .. } => Some(prototype.device.clone()), - _ => None, - }) - .unwrap_or_else(|| "None".into()) +/// Bezel width around a device's screen of width `w`, and the screen's corner radius. +fn bezel(device: &str, w: f32) -> (f32, f32) { + match device { + "MacBook Air" => ((w * 0.025).max(6.0), 0.0), + "Desktop" => ((w * 0.02).max(6.0), 0.0), + d if d.starts_with("iPad") => ((w * 0.05).max(8.0), w * 0.04), + _ => ((w * 0.06).max(6.0), w * 0.13), + } } -fn layout(p: &mut Presenter, area: Rect) -> Option { +/// How far a device's body reaches past its screen of width `w`: (each side, top, bottom). +fn body_outset(device: &str, w: f32) -> (f32, f32, f32) { + let (b, _) = bezel(device, w); + match device { + "MacBook Air" => (b + w * 0.08, b, b + (w * 0.022).max(6.0)), + "Desktop" => (b, b, b + w * 0.08 + 6.0), + _ => (b + 3.0, b, b), + } +} + +/// Place the presented frame in `area` (kept `pad` from its edges): on its device's screen, with +/// the whole device fitting, else the frame itself scaled to fit. Never above 100%. +fn layout(p: &mut Presenter, area: Rect, pad: f32) -> Option { let disp = p.display()?; let fb = disp.world_bounds(p.current)?; - // Leave room for the device body around the screen. - let avail = if device_of(p) == "None" { area.shrink(32.0) } else { area.shrink2(vec2(72.0, 80.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 device = page_settings(&disp, p.current).device(); + let avail = area.shrink(pad); + let (sw, sh) = device.map_or((fb.width(), fb.height()), |d| (d.width, d.height)); + let mut scale = (f64::from(avail.width()) / sw.max(1.0)).min(f64::from(avail.height()) / sh.max(1.0)); + if let Some(d) = device { + let (ws, hs) = ((sw * scale) as f32, (sh * scale) as f32); + let (x, top, bottom) = body_outset(d.name, ws); + scale *= f64::from((avail.width() / (ws + 2.0 * x)).min(avail.height() / (hs + top + bottom))); + } + let scale = if scale.is_finite() { scale.clamp(0.01, 1.0) } else { 0.01 }; + let size = vec2((sw * scale) as f32, (sh * scale) as f32); + // Centre the whole device: a laptop's base hangs below its screen. + let shift = device.map_or(0.0, |d| { + let (_, top, bottom) = body_outset(d.name, size.x); + (top - bottom) / 2.0 + }); + let screen = Rect::from_center_size(area.center() + vec2(0.0, shift), size); + let place = Place::centered(fb, screen.center(), scale); let place_overlay = |o: &OverlayState| { let ob = disp.world_bounds(o.id)?; let size = vec2((ob.width() * scale) as f32, (ob.height() * scale) as f32); - let r = overlay_rect(o.position, target, size, o.anchor); + let r = overlay_rect(o.position, screen, size, o.anchor); Some((o.id, Place { page_min: (ob.x0, ob.y0), origin: r.min, scale }, r)) }; let overlays = p.overlays.iter().filter_map(place_overlay).collect(); let closing = p.closing.iter().filter_map(|(_, o)| place_overlay(o)).collect(); - Some(Layout { disp, scale, place, target, overlays, closing }) + let clip = if device.is_some() { screen } else { area }; + Some(Layout { disp, scale, place, screen, clip, device, overlays, closing }) } +/// The keys that present the current frame full-window. +pub const PRESENT_SHORTCUT: &str = "Alt+Cmd+Enter"; + +/// Where the player sits: the whole window or the preview pane. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Chrome { + pane: bool, + bar_h: f32, + /// Space kept around the device or frame. + pad: f32, + /// Corner radius of the window around the player (the pane's). + round: u8, + /// The player may take keys (the pane only while hovered, and never while the editor is + /// typing: text on the canvas or in a field). + keys: bool, +} + +const FULL: Chrome = Chrome { pane: false, bar_h: 40.0, pad: 32.0, round: 0, keys: true }; + +/// What the player's top bar asked for. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum BarAction { + Close, + Restart, + /// Present full-window (from the pane). + Present, +} + +/// The full-window presenter (while `app.ui.present` is set). Escape or Close ends it. 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 }; - if pres.doc.is_none() { - let doc = app.session.doc().ok().cloned(); - pres.original = doc.clone(); - pres.doc = doc; - pres.arrived = now; + if let Ok(ds) = app.session.ds() { + pres.sync(&ds.doc, 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| { - exit = player(&mut pres, ui, &ctx, now); + let mut action = None; + egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| { + action = player(&mut pres, ui, ui.max_rect(), now, FULL); }); for u in pres.urls.drain(..) { app.services.open_url(&u); } - if exit || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + if action == Some(BarAction::Close) || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { return; } app.ui.present = Some(pres); } +/// The prototype preview pane: the player in a floating, resizable window over the canvas. It +/// follows edits live, and selecting another top-level frame in the editor shows that frame. +pub struct Pane { + pub player: Presenter, + /// The open file it previews (another tab closes it). + doc_uid: u64, + /// The top-level frame last selected in the editor. + selected: Option, +} + +/// The top-level frame of the editor's first selected layer. +fn selected_frame(ds: &figcraft_engine::DocState) -> Option { + ds.selection.first().and_then(|i| ds.doc.top_level_frame(*i)) +} + +impl Pane { + pub fn new(start: NodeId, session: &figcraft_engine::Session) -> Self { + let (doc_uid, selected) = session.ds().map(|ds| (ds.uid, selected_frame(ds))).unwrap_or((0, None)); + Pane { player: Presenter::new(start), doc_uid, selected } + } +} + +/// The preview pane's window id (its position and size persist under it). +pub fn pane_id() -> Id { + Id::new("proto_preview_pane") +} + +/// Show the preview pane while `app.ui.preview` is set (call once per frame, before shortcuts: +/// keys a hovered pane's prototype uses don't reach the editor). +pub fn show_pane(app: &mut FigApp, ctx: &egui::Context) { + let Some(mut pane) = app.ui.preview.take() else { return }; + let Ok(ds) = app.session.ds() else { return }; + if ds.uid != pane.doc_uid { + return; + } + let now = ctx.input(|i| i.time); + let (doc, selected) = (ds.doc.clone(), selected_frame(ds)); + pane.player.sync(&doc, now); + // Deselecting and coming back to the same frame keeps the preview where it got to. + if selected.is_some() && selected != pane.selected { + pane.selected = selected; + if let Some(f) = selected.filter(|f| *f != pane.player.current) { + pane.player.restart(f, now); + } + } + let t = Tokens::get(ctx); + let canvas = app.canvas.rect; + let style = ctx.global_style(); + let typing = ds.text_edit.is_some() || ctx.memory(|m| m.focused().is_some()); + let chrome = Chrome { pane: true, bar_h: 36.0, pad: 12.0, round: style.visuals.window_corner_radius.nw, keys: !typing }; + let mut action = None; + egui::Window::new("Prototype preview") + .id(pane_id()) + .title_bar(false) + .collapsible(false) + .resizable(true) + .default_size(vec2(320.0, 600.0)) + .min_size(vec2(220.0, 260.0)) + .default_pos(pos2(canvas.max.x - 336.0, canvas.min.y + 16.0)) + .constrain_to(ctx.content_rect()) + .frame(egui::Frame::window(&style).fill(t.panel).inner_margin(egui::Margin::same(0))) + .show(ctx, |ui| { + let (rect, _) = ui.allocate_exact_size(ui.available_size(), Sense::hover()); + action = player(&mut pane.player, ui, rect, now, chrome); + }); + for u in pane.player.urls.drain(..) { + app.services.open_url(&u); + } + match action { + Some(BarAction::Close) => {} + Some(BarAction::Present) => { + let start = pane.player.current; + app.ui.preview = Some(pane); + app.run("proto.present", json!({"start": start})); + } + _ => app.ui.preview = Some(pane), + } +} + /// Body of the presentation device behind the screen `s` (drawn in code: rounded slabs). -fn device_body(ui: &Ui, device: &str, s: Rect) { - let p = ui.painter(); +fn device_body(p: &egui::Painter, device: &str, s: Rect) { let body = Color32::from_rgb(0x1C, 0x1C, 0x1E); let edge = Color32::from_rgb(0x5A, 0x5A, 0x5E); let w = s.width(); + let (b, screen_r) = bezel(device, w); match device { - "None" => {} "MacBook Air" => { - let b = (w * 0.025).max(6.0); let lid = s.expand(b); p.rect_filled(lid, CornerRadius::same((b * 1.2).min(255.0) as u8), body); - p.rect_stroke(lid, CornerRadius::same((b * 1.2).min(255.0) as u8), egui::Stroke::new(1.0, edge), egui::StrokeKind::Outside); + p.rect_stroke(lid, CornerRadius::same((b * 1.2).min(255.0) as u8), Stroke::new(1.0, edge), StrokeKind::Outside); // The base: a thin slab wider than the lid, with a finger notch. let base = Rect::from_min_max(pos2(lid.min.x - w * 0.08, lid.max.y), pos2(lid.max.x + w * 0.08, lid.max.y + (w * 0.022).max(6.0))); p.rect_filled(base, CornerRadius { nw: 2, ne: 2, sw: 10, se: 10 }, Color32::from_rgb(0xB8, 0xB9, 0xBD)); @@ -881,10 +1196,9 @@ fn device_body(ui: &Ui, device: &str, s: Rect) { p.rect_filled(notch, CornerRadius::same(2), Color32::from_rgb(0x8E, 0x8E, 0x93)); } "Desktop" => { - let b = (w * 0.02).max(6.0); let screen = s.expand(b); p.rect_filled(screen, CornerRadius::same(8), body); - p.rect_stroke(screen, CornerRadius::same(8), egui::Stroke::new(1.0, edge), egui::StrokeKind::Outside); + p.rect_stroke(screen, CornerRadius::same(8), Stroke::new(1.0, edge), StrokeKind::Outside); let neck = Rect::from_min_max(pos2(screen.center().x - w * 0.06, screen.max.y), pos2(screen.center().x + w * 0.06, screen.max.y + w * 0.08)); p.rect_filled(neck, CornerRadius::ZERO, Color32::from_rgb(0xB8, 0xB9, 0xBD)); @@ -892,61 +1206,65 @@ fn device_body(ui: &Ui, device: &str, s: Rect) { p.rect_filled(foot, CornerRadius::same(3), Color32::from_rgb(0xA0, 0xA1, 0xA6)); } _ => { - // Phones and tablets: a rounded slab with side buttons. - let tablet = device.starts_with("iPad"); - let b = if tablet { (w * 0.05).max(8.0) } else { (w * 0.06).max(6.0) }; - let screen_r = if tablet { w * 0.04 } else { w * 0.13 }; + // Phones and tablets: a rounded slab (phones with side buttons). let slab = s.expand(b); let r = CornerRadius::same((screen_r + b).min(255.0) as u8); - for (x, y0, h) in [(slab.min.x - 2.0, 0.18, 0.06), (slab.min.x - 2.0, 0.27, 0.1), (slab.max.x, 0.24, 0.13)] { - if tablet { - break; + if !device.starts_with("iPad") { + for (x, y0, h) in [(slab.min.x - 2.0, 0.18, 0.06), (slab.min.x - 2.0, 0.27, 0.1), (slab.max.x, 0.24, 0.13)] { + let btn = Rect::from_min_size(pos2(x, slab.min.y + slab.height() * y0), vec2(2.5, slab.height() * h)); + p.rect_filled(btn, CornerRadius::same(1), edge); } - let btn = Rect::from_min_size(pos2(x, slab.min.y + slab.height() * y0), vec2(2.5, slab.height() * h)); - p.rect_filled(btn, CornerRadius::same(1), edge); } p.rect_filled(slab, r, body); - p.rect_stroke(slab, r, egui::Stroke::new(1.5, edge), egui::StrokeKind::Inside); + p.rect_stroke(slab, r, Stroke::new(1.5, edge), StrokeKind::Inside); } } } /// What sits on top of the screen: rounded screen corners (the body colour masks the frame's /// square corners) and a phone's camera pill. -fn device_glass(ui: &Ui, device: &str, s: Rect) { - let p = ui.painter(); - let body = Color32::from_rgb(0x1C, 0x1C, 0x1E); +fn device_glass(p: &egui::Painter, device: &str, s: Rect) { let w = s.width(); - match device { - "None" | "Desktop" | "MacBook Air" => {} - _ => { - let tablet = device.starts_with("iPad"); - let screen_r = if tablet { w * 0.04 } else { w * 0.13 }; - // An outside stroke along the rounded screen covers the frame's square corners. - let bezel = if tablet { (w * 0.05).max(8.0) } else { (w * 0.06).max(6.0) }; - let cover = (screen_r * 0.45 + 1.0).min(bezel); - p.rect_stroke(s, CornerRadius::same(screen_r.min(255.0) as u8), egui::Stroke::new(cover, body), egui::StrokeKind::Outside); - if device.starts_with("iPhone") { - let pill = Rect::from_center_size(pos2(s.center().x, s.min.y + w * 0.045), vec2(w * 0.3, w * 0.085)); - p.rect_filled(pill, CornerRadius::same((w * 0.0425).min(255.0) as u8), Color32::BLACK); - } - } + let (b, screen_r) = bezel(device, w); + if screen_r <= 0.0 { + return; + } + // An outside stroke along the rounded screen covers the frame's square corners. + let cover = (screen_r * 0.45 + 1.0).min(b); + p.rect_stroke(s, CornerRadius::same(screen_r.min(255.0) as u8), Stroke::new(cover, Color32::from_rgb(0x1C, 0x1C, 0x1E)), StrokeKind::Outside); + if device.starts_with("iPhone") { + let pill = Rect::from_center_size(pos2(s.center().x, s.min.y + w * 0.045), vec2(w * 0.3, w * 0.085)); + p.rect_filled(pill, CornerRadius::same((w * 0.0425).min(255.0) as u8), Color32::BLACK); } } -/// 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); +/// One frame of the player in `rect`: the top bar, then the prototype (on its device) below. +/// Returns what the bar asked for (restarting is done here). +fn player(p: &mut Presenter, ui: &mut Ui, rect: Rect, now: f64, chrome: Chrome) -> Option { + let ctx = ui.ctx().clone(); + let bar = Rect::from_min_size(rect.min, vec2(rect.width(), chrome.bar_h)); + let area = Rect::from_min_max(pos2(rect.min.x, bar.max.y), rect.max); + let bg = crate::theme::c32(p.doc.as_ref().map(|d| page_settings(d, p.current)).unwrap_or_default().background); + let r = chrome.round; + ui.painter().rect_filled(area, CornerRadius { nw: 0, ne: 0, sw: r, se: r }, bg); + let action = top_bar(p, ui, bar, now, chrome); + if action == Some(BarAction::Restart) { + p.restart(p.start, now); + } if p.doc.as_ref().is_none_or(|d| !d.contains(p.current)) { - return true; + if !chrome.pane { + return Some(BarAction::Close); + } + ui.painter().text(area.center(), Align2::CENTER_CENTER, "Select a frame to preview it", regular(12.0), Color32::from_gray(170)); + return action; } // 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))); + for (f, from, to) in &sa.frames { + p.scroll.insert(*f, (lerp(from.0, to.0, e), lerp(from.1, to.1, e))); + } p.rev += 1; if raw >= 1.0 { p.scroll_anim = None; @@ -954,16 +1272,22 @@ fn player(p: &mut Presenter, ui: &mut Ui, ctx: &egui::Context, now: f64) -> bool 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 over = resp.contains_pointer(); + if let Some(l) = layout(p, area, chrome.pad) { + // The pane only takes the keyboard while the pointer is over it. + input(p, &ctx, &l, over, chrome.keys && (!chrome.pane || over), now); } - let Some(l) = layout(p, area) else { return true }; - let device = device_of(p); - device_body(ui, &device, l.target); - draw(p, ui, ctx, &l, area, now); - device_glass(ui, &device, l.target); - // Timers: the soonest pending "after delay". + let Some(l) = layout(p, area, chrome.pad) else { return action }; + let painter = ui.painter_at(area); + if let Some(d) = l.device { + device_body(&painter, d.name, l.screen); + painter.rect_filled(l.screen, CornerRadius::ZERO, bg); + } + draw(p, &painter, &ctx, &l, now); + if let Some(d) = l.device { + device_glass(&painter, d.name, l.screen); + } + // Timers: wake up for 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 }; @@ -976,54 +1300,73 @@ fn player(p: &mut Presenter, ui: &mut Ui, ctx: &egui::Context, now: f64) -> bool } } } - 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)), + if let Some(d) = next { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(d.clamp(0.0, 60.0))); } - close + action } -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 }; +/// The bar above the prototype: the flow picker and the frame's name on the left, the buttons on +/// the right (text on the dark full-window bar, icons in the pane, which follows the theme). +fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome) -> Option { + let t = Tokens::get(ui.ctx()); + let (fill, text, dim, hover) = if chrome.pane { + (t.panel, t.text, t.text_secondary, t.hover) + } else { + (Color32::from_rgb(0x2C, 0x2C, 0x2C), Color32::WHITE, Color32::from_gray(140), Color32::from_white_alpha(28)) + }; + let r = chrome.round; + ui.painter().rect_filled(bar, CornerRadius { nw: r, ne: r, sw: 0, se: 0 }, fill); + if chrome.pane { + ui.painter().line_segment([bar.left_bottom(), bar.right_bottom()], Stroke::new(1.0, t.border)); + } + let doc = p.doc.as_ref()?; 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() + let mut action = None; + // Right: the buttons, right to left. + let buttons: &[(BarAction, &str, Icon)] = if chrome.pane { + &[(BarAction::Close, "Close preview", Icon::Close), (BarAction::Present, "Present", Icon::Play), (BarAction::Restart, "Restart", Icon::Reset)] + } else { + &[(BarAction::Close, "Close", Icon::Close), (BarAction::Restart, "Restart", Icon::Reset)] }; - if button(ui, "Close", "close", &mut x) { - close = true; + let mut x = bar.max.x - if chrome.pane { 6.0 } else { 8.0 }; + for (act, label, icon) in buttons { + let w = if chrome.pane { 24.0 } else { ui.painter().layout_no_wrap(label.to_string(), regular(11.5), text).size().x + 16.0 }; + let br = Rect::from_min_size(pos2(x - w, bar.center().y - 12.0), vec2(w, 24.0)); + x = br.min.x - if chrome.pane { 2.0 } else { 4.0 }; + let mut resp = ui.interact(br, ui.id().with(("pbar", *label)), Sense::click()); + if chrome.pane { + resp = resp.on_hover_text(*label); + } + if resp.hovered() { + ui.painter().rect_filled(br, CornerRadius::same(5), hover); + } + if chrome.pane { + icons::paint(ui.painter(), br.shrink(5.0), *icon, t.icon); + } else { + ui.painter().text(br.center(), Align2::CENTER_CENTER, *label, regular(11.5), text); + } + if resp.clicked() { + action = Some(*act); + } } - 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; + // Left: the flow picker and the frame's name, clipped before the buttons. + let left = ui.painter().with_clip_rect(Rect::from_min_max(bar.min, pos2(x, bar.max.y))); + let mut lx = bar.min.x + if chrome.pane { 8.0 } else { 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"); + let label = format!("{} ▾", flow_name.unwrap_or_else(|| "Flows".into())); + let galley = left.layout_no_wrap(label, semibold(11.5), text); + let fr = Rect::from_min_size(pos2(lx, bar.center().y - 12.0), vec2(galley.size().x + 16.0, 24.0)); + let resp = ui.interact(fr.intersect(left.clip_rect()), 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)); + left.rect_filled(fr, CornerRadius::same(5), hover); } - ui.painter().galley(pos2(r.min.x + 8.0, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE); + left.galley(pos2(fr.min.x + 8.0, fr.center().y - galley.size().y / 2.0), galley, text); egui::Popup::menu(&resp).width(200.0).show(|ui| { for f in &flows { if crate::right::menu_item(ui, &f.name, "") { @@ -1032,15 +1375,36 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64) -> bool { } } }); - 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 = fr.max.x + 8.0; + left.text(pos2(lx, bar.center().y), Align2::LEFT_CENTER, "/", regular(11.5), dim); lx += 14.0; } - ui.painter().text(pos2(lx, bar.center().y), Align2::LEFT_CENTER, name, regular(11.5), Color32::from_gray(220)); - close + left.text(pos2(lx, bar.center().y), Align2::LEFT_CENTER, name, regular(11.5), if chrome.pane { text } else { Color32::from_gray(220) }); + action } -fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, now: f64) { +/// Scrolling frames under the screen point `q`, innermost first: those of `root` (in the display +/// document `disp`) that can scroll (`base` is the player's document without scroll offsets). +fn scrollers_at(p: &Presenter, base: &Document, disp: &Document, root: NodeId, place: &Place, q: Pos2) -> Vec { + let mut out: Vec = disp + .subtree(root) + .into_iter() + .filter(|f| disp.node(*f).is_some_and(|n| n.visible && n.overflow != Overflow::None)) + .filter(|f| disp.world_bounds(*f).is_some_and(|b| place.rect(b).contains(q))) + .filter(|f| p.range(base, *f) != (0.0, 0.0)) + .collect(); + out.reverse(); + out +} + +/// Keep a key the prototype used from reaching the editor's shortcuts. +fn consume_key(ctx: &egui::Context, key: egui::Key) { + ctx.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Key { key: k, .. } if *k == key))); +} + +/// Pointer, wheel, keys and timers. `over_area`: the pointer is over the player; `keys_on`: the +/// player takes the keyboard. +fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, keys_on: bool, now: f64) { let (pos, pressed, released, down, wheel, keys) = ctx.input(|i| { let keys: Vec = i .events @@ -1059,14 +1423,18 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, no keys, ) }); + let keys = if keys_on { keys } else { Vec::new() }; let pos = pos.filter(|_| over_area || p.press.is_some()); + if !down { + p.drag_scroll = None; + } // 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 (root, place, clip) = match l.overlays.last() { + Some((id, pl, _)) => (*id, *pl, l.screen), + None => (p.current, l.place, l.clip), }; let doc = &l.disp; - let hot = hotspots(doc, root, &place); + let hot = hotspots(doc, root, &place, clip); // "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)) }; @@ -1075,21 +1443,14 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, no return; } } - // Wheel scrolls the innermost scrollable frame under the pointer. + // The wheel scrolls the innermost frame under the pointer that can still move that way. 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); + for f in scrollers_at(p, &base, doc, root, &place, q) { 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)); + let next = clamp_scroll((cur.0 - f64::from(wheel.x) / l.scale, cur.1 - f64::from(wheel.y) / l.scale), p.range(&base, f)); if (next.0 - cur.0).abs() > 1e-6 || (next.1 - cur.1).abs() > 1e-6 { p.scroll.insert(f, next); p.scroll_anim = None; @@ -1171,11 +1532,31 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, no p.press = None; return; } + // Otherwise the drag scrolls the innermost frame under it that scrolls. + if let Some(base) = p.doc.clone() + && let Some(f) = scrollers_at(p, &base, doc, root, &place, start).first().copied() + { + p.drag_scroll = Some((f, p.scroll.get(&f).copied().unwrap_or((0.0, 0.0)))); + } + } + if down + && let (Some((f, from)), Some(start), Some(q)) = (p.drag_scroll, p.press, pos) + && let Some(base) = p.doc.as_ref() + { + let d = q - start; + let to = clamp_scroll((from.0 - f64::from(d.x) / l.scale, from.1 - f64::from(d.y) / l.scale), p.range(base, f)); + if p.scroll.get(&f).copied().unwrap_or((0.0, 0.0)) != to { + p.scroll.insert(f, to); + p.scroll_anim = None; + p.rev += 1; + } + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); } if released && p.press.is_some() { let dragged = p.dragged; p.press = None; p.dragged = false; + p.drag_scroll = None; if let Some((id, k, r)) = pick(&|t| *t == Trigger::MouseUp) && p.fire(id, k, Some(r), now) { @@ -1200,6 +1581,7 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, no }); if let Some((id, k)) = found { if p.fire(id, k, None, now) { + consume_key(ctx, *key); return; } continue; @@ -1207,13 +1589,14 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, no // 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 Some(base) = p.doc.as_ref() { 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); + if let Some(n) = next.copied() { + p.go(n, Transition::Instant, 0.0, Easing::Linear, now, true, false); + consume_key(ctx, *key); return; } } @@ -1261,10 +1644,9 @@ fn key_alias(k: &str) -> Option<&'static str> { }) } -fn draw(p: &mut Presenter, ui: &mut Ui, ctx: &egui::Context, l: &Layout, area: Rect, now: f64) { +fn draw(p: &mut Presenter, painter: &egui::Painter, ctx: &egui::Context, l: &Layout, 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. @@ -1276,7 +1658,7 @@ fn draw(p: &mut Presenter, ui: &mut Ui, ctx: &egui::Context, l: &Layout, area: R } 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)); + let from_place = a.from_doc.world_bounds(a.from_frame).map(|fb| Place::centered(fb, l.screen.center(), l.scale)); if matches!(a.transition, Transition::SmartAnimate) { #[cfg(not(target_arch = "wasm32"))] let t0 = std::time::Instant::now(); @@ -1295,7 +1677,7 @@ fn draw(p: &mut Presenter, ui: &mut Ui, ctx: &egui::Context, l: &Layout, area: R t } }; - painter.with_clip_rect(l.target).image(tex.id(), l.place.rect(b), uv, Color32::WHITE); + painter.with_clip_rect(l.screen).image(tex.id(), l.place.rect(b), uv, Color32::WHITE); drawn = true; } #[cfg(not(target_arch = "wasm32"))] @@ -1308,15 +1690,16 @@ fn draw(p: &mut Presenter, ui: &mut Ui, ctx: &egui::Context, l: &Layout, area: R 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 })); + // Without a device a dissolve shows both frames whole; on one, the screen clips. + let dissolve_both = l.device.is_none() && matches!(a.transition, Transition::Dissolve); + let clip = painter.with_clip_rect(if dissolve_both { l.screen.union(from_frame_rect) } else { l.screen }); 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)); + p.image(to.tex.id(), to_rect.translate(off(l.screen, f.to_off)), uv, Color32::WHITE.gamma_multiply(f.to_alpha)); }; if f.from_on_top { draw_to(&clip); @@ -1330,7 +1713,7 @@ fn draw(p: &mut Presenter, ui: &mut Ui, ctx: &egui::Context, l: &Layout, area: R } } if !drawn && let Some(to) = &to_tex { - painter.image(to.tex.id(), l.place.rect(to.bounds), uv, Color32::WHITE); + painter.with_clip_rect(l.clip).image(to.tex.id(), l.place.rect(to.bounds), uv, Color32::WHITE); } // Overlays (each with its background and opening transition); closing ones run it backwards. let mut ovs: Vec<(f64, OverlayState)> = @@ -1343,6 +1726,7 @@ fn draw(p: &mut Presenter, ui: &mut Ui, ctx: &egui::Context, l: &Layout, area: R ovs.insert(0, (1.0 - ((now - at) / o.dur.max(1e-3)).clamp(0.0, 1.0), o.clone())); placed.insert(0, *pl); } + let on_screen = painter.with_clip_rect(l.screen); for ((raw, o), (id, place, orect)) in ovs.iter().zip(placed.iter()) { let raw = *raw; if raw < 1.0 { @@ -1356,23 +1740,23 @@ fn draw(p: &mut Presenter, ui: &mut Ui, ctx: &egui::Context, l: &Layout, area: R }; 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)); + on_screen.rect_filled(l.screen, 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)); + on_screen.image(tex.tex.id(), r, uv, Color32::WHITE.gamma_multiply(f.to_alpha)); } } // Finished closing animations go (after drawing, so they stayed paired with the layout). p.closing.retain(|(at, o)| now - at < o.dur); // 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 (root, place, clip) = match l.overlays.last() { + Some((id, pl, _)) => (*id, *pl, l.screen), + None => (p.current, l.place, l.clip), }; let a = ((p.flash_until - now) / 0.5) as f32; - for (_, r) in hotspots(&l.disp, root, &place) { + for (_, r) in hotspots(&l.disp, root, &place, clip) { 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); } @@ -1522,6 +1906,135 @@ mod tests { assert_eq!(y(&out, sticky), 0.0); } + /// A page presented on an iPhone 16 (393×852): a tall screen A that scrolls vertically, with a + /// tab bar fixed to its bottom, a nested scrolling list and a layer at its end; screen B. + fn device_doc() -> (Document, NodeId, NodeId, NodeId, NodeId, NodeId) { + let mut d = Document::new("t"); + let page = d.add_page("Page 1"); + if let NodeKind::Page { prototype, .. } = &mut d.get_mut(page).unwrap().kind { + prototype.device = "iPhone 16".into(); + } + let a = d.create(page, NodeKind::Frame, "A", 0.0, 0.0, 393.0, 2000.0).unwrap(); + let b = d.create(page, NodeKind::Frame, "B", 500.0, 0.0, 393.0, 1500.0).unwrap(); + for f in [a, b] { + d.get_mut(f).unwrap().overflow = Overflow::Vertical; + } + let bar = d.create(a, NodeKind::Rectangle, "Tab bar", 0.0, 1940.0, 393.0, 60.0).unwrap(); + let n = d.get_mut(bar).unwrap(); + n.scroll_position = ScrollPosition::Fixed; + n.constraints.vertical = VConstraint::Bottom; + let list = d.create(a, NodeKind::Frame, "List", 0.0, 1200.0, 393.0, 300.0).unwrap(); + d.get_mut(list).unwrap().overflow = Overflow::Vertical; + let item = d.create(list, NodeKind::Rectangle, "Item", 0.0, 700.0, 393.0, 100.0).unwrap(); + (d, a, b, bar, list, item) + } + + fn player_on(d: Document, start: NodeId) -> Presenter { + let mut p = Presenter::new(start); + p.sync(&Arc::new(d), 0.0); + p + } + + #[test] + fn device_screen_clips_the_frame_and_it_scrolls_through_its_height() { + let (mut d, a, _, bar, list, _) = device_doc(); + assert_eq!(viewport(&d, a), Some((393.0, 852.0))); + // On the device the whole frame scrolls; at its own size nothing overflows it. + assert_eq!(scroll_range_in(&d, a, viewport(&d, a)), (0.0, 1148.0)); + assert_eq!(scroll_range(&d, a), (0.0, 0.0)); + // Nested frames scroll within themselves. + assert_eq!(scroll_range(&d, list), (0.0, 500.0)); + // The display copy is sized to the screen; the fixed tab bar moves to the screen's bottom. + let mut p = player_on(d.clone(), a); + let disp = p.display().unwrap(); + assert_eq!((disp.node(a).unwrap().width, disp.node(a).unwrap().height), (393.0, 852.0)); + assert_eq!(disp.world_bounds(bar).unwrap().y0, 792.0); + // Scrolled, it stays there while the content moves. + p.scroll_to(0.0, 400.0); + let disp = p.display().unwrap(); + assert_eq!(disp.world_bounds(bar).unwrap().y0, 792.0); + assert_eq!(disp.world_bounds(list).unwrap().y0, 800.0); + p.scroll_to(0.0, 99_999.0); + assert_eq!(p.scroll[&a], (0.0, 1148.0), "clamped to the range"); + // Without scrolling the screen only clips; without a device the frame shows whole. + d.get_mut(a).unwrap().overflow = Overflow::None; + assert_eq!(scroll_range_in(&d, a, viewport(&d, a)), (0.0, 0.0)); + if let Some(pg) = d.page_of(a) + && let NodeKind::Page { prototype, .. } = &mut d.get_mut(pg).unwrap().kind + { + prototype.device = "None".into(); + } + assert_eq!(viewport(&d, a), None); + // A frame wider than the screen scrolls sideways when it scrolls both ways. + let (mut d, a, ..) = device_doc(); + let n = d.get_mut(a).unwrap(); + n.width = 800.0; + n.overflow = Overflow::Both; + assert_eq!(scroll_range_in(&d, a, viewport(&d, a)), (407.0, 1148.0)); + } + + #[test] + fn scroll_to_moves_every_scrolling_frame_around_the_layer() { + let (d, a, _, _, list, item) = device_doc(); + let mut p = player_on(d.clone(), a); + assert!(p.perform(&Action::ScrollTo { dest: item }, item, Transition::Instant, 0.0, Easing::Linear, None, 1.0)); + // The list brings the item to its top (as far as it scrolls), the screen brings the list. + assert_eq!(p.scroll[&list], (0.0, 500.0)); + assert_eq!(p.scroll[&a], (0.0, 1148.0)); + // Animated, it gets there over the duration. + let mut p = player_on(d, a); + assert!(p.perform(&Action::ScrollTo { dest: list }, list, Transition::Dissolve, 0.3, Easing::Linear, None, 1.0)); + let anim = p.scroll_anim.as_ref().unwrap(); + assert_eq!(anim.frames, vec![(a, (0.0, 0.0), (0.0, 1148.0))]); + } + + #[test] + fn navigating_resets_scroll_unless_it_preserves_it() { + let (d, a, b, ..) = device_doc(); + let mut p = player_on(d, a); + p.scroll_to(0.0, 600.0); + assert!(p.perform(&Action::Navigate { dest: b, preserve_scroll: false }, a, Transition::Instant, 0.0, Easing::Linear, None, 1.0)); + assert_eq!(p.scroll.get(&b), None, "starts at the top"); + p.restart(a, 2.0); + p.scroll_to(0.0, 1000.0); + assert!(p.perform(&Action::Navigate { dest: b, preserve_scroll: true }, a, Transition::Instant, 0.0, Easing::Linear, None, 3.0)); + assert_eq!(p.scroll[&b], (0.0, 648.0), "kept, as far as B scrolls"); + // Back starts at the top again. + assert!(p.perform(&Action::Back, b, Transition::Instant, 0.0, Easing::Linear, None, 4.0)); + assert_eq!((p.current, p.scroll.get(&a)), (a, None)); + } + + #[test] + fn sync_follows_edits_and_keeps_the_screen() { + let (mut d, a, b, _, list, _) = device_doc(); + let mut p = player_on(d.clone(), a); + p.go(b, Transition::Instant, 0.0, Easing::Linear, 1.0, true, false); + p.scroll_to(0.0, 600.0); + // The same document again changes nothing. + let same = Arc::new(d.clone()); + p.sync(&same, 2.0); + let rev = p.rev; + p.sync(&same, 2.0); + assert_eq!(p.rev, rev); + // An edit is copied; the screen and its scroll stay (clamped to the new range). + d.get_mut(b).unwrap().height = 1000.0; + d.get_mut(b).unwrap().name = "B2".into(); + p.sync(&Arc::new(d.clone()), 3.0); + assert_eq!(p.current, b); + assert_eq!(p.inspect()["name"], "B2"); + assert_eq!(p.scroll[&b], (0.0, 148.0)); + assert_eq!(p.history.len(), 1); + // The screen deleted: back to the start. + d.remove(b).unwrap(); + p.sync(&Arc::new(d.clone()), 4.0); + assert_eq!(p.current, a); + assert!(p.history.is_empty() && p.scroll.is_empty()); + // Junk scroll offsets never panic and stay in range. + p.scroll.insert(list, (f64::NAN, f64::INFINITY)); + p.sync(&Arc::new(d), 5.0); + assert_eq!(p.scroll[&list], (0.0, 0.0)); + } + #[test] fn transitions_move_the_right_way() { let left = transition_frames(Transition::MoveIn { direction: Direction::Left }, 0.0); diff --git a/crates/ui-egui/src/present_tests.rs b/crates/ui-egui/src/present_tests.rs new file mode 100644 index 000000000..d7af5cf78 --- /dev/null +++ b/crates/ui-egui/src/present_tests.rs @@ -0,0 +1,193 @@ +//! The prototype player driven headless through egui: the preview pane opens from a command, +//! follows edits and the editor's selection, takes the keys its prototype uses, and the presented +//! frame scrolls inside the device's screen with the wheel and by dragging. + +use egui::{Event, Key, Pos2, RawInput, Rect, pos2, vec2}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::{Value, json}; + +use crate::{FigApp, NoServices}; + +struct Ui { + ctx: egui::Context, + app: FigApp, + time: f64, +} + +impl Ui { + fn new(s: Session) -> Self { + let mut u = Self { ctx: egui::Context::default(), app: FigApp::new(s, Box::new(NoServices)), time: 0.0 }; + u.frames(3, Vec::new); + u + } + + fn frame(&mut self, events: Vec) { + self.time += 1.0 / 60.0; + let input = + RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), events, ..Default::default() }; + let app = &mut self.app; + self.ctx.run_ui(input, |ui| app.ui(ui)).textures_delta.clear(); + } + + fn frames(&mut self, n: usize, events: impl Fn() -> Vec) { + for _ in 0..n { + self.frame(events()); + } + } + + fn run(&mut self, cmd: &str, params: Value) -> Value { + let r = self.app.session.execute(cmd, ¶ms).unwrap(); + self.app.handle_ui_requests(); + self.frame(vec![]); + r + } + + /// The preview pane's window on screen. + fn pane(&self) -> Rect { + self.ctx.memory(|m| m.area_rect(crate::present::pane_id())).expect("the pane is open") + } + + /// The pane's player state (`ui.inspect` → `preview`). + fn preview(&self) -> Value { + self.app.inspect()["preview"].clone() + } + + fn scroll_y(&self) -> f64 { + self.preview()["scroll"][1].as_f64().unwrap() + } + + fn button(&mut self, p: Pos2, pressed: bool) { + self.frame(vec![ + Event::PointerMoved(p), + Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }, + ]); + } + + fn key(&mut self, at: Pos2, key: Key) { + let k = |pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + self.frame(vec![Event::PointerMoved(at), k(true), k(false)]); + } +} + +fn id(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +/// An iPhone 16 page: screen A (393×2000, scrolls vertically, a tab bar fixed to its bottom, a +/// rectangle selected inside it) and screen B. +fn setup() -> (Ui, NodeId, NodeId, NodeId) { + let mut s = Session::new(); + let ex = |s: &mut Session, c: &str, p: Value| s.execute(c, &p).unwrap(); + ex(&mut s, "proto.settings", json!({"device": "iPhone 16"})); + let a = id(&ex(&mut s, "node.create", json!({"type": "frame", "name": "A", "x": 0, "y": 0, "width": 393, "height": 2000}))); + ex(&mut s, "node.set", json!({"id": a, "props": {"overflow": "vertical"}})); + let card = id(&ex(&mut s, "node.create", json!({"type": "rectangle", "parent": a, "x": 20, "y": 100, "width": 200, "height": 100}))); + let bar = id(&ex(&mut s, "node.create", json!({"type": "rectangle", "parent": a, "x": 0, "y": 1940, "width": 393, "height": 60}))); + ex(&mut s, "node.set", json!({"id": bar, "props": {"scrollPosition": "fixed", "constraints": {"vertical": "bottom"}}})); + let b = id(&ex(&mut s, "node.create", json!({"type": "frame", "name": "B", "x": 500, "y": 0, "width": 393, "height": 852}))); + ex(&mut s, "select.set", json!({"ids": [card]})); + (Ui::new(s), a, b, card) +} + +#[test] +fn preview_pane_opens_follows_edits_and_the_selection() { + let (mut u, a, b, card) = setup(); + assert_eq!(u.run("proto.preview", json!({}))["start"], json!(a), "the selected layer's frame"); + let pane = u.pane(); + assert!(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0)).contains_rect(pane), "{pane:?}"); + assert_eq!(u.preview()["current"], json!(a)); + assert_eq!(u.preview()["device"], "iPhone 16"); + // Edits show live. + u.run("node.set", json!({"id": a, "props": {"name": "Home"}})); + assert_eq!(u.preview()["name"], "Home"); + // Arrow keys over the pane step to the next frame there and don't nudge the selection. + let before = u.app.session.doc().unwrap().world_bounds(card).unwrap(); + u.key(pane.center(), Key::ArrowRight); + assert_eq!(u.preview()["current"], json!(b)); + assert_eq!(u.app.session.doc().unwrap().world_bounds(card).unwrap(), before); + // Away from the pane the editor has the keyboard. + u.key(pos2(600.0, 450.0), Key::ArrowRight); + assert_eq!(u.preview()["current"], json!(b)); + assert_ne!(u.app.session.doc().unwrap().world_bounds(card).unwrap(), before, "nudged"); + // Selecting another top-level frame in the editor shows it. + u.run("select.set", json!({"ids": [b]})); + assert_eq!(u.preview()["current"], json!(b)); + u.run("select.set", json!({"ids": [a]})); + assert_eq!(u.preview()["current"], json!(a)); + // Deselecting and selecting the same frame again keeps where the preview got to. + u.key(pane.center(), Key::ArrowRight); + u.run("select.set", json!({"ids": []})); + u.run("select.set", json!({"ids": [a]})); + assert_eq!(u.preview()["current"], json!(b)); + // Deleting the screen shown falls back to the start, here B itself: nothing to show. + u.run("proto.preview", json!({"start": b})); + u.run("edit.delete", json!({"ids": [b]})); + assert!(u.app.ui.preview.is_some(), "the pane stays open"); + u.run("edit.undo", json!({})); + assert_eq!(u.preview()["current"], json!(b), "back when the frame is"); + u.run("proto.closePreview", json!({})); + assert!(u.app.ui.preview.is_none()); +} + +#[test] +fn the_frame_scrolls_on_the_device_screen_by_wheel_and_drag() { + let (mut u, a, b, _) = setup(); + u.run("proto.preview", json!({"start": a})); + assert_eq!(u.preview()["viewport"], json!([393.0, 852.0])); + assert_eq!(u.preview()["scrollRange"], json!([0.0, 1148.0])); + let c = u.pane().center(); + // The wheel scrolls (egui smooths it over a few frames). + let wheel = move || { + vec![ + Event::PointerMoved(c), + Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -60.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + }, + ] + }; + u.frames(6, wheel); + u.frames(20, Vec::new); + let after_wheel = u.scroll_y(); + assert!(after_wheel > 0.0, "{after_wheel}"); + // Dragging up scrolls further, by the drag over the zoom, and isn't a click. + u.button(c, true); + for k in 1..=10 { + u.frame(vec![Event::PointerMoved(c - vec2(0.0, 10.0 * k as f32))]); + } + u.button(c - vec2(0.0, 100.0), false); + let dragged = u.scroll_y() - after_wheel; + assert!(dragged > 100.0, "the screen is shown below 100%: {dragged}"); + // Never past the end. + for _ in 0..20 { + u.button(c, true); + u.frame(vec![Event::PointerMoved(c - vec2(0.0, 300.0))]); + u.button(c - vec2(0.0, 300.0), false); + } + assert_eq!(u.scroll_y(), 1148.0); + // Navigating starts the next frame at the top. + u.key(c, Key::ArrowRight); + assert_eq!(u.preview()["current"], json!(b)); + assert_eq!(u.scroll_y(), 0.0); +} + +#[test] +fn full_window_presenter_shares_the_player() { + let (mut u, a, ..) = setup(); + u.run("proto.present", json!({"start": a})); + assert_eq!(u.app.inspect()["presenter"]["current"], json!(a)); + crate::control::handle(&mut u.app, &u.ctx, "ui.set", &json!({"presentScroll": [0, 5000]})).unwrap(); + assert_eq!(u.app.inspect()["presenter"]["scroll"], json!([0.0, 1148.0])); + u.frame(vec![]); + u.key(pos2(700.0, 450.0), Key::Escape); + assert!(u.app.ui.present.is_none()); + // The control channel opens and closes the pane too. + crate::control::handle(&mut u.app, &u.ctx, "ui.set", &json!({"preview": a.0})).unwrap(); + assert_eq!(u.preview()["current"], json!(a)); + crate::control::handle(&mut u.app, &u.ctx, "ui.set", &json!({"preview": false})).unwrap(); + assert!(u.app.ui.preview.is_none()); + assert!(crate::control::handle(&mut u.app, &u.ctx, "ui.set", &json!({"presentScroll": [0, 1]})).is_err(), "nothing presented"); +} diff --git a/crates/ui-egui/src/proto_panel.rs b/crates/ui-egui/src/proto_panel.rs index c72c076bf..149fcaaa5 100644 --- a/crates/ui-egui/src/proto_panel.rs +++ b/crates/ui-egui/src/proto_panel.rs @@ -4,9 +4,7 @@ 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, PrototypeSettings, ScrollPosition, Transition, Trigger, -}; +use figcraft_doc::proto::{Action, Direction, Easing, Interaction, Overflow, OverlayPosition, ScrollPosition, Transition, Trigger}; use figcraft_doc::*; use serde_json::{Value, json}; @@ -150,7 +148,7 @@ fn action_of(label: &str, doc: &Document, page: NodeId, node: NodeId, cur: Optio 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? }, + "Navigate to" => Action::Navigate { dest: frame_dest?, preserve_scroll: matches!(cur, Some(Action::Navigate { preserve_scroll: true, .. })) }, "Change to" => { let vs = variants_for(doc, node); let mine = figcraft_engine::components::owning_component(doc, node); @@ -264,31 +262,34 @@ fn flows_of(doc: &Document, page: NodeId) -> Vec { } } -/// One flow row: play, editable name, remove. -fn flow_row(app: &mut FigApp, ui: &mut Ui, f: &proto::Flow) { +/// A square icon button painted at `rect`; true when clicked. +fn icon_button(ui: &mut Ui, id: Id, rect: Rect, icon: Icon, tip: &str) -> bool { let t = Tokens::get(ui.ctx()); + let resp = ui.interact(rect, id, Sense::click()).on_hover_text(tip); + if resp.hovered() { + ui.painter().rect_filled(rect, CornerRadius::same(5), t.hover); + } + icons::paint(ui.painter(), rect.shrink(5.0), icon, t.icon); + resp.clicked() +} + +/// One flow row: play, editable name, preview, remove. +fn flow_row(app: &mut FigApp, ui: &mut Ui, f: &proto::Flow) { 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() { + let button = |dx: f32| Rect::from_center_size(pos2(dx, r.center().y), vec2(24.0, 24.0)); + let play = button(r.min.x + PAD + 8.0); + if icon_button(ui, Id::new(("flow_play", f.start)), play, Icon::Play, "Present this flow") { 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)); + let name_rect = Rect::from_min_max(pos2(play.max.x + 6.0, r.center().y - 9.0), pos2(r.max.x - 64.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); + if icon_button(ui, Id::new(("flow_preview", f.start)), button(r.max.x - 44.0), Icon::Preview, "Preview this flow") { + app.run("proto.preview", json!({"start": f.start})); } - icons::paint(ui.painter(), rm.shrink(5.0), Icon::Minus, t.icon); - if rresp.clicked() { + if icon_button(ui, Id::new(("flow_rm", f.start)), button(r.max.x - 20.0), Icon::Minus, "Remove starting point") { app.run("proto.flow.remove", json!({"start": f.start})); } } @@ -314,7 +315,9 @@ fn flows_overview(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { } fn settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { - widgets::section_header(ui, "Prototype settings", &[], false); + if widgets::section_header(ui, "Prototype settings", &[(Icon::Preview, "Open prototype preview")], false).is_some() { + app.run("proto.preview", json!({})); + } 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), @@ -323,7 +326,7 @@ fn settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { row(ui, 32.0, |ui| { let resp = widgets::dropdown(ui, &device, ui.available_width(), None); egui::Popup::menu(&resp).show(|ui| { - for d in PrototypeSettings::DEVICES { + for d in std::iter::once("None").chain(proto::PrototypeSettings::DEVICES.iter().map(|d| d.name)) { if menu_item(ui, d, "") { app.run("proto.settings", json!({"device": d})); ui.close(); @@ -345,9 +348,16 @@ fn settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { 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})); + // A flow's row has its own preview button. + let buttons: &[(Icon, &str)] = if mine.is_none() { &[(Icon::Preview, "Preview this frame"), (Icon::Plus, "Add starting point")] } else { &[] }; + match widgets::section_header(ui, "Flow starting point", buttons, mine.is_none()) { + Some(0) => { + app.run("proto.preview", json!({"start": frame})); + } + Some(_) => { + app.run("proto.flow.add", json!({"start": frame})); + } + None => {} } if let Some(f) = mine { flow_row(app, ui, &f); @@ -400,12 +410,7 @@ fn interactions(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, nod 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() { + if icon_button(ui, ui.id().with(("proto_rm", id, i)), rm, Icon::Minus, "Remove interaction") { open_editor(ui.ctx(), None); app.run("proto.interaction.remove", json!({"ids": targets_for(app, id), "index": i})); return; @@ -619,7 +624,15 @@ fn action_rows(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, node }); 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::Navigate { dest, preserve_scroll } => { + let (dest, preserve_scroll) = (*dest, *preserve_scroll); + dest_dropdown(app, ui, doc, &frames, Some(dest), "Destination", |d| Action::Navigate { dest: d, preserve_scroll }, node, k); + row(ui, 28.0, |ui| { + if widgets::checkbox(ui, preserve_scroll, "Preserve scroll position") { + patch(app, node, k, json!({"action": Action::Navigate { dest, preserve_scroll: !preserve_scroll }})); + } + }); + } 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); diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 0bf0cede1..5b1499105 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -67,9 +67,14 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { if sresp.clicked() { app.ui.export_preview = true; } + // Play presents; its chevron opens the play menu (present, or preview in a pane). let play = Rect::from_min_size(pos2(share.min.x - 50.0, top.center().y - 14.0), vec2(42.0, 28.0)); - let presp = ui.interact(play, ui.id().with("play"), Sense::click()).on_hover_text("Present ⌥⌘↵"); - if presp.hovered() { + let (tri_r, chev_r) = + (Rect::from_min_max(play.min, pos2(play.max.x - 16.0, play.max.y)), Rect::from_min_max(pos2(play.max.x - 16.0, play.min.y), play.max)); + let present_keys = crate::toolbar::pretty_shortcut(crate::present::PRESENT_SHORTCUT); + let presp = ui.interact(tri_r, ui.id().with("play"), Sense::click()).on_hover_text(format!("Present {present_keys}")); + let mresp = ui.interact(chev_r, ui.id().with("play_menu"), Sense::click()).on_hover_text("Present or preview"); + if presp.hovered() || mresp.hovered() { ui.painter().rect_filled(play, CornerRadius::same(5), t.hover); } let tri = Rect::from_center_size(play.center() - vec2(7.0, 0.0), vec2(16.0, 16.0)); @@ -79,6 +84,17 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { if presp.clicked() { app.run("proto.present", json!({})); } + egui::Popup::menu(&mresp).width(200.0).show(|ui| { + if menu_item(ui, "Present", &present_keys) { + app.run("proto.present", json!({})); + ui.close(); + } + let (label, cmd) = if app.ui.preview.is_some() { ("Close preview", "proto.closePreview") } else { ("Preview", "proto.preview") }; + if menu_item(ui, label, "") { + app.run(cmd, json!({})); + ui.close(); + } + }); // Tabs row. let (tabs, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); ui.painter().line_segment([tabs.left_top(), tabs.right_top()], Stroke::new(1.0, t.border)); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 6c9ea2dbf..75f955ce8 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -172,6 +172,13 @@ fn key_event(app: &mut FigApp, key: Key, m: Modifiers, repeat: bool, ctx: &Conte return true; } } + if let Some((want, k)) = parse(crate::present::PRESENT_SHORTCUT) + && k == key + && mods_match(want, m) + { + app.run("proto.present", json!({})); + return true; + } // Enter while the pen is drawing finishes it. if key == Key::Enter && app.session.tool.pen.is_some() { let _ = app.session.pen_finish(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index da9078e64..347a1cf8b 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -62,7 +62,10 @@ that `node.set` accepts are listed in [mcp.md](mcp.md#conventions-agents-must-kn UI state: panels and tabs, current tool, canvas rect, dialogs, view, timings (`frameMs`, `renderMs`) and what the last canvas render had to do (`canvas.renderWork`: `full`, `partial` with -the `pixels` re-rendered after a pan or an edit, or `reused`). +the `pixels` re-rendered after a pan or an edit, or `reused`). While a prototype plays, `presenter` +(full window) and `preview` (the preview pane) hold the player's state: the `current` frame and its +`name`, `start`, `history`, open `overlays`, the screen's `scroll` offset and `scrollRange`, the +`device` and the `viewport` of the frame its screen shows (`null` when nothing plays). ### `ui.pointer {kind, x, y | sx, sy, to?, steps?, shift?, alt?, cmd?, ctrl?}` @@ -90,14 +93,17 @@ text tool). Invoke a menu item exactly like clicking it (this may open a dialog). Returns `{ok, lastError}`. -### `ui.set {rightTab?, leftTab?, theme?, home?, palette?, mainMenu?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?}` +### `ui.set {rightTab?, leftTab?, theme?, home?, palette?, mainMenu?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?}` Switch panels and modes: `rightTab` `design|prototype|inspect`, `leftTab` `file|assets`, `theme`, `home` (bool), `palette` (bool: the Actions palette), `mainMenu` (bool), `colorPicker` (`{fills?, index?}`: open the colour picker on the selection's paint; `null` closes it), `variables` (bool, or a collection id or name to show: the Variables editor), `layerFilter` (string: open the layers search with that filter; `null` closes it), `collapseLayers` (true: collapse the -layers tree), `exportPreview` (bool: the Design panel Export section's preview). Returns `ui.inspect`. +layers tree), `exportPreview` (bool: the Design panel Export section's preview), `preview` (true or a +frame id: open the prototype preview pane like `proto.preview`; false or `null` closes it), +`presentScroll` (`[x, y]`: scroll the presented screen, the full window's else the pane's, clamped +to what it can scroll). Returns `ui.inspect`. ### `ui.screenshot {path}` diff --git a/docs/mcp.md b/docs/mcp.md index cd2a5fcd9..e234df221 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -133,6 +133,19 @@ from the pen's open end; clicking a point connects or branches), `vector.edit.pe `vector.edit.cornerRadius`, `vector.edit.end`. Point indices refer to `network.vertices` in `vector.edit.state`; local coordinates are the layer's own. +### Prototypes + +Interactions are `proto.interaction.add` / `set` / `remove` / `clear` (a navigate action can keep +the scroll position with `preserveScroll: true`), flows `proto.flow.add` / `rename` / `remove`, +and the device and background `proto.settings {device?, background?}` (`None`, `iPhone 16`, +`iPhone 16 Pro Max`, `Android Compact`, `iPad mini`, `MacBook Air`, `Desktop`). On a device the +presented frame is sized to the device's screen: what doesn't fit is clipped, or scrolls when the +frame's `overflow` allows it, while `fixed` layers stay on screen (pinned to the edges their +constraints name). `proto.present {start?}` plays the prototype full-window and `proto.preview +{start?}` in a resizable pane over the canvas that follows edits live; `proto.closePreview` closes +it. The player's state is in `inspect_ui` (`presenter`, `preview`) when attached, and `ui.set +{presentScroll: [x, y]}` scrolls its screen (see [control-protocol.md](control-protocol.md)). + ### Local libraries A `.figcraft` file can be published as a library and used from other files (local files only: no