Files
figcraft/crates/ui-egui/src/present_tests.rs
BFlatandClaude Opus 5.5 b2738d8f0b 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>
2026-10-10 09:48:29 +07:00

195 lines
8.4 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The prototype player driven headless through egui: the preview pane opens from a command,
//! follows edits and the editor's selection, takes the keys its prototype uses, and the presented
//! frame scrolls inside the device's screen with the wheel and by dragging.
use egui::{Event, Key, Pos2, RawInput, Rect, pos2, vec2};
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use serde_json::{Value, json};
use crate::{FigApp, NoServices};
pub(crate) struct Ui {
pub(crate) ctx: egui::Context,
pub(crate) app: FigApp,
pub(crate) time: f64,
}
impl Ui {
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
}
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() };
let app = &mut self.app;
self.ctx.run_ui(input, |ui| app.ui(ui)).textures_delta.clear();
}
pub(crate) fn frames(&mut self, n: usize, events: impl Fn() -> Vec<Event>) {
for _ in 0..n {
self.frame(events());
}
}
pub(crate) fn run(&mut self, cmd: &str, params: Value) -> Value {
let r = self.app.session.execute(cmd, &params).unwrap();
self.app.handle_ui_requests();
self.frame(vec![]);
r
}
/// The preview pane's window on screen.
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`).
pub(crate) fn preview(&self) -> Value {
self.app.inspect()["preview"].clone()
}
pub(crate) fn scroll_y(&self) -> f64 {
self.preview()["scroll"][1].as_f64().unwrap()
}
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() },
]);
}
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)]);
}
}
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.
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"}));
let a = id(&ex(&mut s, "node.create", json!({"type": "frame", "name": "A", "x": 0, "y": 0, "width": 393, "height": 2000})));
ex(&mut s, "node.set", json!({"id": a, "props": {"overflow": "vertical"}}));
let card = id(&ex(&mut s, "node.create", json!({"type": "rectangle", "parent": a, "x": 20, "y": 100, "width": 200, "height": 100})));
let bar = id(&ex(&mut s, "node.create", json!({"type": "rectangle", "parent": a, "x": 0, "y": 1940, "width": 393, "height": 60})));
ex(&mut s, "node.set", json!({"id": bar, "props": {"scrollPosition": "fixed", "constraints": {"vertical": "bottom"}}}));
let b = id(&ex(&mut s, "node.create", json!({"type": "frame", "name": "B", "x": 500, "y": 0, "width": 393, "height": 852})));
ex(&mut s, "select.set", json!({"ids": [card]}));
(Ui::new(s), a, b, card)
}
#[test]
fn preview_pane_opens_follows_edits_and_the_selection() {
let (mut u, a, b, card) = setup();
assert_eq!(u.run("proto.preview", json!({}))["start"], json!(a), "the selected layer's frame");
let pane = u.pane();
assert!(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0)).contains_rect(pane), "{pane:?}");
assert_eq!(u.preview()["current"], json!(a));
assert_eq!(u.preview()["device"], "iPhone 16");
// Edits show live.
u.run("node.set", json!({"id": a, "props": {"name": "Home"}}));
assert_eq!(u.preview()["name"], "Home");
// Arrow keys over the pane step to the next frame there and don't nudge the selection.
let before = u.app.session.doc().unwrap().world_bounds(card).unwrap();
u.key(pane.center(), Key::ArrowRight);
assert_eq!(u.preview()["current"], json!(b));
assert_eq!(u.app.session.doc().unwrap().world_bounds(card).unwrap(), before);
// Away from the pane the editor has the keyboard.
u.key(pos2(600.0, 450.0), Key::ArrowRight);
assert_eq!(u.preview()["current"], json!(b));
assert_ne!(u.app.session.doc().unwrap().world_bounds(card).unwrap(), before, "nudged");
// Selecting another top-level frame in the editor shows it.
u.run("select.set", json!({"ids": [b]}));
assert_eq!(u.preview()["current"], json!(b));
u.run("select.set", json!({"ids": [a]}));
assert_eq!(u.preview()["current"], json!(a));
// Deselecting and selecting the same frame again keeps where the preview got to.
u.key(pane.center(), Key::ArrowRight);
u.run("select.set", json!({"ids": []}));
u.run("select.set", json!({"ids": [a]}));
assert_eq!(u.preview()["current"], json!(b));
// Deleting the screen shown falls back to the start, here B itself: nothing to show.
u.run("proto.preview", json!({"start": b}));
u.run("edit.delete", json!({"ids": [b]}));
assert!(u.app.ui.preview.is_some(), "the pane stays open");
u.run("edit.undo", json!({}));
assert_eq!(u.preview()["current"], json!(b), "back when the frame is");
u.run("proto.closePreview", json!({}));
assert!(u.app.ui.preview.is_none());
}
#[test]
fn the_frame_scrolls_on_the_device_screen_by_wheel_and_drag() {
let (mut u, a, b, _) = setup();
u.run("proto.preview", json!({"start": a}));
assert_eq!(u.preview()["viewport"], json!([393.0, 852.0]));
assert_eq!(u.preview()["scrollRange"], json!([0.0, 1148.0]));
let c = u.pane().center();
// The wheel scrolls (egui smooths it over a few frames).
let wheel = move || {
vec![
Event::PointerMoved(c),
Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, -60.0),
phase: egui::TouchPhase::Move,
modifiers: Default::default(),
},
]
};
u.frames(6, wheel);
u.frames(20, Vec::new);
let after_wheel = u.scroll_y();
assert!(after_wheel > 0.0, "{after_wheel}");
// Dragging up scrolls further, by the drag over the zoom, and isn't a click.
u.button(c, true);
for k in 1..=10 {
u.frame(vec![Event::PointerMoved(c - vec2(0.0, 10.0 * k as f32))]);
}
u.button(c - vec2(0.0, 100.0), false);
let dragged = u.scroll_y() - after_wheel;
assert!(dragged > 100.0, "the screen is shown below 100%: {dragged}");
// Never past the end: 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);
assert_eq!(u.preview()["current"], json!(b));
assert_eq!(u.scroll_y(), 0.0);
}
#[test]
fn full_window_presenter_shares_the_player() {
let (mut u, a, ..) = setup();
u.run("proto.present", json!({"start": a}));
assert_eq!(u.app.inspect()["presenter"]["current"], json!(a));
crate::control::handle(&mut u.app, &u.ctx, "ui.set", &json!({"presentScroll": [0, 5000]})).unwrap();
assert_eq!(u.app.inspect()["presenter"]["scroll"], json!([0.0, 1148.0]));
u.frame(vec![]);
u.key(pos2(700.0, 450.0), Key::Escape);
assert!(u.app.ui.present.is_none());
// The control channel opens and closes the pane too.
crate::control::handle(&mut u.app, &u.ctx, "ui.set", &json!({"preview": a.0})).unwrap();
assert_eq!(u.preview()["current"], json!(a));
crate::control::handle(&mut u.app, &u.ctx, "ui.set", &json!({"preview": false})).unwrap();
assert!(u.app.ui.preview.is_none());
assert!(crate::control::handle(&mut u.app, &u.ctx, "ui.set", &json!({"presentScroll": [0, 1]})).is_err(), "nothing presented");
}