Skip to main content

ashlar_app/
runtime.rs

1//! The runtime: one frame of an app (control requests, jobs, autosave, menus, keys, the view,
2//! dialogs), the repaint policy, and the eframe glue.
3//!
4//! Repaints happen only for: input (egui/eframe), a store revision change made during a frame, a
5//! job's wake, a control request, toasts and egui animations, injected agent input, the text
6//! caret's blink for a while after input ([`crate::caret`]), and pending
7//! work that asks for its own frame (settings and autosave wake hooks when a save is due, the
8//! autosave writer when a write finished). Every request goes through [`crate::wake`] with a
9//! reason (`app.stats` → `wakes`). An idle app, clean or dirty, requests nothing and draws no
10//! frames.
11
12use std::panic::{AssertUnwindSafe, catch_unwind};
13use std::path::PathBuf;
14use std::sync::Arc;
15use std::sync::mpsc::Receiver;
16use std::time::Duration;
17
18use ashlar_command::shortcut::{Platform, format_list};
19use ashlar_command::{CommandError, CommandInfo, Keymap, Registry};
20use ashlar_control::{AppInfo, Reply, Request, Server};
21use ashlar_fs::{ConfigStore, DirConfig, MemoryConfig, Settings};
22use ashlar_jobs::Jobs;
23use ashlar_store::{Autosave, AutosaveConfig, RecoveryCandidate, Revision};
24use ashlar_ui::automation;
25use ashlar_ui::commands::keys::works_while_typing;
26use ashlar_ui::commands::{CommandPalette, KeymapChange, KeymapEditor, MenuSpec, dispatch, menu_bar};
27use ashlar_ui::devtools;
28use ashlar_ui::dialog::{Dialog, DialogButton, DialogOutcome};
29use ashlar_ui::toasts::Toasts;
30use ashlar_ui::tokens::{self, ThemeMode};
31use ashlar_ui::wake::{self, Reason, Scheduler, WakeStats};
32use serde_json::{Value, json};
33
34use crate::app::{App, Error, SetupFn, Shared, ViewFn};
35use crate::caret::Caret;
36use crate::control::UiQueue;
37use crate::crash::{self, LaunchGuard, PreviousLaunch};
38use crate::open::Inbox;
39use crate::session::{Document, Effect, Origin, Session, now};
40use crate::stats::FrameStats;
41use crate::telemetry::Telemetry;
42use crate::window_state::{self, WindowState};
43use ashlar_native::session_end::{self, EndKind, EndRequest};
44
45/// Wakes for a quit, logout or shutdown request from the OS (IMP-6.7).
46const SESSION_END: Reason = Reason("session_end");
47
48mod prefs;
49
50pub use ashlar_motion::MotionPrefs;
51pub use ashlar_native::system::Preferences;
52
53/// Top-level menus in this order; menus the app adds follow in first-seen order.
54pub const MENU_ORDER: &[&str] = &["File", "Edit", "View"];
55
56/// What `run()` found out before the window opened.
57#[derive(Default)]
58pub struct Launch {
59    /// Start without restoring view settings (after a crash during startup, or `--safe-mode`).
60    pub safe_mode: bool,
61    pub previous: Option<PreviousLaunch>,
62    /// A file named on the command line; it opens after the first frame.
63    pub open: Option<PathBuf>,
64    pub control_port: Option<u16>,
65    pub(crate) guard: Option<LaunchGuard>,
66    pub(crate) telemetry: Telemetry,
67    /// Files from the OS and later launches, set up before the window opened.
68    pub(crate) inbox: Option<Inbox>,
69    /// Held while this is the app's only instance ([`App::single_instance`]).
70    #[cfg(not(target_arch = "wasm32"))]
71    #[allow(dead_code, reason = "held for its drop, which stops listening")]
72    pub(crate) instance: Option<ashlar_native::single_instance::PrimaryGuard>,
73    /// The first line of `run()`, and when eframe had built the window (native only).
74    pub(crate) process_start: Option<std::time::Instant>,
75    pub(crate) window_at: Option<std::time::Instant>,
76    /// `<PREFIX>_READY_FILE`: gets `{pid, ready_ms}` once the first frame is presented.
77    pub(crate) ready_file: Option<PathBuf>,
78    /// The first present was recorded.
79    pub(crate) presented: bool,
80    /// A scene to apply once the session is set up (`--scene`).
81    pub(crate) scene: Option<String>,
82}
83
84/// Which file dialog a command asked for.
85#[derive(Clone, Copy, Debug, PartialEq, Eq)]
86pub enum Pick {
87    Open,
88    SaveAs,
89}
90
91/// Picks a file for [`Pick`]; `None` = cancelled. The default shows the native dialog.
92pub type FilePicker = Box<dyn FnMut(Pick) -> Option<PathBuf>>;
93
94/// What the view gets each frame: the session, commands, jobs, toasts and settings.
95pub struct Cx<'a, D: Document> {
96    session: &'a mut Session<D>,
97    registry: &'a Registry<Session<D>>,
98    /// Background work; a job's apply runs on the session (each edit there is an undo step).
99    pub jobs: &'a mut Jobs<Session<D>>,
100    pub toasts: &'a mut Toasts,
101    /// App settings (debounced, saved in the config folder).
102    pub settings: &'a mut Settings,
103    queued: &'a mut Vec<(String, Value)>,
104    dropped: &'a [PathBuf],
105    ctx: egui::Context,
106    /// Who changed the document since the view last drew (`None`: unchanged).
107    origin: Option<Origin>,
108}
109
110impl<D: Document> Cx<'_, D> {
111    /// The design tokens of the current theme (`cx.tokens().space.md`).
112    pub fn tokens(&self) -> tokens::Tokens {
113        tokens::current(&self.ctx)
114    }
115
116    /// Who changed the document since the view last drew; `None` when it did not change (or
117    /// changed without an edit: opened, replaced).
118    pub fn origin(&self) -> Option<Origin> {
119        self.origin
120    }
121
122    /// The value to draw for `id`, moving toward `target` along `curve`
123    /// ([`ashlar_ui::motion::value`], [`Class::Other`](ashlar_motion::Class::Other)). When the
124    /// document changed by the user's own hand since the last frame ([`Origin::animates`] is
125    /// false) it jumps to `target` instead ([`ashlar_ui::motion::set`]): typing and drags show at
126    /// once, while undo, agent and job changes animate.
127    pub fn motion<T>(&self, id: impl egui::AsId, target: T, curve: impl Into<ashlar_motion::Curve>) -> T
128    where
129        T: ashlar_motion::Animatable + Send + Sync + 'static,
130    {
131        let id = egui::Id::new(id);
132        if self.origin.is_some_and(|o| !o.animates()) {
133            ashlar_ui::motion::set(&self.ctx, id, target);
134            return target;
135        }
136        ashlar_ui::motion::value(&self.ctx, id, target, ashlar_motion::Class::Other, curve)
137    }
138
139    /// The current document.
140    pub fn doc(&self) -> &D {
141        self.session.doc()
142    }
143
144    /// The current document as an `Arc` (to keep while calling `&mut self` methods).
145    pub fn snapshot(&self) -> Arc<D> {
146        self.session.snapshot()
147    }
148
149    /// The revision of the current document (key caches by it).
150    pub fn revision(&self) -> Revision {
151        self.session.revision()
152    }
153
154    pub fn session(&self) -> &Session<D> {
155        self.session
156    }
157
158    pub fn session_mut(&mut self) -> &mut Session<D> {
159        self.session
160    }
161
162    pub fn ctx(&self) -> &egui::Context {
163        &self.ctx
164    }
165
166    /// Files dropped on the window this frame that the runtime did not open as the document
167    /// (another extension, or a second document). Empty with [`App::accept_drops`] off.
168    pub fn dropped_files(&self) -> &[PathBuf] {
169        self.dropped
170    }
171
172    /// Run a command after the view returns (menus, buttons). Errors are shown as toasts.
173    pub fn dispatch(&mut self, id: &str, params: Value) {
174        self.queued.push((id.to_string(), params));
175    }
176
177    /// Run a command now and get its result.
178    pub fn execute(&mut self, id: &str, params: Value) -> Result<Value, CommandError> {
179        self.registry.execute(self.session, id, params)
180    }
181
182    /// An undoable change made directly by the view (one step called `label`).
183    pub fn edit<R>(&mut self, label: &str, f: impl FnOnce(&mut D) -> R) -> R {
184        self.session.edit(label, f)
185    }
186
187    /// An undoable change folded into the previous one with the same label and key (a drag).
188    pub fn edit_merged<R>(&mut self, label: &str, merge_key: &str, f: impl FnOnce(&mut D) -> R) -> R {
189        self.session.edit_merged(label, merge_key, f)
190    }
191
192    /// View state (not in the document; `ui.set` changes it too).
193    pub fn view<T: serde::de::DeserializeOwned>(&self, key: &str) -> Option<T> {
194        self.session.view(key)
195    }
196
197    pub fn view_or<T: serde::de::DeserializeOwned>(&self, key: &str, default: T) -> T {
198        self.session.view_or(key, default)
199    }
200
201    pub fn set_view<T: serde::Serialize + ?Sized>(&mut self, key: &str, value: &T) {
202        self.session.set_view(key, value);
203    }
204}
205
206#[derive(Default)]
207struct InfoCache {
208    key: Option<(Revision, u64, u64, u64)>,
209    seq: u64,
210    infos: Vec<CommandInfo>,
211    menu: MenuSpec,
212}
213
214enum Modal {
215    Recovery,
216    SafeMode,
217    Confirm { quit: bool, command: Option<(String, Value)> },
218}
219
220struct AutosaveState {
221    autosave: Autosave,
222    #[cfg(not(target_arch = "wasm32"))]
223    writer: Option<ashlar_store::BackgroundWriter>,
224    file_gen: u64,
225}
226
227/// One app instance's frame loop. Built by [`App::runtime`] (tests, benches) or `App::run`.
228pub struct Runtime<D: Document> {
229    shared: Arc<Shared<D>>,
230    session: Session<D>,
231    jobs: Jobs<Session<D>>,
232    toasts: Toasts,
233    settings: Settings,
234    view: Option<ViewFn<D>>,
235    setup: Option<SetupFn>,
236    keymap: Keymap,
237    keymap_gen: u64,
238    palette: CommandPalette,
239    keymap_editor: KeymapEditor,
240    keymap_open: bool,
241    cache: InfoCache,
242    control: Option<(Server, Receiver<Request>)>,
243    injected_requests: Vec<Request>,
244    /// The revision the view last drew ([`Cx::origin`]).
245    view_rev: Option<Revision>,
246    autosave: Option<AutosaveState>,
247    recovery: Vec<RecoveryCandidate>,
248    modals: Vec<Modal>,
249    stats: FrameStats,
250    wakes: Scheduler,
251    launch: Launch,
252    queued: Vec<(String, Value)>,
253    created: Option<std::time::Instant>,
254    started: bool,
255    first_frame_done: bool,
256    view_error: Option<String>,
257    allow_close: bool,
258    quit_after_save: bool,
259    /// Quit, logout and shutdown requests from the OS (native only) and where answering them is.
260    session_end: Option<Receiver<EndRequest>>,
261    end_state: session_end::State,
262    /// The last answer given to the OS: `Some(true)` allowed the end.
263    end_reply: Option<bool>,
264    /// A command the confirm dialog's Save holds back until the save (maybe through Save As)
265    /// is done.
266    after_save: Option<(String, Value)>,
267    /// A file dialog a command asked for, shown at the start of the next frame (IMP-6.5).
268    pending_pick: Option<Pick>,
269    /// The window placement written to the settings (IMP-6.4).
270    window: window_state::Tracker,
271    shown_title: String,
272    /// (dirty, file generation) the title was made for.
273    title_key: Option<(bool, u64)>,
274    /// The view generation last written to the settings.
275    persisted_view: Option<u64>,
276    picker: Option<FilePicker>,
277    passes: u64,
278    /// Times each pass for the perf warnings (`None`: nothing uses it).
279    probe: Option<crate::perf_warn::Probe>,
280    /// `ASHLAR_RECORD`: input and control requests appended for replay ([`crate::record`]).
281    recorder: Option<crate::record::Recorder>,
282    probe_installed: bool,
283    /// `app.stats` → `process`.
284    process: crate::stats::ProcessProbe,
285    /// The wake counts at the probe's previous frame (their growth is the frame's reasons).
286    probe_wakes: ashlar_ui::wake::WakeStats,
287    caret: Caret,
288    /// `app.clock`: the time frames see (pause, step, resume).
289    clock: crate::clock::Clock,
290    /// Running in a real window (not the headless driver): OS menus may be installed.
291    #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
292    pub(crate) native: bool,
293    #[cfg(all(feature = "native-menu", any(target_os = "macos", target_os = "windows")))]
294    native_menu: Option<crate::native_menu::NativeMenu>,
295    /// The OS menu bar was tried (installed and attached, or refused) on the first frame.
296    #[cfg_attr(not(all(feature = "native-menu", any(target_os = "macos", target_os = "windows"))), allow(dead_code))]
297    native_menu_tried: bool,
298    /// The tray icon ([`App::tray`](crate::App::tray)), installed with the OS menu bar.
299    #[cfg(all(feature = "tray", not(target_arch = "wasm32")))]
300    tray: Option<ashlar_native::tray::Tray>,
301    #[cfg(all(feature = "tray", not(target_arch = "wasm32")))]
302    tray_tried: bool,
303    #[cfg(all(feature = "hotkey", not(target_arch = "wasm32")))]
304    global_shortcuts: Option<ashlar_native::hotkey::GlobalShortcuts>,
305    #[cfg(all(feature = "hotkey", not(target_arch = "wasm32")))]
306    global_shortcuts_tried: bool,
307    /// Dock reopen requests (macOS), listened for after the first frame.
308    #[cfg(target_os = "macos")]
309    reopen: Option<std::sync::mpsc::Receiver<ashlar_native::mac_shell::Reopen>>,
310    #[cfg(target_os = "macos")]
311    mac_shell_tried: bool,
312    /// Bring the window to the front on the next frame (a forwarded launch, a tray click, a
313    /// global shortcut), through the OS where the focus request alone may be refused.
314    #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
315    bring_front: bool,
316    /// Ids picked in the OS menu bar, run by [`Runtime::logic`] (also fed by
317    /// [`crate::Driver::native_menu_pick`]).
318    native_picks: Vec<String>,
319    os_prefs: prefs::OsPrefs,
320    /// Files from the OS and later launches.
321    inbox: Inbox,
322    /// Paths dropped since the last [`Runtime::logic`] (when drops are accepted).
323    drops: Vec<PathBuf>,
324    /// Dropped files left for the view this frame ([`Cx::dropped_files`]).
325    dropped: Vec<PathBuf>,
326    /// A file from outside waiting for open dialogs to close; a newer one replaces it.
327    open_next: Option<PathBuf>,
328}
329
330fn panic_text(payload: &(dyn std::any::Any + Send)) -> String {
331    payload
332        .downcast_ref::<&str>()
333        .map(|s| (*s).to_string())
334        .or_else(|| payload.downcast_ref::<String>().cloned())
335        .unwrap_or_else(|| "unknown error".into())
336}
337
338impl<D: Document> Runtime<D> {
339    pub(crate) fn new(shared: Arc<Shared<D>>, view: Option<ViewFn<D>>, setup: Option<SetupFn>, mut launch: Launch) -> Self {
340        // `system_locales()` also reads the OS's language list (desktop launches have no `LANG`)
341        ashlar_i18n::set_native_languages(ashlar_native::system::preferred_languages);
342        let inbox = launch.inbox.take().unwrap_or_default();
343        let mut session = Session::new(Arc::clone(&shared.format));
344        session.set_windowed(true);
345        session.telemetry = launch.telemetry.clone();
346        let config: Arc<dyn ConfigStore> = match &shared.dirs {
347            Some(d) => Arc::new(DirConfig::new(d.config.clone())),
348            None => Arc::new(MemoryConfig::new()),
349        };
350        let settings = Settings::load(config, "settings.json", &[]);
351        let mut keymap = Keymap::from_registry(&shared.registry, Platform::current());
352        if let Some(o) = settings.get_value("keymap") {
353            keymap.apply_overrides_json(o);
354        }
355        if !launch.safe_mode
356            && let Some(Value::Object(view)) = settings.get_value("view")
357        {
358            for (k, v) in view {
359                session.set_view_value(k, v.clone());
360            }
361        }
362        let mut rt = Runtime {
363            session,
364            jobs: Jobs::new(),
365            toasts: Toasts::new(),
366            settings,
367            view,
368            setup,
369            keymap,
370            keymap_gen: 0,
371            palette: CommandPalette::new(),
372            keymap_editor: KeymapEditor::new(),
373            keymap_open: false,
374            cache: InfoCache::default(),
375            control: None,
376            injected_requests: Vec::new(),
377            view_rev: None,
378            autosave: None,
379            recovery: Vec::new(),
380            modals: Vec::new(),
381            stats: FrameStats::default(),
382            wakes: Scheduler::default(),
383            queued: Vec::new(),
384            created: now(),
385            started: false,
386            first_frame_done: false,
387            view_error: None,
388            allow_close: false,
389            quit_after_save: false,
390            session_end: None,
391            end_state: session_end::State::Idle,
392            end_reply: None,
393            after_save: None,
394            pending_pick: None,
395            window: window_state::Tracker::default(),
396            shown_title: String::new(),
397            title_key: None,
398            persisted_view: None,
399            picker: None,
400            passes: 0,
401            probe: crate::perf_warn::Probe::from_env(),
402            recorder: crate::record::Recorder::from_env(),
403            probe_installed: false,
404            process: crate::stats::ProcessProbe::default(),
405            probe_wakes: ashlar_ui::wake::WakeStats::default(),
406            caret: Caret::default(),
407            clock: crate::clock::Clock::default(),
408            native: false,
409            #[cfg(all(feature = "native-menu", any(target_os = "macos", target_os = "windows")))]
410            native_menu: None,
411            native_menu_tried: false,
412            #[cfg(all(feature = "tray", not(target_arch = "wasm32")))]
413            tray: None,
414            #[cfg(all(feature = "tray", not(target_arch = "wasm32")))]
415            tray_tried: false,
416            #[cfg(all(feature = "hotkey", not(target_arch = "wasm32")))]
417            global_shortcuts: None,
418            #[cfg(all(feature = "hotkey", not(target_arch = "wasm32")))]
419            global_shortcuts_tried: false,
420            bring_front: false,
421            #[cfg(target_os = "macos")]
422            reopen: None,
423            #[cfg(target_os = "macos")]
424            mac_shell_tried: false,
425            native_picks: Vec::new(),
426            os_prefs: prefs::OsPrefs::new(shared.follow_system_accent, shared.motion),
427            inbox,
428            drops: Vec::new(),
429            dropped: Vec::new(),
430            open_next: None,
431            launch,
432            shared,
433        };
434        if let (Some(s), Some(w)) = (rt.launch.process_start, rt.launch.window_at) {
435            rt.stats.set_to_window(w.saturating_duration_since(s));
436        }
437        rt.start_autosave();
438        // `launch.open` waits for the first frame (open_from_outside): the window shows first
439        if rt.launch.safe_mode {
440            rt.modals.push(Modal::SafeMode);
441        }
442        // with a command-line document, the scene waits for it (open_from_outside)
443        if rt.launch.open.is_none() {
444            rt.apply_launch_scene();
445        }
446        rt
447    }
448
449    /// The `--scene` named at launch, once.
450    fn apply_launch_scene(&mut self) {
451        if let Some(name) = self.launch.scene.take()
452            && let Err(e) = self.apply_scene(&name)
453        {
454            log::error!("{e}");
455            self.toasts.error(e);
456        }
457    }
458
459    /// Run the scene called `name` ([`crate::App::scene`]) on the session. Its effects run on
460    /// the next frame.
461    pub fn apply_scene(&mut self, name: &str) -> Result<(), String> {
462        let shared = Arc::clone(&self.shared);
463        let Some(scene) = shared.scene(name) else {
464            return Err(format!("no scene `{name}` (scenes: {})", shared.scene_names().join(", ")));
465        };
466        let session = &mut self.session;
467        catch_unwind(AssertUnwindSafe(|| scene(session))).map_err(|p| format!("scene `{name}` panicked: {}", panic_text(p.as_ref())))
468    }
469
470    fn start_autosave(&mut self) {
471        let (Some(every), Some(dirs)) = (self.shared.autosave, &self.shared.dirs) else { return };
472        let mut cfg = AutosaveConfig::new(dirs.data.join("Recovery"));
473        cfg.interval = every;
474        match Autosave::start(cfg) {
475            Ok((autosave, candidates)) => {
476                self.recovery = candidates;
477                if !self.recovery.is_empty() {
478                    self.modals.insert(0, Modal::Recovery);
479                }
480                self.autosave = Some(AutosaveState {
481                    autosave,
482                    #[cfg(not(target_arch = "wasm32"))]
483                    writer: ashlar_store::BackgroundWriter::start().ok(),
484                    file_gen: u64::MAX,
485                });
486            }
487            Err(e) => log::warn!("autosave is off: {e}"),
488        }
489    }
490
491    // ------------------------------------------------------------------ public API
492
493    pub fn session(&self) -> &Session<D> {
494        &self.session
495    }
496
497    pub fn session_mut(&mut self) -> &mut Session<D> {
498        &mut self.session
499    }
500
501    pub fn settings(&self) -> &Settings {
502        &self.settings
503    }
504
505    pub fn toasts(&self) -> &Toasts {
506        &self.toasts
507    }
508
509    /// Write one JSON line per frame to `path` (what `ASHLAR_FRAME_LOG=path` does; see
510    /// [`crate::frame_log`]), from the next frame on. Replaces an earlier log.
511    pub fn log_frames(&mut self, path: impl AsRef<std::path::Path>) -> std::io::Result<()> {
512        let log = crate::frame_log::FrameLog::create(path.as_ref(), crate::frame_log::MAX_BYTES)?;
513        self.probe.get_or_insert_with(crate::perf_warn::Probe::default).set_log(log);
514        Ok(())
515    }
516
517    /// Record every frame's input and time, and every control request, to `path` (what
518    /// `ASHLAR_RECORD=path` does; see [`crate::record`]), from the next frame on. Replaces an
519    /// earlier recording. [`crate::Driver::replay`] plays it back.
520    pub fn record_to(&mut self, path: impl AsRef<std::path::Path>) -> std::io::Result<()> {
521        self.recorder = Some(crate::record::Recorder::create(path.as_ref(), crate::record::MAX_BYTES)?);
522        Ok(())
523    }
524
525    /// Watch frames with these warning meters instead of the environment's (tests).
526    #[cfg(test)]
527    pub(crate) fn watch_frames(&mut self, warn: crate::perf_warn::PerfWarn) -> crate::perf_warn::Probe {
528        let p = crate::perf_warn::Probe::with_warn(warn);
529        self.probe = Some(p.clone());
530        p
531    }
532
533    /// Frame-time statistics (`app.stats`).
534    pub fn stats(&self) -> &FrameStats {
535        &self.stats
536    }
537
538    pub fn stats_mut(&mut self) -> &mut FrameStats {
539        &mut self.stats
540    }
541
542    /// Frames and wake requests per [`Reason`] (`app.stats` → `wakes`).
543    pub fn wakes(&self) -> WakeStats {
544        self.wakes.stats()
545    }
546
547    /// Passes run so far (frames, plus egui's occasional extra layout pass).
548    pub fn passes(&self) -> u64 {
549        self.passes
550    }
551
552    /// Replace the OS preferences (reduced motion, accent, contrast) with `source` (tests). Call
553    /// it before the first frame; it is read again whenever the window gains focus.
554    pub fn set_preferences(&mut self, source: impl FnMut() -> Preferences + 'static) {
555        self.os_prefs.source = Some(Box::new(source));
556    }
557
558    /// Replace the native file dialogs (tests, scripted runs).
559    pub fn set_file_picker(&mut self, picker: impl FnMut(Pick) -> Option<PathBuf> + 'static) {
560        self.picker = Some(Box::new(picker));
561    }
562
563    /// Run a command through the chokepoint now.
564    pub fn execute(&mut self, id: &str, params: Value) -> Result<Value, CommandError> {
565        self.shared.registry.execute(&mut self.session, id, params)
566    }
567
568    /// Hand a control request to the runtime as if it came from the control port (tests).
569    pub fn inject_request(&mut self, req: Request) {
570        self.injected_requests.push(req);
571    }
572
573    /// The control port, when one is serving.
574    pub fn control_port(&self) -> Option<u16> {
575        self.control.as_ref().map(|(s, _)| s.port())
576    }
577
578    /// Whether the app has decided to close (the window should go away).
579    pub fn is_closing(&self) -> bool {
580        self.allow_close
581    }
582
583    /// The error that suspended the view, if one did.
584    pub fn view_error(&self) -> Option<&str> {
585        self.view_error.as_deref()
586    }
587
588    fn info(&self) -> AppInfo {
589        AppInfo::new(self.shared.name.clone(), self.shared.version.clone())
590    }
591
592    // ------------------------------------------------------------------ start
593
594    fn ensure_started(&mut self, ctx: &egui::Context) {
595        if self.started {
596            return;
597        }
598        self.started = true;
599        devtools::install(ctx);
600        // without eframe's AccessKit adapter no screen reader can turn AccessKit on
601        if !cfg!(feature = "accesskit") {
602            devtools::set_platform_accesskit(ctx, false);
603        }
604        self.inbox.set_ctx(ctx);
605        let mode = if self.launch.safe_mode { ThemeMode::System } else { self.settings.get::<ThemeMode>("theme").unwrap_or_default() };
606        // OS accent, contrast and reduced motion; tokens the setup below installs win
607        let (light, dark) = self.os_prefs.startup(ctx);
608        tokens::install(ctx, &light, &dark, mode);
609        if let Some(setup) = self.setup.take()
610            && let Err(p) = catch_unwind(AssertUnwindSafe(|| setup(ctx)))
611        {
612            log::error!("app setup panicked: {}", panic_text(p.as_ref()));
613        }
614        let w = wake::waker(ctx, Reason::JOB);
615        self.jobs.set_wake(move || w.wake());
616        let w = wake::waker(ctx, Reason::SETTINGS);
617        self.settings.set_wake(move |d| w.reschedule(d));
618        if let Some(a) = &mut self.autosave {
619            let w = wake::waker(ctx, Reason::AUTOSAVE);
620            a.autosave.set_wake(move |d| w.reschedule(d));
621            #[cfg(not(target_arch = "wasm32"))]
622            if let Some(writer) = &a.writer {
623                let w = wake::waker(ctx, Reason::AUTOSAVE);
624                writer.set_wake(move || w.wake());
625            }
626        }
627        let port = self.launch.control_port.or(self.shared.control_port);
628        // A launcher may say where the key goes (`ashlar-debug open`); otherwise the settings folder.
629        let key_dir = ashlar_control::key::key_dir_from_env().or_else(|| self.shared.dirs.as_ref().map(|d| d.config.clone()));
630        if let (Some(port), Some(key_dir)) = (port, key_dir) {
631            let w = wake::waker(ctx, Reason::CONTROL);
632            match Server::start(ashlar_control::Config::new(port, key_dir), move || w.wake()) {
633                Ok((server, rx)) => {
634                    self.control = Some((server, rx));
635                }
636                Err(e) => {
637                    log::error!("control port {port}: {e}");
638                    self.toasts.error(format!("Agent control is off: {e}"));
639                }
640            }
641        }
642        // the event loop runs by now, so winit's application delegate exists
643        if self.native {
644            match session_end::listen(Arc::new({
645                let w = wake::waker(ctx, SESSION_END);
646                move || w.wake()
647            })) {
648                Ok(rx) => self.session_end = Some(rx),
649                Err(e) => log::debug!("quit requests from the OS: {e}"),
650            }
651        }
652    }
653
654    // ------------------------------------------------------------------ the frame
655
656    /// Work that needs no drawing: control requests, finished jobs, effects, autosave, settings.
657    /// eframe calls it before every frame (and while the window is hidden).
658    pub fn logic(&mut self, ctx: &egui::Context) {
659        self.launch.telemetry.begin_turn();
660        self.ensure_started(ctx);
661        self.os_prefs.poll(ctx);
662        self.serve_control(ctx);
663        self.show_pending_pick(ctx, None);
664        let outer = self.session.swap_source(Some(Origin::Job));
665        let done = self.jobs.drain_ready(&mut self.session);
666        self.session.swap_source(outer);
667        for rec in done {
668            if rec.status == ashlar_jobs::Status::Failed {
669                self.toasts.error(format!("{}: {}", rec.label, rec.message));
670            }
671        }
672        self.apply_effects(ctx);
673        self.open_from_outside(ctx);
674        #[cfg(all(feature = "native-menu", any(target_os = "macos", target_os = "windows")))]
675        if let Some(m) = &self.native_menu {
676            self.native_picks.extend(m.drain());
677        }
678        #[cfg(all(feature = "tray", not(target_arch = "wasm32")))]
679        self.drain_tray(ctx);
680        #[cfg(all(feature = "hotkey", not(target_arch = "wasm32")))]
681        if let Some(g) = &self.global_shortcuts {
682            let pressed = g.drain();
683            if !pressed.is_empty() {
684                self.show_window(ctx);
685            }
686            self.native_picks.extend(pressed);
687        }
688        self.run_native_picks(ctx);
689        self.launch.telemetry.end_turn();
690    }
691
692    /// eframe runs no UI pass while the window is minimized or covered. While debugger work waits
693    /// (ui input, snapshots, waits, screenshots), run one here, after [`Runtime::logic`]: what it
694    /// draws is not presented (screenshots render off screen) and nothing raises the window, so
695    /// agents drive a covered window without taking the focus. Its window commands go to the
696    /// context; the output is returned for the caller to hand its texture changes to the renderer.
697    /// `time` and `events` are for the pass (eframe gives none: the wall clock goes on; the
698    /// headless driver passes its virtual clock and delivers its screenshots this way).
699    pub fn hidden_pass(&mut self, ctx: &egui::Context, time: Option<f64>, events: Vec<egui::Event>) -> Option<egui::FullOutput> {
700        if ctx.input(|i| i.viewport().visible()) != Some(false) || !devtools::has_pending(ctx) {
701            return None;
702        }
703        let mut raw = ctx.input(|i| i.raw.clone());
704        raw.events = events;
705        raw.hovered_files.clear();
706        raw.dropped_files.clear();
707        raw.viewport_id = egui::ViewportId::ROOT;
708        // eframe hands no time to a hidden window: go on from the last pass on the wall clock
709        // (host time: `app.clock` turns it into the app's in `raw_input_hook`)
710        let last = self.clock.host_time();
711        raw.time = Some(time.unwrap_or_else(|| self.created.map_or(last, |c| c.elapsed().as_secs_f64())).max(last));
712        if raw.screen_rect.is_none()
713            && let Some(r) = raw.viewports.get(&egui::ViewportId::ROOT).and_then(|v| v.inner_rect)
714        {
715            raw.screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, r.size()));
716        }
717        self.raw_input_hook(ctx, &mut raw);
718        let out = ctx.run_ui(raw, |ui| self.ui(ui));
719        for (id, vo) in &out.viewport_output {
720            for c in &vo.commands {
721                ctx.send_viewport_cmd_to(*id, c.clone());
722            }
723            if *id == egui::ViewportId::ROOT && vo.repaint_delay < Duration::from_secs(3600) {
724                wake::wake_after(ctx, Reason::CONTROL, vo.repaint_delay);
725            }
726        }
727        if !out.platform_output.commands.is_empty() {
728            let commands = out.platform_output.commands.clone();
729            ctx.output_mut(|o| o.commands.extend(commands));
730        }
731        Some(out)
732    }
733
734    /// The start of a frame (eframe's `raw_input_hook`, before [`Runtime::logic`]): set the
735    /// frame's time (`app.clock` may hold it), then credit the frame to the wake reasons that were
736    /// due. (Injected agent input is handed to egui by the debugger's plugin.)
737    pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
738        // the host's time and input, before `app.clock` changes them: what replay hands back
739        if let Some(r) = &mut self.recorder
740            && !r.frame(raw)
741        {
742            self.recorder = None;
743        }
744        self.clock.begin_frame(raw);
745        // installed every frame: an app may reset egui's memory, which holds the scheduler
746        self.wakes.install(ctx);
747        self.wakes.begin_frame(raw.time.unwrap_or_else(|| ctx.input(|i| i.time)));
748        if let Some(p) = &self.probe {
749            // before egui's pass begins, so the plugin times the first frame too
750            if !self.probe_installed {
751                ctx.add_plugin(p.plugin());
752                self.probe_installed = true;
753            }
754            let now = self.wakes.stats();
755            p.begin_frame(crate::perf_warn::credited(&self.probe_wakes, &now));
756            self.probe_wakes = now;
757        }
758        // on the web a dropped file has no path to open: the view reads it from egui's input
759        #[cfg(not(target_arch = "wasm32"))]
760        if self.shared.accept_drops {
761            self.drops.extend(raw.dropped_files.iter().map(|f| f.path().to_path_buf()));
762        }
763    }
764
765    /// Open what the OS, a later launch or a drop asked for: one document at a time, after any
766    /// open dialog, through `file.open` (a dirty document asks to save first). A forwarded launch
767    /// brings the window to the front.
768    fn open_from_outside(&mut self, ctx: &egui::Context) {
769        let (items, focus) = self.inbox.drain();
770        if focus {
771            ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
772            ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
773            self.bring_front = true;
774        }
775        let mut files = items.into_iter().filter_map(|item| match item {
776            ashlar_native::open_files::Opened::File(p) => Some(p),
777            ashlar_native::open_files::Opened::Url(u) => {
778                log::info!("asked to open {u}: URLs are not handled");
779                None
780            }
781        });
782        if let Some(p) = files.next() {
783            self.open_next = Some(p);
784        }
785        // the command-line document, once the first frame is up (a slow load no longer delays it)
786        if self.first_frame_done
787            && let Some(path) = self.launch.open.take()
788        {
789            self.run_ui_command(ctx, "file.open", json!({"path": path.to_string_lossy(), "discard": true}));
790            self.apply_launch_scene();
791        }
792        let skipped = files.count();
793        if skipped > 0 {
794            log::info!("{skipped} more file(s) to open ignored: one document at a time");
795        }
796        if !self.drops.is_empty() {
797            let (open, rest) = crate::open::split_drops(std::mem::take(&mut self.drops), &self.shared.extensions);
798            if open.is_some() {
799                self.open_next = open;
800            }
801            self.dropped = rest;
802        }
803        if self.modal_open() {
804            return;
805        }
806        if let Some(p) = self.open_next.take() {
807            self.run_ui_command(ctx, "file.open", json!({"path": p.to_string_lossy()}));
808        }
809    }
810
811    /// Called at the start of every native frame: the frame after the first one finds that frame
812    /// presented. Records `launch_to_present_ms` and writes the ready file.
813    #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
814    pub(crate) fn note_presented(&mut self) {
815        if !self.first_frame_done || std::mem::replace(&mut self.launch.presented, true) {
816            return;
817        }
818        let Some(start) = self.launch.process_start else { return };
819        let took = start.elapsed();
820        self.stats.set_presented(took);
821        let t = self.stats.launch();
822        let f = |v: Option<f64>| v.map_or_else(|| "-".to_owned(), |v| format!("{v:.1}"));
823        log::info!(
824            "launch: window {} ms, first frame {} ms (runtime {} ms), presented {} ms",
825            f(t.to_window_ms),
826            f(t.to_first_frame_ms),
827            f(self.stats.summary().first_frame_ms),
828            f(t.launch_to_present_ms)
829        );
830        if let Some(path) = self.launch.ready_file.take() {
831            let body = json!({"pid": std::process::id(), "ready_ms": took.as_secs_f64() * 1000.0}).to_string();
832            // off the UI thread; a launcher polls for the file
833            let spawned = std::thread::Builder::new().name("ashlar-ready".into()).spawn(move || {
834                let r = catch_unwind(AssertUnwindSafe(|| ashlar_fs::atomic_write(&path, body.as_bytes())));
835                match r {
836                    Ok(Ok(())) => {}
837                    Ok(Err(e)) => log::warn!("ready file {}: {e}", path.display()),
838                    Err(_) => log::warn!("ready file {}: the writer panicked", path.display()),
839                }
840            });
841            if let Err(e) = spawned {
842                log::warn!("ready file: {e}");
843            }
844        }
845    }
846
847    /// Queue a later launch's arguments, as [`App::single_instance`] receives them (tests).
848    pub(crate) fn forwarded(&self, f: ashlar_native::single_instance::Forwarded) {
849        self.inbox.forward(f);
850    }
851
852    /// Draw the frame.
853    pub fn ui(&mut self, ui: &mut egui::Ui) {
854        let ctx = ui.ctx().clone();
855        let started = now();
856        self.launch.telemetry.begin_turn();
857        self.ensure_started(&ctx);
858        self.passes = self.passes.saturating_add(1);
859        // timed wakes not yet due when this frame ran (input came first) stay scheduled
860        self.wakes.rearm(&ctx);
861        // before anything consumes this frame's input events
862        self.caret.begin(&ctx);
863        let (focused, active) = ctx.input(|i| (i.focused, !i.events.is_empty()));
864        self.launch.telemetry.input(focused, active);
865
866        self.refresh_infos();
867        if !self.modal_open()
868            && let Some(id) = dispatch(&ctx, &self.keymap)
869        {
870            self.run_ui_command(&ctx, &id, json!({}));
871            // the command may have changed what the menus show
872            self.refresh_infos();
873        }
874        // nothing is drawn yet: changes made so far (control requests, jobs, a key command) show
875        // in this frame; only changes made from here on need another
876        let rev_before = self.session.revision();
877        self.update_title(&ctx);
878        if !self.native_menu_active() {
879            let spec = &self.cache.menu;
880            let chosen = egui::Panel::top("ashlar.menu_bar").show(ui, |ui| menu_bar(ui, spec, Platform::current())).inner;
881            if let Some(id) = chosen {
882                self.run_ui_command(&ctx, &id, json!({}));
883            }
884        }
885        self.draw_view(ui);
886        self.dropped.clear();
887        for (id, params) in std::mem::take(&mut self.queued) {
888            self.run_ui_command(&ctx, &id, params);
889        }
890        self.apply_effects(&ctx);
891        self.draw_modals(&ctx);
892        self.draw_tools(&ctx);
893        self.toasts.show(&ctx);
894        self.run_toast_actions(&ctx);
895        self.handle_close(&ctx);
896        self.poll_session_end(&ctx);
897        self.apply_effects(&ctx);
898        self.persist_view();
899        self.track_window(&ctx);
900        self.tick_storage(&ctx);
901        self.feed_devtools(&ctx);
902
903        // the frame changed the document: draw once more so everything shows the new state
904        if self.session.revision() != rev_before {
905            wake::wake(&ctx, Reason::DOCUMENT);
906        }
907        self.caret.end(&ctx);
908        let took = started.map(|s| s.elapsed());
909        if let Some(took) = took {
910            self.stats.record(took);
911            self.launch.telemetry.frame(took);
912        }
913        self.process.tick();
914        let ran = self.session.take_ran();
915        if let Some(p) = &self.probe {
916            p.end_ui(took, ran);
917        }
918        if !self.first_frame_done {
919            self.first_frame_done = true;
920            if let Some(g) = &mut self.launch.guard {
921                g.started();
922            }
923            if let Some(c) = self.created {
924                self.stats.set_first_frame(c.elapsed());
925            }
926            if let Some(s) = self.launch.process_start {
927                self.stats.set_to_first_frame(s.elapsed());
928            }
929            // one more frame: it opens the command-line document and records the present
930            if self.native || self.launch.open.is_some() {
931                wake::wake(&ctx, Reason::STARTUP);
932            }
933            self.launch.telemetry.first_frame();
934        }
935        self.launch.telemetry.end_turn();
936    }
937
938    fn draw_view(&mut self, ui: &mut egui::Ui) {
939        let Runtime { session, shared, jobs, toasts, settings, queued, view, view_error, dropped, view_rev, .. } = self;
940        let rev = session.revision();
941        let origin = if *view_rev == Some(rev) { None } else { session.origin() };
942        *view_rev = Some(rev);
943        egui::CentralPanel::default().show(ui, |ui| {
944            if let Some(err) = view_error.as_deref() {
945                ui.centered_and_justified(|ui| ui.label(format!("This view stopped after an internal error:\n{err}")));
946                return;
947            }
948            let Some(view) = view.as_mut() else { return };
949            let mut cx = Cx { session, registry: &shared.registry, jobs, toasts, settings, queued, dropped, ctx: ui.ctx().clone(), origin };
950            let r = catch_unwind(AssertUnwindSafe(|| view(ui, &mut cx)));
951            if let Err(p) = r {
952                let msg = crash::last_panic().map_or_else(|| panic_text(p.as_ref()), |r| format!("{} ({})", r.message, r.location));
953                log::error!("the view panicked: {msg}");
954                *view_error = Some(msg);
955            }
956        });
957    }
958
959    fn native_menu_active(&self) -> bool {
960        #[cfg(all(feature = "native-menu", any(target_os = "macos", target_os = "windows")))]
961        {
962            self.native_menu.is_some()
963        }
964        #[cfg(not(all(feature = "native-menu", any(target_os = "macos", target_os = "windows"))))]
965        {
966            false
967        }
968    }
969
970    /// After the first frame, install the OS menu bar and attach it to the window. Until it is
971    /// attached (and when that fails) the in-window bar shows.
972    #[cfg(not(target_arch = "wasm32"))]
973    fn install_native_menu(&mut self, ctx: &egui::Context, window: &eframe::Frame) {
974        #[cfg(all(feature = "native-menu", any(target_os = "macos", target_os = "windows")))]
975        if self.native && self.first_frame_done && !self.native_menu_tried {
976            self.native_menu_tried = true;
977            self.native_menu = crate::native_menu::NativeMenu::install(&self.cache.infos, ctx, window);
978            // the in-window bar goes (or stays) with the next frame
979            wake::wake(ctx, Reason::MENU);
980        }
981        #[cfg(all(feature = "tray", not(target_arch = "wasm32")))]
982        if self.native && self.first_frame_done && !self.tray_tried {
983            self.tray_tried = true;
984            if let Some(spec) = self.shared.tray.clone() {
985                let wake = ashlar_ui::wake::waker(ctx, Reason::MENU);
986                match ashlar_native::tray::Tray::new(spec, std::sync::Arc::new(move || wake.wake())) {
987                    Ok(t) => self.tray = Some(t),
988                    Err(e) => log::warn!("no tray icon: {e}"),
989                }
990            }
991        }
992        #[cfg(all(feature = "hotkey", not(target_arch = "wasm32")))]
993        if self.native && self.first_frame_done && !self.global_shortcuts_tried {
994            self.global_shortcuts_tried = true;
995            if !self.shared.global_shortcuts.is_empty() {
996                let wake = ashlar_ui::wake::waker(ctx, Reason::MENU);
997                match ashlar_native::hotkey::GlobalShortcuts::new(std::sync::Arc::new(move || wake.wake())) {
998                    Ok(mut g) => {
999                        for (command, chord) in &self.shared.global_shortcuts {
1000                            let done = ashlar_command::shortcut::Chord::parse(chord)
1001                                .map_err(|e| ashlar_native::NativeError::Invalid(e.0))
1002                                .and_then(|c| g.register(command.clone(), &c));
1003                            if let Err(e) = done {
1004                                log::warn!("global shortcut `{chord}` for `{command}` skipped: {e}");
1005                            }
1006                        }
1007                        self.global_shortcuts = Some(g);
1008                    }
1009                    Err(e) => log::warn!("no global shortcuts: {e}"),
1010                }
1011            }
1012        }
1013        #[cfg(target_os = "macos")]
1014        if self.native && self.first_frame_done && !self.mac_shell_tried {
1015            self.mac_shell_tried = true;
1016            if let Err(e) = ashlar_native::mac_shell::guard_appearance() {
1017                log::debug!("appearance guard: {e}");
1018            }
1019            let wake = ashlar_ui::wake::waker(ctx, Reason::MENU);
1020            match ashlar_native::mac_shell::listen_reopen(std::sync::Arc::new(move || wake.wake())) {
1021                Ok(rx) => self.reopen = Some(rx),
1022                Err(e) => log::debug!("Dock reopen: {e}"),
1023            }
1024        }
1025        #[cfg(target_os = "macos")]
1026        if self.reopen.as_ref().is_some_and(|rx| rx.try_iter().count() > 0) {
1027            self.show_window(ctx);
1028        }
1029        if std::mem::take(&mut self.bring_front)
1030            && let Err(e) = ashlar_native::system::bring_to_front(window)
1031            && !e.is_unsupported()
1032        {
1033            log::debug!("bring to front: {e}");
1034        }
1035        let _ = (ctx, window);
1036    }
1037
1038    /// Show, restore and focus the window, now through the viewport and on the next frame
1039    /// through the OS.
1040    #[cfg_attr(any(target_arch = "wasm32", not(any(target_os = "macos", feature = "tray", feature = "hotkey"))), allow(dead_code))]
1041    fn show_window(&mut self, ctx: &egui::Context) {
1042        ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
1043        ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
1044        ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
1045        self.bring_front = true;
1046        wake::wake(ctx, Reason::MENU);
1047    }
1048
1049    /// Tray menu picks run like OS menu picks; a click on the icon shows the window.
1050    #[cfg(all(feature = "tray", not(target_arch = "wasm32")))]
1051    fn drain_tray(&mut self, ctx: &egui::Context) {
1052        let Some(t) = &self.tray else { return };
1053        for e in t.events() {
1054            match e {
1055                ashlar_native::tray::TrayEvent::Menu(id) => self.native_picks.push(id),
1056                ashlar_native::tray::TrayEvent::Activate => self.show_window(ctx),
1057            }
1058        }
1059    }
1060
1061    /// Run the ids picked in the OS menu bar, under the rules key presses follow: nothing runs
1062    /// while a dialog or the palette is open, and while a text field has the keyboard a command
1063    /// whose shortcut belongs to the field ([`works_while_typing`]) is skipped. The OS reports a
1064    /// click and a key equivalent the same way, so a click on such an item while typing is
1065    /// skipped too; text-editing shortcuts have no key equivalent (see `native_menu`), so this
1066    /// mostly affects clicks on Undo / Redo while a text field is focused, which then belong to
1067    /// the field anyway.
1068    fn run_native_picks(&mut self, ctx: &egui::Context) {
1069        if self.native_picks.is_empty() {
1070            return;
1071        }
1072        let picks = std::mem::take(&mut self.native_picks);
1073        let typing = ctx.egui_wants_keyboard_input();
1074        for id in picks {
1075            if self.modal_open() {
1076                log::debug!("menu pick `{id}` ignored: a dialog is open");
1077                continue;
1078            }
1079            if typing && self.keymap.keys_for(&id).first().is_some_and(|c| !works_while_typing(c)) {
1080                log::debug!("menu pick `{id}` ignored: a text field has the keyboard");
1081                continue;
1082            }
1083            self.run_ui_command(ctx, &id, json!({}));
1084        }
1085    }
1086
1087    /// Queue an OS menu pick (the headless driver's stand-in for the OS menu bar).
1088    pub(crate) fn push_native_pick(&mut self, id: impl Into<String>) {
1089        self.native_picks.push(id.into());
1090    }
1091
1092    pub(crate) fn modal_open(&self) -> bool {
1093        !self.modals.is_empty() || self.palette.is_open() || self.view_error.is_some()
1094    }
1095
1096    // ------------------------------------------------------------------ commands
1097
1098    fn refresh_infos(&mut self) {
1099        let key = (self.session.revision(), self.session.view_generation(), self.session.file_generation(), self.keymap_gen);
1100        if self.cache.key == Some(key) {
1101            return;
1102        }
1103        // only the document changed (typing): what the menus show moves only if a command's
1104        // enabled state did, and checking that is far cheaper than rebuilding every info
1105        if let Some(old) = self.cache.key
1106            && (old.1, old.2, old.3) == (key.1, key.2, key.3)
1107            && self.enabled_unchanged()
1108        {
1109            self.cache.key = Some(key);
1110            return;
1111        }
1112        let mut infos = self.shared.registry.infos(&self.session);
1113        for info in &mut infos {
1114            let keys = self.keymap.keys_for(&info.id);
1115            info.shortcut = if keys.is_empty() { None } else { Some(format_list(keys)) };
1116        }
1117        self.cache.menu = MenuSpec::from_commands(&infos, MENU_ORDER);
1118        self.cache.infos = infos;
1119        self.cache.key = Some(key);
1120        self.cache.seq = self.cache.seq.wrapping_add(1);
1121        #[cfg(all(feature = "native-menu", any(target_os = "macos", target_os = "windows")))]
1122        if let Some(m) = &mut self.native_menu {
1123            m.update(&self.cache.infos);
1124        }
1125    }
1126
1127    /// Every command's enabled state (and reason) is what the cached infos say.
1128    fn enabled_unchanged(&self) -> bool {
1129        let specs = self.shared.registry.specs();
1130        specs.len() == self.cache.infos.len()
1131            && specs.iter().zip(&self.cache.infos).all(|(spec, info)| match (spec.enabled)(&self.session) {
1132                Ok(()) => info.enabled,
1133                Err(reason) => !info.enabled && info.disabled_reason.as_deref() == Some(reason.as_str()),
1134            })
1135    }
1136
1137    /// With [`App::run_toast_actions`](crate::App::run_toast_actions) on: run the clicked toast
1138    /// actions that name a registered command.
1139    fn run_toast_actions(&mut self, ctx: &egui::Context) {
1140        if !self.shared.toast_commands {
1141            return;
1142        }
1143        let shared = Arc::clone(&self.shared);
1144        for a in self.toasts.take_actions_where(|a| shared.registry.find(&a.command).is_some()) {
1145            self.run_ui_command(ctx, &a.command, json!({}));
1146        }
1147    }
1148
1149    /// Run a command the user chose (menu, key, palette, button): errors become toasts.
1150    fn run_ui_command(&mut self, ctx: &egui::Context, id: &str, params: Value) {
1151        if let Err(e) = self.execute(id, params) {
1152            match e {
1153                CommandError::Disabled { .. } => log::debug!("{e}"),
1154                _ => {
1155                    self.toasts.error(e.to_string());
1156                }
1157            }
1158        }
1159        self.apply_effects(ctx);
1160    }
1161
1162    fn apply_effects(&mut self, ctx: &egui::Context) {
1163        // effects can run commands that request more effects: bounded rounds
1164        for _ in 0..8 {
1165            let effects = self.session.take_effects();
1166            if effects.is_empty() {
1167                return;
1168            }
1169            for e in effects {
1170                self.apply_effect(ctx, e);
1171            }
1172        }
1173    }
1174
1175    fn apply_effect(&mut self, ctx: &egui::Context, e: Effect) {
1176        match e {
1177            Effect::Toast { level, text } => {
1178                self.toasts.push(ashlar_ui::toasts::Toast::new(level, text));
1179            }
1180            Effect::OpenPalette => self.palette.open(),
1181            Effect::OpenKeymap => self.keymap_open = true,
1182            Effect::Theme(mode) => {
1183                tokens::set_mode(ctx, mode);
1184                let _ = self.settings.set("theme", &mode);
1185            }
1186            Effect::PickOpen => self.request_pick(ctx, Pick::Open),
1187            Effect::PickSaveAs => self.request_pick(ctx, Pick::SaveAs),
1188            Effect::ConfirmDiscard { command, params } => self.modals.push(Modal::Confirm { quit: false, command: Some((command, params)) }),
1189            Effect::Quit { discard } => {
1190                if self.session.is_dirty() && !discard {
1191                    self.modals.push(Modal::Confirm { quit: true, command: None });
1192                } else {
1193                    self.close(ctx, discard);
1194                }
1195            }
1196        }
1197    }
1198
1199    fn report(&mut self, r: Result<Value, CommandError>) {
1200        if let Err(e) = r {
1201            self.toasts.error(e.to_string());
1202        }
1203    }
1204
1205    /// Ask for a file dialog. It is not shown inside this frame (a blocking dialog would freeze
1206    /// it half drawn, menus open): the next frame shows it first and acts on the pick.
1207    fn request_pick(&mut self, ctx: &egui::Context, kind: Pick) {
1208        if let Some(open) = self.pending_pick {
1209            log::debug!("a {kind:?} dialog was asked for while a {open:?} dialog waits: ignored");
1210            return;
1211        }
1212        self.pending_pick = Some(kind);
1213        wake::wake(ctx, Reason("dialog"));
1214    }
1215
1216    /// Show the dialog asked for last frame, over `parent` when there is a window, and act on the
1217    /// pick: called before the frame's work, by [`Runtime::logic`] and (with the window) by eframe.
1218    pub(crate) fn show_pending_pick(&mut self, ctx: &egui::Context, parent: Option<&eframe::Frame>) {
1219        let Some(kind) = self.pending_pick.take() else { return };
1220        let picked = match &mut self.picker {
1221            Some(p) => p(kind),
1222            None => {
1223                // the dialog blocks the event loop: not a hang
1224                self.launch.telemetry.pause_hangs();
1225                let r = native_pick(&self.shared, &self.session, kind, parent);
1226                self.launch.telemetry.resume_hangs();
1227                r
1228            }
1229        };
1230        self.deliver_pick(ctx, kind, picked);
1231        self.apply_effects(ctx);
1232        wake::wake(ctx, Reason("dialog"));
1233    }
1234
1235    fn deliver_pick(&mut self, ctx: &egui::Context, kind: Pick, picked: Option<PathBuf>) {
1236        match (kind, picked) {
1237            (Pick::Open, Some(path)) => {
1238                let r = self.execute("file.open", json!({"path": path.to_string_lossy(), "discard": true}));
1239                self.report(r);
1240            }
1241            (Pick::Open, None) => {}
1242            (Pick::SaveAs, Some(path)) => {
1243                let r = self.execute("file.save_as", json!({"path": path.to_string_lossy()}));
1244                self.report(r);
1245                self.continue_after_save(ctx);
1246            }
1247            (Pick::SaveAs, None) => {
1248                self.quit_after_save = false;
1249                self.after_save = None;
1250                if self.answering_end() {
1251                    self.end_step(ctx, session_end::Event::SaveFailed);
1252                }
1253            }
1254        }
1255    }
1256
1257    /// The save the confirm dialog started is over: quit, or run the command it held back.
1258    fn continue_after_save(&mut self, ctx: &egui::Context) {
1259        self.continue_quit(ctx);
1260        let next = self.after_save.take();
1261        if let Some((id, params)) = next
1262            && !self.session.is_dirty()
1263        {
1264            self.run_ui_command(ctx, &id, params);
1265        }
1266    }
1267
1268    // ------------------------------------------------------------------ closing
1269
1270    fn handle_close(&mut self, ctx: &egui::Context) {
1271        let requested = ctx.input(|i| i.viewport().close_requested());
1272        if !requested || self.allow_close {
1273            return;
1274        }
1275        if self.session.is_dirty() {
1276            ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
1277            if !self.modals.iter().any(|m| matches!(m, Modal::Confirm { quit: true, .. })) {
1278                self.modals.push(Modal::Confirm { quit: true, command: None });
1279            }
1280        } else {
1281            self.shutdown(false);
1282        }
1283    }
1284
1285    fn poll_session_end(&mut self, ctx: &egui::Context) {
1286        let requests: Vec<EndRequest> = self.session_end.as_ref().map(|rx| rx.try_iter().collect()).unwrap_or_default();
1287        for r in requests {
1288            self.end_requested(ctx, r.kind, session_end::CAN_WAIT);
1289        }
1290    }
1291
1292    /// The OS asks the app to end (Dock Quit, logout, shutdown): the same dirty check and
1293    /// cleanup as closing the window, answered through `session_end::step`.
1294    fn end_requested(&mut self, ctx: &egui::Context, kind: EndKind, can_wait: bool) {
1295        let dirty = self.session.is_dirty();
1296        self.end_step(ctx, session_end::Event::Requested { kind, dirty, can_wait });
1297    }
1298
1299    /// Whether the confirm dialog on screen answers an OS end request.
1300    fn answering_end(&self) -> bool {
1301        matches!(self.end_state, session_end::State::Asking(_) | session_end::State::Saving(_))
1302    }
1303
1304    fn end_step(&mut self, ctx: &egui::Context, event: session_end::Event) {
1305        let (state, effects) = session_end::step(self.end_state, event);
1306        self.end_state = state;
1307        for effect in effects {
1308            match effect {
1309                session_end::Effect::Ask => {
1310                    if !self.modals.iter().any(|m| matches!(m, Modal::Confirm { quit: true, .. })) {
1311                        self.modals.push(Modal::Confirm { quit: true, command: None });
1312                    }
1313                }
1314                session_end::Effect::Save => {
1315                    self.quit_after_save = true;
1316                    self.after_save = None;
1317                    let r = self.execute("file.save", json!({}));
1318                    self.report(r);
1319                    self.apply_effects(ctx);
1320                    // saved in place (or failed); a Save As answers when its dialog closes
1321                    if self.pending_pick.is_none() {
1322                        self.quit_after_save = false;
1323                        let next = if self.session.is_dirty() { session_end::Event::SaveFailed } else { session_end::Event::Saved };
1324                        self.end_step(ctx, next);
1325                    }
1326                }
1327                session_end::Effect::Shutdown { keep_recovery } => self.shutdown_inner(keep_recovery),
1328                session_end::Effect::Allow => {
1329                    self.end_reply = Some(true);
1330                    session_end::reply(true);
1331                    // macOS ends the process from the reply; elsewhere close the window too
1332                    if !cfg!(target_os = "macos") || self.session_end.is_none() {
1333                        ctx.send_viewport_cmd(egui::ViewportCommand::Close);
1334                    }
1335                }
1336                session_end::Effect::Refuse => {
1337                    self.end_reply = Some(false);
1338                    session_end::reply(false);
1339                }
1340            }
1341        }
1342    }
1343
1344    fn close(&mut self, ctx: &egui::Context, discarded: bool) {
1345        self.shutdown(discarded);
1346        ctx.send_viewport_cmd(egui::ViewportCommand::Close);
1347    }
1348
1349    /// Settle everything before the process ends: stop jobs, flush settings, end autosave.
1350    pub fn shutdown(&mut self, _discarded: bool) {
1351        self.shutdown_inner(false);
1352    }
1353
1354    /// `keep_recovery`: unsaved changes the user wasn't asked about (a logout that can't wait)
1355    /// stay in the autosave files for the next launch to offer.
1356    #[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
1357    fn shutdown_inner(&mut self, keep_recovery: bool) {
1358        if self.allow_close {
1359            return;
1360        }
1361        self.allow_close = true;
1362        self.jobs.cancel_all();
1363        self.persist_view();
1364        if let Err(e) = self.settings.flush() {
1365            log::warn!("settings not saved: {e}");
1366        }
1367        if let Some(mut a) = self.autosave.take() {
1368            #[cfg(not(target_arch = "wasm32"))]
1369            if let Some(w) = a.writer.take() {
1370                for e in w.shutdown(&mut a.autosave) {
1371                    log::warn!("autosave: {e}");
1372                }
1373            }
1374            // a clean exit (saved, or the user chose not to keep the changes), unless the
1375            // changes were never offered for saving
1376            if let Err(e) = a.autosave.close(keep_recovery && self.session.is_dirty()) {
1377                log::warn!("autosave cleanup: {e}");
1378            }
1379        }
1380        self.launch.telemetry.flush(Duration::from_millis(500));
1381        log::info!("{} closed", self.shared.name);
1382    }
1383
1384    /// End without any cleanup, as if the process had died: autosave files stay for the next
1385    /// launch to recover, settings are not flushed. For crash-recovery tests.
1386    #[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
1387    pub fn abandon(mut self) {
1388        self.allow_close = true;
1389        if let Some(mut a) = self.autosave.take() {
1390            #[cfg(not(target_arch = "wasm32"))]
1391            if let Some(w) = a.writer.take() {
1392                let _ = w.shutdown(&mut a.autosave);
1393            }
1394            // dropping (not closing) releases the lock and keeps the session's files
1395            drop(a);
1396        }
1397    }
1398
1399    fn continue_quit(&mut self, ctx: &egui::Context) {
1400        if self.quit_after_save {
1401            self.quit_after_save = false;
1402            if self.answering_end() {
1403                let next = if self.session.is_dirty() { session_end::Event::SaveFailed } else { session_end::Event::Saved };
1404                self.end_step(ctx, next);
1405            } else if !self.session.is_dirty() {
1406                self.close(ctx, false);
1407            }
1408        }
1409    }
1410
1411    // ------------------------------------------------------------------ storage
1412
1413    /// Keep the settings' `"window"` placement current: written only when the viewport info
1414    /// changes it (the settings debounce the file; closing flushes it). Once the window shows,
1415    /// a restored placement that no monitor shows moves back onto the screen.
1416    fn track_window(&mut self, ctx: &egui::Context) {
1417        if !self.native || !self.shared.remember_window || self.shared.dirs.is_none() {
1418            return;
1419        }
1420        let settings = &self.settings;
1421        let tracker = &mut self.window;
1422        let update = ctx.input(|i| tracker.update(i.viewport(), || settings.get::<WindowState>(window_state::KEY).unwrap_or_default()));
1423        if let Some(pos) = update.move_to {
1424            ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos.into()));
1425        }
1426        if let Some(state) = update.save
1427            && let Err(e) = self.settings.set(window_state::KEY, &state)
1428        {
1429            log::warn!("window placement not saved: {e}");
1430        }
1431    }
1432
1433    /// Write the view state to the settings when it changed (keyed by its generation: no copy
1434    /// or comparison on frames that did not change it).
1435    fn persist_view(&mut self) {
1436        let generation = self.session.view_generation();
1437        if self.persisted_view == Some(generation) {
1438            return;
1439        }
1440        self.persisted_view = Some(generation);
1441        let view = Value::Object(self.session.view_state().clone());
1442        if self.settings.get_value("view") != Some(&view) {
1443            self.settings.set_value("view", view);
1444        }
1445    }
1446
1447    fn tick_storage(&mut self, ctx: &egui::Context) {
1448        // host time: saves go on while `app.clock` holds the app's time
1449        let t = self.clock.host_time().max(ctx.input(|i| i.time)).max(0.0);
1450        let now = Duration::from_secs_f64(t);
1451        // a pending save asks for its own frame through the settings' wake hook
1452        if let Err(e) = self.settings.tick(now) {
1453            log::warn!("settings not saved: {e}");
1454        }
1455        let Some(a) = &mut self.autosave else { return };
1456        if a.file_gen != self.session.file_generation() {
1457            a.file_gen = self.session.file_generation();
1458            let _ = a.autosave.set_document(&self.session.display_name(), self.session.path());
1459        }
1460        let dirty = self.session.is_dirty();
1461        let rev = self.session.revision();
1462        if !dirty {
1463            if a.autosave.meta().dirty {
1464                let _ = a.autosave.mark_clean();
1465            }
1466            return;
1467        }
1468        // an autosave not yet due asks for its frame through the autosave's wake hook, a finished
1469        // background write through the writer's
1470        let snap = self.session.store().snapshot();
1471        let format = Arc::clone(&self.shared.format);
1472        #[cfg(not(target_arch = "wasm32"))]
1473        let wrote = match &mut a.writer {
1474            Some(w) => {
1475                for e in w.pump(&mut a.autosave) {
1476                    log::warn!("autosave: {e}");
1477                }
1478                w.tick(&mut a.autosave, now, rev, dirty, move || format.encode(&snap).unwrap_or_default())
1479            }
1480            None => a.autosave.tick(now, rev, dirty, || format.encode(&snap).unwrap_or_default()).ok().flatten().is_some(),
1481        };
1482        #[cfg(target_arch = "wasm32")]
1483        let wrote = a.autosave.tick(now, rev, dirty, || format.encode(&snap).unwrap_or_default()).ok().flatten().is_some();
1484        if wrote {
1485            log::debug!("autosaved revision {}", rev.0);
1486        }
1487    }
1488
1489    /// Set the window title when what it shows changed: the dirty mark, or the file (the
1490    /// display name changes only with the file generation).
1491    fn update_title(&mut self, ctx: &egui::Context) {
1492        let key = (self.session.is_dirty(), self.session.file_generation());
1493        if self.title_key == Some(key) {
1494            return;
1495        }
1496        self.title_key = Some(key);
1497        let dirty = if key.0 { " •" } else { "" };
1498        let title = format!("{}{dirty} — {}", self.session.display_name(), self.shared.title);
1499        if title != self.shown_title {
1500            ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
1501            self.shown_title = title;
1502        }
1503    }
1504
1505    /// The window title the runtime last set (`name • — App` when there are unsaved changes).
1506    pub fn title(&self) -> &str {
1507        &self.shown_title
1508    }
1509
1510    // ------------------------------------------------------------------ dialogs and tools
1511
1512    fn draw_modals(&mut self, ctx: &egui::Context) {
1513        if let Some(err) = self.view_error.clone() {
1514            let buttons = [DialogButton::new("Save As…"), DialogButton::new("Try Again").default()];
1515            let file = crash::last_panic().and_then(|p| p.log_file);
1516            match Dialog::new("ashlar.view_error", "Something went wrong", &buttons).width(460.0).show(ctx, |ui| {
1517                ui.label("The view hit an internal error and was stopped. Your document is safe: save it, then try again.");
1518                ui.add_space(6.0);
1519                ui.monospace(&err);
1520                if let Some(f) = &file {
1521                    ui.small(format!("Crash log: {}", f.display()));
1522                }
1523            }) {
1524                Some(DialogOutcome::Button(0)) => {
1525                    let r = self.execute("file.save_as", json!({}));
1526                    self.report(r);
1527                    self.apply_effects(ctx);
1528                }
1529                Some(_) => {
1530                    self.view_error = None;
1531                    crash::take_last_panic();
1532                }
1533                None => {}
1534            }
1535            return;
1536        }
1537        let Some(modal) = self.modals.first_mut() else { return };
1538        let done = match modal {
1539            Modal::Recovery => self.recovery_dialog(ctx),
1540            Modal::SafeMode => {
1541                let buttons = [DialogButton::new("Keep Settings"), DialogButton::new("Reset View Settings").default()];
1542                let title = format!("{} started in safe mode", self.shared.title);
1543                match Dialog::new("ashlar.safe_mode", &title, &buttons).show(ctx, |ui| {
1544                    ui.label("The last launch did not finish starting. Saved view settings (layout, theme) were not loaded this time.");
1545                }) {
1546                    Some(DialogOutcome::Button(1)) => {
1547                        self.settings.remove("view");
1548                        self.persisted_view = None;
1549                        self.settings.remove("theme");
1550                        ctx.memory_mut(|m| *m = egui::Memory::default());
1551                        self.wakes.install(ctx);
1552                        true
1553                    }
1554                    Some(_) => true,
1555                    None => false,
1556                }
1557            }
1558            Modal::Confirm { quit, command } => {
1559                let (quit, command) = (*quit, command.clone());
1560                self.confirm_dialog(ctx, quit, command)
1561            }
1562        };
1563        if done && !self.modals.is_empty() {
1564            self.modals.remove(0);
1565        }
1566    }
1567
1568    fn confirm_dialog(&mut self, ctx: &egui::Context, quit: bool, command: Option<(String, Value)>) -> bool {
1569        let buttons = [DialogButton::cancel("Cancel"), DialogButton::destructive("Don't Save"), DialogButton::new("Save").default()];
1570        let title = format!("Save changes to “{}”?", self.session.display_name());
1571        let out = Dialog::new("ashlar.confirm_discard", &title, &buttons).show(ctx, |ui| {
1572            ui.label("Your changes will be lost if you don't save them.");
1573        });
1574        if quit && self.answering_end() {
1575            let event = match out {
1576                Some(DialogOutcome::Button(1)) => session_end::Event::Discard,
1577                Some(DialogOutcome::Button(2)) => session_end::Event::Save,
1578                Some(_) => session_end::Event::Cancel,
1579                None => return false,
1580            };
1581            self.end_step(ctx, event);
1582            return true;
1583        }
1584        match out {
1585            Some(DialogOutcome::Button(1)) => {
1586                if quit {
1587                    self.close(ctx, true);
1588                } else if let Some((id, params)) = command {
1589                    self.run_ui_command(ctx, &id, params);
1590                }
1591                true
1592            }
1593            Some(DialogOutcome::Button(2)) => {
1594                self.quit_after_save = quit;
1595                self.after_save = if quit { None } else { command };
1596                let r = self.execute("file.save", json!({}));
1597                self.report(r);
1598                self.apply_effects(ctx);
1599                if self.pending_pick.is_none() {
1600                    // saved in place (or failed): no Save As to wait for
1601                    if self.session.is_dirty() {
1602                        self.quit_after_save = false;
1603                        self.after_save = None;
1604                    } else {
1605                        self.continue_after_save(ctx);
1606                    }
1607                }
1608                true
1609            }
1610            Some(_) => true,
1611            None => false,
1612        }
1613    }
1614
1615    fn recovery_dialog(&mut self, ctx: &egui::Context) -> bool {
1616        let Some(c) = self.recovery.first() else { return true };
1617        let buttons = [DialogButton::destructive("Discard"), DialogButton::new("Later"), DialogButton::new("Recover").default()];
1618        let label = if c.meta.label.is_empty() { "Untitled".to_string() } else { c.meta.label.clone() };
1619        let ago = (ashlar_store::autosave::unix_now() - c.meta.saved_unix).max(0);
1620        let out = Dialog::new("ashlar.recovery", "Recover unsaved work?", &buttons).width(440.0).show(ctx, |ui| {
1621            ui.label(format!("“{label}” had unsaved changes when {} last closed (autosaved {} ago).", self.shared.title, human_age(ago)));
1622        });
1623        let Some(out) = out else { return false };
1624        let c = self.recovery.remove(0);
1625        match out {
1626            DialogOutcome::Button(2) => match c.latest().map(|p| self.shared.format.load(p)) {
1627                Some(Ok(loaded)) => {
1628                    let path = c.meta.doc_path.as_ref().map(PathBuf::from);
1629                    self.session.replace(D::default(), path, None);
1630                    self.session.replace_as_edit("Recover Unsaved Work", loaded.doc);
1631                    if let Err(e) = c.discard() {
1632                        log::warn!("recovery files not removed: {e}");
1633                    }
1634                    self.toasts.success(format!("Recovered “{label}”. Save it to keep it."));
1635                }
1636                Some(Err(e)) => {
1637                    self.toasts.error(format!("Could not recover “{label}”: {e}"));
1638                }
1639                None => {
1640                    let _ = c.discard();
1641                }
1642            },
1643            DialogOutcome::Button(0) => {
1644                if let Err(e) = c.discard() {
1645                    log::warn!("recovery files not removed: {e}");
1646                }
1647            }
1648            _ => {}
1649        }
1650        self.recovery.is_empty()
1651    }
1652
1653    fn draw_tools(&mut self, ctx: &egui::Context) {
1654        if self.palette.is_open()
1655            && let Some(id) = self.palette.show(ctx, &self.cache.infos, self.cache.seq, Platform::current())
1656        {
1657            self.run_ui_command(ctx, &id, json!({}));
1658        }
1659        if self.keymap_open {
1660            let mut open = true;
1661            let mut change = None;
1662            let (keymap, editor, infos, seq) = (&mut self.keymap, &mut self.keymap_editor, &self.cache.infos, self.cache.seq);
1663            egui::Window::new("Keyboard Shortcuts").id(egui::Id::new("ashlar.keymap")).open(&mut open).default_size([520.0, 420.0]).show(ctx, |ui| {
1664                change = editor.show(ui, keymap, infos, seq);
1665            });
1666            self.keymap_open = open;
1667            if let Some(c) = change {
1668                log::info!("keymap changed: {c:?}");
1669                let _ = self.settings.set("keymap", &self.keymap.overrides());
1670                self.keymap_gen = self.keymap_gen.wrapping_add(1);
1671                if matches!(c, KeymapChange::Bound { .. } | KeymapChange::Reset { .. }) {
1672                    self.cache.key = None;
1673                }
1674            }
1675        }
1676    }
1677
1678    // ------------------------------------------------------------------ agent control
1679
1680    fn serve_control(&mut self, ctx: &egui::Context) {
1681        let mut reqs: Vec<Request> = std::mem::take(&mut self.injected_requests);
1682        if let Some((_, rx)) = &self.control {
1683            reqs.extend(rx.try_iter().take(256));
1684        }
1685        if reqs.is_empty() {
1686            return;
1687        }
1688        let info = self.info();
1689        let mut queue = UiQueue { scenes: !self.shared.scenes.is_empty(), ..UiQueue::default() };
1690        for req in reqs {
1691            if let Some(r) = &mut self.recorder
1692                && !r.request(req.method(), req.params())
1693            {
1694                self.recorder = None;
1695            }
1696            let outer = self.session.swap_source(Some(Origin::Agent));
1697            let mut bound = self.shared.registry.bind(&mut self.session);
1698            ashlar_control::handle(req, &mut bound, &mut queue, &info);
1699            self.session.swap_source(outer);
1700            self.apply_effects(ctx);
1701        }
1702        for req in std::mem::take(&mut queue.pending) {
1703            self.answer_ui(ctx, req);
1704        }
1705        // No wake after answering: the port's waker asked for this frame, and requests are served
1706        // in `logic`, before the view draws, so this frame already draws what they changed.
1707        // Debugger requests that need later frames (input, screenshots, waits) ask for them in
1708        // `devtools`, and effects ask for their own.
1709    }
1710
1711    fn answer_ui(&mut self, ctx: &egui::Context, req: Request) {
1712        let p = req.params().clone();
1713        let visible = ctx.input(|i| i.viewport().visible()).unwrap_or(true);
1714        if devtools::handles(req.method()) {
1715            // on a minimized or covered window, [`Runtime::hidden_pass`] runs the frames these need
1716            if let devtools::Outcome::NotMine(req) = devtools::handle(ctx, req) {
1717                req.unknown();
1718            }
1719            return;
1720        }
1721        match req.method() {
1722            "ui.inspect" => {
1723                let ins = automation::inspect(ctx);
1724                req.ok(json!({
1725                    "pass": ins.pass,
1726                    "elements": ins.elements,
1727                    "title": self.shown_title,
1728                    "document": {"name": self.session.display_name(), "dirty": self.session.is_dirty(), "revision": self.session.revision().0, "path": self.session.path().map(|p| p.to_string_lossy().into_owned())},
1729                    "view": self.session.view_state(),
1730                    "palette_open": self.palette.is_open(),
1731                    "keymap_open": self.keymap_open,
1732                    "dialogs": self.modals.len(),
1733                    "view_error": self.view_error,
1734                    "toasts": self.toasts.texts().collect::<Vec<_>>(),
1735                    "visible": visible,
1736                }));
1737            }
1738            "ui.menu.list" => {
1739                self.refresh_infos();
1740                req.ok(serde_json::to_value(&self.cache.menu).unwrap_or(Value::Null));
1741            }
1742            "ui.menu.invoke" => {
1743                let Some(id) = p.get("id").and_then(Value::as_str) else { return req.fail("ui.menu.invoke needs `id`") };
1744                self.refresh_infos();
1745                // menu item ids are command ids
1746                let command = self.cache.menu.find(id).map(|i| i.id.clone());
1747                match command {
1748                    Some(c) => {
1749                        let r = self.execute(&c, json!({}));
1750                        self.apply_effects(ctx);
1751                        req.reply(Reply::from_command(r));
1752                    }
1753                    None => req.fail(format!("no menu item `{id}` (see ui.menu.list)")),
1754                }
1755            }
1756            "ui.set" => {
1757                let Some(obj) = p.as_object() else { return req.fail("ui.set needs an object") };
1758                for (k, v) in obj {
1759                    if k == "theme" {
1760                        let mode: Option<ThemeMode> = serde_json::from_value(v.clone()).ok();
1761                        match mode {
1762                            Some(m) => {
1763                                self.session.set_view("theme", &m);
1764                                self.apply_effect(ctx, Effect::Theme(m));
1765                            }
1766                            None => return req.fail("theme is light, dark or system"),
1767                        }
1768                    } else {
1769                        self.session.set_view_value(k, v.clone());
1770                    }
1771                }
1772                req.ok(json!({}));
1773            }
1774            "app.quit" => {
1775                let discard = p.get("discard").and_then(Value::as_bool).unwrap_or(false);
1776                let dirty = self.session.is_dirty();
1777                self.apply_effect(ctx, Effect::Quit { discard });
1778                req.ok(json!({"closing": !dirty || discard}));
1779            }
1780            "app.stats" => {
1781                let mut v = serde_json::to_value(self.stats.summary()).unwrap_or(Value::Null);
1782                if let Some(o) = v.as_object_mut() {
1783                    o.insert("passes".into(), json!(self.passes));
1784                    o.insert("jobs".into(), self.jobs.to_json());
1785                    o.insert("log_lines_dropped".into(), json!(crate::logging::dropped()));
1786                    o.insert("wakes".into(), serde_json::to_value(self.wakes.stats()).unwrap_or(Value::Null));
1787                    o.insert("process".into(), serde_json::to_value(self.process.read()).unwrap_or(Value::Null));
1788                }
1789                if p.get("reset").and_then(Value::as_bool) == Some(true) {
1790                    self.stats.reset_window();
1791                }
1792                req.ok(v);
1793            }
1794            ashlar_control::APP_CLOCK => {
1795                let op = match ashlar_control::ClockOp::from_params(&p) {
1796                    Ok(op) => op,
1797                    Err(e) => return req.fail(e),
1798                };
1799                let (now, predicted_dt) = ctx.input(|i| (i.time, i.predicted_dt));
1800                match op {
1801                    ashlar_control::ClockOp::Read => {}
1802                    ashlar_control::ClockOp::Pause => self.clock.pause(now, predicted_dt),
1803                    ashlar_control::ClockOp::Step { ms } => {
1804                        self.clock.step(now, predicted_dt, ms / 1000.0);
1805                        // the frame at the new time
1806                        wake::wake(ctx, Reason::CONTROL);
1807                    }
1808                    ashlar_control::ClockOp::Resume => {
1809                        self.clock.resume();
1810                        wake::wake(ctx, Reason::CONTROL);
1811                    }
1812                }
1813                req.ok(json!({"paused": self.clock.is_paused(), "time": self.clock.time(now)}));
1814            }
1815            ashlar_control::UI_SCENE => match p.get("name").and_then(Value::as_str) {
1816                None => req.ok(json!({"scenes": self.shared.scene_names()})),
1817                Some(name) => match self.apply_scene(name) {
1818                    Ok(()) => {
1819                        self.apply_effects(ctx);
1820                        wake::wake(ctx, Reason::CONTROL);
1821                        req.ok(json!({"scene": name}));
1822                    }
1823                    Err(e) => req.fail(e),
1824                },
1825            },
1826            "app.logs" => {
1827                let limit = p.get("limit").and_then(Value::as_u64).unwrap_or(200).min(crate::logging::RING_LINES as u64) as usize;
1828                let level = p.get("level").and_then(Value::as_str).and_then(crate::logging::level_of).unwrap_or(log::Level::Trace);
1829                req.ok(json!({"lines": crate::logging::recent(limit, level)}));
1830            }
1831            _ => req.unknown(),
1832        }
1833    }
1834
1835    /// The inspector's panels and the app's pending work for `ui.wait {for: "idle"}`. The panels
1836    /// are built only while the inspector is open.
1837    fn feed_devtools(&mut self, ctx: &egui::Context) {
1838        devtools::set_busy(ctx, self.jobs.is_busy());
1839        devtools::section(ctx, "Logs", || {
1840            crate::logging::recent(200, log::Level::Trace).into_iter().map(|l| format!("{:<5} {}: {}", l.level, l.target, l.message)).collect()
1841        });
1842        let history = self.session.store().history();
1843        devtools::section(ctx, "Undo", || {
1844            let mut lines: Vec<String> = history.undo_labels().map(|l| format!("  {l}")).collect();
1845            lines.push("── now ──".into());
1846            lines.extend(history.redo_labels().map(|l| format!("  {l} (redo)")));
1847            lines
1848        });
1849    }
1850}
1851
1852impl<D: Document> Drop for Runtime<D> {
1853    fn drop(&mut self) {
1854        self.shutdown(false);
1855    }
1856}
1857
1858fn human_age(secs: i64) -> String {
1859    match secs {
1860        s if s < 90 => format!("{s} s"),
1861        s if s < 90 * 60 => format!("{} min", s / 60),
1862        s if s < 48 * 3600 => format!("{} h", s / 3600),
1863        s => format!("{} days", s / 86_400),
1864    }
1865}
1866
1867#[cfg(not(target_arch = "wasm32"))]
1868fn native_pick<D: Document>(shared: &Shared<D>, session: &Session<D>, kind: Pick, parent: Option<&eframe::Frame>) -> Option<PathBuf> {
1869    use ashlar_native::dialogs::FileDialog;
1870    let exts: Vec<&str> = shared.extensions.iter().map(String::as_str).collect();
1871    let mut d = FileDialog::new();
1872    if !exts.is_empty() {
1873        d = d.filter(shared.title.clone(), &exts);
1874    }
1875    if let Some(dir) = session.path().and_then(std::path::Path::parent) {
1876        d = d.directory(dir);
1877    }
1878    let d = match kind {
1879        Pick::Open => d.title("Open"),
1880        Pick::SaveAs => {
1881            let name = match exts.first() {
1882                Some(e) if session.path().is_none() => format!("Untitled.{e}"),
1883                _ => session.display_name(),
1884            };
1885            d.title("Save As").file_name(name)
1886        }
1887    };
1888    let r = match (kind, parent) {
1889        (Pick::Open, Some(w)) => d.parent(w).open_file(),
1890        (Pick::Open, None) => d.open_file(),
1891        (Pick::SaveAs, Some(w)) => d.parent(w).save_file(),
1892        (Pick::SaveAs, None) => d.save_file(),
1893    };
1894    match r {
1895        Ok(p) => Some(p),
1896        Err(ashlar_native::NativeError::Cancelled) => None,
1897        Err(e) => {
1898            log::warn!("file dialog: {e}");
1899            None
1900        }
1901    }
1902}
1903
1904#[cfg(target_arch = "wasm32")]
1905fn native_pick<D: Document>(_shared: &Shared<D>, _session: &Session<D>, _kind: Pick, _parent: Option<&eframe::Frame>) -> Option<PathBuf> {
1906    None
1907}
1908
1909// ---------------------------------------------------------------------------------------------
1910// eframe
1911// ---------------------------------------------------------------------------------------------
1912
1913struct EframeApp<D: Document> {
1914    rt: Runtime<D>,
1915    /// The debugger renders screenshots on eframe's wgpu device (set on the first frame).
1916    offscreen: bool,
1917}
1918
1919impl<D: Document> EframeApp<D> {
1920    /// Hand the debugger its off-screen renderer once eframe's wgpu device is known.
1921    fn install_offscreen(&mut self, ctx: &egui::Context, frame: &eframe::Frame) {
1922        if std::mem::replace(&mut self.offscreen, true) {
1923            return;
1924        }
1925        #[cfg(not(target_arch = "wasm32"))]
1926        if let Some(rs) = frame.wgpu_render_state() {
1927            devtools::set_offscreen(ctx, Some(crate::offscreen::renderer(rs.clone())));
1928        }
1929        #[cfg(target_arch = "wasm32")]
1930        let _ = (ctx, frame);
1931    }
1932}
1933
1934impl<D: Document> eframe::App for EframeApp<D> {
1935    fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
1936        self.rt.note_presented();
1937        // before the frame's work: the last frame is on screen, the dialog opens over the window
1938        self.rt.show_pending_pick(ctx, Some(frame));
1939        self.rt.logic(ctx);
1940        self.install_offscreen(ctx, frame);
1941        let Some(mut out) = self.rt.hidden_pass(ctx, None, Vec::new()) else { return };
1942        #[cfg(not(target_arch = "wasm32"))]
1943        if let Some(rs) = frame.wgpu_render_state() {
1944            let mut r = rs.renderer.write();
1945            for (id, deltas) in &out.textures_delta.set {
1946                for delta in deltas {
1947                    r.update_texture(&rs.device, &rs.queue, *id, delta);
1948                }
1949            }
1950            for id in &out.textures_delta.free {
1951                r.free_texture(id);
1952            }
1953        }
1954        // handed to the renderer (or, with no wgpu device, nowhere to hand them)
1955        out.textures_delta.clear();
1956    }
1957
1958    fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
1959        self.install_offscreen(ui.ctx(), frame);
1960        // the previous frame's whole CPU time, painting included
1961        if let Some(d) = frame.info().cpu_usage.and_then(|s| Duration::try_from_secs_f32(s).ok()) {
1962            self.rt.stats.record_frame_cpu(d);
1963        }
1964        self.rt.ui(ui);
1965        #[cfg(not(target_arch = "wasm32"))]
1966        self.rt.install_native_menu(ui.ctx(), frame);
1967    }
1968
1969    fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
1970        self.rt.raw_input_hook(ctx, raw_input);
1971    }
1972
1973    fn on_exit(&mut self) {
1974        self.rt.shutdown(false);
1975    }
1976}
1977
1978#[cfg(not(target_arch = "wasm32"))]
1979struct Args {
1980    control: Option<u16>,
1981    scene: Option<String>,
1982    safe_mode: bool,
1983    open: Option<PathBuf>,
1984}
1985
1986#[cfg(not(target_arch = "wasm32"))]
1987fn parse_args(env_prefix: &str) -> Args {
1988    let mut a = Args { control: None, scene: None, safe_mode: false, open: None };
1989    let mut it = std::env::args().skip(1);
1990    while let Some(arg) = it.next() {
1991        match arg.as_str() {
1992            "--control" => a.control = it.next().and_then(|p| p.parse().ok()),
1993            s if s.starts_with("--control=") => a.control = s.trim_start_matches("--control=").parse().ok(),
1994            "--safe-mode" => a.safe_mode = true,
1995            "--scene" => a.scene = it.next(),
1996            s if s.starts_with("--scene=") => a.scene = Some(s.trim_start_matches("--scene=").to_string()),
1997            s if !s.starts_with('-') && a.open.is_none() => a.open = Some(PathBuf::from(s)),
1998            _ => {}
1999        }
2000    }
2001    if a.control.is_none() {
2002        a.control = std::env::var(format!("{env_prefix}_CONTROL_PORT")).ok().and_then(|p| p.trim().parse().ok());
2003    }
2004    a
2005}
2006
2007/// On the web: run in the `<canvas>` [`App::canvas_id`] names (default: the app's name, then
2008/// `ashlar`). Returns at once; the app runs in the browser's event loop. When it cannot start, a
2009/// visible error is put next to the canvas (or at the top of the page) and logged to the console.
2010#[cfg(target_arch = "wasm32")]
2011pub(crate) fn run_native<D: Document>(app: App<D>) -> Result<(), Error> {
2012    use wasm_bindgen::JsCast;
2013    let (shared, view, setup) = app.split();
2014    let level = log::LevelFilter::Info;
2015    let console: crate::logging::Output = Box::new(|line| web_sys::console::log_1(&line.into()));
2016    crate::logging::install(Box::new(crate::logging::Logger::new(&shared.name, None, level, false).with_output(console)), level);
2017    // the browser console sees every panic: ashlar's hook runs first, then chains to this one
2018    static CONSOLE_HOOK: std::sync::Once = std::sync::Once::new();
2019    CONSOLE_HOOK.call_once(|| {
2020        let previous = std::panic::take_hook();
2021        std::panic::set_hook(Box::new(move |info| {
2022            web_sys::console::error_1(&format!("panic: {info}").into());
2023            previous(info);
2024        }));
2025    });
2026    crash::install(None, &shared.name, &shared.version);
2027    let document = web_sys::window().and_then(|w| w.document()).ok_or_else(|| Error::Window("no document".into()))?;
2028    let ids: Vec<String> = shared.canvas_id.iter().cloned().chain([shared.name.clone(), "ashlar".to_string()]).collect();
2029    let canvas = ids.iter().find_map(|id| document.get_element_by_id(id).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()));
2030    let Some(canvas) = canvas else {
2031        let wanted = ids.first().map_or("ashlar", String::as_str);
2032        let msg = format!("{} could not start: the page has no <canvas id=\"{wanted}\">.", shared.title);
2033        show_web_error(&document, None, &msg);
2034        return Err(Error::Window(msg));
2035    };
2036    let title = shared.title.clone();
2037    wasm_bindgen_futures::spawn_local(async move {
2038        let creator: eframe::AppCreator<'static> =
2039            Box::new(move |_cc| Ok(Box::new(EframeApp { rt: Runtime::new(shared, view, setup, Launch::default()), offscreen: false })));
2040        let target = canvas.clone();
2041        if let Err(e) = eframe::WebRunner::new().start(canvas, eframe::WebOptions::default(), creator).await {
2042            log::error!("the app could not start: {e:?}");
2043            let detail = e.as_string().unwrap_or_else(|| "the browser may not support WebGL or WebGPU".to_string());
2044            show_web_error(&document, Some(&target), &format!("{title} could not start: {detail}"));
2045        }
2046    });
2047    Ok(())
2048}
2049
2050/// Put `msg` in a visible box right after `canvas` (or first in the page's body), replacing an
2051/// earlier one; screen readers announce it (`role="alert"`). Errors here are only logged.
2052#[cfg(target_arch = "wasm32")]
2053fn show_web_error(document: &web_sys::Document, canvas: Option<&web_sys::HtmlCanvasElement>, msg: &str) {
2054    const ID: &str = "ashlar-start-error";
2055    let shown = (|| -> Result<(), wasm_bindgen::JsValue> {
2056        if let Some(old) = document.get_element_by_id(ID) {
2057            old.remove();
2058        }
2059        let div = document.create_element("div")?;
2060        div.set_id(ID);
2061        div.set_attribute("role", "alert")?;
2062        div.set_attribute(
2063            "style",
2064            "margin:12px;padding:12px 16px;border:1px solid #c62828;border-radius:6px;background:#fdecea;\
2065             color:#5f2120;font:14px/1.4 system-ui,sans-serif;max-width:640px",
2066        )?;
2067        div.set_text_content(Some(msg));
2068        match canvas.and_then(|c| c.parent_node().map(|p| (p, c.next_sibling()))) {
2069            Some((parent, next)) => drop(parent.insert_before(&div, next.as_ref())?),
2070            None => match document.body() {
2071                Some(body) => drop(body.insert_before(&div, body.first_child().as_ref())?),
2072                None => return Err("no <body>".into()),
2073            },
2074        }
2075        Ok(())
2076    })();
2077    if let Err(e) = shown {
2078        log::error!("could not show the start error in the page: {e:?}");
2079    }
2080}
2081
2082/// The window as the app describes it, at the placement it had last time (kept on screen), with
2083/// the app's icon and its own changes last.
2084#[cfg(not(target_arch = "wasm32"))]
2085fn window_builder<D: Document>(shared: &Shared<D>, window: crate::app::WindowSpec, safe_mode: bool) -> egui::ViewportBuilder {
2086    let mut b =
2087        egui::ViewportBuilder::default().with_title(shared.title.clone()).with_inner_size(shared.window_size).with_app_id(shared.name.clone());
2088    if shared.remember_window
2089        && !safe_mode
2090        && let Some(d) = &shared.dirs
2091    {
2092        let settings = Settings::load(Arc::new(DirConfig::new(d.config.clone())), "settings.json", &[]);
2093        if let Some(saved) = settings.get::<WindowState>(window_state::KEY) {
2094            // checked against the monitors the OS lists now (macOS, Windows); where it lists none,
2095            // sizes and coordinates are only bounded and the first frame moves a window no monitor
2096            // shows
2097            b = window_state::apply(b, &window_state::clamp(saved, &window_state::monitors_now()));
2098        }
2099    }
2100    if let Some(png) = window.icon {
2101        match catch_unwind(AssertUnwindSafe(|| eframe::icon_data::from_png_bytes(&png))) {
2102            Ok(Ok(icon)) => b = b.with_icon(icon),
2103            Ok(Err(e)) => log::warn!("the app icon is not a PNG ashlar can read: {e}"),
2104            Err(_) => log::warn!("the app icon could not be decoded"),
2105        }
2106    }
2107    let Some(f) = window.viewport else { return b };
2108    let fallback = b.clone();
2109    catch_unwind(AssertUnwindSafe(move || f(b))).unwrap_or_else(|p| {
2110        log::error!("App::viewport panicked: {}", panic_text(p.as_ref()));
2111        fallback
2112    })
2113}
2114
2115/// eframe's wgpu setup with `latency` frames queued at most (IMP-2.10: one swapchain image fewer).
2116#[cfg(not(target_arch = "wasm32"))]
2117fn wgpu_options(latency: u32) -> eframe::egui_wgpu::WgpuConfiguration {
2118    let surface = eframe::egui_wgpu::SurfaceConfig {
2119        present_mode: eframe::egui_wgpu::wgpu::PresentMode::AutoVsync,
2120        desired_maximum_frame_latency: Some(latency),
2121    };
2122    eframe::egui_wgpu::WgpuConfiguration { surface, ..Default::default() }
2123}
2124
2125#[cfg(not(target_arch = "wasm32"))]
2126pub(crate) fn run_native<D: Document>(app: App<D>) -> Result<(), Error> {
2127    // launch timings count from here (IMP-6.9)
2128    let process_start = now();
2129    let mut app = app;
2130    let window = app.take_window();
2131    let (shared, view, setup) = app.split();
2132    let args = parse_args(&shared.env_prefix);
2133    let logs = shared.dirs.as_ref().map(|d| d.logs.clone());
2134    if let Some(d) = &shared.dirs
2135        && let Err(e) = d.ensure()
2136    {
2137        eprintln!("{}: could not create its folders: {e}", shared.name);
2138    }
2139    // before anything else: a second launch hands its files to the first and exits
2140    let inbox = Inbox::default();
2141    let mut instance = None;
2142    if shared.single_instance {
2143        let launch_args: Vec<String> = std::env::args().skip(1).collect();
2144        match ashlar_native::single_instance::SingleInstance::new(&shared.name).claim(&launch_args, inbox.forwarder()) {
2145            Ok(ashlar_native::single_instance::Launch::Forwarded) => return Ok(()),
2146            Ok(ashlar_native::single_instance::Launch::Primary(g)) => instance = Some(g),
2147            Err(e) => eprintln!("{}: running without the single-instance check: {e}", shared.name),
2148        }
2149    }
2150    let env_var = format!("{}_LOG", shared.env_prefix);
2151    let filter = crate::logging::filter_from_env(&[env_var.as_str(), "RUST_LOG"], "info");
2152    let level = filter.max_level();
2153    let telemetry = match (&shared.telemetry, &shared.dirs) {
2154        (Some(cfg), Some(d)) => Telemetry::start(cfg, &shared.name, &shared.version, &d.data, true),
2155        _ => Telemetry::default(),
2156    };
2157    let logger = crate::logging::Logger::new(&shared.name, logs.as_deref(), level, true).with_filter(filter);
2158    crate::logging::install(telemetry.wrap_logger(Box::new(logger)), level);
2159    // after the tracer's hook: ours runs first, then chains to it
2160    crash::install(logs.as_deref(), &shared.name, &shared.version);
2161    log::info!("{} {} starting", shared.name, shared.version);
2162    // before the window: in a window station Windows never shows, wgpu panics configuring the
2163    // window's surface (ashlar_native::display)
2164    if let Err(e) = ashlar_native::display::check() {
2165        log::error!("{} cannot open its window: {e}", shared.name);
2166        return Err(Error::NoDesktop(e.0));
2167    }
2168
2169    let (guard, previous) = match &shared.dirs {
2170        Some(d) => {
2171            let (g, p) = LaunchGuard::begin(&d.data);
2172            (Some(g), p)
2173        }
2174        None => (None, PreviousLaunch::Clean),
2175    };
2176    let safe_mode = args.safe_mode || matches!(previous, PreviousLaunch::CrashedAtStartup { .. });
2177    if safe_mode {
2178        log::warn!("starting in safe mode ({previous:?})");
2179    }
2180    // main thread, before the event loop: Finder's launch documents arrive as Apple events
2181    #[cfg(target_os = "macos")]
2182    inbox.listen_os();
2183    let ready_file = std::env::var_os(format!("{}_READY_FILE", shared.env_prefix)).filter(|v| !v.is_empty()).map(PathBuf::from);
2184    let mut launch = Launch {
2185        safe_mode,
2186        previous: Some(previous),
2187        open: args.open,
2188        control_port: args.control,
2189        guard,
2190        telemetry,
2191        inbox: Some(inbox),
2192        instance,
2193        process_start,
2194        window_at: None,
2195        ready_file,
2196        presented: false,
2197        scene: args.scene,
2198    };
2199    let mut options = eframe::NativeOptions {
2200        viewport: window_builder(&shared, window, safe_mode),
2201        wgpu_options: wgpu_options(shared.frame_latency),
2202        ..Default::default()
2203    };
2204    // `<PREFIX>_QUIET`: open without taking the focus (agents relaunching after each build)
2205    if ashlar_native::mac_shell::quiet_requested(&shared.env_prefix) {
2206        options.viewport = options.viewport.with_active(false);
2207        #[cfg(target_os = "macos")]
2208        {
2209            options.event_loop_builder = Some(Box::new(|b| {
2210                use winit::platform::macos::EventLoopBuilderExtMacOS;
2211                b.with_activate_ignoring_other_apps(false);
2212            }));
2213        }
2214    }
2215    let title = shared.title.clone();
2216    eframe::run_native(
2217        &title,
2218        options,
2219        Box::new(move |_cc| {
2220            // eframe has built the window and its wgpu surface by now
2221            launch.window_at = now();
2222            let mut rt = Runtime::new(shared, view, setup, launch);
2223            rt.native = true;
2224            Ok(Box::new(EframeApp { rt, offscreen: false }))
2225        }),
2226    )
2227    .map_err(|e| Error::Window(e.to_string()))
2228}
2229
2230#[cfg(test)]
2231mod tests {
2232    use super::*;
2233
2234    #[test]
2235    fn ages_read_well() {
2236        assert_eq!(human_age(5), "5 s");
2237        assert_eq!(human_age(600), "10 min");
2238        assert_eq!(human_age(7200), "2 h");
2239        assert_eq!(human_age(300_000), "3 days");
2240    }
2241
2242    /// A runtime as `run()` builds it, with `launch` filled in.
2243    fn launched(app: crate::App<i64>, launch: Launch) -> Runtime<i64> {
2244        let (shared, view, setup) = app.split();
2245        let mut rt = Runtime::new(shared, view, setup, launch);
2246        rt.set_preferences(Preferences::default);
2247        rt
2248    }
2249
2250    #[test]
2251    fn launch_times_and_the_ready_file() {
2252        let dir = tempfile::tempdir().expect("tempdir");
2253        let ready = dir.path().join("ready.json");
2254        let start = std::time::Instant::now().checked_sub(Duration::from_millis(5));
2255        let launch = Launch { process_start: start, window_at: now(), ready_file: Some(ready.clone()), ..Default::default() };
2256        let mut d = crate::Driver::new(launched(crate::App::new("launchtest", "0.0.0"), launch));
2257        d.runtime.note_presented();
2258        assert_eq!(d.runtime.stats().launch().launch_to_present_ms, None, "nothing is presented before a frame");
2259        d.frame();
2260        d.runtime.note_presented();
2261        let t = d.runtime.stats().summary().launch;
2262        let (w, f, p) = (t.to_window_ms.unwrap(), t.to_first_frame_ms.unwrap(), t.launch_to_present_ms.unwrap());
2263        assert!(w >= 5.0 && w <= f && f <= p, "window {w}, first frame {f}, present {p}");
2264        let deadline = std::time::Instant::now() + Duration::from_secs(10);
2265        let body = loop {
2266            if let Ok(text) = std::fs::read_to_string(&ready) {
2267                break text;
2268            }
2269            assert!(std::time::Instant::now() < deadline, "no ready file");
2270            std::thread::sleep(Duration::from_millis(5));
2271        };
2272        let v: Value = serde_json::from_str(&body).unwrap();
2273        assert_eq!(v["pid"].as_u64(), Some(u64::from(std::process::id())));
2274        assert_eq!(v["ready_ms"].as_f64(), Some(p));
2275    }
2276
2277    #[test]
2278    fn headless_runs_report_no_launch_times() {
2279        let mut d = crate::Driver::new(crate::App::<i64>::new("launchtest", "0.0.0").runtime());
2280        d.frame();
2281        d.runtime.note_presented();
2282        assert_eq!(d.runtime.stats().launch(), crate::LaunchTimes::default());
2283    }
2284
2285    #[test]
2286    fn the_command_line_document_opens_after_the_first_frame() {
2287        let dir = tempfile::tempdir().expect("tempdir");
2288        let path = dir.path().join("a.num");
2289        let mut h = crate::App::<i64>::new("launchtest", "0.0.0").headless();
2290        h.session_mut().edit("set", |n| *n = 7);
2291        h.session_mut().save_as(&path).unwrap();
2292        let app = crate::App::<i64>::new("launchtest", "0.0.0").file_extension("num");
2293        let mut d = crate::Driver::new(launched(app, Launch { open: Some(path.clone()), ..Default::default() }));
2294        assert_eq!(d.runtime.session().path(), None, "nothing loads before the window");
2295        d.frame();
2296        assert!(d.runtime.stats().summary().first_frame_ms.is_some(), "the first frame is drawn");
2297        assert_eq!(d.runtime.session().path(), None, "with the empty document");
2298        d.settle(10);
2299        assert_eq!(d.runtime.session().path(), Some(path.as_path()), "the next frame opens the file");
2300        assert_eq!(*d.runtime.session().doc(), 7);
2301    }
2302
2303    #[test]
2304    fn menus_follow_enabled_state_but_typing_rebuilds_nothing() {
2305        let mut rt = crate::App::<i64>::new("runtimetest", "0.0.0").runtime();
2306        let undo = |rt: &Runtime<i64>| rt.cache.infos.iter().find(|i| i.id == "app.undo").map(|i| i.enabled);
2307        rt.refresh_infos();
2308        assert_eq!(undo(&rt), Some(false), "nothing to undo yet");
2309        rt.session_mut().edit("bump", |n| *n += 1);
2310        rt.refresh_infos();
2311        assert_eq!(undo(&rt), Some(true), "an edit enables Undo");
2312        assert_eq!(rt.cache.menu.find("app.undo").map(|i| i.enabled), Some(true), "and the menu shows it");
2313        let seq = rt.cache.seq;
2314        rt.session_mut().edit("bump", |n| *n += 1);
2315        rt.refresh_infos();
2316        assert_eq!(rt.cache.seq, seq, "another edit changes no command's state: the infos stay");
2317        assert_eq!(rt.cache.key.map(|k| k.0), Some(rt.session().revision()), "and are current");
2318        rt.session_mut().undo();
2319        rt.session_mut().undo();
2320        rt.refresh_infos();
2321        assert_eq!(undo(&rt), Some(false), "undoing everything disables Undo again");
2322    }
2323
2324    fn enter() -> egui::Event {
2325        egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }
2326    }
2327
2328    /// A driver whose dialogs answer from `answers` and log what they were asked.
2329    fn picking(answers: Vec<(Pick, PathBuf)>) -> (crate::Driver<i64>, std::rc::Rc<std::cell::RefCell<Vec<Pick>>>) {
2330        let app = crate::App::<i64>::new("picktest", "0.0.0").file_extension("num");
2331        let mut rt = launched(app, Launch::default());
2332        let asked = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
2333        let log = asked.clone();
2334        rt.set_file_picker(move |kind| {
2335            log.borrow_mut().push(kind);
2336            answers.iter().find(|(k, _)| *k == kind).map(|(_, p)| p.clone())
2337        });
2338        let mut d = crate::Driver::new(rt);
2339        d.frame();
2340        (d, asked)
2341    }
2342
2343    #[test]
2344    fn the_pick_arrives_on_the_next_frame() {
2345        let dir = tempfile::tempdir().expect("tempdir");
2346        let path = dir.path().join("a.num");
2347        let (mut d, asked) = picking(vec![(Pick::SaveAs, path.clone())]);
2348        d.runtime.session_mut().edit("set", |n| *n = 3);
2349        let r = d.runtime.execute("file.save_as", json!({})).unwrap();
2350        assert_eq!(r["pending"], "dialog");
2351        d.frame();
2352        assert!(asked.borrow().is_empty(), "no dialog inside the frame that asked for it");
2353        assert_eq!(d.runtime.pending_pick, Some(Pick::SaveAs));
2354        d.frame();
2355        assert_eq!(*asked.borrow(), vec![Pick::SaveAs], "the next frame shows it");
2356        assert_eq!(d.runtime.session().path(), Some(path.as_path()), "and saves there");
2357        assert!(!d.runtime.session().is_dirty());
2358    }
2359
2360    #[test]
2361    fn quitting_a_dirty_untitled_document_saves_through_the_dialog_then_closes() {
2362        let dir = tempfile::tempdir().expect("tempdir");
2363        let path = dir.path().join("q.num");
2364        let (mut d, asked) = picking(vec![(Pick::SaveAs, path.clone())]);
2365        d.runtime.session_mut().edit("set", |n| *n = 9);
2366        d.request_close();
2367        d.frame();
2368        assert!(d.runtime.modal_open(), "the confirm dialog asks first");
2369        d.settle(10);
2370        d.push_event(enter());
2371        d.frame();
2372        d.settle(10);
2373        assert_eq!(*asked.borrow(), vec![Pick::SaveAs]);
2374        assert_eq!(d.runtime.session().path(), Some(path.as_path()));
2375        assert!(d.runtime.is_closing(), "the quit goes on once saved");
2376    }
2377
2378    #[test]
2379    fn a_cancelled_save_as_keeps_the_window_open() {
2380        let (mut d, asked) = picking(Vec::new());
2381        d.runtime.session_mut().edit("set", |n| *n = 9);
2382        d.request_close();
2383        d.frame();
2384        d.settle(10);
2385        d.push_event(enter());
2386        d.frame();
2387        d.settle(10);
2388        assert_eq!(*asked.borrow(), vec![Pick::SaveAs]);
2389        assert!(!d.runtime.is_closing(), "cancelled: nothing closes");
2390        assert!(!d.runtime.quit_after_save, "and a later save does not quit");
2391    }
2392
2393    /// An OS end request (Dock Quit, logout) answered in the next frame.
2394    fn os_end(d: &mut crate::Driver<i64>, kind: EndKind, can_wait: bool) {
2395        let ctx = d.ctx().clone();
2396        d.runtime.end_requested(&ctx, kind, can_wait);
2397        d.frame();
2398    }
2399
2400    #[test]
2401    fn a_dock_quit_of_a_clean_document_cleans_up_and_allows_at_once() {
2402        let (mut d, _) = picking(Vec::new());
2403        os_end(&mut d, EndKind::Quit, true);
2404        assert_eq!(d.runtime.end_reply, Some(true));
2405        assert!(d.runtime.is_closing(), "shut down before answering");
2406        assert!(!d.runtime.modal_open());
2407    }
2408
2409    #[test]
2410    fn a_dock_quit_of_a_dirty_document_asks_then_saves_then_allows() {
2411        let dir = tempfile::tempdir().expect("tempdir");
2412        let path = dir.path().join("q.num");
2413        let (mut d, asked) = picking(vec![(Pick::SaveAs, path.clone())]);
2414        d.runtime.session_mut().edit("set", |n| *n = 9);
2415        os_end(&mut d, EndKind::Quit, true);
2416        assert!(d.runtime.modal_open(), "the confirm dialog asks first");
2417        assert_eq!(d.runtime.end_reply, None, "no answer to the OS yet");
2418        d.settle(10);
2419        d.push_event(enter());
2420        d.frame();
2421        d.settle(10);
2422        assert_eq!(*asked.borrow(), vec![Pick::SaveAs]);
2423        assert_eq!(d.runtime.session().path(), Some(path.as_path()));
2424        assert_eq!(d.runtime.end_reply, Some(true), "saved: the quit goes on");
2425        assert!(d.runtime.is_closing());
2426    }
2427
2428    #[test]
2429    fn a_cancelled_save_as_refuses_the_os_quit() {
2430        let (mut d, asked) = picking(Vec::new());
2431        d.runtime.session_mut().edit("set", |n| *n = 9);
2432        os_end(&mut d, EndKind::Logout, true);
2433        d.settle(10);
2434        d.push_event(enter());
2435        d.frame();
2436        d.settle(10);
2437        assert_eq!(*asked.borrow(), vec![Pick::SaveAs]);
2438        assert_eq!(d.runtime.end_reply, Some(false), "the logout is cancelled");
2439        assert!(!d.runtime.is_closing());
2440        assert_eq!(d.runtime.end_state, session_end::State::Idle, "a later quit asks again");
2441    }
2442
2443    #[test]
2444    fn a_logout_that_cant_wait_allows_and_keeps_the_changes_for_recovery() {
2445        let (mut d, asked) = picking(Vec::new());
2446        d.runtime.session_mut().edit("set", |n| *n = 9);
2447        os_end(&mut d, EndKind::Logout, false);
2448        assert!(asked.borrow().is_empty() && !d.runtime.modal_open(), "nothing is asked");
2449        assert_eq!(d.runtime.end_reply, Some(true));
2450        assert!(d.runtime.is_closing());
2451    }
2452
2453    #[test]
2454    fn opening_over_a_dirty_untitled_document_saves_then_shows_the_open_dialog() {
2455        let dir = tempfile::tempdir().expect("tempdir");
2456        let (saved, other) = (dir.path().join("s.num"), dir.path().join("o.num"));
2457        let mut h = crate::App::<i64>::new("picktest", "0.0.0").headless();
2458        h.session_mut().edit("set", |n| *n = 42);
2459        h.session_mut().save_as(&other).unwrap();
2460        let (mut d, asked) = picking(vec![(Pick::SaveAs, saved.clone()), (Pick::Open, other.clone())]);
2461        d.runtime.session_mut().edit("set", |n| *n = 5);
2462        let r = d.runtime.execute("file.open", json!({})).unwrap();
2463        assert_eq!(r["pending"], "confirm");
2464        d.frame();
2465        d.settle(10);
2466        d.push_event(enter());
2467        d.frame();
2468        d.settle(10);
2469        assert_eq!(*asked.borrow(), vec![Pick::SaveAs, Pick::Open], "save first, then pick the file to open");
2470        assert!(saved.exists(), "the changes were saved");
2471        assert_eq!(d.runtime.session().path(), Some(other.as_path()));
2472        assert_eq!(*d.runtime.session().doc(), 42);
2473    }
2474}