Skip to main content

ashlar_app/
app.rs

1//! The [`App`] builder, the headless session behind the CLI and MCP, and `run()`.
2
3use std::path::PathBuf;
4use std::sync::Arc;
5use std::time::Duration;
6
7use ashlar_command::{CommandError, CommandInfo, CommandSpec, Dispatch, Registry};
8use ashlar_fs::AppDirs;
9use ashlar_store::Format;
10use serde_json::Value;
11
12use crate::builtins;
13use crate::runtime::{Cx, Runtime};
14use crate::session::{Document, Session};
15use crate::telemetry::TelemetryConfig;
16
17/// A view: draws the app's UI each frame.
18pub type ViewFn<D> = Box<dyn FnMut(&mut egui::Ui, &mut Cx<'_, D>)>;
19/// Runs once with the egui context before the first frame (fonts, tokens).
20pub type SetupFn = Box<dyn FnOnce(&egui::Context)>;
21/// Changes the window's builder before the window opens (see [`App::viewport`]).
22pub type ViewportFn = Box<dyn FnOnce(egui::ViewportBuilder) -> egui::ViewportBuilder>;
23
24/// What only the window's creation needs (taken out of the [`App`] before it is split).
25#[derive(Default)]
26pub(crate) struct WindowSpec {
27    pub viewport: Option<ViewportFn>,
28    /// PNG bytes for the window and taskbar icon.
29    pub icon: Option<Vec<u8>>,
30}
31/// A named starting state ([`App::scene`]).
32pub type SceneFn<D> = Arc<dyn Fn(&mut Session<D>) + Send + Sync>;
33
34/// Why the app could not start.
35#[derive(Debug, thiserror::Error)]
36pub enum Error {
37    #[error("could not open the window: {0}")]
38    Window(String),
39    /// This process cannot show windows at all (`ashlar_native::display::check`): started by a
40    /// service or as SYSTEM on Windows. Nothing was opened; say so and exit.
41    #[error("no window can be shown here: {0}")]
42    NoDesktop(String),
43    #[error("{0}")]
44    Io(#[from] std::io::Error),
45}
46
47/// What everything built from one [`App`] shares (immutable).
48#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
49pub(crate) struct Shared<D: Document> {
50    pub name: String,
51    pub version: String,
52    pub title: String,
53    /// `NOTES` for an app called `notes`: `<PREFIX>_CONTROL_PORT`, `<PREFIX>_LOG`.
54    pub env_prefix: String,
55    pub registry: Registry<Session<D>>,
56    pub format: Arc<Format<D>>,
57    pub extensions: Vec<String>,
58    pub dirs: Option<AppDirs>,
59    pub autosave: Option<Duration>,
60    pub telemetry: Option<TelemetryConfig>,
61    pub instructions: String,
62    pub window_size: [f32; 2],
63    /// Frames the GPU may queue ahead of the screen (see [`App::frame_latency`]).
64    pub frame_latency: u32,
65    pub control_port: Option<u16>,
66    pub follow_system_accent: bool,
67    pub motion: Option<ashlar_motion::MotionPrefs>,
68    pub single_instance: bool,
69    pub accept_drops: bool,
70    /// Run toast actions whose command is registered (see [`App::run_toast_actions`]).
71    pub toast_commands: bool,
72    /// Save the window's placement and restore it on launch (see [`App::remember_window`]).
73    pub remember_window: bool,
74    /// The tray icon to show (see [`App::tray`]).
75    #[cfg(feature = "tray")]
76    pub tray: Option<ashlar_native::tray::TraySpec>,
77    /// System-wide shortcuts: (command id, chord text) (see [`App::global_shortcut`]).
78    #[cfg(feature = "hotkey")]
79    pub global_shortcuts: Vec<(String, String)>,
80    /// Named starting states, in declaration order.
81    pub scenes: Vec<(String, SceneFn<D>)>,
82    /// The web page's `<canvas>` id (see [`App::canvas_id`]).
83    #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
84    pub canvas_id: Option<String>,
85}
86
87impl<D: Document> Shared<D> {
88    /// The scene called `name`.
89    pub(crate) fn scene(&self, name: &str) -> Option<&SceneFn<D>> {
90        self.scenes.iter().find(|(n, _)| n == name).map(|(_, f)| f)
91    }
92
93    /// Every scene's name, in declaration order.
94    pub(crate) fn scene_names(&self) -> Vec<String> {
95        self.scenes.iter().map(|(n, _)| n.clone()).collect()
96    }
97}
98
99/// An app: name, document type and format, commands, views. `run()` opens the window; the same
100/// definition serves the CLI and MCP ([`App::cli`]) and tests ([`App::headless`]).
101///
102/// ```no_run
103/// use ashlar_app::{App, Cx, Session};
104/// use ashlar_command::{CommandError, CommandSpec};
105///
106/// #[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
107/// struct Doc { count: i64 }
108///
109/// fn bump(s: &mut Session<Doc>, _: &serde_json::Value) -> Result<serde_json::Value, CommandError> {
110///     s.edit("Bump", |d| d.count += 1);
111///     Ok(serde_json::Value::Null)
112/// }
113///
114/// fn main() -> Result<(), ashlar_app::Error> {
115///     App::<Doc>::new("counter", "0.1.0")
116///         .command(CommandSpec::new("count.bump", "Bump", bump).menu(&["Edit"]).shortcut("Mod+B"))
117///         .view(|ui, cx: &mut Cx<'_, Doc>| {
118///             ui.label(format!("{}", cx.doc().count));
119///             if ui.button("Bump").clicked() {
120///                 cx.dispatch("count.bump", serde_json::json!({}));
121///             }
122///         })
123///         .run()
124/// }
125/// ```
126pub struct App<D: Document> {
127    name: String,
128    version: String,
129    title: Option<String>,
130    qualifier: String,
131    org: String,
132    format: Option<Format<D>>,
133    extensions: Vec<String>,
134    commands: Vec<CommandSpec<Session<D>>>,
135    view: Option<ViewFn<D>>,
136    setup: Option<SetupFn>,
137    autosave: Option<Duration>,
138    telemetry: Option<TelemetryConfig>,
139    instructions: String,
140    dirs: Option<AppDirs>,
141    window_size: [f32; 2],
142    frame_latency: u32,
143    control_port: Option<u16>,
144    follow_system_accent: bool,
145    motion: Option<ashlar_motion::MotionPrefs>,
146    single_instance: bool,
147    accept_drops: Option<bool>,
148    toast_commands: bool,
149    remember_window: bool,
150    #[cfg(feature = "tray")]
151    tray: Option<ashlar_native::tray::TraySpec>,
152    #[cfg(feature = "hotkey")]
153    global_shortcuts: Vec<(String, String)>,
154    window: WindowSpec,
155    scenes: Vec<(String, SceneFn<D>)>,
156    canvas_id: Option<String>,
157}
158
159impl<D: Document> App<D> {
160    /// An app called `name` (lower case, used for folders, env vars and the CLI).
161    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
162        App {
163            name: name.into(),
164            version: version.into(),
165            title: None,
166            qualifier: "org".into(),
167            org: "ashlar".into(),
168            format: None,
169            extensions: Vec::new(),
170            commands: Vec::new(),
171            view: None,
172            setup: None,
173            autosave: Some(Duration::from_secs(60)),
174            telemetry: None,
175            instructions: String::new(),
176            dirs: None,
177            window_size: [960.0, 640.0],
178            frame_latency: 1,
179            control_port: None,
180            follow_system_accent: true,
181            motion: None,
182            single_instance: false,
183            accept_drops: None,
184            toast_commands: false,
185            remember_window: true,
186            #[cfg(feature = "tray")]
187            tray: None,
188            #[cfg(feature = "hotkey")]
189            global_shortcuts: Vec::new(),
190            window: WindowSpec::default(),
191            scenes: Vec::new(),
192            canvas_id: None,
193        }
194    }
195
196    /// Reverse-domain qualifier and organisation for the OS folders (`"com", "Acme"`).
197    pub fn organization(mut self, qualifier: impl Into<String>, org: impl Into<String>) -> Self {
198        self.qualifier = qualifier.into();
199        self.org = org.into();
200        self
201    }
202
203    /// Window title (default: the name).
204    pub fn title(mut self, title: impl Into<String>) -> Self {
205        self.title = Some(title.into());
206        self
207    }
208
209    /// The document's file format, with its migrations.
210    pub fn format(mut self, format: Format<D>) -> Self {
211        self.format = Some(format);
212        self
213    }
214
215    /// A file extension offered by Open and Save dialogs (without the dot).
216    pub fn file_extension(mut self, ext: impl Into<String>) -> Self {
217        self.extensions.push(ext.into());
218        self
219    }
220
221    /// Add commands (menus follow declaration order).
222    pub fn commands(mut self, specs: impl IntoIterator<Item = CommandSpec<Session<D>>>) -> Self {
223        self.commands.extend(specs);
224        self
225    }
226
227    pub fn command(mut self, spec: CommandSpec<Session<D>>) -> Self {
228        self.commands.push(spec);
229        self
230    }
231
232    /// The UI, drawn inside the window's central area each frame.
233    pub fn view(mut self, view: impl FnMut(&mut egui::Ui, &mut Cx<'_, D>) + 'static) -> Self {
234        self.view = Some(Box::new(view));
235        self
236    }
237
238    /// Runs once before the first frame (install fonts, tokens).
239    pub fn setup(mut self, f: impl FnOnce(&egui::Context) + 'static) -> Self {
240        self.setup = Some(Box::new(f));
241        self
242    }
243
244    /// Autosave interval (`None`: no autosave or recovery). Default 60 s.
245    pub fn autosave(mut self, every: Option<Duration>) -> Self {
246        self.autosave = every;
247        self
248    }
249
250    /// Telemetry (with the `telemetry` feature): write key and server. Always on when `endpoint`
251    /// is set (no notice, no switches); `TRACER_DISABLED` in the environment turns it off.
252    pub fn telemetry(mut self, key: impl Into<String>, endpoint: Option<&str>) -> Self {
253        self.telemetry = Some(TelemetryConfig { key: key.into(), endpoint: endpoint.map(str::to_string) });
254        self
255    }
256
257    /// What MCP clients are told about the app.
258    pub fn instructions(mut self, text: impl Into<String>) -> Self {
259        self.instructions = text.into();
260        self
261    }
262
263    /// Put config, data, cache and logs here instead of the OS folders (tests, portable).
264    pub fn dirs(mut self, dirs: AppDirs) -> Self {
265        self.dirs = Some(dirs);
266        self
267    }
268
269    /// On the web, the id of the `<canvas>` the app runs in (default: the app's name, then
270    /// `ashlar`). When the app cannot start (no such canvas, no WebGL), a visible error appears
271    /// next to the canvas (or at the top of the page) as well as in the console. Ignored on the
272    /// desktop.
273    pub fn canvas_id(mut self, id: impl Into<String>) -> Self {
274        self.canvas_id = Some(id.into());
275        self
276    }
277
278    pub fn window_size(mut self, width: f32, height: f32) -> Self {
279        self.window_size = [width, height];
280        self
281    }
282
283    /// Frames the GPU may queue ahead of the screen, 1 to 3 (default 1: the lowest input latency,
284    /// and one swapchain image fewer, about 10 MB for a 960×640 window on a Retina screen). Apps
285    /// that draw heavy GPU scenes every frame may prefer 2 for smoother throughput.
286    pub fn frame_latency(mut self, frames: u32) -> Self {
287        self.frame_latency = frames.clamp(1, 3);
288        self
289    }
290
291    /// Serve the control port on this port (also `--control <port>` and `<APP>_CONTROL_PORT`).
292    pub fn control_port(mut self, port: u16) -> Self {
293        self.control_port = Some(port);
294        self
295    }
296
297    /// Recolour the accent (hover, focus, selection) from the OS accent colour (default on).
298    /// Off pins the built-in accent. Tokens the app installs itself always win over the OS.
299    pub fn follow_system_accent(mut self, follow: bool) -> Self {
300        self.follow_system_accent = follow;
301        self
302    }
303
304    /// Motion settings that win over the OS "reduce motion" setting (an app's own motion
305    /// preference). Without this the app follows the OS, at normal speed.
306    pub fn motion(mut self, prefs: ashlar_motion::MotionPrefs) -> Self {
307        self.motion = Some(prefs);
308        self
309    }
310
311    /// One window per app (default off). A second launch hands its arguments (the files to open)
312    /// to the running one and exits; the running one opens them with `file.open` and comes to the
313    /// front. The endpoint is named after the app (see [`App::organization`]); desktop only.
314    pub fn single_instance(mut self, on: bool) -> Self {
315        self.single_instance = on;
316        self
317    }
318
319    /// Show a tray / status-bar icon (feature `tray`; desktop only). Choosing one of its menu items
320    /// runs the command with that id, under the rules the OS menu bar follows; clicking the icon
321    /// shows and focuses the window. It appears after the first frame and goes when the app ends;
322    /// where the desktop has no tray it is skipped with a log line.
323    #[cfg(feature = "tray")]
324    pub fn tray(mut self, spec: ashlar_native::tray::TraySpec) -> Self {
325        self.tray = Some(spec);
326        self
327    }
328
329    /// Run `command` when `chord` (`"Mod+Shift+Space"`, the keymap's syntax) is pressed anywhere,
330    /// even while another app has the focus (feature `hotkey`; macOS, Windows, Linux on X11).
331    /// The window comes to the front first. A chord without Ctrl, Alt or ⌘, or one another app
332    /// holds, is skipped with a log line.
333    #[cfg(feature = "hotkey")]
334    pub fn global_shortcut(mut self, command: impl Into<String>, chord: impl Into<String>) -> Self {
335        self.global_shortcuts.push((command.into(), chord.into()));
336        self
337    }
338
339    /// Open files dropped on the window (default on when the app sets a [`App::format`] or a
340    /// [`App::file_extension`]). The first file with a known extension opens with `file.open`
341    /// (a dirty document asks to save first); the others reach the view as
342    /// [`Cx::dropped_files`](crate::Cx::dropped_files). Off leaves drops to the view's own input.
343    pub fn accept_drops(mut self, on: bool) -> Self {
344        self.accept_drops = Some(on);
345        self
346    }
347
348    /// Run a toast's action when it is clicked (default off). With it on, a clicked
349    /// [`ToastAction`](ashlar_ui::toasts::ToastAction) whose `command` is a registered command id
350    /// runs like a menu pick, with empty params, after the frame's toasts are drawn; errors become
351    /// toasts. Actions naming anything else stay queued for the view's
352    /// [`Toasts::take_actions`](ashlar_ui::toasts::Toasts::take_actions).
353    pub fn run_toast_actions(mut self, on: bool) -> Self {
354        self.toast_commands = on;
355        self
356    }
357
358    /// Change the window before it opens: a minimum size, decorations, transparency… `f` gets
359    /// the builder ashlar made (title, size, app id, the restored placement) and returns it.
360    /// Desktop only; the headless driver has no window.
361    ///
362    /// ```no_run
363    /// # use ashlar_app::App;
364    /// App::<i64>::new("notes", "0.1.0").viewport(|b| b.with_min_inner_size([480.0, 320.0]));
365    /// ```
366    pub fn viewport(mut self, f: impl FnOnce(egui::ViewportBuilder) -> egui::ViewportBuilder + 'static) -> Self {
367        let previous = self.window.viewport.take();
368        self.window.viewport = Some(Box::new(move |b| {
369            let b = match previous {
370                Some(p) => p(b),
371                None => b,
372            };
373            f(b)
374        }));
375        self
376    }
377
378    /// The window and taskbar icon, as PNG bytes (`include_bytes!("icon.png")`). An image that
379    /// does not decode is logged and the default icon stays. Desktop only.
380    pub fn icon(mut self, png: impl Into<Vec<u8>>) -> Self {
381        self.window.icon = Some(png.into());
382        self
383    }
384
385    /// Remember where the window was (default on): its position, size, maximized and full
386    /// screen state go to the settings under `"window"` when they change and on close, and the
387    /// next launch opens the window there, kept on a visible monitor. Ignored without a window
388    /// (`--headless`, [`crate::Driver`]) and without an app folder ([`App::dirs`]).
389    pub fn remember_window(mut self, on: bool) -> Self {
390        self.remember_window = on;
391        self
392    }
393
394    /// The window's own options, taken out before the app is split.
395    #[cfg_attr(target_arch = "wasm32", allow(dead_code))]
396    pub(crate) fn take_window(&mut self) -> WindowSpec {
397        std::mem::take(&mut self.window)
398    }
399
400    /// A named starting state: `setup` runs on the session (load a fixture, fill a list, set the
401    /// theme…) when the scene is applied, with `--scene NAME` (in a window or `--headless`), the
402    /// `ui.scene {name}` control method, the MCP tool `ui_scene` or [`crate::Driver::scene`].
403    /// `--list-scenes` prints the names. A name declared twice keeps the first.
404    ///
405    /// ```no_run
406    /// # use ashlar_app::{App, Session};
407    /// # #[derive(Clone, Default, serde::Serialize, serde::Deserialize)] struct Doc { notes: Vec<String> }
408    /// App::<Doc>::new("notes", "0.1.0").scene("two-notes", |s: &mut Session<Doc>| {
409    ///     s.edit("Two notes", |d| d.notes = vec!["a".into(), "b".into()]);
410    /// });
411    /// ```
412    pub fn scene(mut self, name: impl Into<String>, setup: impl Fn(&mut Session<D>) + Send + Sync + 'static) -> Self {
413        let name = name.into();
414        if self.scenes.iter().any(|(n, _)| *n == name) {
415            log::warn!("scene `{name}` is declared twice; the first one wins");
416        } else {
417            self.scenes.push((name, Arc::new(setup)));
418        }
419        self
420    }
421
422    /// The names of the app's scenes, in declaration order.
423    pub fn scene_names(&self) -> Vec<String> {
424        self.scenes.iter().map(|(n, _)| n.clone()).collect()
425    }
426
427    pub(crate) fn split(self) -> (Arc<Shared<D>>, Option<ViewFn<D>>, Option<SetupFn>) {
428        let env_prefix: String = self.name.chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_uppercase() } else { '_' }).collect();
429        let accept_drops = self.accept_drops.unwrap_or(self.format.is_some() || !self.extensions.is_empty());
430        let format = self.format.unwrap_or_else(|| Format::new(format!("{}.document", self.name), format!("{} {}", self.name, self.version)));
431        let mut specs = builtins::leading::<D>();
432        specs.extend(self.commands);
433        specs.extend(builtins::trailing::<D>());
434        let registry = Registry::new(specs);
435        for dup in registry.duplicates() {
436            log::warn!("command `{dup}` is declared twice; the first one wins");
437        }
438        let dirs = self.dirs.or_else(|| AppDirs::new(&self.qualifier, &self.org, &self.name));
439        let shared = Shared {
440            title: self.title.unwrap_or_else(|| self.name.clone()),
441            name: self.name,
442            version: self.version,
443            env_prefix,
444            registry,
445            format: Arc::new(format),
446            extensions: self.extensions,
447            dirs,
448            autosave: self.autosave,
449            telemetry: self.telemetry,
450            instructions: self.instructions,
451            window_size: self.window_size,
452            frame_latency: self.frame_latency,
453            control_port: self.control_port,
454            follow_system_accent: self.follow_system_accent,
455            motion: self.motion,
456            single_instance: self.single_instance,
457            accept_drops,
458            toast_commands: self.toast_commands,
459            remember_window: self.remember_window,
460            #[cfg(feature = "tray")]
461            tray: self.tray,
462            #[cfg(feature = "hotkey")]
463            global_shortcuts: self.global_shortcuts,
464            scenes: self.scenes,
465            canvas_id: self.canvas_id,
466        };
467        (Arc::new(shared), self.view, self.setup)
468    }
469
470    /// A session without a window, driving the same commands (tests, scripts).
471    pub fn headless(self) -> Headless<D> {
472        let (shared, _, _) = self.split();
473        Headless::new(shared)
474    }
475
476    /// The runtime without a window, for tests and benches: drive it with [`crate::Driver`]. It
477    /// ignores the OS preferences (see [`Runtime::set_preferences`]) so runs match on every machine.
478    pub fn runtime(self) -> Runtime<D> {
479        let (shared, view, setup) = self.split();
480        let mut launch = crate::runtime::Launch::default();
481        if let (Some(cfg), Some(d)) = (&shared.telemetry, &shared.dirs) {
482            launch.telemetry = crate::telemetry::Telemetry::start(cfg, &shared.name, &shared.version, &d.data, true);
483        }
484        let mut rt = Runtime::new(shared, view, setup, launch);
485        // tests and benches see the same tokens and motion on every machine
486        rt.set_preferences(crate::runtime::Preferences::default);
487        rt
488    }
489
490    /// The app's `-cli` binary: `commands`, `exec`, `run`, `mcp`, `serve`, `version`, each on a
491    /// fresh headless session (or the running app with `--connect PORT`). Add app verbs to the
492    /// returned [`ashlar_cli::Cli`] before `run()`.
493    pub fn cli(self) -> ashlar_cli::Cli {
494        let (shared, _, _) = self.split();
495        let factory = Arc::clone(&shared);
496        let mut cli = ashlar_cli::Cli::new(shared.name.clone(), shared.version.clone(), move || {
497            Box::new(Headless::new(Arc::clone(&factory))) as Box<dyn Dispatch>
498        });
499        if let Some(d) = &shared.dirs {
500            cli = cli.key_dir(d.config.clone());
501        }
502        if !shared.instructions.is_empty() {
503            cli = cli.instructions(shared.instructions.clone());
504        }
505        cli
506    }
507
508    /// Open the window and run until it closes.
509    ///
510    /// With `--headless` on the command line, run with no window instead: a virtual-clock
511    /// [`crate::Driver`] in a temporary folder that writes `--snap out.png` and `--tree tree.json`
512    /// (after `--script file.ui` and `--at SECONDS`, at `--size WxH`, `--scale S`, `--theme
513    /// light|dark`, `--scene NAME`), then exits with 0 (done), 1 (a step failed) or 2 (bad
514    /// arguments). (`--headless` needs the `headless` feature, on by default.) `--scene NAME` also
515    /// works with a window; `--list-scenes` prints the scenes.
516    pub fn run(self) -> Result<(), Error> {
517        #[cfg(not(target_arch = "wasm32"))]
518        {
519            let args: Vec<String> = std::env::args().skip(1).collect();
520            if args.iter().any(|a| a == "--list-scenes") {
521                for name in self.scene_names() {
522                    println!("{name}");
523                }
524                std::process::exit(0);
525            }
526            if let Some(name) = scene_arg(&args)
527                && !self.scenes.iter().any(|(n, _)| *n == name)
528            {
529                eprintln!("error: no scene `{name}` (scenes: {})", self.scene_names().join(", "));
530                std::process::exit(i32::from(ashlar_debug::Exit::Usage.code()));
531            }
532        }
533        #[cfg(all(feature = "headless", not(target_arch = "wasm32")))]
534        {
535            let args: Vec<String> = std::env::args().skip(1).collect();
536            if crate::headless_run::requested(&args) {
537                let code = crate::headless_run::run(self, &args);
538                std::process::exit(i32::from(code));
539            }
540        }
541        crate::runtime::run_native(self)
542    }
543}
544
545/// The scene named by `--scene NAME` (or `--scene=NAME`), if any.
546#[cfg(not(target_arch = "wasm32"))]
547fn scene_arg(args: &[String]) -> Option<String> {
548    let mut it = args.iter();
549    while let Some(a) = it.next() {
550        if a == "--scene" {
551            return it.next().cloned();
552        }
553        if let Some(v) = a.strip_prefix("--scene=") {
554            return Some(v.to_string());
555        }
556    }
557    None
558}
559
560/// `fn main() -> ExitCode { ashlar_app::cli_main(my_app::app()) }`: an app's `-cli` binary.
561pub fn cli_main<D: Document>(app: App<D>) -> std::process::ExitCode {
562    app.cli().run()
563}
564
565/// A session of an app without a window: what the CLI, MCP and tests drive. Implements
566/// [`Dispatch`]; effects that need a window are dropped (toasts are logged).
567pub struct Headless<D: Document> {
568    shared: Arc<Shared<D>>,
569    session: Session<D>,
570}
571
572impl<D: Document> Headless<D> {
573    pub(crate) fn new(shared: Arc<Shared<D>>) -> Self {
574        let session = Session::new(Arc::clone(&shared.format));
575        Headless { shared, session }
576    }
577
578    pub fn session(&self) -> &Session<D> {
579        &self.session
580    }
581
582    pub fn session_mut(&mut self) -> &mut Session<D> {
583        &mut self.session
584    }
585
586    /// The command table (built-ins and the app's).
587    pub fn registry(&self) -> &Registry<Session<D>> {
588        &self.shared.registry
589    }
590
591    /// Where the app keeps its files.
592    pub fn dirs(&self) -> Option<&AppDirs> {
593        self.shared.dirs.as_ref()
594    }
595
596    pub fn name(&self) -> &str {
597        &self.shared.name
598    }
599
600    /// Where an untitled document of this app would be saved by default.
601    pub fn default_dir(&self) -> Option<PathBuf> {
602        self.shared.dirs.as_ref().map(|d| d.data.clone())
603    }
604}
605
606impl<D: Document> Dispatch for Headless<D> {
607    fn commands(&self) -> Vec<CommandInfo> {
608        self.shared.registry.infos(&self.session)
609    }
610
611    fn execute(&mut self, id: &str, params: Value) -> Result<Value, CommandError> {
612        let r = self.shared.registry.execute(&mut self.session, id, params);
613        self.session.drain_headless();
614        r
615    }
616}