735 lines
29 KiB
Rust
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, ¶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<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,
|
|
})
|
|
}
|
|
}
|