figcraft/crates/ui-egui/src/lib.rs

735 lines
29 KiB
Rust

//! 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 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_mode;
pub mod dialogs;
pub mod export_panel;
pub mod font_preview;
pub mod frame_presets;
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 measure_overlay;
pub mod menus;
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 canvas_drag_tests;
#[cfg(test)]
mod comments_tests;
#[cfg(test)]
mod layers_drag_tests;
#[cfg(test)]
mod layers_keys_tests;
#[cfg(test)]
mod layers_rename_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_responsive_tests;
#[cfg(test)]
mod shortcuts_tests;
#[cfg(test)]
mod titlebar_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<PathBuf> {
None
}
fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> Option<PathBuf> {
None
}
/// A folder to write several files into.
fn pick_folder(&mut self, _title: &str) -> Option<PathBuf> {
None
}
fn clipboard_set_text(&mut self, _s: &str) {}
fn clipboard_get_text(&mut self) -> Option<String> {
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<u8>)> {
None
}
fn open_url(&mut self, _url: &str) {}
/// Recently opened files (persisted by the app).
fn recent_files(&self) -> Vec<String> {
Vec::new()
}
fn add_recent(&mut self, _path: &str) {}
}
/// 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;
/// 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<NodeId>,
/// The layer being renamed in the layers panel.
pub rename: Option<layers::Rename>,
pub rename_page: Option<(NodeId, String)>,
pub main_menu: bool,
pub palette: Option<String>,
/// The Variables editor, when open.
pub variables: Option<variables_panel::Editor>,
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,
pub toast: Option<(String, f64)>,
pub present: Option<present::Presenter>,
/// 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<present::Pane>,
pub picker: Option<colorpicker::PickerTarget>,
pub context_menu: Option<(egui::Pos2, Vec<NodeId>)>,
/// 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<NodeId>,
/// 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<NodeId>,
pub dark: bool,
pub follow_system: bool,
pub tool_flyout: Option<&'static str>,
pub last_error: Option<String>,
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<figcraft_color::ColorProfile>,
/// The open style picker.
pub style_picker: Option<style_picker::Picker>,
/// 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<String>,
/// 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<String>,
/// 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,
}
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,
}
}
}
/// The whole FigCraft UI.
pub struct FigApp {
pub session: Session,
pub ui: UiState,
pub canvas: canvas::CanvasState,
pub services: Box<dyn Services>,
worker: Option<render_worker::Worker>,
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<ControlRequest>,
/// Input events from the control channel (`ui.input`) for the next frame's raw input.
pub control_input: Vec<egui::Event>,
}
impl FigApp {
pub fn new(session: Session, services: Box<dyn Services>) -> 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(),
}
}
/// Run a command, surfacing errors as a toast. Returns the result.
pub fn run(&mut self, id: &str, params: Value) -> Option<Value> {
match self.session.execute(id, &params) {
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<String>) {
self.ui.toast = Some((msg.into(), 0.0));
}
/// 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,
"panel.layers" => self.ui.left_tab = LeftTab::File,
"panel.assets" => self.ui.left_tab = LeftTab::Assets,
"panel.versionHistory" => versions::open(self),
"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<NodeId> =
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"),
_ => {}
}
}
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.services.add_recent(&ps);
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.services.add_recent(&ps);
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}));
}
}
/// 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(ctx, if dark { theme::Tokens::dark() } else { theme::Tokens::light() });
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, now: f64) {
if now < self.ui.next_autosave {
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;
}
/// 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();
let first = !self.fonts_installed;
self.logic(&ctx);
self.autosave_tick(ctx.input(|i| i.time));
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) || ctx.memory(|m| m.focused().is_some());
// Control requests run first so their results reflect this frame.
control::drain(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| 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| 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| 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 {
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);
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();
}
self.field_focus = ctx.memory(|m| m.focused().is_some());
#[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, 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 {
self.ui.toast = None;
return;
}
self.ui.toast = Some((msg.clone(), age));
ctx.request_repaint();
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| {
ui.label(egui::RichText::new(msg).color(egui::Color32::WHITE).font(theme::regular(12.0)));
});
});
}
/// 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,
"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,
"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,
"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"}),
}),
},
"tool": self.session.tool_info(),
"status": self.session.status(),
"lastError": self.ui.last_error,
})
}
}