Files
vectorcraft/crates/ui-egui/src/app.rs
T

547 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::caches::{CanvasCache, Perf};
use super::clock::now_ms;
use super::clock::now_secs;
use super::services::{Services, browsable_url};
#[cfg(not(target_arch = "wasm32"))]
use super::state::FONT_INSTALL_POLL;
use super::state::{
CaptureEpoch, GeometryStamp, InputGeneration, MAX_PENDING_SCREENSHOTS, MAX_SCREENSHOT_BYTES, MAX_SCREENSHOT_TOTAL_BYTES, PendingScreenshot,
QueuedScreenshot, SCREENSHOT_SETTLE_FRAMES, SCREENSHOT_TIMEOUT_ERROR, SCREENSHOT_TIMEOUT_MS, SYSTEM_CLIPBOARD_POLL, take_frame_once,
};
use super::{
Arc, AutomationRoots, ControlRequest, Ordering, Receiver, Session, UiState, Value, VectorcraftApp, View, ViewInfo, background, canvas, chrome,
clipboard_probe, dialogs, export_progress, file_access, fileio, font_menu, graphics, i18n, io, json, menus, native_menu, opening, palette,
panel_docking, panels, picks, place, placing, plugin_runs, prefs_dialog, recovery, screen_color, scrub, shortcut_editor, shortcuts, theme,
titlebar, toolbar, tools_float, trace_runs, unsaved, updates_ui, widgets,
};
mod clipboard;
mod commands;
mod control;
mod files;
mod fonts;
mod frame;
mod ime;
mod input;
mod links;
mod prefs;
mod screenshot;
mod view;
pub use fonts::MAX_RECENT_FONTS;
impl VectorcraftApp {
/// Takes the host's Caps Lock state, which gives the drawing tools the crosshair while it is on
/// without changing the saved Use Precise Cursors preference.
pub fn set_caps_lock(&mut self, on: bool) {
if self.session.caps_lock != on
&& let Err(err) = self.run("view.capsLock", json!({ "on": on }))
{
log::error!("Could not update Caps Lock cursor state: {err}");
}
}
/// The app around `session`, reaching the platform through `services` (file pickers, the
/// clipboard, files and the like). It starts cataloguing the installed fonts at once, gives
/// Data Recovery the host's store and makes a view for each open document.
pub fn new(mut session: Session, services: Services) -> Self {
// The font menus and the first file opened need the installed fonts: catalog them now.
vectorcraft_text::FontDb::global().scan_in_background();
if let Some(store) = &services.recovery_store {
session.recovery.set_store(store.clone());
}
// The place cursor's clicks and drags place their files through the app
// ([`place::place_from_cursor`]).
session.place_cursor_requests = true;
let views = session.documents().iter().map(View::of).collect();
Self {
session,
ui: UiState::default(),
views,
services,
canvas: CanvasCache {
renderer: vectorcraft_render::Renderer::new(),
texture: None,
key: None,
shown: None,
last_ms: 0.0,
moved: false,
from_worker: false,
waits_for_still: cfg!(target_arch = "wasm32"),
still: None,
fill: None,
fill_worker: None,
worker: None,
worker_started: false,
slices: None,
print_tiling: None,
live_paint_gaps: None,
hidden_chars: Default::default(),
selection_box: None,
selection_bounds: None,
content_bounds: None,
cursors: Default::default(),
view_moved: None,
hover_cursor: None,
presented: None,
panes: Default::default(),
},
perf: Perf::default(),
dock_rt: Default::default(),
titlebar_inset: 0.0,
last_effect: None,
last_pathfinder: None,
last_warp: None,
character_focus: None,
run_count: 0,
clipboard_out: None,
clipboard_published: None,
clipboard_in: None,
clipboard_publishing: Default::default(),
pending_url: None,
control_rx: None,
pending_screenshots: vec![],
queued_screenshots: vec![],
screenshot_token: 0,
screenshot_reserved: 0,
synthetic: vec![],
styled: false,
fonts_ready: false,
context: 0,
ui_ctx: None,
ui_fonts: Default::default(),
updates_rt: Default::default(),
frame_rate: Default::default(),
canvas_rect: None,
hover_doc: None,
custom_titlebar: false,
last_window_title: String::new(),
fullscreen_for_mode: false,
graphics_adapter: None,
place: Default::default(),
system_paste: false,
clipboard_image_request: None,
system_paste_at: f64::NEG_INFINITY,
clipboard_probe: None,
picks: picks::Picks::default(),
screen_sampler: None,
font_check: None,
#[cfg(not(target_arch = "wasm32"))]
font_install_check_at: f64::NEG_INFINITY,
paste_chord: Default::default(),
// Held keys repeat their command (Cmd+= keeps zooming in).
dispatcher: craft_keymap::egui::Dispatcher::new().with_repeat(true),
background: Default::default(),
opening: Default::default(),
plugin_runs: Default::default(),
placing: Default::default(),
trace_runs: Default::default(),
recovery: Default::default(),
host_modifiers: Default::default(),
forwarded: Vec::new(),
forwarded_modifiers: None,
forwarded_modifiers_held: false,
forwarded_frame: false,
synthetic_modifiers: false,
ime_marked: None,
ime_context: Default::default(),
ime_compositions: Default::default(),
ime_resets: Default::default(),
ime_caret_rect: None,
ime_discard: false,
prefs_changed: false,
asset_export_presets: Value::Null,
touch_screen: false,
scrub_group: false,
scrub_scope: None,
automation_roots: None,
synthetic_frame: false,
pen_end: Default::default(),
host_iteration: 0,
#[cfg(test)]
now_override: None,
tick_iteration: None,
input_frames: vec![],
shortcuts_frames: vec![],
synthetic_injected: false,
injected_events: vec![],
injected_generation: InputGeneration::default(),
injected_settle_hook: false,
injected_settled: Default::default(),
geometry_epoch: 0,
geometry_stamp: None,
}
}
pub fn with_control(mut self, rx: Receiver<ControlRequest>) -> Self {
self.control_rx = Some(rx);
self
}
/// Confine what the control channel does to `roots` (#832): its requests, the input it
/// injects (in the frames that carry it) and the saves and exports they start read and write
/// only inside them ([`file_access`]). The host's file services check every path they're given
/// against the roots in force, and so does the engine; outside those scopes the person at the
/// keyboard works as usual.
pub fn with_automation_roots(mut self, roots: Option<Arc<AutomationRoots>>) -> Self {
let Some(roots) = roots else { return self };
self.automation_roots = Some(roots);
let s = &mut self.services;
if let Some(read) = s.read.take() {
s.read = Some(Box::new(move |p: &str| file_access::check_read(p).and_then(|()| read(p))));
}
if let Some(mut write) = s.write.take() {
s.write = Some(Box::new(move |p: &str, b: &[u8]| file_access::check_write(p).and_then(|()| write(p, b))));
}
if let Some(write) = s.write_shared.take() {
s.write_shared = Some(Arc::new(move |p: &str, b: &[u8]| file_access::check_write(p).and_then(|()| write(p, b))));
}
if let Some(stream) = s.stream_shared.take() {
s.stream_shared = Some(Arc::new(move |p: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>| {
file_access::check_write(p).and_then(|()| stream(p, fill))
}));
}
// Edit Original and Show in Folder hand a file to another app: only one automation may read.
for service in [&mut s.open_file, &mut s.reveal] {
if let Some(mut open) = service.take() {
*service = Some(Box::new(move |p: &str| file_access::check_read(p).and_then(|()| open(p))));
}
}
self
}
}
/// eframe isn't a dependency of this crate (the host owns the event loop); these entry points are
/// called from the host's `eframe::App` impl.
impl VectorcraftApp {
/// The language the UI is drawn in: the Preferences dialog's choice while it is open (so a
/// change shows before OK), else the `interfaceLanguage` preference (`auto` = the system's).
pub fn ui_language(&self) -> i18n::Lang {
let editing =
self.ui.dialog.as_ref().filter(|d| d.kind == "preferences").and_then(|d| d.fields.get("interfaceLanguage")).and_then(Value::as_str);
i18n::Lang::from_pref(editing.unwrap_or(&self.session.prefs.interface_language))
}
/// The host starts one iteration: called from both hosts' `raw_input_hook`, before the host
/// decides whether a UI pass follows. It counts the iteration; it is not a frame or pass count
/// and does not promise that `ui` will run (a hidden window gets `tick` only).
pub fn begin_host_iteration(&mut self) {
self.host_iteration = self.host_iteration.wrapping_add(1);
// Frame keys identify input already taken in this host iteration. Each iteration starts
// fresh; this is what makes a viewport's discarded pass a duplicate, while two viewports'
// frame 0 both count.
self.input_frames.clear();
self.shortcuts_frames.clear();
}
/// Background work, once per host iteration (full or logic-only): control requests, background
/// saves/exports/opens, plug-ins, recovery, font and clipboard polls, capture completion. It
/// reads only viewport state from `ctx.input` — never events or `i.time`: a logic-only pass
/// leaves the last shown frame's input in place.
///
/// A bug that panics costs one iteration and shows an error, instead of closing the app with
/// unsaved work.
pub fn tick(&mut self, ctx: &egui::Context) {
let confined = self.synthetic_frame.then(|| self.automation_roots.clone()).flatten();
if let Err(msg) = file_access::confine(confined.as_ref(), || vectorcraft_engine::guard::catch_panic(|| self.tick_frame(ctx))) {
self.picks.recover();
self.status(format!("Internal error: {msg} (please report this bug)"));
}
}
/// [`Self::tick`]'s work, once per host iteration: a second call in the same iteration (a
/// discarded rerun shares one hook call) does nothing.
fn tick_frame(&mut self, ctx: &egui::Context) {
if self.tick_iteration == Some(self.host_iteration) {
return;
}
self.tick_iteration = Some(self.host_iteration);
let dialog_was_open = self.ui.dialog.is_some();
let lang = self.ui_language();
i18n::set_current(lang);
// The engine gives new type the Japanese defaults while the UI is in Japanese.
if self.session.ui_language.as_deref() != Some(lang.code()) {
self.session.ui_language = Some(lang.code().to_string());
}
self.install_injected_settle_hook(ctx);
self.advance_geometry_epoch(ctx);
// Monotonic host time, never egui's `InputState::time`: a logic-only (hidden) iteration
// replays the last shown pass's time, freezing recovery, clipboard polling and deadlines.
// Tests inject a clock to drive idle scheduling deterministically.
#[cfg(test)]
let now = self.now_override.unwrap_or_else(now_secs);
#[cfg(not(test))]
let now = now_secs();
self.perf.fps = self.frame_rate.frame(now, self.perf.frame_ms / 1000.0);
self.sync_views();
updates_ui::tick(self, ctx);
// Read the system clipboard only when that alone decides whether Paste is enabled. A
// background thread reads it where the host installs one (an unresponsive owner must never
// stall the frame loop); otherwise, or once that thread is gone, it is read here, at most a
// few times a second (opening it locks it against other apps on some systems).
let wanted = self.session.clipboard.is_empty() && self.session.active().is_some();
if let Some(make) = self.services.clipboard_probe.take() {
self.clipboard_probe = clipboard_probe::Probe::start(make, ctx.clone());
}
if let Some(pasteable) = self.clipboard_probe.as_mut().and_then(|p| p.pasteable(wanted)) {
self.system_paste = pasteable;
} else if !(0.0..SYSTEM_CLIPBOARD_POLL).contains(&(now - self.system_paste_at)) {
self.clipboard_probe = None;
self.system_paste_at = now;
self.system_paste = wanted && self.system_clipboard_pasteable();
}
#[cfg(not(target_arch = "wasm32"))]
if !(0.0..FONT_INSTALL_POLL).contains(&(now - self.font_install_check_at)) {
self.font_install_check_at = now;
// Read on the next frame the app draws anyway: an idle app isn't woken for it.
self.refresh_installed_fonts(false);
}
self.poll_font_check(ctx);
picks::poll(self, ctx);
// A Paste the host read before this iteration's formats were written must still wait for
// them, even if the worker below finishes them during this same tick.
ctx.data_mut(|d| d.insert_temp(egui::Id::unique("clipboard.pending-at-tick"), self.clipboard_publishing.is_pending()));
background::poll(self);
screen_color::poll(self, ctx);
opening::poll(self);
plugin_runs::poll(self);
trace_runs::poll(self);
placing::poll(self);
#[cfg(not(target_arch = "wasm32"))]
self.tick_link_updates(ctx, now);
if !self.background.jobs.is_empty()
|| !self.opening.pending.is_empty()
|| !self.plugin_runs.running.is_empty()
|| !self.placing.pending.is_empty()
|| !self.trace_runs.running.is_empty()
{
// Keep the status bar's progress moving and pick the result up when it arrives.
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
// The window's close button (or the system quitting the app) asks about unsaved documents,
// once the saves running in the background are done. Not while a file dialog's sheet is on
// the window: what asked for it runs again when it answers.
if ctx.input(|i| i.viewport().close_requested()) {
// This native viewport is going away: release its IME context. No request is
// allocated here: the close prompt or file sheet publishes no custom descriptor, so a
// token would survive for a later stock field. A close the user then cancels starts a
// fresh generation on the next pass (never inherits a composition across the prompt).
crate::ime::close_viewport(self, ctx);
self.ime_context.end_viewport(ctx.viewport_id());
background::wait_all(self);
if self.file_sheet_open() {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
self.status(picks::busy());
} else if unsaved::any_dirty(self) {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
if let Err(e) = unsaved::close_all(self, "quit") {
self.status(e);
}
} else {
// Quitting with nothing unsaved: no copies to leave behind.
vectorcraft_engine::cmd::recovery::forget_all(&mut self.session);
}
}
if let Some(wait) = recovery::frame(self, now) {
// The timer and heartbeat run in an idle window too.
ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait));
}
crate::keymap::note_os(ctx);
shortcut_editor::sync(&self.ui);
prefs_dialog::apply_runtime(self, ctx);
// The OS title bar (and the taskbar / Alt-Tab entry) follows the active file; with the
// system title bar this is where the document name lives, as the in-app mark is hidden.
crate::chrome::sync_window_title(self, ctx);
crate::chrome::sync_fullscreen(self, ctx);
self.drain_control(ctx);
// An egui `PasteImage` with a rich system clipboard: run the paste command now, once. It
// goes through `run`, whose `edit.paste` branch calls `adopt_system_clipboard` (which clears
// the request) to read the formats and then pastes them. A text paste in the same UI pass
// never sets the request, so the two cannot both paste.
if let Some(held) = self.clipboard_image_request.take() {
crate::menus::invoke(self, crate::shortcuts::paste_command(held), json!({}));
}
self.collect_screenshots(ctx);
if let Some(u) = self.pending_url.take() {
ctx.open_url(egui::OpenUrl::new_tab(u));
}
self.flush_clipboard_out(ctx);
self.drain_inbox();
if self.viewport_hidden(ctx) && (self.needs_hidden_paint() || (!dialog_was_open && self.ui.dialog.is_some())) {
// A plain repaint runs only logic in a hidden window: the ui that applies injected
// input, shows a queued dialog or issues a capture needs this command. egui-winit
// handles it; the 0.37 web backend does not (a pending fork fix), so a hidden browser
// tab cannot be claimed as qualified.
ctx.send_viewport_cmd(egui::ViewportCommand::RequestPaintWhileHidden);
}
if !self.synthetic.is_empty() || self.synthetic_injected || !self.pending_screenshots.is_empty() {
ctx.request_repaint();
}
}
/// The 0.36-style entry point, kept for the crate's tests, which call one `logic` inside a
/// `run_ui` pass and draw a panel afterwards (a UI phase). Production hosts call
/// `raw_input_hook`, then `tick` in `App::logic` and `ui` in `App::ui`; `tick` alone never
/// takes input, so a hidden logic-only iteration cannot replay the last shown frame's events.
///
/// Outside tests this is just [`Self::tick`] with an iteration boundary.
pub fn logic(&mut self, ctx: &egui::Context) {
self.begin_host_iteration();
self.tick(ctx);
#[cfg(test)]
{
let confined = self.synthetic_frame.then(|| self.automation_roots.clone()).flatten();
let _ = file_access::confine(confined.as_ref(), || {
vectorcraft_engine::guard::catch_panic(|| {
self.ui_setup(ctx);
self.handle_input_once(ctx);
})
});
}
self.finish_ime_logic(ctx);
}
/// First UI-pass setup, in a real `run_ui` phase: adopt the context, install the interface
/// fonts and theme, and the image loaders. 0.37's `set_fonts` applies at once when no text has
/// been laid out, so the same pass can draw; a hidden logic-only iteration never installs them.
fn ui_setup(&mut self, ctx: &egui::Context) {
// Hosts install before their first pass. Installing here covers later passes only: egui
// has already snapshotted this pass's plugin list, so a direct harness that calls `logic`
// inside its first `run_ui` must call `install_ime_reset_hooks` before that `run_ui`
// (as `tests_lifecycle::ime_frame` does). This is not a first-pass bootstrap fallback.
crate::ime::install_hooks(ctx);
self.adopt_context(ctx);
if !self.styled {
// The saved accent, color scheme and canvas color, not the defaults (#4837).
prefs_dialog::reapply(self, ctx);
egui_extras::install_image_loaders(ctx);
self.styled = true;
}
if !self.fonts_ready {
// `set_fonts` applies at once only while no text was laid out in this pass; when it
// defers, the next pass applies it and this leaves input and drawing to that pass.
let fonts = theme::interface_fonts();
ctx.set_fonts(fonts.clone());
self.fonts_ready = ctx.fonts(|applied| applied.definitions() == &fonts);
}
}
/// The canvas panel's body: the document tabs, the canvas (a pane for each document while
/// Window › Arrange › Tile is on) and the isolation bar over it.
pub(crate) fn canvas_area(&mut self, ui: &mut egui::Ui) {
let t = theme::Tokens::get(ui.ctx());
ui.painter().rect_filled(ui.max_rect(), 0.0, t.pasteboard);
if self.ui.screen_mode < 3 {
chrome::doc_tabs(self, ui);
}
if !canvas::tiled_area(self, ui) {
canvas::show(self, ui);
}
// Over the canvas inside the rulers, so the rulers and scroll bars stay uncovered.
if self.ui.screen_mode < 3
&& !menus::home_showing(self)
&& let Some(area) = self.canvas_rect
{
chrome::isolation_bar(self, ui, area);
}
}
/// Lay out the whole window: the first UI pass of a host iteration sets the UI up (fonts,
/// theme, image loaders) and takes the input; later passes (a `request_discard` rerun) draw
/// only.
pub fn ui(&mut self, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone();
crate::ime::begin_ui(self, &ctx);
let confined = self.synthetic_frame.then(|| self.automation_roots.clone()).flatten();
if let Err(msg) = file_access::confine(confined.as_ref(), || {
vectorcraft_engine::guard::catch_panic(|| {
self.ui_setup(&ctx);
self.handle_input_once(&ctx);
self.ui_frame(ui);
})
}) {
self.picks.recover();
self.status(format!("Internal error: {msg} (please report this bug)"));
}
crate::ime::finish_ui(self, &ctx);
}
/// [`Self::ui`]'s work, inside its guard: the bars, panels and canvas, then the dialogs and
/// progress windows over them.
fn ui_frame(&mut self, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone();
if !self.fonts_ready {
// Do not draw (or run the app's input handling) before the interface fonts are the
// context's: a deferred `set_fonts` applies at the start of the next pass.
ctx.request_repaint();
return;
}
crate::state::sync_zoom_stops_display_print_size(self.session.prefs.display_print_size);
let t0 = now_ms();
widgets::set_dialog_open(&ctx, self.ui.dialog.is_some());
// The canvas records this pass's IME caret geometry; a pass that draws no canvas has none.
self.ime_caret_rect = None;
scrub::begin_frame(self, &ctx);
font_menu::end_stale_preview(self, &ctx);
tools_float::track(self, &ctx);
// A selection move released over a panel ends before any panel sees the release (#2826).
canvas::release_over_panel(self, &ctx);
let t = theme::Tokens::get(&ctx);
if self.ui.screen_mode < 2 {
chrome::app_bar(self, ui);
if self.ui.control_bar {
chrome::control_bar(self, ui);
}
}
if self.ui.status_bar && self.ui.screen_mode < 3 {
chrome::status_bar(self, ui);
chrome::hint_bar(self, ui);
} else {
// Keeps the previous frame's value valid for menus drawn above, before the bars.
widgets::set_menu_floor(&ctx, ctx.content_rect().bottom());
}
if self.ui.toolbar && self.ui.screen_mode < 3 {
toolbar::show(self, ui);
} else if self.ui.screen_mode < 3 && self.ui.tab_hid.is_none() {
// Window › Tools hides the Tools panel alone; Tab hides the toolbars made with New
// Toolbar too.
toolbar::show_custom(self, ui.ctx());
}
panels::library_panel::tidy(self);
// The canvas and the panels: one docking layout. Tab hides the panels but not the canvas.
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.pasteboard)).show(ui, |ui| panel_docking::show(self, ui));
dialogs::show(self, &ctx);
updates_ui::show(self, &ctx);
export_progress::show(self, &ctx);
plugin_runs::show(self, &ctx);
trace_runs::show(self, &ctx);
palette::show(self, &ctx);
if self.custom_titlebar {
titlebar::resize_zones(ui);
}
self.ui_fonts.frame(&ctx);
if self.ui.dialog.is_none() && !self.ui.palette_open {
shortcuts::remember_text_focus(&ctx);
}
scrub::end_frame(self, &ctx);
native_menu::sync(self, &ctx);
// Observe once, after all of this pass's input, dialogs, floating panels, palette and
// commands: the latest completed pass is authoritative for owner, text and cause.
crate::ime::end_of_pass(self, &ctx);
self.drop_formats_after_egui_copy(&ctx);
// A command run by a menu, panel or dialog in this pass may have queued its text copy
// after the UI phase's input flush. Publish it now for the same reason as there.
self.flush_clipboard_out(&ctx);
if opening::long_operation_busy(self) {
ctx.set_cursor_icon(egui::CursorIcon::Progress);
}
// A pass egui will discard and rerun is not a presented frame: it must not become the
// frame-time sample, and it must not issue a capture of what it drew.
if !ctx.will_discard() {
self.perf.frame_ms = now_ms() - t0;
self.issue_screenshots(&ctx);
}
let _ = json!(null);
}
}