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

409 lines
14 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 canvas;
pub mod colorpicker;
pub mod comments;
pub mod component_panel;
pub mod control;
pub mod credits;
pub mod dialogs;
pub mod handles;
pub mod home;
pub mod icons;
pub mod layers;
pub mod menus;
pub mod present;
pub mod proto_canvas;
pub mod proto_panel;
pub mod render_worker;
pub mod right;
pub mod shortcuts;
pub mod tabs;
pub mod theme;
pub mod toolbar;
pub mod typography;
pub mod widgets;
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
}
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]) {}
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,
}
/// 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>,
pub rename: Option<(NodeId, String)>,
pub rename_page: Option<(NodeId, String)>,
pub main_menu: bool,
pub palette: Option<String>,
pub about: bool,
pub preferences: bool,
pub shortcuts_panel: bool,
pub home: bool,
pub toast: Option<(String, f64)>,
pub present: Option<present::Presenter>,
pub picker: Option<colorpicker::PickerTarget>,
pub context_menu: Option<(egui::Pos2, Vec<NodeId>)>,
pub layer_filter: String,
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,
pub export_preview: bool,
pub zoom_menu: bool,
pub find: Option<(String, String)>,
pub left_collapsed: bool,
pub comments: comments::CommentUi,
}
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,
about: false,
preferences: false,
shortcuts_panel: false,
home: false,
toast: None,
present: None,
picker: None,
context_menu: None,
layer_filter: String::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,
zoom_menu: false,
find: None,
left_collapsed: false,
comments: comments::CommentUi::default(),
}
}
}
/// 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,
/// Control-channel requests waiting for the next frame.
pub control_queue: Vec<ControlRequest>,
}
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,
control_queue: 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
}
}
}
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,
"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.layers" => self.ui.left_tab = LeftTab::File,
"panel.assets" => self.ui.left_tab = LeftTab::Assets,
"home" => self.ui.home = true,
"dialog.saveAs" => self.save_as_dialog(),
"present" => {
if let Some(start) = r.data.get("start").and_then(figcraft_engine::params::id_of) {
self.ui.present = Some(present::Presenter::new(start));
}
}
"open.help" => self.services.open_url("https://getartcraft.com/apps/figcraft"),
"layers.collapseAll" => self.ui.expanded.clear(),
"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);
self.fonts_installed = 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()));
}
}
/// 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);
if first {
// New fonts apply from the next frame; draw nothing until then.
ctx.request_repaint();
return;
}
let t = theme::Tokens::get(&ctx);
// Control requests run first so their results reflect this frame.
control::drain(self, &ctx);
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);
}
});
shortcuts::handle(self, &ctx);
menus::popups(self, &ctx);
dialogs::show(self, &ctx);
self.toast_ui(&ctx);
#[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(),
"mainMenu": self.ui.main_menu,
"palette": self.ui.palette.is_some(),
"comments": {"open": self.ui.comments.open, "draft": self.ui.comments.draft.as_ref().map(|d| [d.0, d.1]), "showResolved": self.ui.comments.show_resolved},
"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]},
"tool": self.session.tool_info(),
"status": self.session.status(),
"lastError": self.ui.last_error,
})
}
}