1use 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
45const SESSION_END: Reason = Reason("session_end");
47
48mod prefs;
49
50pub use ashlar_motion::MotionPrefs;
51pub use ashlar_native::system::Preferences;
52
53pub const MENU_ORDER: &[&str] = &["File", "Edit", "View"];
55
56#[derive(Default)]
58pub struct Launch {
59 pub safe_mode: bool,
61 pub previous: Option<PreviousLaunch>,
62 pub open: Option<PathBuf>,
64 pub control_port: Option<u16>,
65 pub(crate) guard: Option<LaunchGuard>,
66 pub(crate) telemetry: Telemetry,
67 pub(crate) inbox: Option<Inbox>,
69 #[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 pub(crate) process_start: Option<std::time::Instant>,
75 pub(crate) window_at: Option<std::time::Instant>,
76 pub(crate) ready_file: Option<PathBuf>,
78 pub(crate) presented: bool,
80 pub(crate) scene: Option<String>,
82}
83
84#[derive(Clone, Copy, Debug, PartialEq, Eq)]
86pub enum Pick {
87 Open,
88 SaveAs,
89}
90
91pub type FilePicker = Box<dyn FnMut(Pick) -> Option<PathBuf>>;
93
94pub struct Cx<'a, D: Document> {
96 session: &'a mut Session<D>,
97 registry: &'a Registry<Session<D>>,
98 pub jobs: &'a mut Jobs<Session<D>>,
100 pub toasts: &'a mut Toasts,
101 pub settings: &'a mut Settings,
103 queued: &'a mut Vec<(String, Value)>,
104 dropped: &'a [PathBuf],
105 ctx: egui::Context,
106 origin: Option<Origin>,
108}
109
110impl<D: Document> Cx<'_, D> {
111 pub fn tokens(&self) -> tokens::Tokens {
113 tokens::current(&self.ctx)
114 }
115
116 pub fn origin(&self) -> Option<Origin> {
119 self.origin
120 }
121
122 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 pub fn doc(&self) -> &D {
141 self.session.doc()
142 }
143
144 pub fn snapshot(&self) -> Arc<D> {
146 self.session.snapshot()
147 }
148
149 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 pub fn dropped_files(&self) -> &[PathBuf] {
169 self.dropped
170 }
171
172 pub fn dispatch(&mut self, id: &str, params: Value) {
174 self.queued.push((id.to_string(), params));
175 }
176
177 pub fn execute(&mut self, id: &str, params: Value) -> Result<Value, CommandError> {
179 self.registry.execute(self.session, id, params)
180 }
181
182 pub fn edit<R>(&mut self, label: &str, f: impl FnOnce(&mut D) -> R) -> R {
184 self.session.edit(label, f)
185 }
186
187 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 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
227pub 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 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 session_end: Option<Receiver<EndRequest>>,
261 end_state: session_end::State,
262 end_reply: Option<bool>,
264 after_save: Option<(String, Value)>,
267 pending_pick: Option<Pick>,
269 window: window_state::Tracker,
271 shown_title: String,
272 title_key: Option<(bool, u64)>,
274 persisted_view: Option<u64>,
276 picker: Option<FilePicker>,
277 passes: u64,
278 probe: Option<crate::perf_warn::Probe>,
280 recorder: Option<crate::record::Recorder>,
282 probe_installed: bool,
283 process: crate::stats::ProcessProbe,
285 probe_wakes: ashlar_ui::wake::WakeStats,
287 caret: Caret,
288 clock: crate::clock::Clock,
290 #[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 #[cfg_attr(not(all(feature = "native-menu", any(target_os = "macos", target_os = "windows"))), allow(dead_code))]
297 native_menu_tried: bool,
298 #[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 #[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 #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
315 bring_front: bool,
316 native_picks: Vec<String>,
319 os_prefs: prefs::OsPrefs,
320 inbox: Inbox,
322 drops: Vec<PathBuf>,
324 dropped: Vec<PathBuf>,
326 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 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 if rt.launch.safe_mode {
440 rt.modals.push(Modal::SafeMode);
441 }
442 if rt.launch.open.is_none() {
444 rt.apply_launch_scene();
445 }
446 rt
447 }
448
449 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 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 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 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 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 #[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 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 pub fn wakes(&self) -> WakeStats {
544 self.wakes.stats()
545 }
546
547 pub fn passes(&self) -> u64 {
549 self.passes
550 }
551
552 pub fn set_preferences(&mut self, source: impl FnMut() -> Preferences + 'static) {
555 self.os_prefs.source = Some(Box::new(source));
556 }
557
558 pub fn set_file_picker(&mut self, picker: impl FnMut(Pick) -> Option<PathBuf> + 'static) {
560 self.picker = Some(Box::new(picker));
561 }
562
563 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 pub fn inject_request(&mut self, req: Request) {
570 self.injected_requests.push(req);
571 }
572
573 pub fn control_port(&self) -> Option<u16> {
575 self.control.as_ref().map(|(s, _)| s.port())
576 }
577
578 pub fn is_closing(&self) -> bool {
580 self.allow_close
581 }
582
583 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 fn ensure_started(&mut self, ctx: &egui::Context) {
595 if self.started {
596 return;
597 }
598 self.started = true;
599 devtools::install(ctx);
600 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 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 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 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 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 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 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 pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
738 if let Some(r) = &mut self.recorder
740 && !r.frame(raw)
741 {
742 self.recorder = None;
743 }
744 self.clock.begin_frame(raw);
745 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 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 #[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 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 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 #[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 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 pub(crate) fn forwarded(&self, f: ashlar_native::single_instance::Forwarded) {
849 self.inbox.forward(f);
850 }
851
852 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 self.wakes.rearm(&ctx);
861 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 self.refresh_infos();
873 }
874 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 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 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 #[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 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 #[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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn shutdown(&mut self, _discarded: bool) {
1351 self.shutdown_inner(false);
1352 }
1353
1354 #[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 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 #[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 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 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 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 let t = self.clock.host_time().max(ctx.input(|i| i.time)).max(0.0);
1450 let now = Duration::from_secs_f64(t);
1451 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 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 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 pub fn title(&self) -> &str {
1507 &self.shown_title
1508 }
1509
1510 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 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 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 }
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 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 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 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 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
1909struct EframeApp<D: Document> {
1914 rt: Runtime<D>,
1915 offscreen: bool,
1917}
1918
1919impl<D: Document> EframeApp<D> {
1920 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 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 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 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#[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 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#[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#[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 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#[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 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 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 crash::install(logs.as_deref(), &shared.name, &shared.version);
2161 log::info!("{} {} starting", shared.name, shared.version);
2162 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 #[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 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 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 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 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 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}