use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use ashlar_command::{CommandError, CommandInfo, CommandSpec, Dispatch, Registry};
use ashlar_fs::AppDirs;
use ashlar_store::Format;
use serde_json::Value;
use crate::builtins;
use crate::runtime::{Cx, Runtime};
use crate::session::{Document, Session};
use crate::telemetry::TelemetryConfig;
pub type ViewFn<D> = Box<dyn FnMut(&mut egui::Ui, &mut Cx<'_, D>)>;
pub type SetupFn = Box<dyn FnOnce(&egui::Context)>;
pub type ViewportFn = Box<dyn FnOnce(egui::ViewportBuilder) -> egui::ViewportBuilder>;
#[derive(Default)]
pub(crate) struct WindowSpec {
pub viewport: Option<ViewportFn>,
pub icon: Option<Vec<u8>>,
}
pub type SceneFn<D> = Arc<dyn Fn(&mut Session<D>) + Send + Sync>;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("could not open the window: {0}")]
Window(String),
#[error("no window can be shown here: {0}")]
NoDesktop(String),
#[error("{0}")]
Io(#[from] std::io::Error),
}
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub(crate) struct Shared<D: Document> {
pub name: String,
pub version: String,
pub title: String,
pub env_prefix: String,
pub registry: Registry<Session<D>>,
pub format: Arc<Format<D>>,
pub extensions: Vec<String>,
pub dirs: Option<AppDirs>,
pub autosave: Option<Duration>,
pub telemetry: Option<TelemetryConfig>,
pub instructions: String,
pub window_size: [f32; 2],
pub frame_latency: u32,
pub control_port: Option<u16>,
pub follow_system_accent: bool,
pub motion: Option<ashlar_motion::MotionPrefs>,
pub single_instance: bool,
pub accept_drops: bool,
pub toast_commands: bool,
pub remember_window: bool,
#[cfg(feature = "tray")]
pub tray: Option<ashlar_native::tray::TraySpec>,
#[cfg(feature = "hotkey")]
pub global_shortcuts: Vec<(String, String)>,
pub scenes: Vec<(String, SceneFn<D>)>,
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub canvas_id: Option<String>,
}
impl<D: Document> Shared<D> {
pub(crate) fn scene(&self, name: &str) -> Option<&SceneFn<D>> {
self.scenes.iter().find(|(n, _)| n == name).map(|(_, f)| f)
}
pub(crate) fn scene_names(&self) -> Vec<String> {
self.scenes.iter().map(|(n, _)| n.clone()).collect()
}
}
pub struct App<D: Document> {
name: String,
version: String,
title: Option<String>,
qualifier: String,
org: String,
format: Option<Format<D>>,
extensions: Vec<String>,
commands: Vec<CommandSpec<Session<D>>>,
view: Option<ViewFn<D>>,
setup: Option<SetupFn>,
autosave: Option<Duration>,
telemetry: Option<TelemetryConfig>,
instructions: String,
dirs: Option<AppDirs>,
window_size: [f32; 2],
frame_latency: u32,
control_port: Option<u16>,
follow_system_accent: bool,
motion: Option<ashlar_motion::MotionPrefs>,
single_instance: bool,
accept_drops: Option<bool>,
toast_commands: bool,
remember_window: bool,
#[cfg(feature = "tray")]
tray: Option<ashlar_native::tray::TraySpec>,
#[cfg(feature = "hotkey")]
global_shortcuts: Vec<(String, String)>,
window: WindowSpec,
scenes: Vec<(String, SceneFn<D>)>,
canvas_id: Option<String>,
}
impl<D: Document> App<D> {
pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
App {
name: name.into(),
version: version.into(),
title: None,
qualifier: "org".into(),
org: "ashlar".into(),
format: None,
extensions: Vec::new(),
commands: Vec::new(),
view: None,
setup: None,
autosave: Some(Duration::from_secs(60)),
telemetry: None,
instructions: String::new(),
dirs: None,
window_size: [960.0, 640.0],
frame_latency: 1,
control_port: None,
follow_system_accent: true,
motion: None,
single_instance: false,
accept_drops: None,
toast_commands: false,
remember_window: true,
#[cfg(feature = "tray")]
tray: None,
#[cfg(feature = "hotkey")]
global_shortcuts: Vec::new(),
window: WindowSpec::default(),
scenes: Vec::new(),
canvas_id: None,
}
}
pub fn organization(mut self, qualifier: impl Into<String>, org: impl Into<String>) -> Self {
self.qualifier = qualifier.into();
self.org = org.into();
self
}
pub fn title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn format(mut self, format: Format<D>) -> Self {
self.format = Some(format);
self
}
pub fn file_extension(mut self, ext: impl Into<String>) -> Self {
self.extensions.push(ext.into());
self
}
pub fn commands(mut self, specs: impl IntoIterator<Item = CommandSpec<Session<D>>>) -> Self {
self.commands.extend(specs);
self
}
pub fn command(mut self, spec: CommandSpec<Session<D>>) -> Self {
self.commands.push(spec);
self
}
pub fn view(mut self, view: impl FnMut(&mut egui::Ui, &mut Cx<'_, D>) + 'static) -> Self {
self.view = Some(Box::new(view));
self
}
pub fn setup(mut self, f: impl FnOnce(&egui::Context) + 'static) -> Self {
self.setup = Some(Box::new(f));
self
}
pub fn autosave(mut self, every: Option<Duration>) -> Self {
self.autosave = every;
self
}
pub fn telemetry(mut self, key: impl Into<String>, endpoint: Option<&str>) -> Self {
self.telemetry = Some(TelemetryConfig { key: key.into(), endpoint: endpoint.map(str::to_string) });
self
}
pub fn instructions(mut self, text: impl Into<String>) -> Self {
self.instructions = text.into();
self
}
pub fn dirs(mut self, dirs: AppDirs) -> Self {
self.dirs = Some(dirs);
self
}
pub fn canvas_id(mut self, id: impl Into<String>) -> Self {
self.canvas_id = Some(id.into());
self
}
pub fn window_size(mut self, width: f32, height: f32) -> Self {
self.window_size = [width, height];
self
}
pub fn frame_latency(mut self, frames: u32) -> Self {
self.frame_latency = frames.clamp(1, 3);
self
}
pub fn control_port(mut self, port: u16) -> Self {
self.control_port = Some(port);
self
}
pub fn follow_system_accent(mut self, follow: bool) -> Self {
self.follow_system_accent = follow;
self
}
pub fn motion(mut self, prefs: ashlar_motion::MotionPrefs) -> Self {
self.motion = Some(prefs);
self
}
pub fn single_instance(mut self, on: bool) -> Self {
self.single_instance = on;
self
}
#[cfg(feature = "tray")]
pub fn tray(mut self, spec: ashlar_native::tray::TraySpec) -> Self {
self.tray = Some(spec);
self
}
#[cfg(feature = "hotkey")]
pub fn global_shortcut(mut self, command: impl Into<String>, chord: impl Into<String>) -> Self {
self.global_shortcuts.push((command.into(), chord.into()));
self
}
pub fn accept_drops(mut self, on: bool) -> Self {
self.accept_drops = Some(on);
self
}
pub fn run_toast_actions(mut self, on: bool) -> Self {
self.toast_commands = on;
self
}
pub fn viewport(mut self, f: impl FnOnce(egui::ViewportBuilder) -> egui::ViewportBuilder + 'static) -> Self {
let previous = self.window.viewport.take();
self.window.viewport = Some(Box::new(move |b| {
let b = match previous {
Some(p) => p(b),
None => b,
};
f(b)
}));
self
}
pub fn icon(mut self, png: impl Into<Vec<u8>>) -> Self {
self.window.icon = Some(png.into());
self
}
pub fn remember_window(mut self, on: bool) -> Self {
self.remember_window = on;
self
}
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub(crate) fn take_window(&mut self) -> WindowSpec {
std::mem::take(&mut self.window)
}
pub fn scene(mut self, name: impl Into<String>, setup: impl Fn(&mut Session<D>) + Send + Sync + 'static) -> Self {
let name = name.into();
if self.scenes.iter().any(|(n, _)| *n == name) {
log::warn!("scene `{name}` is declared twice; the first one wins");
} else {
self.scenes.push((name, Arc::new(setup)));
}
self
}
pub fn scene_names(&self) -> Vec<String> {
self.scenes.iter().map(|(n, _)| n.clone()).collect()
}
pub(crate) fn split(self) -> (Arc<Shared<D>>, Option<ViewFn<D>>, Option<SetupFn>) {
let env_prefix: String = self.name.chars().map(|c| if c.is_ascii_alphanumeric() { c.to_ascii_uppercase() } else { '_' }).collect();
let accept_drops = self.accept_drops.unwrap_or(self.format.is_some() || !self.extensions.is_empty());
let format = self.format.unwrap_or_else(|| Format::new(format!("{}.document", self.name), format!("{} {}", self.name, self.version)));
let mut specs = builtins::leading::<D>();
specs.extend(self.commands);
specs.extend(builtins::trailing::<D>());
let registry = Registry::new(specs);
for dup in registry.duplicates() {
log::warn!("command `{dup}` is declared twice; the first one wins");
}
let dirs = self.dirs.or_else(|| AppDirs::new(&self.qualifier, &self.org, &self.name));
let shared = Shared {
title: self.title.unwrap_or_else(|| self.name.clone()),
name: self.name,
version: self.version,
env_prefix,
registry,
format: Arc::new(format),
extensions: self.extensions,
dirs,
autosave: self.autosave,
telemetry: self.telemetry,
instructions: self.instructions,
window_size: self.window_size,
frame_latency: self.frame_latency,
control_port: self.control_port,
follow_system_accent: self.follow_system_accent,
motion: self.motion,
single_instance: self.single_instance,
accept_drops,
toast_commands: self.toast_commands,
remember_window: self.remember_window,
#[cfg(feature = "tray")]
tray: self.tray,
#[cfg(feature = "hotkey")]
global_shortcuts: self.global_shortcuts,
scenes: self.scenes,
canvas_id: self.canvas_id,
};
(Arc::new(shared), self.view, self.setup)
}
pub fn headless(self) -> Headless<D> {
let (shared, _, _) = self.split();
Headless::new(shared)
}
pub fn runtime(self) -> Runtime<D> {
let (shared, view, setup) = self.split();
let mut launch = crate::runtime::Launch::default();
if let (Some(cfg), Some(d)) = (&shared.telemetry, &shared.dirs) {
launch.telemetry = crate::telemetry::Telemetry::start(cfg, &shared.name, &shared.version, &d.data, true);
}
let mut rt = Runtime::new(shared, view, setup, launch);
rt.set_preferences(crate::runtime::Preferences::default);
rt
}
pub fn cli(self) -> ashlar_cli::Cli {
let (shared, _, _) = self.split();
let factory = Arc::clone(&shared);
let mut cli = ashlar_cli::Cli::new(shared.name.clone(), shared.version.clone(), move || {
Box::new(Headless::new(Arc::clone(&factory))) as Box<dyn Dispatch>
});
if let Some(d) = &shared.dirs {
cli = cli.key_dir(d.config.clone());
}
if !shared.instructions.is_empty() {
cli = cli.instructions(shared.instructions.clone());
}
cli
}
pub fn run(self) -> Result<(), Error> {
#[cfg(not(target_arch = "wasm32"))]
{
let args: Vec<String> = std::env::args().skip(1).collect();
if args.iter().any(|a| a == "--list-scenes") {
for name in self.scene_names() {
println!("{name}");
}
std::process::exit(0);
}
if let Some(name) = scene_arg(&args)
&& !self.scenes.iter().any(|(n, _)| *n == name)
{
eprintln!("error: no scene `{name}` (scenes: {})", self.scene_names().join(", "));
std::process::exit(i32::from(ashlar_debug::Exit::Usage.code()));
}
}
#[cfg(all(feature = "headless", not(target_arch = "wasm32")))]
{
let args: Vec<String> = std::env::args().skip(1).collect();
if crate::headless_run::requested(&args) {
let code = crate::headless_run::run(self, &args);
std::process::exit(i32::from(code));
}
}
crate::runtime::run_native(self)
}
}
#[cfg(not(target_arch = "wasm32"))]
fn scene_arg(args: &[String]) -> Option<String> {
let mut it = args.iter();
while let Some(a) = it.next() {
if a == "--scene" {
return it.next().cloned();
}
if let Some(v) = a.strip_prefix("--scene=") {
return Some(v.to_string());
}
}
None
}
pub fn cli_main<D: Document>(app: App<D>) -> std::process::ExitCode {
app.cli().run()
}
pub struct Headless<D: Document> {
shared: Arc<Shared<D>>,
session: Session<D>,
}
impl<D: Document> Headless<D> {
pub(crate) fn new(shared: Arc<Shared<D>>) -> Self {
let session = Session::new(Arc::clone(&shared.format));
Headless { shared, session }
}
pub fn session(&self) -> &Session<D> {
&self.session
}
pub fn session_mut(&mut self) -> &mut Session<D> {
&mut self.session
}
pub fn registry(&self) -> &Registry<Session<D>> {
&self.shared.registry
}
pub fn dirs(&self) -> Option<&AppDirs> {
self.shared.dirs.as_ref()
}
pub fn name(&self) -> &str {
&self.shared.name
}
pub fn default_dir(&self) -> Option<PathBuf> {
self.shared.dirs.as_ref().map(|d| d.data.clone())
}
}
impl<D: Document> Dispatch for Headless<D> {
fn commands(&self) -> Vec<CommandInfo> {
self.shared.registry.infos(&self.session)
}
fn execute(&mut self, id: &str, params: Value) -> Result<Value, CommandError> {
let r = self.shared.registry.execute(&mut self.session, id, params);
self.session.drain_headless();
r
}
}