M5: prototype device rotation, presentation scaling, scroll momentum, mode picker (#62)

- Device rotation: phones and tablets turn to landscape (`proto.settings {rotation}`), saved in
  the file and imported from REST files (`CCW_90`); the presenter and the preview pane size the
  screen to the turned device and draw its body turned. A rotate toggle sits beside the device
  picker in the Prototype panel.
- Presentation options: fit, fit width, fill and 100% (`proto.presentOptions {scale?,
  deviceFrame?}`, an Options menu in the presenter, Z cycles them, R restarts), the device frame
  shown or hidden, kept between presentations; what overflows the window pans by wheel or drag.
  Presenter textures are capped to the GPU's limit (big frames at 100% no longer overflow it).
- Scroll momentum: drags fling and slow down exactly per frame-rate-independent sub-steps, the
  wheel coasts after it stops, a drag past the ends stretches and springs back; repaints only
  while moving, a press catches a fling.
- Variable modes in the Design panel: frames pick a mode per multi-mode collection, or Auto
  (inherited); `variable.setMode {mode: null}` sets Auto.
- Tests in new modules (engine proto_present_tests, UI present_view_tests); docs and ROADMAP.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat 2026-10-10 09:48:29 +07:00 committed by GitHub
parent b26bbb2da8
commit b2738d8f0b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1214 additions and 165 deletions

View File

@ -83,8 +83,9 @@ dimension measured side by side.
the library's name in the Variables editor and the variable pickers; binding, aliasing or setting
a library collection's mode on a frame copies the variables in as read-only linked copies, so the
file renders without the library; library components keep their variable bindings and modes;
"Library updates available" covers changed variables and bound layers follow).
*Left:* a mode picker for explicit modes in the Design panel (engine and MCP only so far). 1–2 h.
"Library updates available" covers changed variables and bound layers follow). Explicit variable
modes in the Design panel: a frame's mode per collection, Auto (inherited) or its own.
*Left:* library collections in the Design panel's mode picker. 1 h.
5. **Prototyping depth**: *landed:* on-canvas connections with drag-to-connect, interaction editor,
flows, smart animate, overlays, scroll/fixed/sticky, change-to and set-variable in the presenter.
Custom bezier and custom spring easings with editor fields; device frames (phone, tablet,
@ -93,8 +94,13 @@ dimension measured side by side.
(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.
plays the selected frame and follows edits live; device rotation (phones and tablets turn to
landscape: saved in the file, imported from REST files, drawn turned in the presenter and the
pane); presentation options (fit, fit width, fill, 100%, with Z to cycle them and R to
restart, the device frame shown or hidden, kept between presentations; what overflows the
window pans); scroll momentum (drags fling and slow down the same at any frame rate, the wheel
coasts, a drag past the ends stretches and springs back). *Left:* responsive presentation
(frames resized to the window), device colours and custom device sizes. 1–2 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
@ -126,7 +132,7 @@ dimension measured side by side.
### Where we're going
- **Next:** the fidelity pass (7) and hardening (9: zoomed-out renders and save/load at 50k layers,
a real-file corpus); variable width strokes (in progress).
a real-file corpus); variable width strokes (in progress); responsive presentation.
- **Then:** interop depth (6: PDF export, .fig best effort), plugins and collaboration (8).
- **Alpha:** release builds on every push to `release` (packaging in progress), demo files, docs.

View File

@ -460,41 +460,77 @@ pub struct Flow {
pub description: String,
}
/// How the prototype device is held.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum DeviceRotation {
#[default]
Portrait,
/// Turned a quarter turn counter-clockwise: the screen is wider than it is tall.
Landscape,
}
impl DeviceRotation {
/// From a command or file value: `portrait` / `landscape`, or the angle in degrees (0, 90).
pub fn parse(v: &str) -> Option<Self> {
match v.trim().to_ascii_lowercase().as_str() {
"portrait" | "0" | "none" => Some(Self::Portrait),
"landscape" | "90" | "ccw_90" => Some(Self::Landscape),
_ => None,
}
}
}
/// Prototype device settings on a page.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct PrototypeSettings {
pub device: String,
pub background: figcraft_color::Color,
/// Applies to devices that turn (phones and tablets).
pub rotation: DeviceRotation,
}
impl Default for PrototypeSettings {
fn default() -> Self {
Self { device: "None".into(), background: figcraft_color::Color::from_rgb8_const(0x1E, 0x1E, 0x1E) }
Self { device: "None".into(), background: figcraft_color::Color::from_rgb8_const(0x1E, 0x1E, 0x1E), rotation: DeviceRotation::Portrait }
}
}
/// A device prototypes are presented on: its name and screen size in points.
/// A device prototypes are presented on: its name and screen size in points (held upright).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Device {
pub name: &'static str,
pub width: f64,
pub height: f64,
/// It can be turned to landscape (phones and tablets; laptops and desktops can't).
pub rotates: bool,
}
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 },
Device { name: "iPhone 16", width: 393.0, height: 852.0, rotates: true },
Device { name: "iPhone 16 Pro Max", width: 440.0, height: 956.0, rotates: true },
Device { name: "Android Compact", width: 412.0, height: 917.0, rotates: true },
Device { name: "iPad mini", width: 744.0, height: 1133.0, rotates: true },
Device { name: "MacBook Air", width: 1280.0, height: 832.0, rotates: false },
Device { name: "Desktop", width: 1440.0, height: 1024.0, rotates: false },
];
/// The chosen device (None for "None" or a name we don't know).
pub fn device(&self) -> Option<Device> {
/// The chosen device model, held upright (None for "None" or a name we don't know).
pub fn model(&self) -> Option<Device> {
Self::DEVICES.iter().find(|d| d.name == self.device).copied()
}
/// The device turned to landscape (only devices that turn do).
pub fn landscape(&self) -> bool {
self.rotation == DeviceRotation::Landscape && self.model().is_some_and(|d| d.rotates)
}
/// The chosen device as presented: its screen is wider than tall when turned to landscape.
pub fn device(&self) -> Option<Device> {
let d = self.model()?;
Some(if self.landscape() { Device { width: d.height, height: d.width, ..d } } else { d })
}
}

View File

@ -106,20 +106,33 @@ pub fn specs() -> Vec<CommandSpec> {
}
ok()
}),
spec!("proto.settings", "Prototype settings", "{device?, background?}", true, |s, p| {
let page = s.page()?;
let bg = color(p, "background");
let dev = string(p, "device");
if let NodeKind::Page { prototype, .. } = &mut s.doc_mut()?.get_mut(page)?.kind {
if let Some(c) = bg {
prototype.background = c;
}
if let Some(d) = dev {
prototype.device = d;
spec!(
"proto.settings",
"Prototype settings",
"{device?, background?, rotation? portrait|landscape (or 0|90; phones and tablets turn)}",
true,
|s, p| {
let page = s.page()?;
let bg = color(p, "background");
let dev = string(p, "device");
let rot = match p.get("rotation") {
None | Some(Value::Null) => None,
Some(v) => Some(rotation_of(v).ok_or_else(|| bad("proto.settings", "rotation: portrait|landscape"))?),
};
if let NodeKind::Page { prototype, .. } = &mut s.doc_mut()?.get_mut(page)?.kind {
if let Some(c) = bg {
prototype.background = c;
}
if let Some(d) = dev {
prototype.device = d;
}
if let Some(r) = rot {
prototype.rotation = r;
}
}
ok()
}
ok()
}),
),
spec!(
"proto.present",
"Present",
@ -138,9 +151,43 @@ pub fn specs() -> Vec<CommandSpec> {
s.request_ui("preview.close", json!({}));
ok()
}),
spec!(
"proto.presentOptions",
"Presentation options",
"{scale? fit|fitWidth|fill|actual, deviceFrame? bool} how the presenter sizes the prototype (Fit scales down to fit, Fit width fills the width, Fill covers the window, Actual shows it at 100%) and whether it draws the device; kept for later presentations",
false,
|s, p| {
let cmd = "proto.presentOptions";
let mut data = serde_json::Map::new();
if let Some(v) = p.get("scale").filter(|v| !v.is_null()) {
let scale = v.as_str().filter(|x| PRESENT_SCALES.contains(x)).ok_or_else(|| bad(cmd, "scale: fit|fitWidth|fill|actual"))?;
data.insert("scale".into(), json!(scale));
}
if let Some(v) = p.get("deviceFrame").filter(|v| !v.is_null()) {
data.insert("deviceFrame".into(), json!(v.as_bool().ok_or_else(|| bad(cmd, "deviceFrame: true|false"))?));
}
if data.is_empty() {
return Err(bad(cmd, "nothing to set: scale or deviceFrame"));
}
s.request_ui("present.options", Value::Object(data));
ok()
}
),
]
}
/// The presenter's scale options (`proto.presentOptions {scale}`).
pub const PRESENT_SCALES: [&str; 4] = ["fit", "fitWidth", "fill", "actual"];
/// A device rotation from a command value: `portrait` / `landscape`, or 0 / 90 degrees.
fn rotation_of(v: &Value) -> Option<DeviceRotation> {
match v {
Value::String(s) => DeviceRotation::parse(s),
Value::Number(n) => n.as_f64().and_then(|a| DeviceRotation::parse(&format!("{a}"))),
_ => None,
}
}
/// 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> {

View File

@ -149,7 +149,13 @@ pub fn specs() -> Vec<CommandSpec> {
true,
bind
),
spec!("variable.setMode", "Set variable mode", "{ids? (frames/page), collection, mode}", true, set_mode),
spec!(
"variable.setMode",
"Set variable mode",
"{ids? (frames/page), collection, mode (id or name; null: Auto, inherited from the parent)}",
true,
set_mode
),
spec!("variable.list", "List variables", "{}", false, |s, _| Ok(serde_json::to_value(&s.doc()?.variables).unwrap_or(Value::Null))),
]
}
@ -360,10 +366,16 @@ pub fn bind(s: &mut Session, p: &Value) -> Result<Value> {
pub fn set_mode(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "variable.setMode";
let c = req_string(cmd, p, "collection")?;
let m = req_string(cmd, p, "mode")?;
let doc = s.doc()?;
let coll = doc.variables.collection(&c).ok_or_else(|| bad(cmd, "no such collection"))?;
let mode = coll.modes.iter().find(|x| x.id == m || x.name == m).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, "no such mode"))?;
// A null mode clears the explicit mode: the layer inherits it again.
let mode = match p.get("mode") {
Some(Value::Null) => None,
_ => {
let m = req_string(cmd, p, "mode")?;
Some(coll.modes.iter().find(|x| x.id == m || x.name == m).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, "no such mode"))?)
}
};
let cid = coll.id.clone();
let mut ids = targets(s, p);
if ids.is_empty() {
@ -371,7 +383,11 @@ pub fn set_mode(s: &mut Session, p: &Value) -> Result<Value> {
}
let doc = s.doc_mut()?;
for i in ids {
doc.get_mut(i)?.var_modes.insert(cid.clone(), mode.clone());
let modes = &mut doc.get_mut(i)?.var_modes;
match &mode {
Some(m) => modes.insert(cid.clone(), m.clone()),
None => modes.remove(&cid),
};
}
ok()
}

