M5: prototyping: overlays animate out when closed (#19)

- Closing an overlay (Close overlay, Back, a click outside) plays its opening transition
  backwards instead of removing it at once; one closed mid-opening reverses from where it got
  to; instant overlays still go immediately.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat 2026-10-10 03:52:28 +07:00 committed by GitHub
parent b5db089db6
commit 8838515cf7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 77 additions and 17 deletions

View File

@ -64,8 +64,9 @@ dimension measured side by side.
5. **Prototyping depth**: *landed:* on-canvas connections with drag-to-connect, interaction editor,
flows, smart animate, overlays, scroll/fixed/sticky, change-to and set-variable in the presenter.
Custom bezier and custom spring easings with editor fields; device frames (phone, tablet,
laptop, desktop bodies drawn in code) around the presented frame. *Left:* overlay close
animation, multi-selection editing, scrolling inside device frames. 5–8 h.
laptop, desktop bodies drawn in code) around the presented frame; overlays animate out with
their opening transition reversed. *Left:* multi-selection editing, scrolling inside device
frames. 4–7 h.
6. **Interop**: *landed:* image crop handles; REST API file JSON import (`file.open` / `file.import`
/ CLI: pages, layer tree, geometry, paints, strokes, effects, text runs, vectors from geometry
paths, auto layout, constraints, grids, components, sets, instances with overrides, component
@ -140,7 +141,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent).
| Text | 8 | 60% | right-to-left/bidi, text style picker depth, axis hover preview | 8–12 |
| Components & variants | 9 | 55% | nested overrides depth, libraries, slots, descriptions | 12–18 |
| Styles & variables | 5 | 60% | aliasing UI, scoping, binding padding/font size/opacity/text, string and boolean bindings in the panel | 6–10 |
| Prototyping | 6 | 60% | overlay close animation, multi-selection editing, scrolling inside device frames | 6–10 |
| Prototyping | 6 | 65% | multi-selection editing, scrolling inside device frames | 4–7 |
| Import/export | 6 | 50% | PDF (in progress), .fig import, export panel polish, REST import of prototypes/variables | 18–32 |
| Dev mode / inspect | 2 | 55% | asset export list in Inspect, redlines in the Inspect tab, code for whole frames as components | 4–6 |
| Comments, versions, misc | 3 | 25% | comments, version history UI, plugins (WASM) | 15–25 |

View File

@ -94,6 +94,8 @@ 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.
@ -118,11 +120,24 @@ pub struct Presenter {
}
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,
@ -255,8 +270,7 @@ impl Presenter {
true
}
Action::Back => {
if !self.overlays.is_empty() {
self.overlays.pop();
if self.close_overlay(now) {
return true;
}
match self.history.pop() {
@ -332,7 +346,7 @@ impl Presenter {
self.hovered.clear();
true
}
Action::CloseOverlay => self.overlays.pop().is_some(),
Action::CloseOverlay => self.close_overlay(now),
Action::OpenUrl { url } => {
if !url.trim().is_empty() {
self.urls.push(url.clone());
@ -780,6 +794,8 @@ struct Layout {
place: Place,
target: Rect,
overlays: Vec<(NodeId, Place, Rect)>,
/// Overlays animating out (same order as `Presenter::closing`).
closing: Vec<(NodeId, Place, Rect)>,
}
/// The page's prototype device ("None" when unset).
@ -802,14 +818,15 @@ fn layout(p: &mut Presenter, area: Rect) -> Option<Layout> {
let scale = (f64::from(avail.width()) / fb.width().max(1.0)).min(f64::from(avail.height()) / fb.height().max(1.0)).clamp(0.01, 1.0);
let place = Place::centered(fb, area.center(), scale);
let target = place.rect(fb);
let mut overlays = Vec::new();
for o in &p.overlays {
let Some(ob) = disp.world_bounds(o.id) else { continue };
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, target, size, o.anchor);
overlays.push((o.id, Place { page_min: (ob.x0, ob.y0), origin: r.min, scale }, r));
}
Some(Layout { disp, scale, place, target, overlays })
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();
Some(Layout { disp, scale, place, target, overlays, closing })
}
pub fn show(app: &mut FigApp, ui: &mut Ui) {
@ -1124,7 +1141,7 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, no
&& !orect.contains(q)
{
if o.close_on_outside {
p.overlays.pop();
p.close_overlay(now);
p.press = None;
p.hovered.clear();
}
@ -1315,10 +1332,19 @@ fn draw(p: &mut Presenter, ui: &mut Ui, ctx: &egui::Context, l: &Layout, area: R
if !drawn && let Some(to) = &to_tex {
painter.image(to.tex.id(), l.place.rect(to.bounds), uv, Color32::WHITE);
}
// Overlays (each with its background and opening transition).
let ovs = p.overlays.clone();
for (o, (id, place, orect)) in ovs.iter().zip(l.overlays.iter()) {
let raw = if o.dur > 0.0 { ((now - o.opened) / o.dur).clamp(0.0, 1.0) } else { 1.0 };
// 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);
}
for ((raw, o), (id, place, orect)) in ovs.iter().zip(placed.iter()) {
let raw = *raw;
if raw < 1.0 {
ctx.request_repaint();
}
@ -1337,6 +1363,8 @@ fn draw(p: &mut Presenter, ui: &mut Ui, ctx: &egui::Context, l: &Layout, area: R
painter.with_clip_rect(l.target).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) = match l.overlays.last() {
@ -1365,6 +1393,37 @@ mod tests {
(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();