mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-02 06:46:50 -06:00
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:
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+709
-196
File diff suppressed because it is too large
Load Diff
@@ -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, ¶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");
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user