Files
gridcraft/apps/gridcraft/src/main.rs
T

211 lines
7.6 KiB
Rust

//! GridCraft desktop app.
//!
//! Usage: `gridcraft [--control <port>] [--sample <name>] [files…]`
//!
//! `--control <port>` (or `GRIDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control
//! server: `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
//! See `gridcraft_ui_egui::control` and `docs/control-protocol.md` for the methods.
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod control_server;
#[cfg(target_os = "macos")]
mod native_menu;
use gridcraft_engine::Session;
use gridcraft_ui_egui::{Services, SheetApp};
use serde_json::json;
struct App(SheetApp, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
#[cfg(target_os = "macos")]
{
if self.1.is_none() && std::env::var_os("GRIDCRAFT_NO_NATIVE_MENU").is_none() {
self.1 = Some(native_menu::NativeMenu::install(ctx));
}
if let Some(m) = &self.1 {
m.poll(&mut self.0, ctx);
}
}
self.0.logic(ctx);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
// Files dropped on the window open as workbooks.
for f in std::mem::take(&mut raw.dropped_files) {
if let Some(p) = f.path().to_str().map(str::to_string).filter(|p| !p.is_empty()) {
self.0.open_path(&p);
}
}
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.0.ui(ui);
}
fn on_exit(&mut self) {
save_prefs(&self.0);
}
}
fn config_dir() -> Option<std::path::PathBuf> {
if cfg!(target_os = "macos") {
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/GridCraft"))
} else if cfg!(windows) {
std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("GridCraft"))
} else {
std::env::var_os("XDG_CONFIG_HOME")
.map(std::path::PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
.map(|c| c.join("gridcraft"))
}
}
fn load_prefs(app: &mut SheetApp) {
if std::env::var_os("GRIDCRAFT_NO_PREFS").is_some() {
return;
}
let Some(dir) = config_dir() else { return };
if let Ok(b) = std::fs::read(dir.join("ui.json"))
&& let Ok(ui) = serde_json::from_slice(&b)
{
app.ui = ui;
}
if let Ok(b) = std::fs::read(dir.join("prefs.json"))
&& let Ok(p) = serde_json::from_slice(&b)
{
app.session.prefs = p;
}
}
fn save_prefs(app: &SheetApp) {
if std::env::var_os("GRIDCRAFT_NO_PREFS").is_some() {
return;
}
let Some(dir) = config_dir() else { return };
let _ = std::fs::create_dir_all(&dir);
if let Ok(b) = serde_json::to_vec_pretty(&app.ui) {
let _ = std::fs::write(dir.join("ui.json"), b);
}
if let Ok(b) = serde_json::to_vec_pretty(&app.session.prefs) {
let _ = std::fs::write(dir.join("prefs.json"), b);
}
}
fn services() -> Services {
Services {
pick_open: Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter("Spreadsheets", &["xlsx", "xlsm", "ods", "csv", "tsv", "txt", "json"])
.add_filter("Excel Workbook", &["xlsx", "xlsm"])
.add_filter("OpenDocument Spreadsheet (data import)", &["ods"])
.add_filter("CSV", &["csv"])
.pick_file()
.and_then(|p| p.to_str().map(str::to_string))
})),
pick_save: Some(Box::new(|suggested: &str| {
let stem = std::path::Path::new(suggested).file_stem().and_then(|s| s.to_str()).unwrap_or("Book1").to_string();
rfd::FileDialog::new()
.set_file_name(format!("{stem}.xlsx"))
.add_filter("Excel Workbook (.xlsx)", &["xlsx"])
.add_filter("CSV UTF-8 (.csv)", &["csv"])
.add_filter("Web Page (.html)", &["html"])
.save_file()
.and_then(|p| p.to_str().map(str::to_string))
})),
open_url: Some(Box::new(|url: &str| {
let cmd = if cfg!(target_os = "macos") {
"open"
} else if cfg!(windows) {
"explorer"
} else {
"xdg-open"
};
let _ = std::process::Command::new(cmd).arg(url).spawn();
})),
..Default::default()
}
}
fn icon() -> Option<egui::IconData> {
let bytes = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.gridcraft.png");
let img = image::load_from_memory(bytes).ok()?.to_rgba8();
Some(egui::IconData { width: img.width(), height: img.height(), rgba: img.into_raw() })
}
fn main() -> eframe::Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.iter().any(|a| a == "--version" || a == "-V") {
println!("GridCraft {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
let mut control_port: Option<u16> = std::env::var("GRIDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut sample: Option<String> = None;
let mut files: Vec<String> = Vec::new();
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--control" => {
i += 1;
control_port = args.get(i).and_then(|p| p.parse().ok());
}
"--sample" => {
i += 1;
sample = Some(args.get(i).cloned().unwrap_or_else(|| "sales".into()));
}
a if a.starts_with("-psn_") => {}
a => files.push(a.to_string()),
}
i += 1;
}
let mut session = Session::new();
if let Some(s) = &sample
&& let Err(e) = session.execute("file.new", json!({"sample": s}))
{
eprintln!("sample: {e}");
}
for f in &files {
if let Err(e) = session.execute("file.open", json!({"path": f})) {
eprintln!("{f}: {e}");
}
}
if session.documents().is_empty() {
session.new_workbook();
}
let mut app = SheetApp::new(session, services());
app.after_engine(); // Show warnings from files opened on the command line.
let control_port = control_port;
load_prefs(&mut app);
let mut viewport = egui::ViewportBuilder::default()
.with_title("GridCraft")
.with_inner_size([1440.0, 900.0])
.with_min_inner_size([640.0, 420.0])
.with_app_id("ai.storyteller.gridcraft")
.with_drag_and_drop(true);
if cfg!(target_os = "macos") {
viewport = viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false);
}
if let Some(i) = icon() {
viewport = viewport.with_icon(i);
}
let options = eframe::NativeOptions { viewport, ..Default::default() };
eframe::run_native(
"GridCraft",
options,
Box::new(move |cc| {
SheetApp::setup_context(&cc.egui_ctx, app.ui.dark);
if let Some(port) = control_port {
match control_server::start(port, cc.egui_ctx.clone()) {
Ok(rx) => app.control_rx = Some(rx),
Err(e) => eprintln!("control channel on port {port}: {e}"),
}
}
Ok(Box::new(App(
app,
#[cfg(target_os = "macos")]
None,
)))
}),
)
}