//! VectorCraft's egui frontend: an Illustrator-style UI over `vectorcraft-engine`. //! //! The UI is thin: every action goes through [`VectorcraftApp::run`], which dispatches UI commands //! (view/window) here and everything else to the engine. The same entry point serves menus, //! shortcuts, the ⌘K palette and the control channel ([`control`]). #![forbid(unsafe_code)] pub mod background; mod brand; pub mod canvas; pub mod chrome; pub mod community; pub mod control; pub mod cursors; pub mod dialogs; pub mod dock; pub mod find_font; pub mod icon_data; pub mod icons; pub mod io; pub mod menus; pub mod palette; pub mod panels; pub mod place; pub mod prefs_dialog; pub mod render_worker; pub mod shortcut_editor; pub mod shortcuts; pub mod state; pub mod sysclip; pub mod theme; pub mod titlebar; pub mod toolbar; pub mod unsaved; pub mod widgets; pub mod workspaces; #[cfg(test)] mod tests_aisave; #[cfg(test)] mod tests_background; #[cfg(test)] mod tests_clipboard; #[cfg(test)] mod tests_docsetup; #[cfg(test)] mod tests_labels; #[cfg(test)] mod tests_overprint; #[cfg(test)] mod tests_paintchips; #[cfg(test)] mod tests_place; #[cfg(test)] mod tests_recolor; #[cfg(test)] mod tests_save; #[cfg(test)] mod tests_svg; #[cfg(test)] mod tests_svgsave; #[cfg(test)] mod tests_sysclip; #[cfg(test)] mod tests_transparencygrid; use std::sync::mpsc::{Receiver, Sender}; use std::sync::{Arc, Mutex}; use serde_json::{Value, json}; use vectorcraft_engine::cmd::fileio; use vectorcraft_engine::{Session, ViewInfo}; pub use control::{ControlRequest, ControlResponse}; pub use state::{UiState, View}; pub use sysclip::SystemClipboard; /// What a file dialog shows: a suggested file name (save dialogs), the folder to start in and the /// file-type filters. #[derive(Clone, Debug, Default, PartialEq)] pub struct FilePick { pub name: String, pub folder: Option, /// `(label, extensions without the dot)`, the default type first; empty = every file. pub filters: Vec<(&'static str, &'static [&'static str])>, } impl FilePick { /// A save dialog suggesting `name`. pub fn named(name: &str) -> Self { Self { name: name.to_string(), ..Default::default() } } } pub type PickOpen = Box Option>; pub type PickSave = Box Option>; pub type ReadFn = Box Result, String>>; pub type WriteFn = Box Result<(), String>>; pub type Inbox = Arc)>>>; pub type DownloadFn = Box; /// Opens a URL in the system browser. pub type OpenUrlFn = Box; /// Shows a file in the system file manager, or opens it in its app. pub type RevealFn = Box Result<(), String>>; /// Platform services injected by the host app (desktop or web). #[derive(Default)] pub struct Services { /// Show an open dialog; returns a path. pub pick_open: Option, /// Show a save dialog (suggested name, folder, file types); returns a path. pub pick_save: Option, pub read: Option, pub write: Option, /// Files that arrived asynchronously (web open / drops): (name, bytes). pub inbox: Option, /// Web: trigger a browser download instead of writing a path. pub download: Option, /// Web: start an async open (bytes arrive via `inbox`). pub open_async: Option>, /// Read the system clipboard's text (desktop). Without it, pasted text only arrives with /// egui's Paste event (web, and keyboard paste everywhere). pub clipboard_read: Option Option>>, /// Open a URL in the system browser (desktop). Without it, egui opens it (a new tab on the web). pub open_url: Option, /// Show an open dialog for several files (File → Place…); returns their paths (none: cancelled). pub pick_open_multi: Option Vec>>, /// Web: start an async pick of files to place (they arrive via `place_inbox`). pub place_async: Option>, /// Files that arrived asynchronously to be placed (web: picked for Place, or dropped on the /// canvas). pub place_inbox: Option, /// The system clipboard with all its formats (desktop): Copy offers text, SVG, PDF and PNG, /// Paste takes SVG, PDF, text and bitmaps from other apps. Without it, SVG text only (through /// egui and `clipboard_read`). pub system_clipboard: Option>, /// File → Show in Folder: select a file in the system file manager (desktop). pub reveal: Option, /// Write a file from any thread (desktop): lets Background Save and Export write off the UI /// thread ([`background`]). Without it they run at once. pub write_shared: Option, /// Open a file (or a folder) in the system's default app for it (desktop: Edit Original, Show /// Package). Without it (the web) those answer with an error. pub open_file: Option, /// Show a folder picker; returns its path (desktop: Relink to Folder, Package). pub pick_folder: Option Option>>, } /// Cached canvas raster. pub struct CanvasCache { pub renderer: vectorcraft_render::Renderer, pub texture: Option, pub key: Option, pub last_ms: f64, pub worker: Option, pub worker_started: bool, } #[derive(Clone, Debug, PartialEq)] pub struct CacheKey { pub doc: usize, pub revision: u64, pub zoom: f64, pub cx: f64, pub cy: f64, pub w: u32, pub h: u32, pub outline: bool, pub trim: bool, pub ppp: f32, pub hidden: Vec, pub rot: f64, } #[derive(Clone, Copy, Debug, Default)] pub struct Perf { pub frame_ms: f64, pub render_ms: f64, pub fps: f64, } pub struct VectorcraftApp { pub session: Session, pub ui: UiState, pub views: Vec, pub services: Services, pub canvas: CanvasCache, pub perf: Perf, /// macOS: draw our own title strip under the traffic lights. pub integrated_titlebar: bool, /// The host installed a native menu bar (macOS): don't draw in-window menus. pub native_menu: bool, /// Last applied effect (Effect → Apply Last Effect). pub last_effect: Option<(String, serde_json::Value)>, /// Commands whose shortcuts the native menu handles (skip them in egui to avoid double firing). pub native_shortcuts: std::collections::HashSet, control_rx: Option>, /// (token, path, reply, deadline ms): a window that isn't presented never delivers its frame. pending_screenshots: Vec<(u64, Option, Sender, f64)>, queued_screenshots: Vec<(u64, f64, u32)>, screenshot_token: u64, /// Synthetic input events (from the control channel) injected one step per frame. pub synthetic: Vec, styled: bool, fonts_ready: bool, frame: u64, last_time: f64, /// Canvas rect of the last frame (screen points), for control-channel coordinate mapping. pub canvas_rect: Option, /// Hover position in document coordinates. pub hover_doc: Option, /// System clipboard: SVG to publish next frame, the last SVG we published (so pasting it back /// uses the lossless internal clipboard) and text that arrived with a Paste event. clipboard_out: Option, clipboard_published: Option, pub(crate) clipboard_in: Option, /// A URL to open through egui next frame (when the host has no `open_url` service). pending_url: Option, /// Windows and Linux: the window has no OS decorations, so the app bar is the title bar (drag, /// double-click to maximize, caption buttons) and invisible edge zones resize the window. pub custom_titlebar: bool, /// File → Place: picked files, the place cursor's thumbnails, the Control bar's image details. pub place: place::PlaceState, /// The Paste commands can paste from the system clipboard alone: it holds something to paste /// (SVG without a `system_clipboard`) while the internal clipboard is empty. It enables the /// Paste menu items. pub(crate) system_paste: bool, /// When `system_paste` was last checked (app time, s; at most once per frame). system_paste_at: f64, /// Keyboard pastes of something other than text (see [`shortcuts::PasteChord`]). pub(crate) paste_chord: shortcuts::PasteChord, /// Saves and exports running in the background (Preferences → File Handling). pub background: background::Background, } /// Seconds between two looks at the system clipboard for [`VectorcraftApp::system_paste`]. const SYSTEM_CLIPBOARD_POLL: f64 = 0.25; impl VectorcraftApp { pub fn new(session: Session, services: Services) -> Self { let views = session.documents().iter().map(View::of).collect(); Self { session, ui: UiState::default(), views, services, canvas: CanvasCache { renderer: vectorcraft_render::Renderer::new(), texture: None, key: None, last_ms: 0.0, worker: None, worker_started: false, }, perf: Perf::default(), integrated_titlebar: false, native_menu: false, last_effect: None, native_shortcuts: Default::default(), clipboard_out: None, clipboard_published: None, clipboard_in: None, pending_url: None, control_rx: None, pending_screenshots: vec![], queued_screenshots: vec![], screenshot_token: 0, synthetic: vec![], styled: false, fonts_ready: false, frame: 0, last_time: 0.0, canvas_rect: None, hover_doc: None, custom_titlebar: false, place: Default::default(), system_paste: false, system_paste_at: f64::NEG_INFINITY, paste_chord: Default::default(), background: Default::default(), } } pub fn with_control(mut self, rx: Receiver) -> Self { self.control_rx = Some(rx); self } /// Keep `views` aligned with the session's documents (a new one starts at its saved view). pub fn sync_views(&mut self) { let docs = self.session.documents(); self.views.truncate(docs.len()); self.views.extend(docs.iter().skip(self.views.len()).map(View::of)); } pub fn view(&self) -> Option<&View> { self.session.active_index().and_then(|i| self.views.get(i)) } pub fn view_mut(&mut self) -> Option<&mut View> { self.sync_views(); let i = self.session.active_index()?; self.views.get_mut(i) } pub fn view_info(&self) -> ViewInfo { ViewInfo { zoom: self.view().map(|v| v.zoom).unwrap_or(1.0), outline: self.ui.view.outline, smart_guides: self.ui.view.smart_guides, snap_to_grid: self.ui.view.snap_to_grid, snap_to_pixel: self.ui.view.snap_to_pixel, show_bbox: self.ui.view.bounding_box, snap_to_point: self.ui.view.snap_to_point, corner_widgets: self.ui.view.corner_widgets, } } /// Run a UI or engine command by id. The single entry point for every frontend path. pub fn run(&mut self, id: &str, params: Value) -> Result { if let Some(r) = menus::run_ui_command(self, id, ¶ms) { return r; } let mut params = params; if id.starts_with("edit.paste") { if let Err(e) = self.adopt_system_clipboard() { self.ui.status = e.clone(); return Err(e); } // Paste (also without formatting) goes to the centre of the view. if matches!(id, "edit.paste" | "edit.pasteWithoutFormatting") && ["center", "dx", "dy"].iter().all(|k| params.get(k).is_none()) && let Some(c) = self.view().filter(|v| v.fitted).map(|v| v.center) && let Some(p) = params.as_object_mut() { p.insert("center".into(), serde_json::json!([c.x, c.y])); } if let Some(r) = dialogs::swatch_conflict::ask(self, id, ¶ms) { return r; } } // Native files carry the view they reopen at. if fileio::SaveMode::of(id).is_some() { io::remember_view(self); } let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string()); if r.is_ok() && matches!(id, "edit.copy" | "edit.cut") { self.publish_clipboard(); } self.sync_views(); match &r { Err(e) => self.ui.status = e.clone(), Ok(v) => { if id == "file.new" { self.ui.status.clear(); // New Document's Pixel preview mode (Overprint Preview is the engine's). if v["previewMode"] == "pixel" { self.ui.view.pixel_preview = true; } } if id == "text.setStyle" && let Some(font) = params.get("font").and_then(Value::as_str) { let r = &mut self.ui.recent_fonts; r.retain(|f| f != font); r.insert(0, font.to_string()); r.truncate(10); } } } r } /// Open a link in the browser (Help → Discord, website, GitHub…). pub fn open_url(&mut self, url: &str) { match self.services.open_url.as_mut() { Some(open) => open(url), None => self.pending_url = Some(url.to_string()), } self.ui.status = format!("Opened {url}"); } /// Run a Help link command and open the URL it returns. pub fn open_link(&mut self, id: &str) { if let Ok(v) = self.run(id, serde_json::json!({})) && let Some(u) = v["url"].as_str() { let u = u.to_string(); self.open_url(&u); } } /// Select a tool (also used by the toolbar and shortcuts). pub fn select_tool(&mut self, id: &str) { let v = self.view_info(); if let Err(e) = self.session.select_tool(id, v) { self.ui.status = e.to_string(); } if let Some(g) = vectorcraft_tools::catalog::group_of(id) && let Some(slot) = self.ui.group_tool.get_mut(g) { *slot = id.to_string(); } toolbar::remember(self, id); self.ui.flyout = None; } fn drain_control(&mut self, ctx: &egui::Context) { let Some(rx) = self.control_rx.take() else { return }; while let Ok(req) = rx.try_recv() { let reply = req.reply.clone(); // Guarded per request: a panic must not drop the channel (taken out of `self` above). let outcome = vectorcraft_engine::guard::catch_panic(|| control::handle(self, ctx, &req)) .unwrap_or_else(|msg| control::err(format!("internal error: {msg} (please report this bug)"))); match outcome { control::Outcome::Done(v) => { let _ = reply.send(v); } control::Outcome::Screenshot { path } => { self.screenshot_token += 1; let token = self.screenshot_token; let settle = ctx.global_style().animation_time as f64 * 2000.0 + 80.0; self.queued_screenshots.push((token, now_ms() + settle, 0)); self.pending_screenshots.push((token, path, reply, now_ms() + settle + 8000.0)); } } } self.control_rx = Some(rx); } fn issue_screenshots(&mut self, ctx: &egui::Context) { let now = now_ms(); self.queued_screenshots.retain_mut(|(token, at, frames)| { *frames += 1; if now >= *at && *frames >= 3 { ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(*token))); false } else { true } }); if !self.queued_screenshots.is_empty() || !self.pending_screenshots.is_empty() { ctx.request_repaint_after(std::time::Duration::from_millis(16)); } } fn collect_screenshots(&mut self, ctx: &egui::Context) { if self.pending_screenshots.is_empty() { return; } let events: Vec<_> = ctx.input(|i| { i.raw .events .iter() .filter_map(|e| match e { egui::Event::Screenshot { user_data, image, .. } => { let token = user_data.data.as_ref().and_then(|d| d.downcast_ref::()).copied()?; Some((token, image.clone())) } _ => None, }) .collect() }); for (token, image) in events { if let Some(i) = self.pending_screenshots.iter().position(|(t, ..)| *t == token) { let (_, path, reply, _) = self.pending_screenshots.remove(i); let _ = reply.send(control::save_screenshot(self, &image, path.as_deref())); } } let now = now_ms(); self.pending_screenshots.retain(|(_, _, reply, deadline)| { if now < *deadline { return true; } let _ = reply.send(serde_json::json!({ "ok": false, "error": "no frame was presented (screen locked, window minimized or fully covered); ui.render still renders the artboard" })); false }); } /// Show a transient status message. pub fn status(&mut self, s: impl Into) { self.ui.status = s.into(); } fn drain_inbox(&mut self) { let arrived: Vec<(String, Vec)> = self.services.inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default(); for (name, bytes) in arrived { if let Err(e) = io::open_bytes(self, &name, &bytes, None) { self.status(format!("Couldn't open {name}: {e}")); } } place::drain(self); } } pub fn now_ms() -> f64 { #[cfg(not(target_arch = "wasm32"))] { use std::time::{SystemTime, UNIX_EPOCH}; SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0) } #[cfg(target_arch = "wasm32")] { 0.0 } } /// eframe isn't a dependency of this crate (the host owns the event loop); these entry points are /// called from the host's `eframe::App` impl. impl VectorcraftApp { /// Per-frame logic before layout (control channel, shortcuts, inbox). A bug that panics costs /// one frame and shows an error, instead of closing the app with unsaved work. pub fn logic(&mut self, ctx: &egui::Context) { if let Err(msg) = vectorcraft_engine::guard::catch_panic(|| self.logic_frame(ctx)) { self.status(format!("Internal error: {msg} (please report this bug)")); } } fn logic_frame(&mut self, ctx: &egui::Context) { if !self.styled { theme::install_fonts(ctx); theme::apply(ctx, self.ui.brightness); egui_extras::install_image_loaders(ctx); self.styled = true; } else { self.fonts_ready = true; } self.frame += 1; let now = ctx.input(|i| i.time); let dt = now - self.last_time; if dt > 0.0 { self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1; } self.last_time = now; self.sync_views(); // Read the system clipboard only when that alone decides whether Paste is enabled, and at // most a few times a second (opening it locks it against other apps on some systems). if !(0.0..SYSTEM_CLIPBOARD_POLL).contains(&(now - self.system_paste_at)) { self.system_paste_at = now; self.system_paste = self.session.clipboard.is_empty() && self.session.active().is_some() && self.system_clipboard_pasteable(); } background::poll(self); if !self.background.jobs.is_empty() { // Keep the status bar's progress moving and pick the result up when it arrives. ctx.request_repaint_after(std::time::Duration::from_millis(100)); } // The window's close button (or the system quitting the app) asks about unsaved documents, // once the saves running in the background are done. if ctx.input(|i| i.viewport().close_requested()) { background::wait_all(self); if unsaved::any_dirty(self) { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); if let Err(e) = unsaved::close_all(self, "quit") { self.status(e); } } } shortcut_editor::sync(&self.ui); prefs_dialog::apply_runtime(self, ctx); self.drain_control(ctx); if !self.synthetic.is_empty() { ctx.request_repaint(); } self.collect_screenshots(ctx); self.issue_screenshots(ctx); if let Some(u) = self.pending_url.take() { ctx.open_url(egui::OpenUrl::new_tab(u)); } if let Some(t) = self.clipboard_out.take() { ctx.copy_text(t); } self.drain_inbox(); if self.fonts_ready { shortcuts::handle(self, ctx); } // Native only: the web host reads dropped files asynchronously and feeds the inboxes. #[cfg(not(target_arch = "wasm32"))] self.take_dropped_files(ctx); } /// Files dropped on the window: placed where they were dropped on the canvas, else opened. #[cfg(not(target_arch = "wasm32"))] fn take_dropped_files(&mut self, ctx: &egui::Context) { let (dropped, pos, shift) = ctx.input(|i| (i.raw.dropped_files.clone(), i.pointer.latest_pos(), i.modifiers.shift)); if dropped.is_empty() { return; } let target = self.drop_target(pos, shift); let mut files = vec![]; for f in dropped { let path = Some(f.path().to_string_lossy().to_string()).filter(|s| !s.is_empty()); let name = path.as_deref().map_or_else(|| "dropped".into(), vectorcraft_engine::cmd::fileio::file_name); // A file placed by its path is read by the engine. let bytes = if path.is_some() && target != place::DropTarget::Open { Ok(vec![]) } else { f.bytes() }; match bytes { Ok(b) => files.push((name, path, b)), Err(e) => self.status(format!("Couldn't read {name}: {e}")), } } place::drop_files(self, files, target); } /// Inject synthetic events (one press/release step per frame). pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { if self.synthetic.is_empty() { return; } // Pointer events go one per frame so egui sees presses, drags and releases as real input; // keyboard sequences go up to the key release. let n = match self.synthetic[0] { egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } => 1, _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), }; if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() { raw.events.push(egui::Event::PointerMoved(*p)); } raw.events.extend(self.synthetic.drain(..n)); } /// Lay out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { if let Err(msg) = vectorcraft_engine::guard::catch_panic(|| self.ui_frame(ui)) { self.status(format!("Internal error: {msg} (please report this bug)")); } } fn ui_frame(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); if !self.fonts_ready { ctx.request_repaint(); return; } let t0 = now_ms(); let t = theme::Tokens::get(&ctx); if self.ui.screen_mode < 2 { chrome::app_bar(self, ui); if self.ui.control_bar { chrome::control_bar(self, ui); } } if self.ui.status_bar && self.ui.screen_mode < 3 { chrome::status_bar(self, ui); chrome::hint_bar(self, ui); } if self.ui.toolbar && self.ui.screen_mode < 3 { toolbar::show(self, ui); } if self.ui.dock && self.ui.screen_mode < 3 { dock::show(self, ui); } egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.pasteboard)).show(ui, |ui| { if self.ui.screen_mode < 3 { chrome::doc_tabs(self, ui); } canvas::show(self, ui); }); dock::floating_panel(self, &ctx); panels::library_panel::show_window(self, &ctx); dialogs::show(self, &ctx); palette::show(self, &ctx); if self.custom_titlebar { titlebar::resize_zones(ui); } self.perf.frame_ms = now_ms() - t0; let _ = json!(null); } }