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)>;
21pub type ViewportFn = Box<dyn FnOnce(egui::ViewportBuilder) -> egui::ViewportBuilder>;
23
24#[derive(Default)]
26pub(crate) struct WindowSpec {
27 pub viewport: Option<ViewportFn>,
28 pub icon: Option<Vec<u8>>,
30}
31pub type SceneFn<D> = Arc<dyn Fn(&mut Session<D>) + Send + Sync>;
33
34#[derive(Debug, thiserror::Error)]
36pub enum Error {
37 #[error("could not open the window: {0}")]
38 Window(String),
39 #[error("no window can be shown here: {0}")]
42 NoDesktop(String),
43 #[error("{0}")]
44 Io(#[from] std::io::Error),
45}
46
47#[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 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 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 pub toast_commands: bool,
72 pub remember_window: bool,
74 #[cfg(feature = "tray")]
76 pub tray: Option<ashlar_native::tray::TraySpec>,
77 #[cfg(feature = "hotkey")]
79 pub global_shortcuts: Vec<(String, String)>,
80 pub scenes: Vec<(String, SceneFn<D>)>,
82 #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
84 pub canvas_id: Option<String>,
85}
86
87impl<D: Document> Shared<D> {
88 pub(crate) fn scene(&self, name: &str) -> Option<&SceneFn<D>> {
90 self.scenes.iter().find(|(n, _)| n == name).map(|(_, f)| f)
91 }
92
93 pub(crate) fn scene_names(&self) -> Vec<String> {
95 self.scenes.iter().map(|(n, _)| n.clone()).collect()
96 }
97}
98
99pub 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 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 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 pub fn title(mut self, title: impl Into<String>) -> Self {
205 self.title = Some(title.into());
206 self
207 }
208
209 pub fn format(mut self, format: Format<D>) -> Self {
211 self.format = Some(format);
212 self
213 }
214
215 pub fn file_extension(mut self, ext: impl Into<String>) -> Self {
217 self.extensions.push(ext.into());
218 self
219 }
220
221 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 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 pub fn setup(mut self, f: impl FnOnce(&egui::Context) + 'static) -> Self {
240 self.setup = Some(Box::new(f));
241 self
242 }
243
244 pub fn autosave(mut self, every: Option<Duration>) -> Self {
246 self.autosave = every;
247 self
248 }
249
250 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 pub fn instructions(mut self, text: impl Into<String>) -> Self {
259 self.instructions = text.into();
260 self
261 }
262
263 pub fn dirs(mut self, dirs: AppDirs) -> Self {
265 self.dirs = Some(dirs);
266 self
267 }
268
269 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 pub fn frame_latency(mut self, frames: u32) -> Self {
287 self.frame_latency = frames.clamp(1, 3);
288 self
289 }
290
291 pub fn control_port(mut self, port: u16) -> Self {
293 self.control_port = Some(port);
294 self
295 }
296
297 pub fn follow_system_accent(mut self, follow: bool) -> Self {
300 self.follow_system_accent = follow;
301 self
302 }
303
304 pub fn motion(mut self, prefs: ashlar_motion::MotionPrefs) -> Self {
307 self.motion = Some(prefs);
308 self
309 }
310
311 pub fn single_instance(mut self, on: bool) -> Self {
315 self.single_instance = on;
316 self
317 }
318
319 #[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 #[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 pub fn accept_drops(mut self, on: bool) -> Self {
344 self.accept_drops = Some(on);
345 self
346 }
347
348 pub fn run_toast_actions(mut self, on: bool) -> Self {
354 self.toast_commands = on;
355 self
356 }
357
358 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 pub fn icon(mut self, png: impl Into<Vec<u8>>) -> Self {
381 self.window.icon = Some(png.into());
382 self
383 }
384
385 pub fn remember_window(mut self, on: bool) -> Self {
390 self.remember_window = on;
391 self
392 }
393
394 #[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 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 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 pub fn headless(self) -> Headless<D> {
472 let (shared, _, _) = self.split();
473 Headless::new(shared)
474 }
475
476 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 rt.set_preferences(crate::runtime::Preferences::default);
487 rt
488 }
489
490 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 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#[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
560pub fn cli_main<D: Document>(app: App<D>) -> std::process::ExitCode {
562 app.cli().run()
563}
564
565pub 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 pub fn registry(&self) -> &Registry<Session<D>> {
588 &self.shared.registry
589 }
590
591 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 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}