Files
vectorcraft/apps/vectorcraft/src/main.rs
T
bflatasticandClaude Opus 5.5 70e2726321 M4.16: Template safety: Templates folder, "<name> template", New from Template
Save as Template suggests "<name> template.vctemplate" in the Templates folder
(the new templatesFolder preference; default Documents/VectorCraft Templates,
created by the desktop save panel) and never the source path. New from
Template is an engine command (file.newFromTemplate {path} or {name,
dataBase64}) that opens any readable file as a new Untitled-N document; in the
app, bytes from an agent go straight to the engine, otherwise the open dialog
starts in the Templates folder with template types first. .ait and template
files keep opening untitled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:07:15 +07:00

214 lines
8.6 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 clipboard;
mod control_server;
#[cfg(target_os = "macos")]
mod native_menu;
use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::fileio;
use vectorcraft_ui_egui::{FilePick, 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);
}
}
}
/// A native file dialog showing `pick`'s file types, folder and suggested name.
fn file_dialog(pick: &FilePick) -> rfd::FileDialog {
let d = pick.filters.iter().fold(rfd::FileDialog::new(), |d, (name, exts)| d.add_filter(*name, exts));
let d = match &pick.folder {
Some(folder) => d.set_directory(folder),
None => d,
};
if pick.name.is_empty() { d } else { d.set_file_name(&pick.name) }
}
fn services() -> Services {
Services {
pick_open: Some(Box::new(|pick: &FilePick| file_dialog(pick).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()
})),
pick_save: Some(Box::new(|pick: &FilePick| {
// The Templates folder may not exist yet.
if let Some(folder) = &pick.folder {
let _ = std::fs::create_dir_all(folder);
}
file_dialog(pick).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()))),
// Every format Copy offers and Paste reads (menu-bar Paste never sees egui's Paste event).
system_clipboard: Some(clipboard::system_clipboard()),
// 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,
)))
}),
)
}