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 <noreply@anthropic.com>

* M5: reconcile with main: one device list, REST navigate keeps preserveScrollPosition

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat
2026-10-10 06:54:45 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent a85df4d5e5
commit 77a0e33b61
19 changed files with 1168 additions and 270 deletions
+1 -1
View File
@@ -102,7 +102,7 @@
</td>
<td width="50%" valign="top">
<h3>Prototype and hand off</h3>
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.
</td>
</tr>
</table>
+6 -2
View File
@@ -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 |
+1 -1
View File
@@ -756,7 +756,7 @@ impl Document {
fn remap_action(a: &mut proto::Action, map: &BTreeMap<NodeId, NodeId>) {
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;
}
+33 -6
View File
@@ -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<NodeId> {
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<Device> {
Self::DEVICES.iter().find(|d| d.name == self.device).copied()
}
}
+2 -2
View File
@@ -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());
}
+79 -18
View File
@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
}
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<Value> {
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<Value> {
{
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();
+1 -1
View File
@@ -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();
+4 -2
View File
@@ -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 },
@@ -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}");
}
}
+21 -1
View File
@@ -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", &params).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;
}
+8
View File
@@ -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);
+15
View File
@@ -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<present::Presenter>,
/// The prototype preview pane floating over the canvas, when open.
pub preview: Option<present::Pane>,
pub picker: Option<colorpicker::PickerTarget>,
pub context_menu: Option<(egui::Pos2, Vec<NodeId>)>,
/// 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(),
File diff suppressed because it is too large Load Diff
+193
View File
@@ -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<Event>) {
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<Event>) {
for _ in 0..n {
self.frame(events());
}
}
fn run(&mut self, cmd: &str, params: Value) -> Value {
let r = self.app.session.execute(cmd, &params).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");
}
+45 -32
View File
@@ -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<proto::Flow> {
}
}
/// 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);
+18 -2
View File
@@ -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));
+7
View File
@@ -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();
+9 -3
View File
@@ -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}`
+13
View File
@@ -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