//! RustFig desktop and web UI (layer L6), built on egui. //! //! The UI is thin: panels read engine state and act through //! [`App::run`], which executes engine commands or the UI-only commands in //! [`menus::UI_COMMANDS`]. pub mod app_icon; pub mod attributes; #[cfg(not(target_arch = "wasm32"))] pub mod autosave; pub mod bevel; pub mod canvas; pub mod capture; pub mod charmap; #[cfg(not(target_arch = "wasm32"))] pub mod clipboard; pub mod color_dialog; pub mod command_search; pub mod context_help; #[cfg(not(target_arch = "wasm32"))] pub mod control; pub mod dialogs; pub mod digitize; pub mod dimline_panel; #[cfg(not(target_arch = "wasm32"))] pub mod export_bg; pub mod file_panel; pub mod font_chooser; pub mod help; pub mod help_pages; pub mod inject; pub mod input; pub mod key_font; pub mod keys_sheet; pub mod library; pub mod menus; pub mod mode_icons; pub mod panels; pub mod platform; pub mod plot_dialog; pub mod point_entry; pub mod print_dialog; pub mod reload; pub mod rulers; pub mod screen_pick; pub mod settings; /// Listen for the system's "open this file" requests (Finder double-click, `open -a`); call /// before the event loop starts (P3). A no-op except on macOS. pub fn install_open_handler() { rustfig_os::open_docs::install_open_handler(); } pub mod spell_dialog; pub mod styles; #[cfg(not(target_arch = "wasm32"))] pub mod tex_gyre; pub mod theme; pub mod vector_icons; pub mod web_autosave; #[cfg(target_arch = "wasm32")] pub mod web_fetch; pub mod web_io; pub mod xfig_icons; use rustfig_engine::{Engine, UiRequest}; use serde_json::{json, Value}; /// Most messages kept in the message panel's history. pub const MAX_MESSAGES: usize = 200; /// A message shown in the message panel / status bar. #[derive(Debug, Clone)] pub struct Message { pub text: String, pub error: bool, } /// What to do once the user has dealt with unsaved changes. #[derive(Debug, Clone, PartialEq)] pub enum Pending { New, Open(Option), /// A file the browser picked or dropped (C2). OpenWeb(web_io::WebPick), Quit, } /// The application. pub struct App { pub engine: Engine, pub canvas: canvas::CanvasState, pub dialogs: dialogs::Dialogs, /// The latest message. pub message: Option, /// Message history, oldest first. pub messages: Vec, pub show_depths: bool, /// xfig's "Show info balloons" (Meta-Y): tooltips on or off. pub show_balloons: bool, /// A theme to switch to on the next frame (`app.theme`). pub theme_request: Option, /// The screen capture command and its thread (D11). pub capture: capture::Capture, /// E9: the theme setting, "system", "light" or "dark" (persisted). pub theme: String, /// E9: the UI scale, 1.0 = 100 % (persisted). pub ui_scale: f32, /// Button style: bevelled "3D" buttons (default) or flat ones. pub button_3d: bool, /// xfig's mode menu popped up by Alt+wheel over the canvas: drawing /// (wheel up) or editing modes, at the pointer. pub mode_popup: Option<(bool, egui::Pos2)>, /// Crash-recovery copies of the drawing (A6); `None` until enabled. #[cfg(not(target_arch = "wasm32"))] autosave: Option, /// Browser autosave (C3); wasm only. #[cfg(target_arch = "wasm32")] web_autosave: Option>, /// A drawing the last browser session left unsaved, offered at load (C3). pub web_restorable: Option, /// Drawings left by instances that stopped without saving, offered at /// start: (recovery id, the file they came from). pub recoverable: Vec<(String, Option)>, /// How long the last frames took on the UI thread (ms), newest last, /// at most 120 (native; `ui.state` reports them). pub frame_times: std::collections::VecDeque, /// Where the preferences are kept (E1); nowhere until `App::new` or a /// test sets one. pub settings: settings::Settings, /// xfig's compose key while typing text (`menus::compose_text`). pub(crate) compose: menus::Compose, /// The file-name box while it is a text field: the text being typed. pub file_edit: Option, /// While dragging across depth checkboxes: the state they are set to. depth_drag: Option, /// Mouse hints shown instead of the tool's while the pointer is over a /// panel with its own button functions (the attribute buttons). pub hint_override: Option<(&'static str, &'static str, &'static str)>, /// Alt + click as the middle button, Ctrl + click as the right one (macOS). pub ctrl_click: input::CtrlClick, /// Linux, Windows: left + right together as the middle button. pub chord: input::Chord, /// Input queued by the control channel (`ui.pointer`, `ui.key`). pub injector: inject::Injector, /// Labelled widgets of the last frame (filled once `attach` ran). pub widgets: inject::Registry, /// Requests waiting for their injected input to be fed: reply, id, result, sequence. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub(crate) input_waiters: Vec<(std::sync::mpsc::Sender, Value, Value, u64)>, /// xfig's keyboard accelerators (Meta menu keys, Ctrl mode keys). pub xfig_keys: bool, /// A classic menu to pop up on the next frame (xfig's Meta-F/E/V/H). pub open_menu: Option<&'static str>, /// The Unit/Scale settings window (right click on the units box). pub unit_panel_open: bool, /// The Styles window (E6). pub styles: styles::StylesWindow, /// The Character Map window (E5). pub charmap: charmap::CharMap, /// Open/Merge with preview (W10-12). pub file_panel: file_panel::FilePanel, /// The library panel (D6). pub library: library::LibraryPanel, /// The shortcut sheet (E8). pub keys_sheet: keys_sheet::KeysSheet, /// Command search (W18). pub command_search: command_search::CommandSearch, /// F1 context help notes (W18). pub context_help: context_help::ContextHelp, /// Which text flag the Text Flags button shows (xfig's `cur_flagshown`: /// hidden, TeX, rigid; the right and middle buttons cycle). pub flag_shown: usize, /// The mouse cheat sheet (W18). pub mouse_sheet: help_pages::MouseSheet, /// The "What's new" window (W18). pub whats_new_open: bool, /// The typed-coordinates window (xfig's keyboard panel, Ctrl+K). pub point_entry: point_entry::PointEntry, /// The override set during the previous frame, shown in this one. pub hint_shown: Option<(&'static str, &'static str, &'static str)>, pub pending: Option, /// Files the system asked us to open that wait their turn (P3). system_open_queue: Vec, /// The Export panel's export running on its thread (W10-13). #[cfg(not(target_arch = "wasm32"))] pub export_bg: Option, /// "Install TeX Gyre fonts…" running on its thread (F1). #[cfg(not(target_arch = "wasm32"))] pub tex_gyre: Option, /// The file the last background export wrote ("Show in folder"). #[cfg(not(target_arch = "wasm32"))] pub last_export: Option, /// The Digitize window (S4-8). pub digitize: digitize::Digitize, /// The Plot dialog (E12). pub plot_dialog: plot_dialog::PlotDialog, /// Autorefresh mode and the changed-on-disk question (S4-9). pub reload: reload::Reload, /// The action (quit, new, open) that resumes once the Save All / Save /// Part query of an open compound has saved the figure. pub after_save: Option, /// File pickers (native dialogs; replaced in tests). pub file_dialogs: Box, /// Files the browser picked, waiting to be opened (C2). pub web_inbox: web_io::WebInbox, /// A picture the browser picked for the picture panel's Browse/Relink /// (kept in memory under this file name); the panel takes it. pub web_relink_name: Option, quit_confirmed: bool, /// Open pop-ups, menus, windows and dialogs as of the last frame (for `ui.state`). #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub(crate) ui_open: Value, #[cfg(not(target_arch = "wasm32"))] control: Option, #[cfg(not(target_arch = "wasm32"))] screenshot_waiters: Vec<(String, std::sync::mpsc::Sender, Value)>, } impl App { /// Create the app without eframe (used by `new` and by UI tests). pub fn with_engine(engine: Engine) -> App { App { engine, canvas: canvas::CanvasState::default(), dialogs: dialogs::Dialogs::default(), message: None, messages: Vec::new(), show_depths: true, show_balloons: true, theme_request: None, capture: capture::Capture::default(), theme: "system".to_string(), ui_scale: 1.0, button_3d: true, mode_popup: None, #[cfg(not(target_arch = "wasm32"))] autosave: None, #[cfg(target_arch = "wasm32")] web_autosave: None, web_restorable: None, recoverable: Vec::new(), frame_times: std::collections::VecDeque::new(), settings: settings::Settings::default(), compose: menus::Compose::default(), file_edit: None, depth_drag: None, hint_override: None, ctrl_click: input::CtrlClick::default(), chord: input::Chord::default(), injector: inject::Injector::default(), widgets: inject::Registry::default(), input_waiters: Vec::new(), hint_shown: None, unit_panel_open: false, styles: styles::StylesWindow::default(), charmap: charmap::CharMap::default(), file_panel: file_panel::FilePanel::default(), library: library::LibraryPanel::default(), keys_sheet: keys_sheet::KeysSheet::default(), command_search: command_search::CommandSearch::default(), context_help: context_help::ContextHelp::default(), flag_shown: 0, mouse_sheet: help_pages::MouseSheet::default(), whats_new_open: false, point_entry: point_entry::PointEntry::default(), xfig_keys: true, open_menu: None, pending: None, system_open_queue: Vec::new(), #[cfg(not(target_arch = "wasm32"))] export_bg: None, #[cfg(not(target_arch = "wasm32"))] tex_gyre: None, #[cfg(not(target_arch = "wasm32"))] last_export: None, digitize: digitize::Digitize::default(), plot_dialog: plot_dialog::PlotDialog::default(), reload: reload::Reload::default(), after_save: None, file_dialogs: Box::new(dialogs::NativeDialogs), web_inbox: web_io::WebInbox::default(), web_relink_name: None, quit_confirmed: false, ui_open: Value::Null, #[cfg(not(target_arch = "wasm32"))] control: None, #[cfg(not(target_arch = "wasm32"))] screenshot_waiters: Vec::new(), } } /// Create the app. `control_port` starts the JSON-lines control server (native only). pub fn new(cc: &eframe::CreationContext<'_>, engine: Engine, control_port: Option) -> App { cc.egui_ctx.set_theme(egui::ThemePreference::System); // Mutated only by the native control server below. #[cfg_attr(target_arch = "wasm32", allow(unused_mut))] let mut app = App::with_engine(engine); // TeX Gyre fonts installed earlier (F1) join the font lookup. #[cfg(not(target_arch = "wasm32"))] tex_gyre::register_installed(); // Copy as picture (E14) uses the system clipboard; tests and wasm have none. #[cfg(not(target_arch = "wasm32"))] app.engine .set_picture_clipboard(Some(Box::new(clipboard::SystemPictureClipboard::default()))); // Print (B5, B5b) shows the operating system's print dialog (the browser's viewer on the web). app.engine.set_spooler(Box::new(print_dialog::OsSpooler)); #[cfg(not(target_arch = "wasm32"))] if let Some(port) = control_port { app.attach(&cc.egui_ctx); match control::Server::start(port, cc.egui_ctx.clone()) { Ok(s) => { log::info!("control channel listening on 127.0.0.1:{port}"); app.control = Some(s); } Err(e) => { log::error!("control channel: failed to bind 127.0.0.1:{port}: {e}"); app.error(format!("Control port {port} unavailable: {e}")); } } } #[cfg(target_arch = "wasm32")] let _ = control_port; #[cfg(target_arch = "wasm32")] { let mut w = web_autosave::WebAutosave::new(web_autosave::BrowserStore); app.web_restorable = w.find(); app.web_autosave = Some(w); } #[cfg(not(target_arch = "wasm32"))] if let Some(dir) = rustfig_engine::recovery::default_dir() { app.enable_recovery(dir); } app.settings = settings::Settings::platform(); settings::load_and_apply(&mut app); app } /// Keep crash-recovery copies in `dir` and offer the drawings that /// stopped instances left there (A6). #[cfg(not(target_arch = "wasm32"))] pub fn enable_recovery(&mut self, dir: std::path::PathBuf) { self.engine.recovery_dir = Some(dir.clone()); self.autosave = Some(autosave::AutoSave::new(dir)); if let Ok(v) = self.engine.execute("file.recoverable", &serde_json::json!({})) { self.recoverable = v .get("drawings") .and_then(serde_json::Value::as_array) .into_iter() .flatten() .filter_map(|d| { let id = d.get("id")?.as_str()?.to_string(); let original = d .get("original") .and_then(serde_json::Value::as_str) .map(str::to_string); Some((id, original)) }) .collect(); } } fn push_message(&mut self, m: Message) { self.messages.push(m.clone()); if self.messages.len() > MAX_MESSAGES { let extra = self.messages.len() - MAX_MESSAGES; self.messages.drain(..extra); } self.message = Some(m); } /// Show what a mouse event made the engine say (`Engine::status`) on /// the message line; pointer moves (lengths while drawing) replace the /// line without filling the message history. pub fn show_tool_status(&mut self, transient: bool) { let s = self.engine.status().to_string(); if s.is_empty() { return; } self.engine.set_status(""); if transient { self.message = Some(Message { text: s, error: false }); } else { self.info(s); } } pub fn info(&mut self, text: impl Into) { self.push_message(Message { text: text.into(), error: false, }); } pub fn error(&mut self, text: impl Into) { self.push_message(Message { text: text.into(), error: true, }); } /// Run a command (engine or UI-only). Errors are shown in the status bar. pub fn run(&mut self, id: &str, params: Value) -> Option { match self.try_run(id, ¶ms) { Ok(v) => Some(v), Err(e) => { self.error(e); None } } } /// Run a command and return its result or error message. pub fn try_run(&mut self, id: &str, params: &Value) -> Result { if menus::find_ui(id).is_some() { return menus::run_ui(self, id, params); } let r = self.engine.execute(id, params).map_err(|e| e.message); if r.is_ok() && !self.engine.status().is_empty() { let s = self.engine.status().to_string(); self.info(s); self.engine.set_status(""); } r } /// Whether a command is currently enabled. pub fn enabled(&self, id: &str) -> bool { if let Some(c) = rustfig_engine::cmd::find(id) { return (c.enabled)(&self.engine); } menus::find_ui(id).is_some() } fn handle_requests(&mut self, ctx: &egui::Context) { for r in self.engine.take_requests() { match r { // The clipboard's text arrives as a paste event next frame, // which the text tool types. UiRequest::PasteText => ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaste), UiRequest::EditObject { id } => self.dialogs.open_edit(&self.engine, id), UiRequest::OpenLibrary => { let roots = self.engine.library_roots(); let source = self .engine .prefs .get("library_dir") .and_then(|v| v.as_str().map(str::to_string)) .unwrap_or_else(|| "~/xfiglib".to_string()); self.library.start_scan(roots, source); } UiRequest::PlotDialog { rect, edit } => match edit { None => self .plot_dialog .open_new(rect.map(|r| [r.min.x, r.min.y, r.width(), r.height()])), Some(id) => match self.try_run("plot.get", &json!({"id": id.0})) { Ok(v) => self.plot_dialog.open_edit(&v, id.0), Err(e) => self.error(e), }, }, UiRequest::EditFigureComments => self.dialogs.open_figure_comments(&self.engine), UiRequest::EditSplinePoint { id, index } => self.dialogs.open_spline_point(&self.engine, id, index), // The browser has no paths: its picker answers later (web_io). UiRequest::PickPicture { rect, keep_aspect } if self.file_dialogs.is_web() => { self.web_pick( web_io::WebPurpose::PictureBox { rect: [rect.min.x, rect.min.y, rect.width(), rect.height()], keep_aspect, }, "Pictures", dialogs::PICTURE_EXTS, ); } UiRequest::PickPicture { rect, keep_aspect } => { if let Some(path) = self.file_dialogs.pick_file("Pictures", dialogs::PICTURE_EXTS) { self.add_picture( &path, [rect.min.x, rect.min.y, rect.width(), rect.height()], keep_aspect, ); } } } } } /// Open the browser's file picker for `purpose`; the file arrives some /// frames later in the inbox (C2). pub fn web_pick(&mut self, purpose: web_io::WebPurpose, what: &str, exts: &[&str]) { self.web_inbox.request(purpose); self.file_dialogs.pick_web(what, exts, &self.web_inbox); } /// Picture mode's result: the picture in the box drawn (`[x, y, w, h]`), /// then xfig's edit panel for it. pub fn add_picture(&mut self, path: &str, rect: [f64; 4], keep_aspect: bool) { let [x, y, w, h] = rect; let added = self.run( "shape.picture", json!({"path": path, "x": x, "y": y, "width": w, "height": h, "keep_aspect": keep_aspect}), ); // xfig opens the picture's edit panel after the box is drawn. if let Some(id) = added.and_then(|v| v.get("id").and_then(serde_json::Value::as_u64)) { self.dialogs.open_edit(&self.engine, rustfig_model::ObjectId(id)); } } /// Ask before discarding unsaved changes. /// Whether quitting was confirmed (the window closes on the next frame). pub fn quit_requested(&self) -> bool { self.quit_confirmed } pub fn guard_unsaved(&mut self, action: Pending) { if self.engine.is_modified() { self.pending = Some(action); } else { self.do_pending(action); } } pub(crate) fn do_pending(&mut self, action: Pending) { match action { Pending::New => { self.run("file.new", json!({})); self.engine.set_modified(false); } Pending::OpenWeb(pick) => web_io::open_pick(self, &pick), Pending::Open(path) if path.is_none() && self.file_dialogs.is_web() => { self.web_pick(web_io::WebPurpose::Open, "Figures", dialogs::FIGURE_EXTS); } Pending::Open(path) => { let path = path.or_else(|| self.file_dialogs.pick_file("Figures", dialogs::FIGURE_EXTS)); if let Some(p) = path { dialogs::open_or_merge(self, &p, false, json!({})); } } Pending::Quit => { self.quit_confirmed = true; } } } fn window_title(&self) -> String { let name = self .engine .path() .and_then(|p| std::path::Path::new(p).file_name()) .and_then(|n| n.to_str()) .unwrap_or("Untitled"); format!( "{}{} — RustFig", name, if self.engine.is_modified() { " •" } else { "" } ) } } impl eframe::App for App { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.ui(ctx); } fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { self.feed_input(raw_input); if self.chord.waiting() { // A held-back press must get out after the chord interval. ctx.request_repaint_after(std::time::Duration::from_secs_f64(input::CHORD_INTERVAL)); } } fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { // Unsaved work the user didn't discard keeps its recovery copy. #[cfg(not(target_arch = "wasm32"))] if let Some(a) = &mut self.autosave { if self.quit_confirmed || !self.engine.is_modified() { a.finish(); } } } } impl App { /// Start collecting the labelled widgets for `ui.state` and `ui.click_label`. pub fn attach(&mut self, ctx: &egui::Context) { self.widgets = inject::install_registry(ctx); } /// Add the next injected input step to a frame's raw input, then apply the /// platform conventions (what `raw_input_hook` does for every frame). pub fn feed_input(&mut self, raw: &mut egui::RawInput) { self.injector.feed(raw); self.ctrl_click.apply(raw, platform::is_mac()); if !platform::is_mac() { self.chord.apply(raw); } } /// Run one UI frame (guarded against panics). pub fn ui(&mut self, ctx: &egui::Context) { key_font::install(ctx); platform::note_os(ctx); #[cfg(not(target_arch = "wasm32"))] let start = std::time::Instant::now(); let frame_result = rustfig_engine::guard::catch(|| self.frame(ctx)); #[cfg(not(target_arch = "wasm32"))] { if self.frame_times.len() >= 120 { self.frame_times.pop_front(); } self.frame_times.push_back(start.elapsed().as_secs_f64() * 1000.0); } if let Err(msg) = frame_result { // xfig's emergency save: keep the work before reporting. #[cfg(not(target_arch = "wasm32"))] if let Some(a) = &mut self.autosave { a.emergency(&self.engine); } #[cfg(target_arch = "wasm32")] { let (modified, rev) = (self.engine.is_modified(), self.engine.revision()); if let Some(w) = &mut self.web_autosave { let engine = &mut self.engine; w.emergency(modified, rev, || web_fig(engine)); } } self.error(format!("internal error (please report): {msg}")); } } fn frame(&mut self, ctx: &egui::Context) { apply_balloons(ctx, self.show_balloons); if let Some(t) = self.theme_request.take() { ctx.set_theme(t); } if (ctx.zoom_factor() - self.ui_scale).abs() > 1e-3 { ctx.set_zoom_factor(self.ui_scale); } #[cfg(not(target_arch = "wasm32"))] self.poll_control(ctx); self.poll_capture(ctx); // A figure, or a PDF/SVG/PNG export with the figure embedded (E16), // dropped on the window opens. The picture panel takes its own drops. self.web_inbox.attach(ctx); #[cfg(target_arch = "wasm32")] web_fetch::attach(ctx); if self.dialogs.edit.is_none() { // A path on the desktop; the browser gives the bytes and a name (C2). let dropped = ctx.input(|i| { i.raw.dropped_files.iter().find_map(|f| match (&f.path, &f.bytes) { (Some(p), _) => p.to_str().map(|p| Pending::Open(Some(p.to_string()))), (None, Some(b)) => Some(Pending::OpenWeb(web_io::WebPick { name: f.name.clone(), bytes: b.to_vec(), })), (None, None) => None, }) }); // Asked by the system (Finder, `open -a`, P3): the first one now, the rest after. let dropped = dropped.or_else(|| { if self.pending.is_some() { return None; } self.system_open_queue .extend(rustfig_os::open_docs::take_opened_files()); if self.system_open_queue.is_empty() { return None; } let p = self.system_open_queue.remove(0); p.to_str().map(|p| Pending::Open(Some(p.to_string()))) }); if let Some(action) = dropped { self.guard_unsaved(action); } } // The picker's answer; for Open the unsaved changes are asked after it, // and an open edit panel waits (its own Relink pick is for it). if self.pending.is_none() { let edit_open = self.dialogs.edit.is_some(); for (purpose, pick) in self.web_inbox.take() { if purpose == web_io::WebPurpose::Open { if edit_open || self.pending.is_some() { self.web_inbox.requeue(purpose, pick); } else { self.guard_unsaved(Pending::OpenWeb(pick)); } } else { web_io::handle_pick(self, purpose, &pick); } } } let tokens = theme::Tokens::new(ctx.style().visuals.dark_mode, self.button_3d); // Panels set the hint override while hovered; show last frame's. self.hint_shown = self.hint_override.take(); context_help::frame(self, ctx); menus::handle_shortcuts(self, ctx); egui::TopBottomPanel::top("xfig_top").show(ctx, |ui| panels::classic_top(self, ui, &tokens)); let ind = egui::TopBottomPanel::bottom("indicator").show(ctx, |ui| attributes::attribute_panel(self, ui, &tokens)); self.context_help .zones .push((ind.response.rect, context_help::INDICATOR_TOPIC)); egui::SidePanel::left("modes") .resizable(false) .exact_width(panels::MODE_PANEL_WIDTH) .frame(egui::Frame::side_top_panel(&ctx.style()).inner_margin(panels::MODE_PANEL_MARGIN)) .show(ctx, |ui| panels::mode_panel(self, ui, &tokens)); if self.show_depths { let depths = egui::SidePanel::right("depths") .default_width(140.0) .show(ctx, |ui| panels::depth_panel(self, ui, &tokens)); self.context_help .zones .push((depths.response.rect, context_help::DEPTH_TOPIC)); } egui::CentralPanel::default() .frame(egui::Frame::NONE) .show(ctx, |ui| canvas::show(self, ui, &tokens)); dialogs::show(self, ctx); #[cfg(not(target_arch = "wasm32"))] export_bg::tick(self, ctx); #[cfg(not(target_arch = "wasm32"))] tex_gyre::tick(self, ctx); digitize::show(self, ctx); plot_dialog::show(self, ctx); charmap::show(self, ctx); file_panel::show(self, ctx); library::show(self, ctx); keys_sheet::show(self, ctx); command_search::show(self, ctx); help_pages::show_mouse_sheet(self, ctx); help_pages::show_whats_new(self, ctx); reload::tick(self, ctx); reload::ask(self, ctx); panels::mode_popup(self, ctx, &tokens); panels::close_compound_popup(self, ctx); self.handle_requests(ctx); settings::tick(self); #[cfg(target_arch = "wasm32")] if self.web_restorable.is_none() { let now = ctx.input(|i| i.time); let (modified, rev) = (self.engine.is_modified(), self.engine.revision()); if let Some(w) = &mut self.web_autosave { let engine = &mut self.engine; let wait = w.tick(modified, rev, now, || web_fig(engine)); ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait)); } } #[cfg(not(target_arch = "wasm32"))] if let Some(a) = &mut self.autosave { if let Some(wait) = a.tick(&self.engine, std::time::Instant::now()) { ctx.request_repaint_after(wait); } } ctx.send_viewport_cmd(egui::ViewportCommand::Title(self.window_title())); if ctx.input(|i| i.viewport().close_requested()) && !self.quit_confirmed && self.engine.is_modified() { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); self.pending = Some(Pending::Quit); } if self.quit_confirmed { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } } #[cfg(not(target_arch = "wasm32"))] fn poll_control(&mut self, ctx: &egui::Context) { // Screenshots arrive as events a frame or two after the request. let shots: Vec> = ctx.input(|i| { i.raw .events .iter() .filter_map(|e| match e { egui::Event::Screenshot { image, .. } => Some(image.clone()), _ => None, }) .collect() }); if let Some(img) = shots.last() { for (path, reply, id) in self.screenshot_waiters.drain(..) { let rgba: Vec = img.pixels.iter().flat_map(|c| c.to_srgba_unmultiplied()).collect(); let res = rustfig_render::raster::encode_rgba_png(img.size[0] as u32, img.size[1] as u32, &rgba) .and_then(|png| std::fs::write(&path, png).map_err(|e| e.to_string())); let msg = match res { Ok(()) => json!({"id": id, "result": {"path": path, "width": img.size[0], "height": img.size[1]}}), Err(e) => json!({"id": id, "error": {"code": "io", "message": e}}), }; let _ = reply.send(msg.to_string()); // the client may have gone away } } // Answer requests whose injected input has been fed; keep frames coming meanwhile. let (done, waiting): (Vec<_>, Vec<_>) = std::mem::take(&mut self.input_waiters) .into_iter() .partition(|w| self.injector.reached(w.3)); self.input_waiters = waiting; for (reply, id, result, _) in done { let _ = reply.send(json!({"id": id, "result": result}).to_string()); // the client may have gone away } if !self.injector.is_idle() { ctx.request_repaint(); } self.ui_open = control::open_state(self, ctx); let reqs = match &self.control { Some(s) => s.drain(), None => return, }; for r in reqs { if r.method == "ui.screenshot" { let path = r .params .get("path") .and_then(Value::as_str) .unwrap_or("rustfig-screenshot.png") .to_string(); self.screenshot_waiters.push((path, r.reply, r.id)); ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default())); ctx.request_repaint(); continue; } if matches!( r.method.as_str(), "ui.pointer" | "ui.key" | "ui.click_label" | "ui.modifiers" ) { let queued = control::input_steps(self, &r.method, &r.params) .and_then(|(steps, result)| self.injector.push(steps).map(|seq| (seq, result))); match queued { Ok((seq, result)) => self.input_waiters.push((r.reply, r.id, result, seq)), Err(m) => { let msg = json!({"id": r.id, "error": {"code": "params", "message": m}}); let _ = r.reply.send(msg.to_string()); // the client may have gone away } } ctx.request_repaint(); continue; } let res = control::handle(self, &r.method, &r.params); let msg = match res { Ok(v) => json!({"id": r.id, "result": v}), Err((code, m)) => json!({"id": r.id, "error": {"code": code, "message": m}}), }; let _ = r.reply.send(msg.to_string()); // the client may have gone away ctx.request_repaint(); } } } /// Tooltip delay with balloons on (egui's default) and a delay that never /// passes with them off. const BALLOON_DELAY: f32 = 0.5; const NO_BALLOONS: f32 = 1.0e9; fn apply_balloons(ctx: &egui::Context, on: bool) { let want = if on { BALLOON_DELAY } else { NO_BALLOONS }; if ctx.style().interaction.tooltip_delay != want { ctx.style_mut(|s| s.interaction.tooltip_delay = want); } } /// Room for a dialog's content: the window's height less its title bar and /// frame. pub(crate) fn dialog_max_height(ctx: &egui::Context) -> f32 { (ctx.content_rect().height() - 56.0).max(120.0) } /// A dialog's content in a scroll area: as tall as the content, scrolling /// vertically when it doesn't fit on the screen. (Horizontal scrolling would /// give rows unlimited width, which right-aligned and full-width rows fill.) pub(crate) fn dialog_body(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -> R { // A window offers its content egui's default height (420 points); // the content may be as tall as the screen. let max_h = dialog_max_height(ui.ctx()); ui.set_max_height(max_h); egui::ScrollArea::vertical() .max_height(max_h) .auto_shrink([true, true]) .show(ui, add) .inner } /// The drawing as Fig text with its file name, for the browser autosave. #[cfg(target_arch = "wasm32")] fn web_fig(engine: &mut Engine) -> web_autosave::Snapshot { let v = engine .execute("file.save_data", &serde_json::json!({"format": "fig"})) .ok()?; let data = v.get("data")?.as_str()?.to_string(); Some((data, engine.path().map(str::to_string))) } .