1use 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
17pub type ViewFn<D> = Box<dyn FnMut(&mut egui::Ui, &mut Cx<'_, D>)>;
19pub type SetupFn = Box<dyn FnOnce(&egui::Context)>;
21
22#[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#[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 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
50pub 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 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 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 pub fn title(mut self, title: impl Into<String>) -> Self {
128 self.title = Some(title.into());
129 self
130 }
131
132 pub fn format(mut self, format: Format<D>) -> Self {
134 self.format = Some(format);
135 self
136 }
137
138 pub fn file_extension(mut self, ext: impl Into<String>) -> Self {
140 self.extensions.push(ext.into());
141 self
142 }
143
144 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 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 pub fn setup(mut self, f: impl FnOnce(&egui::Context) + 'static) -> Self {
163 self.setup = Some(Box::new(f));
164 self
165 }
166
167 pub fn autosave(mut self, every: Option<Duration>) -> Self {
169 self.autosave = every;
170 self
171 }
172
173 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 pub fn instructions(mut self, text: impl Into<String>) -> Self {
182 self.instructions = text.into();
183 self
184 }
185
186 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 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 pub fn headless(self) -> Headless<D> {
234 let (shared, _, _) = self.split();
235 Headless::new(shared)
236 }
237
238 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 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 pub fn run(self) -> Result<(), Error> {
268 crate::runtime::run_native(self)
269 }
270}
271
272pub fn cli_main<D: Document>(app: App<D>) -> std::process::ExitCode {
274 app.cli().run()
275}
276
277pub 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 pub fn registry(&self) -> &Registry<Session<D>> {
300 &self.shared.registry
301 }
302
303 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 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}