M8: no redraws while idle (#99)

* M8: no redraws while idle

An idle window redrew ~240 times a second: the window title was re-sent every
frame and each viewport command asks for another frame. It is now sent only when
it changes. With that gone, the frames that are still needed wake the UI at their
deadline instead of relying on the loop:

- toasts ask for the frame that hides them (shown-at time instead of summed dt);
- the autosave checkpoint wakes the UI when due;
- the presenter's "after delay" timers schedule and fire from the same layer
  (an overlay over a frame with a pending delay no longer spins);
- the interaction editor's curve preview plays when it shows, changes or is
  hovered, then rests;
- a control-channel screenshot keeps frames coming only until the window
  delivers it (it hung on an idle window), expiring after 60 s;
- the system font scan wakes the UI when it finishes.

ui.inspect reports framesDrawn and repaintCauses (file:line of repaint
requests); ui.set {resetFrameStats} starts them over. Headless idle tests cover
a document, a selected text layer, the comments panel, the interaction editor,
the presenter, toasts, background renders, control requests and screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M8: idle tests build the render job with the focus view's solo field

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat 2026-10-10 23:53:05 +07:00 committed by GitHub
parent bc587ad28a
commit d452da3452
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 453 additions and 78 deletions

View File

@ -271,7 +271,14 @@ dimension measured side by side.
through the layer rows, menus and tool flyouts opened from the keyboard take it and give it back,
Escape returns to the canvas (where Tab selects the next layer again instead of wandering into
the tab strip); reduced motion follows the system's setting or a preference; secondary and
accent text meet WCAG AA in both themes. *Left:* a real-world file corpus, Windows/Linux/browser
accent text meet WCAG AA in both themes.
An idle window draws no frames: it redrew ~240 times a second
(the window title was re-sent every frame, and each viewport command asks for another), ~33% of
a core in a debug build, and now sits at 0 frames and ~0% CPU; the toast, the autosave
checkpoint and the presenter's "after delay" timers wake it at their deadline, the interaction
editor's curve preview plays when it shows, changes or is hovered, and a screenshot keeps frames
coming only until it arrives (`ui.inspect` `framesDrawn` and `repaintCauses` show what asked for
frames; headless tests hold it). *Left:* a real-world file corpus, Windows/Linux/browser
QA, runs with real screen readers (NVDA, Narrator, VoiceOver, Orca), the AccessKit tree in the
browser (eframe's web backend doesn't send it yet), keyboard box selection, keyboard rename and
context menus in the layers panel. 28–46 h.

View File

@ -21,6 +21,9 @@ struct App {
fig: FigApp,
queue: Arc<Mutex<Vec<ControlRequest>>>,
ctx_slot: Arc<Mutex<Option<egui::Context>>>,
/// The window title last sent: a viewport command asks for another frame, so it's only sent
/// when the title changes (sending it every frame kept an idle window redrawing).
title: String,
}
impl eframe::App for App {
@ -39,7 +42,10 @@ impl eframe::App for App {
Ok(ds) => format!("{}{} – FigCraft", ds.doc.name, if ds.is_dirty() { " •" } else { "" }),
Err(_) => "FigCraft".into(),
};
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title));
if title != self.title {
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
self.title = title;
}
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) {
@ -55,6 +61,7 @@ impl eframe::App for App {
e.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| e.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into());
log::error!("UI panic: {msg}");
self.fig.toast(format!("Internal error (please report): {msg}"));
ui.ctx().request_repaint();
services::emergency_save(&self.fig.session);
}
}
@ -89,8 +96,16 @@ fn main() -> eframe::Result {
}
i += 1;
}
// System fonts: index them in the background.
std::thread::spawn(figcraft_text::scan_system_fonts);
// System fonts: index them in the background, then wake the UI (an idle window draws no frames)
// to lay text out again with them.
let ctx_slot: Arc<Mutex<Option<egui::Context>>> = Arc::new(Mutex::new(None));
let fonts_slot = ctx_slot.clone();
std::thread::spawn(move || {
figcraft_text::scan_system_fonts();
if let Some(c) = fonts_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
c.request_repaint();
}
});
let mut session = Session::empty();
for f in &files {
if let Err(e) = session.execute("file.open", &json!({"path": f})) {
@ -107,7 +122,6 @@ fn main() -> eframe::Result {
let mut fig = FigApp::new(session, Box::new(svc));
fig.ui.home = false;
let queue: Arc<Mutex<Vec<ControlRequest>>> = Arc::new(Mutex::new(Vec::new()));
let ctx_slot: Arc<Mutex<Option<egui::Context>>> = Arc::new(Mutex::new(None));
if let Some(p) = port {
control_server::start(p, queue.clone(), ctx_slot.clone());
}
@ -130,5 +144,5 @@ fn main() -> eframe::Result {
viewport = viewport.with_icon(icon);
}
let options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() };
eframe::run_native("FigCraft", options, Box::new(move |_cc| Ok(Box::new(App { fig, queue, ctx_slot }))))
eframe::run_native("FigCraft", options, Box::new(move |_cc| Ok(Box::new(App { fig, queue, ctx_slot, title: String::new() }))))
}

View File

@ -43,13 +43,20 @@ pub struct PendingShot {
pub id: Value,
pub path: String,
pub reply: std::sync::mpsc::Sender<ControlReply>,
/// When it was asked for (UI clock, seconds).
pub since: f64,
}
/// How long a screenshot waits for the window to deliver it (the control server gives up then too).
const SHOT_TIMEOUT: f64 = 60.0;
fn pending(ctx: &Context) -> std::sync::Arc<std::sync::Mutex<Vec<PendingShot>>> {
ctx.data_mut(|d| d.get_temp_mut_or_insert_with(egui::Id::new("pending_shots"), || std::sync::Arc::new(std::sync::Mutex::new(Vec::new()))).clone())
}
pub fn drain(app: &mut FigApp, ctx: &Context) {
let now = ctx.input(|i| i.time);
let list = pending(ctx);
// Finish screenshots that arrived.
let shots: Vec<std::sync::Arc<egui::ColorImage>> = ctx.input(|i| {
i.events
@ -61,9 +68,7 @@ pub fn drain(app: &mut FigApp, ctx: &Context) {
.collect()
});
if let Some(img) = shots.last() {
let list = pending(ctx);
let mut list = list.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
for p in list.drain(..) {
for p in list.lock().unwrap_or_else(std::sync::PoisonError::into_inner).drain(..) {
let res = save_color_image(img, &p.path).map(|_| json!({"path": p.path, "width": img.size[0], "height": img.size[1]}));
let _ = p.reply.send(ControlReply { id: p.id, result: res });
}
@ -72,15 +77,28 @@ pub fn drain(app: &mut FigApp, ctx: &Context) {
for r in reqs {
if r.method == "ui.screenshot" {
let path = r.params.get("path").and_then(Value::as_str).unwrap_or("figcraft-screenshot.png").to_string();
pending(ctx).lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(PendingShot { id: r.id, path, reply: r.reply });
list.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(PendingShot { id: r.id, path, reply: r.reply, since: now });
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default()));
ctx.request_repaint();
continue;
}
let res = handle(app, ctx, &r.method, &r.params);
let _ = r.reply.send(ControlReply { id: r.id, result: res });
ctx.request_repaint();
}
// The window delivers a screenshot some frames after it was asked for (once the GPU has copied
// it back), and only while frames are drawn: keep drawing until it arrives (a hidden window
// never delivers; its requests expire).
let mut list = list.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
list.retain(|p| {
let live = now - p.since < SHOT_TIMEOUT;
if !live {
let _ = p.reply.send(ControlReply { id: p.id.clone(), result: Err("the window didn't deliver a screenshot (is it visible?)".into()) });
}
live
});
if !list.is_empty() {
ctx.request_repaint();
}
}
fn save_color_image(img: &egui::ColorImage, path: &str) -> Result<(), String> {
@ -446,6 +464,9 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
let doc = app.session.doc().map_err(|e| e.to_string())?;
app.ui.dev.categories = crate::dev_mode::CategoryFilter::parse(doc, v).ok_or("annotationFilter: all|none|a category id or name")?;
}
if b(p, "resetFrameStats") {
app.frame_stats.reset();
}
Ok(app.inspect())
}
"ui.render" => {

View File

@ -0,0 +1,40 @@
//! Frame statistics for spotting wasted redraws: how many frames the UI drew and what asked for
//! them (the `file:line` of each repaint request), read through `ui.inspect` (`framesDrawn`,
//! `repaintCauses`) and reset with `ui.set {resetFrameStats: true}`. An idle window should draw
//! no frames at all.
use std::collections::BTreeMap;
use serde_json::{Value, json};
/// How many of the most frequent repaint causes `ui.inspect` lists.
const TOP_CAUSES: usize = 12;
#[derive(Default)]
pub struct FrameStats {
/// Frames drawn since the app started (or the last reset).
pub frames: u64,
/// Repaint requests by call site, counted once per frame they asked for.
causes: BTreeMap<(&'static str, u32), u64>,
}
impl FrameStats {
/// Count this frame and what asked for it (call once a frame).
pub fn record(&mut self, ctx: &egui::Context) {
self.frames += 1;
for c in ctx.repaint_causes() {
*self.causes.entry((c.file, c.line)).or_default() += 1;
}
}
pub fn reset(&mut self) {
*self = Self::default();
}
/// The most frequent repaint causes, most frequent first: `[{at: "file:line", count}]`.
pub fn causes(&self) -> Value {
let mut top: Vec<(&(&'static str, u32), &u64)> = self.causes.iter().collect();
top.sort_by(|a, b| b.1.cmp(a.1));
top.into_iter().take(TOP_CAUSES).map(|((file, line), n)| json!({"at": format!("{file}:{line}"), "count": n})).collect()
}
}

View File

@ -0,0 +1,238 @@
//! An idle window draws no frames: once renders and animations settle, the whole UI (driven
//! headless through egui) asks for no further immediate repaint, only far-off deadlines (the
//! autosave checkpoint, a toast going away). What does need a frame wakes the UI: a finished
//! background render, a screenshot on its way, a control request.
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use egui::{Event, Pos2, RawInput, Rect, ViewportId, vec2};
use figcraft_engine::Session;
use serde_json::{Value, json};
use crate::present_tests::id;
use crate::{ControlRequest, FigApp, NoServices};
/// The shortest repaint delay that counts as idle (a blinking caret wakes twice a second; nothing
/// here edits text).
const IDLE: Duration = Duration::from_secs(1);
struct Harness {
ctx: egui::Context,
app: FigApp,
time: f64,
}
impl Harness {
fn new(s: Session) -> Self {
Self { ctx: egui::Context::default(), app: FigApp::new(s, Box::new(NoServices)), time: 0.0 }
}
/// One frame with `events`, 1/60 s after the last; returns the repaint delay it asked for.
fn frame(&mut self, events: Vec<Event>) -> Duration {
self.time += 1.0 / 60.0;
let input =
RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), events, ..Default::default() };
let app = &mut self.app;
let mut out = self.ctx.run_ui(input, |ui| app.ui(ui));
out.textures_delta.clear();
out.viewport_output.get(&ViewportId::ROOT).map_or(Duration::MAX, |v| v.repaint_delay)
}
/// Draw frames with no input until the UI is idle: no render in flight and three frames in a
/// row asking for no repaint sooner than [`IDLE`]. Returns the last frame's delay; fails with
/// what kept asking for frames.
fn settle(&mut self) -> Duration {
let start = Instant::now();
let mut quiet = 0;
for _ in 0..2000 {
let delay = self.frame(vec![]);
let rendering = self.app.worker.as_ref().is_some_and(|w| w.in_flight);
if rendering {
// The render thread wakes the UI when it's done.
std::thread::sleep(Duration::from_millis(2));
}
quiet = if delay >= IDLE && !rendering { quiet + 1 } else { 0 };
if quiet >= 3 {
return delay;
}
assert!(start.elapsed() < Duration::from_secs(20), "the UI never settled");
}
panic!("still asking for frames after 2000 with no input: {:?}", self.ctx.repaint_causes());
}
fn run(&mut self, cmd: &str, params: Value) -> Value {
let r = self.app.session.execute(cmd, &params).unwrap();
self.app.handle_ui_requests();
r
}
/// Settles, then stays idle: more frames with no input ask for nothing sooner.
fn assert_idle(&mut self, what: &str) {
self.settle();
for _ in 0..10 {
let delay = self.frame(vec![]);
assert!(delay >= IDLE, "{what}: asked for a repaint in {delay:?}: {:?}", self.ctx.repaint_causes());
}
}
}
/// A page with a rectangle, an ellipse and a text layer; returns the text's id.
fn document() -> (Harness, Value) {
let mut s = Session::new();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 200, "height": 120})).unwrap();
s.execute("shape.ellipse", &json!({"x": 300, "y": 40, "width": 120, "height": 120})).unwrap();
let text = s.execute("text.create", &json!({"x": 40, "y": 220, "text": "Idle"})).unwrap()["id"].clone();
s.execute("select.set", &json!({"ids": []})).unwrap();
(Harness::new(s), text)
}
#[test]
fn an_open_document_draws_no_frames_while_idle() {
let (mut h, _) = document();
h.assert_idle("a document");
// The next deadline is the autosave checkpoint, minutes away.
assert!(h.frame(vec![]) > Duration::from_secs(60));
}
#[test]
fn a_selected_text_layer_draws_no_frames_while_idle() {
let (mut h, text) = document();
h.run("select.set", json!({"ids": [text]}));
h.assert_idle("a selected text layer");
}
#[test]
fn the_comments_panel_draws_no_frames_while_idle() {
let (mut h, _) = document();
h.run("comment.add", json!({"x": 50, "y": 50, "text": "Looks good", "author": "Ada", "anchor": true}));
h.run("tool.comment", json!({}));
h.assert_idle("the comments panel");
}
#[test]
fn the_prototype_curve_preview_plays_then_rests() {
let mut s = Session::new();
let a = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 300})).unwrap());
let b = id(&s.execute("frame.create", &json!({"x": 300, "y": 0, "width": 200, "height": 300})).unwrap());
s.execute("proto.interaction.add", &json!({"id": a, "dest": b, "transition": {"type": "dissolve"}, "durationMs": 300})).unwrap();
s.execute("select.set", &json!({"ids": [a]})).unwrap();
let mut h = Harness::new(s);
h.app.ui.right_tab = crate::RightTab::Prototype;
crate::proto_panel::open_editor(&h.ctx, Some((a, 0)));
// It plays when it shows (the dot moves: frames at animation pace) …
h.frame(vec![]);
h.frame(vec![]);
assert!(h.frame(vec![]) < Duration::from_millis(50), "the curve preview plays when it opens");
// … then rests …
h.assert_idle("the interaction editor");
// … and plays again when it shows again.
crate::proto_panel::open_editor(&h.ctx, None);
h.frame(vec![]);
crate::proto_panel::open_editor(&h.ctx, Some((a, 0)));
h.frame(vec![]);
h.frame(vec![]);
assert!(h.frame(vec![]) < Duration::from_millis(50), "the curve preview plays when it shows again");
h.assert_idle("the interaction editor, again");
}
#[test]
fn presenting_a_still_frame_draws_no_frames_while_idle() {
let mut s = Session::new();
let a = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 300})).unwrap());
let mut h = Harness::new(s);
h.run("proto.present", json!({"start": a}));
assert!(h.app.ui.present.is_some());
h.assert_idle("the presenter");
}
#[test]
fn an_after_delay_wakes_the_presenter_when_due_even_under_an_overlay() {
let mut s = Session::new();
let a = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 300})).unwrap());
let b = id(&s.execute("frame.create", &json!({"x": 300, "y": 0, "width": 200, "height": 300})).unwrap());
let o = id(&s.execute("frame.create", &json!({"x": 600, "y": 0, "width": 100, "height": 100})).unwrap());
let delay = |ms: u64, action: Value| json!({"id": a, "trigger": {"type": "afterDelay", "ms": ms}, "action": action});
s.execute("proto.interaction.add", &delay(2000, json!({"type": "navigate", "dest": b}))).unwrap();
s.execute("proto.interaction.add", &delay(100, json!({"type": "openOverlay", "dest": o}))).unwrap();
let mut h = Harness::new(s);
h.run("proto.present", json!({"start": a}));
h.settle();
let presenter = || h.app.ui.present.as_ref().map(crate::present::Presenter::inspect).unwrap();
assert_eq!(presenter()["overlays"], json!([o]), "the overlay opened after 100 ms");
// The 2 s timer belongs to the frame under the overlay, which can't fire it while the overlay
// is open: once it's past due the presenter doesn't spin waiting for it.
h.time += 3.0;
h.assert_idle("an overlay over a frame with a pending delay");
let presenter = h.app.ui.present.as_ref().map(crate::present::Presenter::inspect).unwrap();
assert_eq!(presenter["current"], json!(a), "{presenter}");
}
#[test]
fn a_toast_wakes_the_ui_only_to_go_away() {
let (mut h, _) = document();
h.settle();
h.app.toast("Saved");
// Once it has faded in, the next frame it asks for is the one that hides it.
let delay = h.settle();
assert!(delay > Duration::from_secs(2) && delay <= Duration::from_secs(3), "{delay:?}");
assert!(h.app.ui.toast.is_some());
h.time += 3.0;
h.frame(vec![]);
assert!(h.app.ui.toast.is_none(), "gone after 3 s");
}
#[test]
fn a_finished_background_render_wakes_the_ui() {
let ctx = egui::Context::default();
let wakes = Arc::new(AtomicUsize::new(0));
let w = wakes.clone();
ctx.set_request_repaint_callback(move |_| {
w.fetch_add(1, Ordering::SeqCst);
});
let mut s = Session::new();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap();
let ds = s.ds().unwrap();
let mut worker = crate::render_worker::Worker::new(ctx.clone());
worker.submit(crate::render_worker::Job {
doc: ds.doc.clone(),
page: ds.page,
view: figcraft_render::View { origin: (0.0, 0.0), scale: 1.0, width: 120, height: 120 },
outline: false,
skip: vec![],
solo: None,
});
let start = Instant::now();
while wakes.load(Ordering::SeqCst) == 0 {
assert!(start.elapsed() < Duration::from_secs(20), "the render thread never woke the UI");
std::thread::sleep(Duration::from_millis(2));
}
assert!(worker.poll().is_some(), "the woken frame finds the image");
}
#[test]
fn control_requests_and_screenshots_wake_an_idle_ui() {
let (mut h, _) = document();
h.settle();
// A request is answered in the next frame, which asks for one more to show its effect.
let (tx, rx) = std::sync::mpsc::channel();
h.app.control_queue.push(ControlRequest { id: json!(1), method: "ui.inspect".into(), params: json!({}), reply: tx.clone() });
assert_eq!(h.frame(vec![]), Duration::ZERO);
assert!(rx.try_recv().unwrap().result.is_ok());
h.assert_idle("after a control request");
// A screenshot keeps frames coming until the window delivers it, then the UI rests again.
let path = std::env::temp_dir().join(format!("figcraft-idle-shot-{}.png", std::process::id()));
let params = json!({"path": path.to_string_lossy()});
h.app.control_queue.push(ControlRequest { id: json!(2), method: "ui.screenshot".into(), params, reply: tx });
for _ in 0..5 {
assert_eq!(h.frame(vec![]), Duration::ZERO, "waiting for the screenshot");
}
assert!(rx.try_recv().is_err(), "not delivered yet");
let image = Arc::new(egui::ColorImage::filled([4, 4], egui::Color32::RED));
h.frame(vec![Event::Screenshot { viewport_id: ViewportId::ROOT, user_data: egui::UserData::default(), image }]);
let reply = rx.try_recv().unwrap();
assert_eq!(reply.result.unwrap()["width"], json!(4));
let _ = std::fs::remove_file(&path);
h.assert_idle("after a screenshot");
}

