Files
figcraft/crates/ui-egui/src/present_view_tests.rs
T
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

297 lines
12 KiB
Rust
Raw 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 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);
}