Files
figcraft/crates/ui-egui/src/app.rs
T
Filip StrajnarandClaude Opus 5.5 853e8c6cc4 M8: a minimised window under Wayland answers control requests (#1906)
Under Wayland an app can't tell that its window is minimised (no `minimized`, no `occluded`),
and the compositor stops asking it to draw. eframe keeps running the app's logic for a window
whose redraw is overdue, but `control::background` still left every request for "the window's
frame", which never came: with the window minimised on GNOME or KDE Plasma each control-channel
or attached-MCP request waited and timed out after 60 s ("it was not applied").

`control::background` now treats a window that drew no frame for `NOT_DRAWING_AFTER` (0.2 s)
while a request waited for it as hidden: requests that act on the app's state are answered from
the logic passes, as they are for a window the platform reports minimised. `FigApp::control_waits`
keeps, per window, its pass count and the time the wait began; a frame drawn since starts the
wait again, and a pass is asked for 0.2 s later while a request waits. Screenshots and `ui.input`
still wait for a drawn frame. Measured on GNOME Wayland with the window minimised by its own
button: 0.54 s a request (before: 13.7 s for one, then 60 s timeouts).

Test: `idle_tests::a_window_that_stopped_drawing_answers_from_its_logic_passes`. Docs:
control-protocol.md, mcp.md, hardware-parity.md, target-app-parity.md, ROADMAP.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LvG6E2LxvJ6MuFmiLA6Zqm
2026-11-06 12:27:59 +01:00

419 lines
23 KiB
Rust

use super::clock::{AUTO_SAVE_RETRY_SECS, AUTO_SAVE_SECS, AUTOSAVE_SECS, CJK_CHANGES, FrameClock, TOAST_SECS, ToastClock, repaint_in};
use super::open::OPEN_EXTENSIONS;
use super::services::{DialogFuture, PickFuture, Picked, RepaintWaker, Services};
use super::state::{LEFT_PANEL, LeftTab, MOBILE_STRIP, MobilePanel, NARROW_WIDTH, PANEL_DEFAULT, RIGHT_PANEL, RightTab, TitleBar, fit_panels};
use super::{
Arc, Done, FigApp, NodeId, OsStr, Path, PathBuf, PendingAck, PendingDialog, Poll, Session, TaskContext, UiState, Value, Waker, a11y, branch_ui,
canvas, cjk, comments, cursors, dev_compare, dialogs, frame_stats, home, home_recents, i18n, icons, import_report, json, layers, live, menus,
missing_fonts, online, plugins_ui, present, render_worker, right, selection_spacing, shortcuts, style_picker, tabs, theme, toolbar, ui_scale,
updates_ui, variable_ui, versions, widgets, windows,
};
mod clipboard;
mod commands;
mod control;
mod files;
mod frame;
mod ime;
impl FigApp {
/// Construct `FigApp` from a session and services, taking panel widths and pages height
/// from preferences and initializing UI, canvas, clocks, capture/IME and transient channel
/// state.
pub fn new(session: Session, services: Box<dyn Services>) -> Self {
// The main window's panels open as wide as they were left.
let (left_width, right_width) = session.prefs.panel_widths;
let pages_height = session.prefs.pages_height;
Self {
ui: UiState { left_width, right_width, pages_height, ..UiState::default() },
session,
canvas: canvas::CanvasState::default(),
services,
worker: None,
fonts_installed: false,
script_fonts: None,
cjk_checked: None,
language: None,
field_focus: false,
panels_unsaved: false,
control_queue: Vec::new(),
control_waits: Default::default(),
control_input: Vec::new(),
frame_stats: frame_stats::FrameStats::default(),
windows: windows::Windows::default(),
window_requests: Vec::new(),
waiting: Vec::new(),
pending_dialog: None,
ui_context: None,
automation_roots: None,
control_input_doc: None,
control_synthetic: false,
input_acks: Vec::new(),
applied_acks: Vec::new(),
applied_input: None,
control_input_queued: false,
applied_frame: None,
clock: FrameClock::default(),
capture: crate::control::CaptureState::default(),
logic_ticked: false,
ui_frame_stamp: None,
ime: egui::ime_context::ImeContextTracker::new(),
updates_rt: updates_ui::Runtime::default(),
#[cfg(test)]
ime_observations: 0,
}
}
/// UI-phase setup that needs a real current `Ui`: fonts/language/theme, interface zoom and the
/// render worker. Runs once per frame (pass 0) for the main window, and once per frame for
/// every child window (`windows::draw`), never on a logic-only tick.
fn ui_setup(&mut self, ctx: &egui::Context) {
self.viewport_setup(ctx);
}
/// The app-global part of [`Self::ui_setup`] that every window's frame needs: language, fonts,
/// theme and `ui.dark`, the render worker, and the interface zoom plan. eframe only calls
/// `App::ui` for the root window, so a child must run this itself before it draws; a child that
/// only runs while the root is minimised still gets theme switches, CJK faces for its own
/// document, and the right `ui.dark` for its tabs and dialogs.
pub(crate) fn viewport_setup(&mut self, ctx: &egui::Context) {
if self.language.as_ref() != Some(&self.session.prefs.language) {
i18n::set(&self.session.prefs.language, self.services.system_language().as_deref());
self.language = Some(self.session.prefs.language.clone());
}
// Screen-reader and bidi language of the UI (fork E17).
ctx.set_language(i18n::current().map(|l| l.code.to_string()));
if !self.fonts_installed {
// Cmd/Ctrl+= / - / 0 zoom the canvas (view.zoomIn, view.zoomOut, view.zoom100): egui's
// own keyboard zoom would scale the whole interface on the same keys.
ctx.options_mut(|o| o.zoom_with_keyboard = false);
// A double-click's window is the platform's (0.5 s, Windows' default), not egui's
// stricter 0.3 s: a double-click the OS takes must enter a shape's points too.
ctx.memory_mut(|m| m.options.input_options.max_double_click_delay = 0.5);
theme::install_fonts(ctx, false, 0);
self.fonts_installed = true;
// Installed fonts apply from the next frame; a child window draws on here, so ask for
// that frame (the root draws nothing on the frame it installs them).
ctx.request_repaint();
} else if figcraft_text::system_fonts_scanned() {
// Once the system fonts are indexed: faces for names in scripts the UI fonts lack, and
// for each CJK script once something shows it.
let installed = self.script_fonts.map_or(0, |(s, _)| s);
let cjk = installed | self.cjk_needed(ctx, installed);
// The UI language's script leads the CJK faces, so another language lays them out again.
let lang = i18n::current().map_or("en", |l| l.code);
if self.script_fonts != Some((cjk, lang)) {
self.script_fonts = Some((cjk, lang));
theme::install_fonts(ctx, true, cjk);
}
}
// egui's zoom is for the whole app and multiplies each window's own system scale, so the
// size is the same relative to every screen; the main window, whose size can hold a size
// back, sets it. The browser keeps its own zoom. Every window records its own plan (the
// Preferences warning and `ui.inspect` read it), from its own size.
{
let window_px = ctx.content_rect().size() * ctx.pixels_per_point();
let size = if cfg!(target_arch = "wasm32") { 1.0 } else { self.session.prefs.ui_scale };
let plan = ui_scale::plan(size, ctx.native_pixels_per_point(), Some((window_px.x, window_px.y)));
if ctx.viewport_id() == egui::ViewportId::ROOT {
ctx.set_zoom_factor(plan.zoom);
}
// A size that just changed (a key, Preferences, a command) draws at the new size from
// the next frame: ask for it rather than wait for the next input.
if plan != self.ui.ui_scale {
ctx.request_repaint();
}
self.ui.ui_scale = plan;
}
let dark = match self.session.prefs.theme.as_str() {
"dark" => true,
// The active visuals were set by us last frame (possibly for a manual theme). Read the
// integration's OS theme; winit reports none on Linux and the BSDs, where the platform
// services' colour scheme answers instead. Without either, keep the light fallback.
"system" => ctx.system_theme().map(|t| t == egui::Theme::Dark).or_else(|| self.services.system_dark()).unwrap_or(false),
_ => false,
};
self.ui.dark = dark;
// New files take the theme's page colour (existing pages keep theirs).
self.session.set_ui_dark(dark);
theme::apply_with(ctx, if dark { theme::Tokens::dark() } else { theme::Tokens::light() }, self.reduce_motion());
if self.worker.is_none() {
self.worker = Some(render_worker::Worker::new(ctx.clone()));
}
}
/// Draw the whole UI: the main window (the other windows draw themselves).
pub fn ui(&mut self, ui: &mut egui::Ui) {
#[cfg(not(target_arch = "wasm32"))]
let t0 = std::time::Instant::now();
let ctx = ui.ctx().clone();
self.ui_context = Some(ctx.clone());
// `App::logic` already ticked this pass; direct harnesses do not call it. The marker only
// covers the tick that shares this pass: clearing it here keeps a direct `App::ui` harness
// ticking on every frame (it used to tick every other frame).
if !std::mem::take(&mut self.logic_ticked) {
self.background_tick(&ctx);
self.logic_ticked = false;
}
// Setup once per frame (pass 0), not once per pass and never on a logic-only tick.
let stamp = (ctx.viewport_id(), ctx.cumulative_frame_nr());
let first = !self.fonts_installed;
if ctx.current_pass_index() == 0 && self.ui_frame_stamp != Some(stamp) {
self.ui_frame_stamp = Some(stamp);
self.frame_stats.record(&ctx);
self.ui_setup(&ctx);
}
if first && self.fonts_installed {
// New fonts apply from the next frame; draw nothing until then. Anything taken into
// this frame's (undrawn) input waits for the first drawn frame.
ctx.request_repaint();
if let Some(events) = self.applied_input.take() {
self.push_control_input(events);
}
self.input_acks.append(&mut self.applied_acks);
self.applied_frame = None;
self.control_synthetic = false;
// The next (drawn) frame ticks for itself in a direct harness.
self.logic_ticked = false;
self.observe_ime(&ctx, false);
return;
}
windows::begin(self, &ctx);
let (files, prefs) = (self.session.docs.len(), self.session.prefs.clone());
self.frame(ui);
windows::settle(self, &ctx, files);
if self.session.prefs != prefs {
// This window too: a preference set during the frame (the interface size, the theme)
// is applied as the next frame begins, so ask for it now rather than wait for input.
ctx.request_repaint();
windows::repaint_others(self, &ctx);
}
windows::show(self, &ctx);
// The injected batch was applied by the frame that took it. A discarded sizing pass keeps
// its acknowledgement for the presented pass but never replays the input (its side effects
// already happened and would otherwise duplicate).
self.end_input_frame(&ctx);
// Plugins installed, removed or set up are kept at once: a crash doesn't lose them.
// So are the owner tokens of files put online, and preferences a command keeps at once.
if std::mem::take(&mut self.session.plugins.unsaved)
| std::mem::take(&mut self.session.live.unsaved)
| std::mem::take(&mut self.session.prefs_unsaved)
{
self.services.save_prefs(&self.session.prefs);
}
#[cfg(not(target_arch = "wasm32"))]
{
self.ui.frame_ms = t0.elapsed().as_secs_f64() * 1000.0;
self.frame_stats.timed(self.ui.frame_ms);
}
}
/// One frame of the window drawing now.
pub(crate) fn frame(&mut self, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone();
// Bind this input batch before controls or pointer input can replace its composing
// target/caret. Outgoing IME receipts run after mutation and cannot authorize it.
let ime_input = shortcuts::ImeInputBaseline::capture(self);
let t = theme::Tokens::get(&ctx);
// A field that had the keyboard when the frame began keeps its keys from the shortcuts
// even if it lets go during the frame (Enter commits it), or as it began (egui drops the
// focus on Escape before the frame, which cancels the field's edit and nothing else).
let typing = std::mem::take(&mut self.field_focus) || a11y::field_focused(&ctx);
// Bind the originating field/file before tabs can switch. Its widget commits only after
// processing this batch; delivery remains deferred until the following frame.
let field_save = shortcuts::prepare_field_save(self, &ctx);
let paste = shortcuts::paste_events(self, &ctx);
// A dialog that closed acts first, in the window it was opened from.
self.poll_dialog(&ctx);
// Control requests run first so their results reflect this frame.
crate::control::drain(self, &ctx);
windows::requests(self, &ctx);
selection_spacing::sync(self, &ctx);
// Before any widget: the navigation keys (F6, Tab, arrows, Escape) move the keyboard.
a11y::begin(self, &ctx, ui.scope_id());
if self.ui.title_bar == TitleBar::Custom {
tabs::resize_edges(&ctx);
}
if self.ui.icon_gallery {
icons::gallery(&ctx);
}
theme::set_doc_profile(&ctx, self.session.doc().map_or(figcraft_color::ColorProfile::Srgb, |d| d.color_profile));
widgets::set_nudge(&ctx, self.session.prefs.nudge_small, self.session.prefs.nudge_big);
self.sync_dev_mode();
self.end_pen_outside_design();
tabs::relink_presents(self);
tabs::tab_bar(self, ui);
// Control/window work and this pass's physical tab click may have replaced the owner.
// A shared field ID in that replacement must never consume the old commit marker.
let field_save = shortcuts::revalidate_field_save(self, &ctx, field_save);
if self.ui.present.is_some() {
// A close request can arrive while the presenter fills the window. Let its unsaved
// changes dialog take Escape and draw above the player just as it does in the editor.
dialogs::keys(self, &ctx);
present::show(self, ui);
dialogs::show(self, &ctx);
updates_ui::show(self, &ctx);
// What went wrong while presenting (a widget's plugin failing) shows over it.
self.toast_ui(&ctx);
windows::requests(self, &ctx);
shortcuts::clear_field_save_marker(&ctx);
self.observe_ime(&ctx, false);
return;
}
if !self.has_files() || self.ui.home {
home::show(self, ui);
// The shortcuts panel belongs to the canvas; it doesn't hang over the home screen
// (cleared after the home widgets, which may have just asked for it).
self.ui.shortcuts_panel = false;
dialogs::keys(self, &ctx);
dialogs::show(self, &ctx);
updates_ui::show(self, &ctx);
self.toast_ui(&ctx);
windows::requests(self, &ctx);
shortcuts::clear_field_save_marker(&ctx);
self.observe_ime(&ctx, false);
return;
}
let hidden = self.session.ds().map(|d| d.view.ui_hidden).unwrap_or(false);
// Minimized (Shift+Cmd/Ctrl+\, the Minimize button by the file name): "collapses the
// navigation bar, left sidebar, and right sidebar"; "If you select an object while the
// panels are minimized, the left sidebar remains minimized while the right sidebar expands
// [...] Once all objects are deselected, the right panel minimizes again" (Explore the
// navigation bar and left sidebar). The toolbar stays; Hide UI takes everything.
let narrow = !hidden && ui.available_width() < NARROW_WIDTH;
// A narrow window has no sidebars beside the canvas to take: its panels open from their
// buttons as before, and the interface is minimized again once the window is wide.
let minimized = !hidden && !narrow && self.session.ds().is_ok_and(|d| d.view.ui_minimized);
let right_minimized = minimized && self.session.selection().is_empty();
let mobile = self.ui.mobile_panel.filter(|_| narrow);
self.ui.narrow = narrow;
// A narrow window shows the right panel when its button asks for it, a board's too.
let right_open = (self.right_panel_shown(hidden) && !narrow && !right_minimized) || mobile == Some(MobilePanel::Right);
if !hidden && !minimized && (!narrow || mobile == Some(MobilePanel::Left)) {
egui::Panel::left("left_rail")
.exact_size(layers::RAIL_WIDTH)
.resizable(false)
.frame(egui::Frame::NONE.fill(t.panel).stroke(egui::Stroke::new(1.0, t.border)))
.show(ui, |ui| {
a11y::region(&mut self.ui.nav, ui, a11y::Region::Layers, egui::accesskit::Role::Navigation, i18n::tr("Sidebar"));
layers::rail(self, ui)
});
}
// The panels are as wide as asked, less in a window too small for them and the canvas.
let left_open = !hidden && !minimized && if narrow { mobile == Some(MobilePanel::Left) } else { !self.ui.left_collapsed };
let (left_w, right_w) = if narrow {
let w = (ui.available_width() - MOBILE_STRIP).max(0.0);
(if left_open { w } else { 0.0 }, w)
} else {
fit_panels(ui.available_width(), left_open.then_some(self.ui.left_width), right_open.then_some(self.ui.right_width))
};
let rows = ui.available_rect_before_wrap().y_range();
let mut edges: Vec<(&str, f32, f32, f32)> = Vec::new();
if left_open {
if !narrow {
edges.push(("left", ui.available_rect_before_wrap().min.x + left_w, left_w, 1.0));
}
egui::Panel::left("left_panel")
.resizable(false)
.exact_size(left_w)
.frame(egui::Frame::NONE.fill(t.panel).stroke(egui::Stroke::new(1.0, t.border)))
.show(ui, |ui| {
let label = match self.ui.left_tab {
_ if self.ui.versions.open => i18n::tr("Version history"),
LeftTab::File => i18n::tr("Layers"),
LeftTab::Assets => i18n::tr("Assets"),
};
a11y::region(&mut self.ui.nav, ui, a11y::Region::Layers, egui::accesskit::Role::Pane, label);
layers::left_panel(self, ui)
});
}
self.ui.right_panel_rect = None;
if right_open {
if !narrow {
edges.push(("right", ui.available_rect_before_wrap().max.x - right_w, right_w, -1.0));
}
egui::Panel::right("right_panel")
.resizable(false)
.exact_size(right_w)
// No stroke: a frame's stroke is part of its margin, which would take 1 pt off
// each side of the panel (240 pt by default). The edge is drawn in `right_panel`.
.frame(egui::Frame::NONE.fill(t.panel))
.show(ui, |ui| {
let label = match self.ui.right_tab {
RightTab::Design => i18n::tr("Design"),
RightTab::Prototype => i18n::tr("Prototype"),
RightTab::Inspect => i18n::tr("Dev Mode"),
};
a11y::region(&mut self.ui.nav, ui, a11y::Region::Properties, egui::accesskit::Role::Pane, label);
right::right_panel(self, ui)
});
}
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.canvas)).show(ui, |ui| {
let worker = self.worker.take();
let mut worker = worker.unwrap_or_else(|| render_worker::Worker::new(ctx.clone()));
canvas::show(self, ui, &mut worker);
self.worker = Some(worker);
if !hidden && mobile.is_none() {
ui.scope_builder(egui::UiBuilder::new().id_salt("toolbar"), |ui| {
a11y::container(ui, egui::accesskit::Role::Toolbar, i18n::tr("Tools"));
toolbar::show(self, ui);
});
}
});
if narrow {
let x = if mobile == Some(MobilePanel::Left) { ctx.content_rect().max.x - MOBILE_STRIP + 4.0 } else { ctx.content_rect().min.x + 4.0 };
self.mobile_buttons(&ctx, egui::pos2(x, rows.min + 8.0));
} else if minimized {
self.expand_button(&ctx, egui::pos2(ctx.content_rect().min.x + 8.0, rows.min + 8.0));
}
// The panels' edges, over the canvas and the panels (drawn after them: they take the press).
for (name, x, shown, grows) in edges {
if let Some(w) = Self::panel_edge(ui, name, x, rows, shown, grows) {
let (l, r) = if name == "left" { (w, self.ui.right_width) } else { (self.ui.left_width, w) };
self.set_panel_widths(l, r);
self.panels_unsaved = true;
}
}
// Kept once the drag is over (not on every pointer move).
if self.panels_unsaved && !ctx.input(|i| i.pointer.any_down()) {
self.panels_unsaved = false;
self.services.save_prefs(&self.session.prefs);
}
// Before the shortcuts: keys the hovered pane's prototype uses don't reach the editor, and
// the arrows step through comment threads with the Comment tool.
present::show_pane(self, &ctx);
comments::keys(self, &ctx);
dev_compare::keys(self, &ctx);
branch_ui::keys(self, &ctx);
live::dialog_keys(self, &ctx);
dialogs::keys(self, &ctx);
shortcuts::handle_input(self, &ctx, typing, paste, ime_input, field_save);
menus::popups(self, &ctx);
variable_ui::picker(self, &ctx);
style_picker::show(self, &ctx);
self.end_unwanted_text_preview(&ctx);
self.end_pen_outside_design();
dialogs::show(self, &ctx);
updates_ui::show(self, &ctx);
self.toast_ui(&ctx);
// A rename started after the layers panel drew (Cmd/Ctrl+R, a double-click) opens its field
// next frame; while the panel isn't drawn (the interface hidden or minimized, the panel
// closed in a narrow window) the rename waits for it without asking for frames.
if self.ui.rename.is_some_and(|r| r.start) && left_open && self.ui.left_tab == LeftTab::File {
ctx.request_repaint();
}
a11y::end(self, &ctx);
self.field_focus = a11y::field_focused(&ctx);
windows::requests(self, &ctx);
// Your pointer, selection and view to the others in each live session (when they
// changed), from the window with its file.
let windows = &self.windows;
self.session.live_sync_in(|uid| uid.map_or(0, |u| windows.of(u)) == windows.current);
// The canvas editor's IME context is observed exactly once per pass, after every command
// this pass ran (shortcuts, menus, dialogs, window requests and live sync). A focused panel
// field or an open palette owns the platform IME instead; the early `home`/present paths
// above make the same single idle observation.
self.observe_ime(&ctx, !typing && !self.field_focus && self.ui.palette.is_none());
}
}