View File

@ -21,6 +21,7 @@ pub mod export_panel;
pub mod font_preview;
pub mod frame_labels;
pub mod frame_presets;
pub mod frame_stats;
pub mod guides;
pub mod handles;
pub mod home;
@ -62,6 +63,8 @@ mod dev_mode_tests;
#[cfg(test)]
mod frame_label_tests;
#[cfg(test)]
mod idle_tests;
#[cfg(test)]
mod layers_drag_tests;
#[cfg(test)]
mod layers_keys_tests;
@ -162,6 +165,18 @@ pub enum TitleBar {
/// How often open documents get an autosave checkpoint (seconds).
pub const AUTOSAVE_SECS: f64 = 30.0 * 60.0;
/// How long a toast shows (seconds).
const TOAST_SECS: f64 = 3.0;
/// Draw another frame `secs` from now (nothing else needs drawing until then: an idle window draws
/// no frames).
#[track_caller]
pub(crate) fn repaint_in(ctx: &egui::Context, secs: f64) {
if secs.is_finite() {
ctx.request_repaint_after(std::time::Duration::from_secs_f64(secs.max(0.0)));
}
}
/// UI-only state (serialisable for `ui.inspect`).
pub struct UiState {
pub left_width: f32,
@ -192,7 +207,8 @@ pub struct UiState {
pub title_bar: TitleBar,
/// Every icon on one screen, for reviewing the set (control channel `ui.set {iconGallery}`).
pub icon_gallery: bool,
pub toast: Option<(String, f64)>,
/// A message at the bottom of the window and when it first showed (UI clock, seconds).
pub toast: Option<(String, Option<f64>)>,
pub present: Option<present::Presenter>,
/// The full-window presenter's options (how it sizes the prototype), kept between
/// presentations.
@ -339,6 +355,8 @@ pub struct FigApp {
pub control_queue: Vec<ControlRequest>,
/// Input events from the control channel (`ui.input`) for the next frame's raw input.
pub control_input: Vec<egui::Event>,
/// Frames drawn and what asked for them (`ui.inspect`).
pub frame_stats: frame_stats::FrameStats,
}
impl FigApp {
@ -354,6 +372,7 @@ impl FigApp {
field_focus: false,
control_queue: Vec::new(),
control_input: Vec::new(),
frame_stats: frame_stats::FrameStats::default(),
}
}
@ -411,7 +430,7 @@ impl FigApp {
}
pub fn toast(&mut self, msg: impl Into<String>) {
self.ui.toast = Some((msg.into(), 0.0));
self.ui.toast = Some((msg.into(), None));
}
/// Act on UI requests queued by commands (dialogs, panels).
@ -578,8 +597,11 @@ impl FigApp {
/// Every [`AUTOSAVE_SECS`], each open document with unsaved changes since its last version
/// gets an autosave checkpoint in its version history.
fn autosave_tick(&mut self, now: f64) {
fn autosave_tick(&mut self, ctx: &egui::Context) {
let now = ctx.input(|i| i.time);
if now < self.ui.next_autosave {
// Wake up for it even if nothing else draws a frame by then.
repaint_in(ctx, self.ui.next_autosave - now);
return;
}
if self.ui.next_autosave > 0.0 {
@ -591,6 +613,7 @@ impl FigApp {
self.session.active = active;
}
self.ui.next_autosave = now + AUTOSAVE_SECS;
repaint_in(ctx, AUTOSAVE_SECS);
}
/// Draw the whole UI.
@ -598,9 +621,10 @@ impl FigApp {
#[cfg(not(target_arch = "wasm32"))]
let t0 = std::time::Instant::now();
let ctx = ui.ctx().clone();
self.frame_stats.record(&ctx);
let first = !self.fonts_installed;
self.logic(&ctx);
self.autosave_tick(ctx.input(|i| i.time));
self.autosave_tick(&ctx);
if first {
// New fonts apply from the next frame; draw nothing until then.
ctx.request_repaint();
@ -716,15 +740,16 @@ impl FigApp {
}
fn toast_ui(&mut self, ctx: &egui::Context) {
let Some((msg, age)) = self.ui.toast.clone() else { return };
let dt = ctx.input(|i| i.stable_dt) as f64;
let age = age + dt;
if age > 3.0 {
let Some((msg, shown)) = &mut self.ui.toast else { return };
let now = ctx.input(|i| i.time);
let left = TOAST_SECS - (now - *shown.get_or_insert(now));
if left <= 0.0 {
self.ui.toast = None;
return;
}
self.ui.toast = Some((msg.clone(), age));
ctx.request_repaint();
let msg = msg.clone();
// Nothing moves while it shows: the next frame it needs is the one that hides it.
repaint_in(ctx, left);
let screen = ctx.content_rect();
egui::Area::new(egui::Id::new("toast"))
.fixed_pos(egui::pos2(screen.center().x, screen.max.y - 96.0))
@ -776,6 +801,8 @@ impl FigApp {
},
"frameMs": self.ui.frame_ms,
"renderMs": self.ui.render_ms,
"framesDrawn": self.frame_stats.frames,
"repaintCauses": self.frame_stats.causes(),
"canvas": {
"rect": [self.canvas.rect.min.x, self.canvas.rect.min.y, self.canvas.rect.max.x, self.canvas.rect.max.y],
"renderWork": self.canvas.render_work.map(|w| match w {

View File

@ -1329,6 +1329,37 @@ struct Layout {
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 }
@ -1811,31 +1842,19 @@ fn player(p: &mut Presenter, ui: &mut Ui, rect: Rect, now: f64, chrome: Chrome)
if let Some((d, _)) = body {
device_glass(&painter, d.name, l.screen, l.landscape);
}
// Timers: wake up for the soonest pending "after delay".
let mut next: Option<f64> = None;
for id in l.disp.subtree(p.current) {
let Some(n) = l.disp.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;
next = Some(next.map_or(due, |x: f64| x.min(due)));
}
}
}
if let Some(d) = next {
ctx.request_repaint_after(std::time::Duration::from_secs_f64(d.clamp(0.0, 60.0)));
// 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) = match l.overlays.last() {
Some((id, pl, _)) => (*id, *pl, l.screen),
None => (p.current, l.place, l.clip),
};
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)
{
@ -2069,10 +2088,7 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, ke
p.fling = Some(Fling { target: t, vel: (along(velocity.x, r.0), along(velocity.y, r.1)), decay: DECAY_DRAG, bounce: true, at: now });
}
// The active layer: the top overlay, else the frame.
let (root, place, clip) = match l.overlays.last() {
Some((id, pl, _)) => (*id, *pl, l.screen),
None => (p.current, l.place, l.clip),
};
let (root, place, clip) = l.active(p.current);
let doc = &l.disp;
let hot = hotspots(doc, root, &place, clip);
p.hot.clone_from(&hot);
@ -2294,27 +2310,12 @@ fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, ke
}
}
// After delay (timers start when the frame appears; not while animating).
if p.anim.is_none() {
let mut due: Option<(NodeId, usize)> = 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))
&& now - p.arrived >= f64::from(ms) / 1000.0
{
due = Some((id, k));
break;
}
}
if due.is_some() {
break;
}
}
if let Some((id, k)) = due {
p.delays_fired.insert((id, k));
p.fire(id, k, None, now);
}
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);
}
}
@ -2444,10 +2445,7 @@ fn draw(p: &mut Presenter, painter: &egui::Painter, ctx: &egui::Context, l: &Lay
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) = match l.overlays.last() {
Some((id, pl, _)) => (*id, *pl, l.screen),
None => (p.current, l.place, l.clip),
};
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));

