Files
figcraft/crates/ui-egui/src/present.rs
T
BFlat a2e2012fdc M7: localization layer (#128)
UI strings come from per-language TOML catalogs compiled in (English text as the key and fallback, named placeholders, one/other plurals, context suffixes); command labels are translated at display time while ids, CLI, MCP and control output stay English; accessibility names follow. Preferences > Language (System / English / Español, live; the system language from the OS or the browser), pref.language for scripts. A key check test guards the catalogs. The Spanish catalog is a machine draft awaiting native review; docs/translating.md explains adding a language.
2026-10-11 16:21:16 +07:00

2797 lines
120 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 prototype player: presents a frame full-window ([`show`]) or in the preview pane floating
//! over the canvas ([`show_pane`]), and follows its interactions. Both run the same [`player`].
//!
//! The player works on its own copy of the document (interactive components, variables and scroll
//! offsets change it without touching the user's file) and copies the editor's document again
//! whenever it changes, so the preview pane follows edits live. On a device, the presented frame
//! is sized to the device's screen ([`fit_viewport`]): what doesn't fit is clipped, or scrolls when
//! the frame's overflow allows it. A responsive prototype's frame is instead resized to the screen
//! (the device's, else the player's whole area) and laid out again, its constraints and auto layout
//! responding (`figcraft_engine::cmd::proto::respond`, on the player's copy, from the design each
//! time). Device bodies are drawn in code in the device's colour. Every frame is rendered through
//! `figcraft_render::render_node`
//! 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;
use egui::{Align2, Color32, CornerRadius, Id, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2};
use figcraft_color::{Color, Paint, PaintKind};
use figcraft_doc::proto::{
Action, Device, DeviceColor, Direction, Easing, Overflow, OverlayPosition, PrototypeSettings, ScrollPosition, Transition, Trigger,
};
use figcraft_doc::{Document, HConstraint, Node, NodeId, NodeKind, VConstraint};
use figcraft_geom::{Affine, Point};
use serde_json::{Value, json};
use crate::FigApp;
use crate::a11y::Tip;
use crate::i18n::{tr, tr_noop};
use crate::icons::{self, Icon};
use crate::theme::{ACCENT, Tokens, regular, semibold};
use crate::widgets;
/// A page in the player's history (what Back returns to, and how).
#[derive(Clone, Debug)]
pub struct Visit {
pub frame: NodeId,
pub transition: Transition,
pub dur: f64,
pub easing: Easing,
}
/// An open overlay.
#[derive(Clone, Debug)]
pub struct OverlayState {
pub id: NodeId,
pub position: OverlayPosition,
pub close_on_outside: bool,
pub background: Option<Color>,
/// Screen rect of the layer that opened it (manual position).
pub anchor: Option<Rect>,
opened: f64,
transition: Transition,
dur: f64,
easing: Easing,
}
#[derive(Clone)]
struct FrameTex {
/// The player's revision, the pixel scale and the frames its animated GIFs show.
key: (u64, u64, u64),
tex: egui::TextureHandle,
/// Page-space rect the texture covers.
bounds: figcraft_geom::Rect,
}
/// A transition in progress from a captured state to the current one.
struct Anim {
from_doc: Document,
from_frame: NodeId,
from_tex: Option<FrameTex>,
transition: Transition,
start: f64,
dur: f64,
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) => tr("Fit to window"),
(PresentScale::FitWidth, _) => tr("Fit width"),
(PresentScale::Fill, false) => tr("Fill window"),
(PresentScale::Actual, false) => tr("Actual size (100%)"),
(PresentScale::Fit, true) => tr("Fit device to window"),
(PresentScale::Fill, true) => tr("Zoom device to fill window"),
(PresentScale::Actual, true) => tr("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,
/// Show the comments on the presented frame (Shift+C).
pub comments: bool,
/// A click where nothing reacts flashes the hotspots (the layers with interactions).
pub hotspot_hints: bool,
/// A panel beside the prototype shows the Inspect properties of the layer under the pointer.
pub inspect: bool,
}
impl Default for PresentView {
fn default() -> Self {
Self { scale: PresentScale::Fit, device_frame: true, comments: false, hotspot_hints: true, inspect: false }
}
}
/// How long the hotspot hints take to fade out (seconds).
const FLASH_SECS: f64 = 0.5;
/// A frame scrolling from one offset to another.
type ScrollMove = (NodeId, (f64, f64), (f64, f64));
/// "Scroll to" in progress: each moving frame with its offsets from and to.
struct ScrollAnim {
frames: Vec<ScrollMove>,
start: f64,
dur: f64,
easing: Easing,
}
#[derive(Clone)]
struct Snapshot {
current: NodeId,
doc: Document,
overlays: Vec<OverlayState>,
history: Vec<Visit>,
scroll: HashMap<NodeId, (f64, f64)>,
}
/// A "while hovering / while pressing" state that reverts when the pointer leaves / lifts.
struct Temp {
rect: Rect,
press: bool,
snap: Snapshot,
transition: Transition,
dur: f64,
easing: Easing,
}
pub struct Presenter {
pub current: NodeId,
pub history: Vec<Visit>,
pub overlays: Vec<OverlayState>,
/// Overlays playing their opening transition backwards after being closed (closed at, state).
closing: Vec<(f64, OverlayState)>,
/// Scroll offsets of frames with overflow (page units).
pub scroll: HashMap<NodeId, (f64, f64)>,
/// Milliseconds the last smart-animate frame took to build and render.
pub render_ms: f64,
start: NodeId,
original: Option<Document>,
/// The player's document as designed, with what the prototype changed (variables set,
/// variants changed); `doc` is it laid out for the screen.
design: Option<Document>,
/// The player's document as presented: `design` with the presented frame resized to `fit`.
doc: Option<Document>,
/// The screen a responsive prototype's frame is resized to (None: frames keep their size).
fit: Option<(f64, f64)>,
rev: u64,
display: Option<(u64, Document)>,
scroll_anim: Option<ScrollAnim>,
anim: Option<Anim>,
anim_tex: Option<egui::TextureHandle>,
temp: Option<Temp>,
textures: HashMap<NodeId, FrameTex>,
arrived: f64,
delays_fired: HashSet<(NodeId, usize)>,
hovered: Vec<NodeId>,
press: Option<Pos2>,
dragged: bool,
flash_until: f64,
/// The active screen's hotspots as last laid out (screen rects).
hot: Vec<(NodeId, Rect)>,
/// The time of the last frame played.
clock: f64,
/// The layer the Inspect panel shows (the last one under the pointer).
inspected: Option<NodeId>,
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: 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))>,
/// The frame shown last in each section (navigating to a section returns there).
sections: HashMap<NodeId, NodeId>,
/// The frame shown last, where it was placed and what it could draw into (comment pins go there).
shown: Option<(NodeId, Place, Rect)>,
}
impl Presenter {
/// Close the top overlay; it animates out with its opening transition reversed.
fn close_overlay(&mut self, now: f64) -> bool {
let Some(o) = self.overlays.pop() else { return false };
if !matches!(o.transition, Transition::Instant) && o.dur > 0.0 {
// An overlay closed mid-opening reverses from where it got to.
let shown = ((now - o.opened) / o.dur).clamp(0.0, 1.0);
self.closing.push((now - (1.0 - shown) * o.dur, o));
}
self.hovered.clear();
true
}
pub fn new(start: NodeId) -> Self {
Self {
current: start,
history: Vec::new(),
overlays: Vec::new(),
closing: Vec::new(),
scroll: HashMap::new(),
render_ms: 0.0,
start,
original: None,
design: None,
doc: None,
fit: None,
rev: 0,
display: None,
scroll_anim: None,
anim: None,
anim_tex: None,
temp: None,
textures: HashMap::new(),
arrived: -1.0,
delays_fired: HashSet::new(),
hovered: Vec::new(),
press: None,
dragged: false,
flash_until: 0.0,
hot: Vec::new(),
clock: 0.0,
inspected: None,
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,
sections: HashMap::new(),
shown: 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.
pub fn sync(&mut self, doc: &Arc<Document>, now: f64) {
if self.source.as_ref().is_some_and(|s| Arc::ptr_eq(s, doc)) {
return;
}
let first = self.source.is_none();
self.source = Some(doc.clone());
let copy = Document::clone(doc);
self.original = Some(copy.clone());
self.design = Some(copy);
self.relayout();
if first {
self.arrived = now;
return;
}
if !doc.contains(self.current) {
if doc.contains(self.start) {
self.restart(self.start, now);
}
return;
}
self.history.retain(|v| doc.contains(v.frame));
self.overlays.retain(|o| doc.contains(o.id));
self.closing.retain(|(_, o)| doc.contains(o.id));
// These hold states of the document before the edit.
self.temp = None;
self.anim = None;
self.scroll_anim = None;
self.stop_scrolling();
self.scroll.retain(|f, _| doc.contains(*f));
if let Some(laid) = self.doc.as_ref() {
let clamped: Vec<(NodeId, (f64, f64))> = self.scroll.iter().map(|(f, s)| (*f, clamp_scroll(*s, self.range(laid, *f)))).collect();
self.scroll.extend(clamped);
}
self.rev += 1;
}
/// Lay the document out for the screen: `design` with the presented frame resized to `fit`
/// when the prototype is responsive (its constraints and auto layout respond).
fn relayout(&mut self) {
let mut d = self.design.clone();
if let (Some(d), Some(size)) = (d.as_mut(), self.fit) {
figcraft_engine::cmd::proto::respond(d, self.current, size);
}
self.doc = d;
self.rev += 1;
}
/// Responsive prototypes: resize the presented frame to a screen of `size` (None: frames
/// keep their size). Lays out again only when it changes.
fn respond_to(&mut self, size: Option<(f64, f64)>) {
if self.fit != size {
self.fit = size;
self.relayout();
}
}
/// The player's document with scroll offsets applied and the presented frame sized to the
/// device's screen (cached per revision).
fn display(&mut self) -> Option<Document> {
if let Some((r, d)) = &self.display
&& *r == self.rev
{
return Some(d.clone());
}
let doc = self.doc.as_ref()?;
let mut d = apply_scroll(doc, &self.scroll);
if let Some(vp) = viewport(doc, self.current) {
fit_viewport(&mut d, self.current, vp);
}
self.display = Some((self.rev, d.clone()));
Some(d)
}
/// How far `frame` scrolls (in `doc`, without scroll offsets): the presented frame within the
/// device's screen, other frames within themselves.
fn range(&self, doc: &Document, frame: NodeId) -> (f64, f64) {
let vp = if frame == self.current { viewport(doc, frame) } else { None };
scroll_range_in(doc, frame, vp)
}
/// What "Scroll to" `dest` moves: every scrolling frame around it, innermost first, brings the
/// layer (then the frame holding it) to its top-left as far as it can scroll. Each entry is
/// (frame, offset now, offset after).
fn scroll_to_layer(&self, doc: &Document, dest: NodeId) -> Vec<ScrollMove> {
let mut out = Vec::new();
let mut target = dest;
for f in doc.ancestors(dest) {
let (rx, ry) = self.range(doc, f);
let Some(tn) = doc.node(target) else { break };
if rx <= 0.0 && ry <= 0.0 {
continue;
}
let rel = doc.world_transform(f).inverse() * doc.world_transform(target);
let b = figcraft_geom::transform_rect_bbox(rel, tn.local_rect());
let from = self.scroll.get(&f).copied().unwrap_or((0.0, 0.0));
let to = clamp_scroll((if rx > 0.0 { b.x0 } else { from.0 }, if ry > 0.0 { b.y0 } else { from.1 }), (rx, ry));
out.push((f, from, to));
target = f;
}
out
}
/// Scroll the presented frame to `(x, y)` (page units, clamped to what it can scroll).
pub fn scroll_to(&mut self, x: f64, y: f64) {
let Some(doc) = self.doc.as_ref() else { return };
if !x.is_finite() || !y.is_finite() {
return;
}
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()),
"start": self.start,
"history": self.history.iter().map(|v| v.frame).collect::<Vec<_>>(),
"overlays": self.overlays.iter().map(|o| o.id).collect::<Vec<_>>(),
"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,
"comments": self.view.comments,
"hotspotHints": self.view.hotspot_hints,
"hotspots": self.hot.iter().map(|(id, r)| json!({"id": id, "rect": [r.min.x, r.min.y, r.max.x, r.max.y]})).collect::<Vec<_>>(),
"hotspotFlash": self.flash_until > self.clock,
"inspect": self.view.inspect,
"inspected": self.inspected,
"deviceColor": doc.and_then(|d| page_settings(d, self.current).device_color()).map(|c| c.name),
"responsive": self.fit.map(|f| [f.0, f.1]),
"frameSize": doc.and_then(|d| d.node(self.current)).map(|n| [n.width, n.height]),
"pan": [pan.0, pan.1],
"panRange": [self.pan_range.0, self.pan_range.1],
"zoom": self.zoom,
"flinging": self.fling.is_some(),
})
}
fn snapshot(&self) -> Option<Snapshot> {
Some(Snapshot {
current: self.current,
doc: self.design.clone()?,
overlays: self.overlays.clone(),
history: self.history.clone(),
scroll: self.scroll.clone(),
})
}
/// Start a transition from the state as it is now; call before changing the state.
fn begin(&mut self, transition: Transition, dur: f64, easing: Easing, now: f64) {
let Some(from_doc) = self.display() else { return };
let from_tex = self.textures.get(&self.current).cloned();
self.anim = if matches!(transition, Transition::Instant) || dur <= 0.0 {
None
} else {
Some(Anim { from_doc, from_frame: self.current, from_tex, transition, start: now, dur, easing })
};
}
fn arrive(&mut self, now: f64) {
if let Some(doc) = self.doc.as_ref() {
figcraft_engine::proto_run::remember_sections(doc, self.current, &mut self.sections);
}
self.arrived = now;
self.delays_fired.clear();
self.hovered.clear();
self.rev += 1;
}
/// Show `frame`. It starts scrolled to the top unless `preserve` keeps the scroll position of
/// the frame being left (as far as `frame` can scroll).
fn go(&mut self, frame: NodeId, transition: Transition, dur: f64, easing: Easing, now: f64, remember: bool, preserve: bool) {
self.begin(transition, dur, easing, now);
if remember {
self.history.push(Visit { frame: self.current, transition, dur, easing });
}
let kept = self.scroll.get(&self.current).copied().filter(|_| preserve);
self.overlays.clear();
self.scroll.clear();
self.scroll_anim = None;
self.stop_scrolling();
self.pan = None;
self.temp = None;
self.current = frame;
self.relayout();
if let Some(s) = kept
&& let Some(doc) = self.doc.as_ref()
{
let to = clamp_scroll(s, self.range(doc, frame));
self.scroll.insert(frame, to);
}
self.arrive(now);
}
/// Start over at `start` with the document as the editor has it.
pub fn restart(&mut self, start: NodeId, now: f64) {
self.start = start;
self.current = start;
self.sections.clear();
self.history.clear();
self.overlays.clear();
self.scroll.clear();
self.scroll_anim = None;
self.stop_scrolling();
self.pan = None;
self.anim = None;
self.temp = None;
self.design = self.original.clone();
self.relayout();
self.arrive(now);
}
fn revert(&mut self, now: f64) {
let Some(t) = self.temp.take() else { return };
self.begin(t.transition.reversed(), t.dur, t.easing, now);
self.current = t.snap.current;
self.design = Some(t.snap.doc);
self.relayout();
self.overlays = t.snap.overlays;
self.history = t.snap.history;
self.scroll = t.snap.scroll;
self.stop_scrolling();
self.hovered.clear();
self.rev += 1;
}
/// Run the actions of interaction `k` on `node`. `rect` is the layer's screen rect.
fn fire(&mut self, node: NodeId, k: usize, rect: Option<Rect>, now: f64) -> bool {
let Some(it) = self.doc.as_ref().and_then(|d| d.node(node)).and_then(|n| n.interactions.get(k)).cloned() else { return false };
let dur = f64::from(it.duration_ms) / 1000.0;
let temp = match it.trigger {
Trigger::OnHover => Some(false),
Trigger::OnPress => Some(true),
_ => None,
};
let snap = if temp.is_some() && self.temp.is_none() { self.snapshot() } else { None };
let mut did = false;
for a in &it.actions {
did |= self.perform(a, node, it.transition, dur, it.easing, rect, now);
}
if did && let (Some(press), Some(snap), Some(rect)) = (temp, snap, rect) {
self.temp = Some(Temp { rect, press, snap, transition: it.transition, dur, easing: it.easing });
}
did
}
fn perform(&mut self, a: &Action, node: NodeId, tr: Transition, dur: f64, easing: Easing, rect: Option<Rect>, now: f64) -> bool {
self.perform_at(a, node, tr, dur, easing, rect, now, 0)
}
/// One action; `depth` counts the conditional actions it is inside.
#[allow(clippy::too_many_arguments)]
fn perform_at(&mut self, a: &Action, node: NodeId, tr: Transition, dur: f64, easing: Easing, rect: Option<Rect>, now: f64, depth: usize) -> bool {
let Some(doc) = self.doc.clone() else { return false };
match a {
Action::Navigate { dest, preserve_scroll } => {
let Some(top) = figcraft_engine::proto_run::navigate_target(&doc, *dest, &self.sections) else { return false };
if !doc.contains(top) || (top == self.current && self.overlays.is_empty()) {
return false;
}
self.go(top, tr, dur, easing, now, true, *preserve_scroll);
true
}
Action::Back => {
if self.close_overlay(now) {
return true;
}
match self.history.pop() {
Some(v) => {
self.go(v.frame, v.transition.reversed(), v.dur, v.easing, now, false, false);
true
}
None => false,
}
}
Action::ScrollTo { dest } => {
let frames = self.scroll_to_layer(&doc, *dest);
if frames.is_empty() {
return false;
}
if matches!(tr, Transition::Instant) || dur <= 0.0 {
self.scroll.extend(frames.iter().map(|(f, _, to)| (*f, *to)));
self.rev += 1;
} else {
self.scroll_anim = Some(ScrollAnim { frames, start: now, dur, easing });
}
true
}
Action::OpenOverlay { dest, position, close_on_outside, background } => {
if !doc.contains(*dest) {
return false;
}
self.overlays.push(OverlayState {
id: *dest,
position: *position,
close_on_outside: *close_on_outside,
background: *background,
anchor: rect,
opened: now,
transition: tr,
dur,
easing,
});
self.hovered.clear();
true
}
Action::SwapOverlay { dest } => {
if !doc.contains(*dest) {
return false;
}
match self.overlays.last_mut() {
Some(o) => {
o.id = *dest;
o.opened = now;
o.transition = tr;
o.dur = dur;
o.easing = easing;
}
None => self.overlays.push(OverlayState {
id: *dest,
position: OverlayPosition::Center,
close_on_outside: true,
background: None,
anchor: rect,
opened: now,
transition: tr,
dur,
easing,
}),
}
self.hovered.clear();
true
}
Action::CloseOverlay => self.close_overlay(now),
Action::OpenUrl { url } => {
if !url.trim().is_empty() {
self.urls.push(url.clone());
}
true
}
Action::ChangeTo { dest } => {
let inst = if matches!(doc.node(node).map(|n| &n.kind), Some(NodeKind::Instance { .. })) {
Some(node)
} else {
figcraft_engine::components::owning_instance(&doc, node)
};
let Some(inst) = inst else { return false };
if !doc.contains(*dest) {
return false;
}
self.begin(tr, dur, easing, now);
let Some(d) = self.design.as_mut() else { return false };
if let Some(n) = d.node_mut(inst)
&& let NodeKind::Instance { component, .. } = &mut n.kind
{
*component = *dest;
}
figcraft_engine::components::sync_instance(d, inst);
self.relayout();
true
}
Action::SetVariable { variable, value, expr } => {
let Some(d) = self.design.as_mut() else { return false };
let set = figcraft_engine::proto_run::set_variable(d, node, variable, value, expr.as_ref());
if set {
self.relayout();
}
set
}
Action::SetVariableMode { collection, mode } => {
let Some(d) = self.design.as_mut() else { return false };
let set = figcraft_engine::proto_run::set_mode(d, self.current, collection, mode);
if set {
self.relayout();
}
set
}
Action::Conditional { blocks } => {
if depth >= figcraft_doc::proto::MAX_ACTION_DEPTH {
return false;
}
// Evaluated now: earlier actions of the interaction may have changed variables.
let mut did = false;
for inner in figcraft_engine::proto_run::branch(&doc, node, blocks) {
did |= self.perform_at(&inner, node, tr, dur, easing, rect, now, depth + 1);
}
did
}
}
}
fn texture(&mut self, ctx: &egui::Context, doc: &Document, id: NodeId, px_scale: f64, now: f64) -> Option<FrameTex> {
// Animated GIFs play from when the frame arrived: a new texture when one of them moves on,
// and a wake-up for that moment (nothing else draws frames while it shows). egui wakes a
// display frame before the time asked for, a little earlier still at times: ask that much
// later, and count a frame due within a few milliseconds as due.
let t = (now - self.arrived).max(0.0) + 0.005;
let clock = figcraft_render::images::animation_clock(doc, id, t);
if let Some((_, next)) = clock {
crate::repaint_in(ctx, next + f64::from(ctx.input(|i| i.predicted_dt)));
}
let key = (self.rev, px_scale.to_bits(), clock.map_or(0, |c| c.0));
if let Some(t) = self.textures.get(&id)
&& t.key == key
{
return Some(t.clone());
}
let bounds = figcraft_render::export_bounds(doc, id)?;
let pix = figcraft_render::render_node_at(doc, id, texture_scale(ctx, bounds, px_scale), t)?;
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 };
self.textures.insert(id, ft.clone());
Some(ft)
}
}
/// 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 {
pub page_min: (f64, f64),
pub origin: Pos2,
pub scale: f64,
}
impl Place {
fn centered(fb: figcraft_geom::Rect, center: Pos2, scale: f64) -> Self {
let size = vec2((fb.width() * scale) as f32, (fb.height() * scale) as f32);
Place { page_min: (fb.x0, fb.y0), origin: center - size / 2.0, scale }
}
pub fn pt(&self, x: f64, y: f64) -> Pos2 {
pos2(self.origin.x + ((x - self.page_min.0) * self.scale) as f32, self.origin.y + ((y - self.page_min.1) * self.scale) as f32)
}
pub fn rect(&self, r: figcraft_geom::Rect) -> Rect {
Rect::from_min_max(self.pt(r.x0, r.y0), self.pt(r.x1, r.y1))
}
}
// ---------------------------------------------------------------------------------------------
// Pure prototype logic (tested below).
/// Layer names from below `root` down to `id`, joined (the smart-animate matching key).
pub fn name_path(doc: &Document, root: NodeId, id: NodeId) -> Option<String> {
let mut parts: Vec<&str> = Vec::new();
let mut cur = id;
for _ in 0..1024 {
if cur == root {
parts.reverse();
return Some(parts.join("\u{1f}"));
}
let n = doc.node(cur)?;
parts.push(n.name.as_str());
cur = n.parent?;
}
None
}
/// Smart-animate pairs `(from layer, to layer)`: same name path below the two roots; repeated
/// paths pair up in layer order.
pub fn match_layers(from: &Document, from_root: NodeId, to: &Document, to_root: NodeId) -> Vec<(NodeId, NodeId)> {
let mut pool: HashMap<String, VecDeque<NodeId>> = HashMap::new();
for id in from.subtree(from_root).into_iter().skip(1) {
if let Some(p) = name_path(from, from_root, id) {
pool.entry(p).or_default().push_back(id);
}
}
let mut out = Vec::new();
for id in to.subtree(to_root).into_iter().skip(1) {
if let Some(p) = name_path(to, to_root, id)
&& let Some(q) = pool.get_mut(&p)
&& let Some(f) = q.pop_front()
{
out.push((f, id));
}
}
out
}
fn lerp(a: f64, b: f64, t: f64) -> f64 {
a + (b - a) * t
}
/// Centre, rotation and scale of a node box `(w, h)` placed by `m`.
fn decompose(m: Affine, (w, h): (f64, f64)) -> Option<[f64; 5]> {
let [a, b, c, d, _, _] = m.as_coeffs();
let sx = a.hypot(b);
if !sx.is_finite() || sx < 1e-9 {
return None;
}
let sy = (a * d - b * c) / sx;
let ctr = m * Point::new(w / 2.0, h / 2.0);
Some([ctr.x, ctr.y, b.atan2(a), sx, sy])
}
/// Interpolate two placements of a box: centre, rotation (shortest way round) and scale; the box
/// size is `size` (already interpolated).
pub fn lerp_transform(a: Affine, sa: (f64, f64), b: Affine, sb: (f64, f64), size: (f64, f64), t: f64) -> Affine {
match (decompose(a, sa), decompose(b, sb)) {
(Some(x), Some(y)) if (x[4] < 0.0) == (y[4] < 0.0) => {
let mut da = y[2] - x[2];
while da > std::f64::consts::PI {
da -= std::f64::consts::TAU;
}
while da < -std::f64::consts::PI {
da += std::f64::consts::TAU;
}
let (cx, cy) = (lerp(x[0], y[0], t), lerp(x[1], y[1], t));
let ang = x[2] + da * t;
let (sx, sy) = (lerp(x[3], y[3], t), lerp(x[4], y[4], t));
Affine::translate((cx, cy)) * Affine::rotate(ang) * Affine::scale_non_uniform(sx, sy) * Affine::translate((-size.0 / 2.0, -size.1 / 2.0))
}
_ => {
if t < 0.5 {
a
} else {
b
}
}
}
}
fn lerp_paints(from: &[Paint], to: &mut [Paint], t: f64) {
for (f, p) in from.iter().zip(to.iter_mut()) {
if let (PaintKind::Solid { color: fc }, PaintKind::Solid { color: tc }) = (&f.kind, &mut p.kind) {
*tc = fc.lerp(*tc, t.clamp(0.0, 1.0) as f32);
p.opacity = lerp(f64::from(f.opacity), f64::from(p.opacity), t.clamp(0.0, 1.0)) as f32;
}
}
}
/// The document as it looks at eased progress `t` of a smart-animate transition from
/// `from_root` (in `from`) to `to_root` (in `to`): matched layers tween position, size, rotation,
/// opacity, corner radius and solid fill colours; layers only in the destination fade in and
/// layers only in the source are copied in and fade out. Render `to_root` of the result.
pub fn smart_interpolate(from: &Document, from_root: NodeId, to: &Document, to_root: NodeId, t: f64) -> Document {
let mut out = to.clone();
if !t.is_finite() || t >= 1.0 {
return out;
}
let t = t.max(-1.0);
let fade = t.clamp(0.0, 1.0);
let pairs = match_layers(from, from_root, to, to_root);
let src_of: HashMap<NodeId, NodeId> = pairs.iter().map(|(f, d)| (*d, *f)).collect();
let dst_of: HashMap<NodeId, NodeId> = pairs.iter().copied().collect();
let fw_inv = from.world_transform(from_root).inverse();
let tw_inv = to.world_transform(to_root).inverse();
if let (Some(fr), Some(m)) = (from.node(from_root), out.node_mut(to_root)) {
lerp_paints(&fr.fills, &mut m.fills, t);
}
let mut interp: HashMap<NodeId, Affine> = HashMap::new();
interp.insert(to_root, Affine::IDENTITY);
let mut faded: HashSet<NodeId> = HashSet::new();
for id in to.subtree(to_root).into_iter().skip(1) {
let Some(n) = to.node(id) else { continue };
let parent = n.parent.unwrap_or(to_root);
let p_interp = interp.get(&parent).copied().unwrap_or(Affine::IDENTITY);
match src_of.get(&id).and_then(|s| from.node(*s).map(|sn| (*s, sn))) {
Some((s, sn)) => {
let rel_s = fw_inv * from.world_transform(s);
let rel_d = tw_inv * to.world_transform(id);
let (w, h) = (lerp(sn.width, n.width, t).max(0.0), lerp(sn.height, n.height, t).max(0.0));
let xf = lerp_transform(rel_s, (sn.width, sn.height), rel_d, (n.width, n.height), (w, h), t);
interp.insert(id, xf);
if let Some(m) = out.node_mut(id) {
m.width = w;
m.height = h;
m.transform = p_interp.inverse() * xf;
m.opacity = lerp(f64::from(sn.opacity), f64::from(n.opacity), fade) as f32;
for (r, (a, b)) in m.corners.radii.iter_mut().zip(sn.corners.radii.iter().zip(n.corners.radii.iter())) {
*r = lerp(*a, *b, t).max(0.0);
}
lerp_paints(&sn.fills, &mut m.fills, t);
lerp_paints(&sn.strokes, &mut m.strokes, t);
}
}
None => {
interp.insert(id, p_interp * n.transform);
if !faded.contains(&parent)
&& let Some(m) = out.node_mut(id)
{
m.opacity *= fade as f32;
}
faded.insert(id);
}
}
}
// Layers only in the source: copy the top-most ones in, fading out.
let tops: Vec<NodeId> = from
.subtree(from_root)
.into_iter()
.skip(1)
.filter(|id| !dst_of.contains_key(id))
.filter(|id| from.node(*id).and_then(|n| n.parent).is_some_and(|p| p == from_root || dst_of.contains_key(&p)))
.collect();
for id in tops {
let Some(n) = from.node(id) else { continue };
let pf = n.parent.unwrap_or(from_root);
let matched_parent = dst_of.get(&pf).copied().filter(|p| out.node(*p).is_some_and(|pn| pn.kind.is_container()));
let (parent, local) = match matched_parent {
Some(p) if pf != from_root => (p, n.transform),
_ => (to_root, fw_inv * from.world_transform(id)),
};
let nodes: Vec<Node> = from.subtree(id).into_iter().filter_map(|i| from.node(i).cloned()).collect();
let mut map = BTreeMap::new();
for nn in &nodes {
map.insert(nn.id, out.alloc_id());
}
let index = from.index_in_parent(id);
if let Ok(new) = out.paste_nodes(&nodes, &map, id, parent, index)
&& let Some(m) = out.node_mut(new)
{
m.transform = local;
m.opacity *= (1.0 - fade) as f32;
}
}
out
}
/// How far `frame`'s content can scroll `(x, y)` (0 when it doesn't overflow or doesn't scroll).
pub fn scroll_range(doc: &Document, frame: NodeId) -> (f64, f64) {
scroll_range_in(doc, frame, None)
}
/// How far `frame`'s content can scroll when `viewport` (page units, from its top-left) of it
/// shows, as a device's screen shows a top-level frame: the frame itself is content then, so a
/// frame taller than the screen scrolls through its whole height. Without a viewport the frame
/// shows at its own size and what overflows it scrolls. Only the axes its overflow allows scroll.
pub fn scroll_range_in(doc: &Document, frame: NodeId, viewport: Option<(f64, f64)>) -> (f64, f64) {
let Some(n) = doc.node(frame) else { return (0.0, 0.0) };
if n.overflow == Overflow::None {
return (0.0, 0.0);
}
let (mut x1, mut y1) = (0.0f64, 0.0f64);
for c in &n.children {
let Some(cn) = doc.node(*c) else { continue };
if !cn.visible || cn.scroll_position == ScrollPosition::Fixed {
continue;
}
let b = figcraft_geom::transform_rect_bbox(cn.transform, cn.local_rect());
x1 = x1.max(b.x1);
y1 = y1.max(b.y1);
}
if let Some(l) = &n.layout {
x1 += l.padding[1];
y1 += l.padding[2];
}
let (vw, vh) = match viewport {
Some(v) => {
x1 = x1.max(n.width);
y1 = y1.max(n.height);
v
}
None => (n.width, n.height),
};
let rx = if matches!(n.overflow, Overflow::Horizontal | Overflow::Both) { (x1 - vw).max(0.0) } else { 0.0 };
let ry = if matches!(n.overflow, Overflow::Vertical | Overflow::Both) { (y1 - vh).max(0.0) } else { 0.0 };
(if rx.is_finite() { rx } else { 0.0 }, if ry.is_finite() { ry } else { 0.0 })
}
/// `(x, y)` kept within `0..=range` on each axis (0 for non-finite values).
fn clamp_scroll((x, y): (f64, f64), (rx, ry): (f64, f64)) -> (f64, f64) {
let c = |v: f64, r: f64| if v.is_finite() && r.is_finite() { v.clamp(0.0, r.max(0.0)) } else { 0.0 };
(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)
.and_then(|pg| doc.node(pg))
.and_then(|n| match &n.kind {
NodeKind::Page { prototype, .. } => Some(prototype.clone()),
_ => None,
})
.unwrap_or_default()
}
/// The part of top-level `frame` its page's device shows (page units): the frame clipped to the
/// device's screen. None without a device: the frame then shows whole.
pub fn viewport(doc: &Document, frame: NodeId) -> Option<(f64, f64)> {
let d = page_settings(doc, frame).device()?;
let n = doc.node(frame)?;
Some((n.width.min(d.width), n.height.min(d.height)))
}
/// Size `frame` to the `(w, h)` of it a screen shows. The content keeps its place (what doesn't
/// fit is clipped or scrolls); fixed layers follow their constraints to the screen's edges, so a
/// tab bar pinned to the frame's bottom sits at the bottom of the screen (stretching constraints
/// keep the layer's size).
pub fn fit_viewport(doc: &mut Document, frame: NodeId, (w, h): (f64, f64)) {
let Some(n) = doc.node(frame) else { return };
let (dw, dh) = (w - n.width, h - n.height);
if !dw.is_finite() || !dh.is_finite() || (dw == 0.0 && dh == 0.0) {
return;
}
let fixed: Vec<NodeId> =
n.children.iter().copied().filter(|c| doc.node(*c).is_some_and(|c| c.scroll_position == ScrollPosition::Fixed)).collect();
for c in fixed {
if let Some(m) = doc.node_mut(c) {
let dx = match m.constraints.horizontal {
HConstraint::Right => dw,
HConstraint::Center => dw / 2.0,
_ => 0.0,
};
let dy = match m.constraints.vertical {
VConstraint::Bottom => dh,
VConstraint::Center => dh / 2.0,
_ => 0.0,
};
m.transform = Affine::translate((dx, dy)) * m.transform;
}
}
if let Some(m) = doc.node_mut(frame) {
m.width = w;
m.height = h;
}
}
/// Shift scrolling frames' children by their scroll offsets. Fixed children stay put; sticky
/// children scroll until they reach the frame's top/left edge, then stay there.
pub fn apply_scroll(doc: &Document, scroll: &HashMap<NodeId, (f64, f64)>) -> Document {
let mut out = doc.clone();
let sticky = |pos: f64, s: f64| if pos >= 0.0 && pos - s < 0.0 { -pos } else { -s };
for (&frame, &(sx, sy)) in scroll {
if !sx.is_finite() || !sy.is_finite() || (sx == 0.0 && sy == 0.0) {
continue;
}
let Some(n) = doc.node(frame) else { continue };
for &c in &n.children {
let Some(cn) = doc.node(c) else { continue };
let (dx, dy) = match cn.scroll_position {
ScrollPosition::Fixed => continue,
ScrollPosition::Scrolls => (-sx, -sy),
ScrollPosition::Sticky => {
let b = figcraft_geom::transform_rect_bbox(cn.transform, cn.local_rect());
(sticky(b.x0, sx), sticky(b.y0, sy))
}
};
if let Some(m) = out.node_mut(c) {
m.transform = Affine::translate((dx, dy)) * m.transform;
}
}
}
out
}
/// Where the outgoing and incoming frames are drawn at eased progress `t`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FramePlacement {
/// Offsets as fractions of each frame's size.
pub from_off: (f32, f32),
pub from_alpha: f32,
pub to_off: (f32, f32),
pub to_alpha: f32,
pub from_on_top: bool,
}
/// The unit vector a direction moves along (screen space, y down).
pub fn direction_vec(d: Direction) -> (f32, f32) {
match d {
Direction::Left => (-1.0, 0.0),
Direction::Right => (1.0, 0.0),
Direction::Top => (0.0, -1.0),
Direction::Bottom => (0.0, 1.0),
}
}
/// Frame offsets/opacities for a transition. Directions name where things move: "Move in, left"
/// brings the new frame in from the right edge moving left.
pub fn transition_frames(tr: Transition, t: f32) -> FramePlacement {
let mul = |(x, y): (f32, f32), k: f32| (x * k, y * k);
let base = FramePlacement { from_off: (0.0, 0.0), from_alpha: 1.0, to_off: (0.0, 0.0), to_alpha: 1.0, from_on_top: false };
match tr {
Transition::Instant => FramePlacement { from_alpha: 0.0, ..base },
Transition::Dissolve | Transition::SmartAnimate => FramePlacement { to_alpha: t.clamp(0.0, 1.0), ..base },
Transition::MoveIn { direction } => FramePlacement { to_off: mul(direction_vec(direction), -(1.0 - t)), ..base },
Transition::MoveOut { direction } => FramePlacement { from_off: mul(direction_vec(direction), t), from_on_top: true, ..base },
Transition::Push { direction } => {
let d = direction_vec(direction);
FramePlacement { from_off: mul(d, t), to_off: mul(d, -(1.0 - t)), ..base }
}
Transition::SlideIn { direction } => {
let d = direction_vec(direction);
FramePlacement { from_off: mul(d, 0.3 * t), to_off: mul(d, -(1.0 - t)), ..base }
}
Transition::SlideOut { direction } => {
let d = direction_vec(direction);
FramePlacement { from_off: mul(d, t), to_off: mul(d, -0.3 * (1.0 - t)), from_on_top: true, ..base }
}
}
}
/// Screen rect of an overlay of `size` over the frame drawn at `base`.
pub fn overlay_rect(pos: OverlayPosition, base: Rect, size: Vec2, anchor: Option<Rect>) -> Rect {
let cx = base.center().x - size.x / 2.0;
let cy = base.center().y - size.y / 2.0;
let (x, y) = match pos {
OverlayPosition::Center => (cx, cy),
OverlayPosition::TopLeft => (base.min.x, base.min.y),
OverlayPosition::TopCenter => (cx, base.min.y),
OverlayPosition::TopRight => (base.max.x - size.x, base.min.y),
OverlayPosition::BottomLeft => (base.min.x, base.max.y - size.y),
OverlayPosition::BottomCenter => (cx, base.max.y - size.y),
OverlayPosition::BottomRight => (base.max.x - size.x, base.max.y - size.y),
OverlayPosition::Manual => anchor.map(|a| (a.min.x, a.max.y)).unwrap_or((cx, cy)),
};
Rect::from_min_size(pos2(x, y), size)
}
/// The hotspots of the screen `root` (its visible layers with interactions, bottom-most first)
/// placed on screen and clipped by `screen`.
fn hotspots(doc: &Document, root: NodeId, place: &Place, screen: Rect) -> Vec<(NodeId, Rect)> {
figcraft_engine::proto_run::hotspots(doc, root)
.into_iter()
.map(|(id, b)| (id, place.rect(b).intersect(screen)))
.filter(|(_, r)| r.is_positive())
.collect()
}
/// The first interaction index on `id` whose trigger satisfies `want`.
fn find_trigger(doc: &Document, id: NodeId, want: impl Fn(&Trigger) -> bool) -> Option<usize> {
doc.node(id)?.interactions.iter().position(|it| want(&it.trigger))
}
// ---------------------------------------------------------------------------------------------
// The player UI, shared by the full-window presenter and the preview pane.
struct Layout {
disp: Document,
scale: f64,
place: Place,
/// What shows the prototype: the device's screen, else the presented frame itself.
screen: Rect,
/// What the presented frame may draw into: the device's screen, else the whole area (shadows
/// 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 colour of the device's body, when it is drawn around its screen.
color: Option<DeviceColor>,
overlays: Vec<(NodeId, Place, Rect)>,
/// Overlays animating out (same order as `Presenter::closing`).
closing: Vec<(NodeId, Place, Rect)>,
}
impl Layout {
/// The active layer (the top overlay, else the presented frame `current`), where it's placed
/// and what it may draw into.
fn active(&self, current: NodeId) -> (NodeId, Place, Rect) {
match self.overlays.last() {
Some((id, pl, _)) => (*id, *pl, self.screen),
None => (current, self.place, self.clip),
}
}
}
/// The soonest "after delay" interaction in `root`'s subtree that hasn't fired yet, and the seconds
/// until it's due (zero or less: due now).
fn next_delay(p: &Presenter, doc: &Document, root: NodeId, now: f64) -> Option<((NodeId, usize), f64)> {
let mut next: Option<((NodeId, usize), f64)> = None;
for id in doc.subtree(root) {
let Some(n) = doc.node(id) else { continue };
for (k, it) in n.interactions.iter().enumerate() {
if let Trigger::AfterDelay { ms } = it.trigger
&& !p.delays_fired.contains(&(id, k))
{
let due = p.arrived + f64::from(ms) / 1000.0 - now;
if next.is_none_or(|(_, d)| due < d) {
next = Some(((id, k), due));
}
}
}
}
next
}
/// 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),
"Desktop" => ((w * 0.02).max(6.0), 0.0),
d if d.starts_with("iPad") => ((w * 0.05).max(8.0), w * 0.04),
_ => ((w * 0.06).max(6.0), w * 0.13),
}
}
/// 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`: 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. A responsive prototype's frame is first resized to the device's
/// screen, or without a device to the whole area (shown at 100%).
fn layout(p: &mut Presenter, area: Rect, chrome: Chrome) -> Option<Layout> {
let settings = page_settings(p.doc.as_ref()?, p.current);
let (device, landscape) = (settings.device(), settings.landscape());
let fills = settings.responsive && device.is_none();
let whole_area = (f64::from(area.width().round()), f64::from(area.height().round()));
p.respond_to(settings.responsive.then(|| device.map_or(whole_area, |d| (d.width, d.height))));
let disp = p.display()?;
let fb = disp.world_bounds(p.current)?;
let view = if chrome.pane { PresentView::default() } else { p.view };
// Custom sizes are just a screen: no body.
let body = device.filter(|d| view.device_frame && !d.colors.is_empty());
let pad = if !fills && 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));
// 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 = if fills { 1.0 } else { 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 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)?;
let size = vec2((ob.width() * scale) as f32, (ob.height() * scale) as f32);
let r = overlay_rect(o.position, screen, size, o.anchor);
Some((o.id, Place { page_min: (ob.x0, ob.y0), origin: r.min, scale }, r))
};
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 };
let color = body.and(settings.device_color());
Some(Layout { disp, scale, place, screen, clip, device, landscape, color, overlays, closing })
}
/// Where the player sits: the whole window or the preview pane.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Chrome {
pane: bool,
bar_h: f32,
/// Space kept around the device or frame.
pad: f32,
/// Corner radius of the window around the player (the pane's).
round: u8,
/// The player may take keys (the pane only while hovered, and never while the editor is
/// typing: text on the canvas or in a field).
keys: bool,
}
const FULL: Chrome = Chrome { pane: false, bar_h: 40.0, pad: 32.0, round: 0, keys: true };
/// What the player's top bar asked for.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum BarAction {
Close,
Restart,
/// Present full-window (from the pane).
Present,
/// The full-window presenter's options menu (handled in the bar).
Options,
/// Turn responsive presentation on or off (saved in the file: `proto.settings`).
Responsive(bool),
/// Show or hide the comments on the presented frame.
Comments,
/// Show or hide the Inspect panel.
Inspect,
}
/// The full-window presenter (while `app.ui.present` is set). Escape or Close ends it.
pub fn show(app: &mut FigApp, ui: &mut Ui) {
let ctx = ui.ctx().clone();
let now = ctx.input(|i| i.time);
let Some(mut pres) = app.ui.present.take() else { return };
if let Ok(ds) = app.session.ds() {
pres.sync(&ds.doc, now);
}
let mut action = None;
// Typing a reply to a comment keeps the keys from the prototype; Shift+C shows the comments.
let typing = ctx.egui_wants_keyboard_input();
if !typing && ctx.input_mut(|i| i.consume_key(egui::Modifiers::SHIFT, egui::Key::C)) {
action = Some(BarAction::Comments);
}
if pres.view.inspect {
let t = Tokens::get(&ctx);
egui::Panel::right("present_inspect")
.resizable(false)
.exact_size(INSPECT_W)
.frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.border)))
.show(ui, |ui| inspect_panel(app, ui, &pres));
}
egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| {
action = player(&mut pres, ui, ui.max_rect(), now, Chrome { keys: !typing, ..FULL }).or(action);
});
match action {
Some(BarAction::Comments) => pres.view.comments = !pres.view.comments,
Some(BarAction::Inspect) => pres.view.inspect = !pres.view.inspect,
_ => {}
}
if pres.view.comments
&& let Some((frame, place, clip)) = pres.shown
{
crate::comments::presenter(app, &ctx, frame, place, clip);
} else if app.ui.comments.open.is_some() {
app.ui.comments.close();
}
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 let Some(BarAction::Responsive(on)) = action {
app.run("proto.settings", json!({"responsive": on}));
}
if action == Some(BarAction::Close) || ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
return;
}
app.ui.present = Some(pres);
}
/// The width of the presenter's Inspect panel.
const INSPECT_W: f32 = 280.0;
/// The presenter's Inspect panel: the inspected layer's properties, as in the Inspect tab.
fn inspect_panel(app: &mut FigApp, ui: &mut Ui, pres: &Presenter) {
let t = Tokens::get(ui.ctx());
let found = pres.inspected.zip(pres.doc.as_ref()).and_then(|(id, doc)| doc.node(id).map(|n| (id, doc, n.name.clone())));
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
let Some((id, doc, name)) = found else {
widgets::section_header(ui, tr("Inspect"), &[], false);
crate::right::row(ui, 32.0, |ui| widgets::label(ui, tr("Hover a layer to see its properties"), t.text_secondary));
return;
};
widgets::section_header(ui, &name, &[], false);
for section in figcraft_engine::devinspect::properties(doc, id) {
crate::right::inspect_section(app, ui, &section);
}
});
}
/// The top-most visible layer of `root` (it included) under the screen point `q`.
fn layer_at(doc: &Document, root: NodeId, place: &Place, q: Pos2) -> Option<NodeId> {
let mut hidden: HashSet<NodeId> = HashSet::new();
let mut hit = None;
for id in doc.subtree(root) {
let Some(n) = doc.node(id) else { continue };
if !n.visible || n.parent.is_some_and(|p| hidden.contains(&p)) {
hidden.insert(id);
continue;
}
if doc.world_bounds(id).is_some_and(|b| place.rect(b).contains(q)) {
hit = Some(id);
}
}
hit
}
/// The prototype preview pane: the player in a floating, resizable window over the canvas. It
/// follows edits live, and selecting another top-level frame in the editor shows that frame.
pub struct Pane {
pub player: Presenter,
/// The open file it previews (another tab closes it).
doc_uid: u64,
/// The top-level frame last selected in the editor.
selected: Option<NodeId>,
}
/// The top-level frame of the editor's first selected layer.
fn selected_frame(ds: &figcraft_engine::DocState) -> Option<NodeId> {
ds.selection.first().and_then(|i| ds.doc.top_level_frame(*i))
}
impl Pane {
pub fn new(start: NodeId, session: &figcraft_engine::Session) -> Self {
let (doc_uid, selected) = session.ds().map(|ds| (ds.uid, selected_frame(ds))).unwrap_or((0, None));
Pane { player: Presenter::new(start), doc_uid, selected }
}
}
/// The preview pane's window id (its position and size persist under it).
pub fn pane_id() -> Id {
Id::new("proto_preview_pane")
}
/// Show the preview pane while `app.ui.preview` is set (call once per frame, before shortcuts:
/// keys a hovered pane's prototype uses don't reach the editor).
pub fn show_pane(app: &mut FigApp, ctx: &egui::Context) {
let Some(mut pane) = app.ui.preview.take() else { return };
let Ok(ds) = app.session.ds() else { return };
if ds.uid != pane.doc_uid {
return;
}
let now = ctx.input(|i| i.time);
let (doc, selected) = (ds.doc.clone(), selected_frame(ds));
pane.player.sync(&doc, now);
// Deselecting and coming back to the same frame keeps the preview where it got to.
if selected.is_some() && selected != pane.selected {
pane.selected = selected;
if let Some(f) = selected.filter(|f| *f != pane.player.current) {
pane.player.restart(f, now);
}
}
let t = Tokens::get(ctx);
let canvas = app.canvas.rect;
let style = ctx.global_style();
let typing = ds.text_edit.is_some() || crate::a11y::field_focused(ctx);
let chrome = Chrome { pane: true, bar_h: 36.0, pad: 12.0, round: style.visuals.window_corner_radius.nw, keys: !typing };
let mut action = None;
egui::Window::new(tr("Prototype preview"))
.id(pane_id())
.title_bar(false)
.collapsible(false)
.resizable(true)
.default_size(vec2(320.0, 600.0))
.min_size(vec2(220.0, 260.0))
.default_pos(pos2(canvas.max.x - 336.0, canvas.min.y + 16.0))
.constrain_to(ctx.content_rect())
.frame(egui::Frame::window(&style).fill(t.panel).inner_margin(egui::Margin::same(0)))
.show(ctx, |ui| {
let (rect, _) = ui.allocate_exact_size(ui.available_size(), Sense::hover());
action = player(&mut pane.player, ui, rect, now, chrome);
});
for u in pane.player.urls.drain(..) {
app.services.open_url(&u);
}
match action {
Some(BarAction::Close) => {}
Some(BarAction::Present) => {
let start = pane.player.current;
app.ui.preview = Some(pane);
app.run("proto.present", json!({"start": start}));
}
_ => app.ui.preview = Some(pane),
}
}
/// The black glass around a device's screen.
const GLASS: Color32 = Color32::from_rgb(0x16, 0x16, 0x18);
/// A device colour lighter (`k` > 0, towards white) or darker (`k` < 0, towards black).
fn shade([r, g, b]: [u8; 3], k: f32) -> Color32 {
let k = k.clamp(-1.0, 1.0);
let f = |c: u8| {
let c = f32::from(c);
(if k >= 0.0 { c + (255.0 - c) * k } else { c * (1.0 + k) }).round().clamp(0.0, 255.0) as u8
};
Color32::from_rgb(f(r), f(g), f(b))
}
/// The width of the coloured band round a device's glass (upright screen width `w`, bezel `b`).
fn rim(w: f32, b: f32) -> f32 {
(w * 0.016).clamp(1.5, (b * 0.5).max(1.5))
}
/// Body of the presentation device behind the screen `s` (drawn in code: rounded slabs in the
/// device's colour with black glass round the screen). 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, color: DeviceColor) {
let (body, edge, metal) = (shade(color.rgb, 0.0), shade(color.rgb, -0.3), shade(color.rgb, 0.12));
let w = upright_w(s.size(), landscape);
let (b, screen_r) = bezel(device, w);
let band = rim(w, b);
let radius = |r: f32| CornerRadius::same(r.clamp(0.0, 255.0) as u8);
match device {
"MacBook Air" => {
// The lid's coloured edge round black glass, then the base: a thin slab wider than
// the lid, with a finger notch.
let lid = s.expand(b);
p.rect_filled(lid, radius(b * 1.2), body);
p.rect_filled(lid.shrink(band), radius(b * 1.2 - band), GLASS);
let base = Rect::from_min_max(pos2(lid.min.x - w * 0.08, lid.max.y), pos2(lid.max.x + w * 0.08, lid.max.y + (w * 0.022).max(6.0)));
p.rect_filled(base, CornerRadius { nw: 2, ne: 2, sw: 10, se: 10 }, metal);
let notch = Rect::from_center_size(pos2(base.center().x, base.min.y + 1.5), vec2(w * 0.12, 3.0));
p.rect_filled(notch, CornerRadius::same(2), edge);
}
"Desktop" => {
let screen = s.expand(b);
p.rect_filled(screen, CornerRadius::same(8), body);
p.rect_filled(screen.shrink(band), radius(8.0 - band), GLASS);
let neck =
Rect::from_min_max(pos2(screen.center().x - w * 0.06, screen.max.y), pos2(screen.center().x + w * 0.06, screen.max.y + w * 0.08));
p.rect_filled(neck, CornerRadius::ZERO, metal);
let foot = Rect::from_min_max(pos2(neck.min.x - w * 0.06, neck.max.y), pos2(neck.max.x + w * 0.06, neck.max.y + 6.0));
p.rect_filled(foot, CornerRadius::same(3), edge);
}
_ => {
// Phones and tablets: a rounded slab (phones with side buttons).
let slab = s.expand(b);
if !device.starts_with("iPad") {
// (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);
}
}
p.rect_filled(slab, radius(screen_r + b), body);
p.rect_stroke(slab, radius(screen_r + b), Stroke::new(1.0, edge), StrokeKind::Inside);
// The glass reaches round the screen's square corners (masked by `device_glass`).
let glass = b - band;
p.rect_filled(slab.shrink(band), radius((screen_r + glass).min(glass * 3.4)), GLASS);
}
}
}
/// What sits on top of the screen: rounded screen corners (glass masks the frame's 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 (_, screen_r) = bezel(device, w);
if screen_r <= 0.0 {
return;
}
p.add(corner_masks(s, screen_r.min(s.width() / 2.0).min(s.height() / 2.0), GLASS));
if device.starts_with("iPhone") {
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);
}
}
/// Fill what lies between each corner of `s` and its rounding of radius `r` (fans of triangles
/// from the corner to the arc).
fn corner_masks(s: Rect, r: f32, color: Color32) -> egui::Shape {
const SEGMENTS: usize = 12;
if !(r.is_finite() && r > 0.0) {
return egui::Shape::Noop;
}
let mut mesh = egui::Mesh::default();
// (corner, centre of its arc, angle the arc starts at).
let corners = [
(s.left_top(), s.left_top() + vec2(r, r), std::f32::consts::PI),
(s.right_top(), s.right_top() + vec2(-r, r), std::f32::consts::FRAC_PI_2 * 3.0),
(s.right_bottom(), s.right_bottom() + vec2(-r, -r), 0.0),
(s.left_bottom(), s.left_bottom() + vec2(r, -r), std::f32::consts::FRAC_PI_2),
];
for (corner, centre, start) in corners {
let base = mesh.vertices.len() as u32;
mesh.colored_vertex(corner, color);
for k in 0..=SEGMENTS {
let a = start + std::f32::consts::FRAC_PI_2 * k as f32 / SEGMENTS as f32;
mesh.colored_vertex(centre + r * vec2(a.cos(), a.sin()), color);
}
for k in 0..SEGMENTS as u32 {
mesh.add_triangle(base, base + 1 + k, base + 2 + k);
}
}
egui::Shape::mesh(mesh)
}
/// One frame of the player in `rect`: the top bar, then the prototype (on its device) below.
/// Returns what the bar asked for (restarting is done here).
fn player(p: &mut Presenter, ui: &mut Ui, rect: Rect, now: f64, chrome: Chrome) -> Option<BarAction> {
let ctx = ui.ctx().clone();
p.clock = now;
let bar = Rect::from_min_size(rect.min, vec2(rect.width(), chrome.bar_h));
let area = Rect::from_min_max(pos2(rect.min.x, bar.max.y), rect.max);
let bg = crate::theme::doc_c32(ui.ctx(), p.doc.as_ref().map(|d| page_settings(d, p.current)).unwrap_or_default().background);
let r = chrome.round;
ui.painter().rect_filled(area, CornerRadius { nw: 0, ne: 0, sw: r, se: r }, bg);
let action = top_bar(p, ui, bar, now, chrome);
if action == Some(BarAction::Restart) {
p.restart(p.start, now);
}
if p.doc.as_ref().is_none_or(|d| !d.contains(p.current)) {
if !chrome.pane {
return Some(BarAction::Close);
}
ui.painter().text(area.center(), Align2::CENTER_CENTER, tr("Select a frame to preview it"), regular(12.0), Color32::from_gray(170));
return action;
}
// Scroll animation (Scroll to).
if let Some(sa) = &p.scroll_anim {
let raw = ((now - sa.start) / sa.dur.max(1e-3)).clamp(0.0, 1.0);
let e = sa.easing.eval(raw);
for (f, from, to) in &sa.frames {
p.scroll.insert(*f, (lerp(from.0, to.0, e), lerp(from.1, to.1, e)));
}
p.rev += 1;
if raw >= 1.0 {
p.scroll_anim = None;
}
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) {
// The pane only takes the keyboard while the pointer is over it.
input(p, &ctx, &l, over, chrome.keys && (!chrome.pane || over), chrome.pane, now);
}
let Some(l) = layout(p, area, chrome) else { return action };
p.shown = Some((p.current, l.place, l.clip.intersect(area)));
let painter = ui.painter_at(area);
let body = l.device.zip(l.color);
if l.device.is_some() {
if let Some((d, c)) = body {
device_body(&painter, d.name, l.screen, l.landscape, c);
}
painter.rect_filled(l.screen, CornerRadius::ZERO, bg);
}
draw(p, &painter, &ctx, &l, now);
if p.view.inspect && !chrome.pane {
inspect_hover(p, &painter, &ctx, &l, area);
}
if let Some((d, _)) = body {
device_glass(&painter, d.name, l.screen, l.landscape);
}
// Timers: wake up for the soonest pending "after delay" of the layer that can fire it (they
// wait while a transition plays, which draws every frame anyway).
if p.anim.is_none()
&& let Some((_, due)) = next_delay(p, &l.disp, l.active(p.current).0, now)
{
crate::repaint_in(&ctx, due.min(60.0));
}
action
}
/// Inspecting: the layer under the pointer becomes the inspected one; it is outlined.
fn inspect_hover(p: &mut Presenter, painter: &egui::Painter, ctx: &egui::Context, l: &Layout, area: Rect) {
let (root, place, clip) = l.active(p.current);
if let Some(q) = ctx.pointer_hover_pos().filter(|q| area.contains(*q) && clip.contains(*q))
&& let Some(id) = layer_at(&l.disp, root, &place, q)
{
p.inspected = Some(id);
}
if let Some(id) = p.inspected.filter(|id| *id == root || l.disp.is_ancestor(root, *id))
&& let Some(b) = l.disp.world_bounds(id)
{
painter.with_clip_rect(clip).rect_stroke(place.rect(b), CornerRadius::ZERO, Stroke::new(1.5, ACCENT), StrokeKind::Outside);
}
}
/// The bar above the prototype: the flow picker and the frame's name on the left, the buttons on
/// the right (text on the dark full-window bar, icons in the pane, which follows the theme).
fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome) -> Option<BarAction> {
let t = Tokens::get(ui.ctx());
let (fill, text, dim, hover) = if chrome.pane {
(t.panel, t.text, t.text_secondary, t.hover)
} else {
(Color32::from_rgb(0x2C, 0x2C, 0x2C), Color32::WHITE, Color32::from_gray(140), Color32::from_white_alpha(28))
};
let r = chrome.round;
ui.painter().rect_filled(bar, CornerRadius { nw: r, ne: r, sw: 0, se: 0 }, fill);
if chrome.pane {
ui.painter().line_segment([bar.left_bottom(), bar.right_bottom()], Stroke::new(1.0, t.border));
}
let doc = p.doc.as_ref()?;
let name = doc.node(p.current).map(|n| n.name.clone()).unwrap_or_default();
let flows = match doc.page_of(p.start).and_then(|pg| doc.node(pg)).map(|n| &n.kind) {
Some(NodeKind::Page { flows, .. }) => flows.clone(),
_ => vec![],
};
let settings = page_settings(doc, p.current);
let device = settings.device();
let mut action = None;
// Right: the buttons, right to left (English labels key their ids; shown translated).
let buttons: &[(BarAction, &str, Icon)] = if chrome.pane {
&[
(BarAction::Close, tr_noop("Close preview"), Icon::Close),
(BarAction::Present, tr_noop("Present"), Icon::Play),
(BarAction::Restart, tr_noop("Restart"), Icon::Reset),
]
} else {
&[
(BarAction::Close, tr_noop("Close"), Icon::Close),
(BarAction::Restart, tr_noop("Restart"), Icon::Reset),
(BarAction::Options, tr_noop("Options"), Icon::Dots),
(BarAction::Comments, tr_noop("Comments"), Icon::Comment),
(BarAction::Inspect, tr_noop("Inspect"), Icon::Code),
]
};
let mut x = bar.max.x - if chrome.pane { 6.0 } else { 8.0 };
for (act, label, icon) in buttons {
let shown = tr(label);
let w = if chrome.pane { 24.0 } else { ui.painter().layout_no_wrap(shown.to_string(), regular(11.5), text).size().x + 16.0 };
let br = Rect::from_min_size(pos2(x - w, bar.center().y - 12.0), vec2(w, 24.0));
x = br.min.x - if chrome.pane { 2.0 } else { 4.0 };
let mut resp = ui.interact(br, ui.id().with(("pbar", *label)), Sense::click());
if chrome.pane {
resp = resp.tip(shown);
}
let on = (*act == BarAction::Comments && p.view.comments) || (*act == BarAction::Inspect && p.view.inspect);
if on {
ui.painter().rect_filled(br, CornerRadius::same(5), ACCENT);
} else if resp.hovered() {
ui.painter().rect_filled(br, CornerRadius::same(5), hover);
}
if *act == BarAction::Comments {
resp = resp.tip(tr("Show comments (Shift+C)"));
}
if *act == BarAction::Inspect {
resp = resp.tip(tr("Inspect the layer under the pointer: size, colours, typography"));
}
if chrome.pane {
icons::paint(ui.painter(), br.shrink(5.0), *icon, t.icon);
} else {
ui.painter().text(br.center(), Align2::CENTER_CENTER, shown, regular(11.5), text);
}
if *act == BarAction::Options {
let menu = crate::right::menu_popup(&resp).width(220.0).show(|ui| options_menu(p, ui, device, settings.responsive, now));
action = action.or(menu.and_then(|m| m.inner));
} else if resp.clicked() {
action = Some(*act);
}
}
// Left: the flow picker and the frame's name, clipped before the buttons.
let left = ui.painter().with_clip_rect(Rect::from_min_max(bar.min, pos2(x, bar.max.y)));
let mut lx = bar.min.x + if chrome.pane { 8.0 } else { 12.0 };
// The flow playing: its name on the picker, its description on hover; the menu lists each
// flow with its description.
let playing = flows.iter().find(|f| f.start == p.start);
if !flows.is_empty() {
let label = format!("{} ▾", playing.map_or(tr("Flows"), |f| f.name.as_str()));
let tip = playing.map(|f| f.description.trim()).filter(|d| !d.is_empty()).unwrap_or(tr("Flows")).to_string();
let galley = left.layout_no_wrap(label, semibold(11.5), text);
let fr = Rect::from_min_size(pos2(lx, bar.center().y - 12.0), vec2(galley.size().x + 16.0, 24.0));
let resp = ui.interact(fr.intersect(left.clip_rect()), ui.id().with("pflows"), Sense::click()).tip(tip);
if resp.hovered() {
left.rect_filled(fr, CornerRadius::same(5), hover);
}
left.galley(pos2(fr.min.x + 8.0, fr.center().y - galley.size().y / 2.0), galley, text);
crate::right::menu_popup(&resp).width(240.0).show(|ui| {
let t = Tokens::get(ui.ctx());
for f in &flows {
if crate::right::menu_check(ui, f.start == p.start, &f.name, "") {
p.restart(f.start, now);
ui.close();
}
let d = f.description.trim();
if !d.is_empty() {
egui::Frame::NONE.inner_margin(egui::Margin { left: 26, right: 8, top: 0, bottom: 4 }).show(ui, |ui| {
ui.add(egui::Label::new(egui::RichText::new(d).font(regular(10.5)).color(t.text_secondary)).wrap());
});
}
}
});
lx = fr.max.x + 8.0;
left.text(pos2(lx, bar.center().y), Align2::LEFT_CENTER, "/", regular(11.5), dim);
lx += 14.0;
}
left.text(pos2(lx, bar.center().y), Align2::LEFT_CENTER, name, regular(11.5), if chrome.pane { text } else { Color32::from_gray(220) });
action
}
/// The full-window presenter's options menu: how the prototype is sized (Z cycles them), whether
/// the frame is responsive (a file setting: the frame takes the window's size, or on a device the
/// screen's, instead of being scaled or shown at a fixed size), the device's body, hotspot hints,
/// restart (R).
fn options_menu(p: &mut Presenter, ui: &mut Ui, device: Option<Device>, responsive: bool, now: f64) -> Option<BarAction> {
use crate::right::menu_check;
let mut action = None;
let on_device = device.is_some();
// Without a device, responsive is one of the sizes: the frame fills the window at 100%.
if !on_device && menu_check(ui, responsive, tr("Responsive"), "") {
action = Some(BarAction::Responsive(true));
ui.close();
}
for s in PresentScale::options(on_device) {
if menu_check(ui, p.view.scale == *s && (on_device || !responsive), s.label(on_device), "") {
p.set_scale(*s);
if !on_device && responsive {
action = Some(BarAction::Responsive(false));
}
ui.close();
}
}
if (on_device || !responsive) && menu_check(ui, false, tr("Next size option"), "Z") {
p.set_scale(p.view.scale.next(on_device));
ui.close();
}
ui.separator();
// On a device: the frame takes the screen's size, or keeps its own (clipped or scrolling).
if on_device {
for (on, label) in [(true, tr("Responsive")), (false, tr("Fixed size"))] {
if menu_check(ui, responsive == on, label, "") {
action = (responsive != on).then_some(BarAction::Responsive(on));
ui.close();
}
}
ui.separator();
}
if device.is_some_and(|d| !d.colors.is_empty()) && menu_check(ui, p.view.device_frame, tr("Show device frame"), "") {
p.view.device_frame = !p.view.device_frame;
ui.close();
}
if menu_check(ui, p.view.hotspot_hints, tr("Show hotspot hints"), "") {
p.view.hotspot_hints = !p.view.hotspot_hints;
ui.close();
}
if menu_check(ui, false, tr("Restart"), "R") {
p.restart(p.start, now);
ui.close();
}
action
}
/// 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> {
let mut out: Vec<NodeId> = disp
.subtree(root)
.into_iter()
.filter(|f| disp.node(*f).is_some_and(|n| n.visible && n.overflow != Overflow::None))
.filter(|f| disp.world_bounds(*f).is_some_and(|b| place.rect(b).contains(q)))
.filter(|f| p.range(base, *f) != (0.0, 0.0))
.collect();
out.reverse();
out
}
/// Keep a key the prototype used from reaching the editor's shortcuts.
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; `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, modifiers, .. } => Some((*key, *modifiers)),
_ => None,
})
.collect();
(
i.pointer.hover_pos(),
i.pointer.button_pressed(egui::PointerButton::Primary),
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());
// 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) = l.active(p.current);
let doc = &l.disp;
let hot = hotspots(doc, root, &place, clip);
p.hot.clone_from(&hot);
// "While hovering / pressing" reverts.
if let Some(tmp) = &p.temp {
let leave = if tmp.press { released || !down } else { pos.is_none_or(|q| !tmp.rect.contains(q)) };
if leave {
p.revert(now);
return;
}
}
// 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()
{
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.set_offset(t, next);
p.scroll_anim = None;
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() {
ctx.set_cursor_icon(egui::CursorIcon::PointingHand);
}
// First (top-most) hit that has a matching trigger.
let pick = |want: &dyn Fn(&Trigger) -> bool| -> Option<(NodeId, usize, Rect)> {
hits.iter().find_map(|(id, r)| find_trigger(doc, *id, want).map(|k| (*id, k, *r)))
};
// Enter / leave.
let ids: Vec<NodeId> = hits.iter().map(|h| h.0).collect();
let entered: Vec<(NodeId, Rect)> = hits.iter().filter(|(id, _)| !p.hovered.contains(id)).copied().collect();
let left: Vec<NodeId> = p.hovered.iter().filter(|id| !ids.contains(id)).copied().collect();
p.hovered = ids;
for id in left {
if let Some(k) = find_trigger(doc, id, |t| *t == Trigger::MouseLeave)
&& p.fire(id, k, None, now)
{
return;
}
}
for (id, r) in &entered {
if let Some(k) = find_trigger(doc, *id, |t| *t == Trigger::MouseEnter)
&& p.fire(*id, k, Some(*r), now)
{
return;
}
}
if p.temp.is_none()
&& !entered.is_empty()
&& let Some((id, k, r)) = pick(&|t| *t == Trigger::OnHover)
&& entered.iter().any(|e| e.0 == id)
&& p.fire(id, k, Some(r), now)
{
return;
}
// Press / drag / release.
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)
{
if o.close_on_outside {
p.close_overlay(now);
p.press = None;
p.hovered.clear();
}
return;
}
if let Some((id, k, r)) = pick(&|t| *t == Trigger::MouseDown)
&& p.fire(id, k, Some(r), now)
{
return;
}
if let Some((id, k, r)) = pick(&|t| *t == Trigger::OnPress)
&& p.fire(id, k, Some(r), now)
{
return;
}
}
if down
&& !p.dragged
&& let (Some(start), Some(q)) = (p.press, pos)
&& start.distance(q) > 6.0
{
p.dragged = true;
let at_start: Vec<(NodeId, Rect)> = hot.iter().rev().filter(|(_, r)| r.contains(start)).copied().collect();
if let Some((id, k, r)) = at_start.iter().find_map(|(id, r)| find_trigger(doc, *id, |t| *t == Trigger::OnDrag).map(|k| (*id, k, *r)))
&& p.fire(id, k, Some(r), now)
{
p.press = None;
return;
}
// 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((t, from)), Some(start), Some(q)) = (p.drag_scroll, p.press, pos)
&& let Some(base) = p.doc.as_ref()
{
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() {
let dragged = p.dragged;
p.press = None;
p.dragged = false;
p.drag_scroll = None;
if let Some((id, k, r)) = pick(&|t| *t == Trigger::MouseUp)
&& p.fire(id, k, Some(r), now)
{
return;
}
if !dragged {
if let Some((id, k, r)) = pick(&|t| *t == Trigger::OnClick)
&& p.fire(id, k, Some(r), now)
{
return;
}
// Clicking where nothing reacts flashes the hotspots.
if p.view.hotspot_hints {
p.flash_until = now + FLASH_SECS;
}
}
}
// 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)))
.map(|k| (id, k))
});
if let Some((id, k)) = found {
if p.fire(id, k, None, now) {
consume_key(ctx, *key);
return;
}
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;
}
// (A responsive frame filling the window has no size options.)
egui::Key::Z if !pane && (l.device.is_some() || p.fit.is_none()) => {
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()
&& let Some(base) = p.doc.as_ref()
{
let frames = base.top_frames(base.page_of(p.current).unwrap_or(p.current));
if let Some(i) = frames.iter().position(|f| *f == p.current) {
let next = if *key == egui::Key::ArrowRight { frames.get(i + 1) } else { i.checked_sub(1).and_then(|j| frames.get(j)) };
if let Some(n) = next.copied() {
p.go(n, Transition::Instant, 0.0, Easing::Linear, now, true, false);
consume_key(ctx, *key);
return;
}
}
}
}
// After delay (timers start when the frame appears; not while animating).
if p.anim.is_none()
&& let Some(((id, k), due)) = next_delay(p, doc, root, now)
&& due <= 0.0
{
p.delays_fired.insert((id, k));
p.fire(id, k, None, now);
}
}
/// Alternative spellings people type for keys.
fn key_alias(k: &str) -> Option<&'static str> {
Some(match k.to_ascii_lowercase().as_str() {
"space" | " " => "Space",
"return" | "enter" => "Enter",
"esc" | "escape" => "Escape",
"left" | "arrowleft" | "←" => "ArrowLeft",
"right" | "arrowright" | "→" => "ArrowRight",
"up" | "arrowup" | "↑" => "ArrowUp",
"down" | "arrowdown" | "↓" => "ArrowDown",
"tab" => "Tab",
"backspace" => "Backspace",
"delete" => "Delete",
_ => return None,
})
}
fn draw(p: &mut Presenter, painter: &egui::Painter, ctx: &egui::Context, l: &Layout, now: f64) {
let ppp = f64::from(ctx.pixels_per_point());
let px = l.scale * ppp;
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
let to_tex = p.texture(ctx, &l.disp, p.current, px, now);
// Transition progress.
let mut drawn = false;
if let Some(a) = &p.anim {
let raw = ((now - a.start) / a.dur.max(1e-3)).clamp(0.0, 1.0);
if raw >= 1.0 {
p.anim = None;
} else {
ctx.request_repaint();
let e = a.easing.eval(raw);
let from_place = a.from_doc.world_bounds(a.from_frame).map(|fb| Place::centered(fb, l.screen.center(), l.scale));
if matches!(a.transition, Transition::SmartAnimate) {
#[cfg(not(target_arch = "wasm32"))]
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) = 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) => {
t.set(img, egui::TextureOptions::LINEAR);
t.clone()
}
None => {
let t = ctx.load_texture("present-anim", img, egui::TextureOptions::LINEAR);
p.anim_tex = Some(t.clone());
t
}
};
painter.with_clip_rect(l.screen).image(tex.id(), l.place.rect(b), uv, Color32::WHITE);
drawn = true;
}
#[cfg(not(target_arch = "wasm32"))]
{
p.render_ms = t0.elapsed().as_secs_f64() * 1000.0;
}
} else if let (Some(ft), Some(to), Some(fp)) = (&a.from_tex, &to_tex, from_place) {
let f = transition_frames(a.transition, e as f32);
let from_rect = fp.rect(ft.bounds);
let from_frame_rect = a.from_doc.world_bounds(a.from_frame).map(|b| fp.rect(b)).unwrap_or(from_rect);
let to_rect = l.place.rect(to.bounds);
let off = |r: Rect, o: (f32, f32)| vec2(r.width() * o.0, r.height() * o.1);
// Without a device a dissolve shows both frames whole; on one, the screen clips.
let dissolve_both = l.device.is_none() && matches!(a.transition, Transition::Dissolve);
let clip = painter.with_clip_rect(if dissolve_both { l.screen.union(from_frame_rect) } else { l.screen });
let draw_from = |p: &egui::Painter| {
if f.from_alpha > 0.0 {
p.image(ft.tex.id(), from_rect.translate(off(from_frame_rect, f.from_off)), uv, Color32::WHITE.gamma_multiply(f.from_alpha));
}
};
let draw_to = |p: &egui::Painter| {
p.image(to.tex.id(), to_rect.translate(off(l.screen, f.to_off)), uv, Color32::WHITE.gamma_multiply(f.to_alpha));
};
if f.from_on_top {
draw_to(&clip);
draw_from(&clip);
} else {
draw_from(&clip);
draw_to(&clip);
}
drawn = true;
}
}
}
if !drawn && let Some(to) = &to_tex {
painter.with_clip_rect(l.clip).image(to.tex.id(), l.place.rect(to.bounds), uv, Color32::WHITE);
}
// Overlays (each with its background and opening transition); closing ones run it backwards.
let mut ovs: Vec<(f64, OverlayState)> =
p.overlays.iter().map(|o| (if o.dur > 0.0 { ((now - o.opened) / o.dur).clamp(0.0, 1.0) } else { 1.0 }, o.clone())).collect();
let mut placed: Vec<(NodeId, Place, Rect)> = l.overlays.clone();
for ((at, o), pl) in p.closing.iter().zip(l.closing.iter()) {
if now - at >= o.dur {
continue;
}
ovs.insert(0, (1.0 - ((now - at) / o.dur.max(1e-3)).clamp(0.0, 1.0), o.clone()));
placed.insert(0, *pl);
}
let on_screen = painter.with_clip_rect(l.screen);
for ((raw, o), (id, place, orect)) in ovs.iter().zip(placed.iter()) {
let raw = *raw;
if raw < 1.0 {
ctx.request_repaint();
}
let e = o.easing.eval(raw) as f32;
let f = if matches!(o.transition, Transition::Instant) {
transition_frames(Transition::Dissolve, 1.0)
} else {
transition_frames(o.transition, e)
};
if let Some(bgc) = o.background {
let c = crate::theme::doc_c32(ctx, bgc);
on_screen.rect_filled(l.screen, CornerRadius::ZERO, c.gamma_multiply(raw as f32));
}
if let Some(tex) = p.texture(ctx, &l.disp, *id, px, now) {
let r = place.rect(tex.bounds).translate(vec2(orect.width() * f.to_off.0, orect.height() * f.to_off.1));
on_screen.image(tex.tex.id(), r, uv, Color32::WHITE.gamma_multiply(f.to_alpha));
}
}
// Finished closing animations go (after drawing, so they stayed paired with the layout).
p.closing.retain(|(at, o)| now - at < o.dur);
// Hotspot flash after a click on nothing.
if now < p.flash_until {
let (root, place, clip) = l.active(p.current);
let a = ((p.flash_until - now) / FLASH_SECS) as f32;
for (_, r) in hotspots(&l.disp, root, &place, clip) {
painter.rect_filled(r, CornerRadius::same(4), ACCENT.gamma_multiply(0.25 * a));
painter.rect_stroke(r, CornerRadius::same(4), Stroke::new(1.5, ACCENT.gamma_multiply(a)), StrokeKind::Outside);
}
ctx.request_repaint();
}
}
#[cfg(test)]
#[path = "present_variables_tests.rs"]
mod variables_tests;
#[cfg(test)]
mod tests {
use super::*;
use figcraft_doc::NodeKind;
fn doc_with_two_frames() -> (Document, NodeId, NodeId) {
let mut d = Document::new("t");
let page = d.add_page("Page 1");
let a = d.create(page, NodeKind::Frame, "A", 0.0, 0.0, 200.0, 400.0).unwrap();
let b = d.create(page, NodeKind::Frame, "B", 300.0, 0.0, 200.0, 400.0).unwrap();
(d, a, b)
}
#[test]
fn closed_overlays_animate_out_unless_instant() {
let (_, a, b) = doc_with_two_frames();
let mut p = Presenter::new(a);
let ov = |transition, opened| OverlayState {
id: b,
position: OverlayPosition::Center,
close_on_outside: true,
background: None,
anchor: None,
opened,
transition,
dur: 0.3,
easing: Easing::EaseOut,
};
p.overlays.push(ov(Transition::Dissolve, 0.0));
assert!(p.close_overlay(10.0));
assert!(p.overlays.is_empty());
assert_eq!(p.closing.len(), 1);
assert_eq!(p.closing[0].0, 10.0, "fully open: the reverse starts now");
// Closed half way through opening: it reverses from there.
p.overlays.push(ov(Transition::Dissolve, 20.0));
p.close_overlay(20.15);
assert!((p.closing[1].0 - 20.0).abs() < 1e-9);
// Instant overlays just go.
p.overlays.push(ov(Transition::Instant, 30.0));
p.close_overlay(31.0);
assert_eq!(p.closing.len(), 2);
assert!(!p.close_overlay(32.0), "nothing left to close");
}
#[test]
fn matching_by_name_path() {
let (mut d, a, b) = doc_with_two_frames();
let ga = d.create(a, NodeKind::Group, "Card", 0.0, 0.0, 10.0, 10.0).unwrap();
let ra = d.create(ga, NodeKind::Rectangle, "Box", 0.0, 0.0, 10.0, 10.0).unwrap();
let ra2 = d.create(ga, NodeKind::Rectangle, "Box", 0.0, 0.0, 10.0, 10.0).unwrap();
let only_a = d.create(a, NodeKind::Rectangle, "Gone", 0.0, 0.0, 10.0, 10.0).unwrap();
let gb = d.create(b, NodeKind::Group, "Card", 0.0, 0.0, 10.0, 10.0).unwrap();
let rb = d.create(gb, NodeKind::Rectangle, "Box", 0.0, 0.0, 10.0, 10.0).unwrap();
let rb2 = d.create(gb, NodeKind::Rectangle, "Box", 0.0, 0.0, 10.0, 10.0).unwrap();
// Same name, different parent: no match.
let _other = d.create(b, NodeKind::Rectangle, "Box", 0.0, 0.0, 10.0, 10.0).unwrap();
let m = match_layers(&d, a, &d, b);
assert_eq!(m, vec![(ga, gb), (ra, rb), (ra2, rb2)]);
assert!(!m.iter().any(|(f, _)| *f == only_a));
assert_eq!(name_path(&d, a, ra).as_deref(), Some("Card\u{1f}Box"));
assert_eq!(name_path(&d, b, ra), None);
}
#[test]
fn smart_interpolate_tweens_matched_and_fades_the_rest() {
let (mut d, a, b) = doc_with_two_frames();
let ra = d.create(a, NodeKind::Rectangle, "Box", 0.0, 0.0, 20.0, 20.0).unwrap();
let rb = d.create(b, NodeKind::Rectangle, "Box", 100.0, 200.0, 60.0, 40.0).unwrap();
{
let n = d.get_mut(ra).unwrap();
n.fills = vec![Paint::solid(Color::new(1.0, 0.0, 0.0, 1.0))];
n.corners.radii = [0.0; 4];
n.opacity = 1.0;
}
{
let n = d.get_mut(rb).unwrap();
n.fills = vec![Paint::solid(Color::new(0.0, 0.0, 1.0, 1.0))];
n.corners.radii = [20.0; 4];
n.opacity = 0.5;
// Rotate 90° about its centre.
let c = (130.0, 220.0);
n.transform = Affine::translate(c) * Affine::rotate(std::f64::consts::FRAC_PI_2) * Affine::translate((-30.0, -20.0));
}
let only_b = d.create(b, NodeKind::Rectangle, "New", 0.0, 0.0, 5.0, 5.0);
let only_a = d.create(a, NodeKind::Rectangle, "Old", 0.0, 0.0, 5.0, 5.0).unwrap();
let mid = smart_interpolate(&d, a, &d, b, 0.5);
let n = mid.node(rb).unwrap();
assert!((n.width - 40.0).abs() < 1e-9 && (n.height - 30.0).abs() < 1e-9);
assert!((n.opacity - 0.75).abs() < 1e-6);
assert!((n.corners.radii[0] - 10.0).abs() < 1e-9);
let c = n.fills[0].solid_color().unwrap();
assert!((c.r - 0.5).abs() < 0.02 && (c.b - 0.5).abs() < 0.02, "{c:?}");
// Centre halfway between (10, 10) and (130, 220) in frame space; rotated 45°.
let ctr = mid.world_transform(b).inverse() * mid.world_transform(rb) * Point::new(n.width / 2.0, n.height / 2.0);
assert!((ctr.x - 70.0).abs() < 1e-6 && (ctr.y - 115.0).abs() < 1e-6, "{ctr:?}");
let [aa, bb, ..] = n.transform.as_coeffs();
assert!((bb.atan2(aa) - std::f64::consts::FRAC_PI_4).abs() < 1e-6);
if let Ok(ob) = only_b {
assert!((mid.node(ob).unwrap().opacity - 0.5).abs() < 1e-6);
}
// The source-only layer is copied into the destination frame, half faded.
let copies: Vec<_> = mid.node(b).unwrap().children.iter().filter_map(|c| mid.node(*c)).filter(|n| n.name == "Old").collect();
assert_eq!(copies.len(), 1);
assert!((copies[0].opacity - 0.5).abs() < 1e-6);
assert_ne!(copies[0].id, only_a);
// At the end it is exactly the destination.
let end = smart_interpolate(&d, a, &d, b, 1.0);
assert_eq!(end.node(rb), d.node(rb));
assert_eq!(end.node(b).unwrap().children, d.node(b).unwrap().children);
// At the start the matched layer sits where the source was.
let start = smart_interpolate(&d, a, &d, b, 0.0);
let s = start.world_bounds(rb).unwrap();
let f = start.world_bounds(b).unwrap();
assert!((s.x0 - f.x0).abs() < 1e-6 && (s.y0 - f.y0).abs() < 1e-6 && (s.width() - 20.0).abs() < 1e-6);
}
#[test]
fn scroll_offsets_fixed_and_sticky() {
let (mut d, a, _) = doc_with_two_frames();
d.get_mut(a).unwrap().overflow = Overflow::Vertical;
let long = d.create(a, NodeKind::Rectangle, "Long", 0.0, 0.0, 200.0, 1000.0).unwrap();
let fixed = d.create(a, NodeKind::Rectangle, "Bar", 0.0, 350.0, 200.0, 50.0).unwrap();
let sticky = d.create(a, NodeKind::Rectangle, "Header", 0.0, 100.0, 200.0, 40.0).unwrap();
d.get_mut(fixed).unwrap().scroll_position = ScrollPosition::Fixed;
d.get_mut(sticky).unwrap().scroll_position = ScrollPosition::Sticky;
assert_eq!(scroll_range(&d, a), (0.0, 600.0));
d.get_mut(a).unwrap().overflow = Overflow::Horizontal;
assert_eq!(scroll_range(&d, a), (0.0, 0.0));
d.get_mut(a).unwrap().overflow = Overflow::Vertical;
let mut s = HashMap::new();
s.insert(a, (0.0, 50.0));
let out = apply_scroll(&d, &s);
let y = |doc: &Document, id| doc.world_bounds(id).unwrap().y0;
assert_eq!(y(&out, long), -50.0);
assert_eq!(y(&out, fixed), 350.0);
assert_eq!(y(&out, sticky), 50.0);
s.insert(a, (0.0, 300.0));
let out = apply_scroll(&d, &s);
assert_eq!(y(&out, long), -300.0);
assert_eq!(y(&out, sticky), 0.0);
}
/// A page presented on an iPhone 16 (393×852): a tall screen A that scrolls vertically, with a
/// tab bar fixed to its bottom, a nested scrolling list and a layer at its end; screen B.
fn device_doc() -> (Document, NodeId, NodeId, NodeId, NodeId, NodeId) {
let mut d = Document::new("t");
let page = d.add_page("Page 1");
if let NodeKind::Page { prototype, .. } = &mut d.get_mut(page).unwrap().kind {
prototype.device = "iPhone 16".into();
}
let a = d.create(page, NodeKind::Frame, "A", 0.0, 0.0, 393.0, 2000.0).unwrap();
let b = d.create(page, NodeKind::Frame, "B", 500.0, 0.0, 393.0, 1500.0).unwrap();
for f in [a, b] {
d.get_mut(f).unwrap().overflow = Overflow::Vertical;
}
let bar = d.create(a, NodeKind::Rectangle, "Tab bar", 0.0, 1940.0, 393.0, 60.0).unwrap();
let n = d.get_mut(bar).unwrap();
n.scroll_position = ScrollPosition::Fixed;
n.constraints.vertical = VConstraint::Bottom;
let list = d.create(a, NodeKind::Frame, "List", 0.0, 1200.0, 393.0, 300.0).unwrap();
d.get_mut(list).unwrap().overflow = Overflow::Vertical;
let item = d.create(list, NodeKind::Rectangle, "Item", 0.0, 700.0, 393.0, 100.0).unwrap();
(d, a, b, bar, list, item)
}
fn player_on(d: Document, start: NodeId) -> Presenter {
let mut p = Presenter::new(start);
p.sync(&Arc::new(d), 0.0);
p
}
#[test]
fn device_screen_clips_the_frame_and_it_scrolls_through_its_height() {
let (mut d, a, _, bar, list, _) = device_doc();
assert_eq!(viewport(&d, a), Some((393.0, 852.0)));
// On the device the whole frame scrolls; at its own size nothing overflows it.
assert_eq!(scroll_range_in(&d, a, viewport(&d, a)), (0.0, 1148.0));
assert_eq!(scroll_range(&d, a), (0.0, 0.0));
// Nested frames scroll within themselves.
assert_eq!(scroll_range(&d, list), (0.0, 500.0));
// The display copy is sized to the screen; the fixed tab bar moves to the screen's bottom.
let mut p = player_on(d.clone(), a);
let disp = p.display().unwrap();
assert_eq!((disp.node(a).unwrap().width, disp.node(a).unwrap().height), (393.0, 852.0));
assert_eq!(disp.world_bounds(bar).unwrap().y0, 792.0);
// Scrolled, it stays there while the content moves.
p.scroll_to(0.0, 400.0);
let disp = p.display().unwrap();
assert_eq!(disp.world_bounds(bar).unwrap().y0, 792.0);
assert_eq!(disp.world_bounds(list).unwrap().y0, 800.0);
p.scroll_to(0.0, 99_999.0);
assert_eq!(p.scroll[&a], (0.0, 1148.0), "clamped to the range");
// Without scrolling the screen only clips; without a device the frame shows whole.
d.get_mut(a).unwrap().overflow = Overflow::None;
assert_eq!(scroll_range_in(&d, a, viewport(&d, a)), (0.0, 0.0));
if let Some(pg) = d.page_of(a)
&& let NodeKind::Page { prototype, .. } = &mut d.get_mut(pg).unwrap().kind
{
prototype.device = "None".into();
}
assert_eq!(viewport(&d, a), None);
// A frame wider than the screen scrolls sideways when it scrolls both ways.
let (mut d, a, ..) = device_doc();
let n = d.get_mut(a).unwrap();
n.width = 800.0;
n.overflow = Overflow::Both;
assert_eq!(scroll_range_in(&d, a, viewport(&d, a)), (407.0, 1148.0));
}
#[test]
fn scroll_to_moves_every_scrolling_frame_around_the_layer() {
let (d, a, _, _, list, item) = device_doc();
let mut p = player_on(d.clone(), a);
assert!(p.perform(&Action::ScrollTo { dest: item }, item, Transition::Instant, 0.0, Easing::Linear, None, 1.0));
// The list brings the item to its top (as far as it scrolls), the screen brings the list.
assert_eq!(p.scroll[&list], (0.0, 500.0));
assert_eq!(p.scroll[&a], (0.0, 1148.0));
// Animated, it gets there over the duration.
let mut p = player_on(d, a);
assert!(p.perform(&Action::ScrollTo { dest: list }, list, Transition::Dissolve, 0.3, Easing::Linear, None, 1.0));
let anim = p.scroll_anim.as_ref().unwrap();
assert_eq!(anim.frames, vec![(a, (0.0, 0.0), (0.0, 1148.0))]);
}
#[test]
fn navigating_resets_scroll_unless_it_preserves_it() {
let (d, a, b, ..) = device_doc();
let mut p = player_on(d, a);
p.scroll_to(0.0, 600.0);
assert!(p.perform(&Action::Navigate { dest: b, preserve_scroll: false }, a, Transition::Instant, 0.0, Easing::Linear, None, 1.0));
assert_eq!(p.scroll.get(&b), None, "starts at the top");
p.restart(a, 2.0);
p.scroll_to(0.0, 1000.0);
assert!(p.perform(&Action::Navigate { dest: b, preserve_scroll: true }, a, Transition::Instant, 0.0, Easing::Linear, None, 3.0));
assert_eq!(p.scroll[&b], (0.0, 648.0), "kept, as far as B scrolls");
// Back starts at the top again.
assert!(p.perform(&Action::Back, b, Transition::Instant, 0.0, Easing::Linear, None, 4.0));
assert_eq!((p.current, p.scroll.get(&a)), (a, None));
}
#[test]
fn sync_follows_edits_and_keeps_the_screen() {
let (mut d, a, b, _, list, _) = device_doc();
let mut p = player_on(d.clone(), a);
p.go(b, Transition::Instant, 0.0, Easing::Linear, 1.0, true, false);
p.scroll_to(0.0, 600.0);
// The same document again changes nothing.
let same = Arc::new(d.clone());
p.sync(&same, 2.0);
let rev = p.rev;
p.sync(&same, 2.0);
assert_eq!(p.rev, rev);
// An edit is copied; the screen and its scroll stay (clamped to the new range).
d.get_mut(b).unwrap().height = 1000.0;
d.get_mut(b).unwrap().name = "B2".into();
p.sync(&Arc::new(d.clone()), 3.0);
assert_eq!(p.current, b);
assert_eq!(p.inspect()["name"], "B2");
assert_eq!(p.scroll[&b], (0.0, 148.0));
assert_eq!(p.history.len(), 1);
// The screen deleted: back to the start.
d.remove(b).unwrap();
p.sync(&Arc::new(d.clone()), 4.0);
assert_eq!(p.current, a);
assert!(p.history.is_empty() && p.scroll.is_empty());
// Junk scroll offsets never panic and stay in range.
p.scroll.insert(list, (f64::NAN, f64::INFINITY));
p.sync(&Arc::new(d), 5.0);
assert_eq!(p.scroll[&list], (0.0, 0.0));
}
#[test]
fn transitions_move_the_right_way() {
let left = transition_frames(Transition::MoveIn { direction: Direction::Left }, 0.0);
assert_eq!(left.to_off, (1.0, 0.0));
assert_eq!(transition_frames(Transition::MoveIn { direction: Direction::Left }, 1.0).to_off, (0.0, 0.0));
let push = transition_frames(Transition::Push { direction: Direction::Top }, 0.5);
assert_eq!(push.from_off, (0.0, -0.5));
assert_eq!(push.to_off, (0.0, 0.5));
let out = transition_frames(Transition::MoveOut { direction: Direction::Right }, 0.25);
assert!(out.from_on_top);
assert_eq!(out.from_off, (0.25, 0.0));
assert_eq!(transition_frames(Transition::Dissolve, 0.3).to_alpha, 0.3);
assert_eq!(transition_frames(Transition::Instant, 0.0).from_alpha, 0.0);
let r = Transition::SlideIn { direction: Direction::Bottom }.reversed();
assert_eq!(r, Transition::SlideOut { direction: Direction::Top });
}
#[test]
fn overlay_positions() {
let base = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 800.0));
let size = vec2(200.0, 100.0);
assert_eq!(overlay_rect(OverlayPosition::Center, base, size, None).min, pos2(100.0, 350.0));
assert_eq!(overlay_rect(OverlayPosition::BottomCenter, base, size, None).min, pos2(100.0, 700.0));
assert_eq!(overlay_rect(OverlayPosition::TopRight, base, size, None).min, pos2(200.0, 0.0));
let anchor = Rect::from_min_size(pos2(30.0, 40.0), vec2(50.0, 20.0));
assert_eq!(overlay_rect(OverlayPosition::Manual, base, size, Some(anchor)).min, pos2(30.0, 60.0));
}
/// Smart animate renders a temporary document per animation frame; keep that cheap.
#[test]
fn smart_animate_frame_cost() {
let (mut d, a, b) = doc_with_two_frames();
for (f, dx) in [(a, 0.0), (b, 40.0)] {
for i in 0..40 {
let r = d.create(f, NodeKind::Rectangle, &format!("Row {i}"), dx + 10.0, 10.0 + f64::from(i) * 9.0, 120.0, 8.0).unwrap();
d.get_mut(r).unwrap().corners.radii = [4.0; 4];
}
}
let t0 = std::time::Instant::now();
let n = 10;
for k in 0..n {
let tmp = smart_interpolate(&d, a, &d, b, f64::from(k) / f64::from(n));
assert!(figcraft_render::render_node(&tmp, b, 2.0).is_some());
}
let per = t0.elapsed().as_secs_f64() * 1000.0 / f64::from(n);
eprintln!("smart animate: {per:.2} ms per frame (80 layers, 400x800 px)");
}
}