//! 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)] /// Translate a UI string literal into the language the UI is drawn in (see [`i18n`]). #[macro_export] macro_rules! tl { ($s:expr) => { $crate::i18n::t($s) }; } pub mod background; mod brand; pub mod canvas; pub mod chrome; mod clipboard_probe; pub mod community; pub mod control; pub mod credits; pub mod cursors; pub mod dialogs; pub mod dock; pub mod find_font; pub mod floating; pub mod font_menu; mod free_transform; pub mod graphics; pub mod i18n; pub mod icon_data; pub mod icons; pub mod io; pub mod menus; pub mod native_menu; pub mod palette; pub mod panels; pub mod picks; pub mod place; pub mod prefs_dialog; pub mod print; pub mod recovery; pub mod render_worker; mod scrub; pub mod shortcut_editor; pub mod shortcuts; pub mod state; pub mod sysclip; pub mod theme; pub mod titlebar; pub mod toolbar; mod touch; mod ui_fonts; pub mod unsaved; pub mod widgets; pub mod workspaces; #[cfg(test)] mod tests_adjust; #[cfg(test)] mod tests_aisave; #[cfg(test)] mod tests_background; #[cfg(test)] mod tests_clipboard; #[cfg(test)] mod tests_contextmenu; #[cfg(test)] mod tests_cut; #[cfg(test)] mod tests_distortkeys; #[cfg(test)] mod tests_docsetup; #[cfg(test)] mod tests_font_menu; #[cfg(test)] mod tests_fonts; #[cfg(test)] mod tests_home; #[cfg(test)] mod tests_labels; #[cfg(test)] mod tests_nativemenu; #[cfg(test)] mod tests_nativeoptions; #[cfg(test)] mod tests_numfields; #[cfg(test)] mod tests_overprint; #[cfg(test)] mod tests_paintchips; #[cfg(test)] mod tests_pastechords; #[cfg(test)] mod tests_pathtype; #[cfg(test)] mod tests_pdfoutput; #[cfg(test)] mod tests_picks; #[cfg(test)] mod tests_place; #[cfg(test)] mod tests_plugins; #[cfg(test)] mod tests_printps; #[cfg(test)] mod tests_printtiling; #[cfg(test)] mod tests_puppetwarp; #[cfg(test)] mod tests_recolor; #[cfg(test)] mod tests_recovery; #[cfg(test)] mod tests_removeanchors; #[cfg(test)] mod tests_save; #[cfg(test)] mod tests_saveext; #[cfg(test)] mod tests_screenmode; #[cfg(test)] mod tests_scrub; #[cfg(test)] mod tests_selectall; #[cfg(test)] mod tests_slices; #[cfg(test)] mod tests_svg; #[cfg(test)] mod tests_svgsave; #[cfg(test)] mod tests_synthetic; #[cfg(test)] mod tests_sysclip; #[cfg(test)] mod tests_sysclip_emf; #[cfg(test)] mod tests_sysclip_probe; #[cfg(test)] mod tests_transparencygrid; #[cfg(test)] mod tests_widthtool; 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::{ClipboardProbeFactory, 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>, /// A second system-clipboard handle, for checking whether Paste has something to take on a /// background thread (Linux: an X11 clipboard owner that never answers would freeze the UI). /// Taken on the first frame. Without it the check runs on the UI thread. pub clipboard_probe: 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>>, /// Where Data Recovery keeps its copies when not in a folder (the web's browser storage; /// tests): installed into the session ([`vectorcraft_engine::cmd::recovery`]). pub recovery_store: Option>, /// File → Print: the system's printers and print queue (desktop), the browser's print dialog /// (web). Without it Print saves the job as a PDF. pub print: Option>, /// The macOS menu bar, when the desktop app installed one: the in-window menus are hidden then. pub native_menu: Option, /// Show file dialogs off the UI thread (desktop Linux, where a dialog in line holds the window /// and the compositor finds it not answering): what asked runs again with the answer /// ([`picks`]). Without it they are shown in line. pub start_pick: 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, /// The slices as laid out for (document uid, revision): the canvas draws them every frame. pub slices: Option, /// The print tiling's pages for (document uid, revision) (View → Show Print Tiling). pub print_tiling: Option, /// [`VectorcraftApp::selection_box`] for (document uid, revision, Use Preview Bounds). pub selection_box: Option<((u64, u64, bool), Option)>, /// [`VectorcraftApp::selection_bounds`] for (document uid, revision). pub selection_bounds: Option<((u64, u64), Option)>, /// The tools' cursors as OS cursor bitmaps. pub cursors: cursors::Images, } /// [`CanvasCache::slices`]: the layout of the slices of (document uid, revision). pub type SliceCache = ((u64, u64), std::sync::Arc>); /// [`CanvasCache::print_tiling`]: the print tiling of (document uid, revision). pub type PrintTilingCache = ((u64, u64), std::sync::Arc>); #[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, /// General › Anti-aliased Artwork. pub anti_alias: bool, /// [`vectorcraft_render::placed_document::generation`]: placed documents' bitmaps made since. pub placed: u64, /// View › Pixel Preview: the document pixels rendered (x0, y0, x1, y1), one per point, shown /// with hard edges. None: the art is rendered for the screen. pub pixel: Option<[i64; 4]>, /// Images sampled smoothly ([`vectorcraft_render::RenderOptions::smooth_images`]). pub smooth_images: bool, } #[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, /// Last applied effect (Effect → Apply Last Effect). pub last_effect: Option<(String, serde_json::Value)>, /// Commands run through [`Self::run`] so far: the native menu bar reads its rows again when it /// moves ([`native_menu::sync`]). run_count: u64, 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, /// The egui context the UI's textures were uploaded to (0: none yet; see /// [`Self::adopt_context`]). context: u64, /// Installed fonts added to the UI's for characters its own fonts lack (CJK names…). ui_fonts: ui_fonts::UiFonts, 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 what came with a paste (a Paste event's text, a /// picture or file the host read: [`Self::paste_from_host`]). 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, /// The graphics adapter the window renders with ("name (backend)"), as the host reports it: /// shown in Help › About and `ui.inspect` for GPU bug reports. `None` when unknown. pub graphics_adapter: Option, /// 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 in line (app time, s; at most once per frame). system_paste_at: f64, /// The thread that checks the system clipboard for `system_paste` instead /// ([`Services::clipboard_probe`]). clipboard_probe: Option, /// File dialogs shown off the UI thread and what runs again with their answers. pub(crate) picks: picks::Picks, /// The look for fonts installed or removed while the app was in the background, running /// ([`Self::refresh_installed_fonts`]): whether they were. font_check: Option>, /// 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, /// Data Recovery's timer and startup question ([`recovery`]). pub recovery: recovery::Timer, /// Modifiers the keyboard holds (from the host's input), given back after synthetic input. host_modifiers: egui::Modifiers, /// Synthetic input set the modifiers egui holds (see [`Self::raw_input_hook`]). synthetic_modifiers: bool, /// The marked text the system IME last sent (`None` once it commits or clears). When the Type /// tool stops composing on its own (a click, a tool switch), the IME is told to drop it. pub(crate) ime_marked: Option, /// The IME must drop its marked text (see [`Self::take_ime_discard`]). pub(crate) ime_discard: bool, /// A numeric field is being scrubbed: the document's edits meanwhile are one undo step /// ([`scrub::begin_frame`]). scrub_group: bool, } /// Seconds between two looks at the system clipboard for [`VectorcraftApp::system_paste`]. const SYSTEM_CLIPBOARD_POLL: f64 = 0.25; impl VectorcraftApp { pub fn new(mut session: Session, services: Services) -> Self { // The font menus and the first file opened need the installed fonts: catalog them now. vectorcraft_text::FontDb::global().scan_in_background(); if let Some(store) = &services.recovery_store { session.recovery.set_store(store.clone()); } 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, slices: None, print_tiling: None, selection_box: None, selection_bounds: None, cursors: Default::default(), }, perf: Perf::default(), integrated_titlebar: false, last_effect: None, run_count: 0, 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, context: 0, ui_fonts: Default::default(), frame: 0, last_time: 0.0, canvas_rect: None, hover_doc: None, custom_titlebar: false, graphics_adapter: None, place: Default::default(), system_paste: false, system_paste_at: f64::NEG_INFINITY, clipboard_probe: None, picks: picks::Picks::default(), font_check: None, paste_chord: Default::default(), background: Default::default(), recovery: Default::default(), host_modifiers: Default::default(), synthetic_modifiers: false, ime_marked: None, ime_discard: false, scrub_group: false, } } 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, guides: self.ui.view.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, screen: self.screen_frame(), } } /// The canvas on screen in document coordinates (none before it is laid out). pub fn screen_frame(&self) -> Option { let (rect, view) = (self.canvas_rect?, self.view()?); let xf = canvas::Xf::new(rect, view); let px = |x: f32, y: f32| xf.delta_to_doc(egui::vec2(x, y)); Some(vectorcraft_tools::ScreenFrame { origin: xf.to_doc(rect.left_top()), right: px(1.0, 0.0), down: px(0.0, 1.0), size: (f64::from(rect.width()), f64::from(rect.height())), }) } /// 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 { // A file dialog it shows off the UI thread runs it again with the path picked. if self.picks.is_entry_free() { let entry = picks::Entry::Command(id.to_string(), params.clone()); return picks::as_entry(self, move || entry, |app| app.run_now(id, params)); } self.run_now(id, params) } /// [`Self::run`] it, inside what asks for file dialogs. fn run_now(&mut self, id: &str, params: Value) -> Result { self.run_count = self.run_count.wrapping_add(1); 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])); } // In place, in front, in back: onto the active artboard (#693). if matches!(id, "edit.pasteInPlace" | "edit.pasteInFront" | "edit.pasteInBack") && params.get("artboard").is_none() && let Some(i) = self.view().map(|v| v.artboard) && let Some(p) = params.as_object_mut() { p.insert("artboard".into(), serde_json::json!(i)); } 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(MAX_RECENT_FONTS); } } } 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 }); } /// Did the Type tool end an IME composition on its own this frame? Interrupting the IME /// through egui (`should_interrupt_composition`) doesn't reach the macOS input context, which /// keeps the marked text and types it again into the next composition: the host discards it. pub fn take_ime_discard(&mut self) -> bool { std::mem::take(&mut self.ime_discard) } /// 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 } } /// The most fonts Type › Recent Fonts lists (Preferences › Type › Number of Recent Fonts). pub const MAX_RECENT_FONTS: usize = 15; impl VectorcraftApp { /// Type › Recent Fonts: the fonts used last, newest first, as many as Preferences › Type › /// Number of Recent Fonts says. pub fn recent_fonts(&self) -> &[String] { let n = usize::try_from(self.session.prefs.recent_fonts_count).unwrap_or(MAX_RECENT_FONTS).clamp(1, MAX_RECENT_FONTS); self.ui.recent_fonts.get(..n).unwrap_or(&self.ui.recent_fonts) } /// The selection's bounding box, rotated with rotated objects ([`Session::transform_box`]). The /// canvas and the transform fields read it every frame: it is measured once per revision. pub fn selection_box(&mut self) -> Option { let st = self.session.active()?; let key = (st.uid, st.revision, self.session.prefs.use_preview_bounds); if let Some((k, b)) = self.canvas.selection_box && k == key { return b; } let b = self.session.transform_box(&st.selection.objects); self.canvas.selection_box = Some((key, b)); b } /// The selection's visual bounds (stroke and effects included), square to the page: measured /// once per revision, as the canvas reads it every frame (a traced photo selects a group of /// hundreds of thousands of paths). pub fn selection_bounds(&mut self) -> Option { let st = self.session.active()?; let key = (st.uid, st.revision); if let Some((k, b)) = self.canvas.selection_bounds && k == key { return b; } let b = st.doc.bounds_of(&st.selection.objects, true); self.canvas.selection_bounds = Some((key, b)); b } } /// 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 { /// The language the UI is drawn in: the Preferences dialog's choice while it is open (so a /// change shows before OK), else the `interfaceLanguage` preference (`auto` = the system's). pub fn ui_language(&self) -> i18n::Lang { let editing = self.ui.dialog.as_ref().filter(|d| d.kind == "preferences").and_then(|d| d.fields.get("interfaceLanguage")).and_then(Value::as_str); i18n::Lang::from_pref(editing.unwrap_or(&self.session.prefs.interface_language)) } /// 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) { let lang = self.ui_language(); i18n::set_current(lang); // The engine gives new type the Japanese defaults while the UI is in Japanese. if self.session.ui_language.as_deref() != Some(lang.code()) { self.session.ui_language = Some(lang.code().to_string()); } self.adopt_context(ctx); 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. A // background thread reads it where the host installs one (an unresponsive owner must never // stall the frame loop); otherwise, or once that thread is gone, it is read here, at most a // few times a second (opening it locks it against other apps on some systems). let wanted = self.session.clipboard.is_empty() && self.session.active().is_some(); if let Some(make) = self.services.clipboard_probe.take() { self.clipboard_probe = clipboard_probe::Probe::start(make, ctx.clone()); } if let Some(pasteable) = self.clipboard_probe.as_mut().and_then(|p| p.pasteable(wanted)) { self.system_paste = pasteable; } else if !(0.0..SYSTEM_CLIPBOARD_POLL).contains(&(now - self.system_paste_at)) { self.clipboard_probe = None; self.system_paste_at = now; self.system_paste = wanted && self.system_clipboard_pasteable(); } self.poll_font_check(ctx); picks::poll(self, ctx); 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); } } else { // Quitting with nothing unsaved: no copies to leave behind. vectorcraft_engine::cmd::recovery::forget_all(&mut self.session); } } if let Some(wait) = recovery::frame(self, now) { // The timer and heartbeat run in an idle window too. ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait)); } 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(); native_menu::run(self, ctx); 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: documents opened, pictures and text placed on the canvas /// ([`Self::drop_target`]). #[cfg(not(target_arch = "wasm32"))] fn take_dropped_files(&mut self, ctx: &egui::Context) { let (dropped, shift) = ctx.input(|i| (i.raw.dropped_files.clone(), i.modifiers.shift)); if dropped.is_empty() { return; } let pos = place::drag_pos(ctx); 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); let target = self.drop_target(&name, pos, shift); // 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((target, (name, path, b))), Err(e) => self.status(format!("Couldn't read {name}: {e}")), } } place::drop_files(self, files); } /// Fonts installed or removed while the app was in the background are listed when it comes /// back (Refresh Font List by itself): a look at the font folders, a scan only when they changed. /// The look runs on another thread (asking DirectWrite for the fonts font services loaded /// meanwhile takes tens of milliseconds on Windows, #579), [`Self::poll_font_check`] scans. fn refresh_installed_fonts(&mut self) { if self.font_check.is_some() { return; } let (tx, rx) = std::sync::mpsc::channel(); let look = move || { // The UI gone meanwhile has nothing to refresh. let _ = tx.send(vectorcraft_text::FontDb::global().installed_fonts_changed()); }; let spawned = if cfg!(target_arch = "wasm32") { None } else { std::thread::Builder::new().name("font-check".into()).spawn(look.clone()).ok() }; if spawned.is_none() { look(); } self.font_check = Some(rx); } /// Rescan the fonts once [`Self::refresh_installed_fonts`]'s look says they changed. fn poll_font_check(&mut self, ctx: &egui::Context) { let Some(check) = &self.font_check else { return }; match check.try_recv() { Ok(changed) => { self.font_check = None; if changed { // A failure shows in the status bar, as the menu item's does. let _ = self.run("text.rescanFonts", json!({})); } } Err(std::sync::mpsc::TryRecvError::Empty) => ctx.request_repaint_after(std::time::Duration::from_millis(50)), Err(std::sync::mpsc::TryRecvError::Disconnected) => self.font_check = None, } } /// Inject synthetic events (one press/release step or wheel turn per frame). Handlers read the /// modifiers egui holds (`i.modifiers`), so a synthetic key, button or wheel turn holds its own /// for the frames it spans (a drag's moves included); the keyboard's come back after. pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { // The native menu's key equivalents become the input their keys make, ahead of what came // after them (see `native_menu`). let keys = self.services.native_menu.as_mut().map(native_menu::NativeMenu::take_keys).unwrap_or_default(); if !keys.is_empty() { let events: Vec = keys.into_iter().flat_map(|k| native_menu::key_events(k, || self.system_clipboard_text())).collect(); raw.events.splice(0..0, events); } for e in &raw.events { match e { egui::Event::ModifiersChanged(m) => self.host_modifiers = *m, egui::Event::WindowFocused(false) => self.host_modifiers = egui::Modifiers::NONE, egui::Event::WindowFocused(true) => self.refresh_installed_fonts(), _ => {} } } let Some(first) = self.synthetic.first() else { if std::mem::take(&mut self.synthetic_modifiers) { raw.events.push(egui::Event::ModifiersChanged(self.host_modifiers)); } 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 first { egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } | egui::Event::MouseWheel { .. } => 1, _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), }; if let egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. } = first { raw.events.push(egui::Event::PointerMoved(*p)); } let (now, later) = self.synthetic.split_at(n.min(self.synthetic.len())); // This frame's key or button, else the button a drag holds down (released later). let held = now .iter() .find_map(|e| match e { egui::Event::Key { modifiers, .. } | egui::Event::PointerButton { modifiers, .. } | egui::Event::MouseWheel { modifiers, .. } => { Some(*modifiers) } _ => None, }) .or_else(|| match later.iter().find(|e| matches!(e, egui::Event::PointerButton { .. })) { Some(egui::Event::PointerButton { pressed: false, modifiers, .. }) => Some(*modifiers), _ => None, }); match held { Some(m) => { raw.events.push(egui::Event::ModifiersChanged(m)); self.synthetic_modifiers = true; } None if std::mem::take(&mut self.synthetic_modifiers) => raw.events.push(egui::Event::ModifiersChanged(self.host_modifiers)), None => {} } 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(); scrub::begin_frame(self, &ctx); font_menu::end_stale_preview(self, &ctx); floating::track(self, &ctx); 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); floating::show(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.ui_fonts.frame(&ctx); scrub::end_frame(self, &ctx); native_menu::sync(self, &ctx); self.perf.frame_ms = now_ms() - t0; let _ = json!(null); } }