1use 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
39pub const MENU_ORDER: &[&str] = &["File", "Edit", "View"];
41
42#[derive(Default)]
44pub struct Launch {
45 pub safe_mode: bool,
47 pub previous: Option<PreviousLaunch>,
48 pub open: Option<PathBuf>,
50 pub control_port: Option<u16>,
51 pub(crate) guard: Option<LaunchGuard>,
52 pub(crate) telemetry: Telemetry,
53}
54
55#[derive(Clone, Copy, Debug, PartialEq, Eq)]
57pub enum Pick {
58 Open,
59 SaveAs,
60}
61
62pub type FilePicker = Box<dyn FnMut(Pick) -> Option<PathBuf>>;
64
65pub struct Cx<'a, D: Document> {
67 session: &'a mut Session<D>,
68 registry: &'a Registry<Session<D>>,
69 pub jobs: &'a mut Jobs<Session<D>>,
71 pub toasts: &'a mut Toasts,
72 pub settings: &'a mut Settings,
74 queued: &'a mut Vec<(String, Value)>,
75 ctx: egui::Context,
76}
77
78impl<D: Document> Cx<'_, D> {
79 pub fn doc(&self) -> &D {
81 self.session.doc()
82 }
83
84 pub fn snapshot(&self) -> Arc<D> {
86 self.session.snapshot()
87 }
88
89 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 pub fn dispatch(&mut self, id: &str, params: Value) {
108 self.queued.push((id.to_string(), params));
109 }
110
111 pub fn execute(&mut self, id: &str, params: Value) -> Result<Value, CommandError> {
113 self.registry.execute(self.session, id, params)
114 }
115
116 pub fn edit<R>(&mut self, label: &str, f: impl FnOnce(&mut D) -> R) -> R {
118 self.session.edit(label, f)
119 }
120
121 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 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
161pub 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 #[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 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 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 pub fn wakes(&self) -> WakeStats {
329 self.wakes.stats()
330 }
331
332 pub fn passes(&self) -> u64 {
334 self.passes
335 }
336
337 pub fn set_file_picker(&mut self, picker: impl FnMut(Pick) -> Option<PathBuf> + 'static) {
339 self.picker = Some(Box::new(picker));
340 }
341
342 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 pub fn inject_request(&mut self, req: Request) {
349 self.injected_requests.push(req);
350 }
351
352 pub fn control_port(&self) -> Option<u16> {
354 self.control.as_ref().map(|(s, _)| s.port())
355 }
356
357 pub fn is_closing(&self) -> bool {
359 self.allow_close
360 }
361
362 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 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 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 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 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 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 pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
482 self.wakes.install(ctx);
484 self.wakes.begin_frame(raw.time.unwrap_or_else(|| ctx.input(|i| i.time)));
485 }
486
487 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 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 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 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 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 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 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 #[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 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 #[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 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 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 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 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 pub fn title(&self) -> &str {
827 &self.shown_title
828 }
829
830 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 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 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 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 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
1166struct EframeApp<D: Document> {
1171 rt: Runtime<D>,
1172 offscreen: bool,
1174}
1175
1176impl<D: Document> EframeApp<D> {
1177 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 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#[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 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 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}