View File

@ -895,6 +895,8 @@ mod paint_tests;
#[cfg(test)]
mod proto_multi_tests;
#[cfg(test)]
mod proto_present_tests;
#[cfg(test)]
mod rotated_select_tests;
#[cfg(test)]
mod section_tests;

View File

@ -0,0 +1,99 @@
//! Prototype presentation settings through the session: the device's rotation (saved in the
//! file), the presenter's scale options and explicit variable modes set back to Auto.
use figcraft_doc::NodeKind;
use figcraft_doc::proto::{DeviceRotation, PrototypeSettings};
use serde_json::json;
use crate::Session;
use crate::params::id_of;
fn settings(s: &Session) -> PrototypeSettings {
let doc = s.doc().unwrap();
match &doc.node(s.page().unwrap()).unwrap().kind {
NodeKind::Page { prototype, .. } => prototype.clone(),
_ => panic!("not a page"),
}
}
#[test]
fn device_rotation_turns_phones_and_tablets_and_is_saved() {
let mut s = Session::new();
s.execute("proto.settings", &json!({"device": "iPhone 16"})).unwrap();
assert_eq!(settings(&s).rotation, DeviceRotation::Portrait);
assert_eq!(settings(&s).device().map(|d| (d.width, d.height)), Some((393.0, 852.0)));
// Landscape swaps the screen's sides.
s.execute("proto.settings", &json!({"rotation": "landscape"})).unwrap();
assert!(settings(&s).landscape());
assert_eq!(settings(&s).device().map(|d| (d.width, d.height)), Some((852.0, 393.0)));
// Undoable, and degrees work too.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(settings(&s).rotation, DeviceRotation::Portrait);
s.execute("proto.settings", &json!({"rotation": 90})).unwrap();
assert!(settings(&s).landscape());
// Laptops and desktops don't turn: the rotation is kept but doesn't apply.
s.execute("proto.settings", &json!({"device": "MacBook Air"})).unwrap();
assert_eq!(settings(&s).rotation, DeviceRotation::Landscape);
assert!(!settings(&s).landscape());
assert_eq!(settings(&s).device().map(|d| (d.width, d.height)), Some((1280.0, 832.0)));
// Junk is refused and changes nothing.
for bad in [json!("sideways"), json!(45), json!(true), json!({}), json!(f64::MAX)] {
assert!(s.execute("proto.settings", &json!({"rotation": bad})).is_err(), "{bad}");
}
assert_eq!(settings(&s).rotation, DeviceRotation::Landscape);
// Saved in the file and read back; files without it open in portrait.
s.execute("proto.settings", &json!({"device": "iPad mini"})).unwrap();
let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
let (doc, _) = figcraft_format::load_bytes(&bytes).unwrap();
let NodeKind::Page { prototype, .. } = &doc.node(doc.pages[0]).unwrap().kind else { panic!("not a page") };
assert_eq!(prototype.device().map(|d| (d.width, d.height)), Some((1133.0, 744.0)));
let old: PrototypeSettings = serde_json::from_value(json!({"device": "iPhone 16"})).unwrap();
assert_eq!(old.rotation, DeviceRotation::Portrait);
}
#[test]
fn present_options_ask_the_ui_and_check_their_values() {
let mut s = Session::new();
s.ui_requests.clear();
s.execute("proto.presentOptions", &json!({"scale": "fitWidth", "deviceFrame": false})).unwrap();
let r = s.ui_requests.last().unwrap();
assert_eq!((r.kind.as_str(), &r.data), ("present.options", &json!({"scale": "fitWidth", "deviceFrame": false})));
for scale in crate::cmd::proto::PRESENT_SCALES {
s.execute("proto.presentOptions", &json!({"scale": scale})).unwrap();
}
assert!(s.execute("proto.presentOptions", &json!({"scale": "huge"})).is_err());
assert!(s.execute("proto.presentOptions", &json!({"deviceFrame": "yes"})).is_err());
assert!(s.execute("proto.presentOptions", &json!({})).is_err(), "nothing to set");
// It's a viewing option: the document doesn't change.
assert!(!s.ds().unwrap().is_dirty());
}
#[test]
fn explicit_variable_modes_go_back_to_auto() {
let mut s = Session::new();
let id = |v: serde_json::Value| id_of(&v["id"]).unwrap();
let f = id(s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10, "parent": f})).unwrap());
s.execute("variable.createCollection", &json!({"name": "Tokens", "modes": ["Light", "Dark"]})).unwrap();
s.execute("variable.create", &json!({"collection": "Tokens", "name": "size", "type": "number", "value": 20})).unwrap();
s.execute("variable.set", &json!({"variable": "size", "mode": "Dark", "value": 40})).unwrap();
s.execute("variable.bind", &json!({"ids": [r], "field": "width", "variable": "size"})).unwrap();
let width = |s: &Session| s.doc().unwrap().node(r).unwrap().width;
s.execute("variable.setMode", &json!({"ids": [f], "collection": "Tokens", "mode": "Dark"})).unwrap();
assert_eq!(width(&s), 40.0);
// Auto: the frame inherits the page's mode again, and the layer follows.
s.execute("variable.setMode", &json!({"ids": [f], "collection": "Tokens", "mode": null})).unwrap();
assert!(s.doc().unwrap().node(f).unwrap().var_modes.is_empty());
assert_eq!(width(&s), 20.0);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(width(&s), 40.0);
// The page set to Dark: Auto inherits it.
let page = s.page().unwrap();
s.execute("variable.setMode", &json!({"ids": [page], "collection": "Tokens", "mode": "Dark"})).unwrap();
s.execute("variable.setMode", &json!({"ids": [f], "collection": "Tokens", "mode": "Light"})).unwrap();
assert_eq!(width(&s), 20.0);
s.execute("variable.setMode", &json!({"ids": [f], "collection": "Tokens", "mode": null})).unwrap();
assert_eq!(width(&s), 40.0);
assert!(s.execute("variable.setMode", &json!({"ids": [f], "collection": "Tokens", "mode": "Dusk"})).is_err());
assert!(s.execute("variable.setMode", &json!({"ids": [f], "collection": "Tokens"})).is_err(), "a mode, or null for Auto");
}

View File