View File

@ -953,15 +953,22 @@ fn animation_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &In
}
});
custom_easing_fields(app, ui, node, k, it.easing);
// Curve preview, with a dot running along it.
// Curve preview, with a dot running along it: it plays when the preview shows, when the
// easing or duration changes and while the pointer is over it; otherwise the dot rests at
// the end (an idle window draws no frames).
let w = ui.available_width();
let (r, _) = ui.allocate_exact_size(vec2(w, 72.0), Sense::hover());
let (r, resp) = ui.allocate_exact_size(vec2(w, 72.0), Sense::hover());
let box_r = Rect::from_min_max(pos2(r.min.x + PAD, r.min.y + 4.0), pos2(r.max.x - PAD, r.max.y - 4.0));
ui.painter().rect_filled(box_r, CornerRadius::same(5), t.field);
let period = (f64::from(it.duration_ms) / 1000.0).max(0.05) + 0.6;
let phase = ((ui.input(|i| i.time) % period) / (period - 0.6)).min(1.0);
let period = (f64::from(it.duration_ms) / 1000.0).max(0.05) + CURVE_HOLD;
let elapsed = curve_play(ui, Id::new(("proto_curve", node, k)), Id::new((format!("{:?}", it.easing), it.duration_ms)), resp.hovered());
let phase = if resp.hovered() || elapsed < period * CURVE_PLAYS {
ui.ctx().request_repaint_after(std::time::Duration::from_millis(33));
((elapsed % period) / (period - CURVE_HOLD)).min(1.0)
} else {
1.0
};
curve(ui.painter(), box_r.shrink2(vec2(10.0, 12.0)), it.easing, ACCENT, Some(phase));
ui.ctx().request_repaint_after(std::time::Duration::from_millis(33));
}
}
@ -1021,6 +1028,24 @@ fn custom_easing_fields(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, e
}
/// Draw an easing curve in `r` (x = time, y = progress, up). `dot` marks a point in time.
/// The curve preview's dot rests at the end this long each time round (seconds).
const CURVE_HOLD: f64 = 0.6;
/// How many times the curve preview plays when it shows or changes.
const CURVE_PLAYS: f64 = 2.0;
/// Seconds since the curve preview `id` started playing: it starts over when it shows (it wasn't
/// drawn the pass before), when what it shows (`key`) changes and when the pointer comes over it.
fn curve_play(ui: &Ui, id: Id, key: Id, hovered: bool) -> f64 {
let now = ui.input(|i| i.time);
let pass = ui.ctx().cumulative_pass_nr();
let since = match ui.data(|d| d.get_temp::<(Id, f64, bool, u64)>(id)) {
Some((k, since, was_hovered, drawn)) if k == key && (was_hovered || !hovered) && drawn + 1 >= pass => since,
_ => now,
};
ui.data_mut(|d| d.insert_temp(id, (key, since, hovered, pass)));
now - since
}
pub fn curve(p: &egui::Painter, r: Rect, e: Easing, col: Color32, dot: Option<f64>) {
let n = 48;
let at = |x: f64| {

View File

@ -65,7 +65,10 @@ that `node.set` accepts are listed in [mcp.md](mcp.md#conventions-agents-must-kn
UI state: panels and tabs, current tool (`tool`: its id, overlay hints and, for a selected
ellipse, its arc handles `arcHandles`; see [mcp.md](mcp.md#measuring-the-scale-tool-arc-handles-and-selecting)), canvas rect, dialogs, view, timings (`frameMs`,
`renderMs`) and what the last canvas render had to do (`canvas.renderWork`: `full`, `partial` with
`renderMs`), frames drawn since start or the last `ui.set {resetFrameStats: true}` (`framesDrawn`;
an idle window draws none) with the most frequent repaint requests behind them (`repaintCauses`:
`[{at: "file:line", count}]`, every request made in the frame before each one, delayed ones such as
the autosave deadline included; egui's `context.rs` begin-pass line means input events) and what the last canvas render had to do (`canvas.renderWork`: `full`, `partial` with
the `pixels` re-rendered after a pan or an edit, or `reused`). While a prototype plays, `presenter`
(full window) and `preview` (the preview pane) hold the player's state: the `current` frame and its
`name`, `start`, `history`, open `overlays`, the screen's `scroll` offset and `scrollRange`, the
@ -139,7 +142,7 @@ separate calls. Returns `{queued}`.
Invoke a menu item exactly like clicking it (this may open a dialog). Returns `{ok, lastError}`.
### `ui.set {rightTab?, leftTab?, theme?, home?, palette?, mainMenu?, focusRegion?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?, colorProfileDialog?, stylePicker?, stylePickerEdit?, stylePickerView?, iconGallery?, protoEditor?, contextMenu?, contextSelectLayer?, commentOpen?, commentDraft?, commentFilter?, commentSort?, commentSearch?, commentReply?, commentEdit?, presentComments?, devCompare?, devCompareMode?, devCompareSplit?, annotationFilter?}`
### `ui.set {rightTab?, leftTab?, theme?, home?, palette?, mainMenu?, focusRegion?, colorPicker?, variables?, layerFilter?, collapseLayers?, exportPreview?, preview?, presentScroll?, colorProfileDialog?, stylePicker?, stylePickerEdit?, stylePickerView?, iconGallery?, protoEditor?, contextMenu?, contextSelectLayer?, commentOpen?, commentDraft?, commentFilter?, commentSort?, commentSearch?, commentReply?, commentEdit?, presentComments?, devCompare?, devCompareMode?, devCompareSplit?, annotationFilter?, resetFrameStats?}`
Switch panels and modes: `rightTab` `design|prototype|inspect`, `leftTab` `file|assets`, `theme`,
`home` (bool), `palette` (bool: the Actions palette), `mainMenu` (bool), `focusRegion`
@ -171,14 +174,16 @@ the presenter shows the presented frame's comments, also Shift+C there). Dev Mod
ready-for-dev frame or section id opens the Compare changes view, like `dev.compare {id, show:
true}`; `null` closes it) with `devCompareMode` (`sideBySide|overlay`) and `devCompareSplit` (the
overlay slider, 0–1), `annotationFilter` (`all`, `none` for uncategorised, or a category id or name:
the annotations the canvas shows). Returns `ui.inspect` (which reports `stylePicker`,
the annotations the canvas shows). `resetFrameStats` (true: start `framesDrawn` and
`repaintCauses` over). Returns `ui.inspect` (which reports `stylePicker`,
`stylePickerView`, `comments`: `open`, `draft`, `filter`, `sort`, `search`, `editing` and the
`mentions` the picker offers, `devCompare` (`{id, mode, split}` or `null`), `devFocus` (the frame the
focus view shows, or `null`) and `annotationFilter`).
### `ui.screenshot {path}`
PNG of the whole window, UI included (needs a visible window). Returns `{path, width, height}`.
PNG of the whole window, UI included (needs a visible window; an idle one draws the frame to
capture). Returns `{path, width, height}`, or an error after 60 s when the window doesn't deliver.
### `ui.render {path, scale?, ids?}`