diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index da9750747..c10b402c1 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -4,6 +4,13 @@ use super::services::{DialogFuture, PickFuture, Picked, RepaintWaker, Services}; use super::state::{LEFT_PANEL, LeftTab, MOBILE_STRIP, MobilePanel, NARROW_WIDTH, PANEL_DEFAULT, RIGHT_PANEL, RightTab, TitleBar, fit_panels}; use super::*; +mod clipboard; +mod commands; +mod control; +mod files; +mod frame; +mod ime; + impl FigApp { /// Construct `FigApp` from a session and services, taking panel widths and pages height /// from preferences and initializing UI, canvas, clocks, capture/IME and transient channel @@ -41,7 +48,7 @@ impl FigApp { control_input_queued: false, applied_frame: None, clock: FrameClock::default(), - capture: control::CaptureState::default(), + capture: crate::control::CaptureState::default(), logic_ticked: false, ui_frame_stamp: None, ime: egui::ime_context::ImeContextTracker::new(), @@ -51,703 +58,6 @@ impl FigApp { } } - /// Dismiss the composer after a successful switch away from the Comment tool. - pub(crate) fn clear_comment_draft_after_tool_set(&mut self, id: &str, succeeded: bool) { - if id == "tool.set" && succeeded && self.session.tool.tool != figcraft_engine::tools::ToolId::Comment { - self.ui.comments.draft = None; - } - } - - /// Run a command, surfacing errors as a toast. Returns the result. - pub fn run(&mut self, id: &str, params: Value) -> Option { - let layers_generation = self.session.clipboard.layers_generation; - let r = self.session.execute(id, ¶ms); - self.clear_comment_draft_after_tool_set(id, r.is_ok()); - self.handle_ui_requests(); - self.invalidate_synthetic_copy(layers_generation); - // A command may change the file the window shows (`doc.switch`, `doc.nextTab`, - // `file.open`/`file.close`). Record that presentation before returning, so an in-flight - // screenshot admitted on the old file cannot accept pixels from the new one. - self.observe_presentation(); - r.map_err(|e| self.failed(id, &e)).ok() - } - - /// A programmatic command that changed the engine clipboard never wrote the system clipboard: - /// forget the old system mirror so a later user paste does not trust it as our own copy. - /// `before` is the layers generation captured before the command ran. - pub(crate) fn invalidate_synthetic_copy(&mut self, before: u64) { - if self.control_synthetic && self.session.clipboard.layers_generation != before { - self.ui.last_copy = None; - } - } - - /// Record the file the window drawing now shows, with a monotonic generation that changes on - /// every switch (an A→B→A switch is two generations, never the admitted one again). - pub(crate) fn observe_presentation(&mut self) { - let window = self.windows.current; - self.windows.presentation(&self.session, window); - } - - /// [`run`](Self::run) for something the user did (a dialog, a drop, a paste, a recent file): - /// a file it imports shows its import report. Programmatic calls use `run` and never open one. - pub fn run_user(&mut self, id: &str, params: Value) -> Option { - let result = self.run(id, params.clone()); - if let Some(result) = &result { - import_report::capture(self, id, ¶ms, result); - } - missing_fonts::check_later(self, id, result.as_ref()); - result - } - - /// Show command `id`'s error. - pub(crate) fn failed(&mut self, id: &str, e: &figcraft_engine::EngineError) { - let msg = e.to_string(); - log::warn!("{id}: {msg}"); - self.show_error(msg); - } - - /// Show an error as a toast, kept as the last error. - pub(crate) fn show_error(&mut self, msg: String) { - self.toast(msg.clone()); - self.ui.last_error = Some(msg); - } - - /// Run a command that may talk to a server without waiting for it (the engine's - /// [`start_background`](Session::start_background)): `done` gets the result, at once or when - /// the answer arrives (the network wakes the window then); errors show as with [`run`](Self::run). - /// [`Session::waiting_for`] tells the panels to show that it's on its way. - pub fn run_background(&mut self, ctx: &egui::Context, id: &str, params: Value, done: Done) { - self.waiting.retain(|(c, _)| c != id); - let r = self.session.start_background(id, params); - self.handle_ui_requests(); - match r { - Ok(Some(v)) => done(self, ctx, v), - Ok(None) => self.waiting.push((id.to_string(), done)), - Err(e) => self.failed(id, &e), - } - } - - /// Stop waiting for command `id` (Cancel, or its window closed): its answer is dropped. - pub fn cancel_background(&mut self, id: &str) { - self.session.cancel_background(id); - self.waiting.retain(|(c, _)| c != id); - } - - /// Hand the results of background commands that finished to what's waiting for them. - fn background_done(&mut self, ctx: &egui::Context) { - while let Some((id, r)) = self.session.take_background_done() { - self.handle_ui_requests(); - let done = self.waiting.iter().position(|(c, _)| *c == id).map(|i| self.waiting.remove(i).1); - match (r, done) { - (Ok(v), Some(done)) => done(self, ctx, v), - (Ok(_), None) => {} - (Err(e), _) => self.failed(&id, &e), - } - } - } - - /// Show what `command params` would do on the canvas as a live preview (`text.preview`) while - /// a font family, font style, text style, named instance or axis slider is hovered or dragged. Call it every frame - /// the preview should stay: it ends on the first frame nothing asks for it. - pub fn preview_text(&mut self, ctx: &egui::Context, command: &str, params: Value) { - self.ui.text_preview_wanted = true; - let revision = self.session.ds().map_or(0, |d| d.revision); - if let Some((c, p, rev)) = &self.ui.text_preview - && c == command - && *p == params - && (*rev == revision || figcraft_engine::textpreview::active(&self.session)) - { - return; - } - let shown = self.session.execute("text.preview", &json!({"command": command, "params": params})).is_ok(); - let revision = self.session.ds().map_or(0, |d| d.revision); - self.ui.text_preview = shown.then(|| (command.to_string(), params, revision)); - // The canvas drew before the panels: show the preview next frame. - ctx.request_repaint(); - } - - /// End the live text preview the panels show (Escape, or nothing hovered any more). - pub fn end_text_preview(&mut self, ctx: &egui::Context) { - if self.ui.text_preview.take().is_some() { - let _ = self.session.execute("text.endPreview", &json!({})); - ctx.request_repaint(); - } - } - - /// Dev Mode (the Inspect tab): the canvas measures on hover without Alt and edits nothing; - /// entering it ends editing text or vectors. - fn sync_dev_mode(&mut self) { - let dev = self.ui.right_tab == RightTab::Inspect; - if dev && !self.session.tool.dev_mode { - let _ = self.session.drop_gesture(); - let editing = self.session.ds().map(|d| (d.text_edit.is_some(), d.vector_edit.is_some())).unwrap_or_default(); - for (on, cmd) in [(editing.0, "text.edit.end"), (editing.1, "vector.edit.end")] { - if on { - self.run(cmd, json!({})); - } - } - } - self.session.tool.dev_mode = dev; - } - - /// Leaving Design ends a pen path as Escape does and goes back to Move: Prototype and Inspect - /// don't draw (#307). Checked as the frame starts (the control channel) and after the panels - /// and shortcuts (the mode switcher, the tabs, Shift+E/Shift+D), before the canvas sees more. - fn end_pen_outside_design(&mut self) { - if self.ui.right_tab == self.ui.last_right_tab { - return; - } - self.ui.last_right_tab = self.ui.right_tab; - if self.ui.right_tab != RightTab::Design && self.session.tool.tool == figcraft_engine::tools::ToolId::Pen { - self.run("tool.set", json!({"tool": "move"})); - } - } - - /// Once a frame, after the panels: a preview nothing asked for this frame ends. - fn end_unwanted_text_preview(&mut self, ctx: &egui::Context) { - if !std::mem::take(&mut self.ui.text_preview_wanted) { - self.end_text_preview(ctx); - } - } - - /// Finish the frame that applies an injected batch: acknowledge it once the pass is presented, - /// and stop fencing the system clipboard. Only the viewport whose batch this frame took acts. - pub(crate) fn end_input_frame(&mut self, ctx: &egui::Context) { - if self.applied_frame != Some(ctx.viewport_id()) { - return; - } - self.applied_input = None; - if ctx.will_discard() { - // Keep `applied_frame`/`applied_acks`: the rerun pass must present (and acknowledge) - // the same frame without replaying the input. - return; - } - self.applied_frame = None; - for ack in self.applied_acks.drain(..) { - let _ = ack.tx.send(ack.reply); - } - self.control_synthetic = false; - } - - /// The app's monotonic clock in seconds, also usable from logic-only callbacks (recovery, - /// control deadlines). Never `ctx.input(i.time)`, which is stale there. - pub fn now_secs(&self) -> f64 { - self.clock.now() - } - - /// Queue raw input injected through the control channel for the window drawing next, tagged - /// with the document it belongs to (a batch is dropped if another file is active by then). - pub(crate) fn push_control_input(&mut self, events: Vec) -> bool { - if events.is_empty() { - return false; - } - let doc = self.session.docs.get(self.session.active).map(|d| d.uid); - if !self.control_input.is_empty() && self.control_input_doc != doc { - // A queued batch belongs to the file that was open when it was injected; it cannot be - // merged with one for the file showing now. Its acknowledgement says so. - self.control_input.clear(); - let viewport = crate::windows::viewport(self.windows.current); - crate::windows::fail_input_acks(self, viewport); - } - // Always the live pane vector: while a child window draws, `windows::enter` has swapped - // its pane into the app, and `leave` swaps these events back into that pane. - self.control_input.extend(events); - self.control_input_doc = doc; - self.control_synthetic = true; - true - } - - /// The acknowledgements waiting for `viewport`'s input batch (other windows keep theirs). - pub(crate) fn take_input_acks(&mut self, viewport: egui::ViewportId) -> Vec { - let mut mine = Vec::new(); - for ack in std::mem::take(&mut self.input_acks) { - if ack.viewport == viewport { - mine.push(ack); - } else { - self.input_acks.push(ack); - } - } - mine - } - - /// Put `text` on the system clipboard. Input injected through the control channel never - /// replaces what the user copied: this is the one write path every copy command goes through. - pub(crate) fn clipboard_set_text(&mut self, text: &str) { - if self.control_synthetic { - return; - } - self.services.clipboard_set_text(text); - } - - /// [`Self::clipboard_set_text`] for an image (straight RGBA). - pub(crate) fn clipboard_set_png(&mut self, width: u32, height: u32, rgba: &[u8]) { - if self.control_synthetic { - return; - } - self.services.clipboard_set_png(width, height, rgba); - } - - pub fn toast(&mut self, msg: impl Into) { - self.ui.toast = Some((msg.into(), ToastClock::Raised)); - } - - /// Say that `n` documents left unsaved by a crash were opened again from recovery copies. - pub fn note_recovered(&mut self, n: usize) { - if n > 0 { - self.toast(i18n::trn("Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them", n, &[])); - } - } - - /// Act on UI requests queued by commands (dialogs, panels). - pub fn handle_ui_requests(&mut self) { - let reqs = std::mem::take(&mut self.session.ui_requests); - for r in reqs { - match r.kind.as_str() { - "dialog.about" => { - self.ui.about = true; - self.ui.about_tab = dialogs::AboutTab::About; - } - "dialog.preferences" => self.ui.preferences = true, - "dialog.missingFonts" => missing_fonts::request(self), - "panel.shortcuts" => self.ui.shortcuts_panel = !self.ui.shortcuts_panel, - "palette.actions" => menus::toggle_palette(&mut self.ui), - "panel.design" if self.session.doc().is_ok_and(figcraft_engine::board::is_board) => self.ui.board_panel = !self.ui.board_panel, - "panel.design" => self.ui.right_tab = RightTab::Design, - "board.editText" => { - if let Some(id) = r.data.get("id").and_then(figcraft_engine::params::id_of) - && let Err(e) = figcraft_engine::board::edit_text(&mut self.session, id) - { - self.toast(e.to_string()); - } - } - "panel.prototype" => self.ui.right_tab = RightTab::Prototype, - "panel.designPrototype" => { - self.ui.right_tab = if self.ui.right_tab == RightTab::Prototype { RightTab::Design } else { RightTab::Prototype }; - } - "panel.devMode" => { - self.ui.right_tab = if self.ui.right_tab == RightTab::Inspect { RightTab::Design } else { RightTab::Inspect }; - } - "panel.leftSidebar" => { - let shown = if self.ui.narrow { self.ui.mobile_panel == Some(MobilePanel::Left) } else { !self.ui.left_collapsed }; - if shown { - self.ui.close_left_panel(); - } else { - self.ui.open_left_panel(); - } - } - "panel.widths" => { - let width = |k: &str| r.data.get(k).and_then(Value::as_f64).map(|w| w as f32); - let (left, right) = (width("left"), width("right")); - let reset = left.is_none() && right.is_none(); - self.set_panel_widths( - left.unwrap_or(if reset { PANEL_DEFAULT } else { self.ui.left_width }), - right.unwrap_or(if reset { PANEL_DEFAULT } else { self.ui.right_width }), - ); - self.services.save_prefs(&self.session.prefs); - } - "focus.region" => self.ui.nav.step(r.data.get("step").and_then(Value::as_i64).unwrap_or(1).clamp(-4, 4) as i32), - "panel.layers" => self.ui.left_tab = LeftTab::File, - "panel.assets" => { - self.ui.left_tab = LeftTab::Assets; - self.ui.open_left_panel(); - self.ui.versions = Default::default(); - } - "panel.versionHistory" => versions::open(self), - "view.devCompare" => { - self.ui.dev.compare = dev_compare::CompareView::requested(&r.data); - } - "panel.libraries" => { - self.ui.left_tab = LeftTab::Assets; - self.ui.open_left_panel(); - self.ui.libraries = true; - } - "home" => self.ui.home = true, - "home.templates" => { - self.ui.home = true; - self.ui.home_view.section = home::Section::Templates; - } - "home.gettingStarted" => { - self.ui.home = true; - self.ui.home_view.section = home::Section::Recents; - self.services.save_prefs(&self.session.prefs); - } - "dialog.saveAs" => self.save_as_dialog(), - "dialog.colorProfile" => self.ui.color_profile = self.session.doc().ok().map(|d| d.color_profile), - "present" => { - if let Some(start) = r.data.get("start").and_then(figcraft_engine::params::id_of) { - tabs::open_present(self, start); - } - } - "present.options" => { - let v = &mut self.ui.present_view; - if let Some(s) = r.data.get("scale").and_then(|s| s.as_str()).and_then(present::PresentScale::parse) { - v.scale = s; - } - if let Some(f) = r.data.get("deviceFrame").and_then(|f| f.as_bool()) { - v.device_frame = f; - } - if let Some(h) = r.data.get("hotspotHints").and_then(|h| h.as_bool()) { - v.hotspot_hints = h; - } - if let Some(i) = r.data.get("inspect").and_then(|i| i.as_bool()) { - v.inspect = i; - } - if let Some(p) = self.ui.present.as_mut() { - p.view.device_frame = v.device_frame; - p.view.hotspot_hints = v.hotspot_hints; - p.view.inspect = v.inspect; - p.set_scale(v.scale); - } - } - "preview" => { - if let Some(start) = r.data.get("start").and_then(figcraft_engine::params::id_of) { - self.ui.preview = Some(present::Pane::new(start, &self.session)); - } - } - "preview.close" => self.ui.preview = None, - "open.help" => self.services.open_url("https://getartcraft.com/apps/figcraft"), - "layers.collapseAll" => layers::collapse_all(self), - "contextMenu" => self.ui.key_menu = true, - "selectArea" => self.start_key_box(), - "canvas.renameLabel" => { - self.ui.rename_label = r.data.get("id").and_then(figcraft_engine::params::id_of).map(|id| layers::Rename { id, start: true }); - } - "layers.rename" => { - let ids: Vec = - r.data.get("ids").and_then(Value::as_array).into_iter().flatten().filter_map(figcraft_engine::params::id_of).collect(); - if ids.len() > 1 { - dialogs::open_batch_rename(self, &ids); - } else { - layers::start_rename(self, &ids); - } - } - "comment.open" => { - if let Some(id) = r.data.get("id").and_then(Value::as_u64) { - self.ui.comments.show(id); - } - } - "comment.new" => { - let x = r.data.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0); - let y = r.data.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0); - comments::start_draft(self, x, y); - } - "dialog.unsaved" => self.toast(i18n::tr("This file has unsaved changes")), - "dialog.plugins" => self.ui.plugins_window = true, - "dialog.pluginSave" => plugins_ui::save_file(self, &r.data), - "dialog.cursorChat" => live::open_chat(self), - "window.new" | "window.focus" | "window.fullscreen" => self.window_requests.push(r), - _ => {} - } - } - if let Some(m) = self.session.message.take() { - self.toast(m); - } - } - - /// Set the widths asked for this window's side panels (within their limits); the main - /// window's are kept in the preferences. - pub fn set_panel_widths(&mut self, left: f32, right: f32) { - let within = |w: f32, range: std::ops::RangeInclusive| if w.is_finite() { w.clamp(*range.start(), *range.end()) } else { PANEL_DEFAULT }; - (self.ui.left_width, self.ui.right_width) = (within(left, LEFT_PANEL), within(right, RIGHT_PANEL)); - if self.windows.current == 0 { - self.session.prefs.panel_widths = (self.ui.left_width, self.ui.right_width); - } - } - - /// The edge between a side panel and the canvas, at `x` down `rows`: dragging it sizes the - /// panel (`grows`: +1 for the left panel, whose edge is on its right, −1 for the right one), a - /// double-click puts back the default width. Returns the width asked for, when it changed. - fn panel_edge(ui: &mut egui::Ui, name: &str, x: f32, rows: egui::Rangef, width: f32, grows: f32) -> Option { - let grab = ui.style().interaction.resize_grab_radius_side; - let rect = egui::Rect::from_x_y_ranges(x - grab..=x + grab, rows); - let id = egui::Id::unique(("panel_edge", name)); - // Not focusable: an edge is a pointer target, so it must not take Tab/F6 focus from the - // panels' controls (see `a11y::focusables`). - let resp = ui.interact(rect, id, egui::Sense::CLICK | egui::Sense::DRAG); - if resp.hovered() || resp.dragged() { - ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); - ui.painter().vline(x, rows, egui::Stroke::new(2.0, theme::ACCENT)); - } - if resp.double_clicked() { - return Some(PANEL_DEFAULT); - } - // The edge stays under the pointer where it was grabbed (also back from past a limit). - let at = resp.interact_pointer_pos().filter(|_| resp.dragged())?; - if resp.drag_started() { - let pressed = ui.input(|i| i.pointer.press_origin()).unwrap_or(at); - ui.data_mut(|d| d.insert_temp(id, pressed.x - x)); - } - let grabbed: f32 = ui.data(|d| d.get_temp(id)).unwrap_or(0.0); - let w = width + (at.x - grabbed - x) * grows; - (w != width).then_some(w) - } - - pub fn save_as_dialog(&mut self) { - if self.services.downloads() { - if let Some(r) = self.run("file.saveAs", json!({"data": true})) - && self.download(r["name"].as_str().unwrap_or_default(), &r) - { - self.run("file.markSaved", json!({})); - dialogs::file_saved(self, &r); - } - return; - } - let Ok(name) = self.session.doc().map(|d| d.name.clone()) else { return }; - self.pick_save(i18n::tr("Save as"), &format!("{name}.figcraft"), &["figcraft", "fig"], |app, mut p| { - if p.extension().is_none_or(OsStr::is_empty) { - if !p.set_extension("figcraft") { - app.toast(i18n::tr("Use the .figcraft or .fig extension.")); - return; - } - // This name wasn't confirmed by the system dialog: don't replace an existing file. - match p.try_exists() { - Ok(false) => {} - Ok(true) => { - app.toast(i18n::tr("A .figcraft file with this name already exists. Choose its full name to confirm replacement.")); - return; - } - Err(e) => { - app.toast(e.to_string()); - return; - } - } - } else if !p.extension().and_then(OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("figcraft") || e.eq_ignore_ascii_case("fig")) { - app.toast(i18n::tr("Use the .figcraft or .fig extension.")); - return; - } - if let Some(r) = app.run("file.saveAs", json!({"path": p.to_string_lossy()})) { - dialogs::file_saved(app, &r); - } - }); - } - - pub fn save(&mut self) { - // A branch tab saves its main file. - let tab = figcraft_engine::branches::file_tab(&self.session); - // A path ending in .fig is never saved over silently: a .fig destination is an export copy. - let native_path = self - .session - .docs - .get(tab) - .and_then(|d| d.path.as_deref()) - .is_some_and(|path| !Path::new(path).extension().and_then(OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("fig"))); - if native_path { - if self.run("file.save", json!({})).is_some() { - self.toast(i18n::tr("Saved")); - } - } else { - self.save_as_dialog(); - } - } - - pub fn open_dialog(&mut self) { - // Opening needs no file in front: it goes ahead even if that file closed meanwhile. - self.pick_file(i18n::tr("Open"), OPEN_EXTENSIONS, false, |app, file| { - if app.run_user("file.open", file).is_some() { - app.ui.home = false; - } - }); - } - - pub fn import_dialog(&mut self, place: bool) { - let exts: &[&str] = if place { - &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"] - } else { - &["svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff", "figcraft", "fig", "sketch"] - }; - let title = if place { i18n::tr("Place image") } else { i18n::tr("Import") }; - self.pick_file(title, exts, true, |app, file| { - app.run_user("file.import", file); - }); - } - - /// Pick a file to open or import; `done` gets it as `file.open` and `file.import` take it (a - /// path, or the name and contents where the platform has no paths). `pin`: as - /// [`FigApp::pick_open`]'s file in front. - fn pick_file(&mut self, title: &str, exts: &[&str], pin: bool, done: impl FnOnce(&mut FigApp, Value) + 'static) { - self.start_pick( - |services| -> PickFuture { - match services.pick_open_contents(title, exts) { - Some(read) => { - Box::pin(async move { read.await.map(|r| r.map_or_else(Picked::Unreadable, |(name, bytes)| Picked::Contents(name, bytes))) }) - } - None => { - let pick = services.pick_open(title, exts); - Box::pin(async move { pick.await.map(Picked::Path) }) - } - } - }, - pin, - move |app, picked| match picked.file_params() { - Ok(file) => done(app, file), - Err(why) => app.show_error(why), - }, - ); - } - - /// Pick a file to open. `done` runs with the file in front now back in front (and not at all - /// if that file closed meanwhile). - pub fn pick_open(&mut self, title: &str, exts: &[&str], done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { - self.start_dialog(|services| services.pick_open(title, exts), true, done); - } - - /// Pick where to save; `done` runs as [`FigApp::pick_open`]'s does. - pub fn pick_save(&mut self, title: &str, name: &str, exts: &[&str], done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { - self.start_dialog(|services| services.pick_save(title, name, exts), true, done); - } - - /// Hand a command result's base64 `data` over as a download named `name`, where the platform - /// saves by downloads rather than save dialogs (the browser, #969). Says so when nothing arrived. - pub(crate) fn download(&mut self, name: &str, result: &Value) -> bool { - use base64::Engine as _; - let bytes = result["data"].as_str().and_then(|d| base64::engine::general_purpose::STANDARD.decode(d).ok()); - if bytes.is_some_and(|b| self.services.download(name, &b)) { - return true; - } - self.toast(i18n::trf("Couldn't download {name}", &[("name", &name)])); - false - } - - /// Pick a folder; `done` runs as [`FigApp::pick_open`]'s does. - pub fn pick_folder(&mut self, title: &str, done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { - self.start_dialog(|services| services.pick_folder(title), true, done); - } - - /// Open a native dialog from the window drawing now, for the file in front (`pin`). - fn start_dialog(&mut self, start: impl FnOnce(&mut dyn Services) -> DialogFuture, pin: bool, done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { - self.start_pick( - |services| -> PickFuture { - let pick = start(services); - Box::pin(async move { pick.await.map(Picked::Path) }) - }, - pin, - move |app, picked| { - if let Picked::Path(p) = picked { - done(app, p); - } - }, - ); - } - - /// [`FigApp::start_dialog`] for any kind of pick. - fn start_pick(&mut self, start: impl FnOnce(&mut dyn Services) -> PickFuture, pin: bool, done: impl FnOnce(&mut FigApp, Picked) + 'static) { - if self.pending_dialog.is_some() { - return; - } - let doc = if pin { self.session.ds().ok().filter(|d| self.shows(d.uid)).map(|d| d.uid) } else { None }; - let future = start(self.services.as_mut()); - self.pending_dialog = Some(PendingDialog { future, picked: None, done: Box::new(done), doc, window: self.windows.current }); - if let Some(ctx) = &self.ui_context { - ctx.request_repaint(); - } - } - - /// Once the dialog has a path, its continuation runs in the window that holds its file, with - /// that file in front; then the tab in front before comes back (unless the dialog opened a file). - pub(crate) fn poll_dialog(&mut self, ctx: &egui::Context) { - let Some(d) = &mut self.pending_dialog else { return }; - if d.picked.is_none() { - let waker = Waker::from(Arc::new(RepaintWaker(ctx.clone()))); - let Poll::Ready(picked) = d.future.as_mut().poll(&mut TaskContext::from_waker(&waker)) else { return }; - d.picked = picked; - } - let (doc, origin, picked) = (d.doc, d.window, d.picked.is_some()); - let index = doc.map(|u| self.session.docs.iter().position(|ds| ds.uid == u)); - // Cancelled, or its file closed meanwhile. - if !picked || index == Some(None) { - self.pending_dialog = None; - return; - } - let window = match doc { - Some(u) => self.windows.of(u), - None if self.windows.exists(origin) => origin, - None => 0, - }; - if window != self.windows.current { - ctx.request_repaint_of(windows::viewport(window)); - return; - } - let Some(PendingDialog { picked: Some(picked), done, .. }) = self.pending_dialog.take() else { return }; - let before = self.session.ds().ok().map(|d| d.uid); - if let Some(Some(i)) = index { - self.session.active = i; - } - done(self, picked); - if doc.is_some() - && self.session.ds().ok().map(|d| d.uid) == doc - && let Some(i) = before.and_then(|u| self.session.docs.iter().position(|d| d.uid == u)) - { - self.session.active = i; - } - } - - /// Reduced motion: the preference or the system's setting. - pub fn reduce_motion(&self) -> bool { - self.session.prefs.reduce_motion || self.services.reduce_motion() - } - - /// The CJK, Bengali and Thai scripts the UI shows: the language's, the language picker's (Preferences open), - /// typed text's, and those in the open file's names and comments (read when it changes, not - /// mid-drag). Nothing is checked once every script has a face. - pub(crate) fn cjk_needed(&mut self, ctx: &egui::Context, installed: u8) -> u8 { - if installed == cjk::ALL { - return installed; - } - let mut need = i18n::current().map_or(0, |l| cjk::language(l.code)); - if self.ui.preferences { - need |= i18n::languages().iter().filter(|l| cjk::can_show(l.code)).fold(0, |m, l| m | cjk::language(l.code)); - } - need |= ctx.input(|i| { - i.events.iter().fold(0, |m, e| match e { - egui::Event::Text(t) | egui::Event::Ime(egui::ImeEvent::Preedit { text: t, .. } | egui::ImeEvent::Commit(t)) => m | cjk::scripts(t), - _ => m, - }) - }); - let dragging = ctx.input(|i| i.pointer.any_down()); - // Pans and zooms change the revision too: only a new version of the document is looked - // at, and only the layers it changed (a large file has hundreds of thousands of names). - let current = self.session.docs.get(self.session.active).filter(|_| !dragging).map(|d| (d.uid, d.doc.clone())); - if let Some((uid, doc)) = current { - let before = self.cjk_checked.take().filter(|(u, _)| *u == uid).map(|(_, b)| b); - if !before.as_ref().is_some_and(|b| Arc::ptr_eq(b, &doc)) { - let mut scan = |s: &str| need |= cjk::scripts(s); - scan(&doc.name); - match before.as_ref().and_then(|b| doc.changed_ids(b, CJK_CHANGES)) { - Some(ids) => ids.into_iter().filter_map(|id| doc.node(id)).for_each(|n| scan(&n.name)), - None => doc.nodes().for_each(|n| scan(&n.name)), - } - doc.comments.iter().flat_map(|c| std::iter::once(&c.first).chain(&c.replies)).for_each(|m| { - scan(&m.author); - scan(&m.text); - }); - } - self.cjk_checked = Some((uid, doc)); - } - need - } - - /// Work safe from a logic-only callback (`App::logic`, which also runs while hidden) and from - /// a normal UI pass: consumed control work, live polling, background completions, autosave and - /// recovery deadlines. It never reads UI input, so it must not scan events, run shortcuts or - /// lay out anything. - pub fn background_tick(&mut self, ctx: &egui::Context) { - control::background(self, ctx); - // What happened in a live session (the network woke us for it). - if self.session.live_poll() { - if let Some(m) = self.session.message.take() { - self.toast(live::failure_text(&m)); - } - self.background_done(ctx); - } - // Files that arrived without a command (joined, recovered) take the kept View toggles too. - self.session.apply_view_prefs(); - let now = self.clock.now(); - self.autosave_tick(ctx, now); - self.auto_save_tick(ctx, now); - updates_ui::tick(self, ctx); - self.logic_ticked = true; - } - /// UI-phase setup that needs a real current `Ui`: fonts/language/theme, interface zoom and the /// render worker. Runs once per frame (pass 0) for the main window, and once per frame for /// every child window (`windows::draw`), never on a logic-only tick. @@ -821,102 +131,6 @@ impl FigApp { } } - /// Every [`AUTOSAVE_SECS`], each open document with unsaved changes since its last version - /// gets an autosave checkpoint in its version history. - fn autosave_tick(&mut self, ctx: &egui::Context, now: f64) { - if now < self.ui.next_autosave { - // Wake up for it even if nothing else draws a frame by then. - repaint_in(ctx, self.ui.next_autosave - now); - return; - } - if self.ui.next_autosave > 0.0 { - let active = self.session.active; - for i in 0..self.session.docs.len() { - self.session.active = i; - let _ = self.session.execute("version.autosave", &json!({})); - } - self.session.active = active; - } - self.ui.next_autosave = now + AUTOSAVE_SECS; - repaint_in(ctx, AUTOSAVE_SECS); - } - - /// With auto-save on, files that have a path are saved [`AUTO_SAVE_SECS`] after they last - /// changed (not during a gesture, a live preview or while a version is shown). The file is - /// encoded on a thread of its own, so a big one doesn't hold up the window, and written here. - fn auto_save_tick(&mut self, ctx: &egui::Context, now: f64) { - let mut failed = self.auto_save_done(); - // The browser has no files to save to. - let on = !cfg!(target_arch = "wasm32") && self.session.prefs.auto_save; - let saving = &self.ui.auto_saving; - let due = |d: &&figcraft_engine::DocState| { - d.path.as_deref().is_some_and(|p| d.images_lost_saving_to(p).is_none()) - && d.branch_of.is_none() - && d.preview.is_none() - && d.is_dirty() - && !saving.iter().any(|(uid, _)| *uid == d.uid) - }; - let changes = if on { self.session.docs.iter().filter(due).map(|d| d.revision).reduce(u64::wrapping_add) } else { None }; - match changes { - None => self.ui.auto_save_at = None, - Some(changes) => { - let at = match self.ui.auto_save_at { - Some((at, c)) if c == changes && failed.is_none() => at, - _ => now + if failed.is_some() { AUTO_SAVE_RETRY_SECS } else { AUTO_SAVE_SECS }, - }; - self.ui.auto_save_at = Some((at, changes)); - if now < at || self.session.docs.iter().any(|d| d.interaction.is_some() || d.text_preview.is_some()) { - // Due but busy: look again in a second. - repaint_in(ctx, if now < at { at - now } else { 1.0 }); - } else { - self.ui.auto_save_at = None; - let files: Vec<_> = - self.session.docs.iter().filter(due).map(|d| (d.uid, figcraft_engine::cmd::file::FileSnapshot::of(d))).collect(); - for (uid, file) in files { - let encode = move || { - let bytes = file.encode(); - (file, bytes) - }; - match std::thread::Builder::new().name("figcraft-save".into()).spawn(encode) { - Ok(h) => self.ui.auto_saving.push((uid, h)), - Err(e) => { - failed.get_or_insert(e.to_string()); - self.ui.auto_save_at = Some((now + AUTO_SAVE_RETRY_SECS, changes)); - } - } - } - } - } - } - if !self.ui.auto_saving.is_empty() { - // Look again shortly for the files being encoded. - repaint_in(ctx, 0.1); - } - if let Some(e) = failed { - self.toast(i18n::trf("Couldn't save automatically: {error}", &[("error", &e)])); - } - } - - /// Write the files whose auto-save encoding finished; the first error, if one failed. - fn auto_save_done(&mut self) -> Option { - if self.ui.auto_saving.is_empty() { - return None; - } - let mut failed = None; - let done: Vec<_> = self.ui.auto_saving.extract_if(.., |(_, h)| h.is_finished()).collect(); - for (_, h) in done { - let saved = match h.join() { - Ok((snapshot, Ok(bytes))) => snapshot.save(&mut self.session, &bytes).map(|_| ()).map_err(|e| e.to_string()), - Ok((_, Err(e))) => Err(e.to_string()), - Err(_) => Err("the file couldn't be encoded".to_string()), - }; - if let Err(e) = saved { - failed.get_or_insert(e); - } - } - failed - } - /// Draw the whole UI: the main window (the other windows draw themselves). pub fn ui(&mut self, ui: &mut egui::Ui) { #[cfg(not(target_arch = "wasm32"))] @@ -976,42 +190,6 @@ impl FigApp { } } - /// Whether the right panel shows: never with the UI `hidden`; always on a design file; on a - /// board, which has no prototypes or Dev Mode (its tab stays Design), only when asked for. - fn right_panel_shown(&mut self, hidden: bool) -> bool { - let board = self.session.doc().is_ok_and(figcraft_engine::board::is_board); - if board { - self.ui.right_tab = RightTab::Design; - } - !hidden && (!board || self.ui.board_panel) - } - - /// A narrow window's two buttons for the side panels (touch-sized); the open one closes again. - fn mobile_buttons(&mut self, ctx: &egui::Context, at: egui::Pos2) { - use a11y::Tip as _; - let t = theme::Tokens::get(ctx); - egui::Area::new(egui::Id::unique("mobile_panel_buttons")).order(egui::Order::Foreground).fixed_pos(at).show(ctx, |ui| { - for (side, icon, name) in - [(MobilePanel::Left, icons::Icon::Layers, i18n::tr("Layers")), (MobilePanel::Right, icons::Icon::Panels, i18n::tr("Design"))] - { - let open = self.ui.mobile_panel == Some(side); - let (r, resp) = ui.allocate_exact_size(egui::vec2(40.0, 40.0), egui::Sense::click()); - let resp = resp.tip(name); - let fill = if open { t.accent } else { t.panel }; - ui.painter().rect(r, 8.0, fill, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside); - icons::paint(ui.painter(), r.shrink(10.0), icon, if open { t.accent_on } else { t.icon }); - if resp.clicked() { - if side == MobilePanel::Left && !open { - self.ui.open_left_panel(); - } else { - self.ui.mobile_panel = if open { None } else { Some(side) }; - } - } - ui.add_space(4.0); - } - }); - } - /// One frame of the window drawing now. pub(crate) fn frame(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); @@ -1030,7 +208,7 @@ impl FigApp { // A dialog that closed acts first, in the window it was opened from. self.poll_dialog(&ctx); // Control requests run first so their results reflect this frame. - control::drain(self, &ctx); + crate::control::drain(self, &ctx); windows::requests(self, &ctx); selection_spacing::sync(self, &ctx); // Before any widget: the navigation keys (F6, Tab, arrows, Escape) move the keyboard. @@ -1204,142 +382,4 @@ impl FigApp { // above make the same single idle observation. self.observe_ime(&ctx, !typing && !self.field_focus && self.ui.palette.is_none()); } - - /// The pass's one IME observation: the focused on-canvas editor, or an explicit idle. - /// - /// Calling `ImeContextTracker::observe` more than once in a pass would treat an idle followed - /// by a focused observation as a context change (dropping the frame's receipts) and bump the - /// generation. Keeping this in one place makes the fork contract structural. - fn observe_ime(&mut self, ctx: &egui::Context, focused: bool) { - #[cfg(test)] - { - self.ime_observations = self.ime_observations.saturating_add(1); - } - if focused { - shortcuts::ime_output(self, ctx); - } else { - let _ = self.ime.observe(ctx.viewport_id(), None); - } - } - - /// Draw the toast: start its lifetime on the frame **after** the first drawn frame, request - /// that frame and the frame that hides it, expire and remove it at the deadline, and place it - /// at the bottom centre. - fn toast_ui(&mut self, ctx: &egui::Context) { - let Some((msg, clock)) = &mut self.ui.toast else { return }; - let now = ctx.input(|i| i.time); - // The clock starts on the frame after the one that first draws the toast: that frame's - // time was taken before whatever raised it ran (a file dialog, a long open), and would - // count that wait against it, so a failed open's message expired before it was seen. - let left = match *clock { - ToastClock::Raised => { - *clock = ToastClock::Drawn; - ctx.request_repaint(); - TOAST_SECS - } - ToastClock::Drawn => { - *clock = ToastClock::Since(now); - TOAST_SECS - } - ToastClock::Since(t) => TOAST_SECS - (now - t), - }; - if left <= 0.0 { - self.ui.toast = None; - return; - } - let msg = msg.clone(); - // Nothing moves while it shows: the next frame it needs is the one that hides it. - if matches!(clock, ToastClock::Since(_)) { - repaint_in(ctx, left); - } - let screen = ctx.content_rect(); - egui::Area::new(egui::Id::unique("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| { - // Wrapped at a readable width (the area alone would wrap at a sliver). - let width = (screen.width() - 64.0).clamp(120.0, 480.0); - let galley = ui.painter().layout(msg, theme::regular(12.0), egui::Color32::WHITE, width); - ui.label(galley); - }); - }); - } - - /// UI state as JSON (control channel `ui.inspect`). - pub fn inspect(&self) -> Value { - let mut state = json!({ - "rightTab": self.ui.right_tab, - "leftTab": self.ui.left_tab, - "home": self.ui.home, - "online": online::inspect(self), - "tabs": self.session.docs.iter().map(|d| json!({"name": d.doc.name, "live": tabs::live_mark(self, d.uid).map(|shared| if shared { "shared" } else { "private" })})).collect::>(), - "homeSection": self.ui.home_view.section.id(), - "homeRecents": home_recents::inspect(self), - "dark": self.ui.dark, - "focus": {"region": self.ui.nav.region.id(), "keyboard": self.ui.nav.keyboard}, - "reduceMotion": self.reduce_motion(), - "presenting": self.ui.present.is_some(), - "presenter": tabs::shown_or_paused(self).map(present::Presenter::inspect), - "presentOptions": {"scale": self.ui.present_view.scale.id(), "deviceFrame": self.ui.present_view.device_frame, "comments": self.ui.present_view.comments, "hotspotHints": self.ui.present_view.hotspot_hints, "inspect": self.ui.present_view.inspect}, - "preview": self.ui.preview.as_ref().map(|p| p.player.inspect()), - "mainMenu": self.ui.main_menu, - "palette": self.ui.palette.is_some(), - "variables": self.ui.variables.is_some(), - "versionHistory": self.ui.versions.open, - "branchReview": self.ui.branch_review.as_ref().map(|r| json!({"id": r.id, "picks": r.picks, "mode": if r.mode == dev_compare::Mode::Overlay { "overlay" } else { "sideBySide" }})), - "devCompare": self.ui.dev.compare.as_ref().map(|c| json!({"id": c.id, "mode": if c.mode == dev_compare::Mode::Overlay { "overlay" } else { "sideBySide" }, "split": c.split, "version": c.version})), - "devFocus": self.session.ds().ok().and_then(figcraft_engine::DocState::focused), - "annotationFilter": self.ui.dev.categories.id(), - "colorProfileDialog": self.ui.color_profile.map(|p| p.id()), - "stylePicker": self.ui.style_picker.as_ref().map(|p| p.slot().key()), - "stylePickerView": if self.ui.style_grid { "grid" } else { "list" }, - "textPreview": figcraft_engine::textpreview::active(&self.session), - "comments": { - "open": self.ui.comments.open, - "draft": self.ui.comments.draft.as_ref().map(|d| [d.0, d.1]), - "filter": self.ui.comments.filter.id(), - "sort": self.ui.comments.sort.id(), - "search": self.ui.comments.search, - "editing": self.ui.comments.editing.as_ref().map(|e| [e.0, e.1 as u64]), - "mentions": self.ui.comments.mention.names(), - }, - "frameMs": self.ui.frame_ms, - "renderMs": self.ui.render_ms, - "framesDrawn": self.frame_stats.frames, - "repaintCauses": self.frame_stats.causes(), - "canvas": { - "rect": [self.canvas.rect.min.x, self.canvas.rect.min.y, self.canvas.rect.max.x, self.canvas.rect.max.y], - "renderWork": self.canvas.render_work.map(|w| match w { - figcraft_render::Work::Reused => json!({"kind": "reused"}), - figcraft_render::Work::Partial { pixels } => json!({"kind": "partial", "pixels": pixels}), - figcraft_render::Work::Full => json!({"kind": "full"}), - }), - "quality": if self.canvas.tex_coarse { "preview" } else { "refined" }, - "previewLatencyMs": self.canvas.preview_latency_ms, - "refineLatencyMs": self.canvas.refine_latency_ms, - }, - "live": live::inspect(self), - "window": self.windows.current, - "windows": windows::list(self), - "tool": self.session.tool_info(), - "cursor": self.canvas.cursor.map(cursors::Shown::name), - "panels": {"left": self.ui.left_width, "right": self.ui.right_width, "leftCollapsed": self.ui.left_collapsed}, - "status": self.session.status(), - "lastError": self.ui.last_error, - "importReport": self.ui.import_report.as_ref().map(import_report::Report::inspect), - }); - state["homeSearch"] = json!(self.ui.home_view.file_query); - // Set apart from the literal above, which is at the macro recursion limit. - state["uiScale"] = json!({"choice": self.session.prefs.ui_scale, "zoom": self.ui.ui_scale.zoom, "drawn": self.ui.ui_scale.drawn, "limited": self.ui.ui_scale.limited}); - state["pendingOpens"] = json!(self.ui.pending_opens.len()); - state["missingFonts"] = json!(self.ui.missing_fonts.as_ref().map(missing_fonts::Shown::inspect)); - state["presentTabs"] = tabs::present_tabs_inspect(self); - state - } } diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs new file mode 100644 index 000000000..2280f9a1b --- /dev/null +++ b/crates/ui-egui/src/app/clipboard.rs @@ -0,0 +1,20 @@ +use super::*; + +impl FigApp { + /// Put `text` on the system clipboard. Input injected through the control channel never + /// replaces what the user copied: this is the one write path every copy command goes through. + pub(crate) fn clipboard_set_text(&mut self, text: &str) { + if self.control_synthetic { + return; + } + self.services.clipboard_set_text(text); + } + + /// [`Self::clipboard_set_text`] for an image (straight RGBA). + pub(crate) fn clipboard_set_png(&mut self, width: u32, height: u32, rgba: &[u8]) { + if self.control_synthetic { + return; + } + self.services.clipboard_set_png(width, height, rgba); + } +} diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs new file mode 100644 index 000000000..38adf6609 --- /dev/null +++ b/crates/ui-egui/src/app/commands.rs @@ -0,0 +1,282 @@ +use super::*; + +impl FigApp { + /// Dismiss the composer after a successful switch away from the Comment tool. + pub(crate) fn clear_comment_draft_after_tool_set(&mut self, id: &str, succeeded: bool) { + if id == "tool.set" && succeeded && self.session.tool.tool != figcraft_engine::tools::ToolId::Comment { + self.ui.comments.draft = None; + } + } + + /// Run a command, surfacing errors as a toast. Returns the result. + pub fn run(&mut self, id: &str, params: Value) -> Option { + let layers_generation = self.session.clipboard.layers_generation; + let r = self.session.execute(id, ¶ms); + self.clear_comment_draft_after_tool_set(id, r.is_ok()); + self.handle_ui_requests(); + self.invalidate_synthetic_copy(layers_generation); + // A command may change the file the window shows (`doc.switch`, `doc.nextTab`, + // `file.open`/`file.close`). Record that presentation before returning, so an in-flight + // screenshot admitted on the old file cannot accept pixels from the new one. + self.observe_presentation(); + r.map_err(|e| self.failed(id, &e)).ok() + } + + /// A programmatic command that changed the engine clipboard never wrote the system clipboard: + /// forget the old system mirror so a later user paste does not trust it as our own copy. + /// `before` is the layers generation captured before the command ran. + pub(crate) fn invalidate_synthetic_copy(&mut self, before: u64) { + if self.control_synthetic && self.session.clipboard.layers_generation != before { + self.ui.last_copy = None; + } + } + + /// Record the file the window drawing now shows, with a monotonic generation that changes on + /// every switch (an A→B→A switch is two generations, never the admitted one again). + pub(crate) fn observe_presentation(&mut self) { + let window = self.windows.current; + self.windows.presentation(&self.session, window); + } + + /// [`run`](Self::run) for something the user did (a dialog, a drop, a paste, a recent file): + /// a file it imports shows its import report. Programmatic calls use `run` and never open one. + pub fn run_user(&mut self, id: &str, params: Value) -> Option { + let result = self.run(id, params.clone()); + if let Some(result) = &result { + import_report::capture(self, id, ¶ms, result); + } + missing_fonts::check_later(self, id, result.as_ref()); + result + } + + /// Show command `id`'s error. + pub(crate) fn failed(&mut self, id: &str, e: &figcraft_engine::EngineError) { + let msg = e.to_string(); + log::warn!("{id}: {msg}"); + self.show_error(msg); + } + + /// Show an error as a toast, kept as the last error. + pub(crate) fn show_error(&mut self, msg: String) { + self.toast(msg.clone()); + self.ui.last_error = Some(msg); + } + + /// Run a command that may talk to a server without waiting for it (the engine's + /// [`start_background`](Session::start_background)): `done` gets the result, at once or when + /// the answer arrives (the network wakes the window then); errors show as with [`run`](Self::run). + /// [`Session::waiting_for`] tells the panels to show that it's on its way. + pub fn run_background(&mut self, ctx: &egui::Context, id: &str, params: Value, done: Done) { + self.waiting.retain(|(c, _)| c != id); + let r = self.session.start_background(id, params); + self.handle_ui_requests(); + match r { + Ok(Some(v)) => done(self, ctx, v), + Ok(None) => self.waiting.push((id.to_string(), done)), + Err(e) => self.failed(id, &e), + } + } + + /// Stop waiting for command `id` (Cancel, or its window closed): its answer is dropped. + pub fn cancel_background(&mut self, id: &str) { + self.session.cancel_background(id); + self.waiting.retain(|(c, _)| c != id); + } + + /// Hand the results of background commands that finished to what's waiting for them. + fn background_done(&mut self, ctx: &egui::Context) { + while let Some((id, r)) = self.session.take_background_done() { + self.handle_ui_requests(); + let done = self.waiting.iter().position(|(c, _)| *c == id).map(|i| self.waiting.remove(i).1); + match (r, done) { + (Ok(v), Some(done)) => done(self, ctx, v), + (Ok(_), None) => {} + (Err(e), _) => self.failed(&id, &e), + } + } + } + + /// Dev Mode (the Inspect tab): the canvas measures on hover without Alt and edits nothing; + /// entering it ends editing text or vectors. + pub(super) fn sync_dev_mode(&mut self) { + let dev = self.ui.right_tab == RightTab::Inspect; + if dev && !self.session.tool.dev_mode { + let _ = self.session.drop_gesture(); + let editing = self.session.ds().map(|d| (d.text_edit.is_some(), d.vector_edit.is_some())).unwrap_or_default(); + for (on, cmd) in [(editing.0, "text.edit.end"), (editing.1, "vector.edit.end")] { + if on { + self.run(cmd, json!({})); + } + } + } + self.session.tool.dev_mode = dev; + } + + /// Act on UI requests queued by commands (dialogs, panels). + pub fn handle_ui_requests(&mut self) { + let reqs = std::mem::take(&mut self.session.ui_requests); + for r in reqs { + match r.kind.as_str() { + "dialog.about" => { + self.ui.about = true; + self.ui.about_tab = dialogs::AboutTab::About; + } + "dialog.preferences" => self.ui.preferences = true, + "dialog.missingFonts" => missing_fonts::request(self), + "panel.shortcuts" => self.ui.shortcuts_panel = !self.ui.shortcuts_panel, + "palette.actions" => menus::toggle_palette(&mut self.ui), + "panel.design" if self.session.doc().is_ok_and(figcraft_engine::board::is_board) => self.ui.board_panel = !self.ui.board_panel, + "panel.design" => self.ui.right_tab = RightTab::Design, + "board.editText" => { + if let Some(id) = r.data.get("id").and_then(figcraft_engine::params::id_of) + && let Err(e) = figcraft_engine::board::edit_text(&mut self.session, id) + { + self.toast(e.to_string()); + } + } + "panel.prototype" => self.ui.right_tab = RightTab::Prototype, + "panel.designPrototype" => { + self.ui.right_tab = if self.ui.right_tab == RightTab::Prototype { RightTab::Design } else { RightTab::Prototype }; + } + "panel.devMode" => { + self.ui.right_tab = if self.ui.right_tab == RightTab::Inspect { RightTab::Design } else { RightTab::Inspect }; + } + "panel.leftSidebar" => { + let shown = if self.ui.narrow { self.ui.mobile_panel == Some(MobilePanel::Left) } else { !self.ui.left_collapsed }; + if shown { + self.ui.close_left_panel(); + } else { + self.ui.open_left_panel(); + } + } + "panel.widths" => { + let width = |k: &str| r.data.get(k).and_then(Value::as_f64).map(|w| w as f32); + let (left, right) = (width("left"), width("right")); + let reset = left.is_none() && right.is_none(); + self.set_panel_widths( + left.unwrap_or(if reset { PANEL_DEFAULT } else { self.ui.left_width }), + right.unwrap_or(if reset { PANEL_DEFAULT } else { self.ui.right_width }), + ); + self.services.save_prefs(&self.session.prefs); + } + "focus.region" => self.ui.nav.step(r.data.get("step").and_then(Value::as_i64).unwrap_or(1).clamp(-4, 4) as i32), + "panel.layers" => self.ui.left_tab = LeftTab::File, + "panel.assets" => { + self.ui.left_tab = LeftTab::Assets; + self.ui.open_left_panel(); + self.ui.versions = Default::default(); + } + "panel.versionHistory" => versions::open(self), + "view.devCompare" => { + self.ui.dev.compare = dev_compare::CompareView::requested(&r.data); + } + "panel.libraries" => { + self.ui.left_tab = LeftTab::Assets; + self.ui.open_left_panel(); + self.ui.libraries = true; + } + "home" => self.ui.home = true, + "home.templates" => { + self.ui.home = true; + self.ui.home_view.section = home::Section::Templates; + } + "home.gettingStarted" => { + self.ui.home = true; + self.ui.home_view.section = home::Section::Recents; + self.services.save_prefs(&self.session.prefs); + } + "dialog.saveAs" => self.save_as_dialog(), + "dialog.colorProfile" => self.ui.color_profile = self.session.doc().ok().map(|d| d.color_profile), + "present" => { + if let Some(start) = r.data.get("start").and_then(figcraft_engine::params::id_of) { + tabs::open_present(self, start); + } + } + "present.options" => { + let v = &mut self.ui.present_view; + if let Some(s) = r.data.get("scale").and_then(|s| s.as_str()).and_then(present::PresentScale::parse) { + v.scale = s; + } + if let Some(f) = r.data.get("deviceFrame").and_then(|f| f.as_bool()) { + v.device_frame = f; + } + if let Some(h) = r.data.get("hotspotHints").and_then(|h| h.as_bool()) { + v.hotspot_hints = h; + } + if let Some(i) = r.data.get("inspect").and_then(|i| i.as_bool()) { + v.inspect = i; + } + if let Some(p) = self.ui.present.as_mut() { + p.view.device_frame = v.device_frame; + p.view.hotspot_hints = v.hotspot_hints; + p.view.inspect = v.inspect; + p.set_scale(v.scale); + } + } + "preview" => { + if let Some(start) = r.data.get("start").and_then(figcraft_engine::params::id_of) { + self.ui.preview = Some(present::Pane::new(start, &self.session)); + } + } + "preview.close" => self.ui.preview = None, + "open.help" => self.services.open_url("https://getartcraft.com/apps/figcraft"), + "layers.collapseAll" => layers::collapse_all(self), + "contextMenu" => self.ui.key_menu = true, + "selectArea" => self.start_key_box(), + "canvas.renameLabel" => { + self.ui.rename_label = r.data.get("id").and_then(figcraft_engine::params::id_of).map(|id| layers::Rename { id, start: true }); + } + "layers.rename" => { + let ids: Vec = + r.data.get("ids").and_then(Value::as_array).into_iter().flatten().filter_map(figcraft_engine::params::id_of).collect(); + if ids.len() > 1 { + dialogs::open_batch_rename(self, &ids); + } else { + layers::start_rename(self, &ids); + } + } + "comment.open" => { + if let Some(id) = r.data.get("id").and_then(Value::as_u64) { + self.ui.comments.show(id); + } + } + "comment.new" => { + let x = r.data.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0); + let y = r.data.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0); + comments::start_draft(self, x, y); + } + "dialog.unsaved" => self.toast(i18n::tr("This file has unsaved changes")), + "dialog.plugins" => self.ui.plugins_window = true, + "dialog.pluginSave" => plugins_ui::save_file(self, &r.data), + "dialog.cursorChat" => live::open_chat(self), + "window.new" | "window.focus" | "window.fullscreen" => self.window_requests.push(r), + _ => {} + } + } + if let Some(m) = self.session.message.take() { + self.toast(m); + } + } + + /// Work safe from a logic-only callback (`App::logic`, which also runs while hidden) and from + /// a normal UI pass: consumed control work, live polling, background completions, autosave and + /// recovery deadlines. It never reads UI input, so it must not scan events, run shortcuts or + /// lay out anything. + pub fn background_tick(&mut self, ctx: &egui::Context) { + crate::control::background(self, ctx); + // What happened in a live session (the network woke us for it). + if self.session.live_poll() { + if let Some(m) = self.session.message.take() { + self.toast(live::failure_text(&m)); + } + self.background_done(ctx); + } + // Files that arrived without a command (joined, recovered) take the kept View toggles too. + self.session.apply_view_prefs(); + let now = self.clock.now(); + self.autosave_tick(ctx, now); + self.auto_save_tick(ctx, now); + updates_ui::tick(self, ctx); + self.logic_ticked = true; + } +} diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs new file mode 100644 index 000000000..bbbe9c612 --- /dev/null +++ b/crates/ui-egui/src/app/control.rs @@ -0,0 +1,110 @@ +use super::*; + +impl FigApp { + /// Queue raw input injected through the control channel for the window drawing next, tagged + /// with the document it belongs to (a batch is dropped if another file is active by then). + pub(crate) fn push_control_input(&mut self, events: Vec) -> bool { + if events.is_empty() { + return false; + } + let doc = self.session.docs.get(self.session.active).map(|d| d.uid); + if !self.control_input.is_empty() && self.control_input_doc != doc { + // A queued batch belongs to the file that was open when it was injected; it cannot be + // merged with one for the file showing now. Its acknowledgement says so. + self.control_input.clear(); + let viewport = crate::windows::viewport(self.windows.current); + crate::windows::fail_input_acks(self, viewport); + } + // Always the live pane vector: while a child window draws, `windows::enter` has swapped + // its pane into the app, and `leave` swaps these events back into that pane. + self.control_input.extend(events); + self.control_input_doc = doc; + self.control_synthetic = true; + true + } + + /// The acknowledgements waiting for `viewport`'s input batch (other windows keep theirs). + pub(crate) fn take_input_acks(&mut self, viewport: egui::ViewportId) -> Vec { + let mut mine = Vec::new(); + for ack in std::mem::take(&mut self.input_acks) { + if ack.viewport == viewport { + mine.push(ack); + } else { + self.input_acks.push(ack); + } + } + mine + } + + /// UI state as JSON (control channel `ui.inspect`). + pub fn inspect(&self) -> Value { + let mut state = json!({ + "rightTab": self.ui.right_tab, + "leftTab": self.ui.left_tab, + "home": self.ui.home, + "online": online::inspect(self), + "tabs": self.session.docs.iter().map(|d| json!({"name": d.doc.name, "live": tabs::live_mark(self, d.uid).map(|shared| if shared { "shared" } else { "private" })})).collect::>(), + "homeSection": self.ui.home_view.section.id(), + "homeRecents": home_recents::inspect(self), + "dark": self.ui.dark, + "focus": {"region": self.ui.nav.region.id(), "keyboard": self.ui.nav.keyboard}, + "reduceMotion": self.reduce_motion(), + "presenting": self.ui.present.is_some(), + "presenter": tabs::shown_or_paused(self).map(present::Presenter::inspect), + "presentOptions": {"scale": self.ui.present_view.scale.id(), "deviceFrame": self.ui.present_view.device_frame, "comments": self.ui.present_view.comments, "hotspotHints": self.ui.present_view.hotspot_hints, "inspect": self.ui.present_view.inspect}, + "preview": self.ui.preview.as_ref().map(|p| p.player.inspect()), + "mainMenu": self.ui.main_menu, + "palette": self.ui.palette.is_some(), + "variables": self.ui.variables.is_some(), + "versionHistory": self.ui.versions.open, + "branchReview": self.ui.branch_review.as_ref().map(|r| json!({"id": r.id, "picks": r.picks, "mode": if r.mode == dev_compare::Mode::Overlay { "overlay" } else { "sideBySide" }})), + "devCompare": self.ui.dev.compare.as_ref().map(|c| json!({"id": c.id, "mode": if c.mode == dev_compare::Mode::Overlay { "overlay" } else { "sideBySide" }, "split": c.split, "version": c.version})), + "devFocus": self.session.ds().ok().and_then(figcraft_engine::DocState::focused), + "annotationFilter": self.ui.dev.categories.id(), + "colorProfileDialog": self.ui.color_profile.map(|p| p.id()), + "stylePicker": self.ui.style_picker.as_ref().map(|p| p.slot().key()), + "stylePickerView": if self.ui.style_grid { "grid" } else { "list" }, + "textPreview": figcraft_engine::textpreview::active(&self.session), + "comments": { + "open": self.ui.comments.open, + "draft": self.ui.comments.draft.as_ref().map(|d| [d.0, d.1]), + "filter": self.ui.comments.filter.id(), + "sort": self.ui.comments.sort.id(), + "search": self.ui.comments.search, + "editing": self.ui.comments.editing.as_ref().map(|e| [e.0, e.1 as u64]), + "mentions": self.ui.comments.mention.names(), + }, + "frameMs": self.ui.frame_ms, + "renderMs": self.ui.render_ms, + "framesDrawn": self.frame_stats.frames, + "repaintCauses": self.frame_stats.causes(), + "canvas": { + "rect": [self.canvas.rect.min.x, self.canvas.rect.min.y, self.canvas.rect.max.x, self.canvas.rect.max.y], + "renderWork": self.canvas.render_work.map(|w| match w { + figcraft_render::Work::Reused => json!({"kind": "reused"}), + figcraft_render::Work::Partial { pixels } => json!({"kind": "partial", "pixels": pixels}), + figcraft_render::Work::Full => json!({"kind": "full"}), + }), + "quality": if self.canvas.tex_coarse { "preview" } else { "refined" }, + "previewLatencyMs": self.canvas.preview_latency_ms, + "refineLatencyMs": self.canvas.refine_latency_ms, + }, + "live": live::inspect(self), + "window": self.windows.current, + "windows": windows::list(self), + "tool": self.session.tool_info(), + "cursor": self.canvas.cursor.map(cursors::Shown::name), + "panels": {"left": self.ui.left_width, "right": self.ui.right_width, "leftCollapsed": self.ui.left_collapsed}, + "status": self.session.status(), + "lastError": self.ui.last_error, + "importReport": self.ui.import_report.as_ref().map(import_report::Report::inspect), + }); + state["homeSearch"] = json!(self.ui.home_view.file_query); + // Set apart from the literal above, which is at the macro recursion limit. + state["uiScale"] = json!({"choice": self.session.prefs.ui_scale, "zoom": self.ui.ui_scale.zoom, "drawn": self.ui.ui_scale.drawn, "limited": self.ui.ui_scale.limited}); + state["pendingOpens"] = json!(self.ui.pending_opens.len()); + state["missingFonts"] = json!(self.ui.missing_fonts.as_ref().map(missing_fonts::Shown::inspect)); + state["presentTabs"] = tabs::present_tabs_inspect(self); + state + } +} diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs new file mode 100644 index 000000000..ae17b7bc5 --- /dev/null +++ b/crates/ui-egui/src/app/files.rs @@ -0,0 +1,298 @@ +use super::*; + +impl FigApp { + pub fn save_as_dialog(&mut self) { + if self.services.downloads() { + if let Some(r) = self.run("file.saveAs", json!({"data": true})) + && self.download(r["name"].as_str().unwrap_or_default(), &r) + { + self.run("file.markSaved", json!({})); + dialogs::file_saved(self, &r); + } + return; + } + let Ok(name) = self.session.doc().map(|d| d.name.clone()) else { return }; + self.pick_save(i18n::tr("Save as"), &format!("{name}.figcraft"), &["figcraft", "fig"], |app, mut p| { + if p.extension().is_none_or(OsStr::is_empty) { + if !p.set_extension("figcraft") { + app.toast(i18n::tr("Use the .figcraft or .fig extension.")); + return; + } + // This name wasn't confirmed by the system dialog: don't replace an existing file. + match p.try_exists() { + Ok(false) => {} + Ok(true) => { + app.toast(i18n::tr("A .figcraft file with this name already exists. Choose its full name to confirm replacement.")); + return; + } + Err(e) => { + app.toast(e.to_string()); + return; + } + } + } else if !p.extension().and_then(OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("figcraft") || e.eq_ignore_ascii_case("fig")) { + app.toast(i18n::tr("Use the .figcraft or .fig extension.")); + return; + } + if let Some(r) = app.run("file.saveAs", json!({"path": p.to_string_lossy()})) { + dialogs::file_saved(app, &r); + } + }); + } + + pub fn save(&mut self) { + // A branch tab saves its main file. + let tab = figcraft_engine::branches::file_tab(&self.session); + // A path ending in .fig is never saved over silently: a .fig destination is an export copy. + let native_path = self + .session + .docs + .get(tab) + .and_then(|d| d.path.as_deref()) + .is_some_and(|path| !Path::new(path).extension().and_then(OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("fig"))); + if native_path { + if self.run("file.save", json!({})).is_some() { + self.toast(i18n::tr("Saved")); + } + } else { + self.save_as_dialog(); + } + } + + pub fn open_dialog(&mut self) { + // Opening needs no file in front: it goes ahead even if that file closed meanwhile. + self.pick_file(i18n::tr("Open"), OPEN_EXTENSIONS, false, |app, file| { + if app.run_user("file.open", file).is_some() { + app.ui.home = false; + } + }); + } + + pub fn import_dialog(&mut self, place: bool) { + let exts: &[&str] = if place { + &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"] + } else { + &["svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff", "figcraft", "fig", "sketch"] + }; + let title = if place { i18n::tr("Place image") } else { i18n::tr("Import") }; + self.pick_file(title, exts, true, |app, file| { + app.run_user("file.import", file); + }); + } + + /// Pick a file to open or import; `done` gets it as `file.open` and `file.import` take it (a + /// path, or the name and contents where the platform has no paths). `pin`: as + /// [`FigApp::pick_open`]'s file in front. + fn pick_file(&mut self, title: &str, exts: &[&str], pin: bool, done: impl FnOnce(&mut FigApp, Value) + 'static) { + self.start_pick( + |services| -> PickFuture { + match services.pick_open_contents(title, exts) { + Some(read) => { + Box::pin(async move { read.await.map(|r| r.map_or_else(Picked::Unreadable, |(name, bytes)| Picked::Contents(name, bytes))) }) + } + None => { + let pick = services.pick_open(title, exts); + Box::pin(async move { pick.await.map(Picked::Path) }) + } + } + }, + pin, + move |app, picked| match picked.file_params() { + Ok(file) => done(app, file), + Err(why) => app.show_error(why), + }, + ); + } + + /// Pick a file to open. `done` runs with the file in front now back in front (and not at all + /// if that file closed meanwhile). + pub fn pick_open(&mut self, title: &str, exts: &[&str], done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { + self.start_dialog(|services| services.pick_open(title, exts), true, done); + } + + /// Pick where to save; `done` runs as [`FigApp::pick_open`]'s does. + pub fn pick_save(&mut self, title: &str, name: &str, exts: &[&str], done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { + self.start_dialog(|services| services.pick_save(title, name, exts), true, done); + } + + /// Hand a command result's base64 `data` over as a download named `name`, where the platform + /// saves by downloads rather than save dialogs (the browser, #969). Says so when nothing arrived. + pub(crate) fn download(&mut self, name: &str, result: &Value) -> bool { + use base64::Engine as _; + let bytes = result["data"].as_str().and_then(|d| base64::engine::general_purpose::STANDARD.decode(d).ok()); + if bytes.is_some_and(|b| self.services.download(name, &b)) { + return true; + } + self.toast(i18n::trf("Couldn't download {name}", &[("name", &name)])); + false + } + + /// Pick a folder; `done` runs as [`FigApp::pick_open`]'s does. + pub fn pick_folder(&mut self, title: &str, done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { + self.start_dialog(|services| services.pick_folder(title), true, done); + } + + /// Open a native dialog from the window drawing now, for the file in front (`pin`). + fn start_dialog(&mut self, start: impl FnOnce(&mut dyn Services) -> DialogFuture, pin: bool, done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { + self.start_pick( + |services| -> PickFuture { + let pick = start(services); + Box::pin(async move { pick.await.map(Picked::Path) }) + }, + pin, + move |app, picked| { + if let Picked::Path(p) = picked { + done(app, p); + } + }, + ); + } + + /// [`FigApp::start_dialog`] for any kind of pick. + fn start_pick(&mut self, start: impl FnOnce(&mut dyn Services) -> PickFuture, pin: bool, done: impl FnOnce(&mut FigApp, Picked) + 'static) { + if self.pending_dialog.is_some() { + return; + } + let doc = if pin { self.session.ds().ok().filter(|d| self.shows(d.uid)).map(|d| d.uid) } else { None }; + let future = start(self.services.as_mut()); + self.pending_dialog = Some(PendingDialog { future, picked: None, done: Box::new(done), doc, window: self.windows.current }); + if let Some(ctx) = &self.ui_context { + ctx.request_repaint(); + } + } + + /// Once the dialog has a path, its continuation runs in the window that holds its file, with + /// that file in front; then the tab in front before comes back (unless the dialog opened a file). + pub(crate) fn poll_dialog(&mut self, ctx: &egui::Context) { + let Some(d) = &mut self.pending_dialog else { return }; + if d.picked.is_none() { + let waker = Waker::from(Arc::new(RepaintWaker(ctx.clone()))); + let Poll::Ready(picked) = d.future.as_mut().poll(&mut TaskContext::from_waker(&waker)) else { return }; + d.picked = picked; + } + let (doc, origin, picked) = (d.doc, d.window, d.picked.is_some()); + let index = doc.map(|u| self.session.docs.iter().position(|ds| ds.uid == u)); + // Cancelled, or its file closed meanwhile. + if !picked || index == Some(None) { + self.pending_dialog = None; + return; + } + let window = match doc { + Some(u) => self.windows.of(u), + None if self.windows.exists(origin) => origin, + None => 0, + }; + if window != self.windows.current { + ctx.request_repaint_of(windows::viewport(window)); + return; + } + let Some(PendingDialog { picked: Some(picked), done, .. }) = self.pending_dialog.take() else { return }; + let before = self.session.ds().ok().map(|d| d.uid); + if let Some(Some(i)) = index { + self.session.active = i; + } + done(self, picked); + if doc.is_some() + && self.session.ds().ok().map(|d| d.uid) == doc + && let Some(i) = before.and_then(|u| self.session.docs.iter().position(|d| d.uid == u)) + { + self.session.active = i; + } + } + + /// Every [`AUTOSAVE_SECS`], each open document with unsaved changes since its last version + /// gets an autosave checkpoint in its version history. + pub(super) fn autosave_tick(&mut self, ctx: &egui::Context, now: f64) { + if now < self.ui.next_autosave { + // Wake up for it even if nothing else draws a frame by then. + repaint_in(ctx, self.ui.next_autosave - now); + return; + } + if self.ui.next_autosave > 0.0 { + let active = self.session.active; + for i in 0..self.session.docs.len() { + self.session.active = i; + let _ = self.session.execute("version.autosave", &json!({})); + } + self.session.active = active; + } + self.ui.next_autosave = now + AUTOSAVE_SECS; + repaint_in(ctx, AUTOSAVE_SECS); + } + + /// With auto-save on, files that have a path are saved [`AUTO_SAVE_SECS`] after they last + /// changed (not during a gesture, a live preview or while a version is shown). The file is + /// encoded on a thread of its own, so a big one doesn't hold up the window, and written here. + pub(super) fn auto_save_tick(&mut self, ctx: &egui::Context, now: f64) { + let mut failed = self.auto_save_done(); + // The browser has no files to save to. + let on = !cfg!(target_arch = "wasm32") && self.session.prefs.auto_save; + let saving = &self.ui.auto_saving; + let due = |d: &&figcraft_engine::DocState| { + d.path.as_deref().is_some_and(|p| d.images_lost_saving_to(p).is_none()) + && d.branch_of.is_none() + && d.preview.is_none() + && d.is_dirty() + && !saving.iter().any(|(uid, _)| *uid == d.uid) + }; + let changes = if on { self.session.docs.iter().filter(due).map(|d| d.revision).reduce(u64::wrapping_add) } else { None }; + match changes { + None => self.ui.auto_save_at = None, + Some(changes) => { + let at = match self.ui.auto_save_at { + Some((at, c)) if c == changes && failed.is_none() => at, + _ => now + if failed.is_some() { AUTO_SAVE_RETRY_SECS } else { AUTO_SAVE_SECS }, + }; + self.ui.auto_save_at = Some((at, changes)); + if now < at || self.session.docs.iter().any(|d| d.interaction.is_some() || d.text_preview.is_some()) { + // Due but busy: look again in a second. + repaint_in(ctx, if now < at { at - now } else { 1.0 }); + } else { + self.ui.auto_save_at = None; + let files: Vec<_> = + self.session.docs.iter().filter(due).map(|d| (d.uid, figcraft_engine::cmd::file::FileSnapshot::of(d))).collect(); + for (uid, file) in files { + let encode = move || { + let bytes = file.encode(); + (file, bytes) + }; + match std::thread::Builder::new().name("figcraft-save".into()).spawn(encode) { + Ok(h) => self.ui.auto_saving.push((uid, h)), + Err(e) => { + failed.get_or_insert(e.to_string()); + self.ui.auto_save_at = Some((now + AUTO_SAVE_RETRY_SECS, changes)); + } + } + } + } + } + } + if !self.ui.auto_saving.is_empty() { + // Look again shortly for the files being encoded. + repaint_in(ctx, 0.1); + } + if let Some(e) = failed { + self.toast(i18n::trf("Couldn't save automatically: {error}", &[("error", &e)])); + } + } + + /// Write the files whose auto-save encoding finished; the first error, if one failed. + fn auto_save_done(&mut self) -> Option { + if self.ui.auto_saving.is_empty() { + return None; + } + let mut failed = None; + let done: Vec<_> = self.ui.auto_saving.extract_if(.., |(_, h)| h.is_finished()).collect(); + for (_, h) in done { + let saved = match h.join() { + Ok((snapshot, Ok(bytes))) => snapshot.save(&mut self.session, &bytes).map(|_| ()).map_err(|e| e.to_string()), + Ok((_, Err(e))) => Err(e.to_string()), + Err(_) => Err("the file couldn't be encoded".to_string()), + }; + if let Err(e) = saved { + failed.get_or_insert(e); + } + } + failed + } +} diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs new file mode 100644 index 000000000..96225f207 --- /dev/null +++ b/crates/ui-egui/src/app/frame.rs @@ -0,0 +1,255 @@ +use super::*; + +impl FigApp { + /// Show what `command params` would do on the canvas as a live preview (`text.preview`) while + /// a font family, font style, text style, named instance or axis slider is hovered or dragged. Call it every frame + /// the preview should stay: it ends on the first frame nothing asks for it. + pub fn preview_text(&mut self, ctx: &egui::Context, command: &str, params: Value) { + self.ui.text_preview_wanted = true; + let revision = self.session.ds().map_or(0, |d| d.revision); + if let Some((c, p, rev)) = &self.ui.text_preview + && c == command + && *p == params + && (*rev == revision || figcraft_engine::textpreview::active(&self.session)) + { + return; + } + let shown = self.session.execute("text.preview", &json!({"command": command, "params": params})).is_ok(); + let revision = self.session.ds().map_or(0, |d| d.revision); + self.ui.text_preview = shown.then(|| (command.to_string(), params, revision)); + // The canvas drew before the panels: show the preview next frame. + ctx.request_repaint(); + } + + /// End the live text preview the panels show (Escape, or nothing hovered any more). + pub fn end_text_preview(&mut self, ctx: &egui::Context) { + if self.ui.text_preview.take().is_some() { + let _ = self.session.execute("text.endPreview", &json!({})); + ctx.request_repaint(); + } + } + + /// Leaving Design ends a pen path as Escape does and goes back to Move: Prototype and Inspect + /// don't draw (#307). Checked as the frame starts (the control channel) and after the panels + /// and shortcuts (the mode switcher, the tabs, Shift+E/Shift+D), before the canvas sees more. + pub(super) fn end_pen_outside_design(&mut self) { + if self.ui.right_tab == self.ui.last_right_tab { + return; + } + self.ui.last_right_tab = self.ui.right_tab; + if self.ui.right_tab != RightTab::Design && self.session.tool.tool == figcraft_engine::tools::ToolId::Pen { + self.run("tool.set", json!({"tool": "move"})); + } + } + + /// Once a frame, after the panels: a preview nothing asked for this frame ends. + pub(super) fn end_unwanted_text_preview(&mut self, ctx: &egui::Context) { + if !std::mem::take(&mut self.ui.text_preview_wanted) { + self.end_text_preview(ctx); + } + } + + /// Finish the frame that applies an injected batch: acknowledge it once the pass is presented, + /// and stop fencing the system clipboard. Only the viewport whose batch this frame took acts. + pub(crate) fn end_input_frame(&mut self, ctx: &egui::Context) { + if self.applied_frame != Some(ctx.viewport_id()) { + return; + } + self.applied_input = None; + if ctx.will_discard() { + // Keep `applied_frame`/`applied_acks`: the rerun pass must present (and acknowledge) + // the same frame without replaying the input. + return; + } + self.applied_frame = None; + for ack in self.applied_acks.drain(..) { + let _ = ack.tx.send(ack.reply); + } + self.control_synthetic = false; + } + + /// The app's monotonic clock in seconds, also usable from logic-only callbacks (recovery, + /// control deadlines). Never `ctx.input(i.time)`, which is stale there. + pub fn now_secs(&self) -> f64 { + self.clock.now() + } + + pub fn toast(&mut self, msg: impl Into) { + self.ui.toast = Some((msg.into(), ToastClock::Raised)); + } + + /// Say that `n` documents left unsaved by a crash were opened again from recovery copies. + pub fn note_recovered(&mut self, n: usize) { + if n > 0 { + self.toast(i18n::trn("Recovered {n} unsaved files after the app quit unexpectedly: save them to keep them", n, &[])); + } + } + + /// Set the widths asked for this window's side panels (within their limits); the main + /// window's are kept in the preferences. + pub fn set_panel_widths(&mut self, left: f32, right: f32) { + let within = |w: f32, range: std::ops::RangeInclusive| if w.is_finite() { w.clamp(*range.start(), *range.end()) } else { PANEL_DEFAULT }; + (self.ui.left_width, self.ui.right_width) = (within(left, LEFT_PANEL), within(right, RIGHT_PANEL)); + if self.windows.current == 0 { + self.session.prefs.panel_widths = (self.ui.left_width, self.ui.right_width); + } + } + + /// The edge between a side panel and the canvas, at `x` down `rows`: dragging it sizes the + /// panel (`grows`: +1 for the left panel, whose edge is on its right, −1 for the right one), a + /// double-click puts back the default width. Returns the width asked for, when it changed. + pub(super) fn panel_edge(ui: &mut egui::Ui, name: &str, x: f32, rows: egui::Rangef, width: f32, grows: f32) -> Option { + let grab = ui.style().interaction.resize_grab_radius_side; + let rect = egui::Rect::from_x_y_ranges(x - grab..=x + grab, rows); + let id = egui::Id::unique(("panel_edge", name)); + // Not focusable: an edge is a pointer target, so it must not take Tab/F6 focus from the + // panels' controls (see `a11y::focusables`). + let resp = ui.interact(rect, id, egui::Sense::CLICK | egui::Sense::DRAG); + if resp.hovered() || resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + ui.painter().vline(x, rows, egui::Stroke::new(2.0, theme::ACCENT)); + } + if resp.double_clicked() { + return Some(PANEL_DEFAULT); + } + // The edge stays under the pointer where it was grabbed (also back from past a limit). + let at = resp.interact_pointer_pos().filter(|_| resp.dragged())?; + if resp.drag_started() { + let pressed = ui.input(|i| i.pointer.press_origin()).unwrap_or(at); + ui.data_mut(|d| d.insert_temp(id, pressed.x - x)); + } + let grabbed: f32 = ui.data(|d| d.get_temp(id)).unwrap_or(0.0); + let w = width + (at.x - grabbed - x) * grows; + (w != width).then_some(w) + } + + /// Reduced motion: the preference or the system's setting. + pub fn reduce_motion(&self) -> bool { + self.session.prefs.reduce_motion || self.services.reduce_motion() + } + + /// The CJK, Bengali and Thai scripts the UI shows: the language's, the language picker's (Preferences open), + /// typed text's, and those in the open file's names and comments (read when it changes, not + /// mid-drag). Nothing is checked once every script has a face. + pub(crate) fn cjk_needed(&mut self, ctx: &egui::Context, installed: u8) -> u8 { + if installed == cjk::ALL { + return installed; + } + let mut need = i18n::current().map_or(0, |l| cjk::language(l.code)); + if self.ui.preferences { + need |= i18n::languages().iter().filter(|l| cjk::can_show(l.code)).fold(0, |m, l| m | cjk::language(l.code)); + } + need |= ctx.input(|i| { + i.events.iter().fold(0, |m, e| match e { + egui::Event::Text(t) | egui::Event::Ime(egui::ImeEvent::Preedit { text: t, .. } | egui::ImeEvent::Commit(t)) => m | cjk::scripts(t), + _ => m, + }) + }); + let dragging = ctx.input(|i| i.pointer.any_down()); + // Pans and zooms change the revision too: only a new version of the document is looked + // at, and only the layers it changed (a large file has hundreds of thousands of names). + let current = self.session.docs.get(self.session.active).filter(|_| !dragging).map(|d| (d.uid, d.doc.clone())); + if let Some((uid, doc)) = current { + let before = self.cjk_checked.take().filter(|(u, _)| *u == uid).map(|(_, b)| b); + if !before.as_ref().is_some_and(|b| Arc::ptr_eq(b, &doc)) { + let mut scan = |s: &str| need |= cjk::scripts(s); + scan(&doc.name); + match before.as_ref().and_then(|b| doc.changed_ids(b, CJK_CHANGES)) { + Some(ids) => ids.into_iter().filter_map(|id| doc.node(id)).for_each(|n| scan(&n.name)), + None => doc.nodes().for_each(|n| scan(&n.name)), + } + doc.comments.iter().flat_map(|c| std::iter::once(&c.first).chain(&c.replies)).for_each(|m| { + scan(&m.author); + scan(&m.text); + }); + } + self.cjk_checked = Some((uid, doc)); + } + need + } + + /// Whether the right panel shows: never with the UI `hidden`; always on a design file; on a + /// board, which has no prototypes or Dev Mode (its tab stays Design), only when asked for. + pub(super) fn right_panel_shown(&mut self, hidden: bool) -> bool { + let board = self.session.doc().is_ok_and(figcraft_engine::board::is_board); + if board { + self.ui.right_tab = RightTab::Design; + } + !hidden && (!board || self.ui.board_panel) + } + + /// A narrow window's two buttons for the side panels (touch-sized); the open one closes again. + pub(super) fn mobile_buttons(&mut self, ctx: &egui::Context, at: egui::Pos2) { + use a11y::Tip as _; + let t = theme::Tokens::get(ctx); + egui::Area::new(egui::Id::unique("mobile_panel_buttons")).order(egui::Order::Foreground).fixed_pos(at).show(ctx, |ui| { + for (side, icon, name) in + [(MobilePanel::Left, icons::Icon::Layers, i18n::tr("Layers")), (MobilePanel::Right, icons::Icon::Panels, i18n::tr("Design"))] + { + let open = self.ui.mobile_panel == Some(side); + let (r, resp) = ui.allocate_exact_size(egui::vec2(40.0, 40.0), egui::Sense::click()); + let resp = resp.tip(name); + let fill = if open { t.accent } else { t.panel }; + ui.painter().rect(r, 8.0, fill, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + icons::paint(ui.painter(), r.shrink(10.0), icon, if open { t.accent_on } else { t.icon }); + if resp.clicked() { + if side == MobilePanel::Left && !open { + self.ui.open_left_panel(); + } else { + self.ui.mobile_panel = if open { None } else { Some(side) }; + } + } + ui.add_space(4.0); + } + }); + } + + /// Draw the toast: start its lifetime on the frame **after** the first drawn frame, request + /// that frame and the frame that hides it, expire and remove it at the deadline, and place it + /// at the bottom centre. + pub(super) fn toast_ui(&mut self, ctx: &egui::Context) { + let Some((msg, clock)) = &mut self.ui.toast else { return }; + let now = ctx.input(|i| i.time); + // The clock starts on the frame after the one that first draws the toast: that frame's + // time was taken before whatever raised it ran (a file dialog, a long open), and would + // count that wait against it, so a failed open's message expired before it was seen. + let left = match *clock { + ToastClock::Raised => { + *clock = ToastClock::Drawn; + ctx.request_repaint(); + TOAST_SECS + } + ToastClock::Drawn => { + *clock = ToastClock::Since(now); + TOAST_SECS + } + ToastClock::Since(t) => TOAST_SECS - (now - t), + }; + if left <= 0.0 { + self.ui.toast = None; + return; + } + let msg = msg.clone(); + // Nothing moves while it shows: the next frame it needs is the one that hides it. + if matches!(clock, ToastClock::Since(_)) { + repaint_in(ctx, left); + } + let screen = ctx.content_rect(); + egui::Area::new(egui::Id::unique("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| { + // Wrapped at a readable width (the area alone would wrap at a sliver). + let width = (screen.width() - 64.0).clamp(120.0, 480.0); + let galley = ui.painter().layout(msg, theme::regular(12.0), egui::Color32::WHITE, width); + ui.label(galley); + }); + }); + } +} diff --git a/crates/ui-egui/src/app/ime.rs b/crates/ui-egui/src/app/ime.rs new file mode 100644 index 000000000..df0a7fd2b --- /dev/null +++ b/crates/ui-egui/src/app/ime.rs @@ -0,0 +1,20 @@ +use super::*; + +impl FigApp { + /// The pass's one IME observation: the focused on-canvas editor, or an explicit idle. + /// + /// Calling `ImeContextTracker::observe` more than once in a pass would treat an idle followed + /// by a focused observation as a context change (dropping the frame's receipts) and bump the + /// generation. Keeping this in one place makes the fork contract structural. + pub(super) fn observe_ime(&mut self, ctx: &egui::Context, focused: bool) { + #[cfg(test)] + { + self.ime_observations = self.ime_observations.saturating_add(1); + } + if focused { + shortcuts::ime_output(self, ctx); + } else { + let _ = self.ime.observe(ctx.viewport_id(), None); + } + } +}