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:
parent
b26bbb2da8
commit
b2738d8f0b
16
ROADMAP.md
16
ROADMAP.md
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 ----
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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, ¶ms).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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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?}`
|
||||
|
||||
|
|
|
|||
16
docs/mcp.md
16
docs/mcp.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue