//! The FigCraft egui frontend. //! //! A thin shell over [`figcraft_engine::Session`]: panels read engine state and act by running //! commands; the canvas forwards pointer events to the engine's tools and draws overlays. Nothing //! below this crate knows about egui, so the frontend can be replaced. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod a11y; pub mod assets_panel; pub mod bidi; pub mod canvas; pub mod colorpicker; pub mod comments; pub mod component_panel; pub mod control; pub mod credits; pub mod dev_compare; pub mod dev_mode; pub mod dialogs; 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; pub mod icons; pub mod layers; pub mod layout_panel; pub mod list_drag; pub mod live; pub mod live_text; pub mod measure_overlay; pub mod menus; pub mod plugins_ui; pub mod present; pub mod proto_canvas; pub mod proto_editor; pub mod proto_panel; pub mod render_worker; pub mod right; pub mod scale_panel; pub mod section_panel; pub mod shortcuts; pub mod shortcuts_panel; pub mod style_picker; pub mod tabs; pub mod theme; pub mod toolbar; pub mod typography; pub mod variable_ui; pub mod variables_panel; pub mod vector_panel; pub mod versions; pub mod widgets; #[cfg(test)] mod a11y_tests; #[cfg(test)] mod canvas_drag_tests; #[cfg(test)] mod comments_tests; #[cfg(test)] 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; #[cfg(test)] mod layers_rename_tests; #[cfg(test)] mod live_join_tests; #[cfg(test)] mod live_server_tests; #[cfg(test)] mod live_tests; #[cfg(test)] mod live_text_tests; #[cfg(test)] mod panel_reorder_tests; #[cfg(test)] mod perf_tests; #[cfg(test)] mod popup_width_tests; #[cfg(test)] mod present_tests; #[cfg(test)] mod present_view_tests; #[cfg(test)] mod proto_editor_tests; #[cfg(test)] mod proto_reorder_tests; #[cfg(test)] mod proto_responsive_tests; #[cfg(test)] mod shortcuts_tests; #[cfg(test)] mod titlebar_tests; #[cfg(test)] mod vector_tail_ui_tests; use std::collections::BTreeSet; use std::path::PathBuf; use figcraft_doc::NodeId; use figcraft_engine::Session; use serde_json::{Value, json}; pub use control::{ControlReply, ControlRequest}; /// Platform services the app crate provides (file dialogs, clipboard, URLs). pub trait Services { fn pick_open(&mut self, _title: &str, _exts: &[&str]) -> Option { None } fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> Option { None } /// A folder to write several files into. fn pick_folder(&mut self, _title: &str) -> Option { None } fn clipboard_set_text(&mut self, _s: &str) {} fn clipboard_get_text(&mut self) -> Option { None } fn clipboard_set_png(&mut self, _w: u32, _h: u32, _rgba: &[u8]) {} /// An image on the system clipboard as (width, height, straight RGBA). fn clipboard_get_image(&mut self) -> Option<(u32, u32, Vec)> { None } fn open_url(&mut self, _url: &str) {} /// The system asks for reduced motion (its accessibility setting). fn reduce_motion(&self) -> bool { false } } /// No-op services (tests, headless screenshots). pub struct NoServices; impl Services for NoServices {} #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] pub enum RightTab { Design, Prototype, Inspect, } #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] pub enum LeftTab { File, Assets, } /// The window's title bar. #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum TitleBar { /// The system draws it (and the browser): the tab strip sits below. #[default] Native, /// A frameless window: the tab strip is the title bar, with our own window buttons and edges. Custom, /// macOS: the system's window buttons inset at the left of the tab strip. Inset, } /// 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, pub right_width: f32, pub right_tab: RightTab, pub left_tab: LeftTab, pub pages_open: bool, pub expanded: BTreeSet, /// The layer being renamed in the layers panel. pub rename: Option, pub rename_page: Option<(NodeId, String)>, pub main_menu: bool, pub palette: Option, /// The Variables editor, when open. pub variables: Option, pub about: bool, /// The tab the About window shows. pub about_tab: dialogs::AboutTab, pub preferences: bool, pub shortcuts_panel: bool, /// The Keyboard shortcuts panel's tab and search text. pub shortcuts_tab: figcraft_engine::keymap::Group, pub shortcuts_query: String, /// An opacity digit just typed and when (a second one soon after makes an exact percentage). pub opacity_digit: Option<(u32, f64)>, pub home: bool, /// How the window's title bar is drawn (the desktop app sets it when it opens the window). pub title_bar: TitleBar, /// Every icon on one screen, for reviewing the set (control channel `ui.set {iconGallery}`). pub icon_gallery: bool, /// A message at the bottom of the window and when it first showed (UI clock, seconds). pub toast: Option<(String, Option)>, pub present: Option, /// The full-window presenter's options (how it sizes the prototype), kept between /// presentations. pub present_view: present::PresentView, /// The prototype preview pane floating over the canvas, when open. pub preview: Option, pub picker: Option, pub context_menu: Option<(egui::Pos2, Vec)>, /// Layers panel search: open, the filter text, the shift-click range anchor and the selection /// the tree last revealed (ancestors expand and the row scrolls into view when it changes). pub layer_search: bool, pub layer_filter: String, pub layer_anchor: Option, /// The layers panel has the keyboard: arrow keys move through its rows instead of nudging /// (set by a click on a row, cleared by a press on the canvas). pub layers_focus: bool, pub layers_seen_sel: Vec, pub dark: bool, pub follow_system: bool, pub tool_flyout: Option<&'static str>, pub last_error: Option, pub screenshot_requests: Vec<(u64, String)>, pub frame_ms: f64, pub render_ms: f64, pub quit: bool, /// The Export dialog is open. pub export_preview: bool, /// The Export dialog's PDF print options (bleed, marks, CMYK). pub pdf_print: figcraft_doc::PrintSettings, /// The Color profile dialog is open, with this profile chosen. pub color_profile: Option, /// The open style picker. pub style_picker: Option, /// The style picker shows colour styles as a grid of swatches. pub style_grid: bool, /// Style folders closed in the style picker. pub style_folds: BTreeSet, /// The live text preview the panels show (`text.preview`'s command and params) and the /// document revision it was shown at: it ends the first frame nothing asks for it. pub text_preview: Option<(String, Value, u64)>, /// A panel asked for the preview this frame. pub text_preview_wanted: bool, /// The Design panel's Export section shows its preview. pub export_thumb: bool, pub zoom_menu: bool, pub find: Option<(String, String)>, pub left_collapsed: bool, pub comments: comments::CommentUi, pub versions: versions::VersionUi, /// Dev Mode: annotations on the canvas and the ready-for-dev list. pub dev: dev_mode::DevUi, /// What FigCraft last put on the system clipboard: when the clipboard still holds it, a paste /// uses our copied layers; anything else there (an image, SVG, text) came from another app. pub last_copy: Option, /// When (UI clock, seconds) open documents next get an autosave checkpoint. pub next_autosave: f64, /// The Assets panel shows the libraries view (publish this file, enabled libraries). pub libraries: bool, /// Keyboard navigation: the region with the keyboard, keyboard mode (F6, Tab). pub nav: a11y::Nav, /// The Manage plugins window is open. pub plugins_window: bool, /// A plugin command's parameters form, when open. pub plugin_form: Option, /// Live sessions: remote cursors as drawn, the Join dialog. pub live: live::LiveUi, } impl Default for UiState { fn default() -> Self { Self { left_width: 240.0, right_width: 240.0, right_tab: RightTab::Design, left_tab: LeftTab::File, pages_open: true, expanded: BTreeSet::new(), rename: None, rename_page: None, main_menu: false, palette: None, variables: None, about: false, about_tab: dialogs::AboutTab::About, preferences: false, shortcuts_panel: false, shortcuts_tab: figcraft_engine::keymap::Group::Essential, shortcuts_query: String::new(), opacity_digit: None, home: false, title_bar: TitleBar::Native, icon_gallery: false, toast: None, present: None, present_view: present::PresentView::default(), preview: None, picker: None, context_menu: None, layer_search: false, layer_filter: String::new(), layer_anchor: None, layers_focus: false, layers_seen_sel: Vec::new(), dark: false, follow_system: false, tool_flyout: None, last_error: None, screenshot_requests: Vec::new(), frame_ms: 0.0, render_ms: 0.0, quit: false, export_preview: false, pdf_print: figcraft_doc::PrintSettings::default(), color_profile: None, style_picker: None, style_grid: false, style_folds: BTreeSet::new(), text_preview: None, text_preview_wanted: false, export_thumb: false, zoom_menu: false, find: None, left_collapsed: false, comments: comments::CommentUi::default(), versions: versions::VersionUi::default(), dev: dev_mode::DevUi::default(), last_copy: None, next_autosave: 0.0, libraries: false, nav: a11y::Nav::default(), plugins_window: false, plugin_form: None, live: live::LiveUi::default(), } } } /// The whole FigCraft UI. pub struct FigApp { pub session: Session, pub ui: UiState, pub canvas: canvas::CanvasState, pub services: Box, worker: Option, fonts_installed: bool, /// System faces for scripts the built-in fonts lack were added (after the font scan). script_fonts: bool, /// A field had the keyboard when the last frame ended. field_focus: bool, /// Control-channel requests waiting for the next frame. pub control_queue: Vec, /// Input events from the control channel (`ui.input`) for the next frame's raw input. pub control_input: Vec, /// Frames drawn and what asked for them (`ui.inspect`). pub frame_stats: frame_stats::FrameStats, } impl FigApp { pub fn new(session: Session, services: Box) -> Self { Self { session, ui: UiState::default(), canvas: canvas::CanvasState::default(), services, worker: None, fonts_installed: false, script_fonts: false, field_focus: false, control_queue: Vec::new(), control_input: Vec::new(), frame_stats: frame_stats::FrameStats::default(), } } /// Run a command, surfacing errors as a toast. Returns the result. pub fn run(&mut self, id: &str, params: Value) -> Option { match self.session.execute(id, ¶ms) { Ok(v) => { self.handle_ui_requests(); Some(v) } Err(e) => { self.handle_ui_requests(); let msg = e.to_string(); log::warn!("{id}: {msg}"); self.toast(msg.clone()); self.ui.last_error = Some(msg); None } } } /// Show what `command params` would do on the canvas as a live preview (`text.preview`) while /// a text style, a named instance or an axis slider is hovered or dragged. Call it every frame /// the preview should stay: it ends on the first frame nothing asks for it. pub fn preview_text(&mut self, ctx: &egui::Context, command: &str, params: Value) { self.ui.text_preview_wanted = true; let revision = self.session.ds().map_or(0, |d| d.revision); if let Some((c, p, rev)) = &self.ui.text_preview && c == command && *p == params && (*rev == revision || figcraft_engine::textpreview::active(&self.session)) { return; } let shown = self.session.execute("text.preview", &json!({"command": command, "params": params})).is_ok(); let revision = self.session.ds().map_or(0, |d| d.revision); self.ui.text_preview = shown.then(|| (command.to_string(), params, revision)); // The canvas drew before the panels: show the preview next frame. ctx.request_repaint(); } /// End the live text preview the panels show (Escape, or nothing hovered any more). pub fn end_text_preview(&mut self, ctx: &egui::Context) { if self.ui.text_preview.take().is_some() { let _ = self.session.execute("text.endPreview", &json!({})); ctx.request_repaint(); } } /// Once a frame, after the panels: a preview nothing asked for this frame ends. fn end_unwanted_text_preview(&mut self, ctx: &egui::Context) { if !std::mem::take(&mut self.ui.text_preview_wanted) { self.end_text_preview(ctx); } } pub fn toast(&mut self, msg: impl Into) { self.ui.toast = Some((msg.into(), None)); } /// Act on UI requests queued by commands (dialogs, panels). pub fn handle_ui_requests(&mut self) { let reqs = std::mem::take(&mut self.session.ui_requests); for r in reqs { match r.kind.as_str() { "dialog.about" => { self.ui.about = true; self.ui.about_tab = dialogs::AboutTab::About; } "dialog.preferences" => self.ui.preferences = true, "panel.shortcuts" => self.ui.shortcuts_panel = !self.ui.shortcuts_panel, "palette.actions" => self.ui.palette = Some(String::new()), "panel.design" => self.ui.right_tab = RightTab::Design, "panel.prototype" => self.ui.right_tab = RightTab::Prototype, "panel.designPrototype" => { self.ui.right_tab = if self.ui.right_tab == RightTab::Prototype { RightTab::Design } else { RightTab::Prototype }; } "panel.devMode" => { self.ui.right_tab = if self.ui.right_tab == RightTab::Inspect { RightTab::Design } else { RightTab::Inspect }; } "panel.leftSidebar" => self.ui.left_collapsed = !self.ui.left_collapsed, "focus.region" => self.ui.nav.step(r.data.get("step").and_then(Value::as_i64).unwrap_or(1).clamp(-4, 4) as i32), "panel.layers" => self.ui.left_tab = LeftTab::File, "panel.assets" => self.ui.left_tab = LeftTab::Assets, "panel.versionHistory" => versions::open(self), "view.devCompare" => { self.ui.dev.compare = r.data.get("id").and_then(figcraft_engine::params::id_of).map(dev_compare::CompareView::new); } "panel.libraries" => { self.ui.left_tab = LeftTab::Assets; self.ui.left_collapsed = false; self.ui.libraries = true; } "home" => self.ui.home = true, "dialog.saveAs" => self.save_as_dialog(), "dialog.colorProfile" => self.ui.color_profile = self.session.doc().ok().map(|d| d.color_profile), "present" => { if let Some(start) = r.data.get("start").and_then(figcraft_engine::params::id_of) { let mut p = present::Presenter::new(start); p.view = self.ui.present_view; self.ui.present = Some(p); } } "present.options" => { let v = &mut self.ui.present_view; if let Some(s) = r.data.get("scale").and_then(|s| s.as_str()).and_then(present::PresentScale::parse) { v.scale = s; } if let Some(f) = r.data.get("deviceFrame").and_then(|f| f.as_bool()) { v.device_frame = f; } if let Some(h) = r.data.get("hotspotHints").and_then(|h| h.as_bool()) { v.hotspot_hints = h; } if let Some(i) = r.data.get("inspect").and_then(|i| i.as_bool()) { v.inspect = i; } if let Some(p) = self.ui.present.as_mut() { p.view.device_frame = v.device_frame; p.view.hotspot_hints = v.hotspot_hints; p.view.inspect = v.inspect; p.set_scale(v.scale); } } "preview" => { if let Some(start) = r.data.get("start").and_then(figcraft_engine::params::id_of) { self.ui.preview = Some(present::Pane::new(start, &self.session)); } } "preview.close" => self.ui.preview = None, "open.help" => self.services.open_url("https://getartcraft.com/apps/figcraft"), "layers.collapseAll" => self.ui.expanded.clear(), "layers.rename" => { let ids: Vec = r.data.get("ids").and_then(Value::as_array).into_iter().flatten().filter_map(figcraft_engine::params::id_of).collect(); layers::start_rename(self, &ids); } "comment.open" => { if let Some(id) = r.data.get("id").and_then(Value::as_u64) { self.ui.comments.show(id); } } "comment.new" => { let x = r.data.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0); let y = r.data.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0); comments::start_draft(self, x, y); } "dialog.unsaved" => self.toast("This file has unsaved changes"), "dialog.plugins" => self.ui.plugins_window = true, _ => {} } } if let Some(m) = self.session.message.take() { self.toast(m); } } pub fn save_as_dialog(&mut self) { let name = self.session.doc().map(|d| d.name.clone()).unwrap_or_else(|_| "Untitled".into()); if let Some(p) = self.services.pick_save("Save as", &format!("{name}.figcraft"), &["figcraft"]) { let ps = p.to_string_lossy().to_string(); if self.run("file.saveAs", json!({"path": ps})).is_some() { self.toast(format!("Saved {}", p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())); } } } pub fn save(&mut self) { if self.session.ds().map(|d| d.path.is_some()).unwrap_or(false) { if self.run("file.save", json!({})).is_some() { self.toast("Saved"); } } else { self.save_as_dialog(); } } pub fn open_dialog(&mut self) { if let Some(p) = self.services.pick_open("Open", &["figcraft", "json", "svg", "png", "jpg", "jpeg", "webp", "gif"]) { let ps = p.to_string_lossy().to_string(); if self.run("file.open", json!({"path": ps})).is_some() { self.ui.home = false; } } } pub fn import_dialog(&mut self, place: bool) { let exts: &[&str] = if place { &["png", "jpg", "jpeg", "webp", "gif"] } else { &["svg", "png", "jpg", "jpeg", "webp", "gif", "figcraft"] }; if let Some(p) = self.services.pick_open(if place { "Place image" } else { "Import" }, exts) { let ps = p.to_string_lossy().to_string(); self.run("file.import", json!({"path": ps})); } } /// Reduced motion: the preference or the system's setting. pub fn reduce_motion(&self) -> bool { self.session.prefs.reduce_motion || self.services.reduce_motion() } /// Per-frame logic that doesn't draw (control requests, theme). pub fn logic(&mut self, ctx: &egui::Context) { if !self.fonts_installed { theme::install_fonts(ctx, false); self.fonts_installed = true; } else if !self.script_fonts && figcraft_text::system_fonts_scanned() { // Once the system fonts are indexed: faces for names in scripts the UI fonts lack. self.script_fonts = true; theme::install_fonts(ctx, true); } let dark = match self.session.prefs.theme.as_str() { "dark" => true, "system" => ctx.global_style().visuals.dark_mode, _ => false, }; self.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())); } } /// 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, 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 { let active = self.session.active; for i in 0..self.session.docs.len() { self.session.active = i; let _ = self.session.execute("version.autosave", &json!({})); } self.session.active = active; } self.ui.next_autosave = now + AUTOSAVE_SECS; repaint_in(ctx, AUTOSAVE_SECS); } /// Draw the whole UI. 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.frame_stats.record(&ctx); let first = !self.fonts_installed; self.logic(&ctx); self.autosave_tick(&ctx); if first { // New fonts apply from the next frame; draw nothing until then. ctx.request_repaint(); return; } 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); // Control requests run first so their results reflect this frame. control::drain(self, &ctx); // What happened in a live session (the network woke us for it). if self.session.live_poll() && let Some(m) = self.session.message.take() { self.toast(m); } // Before any widget: the navigation keys (F6, Tab, arrows, Escape) move the keyboard. a11y::begin(self, &ctx); 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)); // The Inspect tab measures on hover without Alt. self.session.tool.redlines = self.ui.right_tab == RightTab::Inspect; if self.ui.present.is_some() { present::show(self, ui); return; } if self.session.docs.is_empty() || self.ui.home { tabs::tab_bar(self, ui); home::show(self, ui); dialogs::show(self, &ctx); return; } let hidden = self.session.ds().map(|d| d.view.ui_hidden).unwrap_or(false); tabs::tab_bar(self, ui); if !hidden { egui::Panel::left("left_rail") .exact_size(56.0) .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, "Sidebar"); layers::rail(self, ui) }); } if !hidden && !self.ui.left_collapsed { egui::Panel::left("left_panel") .resizable(true) .default_size(self.ui.left_width) .size_range(200.0..=480.0) .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 => "Version history", LeftTab::File => "Layers", LeftTab::Assets => "Assets", }; a11y::region(&mut self.ui.nav, ui, a11y::Region::Layers, egui::accesskit::Role::Pane, label); layers::left_panel(self, ui) }); } if !hidden { egui::Panel::right("right_panel") .resizable(true) .default_size(self.ui.right_width) .size_range(240.0..=480.0) .frame(egui::Frame::NONE.fill(t.panel).stroke(egui::Stroke::new(1.0, t.border))) .show(ui, |ui| { let label = match self.ui.right_tab { RightTab::Design => "Design", RightTab::Prototype => "Prototype", RightTab::Inspect => "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 { ui.scope_builder(egui::UiBuilder::new().id_salt("toolbar"), |ui| { a11y::container(ui, egui::accesskit::Role::Toolbar, "Tools"); toolbar::show(self, ui); }); } }); // 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); live::dialog_keys(self, &ctx); shortcuts::handle(self, &ctx, typing); menus::popups(self, &ctx); variable_ui::picker(self, &ctx); style_picker::show(self, &ctx); self.end_unwanted_text_preview(&ctx); dialogs::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. if self.ui.rename.is_some_and(|r| r.start) && !hidden && !self.ui.left_collapsed && self.ui.left_tab == LeftTab::File { ctx.request_repaint(); } a11y::end(self, &ctx); self.field_focus = a11y::field_focused(&ctx); // Your pointer, selection and view to the others in a live session (when they changed). self.session.live_sync(); #[cfg(not(target_arch = "wasm32"))] { self.ui.frame_ms = t0.elapsed().as_secs_f64() * 1000.0; } } fn toast_ui(&mut self, ctx: &egui::Context) { 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; } 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)) .pivot(egui::Align2::CENTER_BOTTOM) .order(egui::Order::Tooltip) .show(ctx, |ui| { egui::Frame::NONE .fill(egui::Color32::from_rgb(0x1E, 0x1E, 0x1E)) .corner_radius(egui::CornerRadius::same(8)) .inner_margin(egui::Margin::symmetric(14, 10)) .show(ui, |ui| { // Wrapped at a readable width (the area alone would wrap at a sliver). let width = (screen.width() - 64.0).clamp(120.0, 480.0); let galley = ui.painter().layout(msg, theme::regular(12.0), egui::Color32::WHITE, width); ui.label(galley); }); }); } /// UI state as JSON (control channel `ui.inspect`). pub fn inspect(&self) -> Value { json!({ "rightTab": self.ui.right_tab, "leftTab": self.ui.left_tab, "home": self.ui.home, "dark": self.ui.dark, "focus": {"region": self.ui.nav.region.id(), "keyboard": self.ui.nav.keyboard}, "reduceMotion": self.reduce_motion(), "presenting": self.ui.present.is_some(), "presenter": self.ui.present.as_ref().map(present::Presenter::inspect), "presentOptions": {"scale": self.ui.present_view.scale.id(), "deviceFrame": self.ui.present_view.device_frame, "comments": self.ui.present_view.comments, "hotspotHints": self.ui.present_view.hotspot_hints, "inspect": self.ui.present_view.inspect}, "preview": self.ui.preview.as_ref().map(|p| p.player.inspect()), "mainMenu": self.ui.main_menu, "palette": self.ui.palette.is_some(), "variables": self.ui.variables.is_some(), "versionHistory": self.ui.versions.open, "devCompare": self.ui.dev.compare.as_ref().map(|c| json!({"id": c.id, "mode": if c.mode == dev_compare::Mode::Overlay { "overlay" } else { "sideBySide" }, "split": c.split})), "devFocus": self.session.ds().ok().and_then(figcraft_engine::DocState::focused), "annotationFilter": self.ui.dev.categories.id(), "colorProfileDialog": self.ui.color_profile.map(|p| p.id()), "stylePicker": self.ui.style_picker.as_ref().map(|p| p.slot().key()), "stylePickerView": if self.ui.style_grid { "grid" } else { "list" }, "textPreview": figcraft_engine::textpreview::active(&self.session), "comments": { "open": self.ui.comments.open, "draft": self.ui.comments.draft.as_ref().map(|d| [d.0, d.1]), "filter": self.ui.comments.filter.id(), "sort": self.ui.comments.sort.id(), "search": self.ui.comments.search, "editing": self.ui.comments.editing.as_ref().map(|e| [e.0, e.1 as u64]), "mentions": self.ui.comments.mention.names(), }, "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 { figcraft_render::Work::Reused => json!({"kind": "reused"}), figcraft_render::Work::Partial { pixels } => json!({"kind": "partial", "pixels": pixels}), figcraft_render::Work::Full => json!({"kind": "full"}), }), }, "live": live::inspect(self), "tool": self.session.tool_info(), "status": self.session.status(), "lastError": self.ui.last_error, }) } }