@ -3,7 +3,7 @@
//! `prototypeDevice`, `prototypeBackgrounds` and `prototypeStartNodeID`.
use figcraft_doc::proto::{
Action, Direction, Easing, Interaction, Overflow, OverlayPosition, PrototypeSettings, ScrollPosition, Transition, Trigger,
Action, DeviceRotation, Direction, Easing, Interaction, Overflow, OverlayPosition, PrototypeSettings, ScrollPosition, Transition, Trigger,
};
use figcraft_doc::{Node, NodeId, VariableValue};
use serde_json::{Value, json};
@ -39,7 +39,7 @@ pub(super) fn scrolling(n: &mut Node, j: &Value) {
};
}
/// A page's prototype device and background, when the file sets them.
/// A page's prototype device (and its rotation) and background, when the file sets them.
pub(super) fn page_settings(c: &Value) -> Option<PrototypeSettings> {
let device = c.get("prototypeDevice").filter(|d| d.is_object());
let background = list(c, "prototypeBackgrounds").first().and_then(color).filter(|b| b.a > 0.0);
@ -49,6 +49,7 @@ pub(super) fn page_settings(c: &Value) -> Option<PrototypeSettings> {
let mut s = PrototypeSettings::default();
if let Some(d) = device {
s.device = device_name(d).to_string();
s.rotation = text(d, "rotation").and_then(DeviceRotation::parse).unwrap_or_default();
}
if let Some(b) = background {
s.background = b.with_alpha(1.0);

View File

@ -3,7 +3,9 @@
use super::tests::pointers;
use super::*;
use figcraft_doc::proto::{Action, Direction, Easing, Overflow, OverlayPosition, PrototypeSettings, ScrollPosition, Transition, Trigger};
use figcraft_doc::proto::{
Action, DeviceRotation, Direction, Easing, Overflow, OverlayPosition, PrototypeSettings, ScrollPosition, Transition, Trigger,
};
use figcraft_doc::{VariableType, VariableValue};
use serde_json::json;
@ -273,7 +275,7 @@ fn scrolling_flows_and_prototype_device() {
let (doc, _) = import(&f, &no_images).unwrap();
let NodeKind::Page { flows, prototype, .. } = &doc.node(doc.pages[0]).unwrap().kind else { panic!() };
assert_eq!(flows.iter().map(|f| f.start).collect::<Vec<_>>(), [named(&doc, "Details").id]);
prototype.device.clone()
prototype.clone()
};
for (d, want) in [
(json!({"type": "NONE", "rotation": "NONE"}), "None"),
@ -284,10 +286,14 @@ fn scrolling_flows_and_prototype_device() {
(json!({"type": "CUSTOM", "size": {"width": 1920, "height": 1080}, "rotation": "NONE"}), "Desktop"),
(json!({"type": "CUSTOM", "size": {"width": 375, "height": 667}, "rotation": "NONE"}), "iPhone 16"),
] {
let got = device(d);
let got = device(d).device;
assert_eq!(got, want);
assert!(got == "None" || PrototypeSettings::DEVICES.iter().any(|d| d.name == got), "{got}");
}
// A device turned a quarter turn presents in landscape.
let turned = device(json!({"type": "PRESET", "presetIdentifier": "APPLE_IPHONE_16", "rotation": "CCW_90"}));
assert_eq!((turned.device.as_str(), turned.rotation), ("iPhone 16", DeviceRotation::Landscape));
assert_eq!(turned.device().map(|d| (d.width, d.height)), Some((852.0, 393.0)));
}
// ---- robustness ----

View File

@ -48,6 +48,8 @@ mod perf_tests;
#[cfg(test)]
mod present_tests;
#[cfg(test)]
mod present_view_tests;
#[cfg(test)]
mod titlebar_tests;
use std::collections::BTreeSet;
@ -146,6 +148,9 @@ pub struct UiState {
pub icon_gallery: bool,
pub toast: Option<(String, f64)>,
pub present: Option<present::Presenter>,
/// The full-window presenter's options (how it sizes the prototype), kept between
/// presentations.
pub present_view: present::PresentView,
/// The prototype preview pane floating over the canvas, when open.
pub preview: Option<present::Pane>,
pub picker: Option<colorpicker::PickerTarget>,
@ -214,6 +219,7 @@ impl Default for UiState {
icon_gallery: false,
toast: None,
present: None,
present_view: present::PresentView::default(),
preview: None,
picker: None,
context_menu: None,
@ -322,7 +328,22 @@ impl FigApp {
"dialog.colorProfile" => self.ui.color_profile = self.session.doc().ok().map(|d| d.color_profile),
"present" => {
if let Some(start) = r.data.get("start").and_then(figcraft_engine::params::id_of) {
self.ui.present = Some(present::Presenter::new(start));
let mut p = present::Presenter::new(start);
p.view = self.ui.present_view;
self.ui.present = Some(p);
}
}
"present.options" => {
let v = &mut self.ui.present_view;
if let Some(s) = r.data.get("scale").and_then(|s| s.as_str()).and_then(present::PresentScale::parse) {
v.scale = s;
}
if let Some(f) = r.data.get("deviceFrame").and_then(|f| f.as_bool()) {
v.device_frame = f;
}
if let Some(p) = self.ui.present.as_mut() {
p.view.device_frame = v.device_frame;
p.set_scale(v.scale);
}
}
"preview" => {
@ -539,6 +560,7 @@ impl FigApp {
"dark": self.ui.dark,
"presenting": self.ui.present.is_some(),
"presenter": self.ui.present.as_ref().map(present::Presenter::inspect),
"presentOptions": {"scale": self.ui.present_view.scale.id(), "deviceFrame": self.ui.present_view.device_frame},
"preview": self.ui.preview.as_ref().map(|p| p.player.inspect()),
"mainMenu": self.ui.main_menu,
"palette": self.ui.palette.is_some(),

View File

@ -9,6 +9,11 @@
//! 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`]).
//!
//! The full-window presenter sizes the prototype by its [`PresentView`] (fit, fit width, fill or
//! actual size; Z cycles them); a prototype bigger than the window pans. Scrolling keeps its
//! momentum after a drag or the wheel lets go ([`fling_axis`]), and a drag past the end of a
//! scroll range stretches ([`rubber_band`]) and springs back on release.
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use std::sync::Arc;
@ -67,6 +72,113 @@ struct Anim {
easing: Easing,
}
/// What a wheel or drag scrolls: a frame with overflow (offsets in page units), or the whole view
/// when the prototype is bigger than the window (offsets in screen points).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Scroller {
Frame(NodeId),
View,
}
/// Scrolling that keeps going after the wheel or the pointer let go, slowing down; past the end of
/// the range (only after a drag, `bounce`) it springs back.
#[derive(Clone, Copy, Debug)]
struct Fling {
target: Scroller,
/// Units per second.
vel: (f64, f64),
/// How quickly it slows down (1/s).
decay: f64,
bounce: bool,
/// When it last moved (UI clock, seconds).
at: f64,
}
/// How quickly a fling slows down after a drag (1/s; it travels its speed / decay).
const DECAY_DRAG: f64 = 2.5;
/// How quickly the wheel's coast slows down (1/s).
const DECAY_WHEEL: f64 = 8.0;
/// Stiffness of the spring that pulls an overscroll back (1/s, critically damped).
const SPRING: f64 = 16.0;
/// A fling inside its range stops below this speed (units/s).
const FLING_STOP: f64 = 6.0;
/// The wheel coasts on when it was faster than this as it stopped (screen points/s).
const COAST_MIN: f64 = 40.0;
/// How far a drag past the end can stretch the content at most (screen points).
const RUBBER_PTS: f64 = 240.0;
/// How the full-window presenter sizes the prototype.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum PresentScale {
/// Scaled down to fit the window (never above 100%).
#[default]
Fit,
/// Scaled to the window's width; what's taller pans.
FitWidth,
/// Scaled to cover the whole window; what overflows pans.
Fill,
/// At 100%.
Actual,
}
impl PresentScale {
const ALL: [PresentScale; 4] = [PresentScale::Fit, PresentScale::FitWidth, PresentScale::Fill, PresentScale::Actual];
/// Its id in commands (`proto.presentOptions {scale}`).
pub fn id(self) -> &'static str {
match self {
PresentScale::Fit => "fit",
PresentScale::FitWidth => "fitWidth",
PresentScale::Fill => "fill",
PresentScale::Actual => "actual",
}
}
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|x| x.id() == s)
}
/// The options the presenter offers, in the order Z cycles them: on a device there's no Fit
/// width (the device fits, fills the window or shows at 100%).
pub fn options(device: bool) -> &'static [PresentScale] {
if device { &[PresentScale::Fit, PresentScale::Fill, PresentScale::Actual] } else { &Self::ALL }
}
pub fn label(self, device: bool) -> &'static str {
match (self, device) {
(PresentScale::Fit, false) => "Fit to window",
(PresentScale::FitWidth, _) => "Fit width",
(PresentScale::Fill, false) => "Fill window",
(PresentScale::Actual, false) => "Actual size (100%)",
(PresentScale::Fit, true) => "Fit device to window",
(PresentScale::Fill, true) => "Zoom device to fill window",
(PresentScale::Actual, true) => "Device at 100%",
}
}
/// The option after this one (Z), wrapping round.
pub fn next(self, device: bool) -> Self {
let opts = Self::options(device);
let i = opts.iter().position(|x| *x == self);
opts.get(i.map_or(0, |i| (i + 1) % opts.len())).copied().unwrap_or_default()
}
}
/// The full-window presenter's options (kept for later presentations; the preview pane always
/// fits, with the device drawn).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PresentView {
pub scale: PresentScale,
/// Draw the device's body around its screen.
pub device_frame: bool,
}
impl Default for PresentView {
fn default() -> Self {
Self { scale: PresentScale::Fit, device_frame: true }
}
}
/// A frame scrolling from one offset to another.
type ScrollMove = (NodeId, (f64, f64), (f64, f64));
@ -126,8 +238,20 @@ pub struct Presenter {
urls: Vec<String>,
/// The editor's document as last copied (the player copies it again when it changes).
source: Option<Arc<Document>>,
/// Scrolling by dragging: the frame and its offset when the drag began.
drag_scroll: Option<(NodeId, (f64, f64))>,
/// Scrolling by dragging: what it scrolls and its offset when the drag began.
drag_scroll: Option<(Scroller, (f64, f64))>,
/// How the full-window presenter sizes the prototype.
pub view: PresentView,
/// How far the view is panned when the prototype is bigger than the window (points; None:
/// centred across, at the top).
pan: Option<(f64, f64)>,
/// How far the view can pan, and the zoom it shows at (from the last layout).
pan_range: (f64, f64),
zoom: f64,
/// Scrolling that carries on after the input let go.
fling: Option<Fling>,
/// The wheel's last target and its speed (units/s), while it turns.
wheel: Option<(Scroller, (f64, f64))>,
}
impl Presenter {
@ -170,9 +294,79 @@ impl Presenter {
urls: Vec::new(),
source: None,
drag_scroll: None,
view: PresentView::default(),
pan: None,
pan_range: (0.0, 0.0),
zoom: 1.0,
fling: None,
wheel: None,
}
}
/// Stop scrolling motion (a fling, the wheel's coast, a drag).
fn stop_scrolling(&mut self) {
self.fling = None;
self.wheel = None;
self.drag_scroll = None;
}
/// Size the prototype another way; the view starts over at its top.
pub fn set_scale(&mut self, scale: PresentScale) {
self.view.scale = scale;
self.pan = None;
self.stop_scrolling();
}
/// The current offset of `t` (a frame's scroll, else the view's pan).
fn offset(&self, t: Scroller) -> (f64, f64) {
match t {
Scroller::Frame(f) => self.scroll.get(&f).copied().unwrap_or((0.0, 0.0)),
Scroller::View => self.pan.unwrap_or((self.pan_range.0 / 2.0, 0.0)),
}
}
/// How far `t` scrolls (`base`: the player's document without scroll offsets).
fn limits(&self, base: &Document, t: Scroller) -> (f64, f64) {
match t {
Scroller::Frame(f) => self.range(base, f),
Scroller::View => self.pan_range,
}
}
fn set_offset(&mut self, t: Scroller, v: (f64, f64)) {
if !v.0.is_finite() || !v.1.is_finite() {
return;
}
match t {
Scroller::Frame(f) => {
if self.scroll.get(&f) != Some(&v) {
self.scroll.insert(f, v);
self.rev += 1;
}
}
Scroller::View => self.pan = Some(v),
}
}
/// Move a fling on to `now`; true while it is still moving.
fn step_fling(&mut self, now: f64) -> bool {
let Some(mut f) = self.fling.take() else { return false };
let Some(base) = self.doc.as_ref() else { return false };
let r = self.limits(base, f.target);
let x = self.offset(f.target);
let dt = (now - f.at).clamp(0.0, 0.25);
f.at = now;
let (nx, vx, done_x) = fling_axis(x.0, f.vel.0, r.0, dt, f.decay, f.bounce);
let (ny, vy, done_y) = fling_axis(x.1, f.vel.1, r.1, dt, f.decay, f.bounce);
self.set_offset(f.target, (nx, ny));
f.vel = (vx, vy);
if done_x && done_y {
return false;
}
self.fling = Some(f);
true
}
/// 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.
@ -202,7 +396,7 @@ impl Presenter {
self.temp = None;
self.anim = None;
self.scroll_anim = None;
self.drag_scroll = None;
self.stop_scrolling();
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);
@ -264,12 +458,14 @@ impl Presenter {
let to = clamp_scroll((x, y), self.range(doc, self.current));
self.scroll.insert(self.current, to);
self.scroll_anim = None;
self.stop_scrolling();
self.rev += 1;
}
/// The player's state for `ui.inspect`.
pub fn inspect(&self) -> Value {
let doc = self.doc.as_ref();
let pan = self.offset(Scroller::View);
json!({
"current": self.current,
"name": doc.and_then(|d| d.node(self.current)).map(|n| n.name.as_str()),
@ -279,7 +475,14 @@ impl Presenter {
"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),
"landscape": doc.is_some_and(|d| page_settings(d, self.current).landscape()),
"viewport": doc.and_then(|d| viewport(d, self.current)).map(|v| [v.0, v.1]),
"scale": self.view.scale.id(),
"deviceFrame": self.view.device_frame,
"pan": [pan.0, pan.1],
"panRange": [self.pan_range.0, self.pan_range.1],
"zoom": self.zoom,
"flinging": self.fling.is_some(),
})
}
@ -322,7 +525,8 @@ impl Presenter {
self.overlays.clear();
self.scroll.clear();
self.scroll_anim = None;
self.drag_scroll = None;
self.stop_scrolling();
self.pan = None;
self.temp = None;
self.current = frame;
if let Some(s) = kept
@ -342,7 +546,8 @@ impl Presenter {
self.overlays.clear();
self.scroll.clear();
self.scroll_anim = None;
self.drag_scroll = None;
self.stop_scrolling();
self.pan = None;
self.anim = None;
self.temp = None;
self.doc = self.original.clone();
@ -357,7 +562,7 @@ impl Presenter {
self.overlays = t.snap.overlays;
self.history = t.snap.history;
self.scroll = t.snap.scroll;
self.drag_scroll = None;
self.stop_scrolling();
self.hovered.clear();
self.rev += 1;
}
@ -515,7 +720,7 @@ impl Presenter {
return Some(t.clone());
}
let bounds = figcraft_render::export_bounds(doc, id)?;
let pix = figcraft_render::render_node(doc, id, px_scale)?;
let pix = figcraft_render::render_node(doc, id, texture_scale(ctx, bounds, px_scale))?;
let img = crate::render_worker::screen_image(&pix, doc.color_profile);
let tex = ctx.load_texture(format!("present-{}", id.0), img, egui::TextureOptions::LINEAR);
let ft = FrameTex { key, tex, bounds };
@ -524,6 +729,19 @@ impl Presenter {
}
}
/// The most pixels a presented frame's texture holds (bigger prototypes, at 100% or filling the
/// window, are drawn from a smaller texture scaled up).
const MAX_TEXTURE_PIXELS: f64 = 16_777_216.0;
/// The pixel scale, at most `px`, at which `bounds` makes a texture the GPU takes (no side past
/// its limit) of at most [`MAX_TEXTURE_PIXELS`].
fn texture_scale(ctx: &egui::Context, bounds: figcraft_geom::Rect, px: f64) -> f64 {
let side = ctx.input(|i| i.max_texture_side) as f64 - 2.0;
let (w, h) = (bounds.width().max(1.0), bounds.height().max(1.0));
let s = px.min(side / w.max(h)).min((MAX_TEXTURE_PIXELS / (w * h)).sqrt());
if s.is_finite() && s > 0.0 { s } else { 1e-3 }
}
/// Page → screen mapping for one presented frame.
#[derive(Clone, Copy, Debug)]
pub struct Place {
@ -772,6 +990,66 @@ fn clamp_scroll((x, y): (f64, f64), (rx, ry): (f64, f64)) -> (f64, f64) {
(c(x, rx), c(y, ry))
}
/// One axis of a fling after `dt` seconds: from offset `x` moving at `v` (units/s) in a range
/// `0..=r`. Inside the range it slows down exponentially (`decay`, 1/s: it travels `v / decay`);
/// past an end it either stops there or, with `bounce`, a critically damped spring pulls it back.
/// Both are solved exactly over short sub-steps, so the motion doesn't depend on the frame rate.
/// Returns the new offset and speed, and whether it has come to rest.
pub fn fling_axis(x: f64, v: f64, r: f64, dt: f64, decay: f64, bounce: bool) -> (f64, f64, bool) {
if !(x.is_finite() && v.is_finite() && r.is_finite() && dt.is_finite() && decay > 0.0) || r <= 0.0 {
return (if r.is_finite() && x.is_finite() { x.clamp(0.0, r.max(0.0)) } else { 0.0 }, 0.0, true);
}
// A frame is never longer than a second, nor a fling faster than a million units a second.
let dt = dt.clamp(0.0, 1.0);
let steps = (dt * 240.0).ceil().clamp(1.0, 64.0);
let h = dt / steps;
let (mut x, mut v) = (x, v.clamp(-1e6, 1e6));
for _ in 0..steps as usize {
if (0.0..=r).contains(&x) {
let e = (-decay * h).exp();
x += v * (1.0 - e) / decay;
v *= e;
if !bounce && !(0.0..=r).contains(&x) {
x = x.clamp(0.0, r);
v = 0.0;
}
} else {
let end = x.clamp(0.0, r);
let (d, e) = (x - end, (-SPRING * h).exp());
let c = v + SPRING * d;
x = end + (d + c * h) * e;
v = (v - SPRING * c * h) * e;
}
}
if !x.is_finite() || !v.is_finite() {
return (0.0, 0.0, true);
}
let end = x.clamp(0.0, r);
if x == end && v.abs() < FLING_STOP {
return (x, 0.0, true);
}
if x != end && (x - end).abs() < 0.5 && v.abs() < FLING_STOP * 4.0 {
return (end, 0.0, true);
}
(x, v, false)
}
/// Where a drag to offset `x` shows in a range `0..=r`: past an end it stretches less the further
/// it goes, never more than `limit` past.
pub fn rubber_band(x: f64, r: f64, limit: f64) -> f64 {
if !x.is_finite() || !r.is_finite() || r <= 0.0 || !limit.is_finite() || limit <= 0.0 {
return if x.is_finite() && r.is_finite() { x.clamp(0.0, r.max(0.0)) } else { 0.0 };
}
let band = |over: f64| limit * (1.0 - 1.0 / (over * 0.55 / limit + 1.0));
if x < 0.0 {
-band(-x)
} else if x > r {
r + band(x - r)
} else {
x
}
}
/// 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)
@ -990,12 +1268,21 @@ struct Layout {
/// and overflowing content show around a frame presented without a device).
clip: Rect,
device: Option<Device>,
/// The device is turned to landscape.
landscape: bool,
/// The device's body is drawn around its screen.
body: bool,
overlays: Vec<(NodeId, Place, Rect)>,
/// Overlays animating out (same order as `Presenter::closing`).
closing: Vec<(NodeId, Place, Rect)>,
}
/// Bezel width around a device's screen of width `w`, and the screen's corner radius.
/// The width of a device's screen of `size` held upright (its short side when turned).
fn upright_w(size: Vec2, landscape: bool) -> f32 {
if landscape { size.y } else { size.x }
}
/// Bezel width around a device's screen of upright 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),
@ -1005,38 +1292,71 @@ fn bezel(device: &str, w: f32) -> (f32, f32) {
}
}
/// 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) {
/// How far a device's body reaches past its screen of `size`: (each side, top, bottom). A phone
/// turned to landscape has its side buttons above and below.
fn body_outset(device: &str, size: Vec2, landscape: bool) -> (f32, f32, f32) {
let w = upright_w(size, landscape);
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),
_ if landscape => (b, b + 3.0, b + 3.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<Layout> {
/// Place the presented frame in `area`: on its device's screen (the whole device sized by the
/// view's scale option), else the frame itself. Fit and 100% keep the chrome's padding from the
/// edges; Fit never goes above 100%. What doesn't fit the window pans (`Presenter::pan`). The
/// preview pane always fits.
fn layout(p: &mut Presenter, area: Rect, chrome: Chrome) -> Option<Layout> {
let disp = p.display()?;
let fb = disp.world_bounds(p.current)?;
let device = page_settings(&disp, p.current).device();
let settings = page_settings(&disp, p.current);
let (device, landscape) = (settings.device(), settings.landscape());
let view = if chrome.pane { PresentView::default() } else { p.view };
let body = device.filter(|_| view.device_frame);
let pad = if matches!(view.scale, PresentScale::Fit | PresentScale::Actual) { chrome.pad } else { 0.0 };
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)));
// The screen at `scale`, the body's outsets around it and the whole size.
let outer = |scale: f64| {
let size = vec2((sw * scale) as f32, (sh * scale) as f32);
let (x, top, bottom) = body.map_or((0.0, 0.0, 0.0), |d| body_outset(d.name, size, landscape));
(size, x, top, vec2(size.x + 2.0 * x, size.y + top + bottom))
};
// The factor that makes something of size `o` fit, fill… the available space.
let factor = |o: Vec2| {
let (kx, ky) = (avail.width() / o.x.max(1.0), avail.height() / o.y.max(1.0));
f64::from(match view.scale {
PresentScale::Fit => kx.min(ky),
PresentScale::FitWidth => kx,
PresentScale::Fill => kx.max(ky),
PresentScale::Actual => 1.0,
})
};
let mut scale = factor(vec2(sw as f32, sh as f32));
// The body doesn't scale exactly with the screen (its edges have a minimum): size it once more.
if body.is_some() {
scale *= factor(outer(scale).3);
}
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 max = if view.scale == PresentScale::Fit { 1.0 } else { 16.0 };
let scale = if scale.is_finite() { scale.clamp(0.01, max) } else { 0.01 };
let (size, x, top, whole) = outer(scale);
p.zoom = scale;
// What's bigger than the window pans: starting centred across and at the top.
let over = |d: f32| if d > 0.5 { d } else { 0.0 };
let excess = vec2(over(whole.x - avail.width()), over(whole.y - avail.height()));
p.pan_range = (f64::from(excess.x), f64::from(excess.y));
// While a drag or fling moves the view it may stretch past the ends; otherwise it stays in range.
let live = matches!(p.drag_scroll, Some((Scroller::View, _))) || p.fling.is_some_and(|f| f.target == Scroller::View);
if !live {
p.pan = p.pan.map(|v| clamp_scroll(v, p.pan_range));
}
let pan = p.offset(Scroller::View);
let along = |excess: f32, min: f32, centre: f32, len: f32, pan: f64| if excess > 0.0 { min - pan as f32 } else { centre - len / 2.0 };
let min = pos2(along(excess.x, avail.min.x, area.center().x, whole.x, pan.0), along(excess.y, avail.min.y, area.center().y, whole.y, pan.1));
let screen = Rect::from_min_size(min + vec2(x, top), size);
let place = Place::centered(fb, screen.center(), scale);
let place_overlay = |o: &OverlayState| {
let ob = disp.world_bounds(o.id)?;
@ -1047,7 +1367,7 @@ fn layout(p: &mut Presenter, area: Rect, pad: f32) -> Option<Layout> {
let overlays = p.overlays.iter().filter_map(place_overlay).collect();
let closing = p.closing.iter().filter_map(|(_, o)| place_overlay(o)).collect();
let clip = if device.is_some() { screen } else { area };
Some(Layout { disp, scale, place, screen, clip, device, overlays, closing })
Some(Layout { disp, scale, place, screen, clip, device, landscape, body: body.is_some(), overlays, closing })
}
/// The keys that present the current frame full-window.
@ -1076,6 +1396,8 @@ enum BarAction {
Restart,
/// Present full-window (from the pane).
Present,
/// The full-window presenter's options menu (handled in the bar).
Options,
}
/// The full-window presenter (while `app.ui.present` is set). Escape or Close ends it.
@ -1093,6 +1415,8 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
for u in pres.urls.drain(..) {
app.services.open_url(&u);
}
// The options carry over to the next presentation.
app.ui.present_view = pres.view;
if action == Some(BarAction::Close) || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
return;
}
@ -1178,11 +1502,12 @@ pub fn show_pane(app: &mut FigApp, ctx: &egui::Context) {
}
}
/// Body of the presentation device behind the screen `s` (drawn in code: rounded slabs).
fn device_body(p: &egui::Painter, device: &str, s: Rect) {
/// Body of the presentation device behind the screen `s` (drawn in code: rounded slabs). Turned
/// to landscape (a quarter turn counter-clockwise), a phone's left side is at the bottom.
fn device_body(p: &egui::Painter, device: &str, s: Rect, landscape: bool) {
let body = Color32::from_rgb(0x1C, 0x1C, 0x1E);
let edge = Color32::from_rgb(0x5A, 0x5A, 0x5E);
let w = s.width();
let w = upright_w(s.size(), landscape);
let (b, screen_r) = bezel(device, w);
match device {
"MacBook Air" => {
@ -1210,8 +1535,15 @@ fn device_body(p: &egui::Painter, device: &str, s: Rect) {
let slab = s.expand(b);
let r = CornerRadius::same((screen_r + b).min(255.0) as u8);
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));
// (on the left side, from the top, length) as fractions of the upright side.
for (left, f0, h) in [(true, 0.18, 0.06), (true, 0.27, 0.1), (false, 0.24, 0.13)] {
let btn = if landscape {
let y = if left { slab.max.y - 0.5 } else { slab.min.y - 2.5 };
Rect::from_min_size(pos2(slab.min.x + slab.width() * f0, y), vec2(slab.width() * h, 2.5))
} else {
let x = if left { slab.min.x - 2.0 } else { slab.max.x };
Rect::from_min_size(pos2(x, slab.min.y + slab.height() * f0), vec2(2.5, slab.height() * h))
};
p.rect_filled(btn, CornerRadius::same(1), edge);
}
}
@ -1222,9 +1554,9 @@ fn device_body(p: &egui::Painter, device: &str, s: Rect) {
}
/// 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(p: &egui::Painter, device: &str, s: Rect) {
let w = s.width();
/// square corners) and a phone's camera pill (at the left when turned to landscape).
fn device_glass(p: &egui::Painter, device: &str, s: Rect, landscape: bool) {
let w = upright_w(s.size(), landscape);
let (b, screen_r) = bezel(device, w);
if screen_r <= 0.0 {
return;
@ -1233,7 +1565,11 @@ fn device_glass(p: &egui::Painter, device: &str, s: Rect) {
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));
let pill = if landscape {
Rect::from_center_size(pos2(s.min.x + w * 0.045, s.center().y), vec2(w * 0.085, w * 0.3))
} else {
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);
}
}
@ -1271,21 +1607,27 @@ fn player(p: &mut Presenter, ui: &mut Ui, rect: Rect, now: f64, chrome: Chrome)
}
ctx.request_repaint();
}
// Scrolling that carries on after the input let go (repaints only while it moves).
if p.step_fling(now) {
ctx.request_repaint();
}
let resp = ui.interact(area, ui.id().with("present_area"), Sense::click_and_drag());
let over = resp.contains_pointer();
if let Some(l) = layout(p, area, chrome.pad) {
if let Some(l) = layout(p, area, chrome) {
// The pane only takes the keyboard while the pointer is over it.
input(p, &ctx, &l, over, chrome.keys && (!chrome.pane || over), now);
input(p, &ctx, &l, over, chrome.keys && (!chrome.pane || over), chrome.pane, now);
}
let Some(l) = layout(p, area, chrome.pad) else { return action };
let Some(l) = layout(p, area, chrome) else { return action };
let painter = ui.painter_at(area);
if let Some(d) = l.device {
device_body(&painter, d.name, l.screen);
if l.device.is_some() {
if let Some(d) = l.device.filter(|_| l.body) {
device_body(&painter, d.name, l.screen, l.landscape);
}
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);
if let Some(d) = l.device.filter(|_| l.body) {
device_glass(&painter, d.name, l.screen, l.landscape);
}
// Timers: wake up for the soonest pending "after delay".
let mut next: Option<f64> = None;
@ -1326,12 +1668,13 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome)
Some(NodeKind::Page { flows, .. }) => flows.clone(),
_ => vec![],
};
let device = page_settings(doc, p.current).device().is_some();
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)]
&[(BarAction::Close, "Close", Icon::Close), (BarAction::Restart, "Restart", Icon::Reset), (BarAction::Options, "Options", Icon::Dots)]
};
let mut x = bar.max.x - if chrome.pane { 6.0 } else { 8.0 };
for (act, label, icon) in buttons {
@ -1350,7 +1693,9 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome)
} else {
ui.painter().text(br.center(), Align2::CENTER_CENTER, *label, regular(11.5), text);
}
if resp.clicked() {
if *act == BarAction::Options {
egui::Popup::menu(&resp).width(220.0).show(|ui| options_menu(p, ui, device, now));
} else if resp.clicked() {
action = Some(*act);
}
}
@ -1383,6 +1728,31 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome)
action
}
/// The full-window presenter's options menu: how the prototype is sized (Z cycles them), the
/// device's body, restart (R).
fn options_menu(p: &mut Presenter, ui: &mut Ui, device: bool, now: f64) {
use crate::right::menu_check;
for s in PresentScale::options(device) {
if menu_check(ui, p.view.scale == *s, s.label(device), "") {
p.set_scale(*s);
ui.close();
}
}
if menu_check(ui, false, "Next size option", "Z") {
p.set_scale(p.view.scale.next(device));
ui.close();
}
ui.separator();
if device && menu_check(ui, p.view.device_frame, "Show device frame", "") {
p.view.device_frame = !p.view.device_frame;
ui.close();
}
if menu_check(ui, false, "Restart", "R") {
p.restart(p.start, now);
ui.close();
}
}
/// 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<NodeId> {
@ -1402,15 +1772,24 @@ 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)));
}
/// Screen points → `t`'s units (page units for frames, points for the view).
fn units_per_point(t: Scroller, scale: f64) -> f64 {
match t {
Scroller::Frame(_) => 1.0 / scale.max(1e-6),
Scroller::View => 1.0,
}
}
/// 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<egui::Key> = i
/// player takes the keyboard; `pane`: it plays in the preview pane (which always fits).
#[allow(clippy::too_many_arguments)]
fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, keys_on: bool, pane: bool, now: f64) {
let (pos, pressed, released, down, wheel, velocity, dt, keys) = ctx.input(|i| {
let keys: Vec<(egui::Key, egui::Modifiers)> = i
.events
.iter()
.filter_map(|e| match e {
egui::Event::Key { key, pressed: true, repeat: false, .. } => Some(*key),
egui::Event::Key { key, pressed: true, repeat: false, modifiers, .. } => Some((*key, *modifiers)),
_ => None,
})
.collect();
@ -1420,13 +1799,20 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, ke
i.pointer.button_released(egui::PointerButton::Primary),
i.pointer.primary_down(),
i.smooth_scroll_delta,
i.pointer.velocity(),
f64::from(i.unstable_dt).clamp(1.0 / 500.0, 0.1),
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;
// A drag that scrolled lets go: it carries on at the pointer's speed (and springs back from
// past the ends).
if !down && let Some((t, _)) = p.drag_scroll.take() {
let u = units_per_point(t, l.scale);
let r = p.doc.as_ref().map_or((0.0, 0.0), |b| p.limits(b, t));
let along = |v: f32, r: f64| if r > 0.0 { -f64::from(v) * u } else { 0.0 };
p.fling = Some(Fling { target: t, vel: (along(velocity.x, r.0), along(velocity.y, r.1)), decay: DECAY_DRAG, bounce: true, at: now });
}
// The active layer: the top overlay, else the frame.
let (root, place, clip) = match l.overlays.last() {
@ -1443,21 +1829,40 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, ke
return;
}
}
// The wheel scrolls the innermost frame under the pointer that can still move that way.
// The wheel scrolls the innermost frame under the pointer that can still move that way, else
// pans a view bigger than the window. When it stops it coasts on, slowing down.
if wheel != Vec2::ZERO
&& let Some(q) = pos
&& let Some(base) = p.doc.clone()
{
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 = clamp_scroll((cur.0 - f64::from(wheel.x) / l.scale, cur.1 - f64::from(wheel.y) / l.scale), p.range(&base, f));
let mut targets: Vec<Scroller> = scrollers_at(p, &base, doc, root, &place, q).into_iter().map(Scroller::Frame).collect();
if p.pan_range != (0.0, 0.0) {
targets.push(Scroller::View);
}
let mut moved = None;
for t in targets {
let u = units_per_point(t, l.scale);
let cur = p.offset(t);
let next = clamp_scroll((cur.0 - f64::from(wheel.x) * u, cur.1 - f64::from(wheel.y) * u), p.limits(&base, t));
if (next.0 - cur.0).abs() > 1e-6 || (next.1 - cur.1).abs() > 1e-6 {
p.scroll.insert(f, next);
p.set_offset(t, next);
p.scroll_anim = None;
p.rev += 1;
p.fling = None;
moved = Some((t, ((next.0 - cur.0) / dt, (next.1 - cur.1) / dt)));
break;
}
}
// The wheel's speed, smoothed over ~50 ms whatever the frame rate.
p.wheel = moved.map(|(t, inst)| {
let prev = p.wheel.filter(|w| w.0 == t).map_or(inst, |w| w.1);
let a = 1.0 - (-dt / 0.05).exp();
(t, (lerp(prev.0, inst.0, a), lerp(prev.1, inst.1, a)))
});
} else if wheel == Vec2::ZERO
&& let Some((t, v)) = p.wheel.take()
&& v.0.hypot(v.1) > COAST_MIN * units_per_point(t, l.scale)
{
p.fling = Some(Fling { target: t, vel: v, decay: DECAY_WHEEL, bounce: false, at: now });
}
let hits: Vec<(NodeId, Rect)> = hot.iter().rev().filter(|(_, r)| pos.is_some_and(|q| r.contains(q))).copied().collect();
if !hits.is_empty() {
@ -1498,6 +1903,10 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, ke
if pressed && pos.is_some() && over_area {
p.press = pos;
p.dragged = false;
// Pressing catches a fling (one springing back from past an end carries on).
let overscrolled = |f: &Fling| p.doc.as_ref().is_some_and(|b| clamp_scroll(p.offset(f.target), p.limits(b, f.target)) != p.offset(f.target));
p.fling = p.fling.filter(overscrolled);
p.wheel = None;
if let (Some((_, _, orect)), Some(o), Some(q)) = (l.overlays.last(), p.overlays.last(), pos)
&& !orect.contains(q)
{
@ -1532,24 +1941,31 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, ke
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))));
// Otherwise the drag scrolls the innermost frame under it that scrolls, else pans a view
// bigger than the window.
if let Some(base) = p.doc.clone() {
let target = scrollers_at(p, &base, doc, root, &place, start)
.first()
.copied()
.map(Scroller::Frame)
.or((p.pan_range != (0.0, 0.0)).then_some(Scroller::View));
if let Some(t) = target {
p.fling = None;
p.drag_scroll = Some((t, clamp_scroll(p.offset(t), p.limits(&base, t))));
}
}
}
// Dragging scrolls with the pointer; past the ends it stretches.
if down
&& let (Some((f, from)), Some(start), Some(q)) = (p.drag_scroll, p.press, pos)
&& let (Some((t, 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;
}
let (d, u) = (q - start, units_per_point(t, l.scale));
let r = p.limits(base, t);
let limit = RUBBER_PTS * u;
let to = (rubber_band(from.0 - f64::from(d.x) * u, r.0, limit), rubber_band(from.1 - f64::from(d.y) * u, r.1, limit));
p.set_offset(t, to);
p.scroll_anim = None;
ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
}
if released && p.press.is_some() {
@ -1573,7 +1989,7 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, ke
}
}
// Keys.
for key in &keys {
for (key, mods) in &keys {
let name = key.name();
let found = doc.subtree(root).into_iter().find_map(|id| {
find_trigger(doc, id, |t| matches!(t, Trigger::KeyPress { key } if key.eq_ignore_ascii_case(name) || key_alias(key) == Some(name)))
@ -1586,6 +2002,23 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, ke
}
continue;
}
// The player's own keys, when the prototype doesn't use them: R restarts, Z cycles the
// full-window presenter's size options.
if mods.is_none() {
match key {
egui::Key::R => {
p.restart(p.start, now);
consume_key(ctx, *key);
return;
}
egui::Key::Z if !pane => {
p.set_scale(p.view.scale.next(l.device.is_some()));
consume_key(ctx, *key);
return;
}
_ => {}
}
}
// 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()
@ -1664,7 +2097,9 @@ fn draw(p: &mut Presenter, painter: &egui::Painter, ctx: &egui::Context, l: &Lay
let t0 = std::time::Instant::now();
let tmp = smart_interpolate(&a.from_doc, a.from_frame, &l.disp, p.current, e);
let bounds = figcraft_render::export_bounds(&tmp, p.current);
if let (Some(b), Some(pix)) = (bounds, figcraft_render::render_node(&tmp, p.current, px)) {
if let Some(b) = bounds
&& let Some(pix) = figcraft_render::render_node(&tmp, p.current, texture_scale(ctx, b, px))
{
let img = crate::render_worker::screen_image(&pix, tmp.color_profile);
let tex = match &mut p.anim_tex {
Some(t) => {

View File

@ -9,20 +9,20 @@ use serde_json::{Value, json};
use crate::{FigApp, NoServices};
struct Ui {
ctx: egui::Context,
app: FigApp,
time: f64,
pub(crate) struct Ui {
pub(crate) ctx: egui::Context,
pub(crate) app: FigApp,
pub(crate) time: f64,
}
impl Ui {
fn new(s: Session) -> Self {
pub(crate) 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>) {
pub(crate) 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() };
@ -30,13 +30,13 @@ impl Ui {
self.ctx.run_ui(input, |ui| app.ui(ui)).textures_delta.clear();
}
fn frames(&mut self, n: usize, events: impl Fn() -> Vec<Event>) {
pub(crate) 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 {
pub(crate) 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![]);
@ -44,39 +44,39 @@ impl Ui {
}
/// The preview pane's window on screen.
fn pane(&self) -> Rect {
pub(crate) 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 {
pub(crate) fn preview(&self) -> Value {
self.app.inspect()["preview"].clone()
}
fn scroll_y(&self) -> f64 {
pub(crate) fn scroll_y(&self) -> f64 {
self.preview()["scroll"][1].as_f64().unwrap()
}
fn button(&mut self, p: Pos2, pressed: bool) {
pub(crate) 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) {
pub(crate) 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 {
pub(crate) 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) {
pub(crate) 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"}));
@ -161,12 +161,13 @@ fn the_frame_scrolls_on_the_device_screen_by_wheel_and_drag() {
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.
// Never past the end: dragged further it stretches, and springs back when let go.
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);
}
u.frames(90, Vec::new);
assert_eq!(u.scroll_y(), 1148.0);
// Navigating starts the next frame at the top.
u.key(c, Key::ArrowRight);

View File

@ -0,0 +1,296 @@
//! The presenter's scale options, device rotation and scroll momentum, driven headless through
//! egui (the harness is `present_tests`'), and the motion maths on its own.
use egui::{Event, Key, Pos2, pos2, vec2};
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use serde_json::{Value, json};
use crate::present::{PresentScale, fling_axis, rubber_band};
use crate::present_tests::{Ui, id, setup};
/// Run a fling for `secs` at `fps` frames a second; the offset where it ends and whether it rests.
fn fling_for(x: f64, v: f64, r: f64, decay: f64, bounce: bool, secs: f64, fps: f64) -> (f64, bool) {
let (mut x, mut v, mut done) = (x, v, false);
let dt = 1.0 / fps;
for _ in 0..(secs * fps).round() as usize {
(x, v, done) = fling_axis(x, v, r, dt, decay, bounce);
if done {
break;
}
}
(x, done)
}
#[test]
fn flings_slow_down_the_same_at_any_frame_rate() {
// It travels speed / decay, coming to rest.
let (x, done) = fling_for(0.0, 1000.0, 10_000.0, 2.5, true, 5.0, 60.0);
assert!(done && (x - 400.0).abs() < 3.0, "{x}");
// 30 and 144 frames a second end up in the same place.
let (a, _) = fling_for(100.0, 1500.0, 10_000.0, 2.5, true, 0.5, 30.0);
let (b, _) = fling_for(100.0, 1500.0, 10_000.0, 2.5, true, 0.5, 144.0);
assert!((a - b).abs() < 0.5, "{a} vs {b}");
// Without bounce it stops at the end; with bounce it overshoots and springs back exactly.
assert_eq!(fling_for(900.0, 3000.0, 1000.0, 8.0, false, 2.0, 60.0), (1000.0, true));
let mut x = 900.0;
let mut v = 3000.0;
let mut furthest: f64 = 0.0;
for _ in 0..240 {
let (nx, nv, done) = fling_axis(x, v, 1000.0, 1.0 / 60.0, 2.5, true);
(x, v) = (nx, nv);
furthest = furthest.max(x);
if done {
break;
}
}
assert!(furthest > 1000.0 && furthest < 1200.0, "{furthest}");
assert_eq!((x, v), (1000.0, 0.0));
// From past the start it comes back to 0.
assert_eq!(fling_for(-80.0, 0.0, 1000.0, 2.5, true, 3.0, 60.0), (0.0, true));
// Junk never panics and stays finite, in range.
for (x, v, r, dt) in [
(f64::NAN, 1.0, 10.0, 0.1),
(1.0, f64::INFINITY, 10.0, 0.1),
(1.0, 1.0, f64::NAN, 0.1),
(1.0, 1.0, 10.0, f64::NAN),
(5.0, 1e300, 10.0, 1e9),
(5.0, 1.0, -3.0, 0.1),
] {
let (nx, nv, _) = fling_axis(x, v, r, dt, 2.5, true);
assert!(nx.is_finite() && nv.is_finite(), "{x} {v} {r} {dt} → {nx} {nv}");
}
}
#[test]
fn rubber_band_stretches_less_the_further_it_goes() {
assert_eq!(rubber_band(50.0, 100.0, 240.0), 50.0);
let a = rubber_band(130.0, 100.0, 240.0);
let b = rubber_band(400.0, 100.0, 240.0);
let far = rubber_band(1e9, 100.0, 240.0);
assert!(100.0 < a && a < 130.0 && a < b && b < far && far <= 340.0, "{a} {b} {far}");
assert!(rubber_band(-30.0, 100.0, 240.0) < 0.0 && rubber_band(-30.0, 100.0, 240.0) > -30.0);
// Nothing to scroll, or junk: no stretch.
assert_eq!(rubber_band(-30.0, 0.0, 240.0), 0.0);
assert_eq!(rubber_band(f64::NAN, 100.0, 240.0), 0.0);
assert_eq!(rubber_band(130.0, 100.0, f64::NAN), 100.0);
}
#[test]
fn scale_options_match_the_command_and_cycle() {
for s in figcraft_engine::cmd::proto::PRESENT_SCALES {
assert_eq!(PresentScale::parse(s).map(PresentScale::id), Some(s));
}
assert_eq!(PresentScale::parse("huge"), None);
// Without a device Z goes fit → fit width → fill → 100% → fit; on one, fit width is skipped.
let mut s = PresentScale::Fit;
let mut seen = vec![];
for _ in 0..4 {
s = s.next(false);
seen.push(s);
}
assert_eq!(seen, [PresentScale::FitWidth, PresentScale::Fill, PresentScale::Actual, PresentScale::Fit]);
assert_eq!(PresentScale::Fit.next(true), PresentScale::Fill);
assert_eq!(PresentScale::FitWidth.next(true), PresentScale::Fit, "not offered on a device: back to the first");
assert!(!PresentScale::options(true).contains(&PresentScale::FitWidth));
}
/// No device: a big screen (2000×3000) and a small one, presenting the big one.
fn big() -> (Ui, NodeId) {
let mut s = Session::new();
let big = id(&s.execute("node.create", &json!({"type": "frame", "name": "Big", "x": 0, "y": 0, "width": 2000, "height": 3000})).unwrap());
s.execute("node.create", &json!({"type": "frame", "name": "Small", "x": 2100, "y": 0, "width": 300, "height": 200})).unwrap();
let mut u = Ui::new(s);
u.run("proto.present", json!({"start": big}));
(u, big)
}
fn presenter(u: &Ui) -> Value {
u.app.inspect()["presenter"].clone()
}
fn num(v: &Value) -> f64 {
v.as_f64().unwrap()
}
#[test]
fn presenter_scales_by_its_options_and_pans_what_overflows() {
let (mut u, big) = big();
let at = pos2(720.0, 470.0);
// Fit: scaled down to the 1376×796 inside the padding; nothing pans.
let p = presenter(&u);
assert_eq!(p["scale"], "fit");
assert!((num(&p["zoom"]) - 796.0 / 3000.0).abs() < 1e-3, "{p}");
assert_eq!(p["panRange"], json!([0.0, 0.0]));
// Z: fit width (the whole 1440 width; the rest of the height pans).
u.key(at, Key::Z);
let p = presenter(&u);
assert_eq!(p["scale"], "fitWidth");
assert!((num(&p["zoom"]) - 0.72).abs() < 1e-3, "{p}");
assert_eq!(p["panRange"][0], 0.0);
assert!((num(&p["panRange"][1]) - (2160.0 - 860.0)).abs() < 1.0, "{p}");
// Z: fill (here the width decides too), then 100%: both ways overflow, centred across at the top.
u.key(at, Key::Z);
assert_eq!(presenter(&u)["scale"], "fill");
u.key(at, Key::Z);
let p = presenter(&u);
assert_eq!((p["scale"].as_str(), num(&p["zoom"])), (Some("actual"), 1.0));
assert_eq!(p["panRange"], json!([624.0, 2204.0]));
assert_eq!(p["pan"], json!([312.0, 0.0]));
// The wheel pans down; a drag pans with the pointer.
let wheel = move || {
vec![
Event::PointerMoved(at),
Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, -80.0),
phase: egui::TouchPhase::Move,
modifiers: Default::default(),
},
]
};
u.frames(4, wheel);
u.frames(60, Vec::new);
let y = num(&presenter(&u)["pan"][1]);
assert!(y > 300.0, "{y}");
u.button(at, true);
for k in 1..=5 {
u.frame(vec![Event::PointerMoved(at - vec2(0.0, 20.0 * k as f32))]);
}
u.button(at - vec2(0.0, 100.0), false);
assert!((num(&presenter(&u)["pan"][1]) - (y + 100.0)).abs() < 1.0, "{}", presenter(&u));
// The command sets the options too; they carry over to the next presentation.
u.run("proto.presentOptions", json!({"scale": "fit"}));
assert_eq!(presenter(&u)["panRange"], json!([0.0, 0.0]));
u.run("proto.presentOptions", json!({"scale": "fill"}));
u.key(at, Key::Escape);
assert!(u.app.ui.present.is_none());
assert_eq!(u.app.inspect()["presentOptions"]["scale"], "fill");
u.run("proto.present", json!({"start": big}));
assert_eq!(presenter(&u)["scale"], "fill");
// R restarts.
u.key(at, Key::ArrowRight);
assert_eq!(presenter(&u)["name"], "Small");
u.key(at, Key::R);
assert_eq!(presenter(&u)["name"], "Big");
}
#[test]
fn devices_turn_and_their_frame_can_hide() {
let (mut u, a, ..) = setup();
u.run("proto.settings", json!({"rotation": "landscape"}));
u.run("proto.preview", json!({"start": a}));
let p = u.preview();
assert_eq!((p["device"].as_str(), &p["landscape"]), (Some("iPhone 16"), &json!(true)));
// The 393-wide screen shows 393 of the frame's height on the 852×393 screen; it scrolls the rest.
assert_eq!(p["viewport"], json!([393.0, 393.0]));
assert_eq!(p["scrollRange"], json!([0.0, 1607.0]));
u.run("proto.closePreview", json!({}));
// Full window, upright: hiding the device's body leaves more room for the screen.
u.run("proto.settings", json!({"rotation": "portrait"}));
u.run("proto.present", json!({"start": a}));
let with_body = num(&presenter(&u)["zoom"]);
u.run("proto.presentOptions", json!({"deviceFrame": false}));
let bare = num(&presenter(&u)["zoom"]);
assert!(bare > with_body && (bare - 796.0 / 852.0).abs() < 1e-3, "{with_body} {bare}");
// A device offers no fit width: Z goes fit → fill → 100%.
u.key(pos2(720.0, 470.0), Key::Z);
assert_eq!(presenter(&u)["scale"], "fill");
}
/// Drag the pane's screen by `dy` per frame for `n` frames, then let go.
fn flick(u: &mut Ui, from: Pos2, dy: f32, n: usize) {
u.frame(vec![Event::PointerMoved(from)]);
u.button(from, true);
for k in 1..=n {
u.frame(vec![Event::PointerMoved(from + vec2(0.0, dy * k as f32))]);
}
u.button(from + vec2(0.0, dy * n as f32), false);
}
#[test]
fn scrolling_keeps_its_momentum_and_springs_back_from_the_ends() {
let (mut u, a, ..) = setup();
u.run("proto.preview", json!({"start": a}));
let c = u.pane().center();
// A quick flick up carries on after letting go, slowing down to rest.
flick(&mut u, c + vec2(0.0, 100.0), -25.0, 6);
let released = u.scroll_y();
assert!(u.preview()["flinging"] == json!(true), "{}", u.preview());
u.frames(20, Vec::new);
let coasted = u.scroll_y();
assert!(coasted > released + 20.0, "{released} → {coasted}");
u.frames(400, Vec::new);
assert_eq!(u.preview()["flinging"], json!(false));
let rest = u.scroll_y();
assert!(rest >= coasted && rest <= 1148.0, "{rest}");
// A press catches a fling.
flick(&mut u, c + vec2(0.0, 100.0), 25.0, 6);
u.frames(2, Vec::new);
u.button(c, true);
let caught = u.scroll_y();
u.frames(10, Vec::new);
assert_eq!(u.scroll_y(), caught);
u.button(c, false);
// Dragged past the top it stretches while held, and springs back to it when let go.
crate::control::handle(&mut u.app, &u.ctx, "ui.set", &json!({"presentScroll": [0, 0]})).unwrap();
u.button(c, true);
for k in 1..=10 {
u.frame(vec![Event::PointerMoved(c + vec2(0.0, 12.0 * k as f32))]);
}
let stretched = u.scroll_y();
assert!(stretched < 0.0, "{stretched}");
for _ in 0..10 {
u.frame(vec![Event::PointerMoved(c + vec2(0.0, 120.0))]);
}
u.button(c + vec2(0.0, 120.0), false);
u.frames(90, Vec::new);
assert_eq!(u.scroll_y(), 0.0);
assert_eq!(u.preview()["flinging"], json!(false));
}
#[test]
fn the_wheel_coasts_after_it_stops() {
let (mut u, a, ..) = setup();
u.run("proto.preview", json!({"start": a}));
let c = u.pane().center();
let wheel = move || {
vec![
Event::PointerMoved(c),
Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, -40.0),
phase: egui::TouchPhase::Move,
modifiers: Default::default(),
},
]
};
u.frames(8, wheel);
let mut coasted = false;
for _ in 0..60 {
u.frame(vec![Event::PointerMoved(c)]);
coasted |= u.preview()["flinging"] == json!(true);
}
assert!(coasted, "the wheel's motion carried on");
u.frames(200, Vec::new);
assert_eq!(u.preview()["flinging"], json!(false), "and came to rest");
}
#[test]
fn design_panel_picks_variable_modes_for_frames() {
let (mut u, a, _, card) = setup();
let ex = |u: &mut Ui, c: &str, p: Value| u.run(c, p);
ex(&mut u, "variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]}));
ex(&mut u, "variable.createCollection", json!({"name": "A collection with a very long name indeed", "modes": ["One", "Two"]}));
ex(&mut u, "variable.createCollection", json!({"name": "Single", "modes": ["Only"]}));
// A frame selected (Auto), then with a mode of its own, then mixed with another frame; a
// rectangle has no modes section. Each draws without trouble.
for (sel, mode) in [(vec![a], None), (vec![a], Some("Dark")), (vec![a, card], None)] {
if let Some(m) = mode {
ex(&mut u, "variable.setMode", json!({"ids": [a], "collection": "Theme", "mode": m}));
}
ex(&mut u, "select.set", json!({"ids": sel}));
u.frames(2, Vec::new);
}
assert_eq!(u.app.session.doc().unwrap().node(a).unwrap().var_modes.len(), 1);
}

View File

@ -318,21 +318,30 @@ fn settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) {
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),
let settings = match doc.node(page).map(|n| &n.kind) {
Some(NodeKind::Page { prototype, .. }) => prototype.clone(),
_ => proto::PrototypeSettings::default(),
};
let bg = settings.background;
// Phones and tablets turn: a toggle beside the device picker.
let rotates = settings.model().is_some_and(|d| d.rotates);
let landscape = settings.landscape();
widgets::sublabels(ui, "Device", None);
row(ui, 32.0, |ui| {
let resp = widgets::dropdown(ui, &device, ui.available_width(), None);
let w = ui.available_width() - if rotates { 32.0 } else { 0.0 };
let resp = widgets::dropdown(ui, &settings.device, w, None);
egui::Popup::menu(&resp).show(|ui| {
for d in std::iter::once("None").chain(proto::PrototypeSettings::DEVICES.iter().map(|d| d.name)) {
if menu_item(ui, d, "") {
if crate::right::menu_check(ui, d == settings.device, d, "") {
app.run("proto.settings", json!({"device": d}));
ui.close();
}
}
});
let tip = if landscape { "Rotate to portrait" } else { "Rotate to landscape" };
if rotates && icons::button(ui, Icon::Rotate, 24.0, landscape, tip).clicked() {
app.run("proto.settings", json!({"rotation": if landscape { "portrait" } else { "landscape" }}));
}
});
widgets::sublabels(ui, "Background", None);
row(ui, 32.0, |ui| {

View File

@ -193,6 +193,11 @@ pub fn menu_item(ui: &mut Ui, label: &str, shortcut: &str) -> bool {
resp.clicked()
}
/// A menu item with a check mark when `on` (the unchecked ones line up with it).
pub fn menu_check(ui: &mut Ui, on: bool, label: &str, shortcut: &str) -> bool {
menu_item(ui, &format!("{}{label}", if on { "✓ " } else { " " }), shortcut)
}
pub(crate) fn row(ui: &mut Ui, h: f32, add: impl FnOnce(&mut Ui)) {
block(ui, h, (h - FIELD_H) / 2.0, add);
}
@ -235,6 +240,9 @@ fn design(app: &mut FigApp, ui: &mut Ui) {
widgets::separator(ui);
appearance(app, ui, &doc, &sel);
widgets::separator(ui);
if variable_modes(app, ui, &doc, &sel) {
widgets::separator(ui);
}
if sel.iter().any(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Text(_)))) {
crate::typography::typography(app, ui, &doc, &sel);
widgets::separator(ui);
@ -658,6 +666,62 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
ui.add_space(8.0);
}
/// The variable modes of the selected frames (sections, components, instances): a row per
/// collection with more than one mode, Auto (the mode the layer inherits from its parent) or a mode
/// of its own (`variable.setMode`). Returns whether it showed.
fn variable_modes(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) -> bool {
let colls: Vec<&Collection> = doc.variables.collections.iter().filter(|c| c.modes.len() > 1).collect();
if colls.is_empty() || !sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like())) {
return false;
}
let t = Tokens::get(ui.ctx());
widgets::section_header(ui, "Variable modes", &[], false);
// The collection's name, then a wider mode picker ("Auto (Light)") across both field columns.
let col = widgets::col_width(ui.available_width());
let (name_w, pick_w) = ((col * 0.7).round(), (col * 1.3).round());
// Auto resolves from the parent of the first selected layer.
let parent_modes = sel.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent).map(|p| doc.modes_for(p)).unwrap_or_default();
for c in colls {
let name_of = |m: &str| c.modes.iter().find(|x| x.id == m).map_or("", |x| x.name.as_str());
// Each layer's own mode (None: Auto); None overall when they differ.
let own = common(doc, sel, |n| n.var_modes.get(&c.id).cloned());
let inherited = parent_modes.get(&c.id).or(c.default_mode()).map_or("", |m| name_of(m));
let auto = format!("Auto ({inherited})");
let label = match &own {
Some(Some(m)) => name_of(m).to_string(),
Some(None) => auto.clone(),
None => "Mixed".into(),
};
row(ui, 32.0, |ui| {
let (r, _) = ui.allocate_exact_size(vec2(name_w, FIELD_H), Sense::hover());
let name = widgets::elide(ui, &c.name, name_w);
ui.painter().text(pos2(r.min.x, r.center().y), Align2::LEFT_CENTER, &name, regular(11.0), t.text);
if name != c.name {
ui.interact(r, Id::new(("var_mode_name", &c.id)), Sense::hover()).on_hover_text(&c.name);
}
let resp = widgets::elided_dropdown(ui, &label, pick_w);
egui::Popup::menu(&resp).width(180.0).show(|ui| {
let mut pick = None;
if menu_check(ui, own == Some(None), &auto, "") {
pick = Some(Value::Null);
}
ui.separator();
for m in &c.modes {
if menu_check(ui, own.as_ref().is_some_and(|o| o.as_ref() == Some(&m.id)), &m.name, "") {
pick = Some(json!(m.id));
}
}
if let Some(mode) = pick {
app.run("variable.setMode", json!({"ids": sel, "collection": c.id, "mode": mode}));
ui.close();
}
});
});
}
ui.add_space(8.0);
true
}
/// While a range of text is selected, the Fill section edits that range's colour.
fn range_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, start: usize, end: usize) {
let t = Tokens::get(ui.ctx());

View File

@ -40,31 +40,6 @@ const FEATURES: [(&str, &str, bool); 12] = [
const MIXED: &str = "Mixed";
/// Horizontal space a dropdown takes besides its label (padding and chevron).
const DROPDOWN_CHROME: f32 = 30.0;
/// A dropdown whose label stays on one line: long names (variable font styles like "SemiBold
/// SemiCondensed") end with an ellipsis and show in full on hover.
fn elided_dropdown(ui: &mut Ui, label: &str, width: f32) -> egui::Response {
let shown = elide(ui, label, width - DROPDOWN_CHROME);
let resp = widgets::dropdown(ui, &shown, width, None);
if shown != label { resp.on_hover_text(label) } else { resp }
}
/// `s` shortened with an ellipsis to fit `max_w` on one line.
fn elide(ui: &Ui, s: &str, max_w: f32) -> String {
let fits = |s: &str| ui.painter().layout_no_wrap(s.to_string(), regular(11.0), Color32::WHITE).size().x <= max_w;
if fits(s) {
return s.to_string();
}
let chars: Vec<char> = s.chars().collect();
(1..chars.len())
.rev()
.map(|n| format!("{}…", chars.iter().take(n).collect::<String>().trim_end()))
.find(|c| fits(c))
.unwrap_or_else(|| "…".into())
}
/// The value shared by every style, or None when they differ.
fn uniform<T: PartialEq + Clone>(styles: &[TextStyle], f: impl Fn(&TextStyle) -> T) -> Option<T> {
let mut it = styles.iter();
@ -132,7 +107,7 @@ pub fn typography(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId])
let fstyle = uniform(&styles, |s| s.font_style.clone());
row(ui, 32.0, |ui| {
let w = ui.available_width();
let resp = elided_dropdown(ui, family.as_deref().unwrap_or(MIXED), w);
let resp = widgets::elided_dropdown(ui, family.as_deref().unwrap_or(MIXED), w);
egui::Popup::menu(&resp).width(w.max(240.0)).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
if let Some(f) = font_picker(ui, family.as_deref(), resp.clicked()) {
remember_family(ui.ctx(), &f);
@ -145,7 +120,7 @@ pub fn typography(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId])
// Style + size.
let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0;
row(ui, 32.0, |ui| {
let resp = elided_dropdown(ui, fstyle.as_deref().unwrap_or(MIXED), half);
let resp = widgets::elided_dropdown(ui, fstyle.as_deref().unwrap_or(MIXED), half);
let fam = family.clone();
egui::Popup::menu(&resp).width(200.0).show(|ui| match &fam {
Some(fam) => {

View File

@ -373,6 +373,31 @@ pub fn dropdown(ui: &mut Ui, label: &str, width: f32, lead: Option<Icon>) -> Res
resp
}
/// Horizontal space a dropdown takes besides its label (padding and chevron).
const DROPDOWN_CHROME: f32 = 30.0;
/// A dropdown whose label stays on one line: long names (variable font styles like "SemiBold
/// SemiCondensed") end with an ellipsis and show in full on hover.
pub fn elided_dropdown(ui: &mut Ui, label: &str, width: f32) -> egui::Response {
let shown = elide(ui, label, width - DROPDOWN_CHROME);
let resp = dropdown(ui, &shown, width, None);
if shown != label { resp.on_hover_text(label) } else { resp }
}
/// `s` shortened with an ellipsis to fit `max_w` on one line.
pub fn elide(ui: &Ui, s: &str, max_w: f32) -> String {
let fits = |s: &str| ui.painter().layout_no_wrap(s.to_string(), regular(11.0), Color32::WHITE).size().x <= max_w;
if fits(s) {
return s.to_string();
}
let chars: Vec<char> = s.chars().collect();
(1..chars.len())
.rev()
.map(|n| format!("{}…", chars.iter().take(n).collect::<String>().trim_end()))
.find(|c| fits(c))
.unwrap_or_else(|| "…".into())
}
/// A row of icon buttons; returns the index clicked.
pub fn segmented(ui: &mut Ui, items: &[(Icon, &str)], active: Option<usize>, cell: f32) -> Option<usize> {
let t = Tokens::get(ui.ctx());

View File

@ -66,7 +66,10 @@ UI state: panels and tabs, current tool, canvas rect, dialogs, view, timings (`f
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).
`device` (and whether it is turned to `landscape`) and the `viewport` of the frame its screen shows,
the size option (`scale`: `fit|fitWidth|fill|actual`, `deviceFrame`), the `zoom` it shows at, how
far the view is panned (`pan`, `panRange`) and whether a scroll is still coasting (`flinging`)
(`null` when nothing plays). `presentOptions` holds the options the next presentation starts with.
### `ui.pointer {kind, x, y | sx, sy, to?, steps?, shift?, alt?, cmd?, ctrl?}`

View File

@ -150,14 +150,19 @@ from the pen's open end; clicking a point connects or branches), `vector.edit.pe
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
and the device, its rotation and the background `proto.settings {device?, background?,
rotation?}` (`None`, `iPhone 16`, `iPhone 16 Pro Max`, `Android Compact`, `iPad mini`, `MacBook
Air`, `Desktop`; `rotation` `portrait` or `landscape`, which turns phones and tablets). 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)).
it. `proto.presentOptions {scale?, deviceFrame?}` sets how the full-window presenter sizes the
prototype (`fit` scales it down to fit, `fitWidth` fills the width, `fill` covers the window,
`actual` shows it at 100%; what overflows pans) and whether it draws the device; the options are
kept for later presentations (in the presenter Z cycles them and R restarts). The player's state is
in `inspect_ui` (`presenter`, `preview`: screen, scroll, `scale`, `zoom`, `pan`) when attached, and
`ui.set {presentScroll: [x, y]}` scrolls its screen (see [control-protocol.md](control-protocol.md)).
### Local libraries
@ -265,7 +270,8 @@ as `variables` (a path, or the JSON object or text) to `open_file`, `file.open`
put it next to the file as `<name>.variables.json` or `variables.json`, or embed its `meta` in the
file JSON. Layers' `boundVariables` (sizes, gaps and padding, corner radii, opacity, stroke weight,
visibility, text, font properties, single solid fills and strokes) and `explicitVariableModes` then
link to them, so switching a mode with `variable.setMode` restyles the layers.
link to them, so switching a mode with `variable.setMode` restyles the layers (`mode: null` sets a
frame back to Auto, inheriting its parent's mode).
### Version history