use std::panic::{AssertUnwindSafe, catch_unwind};
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::mpsc::Receiver;
use std::time::Duration;
use ashlar_command::shortcut::{Platform, format_list};
use ashlar_command::{CommandError, CommandInfo, Keymap, Registry};
use ashlar_control::{AppInfo, Reply, Request, Server};
use ashlar_fs::{ConfigStore, DirConfig, MemoryConfig, Settings};
use ashlar_jobs::Jobs;
use ashlar_store::{Autosave, AutosaveConfig, RecoveryCandidate, Revision};
use ashlar_ui::automation;
use ashlar_ui::commands::keys::works_while_typing;
use ashlar_ui::commands::{CommandPalette, KeymapChange, KeymapEditor, MenuSpec, dispatch, menu_bar};
use ashlar_ui::devtools;
use ashlar_ui::dialog::{Dialog, DialogButton, DialogOutcome};
use ashlar_ui::toasts::Toasts;
use ashlar_ui::tokens::{self, ThemeMode};
use ashlar_ui::wake::{self, Reason, Scheduler, WakeStats};
use serde_json::{Value, json};
use crate::app::{App, Error, SetupFn, Shared, ViewFn};
use crate::caret::Caret;
use crate::control::UiQueue;
use crate::crash::{self, LaunchGuard, PreviousLaunch};
use crate::open::Inbox;
use crate::session::{Document, Effect, Origin, Session, now};
use crate::stats::FrameStats;
use crate::telemetry::Telemetry;
use crate::window_state::{self, WindowState};
use ashlar_native::session_end::{self, EndKind, EndRequest};
const SESSION_END: Reason = Reason("session_end");
mod prefs;
pub use ashlar_motion::MotionPrefs;
pub use ashlar_native::system::Preferences;
pub const MENU_ORDER: &[&str] = &["File", "Edit", "View"];
#[derive(Default)]
pub struct Launch {
pub safe_mode: bool,
pub previous: Option<PreviousLaunch>,
pub open: Option<PathBuf>,
pub control_port: Option<u16>,
pub(crate) guard: Option<LaunchGuard>,
pub(crate) telemetry: Telemetry,
pub(crate) inbox: Option<Inbox>,
#[cfg(not(target_arch = "wasm32"))]
#[allow(dead_code, reason = "held for its drop, which stops listening")]
pub(crate) instance: Option<ashlar_native::single_instance::PrimaryGuard>,
pub(crate) process_start: Option<std::time::Instant>,
pub(crate) window_at: Option<std::time::Instant>,
pub(crate) ready_file: Option<PathBuf>,
pub(crate) presented: bool,
pub(crate) scene: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Pick {
Open,
SaveAs,
}
pub type FilePicker = Box<dyn FnMut(Pick) -> Option<PathBuf>>;
pub struct Cx<'a, D: Document> {
session: &'a mut Session<D>,
registry: &'a Registry<Session<D>>,
pub jobs: &'a mut Jobs<Session<D>>,
pub toasts: &'a mut Toasts,
pub settings: &'a mut Settings,
queued: &'a mut Vec<(String, Value)>,
dropped: &'a [PathBuf],
ctx: egui::Context,
origin: Option<Origin>,
}
impl<D: Document> Cx<'_, D> {
pub fn tokens(&self) -> tokens::Tokens {
tokens::current(&self.ctx)
}
pub fn origin(&self) -> Option<Origin> {
self.origin
}
pub fn motion<T>(&self, id: impl egui::AsId, target: T, curve: impl Into<ashlar_motion::Curve>) -> T
where
T: ashlar_motion::Animatable + Send + Sync + 'static,
{
let id = egui::Id::new(id);
if self.origin.is_some_and(|o| !o.animates()) {
ashlar_ui::motion::set(&self.ctx, id, target);
return target;
}
ashlar_ui::motion::value(&self.ctx, id, target, ashlar_motion::Class::Other, curve)
}
pub fn doc(&self) -> &D {
self.session.doc()
}
pub fn snapshot(&self) -> Arc<D> {
self.session.snapshot()
}
pub fn revision(&self) -> Revision {
self.session.revision()
}
pub fn session(&self) -> &Session<D> {
self.session
}
pub fn session_mut(&mut self) -> &mut Session<D> {
self.session
}
pub fn ctx(&self) -> &egui::Context {
&self.ctx
}
pub fn dropped_files(&self) -> &[PathBuf] {
self.dropped
}
pub fn dispatch(&mut self, id: &str, params: Value) {
self.queued.push((id.to_string(), params));
}
pub fn execute(&mut self, id: &str, params: Value) -> Result<Value, CommandError> {
self.registry.execute(self.session, id, params)
}
pub fn edit<R>(&mut self, label: &str, f: impl FnOnce(&mut D) -> R) -> R {
self.session.edit(label, f)
}
pub fn edit_merged<R>(&mut self, label: &str, merge_key: &str, f: impl FnOnce(&mut D) -> R) -> R {
self.session.edit_merged(label, merge_key, f)
}
pub fn view<T: serde::de::DeserializeOwned>(&self, key: &str) -> Option<T> {
self.session.view(key)
}
pub fn view_or<T: serde::de::DeserializeOwned>(&self, key: &str, default: T) -> T {
self.session.view_or(key, default)
}
pub fn set_view<T: serde::Serialize + ?Sized>(&mut self, key: &str, value: &T) {
self.session.set_view(key, value);
}
}
#[derive(Default)]
struct InfoCache {
key: Option<(Revision, u64, u64, u64)>,
seq: u64,
infos: Vec<CommandInfo>,
menu: MenuSpec,
}
enum Modal {
Recovery,
SafeMode,
Confirm { quit: bool, command: Option<(String, Value)> },
}
struct AutosaveState {
autosave: Autosave,
#[cfg(not(target_arch = "wasm32"))]
writer: Option<ashlar_store::BackgroundWriter>,
file_gen: u64,
}
pub struct Runtime<D: Document> {
shared: Arc<Shared<D>>,
session: Session<D>,
jobs: Jobs<Session<D>>,
toasts: Toasts,
settings: Settings,
view: Option<ViewFn<D>>,
setup: Option<SetupFn>,
keymap: Keymap,
keymap_gen: u64,
palette: CommandPalette,
keymap_editor: KeymapEditor,
keymap_open: bool,
cache: InfoCache,
control: Option<(Server, Receiver<Request>)>,
injected_requests: Vec<Request>,
view_rev: Option<Revision>,
autosave: Option<AutosaveState>,
recovery: Vec<RecoveryCandidate>,
modals: Vec<Modal>,
stats: FrameStats,
wakes: Scheduler,
launch: Launch,
queued: Vec<(String, Value)>,
created: Option<std::time::Instant>,
started: bool,
first_frame_done: bool,
view_error: Option<String>,
allow_close: bool,
quit_after_save: bool,
session_end: Option<Receiver<EndRequest>>,
end_state: session_end::State,
end_reply: Option<bool>,
after_save: Option<(String, Value)>,
pending_pick: Option<Pick>,
window: window_state::Tracker,
shown_title: String,
title_key: Option<(bool, u64)>,
persisted_view: Option<u64>,
picker: Option<FilePicker>,
passes: u64,
probe: Option<crate::perf_warn::Probe>,
recorder: Option<crate::record::Recorder>,
probe_installed: bool,
process: crate::stats::ProcessProbe,
probe_wakes: ashlar_ui::wake::WakeStats,
caret: Caret,
clock: crate::clock::Clock,
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub(crate) native: bool,
#[cfg(all(feature = "native-menu", any(target_os = "macos", target_os = "windows")))]
native_menu: Option<crate::native_menu::NativeMenu>,
#[cfg_attr(not(all(feature = "native-menu", any(target_os = "macos", target_os = "windows"))), allow(dead_code))]
native_menu_tried: bool,
#[cfg(all(feature = "tray", not(target_arch = "wasm32")))]
tray: Option<ashlar_native::tray::Tray>,
#[cfg(all(feature = "tray", not(target_arch = "wasm32")))]
tray_tried: bool,
#[cfg(all(feature = "hotkey", not(target_arch = "wasm32")))]
global_shortcuts: Option<ashlar_native::hotkey::GlobalShortcuts>,
#[cfg(all(feature = "hotkey", not(target_arch = "wasm32")))]
global_shortcuts_tried: bool,
#[cfg(target_os = "macos")]
reopen: Option<std::sync::mpsc::Receiver<ashlar_native::mac_shell::Reopen>>,
#[cfg(target_os = "macos")]
mac_shell_tried: bool,
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
bring_front: bool,
native_picks: Vec<String>,
os_prefs: prefs::OsPrefs,
inbox: Inbox,
drops: Vec<PathBuf>,
dropped: Vec<PathBuf>,
open_next: Option<PathBuf>,
}
fn panic_text(payload: &(dyn std::any::Any + Send)) -> String {
payload
.downcast_ref::<&str>()
.map(|s| (*s).to_string())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "unknown error".into())
}
impl<D: Document> Runtime<D> {
pub(crate) fn new(shared: Arc<Shared<D>>, view: Option<ViewFn<D>>, setup: Option<SetupFn>, mut launch: Launch) -> Self {
ashlar_i18n::set_native_languages(ashlar_native::system::preferred_languages);
let inbox = launch.inbox.take().unwrap_or_default();
let mut session = Session::new(Arc::clone(&shared.format));
session.set_windowed(true);
session.telemetry = launch.telemetry.clone();
let config: Arc<dyn ConfigStore> = match &shared.dirs {
Some(d) => Arc::new(DirConfig::new(d.config.clone())),
None => Arc::new(MemoryConfig::new()),
};
let settings = Settings::load(config, "settings.json", &[]);
let mut keymap = Keymap::from_registry(&shared.registry, Platform::current());
if let Some(o) = settings.get_value("keymap") {
keymap.apply_overrides_json(o);
}
if !launch.safe_mode
&& let Some(Value::Object(view)) = settings.get_value("view")
{
for (k, v) in view {
session.set_view_value(k, v.clone());
}
}
let mut rt = Runtime {
session,
jobs: Jobs::new(),
toasts: Toasts::new(),
settings,
view,
setup,
keymap,
keymap_gen: 0,
palette: CommandPalette::new(),
keymap_editor: KeymapEditor::new(),
keymap_open: false,
cache: InfoCache::default(),
control: None,
injected_requests: Vec::new(),
view_rev: None,
autosave: None,
recovery: Vec::new(),
modals: Vec::new(),
stats: FrameStats::default(),
wakes: Scheduler::default(),
queued: Vec::new(),
created: now(),
started: false,
first_frame_done: false,
view_error: None,
allow_close: false,
quit_after_save: false,
session_end: None,
end_state: session_end::State::Idle,
end_reply: None,
after_save: None,
pending_pick: None,
window: window_state::Tracker::default(),
shown_title: String::new(),
title_key: None,
persisted_view: None,
picker: None,
passes: 0,
probe: crate::perf_warn::Probe::from_env(),
recorder: crate::record::Recorder::from_env(),
probe_installed: false,
process: crate::stats::ProcessProbe::default(),
probe_wakes: ashlar_ui::wake::WakeStats::default(),
caret: Caret::default(),
clock: crate::clock::Clock::default(),
native: false,
#[cfg(all(feature = "native-menu", any(target_os = "macos", target_os = "windows")))]
native_menu: None,
native_menu_tried: false,
#[cfg(all(feature = "tray", not(target_arch = "wasm32")))]
tray: None,
#[cfg(all(feature = "tray", not(target_arch = "wasm32")))]
tray_tried: false,
#[cfg(all(feature = "hotkey", not(target_arch = "wasm32")))]
global_shortcuts: None,
#[cfg(all(feature = "hotkey", not(target_arch = "wasm32")))]
global_shortcuts_tried: false,
bring_front: false,
#[cfg(target_os = "macos")]
reopen: None,
#[cfg(target_os = "macos")]
mac_shell_tried: false,
native_picks: Vec::new(),
os_prefs: prefs::OsPrefs::new(shared.follow_system_accent, shared.motion),
inbox,
drops: Vec::new(),
dropped: Vec::new(),
open_next: None,
launch,
shared,
};
if let (Some(s), Some(w)) = (rt.launch.process_start, rt.launch.window_at) {
rt.stats.set_to_window(w.saturating_duration_since(s));
}
rt.start_autosave();
if rt.launch.safe_mode {
rt.modals.push(Modal::SafeMode);
}
if rt.launch.open.is_none() {
rt.apply_launch_scene();
}
rt
}
fn apply_launch_scene(&mut self) {
if let Some(name) = self.launch.scene.take()
&& let Err(e) = self.apply_scene(&name)
{
log::error!("{e}");
self.toasts.error(e);
}
}
pub fn apply_scene(&mut self, name: &str) -> Result<(), String> {
let shared = Arc::clone(&self.shared);
let Some(scene) = shared.scene(name) else {
return Err(format!("no scene `{name}` (scenes: {})", shared.scene_names().join(", ")));
};
let session = &mut self.session;
catch_unwind(AssertUnwindSafe(|| scene(session))).map_err(|p| format!("scene `{name}` panicked: {}", panic_text(p.as_ref())))
}
fn start_autosave(&mut self) {
let (Some(every), Some(dirs)) = (self.shared.autosave, &self.shared.dirs) else { return };
let mut cfg = AutosaveConfig::new(dirs.data.join("Recovery"));
cfg.interval = every;
match Autosave::start(cfg) {
Ok((autosave, candidates)) => {
self.recovery = candidates;
if !self.recovery.is_empty() {
self.modals.insert(0, Modal::Recovery);
}
self.autosave = Some(AutosaveState {
autosave,
#[cfg(not(target_arch = "wasm32"))]
writer: ashlar_store::BackgroundWriter::start().ok(),
file_gen: u64::MAX,
});
}
Err(e) => log::warn!("autosave is off: {e}"),
}
}
pub fn session(&self) -> &Session<D> {
&self.session
}
pub fn session_mut(&mut self) -> &mut Session<D> {
&mut self.session
}
pub fn settings(&self) -> &Settings {
&self.settings
}
pub fn toasts(&self) -> &Toasts {
&self.toasts
}
pub fn log_frames(&mut self, path: impl AsRef<std::path::Path>) -> std::io::Result<()> {
let log = crate::frame_log::FrameLog::create(path.as_ref(), crate::frame_log::MAX_BYTES)?;
self.probe.get_or_insert_with(crate::perf_warn::Probe::default).set_log(log);
Ok(())
}
pub fn record_to(&mut self, path: impl AsRef<std::path::Path>) -> std::io::Result<()> {
self.recorder = Some(crate::record::Recorder::create(path.as_ref(), crate::record::MAX_BYTES)?);
Ok(())
}
#[cfg(test)]
pub(crate) fn watch_frames(&mut self, warn: crate::perf_warn::PerfWarn) -> crate::perf_warn::Probe {
let p = crate::perf_warn::Probe::with_warn(warn);
self.probe = Some(p.clone());
p
}
pub fn stats(&self) -> &FrameStats {
&self.stats
}
pub fn stats_mut(&mut self) -> &mut FrameStats {
&mut self.stats
}
pub fn wakes(&self) -> WakeStats {
self.wakes.stats()
}
pub fn passes(&self) -> u64 {
self.passes
}
pub fn set_preferences(&mut self, source: impl FnMut() -> Preferences + 'static) {
self.os_prefs.source = Some(Box::new(source));
}
pub fn set_file_picker(&mut self, picker: impl FnMut(Pick) -> Option<PathBuf> + 'static) {
self.picker = Some(Box::new(picker));
}
pub fn execute(&mut self, id: &str, params: Value) -> Result<Value, CommandError> {
self.shared.registry.execute(&mut self.session, id, params)
}
pub fn inject_request(&mut self, req: Request) {
self.injected_requests.push(req);
}
pub fn control_port(&self) -> Option<u16> {
self.control.as_ref().map(|(s, _)| s.port())
}
pub fn is_closing(&self) -> bool {
self.allow_close
}
pub fn view_error(&self) -> Option<&str> {
self.view_error.as_deref()
}
fn info(&self) -> AppInfo {
AppInfo::new(self.shared.name.clone(), self.shared.version.clone())
}
fn ensure_started(&mut self, ctx: &egui::Context) {
if self.started {
return;
}
self.started = true;
devtools::install(ctx);
if !cfg!(feature = "accesskit") {
devtools::set_platform_accesskit(ctx, false);
}
self.inbox.set_ctx(ctx);
let mode = if self.launch.safe_mode { ThemeMode::System } else { self.settings.get::<ThemeMode>("theme").unwrap_or_default() };
let (light, dark) = self.os_prefs.startup(ctx);
tokens::install(ctx, &light, &dark, mode);
if let Some(setup) = self.setup.take()
&& let Err(p) = catch_unwind(AssertUnwindSafe(|| setup(ctx)))
{
log::error!("app setup panicked: {}", panic_text(p.as_ref()));
}
let w = wake::waker(ctx, Reason::JOB);
self.jobs.set_wake(move || w.wake());
let w = wake::waker(ctx, Reason::SETTINGS);
self.settings.set_wake(move |d| w.reschedule(d));
if let Some(a) = &mut self.autosave {
let w = wake::waker(ctx, Reason::AUTOSAVE);
a.autosave.set_wake(move |d| w.reschedule(d));
#[cfg(not(target_arch = "wasm32"))]
if let Some(writer) = &a.writer {
let w = wake::waker(ctx, Reason::AUTOSAVE);
writer.set_wake(move || w.wake());
}
}
let port = self.launch.control_port.or(self.shared.control_port);
let key_dir = ashlar_control::key::key_dir_from_env().or_else(|| self.shared.dirs.as_ref().map(|d| d.config.clone()));
if let (Some(port), Some(key_dir)) = (port, key_dir) {
let w = wake::waker(ctx, Reason::CONTROL);
match Server::start(ashlar_control::Config::new(port, key_dir), move || w.wake()) {
Ok((server, rx)) => {
self.control = Some((server, rx));
}
Err(e) => {
log::error!("control port {port}: {e}");
self.toasts.error(format!("Agent control is off: {e}"));
}
}
}
if self.native {
match session_end::listen(Arc::new({
let w = wake::waker(ctx, SESSION_END);
move || w.wake()
})) {
Ok(rx) => self.session_end = Some(rx),
Err(e) => log::debug!("quit requests from the OS: {e}"),
}
}
}
pub fn logic(&mut self, ctx: &egui::Context) {
self.launch.telemetry.begin_turn();
self.ensure_started(ctx);
self.os_prefs.poll(ctx);
self.serve_control(ctx);
self.show_pending_pick(ctx, None);
let outer = self.session.swap_source(Some(Origin::Job));
let done = self.jobs.drain_ready(&mut self.session);
self.session.swap_source(outer);
for rec in done {
if rec.status == ashlar_jobs::Status::Failed {
self.toasts.error(format!("{}: {}", rec.label, rec.message));
}
}
self.apply_effects(ctx);
self.open_from_outside(ctx);
#[cfg(all(feature = "native-menu", any(target_os = "macos", target_os = "windows")))]
if let Some(m) = &self.native_menu {
self.native_picks.extend(m.drain());
}
#[cfg(all(feature = "tray", not(target_arch = "wasm32")))]
self.drain_tray(ctx);
#[cfg(all(feature = "hotkey", not(target_arch = "wasm32")))]
if let Some(g) = &self.global_shortcuts {
let pressed = g.drain();
if !pressed.is_empty() {
self.show_window(ctx);
}
self.native_picks.extend(pressed);
}
self.run_native_picks(ctx);
self.launch.telemetry.end_turn();
}
pub fn hidden_pass(&mut self, ctx: &egui::Context, time: Option<f64>, events: Vec<egui::Event>) -> Option<egui::FullOutput> {
if ctx.input(|i| i.viewport().visible()) != Some(false) || !devtools::has_pending(ctx) {
return None;
}
let mut raw = ctx.input(|i| i.raw.clone());
raw.events = events;
raw.hovered_files.clear();
raw.dropped_files.clear();
raw.viewport_id = egui::ViewportId::ROOT;
let last = self.clock.host_time();
raw.time = Some(time.unwrap_or_else(|| self.created.map_or(last, |c| c.elapsed().as_secs_f64())).max(last));
if raw.screen_rect.is_none()
&& let Some(r) = raw.viewports.get(&egui::ViewportId::ROOT).and_then(|v| v.inner_rect)
{
raw.screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, r.size()));
}
self.raw_input_hook(ctx, &mut raw);
let out = ctx.run_ui(raw, |ui| self.ui(ui));
for (id, vo) in &out.viewport_output {
for c in &vo.commands {
ctx.send_viewport_cmd_to(*id, c.clone());
}
if *id == egui::ViewportId::ROOT && vo.repaint_delay < Duration::from_secs(3600) {
wake::wake_after(ctx, Reason::CONTROL, vo.repaint_delay);
}
}
if !out.platform_output.commands.is_empty() {
let commands = out.platform_output.commands.clone();
ctx.output_mut(|o| o.commands.extend(commands));
}
Some(out)
}
pub fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
if let Some(r) = &mut self.recorder
&& !r.frame(raw)
{
self.recorder = None;
}
self.clock.begin_frame(raw);
self.wakes.install(ctx);
self.wakes.begin_frame(raw.time.unwrap_or_else(|| ctx.input(|i| i.time)));
if let Some(p) = &self.probe {
if !self.probe_installed {
ctx.add_plugin(p.plugin());
self.probe_installed = true;
}
let now = self.wakes.stats();
p.begin_frame(crate::perf_warn::credited(&self.probe_wakes, &now));
self.probe_wakes = now;
}
#[cfg(not(target_arch = "wasm32"))]
if self.shared.accept_drops {
self.drops.extend(raw.dropped_files.iter().map(|f| f.path().to_path_buf()));
}
}
fn open_from_outside(&mut self, ctx: &egui::Context) {
let (items, focus) = self.inbox.drain();
if focus {
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
self.bring_front = true;
}
let mut files = items.into_iter().filter_map(|item| match item {
ashlar_native::open_files::Opened::File(p) => Some(p),
ashlar_native::open_files::Opened::Url(u) => {
log::info!("asked to open {u}: URLs are not handled");
None
}
});
if let Some(p) = files.next() {
self.open_next = Some(p);
}
if self.first_frame_done
&& let Some(path) = self.launch.open.take()
{
self.run_ui_command(ctx, "file.open", json!({"path": path.to_string_lossy(), "discard": true}));
self.apply_launch_scene();
}
let skipped = files.count();
if skipped > 0 {
log::info!("{skipped} more file(s) to open ignored: one document at a time");
}
if !self.drops.is_empty() {
let (open, rest) = crate::open::split_drops(std::mem::take(&mut self.drops), &self.shared.extensions);
if open.is_some() {
self.open_next = open;
}
self.dropped = rest;
}
if self.modal_open() {
return;
}
if let Some(p) = self.open_next.take() {
self.run_ui_command(ctx, "file.open", json!({"path": p.to_string_lossy()}));
}
}
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub(crate) fn note_presented(&mut self) {
if !self.first_frame_done || std::mem::replace(&mut self.launch.presented, true) {
return;
}
let Some(start) = self.launch.process_start else { return };
let took = start.elapsed();
self.stats.set_presented(took);
let t = self.stats.launch();
let f = |v: Option<f64>| v.map_or_else(|| "-".to_owned(), |v| format!("{v:.1}"));
log::info!(
"launch: window {} ms, first frame {} ms (runtime {} ms), presented {} ms",
f(t.to_window_ms),
f(t.to_first_frame_ms),
f(self.stats.summary().first_frame_ms),
f(t.launch_to_present_ms)
);
if let Some(path) = self.launch.ready_file.take() {
let body = json!({"pid": std::process::id(), "ready_ms": took.as_secs_f64() * 1000.0}).to_string();
let spawned = std::thread::Builder::new().name("ashlar-ready".into()).spawn(move || {
let r = catch_unwind(AssertUnwindSafe(|| ashlar_fs::atomic_write(&path, body.as_bytes())));
match r {
Ok(Ok(())) => {}
Ok(Err(e)) => log::warn!("ready file {}: {e}", path.display()),
Err(_) => log::warn!("ready file {}: the writer panicked", path.display()),
}
});
if let Err(e) = spawned {
log::warn!("ready file: {e}");
}
}
}
pub(crate) fn forwarded(&self, f: ashlar_native::single_instance::Forwarded) {
self.inbox.forward(f);
}
pub fn ui(&mut self, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone();
let started = now();
self.launch.telemetry.begin_turn();
self.ensure_started(&ctx);
self.passes = self.passes.saturating_add(1);
self.wakes.rearm(&ctx);
self.caret.begin(&ctx);
let (focused, active) = ctx.input(|i| (i.focused, !i.events.is_empty()));
self.launch.telemetry.input(focused, active);
self.refresh_infos();
if !self.modal_open()
&& let Some(id) = dispatch(&ctx, &self.keymap)
{
self.run_ui_command(&ctx, &id, json!({}));
self.refresh_infos();
}
let rev_before = self.session.revision();
self.update_title(&ctx);
if !self.native_menu_active() {
let spec = &self.cache.menu;
let chosen = egui::Panel::top("ashlar.menu_bar").show(ui, |ui| menu_bar(ui, spec, Platform::current())).inner;
if let Some(id) = chosen {
self.run_ui_command(&ctx, &id, json!({}));
}
}
self.draw_view(ui);
self.dropped.clear();
for (id, params) in std::mem::take(&mut self.queued) {
self.run_ui_command(&ctx, &id, params);
}
self.apply_effects(&ctx);
self.draw_modals(&ctx);
self.draw_tools(&ctx);
self.toasts.show(&ctx);
self.run_toast_actions(&ctx);
self.handle_close(&ctx);
self.poll_session_end(&ctx);
self.apply_effects(&ctx);
self.persist_view();
self.track_window(&ctx);
self.tick_storage(&ctx);
self.feed_devtools(&ctx);
if self.session.revision() != rev_before {
wake::wake(&ctx, Reason::DOCUMENT);
}
self.caret.end(&ctx);
let took = started.map(|s| s.elapsed());
if let Some(took) = took {
self.stats.record(took);
self.launch.telemetry.frame(took);
}
self.process.tick();
let ran = self.session.take_ran();
if let Some(p) = &self.probe {
p.end_ui(took, ran);
}
if !self.first_frame_done {
self.first_frame_done = true;
if let Some(g) = &mut self.launch.guard {
g.started();
}
if let Some(c) = self.created {
self.stats.set_first_frame(c.elapsed());
}
if let Some(s) = self.launch.process_start {
self.stats.set_to_first_frame(s.elapsed());
}
if self.native || self.launch.open.is_some() {
wake::wake(&ctx, Reason::STARTUP);
}
self.launch.telemetry.first_frame();
}
self.launch.telemetry.end_turn();
}
fn draw_view(&mut self, ui: &mut egui::Ui) {
let Runtime { session, shared, jobs, toasts, settings, queued, view, view_error, dropped, view_rev, .. } = self;
let rev = session.revision();
let origin = if *view_rev == Some(rev) { None } else { session.origin() };
*view_rev = Some(rev);
egui::CentralPanel::default().show(ui, |ui| {
if let Some(err) = view_error.as_deref() {
ui.centered_and_justified(|ui| ui.label(format!("This view stopped after an internal error:\n{err}")));
return;
}
let Some(view) = view.as_mut() else { return };
let mut cx = Cx { session, registry: &shared.registry, jobs, toasts, settings, queued, dropped, ctx: ui.ctx().clone(), origin };
let r = catch_unwind(AssertUnwindSafe(|| view(ui, &mut cx)));
if let Err(p) = r {
let msg = crash::last_panic().map_or_else(|| panic_text(p.as_ref()), |r| format!("{} ({})", r.message, r.location));
log::error!("the view panicked: {msg}");
*view_error = Some(msg);
}
});
}
fn native_menu_active(&self) -> bool {
#[cfg(all(feature = "native-menu", any(target_os = "macos", target_os = "windows")))]
{
self.native_menu.is_some()
}
#[cfg(not(all(feature = "native-menu", any(target_os = "macos", target_os = "windows"))))]
{
false
}
}
#[cfg(not(target_arch = "wasm32"))]
fn install_native_menu(&mut self, ctx: &egui::Context, window: &eframe::Frame) {
#[cfg(all(feature = "native-menu", any(target_os = "macos", target_os = "windows")))]
if self.native && self.first_frame_done && !self.native_menu_tried {
self.native_menu_tried = true;
self.native_menu = crate::native_menu::NativeMenu::install(&self.cache.infos, ctx, window);
wake::wake(ctx, Reason::MENU);
}
#[cfg(all(feature = "tray", not(target_arch = "wasm32")))]
if self.native && self.first_frame_done && !self.tray_tried {
self.tray_tried = true;
if let Some(spec) = self.shared.tray.clone() {
let wake = ashlar_ui::wake::waker(ctx, Reason::MENU);
match ashlar_native::tray::Tray::new(spec, std::sync::Arc::new(move || wake.wake())) {
Ok(t) => self.tray = Some(t),
Err(e) => log::warn!("no tray icon: {e}"),
}
}
}
#[cfg(all(feature = "hotkey", not(target_arch = "wasm32")))]
if self.native && self.first_frame_done && !self.global_shortcuts_tried {
self.global_shortcuts_tried = true;
if !self.shared.global_shortcuts.is_empty() {
let wake = ashlar_ui::wake::waker(ctx, Reason::MENU);
match ashlar_native::hotkey::GlobalShortcuts::new(std::sync::Arc::new(move || wake.wake())) {
Ok(mut g) => {
for (command, chord) in &self.shared.global_shortcuts {
let done = ashlar_command::shortcut::Chord::parse(chord)
.map_err(|e| ashlar_native::NativeError::Invalid(e.0))
.and_then(|c| g.register(command.clone(), &c));
if let Err(e) = done {
log::warn!("global shortcut `{chord}` for `{command}` skipped: {e}");
}
}
self.global_shortcuts = Some(g);
}
Err(e) => log::warn!("no global shortcuts: {e}"),
}
}
}
#[cfg(target_os = "macos")]
if self.native && self.first_frame_done && !self.mac_shell_tried {
self.mac_shell_tried = true;
if let Err(e) = ashlar_native::mac_shell::guard_appearance() {
log::debug!("appearance guard: {e}");
}
let wake = ashlar_ui::wake::waker(ctx, Reason::MENU);
match ashlar_native::mac_shell::listen_reopen(std::sync::Arc::new(move || wake.wake())) {
Ok(rx) => self.reopen = Some(rx),
Err(e) => log::debug!("Dock reopen: {e}"),
}
}
#[cfg(target_os = "macos")]
if self.reopen.as_ref().is_some_and(|rx| rx.try_iter().count() > 0) {
self.show_window(ctx);
}
if std::mem::take(&mut self.bring_front)
&& let Err(e) = ashlar_native::system::bring_to_front(window)
&& !e.is_unsupported()
{
log::debug!("bring to front: {e}");
}
let _ = (ctx, window);
}
#[cfg_attr(any(target_arch = "wasm32", not(any(target_os = "macos", feature = "tray", feature = "hotkey"))), allow(dead_code))]
fn show_window(&mut self, ctx: &egui::Context) {
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
self.bring_front = true;
wake::wake(ctx, Reason::MENU);
}
#[cfg(all(feature = "tray", not(target_arch = "wasm32")))]
fn drain_tray(&mut self, ctx: &egui::Context) {
let Some(t) = &self.tray else { return };
for e in t.events() {
match e {
ashlar_native::tray::TrayEvent::Menu(id) => self.native_picks.push(id),
ashlar_native::tray::TrayEvent::Activate => self.show_window(ctx),
}
}
}
fn run_native_picks(&mut self, ctx: &egui::Context) {
if self.native_picks.is_empty() {
return;
}
let picks = std::mem::take(&mut self.native_picks);
let typing = ctx.egui_wants_keyboard_input();
for id in picks {
if self.modal_open() {
log::debug!("menu pick `{id}` ignored: a dialog is open");
continue;
}
if typing && self.keymap.keys_for(&id).first().is_some_and(|c| !works_while_typing(c)) {
log::debug!("menu pick `{id}` ignored: a text field has the keyboard");
continue;
}
self.run_ui_command(ctx, &id, json!({}));
}
}
pub(crate) fn push_native_pick(&mut self, id: impl Into<String>) {
self.native_picks.push(id.into());
}
pub(crate) fn modal_open(&self) -> bool {
!self.modals.is_empty() || self.palette.is_open() || self.view_error.is_some()
}
fn refresh_infos(&mut self) {
let key = (self.session.revision(), self.session.view_generation(), self.session.file_generation(), self.keymap_gen);
if self.cache.key == Some(key) {
return;
}
if let Some(old) = self.cache.key
&& (old.1, old.2, old.3) == (key.1, key.2, key.3)
&& self.enabled_unchanged()
{
self.cache.key = Some(key);
return;
}
let mut infos = self.shared.registry.infos(&self.session);
for info in &mut infos {
let keys = self.keymap.keys_for(&info.id);
info.shortcut = if keys.is_empty() { None } else { Some(format_list(keys)) };
}
self.cache.menu = MenuSpec::from_commands(&infos, MENU_ORDER);
self.cache.infos = infos;
self.cache.key = Some(key);
self.cache.seq = self.cache.seq.wrapping_add(1);
#[cfg(all(feature = "native-menu", any(target_os = "macos", target_os = "windows")))]
if let Some(m) = &mut self.native_menu {
m.update(&self.cache.infos);
}
}
fn enabled_unchanged(&self) -> bool {
let specs = self.shared.registry.specs();
specs.len() == self.cache.infos.len()
&& specs.iter().zip(&self.cache.infos).all(|(spec, info)| match (spec.enabled)(&self.session) {
Ok(()) => info.enabled,
Err(reason) => !info.enabled && info.disabled_reason.as_deref() == Some(reason.as_str()),
})
}
fn run_toast_actions(&mut self, ctx: &egui::Context) {
if !self.shared.toast_commands {
return;
}
let shared = Arc::clone(&self.shared);
for a in self.toasts.take_actions_where(|a| shared.registry.find(&a.command).is_some()) {
self.run_ui_command(ctx, &a.command, json!({}));
}
}
fn run_ui_command(&mut self, ctx: &egui::Context, id: &str, params: Value) {
if let Err(e) = self.execute(id, params) {
match e {
CommandError::Disabled { .. } => log::debug!("{e}"),
_ => {
self.toasts.error(e.to_string());
}
}
}
self.apply_effects(ctx);
}
fn apply_effects(&mut self, ctx: &egui::Context) {
for _ in 0..8 {
let effects = self.session.take_effects();
if effects.is_empty() {
return;
}
for e in effects {
self.apply_effect(ctx, e);
}
}
}
fn apply_effect(&mut self, ctx: &egui::Context, e: Effect) {
match e {
Effect::Toast { level, text } => {
self.toasts.push(ashlar_ui::toasts::Toast::new(level, text));
}
Effect::OpenPalette => self.palette.open(),
Effect::OpenKeymap => self.keymap_open = true,
Effect::Theme(mode) => {
tokens::set_mode(ctx, mode);
let _ = self.settings.set("theme", &mode);
}
Effect::PickOpen => self.request_pick(ctx, Pick::Open),
Effect::PickSaveAs => self.request_pick(ctx, Pick::SaveAs),
Effect::ConfirmDiscard { command, params } => self.modals.push(Modal::Confirm { quit: false, command: Some((command, params)) }),
Effect::Quit { discard } => {
if self.session.is_dirty() && !discard {
self.modals.push(Modal::Confirm { quit: true, command: None });
} else {
self.close(ctx, discard);
}
}
}
}
fn report(&mut self, r: Result<Value, CommandError>) {
if let Err(e) = r {
self.toasts.error(e.to_string());
}
}
fn request_pick(&mut self, ctx: &egui::Context, kind: Pick) {
if let Some(open) = self.pending_pick {
log::debug!("a {kind:?} dialog was asked for while a {open:?} dialog waits: ignored");
return;
}
self.pending_pick = Some(kind);
wake::wake(ctx, Reason("dialog"));
}
pub(crate) fn show_pending_pick(&mut self, ctx: &egui::Context, parent: Option<&eframe::Frame>) {
let Some(kind) = self.pending_pick.take() else { return };
let picked = match &mut self.picker {
Some(p) => p(kind),
None => {
self.launch.telemetry.pause_hangs();
let r = native_pick(&self.shared, &self.session, kind, parent);
self.launch.telemetry.resume_hangs();
r
}
};
self.deliver_pick(ctx, kind, picked);
self.apply_effects(ctx);
wake::wake(ctx, Reason("dialog"));
}
fn deliver_pick(&mut self, ctx: &egui::Context, kind: Pick, picked: Option<PathBuf>) {
match (kind, picked) {
(Pick::Open, Some(path)) => {
let r = self.execute("file.open", json!({"path": path.to_string_lossy(), "discard": true}));
self.report(r);
}
(Pick::Open, None) => {}
(Pick::SaveAs, Some(path)) => {
let r = self.execute("file.save_as", json!({"path": path.to_string_lossy()}));
self.report(r);
self.continue_after_save(ctx);
}
(Pick::SaveAs, None) => {
self.quit_after_save = false;
self.after_save = None;
if self.answering_end() {
self.end_step(ctx, session_end::Event::SaveFailed);
}
}
}
}
fn continue_after_save(&mut self, ctx: &egui::Context) {
self.continue_quit(ctx);
let next = self.after_save.take();
if let Some((id, params)) = next
&& !self.session.is_dirty()
{
self.run_ui_command(ctx, &id, params);
}
}
fn handle_close(&mut self, ctx: &egui::Context) {
let requested = ctx.input(|i| i.viewport().close_requested());
if !requested || self.allow_close {
return;
}
if self.session.is_dirty() {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
if !self.modals.iter().any(|m| matches!(m, Modal::Confirm { quit: true, .. })) {
self.modals.push(Modal::Confirm { quit: true, command: None });
}
} else {
self.shutdown(false);
}
}
fn poll_session_end(&mut self, ctx: &egui::Context) {
let requests: Vec<EndRequest> = self.session_end.as_ref().map(|rx| rx.try_iter().collect()).unwrap_or_default();
for r in requests {
self.end_requested(ctx, r.kind, session_end::CAN_WAIT);
}
}
fn end_requested(&mut self, ctx: &egui::Context, kind: EndKind, can_wait: bool) {
let dirty = self.session.is_dirty();
self.end_step(ctx, session_end::Event::Requested { kind, dirty, can_wait });
}
fn answering_end(&self) -> bool {
matches!(self.end_state, session_end::State::Asking(_) | session_end::State::Saving(_))
}
fn end_step(&mut self, ctx: &egui::Context, event: session_end::Event) {
let (state, effects) = session_end::step(self.end_state, event);
self.end_state = state;
for effect in effects {
match effect {
session_end::Effect::Ask => {
if !self.modals.iter().any(|m| matches!(m, Modal::Confirm { quit: true, .. })) {
self.modals.push(Modal::Confirm { quit: true, command: None });
}
}
session_end::Effect::Save => {
self.quit_after_save = true;
self.after_save = None;
let r = self.execute("file.save", json!({}));
self.report(r);
self.apply_effects(ctx);
if self.pending_pick.is_none() {
self.quit_after_save = false;
let next = if self.session.is_dirty() { session_end::Event::SaveFailed } else { session_end::Event::Saved };
self.end_step(ctx, next);
}
}
session_end::Effect::Shutdown { keep_recovery } => self.shutdown_inner(keep_recovery),
session_end::Effect::Allow => {
self.end_reply = Some(true);
session_end::reply(true);
if !cfg!(target_os = "macos") || self.session_end.is_none() {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
}
session_end::Effect::Refuse => {
self.end_reply = Some(false);
session_end::reply(false);
}
}
}
}
fn close(&mut self, ctx: &egui::Context, discarded: bool) {
self.shutdown(discarded);
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
pub fn shutdown(&mut self, _discarded: bool) {
self.shutdown_inner(false);
}
#[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
fn shutdown_inner(&mut self, keep_recovery: bool) {
if self.allow_close {
return;
}
self.allow_close = true;
self.jobs.cancel_all();
self.persist_view();
if let Err(e) = self.settings.flush() {
log::warn!("settings not saved: {e}");
}
if let Some(mut a) = self.autosave.take() {
#[cfg(not(target_arch = "wasm32"))]
if let Some(w) = a.writer.take() {
for e in w.shutdown(&mut a.autosave) {
log::warn!("autosave: {e}");
}
}
if let Err(e) = a.autosave.close(keep_recovery && self.session.is_dirty()) {
log::warn!("autosave cleanup: {e}");
}
}
self.launch.telemetry.flush(Duration::from_millis(500));
log::info!("{} closed", self.shared.name);
}
#[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
pub fn abandon(mut self) {
self.allow_close = true;
if let Some(mut a) = self.autosave.take() {
#[cfg(not(target_arch = "wasm32"))]
if let Some(w) = a.writer.take() {
let _ = w.shutdown(&mut a.autosave);
}
drop(a);
}
}
fn continue_quit(&mut self, ctx: &egui::Context) {
if self.quit_after_save {
self.quit_after_save = false;
if self.answering_end() {
let next = if self.session.is_dirty() { session_end::Event::SaveFailed } else { session_end::Event::Saved };
self.end_step(ctx, next);
} else if !self.session.is_dirty() {
self.close(ctx, false);
}
}
}
fn track_window(&mut self, ctx: &egui::Context) {
if !self.native || !self.shared.remember_window || self.shared.dirs.is_none() {
return;
}
let settings = &self.settings;
let tracker = &mut self.window;
let update = ctx.input(|i| tracker.update(i.viewport(), || settings.get::<WindowState>(window_state::KEY).unwrap_or_default()));
if let Some(pos) = update.move_to {
ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos.into()));
}
if let Some(state) = update.save
&& let Err(e) = self.settings.set(window_state::KEY, &state)
{
log::warn!("window placement not saved: {e}");
}
}
fn persist_view(&mut self) {
let generation = self.session.view_generation();
if self.persisted_view == Some(generation) {
return;
}
self.persisted_view = Some(generation);
let view = Value::Object(self.session.view_state().clone());
if self.settings.get_value("view") != Some(&view) {
self.settings.set_value("view", view);
}
}
fn tick_storage(&mut self, ctx: &egui::Context) {
let t = self.clock.host_time().max(ctx.input(|i| i.time)).max(0.0);
let now = Duration::from_secs_f64(t);
if let Err(e) = self.settings.tick(now) {
log::warn!("settings not saved: {e}");
}
let Some(a) = &mut self.autosave else { return };
if a.file_gen != self.session.file_generation() {
a.file_gen = self.session.file_generation();
let _ = a.autosave.set_document(&self.session.display_name(), self.session.path());
}
let dirty = self.session.is_dirty();
let rev = self.session.revision();
if !dirty {
if a.autosave.meta().dirty {
let _ = a.autosave.mark_clean();
}
return;
}
let snap = self.session.store().snapshot();
let format = Arc::clone(&self.shared.format);
#[cfg(not(target_arch = "wasm32"))]
let wrote = match &mut a.writer {
Some(w) => {
for e in w.pump(&mut a.autosave) {
log::warn!("autosave: {e}");
}
w.tick(&mut a.autosave, now, rev, dirty, move || format.encode(&snap).unwrap_or_default())
}
None => a.autosave.tick(now, rev, dirty, || format.encode(&snap).unwrap_or_default()).ok().flatten().is_some(),
};
#[cfg(target_arch = "wasm32")]
let wrote = a.autosave.tick(now, rev, dirty, || format.encode(&snap).unwrap_or_default()).ok().flatten().is_some();
if wrote {
log::debug!("autosaved revision {}", rev.0);
}
}
fn update_title(&mut self, ctx: &egui::Context) {
let key = (self.session.is_dirty(), self.session.file_generation());
if self.title_key == Some(key) {
return;
}
self.title_key = Some(key);
let dirty = if key.0 { " •" } else { "" };
let title = format!("{}{dirty} — {}", self.session.display_name(), self.shared.title);
if title != self.shown_title {
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
self.shown_title = title;
}
}
pub fn title(&self) -> &str {
&self.shown_title
}
fn draw_modals(&mut self, ctx: &egui::Context) {
if let Some(err) = self.view_error.clone() {
let buttons = [DialogButton::new("Save As…"), DialogButton::new("Try Again").default()];
let file = crash::last_panic().and_then(|p| p.log_file);
match Dialog::new("ashlar.view_error", "Something went wrong", &buttons).width(460.0).show(ctx, |ui| {
ui.label("The view hit an internal error and was stopped. Your document is safe: save it, then try again.");
ui.add_space(6.0);
ui.monospace(&err);
if let Some(f) = &file {
ui.small(format!("Crash log: {}", f.display()));
}
}) {
Some(DialogOutcome::Button(0)) => {
let r = self.execute("file.save_as", json!({}));
self.report(r);
self.apply_effects(ctx);
}
Some(_) => {
self.view_error = None;
crash::take_last_panic();
}
None => {}
}
return;
}
let Some(modal) = self.modals.first_mut() else { return };
let done = match modal {
Modal::Recovery => self.recovery_dialog(ctx),
Modal::SafeMode => {
let buttons = [DialogButton::new("Keep Settings"), DialogButton::new("Reset View Settings").default()];
let title = format!("{} started in safe mode", self.shared.title);
match Dialog::new("ashlar.safe_mode", &title, &buttons).show(ctx, |ui| {
ui.label("The last launch did not finish starting. Saved view settings (layout, theme) were not loaded this time.");
}) {
Some(DialogOutcome::Button(1)) => {
self.settings.remove("view");
self.persisted_view = None;
self.settings.remove("theme");
ctx.memory_mut(|m| *m = egui::Memory::default());
self.wakes.install(ctx);
true
}
Some(_) => true,
None => false,
}
}
Modal::Confirm { quit, command } => {
let (quit, command) = (*quit, command.clone());
self.confirm_dialog(ctx, quit, command)
}
};
if done && !self.modals.is_empty() {
self.modals.remove(0);
}
}
fn confirm_dialog(&mut self, ctx: &egui::Context, quit: bool, command: Option<(String, Value)>) -> bool {
let buttons = [DialogButton::cancel("Cancel"), DialogButton::destructive("Don't Save"), DialogButton::new("Save").default()];
let title = format!("Save changes to “{}”?", self.session.display_name());
let out = Dialog::new("ashlar.confirm_discard", &title, &buttons).show(ctx, |ui| {
ui.label("Your changes will be lost if you don't save them.");
});
if quit && self.answering_end() {
let event = match out {
Some(DialogOutcome::Button(1)) => session_end::Event::Discard,
Some(DialogOutcome::Button(2)) => session_end::Event::Save,
Some(_) => session_end::Event::Cancel,
None => return false,
};
self.end_step(ctx, event);
return true;
}
match out {
Some(DialogOutcome::Button(1)) => {
if quit {
self.close(ctx, true);
} else if let Some((id, params)) = command {
self.run_ui_command(ctx, &id, params);
}
true
}
Some(DialogOutcome::Button(2)) => {
self.quit_after_save = quit;
self.after_save = if quit { None } else { command };
let r = self.execute("file.save", json!({}));
self.report(r);
self.apply_effects(ctx);
if self.pending_pick.is_none() {
if self.session.is_dirty() {
self.quit_after_save = false;
self.after_save = None;
} else {
self.continue_after_save(ctx);
}
}
true
}
Some(_) => true,
None => false,
}
}
fn recovery_dialog(&mut self, ctx: &egui::Context) -> bool {
let Some(c) = self.recovery.first() else { return true };
let buttons = [DialogButton::destructive("Discard"), DialogButton::new("Later"), DialogButton::new("Recover").default()];
let label = if c.meta.label.is_empty() { "Untitled".to_string() } else { c.meta.label.clone() };
let ago = (ashlar_store::autosave::unix_now() - c.meta.saved_unix).max(0);
let out = Dialog::new("ashlar.recovery", "Recover unsaved work?", &buttons).width(440.0).show(ctx, |ui| {
ui.label(format!("“{label}” had unsaved changes when {} last closed (autosaved {} ago).", self.shared.title, human_age(ago)));
});
let Some(out) = out else { return false };
let c = self.recovery.remove(0);
match out {
DialogOutcome::Button(2) => match c.latest().map(|p| self.shared.format.load(p)) {
Some(Ok(loaded)) => {
let path = c.meta.doc_path.as_ref().map(PathBuf::from);
self.session.replace(D::default(), path, None);
self.session.replace_as_edit("Recover Unsaved Work", loaded.doc);
if let Err(e) = c.discard() {
log::warn!("recovery files not removed: {e}");
}
self.toasts.success(format!("Recovered “{label}”. Save it to keep it."));
}
Some(Err(e)) => {
self.toasts.error(format!("Could not recover “{label}”: {e}"));
}
None => {
let _ = c.discard();
}
},
DialogOutcome::Button(0) => {
if let Err(e) = c.discard() {
log::warn!("recovery files not removed: {e}");
}
}
_ => {}
}
self.recovery.is_empty()
}
fn draw_tools(&mut self, ctx: &egui::Context) {
if self.palette.is_open()
&& let Some(id) = self.palette.show(ctx, &self.cache.infos, self.cache.seq, Platform::current())
{
self.run_ui_command(ctx, &id, json!({}));
}
if self.keymap_open {
let mut open = true;
let mut change = None;
let (keymap, editor, infos, seq) = (&mut self.keymap, &mut self.keymap_editor, &self.cache.infos, self.cache.seq);
egui::Window::new("Keyboard Shortcuts").id(egui::Id::new("ashlar.keymap")).open(&mut open).default_size([520.0, 420.0]).show(ctx, |ui| {
change = editor.show(ui, keymap, infos, seq);
});
self.keymap_open = open;
if let Some(c) = change {
log::info!("keymap changed: {c:?}");
let _ = self.settings.set("keymap", &self.keymap.overrides());
self.keymap_gen = self.keymap_gen.wrapping_add(1);
if matches!(c, KeymapChange::Bound { .. } | KeymapChange::Reset { .. }) {
self.cache.key = None;
}
}
}
}
fn serve_control(&mut self, ctx: &egui::Context) {
let mut reqs: Vec<Request> = std::mem::take(&mut self.injected_requests);
if let Some((_, rx)) = &self.control {
reqs.extend(rx.try_iter().take(256));
}
if reqs.is_empty() {
return;
}
let info = self.info();
let mut queue = UiQueue { scenes: !self.shared.scenes.is_empty(), ..UiQueue::default() };
for req in reqs {
if let Some(r) = &mut self.recorder
&& !r.request(req.method(), req.params())
{
self.recorder = None;
}
let outer = self.session.swap_source(Some(Origin::Agent));
let mut bound = self.shared.registry.bind(&mut self.session);
ashlar_control::handle(req, &mut bound, &mut queue, &info);
self.session.swap_source(outer);
self.apply_effects(ctx);
}
for req in std::mem::take(&mut queue.pending) {
self.answer_ui(ctx, req);
}
}
fn answer_ui(&mut self, ctx: &egui::Context, req: Request) {
let p = req.params().clone();
let visible = ctx.input(|i| i.viewport().visible()).unwrap_or(true);
if devtools::handles(req.method()) {
if let devtools::Outcome::NotMine(req) = devtools::handle(ctx, req) {
req.unknown();
}
return;
}
match req.method() {
"ui.inspect" => {
let ins = automation::inspect(ctx);
req.ok(json!({
"pass": ins.pass,
"elements": ins.elements,
"title": self.shown_title,
"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())},
"view": self.session.view_state(),
"palette_open": self.palette.is_open(),
"keymap_open": self.keymap_open,
"dialogs": self.modals.len(),
"view_error": self.view_error,
"toasts": self.toasts.texts().collect::<Vec<_>>(),
"visible": visible,
}));
}
"ui.menu.list" => {
self.refresh_infos();
req.ok(serde_json::to_value(&self.cache.menu).unwrap_or(Value::Null));
}
"ui.menu.invoke" => {
let Some(id) = p.get("id").and_then(Value::as_str) else { return req.fail("ui.menu.invoke needs `id`") };
self.refresh_infos();
let command = self.cache.menu.find(id).map(|i| i.id.clone());
match command {
Some(c) => {
let r = self.execute(&c, json!({}));
self.apply_effects(ctx);
req.reply(Reply::from_command(r));
}
None => req.fail(format!("no menu item `{id}` (see ui.menu.list)")),
}
}
"ui.set" => {
let Some(obj) = p.as_object() else { return req.fail("ui.set needs an object") };
for (k, v) in obj {
if k == "theme" {
let mode: Option<ThemeMode> = serde_json::from_value(v.clone()).ok();
match mode {
Some(m) => {
self.session.set_view("theme", &m);
self.apply_effect(ctx, Effect::Theme(m));
}
None => return req.fail("theme is light, dark or system"),
}
} else {
self.session.set_view_value(k, v.clone());
}
}
req.ok(json!({}));
}
"app.quit" => {
let discard = p.get("discard").and_then(Value::as_bool).unwrap_or(false);
let dirty = self.session.is_dirty();
self.apply_effect(ctx, Effect::Quit { discard });
req.ok(json!({"closing": !dirty || discard}));
}
"app.stats" => {
let mut v = serde_json::to_value(self.stats.summary()).unwrap_or(Value::Null);
if let Some(o) = v.as_object_mut() {
o.insert("passes".into(), json!(self.passes));
o.insert("jobs".into(), self.jobs.to_json());
o.insert("log_lines_dropped".into(), json!(crate::logging::dropped()));
o.insert("wakes".into(), serde_json::to_value(self.wakes.stats()).unwrap_or(Value::Null));
o.insert("process".into(), serde_json::to_value(self.process.read()).unwrap_or(Value::Null));
}
if p.get("reset").and_then(Value::as_bool) == Some(true) {
self.stats.reset_window();
}
req.ok(v);
}
ashlar_control::APP_CLOCK => {
let op = match ashlar_control::ClockOp::from_params(&p) {
Ok(op) => op,
Err(e) => return req.fail(e),
};
let (now, predicted_dt) = ctx.input(|i| (i.time, i.predicted_dt));
match op {
ashlar_control::ClockOp::Read => {}
ashlar_control::ClockOp::Pause => self.clock.pause(now, predicted_dt),
ashlar_control::ClockOp::Step { ms } => {
self.clock.step(now, predicted_dt, ms / 1000.0);
wake::wake(ctx, Reason::CONTROL);
}
ashlar_control::ClockOp::Resume => {
self.clock.resume();
wake::wake(ctx, Reason::CONTROL);
}
}
req.ok(json!({"paused": self.clock.is_paused(), "time": self.clock.time(now)}));
}
ashlar_control::UI_SCENE => match p.get("name").and_then(Value::as_str) {
None => req.ok(json!({"scenes": self.shared.scene_names()})),
Some(name) => match self.apply_scene(name) {
Ok(()) => {
self.apply_effects(ctx);
wake::wake(ctx, Reason::CONTROL);
req.ok(json!({"scene": name}));
}
Err(e) => req.fail(e),
},
},
"app.logs" => {
let limit = p.get("limit").and_then(Value::as_u64).unwrap_or(200).min(crate::logging::RING_LINES as u64) as usize;
let level = p.get("level").and_then(Value::as_str).and_then(crate::logging::level_of).unwrap_or(log::Level::Trace);
req.ok(json!({"lines": crate::logging::recent(limit, level)}));
}
_ => req.unknown(),
}
}
fn feed_devtools(&mut self, ctx: &egui::Context) {
devtools::set_busy(ctx, self.jobs.is_busy());
devtools::section(ctx, "Logs", || {
crate::logging::recent(200, log::Level::Trace).into_iter().map(|l| format!("{:<5} {}: {}", l.level, l.target, l.message)).collect()
});
let history = self.session.store().history();
devtools::section(ctx, "Undo", || {
let mut lines: Vec<String> = history.undo_labels().map(|l| format!(" {l}")).collect();
lines.push("── now ──".into());
lines.extend(history.redo_labels().map(|l| format!(" {l} (redo)")));
lines
});
}
}
impl<D: Document> Drop for Runtime<D> {
fn drop(&mut self) {
self.shutdown(false);
}
}
fn human_age(secs: i64) -> String {
match secs {
s if s < 90 => format!("{s} s"),
s if s < 90 * 60 => format!("{} min", s / 60),
s if s < 48 * 3600 => format!("{} h", s / 3600),
s => format!("{} days", s / 86_400),
}
}
#[cfg(not(target_arch = "wasm32"))]
fn native_pick<D: Document>(shared: &Shared<D>, session: &Session<D>, kind: Pick, parent: Option<&eframe::Frame>) -> Option<PathBuf> {
use ashlar_native::dialogs::FileDialog;
let exts: Vec<&str> = shared.extensions.iter().map(String::as_str).collect();
let mut d = FileDialog::new();
if !exts.is_empty() {
d = d.filter(shared.title.clone(), &exts);
}
if let Some(dir) = session.path().and_then(std::path::Path::parent) {
d = d.directory(dir);
}
let d = match kind {
Pick::Open => d.title("Open"),
Pick::SaveAs => {
let name = match exts.first() {
Some(e) if session.path().is_none() => format!("Untitled.{e}"),
_ => session.display_name(),
};
d.title("Save As").file_name(name)
}
};
let r = match (kind, parent) {
(Pick::Open, Some(w)) => d.parent(w).open_file(),
(Pick::Open, None) => d.open_file(),
(Pick::SaveAs, Some(w)) => d.parent(w).save_file(),
(Pick::SaveAs, None) => d.save_file(),
};
match r {
Ok(p) => Some(p),
Err(ashlar_native::NativeError::Cancelled) => None,
Err(e) => {
log::warn!("file dialog: {e}");
None
}
}
}
#[cfg(target_arch = "wasm32")]
fn native_pick<D: Document>(_shared: &Shared<D>, _session: &Session<D>, _kind: Pick, _parent: Option<&eframe::Frame>) -> Option<PathBuf> {
None
}
struct EframeApp<D: Document> {
rt: Runtime<D>,
offscreen: bool,
}
impl<D: Document> EframeApp<D> {
fn install_offscreen(&mut self, ctx: &egui::Context, frame: &eframe::Frame) {
if std::mem::replace(&mut self.offscreen, true) {
return;
}
#[cfg(not(target_arch = "wasm32"))]
if let Some(rs) = frame.wgpu_render_state() {
devtools::set_offscreen(ctx, Some(crate::offscreen::renderer(rs.clone())));
}
#[cfg(target_arch = "wasm32")]
let _ = (ctx, frame);
}
}
impl<D: Document> eframe::App for EframeApp<D> {
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
self.rt.note_presented();
self.rt.show_pending_pick(ctx, Some(frame));
self.rt.logic(ctx);
self.install_offscreen(ctx, frame);
let Some(mut out) = self.rt.hidden_pass(ctx, None, Vec::new()) else { return };
#[cfg(not(target_arch = "wasm32"))]
if let Some(rs) = frame.wgpu_render_state() {
let mut r = rs.renderer.write();
for (id, deltas) in &out.textures_delta.set {
for delta in deltas {
r.update_texture(&rs.device, &rs.queue, *id, delta);
}
}
for id in &out.textures_delta.free {
r.free_texture(id);
}
}
out.textures_delta.clear();
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
self.install_offscreen(ui.ctx(), frame);
if let Some(d) = frame.info().cpu_usage.and_then(|s| Duration::try_from_secs_f32(s).ok()) {
self.rt.stats.record_frame_cpu(d);
}
self.rt.ui(ui);
#[cfg(not(target_arch = "wasm32"))]
self.rt.install_native_menu(ui.ctx(), frame);
}
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
self.rt.raw_input_hook(ctx, raw_input);
}
fn on_exit(&mut self) {
self.rt.shutdown(false);
}
}
#[cfg(not(target_arch = "wasm32"))]
struct Args {
control: Option<u16>,
scene: Option<String>,
safe_mode: bool,
open: Option<PathBuf>,
}
#[cfg(not(target_arch = "wasm32"))]
fn parse_args(env_prefix: &str) -> Args {
let mut a = Args { control: None, scene: None, safe_mode: false, open: None };
let mut it = std::env::args().skip(1);
while let Some(arg) = it.next() {
match arg.as_str() {
"--control" => a.control = it.next().and_then(|p| p.parse().ok()),
s if s.starts_with("--control=") => a.control = s.trim_start_matches("--control=").parse().ok(),
"--safe-mode" => a.safe_mode = true,
"--scene" => a.scene = it.next(),
s if s.starts_with("--scene=") => a.scene = Some(s.trim_start_matches("--scene=").to_string()),
s if !s.starts_with('-') && a.open.is_none() => a.open = Some(PathBuf::from(s)),
_ => {}
}
}
if a.control.is_none() {
a.control = std::env::var(format!("{env_prefix}_CONTROL_PORT")).ok().and_then(|p| p.trim().parse().ok());
}
a
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn run_native<D: Document>(app: App<D>) -> Result<(), Error> {
use wasm_bindgen::JsCast;
let (shared, view, setup) = app.split();
let level = log::LevelFilter::Info;
let console: crate::logging::Output = Box::new(|line| web_sys::console::log_1(&line.into()));
crate::logging::install(Box::new(crate::logging::Logger::new(&shared.name, None, level, false).with_output(console)), level);
static CONSOLE_HOOK: std::sync::Once = std::sync::Once::new();
CONSOLE_HOOK.call_once(|| {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
web_sys::console::error_1(&format!("panic: {info}").into());
previous(info);
}));
});
crash::install(None, &shared.name, &shared.version);
let document = web_sys::window().and_then(|w| w.document()).ok_or_else(|| Error::Window("no document".into()))?;
let ids: Vec<String> = shared.canvas_id.iter().cloned().chain([shared.name.clone(), "ashlar".to_string()]).collect();
let canvas = ids.iter().find_map(|id| document.get_element_by_id(id).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()));
let Some(canvas) = canvas else {
let wanted = ids.first().map_or("ashlar", String::as_str);
let msg = format!("{} could not start: the page has no <canvas id=\"{wanted}\">.", shared.title);
show_web_error(&document, None, &msg);
return Err(Error::Window(msg));
};
let title = shared.title.clone();
wasm_bindgen_futures::spawn_local(async move {
let creator: eframe::AppCreator<'static> =
Box::new(move |_cc| Ok(Box::new(EframeApp { rt: Runtime::new(shared, view, setup, Launch::default()), offscreen: false })));
let target = canvas.clone();
if let Err(e) = eframe::WebRunner::new().start(canvas, eframe::WebOptions::default(), creator).await {
log::error!("the app could not start: {e:?}");
let detail = e.as_string().unwrap_or_else(|| "the browser may not support WebGL or WebGPU".to_string());
show_web_error(&document, Some(&target), &format!("{title} could not start: {detail}"));
}
});
Ok(())
}
#[cfg(target_arch = "wasm32")]
fn show_web_error(document: &web_sys::Document, canvas: Option<&web_sys::HtmlCanvasElement>, msg: &str) {
const ID: &str = "ashlar-start-error";
let shown = (|| -> Result<(), wasm_bindgen::JsValue> {
if let Some(old) = document.get_element_by_id(ID) {
old.remove();
}
let div = document.create_element("div")?;
div.set_id(ID);
div.set_attribute("role", "alert")?;
div.set_attribute(
"style",
"margin:12px;padding:12px 16px;border:1px solid #c62828;border-radius:6px;background:#fdecea;\
color:#5f2120;font:14px/1.4 system-ui,sans-serif;max-width:640px",
)?;
div.set_text_content(Some(msg));
match canvas.and_then(|c| c.parent_node().map(|p| (p, c.next_sibling()))) {
Some((parent, next)) => drop(parent.insert_before(&div, next.as_ref())?),
None => match document.body() {
Some(body) => drop(body.insert_before(&div, body.first_child().as_ref())?),
None => return Err("no <body>".into()),
},
}
Ok(())
})();
if let Err(e) = shown {
log::error!("could not show the start error in the page: {e:?}");
}
}
#[cfg(not(target_arch = "wasm32"))]
fn window_builder<D: Document>(shared: &Shared<D>, window: crate::app::WindowSpec, safe_mode: bool) -> egui::ViewportBuilder {
let mut b =
egui::ViewportBuilder::default().with_title(shared.title.clone()).with_inner_size(shared.window_size).with_app_id(shared.name.clone());
if shared.remember_window
&& !safe_mode
&& let Some(d) = &shared.dirs
{
let settings = Settings::load(Arc::new(DirConfig::new(d.config.clone())), "settings.json", &[]);
if let Some(saved) = settings.get::<WindowState>(window_state::KEY) {
b = window_state::apply(b, &window_state::clamp(saved, &window_state::monitors_now()));
}
}
if let Some(png) = window.icon {
match catch_unwind(AssertUnwindSafe(|| eframe::icon_data::from_png_bytes(&png))) {
Ok(Ok(icon)) => b = b.with_icon(icon),
Ok(Err(e)) => log::warn!("the app icon is not a PNG ashlar can read: {e}"),
Err(_) => log::warn!("the app icon could not be decoded"),
}
}
let Some(f) = window.viewport else { return b };
let fallback = b.clone();
catch_unwind(AssertUnwindSafe(move || f(b))).unwrap_or_else(|p| {
log::error!("App::viewport panicked: {}", panic_text(p.as_ref()));
fallback
})
}
#[cfg(not(target_arch = "wasm32"))]
fn wgpu_options(latency: u32) -> eframe::egui_wgpu::WgpuConfiguration {
let surface = eframe::egui_wgpu::SurfaceConfig {
present_mode: eframe::egui_wgpu::wgpu::PresentMode::AutoVsync,
desired_maximum_frame_latency: Some(latency),
};
eframe::egui_wgpu::WgpuConfiguration { surface, ..Default::default() }
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn run_native<D: Document>(app: App<D>) -> Result<(), Error> {
let process_start = now();
let mut app = app;
let window = app.take_window();
let (shared, view, setup) = app.split();
let args = parse_args(&shared.env_prefix);
let logs = shared.dirs.as_ref().map(|d| d.logs.clone());
if let Some(d) = &shared.dirs
&& let Err(e) = d.ensure()
{
eprintln!("{}: could not create its folders: {e}", shared.name);
}
let inbox = Inbox::default();
let mut instance = None;
if shared.single_instance {
let launch_args: Vec<String> = std::env::args().skip(1).collect();
match ashlar_native::single_instance::SingleInstance::new(&shared.name).claim(&launch_args, inbox.forwarder()) {
Ok(ashlar_native::single_instance::Launch::Forwarded) => return Ok(()),
Ok(ashlar_native::single_instance::Launch::Primary(g)) => instance = Some(g),
Err(e) => eprintln!("{}: running without the single-instance check: {e}", shared.name),
}
}
let env_var = format!("{}_LOG", shared.env_prefix);
let filter = crate::logging::filter_from_env(&[env_var.as_str(), "RUST_LOG"], "info");
let level = filter.max_level();
let telemetry = match (&shared.telemetry, &shared.dirs) {
(Some(cfg), Some(d)) => Telemetry::start(cfg, &shared.name, &shared.version, &d.data, true),
_ => Telemetry::default(),
};
let logger = crate::logging::Logger::new(&shared.name, logs.as_deref(), level, true).with_filter(filter);
crate::logging::install(telemetry.wrap_logger(Box::new(logger)), level);
crash::install(logs.as_deref(), &shared.name, &shared.version);
log::info!("{} {} starting", shared.name, shared.version);
if let Err(e) = ashlar_native::display::check() {
log::error!("{} cannot open its window: {e}", shared.name);
return Err(Error::NoDesktop(e.0));
}
let (guard, previous) = match &shared.dirs {
Some(d) => {
let (g, p) = LaunchGuard::begin(&d.data);
(Some(g), p)
}
None => (None, PreviousLaunch::Clean),
};
let safe_mode = args.safe_mode || matches!(previous, PreviousLaunch::CrashedAtStartup { .. });
if safe_mode {
log::warn!("starting in safe mode ({previous:?})");
}
#[cfg(target_os = "macos")]
inbox.listen_os();
let ready_file = std::env::var_os(format!("{}_READY_FILE", shared.env_prefix)).filter(|v| !v.is_empty()).map(PathBuf::from);
let mut launch = Launch {
safe_mode,
previous: Some(previous),
open: args.open,
control_port: args.control,
guard,
telemetry,
inbox: Some(inbox),
instance,
process_start,
window_at: None,
ready_file,
presented: false,
scene: args.scene,
};
let mut options = eframe::NativeOptions {
viewport: window_builder(&shared, window, safe_mode),
wgpu_options: wgpu_options(shared.frame_latency),
..Default::default()
};
if ashlar_native::mac_shell::quiet_requested(&shared.env_prefix) {
options.viewport = options.viewport.with_active(false);
#[cfg(target_os = "macos")]
{
options.event_loop_builder = Some(Box::new(|b| {
use winit::platform::macos::EventLoopBuilderExtMacOS;
b.with_activate_ignoring_other_apps(false);
}));
}
}
let title = shared.title.clone();
eframe::run_native(
&title,
options,
Box::new(move |_cc| {
launch.window_at = now();
let mut rt = Runtime::new(shared, view, setup, launch);
rt.native = true;
Ok(Box::new(EframeApp { rt, offscreen: false }))
}),
)
.map_err(|e| Error::Window(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ages_read_well() {
assert_eq!(human_age(5), "5 s");
assert_eq!(human_age(600), "10 min");
assert_eq!(human_age(7200), "2 h");
assert_eq!(human_age(300_000), "3 days");
}
fn launched(app: crate::App<i64>, launch: Launch) -> Runtime<i64> {
let (shared, view, setup) = app.split();
let mut rt = Runtime::new(shared, view, setup, launch);
rt.set_preferences(Preferences::default);
rt
}
#[test]
fn launch_times_and_the_ready_file() {
let dir = tempfile::tempdir().expect("tempdir");
let ready = dir.path().join("ready.json");
let start = std::time::Instant::now().checked_sub(Duration::from_millis(5));
let launch = Launch { process_start: start, window_at: now(), ready_file: Some(ready.clone()), ..Default::default() };
let mut d = crate::Driver::new(launched(crate::App::new("launchtest", "0.0.0"), launch));
d.runtime.note_presented();
assert_eq!(d.runtime.stats().launch().launch_to_present_ms, None, "nothing is presented before a frame");
d.frame();
d.runtime.note_presented();
let t = d.runtime.stats().summary().launch;
let (w, f, p) = (t.to_window_ms.unwrap(), t.to_first_frame_ms.unwrap(), t.launch_to_present_ms.unwrap());
assert!(w >= 5.0 && w <= f && f <= p, "window {w}, first frame {f}, present {p}");
let deadline = std::time::Instant::now() + Duration::from_secs(10);
let body = loop {
if let Ok(text) = std::fs::read_to_string(&ready) {
break text;
}
assert!(std::time::Instant::now() < deadline, "no ready file");
std::thread::sleep(Duration::from_millis(5));
};
let v: Value = serde_json::from_str(&body).unwrap();
assert_eq!(v["pid"].as_u64(), Some(u64::from(std::process::id())));
assert_eq!(v["ready_ms"].as_f64(), Some(p));
}
#[test]
fn headless_runs_report_no_launch_times() {
let mut d = crate::Driver::new(crate::App::<i64>::new("launchtest", "0.0.0").runtime());
d.frame();
d.runtime.note_presented();
assert_eq!(d.runtime.stats().launch(), crate::LaunchTimes::default());
}
#[test]
fn the_command_line_document_opens_after_the_first_frame() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("a.num");
let mut h = crate::App::<i64>::new("launchtest", "0.0.0").headless();
h.session_mut().edit("set", |n| *n = 7);
h.session_mut().save_as(&path).unwrap();
let app = crate::App::<i64>::new("launchtest", "0.0.0").file_extension("num");
let mut d = crate::Driver::new(launched(app, Launch { open: Some(path.clone()), ..Default::default() }));
assert_eq!(d.runtime.session().path(), None, "nothing loads before the window");
d.frame();
assert!(d.runtime.stats().summary().first_frame_ms.is_some(), "the first frame is drawn");
assert_eq!(d.runtime.session().path(), None, "with the empty document");
d.settle(10);
assert_eq!(d.runtime.session().path(), Some(path.as_path()), "the next frame opens the file");
assert_eq!(*d.runtime.session().doc(), 7);
}
#[test]
fn menus_follow_enabled_state_but_typing_rebuilds_nothing() {
let mut rt = crate::App::<i64>::new("runtimetest", "0.0.0").runtime();
let undo = |rt: &Runtime<i64>| rt.cache.infos.iter().find(|i| i.id == "app.undo").map(|i| i.enabled);
rt.refresh_infos();
assert_eq!(undo(&rt), Some(false), "nothing to undo yet");
rt.session_mut().edit("bump", |n| *n += 1);
rt.refresh_infos();
assert_eq!(undo(&rt), Some(true), "an edit enables Undo");
assert_eq!(rt.cache.menu.find("app.undo").map(|i| i.enabled), Some(true), "and the menu shows it");
let seq = rt.cache.seq;
rt.session_mut().edit("bump", |n| *n += 1);
rt.refresh_infos();
assert_eq!(rt.cache.seq, seq, "another edit changes no command's state: the infos stay");
assert_eq!(rt.cache.key.map(|k| k.0), Some(rt.session().revision()), "and are current");
rt.session_mut().undo();
rt.session_mut().undo();
rt.refresh_infos();
assert_eq!(undo(&rt), Some(false), "undoing everything disables Undo again");
}
fn enter() -> egui::Event {
egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }
}
fn picking(answers: Vec<(Pick, PathBuf)>) -> (crate::Driver<i64>, std::rc::Rc<std::cell::RefCell<Vec<Pick>>>) {
let app = crate::App::<i64>::new("picktest", "0.0.0").file_extension("num");
let mut rt = launched(app, Launch::default());
let asked = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
let log = asked.clone();
rt.set_file_picker(move |kind| {
log.borrow_mut().push(kind);
answers.iter().find(|(k, _)| *k == kind).map(|(_, p)| p.clone())
});
let mut d = crate::Driver::new(rt);
d.frame();
(d, asked)
}
#[test]
fn the_pick_arrives_on_the_next_frame() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("a.num");
let (mut d, asked) = picking(vec![(Pick::SaveAs, path.clone())]);
d.runtime.session_mut().edit("set", |n| *n = 3);
let r = d.runtime.execute("file.save_as", json!({})).unwrap();
assert_eq!(r["pending"], "dialog");
d.frame();
assert!(asked.borrow().is_empty(), "no dialog inside the frame that asked for it");
assert_eq!(d.runtime.pending_pick, Some(Pick::SaveAs));
d.frame();
assert_eq!(*asked.borrow(), vec![Pick::SaveAs], "the next frame shows it");
assert_eq!(d.runtime.session().path(), Some(path.as_path()), "and saves there");
assert!(!d.runtime.session().is_dirty());
}
#[test]
fn quitting_a_dirty_untitled_document_saves_through_the_dialog_then_closes() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("q.num");
let (mut d, asked) = picking(vec![(Pick::SaveAs, path.clone())]);
d.runtime.session_mut().edit("set", |n| *n = 9);
d.request_close();
d.frame();
assert!(d.runtime.modal_open(), "the confirm dialog asks first");
d.settle(10);
d.push_event(enter());
d.frame();
d.settle(10);
assert_eq!(*asked.borrow(), vec![Pick::SaveAs]);
assert_eq!(d.runtime.session().path(), Some(path.as_path()));
assert!(d.runtime.is_closing(), "the quit goes on once saved");
}
#[test]
fn a_cancelled_save_as_keeps_the_window_open() {
let (mut d, asked) = picking(Vec::new());
d.runtime.session_mut().edit("set", |n| *n = 9);
d.request_close();
d.frame();
d.settle(10);
d.push_event(enter());
d.frame();
d.settle(10);
assert_eq!(*asked.borrow(), vec![Pick::SaveAs]);
assert!(!d.runtime.is_closing(), "cancelled: nothing closes");
assert!(!d.runtime.quit_after_save, "and a later save does not quit");
}
fn os_end(d: &mut crate::Driver<i64>, kind: EndKind, can_wait: bool) {
let ctx = d.ctx().clone();
d.runtime.end_requested(&ctx, kind, can_wait);
d.frame();
}
#[test]
fn a_dock_quit_of_a_clean_document_cleans_up_and_allows_at_once() {
let (mut d, _) = picking(Vec::new());
os_end(&mut d, EndKind::Quit, true);
assert_eq!(d.runtime.end_reply, Some(true));
assert!(d.runtime.is_closing(), "shut down before answering");
assert!(!d.runtime.modal_open());
}
#[test]
fn a_dock_quit_of_a_dirty_document_asks_then_saves_then_allows() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("q.num");
let (mut d, asked) = picking(vec![(Pick::SaveAs, path.clone())]);
d.runtime.session_mut().edit("set", |n| *n = 9);
os_end(&mut d, EndKind::Quit, true);
assert!(d.runtime.modal_open(), "the confirm dialog asks first");
assert_eq!(d.runtime.end_reply, None, "no answer to the OS yet");
d.settle(10);
d.push_event(enter());
d.frame();
d.settle(10);
assert_eq!(*asked.borrow(), vec![Pick::SaveAs]);
assert_eq!(d.runtime.session().path(), Some(path.as_path()));
assert_eq!(d.runtime.end_reply, Some(true), "saved: the quit goes on");
assert!(d.runtime.is_closing());
}
#[test]
fn a_cancelled_save_as_refuses_the_os_quit() {
let (mut d, asked) = picking(Vec::new());
d.runtime.session_mut().edit("set", |n| *n = 9);
os_end(&mut d, EndKind::Logout, true);
d.settle(10);
d.push_event(enter());
d.frame();
d.settle(10);
assert_eq!(*asked.borrow(), vec![Pick::SaveAs]);
assert_eq!(d.runtime.end_reply, Some(false), "the logout is cancelled");
assert!(!d.runtime.is_closing());
assert_eq!(d.runtime.end_state, session_end::State::Idle, "a later quit asks again");
}
#[test]
fn a_logout_that_cant_wait_allows_and_keeps_the_changes_for_recovery() {
let (mut d, asked) = picking(Vec::new());
d.runtime.session_mut().edit("set", |n| *n = 9);
os_end(&mut d, EndKind::Logout, false);
assert!(asked.borrow().is_empty() && !d.runtime.modal_open(), "nothing is asked");
assert_eq!(d.runtime.end_reply, Some(true));
assert!(d.runtime.is_closing());
}
#[test]
fn opening_over_a_dirty_untitled_document_saves_then_shows_the_open_dialog() {
let dir = tempfile::tempdir().expect("tempdir");
let (saved, other) = (dir.path().join("s.num"), dir.path().join("o.num"));
let mut h = crate::App::<i64>::new("picktest", "0.0.0").headless();
h.session_mut().edit("set", |n| *n = 42);
h.session_mut().save_as(&other).unwrap();
let (mut d, asked) = picking(vec![(Pick::SaveAs, saved.clone()), (Pick::Open, other.clone())]);
d.runtime.session_mut().edit("set", |n| *n = 5);
let r = d.runtime.execute("file.open", json!({})).unwrap();
assert_eq!(r["pending"], "confirm");
d.frame();
d.settle(10);
d.push_event(enter());
d.frame();
d.settle(10);
assert_eq!(*asked.borrow(), vec![Pick::SaveAs, Pick::Open], "save first, then pick the file to open");
assert!(saved.exists(), "the changes were saved");
assert_eq!(d.runtime.session().path(), Some(other.as_path()));
assert_eq!(*d.runtime.session().doc(), 42);
}
}