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
22/// Why the app could not start.
23#[derive(Debug, thiserror::Error)]
24pub enum Error {
25    #[error("could not open the window: {0}")]
26    Window(String),
27    #[error("{0}")]
28    Io(#[from] std::io::Error),
29}
30
31/// What everything built from one [`App`] shares (immutable).
32#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
33pub(crate) struct Shared<D: Document> {
34    pub name: String,
35    pub version: String,
36    pub title: String,
37    /// `NOTES` for an app called `notes`: `<PREFIX>_CONTROL_PORT`, `<PREFIX>_LOG`.
38    pub env_prefix: String,
39    pub registry: Registry<Session<D>>,
40    pub format: Arc<Format<D>>,
41    pub extensions: Vec<String>,
42    pub dirs: Option<AppDirs>,
43    pub autosave: Option<Duration>,
44    pub telemetry: Option<TelemetryConfig>,
45    pub instructions: String,
46    pub window_size: [f32; 2],
47    pub control_port: Option<u16>,
48}
49
50/// An app: name, document type and format, commands, views. `run()` opens the window; the same
51/// definition serves the CLI and MCP ([`App::cli`]) and tests ([`App::headless`]).
52///
53/// ```no_run
54/// use ashlar_app::{App, Cx, Session};
55/// use ashlar_command::{CommandError, CommandSpec};
56///
57/// #[derive(Clone, Default, serde::Serialize, serde::Deserialize)]
58/// struct Doc { count: i64 }
59///
60/// fn bump(s: &mut Session<Doc>, _: &serde_json::Value) -> Result<serde_json::Value, CommandError> {
61///     s.edit("Bump", |d| d.count += 1);
62///     Ok(serde_json::Value::Null)
63/// }
64///
65/// fn main() -> Result<(), ashlar_app::Error> {
66///     App::<Doc>::new("counter", "0.1.0")
67///         .command(CommandSpec::new("count.bump", "Bump", bump).menu(&["Edit"]).shortcut("Mod+B"))
68///         .view(|ui, cx: &mut Cx<'_, Doc>| {
69///             ui.label(format!("{}", cx.doc().count));
70///             if ui.button("Bump").clicked() {
71///                 cx.dispatch("count.bump", serde_json::json!({}));
72///             }
73///         })
74///         .run()
75/// }
76/// ```
77pub struct App<D: Document> {
78    name: String,
79    version: String,
80    title: Option<String>,
81    qualifier: String,
82    org: String,
83    format: Option<Format<D>>,
84    extensions: Vec<String>,
85    commands: Vec<CommandSpec<Session<D>>>,
86    view: Option<ViewFn<D>>,
87    setup: Option<SetupFn>,
88    autosave: Option<Duration>,
89    telemetry: Option<TelemetryConfig>,
90    instructions: String,
91    dirs: Option<AppDirs>,
92    window_size: [f32; 2],
93    control_port: Option<u16>,
94}
95
96impl<D: Document> App<D> {
97    /// An app called `name` (lower case, used for folders, env vars and the CLI).
98    pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
99        App {
100            name: name.into(),
101            version: version.into(),
102            title: None,
103            qualifier: "org".into(),
104            org: "ashlar".into(),
105            format: None,
106            extensions: Vec::new(),
107            commands: Vec::new(),
108            view: None,
109            setup: None,
110            autosave: Some(Duration::from_secs(60)),
111            telemetry: None,
112            instructions: String::new(),
113            dirs: None,
114            window_size: [960.0, 640.0],
115            control_port: None,
116        }
117    }
118
119    /// Reverse-domain qualifier and organisation for the OS folders (`"com", "Acme"`).
120    pub fn organization(mut self, qualifier: impl Into<String>, org: impl Into<String>) -> Self {
121        self.qualifier = qualifier.into();
122        self.org = org.into();
123        self
124    }
125
126    /// Window title (default: the name).
127    pub fn title(mut self, title: impl Into<String>) -> Self {
128        self.title = Some(title.into());
129        self
130    }
131
132    /// The document's file format, with its migrations.
133    pub fn format(mut self, format: Format<D>) -> Self {
134        self.format = Some(format);
135        self
136    }
137
138    /// A file extension offered by Open and Save dialogs (without the dot).
139    pub fn file_extension(mut self, ext: impl Into<String>) -> Self {
140        self.extensions.push(ext.into());
141        self
142    }
143
144    /// Add commands (menus follow declaration order).
145    pub fn commands(mut self, specs: impl IntoIterator<Item = CommandSpec<Session<D>>>) -> Self {
146        self.commands.extend(specs);
147        self
148    }
149
150    pub fn command(mut self, spec: CommandSpec<Session<D>>) -> Self {
151        self.commands.push(spec);
152        self
153    }
154
155    /// The UI, drawn inside the window's central area each frame.
156    pub fn view(mut self, view: impl FnMut(&mut egui::Ui, &mut Cx<'_, D>) + 'static) -> Self {
157        self.view = Some(Box::new(view));
158        self
159    }
160
161    /// Runs once before the first frame (install fonts, tokens).
162    pub fn setup(mut self, f: impl FnOnce(&egui::Context) + 'static) -> Self {
163        self.setup = Some(Box::new(f));
164        self
165    }
166
167    /// Autosave interval (`None`: no autosave or recovery). Default 60 s.
168    pub fn autosave(mut self, every: Option<Duration>) -> Self {
169        self.autosave = every;
170        self
171    }
172
173    /// Telemetry (with the `telemetry` feature): write key and server. Always on when `endpoint`
174    /// is set (no notice, no switches); `TRACER_DISABLED` in the environment turns it off.
175    pub fn telemetry(mut self, key: impl Into<String>, endpoint: Option<&str>) -> Self {
176        self.telemetry = Some(TelemetryConfig { key: key.into(), endpoint: endpoint.map(str::to_string) });
177        self
178    }
179
180    /// What MCP clients are told about the app.
181    pub fn instructions(mut self, text: impl Into<String>) -> Self {
182        self.instructions = text.into();
183        self
184    }
185
186    /// Put config, data, cache and logs here instead of the OS folders (tests, portable).
187    pub fn dirs(mut self, dirs: AppDirs) -> Self {
188        self.dirs = Some(dirs);
189        self
190    }
191
192    pub fn window_size(mut self, width: f32, height: f32) -> Self {
193        self.window_size = [width, height];
194        self
195    }
196
197    /// Serve the control port on this port (also `--control <port>` and `<APP>_CONTROL_PORT`).
198    pub fn control_port(mut self, port: u16) -> Self {
199        self.control_port = Some(port);
200        self
201    }
202
203    pub(crate) fn split(self) -> (Arc<Shared<D>>, Option<ViewFn<D>>, Option<SetupFn>) {
204        let env_prefix: String = self.name.chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_uppercase() } else { '_' }).collect();
205        let format = self.format.unwrap_or_else(|| Format::new(format!("{}.document", self.name), format!("{} {}", self.name, self.version)));
206        let mut specs = builtins::leading::<D>();
207        specs.extend(self.commands);
208        specs.extend(builtins::trailing::<D>());
209        let registry = Registry::new(specs);
210        for dup in registry.duplicates() {
211            log::warn!("command `{dup}` is declared twice; the first one wins");
212        }
213        let dirs = self.dirs.or_else(|| AppDirs::new(&self.qualifier, &self.org, &self.name));
214        let shared = Shared {
215            title: self.title.unwrap_or_else(|| self.name.clone()),
216            name: self.name,
217            version: self.version,
218            env_prefix,
219            registry,
220            format: Arc::new(format),
221            extensions: self.extensions,
222            dirs,
223            autosave: self.autosave,
224            telemetry: self.telemetry,
225            instructions: self.instructions,
226            window_size: self.window_size,
227            control_port: self.control_port,
228        };
229        (Arc::new(shared), self.view, self.setup)
230    }
231
232    /// A session without a window, driving the same commands (tests, scripts).
233    pub fn headless(self) -> Headless<D> {
234        let (shared, _, _) = self.split();
235        Headless::new(shared)
236    }
237
238    /// The runtime without a window, for tests and benches: drive it with [`crate::Driver`].
239    pub fn runtime(self) -> Runtime<D> {
240        let (shared, view, setup) = self.split();
241        let mut launch = crate::runtime::Launch::default();
242        if let (Some(cfg), Some(d)) = (&shared.telemetry, &shared.dirs) {
243            launch.telemetry = crate::telemetry::Telemetry::start(cfg, &shared.name, &shared.version, &d.data, true);
244        }
245        Runtime::new(shared, view, setup, launch)
246    }
247
248    /// The app's `-cli` binary: `commands`, `exec`, `run`, `mcp`, `serve`, `version`, each on a
249    /// fresh headless session (or the running app with `--connect PORT`). Add app verbs to the
250    /// returned [`ashlar_cli::Cli`] before `run()`.
251    pub fn cli(self) -> ashlar_cli::Cli {
252        let (shared, _, _) = self.split();
253        let factory = Arc::clone(&shared);
254        let mut cli = ashlar_cli::Cli::new(shared.name.clone(), shared.version.clone(), move || {
255            Box::new(Headless::new(Arc::clone(&factory))) as Box<dyn Dispatch>
256        });
257        if let Some(d) = &shared.dirs {
258            cli = cli.key_dir(d.config.clone());
259        }
260        if !shared.instructions.is_empty() {
261            cli = cli.instructions(shared.instructions.clone());
262        }
263        cli
264    }
265
266    /// Open the window and run until it closes.
267    pub fn run(self) -> Result<(), Error> {
268        crate::runtime::run_native(self)
269    }
270}
271
272/// `fn main() -> ExitCode { ashlar_app::cli_main(my_app::app()) }`: an app's `-cli` binary.
273pub fn cli_main<D: Document>(app: App<D>) -> std::process::ExitCode {
274    app.cli().run()
275}
276
277/// A session of an app without a window: what the CLI, MCP and tests drive. Implements
278/// [`Dispatch`]; effects that need a window are dropped (toasts are logged).
279pub struct Headless<D: Document> {
280    shared: Arc<Shared<D>>,
281    session: Session<D>,
282}
283
284impl<D: Document> Headless<D> {
285    pub(crate) fn new(shared: Arc<Shared<D>>) -> Self {
286        let session = Session::new(Arc::clone(&shared.format));
287        Headless { shared, session }
288    }
289
290    pub fn session(&self) -> &Session<D> {
291        &self.session
292    }
293
294    pub fn session_mut(&mut self) -> &mut Session<D> {
295        &mut self.session
296    }
297
298    /// The command table (built-ins and the app's).
299    pub fn registry(&self) -> &Registry<Session<D>> {
300        &self.shared.registry
301    }
302
303    /// Where the app keeps its files.
304    pub fn dirs(&self) -> Option<&AppDirs> {
305        self.shared.dirs.as_ref()
306    }
307
308    pub fn name(&self) -> &str {
309        &self.shared.name
310    }
311
312    /// Where an untitled document of this app would be saved by default.
313    pub fn default_dir(&self) -> Option<PathBuf> {
314        self.shared.dirs.as_ref().map(|d| d.data.clone())
315    }
316}
317
318impl<D: Document> Dispatch for Headless<D> {
319    fn commands(&self) -> Vec<CommandInfo> {
320        self.shared.registry.infos(&self.session)
321    }
322
323    fn execute(&mut self, id: &str, params: Value) -> Result<Value, CommandError> {
324        let r = self.shared.registry.execute(&mut self.session, id, params);
325        self.session.drain_headless();
326        r
327    }
328}