209 lines
8.5 KiB
Rust
209 lines
8.5 KiB
Rust
//! VectorCraft desktop app.
|
|
//!
|
|
//! Usage: `vectorcraft [--control <port>] [files…]`
|
|
//!
|
|
//! `--control <port>` (or `VECTORCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
|
|
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
|
|
//! See `vectorcraft_ui_egui::control` for the methods.
|
|
|
|
mod control_server;
|
|
#[cfg(target_os = "macos")]
|
|
mod native_menu;
|
|
|
|
use vectorcraft_engine::Session;
|
|
use vectorcraft_engine::cmd::fileio;
|
|
use vectorcraft_ui_egui::{Services, VectorcraftApp};
|
|
|
|
struct App(VectorcraftApp, #[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("VECTORCRAFT_NO_NATIVE_MENU").is_none() {
|
|
self.1 = Some(native_menu::NativeMenu::install(&mut self.0));
|
|
}
|
|
if let Some(m) = &mut self.1 {
|
|
m.poll(&mut self.0);
|
|
}
|
|
}
|
|
self.0.logic(ctx);
|
|
if self.0.ui.status == "quit" {
|
|
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
|
}
|
|
}
|
|
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
|
self.0.raw_input_hook(raw);
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// Where UI preferences live: ~/Library/Application Support/VectorCraft (macOS),
|
|
/// %APPDATA%\VectorCraft (Windows), $XDG_CONFIG_HOME or ~/.config/vectorcraft (Linux).
|
|
fn prefs_path() -> Option<std::path::PathBuf> {
|
|
prefs_path_for("VectorCraft", "vectorcraft")
|
|
}
|
|
|
|
/// The same place under the project's former name (DrawCraft): read once if there are no
|
|
/// VectorCraft preferences yet, so settings survive the rename.
|
|
fn legacy_prefs_path() -> Option<std::path::PathBuf> {
|
|
prefs_path_for("DrawCraft", "drawcraft")
|
|
}
|
|
|
|
fn prefs_path_for(name: &str, lower: &str) -> Option<std::path::PathBuf> {
|
|
let base = if cfg!(target_os = "macos") {
|
|
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support").join(name))
|
|
} else if cfg!(windows) {
|
|
std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join(name))
|
|
} 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(lower))
|
|
};
|
|
base.map(|b| b.join("ui.json"))
|
|
}
|
|
|
|
fn load_prefs(app: &mut VectorcraftApp) {
|
|
if std::env::var_os("VECTORCRAFT_NO_PREFS").is_some() {
|
|
return;
|
|
}
|
|
let bytes = prefs_path().and_then(|p| std::fs::read(p).ok()).or_else(|| legacy_prefs_path().and_then(|p| std::fs::read(p).ok()));
|
|
if let Some(bytes) = bytes
|
|
&& let Ok(ui) = serde_json::from_slice::<vectorcraft_ui_egui::UiState>(&bytes)
|
|
{
|
|
app.ui = ui.sanitized();
|
|
}
|
|
vectorcraft_ui_egui::prefs_dialog::restore(app);
|
|
}
|
|
|
|
fn save_prefs(app: &VectorcraftApp) {
|
|
if std::env::var_os("VECTORCRAFT_NO_PREFS").is_some() {
|
|
return;
|
|
}
|
|
if let Some(p) = prefs_path() {
|
|
let _ = std::fs::create_dir_all(p.parent().unwrap_or(std::path::Path::new(".")));
|
|
let mut ui = app.ui.clone();
|
|
ui.engine_prefs = app.session.prefs.to_json();
|
|
if let Ok(bytes) = serde_json::to_vec_pretty(&ui) {
|
|
let _ = std::fs::write(p, bytes);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn services() -> Services {
|
|
Services {
|
|
pick_open: Some(Box::new(|| {
|
|
fileio::open_filters()
|
|
.fold(rfd::FileDialog::new(), |d, (name, exts)| d.add_filter(name, exts))
|
|
.pick_file()
|
|
.map(|p| p.to_string_lossy().to_string())
|
|
})),
|
|
pick_open_multi: Some(Box::new(|| {
|
|
fileio::place_filters()
|
|
.fold(rfd::FileDialog::new().set_title("Place"), |d, (name, exts)| d.add_filter(name, exts))
|
|
.pick_files()
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.map(|p| p.to_string_lossy().to_string())
|
|
.collect()
|
|
})),
|
|
// Saving a document offers every format Save writes (.vectorcraft, .ai, .svg, .svgz).
|
|
pick_save: Some(Box::new(|name: &str| {
|
|
fileio::save_filters(name)
|
|
.into_iter()
|
|
.fold(rfd::FileDialog::new().set_file_name(name), |d, (label, exts)| d.add_filter(label, exts))
|
|
.save_file()
|
|
.map(|p| p.to_string_lossy().to_string())
|
|
})),
|
|
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
|
|
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
|
|
// Menu-bar Paste never sees egui's Paste event, so read the clipboard directly.
|
|
clipboard_read: Some(Box::new(|| arboard::Clipboard::new().ok()?.get_text().ok())),
|
|
// Help → Discord / website / GitHub, the Discord button, About and Home links.
|
|
open_url: Some(Box::new(|url: &str| {
|
|
let _ = webbrowser::open(url);
|
|
})),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
/// The window, Dock, taskbar and app-switcher icon (`assets/app-icon/`, see its README). macOS gets
|
|
/// the version with Apple's transparent margin; elsewhere the full-bleed tile. The app ID matches
|
|
/// `packaging/linux/ai.storyteller.vectorcraft.desktop` so Wayland docks find the launcher icon.
|
|
fn app_icon() -> egui::IconData {
|
|
#[cfg(target_os = "macos")]
|
|
let png: &[u8] = include_bytes!("../../../assets/app-icon/vectorcraft-macos-512.png");
|
|
#[cfg(not(target_os = "macos"))]
|
|
let png: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.vectorcraft.png");
|
|
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
|
|
}
|
|
|
|
/// Windows and Linux: no OS title bar; the app bar is the title bar (`vectorcraft_ui_egui::titlebar`).
|
|
/// macOS keeps its traffic lights over the integrated title strip.
|
|
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
|
|
|
|
fn main() -> eframe::Result {
|
|
let mut control_port: Option<u16> = std::env::var("VECTORCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
|
let mut files = Vec::new();
|
|
let mut args = std::env::args().skip(1);
|
|
while let Some(a) = args.next() {
|
|
match a.as_str() {
|
|
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
|
"--version" => {
|
|
println!("vectorcraft {}", env!("CARGO_PKG_VERSION"));
|
|
return Ok(());
|
|
}
|
|
_ => files.push(a),
|
|
}
|
|
}
|
|
let options = eframe::NativeOptions {
|
|
viewport: egui::ViewportBuilder::default()
|
|
.with_title("VectorCraft")
|
|
.with_inner_size([1440.0, 900.0])
|
|
.with_min_inner_size([800.0, 500.0])
|
|
.with_drag_and_drop(true)
|
|
.with_decorations(!CUSTOM_TITLEBAR)
|
|
.with_fullsize_content_view(true)
|
|
.with_titlebar_shown(false)
|
|
.with_title_shown(false)
|
|
.with_icon(app_icon())
|
|
.with_app_id("ai.storyteller.vectorcraft"),
|
|
..Default::default()
|
|
};
|
|
eframe::run_native(
|
|
"VectorCraft",
|
|
options,
|
|
Box::new(move |cc| {
|
|
let mut app = VectorcraftApp::new(Session::new(), services());
|
|
load_prefs(&mut app);
|
|
// User Defined swatch and graphic style libraries live next to the preferences.
|
|
let swatches = prefs_path().and_then(|p| Some(p.parent()?.join("Swatches").to_string_lossy().to_string()));
|
|
app.session.swatch_libraries.set_user_dir(swatches);
|
|
let styles = prefs_path().and_then(|p| Some(p.parent()?.join("Graphic Styles").to_string_lossy().to_string()));
|
|
app.session.style_libraries.set_user_dir(styles);
|
|
app.integrated_titlebar = cfg!(target_os = "macos");
|
|
app.custom_titlebar = CUSTOM_TITLEBAR;
|
|
if let Some(port) = control_port {
|
|
let rx = control_server::start(port, cc.egui_ctx.clone());
|
|
app = app.with_control(rx);
|
|
}
|
|
for f in files {
|
|
if let Err(e) = vectorcraft_ui_egui::io::open_path(&mut app, &f) {
|
|
eprintln!("vectorcraft: {f}: {e}");
|
|
}
|
|
}
|
|
Ok(Box::new(App(
|
|
app,
|
|
#[cfg(target_os = "macos")]
|
|
None,
|
|
)))
|
|
}),
|
|
)
|
|
}
|