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, and pending
6//! work that asks for its own frame (settings and autosave wake hooks when a save is due, the
7//! autosave writer when a write finished). Every request goes through [`crate::wake`] with a
8//! reason (`app.stats` → `wakes`). An idle app, clean or dirty, requests nothing and draws no
9//! frames.
10
11use std::panic::{AssertUnwindSafe, catch_unwind};
12use std::path::PathBuf;
13use std::sync::Arc;
14use std::sync::mpsc::Receiver;
15use std::time::Duration;
16
17use ashlar_command::shortcut::{Platform, format_list};
18use ashlar_command::{CommandError, CommandInfo, Keymap, Registry};
19use ashlar_control::{AppInfo, Reply, Request, Server};
20use ashlar_fs::{ConfigStore, DirConfig, MemoryConfig, Settings};
21use ashlar_jobs::Jobs;
22use ashlar_store::{Autosave, AutosaveConfig, RecoveryCandidate, Revision};
23use ashlar_ui::automation;
24use ashlar_ui::commands::{CommandPalette, KeymapChange, KeymapEditor, MenuSpec, dispatch, menu_bar};
25use ashlar_ui::devtools;
26use ashlar_ui::dialog::{Dialog, DialogButton, DialogOutcome};
27use ashlar_ui::toasts::Toasts;
28use ashlar_ui::tokens::{self, ThemeMode, Tokens};
29use ashlar_ui::wake::{self, Reason, Scheduler, WakeStats};
30use serde_json::{Value, json};
31
32use crate::app::{App, Error, SetupFn, Shared, ViewFn};
33use crate::control::UiQueue;
34use crate::crash::{self, LaunchGuard, PreviousLaunch};
35use crate::session::{Document, Effect, Session, now};
36use crate::stats::FrameStats;
37use crate::telemetry::Telemetry;
38
39/// Top-level menus in this order; menus the app adds follow in first-seen order.
40pub const MENU_ORDER: &[&str] = &["File", "Edit", "View"];
41
42/// What `run()` found out before the window opened.
43#[derive(Default)]
44pub struct Launch {
45    /// Start without restoring view settings (after a crash during startup, or `--safe-mode`).
46    pub safe_mode: bool,
47    pub previous: Option<PreviousLaunch>,
48    /// A file named on the command line.
49    pub open: Option<PathBuf>,
50    pub control_port: Option<u16>,
51    pub(crate) guard: Option<LaunchGuard>,
52    pub(crate) telemetry: Telemetry,
53}
54
55/// Which file dialog a command asked for.
56#[derive(Clone, Copy, Debug, PartialEq, Eq)]
57pub enum Pick {
58    Open,
59    SaveAs,
60}
61
62/// Picks a file for [`Pick`]; `None` = cancelled. The default shows the native dialog.
63pub type FilePicker = Box<dyn FnMut(Pick) -> Option<PathBuf>>;
64
65/// What the view gets each frame: the session, commands, jobs, toasts and settings.
66pub struct Cx<'a, D: Document> {
67    session: &'a mut Session<D>,
68    registry: &'a Registry<Session<D>>,
69    /// Background work; a job's apply runs on the session (each edit there is an undo step).
70    pub jobs: &'a mut Jobs<Session<D>>,
71    pub toasts: &'a mut Toasts,
72    /// App settings (debounced, saved in the config folder).
73    pub settings: &'a mut Settings,
74    queued: &'a mut Vec<(String, Value)>,
75    ctx: egui::Context,
76}
77
78impl<D: Document> Cx<'_, D> {
79    /// The current document.
80    pub fn doc(&self) -> &D {
81        self.session.doc()
82    }
83
84    /// The current document as an `Arc` (to keep while calling `&mut self` methods).
85    pub fn snapshot(&self) -> Arc<D> {
86        self.session.snapshot()
87    }
88
89    /// The revision of the current document (key caches by it).
90    pub fn revision(&self) -> Revision {
91        self.session.revision()
92    }
93
94    pub fn session(&self) -> &Session<D> {
95        self.session
96    }
97
98    pub fn session_mut(&mut self) -> &mut Session<D> {
99        self.session
100    }
101
102    pub fn ctx(&self) -> &egui::Context {
103        &self.ctx
104    }
105
106    /// Run a command after the view returns (menus, buttons). Errors are shown as toasts.
107    pub fn dispatch(&mut self, id: &str, params: Value) {
108        self.queued.push((id.to_string(), params));
109    }
110
111    /// Run a command now and get its result.
112    pub fn execute(&mut self, id: &str, params: Value) -> Result<Value, CommandError> {
113        self.registry.execute(self.session, id, params)
114    }
115
116    /// An undoable change made directly by the view (one step called `label`).
117    pub fn edit<R>(&mut self, label: &str, f: impl FnOnce(&mut D) -> R) -> R {
118        self.session.edit(label, f)
119    }
120
121    /// An undoable change folded into the previous one with the same label and key (a drag).
122    pub fn edit_merged<R>(&mut self, label: &str, merge_key: &str, f: impl FnOnce(&mut D) -> R) -> R {
123        self.session.edit_merged(label, merge_key, f)
124    }
125
126    /// View state (not in the document; `ui.set` changes it too).
127    pub fn view<T: serde::de::DeserializeOwned>(&self, key: &str) -> Option<T> {
128        self.session.view(key)
129    }
130
131    pub fn view_or<T: serde::de::DeserializeOwned>(&self, key: &str, default: T) -> T {
132        self.session.view_or(key, default)
133    }
134
135    pub fn set_view<T: serde::Serialize + ?Sized>(&mut self, key: &str, value: &T) {
136        self.session.set_view(key, value);
137    }
138}
139
140#[derive(Default)]
141struct InfoCache {
142    key: Option<(Revision, u64, u64, u64)>,
143    seq: u64,
144    infos: Vec<CommandInfo>,
145    menu: MenuSpec,
146}
147
148enum Modal {
149    Recovery,
150    SafeMode,
151    Confirm { quit: bool, command: Option<(String, Value)> },
152}
153
154struct AutosaveState {
155    autosave: Autosave,
156    #[cfg(not(target_arch = "wasm32"))]
157    writer: Option<ashlar_store::BackgroundWriter>,
158    file_gen: u64,
159}
160
161/// One app instance's frame loop. Built by [`App::runtime`] (tests, benches) or `App::run`.
162pub struct Runtime<D: Document> {
163    shared: Arc<Shared<D>>,
164    session: Session<D>,
165    jobs: Jobs<Session<D>>,
166    toasts: Toasts,
167    settings: Settings,
168    view: Option<ViewFn<D>>,
169    setup: Option<SetupFn>,
170    keymap: Keymap,
171    keymap_gen: u64,
172    palette: CommandPalette,
173    keymap_editor: KeymapEditor,
174    keymap_open: bool,
175    cache: InfoCache,
176    control: Option<(Server, Receiver<Request>)>,
177    injected_requests: Vec<Request>,
178    autosave: Option<AutosaveState>,
179    recovery: Vec<RecoveryCandidate>,
180    modals: Vec<Modal>,
181    stats: FrameStats,
182    wakes: Scheduler,
183    launch: Launch,
184    queued: Vec<(String, Value)>,
185    created: Option<std::time::Instant>,
186    started: bool,
187    first_frame_done: bool,
188    view_error: Option<String>,
189    allow_close: bool,
190    quit_after_save: bool,
191    shown_title: String,
192    picker: Option<FilePicker>,
193    passes: u64,
194    /// Running in a real window (not the headless driver): OS menus may be installed.
195    #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
196    pub(crate) native: bool,
197    #[cfg(all(feature = "native-menu", any(target_os = "macos", target_os = "windows")))]
198    native_menu: Option<crate::native_menu::NativeMenu>,
199}
200
201fn panic_text(payload: &(dyn std::any::Any + Send)) -> String {
202    payload
203        .downcast_ref::<&str>()
204        .map(|s| (*s).to_string())
205        .or_else(|| payload.downcast_ref::<String>().cloned())
206        .unwrap_or_else(|| "unknown error".into())
207}
208
209impl<D: Document> Runtime<D> {
210    pub(crate) fn new(shared: Arc<Shared<D>>, view: Option<ViewFn<D>>, setup: Option<SetupFn>, launch: Launch) -> Self {
211        let mut session = Session::new(Arc::clone(&shared.format));
212        session.set_windowed(true);
213        session.telemetry = launch.telemetry.clone();
214        let config: Arc<dyn ConfigStore> = match &shared.dirs {
215            Some(d) => Arc::new(DirConfig::new(d.config.clone())),
216            None => Arc::new(MemoryConfig::new()),
217        };
218        let settings = Settings::load(config, "settings.json", &[]);
219        let mut keymap = Keymap::from_registry(&shared.registry, Platform::current());
220        if let Some(o) = settings.get_value("keymap") {
221            keymap.apply_overrides_json(o);
222        }
223        if !launch.safe_mode
224            && let Some(Value::Object(view)) = settings.get_value("view")
225        {
226            for (k, v) in view {
227                session.set_view_value(k, v.clone());
228            }
229        }
230        let mut rt = Runtime {
231            session,
232            jobs: Jobs::new(),
233            toasts: Toasts::new(),
234            settings,
235            view,
236            setup,
237            keymap,
238            keymap_gen: 0,
239            palette: CommandPalette::new(),
240            keymap_editor: KeymapEditor::new(),
241            keymap_open: false,
242            cache: InfoCache::default(),
243            control: None,
244            injected_requests: Vec::new(),
245            autosave: None,
246            recovery: Vec::new(),
247            modals: Vec::new(),
248            stats: FrameStats::default(),
249            wakes: Scheduler::default(),
250            queued: Vec::new(),
251            created: now(),
252            started: false,
253            first_frame_done: false,
254            view_error: None,
255            allow_close: false,
256            quit_after_save: false,
257            shown_title: String::new(),
258            picker: None,
259            passes: 0,
260            native: false,
261            #[cfg(all(feature = "native-menu", any(target_os = "macos", target_os = "windows")))]
262            native_menu: None,
263            launch,
264            shared,
265        };
266        rt.start_autosave();
267        if let Some(path) = rt.launch.open.take() {
268            let r = rt.execute("file.open", json!({"path": path.to_string_lossy(), "discard": true}));
269            if let Err(e) = r {
270                rt.toasts.error(e.to_string());
271            }
272        }
273        if rt.launch.safe_mode {
274            rt.modals.push(Modal::SafeMode);
275        }
276        rt
277    }
278
279    fn start_autosave(&mut self) {
280        let (Some(every), Some(dirs)) = (self.shared.autosave, &self.shared.dirs) else { return };
281        let mut cfg = AutosaveConfig::new(dirs.data.join("Recovery"));
282        cfg.interval = every;
283        match Autosave::start(cfg) {
284            Ok((autosave, candidates)) => {
285                self.recovery = candidates;
286                if !self.recovery.is_empty() {
287                    self.modals.insert(0, Modal::Recovery);
288                }
289                self.autosave = Some(AutosaveState {
290                    autosave,
291                    #[cfg(not(target_arch = "wasm32"))]
292                    writer: ashlar_store::BackgroundWriter::start().ok(),
293                    file_gen: u64::MAX,
294                });
295            }
296            Err(e) => log::warn!("autosave is off: {e}"),
297        }
298    }
299
300    // ------------------------------------------------------------------ public API
301
302    pub fn session(&self) -> &Session<D> {
303        &self.session
304    }
305
306    pub fn session_mut(&mut self) -> &mut Session<D> {
307        &mut self.session
308    }
309
310    pub fn settings(&self) -> &Settings {
311        &self.settings
312    }
313
314    pub fn toasts(&self) -> &Toasts {
315        &self.toasts
316    }
317
318    /// Frame-time statistics (`app.stats`).
319    pub fn stats(&self) -> &FrameStats {
320        &self.stats
321    }
322
323    pub fn stats_mut(&mut self) -> &mut FrameStats {
324        &mut self.stats
325    }
326
327    /// Frames and wake requests per [`Reason`] (`app.stats` → `wakes`).
328    pub fn wakes(&self) -> WakeStats {
329        self.wakes.stats()
330    }
331
332    /// Passes run so far (frames, plus egui's occasional extra layout pass).
333    pub fn passes(&self) -> u64 {
334        self.passes
335    }
336
337    /// Replace the native file dialogs (tests, scripted runs).
338    pub fn set_file_picker(&mut self, picker: impl FnMut(Pick) -> Option<PathBuf> + 'static) {
339        self.picker = Some(Box::new(picker));
340    }
341
342    /// Run a command through the chokepoint now.
343    pub fn execute(&mut self, id: &str, params: Value) -> Result<Value, CommandError> {
344        self.shared.registry.execute(&mut self.session, id, params)
345    }
346
347    /// Hand a control request to the runtime as if it came from the control port (tests).
348    pub fn inject_request(&mut self, req: Request) {
349        self.injected_requests.push(req);
350    }
351
352    /// The control port, when one is serving.
353    pub fn control_port(&self) -> Option<u16> {
354        self.control.as_ref().map(|(s, _)| s.port())
355    }
356
357    /// Whether the app has decided to close (the window should go away).
358    pub fn is_closing(&self) -> bool {
359        self.allow_close
360    }
361
362    /// The error that suspended the view, if one did.
363    pub fn view_error(&self) -> Option<&str> {
364        self.view_error.as_deref()
365    }
366
367    fn info(&self) -> AppInfo {
368        AppInfo::new(self.shared.name.clone(), self.shared.version.clone())
369    }
370
371    // ------------------------------------------------------------------ start
372
373    fn ensure_started(&mut self, ctx: &egui::Context) {
374        if self.started {
375            return;
376        }
377        self.started = true;
378        devtools::install(ctx);
379        let mode = if self.launch.safe_mode { ThemeMode::System } else { self.settings.get::<ThemeMode>("theme").unwrap_or_default() };
380        tokens::install(ctx, &Tokens::light(), &Tokens::dark(), mode);
381        if let Some(setup) = self.setup.take()
382            && let Err(p) = catch_unwind(AssertUnwindSafe(|| setup(ctx)))
383        {
384            log::error!("app setup panicked: {}", panic_text(p.as_ref()));
385        }
386        let w = wake::waker(ctx, Reason::JOB);
387        self.jobs.set_wake(move || w.wake());
388        let w = wake::waker(ctx, Reason::SETTINGS);
389        self.settings.set_wake(move |d| w.reschedule(d));
390        if let Some(a) = &mut self.autosave {
391            let w = wake::waker(ctx, Reason::AUTOSAVE);
392            a.autosave.set_wake(move |d| w.reschedule(d));
393            #[cfg(not(target_arch = "wasm32"))]
394            if let Some(writer) = &a.writer {
395                let w = wake::waker(ctx, Reason::AUTOSAVE);
396                writer.set_wake(move || w.wake());
397            }
398        }
399        let port = self.launch.control_port.or(self.shared.control_port);
400        // A launcher may say where the key goes (`ashlar-debug open`); otherwise the settings folder.
401        let key_dir = ashlar_control::key::key_dir_from_env().or_else(|| self.shared.dirs.as_ref().map(|d| d.config.clone()));
402        if let (Some(port), Some(key_dir)) = (port, key_dir) {
403            let w = wake::waker(ctx, Reason::CONTROL);
404            match Server::start(ashlar_control::Config::new(port, key_dir), move || w.wake()) {
405                Ok((server, rx)) => {
406                    self.control = Some((server, rx));
407                }
408                Err(e) => {
409                    log::error!("control port {port}: {e}");
410                    self.toasts.error(format!("Agent control is off: {e}"));
411                }
412            }
413        }
414    }
415
416    // ------------------------------------------------------------------ the frame
417
418    /// Work that needs no drawing: control requests, finished jobs, effects, autosave, settings.
419    /// eframe calls it before every frame (and while the window is hidden).
420    pub fn logic(&mut self, ctx: &egui::Context) {
421        self.launch.telemetry.begin_turn();
422        self.ensure_started(ctx);
423        self.serve_control(ctx);
424        for rec in self.jobs.drain_ready(&mut self.session) {
425            if rec.status == ashlar_jobs::Status::Failed {
426                self.toasts.error(format!("{}: {}", rec.label, rec.message));
427            }
428        }
429        self.apply_effects(ctx);
430        #[cfg(all(feature = "native-menu", any(target_os = "macos", target_os = "windows")))]
431        for id in self.native_menu.as_ref().map(|m| m.drain()).unwrap_or_default() {
432            self.run_ui_command(ctx, &id, json!({}));
433        }
434        self.launch.telemetry.end_turn();
435    }
436
437    /// eframe runs no UI pass while the window is minimized or covered. While debugger work waits
438    /// (ui input, snapshots, waits, screenshots), run one here, after [`Runtime::logic`]: what it
439    /// draws is not presented (screenshots render off screen) and nothing raises the window, so
440    /// agents drive a covered window without taking the focus. Its window commands go to the
441    /// context; the output is returned for the caller to hand its texture changes to the renderer.
442    /// `time` and `events` are for the pass (eframe gives none: the wall clock goes on; the
443    /// headless driver passes its virtual clock and delivers its screenshots this way).
444    pub fn hidden_pass(&mut self, ctx: &egui::Context, time: Option<f64>, events: Vec<egui::Event>) -> Option<egui::FullOutput> {
445        if ctx.input(|i| i.viewport().visible()) != Some(false) || !devtools::has_pending(ctx) {
446            return None;
447        }
448        let mut raw = ctx.input(|i| i.raw.clone());
449        raw.events = events;
450        raw.hovered_files.clear();
451        raw.dropped_files.clear();
452        raw.viewport_id = egui::ViewportId::ROOT;
453        // eframe hands no time to a hidden window: go on from the last pass on the wall clock
454        let last = ctx.input(|i| i.time);
455        raw.time = Some(time.unwrap_or_else(|| self.created.map_or(last, |c| c.elapsed().as_secs_f64())).max(last));
456        if raw.screen_rect.is_none()
457            && let Some(r) = raw.viewports.get(&egui::ViewportId::ROOT).and_then(|v| v.inner_rect)
458        {
459            raw.screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, r.size()));
460        }
461        self.raw_input_hook(ctx, &mut raw);
462        let out = ctx.run_ui(raw, |ui| self.ui(ui));
463        for (id, vo) in &out.viewport_output {
464            for c in &vo.commands {
465                ctx.send_viewport_cmd_to(*id, c.clone());
466            }
467            if *id == egui::ViewportId::ROOT && vo.repaint_delay < Duration::from_secs(3600) {
468                wake::wake_after(ctx, Reason::CONTROL, vo.repaint_delay);
469            }
470        }
471        if !out.platform_output.commands.is_empty() {
472            let commands = out.platform_output.commands.clone();
473            ctx.output_mut(|o| o.commands.extend(commands));
474        }
475        Some(out)
476    }
477
478    /// The start of a frame (eframe's `raw_input_hook`, before [`Runtime::logic`]): credit the
479    /// frame to the wake reasons that were due. (Injected agent input is handed to egui by the
480    /// debugger's plugin.)
481    pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
482        // installed every frame: an app may reset egui's memory, which holds the scheduler
483        self.wakes.install(ctx);
484        self.wakes.begin_frame(raw.time.unwrap_or_else(|| ctx.input(|i| i.time)));
485    }
486
487    /// Draw the frame.
488    pub fn ui(&mut self, ui: &mut egui::Ui) {
489        let ctx = ui.ctx().clone();
490        let started = now();
491        self.launch.telemetry.begin_turn();
492        self.ensure_started(&ctx);
493        self.passes = self.passes.saturating_add(1);
494        // timed wakes not yet due when this frame ran (input came first) stay scheduled
495        self.wakes.rearm(&ctx);
496        let rev_before = self.session.revision();
497        let (focused, active) = ctx.input(|i| (i.focused, !i.events.is_empty()));
498        self.launch.telemetry.input(focused, active);
499
500        self.refresh_infos();
501        if !self.modal_open()
502            && let Some(id) = dispatch(&ctx, &self.keymap)
503        {
504            self.run_ui_command(&ctx, &id, json!({}));
505        }
506        self.update_title(&ctx);
507        if !self.native_menu_active() {
508            let spec = &self.cache.menu;
509            let chosen = egui::Panel::top("ashlar.menu_bar").show(ui, |ui| menu_bar(ui, spec, Platform::current())).inner;
510            if let Some(id) = chosen {
511                self.run_ui_command(&ctx, &id, json!({}));
512            }
513        }
514        self.draw_view(ui);
515        for (id, params) in std::mem::take(&mut self.queued) {
516            self.run_ui_command(&ctx, &id, params);
517        }
518        self.apply_effects(&ctx);
519        self.draw_modals(&ctx);
520        self.draw_tools(&ctx);
521        self.toasts.show(&ctx);
522        self.handle_close(&ctx);
523        self.apply_effects(&ctx);
524        self.persist_view();
525        self.tick_storage(&ctx);
526        self.feed_devtools(&ctx);
527
528        // the frame changed the document: draw once more so everything shows the new state
529        if self.session.revision() != rev_before {
530            wake::wake(&ctx, Reason::DOCUMENT);
531        }
532        if let Some(s) = started {
533            let took = s.elapsed();
534            self.stats.record(took);
535            self.launch.telemetry.frame(took);
536        }
537        if !self.first_frame_done {
538            self.first_frame_done = true;
539            if let Some(g) = &mut self.launch.guard {
540                g.started();
541            }
542            if let Some(c) = self.created {
543                self.stats.set_first_frame(c.elapsed());
544            }
545            self.launch.telemetry.first_frame();
546            self.install_native_menu(&ctx);
547        }
548        self.launch.telemetry.end_turn();
549    }
550
551    fn draw_view(&mut self, ui: &mut egui::Ui) {
552        let Runtime { session, shared, jobs, toasts, settings, queued, view, view_error, .. } = self;
553        egui::CentralPanel::default().show(ui, |ui| {
554            if let Some(err) = view_error.as_deref() {
555                ui.centered_and_justified(|ui| ui.label(format!("This view stopped after an internal error:\n{err}")));
556                return;
557            }
558            let Some(view) = view.as_mut() else { return };
559            let mut cx = Cx { session, registry: &shared.registry, jobs, toasts, settings, queued, ctx: ui.ctx().clone() };
560            let r = catch_unwind(AssertUnwindSafe(|| view(ui, &mut cx)));
561            if let Err(p) = r {
562                let msg = crash::last_panic().map_or_else(|| panic_text(p.as_ref()), |r| format!("{} ({})", r.message, r.location));
563                log::error!("the view panicked: {msg}");
564                *view_error = Some(msg);
565            }
566        });
567    }
568
569    fn native_menu_active(&self) -> bool {
570        #[cfg(all(feature = "native-menu", any(target_os = "macos", target_os = "windows")))]
571        {
572            self.native_menu.is_some()
573        }
574        #[cfg(not(all(feature = "native-menu", any(target_os = "macos", target_os = "windows"))))]
575        {
576            false
577        }
578    }
579
580    fn install_native_menu(&mut self, ctx: &egui::Context) {
581        #[cfg(all(feature = "native-menu", any(target_os = "macos", target_os = "windows")))]
582        if self.native {
583            self.native_menu = crate::native_menu::NativeMenu::install(&self.cache.infos, ctx);
584        }
585        let _ = ctx;
586    }
587
588    fn modal_open(&self) -> bool {
589        !self.modals.is_empty() || self.palette.is_open() || self.view_error.is_some()
590    }
591
592    // ------------------------------------------------------------------ commands
593
594    fn refresh_infos(&mut self) {
595        let key = (self.session.revision(), self.session.view_generation(), self.session.file_generation(), self.keymap_gen);
596        if self.cache.key == Some(key) {
597            return;
598        }
599        let mut infos = self.shared.registry.infos(&self.session);
600        for info in &mut infos {
601            let keys = self.keymap.keys_for(&info.id);
602            info.shortcut = if keys.is_empty() { None } else { Some(format_list(keys)) };
603        }
604        self.cache.menu = MenuSpec::from_commands(&infos, MENU_ORDER);
605        self.cache.infos = infos;
606        self.cache.key = Some(key);
607        self.cache.seq = self.cache.seq.wrapping_add(1);
608        #[cfg(all(feature = "native-menu", any(target_os = "macos", target_os = "windows")))]
609        if let Some(m) = &mut self.native_menu {
610            m.update(&self.cache.infos);
611        }
612    }
613
614    /// Run a command the user chose (menu, key, palette, button): errors become toasts.
615    fn run_ui_command(&mut self, ctx: &egui::Context, id: &str, params: Value) {
616        if let Err(e) = self.execute(id, params) {
617            match e {
618                CommandError::Disabled { .. } => log::debug!("{e}"),
619                _ => {
620                    self.toasts.error(e.to_string());
621                }
622            }
623        }
624        self.apply_effects(ctx);
625    }
626
627    fn apply_effects(&mut self, ctx: &egui::Context) {
628        // effects can run commands that request more effects: bounded rounds
629        for _ in 0..8 {
630            let effects = self.session.take_effects();
631            if effects.is_empty() {
632                return;
633            }
634            for e in effects {
635                self.apply_effect(ctx, e);
636            }
637        }
638    }
639
640    fn apply_effect(&mut self, ctx: &egui::Context, e: Effect) {
641        match e {
642            Effect::Toast { level, text } => {
643                self.toasts.push(ashlar_ui::toasts::Toast::new(level, text));
644            }
645            Effect::OpenPalette => self.palette.open(),
646            Effect::OpenKeymap => self.keymap_open = true,
647            Effect::Theme(mode) => {
648                tokens::set_mode(ctx, mode);
649                let _ = self.settings.set("theme", &mode);
650            }
651            Effect::PickOpen => {
652                if let Some(path) = self.pick(Pick::Open) {
653                    let r = self.execute("file.open", json!({"path": path.to_string_lossy(), "discard": true}));
654                    self.report(r);
655                }
656            }
657            Effect::PickSaveAs => match self.pick(Pick::SaveAs) {
658                Some(path) => {
659                    let r = self.execute("file.save_as", json!({"path": path.to_string_lossy()}));
660                    self.report(r);
661                    self.continue_quit(ctx);
662                }
663                None => self.quit_after_save = false,
664            },
665            Effect::ConfirmDiscard { command, params } => self.modals.push(Modal::Confirm { quit: false, command: Some((command, params)) }),
666            Effect::Quit { discard } => {
667                if self.session.is_dirty() && !discard {
668                    self.modals.push(Modal::Confirm { quit: true, command: None });
669                } else {
670                    self.close(ctx, discard);
671                }
672            }
673        }
674    }
675
676    fn report(&mut self, r: Result<Value, CommandError>) {
677        if let Err(e) = r {
678            self.toasts.error(e.to_string());
679        }
680    }
681
682    fn pick(&mut self, kind: Pick) -> Option<PathBuf> {
683        if let Some(p) = &mut self.picker {
684            return p(kind);
685        }
686        self.launch.telemetry.pause_hangs();
687        let r = native_pick(&self.shared, &self.session, kind);
688        self.launch.telemetry.resume_hangs();
689        r
690    }
691
692    // ------------------------------------------------------------------ closing
693
694    fn handle_close(&mut self, ctx: &egui::Context) {
695        let requested = ctx.input(|i| i.viewport().close_requested());
696        if !requested || self.allow_close {
697            return;
698        }
699        if self.session.is_dirty() {
700            ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
701            if !self.modals.iter().any(|m| matches!(m, Modal::Confirm { quit: true, .. })) {
702                self.modals.push(Modal::Confirm { quit: true, command: None });
703            }
704        } else {
705            self.shutdown(false);
706        }
707    }
708
709    fn close(&mut self, ctx: &egui::Context, discarded: bool) {
710        self.shutdown(discarded);
711        ctx.send_viewport_cmd(egui::ViewportCommand::Close);
712    }
713
714    /// Settle everything before the process ends: stop jobs, flush settings, end autosave.
715    #[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
716    pub fn shutdown(&mut self, _discarded: bool) {
717        if self.allow_close {
718            return;
719        }
720        self.allow_close = true;
721        self.jobs.cancel_all();
722        self.persist_view();
723        if let Err(e) = self.settings.flush() {
724            log::warn!("settings not saved: {e}");
725        }
726        if let Some(mut a) = self.autosave.take() {
727            #[cfg(not(target_arch = "wasm32"))]
728            if let Some(w) = a.writer.take() {
729                for e in w.shutdown(&mut a.autosave) {
730                    log::warn!("autosave: {e}");
731                }
732            }
733            // a clean exit (saved, or the user chose not to keep the changes)
734            if let Err(e) = a.autosave.close(false) {
735                log::warn!("autosave cleanup: {e}");
736            }
737        }
738        self.launch.telemetry.flush(Duration::from_millis(500));
739        log::info!("{} closed", self.shared.name);
740    }
741
742    /// End without any cleanup, as if the process had died: autosave files stay for the next
743    /// launch to recover, settings are not flushed. For crash-recovery tests.
744    #[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
745    pub fn abandon(mut self) {
746        self.allow_close = true;
747        if let Some(mut a) = self.autosave.take() {
748            #[cfg(not(target_arch = "wasm32"))]
749            if let Some(w) = a.writer.take() {
750                let _ = w.shutdown(&mut a.autosave);
751            }
752            // dropping (not closing) releases the lock and keeps the session's files
753            drop(a);
754        }
755    }
756
757    fn continue_quit(&mut self, ctx: &egui::Context) {
758        if self.quit_after_save {
759            self.quit_after_save = false;
760            if !self.session.is_dirty() {
761                self.close(ctx, false);
762            }
763        }
764    }
765
766    // ------------------------------------------------------------------ storage
767
768    fn persist_view(&mut self) {
769        let view = Value::Object(self.session.view_state().clone());
770        if self.settings.get_value("view") != Some(&view) {
771            self.settings.set_value("view", view);
772        }
773    }
774
775    fn tick_storage(&mut self, ctx: &egui::Context) {
776        let t = ctx.input(|i| i.time).max(0.0);
777        let now = Duration::from_secs_f64(t);
778        // a pending save asks for its own frame through the settings' wake hook
779        if let Err(e) = self.settings.tick(now) {
780            log::warn!("settings not saved: {e}");
781        }
782        let Some(a) = &mut self.autosave else { return };
783        if a.file_gen != self.session.file_generation() {
784            a.file_gen = self.session.file_generation();
785            let _ = a.autosave.set_document(&self.session.display_name(), self.session.path());
786        }
787        let dirty = self.session.is_dirty();
788        let rev = self.session.revision();
789        if !dirty {
790            if a.autosave.meta().dirty {
791                let _ = a.autosave.mark_clean();
792            }
793            return;
794        }
795        // an autosave not yet due asks for its frame through the autosave's wake hook, a finished
796        // background write through the writer's
797        let snap = self.session.store().snapshot();
798        let format = Arc::clone(&self.shared.format);
799        #[cfg(not(target_arch = "wasm32"))]
800        let wrote = match &mut a.writer {
801            Some(w) => {
802                for e in w.pump(&mut a.autosave) {
803                    log::warn!("autosave: {e}");
804                }
805                w.tick(&mut a.autosave, now, rev, dirty, move || format.encode(&snap).unwrap_or_default())
806            }
807            None => a.autosave.tick(now, rev, dirty, || format.encode(&snap).unwrap_or_default()).ok().flatten().is_some(),
808        };
809        #[cfg(target_arch = "wasm32")]
810        let wrote = a.autosave.tick(now, rev, dirty, || format.encode(&snap).unwrap_or_default()).ok().flatten().is_some();
811        if wrote {
812            log::debug!("autosaved revision {}", rev.0);
813        }
814    }
815
816    fn update_title(&mut self, ctx: &egui::Context) {
817        let dirty = if self.session.is_dirty() { " •" } else { "" };
818        let title = format!("{}{dirty} — {}", self.session.display_name(), self.shared.title);
819        if title != self.shown_title {
820            ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
821            self.shown_title = title;
822        }
823    }
824
825    /// The window title the runtime last set (`name • — App` when there are unsaved changes).
826    pub fn title(&self) -> &str {
827        &self.shown_title
828    }
829
830    // ------------------------------------------------------------------ dialogs and tools
831
832    fn draw_modals(&mut self, ctx: &egui::Context) {
833        if let Some(err) = self.view_error.clone() {
834            let buttons = [DialogButton::new("Save As…"), DialogButton::new("Try Again").default()];
835            let file = crash::last_panic().and_then(|p| p.log_file);
836            match Dialog::new("ashlar.view_error", "Something went wrong", &buttons).width(460.0).show(ctx, |ui| {
837                ui.label("The view hit an internal error and was stopped. Your document is safe: save it, then try again.");
838                ui.add_space(6.0);
839                ui.monospace(&err);
840                if let Some(f) = &file {
841                    ui.small(format!("Crash log: {}", f.display()));
842                }
843            }) {
844                Some(DialogOutcome::Button(0)) => {
845                    let r = self.execute("file.save_as", json!({}));
846                    self.report(r);
847                    self.apply_effects(ctx);
848                }
849                Some(_) => {
850                    self.view_error = None;
851                    crash::take_last_panic();
852                }
853                None => {}
854            }
855            return;
856        }
857        let Some(modal) = self.modals.first_mut() else { return };
858        let done = match modal {
859            Modal::Recovery => self.recovery_dialog(ctx),
860            Modal::SafeMode => {
861                let buttons = [DialogButton::new("Keep Settings"), DialogButton::new("Reset View Settings").default()];
862                let title = format!("{} started in safe mode", self.shared.title);
863                match Dialog::new("ashlar.safe_mode", &title, &buttons).show(ctx, |ui| {
864                    ui.label("The last launch did not finish starting. Saved view settings (layout, theme) were not loaded this time.");
865                }) {
866                    Some(DialogOutcome::Button(1)) => {
867                        self.settings.remove("view");
868                        self.settings.remove("theme");
869                        ctx.memory_mut(|m| *m = egui::Memory::default());
870                        self.wakes.install(ctx);
871                        true
872                    }
873                    Some(_) => true,
874                    None => false,
875                }
876            }
877            Modal::Confirm { quit, command } => {
878                let (quit, command) = (*quit, command.clone());
879                self.confirm_dialog(ctx, quit, command)
880            }
881        };
882        if done && !self.modals.is_empty() {
883            self.modals.remove(0);
884        }
885    }
886
887    fn confirm_dialog(&mut self, ctx: &egui::Context, quit: bool, command: Option<(String, Value)>) -> bool {
888        let buttons = [DialogButton::cancel("Cancel"), DialogButton::destructive("Don't Save"), DialogButton::new("Save").default()];
889        let title = format!("Save changes to “{}”?", self.session.display_name());
890        let out = Dialog::new("ashlar.confirm_discard", &title, &buttons).show(ctx, |ui| {
891            ui.label("Your changes will be lost if you don't save them.");
892        });
893        match out {
894            Some(DialogOutcome::Button(1)) => {
895                if quit {
896                    self.close(ctx, true);
897                } else if let Some((id, params)) = command {
898                    self.run_ui_command(ctx, &id, params);
899                }
900                true
901            }
902            Some(DialogOutcome::Button(2)) => {
903                self.quit_after_save = quit;
904                let r = self.execute("file.save", json!({}));
905                self.report(r);
906                self.apply_effects(ctx);
907                if !self.session.is_dirty() {
908                    if quit {
909                        self.continue_quit(ctx);
910                    } else if let Some((id, params)) = command {
911                        self.run_ui_command(ctx, &id, params);
912                    }
913                }
914                true
915            }
916            Some(_) => true,
917            None => false,
918        }
919    }
920
921    fn recovery_dialog(&mut self, ctx: &egui::Context) -> bool {
922        let Some(c) = self.recovery.first() else { return true };
923        let buttons = [DialogButton::destructive("Discard"), DialogButton::new("Later"), DialogButton::new("Recover").default()];
924        let label = if c.meta.label.is_empty() { "Untitled".to_string() } else { c.meta.label.clone() };
925        let ago = (ashlar_store::autosave::unix_now() - c.meta.saved_unix).max(0);
926        let out = Dialog::new("ashlar.recovery", "Recover unsaved work?", &buttons).width(440.0).show(ctx, |ui| {
927            ui.label(format!("“{label}” had unsaved changes when {} last closed (autosaved {} ago).", self.shared.title, human_age(ago)));
928        });
929        let Some(out) = out else { return false };
930        let c = self.recovery.remove(0);
931        match out {
932            DialogOutcome::Button(2) => match c.latest().map(|p| self.shared.format.load(p)) {
933                Some(Ok(loaded)) => {
934                    let path = c.meta.doc_path.as_ref().map(PathBuf::from);
935                    self.session.replace(D::default(), path, None);
936                    self.session.replace_as_edit("Recover Unsaved Work", loaded.doc);
937                    if let Err(e) = c.discard() {
938                        log::warn!("recovery files not removed: {e}");
939                    }
940                    self.toasts.success(format!("Recovered “{label}”. Save it to keep it."));
941                }
942                Some(Err(e)) => {
943                    self.toasts.error(format!("Could not recover “{label}”: {e}"));
944                }
945                None => {
946                    let _ = c.discard();
947                }
948            },
949            DialogOutcome::Button(0) => {
950                if let Err(e) = c.discard() {
951                    log::warn!("recovery files not removed: {e}");
952                }
953            }
954            _ => {}
955        }
956        self.recovery.is_empty()
957    }
958
959    fn draw_tools(&mut self, ctx: &egui::Context) {
960        if self.palette.is_open()
961            && let Some(id) = self.palette.show(ctx, &self.cache.infos, self.cache.seq, Platform::current())
962        {
963            self.run_ui_command(ctx, &id, json!({}));
964        }
965        if self.keymap_open {
966            let mut open = true;
967            let mut change = None;
968            let (keymap, editor, infos, seq) = (&mut self.keymap, &mut self.keymap_editor, &self.cache.infos, self.cache.seq);
969            egui::Window::new("Keyboard Shortcuts").id(egui::Id::new("ashlar.keymap")).open(&mut open).default_size([520.0, 420.0]).show(ctx, |ui| {
970                change = editor.show(ui, keymap, infos, seq);
971            });
972            self.keymap_open = open;
973            if let Some(c) = change {
974                log::info!("keymap changed: {c:?}");
975                let _ = self.settings.set("keymap", &self.keymap.overrides());
976                self.keymap_gen = self.keymap_gen.wrapping_add(1);
977                if matches!(c, KeymapChange::Bound { .. } | KeymapChange::Reset { .. }) {
978                    self.cache.key = None;
979                }
980            }
981        }
982    }
983
984    // ------------------------------------------------------------------ agent control
985
986    fn serve_control(&mut self, ctx: &egui::Context) {
987        let mut reqs: Vec<Request> = std::mem::take(&mut self.injected_requests);
988        if let Some((_, rx)) = &self.control {
989            reqs.extend(rx.try_iter().take(256));
990        }
991        if reqs.is_empty() {
992            return;
993        }
994        let info = self.info();
995        let mut queue = UiQueue::default();
996        for req in reqs {
997            let mut bound = self.shared.registry.bind(&mut self.session);
998            ashlar_control::handle(req, &mut bound, &mut queue, &info);
999            self.apply_effects(ctx);
1000        }
1001        for req in std::mem::take(&mut queue.pending) {
1002            self.answer_ui(ctx, req);
1003        }
1004        wake::wake(ctx, Reason::CONTROL);
1005    }
1006
1007    fn answer_ui(&mut self, ctx: &egui::Context, req: Request) {
1008        let p = req.params().clone();
1009        let visible = ctx.input(|i| i.viewport().visible()).unwrap_or(true);
1010        if devtools::handles(req.method()) {
1011            // on a minimized or covered window, [`Runtime::hidden_pass`] runs the frames these need
1012            if let devtools::Outcome::NotMine(req) = devtools::handle(ctx, req) {
1013                req.unknown();
1014            }
1015            return;
1016        }
1017        match req.method() {
1018            "ui.inspect" => {
1019                let ins = automation::inspect(ctx);
1020                req.ok(json!({
1021                    "pass": ins.pass,
1022                    "elements": ins.elements,
1023                    "title": self.shown_title,
1024                    "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())},
1025                    "view": self.session.view_state(),
1026                    "palette_open": self.palette.is_open(),
1027                    "keymap_open": self.keymap_open,
1028                    "dialogs": self.modals.len(),
1029                    "view_error": self.view_error,
1030                    "toasts": self.toasts.texts().collect::<Vec<_>>(),
1031                    "visible": visible,
1032                }));
1033            }
1034            "ui.menu.list" => {
1035                self.refresh_infos();
1036                req.ok(serde_json::to_value(&self.cache.menu).unwrap_or(Value::Null));
1037            }
1038            "ui.menu.invoke" => {
1039                let Some(id) = p.get("id").and_then(Value::as_str) else { return req.fail("ui.menu.invoke needs `id`") };
1040                self.refresh_infos();
1041                // menu item ids are command ids
1042                let command = self.cache.menu.find(id).map(|i| i.id.clone());
1043                match command {
1044                    Some(c) => {
1045                        let r = self.execute(&c, json!({}));
1046                        self.apply_effects(ctx);
1047                        req.reply(Reply::from_command(r));
1048                    }
1049                    None => req.fail(format!("no menu item `{id}` (see ui.menu.list)")),
1050                }
1051            }
1052            "ui.set" => {
1053                let Some(obj) = p.as_object() else { return req.fail("ui.set needs an object") };
1054                for (k, v) in obj {
1055                    if k == "theme" {
1056                        let mode: Option<ThemeMode> = serde_json::from_value(v.clone()).ok();
1057                        match mode {
1058                            Some(m) => {
1059                                self.session.set_view("theme", &m);
1060                                self.apply_effect(ctx, Effect::Theme(m));
1061                            }
1062                            None => return req.fail("theme is light, dark or system"),
1063                        }
1064                    } else {
1065                        self.session.set_view_value(k, v.clone());
1066                    }
1067                }
1068                req.ok(json!({}));
1069            }
1070            "app.quit" => {
1071                let discard = p.get("discard").and_then(Value::as_bool).unwrap_or(false);
1072                let dirty = self.session.is_dirty();
1073                self.apply_effect(ctx, Effect::Quit { discard });
1074                req.ok(json!({"closing": !dirty || discard}));
1075            }
1076            "app.stats" => {
1077                let mut v = serde_json::to_value(self.stats.summary()).unwrap_or(Value::Null);
1078                if let Some(o) = v.as_object_mut() {
1079                    o.insert("passes".into(), json!(self.passes));
1080                    o.insert("jobs".into(), self.jobs.to_json());
1081                    o.insert("log_lines_dropped".into(), json!(crate::logging::dropped()));
1082                    o.insert("wakes".into(), serde_json::to_value(self.wakes.stats()).unwrap_or(Value::Null));
1083                }
1084                if p.get("reset").and_then(Value::as_bool) == Some(true) {
1085                    self.stats.reset_window();
1086                }
1087                req.ok(v);
1088            }
1089            "app.logs" => {
1090                let limit = p.get("limit").and_then(Value::as_u64).unwrap_or(200).min(crate::logging::RING_LINES as u64) as usize;
1091                let level = p.get("level").and_then(Value::as_str).and_then(crate::logging::level_of).unwrap_or(log::Level::Trace);
1092                req.ok(json!({"lines": crate::logging::recent(limit, level)}));
1093            }
1094            _ => req.unknown(),
1095        }
1096    }
1097
1098    /// The inspector's panels and the app's pending work for `ui.wait {for: "idle"}`. The panels
1099    /// are built only while the inspector is open.
1100    fn feed_devtools(&mut self, ctx: &egui::Context) {
1101        devtools::set_busy(ctx, self.jobs.is_busy());
1102        devtools::section(ctx, "Logs", || {
1103            crate::logging::recent(200, log::Level::Trace).into_iter().map(|l| format!("{:<5} {}: {}", l.level, l.target, l.message)).collect()
1104        });
1105        let history = self.session.store().history();
1106        devtools::section(ctx, "Undo", || {
1107            let mut lines: Vec<String> = history.undo_labels().map(|l| format!("  {l}")).collect();
1108            lines.push("── now ──".into());
1109            lines.extend(history.redo_labels().map(|l| format!("  {l} (redo)")));
1110            lines
1111        });
1112    }
1113}
1114
1115impl<D: Document> Drop for Runtime<D> {
1116    fn drop(&mut self) {
1117        self.shutdown(false);
1118    }
1119}
1120
1121fn human_age(secs: i64) -> String {
1122    match secs {
1123        s if s < 90 => format!("{s} s"),
1124        s if s < 90 * 60 => format!("{} min", s / 60),
1125        s if s < 48 * 3600 => format!("{} h", s / 3600),
1126        s => format!("{} days", s / 86_400),
1127    }
1128}
1129
1130#[cfg(not(target_arch = "wasm32"))]
1131fn native_pick<D: Document>(shared: &Shared<D>, session: &Session<D>, kind: Pick) -> Option<PathBuf> {
1132    use ashlar_native::dialogs::FileDialog;
1133    let exts: Vec<&str> = shared.extensions.iter().map(String::as_str).collect();
1134    let mut d = FileDialog::new();
1135    if !exts.is_empty() {
1136        d = d.filter(shared.title.clone(), &exts);
1137    }
1138    if let Some(dir) = session.path().and_then(std::path::Path::parent) {
1139        d = d.directory(dir);
1140    }
1141    let r = match kind {
1142        Pick::Open => d.title("Open").open_file(),
1143        Pick::SaveAs => {
1144            let name = match exts.first() {
1145                Some(e) if session.path().is_none() => format!("Untitled.{e}"),
1146                _ => session.display_name(),
1147            };
1148            d.title("Save As").file_name(name).save_file()
1149        }
1150    };
1151    match r {
1152        Ok(p) => Some(p),
1153        Err(ashlar_native::NativeError::Cancelled) => None,
1154        Err(e) => {
1155            log::warn!("file dialog: {e}");
1156            None
1157        }
1158    }
1159}
1160
1161#[cfg(target_arch = "wasm32")]
1162fn native_pick<D: Document>(_shared: &Shared<D>, _session: &Session<D>, _kind: Pick) -> Option<PathBuf> {
1163    None
1164}
1165
1166// ---------------------------------------------------------------------------------------------
1167// eframe
1168// ---------------------------------------------------------------------------------------------
1169
1170struct EframeApp<D: Document> {
1171    rt: Runtime<D>,
1172    /// The debugger renders screenshots on eframe's wgpu device (set on the first frame).
1173    offscreen: bool,
1174}
1175
1176impl<D: Document> EframeApp<D> {
1177    /// Hand the debugger its off-screen renderer once eframe's wgpu device is known.
1178    fn install_offscreen(&mut self, ctx: &egui::Context, frame: &eframe::Frame) {
1179        if std::mem::replace(&mut self.offscreen, true) {
1180            return;
1181        }
1182        #[cfg(not(target_arch = "wasm32"))]
1183        if let Some(rs) = frame.wgpu_render_state() {
1184            devtools::set_offscreen(ctx, Some(crate::offscreen::renderer(rs.clone())));
1185        }
1186        #[cfg(target_arch = "wasm32")]
1187        let _ = (ctx, frame);
1188    }
1189}
1190
1191impl<D: Document> eframe::App for EframeApp<D> {
1192    fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
1193        self.rt.logic(ctx);
1194        self.install_offscreen(ctx, frame);
1195        let Some(mut out) = self.rt.hidden_pass(ctx, None, Vec::new()) else { return };
1196        #[cfg(not(target_arch = "wasm32"))]
1197        if let Some(rs) = frame.wgpu_render_state() {
1198            let mut r = rs.renderer.write();
1199            for (id, deltas) in &out.textures_delta.set {
1200                for delta in deltas {
1201                    r.update_texture(&rs.device, &rs.queue, *id, delta);
1202                }
1203            }
1204            for id in &out.textures_delta.free {
1205                r.free_texture(id);
1206            }
1207        }
1208        // handed to the renderer (or, with no wgpu device, nowhere to hand them)
1209        out.textures_delta.clear();
1210    }
1211
1212    fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
1213        self.install_offscreen(ui.ctx(), frame);
1214        self.rt.ui(ui);
1215    }
1216
1217    fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
1218        self.rt.raw_input_hook(ctx, raw_input);
1219    }
1220
1221    fn on_exit(&mut self) {
1222        self.rt.shutdown(false);
1223    }
1224}
1225
1226#[cfg(not(target_arch = "wasm32"))]
1227struct Args {
1228    control: Option<u16>,
1229    safe_mode: bool,
1230    open: Option<PathBuf>,
1231}
1232
1233#[cfg(not(target_arch = "wasm32"))]
1234fn parse_args(env_prefix: &str) -> Args {
1235    let mut a = Args { control: None, safe_mode: false, open: None };
1236    let mut it = std::env::args().skip(1);
1237    while let Some(arg) = it.next() {
1238        match arg.as_str() {
1239            "--control" => a.control = it.next().and_then(|p| p.parse().ok()),
1240            s if s.starts_with("--control=") => a.control = s.trim_start_matches("--control=").parse().ok(),
1241            "--safe-mode" => a.safe_mode = true,
1242            s if !s.starts_with('-') && a.open.is_none() => a.open = Some(PathBuf::from(s)),
1243            _ => {}
1244        }
1245    }
1246    if a.control.is_none() {
1247        a.control = std::env::var(format!("{env_prefix}_CONTROL_PORT")).ok().and_then(|p| p.trim().parse().ok());
1248    }
1249    a
1250}
1251
1252/// On the web: run in the `<canvas>` whose id is the app's name (or `ashlar`). Returns at once;
1253/// the app runs in the browser's event loop.
1254#[cfg(target_arch = "wasm32")]
1255pub(crate) fn run_native<D: Document>(app: App<D>) -> Result<(), Error> {
1256    use wasm_bindgen::JsCast;
1257    let (shared, view, setup) = app.split();
1258    let level = log::LevelFilter::Info;
1259    let console: crate::logging::Output = Box::new(|line| web_sys::console::log_1(&line.into()));
1260    crate::logging::install(Box::new(crate::logging::Logger::new(&shared.name, None, level, false).with_output(console)), level);
1261    // the browser console sees every panic: ashlar's hook runs first, then chains to this one
1262    static CONSOLE_HOOK: std::sync::Once = std::sync::Once::new();
1263    CONSOLE_HOOK.call_once(|| {
1264        let previous = std::panic::take_hook();
1265        std::panic::set_hook(Box::new(move |info| {
1266            web_sys::console::error_1(&format!("panic: {info}").into());
1267            previous(info);
1268        }));
1269    });
1270    crash::install(None, &shared.name, &shared.version);
1271    let document = web_sys::window().and_then(|w| w.document()).ok_or_else(|| Error::Window("no document".into()))?;
1272    let canvas = document
1273        .get_element_by_id(&shared.name)
1274        .or_else(|| document.get_element_by_id("ashlar"))
1275        .and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
1276        .ok_or_else(|| Error::Window(format!("no <canvas id=\"{}\"> in the page", shared.name)))?;
1277    wasm_bindgen_futures::spawn_local(async move {
1278        let creator: eframe::AppCreator<'static> =
1279            Box::new(move |_cc| Ok(Box::new(EframeApp { rt: Runtime::new(shared, view, setup, Launch::default()), offscreen: false })));
1280        if let Err(e) = eframe::WebRunner::new().start(canvas, eframe::WebOptions::default(), creator).await {
1281            log::error!("the app could not start: {e:?}");
1282        }
1283    });
1284    Ok(())
1285}
1286
1287#[cfg(not(target_arch = "wasm32"))]
1288pub(crate) fn run_native<D: Document>(app: App<D>) -> Result<(), Error> {
1289    let (shared, view, setup) = app.split();
1290    let args = parse_args(&shared.env_prefix);
1291    let logs = shared.dirs.as_ref().map(|d| d.logs.clone());
1292    if let Some(d) = &shared.dirs
1293        && let Err(e) = d.ensure()
1294    {
1295        eprintln!("{}: could not create its folders: {e}", shared.name);
1296    }
1297    let env_var = format!("{}_LOG", shared.env_prefix);
1298    let filter = crate::logging::filter_from_env(&[env_var.as_str(), "RUST_LOG"], "info");
1299    let level = filter.max_level();
1300    let telemetry = match (&shared.telemetry, &shared.dirs) {
1301        (Some(cfg), Some(d)) => Telemetry::start(cfg, &shared.name, &shared.version, &d.data, true),
1302        _ => Telemetry::default(),
1303    };
1304    let logger = crate::logging::Logger::new(&shared.name, logs.as_deref(), level, true).with_filter(filter);
1305    crate::logging::install(telemetry.wrap_logger(Box::new(logger)), level);
1306    // after the tracer's hook: ours runs first, then chains to it
1307    crash::install(logs.as_deref(), &shared.name, &shared.version);
1308    log::info!("{} {} starting", shared.name, shared.version);
1309
1310    let (guard, previous) = match &shared.dirs {
1311        Some(d) => {
1312            let (g, p) = LaunchGuard::begin(&d.data);
1313            (Some(g), p)
1314        }
1315        None => (None, PreviousLaunch::Clean),
1316    };
1317    let safe_mode = args.safe_mode || matches!(previous, PreviousLaunch::CrashedAtStartup { .. });
1318    if safe_mode {
1319        log::warn!("starting in safe mode ({previous:?})");
1320    }
1321    let launch = Launch { safe_mode, previous: Some(previous), open: args.open, control_port: args.control, guard, telemetry };
1322    let options = eframe::NativeOptions {
1323        viewport: egui::ViewportBuilder::default()
1324            .with_title(shared.title.clone())
1325            .with_inner_size(shared.window_size)
1326            .with_app_id(shared.name.clone()),
1327        ..Default::default()
1328    };
1329    let title = shared.title.clone();
1330    eframe::run_native(
1331        &title,
1332        options,
1333        Box::new(move |_cc| {
1334            let mut rt = Runtime::new(shared, view, setup, launch);
1335            rt.native = true;
1336            Ok(Box::new(EframeApp { rt, offscreen: false }))
1337        }),
1338    )
1339    .map_err(|e| Error::Window(e.to_string()))
1340}
1341
1342#[cfg(test)]
1343mod tests {
1344    use super::*;
1345
1346    #[test]
1347    fn ages_read_well() {
1348        assert_eq!(human_age(5), "5 s");
1349        assert_eq!(human_age(600), "10 min");
1350        assert_eq!(human_age(7200), "2 h");
1351        assert_eq!(human_age(300_000), "3 days");
1352    }
1353}